Compare commits

..

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

171 changed files with 1995 additions and 5949 deletions

View File

@ -3,18 +3,14 @@
namespace App\Console\Commands;
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
{
@ -40,16 +36,11 @@ 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', fn ($query) => $query->active())
->whereHas('user', function ($query) {
$query->active();
})
->get();
$lateCount = 0;
@ -57,118 +48,106 @@ public function handle(): int
$skippedLeave = 0;
$skippedDuplicate = 0;
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();
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;
}
$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++;
if ($attendance === null) {
if ($absentPenalty <= 0) {
continue;
}
});
} catch (\Throwable $e) {
Log::error("Gagal menerapkan penalti presensi: {$e->getMessage()}", [
'trace' => $e->getTraceAsString(),
$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,
]);
$this->error('Gagal menerapkan penalti presensi: '.$e->getMessage());
$payroll->load('adjustments');
$payroll->recalculateAmounts();
$payroll->save();
return self::FAILURE;
$lateCount++;
}
$this->info("Selesai memproses tanggal {$date->format('d/m/Y')}:");
@ -182,7 +161,9 @@ 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;
@ -193,12 +174,4 @@ 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();
}
}

View File

@ -135,16 +135,8 @@ enum Permission: string
case PAYROLL_VIEW = 'payroll.view';
case PAYROLL_ADJUST = 'payroll.adjust';
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 SETTINGS_VIEW = 'settings.view';
case SETTINGS_UPDATE = 'settings.update';
case ACTIVITY_LOGS_VIEW = 'activity_logs.view';
@ -283,16 +275,8 @@ public function label(): string
self::PAYROLL_VIEW => 'Lihat Gaji',
self::PAYROLL_ADJUST => 'Sesuaikan Gaji',
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::SETTINGS_VIEW => 'Lihat Pengaturan Aplikasi',
self::SETTINGS_UPDATE => 'Ubah Pengaturan Aplikasi',
self::ACTIVITY_LOGS_VIEW => 'Lihat Log Aktivitas',
@ -351,11 +335,7 @@ public function group(): string
self::EMPLOYEE_ADVANCES_DELETE, self::EMPLOYEE_ADVANCES_VERIFY,
self::EMPLOYEE_ADVANCES_PAY => 'Kasbon',
self::PAYROLL_VIEW, self::PAYROLL_ADJUST => 'Gaji',
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::SETTINGS_VIEW, self::SETTINGS_UPDATE => 'Pengaturan Aplikasi',
self::ACTIVITY_LOGS_VIEW => 'Log Aktivitas',
self::ROLES_VIEW, self::ROLES_CREATE, self::ROLES_UPDATE,
self::ROLES_DELETE => 'Role & Permission',

View File

@ -193,16 +193,8 @@ public function permissions(): array
Permission::PAYROLL_VIEW,
Permission::PAYROLL_ADJUST,
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,
Permission::SETTINGS_VIEW,
Permission::SETTINGS_UPDATE,
],
self::CASHIER => [

View File

@ -5,14 +5,13 @@
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;
@ -39,14 +38,8 @@ public function index(Request $request): Response
]);
}
public function update(CashTransactionRequest $request, CashTransaction $cashTransaction): RedirectResponse
public function update(UpdateCashTransactionRequest $request, CashTransaction $cashTransaction): RedirectResponse
{
try {
$cashTransaction->ensureEditable();
} catch (ValidationException) {
abort(403);
}
$this->cashService->updateDeposit($cashTransaction, $request->validated());
$this->flashUpdated('Setor kas');
@ -56,12 +49,6 @@ public function update(CashTransactionRequest $request, CashTransaction $cashTra
public function destroy(CashTransaction $cashTransaction): RedirectResponse
{
try {
$cashTransaction->ensureEditable();
} catch (ValidationException) {
abort(403);
}
$this->cashService->deleteTransaction($cashTransaction);
$this->flashDeleted('Setor kas');

View File

@ -5,7 +5,6 @@
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;
@ -14,7 +13,6 @@
use App\Services\Finance\EmployeeAdvanceService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
@ -51,12 +49,6 @@ 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');
@ -66,12 +58,6 @@ 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');
@ -79,7 +65,7 @@ public function destroy(DestroyEmployeeAdvanceRequest $request, EmployeeAdvance
return redirect()->route('admin.finance.employee_advances.index');
}
public function approve(ApproveEmployeeAdvanceRequest $request, EmployeeAdvance $employeeAdvance): RedirectResponse
public function approve(Request $request, EmployeeAdvance $employeeAdvance): RedirectResponse
{
$this->employeeAdvanceService->approve($employeeAdvance, $request->user());

View File

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

View File

@ -12,7 +12,6 @@
use App\Services\Hr\LeaveRequestService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
@ -46,12 +45,6 @@ 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');
@ -61,12 +54,6 @@ 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');

View File

@ -3,7 +3,6 @@
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;
@ -48,20 +47,6 @@ 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());

View File

@ -8,6 +8,7 @@
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;
@ -57,4 +58,14 @@ 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,
]);
}
}

View File

