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
|
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',
|
||||||
|
|||||||
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\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)) {
|
||||||
|
|||||||
@ -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: '',
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>;
|
||||||
|
|||||||
@ -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 () {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user