diff --git a/be/Modules/User/Http/Controllers/UserController.php b/be/Modules/User/Http/Controllers/UserController.php index 979a2ef..366c9cb 100644 --- a/be/Modules/User/Http/Controllers/UserController.php +++ b/be/Modules/User/Http/Controllers/UserController.php @@ -269,6 +269,43 @@ class UserController extends BaseCrudController } } + public function stats(Request $request): JsonResponse + { + $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', + ]); + + $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'), + ]); + + $stats = $this->userService->getListStats( + $request->get('search', ''), + $request->get('status', ''), + $dateFilters + ); + + return response()->json([ + 'success' => true, + 'data' => $stats, + 'message' => 'User stats retrieved successfully.', + ]); + } catch (Exception $e) { + Log::error("Error fetching {$this->resourceNamePlural} stats: ".$e->getMessage()); + + return $this->errorResponse('Failed to retrieve user stats.', 500); + } + } + public function restore(string $id): JsonResponse { $this->authorize('restore', $this->modelClass); diff --git a/be/Modules/User/Repositories/Contracts/UserRepositoryInterface.php b/be/Modules/User/Repositories/Contracts/UserRepositoryInterface.php index c5527ee..4587a55 100644 --- a/be/Modules/User/Repositories/Contracts/UserRepositoryInterface.php +++ b/be/Modules/User/Repositories/Contracts/UserRepositoryInterface.php @@ -55,6 +55,17 @@ interface UserRepositoryInterface string $sortOrder = 'desc' ); + /** + * Get stats for the Users list (filtered). + * + * @return array{total: int, joined_this_month: int} + */ + public function getListStats( + string $search = '', + string $status = '', + array $dateFilters = [] + ): array; + /** * Find soft-deleted User by ID */ diff --git a/be/Modules/User/Repositories/UserRepository.php b/be/Modules/User/Repositories/UserRepository.php index b53d18f..5528484 100644 --- a/be/Modules/User/Repositories/UserRepository.php +++ b/be/Modules/User/Repositories/UserRepository.php @@ -3,6 +3,7 @@ namespace Modules\User\Repositories; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Support\Carbon; use Modules\Auth\Entities\User; use Modules\User\Repositories\Contracts\UserRepositoryInterface; @@ -85,7 +86,18 @@ class UserRepository implements UserRepositoryInterface $query = User::excludeDevelopersUnlessDeveloper() ->with([ - 'roles:id,name,guard_name' + 'roles:id,name,guard_name', + 'employments' => function ($q) { + $q->select([ + 'id', + 'user_id', + 'company_name', + 'is_current', + 'start_date', + ]) + ->orderByDesc('is_current') + ->orderByDesc('start_date'); + }, ]) ->orderBy($sortBy, $sortOrder); @@ -129,6 +141,17 @@ class UserRepository implements UserRepositoryInterface ->excludeDevelopersUnlessDeveloper() ->with([ 'roles:id,name,guard_name', + 'employments' => function ($q) { + $q->select([ + 'id', + 'user_id', + 'company_name', + 'is_current', + 'start_date', + ]) + ->orderByDesc('is_current') + ->orderByDesc('start_date'); + }, ]) ->orderBy($sortBy, $sortOrder); @@ -141,6 +164,36 @@ class UserRepository implements UserRepositoryInterface return $query->paginate($perPage); } + public function getListStats( + string $search = '', + string $status = '', + array $dateFilters = [] + ): array { + $baseQuery = User::excludeDevelopersUnlessDeveloper(); + + $this->applySearch($baseQuery, $search); + + if (! empty($status)) { + $baseQuery->where('status', $status); + } + + $this->applyDateRangeFilters($baseQuery, $dateFilters); + + $total = (int) (clone $baseQuery)->count(); + + $monthStart = Carbon::now()->startOfMonth()->toDateString(); + $monthEnd = Carbon::now()->endOfMonth()->toDateString(); + $joinedThisMonth = (int) (clone $baseQuery) + ->whereDate('join_date', '>=', $monthStart) + ->whereDate('join_date', '<=', $monthEnd) + ->count(); + + return [ + 'total' => $total, + 'joined_this_month' => $joinedThisMonth, + ]; + } + /** * Get all Users with their relationships and search */ diff --git a/be/Modules/User/Routes/api.php b/be/Modules/User/Routes/api.php index c4fc38b..1461c8a 100644 --- a/be/Modules/User/Routes/api.php +++ b/be/Modules/User/Routes/api.php @@ -19,6 +19,7 @@ Route::prefix('v1/public')->group(function () { Route::middleware(['auth:sanctum', 'single.session'])->prefix('v1')->group(function () { Route::get('users/deleted', [UserController::class, 'deletedIndex'])->name('users.deleted.index'); + Route::get('users/stats', [UserController::class, 'stats'])->name('users.stats'); Route::post('users/{id}/restore', [UserController::class, 'restore'])->name('users.restore'); Route::apiResource('users', UserController::class)->names('user'); Route::apiResource('addresses', AddressController::class)->names('address'); diff --git a/be/Modules/User/Services/UserService.php b/be/Modules/User/Services/UserService.php index 67116df..c4eaaac 100644 --- a/be/Modules/User/Services/UserService.php +++ b/be/Modules/User/Services/UserService.php @@ -50,6 +50,14 @@ class UserService ); } + /** + * @return array{total: int, joined_this_month: int} + */ + public function getListStats(string $search, string $status, array $dateFilters = []): array + { + return $this->repository->getListStats($search, $status, $dateFilters); + } + public function restoreUser(string $id): ?User { if (! $this->repository->restore($id)) { diff --git a/be/Modules/User/Transformers/UserListResource.php b/be/Modules/User/Transformers/UserListResource.php index 2a9f4dd..8eaf4dd 100644 --- a/be/Modules/User/Transformers/UserListResource.php +++ b/be/Modules/User/Transformers/UserListResource.php @@ -5,6 +5,7 @@ namespace Modules\User\Transformers; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Support\Facades\Storage; +use Modules\User\Transformers\EmploymentResource; class UserListResource extends JsonResource { @@ -31,6 +32,7 @@ class UserListResource extends JsonResource 'created_at' => $this->created_at, 'updated_at' => $this->updated_at, 'deleted_at' => $this->deleted_at, + 'employments' => EmploymentResource::collection($this->whenLoaded('employments')), 'roles' => $this->roles->map(function ($role) { return [ 'id' => $role->id, diff --git a/be/app/Console/Commands/AutoGenerateWeeklyReportForUnit.php b/be/app/Console/Commands/AutoGenerateWeeklyReportForUnit.php deleted file mode 100644 index 2020eba..0000000 --- a/be/app/Console/Commands/AutoGenerateWeeklyReportForUnit.php +++ /dev/null @@ -1,56 +0,0 @@ -argument('unit'); - - $unit = Unit::where('name', $unitName)->first(); - - if (! $unit) { - $this->error("Unit '{$unitName}' not found."); - - return 1; - } - - $month = strtolower(now()->format('F')); - $week = (int) ceil(now()->day / 7); - - if (! $this->option('force')) { - $existingReports = KJCReport::where('unit_id', $unit->id) - ->where('report_type', 'weekly') - ->where('report_month', $month) - ->where('report_week', $week) - ->exists(); - - if ($existingReports) { - $this->info("Reports for {$unitName} - {$month} week {$week} already exist. Skipping."); - - return 0; - } - } - - $user = User::where('unit_id', $unit->id)->first(); - - GenerateTeamWeeklyReports::dispatch($unit->id, $month, $week, $user?->id); - - $this->info("✓ Queued weekly report generation for {$unitName} - {$month} week {$week}"); - - return 0; - } -} diff --git a/be/app/Console/Commands/TriggerKJCHistoricalDataCapture.php b/be/app/Console/Commands/TriggerKJCHistoricalDataCapture.php deleted file mode 100644 index c8380de..0000000 --- a/be/app/Console/Commands/TriggerKJCHistoricalDataCapture.php +++ /dev/null @@ -1,68 +0,0 @@ -option('month'); - $year = $this->option('year'); - $useQueue = $this->option('queue'); - - // If no month/year provided, use previous month - if (! $month || ! $year) { - $lastMonth = Carbon::now()->subMonth(); - $month = $month ?: $lastMonth->format('m'); - $year = $year ?: $lastMonth->format('Y'); - } - - $this->info("Triggering KJC historical data capture for {$month}/{$year}..."); - - try { - if ($useQueue) { - // Dispatch to queue - CaptureKJCHistoricalDataJob::dispatch($month, $year); - $this->info('✓ KJC historical data capture job dispatched to queue'); - $this->info('You can monitor the job in Horizon dashboard'); - } else { - // Run immediately - $job = new CaptureKJCHistoricalDataJob($month, $year); - $job->handle(app(KJCHistoricalDataService::class)); - $this->info('✓ KJC historical data capture completed immediately'); - } - - } catch (\Exception $e) { - $this->error('Error: '.$e->getMessage()); - - return 1; - } - - return 0; - } -} diff --git a/be/app/Console/Commands/TriggerPKJHistoricalDataCapture.php b/be/app/Console/Commands/TriggerPKJHistoricalDataCapture.php deleted file mode 100644 index b3c638b..0000000 --- a/be/app/Console/Commands/TriggerPKJHistoricalDataCapture.php +++ /dev/null @@ -1,55 +0,0 @@ -option('month'); - $year = $this->option('year'); - $useQueue = $this->option('queue'); - - // If no month/year provided, use previous month - if (! $month || ! $year) { - $lastMonth = Carbon::now()->subMonth(); - $month = $month ?: $lastMonth->format('m'); - $year = $year ?: $lastMonth->format('Y'); - } - - $this->info("Triggering PKJ historical data capture for {$month}/{$year}..."); - - try { - if ($useQueue) { - // Dispatch to queue - CapturePKJHistoricalDataJob::dispatch($month, $year); - $this->info('✓ PKJ historical data capture job dispatched to queue'); - $this->info('You can monitor the job in Horizon dashboard'); - } else { - // Run immediately - $job = new CapturePKJHistoricalDataJob($month, $year); - $job->handle(app(PKJHistoricalDataService::class)); - $this->info('✓ PKJ historical data capture completed immediately'); - } - - } catch (\Exception $e) { - $this->error('Error: '.$e->getMessage()); - - return 1; - } - - return 0; - } -} diff --git a/be/app/Http/Controllers/BaseCrudController.php b/be/app/Http/Controllers/BaseCrudController.php index 8281cb6..badbade 100644 --- a/be/app/Http/Controllers/BaseCrudController.php +++ b/be/app/Http/Controllers/BaseCrudController.php @@ -113,8 +113,6 @@ abstract class BaseCrudController extends Controller $item = $this->repository->create($data); - ActivityLogger::log("Created {$this->resourceName}: {$this->getItemName($item)}", $item); - return response()->json([ 'success' => true, 'data' => new $this->resourceClass($item), @@ -175,8 +173,6 @@ abstract class BaseCrudController extends Controller $item->update($data); - ActivityLogger::log("Updated {$this->resourceName}: {$this->getItemName($item)}", $item); - return response()->json([ 'success' => true, 'data' => new $this->resourceClass($item), @@ -221,8 +217,6 @@ abstract class BaseCrudController extends Controller $this->repository->delete($id); - ActivityLogger::log("Deleted {$this->resourceName}: {$this->getItemName($item)}", $item); - return response()->json([ 'success' => true, 'message' => $this->getSuccessMessage('destroy'), diff --git a/be/app/Jobs/CaptureKJCHistoricalDataJob.php b/be/app/Jobs/CaptureKJCHistoricalDataJob.php deleted file mode 100644 index 2d71057..0000000 --- a/be/app/Jobs/CaptureKJCHistoricalDataJob.php +++ /dev/null @@ -1,102 +0,0 @@ -month = $month; - $this->year = $year; - - // Set queue name for Horizon monitoring - $this->onQueue('kjc-historical-data'); - } - - /** - * Execute the job. - */ - public function handle(KJCHistoricalDataService $kjcHistoricalService) - { - try { - Log::info('Starting KJC historical data capture job', [ - 'month' => $this->month, - 'year' => $this->year, - 'job_id' => $this->job ? $this->job->getJobId() : 'manual-execution', - ]); - - $success = $kjcHistoricalService->captureMonthlySnapshots($this->month, $this->year); - - if ($success) { - Log::info('KJC historical data capture job completed successfully', [ - 'month' => $this->month, - 'year' => $this->year, - 'job_id' => $this->job ? $this->job->getJobId() : 'manual-execution', - ]); - } else { - Log::error('KJC historical data capture job failed', [ - 'month' => $this->month, - 'year' => $this->year, - 'job_id' => $this->job ? $this->job->getJobId() : 'manual-execution', - ]); - - throw new \Exception('KJC historical data capture failed'); - } - - } catch (\Exception $e) { - Log::error('KJC historical data capture job exception', [ - 'month' => $this->month, - 'year' => $this->year, - 'job_id' => $this->job ? $this->job->getJobId() : 'manual-execution', - 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString(), - ]); - - throw $e; - } - } - - /** - * Handle a job failure. - */ - public function failed(\Throwable $exception) - { - Log::error('KJC historical data capture job failed permanently', [ - 'month' => $this->month, - 'year' => $this->year, - 'job_id' => $this->job ? $this->job->getJobId() : 'manual-execution', - 'error' => $exception->getMessage(), - ]); - } - - /** - * Get the tags that should be assigned to the job. - */ - public function tags() - { - return ['kjc-historical-data', "month-{$this->month}", "year-{$this->year}"]; - } -} diff --git a/be/app/Jobs/CapturePKJHistoricalDataJob.php b/be/app/Jobs/CapturePKJHistoricalDataJob.php deleted file mode 100644 index eee5f0d..0000000 --- a/be/app/Jobs/CapturePKJHistoricalDataJob.php +++ /dev/null @@ -1,102 +0,0 @@ -month = $month; - $this->year = $year; - - // Set queue name for Horizon monitoring - $this->onQueue('pkj-historical-data'); - } - - /** - * Execute the job. - */ - public function handle(PKJHistoricalDataService $historicalService) - { - try { - Log::info('Starting PKJ historical data capture job', [ - 'month' => $this->month, - 'year' => $this->year, - 'job_id' => $this->job ? $this->job->getJobId() : 'manual-execution', - ]); - - $success = $historicalService->captureMonthlySnapshots($this->month, $this->year); - - if ($success) { - Log::info('PKJ historical data capture job completed successfully', [ - 'month' => $this->month, - 'year' => $this->year, - 'job_id' => $this->job ? $this->job->getJobId() : 'manual-execution', - ]); - } else { - Log::error('PKJ historical data capture job failed', [ - 'month' => $this->month, - 'year' => $this->year, - 'job_id' => $this->job ? $this->job->getJobId() : 'manual-execution', - ]); - - throw new \Exception('PKJ historical data capture failed'); - } - - } catch (\Exception $e) { - Log::error('PKJ historical data capture job exception', [ - 'month' => $this->month, - 'year' => $this->year, - 'job_id' => $this->job ? $this->job->getJobId() : 'manual-execution', - 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString(), - ]); - - throw $e; - } - } - - /** - * Handle a job failure. - */ - public function failed(\Throwable $exception) - { - Log::error('PKJ historical data capture job failed permanently', [ - 'month' => $this->month, - 'year' => $this->year, - 'job_id' => $this->job ? $this->job->getJobId() : 'manual-execution', - 'error' => $exception->getMessage(), - ]); - } - - /** - * Get the tags that should be assigned to the job. - */ - public function tags() - { - return ['pkj-historical-data', "month-{$this->month}", "year-{$this->year}"]; - } -} diff --git a/be/routes/console.php b/be/routes/console.php index aa56fe4..927793c 100644 --- a/be/routes/console.php +++ b/be/routes/console.php @@ -3,27 +3,7 @@ use Illuminate\Foundation\Inspiring; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Schedule; -use App\Jobs\CaptureKJCHistoricalDataJob; -use App\Jobs\CapturePKJHistoricalDataJob; Artisan::command('inspire', function () { $this->comment(Inspiring::quote()); -})->purpose('Display an inspiring quote'); - -// Schedule KJC Historical Data Capture to run monthly on the 1st day at 2:00 AM -Schedule::job(new CaptureKJCHistoricalDataJob()) - ->monthlyOn(1, '02:00') - ->withoutOverlapping() - ->name('kjc-historical-data-capture'); - -// Schedule PKJ Historical Data Capture to run monthly on the 1st day at 2:30 AM -Schedule::job(new CapturePKJHistoricalDataJob()) - ->monthlyOn(1, '02:30') - ->withoutOverlapping() - ->name('pkj-historical-data-capture'); - -// Auto-generate weekly KJC reports for Jabatan Arah RAJD every Monday at 3:00 AM -Schedule::command('kjc:auto-generate-weekly-report "Jabatan Arah RAJD"') - ->weeklyOn(1, '03:00') // Monday at 3:00 AM - ->withoutOverlapping() - ->name('kjc-auto-weekly-report'); \ No newline at end of file +})->purpose('Display an inspiring quote'); \ No newline at end of file diff --git a/fe/src/composables/useRoleSwitcher.ts b/fe/src/composables/useRoleSwitcher.ts index 65b6fde..8fe06ed 100644 --- a/fe/src/composables/useRoleSwitcher.ts +++ b/fe/src/composables/useRoleSwitcher.ts @@ -86,9 +86,9 @@ export function useRoleSwitcher() { toast: true, position: 'top-end', icon: 'success', - title: res.message || 'Peranan telah ditukar.', + title: res.message, showConfirmButton: false, - timer: 3000, + timer: 500, }) await router.push(resolvePostLoginRoute(res.redirect_path)) diff --git a/fe/src/modules/auth/pages/Login.vue b/fe/src/modules/auth/pages/Login.vue index 89b6cad..317940c 100644 --- a/fe/src/modules/auth/pages/Login.vue +++ b/fe/src/modules/auth/pages/Login.vue @@ -117,7 +117,7 @@ const appVersion = import.meta.env.VITE_APP_VERSION + placeholder="Kata Laluan" autocomplete="current-password" required />
@@ -127,7 +127,7 @@ const appVersion = import.meta.env.VITE_APP_VERSION
diff --git a/fe/src/modules/auth/pages/Register.vue b/fe/src/modules/auth/pages/Register.vue index 4f40dac..ae5f4ee 100644 --- a/fe/src/modules/auth/pages/Register.vue +++ b/fe/src/modules/auth/pages/Register.vue @@ -8,6 +8,7 @@ import { Input } from '@/components/ui/input' import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert' import { PasswordInput } from '@/components/ui/password-input' import { getRegisterErrorMessage, register } from '@/modules/auth' +import { sanitizeIcNumberInput, sanitizeNameInput } from '@/utils/form-input.utils' import illustrationUrl from '@/assets/images/logo.svg' const router = useRouter() @@ -21,6 +22,14 @@ const termsAccepted = ref(false) const loading = ref(false) const errorMessage = ref('') +const handleNameInput = () => { + name.value = sanitizeNameInput(name.value) +} + +const handleIcNumberInput = () => { + icNumber.value = sanitizeIcNumberInput(icNumber.value) +} + const handleRegister = async () => { if (!termsAccepted.value) { errorMessage.value = 'Sila bersetuju dengan Dasar Privasi dan Terma dan Syarat.' @@ -90,15 +99,16 @@ const handleRegister = async () => {
+ placeholder="Nama Penuh" autocomplete="name" required @input="handleNameInput" /> + inputmode="numeric" maxlength="15" placeholder="Contoh: 900101011234" required + @input="handleIcNumberInput" /> + placeholder="Kata Laluan" autocomplete="new-password" minlength="8" required /> + placeholder="Sahkan Kata Laluan" autocomplete="new-password" minlength="8" required />
Nama Penuh - + {{ fieldErrors['applicant.name'] }} @@ -808,7 +820,8 @@ function stepLabelClass(stepId: number) {
Nama - + {{ fieldErrors[`heirs.${index}.name`] }} diff --git a/fe/src/modules/membership-application/utils/membership-application-form.utils.ts b/fe/src/modules/membership-application/utils/membership-application-form.utils.ts index a5fece2..f1ec091 100644 --- a/fe/src/modules/membership-application/utils/membership-application-form.utils.ts +++ b/fe/src/modules/membership-application/utils/membership-application-form.utils.ts @@ -61,9 +61,7 @@ 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 { sanitizeIcNumberInput } from '@/utils/form-input.utils' export function createSelectCollection(options: SelectOption[]) { return select.collection({ @@ -172,11 +170,11 @@ export function detailToFormState(detail: MembershipApplicationDetail): Membersh }, heirs: detail.heirs.length ? detail.heirs.map((heir) => ({ - name: toFormString(heir.name), - ic_number: toFormString(heir.ic_number), - relationship: toFormString(heir.relationship), - phone_number: toFormString(heir.phone_number), - })) + name: toFormString(heir.name), + ic_number: toFormString(heir.ic_number), + relationship: toFormString(heir.relationship), + phone_number: toFormString(heir.phone_number), + })) : [createEmptyHeir()], references: { proposer: { diff --git a/fe/src/modules/profile/pages/ProfileOverview2.vue b/fe/src/modules/profile/pages/ProfileOverview2.vue index bcf3d73..1105295 100644 --- a/fe/src/modules/profile/pages/ProfileOverview2.vue +++ b/fe/src/modules/profile/pages/ProfileOverview2.vue @@ -15,7 +15,6 @@ import MemberDigitalCard from '../components/MemberDigitalCard.vue' import ProfileTab from './ProfileTab.vue' import EmploymentTab from './EmploymentTab.vue' import BankDetailTab from './BankDetailTab.vue' -import HeirTab from './HeirTab.vue' import ChangePasswordTab from './ChangePasswordTab.vue' const authStore = useAuthStore() @@ -263,12 +262,6 @@ onMounted(async () => { - - - - @@ -294,10 +287,6 @@ onMounted(async () => { - - - -
diff --git a/fe/src/modules/profile/pages/ProfileTab.vue b/fe/src/modules/profile/pages/ProfileTab.vue index 89a0841..f4fef6a 100644 --- a/fe/src/modules/profile/pages/ProfileTab.vue +++ b/fe/src/modules/profile/pages/ProfileTab.vue @@ -20,6 +20,7 @@ import { SelectItemText, } from '@/components/ui/select' import { getApiErrorMessage, getApiValidationErrors } from '@/core/utils/getApiErrorMessage' +import { sanitizeIcNumberInput, sanitizeNameInput } from '@/utils/form-input.utils' import { createAddress, deleteAddress, @@ -29,6 +30,7 @@ import { import { updateProfile } from '@/modules/profile/services/profile.service' import type { Address, AddressPayload } from '@/modules/profile/types/address.types' import { useAuthStore } from '@/stores/auth' +import HeirTab from './HeirTab.vue' defineProps<{ embedded?: boolean @@ -159,6 +161,14 @@ const stateValue = ref([]) const addressTypeInitial = ref([]) const stateInitial = ref([]) +function handleNameInput() { + form.name = sanitizeNameInput(form.name) +} + +function handleIcNumberInput() { + form.ic_number = sanitizeIcNumberInput(form.ic_number) +} + function clearProfileFieldError(field: ProfileFieldKey) { delete profileErrors[field] } @@ -569,7 +579,8 @@ onMounted(async () => {
Nama - + E-mel @@ -577,7 +588,8 @@ onMounted(async () => { No. Kad Pengenalan - + No. Telefon @@ -817,5 +829,7 @@ onMounted(async () => {
+ +
diff --git a/fe/src/modules/user/composables/useUserList.ts b/fe/src/modules/user/composables/useUserList.ts index 2174de9..c7082d9 100644 --- a/fe/src/modules/user/composables/useUserList.ts +++ b/fe/src/modules/user/composables/useUserList.ts @@ -3,13 +3,15 @@ import debounce from 'lodash/debounce' import type { SortConfig } from '@/components/ui/usage/DataTable.vue' import { useApiPagination } from '@/composables/useApiPagination' import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage' -import { listUsers } from '../services/user.service' +import { getUserStats, listUsers } from '../services/user.service' import type { UserListItem } from '../types/user.types' export function useUserList() { const users = ref([]) const loading = ref(false) + const statsLoading = ref(false) const error = ref(null) + const statsError = ref(null) const search = ref('') const statusFilter = ref('') const joinDateFrom = ref('') @@ -19,6 +21,7 @@ export function useUserList() { const sortBy = ref([{ key: 'name', order: 'asc' }]) const page = ref(1) const itemsPerPage = ref(10) + const stats = ref<{ total: number; joined_this_month: number }>({ total: 0, joined_this_month: 0 }) const { pagination, applyPagination } = useApiPagination({ per_page: 10 }) @@ -64,6 +67,29 @@ export function useUserList() { } } + async function fetchStats() { + statsLoading.value = true + statsError.value = null + + try { + const res = await getUserStats({ + 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, + }) + + stats.value = res.data + } catch (err) { + statsError.value = getApiErrorMessage(err, 'Gagal memuatkan statistik pengguna.') + stats.value = { total: 0, joined_this_month: 0 } + } finally { + statsLoading.value = false + } + } + function handleSortUpdate(value: SortConfig[]) { sortBy.value = value fetchUsers(1) @@ -71,6 +97,7 @@ export function useUserList() { const debouncedSearch = debounce(() => { fetchUsers(1) + fetchStats() }, 400) watch(search, () => { @@ -79,10 +106,12 @@ export function useUserList() { watch(statusFilter, () => { fetchUsers(1) + fetchStats() }) watch([joinDateFrom, joinDateTo, leaveDateFrom, leaveDateTo], () => { fetchUsers(1) + fetchStats() }) watch(page, (nextPage, previousPage) => { @@ -99,11 +128,15 @@ export function useUserList() { onMounted(() => { fetchUsers(1) + fetchStats() }) return { users, loading, + stats, + statsLoading, + statsError, error, search, statusFilter, @@ -121,5 +154,6 @@ export function useUserList() { pagination, handleSortUpdate, fetchUsers, + fetchStats, } } diff --git a/fe/src/modules/user/pages/UserCreate.vue b/fe/src/modules/user/pages/UserCreate.vue index f488b72..f8f4c80 100644 --- a/fe/src/modules/user/pages/UserCreate.vue +++ b/fe/src/modules/user/pages/UserCreate.vue @@ -19,6 +19,7 @@ import { SelectItemText, } from '@/components/ui/select' import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage' +import { sanitizeIcNumberInput, sanitizeNameInput } from '@/utils/form-input.utils' import { createUser } from '../services/user.service' type SelectOption = { label: string; value: string } @@ -105,6 +106,14 @@ const form = reactive({ birth_place: '', }) +function handleNameInput() { + form.name = sanitizeNameInput(form.name) +} + +function handleIcNumberInput() { + form.ic_number = sanitizeIcNumberInput(form.ic_number) +} + function requireSelectValue(label: string | undefined, fieldName: string): string { if (!label) { throw new Error(`${fieldName} diperlukan.`) @@ -201,6 +210,7 @@ const formDisabled = computed(() => saving.value) placeholder="Nama penuh" :disabled="formDisabled" required + @input="handleNameInput" /> @@ -226,9 +236,12 @@ const formDisabled = computed(() => saving.value) v-model="form.ic_number" class="w-full" type="text" - placeholder="Nombor kad pengenalan" + inputmode="numeric" + maxlength="15" + placeholder="Contoh: 900101011234" :disabled="formDisabled" required + @input="handleIcNumberInput" /> diff --git a/fe/src/modules/user/pages/UserEdit.vue b/fe/src/modules/user/pages/UserEdit.vue index a6e5e6d..fc3d9bc 100644 --- a/fe/src/modules/user/pages/UserEdit.vue +++ b/fe/src/modules/user/pages/UserEdit.vue @@ -20,6 +20,7 @@ import { SelectItemText, } from '@/components/ui/select' import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage' +import { sanitizeIcNumberInput, sanitizeNameInput } from '@/utils/form-input.utils' import { getUser, updateUser } from '../services/user.service' type SelectOption = { label: string; value: string } @@ -121,6 +122,14 @@ const form = reactive({ birth_place: '', }) +function handleNameInput() { + form.name = sanitizeNameInput(form.name) +} + +function handleIcNumberInput() { + form.ic_number = sanitizeIcNumberInput(form.ic_number) +} + function toDateInputValue(value: string | null | undefined): string { if (!value) return '' return value.slice(0, 10) @@ -239,7 +248,7 @@ onMounted(() => { Nama + :disabled="formDisabled" required @input="handleNameInput" /> @@ -251,8 +260,8 @@ onMounted(() => {
Nombor Kad Pengenalan - + diff --git a/fe/src/modules/user/pages/UserHeirTab.vue b/fe/src/modules/user/pages/UserHeirTab.vue deleted file mode 100644 index d764516..0000000 --- a/fe/src/modules/user/pages/UserHeirTab.vue +++ /dev/null @@ -1,38 +0,0 @@ - - - diff --git a/fe/src/modules/user/pages/UserList.vue b/fe/src/modules/user/pages/UserList.vue index a7f6c9d..a00a2f8 100644 --- a/fe/src/modules/user/pages/UserList.vue +++ b/fe/src/modules/user/pages/UserList.vue @@ -1,12 +1,19 @@