5278190750
- 后端: 凭证生成引擎、金蝶导出器、凭证模板服务 - 后端: 成本分析服务、AI问答服务 - 后端: 科目映射CRUD API、分析API、QA API - 后端: 集成测试(认证/任务/凭证) 49个测试全部通过 - 前端: 凭证管理、成本分析、导出中心、知识库、系统设置页面 - 前端: AuthGuard认证守卫、Dashboard AI聊天功能 - 前端: Playwright E2E测试 16 passed, 1 skipped - 基础设施: Docker Compose、Nginx反向代理、.env.example - 文档: 用户手册、管理员手册、发布检查清单
582 lines
22 KiB
TypeScript
582 lines
22 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect, useCallback } from "react";
|
||
import { useSearchParams } from "next/navigation";
|
||
import { motion } from "motion/react";
|
||
import {
|
||
Card,
|
||
CardContent,
|
||
CardHeader,
|
||
CardTitle,
|
||
} from "@/components/ui/card";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from "@/components/ui/table";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
DialogFooter,
|
||
} from "@/components/ui/dialog";
|
||
import {
|
||
Receipt,
|
||
Download,
|
||
Loader2,
|
||
CheckCircle,
|
||
FileText,
|
||
Settings,
|
||
Plus,
|
||
Pencil,
|
||
Trash2,
|
||
} from "lucide-react";
|
||
import { api } from "@/lib/api/client";
|
||
|
||
interface VoucherEntry {
|
||
account_code: string;
|
||
account_name: string;
|
||
debit_amount: number;
|
||
credit_amount: number;
|
||
summary: string;
|
||
department?: string;
|
||
}
|
||
|
||
interface Voucher {
|
||
id: number;
|
||
voucher_number: string;
|
||
voucher_date: string;
|
||
period: string;
|
||
summary: string;
|
||
entries: VoucherEntry[];
|
||
total_debit: number;
|
||
total_credit: number;
|
||
status: string;
|
||
confirmed_by?: number;
|
||
confirmed_at?: string;
|
||
created_at: string;
|
||
}
|
||
|
||
interface AccountMapping {
|
||
id: number;
|
||
standard_field: string;
|
||
debit_account: string;
|
||
debit_account_name: string;
|
||
credit_account: string;
|
||
credit_account_name: string;
|
||
cost_center?: string;
|
||
is_active: boolean;
|
||
}
|
||
|
||
const statusConfig: Record<string, { label: string; color: string }> = {
|
||
DRAFT: { label: "草稿", color: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300" },
|
||
CONFIRMED: { label: "已确认", color: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300" },
|
||
EXPORTED: { label: "已导出", color: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300" },
|
||
};
|
||
|
||
function formatCurrency(value: number): string {
|
||
return new Intl.NumberFormat("zh-CN", {
|
||
style: "currency",
|
||
currency: "CNY",
|
||
minimumFractionDigits: 2,
|
||
}).format(value);
|
||
}
|
||
|
||
export default function VouchersPage() {
|
||
const searchParams = useSearchParams();
|
||
const [voucher, setVoucher] = useState<Voucher | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [generating, setGenerating] = useState(false);
|
||
const [taskId, setTaskId] = useState(searchParams.get("task_id") || "");
|
||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||
const [confirming, setConfirming] = useState(false);
|
||
|
||
const [mappings, setMappings] = useState<AccountMapping[]>([]);
|
||
const [mappingDialogOpen, setMappingDialogOpen] = useState(false);
|
||
const [editingMapping, setEditingMapping] = useState<AccountMapping | null>(null);
|
||
const [mappingForm, setMappingForm] = useState({
|
||
standard_field: "",
|
||
debit_account: "",
|
||
debit_account_name: "",
|
||
credit_account: "",
|
||
credit_account_name: "",
|
||
cost_center: "",
|
||
});
|
||
|
||
const loadVoucher = useCallback(async () => {
|
||
if (!taskId) {
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
setLoading(true);
|
||
try {
|
||
const res = await api.get<Voucher>(`/api/vouchers/task/${taskId}`);
|
||
setVoucher(res.data);
|
||
} catch (error) {
|
||
console.error("加载凭证失败:", error);
|
||
setVoucher(null);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [taskId]);
|
||
|
||
const loadMappings = useCallback(async () => {
|
||
try {
|
||
const res = await api.get<AccountMapping[]>(`/api/vouchers/account-mappings/list`);
|
||
setMappings(res.data || []);
|
||
} catch (error) {
|
||
console.error("加载科目映射失败:", error);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
loadVoucher();
|
||
loadMappings();
|
||
}, [loadVoucher, loadMappings]);
|
||
|
||
async function handleGenerate() {
|
||
if (!taskId) return;
|
||
setGenerating(true);
|
||
try {
|
||
const res = await api.post<Voucher>(`/api/vouchers/generate`, {
|
||
task_id: Number(taskId),
|
||
});
|
||
setVoucher(res.data);
|
||
} catch (error) {
|
||
console.error("生成凭证失败:", error);
|
||
} finally {
|
||
setGenerating(false);
|
||
}
|
||
}
|
||
|
||
async function handleConfirm() {
|
||
if (!voucher) return;
|
||
setConfirming(true);
|
||
try {
|
||
const userId = Number(localStorage.getItem("user_id") || "1");
|
||
const res = await api.post<Voucher>(`/api/vouchers/${voucher.id}/confirm`, {
|
||
user_id: userId,
|
||
});
|
||
setVoucher(res.data);
|
||
setConfirmOpen(false);
|
||
} catch (error) {
|
||
console.error("确认凭证失败:", error);
|
||
} finally {
|
||
setConfirming(false);
|
||
}
|
||
}
|
||
|
||
function handleExport(format: string) {
|
||
if (!taskId) return;
|
||
const token = localStorage.getItem("auth_token");
|
||
const companyId = localStorage.getItem("company_id");
|
||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/vouchers/task/${taskId}/export?format=${format}`;
|
||
fetch(url, {
|
||
headers: {
|
||
Authorization: `Bearer ${token}`,
|
||
"X-Company-ID": companyId || "",
|
||
},
|
||
})
|
||
.then((res) => res.blob())
|
||
.then((blob) => {
|
||
const a = document.createElement("a");
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = `voucher_${taskId}.${format === "excel" ? "xlsx" : "csv"}`;
|
||
a.click();
|
||
URL.revokeObjectURL(a.href);
|
||
});
|
||
}
|
||
|
||
function openMappingDialog(mapping?: AccountMapping) {
|
||
if (mapping) {
|
||
setEditingMapping(mapping);
|
||
setMappingForm({
|
||
standard_field: mapping.standard_field,
|
||
debit_account: mapping.debit_account,
|
||
debit_account_name: mapping.debit_account_name,
|
||
credit_account: mapping.credit_account,
|
||
credit_account_name: mapping.credit_account_name,
|
||
cost_center: mapping.cost_center || "",
|
||
});
|
||
} else {
|
||
setEditingMapping(null);
|
||
setMappingForm({
|
||
standard_field: "",
|
||
debit_account: "",
|
||
debit_account_name: "",
|
||
credit_account: "",
|
||
credit_account_name: "",
|
||
cost_center: "",
|
||
});
|
||
}
|
||
setMappingDialogOpen(true);
|
||
}
|
||
|
||
async function saveMapping() {
|
||
try {
|
||
if (editingMapping) {
|
||
await api.put(`/api/vouchers/account-mappings/${editingMapping.id}`, mappingForm);
|
||
} else {
|
||
await api.post(`/api/vouchers/account-mappings`, mappingForm);
|
||
}
|
||
setMappingDialogOpen(false);
|
||
loadMappings();
|
||
} catch (error) {
|
||
console.error("保存科目映射失败:", error);
|
||
}
|
||
}
|
||
|
||
async function deleteMapping(id: number) {
|
||
try {
|
||
await api.delete(`/api/vouchers/account-mappings/${id}`);
|
||
loadMappings();
|
||
} catch (error) {
|
||
console.error("删除科目映射失败:", error);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-full p-6 lg:p-8 max-w-7xl mx-auto space-y-6">
|
||
<motion.div
|
||
initial={{ opacity: 0, y: -8 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
|
||
className="flex items-center justify-between"
|
||
>
|
||
<div>
|
||
<h1 className="text-heading-1 text-foreground">凭证管理</h1>
|
||
<p className="text-muted-foreground mt-1">生成、预览和导出会计凭证</p>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<Button variant="outline" onClick={() => openMappingDialog()} className="btn-press">
|
||
<Settings className="w-4 h-4 mr-2" />
|
||
科目映射
|
||
</Button>
|
||
</div>
|
||
</motion.div>
|
||
|
||
<Card>
|
||
<CardContent className="p-4">
|
||
<div className="flex flex-wrap items-end gap-3">
|
||
<div className="flex-1 min-w-[180px]">
|
||
<label className="text-caption text-muted-foreground mb-1.5 block">任务ID</label>
|
||
<Input
|
||
placeholder="输入对账任务ID"
|
||
value={taskId}
|
||
onChange={(e) => setTaskId(e.target.value)}
|
||
className="h-9"
|
||
/>
|
||
</div>
|
||
<Button onClick={loadVoucher} variant="outline" className="h-9 btn-press">
|
||
查询凭证
|
||
</Button>
|
||
<Button onClick={handleGenerate} disabled={generating || !taskId} className="h-9 btn-press">
|
||
{generating ? (
|
||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||
) : (
|
||
<Receipt className="w-4 h-4 mr-2" />
|
||
)}
|
||
生成凭证
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{loading ? (
|
||
<div className="flex items-center justify-center py-20">
|
||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||
</div>
|
||
) : !voucher ? (
|
||
<Card>
|
||
<CardContent className="p-8 text-center">
|
||
<div className="w-12 h-12 rounded-full bg-muted mx-auto mb-4 flex items-center justify-center">
|
||
<Receipt className="w-6 h-6 text-muted-foreground" />
|
||
</div>
|
||
<p className="text-body text-muted-foreground mb-2">
|
||
{taskId ? "该任务暂无凭证,请点击「生成凭证」" : "请输入任务ID查询凭证"}
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
) : (
|
||
<>
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 12 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ duration: 0.4 }}
|
||
>
|
||
<Card>
|
||
<CardHeader>
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<CardTitle className="text-lg font-medium">
|
||
{voucher.voucher_number}
|
||
</CardTitle>
|
||
<Badge className={statusConfig[voucher.status]?.color || statusConfig.DRAFT.color}>
|
||
{statusConfig[voucher.status]?.label || voucher.status}
|
||
</Badge>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<Button variant="outline" size="sm" onClick={() => handleExport("csv")} className="btn-press">
|
||
<Download className="w-4 h-4 mr-1" />
|
||
金蝶CSV
|
||
</Button>
|
||
<Button variant="outline" size="sm" onClick={() => handleExport("excel")} className="btn-press">
|
||
<Download className="w-4 h-4 mr-1" />
|
||
Excel
|
||
</Button>
|
||
{voucher.status === "DRAFT" && (
|
||
<Button size="sm" onClick={() => setConfirmOpen(true)} className="btn-press">
|
||
<CheckCircle className="w-4 h-4 mr-1" />
|
||
确认凭证
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||
<div>
|
||
<p className="text-caption text-muted-foreground">凭证日期</p>
|
||
<p className="font-medium">{voucher.voucher_date}</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-caption text-muted-foreground">会计期间</p>
|
||
<p className="font-medium">{voucher.period}</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-caption text-muted-foreground">借方合计</p>
|
||
<p className="font-mono font-semibold text-foreground">{formatCurrency(voucher.total_debit)}</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-caption text-muted-foreground">贷方合计</p>
|
||
<p className="font-mono font-semibold text-foreground">{formatCurrency(voucher.total_credit)}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mb-4">
|
||
<p className="text-caption text-muted-foreground">摘要</p>
|
||
<p className="text-body">{voucher.summary}</p>
|
||
</div>
|
||
|
||
{voucher.confirmed_at && (
|
||
<div className="mb-4 p-3 rounded-lg bg-emerald-50 dark:bg-emerald-950/30">
|
||
<div className="flex items-center gap-2">
|
||
<CheckCircle className="w-4 h-4 text-emerald-600" />
|
||
<span className="text-sm text-emerald-700 dark:text-emerald-400">
|
||
已于 {new Date(voucher.confirmed_at).toLocaleString("zh-CN")} 确认
|
||
</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</motion.div>
|
||
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 12 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ duration: 0.4, delay: 0.1 }}
|
||
>
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-lg font-medium">凭证分录</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="p-0">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow className="hover:bg-transparent">
|
||
<TableHead className="w-[60px]">序号</TableHead>
|
||
<TableHead>科目代码</TableHead>
|
||
<TableHead>科目名称</TableHead>
|
||
<TableHead>摘要</TableHead>
|
||
<TableHead className="text-right">借方金额</TableHead>
|
||
<TableHead className="text-right">贷方金额</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{voucher.entries.map((entry, i) => (
|
||
<TableRow key={i}>
|
||
<TableCell className="text-muted-foreground">{i + 1}</TableCell>
|
||
<TableCell className="font-mono">{entry.account_code}</TableCell>
|
||
<TableCell>{entry.account_name}</TableCell>
|
||
<TableCell className="text-muted-foreground">{entry.summary}</TableCell>
|
||
<TableCell className="text-right font-mono">
|
||
{entry.debit_amount > 0 ? formatCurrency(entry.debit_amount) : "-"}
|
||
</TableCell>
|
||
<TableCell className="text-right font-mono">
|
||
{entry.credit_amount > 0 ? formatCurrency(entry.credit_amount) : "-"}
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
<TableRow className="border-t-2 font-semibold">
|
||
<TableCell colSpan={4} className="text-right">合计</TableCell>
|
||
<TableCell className="text-right font-mono">{formatCurrency(voucher.total_debit)}</TableCell>
|
||
<TableCell className="text-right font-mono">{formatCurrency(voucher.total_credit)}</TableCell>
|
||
</TableRow>
|
||
</TableBody>
|
||
</Table>
|
||
</CardContent>
|
||
</Card>
|
||
</motion.div>
|
||
</>
|
||
)}
|
||
|
||
{mappings.length > 0 && (
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 12 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ duration: 0.4, delay: 0.2 }}
|
||
>
|
||
<Card>
|
||
<CardHeader>
|
||
<div className="flex items-center justify-between">
|
||
<CardTitle className="text-lg font-medium">科目映射配置</CardTitle>
|
||
<Button variant="outline" size="sm" onClick={() => openMappingDialog()} className="btn-press">
|
||
<Plus className="w-4 h-4 mr-1" />
|
||
新增映射
|
||
</Button>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="p-0">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow className="hover:bg-transparent">
|
||
<TableHead>标准字段</TableHead>
|
||
<TableHead>借方科目</TableHead>
|
||
<TableHead>贷方科目</TableHead>
|
||
<TableHead>成本中心</TableHead>
|
||
<TableHead>状态</TableHead>
|
||
<TableHead className="w-[100px]">操作</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{mappings.map((m) => (
|
||
<TableRow key={m.id} className="group">
|
||
<TableCell className="font-medium">{m.standard_field}</TableCell>
|
||
<TableCell>
|
||
<span className="font-mono text-sm">{m.debit_account}</span>
|
||
<span className="text-muted-foreground ml-2">{m.debit_account_name}</span>
|
||
</TableCell>
|
||
<TableCell>
|
||
<span className="font-mono text-sm">{m.credit_account}</span>
|
||
<span className="text-muted-foreground ml-2">{m.credit_account_name}</span>
|
||
</TableCell>
|
||
<TableCell>{m.cost_center || "-"}</TableCell>
|
||
<TableCell>
|
||
<Badge variant={m.is_active ? "default" : "secondary"}>
|
||
{m.is_active ? "启用" : "禁用"}
|
||
</Badge>
|
||
</TableCell>
|
||
<TableCell>
|
||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => openMappingDialog(m)}>
|
||
<Pencil className="h-4 w-4" />
|
||
</Button>
|
||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => deleteMapping(m.id)}>
|
||
<Trash2 className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</CardContent>
|
||
</Card>
|
||
</motion.div>
|
||
)}
|
||
|
||
{/* 确认对话框 */}
|
||
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||
<DialogContent className="max-w-md">
|
||
<DialogHeader>
|
||
<DialogTitle>确认凭证</DialogTitle>
|
||
</DialogHeader>
|
||
<p className="text-body text-muted-foreground">
|
||
确认后凭证将不能修改。确认凭证后将可以导出金蝶格式文件。
|
||
</p>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setConfirmOpen(false)}>取消</Button>
|
||
<Button onClick={handleConfirm} disabled={confirming} className="btn-press">
|
||
{confirming ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <CheckCircle className="w-4 h-4 mr-2" />}
|
||
确认
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* 科目映射对话框 */}
|
||
<Dialog open={mappingDialogOpen} onOpenChange={setMappingDialogOpen}>
|
||
<DialogContent className="max-w-lg">
|
||
<DialogHeader>
|
||
<DialogTitle>{editingMapping ? "编辑科目映射" : "新增科目映射"}</DialogTitle>
|
||
</DialogHeader>
|
||
<div className="space-y-4">
|
||
<div>
|
||
<label className="text-caption text-muted-foreground mb-1.5 block">标准字段</label>
|
||
<Input
|
||
value={mappingForm.standard_field}
|
||
onChange={(e) => setMappingForm({ ...mappingForm, standard_field: e.target.value })}
|
||
placeholder="如:基本工资"
|
||
/>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="text-caption text-muted-foreground mb-1.5 block">借方科目代码</label>
|
||
<Input
|
||
value={mappingForm.debit_account}
|
||
onChange={(e) => setMappingForm({ ...mappingForm, debit_account: e.target.value })}
|
||
placeholder="如:6601.01"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="text-caption text-muted-foreground mb-1.5 block">借方科目名称</label>
|
||
<Input
|
||
value={mappingForm.debit_account_name}
|
||
onChange={(e) => setMappingForm({ ...mappingForm, debit_account_name: e.target.value })}
|
||
placeholder="如:管理费用-工资"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="text-caption text-muted-foreground mb-1.5 block">贷方科目代码</label>
|
||
<Input
|
||
value={mappingForm.credit_account}
|
||
onChange={(e) => setMappingForm({ ...mappingForm, credit_account: e.target.value })}
|
||
placeholder="如:2211.01"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="text-caption text-muted-foreground mb-1.5 block">贷方科目名称</label>
|
||
<Input
|
||
value={mappingForm.credit_account_name}
|
||
onChange={(e) => setMappingForm({ ...mappingForm, credit_account_name: e.target.value })}
|
||
placeholder="如:应付职工薪酬-工资"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="text-caption text-muted-foreground mb-1.5 block">成本中心(可选)</label>
|
||
<Input
|
||
value={mappingForm.cost_center}
|
||
onChange={(e) => setMappingForm({ ...mappingForm, cost_center: e.target.value })}
|
||
placeholder="如:管理部"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setMappingDialogOpen(false)}>取消</Button>
|
||
<Button onClick={saveMapping} className="btn-press">保存</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|