fix:优化数据
This commit is contained in:
503
frontend/app/(main)/finance/page.tsx
Normal file
503
frontend/app/(main)/finance/page.tsx
Normal file
@@ -0,0 +1,503 @@
|
||||
"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<string[]>([]);
|
||||
const [selectedMonth, setSelectedMonth] = useState<string>("");
|
||||
const [records, setRecords] = useState<FinanceRecordRead[]>([]);
|
||||
const [loadingMonths, setLoadingMonths] = useState(true);
|
||||
const [loadingRecords, setLoadingRecords] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [lastSync, setLastSync] = useState<FinanceSyncResponse | null>(null);
|
||||
const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
|
||||
const [uploadFile, setUploadFile] = useState<File | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [reviewRecord, setReviewRecord] = useState<FinanceRecordRead | null>(null);
|
||||
const [reviewAmount, setReviewAmount] = useState("");
|
||||
const [reviewDate, setReviewDate] = useState("");
|
||||
const [savingReview, setSavingReview] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const previewUrlRef = useRef<string | null>(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<HTMLInputElement>) => {
|
||||
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<string, string> = {
|
||||
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 (
|
||||
<div className="p-6 max-w-5xl">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">财务归档</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
从网易邮箱同步发票、回执、流水等附件,或手动上传发票
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{selectedMonth && (
|
||||
<Card className="py-2 px-4">
|
||||
<p className="text-xs text-muted-foreground">本月发票合计</p>
|
||||
<p className="text-lg font-semibold">
|
||||
¥{totalInvoicesThisMonth.toLocaleString("zh-CN", { minimumFractionDigits: 2 })}
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setUploadDialogOpen(true);
|
||||
resetUploadDialog();
|
||||
}}
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
<span className="ml-2">上传发票</span>
|
||||
</Button>
|
||||
<Button onClick={handleSync} disabled={syncing} size="default">
|
||||
{syncing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Mail className="h-4 w-4" />
|
||||
)}
|
||||
<span className="ml-2">同步邮箱</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload Invoice Dialog */}
|
||||
<Dialog open={uploadDialogOpen} onOpenChange={(open) => {
|
||||
setUploadDialogOpen(open);
|
||||
if (!open) resetUploadDialog();
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{reviewRecord ? "核对金额与日期" : "上传发票"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{!reviewRecord ? (
|
||||
<>
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="border-2 border-dashed rounded-lg p-6 text-center cursor-pointer hover:bg-muted/50"
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".pdf,.jpg,.jpeg,.png,.webp"
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
{previewUrl ? (
|
||||
<img src={previewUrl} alt="预览" className="max-h-48 mx-auto object-contain" />
|
||||
) : uploadFile ? (
|
||||
<p className="text-sm font-medium">{uploadFile.name}</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">点击或拖拽 PDF/图片到此处</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setUploadDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleUploadSubmit} disabled={!uploadFile || uploading}>
|
||||
{uploading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
<span className="ml-2">上传并识别</span>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{previewUrl && (
|
||||
<img src={previewUrl} alt="预览" className="max-h-32 rounded border object-contain" />
|
||||
)}
|
||||
<div className="grid gap-2">
|
||||
<Label>金额</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={reviewAmount}
|
||||
onChange={(e) => setReviewAmount(e.target.value)}
|
||||
placeholder="可手动修改"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>开票日期</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={reviewDate}
|
||||
onChange={(e) => setReviewDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => { setReviewRecord(null); setReviewAmount(""); setReviewDate(""); }}>
|
||||
继续上传
|
||||
</Button>
|
||||
<Button onClick={handleReviewSave} disabled={savingReview}>
|
||||
{savingReview ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
<span className="ml-2">保存并关闭</span>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Sync History / Last sync */}
|
||||
<Card>
|
||||
<CardHeader className="py-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Inbox className="h-4 w-4" />
|
||||
同步记录
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="py-2">
|
||||
{lastSync !== null ? (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
最近一次同步:发现 <strong>{lastSync.new_files}</strong> 个新文件
|
||||
</p>
|
||||
{lastSync.details && lastSync.details.length > 0 && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{Object.entries(
|
||||
lastSync.details.reduce<Record<string, FinanceSyncResult[]>>(
|
||||
(acc, item) => {
|
||||
const t = item.type || "others";
|
||||
if (!acc[t]) acc[t] = [];
|
||||
acc[t].push(item);
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
),
|
||||
).map(([t, items]) => (
|
||||
<div key={t}>
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
{typeLabel[t] ?? t}({items.length})
|
||||
</p>
|
||||
<ul className="mt-1 ml-4 list-disc space-y-0.5 text-xs text-muted-foreground">
|
||||
{items.map((it) => (
|
||||
<li key={it.id}>
|
||||
{it.file_name}
|
||||
<span className="ml-1 text-[11px] text-muted-foreground/80">
|
||||
[{it.month}]
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
点击「同步邮箱」后,将显示本次同步结果
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Month + Download */}
|
||||
<Card>
|
||||
<CardHeader className="py-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<CardTitle className="text-base">按月份查看</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={selectedMonth}
|
||||
onValueChange={setSelectedMonth}
|
||||
disabled={loadingMonths}
|
||||
>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="选择月份" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{months.map((m) => (
|
||||
<SelectItem key={m} value={m}>
|
||||
{m}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDownloadZip}
|
||||
disabled={!selectedMonth || records.length === 0}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
<span className="ml-1.5">下载本月全部 (.zip)</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loadingRecords ? (
|
||||
<p className="text-sm text-muted-foreground flex items-center gap-1 py-4">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
加载中…
|
||||
</p>
|
||||
) : records.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4">
|
||||
{selectedMonth ? "该月份暂无归档文件" : "请选择月份或先同步邮箱"}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
类型:发票 / 回执 / 流水。同步的发票已按「日期_金额_原文件名」重命名。
|
||||
</p>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>文件名</TableHead>
|
||||
<TableHead>金额</TableHead>
|
||||
<TableHead>开票/归档时间</TableHead>
|
||||
<TableHead className="w-[100px]">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{records.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell>
|
||||
<span className="text-muted-foreground">
|
||||
{typeLabel[r.type] ?? r.type}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{r.file_name}</TableCell>
|
||||
<TableCell>
|
||||
{r.amount != null
|
||||
? `¥${Number(r.amount).toLocaleString("zh-CN", { minimumFractionDigits: 2 })}`
|
||||
: "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{r.billing_date || formatDate(r.created_at)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<a
|
||||
href={`${apiBase()}${r.file_path.startsWith("/") ? "" : "/"}${r.file_path}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary text-sm hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
下载
|
||||
</a>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
<TableRow className="bg-muted/30 font-medium">
|
||||
<TableCell colSpan={2}>本月合计</TableCell>
|
||||
<TableCell>
|
||||
¥{monthlyTotal.toLocaleString("zh-CN", { minimumFractionDigits: 2 })}
|
||||
</TableCell>
|
||||
<TableCell colSpan={2} />
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user