Dev/v1.2 (#4)
Co-authored-by: ISMAIL MASSERAN <topaz@Mac.dlinkrouter.local> Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
+4
-1
@@ -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
|
||||
+4
-1
@@ -102,4 +102,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
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
+26
-9
@@ -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,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+26
-9
@@ -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,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+8
-5
@@ -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'),
|
||||
]);
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ class MembershipApplication extends Model
|
||||
|
||||
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;
|
||||
|
||||
+24
-5
@@ -17,6 +17,7 @@ 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;
|
||||
@@ -180,18 +181,19 @@ 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'),
|
||||
);
|
||||
|
||||
$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,
|
||||
@@ -206,7 +208,8 @@ class MembershipApplicationController extends Controller
|
||||
|
||||
$result = $this->membershipApplicationService->completeBatch(
|
||||
$request->validated('application_ids'),
|
||||
$request->user()
|
||||
$request->user(),
|
||||
$request->validated('board_meeting_reference'),
|
||||
);
|
||||
|
||||
$succeededCount = count($result['succeeded']);
|
||||
@@ -245,6 +248,22 @@ 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);
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ 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',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -33,6 +34,8 @@ 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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
<?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',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
+8
-1
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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** pada peringkat lembaga.
|
||||
|
||||
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** pada peringkat lembaga.
|
||||
|
||||
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
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
</p>
|
||||
<p style="line-height: 1;">
|
||||
2. {{ $openingTone }} dimaklumkan bahawa, permohonan tuan untuk menjadi anggota Koperasi
|
||||
Permodalan Kelantan Berhad (KoPKB) telah <strong>{{ $boardResultLabel }}</strong> dalam {{ $boardMeetingReference }}.
|
||||
Permodalan Kelantan Berhad (KoPKB) telah <strong>{{ $boardResultLabel }}</strong> dalam {{ $boardMeetingReference }}@if ($isPassed && $memberNumber). Nombor keanggotaan tuan adalah <strong>{{ $memberNumber }}</strong>@endif.
|
||||
</p>
|
||||
|
||||
@if ($isPassed)
|
||||
@@ -66,8 +66,12 @@
|
||||
@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="signature-space">
|
||||
@if (! empty($signatureUrl))
|
||||
<img src="{{ $signatureUrl }}" alt="" class="signature-space-img">
|
||||
@endif
|
||||
</div>
|
||||
<div class="signatory-name">MUHAMMAD NAFIS BIN ZAINUDIN</div>
|
||||
<div class="signatory-title">Pengurus Besar</div>
|
||||
<div class="signatory-title">b/p Setiausaha</div>
|
||||
@endsection
|
||||
|
||||
@@ -38,6 +38,7 @@ 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');
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace Modules\MembershipApplication\Services;
|
||||
|
||||
use App\Models\Document;
|
||||
use App\Services\DocumentService;
|
||||
use App\Services\LetterService;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
@@ -137,15 +138,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([
|
||||
@@ -230,8 +254,11 @@ class MembershipApplicationService
|
||||
});
|
||||
}
|
||||
|
||||
public function complete(MembershipApplication $application, User $processedBy): MembershipApplication
|
||||
{
|
||||
public function complete(
|
||||
MembershipApplication $application,
|
||||
User $processedBy,
|
||||
string $boardMeetingReference,
|
||||
): MembershipApplication {
|
||||
if ($application->status !== ApplicationStatus::PendingNotification) {
|
||||
throw ValidationException::withMessages([
|
||||
'status' => ['Permohonan ini tidak boleh diselesaikan pada masa ini.'],
|
||||
@@ -244,13 +271,16 @@ class MembershipApplicationService
|
||||
]);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($application) {
|
||||
return DB::transaction(function () use ($application, $boardMeetingReference) {
|
||||
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);
|
||||
|
||||
$this->sendResultNotification($application, $resultLetter);
|
||||
|
||||
$application->update([
|
||||
'status' => ApplicationStatus::Completed,
|
||||
@@ -268,7 +298,7 @@ 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): array
|
||||
{
|
||||
$succeeded = [];
|
||||
$failed = [];
|
||||
@@ -287,7 +317,7 @@ class MembershipApplicationService
|
||||
}
|
||||
|
||||
try {
|
||||
$succeeded[] = $this->complete($application, $processedBy);
|
||||
$succeeded[] = $this->complete($application, $processedBy, $boardMeetingReference);
|
||||
} catch (ValidationException $e) {
|
||||
$failed[] = [
|
||||
'id' => $id,
|
||||
@@ -317,40 +347,47 @@ class MembershipApplicationService
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($application, $boardMeetingReference) {
|
||||
$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);
|
||||
$pdf = $this->letterService->renderPdf('membershipapplication::pdf.approved-letter', $viewData);
|
||||
$fileName = 'surat-keputusan-'.$application->application_number.'.pdf';
|
||||
|
||||
$this->documentService->storeGeneratedDocument(
|
||||
$application,
|
||||
$pdf,
|
||||
$fileName,
|
||||
MembershipApplication::RESULT_LETTER_DOCUMENT_TYPE,
|
||||
);
|
||||
$this->storeResultLetter($application, $boardMeetingReference);
|
||||
|
||||
return $this->repository->findByIdWithRelations($application->id);
|
||||
});
|
||||
}
|
||||
|
||||
protected function storeResultLetter(
|
||||
MembershipApplication $application,
|
||||
string $boardMeetingReference,
|
||||
): 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);
|
||||
$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>
|
||||
*/
|
||||
@@ -378,8 +415,9 @@ class MembershipApplicationService
|
||||
): array {
|
||||
$applicant = $application->applicant;
|
||||
$isPassed = $application->board_result === BoardResult::Pass->value;
|
||||
$stockMonthlyContribution = (float) $applicant->stock_monthly_contribution;
|
||||
$monthlyShareInstallment = number_format(
|
||||
MembershipApplication::MINIMUM_SHARE_CAPITAL / MembershipApplication::SHARE_INSTALLMENT_MONTHS,
|
||||
$stockMonthlyContribution < 84 ? 84 : $stockMonthlyContribution,
|
||||
2,
|
||||
'.',
|
||||
'',
|
||||
@@ -396,10 +434,11 @@ class MembershipApplicationService
|
||||
? 'KELULUSAN KEANGGOTAAN KOPERASI PERMODALAN KELANTAN BERHAD'
|
||||
: 'KEPUTUSAN PERMOHONAN KEANGGOTAAN KOPERASI PERMODALAN KELANTAN BERHAD',
|
||||
'monthlyShareInstallment' => $monthlyShareInstallment,
|
||||
'stockMonthlyContribution' => number_format((float) $applicant->stock_monthly_contribution, 2, '.', ''),
|
||||
'stockMonthlyContribution' => number_format($stockMonthlyContribution, 2, '.', ''),
|
||||
'feeMonthlyContribution' => number_format((float) $applicant->fee_monthly_contribution, 2, '.', ''),
|
||||
'shareInstallmentMonths' => MembershipApplication::SHARE_INSTALLMENT_MONTHS,
|
||||
'minimumShareCapital' => number_format(MembershipApplication::MINIMUM_SHARE_CAPITAL, 2, '.', ''),
|
||||
'memberNumber' => $isPassed ? $application->user?->member_number : null,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -488,20 +527,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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.svg')),
|
||||
];
|
||||
|
||||
+34
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
+35
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
+22
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -56,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);
|
||||
@@ -97,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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,4 +82,12 @@ return [
|
||||
'black' => '#000000',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Assets
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
'signature_path' => resource_path('assets/signatures/signature.svg'),
|
||||
|
||||
];
|
||||
|
||||
+1
-1
@@ -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.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');
|
||||
|
||||
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
|
After Width: | Height: | Size: 202 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 7.8 KiB |
@@ -239,6 +239,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;
|
||||
|
||||
@@ -27,19 +27,19 @@
|
||||
</p>
|
||||
<p style="line-height: 1;">
|
||||
2. Sukacita dimaklumkan bahawa, permohonan tuan untuk menjadi anggota Koperasi
|
||||
Permodalan Kelantan Berhad(KoPKB) telah <strong>{{ membership_applications.board_result }}</strong> dalam {{ User manual fills before generate }}
|
||||
Permodalan Kelantan Berhad(KoPKB) telah <strong></strong> dalam
|
||||
</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 {{(6 months auto calculate)}}</li>
|
||||
<li>Potongan sebanyak RM {{ membership_application_applicants.stock_monthly_contribution }} sehingga Syer Maksima</li>
|
||||
<li>Potongan sebanyak RM 84.00 </li>
|
||||
<li>Potongan sebanyak RM sehingga Syer Maksima</li>
|
||||
</ul>
|
||||
<li>Yuran</li>
|
||||
<ul style="line-height: 1;">
|
||||
<li>Potongan sebanyak RM {{ membership_application_applicants.fee_monthly_contribution }} setiap bulan</li>
|
||||
<li>Potongan sebanyak RM setiap bulan</li>
|
||||
</ul>
|
||||
<li>Fi Masuk</li>
|
||||
<ul style="line-height: 1;">
|
||||
@@ -65,7 +65,11 @@
|
||||
@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="signature-space">
|
||||
@if (! empty($signatureUrl))
|
||||
<img src="{{ $signatureUrl }}" alt="" class="signature-space-img">
|
||||
@endif
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user