232 lines
9.3 KiB
TypeScript
232 lines
9.3 KiB
TypeScript
import { useEffect } from "react";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/Components/ui/dialog";
|
||
import { Button } from "@/Components/ui/button";
|
||
import { Input } from "@/Components/ui/input";
|
||
import { Label } from "@/Components/ui/label";
|
||
import { Textarea } from "@/Components/ui/textarea";
|
||
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||
import { useForm } from "@inertiajs/react";
|
||
import { toast } from "sonner";
|
||
import { Calendar } from "lucide-react";
|
||
import { getCurrentDate } from "@/utils/format";
|
||
import { validateInvoiceNumber } from "@/utils/validation";
|
||
|
||
export interface UtilityFee {
|
||
id: number;
|
||
transaction_date: string;
|
||
category: string;
|
||
amount: number | string;
|
||
invoice_number?: string;
|
||
description?: string;
|
||
created_at: string;
|
||
updated_at: string;
|
||
}
|
||
|
||
interface UtilityFeeDialogProps {
|
||
open: boolean;
|
||
onOpenChange: (open: boolean) => void;
|
||
fee: UtilityFee | null;
|
||
availableCategories: string[];
|
||
}
|
||
|
||
const DEFAULT_CATEGORIES = [
|
||
"電費",
|
||
"水費",
|
||
"瓦斯費",
|
||
"電話費",
|
||
"網路費",
|
||
"清潔費",
|
||
"管理費",
|
||
];
|
||
|
||
export default function UtilityFeeDialog({
|
||
open,
|
||
onOpenChange,
|
||
fee,
|
||
availableCategories,
|
||
}: UtilityFeeDialogProps) {
|
||
const { data, setData, post, put, processing, errors, reset, clearErrors } = useForm({
|
||
transaction_date: getCurrentDate(),
|
||
category: "",
|
||
amount: "",
|
||
invoice_number: "",
|
||
description: "",
|
||
});
|
||
|
||
// Combine default and available categories
|
||
const categories = Array.from(new Set([...DEFAULT_CATEGORIES, ...availableCategories]));
|
||
|
||
useEffect(() => {
|
||
if (open) {
|
||
clearErrors();
|
||
if (fee) {
|
||
setData({
|
||
transaction_date: fee.transaction_date.split("T")[0].split(" ")[0],
|
||
category: fee.category,
|
||
amount: fee.amount.toString(),
|
||
invoice_number: fee.invoice_number || "",
|
||
description: fee.description || "",
|
||
});
|
||
} else {
|
||
reset();
|
||
setData("transaction_date", getCurrentDate());
|
||
}
|
||
}
|
||
}, [open, fee]);
|
||
|
||
const handleSubmit = (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
|
||
if (fee) {
|
||
const validation = validateInvoiceNumber(data.invoice_number);
|
||
if (!validation.isValid) {
|
||
toast.error(validation.error);
|
||
return;
|
||
}
|
||
|
||
put(route("utility-fees.update", fee.id), {
|
||
onSuccess: () => {
|
||
toast.success("紀錄已更新");
|
||
onOpenChange(false);
|
||
reset();
|
||
},
|
||
onError: () => {
|
||
toast.error("更新失敗,請檢查輸入資料");
|
||
}
|
||
});
|
||
} else {
|
||
const validation = validateInvoiceNumber(data.invoice_number);
|
||
if (!validation.isValid) {
|
||
toast.error(validation.error);
|
||
return;
|
||
}
|
||
|
||
post(route("utility-fees.store"), {
|
||
onSuccess: () => {
|
||
toast.success("公共事業費已記錄");
|
||
onOpenChange(false);
|
||
reset();
|
||
},
|
||
onError: () => {
|
||
toast.error("紀錄失敗,請檢查輸入資料");
|
||
}
|
||
});
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
<DialogContent className="sm:max-w-[500px]">
|
||
<DialogHeader>
|
||
<DialogTitle>{fee ? "編輯費用紀錄" : "新增費用紀錄"}</DialogTitle>
|
||
<DialogDescription>
|
||
{fee ? "修改此筆公共事業費的詳細資訊" : "記錄一筆新的公共事業費支出"}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<form onSubmit={handleSubmit} className="space-y-4 py-2">
|
||
<div className="grid grid-cols-1 gap-4">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="transaction_date">
|
||
費用日期 <span className="text-red-500">*</span>
|
||
</Label>
|
||
<div className="relative">
|
||
<Calendar className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-400 pointer-events-none" />
|
||
<Input
|
||
id="transaction_date"
|
||
type="date"
|
||
value={data.transaction_date}
|
||
onChange={(e) => setData("transaction_date", e.target.value)}
|
||
className={`pl-9 block w-full ${errors.transaction_date ? "border-red-500" : ""}`}
|
||
required
|
||
/>
|
||
</div>
|
||
{errors.transaction_date && <p className="text-sm text-red-500">{errors.transaction_date}</p>}
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="category">
|
||
費用類別 <span className="text-red-500">*</span>
|
||
</Label>
|
||
<SearchableSelect
|
||
value={data.category}
|
||
onValueChange={(value) => setData("category", value)}
|
||
options={categories.map((c) => ({ label: c, value: c }))}
|
||
placeholder="選擇或輸入類別"
|
||
searchPlaceholder="搜尋類別..."
|
||
className={errors.category ? "border-red-500" : ""}
|
||
/>
|
||
{errors.category && <p className="text-sm text-red-500">{errors.category}</p>}
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="amount">
|
||
金額 <span className="text-red-500">*</span>
|
||
</Label>
|
||
<Input
|
||
id="amount"
|
||
type="number"
|
||
step="0.01"
|
||
value={data.amount}
|
||
onChange={(e) => setData("amount", e.target.value)}
|
||
placeholder="0.00"
|
||
className={errors.amount ? "border-red-500" : ""}
|
||
required
|
||
/>
|
||
{errors.amount && <p className="text-sm text-red-500">{errors.amount}</p>}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="invoice_number">發票號碼</Label>
|
||
<Input
|
||
id="invoice_number"
|
||
value={data.invoice_number}
|
||
onChange={(e) => setData("invoice_number", e.target.value.toUpperCase())}
|
||
placeholder="例:AB-12345678"
|
||
maxLength={11}
|
||
/>
|
||
<p className="text-xs text-gray-500">格式範例:AB-12345678</p>
|
||
{errors.invoice_number && <p className="text-sm text-red-500">{errors.invoice_number}</p>}
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="description">說明 / 備註</Label>
|
||
<Textarea
|
||
id="description"
|
||
value={data.description}
|
||
onChange={(e) => setData("description", e.target.value)}
|
||
placeholder="輸入其他備註資訊..."
|
||
className="resize-none"
|
||
/>
|
||
{errors.description && <p className="text-sm text-red-500">{errors.description}</p>}
|
||
</div>
|
||
</div>
|
||
|
||
<DialogFooter>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={() => onOpenChange(false)}
|
||
className="button-outlined-primary"
|
||
>
|
||
取消
|
||
</Button>
|
||
<Button type="submit" className="button-filled-primary" disabled={processing}>
|
||
{processing ? "處理中..." : (fee ? "儲存變更" : "確認紀錄")}
|
||
</Button>
|
||
</DialogFooter>
|
||
</form>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|