Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ebd3caecc6 |
@@ -54,6 +54,7 @@ class User extends Authenticatable
|
||||
'member_type',
|
||||
'public_profile_token',
|
||||
'join_date',
|
||||
'leave_date',
|
||||
'birth_date',
|
||||
'birth_place',
|
||||
'onboarding_completed_at',
|
||||
@@ -136,6 +137,7 @@ class User extends Authenticatable
|
||||
'member_type' => 'string',
|
||||
'public_profile_token_expires_at' => 'datetime',
|
||||
'join_date' => 'date',
|
||||
'leave_date' => 'date',
|
||||
'birth_date' => 'date',
|
||||
'birth_place' => 'string',
|
||||
'onboarding_completed_at' => 'datetime',
|
||||
|
||||
@@ -38,6 +38,7 @@ class UserResource extends JsonResource
|
||||
'member_type' => $this->member_type,
|
||||
'public_profile_token' => $this->public_profile_token,
|
||||
'join_date' => $this->join_date,
|
||||
'leave_date' => $this->leave_date,
|
||||
'birth_date' => $this->birth_date,
|
||||
'birth_place' => $this->birth_place,
|
||||
'onboarding_completed_at' => $this->onboarding_completed_at,
|
||||
|
||||
@@ -19,6 +19,7 @@ class MembershipApplicationPassedNotification extends Notification implements Sh
|
||||
public function __construct(
|
||||
public MembershipApplication $application,
|
||||
public Document $resultLetter,
|
||||
public ?string $plainPassword = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -40,6 +41,8 @@ class MembershipApplicationPassedNotification extends Notification implements Sh
|
||||
data: [
|
||||
'application' => $this->application,
|
||||
'applicant' => $applicant,
|
||||
'plainPassword' => $this->plainPassword,
|
||||
'loginUrl' => rtrim(config('user.frontend_url'), '/'),
|
||||
],
|
||||
)->attach(
|
||||
Storage::disk(Document::STORAGE_DISK)->path($this->resultLetter->path),
|
||||
|
||||
@@ -9,6 +9,15 @@ Permohonan keahlian anda (**{{ $application->application_number }}**) **dilulusk
|
||||
|
||||
Surat keputusan rasmi dilampirkan dalam e-mel ini.
|
||||
|
||||
@if ($plainPassword)
|
||||
Akaun portal anda telah didaftarkan. Maklumat log masuk adalah seperti berikut:
|
||||
|
||||
- **E-mel:** {{ $applicant->email }}
|
||||
- **Kata laluan:** {{ $plainPassword }}
|
||||
|
||||
Sila log masuk di [{{ $loginUrl }}]({{ $loginUrl }}) dan tukar kata laluan anda selepas log masuk kali pertama.
|
||||
@endif
|
||||
|
||||
Terima kasih atas minat anda. Untuk sebarang pertanyaan, sila hubungi pejabat koperasi.
|
||||
|
||||
@include('emails.partials.footer')
|
||||
|
||||
@@ -274,15 +274,18 @@ class MembershipApplicationService
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($application, $boardMeetingReference, $boardMeetingDate) {
|
||||
$plainPassword = null;
|
||||
|
||||
if ($application->board_result === BoardResult::Pass->value && ! $application->user_id) {
|
||||
$user = $this->createMemberFromApplication($application);
|
||||
$application->update(['user_id' => $user->id]);
|
||||
$member = $this->createMemberFromApplication($application);
|
||||
$application->update(['user_id' => $member['user']->id]);
|
||||
$plainPassword = $member['plainPassword'];
|
||||
$application->refresh();
|
||||
}
|
||||
|
||||
$resultLetter = $this->storeResultLetter($application, $boardMeetingReference, $boardMeetingDate);
|
||||
|
||||
$this->sendResultNotification($application, $resultLetter);
|
||||
$this->sendResultNotification($application, $resultLetter, $plainPassword);
|
||||
|
||||
$application->update([
|
||||
'status' => ApplicationStatus::Completed,
|
||||
@@ -438,7 +441,7 @@ class MembershipApplicationService
|
||||
'application' => $application,
|
||||
'applicant' => $applicant,
|
||||
'boardMeetingReference' => $boardMeetingReference,
|
||||
'boardMeetingDate' => Carbon::parse($boardMeetingDate)->translatedFormat('d M Y'),
|
||||
'boardMeetingDate' => Carbon::parse($boardMeetingDate)->translatedFormat('d F Y'),
|
||||
'isPassed' => $isPassed,
|
||||
'letterSubject' => $isPassed
|
||||
? 'KELULUSAN PERMOHONAN MENJADI ANGGOTA KOPERASI PERMODALAN KELANTAN BERHAD (KoPKB)'
|
||||
@@ -447,7 +450,10 @@ class MembershipApplicationService
|
||||
];
|
||||
}
|
||||
|
||||
protected function createMemberFromApplication(MembershipApplication $application): User
|
||||
/**
|
||||
* @return array{user: User, plainPassword: string}
|
||||
*/
|
||||
protected function createMemberFromApplication(MembershipApplication $application): array
|
||||
{
|
||||
$application->loadMissing(['applicant', 'heirs']);
|
||||
$applicant = $application->applicant;
|
||||
@@ -458,10 +464,12 @@ class MembershipApplicationService
|
||||
]);
|
||||
}
|
||||
|
||||
$plainPassword = Str::password(16);
|
||||
|
||||
$user = User::create([
|
||||
'name' => $applicant->name,
|
||||
'email' => $applicant->email,
|
||||
'password' => Hash::make(Str::password(16)),
|
||||
'password' => Hash::make($plainPassword),
|
||||
'ic_number' => $applicant->ic_number,
|
||||
'phone_number' => $applicant->phone_number,
|
||||
'position' => $applicant->current_position,
|
||||
@@ -507,7 +515,10 @@ class MembershipApplicationService
|
||||
|
||||
app(EmailVerificationOtpService::class)->send($user);
|
||||
|
||||
return $user;
|
||||
return [
|
||||
'user' => $user,
|
||||
'plainPassword' => $plainPassword,
|
||||
];
|
||||
}
|
||||
|
||||
public function lookupMemberByIcNumber(string $icNumber): ?User
|
||||
@@ -532,12 +543,15 @@ class MembershipApplicationService
|
||||
->notify(new MembershipApplicationSubmittedNotification($application));
|
||||
}
|
||||
|
||||
protected function sendResultNotification(MembershipApplication $application, Document $resultLetter): void
|
||||
{
|
||||
protected function sendResultNotification(
|
||||
MembershipApplication $application,
|
||||
Document $resultLetter,
|
||||
?string $plainPassword = null,
|
||||
): void {
|
||||
$application->loadMissing('applicant');
|
||||
|
||||
$notification = $application->board_result === BoardResult::Pass->value
|
||||
? new MembershipApplicationPassedNotification($application, $resultLetter)
|
||||
? new MembershipApplicationPassedNotification($application, $resultLetter, $plainPassword)
|
||||
: new MembershipApplicationFailedNotification($application, $resultLetter);
|
||||
|
||||
Notification::route('mail', $application->applicant->email)->notify($notification);
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->date('leave_date')->nullable()->after('join_date');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('leave_date');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -44,7 +44,7 @@ class UserController extends BaseCrudController
|
||||
$validated['uuid'] = Str::uuid();
|
||||
$validated['password'] = Hash::make('suteraselamanya');
|
||||
|
||||
return $validated;
|
||||
return $this->applyLeaveDateForStatus($validated);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,6 +58,25 @@ class UserController extends BaseCrudController
|
||||
unset($validated['email']);
|
||||
}
|
||||
|
||||
return $this->applyLeaveDateForStatus($validated, $user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set or clear leave_date based on membership status transitions.
|
||||
*/
|
||||
protected function applyLeaveDateForStatus(array $validated, ?User $user = null): array
|
||||
{
|
||||
$newStatus = $validated['status'] ?? $user?->status;
|
||||
$currentStatus = $user?->status;
|
||||
|
||||
if ($newStatus === 'inactive') {
|
||||
if ($currentStatus !== 'inactive') {
|
||||
$validated['leave_date'] = $validated['leave_date'] ?? now()->toDateString();
|
||||
}
|
||||
} elseif (in_array($newStatus, ['active', 'pending'], true)) {
|
||||
$validated['leave_date'] = null;
|
||||
}
|
||||
|
||||
return $validated;
|
||||
}
|
||||
|
||||
@@ -171,13 +190,27 @@ class UserController extends BaseCrudController
|
||||
$this->authorize('viewAny', $this->modelClass);
|
||||
|
||||
try {
|
||||
$request->validate([
|
||||
'join_date_from' => 'nullable|date',
|
||||
'join_date_to' => 'nullable|date',
|
||||
'leave_date_from' => 'nullable|date',
|
||||
'leave_date_to' => 'nullable|date',
|
||||
]);
|
||||
|
||||
$perPage = min((int) $request->get('per_page', 10), 500);
|
||||
$dateFilters = array_filter([
|
||||
'join_date_from' => $request->get('join_date_from'),
|
||||
'join_date_to' => $request->get('join_date_to'),
|
||||
'leave_date_from' => $request->get('leave_date_from'),
|
||||
'leave_date_to' => $request->get('leave_date_to'),
|
||||
]);
|
||||
$items = $this->userService->getPaginatedList(
|
||||
$perPage,
|
||||
$request->get('search', ''),
|
||||
$request->get('status', ''),
|
||||
$request->get('sort_by', 'id'),
|
||||
$request->get('sort_order', 'asc')
|
||||
$request->get('sort_order', 'asc'),
|
||||
$dateFilters
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
|
||||
@@ -44,6 +44,7 @@ class UserRequest extends FormRequest
|
||||
'member_number' => 'required|integer',
|
||||
'member_type' => 'required|string|max:255',
|
||||
'join_date' => 'nullable|date',
|
||||
'leave_date' => 'nullable|date',
|
||||
'birth_date' => 'nullable|date',
|
||||
'birth_place' => 'nullable|string|max:255',
|
||||
];
|
||||
@@ -74,6 +75,7 @@ class UserRequest extends FormRequest
|
||||
'member_number' => 'required|integer',
|
||||
'member_type' => 'required|string|max:255',
|
||||
'join_date' => 'nullable|date',
|
||||
'leave_date' => 'nullable|date',
|
||||
'birth_date' => 'nullable|date',
|
||||
'birth_place' => 'nullable|string|max:255',
|
||||
];
|
||||
@@ -100,6 +102,7 @@ class UserRequest extends FormRequest
|
||||
'member_number' => 'required|integer',
|
||||
'member_type' => 'required|string|max:255',
|
||||
'join_date' => 'required|date',
|
||||
'leave_date' => 'nullable|date',
|
||||
'birth_date' => 'required|date',
|
||||
'birth_place' => 'required|string|max:255',
|
||||
];
|
||||
@@ -136,6 +139,7 @@ class UserRequest extends FormRequest
|
||||
'member_type.max' => 'Jenis anggota tidak boleh melebihi 255 aksara.',
|
||||
'join_date.required' => 'Tarikh join diperlukan.',
|
||||
'join_date.date' => 'Tarikh join tidak sah.',
|
||||
'leave_date.date' => 'Tarikh berhenti tidak sah.',
|
||||
'birth_date.required' => 'Tarikh lahir diperlukan.',
|
||||
'birth_date.date' => 'Tarikh lahir tidak sah.',
|
||||
'birth_place.required' => 'Tempat lahir diperlukan.',
|
||||
|
||||
@@ -20,7 +20,8 @@ interface UserRepositoryInterface
|
||||
string $search = '',
|
||||
string $status = '',
|
||||
string $sortBy = 'name',
|
||||
string $sortOrder = 'asc'
|
||||
string $sortOrder = 'asc',
|
||||
array $dateFilters = []
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
@@ -26,6 +26,25 @@ class UserRepository implements UserRepositoryInterface
|
||||
});
|
||||
}
|
||||
|
||||
private function applyDateRangeFilters($query, array $dateFilters): void
|
||||
{
|
||||
if (! empty($dateFilters['join_date_from'])) {
|
||||
$query->whereDate('join_date', '>=', $dateFilters['join_date_from']);
|
||||
}
|
||||
|
||||
if (! empty($dateFilters['join_date_to'])) {
|
||||
$query->whereDate('join_date', '<=', $dateFilters['join_date_to']);
|
||||
}
|
||||
|
||||
if (! empty($dateFilters['leave_date_from'])) {
|
||||
$query->whereDate('leave_date', '>=', $dateFilters['leave_date_from']);
|
||||
}
|
||||
|
||||
if (! empty($dateFilters['leave_date_to'])) {
|
||||
$query->whereDate('leave_date', '<=', $dateFilters['leave_date_to']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all Users with pagination and search
|
||||
*/
|
||||
@@ -46,7 +65,8 @@ class UserRepository implements UserRepositoryInterface
|
||||
string $search = '',
|
||||
string $status = '',
|
||||
string $sortBy = 'name',
|
||||
string $sortOrder = 'asc'
|
||||
string $sortOrder = 'asc',
|
||||
array $dateFilters = []
|
||||
) {
|
||||
$allowedSortColumns = [
|
||||
'id',
|
||||
@@ -55,6 +75,8 @@ class UserRepository implements UserRepositoryInterface
|
||||
'position',
|
||||
'status',
|
||||
'member_number',
|
||||
'join_date',
|
||||
'leave_date',
|
||||
'created_at',
|
||||
'deleted_at',
|
||||
];
|
||||
@@ -73,6 +95,8 @@ class UserRepository implements UserRepositoryInterface
|
||||
$query->where('status', $status);
|
||||
}
|
||||
|
||||
$this->applyDateRangeFilters($query, $dateFilters);
|
||||
|
||||
return $query->paginate($perPage);
|
||||
}
|
||||
|
||||
@@ -93,6 +117,8 @@ class UserRepository implements UserRepositoryInterface
|
||||
'position',
|
||||
'status',
|
||||
'member_number',
|
||||
'join_date',
|
||||
'leave_date',
|
||||
'created_at',
|
||||
'deleted_at',
|
||||
];
|
||||
|
||||
@@ -21,14 +21,16 @@ class UserService
|
||||
string $search,
|
||||
string $status,
|
||||
string $sortBy,
|
||||
string $sortOrder
|
||||
string $sortOrder,
|
||||
array $dateFilters = []
|
||||
): LengthAwarePaginator {
|
||||
return $this->repository->getAllWithRelationsPaginated(
|
||||
$perPage,
|
||||
$search,
|
||||
$status,
|
||||
$sortBy,
|
||||
$sortOrder
|
||||
$sortOrder,
|
||||
$dateFilters
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ class UserListResource extends JsonResource
|
||||
'member_number' => $this->member_number,
|
||||
'member_type' => $this->member_type,
|
||||
'join_date' => $this->join_date,
|
||||
'leave_date' => $this->leave_date,
|
||||
'birth_date' => $this->birth_date,
|
||||
'birth_place' => $this->birth_place,
|
||||
'created_at' => $this->created_at,
|
||||
|
||||
@@ -100,6 +100,8 @@ const props = withDefaults(defineProps<{
|
||||
currentSort?: SortConfig[];
|
||||
exportable?: boolean;
|
||||
exportFileName?: string;
|
||||
/** Title shown at the top of exported PDF documents. */
|
||||
exportPdfTitle?: string;
|
||||
/** When true, table body scrolls inside a max-height box instead of growing the page. */
|
||||
scrollable?: boolean;
|
||||
/** Max height of the scrollable table area (CSS value). */
|
||||
@@ -622,12 +624,22 @@ const exportToPDF = () => {
|
||||
else if (colCount > 8) fontSize = 7;
|
||||
const cellPadding = colCount > 8 ? 4 : 8;
|
||||
|
||||
let tableStartY = 20;
|
||||
if (props.exportPdfTitle?.trim()) {
|
||||
doc.setFontSize(14);
|
||||
doc.text(props.exportPdfTitle.trim(), doc.internal.pageSize.getWidth() / 2, 28, {
|
||||
align: 'center',
|
||||
});
|
||||
tableStartY = 48;
|
||||
}
|
||||
|
||||
const tableOptions: Parameters<typeof autoTable>[1] = {
|
||||
head: [headers],
|
||||
body: data,
|
||||
startY: tableStartY,
|
||||
styles: { fontSize, cellPadding },
|
||||
headStyles: { fillColor: [66, 139, 202] },
|
||||
margin: { top: 20 },
|
||||
margin: { top: tableStartY },
|
||||
};
|
||||
|
||||
// For wide tables: let table use natural column widths and split across pages
|
||||
|
||||
@@ -59,7 +59,7 @@ const availableModules = computed(() => [
|
||||
visible: hasPermission('lihat aktiviti'),
|
||||
},
|
||||
{
|
||||
title: 'Senarai Pengguna',
|
||||
title: 'Senarai Daftar Anggota',
|
||||
description: 'Pantau dan urus akaun pengguna sistem.',
|
||||
route: 'list-users',
|
||||
icon: 'Users' as Icon,
|
||||
|
||||
@@ -22,6 +22,7 @@ import { AlertRoot, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Lucide } from '@/components/ui/lucide'
|
||||
import { getApiErrorMessage, getApiValidationErrors } from '@/core/utils/getApiErrorMessage'
|
||||
import { submitMembershipApplication, lookupMemberByIcNumber } from '../services/membership-application.service'
|
||||
import { sanitizeIcNumberInput } from '../utils/membership-application-form.utils'
|
||||
import type {
|
||||
MembershipApplicationFormState,
|
||||
MembershipApplicationHeirForm,
|
||||
@@ -37,7 +38,7 @@ const MIN_FEE_MONTHLY_CONTRIBUTION = 30
|
||||
|
||||
const steps = [
|
||||
{ id: 1, label: 'Maklumat Peribadi' },
|
||||
{ id: 2, label: 'Hubungan & Alamat' },
|
||||
{ id: 2, label: 'No. Telefon & Alamat' },
|
||||
{ id: 3, label: 'Maklumat Pekerjaan' },
|
||||
{ id: 4, label: 'Maklumat Penama' },
|
||||
{ id: 5, label: 'Dokumen & Hantar' },
|
||||
@@ -221,9 +222,22 @@ function clearReference(role: 'proposer' | 'supporter') {
|
||||
}
|
||||
|
||||
function handleReferenceIcInput(role: 'proposer' | 'supporter') {
|
||||
form.references[role].ic_number = sanitizeIcNumberInput(form.references[role].ic_number)
|
||||
clearReference(role)
|
||||
}
|
||||
|
||||
function handleApplicantIcInput() {
|
||||
form.applicant.ic_number = sanitizeIcNumberInput(form.applicant.ic_number)
|
||||
delete fieldErrors['applicant.ic_number']
|
||||
}
|
||||
|
||||
function handleHeirIcInput(index: number) {
|
||||
const heir = form.heirs[index]
|
||||
if (!heir) return
|
||||
heir.ic_number = sanitizeIcNumberInput(heir.ic_number)
|
||||
delete fieldErrors[`heirs.${index}.ic_number`]
|
||||
}
|
||||
|
||||
async function lookupReference(role: 'proposer' | 'supporter') {
|
||||
const reference = form.references[role]
|
||||
const icNumber = reference.ic_number.trim()
|
||||
@@ -618,7 +632,8 @@ function stepLabelClass(stepId: number) {
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel for="ic_number">No. Kad Pengenalan</FieldLabel>
|
||||
<Input id="ic_number" v-model="form.applicant.ic_number" type="text" />
|
||||
<Input id="ic_number" v-model="form.applicant.ic_number" type="text" inputmode="numeric" maxlength="15"
|
||||
placeholder="Contoh: 900101011234" @input="handleApplicantIcInput" />
|
||||
<FieldError v-if="fieldErrors['applicant.ic_number']">{{ fieldErrors['applicant.ic_number'] }}
|
||||
</FieldError>
|
||||
</Field>
|
||||
@@ -800,7 +815,8 @@ function stepLabelClass(stepId: number) {
|
||||
</Field>
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel :for="`heir-ic-${index}`">No. Kad Pengenalan</FieldLabel>
|
||||
<Input :id="`heir-ic-${index}`" v-model="heir.ic_number" type="text" />
|
||||
<Input :id="`heir-ic-${index}`" v-model="heir.ic_number" type="text" inputmode="numeric"
|
||||
maxlength="15" placeholder="Contoh: 900101011234" @input="handleHeirIcInput(index)" />
|
||||
<FieldError v-if="fieldErrors[`heirs.${index}.ic_number`]">
|
||||
{{ fieldErrors[`heirs.${index}.ic_number`] }}
|
||||
</FieldError>
|
||||
@@ -857,8 +873,9 @@ function stepLabelClass(stepId: number) {
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel for="proposer_ic_number">No. KP Pencadang</FieldLabel>
|
||||
<Input id="proposer_ic_number" v-model="form.references.proposer.ic_number" type="text"
|
||||
placeholder="Contoh: 900101011234" :disabled="referenceLookupLoading.proposer"
|
||||
@input="handleReferenceIcInput('proposer')" @blur="lookupReference('proposer')" />
|
||||
inputmode="numeric" maxlength="12" placeholder="Contoh: 900101011234"
|
||||
:disabled="referenceLookupLoading.proposer" @input="handleReferenceIcInput('proposer')"
|
||||
@blur="lookupReference('proposer')" />
|
||||
<p v-if="form.references.proposer.name" class="mt-1 text-sm text-success">
|
||||
{{ form.references.proposer.name }}
|
||||
</p>
|
||||
@@ -870,8 +887,9 @@ function stepLabelClass(stepId: number) {
|
||||
<Field class="col-span-12 sm:col-span-6">
|
||||
<FieldLabel for="supporter_ic_number">No. KP Penyokong</FieldLabel>
|
||||
<Input id="supporter_ic_number" v-model="form.references.supporter.ic_number" type="text"
|
||||
placeholder="Contoh: 850505055678" :disabled="referenceLookupLoading.supporter"
|
||||
@input="handleReferenceIcInput('supporter')" @blur="lookupReference('supporter')" />
|
||||
inputmode="numeric" maxlength="12" placeholder="Contoh: 850505055678"
|
||||
:disabled="referenceLookupLoading.supporter" @input="handleReferenceIcInput('supporter')"
|
||||
@blur="lookupReference('supporter')" />
|
||||
<p v-if="form.references.supporter.name" class="mt-1 text-sm text-success">
|
||||
{{ form.references.supporter.name }}
|
||||
</p>
|
||||
@@ -907,7 +925,7 @@ function stepLabelClass(stepId: number) {
|
||||
<Input id="employer_letter" type="file" accept=".pdf,.jpg,.jpeg,.png"
|
||||
@change="handleFileChange('employer_letter', $event)" />
|
||||
<FieldError v-if="fieldErrors['documents.employer_letter']">{{ fieldErrors['documents.employer_letter']
|
||||
}}</FieldError>
|
||||
}}</FieldError>
|
||||
</Field>
|
||||
|
||||
<div class="col-span-12 mt-2 rounded-lg border border-foreground/10 bg-foreground/5 p-4">
|
||||
|
||||
@@ -65,6 +65,8 @@ const DOCUMENT_TYPE_LABELS: Record<string, string> = {
|
||||
[RESULT_LETTER_DOCUMENT_TYPE]: 'Surat Keputusan',
|
||||
}
|
||||
|
||||
const BOARD_MEETING_REFERENCE_PREFIX = 'Mesyuarat Lembaga Bil.'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { hasPermission } = usePermissions()
|
||||
@@ -430,8 +432,10 @@ async function confirmPendingAction() {
|
||||
})
|
||||
boardRemarks.value = ''
|
||||
} else {
|
||||
const boardMeetingReferenceValue = `${BOARD_MEETING_REFERENCE_PREFIX} ${boardMeetingReference.value.trim()}`
|
||||
|
||||
response = await completeMembershipApplication(applicationId.value, {
|
||||
board_meeting_reference: boardMeetingReference.value.trim(),
|
||||
board_meeting_reference: boardMeetingReferenceValue,
|
||||
board_meeting_date: boardMeetingDate.value,
|
||||
})
|
||||
}
|
||||
@@ -1013,9 +1017,15 @@ onUnmounted(() => {
|
||||
<div class="mt-2 opacity-70">{{ confirmDialogDescription }}</div>
|
||||
<Field v-if="pendingAction?.type === 'complete'" class="mt-5 text-left">
|
||||
<FieldLabel for="detail-board-meeting-reference">Rujukan Mesyuarat Lembaga</FieldLabel>
|
||||
<Input id="detail-board-meeting-reference" v-model="boardMeetingReference" type="text"
|
||||
placeholder="Contoh: Mesyuarat Lembaga Bil. 3/2026" :disabled="completeSubmitting"
|
||||
@input="boardMeetingReferenceError = null" />
|
||||
<div class="flex">
|
||||
<span
|
||||
class="inline-flex items-center rounded-l-lg border border-r-0 border-foreground/10 bg-foreground/5 px-3 text-sm opacity-80">
|
||||
{{ BOARD_MEETING_REFERENCE_PREFIX }}
|
||||
</span>
|
||||
<Input id="detail-board-meeting-reference" v-model="boardMeetingReference" type="text"
|
||||
class="rounded-l-none" placeholder="Contoh: 3/2026" :disabled="completeSubmitting"
|
||||
@input="boardMeetingReferenceError = null" />
|
||||
</div>
|
||||
<FieldError v-if="boardMeetingReferenceError">{{ boardMeetingReferenceError }}</FieldError>
|
||||
</Field>
|
||||
<Field v-if="pendingAction?.type === 'complete'" class="mt-4 text-left">
|
||||
|
||||
@@ -61,6 +61,10 @@ export type ApplicantDocumentUploadType = (typeof APPLICANT_DOCUMENT_UPLOAD_TYPE
|
||||
export type AdminDocumentUploadType = (typeof ADMIN_DOCUMENT_UPLOAD_TYPES)[number]
|
||||
export type DocumentUploadType = ApplicantDocumentUploadType | AdminDocumentUploadType
|
||||
|
||||
export function sanitizeIcNumberInput(value: string): string {
|
||||
return value.replace(/\D/g, '')
|
||||
}
|
||||
|
||||
export function createSelectCollection(options: SelectOption[]) {
|
||||
return select.collection({
|
||||
items: options,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import debounce from 'lodash/debounce'
|
||||
import type { SortConfig } from '@/components/ui/usage/DataTable.vue'
|
||||
import { useApiPagination } from '@/composables/useApiPagination'
|
||||
@@ -12,12 +12,29 @@ export function useUserList() {
|
||||
const error = ref<string | null>(null)
|
||||
const search = ref('')
|
||||
const statusFilter = ref('')
|
||||
const joinDateFrom = ref('')
|
||||
const joinDateTo = ref('')
|
||||
const leaveDateFrom = ref('')
|
||||
const leaveDateTo = ref('')
|
||||
const sortBy = ref<SortConfig[]>([{ key: 'name', order: 'asc' }])
|
||||
const page = ref(1)
|
||||
const itemsPerPage = ref(10)
|
||||
|
||||
const { pagination, applyPagination } = useApiPagination({ per_page: 10 })
|
||||
|
||||
const hasJoinDateFilters = computed(() => Boolean(joinDateFrom.value || joinDateTo.value))
|
||||
const hasLeaveDateFilters = computed(() => Boolean(leaveDateFrom.value || leaveDateTo.value))
|
||||
|
||||
function clearJoinDateFilters() {
|
||||
joinDateFrom.value = ''
|
||||
joinDateTo.value = ''
|
||||
}
|
||||
|
||||
function clearLeaveDateFilters() {
|
||||
leaveDateFrom.value = ''
|
||||
leaveDateTo.value = ''
|
||||
}
|
||||
|
||||
async function fetchUsers(requestPage = page.value) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
@@ -31,11 +48,17 @@ export function useUserList() {
|
||||
sort_order: activeSort?.order ?? 'asc',
|
||||
search: search.value.trim() || undefined,
|
||||
status: statusFilter.value.trim() || undefined,
|
||||
join_date_from: joinDateFrom.value || undefined,
|
||||
join_date_to: joinDateTo.value || undefined,
|
||||
leave_date_from: leaveDateFrom.value || undefined,
|
||||
leave_date_to: leaveDateTo.value || undefined,
|
||||
})
|
||||
|
||||
users.value = data.data
|
||||
applyPagination(data.pagination)
|
||||
page.value = data.pagination.current_page
|
||||
} catch (err) {
|
||||
error.value = getApiErrorMessage(err, 'Gagal memuatkan senarai pengguna.')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -58,6 +81,10 @@ export function useUserList() {
|
||||
fetchUsers(1)
|
||||
})
|
||||
|
||||
watch([joinDateFrom, joinDateTo, leaveDateFrom, leaveDateTo], () => {
|
||||
fetchUsers(1)
|
||||
})
|
||||
|
||||
watch(page, (nextPage, previousPage) => {
|
||||
if (nextPage !== previousPage) {
|
||||
fetchUsers(nextPage)
|
||||
@@ -80,6 +107,14 @@ export function useUserList() {
|
||||
error,
|
||||
search,
|
||||
statusFilter,
|
||||
joinDateFrom,
|
||||
joinDateTo,
|
||||
leaveDateFrom,
|
||||
leaveDateTo,
|
||||
hasJoinDateFilters,
|
||||
hasLeaveDateFilters,
|
||||
clearJoinDateFilters,
|
||||
clearLeaveDateFilters,
|
||||
sortBy,
|
||||
page,
|
||||
itemsPerPage,
|
||||
|
||||
@@ -4,7 +4,7 @@ export const userMenu: Menu[] = [
|
||||
{
|
||||
icon: 'Users',
|
||||
route_name: 'list-users',
|
||||
title: 'Senarai Pengguna',
|
||||
title: 'Senarai Daftar Anggota',
|
||||
permission: 'lihat pengguna',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import * as select from '@zag-js/select'
|
||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { Box } from '@/components/ui/box'
|
||||
@@ -115,6 +116,7 @@ const form = reactive({
|
||||
phone_number: '',
|
||||
member_number: '',
|
||||
join_date: '',
|
||||
leave_date: '',
|
||||
birth_date: '',
|
||||
birth_place: '',
|
||||
})
|
||||
@@ -132,6 +134,7 @@ function syncFormFromUser(user: Awaited<ReturnType<typeof getUser>>['data']) {
|
||||
form.phone_number = user.phone_number ?? ''
|
||||
form.member_number = user.member_number != null ? String(user.member_number) : ''
|
||||
form.join_date = toDateInputValue(user.join_date)
|
||||
form.leave_date = toDateInputValue(user.leave_date)
|
||||
form.birth_date = toDateInputValue(user.birth_date)
|
||||
form.birth_place = user.birth_place ?? ''
|
||||
statusValue.value = apiValueToLabel(STATUS_OPTIONS, user.status ?? 'active')
|
||||
@@ -159,6 +162,17 @@ async function fetchUser() {
|
||||
}
|
||||
}
|
||||
|
||||
const selectedStatus = computed(
|
||||
() => labelToApiValue(STATUS_OPTIONS, statusValue.value[0]) ?? 'active',
|
||||
)
|
||||
const isInactiveStatus = computed(() => selectedStatus.value === 'inactive')
|
||||
|
||||
watch(selectedStatus, (newStatus, oldStatus) => {
|
||||
if (newStatus === 'inactive' && oldStatus !== 'inactive' && !form.leave_date) {
|
||||
form.leave_date = dayjs().format('YYYY-MM-DD')
|
||||
}
|
||||
})
|
||||
|
||||
async function handleSubmit() {
|
||||
saving.value = true
|
||||
error.value = null
|
||||
@@ -176,6 +190,7 @@ async function handleSubmit() {
|
||||
member_number: form.member_number ? Number(form.member_number) : null,
|
||||
member_type: labelToApiValue(MEMBER_TYPE_OPTIONS, memberTypeValue.value[0]),
|
||||
join_date: form.join_date || null,
|
||||
leave_date: isInactiveStatus.value ? form.leave_date || null : null,
|
||||
birth_date: form.birth_date || null,
|
||||
birth_place: form.birth_place.trim() || null,
|
||||
})
|
||||
@@ -351,6 +366,17 @@ onMounted(() => {
|
||||
<Input id="user-join-date" v-model="form.join_date" class="w-full" type="date" :disabled="formDisabled" />
|
||||
</Field>
|
||||
|
||||
<Field v-if="isInactiveStatus">
|
||||
<FieldLabel for="user-leave-date">Tarikh Berhenti Menjadi Anggota</FieldLabel>
|
||||
<Input
|
||||
id="user-leave-date"
|
||||
v-model="form.leave_date"
|
||||
class="w-full"
|
||||
type="date"
|
||||
:disabled="formDisabled"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="user-birth-date">Tarikh Lahir</FieldLabel>
|
||||
<Input id="user-birth-date" v-model="form.birth_date" class="w-full" type="date" :disabled="formDisabled" />
|
||||
|
||||
@@ -36,9 +36,9 @@ type StatusFilterChip = {
|
||||
|
||||
const STATUS_FILTER_CHIPS: StatusFilterChip[] = [
|
||||
{ label: 'Semua', value: '', variant: 'ghost' },
|
||||
{ label: 'Active', value: 'active', variant: 'success' },
|
||||
{ label: 'Inactive', value: 'inactive', variant: 'danger' },
|
||||
{ label: 'Pending', value: 'pending', variant: 'pending' },
|
||||
{ label: 'Aktif', value: 'active', variant: 'success' },
|
||||
{ label: 'Tidak Aktif', value: 'inactive', variant: 'danger' },
|
||||
{ label: 'Menunggu', value: 'pending', variant: 'pending' },
|
||||
]
|
||||
|
||||
const router = useRouter()
|
||||
@@ -97,6 +97,11 @@ function formatDeletedAt(value: string | null | undefined): string {
|
||||
return dayjs(value).format('DD MMM YYYY, HH:mm')
|
||||
}
|
||||
|
||||
function formatUserDate(value: string | null | undefined): string {
|
||||
if (!value) return '-'
|
||||
return dayjs(value).format('DD MMM YYYY')
|
||||
}
|
||||
|
||||
function openDeleteConfirmation(user: UserListItem) {
|
||||
userToDelete.value = user
|
||||
deleteError.value = null
|
||||
@@ -217,9 +222,20 @@ async function confirmRestore() {
|
||||
const headers: TableHeader[] = [
|
||||
{ title: 'Bil.', key: '#', sortable: false },
|
||||
{ title: 'Name', key: 'name', sortable: true },
|
||||
{ title: 'Emel', key: 'email', sortable: true },
|
||||
{ title: 'Jawatan', key: 'position', sortable: true },
|
||||
{ title: 'No. Anggota', key: 'member_number', sortable: true, align: 'center' },
|
||||
{
|
||||
title: 'Tarikh Menjadi Anggota',
|
||||
key: 'join_date',
|
||||
sortable: true,
|
||||
exportValue: (item) => formatUserDate(item.join_date),
|
||||
},
|
||||
{
|
||||
title: 'Tarikh Berhenti',
|
||||
key: 'leave_date',
|
||||
sortable: true,
|
||||
exportValue: (item) => formatUserDate(item.leave_date),
|
||||
},
|
||||
{
|
||||
title: 'Peranan',
|
||||
key: 'roles',
|
||||
@@ -253,6 +269,14 @@ const {
|
||||
error,
|
||||
search,
|
||||
statusFilter,
|
||||
joinDateFrom,
|
||||
joinDateTo,
|
||||
leaveDateFrom,
|
||||
leaveDateTo,
|
||||
hasJoinDateFilters,
|
||||
hasLeaveDateFilters,
|
||||
clearJoinDateFilters,
|
||||
clearLeaveDateFilters,
|
||||
sortBy,
|
||||
page,
|
||||
itemsPerPage,
|
||||
@@ -306,8 +330,8 @@ onMounted(() => {
|
||||
<template>
|
||||
<div class="w-full space-y-6">
|
||||
<div>
|
||||
<h2 class="text-lg font-medium">Senarai Pengguna</h2>
|
||||
<p class="mt-1 text-sm opacity-70">Urus dan semak pengguna koperasi.</p>
|
||||
<h2 class="text-lg font-medium">Senarai Daftar Anggota</h2>
|
||||
<p class="mt-1 text-sm opacity-70">Urus dan semak anggota koperasi.</p>
|
||||
</div>
|
||||
<AlertRoot v-if="error" class="mt-6" variant="danger">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
@@ -315,8 +339,8 @@ onMounted(() => {
|
||||
</AlertRoot>
|
||||
|
||||
<DataTable :headers="headers" :items="users" :loading="loading" :pagination="pagination" :current-sort="sortBy"
|
||||
show-pagination exportable export-file-name="users" v-model:page="page" v-model:items-per-page="itemsPerPage"
|
||||
@update:sort-by="handleSortUpdate">
|
||||
show-pagination exportable export-file-name="users" export-pdf-title="Senarai Daftar Anggota" v-model:page="page"
|
||||
v-model:items-per-page="itemsPerPage" @update:sort-by="handleSortUpdate">
|
||||
<template #toolbar>
|
||||
<div class="flex w-full flex-col gap-3">
|
||||
<div class="flex w-full flex-wrap items-center gap-3">
|
||||
@@ -341,6 +365,50 @@ onMounted(() => {
|
||||
{{ chip.label }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="rounded-lg border border-foreground/10 p-3">
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<div class="flex min-w-[16rem] flex-1 flex-col gap-1.5">
|
||||
<span class="text-sm font-medium">Tarikh Menjadi Anggota</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input v-model="joinDateFrom" type="date" aria-label="Tarikh menjadi anggota dari" />
|
||||
<span class="text-sm opacity-50">–</span>
|
||||
<Input v-model="joinDateTo" type="date" aria-label="Tarikh menjadi anggota hingga" />
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
v-if="hasJoinDateFilters"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
look="outline"
|
||||
@click="clearJoinDateFilters"
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-foreground/10 p-3">
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<div class="flex min-w-[16rem] flex-1 flex-col gap-1.5">
|
||||
<span class="text-sm font-medium">Tarikh Berhenti</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input v-model="leaveDateFrom" type="date" aria-label="Tarikh berhenti dari" />
|
||||
<span class="text-sm opacity-50">–</span>
|
||||
<Input v-model="leaveDateTo" type="date" aria-label="Tarikh berhenti hingga" />
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
v-if="hasLeaveDateFilters"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
look="outline"
|
||||
@click="clearLeaveDateFilters"
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -369,6 +437,14 @@ onMounted(() => {
|
||||
<span class="text-center">{{ item.member_number }}</span>
|
||||
</template>
|
||||
|
||||
<template #item.join_date="{ item }">
|
||||
{{ formatUserDate(item.join_date) }}
|
||||
</template>
|
||||
|
||||
<template #item.leave_date="{ item }">
|
||||
{{ formatUserDate(item.leave_date) }}
|
||||
</template>
|
||||
|
||||
<!-- centre align status -->
|
||||
<template #item.status="{ item }">
|
||||
<Badge :variant="statusBadgeVariant(item.status)" class="capitalize text-center">
|
||||
|
||||
@@ -107,6 +107,10 @@ function formatAddressLine(address: Address) {
|
||||
<FieldLabel for="view-user-join-date">Tarikh Sertai</FieldLabel>
|
||||
<Input id="view-user-join-date" :model-value="formatDate(user.join_date)" type="text" disabled />
|
||||
</Field>
|
||||
<Field v-if="user.status === 'inactive' || user.leave_date">
|
||||
<FieldLabel for="view-user-leave-date">Tarikh Berhenti Menjadi Anggota</FieldLabel>
|
||||
<Input id="view-user-leave-date" :model-value="formatDate(user.leave_date)" type="text" disabled />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel for="view-user-birth-date">Tarikh Lahir</FieldLabel>
|
||||
<Input id="view-user-birth-date" :model-value="formatDate(user.birth_date)" type="text" disabled />
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface UserListItem {
|
||||
deleted_at?: string | null
|
||||
roles: UserRole[]
|
||||
member_number?: number | null
|
||||
join_date?: string | null
|
||||
leave_date?: string | null
|
||||
}
|
||||
|
||||
export interface UserDetail {
|
||||
@@ -42,6 +44,7 @@ export interface UserDetail {
|
||||
member_number: number | null
|
||||
member_type: string | null
|
||||
join_date: string | null
|
||||
leave_date: string | null
|
||||
birth_date: string | null
|
||||
birth_place: string | null
|
||||
roles: UserRole[]
|
||||
@@ -62,6 +65,7 @@ export interface UpdateUserPayload {
|
||||
member_number?: number | null
|
||||
member_type?: string | null
|
||||
join_date?: string | null
|
||||
leave_date?: string | null
|
||||
birth_date?: string | null
|
||||
birth_place?: string | null
|
||||
}
|
||||
@@ -89,6 +93,10 @@ export interface ListUsersParams {
|
||||
sort_order: string
|
||||
search?: string
|
||||
status?: string
|
||||
join_date_from?: string
|
||||
join_date_to?: string
|
||||
leave_date_from?: string
|
||||
leave_date_to?: string
|
||||
}
|
||||
|
||||
export interface ListDeletedUsersParams {
|
||||
|
||||
Reference in New Issue
Block a user