diff --git a/app/Http/Requests/Concerns/ValidatesMediaUploads.php b/app/Http/Requests/Concerns/ValidatesMediaUploads.php index 349cb78..02c5eb9 100644 --- a/app/Http/Requests/Concerns/ValidatesMediaUploads.php +++ b/app/Http/Requests/Concerns/ValidatesMediaUploads.php @@ -10,6 +10,8 @@ trait ValidatesMediaUploads protected function photoRules(string $prefix = 'photos', int $max = 1): array { return [ + $prefix => ['nullable', 'array', "max:{$max}"], + "{$prefix}.*" => ['image', 'mimes:jpg,jpeg,png,webp', 'max:5120'], 's3_keys' => ['nullable', 'array', "max:{$max}"], 's3_keys.*' => ['required', 'string'], 'remove_media_ids' => ['nullable', 'array'], @@ -33,9 +35,11 @@ protected function variantImageRules(string $variantsKey = 'variants', int $max /** * @return array */ - protected function photoUploadAttributes(string $label): array + protected function photoUploadAttributes(string $label, string $prefix = 'photos'): array { return [ + $prefix => $label, + "{$prefix}.*" => $label, 's3_keys' => $label, 's3_keys.*' => $label, 'remove_media_ids' => 'media yang dihapus', diff --git a/app/Services/Concerns/RunsInTransaction.php b/app/Services/Concerns/RunsInTransaction.php new file mode 100644 index 0000000..bd09af0 --- /dev/null +++ b/app/Services/Concerns/RunsInTransaction.php @@ -0,0 +1,33 @@ +getMessage()}", [ + 'trace' => $e->getTraceAsString(), + ]); + + throw ValidationException::withMessages([ + 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', + ]); + } + } +} diff --git a/app/Services/Concerns/SyncsPhotos.php b/app/Services/Concerns/SyncsPhotos.php new file mode 100644 index 0000000..2235bc9 --- /dev/null +++ b/app/Services/Concerns/SyncsPhotos.php @@ -0,0 +1,32 @@ +mediaService->syncCollection( + $model, + $collection, + $validated['photos'] ?? null, + $validated['remove_media_ids'] ?? null, + $maxPhotos, + required: $required, + errorKey: $errorKey ?? $collection, + s3Keys: $validated['s3_keys'] ?? null, + ); + } +} diff --git a/app/Services/Finance/ExpenseService.php b/app/Services/Finance/ExpenseService.php index 03151f4..4ffbb0c 100644 --- a/app/Services/Finance/ExpenseService.php +++ b/app/Services/Finance/ExpenseService.php @@ -4,17 +4,18 @@ use App\Models\Expense; use App\Models\User; +use App\Services\Concerns\RunsInTransaction; +use App\Services\Concerns\SyncsPhotos; use App\Services\Media\MediaService; use App\Services\System\PushNotificationService; use App\Support\Media\MediaPresenter; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Log; -use Illuminate\Validation\ValidationException; class ExpenseService { + use RunsInTransaction, SyncsPhotos; + private const MAX_PHOTOS = 1; public function __construct( @@ -51,8 +52,8 @@ public function paginateForIndex(array $tableQuery): LengthAwarePaginator public function create(array $validated, User $user): void { - try { - $expense = DB::transaction(function () use ($validated, $user): Expense { + $expense = $this->runInTransaction( + function () use ($validated, $user): Expense { $amount = (int) $validated['amount']; $description = $validated['description']; @@ -73,21 +74,12 @@ public function create(array $validated, User $user): void 'cash_transaction_id' => $cashTransaction->id, ]); - $this->syncPhotos($expense, $validated); + $this->syncPhotos($expense, $validated, self::MAX_PHOTOS); return $expense; - }); - } catch (ValidationException $e) { - throw $e; - } catch (\Throwable $e) { - Log::error('Gagal membuat pengeluaran: '.$e->getMessage(), [ - 'trace' => $e->getTraceAsString(), - ]); - - throw ValidationException::withMessages([ - 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', - ]); - } + }, + 'Gagal membuat pengeluaran', + ); $this->pushNotificationService->sendToRoles( '💸 Pengeluaran Baru', @@ -99,8 +91,8 @@ public function create(array $validated, User $user): void public function update(Expense $expense, array $validated): void { - try { - DB::transaction(function () use ($expense, $validated): void { + $this->runInTransaction( + function () use ($expense, $validated): void { $amount = (int) $validated['amount']; $description = $validated['description']; @@ -117,19 +109,10 @@ public function update(Expense $expense, array $validated): void ); } - $this->syncPhotos($expense, $validated); - }); - } catch (ValidationException $e) { - throw $e; - } catch (\Throwable $e) { - Log::error('Gagal memperbarui pengeluaran: '.$e->getMessage(), [ - 'trace' => $e->getTraceAsString(), - ]); - - throw ValidationException::withMessages([ - 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', - ]); - } + $this->syncPhotos($expense, $validated, self::MAX_PHOTOS); + }, + 'Gagal memperbarui pengeluaran', + ); $this->pushNotificationService->sendToRoles( '✏️ Pengeluaran Diperbarui', @@ -141,49 +124,29 @@ public function update(Expense $expense, array $validated): void public function delete(Expense $expense): void { - try { - DB::transaction(function () use ($expense): void { + $amountFormatted = $expense->amount_formatted; + $description = $expense->description; + + $this->runInTransaction( + function () use ($expense): void { if ($expense->cashTransaction) { $this->cashService->deleteReferencedTransaction($expense->cashTransaction); } $expense->clearMediaCollection('photos'); $expense->delete(); - }); - } catch (ValidationException $e) { - throw $e; - } catch (\Throwable $e) { - Log::error('Gagal menghapus pengeluaran: '.$e->getMessage(), [ - 'trace' => $e->getTraceAsString(), - ]); - - throw ValidationException::withMessages([ - 'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.', - ]); - } + }, + 'Gagal menghapus pengeluaran', + ); $this->pushNotificationService->sendToRoles( '🗑️ Pengeluaran Dihapus', - "Pengeluaran sebesar {$expense->amount_formatted} dengan keterangan {$expense->description} telah dihapus.", + "Pengeluaran sebesar {$amountFormatted} dengan keterangan {$description} telah dihapus.", ['owner', 'developer', 'direktur'], route('admin.finance.expenses.index'), ); } - private function syncPhotos(Expense $expense, array $validated): void - { - $this->mediaService->syncCollection( - $expense, - 'photos', - $validated['photos'] ?? null, - $validated['remove_media_ids'] ?? null, - self::MAX_PHOTOS, - required: true, - errorKey: 'photos', - s3Keys: $validated['s3_keys'] ?? null, - ); - } - private function applySorting(Builder $query, string $sort, string $direction): void { if (in_array($sort, ['created_at', 'amount', 'description'], true)) { diff --git a/resources/js/pages/admin/finance/expenses/form/ExpenseFormModal.vue b/resources/js/pages/admin/finance/expenses/form/ExpenseFormModal.vue index 564f459..adb3247 100644 --- a/resources/js/pages/admin/finance/expenses/form/ExpenseFormModal.vue +++ b/resources/js/pages/admin/finance/expenses/form/ExpenseFormModal.vue @@ -26,11 +26,10 @@ import { FIELD_LIMITS } from '@/lib/field-limits'; import { formErrors } from '@/lib/form'; import { parseRupiah } from '@/lib/rupiah'; import { store, update } from '@/routes/admin/finance/expenses'; -import type { ExpenseListItem } from '@/types/expense'; +import type { ExpenseFormData, ExpenseListItem } from '@/types/expense'; import { appendRootPhotosToFormData, createMediaUploadState } from '@/types/media'; import type { MediaUploadState } from '@/types/media'; - const open = defineModel('open', { default: false }); const props = defineProps<{ @@ -43,7 +42,7 @@ const photoState = ref(createMediaUploadState()); const isUploading = computed(() => photoState.value.pendingUploads > 0); -const form = useForm({ +const form = useForm({ amount: '', description: '', }); diff --git a/resources/js/pages/admin/finance/expenses/table/data-table-actions.vue b/resources/js/pages/admin/finance/expenses/table/data-table-actions.vue index c0d1773..6a4ff85 100644 --- a/resources/js/pages/admin/finance/expenses/table/data-table-actions.vue +++ b/resources/js/pages/admin/finance/expenses/table/data-table-actions.vue @@ -20,6 +20,6 @@ const { can } = useCan(); + error-message="Gagal menghapus pengeluaran." /> diff --git a/resources/js/types/expense.ts b/resources/js/types/expense.ts index 7f4d669..88447f0 100644 --- a/resources/js/types/expense.ts +++ b/resources/js/types/expense.ts @@ -1,5 +1,5 @@ -import type { MediaItem } from '@/types/media'; import type { Paginated } from '@/types/common'; +import type { MediaItem } from '@/types/media'; export type ExpenseListItem = { id: number; @@ -14,8 +14,12 @@ export type ExpenseListItem = { export type ExpenseFormData = { amount: string; description: string; - photos: File[]; - remove_media_ids: number[]; +}; + +export type ExpenseFilters = { + search: string; + sort?: string; + direction?: 'asc' | 'desc' | null; }; export type PaginatedExpenses = Paginated; diff --git a/tests/Feature/Admin/Finance/ExpenseTest.php b/tests/Feature/Admin/Finance/ExpenseTest.php index 80f9f4d..10a0dca 100644 --- a/tests/Feature/Admin/Finance/ExpenseTest.php +++ b/tests/Feature/Admin/Finance/ExpenseTest.php @@ -129,12 +129,16 @@ function expensePayload(?int $amount = null, ?string $description = null): array $this->actingAs($user) ->post(route('admin.finance.expenses.store'), expensePayload(250000, 'Beli mesin jahit')) + ->assertSessionHasNoErrors() ->assertRedirect(route('admin.finance.expenses.index')); $this->assertDatabaseHas('expenses', [ 'amount' => 250000, 'description' => 'Beli mesin jahit', ]); + + $expense = Expense::where('description', 'Beli mesin jahit')->first(); + expect($expense->getMedia('photos'))->not->toBeEmpty(); }); test('guest cannot create an expense', function () {