"use client"; import { useState, useEffect, useCallback, useRef } from "react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { apiBase, financeApi, type FinanceRecordRead, type FinanceSyncResponse, type FinanceSyncResult, } from "@/lib/api/client"; import { Download, Inbox, Loader2, Mail, FileText, Upload } from "lucide-react"; import { toast } from "sonner"; export default function FinancePage() { const [months, setMonths] = useState([]); const [selectedMonth, setSelectedMonth] = useState(""); const [records, setRecords] = useState([]); const [loadingMonths, setLoadingMonths] = useState(true); const [loadingRecords, setLoadingRecords] = useState(false); const [syncing, setSyncing] = useState(false); const [lastSync, setLastSync] = useState(null); const [uploadDialogOpen, setUploadDialogOpen] = useState(false); const [uploadFile, setUploadFile] = useState(null); const [previewUrl, setPreviewUrl] = useState(null); const [uploading, setUploading] = useState(false); const [reviewRecord, setReviewRecord] = useState(null); const [reviewAmount, setReviewAmount] = useState(""); const [reviewDate, setReviewDate] = useState(""); const [savingReview, setSavingReview] = useState(false); const fileInputRef = useRef(null); const previewUrlRef = useRef(null); const loadMonths = useCallback(async () => { try { const list = await financeApi.listMonths(); setMonths(list); if (list.length > 0 && !selectedMonth) setSelectedMonth(list[0]); } catch { toast.error("加载月份列表失败"); } finally { setLoadingMonths(false); } }, [selectedMonth]); const loadRecords = useCallback(async () => { if (!selectedMonth) { setRecords([]); return; } setLoadingRecords(true); try { const list = await financeApi.listRecords(selectedMonth); setRecords(list); } catch { toast.error("加载记录失败"); } finally { setLoadingRecords(false); } }, [selectedMonth]); useEffect(() => { loadMonths(); }, [loadMonths]); useEffect(() => { loadRecords(); }, [loadRecords]); const handleSync = async () => { setSyncing(true); toast.loading("正在同步邮箱…", { id: "finance-sync" }); try { const res: FinanceSyncResponse = await financeApi.sync(); setLastSync(res); toast.dismiss("finance-sync"); if (res.new_files > 0) { toast.success(`发现 ${res.new_files} 个新文件`); await loadMonths(); if (selectedMonth) await loadRecords(); } else { toast.info("收件箱已是最新,无新文件"); } } catch (e) { toast.dismiss("finance-sync"); toast.error(e instanceof Error ? e.message : "同步失败"); } finally { setSyncing(false); } }; const resetUploadDialog = useCallback(() => { setUploadFile(null); if (previewUrlRef.current) { URL.revokeObjectURL(previewUrlRef.current); previewUrlRef.current = null; } setPreviewUrl(null); setReviewRecord(null); setReviewAmount(""); setReviewDate(""); if (fileInputRef.current) fileInputRef.current.value = ""; }, []); const handleFileSelect = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; const allowed = ["application/pdf", "image/jpeg", "image/png", "image/webp"]; if (!allowed.includes(file.type) && !file.name.match(/\.(pdf|jpg|jpeg|png|webp)$/i)) { toast.error("仅支持 PDF、JPG、PNG、WEBP"); return; } if (previewUrlRef.current) { URL.revokeObjectURL(previewUrlRef.current); previewUrlRef.current = null; } setUploadFile(file); setReviewRecord(null); setReviewAmount(""); setReviewDate(""); if (file.type.startsWith("image/")) { const url = URL.createObjectURL(file); previewUrlRef.current = url; setPreviewUrl(url); } else { setPreviewUrl(null); } }; const handleUploadSubmit = async () => { if (!uploadFile) return; setUploading(true); try { const record = await financeApi.uploadInvoice(uploadFile); setReviewRecord(record); setReviewAmount(record.amount != null ? String(record.amount) : ""); setReviewDate(record.billing_date || ""); toast.success("已上传,请核对金额与日期"); await loadMonths(); if (selectedMonth === record.month) await loadRecords(); } catch (e) { toast.error(e instanceof Error ? e.message : "上传失败"); } finally { setUploading(false); } }; const handleReviewSave = async () => { if (!reviewRecord) return; const amount = reviewAmount.trim() ? parseFloat(reviewAmount) : null; const billing_date = reviewDate.trim() || null; setSavingReview(true); try { await financeApi.updateRecord(reviewRecord.id, { amount, billing_date }); toast.success("已保存"); setUploadDialogOpen(false); resetUploadDialog(); if (selectedMonth) await loadRecords(); } catch (e) { toast.error(e instanceof Error ? e.message : "保存失败"); } finally { setSavingReview(false); } }; const handleDownloadZip = async () => { if (!selectedMonth) { toast.error("请先选择月份"); return; } try { await financeApi.downloadMonth(selectedMonth); toast.success(`已下载 ${selectedMonth}.zip`); } catch (e) { toast.error(e instanceof Error ? e.message : "下载失败"); } }; const formatDate = (s: string) => new Date(s).toLocaleString("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", }); const typeLabel: Record = { invoices: "发票", bank_records: "流水", statements: "流水", receipts: "回执", manual: "手动上传", others: "其他", }; const monthlyTotal = records.reduce((sum, r) => sum + (r.amount ?? 0), 0); const totalInvoicesThisMonth = records.filter( (r) => r.amount != null && (r.type === "manual" || r.type === "invoices") ).reduce((s, r) => s + (r.amount ?? 0), 0); return (

