Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e50e3d19f | |||
| af7f47bf71 | |||
| ebd3caecc6 | |||
| 18c0d268b1 | |||
| 1cc3351ca6 | |||
| 33372f2c95 | |||
| e6e2df72eb | |||
| 580c7d2392 | |||
| d06c4701a4 | |||
| e77712105c | |||
| 421775d3ff |
@@ -1,12 +1,11 @@
|
|||||||
name: Build Docker Image
|
name: Build Docker Image
|
||||||
|
|
||||||
# Builds images on every push to main or tag, but does NOT auto-deploy
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
# branches:
|
||||||
- main
|
# - main
|
||||||
tags:
|
tags:
|
||||||
- "v*" # v1.0.0, v1.0.1, etc.
|
- "v*"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# test-backend:
|
# test-backend:
|
||||||
@@ -92,6 +91,6 @@ jobs:
|
|||||||
--cache-to type=registry,ref=${IMAGE}:buildcache,mode=max \
|
--cache-to type=registry,ref=${IMAGE}:buildcache,mode=max \
|
||||||
-f fe/docker/Dockerfile \
|
-f fe/docker/Dockerfile \
|
||||||
--build-arg VITE_API_BASE_URL=https://api.koppkb.com \
|
--build-arg VITE_API_BASE_URL=https://api.koppkb.com \
|
||||||
--build-arg VITE_APP_URL=https://anggota.koppkb.com \
|
--build-arg VITE_APP_URL=https://mykopkb.koppkb.com \
|
||||||
${TAGS} \
|
${TAGS} \
|
||||||
--push .
|
--push .
|
||||||
|
|||||||
@@ -77,3 +77,6 @@ SELECT setval('public.permissions_id_seq', COALESCE((SELECT MAX(id) FROM permiss
|
|||||||
```bash
|
```bash
|
||||||
git reset --hard development # moves the current branch (e.g., main) to point exactly where development
|
git reset --hard development # moves the current branch (e.g., main) to point exactly where development
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Public endpoint to apply membership:
|
||||||
|
## http://localhost:5173/apply-membership
|
||||||
@@ -15,4 +15,18 @@
|
|||||||
[ ] boleh print semua borang
|
[ ] boleh print semua borang
|
||||||
[ ] jana surat lepas lulus anggota
|
[ ] 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
|
||||||
@@ -96,3 +96,6 @@ API_ALLOWED_USER_AGENTS=
|
|||||||
ENABLE_API_KEY_AUTH=true
|
ENABLE_API_KEY_AUTH=true
|
||||||
API_VALID_KEYS=86f58825e5c1e0872d8786092d47e3bd,6461fc5390660c55dc47bdfcd85ed9ae
|
API_VALID_KEYS=86f58825e5c1e0872d8786092d47e3bd,6461fc5390660c55dc47bdfcd85ed9ae
|
||||||
API_LOG_KEY_USAGE=true
|
API_LOG_KEY_USAGE=true
|
||||||
|
|
||||||
|
PUBLIC_PROFILE_TOKEN_TTL_DAYS=7
|
||||||
|
FRONTEND_URL=https://mykopkb.koppkb.com
|
||||||
@@ -67,11 +67,13 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
|
|||||||
VITE_APP_NAME="${APP_NAME}"
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
|
|
||||||
# Browsershot (PDF generation via headless Chrome + Puppeteer in be/)
|
# Browsershot (PDF generation via headless Chrome + Puppeteer in be/)
|
||||||
# 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_NPM_BINARY=
|
||||||
# BROWSERSHOT_NODE_MODULE_PATH=
|
# BROWSERSHOT_NODE_MODULE_PATH=/var/www
|
||||||
# BROWSERSHOT_CHROME_PATH=
|
# BROWSERSHOT_CHROME_PATH=
|
||||||
# BROWSERSHOT_NO_SANDBOX=false
|
|
||||||
# BROWSERSHOT_TIMEOUT=60
|
# BROWSERSHOT_TIMEOUT=60
|
||||||
|
|
||||||
# HttpOnly Sanctum token cookie (set on api.* domain)
|
# HttpOnly Sanctum token cookie (set on api.* domain)
|
||||||
|
|||||||
@@ -103,3 +103,7 @@ API_ALLOWED_USER_AGENTS=
|
|||||||
ENABLE_API_KEY_AUTH=true
|
ENABLE_API_KEY_AUTH=true
|
||||||
API_VALID_KEYS=86f58825e5c1e0872d8786092d47e3bd,6461fc5390660c55dc47bdfcd85ed9ae
|
API_VALID_KEYS=86f58825e5c1e0872d8786092d47e3bd,6461fc5390660c55dc47bdfcd85ed9ae
|
||||||
API_LOG_KEY_USAGE=true
|
API_LOG_KEY_USAGE=true
|
||||||
|
|
||||||
|
PUBLIC_PROFILE_TOKEN_TTL_DAYS=7
|
||||||
|
FRONTEND_URL=https://mykopkb.koppkb.com
|
||||||
|
BROWSERSHOT_NO_SANDBOX=true
|
||||||
@@ -3,17 +3,22 @@
|
|||||||
namespace Modules\ActivityLog\Http\Controllers;
|
namespace Modules\ActivityLog\Http\Controllers;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Spatie\Activitylog\Models\Activity;
|
use Spatie\Activitylog\Models\Activity;
|
||||||
use App\Services\ActivityLogger;
|
use App\Services\ActivityLogger;
|
||||||
|
|
||||||
class ActivityLogController extends Controller
|
class ActivityLogController extends Controller
|
||||||
{
|
{
|
||||||
|
use AuthorizesRequests;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display a listing of the resource.
|
* Display a listing of the resource.
|
||||||
*/
|
*/
|
||||||
public function index(Request $request)
|
public function index(Request $request)
|
||||||
{
|
{
|
||||||
|
$this->authorize('viewAny', Activity::class);
|
||||||
|
|
||||||
// ActivityLogger::logView(new Activity, 'Viewed activity logs');
|
// ActivityLogger::logView(new Activity, 'Viewed activity logs');
|
||||||
|
|
||||||
$perPage = $request->get('per_page', 10);
|
$perPage = $request->get('per_page', 10);
|
||||||
@@ -27,8 +32,10 @@ class ActivityLogController extends Controller
|
|||||||
$q->where('description', 'ILIKE', "%{$search}%")
|
$q->where('description', 'ILIKE', "%{$search}%")
|
||||||
->orWhere('subject_type', 'ILIKE', "%{$search}%")
|
->orWhere('subject_type', 'ILIKE', "%{$search}%")
|
||||||
->orWhere('event', 'ILIKE', "%{$search}%")
|
->orWhere('event', 'ILIKE', "%{$search}%")
|
||||||
->orWhere('causer.name', 'ILIKE', "%{$search}%")
|
->orWhereHas('causer', function ($causerQuery) use ($search) {
|
||||||
->orWhere('causer.email', 'ILIKE', "%{$search}%");
|
$causerQuery->where('name', 'ILIKE', "%{$search}%")
|
||||||
|
->orWhere('email', 'ILIKE', "%{$search}%");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,10 @@
|
|||||||
namespace Modules\ActivityLog\Providers;
|
namespace Modules\ActivityLog\Providers;
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Blade;
|
use Illuminate\Support\Facades\Blade;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use Modules\ActivityLog\Policies\ActivityLogPolicy;
|
||||||
|
use Spatie\Activitylog\Models\Activity;
|
||||||
use Nwidart\Modules\Traits\PathNamespace;
|
use Nwidart\Modules\Traits\PathNamespace;
|
||||||
use RecursiveDirectoryIterator;
|
use RecursiveDirectoryIterator;
|
||||||
use RecursiveIteratorIterator;
|
use RecursiveIteratorIterator;
|
||||||
@@ -21,6 +24,8 @@ class ActivityLogServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
|
Gate::policy(Activity::class, ActivityLogPolicy::class);
|
||||||
|
|
||||||
$this->registerCommands();
|
$this->registerCommands();
|
||||||
$this->registerCommandSchedules();
|
$this->registerCommandSchedules();
|
||||||
$this->registerTranslations();
|
$this->registerTranslations();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Laravel\Sanctum\HasApiTokens;
|
use Laravel\Sanctum\HasApiTokens;
|
||||||
use Spatie\Activitylog\LogOptions;
|
use Spatie\Activitylog\LogOptions;
|
||||||
use Spatie\Activitylog\Traits\LogsActivity;
|
use Spatie\Activitylog\Traits\LogsActivity;
|
||||||
@@ -51,9 +52,12 @@ class User extends Authenticatable
|
|||||||
'marriage_status',
|
'marriage_status',
|
||||||
'member_number',
|
'member_number',
|
||||||
'member_type',
|
'member_type',
|
||||||
|
'public_profile_token',
|
||||||
'join_date',
|
'join_date',
|
||||||
|
'leave_date',
|
||||||
'birth_date',
|
'birth_date',
|
||||||
'birth_place',
|
'birth_place',
|
||||||
|
'onboarding_completed_at',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -67,8 +71,55 @@ class User extends Authenticatable
|
|||||||
'two_factor_secret',
|
'two_factor_secret',
|
||||||
'two_factor_recovery_codes',
|
'two_factor_recovery_codes',
|
||||||
'two_factor_confirmed_at',
|
'two_factor_confirmed_at',
|
||||||
|
'public_profile_token',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::creating(function (User $user) {
|
||||||
|
$expiryDays = self::publicProfileTokenTtlDays();
|
||||||
|
|
||||||
|
if (empty($user->public_profile_token)) {
|
||||||
|
$user->public_profile_token = Str::random(48);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($user->public_profile_token_expires_at)) {
|
||||||
|
$user->public_profile_token_expires_at = now()->addDays($expiryDays);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function publicProfileTokenTtlDays(): int
|
||||||
|
{
|
||||||
|
return (int) config('user.public_profile_token_ttl_days', 90);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isPublicProfileTokenExpired(): bool
|
||||||
|
{
|
||||||
|
return $this->public_profile_token_expires_at !== null
|
||||||
|
&& $this->public_profile_token_expires_at->isPast();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ensurePublicProfileToken(): string
|
||||||
|
{
|
||||||
|
$expiryDays = self::publicProfileTokenTtlDays();
|
||||||
|
|
||||||
|
if (blank($this->public_profile_token) || $this->isPublicProfileTokenExpired()) {
|
||||||
|
$this->forceFill([
|
||||||
|
'public_profile_token' => Str::random(48),
|
||||||
|
'public_profile_token_expires_at' => now()->addDays($expiryDays),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return $this->public_profile_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->forceFill([
|
||||||
|
'public_profile_token_expires_at' => now()->addDays($expiryDays),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return $this->public_profile_token;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the attributes that should be cast.
|
* Get the attributes that should be cast.
|
||||||
*
|
*
|
||||||
@@ -84,9 +135,12 @@ class User extends Authenticatable
|
|||||||
'marriage_status' => 'string',
|
'marriage_status' => 'string',
|
||||||
'member_number' => 'integer',
|
'member_number' => 'integer',
|
||||||
'member_type' => 'string',
|
'member_type' => 'string',
|
||||||
|
'public_profile_token_expires_at' => 'datetime',
|
||||||
'join_date' => 'date',
|
'join_date' => 'date',
|
||||||
|
'leave_date' => 'date',
|
||||||
'birth_date' => 'date',
|
'birth_date' => 'date',
|
||||||
'birth_place' => 'string',
|
'birth_place' => 'string',
|
||||||
|
'onboarding_completed_at' => 'datetime',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ class SessionController extends Controller
|
|||||||
], 401);
|
], 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$user->ensurePublicProfileToken();
|
||||||
$user->load(['roles.permissions']);
|
$user->load(['roles.permissions']);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ class AuthSessionService
|
|||||||
{
|
{
|
||||||
public function createAuthResponse(User $user, string $message, int $status = 200): JsonResponse
|
public function createAuthResponse(User $user, string $message, int $status = 200): JsonResponse
|
||||||
{
|
{
|
||||||
|
$user->ensurePublicProfileToken();
|
||||||
|
|
||||||
if (config('app.env') !== 'local') {
|
if (config('app.env') !== 'local') {
|
||||||
$user->tokens()->delete();
|
$user->tokens()->delete();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,9 +36,12 @@ class UserResource extends JsonResource
|
|||||||
'marriage_status' => $this->marriage_status,
|
'marriage_status' => $this->marriage_status,
|
||||||
'member_number' => $this->member_number,
|
'member_number' => $this->member_number,
|
||||||
'member_type' => $this->member_type,
|
'member_type' => $this->member_type,
|
||||||
|
'public_profile_token' => $this->public_profile_token,
|
||||||
'join_date' => $this->join_date,
|
'join_date' => $this->join_date,
|
||||||
|
'leave_date' => $this->leave_date,
|
||||||
'birth_date' => $this->birth_date,
|
'birth_date' => $this->birth_date,
|
||||||
'birth_place' => $this->birth_place,
|
'birth_place' => $this->birth_place,
|
||||||
|
'onboarding_completed_at' => $this->onboarding_completed_at,
|
||||||
'created_at' => $this->created_at,
|
'created_at' => $this->created_at,
|
||||||
'updated_at' => $this->updated_at,
|
'updated_at' => $this->updated_at,
|
||||||
'deleted_at' => $this->deleted_at,
|
'deleted_at' => $this->deleted_at,
|
||||||
|
|||||||
@@ -2,19 +2,28 @@
|
|||||||
|
|
||||||
namespace Modules\MembershipApplication\Emails;
|
namespace Modules\MembershipApplication\Emails;
|
||||||
|
|
||||||
|
use App\Models\Document;
|
||||||
|
use App\Notifications\Concerns\BuildsMailMessage;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Notifications\Messages\MailMessage;
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
use Illuminate\Notifications\Notification;
|
use Illuminate\Notifications\Notification;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Modules\MembershipApplication\Entities\MembershipApplication;
|
use Modules\MembershipApplication\Entities\MembershipApplication;
|
||||||
|
|
||||||
class MembershipApplicationFailedNotification extends Notification
|
class MembershipApplicationFailedNotification extends Notification implements ShouldQueue
|
||||||
{
|
{
|
||||||
|
use BuildsMailMessage;
|
||||||
use Queueable;
|
use Queueable;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
protected MembershipApplication $application
|
public MembershipApplication $application,
|
||||||
|
public Document $resultLetter,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
public function via(object $notifiable): array
|
public function via(object $notifiable): array
|
||||||
{
|
{
|
||||||
return ['mail'];
|
return ['mail'];
|
||||||
@@ -23,13 +32,21 @@ class MembershipApplicationFailedNotification extends Notification
|
|||||||
public function toMail(object $notifiable): MailMessage
|
public function toMail(object $notifiable): MailMessage
|
||||||
{
|
{
|
||||||
$this->application->loadMissing('applicant');
|
$this->application->loadMissing('applicant');
|
||||||
|
$applicant = $this->application->applicant;
|
||||||
|
|
||||||
return (new MailMessage)
|
return $this->mailMessage(
|
||||||
->subject('Keputusan Permohonan Keahlian')
|
subject: 'Keputusan Permohonan Keahlian KOPKB - TIDAK LULUS',
|
||||||
->markdown('membershipapplication::emails.failed', [
|
view: 'membershipapplication::emails.failed',
|
||||||
'name' => $this->application->applicant->name,
|
data: [
|
||||||
'applicationNumber' => $this->application->application_number,
|
'application' => $this->application,
|
||||||
'logoPath' => public_path('images/logo-kopkb.svg'),
|
'applicant' => $applicant,
|
||||||
]);
|
],
|
||||||
|
)->attach(
|
||||||
|
Storage::disk(Document::STORAGE_DISK)->path($this->resultLetter->path),
|
||||||
|
[
|
||||||
|
'as' => $this->resultLetter->name,
|
||||||
|
'mime' => $this->resultLetter->mime_type,
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,19 +2,29 @@
|
|||||||
|
|
||||||
namespace Modules\MembershipApplication\Emails;
|
namespace Modules\MembershipApplication\Emails;
|
||||||
|
|
||||||
|
use App\Models\Document;
|
||||||
|
use App\Notifications\Concerns\BuildsMailMessage;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Notifications\Messages\MailMessage;
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
use Illuminate\Notifications\Notification;
|
use Illuminate\Notifications\Notification;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Modules\MembershipApplication\Entities\MembershipApplication;
|
use Modules\MembershipApplication\Entities\MembershipApplication;
|
||||||
|
|
||||||
class MembershipApplicationPassedNotification extends Notification
|
class MembershipApplicationPassedNotification extends Notification implements ShouldQueue
|
||||||
{
|
{
|
||||||
|
use BuildsMailMessage;
|
||||||
use Queueable;
|
use Queueable;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
protected MembershipApplication $application
|
public MembershipApplication $application,
|
||||||
|
public Document $resultLetter,
|
||||||
|
public ?string $plainPassword = null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
public function via(object $notifiable): array
|
public function via(object $notifiable): array
|
||||||
{
|
{
|
||||||
return ['mail'];
|
return ['mail'];
|
||||||
@@ -23,13 +33,23 @@ class MembershipApplicationPassedNotification extends Notification
|
|||||||
public function toMail(object $notifiable): MailMessage
|
public function toMail(object $notifiable): MailMessage
|
||||||
{
|
{
|
||||||
$this->application->loadMissing('applicant');
|
$this->application->loadMissing('applicant');
|
||||||
|
$applicant = $this->application->applicant;
|
||||||
|
|
||||||
return (new MailMessage)
|
return $this->mailMessage(
|
||||||
->subject('Permohonan Keahlian Diluluskan')
|
subject: 'Keputusan Permohonan Keahlian KOPKB - LULUS',
|
||||||
->markdown('membershipapplication::emails.passed', [
|
view: 'membershipapplication::emails.passed',
|
||||||
'name' => $this->application->applicant->name,
|
data: [
|
||||||
'applicationNumber' => $this->application->application_number,
|
'application' => $this->application,
|
||||||
'logoPath' => public_path('images/logo-kopkb.svg'),
|
'applicant' => $applicant,
|
||||||
]);
|
'plainPassword' => $this->plainPassword,
|
||||||
|
'loginUrl' => rtrim(config('user.frontend_url'), '/'),
|
||||||
|
],
|
||||||
|
)->attach(
|
||||||
|
Storage::disk(Document::STORAGE_DISK)->path($this->resultLetter->path),
|
||||||
|
[
|
||||||
|
'as' => $this->resultLetter->name,
|
||||||
|
'mime' => $this->resultLetter->mime_type,
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace Modules\MembershipApplication\Emails;
|
namespace Modules\MembershipApplication\Emails;
|
||||||
|
|
||||||
|
use App\Notifications\Concerns\BuildsMailMessage;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Notifications\Messages\MailMessage;
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
use Illuminate\Notifications\Notification;
|
use Illuminate\Notifications\Notification;
|
||||||
@@ -9,6 +10,7 @@ use Modules\MembershipApplication\Entities\MembershipApplication;
|
|||||||
|
|
||||||
class MembershipApplicationSubmittedNotification extends Notification
|
class MembershipApplicationSubmittedNotification extends Notification
|
||||||
{
|
{
|
||||||
|
use BuildsMailMessage;
|
||||||
use Queueable;
|
use Queueable;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
@@ -24,9 +26,10 @@ class MembershipApplicationSubmittedNotification extends Notification
|
|||||||
{
|
{
|
||||||
$this->application->loadMissing(['applicant', 'heirs', 'documents', 'references.member']);
|
$this->application->loadMissing(['applicant', 'heirs', 'documents', 'references.member']);
|
||||||
|
|
||||||
return (new MailMessage)
|
return $this->mailMessage(
|
||||||
->subject('Pengesahan Permohonan Keahlian')
|
subject: 'Pengesahan Permohonan Keahlian',
|
||||||
->markdown('membershipapplication::emails.submitted', [
|
view: 'membershipapplication::emails.submitted',
|
||||||
|
data: [
|
||||||
'application' => $this->application,
|
'application' => $this->application,
|
||||||
'applicant' => $this->application->applicant,
|
'applicant' => $this->application->applicant,
|
||||||
'heirs' => $this->application->heirs,
|
'heirs' => $this->application->heirs,
|
||||||
@@ -37,7 +40,7 @@ class MembershipApplicationSubmittedNotification extends Notification
|
|||||||
'salary_slip' => 'Slip Gaji',
|
'salary_slip' => 'Slip Gaji',
|
||||||
'employer_letter' => 'Surat Pengesahan Majikan',
|
'employer_letter' => 'Surat Pengesahan Majikan',
|
||||||
],
|
],
|
||||||
'logoPath' => public_path('images/logo-kopkb.svg'),
|
],
|
||||||
]);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,13 @@
|
|||||||
|
|
||||||
namespace Modules\MembershipApplication\Entities;
|
namespace Modules\MembershipApplication\Entities;
|
||||||
|
|
||||||
|
use App\Models\Document;
|
||||||
use App\Traits\HasDocuments;
|
use App\Traits\HasDocuments;
|
||||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\MorphOne;
|
||||||
use Modules\Auth\Entities\User;
|
use Modules\Auth\Entities\User;
|
||||||
use Modules\MembershipApplication\Enums\ApplicationStatus;
|
use Modules\MembershipApplication\Enums\ApplicationStatus;
|
||||||
|
|
||||||
@@ -14,6 +16,14 @@ class MembershipApplication extends Model
|
|||||||
{
|
{
|
||||||
use HasDocuments, HasUuids;
|
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';
|
protected $table = 'membership_applications';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -65,4 +75,10 @@ class MembershipApplication extends Model
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(User::class);
|
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\AssignReferencesRequest;
|
||||||
use Modules\MembershipApplication\Http\Requests\BatchCompleteRequest;
|
use Modules\MembershipApplication\Http\Requests\BatchCompleteRequest;
|
||||||
use Modules\MembershipApplication\Http\Requests\BoardReviewRequest;
|
use Modules\MembershipApplication\Http\Requests\BoardReviewRequest;
|
||||||
|
use Modules\MembershipApplication\Http\Requests\CompleteMembershipApplicationRequest;
|
||||||
|
use Modules\MembershipApplication\Http\Requests\GenerateResultLetterRequest;
|
||||||
use Modules\MembershipApplication\Http\Requests\ManagementReviewRequest;
|
use Modules\MembershipApplication\Http\Requests\ManagementReviewRequest;
|
||||||
use Modules\MembershipApplication\Http\Requests\UpdateMembershipApplicationRequest;
|
use Modules\MembershipApplication\Http\Requests\UpdateMembershipApplicationRequest;
|
||||||
use Modules\MembershipApplication\Http\Requests\UploadMembershipApplicationDocumentRequest;
|
use Modules\MembershipApplication\Http\Requests\UploadMembershipApplicationDocumentRequest;
|
||||||
use Modules\MembershipApplication\Services\MembershipApplicationService;
|
use Modules\MembershipApplication\Services\MembershipApplicationService;
|
||||||
|
use Modules\MembershipApplication\Transformers\MembershipApplicationDocumentResource;
|
||||||
use Modules\MembershipApplication\Transformers\MembershipApplicationListResource;
|
use Modules\MembershipApplication\Transformers\MembershipApplicationListResource;
|
||||||
use Modules\MembershipApplication\Transformers\MembershipApplicationResource;
|
use Modules\MembershipApplication\Transformers\MembershipApplicationResource;
|
||||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
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);
|
$this->authorize('complete', $membershipApplication);
|
||||||
|
|
||||||
$application = $this->membershipApplicationService->complete(
|
$application = $this->membershipApplicationService->complete(
|
||||||
$membershipApplication,
|
$membershipApplication,
|
||||||
$request->user()
|
$request->user(),
|
||||||
|
$request->validated('board_meeting_reference'),
|
||||||
|
$request->validated('board_meeting_date'),
|
||||||
);
|
);
|
||||||
|
|
||||||
$message = $application->board_result === BoardResult::Pass->value
|
$message = $application->board_result === BoardResult::Pass->value
|
||||||
? 'Permohonan selesai. Akaun ahli telah dicipta dan e-mel dihantar.'
|
? 'Permohonan selesai. Surat keputusan dijana, akaun ahli dicipta dan e-mel dihantar.'
|
||||||
: 'Permohonan selesai. E-mel keputusan dihantar.';
|
: 'Permohonan selesai. Surat keputusan dijana dan e-mel dihantar.';
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
@@ -204,7 +209,9 @@ class MembershipApplicationController extends Controller
|
|||||||
|
|
||||||
$result = $this->membershipApplicationService->completeBatch(
|
$result = $this->membershipApplicationService->completeBatch(
|
||||||
$request->validated('application_ids'),
|
$request->validated('application_ids'),
|
||||||
$request->user()
|
$request->user(),
|
||||||
|
$request->validated('board_meeting_reference'),
|
||||||
|
$request->validated('board_meeting_date'),
|
||||||
);
|
);
|
||||||
|
|
||||||
$succeededCount = count($result['succeeded']);
|
$succeededCount = count($result['succeeded']);
|
||||||
@@ -243,6 +250,46 @@ class MembershipApplicationController extends Controller
|
|||||||
return $this->documentService->downloadDocument($document->id);
|
return $this->documentService->downloadDocument($document->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function deleteDocument(MembershipApplication $membershipApplication, string $documentId): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('update', $membershipApplication);
|
||||||
|
|
||||||
|
$application = $this->membershipApplicationService->deleteDocument(
|
||||||
|
$membershipApplication,
|
||||||
|
$documentId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Lampiran pentadbir berjaya dipadam.',
|
||||||
|
'data' => new MembershipApplicationResource($application),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function generateResultLetter(GenerateResultLetterRequest $request, MembershipApplication $membershipApplication,): JsonResponse {
|
||||||
|
$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
|
public function approvedLetter(Request $request, MembershipApplication $membershipApplication): Response
|
||||||
{
|
{
|
||||||
$this->authorize('view', $membershipApplication);
|
$this->authorize('view', $membershipApplication);
|
||||||
@@ -253,10 +300,23 @@ class MembershipApplicationController extends Controller
|
|||||||
abort(404, 'Permohonan tidak dijumpai.');
|
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';
|
$view = 'membershipapplication::pdf.approved-letter';
|
||||||
$data = ['application' => $application];
|
$data = $this->membershipApplicationService->getResultLetterViewData(
|
||||||
|
$application,
|
||||||
|
$boardMeetingReference,
|
||||||
|
$boardMeetingDate,
|
||||||
|
);
|
||||||
$format = $request->query('format', 'html');
|
$format = $request->query('format', 'html');
|
||||||
$filename = 'surat-kelulusan-'.$application->application_number.'.pdf';
|
$filename = 'surat-keputusan-'.$application->application_number.'.pdf';
|
||||||
|
|
||||||
if ($format === 'pdf') {
|
if ($format === 'pdf') {
|
||||||
return $this->letterService->pdfResponse($view, $data, $filename);
|
return $this->letterService->pdfResponse($view, $data, $filename);
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ class BatchCompleteRequest extends FormRequest
|
|||||||
return [
|
return [
|
||||||
'application_ids' => 'required|array|min:1|max:100',
|
'application_ids' => 'required|array|min:1|max:100',
|
||||||
'application_ids.*' => 'required|uuid|distinct|exists:membership_applications,id',
|
'application_ids.*' => 'required|uuid|distinct|exists:membership_applications,id',
|
||||||
|
'board_meeting_reference' => 'required|string|max:255',
|
||||||
|
'board_meeting_date' => 'required|date',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +35,10 @@ class BatchCompleteRequest extends FormRequest
|
|||||||
'application_ids.max' => 'Maksimum 100 permohonan setiap batch.',
|
'application_ids.max' => 'Maksimum 100 permohonan setiap batch.',
|
||||||
'application_ids.*.exists' => 'Salah satu permohonan tidak dijumpai.',
|
'application_ids.*.exists' => 'Salah satu permohonan tidak dijumpai.',
|
||||||
'application_ids.*.distinct' => 'Permohonan duplikat tidak dibenarkan.',
|
'application_ids.*.distinct' => 'Permohonan duplikat tidak dibenarkan.',
|
||||||
|
'board_meeting_reference.required' => 'Rujukan mesyuarat lembaga diperlukan.',
|
||||||
|
'board_meeting_reference.max' => 'Rujukan mesyuarat lembaga tidak boleh melebihi 255 aksara.',
|
||||||
|
'board_meeting_date.required' => 'Tarikh mesyuarat lembaga diperlukan.',
|
||||||
|
'board_meeting_date.date' => 'Tarikh mesyuarat lembaga tidak sah.',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\MembershipApplication\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class CompleteMembershipApplicationRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'board_meeting_reference' => 'required|string|max:255',
|
||||||
|
'board_meeting_date' => 'required|date',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'board_meeting_reference.required' => 'Rujukan mesyuarat lembaga diperlukan.',
|
||||||
|
'board_meeting_reference.max' => 'Rujukan mesyuarat lembaga tidak boleh melebihi 255 aksara.',
|
||||||
|
'board_meeting_date.required' => 'Tarikh mesyuarat lembaga diperlukan.',
|
||||||
|
'board_meeting_date.date' => 'Tarikh mesyuarat lembaga tidak sah.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.ic_copy' => 'required|file|mimes:pdf,jpg,jpeg,png|max:10240',
|
||||||
'documents.photo' => 'nullable|file|mimes:jpg,jpeg,png|max:10240',
|
'documents.photo' => 'nullable|file|mimes:jpg,jpeg,png|max:10240',
|
||||||
'documents.salary_slip' => 'nullable|file|mimes:pdf,jpg,jpeg,png|max:10240',
|
'documents.salary_slip' => 'nullable|file|mimes:pdf,jpg,jpeg,png|max:10240',
|
||||||
'documents.employer_letter' => 'nullable|file|mimes:pdf,jpg,jpeg,png|max:10240',
|
'documents.employer_letter' => 'required|file|mimes:pdf,jpg,jpeg,png|max:10240',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +89,8 @@ class StoreMembershipApplicationRequest extends FormRequest
|
|||||||
|
|
||||||
'documents.ic_copy.required' => 'Salinan kad pengenalan diperlukan.',
|
'documents.ic_copy.required' => 'Salinan kad pengenalan diperlukan.',
|
||||||
'documents.ic_copy.mimes' => 'Salinan kad pengenalan mestilah PDF, JPG, JPEG atau PNG.',
|
'documents.ic_copy.mimes' => 'Salinan kad pengenalan mestilah PDF, JPG, JPEG atau PNG.',
|
||||||
|
'documents.employer_letter.required' => 'Surat pengesahan majikan diperlukan.',
|
||||||
|
'documents.employer_letter.mimes' => 'Surat pengesahan majikan mestilah PDF, JPG, JPEG atau PNG.',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace Modules\MembershipApplication\Http\Requests;
|
|||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
use Modules\MembershipApplication\Entities\MembershipApplication;
|
||||||
|
|
||||||
class UploadMembershipApplicationDocumentRequest extends FormRequest
|
class UploadMembershipApplicationDocumentRequest extends FormRequest
|
||||||
{
|
{
|
||||||
@@ -18,7 +19,13 @@ class UploadMembershipApplicationDocumentRequest extends FormRequest
|
|||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'type' => ['required', Rule::in(['ic_copy', 'photo', 'salary_slip', 'employer_letter'])],
|
'type' => ['required', Rule::in([
|
||||||
|
'ic_copy',
|
||||||
|
'photo',
|
||||||
|
'salary_slip',
|
||||||
|
'employer_letter',
|
||||||
|
MembershipApplication::ADMIN_ATTACHMENT_DOCUMENT_TYPE,
|
||||||
|
])],
|
||||||
'file' => [
|
'file' => [
|
||||||
'required',
|
'required',
|
||||||
'file',
|
'file',
|
||||||
|
|||||||
@@ -65,6 +65,14 @@ class MembershipApplicationPolicy
|
|||||||
return $user->hasPermissionTo('tetapkan pencadang penyokong');
|
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.
|
* Admin: edit application details before completion.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ class MembershipApplicationServiceProvider extends ServiceProvider
|
|||||||
public function registerViews(): void
|
public function registerViews(): void
|
||||||
{
|
{
|
||||||
$viewPath = resource_path('views/modules/'.$this->nameLower);
|
$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']);
|
$this->publishes([$sourcePath => $viewPath], ['views', $this->nameLower.'-module-views']);
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class MembershipApplicationRepository implements MembershipApplicationRepository
|
|||||||
return MembershipApplication::with([
|
return MembershipApplication::with([
|
||||||
'applicant',
|
'applicant',
|
||||||
'heirs',
|
'heirs',
|
||||||
|
'user:id,member_number',
|
||||||
'references.member:id,name,ic_number',
|
'references.member:id,name,ic_number',
|
||||||
'documents',
|
'documents',
|
||||||
'reviews.reviewer:id,name',
|
'reviews.reviewer:id,name',
|
||||||
@@ -46,7 +47,14 @@ class MembershipApplicationRepository implements MembershipApplicationRepository
|
|||||||
$sortOrder = strtolower($sortOrder) === 'asc' ? 'asc' : 'desc';
|
$sortOrder = strtolower($sortOrder) === 'asc' ? 'asc' : 'desc';
|
||||||
|
|
||||||
$query = MembershipApplication::query()
|
$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);
|
->orderBy($sortBy, $sortOrder);
|
||||||
|
|
||||||
if ($status !== null && $status !== '') {
|
if ($status !== null && $status !== '') {
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
@component('mail::message')
|
@component('mail::message')
|
||||||
@if (! empty($logoPath) && file_exists($logoPath))
|
@include('emails.partials.header')
|
||||||
<div style="text-align: center; margin-bottom: 24px;">
|
|
||||||
<img src="{{ $message->embed($logoPath) }}" alt="{{ config('app.name') }}" style="max-height: 80px; width: auto;">
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
# Keputusan Permohonan Keahlian
|
# Keputusan Permohonan Keahlian
|
||||||
|
|
||||||
Assalamualaikum **{{ $name }}**,
|
Assalamualaikum **{{ $applicant->name }}**,
|
||||||
|
|
||||||
Permohonan keahlian anda (**{{ $applicationNumber }}**) **tidak diluluskan** pada peringkat lembaga.
|
Permohonan keahlian anda (**{{ $application->application_number }}**) **tidak diluluskan**.
|
||||||
|
|
||||||
|
Surat keputusan rasmi dilampirkan dalam e-mel ini.
|
||||||
|
|
||||||
Terima kasih atas minat anda. Untuk sebarang pertanyaan, sila hubungi pejabat koperasi.
|
Terima kasih atas minat anda. Untuk sebarang pertanyaan, sila hubungi pejabat koperasi.
|
||||||
|
|
||||||
Terima kasih,<br>
|
@include('emails.partials.footer')
|
||||||
{{ config('app.name') }}
|
|
||||||
@endcomponent
|
@endcomponent
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
@component('mail::message')
|
@component('mail::message')
|
||||||
@if (! empty($logoPath) && file_exists($logoPath))
|
@include('emails.partials.header')
|
||||||
<div style="text-align: center; margin-bottom: 24px;">
|
|
||||||
<img src="{{ $message->embed($logoPath) }}" alt="{{ config('app.name') }}" style="max-height: 80px; width: auto;">
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
# Keputusan Permohonan Keahlian
|
# Keputusan Permohonan Keahlian
|
||||||
|
|
||||||
Assalamualaikum **{{ $name }}**,
|
Assalamualaikum **{{ $applicant->name }}**,
|
||||||
|
|
||||||
Permohonan keahlian anda (**{{ $applicationNumber }}**) **diluluskan** pada peringkat lembaga.
|
Permohonan keahlian anda (**{{ $application->application_number }}**) **diluluskan**.
|
||||||
|
|
||||||
|
Surat keputusan rasmi dilampirkan dalam e-mel ini.
|
||||||
|
|
||||||
|
@if ($plainPassword)
|
||||||
|
Akaun portal anda telah didaftarkan. Maklumat log masuk adalah seperti berikut:
|
||||||
|
|
||||||
|
- **E-mel:** {{ $applicant->email }}
|
||||||
|
- **Kata laluan:** {{ $plainPassword }}
|
||||||
|
|
||||||
|
Sila log masuk di [{{ $loginUrl }}]({{ $loginUrl }}) dan tukar kata laluan anda selepas log masuk kali pertama.
|
||||||
|
@endif
|
||||||
|
|
||||||
Terima kasih atas minat anda. Untuk sebarang pertanyaan, sila hubungi pejabat koperasi.
|
Terima kasih atas minat anda. Untuk sebarang pertanyaan, sila hubungi pejabat koperasi.
|
||||||
|
|
||||||
Terima kasih,<br>
|
@include('emails.partials.footer')
|
||||||
{{ config('app.name') }}
|
|
||||||
@endcomponent
|
@endcomponent
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
@component('mail::message')
|
@component('mail::message')
|
||||||
@if (! empty($logoPath) && file_exists($logoPath))
|
@include('emails.partials.header')
|
||||||
<div style="text-align: center; margin-bottom: 24px;">
|
|
||||||
<img src="{{ $message->embed($logoPath) }}" alt="{{ config('app.name') }}" style="max-height: 80px; width: auto;">
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
# Pengesahan Permohonan Keahlian
|
# Pengesahan Permohonan Keahlian
|
||||||
|
|
||||||
@@ -74,6 +70,5 @@ Tiada dokumen.
|
|||||||
|
|
||||||
Anda akan menerima emel apabila keputusan permohonan tersedia.
|
Anda akan menerima emel apabila keputusan permohonan tersedia.
|
||||||
|
|
||||||
Terima kasih,<br>
|
@include('emails.partials.footer')
|
||||||
{{ config('app.name') }}
|
|
||||||
@endcomponent
|
@endcomponent
|
||||||
|
|||||||
@@ -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')
|
@extends('pdf.layouts.letter')
|
||||||
|
|
||||||
{{-- @section('letter-reference', $application->application_number) --}}
|
@section('letter-title', 'Surat')
|
||||||
{{-- @section('letter-date', ...) --}}
|
|
||||||
{{-- @section('letter-recipient') ... @endsection --}}
|
@section('letter-reference', $application->application_number)
|
||||||
{{-- @section('letter-subject') PERKARA: ... @endsection --}}
|
|
||||||
{{-- @section('letter-body') ... @endsection --}}
|
@section('letter-date', now()->translatedFormat('d F Y'))
|
||||||
{{-- @section('letter-signature') ... @endsection --}}
|
|
||||||
|
@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
|
// Membership application upload document routes
|
||||||
Route::post('membership-applications/{membershipApplication}/documents', [MembershipApplicationController::class, 'uploadDocument'])->name('membership-application.upload-document');
|
Route::post('membership-applications/{membershipApplication}/documents', [MembershipApplicationController::class, 'uploadDocument'])->name('membership-application.upload-document');
|
||||||
|
Route::delete('membership-applications/{membershipApplication}/documents/{documentId}', [MembershipApplicationController::class, 'deleteDocument'])->name('membership-application.delete-document');
|
||||||
// download document routes
|
// download document routes
|
||||||
Route::get('membership-applications/{membershipApplication}/documents/{documentId}/download', [MembershipApplicationController::class, 'downloadDocument'])->name('membership-application.download-document');
|
Route::get('membership-applications/{membershipApplication}/documents/{documentId}/download', [MembershipApplicationController::class, 'downloadDocument'])->name('membership-application.download-document');
|
||||||
|
|
||||||
// approved letter preview (html or pdf)
|
// approved letter preview (html or pdf)
|
||||||
Route::get('membership-applications/{membershipApplication}/approved-letter', [MembershipApplicationController::class, 'approvedLetter'])->name('membership-application.approved-letter');
|
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;
|
namespace Modules\MembershipApplication\Services;
|
||||||
|
|
||||||
|
use App\Models\Document;
|
||||||
use App\Services\DocumentService;
|
use App\Services\DocumentService;
|
||||||
|
use App\Services\LetterService;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Http\UploadedFile;
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Facades\Notification;
|
use Illuminate\Support\Facades\Notification;
|
||||||
@@ -30,6 +33,7 @@ class MembershipApplicationService
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
protected MembershipApplicationRepositoryInterface $repository,
|
protected MembershipApplicationRepositoryInterface $repository,
|
||||||
protected DocumentService $documentService,
|
protected DocumentService $documentService,
|
||||||
|
protected LetterService $letterService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -135,15 +139,38 @@ class MembershipApplicationService
|
|||||||
|
|
||||||
$this->documentService->validateFile($file);
|
$this->documentService->validateFile($file);
|
||||||
|
|
||||||
$application->documentsOfType($type)->get()->each(
|
if ($type !== MembershipApplication::ADMIN_ATTACHMENT_DOCUMENT_TYPE) {
|
||||||
fn ($document) => $this->documentService->deleteDocument($document->id)
|
$application->documentsOfType($type)->get()->each(
|
||||||
);
|
fn ($document) => $this->documentService->deleteDocument($document->id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$this->documentService->uploadDocument($application, $file, $type);
|
$this->documentService->uploadDocument($application, $file, $type);
|
||||||
|
|
||||||
return $this->repository->findByIdWithRelations($application->id);
|
return $this->repository->findByIdWithRelations($application->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function deleteDocument(MembershipApplication $application, string $documentId): MembershipApplication
|
||||||
|
{
|
||||||
|
if ($application->status === ApplicationStatus::Completed) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'status' => ['Permohonan yang telah selesai tidak boleh dikemaskini.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$document = $application->documents()->findOrFail($documentId);
|
||||||
|
|
||||||
|
if ($document->type !== MembershipApplication::ADMIN_ATTACHMENT_DOCUMENT_TYPE) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'document' => ['Hanya lampiran pentadbir boleh dipadam.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->documentService->deleteDocument($document->id);
|
||||||
|
|
||||||
|
return $this->repository->findByIdWithRelations($application->id);
|
||||||
|
}
|
||||||
|
|
||||||
public function managementReview(MembershipApplication $application, ManagementDecision $decision, ?string $remarks, User $reviewer): MembershipApplication {
|
public function managementReview(MembershipApplication $application, ManagementDecision $decision, ?string $remarks, User $reviewer): MembershipApplication {
|
||||||
if ($application->status !== ApplicationStatus::Submitted) {
|
if ($application->status !== ApplicationStatus::Submitted) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
@@ -174,7 +201,7 @@ class MembershipApplicationService
|
|||||||
{
|
{
|
||||||
if ($application->status !== ApplicationStatus::PendingBoard) {
|
if ($application->status !== ApplicationStatus::PendingBoard) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'status' => ['Permohonan ini tidak boleh disemak pada peringkat lembaga.'],
|
'status' => ['Permohonan ini tidak boleh disemak pada peringkat ahli lembaga.'],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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) {
|
if ($application->status !== ApplicationStatus::PendingNotification) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'status' => ['Permohonan ini tidak boleh diselesaikan pada masa ini.'],
|
'status' => ['Permohonan ini tidak boleh diselesaikan pada masa ini.'],
|
||||||
@@ -242,13 +273,19 @@ class MembershipApplicationService
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return DB::transaction(function () use ($application) {
|
return DB::transaction(function () use ($application, $boardMeetingReference, $boardMeetingDate) {
|
||||||
|
$plainPassword = null;
|
||||||
|
|
||||||
if ($application->board_result === BoardResult::Pass->value && ! $application->user_id) {
|
if ($application->board_result === BoardResult::Pass->value && ! $application->user_id) {
|
||||||
$user = $this->createMemberFromApplication($application);
|
$member = $this->createMemberFromApplication($application);
|
||||||
$application->update(['user_id' => $user->id]);
|
$application->update(['user_id' => $member['user']->id]);
|
||||||
|
$plainPassword = $member['plainPassword'];
|
||||||
|
$application->refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->sendResultNotification($application);
|
$resultLetter = $this->storeResultLetter($application, $boardMeetingReference, $boardMeetingDate);
|
||||||
|
|
||||||
|
$this->sendResultNotification($application, $resultLetter, $plainPassword);
|
||||||
|
|
||||||
$application->update([
|
$application->update([
|
||||||
'status' => ApplicationStatus::Completed,
|
'status' => ApplicationStatus::Completed,
|
||||||
@@ -266,8 +303,12 @@ class MembershipApplicationService
|
|||||||
* failed: list<array{id: string, application_number: string|null, message: string}>
|
* failed: list<array{id: string, application_number: string|null, message: string}>
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
public function completeBatch(array $applicationIds, User $processedBy): array
|
public function completeBatch(
|
||||||
{
|
array $applicationIds,
|
||||||
|
User $processedBy,
|
||||||
|
string $boardMeetingReference,
|
||||||
|
string $boardMeetingDate,
|
||||||
|
): array {
|
||||||
$succeeded = [];
|
$succeeded = [];
|
||||||
$failed = [];
|
$failed = [];
|
||||||
|
|
||||||
@@ -285,7 +326,12 @@ class MembershipApplicationService
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$succeeded[] = $this->complete($application, $processedBy);
|
$succeeded[] = $this->complete(
|
||||||
|
$application,
|
||||||
|
$processedBy,
|
||||||
|
$boardMeetingReference,
|
||||||
|
$boardMeetingDate,
|
||||||
|
);
|
||||||
} catch (ValidationException $e) {
|
} catch (ValidationException $e) {
|
||||||
$failed[] = [
|
$failed[] = [
|
||||||
'id' => $id,
|
'id' => $id,
|
||||||
@@ -301,7 +347,113 @@ class MembershipApplicationService
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function createMemberFromApplication(MembershipApplication $application): User
|
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 F 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{user: User, plainPassword: string}
|
||||||
|
*/
|
||||||
|
protected function createMemberFromApplication(MembershipApplication $application): array
|
||||||
{
|
{
|
||||||
$application->loadMissing(['applicant', 'heirs']);
|
$application->loadMissing(['applicant', 'heirs']);
|
||||||
$applicant = $application->applicant;
|
$applicant = $application->applicant;
|
||||||
@@ -312,10 +464,12 @@ class MembershipApplicationService
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$plainPassword = Str::password(16);
|
||||||
|
|
||||||
$user = User::create([
|
$user = User::create([
|
||||||
'name' => $applicant->name,
|
'name' => $applicant->name,
|
||||||
'email' => $applicant->email,
|
'email' => $applicant->email,
|
||||||
'password' => Hash::make(Str::password(16)),
|
'password' => Hash::make($plainPassword),
|
||||||
'ic_number' => $applicant->ic_number,
|
'ic_number' => $applicant->ic_number,
|
||||||
'phone_number' => $applicant->phone_number,
|
'phone_number' => $applicant->phone_number,
|
||||||
'position' => $applicant->current_position,
|
'position' => $applicant->current_position,
|
||||||
@@ -361,7 +515,10 @@ class MembershipApplicationService
|
|||||||
|
|
||||||
app(EmailVerificationOtpService::class)->send($user);
|
app(EmailVerificationOtpService::class)->send($user);
|
||||||
|
|
||||||
return $user;
|
return [
|
||||||
|
'user' => $user,
|
||||||
|
'plainPassword' => $plainPassword,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function lookupMemberByIcNumber(string $icNumber): ?User
|
public function lookupMemberByIcNumber(string $icNumber): ?User
|
||||||
@@ -386,20 +543,33 @@ class MembershipApplicationService
|
|||||||
->notify(new MembershipApplicationSubmittedNotification($application));
|
->notify(new MembershipApplicationSubmittedNotification($application));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function sendResultNotification(MembershipApplication $application): void
|
protected function sendResultNotification(
|
||||||
{
|
MembershipApplication $application,
|
||||||
|
Document $resultLetter,
|
||||||
|
?string $plainPassword = null,
|
||||||
|
): void {
|
||||||
$application->loadMissing('applicant');
|
$application->loadMissing('applicant');
|
||||||
|
|
||||||
$notification = $application->board_result === BoardResult::Pass->value
|
$notification = $application->board_result === BoardResult::Pass->value
|
||||||
? new MembershipApplicationPassedNotification($application)
|
? new MembershipApplicationPassedNotification($application, $resultLetter, $plainPassword)
|
||||||
: new MembershipApplicationFailedNotification($application);
|
: new MembershipApplicationFailedNotification($application, $resultLetter);
|
||||||
|
|
||||||
Notification::route('mail', $application->applicant->email)->notify($notification);
|
Notification::route('mail', $application->applicant->email)->notify($notification);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function generateMemberNumber(): int
|
protected function generateMemberNumber(): int
|
||||||
{
|
{
|
||||||
return ((int) User::max('member_number')) + 1;
|
$latestUser = User::query()
|
||||||
|
->whereNotNull('member_number')
|
||||||
|
->orderByDesc('member_number')
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($latestUser === null && DB::getDriverName() === 'pgsql') {
|
||||||
|
DB::selectOne('SELECT pg_advisory_xact_lock(?) AS locked', [742891035]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ((int) ($latestUser?->member_number ?? 0)) + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ class MembershipApplicationListResource extends JsonResource
|
|||||||
'status' => $this->status?->value ?? $this->status,
|
'status' => $this->status?->value ?? $this->status,
|
||||||
'board_result' => $this->board_result ?: null,
|
'board_result' => $this->board_result ?: null,
|
||||||
'submitted_at' => $this->submitted_at,
|
'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 () => [
|
'applicant' => $this->whenLoaded('applicant', fn () => [
|
||||||
'name' => $this->applicant->name,
|
'name' => $this->applicant->name,
|
||||||
'email' => $this->applicant->email,
|
'email' => $this->applicant->email,
|
||||||
|
|||||||
@@ -2,4 +2,7 @@
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
'name' => 'User',
|
'name' => 'User',
|
||||||
|
'public_profile_token_ttl_days' => (int) env('PUBLIC_PROFILE_TOKEN_TTL_DAYS', 90),
|
||||||
|
'frontend_url' => rtrim(env('FRONTEND_URL', env('APP_URL', 'http://localhost')), '/'),
|
||||||
|
'digital_card_logo_path' => env('DIGITAL_CARD_LOGO_PATH', public_path('images/logo/logo-original.svg')),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Modules\Auth\Entities\User;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->string('public_profile_token', 64)->nullable()->unique()->after('member_type');
|
||||||
|
});
|
||||||
|
|
||||||
|
User::withTrashed()
|
||||||
|
->whereNull('public_profile_token')
|
||||||
|
->cursor()
|
||||||
|
->each(function (User $user) {
|
||||||
|
$user->forceFill([
|
||||||
|
'public_profile_token' => Str::random(48),
|
||||||
|
])->saveQuietly();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropUnique(['public_profile_token']);
|
||||||
|
$table->dropColumn('public_profile_token');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Modules\Auth\Entities\User;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->timestamp('public_profile_token_expires_at')->nullable()->after('public_profile_token');
|
||||||
|
});
|
||||||
|
|
||||||
|
$expiryDays = (int) config('user.public_profile_token_ttl_days', 90);
|
||||||
|
|
||||||
|
User::withTrashed()
|
||||||
|
->whereNotNull('public_profile_token')
|
||||||
|
->whereNull('public_profile_token_expires_at')
|
||||||
|
->cursor()
|
||||||
|
->each(function (User $user) use ($expiryDays) {
|
||||||
|
$user->forceFill([
|
||||||
|
'public_profile_token_expires_at' => now()->addDays($expiryDays),
|
||||||
|
])->saveQuietly();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('public_profile_token_expires_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->timestamp('onboarding_completed_at')->nullable()->after('birth_place');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('onboarding_completed_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->date('leave_date')->nullable()->after('join_date');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('leave_date');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\User\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Modules\User\Services\MemberDigitalCardService;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class MemberDigitalCardController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected MemberDigitalCardService $memberDigitalCardService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function download(Request $request): Response|JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'side' => 'sometimes|in:depan,belakang',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = $request->user();
|
||||||
|
$side = $validated['side'] ?? 'depan';
|
||||||
|
|
||||||
|
if ($user->status !== 'active') {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Kad digital hanya tersedia untuk anggota aktif.',
|
||||||
|
'code' => 'digital_card_inactive',
|
||||||
|
'data' => null,
|
||||||
|
], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$png = $this->memberDigitalCardService->renderPng($user, $side);
|
||||||
|
$memberNumber = $user->member_number ?: 'anggota';
|
||||||
|
$filename = sprintf('kad-digital-%s-%s.png', $memberNumber, $side);
|
||||||
|
|
||||||
|
return response($png, Response::HTTP_OK, [
|
||||||
|
'Content-Type' => 'image/png',
|
||||||
|
'Content-Disposition' => 'attachment; filename="'.$filename.'"',
|
||||||
|
'Cache-Control' => 'no-store, private',
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $exception) {
|
||||||
|
Log::error('Failed to render member digital card.', [
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'side' => $side,
|
||||||
|
'message' => $exception->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Gagal menjana kad digital. Sila cuba lagi.',
|
||||||
|
'code' => 'digital_card_render_failed',
|
||||||
|
'data' => null,
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\User\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Modules\User\Services\PublicMemberProfileService;
|
||||||
|
use Modules\User\Transformers\PublicMemberProfileResource;
|
||||||
|
|
||||||
|
class PublicMemberProfileController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected PublicMemberProfileService $publicMemberProfileService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function show(string $token): JsonResponse
|
||||||
|
{
|
||||||
|
$member = $this->publicMemberProfileService->findMemberByToken($token);
|
||||||
|
|
||||||
|
if (! $member) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Anggota tidak dijumpai atau tidak sah.',
|
||||||
|
'code' => 'public_profile_not_found',
|
||||||
|
'data' => null,
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$member = $this->publicMemberProfileService->loadVerifiableRelations($member);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Anggota disahkan.',
|
||||||
|
'data' => new PublicMemberProfileResource($member),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,7 +44,7 @@ class UserController extends BaseCrudController
|
|||||||
$validated['uuid'] = Str::uuid();
|
$validated['uuid'] = Str::uuid();
|
||||||
$validated['password'] = Hash::make('suteraselamanya');
|
$validated['password'] = Hash::make('suteraselamanya');
|
||||||
|
|
||||||
return $validated;
|
return $this->applyLeaveDateForStatus($validated);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,6 +58,25 @@ class UserController extends BaseCrudController
|
|||||||
unset($validated['email']);
|
unset($validated['email']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return $this->applyLeaveDateForStatus($validated, $user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set or clear leave_date based on membership status transitions.
|
||||||
|
*/
|
||||||
|
protected function applyLeaveDateForStatus(array $validated, ?User $user = null): array
|
||||||
|
{
|
||||||
|
$newStatus = $validated['status'] ?? $user?->status;
|
||||||
|
$currentStatus = $user?->status;
|
||||||
|
|
||||||
|
if ($newStatus === 'inactive') {
|
||||||
|
if ($currentStatus !== 'inactive') {
|
||||||
|
$validated['leave_date'] = $validated['leave_date'] ?? now()->toDateString();
|
||||||
|
}
|
||||||
|
} elseif (in_array($newStatus, ['active', 'pending'], true)) {
|
||||||
|
$validated['leave_date'] = null;
|
||||||
|
}
|
||||||
|
|
||||||
return $validated;
|
return $validated;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,6 +146,17 @@ class UserController extends BaseCrudController
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function completeOnboarding(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $this->userService->completeOnboarding($request->user());
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'data' => new UserResource($user),
|
||||||
|
'message' => 'Onboarding completed.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function updatePassword(Request $request): JsonResponse
|
public function updatePassword(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
@@ -160,13 +190,27 @@ class UserController extends BaseCrudController
|
|||||||
$this->authorize('viewAny', $this->modelClass);
|
$this->authorize('viewAny', $this->modelClass);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
$request->validate([
|
||||||
|
'join_date_from' => 'nullable|date',
|
||||||
|
'join_date_to' => 'nullable|date',
|
||||||
|
'leave_date_from' => 'nullable|date',
|
||||||
|
'leave_date_to' => 'nullable|date',
|
||||||
|
]);
|
||||||
|
|
||||||
$perPage = min((int) $request->get('per_page', 10), 500);
|
$perPage = min((int) $request->get('per_page', 10), 500);
|
||||||
|
$dateFilters = array_filter([
|
||||||
|
'join_date_from' => $request->get('join_date_from'),
|
||||||
|
'join_date_to' => $request->get('join_date_to'),
|
||||||
|
'leave_date_from' => $request->get('leave_date_from'),
|
||||||
|
'leave_date_to' => $request->get('leave_date_to'),
|
||||||
|
]);
|
||||||
$items = $this->userService->getPaginatedList(
|
$items = $this->userService->getPaginatedList(
|
||||||
$perPage,
|
$perPage,
|
||||||
$request->get('search', ''),
|
$request->get('search', ''),
|
||||||
$request->get('status', ''),
|
$request->get('status', ''),
|
||||||
$request->get('sort_by', 'id'),
|
$request->get('sort_by', 'id'),
|
||||||
$request->get('sort_order', 'asc')
|
$request->get('sort_order', 'asc'),
|
||||||
|
$dateFilters
|
||||||
);
|
);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
@@ -225,6 +269,43 @@ class UserController extends BaseCrudController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function stats(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->authorize('viewAny', $this->modelClass);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$request->validate([
|
||||||
|
'join_date_from' => 'nullable|date',
|
||||||
|
'join_date_to' => 'nullable|date',
|
||||||
|
'leave_date_from' => 'nullable|date',
|
||||||
|
'leave_date_to' => 'nullable|date',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$dateFilters = array_filter([
|
||||||
|
'join_date_from' => $request->get('join_date_from'),
|
||||||
|
'join_date_to' => $request->get('join_date_to'),
|
||||||
|
'leave_date_from' => $request->get('leave_date_from'),
|
||||||
|
'leave_date_to' => $request->get('leave_date_to'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$stats = $this->userService->getListStats(
|
||||||
|
$request->get('search', ''),
|
||||||
|
$request->get('status', ''),
|
||||||
|
$dateFilters
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'data' => $stats,
|
||||||
|
'message' => 'User stats retrieved successfully.',
|
||||||
|
]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
Log::error("Error fetching {$this->resourceNamePlural} stats: ".$e->getMessage());
|
||||||
|
|
||||||
|
return $this->errorResponse('Failed to retrieve user stats.', 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function restore(string $id): JsonResponse
|
public function restore(string $id): JsonResponse
|
||||||
{
|
{
|
||||||
$this->authorize('restore', $this->modelClass);
|
$this->authorize('restore', $this->modelClass);
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ class UserRequest extends FormRequest
|
|||||||
'member_number' => 'required|integer',
|
'member_number' => 'required|integer',
|
||||||
'member_type' => 'required|string|max:255',
|
'member_type' => 'required|string|max:255',
|
||||||
'join_date' => 'nullable|date',
|
'join_date' => 'nullable|date',
|
||||||
|
'leave_date' => 'nullable|date',
|
||||||
'birth_date' => 'nullable|date',
|
'birth_date' => 'nullable|date',
|
||||||
'birth_place' => 'nullable|string|max:255',
|
'birth_place' => 'nullable|string|max:255',
|
||||||
];
|
];
|
||||||
@@ -74,6 +75,7 @@ class UserRequest extends FormRequest
|
|||||||
'member_number' => 'required|integer',
|
'member_number' => 'required|integer',
|
||||||
'member_type' => 'required|string|max:255',
|
'member_type' => 'required|string|max:255',
|
||||||
'join_date' => 'nullable|date',
|
'join_date' => 'nullable|date',
|
||||||
|
'leave_date' => 'nullable|date',
|
||||||
'birth_date' => 'nullable|date',
|
'birth_date' => 'nullable|date',
|
||||||
'birth_place' => 'nullable|string|max:255',
|
'birth_place' => 'nullable|string|max:255',
|
||||||
];
|
];
|
||||||
@@ -100,6 +102,7 @@ class UserRequest extends FormRequest
|
|||||||
'member_number' => 'required|integer',
|
'member_number' => 'required|integer',
|
||||||
'member_type' => 'required|string|max:255',
|
'member_type' => 'required|string|max:255',
|
||||||
'join_date' => 'required|date',
|
'join_date' => 'required|date',
|
||||||
|
'leave_date' => 'nullable|date',
|
||||||
'birth_date' => 'required|date',
|
'birth_date' => 'required|date',
|
||||||
'birth_place' => 'required|string|max:255',
|
'birth_place' => 'required|string|max:255',
|
||||||
];
|
];
|
||||||
@@ -136,6 +139,7 @@ class UserRequest extends FormRequest
|
|||||||
'member_type.max' => 'Jenis anggota tidak boleh melebihi 255 aksara.',
|
'member_type.max' => 'Jenis anggota tidak boleh melebihi 255 aksara.',
|
||||||
'join_date.required' => 'Tarikh join diperlukan.',
|
'join_date.required' => 'Tarikh join diperlukan.',
|
||||||
'join_date.date' => 'Tarikh join tidak sah.',
|
'join_date.date' => 'Tarikh join tidak sah.',
|
||||||
|
'leave_date.date' => 'Tarikh berhenti tidak sah.',
|
||||||
'birth_date.required' => 'Tarikh lahir diperlukan.',
|
'birth_date.required' => 'Tarikh lahir diperlukan.',
|
||||||
'birth_date.date' => 'Tarikh lahir tidak sah.',
|
'birth_date.date' => 'Tarikh lahir tidak sah.',
|
||||||
'birth_place.required' => 'Tempat lahir diperlukan.',
|
'birth_place.required' => 'Tempat lahir diperlukan.',
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ namespace Modules\User\Providers;
|
|||||||
use Illuminate\Support\Facades\Blade;
|
use Illuminate\Support\Facades\Blade;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Illuminate\Cache\RateLimiting\Limit;
|
||||||
use Modules\Auth\Entities\User;
|
use Modules\Auth\Entities\User;
|
||||||
use Modules\User\Policies\UserPolicy;
|
use Modules\User\Policies\UserPolicy;
|
||||||
use Modules\User\Entities\Address;
|
use Modules\User\Entities\Address;
|
||||||
@@ -34,6 +36,7 @@ class UserServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
|
$this->registerRateLimiters();
|
||||||
$this->registerCommands();
|
$this->registerCommands();
|
||||||
$this->registerCommandSchedules();
|
$this->registerCommandSchedules();
|
||||||
$this->registerTranslations();
|
$this->registerTranslations();
|
||||||
@@ -96,6 +99,13 @@ class UserServiceProvider extends ServiceProvider
|
|||||||
Gate::policy(Heir::class, HeirPolicy::class);
|
Gate::policy(Heir::class, HeirPolicy::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function registerRateLimiters(): void
|
||||||
|
{
|
||||||
|
RateLimiter::for('public-member-profile', function ($request) {
|
||||||
|
return Limit::perMinute(60)->by($request->ip());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register commands in the format of Command::class
|
* Register commands in the format of Command::class
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ interface UserRepositoryInterface
|
|||||||
string $search = '',
|
string $search = '',
|
||||||
string $status = '',
|
string $status = '',
|
||||||
string $sortBy = 'name',
|
string $sortBy = 'name',
|
||||||
string $sortOrder = 'asc'
|
string $sortOrder = 'asc',
|
||||||
|
array $dateFilters = []
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,6 +55,17 @@ interface UserRepositoryInterface
|
|||||||
string $sortOrder = 'desc'
|
string $sortOrder = 'desc'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get stats for the Users list (filtered).
|
||||||
|
*
|
||||||
|
* @return array{total: int, joined_this_month: int}
|
||||||
|
*/
|
||||||
|
public function getListStats(
|
||||||
|
string $search = '',
|
||||||
|
string $status = '',
|
||||||
|
array $dateFilters = []
|
||||||
|
): array;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find soft-deleted User by ID
|
* Find soft-deleted User by ID
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace Modules\User\Repositories;
|
namespace Modules\User\Repositories;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
use Modules\Auth\Entities\User;
|
use Modules\Auth\Entities\User;
|
||||||
use Modules\User\Repositories\Contracts\UserRepositoryInterface;
|
use Modules\User\Repositories\Contracts\UserRepositoryInterface;
|
||||||
|
|
||||||
@@ -26,6 +27,25 @@ class UserRepository implements UserRepositoryInterface
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function applyDateRangeFilters($query, array $dateFilters): void
|
||||||
|
{
|
||||||
|
if (! empty($dateFilters['join_date_from'])) {
|
||||||
|
$query->whereDate('join_date', '>=', $dateFilters['join_date_from']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($dateFilters['join_date_to'])) {
|
||||||
|
$query->whereDate('join_date', '<=', $dateFilters['join_date_to']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($dateFilters['leave_date_from'])) {
|
||||||
|
$query->whereDate('leave_date', '>=', $dateFilters['leave_date_from']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($dateFilters['leave_date_to'])) {
|
||||||
|
$query->whereDate('leave_date', '<=', $dateFilters['leave_date_to']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all Users with pagination and search
|
* Get all Users with pagination and search
|
||||||
*/
|
*/
|
||||||
@@ -46,7 +66,8 @@ class UserRepository implements UserRepositoryInterface
|
|||||||
string $search = '',
|
string $search = '',
|
||||||
string $status = '',
|
string $status = '',
|
||||||
string $sortBy = 'name',
|
string $sortBy = 'name',
|
||||||
string $sortOrder = 'asc'
|
string $sortOrder = 'asc',
|
||||||
|
array $dateFilters = []
|
||||||
) {
|
) {
|
||||||
$allowedSortColumns = [
|
$allowedSortColumns = [
|
||||||
'id',
|
'id',
|
||||||
@@ -55,6 +76,8 @@ class UserRepository implements UserRepositoryInterface
|
|||||||
'position',
|
'position',
|
||||||
'status',
|
'status',
|
||||||
'member_number',
|
'member_number',
|
||||||
|
'join_date',
|
||||||
|
'leave_date',
|
||||||
'created_at',
|
'created_at',
|
||||||
'deleted_at',
|
'deleted_at',
|
||||||
];
|
];
|
||||||
@@ -63,7 +86,18 @@ class UserRepository implements UserRepositoryInterface
|
|||||||
|
|
||||||
$query = User::excludeDevelopersUnlessDeveloper()
|
$query = User::excludeDevelopersUnlessDeveloper()
|
||||||
->with([
|
->with([
|
||||||
'roles:id,name,guard_name'
|
'roles:id,name,guard_name',
|
||||||
|
'employments' => function ($q) {
|
||||||
|
$q->select([
|
||||||
|
'id',
|
||||||
|
'user_id',
|
||||||
|
'company_name',
|
||||||
|
'is_current',
|
||||||
|
'start_date',
|
||||||
|
])
|
||||||
|
->orderByDesc('is_current')
|
||||||
|
->orderByDesc('start_date');
|
||||||
|
},
|
||||||
])
|
])
|
||||||
->orderBy($sortBy, $sortOrder);
|
->orderBy($sortBy, $sortOrder);
|
||||||
|
|
||||||
@@ -73,6 +107,8 @@ class UserRepository implements UserRepositoryInterface
|
|||||||
$query->where('status', $status);
|
$query->where('status', $status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->applyDateRangeFilters($query, $dateFilters);
|
||||||
|
|
||||||
return $query->paginate($perPage);
|
return $query->paginate($perPage);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +129,8 @@ class UserRepository implements UserRepositoryInterface
|
|||||||
'position',
|
'position',
|
||||||
'status',
|
'status',
|
||||||
'member_number',
|
'member_number',
|
||||||
|
'join_date',
|
||||||
|
'leave_date',
|
||||||
'created_at',
|
'created_at',
|
||||||
'deleted_at',
|
'deleted_at',
|
||||||
];
|
];
|
||||||
@@ -103,6 +141,17 @@ class UserRepository implements UserRepositoryInterface
|
|||||||
->excludeDevelopersUnlessDeveloper()
|
->excludeDevelopersUnlessDeveloper()
|
||||||
->with([
|
->with([
|
||||||
'roles:id,name,guard_name',
|
'roles:id,name,guard_name',
|
||||||
|
'employments' => function ($q) {
|
||||||
|
$q->select([
|
||||||
|
'id',
|
||||||
|
'user_id',
|
||||||
|
'company_name',
|
||||||
|
'is_current',
|
||||||
|
'start_date',
|
||||||
|
])
|
||||||
|
->orderByDesc('is_current')
|
||||||
|
->orderByDesc('start_date');
|
||||||
|
},
|
||||||
])
|
])
|
||||||
->orderBy($sortBy, $sortOrder);
|
->orderBy($sortBy, $sortOrder);
|
||||||
|
|
||||||
@@ -115,6 +164,36 @@ class UserRepository implements UserRepositoryInterface
|
|||||||
return $query->paginate($perPage);
|
return $query->paginate($perPage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getListStats(
|
||||||
|
string $search = '',
|
||||||
|
string $status = '',
|
||||||
|
array $dateFilters = []
|
||||||
|
): array {
|
||||||
|
$baseQuery = User::excludeDevelopersUnlessDeveloper();
|
||||||
|
|
||||||
|
$this->applySearch($baseQuery, $search);
|
||||||
|
|
||||||
|
if (! empty($status)) {
|
||||||
|
$baseQuery->where('status', $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->applyDateRangeFilters($baseQuery, $dateFilters);
|
||||||
|
|
||||||
|
$total = (int) (clone $baseQuery)->count();
|
||||||
|
|
||||||
|
$monthStart = Carbon::now()->startOfMonth()->toDateString();
|
||||||
|
$monthEnd = Carbon::now()->endOfMonth()->toDateString();
|
||||||
|
$joinedThisMonth = (int) (clone $baseQuery)
|
||||||
|
->whereDate('join_date', '>=', $monthStart)
|
||||||
|
->whereDate('join_date', '<=', $monthEnd)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total' => $total,
|
||||||
|
'joined_this_month' => $joinedThisMonth,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all Users with their relationships and search
|
* Get all Users with their relationships and search
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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,9 +8,18 @@ use Modules\User\Http\Controllers\EmploymentController;
|
|||||||
use Modules\User\Http\Controllers\BankController;
|
use Modules\User\Http\Controllers\BankController;
|
||||||
use Modules\User\Http\Controllers\BankDetailController;
|
use Modules\User\Http\Controllers\BankDetailController;
|
||||||
use Modules\User\Http\Controllers\HeirController;
|
use Modules\User\Http\Controllers\HeirController;
|
||||||
|
use Modules\User\Http\Controllers\MemberDigitalCardController;
|
||||||
|
use Modules\User\Http\Controllers\PublicMemberProfileController;
|
||||||
|
|
||||||
|
Route::prefix('v1/public')->group(function () {
|
||||||
|
Route::middleware('throttle:public-member-profile')
|
||||||
|
->get('members/{token}', [PublicMemberProfileController::class, 'show'])
|
||||||
|
->name('user.public.member-profile');
|
||||||
|
});
|
||||||
|
|
||||||
Route::middleware(['auth:sanctum', 'single.session'])->prefix('v1')->group(function () {
|
Route::middleware(['auth:sanctum', 'single.session'])->prefix('v1')->group(function () {
|
||||||
Route::get('users/deleted', [UserController::class, 'deletedIndex'])->name('users.deleted.index');
|
Route::get('users/deleted', [UserController::class, 'deletedIndex'])->name('users.deleted.index');
|
||||||
|
Route::get('users/stats', [UserController::class, 'stats'])->name('users.stats');
|
||||||
Route::post('users/{id}/restore', [UserController::class, 'restore'])->name('users.restore');
|
Route::post('users/{id}/restore', [UserController::class, 'restore'])->name('users.restore');
|
||||||
Route::apiResource('users', UserController::class)->names('user');
|
Route::apiResource('users', UserController::class)->names('user');
|
||||||
Route::apiResource('addresses', AddressController::class)->names('address');
|
Route::apiResource('addresses', AddressController::class)->names('address');
|
||||||
@@ -27,7 +36,10 @@ Route::middleware(['auth:sanctum', 'single.session'])->prefix('v1')->group(funct
|
|||||||
|
|
||||||
// User profile management routes
|
// User profile management routes
|
||||||
Route::post('/profile', [UserController::class, 'updateProfile']);
|
Route::post('/profile', [UserController::class, 'updateProfile']);
|
||||||
|
Route::post('/profile/onboarding/complete', [UserController::class, 'completeOnboarding']);
|
||||||
Route::put('/profile/password', [UserController::class, 'updatePassword']);
|
Route::put('/profile/password', [UserController::class, 'updatePassword']);
|
||||||
|
Route::get('/profile/digital-card', [MemberDigitalCardController::class, 'download'])
|
||||||
|
->name('profile.digital-card.download');
|
||||||
|
|
||||||
// Impersonation routes
|
// Impersonation routes
|
||||||
Route::get('/impersonate/take/{id}', [ImpersonateController::class, 'take'])->name('impersonate');
|
Route::get('/impersonate/take/{id}', [ImpersonateController::class, 'take'])->name('impersonate');
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\User\Services;
|
||||||
|
|
||||||
|
use BaconQrCode\Renderer\Image\SvgImageBackEnd;
|
||||||
|
use BaconQrCode\Renderer\ImageRenderer;
|
||||||
|
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
|
||||||
|
use BaconQrCode\Writer;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Facades\View;
|
||||||
|
use Modules\Auth\Entities\User;
|
||||||
|
use Spatie\Browsershot\Browsershot;
|
||||||
|
|
||||||
|
class MemberDigitalCardService
|
||||||
|
{
|
||||||
|
public const CARD_WIDTH = 840;
|
||||||
|
|
||||||
|
public const CARD_HEIGHT = 540;
|
||||||
|
|
||||||
|
public function renderPng(User $user, string $side): string
|
||||||
|
{
|
||||||
|
$view = $side === 'belakang'
|
||||||
|
? 'user::digital-card.back'
|
||||||
|
: 'user::digital-card.front';
|
||||||
|
|
||||||
|
$html = View::make($view, $this->prepareViewData($user))->render();
|
||||||
|
|
||||||
|
return $this->makeBrowsershot($html)->screenshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function prepareViewData(User $user): array
|
||||||
|
{
|
||||||
|
$user = app(PublicMemberProfileService::class)->loadVerifiableRelations($user);
|
||||||
|
$user->ensurePublicProfileToken();
|
||||||
|
|
||||||
|
$currentEmployment = $user->employments->firstWhere('is_current', true)
|
||||||
|
?? $user->employments->first();
|
||||||
|
|
||||||
|
$logoPath = config('user.digital_card_logo_path');
|
||||||
|
$profileUrl = rtrim(config('user.frontend_url'), '/').'/v/'.$user->public_profile_token;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'memberNumber' => $this->displayCardValue($user->member_number),
|
||||||
|
'memberName' => $user->name ?: '-',
|
||||||
|
'memberType' => $this->displayCardValue($user->member_type),
|
||||||
|
'companyName' => $this->displayCardValue($currentEmployment?->company_name),
|
||||||
|
'avatarDataUri' => $this->resolveStorageDataUri($user->image_url),
|
||||||
|
'avatarInitials' => $this->avatarInitials($user->name),
|
||||||
|
'logoDataUri' => $this->resolveFileDataUri($logoPath),
|
||||||
|
'profileUrl' => $profileUrl,
|
||||||
|
'qrDataUri' => $this->generateQrDataUri($profileUrl),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function makeBrowsershot(string $html): Browsershot
|
||||||
|
{
|
||||||
|
$browsershot = Browsershot::html($html)
|
||||||
|
->setNodeModulePath(config('browsershot.node_module_path'))
|
||||||
|
->timeout(config('browsershot.timeout'))
|
||||||
|
->windowSize(self::CARD_WIDTH, self::CARD_HEIGHT)
|
||||||
|
->deviceScaleFactor(2)
|
||||||
|
->showBackground();
|
||||||
|
|
||||||
|
if ($nodeBinary = config('browsershot.node_binary')) {
|
||||||
|
$browsershot->setNodeBinary($nodeBinary);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($npmBinary = config('browsershot.npm_binary')) {
|
||||||
|
$browsershot->setNpmBinary($npmBinary);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($chromePath = config('browsershot.chrome_path')) {
|
||||||
|
$browsershot->setChromePath($chromePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config('browsershot.no_sandbox')) {
|
||||||
|
$browsershot->noSandbox();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $browsershot;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function generateQrDataUri(string $content): string
|
||||||
|
{
|
||||||
|
$renderer = new ImageRenderer(
|
||||||
|
new RendererStyle(220, 1),
|
||||||
|
new SvgImageBackEnd
|
||||||
|
);
|
||||||
|
$writer = new Writer($renderer);
|
||||||
|
$svg = $writer->writeString($content);
|
||||||
|
|
||||||
|
return 'data:image/svg+xml;base64,'.base64_encode($svg);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function resolveFileDataUri(?string $path): ?string
|
||||||
|
{
|
||||||
|
if (empty($path) || ! file_exists($path)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$mime = mime_content_type($path) ?: 'image/svg+xml';
|
||||||
|
|
||||||
|
return 'data:'.$mime.';base64,'.base64_encode(file_get_contents($path));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function resolveStorageDataUri(?string $path): ?string
|
||||||
|
{
|
||||||
|
if (blank($path)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fullPath = Storage::disk('public')->path($path);
|
||||||
|
|
||||||
|
return $this->resolveFileDataUri($fullPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function avatarInitials(?string $name): string
|
||||||
|
{
|
||||||
|
$trimmed = trim((string) $name);
|
||||||
|
|
||||||
|
if ($trimmed === '') {
|
||||||
|
return '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
return strtoupper(mb_substr($trimmed, 0, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function displayCardValue(mixed $value): string
|
||||||
|
{
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
$trimmed = trim((string) $value);
|
||||||
|
|
||||||
|
return $trimmed !== '' ? $trimmed : '-';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\User\Services;
|
||||||
|
|
||||||
|
use Modules\Auth\Entities\User;
|
||||||
|
|
||||||
|
class PublicMemberProfileService
|
||||||
|
{
|
||||||
|
public function findMemberByToken(string $token): ?User
|
||||||
|
{
|
||||||
|
return User::query()
|
||||||
|
->where('public_profile_token', $token)
|
||||||
|
->where('status', 'active')
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function loadVerifiableRelations(User $member): User
|
||||||
|
{
|
||||||
|
$member->load(['employments' => function ($query) {
|
||||||
|
$query->orderByDesc('is_current')->orderByDesc('start_date');
|
||||||
|
}]);
|
||||||
|
|
||||||
|
return $member;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,14 +21,16 @@ class UserService
|
|||||||
string $search,
|
string $search,
|
||||||
string $status,
|
string $status,
|
||||||
string $sortBy,
|
string $sortBy,
|
||||||
string $sortOrder
|
string $sortOrder,
|
||||||
|
array $dateFilters = []
|
||||||
): LengthAwarePaginator {
|
): LengthAwarePaginator {
|
||||||
return $this->repository->getAllWithRelationsPaginated(
|
return $this->repository->getAllWithRelationsPaginated(
|
||||||
$perPage,
|
$perPage,
|
||||||
$search,
|
$search,
|
||||||
$status,
|
$status,
|
||||||
$sortBy,
|
$sortBy,
|
||||||
$sortOrder
|
$sortOrder,
|
||||||
|
$dateFilters
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +50,14 @@ class UserService
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{total: int, joined_this_month: int}
|
||||||
|
*/
|
||||||
|
public function getListStats(string $search, string $status, array $dateFilters = []): array
|
||||||
|
{
|
||||||
|
return $this->repository->getListStats($search, $status, $dateFilters);
|
||||||
|
}
|
||||||
|
|
||||||
public function restoreUser(string $id): ?User
|
public function restoreUser(string $id): ?User
|
||||||
{
|
{
|
||||||
if (! $this->repository->restore($id)) {
|
if (! $this->repository->restore($id)) {
|
||||||
@@ -121,6 +131,17 @@ class UserService
|
|||||||
/**
|
/**
|
||||||
* @return array{}|array{error: string}
|
* @return array{}|array{error: string}
|
||||||
*/
|
*/
|
||||||
|
public function completeOnboarding(User $user): User
|
||||||
|
{
|
||||||
|
if ($user->onboarding_completed_at === null) {
|
||||||
|
$user->update(['onboarding_completed_at' => now()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->load(['roles.permissions']);
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
public function updatePassword(User $user, string $currentPassword, string $newPassword): array
|
public function updatePassword(User $user, string $currentPassword, string $newPassword): array
|
||||||
{
|
{
|
||||||
if (! Hash::check($currentPassword, $user->password)) {
|
if (! Hash::check($currentPassword, $user->password)) {
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\User\Transformers;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class PublicMemberProfileResource extends JsonResource
|
||||||
|
{
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
$currentEmployment = $this->employments->firstWhere('is_current', true)
|
||||||
|
?? $this->employments->first();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => $this->name,
|
||||||
|
'member_number' => $this->member_number,
|
||||||
|
'member_type' => $this->member_type,
|
||||||
|
'status' => $this->status,
|
||||||
|
'image_url' => $this->image_url ? Storage::disk('public')->url($this->image_url) : null,
|
||||||
|
'company_name' => $currentEmployment?->company_name,
|
||||||
|
'verified_at' => now()->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ namespace Modules\User\Transformers;
|
|||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Modules\User\Transformers\EmploymentResource;
|
||||||
|
|
||||||
class UserListResource extends JsonResource
|
class UserListResource extends JsonResource
|
||||||
{
|
{
|
||||||
@@ -25,11 +26,13 @@ class UserListResource extends JsonResource
|
|||||||
'member_number' => $this->member_number,
|
'member_number' => $this->member_number,
|
||||||
'member_type' => $this->member_type,
|
'member_type' => $this->member_type,
|
||||||
'join_date' => $this->join_date,
|
'join_date' => $this->join_date,
|
||||||
|
'leave_date' => $this->leave_date,
|
||||||
'birth_date' => $this->birth_date,
|
'birth_date' => $this->birth_date,
|
||||||
'birth_place' => $this->birth_place,
|
'birth_place' => $this->birth_place,
|
||||||
'created_at' => $this->created_at,
|
'created_at' => $this->created_at,
|
||||||
'updated_at' => $this->updated_at,
|
'updated_at' => $this->updated_at,
|
||||||
'deleted_at' => $this->deleted_at,
|
'deleted_at' => $this->deleted_at,
|
||||||
|
'employments' => EmploymentResource::collection($this->whenLoaded('employments')),
|
||||||
'roles' => $this->roles->map(function ($role) {
|
'roles' => $this->roles->map(function ($role) {
|
||||||
return [
|
return [
|
||||||
'id' => $role->id,
|
'id' => $role->id,
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
|
||||||
|
|
||||||
use Illuminate\Console\Command;
|
|
||||||
use Modules\KJCReport\Entities\KJCReport;
|
|
||||||
use Modules\KJCReport\Jobs\GenerateTeamWeeklyReports;
|
|
||||||
use Modules\Unit\Entities\Unit;
|
|
||||||
use Modules\Auth\Entities\User;
|
|
||||||
|
|
||||||
class AutoGenerateWeeklyReportForUnit extends Command
|
|
||||||
{
|
|
||||||
protected $signature = 'kjc:auto-generate-weekly-report
|
|
||||||
{unit : Unit name to generate reports for (e.g. "Jabatan Arah RAJD")}
|
|
||||||
{--force : Force generation even if reports already exist}';
|
|
||||||
|
|
||||||
protected $description = 'Auto-generate weekly KJC reports for a specific unit';
|
|
||||||
|
|
||||||
public function handle(): int
|
|
||||||
{
|
|
||||||
$unitName = $this->argument('unit');
|
|
||||||
|
|
||||||
$unit = Unit::where('name', $unitName)->first();
|
|
||||||
|
|
||||||
if (! $unit) {
|
|
||||||
$this->error("Unit '{$unitName}' not found.");
|
|
||||||
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
$month = strtolower(now()->format('F'));
|
|
||||||
$week = (int) ceil(now()->day / 7);
|
|
||||||
|
|
||||||
if (! $this->option('force')) {
|
|
||||||
$existingReports = KJCReport::where('unit_id', $unit->id)
|
|
||||||
->where('report_type', 'weekly')
|
|
||||||
->where('report_month', $month)
|
|
||||||
->where('report_week', $week)
|
|
||||||
->exists();
|
|
||||||
|
|
||||||
if ($existingReports) {
|
|
||||||
$this->info("Reports for {$unitName} - {$month} week {$week} already exist. Skipping.");
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = User::where('unit_id', $unit->id)->first();
|
|
||||||
|
|
||||||
GenerateTeamWeeklyReports::dispatch($unit->id, $month, $week, $user?->id);
|
|
||||||
|
|
||||||
$this->info("✓ Queued weekly report generation for {$unitName} - {$month} week {$week}");
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
|
||||||
|
|
||||||
use App\Jobs\CaptureKJCHistoricalDataJob;
|
|
||||||
use App\Services\KJCHistoricalDataService;
|
|
||||||
use Carbon\Carbon;
|
|
||||||
use Illuminate\Console\Command;
|
|
||||||
|
|
||||||
class TriggerKJCHistoricalDataCapture extends Command
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* The name and signature of the console command.
|
|
||||||
*
|
|
||||||
* @var string
|
|
||||||
*/
|
|
||||||
protected $signature = 'kjc:historical:trigger
|
|
||||||
{--month= : Month to capture (1-12)}
|
|
||||||
{--year= : Year to capture (YYYY)}
|
|
||||||
{--queue : Dispatch to queue instead of running immediately}';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The console command description.
|
|
||||||
*
|
|
||||||
* @var string
|
|
||||||
*/
|
|
||||||
protected $description = 'Manually trigger KJC historical data capture';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute the console command.
|
|
||||||
*/
|
|
||||||
public function handle()
|
|
||||||
{
|
|
||||||
$month = $this->option('month');
|
|
||||||
$year = $this->option('year');
|
|
||||||
$useQueue = $this->option('queue');
|
|
||||||
|
|
||||||
// If no month/year provided, use previous month
|
|
||||||
if (! $month || ! $year) {
|
|
||||||
$lastMonth = Carbon::now()->subMonth();
|
|
||||||
$month = $month ?: $lastMonth->format('m');
|
|
||||||
$year = $year ?: $lastMonth->format('Y');
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->info("Triggering KJC historical data capture for {$month}/{$year}...");
|
|
||||||
|
|
||||||
try {
|
|
||||||
if ($useQueue) {
|
|
||||||
// Dispatch to queue
|
|
||||||
CaptureKJCHistoricalDataJob::dispatch($month, $year);
|
|
||||||
$this->info('✓ KJC historical data capture job dispatched to queue');
|
|
||||||
$this->info('You can monitor the job in Horizon dashboard');
|
|
||||||
} else {
|
|
||||||
// Run immediately
|
|
||||||
$job = new CaptureKJCHistoricalDataJob($month, $year);
|
|
||||||
$job->handle(app(KJCHistoricalDataService::class));
|
|
||||||
$this->info('✓ KJC historical data capture completed immediately');
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
$this->error('Error: '.$e->getMessage());
|
|
||||||
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
|
||||||
|
|
||||||
use App\Jobs\CapturePKJHistoricalDataJob;
|
|
||||||
use App\Services\PKJHistoricalDataService;
|
|
||||||
use Carbon\Carbon;
|
|
||||||
use Illuminate\Console\Command;
|
|
||||||
|
|
||||||
class TriggerPKJHistoricalDataCapture extends Command
|
|
||||||
{
|
|
||||||
protected $signature = 'pkj:historical:trigger
|
|
||||||
{--month= : Month to capture (1-12)}
|
|
||||||
{--year= : Year to capture (YYYY)}
|
|
||||||
{--queue : Dispatch to queue instead of running immediately}';
|
|
||||||
|
|
||||||
protected $description = 'Manually trigger PKJ historical data capture';
|
|
||||||
|
|
||||||
public function handle()
|
|
||||||
{
|
|
||||||
$month = $this->option('month');
|
|
||||||
$year = $this->option('year');
|
|
||||||
$useQueue = $this->option('queue');
|
|
||||||
|
|
||||||
// If no month/year provided, use previous month
|
|
||||||
if (! $month || ! $year) {
|
|
||||||
$lastMonth = Carbon::now()->subMonth();
|
|
||||||
$month = $month ?: $lastMonth->format('m');
|
|
||||||
$year = $year ?: $lastMonth->format('Y');
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->info("Triggering PKJ historical data capture for {$month}/{$year}...");
|
|
||||||
|
|
||||||
try {
|
|
||||||
if ($useQueue) {
|
|
||||||
// Dispatch to queue
|
|
||||||
CapturePKJHistoricalDataJob::dispatch($month, $year);
|
|
||||||
$this->info('✓ PKJ historical data capture job dispatched to queue');
|
|
||||||
$this->info('You can monitor the job in Horizon dashboard');
|
|
||||||
} else {
|
|
||||||
// Run immediately
|
|
||||||
$job = new CapturePKJHistoricalDataJob($month, $year);
|
|
||||||
$job->handle(app(PKJHistoricalDataService::class));
|
|
||||||
$this->info('✓ PKJ historical data capture completed immediately');
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
$this->error('Error: '.$e->getMessage());
|
|
||||||
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -113,8 +113,6 @@ abstract class BaseCrudController extends Controller
|
|||||||
|
|
||||||
$item = $this->repository->create($data);
|
$item = $this->repository->create($data);
|
||||||
|
|
||||||
ActivityLogger::log("Created {$this->resourceName}: {$this->getItemName($item)}", $item);
|
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'data' => new $this->resourceClass($item),
|
'data' => new $this->resourceClass($item),
|
||||||
@@ -175,8 +173,6 @@ abstract class BaseCrudController extends Controller
|
|||||||
|
|
||||||
$item->update($data);
|
$item->update($data);
|
||||||
|
|
||||||
ActivityLogger::log("Updated {$this->resourceName}: {$this->getItemName($item)}", $item);
|
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'data' => new $this->resourceClass($item),
|
'data' => new $this->resourceClass($item),
|
||||||
@@ -221,8 +217,6 @@ abstract class BaseCrudController extends Controller
|
|||||||
|
|
||||||
$this->repository->delete($id);
|
$this->repository->delete($id);
|
||||||
|
|
||||||
ActivityLogger::log("Deleted {$this->resourceName}: {$this->getItemName($item)}", $item);
|
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'message' => $this->getSuccessMessage('destroy'),
|
'message' => $this->getSuccessMessage('destroy'),
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Jobs;
|
|
||||||
|
|
||||||
use App\Services\KJCHistoricalDataService;
|
|
||||||
use Illuminate\Bus\Queueable;
|
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
|
||||||
use Illuminate\Queue\SerializesModels;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
|
|
||||||
class CaptureKJCHistoricalDataJob implements ShouldQueue
|
|
||||||
{
|
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
|
||||||
|
|
||||||
public $timeout = 300; // 5 minutes timeout
|
|
||||||
|
|
||||||
public $tries = 3; // Retry 3 times if failed
|
|
||||||
|
|
||||||
public $backoff = 60; // Wait 60 seconds between retries
|
|
||||||
|
|
||||||
protected $month;
|
|
||||||
|
|
||||||
protected $year;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new job instance.
|
|
||||||
*/
|
|
||||||
public function __construct($month = null, $year = null)
|
|
||||||
{
|
|
||||||
$this->month = $month;
|
|
||||||
$this->year = $year;
|
|
||||||
|
|
||||||
// Set queue name for Horizon monitoring
|
|
||||||
$this->onQueue('kjc-historical-data');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute the job.
|
|
||||||
*/
|
|
||||||
public function handle(KJCHistoricalDataService $kjcHistoricalService)
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
Log::info('Starting KJC historical data capture job', [
|
|
||||||
'month' => $this->month,
|
|
||||||
'year' => $this->year,
|
|
||||||
'job_id' => $this->job ? $this->job->getJobId() : 'manual-execution',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$success = $kjcHistoricalService->captureMonthlySnapshots($this->month, $this->year);
|
|
||||||
|
|
||||||
if ($success) {
|
|
||||||
Log::info('KJC historical data capture job completed successfully', [
|
|
||||||
'month' => $this->month,
|
|
||||||
'year' => $this->year,
|
|
||||||
'job_id' => $this->job ? $this->job->getJobId() : 'manual-execution',
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
Log::error('KJC historical data capture job failed', [
|
|
||||||
'month' => $this->month,
|
|
||||||
'year' => $this->year,
|
|
||||||
'job_id' => $this->job ? $this->job->getJobId() : 'manual-execution',
|
|
||||||
]);
|
|
||||||
|
|
||||||
throw new \Exception('KJC historical data capture failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
Log::error('KJC historical data capture job exception', [
|
|
||||||
'month' => $this->month,
|
|
||||||
'year' => $this->year,
|
|
||||||
'job_id' => $this->job ? $this->job->getJobId() : 'manual-execution',
|
|
||||||
'error' => $e->getMessage(),
|
|
||||||
'trace' => $e->getTraceAsString(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle a job failure.
|
|
||||||
*/
|
|
||||||
public function failed(\Throwable $exception)
|
|
||||||
{
|
|
||||||
Log::error('KJC historical data capture job failed permanently', [
|
|
||||||
'month' => $this->month,
|
|
||||||
'year' => $this->year,
|
|
||||||
'job_id' => $this->job ? $this->job->getJobId() : 'manual-execution',
|
|
||||||
'error' => $exception->getMessage(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the tags that should be assigned to the job.
|
|
||||||
*/
|
|
||||||
public function tags()
|
|
||||||
{
|
|
||||||
return ['kjc-historical-data', "month-{$this->month}", "year-{$this->year}"];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Jobs;
|
|
||||||
|
|
||||||
use App\Services\PKJHistoricalDataService;
|
|
||||||
use Illuminate\Bus\Queueable;
|
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
|
||||||
use Illuminate\Queue\SerializesModels;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
|
|
||||||
class CapturePKJHistoricalDataJob implements ShouldQueue
|
|
||||||
{
|
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
|
||||||
|
|
||||||
public $timeout = 300; // 5 minutes timeout
|
|
||||||
|
|
||||||
public $tries = 3; // Retry 3 times if failed
|
|
||||||
|
|
||||||
public $backoff = 60; // Wait 60 seconds between retries
|
|
||||||
|
|
||||||
protected $month;
|
|
||||||
|
|
||||||
protected $year;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new job instance.
|
|
||||||
*/
|
|
||||||
public function __construct($month = null, $year = null)
|
|
||||||
{
|
|
||||||
$this->month = $month;
|
|
||||||
$this->year = $year;
|
|
||||||
|
|
||||||
// Set queue name for Horizon monitoring
|
|
||||||
$this->onQueue('pkj-historical-data');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute the job.
|
|
||||||
*/
|
|
||||||
public function handle(PKJHistoricalDataService $historicalService)
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
Log::info('Starting PKJ historical data capture job', [
|
|
||||||
'month' => $this->month,
|
|
||||||
'year' => $this->year,
|
|
||||||
'job_id' => $this->job ? $this->job->getJobId() : 'manual-execution',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$success = $historicalService->captureMonthlySnapshots($this->month, $this->year);
|
|
||||||
|
|
||||||
if ($success) {
|
|
||||||
Log::info('PKJ historical data capture job completed successfully', [
|
|
||||||
'month' => $this->month,
|
|
||||||
'year' => $this->year,
|
|
||||||
'job_id' => $this->job ? $this->job->getJobId() : 'manual-execution',
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
Log::error('PKJ historical data capture job failed', [
|
|
||||||
'month' => $this->month,
|
|
||||||
'year' => $this->year,
|
|
||||||
'job_id' => $this->job ? $this->job->getJobId() : 'manual-execution',
|
|
||||||
]);
|
|
||||||
|
|
||||||
throw new \Exception('PKJ historical data capture failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
Log::error('PKJ historical data capture job exception', [
|
|
||||||
'month' => $this->month,
|
|
||||||
'year' => $this->year,
|
|
||||||
'job_id' => $this->job ? $this->job->getJobId() : 'manual-execution',
|
|
||||||
'error' => $e->getMessage(),
|
|
||||||
'trace' => $e->getTraceAsString(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle a job failure.
|
|
||||||
*/
|
|
||||||
public function failed(\Throwable $exception)
|
|
||||||
{
|
|
||||||
Log::error('PKJ historical data capture job failed permanently', [
|
|
||||||
'month' => $this->month,
|
|
||||||
'year' => $this->year,
|
|
||||||
'job_id' => $this->job ? $this->job->getJobId() : 'manual-execution',
|
|
||||||
'error' => $exception->getMessage(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the tags that should be assigned to the job.
|
|
||||||
*/
|
|
||||||
public function tags()
|
|
||||||
{
|
|
||||||
return ['pkj-historical-data', "month-{$this->month}", "year-{$this->year}"];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -13,12 +13,7 @@ class DocumentService
|
|||||||
/**
|
/**
|
||||||
* Upload a document for any model.
|
* Upload a document for any model.
|
||||||
*/
|
*/
|
||||||
public function uploadDocument(
|
public function uploadDocument(Model $model, UploadedFile $file, string $documentType = 'general', ?string $description = null): Document {
|
||||||
Model $model,
|
|
||||||
UploadedFile $file,
|
|
||||||
string $documentType = 'general',
|
|
||||||
?string $description = null
|
|
||||||
): Document {
|
|
||||||
$fileName = time().'_'.$file->getClientOriginalName();
|
$fileName = time().'_'.$file->getClientOriginalName();
|
||||||
$folderName = strtolower(class_basename($model));
|
$folderName = strtolower(class_basename($model));
|
||||||
$filePath = $file->storeAs("documents/{$folderName}", $fileName, Document::STORAGE_DISK);
|
$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.
|
* Delete a document.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ class LetterService
|
|||||||
*/
|
*/
|
||||||
public function pdfResponse(string $view, array $data = [], string $filename = 'letter.pdf'): Response
|
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, [
|
return response($pdf, SymfonyResponse::HTTP_OK, [
|
||||||
'Content-Type' => 'application/pdf',
|
'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
|
* @param array<string, mixed> $overrides
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
@@ -48,11 +56,14 @@ class LetterService
|
|||||||
public function prepareViewData(array $overrides = []): array
|
public function prepareViewData(array $overrides = []): array
|
||||||
{
|
{
|
||||||
$logoPath = $overrides['logoPath'] ?? config('mail.logo_path');
|
$logoPath = $overrides['logoPath'] ?? config('mail.logo_path');
|
||||||
|
$signaturePath = $overrides['signaturePath'] ?? config('letter.signature_path');
|
||||||
|
|
||||||
return array_merge([
|
return array_merge([
|
||||||
'letterLayout' => LetterLayout::resolve(),
|
'letterLayout' => LetterLayout::resolve(),
|
||||||
'logoPath' => $logoPath,
|
'logoPath' => $logoPath,
|
||||||
'logoUrl' => $this->resolveLogoDataUri($logoPath),
|
'logoUrl' => $this->resolveImageDataUri($logoPath),
|
||||||
|
'signaturePath' => $signaturePath,
|
||||||
|
'signatureUrl' => $this->resolveImageDataUri($signaturePath),
|
||||||
'organizationAddress' => $overrides['organizationAddress'] ?? null,
|
'organizationAddress' => $overrides['organizationAddress'] ?? null,
|
||||||
'organizationContact' => $overrides['organizationContact'] ?? null,
|
'organizationContact' => $overrides['organizationContact'] ?? null,
|
||||||
], $overrides);
|
], $overrides);
|
||||||
@@ -89,14 +100,14 @@ class LetterService
|
|||||||
return $browsershot;
|
return $browsershot;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function resolveLogoDataUri(?string $logoPath): ?string
|
protected function resolveImageDataUri(?string $path): ?string
|
||||||
{
|
{
|
||||||
if (empty($logoPath) || ! file_exists($logoPath)) {
|
if (empty($path) || ! file_exists($path)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$mime = mime_content_type($logoPath) ?: 'image/svg+xml';
|
$mime = mime_content_type($path) ?: 'image/svg+xml';
|
||||||
|
|
||||||
return 'data:'.$mime.';base64,'.base64_encode(file_get_contents($logoPath));
|
return 'data:'.$mime.';base64,'.base64_encode(file_get_contents($path));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ return [
|
|||||||
'http://127.0.0.1',
|
'http://127.0.0.1',
|
||||||
'http://localhost:5173',
|
'http://localhost:5173',
|
||||||
'http://127.0.0.1:5173',
|
'http://127.0.0.1:5173',
|
||||||
'https://anggota.koppkb.com',
|
'https://mykopkb.koppkb.com',
|
||||||
'http://anggota.koppkb.com',
|
'http://mykopkb.koppkb.com',
|
||||||
],
|
],
|
||||||
|
|
||||||
'allowed_origins_patterns' => [],
|
'allowed_origins_patterns' => [],
|
||||||
|
|||||||
@@ -82,4 +82,12 @@ return [
|
|||||||
'black' => '#000000',
|
'black' => '#000000',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Assets
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
'signature_path' => resource_path('assets/signatures/signature.svg'),
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -124,6 +124,6 @@ return [
|
|||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'logo_path' => env('MAIL_LOGO_PATH', public_path('images/logo/logo-kopkb-letterhead.svg')),
|
'logo_path' => public_path('images/logo/logo-original.svg'),
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ return new class extends Migration
|
|||||||
public function up(): void
|
public function up(): void
|
||||||
{
|
{
|
||||||
Schema::create('jobs', function (Blueprint $table) {
|
Schema::create('jobs', function (Blueprint $table) {
|
||||||
$table->uuid('id')->primary();
|
$table->id();
|
||||||
$table->string('queue')->index();
|
$table->string('queue')->index();
|
||||||
$table->longText('payload');
|
$table->longText('payload');
|
||||||
$table->unsignedTinyInteger('attempts');
|
$table->unsignedTinyInteger('attempts');
|
||||||
@@ -22,7 +22,7 @@ return new class extends Migration
|
|||||||
});
|
});
|
||||||
|
|
||||||
Schema::create('job_batches', function (Blueprint $table) {
|
Schema::create('job_batches', function (Blueprint $table) {
|
||||||
$table->uuid('id')->primary();
|
$table->string('id')->primary();
|
||||||
$table->string('name');
|
$table->string('name');
|
||||||
$table->integer('total_jobs');
|
$table->integer('total_jobs');
|
||||||
$table->integer('pending_jobs');
|
$table->integer('pending_jobs');
|
||||||
@@ -35,7 +35,7 @@ return new class extends Migration
|
|||||||
});
|
});
|
||||||
|
|
||||||
Schema::create('failed_jobs', function (Blueprint $table) {
|
Schema::create('failed_jobs', function (Blueprint $table) {
|
||||||
$table->uuid('id')->primary();
|
$table->id();
|
||||||
$table->string('uuid')->unique();
|
$table->string('uuid')->unique();
|
||||||
$table->text('connection');
|
$table->text('connection');
|
||||||
$table->text('queue');
|
$table->text('queue');
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
services:
|
services:
|
||||||
laravel.test:
|
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:
|
build:
|
||||||
context: "./vendor/laravel/sail/runtimes/8.4"
|
context: "./vendor/laravel/sail/runtimes/8.4"
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
|||||||
@@ -46,6 +46,25 @@ COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
|
|||||||
RUN composer install --no-dev --optimize-autoloader --no-interaction --no-progress --prefer-dist --no-scripts \
|
RUN composer install --no-dev --optimize-autoloader --no-interaction --no-progress --prefer-dist --no-scripts \
|
||||||
&& rm -rf bootstrap/cache/services.php bootstrap/cache/packages.php
|
&& rm -rf bootstrap/cache/services.php bootstrap/cache/packages.php
|
||||||
|
|
||||||
|
# Stage 1b: Node + Puppeteer for Browsershot PDF generation
|
||||||
|
FROM node:22-bookworm-slim AS node-deps
|
||||||
|
|
||||||
|
WORKDIR /var/www
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
unzip \
|
||||||
|
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY be/package.json be/package-lock.json ./
|
||||||
|
|
||||||
|
ENV PUPPETEER_SKIP_DOWNLOAD=true \
|
||||||
|
PUPPETEER_CACHE_DIR=/var/www/.cache/puppeteer
|
||||||
|
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
|
RUN npx puppeteer browsers install chrome-headless-shell
|
||||||
|
|
||||||
# Stage 2: Unified production image with PHP-FPM + Nginx
|
# Stage 2: Unified production image with PHP-FPM + Nginx
|
||||||
FROM php:8.4-fpm AS base
|
FROM php:8.4-fpm AS base
|
||||||
|
|
||||||
@@ -79,6 +98,37 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
&& docker-php-ext-enable redis \
|
&& docker-php-ext-enable redis \
|
||||||
&& apt-get autoremove -y && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
&& apt-get autoremove -y && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||||
|
|
||||||
|
# Chromium runtime libraries for Puppeteer (Browsershot PDF generation)
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
fonts-liberation \
|
||||||
|
libasound2 \
|
||||||
|
libatk-bridge2.0-0 \
|
||||||
|
libatk1.0-0 \
|
||||||
|
libcairo2 \
|
||||||
|
libcups2 \
|
||||||
|
libdbus-1-3 \
|
||||||
|
libdrm2 \
|
||||||
|
libexpat1 \
|
||||||
|
libfontconfig1 \
|
||||||
|
libgbm1 \
|
||||||
|
libglib2.0-0 \
|
||||||
|
libnspr4 \
|
||||||
|
libnss3 \
|
||||||
|
libpango-1.0-0 \
|
||||||
|
libpangocairo-1.0-0 \
|
||||||
|
libx11-6 \
|
||||||
|
libx11-xcb1 \
|
||||||
|
libxcb1 \
|
||||||
|
libxcomposite1 \
|
||||||
|
libxdamage1 \
|
||||||
|
libxext6 \
|
||||||
|
libxfixes3 \
|
||||||
|
libxkbcommon0 \
|
||||||
|
libxrandr2 \
|
||||||
|
libxrender1 \
|
||||||
|
&& apt-get autoremove -y && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||||
|
|
||||||
# Download and install php-fpm health check script
|
# Download and install php-fpm health check script
|
||||||
RUN curl -o /usr/local/bin/php-fpm-healthcheck \
|
RUN curl -o /usr/local/bin/php-fpm-healthcheck \
|
||||||
https://raw.githubusercontent.com/renatomefi/php-fpm-healthcheck/master/php-fpm-healthcheck \
|
https://raw.githubusercontent.com/renatomefi/php-fpm-healthcheck/master/php-fpm-healthcheck \
|
||||||
@@ -120,11 +170,20 @@ RUN echo '[www]' > /usr/local/etc/php-fpm.d/zzz-status.conf && \
|
|||||||
# Copy the application code and dependencies from the build stage first
|
# Copy the application code and dependencies from the build stage first
|
||||||
COPY --from=builder /var/www /var/www
|
COPY --from=builder /var/www /var/www
|
||||||
|
|
||||||
|
# Node runtime + Puppeteer for Browsershot (letter PDF generation)
|
||||||
|
COPY --from=node-deps /usr/local/bin/node /usr/local/bin/node
|
||||||
|
COPY --from=node-deps /var/www/node_modules /var/www/node_modules
|
||||||
|
COPY --from=node-deps /var/www/.cache/puppeteer /var/www/.cache/puppeteer
|
||||||
|
|
||||||
|
ENV PUPPETEER_CACHE_DIR=/var/www/.cache/puppeteer \
|
||||||
|
BROWSERSHOT_NO_SANDBOX=true \
|
||||||
|
BROWSERSHOT_NODE_BINARY=/usr/local/bin/node
|
||||||
|
|
||||||
# Set working directory
|
# Set working directory
|
||||||
WORKDIR /var/www
|
WORKDIR /var/www
|
||||||
|
|
||||||
# Ensure correct permissions
|
# Ensure correct permissions
|
||||||
RUN chown -R www-data:www-data /var/www
|
RUN chown -R www-data:www-data /var/www /var/www/.cache/puppeteer
|
||||||
|
|
||||||
# Create Nginx log directory
|
# Create Nginx log directory
|
||||||
RUN mkdir -p /var/log/nginx && chown -R www-data:www-data /var/log/nginx
|
RUN mkdir -p /var/log/nginx && chown -R www-data:www-data /var/log/nginx
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"dev": "vite"
|
"dev": "vite",
|
||||||
|
"browsershot:install": "puppeteer browsers install chrome-headless-shell"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 0 B After Width: | Height: | Size: 204 KiB |
|
Before Width: | Height: | Size: 275 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 202 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
@@ -8,6 +8,7 @@
|
|||||||
@section('letter-reference') Ruj: APP-2026-00001 @endsection
|
@section('letter-reference') Ruj: APP-2026-00001 @endsection
|
||||||
@section('letter-date') {{ now()->format('d F Y') }} @endsection
|
@section('letter-date') {{ now()->format('d F Y') }} @endsection
|
||||||
@section('letter-recipient') ... @endsection
|
@section('letter-recipient') ... @endsection
|
||||||
|
@section('letter-honorific') Tuan/Puan @endsection
|
||||||
@section('letter-subject') Perkara: ... @endsection
|
@section('letter-subject') Perkara: ... @endsection
|
||||||
@section('letter-salutation') Assalamualaikum dan Salam Sejahtera, @endsection
|
@section('letter-salutation') Assalamualaikum dan Salam Sejahtera, @endsection
|
||||||
@section('letter-body') ... @endsection
|
@section('letter-body') ... @endsection
|
||||||
@@ -110,74 +111,27 @@
|
|||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
}
|
}
|
||||||
|
|
||||||
.letterhead-line {
|
.letterhead-center {
|
||||||
vertical-align: middle;
|
text-align: center;
|
||||||
padding-right: 16px;
|
vertical-align: top;
|
||||||
}
|
|
||||||
|
|
||||||
.letterhead-rule {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
-webkit-print-color-adjust: exact;
|
|
||||||
print-color-adjust: exact;
|
|
||||||
}
|
|
||||||
|
|
||||||
.letterhead-rule-blue {
|
|
||||||
background-color: var(--letter-color-blue);
|
|
||||||
font-size: 0;
|
|
||||||
line-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.letterhead-rule-orange {
|
|
||||||
background-color: var(--letter-color-orange);
|
|
||||||
font-size: 0;
|
|
||||||
line-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.letterhead-logo {
|
|
||||||
vertical-align: middle;
|
|
||||||
white-space: nowrap;
|
|
||||||
padding-right: 10px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.letterhead-logo-img {
|
.letterhead-logo-img {
|
||||||
height: var(--letter-logo-height);
|
height: var(--letter-logo-height);
|
||||||
width: auto;
|
width: auto;
|
||||||
display: block;
|
display: block;
|
||||||
|
margin: 0 auto 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.letterhead-brand {
|
.letterhead-address,
|
||||||
vertical-align: middle;
|
.letterhead-contact {
|
||||||
white-space: nowrap;
|
|
||||||
padding-left: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.letterhead-brand-inner {
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
|
|
||||||
.letterhead-name {
|
|
||||||
vertical-align: middle;
|
|
||||||
text-align: left;
|
|
||||||
padding-left: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.letterhead-name-line {
|
|
||||||
font-size: var(--letter-font-size);
|
|
||||||
font-weight: bold;
|
|
||||||
line-height: 1.25;
|
|
||||||
color: var(--letter-color-black);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.letterhead-registration {
|
|
||||||
font-size: var(--letterhead-registration-size);
|
font-size: var(--letterhead-registration-size);
|
||||||
font-weight: bold;
|
line-height: 1.4;
|
||||||
line-height: 1.3;
|
|
||||||
color: var(--letter-color-black);
|
color: var(--letter-color-black);
|
||||||
margin-top: 2px;
|
}
|
||||||
letter-spacing: 0.5px;
|
|
||||||
|
.letterhead-address {
|
||||||
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-table {
|
.meta-table {
|
||||||
@@ -192,22 +146,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.meta-label {
|
.meta-label {
|
||||||
width: 70px;
|
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding-right: 4px;
|
||||||
|
vertical-align: top;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-value {
|
.meta-value {
|
||||||
padding-left: 4px;
|
padding-left: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-date {
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recipient-block {
|
.recipient-block {
|
||||||
margin-bottom: var(--letter-recipient-margin-bottom);
|
margin-bottom: var(--letter-recipient-margin-bottom);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.letter-honorific {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.subject-block {
|
.subject-block {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
margin-bottom: var(--letter-subject-margin-bottom);
|
margin-bottom: var(--letter-subject-margin-bottom);
|
||||||
@@ -223,6 +180,26 @@
|
|||||||
margin: 0 0 var(--letter-body-paragraph-margin-bottom);
|
margin: 0 0 var(--letter-body-paragraph-margin-bottom);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.letter-info-table {
|
||||||
|
width: 80%;
|
||||||
|
/* max-width: 50%; */
|
||||||
|
margin: 0 auto var(--letter-body-paragraph-margin-bottom);
|
||||||
|
border-collapse: collapse;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter-info-table th,
|
||||||
|
.letter-info-table td {
|
||||||
|
border: 1px solid var(--letter-color-black);
|
||||||
|
padding: 4px 8px;
|
||||||
|
vertical-align: top;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter-info-table th {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
.signature-block {
|
.signature-block {
|
||||||
padding-left: var(--letter-content-padding-x);
|
padding-left: var(--letter-content-padding-x);
|
||||||
padding-right: var(--letter-content-padding-x);
|
padding-right: var(--letter-content-padding-x);
|
||||||
@@ -239,6 +216,12 @@
|
|||||||
height: var(--letter-signature-space-height);
|
height: var(--letter-signature-space-height);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.signature-space-img {
|
||||||
|
height: var(--letter-signature-space-height);
|
||||||
|
width: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.signatory-name {
|
.signatory-name {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -261,43 +244,18 @@
|
|||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.letter-footer-contact-box {
|
.letter-footer-divider {
|
||||||
background-color: var(--letter-color-orange);
|
border-top: 1px solid var(--letter-color-black);
|
||||||
padding: 8px 12px;
|
font-size: 0;
|
||||||
|
line-height: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter-footer-nota {
|
||||||
|
padding-top: 8px;
|
||||||
font-size: var(--letter-footer-font-size);
|
font-size: var(--letter-footer-font-size);
|
||||||
line-height: var(--letter-footer-line-height);
|
line-height: var(--letter-footer-line-height);
|
||||||
color: var(--letter-color-white);
|
color: var(--letter-body-color);
|
||||||
vertical-align: middle;
|
|
||||||
-webkit-print-color-adjust: exact;
|
|
||||||
print-color-adjust: exact;
|
|
||||||
}
|
|
||||||
|
|
||||||
.letter-footer-address-inner {
|
|
||||||
background-color: var(--letter-color-blue);
|
|
||||||
color: var(--letter-color-white);
|
|
||||||
padding: 8px 12px;
|
|
||||||
font-size: var(--letter-footer-font-size);
|
|
||||||
line-height: var(--letter-footer-line-height);
|
|
||||||
text-align: left;
|
|
||||||
vertical-align: middle;
|
|
||||||
-webkit-print-color-adjust: exact;
|
|
||||||
print-color-adjust: exact;
|
|
||||||
}
|
|
||||||
|
|
||||||
.letter-footer-lines {
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
|
|
||||||
.letter-footer-icon {
|
|
||||||
width: 18px;
|
|
||||||
padding: 2px 8px 2px 0;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.letter-footer-line-text {
|
|
||||||
padding: 2px 0;
|
|
||||||
color: var(--letter-color-white);
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
@@ -322,17 +280,22 @@
|
|||||||
|
|
||||||
<div class="letter-content">
|
<div class="letter-content">
|
||||||
<div class="meta-table">
|
<div class="meta-table">
|
||||||
@if ($showReference && trim($__env->yieldContent('letter-reference')) !== '')
|
@if (($showReference && trim($__env->yieldContent('letter-reference')) !== '') || ($showDate && $letterDate !== ''))
|
||||||
<table cellpadding="0" cellspacing="0">
|
<table cellpadding="0" cellspacing="0">
|
||||||
<tr>
|
@if ($showReference && trim($__env->yieldContent('letter-reference')) !== '')
|
||||||
<td class="meta-label">Ruj.</td>
|
<tr>
|
||||||
<td class="meta-value">: @yield('letter-reference')</td>
|
<td class="meta-label">RUJ. KAMI</td>
|
||||||
</tr>
|
<td class="meta-value">: @yield('letter-reference')</td>
|
||||||
|
</tr>
|
||||||
|
@endif
|
||||||
|
@if ($showDate && $letterDate !== '')
|
||||||
|
<tr>
|
||||||
|
<td class="meta-label">TARIKH</td>
|
||||||
|
<td class="meta-value">: {{ $letterDate }}</td>
|
||||||
|
</tr>
|
||||||
|
@endif
|
||||||
</table>
|
</table>
|
||||||
@endif
|
@endif
|
||||||
@if ($showDate && $letterDate !== '')
|
|
||||||
<div class="meta-date">{{ $letterDate }}</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@hasSection('letter-recipient')
|
@hasSection('letter-recipient')
|
||||||
@@ -341,6 +304,10 @@
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
@hasSection('letter-honorific')
|
||||||
|
<div class="letter-honorific">@yield('letter-honorific')</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
@if ($showSubject && trim($__env->yieldContent('letter-subject')) !== '')
|
@if ($showSubject && trim($__env->yieldContent('letter-subject')) !== '')
|
||||||
<div class="subject-block">
|
<div class="subject-block">
|
||||||
@yield('letter-subject')
|
@yield('letter-subject')
|
||||||
|
|||||||
@@ -1,38 +1,11 @@
|
|||||||
<table class="letter-footer" width="100%" cellpadding="0" cellspacing="0" border="0">
|
<table class="letter-footer" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="letter-footer-contact-box" valign="middle" width="50%" bgcolor="#d35400">
|
<td class="letter-footer-divider"> </td>
|
||||||
<table class="letter-footer-lines" width="100%" cellpadding="0" cellspacing="0" border="0">
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="letter-footer-icon" valign="middle">@include('pdf.partials.footer-icon', ['icon' => 'phone'])</td>
|
<td class="letter-footer-nota">
|
||||||
<td class="letter-footer-line-text" valign="middle">Tel. Pejabat: 09-741 4331</td>
|
<strong>Nota:</strong><br>
|
||||||
</tr>
|
<em>Surat ini dijana secara komputer dan merupakan dokumen rasmi Koperasi Permodalan Kelantan Berhad (KoPKB). Oleh itu, surat ini adalah sah tanpa tandatangan.</em>
|
||||||
<tr>
|
|
||||||
<td class="letter-footer-icon" valign="middle">@include('pdf.partials.footer-icon', ['icon' => 'fax'])</td>
|
|
||||||
<td class="letter-footer-line-text" valign="middle">Faks: 09-741 4330</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="letter-footer-icon" valign="middle">@include('pdf.partials.footer-icon', ['icon' => 'email'])</td>
|
|
||||||
<td class="letter-footer-line-text" valign="middle">E-mel: admin@koppkb.com</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="letter-footer-icon" valign="middle">@include('pdf.partials.footer-icon', ['icon' => 'web'])</td>
|
|
||||||
<td class="letter-footer-line-text" valign="middle">Laman Web: www.koppkb.com</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
<td class="letter-footer-address-inner" valign="middle" width="50%" bgcolor="#0e579b">
|
|
||||||
<table class="letter-footer-lines" width="100%" cellpadding="0" cellspacing="0" border="0">
|
|
||||||
<tr>
|
|
||||||
<td class="letter-footer-icon" rowspan="3" valign="middle">@include('pdf.partials.footer-icon', ['icon' => 'location'])</td>
|
|
||||||
<td class="letter-footer-line-text" valign="middle">Lot PT 448. Tingkat 1, Jalan Kuala Krai</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="letter-footer-line-text" valign="middle">Batu 3 Wakaf Che Yeh</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="letter-footer-line-text" valign="middle">15150 Kota Bharu, Kelantan Darul Naim</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -1,28 +1,15 @@
|
|||||||
<table class="letterhead" width="100%" cellpadding="0" cellspacing="0">
|
<table class="letterhead" width="100%" cellpadding="0" cellspacing="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="letterhead-line" valign="middle">
|
<td class="letterhead-center" align="center" valign="top">
|
||||||
<table class="letterhead-rule" width="100%" cellpadding="0" cellspacing="0">
|
@if (! empty($logoUrl))
|
||||||
<tr>
|
<img src="{{ $logoUrl }}" alt="Koperasi Permodalan Kelantan Berhad" class="letterhead-logo-img">
|
||||||
<td class="letterhead-rule-blue" width="50%" height="6" bgcolor="#0e579b"> </td>
|
@endif
|
||||||
<td class="letterhead-rule-orange" width="50%" height="6" bgcolor="#d35400"> </td>
|
<div class="letterhead-address">
|
||||||
</tr>
|
Lot Pt 448, Tingkat 1, Jalan Kuala Krai, Batu 3, Wakaf Che Yeh, 15150 Kota Bharu, Kelantan.
|
||||||
</table>
|
</div>
|
||||||
</td>
|
<div class="letterhead-contact">
|
||||||
<td class="letterhead-brand" valign="middle" align="right" width="1">
|
Tel: 09-7414331 | Fax: 09-7414330 | Web:www.koppkb.com
|
||||||
<table class="letterhead-brand-inner" cellpadding="0" cellspacing="0" align="right">
|
</div>
|
||||||
<tr>
|
|
||||||
@if (! empty($logoUrl))
|
|
||||||
<td class="letterhead-logo" valign="middle">
|
|
||||||
<img src="{{ $logoUrl }}" alt="Koperasi Permodalan Kelantan Berhad" class="letterhead-logo-img">
|
|
||||||
</td>
|
|
||||||
@endif
|
|
||||||
<td class="letterhead-name" valign="middle">
|
|
||||||
<div class="letterhead-name-line">KOPERASI PERMODALAN</div>
|
|
||||||
<div class="letterhead-name-line">KELANTAN BERHAD</div>
|
|
||||||
<div class="letterhead-registration">D-5-0228</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -7,13 +7,18 @@
|
|||||||
@section('letter-date', now()->translatedFormat('d F Y'))
|
@section('letter-date', now()->translatedFormat('d F Y'))
|
||||||
|
|
||||||
@section('letter-recipient')
|
@section('letter-recipient')
|
||||||
|
{{-- auto fetch from membership_application_applicants --}}
|
||||||
<strong>Ahmad bin Abdullah</strong><br>
|
<strong>Ahmad bin Abdullah</strong><br>
|
||||||
No. 12, Jalan Contoh,<br>
|
No. 12, Jalan Contoh,<br>
|
||||||
43000 Kajang, Selangor
|
43000 Kajang, Selangor
|
||||||
@endsection
|
@endsection
|
||||||
|
|
||||||
|
@section('letter-honorific')
|
||||||
|
Tuan/Puan,
|
||||||
|
@endsection
|
||||||
|
|
||||||
@section('letter-subject')
|
@section('letter-subject')
|
||||||
<strong>KELULUSAN KEANGGOTAAN KOPERASI PERMODALAN KELANTAN BERHAD</strong>
|
<strong>KELULUSAN PERMOHONAN MENJADI ANGGOTA KOPERASI PERMODALAN KELANTAN BERHAD (KoPKB)</strong>
|
||||||
@endsection
|
@endsection
|
||||||
|
|
||||||
{{-- @section('letter-salutation')
|
{{-- @section('letter-salutation')
|
||||||
@@ -22,39 +27,57 @@
|
|||||||
|
|
||||||
@section('letter-body')
|
@section('letter-body')
|
||||||
<p style="line-height: 1;">
|
<p style="line-height: 1;">
|
||||||
Dengan segala hormatnya saya merujuk kepada perkara di atas.
|
Dengan segala hormatnya perkara di atas adalah dirujuk.
|
||||||
</p>
|
</p>
|
||||||
<p style="line-height: 1;">
|
<p style="line-height: 1;">
|
||||||
2. Sukacita dimaklumkan bahawa, permohonan tuan untuk menjadi anggota Koperasi
|
2. Sukacita dimaklumkan bahawa, permohonan tuan/puan untuk menjadi <strong>
|
||||||
Permodalan Kelantan Berhad(KoPKB) telah <strong>DILULUSKAN</strong> dalam Mesyuarat Lembaga
|
Anggota Koperasi Permodalan Kelantan Berhad(KoPKB)</strong> telah dipertimbangkan dan <strong>DILULUSKAN</strong>
|
||||||
Koperasi Bil. 4-2025/2026 pada 24 Jun 2026.
|
oleh Lembaga Koperasi dalam Mesyuarat Lembaga Bil. yang diadakan pada 01 Jan 2026.
|
||||||
</p>
|
</p>
|
||||||
<p style="line-height: 1;">
|
<p style="line-height: 1;">
|
||||||
3. Pihak Koperasi akan membuat potongan gaji melalui majikan tuan sebagaiman ketetapan berikut:-
|
3. Maklumat keanggotaan tuan/puan adalah seperti berikut:-
|
||||||
<ul style="line-height: 1;">
|
</p>
|
||||||
<li>Saham</li>
|
<table class="letter-info-table" cellpadding="0" cellspacing="0">
|
||||||
<ul style="line-height: 1;">
|
<tr>
|
||||||
<li>Potongan sebanyak RM 84.00 (Mei 2026 - Jun 2026)</li>
|
<th>Perkara</th>
|
||||||
<li>Potongan sebanyak RM 50.00 sehingga Syer Maksima</li>
|
<th>Maklumat</th>
|
||||||
</ul>
|
</tr>
|
||||||
<li>Yuran</li>
|
<tr>
|
||||||
<ul style="line-height: 1;">
|
<td>Nama Anggota</td>
|
||||||
<li>Potongan sebanyak RM 50.00 setiap bulan</li>
|
<td>Ali</td>
|
||||||
</ul>
|
</tr>
|
||||||
<li>Fi Masuk</li>
|
<tr>
|
||||||
<ul style="line-height: 1;">
|
<td>No. Kad Pengenalan</td>
|
||||||
<li>Potongan sebanyak RM 10.00 (Pembayaran Pertama)</li>
|
<td>0123456789012345</td>
|
||||||
</ul>
|
</tr>
|
||||||
</ul>
|
<tr>
|
||||||
|
<td>No. Anggota</td>
|
||||||
|
<td>123</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Tarikh Kuat Kuasa Keanggotaan</td>
|
||||||
|
<td>01 Jan</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Modal Syer Permulaan</td>
|
||||||
|
<td>RM500.00 (perlu dijelaskan dalam tempoh 6 bulan)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Fi Masuk</td>
|
||||||
|
<td>RM10.00</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Yuran Tahunan</td>
|
||||||
|
<td>RM30.00 sebulan</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p style="line-height: 1;">
|
||||||
|
4. Sebagai anggota KoPKB, tuan/puan adalah tertakluk kepada Undang-Undang Kecil (UUK), dasar, polisi dan peraturan-peraturan KoPKB yang berkuat kuasa dari semasa ke semasa.
|
||||||
</p>
|
</p>
|
||||||
<p style="line-height: 1;">
|
<p style="line-height: 1;">
|
||||||
4. Untuk makluman tuan, kelulusan keanggotaan ini adalah bersyarat di mana tuan dikehendaki
|
5. Sebagai anggota, tuan/puan adalah layak menikmati kemudahan, produk dan manfaat yang ditawarkan oleh koperasi dari semasa ke semasa, tertakluk kepada syarat-syarat yang ditetapkan. </p>
|
||||||
menjelaskan modal syer minimum sebanyak RM500.00. Bayaran tersebut
|
<p style="line-height: 1;">
|
||||||
perlu dibuat secara ansuran melalui potongan gaji dalam tempoh enam (6) bulan.
|
6. Pihak KoPKB mengucapkan tahniah dan mengalu-alukan penyertaan tuan/puan sebagai anggota. Semoga keanggotaan ini akan memberi manfaat kepada tuan/puan serta menyumbang kepada kemajuan dan kecemerlangan KoPKB.
|
||||||
Pemenuhan syarat ini adalah bagi melayakkan tuan memperoleh hak, kewajipan dan
|
|
||||||
liabiliti sebagai anggota KoPKB sebagaimana terkandung di dalam Undang-Undang Kecil (UUK),
|
|
||||||
Perkara 59 yang telah didaftarkan pada 11 September 2024. Manakala had maksima modal syer adalah
|
|
||||||
sebanyak RM5,000.00.
|
|
||||||
</p>
|
</p>
|
||||||
@endsection
|
@endsection
|
||||||
|
|
||||||
@@ -63,10 +86,5 @@
|
|||||||
@endsection
|
@endsection
|
||||||
|
|
||||||
@section('letter-signature')
|
@section('letter-signature')
|
||||||
<div class="signature-header">Dengan hormatnya,</div>
|
|
||||||
<div class="signature-company">KOPERASI PERMODALAN KELANTAN BERHAD</div>
|
<div class="signature-company">KOPERASI PERMODALAN KELANTAN BERHAD</div>
|
||||||
<div class="signature-space"></div>
|
|
||||||
<div class="signatory-name">MUHAMMAD NAFIS BIN ZAINUDDIN</div>
|
|
||||||
<div class="signatory-title">Pengurus Besar</div>
|
|
||||||
<div class="signatory-title">b/p Setiausaha</div>
|
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@@ -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,27 +3,7 @@
|
|||||||
use Illuminate\Foundation\Inspiring;
|
use Illuminate\Foundation\Inspiring;
|
||||||
use Illuminate\Support\Facades\Artisan;
|
use Illuminate\Support\Facades\Artisan;
|
||||||
use Illuminate\Support\Facades\Schedule;
|
use Illuminate\Support\Facades\Schedule;
|
||||||
use App\Jobs\CaptureKJCHistoricalDataJob;
|
|
||||||
use App\Jobs\CapturePKJHistoricalDataJob;
|
|
||||||
|
|
||||||
Artisan::command('inspire', function () {
|
Artisan::command('inspire', function () {
|
||||||
$this->comment(Inspiring::quote());
|
$this->comment(Inspiring::quote());
|
||||||
})->purpose('Display an inspiring quote');
|
})->purpose('Display an inspiring quote');
|
||||||
|
|
||||||
// Schedule KJC Historical Data Capture to run monthly on the 1st day at 2:00 AM
|
|
||||||
Schedule::job(new CaptureKJCHistoricalDataJob())
|
|
||||||
->monthlyOn(1, '02:00')
|
|
||||||
->withoutOverlapping()
|
|
||||||
->name('kjc-historical-data-capture');
|
|
||||||
|
|
||||||
// Schedule PKJ Historical Data Capture to run monthly on the 1st day at 2:30 AM
|
|
||||||
Schedule::job(new CapturePKJHistoricalDataJob())
|
|
||||||
->monthlyOn(1, '02:30')
|
|
||||||
->withoutOverlapping()
|
|
||||||
->name('pkj-historical-data-capture');
|
|
||||||
|
|
||||||
// Auto-generate weekly KJC reports for Jabatan Arah RAJD every Monday at 3:00 AM
|
|
||||||
Schedule::command('kjc:auto-generate-weekly-report "Jabatan Arah RAJD"')
|
|
||||||
->weeklyOn(1, '03:00') // Monday at 3:00 AM
|
|
||||||
->withoutOverlapping()
|
|
||||||
->name('kjc-auto-weekly-report');
|
|
||||||
@@ -7,8 +7,5 @@ Route::get('/', function () {
|
|||||||
return redirect('/login');
|
return redirect('/login');
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::get('/letters/preview', [LetterPreviewController::class, 'show'])
|
|
||||||
->name('letters.preview');
|
|
||||||
|
|
||||||
Route::get('/letters/preview-approved-membership', [LetterPreviewController::class, 'showMembershipApprovedLetter'])
|
Route::get('/letters/preview-approved-membership', [LetterPreviewController::class, 'showMembershipApprovedLetter'])
|
||||||
->name('letters.preview-membership-approved-membership');
|
->name('letters.preview-membership-approved-membership');
|
||||||
@@ -7,10 +7,16 @@ RUN npm install -g pnpm && \
|
|||||||
pnpm install --frozen-lockfile --ignore-scripts && \
|
pnpm install --frozen-lockfile --ignore-scripts && \
|
||||||
pnpm rebuild esbuild
|
pnpm rebuild esbuild
|
||||||
COPY fe/ ./
|
COPY fe/ ./
|
||||||
|
ARG VITE_APP_NAME="MyKOPKB"
|
||||||
|
ARG VITE_APP_VERSION="1.0"
|
||||||
ARG VITE_API_BASE_URL=https://api.koppkb.com
|
ARG VITE_API_BASE_URL=https://api.koppkb.com
|
||||||
ARG VITE_APP_URL=https://anggota.koppkb.com
|
ARG VITE_APP_URL=https://mykopkb.com
|
||||||
|
|
||||||
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
||||||
ENV VITE_APP_URL=$VITE_APP_URL
|
ENV VITE_APP_URL=$VITE_APP_URL
|
||||||
|
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 run typecheck # uncomment this to run typecheck
|
||||||
RUN pnpm exec vite build --mode=production
|
RUN pnpm exec vite build --mode=production
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,7 @@
|
|||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"maplibre-gl": "^5.18.0",
|
"maplibre-gl": "^5.18.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"sweetalert2": "^11.26.25",
|
"sweetalert2": "^11.26.25",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
@@ -71,6 +72,7 @@
|
|||||||
"@types/jsdom": "^27.0.0",
|
"@types/jsdom": "^27.0.0",
|
||||||
"@types/lodash": "^4.17.21",
|
"@types/lodash": "^4.17.21",
|
||||||
"@types/node": "^24.10.4",
|
"@types/node": "^24.10.4",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@vitejs/plugin-vue": "^6.0.3",
|
"@vitejs/plugin-vue": "^6.0.3",
|
||||||
"@vitest/eslint-plugin": "^1.6.4",
|
"@vitest/eslint-plugin": "^1.6.4",
|
||||||
"@vue/eslint-config-prettier": "^10.2.0",
|
"@vue/eslint-config-prettier": "^10.2.0",
|
||||||
|
|||||||
@@ -119,6 +119,9 @@ importers:
|
|||||||
pinia:
|
pinia:
|
||||||
specifier: ^3.0.4
|
specifier: ^3.0.4
|
||||||
version: 3.0.4(typescript@5.9.3)(vue@3.5.35(typescript@5.9.3))
|
version: 3.0.4(typescript@5.9.3)(vue@3.5.35(typescript@5.9.3))
|
||||||
|
qrcode:
|
||||||
|
specifier: ^1.5.4
|
||||||
|
version: 1.5.4
|
||||||
sweetalert2:
|
sweetalert2:
|
||||||
specifier: ^11.26.25
|
specifier: ^11.26.25
|
||||||
version: 11.26.25
|
version: 11.26.25
|
||||||
@@ -156,6 +159,9 @@ importers:
|
|||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^24.10.4
|
specifier: ^24.10.4
|
||||||
version: 24.12.4
|
version: 24.12.4
|
||||||
|
'@types/qrcode':
|
||||||
|
specifier: ^1.5.6
|
||||||
|
version: 1.5.6
|
||||||
'@vitejs/plugin-vue':
|
'@vitejs/plugin-vue':
|
||||||
specifier: ^6.0.3
|
specifier: ^6.0.3
|
||||||
version: 6.0.7(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(lightningcss@1.32.0))(vue@3.5.35(typescript@5.9.3))
|
version: 6.0.7(vite@7.3.5(@types/node@24.12.4)(jiti@2.7.0)(lightningcss@1.32.0))(vue@3.5.35(typescript@5.9.3))
|
||||||
@@ -1041,6 +1047,9 @@ packages:
|
|||||||
'@types/pako@2.0.4':
|
'@types/pako@2.0.4':
|
||||||
resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==}
|
resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==}
|
||||||
|
|
||||||
|
'@types/qrcode@1.5.6':
|
||||||
|
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
|
||||||
|
|
||||||
'@types/raf@3.4.3':
|
'@types/raf@3.4.3':
|
||||||
resolution: {integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==}
|
resolution: {integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==}
|
||||||
|
|
||||||
@@ -1588,6 +1597,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
|
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
camelcase@5.3.1:
|
||||||
|
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
caniuse-lite@1.0.30001793:
|
caniuse-lite@1.0.30001793:
|
||||||
resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==}
|
resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==}
|
||||||
|
|
||||||
@@ -1637,6 +1650,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==}
|
resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
|
cliui@6.0.0:
|
||||||
|
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
|
||||||
|
|
||||||
clsx@2.1.1:
|
clsx@2.1.1:
|
||||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -1758,6 +1774,10 @@ packages:
|
|||||||
supports-color:
|
supports-color:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
decamelize@1.2.0:
|
||||||
|
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
decimal.js@10.6.0:
|
decimal.js@10.6.0:
|
||||||
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
||||||
|
|
||||||
@@ -1784,6 +1804,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
dijkstrajs@1.0.3:
|
||||||
|
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
|
||||||
|
|
||||||
dompurify@3.4.7:
|
dompurify@3.4.7:
|
||||||
resolution: {integrity: sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==}
|
resolution: {integrity: sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==}
|
||||||
|
|
||||||
@@ -2055,6 +2078,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
find-up@4.1.0:
|
||||||
|
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
find-up@5.0.0:
|
find-up@5.0.0:
|
||||||
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
|
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -2109,6 +2136,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
|
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
get-caller-file@2.0.5:
|
||||||
|
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
|
||||||
|
engines: {node: 6.* || 8.* || >= 10.*}
|
||||||
|
|
||||||
get-east-asian-width@1.6.0:
|
get-east-asian-width@1.6.0:
|
||||||
resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==}
|
resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -2501,6 +2532,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==}
|
resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==}
|
||||||
engines: {node: '>=20.0.0'}
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
|
locate-path@5.0.0:
|
||||||
|
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
locate-path@6.0.0:
|
locate-path@6.0.0:
|
||||||
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -2679,14 +2714,26 @@ packages:
|
|||||||
ospath@1.2.2:
|
ospath@1.2.2:
|
||||||
resolution: {integrity: sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA==}
|
resolution: {integrity: sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA==}
|
||||||
|
|
||||||
|
p-limit@2.3.0:
|
||||||
|
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
p-limit@3.1.0:
|
p-limit@3.1.0:
|
||||||
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
|
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
p-locate@4.1.0:
|
||||||
|
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
p-locate@5.0.0:
|
p-locate@5.0.0:
|
||||||
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
p-try@2.2.0:
|
||||||
|
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
package-json-from-dist@1.0.1:
|
package-json-from-dist@1.0.1:
|
||||||
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
|
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
|
||||||
|
|
||||||
@@ -2769,6 +2816,10 @@ packages:
|
|||||||
typescript:
|
typescript:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
pngjs@5.0.0:
|
||||||
|
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
|
||||||
|
engines: {node: '>=10.13.0'}
|
||||||
|
|
||||||
postcss-selector-parser@7.1.1:
|
postcss-selector-parser@7.1.1:
|
||||||
resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==}
|
resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@@ -2833,6 +2884,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
qrcode@1.5.4:
|
||||||
|
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
|
||||||
|
engines: {node: '>=10.13.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
qs@6.15.2:
|
qs@6.15.2:
|
||||||
resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==}
|
resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==}
|
||||||
engines: {node: '>=0.6'}
|
engines: {node: '>=0.6'}
|
||||||
@@ -2856,10 +2912,17 @@ packages:
|
|||||||
request-progress@3.0.0:
|
request-progress@3.0.0:
|
||||||
resolution: {integrity: sha512-MnWzEHHaxHO2iWiQuHrUPBi/1WeBf5PkxQqNyNvLl9VAYSdXkP8tQ3pBSeCPD+yw0v0Aq1zosWLz0BdeXpWwZg==}
|
resolution: {integrity: sha512-MnWzEHHaxHO2iWiQuHrUPBi/1WeBf5PkxQqNyNvLl9VAYSdXkP8tQ3pBSeCPD+yw0v0Aq1zosWLz0BdeXpWwZg==}
|
||||||
|
|
||||||
|
require-directory@2.1.1:
|
||||||
|
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
require-from-string@2.0.2:
|
require-from-string@2.0.2:
|
||||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
require-main-filename@2.0.0:
|
||||||
|
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
|
||||||
|
|
||||||
resolve-from@4.0.0:
|
resolve-from@4.0.0:
|
||||||
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@@ -2916,6 +2979,9 @@ packages:
|
|||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
set-blocking@2.0.0:
|
||||||
|
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
|
||||||
|
|
||||||
shebang-command@2.0.0:
|
shebang-command@2.0.0:
|
||||||
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -3393,6 +3459,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==}
|
resolution: {integrity: sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
|
which-module@2.0.1:
|
||||||
|
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
|
||||||
|
|
||||||
which@2.0.2:
|
which@2.0.2:
|
||||||
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -3420,6 +3489,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==}
|
resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==}
|
||||||
engines: {node: '>=0.8'}
|
engines: {node: '>=0.8'}
|
||||||
|
|
||||||
|
wrap-ansi@6.2.0:
|
||||||
|
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
wrap-ansi@7.0.0:
|
wrap-ansi@7.0.0:
|
||||||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -3467,9 +3540,20 @@ packages:
|
|||||||
xmlchars@2.2.0:
|
xmlchars@2.2.0:
|
||||||
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
|
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
|
||||||
|
|
||||||
|
y18n@4.0.3:
|
||||||
|
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
|
||||||
|
|
||||||
yallist@3.1.1:
|
yallist@3.1.1:
|
||||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||||
|
|
||||||
|
yargs-parser@18.1.3:
|
||||||
|
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
yargs@15.4.1:
|
||||||
|
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
yauzl@3.3.2:
|
yauzl@3.3.2:
|
||||||
resolution: {integrity: sha512-Md9ankxxN23wncAN8s7+Tn3Co52zLUPMtnrLAbVCnfG5d2tKBFfmygYSgXlqFgXObtzIgqkx7aNgDBpso9+4qA==}
|
resolution: {integrity: sha512-Md9ankxxN23wncAN8s7+Tn3Co52zLUPMtnrLAbVCnfG5d2tKBFfmygYSgXlqFgXObtzIgqkx7aNgDBpso9+4qA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -4215,6 +4299,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/pako@2.0.4': {}
|
'@types/pako@2.0.4': {}
|
||||||
|
|
||||||
|
'@types/qrcode@1.5.6':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 24.12.4
|
||||||
|
|
||||||
'@types/raf@3.4.3':
|
'@types/raf@3.4.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -4978,6 +5066,8 @@ snapshots:
|
|||||||
|
|
||||||
callsites@3.1.0: {}
|
callsites@3.1.0: {}
|
||||||
|
|
||||||
|
camelcase@5.3.1: {}
|
||||||
|
|
||||||
caniuse-lite@1.0.30001793: {}
|
caniuse-lite@1.0.30001793: {}
|
||||||
|
|
||||||
canvg@3.0.11:
|
canvg@3.0.11:
|
||||||
@@ -5033,6 +5123,12 @@ snapshots:
|
|||||||
slice-ansi: 8.0.0
|
slice-ansi: 8.0.0
|
||||||
string-width: 8.2.1
|
string-width: 8.2.1
|
||||||
|
|
||||||
|
cliui@6.0.0:
|
||||||
|
dependencies:
|
||||||
|
string-width: 4.2.3
|
||||||
|
strip-ansi: 6.0.1
|
||||||
|
wrap-ansi: 6.2.0
|
||||||
|
|
||||||
clsx@2.1.1: {}
|
clsx@2.1.1: {}
|
||||||
|
|
||||||
codepage@1.15.0: {}
|
codepage@1.15.0: {}
|
||||||
@@ -5172,6 +5268,8 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
supports-color: 8.1.1
|
supports-color: 8.1.1
|
||||||
|
|
||||||
|
decamelize@1.2.0: {}
|
||||||
|
|
||||||
decimal.js@10.6.0: {}
|
decimal.js@10.6.0: {}
|
||||||
|
|
||||||
deep-is@0.1.4: {}
|
deep-is@0.1.4: {}
|
||||||
@@ -5189,6 +5287,8 @@ snapshots:
|
|||||||
|
|
||||||
detect-libc@2.1.2: {}
|
detect-libc@2.1.2: {}
|
||||||
|
|
||||||
|
dijkstrajs@1.0.3: {}
|
||||||
|
|
||||||
dompurify@3.4.7:
|
dompurify@3.4.7:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/trusted-types': 2.0.7
|
'@types/trusted-types': 2.0.7
|
||||||
@@ -5505,6 +5605,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
to-regex-range: 5.0.1
|
to-regex-range: 5.0.1
|
||||||
|
|
||||||
|
find-up@4.1.0:
|
||||||
|
dependencies:
|
||||||
|
locate-path: 5.0.0
|
||||||
|
path-exists: 4.0.0
|
||||||
|
|
||||||
find-up@5.0.0:
|
find-up@5.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
locate-path: 6.0.0
|
locate-path: 6.0.0
|
||||||
@@ -5554,6 +5659,8 @@ snapshots:
|
|||||||
|
|
||||||
gensync@1.0.0-beta.2: {}
|
gensync@1.0.0-beta.2: {}
|
||||||
|
|
||||||
|
get-caller-file@2.0.5: {}
|
||||||
|
|
||||||
get-east-asian-width@1.6.0: {}
|
get-east-asian-width@1.6.0: {}
|
||||||
|
|
||||||
get-intrinsic@1.3.0:
|
get-intrinsic@1.3.0:
|
||||||
@@ -5928,6 +6035,10 @@ snapshots:
|
|||||||
rfdc: 1.4.1
|
rfdc: 1.4.1
|
||||||
wrap-ansi: 9.0.2
|
wrap-ansi: 9.0.2
|
||||||
|
|
||||||
|
locate-path@5.0.0:
|
||||||
|
dependencies:
|
||||||
|
p-locate: 4.1.0
|
||||||
|
|
||||||
locate-path@6.0.0:
|
locate-path@6.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
p-locate: 5.0.0
|
p-locate: 5.0.0
|
||||||
@@ -6107,14 +6218,24 @@ snapshots:
|
|||||||
|
|
||||||
ospath@1.2.2: {}
|
ospath@1.2.2: {}
|
||||||
|
|
||||||
|
p-limit@2.3.0:
|
||||||
|
dependencies:
|
||||||
|
p-try: 2.2.0
|
||||||
|
|
||||||
p-limit@3.1.0:
|
p-limit@3.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
yocto-queue: 0.1.0
|
yocto-queue: 0.1.0
|
||||||
|
|
||||||
|
p-locate@4.1.0:
|
||||||
|
dependencies:
|
||||||
|
p-limit: 2.3.0
|
||||||
|
|
||||||
p-locate@5.0.0:
|
p-locate@5.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
p-limit: 3.1.0
|
p-limit: 3.1.0
|
||||||
|
|
||||||
|
p-try@2.2.0: {}
|
||||||
|
|
||||||
package-json-from-dist@1.0.1: {}
|
package-json-from-dist@1.0.1: {}
|
||||||
|
|
||||||
pako@2.1.0: {}
|
pako@2.1.0: {}
|
||||||
@@ -6177,6 +6298,8 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
|
|
||||||
|
pngjs@5.0.0: {}
|
||||||
|
|
||||||
postcss-selector-parser@7.1.1:
|
postcss-selector-parser@7.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
cssesc: 3.0.0
|
cssesc: 3.0.0
|
||||||
@@ -6225,6 +6348,12 @@ snapshots:
|
|||||||
|
|
||||||
punycode@2.3.1: {}
|
punycode@2.3.1: {}
|
||||||
|
|
||||||
|
qrcode@1.5.4:
|
||||||
|
dependencies:
|
||||||
|
dijkstrajs: 1.0.3
|
||||||
|
pngjs: 5.0.0
|
||||||
|
yargs: 15.4.1
|
||||||
|
|
||||||
qs@6.15.2:
|
qs@6.15.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
side-channel: 1.1.0
|
side-channel: 1.1.0
|
||||||
@@ -6250,8 +6379,12 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
throttleit: 1.0.1
|
throttleit: 1.0.1
|
||||||
|
|
||||||
|
require-directory@2.1.1: {}
|
||||||
|
|
||||||
require-from-string@2.0.2: {}
|
require-from-string@2.0.2: {}
|
||||||
|
|
||||||
|
require-main-filename@2.0.0: {}
|
||||||
|
|
||||||
resolve-from@4.0.0: {}
|
resolve-from@4.0.0: {}
|
||||||
|
|
||||||
resolve-protobuf-schema@2.1.0:
|
resolve-protobuf-schema@2.1.0:
|
||||||
@@ -6323,6 +6456,8 @@ snapshots:
|
|||||||
|
|
||||||
semver@7.8.1: {}
|
semver@7.8.1: {}
|
||||||
|
|
||||||
|
set-blocking@2.0.0: {}
|
||||||
|
|
||||||
shebang-command@2.0.0:
|
shebang-command@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
shebang-regex: 3.0.0
|
shebang-regex: 3.0.0
|
||||||
@@ -6781,6 +6916,8 @@ snapshots:
|
|||||||
tr46: 6.0.0
|
tr46: 6.0.0
|
||||||
webidl-conversions: 8.0.1
|
webidl-conversions: 8.0.1
|
||||||
|
|
||||||
|
which-module@2.0.1: {}
|
||||||
|
|
||||||
which@2.0.2:
|
which@2.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
isexe: 2.0.0
|
isexe: 2.0.0
|
||||||
@@ -6800,6 +6937,12 @@ snapshots:
|
|||||||
|
|
||||||
word@0.3.0: {}
|
word@0.3.0: {}
|
||||||
|
|
||||||
|
wrap-ansi@6.2.0:
|
||||||
|
dependencies:
|
||||||
|
ansi-styles: 4.3.0
|
||||||
|
string-width: 4.2.3
|
||||||
|
strip-ansi: 6.0.1
|
||||||
|
|
||||||
wrap-ansi@7.0.0:
|
wrap-ansi@7.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
ansi-styles: 4.3.0
|
ansi-styles: 4.3.0
|
||||||
@@ -6843,8 +6986,29 @@ snapshots:
|
|||||||
|
|
||||||
xmlchars@2.2.0: {}
|
xmlchars@2.2.0: {}
|
||||||
|
|
||||||
|
y18n@4.0.3: {}
|
||||||
|
|
||||||
yallist@3.1.1: {}
|
yallist@3.1.1: {}
|
||||||
|
|
||||||
|
yargs-parser@18.1.3:
|
||||||
|
dependencies:
|
||||||
|
camelcase: 5.3.1
|
||||||
|
decamelize: 1.2.0
|
||||||
|
|
||||||
|
yargs@15.4.1:
|
||||||
|
dependencies:
|
||||||
|
cliui: 6.0.0
|
||||||
|
decamelize: 1.2.0
|
||||||
|
find-up: 4.1.0
|
||||||
|
get-caller-file: 2.0.5
|
||||||
|
require-directory: 2.1.1
|
||||||
|
require-main-filename: 2.0.0
|
||||||
|
set-blocking: 2.0.0
|
||||||
|
string-width: 4.2.3
|
||||||
|
which-module: 2.0.1
|
||||||
|
y18n: 4.0.3
|
||||||
|
yargs-parser: 18.1.3
|
||||||
|
|
||||||
yauzl@3.3.2:
|
yauzl@3.3.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
pend: 1.2.0
|
pend: 1.2.0
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 902 KiB |
|
Before Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 93 KiB |