Compare commits

..

No commits in common. "15e2b7ec5b326089e1c81dcbc2d0dc9ddc6b939d" and "4ba6b9da9f1db7add843048364d5acaad9e302b6" have entirely different histories.

171 changed files with 1995 additions and 5949 deletions

View File

@ -3,18 +3,14 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Enums\PayrollAdjustmentType; use App\Enums\PayrollAdjustmentType;
use App\Enums\Role;
use App\Models\Attendance; use App\Models\Attendance;
use App\Models\Employee; use App\Models\Employee;
use App\Models\LeaveRequest; use App\Models\LeaveRequest;
use App\Models\Payroll; use App\Models\Payroll;
use App\Models\PayrollPeriod; use App\Models\PayrollPeriod;
use App\Models\User;
use App\Settings\HrSettings; use App\Settings\HrSettings;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class ApplyAttendancePenalties extends Command class ApplyAttendancePenalties extends Command
{ {
@ -40,16 +36,11 @@ public function handle(): int
} }
$scheduledCheckIn = Carbon::createFromFormat('Y-m-d H:i', $date->format('Y-m-d').' '.$scheduledTime); $scheduledCheckIn = Carbon::createFromFormat('Y-m-d H:i', $date->format('Y-m-d').' '.$scheduledTime);
$createdBy = $this->resolveSystemUser();
if ($createdBy === null) {
$this->error('Tidak ditemukan user sistem (developer/owner) untuk mencatat penalti.');
return self::FAILURE;
}
$employees = Employee::query() $employees = Employee::query()
->whereHas('user', fn ($query) => $query->active()) ->whereHas('user', function ($query) {
$query->active();
})
->get(); ->get();
$lateCount = 0; $lateCount = 0;
@ -57,8 +48,6 @@ public function handle(): int
$skippedLeave = 0; $skippedLeave = 0;
$skippedDuplicate = 0; $skippedDuplicate = 0;
try {
DB::transaction(function () use ($employees, $date, $scheduledCheckIn, $latePenalty, $absentPenalty, $createdBy, &$lateCount, &$absentCount, &$skippedLeave, &$skippedDuplicate): void {
foreach ($employees as $employee) { foreach ($employees as $employee) {
$hasLeave = LeaveRequest::query() $hasLeave = LeaveRequest::query()
->approved() ->approved()
@ -90,7 +79,7 @@ public function handle(): int
} }
$alreadyExists = $payroll->adjustments() $alreadyExists = $payroll->adjustments()
->whereNull('attendance_id') ->where('attendance_id', null)
->deduction() ->deduction()
->where('description', 'like', "%Bolos {$date->format('d/m/Y')}%") ->where('description', 'like', "%Bolos {$date->format('d/m/Y')}%")
->exists(); ->exists();
@ -105,7 +94,7 @@ public function handle(): int
'type' => PayrollAdjustmentType::DEDUCTION, 'type' => PayrollAdjustmentType::DEDUCTION,
'amount' => $absentPenalty, 'amount' => $absentPenalty,
'description' => "Bolos {$date->format('d/m/Y')}", 'description' => "Bolos {$date->format('d/m/Y')}",
'created_by_id' => $createdBy->id, 'created_by_id' => 1,
]); ]);
$payroll->load('adjustments'); $payroll->load('adjustments');
@ -151,7 +140,7 @@ public function handle(): int
'amount' => $latePenalty, 'amount' => $latePenalty,
'description' => "Terlambat {$date->format('d/m/Y')} ({$minutesLate} menit)", 'description' => "Terlambat {$date->format('d/m/Y')} ({$minutesLate} menit)",
'attendance_id' => $attendance->id, 'attendance_id' => $attendance->id,
'created_by_id' => $createdBy->id, 'created_by_id' => 1,
]); ]);
$payroll->load('adjustments'); $payroll->load('adjustments');
@ -160,16 +149,6 @@ public function handle(): int
$lateCount++; $lateCount++;
} }
});
} catch (\Throwable $e) {
Log::error("Gagal menerapkan penalti presensi: {$e->getMessage()}", [
'trace' => $e->getTraceAsString(),
]);
$this->error('Gagal menerapkan penalti presensi: '.$e->getMessage());
return self::FAILURE;
}
$this->info("Selesai memproses tanggal {$date->format('d/m/Y')}:"); $this->info("Selesai memproses tanggal {$date->format('d/m/Y')}:");
$this->info(" - Terlambat: {$lateCount} pegawai"); $this->info(" - Terlambat: {$lateCount} pegawai");
@ -182,7 +161,9 @@ public function handle(): int
private function findOpenPayroll(int $employeeId): ?Payroll private function findOpenPayroll(int $employeeId): ?Payroll
{ {
$period = PayrollPeriod::query()->open()->first(); $period = PayrollPeriod::query()
->open()
->first();
if ($period === null) { if ($period === null) {
return null; return null;
@ -193,12 +174,4 @@ private function findOpenPayroll(int $employeeId): ?Payroll
->where('employee_id', $employeeId) ->where('employee_id', $employeeId)
->first(); ->first();
} }
private function resolveSystemUser(): ?User
{
return User::query()
->whereHas('roles', fn ($query) => $query->whereIn('name', [Role::DEVELOPER->value, Role::OWNER->value]))
->first()
?? User::query()->first();
}
} }

View File

@ -135,16 +135,8 @@ enum Permission: string
case PAYROLL_VIEW = 'payroll.view'; case PAYROLL_VIEW = 'payroll.view';
case PAYROLL_ADJUST = 'payroll.adjust'; case PAYROLL_ADJUST = 'payroll.adjust';
case SETTINGS_VIEW_SYSTEM = 'settings.view_system'; case SETTINGS_VIEW = 'settings.view';
case SETTINGS_UPDATE_SYSTEM = 'settings.update_system'; case SETTINGS_UPDATE = 'settings.update';
case SETTINGS_VIEW_SOCIAL_MEDIA = 'settings.view_social_media';
case SETTINGS_UPDATE_SOCIAL_MEDIA = 'settings.update_social_media';
case SETTINGS_VIEW_MARKETPLACE = 'settings.view_marketplace';
case SETTINGS_UPDATE_MARKETPLACE = 'settings.update_marketplace';
case SETTINGS_VIEW_HR = 'settings.view_hr';
case SETTINGS_UPDATE_HR = 'settings.update_hr';
case SETTINGS_VIEW_HOMEPAGE = 'settings.view_homepage';
case SETTINGS_UPDATE_HOMEPAGE = 'settings.update_homepage';
case ACTIVITY_LOGS_VIEW = 'activity_logs.view'; case ACTIVITY_LOGS_VIEW = 'activity_logs.view';
@ -283,16 +275,8 @@ public function label(): string
self::PAYROLL_VIEW => 'Lihat Gaji', self::PAYROLL_VIEW => 'Lihat Gaji',
self::PAYROLL_ADJUST => 'Sesuaikan Gaji', self::PAYROLL_ADJUST => 'Sesuaikan Gaji',
self::SETTINGS_VIEW_SYSTEM => 'Lihat Pengaturan Sistem', self::SETTINGS_VIEW => 'Lihat Pengaturan Aplikasi',
self::SETTINGS_UPDATE_SYSTEM => 'Ubah Pengaturan Sistem', self::SETTINGS_UPDATE => 'Ubah Pengaturan Aplikasi',
self::SETTINGS_VIEW_SOCIAL_MEDIA => 'Lihat Pengaturan Media Sosial',
self::SETTINGS_UPDATE_SOCIAL_MEDIA => 'Ubah Pengaturan Media Sosial',
self::SETTINGS_VIEW_MARKETPLACE => 'Lihat Pengaturan Marketplace',
self::SETTINGS_UPDATE_MARKETPLACE => 'Ubah Pengaturan Marketplace',
self::SETTINGS_VIEW_HR => 'Lihat Pengaturan HR',
self::SETTINGS_UPDATE_HR => 'Ubah Pengaturan HR',
self::SETTINGS_VIEW_HOMEPAGE => 'Lihat Pengaturan Homepage',
self::SETTINGS_UPDATE_HOMEPAGE => 'Ubah Pengaturan Homepage',
self::ACTIVITY_LOGS_VIEW => 'Lihat Log Aktivitas', self::ACTIVITY_LOGS_VIEW => 'Lihat Log Aktivitas',
@ -351,11 +335,7 @@ public function group(): string
self::EMPLOYEE_ADVANCES_DELETE, self::EMPLOYEE_ADVANCES_VERIFY, self::EMPLOYEE_ADVANCES_DELETE, self::EMPLOYEE_ADVANCES_VERIFY,
self::EMPLOYEE_ADVANCES_PAY => 'Kasbon', self::EMPLOYEE_ADVANCES_PAY => 'Kasbon',
self::PAYROLL_VIEW, self::PAYROLL_ADJUST => 'Gaji', self::PAYROLL_VIEW, self::PAYROLL_ADJUST => 'Gaji',
self::SETTINGS_VIEW_SYSTEM, self::SETTINGS_UPDATE_SYSTEM, self::SETTINGS_VIEW, self::SETTINGS_UPDATE => 'Pengaturan Aplikasi',
self::SETTINGS_VIEW_SOCIAL_MEDIA, self::SETTINGS_UPDATE_SOCIAL_MEDIA,
self::SETTINGS_VIEW_MARKETPLACE, self::SETTINGS_UPDATE_MARKETPLACE,
self::SETTINGS_VIEW_HR, self::SETTINGS_UPDATE_HR,
self::SETTINGS_VIEW_HOMEPAGE, self::SETTINGS_UPDATE_HOMEPAGE => 'Pengaturan Aplikasi',
self::ACTIVITY_LOGS_VIEW => 'Log Aktivitas', self::ACTIVITY_LOGS_VIEW => 'Log Aktivitas',
self::ROLES_VIEW, self::ROLES_CREATE, self::ROLES_UPDATE, self::ROLES_VIEW, self::ROLES_CREATE, self::ROLES_UPDATE,
self::ROLES_DELETE => 'Role & Permission', self::ROLES_DELETE => 'Role & Permission',

View File

@ -193,16 +193,8 @@ public function permissions(): array
Permission::PAYROLL_VIEW, Permission::PAYROLL_VIEW,
Permission::PAYROLL_ADJUST, Permission::PAYROLL_ADJUST,
Permission::SETTINGS_VIEW_SYSTEM, Permission::SETTINGS_VIEW,
Permission::SETTINGS_UPDATE_SYSTEM, Permission::SETTINGS_UPDATE,
Permission::SETTINGS_VIEW_SOCIAL_MEDIA,
Permission::SETTINGS_UPDATE_SOCIAL_MEDIA,
Permission::SETTINGS_VIEW_MARKETPLACE,
Permission::SETTINGS_UPDATE_MARKETPLACE,
Permission::SETTINGS_VIEW_HR,
Permission::SETTINGS_UPDATE_HR,
Permission::SETTINGS_VIEW_HOMEPAGE,
Permission::SETTINGS_UPDATE_HOMEPAGE,
], ],
self::CASHIER => [ self::CASHIER => [

View File

@ -5,14 +5,13 @@
use App\Http\Controllers\Concerns\FlashesEntityMessage; use App\Http\Controllers\Concerns\FlashesEntityMessage;
use App\Http\Controllers\Concerns\ParsesDataTableQuery; use App\Http\Controllers\Concerns\ParsesDataTableQuery;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Finance\Cash\CashTransactionRequest;
use App\Http\Requests\Admin\Finance\Cash\DepositRequest; use App\Http\Requests\Admin\Finance\Cash\DepositRequest;
use App\Http\Requests\Admin\Finance\Cash\WithdrawRequest; use App\Http\Requests\Admin\Finance\Cash\WithdrawRequest;
use App\Http\Requests\Admin\Finance\UpdateCashTransactionRequest;
use App\Models\CashTransaction; use App\Models\CashTransaction;
use App\Services\Finance\CashService; use App\Services\Finance\CashService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@ -39,14 +38,8 @@ public function index(Request $request): Response
]); ]);
} }
public function update(CashTransactionRequest $request, CashTransaction $cashTransaction): RedirectResponse public function update(UpdateCashTransactionRequest $request, CashTransaction $cashTransaction): RedirectResponse
{ {
try {
$cashTransaction->ensureEditable();
} catch (ValidationException) {
abort(403);
}
$this->cashService->updateDeposit($cashTransaction, $request->validated()); $this->cashService->updateDeposit($cashTransaction, $request->validated());
$this->flashUpdated('Setor kas'); $this->flashUpdated('Setor kas');
@ -56,12 +49,6 @@ public function update(CashTransactionRequest $request, CashTransaction $cashTra
public function destroy(CashTransaction $cashTransaction): RedirectResponse public function destroy(CashTransaction $cashTransaction): RedirectResponse
{ {
try {
$cashTransaction->ensureEditable();
} catch (ValidationException) {
abort(403);
}
$this->cashService->deleteTransaction($cashTransaction); $this->cashService->deleteTransaction($cashTransaction);
$this->flashDeleted('Setor kas'); $this->flashDeleted('Setor kas');

View File

@ -5,7 +5,6 @@
use App\Http\Controllers\Concerns\FlashesEntityMessage; use App\Http\Controllers\Concerns\FlashesEntityMessage;
use App\Http\Controllers\Concerns\ParsesDataTableQuery; use App\Http\Controllers\Concerns\ParsesDataTableQuery;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Finance\ApproveEmployeeAdvanceRequest;
use App\Http\Requests\Admin\Finance\DestroyEmployeeAdvanceRequest; use App\Http\Requests\Admin\Finance\DestroyEmployeeAdvanceRequest;
use App\Http\Requests\Admin\Finance\EmployeeAdvanceRequest; use App\Http\Requests\Admin\Finance\EmployeeAdvanceRequest;
use App\Http\Requests\Admin\Finance\PayEmployeeAdvanceRequest; use App\Http\Requests\Admin\Finance\PayEmployeeAdvanceRequest;
@ -14,7 +13,6 @@
use App\Services\Finance\EmployeeAdvanceService; use App\Services\Finance\EmployeeAdvanceService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@ -51,12 +49,6 @@ public function store(EmployeeAdvanceRequest $request): RedirectResponse
public function update(EmployeeAdvanceRequest $request, EmployeeAdvance $employeeAdvance): RedirectResponse public function update(EmployeeAdvanceRequest $request, EmployeeAdvance $employeeAdvance): RedirectResponse
{ {
try {
$employeeAdvance->ensureEditable();
} catch (ValidationException) {
abort(403);
}
$this->employeeAdvanceService->update($employeeAdvance, $request->validated(), $request->user()); $this->employeeAdvanceService->update($employeeAdvance, $request->validated(), $request->user());
$this->flashUpdated('Kasbon'); $this->flashUpdated('Kasbon');
@ -66,12 +58,6 @@ public function update(EmployeeAdvanceRequest $request, EmployeeAdvance $employe
public function destroy(DestroyEmployeeAdvanceRequest $request, EmployeeAdvance $employeeAdvance): RedirectResponse public function destroy(DestroyEmployeeAdvanceRequest $request, EmployeeAdvance $employeeAdvance): RedirectResponse
{ {
try {
$employeeAdvance->ensureEditable();
} catch (ValidationException) {
abort(403);
}
$this->employeeAdvanceService->delete($employeeAdvance, $request->user()); $this->employeeAdvanceService->delete($employeeAdvance, $request->user());
$this->flashDeleted('Kasbon'); $this->flashDeleted('Kasbon');
@ -79,7 +65,7 @@ public function destroy(DestroyEmployeeAdvanceRequest $request, EmployeeAdvance
return redirect()->route('admin.finance.employee_advances.index'); return redirect()->route('admin.finance.employee_advances.index');
} }
public function approve(ApproveEmployeeAdvanceRequest $request, EmployeeAdvance $employeeAdvance): RedirectResponse public function approve(Request $request, EmployeeAdvance $employeeAdvance): RedirectResponse
{ {
$this->employeeAdvanceService->approve($employeeAdvance, $request->user()); $this->employeeAdvanceService->approve($employeeAdvance, $request->user());

View File

@ -4,7 +4,6 @@
use App\Enums\EmploymentStatus; use App\Enums\EmploymentStatus;
use App\Enums\Gender; use App\Enums\Gender;
use App\Enums\Role;
use App\Http\Controllers\Concerns\FlashesEntityMessage; use App\Http\Controllers\Concerns\FlashesEntityMessage;
use App\Http\Controllers\Concerns\ParsesDataTableQuery; use App\Http\Controllers\Concerns\ParsesDataTableQuery;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
@ -47,7 +46,7 @@ public function index(Request $request): Response
'employment_status' => $employmentStatus, 'employment_status' => $employmentStatus,
'is_active' => $isActive, 'is_active' => $isActive,
]), ]),
'roles' => Role::assignableSelectOptions(), 'roles' => $this->employeeService->assignableRoleOptions(),
'genders' => Gender::selectOptions(), 'genders' => Gender::selectOptions(),
'employmentStatuses' => EmploymentStatus::selectOptions(), 'employmentStatuses' => EmploymentStatus::selectOptions(),
]); ]);
@ -58,7 +57,7 @@ public function create(): Response
return Inertia::render('admin/hr/employees/Create', [ return Inertia::render('admin/hr/employees/Create', [
'genders' => Gender::selectOptions(), 'genders' => Gender::selectOptions(),
'employmentStatuses' => EmploymentStatus::selectOptions(), 'employmentStatuses' => EmploymentStatus::selectOptions(),
'roles' => Role::assignableSelectOptions(), 'roles' => $this->employeeService->assignableRoleOptions(),
]); ]);
} }
@ -78,7 +77,7 @@ public function edit(User $user): Response
return Inertia::render('admin/hr/employees/Edit', [ return Inertia::render('admin/hr/employees/Edit', [
'genders' => Gender::selectOptions(), 'genders' => Gender::selectOptions(),
'employmentStatuses' => EmploymentStatus::selectOptions(), 'employmentStatuses' => EmploymentStatus::selectOptions(),
'roles' => Role::assignableSelectOptions(), 'roles' => $this->employeeService->assignableRoleOptions(),
'employee' => $editData['employee'], 'employee' => $editData['employee'],
'profilePhoto' => $editData['profilePhoto'], 'profilePhoto' => $editData['profilePhoto'],
]); ]);

View File

@ -12,7 +12,6 @@
use App\Services\Hr\LeaveRequestService; use App\Services\Hr\LeaveRequestService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@ -46,12 +45,6 @@ public function store(SubmitLeaveRequest $request): RedirectResponse
public function update(SubmitLeaveRequest $request, LeaveRequest $leaveRequest): RedirectResponse public function update(SubmitLeaveRequest $request, LeaveRequest $leaveRequest): RedirectResponse
{ {
try {
$leaveRequest->ensureEditable();
} catch (ValidationException) {
abort(403);
}
$this->leaveRequestService->update($leaveRequest, $request->validated(), $request->user()); $this->leaveRequestService->update($leaveRequest, $request->validated(), $request->user());
$this->flashUpdated('Pengajuan cuti'); $this->flashUpdated('Pengajuan cuti');
@ -61,12 +54,6 @@ public function update(SubmitLeaveRequest $request, LeaveRequest $leaveRequest):
public function destroy(LeaveRequest $leaveRequest): RedirectResponse public function destroy(LeaveRequest $leaveRequest): RedirectResponse
{ {
try {
$leaveRequest->ensureEditable();
} catch (ValidationException) {
abort(403);
}
$this->leaveRequestService->delete($leaveRequest); $this->leaveRequestService->delete($leaveRequest);
$this->flashDeleted('Pengajuan cuti'); $this->flashDeleted('Pengajuan cuti');

View File

@ -3,7 +3,6 @@
namespace App\Http\Controllers\Admin\Manage\Cutting; namespace App\Http\Controllers\Admin\Manage\Cutting;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Manage\CuttingDraftCombinationRequest;
use App\Http\Requests\Admin\Manage\CuttingDraftMaterialRequest; use App\Http\Requests\Admin\Manage\CuttingDraftMaterialRequest;
use App\Http\Requests\Admin\Manage\CuttingDraftResultRequest; use App\Http\Requests\Admin\Manage\CuttingDraftResultRequest;
use App\Http\Requests\Admin\Manage\CuttingQuickCreateProductRequest; use App\Http\Requests\Admin\Manage\CuttingQuickCreateProductRequest;
@ -48,20 +47,6 @@ public function destroyResult(Request $request, ProductVariant $productVariant):
return response()->json(['ok' => true]); return response()->json(['ok' => true]);
} }
public function storeCombination(CuttingDraftCombinationRequest $request): JsonResponse
{
$items = $this->cuttingService->syncDraftCombination($request->validated(), $request->user());
return response()->json(['items' => $items]);
}
public function destroyCombination(Request $request, int $combinationId): JsonResponse
{
$this->cuttingService->removeDraftCombination($request->user(), $combinationId);
return response()->json(['ok' => true]);
}
public function quickCreateRawMaterial(CuttingQuickCreateRawMaterialRequest $request): JsonResponse public function quickCreateRawMaterial(CuttingQuickCreateRawMaterialRequest $request): JsonResponse
{ {
$rawMaterial = $this->cuttingService->quickCreateRawMaterial($request->validated()); $rawMaterial = $this->cuttingService->quickCreateRawMaterial($request->validated());

View File

@ -8,6 +8,7 @@
use App\Http\Requests\Admin\Master\CustomerRequest; use App\Http\Requests\Admin\Master\CustomerRequest;
use App\Models\Customer; use App\Models\Customer;
use App\Services\Master\CustomerService; use App\Services\Master\CustomerService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Inertia; use Inertia\Inertia;
@ -57,4 +58,14 @@ public function destroy(Customer $customer): RedirectResponse
return redirect()->route('admin.master.customers.index'); return redirect()->route('admin.master.customers.index');
} }
public function storeApi(CustomerRequest $request): JsonResponse
{
$customer = $this->customerService->createAndReturn($request->validated());
return response()->json([
'id' => $customer->id,
'name' => $customer->name,
]);
}
} }

View File

@ -17,7 +17,6 @@
use App\Services\System\Setting\SocialMediaService; use App\Services\System\Setting\SocialMediaService;
use App\Services\System\Setting\SystemService; use App\Services\System\Setting\SystemService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@ -34,10 +33,8 @@ public function __construct(
private readonly OwnerVerificationService $ownerVerificationService, private readonly OwnerVerificationService $ownerVerificationService,
) {} ) {}
public function index(Request $request): Response public function index(): Response
{ {
$user = $request->user();
return Inertia::render('admin/system/setting/Index', [ return Inertia::render('admin/system/setting/Index', [
'system' => $this->systemService->systemData(), 'system' => $this->systemService->systemData(),
'socialMedia' => $this->socialMediaService->socialMediaData(), 'socialMedia' => $this->socialMediaService->socialMediaData(),
@ -45,16 +42,15 @@ public function index(Request $request): Response
'hr' => $this->hrSettingService->hrData(), 'hr' => $this->hrSettingService->hrData(),
'homepage' => $this->homepageSettingService->homepageData(), 'homepage' => $this->homepageSettingService->homepageData(),
'hasPendingMarketplaceVerification' => $this->ownerVerificationService->hasPendingMarketplaceVerification(), 'hasPendingMarketplaceVerification' => $this->ownerVerificationService->hasPendingMarketplaceVerification(),
'canUpdateSystem' => $user->can(Permission::SETTINGS_UPDATE_SYSTEM->value),
'canUpdateSocialMedia' => $user->can(Permission::SETTINGS_UPDATE_SOCIAL_MEDIA->value),
'canUpdateMarketplace' => $user->can(Permission::SETTINGS_UPDATE_MARKETPLACE->value),
'canUpdateHr' => $user->can(Permission::SETTINGS_UPDATE_HR->value),
'canUpdateHomepage' => $user->can(Permission::SETTINGS_UPDATE_HOMEPAGE->value),
]); ]);
} }
public function updateSystem(SystemRequest $request): RedirectResponse public function updateSystem(SystemRequest $request): RedirectResponse
{ {
if (! $request->user()->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)) {
abort(403, 'Aksi ini tidak diizinkan.');
}
$this->systemService->updateSystem($request->validated()); $this->systemService->updateSystem($request->validated());
$this->flashSuccess('Pengaturan sistem berhasil disimpan.'); $this->flashSuccess('Pengaturan sistem berhasil disimpan.');
@ -64,6 +60,10 @@ public function updateSystem(SystemRequest $request): RedirectResponse
public function updateSocialMedia(SocialMediaRequest $request): RedirectResponse public function updateSocialMedia(SocialMediaRequest $request): RedirectResponse
{ {
if (! $request->user()->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)) {
abort(403, 'Aksi ini tidak diizinkan.');
}
$this->socialMediaService->updateSocialMedia($request->validated()); $this->socialMediaService->updateSocialMedia($request->validated());
$this->flashSuccess('Pengaturan media sosial berhasil disimpan.'); $this->flashSuccess('Pengaturan media sosial berhasil disimpan.');
@ -86,6 +86,10 @@ public function updateMarketplace(MarketplaceRequest $request): RedirectResponse
public function updateHr(HrSettingRequest $request): RedirectResponse public function updateHr(HrSettingRequest $request): RedirectResponse
{ {
if (! $request->user()->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)) {
abort(403, 'Aksi ini tidak diizinkan.');
}
$this->hrSettingService->updateHr($request->validated()); $this->hrSettingService->updateHr($request->validated());
$this->flashSuccess('Pengaturan HR berhasil disimpan.'); $this->flashSuccess('Pengaturan HR berhasil disimpan.');
@ -95,6 +99,10 @@ public function updateHr(HrSettingRequest $request): RedirectResponse
public function updateHomepage(HomepageSettingRequest $request): RedirectResponse public function updateHomepage(HomepageSettingRequest $request): RedirectResponse
{ {
if (! $request->user()->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)) {
abort(403, 'Aksi ini tidak diizinkan.');
}
$this->homepageSettingService->updateHomepage($request->validated()); $this->homepageSettingService->updateHomepage($request->validated());
$this->flashSuccess('Pengaturan homepage berhasil disimpan.'); $this->flashSuccess('Pengaturan homepage berhasil disimpan.');

View File

@ -1,25 +0,0 @@
<?php
namespace App\Http\Controllers\Api\Master;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Master\CustomerRequest;
use App\Services\Master\CustomerService;
use Illuminate\Http\JsonResponse;
class CustomerApiController extends Controller
{
public function __construct(
private readonly CustomerService $customerService,
) {}
public function store(CustomerRequest $request): JsonResponse
{
$customer = $this->customerService->createAndReturn($request->validated());
return response()->json([
'id' => $customer->id,
'name' => $customer->name,
]);
}
}

View File

@ -18,14 +18,4 @@ public function rules(): array
'amount' => ['nullable', 'integer', 'min:1'], 'amount' => ['nullable', 'integer', 'min:1'],
]; ];
} }
/**
* @return array<string, string>
*/
public function attributes(): array
{
return [
'amount' => 'jumlah',
];
}
} }

View File

@ -1,12 +1,12 @@
<?php <?php
namespace App\Http\Requests\Admin\Finance\Cash; namespace App\Http\Requests\Admin\Finance;
use App\Enums\Permission; use App\Enums\Permission;
use App\Http\Requests\Concerns\ValidatesMediaUploads; use App\Http\Requests\Concerns\ValidatesMediaUploads;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
class CashTransactionRequest extends FormRequest class UpdateCashTransactionRequest extends FormRequest
{ {
use ValidatesMediaUploads; use ValidatesMediaUploads;

View File

@ -1,32 +0,0 @@
<?php
namespace App\Http\Requests\Admin\Manage;
use App\Enums\Permission;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class CuttingDraftCombinationRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can(Permission::CUTTINGS_CREATE->value) ?? false;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'materials' => ['required', 'array', 'min:2'],
'materials.*.raw_material_price_id' => [
'required',
'integer',
Rule::exists('raw_material_prices', 'id')->whereNull('deleted_at'),
],
'materials.*.material_usage' => ['required', 'numeric', 'decimal:0,4', 'gt:0'],
'material_result' => ['nullable', 'integer', 'gte:0'],
];
}
}

View File

@ -25,7 +25,6 @@ public function rules(): array
Rule::exists('raw_material_prices', 'id')->whereNull('deleted_at'), Rule::exists('raw_material_prices', 'id')->whereNull('deleted_at'),
], ],
'material_usage' => ['required', 'numeric', 'decimal:0,4', 'gt:0'], 'material_usage' => ['required', 'numeric', 'decimal:0,4', 'gt:0'],
'material_result' => ['nullable', 'integer', 'gte:0'],
]; ];
} }
} }

View File