财务归档

从网易邮箱同步发票、回执、流水等附件,或手动上传发票

{selectedMonth && (

本月发票合计

¥{totalInvoicesThisMonth.toLocaleString("zh-CN", { minimumFractionDigits: 2 })}

)}
{/* Upload Invoice Dialog */} { setUploadDialogOpen(open); if (!open) resetUploadDialog(); }}> {reviewRecord ? "核对金额与日期" : "上传发票"} {!reviewRecord ? ( <>
fileInputRef.current?.click()} className="border-2 border-dashed rounded-lg p-6 text-center cursor-pointer hover:bg-muted/50" > {previewUrl ? ( 预览 ) : uploadFile ? (

{uploadFile.name}

) : (

点击或拖拽 PDF/图片到此处

)}
) : ( <> {previewUrl && ( 预览 )}
setReviewAmount(e.target.value)} placeholder="可手动修改" />
setReviewDate(e.target.value)} />
)}
{/* Sync History / Last sync */} 同步记录 {lastSync !== null ? ( <>

最近一次同步:发现 {lastSync.new_files} 个新文件

{lastSync.details && lastSync.details.length > 0 && (
{Object.entries( lastSync.details.reduce>( (acc, item) => { const t = item.type || "others"; if (!acc[t]) acc[t] = []; acc[t].push(item); return acc; }, {}, ), ).map(([t, items]) => (

{typeLabel[t] ?? t}({items.length})

    {items.map((it) => (
  • {it.file_name} [{it.month}]
  • ))}
))}
)} ) : (

点击「同步邮箱」后,将显示本次同步结果

)}
{/* Month + Download */}
按月份查看
{loadingRecords ? (

加载中…

) : records.length === 0 ? (

{selectedMonth ? "该月份暂无归档文件" : "请选择月份或先同步邮箱"}

) : ( <>

类型:发票 / 回执 / 流水。同步的发票已按「日期_金额_原文件名」重命名。

类型 文件名 金额 开票/归档时间 操作 {records.map((r) => ( {typeLabel[r.type] ?? r.type} {r.file_name} {r.amount != null ? `¥${Number(r.amount).toLocaleString("zh-CN", { minimumFractionDigits: 2 })}` : "—"} {r.billing_date || formatDate(r.created_at)} 下载 ))} 本月合计 ¥{monthlyTotal.toLocaleString("zh-CN", { minimumFractionDigits: 2 })}
)}
); }