7 Commits

Author SHA1 Message Date
ismailmasseran ebd3caecc6 DONE: use full name for date in letter, include password in email after membership is done, block ic_number to exclude - (#9)
Build Docker Image / build-backend (push) Successful in 44s
Build Docker Image / build-frontend (push) Successful in 18s
Co-authored-by: ISMAIL MASSERAN <topaz@ISMAILs-Macbook.local>
Reviewed-on: #9
2026-07-08 12:15:41 +08:00
ismailmasseran 18c0d268b1 DONE: update logo to original official logo, update letterhead and letterfooter layout (#8)
Build Docker Image / build-backend (push) Successful in 52s
Build Docker Image / build-frontend (push) Successful in 32s
Co-authored-by: ISMAIL MASSERAN <topaz@Mac.dlinkrouter.local>
Reviewed-on: #8
2026-07-07 14:45:20 +08:00
ismailmasseran 1cc3351ca6 DONE: renaming the frontend url to mykopkb (#7)
Build Docker Image / build-backend (push) Successful in 51s
Build Docker Image / build-frontend (push) Successful in 21s
Co-authored-by: ISMAIL MASSERAN <topaz@ISMAILs-Macbook.local>
Reviewed-on: #7
2026-07-06 15:56:28 +08:00
ismailmasseran 33372f2c95 DONE: fix cors issues, use custom-logo for letterhead (#6)
Build Docker Image / build-backend (push) Successful in 54s
Build Docker Image / build-frontend (push) Successful in 7s
Co-authored-by: ISMAIL MASSERAN <topaz@Mac.dlinkrouter.local>
Reviewed-on: #6
2026-07-06 14:48:50 +08:00
ISMAIL MASSERAN e6e2df72eb DONE: fix error on Dockerfile build
Build Docker Image / build-backend (push) Successful in 1m4s
Build Docker Image / build-frontend (push) Successful in 7s
2026-07-06 13:26:25 +08:00
ismailmasseran 580c7d2392 DONE: fix error where image not showing on digital card, generate letter not working on prod (#5)
Build Docker Image / build-backend (push) Failing after 23s
Build Docker Image / build-frontend (push) Successful in 21s
Co-authored-by: ISMAIL MASSERAN <topaz@ISMAILs-Macbook.local>
Reviewed-on: #5
2026-07-06 13:24:42 +08:00
ismailmasseran d06c4701a4 Dev/v1.2 (#4)
Build Docker Image / build-backend (push) Successful in 1m56s
Build Docker Image / build-frontend (push) Successful in 1m55s
Co-authored-by: ISMAIL MASSERAN <topaz@Mac.dlinkrouter.local>
Reviewed-on: #4
2026-07-06 12:50:55 +08:00
263 changed files with 6107 additions and 27358 deletions
+4 -5
View File
@@ -1,12 +1,11 @@
name: Build Docker Image name: Build Docker Image
# Builds images on every push to main or tag, but does NOT auto-deploy
on: on:
push: push:
branches: # branches:
- main # - main
tags: tags:
- "v*" # v1.0.0, v1.0.1, etc. - "v*"
jobs: jobs:
# test-backend: # test-backend:
@@ -92,6 +91,6 @@ jobs:
--cache-to type=registry,ref=${IMAGE}:buildcache,mode=max \ --cache-to type=registry,ref=${IMAGE}:buildcache,mode=max \
-f fe/docker/Dockerfile \ -f fe/docker/Dockerfile \
--build-arg VITE_API_BASE_URL=https://api.koppkb.com \ --build-arg VITE_API_BASE_URL=https://api.koppkb.com \
--build-arg VITE_APP_URL=https://anggota.koppkb.com \ --build-arg VITE_APP_URL=https://mykopkb.koppkb.com \
${TAGS} \ ${TAGS} \
--push . --push .
+3
View File
@@ -77,3 +77,6 @@ SELECT setval('public.permissions_id_seq', COALESCE((SELECT MAX(id) FROM permiss
```bash ```bash
git reset --hard development # moves the current branch (e.g., main) to point exactly where development git reset --hard development # moves the current branch (e.g., main) to point exactly where development
``` ```
## Public endpoint to apply membership:
## http://localhost:5173/apply-membership
+14 -2
View File
@@ -16,5 +16,17 @@
[ ] jana surat lepas lulus anggota [ ] jana surat lepas lulus anggota
## Present to Boss (2/7/2026) ## Present to Boss (2/7/2026)
[ ] discuss logo baru MyKOPKB [x] discuss logo baru MyKOPKB
[ ] layout footer untuk surat rasmi [x] layout footer untuk surat rasmi
[x] maklumat pekerjaan kena buat dropdown untuk nama majikan
[x] tambah minima untuk borang permohonan (tukar jadi caruman saham potongan bulanan)
[x] tukar waris ke penama
[x] ic dan surat pengesahan majikkan perlu mandatori
[ ] semakan lembaga tukar jadi maklumat keputusan
[x] boleh attach document untuk peringkat pentadbir
[x] surat attach dekat email selepas bos
[ ] pembayaran pertama tukar jadi one-off
[ ] syer maksima silap tukar jadi rm50.00
[x] letak sign digital
[x] running no anggota dalam surat dan masa.
[ ] tukar logo di surat offer
+3
View File
@@ -96,3 +96,6 @@ API_ALLOWED_USER_AGENTS=
ENABLE_API_KEY_AUTH=true ENABLE_API_KEY_AUTH=true
API_VALID_KEYS=86f58825e5c1e0872d8786092d47e3bd,6461fc5390660c55dc47bdfcd85ed9ae API_VALID_KEYS=86f58825e5c1e0872d8786092d47e3bd,6461fc5390660c55dc47bdfcd85ed9ae
API_LOG_KEY_USAGE=true API_LOG_KEY_USAGE=true
PUBLIC_PROFILE_TOKEN_TTL_DAYS=7
FRONTEND_URL=https://mykopkb.koppkb.com
+4 -3
View File
@@ -67,11 +67,12 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"
# Browsershot (PDF generation via headless Chrome + Puppeteer in be/) # Browsershot (PDF generation via headless Chrome + Puppeteer in be/)
# Required in Docker/Sail. After first sail up: sail npm install && sail npx puppeteer browsers install chrome-headless-shell # Local/Sail: sail npm install && sail npx puppeteer browsers install chrome-headless-shell
# Production Docker image bundles Node + Puppeteer; keep NO_SANDBOX enabled in containers.
BROWSERSHOT_NO_SANDBOX=true BROWSERSHOT_NO_SANDBOX=true
# BROWSERSHOT_NODE_BINARY= # BROWSERSHOT_NODE_BINARY=/usr/local/bin/node
# BROWSERSHOT_NPM_BINARY= # BROWSERSHOT_NPM_BINARY=
# BROWSERSHOT_NODE_MODULE_PATH= # BROWSERSHOT_NODE_MODULE_PATH=/var/www
# BROWSERSHOT_CHROME_PATH= # BROWSERSHOT_CHROME_PATH=
# BROWSERSHOT_TIMEOUT=60 # BROWSERSHOT_TIMEOUT=60
+4
View File
@@ -103,3 +103,7 @@ API_ALLOWED_USER_AGENTS=
ENABLE_API_KEY_AUTH=true ENABLE_API_KEY_AUTH=true
API_VALID_KEYS=86f58825e5c1e0872d8786092d47e3bd,6461fc5390660c55dc47bdfcd85ed9ae API_VALID_KEYS=86f58825e5c1e0872d8786092d47e3bd,6461fc5390660c55dc47bdfcd85ed9ae
API_LOG_KEY_USAGE=true API_LOG_KEY_USAGE=true
PUBLIC_PROFILE_TOKEN_TTL_DAYS=7
FRONTEND_URL=https://mykopkb.koppkb.com
BROWSERSHOT_NO_SANDBOX=true
@@ -3,17 +3,22 @@
namespace Modules\ActivityLog\Http\Controllers; namespace Modules\ActivityLog\Http\Controllers;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Spatie\Activitylog\Models\Activity; use Spatie\Activitylog\Models\Activity;
use App\Services\ActivityLogger; use App\Services\ActivityLogger;
class ActivityLogController extends Controller class ActivityLogController extends Controller
{ {
use AuthorizesRequests;
/** /**
* Display a listing of the resource. * Display a listing of the resource.
*/ */
public function index(Request $request) public function index(Request $request)
{ {
$this->authorize('viewAny', Activity::class);
// ActivityLogger::logView(new Activity, 'Viewed activity logs'); // ActivityLogger::logView(new Activity, 'Viewed activity logs');
$perPage = $request->get('per_page', 10); $perPage = $request->get('per_page', 10);
@@ -27,8 +32,10 @@ class ActivityLogController extends Controller
$q->where('description', 'ILIKE', "%{$search}%") $q->where('description', 'ILIKE', "%{$search}%")
->orWhere('subject_type', 'ILIKE', "%{$search}%") ->orWhere('subject_type', 'ILIKE', "%{$search}%")
->orWhere('event', 'ILIKE', "%{$search}%") ->orWhere('event', 'ILIKE', "%{$search}%")
->orWhere('causer.name', 'ILIKE', "%{$search}%") ->orWhereHas('causer', function ($causerQuery) use ($search) {
->orWhere('causer.email', 'ILIKE', "%{$search}%"); $causerQuery->where('name', 'ILIKE', "%{$search}%")
->orWhere('email', 'ILIKE', "%{$search}%");
});
}); });
} }
@@ -3,7 +3,10 @@
namespace Modules\ActivityLog\Providers; namespace Modules\ActivityLog\Providers;
use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Modules\ActivityLog\Policies\ActivityLogPolicy;
use Spatie\Activitylog\Models\Activity;
use Nwidart\Modules\Traits\PathNamespace; use Nwidart\Modules\Traits\PathNamespace;
use RecursiveDirectoryIterator; use RecursiveDirectoryIterator;
use RecursiveIteratorIterator; use RecursiveIteratorIterator;
@@ -21,6 +24,8 @@ class ActivityLogServiceProvider extends ServiceProvider
*/ */
public function boot(): void public function boot(): void
{ {
Gate::policy(Activity::class, ActivityLogPolicy::class);
$this->registerCommands(); $this->registerCommands();
$this->registerCommandSchedules(); $this->registerCommandSchedules();
$this->registerTranslations(); $this->registerTranslations();
+54
View File
@@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Str;
use Laravel\Sanctum\HasApiTokens; use Laravel\Sanctum\HasApiTokens;
use Spatie\Activitylog\LogOptions; use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity; use Spatie\Activitylog\Traits\LogsActivity;
@@ -51,9 +52,12 @@ class User extends Authenticatable
'marriage_status', 'marriage_status',
'member_number', 'member_number',
'member_type', 'member_type',
'public_profile_token',
'join_date', 'join_date',
'leave_date',
'birth_date', 'birth_date',
'birth_place', 'birth_place',
'onboarding_completed_at',
]; ];
/** /**
@@ -67,8 +71,55 @@ class User extends Authenticatable
'two_factor_secret', 'two_factor_secret',
'two_factor_recovery_codes', 'two_factor_recovery_codes',
'two_factor_confirmed_at', 'two_factor_confirmed_at',
'public_profile_token',
]; ];
protected static function booted(): void
{
static::creating(function (User $user) {
$expiryDays = self::publicProfileTokenTtlDays();
if (empty($user->public_profile_token)) {
$user->public_profile_token = Str::random(48);
}
if (empty($user->public_profile_token_expires_at)) {
$user->public_profile_token_expires_at = now()->addDays($expiryDays);
}
});
}
public static function publicProfileTokenTtlDays(): int
{
return (int) config('user.public_profile_token_ttl_days', 90);
}
public function isPublicProfileTokenExpired(): bool
{
return $this->public_profile_token_expires_at !== null
&& $this->public_profile_token_expires_at->isPast();
}
public function ensurePublicProfileToken(): string
{
$expiryDays = self::publicProfileTokenTtlDays();
if (blank($this->public_profile_token) || $this->isPublicProfileTokenExpired()) {
$this->forceFill([
'public_profile_token' => Str::random(48),
'public_profile_token_expires_at' => now()->addDays($expiryDays),
])->save();
return $this->public_profile_token;
}
$this->forceFill([
'public_profile_token_expires_at' => now()->addDays($expiryDays),
])->save();
return $this->public_profile_token;
}
/** /**
* Get the attributes that should be cast. * Get the attributes that should be cast.
* *
@@ -84,9 +135,12 @@ class User extends Authenticatable
'marriage_status' => 'string', 'marriage_status' => 'string',
'member_number' => 'integer', 'member_number' => 'integer',
'member_type' => 'string', 'member_type' => 'string',
'public_profile_token_expires_at' => 'datetime',
'join_date' => 'date', 'join_date' => 'date',
'leave_date' => 'date',
'birth_date' => 'date', 'birth_date' => 'date',
'birth_place' => 'string', 'birth_place' => 'string',
'onboarding_completed_at' => 'datetime',
]; ];
} }
@@ -24,6 +24,7 @@ class SessionController extends Controller
], 401); ], 401);
} }
$user->ensurePublicProfileToken();
$user->load(['roles.permissions']); $user->load(['roles.permissions']);
return response()->json([ return response()->json([
@@ -12,6 +12,8 @@ class AuthSessionService
{ {
public function createAuthResponse(User $user, string $message, int $status = 200): JsonResponse public function createAuthResponse(User $user, string $message, int $status = 200): JsonResponse
{ {
$user->ensurePublicProfileToken();
if (config('app.env') !== 'local') { if (config('app.env') !== 'local') {
$user->tokens()->delete(); $user->tokens()->delete();
} }
@@ -36,9 +36,12 @@ class UserResource extends JsonResource
'marriage_status' => $this->marriage_status, 'marriage_status' => $this->marriage_status,
'member_number' => $this->member_number, 'member_number' => $this->member_number,
'member_type' => $this->member_type, 'member_type' => $this->member_type,
'public_profile_token' => $this->public_profile_token,
'join_date' => $this->join_date, 'join_date' => $this->join_date,
'leave_date' => $this->leave_date,
'birth_date' => $this->birth_date, 'birth_date' => $this->birth_date,
'birth_place' => $this->birth_place, 'birth_place' => $this->birth_place,
'onboarding_completed_at' => $this->onboarding_completed_at,
'created_at' => $this->created_at, 'created_at' => $this->created_at,
'updated_at' => $this->updated_at, 'updated_at' => $this->updated_at,
'deleted_at' => $this->deleted_at, 'deleted_at' => $this->deleted_at,
@@ -2,19 +2,28 @@
namespace Modules\MembershipApplication\Emails; namespace Modules\MembershipApplication\Emails;
use App\Models\Document;
use App\Notifications\Concerns\BuildsMailMessage;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification; use Illuminate\Notifications\Notification;
use Illuminate\Support\Facades\Storage;
use Modules\MembershipApplication\Entities\MembershipApplication; use Modules\MembershipApplication\Entities\MembershipApplication;
class MembershipApplicationFailedNotification extends Notification class MembershipApplicationFailedNotification extends Notification implements ShouldQueue
{ {
use BuildsMailMessage;
use Queueable; use Queueable;
public function __construct( public function __construct(
protected MembershipApplication $application public MembershipApplication $application,
public Document $resultLetter,
) {} ) {}
/**
* @return array<int, string>
*/
public function via(object $notifiable): array public function via(object $notifiable): array
{ {
return ['mail']; return ['mail'];
@@ -23,13 +32,21 @@ class MembershipApplicationFailedNotification extends Notification
public function toMail(object $notifiable): MailMessage public function toMail(object $notifiable): MailMessage
{ {
$this->application->loadMissing('applicant'); $this->application->loadMissing('applicant');
$applicant = $this->application->applicant;
return (new MailMessage) return $this->mailMessage(
->subject('Keputusan Permohonan Keahlian') subject: 'Keputusan Permohonan Keahlian KOPKB - TIDAK LULUS',
->markdown('membershipapplication::emails.failed', [ view: 'membershipapplication::emails.failed',
'name' => $this->application->applicant->name, data: [
'applicationNumber' => $this->application->application_number, 'application' => $this->application,
'logoPath' => public_path('images/logo-kopkb.svg'), 'applicant' => $applicant,
]); ],
)->attach(
Storage::disk(Document::STORAGE_DISK)->path($this->resultLetter->path),
[
'as' => $this->resultLetter->name,
'mime' => $this->resultLetter->mime_type,
]
);
} }
} }
@@ -2,19 +2,29 @@
namespace Modules\MembershipApplication\Emails; namespace Modules\MembershipApplication\Emails;
use App\Models\Document;
use App\Notifications\Concerns\BuildsMailMessage;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification; use Illuminate\Notifications\Notification;
use Illuminate\Support\Facades\Storage;
use Modules\MembershipApplication\Entities\MembershipApplication; use Modules\MembershipApplication\Entities\MembershipApplication;
class MembershipApplicationPassedNotification extends Notification class MembershipApplicationPassedNotification extends Notification implements ShouldQueue
{ {
use BuildsMailMessage;
use Queueable; use Queueable;
public function __construct( public function __construct(
protected MembershipApplication $application public MembershipApplication $application,
public Document $resultLetter,
public ?string $plainPassword = null,
) {} ) {}
/**
* @return array<int, string>
*/
public function via(object $notifiable): array public function via(object $notifiable): array
{ {
return ['mail']; return ['mail'];
@@ -23,13 +33,23 @@ class MembershipApplicationPassedNotification extends Notification
public function toMail(object $notifiable): MailMessage public function toMail(object $notifiable): MailMessage
{ {
$this->application->loadMissing('applicant'); $this->application->loadMissing('applicant');
$applicant = $this->application->applicant;
return (new MailMessage) return $this->mailMessage(
->subject('Permohonan Keahlian Diluluskan') subject: 'Keputusan Permohonan Keahlian KOPKB - LULUS',
->markdown('membershipapplication::emails.passed', [ view: 'membershipapplication::emails.passed',
'name' => $this->application->applicant->name, data: [
'applicationNumber' => $this->application->application_number, 'application' => $this->application,
'logoPath' => public_path('images/logo-kopkb.svg'), 'applicant' => $applicant,
]); 'plainPassword' => $this->plainPassword,
'loginUrl' => rtrim(config('user.frontend_url'), '/'),
],
)->attach(
Storage::disk(Document::STORAGE_DISK)->path($this->resultLetter->path),
[
'as' => $this->resultLetter->name,
'mime' => $this->resultLetter->mime_type,
]
);
} }
} }
@@ -2,6 +2,7 @@
namespace Modules\MembershipApplication\Emails; namespace Modules\MembershipApplication\Emails;
use App\Notifications\Concerns\BuildsMailMessage;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification; use Illuminate\Notifications\Notification;
@@ -9,6 +10,7 @@ use Modules\MembershipApplication\Entities\MembershipApplication;
class MembershipApplicationSubmittedNotification extends Notification class MembershipApplicationSubmittedNotification extends Notification
{ {
use BuildsMailMessage;
use Queueable; use Queueable;
public function __construct( public function __construct(
@@ -24,9 +26,10 @@ class MembershipApplicationSubmittedNotification extends Notification
{ {
$this->application->loadMissing(['applicant', 'heirs', 'documents', 'references.member']); $this->application->loadMissing(['applicant', 'heirs', 'documents', 'references.member']);
return (new MailMessage) return $this->mailMessage(
->subject('Pengesahan Permohonan Keahlian') subject: 'Pengesahan Permohonan Keahlian',
->markdown('membershipapplication::emails.submitted', [ view: 'membershipapplication::emails.submitted',
data: [
'application' => $this->application, 'application' => $this->application,
'applicant' => $this->application->applicant, 'applicant' => $this->application->applicant,
'heirs' => $this->application->heirs, 'heirs' => $this->application->heirs,
@@ -37,7 +40,7 @@ class MembershipApplicationSubmittedNotification extends Notification
'salary_slip' => 'Slip Gaji', 'salary_slip' => 'Slip Gaji',
'employer_letter' => 'Surat Pengesahan Majikan', 'employer_letter' => 'Surat Pengesahan Majikan',
], ],
'logoPath' => public_path('images/logo-kopkb.svg'), ],
]); );
} }
} }
@@ -18,6 +18,8 @@ class MembershipApplication extends Model
public const RESULT_LETTER_DOCUMENT_TYPE = 'result_letter'; public const RESULT_LETTER_DOCUMENT_TYPE = 'result_letter';
public const ADMIN_ATTACHMENT_DOCUMENT_TYPE = 'admin_attachment';
public const MINIMUM_SHARE_CAPITAL = 500; public const MINIMUM_SHARE_CAPITAL = 500;
public const SHARE_INSTALLMENT_MONTHS = 6; public const SHARE_INSTALLMENT_MONTHS = 6;
@@ -17,6 +17,7 @@ use Modules\MembershipApplication\Enums\ManagementDecision;
use Modules\MembershipApplication\Http\Requests\AssignReferencesRequest; use Modules\MembershipApplication\Http\Requests\AssignReferencesRequest;
use Modules\MembershipApplication\Http\Requests\BatchCompleteRequest; use Modules\MembershipApplication\Http\Requests\BatchCompleteRequest;
use Modules\MembershipApplication\Http\Requests\BoardReviewRequest; use Modules\MembershipApplication\Http\Requests\BoardReviewRequest;
use Modules\MembershipApplication\Http\Requests\CompleteMembershipApplicationRequest;
use Modules\MembershipApplication\Http\Requests\GenerateResultLetterRequest; use Modules\MembershipApplication\Http\Requests\GenerateResultLetterRequest;
use Modules\MembershipApplication\Http\Requests\ManagementReviewRequest; use Modules\MembershipApplication\Http\Requests\ManagementReviewRequest;
use Modules\MembershipApplication\Http\Requests\UpdateMembershipApplicationRequest; use Modules\MembershipApplication\Http\Requests\UpdateMembershipApplicationRequest;
@@ -180,18 +181,20 @@ class MembershipApplicationController extends Controller
]); ]);
} }
public function complete(Request $request, MembershipApplication $membershipApplication): JsonResponse public function complete(CompleteMembershipApplicationRequest $request, MembershipApplication $membershipApplication): JsonResponse
{ {
$this->authorize('complete', $membershipApplication); $this->authorize('complete', $membershipApplication);
$application = $this->membershipApplicationService->complete( $application = $this->membershipApplicationService->complete(
$membershipApplication, $membershipApplication,
$request->user() $request->user(),
$request->validated('board_meeting_reference'),
$request->validated('board_meeting_date'),
); );
$message = $application->board_result === BoardResult::Pass->value $message = $application->board_result === BoardResult::Pass->value
? 'Permohonan selesai. Akaun ahli telah dicipta dan e-mel dihantar.' ? 'Permohonan selesai. Surat keputusan dijana, akaun ahli dicipta dan e-mel dihantar.'
: 'Permohonan selesai. E-mel keputusan dihantar.'; : 'Permohonan selesai. Surat keputusan dijana dan e-mel dihantar.';
return response()->json([ return response()->json([
'success' => true, 'success' => true,
@@ -206,7 +209,9 @@ class MembershipApplicationController extends Controller
$result = $this->membershipApplicationService->completeBatch( $result = $this->membershipApplicationService->completeBatch(
$request->validated('application_ids'), $request->validated('application_ids'),
$request->user() $request->user(),
$request->validated('board_meeting_reference'),
$request->validated('board_meeting_date'),
); );
$succeededCount = count($result['succeeded']); $succeededCount = count($result['succeeded']);
@@ -245,12 +250,29 @@ class MembershipApplicationController extends Controller
return $this->documentService->downloadDocument($document->id); return $this->documentService->downloadDocument($document->id);
} }
public function deleteDocument(MembershipApplication $membershipApplication, string $documentId): JsonResponse
{
$this->authorize('update', $membershipApplication);
$application = $this->membershipApplicationService->deleteDocument(
$membershipApplication,
$documentId,
);
return response()->json([
'success' => true,
'message' => 'Lampiran pentadbir berjaya dipadam.',
'data' => new MembershipApplicationResource($application),
]);
}
public function generateResultLetter(GenerateResultLetterRequest $request, MembershipApplication $membershipApplication,): JsonResponse { public function generateResultLetter(GenerateResultLetterRequest $request, MembershipApplication $membershipApplication,): JsonResponse {
$this->authorize('generateResultLetter', $membershipApplication); $this->authorize('generateResultLetter', $membershipApplication);
$application = $this->membershipApplicationService->generateResultLetter( $application = $this->membershipApplicationService->generateResultLetter(
$membershipApplication, $membershipApplication,
$request->validated('board_meeting_reference'), $request->validated('board_meeting_reference'),
$request->validated('board_meeting_date'),
); );
$resultLetter = $application->documents $resultLetter = $application->documents
@@ -282,11 +304,16 @@ class MembershipApplicationController extends Controller
'board_meeting_reference', 'board_meeting_reference',
'Mesyuarat Lembaga', 'Mesyuarat Lembaga',
); );
$boardMeetingDate = (string) $request->query(
'board_meeting_date',
now()->toDateString(),
);
$view = 'membershipapplication::pdf.approved-letter'; $view = 'membershipapplication::pdf.approved-letter';
$data = $this->membershipApplicationService->getResultLetterViewData( $data = $this->membershipApplicationService->getResultLetterViewData(
$application, $application,
$boardMeetingReference, $boardMeetingReference,
$boardMeetingDate,
); );
$format = $request->query('format', 'html'); $format = $request->query('format', 'html');
$filename = 'surat-keputusan-'.$application->application_number.'.pdf'; $filename = 'surat-keputusan-'.$application->application_number.'.pdf';
@@ -19,6 +19,8 @@ class BatchCompleteRequest extends FormRequest
return [ return [
'application_ids' => 'required|array|min:1|max:100', 'application_ids' => 'required|array|min:1|max:100',
'application_ids.*' => 'required|uuid|distinct|exists:membership_applications,id', 'application_ids.*' => 'required|uuid|distinct|exists:membership_applications,id',
'board_meeting_reference' => 'required|string|max:255',
'board_meeting_date' => 'required|date',
]; ];
} }
@@ -33,6 +35,10 @@ class BatchCompleteRequest extends FormRequest
'application_ids.max' => 'Maksimum 100 permohonan setiap batch.', 'application_ids.max' => 'Maksimum 100 permohonan setiap batch.',
'application_ids.*.exists' => 'Salah satu permohonan tidak dijumpai.', 'application_ids.*.exists' => 'Salah satu permohonan tidak dijumpai.',
'application_ids.*.distinct' => 'Permohonan duplikat tidak dibenarkan.', 'application_ids.*.distinct' => 'Permohonan duplikat tidak dibenarkan.',
'board_meeting_reference.required' => 'Rujukan mesyuarat lembaga diperlukan.',
'board_meeting_reference.max' => 'Rujukan mesyuarat lembaga tidak boleh melebihi 255 aksara.',
'board_meeting_date.required' => 'Tarikh mesyuarat lembaga diperlukan.',
'board_meeting_date.date' => 'Tarikh mesyuarat lembaga tidak sah.',
]; ];
} }
} }
@@ -0,0 +1,37 @@
<?php
namespace Modules\MembershipApplication\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class CompleteMembershipApplicationRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'board_meeting_reference' => 'required|string|max:255',
'board_meeting_date' => 'required|date',
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'board_meeting_reference.required' => 'Rujukan mesyuarat lembaga diperlukan.',
'board_meeting_reference.max' => 'Rujukan mesyuarat lembaga tidak boleh melebihi 255 aksara.',
'board_meeting_date.required' => 'Tarikh mesyuarat lembaga diperlukan.',
'board_meeting_date.date' => 'Tarikh mesyuarat lembaga tidak sah.',
];
}
}
@@ -18,6 +18,7 @@ class GenerateResultLetterRequest extends FormRequest
{ {
return [ return [
'board_meeting_reference' => 'required|string|max:255', 'board_meeting_reference' => 'required|string|max:255',
'board_meeting_date' => 'required|date',
]; ];
} }
@@ -29,6 +30,8 @@ class GenerateResultLetterRequest extends FormRequest
return [ return [
'board_meeting_reference.required' => 'Rujukan mesyuarat lembaga diperlukan.', 'board_meeting_reference.required' => 'Rujukan mesyuarat lembaga diperlukan.',
'board_meeting_reference.max' => 'Rujukan mesyuarat lembaga tidak boleh melebihi 255 aksara.', 'board_meeting_reference.max' => 'Rujukan mesyuarat lembaga tidak boleh melebihi 255 aksara.',
'board_meeting_date.required' => 'Tarikh mesyuarat lembaga diperlukan.',
'board_meeting_date.date' => 'Tarikh mesyuarat lembaga tidak sah.',
]; ];
} }
} }
@@ -48,7 +48,7 @@ class StoreMembershipApplicationRequest extends FormRequest
'documents.ic_copy' => 'required|file|mimes:pdf,jpg,jpeg,png|max:10240', 'documents.ic_copy' => 'required|file|mimes:pdf,jpg,jpeg,png|max:10240',
'documents.photo' => 'nullable|file|mimes:jpg,jpeg,png|max:10240', 'documents.photo' => 'nullable|file|mimes:jpg,jpeg,png|max:10240',
'documents.salary_slip' => 'nullable|file|mimes:pdf,jpg,jpeg,png|max:10240', 'documents.salary_slip' => 'nullable|file|mimes:pdf,jpg,jpeg,png|max:10240',
'documents.employer_letter' => 'nullable|file|mimes:pdf,jpg,jpeg,png|max:10240', 'documents.employer_letter' => 'required|file|mimes:pdf,jpg,jpeg,png|max:10240',
]; ];
} }
@@ -89,6 +89,8 @@ class StoreMembershipApplicationRequest extends FormRequest
'documents.ic_copy.required' => 'Salinan kad pengenalan diperlukan.', 'documents.ic_copy.required' => 'Salinan kad pengenalan diperlukan.',
'documents.ic_copy.mimes' => 'Salinan kad pengenalan mestilah PDF, JPG, JPEG atau PNG.', 'documents.ic_copy.mimes' => 'Salinan kad pengenalan mestilah PDF, JPG, JPEG atau PNG.',
'documents.employer_letter.required' => 'Surat pengesahan majikan diperlukan.',
'documents.employer_letter.mimes' => 'Surat pengesahan majikan mestilah PDF, JPG, JPEG atau PNG.',
]; ];
} }
} }
@@ -4,6 +4,7 @@ namespace Modules\MembershipApplication\Http\Requests;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Modules\MembershipApplication\Entities\MembershipApplication;
class UploadMembershipApplicationDocumentRequest extends FormRequest class UploadMembershipApplicationDocumentRequest extends FormRequest
{ {
@@ -18,7 +19,13 @@ class UploadMembershipApplicationDocumentRequest extends FormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
'type' => ['required', Rule::in(['ic_copy', 'photo', 'salary_slip', 'employer_letter'])], 'type' => ['required', Rule::in([
'ic_copy',
'photo',
'salary_slip',
'employer_letter',
MembershipApplication::ADMIN_ATTACHMENT_DOCUMENT_TYPE,
])],
'file' => [ 'file' => [
'required', 'required',
'file', 'file',
@@ -23,6 +23,7 @@ class MembershipApplicationRepository implements MembershipApplicationRepository
return MembershipApplication::with([ return MembershipApplication::with([
'applicant', 'applicant',
'heirs', 'heirs',
'user:id,member_number',
'references.member:id,name,ic_number', 'references.member:id,name,ic_number',
'documents', 'documents',
'reviews.reviewer:id,name', 'reviews.reviewer:id,name',
@@ -1,18 +1,15 @@
@component('mail::message') @component('mail::message')
@if (! empty($logoPath) && file_exists($logoPath)) @include('emails.partials.header')
<div style="text-align: center; margin-bottom: 24px;">
<img src="{{ $message->embed($logoPath) }}" alt="{{ config('app.name') }}" style="max-height: 80px; width: auto;">
</div>
@endif
# Keputusan Permohonan Keahlian # Keputusan Permohonan Keahlian
Assalamualaikum **{{ $name }}**, Assalamualaikum **{{ $applicant->name }}**,
Permohonan keahlian anda (**{{ $applicationNumber }}**) **tidak diluluskan** pada peringkat lembaga. Permohonan keahlian anda (**{{ $application->application_number }}**) **tidak diluluskan**.
Surat keputusan rasmi dilampirkan dalam e-mel ini.
Terima kasih atas minat anda. Untuk sebarang pertanyaan, sila hubungi pejabat koperasi. Terima kasih atas minat anda. Untuk sebarang pertanyaan, sila hubungi pejabat koperasi.
Terima kasih,<br> @include('emails.partials.footer')
{{ config('app.name') }}
@endcomponent @endcomponent
@@ -1,18 +1,24 @@
@component('mail::message') @component('mail::message')
@if (! empty($logoPath) && file_exists($logoPath)) @include('emails.partials.header')
<div style="text-align: center; margin-bottom: 24px;">
<img src="{{ $message->embed($logoPath) }}" alt="{{ config('app.name') }}" style="max-height: 80px; width: auto;">
</div>
@endif
# Keputusan Permohonan Keahlian # Keputusan Permohonan Keahlian
Assalamualaikum **{{ $name }}**, Assalamualaikum **{{ $applicant->name }}**,
Permohonan keahlian anda (**{{ $applicationNumber }}**) **diluluskan** pada peringkat lembaga. Permohonan keahlian anda (**{{ $application->application_number }}**) **diluluskan**.
Surat keputusan rasmi dilampirkan dalam e-mel ini.
@if ($plainPassword)
Akaun portal anda telah didaftarkan. Maklumat log masuk adalah seperti berikut:
- **E-mel:** {{ $applicant->email }}
- **Kata laluan:** {{ $plainPassword }}
Sila log masuk di [{{ $loginUrl }}]({{ $loginUrl }}) dan tukar kata laluan anda selepas log masuk kali pertama.
@endif
Terima kasih atas minat anda. Untuk sebarang pertanyaan, sila hubungi pejabat koperasi. Terima kasih atas minat anda. Untuk sebarang pertanyaan, sila hubungi pejabat koperasi.
Terima kasih,<br> @include('emails.partials.footer')
{{ config('app.name') }}
@endcomponent @endcomponent
@@ -1,9 +1,5 @@
@component('mail::message') @component('mail::message')
@if (! empty($logoPath) && file_exists($logoPath)) @include('emails.partials.header')
<div style="text-align: center; margin-bottom: 24px;">
<img src="{{ $message->embed($logoPath) }}" alt="{{ config('app.name') }}" style="max-height: 80px; width: auto;">
</div>
@endif
# Pengesahan Permohonan Keahlian # Pengesahan Permohonan Keahlian
@@ -74,6 +70,5 @@ Tiada dokumen.
Anda akan menerima emel apabila keputusan permohonan tersedia. Anda akan menerima emel apabila keputusan permohonan tersedia.
Terima kasih,<br> @include('emails.partials.footer')
{{ config('app.name') }}
@endcomponent @endcomponent
@@ -12,49 +12,77 @@
{{ $applicant->postcode }} {{ $applicant->postcode }}
@endsection @endsection
@section('letter-honorific')
Tuan/Puan,
@endsection
@section('letter-subject') @section('letter-subject')
<strong>{{ $letterSubject }}</strong> <strong>{{ $letterSubject }}</strong>
@endsection @endsection
@section('letter-body') @section('letter-body')
<p style="line-height: 1;"> <p style="line-height: 1;">
Dengan segala hormatnya saya merujuk kepada perkara di atas. Dengan segala hormatnya perkara di atas adalah dirujuk.
</p> </p>
<p style="line-height: 1;"> <p style="line-height: 1;">
2. {{ $openingTone }} dimaklumkan bahawa, permohonan tuan untuk menjadi anggota Koperasi 2.
Permodalan Kelantan Berhad (KoPKB) telah <strong>{{ $boardResultLabel }}</strong> dalam {{ $boardMeetingReference }}. @if ($isPassed)
Sukacita dimaklumkan bahawa, permohonan tuan/puan untuk menjadi <strong>Anggota Koperasi Permodalan Kelantan Berhad (KoPKB)</strong> telah dipertimbangkan dan <strong>DILULUSKAN</strong> oleh Lembaga Koperasi dalam {{ $boardMeetingReference }} yang diadakan pada {{ $boardMeetingDate }}.
@else
Dukacita dimaklumkan bahawa, permohonan tuan/puan untuk menjadi <strong>Anggota Koperasi Permodalan Kelantan Berhad (KoPKB)</strong> telah dipertimbangkan dan <strong>TIDAK DILULUSKAN</strong> oleh Lembaga Koperasi dalam {{ $boardMeetingReference }} yang diadakan pada {{ $boardMeetingDate }}.
@endif
</p> </p>
@if ($isPassed) @if ($isPassed)
<p style="line-height: 1;"> <p style="line-height: 1;">
3. Pihak Koperasi akan membuat potongan gaji melalui majikan tuan sebagaimana ketetapan berikut:- 3. Maklumat keanggotaan tuan/puan adalah seperti berikut:-
<ul style="line-height: 1;"> </p>
<li>Saham</li> <table class="letter-info-table" cellpadding="0" cellspacing="0">
<ul style="line-height: 1;"> <tr>
<li>Potongan sebanyak RM {{ $monthlyShareInstallment }} ({{ $shareInstallmentMonths }} bulan)</li> <th>Perkara</th>
<li>Potongan sebanyak RM {{ $stockMonthlyContribution }} sehingga Syer Maksima</li> <th>Maklumat</th>
</ul> </tr>
<li>Yuran</li> <tr>
<ul style="line-height: 1;"> <td>Nama Anggota</td>
<li>Potongan sebanyak RM {{ $feeMonthlyContribution }} setiap bulan</li> <td>{{ $applicant->name }}</td>
</ul> </tr>
<li>Fi Masuk</li> <tr>
<ul style="line-height: 1;"> <td>No. Kad Pengenalan</td>
<li>Potongan sebanyak RM 10.00 (Pembayaran Pertama)</li> <td>{{ $applicant->ic_number }}</td>
</ul> </tr>
</ul> <tr>
<td>No. Anggota</td>
<td>{{ $memberNumber }}</td>
</tr>
<tr>
<td>Tarikh Kuat Kuasa Keanggotaan</td>
<td>{{ $boardMeetingDate }}</td>
</tr>
<tr>
<td>Modal Syer Permulaan</td>
<td>RM500.00 (perlu dijelaskan dalam tempoh 6 bulan)</td>
</tr>
<tr>
<td>Fi Masuk</td>
<td>RM10.00</td>
</tr>
<tr>
<td>Yuran Tahunan</td>
<td>RM30.00 sebulan</td>
</tr>
</table>
<p style="line-height: 1;">
4. Sebagai anggota KoPKB, tuan/puan adalah tertakluk kepada Undang-Undang Kecil (UUK), dasar, polisi dan peraturan-peraturan KoPKB yang berkuat kuasa dari semasa ke semasa.
</p> </p>
<p style="line-height: 1;"> <p style="line-height: 1;">
4. Untuk makluman tuan, kelulusan keanggotaan ini adalah bersyarat di mana tuan dikehendaki 5. Sebagai anggota, tuan/puan adalah layak menikmati kemudahan, produk dan manfaat yang ditawarkan oleh koperasi dari semasa ke semasa, tertakluk kepada syarat-syarat yang ditetapkan.
menjelaskan modal syer minimum sebanyak RM{{ $minimumShareCapital }}. </p>
Bayaran tersebut perlu dibuat secara ansuran melalui potongan gaji dalam tempoh enam (6) bulan. <p style="line-height: 1;">
Pemenuhan syarat ini adalah bagi melayakkan tuan memperoleh hak, kewajipan dan liabiliti sebagai anggota KoPKB 6. Pihak KoPKB mengucapkan tahniah dan mengalu-alukan penyertaan tuan/puan sebagai anggota. Semoga keanggotaan ini akan memberi manfaat kepada tuan/puan serta menyumbang kepada kemajuan dan kecemerlangan KoPKB.
sebagaimana terkandung di dalam Undang-Undang Kecil (UUK), Perkara 59 yang telah didaftarkan pada 11 September 2024.
Manakala had maksima modal syer adalah sebanyak RM5,000.00.
</p> </p>
@else @else
<p style="line-height: 1;"> <p style="line-height: 1;">
3. Oleh yang demikian, permohonan tuan untuk menjadi anggota KoPKB tidak dapat diluluskan pada masa ini. 3. Oleh yang demikian, permohonan tuan/puan untuk menjadi anggota KoPKB tidak dapat diluluskan pada masa ini.
</p> </p>
@endif @endif
@endsection @endsection
@@ -64,10 +92,5 @@
@endsection @endsection
@section('letter-signature') @section('letter-signature')
<div class="signature-header">Dengan hormatnya,</div>
<div class="signature-company">KOPERASI PERMODALAN KELANTAN BERHAD</div> <div class="signature-company">KOPERASI PERMODALAN KELANTAN BERHAD</div>
<div class="signature-space"></div>
<div class="signatory-name">MUHAMMAD NAFIS BIN ZAINUDDIN</div>
<div class="signatory-title">Pengurus Besar</div>
<div class="signatory-title">b/p Setiausaha</div>
@endsection @endsection
@@ -38,6 +38,7 @@ Route::middleware(['auth:sanctum', 'single.session'])->prefix('v1')->group(funct
// Membership application upload document routes // Membership application upload document routes
Route::post('membership-applications/{membershipApplication}/documents', [MembershipApplicationController::class, 'uploadDocument'])->name('membership-application.upload-document'); Route::post('membership-applications/{membershipApplication}/documents', [MembershipApplicationController::class, 'uploadDocument'])->name('membership-application.upload-document');
Route::delete('membership-applications/{membershipApplication}/documents/{documentId}', [MembershipApplicationController::class, 'deleteDocument'])->name('membership-application.delete-document');
// download document routes // download document routes
Route::get('membership-applications/{membershipApplication}/documents/{documentId}/download', [MembershipApplicationController::class, 'downloadDocument'])->name('membership-application.download-document'); Route::get('membership-applications/{membershipApplication}/documents/{documentId}/download', [MembershipApplicationController::class, 'downloadDocument'])->name('membership-application.download-document');
@@ -2,10 +2,12 @@
namespace Modules\MembershipApplication\Services; namespace Modules\MembershipApplication\Services;
use App\Models\Document;
use App\Services\DocumentService; use App\Services\DocumentService;
use App\Services\LetterService; use App\Services\LetterService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\Notification;
@@ -137,15 +139,38 @@ class MembershipApplicationService
$this->documentService->validateFile($file); $this->documentService->validateFile($file);
$application->documentsOfType($type)->get()->each( if ($type !== MembershipApplication::ADMIN_ATTACHMENT_DOCUMENT_TYPE) {
fn ($document) => $this->documentService->deleteDocument($document->id) $application->documentsOfType($type)->get()->each(
); fn ($document) => $this->documentService->deleteDocument($document->id)
);
}
$this->documentService->uploadDocument($application, $file, $type); $this->documentService->uploadDocument($application, $file, $type);
return $this->repository->findByIdWithRelations($application->id); return $this->repository->findByIdWithRelations($application->id);
} }
public function deleteDocument(MembershipApplication $application, string $documentId): MembershipApplication
{
if ($application->status === ApplicationStatus::Completed) {
throw ValidationException::withMessages([
'status' => ['Permohonan yang telah selesai tidak boleh dikemaskini.'],
]);
}
$document = $application->documents()->findOrFail($documentId);
if ($document->type !== MembershipApplication::ADMIN_ATTACHMENT_DOCUMENT_TYPE) {
throw ValidationException::withMessages([
'document' => ['Hanya lampiran pentadbir boleh dipadam.'],
]);
}
$this->documentService->deleteDocument($document->id);
return $this->repository->findByIdWithRelations($application->id);
}
public function managementReview(MembershipApplication $application, ManagementDecision $decision, ?string $remarks, User $reviewer): MembershipApplication { public function managementReview(MembershipApplication $application, ManagementDecision $decision, ?string $remarks, User $reviewer): MembershipApplication {
if ($application->status !== ApplicationStatus::Submitted) { if ($application->status !== ApplicationStatus::Submitted) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
@@ -176,7 +201,7 @@ class MembershipApplicationService
{ {
if ($application->status !== ApplicationStatus::PendingBoard) { if ($application->status !== ApplicationStatus::PendingBoard) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'status' => ['Permohonan ini tidak boleh disemak pada peringkat lembaga.'], 'status' => ['Permohonan ini tidak boleh disemak pada peringkat ahli lembaga.'],
]); ]);
} }
@@ -230,8 +255,12 @@ class MembershipApplicationService
}); });
} }
public function complete(MembershipApplication $application, User $processedBy): MembershipApplication public function complete(
{ MembershipApplication $application,
User $processedBy,
string $boardMeetingReference,
string $boardMeetingDate,
): MembershipApplication {
if ($application->status !== ApplicationStatus::PendingNotification) { if ($application->status !== ApplicationStatus::PendingNotification) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'status' => ['Permohonan ini tidak boleh diselesaikan pada masa ini.'], 'status' => ['Permohonan ini tidak boleh diselesaikan pada masa ini.'],
@@ -244,13 +273,19 @@ class MembershipApplicationService
]); ]);
} }
return DB::transaction(function () use ($application) { return DB::transaction(function () use ($application, $boardMeetingReference, $boardMeetingDate) {
$plainPassword = null;
if ($application->board_result === BoardResult::Pass->value && ! $application->user_id) { if ($application->board_result === BoardResult::Pass->value && ! $application->user_id) {
$user = $this->createMemberFromApplication($application); $member = $this->createMemberFromApplication($application);
$application->update(['user_id' => $user->id]); $application->update(['user_id' => $member['user']->id]);
$plainPassword = $member['plainPassword'];
$application->refresh();
} }
$this->sendResultNotification($application); $resultLetter = $this->storeResultLetter($application, $boardMeetingReference, $boardMeetingDate);
$this->sendResultNotification($application, $resultLetter, $plainPassword);
$application->update([ $application->update([
'status' => ApplicationStatus::Completed, 'status' => ApplicationStatus::Completed,
@@ -268,8 +303,12 @@ class MembershipApplicationService
* failed: list<array{id: string, application_number: string|null, message: string}> * failed: list<array{id: string, application_number: string|null, message: string}>
* } * }
*/ */
public function completeBatch(array $applicationIds, User $processedBy): array public function completeBatch(
{ array $applicationIds,
User $processedBy,
string $boardMeetingReference,
string $boardMeetingDate,
): array {
$succeeded = []; $succeeded = [];
$failed = []; $failed = [];
@@ -287,7 +326,12 @@ class MembershipApplicationService
} }
try { try {
$succeeded[] = $this->complete($application, $processedBy); $succeeded[] = $this->complete(
$application,
$processedBy,
$boardMeetingReference,
$boardMeetingDate,
);
} catch (ValidationException $e) { } catch (ValidationException $e) {
$failed[] = [ $failed[] = [
'id' => $id, 'id' => $id,
@@ -303,7 +347,11 @@ class MembershipApplicationService
]; ];
} }
public function generateResultLetter(MembershipApplication $application, string $boardMeetingReference,): MembershipApplication { public function generateResultLetter(
MembershipApplication $application,
string $boardMeetingReference,
string $boardMeetingDate,
): MembershipApplication {
if ($application->status !== ApplicationStatus::Completed) { if ($application->status !== ApplicationStatus::Completed) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'status' => ['Surat keputusan hanya boleh dijana untuk permohonan yang telah selesai.'], 'status' => ['Surat keputusan hanya boleh dijana untuk permohonan yang telah selesai.'],
@@ -316,47 +364,56 @@ class MembershipApplicationService
]); ]);
} }
return DB::transaction(function () use ($application, $boardMeetingReference) { return DB::transaction(function () use ($application, $boardMeetingReference, $boardMeetingDate) {
$lockedApplication = MembershipApplication::query() $this->storeResultLetter($application, $boardMeetingReference, $boardMeetingDate);
->whereKey($application->id)
->lockForUpdate()
->firstOrFail();
if ($lockedApplication->hasDocumentsOfType(MembershipApplication::RESULT_LETTER_DOCUMENT_TYPE)) {
throw ValidationException::withMessages([
'result_letter' => ['Surat keputusan telah dijana dan tidak boleh dijana semula.'],
]);
}
$application = $this->repository->findByIdWithRelations($lockedApplication->id);
if (! $application || ! $application->applicant) {
throw ValidationException::withMessages([
'application' => ['Maklumat pemohon tidak lengkap.'],
]);
}
$viewData = $this->buildResultLetterViewData($application, $boardMeetingReference);
$pdf = $this->letterService->renderPdf('membershipapplication::pdf.approved-letter', $viewData);
$fileName = 'surat-keputusan-'.$application->application_number.'.pdf';
$this->documentService->storeGeneratedDocument(
$application,
$pdf,
$fileName,
MembershipApplication::RESULT_LETTER_DOCUMENT_TYPE,
);
return $this->repository->findByIdWithRelations($application->id); return $this->repository->findByIdWithRelations($application->id);
}); });
} }
protected function storeResultLetter(
MembershipApplication $application,
string $boardMeetingReference,
string $boardMeetingDate,
): Document {
$lockedApplication = MembershipApplication::query()
->whereKey($application->id)
->lockForUpdate()
->firstOrFail();
if ($lockedApplication->hasDocumentsOfType(MembershipApplication::RESULT_LETTER_DOCUMENT_TYPE)) {
throw ValidationException::withMessages([
'result_letter' => ['Surat keputusan telah dijana dan tidak boleh dijana semula.'],
]);
}
$application = $this->repository->findByIdWithRelations($lockedApplication->id);
if (! $application || ! $application->applicant) {
throw ValidationException::withMessages([
'application' => ['Maklumat pemohon tidak lengkap.'],
]);
}
$viewData = $this->buildResultLetterViewData($application, $boardMeetingReference, $boardMeetingDate);
$pdf = $this->letterService->renderPdf('membershipapplication::pdf.approved-letter', $viewData);
$fileName = 'surat-keputusan-'.$application->application_number.'.pdf';
return $this->documentService->storeGeneratedDocument(
$application,
$pdf,
$fileName,
MembershipApplication::RESULT_LETTER_DOCUMENT_TYPE,
);
}
/** /**
* @return array<string, mixed> * @return array<string, mixed>
*/ */
public function getResultLetterViewData( public function getResultLetterViewData(
MembershipApplication $application, MembershipApplication $application,
string $boardMeetingReference, string $boardMeetingReference,
string $boardMeetingDate,
): array { ): array {
$application = $this->repository->findByIdWithRelations($application->id); $application = $this->repository->findByIdWithRelations($application->id);
@@ -366,7 +423,7 @@ class MembershipApplicationService
]); ]);
} }
return $this->buildResultLetterViewData($application, $boardMeetingReference); return $this->buildResultLetterViewData($application, $boardMeetingReference, $boardMeetingDate);
} }
/** /**
@@ -375,35 +432,28 @@ class MembershipApplicationService
protected function buildResultLetterViewData( protected function buildResultLetterViewData(
MembershipApplication $application, MembershipApplication $application,
string $boardMeetingReference, string $boardMeetingReference,
string $boardMeetingDate,
): array { ): array {
$applicant = $application->applicant; $applicant = $application->applicant;
$isPassed = $application->board_result === BoardResult::Pass->value; $isPassed = $application->board_result === BoardResult::Pass->value;
$monthlyShareInstallment = number_format(
MembershipApplication::MINIMUM_SHARE_CAPITAL / MembershipApplication::SHARE_INSTALLMENT_MONTHS,
2,
'.',
'',
);
return [ return [
'application' => $application, 'application' => $application,
'applicant' => $applicant, 'applicant' => $applicant,
'boardMeetingReference' => $boardMeetingReference, 'boardMeetingReference' => $boardMeetingReference,
'boardMeetingDate' => Carbon::parse($boardMeetingDate)->translatedFormat('d F Y'),
'isPassed' => $isPassed, 'isPassed' => $isPassed,
'boardResultLabel' => $isPassed ? 'lulus' : 'gagal',
'openingTone' => $isPassed ? 'Sukacita' : 'Dukacita',
'letterSubject' => $isPassed 'letterSubject' => $isPassed
? 'KELULUSAN KEANGGOTAAN KOPERASI PERMODALAN KELANTAN BERHAD' ? 'KELULUSAN PERMOHONAN MENJADI ANGGOTA KOPERASI PERMODALAN KELANTAN BERHAD (KoPKB)'
: 'KEPUTUSAN PERMOHONAN KEANGGOTAAN KOPERASI PERMODALAN KELANTAN BERHAD', : 'KEPUTUSAN PERMOHONAN MENJADI ANGGOTA KOPERASI PERMODALAN KELANTAN BERHAD (KoPKB)',
'monthlyShareInstallment' => $monthlyShareInstallment, 'memberNumber' => $isPassed ? $application->user?->member_number : null,
'stockMonthlyContribution' => number_format((float) $applicant->stock_monthly_contribution, 2, '.', ''),
'feeMonthlyContribution' => number_format((float) $applicant->fee_monthly_contribution, 2, '.', ''),
'shareInstallmentMonths' => MembershipApplication::SHARE_INSTALLMENT_MONTHS,
'minimumShareCapital' => number_format(MembershipApplication::MINIMUM_SHARE_CAPITAL, 2, '.', ''),
]; ];
} }
protected function createMemberFromApplication(MembershipApplication $application): User /**
* @return array{user: User, plainPassword: string}
*/
protected function createMemberFromApplication(MembershipApplication $application): array
{ {
$application->loadMissing(['applicant', 'heirs']); $application->loadMissing(['applicant', 'heirs']);
$applicant = $application->applicant; $applicant = $application->applicant;
@@ -414,10 +464,12 @@ class MembershipApplicationService
]); ]);
} }
$plainPassword = Str::password(16);
$user = User::create([ $user = User::create([
'name' => $applicant->name, 'name' => $applicant->name,
'email' => $applicant->email, 'email' => $applicant->email,
'password' => Hash::make(Str::password(16)), 'password' => Hash::make($plainPassword),
'ic_number' => $applicant->ic_number, 'ic_number' => $applicant->ic_number,
'phone_number' => $applicant->phone_number, 'phone_number' => $applicant->phone_number,
'position' => $applicant->current_position, 'position' => $applicant->current_position,
@@ -463,7 +515,10 @@ class MembershipApplicationService
app(EmailVerificationOtpService::class)->send($user); app(EmailVerificationOtpService::class)->send($user);
return $user; return [
'user' => $user,
'plainPassword' => $plainPassword,
];
} }
public function lookupMemberByIcNumber(string $icNumber): ?User public function lookupMemberByIcNumber(string $icNumber): ?User
@@ -488,20 +543,33 @@ class MembershipApplicationService
->notify(new MembershipApplicationSubmittedNotification($application)); ->notify(new MembershipApplicationSubmittedNotification($application));
} }
protected function sendResultNotification(MembershipApplication $application): void protected function sendResultNotification(
{ MembershipApplication $application,
Document $resultLetter,
?string $plainPassword = null,
): void {
$application->loadMissing('applicant'); $application->loadMissing('applicant');
$notification = $application->board_result === BoardResult::Pass->value $notification = $application->board_result === BoardResult::Pass->value
? new MembershipApplicationPassedNotification($application) ? new MembershipApplicationPassedNotification($application, $resultLetter, $plainPassword)
: new MembershipApplicationFailedNotification($application); : new MembershipApplicationFailedNotification($application, $resultLetter);
Notification::route('mail', $application->applicant->email)->notify($notification); Notification::route('mail', $application->applicant->email)->notify($notification);
} }
protected function generateMemberNumber(): int protected function generateMemberNumber(): int
{ {
return ((int) User::max('member_number')) + 1; $latestUser = User::query()
->whereNotNull('member_number')
->orderByDesc('member_number')
->lockForUpdate()
->first();
if ($latestUser === null && DB::getDriverName() === 'pgsql') {
DB::selectOne('SELECT pg_advisory_xact_lock(?) AS locked', [742891035]);
}
return ((int) ($latestUser?->member_number ?? 0)) + 1;
} }
/** /**
+3
View File
@@ -2,4 +2,7 @@
return [ return [
'name' => 'User', 'name' => 'User',
'public_profile_token_ttl_days' => (int) env('PUBLIC_PROFILE_TOKEN_TTL_DAYS', 90),
'frontend_url' => rtrim(env('FRONTEND_URL', env('APP_URL', 'http://localhost')), '/'),
'digital_card_logo_path' => env('DIGITAL_CARD_LOGO_PATH', public_path('images/logo/logo-original.svg')),
]; ];
@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Modules\Auth\Entities\User;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('public_profile_token', 64)->nullable()->unique()->after('member_type');
});
User::withTrashed()
->whereNull('public_profile_token')
->cursor()
->each(function (User $user) {
$user->forceFill([
'public_profile_token' => Str::random(48),
])->saveQuietly();
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropUnique(['public_profile_token']);
$table->dropColumn('public_profile_token');
});
}
};
@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Modules\Auth\Entities\User;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->timestamp('public_profile_token_expires_at')->nullable()->after('public_profile_token');
});
$expiryDays = (int) config('user.public_profile_token_ttl_days', 90);
User::withTrashed()
->whereNotNull('public_profile_token')
->whereNull('public_profile_token_expires_at')
->cursor()
->each(function (User $user) use ($expiryDays) {
$user->forceFill([
'public_profile_token_expires_at' => now()->addDays($expiryDays),
])->saveQuietly();
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('public_profile_token_expires_at');
});
}
};
@@ -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('onboarding_completed_at')->nullable()->after('birth_place');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('onboarding_completed_at');
});
}
};
@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->date('leave_date')->nullable()->after('join_date');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('leave_date');
});
}
};
@@ -0,0 +1,61 @@
<?php
namespace Modules\User\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Modules\User\Services\MemberDigitalCardService;
use Symfony\Component\HttpFoundation\Response;
class MemberDigitalCardController extends Controller
{
public function __construct(
protected MemberDigitalCardService $memberDigitalCardService
) {}
public function download(Request $request): Response|JsonResponse
{
$validated = $request->validate([
'side' => 'sometimes|in:depan,belakang',
]);
$user = $request->user();
$side = $validated['side'] ?? 'depan';
if ($user->status !== 'active') {
return response()->json([
'success' => false,
'message' => 'Kad digital hanya tersedia untuk anggota aktif.',
'code' => 'digital_card_inactive',
'data' => null,
], 403);
}
try {
$png = $this->memberDigitalCardService->renderPng($user, $side);
$memberNumber = $user->member_number ?: 'anggota';
$filename = sprintf('kad-digital-%s-%s.png', $memberNumber, $side);
return response($png, Response::HTTP_OK, [
'Content-Type' => 'image/png',
'Content-Disposition' => 'attachment; filename="'.$filename.'"',
'Cache-Control' => 'no-store, private',
]);
} catch (\Throwable $exception) {
Log::error('Failed to render member digital card.', [
'user_id' => $user->id,
'side' => $side,
'message' => $exception->getMessage(),
]);
return response()->json([
'success' => false,
'message' => 'Gagal menjana kad digital. Sila cuba lagi.',
'code' => 'digital_card_render_failed',
'data' => null,
], 500);
}
}
}
@@ -0,0 +1,37 @@
<?php
namespace Modules\User\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Modules\User\Services\PublicMemberProfileService;
use Modules\User\Transformers\PublicMemberProfileResource;
class PublicMemberProfileController extends Controller
{
public function __construct(
protected PublicMemberProfileService $publicMemberProfileService
) {}
public function show(string $token): JsonResponse
{
$member = $this->publicMemberProfileService->findMemberByToken($token);
if (! $member) {
return response()->json([
'success' => false,
'message' => 'Anggota tidak dijumpai atau tidak sah.',
'code' => 'public_profile_not_found',
'data' => null,
], 404);
}
$member = $this->publicMemberProfileService->loadVerifiableRelations($member);
return response()->json([
'success' => true,
'message' => 'Anggota disahkan.',
'data' => new PublicMemberProfileResource($member),
]);
}
}
@@ -44,7 +44,7 @@ class UserController extends BaseCrudController
$validated['uuid'] = Str::uuid(); $validated['uuid'] = Str::uuid();
$validated['password'] = Hash::make('suteraselamanya'); $validated['password'] = Hash::make('suteraselamanya');
return $validated; return $this->applyLeaveDateForStatus($validated);
} }
/** /**
@@ -58,6 +58,25 @@ class UserController extends BaseCrudController
unset($validated['email']); unset($validated['email']);
} }
return $this->applyLeaveDateForStatus($validated, $user);
}
/**
* Set or clear leave_date based on membership status transitions.
*/
protected function applyLeaveDateForStatus(array $validated, ?User $user = null): array
{
$newStatus = $validated['status'] ?? $user?->status;
$currentStatus = $user?->status;
if ($newStatus === 'inactive') {
if ($currentStatus !== 'inactive') {
$validated['leave_date'] = $validated['leave_date'] ?? now()->toDateString();
}
} elseif (in_array($newStatus, ['active', 'pending'], true)) {
$validated['leave_date'] = null;
}
return $validated; return $validated;
} }
@@ -127,6 +146,17 @@ class UserController extends BaseCrudController
]); ]);
} }
public function completeOnboarding(Request $request): JsonResponse
{
$user = $this->userService->completeOnboarding($request->user());
return response()->json([
'success' => true,
'data' => new UserResource($user),
'message' => 'Onboarding completed.',
]);
}
public function updatePassword(Request $request): JsonResponse public function updatePassword(Request $request): JsonResponse
{ {
$user = $request->user(); $user = $request->user();
@@ -160,13 +190,27 @@ class UserController extends BaseCrudController
$this->authorize('viewAny', $this->modelClass); $this->authorize('viewAny', $this->modelClass);
try { try {
$request->validate([
'join_date_from' => 'nullable|date',
'join_date_to' => 'nullable|date',
'leave_date_from' => 'nullable|date',
'leave_date_to' => 'nullable|date',
]);
$perPage = min((int) $request->get('per_page', 10), 500); $perPage = min((int) $request->get('per_page', 10), 500);
$dateFilters = array_filter([
'join_date_from' => $request->get('join_date_from'),
'join_date_to' => $request->get('join_date_to'),
'leave_date_from' => $request->get('leave_date_from'),
'leave_date_to' => $request->get('leave_date_to'),
]);
$items = $this->userService->getPaginatedList( $items = $this->userService->getPaginatedList(
$perPage, $perPage,
$request->get('search', ''), $request->get('search', ''),
$request->get('status', ''), $request->get('status', ''),
$request->get('sort_by', 'id'), $request->get('sort_by', 'id'),
$request->get('sort_order', 'asc') $request->get('sort_order', 'asc'),
$dateFilters
); );
return response()->json([ return response()->json([
@@ -44,6 +44,7 @@ class UserRequest extends FormRequest
'member_number' => 'required|integer', 'member_number' => 'required|integer',
'member_type' => 'required|string|max:255', 'member_type' => 'required|string|max:255',
'join_date' => 'nullable|date', 'join_date' => 'nullable|date',
'leave_date' => 'nullable|date',
'birth_date' => 'nullable|date', 'birth_date' => 'nullable|date',
'birth_place' => 'nullable|string|max:255', 'birth_place' => 'nullable|string|max:255',
]; ];
@@ -74,6 +75,7 @@ class UserRequest extends FormRequest
'member_number' => 'required|integer', 'member_number' => 'required|integer',
'member_type' => 'required|string|max:255', 'member_type' => 'required|string|max:255',
'join_date' => 'nullable|date', 'join_date' => 'nullable|date',
'leave_date' => 'nullable|date',
'birth_date' => 'nullable|date', 'birth_date' => 'nullable|date',
'birth_place' => 'nullable|string|max:255', 'birth_place' => 'nullable|string|max:255',
]; ];
@@ -100,6 +102,7 @@ class UserRequest extends FormRequest
'member_number' => 'required|integer', 'member_number' => 'required|integer',
'member_type' => 'required|string|max:255', 'member_type' => 'required|string|max:255',
'join_date' => 'required|date', 'join_date' => 'required|date',
'leave_date' => 'nullable|date',
'birth_date' => 'required|date', 'birth_date' => 'required|date',
'birth_place' => 'required|string|max:255', 'birth_place' => 'required|string|max:255',
]; ];
@@ -136,6 +139,7 @@ class UserRequest extends FormRequest
'member_type.max' => 'Jenis anggota tidak boleh melebihi 255 aksara.', 'member_type.max' => 'Jenis anggota tidak boleh melebihi 255 aksara.',
'join_date.required' => 'Tarikh join diperlukan.', 'join_date.required' => 'Tarikh join diperlukan.',
'join_date.date' => 'Tarikh join tidak sah.', 'join_date.date' => 'Tarikh join tidak sah.',
'leave_date.date' => 'Tarikh berhenti tidak sah.',
'birth_date.required' => 'Tarikh lahir diperlukan.', 'birth_date.required' => 'Tarikh lahir diperlukan.',
'birth_date.date' => 'Tarikh lahir tidak sah.', 'birth_date.date' => 'Tarikh lahir tidak sah.',
'birth_place.required' => 'Tempat lahir diperlukan.', 'birth_place.required' => 'Tempat lahir diperlukan.',
@@ -5,6 +5,8 @@ namespace Modules\User\Providers;
use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Cache\RateLimiting\Limit;
use Modules\Auth\Entities\User; use Modules\Auth\Entities\User;
use Modules\User\Policies\UserPolicy; use Modules\User\Policies\UserPolicy;
use Modules\User\Entities\Address; use Modules\User\Entities\Address;
@@ -34,6 +36,7 @@ class UserServiceProvider extends ServiceProvider
*/ */
public function boot(): void public function boot(): void
{ {
$this->registerRateLimiters();
$this->registerCommands(); $this->registerCommands();
$this->registerCommandSchedules(); $this->registerCommandSchedules();
$this->registerTranslations(); $this->registerTranslations();
@@ -96,6 +99,13 @@ class UserServiceProvider extends ServiceProvider
Gate::policy(Heir::class, HeirPolicy::class); Gate::policy(Heir::class, HeirPolicy::class);
} }
protected function registerRateLimiters(): void
{
RateLimiter::for('public-member-profile', function ($request) {
return Limit::perMinute(60)->by($request->ip());
});
}
/** /**
* Register commands in the format of Command::class * Register commands in the format of Command::class
*/ */
@@ -20,7 +20,8 @@ interface UserRepositoryInterface
string $search = '', string $search = '',
string $status = '', string $status = '',
string $sortBy = 'name', string $sortBy = 'name',
string $sortOrder = 'asc' string $sortOrder = 'asc',
array $dateFilters = []
); );
/** /**
@@ -26,6 +26,25 @@ class UserRepository implements UserRepositoryInterface
}); });
} }
private function applyDateRangeFilters($query, array $dateFilters): void
{
if (! empty($dateFilters['join_date_from'])) {
$query->whereDate('join_date', '>=', $dateFilters['join_date_from']);
}
if (! empty($dateFilters['join_date_to'])) {
$query->whereDate('join_date', '<=', $dateFilters['join_date_to']);
}
if (! empty($dateFilters['leave_date_from'])) {
$query->whereDate('leave_date', '>=', $dateFilters['leave_date_from']);
}
if (! empty($dateFilters['leave_date_to'])) {
$query->whereDate('leave_date', '<=', $dateFilters['leave_date_to']);
}
}
/** /**
* Get all Users with pagination and search * Get all Users with pagination and search
*/ */
@@ -46,7 +65,8 @@ class UserRepository implements UserRepositoryInterface
string $search = '', string $search = '',
string $status = '', string $status = '',
string $sortBy = 'name', string $sortBy = 'name',
string $sortOrder = 'asc' string $sortOrder = 'asc',
array $dateFilters = []
) { ) {
$allowedSortColumns = [ $allowedSortColumns = [
'id', 'id',
@@ -55,6 +75,8 @@ class UserRepository implements UserRepositoryInterface
'position', 'position',
'status', 'status',
'member_number', 'member_number',
'join_date',
'leave_date',
'created_at', 'created_at',
'deleted_at', 'deleted_at',
]; ];
@@ -73,6 +95,8 @@ class UserRepository implements UserRepositoryInterface
$query->where('status', $status); $query->where('status', $status);
} }
$this->applyDateRangeFilters($query, $dateFilters);
return $query->paginate($perPage); return $query->paginate($perPage);
} }
@@ -93,6 +117,8 @@ class UserRepository implements UserRepositoryInterface
'position', 'position',
'status', 'status',
'member_number', 'member_number',
'join_date',
'leave_date',
'created_at', 'created_at',
'deleted_at', 'deleted_at',
]; ];
@@ -0,0 +1,166 @@
<!DOCTYPE html>
<html lang="ms">
<head>
<meta charset="utf-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 840px;
height: 540px;
overflow: hidden;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.card {
position: relative;
width: 840px;
height: 540px;
overflow: hidden;
border-radius: 16px;
color: #eff6ff;
background: linear-gradient(to bottom right, #1e3a8a, #1e40af, #2563eb);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
outline: 1px solid rgba(255, 255, 255, 0.2);
padding: 24px;
}
.orb-1 {
position: absolute;
right: -48px;
top: -48px;
width: 176px;
height: 176px;
border-radius: 9999px;
background: rgba(255, 255, 255, 0.1);
}
.orb-2 {
position: absolute;
bottom: -64px;
left: -40px;
width: 192px;
height: 192px;
border-radius: 9999px;
background: rgba(255, 255, 255, 0.05);
}
.content {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-shrink: 0;
}
.logo {
height: 36px;
width: auto;
filter: brightness(0) invert(1);
}
.badge {
font-size: 14px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.18em;
opacity: 0.75;
text-align: right;
}
.main {
display: flex;
flex: 1;
min-height: 0;
align-items: center;
gap: 24px;
margin-top: 20px;
}
.qr-wrap {
flex-shrink: 0;
border-radius: 8px;
background: #ffffff;
padding: 12px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.qr-wrap img {
display: block;
width: 144px;
height: 144px;
}
.info {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
justify-content: center;
gap: 20px;
}
.info p {
font-size: 18px;
line-height: 1.375;
opacity: 0.85;
}
.label {
font-size: 14px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.1em;
opacity: 0.6;
}
.member-number {
margin-top: 2px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 36px;
font-weight: 600;
letter-spacing: 0.1em;
}
.footer {
flex-shrink: 0;
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.15);
text-align: center;
}
.footer p {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.2em;
opacity: 0.5;
}
</style>
</head>
<body>
<div class="card">
<div class="orb-1"></div>
<div class="orb-2"></div>
<div class="content">
<div class="header">
@if ($logoDataUri)
<img src="{{ $logoDataUri }}" alt="" class="logo">
@endif
<div class="badge">Belakang · Kod QR</div>
</div>
<div class="main">
<div class="qr-wrap">
<img src="{{ $qrDataUri }}" alt="Kod QR profil anggota">
</div>
<div class="info">
<p>Imbas untuk sahkan profil anggota MyKOPKB.</p>
<div>
<div class="label">No. Anggota</div>
<div class="member-number">{{ $memberNumber }}</div>
</div>
</div>
</div>
<div class="footer">
<p>Koperasi Permodalan Kelantan Berhad (KOPKB)</p>
</div>
</div>
</div>
</body>
</html>
@@ -0,0 +1,214 @@
<!DOCTYPE html>
<html lang="ms">
<head>
<meta charset="utf-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 840px;
height: 540px;
overflow: hidden;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.card {
position: relative;
width: 840px;
height: 540px;
overflow: hidden;
border-radius: 16px;
color: #eff6ff;
background: linear-gradient(to bottom right, #1e3a8a, #1e40af, #1d4ed8);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
outline: 1px solid rgba(255, 255, 255, 0.2);
padding: 32px;
}
.orb-1 {
position: absolute;
right: -40px;
top: -40px;
width: 192px;
height: 192px;
border-radius: 9999px;
background: rgba(255, 255, 255, 0.1);
}
.orb-2 {
position: absolute;
bottom: -48px;
left: -32px;
width: 208px;
height: 208px;
border-radius: 9999px;
background: rgba(255, 255, 255, 0.05);
}
.avatar {
position: absolute;
top: 50%;
right: 24px;
transform: translateY(-50%);
width: 112px;
height: 112px;
overflow: hidden;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.25);
background: rgba(255, 255, 255, 0.1);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.avatar-fallback {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.15);
font-size: 18px;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.content {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
flex-shrink: 0;
}
.logo {
height: 40px;
width: auto;
filter: brightness(0) invert(1);
}
.badge {
font-size: 14px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.2em;
opacity: 0.8;
text-align: right;
}
.main {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
justify-content: center;
gap: 16px;
padding: 8px 0;
}
.label {
font-size: 14px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.1em;
opacity: 0.7;
}
.member-number {
margin-top: 2px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 48px;
font-weight: 600;
letter-spacing: 0.15em;
}
.company-block {
min-width: 0;
padding-right: 152px;
}
.company-value {
font-size: 20px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.footer {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 12px;
flex-shrink: 0;
padding-top: 16px;
border-top: 1px solid rgba(255, 255, 255, 0.15);
}
.footer-name {
min-width: 0;
flex: 1;
font-size: 24px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.footer-type {
flex-shrink: 0;
text-align: right;
font-size: 24px;
font-weight: 600;
}
.footer-sub {
margin-top: 2px;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.6;
}
</style>
</head>
<body>
<div class="card">
<div class="orb-1"></div>
<div class="orb-2"></div>
<div class="avatar">
@if ($avatarDataUri)
<img src="{{ $avatarDataUri }}" alt="{{ $memberName }}">
@else
<div class="avatar-fallback">{{ $avatarInitials }}</div>
@endif
</div>
<div class="content">
<div class="header">
@if ($logoDataUri)
<img src="{{ $logoDataUri }}" alt="" class="logo">
@endif
<div class="badge">Kad Digital</div>
</div>
<div class="main">
<div>
<div class="label">No. Anggota</div>
<div class="member-number">{{ $memberNumber }}</div>
</div>
<div class="company-block">
<div class="label">Unit</div>
<div class="company-value">{{ $companyName }}</div>
</div>
</div>
<div class="footer">
<div>
<div class="footer-name">{{ $memberName }}</div>
<div class="footer-sub">Nama</div>
</div>
<div>
<div class="footer-type">{{ $memberType }}</div>
<div class="footer-sub">Jenis Anggota</div>
</div>
</div>
</div>
</div>
</body>
</html>
+11
View File
@@ -8,6 +8,14 @@ use Modules\User\Http\Controllers\EmploymentController;
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;
use Modules\User\Http\Controllers\MemberDigitalCardController;
use Modules\User\Http\Controllers\PublicMemberProfileController;
Route::prefix('v1/public')->group(function () {
Route::middleware('throttle:public-member-profile')
->get('members/{token}', [PublicMemberProfileController::class, 'show'])
->name('user.public.member-profile');
});
Route::middleware(['auth:sanctum', 'single.session'])->prefix('v1')->group(function () { Route::middleware(['auth:sanctum', 'single.session'])->prefix('v1')->group(function () {
Route::get('users/deleted', [UserController::class, 'deletedIndex'])->name('users.deleted.index'); Route::get('users/deleted', [UserController::class, 'deletedIndex'])->name('users.deleted.index');
@@ -27,7 +35,10 @@ Route::middleware(['auth:sanctum', 'single.session'])->prefix('v1')->group(funct
// User profile management routes // User profile management routes
Route::post('/profile', [UserController::class, 'updateProfile']); Route::post('/profile', [UserController::class, 'updateProfile']);
Route::post('/profile/onboarding/complete', [UserController::class, 'completeOnboarding']);
Route::put('/profile/password', [UserController::class, 'updatePassword']); Route::put('/profile/password', [UserController::class, 'updatePassword']);
Route::get('/profile/digital-card', [MemberDigitalCardController::class, 'download'])
->name('profile.digital-card.download');
// Impersonation routes // Impersonation routes
Route::get('/impersonate/take/{id}', [ImpersonateController::class, 'take'])->name('impersonate'); Route::get('/impersonate/take/{id}', [ImpersonateController::class, 'take'])->name('impersonate');
@@ -0,0 +1,141 @@
<?php
namespace Modules\User\Services;
use BaconQrCode\Renderer\Image\SvgImageBackEnd;
use BaconQrCode\Renderer\ImageRenderer;
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
use BaconQrCode\Writer;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\View;
use Modules\Auth\Entities\User;
use Spatie\Browsershot\Browsershot;
class MemberDigitalCardService
{
public const CARD_WIDTH = 840;
public const CARD_HEIGHT = 540;
public function renderPng(User $user, string $side): string
{
$view = $side === 'belakang'
? 'user::digital-card.back'
: 'user::digital-card.front';
$html = View::make($view, $this->prepareViewData($user))->render();
return $this->makeBrowsershot($html)->screenshot();
}
/**
* @return array<string, mixed>
*/
protected function prepareViewData(User $user): array
{
$user = app(PublicMemberProfileService::class)->loadVerifiableRelations($user);
$user->ensurePublicProfileToken();
$currentEmployment = $user->employments->firstWhere('is_current', true)
?? $user->employments->first();
$logoPath = config('user.digital_card_logo_path');
$profileUrl = rtrim(config('user.frontend_url'), '/').'/v/'.$user->public_profile_token;
return [
'memberNumber' => $this->displayCardValue($user->member_number),
'memberName' => $user->name ?: '-',
'memberType' => $this->displayCardValue($user->member_type),
'companyName' => $this->displayCardValue($currentEmployment?->company_name),
'avatarDataUri' => $this->resolveStorageDataUri($user->image_url),
'avatarInitials' => $this->avatarInitials($user->name),
'logoDataUri' => $this->resolveFileDataUri($logoPath),
'profileUrl' => $profileUrl,
'qrDataUri' => $this->generateQrDataUri($profileUrl),
];
}
protected function makeBrowsershot(string $html): Browsershot
{
$browsershot = Browsershot::html($html)
->setNodeModulePath(config('browsershot.node_module_path'))
->timeout(config('browsershot.timeout'))
->windowSize(self::CARD_WIDTH, self::CARD_HEIGHT)
->deviceScaleFactor(2)
->showBackground();
if ($nodeBinary = config('browsershot.node_binary')) {
$browsershot->setNodeBinary($nodeBinary);
}
if ($npmBinary = config('browsershot.npm_binary')) {
$browsershot->setNpmBinary($npmBinary);
}
if ($chromePath = config('browsershot.chrome_path')) {
$browsershot->setChromePath($chromePath);
}
if (config('browsershot.no_sandbox')) {
$browsershot->noSandbox();
}
return $browsershot;
}
protected function generateQrDataUri(string $content): string
{
$renderer = new ImageRenderer(
new RendererStyle(220, 1),
new SvgImageBackEnd
);
$writer = new Writer($renderer);
$svg = $writer->writeString($content);
return 'data:image/svg+xml;base64,'.base64_encode($svg);
}
protected function resolveFileDataUri(?string $path): ?string
{
if (empty($path) || ! file_exists($path)) {
return null;
}
$mime = mime_content_type($path) ?: 'image/svg+xml';
return 'data:'.$mime.';base64,'.base64_encode(file_get_contents($path));
}
protected function resolveStorageDataUri(?string $path): ?string
{
if (blank($path)) {
return null;
}
$fullPath = Storage::disk('public')->path($path);
return $this->resolveFileDataUri($fullPath);
}
protected function avatarInitials(?string $name): string
{
$trimmed = trim((string) $name);
if ($trimmed === '') {
return '--';
}
return strtoupper(mb_substr($trimmed, 0, 2));
}
protected function displayCardValue(mixed $value): string
{
if ($value === null || $value === '') {
return '-';
}
$trimmed = trim((string) $value);
return $trimmed !== '' ? $trimmed : '-';
}
}
@@ -0,0 +1,25 @@
<?php
namespace Modules\User\Services;
use Modules\Auth\Entities\User;
class PublicMemberProfileService
{
public function findMemberByToken(string $token): ?User
{
return User::query()
->where('public_profile_token', $token)
->where('status', 'active')
->first();
}
public function loadVerifiableRelations(User $member): User
{
$member->load(['employments' => function ($query) {
$query->orderByDesc('is_current')->orderByDesc('start_date');
}]);
return $member;
}
}
+15 -2
View File
@@ -21,14 +21,16 @@ class UserService
string $search, string $search,
string $status, string $status,
string $sortBy, string $sortBy,
string $sortOrder string $sortOrder,
array $dateFilters = []
): LengthAwarePaginator { ): LengthAwarePaginator {
return $this->repository->getAllWithRelationsPaginated( return $this->repository->getAllWithRelationsPaginated(
$perPage, $perPage,
$search, $search,
$status, $status,
$sortBy, $sortBy,
$sortOrder $sortOrder,
$dateFilters
); );
} }
@@ -121,6 +123,17 @@ class UserService
/** /**
* @return array{}|array{error: string} * @return array{}|array{error: string}
*/ */
public function completeOnboarding(User $user): User
{
if ($user->onboarding_completed_at === null) {
$user->update(['onboarding_completed_at' => now()]);
}
$user->load(['roles.permissions']);
return $user;
}
public function updatePassword(User $user, string $currentPassword, string $newPassword): array public function updatePassword(User $user, string $currentPassword, string $newPassword): array
{ {
if (! Hash::check($currentPassword, $user->password)) { if (! Hash::check($currentPassword, $user->password)) {
@@ -0,0 +1,26 @@
<?php
namespace Modules\User\Transformers;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Storage;
class PublicMemberProfileResource extends JsonResource
{
public function toArray(Request $request): array
{
$currentEmployment = $this->employments->firstWhere('is_current', true)
?? $this->employments->first();
return [
'name' => $this->name,
'member_number' => $this->member_number,
'member_type' => $this->member_type,
'status' => $this->status,
'image_url' => $this->image_url ? Storage::disk('public')->url($this->image_url) : null,
'company_name' => $currentEmployment?->company_name,
'verified_at' => now()->toIso8601String(),
];
}
}
@@ -25,6 +25,7 @@ class UserListResource extends JsonResource
'member_number' => $this->member_number, 'member_number' => $this->member_number,
'member_type' => $this->member_type, 'member_type' => $this->member_type,
'join_date' => $this->join_date, 'join_date' => $this->join_date,
'leave_date' => $this->leave_date,
'birth_date' => $this->birth_date, 'birth_date' => $this->birth_date,
'birth_place' => $this->birth_place, 'birth_place' => $this->birth_place,
'created_at' => $this->created_at, 'created_at' => $this->created_at,
+8 -5
View File
@@ -56,11 +56,14 @@ class LetterService
public function prepareViewData(array $overrides = []): array public function prepareViewData(array $overrides = []): array
{ {
$logoPath = $overrides['logoPath'] ?? config('mail.logo_path'); $logoPath = $overrides['logoPath'] ?? config('mail.logo_path');
$signaturePath = $overrides['signaturePath'] ?? config('letter.signature_path');
return array_merge([ return array_merge([
'letterLayout' => LetterLayout::resolve(), 'letterLayout' => LetterLayout::resolve(),
'logoPath' => $logoPath, 'logoPath' => $logoPath,
'logoUrl' => $this->resolveLogoDataUri($logoPath), 'logoUrl' => $this->resolveImageDataUri($logoPath),
'signaturePath' => $signaturePath,
'signatureUrl' => $this->resolveImageDataUri($signaturePath),
'organizationAddress' => $overrides['organizationAddress'] ?? null, 'organizationAddress' => $overrides['organizationAddress'] ?? null,
'organizationContact' => $overrides['organizationContact'] ?? null, 'organizationContact' => $overrides['organizationContact'] ?? null,
], $overrides); ], $overrides);
@@ -97,14 +100,14 @@ class LetterService
return $browsershot; return $browsershot;
} }
protected function resolveLogoDataUri(?string $logoPath): ?string protected function resolveImageDataUri(?string $path): ?string
{ {
if (empty($logoPath) || ! file_exists($logoPath)) { if (empty($path) || ! file_exists($path)) {
return null; return null;
} }
$mime = mime_content_type($logoPath) ?: 'image/svg+xml'; $mime = mime_content_type($path) ?: 'image/svg+xml';
return 'data:'.$mime.';base64,'.base64_encode(file_get_contents($logoPath)); return 'data:'.$mime.';base64,'.base64_encode(file_get_contents($path));
} }
} }
+2 -2
View File
@@ -24,8 +24,8 @@ return [
'http://127.0.0.1', 'http://127.0.0.1',
'http://localhost:5173', 'http://localhost:5173',
'http://127.0.0.1:5173', 'http://127.0.0.1:5173',
'https://anggota.koppkb.com', 'https://mykopkb.koppkb.com',
'http://anggota.koppkb.com', 'http://mykopkb.koppkb.com',
], ],
'allowed_origins_patterns' => [], 'allowed_origins_patterns' => [],
+8
View File
@@ -82,4 +82,12 @@ return [
'black' => '#000000', 'black' => '#000000',
], ],
/*
|--------------------------------------------------------------------------
| Assets
|--------------------------------------------------------------------------
*/
'signature_path' => resource_path('assets/signatures/signature.svg'),
]; ];
+1 -1
View File
@@ -124,6 +124,6 @@ return [
| |
*/ */
'logo_path' => env('MAIL_LOGO_PATH', public_path('images/logo/logo-kopkb-letterhead.svg')), 'logo_path' => public_path('images/logo/logo-original.svg'),
]; ];
@@ -12,7 +12,7 @@ return new class extends Migration
public function up(): void public function up(): void
{ {
Schema::create('jobs', function (Blueprint $table) { Schema::create('jobs', function (Blueprint $table) {
$table->uuid('id')->primary(); $table->id();
$table->string('queue')->index(); $table->string('queue')->index();
$table->longText('payload'); $table->longText('payload');
$table->unsignedTinyInteger('attempts'); $table->unsignedTinyInteger('attempts');
@@ -22,7 +22,7 @@ return new class extends Migration
}); });
Schema::create('job_batches', function (Blueprint $table) { Schema::create('job_batches', function (Blueprint $table) {
$table->uuid('id')->primary(); $table->string('id')->primary();
$table->string('name'); $table->string('name');
$table->integer('total_jobs'); $table->integer('total_jobs');
$table->integer('pending_jobs'); $table->integer('pending_jobs');
@@ -35,7 +35,7 @@ return new class extends Migration
}); });
Schema::create('failed_jobs', function (Blueprint $table) { Schema::create('failed_jobs', function (Blueprint $table) {
$table->uuid('id')->primary(); $table->id();
$table->string('uuid')->unique(); $table->string('uuid')->unique();
$table->text('connection'); $table->text('connection');
$table->text('queue'); $table->text('queue');
+60 -1
View File
@@ -46,6 +46,25 @@ COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
RUN composer install --no-dev --optimize-autoloader --no-interaction --no-progress --prefer-dist --no-scripts \ RUN composer install --no-dev --optimize-autoloader --no-interaction --no-progress --prefer-dist --no-scripts \
&& rm -rf bootstrap/cache/services.php bootstrap/cache/packages.php && rm -rf bootstrap/cache/services.php bootstrap/cache/packages.php
# Stage 1b: Node + Puppeteer for Browsershot PDF generation
FROM node:22-bookworm-slim AS node-deps
WORKDIR /var/www
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
unzip \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
COPY be/package.json be/package-lock.json ./
ENV PUPPETEER_SKIP_DOWNLOAD=true \
PUPPETEER_CACHE_DIR=/var/www/.cache/puppeteer
RUN npm ci --omit=dev
RUN npx puppeteer browsers install chrome-headless-shell
# Stage 2: Unified production image with PHP-FPM + Nginx # Stage 2: Unified production image with PHP-FPM + Nginx
FROM php:8.4-fpm AS base FROM php:8.4-fpm AS base
@@ -79,6 +98,37 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& docker-php-ext-enable redis \ && docker-php-ext-enable redis \
&& apt-get autoremove -y && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* && apt-get autoremove -y && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# Chromium runtime libraries for Puppeteer (Browsershot PDF generation)
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
fonts-liberation \
libasound2 \
libatk-bridge2.0-0 \
libatk1.0-0 \
libcairo2 \
libcups2 \
libdbus-1-3 \
libdrm2 \
libexpat1 \
libfontconfig1 \
libgbm1 \
libglib2.0-0 \
libnspr4 \
libnss3 \
libpango-1.0-0 \
libpangocairo-1.0-0 \
libx11-6 \
libx11-xcb1 \
libxcb1 \
libxcomposite1 \
libxdamage1 \
libxext6 \
libxfixes3 \
libxkbcommon0 \
libxrandr2 \
libxrender1 \
&& apt-get autoremove -y && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# Download and install php-fpm health check script # Download and install php-fpm health check script
RUN curl -o /usr/local/bin/php-fpm-healthcheck \ RUN curl -o /usr/local/bin/php-fpm-healthcheck \
https://raw.githubusercontent.com/renatomefi/php-fpm-healthcheck/master/php-fpm-healthcheck \ https://raw.githubusercontent.com/renatomefi/php-fpm-healthcheck/master/php-fpm-healthcheck \
@@ -120,11 +170,20 @@ RUN echo '[www]' > /usr/local/etc/php-fpm.d/zzz-status.conf && \
# Copy the application code and dependencies from the build stage first # Copy the application code and dependencies from the build stage first
COPY --from=builder /var/www /var/www COPY --from=builder /var/www /var/www
# Node runtime + Puppeteer for Browsershot (letter PDF generation)
COPY --from=node-deps /usr/local/bin/node /usr/local/bin/node
COPY --from=node-deps /var/www/node_modules /var/www/node_modules
COPY --from=node-deps /var/www/.cache/puppeteer /var/www/.cache/puppeteer
ENV PUPPETEER_CACHE_DIR=/var/www/.cache/puppeteer \
BROWSERSHOT_NO_SANDBOX=true \
BROWSERSHOT_NODE_BINARY=/usr/local/bin/node
# Set working directory # Set working directory
WORKDIR /var/www WORKDIR /var/www
# Ensure correct permissions # Ensure correct permissions
RUN chown -R www-data:www-data /var/www RUN chown -R www-data:www-data /var/www /var/www/.cache/puppeteer
# Create Nginx log directory # Create Nginx log directory
RUN mkdir -p /var/log/nginx && chown -R www-data:www-data /var/log/nginx RUN mkdir -p /var/log/nginx && chown -R www-data:www-data /var/log/nginx
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 275 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 202 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.8 KiB

+73 -106
View File
@@ -8,6 +8,7 @@
@section('letter-reference') Ruj: APP-2026-00001 @endsection @section('letter-reference') Ruj: APP-2026-00001 @endsection
@section('letter-date') {{ now()->format('d F Y') }} @endsection @section('letter-date') {{ now()->format('d F Y') }} @endsection
@section('letter-recipient') ... @endsection @section('letter-recipient') ... @endsection
@section('letter-honorific') Tuan/Puan @endsection
@section('letter-subject') Perkara: ... @endsection @section('letter-subject') Perkara: ... @endsection
@section('letter-salutation') Assalamualaikum dan Salam Sejahtera, @endsection @section('letter-salutation') Assalamualaikum dan Salam Sejahtera, @endsection
@section('letter-body') ... @endsection @section('letter-body') ... @endsection
@@ -110,74 +111,27 @@
border-collapse: collapse; border-collapse: collapse;
} }
.letterhead-line { .letterhead-center {
vertical-align: middle; text-align: center;
padding-right: 16px; vertical-align: top;
}
.letterhead-rule {
width: 100%;
border-collapse: collapse;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.letterhead-rule-blue {
background-color: var(--letter-color-blue);
font-size: 0;
line-height: 0;
}
.letterhead-rule-orange {
background-color: var(--letter-color-orange);
font-size: 0;
line-height: 0;
}
.letterhead-logo {
vertical-align: middle;
white-space: nowrap;
padding-right: 10px;
} }
.letterhead-logo-img { .letterhead-logo-img {
height: var(--letter-logo-height); height: var(--letter-logo-height);
width: auto; width: auto;
display: block; display: block;
margin: 0 auto 8px;
} }
.letterhead-brand { .letterhead-address,
vertical-align: middle; .letterhead-contact {
white-space: nowrap;
padding-left: 4px;
}
.letterhead-brand-inner {
border-collapse: collapse;
}
.letterhead-name {
vertical-align: middle;
text-align: left;
padding-left: 2px;
}
.letterhead-name-line {
font-size: var(--letter-font-size);
font-weight: bold;
line-height: 1.25;
color: var(--letter-color-black);
text-transform: uppercase;
letter-spacing: 0.3px;
}
.letterhead-registration {
font-size: var(--letterhead-registration-size); font-size: var(--letterhead-registration-size);
font-weight: bold; line-height: 1.4;
line-height: 1.3;
color: var(--letter-color-black); color: var(--letter-color-black);
margin-top: 2px; }
letter-spacing: 0.5px;
.letterhead-address {
margin-bottom: 4px;
} }
.meta-table { .meta-table {
@@ -192,22 +146,25 @@
} }
.meta-label { .meta-label {
width: 70px;
font-weight: bold; font-weight: bold;
white-space: nowrap;
padding-right: 4px;
vertical-align: top;
} }
.meta-value { .meta-value {
padding-left: 4px; padding-left: 4px;
} }
.meta-date {
margin-top: 4px;
}
.recipient-block { .recipient-block {
margin-bottom: var(--letter-recipient-margin-bottom); margin-bottom: var(--letter-recipient-margin-bottom);
} }
.letter-honorific {
font-weight: bold;
margin-bottom: 8px;
}
.subject-block { .subject-block {
font-weight: bold; font-weight: bold;
margin-bottom: var(--letter-subject-margin-bottom); margin-bottom: var(--letter-subject-margin-bottom);
@@ -223,6 +180,26 @@
margin: 0 0 var(--letter-body-paragraph-margin-bottom); margin: 0 0 var(--letter-body-paragraph-margin-bottom);
} }
.letter-info-table {
width: 80%;
/* max-width: 50%; */
margin: 0 auto var(--letter-body-paragraph-margin-bottom);
border-collapse: collapse;
line-height: 1;
}
.letter-info-table th,
.letter-info-table td {
border: 1px solid var(--letter-color-black);
padding: 4px 8px;
vertical-align: top;
text-align: left;
}
.letter-info-table th {
font-weight: bold;
}
.signature-block { .signature-block {
padding-left: var(--letter-content-padding-x); padding-left: var(--letter-content-padding-x);
padding-right: var(--letter-content-padding-x); padding-right: var(--letter-content-padding-x);
@@ -239,6 +216,12 @@
height: var(--letter-signature-space-height); height: var(--letter-signature-space-height);
} }
.signature-space-img {
height: var(--letter-signature-space-height);
width: auto;
display: block;
}
.signatory-name { .signatory-name {
font-weight: bold; font-weight: bold;
text-transform: uppercase; text-transform: uppercase;
@@ -261,43 +244,18 @@
table-layout: fixed; table-layout: fixed;
} }
.letter-footer-contact-box { .letter-footer-divider {
background-color: var(--letter-color-orange); border-top: 1px solid var(--letter-color-black);
padding: 8px 12px; font-size: 0;
line-height: 0;
padding: 0;
}
.letter-footer-nota {
padding-top: 8px;
font-size: var(--letter-footer-font-size); font-size: var(--letter-footer-font-size);
line-height: var(--letter-footer-line-height); line-height: var(--letter-footer-line-height);
color: var(--letter-color-white); color: var(--letter-body-color);
vertical-align: middle;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.letter-footer-address-inner {
background-color: var(--letter-color-blue);
color: var(--letter-color-white);
padding: 8px 12px;
font-size: var(--letter-footer-font-size);
line-height: var(--letter-footer-line-height);
text-align: left;
vertical-align: middle;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.letter-footer-lines {
border-collapse: collapse;
}
.letter-footer-icon {
width: 18px;
padding: 2px 8px 2px 0;
vertical-align: middle;
}
.letter-footer-line-text {
padding: 2px 0;
color: var(--letter-color-white);
vertical-align: middle;
} }
@media print { @media print {
@@ -322,17 +280,22 @@
<div class="letter-content"> <div class="letter-content">
<div class="meta-table"> <div class="meta-table">
@if ($showReference && trim($__env->yieldContent('letter-reference')) !== '') @if (($showReference && trim($__env->yieldContent('letter-reference')) !== '') || ($showDate && $letterDate !== ''))
<table cellpadding="0" cellspacing="0"> <table cellpadding="0" cellspacing="0">
<tr> @if ($showReference && trim($__env->yieldContent('letter-reference')) !== '')
<td class="meta-label">Ruj.</td> <tr>
<td class="meta-value">: @yield('letter-reference')</td> <td class="meta-label">RUJ. KAMI</td>
</tr> <td class="meta-value">: @yield('letter-reference')</td>
</tr>
@endif
@if ($showDate && $letterDate !== '')
<tr>
<td class="meta-label">TARIKH</td>
<td class="meta-value">: {{ $letterDate }}</td>
</tr>
@endif
</table> </table>
@endif @endif
@if ($showDate && $letterDate !== '')
<div class="meta-date">{{ $letterDate }}</div>
@endif
</div> </div>
@hasSection('letter-recipient') @hasSection('letter-recipient')
@@ -341,6 +304,10 @@
</div> </div>
@endif @endif
@hasSection('letter-honorific')
<div class="letter-honorific">@yield('letter-honorific')</div>
@endif
@if ($showSubject && trim($__env->yieldContent('letter-subject')) !== '') @if ($showSubject && trim($__env->yieldContent('letter-subject')) !== '')
<div class="subject-block"> <div class="subject-block">
@yield('letter-subject') @yield('letter-subject')
@@ -1,38 +1,11 @@
<table class="letter-footer" width="100%" cellpadding="0" cellspacing="0" border="0"> <table class="letter-footer" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr> <tr>
<td class="letter-footer-contact-box" valign="middle" width="50%" bgcolor="#d35400"> <td class="letter-footer-divider">&nbsp;</td>
<table class="letter-footer-lines" width="100%" cellpadding="0" cellspacing="0" border="0"> </tr>
<tr> <tr>
<td class="letter-footer-icon" valign="middle">@include('pdf.partials.footer-icon', ['icon' => 'phone'])</td> <td class="letter-footer-nota">
<td class="letter-footer-line-text" valign="middle">Tel. Pejabat: 09-741 4331</td> <strong>Nota:</strong><br>
</tr> <em>Surat ini dijana secara komputer dan merupakan dokumen rasmi Koperasi Permodalan Kelantan Berhad (KoPKB). Oleh itu, surat ini adalah sah tanpa tandatangan.</em>
<tr>
<td class="letter-footer-icon" valign="middle">@include('pdf.partials.footer-icon', ['icon' => 'fax'])</td>
<td class="letter-footer-line-text" valign="middle">Faks: 09-741 4330</td>
</tr>
<tr>
<td class="letter-footer-icon" valign="middle">@include('pdf.partials.footer-icon', ['icon' => 'email'])</td>
<td class="letter-footer-line-text" valign="middle">E-mel: admin@koppkb.com</td>
</tr>
<tr>
<td class="letter-footer-icon" valign="middle">@include('pdf.partials.footer-icon', ['icon' => 'web'])</td>
<td class="letter-footer-line-text" valign="middle">Laman Web: www.koppkb.com</td>
</tr>
</table>
</td>
<td class="letter-footer-address-inner" valign="middle" width="50%" bgcolor="#0e579b">
<table class="letter-footer-lines" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td class="letter-footer-icon" rowspan="3" valign="middle">@include('pdf.partials.footer-icon', ['icon' => 'location'])</td>
<td class="letter-footer-line-text" valign="middle">Lot PT 448. Tingkat 1, Jalan Kuala Krai</td>
</tr>
<tr>
<td class="letter-footer-line-text" valign="middle">Batu 3 Wakaf Che Yeh</td>
</tr>
<tr>
<td class="letter-footer-line-text" valign="middle">15150 Kota Bharu, Kelantan Darul Naim</td>
</tr>
</table>
</td> </td>
</tr> </tr>
</table> </table>
@@ -1,28 +1,15 @@
<table class="letterhead" width="100%" cellpadding="0" cellspacing="0"> <table class="letterhead" width="100%" cellpadding="0" cellspacing="0">
<tr> <tr>
<td class="letterhead-line" valign="middle"> <td class="letterhead-center" align="center" valign="top">
<table class="letterhead-rule" width="100%" cellpadding="0" cellspacing="0"> @if (! empty($logoUrl))
<tr> <img src="{{ $logoUrl }}" alt="Koperasi Permodalan Kelantan Berhad" class="letterhead-logo-img">
<td class="letterhead-rule-blue" width="50%" height="6" bgcolor="#0e579b">&nbsp;</td> @endif
<td class="letterhead-rule-orange" width="50%" height="6" bgcolor="#d35400">&nbsp;</td> <div class="letterhead-address">
</tr> Lot Pt 448, Tingkat 1, Jalan Kuala Krai, Batu 3, Wakaf Che Yeh, 15150 Kota Bharu, Kelantan.
</table> </div>
</td> <div class="letterhead-contact">
<td class="letterhead-brand" valign="middle" align="right" width="1"> Tel: 09-7414331 | Fax: 09-7414330 | Web:www.koppkb.com
<table class="letterhead-brand-inner" cellpadding="0" cellspacing="0" align="right"> </div>
<tr>
@if (! empty($logoUrl))
<td class="letterhead-logo" valign="middle">
<img src="{{ $logoUrl }}" alt="Koperasi Permodalan Kelantan Berhad" class="letterhead-logo-img">
</td>
@endif
<td class="letterhead-name" valign="middle">
<div class="letterhead-name-line">KOPERASI PERMODALAN</div>
<div class="letterhead-name-line">KELANTAN BERHAD</div>
<div class="letterhead-registration">D-5-0228</div>
</td>
</tr>
</table>
</td> </td>
</tr> </tr>
</table> </table>
@@ -13,8 +13,12 @@
43000 Kajang, Selangor 43000 Kajang, Selangor
@endsection @endsection
@section('letter-honorific')
Tuan/Puan,
@endsection
@section('letter-subject') @section('letter-subject')
<strong>KELULUSAN KEANGGOTAAN KOPERASI PERMODALAN KELANTAN BERHAD</strong> <strong>KELULUSAN PERMOHONAN MENJADI ANGGOTA KOPERASI PERMODALAN KELANTAN BERHAD (KoPKB)</strong>
@endsection @endsection
{{-- @section('letter-salutation') {{-- @section('letter-salutation')
@@ -23,38 +27,57 @@
@section('letter-body') @section('letter-body')
<p style="line-height: 1;"> <p style="line-height: 1;">
Dengan segala hormatnya saya merujuk kepada perkara di atas. Dengan segala hormatnya perkara di atas adalah dirujuk.
</p> </p>
<p style="line-height: 1;"> <p style="line-height: 1;">
2. Sukacita dimaklumkan bahawa, permohonan tuan untuk menjadi anggota Koperasi 2. Sukacita dimaklumkan bahawa, permohonan tuan/puan untuk menjadi <strong>
Permodalan Kelantan Berhad(KoPKB) telah <strong>{{ membership_applications.board_result }}</strong> dalam {{ User manual fills before generate }} Anggota Koperasi Permodalan Kelantan Berhad(KoPKB)</strong> telah dipertimbangkan dan <strong>DILULUSKAN</strong>
oleh Lembaga Koperasi dalam Mesyuarat Lembaga Bil. yang diadakan pada 01 Jan 2026.
</p> </p>
<p style="line-height: 1;"> <p style="line-height: 1;">
3. Pihak Koperasi akan membuat potongan gaji melalui majikan tuan sebagaiman ketetapan berikut:- 3. Maklumat keanggotaan tuan/puan adalah seperti berikut:-
<ul style="line-height: 1;"> </p>
<li>Saham</li> <table class="letter-info-table" cellpadding="0" cellspacing="0">
<ul style="line-height: 1;"> <tr>
<li>Potongan sebanyak RM 84.00 {{(6 months auto calculate)}}</li> <th>Perkara</th>
<li>Potongan sebanyak RM {{ membership_application_applicants.stock_monthly_contribution }} sehingga Syer Maksima</li> <th>Maklumat</th>
</ul> </tr>
<li>Yuran</li> <tr>
<ul style="line-height: 1;"> <td>Nama Anggota</td>
<li>Potongan sebanyak RM {{ membership_application_applicants.fee_monthly_contribution }} setiap bulan</li> <td>Ali</td>
</ul> </tr>
<li>Fi Masuk</li> <tr>
<ul style="line-height: 1;"> <td>No. Kad Pengenalan</td>
<li>Potongan sebanyak RM 10.00 (Pembayaran Pertama)</li> <td>0123456789012345</td>
</ul> </tr>
</ul> <tr>
<td>No. Anggota</td>
<td>123</td>
</tr>
<tr>
<td>Tarikh Kuat Kuasa Keanggotaan</td>
<td>01 Jan</td>
</tr>
<tr>
<td>Modal Syer Permulaan</td>
<td>RM500.00 (perlu dijelaskan dalam tempoh 6 bulan)</td>
</tr>
<tr>
<td>Fi Masuk</td>
<td>RM10.00</td>
</tr>
<tr>
<td>Yuran Tahunan</td>
<td>RM30.00 sebulan</td>
</tr>
</table>
<p style="line-height: 1;">
4. Sebagai anggota KoPKB, tuan/puan adalah tertakluk kepada Undang-Undang Kecil (UUK), dasar, polisi dan peraturan-peraturan KoPKB yang berkuat kuasa dari semasa ke semasa.
</p> </p>
<p style="line-height: 1;"> <p style="line-height: 1;">
4. Untuk makluman tuan, kelulusan keanggotaan ini adalah bersyarat di mana tuan dikehendaki 5. Sebagai anggota, tuan/puan adalah layak menikmati kemudahan, produk dan manfaat yang ditawarkan oleh koperasi dari semasa ke semasa, tertakluk kepada syarat-syarat yang ditetapkan. </p>
menjelaskan modal syer minimum sebanyak RM500.00. Bayaran tersebut <p style="line-height: 1;">
perlu dibuat secara ansuran melalui potongan gaji dalam tempoh enam (6) bulan. 6. Pihak KoPKB mengucapkan tahniah dan mengalu-alukan penyertaan tuan/puan sebagai anggota. Semoga keanggotaan ini akan memberi manfaat kepada tuan/puan serta menyumbang kepada kemajuan dan kecemerlangan KoPKB.
Pemenuhan syarat ini adalah bagi melayakkan tuan memperoleh hak, kewajipan dan
liabiliti sebagai anggota KoPKB sebagaimana terkandung di dalam Undang-Undang Kecil (UUK),
Perkara 59 yang telah didaftarkan pada 11 September 2024. Manakala had maksima modal syer adalah
sebanyak RM5,000.00.
</p> </p>
@endsection @endsection
@@ -63,10 +86,5 @@
@endsection @endsection
@section('letter-signature') @section('letter-signature')
<div class="signature-header">Dengan hormatnya,</div>
<div class="signature-company">KOPERASI PERMODALAN KELANTAN BERHAD</div> <div class="signature-company">KOPERASI PERMODALAN KELANTAN BERHAD</div>
<div class="signature-space"></div>
<div class="signatory-name">MUHAMMAD NAFIS BIN ZAINUDDIN</div>
<div class="signatory-title">Pengurus Besar</div>
<div class="signatory-title">b/p Setiausaha</div>
@endsection @endsection
-43
View File
@@ -1,43 +0,0 @@
@extends('pdf.layouts.letter')
@section('letter-title', 'Pratonton Surat')
@section('letter-reference', 'APP-2026-00001')
@section('letter-date', now()->translatedFormat('d F Y'))
@section('letter-recipient')
<strong>Ahmad bin Abdullah</strong><br>
No. 12, Jalan Contoh,<br>
43000 Kajang, Selangor
@endsection
@section('letter-subject')
<strong>PERKARA: PRATONTON REKA BENTUK SURAT RASMI</strong>
@endsection
@section('letter-salutation')
Assalamualaikum dan Salam Sejahtera,
@endsection
@section('letter-body')
<p>
Dengan hormatnya perkara di atas adalah dirujuk.
</p>
<p>
1. Ini adalah kandungan contoh untuk pratonton reka bentuk surat. Gunakan halaman ini
untuk menguji susun atur, fon, jarak, dan elemen letterhead sebelum menghasilkan PDF sebenar.
</p>
<p>
2. Teks ini boleh diganti apabila templat surat muktamad disediakan.
</p>
@endsection
@section('letter-closing')
Sekian, terima kasih.
@endsection
@section('letter-signature')
<div class="signatory-name">NAMA PENGARAH</div>
<div class="signatory-title">Jawatan / {{ config('app.name') }}</div>
@endsection
-3
View File
@@ -7,8 +7,5 @@ Route::get('/', function () {
return redirect('/login'); return redirect('/login');
}); });
Route::get('/letters/preview', [LetterPreviewController::class, 'show'])
->name('letters.preview');
Route::get('/letters/preview-approved-membership', [LetterPreviewController::class, 'showMembershipApprovedLetter']) Route::get('/letters/preview-approved-membership', [LetterPreviewController::class, 'showMembershipApprovedLetter'])
->name('letters.preview-membership-approved-membership'); ->name('letters.preview-membership-approved-membership');
+1 -1
View File
@@ -10,7 +10,7 @@ COPY fe/ ./
ARG VITE_APP_NAME="MyKOPKB" ARG VITE_APP_NAME="MyKOPKB"
ARG VITE_APP_VERSION="1.0" ARG VITE_APP_VERSION="1.0"
ARG VITE_API_BASE_URL=https://api.koppkb.com ARG VITE_API_BASE_URL=https://api.koppkb.com
ARG VITE_APP_URL=https://anggota.koppkb.com ARG VITE_APP_URL=https://mykopkb.com
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
ENV VITE_APP_URL=$VITE_APP_URL ENV VITE_APP_URL=$VITE_APP_URL
+2
View File
@@ -57,6 +57,7 @@
"lodash": "^4.17.21", "lodash": "^4.17.21",
"maplibre-gl": "^5.18.0", "maplibre-gl": "^5.18.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"qrcode": "^1.5.4",
"sweetalert2": "^11.26.25", "sweetalert2": "^11.26.25",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
@@ -71,6 +72,7 @@
"@types/jsdom": "^27.0.0", "@types/jsdom": "^27.0.0",
"@types/lodash": "^4.17.21", "@types/lodash": "^4.17.21",
"@types/node": "^24.10.4", "@types/node": "^24.10.4",
"@types/qrcode": "^1.5.6",
"@vitejs/plugin-vue": "^6.0.3", "@vitejs/plugin-vue": "^6.0.3",
"@vitest/eslint-plugin": "^1.6.4", "@vitest/eslint-plugin": "^1.6.4",
"@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-prettier": "^10.2.0",
+164
View File
@@ -119,6 +119,9 @@ importers:
pinia: pinia:
specifier: ^3.0.4 specifier: ^3.0.4
version: 3.0.4(typescript@5.9.3)(vue@3.5.35(typescript@5.9.3)) version: 3.0.4(typescript@5.9.3)(vue@3.5.35(typescript@5.9.3))
qrcode:
specifier: ^1.5.4
version: 1.5.4
sweetalert2: sweetalert2:
specifier: ^11.26.25 specifier: ^11.26.25
version: 11.26.25 version: 11.26.25
@@ -156,6 +159,9 @@ importers:
'@types/node': '@types/node':
specifier: ^24.10.4 specifier: ^24.10.4
version: 24.12.4 version: 24.12.4
'@types/qrcode':
specifier: ^1.5.6
version: 1.5.6
'@vitejs/plugin-vue': '@vitejs/plugin-vue':
specifier: ^6.0.3 specifier: ^6.0.3
version: 6.0.7(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(lightningcss@1.32.0))(vue@3.5.35(typescript@5.9.3)) version: 6.0.7(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(lightningcss@1.32.0))(vue@3.5.35(typescript@5.9.3))
@@ -1041,6 +1047,9 @@ packages:
'@types/pako@2.0.4': '@types/pako@2.0.4':
resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==} resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==}
'@types/qrcode@1.5.6':
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
'@types/raf@3.4.3': '@types/raf@3.4.3':
resolution: {integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==} resolution: {integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==}
@@ -1588,6 +1597,10 @@ packages:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
camelcase@5.3.1:
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
engines: {node: '>=6'}
caniuse-lite@1.0.30001793: caniuse-lite@1.0.30001793:
resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==}
@@ -1637,6 +1650,9 @@ packages:
resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==} resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==}
engines: {node: '>=20'} engines: {node: '>=20'}
cliui@6.0.0:
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
clsx@2.1.1: clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -1758,6 +1774,10 @@ packages:
supports-color: supports-color:
optional: true optional: true
decamelize@1.2.0:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
decimal.js@10.6.0: decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
@@ -1784,6 +1804,9 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
dijkstrajs@1.0.3:
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
dompurify@3.4.7: dompurify@3.4.7:
resolution: {integrity: sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==} resolution: {integrity: sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==}
@@ -2055,6 +2078,10 @@ packages:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'} engines: {node: '>=8'}
find-up@4.1.0:
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
engines: {node: '>=8'}
find-up@5.0.0: find-up@5.0.0:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -2109,6 +2136,10 @@ packages:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
get-east-asian-width@1.6.0: get-east-asian-width@1.6.0:
resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -2501,6 +2532,10 @@ packages:
resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==} resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
locate-path@5.0.0:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
locate-path@6.0.0: locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -2679,14 +2714,26 @@ packages:
ospath@1.2.2: ospath@1.2.2:
resolution: {integrity: sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA==} resolution: {integrity: sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA==}
p-limit@2.3.0:
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
engines: {node: '>=6'}
p-limit@3.1.0: p-limit@3.1.0:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
p-locate@4.1.0:
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
engines: {node: '>=8'}
p-locate@5.0.0: p-locate@5.0.0:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'} engines: {node: '>=10'}
p-try@2.2.0:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
package-json-from-dist@1.0.1: package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
@@ -2769,6 +2816,10 @@ packages:
typescript: typescript:
optional: true optional: true
pngjs@5.0.0:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
postcss-selector-parser@7.1.1: postcss-selector-parser@7.1.1:
resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -2833,6 +2884,11 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'} engines: {node: '>=6'}
qrcode@1.5.4:
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
engines: {node: '>=10.13.0'}
hasBin: true
qs@6.15.2: qs@6.15.2:
resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==}
engines: {node: '>=0.6'} engines: {node: '>=0.6'}
@@ -2856,10 +2912,17 @@ packages:
request-progress@3.0.0: request-progress@3.0.0:
resolution: {integrity: sha512-MnWzEHHaxHO2iWiQuHrUPBi/1WeBf5PkxQqNyNvLl9VAYSdXkP8tQ3pBSeCPD+yw0v0Aq1zosWLz0BdeXpWwZg==} resolution: {integrity: sha512-MnWzEHHaxHO2iWiQuHrUPBi/1WeBf5PkxQqNyNvLl9VAYSdXkP8tQ3pBSeCPD+yw0v0Aq1zosWLz0BdeXpWwZg==}
require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
require-from-string@2.0.2: require-from-string@2.0.2:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
require-main-filename@2.0.0:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
resolve-from@4.0.0: resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -2916,6 +2979,9 @@ packages:
engines: {node: '>=10'} engines: {node: '>=10'}
hasBin: true hasBin: true
set-blocking@2.0.0:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
shebang-command@2.0.0: shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -3393,6 +3459,9 @@ packages:
resolution: {integrity: sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==} resolution: {integrity: sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==}
engines: {node: '>=20'} engines: {node: '>=20'}
which-module@2.0.1:
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
which@2.0.2: which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -3420,6 +3489,10 @@ packages:
resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==} resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==}
engines: {node: '>=0.8'} engines: {node: '>=0.8'}
wrap-ansi@6.2.0:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
wrap-ansi@7.0.0: wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -3467,9 +3540,20 @@ packages:
xmlchars@2.2.0: xmlchars@2.2.0:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
y18n@4.0.3:
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
yallist@3.1.1: yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
yargs-parser@18.1.3:
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
engines: {node: '>=6'}
yargs@15.4.1:
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
engines: {node: '>=8'}
yauzl@3.3.2: yauzl@3.3.2:
resolution: {integrity: sha512-Md9ankxxN23wncAN8s7+Tn3Co52zLUPMtnrLAbVCnfG5d2tKBFfmygYSgXlqFgXObtzIgqkx7aNgDBpso9+4qA==} resolution: {integrity: sha512-Md9ankxxN23wncAN8s7+Tn3Co52zLUPMtnrLAbVCnfG5d2tKBFfmygYSgXlqFgXObtzIgqkx7aNgDBpso9+4qA==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -4215,6 +4299,10 @@ snapshots:
'@types/pako@2.0.4': {} '@types/pako@2.0.4': {}
'@types/qrcode@1.5.6':
dependencies:
'@types/node': 24.12.4
'@types/raf@3.4.3': '@types/raf@3.4.3':
optional: true optional: true
@@ -4978,6 +5066,8 @@ snapshots:
callsites@3.1.0: {} callsites@3.1.0: {}
camelcase@5.3.1: {}
caniuse-lite@1.0.30001793: {} caniuse-lite@1.0.30001793: {}
canvg@3.0.11: canvg@3.0.11:
@@ -5033,6 +5123,12 @@ snapshots:
slice-ansi: 8.0.0 slice-ansi: 8.0.0
string-width: 8.2.1 string-width: 8.2.1
cliui@6.0.0:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 6.2.0
clsx@2.1.1: {} clsx@2.1.1: {}
codepage@1.15.0: {} codepage@1.15.0: {}
@@ -5172,6 +5268,8 @@ snapshots:
optionalDependencies: optionalDependencies:
supports-color: 8.1.1 supports-color: 8.1.1
decamelize@1.2.0: {}
decimal.js@10.6.0: {} decimal.js@10.6.0: {}
deep-is@0.1.4: {} deep-is@0.1.4: {}
@@ -5189,6 +5287,8 @@ snapshots:
detect-libc@2.1.2: {} detect-libc@2.1.2: {}
dijkstrajs@1.0.3: {}
dompurify@3.4.7: dompurify@3.4.7:
optionalDependencies: optionalDependencies:
'@types/trusted-types': 2.0.7 '@types/trusted-types': 2.0.7
@@ -5505,6 +5605,11 @@ snapshots:
dependencies: dependencies:
to-regex-range: 5.0.1 to-regex-range: 5.0.1
find-up@4.1.0:
dependencies:
locate-path: 5.0.0
path-exists: 4.0.0
find-up@5.0.0: find-up@5.0.0:
dependencies: dependencies:
locate-path: 6.0.0 locate-path: 6.0.0
@@ -5554,6 +5659,8 @@ snapshots:
gensync@1.0.0-beta.2: {} gensync@1.0.0-beta.2: {}
get-caller-file@2.0.5: {}
get-east-asian-width@1.6.0: {} get-east-asian-width@1.6.0: {}
get-intrinsic@1.3.0: get-intrinsic@1.3.0:
@@ -5928,6 +6035,10 @@ snapshots:
rfdc: 1.4.1 rfdc: 1.4.1
wrap-ansi: 9.0.2 wrap-ansi: 9.0.2
locate-path@5.0.0:
dependencies:
p-locate: 4.1.0
locate-path@6.0.0: locate-path@6.0.0:
dependencies: dependencies:
p-locate: 5.0.0 p-locate: 5.0.0
@@ -6107,14 +6218,24 @@ snapshots:
ospath@1.2.2: {} ospath@1.2.2: {}
p-limit@2.3.0:
dependencies:
p-try: 2.2.0
p-limit@3.1.0: p-limit@3.1.0:
dependencies: dependencies:
yocto-queue: 0.1.0 yocto-queue: 0.1.0
p-locate@4.1.0:
dependencies:
p-limit: 2.3.0
p-locate@5.0.0: p-locate@5.0.0:
dependencies: dependencies:
p-limit: 3.1.0 p-limit: 3.1.0
p-try@2.2.0: {}
package-json-from-dist@1.0.1: {} package-json-from-dist@1.0.1: {}
pako@2.1.0: {} pako@2.1.0: {}
@@ -6177,6 +6298,8 @@ snapshots:
optionalDependencies: optionalDependencies:
typescript: 5.9.3 typescript: 5.9.3
pngjs@5.0.0: {}
postcss-selector-parser@7.1.1: postcss-selector-parser@7.1.1:
dependencies: dependencies:
cssesc: 3.0.0 cssesc: 3.0.0
@@ -6225,6 +6348,12 @@ snapshots:
punycode@2.3.1: {} punycode@2.3.1: {}
qrcode@1.5.4:
dependencies:
dijkstrajs: 1.0.3
pngjs: 5.0.0
yargs: 15.4.1
qs@6.15.2: qs@6.15.2:
dependencies: dependencies:
side-channel: 1.1.0 side-channel: 1.1.0
@@ -6250,8 +6379,12 @@ snapshots:
dependencies: dependencies:
throttleit: 1.0.1 throttleit: 1.0.1
require-directory@2.1.1: {}
require-from-string@2.0.2: {} require-from-string@2.0.2: {}
require-main-filename@2.0.0: {}
resolve-from@4.0.0: {} resolve-from@4.0.0: {}
resolve-protobuf-schema@2.1.0: resolve-protobuf-schema@2.1.0:
@@ -6323,6 +6456,8 @@ snapshots:
semver@7.8.1: {} semver@7.8.1: {}
set-blocking@2.0.0: {}
shebang-command@2.0.0: shebang-command@2.0.0:
dependencies: dependencies:
shebang-regex: 3.0.0 shebang-regex: 3.0.0
@@ -6781,6 +6916,8 @@ snapshots:
tr46: 6.0.0 tr46: 6.0.0
webidl-conversions: 8.0.1 webidl-conversions: 8.0.1
which-module@2.0.1: {}
which@2.0.2: which@2.0.2:
dependencies: dependencies:
isexe: 2.0.0 isexe: 2.0.0
@@ -6800,6 +6937,12 @@ snapshots:
word@0.3.0: {} word@0.3.0: {}
wrap-ansi@6.2.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi@7.0.0: wrap-ansi@7.0.0:
dependencies: dependencies:
ansi-styles: 4.3.0 ansi-styles: 4.3.0
@@ -6843,8 +6986,29 @@ snapshots:
xmlchars@2.2.0: {} xmlchars@2.2.0: {}
y18n@4.0.3: {}
yallist@3.1.1: {} yallist@3.1.1: {}
yargs-parser@18.1.3:
dependencies:
camelcase: 5.3.1
decamelize: 1.2.0
yargs@15.4.1:
dependencies:
cliui: 6.0.0
decamelize: 1.2.0
find-up: 4.1.0
get-caller-file: 2.0.5
require-directory: 2.1.1
require-main-filename: 2.0.0
set-blocking: 2.0.0
string-width: 4.2.3
which-module: 2.0.1
y18n: 4.0.3
yargs-parser: 18.1.3
yauzl@3.3.2: yauzl@3.3.2:
dependencies: dependencies:
pend: 1.2.0 pend: 1.2.0
BIN
View File
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Some files were not shown because too many files have changed in this diff Show More