feat: implement expense management module with CRUD operations, validation, and media handling for expense records

This commit is contained in:
Yoga Pangestu 2026-04-17 13:08:54 +07:00
parent fd1c215ede
commit a761540447
14 changed files with 717 additions and 1 deletions

View File

@ -0,0 +1,64 @@
<?php
namespace App\Http\Controllers\Admin\Finance;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Finance\ExpenseRequest;
use App\Models\Expense;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class ExpenseController extends Controller
{
public function index(): Response
{
return Inertia::render('admin/finance/expense/index', [
'expenses' => 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');
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests\Admin\Finance;
use Illuminate\Foundation\Http\FormRequest;
class ExpenseRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:100'],
'amount' => ['required', 'numeric', 'min:0'],
'image' => ['nullable', 'image', 'max:5120'],
];
}
}

39
app/Models/Expense.php Normal file
View File

@ -0,0 +1,39 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
class Expense extends Model implements HasMedia
{
use HasFactory, InteractsWithMedia, SoftDeletes;
protected $guarded = ['id'];
protected $appends = ['proof_url'];
protected function casts(): array
{
return [
'amount' => 'integer',
];
}
protected function proofUrl(): Attribute
{
return Attribute::make(
get: fn () => $this->getFirstMediaUrl('proof') ?: null,
);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@ -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);
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace Database\Factories;
use App\Models\Expense;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Expense>
*/
class ExpenseFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => $this->faker->sentence(),
'amount' => $this->faker->numberBetween(10000, 1000000),
];
}
}

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('expenses', function (Blueprint $table) {
$table->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');
}
};

View File

@ -17,6 +17,7 @@ public function run(): void
UserSeeder::class,
CategorySeeder::class,
ProductSeeder::class,
ExpenseSeeder::class,
]);
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace Database\Seeders;
use App\Models\Expense;
use App\Models\User;
use Illuminate\Database\Seeder;
class ExpenseSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$user = User::first();
$expenses = [
['name' => '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);
}
}
}

View File

