Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1003198596 | |||
| 5b65acbafb | |||
| 0419a551dc | |||
| 51271b8e59 | |||
| 1cf08e4ae0 |
+11
-1
@@ -104,4 +104,14 @@ ONEWAYSMS_API_USERNAME=
|
||||
ONEWAYSMS_API_PASSWORD=
|
||||
ONEWAYSMS_SENDER_ID=
|
||||
|
||||
EXTERNAL_SYSTEM_SSO_ISSUER=mykopkb
|
||||
EXTERNAL_SYSTEM_SSO_ISSUER=mykopkb
|
||||
|
||||
# Billplz (Investment module) — use sandbox credentials locally
|
||||
BILLPLZ_API_KEY=
|
||||
BILLPLZ_X_SIGNATURE_KEY=
|
||||
BILLPLZ_COLLECTION_ID=
|
||||
BILLPLZ_BASE_URL=https://www.billplz-sandbox.com/api/v3
|
||||
# Optional overrides; defaults: {APP_URL}/v1/billplz/callback
|
||||
BILLPLZ_CALLBACK_URL=
|
||||
# Frontend return URL, investment_id query is appended automatically
|
||||
BILLPLZ_REDIRECT_URL=
|
||||
@@ -19,8 +19,6 @@ class ActivityLogController extends Controller
|
||||
{
|
||||
$this->authorize('viewAny', Activity::class);
|
||||
|
||||
// ActivityLogger::logView(new Activity, 'Viewed activity logs');
|
||||
|
||||
$perPage = $request->get('per_page', 10);
|
||||
$perPage = min($perPage, 1000); // Limit max per page to 100
|
||||
$search = trim((string) $request->get('search', ''));
|
||||
|
||||
@@ -11,6 +11,7 @@ use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
use Spatie\Activitylog\Facades\Activity;
|
||||
use Spatie\Activitylog\LogOptions;
|
||||
use Spatie\Activitylog\Traits\LogsActivity;
|
||||
use Spatie\Permission\Contracts\Permission;
|
||||
@@ -105,22 +106,24 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
|
||||
public function ensurePublicProfileToken(): string
|
||||
{
|
||||
$expiryDays = self::publicProfileTokenTtlDays();
|
||||
return Activity::withoutLogs(function () {
|
||||
$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;
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -189,6 +192,12 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
{
|
||||
return LogOptions::defaults()
|
||||
->logAll()
|
||||
->logExcept([
|
||||
'public_profile_token',
|
||||
'public_profile_token_expires_at',
|
||||
'remember_token',
|
||||
'updated_at',
|
||||
])
|
||||
->logOnlyDirty()
|
||||
->dontSubmitEmptyLogs()
|
||||
->setDescriptionForEvent(fn (string $eventName) => "User {$this->name} was {$eventName}");
|
||||
|
||||
@@ -7,45 +7,48 @@ use App\Support\AuthCookie;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Modules\Auth\Entities\User;
|
||||
use Modules\Auth\Transformers\UserResource;
|
||||
use Spatie\Activitylog\Facades\Activity;
|
||||
|
||||
class AuthSessionService
|
||||
{
|
||||
public function createAuthResponse(User $user, string $message, int $status = 200): JsonResponse
|
||||
{
|
||||
$user->ensurePublicProfileToken();
|
||||
return Activity::withoutLogs(function () use ($user, $message, $status) {
|
||||
$user->ensurePublicProfileToken();
|
||||
|
||||
if (config('app.env') !== 'local') {
|
||||
$user->tokens()->delete();
|
||||
}
|
||||
if (config('app.env') !== 'local') {
|
||||
$user->tokens()->delete();
|
||||
}
|
||||
|
||||
$token = $user->createToken(
|
||||
name: 'authToken',
|
||||
abilities: ['*'],
|
||||
expiresAt: now()->addMinutes((int) config('auth_cookie.lifetime_minutes', 720))
|
||||
);
|
||||
$token = $user->createToken(
|
||||
name: 'authToken',
|
||||
abilities: ['*'],
|
||||
expiresAt: now()->addMinutes((int) config('auth_cookie.lifetime_minutes', 720))
|
||||
);
|
||||
|
||||
$user->load(['roles.permissions']);
|
||||
$user->load(['roles.permissions']);
|
||||
|
||||
ActiveRoleService::assignDefaultToToken($user, $token->accessToken);
|
||||
ActiveRoleService::assignDefaultToToken($user, $token->accessToken);
|
||||
|
||||
$payload = [
|
||||
'success' => true,
|
||||
'message' => $message,
|
||||
'data' => [
|
||||
'user' => new UserResource($user),
|
||||
'token' => $token->plainTextToken,
|
||||
'token_type' => 'Bearer',
|
||||
'expires_at' => $token->accessToken->expires_at,
|
||||
],
|
||||
...ActiveRoleService::sessionMeta($user),
|
||||
];
|
||||
$payload = [
|
||||
'success' => true,
|
||||
'message' => $message,
|
||||
'data' => [
|
||||
'user' => new UserResource($user),
|
||||
'token' => $token->plainTextToken,
|
||||
'token_type' => 'Bearer',
|
||||
'expires_at' => $token->accessToken->expires_at,
|
||||
],
|
||||
...ActiveRoleService::sessionMeta($user),
|
||||
];
|
||||
|
||||
if (AuthCookie::shouldExposeTokenInResponse()) {
|
||||
$payload['data']['token'] = $token->plainTextToken;
|
||||
}
|
||||
if (AuthCookie::shouldExposeTokenInResponse()) {
|
||||
$payload['data']['token'] = $token->plainTextToken;
|
||||
}
|
||||
|
||||
$response = response()->json($payload, $status);
|
||||
$response = response()->json($payload, $status);
|
||||
|
||||
return AuthCookie::attachAuthToken($response, $token->plainTextToken);
|
||||
return AuthCookie::attachAuthToken($response, $token->plainTextToken);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'name' => 'Dashboard',
|
||||
];
|
||||
@@ -1,16 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Dashboard\Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class DashboardDatabaseSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// $this->call([]);
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Dashboard\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Modules\Dashboard\Services\KJC\KJCDashboardService;
|
||||
use Modules\Dashboard\Services\PKJ\PKJDashboardService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
protected KJCDashboardService $kjcDashboardService;
|
||||
protected PKJDashboardService $pkjDashboardService;
|
||||
|
||||
public function __construct(
|
||||
KJCDashboardService $kjcDashboardService,
|
||||
PKJDashboardService $pkjDashboardService
|
||||
) {
|
||||
$this->kjcDashboardService = $kjcDashboardService;
|
||||
$this->pkjDashboardService = $pkjDashboardService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dashboard overview data
|
||||
*/
|
||||
public function getOverview(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$filters = $request->only(['date_from', 'date_to', 'unit_id', 'camp_id', 'formation_id', 'government_id']);
|
||||
$user = $request->user(); // Get authenticated user
|
||||
|
||||
$overview = [
|
||||
'total_units' => $this->kjcDashboardService->getTotalUnits($filters),
|
||||
'total_camps' => $this->kjcDashboardService->getTotalCamps($filters),
|
||||
'total_formations' => $this->kjcDashboardService->getTotalFormations($filters),
|
||||
'kjc' => [
|
||||
'total_assets' => $this->kjcDashboardService->getTotalAssets($filters, $user),
|
||||
'operational_assets' => $this->kjcDashboardService->getOperationalAssets($filters, $user),
|
||||
'under_repair' => $this->kjcDashboardService->getUnderRepairAssets($filters, $user),
|
||||
'maintenance_due' => $this->kjcDashboardService->getMaintenanceDueAssets($filters, $user),
|
||||
'active_repairs' => $this->kjcDashboardService->getActiveRepairs($filters),
|
||||
'pending_reports' => $this->kjcDashboardService->getPendingReports($filters)
|
||||
],
|
||||
'pkj' => [
|
||||
'total_assets' => $this->pkjDashboardService->getTotalAssets($filters, $user),
|
||||
'operational_assets' => $this->pkjDashboardService->getOperationalAssets($filters, $user),
|
||||
'under_repair' => $this->pkjDashboardService->getUnderRepairAssets($filters, $user),
|
||||
'maintenance_due' => $this->pkjDashboardService->getMaintenanceDueAssets($filters, $user),
|
||||
'active_repairs' => $this->pkjDashboardService->getActiveRepairs($filters),
|
||||
'pending_reports' => $this->pkjDashboardService->getPendingReports($filters)
|
||||
]
|
||||
];
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $overview
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to fetch dashboard overview',
|
||||
'error' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stat cards data
|
||||
*/
|
||||
public function getStatCards(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$filters = $request->only(['date_from', 'date_to', 'unit_id', 'camp_id', 'formation_id', 'government_id']);
|
||||
$user = $request->user(); // Get authenticated user
|
||||
|
||||
$statCards = [
|
||||
'kjc' => [
|
||||
'total_assets' => $this->kjcDashboardService->getTotalAssets($filters, $user),
|
||||
'operational_assets' => $this->kjcDashboardService->getOperationalAssets($filters, $user),
|
||||
'under_repair' => $this->kjcDashboardService->getUnderRepairAssets($filters, $user),
|
||||
'maintenance_due' => $this->kjcDashboardService->getMaintenanceDueAssets($filters, $user),
|
||||
],
|
||||
'pkj' => [
|
||||
'total_assets' => $this->pkjDashboardService->getTotalAssets($filters, $user),
|
||||
'operational_assets' => $this->pkjDashboardService->getOperationalAssets($filters, $user),
|
||||
'under_repair' => $this->pkjDashboardService->getUnderRepairAssets($filters, $user),
|
||||
'maintenance_due' => $this->pkjDashboardService->getMaintenanceDueAssets($filters, $user),
|
||||
],
|
||||
'overview' => [
|
||||
'total_units' => $this->kjcDashboardService->getTotalUnits($filters),
|
||||
'active_repairs' => $this->kjcDashboardService->getActiveRepairs($filters) + $this->pkjDashboardService->getActiveRepairs($filters),
|
||||
'pending_reports' => $this->kjcDashboardService->getPendingReports($filters) + $this->pkjDashboardService->getPendingReports($filters),
|
||||
]
|
||||
];
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $statCards
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to fetch stat cards',
|
||||
'error' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,302 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Dashboard\Http\Controllers\KJC;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Modules\KJCAssetEntitlement\Entities\KJCAssetEntitlement;
|
||||
use Modules\KJCAssetHolding\Entities\KJCAssetHolding;
|
||||
use Modules\Dashboard\Services\KJC\KJCDashboardService;
|
||||
use Modules\Dashboard\Repositories\Contracts\KJCDashboardRepositoryInterface;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class KJCDashboardController extends Controller
|
||||
{
|
||||
protected KJCDashboardService $kjcDashboardService;
|
||||
protected KJCDashboardRepositoryInterface $kjcDashboardRepository;
|
||||
|
||||
public function __construct(KJCDashboardService $kjcDashboardService, KJCDashboardRepositoryInterface $kjcDashboardRepository)
|
||||
{
|
||||
$this->kjcDashboardService = $kjcDashboardService;
|
||||
$this->kjcDashboardRepository = $kjcDashboardRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get KJC entitlement vs holdings pie chart
|
||||
*/
|
||||
public function getEntitlementVsHoldingsPieChart(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$filters = $request->only(['date_from', 'date_to', 'unit_id', 'camp_id', 'formation_id', 'government_id']);
|
||||
$user = $request->user(); // Get authenticated user
|
||||
$chartData = $this->kjcDashboardService->getEntitlementVsHoldingsPieChart($filters, $user);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $chartData
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to fetch KJC entitlement vs holdings chart',
|
||||
'error' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get KJC holdings status breakdown pie chart
|
||||
*/
|
||||
public function getHoldingsStatusBreakdownPieChart(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$filters = $request->only(['date_from', 'date_to', 'unit_id', 'camp_id', 'formation_id', 'government_id']);
|
||||
$user = $request->user(); // Get authenticated user
|
||||
$chartData = $this->kjcDashboardService->getHoldingsStatusBreakdownPieChart($filters, $user);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $chartData
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to fetch KJC holdings status breakdown chart',
|
||||
'error' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get KJC unit performance chart
|
||||
*/
|
||||
public function getUnitPerformanceChart(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$filters = $request->only(['date_from', 'date_to', 'unit_id', 'camp_id', 'formation_id', 'government_id']);
|
||||
$user = $request->user(); // Get authenticated user
|
||||
$chartData = $this->kjcDashboardService->getUnitPerformanceChart($filters, $user);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $chartData
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to fetch KJC unit performance chart',
|
||||
'error' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get KJC monthly trends chart
|
||||
*/
|
||||
public function getMonthlyTrendsChart(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$filters = $request->only(['date_from', 'date_to', 'unit_id', 'camp_id', 'formation_id', 'government_id']);
|
||||
$user = $request->user(); // Get authenticated user
|
||||
$chartData = $this->kjcDashboardService->getMonthlyTrendsChart($filters, $user);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $chartData
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to fetch KJC monthly trends chart',
|
||||
'error' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get KJC historical metrics bar chart
|
||||
*/
|
||||
public function getHistoricalMetricsBarChart(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$filters = $request->only(['date_from', 'date_to', 'unit_id', 'camp_id', 'formation_id', 'government_id']);
|
||||
$user = $request->user(); // Get authenticated user
|
||||
$chartData = $this->kjcDashboardService->getHistoricalMetricsBarChart($filters, $user);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $chartData
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to fetch KJC historical metrics chart',
|
||||
'error' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get drill-down data for entitlement vs holdings
|
||||
*/
|
||||
public function getEntitlementVsHoldingsDetails(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$filters = $request->only(['type', 'date_from', 'date_to', 'unit_id', 'camp_id', 'formation_id', 'government_id']);
|
||||
|
||||
$type = $filters['type'] ?? 'entitlement';
|
||||
|
||||
if ($type === 'entitlement') {
|
||||
// Return entitlement details
|
||||
$entitlements = KJCAssetEntitlement::with(['kjcSubcategory'])
|
||||
->paginate(20);
|
||||
|
||||
$data = [
|
||||
'title' => 'KJC Asset Entitlements',
|
||||
'filters' => array_merge($filters, ['total_count' => $entitlements->total()]),
|
||||
'items' => $entitlements->items(),
|
||||
'pagination' => [
|
||||
'current_page' => $entitlements->currentPage(),
|
||||
'per_page' => $entitlements->perPage(),
|
||||
'total' => $entitlements->total(),
|
||||
'last_page' => $entitlements->lastPage(),
|
||||
'from' => $entitlements->firstItem(),
|
||||
'to' => $entitlements->lastItem()
|
||||
]
|
||||
];
|
||||
} else {
|
||||
// Return holdings details
|
||||
$query = KJCAssetHolding::with(['unit', 'kjcCategory', 'kjcSubcategory', 'kjcModel']);
|
||||
|
||||
if (isset($filters['date_from'])) {
|
||||
$query->where('created_at', '>=', $filters['date_from']);
|
||||
}
|
||||
|
||||
if (isset($filters['date_to'])) {
|
||||
$query->where('created_at', '<=', $filters['date_to']);
|
||||
}
|
||||
|
||||
if (isset($filters['unit_id'])) {
|
||||
$query->where('unit_id', $filters['unit_id']);
|
||||
}
|
||||
|
||||
$holdings = $query->paginate(20);
|
||||
|
||||
$data = [
|
||||
'title' => 'KJC Asset Holdings',
|
||||
'filters' => array_merge($filters, ['total_count' => $holdings->total()]),
|
||||
'items' => $holdings->items(),
|
||||
'pagination' => [
|
||||
'current_page' => $holdings->currentPage(),
|
||||
'per_page' => $holdings->perPage(),
|
||||
'total' => $holdings->total(),
|
||||
'last_page' => $holdings->lastPage(),
|
||||
'from' => $holdings->firstItem(),
|
||||
'to' => $holdings->lastItem()
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $data
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to fetch entitlement vs holdings details',
|
||||
'error' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get drill-down data for holdings status
|
||||
*/
|
||||
public function getHoldingsStatusDetails(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$filters = $request->only(['status', 'date_from', 'date_to', 'unit_id', 'camp_id', 'formation_id', 'government_id']);
|
||||
|
||||
$query = KJCAssetHolding::with(['unit', 'kjcCategory', 'kjcSubcategory', 'kjcModel']);
|
||||
|
||||
if (isset($filters['status'])) {
|
||||
$query->where('status', $filters['status']);
|
||||
}
|
||||
|
||||
if (isset($filters['date_from'])) {
|
||||
$query->where('created_at', '>=', $filters['date_from']);
|
||||
}
|
||||
|
||||
if (isset($filters['date_to'])) {
|
||||
$query->where('created_at', '<=', $filters['date_to']);
|
||||
}
|
||||
|
||||
if (isset($filters['unit_id'])) {
|
||||
$query->where('unit_id', $filters['unit_id']);
|
||||
}
|
||||
|
||||
$holdings = $query->paginate(20);
|
||||
|
||||
// Map status codes to readable names
|
||||
$statusNames = [
|
||||
'BP' => 'Beroperasi (Operational)',
|
||||
'BDG' => 'Baik Diselenggara (Well Maintained)',
|
||||
'TBDG' => 'Tidak Baik Diselenggara (Poorly Maintained)'
|
||||
];
|
||||
|
||||
$statusName = $statusNames[$filters['status']] ?? $filters['status'];
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'title' => "KJC Assets with Status: {$statusName}",
|
||||
'filters' => array_merge($filters, ['total_count' => $holdings->total()]),
|
||||
'items' => $holdings->items(),
|
||||
'pagination' => [
|
||||
'current_page' => $holdings->currentPage(),
|
||||
'per_page' => $holdings->perPage(),
|
||||
'total' => $holdings->total(),
|
||||
'last_page' => $holdings->lastPage(),
|
||||
'from' => $holdings->firstItem(),
|
||||
'to' => $holdings->lastItem()
|
||||
]
|
||||
]
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to fetch holdings status details',
|
||||
'error' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get KJC asset details by status (drill-down table, paginated).
|
||||
* Status can be 'null' for assets not yet assigned a status (DB column NULL).
|
||||
*/
|
||||
public function getAssetDetailsByStatus(Request $request, string $status): JsonResponse
|
||||
{
|
||||
try {
|
||||
$filters = $request->only(['date_from', 'date_to', 'unit_id', 'camp_id', 'formation_id', 'government_id', 'page', 'per_page']);
|
||||
$user = $request->user();
|
||||
|
||||
$status = trim($status);
|
||||
$status = $status === 'null' ? '' : $status;
|
||||
|
||||
$details = $this->kjcDashboardRepository->getAssetDetailsByStatus($status, $filters, $user);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $details
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to fetch KJC asset details',
|
||||
'error' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Dashboard\Http\Controllers\PKJ;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Modules\Dashboard\Services\PKJ\PKJDashboardService;
|
||||
use Modules\Dashboard\Repositories\Contracts\PKJDashboardRepositoryInterface;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class PKJDashboardController extends Controller
|
||||
{
|
||||
protected PKJDashboardService $pkjDashboardService;
|
||||
protected PKJDashboardRepositoryInterface $pkjDashboardRepository;
|
||||
|
||||
public function __construct(PKJDashboardService $pkjDashboardService, PKJDashboardRepositoryInterface $pkjDashboardRepository)
|
||||
{
|
||||
$this->pkjDashboardService = $pkjDashboardService;
|
||||
$this->pkjDashboardRepository = $pkjDashboardRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PKJ entitlement vs holdings pie chart
|
||||
*/
|
||||
public function getEntitlementVsHoldingsPieChart(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$filters = $request->only(['date_from', 'date_to', 'unit_id', 'camp_id', 'formation_id', 'government_id']);
|
||||
$user = $request->user(); // Get authenticated user
|
||||
$chartData = $this->pkjDashboardService->getEntitlementVsHoldingsPieChart($filters, $user);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $chartData
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to fetch PKJ entitlement vs holdings chart',
|
||||
'error' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PKJ holdings status breakdown pie chart
|
||||
*/
|
||||
public function getHoldingsStatusBreakdownPieChart(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$filters = $request->only(['date_from', 'date_to', 'unit_id', 'camp_id', 'formation_id', 'government_id']);
|
||||
$user = $request->user(); // Get authenticated user
|
||||
$chartData = $this->pkjDashboardService->getHoldingsStatusBreakdownPieChart($filters, $user);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $chartData
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to fetch PKJ holdings status breakdown chart',
|
||||
'error' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PKJ monthly trends chart
|
||||
*/
|
||||
public function getMonthlyTrendsChart(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$filters = $request->only(['date_from', 'date_to', 'unit_id', 'camp_id', 'formation_id', 'government_id']);
|
||||
$user = $request->user(); // Get authenticated user
|
||||
$chartData = $this->pkjDashboardService->getMonthlyTrendsChart($filters, $user);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $chartData
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to fetch PKJ monthly trends chart',
|
||||
'error' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PKJ holdings by category bar chart
|
||||
*/
|
||||
public function getHoldingsByCategoryBarChart(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$filters = $request->only(['date_from', 'date_to', 'unit_id', 'camp_id', 'formation_id', 'government_id']);
|
||||
$user = $request->user(); // Get authenticated user
|
||||
$chartData = $this->pkjDashboardService->getHoldingsByCategoryBarChart($filters, $user);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $chartData
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to fetch PKJ holdings by category chart',
|
||||
'error' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PKJ category metrics bar chart (drill-down)
|
||||
*/
|
||||
public function getCategoryMetricsBarChart(Request $request, int $categoryId): JsonResponse
|
||||
{
|
||||
try {
|
||||
$filters = $request->only(['date_from', 'date_to', 'unit_id', 'camp_id', 'formation_id', 'government_id']);
|
||||
$user = $request->user(); // Get authenticated user
|
||||
$chartData = $this->pkjDashboardService->getCategoryMetricsBarChart($categoryId, $filters, $user);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $chartData
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to fetch PKJ category metrics chart',
|
||||
'error' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PKJ category status breakdown pie chart (drill-down)
|
||||
*/
|
||||
public function getCategoryStatusBreakdownPieChart(Request $request, int $categoryId): JsonResponse
|
||||
{
|
||||
try {
|
||||
$filters = $request->only(['date_from', 'date_to', 'unit_id', 'camp_id', 'formation_id', 'government_id']);
|
||||
$user = $request->user(); // Get authenticated user
|
||||
$chartData = $this->pkjDashboardService->getCategoryStatusBreakdownPieChart($categoryId, $filters, $user);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $chartData
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to fetch PKJ category status breakdown chart',
|
||||
'error' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PKJ asset details by category and status (drill-down)
|
||||
*/
|
||||
public function getAssetDetailsByCategoryAndStatus(Request $request, int $categoryId, string $status): JsonResponse
|
||||
{
|
||||
try {
|
||||
$filters = $request->only(['date_from', 'date_to', 'unit_id', 'camp_id', 'formation_id', 'government_id', 'page', 'per_page']);
|
||||
$user = $request->user(); // Get authenticated user
|
||||
|
||||
// Convert 'null' string to empty string for null status handling
|
||||
// Trim whitespace to ensure exact matching
|
||||
$status = trim($status);
|
||||
$status = $status === 'null' ? '' : $status;
|
||||
|
||||
$details = $this->pkjDashboardRepository->getAssetDetailsByCategoryAndStatus($categoryId, $status, $filters, $user);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $details
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to fetch PKJ asset details',
|
||||
'error' => $e->getMessage()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Dashboard\Repositories\Contracts;
|
||||
|
||||
interface KJCDashboardRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* Get total KJC asset entitlement
|
||||
*/
|
||||
public function getTotalEntitlement(array $filters = [], $user = null): int;
|
||||
|
||||
/**
|
||||
* Get total KJC asset holdings
|
||||
*/
|
||||
public function getTotalHoldings(array $filters = [], $user = null): int;
|
||||
|
||||
/**
|
||||
* Get KJC asset holdings by status
|
||||
*/
|
||||
public function getHoldingsByStatus(array $filters = [], $user = null): array;
|
||||
|
||||
/**
|
||||
* Get KJC asset holdings with pagination
|
||||
*/
|
||||
public function getHoldingsPaginated(array $filters = [], int $perPage = 20, $user = null): array;
|
||||
|
||||
/**
|
||||
* Get KJC asset entitlements with pagination
|
||||
*/
|
||||
public function getEntitlementsPaginated(array $filters = [], int $perPage = 20, $user = null): array;
|
||||
|
||||
/**
|
||||
* Get KJC asset holdings by specific status
|
||||
*/
|
||||
public function getHoldingsBySpecificStatus(string $status, array $filters = [], $user = null): array;
|
||||
|
||||
/**
|
||||
* Get KJC asset holdings grouped by unit
|
||||
*/
|
||||
public function getHoldingsByUnit(array $filters = [], $user = null): array;
|
||||
|
||||
/**
|
||||
* Get KJC asset holdings monthly trends
|
||||
*/
|
||||
public function getMonthlyTrends(array $filters = [], $user = null): array;
|
||||
|
||||
/**
|
||||
* Get KJC historical metrics (KEUPAYAAN, KESIAGAAN, SERVISIBILITI) by month
|
||||
*/
|
||||
public function getHistoricalMetricsByMonth(array $filters = []): array;
|
||||
|
||||
/**
|
||||
* Get KJC asset details by status (paginated).
|
||||
* When status is '' or 'null', returns assets where status column is NULL (not yet assigned).
|
||||
*/
|
||||
public function getAssetDetailsByStatus(string $status, array $filters = [], $user = null): array;
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Dashboard\Repositories\Contracts;
|
||||
|
||||
interface PKJDashboardRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* Get total PKJ asset entitlement
|
||||
*/
|
||||
public function getTotalEntitlement(array $filters = [], $user = null): int;
|
||||
|
||||
/**
|
||||
* Get total PKJ asset holdings
|
||||
*/
|
||||
public function getTotalHoldings(array $filters = [], $user = null): int;
|
||||
|
||||
/**
|
||||
* Get PKJ asset holdings grouped by status
|
||||
*/
|
||||
public function getHoldingsByStatus(array $filters = [], $user = null): array;
|
||||
|
||||
/**
|
||||
* Get PKJ asset holdings by specific status
|
||||
*/
|
||||
public function getHoldingsBySpecificStatus(string $status, array $filters = [], $user = null): array;
|
||||
|
||||
/**
|
||||
* Get PKJ monthly trends
|
||||
*/
|
||||
public function getMonthlyTrends(array $filters = [], $user = null): array;
|
||||
|
||||
/**
|
||||
* Get PKJ holdings by category
|
||||
*/
|
||||
public function getHoldingsByCategory(array $filters = [], $user = null): array;
|
||||
|
||||
/**
|
||||
* Get PKJ category metrics (KEUPAYAAN, KESIAGAAN, SERVISIBILITI)
|
||||
*/
|
||||
public function getCategoryMetrics(int $categoryId, array $filters = [], $user = null): array;
|
||||
|
||||
/**
|
||||
* Get PKJ category status breakdown
|
||||
*/
|
||||
public function getCategoryStatusBreakdown(int $categoryId, array $filters = [], $user = null): array;
|
||||
|
||||
/**
|
||||
* Get PKJ asset details by category and status
|
||||
*/
|
||||
public function getAssetDetailsByCategoryAndStatus(int $categoryId, string $status, array $filters = [], $user = null): array;
|
||||
}
|
||||
@@ -1,645 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Dashboard\Repositories;
|
||||
|
||||
use Modules\Dashboard\Repositories\Contracts\KJCDashboardRepositoryInterface;
|
||||
use Modules\KJCAssetEntitlement\Entities\KJCAssetEntitlement;
|
||||
use Modules\KJCAssetHolding\Entities\KJCAssetHolding;
|
||||
use App\Models\KJCHistoricalEntitlement;
|
||||
use App\Models\KJCHistoricalHoldingStatus;
|
||||
use Modules\Unit\Entities\Unit;
|
||||
use Modules\Formation\Entities\Formation;
|
||||
use App\Services\VisibilityService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class KJCDashboardRepository implements KJCDashboardRepositoryInterface
|
||||
{
|
||||
public function __construct(
|
||||
protected VisibilityService $visibilityService
|
||||
) {}
|
||||
/**
|
||||
* Get total KJC asset entitlement
|
||||
*/
|
||||
public function getTotalEntitlement(array $filters = [], $user = null): int
|
||||
{
|
||||
$query = KJCAssetEntitlement::query();
|
||||
|
||||
// Apply visibility scoping if user is provided
|
||||
if ($user) {
|
||||
$query->visibleTo($user);
|
||||
}
|
||||
|
||||
$this->applyFilters($query, $filters, excludeDateFilter: true);
|
||||
|
||||
return $query->sum('entitlement');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total KJC asset holdings
|
||||
*/
|
||||
public function getTotalHoldings(array $filters = [], $user = null): int
|
||||
{
|
||||
$query = KJCAssetEntitlement::query();
|
||||
|
||||
// Apply visibility scoping if user is provided
|
||||
if ($user) {
|
||||
$query->visibleTo($user);
|
||||
}
|
||||
|
||||
$this->applyFilters($query, $filters, excludeDateFilter: true);
|
||||
|
||||
return $query->sum('holding');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get KJC asset holdings by status.
|
||||
* Does not filter by date so totals match full KJCAssetHolding counts (unit/government/formation filters still apply).
|
||||
*/
|
||||
public function getHoldingsByStatus(array $filters = [], $user = null): array
|
||||
{
|
||||
$query = KJCAssetHolding::query();
|
||||
|
||||
// Apply visibility scoping if user is provided
|
||||
if ($user) {
|
||||
$query->visibleTo($user);
|
||||
}
|
||||
|
||||
$this->applyFilters($query, $filters, excludeDateFilter: true);
|
||||
|
||||
return $query->selectRaw('status, COUNT(*) as count')
|
||||
->groupBy('status')
|
||||
->get()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get KJC asset holdings with pagination
|
||||
*/
|
||||
public function getHoldingsPaginated(array $filters = [], int $perPage = 20, $user = null): array
|
||||
{
|
||||
$query = KJCAssetHolding::with(['unit', 'kjcCategory', 'kjcSubcategory', 'kjcModel']);
|
||||
|
||||
// Apply visibility scoping if user is provided
|
||||
if ($user) {
|
||||
$query->visibleTo($user);
|
||||
}
|
||||
|
||||
$this->applyFilters($query, $filters);
|
||||
|
||||
$holdings = $query->paginate($perPage);
|
||||
|
||||
return [
|
||||
'items' => $holdings->items(),
|
||||
'pagination' => [
|
||||
'current_page' => $holdings->currentPage(),
|
||||
'per_page' => $holdings->perPage(),
|
||||
'total' => $holdings->total(),
|
||||
'last_page' => $holdings->lastPage(),
|
||||
'from' => $holdings->firstItem(),
|
||||
'to' => $holdings->lastItem()
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get KJC asset entitlements with pagination
|
||||
*/
|
||||
public function getEntitlementsPaginated(array $filters = [], int $perPage = 20, $user = null): array
|
||||
{
|
||||
$query = KJCAssetEntitlement::with(['kjcSubcategory']);
|
||||
|
||||
// Apply visibility scoping if user is provided
|
||||
if ($user) {
|
||||
$query->visibleTo($user);
|
||||
}
|
||||
|
||||
$this->applyFilters($query, $filters);
|
||||
|
||||
$entitlements = $query->paginate($perPage);
|
||||
|
||||
return [
|
||||
'items' => $entitlements->items(),
|
||||
'pagination' => [
|
||||
'current_page' => $entitlements->currentPage(),
|
||||
'per_page' => $entitlements->perPage(),
|
||||
'total' => $entitlements->total(),
|
||||
'last_page' => $entitlements->lastPage(),
|
||||
'from' => $entitlements->firstItem(),
|
||||
'to' => $entitlements->lastItem()
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get KJC asset holdings by specific status
|
||||
*/
|
||||
public function getHoldingsBySpecificStatus(string $status, array $filters = [], $user = null): array
|
||||
{
|
||||
$query = KJCAssetHolding::with(['unit', 'kjcCategory', 'kjcSubcategory', 'kjcModel'])
|
||||
->where('status', $status);
|
||||
|
||||
// Apply visibility scoping if user is provided
|
||||
if ($user) {
|
||||
$query->visibleTo($user);
|
||||
}
|
||||
|
||||
$this->applyFilters($query, $filters);
|
||||
|
||||
$holdings = $query->paginate(20);
|
||||
|
||||
return [
|
||||
'items' => $holdings->items(),
|
||||
'pagination' => [
|
||||
'current_page' => $holdings->currentPage(),
|
||||
'per_page' => $holdings->perPage(),
|
||||
'total' => $holdings->total(),
|
||||
'last_page' => $holdings->lastPage(),
|
||||
'from' => $holdings->firstItem(),
|
||||
'to' => $holdings->lastItem()
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get KJC asset details by status (paginated).
|
||||
* When status is '' or 'null', returns assets where status column is NULL (not yet assigned).
|
||||
*/
|
||||
public function getAssetDetailsByStatus(string $status, array $filters = [], $user = null): array
|
||||
{
|
||||
$status = trim($status ?? '');
|
||||
|
||||
$query = KJCAssetHolding::query()
|
||||
->with(['kjcCategory', 'kjcSubcategory', 'kjcModel', 'unit'])
|
||||
->select([
|
||||
'id',
|
||||
'uuid',
|
||||
'kjc_category_id',
|
||||
'kjc_subcategory_id',
|
||||
'kjc_model_id',
|
||||
'registration_number',
|
||||
'kewpa_registration_number',
|
||||
'status',
|
||||
'unit_id',
|
||||
'purchase_date',
|
||||
'receipt_date',
|
||||
'purchase_price',
|
||||
'contract_reference',
|
||||
'economic_year',
|
||||
'economic_distance',
|
||||
'note',
|
||||
'engine_number',
|
||||
'chassis_number',
|
||||
'created_at',
|
||||
'updated_at'
|
||||
]);
|
||||
|
||||
if ($status === '' || $status === 'null') {
|
||||
$query->whereNull('status');
|
||||
} else {
|
||||
$query->whereNotNull('status')
|
||||
->where('status', '=', $status);
|
||||
}
|
||||
|
||||
if ($user) {
|
||||
$query->visibleTo($user);
|
||||
}
|
||||
|
||||
$this->applyFilters($query, $filters, excludeDateFilter: true);
|
||||
|
||||
$perPage = isset($filters['per_page']) ? (int) $filters['per_page'] : 10;
|
||||
$page = isset($filters['page']) ? (int) $filters['page'] : null;
|
||||
$assets = $query->paginate($perPage, ['*'], 'page', $page);
|
||||
|
||||
return [
|
||||
'data' => $assets->items(),
|
||||
'pagination' => [
|
||||
'current_page' => $assets->currentPage(),
|
||||
'per_page' => $assets->perPage(),
|
||||
'total' => $assets->total(),
|
||||
'last_page' => $assets->lastPage(),
|
||||
'from' => $assets->firstItem(),
|
||||
'to' => $assets->lastItem(),
|
||||
'has_more_pages' => $assets->hasMorePages()
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get KJC asset holdings grouped by unit
|
||||
*/
|
||||
public function getHoldingsByUnit(array $filters = [], $user = null): array
|
||||
{
|
||||
$query = KJCAssetHolding::query();
|
||||
|
||||
// Apply visibility scoping if user is provided
|
||||
if ($user) {
|
||||
$query->visibleTo($user);
|
||||
}
|
||||
|
||||
$this->applyFilters($query, $filters);
|
||||
|
||||
return $query->join('units', 'kjc_asset_holdings.unit_id', '=', 'units.id')
|
||||
->selectRaw('units.name as unit_name, COUNT(*) as count')
|
||||
->groupBy('units.name')
|
||||
->get()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get KJC asset holdings monthly trends
|
||||
*/
|
||||
public function getMonthlyTrends(array $filters = [], $user = null): array
|
||||
{
|
||||
$dateRange = $this->getDateRange($filters);
|
||||
|
||||
$query = KJCAssetHolding::query();
|
||||
|
||||
// Apply visibility scoping if user is provided
|
||||
if ($user) {
|
||||
$query->visibleTo($user);
|
||||
}
|
||||
|
||||
$this->applyFilters($query, $filters);
|
||||
|
||||
return $query->selectRaw('
|
||||
DATE_FORMAT(created_at, "%Y-%m") as month,
|
||||
status,
|
||||
COUNT(*) as count
|
||||
')
|
||||
->whereBetween('created_at', [$dateRange['from'], $dateRange['to']])
|
||||
->groupBy('month', 'status')
|
||||
->orderBy('month')
|
||||
->get()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get KJC historical metrics (KEUPAYAAN, KESIAGAAN, SERVISIBILITI) by month
|
||||
* 1. KEUPAYAAN (%) = (PEGANGAN/PERJAWATAN) * 100
|
||||
* 2. SIAPSIAGA or KESIAGAAN (%) = (BDG/perjawatan) * 100
|
||||
* 3. SERVISIBILITI (%) = (BDG/pegangan) * 100
|
||||
*/
|
||||
public function getHistoricalMetricsByMonth(array $filters = [], $user = null): array
|
||||
{
|
||||
$dateRange = $this->getDateRange($filters);
|
||||
$currentMonth = Carbon::now()->format('Y-m');
|
||||
|
||||
// Get entitlement and holding data by month from historical tables using Eloquent
|
||||
$entitlementQuery = KJCHistoricalEntitlement::query()
|
||||
->selectRaw('
|
||||
TO_CHAR(kjc_historical_entitlement.date, \'YYYY-MM\') as month,
|
||||
SUM(kjc_historical_entitlement.entitlement) as total_entitlement,
|
||||
SUM(kjc_historical_entitlement.holding) as total_holding
|
||||
')
|
||||
->whereBetween('kjc_historical_entitlement.date', [$dateRange['from'], $dateRange['to']]);
|
||||
|
||||
// Apply filters to historical entitlement query
|
||||
$this->applyHistoricalFiltersToEloquent($entitlementQuery, $filters);
|
||||
|
||||
// Apply visibility scoping if user is provided (uses HasVisibility trait)
|
||||
if ($user) {
|
||||
$entitlementQuery->visibleTo($user);
|
||||
}
|
||||
|
||||
$entitlementData = $entitlementQuery->groupBy('month')
|
||||
->orderBy('month')
|
||||
->get()
|
||||
->keyBy('month');
|
||||
|
||||
// Get BDG status count by month from historical tables using Eloquent
|
||||
$bdgQuery = KJCHistoricalHoldingStatus::query()
|
||||
->selectRaw('
|
||||
TO_CHAR(kjc_historical_holding_status.date, \'YYYY-MM\') as month,
|
||||
COUNT(*) as bdg_count
|
||||
')
|
||||
->where('kjc_historical_holding_status.status', 'BDG')
|
||||
->whereBetween('kjc_historical_holding_status.date', [$dateRange['from'], $dateRange['to']]);
|
||||
|
||||
// Apply filters to historical holding status query
|
||||
$this->applyHistoricalFiltersToEloquent($bdgQuery, $filters);
|
||||
|
||||
// Apply visibility scoping if user is provided (uses HasVisibility trait)
|
||||
if ($user) {
|
||||
$bdgQuery->visibleTo($user);
|
||||
}
|
||||
|
||||
$bdgData = $bdgQuery->groupBy('month')
|
||||
->orderBy('month')
|
||||
->get()
|
||||
->keyBy('month');
|
||||
|
||||
// Get current month data from real-time tables if current month is in range
|
||||
$currentMonthEntitlement = 0;
|
||||
$currentMonthHolding = 0;
|
||||
$currentMonthBdgCount = 0;
|
||||
|
||||
if ($this->isCurrentMonthInRange($currentMonth, $dateRange)) {
|
||||
// Get current month entitlement data (all entitlements, not filtered by updated_at)
|
||||
// This ensures we get real-time entitlement values regardless of when they were last updated
|
||||
$currentEntitlementQuery = KJCAssetEntitlement::query();
|
||||
if ($user) {
|
||||
$currentEntitlementQuery->visibleTo($user);
|
||||
}
|
||||
$this->applyCurrentMonthFilters($currentEntitlementQuery, $filters);
|
||||
|
||||
$currentMonthEntitlement = $currentEntitlementQuery->sum('entitlement');
|
||||
|
||||
// Calculate current month holdings from actual KJCAssetHolding records
|
||||
// This ensures we get real-time holding counts even if entitlement.holding is stale
|
||||
$currentHoldingQuery = KJCAssetHolding::query();
|
||||
if ($user) {
|
||||
$currentHoldingQuery->visibleTo($user);
|
||||
}
|
||||
$this->applyCurrentMonthFilters($currentHoldingQuery, $filters);
|
||||
|
||||
$currentMonthHolding = $currentHoldingQuery->count();
|
||||
|
||||
// Get current month BDG count (only holdings updated in current month)
|
||||
// This shows BDG holdings that were updated/changed status in the current month
|
||||
$currentBdgQuery = KJCAssetHolding::query()
|
||||
->where('status', 'BDG')
|
||||
->whereMonth('updated_at', Carbon::now()->month)
|
||||
->whereYear('updated_at', Carbon::now()->year);
|
||||
if ($user) {
|
||||
$currentBdgQuery->visibleTo($user);
|
||||
}
|
||||
$this->applyCurrentMonthFilters($currentBdgQuery, $filters);
|
||||
|
||||
$currentMonthBdgCount = $currentBdgQuery->count();
|
||||
}
|
||||
|
||||
// Generate all months in the range
|
||||
$months = [];
|
||||
$current = $dateRange['from']->copy()->startOfMonth();
|
||||
$end = $dateRange['to']->copy()->endOfMonth();
|
||||
|
||||
while ($current->lte($end)) {
|
||||
$monthKey = $current->format('Y-m');
|
||||
$months[] = $monthKey;
|
||||
$current->addMonth();
|
||||
}
|
||||
|
||||
// Calculate metrics for each month
|
||||
$result = [];
|
||||
foreach ($months as $month) {
|
||||
$entitlement = $entitlementData->get($month);
|
||||
$bdg = $bdgData->get($month);
|
||||
|
||||
// Use current month real-time data if this is the current month
|
||||
if ($month === $currentMonth && $this->isCurrentMonthInRange($currentMonth, $dateRange)) {
|
||||
$totalEntitlement = $currentMonthEntitlement;
|
||||
$totalHolding = $currentMonthHolding;
|
||||
$bdgCount = $currentMonthBdgCount;
|
||||
} else {
|
||||
$totalEntitlement = $entitlement ? $entitlement->total_entitlement : 0;
|
||||
$totalHolding = $entitlement ? $entitlement->total_holding : 0;
|
||||
$bdgCount = $bdg ? $bdg->bdg_count : 0;
|
||||
}
|
||||
|
||||
// Calculate metrics
|
||||
$keupayaan = $totalEntitlement > 0 ? ($totalHolding / $totalEntitlement) * 100 : 0;
|
||||
$kesiagaan = $totalEntitlement > 0 ? ($bdgCount / $totalEntitlement) * 100 : 0;
|
||||
$servisibiliti = $totalHolding > 0 ? ($bdgCount / $totalHolding) * 100 : 0;
|
||||
|
||||
$result[] = [
|
||||
'month' => $month,
|
||||
'keupayaan' => round($keupayaan, 2),
|
||||
'kesiagaan' => round($kesiagaan, 2),
|
||||
'servisibiliti' => round($servisibiliti, 2),
|
||||
'total_entitlement' => $totalEntitlement,
|
||||
'total_holding' => $totalHolding,
|
||||
'bdg_count' => $bdgCount
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply common filters to query
|
||||
*/
|
||||
private function applyFilters($query, array $filters = [], bool $excludeDateFilter = false): void
|
||||
{
|
||||
$model = $query->getModel();
|
||||
if ($model && method_exists($model, 'getTable')) {
|
||||
$schema = $model->getConnection()->getSchemaBuilder();
|
||||
$columns = $schema->getColumnListing($model->getTable());
|
||||
|
||||
// Only apply created_at filter if the model has created_at column (skip when excludeDateFilter)
|
||||
if (!$excludeDateFilter && in_array('created_at', $columns)) {
|
||||
$dateRange = $this->getDateRange($filters);
|
||||
$query->whereBetween('created_at', [$dateRange['from'], $dateRange['to']]);
|
||||
}
|
||||
|
||||
// Apply unit_id filter if model has unit_id column
|
||||
if (isset($filters['unit_id']) && $filters['unit_id']) {
|
||||
if (in_array('unit_id', $columns)) {
|
||||
$query->where('unit_id', $filters['unit_id']);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply government_id filter if provided
|
||||
if (isset($filters['government_id']) && $filters['government_id']) {
|
||||
$unitIds = $this->getUnitsByGovernment($filters['government_id']);
|
||||
if (in_array('unit_id', $columns)) {
|
||||
if (!empty($unitIds)) {
|
||||
$query->whereIn('unit_id', $unitIds);
|
||||
} else {
|
||||
// No units found for this government - return empty result
|
||||
$query->whereNull('id');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply camp_id filter
|
||||
if (isset($filters['camp_id']) && $filters['camp_id']) {
|
||||
$query->whereHas('unit', function ($q) use ($filters) {
|
||||
$q->where('camp_id', $filters['camp_id']);
|
||||
});
|
||||
}
|
||||
|
||||
// Apply formation_id filter
|
||||
if (isset($filters['formation_id']) && $filters['formation_id']) {
|
||||
// Get units under this formation
|
||||
$unitIds = Unit::where('formation_id', $filters['formation_id'])
|
||||
->pluck('id')
|
||||
->toArray();
|
||||
|
||||
if (!empty($unitIds)) {
|
||||
if (in_array('unit_id', $columns)) {
|
||||
$query->whereIn('unit_id', $unitIds);
|
||||
} else {
|
||||
// Fallback to whereHas if no unit_id column
|
||||
$query->whereHas('unit', function ($q) use ($filters) {
|
||||
$q->where('formation_id', $filters['formation_id']);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// No units found for this formation - return empty result
|
||||
$query->whereNull('id');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get units by government ID
|
||||
* Uses VisibilityService for consistent logic
|
||||
*/
|
||||
private function getUnitsByGovernment(int $governmentId): array
|
||||
{
|
||||
$governmentUnit = Unit::where('government_id', $governmentId)->first();
|
||||
if (!$governmentUnit) {
|
||||
// If no direct unit found, try to get from formation
|
||||
$formation = Formation::where('government_id', $governmentId)->first();
|
||||
if ($formation) {
|
||||
$governmentUnit = Unit::where('formation_id', $formation->id)->first();
|
||||
}
|
||||
}
|
||||
|
||||
if (!$governmentUnit) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->visibilityService->getUnitsUnderGovernment($governmentUnit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get date range for filtering
|
||||
*/
|
||||
private function getDateRange(array $filters = []): array
|
||||
{
|
||||
$dateFrom = $filters['date_from'] ?? Carbon::now()->startOfYear();
|
||||
$dateTo = $filters['date_to'] ?? Carbon::now()->endOfYear();
|
||||
|
||||
return [
|
||||
'from' => Carbon::parse($dateFrom)->startOfDay(),
|
||||
'to' => Carbon::parse($dateTo)->endOfDay()
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply filters to historical Eloquent queries
|
||||
* Handles government_id, formation_id, unit_id, and camp_id filters
|
||||
*/
|
||||
private function applyHistoricalFiltersToEloquent($query, array $filters = []): void
|
||||
{
|
||||
$model = $query->getModel();
|
||||
$isHoldingStatus = $model instanceof KJCHistoricalHoldingStatus;
|
||||
|
||||
// Apply unit_id filter
|
||||
if (isset($filters['unit_id']) && $filters['unit_id']) {
|
||||
if ($isHoldingStatus) {
|
||||
// For holding status, filter through the relationship
|
||||
$query->whereHas('kjcAssetHolding', function ($q) use ($filters) {
|
||||
$q->where('unit_id', $filters['unit_id']);
|
||||
});
|
||||
} else {
|
||||
$query->where('unit_id', $filters['unit_id']);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply government_id filter
|
||||
if (isset($filters['government_id']) && $filters['government_id']) {
|
||||
$unitIds = $this->getUnitsByGovernment($filters['government_id']);
|
||||
if (!empty($unitIds)) {
|
||||
if ($isHoldingStatus) {
|
||||
$query->whereHas('kjcAssetHolding', function ($q) use ($unitIds) {
|
||||
$q->whereIn('unit_id', $unitIds);
|
||||
});
|
||||
} else {
|
||||
$query->whereIn('unit_id', $unitIds);
|
||||
}
|
||||
} else {
|
||||
// No units found for this government - return empty result
|
||||
$query->whereRaw('1 = 0');
|
||||
}
|
||||
}
|
||||
|
||||
// Apply formation_id filter
|
||||
if (isset($filters['formation_id']) && $filters['formation_id']) {
|
||||
// Get units under this formation
|
||||
$unitIds = Unit::where('formation_id', $filters['formation_id'])
|
||||
->pluck('id')
|
||||
->toArray();
|
||||
|
||||
if (!empty($unitIds)) {
|
||||
if ($isHoldingStatus) {
|
||||
$query->whereHas('kjcAssetHolding', function ($q) use ($unitIds) {
|
||||
$q->whereIn('unit_id', $unitIds);
|
||||
});
|
||||
} else {
|
||||
$query->whereIn('unit_id', $unitIds);
|
||||
}
|
||||
} else {
|
||||
// No units found for this formation - return empty result
|
||||
$query->whereRaw('1 = 0');
|
||||
}
|
||||
}
|
||||
|
||||
// Apply camp_id filter
|
||||
if (isset($filters['camp_id']) && $filters['camp_id']) {
|
||||
if ($isHoldingStatus) {
|
||||
$query->whereHas('kjcAssetHolding.unit', function ($q) use ($filters) {
|
||||
$q->where('camp_id', $filters['camp_id']);
|
||||
});
|
||||
} else {
|
||||
$query->whereHas('unit', function ($q) use ($filters) {
|
||||
$q->where('camp_id', $filters['camp_id']);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current month is within the date range
|
||||
*/
|
||||
private function isCurrentMonthInRange(string $currentMonth, array $dateRange): bool
|
||||
{
|
||||
$currentMonthDate = Carbon::createFromFormat('Y-m', $currentMonth)->startOfMonth();
|
||||
return $currentMonthDate->between($dateRange['from'], $dateRange['to']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply filters for current month data queries (without date range filtering)
|
||||
*/
|
||||
private function applyCurrentMonthFilters($query, array $filters = []): void
|
||||
{
|
||||
if (isset($filters['unit_id']) && $filters['unit_id']) {
|
||||
$query->where('unit_id', $filters['unit_id']);
|
||||
}
|
||||
|
||||
// Apply government_id filter
|
||||
if (isset($filters['government_id']) && $filters['government_id']) {
|
||||
$unitIds = $this->getUnitsByGovernment($filters['government_id']);
|
||||
if (!empty($unitIds)) {
|
||||
$query->whereIn('unit_id', $unitIds);
|
||||
} else {
|
||||
// No units found for this government - return empty result
|
||||
$query->whereNull('id');
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($filters['camp_id']) && $filters['camp_id']) {
|
||||
$query->whereHas('unit', function ($q) use ($filters) {
|
||||
$q->where('camp_id', $filters['camp_id']);
|
||||
});
|
||||
}
|
||||
|
||||
if (isset($filters['formation_id']) && $filters['formation_id']) {
|
||||
// Get units under this formation
|
||||
$unitIds = Unit::where('formation_id', $filters['formation_id'])
|
||||
->pluck('id')
|
||||
->toArray();
|
||||
|
||||
if (!empty($unitIds)) {
|
||||
$query->whereIn('unit_id', $unitIds);
|
||||
} else {
|
||||
// No units found for this formation - return empty result
|
||||
$query->whereNull('id');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,495 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Dashboard\Repositories;
|
||||
|
||||
use Modules\Dashboard\Repositories\Contracts\PKJDashboardRepositoryInterface;
|
||||
use Modules\PKJAssetEntitlement\Entities\PKJAssetEntitlement;
|
||||
use Modules\PKJAssetHolding\Entities\PKJAssetHolding;
|
||||
use Modules\PKJRepair\Entities\PKJRepair;
|
||||
use Modules\PKJReport\Entities\PKJReport;
|
||||
use Modules\PKJVariant\Entities\PKJCategory;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\Unit\Entities\Unit;
|
||||
use App\Services\VisibilityService;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class PKJDashboardRepository implements PKJDashboardRepositoryInterface
|
||||
{
|
||||
public function __construct(
|
||||
protected VisibilityService $visibilityService
|
||||
) {}
|
||||
/**
|
||||
* Get total PKJ asset entitlement
|
||||
*/
|
||||
public function getTotalEntitlement(array $filters = [], $user = null): int
|
||||
{
|
||||
$query = PKJAssetEntitlement::query();
|
||||
|
||||
// Apply visibility scoping if user is provided
|
||||
if ($user) {
|
||||
$query->visibleTo($user);
|
||||
}
|
||||
|
||||
$this->applyFilters($query, $filters, excludeDateFilter: true);
|
||||
|
||||
return $query->sum('entitlement');
|
||||
}
|
||||
|
||||
public function getTotalHoldings(array $filters = [], $user = null): int
|
||||
{
|
||||
$query = PKJAssetEntitlement::query();
|
||||
|
||||
// Apply visibility scoping if user is provided
|
||||
if ($user) {
|
||||
$query->visibleTo($user);
|
||||
}
|
||||
|
||||
$this->applyFilters($query, $filters, excludeDateFilter: true);
|
||||
|
||||
return $query->sum('holding') ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PKJ asset holdings grouped by status
|
||||
*/
|
||||
public function getHoldingsByStatus(array $filters = [], $user = null): array
|
||||
{
|
||||
$query = PKJAssetHolding::query();
|
||||
|
||||
// Apply visibility scoping if user is provided
|
||||
if ($user) {
|
||||
$query->visibleTo($user);
|
||||
}
|
||||
|
||||
$this->applyFilters($query, $filters);
|
||||
|
||||
return $query->selectRaw('status, COUNT(*) as count')
|
||||
->groupBy('status')
|
||||
->orderBy('status')
|
||||
->get()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PKJ asset holdings by specific status
|
||||
*/
|
||||
public function getHoldingsBySpecificStatus(string $status, array $filters = [], $user = null): array
|
||||
{
|
||||
$query = PKJAssetHolding::query();
|
||||
|
||||
// Apply visibility scoping if user is provided
|
||||
if ($user) {
|
||||
$query->visibleTo($user);
|
||||
}
|
||||
|
||||
$this->applyFilters($query, $filters);
|
||||
|
||||
$holdings = $query->where('status', $status)->paginate(10);
|
||||
|
||||
return [
|
||||
'data' => $holdings->items(),
|
||||
'pagination' => [
|
||||
'current_page' => $holdings->currentPage(),
|
||||
'per_page' => $holdings->perPage(),
|
||||
'total' => $holdings->total(),
|
||||
'last_page' => $holdings->lastPage(),
|
||||
'from' => $holdings->firstItem(),
|
||||
'to' => $holdings->lastItem()
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PKJ monthly trends
|
||||
*/
|
||||
public function getMonthlyTrends(array $filters = [], $user = null): array
|
||||
{
|
||||
$dateRange = $this->getDateRange($filters);
|
||||
|
||||
$query = PKJReport::query();
|
||||
|
||||
// Apply visibility scoping if user is provided
|
||||
if ($user) {
|
||||
$query->visibleTo($user);
|
||||
}
|
||||
|
||||
$trends = $query->selectRaw('
|
||||
TO_CHAR(created_at, \'YYYY-MM\') as month,
|
||||
COUNT(*) as total_reports
|
||||
')
|
||||
->whereBetween('created_at', [$dateRange['from'], $dateRange['to']])
|
||||
->groupBy('month')
|
||||
->orderBy('month')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
return $trends;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PKJ holdings by category
|
||||
*/
|
||||
public function getHoldingsByCategory(array $filters = [], $user = null): array
|
||||
{
|
||||
// Build a subquery for filtered entitlements
|
||||
// This ensures all categories are included, but only matching entitlements are summed
|
||||
$entitlementSubquery = PKJAssetEntitlement::query()
|
||||
->select('pkj_category_id', DB::raw('SUM(holding) as total_holding'))
|
||||
->groupBy('pkj_category_id');
|
||||
|
||||
// Apply visibility scoping if user is provided
|
||||
if ($user) {
|
||||
$entitlementSubquery->visibleTo($user);
|
||||
}
|
||||
|
||||
// Apply filters to entitlement subquery
|
||||
if (isset($filters['unit_id'])) {
|
||||
$entitlementSubquery->where('unit_id', $filters['unit_id']);
|
||||
}
|
||||
|
||||
// Apply government_id filter
|
||||
if (isset($filters['government_id']) && $filters['government_id']) {
|
||||
$unitIds = $this->getUnitsByGovernment($filters['government_id']);
|
||||
if (!empty($unitIds)) {
|
||||
$entitlementSubquery->whereIn('unit_id', $unitIds);
|
||||
} else {
|
||||
// If no units match, set to empty result but don't filter categories
|
||||
$entitlementSubquery->whereRaw('1 = 0');
|
||||
}
|
||||
}
|
||||
|
||||
// Apply formation_id filter
|
||||
if (isset($filters['formation_id']) && $filters['formation_id']) {
|
||||
$unitIds = Unit::where('formation_id', $filters['formation_id'])
|
||||
->pluck('id')
|
||||
->toArray();
|
||||
|
||||
if (!empty($unitIds)) {
|
||||
$entitlementSubquery->whereIn('unit_id', $unitIds);
|
||||
} else {
|
||||
// If no units match, set to empty result but don't filter categories
|
||||
$entitlementSubquery->whereRaw('1 = 0');
|
||||
}
|
||||
}
|
||||
|
||||
// Main query: Get ALL categories and LEFT JOIN with filtered entitlements
|
||||
// This ensures all categories are returned even if they have no matching entitlements
|
||||
$query = PKJCategory::query()
|
||||
->leftJoinSub($entitlementSubquery, 'filtered_entitlements', function ($join) {
|
||||
$join->on('pkj_categories.id', '=', 'filtered_entitlements.pkj_category_id');
|
||||
})
|
||||
->selectRaw('
|
||||
pkj_categories.id as category_id,
|
||||
pkj_categories.name as category_name,
|
||||
COALESCE(filtered_entitlements.total_holding, 0) as total_holding
|
||||
')
|
||||
->orderBy('pkj_categories.name');
|
||||
|
||||
return $query->get()->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PKJ category metrics (KEUPAYAAN, KESIAGAAN, SERVISIBILITI)
|
||||
*/
|
||||
public function getCategoryMetrics(int $categoryId, array $filters = [], $user = null): array
|
||||
{
|
||||
// Get category name
|
||||
$category = PKJCategory::find($categoryId);
|
||||
$categoryName = $category ? $category->name : 'Unknown Category';
|
||||
|
||||
// Get entitlement and holding data for the category
|
||||
$entitlementQuery = PKJAssetEntitlement::query()
|
||||
->where('pkj_category_id', $categoryId);
|
||||
|
||||
// Apply visibility scoping if user is provided
|
||||
if ($user) {
|
||||
$entitlementQuery->visibleTo($user);
|
||||
}
|
||||
|
||||
// Apply filters
|
||||
if (isset($filters['unit_id'])) {
|
||||
$entitlementQuery->where('unit_id', $filters['unit_id']);
|
||||
}
|
||||
|
||||
$entitlementData = $entitlementQuery->selectRaw('
|
||||
SUM(entitlement) as total_entitlement,
|
||||
SUM(holding) as total_holding
|
||||
')->first();
|
||||
|
||||
// Get operational/usable assets count (BDG, BT, BP statuses) for the category
|
||||
$operationalQuery = PKJAssetHolding::query()
|
||||
->where('pkj_category_id', $categoryId)
|
||||
->whereIn('status', ['BDG', 'BT', 'BP']);
|
||||
|
||||
// Apply visibility scoping if user is provided
|
||||
if ($user) {
|
||||
$operationalQuery->visibleTo($user);
|
||||
}
|
||||
|
||||
// Apply filters
|
||||
if (isset($filters['unit_id'])) {
|
||||
$operationalQuery->where('unit_id', $filters['unit_id']);
|
||||
}
|
||||
|
||||
$operationalCount = $operationalQuery->count();
|
||||
|
||||
// Get BDG, BT, BG count specifically for servisibiliti calculation
|
||||
$bdgQuery = PKJAssetHolding::query()
|
||||
->where('pkj_category_id', $categoryId)
|
||||
->whereIn('status', ['BDG', 'BT', 'BG']);
|
||||
|
||||
// Apply visibility scoping if user is provided
|
||||
if ($user) {
|
||||
$bdgQuery->visibleTo($user);
|
||||
}
|
||||
|
||||
// Apply filters
|
||||
if (isset($filters['unit_id'])) {
|
||||
$bdgQuery->where('unit_id', $filters['unit_id']);
|
||||
}
|
||||
|
||||
$bdgCount = $bdgQuery->count();
|
||||
|
||||
$totalEntitlement = $entitlementData ? $entitlementData->total_entitlement : 0;
|
||||
$totalHolding = $entitlementData ? $entitlementData->total_holding : 0;
|
||||
|
||||
// Calculate metrics
|
||||
$keupayaan = $totalEntitlement > 0 ? ($totalHolding / $totalEntitlement) * 100 : 0;
|
||||
$kesiagaan = $totalEntitlement > 0 ? ($operationalCount / $totalEntitlement) * 100 : 0;
|
||||
$servisibiliti = $totalHolding > 0 ? ($bdgCount / $totalHolding) * 100 : 0;
|
||||
|
||||
return [
|
||||
'category_id' => $categoryId,
|
||||
'category_name' => $categoryName,
|
||||
'keupayaan' => round($keupayaan, 2),
|
||||
'kesiagaan' => round($kesiagaan, 2),
|
||||
'servisibiliti' => round($servisibiliti, 2),
|
||||
'total_entitlement' => $totalEntitlement,
|
||||
'total_holding' => $totalHolding,
|
||||
'operational_count' => $operationalCount,
|
||||
'bdg_count' => $bdgCount
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PKJ category status breakdown
|
||||
*/
|
||||
public function getCategoryStatusBreakdown(int $categoryId, array $filters = [], $user = null): array
|
||||
{
|
||||
// Get category name
|
||||
$category = PKJCategory::find($categoryId);
|
||||
$categoryName = $category ? $category->name : 'Unknown Category';
|
||||
|
||||
$query = PKJAssetHolding::query()
|
||||
->where('pkj_category_id', $categoryId)
|
||||
->selectRaw('status, COUNT(*) as count')
|
||||
->groupBy('status')
|
||||
->orderBy('status');
|
||||
|
||||
// Apply visibility scoping if user is provided
|
||||
if ($user) {
|
||||
$query->visibleTo($user);
|
||||
}
|
||||
|
||||
// Apply filters
|
||||
if (isset($filters['unit_id'])) {
|
||||
$query->where('unit_id', $filters['unit_id']);
|
||||
}
|
||||
|
||||
$statusCounts = $query->get()->toArray();
|
||||
|
||||
return [
|
||||
'category_id' => $categoryId,
|
||||
'category_name' => $categoryName,
|
||||
'status_breakdown' => $statusCounts
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PKJ asset details by category and status.
|
||||
*
|
||||
* When the asset has no status yet, the status column in the database is NULL.
|
||||
* Callers pass status as '' or the string 'null' to request these "no status" assets.
|
||||
*/
|
||||
public function getAssetDetailsByCategoryAndStatus(int $categoryId, string $status, array $filters = [], $user = null): array
|
||||
{
|
||||
// Handle no-status: API passes '' or 'null'; DB column is NULL for assets not yet assigned a status
|
||||
$status = trim($status ?? '');
|
||||
|
||||
$query = PKJAssetHolding::query()
|
||||
->with(['unit', 'pkjCategory', 'pkjSubcategory', 'pkjModel'])
|
||||
->where('pkj_category_id', $categoryId);
|
||||
|
||||
if ($status === '' || $status === 'null') {
|
||||
// Assets with status column NULL (not yet assigned a status)
|
||||
$query->whereNull('status');
|
||||
} else {
|
||||
// For specific status, must match exactly and NOT be null
|
||||
$query->whereNotNull('status')
|
||||
->where('status', '=', $status);
|
||||
}
|
||||
|
||||
$query->select([
|
||||
'id',
|
||||
'uuid',
|
||||
'registration_number',
|
||||
'kewpa_registration_number',
|
||||
'status',
|
||||
'unit_id',
|
||||
'pkj_category_id',
|
||||
'pkj_subcategory_id',
|
||||
'pkj_model_id',
|
||||
'purchase_date',
|
||||
'receipt_date',
|
||||
'purchase_price',
|
||||
'contract_reference',
|
||||
'country_of_manufacture',
|
||||
'economic_year',
|
||||
'economic_distance',
|
||||
'note',
|
||||
'engine_number',
|
||||
'chassis_number',
|
||||
'created_at',
|
||||
'updated_at'
|
||||
]);
|
||||
|
||||
// Apply visibility scoping if user is provided
|
||||
if ($user) {
|
||||
$query->visibleTo($user);
|
||||
}
|
||||
|
||||
// Apply filters
|
||||
if (isset($filters['unit_id'])) {
|
||||
$query->where('unit_id', $filters['unit_id']);
|
||||
}
|
||||
|
||||
$perPage = isset($filters['per_page']) ? (int) $filters['per_page'] : 10;
|
||||
$page = isset($filters['page']) ? (int) $filters['page'] : null;
|
||||
$assets = $query->paginate($perPage, ['*'], 'page', $page);
|
||||
|
||||
return [
|
||||
'data' => $assets->items(),
|
||||
'pagination' => [
|
||||
'current_page' => $assets->currentPage(),
|
||||
'per_page' => $assets->perPage(),
|
||||
'total' => $assets->total(),
|
||||
'last_page' => $assets->lastPage(),
|
||||
'from' => $assets->firstItem(),
|
||||
'to' => $assets->lastItem(),
|
||||
'has_more_pages' => $assets->hasMorePages()
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply common filters to query
|
||||
*/
|
||||
private function applyFilters($query, array $filters = [], bool $excludeDateFilter = false): void
|
||||
{
|
||||
// Only apply created_at filter if the model has created_at column
|
||||
$model = $query->getModel();
|
||||
if ($model && method_exists($model, 'getTable')) {
|
||||
$schema = $model->getConnection()->getSchemaBuilder();
|
||||
$columns = $schema->getColumnListing($model->getTable());
|
||||
|
||||
if (!$excludeDateFilter && in_array('created_at', $columns)) {
|
||||
$dateRange = $this->getDateRange($filters);
|
||||
$query->whereBetween('created_at', [$dateRange['from'], $dateRange['to']]);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply unit_id filter if model has unit_id column
|
||||
if (isset($filters['unit_id']) && $filters['unit_id']) {
|
||||
if ($model && method_exists($model, 'getTable')) {
|
||||
$columns = $schema->getColumnListing($model->getTable());
|
||||
if (in_array('unit_id', $columns)) {
|
||||
$query->where('unit_id', $filters['unit_id']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply government_id filter if provided
|
||||
if (isset($filters['government_id']) && $filters['government_id']) {
|
||||
// Get units under the government
|
||||
$unitIds = $this->getUnitsByGovernment($filters['government_id']);
|
||||
|
||||
if ($model && method_exists($model, 'getTable')) {
|
||||
$columns = $schema->getColumnListing($model->getTable());
|
||||
if (in_array('unit_id', $columns)) {
|
||||
if (!empty($unitIds)) {
|
||||
$query->whereIn('unit_id', $unitIds);
|
||||
} else {
|
||||
// No units found for this government - return empty result
|
||||
$query->whereNull('id');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply formation_id filter if provided
|
||||
if (isset($filters['formation_id']) && $filters['formation_id']) {
|
||||
if ($model && method_exists($model, 'getTable')) {
|
||||
$columns = $schema->getColumnListing($model->getTable());
|
||||
if (in_array('unit_id', $columns)) {
|
||||
// Get units under this formation
|
||||
$unitIds = Unit::where('formation_id', $filters['formation_id'])
|
||||
->pluck('id')
|
||||
->toArray();
|
||||
|
||||
if (!empty($unitIds)) {
|
||||
$query->whereIn('unit_id', $unitIds);
|
||||
} else {
|
||||
// No units found for this formation - return empty result
|
||||
$query->whereNull('id');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply camp_id filter if provided
|
||||
if (isset($filters['camp_id']) && $filters['camp_id']) {
|
||||
if ($model && method_exists($model, 'getTable')) {
|
||||
$columns = $schema->getColumnListing($model->getTable());
|
||||
if (in_array('camp_id', $columns)) {
|
||||
$query->where('camp_id', $filters['camp_id']);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get units by government ID
|
||||
* Uses VisibilityService for consistent logic
|
||||
*/
|
||||
private function getUnitsByGovernment(int $governmentId): array
|
||||
{
|
||||
$governmentUnit = Unit::where('government_id', $governmentId)->first();
|
||||
if (!$governmentUnit) {
|
||||
// If no direct unit found, try to get from formation
|
||||
$formation = \Modules\Formation\Entities\Formation::where('government_id', $governmentId)->first();
|
||||
if ($formation) {
|
||||
$governmentUnit = Unit::where('formation_id', $formation->id)->first();
|
||||
}
|
||||
}
|
||||
|
||||
if (!$governmentUnit) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->visibilityService->getUnitsUnderGovernment($governmentUnit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get date range for filtering
|
||||
*/
|
||||
private function getDateRange(array $filters = []): array
|
||||
{
|
||||
$dateFrom = $filters['date_from'] ?? Carbon::now()->startOfYear();
|
||||
$dateTo = $filters['date_to'] ?? Carbon::now()->endOfYear();
|
||||
|
||||
return [
|
||||
'from' => Carbon::parse($dateFrom)->startOfDay(),
|
||||
'to' => Carbon::parse($dateTo)->endOfDay()
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\Dashboard\Http\Controllers\DashboardController;
|
||||
use Modules\Dashboard\Http\Controllers\KJC\KJCDashboardController;
|
||||
use Modules\Dashboard\Http\Controllers\PKJ\PKJDashboardController;
|
||||
|
||||
Route::middleware(['auth:sanctum', 'single.session'])->prefix('v1')->group(function () {
|
||||
// Main dashboard endpoints
|
||||
Route::get('/dashboard/stat-cards', [DashboardController::class, 'getStatCards']);
|
||||
Route::get('/dashboard/overview', [DashboardController::class, 'getOverview']);
|
||||
|
||||
// KJC specific endpoints
|
||||
Route::prefix('dashboard/kjc')->group(function () {
|
||||
// Pie charts
|
||||
Route::get('/entitlement-vs-holdings-pie', [KJCDashboardController::class, 'getEntitlementVsHoldingsPieChart']);
|
||||
Route::get('/holdings-status-breakdown-pie', [KJCDashboardController::class, 'getHoldingsStatusBreakdownPieChart']);
|
||||
|
||||
// Bar charts
|
||||
Route::get('/historical-metrics-bar', [KJCDashboardController::class, 'getHistoricalMetricsBarChart']);
|
||||
|
||||
// Other charts
|
||||
Route::get('/unit-performance-chart', [KJCDashboardController::class, 'getUnitPerformanceChart']);
|
||||
Route::get('/monthly-trends-chart', [KJCDashboardController::class, 'getMonthlyTrendsChart']);
|
||||
|
||||
// Drill-down endpoints
|
||||
Route::get('/drill-down/entitlement-vs-holdings', [KJCDashboardController::class, 'getEntitlementVsHoldingsDetails']);
|
||||
Route::get('/drill-down/holdings-status', [KJCDashboardController::class, 'getHoldingsStatusDetails']);
|
||||
Route::get('/status/{status}/details', [KJCDashboardController::class, 'getAssetDetailsByStatus']);
|
||||
});
|
||||
|
||||
// PKJ specific endpoints
|
||||
Route::prefix('dashboard/pkj')->group(function () {
|
||||
Route::get('/entitlement-vs-holdings-pie', [PKJDashboardController::class, 'getEntitlementVsHoldingsPieChart']);
|
||||
Route::get('/holdings-status-breakdown-pie', [PKJDashboardController::class, 'getHoldingsStatusBreakdownPieChart']);
|
||||
Route::get('/monthly-trends', [PKJDashboardController::class, 'getMonthlyTrendsChart']);
|
||||
Route::get('/holdings-by-category-bar', [PKJDashboardController::class, 'getHoldingsByCategoryBarChart']);
|
||||
|
||||
// Drill-down endpoints
|
||||
Route::get('/category/{categoryId}/metrics-bar', [PKJDashboardController::class, 'getCategoryMetricsBarChart']);
|
||||
Route::get('/category/{categoryId}/status-breakdown-pie', [PKJDashboardController::class, 'getCategoryStatusBreakdownPieChart']);
|
||||
Route::get('/category/{categoryId}/status/{status}/details', [PKJDashboardController::class, 'getAssetDetailsByCategoryAndStatus']);
|
||||
});
|
||||
});
|
||||
@@ -1,486 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Dashboard\Services;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\Unit\Entities\Unit;
|
||||
use Modules\Camp\Entities\Camp;
|
||||
use Modules\Formation\Entities\Formation;
|
||||
use App\Services\VisibilityService;
|
||||
|
||||
abstract class DashboardService
|
||||
{
|
||||
public function __construct(
|
||||
protected VisibilityService $visibilityService
|
||||
) {}
|
||||
/**
|
||||
* Get date range for filtering
|
||||
*/
|
||||
protected function getDateRange(array $filters = []): array
|
||||
{
|
||||
$dateFrom = $filters['date_from'] ?? Carbon::now()->startOfYear();
|
||||
$dateTo = $filters['date_to'] ?? Carbon::now()->endOfYear();
|
||||
|
||||
return [
|
||||
'from' => Carbon::parse($dateFrom)->startOfDay(),
|
||||
'to' => Carbon::parse($dateTo)->endOfDay()
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply common filters to query
|
||||
*/
|
||||
protected function applyCommonFilters($query, array $filters = []): void
|
||||
{
|
||||
$dateRange = $this->getDateRange($filters);
|
||||
|
||||
$query->whereBetween('created_at', [$dateRange['from'], $dateRange['to']]);
|
||||
|
||||
// Government filter: Filter by government (get all units under that government)
|
||||
if (isset($filters['government_id']) && $filters['government_id']) {
|
||||
$unitIds = $this->getUnitsByGovernment($filters['government_id']);
|
||||
|
||||
if (!empty($unitIds)) {
|
||||
$query->whereIn('unit_id', $unitIds);
|
||||
} else {
|
||||
// No units found for this government
|
||||
$query->whereNull('id');
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($filters['unit_id']) && $filters['unit_id']) {
|
||||
$query->where('unit_id', $filters['unit_id']);
|
||||
}
|
||||
|
||||
if (isset($filters['camp_id']) && $filters['camp_id']) {
|
||||
$query->whereHas('unit', function ($q) use ($filters) {
|
||||
$q->where('camp_id', $filters['camp_id']);
|
||||
});
|
||||
}
|
||||
|
||||
if (isset($filters['formation_id']) && $filters['formation_id']) {
|
||||
$query->whereHas('unit', function ($q) use ($filters) {
|
||||
$q->where('formation_id', $filters['formation_id']);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply visibility scoping to query based on user permissions
|
||||
*/
|
||||
protected function applyVisibilityScoping($query, $user): void
|
||||
{
|
||||
if (!$user) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the model has the HasVisibility trait
|
||||
if (method_exists($query->getModel(), 'scopeVisibleTo')) {
|
||||
$query->visibleTo($user);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply visibility scoping to raw database queries
|
||||
* Uses VisibilityService for consistent logic
|
||||
*/
|
||||
protected function applyRawVisibilityScoping($query, $user, string $tableName): void
|
||||
{
|
||||
if (!$user) {
|
||||
return;
|
||||
}
|
||||
|
||||
$userUnit = Unit::find($user->unit_id);
|
||||
if (!$userUnit) {
|
||||
$query->whereRaw('1 = 0');
|
||||
return;
|
||||
}
|
||||
|
||||
$unitIds = $this->visibilityService->getVisibleUnitIds($user);
|
||||
|
||||
if (empty($unitIds)) {
|
||||
$query->whereRaw('1 = 0');
|
||||
} else {
|
||||
$query->whereIn('unit_id', $unitIds);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all units by government ID
|
||||
* Uses VisibilityService for consistent logic
|
||||
*/
|
||||
protected function getUnitsByGovernment(int $governmentId): array
|
||||
{
|
||||
$governmentUnit = Unit::where('government_id', $governmentId)->first();
|
||||
if (!$governmentUnit) {
|
||||
// If no direct unit found, try to get from formation
|
||||
$formation = Formation::where('government_id', $governmentId)->first();
|
||||
if ($formation) {
|
||||
$governmentUnit = Unit::where('formation_id', $formation->id)->first();
|
||||
}
|
||||
}
|
||||
|
||||
if (!$governmentUnit) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->visibilityService->getUnitsUnderGovernment($governmentUnit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total units count
|
||||
*/
|
||||
public function getTotalUnits(array $filters = []): int
|
||||
{
|
||||
$query = Unit::query();
|
||||
|
||||
if (isset($filters['camp_id']) && $filters['camp_id']) {
|
||||
$query->where('camp_id', $filters['camp_id']);
|
||||
}
|
||||
|
||||
if (isset($filters['formation_id']) && $filters['formation_id']) {
|
||||
$query->where('formation_id', $filters['formation_id']);
|
||||
}
|
||||
|
||||
return $query->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total camps count
|
||||
*/
|
||||
public function getTotalCamps(array $filters = []): int
|
||||
{
|
||||
return Camp::count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total formations count
|
||||
*/
|
||||
public function getTotalFormations(array $filters = []): int
|
||||
{
|
||||
return Formation::count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active repairs count (to be implemented by child classes)
|
||||
*/
|
||||
abstract public function getActiveRepairs(array $filters = []): int;
|
||||
|
||||
/**
|
||||
* Get pending reports count (to be implemented by child classes)
|
||||
*/
|
||||
abstract public function getPendingReports(array $filters = []): int;
|
||||
|
||||
/**
|
||||
* Get total assets count (to be implemented by child classes)
|
||||
*/
|
||||
abstract public function getTotalAssets(array $filters = []): int;
|
||||
|
||||
/**
|
||||
* Get operational assets count (to be implemented by child classes)
|
||||
*/
|
||||
abstract public function getOperationalAssets(array $filters = []): int;
|
||||
|
||||
/**
|
||||
* Get under repair assets count (to be implemented by child classes)
|
||||
*/
|
||||
abstract public function getUnderRepairAssets(array $filters = []): int;
|
||||
|
||||
/**
|
||||
* Get maintenance due assets count (to be implemented by child classes)
|
||||
*/
|
||||
abstract public function getMaintenanceDueAssets(array $filters = []): int;
|
||||
|
||||
/**
|
||||
* Format chart data with common structure
|
||||
*/
|
||||
protected function formatChartData(
|
||||
string $chartType,
|
||||
string $title,
|
||||
array $series,
|
||||
array $categories = [],
|
||||
array $drillDown = []
|
||||
): array {
|
||||
return [
|
||||
'chart_type' => $chartType,
|
||||
'title' => $title,
|
||||
'series' => $series,
|
||||
'categories' => $categories,
|
||||
'drill_down' => $drillDown
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format pie chart data
|
||||
*/
|
||||
protected function formatPieChartData(
|
||||
string $title,
|
||||
array $data,
|
||||
string $drillDownEndpoint = '',
|
||||
array $drillDownParameters = []
|
||||
): array {
|
||||
$total = array_sum(array_column($data, 'value'));
|
||||
$series = [];
|
||||
|
||||
foreach ($data as $item) {
|
||||
$percentage = $total > 0 ? round(($item['value'] / $total) * 100, 1) : 0;
|
||||
|
||||
$seriesItem = [
|
||||
'name' => $item['name'],
|
||||
'value' => $item['value'],
|
||||
'percentage' => $percentage
|
||||
];
|
||||
|
||||
// Preserve status_code if it exists (for drill-down functionality)
|
||||
if (isset($item['status_code'])) {
|
||||
$seriesItem['status_code'] = $item['status_code'];
|
||||
}
|
||||
|
||||
$series[] = $seriesItem;
|
||||
}
|
||||
|
||||
return $this->formatChartData(
|
||||
'pie',
|
||||
$title,
|
||||
$series,
|
||||
[],
|
||||
[
|
||||
'enabled' => !empty($drillDownEndpoint),
|
||||
'endpoint' => $drillDownEndpoint,
|
||||
'parameters' => $drillDownParameters
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bar chart data
|
||||
*/
|
||||
protected function formatBarChartData(
|
||||
string $title,
|
||||
array $series,
|
||||
array $categories,
|
||||
string $drillDownEndpoint = '',
|
||||
array $drillDownParameters = []
|
||||
): array {
|
||||
return $this->formatChartData(
|
||||
'bar',
|
||||
$title,
|
||||
$series,
|
||||
$categories,
|
||||
[
|
||||
'enabled' => !empty($drillDownEndpoint),
|
||||
'endpoint' => $drillDownEndpoint,
|
||||
'parameters' => $drillDownParameters
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format line chart data
|
||||
*/
|
||||
protected function formatLineChartData(
|
||||
string $title,
|
||||
array $series,
|
||||
array $categories,
|
||||
string $drillDownEndpoint = '',
|
||||
array $drillDownParameters = []
|
||||
): array {
|
||||
return $this->formatChartData(
|
||||
'line',
|
||||
$title,
|
||||
$series,
|
||||
$categories,
|
||||
[
|
||||
'enabled' => !empty($drillDownEndpoint),
|
||||
'endpoint' => $drillDownEndpoint,
|
||||
'parameters' => $drillDownParameters
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get monthly categories for date range
|
||||
*/
|
||||
protected function getMonthlyCategories(array $filters = []): array
|
||||
{
|
||||
$dateRange = $this->getDateRange($filters);
|
||||
$categories = [];
|
||||
|
||||
$current = $dateRange['from']->copy()->startOfMonth();
|
||||
$end = $dateRange['to']->copy()->endOfMonth();
|
||||
|
||||
while ($current->lte($end)) {
|
||||
$categories[] = $current->format('M Y');
|
||||
$current->addMonth();
|
||||
}
|
||||
|
||||
return $categories;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get weekly categories for date range
|
||||
*/
|
||||
protected function getWeeklyCategories(array $filters = []): array
|
||||
{
|
||||
$dateRange = $this->getDateRange($filters);
|
||||
$categories = [];
|
||||
|
||||
$current = $dateRange['from']->copy()->startOfWeek();
|
||||
$end = $dateRange['to']->copy()->endOfWeek();
|
||||
|
||||
while ($current->lte($end)) {
|
||||
$categories[] = 'Week ' . $current->weekOfYear . ' ' . $current->year;
|
||||
$current->addWeek();
|
||||
}
|
||||
|
||||
return $categories;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get daily categories for date range
|
||||
*/
|
||||
protected function getDailyCategories(array $filters = []): array
|
||||
{
|
||||
$dateRange = $this->getDateRange($filters);
|
||||
$categories = [];
|
||||
|
||||
$current = $dateRange['from']->copy();
|
||||
$end = $dateRange['to']->copy();
|
||||
|
||||
while ($current->lte($end)) {
|
||||
$categories[] = $current->format('M d');
|
||||
$current->addDay();
|
||||
}
|
||||
|
||||
return $categories;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate percentage change
|
||||
*/
|
||||
protected function calculatePercentageChange(int $current, int $previous): float
|
||||
{
|
||||
if ($previous === 0) {
|
||||
return $current > 0 ? 100.0 : 0.0;
|
||||
}
|
||||
|
||||
return round((($current - $previous) / $previous) * 100, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status color mapping
|
||||
*/
|
||||
protected function getStatusColors(): array
|
||||
{
|
||||
return [
|
||||
'operational' => '#4caf50',
|
||||
'under_repair' => '#ff9800',
|
||||
'maintenance_due' => '#f44336',
|
||||
'out_of_service' => '#9e9e9e',
|
||||
'completed' => '#4caf50',
|
||||
'pending' => '#ff9800',
|
||||
'in_progress' => '#2196f3',
|
||||
'cancelled' => '#f44336'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get priority color mapping
|
||||
*/
|
||||
protected function getPriorityColors(): array
|
||||
{
|
||||
return [
|
||||
'high' => '#f44336',
|
||||
'medium' => '#ff9800',
|
||||
'low' => '#4caf50'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format currency
|
||||
*/
|
||||
protected function formatCurrency(float $amount): string
|
||||
{
|
||||
return 'RM ' . number_format($amount, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration in hours
|
||||
*/
|
||||
protected function formatDuration(float $hours): string
|
||||
{
|
||||
if ($hours < 24) {
|
||||
return round($hours, 1) . ' hours';
|
||||
}
|
||||
|
||||
$days = floor($hours / 24);
|
||||
$remainingHours = $hours % 24;
|
||||
|
||||
if ($remainingHours > 0) {
|
||||
return $days . ' days ' . round($remainingHours, 1) . ' hours';
|
||||
}
|
||||
|
||||
return $days . ' days';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get common drill-down parameters
|
||||
*/
|
||||
protected function getCommonDrillDownParameters(): array
|
||||
{
|
||||
return ['date_from', 'date_to', 'unit_id', 'camp_id', 'formation_id'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build drill-down endpoint
|
||||
*/
|
||||
protected function buildDrillDownEndpoint(string $baseEndpoint, array $parameters = []): string
|
||||
{
|
||||
$endpoint = $baseEndpoint;
|
||||
|
||||
if (!empty($parameters)) {
|
||||
$endpoint .= '?' . http_build_query($parameters);
|
||||
}
|
||||
|
||||
return $endpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get summary statistics for drill-down
|
||||
*/
|
||||
protected function getSummaryStatistics($query, array $filters = []): array
|
||||
{
|
||||
$total = $query->count();
|
||||
|
||||
// Get breakdown by unit
|
||||
$byUnit = $query->clone()
|
||||
->join('units', function ($join) {
|
||||
$join->on('units.id', '=', $this->getUnitIdColumn());
|
||||
})
|
||||
->selectRaw('units.name as unit_name, COUNT(*) as count')
|
||||
->groupBy('units.name')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'by_unit' => $byUnit
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unit ID column name (to be implemented by child classes)
|
||||
*/
|
||||
abstract protected function getUnitIdColumn(): string;
|
||||
|
||||
/**
|
||||
* Get asset model class (to be implemented by child classes)
|
||||
*/
|
||||
abstract protected function getAssetModelClass(): string;
|
||||
|
||||
/**
|
||||
* Get repair model class (to be implemented by child classes)
|
||||
*/
|
||||
abstract protected function getRepairModelClass(): string;
|
||||
}
|
||||
@@ -1,315 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Dashboard\Services\KJC;
|
||||
|
||||
use Modules\Dashboard\Services\DashboardService;
|
||||
use Modules\Dashboard\Repositories\Contracts\KJCDashboardRepositoryInterface;
|
||||
use Modules\KJCRepair\Entities\KJCRepair;
|
||||
use Modules\KJCReport\Entities\KJCReport;
|
||||
use Modules\Unit\Entities\Unit;
|
||||
use App\Services\VisibilityService;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class KJCDashboardService extends DashboardService
|
||||
{
|
||||
protected KJCDashboardRepositoryInterface $kjcDashboardRepository;
|
||||
|
||||
public function __construct(
|
||||
VisibilityService $visibilityService,
|
||||
KJCDashboardRepositoryInterface $kjcDashboardRepository
|
||||
) {
|
||||
parent::__construct($visibilityService);
|
||||
$this->kjcDashboardRepository = $kjcDashboardRepository;
|
||||
}
|
||||
/**
|
||||
* Get total KJC assets count
|
||||
*/
|
||||
public function getTotalAssets(array $filters = [], $user = null): int
|
||||
{
|
||||
return $this->kjcDashboardRepository->getTotalHoldings($filters, $user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get operational KJC assets count (BP status)
|
||||
*/
|
||||
public function getOperationalAssets(array $filters = [], $user = null): int
|
||||
{
|
||||
$holdings = $this->kjcDashboardRepository->getHoldingsBySpecificStatus('BP', $filters, $user);
|
||||
return $holdings['pagination']['total'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get under repair KJC assets count (BDG status)
|
||||
*/
|
||||
public function getUnderRepairAssets(array $filters = [], $user = null): int
|
||||
{
|
||||
$holdings = $this->kjcDashboardRepository->getHoldingsBySpecificStatus('BDG', $filters, $user);
|
||||
return $holdings['pagination']['total'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get maintenance due KJC assets count (TBDG status)
|
||||
*/
|
||||
public function getMaintenanceDueAssets(array $filters = [], $user = null): int
|
||||
{
|
||||
$holdings = $this->kjcDashboardRepository->getHoldingsBySpecificStatus('TBDG', $filters, $user);
|
||||
return $holdings['pagination']['total'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active KJC repairs count
|
||||
*/
|
||||
public function getActiveRepairs(array $filters = []): int
|
||||
{
|
||||
$query = KJCRepair::whereIn('status', ['pending', 'in_progress']);
|
||||
$this->applyCommonFilters($query, $filters);
|
||||
|
||||
return $query->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending KJC reports count
|
||||
*/
|
||||
public function getPendingReports(array $filters = []): int
|
||||
{
|
||||
$query = KJCReport::where('status', 'pending');
|
||||
$this->applyCommonFilters($query, $filters);
|
||||
|
||||
return $query->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get KJC entitlement vs holdings pie chart
|
||||
*/
|
||||
public function getEntitlementVsHoldingsPieChart(array $filters = [], $user = null): array
|
||||
{
|
||||
// Get total entitlement
|
||||
$totalEntitlement = $this->kjcDashboardRepository->getTotalEntitlement($filters, $user);
|
||||
|
||||
// Get total holdings
|
||||
$totalHoldings = $this->kjcDashboardRepository->getTotalHoldings($filters, $user);
|
||||
|
||||
// Prepare data for pie chart (only showing Perjawatan and Pegangan)
|
||||
$data = [
|
||||
[
|
||||
'name' => 'Perjawatan',
|
||||
'value' => $totalEntitlement,
|
||||
'color' => '#4caf50'
|
||||
],
|
||||
[
|
||||
'name' => 'Pegangan',
|
||||
'value' => $totalHoldings,
|
||||
'color' => '#2196f3'
|
||||
]
|
||||
];
|
||||
|
||||
return $this->formatPieChartData(
|
||||
'Perjawatan vs Pegangan KJC',
|
||||
$data,
|
||||
'/v1/dashboard/kjc/drill-down/entitlement-vs-holdings',
|
||||
['type']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get KJC holdings status breakdown pie chart
|
||||
*/
|
||||
public function getHoldingsStatusBreakdownPieChart(array $filters = [], $user = null): array
|
||||
{
|
||||
$statusCounts = $this->kjcDashboardRepository->getHoldingsByStatus($filters, $user);
|
||||
|
||||
$statusNames = [
|
||||
'BP' => 'BP',
|
||||
'BDG' => 'BDG',
|
||||
'TBDG' => 'TBDG',
|
||||
'BT' => 'BT',
|
||||
'TBT' => 'TBT',
|
||||
'BG' => 'BG',
|
||||
'TBG' => 'TBG',
|
||||
];
|
||||
|
||||
$data = [];
|
||||
foreach ($statusCounts as $item) {
|
||||
$statusCode = $item['status'];
|
||||
if ($statusCode === null || $statusCode === '') {
|
||||
$statusName = 'Belum Diberi Status';
|
||||
} else {
|
||||
$statusName = $statusNames[$statusCode] ?? $statusCode;
|
||||
}
|
||||
$data[] = [
|
||||
'name' => $statusName,
|
||||
'value' => $item['count'],
|
||||
'percentage' => 0,
|
||||
'status_code' => $statusCode,
|
||||
];
|
||||
}
|
||||
|
||||
// Calculate percentages
|
||||
$total = array_sum(array_column($data, 'value'));
|
||||
foreach ($data as &$row) {
|
||||
$row['percentage'] = $total > 0 ? round(($row['value'] / $total) * 100, 2) : 0;
|
||||
}
|
||||
|
||||
return $this->formatPieChartData(
|
||||
'KJC Status Pegangan',
|
||||
$data,
|
||||
'/v1/dashboard/kjc/drill-down/holdings-status',
|
||||
['status']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get KJC unit performance chart
|
||||
*/
|
||||
public function getUnitPerformanceChart(array $filters = []): array
|
||||
{
|
||||
$units = Unit::withCount([
|
||||
'kjcAssetHoldings as bp_count' => function ($query) use ($filters) {
|
||||
$query->where('status', 'BP');
|
||||
$this->applyCommonFilters($query, $filters);
|
||||
},
|
||||
'kjcAssetHoldings as bdg_count' => function ($query) use ($filters) {
|
||||
$query->where('status', 'BDG');
|
||||
$this->applyCommonFilters($query, $filters);
|
||||
},
|
||||
'kjcAssetHoldings as tbdg_count' => function ($query) use ($filters) {
|
||||
$query->where('status', 'TBDG');
|
||||
$this->applyCommonFilters($query, $filters);
|
||||
}
|
||||
])->get();
|
||||
|
||||
$unitNames = [];
|
||||
$bpCounts = [];
|
||||
$bdgCounts = [];
|
||||
$tbdgCounts = [];
|
||||
|
||||
foreach ($units as $unit) {
|
||||
$unitNames[] = $unit->name;
|
||||
$bpCounts[] = $unit->bp_count;
|
||||
$bdgCounts[] = $unit->bdg_count;
|
||||
$tbdgCounts[] = $unit->tbdg_count;
|
||||
}
|
||||
|
||||
return $this->formatBarChartData(
|
||||
'KJC Unit 2025',
|
||||
[
|
||||
['name' => 'Baik dalam Perhatian (BP)', 'data' => $bpCounts],
|
||||
['name' => 'Boleh Digunakan (BDG)', 'data' => $bdgCounts],
|
||||
['name' => 'Tidak Boleh Digunakan (TBDG)', 'data' => $tbdgCounts]
|
||||
],
|
||||
$unitNames,
|
||||
'/v1/dashboard/kjc/drill-down/unit-performance',
|
||||
['unit_id']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get KJC monthly trends chart
|
||||
*/
|
||||
public function getMonthlyTrendsChart(array $filters = [], $user = null): array
|
||||
{
|
||||
$dateRange = $this->getDateRange($filters);
|
||||
$categories = $this->getMonthlyCategories($filters);
|
||||
|
||||
$monthlyData = $this->kjcDashboardRepository->getMonthlyTrends($filters, $user);
|
||||
|
||||
// Initialize data arrays
|
||||
$bpData = array_fill(0, count($categories), 0);
|
||||
$bdgData = array_fill(0, count($categories), 0);
|
||||
$tbdgData = array_fill(0, count($categories), 0);
|
||||
|
||||
// Fill data arrays
|
||||
foreach ($monthlyData as $item) {
|
||||
$index = array_search($item['month'], $categories);
|
||||
if ($index !== false) {
|
||||
switch ($item['status']) {
|
||||
case 'BP':
|
||||
$bpData[$index] = $item['count'];
|
||||
break;
|
||||
case 'BDG':
|
||||
$bdgData[$index] = $item['count'];
|
||||
break;
|
||||
case 'TBDG':
|
||||
$tbdgData[$index] = $item['count'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this->formatLineChartData(
|
||||
'KJC Trend Bulanan 2025',
|
||||
[
|
||||
['name' => 'Baik dalam Perhatian (BP)', 'data' => $bpData],
|
||||
['name' => 'Boleh Digunakan (BDG)', 'data' => $bdgData],
|
||||
['name' => 'Tidak Boleh Digunakan (TBDG)', 'data' => $tbdgData]
|
||||
],
|
||||
$categories,
|
||||
'/v1/dashboard/kjc/drill-down/monthly-trends',
|
||||
['month', 'status']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get KJC historical metrics bar chart (KEUPAYAAN, KESIAGAAN, SERVISIBILITI)
|
||||
*/
|
||||
public function getHistoricalMetricsBarChart(array $filters = [], $user = null): array
|
||||
{
|
||||
$monthlyData = $this->kjcDashboardRepository->getHistoricalMetricsByMonth($filters, $user);
|
||||
|
||||
// Extract data for chart
|
||||
$categories = [];
|
||||
$keupayaanData = [];
|
||||
$kesiagaanData = [];
|
||||
$servisibilitiData = [];
|
||||
|
||||
foreach ($monthlyData as $data) {
|
||||
$monthDate = Carbon::createFromFormat('Y-m', $data['month']);
|
||||
$categories[] = $monthDate->format('M'); // Remove year from categories
|
||||
$keupayaanData[] = $data['keupayaan'];
|
||||
$kesiagaanData[] = $data['kesiagaan'];
|
||||
$servisibilitiData[] = $data['servisibiliti'];
|
||||
}
|
||||
|
||||
// Use year from user selection (date_from/date_to) or fall back to current year
|
||||
$year = Carbon::now()->year;
|
||||
if (!empty($filters['date_from'])) {
|
||||
$year = Carbon::parse($filters['date_from'])->year;
|
||||
} elseif (!empty($filters['date_to'])) {
|
||||
$year = Carbon::parse($filters['date_to'])->year;
|
||||
}
|
||||
|
||||
return $this->formatBarChartData(
|
||||
"Metrik Tahunan KJC {$year}",
|
||||
[
|
||||
['name' => 'KEUPAYAAN (%)', 'data' => $keupayaanData],
|
||||
['name' => 'KESIAGAAN (%)', 'data' => $kesiagaanData],
|
||||
['name' => 'SERVISIBILITI (%)', 'data' => $servisibilitiData]
|
||||
],
|
||||
$categories
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unit ID column name for KJC
|
||||
*/
|
||||
protected function getUnitIdColumn(): string
|
||||
{
|
||||
return 'kjc_asset_holdings.unit_id';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get asset model class for KJC
|
||||
*/
|
||||
protected function getAssetModelClass(): string
|
||||
{
|
||||
return KJCAssetHolding::class;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get repair model class for KJC
|
||||
*/
|
||||
protected function getRepairModelClass(): string
|
||||
{
|
||||
return KJCRepair::class;
|
||||
}
|
||||
}
|
||||
@@ -1,319 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Dashboard\Services\PKJ;
|
||||
|
||||
use Modules\Dashboard\Services\DashboardService;
|
||||
use Modules\Dashboard\Repositories\Contracts\PKJDashboardRepositoryInterface;
|
||||
use Modules\PKJAssetEntitlement\Entities\PKJAssetEntitlement;
|
||||
use Modules\PKJAssetHolding\Entities\PKJAssetHolding;
|
||||
use Modules\PKJRepair\Entities\PKJRepair;
|
||||
use Modules\PKJReport\Entities\PKJReport;
|
||||
use App\Services\VisibilityService;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class PKJDashboardService extends DashboardService
|
||||
{
|
||||
protected PKJDashboardRepositoryInterface $pkjDashboardRepository;
|
||||
|
||||
public function __construct(
|
||||
VisibilityService $visibilityService,
|
||||
PKJDashboardRepositoryInterface $pkjDashboardRepository
|
||||
) {
|
||||
parent::__construct($visibilityService);
|
||||
$this->pkjDashboardRepository = $pkjDashboardRepository;
|
||||
}
|
||||
/**
|
||||
* Get total PKJ assets
|
||||
*/
|
||||
public function getTotalAssets(array $filters = [], $user = null): int
|
||||
{
|
||||
return $this->pkjDashboardRepository->getTotalHoldings($filters, $user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get operational PKJ assets (BP status)
|
||||
*/
|
||||
public function getOperationalAssets(array $filters = [], $user = null): int
|
||||
{
|
||||
$holdings = $this->pkjDashboardRepository->getHoldingsBySpecificStatus('BP', $filters, $user);
|
||||
return $holdings['pagination']['total'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get under repair PKJ assets (TBDG status)
|
||||
*/
|
||||
public function getUnderRepairAssets(array $filters = [], $user = null): int
|
||||
{
|
||||
$holdings = $this->pkjDashboardRepository->getHoldingsBySpecificStatus('TBDG', $filters, $user);
|
||||
return $holdings['pagination']['total'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get maintenance due PKJ assets (BDG status)
|
||||
*/
|
||||
public function getMaintenanceDueAssets(array $filters = [], $user = null): int
|
||||
{
|
||||
$holdings = $this->pkjDashboardRepository->getHoldingsBySpecificStatus('BDG', $filters, $user);
|
||||
return $holdings['pagination']['total'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active PKJ repairs
|
||||
*/
|
||||
public function getActiveRepairs(array $filters = []): int
|
||||
{
|
||||
$dateRange = $this->getDateRange($filters);
|
||||
|
||||
return PKJRepair::query()
|
||||
->whereBetween('created_at', [$dateRange['from'], $dateRange['to']])
|
||||
->where('status', 'active')
|
||||
->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending PKJ reports
|
||||
*/
|
||||
public function getPendingReports(array $filters = []): int
|
||||
{
|
||||
$dateRange = $this->getDateRange($filters);
|
||||
|
||||
return PKJReport::query()
|
||||
->whereBetween('created_at', [$dateRange['from'], $dateRange['to']])
|
||||
->where('status', 'pending')
|
||||
->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PKJ entitlement vs holdings pie chart
|
||||
*/
|
||||
public function getEntitlementVsHoldingsPieChart(array $filters = [], $user = null): array
|
||||
{
|
||||
$totalEntitlement = $this->pkjDashboardRepository->getTotalEntitlement($filters, $user);
|
||||
$totalHoldings = $this->pkjDashboardRepository->getTotalHoldings($filters, $user);
|
||||
|
||||
$data = [
|
||||
[
|
||||
'name' => 'Perjawatan',
|
||||
'value' => $totalEntitlement,
|
||||
'percentage' => ($totalEntitlement + $totalHoldings) > 0 ? round(($totalEntitlement / ($totalEntitlement + $totalHoldings)) * 100, 2) : 0
|
||||
],
|
||||
[
|
||||
'name' => 'Pegangan',
|
||||
'value' => $totalHoldings,
|
||||
'percentage' => ($totalEntitlement + $totalHoldings) > 0 ? round(($totalHoldings / ($totalEntitlement + $totalHoldings)) * 100, 2) : 0
|
||||
]
|
||||
];
|
||||
|
||||
return $this->formatPieChartData(
|
||||
'Perjawatan vs Pegangan PKJ',
|
||||
$data,
|
||||
'', // No drill-down
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PKJ holdings status breakdown pie chart
|
||||
*/
|
||||
public function getHoldingsStatusBreakdownPieChart(array $filters = [], $user = null): array
|
||||
{
|
||||
$statusCounts = $this->pkjDashboardRepository->getHoldingsByStatus($filters, $user);
|
||||
|
||||
// Map status codes to readable names
|
||||
$statusNames = [
|
||||
'BP' => 'Baik dalam Perhatian',
|
||||
'BDG' => 'Boleh Digunakan',
|
||||
'TBDG' => 'Tidak Boleh Digunakan',
|
||||
'BT' => 'Boleh Tembak',
|
||||
'BG' => 'Baik Guna'
|
||||
];
|
||||
|
||||
$data = [];
|
||||
foreach ($statusCounts as $status) {
|
||||
$statusName = $statusNames[$status['status']] ?? $status['status'];
|
||||
$data[] = [
|
||||
'name' => $statusName,
|
||||
'value' => $status['count'],
|
||||
'percentage' => 0 // Will be calculated after total
|
||||
];
|
||||
}
|
||||
|
||||
// Calculate percentages
|
||||
$total = array_sum(array_column($data, 'value'));
|
||||
foreach ($data as &$item) {
|
||||
$item['percentage'] = $total > 0 ? round(($item['value'] / $total) * 100, 2) : 0;
|
||||
}
|
||||
|
||||
return $this->formatPieChartData(
|
||||
'PKJ Status Pegangan',
|
||||
$data,
|
||||
'', // No drill-down
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PKJ monthly trends chart
|
||||
*/
|
||||
public function getMonthlyTrendsChart(array $filters = [], $user = null): array
|
||||
{
|
||||
$monthlyData = $this->pkjDashboardRepository->getMonthlyTrends($filters, $user);
|
||||
|
||||
$categories = [];
|
||||
$data = [];
|
||||
|
||||
foreach ($monthlyData as $trend) {
|
||||
$categories[] = Carbon::createFromFormat('Y-m', $trend['month'])->format('M Y');
|
||||
$data[] = $trend['total_reports'];
|
||||
}
|
||||
|
||||
return $this->formatBarChartData(
|
||||
'Trend Bulanan PKJ 2025',
|
||||
[
|
||||
['name' => 'Laporan', 'data' => $data]
|
||||
],
|
||||
$categories
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PKJ holdings by category bar chart
|
||||
*/
|
||||
public function getHoldingsByCategoryBarChart(array $filters = [], $user = null): array
|
||||
{
|
||||
$categoryData = $this->pkjDashboardRepository->getHoldingsByCategory($filters, $user);
|
||||
|
||||
$categories = [];
|
||||
$categoryIds = [];
|
||||
$data = [];
|
||||
|
||||
foreach ($categoryData as $category) {
|
||||
$categories[] = $category['category_name'];
|
||||
$categoryIds[] = (int) $category['category_id'];
|
||||
$data[] = (int) $category['total_holding'];
|
||||
}
|
||||
|
||||
$chartData = $this->formatBarChartData(
|
||||
'Pegangan PKJ Mengikut Kategori',
|
||||
[
|
||||
['name' => 'Jumlah Pegangan', 'data' => $data]
|
||||
],
|
||||
$categories
|
||||
);
|
||||
|
||||
// Add category IDs to the chart data for drill-down functionality
|
||||
$chartData['category_ids'] = $categoryIds;
|
||||
|
||||
return $chartData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PKJ category metrics bar chart (drill-down)
|
||||
*/
|
||||
public function getCategoryMetricsBarChart(int $categoryId, array $filters = [], $user = null): array
|
||||
{
|
||||
$metrics = $this->pkjDashboardRepository->getCategoryMetrics($categoryId, $filters, $user);
|
||||
|
||||
$categories = ['KEUPAYAAN (%)', 'KESIAGAAN (%)', 'SERVISIBILITI (%)'];
|
||||
$data = [
|
||||
$metrics['keupayaan'],
|
||||
$metrics['kesiagaan'],
|
||||
$metrics['servisibiliti']
|
||||
];
|
||||
|
||||
$chartData = $this->formatBarChartData(
|
||||
'Metrik Kategori PKJ',
|
||||
[
|
||||
['name' => 'Metrics', 'data' => $data]
|
||||
],
|
||||
$categories
|
||||
);
|
||||
|
||||
// Add category information to the response
|
||||
$chartData['category_id'] = $metrics['category_id'];
|
||||
$chartData['category_name'] = $metrics['category_name'];
|
||||
|
||||
return $chartData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PKJ category status breakdown pie chart (drill-down)
|
||||
*/
|
||||
public function getCategoryStatusBreakdownPieChart(int $categoryId, array $filters = [], $user = null): array
|
||||
{
|
||||
$breakdown = $this->pkjDashboardRepository->getCategoryStatusBreakdown($categoryId, $filters, $user);
|
||||
|
||||
// Map status codes to readable names
|
||||
$statusNames = [
|
||||
'BP' => 'Baik dalam Perhatian',
|
||||
'BDG' => 'Boleh Digunakan',
|
||||
'TBDG' => 'Tidak Boleh Digunakan',
|
||||
'BT' => 'Boleh Tembak',
|
||||
'TBT' => 'Tidak Boleh Tembak',
|
||||
'BG' => 'Baik Guna',
|
||||
'TBG' => 'Tidak Baik Guna',
|
||||
];
|
||||
|
||||
$data = [];
|
||||
foreach ($breakdown['status_breakdown'] as $status) {
|
||||
$statusCode = $status['status'];
|
||||
|
||||
// In the breakdown, status_code is null when the asset has no status (DB column is NULL).
|
||||
if ($statusCode === null || $statusCode === '') {
|
||||
$statusName = 'Status Tidak Diketahui';
|
||||
} else {
|
||||
$statusName = $statusNames[$statusCode] ?? $statusCode;
|
||||
}
|
||||
|
||||
$data[] = [
|
||||
'name' => $statusName,
|
||||
'value' => $status['count'],
|
||||
'percentage' => 0, // Will be calculated after total
|
||||
'status_code' => $statusCode // null when DB status column is NULL (Belum Diberi Status)
|
||||
];
|
||||
}
|
||||
|
||||
// Calculate percentages
|
||||
$total = array_sum(array_column($data, 'value'));
|
||||
foreach ($data as &$item) {
|
||||
$item['percentage'] = $total > 0 ? round(($item['value'] / $total) * 100, 2) : 0;
|
||||
}
|
||||
|
||||
$chartData = $this->formatPieChartData(
|
||||
'PKJ Status Pegangan Mengikut Kategori',
|
||||
$data,
|
||||
'/v1/dashboard/pkj/category/' . $categoryId . '/status-details',
|
||||
['status']
|
||||
);
|
||||
|
||||
// Add category information to the response
|
||||
$chartData['category_id'] = $breakdown['category_id'];
|
||||
$chartData['category_name'] = $breakdown['category_name'];
|
||||
|
||||
return $chartData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unit ID column name for PKJ
|
||||
*/
|
||||
protected function getUnitIdColumn(): string
|
||||
{
|
||||
return 'pkj_asset_holdings.unit_id';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get asset model class for PKJ
|
||||
*/
|
||||
protected function getAssetModelClass(): string
|
||||
{
|
||||
return PKJAssetHolding::class;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get repair model class for PKJ
|
||||
*/
|
||||
protected function getRepairModelClass(): string
|
||||
{
|
||||
return PKJRepair::class;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"name": "Dashboard",
|
||||
"alias": "dashboard",
|
||||
"description": "",
|
||||
"keywords": [],
|
||||
"priority": 0,
|
||||
"providers": [
|
||||
"Modules\\Dashboard\\Providers\\DashboardServiceProvider"
|
||||
],
|
||||
"files": []
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'name' => 'Investment',
|
||||
|
||||
'billplz' => [
|
||||
'api_key' => env('BILLPLZ_API_KEY'),
|
||||
'x_signature_key' => env('BILLPLZ_X_SIGNATURE_KEY'),
|
||||
'collection_id' => env('BILLPLZ_COLLECTION_ID'),
|
||||
'base_url' => rtrim(env('BILLPLZ_BASE_URL', 'https://www.billplz-sandbox.com/api/v3'), '/'),
|
||||
'callback_url' => env('BILLPLZ_CALLBACK_URL'),
|
||||
'redirect_url' => env('BILLPLZ_REDIRECT_URL'),
|
||||
],
|
||||
];
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('investment_types', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
|
||||
$table->string('code');
|
||||
$table->index('code');
|
||||
$table->string('name');
|
||||
$table->text('description')->nullable();
|
||||
|
||||
$table->string('status')->default('ACTIVE');
|
||||
|
||||
$table->decimal('minimum_amount', 15, 2)->default(0);
|
||||
$table->decimal('maximum_amount', 15, 2)->nullable();
|
||||
$table->decimal('total_fund_limit', 15, 2)->nullable();
|
||||
$table->decimal('reserved_amount', 15, 2)->default(0);
|
||||
|
||||
$table->date('start_date')->nullable();
|
||||
$table->date('end_date')->nullable();
|
||||
|
||||
$table->boolean('allow_billplz')->default(true);
|
||||
$table->boolean('allow_salary_deduction')->default(false);
|
||||
|
||||
$table->string('billplz_collection_id')->nullable();
|
||||
|
||||
$table->foreignUuid('created_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->foreignUuid('updated_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('investment_types');
|
||||
}
|
||||
};
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('investment_accounts', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
|
||||
$table->foreignUuid('user_id')->constrained('users')->restrictOnDelete();
|
||||
$table->foreignUuid('investment_type_id')->constrained('investment_types')->restrictOnDelete();
|
||||
|
||||
$table->decimal('current_balance', 15, 2)->default(0);
|
||||
$table->decimal('pending_balance', 15, 2)->default(0);
|
||||
|
||||
$table->string('status')->default('ACTIVE');
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['user_id', 'investment_type_id']);
|
||||
$table->index('status');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('investment_accounts');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('investments', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
|
||||
$table->string('reference_no')->unique();
|
||||
|
||||
$table->foreignUuid('user_id')->constrained('users')->restrictOnDelete();
|
||||
$table->foreignUuid('investment_account_id')->constrained('investment_accounts')->restrictOnDelete();
|
||||
$table->foreignUuid('investment_type_id')->constrained('investment_types')->restrictOnDelete();
|
||||
|
||||
$table->decimal('amount', 15, 2);
|
||||
|
||||
$table->string('source');
|
||||
$table->string('status');
|
||||
|
||||
$table->text('remarks')->nullable();
|
||||
|
||||
$table->foreignUuid('created_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->foreignUuid('approved_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
|
||||
$table->timestamp('approved_at')->nullable();
|
||||
$table->timestamp('completed_at')->nullable();
|
||||
$table->timestamp('cancelled_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('user_id');
|
||||
$table->index('investment_type_id');
|
||||
$table->index('source');
|
||||
$table->index('status');
|
||||
$table->index(['status', 'investment_type_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('investments');
|
||||
}
|
||||
};
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('investment_ledger', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
|
||||
$table->foreignUuid('investment_id')->nullable()->constrained('investments')->restrictOnDelete();
|
||||
$table->foreignUuid('investment_account_id')->constrained('investment_accounts')->restrictOnDelete();
|
||||
$table->foreignUuid('user_id')->constrained('users')->restrictOnDelete();
|
||||
$table->foreignUuid('investment_type_id')->constrained('investment_types')->restrictOnDelete();
|
||||
|
||||
$table->string('transaction_type');
|
||||
|
||||
$table->decimal('credit', 15, 2)->default(0);
|
||||
$table->decimal('debit', 15, 2)->default(0);
|
||||
$table->decimal('balance_after', 15, 2);
|
||||
|
||||
$table->foreignUuid('created_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
|
||||
$table->index('investment_account_id');
|
||||
$table->index('user_id');
|
||||
$table->index('investment_type_id');
|
||||
$table->index('transaction_type');
|
||||
$table->index('created_at');
|
||||
});
|
||||
|
||||
// One INVESTMENT credit post per investment (idempotency guard).
|
||||
// Other types (ADJUSTMENT / REVERSAL / REFUND) may repeat if needed.
|
||||
DB::statement("
|
||||
CREATE UNIQUE INDEX investment_ledger_investment_investment_unique
|
||||
ON investment_ledger (investment_id)
|
||||
WHERE transaction_type = 'INVESTMENT'
|
||||
AND investment_id IS NOT NULL
|
||||
");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('investment_ledger');
|
||||
}
|
||||
};
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('investment_fund_summary', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
|
||||
$table->foreignUuid('investment_type_id')->unique()->constrained('investment_types')->restrictOnDelete();
|
||||
|
||||
$table->decimal('total_invested', 15, 2)->default(0);
|
||||
$table->decimal('reserved_amount', 15, 2)->default(0);
|
||||
$table->decimal('remaining_capacity', 15, 2)->nullable();
|
||||
|
||||
$table->timestamp('updated_at')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('investment_fund_summary');
|
||||
}
|
||||
};
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('investment_limit_profiles', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
|
||||
$table->string('name')->unique();
|
||||
$table->text('description')->nullable();
|
||||
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('investment_limit_profiles');
|
||||
}
|
||||
};
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('profile_investment_limits', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
|
||||
$table->foreignUuid('profile_id')
|
||||
->constrained('investment_limit_profiles')
|
||||
->cascadeOnDelete();
|
||||
$table->foreignUuid('investment_type_id')
|
||||
->constrained('investment_types')
|
||||
->restrictOnDelete();
|
||||
|
||||
$table->decimal('max_investment_amount', 15, 2)->nullable();
|
||||
$table->decimal('max_transaction_amount', 15, 2)->nullable();
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['profile_id', 'investment_type_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('profile_investment_limits');
|
||||
}
|
||||
};
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('user_limit_profiles', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
|
||||
$table->foreignUuid('user_id')->constrained('users')->cascadeOnDelete();
|
||||
$table->foreignUuid('profile_id')
|
||||
->constrained('investment_limit_profiles')
|
||||
->restrictOnDelete();
|
||||
|
||||
$table->date('effective_date');
|
||||
$table->date('expiry_date')->nullable();
|
||||
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
|
||||
$table->index(['user_id', 'effective_date']);
|
||||
$table->index(['user_id', 'expiry_date']);
|
||||
});
|
||||
|
||||
// At most one currently active profile assignment per user.
|
||||
DB::statement('
|
||||
CREATE UNIQUE INDEX user_limit_profiles_user_active_unique
|
||||
ON user_limit_profiles (user_id)
|
||||
WHERE expiry_date IS NULL
|
||||
');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('user_limit_profiles');
|
||||
}
|
||||
};
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('user_investment_limits', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
|
||||
$table->foreignUuid('user_id')->constrained('users')->cascadeOnDelete();
|
||||
$table->foreignUuid('investment_type_id')
|
||||
->constrained('investment_types')
|
||||
->restrictOnDelete();
|
||||
|
||||
$table->decimal('max_investment_amount', 15, 2)->nullable();
|
||||
$table->decimal('max_transaction_amount', 15, 2)->nullable();
|
||||
|
||||
$table->date('effective_date');
|
||||
$table->date('expiry_date')->nullable();
|
||||
|
||||
$table->foreignUuid('created_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['user_id', 'investment_type_id']);
|
||||
$table->index(['user_id', 'investment_type_id', 'effective_date', 'expiry_date']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('user_investment_limits');
|
||||
}
|
||||
};
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('investment_payments', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
|
||||
$table->foreignUuid('investment_id')
|
||||
->unique()
|
||||
->constrained('investments')
|
||||
->restrictOnDelete();
|
||||
|
||||
$table->string('gateway')->default('BILLPLZ');
|
||||
|
||||
$table->decimal('amount', 15, 2);
|
||||
$table->string('currency', 3)->default('MYR');
|
||||
|
||||
$table->string('status')->default('PENDING');
|
||||
|
||||
$table->string('external_reference')->nullable();
|
||||
$table->text('failure_reason')->nullable();
|
||||
|
||||
$table->string('idempotency_key')->unique();
|
||||
|
||||
$table->text('redirect_url')->nullable();
|
||||
$table->text('callback_url')->nullable();
|
||||
|
||||
$table->timestamp('paid_at')->nullable();
|
||||
$table->timestamp('expired_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('status');
|
||||
$table->index('gateway');
|
||||
$table->index('external_reference');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('investment_payments');
|
||||
}
|
||||
};
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('billplz_transactions', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
|
||||
$table->foreignUuid('payment_id')
|
||||
->constrained('investment_payments')
|
||||
->restrictOnDelete();
|
||||
|
||||
$table->string('bill_id')->unique();
|
||||
$table->string('collection_id')->nullable();
|
||||
|
||||
$table->text('bill_url')->nullable();
|
||||
|
||||
$table->string('reference_1')->nullable();
|
||||
$table->string('reference_2')->nullable();
|
||||
|
||||
$table->decimal('paid_amount', 15, 2)->nullable();
|
||||
$table->string('state')->nullable();
|
||||
$table->string('transaction_status')->default('PENDING');
|
||||
|
||||
$table->timestamp('paid_at')->nullable();
|
||||
|
||||
$table->string('x_signature')->nullable();
|
||||
$table->boolean('signature_verified')->default(false);
|
||||
|
||||
$table->json('callback_payload')->nullable();
|
||||
$table->json('redirect_payload')->nullable();
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('payment_id');
|
||||
$table->index('transaction_status');
|
||||
$table->index('reference_1');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('billplz_transactions');
|
||||
}
|
||||
};
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('salary_contributions', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
|
||||
$table->foreignUuid('user_id')->constrained('users')->restrictOnDelete();
|
||||
$table->foreignUuid('investment_type_id')->constrained('investment_types')->restrictOnDelete();
|
||||
|
||||
$table->decimal('monthly_amount', 15, 2);
|
||||
|
||||
$table->date('start_date');
|
||||
$table->date('end_date')->nullable();
|
||||
|
||||
$table->string('status')->default('ACTIVE');
|
||||
|
||||
$table->foreignUuid('created_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['user_id', 'investment_type_id']);
|
||||
$table->index('status');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('salary_contributions');
|
||||
}
|
||||
};
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('salary_deduction_batches', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
|
||||
$table->string('batch_no')->unique();
|
||||
|
||||
$table->unsignedTinyInteger('month');
|
||||
$table->unsignedSmallInteger('year');
|
||||
|
||||
$table->unsignedInteger('total_records')->default(0);
|
||||
$table->decimal('total_amount', 15, 2)->default(0);
|
||||
|
||||
$table->string('status')->default('UPLOADED');
|
||||
|
||||
$table->foreignUuid('uploaded_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->foreignUuid('verified_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
|
||||
$table->timestamp('uploaded_at')->nullable();
|
||||
$table->timestamp('verified_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['month', 'year']);
|
||||
$table->index('status');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('salary_deduction_batches');
|
||||
}
|
||||
};
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('salary_deduction_batch_details', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
|
||||
$table->foreignUuid('batch_id')
|
||||
->constrained('salary_deduction_batches')
|
||||
->cascadeOnDelete();
|
||||
|
||||
$table->foreignUuid('user_id')->constrained('users')->restrictOnDelete();
|
||||
$table->foreignUuid('salary_contribution_id')
|
||||
->nullable()
|
||||
->constrained('salary_contributions')
|
||||
->nullOnDelete();
|
||||
$table->foreignUuid('investment_type_id')->constrained('investment_types')->restrictOnDelete();
|
||||
$table->foreignUuid('investment_id')
|
||||
->nullable()
|
||||
->constrained('investments')
|
||||
->nullOnDelete();
|
||||
|
||||
$table->decimal('amount', 15, 2);
|
||||
|
||||
$table->string('status')->default('PENDING');
|
||||
$table->text('remarks')->nullable();
|
||||
|
||||
$table->foreignUuid('verified_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->timestamp('verified_at')->nullable();
|
||||
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
|
||||
$table->index(['batch_id', 'status']);
|
||||
$table->index(['user_id', 'investment_type_id']);
|
||||
$table->index('investment_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('salary_deduction_batch_details');
|
||||
}
|
||||
};
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('salary_verification_logs', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
|
||||
$table->foreignUuid('batch_detail_id')
|
||||
->constrained('salary_deduction_batch_details')
|
||||
->cascadeOnDelete();
|
||||
|
||||
$table->string('old_status')->nullable();
|
||||
$table->string('new_status');
|
||||
|
||||
$table->foreignUuid('action_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
|
||||
$table->text('remarks')->nullable();
|
||||
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
|
||||
$table->index('batch_detail_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('salary_verification_logs');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class InvestmentDatabaseSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$this->call([
|
||||
InvestmentTypePermissionSeeder::class,
|
||||
InvestmentPermissionSeeder::class,
|
||||
InvestmentLimitPermissionSeeder::class,
|
||||
SalaryDeductionPermissionSeeder::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Database\Seeders;
|
||||
|
||||
use App\Models\Permission;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class InvestmentLimitPermissionSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$permissions = [
|
||||
'lihat had pelaburan',
|
||||
'tambah had pelaburan',
|
||||
'kemaskini had pelaburan',
|
||||
'hapus had pelaburan',
|
||||
];
|
||||
|
||||
foreach ($permissions as $name) {
|
||||
Permission::firstOrCreate(
|
||||
['name' => $name, 'guard_name' => 'api'],
|
||||
['name' => $name, 'guard_name' => 'api']
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Database\Seeders;
|
||||
|
||||
use App\Models\Permission;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class InvestmentPermissionSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$permissions = [
|
||||
'lihat pelaburan',
|
||||
'tambah pelaburan manual',
|
||||
'tambah pelaburan billplz',
|
||||
];
|
||||
|
||||
foreach ($permissions as $name) {
|
||||
Permission::firstOrCreate(
|
||||
['name' => $name, 'guard_name' => 'api'],
|
||||
['name' => $name, 'guard_name' => 'api']
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Database\Seeders;
|
||||
|
||||
use App\Models\Permission;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class InvestmentTypePermissionSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$permissions = [
|
||||
'lihat jenis pelaburan',
|
||||
'tambah jenis pelaburan',
|
||||
'kemaskini jenis pelaburan',
|
||||
'hapus jenis pelaburan',
|
||||
];
|
||||
|
||||
foreach ($permissions as $name) {
|
||||
Permission::firstOrCreate(
|
||||
['name' => $name, 'guard_name' => 'api'],
|
||||
['name' => $name, 'guard_name' => 'api']
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Database\Seeders;
|
||||
|
||||
use App\Models\Permission;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class SalaryDeductionPermissionSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$permissions = [
|
||||
'lihat potongan gaji',
|
||||
'tambah potongan gaji',
|
||||
'kemaskini potongan gaji',
|
||||
'hapus potongan gaji',
|
||||
'sahkan potongan gaji',
|
||||
];
|
||||
|
||||
foreach ($permissions as $name) {
|
||||
Permission::firstOrCreate(
|
||||
['name' => $name, 'guard_name' => 'api'],
|
||||
['name' => $name, 'guard_name' => 'api']
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Entities;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Modules\Investment\Enums\BillplzTransactionStatus;
|
||||
|
||||
class BillplzTransaction extends Model
|
||||
{
|
||||
use HasUuids;
|
||||
|
||||
protected $table = 'billplz_transactions';
|
||||
|
||||
protected $fillable = [
|
||||
'payment_id',
|
||||
'bill_id',
|
||||
'collection_id',
|
||||
'bill_url',
|
||||
'reference_1',
|
||||
'reference_2',
|
||||
'paid_amount',
|
||||
'state',
|
||||
'transaction_status',
|
||||
'paid_at',
|
||||
'x_signature',
|
||||
'signature_verified',
|
||||
'callback_payload',
|
||||
'redirect_payload',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'paid_amount' => 'decimal:2',
|
||||
'transaction_status' => BillplzTransactionStatus::class,
|
||||
'paid_at' => 'datetime',
|
||||
'signature_verified' => 'boolean',
|
||||
'callback_payload' => 'array',
|
||||
'redirect_payload' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function payment(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InvestmentPayment::class, 'payment_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Entities;
|
||||
|
||||
use App\Traits\HasDocuments;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Modules\Auth\Entities\User;
|
||||
use Modules\Investment\Enums\InvestmentSource;
|
||||
use Modules\Investment\Enums\InvestmentStatus;
|
||||
|
||||
class Investment extends Model
|
||||
{
|
||||
use HasDocuments, HasUuids;
|
||||
|
||||
protected $table = 'investments';
|
||||
|
||||
protected $fillable = [
|
||||
'reference_no',
|
||||
'user_id',
|
||||
'investment_account_id',
|
||||
'investment_type_id',
|
||||
'amount',
|
||||
'source',
|
||||
'status',
|
||||
'remarks',
|
||||
'created_by',
|
||||
'approved_by',
|
||||
'approved_at',
|
||||
'completed_at',
|
||||
'cancelled_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'amount' => 'decimal:2',
|
||||
'source' => InvestmentSource::class,
|
||||
'status' => InvestmentStatus::class,
|
||||
'approved_at' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
'cancelled_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
public function account(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InvestmentAccount::class, 'investment_account_id');
|
||||
}
|
||||
|
||||
public function investmentType(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InvestmentType::class, 'investment_type_id');
|
||||
}
|
||||
|
||||
public function createdBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function approvedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'approved_by');
|
||||
}
|
||||
|
||||
public function ledgerEntries(): HasMany
|
||||
{
|
||||
return $this->hasMany(InvestmentLedger::class, 'investment_id');
|
||||
}
|
||||
|
||||
public function payment(): HasOne
|
||||
{
|
||||
return $this->hasOne(InvestmentPayment::class, 'investment_id');
|
||||
}
|
||||
|
||||
public function salaryDeductionDetail(): HasOne
|
||||
{
|
||||
return $this->hasOne(SalaryDeductionBatchDetail::class, 'investment_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Entities;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Modules\Auth\Entities\User;
|
||||
use Modules\Investment\Enums\InvestmentAccountStatus;
|
||||
|
||||
class InvestmentAccount extends Model
|
||||
{
|
||||
use HasUuids;
|
||||
|
||||
protected $table = 'investment_accounts';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'investment_type_id',
|
||||
'current_balance',
|
||||
'pending_balance',
|
||||
'status',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'status' => InvestmentAccountStatus::class,
|
||||
'current_balance' => 'decimal:2',
|
||||
'pending_balance' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
public function investmentType(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InvestmentType::class, 'investment_type_id');
|
||||
}
|
||||
|
||||
public function investments(): HasMany
|
||||
{
|
||||
return $this->hasMany(Investment::class, 'investment_account_id');
|
||||
}
|
||||
|
||||
public function ledgerEntries(): HasMany
|
||||
{
|
||||
return $this->hasMany(InvestmentLedger::class, 'investment_account_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Entities;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class InvestmentFundSummary extends Model
|
||||
{
|
||||
use HasUuids;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $table = 'investment_fund_summary';
|
||||
|
||||
protected $fillable = [
|
||||
'investment_type_id',
|
||||
'total_invested',
|
||||
'reserved_amount',
|
||||
'remaining_capacity',
|
||||
'updated_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'total_invested' => 'decimal:2',
|
||||
'reserved_amount' => 'decimal:2',
|
||||
'remaining_capacity' => 'decimal:2',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function investmentType(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InvestmentType::class, 'investment_type_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Entities;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Modules\Auth\Entities\User;
|
||||
use Modules\Investment\Enums\InvestmentLedgerTransactionType;
|
||||
|
||||
class InvestmentLedger extends Model
|
||||
{
|
||||
use HasUuids;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $table = 'investment_ledger';
|
||||
|
||||
protected $fillable = [
|
||||
'investment_id',
|
||||
'investment_account_id',
|
||||
'user_id',
|
||||
'investment_type_id',
|
||||
'transaction_type',
|
||||
'credit',
|
||||
'debit',
|
||||
'balance_after',
|
||||
'created_by',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'transaction_type' => InvestmentLedgerTransactionType::class,
|
||||
'credit' => 'decimal:2',
|
||||
'debit' => 'decimal:2',
|
||||
'balance_after' => 'decimal:2',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function investment(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Investment::class, 'investment_id');
|
||||
}
|
||||
|
||||
public function account(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InvestmentAccount::class, 'investment_account_id');
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
public function investmentType(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InvestmentType::class, 'investment_type_id');
|
||||
}
|
||||
|
||||
public function createdBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Entities;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class InvestmentLimitProfile extends Model
|
||||
{
|
||||
use HasUuids;
|
||||
|
||||
protected $table = 'investment_limit_profiles';
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'description',
|
||||
];
|
||||
|
||||
public function profileLimits(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProfileInvestmentLimit::class, 'profile_id');
|
||||
}
|
||||
|
||||
public function userAssignments(): HasMany
|
||||
{
|
||||
return $this->hasMany(UserLimitProfile::class, 'profile_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Entities;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Modules\Investment\Enums\InvestmentPaymentGateway;
|
||||
use Modules\Investment\Enums\InvestmentPaymentStatus;
|
||||
|
||||
class InvestmentPayment extends Model
|
||||
{
|
||||
use HasUuids;
|
||||
|
||||
protected $table = 'investment_payments';
|
||||
|
||||
protected $fillable = [
|
||||
'investment_id',
|
||||
'gateway',
|
||||
'amount',
|
||||
'currency',
|
||||
'status',
|
||||
'external_reference',
|
||||
'failure_reason',
|
||||
'idempotency_key',
|
||||
'redirect_url',
|
||||
'callback_url',
|
||||
'paid_at',
|
||||
'expired_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'gateway' => InvestmentPaymentGateway::class,
|
||||
'status' => InvestmentPaymentStatus::class,
|
||||
'amount' => 'decimal:2',
|
||||
'paid_at' => 'datetime',
|
||||
'expired_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function investment(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Investment::class, 'investment_id');
|
||||
}
|
||||
|
||||
public function billplzTransaction(): HasOne
|
||||
{
|
||||
return $this->hasOne(BillplzTransaction::class, 'payment_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Entities;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Modules\Auth\Entities\User;
|
||||
use Modules\Investment\Enums\InvestmentTypeStatus;
|
||||
|
||||
class InvestmentType extends Model
|
||||
{
|
||||
use HasUuids, SoftDeletes;
|
||||
|
||||
protected $table = 'investment_types';
|
||||
|
||||
protected $fillable = [
|
||||
'code',
|
||||
'name',
|
||||
'description',
|
||||
'status',
|
||||
'minimum_amount',
|
||||
'maximum_amount',
|
||||
'total_fund_limit',
|
||||
'start_date',
|
||||
'end_date',
|
||||
'allow_billplz',
|
||||
'allow_salary_deduction',
|
||||
'billplz_collection_id',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'status' => InvestmentTypeStatus::class,
|
||||
'minimum_amount' => 'decimal:2',
|
||||
'maximum_amount' => 'decimal:2',
|
||||
'total_fund_limit' => 'decimal:2',
|
||||
'reserved_amount' => 'decimal:2',
|
||||
'start_date' => 'date',
|
||||
'end_date' => 'date',
|
||||
'allow_billplz' => 'boolean',
|
||||
'allow_salary_deduction' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function createdBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function updatedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'updated_by');
|
||||
}
|
||||
|
||||
public function accounts(): HasMany
|
||||
{
|
||||
return $this->hasMany(InvestmentAccount::class, 'investment_type_id');
|
||||
}
|
||||
|
||||
public function investments(): HasMany
|
||||
{
|
||||
return $this->hasMany(Investment::class, 'investment_type_id');
|
||||
}
|
||||
|
||||
public function ledgerEntries(): HasMany
|
||||
{
|
||||
return $this->hasMany(InvestmentLedger::class, 'investment_type_id');
|
||||
}
|
||||
|
||||
public function fundSummary(): HasOne
|
||||
{
|
||||
return $this->hasOne(InvestmentFundSummary::class, 'investment_type_id');
|
||||
}
|
||||
|
||||
public function profileInvestmentLimits(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProfileInvestmentLimit::class, 'investment_type_id');
|
||||
}
|
||||
|
||||
public function userInvestmentLimits(): HasMany
|
||||
{
|
||||
return $this->hasMany(UserInvestmentLimit::class, 'investment_type_id');
|
||||
}
|
||||
|
||||
public function salaryContributions(): HasMany
|
||||
{
|
||||
return $this->hasMany(SalaryContribution::class, 'investment_type_id');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Entities;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ProfileInvestmentLimit extends Model
|
||||
{
|
||||
use HasUuids;
|
||||
|
||||
protected $table = 'profile_investment_limits';
|
||||
|
||||
protected $fillable = [
|
||||
'profile_id',
|
||||
'investment_type_id',
|
||||
'max_investment_amount',
|
||||
'max_transaction_amount',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'max_investment_amount' => 'decimal:2',
|
||||
'max_transaction_amount' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
|
||||
public function profile(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InvestmentLimitProfile::class, 'profile_id');
|
||||
}
|
||||
|
||||
public function investmentType(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InvestmentType::class, 'investment_type_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Entities;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Modules\Auth\Entities\User;
|
||||
use Modules\Investment\Enums\SalaryContributionStatus;
|
||||
|
||||
class SalaryContribution extends Model
|
||||
{
|
||||
use HasUuids;
|
||||
|
||||
protected $table = 'salary_contributions';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'investment_type_id',
|
||||
'monthly_amount',
|
||||
'start_date',
|
||||
'end_date',
|
||||
'status',
|
||||
'created_by',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'monthly_amount' => 'decimal:2',
|
||||
'start_date' => 'date',
|
||||
'end_date' => 'date',
|
||||
'status' => SalaryContributionStatus::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
public function investmentType(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InvestmentType::class, 'investment_type_id');
|
||||
}
|
||||
|
||||
public function createdBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function batchDetails(): HasMany
|
||||
{
|
||||
return $this->hasMany(SalaryDeductionBatchDetail::class, 'salary_contribution_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Entities;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Modules\Auth\Entities\User;
|
||||
use Modules\Investment\Enums\SalaryDeductionBatchStatus;
|
||||
|
||||
class SalaryDeductionBatch extends Model
|
||||
{
|
||||
use HasUuids;
|
||||
|
||||
protected $table = 'salary_deduction_batches';
|
||||
|
||||
protected $fillable = [
|
||||
'batch_no',
|
||||
'month',
|
||||
'year',
|
||||
'total_records',
|
||||
'total_amount',
|
||||
'status',
|
||||
'uploaded_by',
|
||||
'verified_by',
|
||||
'uploaded_at',
|
||||
'verified_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'month' => 'integer',
|
||||
'year' => 'integer',
|
||||
'total_records' => 'integer',
|
||||
'total_amount' => 'decimal:2',
|
||||
'status' => SalaryDeductionBatchStatus::class,
|
||||
'uploaded_at' => 'datetime',
|
||||
'verified_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function details(): HasMany
|
||||
{
|
||||
return $this->hasMany(SalaryDeductionBatchDetail::class, 'batch_id');
|
||||
}
|
||||
|
||||
public function uploadedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'uploaded_by');
|
||||
}
|
||||
|
||||
public function verifiedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'verified_by');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Entities;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Modules\Auth\Entities\User;
|
||||
use Modules\Investment\Enums\SalaryDeductionDetailStatus;
|
||||
|
||||
class SalaryDeductionBatchDetail extends Model
|
||||
{
|
||||
use HasUuids;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $table = 'salary_deduction_batch_details';
|
||||
|
||||
protected $fillable = [
|
||||
'batch_id',
|
||||
'user_id',
|
||||
'salary_contribution_id',
|
||||
'investment_type_id',
|
||||
'investment_id',
|
||||
'amount',
|
||||
'status',
|
||||
'remarks',
|
||||
'verified_by',
|
||||
'verified_at',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'amount' => 'decimal:2',
|
||||
'status' => SalaryDeductionDetailStatus::class,
|
||||
'verified_at' => 'datetime',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function batch(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SalaryDeductionBatch::class, 'batch_id');
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
public function salaryContribution(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SalaryContribution::class, 'salary_contribution_id');
|
||||
}
|
||||
|
||||
public function investmentType(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InvestmentType::class, 'investment_type_id');
|
||||
}
|
||||
|
||||
public function investment(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Investment::class, 'investment_id');
|
||||
}
|
||||
|
||||
public function verifiedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'verified_by');
|
||||
}
|
||||
|
||||
public function verificationLogs(): HasMany
|
||||
{
|
||||
return $this->hasMany(SalaryVerificationLog::class, 'batch_detail_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Entities;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Modules\Auth\Entities\User;
|
||||
|
||||
class SalaryVerificationLog extends Model
|
||||
{
|
||||
use HasUuids;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $table = 'salary_verification_logs';
|
||||
|
||||
protected $fillable = [
|
||||
'batch_detail_id',
|
||||
'old_status',
|
||||
'new_status',
|
||||
'action_by',
|
||||
'remarks',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function batchDetail(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(SalaryDeductionBatchDetail::class, 'batch_detail_id');
|
||||
}
|
||||
|
||||
public function actionBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'action_by');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Entities;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Modules\Auth\Entities\User;
|
||||
|
||||
class UserInvestmentLimit extends Model
|
||||
{
|
||||
use HasUuids;
|
||||
|
||||
protected $table = 'user_investment_limits';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'investment_type_id',
|
||||
'max_investment_amount',
|
||||
'max_transaction_amount',
|
||||
'effective_date',
|
||||
'expiry_date',
|
||||
'created_by',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'max_investment_amount' => 'decimal:2',
|
||||
'max_transaction_amount' => 'decimal:2',
|
||||
'effective_date' => 'date',
|
||||
'expiry_date' => 'date',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
public function investmentType(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InvestmentType::class, 'investment_type_id');
|
||||
}
|
||||
|
||||
public function createdBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function scopeActive(Builder $query, $onDate = null): Builder
|
||||
{
|
||||
$date = ($onDate ?? now())->toDateString();
|
||||
|
||||
return $query
|
||||
->whereDate('effective_date', '<=', $date)
|
||||
->where(function (Builder $builder) use ($date) {
|
||||
$builder->whereNull('expiry_date')
|
||||
->orWhereDate('expiry_date', '>=', $date);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Entities;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Modules\Auth\Entities\User;
|
||||
|
||||
class UserLimitProfile extends Model
|
||||
{
|
||||
use HasUuids;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $table = 'user_limit_profiles';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'profile_id',
|
||||
'effective_date',
|
||||
'expiry_date',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'effective_date' => 'date',
|
||||
'expiry_date' => 'date',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
public function profile(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InvestmentLimitProfile::class, 'profile_id');
|
||||
}
|
||||
|
||||
public function scopeActive(Builder $query, $onDate = null): Builder
|
||||
{
|
||||
$date = ($onDate ?? now())->toDateString();
|
||||
|
||||
return $query
|
||||
->whereDate('effective_date', '<=', $date)
|
||||
->where(function (Builder $builder) use ($date) {
|
||||
$builder->whereNull('expiry_date')
|
||||
->orWhereDate('expiry_date', '>=', $date);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Enums;
|
||||
|
||||
enum BillplzTransactionStatus: string
|
||||
{
|
||||
case Pending = 'PENDING';
|
||||
case Paid = 'PAID';
|
||||
case Failed = 'FAILED';
|
||||
case Deleted = 'DELETED';
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Enums;
|
||||
|
||||
enum InvestmentAccountStatus: string
|
||||
{
|
||||
case Active = 'ACTIVE';
|
||||
case Frozen = 'FROZEN';
|
||||
case Closed = 'CLOSED';
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Enums;
|
||||
|
||||
enum InvestmentLedgerTransactionType: string
|
||||
{
|
||||
case Investment = 'INVESTMENT';
|
||||
case Refund = 'REFUND';
|
||||
case Adjustment = 'ADJUSTMENT';
|
||||
case Reversal = 'REVERSAL';
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Enums;
|
||||
|
||||
enum InvestmentPaymentGateway: string
|
||||
{
|
||||
case Billplz = 'BILLPLZ';
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Enums;
|
||||
|
||||
enum InvestmentPaymentStatus: string
|
||||
{
|
||||
case Pending = 'PENDING';
|
||||
case Paid = 'PAID';
|
||||
case Failed = 'FAILED';
|
||||
case Cancelled = 'CANCELLED';
|
||||
case Expired = 'EXPIRED';
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Enums;
|
||||
|
||||
enum InvestmentSource: string
|
||||
{
|
||||
case Billplz = 'BILLPLZ';
|
||||
case SalaryDeduction = 'SALARY_DEDUCTION';
|
||||
case Manual = 'MANUAL';
|
||||
case Adjustment = 'ADJUSTMENT';
|
||||
case Refund = 'REFUND';
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Enums;
|
||||
|
||||
enum InvestmentStatus: string
|
||||
{
|
||||
case Draft = 'DRAFT';
|
||||
case PendingPayment = 'PENDING_PAYMENT';
|
||||
case PendingVerification = 'PENDING_VERIFICATION';
|
||||
case Completed = 'COMPLETED';
|
||||
case Rejected = 'REJECTED';
|
||||
case Cancelled = 'CANCELLED';
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Enums;
|
||||
|
||||
enum InvestmentTypeStatus: string
|
||||
{
|
||||
case Active = 'ACTIVE';
|
||||
case Inactive = 'INACTIVE';
|
||||
case Closed = 'CLOSED';
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Enums;
|
||||
|
||||
enum SalaryContributionStatus: string
|
||||
{
|
||||
case Active = 'ACTIVE';
|
||||
case Paused = 'PAUSED';
|
||||
case Ended = 'ENDED';
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Enums;
|
||||
|
||||
enum SalaryDeductionBatchStatus: string
|
||||
{
|
||||
case Uploaded = 'UPLOADED';
|
||||
case UnderReview = 'UNDER_REVIEW';
|
||||
case Verified = 'VERIFIED';
|
||||
case Rejected = 'REJECTED';
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Enums;
|
||||
|
||||
enum SalaryDeductionDetailStatus: string
|
||||
{
|
||||
case Pending = 'PENDING';
|
||||
case Verified = 'VERIFIED';
|
||||
case Rejected = 'REJECTED';
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Investment\Entities\Investment;
|
||||
use Modules\Investment\Http\Requests\BillplzInvestmentRequest;
|
||||
use Modules\Investment\Services\BillplzInvestmentService;
|
||||
use Modules\Investment\Transformers\InvestmentResource;
|
||||
|
||||
class BillplzInvestmentController extends Controller
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public function __construct(
|
||||
protected BillplzInvestmentService $billplzInvestmentService,
|
||||
) {}
|
||||
|
||||
public function store(BillplzInvestmentRequest $request): JsonResponse
|
||||
{
|
||||
$this->authorize('createBillplz', Investment::class);
|
||||
|
||||
$validated = $request->validated();
|
||||
$result = $this->billplzInvestmentService->initiate(
|
||||
$request->user()->id,
|
||||
$validated['investment_type_id'],
|
||||
$validated['amount'],
|
||||
$validated['remarks'] ?? null,
|
||||
$request->user(),
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'investment' => new InvestmentResource($result['investment']),
|
||||
'bill_url' => $result['bill_url'],
|
||||
],
|
||||
'message' => 'Bil Billplz berjaya dijana',
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function callback(Request $request): JsonResponse
|
||||
{
|
||||
$investment = $this->billplzInvestmentService->handleCallback($request->all());
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'investment_id' => $investment->id,
|
||||
'status' => $investment->status?->value ?? $investment->status,
|
||||
],
|
||||
'message' => 'Callback Billplz diterima',
|
||||
]);
|
||||
}
|
||||
|
||||
public function status(string $id): JsonResponse
|
||||
{
|
||||
$this->authorize('view', Investment::class);
|
||||
|
||||
$investment = $this->billplzInvestmentService->syncFromGateway($id);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => new InvestmentResource(
|
||||
$investment->loadMissing(['account', 'investmentType', 'payment.billplzTransaction', 'ledgerEntries'])
|
||||
),
|
||||
'message' => 'Status pelaburan berjaya dimuatkan',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\DocumentService;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Modules\Investment\Entities\Investment;
|
||||
use Modules\Investment\Http\Requests\ManualInvestmentRequest;
|
||||
use Modules\Investment\Services\InvestmentPostingService;
|
||||
use Modules\Investment\Services\InvestmentSummaryService;
|
||||
use Modules\Investment\Transformers\InvestmentResource;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class InvestmentController extends Controller
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public function __construct(
|
||||
protected InvestmentPostingService $postingService,
|
||||
protected InvestmentSummaryService $summaryService,
|
||||
protected DocumentService $documentService,
|
||||
) {}
|
||||
|
||||
public function summary(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('viewOwnSummary', Investment::class);
|
||||
|
||||
$summary = $this->summaryService->getMemberSummary($request->user()->id);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $summary,
|
||||
'message' => 'Ringkasan pelaburan berjaya dimuatkan',
|
||||
]);
|
||||
}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('viewAny', Investment::class);
|
||||
|
||||
$perPage = min((int) $request->get('per_page', 10), 1000);
|
||||
$sortBy = (string) $request->get('sort_by', 'created_at');
|
||||
$sortOrder = strtolower((string) $request->get('sort_order', 'desc')) === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
$allowedSort = [
|
||||
'created_at',
|
||||
'amount',
|
||||
'status',
|
||||
'source',
|
||||
'reference_no',
|
||||
'completed_at',
|
||||
];
|
||||
if (! in_array($sortBy, $allowedSort, true)) {
|
||||
$sortBy = 'created_at';
|
||||
}
|
||||
|
||||
$query = Investment::query()
|
||||
->with([
|
||||
'user:id,name,email,ic_number,member_number',
|
||||
'investmentType:id,code,name',
|
||||
])
|
||||
->orderBy($sortBy, $sortOrder);
|
||||
|
||||
if ($search = trim((string) $request->get('search', ''))) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('reference_no', 'ILIKE', "%{$search}%")
|
||||
->orWhere('remarks', 'ILIKE', "%{$search}%")
|
||||
->orWhereHas('user', function ($userQuery) use ($search) {
|
||||
$userQuery->where('name', 'ILIKE', "%{$search}%")
|
||||
->orWhere('email', 'ILIKE', "%{$search}%")
|
||||
->orWhere('ic_number', 'ILIKE', "%{$search}%");
|
||||
})
|
||||
->orWhereHas('investmentType', function ($typeQuery) use ($search) {
|
||||
$typeQuery->where('name', 'ILIKE', "%{$search}%")
|
||||
->orWhere('code', 'ILIKE', "%{$search}%");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if ($status = $request->query('status')) {
|
||||
$query->where('status', $status);
|
||||
}
|
||||
|
||||
if ($source = $request->query('source')) {
|
||||
$query->where('source', $source);
|
||||
}
|
||||
|
||||
if ($userId = $request->query('user_id')) {
|
||||
$query->where('user_id', $userId);
|
||||
}
|
||||
|
||||
if ($typeId = $request->query('investment_type_id')) {
|
||||
$query->where('investment_type_id', $typeId);
|
||||
}
|
||||
|
||||
$investments = $query->paginate($perPage);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => InvestmentResource::collection($investments->items()),
|
||||
'pagination' => [
|
||||
'current_page' => $investments->currentPage(),
|
||||
'per_page' => $investments->perPage(),
|
||||
'total' => $investments->total(),
|
||||
'last_page' => $investments->lastPage(),
|
||||
'from' => $investments->firstItem(),
|
||||
'to' => $investments->lastItem(),
|
||||
'has_more_pages' => $investments->hasMorePages(),
|
||||
],
|
||||
'message' => 'Pelaburan berjaya dimuatkan',
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(string $id): JsonResponse
|
||||
{
|
||||
$this->authorize('view', Investment::class);
|
||||
|
||||
$investment = Investment::query()
|
||||
->with([
|
||||
'user:id,name,email,ic_number,member_number',
|
||||
'account',
|
||||
'investmentType',
|
||||
'createdBy:id,name,email',
|
||||
'approvedBy:id,name,email',
|
||||
'ledgerEntries',
|
||||
'payment.billplzTransaction',
|
||||
'documents',
|
||||
])
|
||||
->find($id);
|
||||
|
||||
if (! $investment) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Pelaburan tidak dijumpai.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => new InvestmentResource($investment),
|
||||
'message' => 'Pelaburan berjaya dimuatkan',
|
||||
]);
|
||||
}
|
||||
|
||||
public function storeManual(ManualInvestmentRequest $request): JsonResponse
|
||||
{
|
||||
$this->authorize('createManual', Investment::class);
|
||||
|
||||
$validated = $request->validated();
|
||||
$documents = collect($request->file('documents', []))
|
||||
->filter(fn ($file) => $file instanceof UploadedFile)
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$investment = $this->postingService->postManualInvestment(
|
||||
$validated['user_id'],
|
||||
$validated['investment_type_id'],
|
||||
$validated['amount'],
|
||||
$validated['remarks'] ?? null,
|
||||
$request->user(),
|
||||
);
|
||||
|
||||
foreach ($documents as $file) {
|
||||
$this->documentService->validateFile($file);
|
||||
$this->documentService->uploadDocument($investment, $file, 'attachment');
|
||||
}
|
||||
|
||||
$investment->loadMissing([
|
||||
'user:id,name,email,ic_number,member_number',
|
||||
'investmentType:id,code,name',
|
||||
'account',
|
||||
'documents',
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => new InvestmentResource($investment),
|
||||
'message' => 'Pelaburan manual berjaya direkodkan',
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function downloadDocument(string $id, string $documentId): StreamedResponse|JsonResponse
|
||||
{
|
||||
$this->authorize('view', Investment::class);
|
||||
|
||||
$investment = Investment::query()->find($id);
|
||||
|
||||
if (! $investment) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Pelaburan tidak dijumpai.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$document = $investment->documents()->find($documentId);
|
||||
|
||||
if (! $document) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Dokumen tidak dijumpai.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return $this->documentService->downloadDocument($document->id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\BaseCrudController;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Modules\Investment\Entities\InvestmentLimitProfile;
|
||||
use Modules\Investment\Http\Requests\InvestmentLimitProfileRequest;
|
||||
use Modules\Investment\Repositories\Contracts\InvestmentLimitProfileRepositoryInterface;
|
||||
use Modules\Investment\Transformers\InvestmentLimitProfileResource;
|
||||
|
||||
class InvestmentLimitProfileController extends BaseCrudController
|
||||
{
|
||||
protected $modelClass = InvestmentLimitProfile::class;
|
||||
|
||||
protected $resourceClass = InvestmentLimitProfileResource::class;
|
||||
|
||||
protected $requestClass = InvestmentLimitProfileRequest::class;
|
||||
|
||||
protected $resourceName = 'profil had pelaburan';
|
||||
|
||||
protected $resourceNamePlural = 'profil had pelaburan';
|
||||
|
||||
public function __construct(
|
||||
InvestmentLimitProfileRepositoryInterface $repository,
|
||||
) {
|
||||
parent::__construct($repository);
|
||||
}
|
||||
|
||||
protected function loadShowRelations($item)
|
||||
{
|
||||
return $item->load(['profileLimits.investmentType:id,code,name']);
|
||||
}
|
||||
|
||||
protected function getItemName($item): string
|
||||
{
|
||||
return $item->name ?? $item->id;
|
||||
}
|
||||
|
||||
protected function checkDependencies($item): ?JsonResponse
|
||||
{
|
||||
if ($item->userAssignments()->whereNull('expiry_date')->exists()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Profil had tidak boleh dipadam kerana masih ditugaskan kepada ahli.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\BaseCrudController;
|
||||
use Modules\Investment\Entities\InvestmentType;
|
||||
use Modules\Investment\Http\Requests\InvestmentTypeRequest;
|
||||
use Modules\Investment\Repositories\Contracts\InvestmentTypeRepositoryInterface;
|
||||
use Modules\Investment\Transformers\InvestmentTypeResource;
|
||||
|
||||
class InvestmentTypeController extends BaseCrudController
|
||||
{
|
||||
protected $modelClass = InvestmentType::class;
|
||||
|
||||
protected $resourceClass = InvestmentTypeResource::class;
|
||||
|
||||
protected $requestClass = InvestmentTypeRequest::class;
|
||||
|
||||
protected $resourceName = 'jenis pelaburan';
|
||||
|
||||
protected $resourceNamePlural = 'jenis pelaburan';
|
||||
|
||||
public function __construct(
|
||||
InvestmentTypeRepositoryInterface $repository,
|
||||
) {
|
||||
parent::__construct($repository);
|
||||
}
|
||||
|
||||
protected function prepareStoreData(array $validated): array
|
||||
{
|
||||
$validated['created_by'] = auth()->id();
|
||||
$validated['updated_by'] = auth()->id();
|
||||
|
||||
return $validated;
|
||||
}
|
||||
|
||||
protected function prepareUpdateData(array $validated, $item): array
|
||||
{
|
||||
$validated['updated_by'] = auth()->id();
|
||||
|
||||
return $validated;
|
||||
}
|
||||
|
||||
protected function getItemName($item): string
|
||||
{
|
||||
return $item->name ?? $item->id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Modules\Investment\Entities\InvestmentLimitProfile;
|
||||
use Modules\Investment\Entities\ProfileInvestmentLimit;
|
||||
use Modules\Investment\Http\Requests\ProfileInvestmentLimitRequest;
|
||||
use Modules\Investment\Transformers\ProfileInvestmentLimitResource;
|
||||
|
||||
class ProfileInvestmentLimitController extends Controller
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public function index(string $investmentLimitProfile): JsonResponse
|
||||
{
|
||||
$this->authorize('viewAny', InvestmentLimitProfile::class);
|
||||
|
||||
$profile = InvestmentLimitProfile::query()->find($investmentLimitProfile);
|
||||
|
||||
if (! $profile) {
|
||||
return $this->notFound();
|
||||
}
|
||||
|
||||
$limits = $profile->profileLimits()
|
||||
->with('investmentType:id,code,name')
|
||||
->orderBy('created_at')
|
||||
->get();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => ProfileInvestmentLimitResource::collection($limits),
|
||||
'message' => 'Had profil berjaya dimuatkan',
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(ProfileInvestmentLimitRequest $request, string $investmentLimitProfile): JsonResponse
|
||||
{
|
||||
$this->authorize('create', InvestmentLimitProfile::class);
|
||||
|
||||
$profile = InvestmentLimitProfile::query()->find($investmentLimitProfile);
|
||||
|
||||
if (! $profile) {
|
||||
return $this->notFound();
|
||||
}
|
||||
|
||||
$limit = $profile->profileLimits()->create($request->validated());
|
||||
$limit->load('investmentType:id,code,name');
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => new ProfileInvestmentLimitResource($limit),
|
||||
'message' => 'Had profil berjaya ditambah',
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function update(
|
||||
ProfileInvestmentLimitRequest $request,
|
||||
string $investmentLimitProfile,
|
||||
string $limit,
|
||||
): JsonResponse {
|
||||
$this->authorize('update', InvestmentLimitProfile::class);
|
||||
|
||||
$item = ProfileInvestmentLimit::query()
|
||||
->where('profile_id', $investmentLimitProfile)
|
||||
->whereKey($limit)
|
||||
->first();
|
||||
|
||||
if (! $item) {
|
||||
return $this->notFound('Had profil tidak dijumpai.');
|
||||
}
|
||||
|
||||
$item->update($request->validated());
|
||||
$item->load('investmentType:id,code,name');
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => new ProfileInvestmentLimitResource($item),
|
||||
'message' => 'Had profil berjaya dikemaskini',
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(string $investmentLimitProfile, string $limit): JsonResponse
|
||||
{
|
||||
$this->authorize('delete', InvestmentLimitProfile::class);
|
||||
|
||||
$item = ProfileInvestmentLimit::query()
|
||||
->where('profile_id', $investmentLimitProfile)
|
||||
->whereKey($limit)
|
||||
->first();
|
||||
|
||||
if (! $item) {
|
||||
return $this->notFound('Had profil tidak dijumpai.');
|
||||
}
|
||||
|
||||
$item->delete();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Had profil berjaya dipadam',
|
||||
]);
|
||||
}
|
||||
|
||||
protected function notFound(string $message = 'Profil had tidak dijumpai.'): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $message,
|
||||
], 404);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\BaseCrudController;
|
||||
use Modules\Investment\Entities\SalaryContribution;
|
||||
use Modules\Investment\Http\Requests\SalaryContributionRequest;
|
||||
use Modules\Investment\Repositories\Contracts\SalaryContributionRepositoryInterface;
|
||||
use Modules\Investment\Transformers\SalaryContributionResource;
|
||||
|
||||
class SalaryContributionController extends BaseCrudController
|
||||
{
|
||||
protected $modelClass = SalaryContribution::class;
|
||||
|
||||
protected $resourceClass = SalaryContributionResource::class;
|
||||
|
||||
protected $requestClass = SalaryContributionRequest::class;
|
||||
|
||||
protected $resourceName = 'caruman potongan gaji';
|
||||
|
||||
protected $resourceNamePlural = 'caruman potongan gaji';
|
||||
|
||||
public function __construct(
|
||||
SalaryContributionRepositoryInterface $repository,
|
||||
) {
|
||||
parent::__construct($repository);
|
||||
}
|
||||
|
||||
protected function prepareStoreData(array $validated): array
|
||||
{
|
||||
$validated['created_by'] = auth()->id();
|
||||
|
||||
return $validated;
|
||||
}
|
||||
|
||||
protected function loadShowRelations($item)
|
||||
{
|
||||
return $item->load(['user:id,name,email,ic_number', 'investmentType:id,code,name']);
|
||||
}
|
||||
|
||||
protected function getItemName($item): string
|
||||
{
|
||||
return $item->id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Investment\Entities\SalaryContribution;
|
||||
use Modules\Investment\Entities\SalaryDeductionBatch;
|
||||
use Modules\Investment\Entities\SalaryDeductionBatchDetail;
|
||||
use Modules\Investment\Http\Requests\GenerateSalaryDeductionBatchRequest;
|
||||
use Modules\Investment\Http\Requests\RejectSalaryDeductionRequest;
|
||||
use Modules\Investment\Http\Requests\UploadSalaryDeductionBatchRequest;
|
||||
use Modules\Investment\Http\Requests\VerifySalaryDeductionDetailRequest;
|
||||
use Modules\Investment\Services\SalaryDeductionService;
|
||||
use Modules\Investment\Transformers\SalaryDeductionBatchDetailResource;
|
||||
use Modules\Investment\Transformers\SalaryDeductionBatchResource;
|
||||
|
||||
class SalaryDeductionBatchController extends Controller
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public function __construct(
|
||||
protected SalaryDeductionService $salaryDeductionService,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('viewAny', SalaryContribution::class);
|
||||
|
||||
$query = SalaryDeductionBatch::query()->orderByDesc('year')->orderByDesc('month');
|
||||
|
||||
if ($status = $request->query('status')) {
|
||||
$query->where('status', $status);
|
||||
}
|
||||
|
||||
$batches = $query->paginate(min((int) $request->get('per_page', 10), 1000));
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => SalaryDeductionBatchResource::collection($batches->items()),
|
||||
'pagination' => [
|
||||
'current_page' => $batches->currentPage(),
|
||||
'per_page' => $batches->perPage(),
|
||||
'total' => $batches->total(),
|
||||
'last_page' => $batches->lastPage(),
|
||||
'from' => $batches->firstItem(),
|
||||
'to' => $batches->lastItem(),
|
||||
'has_more_pages' => $batches->hasMorePages(),
|
||||
],
|
||||
'message' => 'Batch potongan gaji berjaya dimuatkan',
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(string $id): JsonResponse
|
||||
{
|
||||
$this->authorize('view', SalaryContribution::class);
|
||||
|
||||
$batch = SalaryDeductionBatch::query()
|
||||
->with([
|
||||
'details.user:id,name,email,ic_number',
|
||||
'details.investmentType:id,code,name',
|
||||
'details.investment:id,reference_no,status,amount',
|
||||
'uploadedBy:id,name,email',
|
||||
'verifiedBy:id,name,email',
|
||||
])
|
||||
->find($id);
|
||||
|
||||
if (! $batch) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Batch potongan gaji tidak dijumpai.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => new SalaryDeductionBatchResource($batch),
|
||||
'message' => 'Batch potongan gaji berjaya dimuatkan',
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(UploadSalaryDeductionBatchRequest $request): JsonResponse
|
||||
{
|
||||
$this->authorize('create', SalaryContribution::class);
|
||||
|
||||
$validated = $request->validated();
|
||||
$batch = $this->salaryDeductionService->uploadBatch(
|
||||
(int) $validated['month'],
|
||||
(int) $validated['year'],
|
||||
$validated['details'],
|
||||
$request->user(),
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => new SalaryDeductionBatchResource($batch),
|
||||
'message' => 'Batch potongan gaji berjaya dimuat naik',
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function generate(GenerateSalaryDeductionBatchRequest $request): JsonResponse
|
||||
{
|
||||
$this->authorize('create', SalaryContribution::class);
|
||||
|
||||
$validated = $request->validated();
|
||||
$batch = $this->salaryDeductionService->generateBatchFromContributions(
|
||||
(int) $validated['month'],
|
||||
(int) $validated['year'],
|
||||
$request->user(),
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => new SalaryDeductionBatchResource($batch),
|
||||
'message' => 'Batch potongan gaji berjaya dijana daripada caruman aktif',
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function markUnderReview(string $id): JsonResponse
|
||||
{
|
||||
$this->authorize('verify', SalaryContribution::class);
|
||||
|
||||
$batch = SalaryDeductionBatch::query()->findOrFail($id);
|
||||
$batch = $this->salaryDeductionService->markUnderReview($batch);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => new SalaryDeductionBatchResource($batch),
|
||||
'message' => 'Batch dihantar untuk semakan',
|
||||
]);
|
||||
}
|
||||
|
||||
public function verifyDetail(
|
||||
VerifySalaryDeductionDetailRequest $request,
|
||||
string $id,
|
||||
string $detailId,
|
||||
): JsonResponse {
|
||||
$this->authorize('verify', SalaryContribution::class);
|
||||
|
||||
$detail = SalaryDeductionBatchDetail::query()
|
||||
->where('batch_id', $id)
|
||||
->whereKey($detailId)
|
||||
->firstOrFail();
|
||||
|
||||
$detail = $this->salaryDeductionService->verifyDetail(
|
||||
$detail,
|
||||
$request->user(),
|
||||
$request->validated('remarks'),
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => new SalaryDeductionBatchDetailResource($detail),
|
||||
'message' => 'Rekod potongan gaji berjaya disahkan',
|
||||
]);
|
||||
}
|
||||
|
||||
public function rejectDetail(
|
||||
RejectSalaryDeductionRequest $request,
|
||||
string $id,
|
||||
string $detailId,
|
||||
): JsonResponse {
|
||||
$this->authorize('verify', SalaryContribution::class);
|
||||
|
||||
$detail = SalaryDeductionBatchDetail::query()
|
||||
->where('batch_id', $id)
|
||||
->whereKey($detailId)
|
||||
->firstOrFail();
|
||||
|
||||
$detail = $this->salaryDeductionService->rejectDetail(
|
||||
$detail,
|
||||
$request->user(),
|
||||
$request->validated('remarks'),
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => new SalaryDeductionBatchDetailResource($detail),
|
||||
'message' => 'Rekod potongan gaji berjaya ditolak',
|
||||
]);
|
||||
}
|
||||
|
||||
public function verifyAll(string $id): JsonResponse
|
||||
{
|
||||
$this->authorize('verify', SalaryContribution::class);
|
||||
|
||||
$batch = SalaryDeductionBatch::query()->findOrFail($id);
|
||||
$batch = $this->salaryDeductionService->verifyAllPending($batch, request()->user());
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => new SalaryDeductionBatchResource($batch),
|
||||
'message' => 'Semua rekod pending berjaya disahkan',
|
||||
]);
|
||||
}
|
||||
|
||||
public function rejectBatch(RejectSalaryDeductionRequest $request, string $id): JsonResponse
|
||||
{
|
||||
$this->authorize('verify', SalaryContribution::class);
|
||||
|
||||
$batch = SalaryDeductionBatch::query()->findOrFail($id);
|
||||
$batch = $this->salaryDeductionService->rejectBatch(
|
||||
$batch,
|
||||
$request->user(),
|
||||
$request->validated('remarks'),
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => new SalaryDeductionBatchResource($batch),
|
||||
'message' => 'Batch potongan gaji berjaya ditolak',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\BaseCrudController;
|
||||
use Modules\Investment\Entities\UserInvestmentLimit;
|
||||
use Modules\Investment\Http\Requests\UserInvestmentLimitRequest;
|
||||
use Modules\Investment\Repositories\Contracts\UserInvestmentLimitRepositoryInterface;
|
||||
use Modules\Investment\Transformers\UserInvestmentLimitResource;
|
||||
|
||||
class UserInvestmentLimitController extends BaseCrudController
|
||||
{
|
||||
protected $modelClass = UserInvestmentLimit::class;
|
||||
|
||||
protected $resourceClass = UserInvestmentLimitResource::class;
|
||||
|
||||
protected $requestClass = UserInvestmentLimitRequest::class;
|
||||
|
||||
protected $resourceName = 'had pelaburan ahli';
|
||||
|
||||
protected $resourceNamePlural = 'had pelaburan ahli';
|
||||
|
||||
public function __construct(
|
||||
UserInvestmentLimitRepositoryInterface $repository,
|
||||
) {
|
||||
parent::__construct($repository);
|
||||
}
|
||||
|
||||
protected function prepareStoreData(array $validated): array
|
||||
{
|
||||
$validated['created_by'] = auth()->id();
|
||||
|
||||
return $validated;
|
||||
}
|
||||
|
||||
protected function getItemName($item): string
|
||||
{
|
||||
return $item->id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Investment\Entities\InvestmentLimitProfile;
|
||||
use Modules\Investment\Entities\UserLimitProfile;
|
||||
use Modules\Investment\Http\Requests\AssignUserLimitProfileRequest;
|
||||
use Modules\Investment\Services\InvestmentLimitService;
|
||||
use Modules\Investment\Transformers\UserLimitProfileResource;
|
||||
|
||||
class UserLimitProfileController extends Controller
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public function __construct(
|
||||
protected InvestmentLimitService $limitService,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('viewAny', InvestmentLimitProfile::class);
|
||||
|
||||
$query = UserLimitProfile::query()
|
||||
->with(['user:id,name,email,ic_number', 'profile'])
|
||||
->orderByDesc('effective_date');
|
||||
|
||||
if ($userId = $request->query('user_id')) {
|
||||
$query->where('user_id', $userId);
|
||||
}
|
||||
|
||||
if ($request->boolean('active_only')) {
|
||||
$query->active();
|
||||
}
|
||||
|
||||
$assignments = $query->paginate(min((int) $request->get('per_page', 10), 1000));
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => UserLimitProfileResource::collection($assignments->items()),
|
||||
'pagination' => [
|
||||
'current_page' => $assignments->currentPage(),
|
||||
'per_page' => $assignments->perPage(),
|
||||
'total' => $assignments->total(),
|
||||
'last_page' => $assignments->lastPage(),
|
||||
'from' => $assignments->firstItem(),
|
||||
'to' => $assignments->lastItem(),
|
||||
'has_more_pages' => $assignments->hasMorePages(),
|
||||
],
|
||||
'message' => 'Tugasan profil had berjaya dimuatkan',
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(AssignUserLimitProfileRequest $request): JsonResponse
|
||||
{
|
||||
$this->authorize('create', InvestmentLimitProfile::class);
|
||||
|
||||
$validated = $request->validated();
|
||||
|
||||
$assignment = $this->limitService->assignProfileToUser(
|
||||
$validated['user_id'],
|
||||
$validated['profile_id'],
|
||||
$validated['effective_date'],
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => new UserLimitProfileResource($assignment),
|
||||
'message' => 'Profil had berjaya ditugaskan kepada ahli',
|
||||
], 201);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class AssignUserLimitProfileRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'user_id' => ['required', 'uuid', 'exists:users,id'],
|
||||
'profile_id' => ['required', 'uuid', 'exists:investment_limit_profiles,id'],
|
||||
'effective_date' => ['required', 'date'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'user_id.required' => 'Ahli diperlukan.',
|
||||
'user_id.exists' => 'Ahli tidak dijumpai.',
|
||||
'profile_id.required' => 'Profil had diperlukan.',
|
||||
'profile_id.exists' => 'Profil had tidak dijumpai.',
|
||||
'effective_date.required' => 'Tarikh berkuat kuasa diperlukan.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class BillplzInvestmentRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'investment_type_id' => ['required', 'uuid', 'exists:investment_types,id'],
|
||||
'amount' => ['required', 'numeric', 'gt:0'],
|
||||
'remarks' => ['nullable', 'string', 'max:1000'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'investment_type_id.required' => 'Jenis pelaburan diperlukan.',
|
||||
'investment_type_id.exists' => 'Jenis pelaburan tidak dijumpai.',
|
||||
'amount.required' => 'Jumlah pelaburan diperlukan.',
|
||||
'amount.gt' => 'Jumlah pelaburan mestilah lebih daripada 0.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class GenerateSalaryDeductionBatchRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'month' => ['required', 'integer', 'min:1', 'max:12'],
|
||||
'year' => ['required', 'integer', 'min:2000', 'max:2100'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class InvestmentLimitProfileRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$isCreate = $this->isMethod('POST');
|
||||
$isUpdate = $this->isMethod('PUT') || $this->isMethod('PATCH');
|
||||
|
||||
if (! $isCreate && ! $isUpdate) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$nameRule = Rule::unique('investment_limit_profiles', 'name');
|
||||
|
||||
if ($isUpdate) {
|
||||
$nameRule = $nameRule->ignore($this->route('investment_limit_profile'));
|
||||
}
|
||||
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:100', $nameRule],
|
||||
'description' => ['nullable', 'string'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'name.required' => 'Nama profil had diperlukan.',
|
||||
'name.unique' => 'Nama profil had sudah wujud.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Modules\Investment\Enums\InvestmentTypeStatus;
|
||||
|
||||
class InvestmentTypeRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$isCreate = $this->isMethod('POST');
|
||||
$isUpdate = $this->isMethod('PUT') || $this->isMethod('PATCH');
|
||||
|
||||
if (! $isCreate && ! $isUpdate) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$codeRule = Rule::unique('investment_types', 'code')->whereNull('deleted_at');
|
||||
|
||||
if ($isUpdate) {
|
||||
$codeRule = $codeRule->ignore($this->route('investment_type'));
|
||||
}
|
||||
|
||||
return [
|
||||
'code' => ['required', 'string', 'max:50', 'alpha_dash', $codeRule],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'status' => ['required', Rule::enum(InvestmentTypeStatus::class)],
|
||||
'minimum_amount' => ['required', 'numeric', 'min:0'],
|
||||
'maximum_amount' => ['nullable', 'numeric', 'gte:minimum_amount'],
|
||||
'total_fund_limit' => ['nullable', 'numeric', 'min:0'],
|
||||
'start_date' => ['nullable', 'date'],
|
||||
'end_date' => ['nullable', 'date', 'after_or_equal:start_date'],
|
||||
'allow_billplz' => ['required', 'boolean'],
|
||||
'allow_salary_deduction' => ['required', 'boolean'],
|
||||
'billplz_collection_id' => ['nullable', 'string', 'max:255'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'code.required' => 'Kod jenis pelaburan diperlukan.',
|
||||
'code.unique' => 'Kod jenis pelaburan sudah wujud.',
|
||||
'code.alpha_dash' => 'Kod jenis pelaburan hanya boleh mengandungi huruf, nombor, sempang dan garis bawah.',
|
||||
'name.required' => 'Nama jenis pelaburan diperlukan.',
|
||||
'status.required' => 'Status jenis pelaburan diperlukan.',
|
||||
'minimum_amount.required' => 'Jumlah minimum diperlukan.',
|
||||
'minimum_amount.min' => 'Jumlah minimum tidak boleh negatif.',
|
||||
'maximum_amount.gte' => 'Jumlah maksimum mestilah sama atau lebih besar daripada jumlah minimum.',
|
||||
'end_date.after_or_equal' => 'Tarikh tamat mestilah pada atau selepas tarikh mula.',
|
||||
'allow_billplz.required' => 'Pilihan Billplz diperlukan.',
|
||||
'allow_salary_deduction.required' => 'Pilihan potongan gaji diperlukan.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ManualInvestmentRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'user_id' => ['required', 'uuid', 'exists:users,id'],
|
||||
'investment_type_id' => ['required', 'uuid', 'exists:investment_types,id'],
|
||||
'amount' => ['required', 'numeric', 'gt:0'],
|
||||
'remarks' => ['nullable', 'string', 'max:1000'],
|
||||
'documents' => ['nullable', 'array'],
|
||||
'documents.*' => ['file', 'mimes:pdf,jpg,jpeg,png,doc,docx', 'max:10240'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'user_id.required' => 'Ahli diperlukan.',
|
||||
'user_id.exists' => 'Ahli tidak dijumpai.',
|
||||
'investment_type_id.required' => 'Jenis pelaburan diperlukan.',
|
||||
'investment_type_id.exists' => 'Jenis pelaburan tidak dijumpai.',
|
||||
'amount.required' => 'Jumlah pelaburan diperlukan.',
|
||||
'amount.gt' => 'Jumlah pelaburan mestilah lebih daripada 0.',
|
||||
'documents.*.file' => 'Dokumen mestilah fail yang sah.',
|
||||
'documents.*.mimes' => 'Format dokumen tidak disokong. Gunakan PDF, JPG, PNG, DOC atau DOCX.',
|
||||
'documents.*.max' => 'Saiz dokumen melebihi had maksimum 10MB.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ProfileInvestmentLimitRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$profileId = $this->route('investment_limit_profile');
|
||||
$limitId = $this->route('limit');
|
||||
|
||||
$typeRule = Rule::unique('profile_investment_limits', 'investment_type_id')
|
||||
->where(fn ($query) => $query->where('profile_id', $profileId));
|
||||
|
||||
if ($this->isMethod('PUT') || $this->isMethod('PATCH')) {
|
||||
$typeRule = $typeRule->ignore($limitId);
|
||||
}
|
||||
|
||||
return [
|
||||
'investment_type_id' => [
|
||||
'required',
|
||||
'uuid',
|
||||
'exists:investment_types,id',
|
||||
$typeRule,
|
||||
],
|
||||
'max_investment_amount' => ['nullable', 'numeric', 'min:0'],
|
||||
'max_transaction_amount' => ['nullable', 'numeric', 'min:0'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'investment_type_id.required' => 'Jenis pelaburan diperlukan.',
|
||||
'investment_type_id.exists' => 'Jenis pelaburan tidak dijumpai.',
|
||||
'investment_type_id.unique' => 'Had untuk jenis pelaburan ini sudah wujud dalam profil.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class RejectSalaryDeductionRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'remarks' => ['nullable', 'string', 'max:1000'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Modules\Investment\Enums\SalaryContributionStatus;
|
||||
|
||||
class SalaryContributionRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$isCreate = $this->isMethod('POST');
|
||||
$isUpdate = $this->isMethod('PUT') || $this->isMethod('PATCH');
|
||||
|
||||
if (! $isCreate && ! $isUpdate) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'user_id' => [$isCreate ? 'required' : 'sometimes', 'uuid', 'exists:users,id'],
|
||||
'investment_type_id' => [$isCreate ? 'required' : 'sometimes', 'uuid', 'exists:investment_types,id'],
|
||||
'monthly_amount' => ['required', 'numeric', 'gt:0'],
|
||||
'start_date' => ['required', 'date'],
|
||||
'end_date' => ['nullable', 'date', 'after_or_equal:start_date'],
|
||||
'status' => ['required', Rule::enum(SalaryContributionStatus::class)],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'user_id.required' => 'Ahli diperlukan.',
|
||||
'investment_type_id.required' => 'Jenis pelaburan diperlukan.',
|
||||
'monthly_amount.required' => 'Jumlah bulanan diperlukan.',
|
||||
'monthly_amount.gt' => 'Jumlah bulanan mestilah lebih daripada 0.',
|
||||
'start_date.required' => 'Tarikh mula diperlukan.',
|
||||
'status.required' => 'Status caruman diperlukan.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UploadSalaryDeductionBatchRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'month' => ['required', 'integer', 'min:1', 'max:12'],
|
||||
'year' => ['required', 'integer', 'min:2000', 'max:2100'],
|
||||
'details' => ['required', 'array', 'min:1'],
|
||||
'details.*.user_id' => ['required', 'uuid', 'exists:users,id'],
|
||||
'details.*.investment_type_id' => ['required', 'uuid', 'exists:investment_types,id'],
|
||||
'details.*.amount' => ['required', 'numeric', 'gt:0'],
|
||||
'details.*.salary_contribution_id' => ['nullable', 'uuid', 'exists:salary_contributions,id'],
|
||||
'details.*.remarks' => ['nullable', 'string', 'max:1000'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UserInvestmentLimitRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$isCreate = $this->isMethod('POST');
|
||||
$isUpdate = $this->isMethod('PUT') || $this->isMethod('PATCH');
|
||||
|
||||
if (! $isCreate && ! $isUpdate) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'user_id' => [$isCreate ? 'required' : 'sometimes', 'uuid', 'exists:users,id'],
|
||||
'investment_type_id' => [$isCreate ? 'required' : 'sometimes', 'uuid', 'exists:investment_types,id'],
|
||||
'max_investment_amount' => ['nullable', 'numeric', 'min:0'],
|
||||
'max_transaction_amount' => ['nullable', 'numeric', 'min:0'],
|
||||
'effective_date' => ['required', 'date'],
|
||||
'expiry_date' => ['nullable', 'date', 'after_or_equal:effective_date'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'user_id.required' => 'Ahli diperlukan.',
|
||||
'investment_type_id.required' => 'Jenis pelaburan diperlukan.',
|
||||
'effective_date.required' => 'Tarikh berkuat kuasa diperlukan.',
|
||||
'expiry_date.after_or_equal' => 'Tarikh tamat mestilah pada atau selepas tarikh berkuat kuasa.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Investment\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class VerifySalaryDeductionDetailRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'remarks' => ['nullable', 'string', 'max:1000'],
|
||||
];
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user