@ -39,12 +39,10 @@ public function rules(): array
$rules['materials.*.raw_material_price_id'] = [ $rules['materials.*.raw_material_price_id'] = [
'required', 'required',
'integer', 'integer',
'distinct',
Rule::exists('raw_material_prices', 'id')->whereNull('deleted_at'), Rule::exists('raw_material_prices', 'id')->whereNull('deleted_at'),
]; ];
$rules['materials.*.material_usage'] = ['required', 'numeric', 'decimal:0,4', 'gt:0']; $rules['materials.*.material_usage'] = ['required', 'numeric', 'decimal:0,4', 'gt:0'];
$rules['materials.*.material_result'] = ['nullable', 'integer', 'min:0'];
$rules['materials.*.combination_id'] = ['nullable', 'integer'];
$rules['materials.*.combination_material_result'] = ['nullable', 'integer', 'min:0'];
$rules['results'] = ['required', 'array', 'min:1']; $rules['results'] = ['required', 'array', 'min:1'];
$rules['results.*.product_variant_id'] = [ $rules['results.*.product_variant_id'] = [
@ -75,8 +73,6 @@ public function attributes(): array
'materials' => 'Bahan Baku', 'materials' => 'Bahan Baku',
'materials.*.raw_material_price_id' => 'Bahan Baku', 'materials.*.raw_material_price_id' => 'Bahan Baku',
'materials.*.material_usage' => 'Pemakaian', 'materials.*.material_usage' => 'Pemakaian',
'materials.*.material_result' => 'Hasil',
'materials.*.combination_material_result' => 'Hasil Kombinasi',
'results' => 'Hasil Produk', 'results' => 'Hasil Produk',
'results.*.product_variant_id' => 'Varian Produk', 'results.*.product_variant_id' => 'Varian Produk',
'results.*.cutting_result' => 'Hasil', 'results.*.cutting_result' => 'Hasil',

View File

@ -9,7 +9,7 @@ class HomepageSettingRequest extends FormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {
return $this->user()?->can(Permission::SETTINGS_UPDATE_HOMEPAGE->value) ?? false; return $this->user()?->can(Permission::SETTINGS_UPDATE->value) ?? false;
} }
/** /**
@ -24,8 +24,6 @@ public function rules(): array
'about_image_s3_key' => ['nullable', 'string'], 'about_image_s3_key' => ['nullable', 'string'],
'gallery_s3_keys' => ['nullable', 'array', 'max:10'], 'gallery_s3_keys' => ['nullable', 'array', 'max:10'],
'gallery_s3_keys.*' => ['required', 'string'], 'gallery_s3_keys.*' => ['required', 'string'],
'gallery_images' => ['nullable', 'array', 'max:10'],
'gallery_images.*' => ['required', 'image', 'max:5120'],
'gallery_images_remove' => ['nullable', 'array'], 'gallery_images_remove' => ['nullable', 'array'],
'gallery_images_remove.*' => ['integer'], 'gallery_images_remove.*' => ['integer'],
]; ];

View File

@ -9,7 +9,7 @@ class HrSettingRequest extends FormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {
return $this->user()?->can(Permission::SETTINGS_UPDATE_HR->value) ?? false; return $this->user()?->can(Permission::SETTINGS_UPDATE->value) ?? false;
} }
/** /**

View File

@ -13,7 +13,7 @@ class MarketplaceRequest extends FormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {
return $this->user()?->can(Permission::SETTINGS_UPDATE_MARKETPLACE->value) ?? false; return $this->user()?->can(Permission::SETTINGS_UPDATE->value) ?? false;
} }
/** /**

View File

@ -9,7 +9,7 @@ class SocialMediaRequest extends FormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {
return $this->user()?->can(Permission::SETTINGS_UPDATE_SOCIAL_MEDIA->value) ?? false; return $this->user()?->can(Permission::SETTINGS_UPDATE->value) ?? false;
} }
/** /**

View File

@ -10,7 +10,7 @@ class SystemRequest extends FormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {
return $this->user()?->can(Permission::SETTINGS_UPDATE_SYSTEM->value) ?? false; return $this->user()?->can(Permission::SETTINGS_UPDATE->value) ?? false;
} }
/** /**

View File

@ -10,8 +10,6 @@ trait ValidatesMediaUploads
protected function photoRules(string $prefix = 'photos', int $max = 1): array protected function photoRules(string $prefix = 'photos', int $max = 1): array
{ {
return [ return [
$prefix => ['nullable', 'array', "max:{$max}"],
"{$prefix}.*" => ['image', 'mimes:jpg,jpeg,png,webp', 'max:5120'],
's3_keys' => ['nullable', 'array', "max:{$max}"], 's3_keys' => ['nullable', 'array', "max:{$max}"],
's3_keys.*' => ['required', 'string'], 's3_keys.*' => ['required', 'string'],
'remove_media_ids' => ['nullable', 'array'], 'remove_media_ids' => ['nullable', 'array'],
@ -35,11 +33,9 @@ protected function variantImageRules(string $variantsKey = 'variants', int $max
/** /**
* @return array<string, string> * @return array<string, string>
*/ */
protected function photoUploadAttributes(string $label, string $prefix = 'photos'): array protected function photoUploadAttributes(string $label): array
{ {
return [ return [
$prefix => $label,
"{$prefix}.*" => $label,
's3_keys' => $label, 's3_keys' => $label,
's3_keys.*' => $label, 's3_keys.*' => $label,
'remove_media_ids' => 'media yang dihapus', 'remove_media_ids' => 'media yang dihapus',

View File

@ -5,6 +5,7 @@
use App\Models\Concerns\HasModuleMedia; use App\Models\Concerns\HasModuleMedia;
use App\Models\Concerns\InteractsWithActivityLog; use App\Models\Concerns\InteractsWithActivityLog;
use App\Support\Media\MediaPresenter; use App\Support\Media\MediaPresenter;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Attributes\Appends; use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded; use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
@ -48,7 +49,7 @@ protected function casts(): array
public function attendanceDateFormatted(): Attribute public function attendanceDateFormatted(): Attribute
{ {
return Attribute::make( return Attribute::make(
get: fn () => $this->attendance_date?->translatedFormat('l, d F Y'), get: fn () => Carbon::parse($this->attendance_date)->translatedFormat('l, d F Y'),
); );
} }

View File

@ -16,7 +16,6 @@
use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Validation\ValidationException;
use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\HasMedia;
#[Guarded(['id'])] #[Guarded(['id'])]
@ -128,15 +127,6 @@ public function sourceBadgeClass(): Attribute
} }
// 5. Other Methods // 5. Other Methods
public function ensureEditable(): void
{
if ($this->reference_type !== null) {
throw ValidationException::withMessages([
'transaction' => 'Transaksi ini tidak dapat diubah dari halaman kas.',
]);
}
}
public function getEmployeeAdvanceLabel(): string public function getEmployeeAdvanceLabel(): string
{ {
return $this->isEmployeeAdvanceRepayment() ? 'Pelunasan Kasbon' : 'Pencairan Kasbon'; return $this->isEmployeeAdvanceRepayment() ? 'Pelunasan Kasbon' : 'Pencairan Kasbon';

View File

@ -113,11 +113,6 @@ public function materials(): HasMany
return $this->hasMany(CuttingMaterial::class); return $this->hasMany(CuttingMaterial::class);
} }
public function combinations(): HasMany
{
return $this->hasMany(CuttingMaterialCombination::class);
}
public function resultPrices(): HasMany public function resultPrices(): HasMany
{ {
return $this->hasMany(CuttingResultPrice::class); return $this->hasMany(CuttingResultPrice::class);

View File

@ -27,7 +27,6 @@ protected function casts(): array
{ {
return [ return [
'material_usage' => 'decimal:4', 'material_usage' => 'decimal:4',
'material_result' => 'integer',
]; ];
} }
@ -89,11 +88,6 @@ public function rawMaterialPrice(): BelongsTo
return $this->belongsTo(RawMaterialPrice::class); return $this->belongsTo(RawMaterialPrice::class);
} }
public function combination(): BelongsTo
{
return $this->belongsTo(CuttingMaterialCombination::class, 'combination_id');
}
public function user(): BelongsTo public function user(): BelongsTo
{ {
return $this->belongsTo(User::class); return $this->belongsTo(User::class);

View File

@ -1,39 +0,0 @@
<?php
namespace App\Models;
use App\Models\Concerns\InteractsWithActivityLog;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Guarded(['id'])]
class CuttingMaterialCombination extends Model
{
use HasFactory, InteractsWithActivityLog, SoftDeletes;
protected function casts(): array
{
return [
'material_result' => 'integer',
];
}
public function cutting(): BelongsTo
{
return $this->belongsTo(Cutting::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function materials(): HasMany
{
return $this->hasMany(CuttingMaterial::class, 'combination_id');
}
}

View File

@ -4,6 +4,7 @@
use App\Enums\EmploymentStatus; use App\Enums\EmploymentStatus;
use App\Models\Concerns\InteractsWithActivityLog; use App\Models\Concerns\InteractsWithActivityLog;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Attributes\Appends; use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded; use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Attributes\Scope; use Illuminate\Database\Eloquent\Attributes\Scope;
@ -77,7 +78,7 @@ public function employmentStatusLabel(): Attribute
public function joinDateFormatted(): Attribute public function joinDateFormatted(): Attribute
{ {
return Attribute::make( return Attribute::make(
get: fn () => $this->join_date?->translatedFormat('l, d F Y') ?? '-', get: fn () => $this->join_date ? Carbon::parse($this->join_date)->translatedFormat('l, d F Y') : '-',
); );
} }
@ -91,7 +92,7 @@ public function joinDateInput(): Attribute
public function resignDateFormatted(): Attribute public function resignDateFormatted(): Attribute
{ {
return Attribute::make( return Attribute::make(
get: fn () => $this->resign_date?->translatedFormat('l, d F Y'), get: fn () => $this->resign_date ? Carbon::parse($this->resign_date)->translatedFormat('l, d F Y') : null,
); );
} }

View File

@ -3,7 +3,6 @@
namespace App\Models; namespace App\Models;
use App\Enums\EmployeeAdvanceStatus; use App\Enums\EmployeeAdvanceStatus;
use App\Enums\Permission;
use App\Models\Concerns\HasRejection; use App\Models\Concerns\HasRejection;
use App\Models\Concerns\InteractsWithActivityLog; use App\Models\Concerns\InteractsWithActivityLog;
use Illuminate\Database\Eloquent\Attributes\Appends; use Illuminate\Database\Eloquent\Attributes\Appends;
@ -15,7 +14,6 @@
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Validation\ValidationException;
#[Guarded(['id'])] #[Guarded(['id'])]
#[Appends([ #[Appends([
@ -176,24 +174,7 @@ public function statusLabel(): Attribute
); );
} }
// 5. Other Methods // 5. Relation
public function ensureEditable(): void
{
if ($this->status !== EmployeeAdvanceStatus::PENDING) {
throw ValidationException::withMessages([
'status' => 'Kasbon tidak dapat diubah.',
]);
}
}
public static function canSubmit(User $user): bool
{
return $user->can(Permission::EMPLOYEE_ADVANCES_CREATE->value)
&& ! $user->can(Permission::EMPLOYEE_ADVANCES_VERIFY->value)
&& $user->employee !== null;
}
// 6. Relation
public function cashTransaction(): BelongsTo public function cashTransaction(): BelongsTo
{ {
return $this->belongsTo(CashTransaction::class); return $this->belongsTo(CashTransaction::class);

View File

@ -3,7 +3,6 @@
namespace App\Models; namespace App\Models;
use App\Enums\LeaveRequestStatus; use App\Enums\LeaveRequestStatus;
use App\Enums\Permission;
use App\Models\Concerns\HasRejection; use App\Models\Concerns\HasRejection;
use App\Models\Concerns\InteractsWithActivityLog; use App\Models\Concerns\InteractsWithActivityLog;
use Illuminate\Database\Eloquent\Attributes\Appends; use Illuminate\Database\Eloquent\Attributes\Appends;
@ -14,7 +13,6 @@
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Validation\ValidationException;
#[Guarded(['id'])] #[Guarded(['id'])]
#[Appends([ #[Appends([
@ -138,24 +136,7 @@ public function statusLabel(): Attribute
); );
} }
// 5. Other Methods // 5. Relation
public function ensureEditable(): void
{
if ($this->status !== LeaveRequestStatus::PENDING) {
throw ValidationException::withMessages([
'status' => 'Pengajuan cuti tidak dapat diubah.',
]);
}
}
public static function canSubmit(User $user): bool
{
return $user->can(Permission::LEAVE_REQUESTS_CREATE->value)
&& ! $user->can(Permission::LEAVE_REQUESTS_VERIFY->value)
&& $user->employee !== null;
}
// 6. Relation
public function employee(): BelongsTo public function employee(): BelongsTo
{ {
return $this->belongsTo(Employee::class); return $this->belongsTo(Employee::class);

View File

@ -2,7 +2,6 @@
namespace App\Models; namespace App\Models;
use App\Enums\EmployeeAdvanceStatus;
use App\Enums\PayrollAdjustmentType; use App\Enums\PayrollAdjustmentType;
use App\Enums\PayrollStatus; use App\Enums\PayrollStatus;
use App\Models\Concerns\InteractsWithActivityLog; use App\Models\Concerns\InteractsWithActivityLog;

View File

@ -11,7 +11,6 @@
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password; use Illuminate\Validation\Rules\Password;
use Spatie\Activitylog\Support\ActivityBuffer;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
@ -20,10 +19,7 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function register(): void public function register(): void
{ {
$this->app->scoped( //
ActivityBuffer::class,
\App\Support\ActivityLog\ActivityBuffer::class
);
} }
/** /**

View File

@ -3,30 +3,27 @@
namespace App\Services\Account; namespace App\Services\Account;
use App\Models\User; use App\Models\User;
use App\Models\UserProfile;
use App\Services\Concerns\RunsInTransaction;
use App\Services\Concerns\SyncsPhotos;
use App\Services\Media\MediaService; use App\Services\Media\MediaService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
class ProfileService class ProfileService
{ {
use RunsInTransaction, SyncsPhotos;
public function __construct( public function __construct(
private readonly MediaService $mediaService, private readonly MediaService $mediaService,
) {} ) {}
public function update(array $validated, User $user): void public function update(array $validated, User $user): void
{ {
$this->runInTransaction( try {
function () use ($user, $validated): void { DB::transaction(function () use ($user, $validated): void {
$user->update([ $user->update([
'email' => $validated['email'], 'email' => $validated['email'],
'username' => $validated['username'], 'username' => $validated['username'],
]); ]);
/** @var UserProfile $profile */
$profile = $user->profile()->updateOrCreate( $profile = $user->profile()->updateOrCreate(
['user_id' => $user->id], ['user_id' => $user->id],
[ [
@ -38,20 +35,29 @@ function () use ($user, $validated): void {
], ],
); );
$this->syncPhotos( $s3Keys = ! empty($validated['profile_s3_key']) ? [$validated['profile_s3_key']] : null;
$removeIds = $validated['remove_profile_photo_ids'] ?? null;
$this->mediaService->syncCollection(
$profile, $profile,
[ 'profile_photo',
'photos' => $validated['profile_photo'] ?? null, null,
'remove_media_ids' => $validated['remove_profile_photo_ids'] ?? null, $removeIds,
's3_keys' => ! empty($validated['profile_s3_key']) ? [$validated['profile_s3_key']] : null, 1,
], s3Keys: $s3Keys,
maxPhotos: 1,
required: false,
collection: 'profile_photo',
);
},
'Gagal memperbarui profil',
); );
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal memperbarui profil: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
} }
public function updatePassword(User $user, string $password): void public function updatePassword(User $user, string $password): void

View File