@ -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 (
<Sidebar collapsible="icon" variant="inset">
@ -55,6 +64,7 @@ export function AppSidebar() {
<SidebarContent>
<NavMain items={mainNavItems} />
<NavMain items={masterNavItems} label='Master' />
<NavMain items={financeNavItems} label='Keuangan' />
</SidebarContent>
</Sidebar>
);

View File

@ -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<Expense | null>(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<string | null>(null);
const [selectedProof, setSelectedProof] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const [expenseToDelete, setExpenseToDelete] = useState<Expense | null>(null);
const [rowsToDelete, setRowsToDelete] = useState<any[]>([]);
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<HTMLInputElement>) => {
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<Expense>[] = [
{
accessorKey: "proof_url",
header: "Bukti",
cell: ({ row }) => {
const url = row.original.proof_url;
return url ? (
<button onClick={() => setSelectedProof(url)} className="block w-fit">
<img src={url} alt="Proof" className="h-10 w-10 object-cover rounded-md border hover:opacity-80 transition-opacity" />
</button>
) : (
<div className="h-10 w-10 flex items-center justify-center bg-muted rounded-md border">
<ImagePlus className="h-4 w-4 text-muted-foreground" />
</div>
);
},
meta: { title: "Bukti" },
},
{
accessorKey: "user.name",
header: ({ column }) => {
return (
<DataTableColumnHeader column={column} title="Petugas" />
)
},
cell: ({ row }) => row.original.user?.name,
meta: { title: "Petugas" },
},
{
accessorKey: "name",
header: ({ column }) => {
return (
<DataTableColumnHeader column={column} title="Nama" />
)
},
meta: { title: "Nama" },
},
{
accessorKey: "amount",
header: ({ column }) => {
return (
<DataTableColumnHeader column={column} title="Nominal" />
)
},
cell: ({ row }) => formatCurrency(row.original.amount),
meta: { title: "Nominal" },
},
{
accessorKey: "created_at",
header: ({ column }) => {
return (
<DataTableColumnHeader column={column} title="Tanggal" />
)
},
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 (
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger>
<Button variant="ghost" size="icon" className='text-yellow-600' onClick={() => onEdit(expense)}>
<Pencil className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Ubah</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className='text-red-600' onClick={() => onDelete(expense)}>
<Trash2 className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Hapus</p>
</TooltipContent>
</Tooltip>
</div>
);
},
meta: { title: "Aksi" },
},
];
return (
<div className="flex flex-col gap-6 p-6">
<Head title="Pengeluaran" />
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Pengeluaran</h1>
</div>
<Button onClick={() => setIsOpen(true)}>
Tambah
</Button>
</div>
<Dialog open={isOpen} onOpenChange={(open) => !open && closeModal()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{isEditing ? 'Ubah Pengeluaran' : 'Tambah Pengeluaran'}</DialogTitle>
</DialogHeader>
<form onSubmit={onSubmit}>
<FieldGroup>
<Field>
<Label htmlFor="name">Nama</Label>
<Input
id="name"
name="name"
value={data.name}
onChange={e => setData('name', e.target.value)}
autoComplete='off'
placeholder='Contoh: Bayar Listrik'
maxLength={100}
/>
{errors.name && <p className="text-xs text-red-500">{errors.name}</p>}
</Field>
<Field>
<Label htmlFor="amount">Nominal</Label>
<NumericFormat
id="amount"
customInput={Input}
thousandSeparator="."
decimalSeparator=","
prefix="Rp "
value={data.amount}
onValueChange={(values) => {
setData('amount', values.value)
}}
placeholder="Rp 0"
autoComplete='off'
/>
{errors.amount && <p className="text-xs text-red-500">{errors.amount}</p>}
</Field>
<Field>
<Label>Bukti</Label>
<div className="mt-2">
{imagePreview ? (
<div className="relative inline-block">
<img
src={imagePreview}
alt="Preview"
className="object-cover rounded-lg border shadow-sm"
/>
<button
type="button"
onClick={removeImage}
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1 shadow-md hover:bg-red-600 transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
) : (
<div
onClick={() => 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"
>
<ImagePlus className="h-8 w-8 text-muted-foreground mb-2" />
<span className="text-sm text-muted-foreground font-medium">Klik untuk upload bukti</span>
<span className="text-xs text-muted-foreground mt-1">PNG, JPG up to 5MB</span>
</div>
)}
<input
type="file"
ref={fileInputRef}
className="hidden"
accept="image/*"
onChange={onImageChange}
/>
</div>
{errors.image && <p className="text-xs text-red-500 mt-1">{errors.image}</p>}
</Field>
</FieldGroup>
<DialogFooter className="mt-6">
<Button type="button" variant="outline" onClick={closeModal}>Batal</Button>
<Button type="submit" disabled={processing}>
{processing ? 'Menyimpan...' : 'Simpan'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Dialog open={!!selectedProof} onOpenChange={() => setSelectedProof(null)}>
<DialogContent className="max-w-3xl p-0 overflow-hidden border-none bg-transparent shadow-none">
<div className="relative group">
<img
src={selectedProof || ''}
alt="Proof"
className="w-full h-auto max-h-[80vh] object-contain rounded-lg"
/>
<button
onClick={() => setSelectedProof(null)}
className="absolute top-4 right-4 bg-black/50 hover:bg-black/70 text-white rounded-full p-2 backdrop-blur-sm transition-all shadow-xl"
>
<X className="size-5" />
</button>
</div>
</DialogContent>
</Dialog>
<Card className="overflow-hidden border-none shadow-lg bg-card/50 backdrop-blur-sm p-5">
<CardContent className="p-0">
<DataTable
columns={columns}
data={expenses}
rowSelection={rowSelection}
onRowSelectionChange={setRowSelection}
bulkActions={[
{
label: 'Hapus Terpilih',
onClick: (rows) => {
setRowsToDelete(rows);
setIsBulkDeleteDialogOpen(true);
},
icon: Trash2,
variant: 'destructive'
},
]}
/>
</CardContent>
</Card>
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent size="default">
<AlertDialogHeader>
<AlertDialogMedia className="bg-destructive/10 text-destructive dark:bg-destructive/20 dark:text-destructive">
<Trash2 className="size-5" />
</AlertDialogMedia>
<AlertDialogTitle>Hapus pengeluaran?</AlertDialogTitle>
<AlertDialogDescription>
Tindakan ini tidak dapat dibatalkan. Pengeluaran <strong>{expenseToDelete?.name}</strong> akan dihapus secara permanen.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel variant="outline">Batal</AlertDialogCancel>
<AlertDialogAction onClick={confirmDelete} variant="destructive">Hapus</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={isBulkDeleteDialogOpen} onOpenChange={setIsBulkDeleteDialogOpen}>
<AlertDialogContent size="default">
<AlertDialogHeader>
<AlertDialogMedia className="bg-destructive/10 text-destructive dark:bg-destructive/20 dark:text-destructive">
<Trash2 className="size-5" />
</AlertDialogMedia>
<AlertDialogTitle>Hapus {rowsToDelete.length} pengeluaran?</AlertDialogTitle>
<AlertDialogDescription>
Tindakan ini tidak dapat dibatalkan. <strong>{rowsToDelete.length}</strong> item yang terpilih akan dihapus secara permanen.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel variant="outline">Batal</AlertDialogCancel>
<AlertDialogAction
onClick={confirmBulkDelete}
variant="destructive"
>
Hapus
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
ExpenseIndex.layout = {
breadcrumbs: [
{
title: 'Finance',
},
],
};

View File

@ -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;
}

View File

@ -3,3 +3,4 @@ export type * from './navigation';
export type * from './ui';
export type * from './category';
export type * from './product';
export type * from './expense';

14
routes/finance.php Normal file
View File

@ -0,0 +1,14 @@
<?php
use App\Http\Controllers\Admin\Finance\ExpenseController;
use Illuminate\Support\Facades\Route;
Route::middleware(['auth'])->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');
});
});

View File

@ -13,3 +13,4 @@
require __DIR__.'/settings.php';
require __DIR__.'/master.php';
require __DIR__.'/finance.php';