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:
parent
bac44df627
commit
143448e6e0
@ -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',
|
||||
|
||||
33
app/Services/Concerns/RunsInTransaction.php
Normal file
33
app/Services/Concerns/RunsInTransaction.php
Normal file
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Concerns;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
trait RunsInTransaction
|
||||
{
|
||||
/**
|
||||
* @template TReturn
|
||||
*
|
||||
* @param callable(): TReturn $callback
|
||||
* @return TReturn
|
||||
*/
|
||||
protected function runInTransaction(callable $callback, string $context)
|
||||
{
|
||||
try {
|
||||
return DB::transaction($callback);
|
||||
} catch (ValidationException $e) {
|
||||
throw $e;
|
||||
} catch (\Throwable $e) {
|
||||
Log::error("{$context}: {$e->getMessage()}", [
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'system' => 'Terjadi kesalahan pada server. Silakan laporkan masalah ini ke pihak terkait.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
32
app/Services/Concerns/SyncsPhotos.php
Normal file
32
app/Services/Concerns/SyncsPhotos.php
Normal file
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Concerns;
|
||||
|
||||
use App\Services\Media\MediaService;
|
||||
use Spatie\MediaLibrary\HasMedia;
|
||||
|
||||
/**
|
||||
* @property-read MediaService $mediaService
|
||||
*/
|
||||
trait SyncsPhotos
|
||||
{
|
||||
protected function syncPhotos(
|
||||
HasMedia $model,
|
||||
array $validated,
|
||||
int $maxPhotos = 1,
|
||||
bool $required = true,
|
||||
string $collection = 'photos',
|
||||
?string $errorKey = null,
|
||||
): void {
|
||||
$this->mediaService->syncCollection(
|
||||
$model,
|
||||
$collection,
|
||||
$validated['photos'] ?? null,
|
||||
$validated['remove_media_ids'] ?? null,
|
||||
$maxPhotos,
|
||||
required: $required,
|
||||
errorKey: $errorKey ?? $collection,
|
||||
s3Keys: $validated['s3_keys'] ?? null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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)) {
|
||||
|
||||
@ -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: '',
|
||||
});
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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 () {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user