diff --git a/app/Http/Requests/Admin/Finance/ExpenseRequest.php b/app/Http/Requests/Admin/Finance/ExpenseRequest.php index cf1ed4e..47ab65e 100644 --- a/app/Http/Requests/Admin/Finance/ExpenseRequest.php +++ b/app/Http/Requests/Admin/Finance/ExpenseRequest.php @@ -27,7 +27,7 @@ public function rules(): array return [ 'amount' => ['required', 'integer', 'min:1'], 'description' => ['required', 'string', 'max:100'], - 'receipt_key' => ['nullable', 'string', 'max:500'], + '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'], ]; diff --git a/app/Models/Expense.php b/app/Models/Expense.php index 5e0385d..acc9251 100644 --- a/app/Models/Expense.php +++ b/app/Models/Expense.php @@ -7,11 +7,13 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\SoftDeletes; +use Spatie\MediaLibrary\HasMedia; +use Spatie\MediaLibrary\InteractsWithMedia; #[Guarded(['id'])] -class Expense extends Model +class Expense extends Model implements HasMedia { - use HasFactory, SoftDeletes; + use HasFactory, SoftDeletes, InteractsWithMedia; protected function casts(): array { diff --git a/app/Services/Admin/Finance/ExpenseService.php b/app/Services/Admin/Finance/ExpenseService.php index 7d78406..94513f5 100644 --- a/app/Services/Admin/Finance/ExpenseService.php +++ b/app/Services/Admin/Finance/ExpenseService.php @@ -6,17 +6,50 @@ use App\Models\CashAccount; use App\Models\CashTransaction; use App\Models\Expense; +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 ExpenseService { + public function __construct( + private S3PresignedService $s3Service = new S3PresignedService, + ) {} + public function getAll(): Collection { - return Expense::with('createdBy.userProfile') + return Expense::select('id', 'created_by_id', 'amount', 'description', 'created_at') + ->with('createdBy.userProfile', 'media') ->latest() - ->get(); + ->get() + ->map(fn (Expense $expense) => $this->formatExpense($expense)); + } + + private function formatExpense(Expense $expense): array + { + $media = $expense->getFirstMedia('receipts'); + + if (! $media) { + return $expense->toArray() + [ + 'receipt_key' => null, + 'receipt_url' => null, + ]; + } + + $s3Key = $media->file_name; + + // Handle old data where file_name is just the filename, not full path + if (str_starts_with($s3Key, '/') || ! str_contains($s3Key, '/')) { + $s3Key = $media->getPath(); + } + + return $expense->toArray() + [ + 'receipt_key' => $s3Key, + 'receipt_url' => $this->s3Service->getTemporaryUrl($s3Key), + ]; } public function create(array $data): Expense @@ -42,12 +75,18 @@ public function create(array $data): Expense 'description' => $data['description'], ]); - return Expense::create([ + $expense = Expense::create([ 'cash_transaction_id' => $cashTransaction->id, 'created_by_id' => auth()->id(), 'amount' => $data['amount'], 'description' => $data['description'], ]); + + if (! empty($data['receipt_key'])) { + $this->registerMedia($expense, $data['receipt_key'], $data['file_size'] ?? null, $data['file_mime_type'] ?? null); + } + + return $expense; }); } @@ -81,6 +120,24 @@ public function update(Expense $expense, array $data): Expense 'description' => $data['description'], ]); + if (array_key_exists('receipt_key', $data)) { + $currentMedia = $expense->getFirstMedia('receipts'); + $currentKey = $currentMedia?->file_name; + + // Normalize: if file_name is just a filename (old data), use getPath() + if ($currentMedia && ! str_contains($currentKey, '/')) { + $currentKey = $currentMedia->getPath(); + } + + if ($data['receipt_key'] !== $currentKey) { + $expense->clearMediaCollection('receipts'); + + if (! empty($data['receipt_key'])) { + $this->registerMedia($expense, $data['receipt_key'], $data['file_size'] ?? null, $data['file_mime_type'] ?? null); + } + } + } + return $expense; }); } @@ -89,12 +146,37 @@ public function delete(Expense $expense): bool { return DB::transaction(function () use ($expense) { $cashAccount = CashAccount::firstOrFail(); - $cashTransaction = $expense->cashTransaction; $newBalance = $cashAccount->balance + $expense->amount; $cashAccount->update(['balance' => $newBalance]); + $expense->clearMediaCollection('receipts'); + return $expense->delete(); }); } + + private function registerMedia(Expense $expense, string $s3Key, ?int $fileSize = null, ?string $mimeType = null): void + { + $fileName = pathinfo($s3Key, PATHINFO_BASENAME); + $name = pathinfo($s3Key, PATHINFO_FILENAME); + + Media::create([ + 'model_type' => Expense::class, + 'model_id' => $expense->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/expense/columns.tsx b/resources/js/pages/admin/finance/expense/columns.tsx index 50cdb8b..f2518fc 100644 --- a/resources/js/pages/admin/finance/expense/columns.tsx +++ b/resources/js/pages/admin/finance/expense/columns.tsx @@ -1,5 +1,4 @@ -import type { ColumnDef } from '@tanstack/react-table'; -import { ArrowUpDown, Pencil, Trash2 } from 'lucide-react'; +import { ImagePreviewModal } from '@/components/image-preview-modal'; import { Button } from '@/components/ui/button'; import { Tooltip, @@ -8,11 +7,16 @@ import { TooltipTrigger, } from '@/components/ui/tooltip'; 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 Expense = { id: number; amount: number; description: string; + receipt_key: string | null; + receipt_url: string | null; created_at: string; created_by: { user_profile: { @@ -39,6 +43,31 @@ type CreateColumnsParams = { handleDeleteClick: (expense: Expense) => void; }; +function ReceiptPreview({ url, title }: { url: string; title: string }) { + const [open, setOpen] = useState(false); + + return ( + <> + setOpen(true)} + className="block h-10 w-10 overflow-hidden rounded-md border bg-muted transition-opacity hover:opacity-80" + > + + + + > + ); +} + export function createExpenseColumns( params: CreateColumnsParams, ): ColumnDef[] { @@ -85,6 +114,19 @@ export function createExpenseColumns( {row.getValue('description') as string} ), }, + { + id: 'receipt', + header: () => Bukti, + cell: ({ row }) => { + const receiptUrl = row.original.receipt_url; + + if (!receiptUrl) { + return -; + } + + return ; + }, + }, { accessorKey: 'amount', header: ({ column }) => ( @@ -114,7 +156,6 @@ export function createExpenseColumns( const createdBy = row.original.created_by; return {createdBy?.user_profile?.full_name ?? '-'}; - }, }, { diff --git a/resources/js/pages/admin/finance/expense/index.tsx b/resources/js/pages/admin/finance/expense/index.tsx index be02026..3d88d98 100644 --- a/resources/js/pages/admin/finance/expense/index.tsx +++ b/resources/js/pages/admin/finance/expense/index.tsx @@ -1,6 +1,9 @@ import { Form, Head, router } from '@inertiajs/react'; import { Plus } from 'lucide-react'; import { useState } from 'react'; +import { ConfirmDialog } from '@/components/confirm-dialog'; +import { DataTable } from '@/components/data-table'; +import { FileUpload } from '@/components/file-upload'; import InputError from '@/components/input-error'; import { RupiahInput } from '@/components/rupiah-input'; import { Button } from '@/components/ui/button'; @@ -16,8 +19,6 @@ import { Label } from '@/components/ui/label'; import { destroy, index as expenseIndex, store, update } from '@/routes/admin/finance/expenses'; import { createExpenseColumns } from './columns'; import type { Expense } from './columns'; -import { ConfirmDialog } from '@/components/confirm-dialog'; -import { DataTable } from '@/components/data-table'; type Props = { expenses: Expense[]; @@ -27,6 +28,12 @@ export default function ExpenseIndex({ expenses }: Props) { const [createOpen, setCreateOpen] = useState(false); const [editing, setEditing] = useState(null); const [deleting, setDeleting] = useState(null); + const [createReceiptKey, setCreateReceiptKey] = useState(null); + const [editReceiptKey, setEditReceiptKey] = useState(null); + const [createUploading, setCreateUploading] = useState(false); + const [editUploading, setEditUploading] = useState(false); + const [createFileMeta, setCreateFileMeta] = useState<{ size: number; type: string } | null>(null); + const [editFileMeta, setEditFileMeta] = useState<{ size: number; type: string } | null>(null); function handleDelete() { if (!deleting) { @@ -39,7 +46,10 @@ export default function ExpenseIndex({ expenses }: Props) { } const columns = createExpenseColumns({ - handleEdit: (expense) => setEditing(expense), + handleEdit: (expense) => { + setEditing(expense); + setEditReceiptKey(expense.receipt_key ?? null); + }, handleDeleteClick: (expense) => setDeleting(expense), }); @@ -54,7 +64,14 @@ export default function ExpenseIndex({ expenses }: Props) { Pengeluaran - + { + setCreateOpen(open); + + if (!open) { + setCreateReceiptKey(null); + setCreateFileMeta(null); + } + }}> - setCreateOpen(false)}> + { + setCreateOpen(false); + setCreateReceiptKey(null); + setCreateFileMeta(null); + }}> {({ errors, processing }) => { return ( @@ -92,6 +113,20 @@ export default function ExpenseIndex({ expenses }: Props) { /> + + Bukti{' '} * + + + + + + {processing ? 'Menyimpan...' - : 'Simpan'} + : createUploading + ? 'Mengunggah...' + : 'Simpan'} > @@ -131,12 +168,18 @@ export default function ExpenseIndex({ expenses }: Props) { onOpenChange={(open) => { if (!open) { setEditing(null); + setEditReceiptKey(null); + setEditFileMeta(null); } }} > {editing && ( - setEditing(null)}> + { + setEditing(null); + setEditReceiptKey(null); + setEditFileMeta(null); + }}> {({ errors, processing }) => { return ( @@ -160,6 +203,21 @@ export default function ExpenseIndex({ expenses }: Props) { /> + + Bukti{' '} * + + + + + + {processing ? 'Menyimpan...' - : 'Simpan'} + : editUploading + ? 'Mengunggah...' + : 'Simpan'} >