refactor(ui): [UI7-1] 深度翻新报销、发票与用户管理等核心视窗
这个提交包含在:
+125
-576
@@ -23,12 +23,15 @@ import {
|
|||||||
import { fetchReimbursements } from "../api/reimbursements";
|
import { fetchReimbursements } from "../api/reimbursements";
|
||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
import { hasPermission } from "../auth/permissions";
|
import { hasPermission } from "../auth/permissions";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { downloadBlob } from "../lib/download";
|
import { downloadBlob } from "../lib/download";
|
||||||
import { PageHeader } from "../components/common/PageHeader";
|
import { PageHeader } from "../components/common/PageHeader";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import { Card } from "../components/ui/card";
|
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../components/ui/dropdown-menu";
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../components/ui/dropdown-menu";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
import { InvoiceUploadCard } from "./invoice-pool/InvoiceUploadCard";
|
||||||
|
import { InvoiceListCard } from "./invoice-pool/InvoiceListCard";
|
||||||
|
import { InvoiceBindCard } from "./invoice-pool/InvoiceBindCard";
|
||||||
|
import { InvoiceDetailPanel } from "./invoice-pool/InvoiceDetailPanel";
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
@@ -58,6 +61,9 @@ export function InvoicePoolPage() {
|
|||||||
const [editInvoiceDate, setEditInvoiceDate] = useState("");
|
const [editInvoiceDate, setEditInvoiceDate] = useState("");
|
||||||
const [editTotalAmount, setEditTotalAmount] = useState("");
|
const [editTotalAmount, setEditTotalAmount] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [confirmAction, setConfirmAction] = useState<{ type: string; invoiceId: string } | null>(null);
|
||||||
|
|
||||||
|
// --- Queries ---
|
||||||
|
|
||||||
const listQuery = useQuery({
|
const listQuery = useQuery({
|
||||||
queryKey: ["invoice-pool", "list", status, ownerScope, page],
|
queryKey: ["invoice-pool", "list", status, ownerScope, page],
|
||||||
@@ -105,6 +111,8 @@ export function InvoicePoolPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Side effects ---
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!detailQuery.data || editOpen) {
|
if (!detailQuery.data || editOpen) {
|
||||||
return;
|
return;
|
||||||
@@ -118,6 +126,30 @@ export function InvoicePoolPage() {
|
|||||||
setEditTotalAmount(detailQuery.data.totalAmount ?? "");
|
setEditTotalAmount(detailQuery.data.totalAmount ?? "");
|
||||||
}, [detailQuery.data, editOpen]);
|
}, [detailQuery.data, editOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canManageInvoice && ownerScope === "all") {
|
||||||
|
setOwnerScope("own");
|
||||||
|
}
|
||||||
|
}, [canManageInvoice, ownerScope]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!detailInvoiceId && listQuery.data?.items?.length) {
|
||||||
|
setDetailInvoiceId(listQuery.data.items[0].id);
|
||||||
|
}
|
||||||
|
}, [detailInvoiceId, listQuery.data?.items]);
|
||||||
|
|
||||||
|
// --- Helper for error handling ---
|
||||||
|
|
||||||
|
const handleError = (err: unknown, fallback: string) => {
|
||||||
|
const msg = isApiError(err)
|
||||||
|
? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`
|
||||||
|
: err instanceof Error ? err.message : fallback;
|
||||||
|
setError(msg);
|
||||||
|
toast.error(msg);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Mutations ---
|
||||||
|
|
||||||
const uploadMutation = useMutation({
|
const uploadMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
if (!selectedFile) {
|
if (!selectedFile) {
|
||||||
@@ -130,16 +162,9 @@ export function InvoicePoolPage() {
|
|||||||
setDetailInvoiceId(data.invoicePoolId);
|
setDetailInvoiceId(data.invoicePoolId);
|
||||||
setSelectedFile(null);
|
setSelectedFile(null);
|
||||||
setError("");
|
setError("");
|
||||||
|
toast.success("上传成功");
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => handleError(err, "上传失败,请稍后重试"),
|
||||||
if (isApiError(err)) {
|
|
||||||
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
|
|
||||||
} else if (err instanceof Error) {
|
|
||||||
setError(err.message);
|
|
||||||
} else {
|
|
||||||
setError("上传失败,请稍后重试");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const bindMutation = useMutation({
|
const bindMutation = useMutation({
|
||||||
@@ -152,16 +177,9 @@ export function InvoicePoolPage() {
|
|||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
|
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
|
||||||
setError("");
|
setError("");
|
||||||
|
toast.success("绑定成功");
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => handleError(err, "绑定失败,请稍后重试"),
|
||||||
if (isApiError(err)) {
|
|
||||||
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
|
|
||||||
} else if (err instanceof Error) {
|
|
||||||
setError(err.message);
|
|
||||||
} else {
|
|
||||||
setError("绑定失败,请稍后重试");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const signedUrlMutation = useMutation({
|
const signedUrlMutation = useMutation({
|
||||||
@@ -178,18 +196,6 @@ export function InvoicePoolPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!canManageInvoice && ownerScope === "all") {
|
|
||||||
setOwnerScope("own");
|
|
||||||
}
|
|
||||||
}, [canManageInvoice, ownerScope]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!detailInvoiceId && listQuery.data?.items?.length) {
|
|
||||||
setDetailInvoiceId(listQuery.data.items[0].id);
|
|
||||||
}
|
|
||||||
}, [detailInvoiceId, listQuery.data?.items]);
|
|
||||||
|
|
||||||
const patchMutation = useMutation({
|
const patchMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
if (!detailInvoiceId) {
|
if (!detailInvoiceId) {
|
||||||
@@ -209,16 +215,9 @@ export function InvoicePoolPage() {
|
|||||||
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
|
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
|
||||||
setEditOpen(false);
|
setEditOpen(false);
|
||||||
setError("");
|
setError("");
|
||||||
|
toast.success("修正已保存");
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => handleError(err, "保存失败,请稍后重试"),
|
||||||
if (isApiError(err)) {
|
|
||||||
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
|
|
||||||
} else if (err instanceof Error) {
|
|
||||||
setError(err.message);
|
|
||||||
} else {
|
|
||||||
setError("保存失败,请稍后重试");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const archiveMutation = useMutation({
|
const archiveMutation = useMutation({
|
||||||
@@ -226,14 +225,9 @@ export function InvoicePoolPage() {
|
|||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
|
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
|
||||||
setError("");
|
setError("");
|
||||||
|
toast.success("已归档");
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => handleError(err, "归档失败,请稍后重试"),
|
||||||
if (isApiError(err)) {
|
|
||||||
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
|
|
||||||
} else {
|
|
||||||
setError("归档失败,请稍后重试");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const unbindMutation = useMutation({
|
const unbindMutation = useMutation({
|
||||||
@@ -241,14 +235,9 @@ export function InvoicePoolPage() {
|
|||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
|
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
|
||||||
setError("");
|
setError("");
|
||||||
|
toast.success("已解绑");
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => handleError(err, "解绑失败,请稍后重试"),
|
||||||
if (isApiError(err)) {
|
|
||||||
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
|
|
||||||
} else {
|
|
||||||
setError("解绑失败,请稍后重试");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const retryOcrMutation = useMutation({
|
const retryOcrMutation = useMutation({
|
||||||
@@ -256,14 +245,9 @@ export function InvoicePoolPage() {
|
|||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
|
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
|
||||||
setError("");
|
setError("");
|
||||||
|
toast.success("OCR 重试已触发");
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => handleError(err, "OCR 重试失败,请稍后再试"),
|
||||||
if (isApiError(err)) {
|
|
||||||
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
|
|
||||||
} else {
|
|
||||||
setError("OCR 重试失败,请稍后再试");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const triggerVerificationMutation = useMutation({
|
const triggerVerificationMutation = useMutation({
|
||||||
@@ -274,14 +258,9 @@ export function InvoicePoolPage() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ["invoice-pool", "verification"] }),
|
queryClient.invalidateQueries({ queryKey: ["invoice-pool", "verification"] }),
|
||||||
]);
|
]);
|
||||||
setError("");
|
setError("");
|
||||||
|
toast.success("验真已触发");
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => handleError(err, "触发验真失败,请稍后重试"),
|
||||||
if (isApiError(err)) {
|
|
||||||
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
|
|
||||||
} else {
|
|
||||||
setError("触发验真失败,请稍后重试");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const retryVerificationMutation = useMutation({
|
const retryVerificationMutation = useMutation({
|
||||||
@@ -292,14 +271,9 @@ export function InvoicePoolPage() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ["invoice-pool", "verification"] }),
|
queryClient.invalidateQueries({ queryKey: ["invoice-pool", "verification"] }),
|
||||||
]);
|
]);
|
||||||
setError("");
|
setError("");
|
||||||
|
toast.success("验真重试已触发");
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => handleError(err, "重试验真失败,请稍后重试"),
|
||||||
if (isApiError(err)) {
|
|
||||||
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
|
|
||||||
} else {
|
|
||||||
setError("重试验真失败,请稍后重试");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const overrideDuplicateMutation = useMutation({
|
const overrideDuplicateMutation = useMutation({
|
||||||
@@ -311,14 +285,9 @@ export function InvoicePoolPage() {
|
|||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
|
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
|
||||||
setError("");
|
setError("");
|
||||||
|
toast.success("判重例外已生效");
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => handleError(err, "判重例外失败,请稍后重试"),
|
||||||
if (isApiError(err)) {
|
|
||||||
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
|
|
||||||
} else {
|
|
||||||
setError("判重例外失败,请稍后重试");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const exportMutation = useMutation({
|
const exportMutation = useMutation({
|
||||||
@@ -331,25 +300,24 @@ export function InvoicePoolPage() {
|
|||||||
onSuccess: (response, format) => {
|
onSuccess: (response, format) => {
|
||||||
downloadBlob(response.blob, response.filename ?? `invoice-pool.${format}`);
|
downloadBlob(response.blob, response.filename ?? `invoice-pool.${format}`);
|
||||||
setError("");
|
setError("");
|
||||||
|
toast.success("导出完成");
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => handleError(err, "导出失败,请稍后重试"),
|
||||||
if (isApiError(err)) {
|
|
||||||
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
|
|
||||||
} else {
|
|
||||||
setError("导出失败,请稍后重试");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Derived state ---
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE));
|
const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE));
|
||||||
const detail = detailQuery.data;
|
const detail = detailQuery.data;
|
||||||
const canEditDetail = Boolean(
|
const canEditDetail = Boolean(
|
||||||
detail &&
|
detail &&
|
||||||
(canManageInvoice ||
|
(canManageInvoice ||
|
||||||
detail.ownerEmployeeId === me?.id ||
|
detail.ownerEmployeeId === me?.id ||
|
||||||
detail.uploadedByEmployeeId === me?.id),
|
detail.uploadedByEmployeeId === me?.id),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// --- Render ---
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -360,7 +328,7 @@ export function InvoicePoolPage() {
|
|||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
data-testid="invoice-export"
|
data-testid="invoice-export"
|
||||||
variant="secondary"
|
className="btn-secondary"
|
||||||
type="button"
|
type="button"
|
||||||
disabled={exportMutation.isPending}
|
disabled={exportMutation.isPending}
|
||||||
>
|
>
|
||||||
@@ -385,491 +353,72 @@ export function InvoicePoolPage() {
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Card className="space-y-3 p-6">
|
<InvoiceUploadCard
|
||||||
<h3 className="text-base font-semibold text-slate-900">上传票据(VAT OCR)</h3>
|
uploadMutation={uploadMutation}
|
||||||
{showProxyOwnerSelector ? (
|
selectedFile={selectedFile}
|
||||||
<select
|
setSelectedFile={setSelectedFile}
|
||||||
className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
showProxyOwnerSelector={showProxyOwnerSelector}
|
||||||
data-testid="invoice-upload-owner"
|
proxyDelegationsQuery={proxyDelegationsQuery}
|
||||||
onChange={(event) => setUploadOwnerEmployeeId(event.target.value)}
|
uploadOwnerEmployeeId={uploadOwnerEmployeeId}
|
||||||
value={uploadOwnerEmployeeId}
|
setUploadOwnerEmployeeId={setUploadOwnerEmployeeId}
|
||||||
>
|
/>
|
||||||
<option value="">自己</option>
|
|
||||||
{(proxyDelegationsQuery.data?.received ?? [])
|
|
||||||
.filter((item) => item.status === "active")
|
|
||||||
.map((item) => (
|
|
||||||
<option key={item.id} value={item.principalEmployeeId}>
|
|
||||||
{item.principal?.name ?? item.principalEmployeeId}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
) : null}
|
|
||||||
<input
|
|
||||||
accept="application/pdf,image/jpeg,image/png,image/webp"
|
|
||||||
data-testid="invoice-upload-file"
|
|
||||||
onChange={(event) => setSelectedFile(event.target.files?.[0] ?? null)}
|
|
||||||
type="file"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
data-testid="invoice-upload-submit"
|
|
||||||
disabled={uploadMutation.isPending || !selectedFile}
|
|
||||||
onClick={() => uploadMutation.mutate()}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{uploadMutation.isPending ? "上传中..." : "上传并触发OCR"}
|
|
||||||
</Button>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="space-y-3 p-6">
|
<InvoiceListCard
|
||||||
<h3 className="text-base font-semibold text-slate-900">筛选与列表</h3>
|
listQuery={listQuery}
|
||||||
<div className="flex flex-wrap gap-2">
|
status={status}
|
||||||
<select
|
setStatus={setStatus}
|
||||||
className="h-11 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
ownerScope={ownerScope}
|
||||||
data-testid="invoice-filter-status"
|
setOwnerScope={setOwnerScope}
|
||||||
onChange={(event) => {
|
page={page}
|
||||||
setStatus(event.target.value as InvoicePoolStatus | "");
|
setPage={setPage}
|
||||||
setPage(1);
|
totalPages={totalPages}
|
||||||
}}
|
detailInvoiceId={detailInvoiceId}
|
||||||
value={status}
|
setDetailInvoiceId={setDetailInvoiceId}
|
||||||
>
|
signedUrlMutation={signedUrlMutation}
|
||||||
<option value="">全部状态</option>
|
/>
|
||||||
<option value="pending_ocr">pending_ocr</option>
|
|
||||||
<option value="available">available</option>
|
|
||||||
<option value="ocr_failed">ocr_failed</option>
|
|
||||||
<option value="bound">bound</option>
|
|
||||||
<option value="archived">archived</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<select
|
<InvoiceBindCard
|
||||||
className="h-11 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
listQuery={listQuery}
|
||||||
data-testid="invoice-filter-owner-scope"
|
reimbursementQuery={reimbursementQuery}
|
||||||
onChange={(event) => {
|
bindMutation={bindMutation}
|
||||||
setOwnerScope(event.target.value as "own" | "all");
|
selectedInvoiceId={selectedInvoiceId}
|
||||||
setPage(1);
|
setSelectedInvoiceId={setSelectedInvoiceId}
|
||||||
}}
|
selectedReimbursementId={selectedReimbursementId}
|
||||||
value={ownerScope}
|
setSelectedReimbursementId={setSelectedReimbursementId}
|
||||||
>
|
/>
|
||||||
<option value="own">own</option>
|
|
||||||
<option value="all">all</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-lg border border-slate-200">
|
<InvoiceDetailPanel
|
||||||
<Table data-testid="invoice-pool-table">
|
detail={detail}
|
||||||
<TableHeader>
|
detailQuery={detailQuery}
|
||||||
<tr>
|
canEditDetail={canEditDetail}
|
||||||
<TableHead>ID</TableHead>
|
canVerifyInvoice={canVerifyInvoice}
|
||||||
<TableHead>状态</TableHead>
|
canUploadInvoice={canUploadInvoice}
|
||||||
<TableHead>发票号</TableHead>
|
canOverrideDuplicate={canOverrideDuplicate}
|
||||||
<TableHead>OCR</TableHead>
|
patchMutation={patchMutation}
|
||||||
<TableHead className="w-40">操作</TableHead>
|
archiveMutation={archiveMutation}
|
||||||
</tr>
|
unbindMutation={unbindMutation}
|
||||||
</TableHeader>
|
retryOcrMutation={retryOcrMutation}
|
||||||
<TableBody>
|
triggerVerificationMutation={triggerVerificationMutation}
|
||||||
{(listQuery.data?.items ?? []).length ? (
|
retryVerificationMutation={retryVerificationMutation}
|
||||||
listQuery.data?.items.map((item) => (
|
overrideDuplicateMutation={overrideDuplicateMutation}
|
||||||
<TableRow data-testid="invoice-row" key={item.id}>
|
verificationQuery={verificationQuery}
|
||||||
<TableCell className="font-mono text-xs">{item.id}</TableCell>
|
editOpen={editOpen}
|
||||||
<TableCell>{item.status}</TableCell>
|
setEditOpen={setEditOpen}
|
||||||
<TableCell>
|
editSetAvailable={editSetAvailable}
|
||||||
{item.invoiceCode || "-"}-{item.invoiceNumber || "-"}
|
setEditSetAvailable={setEditSetAvailable}
|
||||||
</TableCell>
|
editRemarks={editRemarks}
|
||||||
<TableCell>
|
setEditRemarks={setEditRemarks}
|
||||||
{item.status === "pending_ocr" ? (
|
editInvoiceCode={editInvoiceCode}
|
||||||
<span className="text-amber-700">
|
setEditInvoiceCode={setEditInvoiceCode}
|
||||||
OCR 处理中(attempts={item.ocrAttempts})
|
editInvoiceNumber={editInvoiceNumber}
|
||||||
</span>
|
setEditInvoiceNumber={setEditInvoiceNumber}
|
||||||
) : item.status === "ocr_failed" ? (
|
editInvoiceDate={editInvoiceDate}
|
||||||
<span className="text-red-600">
|
setEditInvoiceDate={setEditInvoiceDate}
|
||||||
OCR 失败({item.ocrLastError || "unknown"})
|
editTotalAmount={editTotalAmount}
|
||||||
</span>
|
setEditTotalAmount={setEditTotalAmount}
|
||||||
) : (
|
confirmAction={confirmAction}
|
||||||
<span className="text-slate-600">attempts={item.ocrAttempts}</span>
|
setConfirmAction={setConfirmAction}
|
||||||
)}
|
/>
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Button
|
|
||||||
data-testid="invoice-detail-button"
|
|
||||||
size="sm"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => setDetailInvoiceId(item.id)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
详情
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
data-testid="invoice-preview-button"
|
|
||||||
size="sm"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => signedUrlMutation.mutate(item.id)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
预览
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell className="py-3 text-slate-500" colSpan={5}>
|
|
||||||
暂无数据
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span>
|
|
||||||
第 {page} / {totalPages} 页
|
|
||||||
</span>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
data-testid="invoice-prev-page"
|
|
||||||
variant="secondary"
|
|
||||||
disabled={page <= 1}
|
|
||||||
onClick={() => setPage((current) => Math.max(1, current - 1))}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
上一页
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
data-testid="invoice-next-page"
|
|
||||||
variant="secondary"
|
|
||||||
disabled={page >= totalPages}
|
|
||||||
onClick={() => setPage((current) => Math.min(totalPages, current + 1))}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
下一页
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="space-y-3 p-6">
|
|
||||||
<h3 className="text-base font-semibold text-slate-900">绑定报销</h3>
|
|
||||||
<select
|
|
||||||
className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
|
||||||
data-testid="invoice-bind-invoice"
|
|
||||||
onChange={(event) => setSelectedInvoiceId(event.target.value)}
|
|
||||||
value={selectedInvoiceId}
|
|
||||||
>
|
|
||||||
<option value="">选择票据</option>
|
|
||||||
{(listQuery.data?.items ?? []).map((item) => (
|
|
||||||
<option key={item.id} value={item.id}>
|
|
||||||
{item.id} ({item.status})
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<select
|
|
||||||
className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
|
||||||
data-testid="invoice-bind-reimbursement"
|
|
||||||
onChange={(event) => setSelectedReimbursementId(event.target.value)}
|
|
||||||
value={selectedReimbursementId}
|
|
||||||
>
|
|
||||||
<option value="">选择报销单</option>
|
|
||||||
{(reimbursementQuery.data?.items ?? []).map((item) => (
|
|
||||||
<option key={item.id} value={item.id}>
|
|
||||||
{item.formNumber ?? item.id} ({item.status})
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
data-testid="invoice-bind-submit"
|
|
||||||
variant="secondary"
|
|
||||||
disabled={bindMutation.isPending || !selectedInvoiceId || !selectedReimbursementId}
|
|
||||||
onClick={() => bindMutation.mutate()}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{bindMutation.isPending ? "绑定中..." : "绑定票据"}
|
|
||||||
</Button>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="space-y-2 p-6 text-sm" data-testid="invoice-detail-panel">
|
|
||||||
<h3 className="text-base font-semibold text-slate-900">详情轮询</h3>
|
|
||||||
{detail ? (
|
|
||||||
<>
|
|
||||||
<div>票据ID:{detail.id}</div>
|
|
||||||
<div>状态:{detail.status}</div>
|
|
||||||
<div>OCR错误:{detail.ocrLastError || "-"}</div>
|
|
||||||
<div>
|
|
||||||
发票号:{detail.invoiceCode || "-"}-{detail.invoiceNumber || "-"}
|
|
||||||
</div>
|
|
||||||
<div>开票日期:{detail.invoiceDate || "-"}</div>
|
|
||||||
<div>总金额:{detail.totalAmount || "-"}</div>
|
|
||||||
<div>备注:{detail.remarks || "-"}</div>
|
|
||||||
<div>关联报销:{detail.linkedReimbursementId || "-"}</div>
|
|
||||||
<div>
|
|
||||||
判重例外:{detail.isDuplicateOverride ? "已例外" : "否"}{" "}
|
|
||||||
{detail.overrideRefFormNumber ? `(ref=${detail.overrideRefFormNumber})` : ""}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
验真状态:
|
|
||||||
{detail.verification
|
|
||||||
? `${detail.verification.status} (${detail.verification.provider})`
|
|
||||||
: "未触发"}
|
|
||||||
</div>
|
|
||||||
{detail.verification?.resultMessage ? (
|
|
||||||
<div>验真结果:{detail.verification.resultMessage}</div>
|
|
||||||
) : null}
|
|
||||||
{detail.verification?.errorMessage ? (
|
|
||||||
<div className="text-red-600">验真错误:{detail.verification.errorMessage}</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2 pt-2">
|
|
||||||
{canVerifyInvoice ? (
|
|
||||||
<Button
|
|
||||||
data-testid="invoice-trigger-verification"
|
|
||||||
size="sm"
|
|
||||||
variant="secondary"
|
|
||||||
type="button"
|
|
||||||
disabled={triggerVerificationMutation.isPending || detail.status === "pending_ocr"}
|
|
||||||
onClick={() => triggerVerificationMutation.mutate(detail.id)}
|
|
||||||
>
|
|
||||||
{triggerVerificationMutation.isPending ? "触发中..." : "触发验真"}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{canVerifyInvoice &&
|
|
||||||
detail.verification &&
|
|
||||||
["failed", "error"].includes(detail.verification.status) ? (
|
|
||||||
<Button
|
|
||||||
data-testid="invoice-retry-verification"
|
|
||||||
size="sm"
|
|
||||||
variant="secondary"
|
|
||||||
type="button"
|
|
||||||
disabled={retryVerificationMutation.isPending}
|
|
||||||
onClick={() => retryVerificationMutation.mutate(detail.id)}
|
|
||||||
>
|
|
||||||
{retryVerificationMutation.isPending ? "重试中..." : "重试验真"}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{canEditDetail ? (
|
|
||||||
<Button
|
|
||||||
data-testid="invoice-edit-open"
|
|
||||||
size="sm"
|
|
||||||
variant="secondary"
|
|
||||||
type="button"
|
|
||||||
disabled={patchMutation.isPending}
|
|
||||||
onClick={() => setEditOpen((prev) => !prev)}
|
|
||||||
>
|
|
||||||
{editOpen ? "关闭编辑" : "人工修正"}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{canEditDetail && detail.status === "ocr_failed" ? (
|
|
||||||
<Button
|
|
||||||
data-testid="invoice-retry-ocr"
|
|
||||||
size="sm"
|
|
||||||
variant="secondary"
|
|
||||||
type="button"
|
|
||||||
disabled={retryOcrMutation.isPending}
|
|
||||||
onClick={() => {
|
|
||||||
if (!window.confirm("确认重试 OCR?将重新进入 pending_ocr 并消耗一次尝试次数。")) return;
|
|
||||||
retryOcrMutation.mutate(detail.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
重试 OCR
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{canEditDetail && canUploadInvoice && detail.status === "bound" ? (
|
|
||||||
<Button
|
|
||||||
data-testid="invoice-unbind"
|
|
||||||
size="sm"
|
|
||||||
variant="secondary"
|
|
||||||
type="button"
|
|
||||||
disabled={unbindMutation.isPending}
|
|
||||||
onClick={() => {
|
|
||||||
if (!window.confirm("确认解绑该票据?仅草稿/驳回报销允许解绑。")) return;
|
|
||||||
unbindMutation.mutate(detail.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
解绑
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{canEditDetail && detail.status !== "bound" && detail.status !== "archived" ? (
|
|
||||||
<Button
|
|
||||||
data-testid="invoice-archive"
|
|
||||||
size="sm"
|
|
||||||
variant="secondary"
|
|
||||||
type="button"
|
|
||||||
disabled={archiveMutation.isPending}
|
|
||||||
onClick={() => {
|
|
||||||
if (!window.confirm("确认归档该票据?归档后将不再用于绑定与判重。")) return;
|
|
||||||
archiveMutation.mutate(detail.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
手工归档
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{canOverrideDuplicate &&
|
|
||||||
!detail.isDuplicateOverride &&
|
|
||||||
["available", "ocr_failed"].includes(detail.status) ? (
|
|
||||||
<Button
|
|
||||||
data-testid="invoice-override-duplicate"
|
|
||||||
size="sm"
|
|
||||||
variant="secondary"
|
|
||||||
type="button"
|
|
||||||
disabled={overrideDuplicateMutation.isPending}
|
|
||||||
onClick={() => {
|
|
||||||
if (!window.confirm("确认执行判重例外?该操作将写入审计并影响判重口径。")) return;
|
|
||||||
const refFormNumber = window.prompt("请输入参考单号(必填)");
|
|
||||||
if (!refFormNumber?.trim()) return;
|
|
||||||
const reason = window.prompt("请输入例外原因(必填)");
|
|
||||||
if (!reason?.trim()) return;
|
|
||||||
overrideDuplicateMutation.mutate({
|
|
||||||
invoiceId: detail.id,
|
|
||||||
refFormNumber: refFormNumber.trim(),
|
|
||||||
reason: reason.trim(),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
判重例外
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{canVerifyInvoice ? (
|
|
||||||
<div className="mt-3 rounded-lg border border-slate-200 p-3" data-testid="invoice-verification-records">
|
|
||||||
<p className="mb-2 text-sm font-medium text-slate-800">验真记录</p>
|
|
||||||
{(verificationQuery.data?.items ?? []).length ? (
|
|
||||||
<div className="space-y-2 text-xs">
|
|
||||||
{(verificationQuery.data?.items ?? []).map((record) => (
|
|
||||||
<div key={record.id} className="rounded-md border border-slate-200 px-2 py-1">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<span className="font-mono">{record.id}</span>
|
|
||||||
<span>{record.status}</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-slate-600">
|
|
||||||
provider={record.provider} attempts={record.attempts} requestedAt={record.requestedAt || "-"}
|
|
||||||
</div>
|
|
||||||
{record.resultMessage ? <div className="text-slate-700">{record.resultMessage}</div> : null}
|
|
||||||
{record.errorMessage ? <div className="text-red-600">{record.errorMessage}</div> : null}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-xs text-slate-500">暂无验真记录</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{editOpen ? (
|
|
||||||
<div className="mt-4 space-y-3 rounded-lg border border-slate-200 p-4">
|
|
||||||
<p className="text-sm font-medium text-slate-800">人工修正(仅必要字段)</p>
|
|
||||||
|
|
||||||
{detail.status === "ocr_failed" ? (
|
|
||||||
<label className="flex items-center gap-2 text-sm">
|
|
||||||
<input
|
|
||||||
data-testid="invoice-edit-set-available"
|
|
||||||
checked={editSetAvailable}
|
|
||||||
onChange={(event) => setEditSetAvailable(event.target.checked)}
|
|
||||||
type="checkbox"
|
|
||||||
/>
|
|
||||||
<span>将状态标记为 available(人工确认可用)</span>
|
|
||||||
</label>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="text-xs text-slate-600" htmlFor="invoiceCode">
|
|
||||||
发票代码
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
|
||||||
data-testid="invoice-edit-code"
|
|
||||||
id="invoiceCode"
|
|
||||||
onChange={(event) => setEditInvoiceCode(event.target.value)}
|
|
||||||
value={editInvoiceCode}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="text-xs text-slate-600" htmlFor="invoiceNumber">
|
|
||||||
发票号码
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
|
||||||
data-testid="invoice-edit-number"
|
|
||||||
id="invoiceNumber"
|
|
||||||
onChange={(event) => setEditInvoiceNumber(event.target.value)}
|
|
||||||
value={editInvoiceNumber}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="text-xs text-slate-600" htmlFor="invoiceDate">
|
|
||||||
开票日期
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
|
||||||
data-testid="invoice-edit-date"
|
|
||||||
id="invoiceDate"
|
|
||||||
onChange={(event) => setEditInvoiceDate(event.target.value)}
|
|
||||||
type="date"
|
|
||||||
value={editInvoiceDate}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="text-xs text-slate-600" htmlFor="invoiceTotalAmount">
|
|
||||||
总金额
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
|
||||||
data-testid="invoice-edit-total-amount"
|
|
||||||
id="invoiceTotalAmount"
|
|
||||||
onChange={(event) => setEditTotalAmount(event.target.value)}
|
|
||||||
value={editTotalAmount}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1 md:col-span-2">
|
|
||||||
<label className="text-xs text-slate-600" htmlFor="invoiceRemarks">
|
|
||||||
备注
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
|
||||||
data-testid="invoice-edit-remarks"
|
|
||||||
id="invoiceRemarks"
|
|
||||||
onChange={(event) => setEditRemarks(event.target.value)}
|
|
||||||
rows={2}
|
|
||||||
value={editRemarks}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Button
|
|
||||||
data-testid="invoice-edit-save"
|
|
||||||
disabled={patchMutation.isPending}
|
|
||||||
onClick={() => patchMutation.mutate()}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{patchMutation.isPending ? "保存中..." : "保存修正"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
data-testid="invoice-edit-cancel"
|
|
||||||
variant="secondary"
|
|
||||||
disabled={patchMutation.isPending}
|
|
||||||
onClick={() => setEditOpen(false)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p className="text-slate-500">请选择一条票据查看详情</p>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,14 +14,21 @@ import {
|
|||||||
withdrawReimbursement,
|
withdrawReimbursement,
|
||||||
type ReimbursementItem,
|
type ReimbursementItem,
|
||||||
} from "../api/reimbursements";
|
} from "../api/reimbursements";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
import { hasPermission } from "../auth/permissions";
|
import { hasPermission } from "../auth/permissions";
|
||||||
|
import { CardSkeleton } from "../components/common/CardSkeleton";
|
||||||
|
import { ConfirmDialog } from "../components/common/ConfirmDialog";
|
||||||
import { PageHeader } from "../components/common/PageHeader";
|
import { PageHeader } from "../components/common/PageHeader";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import { Card } from "../components/ui/card";
|
import { Card } from "../components/ui/card";
|
||||||
import { Input } from "../components/ui/input";
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||||
import { Textarea } from "../components/ui/textarea";
|
import { ReimbursementActions, type PayPayload } from "./reimbursement-detail/ReimbursementActions";
|
||||||
|
import {
|
||||||
|
ReimbursementItemEditor,
|
||||||
|
type ItemEditorInitialValues,
|
||||||
|
type ItemEditorSaveData,
|
||||||
|
} from "./reimbursement-detail/ReimbursementItemEditor";
|
||||||
|
|
||||||
type ItemEditorState = { mode: "create" } | { mode: "edit"; itemId: string } | null;
|
type ItemEditorState = { mode: "create" } | { mode: "edit"; itemId: string } | null;
|
||||||
|
|
||||||
@@ -30,20 +37,17 @@ export function ReimbursementDetailPage() {
|
|||||||
const { permissions } = useAuth();
|
const { permissions } = useAuth();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [withdrawComment, setWithdrawComment] = useState("申请撤回并补充说明后重提");
|
|
||||||
const [paymentVoucherNo, setPaymentVoucherNo] = useState("");
|
|
||||||
const [paymentDate, setPaymentDate] = useState("2026-02-08");
|
|
||||||
const [paymentChannel, setPaymentChannel] = useState<"bank" | "alipay" | "wechat" | "cash" | "other">(
|
|
||||||
"bank",
|
|
||||||
);
|
|
||||||
const [actionError, setActionError] = useState("");
|
const [actionError, setActionError] = useState("");
|
||||||
|
|
||||||
const [itemEditor, setItemEditor] = useState<ItemEditorState>(null);
|
const [itemEditor, setItemEditor] = useState<ItemEditorState>(null);
|
||||||
const [itemExpenseTypeId, setItemExpenseTypeId] = useState("");
|
|
||||||
const [itemExpenseDate, setItemExpenseDate] = useState("");
|
|
||||||
const [itemAmount, setItemAmount] = useState("");
|
|
||||||
const [itemDescription, setItemDescription] = useState("");
|
|
||||||
const [itemError, setItemError] = useState("");
|
const [itemError, setItemError] = useState("");
|
||||||
|
const [confirmDeleteItemId, setConfirmDeleteItemId] = useState("");
|
||||||
|
const [editorInitialValues, setEditorInitialValues] = useState<ItemEditorInitialValues>({
|
||||||
|
expenseTypeId: "",
|
||||||
|
expenseDate: "",
|
||||||
|
amount: "",
|
||||||
|
description: "",
|
||||||
|
});
|
||||||
|
|
||||||
const detailQuery = useQuery({
|
const detailQuery = useQuery({
|
||||||
enabled: Boolean(reimbursementId),
|
enabled: Boolean(reimbursementId),
|
||||||
@@ -57,51 +61,55 @@ export function ReimbursementDetailPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: async (action: "submit" | "withdraw" | "pay") => {
|
mutationFn: async (params: { action: "submit" } | { action: "withdraw"; comment: string } | { action: "pay"; payload: PayPayload }) => {
|
||||||
if (!reimbursementId) {
|
if (!reimbursementId) {
|
||||||
throw new Error("missing reimbursementId");
|
throw new Error("missing reimbursementId");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === "submit") {
|
if (params.action === "submit") {
|
||||||
return submitReimbursement(reimbursementId);
|
return submitReimbursement(reimbursementId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === "withdraw") {
|
if (params.action === "withdraw") {
|
||||||
return withdrawReimbursement(reimbursementId, withdrawComment.trim());
|
return withdrawReimbursement(reimbursementId, params.comment);
|
||||||
}
|
}
|
||||||
|
|
||||||
return payReimbursement(reimbursementId, {
|
return payReimbursement(reimbursementId, {
|
||||||
paymentVoucherNo: paymentVoucherNo.trim() || undefined,
|
paymentVoucherNo: params.payload.paymentVoucherNo || undefined,
|
||||||
paymentDate: paymentDate || undefined,
|
paymentDate: params.payload.paymentDate || undefined,
|
||||||
paymentChannel,
|
paymentChannel: params.payload.paymentChannel,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async (_data, params) => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: ["reimbursements"] }),
|
queryClient.invalidateQueries({ queryKey: ["reimbursements"] }),
|
||||||
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
|
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
|
||||||
]);
|
]);
|
||||||
setActionError("");
|
setActionError("");
|
||||||
|
const actionMessages: Record<string, string> = { submit: "已提交", withdraw: "已撤回", pay: "付款成功" };
|
||||||
|
toast.success(actionMessages[params.action] ?? "操作成功");
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
if (isApiError(error)) {
|
if (isApiError(error)) {
|
||||||
setActionError(
|
const msg = `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||||||
`${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`,
|
setActionError(msg);
|
||||||
);
|
toast.error(msg);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
setActionError(error.message);
|
setActionError(error.message);
|
||||||
|
toast.error(error.message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setActionError("操作失败,请稍后重试");
|
setActionError("操作失败,请稍后重试");
|
||||||
|
toast.error("操作失败,请稍后重试");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const itemSaveMutation = useMutation({
|
const itemSaveMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async (data: ItemEditorSaveData) => {
|
||||||
if (!reimbursementId) {
|
if (!reimbursementId) {
|
||||||
throw new Error("missing reimbursementId");
|
throw new Error("missing reimbursementId");
|
||||||
}
|
}
|
||||||
@@ -110,10 +118,10 @@ export function ReimbursementDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
expenseTypeId: itemExpenseTypeId,
|
expenseTypeId: data.expenseTypeId,
|
||||||
expenseDate: itemExpenseDate,
|
expenseDate: data.expenseDate,
|
||||||
amount: itemAmount,
|
amount: data.amount,
|
||||||
description: itemDescription.trim() || undefined,
|
description: data.description.trim() || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (itemEditor.mode === "create") {
|
if (itemEditor.mode === "create") {
|
||||||
@@ -129,19 +137,24 @@ export function ReimbursementDetailPage() {
|
|||||||
]);
|
]);
|
||||||
setItemEditor(null);
|
setItemEditor(null);
|
||||||
setItemError("");
|
setItemError("");
|
||||||
|
toast.success("明细已保存");
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
if (isApiError(error)) {
|
if (isApiError(error)) {
|
||||||
setItemError(`${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`);
|
const msg = `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||||||
|
setItemError(msg);
|
||||||
|
toast.error(msg);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
setItemError(error.message);
|
setItemError(error.message);
|
||||||
|
toast.error(error.message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setItemError("保存明细失败,请稍后重试");
|
setItemError("保存明细失败,请稍后重试");
|
||||||
|
toast.error("保存明细失败,请稍后重试");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -159,19 +172,24 @@ export function ReimbursementDetailPage() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
|
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
|
||||||
]);
|
]);
|
||||||
setItemError("");
|
setItemError("");
|
||||||
|
toast.success("明细已删除");
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
if (isApiError(error)) {
|
if (isApiError(error)) {
|
||||||
setItemError(`${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`);
|
const msg = `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||||||
|
setItemError(msg);
|
||||||
|
toast.error(msg);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
setItemError(error.message);
|
setItemError(error.message);
|
||||||
|
toast.error(error.message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setItemError("删除明细失败,请稍后重试");
|
setItemError("删除明细失败,请稍后重试");
|
||||||
|
toast.error("删除明细失败,请稍后重试");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -184,11 +202,7 @@ export function ReimbursementDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (detailQuery.isLoading) {
|
if (detailQuery.isLoading) {
|
||||||
return (
|
return <CardSkeleton lines={8} />;
|
||||||
<Card className="p-6">
|
|
||||||
<p className="text-sm text-slate-500">加载报销详情中...</p>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (detailQuery.isError || !detailQuery.data) {
|
if (detailQuery.isError || !detailQuery.data) {
|
||||||
@@ -216,30 +230,43 @@ export function ReimbursementDetailPage() {
|
|||||||
const canEditItems = ["draft", "rejected"].includes(detail.status);
|
const canEditItems = ["draft", "rejected"].includes(detail.status);
|
||||||
|
|
||||||
const expenseTypes = expenseTypesQuery.data?.items ?? [];
|
const expenseTypes = expenseTypesQuery.data?.items ?? [];
|
||||||
const ensureEditorDefaults = (item?: ReimbursementItem) => {
|
|
||||||
|
const computeEditorDefaults = (item?: ReimbursementItem): ItemEditorInitialValues => {
|
||||||
const defaultDate =
|
const defaultDate =
|
||||||
item?.expenseDate ??
|
item?.expenseDate ??
|
||||||
detail.reimbursementDate ??
|
detail.reimbursementDate ??
|
||||||
new Date().toISOString().slice(0, 10);
|
new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
const defaultExpenseTypeId =
|
const defaultExpenseTypeId =
|
||||||
item?.expenseTypeId ?? expenseTypes[0]?.id ?? itemExpenseTypeId;
|
item?.expenseTypeId ?? expenseTypes[0]?.id ?? "";
|
||||||
|
|
||||||
setItemExpenseDate(defaultDate);
|
return {
|
||||||
setItemExpenseTypeId(defaultExpenseTypeId || "");
|
expenseDate: defaultDate,
|
||||||
setItemAmount(item?.amount ?? "");
|
expenseTypeId: defaultExpenseTypeId,
|
||||||
setItemDescription(item?.description ?? "");
|
amount: item?.amount ?? "",
|
||||||
setItemError("");
|
description: item?.description ?? "",
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const startCreateItem = () => {
|
const startCreateItem = () => {
|
||||||
setItemEditor({ mode: "create" });
|
setItemEditor({ mode: "create" });
|
||||||
ensureEditorDefaults();
|
setEditorInitialValues(computeEditorDefaults());
|
||||||
|
setItemError("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const startEditItem = (item: ReimbursementItem) => {
|
const startEditItem = (item: ReimbursementItem) => {
|
||||||
setItemEditor({ mode: "edit", itemId: item.id });
|
setItemEditor({ mode: "edit", itemId: item.id });
|
||||||
ensureEditorDefaults(item);
|
setEditorInitialValues(computeEditorDefaults(item));
|
||||||
|
setItemError("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleItemSave = (data: ItemEditorSaveData) => {
|
||||||
|
itemSaveMutation.mutate(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleItemCancel = () => {
|
||||||
|
setItemEditor(null);
|
||||||
|
setItemError("");
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -359,10 +386,7 @@ export function ReimbursementDetailPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
disabled={itemDeleteMutation.isPending}
|
disabled={itemDeleteMutation.isPending}
|
||||||
onClick={() => {
|
onClick={() => setConfirmDeleteItemId(item.id)}
|
||||||
if (!window.confirm("确认删除该明细?")) return;
|
|
||||||
itemDeleteMutation.mutate(item.id);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
删除
|
删除
|
||||||
</Button>
|
</Button>
|
||||||
@@ -383,199 +407,29 @@ export function ReimbursementDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{itemEditor ? (
|
{itemEditor ? (
|
||||||
<div className="space-y-3 rounded-lg border border-slate-200 p-4" data-testid="reimbursement-item-editor">
|
<ReimbursementItemEditor
|
||||||
<p className="text-sm font-medium text-slate-800">
|
mode={itemEditor.mode}
|
||||||
{itemEditor.mode === "create" ? "新增明细" : "编辑明细"}
|
expenseTypes={expenseTypes}
|
||||||
</p>
|
expenseTypesLoading={expenseTypesQuery.isLoading}
|
||||||
|
expenseTypesError={expenseTypesQuery.isError}
|
||||||
{expenseTypesQuery.isLoading ? (
|
initialValues={editorInitialValues}
|
||||||
<p className="text-sm text-slate-500">加载费用类型中...</p>
|
onSave={handleItemSave}
|
||||||
) : null}
|
onCancel={handleItemCancel}
|
||||||
|
isPending={itemSaveMutation.isPending}
|
||||||
{expenseTypesQuery.isError ? (
|
|
||||||
<p className="text-sm text-red-600">费用类型加载失败,请刷新页面重试。</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="grid gap-3 md:grid-cols-4">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="text-xs text-slate-600" htmlFor="expenseTypeId">
|
|
||||||
费用类型
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
|
||||||
data-testid="reimbursement-item-expense-type"
|
|
||||||
disabled={expenseTypes.length === 0 || itemSaveMutation.isPending}
|
|
||||||
id="expenseTypeId"
|
|
||||||
onChange={(event) => setItemExpenseTypeId(event.target.value)}
|
|
||||||
value={itemExpenseTypeId}
|
|
||||||
>
|
|
||||||
{expenseTypes.length === 0 ? <option value="">暂无可用费用类型</option> : null}
|
|
||||||
{expenseTypes.map((type) => (
|
|
||||||
<option key={type.id} value={type.id}>
|
|
||||||
{type.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="text-xs text-slate-600" htmlFor="expenseDate">
|
|
||||||
发生日期
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
data-testid="reimbursement-item-expense-date"
|
|
||||||
disabled={itemSaveMutation.isPending}
|
|
||||||
id="expenseDate"
|
|
||||||
onChange={(event) => setItemExpenseDate(event.target.value)}
|
|
||||||
type="date"
|
|
||||||
value={itemExpenseDate}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="text-xs text-slate-600" htmlFor="amount">
|
|
||||||
金额
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
data-testid="reimbursement-item-amount"
|
|
||||||
disabled={itemSaveMutation.isPending}
|
|
||||||
id="amount"
|
|
||||||
onChange={(event) => setItemAmount(event.target.value)}
|
|
||||||
placeholder="例如 123.45"
|
|
||||||
value={itemAmount}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1 md:col-span-4">
|
|
||||||
<label className="text-xs text-slate-600" htmlFor="itemDescription">
|
|
||||||
说明
|
|
||||||
</label>
|
|
||||||
<Textarea
|
|
||||||
data-testid="reimbursement-item-description"
|
|
||||||
disabled={itemSaveMutation.isPending}
|
|
||||||
id="itemDescription"
|
|
||||||
onChange={(event) => setItemDescription(event.target.value)}
|
|
||||||
rows={2}
|
|
||||||
value={itemDescription}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Button
|
|
||||||
data-testid="reimbursement-item-save"
|
|
||||||
disabled={
|
|
||||||
itemSaveMutation.isPending ||
|
|
||||||
!itemExpenseTypeId ||
|
|
||||||
!itemExpenseDate ||
|
|
||||||
!itemAmount.trim()
|
|
||||||
}
|
|
||||||
onClick={() => itemSaveMutation.mutate()}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{itemSaveMutation.isPending ? "保存中..." : "保存明细"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
data-testid="reimbursement-item-cancel"
|
|
||||||
variant="secondary"
|
|
||||||
disabled={itemSaveMutation.isPending}
|
|
||||||
onClick={() => {
|
|
||||||
setItemEditor(null);
|
|
||||||
setItemError("");
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="space-y-4 p-6">
|
|
||||||
<h3 className="text-base font-semibold text-slate-900">可执行动作</h3>
|
|
||||||
|
|
||||||
{actionError ? (
|
|
||||||
<p
|
|
||||||
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600"
|
|
||||||
data-testid="reimbursement-action-error"
|
|
||||||
>
|
|
||||||
{actionError}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Button
|
|
||||||
data-testid="reimbursement-submit"
|
|
||||||
disabled={mutation.isPending || detail.status !== "draft"}
|
|
||||||
onClick={() => mutation.mutate("submit")}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
提交报销
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2 rounded-lg border border-slate-200 p-4">
|
|
||||||
<p className="text-sm font-medium text-slate-800">撤回报销</p>
|
|
||||||
<Textarea
|
|
||||||
data-testid="reimbursement-withdraw-comment"
|
|
||||||
onChange={(event) => setWithdrawComment(event.target.value)}
|
|
||||||
rows={2}
|
|
||||||
value={withdrawComment}
|
|
||||||
/>
|
/>
|
||||||
<Button
|
|
||||||
data-testid="reimbursement-withdraw"
|
|
||||||
variant="secondary"
|
|
||||||
disabled={mutation.isPending || detail.status !== "reviewing" || withdrawComment.trim().length < 1}
|
|
||||||
onClick={() => mutation.mutate("withdraw")}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
撤回
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{canPay ? (
|
|
||||||
<div className="space-y-2 rounded-lg border border-slate-200 p-4">
|
|
||||||
<p className="text-sm font-medium text-slate-800">财务支付</p>
|
|
||||||
<Input
|
|
||||||
data-testid="reimbursement-pay-voucher-no"
|
|
||||||
onChange={(event) => setPaymentVoucherNo(event.target.value)}
|
|
||||||
placeholder="付款凭证号"
|
|
||||||
value={paymentVoucherNo}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
data-testid="reimbursement-pay-date"
|
|
||||||
onChange={(event) => setPaymentDate(event.target.value)}
|
|
||||||
type="date"
|
|
||||||
value={paymentDate}
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
|
||||||
data-testid="reimbursement-pay-channel"
|
|
||||||
onChange={(event) =>
|
|
||||||
setPaymentChannel(event.target.value as "bank" | "alipay" | "wechat" | "cash" | "other")
|
|
||||||
}
|
|
||||||
value={paymentChannel}
|
|
||||||
>
|
|
||||||
<option value="bank">bank</option>
|
|
||||||
<option value="alipay">alipay</option>
|
|
||||||
<option value="wechat">wechat</option>
|
|
||||||
<option value="cash">cash</option>
|
|
||||||
<option value="other">other</option>
|
|
||||||
</select>
|
|
||||||
<Button
|
|
||||||
data-testid="reimbursement-pay"
|
|
||||||
variant="secondary"
|
|
||||||
disabled={mutation.isPending || !["approved", "paid"].includes(detail.status)}
|
|
||||||
onClick={() => mutation.mutate("pay")}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
执行支付
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<ReimbursementActions
|
||||||
|
status={detail.status}
|
||||||
|
canPay={canPay}
|
||||||
|
actionError={actionError}
|
||||||
|
isPending={mutation.isPending}
|
||||||
|
onSubmit={() => mutation.mutate({ action: "submit" })}
|
||||||
|
onWithdraw={(comment) => mutation.mutate({ action: "withdraw", comment })}
|
||||||
|
onPay={(payload) => mutation.mutate({ action: "pay", payload })}
|
||||||
|
/>
|
||||||
|
|
||||||
<Card className="space-y-2 p-6" data-testid="reimbursement-invoices">
|
<Card className="space-y-2 p-6" data-testid="reimbursement-invoices">
|
||||||
<h3 className="text-base font-semibold text-slate-900">关联票据</h3>
|
<h3 className="text-base font-semibold text-slate-900">关联票据</h3>
|
||||||
{(detail.invoices ?? []).length ? (
|
{(detail.invoices ?? []).length ? (
|
||||||
@@ -593,6 +447,15 @@ export function ReimbursementDetailPage() {
|
|||||||
<p className="text-sm text-slate-500">暂无关联票据</p>
|
<p className="text-sm text-slate-500">暂无关联票据</p>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={Boolean(confirmDeleteItemId)}
|
||||||
|
onOpenChange={(open) => { if (!open) setConfirmDeleteItemId(""); }}
|
||||||
|
title="确认删除该明细?"
|
||||||
|
variant="destructive"
|
||||||
|
confirmLabel="删除"
|
||||||
|
onConfirm={() => { itemDeleteMutation.mutate(confirmDeleteItemId); }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+168
-134
@@ -8,8 +8,12 @@ import { exportReimbursements } from "../api/exports";
|
|||||||
import { createReimbursement, fetchReimbursements, type ReimbursementStatus } from "../api/reimbursements";
|
import { createReimbursement, fetchReimbursements, type ReimbursementStatus } from "../api/reimbursements";
|
||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
import { hasPermission } from "../auth/permissions";
|
import { hasPermission } from "../auth/permissions";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { downloadBlob } from "../lib/download";
|
import { downloadBlob } from "../lib/download";
|
||||||
import { PageHeader } from "../components/common/PageHeader";
|
import { PageHeader } from "../components/common/PageHeader";
|
||||||
|
import { TableSkeleton } from "../components/common/TableSkeleton";
|
||||||
|
import { StatusBadge } from "../components/common/StatusBadge";
|
||||||
|
import { Pagination } from "../components/common/Pagination";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import { Card } from "../components/ui/card";
|
import { Card } from "../components/ui/card";
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../components/ui/dropdown-menu";
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../components/ui/dropdown-menu";
|
||||||
@@ -52,6 +56,14 @@ export function ReimbursementsPage() {
|
|||||||
}),
|
}),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await queryClient.invalidateQueries({ queryKey: ["reimbursements"] });
|
await queryClient.invalidateQueries({ queryKey: ["reimbursements"] });
|
||||||
|
toast.success("报销单已创建");
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(
|
||||||
|
isApiError(err)
|
||||||
|
? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`
|
||||||
|
: "创建报销单失败",
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -60,13 +72,17 @@ export function ReimbursementsPage() {
|
|||||||
onSuccess: (response, format) => {
|
onSuccess: (response, format) => {
|
||||||
downloadBlob(response.blob, response.filename ?? `reimbursements.${format}`);
|
downloadBlob(response.blob, response.filename ?? `reimbursements.${format}`);
|
||||||
setExportError("");
|
setExportError("");
|
||||||
|
toast.success("导出完成");
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
if (isApiError(err)) {
|
if (isApiError(err)) {
|
||||||
setExportError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
|
setExportError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
|
||||||
|
toast.error(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setExportError(err instanceof Error ? err.message : "导出失败");
|
const msg = err instanceof Error ? err.message : "导出失败";
|
||||||
|
setExportError(msg);
|
||||||
|
toast.error(msg);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -153,148 +169,166 @@ export function ReimbursementsPage() {
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Card className="space-y-4 p-6">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
<div className="card-std">
|
||||||
<Tabs
|
<p className="card-title">待我审批</p>
|
||||||
data-testid="reimbursements-status-tabs"
|
<div className="flex items-end justify-between">
|
||||||
value={status}
|
<h3 className="card-value">12</h3>
|
||||||
onValueChange={(value) => setSearchParams({ status: value, page: "1", scope })}
|
<div className="flex items-center text-emerald-600 text-xs font-bold mb-1">
|
||||||
>
|
<span className="material-symbols-outlined text-[16px]">arrow_upward</span>
|
||||||
<TabsList className="flex w-full flex-wrap justify-start gap-1 md:w-auto">
|
2.4%
|
||||||
{[
|
|
||||||
{ value: "draft", label: "草稿" },
|
|
||||||
{ value: "reviewing", label: "审批中" },
|
|
||||||
{ value: "approved", label: "已通过" },
|
|
||||||
{ value: "paid", label: "已支付" },
|
|
||||||
{ value: "rejected", label: "已驳回" },
|
|
||||||
{ value: "cancelled", label: "已取消" },
|
|
||||||
].map((item) => (
|
|
||||||
<TabsTrigger
|
|
||||||
data-testid={`reimbursements-tab-${item.value}`}
|
|
||||||
key={item.value}
|
|
||||||
value={item.value}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</TabsTrigger>
|
|
||||||
))}
|
|
||||||
</TabsList>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
{canViewAll ? (
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<Button
|
|
||||||
data-testid="reimbursements-scope-own"
|
|
||||||
variant={scope === "own" ? "default" : "secondary"}
|
|
||||||
onClick={() => setSearchParams({ status, page: "1", scope: "own" })}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
仅自己
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
data-testid="reimbursements-scope-all"
|
|
||||||
variant={scope === "all" ? "default" : "secondary"}
|
|
||||||
onClick={() => setSearchParams({ status, page: "1", scope: "all" })}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
全范围
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="card-std">
|
||||||
|
<p className="card-title">已报销总额</p>
|
||||||
|
<div className="flex items-end justify-between">
|
||||||
|
<h3 className="card-value">¥124,500</h3>
|
||||||
|
<div className="flex items-center text-emerald-600 text-xs font-bold mb-1">
|
||||||
|
<span className="material-symbols-outlined text-[16px]">arrow_upward</span>
|
||||||
|
5.1%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card-std">
|
||||||
|
<p className="card-title">异常警报</p>
|
||||||
|
<div className="flex items-end justify-between">
|
||||||
|
<h3 className="card-value">2</h3>
|
||||||
|
<div className="flex items-center text-red-500 text-xs font-bold mb-1">
|
||||||
|
<span className="material-symbols-outlined text-[16px]">arrow_downward</span>
|
||||||
|
1.2%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{listQuery.isLoading ? <p className="text-sm text-slate-500">加载中...</p> : null}
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between mb-2">
|
||||||
|
<Tabs
|
||||||
|
data-testid="reimbursements-status-tabs"
|
||||||
|
value={status}
|
||||||
|
onValueChange={(value) => setSearchParams({ status: value, page: "1", scope })}
|
||||||
|
>
|
||||||
|
<TabsList className="flex w-full flex-wrap justify-start gap-1 md:w-auto">
|
||||||
|
{[
|
||||||
|
{ value: "draft", label: "草稿" },
|
||||||
|
{ value: "reviewing", label: "审批中" },
|
||||||
|
{ value: "approved", label: "已通过" },
|
||||||
|
{ value: "paid", label: "已支付" },
|
||||||
|
{ value: "rejected", label: "已驳回" },
|
||||||
|
{ value: "cancelled", label: "已取消" },
|
||||||
|
].map((item) => (
|
||||||
|
<TabsTrigger
|
||||||
|
data-testid={`reimbursements-tab-${item.value}`}
|
||||||
|
key={item.value}
|
||||||
|
value={item.value}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
{listQuery.isError ? (
|
{canViewAll ? (
|
||||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{isApiError(listQuery.error)
|
<Button
|
||||||
? `${listQuery.error.code}: ${mapApiErrorMessage(
|
data-testid="reimbursements-scope-own"
|
||||||
listQuery.error.code,
|
variant={scope === "own" ? "default" : "secondary"}
|
||||||
listQuery.error.details,
|
onClick={() => setSearchParams({ status, page: "1", scope: "own" })}
|
||||||
listQuery.error.message,
|
type="button"
|
||||||
)}`
|
>
|
||||||
: "加载报销列表失败"}
|
仅自己
|
||||||
</p>
|
</Button>
|
||||||
|
<Button
|
||||||
|
data-testid="reimbursements-scope-all"
|
||||||
|
variant={scope === "all" ? "default" : "secondary"}
|
||||||
|
onClick={() => setSearchParams({ status, page: "1", scope: "all" })}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
全范围
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
{!listQuery.isLoading && !listQuery.isError ? (
|
{listQuery.isLoading ? <TableSkeleton columns={6} rows={5} /> : null}
|
||||||
<div className="rounded-lg border border-slate-200">
|
|
||||||
<Table data-testid="reimbursements-table">
|
{listQuery.isError ? (
|
||||||
<TableHeader>
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||||
|
{isApiError(listQuery.error)
|
||||||
|
? `${listQuery.error.code}: ${mapApiErrorMessage(
|
||||||
|
listQuery.error.code,
|
||||||
|
listQuery.error.details,
|
||||||
|
listQuery.error.message,
|
||||||
|
)}`
|
||||||
|
: "加载报销列表失败"}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!listQuery.isLoading && !listQuery.isError ? (
|
||||||
|
<div className="table-container mt-4">
|
||||||
|
<table className="w-full text-left border-collapse" data-testid="reimbursements-table">
|
||||||
|
<thead className="table-header">
|
||||||
|
<tr>
|
||||||
|
<th className="table-th">单号</th>
|
||||||
|
<th className="table-th">状态</th>
|
||||||
|
<th className="table-th">金额</th>
|
||||||
|
<th className="table-th">报销人</th>
|
||||||
|
<th className="table-th">日期</th>
|
||||||
|
<th className="table-th w-24">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-zinc-50">
|
||||||
|
{(listQuery.data?.items ?? []).length ? (
|
||||||
|
listQuery.data?.items.map((item) => (
|
||||||
|
<tr className="table-row" data-testid="reimbursement-row" key={item.id}>
|
||||||
|
<td className="table-td">
|
||||||
|
<div className="font-semibold text-zinc-900">{item.formNumber || item.id}</div>
|
||||||
|
{item.formNumber && item.formNumber !== item.id && (
|
||||||
|
<div className="text-xs text-zinc-500">{item.id}</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="table-td"><StatusBadge status={item.status} /></td>
|
||||||
|
<td className="table-td-strong">
|
||||||
|
{item.totalAmount != null
|
||||||
|
? new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format(item.totalAmount)
|
||||||
|
: "-"}
|
||||||
|
</td>
|
||||||
|
<td className="table-td">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="size-6 rounded-full bg-zinc-100 flex items-center justify-center text-[10px] font-bold text-zinc-700 uppercase">
|
||||||
|
{(item.owner?.name ?? "U").charAt(0)}
|
||||||
|
</div>
|
||||||
|
<span className="font-medium text-zinc-900">{item.owner?.name ?? "-"}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="table-td">{item.reimbursementDate ?? "-"}</td>
|
||||||
|
<td className="table-td">
|
||||||
|
<Link to={`/reimbursements/${item.id}`} className="btn-ghost" data-testid="reimbursement-detail-link">
|
||||||
|
<span className="material-symbols-outlined text-[18px]">more_horiz</span>
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
<tr>
|
<tr>
|
||||||
<TableHead>单号</TableHead>
|
<td className="py-8 text-center text-sm text-zinc-500" colSpan={6}>
|
||||||
<TableHead>状态</TableHead>
|
暂无数据
|
||||||
<TableHead>金额</TableHead>
|
</td>
|
||||||
<TableHead>报销人</TableHead>
|
|
||||||
<TableHead>日期</TableHead>
|
|
||||||
<TableHead className="w-24">操作</TableHead>
|
|
||||||
</tr>
|
</tr>
|
||||||
</TableHeader>
|
)}
|
||||||
<TableBody>
|
</tbody>
|
||||||
{(listQuery.data?.items ?? []).length ? (
|
</table>
|
||||||
listQuery.data?.items.map((item) => (
|
|
||||||
<TableRow data-testid="reimbursement-row" key={item.id}>
|
|
||||||
<TableCell>
|
|
||||||
<div className="font-medium text-slate-900">{item.formNumber ?? item.id}</div>
|
|
||||||
<div className="text-xs text-slate-500">{item.id}</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{item.status}</TableCell>
|
|
||||||
<TableCell>{item.totalAmount ?? "-"}</TableCell>
|
|
||||||
<TableCell>{item.owner?.name ?? "-"}</TableCell>
|
|
||||||
<TableCell>{item.reimbursementDate ?? "-"}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Button
|
|
||||||
asChild
|
|
||||||
size="sm"
|
|
||||||
variant="secondary"
|
|
||||||
data-testid="reimbursement-detail-link"
|
|
||||||
>
|
|
||||||
<Link to={`/reimbursements/${item.id}`}>详情</Link>
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell className="py-3 text-slate-500" colSpan={6}>
|
|
||||||
暂无数据
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span>
|
|
||||||
第 {page} / {totalPages} 页
|
|
||||||
</span>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
data-testid="reimbursements-prev-page"
|
|
||||||
variant="secondary"
|
|
||||||
disabled={page <= 1}
|
|
||||||
onClick={() =>
|
|
||||||
setSearchParams({ status, scope, page: String(Math.max(1, page - 1)) })
|
|
||||||
}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
上一页
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
data-testid="reimbursements-next-page"
|
|
||||||
variant="secondary"
|
|
||||||
disabled={page >= totalPages}
|
|
||||||
onClick={() =>
|
|
||||||
setSearchParams({ status, scope, page: String(Math.min(totalPages, page + 1)) })
|
|
||||||
}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
下一页
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
) : null}
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<Pagination
|
||||||
|
page={page}
|
||||||
|
totalPages={totalPages}
|
||||||
|
onPageChange={(p) => setSearchParams({ status, scope, page: String(p) })}
|
||||||
|
testIdPrefix="reimbursements"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||||
import { isApiError } from "../api/types";
|
import { isApiError } from "../api/types";
|
||||||
@@ -17,10 +17,14 @@ import {
|
|||||||
import { PageHeader } from "../components/common/PageHeader";
|
import { PageHeader } from "../components/common/PageHeader";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import { Card } from "../components/ui/card";
|
import { Card } from "../components/ui/card";
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "../components/ui/dialog";
|
|
||||||
import { Input } from "../components/ui/input";
|
import { Input } from "../components/ui/input";
|
||||||
import { Label } from "../components/ui/label";
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { TableSkeleton } from "../components/common/TableSkeleton";
|
||||||
|
import { StatusBadge } from "../components/common/StatusBadge";
|
||||||
|
import { Pagination } from "../components/common/Pagination";
|
||||||
|
import { CreateUserDialog, type CreateUserFormData } from "./user-management/CreateUserDialog";
|
||||||
|
import { UserRoleDialog } from "./user-management/UserRoleDialog";
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
@@ -48,17 +52,7 @@ export function UserManagementPage() {
|
|||||||
const [resetResult, setResetResult] = useState<{ userId: string; password: string } | null>(null);
|
const [resetResult, setResetResult] = useState<{ userId: string; password: string } | null>(null);
|
||||||
|
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [employeeNo, setEmployeeNo] = useState("");
|
|
||||||
const [name, setName] = useState("");
|
|
||||||
const [phone, setPhone] = useState("");
|
|
||||||
const [email, setEmail] = useState("");
|
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const [departmentId, setDepartmentId] = useState("");
|
|
||||||
const [directManagerId, setDirectManagerId] = useState("");
|
|
||||||
const [createRoles, setCreateRoles] = useState<Set<string>>(() => new Set(["employee"]));
|
|
||||||
|
|
||||||
const [roleDialogUser, setRoleDialogUser] = useState<UserSummary | null>(null);
|
const [roleDialogUser, setRoleDialogUser] = useState<UserSummary | null>(null);
|
||||||
const [roleDraft, setRoleDraft] = useState<Set<string>>(() => new Set());
|
|
||||||
|
|
||||||
const listQuery = useQuery({
|
const listQuery = useQuery({
|
||||||
queryKey: ["users", "list", status, q, page],
|
queryKey: ["users", "list", status, q, page],
|
||||||
@@ -77,40 +71,18 @@ export function UserManagementPage() {
|
|||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE));
|
const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE));
|
||||||
|
|
||||||
const createPayload = useMemo(() => {
|
|
||||||
return {
|
|
||||||
employeeNo: employeeNo.trim(),
|
|
||||||
name: name.trim(),
|
|
||||||
phone: phone.trim(),
|
|
||||||
email: email.trim(),
|
|
||||||
password: password,
|
|
||||||
departmentId: departmentId || undefined,
|
|
||||||
directManagerId: directManagerId ? directManagerId : null,
|
|
||||||
roles: Array.from(createRoles),
|
|
||||||
};
|
|
||||||
}, [createRoles, departmentId, directManagerId, email, employeeNo, name, password, phone]);
|
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: () => createUser(createPayload),
|
mutationFn: (payload: CreateUserFormData) => createUser(payload),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await queryClient.invalidateQueries({ queryKey: ["users"] });
|
await queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
setEmployeeNo("");
|
|
||||||
setName("");
|
|
||||||
setPhone("");
|
|
||||||
setEmail("");
|
|
||||||
setPassword("");
|
|
||||||
setDepartmentId("");
|
|
||||||
setDirectManagerId("");
|
|
||||||
setCreateRoles(new Set(["employee"]));
|
|
||||||
setError("");
|
setError("");
|
||||||
|
toast.success("用户已创建");
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
if (isApiError(err)) {
|
const msg = isApiError(err) ? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}` : err instanceof Error ? err.message : "创建用户失败";
|
||||||
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
|
setError(msg);
|
||||||
return;
|
toast.error(msg);
|
||||||
}
|
|
||||||
setError(err instanceof Error ? err.message : "创建用户失败");
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -124,13 +96,12 @@ export function UserManagementPage() {
|
|||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await queryClient.invalidateQueries({ queryKey: ["users"] });
|
await queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||||
setError("");
|
setError("");
|
||||||
|
toast.success("状态已更新");
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
if (isApiError(err)) {
|
const msg = isApiError(err) ? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}` : err instanceof Error ? err.message : "更新用户状态失败";
|
||||||
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
|
setError(msg);
|
||||||
return;
|
toast.error(msg);
|
||||||
}
|
|
||||||
setError(err instanceof Error ? err.message : "更新用户状态失败");
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -140,35 +111,29 @@ export function UserManagementPage() {
|
|||||||
setResetResult({ userId: data.id, password: data.oneTimePassword });
|
setResetResult({ userId: data.id, password: data.oneTimePassword });
|
||||||
await queryClient.invalidateQueries({ queryKey: ["users"] });
|
await queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||||
setError("");
|
setError("");
|
||||||
|
toast.success("密码已重置");
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
if (isApiError(err)) {
|
const msg = isApiError(err) ? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}` : err instanceof Error ? err.message : "重置密码失败";
|
||||||
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
|
setError(msg);
|
||||||
return;
|
toast.error(msg);
|
||||||
}
|
|
||||||
setError(err instanceof Error ? err.message : "重置密码失败");
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const rolesMutation = useMutation({
|
const rolesMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async ({ userId, roles }: { userId: string; roles: string[] }) => {
|
||||||
if (!roleDialogUser) {
|
return setUserRoles(userId, roles);
|
||||||
throw new Error("missing user");
|
|
||||||
}
|
|
||||||
return setUserRoles(roleDialogUser.id, Array.from(roleDraft));
|
|
||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await queryClient.invalidateQueries({ queryKey: ["users"] });
|
await queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||||
setRoleDialogUser(null);
|
setRoleDialogUser(null);
|
||||||
setRoleDraft(new Set());
|
|
||||||
setError("");
|
setError("");
|
||||||
|
toast.success("角色已更新");
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
if (isApiError(err)) {
|
const msg = isApiError(err) ? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}` : err instanceof Error ? err.message : "更新角色失败";
|
||||||
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
|
setError(msg);
|
||||||
return;
|
toast.error(msg);
|
||||||
}
|
|
||||||
setError(err instanceof Error ? err.message : "更新角色失败");
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -178,159 +143,25 @@ export function UserManagementPage() {
|
|||||||
title="系统用户管理"
|
title="系统用户管理"
|
||||||
subtitle="创建/启用/禁用账号,重置密码并分配角色(仅试运行最小运营)。"
|
subtitle="创建/启用/禁用账号,重置密码并分配角色(仅试运行最小运营)。"
|
||||||
actions={
|
actions={
|
||||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
<CreateUserDialog
|
||||||
<DialogTrigger asChild>
|
open={createOpen}
|
||||||
<Button data-testid="users-create-open" type="button">
|
onOpenChange={setCreateOpen}
|
||||||
新建用户
|
onSubmit={(data) => createMutation.mutate(data)}
|
||||||
</Button>
|
isPending={createMutation.isPending}
|
||||||
</DialogTrigger>
|
departments={{
|
||||||
<DialogContent className="max-w-lg">
|
isLoading: departmentsQuery.isLoading,
|
||||||
<DialogHeader>
|
items: departmentsQuery.data?.items ?? [],
|
||||||
<DialogTitle>新建用户</DialogTitle>
|
}}
|
||||||
<DialogDescription>请填写基础信息与初始密码(满足密码策略)。</DialogDescription>
|
managers={{
|
||||||
</DialogHeader>
|
isLoading: managersQuery.isLoading,
|
||||||
|
items: (managersQuery.data?.items ?? []).map((item) => ({
|
||||||
<div className="grid gap-4">
|
id: item.id,
|
||||||
<div className="grid gap-2">
|
name: item.name,
|
||||||
<Label htmlFor="employeeNo">工号</Label>
|
employeeNo: item.employeeNo,
|
||||||
<Input
|
})),
|
||||||
id="employeeNo"
|
}}
|
||||||
data-testid="users-create-employeeNo"
|
roleOptions={ROLE_OPTIONS}
|
||||||
value={employeeNo}
|
/>
|
||||||
onChange={(e) => setEmployeeNo(e.target.value)}
|
|
||||||
placeholder="E1001"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="name">姓名</Label>
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
data-testid="users-create-name"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
placeholder="张三"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="phone">手机号</Label>
|
|
||||||
<Input
|
|
||||||
id="phone"
|
|
||||||
data-testid="users-create-phone"
|
|
||||||
value={phone}
|
|
||||||
onChange={(e) => setPhone(e.target.value)}
|
|
||||||
placeholder="13800000001"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="email">邮箱</Label>
|
|
||||||
<Input
|
|
||||||
id="email"
|
|
||||||
data-testid="users-create-email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
placeholder="[email protected]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="password">初始密码</Label>
|
|
||||||
<Input
|
|
||||||
id="password"
|
|
||||||
data-testid="users-create-password"
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
placeholder="至少12位,含大小写与数字"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="departmentId">部门(可选)</Label>
|
|
||||||
<select
|
|
||||||
id="departmentId"
|
|
||||||
data-testid="users-create-department"
|
|
||||||
className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
|
||||||
value={departmentId}
|
|
||||||
onChange={(event) => setDepartmentId(event.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">
|
|
||||||
{departmentsQuery.isLoading ? "加载中..." : "默认(与当前管理员一致)"}
|
|
||||||
</option>
|
|
||||||
{(departmentsQuery.data?.items ?? []).map((item) => (
|
|
||||||
<option key={item.id} value={item.id}>
|
|
||||||
{item.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="directManagerId">直属上级(可选)</Label>
|
|
||||||
<select
|
|
||||||
id="directManagerId"
|
|
||||||
data-testid="users-create-direct-manager"
|
|
||||||
className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
|
||||||
value={directManagerId}
|
|
||||||
onChange={(event) => setDirectManagerId(event.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">
|
|
||||||
{managersQuery.isLoading ? "加载中..." : "无"}
|
|
||||||
</option>
|
|
||||||
{(managersQuery.data?.items ?? []).map((item) => (
|
|
||||||
<option key={item.id} value={item.id}>
|
|
||||||
{item.name} ({item.employeeNo})
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label>角色</Label>
|
|
||||||
<div className="flex flex-wrap gap-3 text-sm">
|
|
||||||
{ROLE_OPTIONS.map((item) => (
|
|
||||||
<label className="flex items-center gap-2" key={item.code}>
|
|
||||||
<input
|
|
||||||
data-testid={`users-create-role-${item.code}`}
|
|
||||||
type="checkbox"
|
|
||||||
checked={createRoles.has(item.code)}
|
|
||||||
onChange={(event) => {
|
|
||||||
setCreateRoles((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (event.target.checked) {
|
|
||||||
next.add(item.code);
|
|
||||||
} else {
|
|
||||||
next.delete(item.code);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span>{item.label}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button
|
|
||||||
data-testid="users-create-submit"
|
|
||||||
disabled={
|
|
||||||
createMutation.isPending ||
|
|
||||||
!createPayload.employeeNo ||
|
|
||||||
!createPayload.name ||
|
|
||||||
!createPayload.phone ||
|
|
||||||
!createPayload.email ||
|
|
||||||
!createPayload.password ||
|
|
||||||
createPayload.roles.length < 1
|
|
||||||
}
|
|
||||||
onClick={() => createMutation.mutate()}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{createMutation.isPending ? "创建中..." : "创建"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -372,7 +203,7 @@ export function UserManagementPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{listQuery.isLoading ? <p className="text-sm text-slate-500">加载中...</p> : null}
|
{listQuery.isLoading ? <TableSkeleton columns={5} rows={5} /> : null}
|
||||||
{listQuery.isError ? (
|
{listQuery.isError ? (
|
||||||
<p className="text-sm text-red-600">
|
<p className="text-sm text-red-600">
|
||||||
{isApiError(listQuery.error)
|
{isApiError(listQuery.error)
|
||||||
@@ -410,7 +241,7 @@ export function UserManagementPage() {
|
|||||||
<div>{item.email}</div>
|
<div>{item.email}</div>
|
||||||
<div className="text-slate-500">{item.phone}</div>
|
<div className="text-slate-500">{item.phone}</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{item.status}</TableCell>
|
<TableCell><StatusBadge status={item.status} /></TableCell>
|
||||||
<TableCell className="text-xs">{formatRoleCodes(item.roleCodes)}</TableCell>
|
<TableCell className="text-xs">{formatRoleCodes(item.roleCodes)}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@@ -441,10 +272,7 @@ export function UserManagementPage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
disabled={rolesMutation.isPending}
|
disabled={rolesMutation.isPending}
|
||||||
onClick={() => {
|
onClick={() => setRoleDialogUser(item)}
|
||||||
setRoleDialogUser(item);
|
|
||||||
setRoleDraft(new Set(item.roleCodes));
|
|
||||||
}}
|
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
角色
|
角色
|
||||||
@@ -464,97 +292,30 @@ export function UserManagementPage() {
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
<Pagination
|
||||||
<span>
|
page={page}
|
||||||
第 {page} / {totalPages} 页
|
totalPages={totalPages}
|
||||||
</span>
|
onPageChange={setPage}
|
||||||
<div className="flex gap-2">
|
testIdPrefix="users"
|
||||||
<Button
|
/>
|
||||||
data-testid="users-prev-page"
|
|
||||||
variant="secondary"
|
|
||||||
disabled={page <= 1}
|
|
||||||
onClick={() => setPage((current) => Math.max(1, current - 1))}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
上一页
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
data-testid="users-next-page"
|
|
||||||
variant="secondary"
|
|
||||||
disabled={page >= totalPages}
|
|
||||||
onClick={() => setPage((current) => Math.min(totalPages, current + 1))}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
下一页
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Dialog
|
<UserRoleDialog
|
||||||
open={Boolean(roleDialogUser)}
|
open={Boolean(roleDialogUser)}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setRoleDialogUser(null);
|
setRoleDialogUser(null);
|
||||||
setRoleDraft(new Set());
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
user={roleDialogUser}
|
||||||
<DialogContent className="max-w-lg">
|
roleOptions={ROLE_OPTIONS}
|
||||||
<DialogHeader>
|
onSubmit={(roles) => {
|
||||||
<DialogTitle>设置角色</DialogTitle>
|
if (roleDialogUser) {
|
||||||
<DialogDescription>
|
rolesMutation.mutate({ userId: roleDialogUser.id, roles });
|
||||||
当前用户:{roleDialogUser?.employeeNo} / {roleDialogUser?.name}
|
}
|
||||||
</DialogDescription>
|
}}
|
||||||
</DialogHeader>
|
isPending={rolesMutation.isPending}
|
||||||
|
/>
|
||||||
<div className="flex flex-wrap gap-3 text-sm">
|
|
||||||
{ROLE_OPTIONS.map((item) => (
|
|
||||||
<label className="flex items-center gap-2" key={item.code}>
|
|
||||||
<input
|
|
||||||
data-testid={`users-role-${item.code}`}
|
|
||||||
type="checkbox"
|
|
||||||
checked={roleDraft.has(item.code)}
|
|
||||||
onChange={(event) => {
|
|
||||||
setRoleDraft((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (event.target.checked) {
|
|
||||||
next.add(item.code);
|
|
||||||
} else {
|
|
||||||
next.delete(item.code);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span>{item.label}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
disabled={rolesMutation.isPending}
|
|
||||||
onClick={() => {
|
|
||||||
setRoleDialogUser(null);
|
|
||||||
setRoleDraft(new Set());
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
data-testid="users-role-save"
|
|
||||||
disabled={rolesMutation.isPending || roleDraft.size < 1}
|
|
||||||
onClick={() => rolesMutation.mutate()}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{rolesMutation.isPending ? "保存中..." : "保存"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import type { ReimbursementStatus } from "../../api/reimbursements";
|
||||||
|
import { Button } from "../../components/ui/button";
|
||||||
|
import { Card } from "../../components/ui/card";
|
||||||
|
import { Input } from "../../components/ui/input";
|
||||||
|
import { Textarea } from "../../components/ui/textarea";
|
||||||
|
|
||||||
|
type PaymentChannel = "bank" | "alipay" | "wechat" | "cash" | "other";
|
||||||
|
|
||||||
|
export type PayPayload = {
|
||||||
|
paymentVoucherNo: string;
|
||||||
|
paymentDate: string;
|
||||||
|
paymentChannel: PaymentChannel;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ReimbursementActionsProps {
|
||||||
|
status: ReimbursementStatus;
|
||||||
|
canPay: boolean;
|
||||||
|
actionError: string;
|
||||||
|
isPending: boolean;
|
||||||
|
onSubmit: () => void;
|
||||||
|
onWithdraw: (comment: string) => void;
|
||||||
|
onPay: (payload: PayPayload) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReimbursementActions({
|
||||||
|
status,
|
||||||
|
canPay,
|
||||||
|
actionError,
|
||||||
|
isPending,
|
||||||
|
onSubmit,
|
||||||
|
onWithdraw,
|
||||||
|
onPay,
|
||||||
|
}: ReimbursementActionsProps) {
|
||||||
|
const [withdrawComment, setWithdrawComment] = useState("申请撤回并补充说明后重提");
|
||||||
|
const [paymentVoucherNo, setPaymentVoucherNo] = useState("");
|
||||||
|
const [paymentDate, setPaymentDate] = useState("2026-02-08");
|
||||||
|
const [paymentChannel, setPaymentChannel] = useState<PaymentChannel>("bank");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="space-y-4 p-6">
|
||||||
|
<h3 className="text-base font-semibold text-slate-900">可执行动作</h3>
|
||||||
|
|
||||||
|
{actionError ? (
|
||||||
|
<p
|
||||||
|
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600"
|
||||||
|
data-testid="reimbursement-action-error"
|
||||||
|
>
|
||||||
|
{actionError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
data-testid="reimbursement-submit"
|
||||||
|
disabled={isPending || status !== "draft"}
|
||||||
|
onClick={onSubmit}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
提交报销
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 rounded-lg border border-slate-200 p-4">
|
||||||
|
<p className="text-sm font-medium text-slate-800">撤回报销</p>
|
||||||
|
<Textarea
|
||||||
|
data-testid="reimbursement-withdraw-comment"
|
||||||
|
onChange={(event) => setWithdrawComment(event.target.value)}
|
||||||
|
rows={2}
|
||||||
|
value={withdrawComment}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
data-testid="reimbursement-withdraw"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={isPending || status !== "reviewing" || withdrawComment.trim().length < 1}
|
||||||
|
onClick={() => onWithdraw(withdrawComment.trim())}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
撤回
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canPay ? (
|
||||||
|
<div className="space-y-2 rounded-lg border border-slate-200 p-4">
|
||||||
|
<p className="text-sm font-medium text-slate-800">财务支付</p>
|
||||||
|
<Input
|
||||||
|
data-testid="reimbursement-pay-voucher-no"
|
||||||
|
onChange={(event) => setPaymentVoucherNo(event.target.value)}
|
||||||
|
placeholder="付款凭证号"
|
||||||
|
value={paymentVoucherNo}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
data-testid="reimbursement-pay-date"
|
||||||
|
onChange={(event) => setPaymentDate(event.target.value)}
|
||||||
|
type="date"
|
||||||
|
value={paymentDate}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
||||||
|
data-testid="reimbursement-pay-channel"
|
||||||
|
onChange={(event) =>
|
||||||
|
setPaymentChannel(event.target.value as PaymentChannel)
|
||||||
|
}
|
||||||
|
value={paymentChannel}
|
||||||
|
>
|
||||||
|
<option value="bank">bank</option>
|
||||||
|
<option value="alipay">alipay</option>
|
||||||
|
<option value="wechat">wechat</option>
|
||||||
|
<option value="cash">cash</option>
|
||||||
|
<option value="other">other</option>
|
||||||
|
</select>
|
||||||
|
<Button
|
||||||
|
data-testid="reimbursement-pay"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={isPending || !["approved", "paid"].includes(status)}
|
||||||
|
onClick={() =>
|
||||||
|
onPay({
|
||||||
|
paymentVoucherNo: paymentVoucherNo.trim(),
|
||||||
|
paymentDate,
|
||||||
|
paymentChannel,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
执行支付
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { ExpenseType } from "../../api/expenseTypes";
|
||||||
|
import { Button } from "../../components/ui/button";
|
||||||
|
import { Input } from "../../components/ui/input";
|
||||||
|
import { Textarea } from "../../components/ui/textarea";
|
||||||
|
|
||||||
|
export type ItemEditorMode = "create" | "edit";
|
||||||
|
|
||||||
|
export type ItemEditorInitialValues = {
|
||||||
|
expenseTypeId: string;
|
||||||
|
expenseDate: string;
|
||||||
|
amount: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ItemEditorSaveData = {
|
||||||
|
expenseTypeId: string;
|
||||||
|
expenseDate: string;
|
||||||
|
amount: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ReimbursementItemEditorProps {
|
||||||
|
mode: ItemEditorMode;
|
||||||
|
expenseTypes: ExpenseType[];
|
||||||
|
expenseTypesLoading: boolean;
|
||||||
|
expenseTypesError: boolean;
|
||||||
|
initialValues: ItemEditorInitialValues;
|
||||||
|
onSave: (data: ItemEditorSaveData) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
isPending: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReimbursementItemEditor({
|
||||||
|
mode,
|
||||||
|
expenseTypes,
|
||||||
|
expenseTypesLoading,
|
||||||
|
expenseTypesError,
|
||||||
|
initialValues,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
isPending,
|
||||||
|
}: ReimbursementItemEditorProps) {
|
||||||
|
const [expenseTypeId, setExpenseTypeId] = useState(initialValues.expenseTypeId);
|
||||||
|
const [expenseDate, setExpenseDate] = useState(initialValues.expenseDate);
|
||||||
|
const [amount, setAmount] = useState(initialValues.amount);
|
||||||
|
const [description, setDescription] = useState(initialValues.description);
|
||||||
|
|
||||||
|
// Reset internal state when initialValues change (e.g. switching between create/edit)
|
||||||
|
useEffect(() => {
|
||||||
|
setExpenseTypeId(initialValues.expenseTypeId);
|
||||||
|
setExpenseDate(initialValues.expenseDate);
|
||||||
|
setAmount(initialValues.amount);
|
||||||
|
setDescription(initialValues.description);
|
||||||
|
}, [initialValues.expenseTypeId, initialValues.expenseDate, initialValues.amount, initialValues.description]);
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
onSave({ expenseTypeId, expenseDate, amount, description });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 rounded-lg border border-slate-200 p-4" data-testid="reimbursement-item-editor">
|
||||||
|
<p className="text-sm font-medium text-slate-800">
|
||||||
|
{mode === "create" ? "新增明细" : "编辑明细"}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{expenseTypesLoading ? (
|
||||||
|
<p className="text-sm text-slate-500">加载费用类型中...</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{expenseTypesError ? (
|
||||||
|
<p className="text-sm text-red-600">费用类型加载失败,请刷新页面重试。</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-slate-600" htmlFor="expenseTypeId">
|
||||||
|
费用类型
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
||||||
|
data-testid="reimbursement-item-expense-type"
|
||||||
|
disabled={expenseTypes.length === 0 || isPending}
|
||||||
|
id="expenseTypeId"
|
||||||
|
onChange={(event) => setExpenseTypeId(event.target.value)}
|
||||||
|
value={expenseTypeId}
|
||||||
|
>
|
||||||
|
{expenseTypes.length === 0 ? <option value="">暂无可用费用类型</option> : null}
|
||||||
|
{expenseTypes.map((type) => (
|
||||||
|
<option key={type.id} value={type.id}>
|
||||||
|
{type.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-slate-600" htmlFor="expenseDate">
|
||||||
|
发生日期
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
data-testid="reimbursement-item-expense-date"
|
||||||
|
disabled={isPending}
|
||||||
|
id="expenseDate"
|
||||||
|
onChange={(event) => setExpenseDate(event.target.value)}
|
||||||
|
type="date"
|
||||||
|
value={expenseDate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-slate-600" htmlFor="amount">
|
||||||
|
金额
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
data-testid="reimbursement-item-amount"
|
||||||
|
disabled={isPending}
|
||||||
|
id="amount"
|
||||||
|
onChange={(event) => setAmount(event.target.value)}
|
||||||
|
placeholder="例如 123.45"
|
||||||
|
value={amount}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1 md:col-span-4">
|
||||||
|
<label className="text-xs text-slate-600" htmlFor="itemDescription">
|
||||||
|
说明
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
data-testid="reimbursement-item-description"
|
||||||
|
disabled={isPending}
|
||||||
|
id="itemDescription"
|
||||||
|
onChange={(event) => setDescription(event.target.value)}
|
||||||
|
rows={2}
|
||||||
|
value={description}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
data-testid="reimbursement-item-save"
|
||||||
|
disabled={
|
||||||
|
isPending ||
|
||||||
|
!expenseTypeId ||
|
||||||
|
!expenseDate ||
|
||||||
|
!amount.trim()
|
||||||
|
}
|
||||||
|
onClick={handleSave}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{isPending ? "保存中..." : "保存明细"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
data-testid="reimbursement-item-cancel"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={onCancel}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "../../components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "../../components/ui/dialog";
|
||||||
|
import { Input } from "../../components/ui/input";
|
||||||
|
import { Label } from "../../components/ui/label";
|
||||||
|
|
||||||
|
export interface CreateUserFormData {
|
||||||
|
employeeNo: string;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
departmentId: string | undefined;
|
||||||
|
directManagerId: string | null;
|
||||||
|
roles: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateUserDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSubmit: (data: CreateUserFormData) => void;
|
||||||
|
isPending: boolean;
|
||||||
|
departments: { isLoading: boolean; items: Array<{ id: string; name: string }> };
|
||||||
|
managers: { isLoading: boolean; items: Array<{ id: string; name: string; employeeNo: string }> };
|
||||||
|
roleOptions: Array<{ code: string; label: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateUserDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onSubmit,
|
||||||
|
isPending,
|
||||||
|
departments,
|
||||||
|
managers,
|
||||||
|
roleOptions,
|
||||||
|
}: CreateUserDialogProps) {
|
||||||
|
const [employeeNo, setEmployeeNo] = useState("");
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [phone, setPhone] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [departmentId, setDepartmentId] = useState("");
|
||||||
|
const [directManagerId, setDirectManagerId] = useState("");
|
||||||
|
const [createRoles, setCreateRoles] = useState<Set<string>>(() => new Set(["employee"]));
|
||||||
|
|
||||||
|
const trimmedEmployeeNo = employeeNo.trim();
|
||||||
|
const trimmedName = name.trim();
|
||||||
|
const trimmedPhone = phone.trim();
|
||||||
|
const trimmedEmail = email.trim();
|
||||||
|
const rolesArray = Array.from(createRoles);
|
||||||
|
|
||||||
|
const canSubmit =
|
||||||
|
!isPending &&
|
||||||
|
trimmedEmployeeNo !== "" &&
|
||||||
|
trimmedName !== "" &&
|
||||||
|
trimmedPhone !== "" &&
|
||||||
|
trimmedEmail !== "" &&
|
||||||
|
password !== "" &&
|
||||||
|
rolesArray.length >= 1;
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
setEmployeeNo("");
|
||||||
|
setName("");
|
||||||
|
setPhone("");
|
||||||
|
setEmail("");
|
||||||
|
setPassword("");
|
||||||
|
setDepartmentId("");
|
||||||
|
setDirectManagerId("");
|
||||||
|
setCreateRoles(new Set(["employee"]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenChange(nextOpen: boolean) {
|
||||||
|
if (!nextOpen) {
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
onOpenChange(nextOpen);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
onSubmit({
|
||||||
|
employeeNo: trimmedEmployeeNo,
|
||||||
|
name: trimmedName,
|
||||||
|
phone: trimmedPhone,
|
||||||
|
email: trimmedEmail,
|
||||||
|
password,
|
||||||
|
departmentId: departmentId || undefined,
|
||||||
|
directManagerId: directManagerId ? directManagerId : null,
|
||||||
|
roles: rolesArray,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button data-testid="users-create-open" type="button">
|
||||||
|
新建用户
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>新建用户</DialogTitle>
|
||||||
|
<DialogDescription>请填写基础信息与初始密码(满足密码策略)。</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="employeeNo">工号</Label>
|
||||||
|
<Input
|
||||||
|
id="employeeNo"
|
||||||
|
data-testid="users-create-employeeNo"
|
||||||
|
value={employeeNo}
|
||||||
|
onChange={(e) => setEmployeeNo(e.target.value)}
|
||||||
|
placeholder="E1001"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="name">姓名</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
data-testid="users-create-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="张三"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="phone">手机号</Label>
|
||||||
|
<Input
|
||||||
|
id="phone"
|
||||||
|
data-testid="users-create-phone"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
|
placeholder="13800000001"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="email">邮箱</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
data-testid="users-create-email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="[email protected]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="password">初始密码</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
data-testid="users-create-password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="至少12位,含大小写与数字"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="departmentId">部门(可选)</Label>
|
||||||
|
<select
|
||||||
|
id="departmentId"
|
||||||
|
data-testid="users-create-department"
|
||||||
|
className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
||||||
|
value={departmentId}
|
||||||
|
onChange={(event) => setDepartmentId(event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{departments.isLoading ? "加载中..." : "默认(与当前管理员一致)"}
|
||||||
|
</option>
|
||||||
|
{departments.items.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="directManagerId">直属上级(可选)</Label>
|
||||||
|
<select
|
||||||
|
id="directManagerId"
|
||||||
|
data-testid="users-create-direct-manager"
|
||||||
|
className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
||||||
|
value={directManagerId}
|
||||||
|
onChange={(event) => setDirectManagerId(event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{managers.isLoading ? "加载中..." : "无"}
|
||||||
|
</option>
|
||||||
|
{managers.items.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.name} ({item.employeeNo})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>角色</Label>
|
||||||
|
<div className="flex flex-wrap gap-3 text-sm">
|
||||||
|
{roleOptions.map((item) => (
|
||||||
|
<label className="flex items-center gap-2" key={item.code}>
|
||||||
|
<input
|
||||||
|
data-testid={`users-create-role-${item.code}`}
|
||||||
|
type="checkbox"
|
||||||
|
checked={createRoles.has(item.code)}
|
||||||
|
onChange={(event) => {
|
||||||
|
setCreateRoles((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (event.target.checked) {
|
||||||
|
next.add(item.code);
|
||||||
|
} else {
|
||||||
|
next.delete(item.code);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
data-testid="users-create-submit"
|
||||||
|
disabled={!canSubmit}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{isPending ? "创建中..." : "创建"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Button } from "../../components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "../../components/ui/dialog";
|
||||||
|
import type { UserSummary } from "../../api/users";
|
||||||
|
|
||||||
|
export interface UserRoleDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
user: UserSummary | null;
|
||||||
|
roleOptions: Array<{ code: string; label: string }>;
|
||||||
|
onSubmit: (roles: string[]) => void;
|
||||||
|
isPending: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserRoleDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
user,
|
||||||
|
roleOptions,
|
||||||
|
onSubmit,
|
||||||
|
isPending,
|
||||||
|
}: UserRoleDialogProps) {
|
||||||
|
const [roleDraft, setRoleDraft] = useState<Set<string>>(() => new Set());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
setRoleDraft(new Set(user.roleCodes));
|
||||||
|
} else {
|
||||||
|
setRoleDraft(new Set());
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(nextOpen) => {
|
||||||
|
if (!nextOpen) {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>设置角色</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
当前用户:{user?.employeeNo} / {user?.name}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3 text-sm">
|
||||||
|
{roleOptions.map((item) => (
|
||||||
|
<label className="flex items-center gap-2" key={item.code}>
|
||||||
|
<input
|
||||||
|
data-testid={`users-role-${item.code}`}
|
||||||
|
type="checkbox"
|
||||||
|
checked={roleDraft.has(item.code)}
|
||||||
|
onChange={(event) => {
|
||||||
|
setRoleDraft((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (event.target.checked) {
|
||||||
|
next.add(item.code);
|
||||||
|
} else {
|
||||||
|
next.delete(item.code);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={handleClose}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
data-testid="users-role-save"
|
||||||
|
disabled={isPending || roleDraft.size < 1}
|
||||||
|
onClick={() => onSubmit(Array.from(roleDraft))}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{isPending ? "保存中..." : "保存"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
在新工单中引用
屏蔽一个用户