From a7615404474814e4bff7717da529b1b72887e428 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Fri, 17 Apr 2026 13:08:54 +0700 Subject: [PATCH] feat: implement expense management module with CRUD operations, validation, and media handling for expense records --- .../Admin/Finance/ExpenseController.php | 64 +++ .../Requests/Admin/Finance/ExpenseRequest.php | 30 ++ app/Models/Expense.php | 39 ++ app/Models/User.php | 6 + database/factories/ExpenseFactory.php | 25 + ...026_04_17_111050_create_expenses_table.php | 32 ++ database/seeders/DatabaseSeeder.php | 1 + database/seeders/ExpenseSeeder.php | 28 ++ resources/js/components/app-sidebar.tsx | 12 +- .../js/pages/admin/finance/expense/index.tsx | 453 ++++++++++++++++++ resources/js/types/expense.ts | 12 + resources/js/types/index.ts | 1 + routes/finance.php | 14 + routes/web.php | 1 + 14 files changed, 717 insertions(+), 1 deletion(-) create mode 100644 app/Http/Controllers/Admin/Finance/ExpenseController.php create mode 100644 app/Http/Requests/Admin/Finance/ExpenseRequest.php create mode 100644 app/Models/Expense.php create mode 100644 database/factories/ExpenseFactory.php create mode 100644 database/migrations/2026_04_17_111050_create_expenses_table.php create mode 100644 database/seeders/ExpenseSeeder.php create mode 100644 resources/js/pages/admin/finance/expense/index.tsx create mode 100644 resources/js/types/expense.ts create mode 100644 routes/finance.php diff --git a/app/Http/Controllers/Admin/Finance/ExpenseController.php b/app/Http/Controllers/Admin/Finance/ExpenseController.php new file mode 100644 index 0000000..962bbe5 --- /dev/null +++ b/app/Http/Controllers/Admin/Finance/ExpenseController.php @@ -0,0 +1,64 @@ + Expense::with(['media', 'user'])->latest()->get(), + ]); + } + + public function store(ExpenseRequest $request): RedirectResponse + { + $validated = $request->validated(); + $validated['user_id'] = auth()->id(); + + $expense = Expense::create($validated); + + if ($request->hasFile('image')) { + $expense->addMediaFromRequest('image')->toMediaCollection('proof'); + } + + return redirect()->back()->with('success', 'Data berhasil disimpan'); + } + + public function update(ExpenseRequest $request, Expense $expense): RedirectResponse + { + $validated = $request->validated(); + + $expense->update($validated); + + if ($request->hasFile('image')) { + $expense->addMediaFromRequest('image')->toMediaCollection('proof'); + } + + return redirect()->back()->with('success', 'Data berhasil diperbarui'); + } + + public function destroy(Expense $expense): RedirectResponse + { + $expense->delete(); + + return redirect()->back()->with('success', 'Data berhasil dihapus'); + } + + public function bulkDestroy(Request $request): RedirectResponse + { + $ids = $request->input('ids'); + + Expense::whereIn('id', $ids)->delete(); + + return redirect()->back()->with('success', 'Data terpilih berhasil dihapus'); + } +} diff --git a/app/Http/Requests/Admin/Finance/ExpenseRequest.php b/app/Http/Requests/Admin/Finance/ExpenseRequest.php new file mode 100644 index 0000000..8486a03 --- /dev/null +++ b/app/Http/Requests/Admin/Finance/ExpenseRequest.php @@ -0,0 +1,30 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:100'], + 'amount' => ['required', 'numeric', 'min:0'], + 'image' => ['nullable', 'image', 'max:5120'], + ]; + } +} diff --git a/app/Models/Expense.php b/app/Models/Expense.php new file mode 100644 index 0000000..d7ce399 --- /dev/null +++ b/app/Models/Expense.php @@ -0,0 +1,39 @@ + 'integer', + ]; + } + + protected function proofUrl(): Attribute + { + return Attribute::make( + get: fn () => $this->getFirstMediaUrl('proof') ?: null, + ); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index f40a71d..5e1baf1 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Fortify\TwoFactorAuthenticatable; @@ -31,4 +32,9 @@ protected function casts(): array 'two_factor_confirmed_at' => 'datetime', ]; } + + public function expenses(): HasMany + { + return $this->hasMany(Expense::class); + } } diff --git a/database/factories/ExpenseFactory.php b/database/factories/ExpenseFactory.php new file mode 100644 index 0000000..b13ec98 --- /dev/null +++ b/database/factories/ExpenseFactory.php @@ -0,0 +1,25 @@ + + */ +class ExpenseFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => $this->faker->sentence(), + 'amount' => $this->faker->numberBetween(10000, 1000000), + ]; + } +} diff --git a/database/migrations/2026_04_17_111050_create_expenses_table.php b/database/migrations/2026_04_17_111050_create_expenses_table.php new file mode 100644 index 0000000..b6d1b60 --- /dev/null +++ b/database/migrations/2026_04_17_111050_create_expenses_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('name', 100); + $table->unsignedInteger('amount'); + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('updated_at')->nullable()->useCurrentOnUpdate(); + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('expenses'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 278e9e8..eda85a8 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -17,6 +17,7 @@ public function run(): void UserSeeder::class, CategorySeeder::class, ProductSeeder::class, + ExpenseSeeder::class, ]); } } diff --git a/database/seeders/ExpenseSeeder.php b/database/seeders/ExpenseSeeder.php new file mode 100644 index 0000000..2066107 --- /dev/null +++ b/database/seeders/ExpenseSeeder.php @@ -0,0 +1,28 @@ + 'Listrik Toko', 'amount' => 120000, 'user_id' => $user->id], + ['name' => 'Wifi Bulanan', 'amount' => 300000, 'user_id' => $user->id], + ['name' => 'Sampah Bulanan', 'amount' => 100000, 'user_id' => $user->id], + ]; + + foreach ($expenses as $expense) { + Expense::create($expense); + } + } +} diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index d1c7874..01da09e 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -1,5 +1,5 @@ import { Link } from '@inertiajs/react'; -import { Boxes, LayoutGrid, List } from 'lucide-react'; +import { Boxes, LayoutGrid, List, Wallet } from 'lucide-react'; import AppLogo from '@/components/app-logo'; import { NavMain } from '@/components/nav-main'; import { @@ -15,6 +15,7 @@ import category from '@/routes/category'; import type { NavItem } from '@/types'; import product from '@/routes/product'; +import expense from '@/routes/expense'; const mainNavItems: NavItem[] = [ { @@ -37,6 +38,14 @@ const masterNavItems: NavItem[] = [ }, ]; +const financeNavItems: NavItem[] = [ + { + title: 'Pengeluaran', + href: expense.index().url, + icon: Wallet, + }, +]; + export function AppSidebar() { return ( @@ -55,6 +64,7 @@ export function AppSidebar() { + ); diff --git a/resources/js/pages/admin/finance/expense/index.tsx b/resources/js/pages/admin/finance/expense/index.tsx new file mode 100644 index 0000000..7724e50 --- /dev/null +++ b/resources/js/pages/admin/finance/expense/index.tsx @@ -0,0 +1,453 @@ +import { Head, useForm, router } from '@inertiajs/react'; +import type { Expense } from '@/types'; +import { ColumnDef } from '@tanstack/react-table'; +import { Card, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Trash2, Pencil, ImagePlus, X } from 'lucide-react'; +import { DataTable } from '@/components/data-table'; +import { DataTableColumnHeader } from '@/components/data-table-column-header'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Field, FieldGroup } from "@/components/ui/field" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { useState, useRef } from 'react'; +import { toast } from 'sonner'; +import { TooltipContent, TooltipTrigger } from '@radix-ui/react-tooltip'; +import { Tooltip } from '@/components/ui/tooltip'; +import expenseRoutes from '@/routes/expense'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { NumericFormat } from 'react-number-format'; + +export default function ExpenseIndex({ expenses }: { expenses: Expense[] }) { + const [isOpen, setIsOpen] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const [selectedExpense, setSelectedExpense] = useState(null); + + const { data, setData, post, patch, processing, errors, reset, clearErrors } = useForm<{ + name: string; + amount: string; + image: File | null; + }>({ + name: '', + amount: '', + image: null, + }); + + const [imagePreview, setImagePreview] = useState(null); + const [selectedProof, setSelectedProof] = useState(null); + const fileInputRef = useRef(null); + + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false); + const [expenseToDelete, setExpenseToDelete] = useState(null); + const [rowsToDelete, setRowsToDelete] = useState([]); + const [rowSelection, setRowSelection] = useState({}); + + const onEdit = (expense: Expense) => { + setIsEditing(true); + setSelectedExpense(expense); + setData({ + name: expense.name, + amount: expense.amount.toString(), + image: null, + }); + setImagePreview(expense.proof_url || null); + setIsOpen(true); + }; + + const onDelete = (expense: Expense) => { + setExpenseToDelete(expense); + setIsDeleteDialogOpen(true); + }; + + const confirmDelete = () => { + if (expenseToDelete) { + router.delete(expenseRoutes.destroy(expenseToDelete.id).url, { + onSuccess: (response: any) => { + toast.success(response.props.flash.success); + setIsDeleteDialogOpen(false); + setExpenseToDelete(null); + setRowSelection({}); + }, + }); + } + }; + + const confirmBulkDelete = () => { + router.post(expenseRoutes.bulkDestroy().url, { + ids: rowsToDelete.map((row: any) => row.id), + _method: 'DELETE' + }, { + onSuccess: (response: any) => { + toast.success(response.props.flash.success); + setIsBulkDeleteDialogOpen(false); + setRowsToDelete([]); + setRowSelection({}); + }, + }); + }; + + const closeModal = () => { + setIsOpen(false); + setTimeout(() => { + setIsEditing(false); + setSelectedExpense(null); + setImagePreview(null); + reset(); + clearErrors(); + }, 200); + }; + + const onImageChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + setData('image', file); + const reader = new FileReader(); + reader.onloadend = () => { + setImagePreview(reader.result as string); + }; + reader.readAsDataURL(file); + } + }; + + const removeImage = () => { + setData('image', null); + setImagePreview(null); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + const onSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (isEditing && selectedExpense) { + router.post(expenseRoutes.update(selectedExpense.id).url, { + ...data, + _method: 'PATCH', + }, { + onSuccess: (response: any) => { + toast.success(response.props.flash.success); + closeModal(); + }, + }); + } else { + post(expenseRoutes.store().url, { + onSuccess: (response: any) => { + toast.success(response.props.flash.success); + closeModal(); + }, + }); + } + }; + + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat('id-ID', { + style: 'currency', + currency: 'IDR', + minimumFractionDigits: 0, + }).format(amount); + }; + + const columns: ColumnDef[] = [ + { + accessorKey: "proof_url", + header: "Bukti", + cell: ({ row }) => { + const url = row.original.proof_url; + return url ? ( + + ) : ( +
+ +
+ ); + }, + meta: { title: "Bukti" }, + }, + { + accessorKey: "user.name", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => row.original.user?.name, + meta: { title: "Petugas" }, + }, + { + accessorKey: "name", + header: ({ column }) => { + return ( + + ) + }, + meta: { title: "Nama" }, + }, + { + accessorKey: "amount", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => formatCurrency(row.original.amount), + meta: { title: "Nominal" }, + }, + { + accessorKey: "created_at", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => new Date(row.original.created_at).toLocaleDateString('id-ID', { + day: '2-digit', + month: 'long', + year: 'numeric' + }), + meta: { title: "Tanggal" }, + }, + { + id: "actions", + header: "Aksi", + cell: ({ row }) => { + const expense = row.original; + return ( +
+ + + + + +

Ubah

+
+
+ + + + + +

Hapus

+
+
+
+ ); + }, + meta: { title: "Aksi" }, + }, + ]; + + return ( +
+ + +
+
+

Pengeluaran

+
+ +
+ + !open && closeModal()}> + + + {isEditing ? 'Ubah Pengeluaran' : 'Tambah Pengeluaran'} + +
+ + + + setData('name', e.target.value)} + autoComplete='off' + placeholder='Contoh: Bayar Listrik' + maxLength={100} + /> + {errors.name &&

{errors.name}

} +
+ + + + { + setData('amount', values.value) + }} + placeholder="Rp 0" + autoComplete='off' + /> + {errors.amount &&

{errors.amount}

} +
+ + + +
+ {imagePreview ? ( +
+ Preview + +
+ ) : ( +
fileInputRef.current?.click()} + className="h-40 w-full flex flex-col items-center justify-center border-2 border-dashed rounded-lg cursor-pointer hover:bg-accent/50 transition-colors" + > + + Klik untuk upload bukti + PNG, JPG up to 5MB +
+ )} + +
+ {errors.image &&

{errors.image}

} +
+
+ + + + +
+
+
+ + setSelectedProof(null)}> + +
+ Proof + +
+
+
+ + + + { + setRowsToDelete(rows); + setIsBulkDeleteDialogOpen(true); + }, + icon: Trash2, + variant: 'destructive' + }, + ]} + /> + + + + + + + + + + Hapus pengeluaran? + + Tindakan ini tidak dapat dibatalkan. Pengeluaran {expenseToDelete?.name} akan dihapus secara permanen. + + + + Batal + Hapus + + + + + + + + + + + Hapus {rowsToDelete.length} pengeluaran? + + Tindakan ini tidak dapat dibatalkan. {rowsToDelete.length} item yang terpilih akan dihapus secara permanen. + + + + Batal + + Hapus + + + + +
+ ); +} + +ExpenseIndex.layout = { + breadcrumbs: [ + { + title: 'Finance', + }, + ], +}; diff --git a/resources/js/types/expense.ts b/resources/js/types/expense.ts new file mode 100644 index 0000000..4d1be7d --- /dev/null +++ b/resources/js/types/expense.ts @@ -0,0 +1,12 @@ +import { User } from "./auth"; + +export interface Expense { + id: number; + user_id: number; + name: string; + amount: number; + proof_url?: string | null; + user?: User; + created_at: string; + updated_at: string; +} diff --git a/resources/js/types/index.ts b/resources/js/types/index.ts index 7533781..00169cf 100644 --- a/resources/js/types/index.ts +++ b/resources/js/types/index.ts @@ -3,3 +3,4 @@ export type * from './navigation'; export type * from './ui'; export type * from './category'; export type * from './product'; +export type * from './expense'; diff --git a/routes/finance.php b/routes/finance.php new file mode 100644 index 0000000..041f764 --- /dev/null +++ b/routes/finance.php @@ -0,0 +1,14 @@ +group(function () { + Route::prefix('admin/finance')->group(function () { + Route::get('expenses', [ExpenseController::class, 'index'])->name('expense.index'); + Route::post('expense/store', [ExpenseController::class, 'store'])->name('expense.store'); + Route::patch('expense/update/{expense}', [ExpenseController::class, 'update'])->name('expense.update'); + Route::delete('expense/destroy/{expense}', [ExpenseController::class, 'destroy'])->name('expense.destroy'); + Route::delete('expense/bulk-destroy', [ExpenseController::class, 'bulkDestroy'])->name('expense.bulkDestroy'); + }); +}); diff --git a/routes/web.php b/routes/web.php index 84bd43a..b57947d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -13,3 +13,4 @@ require __DIR__.'/settings.php'; require __DIR__.'/master.php'; +require __DIR__.'/finance.php';