DONE: phone number verification, admin can add user employment; WIP: wire with onewaysms
This commit is contained in:
@@ -11,7 +11,7 @@
|
|||||||
[ ] sumbangan
|
[ ] sumbangan
|
||||||
[x] wasi/penama
|
[x] wasi/penama
|
||||||
[ ] pendaftaran anggota/meneruskan anggota/pencen
|
[ ] pendaftaran anggota/meneruskan anggota/pencen
|
||||||
[ ] daftar lembaga (backdated)
|
[x] daftar lembaga (backdated)
|
||||||
[x] boleh print semua borang
|
[x] boleh print semua borang
|
||||||
[x] jana surat lepas lulus anggota
|
[x] jana surat lepas lulus anggota
|
||||||
|
|
||||||
@@ -29,4 +29,4 @@
|
|||||||
[ ] syer maksima silap tukar jadi rm50.00
|
[ ] syer maksima silap tukar jadi rm50.00
|
||||||
[x] letak sign digital
|
[x] letak sign digital
|
||||||
[x] running no anggota dalam surat dan masa.
|
[x] running no anggota dalam surat dan masa.
|
||||||
[ ] tukar logo di surat offer
|
[x] tukar logo di surat offer
|
||||||
@@ -89,5 +89,17 @@ ACTIVE_ROLE_PREFER_MEMBER=true
|
|||||||
ACTIVE_ROLE_MEMBER_REDIRECT=/profile
|
ACTIVE_ROLE_MEMBER_REDIRECT=/profile
|
||||||
ACTIVE_ROLE_ADMIN_REDIRECT=/profile
|
ACTIVE_ROLE_ADMIN_REDIRECT=/profile
|
||||||
|
|
||||||
|
PUBLIC_PROFILE_TOKEN_TTL_DAYS=7
|
||||||
|
|
||||||
EMAIL_VERIFICATION_EXPIRE_MINUTES=60
|
EMAIL_VERIFICATION_EXPIRE_MINUTES=60
|
||||||
EMAIL_VERIFICATION_REDIRECT_PATH=/profile-overview-2
|
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
|
||||||
|
ONEWAYSMS_BASE_URL=http://gateway.onewaysms.com.my:10001/api.aspx
|
||||||
|
ONEWAYSMS_API_USERNAME=
|
||||||
|
ONEWAYSMS_API_PASSWORD=
|
||||||
|
ONEWAYSMS_SENDER_ID=
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use Illuminate\Support\Str;
|
|||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
||||||
use Modules\Auth\Entities\User;
|
use Modules\Auth\Entities\User;
|
||||||
|
use Modules\Auth\Services\PhoneVerificationOtpService;
|
||||||
use Modules\Role\Entities\Role;
|
use Modules\Role\Entities\Role;
|
||||||
use Modules\User\Notifications\UserActivationNotification;
|
use Modules\User\Notifications\UserActivationNotification;
|
||||||
use Exception;
|
use Exception;
|
||||||
@@ -18,6 +19,10 @@ class CreateNewUser implements CreatesNewUsers
|
|||||||
{
|
{
|
||||||
use PasswordValidationRules, NotifiesAdmins;
|
use PasswordValidationRules, NotifiesAdmins;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected PhoneVerificationOtpService $phoneVerificationOtpService,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate and create a newly registered user.
|
* Validate and create a newly registered user.
|
||||||
*
|
*
|
||||||
@@ -25,7 +30,9 @@ class CreateNewUser implements CreatesNewUsers
|
|||||||
*/
|
*/
|
||||||
public function create(array $input): User
|
public function create(array $input): User
|
||||||
{
|
{
|
||||||
Validator::make($input, [
|
$phoneNumber = $this->phoneVerificationOtpService->normalizePhoneNumber($input['phone_number'] ?? '');
|
||||||
|
|
||||||
|
Validator::make(array_merge($input, ['phone_number' => $phoneNumber]), [
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
'email' => [
|
'email' => [
|
||||||
'required',
|
'required',
|
||||||
@@ -35,15 +42,32 @@ class CreateNewUser implements CreatesNewUsers
|
|||||||
Rule::unique(User::class),
|
Rule::unique(User::class),
|
||||||
],
|
],
|
||||||
'ic_number' => ['required', 'string', 'max:255'],
|
'ic_number' => ['required', 'string', 'max:255'],
|
||||||
|
'phone_number' => [
|
||||||
|
...$this->phoneVerificationOtpService->phoneNumberRules(),
|
||||||
|
Rule::unique(User::class),
|
||||||
|
],
|
||||||
|
'phone_verification_token' => ['required', 'string', 'size:64'],
|
||||||
'password' => ['required', 'string', 'min:8'],
|
'password' => ['required', 'string', 'min:8'],
|
||||||
|
], [
|
||||||
|
'phone_number.required' => 'Nombor telefon diperlukan.',
|
||||||
|
'phone_number.regex' => 'Format nombor telefon tidak sah.',
|
||||||
|
'phone_number.unique' => 'Nombor telefon ini telah didaftarkan.',
|
||||||
|
'phone_verification_token.required' => 'Pengesahan nombor telefon diperlukan.',
|
||||||
])->validate();
|
])->validate();
|
||||||
|
|
||||||
|
$this->phoneVerificationOtpService->consumeRegistrationToken(
|
||||||
|
$phoneNumber,
|
||||||
|
$input['phone_verification_token'],
|
||||||
|
);
|
||||||
|
|
||||||
$user = User::create([
|
$user = User::create([
|
||||||
'name' => $input['name'],
|
'name' => $input['name'],
|
||||||
'uuid' => Str::uuid(),
|
'uuid' => Str::uuid(),
|
||||||
'email' => $input['email'],
|
'email' => $input['email'],
|
||||||
'password' => Hash::make($input['password']),
|
'password' => Hash::make($input['password']),
|
||||||
'ic_number' => $input['ic_number'],
|
'ic_number' => $input['ic_number'],
|
||||||
|
'phone_number' => $phoneNumber,
|
||||||
|
'phone_verified_at' => now(),
|
||||||
'status' => 'pending',
|
'status' => 'pending',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Auth\Contracts;
|
||||||
|
|
||||||
|
interface SmsSender
|
||||||
|
{
|
||||||
|
public function send(string $phoneNumber, string $message): void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Auth\Entities;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class PhoneVerificationOtp extends Model
|
||||||
|
{
|
||||||
|
use HasUuids;
|
||||||
|
|
||||||
|
protected $table = 'phone_verification_otps';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'phone_number',
|
||||||
|
'code',
|
||||||
|
'expires_at',
|
||||||
|
'attempts',
|
||||||
|
'verified_at',
|
||||||
|
'verification_token',
|
||||||
|
'verification_token_expires_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'expires_at' => 'datetime',
|
||||||
|
'attempts' => 'integer',
|
||||||
|
'verified_at' => 'datetime',
|
||||||
|
'verification_token_expires_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isExpired(): bool
|
||||||
|
{
|
||||||
|
return $this->expires_at->isPast();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasExceededMaxAttempts(): bool
|
||||||
|
{
|
||||||
|
return $this->attempts >= (int) config('auth.phone_verification.max_attempts', 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isVerificationTokenExpired(): bool
|
||||||
|
{
|
||||||
|
return $this->verification_token_expires_at === null
|
||||||
|
|| $this->verification_token_expires_at->isPast();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||||||
'ic_number',
|
'ic_number',
|
||||||
'position',
|
'position',
|
||||||
'phone_number',
|
'phone_number',
|
||||||
|
'phone_verified_at',
|
||||||
'image_url',
|
'image_url',
|
||||||
'status',
|
'status',
|
||||||
'two_factor_secret',
|
'two_factor_secret',
|
||||||
@@ -131,6 +132,7 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'email_verified_at' => 'datetime',
|
'email_verified_at' => 'datetime',
|
||||||
|
'phone_verified_at' => 'datetime',
|
||||||
'password' => 'hashed',
|
'password' => 'hashed',
|
||||||
'status' => 'string',
|
'status' => 'string',
|
||||||
'gender' => 'string',
|
'gender' => 'string',
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Auth\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Modules\Auth\Services\PhoneVerificationOtpService;
|
||||||
|
use Modules\Auth\Transformers\UserResource;
|
||||||
|
|
||||||
|
class PhoneVerificationController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected PhoneVerificationOtpService $phoneVerificationOtpService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function send(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'phone_number' => $this->phoneVerificationOtpService->phoneNumberRules(),
|
||||||
|
], [
|
||||||
|
'phone_number.required' => 'Nombor telefon diperlukan.',
|
||||||
|
'phone_number.regex' => 'Format nombor telefon tidak sah.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->phoneVerificationOtpService->send($validated['phone_number']);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Kod OTP telah dihantar ke nombor telefon anda.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verify(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'phone_number' => $this->phoneVerificationOtpService->phoneNumberRules(),
|
||||||
|
'otp' => ['required', 'string', 'digits:6'],
|
||||||
|
], [
|
||||||
|
'phone_number.required' => 'Nombor telefon diperlukan.',
|
||||||
|
'phone_number.regex' => 'Format nombor telefon tidak sah.',
|
||||||
|
'otp.required' => 'Kod OTP diperlukan.',
|
||||||
|
'otp.digits' => 'Kod OTP mestilah 6 digit.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = $this->phoneVerificationOtpService->verify(
|
||||||
|
$validated['phone_number'],
|
||||||
|
$validated['otp'],
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Nombor telefon berjaya disahkan.',
|
||||||
|
'data' => [
|
||||||
|
'phone_number' => $result['phone_number'],
|
||||||
|
'verification_token' => $result['verification_token'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sendForAuthenticatedUser(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'phone_number' => $this->phoneVerificationOtpService->phoneNumberRules(),
|
||||||
|
], [
|
||||||
|
'phone_number.required' => 'Nombor telefon diperlukan.',
|
||||||
|
'phone_number.regex' => 'Format nombor telefon tidak sah.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->phoneVerificationOtpService->sendForUser($user, $validated['phone_number']);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Kod OTP telah dihantar ke nombor telefon anda.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verifyForAuthenticatedUser(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
$wasVerified = (bool) $user->phone_verified_at;
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'phone_number' => $this->phoneVerificationOtpService->phoneNumberRules(),
|
||||||
|
'otp' => ['required', 'string', 'digits:6'],
|
||||||
|
], [
|
||||||
|
'phone_number.required' => 'Nombor telefon diperlukan.',
|
||||||
|
'phone_number.regex' => 'Format nombor telefon tidak sah.',
|
||||||
|
'otp.required' => 'Kod OTP diperlukan.',
|
||||||
|
'otp.digits' => 'Kod OTP mestilah 6 digit.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$verifiedUser = $this->phoneVerificationOtpService->verifyForUser(
|
||||||
|
$user,
|
||||||
|
$validated['phone_number'],
|
||||||
|
$validated['otp'],
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => $wasVerified
|
||||||
|
? 'Nombor telefon berjaya dikemas kini.'
|
||||||
|
: 'Nombor telefon berjaya disahkan.',
|
||||||
|
'data' => new UserResource($verifiedUser),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,9 @@ use Modules\Auth\Actions\Fortify\CreateNewUser;
|
|||||||
use Modules\Auth\Actions\Fortify\ResetUserPassword;
|
use Modules\Auth\Actions\Fortify\ResetUserPassword;
|
||||||
use Modules\Auth\Actions\Fortify\UpdateUserPassword;
|
use Modules\Auth\Actions\Fortify\UpdateUserPassword;
|
||||||
use Modules\Auth\Actions\Fortify\UpdateUserProfileInformation;
|
use Modules\Auth\Actions\Fortify\UpdateUserProfileInformation;
|
||||||
|
use Modules\Auth\Contracts\SmsSender;
|
||||||
|
use Modules\Auth\Services\LogSmsSender;
|
||||||
|
use Modules\Auth\Services\OneWaySmsSender;
|
||||||
use Nwidart\Modules\Traits\PathNamespace;
|
use Nwidart\Modules\Traits\PathNamespace;
|
||||||
use RecursiveDirectoryIterator;
|
use RecursiveDirectoryIterator;
|
||||||
use RecursiveIteratorIterator;
|
use RecursiveIteratorIterator;
|
||||||
@@ -42,6 +45,13 @@ class AuthServiceProvider extends ServiceProvider
|
|||||||
{
|
{
|
||||||
$this->app->register(EventServiceProvider::class);
|
$this->app->register(EventServiceProvider::class);
|
||||||
$this->app->register(RouteServiceProvider::class);
|
$this->app->register(RouteServiceProvider::class);
|
||||||
|
|
||||||
|
$this->app->bind(SmsSender::class, function () {
|
||||||
|
return match (config('onewaysms.driver')) {
|
||||||
|
'log' => $this->app->make(LogSmsSender::class),
|
||||||
|
default => $this->app->make(OneWaySmsSender::class),
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,11 +3,17 @@
|
|||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use Modules\Auth\Http\Controllers\EmailVerificationController;
|
use Modules\Auth\Http\Controllers\EmailVerificationController;
|
||||||
use Modules\Auth\Http\Controllers\PasswordResetController;
|
use Modules\Auth\Http\Controllers\PasswordResetController;
|
||||||
|
use Modules\Auth\Http\Controllers\PhoneVerificationController;
|
||||||
use Modules\Auth\Http\Controllers\SessionController;
|
use Modules\Auth\Http\Controllers\SessionController;
|
||||||
use Laravel\Fortify\Http\Controllers\AuthenticatedSessionController;
|
use Laravel\Fortify\Http\Controllers\AuthenticatedSessionController;
|
||||||
use Laravel\Fortify\Http\Controllers\RegisteredUserController;
|
use Laravel\Fortify\Http\Controllers\RegisteredUserController;
|
||||||
|
|
||||||
// Auth
|
// Auth
|
||||||
|
Route::post('/phone-verification/send', [PhoneVerificationController::class, 'send'])
|
||||||
|
->middleware('throttle:phone-verification-send');
|
||||||
|
Route::post('/phone-verification/verify', [PhoneVerificationController::class, 'verify'])
|
||||||
|
->middleware('throttle:phone-verification-verify');
|
||||||
|
|
||||||
Route::post('/register', [RegisteredUserController::class, 'store']);
|
Route::post('/register', [RegisteredUserController::class, 'store']);
|
||||||
Route::post('/login', [AuthenticatedSessionController::class, 'store'])->middleware('block.api.tools');
|
Route::post('/login', [AuthenticatedSessionController::class, 'store'])->middleware('block.api.tools');
|
||||||
|
|
||||||
@@ -33,4 +39,10 @@ Route::middleware(['auth:sanctum'])->prefix('v1')->group(function () {
|
|||||||
// optional email verification (post-login)
|
// optional email verification (post-login)
|
||||||
Route::post('/email/verification-notification', [EmailVerificationController::class, 'send'])
|
Route::post('/email/verification-notification', [EmailVerificationController::class, 'send'])
|
||||||
->middleware('throttle:email-verification-resend');
|
->middleware('throttle:email-verification-resend');
|
||||||
|
|
||||||
|
// optional phone verification (post-login)
|
||||||
|
Route::post('/phone-verification/send', [PhoneVerificationController::class, 'sendForAuthenticatedUser'])
|
||||||
|
->middleware('throttle:phone-verification-send');
|
||||||
|
Route::post('/phone-verification/verify', [PhoneVerificationController::class, 'verifyForAuthenticatedUser'])
|
||||||
|
->middleware('throttle:phone-verification-verify');
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Auth\Services;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Modules\Auth\Contracts\SmsSender;
|
||||||
|
|
||||||
|
class LogSmsSender implements SmsSender
|
||||||
|
{
|
||||||
|
public function send(string $phoneNumber, string $message): void
|
||||||
|
{
|
||||||
|
Log::info('SMS sent (log driver)', [
|
||||||
|
'phone_number' => $phoneNumber,
|
||||||
|
'message' => $message,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Auth\Services;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Modules\Auth\Contracts\SmsSender;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class OneWaySmsSender implements SmsSender
|
||||||
|
{
|
||||||
|
public function send(string $phoneNumber, string $message): void
|
||||||
|
{
|
||||||
|
$baseUrl = (string) config('onewaysms.base_url');
|
||||||
|
$username = (string) config('onewaysms.api_username');
|
||||||
|
$password = (string) config('onewaysms.api_password');
|
||||||
|
$senderId = (string) config('onewaysms.sender_id');
|
||||||
|
|
||||||
|
if ($baseUrl === '' || $username === '' || $password === '' || $senderId === '') {
|
||||||
|
throw new RuntimeException('OneWaySMS credentials are not configured.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$mobileNo = $this->toInternationalMobileNumber($phoneNumber);
|
||||||
|
|
||||||
|
$response = Http::timeout((int) config('http.timeout', 30))
|
||||||
|
->connectTimeout((int) config('http.connect_timeout', 10))
|
||||||
|
->get($baseUrl, [
|
||||||
|
'apiusername' => $username,
|
||||||
|
'apipassword' => $password,
|
||||||
|
'senderid' => $senderId,
|
||||||
|
'mobileno' => $mobileNo,
|
||||||
|
'message' => $message,
|
||||||
|
'languagetype' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! $response->successful()) {
|
||||||
|
Log::error('OneWaySMS HTTP request failed', [
|
||||||
|
'status' => $response->status(),
|
||||||
|
'body' => $response->body(),
|
||||||
|
'phone_number' => $mobileNo,
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw new RuntimeException('Failed to send SMS via OneWaySMS.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$mtId = trim($response->body());
|
||||||
|
|
||||||
|
// Positive MT ID = success; zero/negative = gateway error codes.
|
||||||
|
if (! is_numeric($mtId) || (int) $mtId <= 0) {
|
||||||
|
Log::error('OneWaySMS gateway rejected SMS', [
|
||||||
|
'mt_id' => $mtId,
|
||||||
|
'phone_number' => $mobileNo,
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw new RuntimeException('OneWaySMS gateway rejected the SMS request.');
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::info('OneWaySMS sent successfully', [
|
||||||
|
'mt_id' => $mtId,
|
||||||
|
'phone_number' => $mobileNo,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function toInternationalMobileNumber(string $phoneNumber): string
|
||||||
|
{
|
||||||
|
$phoneNumber = preg_replace('/[\s\-]/', '', trim($phoneNumber)) ?? '';
|
||||||
|
|
||||||
|
if (str_starts_with($phoneNumber, '+')) {
|
||||||
|
$phoneNumber = substr($phoneNumber, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($phoneNumber, '0')) {
|
||||||
|
$phoneNumber = '60'.substr($phoneNumber, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $phoneNumber;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Auth\Services;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Modules\Auth\Contracts\SmsSender;
|
||||||
|
use Modules\Auth\Entities\PhoneVerificationOtp;
|
||||||
|
use Modules\Auth\Entities\User;
|
||||||
|
|
||||||
|
class PhoneVerificationOtpService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected SmsSender $smsSender,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function send(string $phoneNumber): void
|
||||||
|
{
|
||||||
|
$phoneNumber = $this->normalizePhoneNumber($phoneNumber);
|
||||||
|
|
||||||
|
if ($this->phoneNumberBelongsToAnotherUser($phoneNumber)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'phone_number' => ['Nombor telefon ini telah didaftarkan.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->dispatchOtp($phoneNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sendForUser(User $user, string $phoneNumber): void
|
||||||
|
{
|
||||||
|
$phoneNumber = $this->normalizePhoneNumber($phoneNumber);
|
||||||
|
|
||||||
|
$this->assertPhoneNumberAvailableForUser($user, $phoneNumber);
|
||||||
|
|
||||||
|
if ($user->phone_verified_at && $user->phone_number === $phoneNumber) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'phone_number' => ['Nombor telefon ini sama dengan nombor sedia ada.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->dispatchOtp($phoneNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verifyForUser(User $user, string $phoneNumber, string $otp): User
|
||||||
|
{
|
||||||
|
$phoneNumber = $this->normalizePhoneNumber($phoneNumber);
|
||||||
|
|
||||||
|
$this->assertPhoneNumberAvailableForUser($user, $phoneNumber);
|
||||||
|
|
||||||
|
$record = $this->validateOtpRecord($phoneNumber, $otp);
|
||||||
|
$record->delete();
|
||||||
|
|
||||||
|
$user->forceFill([
|
||||||
|
'phone_number' => $phoneNumber,
|
||||||
|
'phone_verified_at' => now(),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return $user->fresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{verification_token: string, phone_number: string}
|
||||||
|
*/
|
||||||
|
public function verify(string $phoneNumber, string $otp): array
|
||||||
|
{
|
||||||
|
$phoneNumber = $this->normalizePhoneNumber($phoneNumber);
|
||||||
|
|
||||||
|
$record = $this->validateOtpRecord($phoneNumber, $otp);
|
||||||
|
|
||||||
|
$verificationToken = Str::random(64);
|
||||||
|
|
||||||
|
$record->forceFill([
|
||||||
|
'verified_at' => now(),
|
||||||
|
'verification_token' => Hash::make($verificationToken),
|
||||||
|
'verification_token_expires_at' => now()->addMinutes($this->tokenExpiryMinutes()),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'verification_token' => $verificationToken,
|
||||||
|
'phone_number' => $phoneNumber,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function consumeRegistrationToken(string $phoneNumber, string $verificationToken): void
|
||||||
|
{
|
||||||
|
$phoneNumber = $this->normalizePhoneNumber($phoneNumber);
|
||||||
|
|
||||||
|
$record = PhoneVerificationOtp::query()
|
||||||
|
->where('phone_number', $phoneNumber)
|
||||||
|
->whereNotNull('verified_at')
|
||||||
|
->latest()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $record || $record->isVerificationTokenExpired()) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'phone_verification_token' => ['Pengesahan nombor telefon tidak sah atau telah tamat tempoh. Sila sahkan semula.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! Hash::check($verificationToken, (string) $record->verification_token)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'phone_verification_token' => ['Pengesahan nombor telefon tidak sah atau telah tamat tempoh. Sila sahkan semula.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$record->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function dispatchOtp(string $phoneNumber): void
|
||||||
|
{
|
||||||
|
$otp = $this->generateOtp();
|
||||||
|
|
||||||
|
PhoneVerificationOtp::query()
|
||||||
|
->where('phone_number', $phoneNumber)
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
PhoneVerificationOtp::create([
|
||||||
|
'phone_number' => $phoneNumber,
|
||||||
|
'code' => Hash::make($otp),
|
||||||
|
'expires_at' => now()->addMinutes($this->otpExpiryMinutes()),
|
||||||
|
'attempts' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->smsSender->send(
|
||||||
|
$phoneNumber,
|
||||||
|
$this->buildOtpMessage($otp),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function validateOtpRecord(string $phoneNumber, string $otp): PhoneVerificationOtp
|
||||||
|
{
|
||||||
|
$record = PhoneVerificationOtp::query()
|
||||||
|
->where('phone_number', $phoneNumber)
|
||||||
|
->latest()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $record) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'otp' => ['Kod OTP tidak dijumpai. Sila minta kod baharu.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($record->isExpired()) {
|
||||||
|
$record->delete();
|
||||||
|
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'otp' => ['Kod OTP telah tamat tempoh. Sila minta kod baharu.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($record->hasExceededMaxAttempts()) {
|
||||||
|
$record->delete();
|
||||||
|
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'otp' => ['Terlalu banyak percubaan. Sila minta kod baharu.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! Hash::check($otp, $record->code)) {
|
||||||
|
$record->increment('attempts');
|
||||||
|
|
||||||
|
if ($record->fresh()->hasExceededMaxAttempts()) {
|
||||||
|
$record->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'otp' => ['Kod OTP tidak sah.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $record;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function assertPhoneNumberAvailableForUser(User $user, string $phoneNumber): void
|
||||||
|
{
|
||||||
|
if ($this->phoneNumberBelongsToAnotherUser($phoneNumber, $user->id)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'phone_number' => ['Nombor telefon ini telah digunakan oleh akaun lain.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function phoneNumberBelongsToAnotherUser(string $phoneNumber, ?string $exceptUserId = null): bool
|
||||||
|
{
|
||||||
|
return User::query()
|
||||||
|
->where('phone_number', $phoneNumber)
|
||||||
|
->when($exceptUserId, fn ($query) => $query->where('id', '!=', $exceptUserId))
|
||||||
|
->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function normalizePhoneNumber(string $phoneNumber): string
|
||||||
|
{
|
||||||
|
$phoneNumber = preg_replace('/[\s\-]/', '', trim($phoneNumber)) ?? '';
|
||||||
|
|
||||||
|
if (str_starts_with($phoneNumber, '+60')) {
|
||||||
|
$phoneNumber = '0'.substr($phoneNumber, 3);
|
||||||
|
} elseif (str_starts_with($phoneNumber, '60') && strlen($phoneNumber) > 10) {
|
||||||
|
$phoneNumber = '0'.substr($phoneNumber, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $phoneNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function phoneNumberRules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'max:20',
|
||||||
|
'regex:/^(\+?60|0)1[0-9]{8,9}$/',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function generateOtp(): string
|
||||||
|
{
|
||||||
|
return str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function buildOtpMessage(string $otp): string
|
||||||
|
{
|
||||||
|
$template = (string) config('auth.phone_verification.message');
|
||||||
|
|
||||||
|
return str_replace(
|
||||||
|
[':otp', ':minutes'],
|
||||||
|
[$otp, (string) $this->otpExpiryMinutes()],
|
||||||
|
$template,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function otpExpiryMinutes(): int
|
||||||
|
{
|
||||||
|
return (int) config('auth.phone_verification.otp_expiry_minutes', 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tokenExpiryMinutes(): int
|
||||||
|
{
|
||||||
|
return (int) config('auth.phone_verification.token_expiry_minutes', 30);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ class UserResource extends JsonResource
|
|||||||
'ic_number' => $this->ic_number,
|
'ic_number' => $this->ic_number,
|
||||||
'position' => $this->position,
|
'position' => $this->position,
|
||||||
'phone_number' => $this->phone_number,
|
'phone_number' => $this->phone_number,
|
||||||
|
'phone_verified_at' => $this->phone_verified_at,
|
||||||
'image_url' => $this->image_url ? Storage::disk('public')->url($this->image_url) : null,
|
'image_url' => $this->image_url ? Storage::disk('public')->url($this->image_url) : null,
|
||||||
'status' => $this->status,
|
'status' => $this->status,
|
||||||
'two_factor_secret' => $this->two_factor_secret,
|
'two_factor_secret' => $this->two_factor_secret,
|
||||||
|
|||||||
@@ -195,6 +195,7 @@ class UserController extends BaseCrudController
|
|||||||
'join_date_to' => 'nullable|date',
|
'join_date_to' => 'nullable|date',
|
||||||
'leave_date_from' => 'nullable|date',
|
'leave_date_from' => 'nullable|date',
|
||||||
'leave_date_to' => 'nullable|date',
|
'leave_date_to' => 'nullable|date',
|
||||||
|
'company_name' => 'nullable|string|max:255',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$perPage = min((int) $request->get('per_page', 10), 500);
|
$perPage = min((int) $request->get('per_page', 10), 500);
|
||||||
@@ -203,6 +204,7 @@ class UserController extends BaseCrudController
|
|||||||
'join_date_to' => $request->get('join_date_to'),
|
'join_date_to' => $request->get('join_date_to'),
|
||||||
'leave_date_from' => $request->get('leave_date_from'),
|
'leave_date_from' => $request->get('leave_date_from'),
|
||||||
'leave_date_to' => $request->get('leave_date_to'),
|
'leave_date_to' => $request->get('leave_date_to'),
|
||||||
|
'company_name' => $request->get('company_name'),
|
||||||
]);
|
]);
|
||||||
$items = $this->userService->getPaginatedList(
|
$items = $this->userService->getPaginatedList(
|
||||||
$perPage,
|
$perPage,
|
||||||
@@ -279,6 +281,7 @@ class UserController extends BaseCrudController
|
|||||||
'join_date_to' => 'nullable|date',
|
'join_date_to' => 'nullable|date',
|
||||||
'leave_date_from' => 'nullable|date',
|
'leave_date_from' => 'nullable|date',
|
||||||
'leave_date_to' => 'nullable|date',
|
'leave_date_to' => 'nullable|date',
|
||||||
|
'company_name' => 'nullable|string|max:255',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$dateFilters = array_filter([
|
$dateFilters = array_filter([
|
||||||
@@ -286,6 +289,7 @@ class UserController extends BaseCrudController
|
|||||||
'join_date_to' => $request->get('join_date_to'),
|
'join_date_to' => $request->get('join_date_to'),
|
||||||
'leave_date_from' => $request->get('leave_date_from'),
|
'leave_date_from' => $request->get('leave_date_from'),
|
||||||
'leave_date_to' => $request->get('leave_date_to'),
|
'leave_date_to' => $request->get('leave_date_to'),
|
||||||
|
'company_name' => $request->get('company_name'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$stats = $this->userService->getListStats(
|
$stats = $this->userService->getListStats(
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\User\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\ActivityLogger;
|
||||||
|
use Exception;
|
||||||
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Modules\Auth\Entities\User;
|
||||||
|
use Modules\User\Entities\Employment;
|
||||||
|
use Modules\User\Http\Requests\EmploymentRequest;
|
||||||
|
use Modules\User\Transformers\EmploymentResource;
|
||||||
|
|
||||||
|
class UserEmploymentController extends Controller
|
||||||
|
{
|
||||||
|
use AuthorizesRequests;
|
||||||
|
public function store(EmploymentRequest $request, string $user): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('update', User::class);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$targetUser = User::query()->find($user);
|
||||||
|
|
||||||
|
if (! $targetUser) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'User not found.',
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$employment = $targetUser->employments()->create($request->validated());
|
||||||
|
|
||||||
|
ActivityLogger::log(
|
||||||
|
"Created employment for user {$targetUser->name}: {$employment->company_name}",
|
||||||
|
$employment
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'data' => new EmploymentResource($employment),
|
||||||
|
'message' => 'Employment created successfully.',
|
||||||
|
], 201);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
Log::error('Error creating user employment: '.$e->getMessage());
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Failed to create employment.',
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(EmploymentRequest $request, string $user, string $employment): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('update', User::class);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$targetUser = User::query()->find($user);
|
||||||
|
|
||||||
|
if (! $targetUser) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'User not found.',
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$employmentModel = $targetUser->employments()->find($employment);
|
||||||
|
|
||||||
|
if (! $employmentModel) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Employment not found.',
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$employmentModel->update($request->validated());
|
||||||
|
|
||||||
|
ActivityLogger::log(
|
||||||
|
"Updated employment for user {$targetUser->name}: {$employmentModel->company_name}",
|
||||||
|
$employmentModel
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'data' => new EmploymentResource($employmentModel->fresh()),
|
||||||
|
'message' => 'Employment updated successfully.',
|
||||||
|
]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
Log::error('Error updating user employment: '.$e->getMessage());
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Failed to update employment.',
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(string $user, string $employment): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('update', User::class);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$targetUser = User::query()->find($user);
|
||||||
|
|
||||||
|
if (! $targetUser) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'User not found.',
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$employmentModel = $targetUser->employments()->find($employment);
|
||||||
|
|
||||||
|
if (! $employmentModel) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Employment not found.',
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$companyName = $employmentModel->company_name;
|
||||||
|
$employmentModel->delete();
|
||||||
|
|
||||||
|
ActivityLogger::log(
|
||||||
|
"Deleted employment for user {$targetUser->name}: {$companyName}",
|
||||||
|
$targetUser
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Employment deleted successfully.',
|
||||||
|
]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
Log::error('Error deleting user employment: '.$e->getMessage());
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Failed to delete employment.',
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,31 @@ class UserRepository implements UserRepositoryInterface
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function applyCompanyNameFilter($query, string $companyName): void
|
||||||
|
{
|
||||||
|
if ($companyName === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query->whereHas('employments', function ($employmentQuery) use ($companyName) {
|
||||||
|
$employmentQuery
|
||||||
|
->where('company_name', $companyName)
|
||||||
|
->whereRaw('employments.id = (
|
||||||
|
SELECT e2.id
|
||||||
|
FROM employments e2
|
||||||
|
WHERE e2.user_id = employments.user_id
|
||||||
|
ORDER BY e2.is_current DESC, e2.start_date DESC
|
||||||
|
LIMIT 1
|
||||||
|
)');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function applyListFilters($query, array $filters): void
|
||||||
|
{
|
||||||
|
$this->applyDateRangeFilters($query, $filters);
|
||||||
|
$this->applyCompanyNameFilter($query, $filters['company_name'] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all Users with pagination and search
|
* Get all Users with pagination and search
|
||||||
*/
|
*/
|
||||||
@@ -107,7 +132,7 @@ class UserRepository implements UserRepositoryInterface
|
|||||||
$query->where('status', $status);
|
$query->where('status', $status);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->applyDateRangeFilters($query, $dateFilters);
|
$this->applyListFilters($query, $dateFilters);
|
||||||
|
|
||||||
return $query->paginate($perPage);
|
return $query->paginate($perPage);
|
||||||
}
|
}
|
||||||
@@ -177,7 +202,7 @@ class UserRepository implements UserRepositoryInterface
|
|||||||
$baseQuery->where('status', $status);
|
$baseQuery->where('status', $status);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->applyDateRangeFilters($baseQuery, $dateFilters);
|
$this->applyListFilters($baseQuery, $dateFilters);
|
||||||
|
|
||||||
$total = (int) (clone $baseQuery)->count();
|
$total = (int) (clone $baseQuery)->count();
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use Modules\User\Http\Controllers\UserController;
|
|||||||
use App\Http\Controllers\ImpersonateController;
|
use App\Http\Controllers\ImpersonateController;
|
||||||
use Modules\User\Http\Controllers\AddressController;
|
use Modules\User\Http\Controllers\AddressController;
|
||||||
use Modules\User\Http\Controllers\EmploymentController;
|
use Modules\User\Http\Controllers\EmploymentController;
|
||||||
|
use Modules\User\Http\Controllers\UserEmploymentController;
|
||||||
use Modules\User\Http\Controllers\BankController;
|
use Modules\User\Http\Controllers\BankController;
|
||||||
use Modules\User\Http\Controllers\BankDetailController;
|
use Modules\User\Http\Controllers\BankDetailController;
|
||||||
use Modules\User\Http\Controllers\HeirController;
|
use Modules\User\Http\Controllers\HeirController;
|
||||||
@@ -33,6 +34,9 @@ Route::middleware(['auth:sanctum', 'single.session'])->prefix('v1')->group(funct
|
|||||||
Route::apiResource('heirs', HeirController::class)->names('heir');
|
Route::apiResource('heirs', HeirController::class)->names('heir');
|
||||||
// User role management routes
|
// User role management routes
|
||||||
Route::post('users/{user}/roles', [UserController::class, 'assignRoles'])->name('users.roles.assign');
|
Route::post('users/{user}/roles', [UserController::class, 'assignRoles'])->name('users.roles.assign');
|
||||||
|
Route::post('users/{user}/employments', [UserEmploymentController::class, 'store'])->name('users.employments.store');
|
||||||
|
Route::put('users/{user}/employments/{employment}', [UserEmploymentController::class, 'update'])->name('users.employments.update');
|
||||||
|
Route::delete('users/{user}/employments/{employment}', [UserEmploymentController::class, 'destroy'])->name('users.employments.destroy');
|
||||||
|
|
||||||
// User profile management routes
|
// User profile management routes
|
||||||
Route::post('/profile', [UserController::class, 'updateProfile']);
|
Route::post('/profile', [UserController::class, 'updateProfile']);
|
||||||
|
|||||||
@@ -80,6 +80,18 @@ class FortifyServiceProvider extends ServiceProvider
|
|||||||
return Limit::perMinute(5)->by($throttleKey);
|
return Limit::perMinute(5)->by($throttleKey);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
RateLimiter::for('phone-verification-send', function (Request $request) {
|
||||||
|
$throttleKey = Str::transliterate($request->input('phone_number', '').'|'.$request->ip());
|
||||||
|
|
||||||
|
return Limit::perMinute(5)->by($throttleKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
RateLimiter::for('phone-verification-verify', function (Request $request) {
|
||||||
|
$throttleKey = Str::transliterate($request->input('phone_number', '').'|'.$request->ip());
|
||||||
|
|
||||||
|
return Limit::perMinute(5)->by($throttleKey);
|
||||||
|
});
|
||||||
|
|
||||||
Fortify::authenticateUsing(function (Request $request) {
|
Fortify::authenticateUsing(function (Request $request) {
|
||||||
$user = User::where('email', $request->email)->first();
|
$user = User::where('email', $request->email)->first();
|
||||||
|
|
||||||
|
|||||||
@@ -133,4 +133,17 @@ return [
|
|||||||
'max_attempts' => (int) env('PASSWORD_RESET_OTP_MAX_ATTEMPTS', 5),
|
'max_attempts' => (int) env('PASSWORD_RESET_OTP_MAX_ATTEMPTS', 5),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Phone Verification (Registration)
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
'phone_verification' => [
|
||||||
|
'otp_expiry_minutes' => (int) env('PHONE_VERIFICATION_OTP_EXPIRY_MINUTES', 10),
|
||||||
|
'token_expiry_minutes' => (int) env('PHONE_VERIFICATION_TOKEN_EXPIRY_MINUTES', 30),
|
||||||
|
'max_attempts' => (int) env('PHONE_VERIFICATION_OTP_MAX_ATTEMPTS', 5),
|
||||||
|
'message' => 'Kod OTP MyKOPKB anda: :otp. Kod ini tamat tempoh dalam :minutes minit.',
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
/*
|
||||||
|
| Supported: "onewaysms", "log"
|
||||||
|
*/
|
||||||
|
'driver' => env('SMS_DRIVER', 'onewaysms'),
|
||||||
|
|
||||||
|
'base_url' => env('ONEWAYSMS_BASE_URL'),
|
||||||
|
'api_username' => env('ONEWAYSMS_API_USERNAME'),
|
||||||
|
'api_password' => env('ONEWAYSMS_API_PASSWORD'),
|
||||||
|
'sender_id' => env('ONEWAYSMS_SENDER_ID'),
|
||||||
|
];
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('phone_verification_otps', function (Blueprint $table) {
|
||||||
|
$table->uuid('id')->primary();
|
||||||
|
$table->string('phone_number');
|
||||||
|
$table->string('code');
|
||||||
|
$table->timestamp('expires_at');
|
||||||
|
$table->unsignedTinyInteger('attempts')->default(0);
|
||||||
|
$table->timestamp('verified_at')->nullable();
|
||||||
|
$table->string('verification_token')->nullable();
|
||||||
|
$table->timestamp('verification_token_expires_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['phone_number', 'expires_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('phone_verification_otps');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->timestamp('phone_verified_at')->nullable()->after('phone_number');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('phone_verified_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export 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
|
||||||
@@ -4,24 +4,35 @@ export {
|
|||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
register,
|
register,
|
||||||
|
sendPhoneVerificationOtp,
|
||||||
|
verifyPhoneVerificationOtp,
|
||||||
|
sendAuthenticatedPhoneVerificationOtp,
|
||||||
|
verifyAuthenticatedPhoneVerificationOtp,
|
||||||
sendVerificationEmail,
|
sendVerificationEmail,
|
||||||
requestForgotPassword,
|
requestForgotPassword,
|
||||||
resetPassword,
|
resetPassword,
|
||||||
fetchCurrentUser,
|
fetchCurrentUser,
|
||||||
getAuthErrorMessage,
|
getAuthErrorMessage,
|
||||||
getRegisterErrorMessage,
|
getRegisterErrorMessage,
|
||||||
|
getPhoneVerificationErrorMessage,
|
||||||
getForgotPasswordErrorMessage,
|
getForgotPasswordErrorMessage,
|
||||||
getResetPasswordErrorMessage,
|
getResetPasswordErrorMessage,
|
||||||
resolvePostLoginRoute,
|
resolvePostLoginRoute,
|
||||||
resolvePostAuthRoute,
|
resolvePostAuthRoute,
|
||||||
isAccountPending,
|
isAccountPending,
|
||||||
isEmailVerified,
|
isEmailVerified,
|
||||||
|
isPhoneVerified,
|
||||||
} from './services/auth.service'
|
} from './services/auth.service'
|
||||||
export type {
|
export type {
|
||||||
LoginCredentials,
|
LoginCredentials,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
RegisterCredentials,
|
RegisterCredentials,
|
||||||
RegisterResponse,
|
RegisterResponse,
|
||||||
|
SendPhoneVerificationOtpPayload,
|
||||||
|
SendPhoneVerificationOtpResponse,
|
||||||
|
VerifyPhoneVerificationOtpPayload,
|
||||||
|
VerifyPhoneVerificationOtpResponse,
|
||||||
|
VerifyAuthenticatedPhoneVerificationOtpResponse,
|
||||||
ResendVerificationResponse,
|
ResendVerificationResponse,
|
||||||
ForgotPasswordPayload,
|
ForgotPasswordPayload,
|
||||||
ForgotPasswordResponse,
|
ForgotPasswordResponse,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { RouterLink, useRouter } from 'vue-router'
|
import { RouterLink, useRouter } from 'vue-router'
|
||||||
import { Box } from '@/components/ui/box'
|
import { Box } from '@/components/ui/box'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -7,14 +7,32 @@ import { CheckboxRoot, CheckboxControl, CheckboxLabel } from '@/components/ui/ch
|
|||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||||
import { PasswordInput } from '@/components/ui/password-input'
|
import { PasswordInput } from '@/components/ui/password-input'
|
||||||
import { getRegisterErrorMessage, register, resolvePostAuthRoute } from '@/modules/auth'
|
import {
|
||||||
|
getPhoneVerificationErrorMessage,
|
||||||
|
getRegisterErrorMessage,
|
||||||
|
register,
|
||||||
|
resolvePostAuthRoute,
|
||||||
|
sendPhoneVerificationOtp,
|
||||||
|
verifyPhoneVerificationOtp,
|
||||||
|
} from '@/modules/auth'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { sanitizeIcNumberInput, sanitizeNameInput } from '@/utils/form-input.utils'
|
import { sanitizeIcNumberInput, sanitizeNameInput } from '@/utils/form-input.utils'
|
||||||
import illustrationUrl from '@/assets/images/logo.svg'
|
import illustrationUrl from '@/assets/images/logo.svg'
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{ id: 1, label: 'No. Telefon' },
|
||||||
|
{ id: 2, label: 'Sahkan OTP' },
|
||||||
|
{ id: 3, label: 'Maklumat Akaun' },
|
||||||
|
{ id: 4, label: 'Kata Laluan' },
|
||||||
|
] as const
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const currentStep = ref(1)
|
||||||
|
const phoneNumber = ref('')
|
||||||
|
const otp = ref('')
|
||||||
|
const phoneVerificationToken = ref('')
|
||||||
const name = ref('')
|
const name = ref('')
|
||||||
const email = ref('')
|
const email = ref('')
|
||||||
const icNumber = ref('')
|
const icNumber = ref('')
|
||||||
@@ -23,6 +41,61 @@ const passwordConfirmation = ref('')
|
|||||||
const termsAccepted = ref(false)
|
const termsAccepted = ref(false)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
|
const successMessage = ref('')
|
||||||
|
|
||||||
|
const stepTitle = computed(() => {
|
||||||
|
switch (currentStep.value) {
|
||||||
|
case 1:
|
||||||
|
return 'Sahkan Nombor Telefon'
|
||||||
|
case 2:
|
||||||
|
return 'Masukkan Kod OTP'
|
||||||
|
case 3:
|
||||||
|
return 'Maklumat Akaun'
|
||||||
|
default:
|
||||||
|
return 'Tetapkan Kata Laluan'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const stepDescription = computed(() => {
|
||||||
|
switch (currentStep.value) {
|
||||||
|
case 1:
|
||||||
|
return 'Masukkan nombor telefon anda untuk menerima kod OTP melalui SMS.'
|
||||||
|
case 2:
|
||||||
|
return 'Masukkan kod OTP 6 digit yang telah dihantar ke nombor telefon anda.'
|
||||||
|
case 3:
|
||||||
|
return 'Lengkapkan maklumat asas akaun anda.'
|
||||||
|
default:
|
||||||
|
return 'Tetapkan kata laluan dan bersetuju dengan terma pendaftaran.'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const primaryActionLabel = computed(() => {
|
||||||
|
if (loading.value) {
|
||||||
|
switch (currentStep.value) {
|
||||||
|
case 1:
|
||||||
|
return 'Menghantar OTP...'
|
||||||
|
case 2:
|
||||||
|
return 'Mengesahkan OTP...'
|
||||||
|
case 3:
|
||||||
|
return 'Seterusnya'
|
||||||
|
default:
|
||||||
|
return 'Mendaftar...'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (currentStep.value) {
|
||||||
|
case 1:
|
||||||
|
return 'Hantar OTP'
|
||||||
|
case 2:
|
||||||
|
return 'Sahkan OTP'
|
||||||
|
case 3:
|
||||||
|
return 'Seterusnya'
|
||||||
|
default:
|
||||||
|
return 'Daftar'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const inputClass = 'box block min-w-full px-4 py-4 xl:min-w-md'
|
||||||
|
|
||||||
const handleNameInput = () => {
|
const handleNameInput = () => {
|
||||||
name.value = sanitizeNameInput(name.value)
|
name.value = sanitizeNameInput(name.value)
|
||||||
@@ -32,13 +105,102 @@ const handleIcNumberInput = () => {
|
|||||||
icNumber.value = sanitizeIcNumberInput(icNumber.value)
|
icNumber.value = sanitizeIcNumberInput(icNumber.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handlePhoneInput = () => {
|
||||||
|
phoneNumber.value = phoneNumber.value.replace(/[^\d+]/g, '')
|
||||||
|
phoneVerificationToken.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOtpInput = () => {
|
||||||
|
otp.value = otp.value.replace(/\D/g, '').slice(0, 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearMessages = () => {
|
||||||
|
errorMessage.value = ''
|
||||||
|
successMessage.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSendOtp = async () => {
|
||||||
|
clearMessages()
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await sendPhoneVerificationOtp({
|
||||||
|
phone_number: phoneNumber.value,
|
||||||
|
})
|
||||||
|
|
||||||
|
successMessage.value = response.message
|
||||||
|
currentStep.value = 2
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = getPhoneVerificationErrorMessage(error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleVerifyOtp = async () => {
|
||||||
|
clearMessages()
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await verifyPhoneVerificationOtp({
|
||||||
|
phone_number: phoneNumber.value,
|
||||||
|
otp: otp.value,
|
||||||
|
})
|
||||||
|
|
||||||
|
phoneNumber.value = response.data.phone_number
|
||||||
|
phoneVerificationToken.value = response.data.verification_token
|
||||||
|
successMessage.value = response.message
|
||||||
|
currentStep.value = 3
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = getPhoneVerificationErrorMessage(error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateAccountStep = (): boolean => {
|
||||||
|
clearMessages()
|
||||||
|
|
||||||
|
if (!name.value.trim()) {
|
||||||
|
errorMessage.value = 'Nama penuh diperlukan.'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!email.value.trim()) {
|
||||||
|
errorMessage.value = 'Emel diperlukan.'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.value)) {
|
||||||
|
errorMessage.value = 'Emel tidak sah.'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!icNumber.value.trim()) {
|
||||||
|
errorMessage.value = 'No. kad pengenalan diperlukan.'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
const handleRegister = async () => {
|
const handleRegister = async () => {
|
||||||
|
if (!password.value || !passwordConfirmation.value) {
|
||||||
|
errorMessage.value = 'Kata laluan diperlukan.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.value !== passwordConfirmation.value) {
|
||||||
|
errorMessage.value = 'Pengesahan kata laluan tidak sepadan.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (!termsAccepted.value) {
|
if (!termsAccepted.value) {
|
||||||
errorMessage.value = 'Sila bersetuju dengan Dasar Privasi dan Terma dan Syarat.'
|
errorMessage.value = 'Sila bersetuju dengan Dasar Privasi dan Terma dan Syarat.'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
errorMessage.value = ''
|
clearMessages()
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -46,6 +208,8 @@ const handleRegister = async () => {
|
|||||||
name: name.value,
|
name: name.value,
|
||||||
email: email.value,
|
email: email.value,
|
||||||
ic_number: icNumber.value,
|
ic_number: icNumber.value,
|
||||||
|
phone_number: phoneNumber.value,
|
||||||
|
phone_verification_token: phoneVerificationToken.value,
|
||||||
password: password.value,
|
password: password.value,
|
||||||
password_confirmation: passwordConfirmation.value,
|
password_confirmation: passwordConfirmation.value,
|
||||||
})
|
})
|
||||||
@@ -61,21 +225,70 @@ const handleRegister = async () => {
|
|||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handlePrimaryAction = async () => {
|
||||||
|
if (currentStep.value === 1) {
|
||||||
|
await handleSendOtp()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentStep.value === 2) {
|
||||||
|
await handleVerifyOtp()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentStep.value === 3) {
|
||||||
|
if (!validateAccountStep()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
currentStep.value = 4
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await handleRegister()
|
||||||
|
}
|
||||||
|
|
||||||
|
const goPrevious = () => {
|
||||||
|
clearMessages()
|
||||||
|
|
||||||
|
if (currentStep.value > 1) {
|
||||||
|
currentStep.value -= 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stepButtonClass = (stepId: number) => {
|
||||||
|
if (stepId === currentStep.value) {
|
||||||
|
return 'size-10 rounded-full shadow-none'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stepId < currentStep.value) {
|
||||||
|
return 'size-10 rounded-full shadow-none bg-primary text-primary-foreground'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'bg-background border border-foreground/15 shadow-none size-10 rounded-full'
|
||||||
|
}
|
||||||
|
|
||||||
|
const stepLabelClass = (stepId: number) => {
|
||||||
|
return stepId === currentStep.value
|
||||||
|
? 'mt-2 text-xs font-medium text-primary'
|
||||||
|
: 'mt-2 text-xs opacity-70'
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div :class="[
|
<div :class="[
|
||||||
'relative h-screen lg:overflow-hidden bg-primary bg-noise xl:bg-background xl:bg-none',
|
'relative min-h-dvh bg-primary bg-noise xl:h-screen xl:overflow-hidden xl:bg-background xl:bg-none',
|
||||||
'before:hidden before:xl:block before:content-[\'\'] before:w-[57%] before:mt-[-28%] before:mb-[-16%] before:ml-[-12%] before:absolute before:inset-y-0 before:left-0 before:transform before:rotate-6 before:bg-primary/95 before:bg-noise before:rounded-[35%]',
|
'before:hidden before:xl:block before:content-[\'\'] before:w-[57%] before:mt-[-28%] before:mb-[-16%] before:ml-[-12%] before:absolute before:inset-y-0 before:left-0 before:transform before:rotate-6 before:bg-primary/95 before:bg-noise before:rounded-[35%]',
|
||||||
'after:hidden after:xl:block after:content-[\'\'] after:w-[57%] after:mt-[-28%] after:mb-[-16%] after:ml-[-12%] after:absolute after:inset-y-0 after:left-0 after:transform after:rotate-6 after:border after:bg-accent after:bg-cover after:blur-xl after:rounded-[35%] after:border-primary',
|
'after:hidden after:xl:block after:content-[\'\'] after:w-[57%] after:mt-[-28%] after:mb-[-16%] after:ml-[-12%] after:absolute after:inset-y-0 after:left-0 after:transform after:rotate-6 after:border after:bg-accent after:bg-cover after:blur-xl after:rounded-[35%] after:border-primary',
|
||||||
]">
|
]">
|
||||||
<div :class="[
|
<div :class="[
|
||||||
'p-3 sm:px-8 relative h-full',
|
'relative p-3 sm:px-8',
|
||||||
'before:hidden before:xl:block before:w-[57%] before:mt-[-20%] before:mb-[-13%] before:ml-[-12%] before:absolute before:inset-y-0 before:left-0 before:transform before:-rotate-6 before:bg-primary/40 before:bg-noise before:border before:border-primary/50 before:opacity-60 before:rounded-[20%]',
|
'before:hidden before:xl:block before:w-[57%] before:mt-[-20%] before:mb-[-13%] before:ml-[-12%] before:absolute before:inset-y-0 before:left-0 before:transform before:-rotate-6 before:bg-primary/40 before:bg-noise before:border before:border-primary/50 before:opacity-60 before:rounded-[20%]',
|
||||||
]">
|
]">
|
||||||
<div class="container relative z-10 mx-auto sm:px-20">
|
<div class="container relative z-10 mx-auto sm:px-20">
|
||||||
<div class="block grid-cols-2 gap-4 xl:grid">
|
<div class="block xl:grid xl:grid-cols-2 xl:gap-4">
|
||||||
<div class="hidden min-h-screen flex-col xl:flex">
|
<div class="hidden min-h-screen flex-col xl:flex">
|
||||||
<div class="my-auto">
|
<div class="my-auto">
|
||||||
<img class="-mt-16 w-1/2" :src="illustrationUrl" alt="logo-RAJD" />
|
<img class="-mt-16 w-1/2" :src="illustrationUrl" alt="logo-RAJD" />
|
||||||
@@ -88,61 +301,115 @@ const handleRegister = async () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="my-10 flex h-screen py-5 xl:my-0 xl:h-auto xl:py-0">
|
<div class="my-6 py-4 xl:my-0 xl:flex xl:min-h-screen xl:items-center xl:py-0">
|
||||||
<Box raised="double"
|
<Box raised="double"
|
||||||
class="mx-auto my-auto w-full px-5 py-8 sm:w-3/4 sm:px-8 lg:w-2/4 xl:ml-24 xl:w-auto xl:p-0 xl:before:hidden xl:after:hidden xl:shadow-none xl:border-none xl:bg-none">
|
class="mx-auto w-full px-5 py-6 sm:w-3/4 sm:px-8 lg:w-2/4 xl:ml-24 xl:w-auto xl:p-0 xl:before:hidden xl:after:hidden xl:shadow-none xl:border-none xl:bg-none">
|
||||||
<h2 class="text-center text-2xl font-semibold xl:text-left xl:text-3xl">Daftar Akaun</h2>
|
<h2 class="text-center text-2xl font-semibold xl:text-left xl:text-3xl">Daftar Akaun</h2>
|
||||||
<div class="mt-2 text-center opacity-70 xl:hidden">
|
|
||||||
Daftar Akaun
|
<div
|
||||||
|
class="before:bg-foreground/10 relative mt-5 flex flex-row justify-between gap-1 px-1 before:absolute before:bottom-[calc(50%-0.75rem)] before:left-[12%] before:right-[12%] before:h-0.5 before:w-auto">
|
||||||
|
<div v-for="step in steps" :key="step.id" class="z-10 flex flex-1 flex-col items-center text-center">
|
||||||
|
<Button :class="stepButtonClass(step.id)" :variant="step.id === currentStep ? 'primary' : 'ghost'"
|
||||||
|
type="button">
|
||||||
|
{{ step.id }}
|
||||||
|
</Button>
|
||||||
|
<div :class="stepLabelClass(step.id)">
|
||||||
|
{{ step.label }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AlertRoot v-if="errorMessage" class="mt-6" variant="danger">
|
<div class="mt-5 border-t border-foreground/10 pt-5">
|
||||||
<AlertTitle>Daftar gagal</AlertTitle>
|
<div class="text-center xl:text-left">
|
||||||
<AlertDescription>{{ errorMessage }}</AlertDescription>
|
<div class="text-base font-medium">{{ stepTitle }}</div>
|
||||||
</AlertRoot>
|
<div class="mt-1 text-sm opacity-70">{{ stepDescription }}</div>
|
||||||
|
|
||||||
<form class="mt-8 flex flex-col gap-5" @submit.prevent="handleRegister">
|
|
||||||
<Input v-model="name" class="box block min-w-full px-5 py-6 xl:min-w-md" type="text"
|
|
||||||
placeholder="Nama Penuh" autocomplete="name" required @input="handleNameInput" />
|
|
||||||
<Input v-model="email" class="box block min-w-full px-5 py-6 xl:min-w-md" type="email"
|
|
||||||
placeholder="Email" autocomplete="email" required />
|
|
||||||
<Input v-model="icNumber" class="box block min-w-full px-5 py-6 xl:min-w-md" type="text"
|
|
||||||
inputmode="numeric" maxlength="15" placeholder="Contoh: 900101011234" required
|
|
||||||
@input="handleIcNumberInput" />
|
|
||||||
<PasswordInput v-model="password" class="box block min-w-full px-5 py-6 xl:min-w-md" type="password"
|
|
||||||
placeholder="Kata Laluan" autocomplete="new-password" minlength="8" required />
|
|
||||||
<PasswordInput v-model="passwordConfirmation" class="box block min-w-full px-5 py-6 xl:min-w-md"
|
|
||||||
placeholder="Sahkan Kata Laluan" autocomplete="new-password" minlength="8" required />
|
|
||||||
|
|
||||||
<div class="flex text-xs sm:text-sm">
|
|
||||||
<CheckboxRoot :checked="termsAccepted"
|
|
||||||
@checked-change="({ checked }) => (termsAccepted = checked === true)">
|
|
||||||
<CheckboxControl />
|
|
||||||
<CheckboxLabel>
|
|
||||||
Dengan mendaftar, anda bersetuju dengan
|
|
||||||
<RouterLink class="text-primary ml-1" to="/privacy-policy">
|
|
||||||
Dasar Privasi
|
|
||||||
</RouterLink>
|
|
||||||
&
|
|
||||||
<RouterLink class="text-primary ml-1" to="/terms">
|
|
||||||
Terma dan Syarat
|
|
||||||
</RouterLink>
|
|
||||||
.
|
|
||||||
</CheckboxLabel>
|
|
||||||
</CheckboxRoot>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-5 text-center xl:mt-10 xl:text-left">
|
<AlertRoot v-if="errorMessage" class="mt-4 py-3" variant="danger">
|
||||||
<Button class="box w-full px-4 py-5" variant="primary" type="submit"
|
<AlertTitle>Ralat</AlertTitle>
|
||||||
:disabled="loading || !termsAccepted">
|
<AlertDescription>{{ errorMessage }}</AlertDescription>
|
||||||
{{ loading ? 'Mendaftar...' : 'Daftar' }}
|
</AlertRoot>
|
||||||
</Button>
|
|
||||||
<Button class="box mt-4 w-full px-4 py-5" look="outline" type="button"
|
<AlertRoot v-if="successMessage" class="mt-4 py-3" variant="success">
|
||||||
@click="router.push({ name: 'login' })">
|
<AlertTitle>Berjaya</AlertTitle>
|
||||||
Log masuk
|
<AlertDescription>{{ successMessage }}</AlertDescription>
|
||||||
</Button>
|
</AlertRoot>
|
||||||
</div>
|
|
||||||
</form>
|
<form class="mt-4 flex flex-col gap-5" @submit.prevent="handlePrimaryAction">
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<template v-if="currentStep === 1">
|
||||||
|
<Input v-model="phoneNumber" :class="inputClass" type="tel" inputmode="tel"
|
||||||
|
placeholder="No. Telefon, contoh: 0123456790" autocomplete="tel" required
|
||||||
|
@input="handlePhoneInput" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="currentStep === 2">
|
||||||
|
<Input v-model="otp" :class="`${inputClass} text-center tracking-[0.4em]`" type="text"
|
||||||
|
inputmode="numeric" maxlength="6" placeholder="000000" autocomplete="one-time-code" required
|
||||||
|
@input="handleOtpInput" />
|
||||||
|
<Button class="box w-full px-4 py-4" look="outline" type="button" :disabled="loading"
|
||||||
|
@click="handleSendOtp">
|
||||||
|
Hantar Semula OTP
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="currentStep === 3">
|
||||||
|
<div class="rounded-lg border border-foreground/10 bg-foreground/5 px-4 py-3 text-sm">
|
||||||
|
<span class="opacity-70">Telefon disahkan:</span>
|
||||||
|
<span class="ml-1 font-medium">{{ phoneNumber }}</span>
|
||||||
|
</div>
|
||||||
|
<Input v-model="name" :class="inputClass" type="text" placeholder="Nama Penuh" autocomplete="name"
|
||||||
|
required @input="handleNameInput" />
|
||||||
|
<Input v-model="email" :class="inputClass" type="email" placeholder="Email" autocomplete="email"
|
||||||
|
required />
|
||||||
|
<Input v-model="icNumber" :class="inputClass" type="text" inputmode="numeric" maxlength="15"
|
||||||
|
placeholder="Contoh: 900101011234" required @input="handleIcNumberInput" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<PasswordInput v-model="password" :class="inputClass" placeholder="Kata Laluan"
|
||||||
|
autocomplete="new-password" minlength="8" required />
|
||||||
|
<PasswordInput v-model="passwordConfirmation" :class="inputClass" placeholder="Sahkan Kata Laluan"
|
||||||
|
autocomplete="new-password" minlength="8" required />
|
||||||
|
|
||||||
|
<div class="flex text-xs sm:text-sm">
|
||||||
|
<CheckboxRoot :checked="termsAccepted"
|
||||||
|
@checked-change="({ checked }) => (termsAccepted = checked === true)">
|
||||||
|
<CheckboxControl />
|
||||||
|
<CheckboxLabel>
|
||||||
|
Dengan mendaftar, anda bersetuju dengan
|
||||||
|
<RouterLink class="text-primary ml-1" to="/privacy-policy">
|
||||||
|
Dasar Privasi
|
||||||
|
</RouterLink>
|
||||||
|
&
|
||||||
|
<RouterLink class="text-primary ml-1" to="/terms">
|
||||||
|
Terma dan Syarat
|
||||||
|
</RouterLink>
|
||||||
|
.
|
||||||
|
</CheckboxLabel>
|
||||||
|
</CheckboxRoot>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<Button v-if="currentStep > 1" class="box w-full px-4 py-4" look="outline" type="button"
|
||||||
|
:disabled="loading" @click="goPrevious">
|
||||||
|
Sebelum
|
||||||
|
</Button>
|
||||||
|
<Button class="box w-full px-4 py-4" variant="primary" type="submit"
|
||||||
|
:disabled="loading || (currentStep === 4 && !termsAccepted)">
|
||||||
|
{{ primaryActionLabel }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="w-full text-sm opacity-70 hover:opacity-100"
|
||||||
|
@click="router.push({ name: 'login' })">
|
||||||
|
Sudah mempunyai akaun? Log masuk
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</Box>
|
</Box>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ import type {
|
|||||||
ResetPasswordResponse,
|
ResetPasswordResponse,
|
||||||
ResendVerificationResponse,
|
ResendVerificationResponse,
|
||||||
SessionResponse,
|
SessionResponse,
|
||||||
|
SendPhoneVerificationOtpPayload,
|
||||||
|
SendPhoneVerificationOtpResponse,
|
||||||
SwitchRoleResponse,
|
SwitchRoleResponse,
|
||||||
|
VerifyPhoneVerificationOtpPayload,
|
||||||
|
VerifyPhoneVerificationOtpResponse,
|
||||||
|
VerifyAuthenticatedPhoneVerificationOtpResponse,
|
||||||
} from '../types/auth.types'
|
} from '../types/auth.types'
|
||||||
|
|
||||||
export async function login(credentials: LoginCredentials): Promise<LoginResponse> {
|
export async function login(credentials: LoginCredentials): Promise<LoginResponse> {
|
||||||
@@ -24,6 +29,46 @@ export async function register(credentials: RegisterCredentials): Promise<Regist
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function sendPhoneVerificationOtp(
|
||||||
|
payload: SendPhoneVerificationOtpPayload,
|
||||||
|
): Promise<SendPhoneVerificationOtpResponse> {
|
||||||
|
const { data } = await api.post<SendPhoneVerificationOtpResponse>(
|
||||||
|
'/phone-verification/send',
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPhoneVerificationOtp(
|
||||||
|
payload: VerifyPhoneVerificationOtpPayload,
|
||||||
|
): Promise<VerifyPhoneVerificationOtpResponse> {
|
||||||
|
const { data } = await api.post<VerifyPhoneVerificationOtpResponse>(
|
||||||
|
'/phone-verification/verify',
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendAuthenticatedPhoneVerificationOtp(
|
||||||
|
payload: SendPhoneVerificationOtpPayload,
|
||||||
|
): Promise<SendPhoneVerificationOtpResponse> {
|
||||||
|
const { data } = await api.post<SendPhoneVerificationOtpResponse>(
|
||||||
|
'/v1/phone-verification/send',
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyAuthenticatedPhoneVerificationOtp(
|
||||||
|
payload: VerifyPhoneVerificationOtpPayload,
|
||||||
|
): Promise<VerifyAuthenticatedPhoneVerificationOtpResponse> {
|
||||||
|
const { data } = await api.post<VerifyAuthenticatedPhoneVerificationOtpResponse>(
|
||||||
|
'/v1/phone-verification/verify',
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
export async function sendVerificationEmail(): Promise<ResendVerificationResponse> {
|
export async function sendVerificationEmail(): Promise<ResendVerificationResponse> {
|
||||||
const { data } = await api.post<ResendVerificationResponse>('/v1/email/verification-notification')
|
const { data } = await api.post<ResendVerificationResponse>('/v1/email/verification-notification')
|
||||||
return data
|
return data
|
||||||
@@ -65,6 +110,10 @@ export function getRegisterErrorMessage(error: unknown): string {
|
|||||||
return getApiErrorMessage(error, 'Registration failed. Please try again.')
|
return getApiErrorMessage(error, 'Registration failed. Please try again.')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getPhoneVerificationErrorMessage(error: unknown): string {
|
||||||
|
return getApiErrorMessage(error, 'Gagal mengesahkan nombor telefon. Sila cuba lagi.')
|
||||||
|
}
|
||||||
|
|
||||||
export function getForgotPasswordErrorMessage(error: unknown): string {
|
export function getForgotPasswordErrorMessage(error: unknown): string {
|
||||||
return getApiErrorMessage(error, 'Gagal menghantar kod OTP. Sila cuba lagi.')
|
return getApiErrorMessage(error, 'Gagal menghantar kod OTP. Sila cuba lagi.')
|
||||||
}
|
}
|
||||||
@@ -77,6 +126,10 @@ export function isEmailVerified(user: { email_verified_at?: string | null } | nu
|
|||||||
return Boolean(user?.email_verified_at)
|
return Boolean(user?.email_verified_at)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isPhoneVerified(user: { phone_verified_at?: string | null } | null | undefined): boolean {
|
||||||
|
return Boolean(user?.phone_verified_at)
|
||||||
|
}
|
||||||
|
|
||||||
export function isAccountPending(user: { status: string } | null | undefined): boolean {
|
export function isAccountPending(user: { status: string } | null | undefined): boolean {
|
||||||
return user?.status === 'pending'
|
return user?.status === 'pending'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export interface AuthUser {
|
|||||||
ic_number: string | null
|
ic_number: string | null
|
||||||
position: string | null
|
position: string | null
|
||||||
phone_number: string | null
|
phone_number: string | null
|
||||||
|
phone_verified_at: string | null
|
||||||
image_url: string | null
|
image_url: string | null
|
||||||
member_number: number | null
|
member_number: number | null
|
||||||
member_type: string | null
|
member_type: string | null
|
||||||
@@ -74,12 +75,43 @@ export interface RegisterCredentials {
|
|||||||
name: string
|
name: string
|
||||||
email: string
|
email: string
|
||||||
ic_number: string
|
ic_number: string
|
||||||
|
phone_number: string
|
||||||
|
phone_verification_token: string
|
||||||
password: string
|
password: string
|
||||||
password_confirmation: string
|
password_confirmation: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RegisterResponse = AuthSessionResponse
|
export type RegisterResponse = AuthSessionResponse
|
||||||
|
|
||||||
|
export interface SendPhoneVerificationOtpPayload {
|
||||||
|
phone_number: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendPhoneVerificationOtpResponse {
|
||||||
|
success: boolean
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerifyPhoneVerificationOtpPayload {
|
||||||
|
phone_number: string
|
||||||
|
otp: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerifyPhoneVerificationOtpResponse {
|
||||||
|
success: boolean
|
||||||
|
message: string
|
||||||
|
data: {
|
||||||
|
phone_number: string
|
||||||
|
verification_token: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerifyAuthenticatedPhoneVerificationOtpResponse {
|
||||||
|
success: boolean
|
||||||
|
message: string
|
||||||
|
data: AuthUser
|
||||||
|
}
|
||||||
|
|
||||||
export interface ResendVerificationResponse {
|
export interface ResendVerificationResponse {
|
||||||
success: boolean
|
success: boolean
|
||||||
message: string
|
message: string
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Box } from '@/components/ui/box'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Field, FieldGroup, FieldLabel } from '@/components/ui/field'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import {
|
||||||
|
getPhoneVerificationErrorMessage,
|
||||||
|
sendAuthenticatedPhoneVerificationOtp,
|
||||||
|
verifyAuthenticatedPhoneVerificationOtp,
|
||||||
|
} from '@/modules/auth'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const changingPhone = ref(false)
|
||||||
|
const otpSent = ref(false)
|
||||||
|
const phoneNumber = ref(authStore.user?.phone_number ?? '')
|
||||||
|
const otp = ref('')
|
||||||
|
const feedbackMessage = ref('')
|
||||||
|
const errorMessage = ref('')
|
||||||
|
|
||||||
|
const isVerified = computed(() => authStore.isPhoneVerified)
|
||||||
|
|
||||||
|
const sectionDescription = computed(() => {
|
||||||
|
if (changingPhone.value) {
|
||||||
|
return 'Masukkan nombor telefon baharu dan sahkan dengan kod OTP SMS.'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isVerified.value) {
|
||||||
|
return 'Nombor telefon anda telah disahkan.'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Sahkan nombor telefon anda melalui kod OTP SMS.'
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => authStore.user?.phone_number,
|
||||||
|
(value) => {
|
||||||
|
if (!otpSent.value && !changingPhone.value) {
|
||||||
|
phoneNumber.value = value ?? ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const resetOtpState = () => {
|
||||||
|
otp.value = ''
|
||||||
|
otpSent.value = false
|
||||||
|
clearMessages()
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearMessages = () => {
|
||||||
|
feedbackMessage.value = ''
|
||||||
|
errorMessage.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePhoneInput = () => {
|
||||||
|
phoneNumber.value = phoneNumber.value.replace(/[^\d+]/g, '')
|
||||||
|
otp.value = ''
|
||||||
|
otpSent.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOtpInput = () => {
|
||||||
|
otp.value = otp.value.replace(/\D/g, '').slice(0, 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
const startChangePhone = () => {
|
||||||
|
changingPhone.value = true
|
||||||
|
phoneNumber.value = ''
|
||||||
|
resetOtpState()
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelChangePhone = () => {
|
||||||
|
changingPhone.value = false
|
||||||
|
phoneNumber.value = authStore.user?.phone_number ?? ''
|
||||||
|
resetOtpState()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSendOtp = async () => {
|
||||||
|
loading.value = true
|
||||||
|
clearMessages()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await sendAuthenticatedPhoneVerificationOtp({
|
||||||
|
phone_number: phoneNumber.value,
|
||||||
|
})
|
||||||
|
|
||||||
|
feedbackMessage.value = response.message
|
||||||
|
otpSent.value = true
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = getPhoneVerificationErrorMessage(error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleVerifyOtp = async () => {
|
||||||
|
loading.value = true
|
||||||
|
clearMessages()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await verifyAuthenticatedPhoneVerificationOtp({
|
||||||
|
phone_number: phoneNumber.value,
|
||||||
|
otp: otp.value,
|
||||||
|
})
|
||||||
|
|
||||||
|
authStore.setUserProfile(response.data)
|
||||||
|
phoneNumber.value = response.data.phone_number ?? phoneNumber.value
|
||||||
|
changingPhone.value = false
|
||||||
|
otp.value = ''
|
||||||
|
otpSent.value = false
|
||||||
|
feedbackMessage.value = response.message
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = getPhoneVerificationErrorMessage(error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Box raised="single" class="p-6">
|
||||||
|
<div class="mb-6">
|
||||||
|
<h3 class="text-lg font-semibold text-slate-900">Nombor Telefon</h3>
|
||||||
|
<p class="mt-1 text-sm text-slate-500">
|
||||||
|
{{ sectionDescription }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isVerified && !changingPhone" class="space-y-4">
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<Input id="profile-phone-verified" :model-value="authStore.user?.phone_number ?? '-'" type="tel" disabled
|
||||||
|
class="max-w-sm" />
|
||||||
|
<Badge variant="success">Disahkan</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="feedbackMessage" class="text-sm text-slate-600">{{ feedbackMessage }}</p>
|
||||||
|
|
||||||
|
<Button type="button" look="outline" :disabled="loading" @click="startChangePhone">
|
||||||
|
Tukar Nombor Telefon
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-4">
|
||||||
|
<FieldGroup>
|
||||||
|
<Field class="max-w-sm">
|
||||||
|
<FieldLabel for="profile-phone-verify">No. Telefon</FieldLabel>
|
||||||
|
<Input id="profile-phone-verify" v-model="phoneNumber" type="tel" inputmode="tel"
|
||||||
|
placeholder="No. Telefon, contoh: 0123456790" autocomplete="tel" :disabled="loading || otpSent"
|
||||||
|
@input="handlePhoneInput" />
|
||||||
|
</Field>
|
||||||
|
<Field v-if="otpSent" class="max-w-sm">
|
||||||
|
<FieldLabel for="profile-phone-otp">Kod OTP</FieldLabel>
|
||||||
|
<Input id="profile-phone-otp" v-model="otp" type="text" inputmode="numeric" maxlength="6" placeholder="000000"
|
||||||
|
autocomplete="one-time-code" class="text-center tracking-[0.4em]" :disabled="loading"
|
||||||
|
@input="handleOtpInput" />
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
|
||||||
|
<p v-if="feedbackMessage" class="text-sm text-slate-600">{{ feedbackMessage }}</p>
|
||||||
|
<p v-if="errorMessage" class="text-sm text-danger">{{ errorMessage }}</p>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<Button v-if="!otpSent" type="button" variant="primary" :disabled="loading || !phoneNumber"
|
||||||
|
@click="handleSendOtp">
|
||||||
|
{{ loading ? 'Menghantar...' : 'Hantar Kod OTP' }}
|
||||||
|
</Button>
|
||||||
|
<template v-else>
|
||||||
|
<Button type="button" variant="primary" :disabled="loading || otp.length !== 6" @click="handleVerifyOtp">
|
||||||
|
{{ loading ? 'Mengesahkan...' : 'Sahkan OTP' }}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" look="outline" :disabled="loading" @click="handleSendOtp">
|
||||||
|
Hantar Semula OTP
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
<Button v-if="changingPhone" type="button" look="outline" :disabled="loading" @click="cancelChangePhone">
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Box>
|
||||||
|
</template>
|
||||||
@@ -72,7 +72,7 @@ const EMPLOYMENT_TYPE_OPTIONS: SelectOption[] = [
|
|||||||
// TODO: replace with API lookup
|
// TODO: replace with API lookup
|
||||||
const EMPLOYERS = [
|
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',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -89,6 +89,10 @@ const EMPLOYERS = [
|
|||||||
name: "An-Nisa'",
|
name: "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.",
|
||||||
|
address: "Tingkat 2 Menara Perbadanan, Jalan Tengku Petra Semerak, 15000 Kota Bharu, Kelantan.",
|
||||||
|
}
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
const COMPANY_OPTIONS: SelectOption[] = EMPLOYERS.map((employer) => ({
|
const COMPANY_OPTIONS: SelectOption[] = EMPLOYERS.map((employer) => ({
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { updateProfile } from '@/modules/profile/services/profile.service'
|
|||||||
import type { Address, AddressPayload } from '@/modules/profile/types/address.types'
|
import type { Address, AddressPayload } from '@/modules/profile/types/address.types'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import HeirTab from './HeirTab.vue'
|
import HeirTab from './HeirTab.vue'
|
||||||
|
import PhoneVerificationSection from '../components/PhoneVerificationSection.vue'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
embedded?: boolean
|
embedded?: boolean
|
||||||
@@ -44,7 +45,6 @@ const form = reactive({
|
|||||||
name: '',
|
name: '',
|
||||||
ic_number: '',
|
ic_number: '',
|
||||||
position: '',
|
position: '',
|
||||||
phone_number: '',
|
|
||||||
birth_date: '',
|
birth_date: '',
|
||||||
birth_place: '',
|
birth_place: '',
|
||||||
})
|
})
|
||||||
@@ -350,7 +350,6 @@ function syncFormFromUser() {
|
|||||||
form.name = user.name ?? ''
|
form.name = user.name ?? ''
|
||||||
form.ic_number = user.ic_number ?? ''
|
form.ic_number = user.ic_number ?? ''
|
||||||
form.position = user.position ?? ''
|
form.position = user.position ?? ''
|
||||||
form.phone_number = user.phone_number ?? ''
|
|
||||||
form.birth_date = toDateInputValue(user.birth_date)
|
form.birth_date = toDateInputValue(user.birth_date)
|
||||||
form.birth_place = user.birth_place ?? ''
|
form.birth_place = user.birth_place ?? ''
|
||||||
syncProfileSelectValues()
|
syncProfileSelectValues()
|
||||||
@@ -368,7 +367,6 @@ async function onSaveProfile() {
|
|||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
ic_number: form.ic_number.trim(),
|
ic_number: form.ic_number.trim(),
|
||||||
position: form.position.trim(),
|
position: form.position.trim(),
|
||||||
phone_number: form.phone_number.trim(),
|
|
||||||
gender: gender ?? undefined,
|
gender: gender ?? undefined,
|
||||||
marriage_status: marriageStatus ?? undefined,
|
marriage_status: marriageStatus ?? undefined,
|
||||||
birth_date: form.birth_date || undefined,
|
birth_date: form.birth_date || undefined,
|
||||||
@@ -591,11 +589,6 @@ onMounted(async () => {
|
|||||||
<Input id="profile-ic" v-model="form.ic_number" type="text" inputmode="numeric" maxlength="15"
|
<Input id="profile-ic" v-model="form.ic_number" type="text" inputmode="numeric" maxlength="15"
|
||||||
placeholder="Contoh: 900101011234" @input="handleIcNumberInput" />
|
placeholder="Contoh: 900101011234" @input="handleIcNumberInput" />
|
||||||
</Field>
|
</Field>
|
||||||
<Field>
|
|
||||||
<FieldLabel for="profile-phone">No. Telefon</FieldLabel>
|
|
||||||
<Input id="profile-phone" v-model="form.phone_number" type="tel" pattern="[0-9]*"
|
|
||||||
placeholder="0123456789" />
|
|
||||||
</Field>
|
|
||||||
<Field class="md:col-span-2">
|
<Field class="md:col-span-2">
|
||||||
<FieldLabel for="profile-position">Jawatan</FieldLabel>
|
<FieldLabel for="profile-position">Jawatan</FieldLabel>
|
||||||
<Input id="profile-position" v-model="form.position" type="text" placeholder="Jawatan" />
|
<Input id="profile-position" v-model="form.position" type="text" placeholder="Jawatan" />
|
||||||
@@ -677,6 +670,8 @@ onMounted(async () => {
|
|||||||
</form>
|
</form>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
<PhoneVerificationSection />
|
||||||
|
|
||||||
<Box raised="single" class="p-6">
|
<Box raised="single" class="p-6">
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<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">
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export function useUserList() {
|
|||||||
const joinDateTo = ref('')
|
const joinDateTo = ref('')
|
||||||
const leaveDateFrom = ref('')
|
const leaveDateFrom = ref('')
|
||||||
const leaveDateTo = ref('')
|
const leaveDateTo = ref('')
|
||||||
|
const unitFilter = ref('')
|
||||||
const sortBy = ref<SortConfig[]>([{ key: 'name', order: 'asc' }])
|
const sortBy = ref<SortConfig[]>([{ key: 'name', order: 'asc' }])
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const itemsPerPage = ref(10)
|
const itemsPerPage = ref(10)
|
||||||
@@ -55,6 +56,7 @@ export function useUserList() {
|
|||||||
join_date_to: joinDateTo.value || undefined,
|
join_date_to: joinDateTo.value || undefined,
|
||||||
leave_date_from: leaveDateFrom.value || undefined,
|
leave_date_from: leaveDateFrom.value || undefined,
|
||||||
leave_date_to: leaveDateTo.value || undefined,
|
leave_date_to: leaveDateTo.value || undefined,
|
||||||
|
company_name: unitFilter.value.trim() || undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
users.value = data.data
|
users.value = data.data
|
||||||
@@ -79,6 +81,7 @@ export function useUserList() {
|
|||||||
join_date_to: joinDateTo.value || undefined,
|
join_date_to: joinDateTo.value || undefined,
|
||||||
leave_date_from: leaveDateFrom.value || undefined,
|
leave_date_from: leaveDateFrom.value || undefined,
|
||||||
leave_date_to: leaveDateTo.value || undefined,
|
leave_date_to: leaveDateTo.value || undefined,
|
||||||
|
company_name: unitFilter.value.trim() || undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
stats.value = res.data
|
stats.value = res.data
|
||||||
@@ -109,6 +112,11 @@ export function useUserList() {
|
|||||||
fetchStats()
|
fetchStats()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(unitFilter, () => {
|
||||||
|
fetchUsers(1)
|
||||||
|
fetchStats()
|
||||||
|
})
|
||||||
|
|
||||||
watch([joinDateFrom, joinDateTo, leaveDateFrom, leaveDateTo], () => {
|
watch([joinDateFrom, joinDateTo, leaveDateFrom, leaveDateTo], () => {
|
||||||
fetchUsers(1)
|
fetchUsers(1)
|
||||||
fetchStats()
|
fetchStats()
|
||||||
@@ -144,6 +152,7 @@ export function useUserList() {
|
|||||||
joinDateTo,
|
joinDateTo,
|
||||||
leaveDateFrom,
|
leaveDateFrom,
|
||||||
leaveDateTo,
|
leaveDateTo,
|
||||||
|
unitFilter,
|
||||||
hasJoinDateFilters,
|
hasJoinDateFilters,
|
||||||
hasLeaveDateFilters,
|
hasLeaveDateFilters,
|
||||||
clearJoinDateFilters,
|
clearJoinDateFilters,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, reactive, ref } from 'vue'
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import * as select from '@zag-js/select'
|
import * as select from '@zag-js/select'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||||
import { Box } from '@/components/ui/box'
|
import { Box } from '@/components/ui/box'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -102,6 +103,7 @@ const form = reactive({
|
|||||||
phone_number: '',
|
phone_number: '',
|
||||||
member_number: '',
|
member_number: '',
|
||||||
join_date: '',
|
join_date: '',
|
||||||
|
leave_date: '',
|
||||||
birth_date: '',
|
birth_date: '',
|
||||||
birth_place: '',
|
birth_place: '',
|
||||||
})
|
})
|
||||||
@@ -154,6 +156,7 @@ async function handleSubmit() {
|
|||||||
member_number: Number(form.member_number),
|
member_number: Number(form.member_number),
|
||||||
member_type: memberType!,
|
member_type: memberType!,
|
||||||
join_date: form.join_date,
|
join_date: form.join_date,
|
||||||
|
leave_date: isInactiveStatus.value ? form.leave_date || null : null,
|
||||||
birth_date: form.birth_date,
|
birth_date: form.birth_date,
|
||||||
birth_place: form.birth_place.trim(),
|
birth_place: form.birth_place.trim(),
|
||||||
})
|
})
|
||||||
@@ -170,6 +173,17 @@ async function handleSubmit() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const formDisabled = computed(() => saving.value)
|
const formDisabled = computed(() => saving.value)
|
||||||
|
|
||||||
|
const selectedStatus = computed(
|
||||||
|
() => labelToApiValue(STATUS_OPTIONS, statusValue.value[0]) ?? 'pending',
|
||||||
|
)
|
||||||
|
const isInactiveStatus = computed(() => selectedStatus.value === 'inactive')
|
||||||
|
|
||||||
|
watch(selectedStatus, (newStatus, oldStatus) => {
|
||||||
|
if (newStatus === 'inactive' && oldStatus !== 'inactive' && !form.leave_date) {
|
||||||
|
form.leave_date = dayjs().format('YYYY-MM-DD')
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -417,6 +431,17 @@ const formDisabled = computed(() => saving.value)
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
<Field v-if="isInactiveStatus">
|
||||||
|
<FieldLabel for="user-leave-date">Tarikh Berhenti Menjadi Anggota</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="user-leave-date"
|
||||||
|
v-model="form.leave_date"
|
||||||
|
class="w-full"
|
||||||
|
type="date"
|
||||||
|
:disabled="formDisabled"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel for="user-birth-date">Tarikh Lahir</FieldLabel>
|
<FieldLabel for="user-birth-date">Tarikh Lahir</FieldLabel>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -3,11 +3,15 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import * as select from '@zag-js/select'
|
import * as select from '@zag-js/select'
|
||||||
|
import Swal from 'sweetalert2'
|
||||||
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
import { AlertRoot, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Box } from '@/components/ui/box'
|
import { Box } from '@/components/ui/box'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Field, FieldLabel } from '@/components/ui/field'
|
import { CheckboxRoot, CheckboxControl, CheckboxLabel } from '@/components/ui/checkbox'
|
||||||
|
import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Lucide } from '@/components/ui/lucide'
|
||||||
import {
|
import {
|
||||||
SelectRoot,
|
SelectRoot,
|
||||||
SelectControl,
|
SelectControl,
|
||||||
@@ -19,8 +23,14 @@ import {
|
|||||||
SelectItem,
|
SelectItem,
|
||||||
SelectItemText,
|
SelectItemText,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
import { getApiErrorMessage, getApiValidationErrors } from '@/core/utils/getApiErrorMessage'
|
||||||
|
import type { Employment, EmploymentPayload } from '@/modules/profile/types/employment.types'
|
||||||
import { sanitizeIcNumberInput, sanitizeNameInput } from '@/utils/form-input.utils'
|
import { sanitizeIcNumberInput, sanitizeNameInput } from '@/utils/form-input.utils'
|
||||||
|
import {
|
||||||
|
createUserEmployment,
|
||||||
|
deleteUserEmployment,
|
||||||
|
updateUserEmployment,
|
||||||
|
} from '../services/userEmployment.service'
|
||||||
import { getUser, updateUser } from '../services/user.service'
|
import { getUser, updateUser } from '../services/user.service'
|
||||||
|
|
||||||
type SelectOption = { label: string; value: string }
|
type SelectOption = { label: string; value: string }
|
||||||
@@ -49,6 +59,62 @@ const MEMBER_TYPE_OPTIONS: SelectOption[] = [
|
|||||||
{ label: 'Pesara', value: 'Pesara' },
|
{ label: 'Pesara', value: 'Pesara' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const EMPLOYMENT_TYPE_OPTIONS: SelectOption[] = [
|
||||||
|
{ label: 'Tetap', value: 'Permanent' },
|
||||||
|
{ label: 'Kontrak', value: 'Contract' },
|
||||||
|
{ label: 'Latihan Industri', value: 'Internship' },
|
||||||
|
{ label: 'Freelance', value: 'Freelance' },
|
||||||
|
]
|
||||||
|
|
||||||
|
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) => ({
|
||||||
|
label: employer.name,
|
||||||
|
value: employer.name,
|
||||||
|
}))
|
||||||
|
|
||||||
|
type EmploymentFieldKey =
|
||||||
|
| 'company_name'
|
||||||
|
| 'job_title'
|
||||||
|
| 'employment_type'
|
||||||
|
| 'salary'
|
||||||
|
| 'start_date'
|
||||||
|
| 'end_date'
|
||||||
|
| 'is_current'
|
||||||
|
|
||||||
|
const EMPLOYMENT_FIELD_KEYS: EmploymentFieldKey[] = [
|
||||||
|
'company_name',
|
||||||
|
'job_title',
|
||||||
|
'employment_type',
|
||||||
|
'salary',
|
||||||
|
'start_date',
|
||||||
|
'end_date',
|
||||||
|
'is_current',
|
||||||
|
]
|
||||||
|
|
||||||
function createSelectCollection(options: SelectOption[]) {
|
function createSelectCollection(options: SelectOption[]) {
|
||||||
return select.collection({
|
return select.collection({
|
||||||
items: options,
|
items: options,
|
||||||
@@ -71,6 +137,8 @@ const statusCollection = createSelectCollection(STATUS_OPTIONS)
|
|||||||
const genderCollection = createSelectCollection(GENDER_OPTIONS)
|
const genderCollection = createSelectCollection(GENDER_OPTIONS)
|
||||||
const marriageStatusCollection = createSelectCollection(MARRIAGE_STATUS_OPTIONS)
|
const marriageStatusCollection = createSelectCollection(MARRIAGE_STATUS_OPTIONS)
|
||||||
const memberTypeCollection = createSelectCollection(MEMBER_TYPE_OPTIONS)
|
const memberTypeCollection = createSelectCollection(MEMBER_TYPE_OPTIONS)
|
||||||
|
const employmentTypeCollection = createSelectCollection(EMPLOYMENT_TYPE_OPTIONS)
|
||||||
|
const companyNameCollection = createSelectCollection(COMPANY_OPTIONS)
|
||||||
|
|
||||||
const statusValue = ref<string[]>([])
|
const statusValue = ref<string[]>([])
|
||||||
const genderValue = ref<string[]>([])
|
const genderValue = ref<string[]>([])
|
||||||
@@ -82,6 +150,31 @@ const genderInitial = ref<string[]>([])
|
|||||||
const marriageStatusInitial = ref<string[]>([])
|
const marriageStatusInitial = ref<string[]>([])
|
||||||
const memberTypeInitial = ref<string[]>([])
|
const memberTypeInitial = ref<string[]>([])
|
||||||
|
|
||||||
|
const employmentTypeValue = ref<string[]>([])
|
||||||
|
const employmentTypeInitial = ref<string[]>([])
|
||||||
|
const companyNameValue = ref<string[]>([])
|
||||||
|
const companyNameInitial = ref<string[]>([])
|
||||||
|
|
||||||
|
const employments = ref<Employment[]>([])
|
||||||
|
const savingEmployment = ref(false)
|
||||||
|
const deletingEmploymentId = ref<string | null>(null)
|
||||||
|
const editingEmploymentId = ref<string | null>(null)
|
||||||
|
const employmentErrors = reactive<Partial<Record<EmploymentFieldKey, string>>>({})
|
||||||
|
|
||||||
|
function emptyEmploymentForm() {
|
||||||
|
return {
|
||||||
|
company_name: '',
|
||||||
|
job_title: '',
|
||||||
|
employment_type: '',
|
||||||
|
salary: '',
|
||||||
|
start_date: '',
|
||||||
|
end_date: '',
|
||||||
|
is_current: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const employmentForm = reactive(emptyEmploymentForm())
|
||||||
|
|
||||||
function setStatusValue(details: { value: string[] }) {
|
function setStatusValue(details: { value: string[] }) {
|
||||||
statusValue.value = details.value
|
statusValue.value = details.value
|
||||||
}
|
}
|
||||||
@@ -98,6 +191,59 @@ function setMemberTypeValue(details: { value: string[] }) {
|
|||||||
memberTypeValue.value = details.value
|
memberTypeValue.value = details.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setEmploymentTypeValue(details: { value: string[] }) {
|
||||||
|
employmentTypeValue.value = details.value
|
||||||
|
clearEmploymentFieldError('employment_type')
|
||||||
|
employmentForm.employment_type =
|
||||||
|
labelToApiValue(EMPLOYMENT_TYPE_OPTIONS, details.value[0]) ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCompanyNameValue(details: { value: string[] }) {
|
||||||
|
companyNameValue.value = details.value
|
||||||
|
clearEmploymentFieldError('company_name')
|
||||||
|
employmentForm.company_name = details.value[0] ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearEmploymentFieldError(field: EmploymentFieldKey) {
|
||||||
|
delete employmentErrors[field]
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearEmploymentErrors() {
|
||||||
|
for (const field of EMPLOYMENT_FIELD_KEYS) {
|
||||||
|
delete employmentErrors[field]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setEmploymentErrorsFromApi(error: unknown): boolean {
|
||||||
|
const apiErrors = getApiValidationErrors(error)
|
||||||
|
if (!apiErrors) return false
|
||||||
|
|
||||||
|
for (const [field, messages] of Object.entries(apiErrors)) {
|
||||||
|
if (EMPLOYMENT_FIELD_KEYS.includes(field as EmploymentFieldKey) && messages[0]) {
|
||||||
|
employmentErrors[field as EmploymentFieldKey] = messages[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(employmentErrors).length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncEmploymentSelectValues() {
|
||||||
|
employmentTypeValue.value = apiValueToLabel(
|
||||||
|
EMPLOYMENT_TYPE_OPTIONS,
|
||||||
|
employmentForm.employment_type,
|
||||||
|
)
|
||||||
|
employmentTypeInitial.value = [...employmentTypeValue.value]
|
||||||
|
companyNameValue.value = apiValueToLabel(COMPANY_OPTIONS, employmentForm.company_name)
|
||||||
|
companyNameInitial.value = [...companyNameValue.value]
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetEmploymentForm() {
|
||||||
|
Object.assign(employmentForm, emptyEmploymentForm())
|
||||||
|
editingEmploymentId.value = null
|
||||||
|
clearEmploymentErrors()
|
||||||
|
syncEmploymentSelectValues()
|
||||||
|
}
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
@@ -154,6 +300,219 @@ function syncFormFromUser(user: Awaited<ReturnType<typeof getUser>>['data']) {
|
|||||||
genderInitial.value = [...genderValue.value]
|
genderInitial.value = [...genderValue.value]
|
||||||
marriageStatusInitial.value = [...marriageStatusValue.value]
|
marriageStatusInitial.value = [...marriageStatusValue.value]
|
||||||
memberTypeInitial.value = [...memberTypeValue.value]
|
memberTypeInitial.value = [...memberTypeValue.value]
|
||||||
|
employments.value = user.employments ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
const employmentTypeLabel = computed(() =>
|
||||||
|
Object.fromEntries(EMPLOYMENT_TYPE_OPTIONS.map((option) => [option.value, option.label])),
|
||||||
|
)
|
||||||
|
|
||||||
|
const isEditingEmployment = computed(() => editingEmploymentId.value !== null)
|
||||||
|
|
||||||
|
const canAddEmployment = computed(() => !loading.value && employments.value.length === 0)
|
||||||
|
|
||||||
|
const showEmploymentForm = computed(() => isEditingEmployment.value || canAddEmployment.value)
|
||||||
|
|
||||||
|
function formatSalary(value: number | string | null | undefined): string {
|
||||||
|
const amount = Number(value)
|
||||||
|
if (Number.isNaN(amount)) return '-'
|
||||||
|
return new Intl.NumberFormat('ms-MY', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'MYR',
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
}).format(amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateLabel(value: string | null | undefined): string {
|
||||||
|
if (!value) return ''
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) return value
|
||||||
|
return new Intl.DateTimeFormat('ms-MY', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
}).format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEmploymentPeriod(employment: Employment): string {
|
||||||
|
const start = formatDateLabel(employment.start_date)
|
||||||
|
if (employment.is_current) {
|
||||||
|
return `${start} - Kini`
|
||||||
|
}
|
||||||
|
const end = formatDateLabel(employment.end_date)
|
||||||
|
return end ? `${start} - ${end}` : start
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateEmploymentForm(): boolean {
|
||||||
|
clearEmploymentErrors()
|
||||||
|
|
||||||
|
let valid = true
|
||||||
|
|
||||||
|
if (!companyNameValue.value[0]?.trim()) {
|
||||||
|
employmentErrors.company_name = 'Nama syarikat diperlukan.'
|
||||||
|
valid = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!employmentForm.job_title.trim()) {
|
||||||
|
employmentErrors.job_title = 'Jawatan diperlukan.'
|
||||||
|
valid = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!employmentTypeValue.value[0] ||
|
||||||
|
!labelToApiValue(EMPLOYMENT_TYPE_OPTIONS, employmentTypeValue.value[0])
|
||||||
|
) {
|
||||||
|
employmentErrors.employment_type = 'Jenis kerja diperlukan.'
|
||||||
|
valid = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const salary = Number(employmentForm.salary)
|
||||||
|
if (!employmentForm.salary.toString().trim() || Number.isNaN(salary) || salary < 0) {
|
||||||
|
employmentErrors.salary = 'Gaji diperlukan.'
|
||||||
|
valid = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!employmentForm.start_date) {
|
||||||
|
employmentErrors.start_date = 'Tarikh mula diperlukan.'
|
||||||
|
valid = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!employmentForm.is_current && !employmentForm.end_date) {
|
||||||
|
employmentErrors.end_date = 'Tarikh tamat diperlukan jika bukan pekerjaan semasa.'
|
||||||
|
valid = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!employmentForm.is_current &&
|
||||||
|
employmentForm.start_date &&
|
||||||
|
employmentForm.end_date &&
|
||||||
|
employmentForm.end_date < employmentForm.start_date
|
||||||
|
) {
|
||||||
|
employmentErrors.end_date = 'Tarikh tamat mesti selepas tarikh mula.'
|
||||||
|
valid = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return valid
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEmploymentPayload(): EmploymentPayload {
|
||||||
|
return {
|
||||||
|
company_name: employmentForm.company_name.trim(),
|
||||||
|
job_title: employmentForm.job_title.trim(),
|
||||||
|
employment_type:
|
||||||
|
labelToApiValue(EMPLOYMENT_TYPE_OPTIONS, employmentTypeValue.value[0]) ??
|
||||||
|
employmentForm.employment_type,
|
||||||
|
salary: Number(employmentForm.salary),
|
||||||
|
start_date: employmentForm.start_date,
|
||||||
|
end_date: employmentForm.is_current ? null : employmentForm.end_date || null,
|
||||||
|
is_current: employmentForm.is_current,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshEmployments() {
|
||||||
|
const response = await getUser(userId.value)
|
||||||
|
employments.value = response.data.employments ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEditEmployment(employment: Employment) {
|
||||||
|
clearEmploymentErrors()
|
||||||
|
editingEmploymentId.value = employment.id
|
||||||
|
employmentForm.company_name = employment.company_name
|
||||||
|
employmentForm.job_title = employment.job_title
|
||||||
|
employmentForm.employment_type = employment.employment_type
|
||||||
|
employmentForm.salary = String(employment.salary)
|
||||||
|
employmentForm.start_date = toDateInputValue(employment.start_date)
|
||||||
|
employmentForm.end_date = toDateInputValue(employment.end_date)
|
||||||
|
employmentForm.is_current = employment.is_current
|
||||||
|
syncEmploymentSelectValues()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSaveEmployment() {
|
||||||
|
if (!validateEmploymentForm()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
savingEmployment.value = true
|
||||||
|
const wasEditing = isEditingEmployment.value
|
||||||
|
const payload = buildEmploymentPayload()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = wasEditing
|
||||||
|
? await updateUserEmployment(userId.value, editingEmploymentId.value!, payload)
|
||||||
|
: await createUserEmployment(userId.value, payload)
|
||||||
|
|
||||||
|
if (!res.success) {
|
||||||
|
throw new Error(res.message ?? 'Gagal menyimpan pekerjaan.')
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshEmployments()
|
||||||
|
resetEmploymentForm()
|
||||||
|
|
||||||
|
await Swal.fire({
|
||||||
|
toast: true,
|
||||||
|
position: 'top-end',
|
||||||
|
icon: 'success',
|
||||||
|
title: wasEditing ? 'Pekerjaan berjaya dikemas kini.' : 'Pekerjaan berjaya ditambah.',
|
||||||
|
showConfirmButton: false,
|
||||||
|
timer: 3000,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
if (!setEmploymentErrorsFromApi(err)) {
|
||||||
|
await Swal.fire({
|
||||||
|
icon: 'error',
|
||||||
|
title: 'Ralat',
|
||||||
|
text: getApiErrorMessage(err, 'Gagal menyimpan pekerjaan.'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
savingEmployment.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDeleteEmployment(employment: Employment) {
|
||||||
|
const result = await Swal.fire({
|
||||||
|
icon: 'warning',
|
||||||
|
title: 'Padam pekerjaan?',
|
||||||
|
text: 'Tindakan ini tidak boleh dibatalkan.',
|
||||||
|
showCancelButton: true,
|
||||||
|
confirmButtonText: 'Padam',
|
||||||
|
cancelButtonText: 'Batal',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result.isConfirmed) return
|
||||||
|
|
||||||
|
deletingEmploymentId.value = employment.id
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await deleteUserEmployment(userId.value, employment.id)
|
||||||
|
|
||||||
|
if (!res.success) {
|
||||||
|
throw new Error(res.message ?? 'Gagal memadam pekerjaan.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingEmploymentId.value === employment.id) {
|
||||||
|
resetEmploymentForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshEmployments()
|
||||||
|
|
||||||
|
await Swal.fire({
|
||||||
|
toast: true,
|
||||||
|
position: 'top-end',
|
||||||
|
icon: 'success',
|
||||||
|
title: 'Pekerjaan berjaya dipadam.',
|
||||||
|
showConfirmButton: false,
|
||||||
|
timer: 3000,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
await Swal.fire({
|
||||||
|
icon: 'error',
|
||||||
|
title: 'Ralat',
|
||||||
|
text: getApiErrorMessage(err, 'Gagal memadam pekerjaan.'),
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
deletingEmploymentId.value = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchUser() {
|
async function fetchUser() {
|
||||||
@@ -182,6 +541,16 @@ watch(selectedStatus, (newStatus, oldStatus) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => employmentForm.is_current,
|
||||||
|
(isCurrent) => {
|
||||||
|
if (isCurrent) {
|
||||||
|
employmentForm.end_date = ''
|
||||||
|
clearEmploymentFieldError('end_date')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
saving.value = true
|
saving.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
@@ -218,6 +587,7 @@ async function handleSubmit() {
|
|||||||
const formDisabled = computed(() => loading.value || saving.value)
|
const formDisabled = computed(() => loading.value || saving.value)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
syncEmploymentSelectValues()
|
||||||
fetchUser()
|
fetchUser()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -405,5 +775,249 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold text-slate-900">Pekerjaan</h3>
|
||||||
|
<p class="mt-1 text-sm text-slate-500">Urus maklumat pekerjaan pengguna.</p>
|
||||||
|
</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-for="employment in employments"
|
||||||
|
: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="flex flex-wrap items-center gap-2">
|
||||||
|
<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 look="outline">
|
||||||
|
{{ employmentTypeLabel[employment.employment_type] ?? employment.employment_type }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-sm font-medium text-slate-700">{{ employment.job_title }}</p>
|
||||||
|
<p class="mt-1 text-sm text-slate-500">{{ formatEmploymentPeriod(employment) }}</p>
|
||||||
|
<p class="mt-1 text-sm text-slate-500">{{ formatSalary(employment.salary) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
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" />
|
||||||
|
Kemaskini
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
class="border border-foreground/15 shadow-none text-danger"
|
||||||
|
: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
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="rounded-lg border border-dashed border-foreground/15 p-6 text-center text-sm text-slate-500"
|
||||||
|
>
|
||||||
|
Tiada pekerjaan direkodkan.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
v-if="showEmploymentForm"
|
||||||
|
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>
|
||||||
|
<h4 class="text-base font-semibold text-slate-900">
|
||||||
|
{{ isEditingEmployment ? 'Kemaskini Pekerjaan' : 'Tambah Pekerjaan' }}
|
||||||
|
</h4>
|
||||||
|
<p class="mt-1 text-sm text-slate-500">
|
||||||
|
{{
|
||||||
|
isEditingEmployment
|
||||||
|
? 'Kemas kini maklumat pekerjaan yang dipilih.'
|
||||||
|
: 'Tambah rekod pekerjaan baharu untuk pengguna ini.'
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
v-if="isEditingEmployment"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
class="border border-foreground/15 shadow-none"
|
||||||
|
:disabled="savingEmployment"
|
||||||
|
@click="resetEmploymentForm"
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="primary" :disabled="savingEmployment || saving">
|
||||||
|
{{ savingEmployment ? 'Menyimpan...' : isEditingEmployment ? 'Kemaskini' : 'Tambah' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FieldGroup>
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>Nama Syarikat</FieldLabel>
|
||||||
|
<SelectRoot
|
||||||
|
:key="`company-name-${editingEmploymentId ?? 'new'}`"
|
||||||
|
class="w-full"
|
||||||
|
:collection="companyNameCollection"
|
||||||
|
:default-value="companyNameInitial"
|
||||||
|
:disabled="savingEmployment || saving"
|
||||||
|
@value-change="setCompanyNameValue"
|
||||||
|
>
|
||||||
|
<SelectControl>
|
||||||
|
<SelectTrigger :aria-invalid="!!employmentErrors.company_name">
|
||||||
|
<SelectValueText placeholder="Pilih syarikat" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</SelectControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItemGroup>
|
||||||
|
<SelectItemGroupLabel>Nama Syarikat</SelectItemGroupLabel>
|
||||||
|
<SelectItem
|
||||||
|
v-for="item in companyNameCollection.items"
|
||||||
|
:key="item.label"
|
||||||
|
:item="item"
|
||||||
|
>
|
||||||
|
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectItemGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</SelectRoot>
|
||||||
|
<FieldError v-if="employmentErrors.company_name">
|
||||||
|
{{ employmentErrors.company_name }}
|
||||||
|
</FieldError>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel for="employment-job-title">Jawatan</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="employment-job-title"
|
||||||
|
v-model="employmentForm.job_title"
|
||||||
|
type="text"
|
||||||
|
placeholder="Jawatan"
|
||||||
|
:disabled="savingEmployment || saving"
|
||||||
|
:aria-invalid="!!employmentErrors.job_title"
|
||||||
|
@input="clearEmploymentFieldError('job_title')"
|
||||||
|
/>
|
||||||
|
<FieldError v-if="employmentErrors.job_title">
|
||||||
|
{{ employmentErrors.job_title }}
|
||||||
|
</FieldError>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>Jenis Kerja</FieldLabel>
|
||||||
|
<SelectRoot
|
||||||
|
:key="`employment-type-${editingEmploymentId ?? 'new'}`"
|
||||||
|
class="w-full"
|
||||||
|
:collection="employmentTypeCollection"
|
||||||
|
:default-value="employmentTypeInitial"
|
||||||
|
:disabled="savingEmployment || saving"
|
||||||
|
@value-change="setEmploymentTypeValue"
|
||||||
|
>
|
||||||
|
<SelectControl>
|
||||||
|
<SelectTrigger :aria-invalid="!!employmentErrors.employment_type">
|
||||||
|
<SelectValueText placeholder="Pilih jenis kerja" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</SelectControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItemGroup>
|
||||||
|
<SelectItemGroupLabel>Jenis Kerja</SelectItemGroupLabel>
|
||||||
|
<SelectItem
|
||||||
|
v-for="item in employmentTypeCollection.items"
|
||||||
|
:key="item.label"
|
||||||
|
:item="item"
|
||||||
|
>
|
||||||
|
<SelectItemText>{{ item.label }}</SelectItemText>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectItemGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</SelectRoot>
|
||||||
|
<FieldError v-if="employmentErrors.employment_type">
|
||||||
|
{{ employmentErrors.employment_type }}
|
||||||
|
</FieldError>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel for="employment-salary">Gaji (RM)</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="employment-salary"
|
||||||
|
v-model="employmentForm.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>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel for="employment-start-date">Tarikh Mula</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="employment-start-date"
|
||||||
|
v-model="employmentForm.start_date"
|
||||||
|
type="date"
|
||||||
|
:disabled="savingEmployment || saving"
|
||||||
|
:aria-invalid="!!employmentErrors.start_date"
|
||||||
|
@input="clearEmploymentFieldError('start_date')"
|
||||||
|
/>
|
||||||
|
<FieldError v-if="employmentErrors.start_date">
|
||||||
|
{{ employmentErrors.start_date }}
|
||||||
|
</FieldError>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel for="employment-end-date">Tarikh Tamat</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="employment-end-date"
|
||||||
|
v-model="employmentForm.end_date"
|
||||||
|
type="date"
|
||||||
|
:disabled="employmentForm.is_current || savingEmployment || saving"
|
||||||
|
:aria-invalid="!!employmentErrors.end_date"
|
||||||
|
@input="clearEmploymentFieldError('end_date')"
|
||||||
|
/>
|
||||||
|
<FieldError v-if="employmentErrors.end_date">
|
||||||
|
{{ employmentErrors.end_date }}
|
||||||
|
</FieldError>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field class="md:col-span-2">
|
||||||
|
<CheckboxRoot
|
||||||
|
:checked="employmentForm.is_current"
|
||||||
|
:disabled="savingEmployment || saving"
|
||||||
|
@checked-change="({ checked }) => (employmentForm.is_current = checked === true)"
|
||||||
|
>
|
||||||
|
<CheckboxControl />
|
||||||
|
<CheckboxLabel>Pekerjaan semasa</CheckboxLabel>
|
||||||
|
</CheckboxRoot>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</FieldGroup>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Box>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { Lucide } from '@/components/ui/lucide'
|
|||||||
import DataTable from '@/components/ui/usage/DataTable.vue'
|
import DataTable from '@/components/ui/usage/DataTable.vue'
|
||||||
import type { TableHeader } from '@/components/ui/usage/DataTable.vue'
|
import type { TableHeader } from '@/components/ui/usage/DataTable.vue'
|
||||||
import { usePermissions } from '@/composables/usePermissions'
|
import { usePermissions } from '@/composables/usePermissions'
|
||||||
|
import { EMPLOYERS } from '@/constants/employers'
|
||||||
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
import { getApiErrorMessage } from '@/core/utils/getApiErrorMessage'
|
||||||
import { listRoles } from '@/modules/role/services/role.service'
|
import { listRoles } from '@/modules/role/services/role.service'
|
||||||
import type { RoleListItem } from '@/modules/role/types/role.types'
|
import type { RoleListItem } from '@/modules/role/types/role.types'
|
||||||
@@ -48,6 +49,22 @@ const STATUS_FILTER_CHIPS: StatusFilterChip[] = [
|
|||||||
{ label: 'Menunggu', value: 'pending', variant: 'pending' },
|
{ label: 'Menunggu', value: 'pending', variant: 'pending' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
function getEmployerShortLabel(name: string): string {
|
||||||
|
const match = name.match(/\(([^)]+)\)/)
|
||||||
|
if (match?.[1]) return match[1]
|
||||||
|
if (name.startsWith('An-Nisa')) return "An-Nisa'"
|
||||||
|
if (name.startsWith('Kel Infra')) return 'Kel Infra'
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
const UNIT_FILTER_CHIPS = [
|
||||||
|
{ label: 'Semua', value: '' },
|
||||||
|
...EMPLOYERS.map((employer) => ({
|
||||||
|
label: getEmployerShortLabel(employer.name),
|
||||||
|
value: employer.name,
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { hasPermission } = usePermissions()
|
const { hasPermission } = usePermissions()
|
||||||
|
|
||||||
@@ -163,6 +180,14 @@ function setStatusFilter(value: string) {
|
|||||||
statusFilter.value = value
|
statusFilter.value = value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isUnitFilterActive(value: string) {
|
||||||
|
return unitFilter.value === value
|
||||||
|
}
|
||||||
|
|
||||||
|
function setUnitFilter(value: string) {
|
||||||
|
unitFilter.value = value
|
||||||
|
}
|
||||||
|
|
||||||
function formatDeletedAt(value: string | null | undefined): string {
|
function formatDeletedAt(value: string | null | undefined): string {
|
||||||
if (!value) return '-'
|
if (!value) return '-'
|
||||||
return dayjs(value).format('DD MMM YYYY, HH:mm')
|
return dayjs(value).format('DD MMM YYYY, HH:mm')
|
||||||
@@ -356,6 +381,7 @@ const {
|
|||||||
error,
|
error,
|
||||||
search,
|
search,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
|
unitFilter,
|
||||||
joinDateFrom,
|
joinDateFrom,
|
||||||
joinDateTo,
|
joinDateTo,
|
||||||
leaveDateFrom,
|
leaveDateFrom,
|
||||||
@@ -540,6 +566,23 @@ onMounted(() => {
|
|||||||
{{ chip.label }}
|
{{ chip.label }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="text-sm opacity-70">Unit:</span>
|
||||||
|
<Badge
|
||||||
|
v-for="chip in UNIT_FILTER_CHIPS"
|
||||||
|
:key="chip.value || 'all-units'"
|
||||||
|
variant="ghost"
|
||||||
|
: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 }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { api } from '@/core/services/api'
|
||||||
|
import type { EmploymentApiResponse, EmploymentPayload } from '@/modules/profile/types/employment.types'
|
||||||
|
|
||||||
|
export async function createUserEmployment(
|
||||||
|
userId: string,
|
||||||
|
payload: EmploymentPayload,
|
||||||
|
): Promise<EmploymentApiResponse> {
|
||||||
|
const { data } = await api.post<EmploymentApiResponse>(`/v1/users/${userId}/employments`, payload)
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message ?? 'Gagal menambah pekerjaan.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUserEmployment(
|
||||||
|
userId: string,
|
||||||
|
employmentId: string,
|
||||||
|
payload: EmploymentPayload,
|
||||||
|
): Promise<EmploymentApiResponse> {
|
||||||
|
const { data } = await api.put<EmploymentApiResponse>(
|
||||||
|
`/v1/users/${userId}/employments/${employmentId}`,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message ?? 'Gagal mengemas kini pekerjaan.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUserEmployment(
|
||||||
|
userId: string,
|
||||||
|
employmentId: string,
|
||||||
|
): Promise<EmploymentApiResponse> {
|
||||||
|
const { data } = await api.delete<EmploymentApiResponse>(
|
||||||
|
`/v1/users/${userId}/employments/${employmentId}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message ?? 'Gagal memadam pekerjaan.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
@@ -84,6 +84,7 @@ export interface CreateUserPayload {
|
|||||||
member_number: number
|
member_number: number
|
||||||
member_type: string
|
member_type: string
|
||||||
join_date: string
|
join_date: string
|
||||||
|
leave_date?: string | null
|
||||||
birth_date: string
|
birth_date: string
|
||||||
birth_place: string
|
birth_place: string
|
||||||
}
|
}
|
||||||
@@ -99,6 +100,7 @@ export interface ListUsersParams {
|
|||||||
join_date_to?: string
|
join_date_to?: string
|
||||||
leave_date_from?: string
|
leave_date_from?: string
|
||||||
leave_date_to?: string
|
leave_date_to?: string
|
||||||
|
company_name?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListDeletedUsersParams {
|
export interface ListDeletedUsersParams {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
isAccountPending: (state) => state.user?.status === 'pending',
|
isAccountPending: (state) => state.user?.status === 'pending',
|
||||||
isAccountActive: (state) => state.user?.status === 'active',
|
isAccountActive: (state) => state.user?.status === 'active',
|
||||||
isEmailVerified: (state) => Boolean(state.user?.email_verified_at),
|
isEmailVerified: (state) => Boolean(state.user?.email_verified_at),
|
||||||
|
isPhoneVerified: (state) => Boolean(state.user?.phone_verified_at),
|
||||||
roles: (state) => state.user?.roles ?? [],
|
roles: (state) => state.user?.roles ?? [],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user