feat: enhance Expense module by improving media upload handling, adding transaction management, and refining form validation; update related components for better user experience

This commit is contained in:
Yoga Pangestu 2026-07-03 22:44:33 +07:00
parent bac44df627
commit 143448e6e0
8 changed files with 109 additions and 70 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -20,6 +20,6 @@ const { can } = useCan();
<RowEditAction v-if="can('expenses.update')" @click="emit('edit', expense)" /> <RowEditAction v-if="can('expenses.update')" @click="emit('edit', expense)" />
<RowDeleteAction v-if="can('expenses.delete')" :action-url="destroy.url(expense.id)" title="Hapus pengeluaran?" <RowDeleteAction v-if="can('expenses.delete')" :action-url="destroy.url(expense.id)" title="Hapus pengeluaran?"
:description="`Pengeluaran ${expense.amount_formatted} akan dihapus. Saldo kas akan disesuaikan.`" :description="`Pengeluaran ${expense.amount_formatted} akan dihapus. Saldo kas akan disesuaikan.`"
:on-error="(errors) => errors.amount || errors.transaction || 'Gagal menghapus pengeluaran.'" /> error-message="Gagal menghapus pengeluaran." />
</div> </div>
</template> </template>

View File

@ -1,5 +1,5 @@
import type { MediaItem } from '@/types/media';
import type { Paginated } from '@/types/common'; import type { Paginated } from '@/types/common';
import type { MediaItem } from '@/types/media';
export type ExpenseListItem = { export type ExpenseListItem = {
id: number; id: number;
@ -14,8 +14,12 @@ export type ExpenseListItem = {
export type ExpenseFormData = { export type ExpenseFormData = {
amount: string; amount: string;
description: string; description: string;
photos: File[]; };
remove_media_ids: number[];
export type ExpenseFilters = {
search: string;
sort?: string;
direction?: 'asc' | 'desc' | null;
}; };
export type PaginatedExpenses = Paginated<ExpenseListItem>; export type PaginatedExpenses = Paginated<ExpenseListItem>;

View File

@ -129,12 +129,16 @@ function expensePayload(?int $amount = null, ?string $description = null): array
$this->actingAs($user) $this->actingAs($user)
->post(route('admin.finance.expenses.store'), expensePayload(250000, 'Beli mesin jahit')) ->post(route('admin.finance.expenses.store'), expensePayload(250000, 'Beli mesin jahit'))
->assertSessionHasNoErrors()
->assertRedirect(route('admin.finance.expenses.index')); ->assertRedirect(route('admin.finance.expenses.index'));
$this->assertDatabaseHas('expenses', [ $this->assertDatabaseHas('expenses', [
'amount' => 250000, 'amount' => 250000,
'description' => 'Beli mesin jahit', '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 () { test('guest cannot create an expense', function () {