Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 757cd1ca9e | |||
| 820f8e46d9 | |||
| db3ac37042 | |||
| 605ccf5a7b | |||
| 06b687f408 | |||
| d0f5e368e3 | |||
| 59b571d944 |
+43
-18
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
; Keep above Laravel validation (max:10240 = 10MB)
|
||||||
|
upload_max_filesize = 12M
|
||||||
|
post_max_size = 12M
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user