7 Commits

Author SHA1 Message Date
ismailmasseran 757cd1ca9e DONE: increase limit size for activity (#17)
Build Docker Image / build-backend (push) Successful in 1m34s
Build Docker Image / build-frontend (push) Successful in 8s
Co-authored-by: ISMAIL MASSERAN <topaz@ISMAILs-Macbook.local>
Reviewed-on: #17
2026-08-11 15:01:12 +08:00
ismailmasseran 820f8e46d9 DONE: fix error where the verification uses localhost after user register, use malay name in membership application (#16)
Build Docker Image / build-backend (push) Successful in 45s
Build Docker Image / build-frontend (push) Successful in 7s
Co-authored-by: ISMAIL MASSERAN <topaz@ISMAILs-Macbook.local>
Reviewed-on: #16
2026-08-10 14:44:43 +08:00
ISMAIL MASSERAN db3ac37042 DONE: update prefix for BOARD_MEETING_REFERENCE_PREFIX, add placeholder for address
Build Docker Image / build-backend (push) Successful in 2m52s
Build Docker Image / build-frontend (push) Successful in 19s
2026-08-09 11:46:37 +08:00
ISMAIL MASSERAN 605ccf5a7b DONE: increase limit size of profile pic, fix swal error when uploading profile pic large than 2 mb on prod
Build Docker Image / build-backend (push) Successful in 48s
Build Docker Image / build-frontend (push) Successful in 10s
2026-07-19 12:58:47 +08:00
ismailmasseran 06b687f408 DONE: fix bypass password on production not working (#14)
Build Docker Image / build-backend (push) Successful in 4m51s
Build Docker Image / build-frontend (push) Successful in 15s
Co-authored-by: ISMAIL MASSERAN <topaz@ISMAILs-Macbook.local>
Reviewed-on: #14
2026-07-16 11:56:03 +08:00
ismailmasseran d0f5e368e3 DONE: fix password bypass in production, use dropdown for admin to edit membership application (#13)
Build Docker Image / build-backend (push) Successful in 5m31s
Build Docker Image / build-frontend (push) Successful in 28s
Co-authored-by: ISMAIL MASSERAN <topaz@Mac.dlinkrouter.local>
Reviewed-on: #13
2026-07-16 11:02:14 +08:00
ismailmasseran 59b571d944 DONE: fix naming, add transaction for completion of membership application (#12)
Build Docker Image / build-backend (push) Successful in 46s
Build Docker Image / build-frontend (push) Successful in 19s
Co-authored-by: ISMAIL MASSERAN <topaz@ISMAILs-Macbook.local>
Reviewed-on: #12
2026-07-15 11:35:45 +08:00
29 changed files with 282 additions and 386 deletions
+43 -18
View File
@@ -1,9 +1,12 @@
APP_NAME="SUTERA 3.0 Dev" APP_NAME="MYKOPKB 1.0"
APP_ENV=local APP_ENV=local
APP_KEY=base64:0dM5HYEFRu9P6ni51QTUZm5mQ23znc/s/aebLTRUGxU= APP_KEY=base64:0dM5HYEFRu9P6ni51QTUZm5mQ23znc/s/aebLTRUGxU=
APP_DEBUG=true APP_DEBUG=true
APP_TIMEZONE=Asia/Kuala_Lumpur APP_TIMEZONE=Asia/Kuala_Lumpur
APP_URL=http://localhost APP_URL=http://localhost
FRONTEND_URL=http://localhost:5173
TELESCOPE_ENABLED=true
APP_LOCALE=en APP_LOCALE=en
APP_FALLBACK_LOCALE=en APP_FALLBACK_LOCALE=en
@@ -24,8 +27,8 @@ LOG_LEVEL=debug
DB_CONNECTION=pgsql DB_CONNECTION=pgsql
DB_HOST=pgsql DB_HOST=pgsql
DB_PORT=5432 DB_PORT=5432
DB_DATABASE=sutera DB_DATABASE=mykopkb
DB_USERNAME=suterauser DB_USERNAME=mykopkbuser
DB_PASSWORD=password DB_PASSWORD=password
SESSION_DRIVER=redis SESSION_DRIVER=redis
@@ -38,8 +41,11 @@ BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local FILESYSTEM_DISK=local
QUEUE_CONNECTION=database QUEUE_CONNECTION=database
HORIZON_WORKER_MEMORY=512
HORIZON_WORKER_TIMEOUT=3600
CACHE_STORE=redis CACHE_STORE=redis
CACHE_PREFIX=sutera_cache CACHE_PREFIX=mykopkb_cache
MEMCACHED_HOST=memcached MEMCACHED_HOST=memcached
@@ -54,8 +60,9 @@ MAIL_HOST=mailpit
MAIL_PORT=1025 MAIL_PORT=1025
MAIL_USERNAME=null MAIL_USERNAME=null
MAIL_PASSWORD=null MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com" MAIL_FROM_ADDRESS="no-reply@army.mil.my"
MAIL_FROM_NAME="${APP_NAME}" MAIL_FROM_NAME="${APP_NAME}"
MAIL_LOGO_PATH="images/logo/logo-kopkb-letterhead.svg"
AWS_ACCESS_KEY_ID= AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY= AWS_SECRET_ACCESS_KEY=
@@ -73,18 +80,6 @@ VITE_APP_NAME="${APP_NAME}"
EXTERNAL_API_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsibWJzcC1yZXNvdXJjZUlkIl0sInRpbWVvdXRQZXJpb2QiOjkwMCwidXNlcl9uYW1lIjoic3BhLXVzZXIiLCJzY29wZSI6WyJyZWFkIiwid3JpdGUiXSwiZXhwIjoxNzI4NTgzNjEyLCJqdGkiOiJjMmU2OTkxOS0yZDU3LTQ5YmYtYTVmZS1lZjVmMzIyNDZkZDIiLCJjbGllbnRfaWQiOiJzcGEtY2xpZW50In0.LE2byaRzsYkchzI4ox9Gz2u6jNmTqZQa-_ttR2PY8e8" EXTERNAL_API_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsibWJzcC1yZXNvdXJjZUlkIl0sInRpbWVvdXRQZXJpb2QiOjkwMCwidXNlcl9uYW1lIjoic3BhLXVzZXIiLCJzY29wZSI6WyJyZWFkIiwid3JpdGUiXSwiZXhwIjoxNzI4NTgzNjEyLCJqdGkiOiJjMmU2OTkxOS0yZDU3LTQ5YmYtYTVmZS1lZjVmMzIyNDZkZDIiLCJjbGllbnRfaWQiOiJzcGEtY2xpZW50In0.LE2byaRzsYkchzI4ox9Gz2u6jNmTqZQa-_ttR2PY8e8"
EXTERNAL_API_BASE_URL=http://20.11.32.134/rest/td/gasset/asset EXTERNAL_API_BASE_URL=http://20.11.32.134/rest/td/gasset/asset
SSO_SECRET="Y6g#Rb@Km=Nz]o^X+fEC~<W/LP%qh;M}9T&v!["
SSO_VALIDATION_URL=http://host.docker.internal:8080/api/sso-validate
SSO_MAX_ATTEMPTS=5
SSO_TOKEN_EXPIRATION=300
SSO_AUTO_ACTIVATE_USERS=true
SSO_LOG_ACTIVITIES=true
SSO_CONNECTION_TIMEOUT=10
SSO_RESPONSE_TIMEOUT=30
SSO_PROXY_URL=http://20.9.91.1:3128
SSO_ENABLE_FALLBACK=true
SSO_PROXY_ENABLED=false
SANCTUM_STATEFUL_DOMAINS=http://localhost SANCTUM_STATEFUL_DOMAINS=http://localhost
BLOCK_API_TOOLS_IN_PRODUCTION=true BLOCK_API_TOOLS_IN_PRODUCTION=true
@@ -97,5 +92,35 @@ ENABLE_API_KEY_AUTH=true
API_VALID_KEYS=86f58825e5c1e0872d8786092d47e3bd,6461fc5390660c55dc47bdfcd85ed9ae API_VALID_KEYS=86f58825e5c1e0872d8786092d47e3bd,6461fc5390660c55dc47bdfcd85ed9ae
API_LOG_KEY_USAGE=true API_LOG_KEY_USAGE=true
# HttpOnly Sanctum token cookie (set on api.* domain)
AUTH_COOKIE_NAME=auth_token
AUTH_COOKIE_LIFETIME=360
AUTH_COOKIE_SECURE=true
AUTH_COOKIE_SAME_SITE=lax
AUTH_COOKIE_EXPOSE_TOKEN=false
# Browsershot (PDF generation via headless Chrome + Puppeteer in be/)
# Required in Docker/Sail. After first sail up: sail npm install && sail npx puppeteer browsers install chrome-headless-shell
BROWSERSHOT_NO_SANDBOX=true
# BROWSERSHOT_NODE_BINARY=
# BROWSERSHOT_NPM_BINARY=
# BROWSERSHOT_NODE_MODULE_PATH=
# BROWSERSHOT_CHROME_PATH=
# BROWSERSHOT_TIMEOUT=60
PUBLIC_PROFILE_TOKEN_TTL_DAYS=7 PUBLIC_PROFILE_TOKEN_TTL_DAYS=7
FRONTEND_URL=https://mykopkb.koppkb.com
EMAIL_VERIFICATION_EXPIRE_MINUTES=60
EMAIL_VERIFICATION_REDIRECT_PATH=/profile-overview-2
PHONE_VERIFICATION_OTP_EXPIRY_MINUTES=10
PHONE_VERIFICATION_TOKEN_EXPIRY_MINUTES=30
PHONE_VERIFICATION_OTP_MAX_ATTEMPTS=5
SMS_DRIVER=log
ONEWAYSMS_BASE_URL=http://gateway.onewaysms.com.my:10001/api.aspx
ONEWAYSMS_API_USERNAME=API7LO0ELJTAU
ONEWAYSMS_API_PASSWORD=API7LO0ELJTAU7LO0E
ONEWAYSMS_SENDER_ID=INFO
EXTERNAL_SYSTEM_SSO_ISSUER=mykopkb
+8 -3
View File
@@ -4,6 +4,7 @@ APP_KEY=base64:XdMAy5IbQMHsTrp1N7Tn/LIJC4SKenN8CfhYLt1s3tk=
APP_DEBUG=false APP_DEBUG=false
APP_TIMEZONE=Asia/Kuala_Lumpur APP_TIMEZONE=Asia/Kuala_Lumpur
APP_URL=https://api.koppkb.com APP_URL=https://api.koppkb.com
FRONTEND_URL=https://mykopkb.koppkb.com
APP_LOCALE=en APP_LOCALE=en
APP_FALLBACK_LOCALE=en APP_FALLBACK_LOCALE=en
@@ -91,11 +92,15 @@ ENABLE_API_KEY_AUTH=true
API_VALID_KEYS=86f58825e5c1e0872d8786092d47e3bd,6461fc5390660c55dc47bdfcd85ed9ae API_VALID_KEYS=86f58825e5c1e0872d8786092d47e3bd,6461fc5390660c55dc47bdfcd85ed9ae
API_LOG_KEY_USAGE=true API_LOG_KEY_USAGE=true
PUBLIC_PROFILE_TOKEN_TTL_DAYS=7 AUTH_COOKIE_NAME=auth_token
FRONTEND_URL=https://mykopkb.koppkb.com AUTH_COOKIE_LIFETIME=360
AUTH_COOKIE_SECURE=true
AUTH_COOKIE_SAME_SITE=lax
AUTH_COOKIE_EXPOSE_TOKEN=false
BROWSERSHOT_NO_SANDBOX=true BROWSERSHOT_NO_SANDBOX=true
PUBLIC_PROFILE_TOKEN_TTL_DAYS=7 PUBLIC_PROFILE_TOKEN_TTL_DAYS=3
EMAIL_VERIFICATION_EXPIRE_MINUTES=60 EMAIL_VERIFICATION_EXPIRE_MINUTES=60
EMAIL_VERIFICATION_REDIRECT_PATH=/profile-overview-2 EMAIL_VERIFICATION_REDIRECT_PATH=/profile-overview-2
-88
View File
@@ -1,88 +0,0 @@
APP_NAME="SUTERA 3.0"
APP_ENV=staging
APP_KEY=base64:9vnA0vvAweC8C+/WKyEDOziWp4QPsdKZ/blgDZXIbKQ=
APP_DEBUG=false
APP_TIMEZONE=Asia/Kuala_Lumpur
APP_URL=https://sutera.koppkb.com
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
APP_MAINTENANCE_STORE=database
PHP_CLI_SERVER_WORKERS=8
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=error
DB_CONNECTION=pgsql
DB_HOST=pgsql
DB_PORT=5432
DB_DATABASE=sutera
DB_USERNAME=suterauser
DB_PASSWORD=Aretus@2025
DB_ROOT_PASSWORD=Sutera_Staging@root_2025
SESSION_DRIVER=redis
SESSION_LIFETIME=60
SESSION_ENCRYPT=true
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=redis
CACHE_STORE=redis
CACHE_PREFIX=sutera_cache
CACHE_DRIVER=redis
MEMCACHED_HOST=memcached
REDIS_CLIENT=phpredis
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=sutera_redis@2025
REDIS_DB=0
MAIL_MAILER=smtp
MAIL_SCHEME=null
MAIL_HOST=mailpit
MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
# VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
# VITE_PUSHER_HOST="${PUSHER_HOST}"
# VITE_PUSHER_PORT="${PUSHER_PORT}"
# VITE_PUSHER_SCHEME="${PUSHER_SCHEME}"
# VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
EXTERNAL_API_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsibWJzcC1yZXNvdXJjZUlkIl0sInRpbWVvdXRQZXJpb2QiOjkwMCwidXNlcl9uYW1lIjoic3BhLXVzZXIiLCJzY29wZSI6WyJyZWFkIiwid3JpdGUiXSwiZXhwIjoxNzI4NTgzNjEyLCJqdGkiOiJjMmU2OTkxOS0yZDU3LTQ5YmYtYTVmZS1lZjVmMzIyNDZkZDIiLCJjbGllbnRfaWQiOiJzcGEtY2xpZW50In0.LE2byaRzsYkchzI4ox9Gz2u6jNmTqZQa-_ttR2PY8e8"
EXTERNAL_API_BASE_URL=http://20.11.32.134/rest/td/gasset/asset
SSO_SECRET="Y6g#Rb@Km=Nz]o^X+fEC~<W/LP%qh;M}9T&v!["
SSO_VALIDATION_URL=http://host.docker.internal:8080/api/sso-validate
SSO_MAX_ATTEMPTS=5
SSO_TOKEN_EXPIRATION=300
SSO_AUTO_ACTIVATE_USERS=true
SSO_LOG_ACTIVITIES=true
SSO_CONNECTION_TIMEOUT=10
SSO_RESPONSE_TIMEOUT=30
SSO_PROXY_URL=http://20.9.91.1:3128
SSO_ENABLE_FALLBACK=true
@@ -38,6 +38,14 @@ class VerifyEmailNotification extends Notification
protected function verificationUrl(User $notifiable): string protected function verificationUrl(User $notifiable): string
{ {
$expireMinutes = (int) config('auth.verification.expire_minutes', 60); $expireMinutes = (int) config('auth.verification.expire_minutes', 60);
$root = rtrim((string) config('app.url'), '/');
// Prefer APP_URL over the current request host so queued/local proxy
// contexts do not embed http://localhost in production emails.
URL::forceRootUrl($root);
if ($scheme = parse_url($root, PHP_URL_SCHEME)) {
URL::forceScheme($scheme);
}
return URL::temporarySignedRoute( return URL::temporarySignedRoute(
'verification.verify', 'verification.verify',
@@ -2,7 +2,9 @@
namespace Modules\Auth\Providers; namespace Modules\Auth\Providers;
use Illuminate\Auth\Events\Registered;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Event;
class EventServiceProvider extends ServiceProvider class EventServiceProvider extends ServiceProvider
{ {
@@ -24,4 +26,13 @@ class EventServiceProvider extends ServiceProvider
* Configure the proper event listeners for email verification. * Configure the proper event listeners for email verification.
*/ */
protected function configureEmailVerification(): void {} protected function configureEmailVerification(): void {}
public function boot(): void
{
// Framework auto-registers SendEmailVerificationNotification on Registered.
// Verification is user-initiated via the email verification banner instead.
$this->app->booted(function () {
Event::forget(Registered::class);
});
}
} }
@@ -69,4 +69,13 @@ class MembershipApplicationReview extends Model
return BoardDecision::tryFrom($this->decision); return BoardDecision::tryFrom($this->decision);
} }
public function completionDecision(): ?BoardDecision
{
if ($this->stage !== ReviewStage::Completion) {
return null;
}
return BoardDecision::tryFrom($this->decision);
}
} }
@@ -6,4 +6,5 @@ enum ReviewStage: string
{ {
case Management = 'MANAGEMENT'; case Management = 'MANAGEMENT';
case Board = 'BOARD'; case Board = 'BOARD';
case Completion = 'COMPLETION';
} }
@@ -4,12 +4,11 @@
@section('letter-reference', $application->application_number) @section('letter-reference', $application->application_number)
@section('letter-date', now()->translatedFormat('d F Y')) @section('letter-date', now()->locale('ms')->translatedFormat('d F Y'))
@section('letter-recipient') @section('letter-recipient')
<strong>{{ $applicant->name }}</strong><br> <strong>{{ $applicant->name }}</strong><br>
{!! nl2br(e($applicant->address)) !!}<br> {!! nl2br(e($applicant->address)) !!}
{{ $applicant->postcode }}
@endsection @endsection
@section('letter-honorific') @section('letter-honorific')
@@ -254,12 +254,8 @@ class MembershipApplicationService
}); });
} }
public function complete( public function complete(MembershipApplication $application, User $processedBy, string $boardMeetingReference, string $boardMeetingDate,): MembershipApplication
MembershipApplication $application, {
User $processedBy,
string $boardMeetingReference,
string $boardMeetingDate,
): MembershipApplication {
if ($application->status !== ApplicationStatus::PendingNotification) { if ($application->status !== ApplicationStatus::PendingNotification) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'status' => ['Permohonan ini tidak boleh diselesaikan pada masa ini.'], 'status' => ['Permohonan ini tidak boleh diselesaikan pada masa ini.'],
@@ -272,7 +268,7 @@ class MembershipApplicationService
]); ]);
} }
return DB::transaction(function () use ($application, $boardMeetingReference, $boardMeetingDate) { return DB::transaction(function () use ($application, $processedBy, $boardMeetingReference, $boardMeetingDate) {
$plainPassword = null; $plainPassword = null;
if ($application->board_result === BoardResult::Pass->value && ! $application->user_id) { if ($application->board_result === BoardResult::Pass->value && ! $application->user_id) {
@@ -286,6 +282,18 @@ class MembershipApplicationService
$this->sendResultNotification($application, $resultLetter, $plainPassword); $this->sendResultNotification($application, $resultLetter, $plainPassword);
$application->reviews()->create([
'stage' => ReviewStage::Completion,
'decision' => $application->board_result,
'remarks' => sprintf(
'Rujukan mesyuarat lembaga: %s, Tarikh mesyuarat: %s',
$boardMeetingReference,
$boardMeetingDate,
),
'reviewer_id' => $processedBy->id,
'reviewed_at' => now(),
]);
$application->update([ $application->update([
'status' => ApplicationStatus::Completed, 'status' => ApplicationStatus::Completed,
'completed_at' => now()->toDateTimeString(), 'completed_at' => now()->toDateTimeString(),
@@ -302,12 +310,8 @@ class MembershipApplicationService
* failed: list<array{id: string, application_number: string|null, message: string}> * failed: list<array{id: string, application_number: string|null, message: string}>
* } * }
*/ */
public function completeBatch( public function completeBatch(array $applicationIds, User $processedBy, string $boardMeetingReference, string $boardMeetingDate): array
array $applicationIds, {
User $processedBy,
string $boardMeetingReference,
string $boardMeetingDate,
): array {
$succeeded = []; $succeeded = [];
$failed = []; $failed = [];
@@ -440,7 +444,7 @@ class MembershipApplicationService
'application' => $application, 'application' => $application,
'applicant' => $applicant, 'applicant' => $applicant,
'boardMeetingReference' => $boardMeetingReference, 'boardMeetingReference' => $boardMeetingReference,
'boardMeetingDate' => Carbon::parse($boardMeetingDate)->translatedFormat('d F Y'), 'boardMeetingDate' => Carbon::parse($boardMeetingDate)->locale('ms')->translatedFormat('d F Y'),
'isPassed' => $isPassed, 'isPassed' => $isPassed,
'letterSubject' => $isPassed 'letterSubject' => $isPassed
? 'KELULUSAN PERMOHONAN MENJADI ANGGOTA KOPERASI PERMODALAN KELANTAN BERHAD (KoPKB)' ? 'KELULUSAN PERMOHONAN MENJADI ANGGOTA KOPERASI PERMODALAN KELANTAN BERHAD (KoPKB)'
@@ -122,7 +122,7 @@ class UserController extends BaseCrudController
'ic_number' => 'sometimes|string|max:255|unique:users,ic_number,'.$user->id, 'ic_number' => 'sometimes|string|max:255|unique:users,ic_number,'.$user->id,
'position' => 'sometimes|string|max:255', 'position' => 'sometimes|string|max:255',
'phone_number' => 'sometimes|string|max:255', 'phone_number' => 'sometimes|string|max:255',
'image' => 'sometimes|image|mimes:jpeg,png,jpg,gif|max:2048', 'image' => 'sometimes|image|mimes:jpeg,png,jpg,gif|max:5120',
'image_url' => 'sometimes|string|max:255', 'image_url' => 'sometimes|string|max:255',
'gender' => 'sometimes|string|max:255', 'gender' => 'sometimes|string|max:255',
'marriage_status' => 'sometimes|string|max:255', 'marriage_status' => 'sometimes|string|max:255',
+23 -11
View File
@@ -94,19 +94,31 @@ class FortifyServiceProvider extends ServiceProvider
Fortify::authenticateUsing(function (Request $request) { Fortify::authenticateUsing(function (Request $request) {
$user = User::where('email', $request->email)->first(); $user = User::where('email', $request->email)->first();
$invalidCredentialsMessage = 'Emel atau kata laluan tidak sah.';
// Bypass password check in local or development environments
$shouldBypassPassword = config('app.env', 'local');
if ($user && ($shouldBypassPassword || Hash::check($request->password, $user->password))) {
if (! $user->canAuthenticate()) {
throw ValidationException::withMessages([
'email' => [$user->getLoginRestrictionMessage()],
]);
}
return $user; $failLogin = function () use ($invalidCredentialsMessage): never {
throw ValidationException::withMessages([
'email' => [$invalidCredentialsMessage],
]);
};
if (! $user) {
$failLogin();
} }
$shouldBypassPassword = app()->environment('local');
if (! $shouldBypassPassword && ! Hash::check($request->password, $user->password)) {
$failLogin();
}
if (! $user->canAuthenticate()) {
throw ValidationException::withMessages([
'email' => [$user->getLoginRestrictionMessage()],
]);
}
return $user;
}); });
} }
} }
+5 -2
View File
@@ -59,10 +59,13 @@ return Application::configure(basePath: dirname(__DIR__))
// Handle Validation exceptions (422 errors) // Handle Validation exceptions (422 errors)
$exceptions->render(function (Illuminate\Validation\ValidationException $e, $request) { $exceptions->render(function (Illuminate\Validation\ValidationException $e, $request) {
if ($request->expectsJson()) { if ($request->expectsJson()) {
$errors = $e->errors();
$firstMessage = collect($errors)->flatten()->first() ?? 'Data tidak sah.';
return response()->json([ return response()->json([
'success' => false, 'success' => false,
'message' => 'Data tidak sah.', 'message' => $firstMessage,
'errors' => $e->errors(), 'errors' => $errors,
], 422); ], 422);
} }
}); });
+3
View File
@@ -163,6 +163,9 @@ COPY --from=builder /usr/local/bin/docker-php-ext-* /usr/local/bin/
# Use the recommended production PHP configuration # Use the recommended production PHP configuration
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
# Custom PHP overrides (upload limits, etc.) — official image loads conf.d/*.ini
COPY be/docker/common/unified/php/uploads.ini /usr/local/etc/php/conf.d/uploads.ini
# Enable PHP-FPM status page via separate config file (avoid modifying zz-docker.conf) # Enable PHP-FPM status page via separate config file (avoid modifying zz-docker.conf)
RUN echo '[www]' > /usr/local/etc/php-fpm.d/zzz-status.conf && \ RUN echo '[www]' > /usr/local/etc/php-fpm.d/zzz-status.conf && \
echo 'pm.status_path = /status' >> /usr/local/etc/php-fpm.d/zzz-status.conf echo 'pm.status_path = /status' >> /usr/local/etc/php-fpm.d/zzz-status.conf
@@ -9,6 +9,8 @@ http {
sendfile on; sendfile on;
keepalive_timeout 65; keepalive_timeout 65;
client_max_body_size 128M;
# Logging # Logging
access_log /var/log/nginx/access.log; access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log; error_log /var/log/nginx/error.log;
+3
View File
@@ -0,0 +1,3 @@
; Keep above Laravel validation (max:10240 = 10MB)
upload_max_filesize = 12M
post_max_size = 12M
+2
View File
@@ -8,6 +8,8 @@ http {
sendfile on; sendfile on;
keepalive_timeout 65; keepalive_timeout 65;
client_max_body_size 128M;
access_log /var/log/nginx/access.log; access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log; error_log /var/log/nginx/error.log;
+2
View File
@@ -9,6 +9,8 @@ http {
sendfile on; sendfile on;
keepalive_timeout 65; keepalive_timeout 65;
client_max_body_size 128M;
# Logging # Logging
access_log /var/log/nginx/access.log; access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log; error_log /var/log/nginx/error.log;
+12 -4
View File
@@ -1,7 +1,7 @@
export const EMPLOYERS = [ export const EMPLOYERS = [
{ {
name: 'Infra Quest Sdn. Bhd. (IQSB)', name: 'Infra Quest Sdn Bhd (IQSB)',
address: 'Lot 1045, Jalan Dato Lundang, 15200 Kota Bharu, Kelantan', address: "Lot 1045, Jalan Dato' Lundang, 15200 Kota Bharu, Kelantan",
}, },
{ {
name: 'Permodalan Kelantan Berhad (PKB)', name: 'Permodalan Kelantan Berhad (PKB)',
@@ -14,11 +14,19 @@ export const EMPLOYERS = [
'Lot Pt 448, Tingkat 1,Jalan Kuala Krai, Batu 3, Wakaf Che Yeh, 15150 Kota Bharu, Kelantan.', 'Lot Pt 448, Tingkat 1,Jalan Kuala Krai, Batu 3, Wakaf Che Yeh, 15150 Kota Bharu, Kelantan.',
}, },
{ {
name: "An-Nisa'", name: "Pusat Perubatan An-Nisa'",
address: 'Jln Sultan Ibrahim, Bandar Kota Bharu, 15050 Kota Bharu, Kelantan.', address: 'Jln Sultan Ibrahim, Bandar Kota Bharu, 15050 Kota Bharu, Kelantan.',
}, },
{ {
name: 'Kel Infra Sdn. Bhd.', name: 'Kel Infra Sdn Bhd (KISB)',
address: 'Tingkat 2 Menara Perbadanan, Jalan Tengku Petra Semerak, 15000 Kota Bharu, Kelantan.', address: 'Tingkat 2 Menara Perbadanan, Jalan Tengku Petra Semerak, 15000 Kota Bharu, Kelantan.',
}, },
{
name: 'Xtra Biz Sdn Bhd (XBSB)',
address: "-",
},
{
name: 'Pesara',
address: '-'
}
] as const ] as const
@@ -30,6 +30,7 @@ import type {
MembershipApplicationReferenceForm, MembershipApplicationReferenceForm,
} from '../types/membership-application.types' } from '../types/membership-application.types'
import illustrationUrl from '@/assets/images/logo.svg' import illustrationUrl from '@/assets/images/logo.svg'
import { EMPLOYERS } from '@/constants/employers'
const MAX_FILE_SIZE_MB = 10 const MAX_FILE_SIZE_MB = 10
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024 const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024
@@ -78,26 +79,6 @@ const RELATIONSHIP_OPTIONS: SelectOption[] = [
{ label: 'Lain-lain', value: 'Lain-lain' }, { label: 'Lain-lain', value: 'Lain-lain' },
] ]
// TODO: replace with API lookup
const EMPLOYERS = [
{
name: 'Infra Quest Sdn. Bhd. (IQSB)',
address: 'Lot 1045, Jalan Dato Lundang, 15200 Kota Bharu, Kelantan',
},
{
name: 'Permodalan Kelantan Berhad (PKB)',
address: 'Permodalan Kelantan Berhad, Tingkat 4, Wisma Permodalan Kelantan Berhad, Jalan Maju, 15000 Kota Bharu Kelantan',
},
{
name: 'Koperasi Permodalan Kelantan Berhad (KOPKB)',
address: 'Lot Pt 448, Tingkat 1,Jalan Kuala Krai, Batu 3, Wakaf Che Yeh, 15150 Kota Bharu, Kelantan.',
},
{
name: "An-Nisa'",
address: 'Jln Sultan Ibrahim, Bandar Kota Bharu, 15050 Kota Bharu, Kelantan.',
},
] as const
const EMPLOYER_OPTIONS: SelectOption[] = EMPLOYERS.map((employer) => ({ const EMPLOYER_OPTIONS: SelectOption[] = EMPLOYERS.map((employer) => ({
label: employer.name, label: employer.name,
value: employer.name, value: employer.name,
@@ -319,7 +300,7 @@ const stepTitle = computed(() => {
case 1: case 1:
return 'Maklumat Peribadi' return 'Maklumat Peribadi'
case 2: case 2:
return 'Hubungan & Alamat' return 'Hubungan & Alamat Rumah Semasa'
case 3: case 3:
return 'Maklumat Pekerjaan & Caruman' return 'Maklumat Pekerjaan & Caruman'
case 4: case 4:
@@ -711,7 +692,12 @@ function stepLabelClass(stepId: number) {
<template v-else-if="currentStep === 2"> <template v-else-if="currentStep === 2">
<Field class="col-span-12"> <Field class="col-span-12">
<FieldLabel for="address">Alamat</FieldLabel> <FieldLabel for="address">Alamat</FieldLabel>
<Textarea id="address" v-model="form.applicant.address" rows="3" /> <Textarea
id="address"
v-model="form.applicant.address"
rows="3"
placeholder="Lot Pt 448, Tingkat 1, Jalan Kuala Krai, Batu 3, Wakaf Che Yeh, 15150 Kota Bharu, Kelantan."
/>
<FieldError v-if="fieldErrors['applicant.address']">{{ fieldErrors['applicant.address'] }}</FieldError> <FieldError v-if="fieldErrors['applicant.address']">{{ fieldErrors['applicant.address'] }}</FieldError>
</Field> </Field>
<Field class="col-span-12 sm:col-span-4"> <Field class="col-span-12 sm:col-span-4">
@@ -940,7 +926,7 @@ function stepLabelClass(stepId: number) {
<Input id="employer_letter" type="file" accept=".pdf,.jpg,.jpeg,.png" <Input id="employer_letter" type="file" accept=".pdf,.jpg,.jpeg,.png"
@change="handleFileChange('employer_letter', $event)" /> @change="handleFileChange('employer_letter', $event)" />
<FieldError v-if="fieldErrors['documents.employer_letter']">{{ fieldErrors['documents.employer_letter'] <FieldError v-if="fieldErrors['documents.employer_letter']">{{ fieldErrors['documents.employer_letter']
}}</FieldError> }}</FieldError>
</Field> </Field>
<div class="col-span-12 mt-2 rounded-lg border border-foreground/10 bg-foreground/5 p-4"> <div class="col-span-12 mt-2 rounded-lg border border-foreground/10 bg-foreground/5 p-4">
@@ -65,7 +65,7 @@ const DOCUMENT_TYPE_LABELS: Record<string, string> = {
[RESULT_LETTER_DOCUMENT_TYPE]: 'Surat Keputusan', [RESULT_LETTER_DOCUMENT_TYPE]: 'Surat Keputusan',
} }
const BOARD_MEETING_REFERENCE_PREFIX = 'Mesyuarat Lembaga Bil.' const BOARD_MEETING_REFERENCE_PREFIX = 'Mesyuarat Lembaga KoPKB Bil. '
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@@ -168,14 +168,14 @@ const confirmDialogTitle = computed(() => {
if (pendingAction.value.type === 'management') { if (pendingAction.value.type === 'management') {
return pendingAction.value.decision === 'APPROVED' return pendingAction.value.decision === 'APPROVED'
? 'Luluskan Permohonan?' ? 'Semak Permohonan?'
: 'Tolak Permohonan?' : 'Tolak Permohonan?'
} }
if (pendingAction.value.type === 'board') { if (pendingAction.value.type === 'board') {
return pendingAction.value.decision === 'PASS' return pendingAction.value.decision === 'PASS'
? 'Luluskan?' ? 'Keputusan ALK?'
: 'Gagalkan?' : 'Keputusan ALK?'
} }
return 'Selesaikan Permohonan?' return 'Selesaikan Permohonan?'
@@ -186,7 +186,7 @@ const confirmDialogDescription = computed(() => {
if (pendingAction.value.type === 'management') { if (pendingAction.value.type === 'management') {
return pendingAction.value.decision === 'APPROVED' return pendingAction.value.decision === 'APPROVED'
? 'Permohonan akan dihantar ke mesyuarat ALK.' ? 'Permohonan akan dihantar ke peringkat keputusan ALK.'
: 'Permohonan akan ditolak pada peringkat pentadbiran.' : 'Permohonan akan ditolak pada peringkat pentadbiran.'
} }
@@ -325,6 +325,7 @@ function getReference(type: 'PROPOSER' | 'SUPPORTER'): MembershipApplicationRefe
function reviewStageLabel(stage: string): string { function reviewStageLabel(stage: string): string {
if (stage === 'MANAGEMENT') return 'Semakan Pentadbiran' if (stage === 'MANAGEMENT') return 'Semakan Pentadbiran'
if (stage === 'BOARD') return 'Keputusan Ahli Lembaga Koperasi (ALK)' if (stage === 'BOARD') return 'Keputusan Ahli Lembaga Koperasi (ALK)'
if (stage === 'COMPLETION') return 'Penyelesaian & Makluman Keputusan'
return stage return stage
} }
@@ -332,11 +333,11 @@ function reviewDecisionLabel(decision: string | null, stage: string): string {
if (!decision) return '-' if (!decision) return '-'
if (stage === 'MANAGEMENT') { if (stage === 'MANAGEMENT') {
if (decision === 'APPROVED') return 'Diluluskan' if (decision === 'APPROVED') return 'Disemak'
if (decision === 'REJECTED') return 'Ditolak' if (decision === 'REJECTED') return 'Ditolak'
} }
if (stage === 'BOARD') { if (stage === 'BOARD' || stage === 'COMPLETION') {
if (decision === 'PASS') return 'Lulus' if (decision === 'PASS') return 'Lulus'
if (decision === 'FAIL') return 'Gagal' if (decision === 'FAIL') return 'Gagal'
} }
@@ -647,7 +648,7 @@ onUnmounted(() => {
<Box v-if="showManagementReview" class="p-5 sm:p-6"> <Box v-if="showManagementReview" class="p-5 sm:p-6">
<div class="font-medium">Semakan Pentadbiran</div> <div class="font-medium">Semakan Pentadbiran</div>
<p class="mt-1 text-sm opacity-70"> <p class="mt-1 text-sm opacity-70">
Luluskan atau tolak permohonan pada peringkat pentadbiran. Semak atau tolak permohonan pada peringkat pentadbiran.
</p> </p>
<Field class="mt-4"> <Field class="mt-4">
<FieldLabel for="management-remarks">Catatan</FieldLabel> <FieldLabel for="management-remarks">Catatan</FieldLabel>
@@ -657,7 +658,7 @@ onUnmounted(() => {
<div class="mt-4 flex flex-wrap gap-2"> <div class="mt-4 flex flex-wrap gap-2">
<Button type="button" variant="success" :disabled="reviewSubmitting || completeSubmitting" <Button type="button" variant="success" :disabled="reviewSubmitting || completeSubmitting"
@click="openConfirmAction({ type: 'management', decision: 'APPROVED' })"> @click="openConfirmAction({ type: 'management', decision: 'APPROVED' })">
Luluskan Semak
</Button> </Button>
<Button type="button" variant="danger" look="outline" :disabled="reviewSubmitting || completeSubmitting" <Button type="button" variant="danger" look="outline" :disabled="reviewSubmitting || completeSubmitting"
@click="openConfirmAction({ type: 'management', decision: 'REJECTED' })"> @click="openConfirmAction({ type: 'management', decision: 'REJECTED' })">
@@ -667,9 +668,9 @@ onUnmounted(() => {
</Box> </Box>
<Box v-if="showBoardReview" class="p-5 sm:p-6"> <Box v-if="showBoardReview" class="p-5 sm:p-6">
<div class="font-medium">Semakan Lembaga</div> <div class="font-medium">Keputusan Ahli Lembaga Koperasi (ALK)</div>
<p class="mt-1 text-sm opacity-70"> <p class="mt-1 text-sm opacity-70">
Luluskan atau gagalkan permohonan pada peringkat lembaga. Luluskan atau gagalkan permohonan pada peringkat ALK.
</p> </p>
<Field class="mt-4"> <Field class="mt-4">
<FieldLabel for="board-remarks">Catatan</FieldLabel> <FieldLabel for="board-remarks">Catatan</FieldLabel>
@@ -1030,8 +1031,8 @@ onUnmounted(() => {
</Field> </Field>
<Field v-if="pendingAction?.type === 'complete'" class="mt-4 text-left"> <Field v-if="pendingAction?.type === 'complete'" class="mt-4 text-left">
<FieldLabel for="detail-board-meeting-date">Tarikh Mesyuarat Lembaga</FieldLabel> <FieldLabel for="detail-board-meeting-date">Tarikh Mesyuarat Lembaga</FieldLabel>
<Input id="detail-board-meeting-date" v-model="boardMeetingDate" type="date" <Input id="detail-board-meeting-date" v-model="boardMeetingDate" type="date" :disabled="completeSubmitting"
:disabled="completeSubmitting" @input="boardMeetingDateError = null" /> @input="boardMeetingDateError = null" />
<FieldError v-if="boardMeetingDateError">{{ boardMeetingDateError }}</FieldError> <FieldError v-if="boardMeetingDateError">{{ boardMeetingDateError }}</FieldError>
</Field> </Field>
</div> </div>
@@ -58,6 +58,7 @@ import { ADMIN_ATTACHMENT_DOCUMENT_TYPE } from '../types/membership-application.
import { import {
APPLICANT_DOCUMENT_UPLOAD_TYPES, APPLICANT_DOCUMENT_UPLOAD_TYPES,
DOCUMENT_TYPE_LABELS, DOCUMENT_TYPE_LABELS,
EMPLOYER_OPTIONS,
GENDER_OPTIONS, GENDER_OPTIONS,
MARRIAGE_STATUS_OPTIONS, MARRIAGE_STATUS_OPTIONS,
RELATIONSHIP_OPTIONS, RELATIONSHIP_OPTIONS,
@@ -67,6 +68,7 @@ import {
createEmptyFormState, createEmptyFormState,
createSelectCollection, createSelectCollection,
detailToFormState, detailToFormState,
getEmployerAddress,
labelToApiValue, labelToApiValue,
} from '../utils/membership-application-form.utils' } from '../utils/membership-application-form.utils'
@@ -112,6 +114,7 @@ const referenceLookupLoading = reactive({
const genderCollection = createSelectCollection(GENDER_OPTIONS) const genderCollection = createSelectCollection(GENDER_OPTIONS)
const marriageStatusCollection = createSelectCollection(MARRIAGE_STATUS_OPTIONS) const marriageStatusCollection = createSelectCollection(MARRIAGE_STATUS_OPTIONS)
const relationshipCollection = createSelectCollection(RELATIONSHIP_OPTIONS) const relationshipCollection = createSelectCollection(RELATIONSHIP_OPTIONS)
const employerCollection = createSelectCollection(EMPLOYER_OPTIONS)
const canEdit = computed( const canEdit = computed(
() => () =>
@@ -123,6 +126,7 @@ const genderInitial = computed(() => apiValueToLabel(GENDER_OPTIONS, form.applic
const marriageStatusInitial = computed(() => const marriageStatusInitial = computed(() =>
apiValueToLabel(MARRIAGE_STATUS_OPTIONS, form.applicant.marriage_status), apiValueToLabel(MARRIAGE_STATUS_OPTIONS, form.applicant.marriage_status),
) )
const employerInitial = computed(() => apiValueToLabel(EMPLOYER_OPTIONS, form.applicant.employer_name))
const workflowProgress = computed(() => const workflowProgress = computed(() =>
application.value ? getWorkflowProgress(application.value.status) : null, application.value ? getWorkflowProgress(application.value.status) : null,
@@ -260,6 +264,7 @@ const isPreviewPdf = computed(() => (previewDocument.value ? isPdfDocument(previ
function reviewStageLabel(stage: string): string { function reviewStageLabel(stage: string): string {
if (stage === 'MANAGEMENT') return 'Semakan Pentadbiran' if (stage === 'MANAGEMENT') return 'Semakan Pentadbiran'
if (stage === 'BOARD') return 'Keputusan Ahli Lembaga Koperasi (ALK)' if (stage === 'BOARD') return 'Keputusan Ahli Lembaga Koperasi (ALK)'
if (stage === 'COMPLETION') return 'Penyelesaian & Makluman Keputusan'
return stage return stage
} }
@@ -271,7 +276,7 @@ function reviewDecisionLabel(decision: string | null, stage: string): string {
if (decision === 'REJECTED') return 'Ditolak' if (decision === 'REJECTED') return 'Ditolak'
} }
if (stage === 'BOARD') { if (stage === 'BOARD' || stage === 'COMPLETION') {
if (decision === 'PASS') return 'Lulus' if (decision === 'PASS') return 'Lulus'
if (decision === 'FAIL') return 'Gagal' if (decision === 'FAIL') return 'Gagal'
} }
@@ -308,6 +313,14 @@ function setMarriageStatusValue(details: { value: string[] }) {
delete fieldErrors['applicant.marriage_status'] delete fieldErrors['applicant.marriage_status']
} }
function setEmployerValue(details: { value: string[] }) {
const employerName = labelToApiValue(EMPLOYER_OPTIONS, details.value[0])
form.applicant.employer_name = employerName
form.applicant.employer_address = getEmployerAddress(employerName)
delete fieldErrors['applicant.employer_name']
delete fieldErrors['applicant.employer_address']
}
function setHeirRelationshipValue(index: number, details: { value: string[] }) { function setHeirRelationshipValue(index: number, details: { value: string[] }) {
const heir = form.heirs[index] const heir = form.heirs[index]
if (!heir) return if (!heir) return
@@ -858,8 +871,24 @@ onUnmounted(() => {
<TabsContent value="employment" class="mt-6"> <TabsContent value="employment" class="mt-6">
<div class="grid grid-cols-12 gap-4 gap-y-5"> <div class="grid grid-cols-12 gap-4 gap-y-5">
<Field class="col-span-12 sm:col-span-6"> <Field class="col-span-12 sm:col-span-6">
<FieldLabel for="employer_name">Nama Majikan</FieldLabel> <FieldLabel>Nama Majikan</FieldLabel>
<Input id="employer_name" v-model="form.applicant.employer_name" type="text" :disabled="!canEdit" /> <SelectRoot :key="`employer-${form.applicant.employer_name}`" class="w-full"
:collection="employerCollection" :default-value="employerInitial" :disabled="!canEdit"
@value-change="setEmployerValue">
<SelectControl>
<SelectTrigger :aria-invalid="!!fieldErrors['applicant.employer_name']">
<SelectValueText placeholder="Pilih majikan" />
</SelectTrigger>
</SelectControl>
<SelectContent>
<SelectItemGroup>
<SelectItemGroupLabel>Nama Majikan</SelectItemGroupLabel>
<SelectItem v-for="item in employerCollection.items" :key="item.label" :item="item">
<SelectItemText>{{ item.label }}</SelectItemText>
</SelectItem>
</SelectItemGroup>
</SelectContent>
</SelectRoot>
<FieldError v-if="fieldErrors['applicant.employer_name']">{{ fieldErrors['applicant.employer_name'] }} <FieldError v-if="fieldErrors['applicant.employer_name']">{{ fieldErrors['applicant.employer_name'] }}
</FieldError> </FieldError>
</Field> </Field>
@@ -867,13 +896,13 @@ onUnmounted(() => {
<FieldLabel for="current_position">Jawatan Semasa</FieldLabel> <FieldLabel for="current_position">Jawatan Semasa</FieldLabel>
<Input id="current_position" v-model="form.applicant.current_position" type="text" :disabled="!canEdit" /> <Input id="current_position" v-model="form.applicant.current_position" type="text" :disabled="!canEdit" />
<FieldError v-if="fieldErrors['applicant.current_position']">{{ fieldErrors['applicant.current_position'] <FieldError v-if="fieldErrors['applicant.current_position']">{{ fieldErrors['applicant.current_position']
}}</FieldError> }}</FieldError>
</Field> </Field>
<Field class="col-span-12"> <Field class="col-span-12">
<FieldLabel for="employer_address">Alamat Majikan</FieldLabel> <FieldLabel for="employer_address">Alamat Majikan</FieldLabel>
<Textarea id="employer_address" v-model="form.applicant.employer_address" rows="3" :disabled="!canEdit" /> <Textarea id="employer_address" v-model="form.applicant.employer_address" rows="3" disabled />
<FieldError v-if="fieldErrors['applicant.employer_address']">{{ fieldErrors['applicant.employer_address'] <FieldError v-if="fieldErrors['applicant.employer_address']">{{ fieldErrors['applicant.employer_address']
}}</FieldError> }}</FieldError>
</Field> </Field>
<Field class="col-span-12 sm:col-span-4"> <Field class="col-span-12 sm:col-span-4">
<FieldLabel for="start_work_date">Tarikh Mula Berkhidmat</FieldLabel> <FieldLabel for="start_work_date">Tarikh Mula Berkhidmat</FieldLabel>
@@ -919,7 +948,7 @@ onUnmounted(() => {
<FieldLabel :for="`heir-ic-${index}`">No. Kad Pengenalan</FieldLabel> <FieldLabel :for="`heir-ic-${index}`">No. Kad Pengenalan</FieldLabel>
<Input :id="`heir-ic-${index}`" v-model="heir.ic_number" type="text" :disabled="!canEdit" /> <Input :id="`heir-ic-${index}`" v-model="heir.ic_number" type="text" :disabled="!canEdit" />
<FieldError v-if="fieldErrors[`heirs.${index}.ic_number`]">{{ fieldErrors[`heirs.${index}.ic_number`] <FieldError v-if="fieldErrors[`heirs.${index}.ic_number`]">{{ fieldErrors[`heirs.${index}.ic_number`]
}}</FieldError> }}</FieldError>
</Field> </Field>
<Field class="col-span-12 sm:col-span-6"> <Field class="col-span-12 sm:col-span-6">
<FieldLabel>Hubungan</FieldLabel> <FieldLabel>Hubungan</FieldLabel>
@@ -164,7 +164,7 @@ export interface MembershipApplicationDocumentDetail {
export interface MembershipApplicationReviewDetail { export interface MembershipApplicationReviewDetail {
id: string id: string
stage: 'MANAGEMENT' | 'BOARD' stage: 'MANAGEMENT' | 'BOARD' | 'COMPLETION'
decision: string | null decision: string | null
remarks: string | null remarks: string | null
reviewer: { id: string; name: string } | null reviewer: { id: string; name: string } | null
@@ -45,7 +45,7 @@ export function reviewDecisionBadgeVariant(
if (decision === 'REJECTED') return 'danger' if (decision === 'REJECTED') return 'danger'
} }
if (stage === 'BOARD') { if (stage === 'BOARD' || stage === 'COMPLETION') {
if (decision === 'PASS') return 'success' if (decision === 'PASS') return 'success'
if (decision === 'FAIL') return 'danger' if (decision === 'FAIL') return 'danger'
} }
@@ -59,7 +59,7 @@ export function reviewDecisionBadgeLook(
): BadgeVariants['look'] { ): BadgeVariants['look'] {
if ( if (
(stage === 'MANAGEMENT' && (decision === 'APPROVED' || decision === 'REJECTED')) || (stage === 'MANAGEMENT' && (decision === 'APPROVED' || decision === 'REJECTED')) ||
(stage === 'BOARD' && (decision === 'PASS' || decision === 'FAIL')) ((stage === 'BOARD' || stage === 'COMPLETION') && (decision === 'PASS' || decision === 'FAIL'))
) { ) {
return undefined return undefined
} }
@@ -1,5 +1,6 @@
import dayjs from 'dayjs' import dayjs from 'dayjs'
import * as select from '@zag-js/select' import * as select from '@zag-js/select'
import { EMPLOYERS } from '@/constants/employers'
import type { import type {
MembershipApplicationDetail, MembershipApplicationDetail,
MembershipApplicationDocumentsForm, MembershipApplicationDocumentsForm,
@@ -35,6 +36,15 @@ export const RELATIONSHIP_OPTIONS: SelectOption[] = [
{ label: 'Lain-lain', value: 'Lain-lain' }, { label: 'Lain-lain', value: 'Lain-lain' },
] ]
export const EMPLOYER_OPTIONS: SelectOption[] = EMPLOYERS.map((employer) => ({
label: employer.name,
value: employer.name,
}))
export function getEmployerAddress(name: string): string {
return EMPLOYERS.find((employer) => employer.name === name)?.address ?? ''
}
export const DOCUMENT_TYPE_LABELS: Record<string, string> = { export const DOCUMENT_TYPE_LABELS: Record<string, string> = {
ic_copy: 'Salinan Kad Pengenalan', ic_copy: 'Salinan Kad Pengenalan',
photo: 'Gambar Passport', photo: 'Gambar Passport',
+5 -30
View File
@@ -28,6 +28,7 @@ import {
updateEmployment, updateEmployment,
} from '@/modules/profile/services/employment.service' } from '@/modules/profile/services/employment.service'
import type { Employment, EmploymentPayload } from '@/modules/profile/types/employment.types' import type { Employment, EmploymentPayload } from '@/modules/profile/types/employment.types'
import { EMPLOYERS } from '@/constants/employers'
defineProps<{ defineProps<{
embedded?: boolean embedded?: boolean
@@ -65,36 +66,10 @@ type SelectOption = { label: string; value: string }
const EMPLOYMENT_TYPE_OPTIONS: SelectOption[] = [ const EMPLOYMENT_TYPE_OPTIONS: SelectOption[] = [
{ label: 'Tetap', value: 'Permanent' }, { label: 'Tetap', value: 'Permanent' },
{ label: 'Kontrak', value: 'Contract' }, { label: 'Kontrak', value: 'Contract' },
{ label: 'Latihan Industri', value: 'Internship' }, { label: 'Percubaan', value: 'Trial' },
{ label: 'Freelance', value: 'Freelance' }, { label: 'Sambilan', value: 'Part-time' },
] ]
// TODO: replace with API lookup
const EMPLOYERS = [
{
name: 'Infra Quest Sdn. Bhd. (IQSB)',
address: 'Lot 1045, Jalan Dato Lundang, 15200 Kota Bharu, Kelantan',
},
{
name: 'Permodalan Kelantan Berhad (PKB)',
address:
'Permodalan Kelantan Berhad, Tingkat 4, Wisma Permodalan Kelantan Berhad, Jalan Maju, 15000 Kota Bharu Kelantan',
},
{
name: 'Koperasi Permodalan Kelantan Berhad (KOPKB)',
address:
'Lot Pt 448, Tingkat 1,Jalan Kuala Krai, Batu 3, Wakaf Che Yeh, 15150 Kota Bharu, Kelantan.',
},
{
name: "An-Nisa'",
address: 'Jln Sultan Ibrahim, Bandar Kota Bharu, 15050 Kota Bharu, Kelantan.',
},
{
name: "Kel Infra Sdn. Bhd.",
address: "Tingkat 2 Menara Perbadanan, Jalan Tengku Petra Semerak, 15000 Kota Bharu, Kelantan.",
}
] as const
const COMPANY_OPTIONS: SelectOption[] = EMPLOYERS.map((employer) => ({ const COMPANY_OPTIONS: SelectOption[] = EMPLOYERS.map((employer) => ({
label: employer.name, label: employer.name,
value: employer.name, value: employer.name,
@@ -574,13 +549,13 @@ onMounted(async () => {
<FieldError v-if="employmentErrors.salary">{{ employmentErrors.salary }}</FieldError> <FieldError v-if="employmentErrors.salary">{{ employmentErrors.salary }}</FieldError>
</Field> </Field>
<Field> <Field>
<FieldLabel for="employment-start-date">Tarikh Mula</FieldLabel> <FieldLabel for="employment-start-date">Tarikh Mula Perkhidmatan</FieldLabel>
<Input id="employment-start-date" v-model="employmentForm.start_date" type="date" <Input id="employment-start-date" v-model="employmentForm.start_date" type="date"
:aria-invalid="!!employmentErrors.start_date" @input="clearEmploymentFieldError('start_date')" /> :aria-invalid="!!employmentErrors.start_date" @input="clearEmploymentFieldError('start_date')" />
<FieldError v-if="employmentErrors.start_date">{{ employmentErrors.start_date }}</FieldError> <FieldError v-if="employmentErrors.start_date">{{ employmentErrors.start_date }}</FieldError>
</Field> </Field>
<Field> <Field>
<FieldLabel for="employment-end-date">Tarikh Tamat</FieldLabel> <FieldLabel for="employment-end-date">Tarikh Tamat Perkhidmatan</FieldLabel>
<Input id="employment-end-date" v-model="employmentForm.end_date" type="date" <Input id="employment-end-date" v-model="employmentForm.end_date" type="date"
:disabled="employmentForm.is_current" :aria-invalid="!!employmentErrors.end_date" :disabled="employmentForm.is_current" :aria-invalid="!!employmentErrors.end_date"
@input="clearEmploymentFieldError('end_date')" /> @input="clearEmploymentFieldError('end_date')" />
@@ -190,6 +190,9 @@ onMounted(async () => {
No. {{ authStore.userMemberNumber }} No. {{ authStore.userMemberNumber }}
</Badge> </Badge>
</div> </div>
<p class="mt-2 text-xs opacity-70">
Gambar profil: maksimum 2 MB (JPEG, PNG, GIF).
</p>
</div> </div>
</div> </div>
+39 -141
View File
@@ -32,6 +32,7 @@ import {
updateUserEmployment, updateUserEmployment,
} from '../services/userEmployment.service' } from '../services/userEmployment.service'
import { getUser, updateUser } from '../services/user.service' import { getUser, updateUser } from '../services/user.service'
import { EMPLOYERS } from '@/constants/employers'
type SelectOption = { label: string; value: string } type SelectOption = { label: string; value: string }
@@ -62,35 +63,10 @@ const MEMBER_TYPE_OPTIONS: SelectOption[] = [
const EMPLOYMENT_TYPE_OPTIONS: SelectOption[] = [ const EMPLOYMENT_TYPE_OPTIONS: SelectOption[] = [
{ label: 'Tetap', value: 'Permanent' }, { label: 'Tetap', value: 'Permanent' },
{ label: 'Kontrak', value: 'Contract' }, { label: 'Kontrak', value: 'Contract' },
{ label: 'Latihan Industri', value: 'Internship' }, { label: 'Percubaan', value: 'Trial' },
{ label: 'Freelance', value: 'Freelance' }, { label: 'Sambilan', value: 'Part-time' },
] ]
const EMPLOYERS = [
{
name: 'Infra Quest Sdn. Bhd. (IQSB)',
address: 'Lot 1045, Jalan Dato Lundang, 15200 Kota Bharu, Kelantan',
},
{
name: 'Permodalan Kelantan Berhad (PKB)',
address:
'Permodalan Kelantan Berhad, Tingkat 4, Wisma Permodalan Kelantan Berhad, Jalan Maju, 15000 Kota Bharu Kelantan',
},
{
name: 'Koperasi Permodalan Kelantan Berhad (KOPKB)',
address:
'Lot Pt 448, Tingkat 1,Jalan Kuala Krai, Batu 3, Wakaf Che Yeh, 15150 Kota Bharu, Kelantan.',
},
{
name: "An-Nisa'",
address: 'Jln Sultan Ibrahim, Bandar Kota Bharu, 15050 Kota Bharu, Kelantan.',
},
{
name: 'Kel Infra Sdn. Bhd.',
address: 'Tingkat 2 Menara Perbadanan, Jalan Tengku Petra Semerak, 15000 Kota Bharu, Kelantan.',
},
] as const
const COMPANY_OPTIONS: SelectOption[] = EMPLOYERS.map((employer) => ({ const COMPANY_OPTIONS: SelectOption[] = EMPLOYERS.map((employer) => ({
label: employer.name, label: employer.name,
value: employer.name, value: employer.name,
@@ -747,13 +723,7 @@ onMounted(() => {
<Field v-if="isInactiveStatus"> <Field v-if="isInactiveStatus">
<FieldLabel for="user-leave-date">Tarikh Berhenti Menjadi Anggota</FieldLabel> <FieldLabel for="user-leave-date">Tarikh Berhenti Menjadi Anggota</FieldLabel>
<Input <Input id="user-leave-date" v-model="form.leave_date" class="w-full" type="date" :disabled="formDisabled" />
id="user-leave-date"
v-model="form.leave_date"
class="w-full"
type="date"
:disabled="formDisabled"
/>
</Field> </Field>
<Field> <Field>
@@ -786,11 +756,8 @@ onMounted(() => {
<div v-if="loading" class="text-sm text-slate-500">Memuatkan pekerjaan...</div> <div v-if="loading" class="text-sm text-slate-500">Memuatkan pekerjaan...</div>
<div v-else-if="employments.length" class="space-y-3"> <div v-else-if="employments.length" class="space-y-3">
<div <div v-for="employment in employments" :key="employment.id"
v-for="employment in employments" class="flex flex-col gap-4 rounded-lg border border-foreground/10 p-4 sm:flex-row sm:items-start sm:justify-between">
:key="employment.id"
class="flex flex-col gap-4 rounded-lg border border-foreground/10 p-4 sm:flex-row sm:items-start sm:justify-between"
>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<span class="font-medium text-slate-900">{{ employment.company_name }}</span> <span class="font-medium text-slate-900">{{ employment.company_name }}</span>
@@ -804,46 +771,27 @@ onMounted(() => {
<p class="mt-1 text-sm text-slate-500">{{ formatSalary(employment.salary) }}</p> <p class="mt-1 text-sm text-slate-500">{{ formatSalary(employment.salary) }}</p>
</div> </div>
<div class="flex shrink-0 gap-2"> <div class="flex shrink-0 gap-2">
<Button <Button type="button" variant="ghost" class="border border-foreground/15 shadow-none"
type="button" :disabled="deletingEmploymentId === employment.id || saving" @click="startEditEmployment(employment)">
variant="ghost"
class="border border-foreground/15 shadow-none"
:disabled="deletingEmploymentId === employment.id || saving"
@click="startEditEmployment(employment)"
>
<Lucide class="mr-2 size-4" icon="Pencil" /> <Lucide class="mr-2 size-4" icon="Pencil" />
Kemaskini Kemaskini
</Button> </Button>
<Button <Button type="button" variant="ghost" class="border border-foreground/15 shadow-none text-danger"
type="button" :disabled="deletingEmploymentId === employment.id || saving" @click="onDeleteEmployment(employment)">
variant="ghost" <Lucide class="mr-2 size-4" :icon="deletingEmploymentId === employment.id ? 'LoaderCircle' : 'Trash'"
class="border border-foreground/15 shadow-none text-danger" :class="{ 'animate-spin': deletingEmploymentId === employment.id }" />
:disabled="deletingEmploymentId === employment.id || saving"
@click="onDeleteEmployment(employment)"
>
<Lucide
class="mr-2 size-4"
:icon="deletingEmploymentId === employment.id ? 'LoaderCircle' : 'Trash'"
:class="{ 'animate-spin': deletingEmploymentId === employment.id }"
/>
Padam Padam
</Button> </Button>
</div> </div>
</div> </div>
</div> </div>
<div <div v-else class="rounded-lg border border-dashed border-foreground/15 p-6 text-center text-sm text-slate-500">
v-else
class="rounded-lg border border-dashed border-foreground/15 p-6 text-center text-sm text-slate-500"
>
Tiada pekerjaan direkodkan. Tiada pekerjaan direkodkan.
</div> </div>
<form <form v-if="showEmploymentForm" class="space-y-6 border-t border-foreground/10 pt-6"
v-if="showEmploymentForm" @submit.prevent="onSaveEmployment">
class="space-y-6 border-t border-foreground/10 pt-6"
@submit.prevent="onSaveEmployment"
>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<h4 class="text-base font-semibold text-slate-900"> <h4 class="text-base font-semibold text-slate-900">
@@ -858,14 +806,9 @@ onMounted(() => {
</p> </p>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<Button <Button v-if="isEditingEmployment" type="button" variant="ghost"
v-if="isEditingEmployment" class="border border-foreground/15 shadow-none" :disabled="savingEmployment"
type="button" @click="resetEmploymentForm">
variant="ghost"
class="border border-foreground/15 shadow-none"
:disabled="savingEmployment"
@click="resetEmploymentForm"
>
Batal Batal
</Button> </Button>
<Button type="submit" variant="primary" :disabled="savingEmployment || saving"> <Button type="submit" variant="primary" :disabled="savingEmployment || saving">
@@ -878,14 +821,9 @@ onMounted(() => {
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<Field> <Field>
<FieldLabel>Nama Syarikat</FieldLabel> <FieldLabel>Nama Syarikat</FieldLabel>
<SelectRoot <SelectRoot :key="`company-name-${editingEmploymentId ?? 'new'}`" class="w-full"
:key="`company-name-${editingEmploymentId ?? 'new'}`" :collection="companyNameCollection" :default-value="companyNameInitial"
class="w-full" :disabled="savingEmployment || saving" @value-change="setCompanyNameValue">
:collection="companyNameCollection"
:default-value="companyNameInitial"
:disabled="savingEmployment || saving"
@value-change="setCompanyNameValue"
>
<SelectControl> <SelectControl>
<SelectTrigger :aria-invalid="!!employmentErrors.company_name"> <SelectTrigger :aria-invalid="!!employmentErrors.company_name">
<SelectValueText placeholder="Pilih syarikat" /> <SelectValueText placeholder="Pilih syarikat" />
@@ -894,11 +832,7 @@ onMounted(() => {
<SelectContent> <SelectContent>
<SelectItemGroup> <SelectItemGroup>
<SelectItemGroupLabel>Nama Syarikat</SelectItemGroupLabel> <SelectItemGroupLabel>Nama Syarikat</SelectItemGroupLabel>
<SelectItem <SelectItem v-for="item in companyNameCollection.items" :key="item.label" :item="item">
v-for="item in companyNameCollection.items"
:key="item.label"
:item="item"
>
<SelectItemText>{{ item.label }}</SelectItemText> <SelectItemText>{{ item.label }}</SelectItemText>
</SelectItem> </SelectItem>
</SelectItemGroup> </SelectItemGroup>
@@ -911,15 +845,9 @@ onMounted(() => {
<Field> <Field>
<FieldLabel for="employment-job-title">Jawatan</FieldLabel> <FieldLabel for="employment-job-title">Jawatan</FieldLabel>
<Input <Input id="employment-job-title" v-model="employmentForm.job_title" type="text" placeholder="Jawatan"
id="employment-job-title" :disabled="savingEmployment || saving" :aria-invalid="!!employmentErrors.job_title"
v-model="employmentForm.job_title" @input="clearEmploymentFieldError('job_title')" />
type="text"
placeholder="Jawatan"
:disabled="savingEmployment || saving"
:aria-invalid="!!employmentErrors.job_title"
@input="clearEmploymentFieldError('job_title')"
/>
<FieldError v-if="employmentErrors.job_title"> <FieldError v-if="employmentErrors.job_title">
{{ employmentErrors.job_title }} {{ employmentErrors.job_title }}
</FieldError> </FieldError>
@@ -927,14 +855,9 @@ onMounted(() => {
<Field> <Field>
<FieldLabel>Jenis Kerja</FieldLabel> <FieldLabel>Jenis Kerja</FieldLabel>
<SelectRoot <SelectRoot :key="`employment-type-${editingEmploymentId ?? 'new'}`" class="w-full"
:key="`employment-type-${editingEmploymentId ?? 'new'}`" :collection="employmentTypeCollection" :default-value="employmentTypeInitial"
class="w-full" :disabled="savingEmployment || saving" @value-change="setEmploymentTypeValue">
:collection="employmentTypeCollection"
:default-value="employmentTypeInitial"
:disabled="savingEmployment || saving"
@value-change="setEmploymentTypeValue"
>
<SelectControl> <SelectControl>
<SelectTrigger :aria-invalid="!!employmentErrors.employment_type"> <SelectTrigger :aria-invalid="!!employmentErrors.employment_type">
<SelectValueText placeholder="Pilih jenis kerja" /> <SelectValueText placeholder="Pilih jenis kerja" />
@@ -943,11 +866,7 @@ onMounted(() => {
<SelectContent> <SelectContent>
<SelectItemGroup> <SelectItemGroup>
<SelectItemGroupLabel>Jenis Kerja</SelectItemGroupLabel> <SelectItemGroupLabel>Jenis Kerja</SelectItemGroupLabel>
<SelectItem <SelectItem v-for="item in employmentTypeCollection.items" :key="item.label" :item="item">
v-for="item in employmentTypeCollection.items"
:key="item.label"
:item="item"
>
<SelectItemText>{{ item.label }}</SelectItemText> <SelectItemText>{{ item.label }}</SelectItemText>
</SelectItem> </SelectItem>
</SelectItemGroup> </SelectItemGroup>
@@ -960,30 +879,17 @@ onMounted(() => {
<Field> <Field>
<FieldLabel for="employment-salary">Gaji (RM)</FieldLabel> <FieldLabel for="employment-salary">Gaji (RM)</FieldLabel>
<Input <Input id="employment-salary" v-model="employmentForm.salary" type="number" min="0" step="0.01"
id="employment-salary" placeholder="0.00" :disabled="savingEmployment || saving" :aria-invalid="!!employmentErrors.salary"
v-model="employmentForm.salary" @input="clearEmploymentFieldError('salary')" />
type="number"
min="0"
step="0.01"
placeholder="0.00"
:disabled="savingEmployment || saving"
:aria-invalid="!!employmentErrors.salary"
@input="clearEmploymentFieldError('salary')"
/>
<FieldError v-if="employmentErrors.salary">{{ employmentErrors.salary }}</FieldError> <FieldError v-if="employmentErrors.salary">{{ employmentErrors.salary }}</FieldError>
</Field> </Field>
<Field> <Field>
<FieldLabel for="employment-start-date">Tarikh Mula</FieldLabel> <FieldLabel for="employment-start-date">Tarikh Mula</FieldLabel>
<Input <Input id="employment-start-date" v-model="employmentForm.start_date" type="date"
id="employment-start-date" :disabled="savingEmployment || saving" :aria-invalid="!!employmentErrors.start_date"
v-model="employmentForm.start_date" @input="clearEmploymentFieldError('start_date')" />
type="date"
:disabled="savingEmployment || saving"
:aria-invalid="!!employmentErrors.start_date"
@input="clearEmploymentFieldError('start_date')"
/>
<FieldError v-if="employmentErrors.start_date"> <FieldError v-if="employmentErrors.start_date">
{{ employmentErrors.start_date }} {{ employmentErrors.start_date }}
</FieldError> </FieldError>
@@ -991,25 +897,17 @@ onMounted(() => {
<Field> <Field>
<FieldLabel for="employment-end-date">Tarikh Tamat</FieldLabel> <FieldLabel for="employment-end-date">Tarikh Tamat</FieldLabel>
<Input <Input id="employment-end-date" v-model="employmentForm.end_date" type="date"
id="employment-end-date"
v-model="employmentForm.end_date"
type="date"
:disabled="employmentForm.is_current || savingEmployment || saving" :disabled="employmentForm.is_current || savingEmployment || saving"
:aria-invalid="!!employmentErrors.end_date" :aria-invalid="!!employmentErrors.end_date" @input="clearEmploymentFieldError('end_date')" />
@input="clearEmploymentFieldError('end_date')"
/>
<FieldError v-if="employmentErrors.end_date"> <FieldError v-if="employmentErrors.end_date">
{{ employmentErrors.end_date }} {{ employmentErrors.end_date }}
</FieldError> </FieldError>
</Field> </Field>
<Field class="md:col-span-2"> <Field class="md:col-span-2">
<CheckboxRoot <CheckboxRoot :checked="employmentForm.is_current" :disabled="savingEmployment || saving"
:checked="employmentForm.is_current" @checked-change="({ checked }) => (employmentForm.is_current = checked === true)">
:disabled="savingEmployment || saving"
@checked-change="({ checked }) => (employmentForm.is_current = checked === true)"
>
<CheckboxControl /> <CheckboxControl />
<CheckboxLabel>Pekerjaan semasa</CheckboxLabel> <CheckboxLabel>Pekerjaan semasa</CheckboxLabel>
</CheckboxRoot> </CheckboxRoot>
@@ -12,8 +12,8 @@ defineProps<{
const EMPLOYMENT_TYPE_LABEL: Record<string, string> = { const EMPLOYMENT_TYPE_LABEL: Record<string, string> = {
Permanent: 'Tetap', Permanent: 'Tetap',
Contract: 'Kontrak', Contract: 'Kontrak',
Internship: 'Latihan Industri', Trial: 'Percubaan',
Freelance: 'Freelance', PartTime: 'Sambilan',
} }
function formatSalary(value: number | string | null | undefined): string { function formatSalary(value: number | string | null | undefined): string {
@@ -56,11 +56,8 @@ function formatEmploymentPeriod(employment: Employment): string {
</div> </div>
<div v-if="user.employments?.length" class="space-y-3"> <div v-if="user.employments?.length" class="space-y-3">
<div <div v-for="employment in user.employments" :key="employment.id"
v-for="employment in user.employments" class="rounded-lg border border-foreground/10 p-4">
:key="employment.id"
class="rounded-lg border border-foreground/10 p-4"
>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<span class="font-medium text-slate-900">{{ employment.company_name }}</span> <span class="font-medium text-slate-900">{{ employment.company_name }}</span>
<Badge v-if="employment.is_current" class="bg-green-500 text-white">Semasa</Badge> <Badge v-if="employment.is_current" class="bg-green-500 text-white">Semasa</Badge>
@@ -74,10 +71,7 @@ function formatEmploymentPeriod(employment: Employment): string {
</div> </div>
</div> </div>
<div <div v-else class="rounded-lg border border-dashed border-foreground/15 p-6 text-center text-sm text-slate-500">
v-else
class="rounded-lg border border-dashed border-foreground/15 p-6 text-center text-sm text-slate-500"
>
Tiada pekerjaan direkodkan. Tiada pekerjaan direkodkan.
</div> </div>
</Box> </Box>
+5 -14
View File
@@ -52,7 +52,7 @@ const STATUS_FILTER_CHIPS: StatusFilterChip[] = [
function getEmployerShortLabel(name: string): string { function getEmployerShortLabel(name: string): string {
const match = name.match(/\(([^)]+)\)/) const match = name.match(/\(([^)]+)\)/)
if (match?.[1]) return match[1] if (match?.[1]) return match[1]
if (name.startsWith('An-Nisa')) return "An-Nisa'" if (name.startsWith('Pusat Perubatan An-Nisa')) return "Pusat Perubatan An-Nisa'"
if (name.startsWith('Kel Infra')) return 'Kel Infra' if (name.startsWith('Kel Infra')) return 'Kel Infra'
return name return name
} }
@@ -318,7 +318,6 @@ async function confirmRestore() {
const headers: TableHeader[] = [ const headers: TableHeader[] = [
{ title: 'Bil.', key: '#', sortable: false }, { title: 'Bil.', key: '#', sortable: false },
{ title: 'Name', key: 'name', sortable: true }, { title: 'Name', key: 'name', sortable: true },
{ title: 'Jenis Anggota', key: 'member_type', sortable: true },
{ {
title: 'Unit', title: 'Unit',
key: 'company_name', key: 'company_name',
@@ -568,18 +567,10 @@ onMounted(() => {
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<span class="text-sm opacity-70">Unit:</span> <span class="text-sm opacity-70">Unit:</span>
<Badge <Badge v-for="chip in UNIT_FILTER_CHIPS" :key="chip.value || 'all-units'" variant="ghost"
v-for="chip in UNIT_FILTER_CHIPS" :look="isUnitFilterActive(chip.value) ? 'filled' : 'outline'" role="button" tabindex="0"
:key="chip.value || 'all-units'" :title="chip.value || 'Semua unit'" :aria-pressed="isUnitFilterActive(chip.value)"
variant="ghost" @click="setUnitFilter(chip.value)" @keydown.enter="setUnitFilter(chip.value)">
:look="isUnitFilterActive(chip.value) ? 'filled' : 'outline'"
role="button"
tabindex="0"
:title="chip.value || 'Semua unit'"
:aria-pressed="isUnitFilterActive(chip.value)"
@click="setUnitFilter(chip.value)"
@keydown.enter="setUnitFilter(chip.value)"
>
{{ chip.label }} {{ chip.label }}
</Badge> </Badge>
</div> </div>