203 lines
8.0 KiB
TypeScript
203 lines
8.0 KiB
TypeScript
import { useForm, router } from '@inertiajs/react';
|
|
import { ImagePlus, X, Save, Loader } from 'lucide-react';
|
|
import { useEffect, useState, useRef } from 'react';
|
|
import { NumericFormat } from 'react-number-format';
|
|
import { toast } from 'sonner';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog"
|
|
import { Field, FieldError, FieldGroup } from "@/components/ui/field"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Label } from "@/components/ui/label"
|
|
import expenseRoutes from '@/routes/expense';
|
|
import type { Expense } from '@/types';
|
|
|
|
interface ExpenseFormModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
expense: Expense | null;
|
|
}
|
|
|
|
export function ExpenseFormModal({ isOpen, onClose, expense }: ExpenseFormModalProps) {
|
|
const isEditing = !!expense;
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
|
|
|
const { data, setData, post, processing, errors, reset, clearErrors } = useForm<{
|
|
name: string;
|
|
amount: string;
|
|
image: File | null;
|
|
}>({
|
|
name: '',
|
|
amount: '',
|
|
image: null,
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (expense) {
|
|
setData({
|
|
name: expense.name,
|
|
amount: expense.amount.toString(),
|
|
image: null,
|
|
});
|
|
setImagePreview(expense.proof_url || null);
|
|
} else {
|
|
reset();
|
|
setImagePreview(null);
|
|
}
|
|
}, [expense]);
|
|
|
|
const handleClose = () => {
|
|
onClose();
|
|
setTimeout(() => {
|
|
reset();
|
|
setImagePreview(null);
|
|
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 && expense) {
|
|
router.post(expenseRoutes.update(expense.id).url, {
|
|
...data,
|
|
_method: 'PATCH',
|
|
}, {
|
|
onSuccess: (response: any) => {
|
|
toast.success(response.props.flash.success);
|
|
handleClose();
|
|
},
|
|
});
|
|
} else {
|
|
post(expenseRoutes.store().url, {
|
|
onSuccess: (response: any) => {
|
|
toast.success(response.props.flash.success);
|
|
handleClose();
|
|
},
|
|
});
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>{isEditing ? 'Ubah Pengeluaran' : 'Tambah Pengeluaran'}</DialogTitle>
|
|
</DialogHeader>
|
|
<form onSubmit={onSubmit}>
|
|
<FieldGroup>
|
|
<Field>
|
|
<Label htmlFor="name" required>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}
|
|
/>
|
|
<FieldError error={errors.name} label="Nama" className="text-xs" />
|
|
</Field>
|
|
|
|
<Field>
|
|
<Label htmlFor="amount" required>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'
|
|
/>
|
|
<FieldError error={errors.amount} label="Nominal" className="text-xs" />
|
|
</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>
|
|
<FieldError error={errors.image} label="Bukti" className="text-xs mt-1" />
|
|
</Field>
|
|
</FieldGroup>
|
|
<DialogFooter className="mt-6">
|
|
<Button type="button" variant="outline" className="gap-2" onClick={handleClose}>
|
|
<X className="size-4" />
|
|
Batal
|
|
</Button>
|
|
<Button type="submit" className="gap-2" disabled={processing}>
|
|
{processing ? <Loader className="size-4 animate-spin" /> : <Save className="size-4" />}
|
|
{processing ? 'Menyimpan...' : 'Simpan'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|