All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 55s
305 lines
13 KiB
TypeScript
305 lines
13 KiB
TypeScript
import { useState, useEffect } from "react";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogHeader,
|
||
DialogDescription,
|
||
DialogTitle,
|
||
} from "@/Components/ui/dialog";
|
||
import {
|
||
AlertDialog,
|
||
AlertDialogAction,
|
||
AlertDialogCancel,
|
||
AlertDialogContent,
|
||
AlertDialogDescription,
|
||
AlertDialogFooter,
|
||
AlertDialogHeader,
|
||
AlertDialogTitle,
|
||
} from "@/Components/ui/alert-dialog";
|
||
import { Button } from "@/Components/ui/button";
|
||
import {
|
||
FileText,
|
||
Loader2,
|
||
Trash2,
|
||
Eye,
|
||
Upload
|
||
} from "lucide-react";
|
||
import { UtilityFee } from "./UtilityFeeDialog";
|
||
import { toast } from "sonner";
|
||
import axios from "axios";
|
||
|
||
interface Attachment {
|
||
id: number;
|
||
url: string;
|
||
original_name: string;
|
||
mime_type: string;
|
||
size: number;
|
||
}
|
||
|
||
|
||
|
||
interface Props {
|
||
open: boolean;
|
||
onOpenChange: (open: boolean) => void;
|
||
fee: UtilityFee | null;
|
||
onAttachmentsChange?: () => void;
|
||
}
|
||
|
||
export default function AttachmentDialog({
|
||
open,
|
||
onOpenChange,
|
||
fee,
|
||
onAttachmentsChange,
|
||
}: Props) {
|
||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [uploading, setUploading] = useState(false);
|
||
const [isDeleting, setIsDeleting] = useState(false);
|
||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (open && fee) {
|
||
fetchAttachments();
|
||
} else {
|
||
setAttachments([]);
|
||
}
|
||
}, [open, fee]);
|
||
|
||
const fetchAttachments = async () => {
|
||
if (!fee) return;
|
||
setLoading(true);
|
||
try {
|
||
const response = await axios.get(route("utility-fees.attachments", fee.id));
|
||
setAttachments(response.data.attachments || []);
|
||
} catch (error) {
|
||
toast.error("取得附件失敗");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const files = e.target.files;
|
||
if (!files || files.length === 0 || !fee) return;
|
||
|
||
const file = files[0];
|
||
|
||
// 驗證檔案大小 (2MB)
|
||
if (file.size > 2 * 1024 * 1024) {
|
||
toast.error("檔案大小不能超過 2MB");
|
||
return;
|
||
}
|
||
|
||
// 驗證檔案類型
|
||
const allowedTypes = ["image/jpeg", "image/png", "image/webp", "application/pdf"];
|
||
if (!allowedTypes.includes(file.type)) {
|
||
toast.error("僅支援 JPG, PNG, WebP 圖片及 PDF 文件");
|
||
return;
|
||
}
|
||
|
||
// 驗證數量限制 (3張)
|
||
if (attachments.length >= 3) {
|
||
toast.error("最多僅可上傳 3 個附件");
|
||
return;
|
||
}
|
||
|
||
const formData = new FormData();
|
||
formData.append("file", file);
|
||
|
||
setUploading(true);
|
||
try {
|
||
await axios.post(route("utility-fees.upload-attachment", fee.id), formData, {
|
||
headers: { "Content-Type": "multipart/form-data" },
|
||
});
|
||
toast.success("上傳成功");
|
||
fetchAttachments();
|
||
onAttachmentsChange?.();
|
||
} catch (error: any) {
|
||
const errorMsg = error.response?.data?.message || Object.values(error.response?.data?.errors || {})[0] || "上傳失敗";
|
||
toast.error(errorMsg as string);
|
||
} finally {
|
||
setUploading(false);
|
||
// 清空 input 讓同一個檔案可以重複觸發 onChange
|
||
e.target.value = "";
|
||
}
|
||
};
|
||
|
||
const confirmDelete = (id: number) => {
|
||
setDeleteId(id);
|
||
setIsDeleteDialogOpen(true);
|
||
};
|
||
|
||
const handleDelete = async () => {
|
||
if (!deleteId || !fee) return;
|
||
|
||
setIsDeleting(true);
|
||
try {
|
||
await axios.delete(route("utility-fees.delete-attachment", [fee.id, deleteId]));
|
||
toast.success("附件已刪除");
|
||
setAttachments(attachments.filter((a) => a.id !== deleteId));
|
||
onAttachmentsChange?.();
|
||
} catch (error) {
|
||
toast.error("刪除失敗");
|
||
} finally {
|
||
setIsDeleting(false);
|
||
setDeleteId(null);
|
||
setIsDeleteDialogOpen(false);
|
||
}
|
||
};
|
||
|
||
const formatSize = (bytes: number) => {
|
||
if (bytes === 0) return "0 B";
|
||
const k = 1024;
|
||
const sizes = ["B", "KB", "MB"];
|
||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
<DialogContent className="sm:max-w-[550px]">
|
||
<DialogHeader>
|
||
<DialogTitle className="flex items-center gap-2 text-xl">
|
||
<FileText className="h-6 w-6 text-primary-main" />
|
||
附件管理
|
||
</DialogTitle>
|
||
<DialogDescription>
|
||
管理 {fee?.billing_month} {fee?.category_name} 的支援文件(對帳單、收據等)。
|
||
最多可上傳 3 個檔案,單一檔案上限 2MB。
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<div className="py-4 space-y-6">
|
||
{/* 上傳區塊 */}
|
||
<div className="flex items-center justify-between p-4 bg-slate-50 rounded-xl border border-dashed border-slate-300">
|
||
<div className="text-sm text-slate-500">
|
||
{attachments.length < 3
|
||
? `還可以上傳 ${3 - attachments.length} 個附件`
|
||
: "已達到上傳數量上限"}
|
||
</div>
|
||
<input
|
||
type="file"
|
||
id="file-upload"
|
||
className="hidden"
|
||
onChange={handleUpload}
|
||
disabled={uploading || attachments.length >= 3}
|
||
accept=".jpg,.jpeg,.png,.webp,.pdf"
|
||
/>
|
||
<Button
|
||
asChild
|
||
disabled={uploading || attachments.length >= 3}
|
||
className="button-filled-primary"
|
||
>
|
||
<label htmlFor="file-upload" className="cursor-pointer flex items-center">
|
||
{uploading ? (
|
||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||
) : (
|
||
<Upload className="h-4 w-4 mr-2" />
|
||
)}
|
||
選擇檔案
|
||
</label>
|
||
</Button>
|
||
</div>
|
||
|
||
{/* 附件列表 */}
|
||
<div className="space-y-3">
|
||
{loading ? (
|
||
<div className="flex flex-col items-center justify-center py-8 text-slate-400">
|
||
<Loader2 className="h-8 w-8 animate-spin mb-2" />
|
||
<p>載入中...</p>
|
||
</div>
|
||
) : attachments.length === 0 ? (
|
||
<div className="text-center py-8 bg-slate-50 rounded-xl border border-slate-100">
|
||
<p className="text-slate-400">目前尚無附件</p>
|
||
</div>
|
||
) : (
|
||
<div className="grid grid-cols-1 gap-3">
|
||
{attachments.map((file) => (
|
||
<div
|
||
key={file.id}
|
||
className="group relative flex items-center justify-between p-3 bg-white rounded-xl border border-slate-200 hover:border-primary-light transition-all shadow-sm"
|
||
>
|
||
<div className="flex items-center gap-3 overflow-hidden">
|
||
<div className="h-12 w-12 rounded-lg bg-slate-100 flex items-center justify-center overflow-hidden shrink-0 border border-slate-100">
|
||
{file.mime_type.startsWith("image/") ? (
|
||
<img
|
||
src={file.url}
|
||
alt={file.original_name}
|
||
className="h-full w-full object-cover"
|
||
onError={(e) => {
|
||
(e.target as HTMLImageElement).src = "";
|
||
(e.target as HTMLImageElement).className = "hidden";
|
||
(e.target as HTMLImageElement).parentElement?.classList.add("bg-slate-200");
|
||
}}
|
||
/>
|
||
) : (
|
||
<FileText className="h-6 w-6 text-blue-500" />
|
||
)}
|
||
</div>
|
||
<div className="overflow-hidden">
|
||
<p className="text-sm font-medium text-slate-900 truncate pr-2" title={file.original_name}>
|
||
{file.original_name}
|
||
</p>
|
||
<p className="text-xs text-slate-500">
|
||
{formatSize(file.size)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="button-outlined-primary h-8 w-8 p-0"
|
||
asChild
|
||
title="另開分頁"
|
||
>
|
||
<a href={file.url} target="_blank" rel="noopener noreferrer">
|
||
<Eye className="h-4 w-4" />
|
||
</a>
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="button-outlined-error h-8 w-8 p-0"
|
||
onClick={() => confirmDelete(file.id)}
|
||
disabled={isDeleting}
|
||
title="刪除附件"
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||
<AlertDialogContent>
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle>確認刪除附件?</AlertDialogTitle>
|
||
<AlertDialogDescription>
|
||
這將永久刪除此附件,此操作無法撤銷。
|
||
</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||
<AlertDialogAction
|
||
onClick={handleDelete}
|
||
className="bg-red-600 hover:bg-red-700 text-white"
|
||
>
|
||
確認刪除
|
||
</AlertDialogAction>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
</>
|
||
);
|
||
}
|