@ -17,7 +17,6 @@
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;
@ -34,10 +33,8 @@ public function __construct(
private readonly OwnerVerificationService $ownerVerificationService,
) {}
public function index(Request $request): Response
public function index(): Response
{
$user = $request->user();
return Inertia::render('admin/system/setting/Index', [
'system' => $this->systemService->systemData(),
'socialMedia' => $this->socialMediaService->socialMediaData(),
@ -45,16 +42,15 @@ public function index(Request $request): 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.');
@ -64,6 +60,10 @@ 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,6 +86,10 @@ 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.');
@ -95,6 +99,10 @@ 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.');

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -39,12 +39,10 @@ public function rules(): array
$rules['materials.*.raw_material_price_id'] = [
'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'] = [
@ -75,8 +73,6 @@ 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',

View File

@ -9,7 +9,7 @@ class HomepageSettingRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can(Permission::SETTINGS_UPDATE_HOMEPAGE->value) ?? false;
return $this->user()?->can(Permission::SETTINGS_UPDATE->value) ?? false;
}
/**
@ -24,8 +24,6 @@ public function rules(): array
'about_image_s3_key' => ['nullable', 'string'],
'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'],
];

View File

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

View File

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

View File

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

View File

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

View File

@ -10,8 +10,6 @@ trait ValidatesMediaUploads
protected function photoRules(string $prefix = 'photos', int $max = 1): array
{
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'],
@ -35,11 +33,9 @@ protected function variantImageRules(string $variantsKey = 'variants', int $max
/**
* @return array<string, string>
*/
protected function photoUploadAttributes(string $label, string $prefix = 'photos'): array
protected function photoUploadAttributes(string $label): array
{
return [
$prefix => $label,
"{$prefix}.*" => $label,
's3_keys' => $label,
's3_keys.*' => $label,
'remove_media_ids' => 'media yang dihapus',

View File

@ -5,6 +5,7 @@
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;
@ -48,7 +49,7 @@ protected function casts(): array
public function attendanceDateFormatted(): Attribute
{
return Attribute::make(
get: fn () => $this->attendance_date?->translatedFormat('l, d F Y'),
get: fn () => Carbon::parse($this->attendance_date)->translatedFormat('l, d F Y'),
);
}

View File

@ -16,7 +16,6 @@
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Validation\ValidationException;
use Spatie\MediaLibrary\HasMedia;
#[Guarded(['id'])]
@ -128,15 +127,6 @@ 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';

View File

@ -113,11 +113,6 @@ 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);

View File

@ -27,7 +27,6 @@ protected function casts(): array
{
return [
'material_usage' => 'decimal:4',
'material_result' => 'integer',
];
}
@ -89,11 +88,6 @@ 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);

View File

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

View File

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

View File

@ -3,7 +3,6 @@
namespace App\Models;
use App\Enums\EmployeeAdvanceStatus;
use App\Enums\Permission;
use App\Models\Concerns\HasRejection;
use App\Models\Concerns\InteractsWithActivityLog;
use Illuminate\Database\Eloquent\Attributes\Appends;
@ -15,7 +14,6 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Validation\ValidationException;
#[Guarded(['id'])]
#[Appends([
@ -176,24 +174,7 @@ public function statusLabel(): Attribute
);
}
// 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
// 5. Relation
public function cashTransaction(): BelongsTo
{
return $this->belongsTo(CashTransaction::class);

View File

@ -3,7 +3,6 @@
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;
@ -14,7 +13,6 @@
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Validation\ValidationException;
#[Guarded(['id'])]
#[Appends([
@ -138,24 +136,7 @@ public function statusLabel(): Attribute
);
}
// 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
// 5. Relation
public function employee(): BelongsTo
{
return $this->belongsTo(Employee::class);

View File

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

View File

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

View File

@ -3,30 +3,27 @@
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
{
$this->runInTransaction(
function () use ($user, $validated): void {
try {
DB::transaction(function () use ($user, $validated): void {
$user->update([
'email' => $validated['email'],
'username' => $validated['username'],
]);
/** @var UserProfile $profile */
$profile = $user->profile()->updateOrCreate(
['user_id' => $user->id],
[
@ -38,20 +35,29 @@ function () use ($user, $validated): void {
],
);
$this->syncPhotos(
$s3Keys = ! empty($validated['profile_s3_key']) ? [$validated['profile_s3_key']] : null;
$removeIds = $validated['remove_profile_photo_ids'] ?? null;
$this->mediaService->syncCollection(
$profile,
[
'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',
'profile_photo',
null,
$removeIds,
1,
s3Keys: $s3Keys,
);
},
'Gagal memperbarui profil',
);
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal memperbarui profil: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
}
public function updatePassword(User $user, string $password): void

View File

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

View File

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

View File

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

View File

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

View File

@ -4,18 +4,17 @@
use App\Models\Expense;
use App\Models\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(
@ -52,8 +51,8 @@ public function paginateForIndex(array $tableQuery): LengthAwarePaginator
public function create(array $validated, User $user): void
{
$expense = $this->runInTransaction(
function () use ($validated, $user): Expense {
try {
$expense = DB::transaction(function () use ($validated, $user): Expense {
$amount = (int) $validated['amount'];
$description = $validated['description'];
@ -74,12 +73,21 @@ function () use ($validated, $user): Expense {
'cash_transaction_id' => $cashTransaction->id,
]);
$this->syncPhotos($expense, $validated, self::MAX_PHOTOS);
$this->syncPhotos($expense, $validated);
return $expense;
},
'Gagal membuat pengeluaran',
);
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal membuat pengeluaran: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$this->pushNotificationService->sendToRoles(
'💸 Pengeluaran Baru',
@ -91,8 +99,8 @@ function () use ($validated, $user): Expense {
public function update(Expense $expense, array $validated): void
{
$this->runInTransaction(
function () use ($expense, $validated): void {
try {
DB::transaction(function () use ($expense, $validated): void {
$amount = (int) $validated['amount'];
$description = $validated['description'];
@ -109,10 +117,19 @@ function () use ($expense, $validated): void {
);
}
$this->syncPhotos($expense, $validated, self::MAX_PHOTOS);
},
'Gagal memperbarui pengeluaran',
);
$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->pushNotificationService->sendToRoles(
'✏️ Pengeluaran Diperbarui',
@ -124,29 +141,49 @@ function () use ($expense, $validated): void {
public function delete(Expense $expense): void
{
$amountFormatted = $expense->amount_formatted;
$description = $expense->description;
$this->runInTransaction(
function () use ($expense): void {
try {
DB::transaction(function () use ($expense): void {
if ($expense->cashTransaction) {
$this->cashService->deleteReferencedTransaction($expense->cashTransaction);
}
$expense->clearMediaCollection('photos');
$expense->delete();
},
'Gagal menghapus pengeluaran',
);
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal menghapus pengeluaran: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$this->pushNotificationService->sendToRoles(
'🗑️ Pengeluaran Dihapus',
"Pengeluaran sebesar {$amountFormatted} dengan keterangan {$description} telah dihapus.",
"Pengeluaran sebesar {$expense->amount_formatted} dengan keterangan {$expense->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)) {

View File

@ -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();
$period = $this->runInTransaction(
function () use ($user): PayrollPeriod {
try {
$period = DB::transaction(function () use ($user): PayrollPeriod {
$now = now();
$year = $now->year;
$month = $now->month;
@ -157,9 +157,18 @@ function () use ($user): PayrollPeriod {
$this->generatePayrollsForPeriod($period);
return $period->fresh();
},
'Gagal membuka periode payroll',
);
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal membuka periode payroll: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
return $period;
}
@ -196,8 +205,9 @@ public function generatePayrollsForPeriod(PayrollPeriod $period): void
public function addAdjustment(Payroll $payroll, array $validated, User $user): void
{
$this->runInTransaction(
function () use ($payroll, $validated, $user): void {
try {
DB::transaction(function () use ($payroll, $validated, $user): void {
$payroll->adjustments()->create([
'type' => PayrollAdjustmentType::from($validated['type']),
'amount' => (int) $validated['amount'],
@ -208,9 +218,18 @@ function () use ($payroll, $validated, $user): void {
$payroll->load('adjustments');
$payroll->recalculateAmounts();
$payroll->save();
},
'Gagal menambahkan penyesuaian gaji',
);
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal menambahkan penyesuaian gaji: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
if ($payroll->employee?->user_id) {
$typeLabel = PayrollAdjustmentType::from($validated['type'])->label();
@ -229,8 +248,8 @@ public function updateAdjustment(PayrollAdjustment $adjustment, array $validated
$payroll = $adjustment->payroll;
$payroll->loadMissing(['payrollPeriod', 'employee.user']);
$this->runInTransaction(
function () use ($payroll, $adjustment, $validated): void {
try {
DB::transaction(function () use ($payroll, $adjustment, $validated): void {
$adjustment->type = PayrollAdjustmentType::from($validated['type']);
$adjustment->amount = (int) $validated['amount'];
$adjustment->description = $validated['description'];
@ -239,9 +258,18 @@ function () use ($payroll, $adjustment, $validated): void {
$payroll->load('adjustments');
$payroll->recalculateAmounts();
$payroll->save();
},
'Gagal memperbarui penyesuaian gaji',
);
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal memperbarui penyesuaian gaji: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
if ($payroll->employee?->user_id) {
$typeLabel = PayrollAdjustmentType::from($validated['type'])->label();
@ -260,16 +288,25 @@ public function deleteAdjustment(PayrollAdjustment $adjustment): void
$payroll = $adjustment->payroll;
$payroll->loadMissing(['payrollPeriod', 'employee.user']);
$this->runInTransaction(
function () use ($payroll, $adjustment): void {
try {
DB::transaction(function () use ($payroll, $adjustment): void {
$adjustment->delete();
$payroll->load('adjustments');
$payroll->recalculateAmounts();
$payroll->save();
},
'Gagal menghapus penyesuaian gaji',
);
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal menghapus penyesuaian gaji: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
if ($payroll->employee?->user_id) {
$this->pushNotificationService->sendToUser(
@ -286,23 +323,32 @@ public function pay(Payroll $payroll, User $user): void
$payroll->loadMissing(['payrollPeriod', 'employee.user.profile']);
if ($payroll->total_amount <= 0) {
$this->runInTransaction(
function () use ($payroll, $user): void {
try {
DB::transaction(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);
},
'Gagal membayar gaji (total 0)',
);
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal membayar gaji (total 0): '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
return;
}
$this->runInTransaction(
function () use ($payroll, $user): void {
try {
DB::transaction(function () use ($payroll, $user): void {
$description = sprintf(
'Pembayaran gaji: %s (%s)',
$payroll->employeeName,
@ -323,9 +369,18 @@ function () use ($payroll, $user): void {
$payroll->save();
$this->settleKasbonFromPayroll($payroll, $user);
},
'Gagal membayar gaji',
);
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal membayar gaji: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
if ($payroll->employee?->user_id) {
$this->pushNotificationService->sendToUser(

View File

@ -9,7 +9,6 @@
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;
@ -19,7 +18,7 @@
class AttendanceService
{
use ResolvesAuthenticatedEmployee, RunsInTransaction;
use ResolvesAuthenticatedEmployee;
public function __construct(
private readonly MediaService $mediaService,
@ -72,35 +71,30 @@ public function checkIn(array $validated, User $user): void
{
$employee = $this->resolveAuthenticatedEmployee($user);
$this->runInTransaction(
function () use ($employee, $validated): void {
$existing = Attendance::query()
->where('employee_id', $employee->id)
->whereDate('attendance_date', today())
->exists();
$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',
);
},
'Gagal mencatat presensi masuk',
$this->mediaService->addBase64Image(
$attendance,
$validated['photo'],
'checkin',
'checkin',
);
$this->pushNotificationService->sendToRoles(
@ -115,45 +109,38 @@ public function checkOut(array $validated, User $user): void
{
$employee = $this->resolveAuthenticatedEmployee($user);
$workDurationMinutes = $this->runInTransaction(
function () use ($employee, $validated): int {
$attendance = Attendance::query()
->where('employee_id', $employee->id)
->whereDate('attendance_date', today())
->first();
$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',
);
return $workDurationMinutes;
},
'Gagal mencatat presensi pulang',
$this->mediaService->addBase64Image(
$attendance,
$validated['photo'],
'checkout',
'checkout',
);
$this->pushNotificationService->sendToRoles(
@ -170,18 +157,13 @@ public function delete(Attendance $attendance): void
$employeeName = $attendance->employee?->user?->profile?->full_name;
$date = $attendance->attendance_date;
$this->runInTransaction(
function () use ($attendance): void {
$attendance->clearMediaCollection('checkin');
$attendance->clearMediaCollection('checkout');
$attendance->delete();
},
'Gagal menghapus presensi',
);
$attendance->clearMediaCollection('checkin');
$attendance->clearMediaCollection('checkout');
$attendance->delete();
$this->pushNotificationService->sendToRoles(
'🗑️ Presensi Dihapus',
"Data presensi {$employeeName} tanggal {$date?->toDateString()} telah dihapus.",
"Data presensi {$employeeName} tanggal {$date} telah dihapus.",
['owner', 'developer', 'direktur'],
route('admin.hr.attendances.index'),
);

View File

@ -6,19 +6,17 @@
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,
) {}
@ -77,8 +75,8 @@ public function findForEdit(User $user): array
public function create(array $validated): void
{
$this->runInTransaction(
function () use ($validated): void {
try {
DB::transaction(function () use ($validated): void {
$user = User::create([
'email' => $validated['email'],
'username' => $validated['username'],
@ -106,17 +104,26 @@ function () use ($validated): void {
}
$user->syncRoles([$validated['role']]);
},
'Gagal membuat karyawan',
);
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal membuat karyawan: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
}
public function update(User $user, array $validated): void
{
$employee = $user->employee;
$this->runInTransaction(
function () use ($validated, $user, $employee): void {
try {
DB::transaction(function () use ($validated, $user, $employee): void {
$user->update([
'email' => $validated['email'],
'username' => $validated['username'],
@ -159,65 +166,73 @@ function () use ($validated, $user, $employee): void {
}
$user->syncRoles([$validated['role']]);
},
'Gagal memperbarui karyawan',
);
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal memperbarui karyawan: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
}
public function toggleStatus(User $user, array $validated): void
{
$this->runInTransaction(
function () use ($user, $validated): void {
$user->update([
'is_active' => $validated['is_active'],
]);
$user->update([
'is_active' => $validated['is_active'],
]);
if (! $validated['is_active']) {
DB::table('sessions')->where('user_id', $user->id)->delete();
}
},
'Gagal memperbarui status karyawan',
);
if (! $validated['is_active']) {
DB::table('sessions')->where('user_id', $user->id)->delete();
}
}
public function resetPassword(User $user): void
{
$this->runInTransaction(
function () use ($user): void {
$user->update([
'password' => config('auth.password_default'),
]);
$user->update([
'password' => config('auth.password_default'),
]);
DB::table('sessions')->where('user_id', $user->id)->delete();
},
'Gagal mereset kata sandi karyawan',
);
DB::table('sessions')->where('user_id', $user->id)->delete();
}
public function delete(User $user): void
{
$this->runInTransaction(
function () use ($user): void {
try {
DB::transaction(function () use ($user): void {
$user->employee?->delete();
$user->profile?->delete();
$user->delete();
},
'Gagal menghapus karyawan',
);
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal menghapus karyawan: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
}
private function syncProfilePhoto(UserProfile $profile, array $validated): void
{
$this->syncPhotos(
$s3Keys = ! empty($validated['profile_s3_key']) ? [$validated['profile_s3_key']] : null;
$removeIds = $validated['remove_profile_photo_ids'] ?? null;
$this->mediaService->syncCollection(
$profile,
[
'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',
'profile_photo',
null,
$removeIds,
1,
s3Keys: $s3Keys,
);
}

View File

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

View File

@ -7,7 +7,6 @@
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;
@ -15,19 +14,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\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,
@ -42,7 +40,6 @@ 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',
])
@ -86,7 +83,6 @@ 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',
])
@ -111,7 +107,6 @@ 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',
])
@ -200,7 +195,6 @@ 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',
@ -219,7 +213,6 @@ 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',
]);
@ -261,7 +254,6 @@ public function draftMaterialsForUser(User $user): array
->with([
'rawMaterialPrice.rawMaterial:id,name,unit',
'rawMaterialPrice.media',
'combination',
])
->get()
->map(function (CuttingMaterial $item) {
@ -301,10 +293,6 @@ public function syncDraftMaterial(array $validated, User $user): array
]);
}
$materialResult = array_key_exists('material_result', $validated) && $validated['material_result'] !== null
? (int) $validated['material_result']
: null;
$item = CuttingMaterial::query()->updateOrCreate(
[
'user_id' => $user->id,
@ -313,7 +301,6 @@ public function syncDraftMaterial(array $validated, User $user): array
],
[
'material_usage' => $materialUsage,
'material_result' => $materialResult,
],
);
@ -409,80 +396,10 @@ 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
{
$cutting = $this->runInTransaction(
function () use ($validated, $user): Cutting {
try {
$cutting = DB::transaction(function () use ($validated, $user): Cutting {
$draftMaterials = $this->draftMaterialsQuery($user)
->with('rawMaterialPrice.rawMaterial')
@ -515,19 +432,6 @@ function () use ($validated, $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;
@ -544,9 +448,18 @@ function () use ($validated, $user): Cutting {
$this->deductMaterialStock($cutting);
return $cutting;
},
'Gagal membuat proses cutting',
);
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal membuat proses cutting: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$this->pushNotificationService->sendToRoles(
'✂️ Proses Cutting Baru',
@ -566,9 +479,9 @@ public function update(Cutting $cutting, array $validated): void
]);
}
$this->runInTransaction(
function () use ($cutting, $validated): void {
$cutting->load(['materials.rawMaterialPrice.rawMaterial', 'results', 'materials.combination']);
try {
DB::transaction(function () use ($cutting, $validated): void {
$cutting->load(['materials.rawMaterialPrice.rawMaterial', 'results']);
if ($cutting->status === CuttingStatus::IN_PROGRESS) {
$this->reverseTotalMaterialStock($cutting);
@ -578,7 +491,6 @@ function () use ($cutting, $validated): void {
$cutting->materials()->delete();
$cutting->results()->delete();
$cutting->combinations()->delete();
$materials = $this->buildMaterials($validated['materials']);
$results = $this->buildResults($validated['results']);
@ -594,41 +506,8 @@ function () use ($cutting, $validated): void {
$this->syncImages($cutting, $validated);
// Group materials by combination_id to create combinations
$combinationGroups = [];
foreach ($materials as $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,
]);
$cutting->materials()->create($materialData);
}
foreach ($results as $resultData) {
@ -639,9 +518,18 @@ function () use ($cutting, $validated): void {
$cutting->load('materials.rawMaterialPrice.rawMaterial');
$this->deductMaterialStock($cutting);
}
},
'Gagal memperbarui proses cutting',
);
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal memperbarui proses cutting: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$description = $cutting->description ?? '-';
$this->pushNotificationService->sendToRoles(
@ -662,8 +550,8 @@ public function delete(Cutting $cutting): void
$description = $cutting->description ?? '-';
$this->runInTransaction(
function () use ($cutting): void {
try {
DB::transaction(function () use ($cutting): void {
$cutting->load(['materials.rawMaterialPrice.rawMaterial']);
if ($cutting->status === CuttingStatus::IN_PROGRESS) {
@ -676,9 +564,18 @@ function () use ($cutting): void {
$cutting->results()->delete();
$cutting->clearMediaCollection('images');
$cutting->delete();
},
'Gagal menghapus proses cutting',
);
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal menghapus proses cutting: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$this->pushNotificationService->sendToRoles(
'🗑️ Proses Cutting Dihapus',
@ -703,8 +600,8 @@ public function transitionStatus(
]);
}
$this->runInTransaction(
function () use ($cutting, $status, $verificationNote, $results, $resultPrices, $user, $reason): void {
try {
DB::transaction(function () use ($cutting, $status, $verificationNote, $results, $resultPrices, $user, $reason): void {
$cutting->load(['materials.rawMaterialPrice', 'results']);
if ($status === CuttingStatus::COMPLETED) {
@ -745,9 +642,18 @@ function () use ($cutting, $status, $verificationNote, $results, $resultPrices,
$cutting->status = $status;
$cutting->save();
},
'Gagal mengubah status proses cutting',
);
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal mengubah status proses cutting: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$description = $cutting->description ?? '-';
$message = match ($status) {
@ -800,16 +706,9 @@ private function buildMaterials(array $materials): array
]);
}
$materialResult = array_key_exists('material_result', $itemData) && $itemData['material_result'] !== null
? (int) $itemData['material_result']
: null;
return [
'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();
@ -959,12 +858,6 @@ 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;
@ -986,7 +879,6 @@ private function breakMaterialCircularReference(CuttingMaterial $material): void
}
$material->unsetRelation('rawMaterialPrice');
$material->unsetRelation('combination');
}
private function applySorting(Builder $query, string $sort, string $direction): void
@ -1018,7 +910,6 @@ 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,
@ -1028,10 +919,7 @@ 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,
];
}

View File

@ -13,7 +13,6 @@
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;
@ -23,12 +22,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(
@ -389,16 +388,16 @@ public function resyncDraftPrices(User $user, string $priceTypeValue): array
public function create(array $validated, User $user): Order
{
// Force cashier settings
if ($user->hasRole('cashier')) {
$validated['channel'] = 'store';
$validated['price_type'] = 'retail';
$validated['payment_type'] = 'cash';
unset($validated['customer_id']);
}
try {
// Force cashier settings
if ($user->hasRole('cashier')) {
$validated['channel'] = 'store';
$validated['price_type'] = 'retail';
$validated['payment_type'] = 'cash';
unset($validated['customer_id']);
}
$order = $this->runInTransaction(
function () use ($validated, $user): Order {
$order = DB::transaction(function () use ($validated, $user): Order {
$priceType = $this->resolvePriceType($validated['channel'], $validated['price_type']);
$draftItems = $this->draftItemsQuery($user)
@ -483,9 +482,18 @@ function () use ($validated, $user): Order {
}
return $order;
},
'Gagal membuat pesanan',
);
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal membuat pesanan: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$this->pushNotificationService->sendToRoles(
'📦 Pesanan Baru',
@ -501,8 +509,8 @@ public function update(Order $order, array $validated): void
{
$order->ensureEditable();
$this->runInTransaction(
function () use ($order, $validated): void {
try {
DB::transaction(function () use ($order, $validated): void {
$order->load('items');
foreach ($order->items as $item) {
@ -572,9 +580,18 @@ function () use ($order, $validated): void {
$orderItem = $order->items()->create($itemData);
$this->decrementStock($orderItem);
}
},
'Gagal memperbarui pesanan',
);
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal memperbarui pesanan: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$this->pushNotificationService->sendToRoles(
'✏️ Pesanan Diperbarui',
@ -589,8 +606,8 @@ public function delete(Order $order): void
$orderNumber = $order->order_number;
$totalAmount = $order->total_amount;
$this->runInTransaction(
function () use ($order): void {
try {
DB::transaction(function () use ($order): void {
$order->load('items');
if ($order->status->isEditable()) {
@ -605,9 +622,18 @@ function () use ($order): void {
$order->items()->delete();
$order->delete();
},
'Gagal menghapus pesanan',
);
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal menghapus pesanan: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$this->pushNotificationService->sendToRoles(
'🗑️ Pesanan Dihapus',
@ -625,8 +651,8 @@ public function transitionStatus(Order $order, OrderStatus $status): void
]);
}
$this->runInTransaction(
function () use ($order, $status): void {
try {
DB::transaction(function () use ($order, $status): void {
if ($status === OrderStatus::CANCELLED) {
$order->load('items');
@ -642,9 +668,18 @@ function () use ($order, $status): void {
$order->status = $status;
$order->save();
},
'Gagal mengubah status pesanan',
);
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal mengubah status pesanan: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
$this->pushNotificationService->sendToRoles(
'📦 Status Pesanan Diubah',

View File

@ -12,19 +12,18 @@
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(
@ -253,8 +252,8 @@ public function create(array $validated, User $user): Purchase
{
$isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value);
$purchase = $this->runInTransaction(
function () use ($validated, $user, $isOwner): Purchase {
try {
$purchase = DB::transaction(function () use ($validated, $user, $isOwner): Purchase {
$draftItems = $this->draftItemsQuery($user)
->lockForUpdate()
@ -310,9 +309,18 @@ function () use ($validated, $user, $isOwner): Purchase {
}
return $purchase;
},
'Gagal membuat pembelian',
);
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal membuat pembelian: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
if (! $isOwner) {
$this->notifyForPendingRequest(
@ -332,8 +340,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']);
$this->runInTransaction(
function () use ($purchase, $validated, $user, $isOwner): void {
try {
DB::transaction(function () use ($purchase, $validated, $user, $isOwner): void {
if ($isOwner) {
$payload = $this->buildPayloadFromValidated($validated);
$this->applyPayloadToPurchase($purchase, $payload);
@ -358,9 +366,18 @@ function () use ($purchase, $validated, $user, $isOwner): void {
$this->syncRequestPhotos($verificationRequest, $validated);
}
}
},
'Gagal memperbarui pembelian',
);
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal memperbarui pembelian: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
if (! $isOwner) {
$purchase->load('supplier');
@ -387,22 +404,27 @@ public function delete(Purchase $purchase, User $user): void
$purchase->load(['supplier', 'items.rawMaterialPrice.rawMaterial:id,name']);
$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',
);
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.',
]);
}
$purchase->load('supplier');
@ -593,32 +615,26 @@ private function rejectCreate(OwnerVerificationRequest $verificationRequest): vo
return;
}
$this->runInTransaction(
function () use ($purchase): void {
$purchase->clearMediaCollection('photos');
$purchase->items()->delete();
$purchase->delete();
},
'Gagal menolak belanja',
);
DB::transaction(function () use ($purchase): void {
$purchase->clearMediaCollection('photos');
$purchase->items()->delete();
$purchase->delete();
});
}
private function executeDelete(Purchase $purchase): void
{
$this->runInTransaction(
function () use ($purchase): void {
$purchase->load('items');
DB::transaction(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();
},
'Gagal menghapus belanja',
);
$purchase->clearMediaCollection('photos');
$purchase->items()->delete();
$purchase->delete();
});
}
private function applyPayloadToPurchase(
@ -626,41 +642,38 @@ private function applyPayloadToPurchase(
array $payload,
?OwnerVerificationRequest $verificationRequest = null,
): void {
$this->runInTransaction(
function () use ($purchase, $payload, $verificationRequest): void {
$purchase->load('items');
DB::transaction(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'],
]);
$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,
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);
}
if ($verificationRequest !== null) {
$this->applyRequestPhotos($verificationRequest, $purchase, $payload);
}
},
'Gagal memperbarui belanja',
);
$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);
}
});
}
private function snapshotPurchase(Purchase $purchase): array

View File

@ -10,18 +10,17 @@
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(
@ -120,8 +119,8 @@ public function create(array $validated, User $user): void
{
$isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value);
$this->runInTransaction(
function () use ($validated, $user, $isOwner): void {
try {
DB::transaction(function () use ($validated, $user, $isOwner): void {
$product = Product::create([
'name' => $validated['name'],
'description' => $validated['description'] ?? null,
@ -162,9 +161,18 @@ function () use ($validated, $user, $isOwner): void {
],
]);
}
},
'Gagal membuat produk',
);
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal membuat produk: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
if (! $isOwner) {
$this->notifyForPendingRequest(
@ -181,8 +189,8 @@ public function update(Product $product, array $validated, User $user): void
{
$isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value);
$this->runInTransaction(
function () use ($validated, $product, $user, $isOwner): void {
try {
DB::transaction(function () use ($validated, $product, $user, $isOwner): void {
if ($isOwner) {
$payload = $this->enrichPayload($this->buildPayloadFromValidated($validated));
$this->applyPayloadToProduct($product, $payload);
@ -236,9 +244,18 @@ function () use ($validated, $product, $user, $isOwner): void {
$this->syncRequestVariantImages($verificationRequest, $variantData, $index, required: $isNewVariant);
}
}
},
'Gagal memperbarui produk',
);
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal memperbarui produk: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
if (! $isOwner) {
$this->notifyForPendingRequest(
@ -261,22 +278,27 @@ public function delete(Product $product, User $user): void
return;
}
$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',
);
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->notifyForPendingRequest(
$user,
@ -299,28 +321,33 @@ public function toggleStatus(Product $product, array $validated, User $user): vo
return;
}
$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'],
],
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,
],
]);
},
'Gagal mengajukan perubahan status produk',
);
'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.',
]);
}
$statusLabel = $validated['is_active'] ? 'aktif' : 'nonaktif';
@ -490,17 +517,14 @@ public function applyDelete(OwnerVerificationRequest $verificationRequest): void
private function applyDeleteSubject(Product $product): void
{
$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',
);
DB::transaction(function () use ($product): void {
$product->variants()->each(function (ProductVariant $variant): void {
$variant->clearMediaCollection('images');
});
$product->variants()->delete();
$product->categories()->detach();
$product->delete();
});
}
public function applyToggleStatus(OwnerVerificationRequest $verificationRequest): void
@ -550,17 +574,14 @@ private function rejectCreate(OwnerVerificationRequest $verificationRequest): vo
return;
}
$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',
);
DB::transaction(function () use ($product): void {
$product->variants()->each(function (ProductVariant $variant): void {
$variant->clearMediaCollection('images');
});
$product->variants()->delete();
$product->categories()->detach();
$product->delete();
});
}
private function rollbackUpdate(OwnerVerificationRequest $verificationRequest): void

View File

@ -10,18 +10,17 @@
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(
@ -115,8 +114,8 @@ public function create(array $validated, User $user): void
{
$isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value);
$this->runInTransaction(
function () use ($validated, $user, $isOwner): void {
try {
DB::transaction(function () use ($validated, $user, $isOwner): void {
$rawMaterial = RawMaterial::create([
'name' => $validated['name'],
'unit' => $validated['unit'],
@ -140,9 +139,18 @@ function () use ($validated, $user, $isOwner): void {
],
]);
}
},
'Gagal membuat bahan baku',
);
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal membuat bahan baku: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
if (! $isOwner) {
$this->notifyForPendingRequest(
@ -159,8 +167,8 @@ public function update(RawMaterial $rawMaterial, array $validated, User $user):
{
$isOwner = $user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value);
$this->runInTransaction(
function () use ($validated, $rawMaterial, $user, $isOwner): void {
try {
DB::transaction(function () use ($validated, $rawMaterial, $user, $isOwner): void {
if ($isOwner) {
$payload = $this->enrichPayload($this->buildPayloadFromValidated($validated));
$this->applyPayloadToRawMaterial($rawMaterial, $payload);
@ -193,9 +201,18 @@ function () use ($validated, $rawMaterial, $user, $isOwner): void {
$this->syncRequestPriceImages($verificationRequest, $priceData, $index, required: $isNewPrice);
}
}
},
'Gagal memperbarui bahan baku',
);
});
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
Log::error('Gagal memperbarui bahan baku: '.$e->getMessage(), [
'trace' => $e->getTraceAsString(),
]);
throw ValidationException::withMessages([
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
]);
}
if (! $isOwner) {
$this->notifyForPendingRequest(
@ -218,22 +235,27 @@ public function delete(RawMaterial $rawMaterial, User $user): void
return;
}
$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',
);
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->notifyForPendingRequest(
$user,
@ -256,28 +278,33 @@ public function toggleStatus(RawMaterial $rawMaterial, array $validated, User $u
return;
}
$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'],
],
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,
],
]);
},
'Gagal mengajukan perubahan status bahan baku',
);
'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.',
]);
}
$statusLabel = $validated['is_active'] ? 'aktif' : 'nonaktif';
@ -391,16 +418,13 @@ public function applyDelete(OwnerVerificationRequest $verificationRequest): void
private function applyDeleteSubject(RawMaterial $rawMaterial): void
{
$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',
);
DB::transaction(function () use ($rawMaterial): void {
$rawMaterial->prices()->each(function (RawMaterialPrice $price): void {
$price->clearMediaCollection('images');
});
$rawMaterial->prices()->delete();
$rawMaterial->delete();
});
}
public function applyToggleStatus(OwnerVerificationRequest $verificationRequest): void
@ -486,16 +510,13 @@ private function rejectCreate(OwnerVerificationRequest $verificationRequest): vo
return;
}
$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',
);
DB::transaction(function () use ($rawMaterial): void {
$rawMaterial->prices()->each(function (RawMaterialPrice $price): void {
$price->clearMediaCollection('images');
});
$rawMaterial->prices()->delete();
$rawMaterial->delete();
});
}
private function rollbackUpdate(OwnerVerificationRequest $verificationRequest): void

View File

@ -3,15 +3,12 @@
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,
) {}
@ -34,45 +31,30 @@ public function homepageData(): array
public function updateHomepage(array $validated): void
{
$this->runInTransaction(
function () use ($validated): void {
$configuration = HomepageConfiguration::instance();
$configuration = HomepageConfiguration::instance();
$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['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['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['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' => $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',
);
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,
);
}
}
}

View File

@ -8,7 +8,6 @@
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;
@ -17,8 +16,6 @@
class MarketplaceService
{
use RunsInTransaction;
public function __construct(
private readonly PushNotificationService $pushNotificationService,
) {}
@ -75,66 +72,61 @@ public function marketplaceData(): array
public function updateMarketplace(array $validated, User $user): void
{
$this->runInTransaction(
function () use ($validated, $user): void {
if ($user->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)) {
$this->saveSettings($validated);
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'),
);
},
'Gagal memperbarui pengaturan marketplace',
$this->pushNotificationService->sendToUser(
'📤 Pengajuan Terkirim',
'Pengajuan ubah pengaturan marketplace menunggu verifikasi owner.',
$user->id,
route('admin.system.settings.index'),
);
}
@ -155,13 +147,8 @@ public function saveSettings(array $validated): void
public function applyVerificationRequest(OwnerVerificationRequest $request): void
{
$this->runInTransaction(
function () use ($request): void {
$newPayload = $request->payload['new'] ?? [];
$this->saveSettings($newPayload);
},
'Gagal menerapkan pengajuan verifikasi owner',
);
$newPayload = $request->payload['new'] ?? [];
$this->saveSettings($newPayload);
}
public function buildOrderSnapshot(OrderChannel $channel, int $totalAmount, array $lineItems, bool $isAffiliate = false): ?array

View File

@ -3,17 +3,14 @@
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,
) {}
@ -42,70 +39,50 @@ public function systemData(): array
public function updateSystem(array $validated): void
{
$this->runInTransaction(
function () use ($validated): void {
$settings = app(SystemSettings::class);
$configuration = SystemConfiguration::instance();
$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();
$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['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['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['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['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',
);
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');
}
$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);
}
},
'Gagal memperbarui pengaturan sistem',
);
if ($errors !== []) {
throw ValidationException::withMessages($errors);
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -17,7 +17,6 @@ public function definition(): array
'product_id' => Product::factory(),
'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),
];
}

View File

@ -1,27 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('cutting_material_combinations', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->nullable()->constrained()->cascadeOnDelete();
$table->foreignId('cutting_id')->nullable()->constrained()->cascadeOnDelete();
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();
$table->softDeletes();
});
}
public function down(): void
{
Schema::dropIfExists('cutting_material_combinations');
}
};

View File

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

View File

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

View File

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

View File

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

View File

@ -24,7 +24,7 @@ interface MenuItem {
title: string;
href: string;
icon: any;
permission?: string | string[];
permission?: string;
badgeKey?: 'pendingLeaveRequests' | 'pendingEmployeeAdvances' | 'pendingCuttings';
}
@ -82,18 +82,7 @@ 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_system',
'settings.view_social_media',
'settings.view_marketplace',
'settings.view_hr',
'settings.view_homepage',
],
},
{ title: 'Pengaturan', href: admin.system.settings.index.url(), icon: Settings2, permission: 'settings.view' },
{ title: 'Log Aktivitas', href: admin.system.activity_logs.index.url(), icon: History, permission: 'activity_logs.view' },
],
},

View File

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

View File

@ -4,6 +4,7 @@ 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,
@ -14,7 +15,6 @@ import {
TableRow,
} from '@/components/ui/table';
import TableEmpty from '@/components/ui/table/TableEmpty.vue';
import { buildPaginationSummary } from '@/lib/grouped-table';
import type {
DataTableFilterDef,
DataTablePagination,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -212,7 +212,6 @@ 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>
@ -224,7 +223,6 @@ 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>
@ -237,7 +235,6 @@ 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>
@ -249,7 +246,6 @@ 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>

View File

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

View File

@ -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;

View File

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

View File

@ -6,19 +6,15 @@ import { DataTable } from '@/components/data-table';
import { Button } from '@/components/ui/button';
import { 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;
@ -120,13 +116,11 @@ 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>
@ -148,7 +142,7 @@ watch(
</Card>
<Card class="min-w-0">
<CardContent class="min-w-0">
<CardContent class="min-w-0 pt-6">
<DataTable v-model:search="search" :columns="columns" :data="transactions.data" :pagination="pagination"
:pagination-links="transactions.links" :sort="currentSort" :filter-defs="filterDefs"
:filter-values="filterValues" @sort-change="setSort" @filter-change="setFilter"

View File

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

View File

@ -1,7 +1,4 @@
<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';
@ -12,16 +9,19 @@ 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>();

View File

@ -1,6 +1,4 @@
<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';
@ -10,12 +8,14 @@ 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;

View File

@ -26,10 +26,11 @@ 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 { ExpenseFormData, ExpenseListItem } from '@/types/expense';
import type { 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<{
@ -42,7 +43,7 @@ const photoState = ref<MediaUploadState>(createMediaUploadState());
const isUploading = computed(() => photoState.value.pendingUploads > 0);
const form = useForm<ExpenseFormData>({
const form = useForm({
amount: '',
description: '',
});

View File

@ -1,8 +1,8 @@
<script setup lang="ts">
import { RowDeleteAction, RowEditAction } from '@/components/button';
import { useCan } from '@/composables/useCan';
import { destroy } from '@/routes/admin/finance/expenses';
import type { ExpenseListItem } from '@/types/expense';
import { destroy } from '@/routes/admin/finance/expenses';
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.`"
error-message="Gagal menghapus pengeluaran." />
:on-error="(errors) => errors.amount || errors.transaction || 'Gagal menghapus pengeluaran.'" />
</div>
</template>

View File

@ -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>();

View File

@ -8,7 +8,7 @@ import type { AttendanceListItem, CalendarRange, TodayAttendance } from '@/types
import AttendanceWebcamModal from './form/AttendanceWebcamModal.vue';
import AttendanceCalendar from './table/AttendanceCalendar.vue';
defineProps<{
const props = defineProps<{
attendances: AttendanceListItem[];
todayAttendance: TodayAttendance;
isOnLeave: boolean;
@ -44,8 +44,13 @@ 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">

View File

@ -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 { index } from '@/routes/admin/hr/attendances';
import type { AttendanceListItem, CalendarRange } from '@/types/attendance';
import AttendanceDetailDialog from './AttendanceDetailDialog.vue';
import { index } from '@/routes/admin/hr/attendances';
const props = defineProps<{
attendances: AttendanceListItem[];
@ -147,7 +147,6 @@ function scrollToToday() {
requestAnimationFrame(() => {
const container = calendarRef.value?.$el?.closest('.overflow-x-auto');
const todayEl = container?.querySelector('.fc-day-today');
if (!container || !todayEl) {
return;
}

View File

@ -1,5 +1,7 @@
<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 {
@ -11,10 +13,8 @@ import {
} from '@/components/ui/dialog';
import { useCan } from '@/composables/useCan';
import { useDestroy } from '@/composables/useDestroy';
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';
import { destroy } from '@/routes/admin/hr/attendances';
const open = defineModel<boolean>('open', { required: true });
@ -76,20 +76,29 @@ 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>
@ -98,9 +107,15 @@ 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>

View File

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

View File

@ -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 { index, store } from '@/routes/admin/hr/employees';
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';
defineProps<{
genders: EnumOption[];

View File

@ -1,12 +1,12 @@
<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/hr/employees';
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 { index, update } from '@/routes/admin/hr/employees';
const props = defineProps<{
employee: EmployeeListItem;

View File

@ -1,6 +1,4 @@
<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';
@ -11,10 +9,12 @@ 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,24 +112,37 @@ 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>

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { Save } from '@lucide/vue';
import { computed, ref } from 'vue';
import { 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,7 +201,8 @@ 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>

View File

@ -2,7 +2,6 @@ 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';
@ -10,24 +9,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 }) => {
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)]);
},
cell: ({ row }) => row.original.profile?.full_name ?? '-',
},
{
id: 'account',

View File

@ -1,6 +1,4 @@
<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';
@ -10,16 +8,17 @@ 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>();
@ -88,29 +87,48 @@ 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>

View File

@ -1,8 +1,6 @@
<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,
@ -11,7 +9,9 @@ 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[];

View File

@ -1,9 +1,6 @@
<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,
@ -11,7 +8,10 @@ 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,10 +34,7 @@ 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,
@ -53,11 +50,12 @@ 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>
@ -65,8 +63,15 @@ 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>

View File

@ -1,6 +1,4 @@
<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';
@ -9,10 +7,12 @@ 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;

View File

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

View File

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

View File

@ -99,8 +99,6 @@ const {
decreaseResultQty,
syncMaterialField,
syncResultField,
removeCombination,
syncCombinationResult,
} = useCuttingPosCart({
rawMaterialCatalog: rawMaterialCatalogState,
productCatalog: productCatalogState,
@ -144,14 +142,6 @@ 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]`,
@ -161,30 +151,6 @@ 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(
@ -251,14 +217,6 @@ function onRawMaterialCreated(rawMaterial: CuttingRawMaterialCatalogItem) {
}
}
function onCombinationCreated(items: CuttingMaterialCartItem[]) {
for (const item of items) {
// Combination items use raw_material_price_id + combination_id as unique key
// Same variant can exist in multiple combinations
materialCart.value.push({ ...item });
}
}
function onProductCreated(product: CuttingProductCatalogItem) {
// Add to catalog
productCatalogState.value.push(product);
@ -276,10 +234,9 @@ 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" :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" />
:filtered-raw-materials="filteredRawMaterials" :get-material-cart-item="getMaterialCartItem"
:units="units" @add-material="addMaterial" @decrease-material-qty="decreaseMaterialQty"
@raw-material-created="onRawMaterialCreated" />
<CuttingPosResultCatalogPanel v-model:product-search="productSearch" :filtered-products="filteredProducts"
:get-result-cart-item="getResultCartItem" @add-result="addResult" :is-create-mode="isCreateMode"
@ -291,8 +248,7 @@ function onProductCreated(product: CuttingProductCatalogItem) {
@submit="submit" :is-create-mode="isCreateMode" :is-uploading="isUploading"
@open-detail="cartDetailOpen = true" @remove-material="removeMaterial"
@sync-material-field="syncMaterialField" @remove-result="removeResult"
@sync-result-totals="syncResultTotals" @sync-result-field="syncResultField"
@remove-combination="removeCombination" @sync-combination-result="syncCombinationResult" />
@sync-result-totals="syncResultTotals" @sync-result-field="syncResultField" />
</div>
<CuttingPosCartDetailDialog v-model:open="cartDetailOpen" :material-cart="materialCart" :result-cart="resultCart"

View File

@ -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,14 +16,11 @@ 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 CuttingPosCombinationDialog from './CuttingPosCombinationDialog.vue';
import QuickCreateRawMaterialModal from './QuickCreateRawMaterialModal.vue';
import type { CuttingCatalogPrice } from './useCuttingPosCart';
import QuickCreateRawMaterialModal from './QuickCreateRawMaterialModal.vue';
defineProps<{
filteredRawMaterials: CuttingRawMaterialCatalogItem[];
rawMaterialCatalog: CuttingRawMaterialCatalogItem[];
materialCart: CuttingMaterialCartItem[];
getMaterialCartItem: (priceId: number) => CuttingMaterialCartItem | undefined;
units: EnumOption[];
}>();
@ -34,20 +31,9 @@ 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>
@ -78,8 +64,12 @@ function openCombination(rawMaterial: CuttingRawMaterialCatalogItem, price: Cutt
</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 }}
@ -89,13 +79,18 @@ function openCombination(rawMaterial: CuttingRawMaterialCatalogItem, price: Cutt
<p v-if="!rawMaterial.prices.length" class="px-3 py-4 text-sm text-muted-foreground">
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">
@ -106,46 +101,36 @@ function openCombination(rawMaterial: CuttingRawMaterialCatalogItem, price: Cutt
</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>
<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>
<Button
type="button"
variant="outline"
size="icon-sm"
@click.stop="emit('add-material', rawMaterial, price)"
>
<Plus class="size-3.5" />
</Button>
</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>
@ -153,10 +138,9 @@ function openCombination(rawMaterial: CuttingRawMaterialCatalogItem, price: Cutt
</CardContent>
</Card>
<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)" />
<QuickCreateRawMaterialModal
v-model:open="quickCreateOpen"
:units="units"
@created="emit('raw-material-created', $event)"
/>
</template>

View File

@ -1,9 +1,6 @@
<script setup lang="ts">
import { Layers, Trash2 } from '@lucide/vue';
import { computed } from 'vue';
import { Trash2 } from '@lucide/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,
@ -14,7 +11,7 @@ import { formErrors } from '@/lib/form';
import type { FormWithErrors } from '@/lib/form';
import type { CuttingMaterialCartItem } from '@/types/cutting';
const props = defineProps<{
defineProps<{
form: FormWithErrors;
materialCart: CuttingMaterialCartItem[];
}>();
@ -22,189 +19,59 @@ const props = 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">
<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
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>
</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 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>
<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>
</div>
</template>

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { Check, Minus, Plus, Search } from '@lucide/vue';
import { ref } from 'vue';
import { Check, Minus, Plus, Search } from '@lucide/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 QuickCreateProductModal from './QuickCreateProductModal.vue';
import type { CuttingCatalogVariant } from './useCuttingPosCart';
import QuickCreateProductModal from './QuickCreateProductModal.vue';
defineProps<{
filteredProducts: CuttingProductCatalogItem[];

View File

@ -1,6 +1,5 @@
<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';
@ -14,15 +13,15 @@ import {
import { Separator } from '@/components/ui/separator';
import { Textarea } from '@/components/ui/textarea';
import { FIELD_LIMITS } from '@/lib/field-limits';
import { formErrors } from '@/lib/form';
import type { FormWithErrors } from '@/lib/form';
import { formErrors, 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 };
@ -42,8 +41,6 @@ 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', {
@ -54,19 +51,27 @@ 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>
@ -77,39 +82,65 @@ 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)"
@remove-combination="emit('remove-combination', $event)"
@sync-combination-result="(combinationId, result) => emit('sync-combination-result', combinationId, result)" />
<CuttingPosMaterialSummaryItems
:form="form"
:material-cart="materialCart"
@remove="emit('remove-material', $event)"
@sync-field="emit('sync-material-field', $event)"
/>
<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