Compare commits
19 Commits
4ba6b9da9f
...
15e2b7ec5b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15e2b7ec5b | ||
|
|
a3ff10d6e1 | ||
|
|
ba3f7ef9f9 | ||
|
|
b52b9ce08c | ||
|
|
8e306c8f73 | ||
|
|
6158a91060 | ||
|
|
9d0075d7b2 | ||
|
|
80e1542151 | ||
|
|
8404b8af41 | ||
|
|
37d9ac10a6 | ||
|
|
2b7208a0c4 | ||
|
|
f1c3658166 | ||
|
|
786d0fdb39 | ||
|
|
e153497114 | ||
|
|
a8ab96b10b | ||
|
|
c685f45bcf | ||
|
|
143448e6e0 | ||
|
|
bac44df627 | ||
|
|
59d7f94d0f |
@ -3,14 +3,18 @@
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\PayrollAdjustmentType;
|
||||
use App\Enums\Role;
|
||||
use App\Models\Attendance;
|
||||
use App\Models\Employee;
|
||||
use App\Models\LeaveRequest;
|
||||
use App\Models\Payroll;
|
||||
use App\Models\PayrollPeriod;
|
||||
use App\Models\User;
|
||||
use App\Settings\HrSettings;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ApplyAttendancePenalties extends Command
|
||||
{
|
||||
@ -36,11 +40,16 @@ public function handle(): int
|
||||
}
|
||||
|
||||
$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()
|
||||
->whereHas('user', function ($query) {
|
||||
$query->active();
|
||||
})
|
||||
->whereHas('user', fn ($query) => $query->active())
|
||||
->get();
|
||||
|
||||
$lateCount = 0;
|
||||
@ -48,106 +57,118 @@ public function handle(): int
|
||||
$skippedLeave = 0;
|
||||
$skippedDuplicate = 0;
|
||||
|
||||
foreach ($employees as $employee) {
|
||||
$hasLeave = LeaveRequest::query()
|
||||
->approved()
|
||||
->where('employee_id', $employee->id)
|
||||
->whereDate('start_date', '<=', $date)
|
||||
->whereDate('end_date', '>=', $date)
|
||||
->exists();
|
||||
try {
|
||||
DB::transaction(function () use ($employees, $date, $scheduledCheckIn, $latePenalty, $absentPenalty, $createdBy, &$lateCount, &$absentCount, &$skippedLeave, &$skippedDuplicate): void {
|
||||
foreach ($employees as $employee) {
|
||||
$hasLeave = LeaveRequest::query()
|
||||
->approved()
|
||||
->where('employee_id', $employee->id)
|
||||
->whereDate('start_date', '<=', $date)
|
||||
->whereDate('end_date', '>=', $date)
|
||||
->exists();
|
||||
|
||||
if ($hasLeave) {
|
||||
$skippedLeave++;
|
||||
if ($hasLeave) {
|
||||
$skippedLeave++;
|
||||
|
||||
continue;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
$attendance = Attendance::query()
|
||||
->where('employee_id', $employee->id)
|
||||
->whereDate('attendance_date', $date)
|
||||
->first();
|
||||
$attendance = Attendance::query()
|
||||
->where('employee_id', $employee->id)
|
||||
->whereDate('attendance_date', $date)
|
||||
->first();
|
||||
|
||||
if ($attendance === null) {
|
||||
if ($absentPenalty <= 0) {
|
||||
continue;
|
||||
if ($attendance === null) {
|
||||
if ($absentPenalty <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$payroll = $this->findOpenPayroll($employee->id);
|
||||
|
||||
if ($payroll === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$alreadyExists = $payroll->adjustments()
|
||||
->whereNull('attendance_id')
|
||||
->deduction()
|
||||
->where('description', 'like', "%Bolos {$date->format('d/m/Y')}%")
|
||||
->exists();
|
||||
|
||||
if ($alreadyExists) {
|
||||
$skippedDuplicate++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$payroll->adjustments()->create([
|
||||
'type' => PayrollAdjustmentType::DEDUCTION,
|
||||
'amount' => $absentPenalty,
|
||||
'description' => "Bolos {$date->format('d/m/Y')}",
|
||||
'created_by_id' => $createdBy->id,
|
||||
]);
|
||||
|
||||
$payroll->load('adjustments');
|
||||
$payroll->recalculateAmounts();
|
||||
$payroll->save();
|
||||
|
||||
$absentCount++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($latePenalty <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$checkInTime = Carbon::parse($attendance->check_in_at);
|
||||
|
||||
if ($checkInTime->lte($scheduledCheckIn)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$payroll = $this->findOpenPayroll($employee->id);
|
||||
|
||||
if ($payroll === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$alreadyExists = $payroll->adjustments()
|
||||
->where('attendance_id', $attendance->id)
|
||||
->where('type', PayrollAdjustmentType::DEDUCTION)
|
||||
->exists();
|
||||
|
||||
if ($alreadyExists) {
|
||||
$skippedDuplicate++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$minutesLate = (int) $scheduledCheckIn->diffInMinutes($checkInTime);
|
||||
|
||||
$payroll->adjustments()->create([
|
||||
'type' => PayrollAdjustmentType::DEDUCTION,
|
||||
'amount' => $latePenalty,
|
||||
'description' => "Terlambat {$date->format('d/m/Y')} ({$minutesLate} menit)",
|
||||
'attendance_id' => $attendance->id,
|
||||
'created_by_id' => $createdBy->id,
|
||||
]);
|
||||
|
||||
$payroll->load('adjustments');
|
||||
$payroll->recalculateAmounts();
|
||||
$payroll->save();
|
||||
|
||||
$lateCount++;
|
||||
}
|
||||
|
||||
$payroll = $this->findOpenPayroll($employee->id);
|
||||
|
||||
if ($payroll === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$alreadyExists = $payroll->adjustments()
|
||||
->where('attendance_id', null)
|
||||
->deduction()
|
||||
->where('description', 'like', "%Bolos {$date->format('d/m/Y')}%")
|
||||
->exists();
|
||||
|
||||
if ($alreadyExists) {
|
||||
$skippedDuplicate++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$payroll->adjustments()->create([
|
||||
'type' => PayrollAdjustmentType::DEDUCTION,
|
||||
'amount' => $absentPenalty,
|
||||
'description' => "Bolos {$date->format('d/m/Y')}",
|
||||
'created_by_id' => 1,
|
||||
]);
|
||||
|
||||
$payroll->load('adjustments');
|
||||
$payroll->recalculateAmounts();
|
||||
$payroll->save();
|
||||
|
||||
$absentCount++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($latePenalty <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$checkInTime = Carbon::parse($attendance->check_in_at);
|
||||
|
||||
if ($checkInTime->lte($scheduledCheckIn)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$payroll = $this->findOpenPayroll($employee->id);
|
||||
|
||||
if ($payroll === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$alreadyExists = $payroll->adjustments()
|
||||
->where('attendance_id', $attendance->id)
|
||||
->where('type', PayrollAdjustmentType::DEDUCTION)
|
||||
->exists();
|
||||
|
||||
if ($alreadyExists) {
|
||||
$skippedDuplicate++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$minutesLate = (int) $scheduledCheckIn->diffInMinutes($checkInTime);
|
||||
|
||||
$payroll->adjustments()->create([
|
||||
'type' => PayrollAdjustmentType::DEDUCTION,
|
||||
'amount' => $latePenalty,
|
||||
'description' => "Terlambat {$date->format('d/m/Y')} ({$minutesLate} menit)",
|
||||
'attendance_id' => $attendance->id,
|
||||
'created_by_id' => 1,
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
Log::error("Gagal menerapkan penalti presensi: {$e->getMessage()}", [
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
$payroll->load('adjustments');
|
||||
$payroll->recalculateAmounts();
|
||||
$payroll->save();
|
||||
$this->error('Gagal menerapkan penalti presensi: '.$e->getMessage());
|
||||
|
||||
$lateCount++;
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info("Selesai memproses tanggal {$date->format('d/m/Y')}:");
|
||||
@ -161,9 +182,7 @@ public function handle(): int
|
||||
|
||||
private function findOpenPayroll(int $employeeId): ?Payroll
|
||||
{
|
||||
$period = PayrollPeriod::query()
|
||||
->open()
|
||||
->first();
|
||||
$period = PayrollPeriod::query()->open()->first();
|
||||
|
||||
if ($period === null) {
|
||||
return null;
|
||||
@ -174,4 +193,12 @@ private function findOpenPayroll(int $employeeId): ?Payroll
|
||||
->where('employee_id', $employeeId)
|
||||
->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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -135,8 +135,16 @@ enum Permission: string
|
||||
case PAYROLL_VIEW = 'payroll.view';
|
||||
case PAYROLL_ADJUST = 'payroll.adjust';
|
||||
|
||||
case SETTINGS_VIEW = 'settings.view';
|
||||
case SETTINGS_UPDATE = 'settings.update';
|
||||
case SETTINGS_VIEW_SYSTEM = 'settings.view_system';
|
||||
case SETTINGS_UPDATE_SYSTEM = 'settings.update_system';
|
||||
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';
|
||||
|
||||
@ -275,8 +283,16 @@ public function label(): string
|
||||
self::PAYROLL_VIEW => 'Lihat Gaji',
|
||||
self::PAYROLL_ADJUST => 'Sesuaikan Gaji',
|
||||
|
||||
self::SETTINGS_VIEW => 'Lihat Pengaturan Aplikasi',
|
||||
self::SETTINGS_UPDATE => 'Ubah Pengaturan Aplikasi',
|
||||
self::SETTINGS_VIEW_SYSTEM => 'Lihat Pengaturan Sistem',
|
||||
self::SETTINGS_UPDATE_SYSTEM => 'Ubah Pengaturan Sistem',
|
||||
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',
|
||||
|
||||
@ -335,7 +351,11 @@ public function group(): string
|
||||
self::EMPLOYEE_ADVANCES_DELETE, self::EMPLOYEE_ADVANCES_VERIFY,
|
||||
self::EMPLOYEE_ADVANCES_PAY => 'Kasbon',
|
||||
self::PAYROLL_VIEW, self::PAYROLL_ADJUST => 'Gaji',
|
||||
self::SETTINGS_VIEW, self::SETTINGS_UPDATE => 'Pengaturan Aplikasi',
|
||||
self::SETTINGS_VIEW_SYSTEM, self::SETTINGS_UPDATE_SYSTEM,
|
||||
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::ROLES_VIEW, self::ROLES_CREATE, self::ROLES_UPDATE,
|
||||
self::ROLES_DELETE => 'Role & Permission',
|
||||
|
||||
@ -193,8 +193,16 @@ public function permissions(): array
|
||||
Permission::PAYROLL_VIEW,
|
||||
Permission::PAYROLL_ADJUST,
|
||||
|
||||
Permission::SETTINGS_VIEW,
|
||||
Permission::SETTINGS_UPDATE,
|
||||
Permission::SETTINGS_VIEW_SYSTEM,
|
||||
Permission::SETTINGS_UPDATE_SYSTEM,
|
||||
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 => [
|
||||
|
||||
@ -5,13 +5,14 @@
|
||||
use App\Http\Controllers\Concerns\FlashesEntityMessage;
|
||||
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
|
||||
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\WithdrawRequest;
|
||||
use App\Http\Requests\Admin\Finance\UpdateCashTransactionRequest;
|
||||
use App\Models\CashTransaction;
|
||||
use App\Services\Finance\CashService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
@ -38,8 +39,14 @@ public function index(Request $request): Response
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(UpdateCashTransactionRequest $request, CashTransaction $cashTransaction): RedirectResponse
|
||||
public function update(CashTransactionRequest $request, CashTransaction $cashTransaction): RedirectResponse
|
||||
{
|
||||
try {
|
||||
$cashTransaction->ensureEditable();
|
||||
} catch (ValidationException) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$this->cashService->updateDeposit($cashTransaction, $request->validated());
|
||||
|
||||
$this->flashUpdated('Setor kas');
|
||||
@ -49,6 +56,12 @@ public function update(UpdateCashTransactionRequest $request, CashTransaction $c
|
||||
|
||||
public function destroy(CashTransaction $cashTransaction): RedirectResponse
|
||||
{
|
||||
try {
|
||||
$cashTransaction->ensureEditable();
|
||||
} catch (ValidationException) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$this->cashService->deleteTransaction($cashTransaction);
|
||||
|
||||
$this->flashDeleted('Setor kas');
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
use App\Http\Controllers\Concerns\FlashesEntityMessage;
|
||||
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
|
||||
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\EmployeeAdvanceRequest;
|
||||
use App\Http\Requests\Admin\Finance\PayEmployeeAdvanceRequest;
|
||||
@ -13,6 +14,7 @@
|
||||
use App\Services\Finance\EmployeeAdvanceService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
@ -49,6 +51,12 @@ public function store(EmployeeAdvanceRequest $request): 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->flashUpdated('Kasbon');
|
||||
@ -58,6 +66,12 @@ public function update(EmployeeAdvanceRequest $request, EmployeeAdvance $employe
|
||||
|
||||
public function destroy(DestroyEmployeeAdvanceRequest $request, EmployeeAdvance $employeeAdvance): RedirectResponse
|
||||
{
|
||||
try {
|
||||
$employeeAdvance->ensureEditable();
|
||||
} catch (ValidationException) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$this->employeeAdvanceService->delete($employeeAdvance, $request->user());
|
||||
|
||||
$this->flashDeleted('Kasbon');
|
||||
@ -65,7 +79,7 @@ public function destroy(DestroyEmployeeAdvanceRequest $request, EmployeeAdvance
|
||||
return redirect()->route('admin.finance.employee_advances.index');
|
||||
}
|
||||
|
||||
public function approve(Request $request, EmployeeAdvance $employeeAdvance): RedirectResponse
|
||||
public function approve(ApproveEmployeeAdvanceRequest $request, EmployeeAdvance $employeeAdvance): RedirectResponse
|
||||
{
|
||||
$this->employeeAdvanceService->approve($employeeAdvance, $request->user());
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
use App\Enums\EmploymentStatus;
|
||||
use App\Enums\Gender;
|
||||
use App\Enums\Role;
|
||||
use App\Http\Controllers\Concerns\FlashesEntityMessage;
|
||||
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
|
||||
use App\Http\Controllers\Controller;
|
||||
@ -46,7 +47,7 @@ public function index(Request $request): Response
|
||||
'employment_status' => $employmentStatus,
|
||||
'is_active' => $isActive,
|
||||
]),
|
||||
'roles' => $this->employeeService->assignableRoleOptions(),
|
||||
'roles' => Role::assignableSelectOptions(),
|
||||
'genders' => Gender::selectOptions(),
|
||||
'employmentStatuses' => EmploymentStatus::selectOptions(),
|
||||
]);
|
||||
@ -57,7 +58,7 @@ public function create(): Response
|
||||
return Inertia::render('admin/hr/employees/Create', [
|
||||
'genders' => Gender::selectOptions(),
|
||||
'employmentStatuses' => EmploymentStatus::selectOptions(),
|
||||
'roles' => $this->employeeService->assignableRoleOptions(),
|
||||
'roles' => Role::assignableSelectOptions(),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -77,7 +78,7 @@ public function edit(User $user): Response
|
||||
return Inertia::render('admin/hr/employees/Edit', [
|
||||
'genders' => Gender::selectOptions(),
|
||||
'employmentStatuses' => EmploymentStatus::selectOptions(),
|
||||
'roles' => $this->employeeService->assignableRoleOptions(),
|
||||
'roles' => Role::assignableSelectOptions(),
|
||||
'employee' => $editData['employee'],
|
||||
'profilePhoto' => $editData['profilePhoto'],
|
||||
]);
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
use App\Services\Hr\LeaveRequestService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
@ -45,6 +46,12 @@ public function store(SubmitLeaveRequest $request): 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->flashUpdated('Pengajuan cuti');
|
||||
@ -54,6 +61,12 @@ public function update(SubmitLeaveRequest $request, LeaveRequest $leaveRequest):
|
||||
|
||||
public function destroy(LeaveRequest $leaveRequest): RedirectResponse
|
||||
{
|
||||
try {
|
||||
$leaveRequest->ensureEditable();
|
||||
} catch (ValidationException) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$this->leaveRequestService->delete($leaveRequest);
|
||||
|
||||
$this->flashDeleted('Pengajuan cuti');
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers\Admin\Manage\Cutting;
|
||||
|
||||
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\CuttingDraftResultRequest;
|
||||
use App\Http\Requests\Admin\Manage\CuttingQuickCreateProductRequest;
|
||||
@ -47,6 +48,20 @@ public function destroyResult(Request $request, ProductVariant $productVariant):
|
||||
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
|
||||
{
|
||||
$rawMaterial = $this->cuttingService->quickCreateRawMaterial($request->validated());
|
||||
|
||||
@ -8,7 +8,6 @@
|
||||
use App\Http\Requests\Admin\Master\CustomerRequest;
|
||||
use App\Models\Customer;
|
||||
use App\Services\Master\CustomerService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
@ -58,14 +57,4 @@ public function destroy(Customer $customer): RedirectResponse
|
||||
|
||||
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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
use App\Services\System\Setting\SocialMediaService;
|
||||
use App\Services\System\Setting\SystemService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
@ -33,8 +34,10 @@ public function __construct(
|
||||
private readonly OwnerVerificationService $ownerVerificationService,
|
||||
) {}
|
||||
|
||||
public function index(): Response
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
return Inertia::render('admin/system/setting/Index', [
|
||||
'system' => $this->systemService->systemData(),
|
||||
'socialMedia' => $this->socialMediaService->socialMediaData(),
|
||||
@ -42,15 +45,16 @@ public function index(): Response
|
||||
'hr' => $this->hrSettingService->hrData(),
|
||||
'homepage' => $this->homepageSettingService->homepageData(),
|
||||
'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
|
||||
{
|
||||
if (! $request->user()->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)) {
|
||||
abort(403, 'Aksi ini tidak diizinkan.');
|
||||
}
|
||||
|
||||
$this->systemService->updateSystem($request->validated());
|
||||
|
||||
$this->flashSuccess('Pengaturan sistem berhasil disimpan.');
|
||||
@ -60,10 +64,6 @@ public function updateSystem(SystemRequest $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->flashSuccess('Pengaturan media sosial berhasil disimpan.');
|
||||
@ -86,10 +86,6 @@ public function updateMarketplace(MarketplaceRequest $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->flashSuccess('Pengaturan HR berhasil disimpan.');
|
||||
@ -99,10 +95,6 @@ public function updateHr(HrSettingRequest $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->flashSuccess('Pengaturan homepage berhasil disimpan.');
|
||||
|
||||
25
app/Http/Controllers/Api/Master/CustomerApiController.php
Normal file
25
app/Http/Controllers/Api/Master/CustomerApiController.php
Normal file
@ -0,0 +1,25 @@
|
||||
<?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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -1,12 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin\Finance;
|
||||
namespace App\Http\Requests\Admin\Finance\Cash;
|
||||
|
||||
use App\Enums\Permission;
|
||||
use App\Http\Requests\Concerns\ValidatesMediaUploads;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateCashTransactionRequest extends FormRequest
|
||||
class CashTransactionRequest extends FormRequest
|
||||
{
|
||||
use ValidatesMediaUploads;
|
||||
|
||||
@ -18,4 +18,14 @@ public function rules(): array
|
||||
'amount' => ['nullable', 'integer', 'min:1'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'amount' => 'jumlah',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,32 @@
|
||||
<?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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -25,6 +25,7 @@ public function rules(): array
|
||||
Rule::exists('raw_material_prices', 'id')->whereNull('deleted_at'),
|
||||
],
|
||||
'material_usage' => ['required', 'numeric', 'decimal:0,4', 'gt:0'],
|
||||
'material_result' => ['nullable', 'integer', 'gte:0'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -39,10 +39,12 @@ public function rules(): array
|
||||
$rules['materials.*.raw_material_price_id'] = [
|
||||
'required',
|
||||
'integer',
|
||||
'distinct',
|
||||
Rule::exists('raw_material_prices', 'id')->whereNull('deleted_at'),
|
||||
];
|
||||
$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.*.product_variant_id'] = [
|
||||
@ -73,6 +75,8 @@ public function attributes(): array
|
||||
'materials' => 'Bahan Baku',
|
||||
'materials.*.raw_material_price_id' => 'Bahan Baku',
|
||||
'materials.*.material_usage' => 'Pemakaian',
|
||||
'materials.*.material_result' => 'Hasil',
|
||||
'materials.*.combination_material_result' => 'Hasil Kombinasi',
|
||||
'results' => 'Hasil Produk',
|
||||
'results.*.product_variant_id' => 'Varian Produk',
|
||||
'results.*.cutting_result' => 'Hasil',
|
||||
|
||||
@ -9,7 +9,7 @@ class HomepageSettingRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can(Permission::SETTINGS_UPDATE->value) ?? false;
|
||||
return $this->user()?->can(Permission::SETTINGS_UPDATE_HOMEPAGE->value) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -24,6 +24,8 @@ public function rules(): array
|
||||
'about_image_s3_key' => ['nullable', 'string'],
|
||||
'gallery_s3_keys' => ['nullable', 'array', 'max:10'],
|
||||
'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.*' => ['integer'],
|
||||
];
|
||||
|
||||
@ -9,7 +9,7 @@ class HrSettingRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can(Permission::SETTINGS_UPDATE->value) ?? false;
|
||||
return $this->user()?->can(Permission::SETTINGS_UPDATE_HR->value) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -13,7 +13,7 @@ class MarketplaceRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can(Permission::SETTINGS_UPDATE->value) ?? false;
|
||||
return $this->user()?->can(Permission::SETTINGS_UPDATE_MARKETPLACE->value) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -9,7 +9,7 @@ class SocialMediaRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can(Permission::SETTINGS_UPDATE->value) ?? false;
|
||||
return $this->user()?->can(Permission::SETTINGS_UPDATE_SOCIAL_MEDIA->value) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -10,7 +10,7 @@ class SystemRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can(Permission::SETTINGS_UPDATE->value) ?? false;
|
||||
return $this->user()?->can(Permission::SETTINGS_UPDATE_SYSTEM->value) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -10,6 +10,8 @@ trait ValidatesMediaUploads
|
||||
protected function photoRules(string $prefix = 'photos', int $max = 1): array
|
||||
{
|
||||
return [
|
||||
$prefix => ['nullable', 'array', "max:{$max}"],
|
||||
"{$prefix}.*" => ['image', 'mimes:jpg,jpeg,png,webp', 'max:5120'],
|
||||
's3_keys' => ['nullable', 'array', "max:{$max}"],
|
||||
's3_keys.*' => ['required', 'string'],
|
||||
'remove_media_ids' => ['nullable', 'array'],
|
||||
@ -33,9 +35,11 @@ protected function variantImageRules(string $variantsKey = 'variants', int $max
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function photoUploadAttributes(string $label): array
|
||||
protected function photoUploadAttributes(string $label, string $prefix = 'photos'): array
|
||||
{
|
||||
return [
|
||||
$prefix => $label,
|
||||
"{$prefix}.*" => $label,
|
||||
's3_keys' => $label,
|
||||
's3_keys.*' => $label,
|
||||
'remove_media_ids' => 'media yang dihapus',
|
||||
|
||||
@ -5,7 +5,6 @@
|
||||
use App\Models\Concerns\HasModuleMedia;
|
||||
use App\Models\Concerns\InteractsWithActivityLog;
|
||||
use App\Support\Media\MediaPresenter;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Attributes\Appends;
|
||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
@ -49,7 +48,7 @@ protected function casts(): array
|
||||
public function attendanceDateFormatted(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => Carbon::parse($this->attendance_date)->translatedFormat('l, d F Y'),
|
||||
get: fn () => $this->attendance_date?->translatedFormat('l, d F Y'),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -16,6 +16,7 @@
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Spatie\MediaLibrary\HasMedia;
|
||||
|
||||
#[Guarded(['id'])]
|
||||
@ -127,6 +128,15 @@ public function sourceBadgeClass(): Attribute
|
||||
}
|
||||
|
||||
// 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
|
||||
{
|
||||
return $this->isEmployeeAdvanceRepayment() ? 'Pelunasan Kasbon' : 'Pencairan Kasbon';
|
||||
|
||||
@ -113,6 +113,11 @@ public function materials(): HasMany
|
||||
return $this->hasMany(CuttingMaterial::class);
|
||||
}
|
||||
|
||||
public function combinations(): HasMany
|
||||
{
|
||||
return $this->hasMany(CuttingMaterialCombination::class);
|
||||
}
|
||||
|
||||
public function resultPrices(): HasMany
|
||||
{
|
||||
return $this->hasMany(CuttingResultPrice::class);
|
||||
|
||||
@ -27,6 +27,7 @@ protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'material_usage' => 'decimal:4',
|
||||
'material_result' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
@ -88,6 +89,11 @@ public function rawMaterialPrice(): BelongsTo
|
||||
return $this->belongsTo(RawMaterialPrice::class);
|
||||
}
|
||||
|
||||
public function combination(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CuttingMaterialCombination::class, 'combination_id');
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
|
||||
39
app/Models/CuttingMaterialCombination.php
Normal file
39
app/Models/CuttingMaterialCombination.php
Normal file
@ -0,0 +1,39 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
@ -4,7 +4,6 @@
|
||||
|
||||
use App\Enums\EmploymentStatus;
|
||||
use App\Models\Concerns\InteractsWithActivityLog;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Attributes\Appends;
|
||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||
use Illuminate\Database\Eloquent\Attributes\Scope;
|
||||
@ -78,7 +77,7 @@ public function employmentStatusLabel(): Attribute
|
||||
public function joinDateFormatted(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->join_date ? Carbon::parse($this->join_date)->translatedFormat('l, d F Y') : '-',
|
||||
get: fn () => $this->join_date?->translatedFormat('l, d F Y') ?? '-',
|
||||
);
|
||||
}
|
||||
|
||||
@ -92,7 +91,7 @@ public function joinDateInput(): Attribute
|
||||
public function resignDateFormatted(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->resign_date ? Carbon::parse($this->resign_date)->translatedFormat('l, d F Y') : null,
|
||||
get: fn () => $this->resign_date?->translatedFormat('l, d F Y'),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\EmployeeAdvanceStatus;
|
||||
use App\Enums\Permission;
|
||||
use App\Models\Concerns\HasRejection;
|
||||
use App\Models\Concerns\InteractsWithActivityLog;
|
||||
use Illuminate\Database\Eloquent\Attributes\Appends;
|
||||
@ -14,6 +15,7 @@
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
#[Guarded(['id'])]
|
||||
#[Appends([
|
||||
@ -174,7 +176,24 @@ public function statusLabel(): Attribute
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Relation
|
||||
// 5. Other Methods
|
||||
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
|
||||
{
|
||||
return $this->belongsTo(CashTransaction::class);
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\LeaveRequestStatus;
|
||||
use App\Enums\Permission;
|
||||
use App\Models\Concerns\HasRejection;
|
||||
use App\Models\Concerns\InteractsWithActivityLog;
|
||||
use Illuminate\Database\Eloquent\Attributes\Appends;
|
||||
@ -13,6 +14,7 @@
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
#[Guarded(['id'])]
|
||||
#[Appends([
|
||||
@ -136,7 +138,24 @@ public function statusLabel(): Attribute
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Relation
|
||||
// 5. Other Methods
|
||||
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
|
||||
{
|
||||
return $this->belongsTo(Employee::class);
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\EmployeeAdvanceStatus;
|
||||
use App\Enums\PayrollAdjustmentType;
|
||||
use App\Enums\PayrollStatus;
|
||||
use App\Models\Concerns\InteractsWithActivityLog;
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Spatie\Activitylog\Support\ActivityBuffer;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
@ -19,7 +20,10 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
$this->app->scoped(
|
||||
ActivityBuffer::class,
|
||||
\App\Support\ActivityLog\ActivityBuffer::class
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -3,27 +3,30 @@
|
||||
namespace App\Services\Account;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\UserProfile;
|
||||
use App\Services\Concerns\RunsInTransaction;
|
||||
use App\Services\Concerns\SyncsPhotos;
|
||||
use App\Services\Media\MediaService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class ProfileService
|
||||
{
|
||||
use RunsInTransaction, SyncsPhotos;
|
||||
|
||||
public function __construct(
|
||||
private readonly MediaService $mediaService,
|
||||
) {}
|
||||
|
||||
public function update(array $validated, User $user): void
|
||||
{
|
||||
try {
|
||||
DB::transaction(function () use ($user, $validated): void {
|
||||
$this->runInTransaction(
|
||||
function () use ($user, $validated): void {
|
||||
$user->update([
|
||||
'email' => $validated['email'],
|
||||
'username' => $validated['username'],
|
||||
]);
|
||||
|
||||
/** @var UserProfile $profile */
|
||||
$profile = $user->profile()->updateOrCreate(
|
||||
['user_id' => $user->id],
|
||||
[
|
||||
@ -35,29 +38,20 @@ public function update(array $validated, User $user): void
|
||||
],
|
||||
);
|
||||
|
||||
$s3Keys = ! empty($validated['profile_s3_key']) ? [$validated['profile_s3_key']] : null;
|
||||
$removeIds = $validated['remove_profile_photo_ids'] ?? null;
|
||||
|
||||
$this->mediaService->syncCollection(
|
||||
$this->syncPhotos(
|
||||
$profile,
|
||||
'profile_photo',
|
||||
null,
|
||||
$removeIds,
|
||||
1,
|
||||
s3Keys: $s3Keys,
|
||||
[
|
||||
'photos' => $validated['profile_photo'] ?? null,
|
||||
'remove_media_ids' => $validated['remove_profile_photo_ids'] ?? null,
|
||||
's3_keys' => ! empty($validated['profile_s3_key']) ? [$validated['profile_s3_key']] : null,
|
||||
],
|
||||
maxPhotos: 1,
|
||||
required: false,
|
||||
collection: 'profile_photo',
|
||||
);
|
||||
});
|
||||
} 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.',
|
||||
]);
|
||||
}
|
||||
},
|
||||
'Gagal memperbarui profil',
|
||||
);
|
||||
}
|
||||
|
||||
public function updatePassword(User $user, string $password): void
|
||||
|
||||
33
app/Services/Concerns/RunsInTransaction.php
Normal file
33
app/Services/Concerns/RunsInTransaction.php
Normal file
@ -0,0 +1,33 @@
|
||||
<?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.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
32
app/Services/Concerns/SyncsPhotos.php
Normal file
32
app/Services/Concerns/SyncsPhotos.php
Normal file
@ -0,0 +1,32 @@
|
||||
<?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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -6,18 +6,20 @@
|
||||
use App\Models\CashAccount;
|
||||
use App\Models\CashTransaction;
|
||||
use App\Models\User;
|
||||
use App\Services\Concerns\RunsInTransaction;
|
||||
use App\Services\Concerns\SyncsPhotos;
|
||||
use App\Services\Media\MediaService;
|
||||
use App\Services\System\PushNotificationService;
|
||||
use App\Support\Media\MediaPresenter;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class CashService
|
||||
{
|
||||
use RunsInTransaction, SyncsPhotos;
|
||||
|
||||
private const MAX_PHOTOS = 1;
|
||||
|
||||
public function __construct(
|
||||
@ -66,8 +68,8 @@ public function paginateForIndex(CashAccount $cashAccount, array $tableQuery, st
|
||||
|
||||
public function deposit(CashAccount $cashAccount, array $validated, User $user): CashTransaction
|
||||
{
|
||||
try {
|
||||
$transaction = DB::transaction(function () use ($cashAccount, $validated, $user): CashTransaction {
|
||||
$transaction = $this->runInTransaction(
|
||||
function () use ($cashAccount, $validated, $user): CashTransaction {
|
||||
$account = CashAccount::query()->lockForUpdate()->findOrFail($cashAccount->id);
|
||||
$amount = (int) $validated['amount'];
|
||||
$newBalance = $account->balance + $amount;
|
||||
@ -83,21 +85,12 @@ public function deposit(CashAccount $cashAccount, array $validated, User $user):
|
||||
'created_by_id' => $user->id,
|
||||
]);
|
||||
|
||||
$this->syncPhotos($transaction, $validated);
|
||||
$this->syncPhotos($transaction, $validated, self::MAX_PHOTOS);
|
||||
|
||||
return $transaction;
|
||||
});
|
||||
} 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.',
|
||||
]);
|
||||
}
|
||||
},
|
||||
'Gagal melakukan setoran kas',
|
||||
);
|
||||
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
'💰 Setoran Kas',
|
||||
@ -111,8 +104,8 @@ public function deposit(CashAccount $cashAccount, array $validated, User $user):
|
||||
|
||||
public function withdraw(CashAccount $cashAccount, array $validated, User $user): CashTransaction
|
||||
{
|
||||
try {
|
||||
$transaction = DB::transaction(function () use ($cashAccount, $validated, $user): CashTransaction {
|
||||
$transaction = $this->runInTransaction(
|
||||
function () use ($cashAccount, $validated, $user): CashTransaction {
|
||||
$account = CashAccount::query()->lockForUpdate()->findOrFail($cashAccount->id);
|
||||
$amount = (int) $validated['amount'];
|
||||
|
||||
@ -135,21 +128,12 @@ public function withdraw(CashAccount $cashAccount, array $validated, User $user)
|
||||
'created_by_id' => $user->id,
|
||||
]);
|
||||
|
||||
$this->syncPhotos($transaction, $validated);
|
||||
$this->syncPhotos($transaction, $validated, self::MAX_PHOTOS);
|
||||
|
||||
return $transaction;
|
||||
});
|
||||
} 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.',
|
||||
]);
|
||||
}
|
||||
},
|
||||
'Gagal melakukan tarik kas',
|
||||
);
|
||||
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
'🏦 Tarik Kas',
|
||||
@ -168,8 +152,8 @@ public function recordOutgoing(
|
||||
User $user,
|
||||
?CashAccount $cashAccount = null,
|
||||
): CashTransaction {
|
||||
try {
|
||||
return DB::transaction(function () use ($reference, $amount, $description, $user, $cashAccount): CashTransaction {
|
||||
return $this->runInTransaction(
|
||||
function () use ($reference, $amount, $description, $user, $cashAccount): CashTransaction {
|
||||
$account = CashAccount::query()->lockForUpdate()->findOrFail(
|
||||
($cashAccount ?? $this->getDefaultAccount())->id,
|
||||
);
|
||||
@ -197,18 +181,9 @@ public function recordOutgoing(
|
||||
$transaction->save();
|
||||
|
||||
return $transaction;
|
||||
});
|
||||
} 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.',
|
||||
]);
|
||||
}
|
||||
},
|
||||
'Gagal mencatat transaksi keluar kas',
|
||||
);
|
||||
}
|
||||
|
||||
public function recordIncoming(
|
||||
@ -218,8 +193,8 @@ public function recordIncoming(
|
||||
User $user,
|
||||
?CashAccount $cashAccount = null,
|
||||
): CashTransaction {
|
||||
try {
|
||||
return DB::transaction(function () use ($reference, $amount, $description, $user, $cashAccount): CashTransaction {
|
||||
return $this->runInTransaction(
|
||||
function () use ($reference, $amount, $description, $user, $cashAccount): CashTransaction {
|
||||
$account = CashAccount::query()->lockForUpdate()->findOrFail(
|
||||
($cashAccount ?? $this->getDefaultAccount())->id,
|
||||
);
|
||||
@ -241,26 +216,15 @@ public function recordIncoming(
|
||||
$transaction->save();
|
||||
|
||||
return $transaction;
|
||||
});
|
||||
} 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.',
|
||||
]);
|
||||
}
|
||||
},
|
||||
'Gagal mencatat transaksi masuk kas',
|
||||
);
|
||||
}
|
||||
|
||||
public function updateDeposit(CashTransaction $transaction, array $validated): void
|
||||
{
|
||||
$this->ensureEditable($transaction);
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($transaction, $validated): void {
|
||||
$this->runInTransaction(
|
||||
function () use ($transaction, $validated): void {
|
||||
CashAccount::query()->lockForUpdate()->findOrFail($transaction->cash_account_id);
|
||||
|
||||
$transaction->update([
|
||||
@ -268,7 +232,7 @@ public function updateDeposit(CashTransaction $transaction, array $validated): v
|
||||
'description' => $validated['description'],
|
||||
]);
|
||||
|
||||
$this->syncPhotos($transaction, $validated);
|
||||
$this->syncPhotos($transaction, $validated, self::MAX_PHOTOS);
|
||||
|
||||
$this->recalculateBalances($transaction->cashAccount);
|
||||
|
||||
@ -279,18 +243,9 @@ public function updateDeposit(CashTransaction $transaction, array $validated): v
|
||||
'amount' => 'Saldo kas tidak mencukupi.',
|
||||
]);
|
||||
}
|
||||
});
|
||||
} 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.',
|
||||
]);
|
||||
}
|
||||
},
|
||||
'Gagal memperbarui setoran kas',
|
||||
);
|
||||
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
'✏️ Transaksi Kas Diperbarui',
|
||||
@ -302,10 +257,11 @@ public function updateDeposit(CashTransaction $transaction, array $validated): v
|
||||
|
||||
public function deleteTransaction(CashTransaction $transaction): void
|
||||
{
|
||||
$this->ensureEditable($transaction);
|
||||
$amountFormatted = $transaction->amount_formatted;
|
||||
$description = $transaction->description;
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($transaction): void {
|
||||
$this->runInTransaction(
|
||||
function () use ($transaction): void {
|
||||
CashAccount::query()->lockForUpdate()->findOrFail($transaction->cash_account_id);
|
||||
|
||||
$account = $transaction->cashAccount;
|
||||
@ -320,22 +276,13 @@ public function deleteTransaction(CashTransaction $transaction): void
|
||||
'transaction' => 'Saldo kas tidak mencukupi jika transaksi ini dihapus.',
|
||||
]);
|
||||
}
|
||||
});
|
||||
} 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.',
|
||||
]);
|
||||
}
|
||||
},
|
||||
'Gagal menghapus transaksi kas',
|
||||
);
|
||||
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
'🗑️ Transaksi Kas Dihapus',
|
||||
"Transaksi kas senilai {$transaction->amount_formatted} dengan keterangan {$transaction->description} telah dihapus.",
|
||||
"Transaksi kas senilai {$amountFormatted} dengan keterangan {$description} telah dihapus.",
|
||||
['owner', 'developer', 'admin-toko'],
|
||||
route('admin.finance.cash.index'),
|
||||
);
|
||||
@ -346,8 +293,8 @@ public function updateReferencedTransaction(
|
||||
int $amount,
|
||||
string $description,
|
||||
): void {
|
||||
try {
|
||||
DB::transaction(function () use ($transaction, $amount, $description): void {
|
||||
$this->runInTransaction(
|
||||
function () use ($transaction, $amount, $description): void {
|
||||
CashAccount::query()->lockForUpdate()->findOrFail($transaction->cash_account_id);
|
||||
|
||||
$transaction->update([
|
||||
@ -364,24 +311,15 @@ public function updateReferencedTransaction(
|
||||
'amount' => 'Saldo kas tidak mencukupi.',
|
||||
]);
|
||||
}
|
||||
});
|
||||
} 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.',
|
||||
]);
|
||||
}
|
||||
},
|
||||
'Gagal memperbarui transaksi kas referensi',
|
||||
);
|
||||
}
|
||||
|
||||
public function deleteReferencedTransaction(CashTransaction $transaction): void
|
||||
{
|
||||
try {
|
||||
DB::transaction(function () use ($transaction): void {
|
||||
$this->runInTransaction(
|
||||
function () use ($transaction): void {
|
||||
CashAccount::query()->lockForUpdate()->findOrFail($transaction->cash_account_id);
|
||||
$account = $transaction->cashAccount;
|
||||
|
||||
@ -394,43 +332,11 @@ public function deleteReferencedTransaction(CashTransaction $transaction): void
|
||||
'transaction' => 'Saldo kas tidak mencukupi jika transaksi ini dihapus.',
|
||||
]);
|
||||
}
|
||||
});
|
||||
} 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,
|
||||
},
|
||||
'Gagal menghapus transaksi kas referensi',
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
$account = CashAccount::query()->lockForUpdate()->findOrFail($cashAccount->id);
|
||||
|
||||
@ -3,22 +3,19 @@
|
||||
namespace App\Services\Finance;
|
||||
|
||||
use App\Enums\EmployeeAdvanceStatus;
|
||||
use App\Enums\Permission;
|
||||
use App\Enums\Role;
|
||||
use App\Models\EmployeeAdvance;
|
||||
use App\Models\EmployeeAdvancePayment;
|
||||
use App\Models\User;
|
||||
use App\Services\Concerns\ResolvesAuthenticatedEmployee;
|
||||
use App\Services\Concerns\RunsInTransaction;
|
||||
use App\Services\System\PushNotificationService;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class EmployeeAdvanceService
|
||||
{
|
||||
use ResolvesAuthenticatedEmployee;
|
||||
use ResolvesAuthenticatedEmployee, RunsInTransaction;
|
||||
|
||||
public function __construct(
|
||||
private readonly CashService $cashService,
|
||||
@ -72,8 +69,8 @@ public function create(array $validated, User $user): void
|
||||
{
|
||||
$employee = $this->resolveAuthenticatedEmployee($user);
|
||||
|
||||
try {
|
||||
$employeeAdvance = DB::transaction(function () use ($validated, $employee) {
|
||||
$employeeAdvance = $this->runInTransaction(
|
||||
function () use ($validated, $employee): EmployeeAdvance {
|
||||
return EmployeeAdvance::create([
|
||||
'employee_id' => $employee->id,
|
||||
'amount' => (int) $validated['amount'],
|
||||
@ -81,22 +78,13 @@ public function create(array $validated, User $user): void
|
||||
'due_date' => $validated['due_date'],
|
||||
'status' => EmployeeAdvanceStatus::PENDING,
|
||||
]);
|
||||
});
|
||||
} 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.',
|
||||
]);
|
||||
}
|
||||
},
|
||||
'Gagal membuat pengajuan kasbon',
|
||||
);
|
||||
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
'💰 Pengajuan Kasbon Baru',
|
||||
"Karyawan {$user->profil?->full_name} mengajukan kasbon sebesar {$employeeAdvance->amount_formatted} dengan keterangan: {$employeeAdvance->description}.",
|
||||
"Karyawan {$user->profile?->full_name} mengajukan kasbon sebesar {$employeeAdvance->amount_formatted} dengan keterangan: {$employeeAdvance->description}.",
|
||||
['owner', 'developer', 'direktur'],
|
||||
route('admin.finance.employee_advances.index'),
|
||||
);
|
||||
@ -104,25 +92,16 @@ public function create(array $validated, User $user): void
|
||||
|
||||
public function update(EmployeeAdvance $employeeAdvance, array $validated, User $user): void
|
||||
{
|
||||
try {
|
||||
DB::transaction(function () use ($employeeAdvance, $validated): void {
|
||||
$this->runInTransaction(
|
||||
function () use ($employeeAdvance, $validated): void {
|
||||
$employeeAdvance->update([
|
||||
'amount' => (int) $validated['amount'],
|
||||
'description' => $validated['description'],
|
||||
'due_date' => $validated['due_date'],
|
||||
]);
|
||||
});
|
||||
} 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.',
|
||||
]);
|
||||
}
|
||||
},
|
||||
'Gagal memperbarui kasbon',
|
||||
);
|
||||
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
'✏️ Kasbon Diperbarui',
|
||||
@ -134,14 +113,19 @@ public function update(EmployeeAdvance $employeeAdvance, array $validated, User
|
||||
|
||||
public function delete(EmployeeAdvance $employeeAdvance, User $user): void
|
||||
{
|
||||
$amount = $employeeAdvance->amount_formatted;
|
||||
$amountFormatted = $employeeAdvance->amount_formatted;
|
||||
$description = $employeeAdvance->description;
|
||||
|
||||
$employeeAdvance->delete();
|
||||
$this->runInTransaction(
|
||||
function () use ($employeeAdvance): void {
|
||||
$employeeAdvance->delete();
|
||||
},
|
||||
'Gagal menghapus kasbon',
|
||||
);
|
||||
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
'🗑️ Kasbon Dihapus',
|
||||
"Kasbon sebesar {$amount} dengan keterangan {$description} telah dihapus.",
|
||||
"Kasbon sebesar {$amountFormatted} dengan keterangan {$description} telah dihapus.",
|
||||
['owner', 'developer', 'direktur'],
|
||||
route('admin.finance.employee_advances.index'),
|
||||
);
|
||||
@ -149,8 +133,8 @@ public function delete(EmployeeAdvance $employeeAdvance, User $user): void
|
||||
|
||||
public function approve(EmployeeAdvance $employeeAdvance, User $user): void
|
||||
{
|
||||
try {
|
||||
DB::transaction(function () use ($employeeAdvance, $user): void {
|
||||
$this->runInTransaction(
|
||||
function () use ($employeeAdvance, $user): void {
|
||||
$employeeAdvance->loadMissing('employee.user.profile');
|
||||
|
||||
$description = sprintf(
|
||||
@ -171,18 +155,9 @@ public function approve(EmployeeAdvance $employeeAdvance, User $user): void
|
||||
'verified_at' => now(),
|
||||
'verified_by_id' => $user->id,
|
||||
]);
|
||||
});
|
||||
} 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.',
|
||||
]);
|
||||
}
|
||||
},
|
||||
'Gagal menyetujui kasbon',
|
||||
);
|
||||
|
||||
$employeeAdvance->loadMissing('employee.user');
|
||||
if ($employeeAdvance->employee?->user_id) {
|
||||
@ -197,8 +172,8 @@ public function approve(EmployeeAdvance $employeeAdvance, User $user): void
|
||||
|
||||
public function reject(EmployeeAdvance $employeeAdvance, string $reason, User $user): void
|
||||
{
|
||||
try {
|
||||
DB::transaction(function () use ($employeeAdvance, $user, $reason): void {
|
||||
$this->runInTransaction(
|
||||
function () use ($employeeAdvance, $user, $reason): void {
|
||||
$employeeAdvance->update([
|
||||
'status' => EmployeeAdvanceStatus::REJECTED,
|
||||
'verified_at' => now(),
|
||||
@ -209,18 +184,9 @@ public function reject(EmployeeAdvance $employeeAdvance, string $reason, User $u
|
||||
'reason' => $reason,
|
||||
'rejected_by_id' => $user->id,
|
||||
]);
|
||||
});
|
||||
} 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.',
|
||||
]);
|
||||
}
|
||||
},
|
||||
'Gagal menolak kasbon',
|
||||
);
|
||||
|
||||
$employeeAdvance->loadMissing('employee.user');
|
||||
if ($employeeAdvance->employee?->user_id) {
|
||||
@ -235,8 +201,8 @@ public function reject(EmployeeAdvance $employeeAdvance, string $reason, User $u
|
||||
|
||||
public function pay(EmployeeAdvance $employeeAdvance, User $user, ?int $payAmount = null): void
|
||||
{
|
||||
try {
|
||||
DB::transaction(function () use ($employeeAdvance, $user, $payAmount): void {
|
||||
$this->runInTransaction(
|
||||
function () use ($employeeAdvance, $user, $payAmount): void {
|
||||
$employeeAdvance->loadMissing('employee.user.profile');
|
||||
|
||||
$remaining = $employeeAdvance->amount - $employeeAdvance->paid_amount;
|
||||
@ -274,18 +240,19 @@ public function pay(EmployeeAdvance $employeeAdvance, User $user, ?int $payAmoun
|
||||
'description' => $description,
|
||||
'paid_at' => now(),
|
||||
]);
|
||||
});
|
||||
} catch (ValidationException $e) {
|
||||
throw $e;
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Gagal melunasi kasbon: '.$e->getMessage(), [
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
},
|
||||
'Gagal membayar kasbon',
|
||||
);
|
||||
}
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
|
||||
]);
|
||||
}
|
||||
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' => EmployeeAdvance::canSubmit($user),
|
||||
];
|
||||
}
|
||||
|
||||
private function applySorting(Builder $query, string $sort, string $direction): void
|
||||
@ -298,21 +265,4 @@ private function applySorting(Builder $query, string $sort, string $direction):
|
||||
|
||||
$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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,17 +4,18 @@
|
||||
|
||||
use App\Models\Expense;
|
||||
use App\Models\User;
|
||||
use App\Services\Concerns\RunsInTransaction;
|
||||
use App\Services\Concerns\SyncsPhotos;
|
||||
use App\Services\Media\MediaService;
|
||||
use App\Services\System\PushNotificationService;
|
||||
use App\Support\Media\MediaPresenter;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class ExpenseService
|
||||
{
|
||||
use RunsInTransaction, SyncsPhotos;
|
||||
|
||||
private const MAX_PHOTOS = 1;
|
||||
|
||||
public function __construct(
|
||||
@ -51,8 +52,8 @@ public function paginateForIndex(array $tableQuery): LengthAwarePaginator
|
||||
|
||||
public function create(array $validated, User $user): void
|
||||
{
|
||||
try {
|
||||
$expense = DB::transaction(function () use ($validated, $user): Expense {
|
||||
$expense = $this->runInTransaction(
|
||||
function () use ($validated, $user): Expense {
|
||||
$amount = (int) $validated['amount'];
|
||||
$description = $validated['description'];
|
||||
|
||||
@ -73,21 +74,12 @@ public function create(array $validated, User $user): void
|
||||
'cash_transaction_id' => $cashTransaction->id,
|
||||
]);
|
||||
|
||||
$this->syncPhotos($expense, $validated);
|
||||
$this->syncPhotos($expense, $validated, self::MAX_PHOTOS);
|
||||
|
||||
return $expense;
|
||||
});
|
||||
} 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.',
|
||||
]);
|
||||
}
|
||||
},
|
||||
'Gagal membuat pengeluaran',
|
||||
);
|
||||
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
'💸 Pengeluaran Baru',
|
||||
@ -99,8 +91,8 @@ public function create(array $validated, User $user): void
|
||||
|
||||
public function update(Expense $expense, array $validated): void
|
||||
{
|
||||
try {
|
||||
DB::transaction(function () use ($expense, $validated): void {
|
||||
$this->runInTransaction(
|
||||
function () use ($expense, $validated): void {
|
||||
$amount = (int) $validated['amount'];
|
||||
$description = $validated['description'];
|
||||
|
||||
@ -117,19 +109,10 @@ public function update(Expense $expense, array $validated): void
|
||||
);
|
||||
}
|
||||
|
||||
$this->syncPhotos($expense, $validated);
|
||||
});
|
||||
} 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->syncPhotos($expense, $validated, self::MAX_PHOTOS);
|
||||
},
|
||||
'Gagal memperbarui pengeluaran',
|
||||
);
|
||||
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
'✏️ Pengeluaran Diperbarui',
|
||||
@ -141,49 +124,29 @@ public function update(Expense $expense, array $validated): void
|
||||
|
||||
public function delete(Expense $expense): void
|
||||
{
|
||||
try {
|
||||
DB::transaction(function () use ($expense): void {
|
||||
$amountFormatted = $expense->amount_formatted;
|
||||
$description = $expense->description;
|
||||
|
||||
$this->runInTransaction(
|
||||
function () use ($expense): void {
|
||||
if ($expense->cashTransaction) {
|
||||
$this->cashService->deleteReferencedTransaction($expense->cashTransaction);
|
||||
}
|
||||
|
||||
$expense->clearMediaCollection('photos');
|
||||
$expense->delete();
|
||||
});
|
||||
} 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.',
|
||||
]);
|
||||
}
|
||||
},
|
||||
'Gagal menghapus pengeluaran',
|
||||
);
|
||||
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
'🗑️ Pengeluaran Dihapus',
|
||||
"Pengeluaran sebesar {$expense->amount_formatted} dengan keterangan {$expense->description} telah dihapus.",
|
||||
"Pengeluaran sebesar {$amountFormatted} dengan keterangan {$description} telah dihapus.",
|
||||
['owner', 'developer', 'direktur'],
|
||||
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
|
||||
{
|
||||
if (in_array($sort, ['created_at', 'amount', 'description'], true)) {
|
||||
|
||||
@ -13,17 +13,17 @@
|
||||
use App\Models\PayrollAdjustment;
|
||||
use App\Models\PayrollPeriod;
|
||||
use App\Models\User;
|
||||
use App\Services\Concerns\RunsInTransaction;
|
||||
use App\Services\System\PushNotificationService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class PayrollService
|
||||
{
|
||||
use RunsInTransaction;
|
||||
|
||||
public function __construct(
|
||||
private readonly CashService $cashService,
|
||||
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()->first();
|
||||
|
||||
try {
|
||||
$period = DB::transaction(function () use ($user): PayrollPeriod {
|
||||
$period = $this->runInTransaction(
|
||||
function () use ($user): PayrollPeriod {
|
||||
$now = now();
|
||||
$year = $now->year;
|
||||
$month = $now->month;
|
||||
@ -157,18 +157,9 @@ public function openCurrentPeriod(?User $closedBy = null): PayrollPeriod
|
||||
$this->generatePayrollsForPeriod($period);
|
||||
|
||||
return $period->fresh();
|
||||
});
|
||||
} 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.',
|
||||
]);
|
||||
}
|
||||
},
|
||||
'Gagal membuka periode payroll',
|
||||
);
|
||||
|
||||
return $period;
|
||||
}
|
||||
@ -205,9 +196,8 @@ public function generatePayrollsForPeriod(PayrollPeriod $period): void
|
||||
|
||||
public function addAdjustment(Payroll $payroll, array $validated, User $user): void
|
||||
{
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($payroll, $validated, $user): void {
|
||||
$this->runInTransaction(
|
||||
function () use ($payroll, $validated, $user): void {
|
||||
$payroll->adjustments()->create([
|
||||
'type' => PayrollAdjustmentType::from($validated['type']),
|
||||
'amount' => (int) $validated['amount'],
|
||||
@ -218,18 +208,9 @@ public function addAdjustment(Payroll $payroll, array $validated, User $user): v
|
||||
$payroll->load('adjustments');
|
||||
$payroll->recalculateAmounts();
|
||||
$payroll->save();
|
||||
});
|
||||
} 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.',
|
||||
]);
|
||||
}
|
||||
},
|
||||
'Gagal menambahkan penyesuaian gaji',
|
||||
);
|
||||
|
||||
if ($payroll->employee?->user_id) {
|
||||
$typeLabel = PayrollAdjustmentType::from($validated['type'])->label();
|
||||
@ -248,8 +229,8 @@ public function updateAdjustment(PayrollAdjustment $adjustment, array $validated
|
||||
$payroll = $adjustment->payroll;
|
||||
$payroll->loadMissing(['payrollPeriod', 'employee.user']);
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($payroll, $adjustment, $validated): void {
|
||||
$this->runInTransaction(
|
||||
function () use ($payroll, $adjustment, $validated): void {
|
||||
$adjustment->type = PayrollAdjustmentType::from($validated['type']);
|
||||
$adjustment->amount = (int) $validated['amount'];
|
||||
$adjustment->description = $validated['description'];
|
||||
@ -258,18 +239,9 @@ public function updateAdjustment(PayrollAdjustment $adjustment, array $validated
|
||||
$payroll->load('adjustments');
|
||||
$payroll->recalculateAmounts();
|
||||
$payroll->save();
|
||||
});
|
||||
} 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.',
|
||||
]);
|
||||
}
|
||||
},
|
||||
'Gagal memperbarui penyesuaian gaji',
|
||||
);
|
||||
|
||||
if ($payroll->employee?->user_id) {
|
||||
$typeLabel = PayrollAdjustmentType::from($validated['type'])->label();
|
||||
@ -288,25 +260,16 @@ public function deleteAdjustment(PayrollAdjustment $adjustment): void
|
||||
$payroll = $adjustment->payroll;
|
||||
$payroll->loadMissing(['payrollPeriod', 'employee.user']);
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($payroll, $adjustment): void {
|
||||
$this->runInTransaction(
|
||||
function () use ($payroll, $adjustment): void {
|
||||
$adjustment->delete();
|
||||
|
||||
$payroll->load('adjustments');
|
||||
$payroll->recalculateAmounts();
|
||||
$payroll->save();
|
||||
});
|
||||
} 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.',
|
||||
]);
|
||||
}
|
||||
},
|
||||
'Gagal menghapus penyesuaian gaji',
|
||||
);
|
||||
|
||||
if ($payroll->employee?->user_id) {
|
||||
$this->pushNotificationService->sendToUser(
|
||||
@ -323,32 +286,23 @@ public function pay(Payroll $payroll, User $user): void
|
||||
$payroll->loadMissing(['payrollPeriod', 'employee.user.profile']);
|
||||
|
||||
if ($payroll->total_amount <= 0) {
|
||||
try {
|
||||
DB::transaction(function () use ($payroll, $user): void {
|
||||
$this->runInTransaction(
|
||||
function () use ($payroll, $user): void {
|
||||
$payroll->status = PayrollStatus::PAID;
|
||||
$payroll->paid_at = now();
|
||||
$payroll->paid_by_id = $user->id;
|
||||
$payroll->save();
|
||||
|
||||
$this->settleKasbonFromPayroll($payroll, $user);
|
||||
});
|
||||
} 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.',
|
||||
]);
|
||||
}
|
||||
},
|
||||
'Gagal membayar gaji (total 0)',
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($payroll, $user): void {
|
||||
$this->runInTransaction(
|
||||
function () use ($payroll, $user): void {
|
||||
$description = sprintf(
|
||||
'Pembayaran gaji: %s (%s)',
|
||||
$payroll->employeeName,
|
||||
@ -369,18 +323,9 @@ public function pay(Payroll $payroll, User $user): void
|
||||
$payroll->save();
|
||||
|
||||
$this->settleKasbonFromPayroll($payroll, $user);
|
||||
});
|
||||
} 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.',
|
||||
]);
|
||||
}
|
||||
},
|
||||
'Gagal membayar gaji',
|
||||
);
|
||||
|
||||
if ($payroll->employee?->user_id) {
|
||||
$this->pushNotificationService->sendToUser(
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
use App\Models\LeaveRequest;
|
||||
use App\Models\User;
|
||||
use App\Services\Concerns\ResolvesAuthenticatedEmployee;
|
||||
use App\Services\Concerns\RunsInTransaction;
|
||||
use App\Services\Media\MediaService;
|
||||
use App\Services\System\PushNotificationService;
|
||||
use Carbon\CarbonInterface;
|
||||
@ -18,7 +19,7 @@
|
||||
|
||||
class AttendanceService
|
||||
{
|
||||
use ResolvesAuthenticatedEmployee;
|
||||
use ResolvesAuthenticatedEmployee, RunsInTransaction;
|
||||
|
||||
public function __construct(
|
||||
private readonly MediaService $mediaService,
|
||||
@ -71,30 +72,35 @@ public function checkIn(array $validated, User $user): void
|
||||
{
|
||||
$employee = $this->resolveAuthenticatedEmployee($user);
|
||||
|
||||
$existing = Attendance::query()
|
||||
->where('employee_id', $employee->id)
|
||||
->whereDate('attendance_date', today())
|
||||
->exists();
|
||||
$this->runInTransaction(
|
||||
function () use ($employee, $validated): void {
|
||||
$existing = Attendance::query()
|
||||
->where('employee_id', $employee->id)
|
||||
->whereDate('attendance_date', today())
|
||||
->exists();
|
||||
|
||||
if ($existing) {
|
||||
throw ValidationException::withMessages([
|
||||
'attendance' => 'Anda sudah melakukan presensi masuk hari ini.',
|
||||
]);
|
||||
}
|
||||
if ($existing) {
|
||||
throw ValidationException::withMessages([
|
||||
'attendance' => 'Anda sudah melakukan presensi masuk hari ini.',
|
||||
]);
|
||||
}
|
||||
|
||||
$attendance = Attendance::create([
|
||||
'employee_id' => $employee->id,
|
||||
'attendance_date' => today(),
|
||||
'check_in_at' => now(),
|
||||
'check_in_latitude' => $validated['latitude'],
|
||||
'check_in_longitude' => $validated['longitude'],
|
||||
]);
|
||||
$attendance = Attendance::create([
|
||||
'employee_id' => $employee->id,
|
||||
'attendance_date' => today(),
|
||||
'check_in_at' => now(),
|
||||
'check_in_latitude' => $validated['latitude'],
|
||||
'check_in_longitude' => $validated['longitude'],
|
||||
]);
|
||||
|
||||
$this->mediaService->addBase64Image(
|
||||
$attendance,
|
||||
$validated['photo'],
|
||||
'checkin',
|
||||
'checkin',
|
||||
$this->mediaService->addBase64Image(
|
||||
$attendance,
|
||||
$validated['photo'],
|
||||
'checkin',
|
||||
'checkin',
|
||||
);
|
||||
},
|
||||
'Gagal mencatat presensi masuk',
|
||||
);
|
||||
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
@ -109,38 +115,45 @@ public function checkOut(array $validated, User $user): void
|
||||
{
|
||||
$employee = $this->resolveAuthenticatedEmployee($user);
|
||||
|
||||
$attendance = Attendance::query()
|
||||
->where('employee_id', $employee->id)
|
||||
->whereDate('attendance_date', today())
|
||||
->first();
|
||||
$workDurationMinutes = $this->runInTransaction(
|
||||
function () use ($employee, $validated): int {
|
||||
$attendance = Attendance::query()
|
||||
->where('employee_id', $employee->id)
|
||||
->whereDate('attendance_date', today())
|
||||
->first();
|
||||
|
||||
if ($attendance === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'attendance' => 'Anda belum melakukan presensi masuk hari ini.',
|
||||
]);
|
||||
}
|
||||
if ($attendance === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'attendance' => 'Anda belum melakukan presensi masuk hari ini.',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($attendance->check_out_at !== null) {
|
||||
throw ValidationException::withMessages([
|
||||
'attendance' => 'Anda sudah melakukan presensi pulang hari ini.',
|
||||
]);
|
||||
}
|
||||
if ($attendance->check_out_at !== null) {
|
||||
throw ValidationException::withMessages([
|
||||
'attendance' => 'Anda sudah melakukan presensi pulang hari ini.',
|
||||
]);
|
||||
}
|
||||
|
||||
$checkOutAt = now();
|
||||
$workDurationMinutes = (int) $attendance->check_in_at->diffInMinutes($checkOutAt);
|
||||
$checkOutAt = now();
|
||||
$workDurationMinutes = (int) $attendance->check_in_at->diffInMinutes($checkOutAt);
|
||||
|
||||
$attendance->update([
|
||||
'check_out_at' => $checkOutAt,
|
||||
'check_out_latitude' => $validated['latitude'],
|
||||
'check_out_longitude' => $validated['longitude'],
|
||||
'work_duration_minutes' => $workDurationMinutes,
|
||||
]);
|
||||
$attendance->update([
|
||||
'check_out_at' => $checkOutAt,
|
||||
'check_out_latitude' => $validated['latitude'],
|
||||
'check_out_longitude' => $validated['longitude'],
|
||||
'work_duration_minutes' => $workDurationMinutes,
|
||||
]);
|
||||
|
||||
$this->mediaService->addBase64Image(
|
||||
$attendance,
|
||||
$validated['photo'],
|
||||
'checkout',
|
||||
'checkout',
|
||||
$this->mediaService->addBase64Image(
|
||||
$attendance,
|
||||
$validated['photo'],
|
||||
'checkout',
|
||||
'checkout',
|
||||
);
|
||||
|
||||
return $workDurationMinutes;
|
||||
},
|
||||
'Gagal mencatat presensi pulang',
|
||||
);
|
||||
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
@ -157,13 +170,18 @@ public function delete(Attendance $attendance): void
|
||||
$employeeName = $attendance->employee?->user?->profile?->full_name;
|
||||
$date = $attendance->attendance_date;
|
||||
|
||||
$attendance->clearMediaCollection('checkin');
|
||||
$attendance->clearMediaCollection('checkout');
|
||||
$attendance->delete();
|
||||
$this->runInTransaction(
|
||||
function () use ($attendance): void {
|
||||
$attendance->clearMediaCollection('checkin');
|
||||
$attendance->clearMediaCollection('checkout');
|
||||
$attendance->delete();
|
||||
},
|
||||
'Gagal menghapus presensi',
|
||||
);
|
||||
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
'🗑️ Presensi Dihapus',
|
||||
"Data presensi {$employeeName} tanggal {$date} telah dihapus.",
|
||||
"Data presensi {$employeeName} tanggal {$date?->toDateString()} telah dihapus.",
|
||||
['owner', 'developer', 'direktur'],
|
||||
route('admin.hr.attendances.index'),
|
||||
);
|
||||
|
||||
@ -6,17 +6,19 @@
|
||||
use App\Models\Employee;
|
||||
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\Support\Media\MediaPresenter;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class EmployeeService
|
||||
{
|
||||
use RunsInTransaction, SyncsPhotos;
|
||||
|
||||
public function __construct(
|
||||
private readonly MediaService $mediaService,
|
||||
) {}
|
||||
@ -75,8 +77,8 @@ public function findForEdit(User $user): array
|
||||
|
||||
public function create(array $validated): void
|
||||
{
|
||||
try {
|
||||
DB::transaction(function () use ($validated): void {
|
||||
$this->runInTransaction(
|
||||
function () use ($validated): void {
|
||||
$user = User::create([
|
||||
'email' => $validated['email'],
|
||||
'username' => $validated['username'],
|
||||
@ -104,26 +106,17 @@ public function create(array $validated): void
|
||||
}
|
||||
|
||||
$user->syncRoles([$validated['role']]);
|
||||
});
|
||||
} 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.',
|
||||
]);
|
||||
}
|
||||
},
|
||||
'Gagal membuat karyawan',
|
||||
);
|
||||
}
|
||||
|
||||
public function update(User $user, array $validated): void
|
||||
{
|
||||
$employee = $user->employee;
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($validated, $user, $employee): void {
|
||||
$this->runInTransaction(
|
||||
function () use ($validated, $user, $employee): void {
|
||||
$user->update([
|
||||
'email' => $validated['email'],
|
||||
'username' => $validated['username'],
|
||||
@ -166,73 +159,65 @@ public function update(User $user, array $validated): void
|
||||
}
|
||||
|
||||
$user->syncRoles([$validated['role']]);
|
||||
});
|
||||
} 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.',
|
||||
]);
|
||||
}
|
||||
},
|
||||
'Gagal memperbarui karyawan',
|
||||
);
|
||||
}
|
||||
|
||||
public function toggleStatus(User $user, array $validated): void
|
||||
{
|
||||
$user->update([
|
||||
'is_active' => $validated['is_active'],
|
||||
]);
|
||||
$this->runInTransaction(
|
||||
function () use ($user, $validated): void {
|
||||
$user->update([
|
||||
'is_active' => $validated['is_active'],
|
||||
]);
|
||||
|
||||
if (! $validated['is_active']) {
|
||||
DB::table('sessions')->where('user_id', $user->id)->delete();
|
||||
}
|
||||
if (! $validated['is_active']) {
|
||||
DB::table('sessions')->where('user_id', $user->id)->delete();
|
||||
}
|
||||
},
|
||||
'Gagal memperbarui status karyawan',
|
||||
);
|
||||
}
|
||||
|
||||
public function resetPassword(User $user): void
|
||||
{
|
||||
$user->update([
|
||||
'password' => config('auth.password_default'),
|
||||
]);
|
||||
$this->runInTransaction(
|
||||
function () use ($user): void {
|
||||
$user->update([
|
||||
'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
|
||||
{
|
||||
try {
|
||||
DB::transaction(function () use ($user): void {
|
||||
$this->runInTransaction(
|
||||
function () use ($user): void {
|
||||
$user->employee?->delete();
|
||||
$user->profile?->delete();
|
||||
$user->delete();
|
||||
});
|
||||
} 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.',
|
||||
]);
|
||||
}
|
||||
},
|
||||
'Gagal menghapus karyawan',
|
||||
);
|
||||
}
|
||||
|
||||
private function syncProfilePhoto(UserProfile $profile, array $validated): void
|
||||
{
|
||||
$s3Keys = ! empty($validated['profile_s3_key']) ? [$validated['profile_s3_key']] : null;
|
||||
$removeIds = $validated['remove_profile_photo_ids'] ?? null;
|
||||
|
||||
$this->mediaService->syncCollection(
|
||||
$this->syncPhotos(
|
||||
$profile,
|
||||
'profile_photo',
|
||||
null,
|
||||
$removeIds,
|
||||
1,
|
||||
s3Keys: $s3Keys,
|
||||
[
|
||||
'photos' => $validated['profile_photo'] ?? null,
|
||||
'remove_media_ids' => $validated['remove_profile_photo_ids'] ?? null,
|
||||
's3_keys' => ! empty($validated['profile_s3_key']) ? [$validated['profile_s3_key']] : null,
|
||||
],
|
||||
maxPhotos: 1,
|
||||
required: false,
|
||||
collection: 'profile_photo',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -3,21 +3,19 @@
|
||||
namespace App\Services\Hr;
|
||||
|
||||
use App\Enums\LeaveRequestStatus;
|
||||
use App\Enums\Permission;
|
||||
use App\Models\LeaveRequest;
|
||||
use App\Models\User;
|
||||
use App\Services\Concerns\ResolvesAuthenticatedEmployee;
|
||||
use App\Services\Concerns\RunsInTransaction;
|
||||
use App\Services\System\PushNotificationService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class LeaveRequestService
|
||||
{
|
||||
use ResolvesAuthenticatedEmployee;
|
||||
use ResolvesAuthenticatedEmployee, RunsInTransaction;
|
||||
|
||||
public function __construct(
|
||||
private readonly PushNotificationService $pushNotificationService,
|
||||
@ -109,7 +107,12 @@ public function delete(LeaveRequest $leaveRequest): void
|
||||
$totalDays = $leaveRequest->total_days;
|
||||
$employeeName = $leaveRequest->employee?->user?->profile?->full_name;
|
||||
|
||||
$leaveRequest->delete();
|
||||
$this->runInTransaction(
|
||||
function () use ($leaveRequest): void {
|
||||
$leaveRequest->delete();
|
||||
},
|
||||
'Gagal menghapus pengajuan cuti',
|
||||
);
|
||||
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
'🗑️ Pengajuan Cuti Dihapus',
|
||||
@ -121,25 +124,16 @@ public function delete(LeaveRequest $leaveRequest): void
|
||||
|
||||
public function approve(LeaveRequest $leaveRequest, User $user): void
|
||||
{
|
||||
try {
|
||||
DB::transaction(function () use ($leaveRequest, $user): void {
|
||||
$this->runInTransaction(
|
||||
function () use ($leaveRequest, $user): void {
|
||||
$leaveRequest->update([
|
||||
'status' => LeaveRequestStatus::APPROVED,
|
||||
'verified_at' => Carbon::now(),
|
||||
'verified_by_id' => $user->id,
|
||||
]);
|
||||
});
|
||||
} 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.',
|
||||
]);
|
||||
}
|
||||
},
|
||||
'Gagal menyetujui pengajuan cuti',
|
||||
);
|
||||
|
||||
$leaveRequest->loadMissing('employee.user');
|
||||
if ($leaveRequest->employee?->user_id) {
|
||||
@ -154,8 +148,8 @@ public function approve(LeaveRequest $leaveRequest, User $user): void
|
||||
|
||||
public function reject(LeaveRequest $leaveRequest, string $reason, User $user): void
|
||||
{
|
||||
try {
|
||||
DB::transaction(function () use ($leaveRequest, $user, $reason): void {
|
||||
$this->runInTransaction(
|
||||
function () use ($leaveRequest, $user, $reason): void {
|
||||
$leaveRequest->update([
|
||||
'status' => LeaveRequestStatus::REJECTED,
|
||||
'verified_at' => Carbon::now(),
|
||||
@ -166,18 +160,9 @@ public function reject(LeaveRequest $leaveRequest, string $reason, User $user):
|
||||
'reason' => $reason,
|
||||
'rejected_by_id' => $user->id,
|
||||
]);
|
||||
});
|
||||
} 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.',
|
||||
]);
|
||||
}
|
||||
},
|
||||
'Gagal menolak pengajuan cuti',
|
||||
);
|
||||
|
||||
$leaveRequest->loadMissing('employee.user');
|
||||
if ($leaveRequest->employee?->user_id) {
|
||||
@ -190,6 +175,28 @@ public function reject(LeaveRequest $leaveRequest, string $reason, User $user):
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
return (int) $startDate->diffInDays($endDate) + 1;
|
||||
@ -216,33 +223,4 @@ private function applySorting(Builder $query, string $sort, string $direction):
|
||||
|
||||
$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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
use App\Enums\Role;
|
||||
use App\Models\Cutting;
|
||||
use App\Models\CuttingMaterial;
|
||||
use App\Models\CuttingMaterialCombination;
|
||||
use App\Models\CuttingResult;
|
||||
use App\Models\CuttingResultPrice;
|
||||
use App\Models\Product;
|
||||
@ -14,18 +15,19 @@
|
||||
use App\Models\RawMaterial;
|
||||
use App\Models\RawMaterialPrice;
|
||||
use App\Models\User;
|
||||
use App\Services\Concerns\RunsInTransaction;
|
||||
use App\Services\Media\MediaService;
|
||||
use App\Services\System\PushNotificationService;
|
||||
use App\Support\Media\MediaPresenter;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class CuttingService
|
||||
{
|
||||
use RunsInTransaction;
|
||||
|
||||
public function __construct(
|
||||
private readonly PushNotificationService $pushNotificationService,
|
||||
private readonly MediaService $mediaService,
|
||||
@ -40,6 +42,7 @@ public function paginateForIndex(array $tableQuery, User $user): LengthAwarePagi
|
||||
'rejection.rejectedBy.profile',
|
||||
'materials.rawMaterialPrice.rawMaterial:id,name,unit',
|
||||
'materials.rawMaterialPrice.media',
|
||||
'materials.combination',
|
||||
'results.productVariant.product:id,name',
|
||||
'results.productVariant.media',
|
||||
])
|
||||
@ -83,6 +86,7 @@ public function getInProgressCuttings(User $user): Collection
|
||||
'rejection.rejectedBy.profile',
|
||||
'materials.rawMaterialPrice.rawMaterial:id,name,unit',
|
||||
'materials.rawMaterialPrice.media',
|
||||
'materials.combination',
|
||||
'results.productVariant.product:id,name',
|
||||
'results.productVariant.media',
|
||||
])
|
||||
@ -107,6 +111,7 @@ public function getCompletedCuttings(User $user): Collection
|
||||
'rejection.rejectedBy.profile',
|
||||
'materials.rawMaterialPrice.rawMaterial:id,name,unit',
|
||||
'materials.rawMaterialPrice.media',
|
||||
'materials.combination',
|
||||
'results.productVariant.product:id,name',
|
||||
'results.productVariant.media',
|
||||
])
|
||||
@ -195,6 +200,7 @@ public function findForEdit(Cutting $cutting): Cutting
|
||||
'media',
|
||||
'materials.rawMaterialPrice.rawMaterial:id,name,unit',
|
||||
'materials.rawMaterialPrice.media',
|
||||
'materials.combination',
|
||||
'results.productVariant.product:id,name',
|
||||
'results.productVariant.media',
|
||||
'rejection.rejectedBy.profile',
|
||||
@ -213,6 +219,7 @@ public function findForShare(Cutting $cutting): Cutting
|
||||
'createdBy.profile',
|
||||
'materials.rawMaterialPrice.rawMaterial:id,name,unit',
|
||||
'materials.rawMaterialPrice.media',
|
||||
'materials.combination',
|
||||
'results.productVariant.product:id,name',
|
||||
'results.productVariant.media',
|
||||
]);
|
||||
@ -254,6 +261,7 @@ public function draftMaterialsForUser(User $user): array
|
||||
->with([
|
||||
'rawMaterialPrice.rawMaterial:id,name,unit',
|
||||
'rawMaterialPrice.media',
|
||||
'combination',
|
||||
])
|
||||
->get()
|
||||
->map(function (CuttingMaterial $item) {
|
||||
@ -293,6 +301,10 @@ 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(
|
||||
[
|
||||
'user_id' => $user->id,
|
||||
@ -301,6 +313,7 @@ public function syncDraftMaterial(array $validated, User $user): array
|
||||
],
|
||||
[
|
||||
'material_usage' => $materialUsage,
|
||||
'material_result' => $materialResult,
|
||||
],
|
||||
);
|
||||
|
||||
@ -396,10 +409,80 @@ public function removeDraftResult(User $user, ProductVariant $productVariant): v
|
||||
->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
|
||||
{
|
||||
try {
|
||||
$cutting = DB::transaction(function () use ($validated, $user): Cutting {
|
||||
$cutting = $this->runInTransaction(
|
||||
function () use ($validated, $user): Cutting {
|
||||
|
||||
$draftMaterials = $this->draftMaterialsQuery($user)
|
||||
->with('rawMaterialPrice.rawMaterial')
|
||||
@ -432,6 +515,19 @@ public function create(array $validated, User $user): Cutting
|
||||
|
||||
$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) {
|
||||
$material->cutting_id = $cutting->id;
|
||||
$material->user_id = null;
|
||||
@ -448,18 +544,9 @@ public function create(array $validated, User $user): Cutting
|
||||
$this->deductMaterialStock($cutting);
|
||||
|
||||
return $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.',
|
||||
]);
|
||||
}
|
||||
},
|
||||
'Gagal membuat proses cutting',
|
||||
);
|
||||
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
'✂️ Proses Cutting Baru',
|
||||
@ -479,9 +566,9 @@ public function update(Cutting $cutting, array $validated): void
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($cutting, $validated): void {
|
||||
$cutting->load(['materials.rawMaterialPrice.rawMaterial', 'results']);
|
||||
$this->runInTransaction(
|
||||
function () use ($cutting, $validated): void {
|
||||
$cutting->load(['materials.rawMaterialPrice.rawMaterial', 'results', 'materials.combination']);
|
||||
|
||||
if ($cutting->status === CuttingStatus::IN_PROGRESS) {
|
||||
$this->reverseTotalMaterialStock($cutting);
|
||||
@ -491,6 +578,7 @@ public function update(Cutting $cutting, array $validated): void
|
||||
|
||||
$cutting->materials()->delete();
|
||||
$cutting->results()->delete();
|
||||
$cutting->combinations()->delete();
|
||||
|
||||
$materials = $this->buildMaterials($validated['materials']);
|
||||
$results = $this->buildResults($validated['results']);
|
||||
@ -506,8 +594,41 @@ public function update(Cutting $cutting, array $validated): void
|
||||
|
||||
$this->syncImages($cutting, $validated);
|
||||
|
||||
// Group materials by combination_id to create combinations
|
||||
$combinationGroups = [];
|
||||
foreach ($materials as $materialData) {
|
||||
$cutting->materials()->create($materialData);
|
||||
$combinationId = $materialData['combination_id'];
|
||||
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) {
|
||||
@ -518,18 +639,9 @@ public function update(Cutting $cutting, array $validated): void
|
||||
$cutting->load('materials.rawMaterialPrice.rawMaterial');
|
||||
$this->deductMaterialStock($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.',
|
||||
]);
|
||||
}
|
||||
},
|
||||
'Gagal memperbarui proses cutting',
|
||||
);
|
||||
|
||||
$description = $cutting->description ?? '-';
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
@ -550,8 +662,8 @@ public function delete(Cutting $cutting): void
|
||||
|
||||
$description = $cutting->description ?? '-';
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($cutting): void {
|
||||
$this->runInTransaction(
|
||||
function () use ($cutting): void {
|
||||
$cutting->load(['materials.rawMaterialPrice.rawMaterial']);
|
||||
|
||||
if ($cutting->status === CuttingStatus::IN_PROGRESS) {
|
||||
@ -564,18 +676,9 @@ public function delete(Cutting $cutting): void
|
||||
$cutting->results()->delete();
|
||||
$cutting->clearMediaCollection('images');
|
||||
$cutting->delete();
|
||||
});
|
||||
} 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.',
|
||||
]);
|
||||
}
|
||||
},
|
||||
'Gagal menghapus proses cutting',
|
||||
);
|
||||
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
'🗑️ Proses Cutting Dihapus',
|
||||
@ -600,8 +703,8 @@ public function transitionStatus(
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($cutting, $status, $verificationNote, $results, $resultPrices, $user, $reason): void {
|
||||
$this->runInTransaction(
|
||||
function () use ($cutting, $status, $verificationNote, $results, $resultPrices, $user, $reason): void {
|
||||
$cutting->load(['materials.rawMaterialPrice', 'results']);
|
||||
|
||||
if ($status === CuttingStatus::COMPLETED) {
|
||||
@ -642,18 +745,9 @@ public function transitionStatus(
|
||||
|
||||
$cutting->status = $status;
|
||||
$cutting->save();
|
||||
});
|
||||
} 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.',
|
||||
]);
|
||||
}
|
||||
},
|
||||
'Gagal mengubah status proses cutting',
|
||||
);
|
||||
|
||||
$description = $cutting->description ?? '-';
|
||||
$message = match ($status) {
|
||||
@ -706,9 +800,16 @@ private function buildMaterials(array $materials): array
|
||||
]);
|
||||
}
|
||||
|
||||
$materialResult = array_key_exists('material_result', $itemData) && $itemData['material_result'] !== null
|
||||
? (int) $itemData['material_result']
|
||||
: null;
|
||||
|
||||
return [
|
||||
'raw_material_price_id' => $price->id,
|
||||
'material_usage' => $materialUsage,
|
||||
'material_result' => $materialResult,
|
||||
'combination_id' => $itemData['combination_id'] ?? null,
|
||||
'combination_material_result' => $itemData['combination_material_result'] ?? null,
|
||||
];
|
||||
})
|
||||
->all();
|
||||
@ -858,6 +959,12 @@ private function breakMaterialCircularReference(CuttingMaterial $material): void
|
||||
{
|
||||
$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) {
|
||||
$rawMaterial = $price->rawMaterial;
|
||||
|
||||
@ -879,6 +986,7 @@ private function breakMaterialCircularReference(CuttingMaterial $material): void
|
||||
}
|
||||
|
||||
$material->unsetRelation('rawMaterialPrice');
|
||||
$material->unsetRelation('combination');
|
||||
}
|
||||
|
||||
private function applySorting(Builder $query, string $sort, string $direction): void
|
||||
@ -910,6 +1018,7 @@ private function presentDraftMaterial(CuttingMaterial $item): array
|
||||
{
|
||||
$price = $item->rawMaterialPrice;
|
||||
$rawMaterial = $price?->rawMaterial;
|
||||
$combination = $item->combination;
|
||||
|
||||
return [
|
||||
'raw_material_price_id' => $item->raw_material_price_id,
|
||||
@ -919,7 +1028,10 @@ private function presentDraftMaterial(CuttingMaterial $item): array
|
||||
'unit_abbreviation' => $rawMaterial?->unit?->abbreviation() ?? '',
|
||||
'stock_input' => $price?->stock_input ?? '',
|
||||
'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') : [],
|
||||
'combination_id' => $item->combination_id,
|
||||
'combination_material_result' => $combination?->material_result,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
use App\Models\Product;
|
||||
use App\Models\ProductVariant;
|
||||
use App\Models\User;
|
||||
use App\Services\Concerns\RunsInTransaction;
|
||||
use App\Services\Finance\CashService;
|
||||
use App\Services\Media\MediaService;
|
||||
use App\Services\System\PushNotificationService;
|
||||
@ -22,12 +23,12 @@
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class OrderService
|
||||
{
|
||||
use RunsInTransaction;
|
||||
|
||||
private const MAX_PHOTOS = 1;
|
||||
|
||||
public function __construct(
|
||||
@ -388,16 +389,16 @@ public function resyncDraftPrices(User $user, string $priceTypeValue): array
|
||||
|
||||
public function create(array $validated, User $user): Order
|
||||
{
|
||||
try {
|
||||
// Force cashier settings
|
||||
if ($user->hasRole('cashier')) {
|
||||
$validated['channel'] = 'store';
|
||||
$validated['price_type'] = 'retail';
|
||||
$validated['payment_type'] = 'cash';
|
||||
unset($validated['customer_id']);
|
||||
}
|
||||
// Force cashier settings
|
||||
if ($user->hasRole('cashier')) {
|
||||
$validated['channel'] = 'store';
|
||||
$validated['price_type'] = 'retail';
|
||||
$validated['payment_type'] = 'cash';
|
||||
unset($validated['customer_id']);
|
||||
}
|
||||
|
||||
$order = DB::transaction(function () use ($validated, $user): Order {
|
||||
$order = $this->runInTransaction(
|
||||
function () use ($validated, $user): Order {
|
||||
$priceType = $this->resolvePriceType($validated['channel'], $validated['price_type']);
|
||||
|
||||
$draftItems = $this->draftItemsQuery($user)
|
||||
@ -482,18 +483,9 @@ public function create(array $validated, User $user): Order
|
||||
}
|
||||
|
||||
return $order;
|
||||
});
|
||||
} 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.',
|
||||
]);
|
||||
}
|
||||
},
|
||||
'Gagal membuat pesanan',
|
||||
);
|
||||
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
'📦 Pesanan Baru',
|
||||
@ -509,8 +501,8 @@ public function update(Order $order, array $validated): void
|
||||
{
|
||||
$order->ensureEditable();
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($order, $validated): void {
|
||||
$this->runInTransaction(
|
||||
function () use ($order, $validated): void {
|
||||
$order->load('items');
|
||||
|
||||
foreach ($order->items as $item) {
|
||||
@ -580,18 +572,9 @@ public function update(Order $order, array $validated): void
|
||||
$orderItem = $order->items()->create($itemData);
|
||||
$this->decrementStock($orderItem);
|
||||
}
|
||||
});
|
||||
} 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.',
|
||||
]);
|
||||
}
|
||||
},
|
||||
'Gagal memperbarui pesanan',
|
||||
);
|
||||
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
'✏️ Pesanan Diperbarui',
|
||||
@ -606,8 +589,8 @@ public function delete(Order $order): void
|
||||
$orderNumber = $order->order_number;
|
||||
$totalAmount = $order->total_amount;
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($order): void {
|
||||
$this->runInTransaction(
|
||||
function () use ($order): void {
|
||||
$order->load('items');
|
||||
|
||||
if ($order->status->isEditable()) {
|
||||
@ -622,18 +605,9 @@ public function delete(Order $order): void
|
||||
|
||||
$order->items()->delete();
|
||||
$order->delete();
|
||||
});
|
||||
} 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.',
|
||||
]);
|
||||
}
|
||||
},
|
||||
'Gagal menghapus pesanan',
|
||||
);
|
||||
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
'🗑️ Pesanan Dihapus',
|
||||
@ -651,8 +625,8 @@ public function transitionStatus(Order $order, OrderStatus $status): void
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($order, $status): void {
|
||||
$this->runInTransaction(
|
||||
function () use ($order, $status): void {
|
||||
if ($status === OrderStatus::CANCELLED) {
|
||||
$order->load('items');
|
||||
|
||||
@ -668,18 +642,9 @@ public function transitionStatus(Order $order, OrderStatus $status): void
|
||||
|
||||
$order->status = $status;
|
||||
$order->save();
|
||||
});
|
||||
} 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.',
|
||||
]);
|
||||
}
|
||||
},
|
||||
'Gagal mengubah status pesanan',
|
||||
);
|
||||
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
'📦 Status Pesanan Diubah',
|
||||
|
||||
@ -12,18 +12,19 @@
|
||||
use App\Models\RawMaterialPrice;
|
||||
use App\Models\Supplier;
|
||||
use App\Models\User;
|
||||
use App\Services\Concerns\RunsInTransaction;
|
||||
use App\Services\Media\MediaService;
|
||||
use App\Services\System\PushNotificationService;
|
||||
use App\Support\Media\MediaPresenter;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class PurchaseService
|
||||
{
|
||||
use RunsInTransaction;
|
||||
|
||||
private const MAX_PHOTOS = 1;
|
||||
|
||||
public function __construct(
|
||||
@ -252,8 +253,8 @@ public function create(array $validated, User $user): Purchase
|
||||
{
|
||||
$isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value);
|
||||
|
||||
try {
|
||||
$purchase = DB::transaction(function () use ($validated, $user, $isOwner): Purchase {
|
||||
$purchase = $this->runInTransaction(
|
||||
function () use ($validated, $user, $isOwner): Purchase {
|
||||
|
||||
$draftItems = $this->draftItemsQuery($user)
|
||||
->lockForUpdate()
|
||||
@ -309,18 +310,9 @@ public function create(array $validated, User $user): Purchase
|
||||
}
|
||||
|
||||
return $purchase;
|
||||
});
|
||||
} 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.',
|
||||
]);
|
||||
}
|
||||
},
|
||||
'Gagal membuat pembelian',
|
||||
);
|
||||
|
||||
if (! $isOwner) {
|
||||
$this->notifyForPendingRequest(
|
||||
@ -340,8 +332,8 @@ public function update(Purchase $purchase, array $validated, User $user): void
|
||||
$isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value);
|
||||
$purchase->load(['supplier', 'items.rawMaterialPrice.rawMaterial:id,name']);
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($purchase, $validated, $user, $isOwner): void {
|
||||
$this->runInTransaction(
|
||||
function () use ($purchase, $validated, $user, $isOwner): void {
|
||||
if ($isOwner) {
|
||||
$payload = $this->buildPayloadFromValidated($validated);
|
||||
$this->applyPayloadToPurchase($purchase, $payload);
|
||||
@ -366,18 +358,9 @@ public function update(Purchase $purchase, array $validated, User $user): void
|
||||
$this->syncRequestPhotos($verificationRequest, $validated);
|
||||
}
|
||||
}
|
||||
});
|
||||
} 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.',
|
||||
]);
|
||||
}
|
||||
},
|
||||
'Gagal memperbarui pembelian',
|
||||
);
|
||||
|
||||
if (! $isOwner) {
|
||||
$purchase->load('supplier');
|
||||
@ -404,27 +387,22 @@ public function delete(Purchase $purchase, User $user): void
|
||||
|
||||
$purchase->load(['supplier', 'items.rawMaterialPrice.rawMaterial:id,name']);
|
||||
|
||||
try {
|
||||
OwnerVerificationRequest::create([
|
||||
'action' => OwnerVerificationAction::DELETE,
|
||||
'status' => OwnerVerificationStatus::PENDING,
|
||||
'subject_type' => Purchase::class,
|
||||
'subject_id' => $purchase->id,
|
||||
'submitted_by_id' => $user->id,
|
||||
'payload' => [
|
||||
'old' => $this->snapshotPurchase($purchase),
|
||||
'new' => null,
|
||||
],
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
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.',
|
||||
]);
|
||||
}
|
||||
$this->runInTransaction(
|
||||
function () use ($purchase, $user): void {
|
||||
OwnerVerificationRequest::create([
|
||||
'action' => OwnerVerificationAction::DELETE,
|
||||
'status' => OwnerVerificationStatus::PENDING,
|
||||
'subject_type' => Purchase::class,
|
||||
'subject_id' => $purchase->id,
|
||||
'submitted_by_id' => $user->id,
|
||||
'payload' => [
|
||||
'old' => $this->snapshotPurchase($purchase),
|
||||
'new' => null,
|
||||
],
|
||||
]);
|
||||
},
|
||||
'Gagal mengajukan penghapusan belanja',
|
||||
);
|
||||
|
||||
$purchase->load('supplier');
|
||||
|
||||
@ -615,26 +593,32 @@ private function rejectCreate(OwnerVerificationRequest $verificationRequest): vo
|
||||
return;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($purchase): void {
|
||||
$purchase->clearMediaCollection('photos');
|
||||
$purchase->items()->delete();
|
||||
$purchase->delete();
|
||||
});
|
||||
$this->runInTransaction(
|
||||
function () use ($purchase): void {
|
||||
$purchase->clearMediaCollection('photos');
|
||||
$purchase->items()->delete();
|
||||
$purchase->delete();
|
||||
},
|
||||
'Gagal menolak belanja',
|
||||
);
|
||||
}
|
||||
|
||||
private function executeDelete(Purchase $purchase): void
|
||||
{
|
||||
DB::transaction(function () use ($purchase): void {
|
||||
$purchase->load('items');
|
||||
$this->runInTransaction(
|
||||
function () use ($purchase): void {
|
||||
$purchase->load('items');
|
||||
|
||||
foreach ($purchase->items as $item) {
|
||||
$this->decrementStock($item);
|
||||
}
|
||||
foreach ($purchase->items as $item) {
|
||||
$this->decrementStock($item);
|
||||
}
|
||||
|
||||
$purchase->clearMediaCollection('photos');
|
||||
$purchase->items()->delete();
|
||||
$purchase->delete();
|
||||
});
|
||||
$purchase->clearMediaCollection('photos');
|
||||
$purchase->items()->delete();
|
||||
$purchase->delete();
|
||||
},
|
||||
'Gagal menghapus belanja',
|
||||
);
|
||||
}
|
||||
|
||||
private function applyPayloadToPurchase(
|
||||
@ -642,38 +626,41 @@ private function applyPayloadToPurchase(
|
||||
array $payload,
|
||||
?OwnerVerificationRequest $verificationRequest = null,
|
||||
): void {
|
||||
DB::transaction(function () use ($purchase, $payload, $verificationRequest): void {
|
||||
$purchase->load('items');
|
||||
$this->runInTransaction(
|
||||
function () use ($purchase, $payload, $verificationRequest): void {
|
||||
$purchase->load('items');
|
||||
|
||||
foreach ($purchase->items as $item) {
|
||||
$this->decrementStock($item);
|
||||
}
|
||||
foreach ($purchase->items as $item) {
|
||||
$this->decrementStock($item);
|
||||
}
|
||||
|
||||
$purchase->items()->delete();
|
||||
$purchase->items()->delete();
|
||||
|
||||
foreach ($payload['items'] ?? [] as $itemData) {
|
||||
$purchaseItem = $purchase->items()->create([
|
||||
'raw_material_price_id' => $itemData['raw_material_price_id'],
|
||||
'quantity' => $itemData['quantity'],
|
||||
'unit_price' => $itemData['unit_price'],
|
||||
'subtotal' => $itemData['subtotal'],
|
||||
foreach ($payload['items'] ?? [] as $itemData) {
|
||||
$purchaseItem = $purchase->items()->create([
|
||||
'raw_material_price_id' => $itemData['raw_material_price_id'],
|
||||
'quantity' => $itemData['quantity'],
|
||||
'unit_price' => $itemData['unit_price'],
|
||||
'subtotal' => $itemData['subtotal'],
|
||||
]);
|
||||
$this->incrementStock($purchaseItem);
|
||||
}
|
||||
|
||||
$purchase->update([
|
||||
'supplier_id' => $payload['supplier_id'],
|
||||
'subtotal' => $payload['subtotal'],
|
||||
'discount' => $payload['discount'],
|
||||
'shipping_cost' => $payload['shipping_cost'],
|
||||
'total' => $payload['total'],
|
||||
'notes' => $payload['notes'] ?? null,
|
||||
]);
|
||||
$this->incrementStock($purchaseItem);
|
||||
}
|
||||
|
||||
$purchase->update([
|
||||
'supplier_id' => $payload['supplier_id'],
|
||||
'subtotal' => $payload['subtotal'],
|
||||
'discount' => $payload['discount'],
|
||||
'shipping_cost' => $payload['shipping_cost'],
|
||||
'total' => $payload['total'],
|
||||
'notes' => $payload['notes'] ?? null,
|
||||
]);
|
||||
|
||||
if ($verificationRequest !== null) {
|
||||
$this->applyRequestPhotos($verificationRequest, $purchase, $payload);
|
||||
}
|
||||
});
|
||||
if ($verificationRequest !== null) {
|
||||
$this->applyRequestPhotos($verificationRequest, $purchase, $payload);
|
||||
}
|
||||
},
|
||||
'Gagal memperbarui belanja',
|
||||
);
|
||||
}
|
||||
|
||||
private function snapshotPurchase(Purchase $purchase): array
|
||||
|
||||
@ -10,17 +10,18 @@
|
||||
use App\Models\Product;
|
||||
use App\Models\ProductVariant;
|
||||
use App\Models\User;
|
||||
use App\Services\Concerns\RunsInTransaction;
|
||||
use App\Services\Media\MediaService;
|
||||
use App\Services\System\PushNotificationService;
|
||||
use App\Support\Media\MediaPresenter;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class ProductService
|
||||
{
|
||||
use RunsInTransaction;
|
||||
|
||||
private const MAX_VARIANT_IMAGES = 5;
|
||||
|
||||
public function __construct(
|
||||
@ -119,8 +120,8 @@ public function create(array $validated, User $user): void
|
||||
{
|
||||
$isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value);
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($validated, $user, $isOwner): void {
|
||||
$this->runInTransaction(
|
||||
function () use ($validated, $user, $isOwner): void {
|
||||
$product = Product::create([
|
||||
'name' => $validated['name'],
|
||||
'description' => $validated['description'] ?? null,
|
||||
@ -161,18 +162,9 @@ public function create(array $validated, User $user): void
|
||||
],
|
||||
]);
|
||||
}
|
||||
});
|
||||
} 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.',
|
||||
]);
|
||||
}
|
||||
},
|
||||
'Gagal membuat produk',
|
||||
);
|
||||
|
||||
if (! $isOwner) {
|
||||
$this->notifyForPendingRequest(
|
||||
@ -189,8 +181,8 @@ public function update(Product $product, array $validated, User $user): void
|
||||
{
|
||||
$isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value);
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($validated, $product, $user, $isOwner): void {
|
||||
$this->runInTransaction(
|
||||
function () use ($validated, $product, $user, $isOwner): void {
|
||||
if ($isOwner) {
|
||||
$payload = $this->enrichPayload($this->buildPayloadFromValidated($validated));
|
||||
$this->applyPayloadToProduct($product, $payload);
|
||||
@ -244,18 +236,9 @@ public function update(Product $product, array $validated, User $user): void
|
||||
$this->syncRequestVariantImages($verificationRequest, $variantData, $index, required: $isNewVariant);
|
||||
}
|
||||
}
|
||||
});
|
||||
} 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.',
|
||||
]);
|
||||
}
|
||||
},
|
||||
'Gagal memperbarui produk',
|
||||
);
|
||||
|
||||
if (! $isOwner) {
|
||||
$this->notifyForPendingRequest(
|
||||
@ -278,27 +261,22 @@ public function delete(Product $product, User $user): void
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
OwnerVerificationRequest::create([
|
||||
'action' => OwnerVerificationAction::DELETE,
|
||||
'status' => OwnerVerificationStatus::PENDING,
|
||||
'subject_type' => Product::class,
|
||||
'subject_id' => $product->id,
|
||||
'submitted_by_id' => $user->id,
|
||||
'payload' => [
|
||||
'old' => $this->snapshotProduct($product),
|
||||
'new' => null,
|
||||
],
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
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->runInTransaction(
|
||||
function () use ($product, $user): void {
|
||||
OwnerVerificationRequest::create([
|
||||
'action' => OwnerVerificationAction::DELETE,
|
||||
'status' => OwnerVerificationStatus::PENDING,
|
||||
'subject_type' => Product::class,
|
||||
'subject_id' => $product->id,
|
||||
'submitted_by_id' => $user->id,
|
||||
'payload' => [
|
||||
'old' => $this->snapshotProduct($product),
|
||||
'new' => null,
|
||||
],
|
||||
]);
|
||||
},
|
||||
'Gagal mengajukan penghapusan produk',
|
||||
);
|
||||
|
||||
$this->notifyForPendingRequest(
|
||||
$user,
|
||||
@ -321,33 +299,28 @@ public function toggleStatus(Product $product, array $validated, User $user): vo
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
OwnerVerificationRequest::create([
|
||||
'action' => OwnerVerificationAction::TOGGLE_STATUS,
|
||||
'status' => OwnerVerificationStatus::PENDING,
|
||||
'subject_type' => Product::class,
|
||||
'subject_id' => $product->id,
|
||||
'submitted_by_id' => $user->id,
|
||||
'payload' => [
|
||||
'old' => [
|
||||
'name' => $product->name,
|
||||
'is_active' => $product->is_active,
|
||||
$this->runInTransaction(
|
||||
function () use ($product, $validated, $user): void {
|
||||
OwnerVerificationRequest::create([
|
||||
'action' => OwnerVerificationAction::TOGGLE_STATUS,
|
||||
'status' => OwnerVerificationStatus::PENDING,
|
||||
'subject_type' => Product::class,
|
||||
'subject_id' => $product->id,
|
||||
'submitted_by_id' => $user->id,
|
||||
'payload' => [
|
||||
'old' => [
|
||||
'name' => $product->name,
|
||||
'is_active' => $product->is_active,
|
||||
],
|
||||
'new' => [
|
||||
'name' => $product->name,
|
||||
'is_active' => (bool) $validated['is_active'],
|
||||
],
|
||||
],
|
||||
'new' => [
|
||||
'name' => $product->name,
|
||||
'is_active' => (bool) $validated['is_active'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
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.',
|
||||
]);
|
||||
}
|
||||
]);
|
||||
},
|
||||
'Gagal mengajukan perubahan status produk',
|
||||
);
|
||||
|
||||
$statusLabel = $validated['is_active'] ? 'aktif' : 'nonaktif';
|
||||
|
||||
@ -517,14 +490,17 @@ public function applyDelete(OwnerVerificationRequest $verificationRequest): void
|
||||
|
||||
private function applyDeleteSubject(Product $product): void
|
||||
{
|
||||
DB::transaction(function () use ($product): void {
|
||||
$product->variants()->each(function (ProductVariant $variant): void {
|
||||
$variant->clearMediaCollection('images');
|
||||
});
|
||||
$product->variants()->delete();
|
||||
$product->categories()->detach();
|
||||
$product->delete();
|
||||
});
|
||||
$this->runInTransaction(
|
||||
function () use ($product): void {
|
||||
$product->variants()->each(function (ProductVariant $variant): void {
|
||||
$variant->clearMediaCollection('images');
|
||||
});
|
||||
$product->variants()->delete();
|
||||
$product->categories()->detach();
|
||||
$product->delete();
|
||||
},
|
||||
'Gagal menghapus produk',
|
||||
);
|
||||
}
|
||||
|
||||
public function applyToggleStatus(OwnerVerificationRequest $verificationRequest): void
|
||||
@ -574,14 +550,17 @@ private function rejectCreate(OwnerVerificationRequest $verificationRequest): vo
|
||||
return;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($product): void {
|
||||
$product->variants()->each(function (ProductVariant $variant): void {
|
||||
$variant->clearMediaCollection('images');
|
||||
});
|
||||
$product->variants()->delete();
|
||||
$product->categories()->detach();
|
||||
$product->delete();
|
||||
});
|
||||
$this->runInTransaction(
|
||||
function () use ($product): void {
|
||||
$product->variants()->each(function (ProductVariant $variant): void {
|
||||
$variant->clearMediaCollection('images');
|
||||
});
|
||||
$product->variants()->delete();
|
||||
$product->categories()->detach();
|
||||
$product->delete();
|
||||
},
|
||||
'Gagal menolak produk',
|
||||
);
|
||||
}
|
||||
|
||||
private function rollbackUpdate(OwnerVerificationRequest $verificationRequest): void
|
||||
|
||||
@ -10,17 +10,18 @@
|
||||
use App\Models\RawMaterial;
|
||||
use App\Models\RawMaterialPrice;
|
||||
use App\Models\User;
|
||||
use App\Services\Concerns\RunsInTransaction;
|
||||
use App\Services\Media\MediaService;
|
||||
use App\Services\System\PushNotificationService;
|
||||
use App\Support\Media\MediaPresenter;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class RawMaterialService
|
||||
{
|
||||
use RunsInTransaction;
|
||||
|
||||
private const MAX_VARIANT_IMAGES = 5;
|
||||
|
||||
public function __construct(
|
||||
@ -114,8 +115,8 @@ public function create(array $validated, User $user): void
|
||||
{
|
||||
$isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value);
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($validated, $user, $isOwner): void {
|
||||
$this->runInTransaction(
|
||||
function () use ($validated, $user, $isOwner): void {
|
||||
$rawMaterial = RawMaterial::create([
|
||||
'name' => $validated['name'],
|
||||
'unit' => $validated['unit'],
|
||||
@ -139,18 +140,9 @@ public function create(array $validated, User $user): void
|
||||
],
|
||||
]);
|
||||
}
|
||||
});
|
||||
} 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.',
|
||||
]);
|
||||
}
|
||||
},
|
||||
'Gagal membuat bahan baku',
|
||||
);
|
||||
|
||||
if (! $isOwner) {
|
||||
$this->notifyForPendingRequest(
|
||||
@ -167,8 +159,8 @@ public function update(RawMaterial $rawMaterial, array $validated, User $user):
|
||||
{
|
||||
$isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value);
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($validated, $rawMaterial, $user, $isOwner): void {
|
||||
$this->runInTransaction(
|
||||
function () use ($validated, $rawMaterial, $user, $isOwner): void {
|
||||
if ($isOwner) {
|
||||
$payload = $this->enrichPayload($this->buildPayloadFromValidated($validated));
|
||||
$this->applyPayloadToRawMaterial($rawMaterial, $payload);
|
||||
@ -201,18 +193,9 @@ public function update(RawMaterial $rawMaterial, array $validated, User $user):
|
||||
$this->syncRequestPriceImages($verificationRequest, $priceData, $index, required: $isNewPrice);
|
||||
}
|
||||
}
|
||||
});
|
||||
} 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.',
|
||||
]);
|
||||
}
|
||||
},
|
||||
'Gagal memperbarui bahan baku',
|
||||
);
|
||||
|
||||
if (! $isOwner) {
|
||||
$this->notifyForPendingRequest(
|
||||
@ -235,27 +218,22 @@ public function delete(RawMaterial $rawMaterial, User $user): void
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
OwnerVerificationRequest::create([
|
||||
'action' => OwnerVerificationAction::DELETE,
|
||||
'status' => OwnerVerificationStatus::PENDING,
|
||||
'subject_type' => RawMaterial::class,
|
||||
'subject_id' => $rawMaterial->id,
|
||||
'submitted_by_id' => $user->id,
|
||||
'payload' => [
|
||||
'old' => $this->snapshotRawMaterial($rawMaterial),
|
||||
'new' => null,
|
||||
],
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
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->runInTransaction(
|
||||
function () use ($rawMaterial, $user): void {
|
||||
OwnerVerificationRequest::create([
|
||||
'action' => OwnerVerificationAction::DELETE,
|
||||
'status' => OwnerVerificationStatus::PENDING,
|
||||
'subject_type' => RawMaterial::class,
|
||||
'subject_id' => $rawMaterial->id,
|
||||
'submitted_by_id' => $user->id,
|
||||
'payload' => [
|
||||
'old' => $this->snapshotRawMaterial($rawMaterial),
|
||||
'new' => null,
|
||||
],
|
||||
]);
|
||||
},
|
||||
'Gagal mengajukan penghapusan bahan baku',
|
||||
);
|
||||
|
||||
$this->notifyForPendingRequest(
|
||||
$user,
|
||||
@ -278,33 +256,28 @@ public function toggleStatus(RawMaterial $rawMaterial, array $validated, User $u
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
OwnerVerificationRequest::create([
|
||||
'action' => OwnerVerificationAction::TOGGLE_STATUS,
|
||||
'status' => OwnerVerificationStatus::PENDING,
|
||||
'subject_type' => RawMaterial::class,
|
||||
'subject_id' => $rawMaterial->id,
|
||||
'submitted_by_id' => $user->id,
|
||||
'payload' => [
|
||||
'old' => [
|
||||
'name' => $rawMaterial->name,
|
||||
'is_active' => $rawMaterial->is_active,
|
||||
$this->runInTransaction(
|
||||
function () use ($rawMaterial, $validated, $user): void {
|
||||
OwnerVerificationRequest::create([
|
||||
'action' => OwnerVerificationAction::TOGGLE_STATUS,
|
||||
'status' => OwnerVerificationStatus::PENDING,
|
||||
'subject_type' => RawMaterial::class,
|
||||
'subject_id' => $rawMaterial->id,
|
||||
'submitted_by_id' => $user->id,
|
||||
'payload' => [
|
||||
'old' => [
|
||||
'name' => $rawMaterial->name,
|
||||
'is_active' => $rawMaterial->is_active,
|
||||
],
|
||||
'new' => [
|
||||
'name' => $rawMaterial->name,
|
||||
'is_active' => (bool) $validated['is_active'],
|
||||
],
|
||||
],
|
||||
'new' => [
|
||||
'name' => $rawMaterial->name,
|
||||
'is_active' => (bool) $validated['is_active'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
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.',
|
||||
]);
|
||||
}
|
||||
]);
|
||||
},
|
||||
'Gagal mengajukan perubahan status bahan baku',
|
||||
);
|
||||
|
||||
$statusLabel = $validated['is_active'] ? 'aktif' : 'nonaktif';
|
||||
|
||||
@ -418,13 +391,16 @@ public function applyDelete(OwnerVerificationRequest $verificationRequest): void
|
||||
|
||||
private function applyDeleteSubject(RawMaterial $rawMaterial): void
|
||||
{
|
||||
DB::transaction(function () use ($rawMaterial): void {
|
||||
$rawMaterial->prices()->each(function (RawMaterialPrice $price): void {
|
||||
$price->clearMediaCollection('images');
|
||||
});
|
||||
$rawMaterial->prices()->delete();
|
||||
$rawMaterial->delete();
|
||||
});
|
||||
$this->runInTransaction(
|
||||
function () use ($rawMaterial): void {
|
||||
$rawMaterial->prices()->each(function (RawMaterialPrice $price): void {
|
||||
$price->clearMediaCollection('images');
|
||||
});
|
||||
$rawMaterial->prices()->delete();
|
||||
$rawMaterial->delete();
|
||||
},
|
||||
'Gagal menghapus bahan baku',
|
||||
);
|
||||
}
|
||||
|
||||
public function applyToggleStatus(OwnerVerificationRequest $verificationRequest): void
|
||||
@ -510,13 +486,16 @@ private function rejectCreate(OwnerVerificationRequest $verificationRequest): vo
|
||||
return;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($rawMaterial): void {
|
||||
$rawMaterial->prices()->each(function (RawMaterialPrice $price): void {
|
||||
$price->clearMediaCollection('images');
|
||||
});
|
||||
$rawMaterial->prices()->delete();
|
||||
$rawMaterial->delete();
|
||||
});
|
||||
$this->runInTransaction(
|
||||
function () use ($rawMaterial): void {
|
||||
$rawMaterial->prices()->each(function (RawMaterialPrice $price): void {
|
||||
$price->clearMediaCollection('images');
|
||||
});
|
||||
$rawMaterial->prices()->delete();
|
||||
$rawMaterial->delete();
|
||||
},
|
||||
'Gagal menolak bahan baku',
|
||||
);
|
||||
}
|
||||
|
||||
private function rollbackUpdate(OwnerVerificationRequest $verificationRequest): void
|
||||
|
||||
@ -3,12 +3,15 @@
|
||||
namespace App\Services\System\Setting;
|
||||
|
||||
use App\Models\HomepageConfiguration;
|
||||
use App\Services\Concerns\RunsInTransaction;
|
||||
use App\Services\Concerns\SyncsPhotos;
|
||||
use App\Services\Media\MediaService;
|
||||
use App\Support\Media\MediaPresenter;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
|
||||
class HomepageSettingService
|
||||
{
|
||||
use RunsInTransaction, SyncsPhotos;
|
||||
|
||||
public function __construct(
|
||||
private readonly MediaService $mediaService,
|
||||
) {}
|
||||
@ -31,30 +34,45 @@ public function homepageData(): array
|
||||
|
||||
public function updateHomepage(array $validated): void
|
||||
{
|
||||
$configuration = HomepageConfiguration::instance();
|
||||
$this->runInTransaction(
|
||||
function () use ($validated): void {
|
||||
$configuration = HomepageConfiguration::instance();
|
||||
|
||||
if (isset($validated['hero_image_s3_key'])) {
|
||||
$this->mediaService->replaceSingleS3Key($configuration, $validated['hero_image_s3_key'], 'hero_image', 'hero-image');
|
||||
} elseif (isset($validated['hero_image']) && $validated['hero_image'] instanceof UploadedFile) {
|
||||
$this->mediaService->replaceSingleFile($configuration, $validated['hero_image'], 'hero_image', 'hero-image');
|
||||
}
|
||||
$this->syncPhotos(
|
||||
$configuration,
|
||||
[
|
||||
'photos' => isset($validated['hero_image']) ? [$validated['hero_image']] : null,
|
||||
's3_keys' => isset($validated['hero_image_s3_key']) ? [$validated['hero_image_s3_key']] : null,
|
||||
],
|
||||
maxPhotos: 1,
|
||||
required: false,
|
||||
collection: 'hero_image',
|
||||
);
|
||||
|
||||
if (isset($validated['about_image_s3_key'])) {
|
||||
$this->mediaService->replaceSingleS3Key($configuration, $validated['about_image_s3_key'], 'about_image', 'about-image');
|
||||
} elseif (isset($validated['about_image']) && $validated['about_image'] instanceof UploadedFile) {
|
||||
$this->mediaService->replaceSingleFile($configuration, $validated['about_image'], 'about_image', 'about-image');
|
||||
}
|
||||
$this->syncPhotos(
|
||||
$configuration,
|
||||
[
|
||||
'photos' => isset($validated['about_image']) ? [$validated['about_image']] : null,
|
||||
's3_keys' => isset($validated['about_image_s3_key']) ? [$validated['about_image_s3_key']] : null,
|
||||
],
|
||||
maxPhotos: 1,
|
||||
required: false,
|
||||
collection: 'about_image',
|
||||
);
|
||||
|
||||
if (isset($validated['gallery_s3_keys']) || isset($validated['gallery_images_remove'])) {
|
||||
$this->mediaService->syncCollection(
|
||||
$configuration,
|
||||
'gallery',
|
||||
null,
|
||||
$validated['gallery_images_remove'] ?? null,
|
||||
10,
|
||||
'gallery',
|
||||
s3Keys: $validated['gallery_s3_keys'] ?? null,
|
||||
);
|
||||
}
|
||||
$this->syncPhotos(
|
||||
$configuration,
|
||||
[
|
||||
'photos' => $validated['gallery_images'] ?? null,
|
||||
'remove_media_ids' => $validated['gallery_images_remove'] ?? null,
|
||||
's3_keys' => $validated['gallery_s3_keys'] ?? null,
|
||||
],
|
||||
maxPhotos: 10,
|
||||
required: false,
|
||||
collection: 'gallery',
|
||||
);
|
||||
},
|
||||
'Gagal memperbarui pengaturan homepage',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
use App\Enums\Permission;
|
||||
use App\Models\OwnerVerificationRequest;
|
||||
use App\Models\User;
|
||||
use App\Services\Concerns\RunsInTransaction;
|
||||
use App\Services\System\PushNotificationService;
|
||||
use App\Settings\MarketplaceSettings;
|
||||
use App\Support\Marketplace\MarketplaceFeeCalculator;
|
||||
@ -16,6 +17,8 @@
|
||||
|
||||
class MarketplaceService
|
||||
{
|
||||
use RunsInTransaction;
|
||||
|
||||
public function __construct(
|
||||
private readonly PushNotificationService $pushNotificationService,
|
||||
) {}
|
||||
@ -72,61 +75,66 @@ public function marketplaceData(): array
|
||||
|
||||
public function updateMarketplace(array $validated, User $user): void
|
||||
{
|
||||
if ($user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)) {
|
||||
$this->saveSettings($validated);
|
||||
$this->runInTransaction(
|
||||
function () use ($validated, $user): void {
|
||||
if ($user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)) {
|
||||
$this->saveSettings($validated);
|
||||
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$hasPending = OwnerVerificationRequest::query()
|
||||
->where('subject_type', MarketplaceSettings::class)
|
||||
->pending()
|
||||
->exists();
|
||||
$hasPending = OwnerVerificationRequest::query()
|
||||
->where('subject_type', MarketplaceSettings::class)
|
||||
->pending()
|
||||
->exists();
|
||||
|
||||
if ($hasPending) {
|
||||
throw ValidationException::withMessages([
|
||||
'system' => 'Perubahan pengaturan marketplace sedang menunggu verifikasi owner.',
|
||||
]);
|
||||
}
|
||||
if ($hasPending) {
|
||||
throw ValidationException::withMessages([
|
||||
'system' => 'Perubahan pengaturan marketplace sedang menunggu verifikasi owner.',
|
||||
]);
|
||||
}
|
||||
|
||||
$settings = app(MarketplaceSettings::class);
|
||||
$oldPayload = [];
|
||||
$newPayload = [];
|
||||
$settings = app(MarketplaceSettings::class);
|
||||
$oldPayload = [];
|
||||
$newPayload = [];
|
||||
|
||||
foreach ($this->tiktokFeeKeys() as $key) {
|
||||
$oldPayload[$key] = $this->presentFeeRule($settings->{$key});
|
||||
$newPayload[$key] = $this->normalizeFeeRule($validated[$key]);
|
||||
}
|
||||
foreach ($this->tiktokFeeKeys() as $key) {
|
||||
$oldPayload[$key] = $this->presentFeeRule($settings->{$key});
|
||||
$newPayload[$key] = $this->normalizeFeeRule($validated[$key]);
|
||||
}
|
||||
|
||||
foreach ($this->shopeeFeeKeys() as $key) {
|
||||
$oldPayload[$key] = $this->presentFeeRule($settings->{$key});
|
||||
$newPayload[$key] = $this->normalizeFeeRule($validated[$key]);
|
||||
}
|
||||
foreach ($this->shopeeFeeKeys() as $key) {
|
||||
$oldPayload[$key] = $this->presentFeeRule($settings->{$key});
|
||||
$newPayload[$key] = $this->normalizeFeeRule($validated[$key]);
|
||||
}
|
||||
|
||||
OwnerVerificationRequest::create([
|
||||
'action' => OwnerVerificationAction::UPDATE,
|
||||
'status' => OwnerVerificationStatus::PENDING,
|
||||
'subject_type' => MarketplaceSettings::class,
|
||||
'subject_id' => null,
|
||||
'submitted_by_id' => $user->id,
|
||||
'payload' => [
|
||||
'old' => $oldPayload,
|
||||
'new' => $newPayload,
|
||||
],
|
||||
]);
|
||||
OwnerVerificationRequest::create([
|
||||
'action' => OwnerVerificationAction::UPDATE,
|
||||
'status' => OwnerVerificationStatus::PENDING,
|
||||
'subject_type' => MarketplaceSettings::class,
|
||||
'subject_id' => null,
|
||||
'submitted_by_id' => $user->id,
|
||||
'payload' => [
|
||||
'old' => $oldPayload,
|
||||
'new' => $newPayload,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
'⚙️ Pengaturan Marketplace Menunggu Persetujuan Owner',
|
||||
"Pengajuan ubah pengaturan marketplace oleh '{$user->username}' menunggu verifikasi owner.",
|
||||
['owner', 'developer', 'direktur'],
|
||||
route('admin.system.settings.index'),
|
||||
);
|
||||
$this->pushNotificationService->sendToRoles(
|
||||
'⚙️ Pengaturan Marketplace Menunggu Persetujuan Owner',
|
||||
"Pengajuan ubah pengaturan marketplace oleh '{$user->username}' menunggu verifikasi owner.",
|
||||
['owner', 'developer', 'direktur'],
|
||||
route('admin.system.settings.index'),
|
||||
);
|
||||
|
||||
$this->pushNotificationService->sendToUser(
|
||||
'📤 Pengajuan Terkirim',
|
||||
'Pengajuan ubah pengaturan marketplace menunggu verifikasi owner.',
|
||||
$user->id,
|
||||
route('admin.system.settings.index'),
|
||||
$this->pushNotificationService->sendToUser(
|
||||
'📤 Pengajuan Terkirim',
|
||||
'Pengajuan ubah pengaturan marketplace menunggu verifikasi owner.',
|
||||
$user->id,
|
||||
route('admin.system.settings.index'),
|
||||
);
|
||||
},
|
||||
'Gagal memperbarui pengaturan marketplace',
|
||||
);
|
||||
}
|
||||
|
||||
@ -147,8 +155,13 @@ public function saveSettings(array $validated): void
|
||||
|
||||
public function applyVerificationRequest(OwnerVerificationRequest $request): void
|
||||
{
|
||||
$newPayload = $request->payload['new'] ?? [];
|
||||
$this->saveSettings($newPayload);
|
||||
$this->runInTransaction(
|
||||
function () use ($request): void {
|
||||
$newPayload = $request->payload['new'] ?? [];
|
||||
$this->saveSettings($newPayload);
|
||||
},
|
||||
'Gagal menerapkan pengajuan verifikasi owner',
|
||||
);
|
||||
}
|
||||
|
||||
public function buildOrderSnapshot(OrderChannel $channel, int $totalAmount, array $lineItems, bool $isAffiliate = false): ?array
|
||||
|
||||
@ -3,14 +3,17 @@
|
||||
namespace App\Services\System\Setting;
|
||||
|
||||
use App\Models\SystemConfiguration;
|
||||
use App\Services\Concerns\RunsInTransaction;
|
||||
use App\Services\Concerns\SyncsPhotos;
|
||||
use App\Services\Media\MediaService;
|
||||
use App\Settings\SystemSettings;
|
||||
use App\Support\Media\MediaPresenter;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class SystemService
|
||||
{
|
||||
use RunsInTransaction, SyncsPhotos;
|
||||
|
||||
public function __construct(
|
||||
private readonly MediaService $mediaService,
|
||||
) {}
|
||||
@ -39,50 +42,70 @@ public function systemData(): array
|
||||
|
||||
public function updateSystem(array $validated): void
|
||||
{
|
||||
$settings = app(SystemSettings::class);
|
||||
$configuration = SystemConfiguration::instance();
|
||||
$this->runInTransaction(
|
||||
function () use ($validated): void {
|
||||
$settings = app(SystemSettings::class);
|
||||
$configuration = SystemConfiguration::instance();
|
||||
|
||||
$settings->app_name = $validated['app_name'];
|
||||
$settings->about_app = $validated['about_app'] ?? null;
|
||||
$settings->email = $validated['email'] ?? null;
|
||||
$settings->phone = $validated['phone'] ?? null;
|
||||
$settings->address = $validated['address'] ?? null;
|
||||
$settings->save();
|
||||
$settings->app_name = $validated['app_name'];
|
||||
$settings->about_app = $validated['about_app'] ?? null;
|
||||
$settings->email = $validated['email'] ?? null;
|
||||
$settings->phone = $validated['phone'] ?? null;
|
||||
$settings->address = $validated['address'] ?? null;
|
||||
$settings->save();
|
||||
|
||||
if (isset($validated['logo_s3_key'])) {
|
||||
$this->mediaService->replaceSingleS3Key($configuration, $validated['logo_s3_key'], 'logo', 'logo');
|
||||
} elseif (isset($validated['logo']) && $validated['logo'] instanceof UploadedFile) {
|
||||
$this->mediaService->replaceSingleFile($configuration, $validated['logo'], 'logo', 'logo');
|
||||
}
|
||||
$this->syncPhotos(
|
||||
$configuration,
|
||||
[
|
||||
'photos' => isset($validated['logo']) ? [$validated['logo']] : null,
|
||||
's3_keys' => isset($validated['logo_s3_key']) ? [$validated['logo_s3_key']] : null,
|
||||
],
|
||||
maxPhotos: 1,
|
||||
required: false,
|
||||
collection: 'logo',
|
||||
);
|
||||
|
||||
if (isset($validated['favicon_s3_key'])) {
|
||||
$this->mediaService->replaceSingleS3Key($configuration, $validated['favicon_s3_key'], 'favicon', 'favicon');
|
||||
} elseif (isset($validated['favicon']) && $validated['favicon'] instanceof UploadedFile) {
|
||||
$this->mediaService->replaceSingleFile($configuration, $validated['favicon'], 'favicon', 'favicon');
|
||||
}
|
||||
$this->syncPhotos(
|
||||
$configuration,
|
||||
[
|
||||
'photos' => isset($validated['favicon']) ? [$validated['favicon']] : null,
|
||||
's3_keys' => isset($validated['favicon_s3_key']) ? [$validated['favicon_s3_key']] : null,
|
||||
],
|
||||
maxPhotos: 1,
|
||||
required: false,
|
||||
collection: 'favicon',
|
||||
);
|
||||
|
||||
if (isset($validated['login_cover_s3_key'])) {
|
||||
$this->mediaService->replaceSingleS3Key($configuration, $validated['login_cover_s3_key'], 'login_cover', 'login-cover');
|
||||
} elseif (isset($validated['login_cover']) && $validated['login_cover'] instanceof UploadedFile) {
|
||||
$this->mediaService->replaceSingleFile($configuration, $validated['login_cover'], 'login_cover', 'login-cover');
|
||||
}
|
||||
$this->syncPhotos(
|
||||
$configuration,
|
||||
[
|
||||
'photos' => isset($validated['login_cover']) ? [$validated['login_cover']] : null,
|
||||
's3_keys' => isset($validated['login_cover_s3_key']) ? [$validated['login_cover_s3_key']] : null,
|
||||
],
|
||||
maxPhotos: 1,
|
||||
required: false,
|
||||
collection: 'login_cover',
|
||||
);
|
||||
|
||||
$errors = [];
|
||||
$errors = [];
|
||||
|
||||
if (! $configuration->hasMedia('logo')) {
|
||||
$errors['logo'] = 'Logo wajib diisi.';
|
||||
}
|
||||
if (! $configuration->hasMedia('logo')) {
|
||||
$errors['logo'] = 'Logo wajib diisi.';
|
||||
}
|
||||
|
||||
if (! $configuration->hasMedia('favicon')) {
|
||||
$errors['favicon'] = 'Favicon wajib diisi.';
|
||||
}
|
||||
if (! $configuration->hasMedia('favicon')) {
|
||||
$errors['favicon'] = 'Favicon wajib diisi.';
|
||||
}
|
||||
|
||||
if (! $configuration->hasMedia('login_cover')) {
|
||||
$errors['login_cover'] = 'Cover login wajib diisi.';
|
||||
}
|
||||
if (! $configuration->hasMedia('login_cover')) {
|
||||
$errors['login_cover'] = 'Cover login wajib diisi.';
|
||||
}
|
||||
|
||||
if ($errors !== []) {
|
||||
throw ValidationException::withMessages($errors);
|
||||
}
|
||||
if ($errors !== []) {
|
||||
throw ValidationException::withMessages($errors);
|
||||
}
|
||||
},
|
||||
'Gagal memperbarui pengaturan sistem',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
42
app/Support/ActivityLog/ActivityBuffer.php
Normal file
42
app/Support/ActivityLog/ActivityBuffer.php
Normal file
@ -0,0 +1,42 @@
|
||||
<?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 = [];
|
||||
}
|
||||
}
|
||||
@ -16,6 +16,7 @@
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
web: __DIR__.'/../routes/web.php',
|
||||
api: __DIR__.'/../routes/api.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
health: '/up',
|
||||
)
|
||||
@ -42,4 +43,8 @@
|
||||
$schedule->command('payroll:open-period')
|
||||
->monthlyOn(1, '00:05')
|
||||
->timezone('Asia/Jakarta');
|
||||
|
||||
$schedule->command('attendance:apply-penalties')
|
||||
->dailyAt('23:55')
|
||||
->timezone('Asia/Jakarta');
|
||||
})->create();
|
||||
|
||||
23
database/factories/CuttingMaterialCombinationFactory.php
Normal file
23
database/factories/CuttingMaterialCombinationFactory.php
Normal file
@ -0,0 +1,23 @@
|
||||
<?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,
|
||||
];
|
||||
}
|
||||
}
|
||||
23
database/factories/ProductPriceFactory.php
Normal file
23
database/factories/ProductPriceFactory.php
Normal file
@ -0,0 +1,23 @@
|
||||
<?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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -17,6 +17,7 @@ public function definition(): array
|
||||
'product_id' => Product::factory(),
|
||||
'name' => fake()->randomElement(['S', 'M', 'L', 'XL', 'All Size']),
|
||||
'stock' => fake()->numberBetween(0, 100),
|
||||
'retail_stock' => fake()->numberBetween(0, 50),
|
||||
'reject_stock' => fake()->numberBetween(0, 20),
|
||||
];
|
||||
}
|
||||
|
||||
@ -0,0 +1,27 @@
|
||||
<?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');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -24,7 +24,7 @@ interface MenuItem {
|
||||
title: string;
|
||||
href: string;
|
||||
icon: any;
|
||||
permission?: string;
|
||||
permission?: string | string[];
|
||||
badgeKey?: 'pendingLeaveRequests' | 'pendingEmployeeAdvances' | 'pendingCuttings';
|
||||
}
|
||||
|
||||
@ -82,7 +82,18 @@ const menuGroups: MenuGroup[] = [
|
||||
label: 'Sistem',
|
||||
items: [
|
||||
{ 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' },
|
||||
],
|
||||
},
|
||||
|
||||
@ -7,12 +7,16 @@ defineProps<{
|
||||
tooltip?: string;
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="size-8" :disabled="disabled">
|
||||
<Button variant="ghost" size="icon" class="size-8" :disabled="disabled" @click="emit('click')">
|
||||
<SlidersHorizontal class="size-4" />
|
||||
<span class="sr-only">{{ tooltip || 'Sesuaikan' }}</span>
|
||||
</Button>
|
||||
|
||||
@ -4,7 +4,6 @@ import type { ColumnDef } from '@tanstack/vue-table';
|
||||
import { FlexRender, getCoreRowModel, useVueTable } from '@tanstack/vue-table';
|
||||
import { computed, provide } from 'vue';
|
||||
import DataTableToolbar from '@/components/data-table/DataTableToolbar.vue';
|
||||
import { buildPaginationSummary } from '@/lib/grouped-table';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Table,
|
||||
@ -15,6 +14,7 @@ import {
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import TableEmpty from '@/components/ui/table/TableEmpty.vue';
|
||||
import { buildPaginationSummary } from '@/lib/grouped-table';
|
||||
import type {
|
||||
DataTableFilterDef,
|
||||
DataTablePagination,
|
||||
|
||||
@ -7,7 +7,11 @@ export function useCan() {
|
||||
const permissions = computed(() => page.props.auth.user?.permissions ?? []);
|
||||
const roles = computed(() => page.props.auth.user?.roles ?? []);
|
||||
|
||||
function can(permission: string): boolean {
|
||||
function can(permission: string | string[]): boolean {
|
||||
if (Array.isArray(permission)) {
|
||||
return permission.some((p) => permissions.value.includes(p));
|
||||
}
|
||||
|
||||
return permissions.value.includes(permission);
|
||||
}
|
||||
|
||||
|
||||
@ -26,8 +26,10 @@ export function useDestroy({ url, preserveScroll = true, errorMessage, onSuccess
|
||||
onError: (errors) => {
|
||||
if (onError) {
|
||||
const result = onError(errors);
|
||||
|
||||
if (typeof result === 'string') {
|
||||
toast.error(result);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { computed, type MaybeRefOrGetter, toValue } from 'vue';
|
||||
import { computed, toValue } from 'vue';
|
||||
import type {MaybeRefOrGetter} from 'vue';
|
||||
import { buildPaginationSummary } from '@/lib/grouped-table';
|
||||
import type { DataTablePagination } from '@/types/data-table';
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { Component } from 'vue';
|
||||
import { Check, RotateCcw, Scissors, X } from '@lucide/vue';
|
||||
import type { Component } from 'vue';
|
||||
import type { BadgeVariant } from '@/lib/badge-variant';
|
||||
|
||||
export const CuttingStatus = {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { Component } from 'vue';
|
||||
import { Check, Send, X } from '@lucide/vue';
|
||||
import type { Component } from 'vue';
|
||||
import type { BadgeVariant } from '@/lib/badge-variant';
|
||||
|
||||
export const OrderStatus = {
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { Home, Settings2, Share2, ShoppingBag, Users } from '@lucide/vue';
|
||||
import { SettingSection as SettingSectionConst } from '@/constants/setting-section';
|
||||
import { computed } from 'vue';
|
||||
import { useCan } from '@/composables/useCan';
|
||||
import { SettingSection as SettingSectionConst } from '@/constants/setting-section';
|
||||
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { SettingSection } from '@/types/setting';
|
||||
@ -23,6 +23,7 @@ const filteredNavItems = computed(() => {
|
||||
if (hasRole('admin-toko')) {
|
||||
return navItems.filter((item) => item.key === SettingSectionConst.MARKETPLACE);
|
||||
}
|
||||
|
||||
return navItems;
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -212,6 +212,7 @@ const donutSegmentSelector = Donut.selectors.segment;
|
||||
|
||||
function channelTooltip(arc: any) {
|
||||
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">
|
||||
<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>
|
||||
@ -223,6 +224,7 @@ function channelTooltip(arc: any) {
|
||||
|
||||
function paymentTooltip(arc: any) {
|
||||
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">
|
||||
<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>
|
||||
@ -235,6 +237,7 @@ function paymentTooltip(arc: any) {
|
||||
function marketingTooltip(arc: any) {
|
||||
const d = arc.data as typeof props.orderStats.by_marketing[number];
|
||||
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">
|
||||
<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>
|
||||
@ -246,6 +249,7 @@ function marketingTooltip(arc: any) {
|
||||
|
||||
function statusTooltip(arc: any) {
|
||||
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">
|
||||
<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>
|
||||
|
||||
@ -1,7 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
ChartConfig,
|
||||
} from "@/components/ui/chart"
|
||||
import { TrendingUp } from "@lucide/vue"
|
||||
|
||||
import { CurveType } from "@unovis/ts"
|
||||
@ -14,6 +11,9 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import type {
|
||||
ChartConfig,
|
||||
} from "@/components/ui/chart"
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartCrosshair,
|
||||
|
||||
@ -17,9 +17,9 @@ import { AppearanceMode as AppearanceModeConst } from '@/constants/appearance-mo
|
||||
import AccountLayout from '@/layouts/AccountLayout.vue';
|
||||
import { formErrors } from '@/lib/form';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { update } from '@/routes/admin/account/appearance';
|
||||
import type { AppearanceFormData, AppearanceMode } from '@/types/account';
|
||||
|
||||
import { update } from '@/routes/admin/account/appearance';
|
||||
|
||||
const props = defineProps<{
|
||||
appearance: AppearanceMode;
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, useForm } from '@inertiajs/vue3';
|
||||
import { Save } from '@lucide/vue';
|
||||
import { ref } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
import { PhoneNumberInput } from '@/components/form/phone-number-input';
|
||||
import MediaDropzone from '@/components/media/MediaDropzone.vue';
|
||||
|
||||
@ -6,15 +6,19 @@ import { DataTable } from '@/components/data-table';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useCan } from '@/composables/useCan';
|
||||
import {
|
||||
useDataTableQuery,
|
||||
useDataTableQuerySync,
|
||||
} from '@/composables/useDataTableQuery';
|
||||
import { CashReferenceType } from '@/constants/cash-reference-type';
|
||||
import { CashTransactionType } from '@/constants/cash-transaction-type';
|
||||
import { useDataTableQuery, useDataTableQuerySync } from '@/composables/useDataTableQuery';
|
||||
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||
import { index } from '@/routes/admin/finance/cash';
|
||||
import type { CashAccount, CashTransactionListItem, PaginatedCashTransactions } from '@/types/cash';
|
||||
import type { DataTableFilterDef, DataTableSort } from '@/types/data-table';
|
||||
import CashTransactionFormModal from './form/CashTransactionFormModal.vue';
|
||||
import { createColumns } from './table/columns';
|
||||
import { index } from '@/routes/admin/finance/cash';
|
||||
|
||||
|
||||
const props = defineProps<{
|
||||
cashAccount: CashAccount;
|
||||
@ -116,11 +120,13 @@ watch(
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-2 self-start sm:self-center">
|
||||
<Button v-if="can('cash.deposit')" variant="outline" @click="openCreateModal(CashTransactionType.DEPOSIT)">
|
||||
<Button v-if="can('cash.deposit')" variant="outline"
|
||||
@click="openCreateModal(CashTransactionType.DEPOSIT)">
|
||||
<ArrowDownCircle class="size-4" />
|
||||
Setor Kas
|
||||
</Button>
|
||||
<Button v-if="can('cash.withdraw')" variant="outline" @click="openCreateModal(CashTransactionType.WITHDRAWAL)">
|
||||
<Button v-if="can('cash.withdraw')" variant="outline"
|
||||
@click="openCreateModal(CashTransactionType.WITHDRAWAL)">
|
||||
<ArrowUpCircle class="size-4" />
|
||||
Tarik Kas
|
||||
</Button>
|
||||
@ -142,7 +148,7 @@ watch(
|
||||
</Card>
|
||||
|
||||
<Card class="min-w-0">
|
||||
<CardContent class="min-w-0 pt-6">
|
||||
<CardContent class="min-w-0">
|
||||
<DataTable v-model:search="search" :columns="columns" :data="transactions.data" :pagination="pagination"
|
||||
:pagination-links="transactions.links" :sort="currentSort" :filter-defs="filterDefs"
|
||||
:filter-values="filterValues" @sort-change="setSort" @filter-change="setFilter"
|
||||
|
||||
@ -2,8 +2,8 @@
|
||||
import { computed } from 'vue';
|
||||
import { RowDeleteAction, RowEditAction } from '@/components/button';
|
||||
import { useCan } from '@/composables/useCan';
|
||||
import type { CashTransactionListItem } from '@/types/cash';
|
||||
import { destroy } from '@/routes/admin/finance/cash/transactions';
|
||||
import type { CashTransactionListItem } from '@/types/cash';
|
||||
|
||||
const props = defineProps<{
|
||||
transaction: CashTransactionListItem;
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
<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 { DataTable } from '@/components/data-table';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@ -9,19 +12,16 @@ import {
|
||||
} from '@/composables/useDataTableQuery';
|
||||
import { EmployeeAdvanceStatus } from '@/constants/employee-advance-status';
|
||||
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||
import { index } from '@/routes/admin/finance/employee_advances';
|
||||
import type { DataTableFilterDef, DataTableSort } from '@/types/data-table';
|
||||
import type {
|
||||
EmployeeAdvanceListItem,
|
||||
EmployeeAdvancePageProps,
|
||||
} 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 RejectEmployeeAdvanceModal from './form/RejectEmployeeAdvanceModal.vue';
|
||||
import { createColumns } from './table/columns';
|
||||
|
||||
import { index } from '@/routes/admin/finance/employee_advances';
|
||||
|
||||
const props = defineProps<EmployeeAdvancePageProps>();
|
||||
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import CreateButton from '@/components/button/CreateButton.vue';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
@ -8,14 +10,12 @@ import {
|
||||
useDataTableQuerySync,
|
||||
} from '@/composables/useDataTableQuery';
|
||||
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||
import { index } from '@/routes/admin/finance/expenses';
|
||||
import type { DataTableSort } from '@/types/data-table';
|
||||
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 { createColumns } from './table/columns';
|
||||
|
||||
import { index } from '@/routes/admin/finance/expenses';
|
||||
|
||||
const props = defineProps<{
|
||||
expenses: PaginatedExpenses;
|
||||
|
||||
@ -26,11 +26,10 @@ import { FIELD_LIMITS } from '@/lib/field-limits';
|
||||
import { formErrors } from '@/lib/form';
|
||||
import { parseRupiah } from '@/lib/rupiah';
|
||||
import { store, update } from '@/routes/admin/finance/expenses';
|
||||
import type { ExpenseListItem } from '@/types/expense';
|
||||
import type { ExpenseFormData, ExpenseListItem } from '@/types/expense';
|
||||
import { appendRootPhotosToFormData, createMediaUploadState } from '@/types/media';
|
||||
import type { MediaUploadState } from '@/types/media';
|
||||
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false });
|
||||
|
||||
const props = defineProps<{
|
||||
@ -43,7 +42,7 @@ const photoState = ref<MediaUploadState>(createMediaUploadState());
|
||||
|
||||
const isUploading = computed(() => photoState.value.pendingUploads > 0);
|
||||
|
||||
const form = useForm({
|
||||
const form = useForm<ExpenseFormData>({
|
||||
amount: '',
|
||||
description: '',
|
||||
});
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { RowDeleteAction, RowEditAction } from '@/components/button';
|
||||
import { useCan } from '@/composables/useCan';
|
||||
import type { ExpenseListItem } from '@/types/expense';
|
||||
import { destroy } from '@/routes/admin/finance/expenses';
|
||||
import type { ExpenseListItem } from '@/types/expense';
|
||||
|
||||
defineProps<{
|
||||
expense: ExpenseListItem;
|
||||
@ -20,6 +20,6 @@ const { can } = useCan();
|
||||
<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?"
|
||||
:description="`Pengeluaran ${expense.amount_formatted} akan dihapus. Saldo kas akan disesuaikan.`"
|
||||
:on-error="(errors) => errors.amount || errors.transaction || 'Gagal menghapus pengeluaran.'" />
|
||||
error-message="Gagal menghapus pengeluaran." />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -22,12 +22,12 @@ import {
|
||||
useDataTableQuerySync,
|
||||
} from '@/composables/useDataTableQuery';
|
||||
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||
import { index } from '@/routes/admin/finance/payroll';
|
||||
import type { DataTableSort } from '@/types/data-table';
|
||||
import type { PayrollListItem, PayrollPageProps } from '@/types/payroll';
|
||||
import PayrollAdjustmentModal from './form/PayrollAdjustmentModal.vue';
|
||||
import { createColumns } from './table/columns';
|
||||
|
||||
import { index } from '@/routes/admin/finance/payroll';
|
||||
|
||||
const props = defineProps<PayrollPageProps>();
|
||||
|
||||
|
||||
@ -8,7 +8,7 @@ import type { AttendanceListItem, CalendarRange, TodayAttendance } from '@/types
|
||||
import AttendanceWebcamModal from './form/AttendanceWebcamModal.vue';
|
||||
import AttendanceCalendar from './table/AttendanceCalendar.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
defineProps<{
|
||||
attendances: AttendanceListItem[];
|
||||
todayAttendance: TodayAttendance;
|
||||
isOnLeave: boolean;
|
||||
@ -44,13 +44,8 @@ function openCheckOutModal() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AttendanceCard
|
||||
:today-attendance="todayAttendance"
|
||||
:is-on-leave="isOnLeave"
|
||||
:can-check-in="canCheckIn"
|
||||
@check-in="openCheckInModal"
|
||||
@check-out="openCheckOutModal"
|
||||
/>
|
||||
<AttendanceCard :today-attendance="todayAttendance" :is-on-leave="isOnLeave" :can-check-in="canCheckIn"
|
||||
@check-in="openCheckInModal" @check-out="openCheckOutModal" />
|
||||
|
||||
<Card class="min-w-0 overflow-hidden">
|
||||
<CardContent class="min-w-0">
|
||||
|
||||
@ -7,13 +7,13 @@ import FullCalendar from '@fullcalendar/vue3';
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import { ChevronLeft, ChevronRight } from '@lucide/vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import AttendanceDetailDialog from './AttendanceDetailDialog.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { AttendanceListItem, CalendarRange } from '@/types/attendance';
|
||||
import { index } from '@/routes/admin/hr/attendances';
|
||||
import type { AttendanceListItem, CalendarRange } from '@/types/attendance';
|
||||
import AttendanceDetailDialog from './AttendanceDetailDialog.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
attendances: AttendanceListItem[];
|
||||
@ -147,6 +147,7 @@ function scrollToToday() {
|
||||
requestAnimationFrame(() => {
|
||||
const container = calendarRef.value?.$el?.closest('.overflow-x-auto');
|
||||
const todayEl = container?.querySelector('.fc-day-today');
|
||||
|
||||
if (!container || !todayEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { Trash2 } from '@lucide/vue';
|
||||
import AttendanceMap from './AttendanceMap.vue';
|
||||
import AttendancePhotoCell from './attendance-photo-cell.vue';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@ -13,8 +11,10 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { useCan } from '@/composables/useCan';
|
||||
import { useDestroy } from '@/composables/useDestroy';
|
||||
import type { AttendanceListItem } from '@/types/attendance';
|
||||
import { destroy } from '@/routes/admin/hr/attendances';
|
||||
import type { AttendanceListItem } from '@/types/attendance';
|
||||
import AttendancePhotoCell from './attendance-photo-cell.vue';
|
||||
import AttendanceMap from './AttendanceMap.vue';
|
||||
|
||||
const open = defineModel<boolean>('open', { required: true });
|
||||
|
||||
@ -76,29 +76,20 @@ const { open: deleteConfirmOpen, processing: deleteProcessing, destroy: destroyA
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<AttendanceMap
|
||||
:check-in-lat="attendance.check_in_latitude"
|
||||
:check-in-lng="attendance.check_in_longitude"
|
||||
:check-out-lat="attendance.check_out_latitude"
|
||||
:check-out-lng="attendance.check_out_longitude"
|
||||
/>
|
||||
<AttendanceMap :check-in-lat="attendance.check_in_latitude"
|
||||
:check-in-lng="attendance.check_in_longitude" :check-out-lat="attendance.check_out_latitude"
|
||||
:check-out-lng="attendance.check_out_longitude" />
|
||||
|
||||
<div>
|
||||
<p class="text-muted-foreground mb-2 text-sm">
|
||||
Foto
|
||||
</p>
|
||||
<AttendancePhotoCell
|
||||
:check-in-photo-url="attendance.check_in_photo_url"
|
||||
:check-out-photo-url="attendance.check_out_photo_url"
|
||||
/>
|
||||
<AttendancePhotoCell :check-in-photo-url="attendance.check_in_photo_url"
|
||||
:check-out-photo-url="attendance.check_out_photo_url" />
|
||||
</div>
|
||||
|
||||
<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" />
|
||||
Hapus
|
||||
</Button>
|
||||
@ -107,15 +98,9 @@ const { open: deleteConfirmOpen, processing: deleteProcessing, destroy: destroyA
|
||||
</DialogContent>
|
||||
</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?"
|
||||
:description="`Presensi tanggal ${attendance.attendance_date_formatted} akan dihapus secara permanen.`"
|
||||
confirm-label="Hapus"
|
||||
cancel-label="Batal"
|
||||
destructive
|
||||
:loading="deleteProcessing"
|
||||
@confirm="destroyAttendance"
|
||||
/>
|
||||
confirm-label="Hapus" cancel-label="Batal" destructive :loading="deleteProcessing"
|
||||
@confirm="destroyAttendance" />
|
||||
</template>
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { RowDeleteAction } from '@/components/button';
|
||||
import { useCan } from '@/composables/useCan';
|
||||
import type { AttendanceListItem } from '@/types/attendance';
|
||||
import { destroy } from '@/routes/admin/hr/attendances';
|
||||
import type { AttendanceListItem } from '@/types/attendance';
|
||||
|
||||
defineProps<{
|
||||
attendance: AttendanceListItem;
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import BackButton from '@/components/button/BackButton.vue';
|
||||
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||
import type { EnumOption } from '@/types/employee';
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import EmployeeForm from './form/EmployeeForm.vue';
|
||||
import { index, store } from '@/routes/admin/hr/employees';
|
||||
import type { EnumOption } from '@/types/employee';
|
||||
import EmployeeForm from './form/EmployeeForm.vue';
|
||||
|
||||
defineProps<{
|
||||
genders: EnumOption[];
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import BackButton from '@/components/button/BackButton.vue';
|
||||
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||
import type { EmployeeListItem, EnumOption } from '@/types/employee';
|
||||
import type { MediaItem } from '@/types/media';
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import { computed } from 'vue';
|
||||
import EmployeeForm from './form/EmployeeForm.vue';
|
||||
import BackButton from '@/components/button/BackButton.vue';
|
||||
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||
import { index, update } from '@/routes/admin/hr/employees';
|
||||
import type { EmployeeListItem, EnumOption } from '@/types/employee';
|
||||
import type { MediaItem } from '@/types/media';
|
||||
import EmployeeForm from './form/EmployeeForm.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
employee: EmployeeListItem;
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import CreateButton from '@/components/button/CreateButton.vue';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
@ -9,12 +11,10 @@ import {
|
||||
} from '@/composables/useDataTableQuery';
|
||||
import { ActiveStatus } from '@/constants/active-status';
|
||||
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||
import { index, create } from '@/routes/admin/hr/employees';
|
||||
import type { DataTableFilterDef, DataTableSort } from '@/types/data-table';
|
||||
import type { EnumOption, PaginatedEmployees } from '@/types/employee';
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { columns } from './table/columns';
|
||||
import { index, create } from '@/routes/admin/hr/employees';
|
||||
|
||||
const props = defineProps<{
|
||||
employees: PaginatedEmployees;
|
||||
@ -112,37 +112,24 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<Head title="Pegawai" />
|
||||
|
||||
<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">
|
||||
<h2 class="text-2xl font-bold tracking-tight">Pegawai</h2>
|
||||
</div>
|
||||
|
||||
<CreateButton
|
||||
v-if="can('employees.create')"
|
||||
:href="create.url()"
|
||||
/>
|
||||
<CreateButton v-if="can('employees.create')" :href="create.url()" />
|
||||
</div>
|
||||
|
||||
<Card class="min-w-0">
|
||||
<CardContent class="min-w-0">
|
||||
<DataTable
|
||||
v-model:search="search"
|
||||
:columns="columns"
|
||||
: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"
|
||||
/>
|
||||
<DataTable v-model:search="search" :columns="columns" :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>
|
||||
</Card>
|
||||
</AdminLayout>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { Save } from '@lucide/vue';
|
||||
import { ref } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
import { PhoneNumberInput } from '@/components/form/phone-number-input';
|
||||
import { RupiahInput } from '@/components/form/rupiah-input';
|
||||
@ -131,8 +131,8 @@ function submit() {
|
||||
<FieldSet class="grid gap-4 md:grid-cols-3">
|
||||
<Field>
|
||||
<FieldLabel for="email" required>Email</FieldLabel>
|
||||
<Input id="email" v-model="form.email" type="email"
|
||||
placeholder="Masukkan email" :maxlength="FIELD_LIMITS.email" />
|
||||
<Input id="email" v-model="form.email" type="email" placeholder="Masukkan email"
|
||||
:maxlength="FIELD_LIMITS.email" />
|
||||
<FieldError :errors="formErrors(form, 'email')" />
|
||||
</Field>
|
||||
<Field>
|
||||
@ -201,8 +201,7 @@ function submit() {
|
||||
:errors="formErrors(form, 'profile_photo')" class="md:col-span-3" />
|
||||
<Field class="md:col-span-3">
|
||||
<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')" />
|
||||
</Field>
|
||||
</FieldSet>
|
||||
|
||||
@ -2,6 +2,7 @@ import type { ColumnDef } from '@tanstack/vue-table';
|
||||
import { h } from 'vue';
|
||||
import { DataTableColumnHeader } from '@/components/data-table';
|
||||
import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { EmployeeListItem } from '@/types/employee';
|
||||
import type { MediaItem } from '@/types/media';
|
||||
@ -9,24 +10,24 @@ import DataTableActions from './data-table-actions.vue';
|
||||
import EmployeeStatusToggle from './employee-status-toggle.vue';
|
||||
|
||||
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',
|
||||
enableSorting: true,
|
||||
header: () => h(DataTableColumnHeader, { title: 'Nama', column: 'full_name' }),
|
||||
cell: ({ row }) => row.original.profile?.full_name ?? '-',
|
||||
cell: ({ row }) => {
|
||||
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',
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import CreateButton from '@/components/button/CreateButton.vue';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
@ -8,17 +10,16 @@ import {
|
||||
useDataTableQuerySync,
|
||||
} from '@/composables/useDataTableQuery';
|
||||
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||
import { index } from '@/routes/admin/hr/leave_requests';
|
||||
import type { DataTableSort } from '@/types/data-table';
|
||||
import type {
|
||||
LeaveRequestListItem,
|
||||
LeaveRequestPageProps,
|
||||
} from '@/types/leave-request';
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import LeaveRequestFormModal from './form/LeaveRequestFormModal.vue';
|
||||
import RejectLeaveRequestModal from './form/RejectLeaveRequestModal.vue';
|
||||
import { createColumns } from './table/columns';
|
||||
import { index } from '@/routes/admin/hr/leave_requests';
|
||||
|
||||
|
||||
const props = defineProps<LeaveRequestPageProps>();
|
||||
|
||||
@ -87,48 +88,29 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<Head title="Cuti" />
|
||||
|
||||
<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">
|
||||
<h2 class="text-2xl font-bold tracking-tight">Cuti</h2>
|
||||
</div>
|
||||
|
||||
<CreateButton
|
||||
v-if="canSubmit && !hasPending"
|
||||
@click="openCreateModal"
|
||||
label="Ajukan"
|
||||
/>
|
||||
<CreateButton v-if="canSubmit && !hasPending" @click="openCreateModal" label="Ajukan" />
|
||||
</div>
|
||||
|
||||
<Card class="min-w-0">
|
||||
<CardContent class="min-w-0 pt-6">
|
||||
<DataTable
|
||||
v-model:search="search"
|
||||
:columns="columns"
|
||||
:data="leaveRequests.data"
|
||||
:pagination="pagination"
|
||||
:pagination-links="leaveRequests.links"
|
||||
:sort="currentSort"
|
||||
@sort-change="setSort"
|
||||
@filters-reset="resetFilters"
|
||||
/>
|
||||
<DataTable v-model:search="search" :columns="columns" :data="leaveRequests.data"
|
||||
:pagination="pagination" :pagination-links="leaveRequests.links" :sort="currentSort"
|
||||
@sort-change="setSort" @filters-reset="resetFilters" />
|
||||
</CardContent>
|
||||
</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"
|
||||
:leave-request="rejectingLeaveRequest"
|
||||
/>
|
||||
<RejectLeaveRequestModal v-if="can('leave_requests.verify')" v-model:open="rejectModalOpen"
|
||||
:leave-request="rejectingLeaveRequest" />
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import BackButton from '@/components/button/BackButton.vue';
|
||||
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||
import { index, store } from '@/routes/admin/manage/cuttings';
|
||||
import type {
|
||||
CuttingMaterialCartItem,
|
||||
CuttingProductCatalogItem,
|
||||
@ -9,9 +11,7 @@ import type {
|
||||
} from '@/types/cutting';
|
||||
import type { CategoryOption } from '@/types/product';
|
||||
import type { EnumOption } from '@/types/raw-material';
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import CuttingPosForm from './form/CuttingPosForm.vue';
|
||||
import { index, store } from '@/routes/admin/manage/cuttings';
|
||||
|
||||
defineProps<{
|
||||
rawMaterialCatalog: CuttingRawMaterialCatalogItem[];
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import { computed } from 'vue';
|
||||
import BackButton from '@/components/button/BackButton.vue';
|
||||
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||
import { index, update } from '@/routes/admin/manage/cuttings';
|
||||
import type {
|
||||
CuttingEditItem,
|
||||
CuttingProductCatalogItem,
|
||||
@ -8,10 +11,7 @@ import type {
|
||||
} from '@/types/cutting';
|
||||
import type { CategoryOption } from '@/types/product';
|
||||
import type { EnumOption } from '@/types/raw-material';
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import { computed } from 'vue';
|
||||
import CuttingPosForm from './form/CuttingPosForm.vue';
|
||||
import { index, update } from '@/routes/admin/manage/cuttings';
|
||||
|
||||
const props = defineProps<{
|
||||
cutting: CuttingEditItem;
|
||||
@ -34,7 +34,10 @@ const initialData = computed(() => ({
|
||||
unit_abbreviation: item.unit_abbreviation ?? '',
|
||||
stock_input: item.stock_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 ?? [],
|
||||
combination_id: item.combination_id ?? null,
|
||||
combination_material_result: item.combination_material_result ?? null,
|
||||
})),
|
||||
results: props.cutting.results.map((item) => ({
|
||||
product_variant_id: item.product_variant_id,
|
||||
@ -50,12 +53,11 @@ const initialData = computed(() => ({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<Head title="Ubah Cutting" />
|
||||
|
||||
<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">
|
||||
<h2 class="text-2xl font-bold tracking-tight">Ubah Cutting</h2>
|
||||
</div>
|
||||
@ -63,15 +65,8 @@ const initialData = computed(() => ({
|
||||
<BackButton :href="index.url()" />
|
||||
</div>
|
||||
|
||||
<CuttingPosForm
|
||||
:raw-material-catalog="rawMaterialCatalog"
|
||||
:product-catalog="productCatalog"
|
||||
:categories="categories"
|
||||
:units="units"
|
||||
:initial-data="initialData"
|
||||
:submit-url="update.url(props.cutting.id)"
|
||||
method="put"
|
||||
submit-label="Perbarui"
|
||||
/>
|
||||
<CuttingPosForm :raw-material-catalog="rawMaterialCatalog" :product-catalog="productCatalog"
|
||||
:categories="categories" :units="units" :initial-data="initialData"
|
||||
:submit-url="update.url(props.cutting.id)" method="put" submit-label="Perbarui" />
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import CreateButton from '@/components/button/CreateButton.vue';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { useCan } from '@/composables/useCan';
|
||||
@ -7,12 +9,10 @@ import {
|
||||
useDataTableQuerySync,
|
||||
} from '@/composables/useDataTableQuery';
|
||||
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||
import { index, create } from '@/routes/admin/manage/cuttings';
|
||||
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 CuttingInProgressSection from './table/CuttingInProgressSection.vue';
|
||||
import { index, create } from '@/routes/admin/manage/cuttings';
|
||||
|
||||
const props = defineProps<{
|
||||
cuttings: PaginatedCuttings;
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Table,
|
||||
@ -10,7 +11,6 @@ import {
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { cuttingStatusBadgeVariant } from '@/constants/cutting-status';
|
||||
import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue';
|
||||
import type { CuttingListItem } from '@/types/cutting';
|
||||
|
||||
const props = defineProps<{
|
||||
@ -22,6 +22,7 @@ interface GroupedMaterials {
|
||||
name: string;
|
||||
unitLabel?: string;
|
||||
items: typeof props.cutting.materials;
|
||||
isCombination?: boolean;
|
||||
}
|
||||
|
||||
interface GroupedResults {
|
||||
@ -31,9 +32,38 @@ interface GroupedResults {
|
||||
}
|
||||
|
||||
const groupedMaterials = computed<GroupedMaterials[]>(() => {
|
||||
const groups: Record<number, 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> = {};
|
||||
|
||||
nonCombinationItems.forEach((item) => {
|
||||
const key = item.raw_material_id ?? 0;
|
||||
const name = item.raw_material_name || 'Bahan Baku Tidak Diketahui';
|
||||
|
||||
@ -49,7 +79,9 @@ const groupedMaterials = computed<GroupedMaterials[]>(() => {
|
||||
groups[key].items.push(item);
|
||||
});
|
||||
|
||||
return Object.values(groups);
|
||||
result.push(...Object.values(groups));
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const groupedResults = computed<GroupedResults[]>(() => {
|
||||
@ -164,6 +196,9 @@ const groupedResults = computed<GroupedResults[]>(() => {
|
||||
<TableHead class="text-right"
|
||||
>Pemakaian</TableHead
|
||||
>
|
||||
<TableHead class="text-right"
|
||||
>Hasil</TableHead
|
||||
>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@ -175,17 +210,35 @@ const groupedResults = computed<GroupedResults[]>(() => {
|
||||
class="bg-muted/20 hover:bg-muted/20"
|
||||
>
|
||||
<TableCell
|
||||
colspan="3"
|
||||
colspan="4"
|
||||
class="font-semibold"
|
||||
>
|
||||
{{ group.name }}
|
||||
<Badge
|
||||
v-if="group.unitLabel"
|
||||
variant="secondary"
|
||||
class="ml-2 font-normal"
|
||||
>
|
||||
{{ group.unitLabel }}
|
||||
</Badge>
|
||||
<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 }}
|
||||
<Badge
|
||||
v-if="group.unitLabel"
|
||||
variant="secondary"
|
||||
class="ml-2 font-normal"
|
||||
>
|
||||
{{ group.unitLabel }}
|
||||
</Badge>
|
||||
</template>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow
|
||||
@ -208,6 +261,19 @@ const groupedResults = computed<GroupedResults[]>(() => {
|
||||
material.material_usage_formatted
|
||||
}}
|
||||
</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>
|
||||
</template>
|
||||
</TableBody>
|
||||
|
||||
@ -0,0 +1,278 @@
|
||||
<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>
|
||||
@ -99,6 +99,8 @@ const {
|
||||
decreaseResultQty,
|
||||
syncMaterialField,
|
||||
syncResultField,
|
||||
removeCombination,
|
||||
syncCombinationResult,
|
||||
} = useCuttingPosCart({
|
||||
rawMaterialCatalog: rawMaterialCatalogState,
|
||||
productCatalog: productCatalogState,
|
||||
@ -142,6 +144,14 @@ function buildFormData(): FormData {
|
||||
appendRootPhotosToFormData(formData, imageState.value);
|
||||
|
||||
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) => {
|
||||
formData.append(
|
||||
`materials[${index}][raw_material_price_id]`,
|
||||
@ -151,6 +161,30 @@ function buildFormData(): FormData {
|
||||
`materials[${index}][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) => {
|
||||
formData.append(
|
||||
@ -217,6 +251,14 @@ 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) {
|
||||
// Add to catalog
|
||||
productCatalogState.value.push(product);
|
||||
@ -234,9 +276,10 @@ function onProductCreated(product: CuttingProductCatalogItem) {
|
||||
<div class="grid gap-4 xl:grid-cols-[1fr_400px]">
|
||||
<div class="space-y-4">
|
||||
<CuttingPosMaterialCatalogPanel v-model:material-search="materialSearch"
|
||||
:filtered-raw-materials="filteredRawMaterials" :get-material-cart-item="getMaterialCartItem"
|
||||
:units="units" @add-material="addMaterial" @decrease-material-qty="decreaseMaterialQty"
|
||||
@raw-material-created="onRawMaterialCreated" />
|
||||
:filtered-raw-materials="filteredRawMaterials" :raw-material-catalog="rawMaterialCatalogState"
|
||||
:material-cart="materialCart" :get-material-cart-item="getMaterialCartItem" :units="units"
|
||||
@add-material="addMaterial" @decrease-material-qty="decreaseMaterialQty"
|
||||
@raw-material-created="onRawMaterialCreated" @combination-created="onCombinationCreated" />
|
||||
|
||||
<CuttingPosResultCatalogPanel v-model:product-search="productSearch" :filtered-products="filteredProducts"
|
||||
:get-result-cart-item="getResultCartItem" @add-result="addResult" :is-create-mode="isCreateMode"
|
||||
@ -248,7 +291,8 @@ function onProductCreated(product: CuttingProductCatalogItem) {
|
||||
@submit="submit" :is-create-mode="isCreateMode" :is-uploading="isUploading"
|
||||
@open-detail="cartDetailOpen = true" @remove-material="removeMaterial"
|
||||
@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>
|
||||
|
||||
<CuttingPosCartDetailDialog v-model:open="cartDetailOpen" :material-cart="materialCart" :result-cart="resultCart"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Layers, Minus, Plus, Search } from '@lucide/vue';
|
||||
import { ref } from 'vue';
|
||||
import { Minus, Plus, Search } from '@lucide/vue';
|
||||
import PosCatalogCard from '@/components/catalog/PosCatalogCard.vue';
|
||||
import PosCatalogVariantThumb from '@/components/catalog/PosCatalogVariantThumb.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@ -16,11 +16,14 @@ import { Input } from '@/components/ui/input';
|
||||
import { getFirstCoverImage } from '@/lib/catalog-cover';
|
||||
import type { CuttingMaterialCartItem, CuttingRawMaterialCatalogItem } from '@/types/cutting';
|
||||
import type { EnumOption } from '@/types/raw-material';
|
||||
import type { CuttingCatalogPrice } from './useCuttingPosCart';
|
||||
import CuttingPosCombinationDialog from './CuttingPosCombinationDialog.vue';
|
||||
import QuickCreateRawMaterialModal from './QuickCreateRawMaterialModal.vue';
|
||||
import type { CuttingCatalogPrice } from './useCuttingPosCart';
|
||||
|
||||
defineProps<{
|
||||
filteredRawMaterials: CuttingRawMaterialCatalogItem[];
|
||||
rawMaterialCatalog: CuttingRawMaterialCatalogItem[];
|
||||
materialCart: CuttingMaterialCartItem[];
|
||||
getMaterialCartItem: (priceId: number) => CuttingMaterialCartItem | undefined;
|
||||
units: EnumOption[];
|
||||
}>();
|
||||
@ -31,9 +34,20 @@ const emit = defineEmits<{
|
||||
'add-material': [rawMaterial: CuttingRawMaterialCatalogItem, price: CuttingCatalogPrice];
|
||||
'decrease-material-qty': [priceId: number];
|
||||
'raw-material-created': [rawMaterial: CuttingRawMaterialCatalogItem];
|
||||
'combination-created': [items: CuttingMaterialCartItem[]];
|
||||
}>();
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
@ -64,12 +78,8 @@ const quickCreateOpen = ref(false);
|
||||
</div>
|
||||
|
||||
<div v-else class="columns-1 gap-4 sm:columns-2">
|
||||
<PosCatalogCard
|
||||
v-for="rawMaterial in filteredRawMaterials"
|
||||
:key="rawMaterial.id"
|
||||
:title="rawMaterial.name"
|
||||
:cover-image="getFirstCoverImage(rawMaterial.prices)"
|
||||
>
|
||||
<PosCatalogCard v-for="rawMaterial in filteredRawMaterials" :key="rawMaterial.id"
|
||||
:title="rawMaterial.name" :cover-image="getFirstCoverImage(rawMaterial.prices)">
|
||||
<template #header-extra>
|
||||
<Badge variant="secondary" class="mt-1.5">
|
||||
{{ rawMaterial.unit_label }}
|
||||
@ -79,18 +89,13 @@ const quickCreateOpen = ref(false);
|
||||
<p v-if="!rawMaterial.prices.length" class="px-3 py-4 text-sm text-muted-foreground">
|
||||
Belum ada varian
|
||||
</p>
|
||||
<div
|
||||
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="[
|
||||
<div 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',
|
||||
getMaterialCartItem(price.id)
|
||||
? '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" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium">
|
||||
@ -101,36 +106,46 @@ const quickCreateOpen = ref(false);
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="getMaterialCartItem(price.id)" class="flex shrink-0 items-center gap-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
@click.stop="emit('decrease-material-qty', price.id)"
|
||||
>
|
||||
<Button type="button" variant="outline" size="icon-sm"
|
||||
@click.stop="emit('decrease-material-qty', price.id)">
|
||||
<Minus class="size-3.5" />
|
||||
</Button>
|
||||
<span class="min-w-5 text-center text-xs font-semibold tabular-nums">
|
||||
{{ getMaterialCartItem(price.id)!.material_usage }}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
@click.stop="emit('add-material', rawMaterial, price)"
|
||||
>
|
||||
<Plus class="size-3.5" />
|
||||
</Button>
|
||||
<div 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" />
|
||||
</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 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" />
|
||||
</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>
|
||||
<Button
|
||||
v-else
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
class="shrink-0"
|
||||
@click.stop="emit('add-material', rawMaterial, price)"
|
||||
>
|
||||
<Plus class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</PosCatalogCard>
|
||||
</div>
|
||||
@ -138,9 +153,10 @@ const quickCreateOpen = ref(false);
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<QuickCreateRawMaterialModal
|
||||
v-model:open="quickCreateOpen"
|
||||
:units="units"
|
||||
@created="emit('raw-material-created', $event)"
|
||||
/>
|
||||
<QuickCreateRawMaterialModal v-model:open="quickCreateOpen" :units="units"
|
||||
@created="emit('raw-material-created', $event)" />
|
||||
|
||||
<CuttingPosCombinationDialog v-model:open="combinationOpen" :raw-material-catalog="rawMaterialCatalog"
|
||||
:existing-cart-items="materialCart" :initial-variant="combinationInitialVariant"
|
||||
@combination-created="emit('combination-created', $event)" />
|
||||
</template>
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { Trash2 } from '@lucide/vue';
|
||||
import { Layers, Trash2 } from '@lucide/vue';
|
||||
import { computed } from '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 {
|
||||
Field,
|
||||
@ -11,7 +14,7 @@ import { formErrors } from '@/lib/form';
|
||||
import type { FormWithErrors } from '@/lib/form';
|
||||
import type { CuttingMaterialCartItem } from '@/types/cutting';
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
form: FormWithErrors;
|
||||
materialCart: CuttingMaterialCartItem[];
|
||||
}>();
|
||||
@ -19,59 +22,189 @@ defineProps<{
|
||||
const emit = defineEmits<{
|
||||
remove: [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>
|
||||
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm font-medium">Bahan Baku</p>
|
||||
|
||||
<div
|
||||
v-if="materialCart.length === 0"
|
||||
class="rounded-lg border border-dashed px-4 py-6 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
<div 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.
|
||||
</div>
|
||||
|
||||
<div v-else class="scrollbar-thin max-h-80 space-y-3 overflow-y-auto overscroll-y-contain">
|
||||
<div
|
||||
v-for="(item, index) in materialCart"
|
||||
: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="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>
|
||||
<template v-for="group in materialGroups" :key="group.combinationId ?? `single-${group.items[0]?.index}`">
|
||||
<div v-if="group.type === 'combination'"
|
||||
class="rounded-lg border-2 border-dashed border-primary/30 p-3">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<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>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-7 shrink-0 text-destructive hover:text-destructive"
|
||||
@click="emit('remove', index)"
|
||||
>
|
||||
<Trash2 class="size-3.5" />
|
||||
</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-8"
|
||||
: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 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="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-7 shrink-0 text-destructive hover:text-destructive"
|
||||
@click="emit('remove', index)">
|
||||
<Trash2 class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<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-8"
|
||||
: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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { Check, Minus, Plus, Search } from '@lucide/vue';
|
||||
import { ref } from 'vue';
|
||||
import PosCatalogCard from '@/components/catalog/PosCatalogCard.vue';
|
||||
import PosCatalogVariantThumb from '@/components/catalog/PosCatalogVariantThumb.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@ -15,8 +15,8 @@ import { Input } from '@/components/ui/input';
|
||||
import { getFirstCoverImage } from '@/lib/catalog-cover';
|
||||
import type { CuttingProductCatalogItem, CuttingResultCartItem } from '@/types/cutting';
|
||||
import type { CategoryOption } from '@/types/product';
|
||||
import type { CuttingCatalogVariant } from './useCuttingPosCart';
|
||||
import QuickCreateProductModal from './QuickCreateProductModal.vue';
|
||||
import type { CuttingCatalogVariant } from './useCuttingPosCart';
|
||||
|
||||
defineProps<{
|
||||
filteredProducts: CuttingProductCatalogItem[];
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Save, Scissors } from '@lucide/vue';
|
||||
import MediaDropzone from '@/components/media/MediaDropzone.vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@ -13,15 +14,15 @@ import {
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { FIELD_LIMITS } from '@/lib/field-limits';
|
||||
import { formErrors, type FormWithErrors } from '@/lib/form';
|
||||
import { formErrors } from '@/lib/form';
|
||||
import type { FormWithErrors } from '@/lib/form';
|
||||
import type {
|
||||
CuttingMaterialCartItem,
|
||||
CuttingResultCartItem,
|
||||
} from '@/types/cutting';
|
||||
import type { MediaUploadState } from '@/types/media';
|
||||
import CuttingPosMaterialSummaryItems from './CuttingPosMaterialSummaryItems.vue';
|
||||
import CuttingPosResultSummaryItems from './CuttingPosResultSummaryItems.vue';
|
||||
import MediaDropzone from '@/components/media/MediaDropzone.vue';
|
||||
import type { MediaUploadState } from '@/types/media';
|
||||
|
||||
defineProps<{
|
||||
form: FormWithErrors & { description: string; processing?: boolean };
|
||||
@ -41,6 +42,8 @@ const emit = defineEmits<{
|
||||
'remove-result': [index: number];
|
||||
'sync-result-totals': [item: CuttingResultCartItem];
|
||||
'sync-result-field': [index: number];
|
||||
'remove-combination': [combinationId: number];
|
||||
'sync-combination-result': [combinationId: number, result: number | null];
|
||||
}>();
|
||||
|
||||
const imageState = defineModel<MediaUploadState>('imageState', {
|
||||
@ -51,27 +54,19 @@ const imageState = defineModel<MediaUploadState>('imageState', {
|
||||
<template>
|
||||
<Card class="h-fit xl:sticky xl:top-4">
|
||||
<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">
|
||||
<Scissors class="size-4" />
|
||||
Ringkasan Cutting
|
||||
</span>
|
||||
<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"
|
||||
@click="emit('open-detail')"
|
||||
>
|
||||
@click="emit('open-detail')">
|
||||
Lihat Detail
|
||||
</button>
|
||||
<Badge
|
||||
v-if="!isCreateMode && totalResultPieces > 0"
|
||||
variant="secondary"
|
||||
class="font-semibold tabular-nums"
|
||||
>
|
||||
<Badge v-if="!isCreateMode && totalResultPieces > 0" variant="secondary"
|
||||
class="font-semibold tabular-nums">
|
||||
Total: {{ totalResultPieces }} pcs
|
||||
</Badge>
|
||||
</span>
|
||||
@ -82,65 +77,39 @@ const imageState = defineModel<MediaUploadState>('imageState', {
|
||||
<FieldGroup>
|
||||
<FieldSet class="grid gap-4">
|
||||
<Field>
|
||||
<FieldLabel for="description"
|
||||
>Keterangan</FieldLabel
|
||||
>
|
||||
<Textarea
|
||||
id="description"
|
||||
v-model="form.description"
|
||||
placeholder="Masukkan keterangan"
|
||||
rows="2"
|
||||
:maxlength="FIELD_LIMITS.description"
|
||||
/>
|
||||
<FieldError
|
||||
:errors="formErrors(form, 'description')"
|
||||
/>
|
||||
<FieldLabel for="description">Keterangan</FieldLabel>
|
||||
<Textarea id="description" v-model="form.description" placeholder="Masukkan keterangan"
|
||||
rows="2" :maxlength="FIELD_LIMITS.description" />
|
||||
<FieldError :errors="formErrors(form, 'description')" />
|
||||
</Field>
|
||||
|
||||
<MediaDropzone
|
||||
id="cutting-images"
|
||||
v-model="imageState"
|
||||
label="Foto Cutting"
|
||||
:max-files="10"
|
||||
:errors="formErrors(form, 'images')"
|
||||
/>
|
||||
<MediaDropzone id="cutting-images" v-model="imageState" label="Foto Cutting" :max-files="10"
|
||||
:errors="formErrors(form, 'images')" />
|
||||
|
||||
<CuttingPosMaterialSummaryItems
|
||||
:form="form"
|
||||
:material-cart="materialCart"
|
||||
@remove="emit('remove-material', $event)"
|
||||
@sync-field="emit('sync-material-field', $event)"
|
||||
/>
|
||||
<CuttingPosMaterialSummaryItems :form="form" :material-cart="materialCart"
|
||||
@remove="emit('remove-material', $event)" @sync-field="emit('sync-material-field', $event)"
|
||||
@remove-combination="emit('remove-combination', $event)"
|
||||
@sync-combination-result="(combinationId, result) => emit('sync-combination-result', combinationId, result)" />
|
||||
|
||||
<Separator />
|
||||
|
||||
<CuttingPosResultSummaryItems
|
||||
:form="form"
|
||||
:result-cart="resultCart"
|
||||
: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)"
|
||||
/>
|
||||
<CuttingPosResultSummaryItems :form="form" :result-cart="resultCart"
|
||||
: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 ||
|
||||
isUploading ||
|
||||
materialCart.length === 0 ||
|
||||
resultCart.length === 0
|
||||
"
|
||||
>
|
||||
<Button type="submit" class="w-full" :disabled="form.processing ||
|
||||
isUploading ||
|
||||
materialCart.length === 0 ||
|
||||
resultCart.length === 0
|
||||
">
|
||||
<Save class="size-4" />
|
||||
{{
|
||||
isUploading
|
||||
? 'Mengunggah...'
|
||||
: form.processing
|
||||
? 'Menyimpan...'
|
||||
: submitLabel
|
||||
? 'Menyimpan...'
|
||||
: submitLabel
|
||||
}}
|
||||
</Button>
|
||||
</FieldSet>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user