From 9033aef5f7f3d4639f2f7a6227b92afe62e19d70 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Thu, 30 Jul 2026 10:28:18 +0700 Subject: [PATCH] feat: add receipt handling and media upload functionality to CashTransaction and CashAccount components --- .../Admin/Finance/CashTransactionRequest.php | 6 + app/Models/CashTransaction.php | 6 +- .../Admin/Finance/CashAccountService.php | 94 +++++++++++++- .../admin/finance/cash-account/index.tsx | 121 ++++++++++++++++-- .../cash-account/transaction-columns.tsx | 42 ++++++ 5 files changed, 251 insertions(+), 18 deletions(-) diff --git a/app/Http/Requests/Admin/Finance/CashTransactionRequest.php b/app/Http/Requests/Admin/Finance/CashTransactionRequest.php index 2abdc5e..3aeb5d6 100644 --- a/app/Http/Requests/Admin/Finance/CashTransactionRequest.php +++ b/app/Http/Requests/Admin/Finance/CashTransactionRequest.php @@ -27,6 +27,9 @@ public function rules(): array return [ 'amount' => ['required', 'integer', 'min:1'], 'description' => ['required', 'string', 'max:100'], + 'receipt_key' => ['required', 'string', 'max:500'], + 'file_size' => ['nullable', 'integer', 'min:1'], + 'file_mime_type' => ['nullable', 'string', 'in:image/jpeg,image/png,image/webp,image/gif'], ]; } @@ -35,6 +38,9 @@ public function attributes(): array return [ 'amount' => 'jumlah', 'description' => 'keterangan', + 'receipt_key' => 'bukti', + 'file_size' => 'ukuran file', + 'file_mime_type' => 'tipe file', ]; } } diff --git a/app/Models/CashTransaction.php b/app/Models/CashTransaction.php index a568a70..33e0a23 100644 --- a/app/Models/CashTransaction.php +++ b/app/Models/CashTransaction.php @@ -12,11 +12,13 @@ use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\SoftDeletes; +use Spatie\MediaLibrary\HasMedia; +use Spatie\MediaLibrary\InteractsWithMedia; #[Guarded(['id'])] -class CashTransaction extends Model +class CashTransaction extends Model implements HasMedia { - use HasFactory, SoftDeletes; + use HasFactory, SoftDeletes, InteractsWithMedia; protected function casts(): array { diff --git a/app/Services/Admin/Finance/CashAccountService.php b/app/Services/Admin/Finance/CashAccountService.php index 3252ac8..30f6fad 100644 --- a/app/Services/Admin/Finance/CashAccountService.php +++ b/app/Services/Admin/Finance/CashAccountService.php @@ -5,12 +5,19 @@ use App\Enums\CashTransactionType; use App\Models\CashAccount; use App\Models\CashTransaction; +use App\Services\S3PresignedService; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; +use Spatie\MediaLibrary\MediaCollections\Models\Media; class CashAccountService { + public function __construct( + private S3PresignedService $s3Service = new S3PresignedService, + ) {} + public function get(): ?CashAccount { return CashAccount::select('id', 'name', 'balance')->first(); @@ -26,9 +33,33 @@ public function getAllTransactions(): Collection return $cashAccount->cashTransactions() ->select('id', 'created_by_id', 'reference_type', 'reference_id', 'amount', 'balance_after', 'type', 'description', 'created_at') - ->with('createdBy.userProfile') + ->with('createdBy.userProfile', 'media') ->latest() - ->get(); + ->get() + ->map(fn (CashTransaction $transaction) => $this->formatTransaction($transaction)); + } + + private function formatTransaction(CashTransaction $transaction): array + { + $media = $transaction->getFirstMedia('receipts'); + + if (! $media) { + return $transaction->toArray() + [ + 'receipt_key' => null, + 'receipt_url' => null, + ]; + } + + $s3Key = $media->file_name; + + if (str_starts_with($s3Key, '/') || ! str_contains($s3Key, '/')) { + $s3Key = $media->getPath(); + } + + return $transaction->toArray() + [ + 'receipt_key' => $s3Key, + 'receipt_url' => $this->s3Service->getTemporaryUrl($s3Key), + ]; } public function deposit(array $data): CashTransaction @@ -39,7 +70,7 @@ public function deposit(array $data): CashTransaction $cashAccount->update(['balance' => $newBalance]); - return CashTransaction::create([ + $transaction = CashTransaction::create([ 'cash_account_id' => $cashAccount->id, 'created_by_id' => auth()->id(), 'amount' => $data['amount'], @@ -47,6 +78,12 @@ public function deposit(array $data): CashTransaction 'type' => CashTransactionType::DEPOSIT, 'description' => $data['description'], ]); + + if (! empty($data['receipt_key'])) { + $this->registerMedia($transaction, $data['receipt_key'], $data['file_size'] ?? null, $data['file_mime_type'] ?? null); + } + + return $transaction; }); } @@ -64,7 +101,7 @@ public function withdrawal(array $data): CashTransaction $newBalance = $cashAccount->balance - $data['amount']; $cashAccount->update(['balance' => $newBalance]); - return CashTransaction::create([ + $transaction = CashTransaction::create([ 'cash_account_id' => $cashAccount->id, 'created_by_id' => auth()->id(), 'amount' => $data['amount'], @@ -72,6 +109,12 @@ public function withdrawal(array $data): CashTransaction 'type' => CashTransactionType::WITHDRAWAL, 'description' => $data['description'], ]); + + if (! empty($data['receipt_key'])) { + $this->registerMedia($transaction, $data['receipt_key'], $data['file_size'] ?? null, $data['file_mime_type'] ?? null); + } + + return $transaction; }); } @@ -106,6 +149,23 @@ public function updateTransaction(CashTransaction $transaction, array $data): Ca 'description' => $data['description'], ]); + if (array_key_exists('receipt_key', $data)) { + $currentMedia = $transaction->getFirstMedia('receipts'); + $currentKey = $currentMedia?->file_name; + + if ($currentMedia && ! str_contains($currentKey, '/')) { + $currentKey = $currentMedia->getPath(); + } + + if ($data['receipt_key'] !== $currentKey) { + $transaction->clearMediaCollection('receipts'); + + if (! empty($data['receipt_key'])) { + $this->registerMedia($transaction, $data['receipt_key'], $data['file_size'] ?? null, $data['file_mime_type'] ?? null); + } + } + } + return $transaction; }); } @@ -134,7 +194,33 @@ public function deleteTransaction(CashTransaction $transaction): bool $cashAccount->update(['balance' => $newBalance]); + $transaction->clearMediaCollection('receipts'); + return $transaction->delete(); }); } + + private function registerMedia(CashTransaction $transaction, string $s3Key, ?int $fileSize = null, ?string $mimeType = null): void + { + $fileName = pathinfo($s3Key, PATHINFO_BASENAME); + $name = pathinfo($s3Key, PATHINFO_FILENAME); + + Media::create([ + 'model_type' => CashTransaction::class, + 'model_id' => $transaction->id, + 'uuid' => Str::uuid(), + 'collection_name' => 'receipts', + 'name' => $name, + 'file_name' => $s3Key, + 'mime_type' => $mimeType ?? 'image/jpeg', + 'disk' => 's3', + 'conversions_disk' => 's3', + 'size' => $fileSize ?? 0, + 'manipulations' => [], + 'custom_properties' => [], + 'generated_conversions' => ['thumb' => true], + 'responsive_images' => [], + 'order_column' => 1, + ]); + } } diff --git a/resources/js/pages/admin/finance/cash-account/index.tsx b/resources/js/pages/admin/finance/cash-account/index.tsx index a8c7fc6..0bb4f68 100644 --- a/resources/js/pages/admin/finance/cash-account/index.tsx +++ b/resources/js/pages/admin/finance/cash-account/index.tsx @@ -21,6 +21,7 @@ import { createTransactionColumns } from './transaction-columns'; import type { CashTransaction } from './transaction-columns'; import { DataTable } from '@/components/data-table'; import { ConfirmDialog } from '@/components/confirm-dialog'; +import { FileUpload } from '@/components/file-upload'; type CashAccount = { id: number; @@ -39,6 +40,18 @@ export default function CashAccountIndex({ cashAccount, transactions }: Props) { const [editing, setEditing] = useState(null); const [deleting, setDeleting] = useState(null); + const [depositReceiptKey, setDepositReceiptKey] = useState(null); + const [depositUploading, setDepositUploading] = useState(false); + const [depositFileMeta, setDepositFileMeta] = useState<{ size: number; type: string } | null>(null); + + const [withdrawalReceiptKey, setWithdrawalReceiptKey] = useState(null); + const [withdrawalUploading, setWithdrawalUploading] = useState(false); + const [withdrawalFileMeta, setWithdrawalFileMeta] = useState<{ size: number; type: string } | null>(null); + + const [editReceiptKey, setEditReceiptKey] = useState(null); + const [editUploading, setEditUploading] = useState(false); + const [editFileMeta, setEditFileMeta] = useState<{ size: number; type: string } | null>(null); + function handleDelete() { if (!deleting) { return; @@ -50,7 +63,10 @@ export default function CashAccountIndex({ cashAccount, transactions }: Props) { } const columns = createTransactionColumns({ - handleEdit: (transaction) => setEditing(transaction), + handleEdit: (transaction) => { + setEditing(transaction); + setEditReceiptKey(transaction.receipt_key ?? null); + }, handleDeleteClick: (transaction) => setDeleting(transaction), }); @@ -99,9 +115,19 @@ export default function CashAccountIndex({ cashAccount, transactions }: Props) { emptyText="Belum ada riwayat transaksi." /> - + { + setDepositOpen(open); + if (!open) { + setDepositReceiptKey(null); + setDepositFileMeta(null); + } + }}> -
setDepositOpen(false)}> + { + setDepositOpen(false); + setDepositReceiptKey(null); + setDepositFileMeta(null); + }}> {({ errors, processing }) => ( <> @@ -125,13 +151,31 @@ export default function CashAccountIndex({ cashAccount, transactions }: Props) { /> +
+ + + + + + +
- @@ -140,9 +184,19 @@ export default function CashAccountIndex({ cashAccount, transactions }: Props) {
- + { + setWithdrawalOpen(open); + if (!open) { + setWithdrawalReceiptKey(null); + setWithdrawalFileMeta(null); + } + }}> - setWithdrawalOpen(false)}> + { + setWithdrawalOpen(false); + setWithdrawalReceiptKey(null); + setWithdrawalFileMeta(null); + }}> {({ errors, processing }) => ( <> @@ -166,13 +220,31 @@ export default function CashAccountIndex({ cashAccount, transactions }: Props) { /> +
+ + + + + + +
- @@ -186,12 +258,18 @@ export default function CashAccountIndex({ cashAccount, transactions }: Props) { onOpenChange={(open) => { if (!open) { setEditing(null); + setEditReceiptKey(null); + setEditFileMeta(null); } }} > {editing && ( - setEditing(null)}> + { + setEditing(null); + setEditReceiptKey(null); + setEditFileMeta(null); + }}> {({ errors, processing }) => ( <> @@ -216,13 +294,32 @@ export default function CashAccountIndex({ cashAccount, transactions }: Props) { /> +
+ + + + + + +
- diff --git a/resources/js/pages/admin/finance/cash-account/transaction-columns.tsx b/resources/js/pages/admin/finance/cash-account/transaction-columns.tsx index 550bcf6..eda194f 100644 --- a/resources/js/pages/admin/finance/cash-account/transaction-columns.tsx +++ b/resources/js/pages/admin/finance/cash-account/transaction-columns.tsx @@ -1,3 +1,4 @@ +import { ImagePreviewModal } from '@/components/image-preview-modal'; import { Button } from '@/components/ui/button'; import { Tooltip, @@ -8,6 +9,7 @@ import { import { formatCurrency } from '@/lib/utils'; import type { ColumnDef } from '@tanstack/react-table'; import { ArrowUpDown, Pencil, Trash2 } from 'lucide-react'; +import { useState } from 'react'; export type CashTransaction = { id: number; @@ -15,6 +17,8 @@ export type CashTransaction = { balance_after: number; type: 'deposit' | 'withdrawal' | 'expense' | 'transfer'; description: string; + receipt_key: string | null; + receipt_url: string | null; created_at: string; created_by: { user_profile: { @@ -61,6 +65,31 @@ function getReferenceLabel(type: string): string { return labels[type] ?? '-'; } +function ReceiptPreview({ url, title }: { url: string; title: string }) { + const [open, setOpen] = useState(false); + + return ( + <> + + + + ); +} + type CreateColumnsParams = { handleEdit: (transaction: CashTransaction) => void; handleDeleteClick: (transaction: CashTransaction) => void; @@ -173,6 +202,19 @@ export function createTransactionColumns( {row.getValue('description') as string} ), }, + { + id: 'receipt', + header: () => Bukti, + cell: ({ row }) => { + const receiptUrl = row.original.receipt_url; + + if (!receiptUrl) { + return -; + } + + return ; + }, + }, { id: 'created_by', header: () => Oleh,