8 Commits

Author SHA1 Message Date
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
ismailmasseran e77712105c DONE: add the default logo back because the build failed (#3)
Build Docker Image / build-backend (push) Successful in 8s
Build Docker Image / build-frontend (push) Successful in 32s
Co-authored-by: ISMAIL MASSERAN <topaz@ISMAILs-Macbook.local>
Reviewed-on: #3
2026-07-02 10:14:20 +08:00
ismailmasseran 421775d3ff Dev/v1.1 (#2)
Build Docker Image / build-backend (push) Successful in 46s
Build Docker Image / build-frontend (push) Failing after 11s
Co-authored-by: ISMAIL MASSERAN <topaz@ISMAILs-Macbook.local>
Reviewed-on: #2
2026-07-02 10:09:08 +08:00
267 changed files with 6516 additions and 27787 deletions
+4 -5
View File
@@ -1,12 +1,11 @@
name: Build Docker Image
# Builds images on every push to main or tag, but does NOT auto-deploy
on:
push:
branches:
- main
# branches:
# - main
tags:
- "v*" # v1.0.0, v1.0.1, etc.
- "v*"
jobs:
# test-backend:
@@ -92,6 +91,6 @@ jobs:
--cache-to type=registry,ref=${IMAGE}:buildcache,mode=max \
-f fe/docker/Dockerfile \
--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} \
--push .
+4 -1
View File
@@ -76,4 +76,7 @@ SELECT setval('public.permissions_id_seq', COALESCE((SELECT MAX(id) FROM permiss
## DO NOT RUN THIS BECAUSE IT WILL REMOVE ALL THE UNTAGGED FILES IN LOCAL
```bash
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
+15 -1
View File
@@ -15,4 +15,18 @@
[ ] boleh print semua borang
[ ] jana surat lepas lulus anggota
##
## Present to Boss (2/7/2026)
[x] discuss logo baru MyKOPKB
[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
+4 -1
View File
@@ -95,4 +95,7 @@ API_ALLOWED_USER_AGENTS=
ENABLE_API_KEY_AUTH=true
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
+5 -3
View File
@@ -67,11 +67,13 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
# Browsershot (PDF generation via headless Chrome + Puppeteer in be/)
# BROWSERSHOT_NODE_BINARY=
# 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_NODE_BINARY=/usr/local/bin/node
# BROWSERSHOT_NPM_BINARY=
# BROWSERSHOT_NODE_MODULE_PATH=
# BROWSERSHOT_NODE_MODULE_PATH=/var/www
# BROWSERSHOT_CHROME_PATH=
# BROWSERSHOT_NO_SANDBOX=false
# BROWSERSHOT_TIMEOUT=60
# HttpOnly Sanctum token cookie (set on api.* domain)
+5 -1
View File
@@ -102,4 +102,8 @@ API_ALLOWED_USER_AGENTS=
ENABLE_API_KEY_AUTH=true
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;
use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Request;
use Spatie\Activitylog\Models\Activity;
use App\Services\ActivityLogger;
class ActivityLogController extends Controller
{
use AuthorizesRequests;
/**
* Display a listing of the resource.
*/
public function index(Request $request)
{
$this->authorize('viewAny', Activity::class);
// ActivityLogger::logView(new Activity, 'Viewed activity logs');
$perPage = $request->get('per_page', 10);
@@ -27,8 +32,10 @@ class ActivityLogController extends Controller
$q->where('description', 'ILIKE', "%{$search}%")
->orWhere('subject_type', 'ILIKE', "%{$search}%")
->orWhere('event', 'ILIKE', "%{$search}%")
->orWhere('causer.name', 'ILIKE', "%{$search}%")
->orWhere('causer.email', 'ILIKE', "%{$search}%");
->orWhereHas('causer', function ($causerQuery) use ($search) {
$causerQuery->where('name', 'ILIKE', "%{$search}%")
->orWhere('email', 'ILIKE', "%{$search}%");
});
});
}
@@ -3,7 +3,10 @@
namespace Modules\ActivityLog\Providers;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;
use Modules\ActivityLog\Policies\ActivityLogPolicy;
use Spatie\Activitylog\Models\Activity;
use Nwidart\Modules\Traits\PathNamespace;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
@@ -21,6 +24,8 @@ class ActivityLogServiceProvider extends ServiceProvider
*/
public function boot(): void
{
Gate::policy(Activity::class, ActivityLogPolicy::class);
$this->registerCommands();
$this->registerCommandSchedules();
$this->registerTranslations();
+52
View File
@@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Str;
use Laravel\Sanctum\HasApiTokens;
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity;
@@ -51,9 +52,11 @@ class User extends Authenticatable
'marriage_status',
'member_number',
'member_type',
'public_profile_token',
'join_date',
'birth_date',
'birth_place',
'onboarding_completed_at',
];
/**
@@ -67,8 +70,55 @@ class User extends Authenticatable
'two_factor_secret',
'two_factor_recovery_codes',
'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.
*
@@ -84,9 +134,11 @@ class User extends Authenticatable
'marriage_status' => 'string',
'member_number' => 'integer',
'member_type' => 'string',
'public_profile_token_expires_at' => 'datetime',
'join_date' => 'date',
'birth_date' => 'date',
'birth_place' => 'string',
'onboarding_completed_at' => 'datetime',
];
}
@@ -24,6 +24,7 @@ class SessionController extends Controller
], 401);
}
$user->ensurePublicProfileToken();
$user->load(['roles.permissions']);
return response()->json([
@@ -12,6 +12,8 @@ class AuthSessionService
{
public function createAuthResponse(User $user, string $message, int $status = 200): JsonResponse
{
$user->ensurePublicProfileToken();
if (config('app.env') !== 'local') {
$user->tokens()->delete();
}
@@ -36,9 +36,11 @@ class UserResource extends JsonResource
'marriage_status' => $this->marriage_status,
'member_number' => $this->member_number,
'member_type' => $this->member_type,
'public_profile_token' => $this->public_profile_token,
'join_date' => $this->join_date,
'birth_date' => $this->birth_date,
'birth_place' => $this->birth_place,
'onboarding_completed_at' => $this->onboarding_completed_at,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
'deleted_at' => $this->deleted_at,
@@ -2,19 +2,28 @@
namespace Modules\MembershipApplication\Emails;
use App\Models\Document;
use App\Notifications\Concerns\BuildsMailMessage;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Facades\Storage;
use Modules\MembershipApplication\Entities\MembershipApplication;
class MembershipApplicationFailedNotification extends Notification
class MembershipApplicationFailedNotification extends Notification implements ShouldQueue
{
use BuildsMailMessage;
use Queueable;
public function __construct(
protected MembershipApplication $application
public MembershipApplication $application,
public Document $resultLetter,
) {}
/**
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return ['mail'];
@@ -23,13 +32,21 @@ class MembershipApplicationFailedNotification extends Notification
public function toMail(object $notifiable): MailMessage
{
$this->application->loadMissing('applicant');
$applicant = $this->application->applicant;
return (new MailMessage)
->subject('Keputusan Permohonan Keahlian')
->markdown('membershipapplication::emails.failed', [
'name' => $this->application->applicant->name,
'applicationNumber' => $this->application->application_number,
'logoPath' => public_path('images/logo-kopkb.svg'),
]);
return $this->mailMessage(
subject: 'Keputusan Permohonan Keahlian KOPKB - TIDAK LULUS',
view: 'membershipapplication::emails.failed',
data: [
'application' => $this->application,
'applicant' => $applicant,
],
)->attach(
Storage::disk(Document::STORAGE_DISK)->path($this->resultLetter->path),
[
'as' => $this->resultLetter->name,
'mime' => $this->resultLetter->mime_type,
]
);
}
}
@@ -2,19 +2,28 @@
namespace Modules\MembershipApplication\Emails;
use App\Models\Document;
use App\Notifications\Concerns\BuildsMailMessage;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Facades\Storage;
use Modules\MembershipApplication\Entities\MembershipApplication;
class MembershipApplicationPassedNotification extends Notification
class MembershipApplicationPassedNotification extends Notification implements ShouldQueue
{
use BuildsMailMessage;
use Queueable;
public function __construct(
protected MembershipApplication $application
public MembershipApplication $application,
public Document $resultLetter,
) {}
/**
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return ['mail'];
@@ -23,13 +32,21 @@ class MembershipApplicationPassedNotification extends Notification
public function toMail(object $notifiable): MailMessage
{
$this->application->loadMissing('applicant');
$applicant = $this->application->applicant;
return (new MailMessage)
->subject('Permohonan Keahlian Diluluskan')
->markdown('membershipapplication::emails.passed', [
'name' => $this->application->applicant->name,
'applicationNumber' => $this->application->application_number,
'logoPath' => public_path('images/logo-kopkb.svg'),
]);
return $this->mailMessage(
subject: 'Keputusan Permohonan Keahlian KOPKB - LULUS',
view: 'membershipapplication::emails.passed',
data: [
'application' => $this->application,
'applicant' => $applicant,
],
)->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;
use App\Notifications\Concerns\BuildsMailMessage;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
@@ -9,6 +10,7 @@ use Modules\MembershipApplication\Entities\MembershipApplication;
class MembershipApplicationSubmittedNotification extends Notification
{
use BuildsMailMessage;
use Queueable;
public function __construct(
@@ -24,9 +26,10 @@ class MembershipApplicationSubmittedNotification extends Notification
{
$this->application->loadMissing(['applicant', 'heirs', 'documents', 'references.member']);
return (new MailMessage)
->subject('Pengesahan Permohonan Keahlian')
->markdown('membershipapplication::emails.submitted', [
return $this->mailMessage(
subject: 'Pengesahan Permohonan Keahlian',
view: 'membershipapplication::emails.submitted',
data: [
'application' => $this->application,
'applicant' => $this->application->applicant,
'heirs' => $this->application->heirs,
@@ -37,7 +40,7 @@ class MembershipApplicationSubmittedNotification extends Notification
'salary_slip' => 'Slip Gaji',
'employer_letter' => 'Surat Pengesahan Majikan',
],
'logoPath' => public_path('images/logo-kopkb.svg'),
]);
],
);
}
}
@@ -2,11 +2,13 @@
namespace Modules\MembershipApplication\Entities;
use App\Models\Document;
use App\Traits\HasDocuments;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\MorphOne;
use Modules\Auth\Entities\User;
use Modules\MembershipApplication\Enums\ApplicationStatus;
@@ -14,6 +16,14 @@ class MembershipApplication extends Model
{
use HasDocuments, HasUuids;
public const RESULT_LETTER_DOCUMENT_TYPE = 'result_letter';
public const ADMIN_ATTACHMENT_DOCUMENT_TYPE = 'admin_attachment';
public const MINIMUM_SHARE_CAPITAL = 500;
public const SHARE_INSTALLMENT_MONTHS = 6;
protected $table = 'membership_applications';
/**
@@ -65,4 +75,10 @@ class MembershipApplication extends Model
{
return $this->belongsTo(User::class);
}
public function resultLetterDocument(): MorphOne
{
return $this->morphOne(Document::class, 'documentable')
->where('type', self::RESULT_LETTER_DOCUMENT_TYPE);
}
}
@@ -17,10 +17,13 @@ use Modules\MembershipApplication\Enums\ManagementDecision;
use Modules\MembershipApplication\Http\Requests\AssignReferencesRequest;
use Modules\MembershipApplication\Http\Requests\BatchCompleteRequest;
use Modules\MembershipApplication\Http\Requests\BoardReviewRequest;
use Modules\MembershipApplication\Http\Requests\CompleteMembershipApplicationRequest;
use Modules\MembershipApplication\Http\Requests\GenerateResultLetterRequest;
use Modules\MembershipApplication\Http\Requests\ManagementReviewRequest;
use Modules\MembershipApplication\Http\Requests\UpdateMembershipApplicationRequest;
use Modules\MembershipApplication\Http\Requests\UploadMembershipApplicationDocumentRequest;
use Modules\MembershipApplication\Services\MembershipApplicationService;
use Modules\MembershipApplication\Transformers\MembershipApplicationDocumentResource;
use Modules\MembershipApplication\Transformers\MembershipApplicationListResource;
use Modules\MembershipApplication\Transformers\MembershipApplicationResource;
use Symfony\Component\HttpFoundation\StreamedResponse;
@@ -178,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);
$application = $this->membershipApplicationService->complete(
$membershipApplication,
$request->user()
$request->user(),
$request->validated('board_meeting_reference'),
$request->validated('board_meeting_date'),
);
$message = $application->board_result === BoardResult::Pass->value
? 'Permohonan selesai. Akaun ahli telah dicipta dan e-mel dihantar.'
: 'Permohonan selesai. E-mel keputusan dihantar.';
? 'Permohonan selesai. Surat keputusan dijana, akaun ahli dicipta dan e-mel dihantar.'
: 'Permohonan selesai. Surat keputusan dijana dan e-mel dihantar.';
return response()->json([
'success' => true,
@@ -204,7 +209,9 @@ class MembershipApplicationController extends Controller
$result = $this->membershipApplicationService->completeBatch(
$request->validated('application_ids'),
$request->user()
$request->user(),
$request->validated('board_meeting_reference'),
$request->validated('board_meeting_date'),
);
$succeededCount = count($result['succeeded']);
@@ -243,6 +250,46 @@ class MembershipApplicationController extends Controller
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 {
$this->authorize('generateResultLetter', $membershipApplication);
$application = $this->membershipApplicationService->generateResultLetter(
$membershipApplication,
$request->validated('board_meeting_reference'),
$request->validated('board_meeting_date'),
);
$resultLetter = $application->documents
->firstWhere('type', MembershipApplication::RESULT_LETTER_DOCUMENT_TYPE);
return response()->json([
'success' => true,
'message' => 'Surat keputusan berjaya dijana.',
'data' => [
'application' => new MembershipApplicationResource($application),
'document' => $resultLetter
? new MembershipApplicationDocumentResource($resultLetter)
: null,
],
]);
}
public function approvedLetter(Request $request, MembershipApplication $membershipApplication): Response
{
$this->authorize('view', $membershipApplication);
@@ -253,10 +300,23 @@ class MembershipApplicationController extends Controller
abort(404, 'Permohonan tidak dijumpai.');
}
$boardMeetingReference = (string) $request->query(
'board_meeting_reference',
'Mesyuarat Lembaga',
);
$boardMeetingDate = (string) $request->query(
'board_meeting_date',
now()->toDateString(),
);
$view = 'membershipapplication::pdf.approved-letter';
$data = ['application' => $application];
$data = $this->membershipApplicationService->getResultLetterViewData(
$application,
$boardMeetingReference,
$boardMeetingDate,
);
$format = $request->query('format', 'html');
$filename = 'surat-kelulusan-'.$application->application_number.'.pdf';
$filename = 'surat-keputusan-'.$application->application_number.'.pdf';
if ($format === 'pdf') {
return $this->letterService->pdfResponse($view, $data, $filename);
@@ -19,6 +19,8 @@ class BatchCompleteRequest extends FormRequest
return [
'application_ids' => 'required|array|min:1|max:100',
'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.*.exists' => 'Salah satu permohonan tidak dijumpai.',
'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.',
];
}
}
@@ -0,0 +1,37 @@
<?php
namespace Modules\MembershipApplication\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class GenerateResultLetterRequest 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.',
];
}
}
@@ -48,7 +48,7 @@ class StoreMembershipApplicationRequest extends FormRequest
'documents.ic_copy' => 'required|file|mimes:pdf,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.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.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\Validation\Rule;
use Modules\MembershipApplication\Entities\MembershipApplication;
class UploadMembershipApplicationDocumentRequest extends FormRequest
{
@@ -18,7 +19,13 @@ class UploadMembershipApplicationDocumentRequest extends FormRequest
public function rules(): array
{
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' => [
'required',
'file',
@@ -65,6 +65,14 @@ class MembershipApplicationPolicy
return $user->hasPermissionTo('tetapkan pencadang penyokong');
}
/**
* Generate the result letter PDF for a completed application.
*/
public function generateResultLetter($user, ?MembershipApplication $membershipApplication = null): bool
{
return $user->hasPermissionTo('jana surat keputusan keahlian');
}
/**
* Admin: edit application details before completion.
*/
@@ -155,7 +155,7 @@ class MembershipApplicationServiceProvider extends ServiceProvider
public function registerViews(): void
{
$viewPath = resource_path('views/modules/'.$this->nameLower);
$sourcePath = module_path($this->name, 'Resources/Views');
$sourcePath = module_path($this->name, 'Resources/views');
$this->publishes([$sourcePath => $viewPath], ['views', $this->nameLower.'-module-views']);
@@ -23,6 +23,7 @@ class MembershipApplicationRepository implements MembershipApplicationRepository
return MembershipApplication::with([
'applicant',
'heirs',
'user:id,member_number',
'references.member:id,name,ic_number',
'documents',
'reviews.reviewer:id,name',
@@ -46,7 +47,14 @@ class MembershipApplicationRepository implements MembershipApplicationRepository
$sortOrder = strtolower($sortOrder) === 'asc' ? 'asc' : 'desc';
$query = MembershipApplication::query()
->with(['applicant:id,membership_application_id,name,email,ic_number'])
->with([
'applicant:id,membership_application_id,name,email,ic_number',
'resultLetterDocument:id,documentable_type,documentable_id,name,type,mime_type,file_size',
])
->withCount([
'documents as result_letter_count' => fn ($documentQuery) => $documentQuery
->where('type', MembershipApplication::RESULT_LETTER_DOCUMENT_TYPE),
])
->orderBy($sortBy, $sortOrder);
if ($status !== null && $status !== '') {
@@ -1,18 +1,15 @@
@component('mail::message')
@if (! empty($logoPath) && file_exists($logoPath))
<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
@include('emails.partials.header')
# 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,<br>
{{ config('app.name') }}
@include('emails.partials.footer')
@endcomponent
@@ -1,18 +1,15 @@
@component('mail::message')
@if (! empty($logoPath) && file_exists($logoPath))
<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
@include('emails.partials.header')
# 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.
Terima kasih atas minat anda. Untuk sebarang pertanyaan, sila hubungi pejabat koperasi.
Terima kasih,<br>
{{ config('app.name') }}
@include('emails.partials.footer')
@endcomponent
@@ -1,9 +1,5 @@
@component('mail::message')
@if (! empty($logoPath) && file_exists($logoPath))
<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
@include('emails.partials.header')
# Pengesahan Permohonan Keahlian
@@ -74,6 +70,5 @@ Tiada dokumen.
Anda akan menerima emel apabila keputusan permohonan tersedia.
Terima kasih,<br>
{{ config('app.name') }}
@include('emails.partials.footer')
@endcomponent
@@ -1,12 +1,96 @@
{{--
Approval letter extends the shared base layout.
Fill in sections below when the final design is ready.
--}}
@extends('pdf.layouts.letter')
{{-- @section('letter-reference', $application->application_number) --}}
{{-- @section('letter-date', ...) --}}
{{-- @section('letter-recipient') ... @endsection --}}
{{-- @section('letter-subject') PERKARA: ... @endsection --}}
{{-- @section('letter-body') ... @endsection --}}
{{-- @section('letter-signature') ... @endsection --}}
@section('letter-title', 'Surat')
@section('letter-reference', $application->application_number)
@section('letter-date', now()->translatedFormat('d F Y'))
@section('letter-recipient')
<strong>{{ $applicant->name }}</strong><br>
{!! nl2br(e($applicant->address)) !!}<br>
{{ $applicant->postcode }}
@endsection
@section('letter-honorific')
Tuan/Puan,
@endsection
@section('letter-subject')
<strong>{{ $letterSubject }}</strong>
@endsection
@section('letter-body')
<p style="line-height: 1;">
Dengan segala hormatnya perkara di atas adalah dirujuk.
</p>
<p style="line-height: 1;">
2.
@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>
@if ($isPassed)
<p style="line-height: 1;">
3. Maklumat keanggotaan tuan/puan adalah seperti berikut:-
</p>
<table class="letter-info-table" cellpadding="0" cellspacing="0">
<tr>
<th>Perkara</th>
<th>Maklumat</th>
</tr>
<tr>
<td>Nama Anggota</td>
<td>{{ $applicant->name }}</td>
</tr>
<tr>
<td>No. Kad Pengenalan</td>
<td>{{ $applicant->ic_number }}</td>
</tr>
<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 style="line-height: 1;">
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>
<p style="line-height: 1;">
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.
</p>
@else
<p style="line-height: 1;">
3. Oleh yang demikian, permohonan tuan/puan untuk menjadi anggota KoPKB tidak dapat diluluskan pada masa ini.
</p>
@endif
@endsection
@section('letter-closing')
Sekian, terima kasih.
@endsection
@section('letter-signature')
<div class="signature-company">KOPERASI PERMODALAN KELANTAN BERHAD</div>
@endsection
@@ -38,9 +38,12 @@ Route::middleware(['auth:sanctum', 'single.session'])->prefix('v1')->group(funct
// Membership application upload document routes
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
Route::get('membership-applications/{membershipApplication}/documents/{documentId}/download', [MembershipApplicationController::class, 'downloadDocument'])->name('membership-application.download-document');
// approved letter preview (html or pdf)
Route::get('membership-applications/{membershipApplication}/approved-letter', [MembershipApplicationController::class, 'approvedLetter'])->name('membership-application.approved-letter');
// generate and store result letter (one-time)
Route::post('membership-applications/{membershipApplication}/result-letter', [MembershipApplicationController::class, 'generateResultLetter'])->name('membership-application.generate-result-letter');
});
@@ -2,9 +2,12 @@
namespace Modules\MembershipApplication\Services;
use App\Models\Document;
use App\Services\DocumentService;
use App\Services\LetterService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Notification;
@@ -30,6 +33,7 @@ class MembershipApplicationService
public function __construct(
protected MembershipApplicationRepositoryInterface $repository,
protected DocumentService $documentService,
protected LetterService $letterService,
) {}
/**
@@ -135,15 +139,38 @@ class MembershipApplicationService
$this->documentService->validateFile($file);
$application->documentsOfType($type)->get()->each(
fn ($document) => $this->documentService->deleteDocument($document->id)
);
if ($type !== MembershipApplication::ADMIN_ATTACHMENT_DOCUMENT_TYPE) {
$application->documentsOfType($type)->get()->each(
fn ($document) => $this->documentService->deleteDocument($document->id)
);
}
$this->documentService->uploadDocument($application, $file, $type);
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 {
if ($application->status !== ApplicationStatus::Submitted) {
throw ValidationException::withMessages([
@@ -174,7 +201,7 @@ class MembershipApplicationService
{
if ($application->status !== ApplicationStatus::PendingBoard) {
throw ValidationException::withMessages([
'status' => ['Permohonan ini tidak boleh disemak pada peringkat lembaga.'],
'status' => ['Permohonan ini tidak boleh disemak pada peringkat ahli lembaga.'],
]);
}
@@ -228,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) {
throw ValidationException::withMessages([
'status' => ['Permohonan ini tidak boleh diselesaikan pada masa ini.'],
@@ -242,13 +273,16 @@ class MembershipApplicationService
]);
}
return DB::transaction(function () use ($application) {
return DB::transaction(function () use ($application, $boardMeetingReference, $boardMeetingDate) {
if ($application->board_result === BoardResult::Pass->value && ! $application->user_id) {
$user = $this->createMemberFromApplication($application);
$application->update(['user_id' => $user->id]);
$application->refresh();
}
$this->sendResultNotification($application);
$resultLetter = $this->storeResultLetter($application, $boardMeetingReference, $boardMeetingDate);
$this->sendResultNotification($application, $resultLetter);
$application->update([
'status' => ApplicationStatus::Completed,
@@ -266,8 +300,12 @@ class MembershipApplicationService
* 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 = [];
$failed = [];
@@ -285,7 +323,12 @@ class MembershipApplicationService
}
try {
$succeeded[] = $this->complete($application, $processedBy);
$succeeded[] = $this->complete(
$application,
$processedBy,
$boardMeetingReference,
$boardMeetingDate,
);
} catch (ValidationException $e) {
$failed[] = [
'id' => $id,
@@ -301,6 +344,109 @@ class MembershipApplicationService
];
}
public function generateResultLetter(
MembershipApplication $application,
string $boardMeetingReference,
string $boardMeetingDate,
): MembershipApplication {
if ($application->status !== ApplicationStatus::Completed) {
throw ValidationException::withMessages([
'status' => ['Surat keputusan hanya boleh dijana untuk permohonan yang telah selesai.'],
]);
}
if (! in_array($application->board_result, [BoardResult::Pass->value, BoardResult::Fail->value], true)) {
throw ValidationException::withMessages([
'board_result' => ['Keputusan lembaga belum ditetapkan.'],
]);
}
return DB::transaction(function () use ($application, $boardMeetingReference, $boardMeetingDate) {
$this->storeResultLetter($application, $boardMeetingReference, $boardMeetingDate);
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>
*/
public function getResultLetterViewData(
MembershipApplication $application,
string $boardMeetingReference,
string $boardMeetingDate,
): array {
$application = $this->repository->findByIdWithRelations($application->id);
if (! $application || ! $application->applicant) {
throw ValidationException::withMessages([
'application' => ['Maklumat pemohon tidak lengkap.'],
]);
}
return $this->buildResultLetterViewData($application, $boardMeetingReference, $boardMeetingDate);
}
/**
* @return array<string, mixed>
*/
protected function buildResultLetterViewData(
MembershipApplication $application,
string $boardMeetingReference,
string $boardMeetingDate,
): array {
$applicant = $application->applicant;
$isPassed = $application->board_result === BoardResult::Pass->value;
return [
'application' => $application,
'applicant' => $applicant,
'boardMeetingReference' => $boardMeetingReference,
'boardMeetingDate' => Carbon::parse($boardMeetingDate)->translatedFormat('d M Y'),
'isPassed' => $isPassed,
'letterSubject' => $isPassed
? 'KELULUSAN PERMOHONAN MENJADI ANGGOTA KOPERASI PERMODALAN KELANTAN BERHAD (KoPKB)'
: 'KEPUTUSAN PERMOHONAN MENJADI ANGGOTA KOPERASI PERMODALAN KELANTAN BERHAD (KoPKB)',
'memberNumber' => $isPassed ? $application->user?->member_number : null,
];
}
protected function createMemberFromApplication(MembershipApplication $application): User
{
$application->loadMissing(['applicant', 'heirs']);
@@ -386,20 +532,30 @@ class MembershipApplicationService
->notify(new MembershipApplicationSubmittedNotification($application));
}
protected function sendResultNotification(MembershipApplication $application): void
protected function sendResultNotification(MembershipApplication $application, Document $resultLetter): void
{
$application->loadMissing('applicant');
$notification = $application->board_result === BoardResult::Pass->value
? new MembershipApplicationPassedNotification($application)
: new MembershipApplicationFailedNotification($application);
? new MembershipApplicationPassedNotification($application, $resultLetter)
: new MembershipApplicationFailedNotification($application, $resultLetter);
Notification::route('mail', $application->applicant->email)->notify($notification);
}
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;
}
/**
@@ -15,6 +15,11 @@ class MembershipApplicationListResource extends JsonResource
'status' => $this->status?->value ?? $this->status,
'board_result' => $this->board_result ?: null,
'submitted_at' => $this->submitted_at,
'has_result_letter' => ($this->result_letter_count ?? 0) > 0,
'result_letter_document' => $this->when(
($this->result_letter_count ?? 0) > 0 && $this->relationLoaded('resultLetterDocument') && $this->resultLetterDocument,
fn () => new MembershipApplicationDocumentResource($this->resultLetterDocument),
),
'applicant' => $this->whenLoaded('applicant', fn () => [
'name' => $this->applicant->name,
'email' => $this->applicant->email,
+3
View File
@@ -2,4 +2,7 @@
return [
'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,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),
]);
}
}
@@ -127,6 +127,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
{
$user = $request->user();
@@ -5,6 +5,8 @@ namespace Modules\User\Providers;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Cache\RateLimiting\Limit;
use Modules\Auth\Entities\User;
use Modules\User\Policies\UserPolicy;
use Modules\User\Entities\Address;
@@ -34,6 +36,7 @@ class UserServiceProvider extends ServiceProvider
*/
public function boot(): void
{
$this->registerRateLimiters();
$this->registerCommands();
$this->registerCommandSchedules();
$this->registerTranslations();
@@ -96,6 +99,13 @@ class UserServiceProvider extends ServiceProvider
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
*/
@@ -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\BankDetailController;
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::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
Route::post('/profile', [UserController::class, 'updateProfile']);
Route::post('/profile/onboarding/complete', [UserController::class, 'completeOnboarding']);
Route::put('/profile/password', [UserController::class, 'updatePassword']);
Route::get('/profile/digital-card', [MemberDigitalCardController::class, 'download'])
->name('profile.digital-card.download');
// Impersonation routes
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;
}
}
+11
View File
@@ -121,6 +121,17 @@ class UserService
/**
* @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
{
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(),
];
}
}
+22 -6
View File
@@ -13,12 +13,7 @@ class DocumentService
/**
* Upload a document for any model.
*/
public function uploadDocument(
Model $model,
UploadedFile $file,
string $documentType = 'general',
?string $description = null
): Document {
public function uploadDocument(Model $model, UploadedFile $file, string $documentType = 'general', ?string $description = null): Document {
$fileName = time().'_'.$file->getClientOriginalName();
$folderName = strtolower(class_basename($model));
$filePath = $file->storeAs("documents/{$folderName}", $fileName, Document::STORAGE_DISK);
@@ -34,6 +29,27 @@ class DocumentService
]);
}
/**
* Store generated file contents (e.g. PDF) for a model.
*/
public function storeGeneratedDocument(Model $model, string $contents, string $fileName, string $documentType = 'general', string $mimeType = 'application/pdf', ?string $description = null,): Document {
$folderName = strtolower(class_basename($model));
$storedFileName = time().'_'.$fileName;
$filePath = "documents/{$folderName}/{$storedFileName}";
Storage::disk(Document::STORAGE_DISK)->put($filePath, $contents);
return $model->documents()->create([
'name' => $fileName,
'path' => $filePath,
'file_size' => strlen($contents),
'mime_type' => $mimeType,
'type' => $documentType,
'description' => $description,
'uploaded_by' => auth()->id(),
]);
}
/**
* Delete a document.
*/
+17 -6
View File
@@ -33,7 +33,7 @@ class LetterService
*/
public function pdfResponse(string $view, array $data = [], string $filename = 'letter.pdf'): Response
{
$pdf = $this->makeBrowsershot($this->renderHtml($view, $data))->pdf();
$pdf = $this->renderPdf($view, $data);
return response($pdf, SymfonyResponse::HTTP_OK, [
'Content-Type' => 'application/pdf',
@@ -41,6 +41,14 @@ class LetterService
]);
}
/**
* @param array<string, mixed> $data
*/
public function renderPdf(string $view, array $data = []): string
{
return $this->makeBrowsershot($this->renderHtml($view, $data))->pdf();
}
/**
* @param array<string, mixed> $overrides
* @return array<string, mixed>
@@ -48,11 +56,14 @@ class LetterService
public function prepareViewData(array $overrides = []): array
{
$logoPath = $overrides['logoPath'] ?? config('mail.logo_path');
$signaturePath = $overrides['signaturePath'] ?? config('letter.signature_path');
return array_merge([
'letterLayout' => LetterLayout::resolve(),
'logoPath' => $logoPath,
'logoUrl' => $this->resolveLogoDataUri($logoPath),
'logoUrl' => $this->resolveImageDataUri($logoPath),
'signaturePath' => $signaturePath,
'signatureUrl' => $this->resolveImageDataUri($signaturePath),
'organizationAddress' => $overrides['organizationAddress'] ?? null,
'organizationContact' => $overrides['organizationContact'] ?? null,
], $overrides);
@@ -89,14 +100,14 @@ class LetterService
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;
}
$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://localhost:5173',
'http://127.0.0.1:5173',
'https://anggota.koppkb.com',
'http://anggota.koppkb.com',
'https://mykopkb.koppkb.com',
'http://mykopkb.koppkb.com',
],
'allowed_origins_patterns' => [],
+8
View File
@@ -82,4 +82,12 @@ return [
'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
{
Schema::create('jobs', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->id();
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedTinyInteger('attempts');
@@ -22,7 +22,7 @@ return new class extends Migration
});
Schema::create('job_batches', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('id')->primary();
$table->string('name');
$table->integer('total_jobs');
$table->integer('pending_jobs');
@@ -35,7 +35,7 @@ return new class extends Migration
});
Schema::create('failed_jobs', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->id();
$table->string('uuid')->unique();
$table->text('connection');
$table->text('queue');
+3
View File
@@ -1,5 +1,8 @@
services:
laravel.test:
# Apple Silicon / OrbStack: Puppeteer's Chrome must match container arch.
# amd64 emulation avoids linux_arm + linux64 chrome-headless-shell mismatch.
platform: linux/amd64
build:
context: "./vendor/laravel/sail/runtimes/8.4"
dockerfile: Dockerfile
+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 \
&& 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
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 \
&& 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
RUN curl -o /usr/local/bin/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 --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
WORKDIR /var/www
# 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
RUN mkdir -p /var/log/nginx && chown -R www-data:www-data /var/log/nginx
+2 -1
View File
@@ -4,7 +4,8 @@
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite"
"dev": "vite",
"browsershot:install": "puppeteer browsers install chrome-headless-shell"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 0 B

After

Width:  |  Height:  |  Size: 204 KiB

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: 50 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-date') {{ now()->format('d F Y') }} @endsection
@section('letter-recipient') ... @endsection
@section('letter-honorific') Tuan/Puan @endsection
@section('letter-subject') Perkara: ... @endsection
@section('letter-salutation') Assalamualaikum dan Salam Sejahtera, @endsection
@section('letter-body') ... @endsection
@@ -110,74 +111,27 @@
border-collapse: collapse;
}
.letterhead-line {
vertical-align: middle;
padding-right: 16px;
}
.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-center {
text-align: center;
vertical-align: top;
}
.letterhead-logo-img {
height: var(--letter-logo-height);
width: auto;
display: block;
margin: 0 auto 8px;
}
.letterhead-brand {
vertical-align: middle;
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 {
.letterhead-address,
.letterhead-contact {
font-size: var(--letterhead-registration-size);
font-weight: bold;
line-height: 1.3;
line-height: 1.4;
color: var(--letter-color-black);
margin-top: 2px;
letter-spacing: 0.5px;
}
.letterhead-address {
margin-bottom: 4px;
}
.meta-table {
@@ -192,22 +146,25 @@
}
.meta-label {
width: 70px;
font-weight: bold;
white-space: nowrap;
padding-right: 4px;
vertical-align: top;
}
.meta-value {
padding-left: 4px;
}
.meta-date {
margin-top: 4px;
}
.recipient-block {
margin-bottom: var(--letter-recipient-margin-bottom);
}
.letter-honorific {
font-weight: bold;
margin-bottom: 8px;
}
.subject-block {
font-weight: bold;
margin-bottom: var(--letter-subject-margin-bottom);
@@ -223,6 +180,26 @@
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 {
padding-left: var(--letter-content-padding-x);
padding-right: var(--letter-content-padding-x);
@@ -239,6 +216,12 @@
height: var(--letter-signature-space-height);
}
.signature-space-img {
height: var(--letter-signature-space-height);
width: auto;
display: block;
}
.signatory-name {
font-weight: bold;
text-transform: uppercase;
@@ -261,43 +244,18 @@
table-layout: fixed;
}
.letter-footer-contact-box {
background-color: var(--letter-color-orange);
padding: 8px 12px;
.letter-footer-divider {
border-top: 1px solid var(--letter-color-black);
font-size: 0;
line-height: 0;
padding: 0;
}
.letter-footer-nota {
padding-top: 8px;
font-size: var(--letter-footer-font-size);
line-height: var(--letter-footer-line-height);
color: var(--letter-color-white);
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;
color: var(--letter-body-color);
}
@media print {
@@ -322,17 +280,22 @@
<div class="letter-content">
<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">
<tr>
<td class="meta-label">Ruj.</td>
<td class="meta-value">: @yield('letter-reference')</td>
</tr>
@if ($showReference && trim($__env->yieldContent('letter-reference')) !== '')
<tr>
<td class="meta-label">RUJ. KAMI</td>
<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>
@endif
@if ($showDate && $letterDate !== '')
<div class="meta-date">{{ $letterDate }}</div>
@endif
</div>
@hasSection('letter-recipient')
@@ -341,6 +304,10 @@
</div>
@endif
@hasSection('letter-honorific')
<div class="letter-honorific">@yield('letter-honorific')</div>
@endif
@if ($showSubject && trim($__env->yieldContent('letter-subject')) !== '')
<div class="subject-block">
@yield('letter-subject')
@@ -1,38 +1,11 @@
<table class="letter-footer" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td class="letter-footer-contact-box" valign="middle" width="50%" bgcolor="#d35400">
<table class="letter-footer-lines" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td class="letter-footer-icon" valign="middle">@include('pdf.partials.footer-icon', ['icon' => 'phone'])</td>
<td class="letter-footer-line-text" valign="middle">Tel. Pejabat: 09-741 4331</td>
</tr>
<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 class="letter-footer-divider">&nbsp;</td>
</tr>
<tr>
<td class="letter-footer-nota">
<strong>Nota:</strong><br>
<em>Surat ini dijana secara komputer dan merupakan dokumen rasmi Koperasi Permodalan Kelantan Berhad (KoPKB). Oleh itu, surat ini adalah sah tanpa tandatangan.</em>
</td>
</tr>
</table>
@@ -1,28 +1,15 @@
<table class="letterhead" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td class="letterhead-line" valign="middle">
<table class="letterhead-rule" width="100%" cellpadding="0" cellspacing="0">
<tr>
<td class="letterhead-rule-blue" width="50%" height="6" bgcolor="#0e579b">&nbsp;</td>
<td class="letterhead-rule-orange" width="50%" height="6" bgcolor="#d35400">&nbsp;</td>
</tr>
</table>
</td>
<td class="letterhead-brand" valign="middle" align="right" width="1">
<table class="letterhead-brand-inner" cellpadding="0" cellspacing="0" align="right">
<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 class="letterhead-center" align="center" valign="top">
@if (! empty($logoUrl))
<img src="{{ $logoUrl }}" alt="Koperasi Permodalan Kelantan Berhad" class="letterhead-logo-img">
@endif
<div class="letterhead-address">
Lot Pt 448, Tingkat 1, Jalan Kuala Krai, Batu 3, Wakaf Che Yeh, 15150 Kota Bharu, Kelantan.
</div>
<div class="letterhead-contact">
Tel: 09-7414331 | Fax: 09-7414330 | Web:www.koppkb.com
</div>
</td>
</tr>
</table>
@@ -7,13 +7,18 @@
@section('letter-date', now()->translatedFormat('d F Y'))
@section('letter-recipient')
{{-- auto fetch from membership_application_applicants --}}
<strong>Ahmad bin Abdullah</strong><br>
No. 12, Jalan Contoh,<br>
43000 Kajang, Selangor
@endsection
@section('letter-honorific')
Tuan/Puan,
@endsection
@section('letter-subject')
<strong>KELULUSAN KEANGGOTAAN KOPERASI PERMODALAN KELANTAN BERHAD</strong>
<strong>KELULUSAN PERMOHONAN MENJADI ANGGOTA KOPERASI PERMODALAN KELANTAN BERHAD (KoPKB)</strong>
@endsection
{{-- @section('letter-salutation')
@@ -22,39 +27,57 @@
@section('letter-body')
<p style="line-height: 1;">
Dengan segala hormatnya saya merujuk kepada perkara di atas.
Dengan segala hormatnya perkara di atas adalah dirujuk.
</p>
<p style="line-height: 1;">
2. Sukacita dimaklumkan bahawa, permohonan tuan untuk menjadi anggota Koperasi
Permodalan Kelantan Berhad(KoPKB) telah <strong>DILULUSKAN</strong> dalam Mesyuarat Lembaga
Koperasi Bil. 4-2025/2026 pada 24 Jun 2026.
2. 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 Mesyuarat Lembaga Bil. yang diadakan pada 01 Jan 2026.
</p>
<p style="line-height: 1;">
3. Pihak Koperasi akan membuat potongan gaji melalui majikan tuan sebagaiman ketetapan berikut:-
<ul style="line-height: 1;">
<li>Saham</li>
<ul style="line-height: 1;">
<li>Potongan sebanyak RM 84.00 (Mei 2026 - Jun 2026)</li>
<li>Potongan sebanyak RM 50.00 sehingga Syer Maksima</li>
</ul>
<li>Yuran</li>
<ul style="line-height: 1;">
<li>Potongan sebanyak RM 50.00 setiap bulan</li>
</ul>
<li>Fi Masuk</li>
<ul style="line-height: 1;">
<li>Potongan sebanyak RM 10.00 (Pembayaran Pertama)</li>
</ul>
</ul>
3. Maklumat keanggotaan tuan/puan adalah seperti berikut:-
</p>
<table class="letter-info-table" cellpadding="0" cellspacing="0">
<tr>
<th>Perkara</th>
<th>Maklumat</th>
</tr>
<tr>
<td>Nama Anggota</td>
<td>Ali</td>
</tr>
<tr>
<td>No. Kad Pengenalan</td>
<td>0123456789012345</td>
</tr>
<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 style="line-height: 1;">
4. Untuk makluman tuan, kelulusan keanggotaan ini adalah bersyarat di mana tuan dikehendaki
menjelaskan modal syer minimum sebanyak RM500.00. Bayaran tersebut
perlu dibuat secara ansuran melalui potongan gaji dalam tempoh enam (6) bulan.
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.
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>
<p style="line-height: 1;">
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.
</p>
@endsection
@@ -63,10 +86,5 @@
@endsection
@section('letter-signature')
<div class="signature-header">Dengan hormatnya,</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
-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');
});
Route::get('/letters/preview', [LetterPreviewController::class, 'show'])
->name('letters.preview');
Route::get('/letters/preview-approved-membership', [LetterPreviewController::class, 'showMembershipApprovedLetter'])
->name('letters.preview-membership-approved-membership');
BIN
View File
Binary file not shown.
+7 -1
View File
@@ -7,10 +7,16 @@ RUN npm install -g pnpm && \
pnpm install --frozen-lockfile --ignore-scripts && \
pnpm rebuild esbuild
COPY fe/ ./
ARG VITE_APP_NAME="MyKOPKB"
ARG VITE_APP_VERSION="1.0"
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_APP_URL=$VITE_APP_URL
ENV VITE_APP_NAME=$VITE_APP_NAME
ENV VITE_APP_VERSION=$VITE_APP_VERSION
# RUN pnpm run typecheck # uncomment this to run typecheck
RUN pnpm exec vite build --mode=production
+2
View File
@@ -57,6 +57,7 @@
"lodash": "^4.17.21",
"maplibre-gl": "^5.18.0",
"pinia": "^3.0.4",
"qrcode": "^1.5.4",
"sweetalert2": "^11.26.25",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.1.18",
@@ -71,6 +72,7 @@
"@types/jsdom": "^27.0.0",
"@types/lodash": "^4.17.21",
"@types/node": "^24.10.4",
"@types/qrcode": "^1.5.6",
"@vitejs/plugin-vue": "^6.0.3",
"@vitest/eslint-plugin": "^1.6.4",
"@vue/eslint-config-prettier": "^10.2.0",
+164
View File
@@ -119,6 +119,9 @@ importers:
pinia:
specifier: ^3.0.4
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:
specifier: ^11.26.25
version: 11.26.25
@@ -156,6 +159,9 @@ importers:
'@types/node':
specifier: ^24.10.4
version: 24.12.4
'@types/qrcode':
specifier: ^1.5.6
version: 1.5.6
'@vitejs/plugin-vue':
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))
@@ -1041,6 +1047,9 @@ packages:
'@types/pako@2.0.4':
resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==}
'@types/qrcode@1.5.6':
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
'@types/raf@3.4.3':
resolution: {integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==}
@@ -1588,6 +1597,10 @@ packages:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
camelcase@5.3.1:
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
engines: {node: '>=6'}
caniuse-lite@1.0.30001793:
resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==}
@@ -1637,6 +1650,9 @@ packages:
resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==}
engines: {node: '>=20'}
cliui@6.0.0:
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
@@ -1758,6 +1774,10 @@ packages:
supports-color:
optional: true
decamelize@1.2.0:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
@@ -1784,6 +1804,9 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
dijkstrajs@1.0.3:
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
dompurify@3.4.7:
resolution: {integrity: sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==}
@@ -2055,6 +2078,10 @@ packages:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
find-up@4.1.0:
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
engines: {node: '>=8'}
find-up@5.0.0:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
@@ -2109,6 +2136,10 @@ packages:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
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:
resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==}
engines: {node: '>=18'}
@@ -2501,6 +2532,10 @@ packages:
resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==}
engines: {node: '>=20.0.0'}
locate-path@5.0.0:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
@@ -2679,14 +2714,26 @@ packages:
ospath@1.2.2:
resolution: {integrity: sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA==}
p-limit@2.3.0:
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
engines: {node: '>=6'}
p-limit@3.1.0:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
engines: {node: '>=10'}
p-locate@4.1.0:
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
engines: {node: '>=8'}
p-locate@5.0.0:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
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:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
@@ -2769,6 +2816,10 @@ packages:
typescript:
optional: true
pngjs@5.0.0:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
postcss-selector-parser@7.1.1:
resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==}
engines: {node: '>=4'}
@@ -2833,6 +2884,11 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
qrcode@1.5.4:
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
engines: {node: '>=10.13.0'}
hasBin: true
qs@6.15.2:
resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==}
engines: {node: '>=0.6'}
@@ -2856,10 +2912,17 @@ packages:
request-progress@3.0.0:
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:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
require-main-filename@2.0.0:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@@ -2916,6 +2979,9 @@ packages:
engines: {node: '>=10'}
hasBin: true
set-blocking@2.0.0:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
@@ -3393,6 +3459,9 @@ packages:
resolution: {integrity: sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==}
engines: {node: '>=20'}
which-module@2.0.1:
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
@@ -3420,6 +3489,10 @@ packages:
resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==}
engines: {node: '>=0.8'}
wrap-ansi@6.2.0:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@@ -3467,9 +3540,20 @@ packages:
xmlchars@2.2.0:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
y18n@4.0.3:
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
yallist@3.1.1:
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:
resolution: {integrity: sha512-Md9ankxxN23wncAN8s7+Tn3Co52zLUPMtnrLAbVCnfG5d2tKBFfmygYSgXlqFgXObtzIgqkx7aNgDBpso9+4qA==}
engines: {node: '>=12'}
@@ -4215,6 +4299,10 @@ snapshots:
'@types/pako@2.0.4': {}
'@types/qrcode@1.5.6':
dependencies:
'@types/node': 24.12.4
'@types/raf@3.4.3':
optional: true
@@ -4978,6 +5066,8 @@ snapshots:
callsites@3.1.0: {}
camelcase@5.3.1: {}
caniuse-lite@1.0.30001793: {}
canvg@3.0.11:
@@ -5033,6 +5123,12 @@ snapshots:
slice-ansi: 8.0.0
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: {}
codepage@1.15.0: {}
@@ -5172,6 +5268,8 @@ snapshots:
optionalDependencies:
supports-color: 8.1.1
decamelize@1.2.0: {}
decimal.js@10.6.0: {}
deep-is@0.1.4: {}
@@ -5189,6 +5287,8 @@ snapshots:
detect-libc@2.1.2: {}
dijkstrajs@1.0.3: {}
dompurify@3.4.7:
optionalDependencies:
'@types/trusted-types': 2.0.7
@@ -5505,6 +5605,11 @@ snapshots:
dependencies:
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:
dependencies:
locate-path: 6.0.0
@@ -5554,6 +5659,8 @@ snapshots:
gensync@1.0.0-beta.2: {}
get-caller-file@2.0.5: {}
get-east-asian-width@1.6.0: {}
get-intrinsic@1.3.0:
@@ -5928,6 +6035,10 @@ snapshots:
rfdc: 1.4.1
wrap-ansi: 9.0.2
locate-path@5.0.0:
dependencies:
p-locate: 4.1.0
locate-path@6.0.0:
dependencies:
p-locate: 5.0.0
@@ -6107,14 +6218,24 @@ snapshots:
ospath@1.2.2: {}
p-limit@2.3.0:
dependencies:
p-try: 2.2.0
p-limit@3.1.0:
dependencies:
yocto-queue: 0.1.0
p-locate@4.1.0:
dependencies:
p-limit: 2.3.0
p-locate@5.0.0:
dependencies:
p-limit: 3.1.0
p-try@2.2.0: {}
package-json-from-dist@1.0.1: {}
pako@2.1.0: {}
@@ -6177,6 +6298,8 @@ snapshots:
optionalDependencies:
typescript: 5.9.3
pngjs@5.0.0: {}
postcss-selector-parser@7.1.1:
dependencies:
cssesc: 3.0.0
@@ -6225,6 +6348,12 @@ snapshots:
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:
dependencies:
side-channel: 1.1.0
@@ -6250,8 +6379,12 @@ snapshots:
dependencies:
throttleit: 1.0.1
require-directory@2.1.1: {}
require-from-string@2.0.2: {}
require-main-filename@2.0.0: {}
resolve-from@4.0.0: {}
resolve-protobuf-schema@2.1.0:
@@ -6323,6 +6456,8 @@ snapshots:
semver@7.8.1: {}
set-blocking@2.0.0: {}
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
@@ -6781,6 +6916,8 @@ snapshots:
tr46: 6.0.0
webidl-conversions: 8.0.1
which-module@2.0.1: {}
which@2.0.2:
dependencies:
isexe: 2.0.0
@@ -6800,6 +6937,12 @@ snapshots:
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:
dependencies:
ansi-styles: 4.3.0
@@ -6843,8 +6986,29 @@ snapshots:
xmlchars@2.2.0: {}
y18n@4.0.3: {}
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:
dependencies:
pend: 1.2.0
BIN
View File
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 902 KiB

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

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