@ -1,33 +0,0 @@
<?php
namespace App\Services\Concerns;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
trait RunsInTransaction
{
/**
* @template TReturn
*
* @param callable(): TReturn $callback
* @return TReturn
*/
protected function runInTransaction(callable $callback, string $context)
{
try {
return DB::transaction($callback);
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error("{$context}: {$e->getMessage()}", [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
}
}

View File

@ -1,32 +0,0 @@
<?php
namespace App\Services\Concerns;
use App\Services\Media\MediaService;
use Spatie\MediaLibrary\HasMedia;
/**
* @property-read MediaService $mediaService
*/
trait SyncsPhotos
{
protected function syncPhotos(
HasMedia $model,
array $validated,
int $maxPhotos = 1,
bool $required = true,
string $collection = 'photos',
?string $errorKey = null,
): void {
$this->mediaService->syncCollection(
$model,
$collection,
$validated['photos'] ?? null,
$validated['remove_media_ids'] ?? null,
$maxPhotos,
required: $required,
errorKey: $errorKey ?? $collection,
s3Keys: $validated['s3_keys'] ?? null,
);
}
}

View File

@ -6,20 +6,18 @@
use App\Models\CashAccount; use App\Models\CashAccount;
use App\Models\CashTransaction; use App\Models\CashTransaction;
use App\Models\User; use App\Models\User;
use App\Services\Concerns\RunsInTransaction;
use App\Services\Concerns\SyncsPhotos;
use App\Services\Media\MediaService; use App\Services\Media\MediaService;
use App\Services\System\PushNotificationService; use App\Services\System\PushNotificationService;
use App\Support\Media\MediaPresenter; use App\Support\Media\MediaPresenter;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
class CashService class CashService
{ {
use RunsInTransaction, SyncsPhotos;
private const MAX_PHOTOS = 1; private const MAX_PHOTOS = 1;
public function __construct( public function __construct(
@ -68,8 +66,8 @@ public function paginateForIndex(CashAccount $cashAccount, array $tableQuery, st
public function deposit(CashAccount $cashAccount, array $validated, User $user): CashTransaction public function deposit(CashAccount $cashAccount, array $validated, User $user): CashTransaction
{ {
$transaction = $this->runInTransaction( try {
function () use ($cashAccount, $validated, $user): CashTransaction { $transaction = DB::transaction(function () use ($cashAccount, $validated, $user): CashTransaction {
$account = CashAccount::query()->lockForUpdate()->findOrFail($cashAccount->id); $account = CashAccount::query()->lockForUpdate()->findOrFail($cashAccount->id);
$amount = (int) $validated['amount']; $amount = (int) $validated['amount'];
$newBalance = $account->balance + $amount; $newBalance = $account->balance + $amount;
@ -85,12 +83,21 @@ function () use ($cashAccount, $validated, $user): CashTransaction {
'created_by_id' => $user->id, 'created_by_id' => $user->id,
]); ]);
$this->syncPhotos($transaction, $validated, self::MAX_PHOTOS); $this->syncPhotos($transaction, $validated);
return $transaction; return $transaction;
}, });
'Gagal melakukan setoran kas', } catch (ValidationException $e) {
); throw $e;
} catch (\Throwable $e) {
Log::error('Gagal melakukan setoran kas: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$this->pushNotificationService->sendToRoles( $this->pushNotificationService->sendToRoles(
'💰 Setoran Kas', '💰 Setoran Kas',
@ -104,8 +111,8 @@ function () use ($cashAccount, $validated, $user): CashTransaction {
public function withdraw(CashAccount $cashAccount, array $validated, User $user): CashTransaction public function withdraw(CashAccount $cashAccount, array $validated, User $user): CashTransaction
{ {
$transaction = $this->runInTransaction( try {
function () use ($cashAccount, $validated, $user): CashTransaction { $transaction = DB::transaction(function () use ($cashAccount, $validated, $user): CashTransaction {
$account = CashAccount::query()->lockForUpdate()->findOrFail($cashAccount->id); $account = CashAccount::query()->lockForUpdate()->findOrFail($cashAccount->id);
$amount = (int) $validated['amount']; $amount = (int) $validated['amount'];
@ -128,12 +135,21 @@ function () use ($cashAccount, $validated, $user): CashTransaction {
'created_by_id' => $user->id, 'created_by_id' => $user->id,
]); ]);
$this->syncPhotos($transaction, $validated, self::MAX_PHOTOS); $this->syncPhotos($transaction, $validated);
return $transaction; return $transaction;
}, });
'Gagal melakukan tarik kas', } catch (ValidationException $e) {
); throw $e;
} catch (\Throwable $e) {
Log::error('Gagal melakukan tarik kas: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$this->pushNotificationService->sendToRoles( $this->pushNotificationService->sendToRoles(
'🏦 Tarik Kas', '🏦 Tarik Kas',
@ -152,8 +168,8 @@ public function recordOutgoing(
User $user, User $user,
?CashAccount $cashAccount = null, ?CashAccount $cashAccount = null,
): CashTransaction { ): CashTransaction {
return $this->runInTransaction( try {
function () use ($reference, $amount, $description, $user, $cashAccount): CashTransaction { return DB::transaction(function () use ($reference, $amount, $description, $user, $cashAccount): CashTransaction {
$account = CashAccount::query()->lockForUpdate()->findOrFail( $account = CashAccount::query()->lockForUpdate()->findOrFail(
($cashAccount ?? $this->getDefaultAccount())->id, ($cashAccount ?? $this->getDefaultAccount())->id,
); );
@ -181,9 +197,18 @@ function () use ($reference, $amount, $description, $user, $cashAccount): CashTr
$transaction->save(); $transaction->save();
return $transaction; return $transaction;
}, });
'Gagal mencatat transaksi keluar kas', } catch (ValidationException $e) {
); throw $e;
} catch (\Throwable $e) {
Log::error('Gagal mencatat transaksi keluar kas: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
} }
public function recordIncoming( public function recordIncoming(
@ -193,8 +218,8 @@ public function recordIncoming(
User $user, User $user,
?CashAccount $cashAccount = null, ?CashAccount $cashAccount = null,
): CashTransaction { ): CashTransaction {
return $this->runInTransaction( try {
function () use ($reference, $amount, $description, $user, $cashAccount): CashTransaction { return DB::transaction(function () use ($reference, $amount, $description, $user, $cashAccount): CashTransaction {
$account = CashAccount::query()->lockForUpdate()->findOrFail( $account = CashAccount::query()->lockForUpdate()->findOrFail(
($cashAccount ?? $this->getDefaultAccount())->id, ($cashAccount ?? $this->getDefaultAccount())->id,
); );
@ -216,15 +241,26 @@ function () use ($reference, $amount, $description, $user, $cashAccount): CashTr
$transaction->save(); $transaction->save();
return $transaction; return $transaction;
}, });
'Gagal mencatat transaksi masuk kas', } catch (ValidationException $e) {
); throw $e;
} catch (\Throwable $e) {
Log::error('Gagal mencatat transaksi masuk kas: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
} }
public function updateDeposit(CashTransaction $transaction, array $validated): void public function updateDeposit(CashTransaction $transaction, array $validated): void
{ {
$this->runInTransaction( $this->ensureEditable($transaction);
function () use ($transaction, $validated): void {
try {
DB::transaction(function () use ($transaction, $validated): void {
CashAccount::query()->lockForUpdate()->findOrFail($transaction->cash_account_id); CashAccount::query()->lockForUpdate()->findOrFail($transaction->cash_account_id);
$transaction->update([ $transaction->update([
@ -232,7 +268,7 @@ function () use ($transaction, $validated): void {
'description' => $validated['description'], 'description' => $validated['description'],
]); ]);
$this->syncPhotos($transaction, $validated, self::MAX_PHOTOS); $this->syncPhotos($transaction, $validated);
$this->recalculateBalances($transaction->cashAccount); $this->recalculateBalances($transaction->cashAccount);
@ -243,9 +279,18 @@ function () use ($transaction, $validated): void {
'amount' => 'Saldo kas tidak mencukupi.', 'amount' => 'Saldo kas tidak mencukupi.',
]); ]);
} }
}, });
'Gagal memperbarui setoran kas', } catch (ValidationException $e) {
); throw $e;
} catch (\Throwable $e) {
Log::error('Gagal memperbarui setoran kas: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$this->pushNotificationService->sendToRoles( $this->pushNotificationService->sendToRoles(
'✏️ Transaksi Kas Diperbarui', '✏️ Transaksi Kas Diperbarui',
@ -257,11 +302,10 @@ function () use ($transaction, $validated): void {
public function deleteTransaction(CashTransaction $transaction): void public function deleteTransaction(CashTransaction $transaction): void
{ {
$amountFormatted = $transaction->amount_formatted; $this->ensureEditable($transaction);
$description = $transaction->description;
$this->runInTransaction( try {
function () use ($transaction): void { DB::transaction(function () use ($transaction): void {
CashAccount::query()->lockForUpdate()->findOrFail($transaction->cash_account_id); CashAccount::query()->lockForUpdate()->findOrFail($transaction->cash_account_id);
$account = $transaction->cashAccount; $account = $transaction->cashAccount;
@ -276,13 +320,22 @@ function () use ($transaction): void {
'transaction' => 'Saldo kas tidak mencukupi jika transaksi ini dihapus.', 'transaction' => 'Saldo kas tidak mencukupi jika transaksi ini dihapus.',
]); ]);
} }
}, });
'Gagal menghapus transaksi kas', } catch (ValidationException $e) {
); throw $e;
} catch (\Throwable $e) {
Log::error('Gagal menghapus transaksi kas: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$this->pushNotificationService->sendToRoles( $this->pushNotificationService->sendToRoles(
'🗑️ Transaksi Kas Dihapus', '🗑️ Transaksi Kas Dihapus',
"Transaksi kas senilai {$amountFormatted} dengan keterangan {$description} telah dihapus.", "Transaksi kas senilai {$transaction->amount_formatted} dengan keterangan {$transaction->description} telah dihapus.",
['owner', 'developer', 'admin-toko'], ['owner', 'developer', 'admin-toko'],
route('admin.finance.cash.index'), route('admin.finance.cash.index'),
); );
@ -293,8 +346,8 @@ public function updateReferencedTransaction(
int $amount, int $amount,
string $description, string $description,
): void { ): void {
$this->runInTransaction( try {
function () use ($transaction, $amount, $description): void { DB::transaction(function () use ($transaction, $amount, $description): void {
CashAccount::query()->lockForUpdate()->findOrFail($transaction->cash_account_id); CashAccount::query()->lockForUpdate()->findOrFail($transaction->cash_account_id);
$transaction->update([ $transaction->update([
@ -311,15 +364,24 @@ function () use ($transaction, $amount, $description): void {
'amount' => 'Saldo kas tidak mencukupi.', 'amount' => 'Saldo kas tidak mencukupi.',
]); ]);
} }
}, });
'Gagal memperbarui transaksi kas referensi', } catch (ValidationException $e) {
); throw $e;
} catch (\Throwable $e) {
Log::error('Gagal memperbarui transaksi kas referensi: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
} }
public function deleteReferencedTransaction(CashTransaction $transaction): void public function deleteReferencedTransaction(CashTransaction $transaction): void
{ {
$this->runInTransaction( try {
function () use ($transaction): void { DB::transaction(function () use ($transaction): void {
CashAccount::query()->lockForUpdate()->findOrFail($transaction->cash_account_id); CashAccount::query()->lockForUpdate()->findOrFail($transaction->cash_account_id);
$account = $transaction->cashAccount; $account = $transaction->cashAccount;
@ -332,11 +394,43 @@ function () use ($transaction): void {
'transaction' => 'Saldo kas tidak mencukupi jika transaksi ini dihapus.', 'transaction' => 'Saldo kas tidak mencukupi jika transaksi ini dihapus.',
]); ]);
} }
}, });
'Gagal menghapus transaksi kas referensi', } catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal menghapus transaksi kas referensi: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
}
private function syncPhotos(CashTransaction $transaction, array $validated): void
{
$this->mediaService->syncCollection(
$transaction,
'photos',
$validated['photos'] ?? null,
$validated['remove_media_ids'] ?? null,
self::MAX_PHOTOS,
required: true,
errorKey: 'photos',
s3Keys: $validated['s3_keys'] ?? null,
); );
} }
private function ensureEditable(CashTransaction $transaction): void
{
if ($transaction->reference_type !== null) {
throw ValidationException::withMessages([
'transaction' => 'Transaksi ini tidak dapat diubah dari halaman kas.',
]);
}
}
private function recalculateBalances(CashAccount $cashAccount): void private function recalculateBalances(CashAccount $cashAccount): void
{ {
$account = CashAccount::query()->lockForUpdate()->findOrFail($cashAccount->id); $account = CashAccount::query()->lockForUpdate()->findOrFail($cashAccount->id);

View File

@ -3,19 +3,22 @@
namespace App\Services\Finance; namespace App\Services\Finance;
use App\Enums\EmployeeAdvanceStatus; use App\Enums\EmployeeAdvanceStatus;
use App\Enums\Permission;
use App\Enums\Role; use App\Enums\Role;
use App\Models\EmployeeAdvance; use App\Models\EmployeeAdvance;
use App\Models\EmployeeAdvancePayment; use App\Models\EmployeeAdvancePayment;
use App\Models\User; use App\Models\User;
use App\Services\Concerns\ResolvesAuthenticatedEmployee; use App\Services\Concerns\ResolvesAuthenticatedEmployee;
use App\Services\Concerns\RunsInTransaction;
use App\Services\System\PushNotificationService; use App\Services\System\PushNotificationService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
class EmployeeAdvanceService class EmployeeAdvanceService
{ {
use ResolvesAuthenticatedEmployee, RunsInTransaction; use ResolvesAuthenticatedEmployee;
public function __construct( public function __construct(
private readonly CashService $cashService, private readonly CashService $cashService,
@ -69,8 +72,8 @@ public function create(array $validated, User $user): void
{ {
$employee = $this->resolveAuthenticatedEmployee($user); $employee = $this->resolveAuthenticatedEmployee($user);
$employeeAdvance = $this->runInTransaction( try {
function () use ($validated, $employee): EmployeeAdvance { $employeeAdvance = DB::transaction(function () use ($validated, $employee) {
return EmployeeAdvance::create([ return EmployeeAdvance::create([
'employee_id' => $employee->id, 'employee_id' => $employee->id,
'amount' => (int) $validated['amount'], 'amount' => (int) $validated['amount'],
@ -78,13 +81,22 @@ function () use ($validated, $employee): EmployeeAdvance {
'due_date' => $validated['due_date'], 'due_date' => $validated['due_date'],
'status' => EmployeeAdvanceStatus::PENDING, 'status' => EmployeeAdvanceStatus::PENDING,
]); ]);
}, });
'Gagal membuat pengajuan kasbon', } catch (ValidationException $e) {
); throw $e;
} catch (\Throwable $e) {
Log::error('Gagal membuat pengajuan kasbon: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$this->pushNotificationService->sendToRoles( $this->pushNotificationService->sendToRoles(
'💰 Pengajuan Kasbon Baru', '💰 Pengajuan Kasbon Baru',
"Karyawan {$user->profile?->full_name} mengajukan kasbon sebesar {$employeeAdvance->amount_formatted} dengan keterangan: {$employeeAdvance->description}.", "Karyawan {$user->profil?->full_name} mengajukan kasbon sebesar {$employeeAdvance->amount_formatted} dengan keterangan: {$employeeAdvance->description}.",
['owner', 'developer', 'direktur'], ['owner', 'developer', 'direktur'],
route('admin.finance.employee_advances.index'), route('admin.finance.employee_advances.index'),
); );
@ -92,16 +104,25 @@ function () use ($validated, $employee): EmployeeAdvance {
public function update(EmployeeAdvance $employeeAdvance, array $validated, User $user): void public function update(EmployeeAdvance $employeeAdvance, array $validated, User $user): void
{ {
$this->runInTransaction( try {
function () use ($employeeAdvance, $validated): void { DB::transaction(function () use ($employeeAdvance, $validated): void {
$employeeAdvance->update([ $employeeAdvance->update([
'amount' => (int) $validated['amount'], 'amount' => (int) $validated['amount'],
'description' => $validated['description'], 'description' => $validated['description'],
'due_date' => $validated['due_date'], 'due_date' => $validated['due_date'],
]); ]);
}, });
'Gagal memperbarui kasbon', } catch (ValidationException $e) {
); throw $e;
} catch (\Throwable $e) {
Log::error('Gagal memperbarui kasbon: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$this->pushNotificationService->sendToRoles( $this->pushNotificationService->sendToRoles(
'✏️ Kasbon Diperbarui', '✏️ Kasbon Diperbarui',
@ -113,19 +134,14 @@ function () use ($employeeAdvance, $validated): void {
public function delete(EmployeeAdvance $employeeAdvance, User $user): void public function delete(EmployeeAdvance $employeeAdvance, User $user): void
{ {
$amountFormatted = $employeeAdvance->amount_formatted; $amount = $employeeAdvance->amount_formatted;
$description = $employeeAdvance->description; $description = $employeeAdvance->description;
$this->runInTransaction(
function () use ($employeeAdvance): void {
$employeeAdvance->delete(); $employeeAdvance->delete();
},
'Gagal menghapus kasbon',
);
$this->pushNotificationService->sendToRoles( $this->pushNotificationService->sendToRoles(
'🗑️ Kasbon Dihapus', '🗑️ Kasbon Dihapus',
"Kasbon sebesar {$amountFormatted} dengan keterangan {$description} telah dihapus.", "Kasbon sebesar {$amount} dengan keterangan {$description} telah dihapus.",
['owner', 'developer', 'direktur'], ['owner', 'developer', 'direktur'],
route('admin.finance.employee_advances.index'), route('admin.finance.employee_advances.index'),
); );
@ -133,8 +149,8 @@ function () use ($employeeAdvance): void {
public function approve(EmployeeAdvance $employeeAdvance, User $user): void public function approve(EmployeeAdvance $employeeAdvance, User $user): void
{ {
$this->runInTransaction( try {
function () use ($employeeAdvance, $user): void { DB::transaction(function () use ($employeeAdvance, $user): void {
$employeeAdvance->loadMissing('employee.user.profile'); $employeeAdvance->loadMissing('employee.user.profile');
$description = sprintf( $description = sprintf(
@ -155,9 +171,18 @@ function () use ($employeeAdvance, $user): void {
'verified_at' => now(), 'verified_at' => now(),
'verified_by_id' => $user->id, 'verified_by_id' => $user->id,
]); ]);
}, });
'Gagal menyetujui kasbon', } catch (ValidationException $e) {
); throw $e;
} catch (\Throwable $e) {
Log::error('Gagal menyetujui kasbon: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$employeeAdvance->loadMissing('employee.user'); $employeeAdvance->loadMissing('employee.user');
if ($employeeAdvance->employee?->user_id) { if ($employeeAdvance->employee?->user_id) {
@ -172,8 +197,8 @@ function () use ($employeeAdvance, $user): void {
public function reject(EmployeeAdvance $employeeAdvance, string $reason, User $user): void public function reject(EmployeeAdvance $employeeAdvance, string $reason, User $user): void
{ {
$this->runInTransaction( try {
function () use ($employeeAdvance, $user, $reason): void { DB::transaction(function () use ($employeeAdvance, $user, $reason): void {
$employeeAdvance->update([ $employeeAdvance->update([
'status' => EmployeeAdvanceStatus::REJECTED, 'status' => EmployeeAdvanceStatus::REJECTED,
'verified_at' => now(), 'verified_at' => now(),
@ -184,9 +209,18 @@ function () use ($employeeAdvance, $user, $reason): void {
'reason' => $reason, 'reason' => $reason,
'rejected_by_id' => $user->id, 'rejected_by_id' => $user->id,
]); ]);
}, });
'Gagal menolak kasbon', } catch (ValidationException $e) {
); throw $e;
} catch (\Throwable $e) {
Log::error('Gagal menolak kasbon: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$employeeAdvance->loadMissing('employee.user'); $employeeAdvance->loadMissing('employee.user');
if ($employeeAdvance->employee?->user_id) { if ($employeeAdvance->employee?->user_id) {
@ -201,8 +235,8 @@ function () use ($employeeAdvance, $user, $reason): void {
public function pay(EmployeeAdvance $employeeAdvance, User $user, ?int $payAmount = null): void public function pay(EmployeeAdvance $employeeAdvance, User $user, ?int $payAmount = null): void
{ {
$this->runInTransaction( try {
function () use ($employeeAdvance, $user, $payAmount): void { DB::transaction(function () use ($employeeAdvance, $user, $payAmount): void {
$employeeAdvance->loadMissing('employee.user.profile'); $employeeAdvance->loadMissing('employee.user.profile');
$remaining = $employeeAdvance->amount - $employeeAdvance->paid_amount; $remaining = $employeeAdvance->amount - $employeeAdvance->paid_amount;
@ -240,19 +274,18 @@ function () use ($employeeAdvance, $user, $payAmount): void {
'description' => $description, 'description' => $description,
'paid_at' => now(), 'paid_at' => now(),
]); ]);
}, });
'Gagal membayar kasbon', } catch (ValidationException $e) {
); throw $e;
} } catch (\Throwable $e) {
Log::error('Gagal melunasi kasbon: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
public function indexPageData(array $tableQuery, User $user, string $status = ''): array throw ValidationException::withMessages([
{ 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
return [ ]);
'employeeAdvances' => $this->paginateForIndex($tableQuery, $user, $status), }
'summary' => $this->outstandingSummary($user),
'authEmployeeId' => $user->employee?->id,
'canSubmit' => EmployeeAdvance::canSubmit($user),
];
} }
private function applySorting(Builder $query, string $sort, string $direction): void private function applySorting(Builder $query, string $sort, string $direction): void
@ -265,4 +298,21 @@ private function applySorting(Builder $query, string $sort, string $direction):
$query->latest(); $query->latest();
} }
public function canSubmit(User $user): bool
{
return $user->can(Permission::EMPLOYEE_ADVANCES_CREATE->value)
&& ! $user->can(Permission::EMPLOYEE_ADVANCES_VERIFY->value)
&& $user->employee !== null;
}
public function indexPageData(array $tableQuery, User $user, string $status = ''): array
{
return [
'employeeAdvances' => $this->paginateForIndex($tableQuery, $user, $status),
'summary' => $this->outstandingSummary($user),
'authEmployeeId' => $user->employee?->id,
'canSubmit' => $this->canSubmit($user),
];
}
} }

View File

@ -4,18 +4,17 @@
use App\Models\Expense; use App\Models\Expense;
use App\Models\User; use App\Models\User;
use App\Services\Concerns\RunsInTransaction;
use App\Services\Concerns\SyncsPhotos;
use App\Services\Media\MediaService; use App\Services\Media\MediaService;
use App\Services\System\PushNotificationService; use App\Services\System\PushNotificationService;
use App\Support\Media\MediaPresenter; use App\Support\Media\MediaPresenter;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
class ExpenseService class ExpenseService
{ {
use RunsInTransaction, SyncsPhotos;
private const MAX_PHOTOS = 1; private const MAX_PHOTOS = 1;
public function __construct( public function __construct(
@ -52,8 +51,8 @@ public function paginateForIndex(array $tableQuery): LengthAwarePaginator
public function create(array $validated, User $user): void public function create(array $validated, User $user): void
{ {
$expense = $this->runInTransaction( try {
function () use ($validated, $user): Expense { $expense = DB::transaction(function () use ($validated, $user): Expense {
$amount = (int) $validated['amount']; $amount = (int) $validated['amount'];
$description = $validated['description']; $description = $validated['description'];
@ -74,12 +73,21 @@ function () use ($validated, $user): Expense {
'cash_transaction_id' => $cashTransaction->id, 'cash_transaction_id' => $cashTransaction->id,
]); ]);
$this->syncPhotos($expense, $validated, self::MAX_PHOTOS); $this->syncPhotos($expense, $validated);
return $expense; return $expense;
}, });
'Gagal membuat pengeluaran', } catch (ValidationException $e) {
); throw $e;
} catch (\Throwable $e) {
Log::error('Gagal membuat pengeluaran: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$this->pushNotificationService->sendToRoles( $this->pushNotificationService->sendToRoles(
'💸 Pengeluaran Baru', '💸 Pengeluaran Baru',
@ -91,8 +99,8 @@ function () use ($validated, $user): Expense {
public function update(Expense $expense, array $validated): void public function update(Expense $expense, array $validated): void
{ {
$this->runInTransaction( try {
function () use ($expense, $validated): void { DB::transaction(function () use ($expense, $validated): void {
$amount = (int) $validated['amount']; $amount = (int) $validated['amount'];
$description = $validated['description']; $description = $validated['description'];
@ -109,10 +117,19 @@ function () use ($expense, $validated): void {
); );
} }
$this->syncPhotos($expense, $validated, self::MAX_PHOTOS); $this->syncPhotos($expense, $validated);
}, });
'Gagal memperbarui pengeluaran', } catch (ValidationException $e) {
); throw $e;
} catch (\Throwable $e) {
Log::error('Gagal memperbarui pengeluaran: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$this->pushNotificationService->sendToRoles( $this->pushNotificationService->sendToRoles(
'✏️ Pengeluaran Diperbarui', '✏️ Pengeluaran Diperbarui',
@ -124,29 +141,49 @@ function () use ($expense, $validated): void {
public function delete(Expense $expense): void public function delete(Expense $expense): void
{ {
$amountFormatted = $expense->amount_formatted; try {
$description = $expense->description; DB::transaction(function () use ($expense): void {
$this->runInTransaction(
function () use ($expense): void {
if ($expense->cashTransaction) { if ($expense->cashTransaction) {
$this->cashService->deleteReferencedTransaction($expense->cashTransaction); $this->cashService->deleteReferencedTransaction($expense->cashTransaction);
} }
$expense->clearMediaCollection('photos'); $expense->clearMediaCollection('photos');
$expense->delete(); $expense->delete();
}, });
'Gagal menghapus pengeluaran', } catch (ValidationException $e) {
); throw $e;
} catch (\Throwable $e) {
Log::error('Gagal menghapus pengeluaran: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$this->pushNotificationService->sendToRoles( $this->pushNotificationService->sendToRoles(
'🗑️ Pengeluaran Dihapus', '🗑️ Pengeluaran Dihapus',
"Pengeluaran sebesar {$amountFormatted} dengan keterangan {$description} telah dihapus.", "Pengeluaran sebesar {$expense->amount_formatted} dengan keterangan {$expense->description} telah dihapus.",
['owner', 'developer', 'direktur'], ['owner', 'developer', 'direktur'],
route('admin.finance.expenses.index'), route('admin.finance.expenses.index'),
); );
} }
private function syncPhotos(Expense $expense, array $validated): void
{
$this->mediaService->syncCollection(
$expense,
'photos',
$validated['photos'] ?? null,
$validated['remove_media_ids'] ?? null,
self::MAX_PHOTOS,
required: true,
errorKey: 'photos',
s3Keys: $validated['s3_keys'] ?? null,
);
}
private function applySorting(Builder $query, string $sort, string $direction): void private function applySorting(Builder $query, string $sort, string $direction): void
{ {
if (in_array($sort, ['created_at', 'amount', 'description'], true)) { if (in_array($sort, ['created_at', 'amount', 'description'], true)) {

View File

@ -13,17 +13,17 @@
use App\Models\PayrollAdjustment; use App\Models\PayrollAdjustment;
use App\Models\PayrollPeriod; use App\Models\PayrollPeriod;
use App\Models\User; use App\Models\User;
use App\Services\Concerns\RunsInTransaction;
use App\Services\System\PushNotificationService; use App\Services\System\PushNotificationService;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
class PayrollService class PayrollService
{ {
use RunsInTransaction;
public function __construct( public function __construct(
private readonly CashService $cashService, private readonly CashService $cashService,
private readonly PushNotificationService $pushNotificationService, private readonly PushNotificationService $pushNotificationService,
@ -108,8 +108,8 @@ public function openCurrentPeriod(?User $closedBy = null): PayrollPeriod
?? User::query()->whereHas('roles', fn (Builder $roleQuery) => $roleQuery->whereIn('name', [Role::DEVELOPER->value, Role::OWNER->value]))->first() ?? User::query()->whereHas('roles', fn (Builder $roleQuery) => $roleQuery->whereIn('name', [Role::DEVELOPER->value, Role::OWNER->value]))->first()
?? User::query()->first(); ?? User::query()->first();
$period = $this->runInTransaction( try {
function () use ($user): PayrollPeriod { $period = DB::transaction(function () use ($user): PayrollPeriod {
$now = now(); $now = now();
$year = $now->year; $year = $now->year;
$month = $now->month; $month = $now->month;
@ -157,9 +157,18 @@ function () use ($user): PayrollPeriod {
$this->generatePayrollsForPeriod($period); $this->generatePayrollsForPeriod($period);
return $period->fresh(); return $period->fresh();
}, });
'Gagal membuka periode payroll', } catch (ValidationException $e) {
); throw $e;
} catch (\Throwable $e) {
Log::error('Gagal membuka periode payroll: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
return $period; return $period;
} }
@ -196,8 +205,9 @@ public function generatePayrollsForPeriod(PayrollPeriod $period): void
public function addAdjustment(Payroll $payroll, array $validated, User $user): void public function addAdjustment(Payroll $payroll, array $validated, User $user): void
{ {
$this->runInTransaction(
function () use ($payroll, $validated, $user): void { try {
DB::transaction(function () use ($payroll, $validated, $user): void {
$payroll->adjustments()->create([ $payroll->adjustments()->create([
'type' => PayrollAdjustmentType::from($validated['type']), 'type' => PayrollAdjustmentType::from($validated['type']),
'amount' => (int) $validated['amount'], 'amount' => (int) $validated['amount'],
@ -208,9 +218,18 @@ function () use ($payroll, $validated, $user): void {
$payroll->load('adjustments'); $payroll->load('adjustments');
$payroll->recalculateAmounts(); $payroll->recalculateAmounts();
$payroll->save(); $payroll->save();
}, });
'Gagal menambahkan penyesuaian gaji', } catch (ValidationException $e) {
); throw $e;
} catch (\Throwable $e) {
Log::error('Gagal menambahkan penyesuaian gaji: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
if ($payroll->employee?->user_id) { if ($payroll->employee?->user_id) {
$typeLabel = PayrollAdjustmentType::from($validated['type'])->label(); $typeLabel = PayrollAdjustmentType::from($validated['type'])->label();
@ -229,8 +248,8 @@ public function updateAdjustment(PayrollAdjustment $adjustment, array $validated
$payroll = $adjustment->payroll; $payroll = $adjustment->payroll;
$payroll->loadMissing(['payrollPeriod', 'employee.user']); $payroll->loadMissing(['payrollPeriod', 'employee.user']);
$this->runInTransaction( try {
function () use ($payroll, $adjustment, $validated): void { DB::transaction(function () use ($payroll, $adjustment, $validated): void {
$adjustment->type = PayrollAdjustmentType::from($validated['type']); $adjustment->type = PayrollAdjustmentType::from($validated['type']);
$adjustment->amount = (int) $validated['amount']; $adjustment->amount = (int) $validated['amount'];
$adjustment->description = $validated['description']; $adjustment->description = $validated['description'];
@ -239,9 +258,18 @@ function () use ($payroll, $adjustment, $validated): void {
$payroll->load('adjustments'); $payroll->load('adjustments');
$payroll->recalculateAmounts(); $payroll->recalculateAmounts();
$payroll->save(); $payroll->save();
}, });
'Gagal memperbarui penyesuaian gaji', } catch (ValidationException $e) {
); throw $e;
} catch (\Throwable $e) {
Log::error('Gagal memperbarui penyesuaian gaji: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
if ($payroll->employee?->user_id) { if ($payroll->employee?->user_id) {
$typeLabel = PayrollAdjustmentType::from($validated['type'])->label(); $typeLabel = PayrollAdjustmentType::from($validated['type'])->label();
@ -260,16 +288,25 @@ public function deleteAdjustment(PayrollAdjustment $adjustment): void
$payroll = $adjustment->payroll; $payroll = $adjustment->payroll;
$payroll->loadMissing(['payrollPeriod', 'employee.user']); $payroll->loadMissing(['payrollPeriod', 'employee.user']);
$this->runInTransaction( try {
function () use ($payroll, $adjustment): void { DB::transaction(function () use ($payroll, $adjustment): void {
$adjustment->delete(); $adjustment->delete();
$payroll->load('adjustments'); $payroll->load('adjustments');
$payroll->recalculateAmounts(); $payroll->recalculateAmounts();
$payroll->save(); $payroll->save();
}, });
'Gagal menghapus penyesuaian gaji', } catch (ValidationException $e) {
); throw $e;
} catch (\Throwable $e) {
Log::error('Gagal menghapus penyesuaian gaji: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
if ($payroll->employee?->user_id) { if ($payroll->employee?->user_id) {
$this->pushNotificationService->sendToUser( $this->pushNotificationService->sendToUser(
@ -286,23 +323,32 @@ public function pay(Payroll $payroll, User $user): void
$payroll->loadMissing(['payrollPeriod', 'employee.user.profile']); $payroll->loadMissing(['payrollPeriod', 'employee.user.profile']);
if ($payroll->total_amount <= 0) { if ($payroll->total_amount <= 0) {
$this->runInTransaction( try {
function () use ($payroll, $user): void { DB::transaction(function () use ($payroll, $user): void {
$payroll->status = PayrollStatus::PAID; $payroll->status = PayrollStatus::PAID;
$payroll->paid_at = now(); $payroll->paid_at = now();
$payroll->paid_by_id = $user->id; $payroll->paid_by_id = $user->id;
$payroll->save(); $payroll->save();
$this->settleKasbonFromPayroll($payroll, $user); $this->settleKasbonFromPayroll($payroll, $user);
}, });
'Gagal membayar gaji (total 0)', } catch (ValidationException $e) {
); throw $e;
} catch (\Throwable $e) {
Log::error('Gagal membayar gaji (total 0): '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
return; return;
} }
$this->runInTransaction( try {
function () use ($payroll, $user): void { DB::transaction(function () use ($payroll, $user): void {
$description = sprintf( $description = sprintf(
'Pembayaran gaji: %s (%s)', 'Pembayaran gaji: %s (%s)',
$payroll->employeeName, $payroll->employeeName,
@ -323,9 +369,18 @@ function () use ($payroll, $user): void {
$payroll->save(); $payroll->save();
$this->settleKasbonFromPayroll($payroll, $user); $this->settleKasbonFromPayroll($payroll, $user);
}, });
'Gagal membayar gaji', } catch (ValidationException $e) {
); throw $e;
} catch (\Throwable $e) {
Log::error('Gagal membayar gaji: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
if ($payroll->employee?->user_id) { if ($payroll->employee?->user_id) {
$this->pushNotificationService->sendToUser( $this->pushNotificationService->sendToUser(

View File

@ -9,7 +9,6 @@
use App\Models\LeaveRequest; use App\Models\LeaveRequest;
use App\Models\User; use App\Models\User;
use App\Services\Concerns\ResolvesAuthenticatedEmployee; use App\Services\Concerns\ResolvesAuthenticatedEmployee;
use App\Services\Concerns\RunsInTransaction;
use App\Services\Media\MediaService; use App\Services\Media\MediaService;
use App\Services\System\PushNotificationService; use App\Services\System\PushNotificationService;
use Carbon\CarbonInterface; use Carbon\CarbonInterface;
@ -19,7 +18,7 @@
class AttendanceService class AttendanceService
{ {
use ResolvesAuthenticatedEmployee, RunsInTransaction; use ResolvesAuthenticatedEmployee;
public function __construct( public function __construct(
private readonly MediaService $mediaService, private readonly MediaService $mediaService,
@ -72,8 +71,6 @@ public function checkIn(array $validated, User $user): void
{ {
$employee = $this->resolveAuthenticatedEmployee($user); $employee = $this->resolveAuthenticatedEmployee($user);
$this->runInTransaction(
function () use ($employee, $validated): void {
$existing = Attendance::query() $existing = Attendance::query()
->where('employee_id', $employee->id) ->where('employee_id', $employee->id)
->whereDate('attendance_date', today()) ->whereDate('attendance_date', today())
@ -99,9 +96,6 @@ function () use ($employee, $validated): void {
'checkin', 'checkin',
'checkin', 'checkin',
); );
},
'Gagal mencatat presensi masuk',
);
$this->pushNotificationService->sendToRoles( $this->pushNotificationService->sendToRoles(
'⏰ Presensi Masuk', '⏰ Presensi Masuk',
@ -115,8 +109,6 @@ public function checkOut(array $validated, User $user): void
{ {
$employee = $this->resolveAuthenticatedEmployee($user); $employee = $this->resolveAuthenticatedEmployee($user);
$workDurationMinutes = $this->runInTransaction(
function () use ($employee, $validated): int {
$attendance = Attendance::query() $attendance = Attendance::query()
->where('employee_id', $employee->id) ->where('employee_id', $employee->id)
->whereDate('attendance_date', today()) ->whereDate('attendance_date', today())
@ -151,11 +143,6 @@ function () use ($employee, $validated): int {
'checkout', 'checkout',
); );
return $workDurationMinutes;
},
'Gagal mencatat presensi pulang',
);
$this->pushNotificationService->sendToRoles( $this->pushNotificationService->sendToRoles(
'⏰ Presensi Pulang', '⏰ Presensi Pulang',
"Karyawan {$user->profile?->full_name} melakukan presensi pulang (Durasi kerja: ".round($workDurationMinutes / 60, 1).' jam).', "Karyawan {$user->profile?->full_name} melakukan presensi pulang (Durasi kerja: ".round($workDurationMinutes / 60, 1).' jam).',
@ -170,18 +157,13 @@ public function delete(Attendance $attendance): void
$employeeName = $attendance->employee?->user?->profile?->full_name; $employeeName = $attendance->employee?->user?->profile?->full_name;
$date = $attendance->attendance_date; $date = $attendance->attendance_date;
$this->runInTransaction(
function () use ($attendance): void {
$attendance->clearMediaCollection('checkin'); $attendance->clearMediaCollection('checkin');
$attendance->clearMediaCollection('checkout'); $attendance->clearMediaCollection('checkout');
$attendance->delete(); $attendance->delete();
},
'Gagal menghapus presensi',
);
$this->pushNotificationService->sendToRoles( $this->pushNotificationService->sendToRoles(
'🗑️ Presensi Dihapus', '🗑️ Presensi Dihapus',
"Data presensi {$employeeName} tanggal {$date?->toDateString()} telah dihapus.", "Data presensi {$employeeName} tanggal {$date} telah dihapus.",
['owner', 'developer', 'direktur'], ['owner', 'developer', 'direktur'],
route('admin.hr.attendances.index'), route('admin.hr.attendances.index'),
); );

View File

@ -6,19 +6,17 @@
use App\Models\Employee; use App\Models\Employee;
use App\Models\User; use App\Models\User;
use App\Models\UserProfile; use App\Models\UserProfile;
use App\Services\Concerns\RunsInTransaction;
use App\Services\Concerns\SyncsPhotos;
use App\Services\Media\MediaService; use App\Services\Media\MediaService;
use App\Support\Media\MediaPresenter; use App\Support\Media\MediaPresenter;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
class EmployeeService class EmployeeService
{ {
use RunsInTransaction, SyncsPhotos;
public function __construct( public function __construct(
private readonly MediaService $mediaService, private readonly MediaService $mediaService,
) {} ) {}
@ -77,8 +75,8 @@ public function findForEdit(User $user): array
public function create(array $validated): void public function create(array $validated): void
{ {
$this->runInTransaction( try {
function () use ($validated): void { DB::transaction(function () use ($validated): void {
$user = User::create([ $user = User::create([
'email' => $validated['email'], 'email' => $validated['email'],
'username' => $validated['username'], 'username' => $validated['username'],
@ -106,17 +104,26 @@ function () use ($validated): void {
} }
$user->syncRoles([$validated['role']]); $user->syncRoles([$validated['role']]);
}, });
'Gagal membuat karyawan', } catch (ValidationException $e) {
); throw $e;
} catch (\Throwable $e) {
Log::error('Gagal membuat karyawan: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
} }
public function update(User $user, array $validated): void public function update(User $user, array $validated): void
{ {
$employee = $user->employee; $employee = $user->employee;
$this->runInTransaction( try {
function () use ($validated, $user, $employee): void { DB::transaction(function () use ($validated, $user, $employee): void {
$user->update([ $user->update([
'email' => $validated['email'], 'email' => $validated['email'],
'username' => $validated['username'], 'username' => $validated['username'],
@ -159,15 +166,22 @@ function () use ($validated, $user, $employee): void {
} }
$user->syncRoles([$validated['role']]); $user->syncRoles([$validated['role']]);
}, });
'Gagal memperbarui karyawan', } catch (ValidationException $e) {
); throw $e;
} catch (\Throwable $e) {
Log::error('Gagal memperbarui karyawan: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
} }
public function toggleStatus(User $user, array $validated): void public function toggleStatus(User $user, array $validated): void
{ {
$this->runInTransaction(
function () use ($user, $validated): void {
$user->update([ $user->update([
'is_active' => $validated['is_active'], 'is_active' => $validated['is_active'],
]); ]);
@ -175,49 +189,50 @@ function () use ($user, $validated): void {
if (! $validated['is_active']) { if (! $validated['is_active']) {
DB::table('sessions')->where('user_id', $user->id)->delete(); DB::table('sessions')->where('user_id', $user->id)->delete();
} }
},
'Gagal memperbarui status karyawan',
);
} }
public function resetPassword(User $user): void public function resetPassword(User $user): void
{ {
$this->runInTransaction(
function () use ($user): void {
$user->update([ $user->update([
'password' => config('auth.password_default'), 'password' => config('auth.password_default'),
]); ]);
DB::table('sessions')->where('user_id', $user->id)->delete(); DB::table('sessions')->where('user_id', $user->id)->delete();
},
'Gagal mereset kata sandi karyawan',
);
} }
public function delete(User $user): void public function delete(User $user): void
{ {
$this->runInTransaction( try {
function () use ($user): void { DB::transaction(function () use ($user): void {
$user->employee?->delete(); $user->employee?->delete();
$user->profile?->delete(); $user->profile?->delete();
$user->delete(); $user->delete();
}, });
'Gagal menghapus karyawan', } catch (ValidationException $e) {
); throw $e;
} catch (\Throwable $e) {
Log::error('Gagal menghapus karyawan: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
} }
private function syncProfilePhoto(UserProfile $profile, array $validated): void private function syncProfilePhoto(UserProfile $profile, array $validated): void
{ {
$this->syncPhotos( $s3Keys = ! empty($validated['profile_s3_key']) ? [$validated['profile_s3_key']] : null;
$removeIds = $validated['remove_profile_photo_ids'] ?? null;
$this->mediaService->syncCollection(
$profile, $profile,
[ 'profile_photo',
'photos' => $validated['profile_photo'] ?? null, null,
'remove_media_ids' => $validated['remove_profile_photo_ids'] ?? null, $removeIds,
's3_keys' => ! empty($validated['profile_s3_key']) ? [$validated['profile_s3_key']] : null, 1,
], s3Keys: $s3Keys,
maxPhotos: 1,
required: false,
collection: 'profile_photo',
); );
} }

View File

@ -3,19 +3,21 @@
namespace App\Services\Hr; namespace App\Services\Hr;
use App\Enums\LeaveRequestStatus; use App\Enums\LeaveRequestStatus;
use App\Enums\Permission;
use App\Models\LeaveRequest; use App\Models\LeaveRequest;
use App\Models\User; use App\Models\User;
use App\Services\Concerns\ResolvesAuthenticatedEmployee; use App\Services\Concerns\ResolvesAuthenticatedEmployee;
use App\Services\Concerns\RunsInTransaction;
use App\Services\System\PushNotificationService; use App\Services\System\PushNotificationService;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
class LeaveRequestService class LeaveRequestService
{ {
use ResolvesAuthenticatedEmployee, RunsInTransaction; use ResolvesAuthenticatedEmployee;
public function __construct( public function __construct(
private readonly PushNotificationService $pushNotificationService, private readonly PushNotificationService $pushNotificationService,
@ -107,12 +109,7 @@ public function delete(LeaveRequest $leaveRequest): void
$totalDays = $leaveRequest->total_days; $totalDays = $leaveRequest->total_days;
$employeeName = $leaveRequest->employee?->user?->profile?->full_name; $employeeName = $leaveRequest->employee?->user?->profile?->full_name;
$this->runInTransaction(
function () use ($leaveRequest): void {
$leaveRequest->delete(); $leaveRequest->delete();
},
'Gagal menghapus pengajuan cuti',
);
$this->pushNotificationService->sendToRoles( $this->pushNotificationService->sendToRoles(
'🗑️ Pengajuan Cuti Dihapus', '🗑️ Pengajuan Cuti Dihapus',
@ -124,16 +121,25 @@ function () use ($leaveRequest): void {
public function approve(LeaveRequest $leaveRequest, User $user): void public function approve(LeaveRequest $leaveRequest, User $user): void
{ {
$this->runInTransaction( try {
function () use ($leaveRequest, $user): void { DB::transaction(function () use ($leaveRequest, $user): void {
$leaveRequest->update([ $leaveRequest->update([
'status' => LeaveRequestStatus::APPROVED, 'status' => LeaveRequestStatus::APPROVED,
'verified_at' => Carbon::now(), 'verified_at' => Carbon::now(),
'verified_by_id' => $user->id, 'verified_by_id' => $user->id,
]); ]);
}, });
'Gagal menyetujui pengajuan cuti', } catch (ValidationException $e) {
); throw $e;
} catch (\Throwable $e) {
Log::error('Gagal menyetujui pengajuan cuti: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$leaveRequest->loadMissing('employee.user'); $leaveRequest->loadMissing('employee.user');
if ($leaveRequest->employee?->user_id) { if ($leaveRequest->employee?->user_id) {
@ -148,8 +154,8 @@ function () use ($leaveRequest, $user): void {
public function reject(LeaveRequest $leaveRequest, string $reason, User $user): void public function reject(LeaveRequest $leaveRequest, string $reason, User $user): void
{ {
$this->runInTransaction( try {
function () use ($leaveRequest, $user, $reason): void { DB::transaction(function () use ($leaveRequest, $user, $reason): void {
$leaveRequest->update([ $leaveRequest->update([
'status' => LeaveRequestStatus::REJECTED, 'status' => LeaveRequestStatus::REJECTED,
'verified_at' => Carbon::now(), 'verified_at' => Carbon::now(),
@ -160,9 +166,18 @@ function () use ($leaveRequest, $user, $reason): void {
'reason' => $reason, 'reason' => $reason,
'rejected_by_id' => $user->id, 'rejected_by_id' => $user->id,
]); ]);
}, });
'Gagal menolak pengajuan cuti', } catch (ValidationException $e) {
); throw $e;
} catch (\Throwable $e) {
Log::error('Gagal menolak pengajuan cuti: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$leaveRequest->loadMissing('employee.user'); $leaveRequest->loadMissing('employee.user');
if ($leaveRequest->employee?->user_id) { if ($leaveRequest->employee?->user_id) {
@ -175,28 +190,6 @@ function () use ($leaveRequest, $user, $reason): void {
} }
} }
public function hasPendingForEmployee(User $user): bool
{
if ($user->employee === null) {
return false;
}
return LeaveRequest::query()
->pending()
->where('employee_id', $user->employee->id)
->exists();
}
public function indexPageData(array $tableQuery, User $user): array
{
return [
'leaveRequests' => $this->paginateForIndex($tableQuery, $user),
'authEmployeeId' => $user->employee?->id,
'canSubmit' => LeaveRequest::canSubmit($user),
'hasPending' => $this->hasPendingForEmployee($user),
];
}
private function calculateTotalDays(Carbon $startDate, Carbon $endDate): int private function calculateTotalDays(Carbon $startDate, Carbon $endDate): int
{ {
return (int) $startDate->diffInDays($endDate) + 1; return (int) $startDate->diffInDays($endDate) + 1;
@ -223,4 +216,33 @@ private function applySorting(Builder $query, string $sort, string $direction):
$query->latest(); $query->latest();
} }
public function hasPendingForEmployee(User $user): bool
{
if ($user->employee === null) {
return false;
}
return LeaveRequest::query()
->pending()
->where('employee_id', $user->employee->id)
->exists();
}
public function canSubmit(User $user): bool
{
return $user->can(Permission::LEAVE_REQUESTS_CREATE->value)
&& ! $user->can(Permission::LEAVE_REQUESTS_VERIFY->value)
&& $user->employee !== null;
}
public function indexPageData(array $tableQuery, User $user): array
{
return [
'leaveRequests' => $this->paginateForIndex($tableQuery, $user),
'authEmployeeId' => $user->employee?->id,
'canSubmit' => $this->canSubmit($user),
'hasPending' => $this->hasPendingForEmployee($user),
];
}
} }

View File

@ -7,7 +7,6 @@
use App\Enums\Role; use App\Enums\Role;
use App\Models\Cutting; use App\Models\Cutting;
use App\Models\CuttingMaterial; use App\Models\CuttingMaterial;
use App\Models\CuttingMaterialCombination;
use App\Models\CuttingResult; use App\Models\CuttingResult;
use App\Models\CuttingResultPrice; use App\Models\CuttingResultPrice;
use App\Models\Product; use App\Models\Product;
@ -15,19 +14,18 @@
use App\Models\RawMaterial; use App\Models\RawMaterial;
use App\Models\RawMaterialPrice; use App\Models\RawMaterialPrice;
use App\Models\User; use App\Models\User;
use App\Services\Concerns\RunsInTransaction;
use App\Services\Media\MediaService; use App\Services\Media\MediaService;
use App\Services\System\PushNotificationService; use App\Services\System\PushNotificationService;
use App\Support\Media\MediaPresenter; use App\Support\Media\MediaPresenter;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
class CuttingService class CuttingService
{ {
use RunsInTransaction;
public function __construct( public function __construct(
private readonly PushNotificationService $pushNotificationService, private readonly PushNotificationService $pushNotificationService,
private readonly MediaService $mediaService, private readonly MediaService $mediaService,
@ -42,7 +40,6 @@ public function paginateForIndex(array $tableQuery, User $user): LengthAwarePagi
'rejection.rejectedBy.profile', 'rejection.rejectedBy.profile',
'materials.rawMaterialPrice.rawMaterial:id,name,unit', 'materials.rawMaterialPrice.rawMaterial:id,name,unit',
'materials.rawMaterialPrice.media', 'materials.rawMaterialPrice.media',
'materials.combination',
'results.productVariant.product:id,name', 'results.productVariant.product:id,name',
'results.productVariant.media', 'results.productVariant.media',
]) ])
@ -86,7 +83,6 @@ public function getInProgressCuttings(User $user): Collection
'rejection.rejectedBy.profile', 'rejection.rejectedBy.profile',
'materials.rawMaterialPrice.rawMaterial:id,name,unit', 'materials.rawMaterialPrice.rawMaterial:id,name,unit',
'materials.rawMaterialPrice.media', 'materials.rawMaterialPrice.media',
'materials.combination',
'results.productVariant.product:id,name', 'results.productVariant.product:id,name',
'results.productVariant.media', 'results.productVariant.media',
]) ])
@ -111,7 +107,6 @@ public function getCompletedCuttings(User $user): Collection
'rejection.rejectedBy.profile', 'rejection.rejectedBy.profile',
'materials.rawMaterialPrice.rawMaterial:id,name,unit', 'materials.rawMaterialPrice.rawMaterial:id,name,unit',
'materials.rawMaterialPrice.media', 'materials.rawMaterialPrice.media',
'materials.combination',
'results.productVariant.product:id,name', 'results.productVariant.product:id,name',
'results.productVariant.media', 'results.productVariant.media',
]) ])
@ -200,7 +195,6 @@ public function findForEdit(Cutting $cutting): Cutting
'media', 'media',
'materials.rawMaterialPrice.rawMaterial:id,name,unit', 'materials.rawMaterialPrice.rawMaterial:id,name,unit',
'materials.rawMaterialPrice.media', 'materials.rawMaterialPrice.media',
'materials.combination',
'results.productVariant.product:id,name', 'results.productVariant.product:id,name',
'results.productVariant.media', 'results.productVariant.media',
'rejection.rejectedBy.profile', 'rejection.rejectedBy.profile',
@ -219,7 +213,6 @@ public function findForShare(Cutting $cutting): Cutting
'createdBy.profile', 'createdBy.profile',
'materials.rawMaterialPrice.rawMaterial:id,name,unit', 'materials.rawMaterialPrice.rawMaterial:id,name,unit',
'materials.rawMaterialPrice.media', 'materials.rawMaterialPrice.media',
'materials.combination',
'results.productVariant.product:id,name', 'results.productVariant.product:id,name',
'results.productVariant.media', 'results.productVariant.media',
]); ]);
@ -261,7 +254,6 @@ public function draftMaterialsForUser(User $user): array
->with([ ->with([
'rawMaterialPrice.rawMaterial:id,name,unit', 'rawMaterialPrice.rawMaterial:id,name,unit',
'rawMaterialPrice.media', 'rawMaterialPrice.media',
'combination',
]) ])
->get() ->get()
->map(function (CuttingMaterial $item) { ->map(function (CuttingMaterial $item) {
@ -301,10 +293,6 @@ public function syncDraftMaterial(array $validated, User $user): array
]); ]);
} }
$materialResult = array_key_exists('material_result', $validated) && $validated['material_result'] !== null
? (int) $validated['material_result']
: null;
$item = CuttingMaterial::query()->updateOrCreate( $item = CuttingMaterial::query()->updateOrCreate(
[ [
'user_id' => $user->id, 'user_id' => $user->id,
@ -313,7 +301,6 @@ public function syncDraftMaterial(array $validated, User $user): array
], ],
[ [
'material_usage' => $materialUsage, 'material_usage' => $materialUsage,
'material_result' => $materialResult,
], ],
); );
@ -409,80 +396,10 @@ public function removeDraftResult(User $user, ProductVariant $productVariant): v
->delete(); ->delete();
} }
/**
* @return list<array>
*/
public function syncDraftCombination(array $validated, User $user): array
{
$materialResult = array_key_exists('material_result', $validated) && $validated['material_result'] !== null
? (int) $validated['material_result']
: null;
$combination = CuttingMaterialCombination::create([
'user_id' => $user->id,
'cutting_id' => null,
'material_result' => $materialResult,
]);
$items = [];
foreach ($validated['materials'] as $index => $materialData) {
$price = RawMaterialPrice::query()
->with('rawMaterial')
->findOrFail($materialData['raw_material_price_id']);
$materialUsage = round((float) $materialData['material_usage'], 2);
if ($materialUsage <= 0) {
throw ValidationException::withMessages([
"materials.{$index}.material_usage" => 'Pemakaian bahan harus lebih dari 0.',
]);
}
$item = CuttingMaterial::create([
'user_id' => $user->id,
'cutting_id' => null,
'raw_material_price_id' => $price->id,
'combination_id' => $combination->id,
'material_usage' => $materialUsage,
]);
$item->load([
'rawMaterialPrice.rawMaterial:id,name,unit',
'rawMaterialPrice.media',
]);
$items[] = $this->presentDraftMaterial($item);
$this->breakMaterialCircularReference($item);
}
return $items;
}
public function removeDraftCombination(User $user, int $combinationId): void
{
$combination = CuttingMaterialCombination::query()
->whereNull('cutting_id')
->where('user_id', $user->id)
->where('id', $combinationId)
->first();
if ($combination === null) {
return;
}
// Hard delete associated materials first (cascadeOnDelete only works with hard delete)
CuttingMaterial::query()
->where('combination_id', $combination->id)
->forceDelete();
$combination->forceDelete();
}
public function create(array $validated, User $user): Cutting public function create(array $validated, User $user): Cutting
{ {
$cutting = $this->runInTransaction( try {
function () use ($validated, $user): Cutting { $cutting = DB::transaction(function () use ($validated, $user): Cutting {
$draftMaterials = $this->draftMaterialsQuery($user) $draftMaterials = $this->draftMaterialsQuery($user)
->with('rawMaterialPrice.rawMaterial') ->with('rawMaterialPrice.rawMaterial')
@ -515,19 +432,6 @@ function () use ($validated, $user): Cutting {
$this->syncImages($cutting, $validated); $this->syncImages($cutting, $validated);
// Transfer draft combinations to cutting
$draftCombinations = CuttingMaterialCombination::query()
->whereNull('cutting_id')
->where('user_id', $user->id)
->lockForUpdate()
->get();
foreach ($draftCombinations as $combination) {
$combination->cutting_id = $cutting->id;
$combination->user_id = null;
$combination->save();
}
foreach ($draftMaterials as $material) { foreach ($draftMaterials as $material) {
$material->cutting_id = $cutting->id; $material->cutting_id = $cutting->id;
$material->user_id = null; $material->user_id = null;
@ -544,9 +448,18 @@ function () use ($validated, $user): Cutting {
$this->deductMaterialStock($cutting); $this->deductMaterialStock($cutting);
return $cutting; return $cutting;
}, });
'Gagal membuat proses cutting', } catch (ValidationException $e) {
); throw $e;
} catch (\Throwable $e) {
Log::error('Gagal membuat proses cutting: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$this->pushNotificationService->sendToRoles( $this->pushNotificationService->sendToRoles(
'✂️ Proses Cutting Baru', '✂️ Proses Cutting Baru',
@ -566,9 +479,9 @@ public function update(Cutting $cutting, array $validated): void
]); ]);
} }
$this->runInTransaction( try {
function () use ($cutting, $validated): void { DB::transaction(function () use ($cutting, $validated): void {
$cutting->load(['materials.rawMaterialPrice.rawMaterial', 'results', 'materials.combination']); $cutting->load(['materials.rawMaterialPrice.rawMaterial', 'results']);
if ($cutting->status === CuttingStatus::IN_PROGRESS) { if ($cutting->status === CuttingStatus::IN_PROGRESS) {
$this->reverseTotalMaterialStock($cutting); $this->reverseTotalMaterialStock($cutting);
@ -578,7 +491,6 @@ function () use ($cutting, $validated): void {
$cutting->materials()->delete(); $cutting->materials()->delete();
$cutting->results()->delete(); $cutting->results()->delete();
$cutting->combinations()->delete();
$materials = $this->buildMaterials($validated['materials']); $materials = $this->buildMaterials($validated['materials']);
$results = $this->buildResults($validated['results']); $results = $this->buildResults($validated['results']);
@ -594,41 +506,8 @@ function () use ($cutting, $validated): void {
$this->syncImages($cutting, $validated); $this->syncImages($cutting, $validated);
// Group materials by combination_id to create combinations
$combinationGroups = [];
foreach ($materials as $materialData) { foreach ($materials as $materialData) {
$combinationId = $materialData['combination_id']; $cutting->materials()->create($materialData);
if ($combinationId !== null) {
if (!isset($combinationGroups[$combinationId])) {
$combinationGroups[$combinationId] = [
'materials' => [],
'material_result' => $materialData['combination_material_result'] ?? null,
];
}
$combinationGroups[$combinationId]['materials'][] = $materialData;
}
}
// Create combinations and update combination_id for materials
$combinationIdMap = [];
foreach ($combinationGroups as $oldCombinationId => $group) {
$combination = $cutting->combinations()->create([
'material_result' => $group['material_result'],
]);
$combinationIdMap[$oldCombinationId] = $combination->id;
}
foreach ($materials as $materialData) {
$newCombinationId = $materialData['combination_id'] !== null
? $combinationIdMap[$materialData['combination_id']] ?? null
: null;
$cutting->materials()->create([
'raw_material_price_id' => $materialData['raw_material_price_id'],
'material_usage' => $materialData['material_usage'],
'material_result' => $materialData['material_result'],
'combination_id' => $newCombinationId,
]);
} }
foreach ($results as $resultData) { foreach ($results as $resultData) {
@ -639,9 +518,18 @@ function () use ($cutting, $validated): void {
$cutting->load('materials.rawMaterialPrice.rawMaterial'); $cutting->load('materials.rawMaterialPrice.rawMaterial');
$this->deductMaterialStock($cutting); $this->deductMaterialStock($cutting);
} }
}, });
'Gagal memperbarui proses cutting', } catch (ValidationException $e) {
); throw $e;
} catch (\Throwable $e) {
Log::error('Gagal memperbarui proses cutting: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$description = $cutting->description ?? '-'; $description = $cutting->description ?? '-';
$this->pushNotificationService->sendToRoles( $this->pushNotificationService->sendToRoles(
@ -662,8 +550,8 @@ public function delete(Cutting $cutting): void
$description = $cutting->description ?? '-'; $description = $cutting->description ?? '-';
$this->runInTransaction( try {
function () use ($cutting): void { DB::transaction(function () use ($cutting): void {
$cutting->load(['materials.rawMaterialPrice.rawMaterial']); $cutting->load(['materials.rawMaterialPrice.rawMaterial']);
if ($cutting->status === CuttingStatus::IN_PROGRESS) { if ($cutting->status === CuttingStatus::IN_PROGRESS) {
@ -676,9 +564,18 @@ function () use ($cutting): void {
$cutting->results()->delete(); $cutting->results()->delete();
$cutting->clearMediaCollection('images'); $cutting->clearMediaCollection('images');
$cutting->delete(); $cutting->delete();
}, });
'Gagal menghapus proses cutting', } catch (ValidationException $e) {
); throw $e;
} catch (\Throwable $e) {
Log::error('Gagal menghapus proses cutting: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$this->pushNotificationService->sendToRoles( $this->pushNotificationService->sendToRoles(
'🗑️ Proses Cutting Dihapus', '🗑️ Proses Cutting Dihapus',
@ -703,8 +600,8 @@ public function transitionStatus(
]); ]);
} }
$this->runInTransaction( try {
function () use ($cutting, $status, $verificationNote, $results, $resultPrices, $user, $reason): void { DB::transaction(function () use ($cutting, $status, $verificationNote, $results, $resultPrices, $user, $reason): void {
$cutting->load(['materials.rawMaterialPrice', 'results']); $cutting->load(['materials.rawMaterialPrice', 'results']);
if ($status === CuttingStatus::COMPLETED) { if ($status === CuttingStatus::COMPLETED) {
@ -745,9 +642,18 @@ function () use ($cutting, $status, $verificationNote, $results, $resultPrices,
$cutting->status = $status; $cutting->status = $status;
$cutting->save(); $cutting->save();
}, });
'Gagal mengubah status proses cutting', } catch (ValidationException $e) {
); throw $e;
} catch (\Throwable $e) {
Log::error('Gagal mengubah status proses cutting: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$description = $cutting->description ?? '-'; $description = $cutting->description ?? '-';
$message = match ($status) { $message = match ($status) {
@ -800,16 +706,9 @@ private function buildMaterials(array $materials): array
]); ]);
} }
$materialResult = array_key_exists('material_result', $itemData) && $itemData['material_result'] !== null
? (int) $itemData['material_result']
: null;
return [ return [
'raw_material_price_id' => $price->id, 'raw_material_price_id' => $price->id,
'material_usage' => $materialUsage, 'material_usage' => $materialUsage,
'material_result' => $materialResult,
'combination_id' => $itemData['combination_id'] ?? null,
'combination_material_result' => $itemData['combination_material_result'] ?? null,
]; ];
}) })
->all(); ->all();
@ -959,12 +858,6 @@ private function breakMaterialCircularReference(CuttingMaterial $material): void
{ {
$price = $material->rawMaterialPrice; $price = $material->rawMaterialPrice;
// Always set these attributes regardless of price
$material->setAttribute('material_result', $material->material_result);
$material->setAttribute('material_result_input', $material->material_result);
$material->setAttribute('combination_id', $material->combination_id);
$material->setAttribute('combination_material_result', $material->combination?->material_result);
if ($price) { if ($price) {
$rawMaterial = $price->rawMaterial; $rawMaterial = $price->rawMaterial;
@ -986,7 +879,6 @@ private function breakMaterialCircularReference(CuttingMaterial $material): void
} }
$material->unsetRelation('rawMaterialPrice'); $material->unsetRelation('rawMaterialPrice');
$material->unsetRelation('combination');
} }
private function applySorting(Builder $query, string $sort, string $direction): void private function applySorting(Builder $query, string $sort, string $direction): void
@ -1018,7 +910,6 @@ private function presentDraftMaterial(CuttingMaterial $item): array
{ {
$price = $item->rawMaterialPrice; $price = $item->rawMaterialPrice;
$rawMaterial = $price?->rawMaterial; $rawMaterial = $price?->rawMaterial;
$combination = $item->combination;
return [ return [
'raw_material_price_id' => $item->raw_material_price_id, 'raw_material_price_id' => $item->raw_material_price_id,
@ -1028,10 +919,7 @@ private function presentDraftMaterial(CuttingMaterial $item): array
'unit_abbreviation' => $rawMaterial?->unit?->abbreviation() ?? '', 'unit_abbreviation' => $rawMaterial?->unit?->abbreviation() ?? '',
'stock_input' => $price?->stock_input ?? '', 'stock_input' => $price?->stock_input ?? '',
'material_usage' => $this->formatQuantityInput((float) $item->material_usage), 'material_usage' => $this->formatQuantityInput((float) $item->material_usage),
'material_result' => $item->material_result !== null ? (int) $item->material_result : null,
'images' => $price ? MediaPresenter::collection($price, 'images') : [], 'images' => $price ? MediaPresenter::collection($price, 'images') : [],
'combination_id' => $item->combination_id,
'combination_material_result' => $combination?->material_result,
]; ];
} }

View File

@ -13,7 +13,6 @@
use App\Models\Product; use App\Models\Product;
use App\Models\ProductVariant; use App\Models\ProductVariant;
use App\Models\User; use App\Models\User;
use App\Services\Concerns\RunsInTransaction;
use App\Services\Finance\CashService; use App\Services\Finance\CashService;
use App\Services\Media\MediaService; use App\Services\Media\MediaService;
use App\Services\System\PushNotificationService; use App\Services\System\PushNotificationService;
@ -23,12 +22,12 @@
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
class OrderService class OrderService
{ {
use RunsInTransaction;
private const MAX_PHOTOS = 1; private const MAX_PHOTOS = 1;
public function __construct( public function __construct(
@ -389,6 +388,7 @@ public function resyncDraftPrices(User $user, string $priceTypeValue): array
public function create(array $validated, User $user): Order public function create(array $validated, User $user): Order
{ {
try {
// Force cashier settings // Force cashier settings
if ($user->hasRole('cashier')) { if ($user->hasRole('cashier')) {
$validated['channel'] = 'store'; $validated['channel'] = 'store';
@ -397,8 +397,7 @@ public function create(array $validated, User $user): Order
unset($validated['customer_id']); unset($validated['customer_id']);
} }
$order = $this->runInTransaction( $order = DB::transaction(function () use ($validated, $user): Order {
function () use ($validated, $user): Order {
$priceType = $this->resolvePriceType($validated['channel'], $validated['price_type']); $priceType = $this->resolvePriceType($validated['channel'], $validated['price_type']);
$draftItems = $this->draftItemsQuery($user) $draftItems = $this->draftItemsQuery($user)
@ -483,9 +482,18 @@ function () use ($validated, $user): Order {
} }
return $order; return $order;
}, });
'Gagal membuat pesanan', } catch (ValidationException $e) {
); throw $e;
} catch (\Throwable $e) {
Log::error('Gagal membuat pesanan: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$this->pushNotificationService->sendToRoles( $this->pushNotificationService->sendToRoles(
'📦 Pesanan Baru', '📦 Pesanan Baru',
@ -501,8 +509,8 @@ public function update(Order $order, array $validated): void
{ {
$order->ensureEditable(); $order->ensureEditable();
$this->runInTransaction( try {
function () use ($order, $validated): void { DB::transaction(function () use ($order, $validated): void {
$order->load('items'); $order->load('items');
foreach ($order->items as $item) { foreach ($order->items as $item) {
@ -572,9 +580,18 @@ function () use ($order, $validated): void {
$orderItem = $order->items()->create($itemData); $orderItem = $order->items()->create($itemData);
$this->decrementStock($orderItem); $this->decrementStock($orderItem);
} }
}, });
'Gagal memperbarui pesanan', } catch (ValidationException $e) {
); throw $e;
} catch (\Throwable $e) {
Log::error('Gagal memperbarui pesanan: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$this->pushNotificationService->sendToRoles( $this->pushNotificationService->sendToRoles(
'✏️ Pesanan Diperbarui', '✏️ Pesanan Diperbarui',
@ -589,8 +606,8 @@ public function delete(Order $order): void
$orderNumber = $order->order_number; $orderNumber = $order->order_number;
$totalAmount = $order->total_amount; $totalAmount = $order->total_amount;
$this->runInTransaction( try {
function () use ($order): void { DB::transaction(function () use ($order): void {
$order->load('items'); $order->load('items');
if ($order->status->isEditable()) { if ($order->status->isEditable()) {
@ -605,9 +622,18 @@ function () use ($order): void {
$order->items()->delete(); $order->items()->delete();
$order->delete(); $order->delete();
}, });
'Gagal menghapus pesanan', } catch (ValidationException $e) {
); throw $e;
} catch (\Throwable $e) {
Log::error('Gagal menghapus pesanan: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$this->pushNotificationService->sendToRoles( $this->pushNotificationService->sendToRoles(
'🗑️ Pesanan Dihapus', '🗑️ Pesanan Dihapus',
@ -625,8 +651,8 @@ public function transitionStatus(Order $order, OrderStatus $status): void
]); ]);
} }
$this->runInTransaction( try {
function () use ($order, $status): void { DB::transaction(function () use ($order, $status): void {
if ($status === OrderStatus::CANCELLED) { if ($status === OrderStatus::CANCELLED) {
$order->load('items'); $order->load('items');
@ -642,9 +668,18 @@ function () use ($order, $status): void {
$order->status = $status; $order->status = $status;
$order->save(); $order->save();
}, });
'Gagal mengubah status pesanan', } catch (ValidationException $e) {
); throw $e;
} catch (\Throwable $e) {
Log::error('Gagal mengubah status pesanan: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$this->pushNotificationService->sendToRoles( $this->pushNotificationService->sendToRoles(
'📦 Status Pesanan Diubah', '📦 Status Pesanan Diubah',

View File

@ -12,19 +12,18 @@
use App\Models\RawMaterialPrice; use App\Models\RawMaterialPrice;
use App\Models\Supplier; use App\Models\Supplier;
use App\Models\User; use App\Models\User;
use App\Services\Concerns\RunsInTransaction;
use App\Services\Media\MediaService; use App\Services\Media\MediaService;
use App\Services\System\PushNotificationService; use App\Services\System\PushNotificationService;
use App\Support\Media\MediaPresenter; use App\Support\Media\MediaPresenter;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
class PurchaseService class PurchaseService
{ {
use RunsInTransaction;
private const MAX_PHOTOS = 1; private const MAX_PHOTOS = 1;
public function __construct( public function __construct(
@ -253,8 +252,8 @@ public function create(array $validated, User $user): Purchase
{ {
$isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value); $isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value);
$purchase = $this->runInTransaction( try {
function () use ($validated, $user, $isOwner): Purchase { $purchase = DB::transaction(function () use ($validated, $user, $isOwner): Purchase {
$draftItems = $this->draftItemsQuery($user) $draftItems = $this->draftItemsQuery($user)
->lockForUpdate() ->lockForUpdate()
@ -310,9 +309,18 @@ function () use ($validated, $user, $isOwner): Purchase {
} }
return $purchase; return $purchase;
}, });
'Gagal membuat pembelian', } catch (ValidationException $e) {
); throw $e;
} catch (\Throwable $e) {
Log::error('Gagal membuat pembelian: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
if (! $isOwner) { if (! $isOwner) {
$this->notifyForPendingRequest( $this->notifyForPendingRequest(
@ -332,8 +340,8 @@ public function update(Purchase $purchase, array $validated, User $user): void
$isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value); $isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value);
$purchase->load(['supplier', 'items.rawMaterialPrice.rawMaterial:id,name']); $purchase->load(['supplier', 'items.rawMaterialPrice.rawMaterial:id,name']);
$this->runInTransaction( try {
function () use ($purchase, $validated, $user, $isOwner): void { DB::transaction(function () use ($purchase, $validated, $user, $isOwner): void {
if ($isOwner) { if ($isOwner) {
$payload = $this->buildPayloadFromValidated($validated); $payload = $this->buildPayloadFromValidated($validated);
$this->applyPayloadToPurchase($purchase, $payload); $this->applyPayloadToPurchase($purchase, $payload);
@ -358,9 +366,18 @@ function () use ($purchase, $validated, $user, $isOwner): void {
$this->syncRequestPhotos($verificationRequest, $validated); $this->syncRequestPhotos($verificationRequest, $validated);
} }
} }
}, });
'Gagal memperbarui pembelian', } catch (ValidationException $e) {
); throw $e;
} catch (\Throwable $e) {
Log::error('Gagal memperbarui pembelian: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
if (! $isOwner) { if (! $isOwner) {
$purchase->load('supplier'); $purchase->load('supplier');
@ -387,8 +404,7 @@ public function delete(Purchase $purchase, User $user): void
$purchase->load(['supplier', 'items.rawMaterialPrice.rawMaterial:id,name']); $purchase->load(['supplier', 'items.rawMaterialPrice.rawMaterial:id,name']);
$this->runInTransaction( try {
function () use ($purchase, $user): void {
OwnerVerificationRequest::create([ OwnerVerificationRequest::create([
'action' => OwnerVerificationAction::DELETE, 'action' => OwnerVerificationAction::DELETE,
'status' => OwnerVerificationStatus::PENDING, 'status' => OwnerVerificationStatus::PENDING,
@ -400,9 +416,15 @@ function () use ($purchase, $user): void {
'new' => null, 'new' => null,
], ],
]); ]);
}, } catch (\Throwable $e) {
'Gagal mengajukan penghapusan belanja', Log::error('Gagal mengajukan penghapusan belanja: '.$e->getMessage(), [
); 'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$purchase->load('supplier'); $purchase->load('supplier');
@ -593,20 +615,16 @@ private function rejectCreate(OwnerVerificationRequest $verificationRequest): vo
return; return;
} }
$this->runInTransaction( DB::transaction(function () use ($purchase): void {
function () use ($purchase): void {
$purchase->clearMediaCollection('photos'); $purchase->clearMediaCollection('photos');
$purchase->items()->delete(); $purchase->items()->delete();
$purchase->delete(); $purchase->delete();
}, });
'Gagal menolak belanja',
);
} }
private function executeDelete(Purchase $purchase): void private function executeDelete(Purchase $purchase): void
{ {
$this->runInTransaction( DB::transaction(function () use ($purchase): void {
function () use ($purchase): void {
$purchase->load('items'); $purchase->load('items');
foreach ($purchase->items as $item) { foreach ($purchase->items as $item) {
@ -616,9 +634,7 @@ function () use ($purchase): void {
$purchase->clearMediaCollection('photos'); $purchase->clearMediaCollection('photos');
$purchase->items()->delete(); $purchase->items()->delete();
$purchase->delete(); $purchase->delete();
}, });
'Gagal menghapus belanja',
);
} }
private function applyPayloadToPurchase( private function applyPayloadToPurchase(
@ -626,8 +642,7 @@ private function applyPayloadToPurchase(
array $payload, array $payload,
?OwnerVerificationRequest $verificationRequest = null, ?OwnerVerificationRequest $verificationRequest = null,
): void { ): void {
$this->runInTransaction( DB::transaction(function () use ($purchase, $payload, $verificationRequest): void {
function () use ($purchase, $payload, $verificationRequest): void {
$purchase->load('items'); $purchase->load('items');
foreach ($purchase->items as $item) { foreach ($purchase->items as $item) {
@ -658,9 +673,7 @@ function () use ($purchase, $payload, $verificationRequest): void {
if ($verificationRequest !== null) { if ($verificationRequest !== null) {
$this->applyRequestPhotos($verificationRequest, $purchase, $payload); $this->applyRequestPhotos($verificationRequest, $purchase, $payload);
} }
}, });
'Gagal memperbarui belanja',
);
} }
private function snapshotPurchase(Purchase $purchase): array private function snapshotPurchase(Purchase $purchase): array

View File

@ -10,18 +10,17 @@
use App\Models\Product; use App\Models\Product;
use App\Models\ProductVariant; use App\Models\ProductVariant;
use App\Models\User; use App\Models\User;
use App\Services\Concerns\RunsInTransaction;
use App\Services\Media\MediaService; use App\Services\Media\MediaService;
use App\Services\System\PushNotificationService; use App\Services\System\PushNotificationService;
use App\Support\Media\MediaPresenter; use App\Support\Media\MediaPresenter;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
class ProductService class ProductService
{ {
use RunsInTransaction;
private const MAX_VARIANT_IMAGES = 5; private const MAX_VARIANT_IMAGES = 5;
public function __construct( public function __construct(
@ -120,8 +119,8 @@ public function create(array $validated, User $user): void
{ {
$isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value); $isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value);
$this->runInTransaction( try {
function () use ($validated, $user, $isOwner): void { DB::transaction(function () use ($validated, $user, $isOwner): void {
$product = Product::create([ $product = Product::create([
'name' => $validated['name'], 'name' => $validated['name'],
'description' => $validated['description'] ?? null, 'description' => $validated['description'] ?? null,
@ -162,9 +161,18 @@ function () use ($validated, $user, $isOwner): void {
], ],
]); ]);
} }
}, });
'Gagal membuat produk', } catch (ValidationException $e) {
); throw $e;
} catch (\Throwable $e) {
Log::error('Gagal membuat produk: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
if (! $isOwner) { if (! $isOwner) {
$this->notifyForPendingRequest( $this->notifyForPendingRequest(
@ -181,8 +189,8 @@ public function update(Product $product, array $validated, User $user): void
{ {
$isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value); $isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value);
$this->runInTransaction( try {
function () use ($validated, $product, $user, $isOwner): void { DB::transaction(function () use ($validated, $product, $user, $isOwner): void {
if ($isOwner) { if ($isOwner) {
$payload = $this->enrichPayload($this->buildPayloadFromValidated($validated)); $payload = $this->enrichPayload($this->buildPayloadFromValidated($validated));
$this->applyPayloadToProduct($product, $payload); $this->applyPayloadToProduct($product, $payload);
@ -236,9 +244,18 @@ function () use ($validated, $product, $user, $isOwner): void {
$this->syncRequestVariantImages($verificationRequest, $variantData, $index, required: $isNewVariant); $this->syncRequestVariantImages($verificationRequest, $variantData, $index, required: $isNewVariant);
} }
} }
}, });
'Gagal memperbarui produk', } catch (ValidationException $e) {
); throw $e;
} catch (\Throwable $e) {
Log::error('Gagal memperbarui produk: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
if (! $isOwner) { if (! $isOwner) {
$this->notifyForPendingRequest( $this->notifyForPendingRequest(
@ -261,8 +278,7 @@ public function delete(Product $product, User $user): void
return; return;
} }
$this->runInTransaction( try {
function () use ($product, $user): void {
OwnerVerificationRequest::create([ OwnerVerificationRequest::create([
'action' => OwnerVerificationAction::DELETE, 'action' => OwnerVerificationAction::DELETE,
'status' => OwnerVerificationStatus::PENDING, 'status' => OwnerVerificationStatus::PENDING,
@ -274,9 +290,15 @@ function () use ($product, $user): void {
'new' => null, 'new' => null,
], ],
]); ]);
}, } catch (\Throwable $e) {
'Gagal mengajukan penghapusan produk', Log::error('Gagal mengajukan penghapusan produk: '.$e->getMessage(), [
); 'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$this->notifyForPendingRequest( $this->notifyForPendingRequest(
$user, $user,
@ -299,8 +321,7 @@ public function toggleStatus(Product $product, array $validated, User $user): vo
return; return;
} }
$this->runInTransaction( try {
function () use ($product, $validated, $user): void {
OwnerVerificationRequest::create([ OwnerVerificationRequest::create([
'action' => OwnerVerificationAction::TOGGLE_STATUS, 'action' => OwnerVerificationAction::TOGGLE_STATUS,
'status' => OwnerVerificationStatus::PENDING, 'status' => OwnerVerificationStatus::PENDING,
@ -318,9 +339,15 @@ function () use ($product, $validated, $user): void {
], ],
], ],
]); ]);
}, } catch (\Throwable $e) {
'Gagal mengajukan perubahan status produk', Log::error('Gagal mengajukan perubahan status produk: '.$e->getMessage(), [
); 'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$statusLabel = $validated['is_active'] ? 'aktif' : 'nonaktif'; $statusLabel = $validated['is_active'] ? 'aktif' : 'nonaktif';
@ -490,17 +517,14 @@ public function applyDelete(OwnerVerificationRequest $verificationRequest): void
private function applyDeleteSubject(Product $product): void private function applyDeleteSubject(Product $product): void
{ {
$this->runInTransaction( DB::transaction(function () use ($product): void {
function () use ($product): void {
$product->variants()->each(function (ProductVariant $variant): void { $product->variants()->each(function (ProductVariant $variant): void {
$variant->clearMediaCollection('images'); $variant->clearMediaCollection('images');
}); });
$product->variants()->delete(); $product->variants()->delete();
$product->categories()->detach(); $product->categories()->detach();
$product->delete(); $product->delete();
}, });
'Gagal menghapus produk',
);
} }
public function applyToggleStatus(OwnerVerificationRequest $verificationRequest): void public function applyToggleStatus(OwnerVerificationRequest $verificationRequest): void
@ -550,17 +574,14 @@ private function rejectCreate(OwnerVerificationRequest $verificationRequest): vo
return; return;
} }
$this->runInTransaction( DB::transaction(function () use ($product): void {
function () use ($product): void {
$product->variants()->each(function (ProductVariant $variant): void { $product->variants()->each(function (ProductVariant $variant): void {
$variant->clearMediaCollection('images'); $variant->clearMediaCollection('images');
}); });
$product->variants()->delete(); $product->variants()->delete();
$product->categories()->detach(); $product->categories()->detach();
$product->delete(); $product->delete();
}, });
'Gagal menolak produk',
);
} }
private function rollbackUpdate(OwnerVerificationRequest $verificationRequest): void private function rollbackUpdate(OwnerVerificationRequest $verificationRequest): void

View File

@ -10,18 +10,17 @@
use App\Models\RawMaterial; use App\Models\RawMaterial;
use App\Models\RawMaterialPrice; use App\Models\RawMaterialPrice;
use App\Models\User; use App\Models\User;
use App\Services\Concerns\RunsInTransaction;
use App\Services\Media\MediaService; use App\Services\Media\MediaService;
use App\Services\System\PushNotificationService; use App\Services\System\PushNotificationService;
use App\Support\Media\MediaPresenter; use App\Support\Media\MediaPresenter;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
class RawMaterialService class RawMaterialService
{ {
use RunsInTransaction;
private const MAX_VARIANT_IMAGES = 5; private const MAX_VARIANT_IMAGES = 5;
public function __construct( public function __construct(
@ -115,8 +114,8 @@ public function create(array $validated, User $user): void
{ {
$isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value); $isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value);
$this->runInTransaction( try {
function () use ($validated, $user, $isOwner): void { DB::transaction(function () use ($validated, $user, $isOwner): void {
$rawMaterial = RawMaterial::create([ $rawMaterial = RawMaterial::create([
'name' => $validated['name'], 'name' => $validated['name'],
'unit' => $validated['unit'], 'unit' => $validated['unit'],
@ -140,9 +139,18 @@ function () use ($validated, $user, $isOwner): void {
], ],
]); ]);
} }
}, });
'Gagal membuat bahan baku', } catch (ValidationException $e) {
); throw $e;
} catch (\Throwable $e) {
Log::error('Gagal membuat bahan baku: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
if (! $isOwner) { if (! $isOwner) {
$this->notifyForPendingRequest( $this->notifyForPendingRequest(
@ -159,8 +167,8 @@ public function update(RawMaterial $rawMaterial, array $validated, User $user):
{ {
$isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value); $isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value);
$this->runInTransaction( try {
function () use ($validated, $rawMaterial, $user, $isOwner): void { DB::transaction(function () use ($validated, $rawMaterial, $user, $isOwner): void {
if ($isOwner) { if ($isOwner) {
$payload = $this->enrichPayload($this->buildPayloadFromValidated($validated)); $payload = $this->enrichPayload($this->buildPayloadFromValidated($validated));
$this->applyPayloadToRawMaterial($rawMaterial, $payload); $this->applyPayloadToRawMaterial($rawMaterial, $payload);
@ -193,9 +201,18 @@ function () use ($validated, $rawMaterial, $user, $isOwner): void {
$this->syncRequestPriceImages($verificationRequest, $priceData, $index, required: $isNewPrice); $this->syncRequestPriceImages($verificationRequest, $priceData, $index, required: $isNewPrice);
} }
} }
}, });
'Gagal memperbarui bahan baku', } catch (ValidationException $e) {
); throw $e;
} catch (\Throwable $e) {
Log::error('Gagal memperbarui bahan baku: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
if (! $isOwner) { if (! $isOwner) {
$this->notifyForPendingRequest( $this->notifyForPendingRequest(
@ -218,8 +235,7 @@ public function delete(RawMaterial $rawMaterial, User $user): void
return; return;
} }
$this->runInTransaction( try {
function () use ($rawMaterial, $user): void {
OwnerVerificationRequest::create([ OwnerVerificationRequest::create([
'action' => OwnerVerificationAction::DELETE, 'action' => OwnerVerificationAction::DELETE,
'status' => OwnerVerificationStatus::PENDING, 'status' => OwnerVerificationStatus::PENDING,
@ -231,9 +247,15 @@ function () use ($rawMaterial, $user): void {
'new' => null, 'new' => null,
], ],
]); ]);
}, } catch (\Throwable $e) {
'Gagal mengajukan penghapusan bahan baku', Log::error('Gagal mengajukan penghapusan bahan baku: '.$e->getMessage(), [
); 'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$this->notifyForPendingRequest( $this->notifyForPendingRequest(
$user, $user,
@ -256,8 +278,7 @@ public function toggleStatus(RawMaterial $rawMaterial, array $validated, User $u
return; return;
} }
$this->runInTransaction( try {
function () use ($rawMaterial, $validated, $user): void {
OwnerVerificationRequest::create([ OwnerVerificationRequest::create([
'action' => OwnerVerificationAction::TOGGLE_STATUS, 'action' => OwnerVerificationAction::TOGGLE_STATUS,
'status' => OwnerVerificationStatus::PENDING, 'status' => OwnerVerificationStatus::PENDING,
@ -275,9 +296,15 @@ function () use ($rawMaterial, $validated, $user): void {
], ],
], ],
]); ]);
}, } catch (\Throwable $e) {
'Gagal mengajukan perubahan status bahan baku', Log::error('Gagal mengajukan perubahan status bahan baku: '.$e->getMessage(), [
); 'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$statusLabel = $validated['is_active'] ? 'aktif' : 'nonaktif'; $statusLabel = $validated['is_active'] ? 'aktif' : 'nonaktif';
@ -391,16 +418,13 @@ public function applyDelete(OwnerVerificationRequest $verificationRequest): void
private function applyDeleteSubject(RawMaterial $rawMaterial): void private function applyDeleteSubject(RawMaterial $rawMaterial): void
{ {
$this->runInTransaction( DB::transaction(function () use ($rawMaterial): void {
function () use ($rawMaterial): void {
$rawMaterial->prices()->each(function (RawMaterialPrice $price): void { $rawMaterial->prices()->each(function (RawMaterialPrice $price): void {
$price->clearMediaCollection('images'); $price->clearMediaCollection('images');
}); });
$rawMaterial->prices()->delete(); $rawMaterial->prices()->delete();
$rawMaterial->delete(); $rawMaterial->delete();
}, });
'Gagal menghapus bahan baku',
);
} }
public function applyToggleStatus(OwnerVerificationRequest $verificationRequest): void public function applyToggleStatus(OwnerVerificationRequest $verificationRequest): void
@ -486,16 +510,13 @@ private function rejectCreate(OwnerVerificationRequest $verificationRequest): vo
return; return;
} }
$this->runInTransaction( DB::transaction(function () use ($rawMaterial): void {
function () use ($rawMaterial): void {
$rawMaterial->prices()->each(function (RawMaterialPrice $price): void { $rawMaterial->prices()->each(function (RawMaterialPrice $price): void {
$price->clearMediaCollection('images'); $price->clearMediaCollection('images');
}); });
$rawMaterial->prices()->delete(); $rawMaterial->prices()->delete();
$rawMaterial->delete(); $rawMaterial->delete();
}, });
'Gagal menolak bahan baku',
);
} }
private function rollbackUpdate(OwnerVerificationRequest $verificationRequest): void private function rollbackUpdate(OwnerVerificationRequest $verificationRequest): void

View File

@ -3,15 +3,12 @@
namespace App\Services\System\Setting; namespace App\Services\System\Setting;
use App\Models\HomepageConfiguration; use App\Models\HomepageConfiguration;
use App\Services\Concerns\RunsInTransaction;
use App\Services\Concerns\SyncsPhotos;
use App\Services\Media\MediaService; use App\Services\Media\MediaService;
use App\Support\Media\MediaPresenter; use App\Support\Media\MediaPresenter;
use Illuminate\Http\UploadedFile;
class HomepageSettingService class HomepageSettingService
{ {
use RunsInTransaction, SyncsPhotos;
public function __construct( public function __construct(
private readonly MediaService $mediaService, private readonly MediaService $mediaService,
) {} ) {}
@ -34,45 +31,30 @@ public function homepageData(): array
public function updateHomepage(array $validated): void public function updateHomepage(array $validated): void
{ {
$this->runInTransaction(
function () use ($validated): void {
$configuration = HomepageConfiguration::instance(); $configuration = HomepageConfiguration::instance();
$this->syncPhotos( if (isset($validated['hero_image_s3_key'])) {
$configuration, $this->mediaService->replaceSingleS3Key($configuration, $validated['hero_image_s3_key'], 'hero_image', 'hero-image');
[ } elseif (isset($validated['hero_image']) && $validated['hero_image'] instanceof UploadedFile) {
'photos' => isset($validated['hero_image']) ? [$validated['hero_image']] : null, $this->mediaService->replaceSingleFile($configuration, $validated['hero_image'], 'hero_image', 'hero-image');
's3_keys' => isset($validated['hero_image_s3_key']) ? [$validated['hero_image_s3_key']] : null, }
],
maxPhotos: 1,
required: false,
collection: 'hero_image',
);
$this->syncPhotos( if (isset($validated['about_image_s3_key'])) {
$configuration, $this->mediaService->replaceSingleS3Key($configuration, $validated['about_image_s3_key'], 'about_image', 'about-image');
[ } elseif (isset($validated['about_image']) && $validated['about_image'] instanceof UploadedFile) {
'photos' => isset($validated['about_image']) ? [$validated['about_image']] : null, $this->mediaService->replaceSingleFile($configuration, $validated['about_image'], 'about_image', 'about-image');
's3_keys' => isset($validated['about_image_s3_key']) ? [$validated['about_image_s3_key']] : null, }
],
maxPhotos: 1,
required: false,
collection: 'about_image',
);
$this->syncPhotos( if (isset($validated['gallery_s3_keys']) || isset($validated['gallery_images_remove'])) {
$this->mediaService->syncCollection(
$configuration, $configuration,
[ 'gallery',
'photos' => $validated['gallery_images'] ?? null, null,
'remove_media_ids' => $validated['gallery_images_remove'] ?? null, $validated['gallery_images_remove'] ?? null,
's3_keys' => $validated['gallery_s3_keys'] ?? null, 10,
], 'gallery',
maxPhotos: 10, s3Keys: $validated['gallery_s3_keys'] ?? null,
required: false,
collection: 'gallery',
);
},
'Gagal memperbarui pengaturan homepage',
); );
} }
} }
}

View File

@ -8,7 +8,6 @@
use App\Enums\Permission; use App\Enums\Permission;
use App\Models\OwnerVerificationRequest; use App\Models\OwnerVerificationRequest;
use App\Models\User; use App\Models\User;
use App\Services\Concerns\RunsInTransaction;
use App\Services\System\PushNotificationService; use App\Services\System\PushNotificationService;
use App\Settings\MarketplaceSettings; use App\Settings\MarketplaceSettings;
use App\Support\Marketplace\MarketplaceFeeCalculator; use App\Support\Marketplace\MarketplaceFeeCalculator;
@ -17,8 +16,6 @@
class MarketplaceService class MarketplaceService
{ {
use RunsInTransaction;
public function __construct( public function __construct(
private readonly PushNotificationService $pushNotificationService, private readonly PushNotificationService $pushNotificationService,
) {} ) {}
@ -75,8 +72,6 @@ public function marketplaceData(): array
public function updateMarketplace(array $validated, User $user): void public function updateMarketplace(array $validated, User $user): void
{ {
$this->runInTransaction(
function () use ($validated, $user): void {
if ($user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)) { if ($user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)) {
$this->saveSettings($validated); $this->saveSettings($validated);
@ -133,9 +128,6 @@ function () use ($validated, $user): void {
$user->id, $user->id,
route('admin.system.settings.index'), route('admin.system.settings.index'),
); );
},
'Gagal memperbarui pengaturan marketplace',
);
} }
public function saveSettings(array $validated): void public function saveSettings(array $validated): void
@ -155,13 +147,8 @@ public function saveSettings(array $validated): void
public function applyVerificationRequest(OwnerVerificationRequest $request): void public function applyVerificationRequest(OwnerVerificationRequest $request): void
{ {
$this->runInTransaction(
function () use ($request): void {
$newPayload = $request->payload['new'] ?? []; $newPayload = $request->payload['new'] ?? [];
$this->saveSettings($newPayload); $this->saveSettings($newPayload);
},
'Gagal menerapkan pengajuan verifikasi owner',
);
} }
public function buildOrderSnapshot(OrderChannel $channel, int $totalAmount, array $lineItems, bool $isAffiliate = false): ?array public function buildOrderSnapshot(OrderChannel $channel, int $totalAmount, array $lineItems, bool $isAffiliate = false): ?array

View File

@ -3,17 +3,14 @@
namespace App\Services\System\Setting; namespace App\Services\System\Setting;
use App\Models\SystemConfiguration; use App\Models\SystemConfiguration;
use App\Services\Concerns\RunsInTransaction;
use App\Services\Concerns\SyncsPhotos;
use App\Services\Media\MediaService; use App\Services\Media\MediaService;
use App\Settings\SystemSettings; use App\Settings\SystemSettings;
use App\Support\Media\MediaPresenter; use App\Support\Media\MediaPresenter;
use Illuminate\Http\UploadedFile;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
class SystemService class SystemService
{ {
use RunsInTransaction, SyncsPhotos;
public function __construct( public function __construct(
private readonly MediaService $mediaService, private readonly MediaService $mediaService,
) {} ) {}
@ -42,8 +39,6 @@ public function systemData(): array
public function updateSystem(array $validated): void public function updateSystem(array $validated): void
{ {
$this->runInTransaction(
function () use ($validated): void {
$settings = app(SystemSettings::class); $settings = app(SystemSettings::class);
$configuration = SystemConfiguration::instance(); $configuration = SystemConfiguration::instance();
@ -54,38 +49,23 @@ function () use ($validated): void {
$settings->address = $validated['address'] ?? null; $settings->address = $validated['address'] ?? null;
$settings->save(); $settings->save();
$this->syncPhotos( if (isset($validated['logo_s3_key'])) {
$configuration, $this->mediaService->replaceSingleS3Key($configuration, $validated['logo_s3_key'], 'logo', 'logo');
[ } elseif (isset($validated['logo']) && $validated['logo'] instanceof UploadedFile) {
'photos' => isset($validated['logo']) ? [$validated['logo']] : null, $this->mediaService->replaceSingleFile($configuration, $validated['logo'], 'logo', 'logo');
's3_keys' => isset($validated['logo_s3_key']) ? [$validated['logo_s3_key']] : null, }
],
maxPhotos: 1,
required: false,
collection: 'logo',
);
$this->syncPhotos( if (isset($validated['favicon_s3_key'])) {
$configuration, $this->mediaService->replaceSingleS3Key($configuration, $validated['favicon_s3_key'], 'favicon', 'favicon');
[ } elseif (isset($validated['favicon']) && $validated['favicon'] instanceof UploadedFile) {
'photos' => isset($validated['favicon']) ? [$validated['favicon']] : null, $this->mediaService->replaceSingleFile($configuration, $validated['favicon'], 'favicon', 'favicon');
's3_keys' => isset($validated['favicon_s3_key']) ? [$validated['favicon_s3_key']] : null, }
],
maxPhotos: 1,
required: false,
collection: 'favicon',
);
$this->syncPhotos( if (isset($validated['login_cover_s3_key'])) {
$configuration, $this->mediaService->replaceSingleS3Key($configuration, $validated['login_cover_s3_key'], 'login_cover', 'login-cover');
[ } elseif (isset($validated['login_cover']) && $validated['login_cover'] instanceof UploadedFile) {
'photos' => isset($validated['login_cover']) ? [$validated['login_cover']] : null, $this->mediaService->replaceSingleFile($configuration, $validated['login_cover'], 'login_cover', 'login-cover');
's3_keys' => isset($validated['login_cover_s3_key']) ? [$validated['login_cover_s3_key']] : null, }
],
maxPhotos: 1,
required: false,
collection: 'login_cover',
);
$errors = []; $errors = [];
@ -104,8 +84,5 @@ function () use ($validated): void {
if ($errors !== []) { if ($errors !== []) {
throw ValidationException::withMessages($errors); throw ValidationException::withMessages($errors);
} }
},
'Gagal memperbarui pengaturan sistem',
);
} }
} }

View File

@ -1,42 +0,0 @@
<?php
namespace App\Support\ActivityLog;
use Spatie\Activitylog\Support\ActivityBuffer as SpatieActivityBuffer;
use Spatie\Activitylog\Support\Config;
class ActivityBuffer extends SpatieActivityBuffer
{
public function flush(): void
{
if (empty($this->pending)) {
return;
}
$records = $this->pending;
// Collect all unique keys from all records
$allKeys = [];
foreach ($records as $record) {
foreach ($record as $key => $val) {
$allKeys[$key] = true;
}
}
// Normalize records so that every record has the exact same keys in the same order
$normalizedRecords = [];
foreach ($records as $record) {
$normalized = [];
foreach ($allKeys as $key => $_) {
$normalized[$key] = array_key_exists($key, $record) ? $record[$key] : null;
}
$normalizedRecords[] = $normalized;
}
$modelClass = Config::activityModel();
$modelClass::query()->insert($normalizedRecords);
$this->pending = [];
}
}

View File

@ -16,7 +16,6 @@
return Application::configure(basePath: dirname(__DIR__)) return Application::configure(basePath: dirname(__DIR__))
->withRouting( ->withRouting(
web: __DIR__.'/../routes/web.php', web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php', commands: __DIR__.'/../routes/console.php',
health: '/up', health: '/up',
) )
@ -43,8 +42,4 @@
$schedule->command('payroll:open-period') $schedule->command('payroll:open-period')
->monthlyOn(1, '00:05') ->monthlyOn(1, '00:05')
->timezone('Asia/Jakarta'); ->timezone('Asia/Jakarta');
$schedule->command('attendance:apply-penalties')
->dailyAt('23:55')
->timezone('Asia/Jakarta');
})->create(); })->create();

View File

@ -1,23 +0,0 @@
<?php
namespace Database\Factories;
use App\Models\Cutting;
use App\Models\CuttingMaterialCombination;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<CuttingMaterialCombination>
*/
class CuttingMaterialCombinationFactory extends Factory
{
protected $model = CuttingMaterialCombination::class;
public function definition(): array
{
return [
'cutting_id' => Cutting::factory(),
'user_id' => null,
];
}
}

View File

@ -1,23 +0,0 @@
<?php
namespace Database\Factories;
use App\Enums\PriceType;
use App\Models\ProductPrice;
use App\Models\ProductVariant;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<ProductPrice>
*/
class ProductPriceFactory extends Factory
{
public function definition(): array
{
return [
'variant_id' => ProductVariant::factory(),
'type' => fake()->randomElement(PriceType::cases()),
'price' => fake()->numberBetween(50000, 500000),
];
}
}

View File

@ -17,7 +17,6 @@ public function definition(): array
'product_id' => Product::factory(), 'product_id' => Product::factory(),
'name' => fake()->randomElement(['S', 'M', 'L', 'XL', 'All Size']), 'name' => fake()->randomElement(['S', 'M', 'L', 'XL', 'All Size']),
'stock' => fake()->numberBetween(0, 100), 'stock' => fake()->numberBetween(0, 100),
'retail_stock' => fake()->numberBetween(0, 50),
'reject_stock' => fake()->numberBetween(0, 20), 'reject_stock' => fake()->numberBetween(0, 20),
]; ];
} }

View File

@ -1,27 +0,0 @@
<?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('cutting_material_combinations', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->nullable()->constrained()->cascadeOnDelete();
$table->foreignId('cutting_id')->nullable()->constrained()->cascadeOnDelete();
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();
$table->softDeletes();
});
}
public function down(): void
{
Schema::dropIfExists('cutting_material_combinations');
}
};

View File

@ -1,22 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('cutting_materials', function (Blueprint $table): void {
$table->foreignId('combination_id')->nullable()->after('raw_material_price_id')->constrained('cutting_material_combinations')->cascadeOnDelete();
});
}
public function down(): void
{
Schema::table('cutting_materials', function (Blueprint $table): void {
$table->dropConstrainedForeignId('combination_id');
});
}
};

View File

@ -1,28 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('cutting_materials', function (Blueprint $table) {
$table->decimal('material_result', 18, 2)->nullable()->after('material_usage');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('cutting_materials', function (Blueprint $table) {
$table->dropColumn('material_result');
});
}
};

View File

@ -1,28 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('cutting_materials', function (Blueprint $table) {
$table->integer('material_result')->nullable()->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('cutting_materials', function (Blueprint $table) {
$table->decimal('material_result', 18, 2)->nullable()->change();
});
}
};

View File

@ -1,28 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('cutting_material_combinations', function (Blueprint $table) {
$table->integer('material_result')->nullable()->after('cutting_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('cutting_material_combinations', function (Blueprint $table) {
$table->dropColumn('material_result');
});
}
};

View File

@ -24,7 +24,7 @@ interface MenuItem {
title: string; title: string;
href: string; href: string;
icon: any; icon: any;
permission?: string | string[]; permission?: string;
badgeKey?: 'pendingLeaveRequests' | 'pendingEmployeeAdvances' | 'pendingCuttings'; badgeKey?: 'pendingLeaveRequests' | 'pendingEmployeeAdvances' | 'pendingCuttings';
} }
@ -82,18 +82,7 @@ const menuGroups: MenuGroup[] = [
label: 'Sistem', label: 'Sistem',
items: [ items: [
{ title: 'Role & Permission', href: admin.system.roles.index.url(), icon: Shield, permission: 'roles.view' }, { title: 'Role & Permission', href: admin.system.roles.index.url(), icon: Shield, permission: 'roles.view' },
{ { title: 'Pengaturan', href: admin.system.settings.index.url(), icon: Settings2, permission: 'settings.view' },
title: 'Pengaturan',
href: admin.system.settings.index.url(),
icon: Settings2,
permission: [
'settings.view_system',
'settings.view_social_media',
'settings.view_marketplace',
'settings.view_hr',
'settings.view_homepage',
],
},
{ title: 'Log Aktivitas', href: admin.system.activity_logs.index.url(), icon: History, permission: 'activity_logs.view' }, { title: 'Log Aktivitas', href: admin.system.activity_logs.index.url(), icon: History, permission: 'activity_logs.view' },
], ],
}, },

View File

@ -7,16 +7,12 @@ defineProps<{
tooltip?: string; tooltip?: string;
disabled?: boolean; disabled?: boolean;
}>(); }>();
const emit = defineEmits<{
click: [];
}>();
</script> </script>
<template> <template>
<Tooltip> <Tooltip>
<TooltipTrigger as-child> <TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="size-8" :disabled="disabled" @click="emit('click')"> <Button variant="ghost" size="icon" class="size-8" :disabled="disabled">
<SlidersHorizontal class="size-4" /> <SlidersHorizontal class="size-4" />
<span class="sr-only">{{ tooltip || 'Sesuaikan' }}</span> <span class="sr-only">{{ tooltip || 'Sesuaikan' }}</span>
</Button> </Button>

View File

@ -4,6 +4,7 @@ import type { ColumnDef } from '@tanstack/vue-table';
import { FlexRender, getCoreRowModel, useVueTable } from '@tanstack/vue-table'; import { FlexRender, getCoreRowModel, useVueTable } from '@tanstack/vue-table';
import { computed, provide } from 'vue'; import { computed, provide } from 'vue';
import DataTableToolbar from '@/components/data-table/DataTableToolbar.vue'; import DataTableToolbar from '@/components/data-table/DataTableToolbar.vue';
import { buildPaginationSummary } from '@/lib/grouped-table';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Table, Table,
@ -14,7 +15,6 @@ import {
TableRow, TableRow,
} from '@/components/ui/table'; } from '@/components/ui/table';
import TableEmpty from '@/components/ui/table/TableEmpty.vue'; import TableEmpty from '@/components/ui/table/TableEmpty.vue';
import { buildPaginationSummary } from '@/lib/grouped-table';
import type { import type {
DataTableFilterDef, DataTableFilterDef,
DataTablePagination, DataTablePagination,

View File

@ -7,11 +7,7 @@ export function useCan() {
const permissions = computed(() => page.props.auth.user?.permissions ?? []); const permissions = computed(() => page.props.auth.user?.permissions ?? []);
const roles = computed(() => page.props.auth.user?.roles ?? []); const roles = computed(() => page.props.auth.user?.roles ?? []);
function can(permission: string | string[]): boolean { function can(permission: string): boolean {
if (Array.isArray(permission)) {
return permission.some((p) => permissions.value.includes(p));
}
return permissions.value.includes(permission); return permissions.value.includes(permission);
} }

View File

@ -26,10 +26,8 @@ export function useDestroy({ url, preserveScroll = true, errorMessage, onSuccess
onError: (errors) => { onError: (errors) => {
if (onError) { if (onError) {
const result = onError(errors); const result = onError(errors);
if (typeof result === 'string') { if (typeof result === 'string') {
toast.error(result); toast.error(result);
return; return;
} }
} }

View File

@ -1,5 +1,4 @@
import { computed, toValue } from 'vue'; import { computed, type MaybeRefOrGetter, toValue } from 'vue';
import type {MaybeRefOrGetter} from 'vue';
import { buildPaginationSummary } from '@/lib/grouped-table'; import { buildPaginationSummary } from '@/lib/grouped-table';
import type { DataTablePagination } from '@/types/data-table'; import type { DataTablePagination } from '@/types/data-table';

View File

@ -1,5 +1,5 @@
import { Check, RotateCcw, Scissors, X } from '@lucide/vue';
import type { Component } from 'vue'; import type { Component } from 'vue';
import { Check, RotateCcw, Scissors, X } from '@lucide/vue';
import type { BadgeVariant } from '@/lib/badge-variant'; import type { BadgeVariant } from '@/lib/badge-variant';
export const CuttingStatus = { export const CuttingStatus = {

View File

@ -1,5 +1,5 @@
import { Check, Send, X } from '@lucide/vue';
import type { Component } from 'vue'; import type { Component } from 'vue';
import { Check, Send, X } from '@lucide/vue';
import type { BadgeVariant } from '@/lib/badge-variant'; import type { BadgeVariant } from '@/lib/badge-variant';
export const OrderStatus = { export const OrderStatus = {

View File

@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { Home, Settings2, Share2, ShoppingBag, Users } from '@lucide/vue';
import { computed } from 'vue'; import { computed } from 'vue';
import { useCan } from '@/composables/useCan'; import { Home, Settings2, Share2, ShoppingBag, Users } from '@lucide/vue';
import { SettingSection as SettingSectionConst } from '@/constants/setting-section'; import { SettingSection as SettingSectionConst } from '@/constants/setting-section';
import { useCan } from '@/composables/useCan';
import AdminLayout from '@/layouts/AdminLayout.vue'; import AdminLayout from '@/layouts/AdminLayout.vue';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { SettingSection } from '@/types/setting'; import type { SettingSection } from '@/types/setting';
@ -23,7 +23,6 @@ const filteredNavItems = computed(() => {
if (hasRole('admin-toko')) { if (hasRole('admin-toko')) {
return navItems.filter((item) => item.key === SettingSectionConst.MARKETPLACE); return navItems.filter((item) => item.key === SettingSectionConst.MARKETPLACE);
} }
return navItems; return navItems;
}); });
</script> </script>

View File

@ -212,7 +212,6 @@ const donutSegmentSelector = Donut.selectors.segment;
function channelTooltip(arc: any) { function channelTooltip(arc: any) {
const d = arc.data as typeof props.orderStats.by_channel[number]; const d = arc.data as typeof props.orderStats.by_channel[number];
return `<div class="rounded-lg border bg-background px-3 py-1.5 shadow-xl" style="line-height:1.2"> return `<div class="rounded-lg border bg-background px-3 py-1.5 shadow-xl" style="line-height:1.2">
<p style="margin:0;display:flex;align-items:center;gap:0.5rem"> <p style="margin:0;display:flex;align-items:center;gap:0.5rem">
<span class="size-2.5 rounded-full" style="background-color: ${channelChartConfig.value[d.channel]?.color ?? channelColors[0]}"></span> <span class="size-2.5 rounded-full" style="background-color: ${channelChartConfig.value[d.channel]?.color ?? channelColors[0]}"></span>
@ -224,7 +223,6 @@ function channelTooltip(arc: any) {
function paymentTooltip(arc: any) { function paymentTooltip(arc: any) {
const d = arc.data as typeof props.orderStats.by_payment_type[number]; const d = arc.data as typeof props.orderStats.by_payment_type[number];
return `<div class="rounded-lg border bg-background px-3 py-1.5 shadow-xl" style="line-height:1.2"> return `<div class="rounded-lg border bg-background px-3 py-1.5 shadow-xl" style="line-height:1.2">
<p style="margin:0;display:flex;align-items:center;gap:0.5rem"> <p style="margin:0;display:flex;align-items:center;gap:0.5rem">
<span class="size-2.5 rounded-full" style="background-color: ${paymentChartConfig.value[d.payment_type]?.color ?? paymentColors[0]}"></span> <span class="size-2.5 rounded-full" style="background-color: ${paymentChartConfig.value[d.payment_type]?.color ?? paymentColors[0]}"></span>
@ -237,7 +235,6 @@ function paymentTooltip(arc: any) {
function marketingTooltip(arc: any) { function marketingTooltip(arc: any) {
const d = arc.data as typeof props.orderStats.by_marketing[number]; const d = arc.data as typeof props.orderStats.by_marketing[number];
const idx = props.orderStats.by_marketing.indexOf(d); const idx = props.orderStats.by_marketing.indexOf(d);
return `<div class="rounded-lg border bg-background px-3 py-1.5 shadow-xl" style="line-height:1.2"> return `<div class="rounded-lg border bg-background px-3 py-1.5 shadow-xl" style="line-height:1.2">
<p style="margin:0;display:flex;align-items:center;gap:0.5rem"> <p style="margin:0;display:flex;align-items:center;gap:0.5rem">
<span class="size-2.5 rounded-full" style="background-color: ${marketingColors[idx % marketingColors.length]}"></span> <span class="size-2.5 rounded-full" style="background-color: ${marketingColors[idx % marketingColors.length]}"></span>
@ -249,7 +246,6 @@ function marketingTooltip(arc: any) {
function statusTooltip(arc: any) { function statusTooltip(arc: any) {
const d = arc.data as typeof props.orderStats.by_status[number]; const d = arc.data as typeof props.orderStats.by_status[number];
return `<div class="rounded-lg border bg-background px-3 py-1.5 shadow-xl" style="line-height:1.2"> return `<div class="rounded-lg border bg-background px-3 py-1.5 shadow-xl" style="line-height:1.2">
<p style="margin:0;display:flex;align-items:center;gap:0.5rem"> <p style="margin:0;display:flex;align-items:center;gap:0.5rem">
<span class="size-2.5 rounded-full" style="background-color: ${statusChartConfig.value[d.status]?.color ?? statusColors[0]}"></span> <span class="size-2.5 rounded-full" style="background-color: ${statusChartConfig.value[d.status]?.color ?? statusColors[0]}"></span>

View File

@ -1,4 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type {
ChartConfig,
} from "@/components/ui/chart"
import { TrendingUp } from "@lucide/vue" import { TrendingUp } from "@lucide/vue"
import { CurveType } from "@unovis/ts" import { CurveType } from "@unovis/ts"
@ -11,9 +14,6 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card" } from "@/components/ui/card"
import type {
ChartConfig,
} from "@/components/ui/chart"
import { import {
ChartContainer, ChartContainer,
ChartCrosshair, ChartCrosshair,

View File

@ -17,9 +17,9 @@ import { AppearanceMode as AppearanceModeConst } from '@/constants/appearance-mo
import AccountLayout from '@/layouts/AccountLayout.vue'; import AccountLayout from '@/layouts/AccountLayout.vue';
import { formErrors } from '@/lib/form'; import { formErrors } from '@/lib/form';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { update } from '@/routes/admin/account/appearance';
import type { AppearanceFormData, AppearanceMode } from '@/types/account'; import type { AppearanceFormData, AppearanceMode } from '@/types/account';
import { update } from '@/routes/admin/account/appearance';
const props = defineProps<{ const props = defineProps<{
appearance: AppearanceMode; appearance: AppearanceMode;

View File

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3'; import { Head, useForm } from '@inertiajs/vue3';
import { Save } from '@lucide/vue'; import { Save } from '@lucide/vue';
import { computed, ref } from 'vue'; import { ref } from 'vue';
import { toast } from 'vue-sonner'; import { toast } from 'vue-sonner';
import { PhoneNumberInput } from '@/components/form/phone-number-input'; import { PhoneNumberInput } from '@/components/form/phone-number-input';
import MediaDropzone from '@/components/media/MediaDropzone.vue'; import MediaDropzone from '@/components/media/MediaDropzone.vue';

View File

@ -6,19 +6,15 @@ import { DataTable } from '@/components/data-table';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useCan } from '@/composables/useCan'; import { useCan } from '@/composables/useCan';
import {
useDataTableQuery,
useDataTableQuerySync,
} from '@/composables/useDataTableQuery';
import { CashReferenceType } from '@/constants/cash-reference-type'; import { CashReferenceType } from '@/constants/cash-reference-type';
import { CashTransactionType } from '@/constants/cash-transaction-type'; import { CashTransactionType } from '@/constants/cash-transaction-type';
import { useDataTableQuery, useDataTableQuerySync } from '@/composables/useDataTableQuery';
import AdminLayout from '@/layouts/AdminLayout.vue'; import AdminLayout from '@/layouts/AdminLayout.vue';
import { index } from '@/routes/admin/finance/cash';
import type { CashAccount, CashTransactionListItem, PaginatedCashTransactions } from '@/types/cash'; import type { CashAccount, CashTransactionListItem, PaginatedCashTransactions } from '@/types/cash';
import type { DataTableFilterDef, DataTableSort } from '@/types/data-table'; import type { DataTableFilterDef, DataTableSort } from '@/types/data-table';
import CashTransactionFormModal from './form/CashTransactionFormModal.vue'; import CashTransactionFormModal from './form/CashTransactionFormModal.vue';
import { createColumns } from './table/columns'; import { createColumns } from './table/columns';
import { index } from '@/routes/admin/finance/cash';
const props = defineProps<{ const props = defineProps<{
cashAccount: CashAccount; cashAccount: CashAccount;
@ -120,13 +116,11 @@ watch(
</div> </div>
<div class="flex shrink-0 items-center gap-2 self-start sm:self-center"> <div class="flex shrink-0 items-center gap-2 self-start sm:self-center">
<Button v-if="can('cash.deposit')" variant="outline" <Button v-if="can('cash.deposit')" variant="outline" @click="openCreateModal(CashTransactionType.DEPOSIT)">
@click="openCreateModal(CashTransactionType.DEPOSIT)">
<ArrowDownCircle class="size-4" /> <ArrowDownCircle class="size-4" />
Setor Kas Setor Kas
</Button> </Button>
<Button v-if="can('cash.withdraw')" variant="outline" <Button v-if="can('cash.withdraw')" variant="outline" @click="openCreateModal(CashTransactionType.WITHDRAWAL)">
@click="openCreateModal(CashTransactionType.WITHDRAWAL)">
<ArrowUpCircle class="size-4" /> <ArrowUpCircle class="size-4" />
Tarik Kas Tarik Kas
</Button> </Button>
@ -148,7 +142,7 @@ watch(
</Card> </Card>
<Card class="min-w-0"> <Card class="min-w-0">
<CardContent class="min-w-0"> <CardContent class="min-w-0 pt-6">
<DataTable v-model:search="search" :columns="columns" :data="transactions.data" :pagination="pagination" <DataTable v-model:search="search" :columns="columns" :data="transactions.data" :pagination="pagination"
:pagination-links="transactions.links" :sort="currentSort" :filter-defs="filterDefs" :pagination-links="transactions.links" :sort="currentSort" :filter-defs="filterDefs"
:filter-values="filterValues" @sort-change="setSort" @filter-change="setFilter" :filter-values="filterValues" @sort-change="setSort" @filter-change="setFilter"

View File

@ -2,8 +2,8 @@
import { computed } from 'vue'; import { computed } from 'vue';
import { RowDeleteAction, RowEditAction } from '@/components/button'; import { RowDeleteAction, RowEditAction } from '@/components/button';
import { useCan } from '@/composables/useCan'; import { useCan } from '@/composables/useCan';
import { destroy } from '@/routes/admin/finance/cash/transactions';
import type { CashTransactionListItem } from '@/types/cash'; import type { CashTransactionListItem } from '@/types/cash';
import { destroy } from '@/routes/admin/finance/cash/transactions';
const props = defineProps<{ const props = defineProps<{
transaction: CashTransactionListItem; transaction: CashTransactionListItem;

View File

@ -1,7 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { HandCoins } from '@lucide/vue';
import { computed, ref, watch } from 'vue';
import CreateButton from '@/components/button/CreateButton.vue'; import CreateButton from '@/components/button/CreateButton.vue';
import { DataTable } from '@/components/data-table'; import { DataTable } from '@/components/data-table';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@ -12,16 +9,19 @@ import {
} from '@/composables/useDataTableQuery'; } from '@/composables/useDataTableQuery';
import { EmployeeAdvanceStatus } from '@/constants/employee-advance-status'; import { EmployeeAdvanceStatus } from '@/constants/employee-advance-status';
import AdminLayout from '@/layouts/AdminLayout.vue'; import AdminLayout from '@/layouts/AdminLayout.vue';
import { index } from '@/routes/admin/finance/employee_advances';
import type { DataTableFilterDef, DataTableSort } from '@/types/data-table'; import type { DataTableFilterDef, DataTableSort } from '@/types/data-table';
import type { import type {
EmployeeAdvanceListItem, EmployeeAdvanceListItem,
EmployeeAdvancePageProps, EmployeeAdvancePageProps,
} from '@/types/employee-advance'; } from '@/types/employee-advance';
import { Head } from '@inertiajs/vue3';
import { HandCoins } from '@lucide/vue';
import { computed, ref, watch } from 'vue';
import EmployeeAdvanceFormModal from './form/EmployeeAdvanceFormModal.vue'; import EmployeeAdvanceFormModal from './form/EmployeeAdvanceFormModal.vue';
import RejectEmployeeAdvanceModal from './form/RejectEmployeeAdvanceModal.vue'; import RejectEmployeeAdvanceModal from './form/RejectEmployeeAdvanceModal.vue';
import { createColumns } from './table/columns'; import { createColumns } from './table/columns';
import { index } from '@/routes/admin/finance/employee_advances';
const props = defineProps<EmployeeAdvancePageProps>(); const props = defineProps<EmployeeAdvancePageProps>();

View File

@ -1,6 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { computed, ref, watch } from 'vue';
import CreateButton from '@/components/button/CreateButton.vue'; import CreateButton from '@/components/button/CreateButton.vue';
import { DataTable } from '@/components/data-table'; import { DataTable } from '@/components/data-table';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
@ -10,12 +8,14 @@ import {
useDataTableQuerySync, useDataTableQuerySync,
} from '@/composables/useDataTableQuery'; } from '@/composables/useDataTableQuery';
import AdminLayout from '@/layouts/AdminLayout.vue'; import AdminLayout from '@/layouts/AdminLayout.vue';
import { index } from '@/routes/admin/finance/expenses';
import type { DataTableSort } from '@/types/data-table'; import type { DataTableSort } from '@/types/data-table';
import type { ExpenseListItem, PaginatedExpenses } from '@/types/expense'; import type { ExpenseListItem, PaginatedExpenses } from '@/types/expense';
import { Head } from '@inertiajs/vue3';
import { computed, ref, watch } from 'vue';
import ExpenseFormModal from './form/ExpenseFormModal.vue'; import ExpenseFormModal from './form/ExpenseFormModal.vue';
import { createColumns } from './table/columns'; import { createColumns } from './table/columns';
import { index } from '@/routes/admin/finance/expenses';
const props = defineProps<{ const props = defineProps<{
expenses: PaginatedExpenses; expenses: PaginatedExpenses;

View File

@ -26,10 +26,11 @@ import { FIELD_LIMITS } from '@/lib/field-limits';
import { formErrors } from '@/lib/form'; import { formErrors } from '@/lib/form';
import { parseRupiah } from '@/lib/rupiah'; import { parseRupiah } from '@/lib/rupiah';
import { store, update } from '@/routes/admin/finance/expenses'; import { store, update } from '@/routes/admin/finance/expenses';
import type { ExpenseFormData, ExpenseListItem } from '@/types/expense'; import type { ExpenseListItem } from '@/types/expense';
import { appendRootPhotosToFormData, createMediaUploadState } from '@/types/media'; import { appendRootPhotosToFormData, createMediaUploadState } from '@/types/media';
import type { MediaUploadState } from '@/types/media'; import type { MediaUploadState } from '@/types/media';
const open = defineModel<boolean>('open', { default: false }); const open = defineModel<boolean>('open', { default: false });
const props = defineProps<{ const props = defineProps<{
@ -42,7 +43,7 @@ const photoState = ref<MediaUploadState>(createMediaUploadState());
const isUploading = computed(() => photoState.value.pendingUploads > 0); const isUploading = computed(() => photoState.value.pendingUploads > 0);
const form = useForm<ExpenseFormData>({ const form = useForm({
amount: '', amount: '',
description: '', description: '',
}); });

View File

@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { RowDeleteAction, RowEditAction } from '@/components/button'; import { RowDeleteAction, RowEditAction } from '@/components/button';
import { useCan } from '@/composables/useCan'; import { useCan } from '@/composables/useCan';
import { destroy } from '@/routes/admin/finance/expenses';
import type { ExpenseListItem } from '@/types/expense'; import type { ExpenseListItem } from '@/types/expense';
import { destroy } from '@/routes/admin/finance/expenses';
defineProps<{ defineProps<{
expense: ExpenseListItem; expense: ExpenseListItem;
@ -20,6 +20,6 @@ const { can } = useCan();
<RowEditAction v-if="can('expenses.update')" @click="emit('edit', expense)" /> <RowEditAction v-if="can('expenses.update')" @click="emit('edit', expense)" />
<RowDeleteAction v-if="can('expenses.delete')" :action-url="destroy.url(expense.id)" title="Hapus pengeluaran?" <RowDeleteAction v-if="can('expenses.delete')" :action-url="destroy.url(expense.id)" title="Hapus pengeluaran?"
:description="`Pengeluaran ${expense.amount_formatted} akan dihapus. Saldo kas akan disesuaikan.`" :description="`Pengeluaran ${expense.amount_formatted} akan dihapus. Saldo kas akan disesuaikan.`"
error-message="Gagal menghapus pengeluaran." /> :on-error="(errors) => errors.amount || errors.transaction || 'Gagal menghapus pengeluaran.'" />
</div> </div>
</template> </template>

View File

@ -22,12 +22,12 @@ import {
useDataTableQuerySync, useDataTableQuerySync,
} from '@/composables/useDataTableQuery'; } from '@/composables/useDataTableQuery';
import AdminLayout from '@/layouts/AdminLayout.vue'; import AdminLayout from '@/layouts/AdminLayout.vue';
import { index } from '@/routes/admin/finance/payroll';
import type { DataTableSort } from '@/types/data-table'; import type { DataTableSort } from '@/types/data-table';
import type { PayrollListItem, PayrollPageProps } from '@/types/payroll'; import type { PayrollListItem, PayrollPageProps } from '@/types/payroll';
import PayrollAdjustmentModal from './form/PayrollAdjustmentModal.vue'; import PayrollAdjustmentModal from './form/PayrollAdjustmentModal.vue';
import { createColumns } from './table/columns'; import { createColumns } from './table/columns';
import { index } from '@/routes/admin/finance/payroll';
const props = defineProps<PayrollPageProps>(); const props = defineProps<PayrollPageProps>();

View File

@ -8,7 +8,7 @@ import type { AttendanceListItem, CalendarRange, TodayAttendance } from '@/types
import AttendanceWebcamModal from './form/AttendanceWebcamModal.vue'; import AttendanceWebcamModal from './form/AttendanceWebcamModal.vue';
import AttendanceCalendar from './table/AttendanceCalendar.vue'; import AttendanceCalendar from './table/AttendanceCalendar.vue';
defineProps<{ const props = defineProps<{
attendances: AttendanceListItem[]; attendances: AttendanceListItem[];
todayAttendance: TodayAttendance; todayAttendance: TodayAttendance;
isOnLeave: boolean; isOnLeave: boolean;
@ -44,8 +44,13 @@ function openCheckOutModal() {
</div> </div>
</div> </div>
<AttendanceCard :today-attendance="todayAttendance" :is-on-leave="isOnLeave" :can-check-in="canCheckIn" <AttendanceCard
@check-in="openCheckInModal" @check-out="openCheckOutModal" /> :today-attendance="todayAttendance"
:is-on-leave="isOnLeave"
:can-check-in="canCheckIn"
@check-in="openCheckInModal"
@check-out="openCheckOutModal"
/>
<Card class="min-w-0 overflow-hidden"> <Card class="min-w-0 overflow-hidden">
<CardContent class="min-w-0"> <CardContent class="min-w-0">

View File

@ -7,13 +7,13 @@ import FullCalendar from '@fullcalendar/vue3';
import { router } from '@inertiajs/vue3'; import { router } from '@inertiajs/vue3';
import { ChevronLeft, ChevronRight } from '@lucide/vue'; import { ChevronLeft, ChevronRight } from '@lucide/vue';
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import AttendanceDetailDialog from './AttendanceDetailDialog.vue';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { DatePicker } from '@/components/ui/date-picker'; import { DatePicker } from '@/components/ui/date-picker';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { index } from '@/routes/admin/hr/attendances';
import type { AttendanceListItem, CalendarRange } from '@/types/attendance'; import type { AttendanceListItem, CalendarRange } from '@/types/attendance';
import AttendanceDetailDialog from './AttendanceDetailDialog.vue'; import { index } from '@/routes/admin/hr/attendances';
const props = defineProps<{ const props = defineProps<{
attendances: AttendanceListItem[]; attendances: AttendanceListItem[];
@ -147,7 +147,6 @@ function scrollToToday() {
requestAnimationFrame(() => { requestAnimationFrame(() => {
const container = calendarRef.value?.$el?.closest('.overflow-x-auto'); const container = calendarRef.value?.$el?.closest('.overflow-x-auto');
const todayEl = container?.querySelector('.fc-day-today'); const todayEl = container?.querySelector('.fc-day-today');
if (!container || !todayEl) { if (!container || !todayEl) {
return; return;
} }

View File

@ -1,5 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Trash2 } from '@lucide/vue'; import { Trash2 } from '@lucide/vue';
import AttendanceMap from './AttendanceMap.vue';
import AttendancePhotoCell from './attendance-photo-cell.vue';
import ConfirmDialog from '@/components/ConfirmDialog.vue'; import ConfirmDialog from '@/components/ConfirmDialog.vue';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@ -11,10 +13,8 @@ import {
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { useCan } from '@/composables/useCan'; import { useCan } from '@/composables/useCan';
import { useDestroy } from '@/composables/useDestroy'; import { useDestroy } from '@/composables/useDestroy';
import { destroy } from '@/routes/admin/hr/attendances';
import type { AttendanceListItem } from '@/types/attendance'; import type { AttendanceListItem } from '@/types/attendance';
import AttendancePhotoCell from './attendance-photo-cell.vue'; import { destroy } from '@/routes/admin/hr/attendances';
import AttendanceMap from './AttendanceMap.vue';
const open = defineModel<boolean>('open', { required: true }); const open = defineModel<boolean>('open', { required: true });
@ -76,20 +76,29 @@ const { open: deleteConfirmOpen, processing: deleteProcessing, destroy: destroyA
</div> </div>
</dl> </dl>
<AttendanceMap :check-in-lat="attendance.check_in_latitude" <AttendanceMap
:check-in-lng="attendance.check_in_longitude" :check-out-lat="attendance.check_out_latitude" :check-in-lat="attendance.check_in_latitude"
:check-out-lng="attendance.check_out_longitude" /> :check-in-lng="attendance.check_in_longitude"
:check-out-lat="attendance.check_out_latitude"
:check-out-lng="attendance.check_out_longitude"
/>
<div> <div>
<p class="text-muted-foreground mb-2 text-sm"> <p class="text-muted-foreground mb-2 text-sm">
Foto Foto
</p> </p>
<AttendancePhotoCell :check-in-photo-url="attendance.check_in_photo_url" <AttendancePhotoCell
:check-out-photo-url="attendance.check_out_photo_url" /> :check-in-photo-url="attendance.check_in_photo_url"
:check-out-photo-url="attendance.check_out_photo_url"
/>
</div> </div>
<div v-if="can('attendances.delete')" class="flex justify-end border-t pt-4"> <div v-if="can('attendances.delete')" class="flex justify-end border-t pt-4">
<Button variant="destructive" size="sm" @click="deleteConfirmOpen = true"> <Button
variant="destructive"
size="sm"
@click="deleteConfirmOpen = true"
>
<Trash2 class="size-4" /> <Trash2 class="size-4" />
Hapus Hapus
</Button> </Button>
@ -98,9 +107,15 @@ const { open: deleteConfirmOpen, processing: deleteProcessing, destroy: destroyA
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<ConfirmDialog v-if="can('attendances.delete') && attendance" v-model:open="deleteConfirmOpen" <ConfirmDialog
v-if="can('attendances.delete') && attendance"
v-model:open="deleteConfirmOpen"
title="Hapus data presensi?" title="Hapus data presensi?"
:description="`Presensi tanggal ${attendance.attendance_date_formatted} akan dihapus secara permanen.`" :description="`Presensi tanggal ${attendance.attendance_date_formatted} akan dihapus secara permanen.`"
confirm-label="Hapus" cancel-label="Batal" destructive :loading="deleteProcessing" confirm-label="Hapus"
@confirm="destroyAttendance" /> cancel-label="Batal"
destructive
:loading="deleteProcessing"
@confirm="destroyAttendance"
/>
</template> </template>

View File

@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { RowDeleteAction } from '@/components/button'; import { RowDeleteAction } from '@/components/button';
import { useCan } from '@/composables/useCan'; import { useCan } from '@/composables/useCan';
import { destroy } from '@/routes/admin/hr/attendances';
import type { AttendanceListItem } from '@/types/attendance'; import type { AttendanceListItem } from '@/types/attendance';
import { destroy } from '@/routes/admin/hr/attendances';
defineProps<{ defineProps<{
attendance: AttendanceListItem; attendance: AttendanceListItem;

View File

@ -1,10 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import BackButton from '@/components/button/BackButton.vue'; import BackButton from '@/components/button/BackButton.vue';
import AdminLayout from '@/layouts/AdminLayout.vue'; import AdminLayout from '@/layouts/AdminLayout.vue';
import { index, store } from '@/routes/admin/hr/employees';
import type { EnumOption } from '@/types/employee'; import type { EnumOption } from '@/types/employee';
import { Head } from '@inertiajs/vue3';
import EmployeeForm from './form/EmployeeForm.vue'; import EmployeeForm from './form/EmployeeForm.vue';
import { index, store } from '@/routes/admin/hr/employees';
defineProps<{ defineProps<{
genders: EnumOption[]; genders: EnumOption[];

View File

@ -1,12 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { computed } from 'vue';
import BackButton from '@/components/button/BackButton.vue'; import BackButton from '@/components/button/BackButton.vue';
import AdminLayout from '@/layouts/AdminLayout.vue'; import AdminLayout from '@/layouts/AdminLayout.vue';
import { index, update } from '@/routes/admin/hr/employees';
import type { EmployeeListItem, EnumOption } from '@/types/employee'; import type { EmployeeListItem, EnumOption } from '@/types/employee';
import type { MediaItem } from '@/types/media'; import type { MediaItem } from '@/types/media';
import { Head } from '@inertiajs/vue3';
import { computed } from 'vue';
import EmployeeForm from './form/EmployeeForm.vue'; import EmployeeForm from './form/EmployeeForm.vue';
import { index, update } from '@/routes/admin/hr/employees';
const props = defineProps<{ const props = defineProps<{
employee: EmployeeListItem; employee: EmployeeListItem;

View File

@ -1,6 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { computed, ref, watch } from 'vue';
import CreateButton from '@/components/button/CreateButton.vue'; import CreateButton from '@/components/button/CreateButton.vue';
import { DataTable } from '@/components/data-table'; import { DataTable } from '@/components/data-table';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
@ -11,10 +9,12 @@ import {
} from '@/composables/useDataTableQuery'; } from '@/composables/useDataTableQuery';
import { ActiveStatus } from '@/constants/active-status'; import { ActiveStatus } from '@/constants/active-status';
import AdminLayout from '@/layouts/AdminLayout.vue'; import AdminLayout from '@/layouts/AdminLayout.vue';
import { index, create } from '@/routes/admin/hr/employees';
import type { DataTableFilterDef, DataTableSort } from '@/types/data-table'; import type { DataTableFilterDef, DataTableSort } from '@/types/data-table';
import type { EnumOption, PaginatedEmployees } from '@/types/employee'; import type { EnumOption, PaginatedEmployees } from '@/types/employee';
import { Head } from '@inertiajs/vue3';
import { computed, ref, watch } from 'vue';
import { columns } from './table/columns'; import { columns } from './table/columns';
import { index, create } from '@/routes/admin/hr/employees';
const props = defineProps<{ const props = defineProps<{
employees: PaginatedEmployees; employees: PaginatedEmployees;
@ -112,24 +112,37 @@ watch(
</script> </script>
<template> <template>
<Head title="Pegawai" /> <Head title="Pegawai" />
<AdminLayout> <AdminLayout>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div
class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"
>
<div class="space-y-1"> <div class="space-y-1">
<h2 class="text-2xl font-bold tracking-tight">Pegawai</h2> <h2 class="text-2xl font-bold tracking-tight">Pegawai</h2>
</div> </div>
<CreateButton v-if="can('employees.create')" :href="create.url()" /> <CreateButton
v-if="can('employees.create')"
:href="create.url()"
/>
</div> </div>
<Card class="min-w-0"> <Card class="min-w-0">
<CardContent class="min-w-0"> <CardContent class="min-w-0">
<DataTable v-model:search="search" :columns="columns" :data="employees.data" :pagination="pagination" <DataTable
:pagination-links="employees.links" :sort="currentSort" :filter-defs="filterDefs" v-model:search="search"
:filter-values="filterValues" @sort-change="setSort" @filter-change="setFilter" :columns="columns"
@filters-reset="resetFilters" /> :data="employees.data"
:pagination="pagination"
:pagination-links="employees.links"
:sort="currentSort"
:filter-defs="filterDefs"
:filter-values="filterValues"
@sort-change="setSort"
@filter-change="setFilter"
@filters-reset="resetFilters"
/>
</CardContent> </CardContent>
</Card> </Card>
</AdminLayout> </AdminLayout>

View File

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useForm } from '@inertiajs/vue3'; import { useForm } from '@inertiajs/vue3';
import { Save } from '@lucide/vue'; import { Save } from '@lucide/vue';
import { computed, ref } from 'vue'; import { ref } from 'vue';
import { toast } from 'vue-sonner'; import { toast } from 'vue-sonner';
import { PhoneNumberInput } from '@/components/form/phone-number-input'; import { PhoneNumberInput } from '@/components/form/phone-number-input';
import { RupiahInput } from '@/components/form/rupiah-input'; import { RupiahInput } from '@/components/form/rupiah-input';
@ -131,8 +131,8 @@ function submit() {
<FieldSet class="grid gap-4 md:grid-cols-3"> <FieldSet class="grid gap-4 md:grid-cols-3">
<Field> <Field>
<FieldLabel for="email" required>Email</FieldLabel> <FieldLabel for="email" required>Email</FieldLabel>
<Input id="email" v-model="form.email" type="email" placeholder="Masukkan email" <Input id="email" v-model="form.email" type="email"
:maxlength="FIELD_LIMITS.email" /> placeholder="Masukkan email" :maxlength="FIELD_LIMITS.email" />
<FieldError :errors="formErrors(form, 'email')" /> <FieldError :errors="formErrors(form, 'email')" />
</Field> </Field>
<Field> <Field>
@ -201,7 +201,8 @@ function submit() {
:errors="formErrors(form, 'profile_photo')" class="md:col-span-3" /> :errors="formErrors(form, 'profile_photo')" class="md:col-span-3" />
<Field class="md:col-span-3"> <Field class="md:col-span-3">
<FieldLabel for="address">Alamat</FieldLabel> <FieldLabel for="address">Alamat</FieldLabel>
<Textarea id="address" v-model="form.address" placeholder="Masukkan alamat" rows="3" /> <Textarea id="address" v-model="form.address"
placeholder="Masukkan alamat" rows="3" />
<FieldError :errors="formErrors(form, 'address')" /> <FieldError :errors="formErrors(form, 'address')" />
</Field> </Field>
</FieldSet> </FieldSet>

View File

@ -2,7 +2,6 @@ import type { ColumnDef } from '@tanstack/vue-table';
import { h } from 'vue'; import { h } from 'vue';
import { DataTableColumnHeader } from '@/components/data-table'; import { DataTableColumnHeader } from '@/components/data-table';
import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue'; import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import type { EmployeeListItem } from '@/types/employee'; import type { EmployeeListItem } from '@/types/employee';
import type { MediaItem } from '@/types/media'; import type { MediaItem } from '@/types/media';
@ -10,24 +9,24 @@ import DataTableActions from './data-table-actions.vue';
import EmployeeStatusToggle from './employee-status-toggle.vue'; import EmployeeStatusToggle from './employee-status-toggle.vue';
export const columns: ColumnDef<EmployeeListItem>[] = [ export const columns: ColumnDef<EmployeeListItem>[] = [
{
id: 'photo',
enableSorting: false,
header: () => 'Foto',
cell: ({ row }) => {
const photoUrl = row.original.profile?.profile_photo_url;
const items: MediaItem[] = photoUrl
? [{ id: 0, url: photoUrl, thumb_url: photoUrl }]
: [];
return h(MediaThumbnailCell, { items, maxVisible: 1 });
},
},
{ {
accessorKey: 'profile.full_name', accessorKey: 'profile.full_name',
enableSorting: true, enableSorting: true,
header: () => h(DataTableColumnHeader, { title: 'Nama', column: 'full_name' }), header: () => h(DataTableColumnHeader, { title: 'Nama', column: 'full_name' }),
cell: ({ row }) => { cell: ({ row }) => row.original.profile?.full_name ?? '-',
const photoUrl = row.original.profile?.profile_photo_url;
const fullName = row.original.profile?.full_name ?? '-';
const photoEl = photoUrl
? h('div', { class: 'size-8 overflow-hidden rounded-full' }, [
h(MediaThumbnailCell, { items: [{ id: 0, url: photoUrl, thumb_url: photoUrl } as MediaItem], maxVisible: 1 }),
])
: h(Avatar, { class: 'size-8' }, () => [
h(AvatarFallback, () => (row.original.username ?? '').slice(0, 2).toUpperCase()),
]);
return h('div', { class: 'flex items-center gap-2' }, [photoEl, h('span', {}, fullName)]);
},
}, },
{ {
id: 'account', id: 'account',

View File

@ -1,6 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { computed, ref, watch } from 'vue';
import CreateButton from '@/components/button/CreateButton.vue'; import CreateButton from '@/components/button/CreateButton.vue';
import { DataTable } from '@/components/data-table'; import { DataTable } from '@/components/data-table';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
@ -10,16 +8,17 @@ import {
useDataTableQuerySync, useDataTableQuerySync,
} from '@/composables/useDataTableQuery'; } from '@/composables/useDataTableQuery';
import AdminLayout from '@/layouts/AdminLayout.vue'; import AdminLayout from '@/layouts/AdminLayout.vue';
import { index } from '@/routes/admin/hr/leave_requests';
import type { DataTableSort } from '@/types/data-table'; import type { DataTableSort } from '@/types/data-table';
import type { import type {
LeaveRequestListItem, LeaveRequestListItem,
LeaveRequestPageProps, LeaveRequestPageProps,
} from '@/types/leave-request'; } from '@/types/leave-request';
import { Head } from '@inertiajs/vue3';
import { computed, ref, watch } from 'vue';
import LeaveRequestFormModal from './form/LeaveRequestFormModal.vue'; import LeaveRequestFormModal from './form/LeaveRequestFormModal.vue';
import RejectLeaveRequestModal from './form/RejectLeaveRequestModal.vue'; import RejectLeaveRequestModal from './form/RejectLeaveRequestModal.vue';
import { createColumns } from './table/columns'; import { createColumns } from './table/columns';
import { index } from '@/routes/admin/hr/leave_requests';
const props = defineProps<LeaveRequestPageProps>(); const props = defineProps<LeaveRequestPageProps>();
@ -88,29 +87,48 @@ watch(
</script> </script>
<template> <template>
<Head title="Cuti" /> <Head title="Cuti" />
<AdminLayout> <AdminLayout>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div
class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"
>
<div class="space-y-1"> <div class="space-y-1">
<h2 class="text-2xl font-bold tracking-tight">Cuti</h2> <h2 class="text-2xl font-bold tracking-tight">Cuti</h2>
</div> </div>
<CreateButton v-if="canSubmit && !hasPending" @click="openCreateModal" label="Ajukan" /> <CreateButton
v-if="canSubmit && !hasPending"
@click="openCreateModal"
label="Ajukan"
/>
</div> </div>
<Card class="min-w-0"> <Card class="min-w-0">
<CardContent class="min-w-0 pt-6"> <CardContent class="min-w-0 pt-6">
<DataTable v-model:search="search" :columns="columns" :data="leaveRequests.data" <DataTable
:pagination="pagination" :pagination-links="leaveRequests.links" :sort="currentSort" v-model:search="search"
@sort-change="setSort" @filters-reset="resetFilters" /> :columns="columns"
:data="leaveRequests.data"
:pagination="pagination"
:pagination-links="leaveRequests.links"
:sort="currentSort"
@sort-change="setSort"
@filters-reset="resetFilters"
/>
</CardContent> </CardContent>
</Card> </Card>
<LeaveRequestFormModal v-if="canSubmit" v-model:open="formModalOpen" :leave-request="editingLeaveRequest" /> <LeaveRequestFormModal
v-if="canSubmit"
v-model:open="formModalOpen"
:leave-request="editingLeaveRequest"
/>
<RejectLeaveRequestModal v-if="can('leave_requests.verify')" v-model:open="rejectModalOpen" <RejectLeaveRequestModal
:leave-request="rejectingLeaveRequest" /> v-if="can('leave_requests.verify')"
v-model:open="rejectModalOpen"
:leave-request="rejectingLeaveRequest"
/>
</AdminLayout> </AdminLayout>
</template> </template>

View File

@ -1,8 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import BackButton from '@/components/button/BackButton.vue'; import BackButton from '@/components/button/BackButton.vue';
import AdminLayout from '@/layouts/AdminLayout.vue'; import AdminLayout from '@/layouts/AdminLayout.vue';
import { index, store } from '@/routes/admin/manage/cuttings';
import type { import type {
CuttingMaterialCartItem, CuttingMaterialCartItem,
CuttingProductCatalogItem, CuttingProductCatalogItem,
@ -11,7 +9,9 @@ import type {
} from '@/types/cutting'; } from '@/types/cutting';
import type { CategoryOption } from '@/types/product'; import type { CategoryOption } from '@/types/product';
import type { EnumOption } from '@/types/raw-material'; import type { EnumOption } from '@/types/raw-material';
import { Head } from '@inertiajs/vue3';
import CuttingPosForm from './form/CuttingPosForm.vue'; import CuttingPosForm from './form/CuttingPosForm.vue';
import { index, store } from '@/routes/admin/manage/cuttings';
defineProps<{ defineProps<{
rawMaterialCatalog: CuttingRawMaterialCatalogItem[]; rawMaterialCatalog: CuttingRawMaterialCatalogItem[];

View File

@ -1,9 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { computed } from 'vue';
import BackButton from '@/components/button/BackButton.vue'; import BackButton from '@/components/button/BackButton.vue';
import AdminLayout from '@/layouts/AdminLayout.vue'; import AdminLayout from '@/layouts/AdminLayout.vue';
import { index, update } from '@/routes/admin/manage/cuttings';
import type { import type {
CuttingEditItem, CuttingEditItem,
CuttingProductCatalogItem, CuttingProductCatalogItem,
@ -11,7 +8,10 @@ import type {
} from '@/types/cutting'; } from '@/types/cutting';
import type { CategoryOption } from '@/types/product'; import type { CategoryOption } from '@/types/product';
import type { EnumOption } from '@/types/raw-material'; import type { EnumOption } from '@/types/raw-material';
import { Head } from '@inertiajs/vue3';
import { computed } from 'vue';
import CuttingPosForm from './form/CuttingPosForm.vue'; import CuttingPosForm from './form/CuttingPosForm.vue';
import { index, update } from '@/routes/admin/manage/cuttings';
const props = defineProps<{ const props = defineProps<{
cutting: CuttingEditItem; cutting: CuttingEditItem;
@ -34,10 +34,7 @@ const initialData = computed(() => ({
unit_abbreviation: item.unit_abbreviation ?? '', unit_abbreviation: item.unit_abbreviation ?? '',
stock_input: item.stock_input ?? '', stock_input: item.stock_input ?? '',
material_usage: item.material_usage_input, material_usage: item.material_usage_input,
material_result: item.material_result_input !== null && item.material_result_input !== undefined ? String(item.material_result_input) : null,
images: item.images ?? [], images: item.images ?? [],
combination_id: item.combination_id ?? null,
combination_material_result: item.combination_material_result ?? null,
})), })),
results: props.cutting.results.map((item) => ({ results: props.cutting.results.map((item) => ({
product_variant_id: item.product_variant_id, product_variant_id: item.product_variant_id,
@ -53,11 +50,12 @@ const initialData = computed(() => ({
</script> </script>
<template> <template>
<Head title="Ubah Cutting" /> <Head title="Ubah Cutting" />
<AdminLayout> <AdminLayout>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div
class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"
>
<div class="space-y-1"> <div class="space-y-1">
<h2 class="text-2xl font-bold tracking-tight">Ubah Cutting</h2> <h2 class="text-2xl font-bold tracking-tight">Ubah Cutting</h2>
</div> </div>
@ -65,8 +63,15 @@ const initialData = computed(() => ({
<BackButton :href="index.url()" /> <BackButton :href="index.url()" />
</div> </div>
<CuttingPosForm :raw-material-catalog="rawMaterialCatalog" :product-catalog="productCatalog" <CuttingPosForm
:categories="categories" :units="units" :initial-data="initialData" :raw-material-catalog="rawMaterialCatalog"
:submit-url="update.url(props.cutting.id)" method="put" submit-label="Perbarui" /> :product-catalog="productCatalog"
:categories="categories"
:units="units"
:initial-data="initialData"
:submit-url="update.url(props.cutting.id)"
method="put"
submit-label="Perbarui"
/>
</AdminLayout> </AdminLayout>
</template> </template>

View File

@ -1,6 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { computed, ref, watch } from 'vue';
import CreateButton from '@/components/button/CreateButton.vue'; import CreateButton from '@/components/button/CreateButton.vue';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { useCan } from '@/composables/useCan'; import { useCan } from '@/composables/useCan';
@ -9,10 +7,12 @@ import {
useDataTableQuerySync, useDataTableQuerySync,
} from '@/composables/useDataTableQuery'; } from '@/composables/useDataTableQuery';
import AdminLayout from '@/layouts/AdminLayout.vue'; import AdminLayout from '@/layouts/AdminLayout.vue';
import { index, create } from '@/routes/admin/manage/cuttings';
import type { CuttingListItem, PaginatedCuttings } from '@/types/cutting'; import type { CuttingListItem, PaginatedCuttings } from '@/types/cutting';
import { Head } from '@inertiajs/vue3';
import { computed, ref, watch } from 'vue';
import CuttingGroupedTable from './table/CuttingGroupedTable.vue'; import CuttingGroupedTable from './table/CuttingGroupedTable.vue';
import CuttingInProgressSection from './table/CuttingInProgressSection.vue'; import CuttingInProgressSection from './table/CuttingInProgressSection.vue';
import { index, create } from '@/routes/admin/manage/cuttings';
const props = defineProps<{ const props = defineProps<{
cuttings: PaginatedCuttings; cuttings: PaginatedCuttings;

View File

@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { import {
Table, Table,
@ -11,6 +10,7 @@ import {
TableRow, TableRow,
} from '@/components/ui/table'; } from '@/components/ui/table';
import { cuttingStatusBadgeVariant } from '@/constants/cutting-status'; import { cuttingStatusBadgeVariant } from '@/constants/cutting-status';
import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue';
import type { CuttingListItem } from '@/types/cutting'; import type { CuttingListItem } from '@/types/cutting';
const props = defineProps<{ const props = defineProps<{
@ -22,7 +22,6 @@ interface GroupedMaterials {
name: string; name: string;
unitLabel?: string; unitLabel?: string;
items: typeof props.cutting.materials; items: typeof props.cutting.materials;
isCombination?: boolean;
} }
interface GroupedResults { interface GroupedResults {
@ -32,38 +31,9 @@ interface GroupedResults {
} }
const groupedMaterials = computed<GroupedMaterials[]>(() => { const groupedMaterials = computed<GroupedMaterials[]>(() => {
// First, group by combination_id
const combinationGroups: Record<number, typeof props.cutting.materials> = {};
const nonCombinationItems: typeof props.cutting.materials = [];
props.cutting.materials.forEach((item) => {
if (item.combination_id) {
if (!combinationGroups[item.combination_id]) {
combinationGroups[item.combination_id] = [];
}
combinationGroups[item.combination_id].push(item);
} else {
nonCombinationItems.push(item);
}
});
const result: GroupedMaterials[] = [];
// Add combination groups
Object.entries(combinationGroups).forEach(([combinationId, items]) => {
result.push({
key: Number(combinationId) * -1,
name: 'Kombinasi',
items,
isCombination: true,
});
});
// Group non-combination items by raw material
const groups: Record<number, GroupedMaterials> = {}; const groups: Record<number, GroupedMaterials> = {};
nonCombinationItems.forEach((item) => { props.cutting.materials.forEach((item) => {
const key = item.raw_material_id ?? 0; const key = item.raw_material_id ?? 0;
const name = item.raw_material_name || 'Bahan Baku Tidak Diketahui'; const name = item.raw_material_name || 'Bahan Baku Tidak Diketahui';
@ -79,9 +49,7 @@ const groupedMaterials = computed<GroupedMaterials[]>(() => {
groups[key].items.push(item); groups[key].items.push(item);
}); });
result.push(...Object.values(groups)); return Object.values(groups);
return result;
}); });
const groupedResults = computed<GroupedResults[]>(() => { const groupedResults = computed<GroupedResults[]>(() => {
@ -196,9 +164,6 @@ const groupedResults = computed<GroupedResults[]>(() => {
<TableHead class="text-right" <TableHead class="text-right"
>Pemakaian</TableHead >Pemakaian</TableHead
> >
<TableHead class="text-right"
>Hasil</TableHead
>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@ -210,26 +175,9 @@ const groupedResults = computed<GroupedResults[]>(() => {
class="bg-muted/20 hover:bg-muted/20" class="bg-muted/20 hover:bg-muted/20"
> >
<TableCell <TableCell
colspan="4" colspan="3"
class="font-semibold" class="font-semibold"
> >
<template v-if="group.isCombination">
<span class="text-primary">{{ group.name }}</span>
<Badge
variant="default"
class="ml-2 font-normal"
>
{{ group.items.length }} bahan
</Badge>
<Badge
v-if="group.items[0]?.combination_material_result !== null && group.items[0]?.combination_material_result !== undefined"
variant="secondary"
class="ml-2 font-normal"
>
Hasil: {{ group.items[0].combination_material_result }} pcs
</Badge>
</template>
<template v-else>
{{ group.name }} {{ group.name }}
<Badge <Badge
v-if="group.unitLabel" v-if="group.unitLabel"
@ -238,7 +186,6 @@ const groupedResults = computed<GroupedResults[]>(() => {
> >
{{ group.unitLabel }} {{ group.unitLabel }}
</Badge> </Badge>
</template>
</TableCell> </TableCell>
</TableRow> </TableRow>
<TableRow <TableRow
@ -261,19 +208,6 @@ const groupedResults = computed<GroupedResults[]>(() => {
material.material_usage_formatted material.material_usage_formatted
}} }}
</TableCell> </TableCell>
<TableCell
class="text-right tabular-nums"
>
<template v-if="group.isCombination && material.combination_material_result !== null && material.combination_material_result !== undefined">
{{ material.combination_material_result }} pcs
</template>
<template v-else-if="!group.isCombination && material.material_result !== null && material.material_result !== undefined">
{{ material.material_result }} pcs
</template>
<template v-else>
-
</template>
</TableCell>
</TableRow> </TableRow>
</template> </template>
</TableBody> </TableBody>

View File

@ -1,278 +0,0 @@
<script setup lang="ts">
import { Layers, Plus, Search, X } from '@lucide/vue';
import { computed, ref, watch } from 'vue';
import { toast } from 'vue-sonner';
import PosCatalogVariantThumb from '@/components/catalog/PosCatalogVariantThumb.vue';
import { DecimalInput } from '@/components/form/decimal-input';
import { NumberInput } from '@/components/form/number-input';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { apiFetch } from '@/lib/api';
import draft_combinations from '@/routes/admin/manage/cuttings/draft_combinations';
import type { CuttingMaterialCartItem, CuttingRawMaterialCatalogItem } from '@/types/cutting';
export type CuttingCatalogPrice = CuttingRawMaterialCatalogItem['prices'][number];
type SelectedMaterial = {
raw_material_price_id: number;
raw_material_name: string;
variant: string;
unit: string;
unit_abbreviation: string;
stock_input: string;
material_usage: string;
images?: CuttingCatalogPrice['images'];
is_initial?: boolean;
};
const props = defineProps<{
rawMaterialCatalog: CuttingRawMaterialCatalogItem[];
existingCartItems: CuttingMaterialCartItem[];
initialVariant?: {
rawMaterial: CuttingRawMaterialCatalogItem;
price: CuttingCatalogPrice;
} | null;
}>();
const open = defineModel<boolean>('open', { required: true });
const emit = defineEmits<{
'combination-created': [items: CuttingMaterialCartItem[]];
}>();
const search = ref('');
const selectedMaterials = ref<SelectedMaterial[]>([]);
const combinationResult = ref<string>('');
const loading = ref(false);
const filteredRawMaterials = computed(() => {
const keyword = search.value.trim().toLowerCase();
const catalog = props.rawMaterialCatalog;
if (!keyword) {
return catalog;
}
return catalog.filter(
(rawMaterial) =>
rawMaterial.name.toLowerCase().includes(keyword)
|| rawMaterial.prices.some((price) => price.variant.toLowerCase().includes(keyword)),
);
});
function isSelected(priceId: number): boolean {
return selectedMaterials.value.some((item) => item.raw_material_price_id === priceId);
}
function toggleVariant(rawMaterial: CuttingRawMaterialCatalogItem, price: CuttingCatalogPrice) {
const index = selectedMaterials.value.findIndex(
(item) => item.raw_material_price_id === price.id,
);
if (index !== -1) {
if (!selectedMaterials.value[index].is_initial) {
selectedMaterials.value.splice(index, 1);
}
} else {
selectedMaterials.value.push({
raw_material_price_id: price.id,
raw_material_name: rawMaterial.name,
variant: price.variant,
unit: rawMaterial.unit,
unit_abbreviation: rawMaterial.unit_abbreviation,
stock_input: price.stock_input,
material_usage: '1',
images: price.images,
});
}
}
function removeSelected(priceId: number) {
selectedMaterials.value = selectedMaterials.value.filter(
(item) => item.raw_material_price_id !== priceId || item.is_initial,
);
}
function resetForm() {
search.value = '';
selectedMaterials.value = [];
combinationResult.value = '';
}
function initFromVariant() {
if (props.initialVariant) {
const { rawMaterial, price } = props.initialVariant;
selectedMaterials.value = [{
raw_material_price_id: price.id,
raw_material_name: rawMaterial.name,
variant: price.variant,
unit: rawMaterial.unit,
unit_abbreviation: rawMaterial.unit_abbreviation,
stock_input: price.stock_input,
material_usage: '1',
images: price.images,
is_initial: true,
}];
}
}
async function submit() {
if (selectedMaterials.value.length < 2) {
toast.error('Pilih minimal 2 bahan baku untuk dikombinasikan.');
return;
}
loading.value = true;
try {
const data = await apiFetch<{ items: CuttingMaterialCartItem[] }>(draft_combinations.store.url(), {
method: 'POST',
body: JSON.stringify({
materials: selectedMaterials.value.map((item) => ({
raw_material_price_id: item.raw_material_price_id,
material_usage: item.material_usage,
})),
material_result: combinationResult.value ? Number(combinationResult.value) : null,
}),
});
toast.success('Kombinasi bahan baku berhasil ditambahkan.');
emit('combination-created', data.items);
open.value = false;
resetForm();
} catch (error) {
if (error instanceof Error) {
toast.error(error.message);
}
} finally {
loading.value = false;
}
}
watch(open, (value) => {
if (value) {
initFromVariant();
} else {
resetForm();
}
});
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="flex max-h-[90vh] flex-col sm:max-w-4xl">
<DialogHeader>
<DialogTitle>Kombinasi Bahan Baku</DialogTitle>
</DialogHeader>
<div class="scrollbar-thin flex-1 overflow-y-auto pr-1">
<div class="space-y-4">
<div v-if="selectedMaterials.length > 0" class="rounded-lg border p-3 space-y-2">
<p class="text-sm font-medium">Bahan Baku Terpilih ({{ selectedMaterials.length }})</p>
<div class="space-y-2">
<div v-for="item in selectedMaterials" :key="item.raw_material_price_id"
class="flex items-center gap-2 rounded-md border p-2"
:class="item.is_initial ? 'border-primary bg-primary/5' : ''">
<PosCatalogVariantThumb :items="item.images" />
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">
{{ item.raw_material_name }} - {{ item.variant }}
</p>
<Badge v-if="item.is_initial" variant="secondary" class="text-xs mt-0.5">
Bahan awal
</Badge>
</div>
<div class="flex items-center gap-1" @click.stop>
<Label class="text-xs whitespace-nowrap">Pemakaian:</Label>
<DecimalInput v-model="item.material_usage" class="h-7 w-20" />
<span class="text-xs text-muted-foreground">{{ item.unit_abbreviation }}</span>
</div>
<Button v-if="!item.is_initial" type="button" variant="ghost" size="icon-sm"
class="text-destructive" @click="removeSelected(item.raw_material_price_id)">
<X class="size-3.5" />
</Button>
</div>
</div>
</div>
<div class="relative">
<Search class="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input v-model="search" placeholder="Cari bahan baku lain untuk dikombinasikan..."
class="pl-9" />
</div>
<div v-if="filteredRawMaterials.length === 0"
class="py-8 text-center text-sm text-muted-foreground">
Tidak ada bahan baku ditemukan.
</div>
<div v-else class="space-y-3">
<div v-for="rawMaterial in filteredRawMaterials" :key="rawMaterial.id"
class="rounded-lg border p-3">
<div class="mb-2 flex items-center gap-2">
<p class="text-sm font-medium">{{ rawMaterial.name }}</p>
<Badge variant="outline" class="text-xs">
{{ rawMaterial.unit_label }}
</Badge>
</div>
<div class="space-y-1">
<div v-for="price in rawMaterial.prices" :key="price.id"
class="flex items-center gap-2.5 rounded-md px-2 py-1.5 transition-all duration-200"
:class="[
isSelected(price.id) ? 'border-2 border-primary bg-primary/5' : 'cursor-pointer hover:bg-muted/30',
]" @click="!isSelected(price.id) && toggleVariant(rawMaterial, price)">
<PosCatalogVariantThumb :items="price.images" />
<div class="min-w-0 flex-1">
<p class="truncate text-sm">{{ price.variant }}</p>
<p class="text-xs tabular-nums text-muted-foreground">
Stok: {{ price.stock_formatted }}
</p>
</div>
<Button v-if="!isSelected(price.id)" type="button" variant="outline" size="icon-sm"
class="shrink-0" @click.stop="toggleVariant(rawMaterial, price)">
<Plus class="size-3.5" />
</Button>
<Badge v-else variant="secondary" class="text-xs">
Terpilih
</Badge>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="flex items-center justify-between border-t pt-4">
<div class="flex items-center gap-3">
<div class="flex items-center gap-2">
<Label class="text-sm whitespace-nowrap">Hasil:</Label>
<NumberInput v-model="combinationResult" class="h-8 w-20" placeholder="0" />
<span class="text-xs text-muted-foreground">pcs</span>
</div>
<p class="text-sm text-muted-foreground">
{{ selectedMaterials.length }} bahan dipilih
</p>
</div>
<div class="flex gap-2">
<Button type="button" variant="outline" :disabled="loading" @click="open = false">
Batal
</Button>
<Button type="button" :disabled="loading || selectedMaterials.length < 2" @click="submit">
<Layers class="size-4 mr-1" />
{{ loading ? 'Menyimpan...' : 'Simpan Kombinasi' }}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</template>

View File

@ -99,8 +99,6 @@ const {
decreaseResultQty, decreaseResultQty,
syncMaterialField, syncMaterialField,
syncResultField, syncResultField,
removeCombination,
syncCombinationResult,
} = useCuttingPosCart({ } = useCuttingPosCart({
rawMaterialCatalog: rawMaterialCatalogState, rawMaterialCatalog: rawMaterialCatalogState,
productCatalog: productCatalogState, productCatalog: productCatalogState,
@ -144,14 +142,6 @@ function buildFormData(): FormData {
appendRootPhotosToFormData(formData, imageState.value); appendRootPhotosToFormData(formData, imageState.value);
if (props.method === 'put') { if (props.method === 'put') {
// Build combination results map
const combinationResults: Record<number, number | null> = {};
materialCart.value.forEach(item => {
if (item.combination_id && item.combination_material_result !== undefined) {
combinationResults[item.combination_id] = item.combination_material_result;
}
});
materialCart.value.forEach((item, index) => { materialCart.value.forEach((item, index) => {
formData.append( formData.append(
`materials[${index}][raw_material_price_id]`, `materials[${index}][raw_material_price_id]`,
@ -161,30 +151,6 @@ function buildFormData(): FormData {
`materials[${index}][material_usage]`, `materials[${index}][material_usage]`,
item.material_usage, item.material_usage,
); );
if (item.material_result !== undefined && item.material_result !== null) {
formData.append(
`materials[${index}][material_result]`,
item.material_result,
);
}
if (item.combination_id) {
formData.append(
`materials[${index}][combination_id]`,
String(item.combination_id),
);
// Add combination_material_result
const combinationResult = combinationResults[item.combination_id];
if (combinationResult !== undefined && combinationResult !== null) {
formData.append(
`materials[${index}][combination_material_result]`,
String(combinationResult),
);
}
}
}); });
resultCart.value.forEach((item, index) => { resultCart.value.forEach((item, index) => {
formData.append( formData.append(
@ -251,14 +217,6 @@ function onRawMaterialCreated(rawMaterial: CuttingRawMaterialCatalogItem) {
} }
} }
function onCombinationCreated(items: CuttingMaterialCartItem[]) {
for (const item of items) {
// Combination items use raw_material_price_id + combination_id as unique key
// Same variant can exist in multiple combinations
materialCart.value.push({ ...item });
}
}
function onProductCreated(product: CuttingProductCatalogItem) { function onProductCreated(product: CuttingProductCatalogItem) {
// Add to catalog // Add to catalog
productCatalogState.value.push(product); productCatalogState.value.push(product);
@ -276,10 +234,9 @@ function onProductCreated(product: CuttingProductCatalogItem) {
<div class="grid gap-4 xl:grid-cols-[1fr_400px]"> <div class="grid gap-4 xl:grid-cols-[1fr_400px]">
<div class="space-y-4"> <div class="space-y-4">
<CuttingPosMaterialCatalogPanel v-model:material-search="materialSearch" <CuttingPosMaterialCatalogPanel v-model:material-search="materialSearch"
:filtered-raw-materials="filteredRawMaterials" :raw-material-catalog="rawMaterialCatalogState" :filtered-raw-materials="filteredRawMaterials" :get-material-cart-item="getMaterialCartItem"
:material-cart="materialCart" :get-material-cart-item="getMaterialCartItem" :units="units" :units="units" @add-material="addMaterial" @decrease-material-qty="decreaseMaterialQty"
@add-material="addMaterial" @decrease-material-qty="decreaseMaterialQty" @raw-material-created="onRawMaterialCreated" />
@raw-material-created="onRawMaterialCreated" @combination-created="onCombinationCreated" />
<CuttingPosResultCatalogPanel v-model:product-search="productSearch" :filtered-products="filteredProducts" <CuttingPosResultCatalogPanel v-model:product-search="productSearch" :filtered-products="filteredProducts"
:get-result-cart-item="getResultCartItem" @add-result="addResult" :is-create-mode="isCreateMode" :get-result-cart-item="getResultCartItem" @add-result="addResult" :is-create-mode="isCreateMode"
@ -291,8 +248,7 @@ function onProductCreated(product: CuttingProductCatalogItem) {
@submit="submit" :is-create-mode="isCreateMode" :is-uploading="isUploading" @submit="submit" :is-create-mode="isCreateMode" :is-uploading="isUploading"
@open-detail="cartDetailOpen = true" @remove-material="removeMaterial" @open-detail="cartDetailOpen = true" @remove-material="removeMaterial"
@sync-material-field="syncMaterialField" @remove-result="removeResult" @sync-material-field="syncMaterialField" @remove-result="removeResult"
@sync-result-totals="syncResultTotals" @sync-result-field="syncResultField" @sync-result-totals="syncResultTotals" @sync-result-field="syncResultField" />
@remove-combination="removeCombination" @sync-combination-result="syncCombinationResult" />
</div> </div>
<CuttingPosCartDetailDialog v-model:open="cartDetailOpen" :material-cart="materialCart" :result-cart="resultCart" <CuttingPosCartDetailDialog v-model:open="cartDetailOpen" :material-cart="materialCart" :result-cart="resultCart"

View File

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Layers, Minus, Plus, Search } from '@lucide/vue';
import { ref } from 'vue'; import { ref } from 'vue';
import { Minus, Plus, Search } from '@lucide/vue';
import PosCatalogCard from '@/components/catalog/PosCatalogCard.vue'; import PosCatalogCard from '@/components/catalog/PosCatalogCard.vue';
import PosCatalogVariantThumb from '@/components/catalog/PosCatalogVariantThumb.vue'; import PosCatalogVariantThumb from '@/components/catalog/PosCatalogVariantThumb.vue';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@ -16,14 +16,11 @@ import { Input } from '@/components/ui/input';
import { getFirstCoverImage } from '@/lib/catalog-cover'; import { getFirstCoverImage } from '@/lib/catalog-cover';
import type { CuttingMaterialCartItem, CuttingRawMaterialCatalogItem } from '@/types/cutting'; import type { CuttingMaterialCartItem, CuttingRawMaterialCatalogItem } from '@/types/cutting';
import type { EnumOption } from '@/types/raw-material'; import type { EnumOption } from '@/types/raw-material';
import CuttingPosCombinationDialog from './CuttingPosCombinationDialog.vue';
import QuickCreateRawMaterialModal from './QuickCreateRawMaterialModal.vue';
import type { CuttingCatalogPrice } from './useCuttingPosCart'; import type { CuttingCatalogPrice } from './useCuttingPosCart';
import QuickCreateRawMaterialModal from './QuickCreateRawMaterialModal.vue';
defineProps<{ defineProps<{
filteredRawMaterials: CuttingRawMaterialCatalogItem[]; filteredRawMaterials: CuttingRawMaterialCatalogItem[];
rawMaterialCatalog: CuttingRawMaterialCatalogItem[];
materialCart: CuttingMaterialCartItem[];
getMaterialCartItem: (priceId: number) => CuttingMaterialCartItem | undefined; getMaterialCartItem: (priceId: number) => CuttingMaterialCartItem | undefined;
units: EnumOption[]; units: EnumOption[];
}>(); }>();
@ -34,20 +31,9 @@ const emit = defineEmits<{
'add-material': [rawMaterial: CuttingRawMaterialCatalogItem, price: CuttingCatalogPrice]; 'add-material': [rawMaterial: CuttingRawMaterialCatalogItem, price: CuttingCatalogPrice];
'decrease-material-qty': [priceId: number]; 'decrease-material-qty': [priceId: number];
'raw-material-created': [rawMaterial: CuttingRawMaterialCatalogItem]; 'raw-material-created': [rawMaterial: CuttingRawMaterialCatalogItem];
'combination-created': [items: CuttingMaterialCartItem[]];
}>(); }>();
const quickCreateOpen = ref(false); const quickCreateOpen = ref(false);
const combinationOpen = ref(false);
const combinationInitialVariant = ref<{
rawMaterial: CuttingRawMaterialCatalogItem;
price: CuttingCatalogPrice;
} | null>(null);
function openCombination(rawMaterial: CuttingRawMaterialCatalogItem, price: CuttingCatalogPrice) {
combinationInitialVariant.value = { rawMaterial, price };
combinationOpen.value = true;
}
</script> </script>
<template> <template>
@ -78,8 +64,12 @@ function openCombination(rawMaterial: CuttingRawMaterialCatalogItem, price: Cutt
</div> </div>
<div v-else class="columns-1 gap-4 sm:columns-2"> <div v-else class="columns-1 gap-4 sm:columns-2">
<PosCatalogCard v-for="rawMaterial in filteredRawMaterials" :key="rawMaterial.id" <PosCatalogCard
:title="rawMaterial.name" :cover-image="getFirstCoverImage(rawMaterial.prices)"> v-for="rawMaterial in filteredRawMaterials"
:key="rawMaterial.id"
:title="rawMaterial.name"
:cover-image="getFirstCoverImage(rawMaterial.prices)"
>
<template #header-extra> <template #header-extra>
<Badge variant="secondary" class="mt-1.5"> <Badge variant="secondary" class="mt-1.5">
{{ rawMaterial.unit_label }} {{ rawMaterial.unit_label }}
@ -89,13 +79,18 @@ function openCombination(rawMaterial: CuttingRawMaterialCatalogItem, price: Cutt
<p v-if="!rawMaterial.prices.length" class="px-3 py-4 text-sm text-muted-foreground"> <p v-if="!rawMaterial.prices.length" class="px-3 py-4 text-sm text-muted-foreground">
Belum ada varian Belum ada varian
</p> </p>
<div v-for="price in rawMaterial.prices" :key="price.id" <div
class="flex items-center gap-2.5 px-3 py-2.5 transition-all duration-200" :class="[ v-for="price in rawMaterial.prices"
:key="price.id"
class="flex items-center gap-2.5 px-3 py-2.5 transition-all duration-200"
:class="[
'cursor-pointer hover:bg-muted/30', 'cursor-pointer hover:bg-muted/30',
getMaterialCartItem(price.id) getMaterialCartItem(price.id)
? 'mx-1 my-0.5 rounded-md border-2 border-primary bg-primary/5' ? 'mx-1 my-0.5 rounded-md border-2 border-primary bg-primary/5'
: '', : '',
]" @click="!getMaterialCartItem(price.id) && emit('add-material', rawMaterial, price)"> ]"
@click="!getMaterialCartItem(price.id) && emit('add-material', rawMaterial, price)"
>
<PosCatalogVariantThumb :items="price.images" /> <PosCatalogVariantThumb :items="price.images" />
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium"> <p class="truncate text-sm font-medium">
@ -106,46 +101,36 @@ function openCombination(rawMaterial: CuttingRawMaterialCatalogItem, price: Cutt
</p> </p>
</div> </div>
<div v-if="getMaterialCartItem(price.id)" class="flex shrink-0 items-center gap-1.5"> <div v-if="getMaterialCartItem(price.id)" class="flex shrink-0 items-center gap-1.5">
<Button type="button" variant="outline" size="icon-sm" <Button
@click.stop="emit('decrease-material-qty', price.id)"> type="button"
variant="outline"
size="icon-sm"
@click.stop="emit('decrease-material-qty', price.id)"
>
<Minus class="size-3.5" /> <Minus class="size-3.5" />
</Button> </Button>
<span class="min-w-5 text-center text-xs font-semibold tabular-nums"> <span class="min-w-5 text-center text-xs font-semibold tabular-nums">
{{ getMaterialCartItem(price.id)!.material_usage }} {{ getMaterialCartItem(price.id)!.material_usage }}
</span> </span>
<div class="flex shrink-0 items-center gap-1.5"> <Button
<div class="flex flex-col items-center gap-0.5"> type="button"
<Button type="button" variant="outline" size="icon-sm" variant="outline"
@click.stop="emit('add-material', rawMaterial, price)"> size="icon-sm"
@click.stop="emit('add-material', rawMaterial, price)"
>
<Plus class="size-3.5" /> <Plus class="size-3.5" />
</Button> </Button>
<span class="text-[9px] text-muted-foreground leading-none">Keranjang</span>
</div> </div>
<div class="flex flex-col items-center gap-0.5"> <Button
<Button type="button" variant="default" size="icon-sm" v-else
@click.stop="openCombination(rawMaterial, price)"> type="button"
<Layers class="size-3.5" /> variant="outline"
</Button> size="icon-sm"
<span class="text-[9px] text-muted-foreground leading-none">Kombinasi</span> class="shrink-0"
</div> @click.stop="emit('add-material', rawMaterial, price)"
</div> >
</div>
<div v-else class="flex shrink-0 items-center gap-1.5">
<div class="flex flex-col items-center gap-0.5">
<Button type="button" variant="outline" size="icon-sm"
@click.stop="emit('add-material', rawMaterial, price)">
<Plus class="size-3.5" /> <Plus class="size-3.5" />
</Button> </Button>
<span class="text-[9px] text-muted-foreground leading-none">Keranjang</span>
</div>
<div class="flex flex-col items-center gap-0.5">
<Button type="button" variant="default" size="icon-sm"
@click.stop="openCombination(rawMaterial, price)">
<Layers class="size-3.5" />
</Button>
<span class="text-[9px] text-muted-foreground leading-none">Kombinasi</span>
</div>
</div>
</div> </div>
</PosCatalogCard> </PosCatalogCard>
</div> </div>
@ -153,10 +138,9 @@ function openCombination(rawMaterial: CuttingRawMaterialCatalogItem, price: Cutt
</CardContent> </CardContent>
</Card> </Card>
<QuickCreateRawMaterialModal v-model:open="quickCreateOpen" :units="units" <QuickCreateRawMaterialModal
@created="emit('raw-material-created', $event)" /> v-model:open="quickCreateOpen"
:units="units"
<CuttingPosCombinationDialog v-model:open="combinationOpen" :raw-material-catalog="rawMaterialCatalog" @created="emit('raw-material-created', $event)"
:existing-cart-items="materialCart" :initial-variant="combinationInitialVariant" />
@combination-created="emit('combination-created', $event)" />
</template> </template>

View File

@ -1,9 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Layers, Trash2 } from '@lucide/vue'; import { Trash2 } from '@lucide/vue';
import { computed } from 'vue';
import { DecimalInput } from '@/components/form/decimal-input'; import { DecimalInput } from '@/components/form/decimal-input';
import { NumberInput } from '@/components/form/number-input';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Field, Field,
@ -14,7 +11,7 @@ import { formErrors } from '@/lib/form';
import type { FormWithErrors } from '@/lib/form'; import type { FormWithErrors } from '@/lib/form';
import type { CuttingMaterialCartItem } from '@/types/cutting'; import type { CuttingMaterialCartItem } from '@/types/cutting';
const props = defineProps<{ defineProps<{
form: FormWithErrors; form: FormWithErrors;
materialCart: CuttingMaterialCartItem[]; materialCart: CuttingMaterialCartItem[];
}>(); }>();
@ -22,147 +19,26 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
remove: [index: number]; remove: [index: number];
'sync-field': [index: number]; 'sync-field': [index: number];
'remove-combination': [combinationId: number];
'sync-combination-result': [combinationId: number, result: number | null];
}>(); }>();
type MaterialGroup = {
type: 'combination' | 'single';
combinationId?: number;
items: Array<{ item: CuttingMaterialCartItem; index: number }>;
};
const materialGroups = computed<MaterialGroup[]>(() => {
const combinationMap = new Map<number, Array<{ item: CuttingMaterialCartItem; index: number }>>();
const singleItems: Array<{ item: CuttingMaterialCartItem; index: number }> = [];
props.materialCart.forEach((item, index) => {
if (item.combination_id) {
if (!combinationMap.has(item.combination_id)) {
combinationMap.set(item.combination_id, []);
}
combinationMap.get(item.combination_id)!.push({ item, index });
} else {
singleItems.push({ item, index });
}
});
const groups: MaterialGroup[] = [];
for (const [combinationId, items] of combinationMap) {
groups.push({
type: 'combination',
combinationId,
items,
});
}
if (singleItems.length > 0) {
groups.push({
type: 'single',
items: singleItems,
});
}
return groups;
});
function getCombinationResult(combinationId: number | undefined): number | null {
if (!combinationId) {
return null;
}
const item = props.materialCart.find(i => i.combination_id === combinationId);
return item?.combination_material_result ?? null;
}
function setCombinationResult(combinationId: number | undefined, value: string) {
if (!combinationId) {
return;
}
const result = value ? Number(value) : null;
emit('sync-combination-result', combinationId, result);
}
</script> </script>
<template> <template>
<div class="space-y-2"> <div class="space-y-2">
<p class="text-sm font-medium">Bahan Baku</p> <p class="text-sm font-medium">Bahan Baku</p>
<div v-if="materialCart.length === 0" <div
class="rounded-lg border border-dashed px-4 py-6 text-center text-sm text-muted-foreground"> v-if="materialCart.length === 0"
class="rounded-lg border border-dashed px-4 py-6 text-center text-sm text-muted-foreground"
>
Belum ada bahan baku dipilih. Belum ada bahan baku dipilih.
</div> </div>
<div v-else class="scrollbar-thin max-h-80 space-y-3 overflow-y-auto overscroll-y-contain"> <div v-else class="scrollbar-thin max-h-80 space-y-3 overflow-y-auto overscroll-y-contain">
<template v-for="group in materialGroups" :key="group.combinationId ?? `single-${group.items[0]?.index}`"> <div
<div v-if="group.type === 'combination'" v-for="(item, index) in materialCart"
class="rounded-lg border-2 border-dashed border-primary/30 p-3"> :key="item.raw_material_price_id"
<div class="mb-2 flex items-center justify-between"> class="rounded-lg border p-3"
<div class="flex items-center gap-2"> >
<Layers class="size-4 text-primary" />
<span class="text-sm font-medium text-primary">Kombinasi</span>
<Badge variant="secondary" class="text-xs">
{{ group.items.length }} bahan
</Badge>
</div>
<div class="flex items-center gap-1.5">
<div class="flex items-center gap-1" @click.stop>
<NumberInput
:model-value="getCombinationResult(group.combinationId)"
class="h-7 w-16"
placeholder="Hasil"
@update:model-value="setCombinationResult(group.combinationId, $event)"
/>
<span class="text-[10px] text-muted-foreground">pcs</span>
</div>
<Button type="button" variant="ghost" size="icon"
class="size-7 shrink-0 text-destructive hover:text-destructive"
@click="group.combinationId && emit('remove-combination', group.combinationId)">
<Trash2 class="size-3.5" />
</Button>
</div>
</div>
<div class="space-y-2">
<div v-for="{ item, index } in group.items" :key="item.raw_material_price_id"
class="rounded-md border bg-background p-2.5">
<div class="mb-1.5 flex items-start justify-between gap-2">
<div class="min-w-0">
<p class="truncate text-sm font-medium">
{{ item.raw_material_name }}
</p>
<p class="truncate text-xs text-muted-foreground">
{{ item.variant }} · Stok {{ item.stock_input }} {{ item.unit_abbreviation }}
</p>
</div>
<Button type="button" variant="ghost" size="icon"
class="size-6 shrink-0 text-destructive hover:text-destructive"
@click="emit('remove', index)">
<Trash2 class="size-3" />
</Button>
</div>
<Field
:data-invalid="formErrors(form, `materials.${index}.material_usage`).length > 0 ? 'true' : undefined">
<FieldLabel class="text-xs">
Pemakaian ({{ item.unit_abbreviation }})
</FieldLabel>
<DecimalInput v-model="item.material_usage" class="h-7"
:aria-invalid="formErrors(form, `materials.${index}.material_usage`).length > 0"
@change="emit('sync-field', index)" />
<FieldError :errors="formErrors(form, `materials.${index}.material_usage`)"
class="text-[10px] mt-0.5 leading-tight" />
</Field>
</div>
</div>
</div>
<div v-else v-for="{ item, index } in group.items" :key="item.raw_material_price_id"
class="rounded-lg border p-3">
<div class="mb-2 flex items-start justify-between gap-2"> <div class="mb-2 flex items-start justify-between gap-2">
<div class="min-w-0"> <div class="min-w-0">
<p class="truncate text-sm font-medium"> <p class="truncate text-sm font-medium">
@ -172,39 +48,30 @@ function setCombinationResult(combinationId: number | undefined, value: string)
{{ item.variant }} · Stok {{ item.stock_input }} {{ item.unit_abbreviation }} {{ item.variant }} · Stok {{ item.stock_input }} {{ item.unit_abbreviation }}
</p> </p>
</div> </div>
<Button type="button" variant="ghost" size="icon" <Button
type="button"
variant="ghost"
size="icon"
class="size-7 shrink-0 text-destructive hover:text-destructive" class="size-7 shrink-0 text-destructive hover:text-destructive"
@click="emit('remove', index)"> @click="emit('remove', index)"
>
<Trash2 class="size-3.5" /> <Trash2 class="size-3.5" />
</Button> </Button>
</div> </div>
<div class="grid grid-cols-2 gap-2"> <Field :data-invalid="formErrors(form, `materials.${index}.material_usage`).length > 0 ? 'true' : undefined">
<Field
:data-invalid="formErrors(form, `materials.${index}.material_usage`).length > 0 ? 'true' : undefined">
<FieldLabel class="text-xs"> <FieldLabel class="text-xs">
Pemakaian ({{ item.unit_abbreviation }}) Pemakaian ({{ item.unit_abbreviation }})
</FieldLabel> </FieldLabel>
<DecimalInput v-model="item.material_usage" class="h-8" <DecimalInput
v-model="item.material_usage"
class="h-8"
:aria-invalid="formErrors(form, `materials.${index}.material_usage`).length > 0" :aria-invalid="formErrors(form, `materials.${index}.material_usage`).length > 0"
@change="emit('sync-field', index)" /> @change="emit('sync-field', index)"
<FieldError :errors="formErrors(form, `materials.${index}.material_usage`)" />
class="text-[10px] mt-0.5 leading-tight" /> <FieldError :errors="formErrors(form, `materials.${index}.material_usage`)" class="text-[10px] mt-0.5 leading-tight" />
</Field>
<Field
:data-invalid="formErrors(form, `materials.${index}.material_result`).length > 0 ? 'true' : undefined">
<FieldLabel class="text-xs">
Hasil (pcs)
</FieldLabel>
<NumberInput v-model="item.material_result" class="h-8"
:aria-invalid="formErrors(form, `materials.${index}.material_result`).length > 0"
@change="emit('sync-field', index)" />
<FieldError :errors="formErrors(form, `materials.${index}.material_result`)"
class="text-[10px] mt-0.5 leading-tight" />
</Field> </Field>
</div> </div>
</div> </div>
</template>
</div>
</div> </div>
</template> </template>

View File

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Check, Minus, Plus, Search } from '@lucide/vue';
import { ref } from 'vue'; import { ref } from 'vue';
import { Check, Minus, Plus, Search } from '@lucide/vue';
import PosCatalogCard from '@/components/catalog/PosCatalogCard.vue'; import PosCatalogCard from '@/components/catalog/PosCatalogCard.vue';
import PosCatalogVariantThumb from '@/components/catalog/PosCatalogVariantThumb.vue'; import PosCatalogVariantThumb from '@/components/catalog/PosCatalogVariantThumb.vue';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@ -15,8 +15,8 @@ import { Input } from '@/components/ui/input';
import { getFirstCoverImage } from '@/lib/catalog-cover'; import { getFirstCoverImage } from '@/lib/catalog-cover';
import type { CuttingProductCatalogItem, CuttingResultCartItem } from '@/types/cutting'; import type { CuttingProductCatalogItem, CuttingResultCartItem } from '@/types/cutting';
import type { CategoryOption } from '@/types/product'; import type { CategoryOption } from '@/types/product';
import QuickCreateProductModal from './QuickCreateProductModal.vue';
import type { CuttingCatalogVariant } from './useCuttingPosCart'; import type { CuttingCatalogVariant } from './useCuttingPosCart';
import QuickCreateProductModal from './QuickCreateProductModal.vue';
defineProps<{ defineProps<{
filteredProducts: CuttingProductCatalogItem[]; filteredProducts: CuttingProductCatalogItem[];

View File

@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { Save, Scissors } from '@lucide/vue'; import { Save, Scissors } from '@lucide/vue';
import MediaDropzone from '@/components/media/MediaDropzone.vue';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@ -14,15 +13,15 @@ import {
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { FIELD_LIMITS } from '@/lib/field-limits'; import { FIELD_LIMITS } from '@/lib/field-limits';
import { formErrors } from '@/lib/form'; import { formErrors, type FormWithErrors } from '@/lib/form';
import type { FormWithErrors } from '@/lib/form';
import type { import type {
CuttingMaterialCartItem, CuttingMaterialCartItem,
CuttingResultCartItem, CuttingResultCartItem,
} from '@/types/cutting'; } from '@/types/cutting';
import type { MediaUploadState } from '@/types/media';
import CuttingPosMaterialSummaryItems from './CuttingPosMaterialSummaryItems.vue'; import CuttingPosMaterialSummaryItems from './CuttingPosMaterialSummaryItems.vue';
import CuttingPosResultSummaryItems from './CuttingPosResultSummaryItems.vue'; import CuttingPosResultSummaryItems from './CuttingPosResultSummaryItems.vue';
import MediaDropzone from '@/components/media/MediaDropzone.vue';
import type { MediaUploadState } from '@/types/media';
defineProps<{ defineProps<{
form: FormWithErrors & { description: string; processing?: boolean }; form: FormWithErrors & { description: string; processing?: boolean };
@ -42,8 +41,6 @@ const emit = defineEmits<{
'remove-result': [index: number]; 'remove-result': [index: number];
'sync-result-totals': [item: CuttingResultCartItem]; 'sync-result-totals': [item: CuttingResultCartItem];
'sync-result-field': [index: number]; 'sync-result-field': [index: number];
'remove-combination': [combinationId: number];
'sync-combination-result': [combinationId: number, result: number | null];
}>(); }>();
const imageState = defineModel<MediaUploadState>('imageState', { const imageState = defineModel<MediaUploadState>('imageState', {
@ -54,19 +51,27 @@ const imageState = defineModel<MediaUploadState>('imageState', {
<template> <template>
<Card class="h-fit xl:sticky xl:top-4"> <Card class="h-fit xl:sticky xl:top-4">
<CardHeader class="pb-3"> <CardHeader class="pb-3">
<CardTitle class="flex items-center justify-between gap-2 text-base"> <CardTitle
class="flex items-center justify-between gap-2 text-base"
>
<span class="flex items-center gap-2"> <span class="flex items-center gap-2">
<Scissors class="size-4" /> <Scissors class="size-4" />
Ringkasan Cutting Ringkasan Cutting
</span> </span>
<span class="flex items-center gap-2"> <span class="flex items-center gap-2">
<button v-if="materialCart.length > 0 || resultCart.length > 0" type="button" <button
v-if="materialCart.length > 0 || resultCart.length > 0"
type="button"
class="text-xs font-normal text-primary underline underline-offset-2 hover:text-primary/80" class="text-xs font-normal text-primary underline underline-offset-2 hover:text-primary/80"
@click="emit('open-detail')"> @click="emit('open-detail')"
>
Lihat Detail Lihat Detail
</button> </button>
<Badge v-if="!isCreateMode && totalResultPieces > 0" variant="secondary" <Badge
class="font-semibold tabular-nums"> v-if="!isCreateMode && totalResultPieces > 0"
variant="secondary"
class="font-semibold tabular-nums"
>
Total: {{ totalResultPieces }} pcs Total: {{ totalResultPieces }} pcs
</Badge> </Badge>
</span> </span>
@ -77,32 +82,58 @@ const imageState = defineModel<MediaUploadState>('imageState', {
<FieldGroup> <FieldGroup>
<FieldSet class="grid gap-4"> <FieldSet class="grid gap-4">
<Field> <Field>
<FieldLabel for="description">Keterangan</FieldLabel> <FieldLabel for="description"
<Textarea id="description" v-model="form.description" placeholder="Masukkan keterangan" >Keterangan</FieldLabel
rows="2" :maxlength="FIELD_LIMITS.description" /> >
<FieldError :errors="formErrors(form, 'description')" /> <Textarea
id="description"
v-model="form.description"
placeholder="Masukkan keterangan"
rows="2"
:maxlength="FIELD_LIMITS.description"
/>
<FieldError
:errors="formErrors(form, 'description')"
/>
</Field> </Field>
<MediaDropzone id="cutting-images" v-model="imageState" label="Foto Cutting" :max-files="10" <MediaDropzone
:errors="formErrors(form, 'images')" /> id="cutting-images"
v-model="imageState"
label="Foto Cutting"
:max-files="10"
:errors="formErrors(form, 'images')"
/>
<CuttingPosMaterialSummaryItems :form="form" :material-cart="materialCart" <CuttingPosMaterialSummaryItems
@remove="emit('remove-material', $event)" @sync-field="emit('sync-material-field', $event)" :form="form"
@remove-combination="emit('remove-combination', $event)" :material-cart="materialCart"
@sync-combination-result="(combinationId, result) => emit('sync-combination-result', combinationId, result)" /> @remove="emit('remove-material', $event)"
@sync-field="emit('sync-material-field', $event)"
/>
<Separator /> <Separator />
<CuttingPosResultSummaryItems :form="form" :result-cart="resultCart" <CuttingPosResultSummaryItems
:total-result-pieces="totalResultPieces" :is-create-mode="isCreateMode" :form="form"
@remove="emit('remove-result', $event)" @sync-totals="emit('sync-result-totals', $event)" :result-cart="resultCart"
@sync-field="emit('sync-result-field', $event)" /> :total-result-pieces="totalResultPieces"
:is-create-mode="isCreateMode"
@remove="emit('remove-result', $event)"
@sync-totals="emit('sync-result-totals', $event)"
@sync-field="emit('sync-result-field', $event)"
/>
<Button type="submit" class="w-full" :disabled="form.processing || <Button
type="submit"
class="w-full"
:disabled="
form.processing ||
isUploading || isUploading ||
materialCart.length === 0 || materialCart.length === 0 ||
resultCart.length === 0 resultCart.length === 0
"> "
>
<Save class="size-4" /> <Save class="size-4" />
{{ {{
isUploading isUploading

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