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

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\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)) {

View File

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

View File

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

View File

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

View File

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