feat: implement expense management module with CRUD operations, validation, and media handling for expense records
This commit is contained in:
parent
fd1c215ede
commit
a761540447
64
app/Http/Controllers/Admin/Finance/ExpenseController.php
Normal file
64
app/Http/Controllers/Admin/Finance/ExpenseController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
30
app/Http/Requests/Admin/Finance/ExpenseRequest.php
Normal file
30
app/Http/Requests/Admin/Finance/ExpenseRequest.php
Normal 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
39
app/Models/Expense.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
25
database/factories/ExpenseFactory.php
Normal file
25
database/factories/ExpenseFactory.php
Normal 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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -17,6 +17,7 @@ public function run(): void
|
||||
UserSeeder::class,
|
||||
CategorySeeder::class,
|
||||
ProductSeeder::class,
|
||||
ExpenseSeeder::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
28
database/seeders/ExpenseSeeder.php
Normal file
28
database/seeders/ExpenseSeeder.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
453
resources/js/pages/admin/finance/expense/index.tsx
Normal file
453
resources/js/pages/admin/finance/expense/index.tsx
Normal 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',
|
||||
},
|
||||
],
|
||||
};
|
||||
12
resources/js/types/expense.ts
Normal file
12
resources/js/types/expense.ts
Normal 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;
|
||||
}
|
||||
@ -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
14
routes/finance.php
Normal 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');
|
||||
});
|
||||
});
|
||||
@ -13,3 +13,4 @@
|
||||
|
||||
require __DIR__.'/settings.php';
|
||||
require __DIR__.'/master.php';
|
||||
require __DIR__.'/finance.php';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user