统一菜单命名并移除剩余台账工具(05-31-unified-menu-remove-ledger-tools-wave2)
这个提交包含在:
@@ -28,7 +28,7 @@ function entityLabel(entityType: string) {
|
||||
if (entityType === "travel_request") return "差旅";
|
||||
if (entityType === "asset") return "资产";
|
||||
if (entityType === "project") return "项目";
|
||||
if (entityType === "contract_registry") return "合同台账";
|
||||
if (entityType === "contract_registry") return "合同";
|
||||
if (entityType === "confidential_doc_ref") return "资料引用";
|
||||
return entityType;
|
||||
}
|
||||
|
||||
@@ -29,8 +29,8 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
{ to: "/projects", label: "项目", icon: "workspaces", anyOf: ["project.view_own", "project.view_all", "project.manage"] },
|
||||
{ to: "/system/settings", label: "管理", icon: "settings", anyOf: ["system.settings", "system.users", "masterdata.manage", "notification.channel.manage", "integration.manage", "system.audit.view"] },
|
||||
{ to: "/workflow/tasks", label: "待我处理", icon: "fact_check", anyOf: ["expense.approve", "leave.approve", "attendance.approve", "procurement.approve", "travel.approve"] },
|
||||
{ to: "/contracts-registry", label: "合同台账", icon: "description", anyOf: ["project.view_all", "project.manage", "system.settings"] },
|
||||
{ to: "/purchase-requests", label: "采购申请", icon: "shopping_cart", anyOf: ["procurement.create", "procurement.view_own", "procurement.view_all", "procurement.manage"] },
|
||||
{ to: "/contracts-registry", label: "合同", icon: "description", anyOf: ["project.view_all", "project.manage", "system.settings"] },
|
||||
{ to: "/purchase-requests", label: "采购", icon: "shopping_cart", anyOf: ["procurement.create", "procurement.view_own", "procurement.view_all", "procurement.manage"] },
|
||||
{ to: "/travel-requests", label: "差旅申请", icon: "flight", anyOf: ["travel.view_all", "travel.manage", "travel.payment.manage"] },
|
||||
{ to: "/budgets", label: "预算管理", icon: "account_balance", anyOf: ["budget.view", "budget.manage"] },
|
||||
{ to: "/payment-batches", label: "付款", icon: "payments", anyOf: ["payment.batch.manage", "risk.review.manage"] },
|
||||
@@ -43,7 +43,7 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
const ACCESSIBLE_LABELS: Record<string, string> = {
|
||||
"/reimbursements": "我的报销",
|
||||
"/workflow/tasks": "待我处理",
|
||||
"/assets": "资产台账",
|
||||
"/assets": "资产",
|
||||
};
|
||||
|
||||
const MANAGEMENT_ACTIVE_PATHS = [
|
||||
|
||||
@@ -9,8 +9,8 @@ const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [
|
||||
{ path: "/attendance", title: "考勤", group: "业务" },
|
||||
{ path: "/wecom-approval-archive", title: "企微审批历史", group: "业务" },
|
||||
{ path: "/projects", title: "项目", group: "业务" },
|
||||
{ path: "/contracts-registry", title: "合同台账", group: "业务" },
|
||||
{ path: "/purchase-requests", title: "采购申请", group: "业务" },
|
||||
{ path: "/contracts-registry", title: "合同", group: "业务" },
|
||||
{ path: "/purchase-requests", title: "采购", group: "业务" },
|
||||
{ path: "/travel-requests", title: "差旅申请", group: "更多" },
|
||||
{ path: "/budgets", title: "预算管理", group: "财务" },
|
||||
{ path: "/payment-batches", title: "付款", group: "财务" },
|
||||
|
||||
@@ -44,6 +44,7 @@ import { ConfirmDialog } from "../components/common/ConfirmDialog";
|
||||
import { DateTextInput } from "../components/common/DateTextInput";
|
||||
import { ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../components/ui/dropdown-menu";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -258,7 +259,6 @@ export function ApOperationsPage() {
|
||||
const [ledgerDateTo, setLedgerDateTo] = useState("");
|
||||
const [ledgerSearch, setLedgerSearch] = useState("");
|
||||
const [ledgerPage, setLedgerPage] = useState(1);
|
||||
const [showLedgerTools, setShowLedgerTools] = useState(false);
|
||||
const [showLedgerAdvancedFilters, setShowLedgerAdvancedFilters] = useState(false);
|
||||
|
||||
const [reviewTarget, setReviewTarget] = useState<PaymentBatch | null>(null);
|
||||
@@ -605,13 +605,26 @@ export function ApOperationsPage() {
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setShowLedgerTools((current) => !current)}
|
||||
>
|
||||
台账工具
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
data-testid="ap-export"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={exportMutation.isPending}
|
||||
>
|
||||
{exportMutation.isPending ? "导出中..." : "导出"}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem data-testid="ap-export-csv" onSelect={() => exportMutation.mutate("csv")}>
|
||||
导出 CSV
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem data-testid="ap-export-xlsx" onSelect={() => exportMutation.mutate("xlsx")}>
|
||||
导出 Excel
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -629,34 +642,6 @@ export function ApOperationsPage() {
|
||||
<SectionError message={resolveErrorMessage(financeLedgerQuery.error, "财务台账加载失败")} />
|
||||
) : null}
|
||||
|
||||
{showLedgerTools ? (
|
||||
<div className="finance-command-panel space-y-3" data-testid="ap-ledger-tools">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-zinc-900">台账工具</h3>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => exportMutation.mutate("csv")}
|
||||
disabled={exportMutation.isPending}
|
||||
>
|
||||
{exportMutation.isPending ? "导出中..." : "导出 CSV"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => exportMutation.mutate("xlsx")}
|
||||
disabled={exportMutation.isPending}
|
||||
>
|
||||
{exportMutation.isPending ? "导出中..." : "导出 Excel"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="finance-command-panel">
|
||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as OperationsTab)}>
|
||||
<TabsList className="ops-command-bar__tabs !w-full !justify-start">
|
||||
|
||||
@@ -725,7 +725,7 @@ export function AssetRegistryPage() {
|
||||
return (
|
||||
<ListWorkspace>
|
||||
<WorkspaceHeader
|
||||
title="资产台账"
|
||||
title="资产"
|
||||
actions={
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{isAssetDetailMode ? (
|
||||
|
||||
@@ -216,7 +216,7 @@ export function ContractsRegistryPage() {
|
||||
const [editor, setEditor] = useState<EditorState>(() => buildEmptyEditor(initialProjectId));
|
||||
const [showExpiring, setShowExpiring] = useState(false);
|
||||
const [showTrialData, setShowTrialData] = useState(false);
|
||||
const [showLedgerTools, setShowLedgerTools] = useState(false);
|
||||
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false);
|
||||
const [showEditor, setShowEditor] = useState(false);
|
||||
const [confirmExportFormat, setConfirmExportFormat] = useState<ContractRegistryExportFormat | null>(null);
|
||||
const [confirmStatusTarget, setConfirmStatusTarget] = useState<ContractRegistryItem | null>(null);
|
||||
@@ -293,9 +293,9 @@ export function ContractsRegistryPage() {
|
||||
await refreshAll();
|
||||
setShowEditor(false);
|
||||
resetEditor();
|
||||
toast.success(editorId ? "合同台账已更新" : "合同台账已创建");
|
||||
toast.success(editorId ? "合同已更新" : "合同已创建");
|
||||
},
|
||||
onError: (error) => toast.error(resolveError(error, editorId ? "更新合同台账失败" : "创建合同台账失败")),
|
||||
onError: (error) => toast.error(resolveError(error, editorId ? "更新合同失败" : "创建合同失败")),
|
||||
});
|
||||
|
||||
const toggleStatusMutation = useMutation({
|
||||
@@ -324,15 +324,15 @@ export function ContractsRegistryPage() {
|
||||
}),
|
||||
onSuccess: (response, format) => {
|
||||
downloadBlob(response.blob, response.filename ?? `contracts-registry.${format}`);
|
||||
toast.success(`合同台账已导出为 ${format.toUpperCase()}`);
|
||||
toast.success(`合同已导出为 ${format.toUpperCase()}`);
|
||||
},
|
||||
onError: (error) => toast.error(resolveError(error, "导出合同台账失败")),
|
||||
onError: (error) => toast.error(resolveError(error, "导出合同失败")),
|
||||
});
|
||||
|
||||
return (
|
||||
<PageScaffold>
|
||||
<PageHeader
|
||||
title="合同台账"
|
||||
title="合同"
|
||||
actions={
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{initialProjectId ? (
|
||||
@@ -349,73 +349,58 @@ export function ContractsRegistryPage() {
|
||||
>
|
||||
新增合同
|
||||
</Button>
|
||||
<Button type="button" variant={showLedgerTools ? "default" : "secondary"} onClick={() => setShowLedgerTools((value) => !value)}>
|
||||
台账工具
|
||||
<Button
|
||||
type="button"
|
||||
variant={showExpiring ? "default" : "secondary"}
|
||||
disabled={expiringQuery.isLoading}
|
||||
onClick={() => setShowExpiring((value) => !value)}
|
||||
>
|
||||
到期列表 {expiringContractCount}
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" variant="secondary" disabled={exportMutation.isPending}>
|
||||
{exportMutation.isPending ? "导出中..." : "导出"}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => setConfirmExportFormat("csv")}>
|
||||
导出 CSV
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => setConfirmExportFormat("xlsx")}>
|
||||
导出 Excel
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
type="button"
|
||||
variant={showAdvancedFilters || filters.expiryDateFrom || filters.expiryDateTo || showTrialData ? "default" : "secondary"}
|
||||
onClick={() => setShowAdvancedFilters((value) => !value)}
|
||||
>
|
||||
筛选
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{showLedgerTools ? (
|
||||
<div className="detail-command-panel space-y-4" data-testid="contracts-ledger-tools">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-zinc-900">台账工具</h3>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={expiringQuery.isLoading}
|
||||
onClick={() => setShowExpiring((value) => !value)}
|
||||
>
|
||||
到期列表 {expiringContractCount}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" disabled={exportMutation.isPending} onClick={() => setConfirmExportFormat("csv")}>
|
||||
{exportMutation.isPending ? "导出中..." : "导出 CSV"}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" disabled={exportMutation.isPending} onClick={() => setConfirmExportFormat("xlsx")}>
|
||||
{exportMutation.isPending ? "导出中..." : "导出 Excel"}
|
||||
{expiringQuery.isError ? (
|
||||
<div className="text-sm text-red-600">{resolveError(expiringQuery.error, "加载到期预警失败")}</div>
|
||||
) : null}
|
||||
{showExpiring ? (
|
||||
<div className="detail-command-panel space-y-2" data-testid="contracts-expiring-list">
|
||||
{expiringItems.map((item) => (
|
||||
<div key={item.id} className="flex items-center justify-between gap-3 rounded-lg border border-zinc-200 px-3 py-2 text-sm">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-zinc-900">{item.contractNo} · {item.counterpartyName}</div>
|
||||
<div className="text-xs text-zinc-500">到期日 {formatDisplayDate(item.expiryDate)} · 责任人 {item.owner?.name ?? "-"}</div>
|
||||
</div>
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => openEditor(item)}>
|
||||
编辑
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="space-y-2">
|
||||
<Label>到期日开始</Label>
|
||||
<DateTextInput
|
||||
value={filters.expiryDateFrom}
|
||||
onChange={(event) => setFilters((current) => ({ ...current, expiryDateFrom: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>到期日结束</Label>
|
||||
<DateTextInput
|
||||
value={filters.expiryDateTo}
|
||||
onChange={(event) => setFilters((current) => ({ ...current, expiryDateTo: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{expiringQuery.isError ? (
|
||||
<div className="text-sm text-red-600">{resolveError(expiringQuery.error, "加载到期预警失败")}</div>
|
||||
) : null}
|
||||
{showExpiring ? (
|
||||
<div className="space-y-2 border-t border-zinc-200 pt-3">
|
||||
{expiringItems.map((item) => (
|
||||
<div key={item.id} className="flex items-center justify-between gap-3 rounded-lg border border-zinc-200 px-3 py-2 text-sm">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-zinc-900">{item.contractNo} · {item.counterpartyName}</div>
|
||||
<div className="text-xs text-zinc-500">到期日 {formatDisplayDate(item.expiryDate)} · 责任人 {item.owner?.name ?? "-"}</div>
|
||||
</div>
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => openEditor(item)}>
|
||||
编辑
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{!expiringQuery.isLoading && !expiringItems.length ? (
|
||||
<div className="text-sm text-zinc-500">暂无到期合同</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{!expiringQuery.isLoading && !expiringItems.length ? (
|
||||
<div className="text-sm text-zinc-500">暂无到期合同</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -425,7 +410,7 @@ export function ContractsRegistryPage() {
|
||||
<div className="card-std space-y-4 p-6" data-testid="contracts-editor-panel">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-zinc-900">{editorId ? "编辑合同台账" : "新建合同台账"}</h3>
|
||||
<h3 className="text-base font-semibold text-zinc-900">{editorId ? "编辑合同" : "新建合同"}</h3>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
补齐合同类型、金额、签署/生效/到期日期,续约将自动创建新的草稿合同。
|
||||
</p>
|
||||
@@ -676,7 +661,7 @@ export function ContractsRegistryPage() {
|
||||
}
|
||||
onClick={() => saveMutation.mutate()}
|
||||
>
|
||||
{saveMutation.isPending ? (editorId ? "保存中..." : "创建中...") : editorId ? "保存修改" : "创建合同台账"}
|
||||
{saveMutation.isPending ? (editorId ? "保存中..." : "创建中...") : editorId ? "保存修改" : "创建合同"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -704,13 +689,33 @@ export function ContractsRegistryPage() {
|
||||
>
|
||||
重置筛选
|
||||
</Button>
|
||||
{showLedgerTools ? (
|
||||
</>
|
||||
}
|
||||
advanced={showAdvancedFilters || filters.expiryDateFrom || filters.expiryDateTo || showTrialData ? (
|
||||
<div className="grid gap-3 md:grid-cols-[160px_160px_auto] xl:grid-cols-[180px_180px_auto]" data-testid="contracts-advanced-filters">
|
||||
<div className="space-y-2">
|
||||
<Label>到期日开始</Label>
|
||||
<DateTextInput
|
||||
value={filters.expiryDateFrom}
|
||||
onChange={(event) => setFilters((current) => ({ ...current, expiryDateFrom: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>到期日结束</Label>
|
||||
<DateTextInput
|
||||
value={filters.expiryDateTo}
|
||||
onChange={(event) => setFilters((current) => ({ ...current, expiryDateTo: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end">
|
||||
<Button type="button" variant={showTrialData ? "default" : "secondary"} onClick={() => setShowTrialData((value) => !value)}>
|
||||
{showTrialData ? "隐藏测试数据" : "显示测试数据"}
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
>
|
||||
<div className="grid gap-3 xl:grid-cols-[minmax(180px,1fr)_160px_160px_minmax(220px,1fr)]">
|
||||
<div>
|
||||
@@ -774,7 +779,7 @@ export function ContractsRegistryPage() {
|
||||
|
||||
{contractsQuery.isLoading ? <div className="text-sm text-zinc-500">加载中...</div> : null}
|
||||
{contractsQuery.isError ? (
|
||||
<div className="text-sm text-red-600">{resolveError(contractsQuery.error, "加载合同台账失败")}</div>
|
||||
<div className="text-sm text-red-600">{resolveError(contractsQuery.error, "加载合同失败")}</div>
|
||||
) : null}
|
||||
|
||||
{!showTrialData && hiddenTrialContractCount > 0 ? (
|
||||
@@ -804,7 +809,7 @@ export function ContractsRegistryPage() {
|
||||
</article>
|
||||
))}
|
||||
{!contractsQuery.isLoading && !visibleContracts.length ? (
|
||||
<div className="rounded-lg border border-zinc-200 bg-white p-4 text-sm text-zinc-500">暂无合同台账</div>
|
||||
<div className="rounded-lg border border-zinc-200 bg-white p-4 text-sm text-zinc-500">暂无合同</div>
|
||||
) : null}
|
||||
</BusinessMobileList>
|
||||
|
||||
@@ -884,7 +889,7 @@ export function ContractsRegistryPage() {
|
||||
) : (
|
||||
<tr>
|
||||
<td className="py-8 text-center text-sm text-zinc-500" colSpan={6}>
|
||||
暂无合同台账
|
||||
暂无合同
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
@@ -898,7 +903,7 @@ export function ContractsRegistryPage() {
|
||||
if (!open) setConfirmExportFormat(null);
|
||||
}}
|
||||
kind="sensitiveExport"
|
||||
targetName="合同台账"
|
||||
targetName="合同"
|
||||
description="导出文件可能包含合同金额、相对方、项目归属、责任人和归档位置摘要。请确认当前筛选范围和用途合规。"
|
||||
onConfirm={() => {
|
||||
if (!confirmExportFormat) return;
|
||||
|
||||
@@ -87,7 +87,7 @@ export function InvoicePoolPage() {
|
||||
const [editTotalAmount, setEditTotalAmount] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [confirmAction, setConfirmAction] = useState<{ type: string; invoiceId: string } | null>(null);
|
||||
const [showLedgerTools, setShowLedgerTools] = useState(false);
|
||||
const [invoiceActionPanel, setInvoiceActionPanel] = useState<"upload" | "bind" | null>(null);
|
||||
const previousDetailStatusRef = useRef<InvoicePoolStatus | null>(null);
|
||||
|
||||
// --- Queries ---
|
||||
@@ -473,7 +473,7 @@ export function InvoicePoolPage() {
|
||||
return (
|
||||
<ListWorkspace>
|
||||
<WorkspaceHeader
|
||||
title="票据中心"
|
||||
title="票据"
|
||||
actions={
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{isInvoiceDetailMode ? (
|
||||
@@ -481,9 +481,42 @@ export function InvoicePoolPage() {
|
||||
返回列表
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" variant={showLedgerTools ? "default" : "secondary"} onClick={() => setShowLedgerTools((current) => !current)}>
|
||||
台账工具
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant={invoiceActionPanel === "upload" ? "default" : "secondary"}
|
||||
onClick={() => setInvoiceActionPanel((current) => (current === "upload" ? null : "upload"))}
|
||||
>
|
||||
上传票据
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={invoiceActionPanel === "bind" ? "default" : "secondary"}
|
||||
onClick={() => setInvoiceActionPanel((current) => (current === "bind" ? null : "bind"))}
|
||||
>
|
||||
绑定
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
data-testid="invoice-export"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={exportMutation.isPending}
|
||||
>
|
||||
{exportMutation.isPending ? "导出中..." : "导出"}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem data-testid="invoice-export-csv" onSelect={() => exportMutation.mutate("csv")}>
|
||||
导出 CSV
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem data-testid="invoice-export-xlsx" onSelect={() => exportMutation.mutate("xlsx")}>
|
||||
导出 Excel
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
@@ -495,66 +528,45 @@ export function InvoicePoolPage() {
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{!isInvoiceDetailMode && showLedgerTools ? (
|
||||
<div className="finance-command-panel space-y-3" data-testid="invoice-ledger-tools">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-zinc-900">台账工具</h3>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
data-testid="invoice-export"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={exportMutation.isPending}
|
||||
>
|
||||
{exportMutation.isPending ? "导出中..." : "导出"}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem data-testid="invoice-export-csv" onSelect={() => exportMutation.mutate("csv")}>
|
||||
导出 CSV
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem data-testid="invoice-export-xlsx" onSelect={() => exportMutation.mutate("xlsx")}>
|
||||
导出 Excel
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<InvoiceUploadCard
|
||||
selectedFiles={selectedFiles}
|
||||
setSelectedFiles={setSelectedFiles}
|
||||
isUploading={uploadMutation.isPending}
|
||||
uploadProgressLabel={
|
||||
uploadProgress
|
||||
? `上传中 ${uploadProgress.completed}/${uploadProgress.total}`
|
||||
: "上传中..."
|
||||
}
|
||||
onUpload={() => uploadMutation.mutate()}
|
||||
showProxyOwnerSelector={showProxyOwnerSelector}
|
||||
proxyDelegationsQuery={proxyDelegationsQuery}
|
||||
uploadOwnerEmployeeId={uploadOwnerEmployeeId}
|
||||
setUploadOwnerEmployeeId={setUploadOwnerEmployeeId}
|
||||
/>
|
||||
<InvoiceBindCard
|
||||
listQuery={listQuery}
|
||||
reimbursementQuery={reimbursementQuery}
|
||||
travelRequestQuery={travelRequestQuery}
|
||||
travelRequestDetailQuery={travelRequestDetailQuery}
|
||||
bindMutation={bindMutation}
|
||||
bindMode={bindMode}
|
||||
setBindMode={setBindMode}
|
||||
canManageTravelPayments={canManageTravelPayments}
|
||||
selectedInvoiceId={selectedInvoiceId}
|
||||
setSelectedInvoiceId={setSelectedInvoiceId}
|
||||
selectedReimbursementId={selectedReimbursementId}
|
||||
setSelectedReimbursementId={setSelectedReimbursementId}
|
||||
selectedTravelRequestId={selectedTravelRequestId}
|
||||
setSelectedTravelRequestId={setSelectedTravelRequestId}
|
||||
selectedTravelOrderId={selectedTravelOrderId}
|
||||
setSelectedTravelOrderId={setSelectedTravelOrderId}
|
||||
/>
|
||||
{!isInvoiceDetailMode && invoiceActionPanel ? (
|
||||
<div className="finance-command-panel space-y-3" data-testid="invoice-actions-panel">
|
||||
{invoiceActionPanel === "upload" ? (
|
||||
<InvoiceUploadCard
|
||||
selectedFiles={selectedFiles}
|
||||
setSelectedFiles={setSelectedFiles}
|
||||
isUploading={uploadMutation.isPending}
|
||||
uploadProgressLabel={
|
||||
uploadProgress
|
||||
? `上传中 ${uploadProgress.completed}/${uploadProgress.total}`
|
||||
: "上传中..."
|
||||
}
|
||||
onUpload={() => uploadMutation.mutate()}
|
||||
showProxyOwnerSelector={showProxyOwnerSelector}
|
||||
proxyDelegationsQuery={proxyDelegationsQuery}
|
||||
uploadOwnerEmployeeId={uploadOwnerEmployeeId}
|
||||
setUploadOwnerEmployeeId={setUploadOwnerEmployeeId}
|
||||
/>
|
||||
) : null}
|
||||
{invoiceActionPanel === "bind" ? (
|
||||
<InvoiceBindCard
|
||||
listQuery={listQuery}
|
||||
reimbursementQuery={reimbursementQuery}
|
||||
travelRequestQuery={travelRequestQuery}
|
||||
travelRequestDetailQuery={travelRequestDetailQuery}
|
||||
bindMutation={bindMutation}
|
||||
bindMode={bindMode}
|
||||
setBindMode={setBindMode}
|
||||
canManageTravelPayments={canManageTravelPayments}
|
||||
selectedInvoiceId={selectedInvoiceId}
|
||||
setSelectedInvoiceId={setSelectedInvoiceId}
|
||||
selectedReimbursementId={selectedReimbursementId}
|
||||
setSelectedReimbursementId={setSelectedReimbursementId}
|
||||
selectedTravelRequestId={selectedTravelRequestId}
|
||||
setSelectedTravelRequestId={setSelectedTravelRequestId}
|
||||
selectedTravelOrderId={selectedTravelOrderId}
|
||||
setSelectedTravelOrderId={setSelectedTravelOrderId}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ export function PaymentBatchesPage() {
|
||||
const receiptInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [selectedReceiptFiles, setSelectedReceiptFiles] = useState<File[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
const [showBatchTools, setShowBatchTools] = useState(false);
|
||||
const [showCreatePanel, setShowCreatePanel] = useState(false);
|
||||
const [confirmPaymentAction, setConfirmPaymentAction] = useState<{
|
||||
kind: "financeReview" | "financePayment" | "cancel";
|
||||
batchId: string;
|
||||
@@ -356,8 +356,8 @@ export function PaymentBatchesPage() {
|
||||
刷新
|
||||
</Button>
|
||||
{!selectedBatchId ? (
|
||||
<Button type="button" variant={showBatchTools ? "default" : "secondary"} onClick={() => setShowBatchTools((current) => !current)}>
|
||||
台账工具
|
||||
<Button type="button" variant={showCreatePanel ? "default" : "secondary"} onClick={() => setShowCreatePanel((current) => !current)}>
|
||||
新建批次
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -366,7 +366,7 @@ export function PaymentBatchesPage() {
|
||||
|
||||
{error ? <div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</div> : null}
|
||||
|
||||
{!selectedBatchId && showBatchTools ? (
|
||||
{!selectedBatchId && showCreatePanel ? (
|
||||
<div className="finance-command-panel space-y-4" data-testid="payment-batch-create-panel">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-zinc-900">新建批次</h3>
|
||||
|
||||
@@ -596,7 +596,7 @@ export function ProjectDetailPage() {
|
||||
<Link to="/projects">返回项目</Link>
|
||||
</Button>
|
||||
<Button asChild variant="secondary" type="button">
|
||||
<Link to={`/contracts-registry?projectId=${detail.id}`}>合同台账</Link>
|
||||
<Link to={`/contracts-registry?projectId=${detail.id}`}>合同</Link>
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" disabled={archiveProjectMutation.isPending || detail.status === "archived"} onClick={() => setConfirmArchiveProject(true)}>
|
||||
{archiveProjectMutation.isPending ? "归档中..." : "归档项目"}
|
||||
@@ -1090,7 +1090,7 @@ export function ProjectDetailPage() {
|
||||
<div>负责人:{detail.owner?.name ?? "-"}</div>
|
||||
<div>项目经理:{detail.manager?.name ?? "-"}</div>
|
||||
<div>周报关联:{counts?.weeklyReports ?? 0}</div>
|
||||
<div>合同台账:{counts?.contracts ?? 0}</div>
|
||||
<div>合同:{counts?.contracts ?? 0}</div>
|
||||
<div>交付记录:{counts?.deliveries ?? 0}</div>
|
||||
<div>资料引用:{counts?.documentRefs ?? 0}</div>
|
||||
<div className="rounded-lg border border-zinc-200 p-3 text-zinc-600">
|
||||
@@ -1156,7 +1156,7 @@ export function ProjectDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-200 p-4">
|
||||
<div className="text-sm text-zinc-500">合同台账</div>
|
||||
<div className="text-sm text-zinc-500">合同</div>
|
||||
<div className="mt-1 text-xl font-semibold text-zinc-900">{contractsQuery.data?.items.length ?? 0}</div>
|
||||
<div className="mt-2 space-y-2 text-sm">
|
||||
{(contractsQuery.data?.items ?? []).slice(0, 5).map((item) => (
|
||||
|
||||
@@ -82,7 +82,7 @@ export function ProjectsPage() {
|
||||
const [targetEndAt, setTargetEndAt] = useState("");
|
||||
const [isConfidential, setIsConfidential] = useState(false);
|
||||
const [showTrialData, setShowTrialData] = useState(false);
|
||||
const [showLedgerTools, setShowLedgerTools] = useState(false);
|
||||
const [showCreatePanel, setShowCreatePanel] = useState(false);
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: ["projects", "list", status, mine, q],
|
||||
@@ -181,79 +181,76 @@ export function ProjectsPage() {
|
||||
actions={
|
||||
<div className="flex gap-2">
|
||||
<Button asChild variant="secondary" type="button">
|
||||
<Link to="/contracts-registry">合同台账</Link>
|
||||
<Link to="/contracts-registry">合同</Link>
|
||||
</Button>
|
||||
<Button type="button" variant={showLedgerTools ? "default" : "secondary"} onClick={() => setShowLedgerTools((value) => !value)}>
|
||||
台账工具
|
||||
<Button type="button" variant={showTrialData ? "default" : "secondary"} onClick={() => setShowTrialData((value) => !value)}>
|
||||
{showTrialData ? "隐藏测试数据" : "显示测试数据"}
|
||||
</Button>
|
||||
{canManageProjects ? (
|
||||
<Button type="button" variant={showCreatePanel ? "default" : "secondary"} onClick={() => setShowCreatePanel((value) => !value)}>
|
||||
新建项目
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{showLedgerTools ? (
|
||||
<SectionPanel title="台账工具">
|
||||
{showCreatePanel ? (
|
||||
<SectionPanel title="新建项目">
|
||||
<div className="space-y-4">
|
||||
{!canManageProjects ? (
|
||||
<div className="text-sm text-zinc-500">你当前只有查看权限,项目创建入口仅对老板 / 管理员 / 项目管理角色开放。</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>项目名称</Label>
|
||||
<Input value={name} onChange={(event) => setName(event.target.value)} placeholder="例如:深圳某客户现场实施" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>客户名称</Label>
|
||||
<Input value={clientName} onChange={(event) => setClientName(event.target.value)} placeholder="客户全称" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>客户简称</Label>
|
||||
<Input value={clientAlias} onChange={(event) => setClientAlias(event.target.value)} placeholder="可选" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>项目经理</Label>
|
||||
<select className="select-std" value={managerEmployeeId} onChange={(event) => setManagerEmployeeId(event.target.value)}>
|
||||
<option value="">未指定</option>
|
||||
{managerCandidates.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name} · {item.employeeNo}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>开始日期</Label>
|
||||
<DateTextInput value={startsAt} onChange={(event) => setStartsAt(event.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>目标完成</Label>
|
||||
<DateTextInput value={targetEndAt} onChange={(event) => setTargetEndAt(event.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>项目摘要</Label>
|
||||
<Textarea value={summary} onChange={(event) => setSummary(event.target.value)} placeholder="填项目目标、交付范围、关键约束" />
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-zinc-700">
|
||||
<input type="checkbox" checked={isConfidential} onChange={(event) => setIsConfidential(event.target.checked)} />
|
||||
项目含涉密内容(正文与附件继续离线保存)
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>项目名称</Label>
|
||||
<Input value={name} onChange={(event) => setName(event.target.value)} placeholder="例如:深圳某客户现场实施" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>客户名称</Label>
|
||||
<Input value={clientName} onChange={(event) => setClientName(event.target.value)} placeholder="客户全称" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>客户简称</Label>
|
||||
<Input value={clientAlias} onChange={(event) => setClientAlias(event.target.value)} placeholder="可选" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>项目经理</Label>
|
||||
<select className="select-std" value={managerEmployeeId} onChange={(event) => setManagerEmployeeId(event.target.value)}>
|
||||
<option value="">未指定</option>
|
||||
{managerCandidates.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name} · {item.employeeNo}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>开始日期</Label>
|
||||
<DateTextInput value={startsAt} onChange={(event) => setStartsAt(event.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>目标完成</Label>
|
||||
<DateTextInput value={targetEndAt} onChange={(event) => setTargetEndAt(event.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>项目摘要</Label>
|
||||
<Textarea value={summary} onChange={(event) => setSummary(event.target.value)} placeholder="填项目目标、交付范围、关键约束" />
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-zinc-700">
|
||||
<input type="checkbox" checked={isConfidential} onChange={(event) => setIsConfidential(event.target.checked)} />
|
||||
项目含涉密内容(正文与附件继续离线保存)
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{canManageProjects ? (
|
||||
<Button
|
||||
type="button"
|
||||
disabled={createMutation.isPending || !name.trim() || !clientName.trim()}
|
||||
onClick={() => createMutation.mutate()}
|
||||
>
|
||||
{createMutation.isPending ? "创建中..." : "创建项目"}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="button" variant={showTrialData ? "default" : "secondary"} onClick={() => setShowTrialData((value) => !value)}>
|
||||
{showTrialData ? "隐藏测试数据" : "显示测试数据"}
|
||||
<Button
|
||||
type="button"
|
||||
disabled={createMutation.isPending || !name.trim() || !clientName.trim()}
|
||||
onClick={() => createMutation.mutate()}
|
||||
>
|
||||
{createMutation.isPending ? "创建中..." : "创建项目"}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => setShowCreatePanel(false)}>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { Wrench } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { isApiError } from "../api/types";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
@@ -281,7 +280,7 @@ export function PurchaseRequestsPage() {
|
||||
const [supplierFilter, setSupplierFilter] = useState("");
|
||||
const [legalEntityFilter, setLegalEntityFilter] = useState("");
|
||||
const [costCenterFilter, setCostCenterFilter] = useState("");
|
||||
const [showLedgerTools, setShowLedgerTools] = useState(false);
|
||||
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [selectedId, setSelectedId] = useState(searchParams.get("id") ?? "");
|
||||
const [hydratedDetailId, setHydratedDetailId] = useState("");
|
||||
@@ -457,12 +456,12 @@ export function PurchaseRequestsPage() {
|
||||
const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE));
|
||||
const purchaseItems = listQuery.data?.items ?? [];
|
||||
const hasAdvancedFilters = Boolean(supplierFilter || legalEntityFilter || costCenterFilter || (canViewAll && !mine));
|
||||
const canShowLedgerTools =
|
||||
const canShowAdvancedFilters =
|
||||
canViewAll ||
|
||||
Boolean((suppliersQuery.data?.items ?? []).length) ||
|
||||
Boolean((legalEntitiesQuery.data?.items ?? []).length) ||
|
||||
Boolean((costCentersQuery.data?.items ?? []).length);
|
||||
const showAdvancedTools = canShowLedgerTools && (showLedgerTools || hasAdvancedFilters);
|
||||
const showAdvancedFilterPanel = canShowAdvancedFilters && (showAdvancedFilters || hasAdvancedFilters);
|
||||
const emptyState = resolvePurchaseEmptyStateCopy(status);
|
||||
const totalAmountPreview = useMemo(
|
||||
() => items.reduce((sum, item) => sum + Number(moneyPreview(item.quantity, item.unitPrice)), 0).toFixed(2),
|
||||
@@ -803,7 +802,7 @@ export function PurchaseRequestsPage() {
|
||||
<ListWorkspace>
|
||||
{downloadReasonDialog}
|
||||
<WorkspaceHeader
|
||||
title="采购申请"
|
||||
title="采购"
|
||||
actions={
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{isPurchaseDetailMode ? (
|
||||
@@ -822,15 +821,14 @@ export function PurchaseRequestsPage() {
|
||||
{createMutation.isPending ? "创建中..." : "新建采购"}
|
||||
</Button>
|
||||
) : null}
|
||||
{canShowLedgerTools ? (
|
||||
{canShowAdvancedFilters ? (
|
||||
<Button
|
||||
data-testid="purchase-ledger-tools"
|
||||
data-testid="purchase-advanced-filters"
|
||||
type="button"
|
||||
variant={showAdvancedTools ? "default" : "secondary"}
|
||||
onClick={() => setShowLedgerTools((current) => !current)}
|
||||
variant={showAdvancedFilterPanel ? "default" : "secondary"}
|
||||
onClick={() => setShowAdvancedFilters((current) => !current)}
|
||||
>
|
||||
<Wrench aria-hidden className="mr-1 size-4" />
|
||||
台账工具
|
||||
筛选
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
@@ -853,8 +851,8 @@ export function PurchaseRequestsPage() {
|
||||
{mine ? "我相关" : "全范围"}
|
||||
</span>
|
||||
}
|
||||
advanced={showAdvancedTools ? (
|
||||
<div className="grid gap-3 md:grid-cols-3 xl:grid-cols-4" data-testid="purchase-ledger-tools-panel">
|
||||
advanced={showAdvancedFilterPanel ? (
|
||||
<div className="grid gap-3 md:grid-cols-3 xl:grid-cols-4" data-testid="purchase-advanced-filters-panel">
|
||||
<select
|
||||
className="select-std"
|
||||
value={supplierFilter}
|
||||
|
||||
@@ -22,7 +22,7 @@ const MODULE_LABELS: Record<string, string> = {
|
||||
workspace: "工作区设置",
|
||||
people: "人员管理",
|
||||
organization: "组织架构",
|
||||
contractsRegistry: "合同台账",
|
||||
contractsRegistry: "合同",
|
||||
workflowDefinitions: "流程定义",
|
||||
systemSettings: "管理",
|
||||
featureFlags: "功能管理",
|
||||
@@ -34,7 +34,7 @@ const MODULE_LABELS: Record<string, string> = {
|
||||
notificationChannels: "通知渠道",
|
||||
workflowTasks: "待我处理",
|
||||
dashboard: "仪表盘",
|
||||
purchaseRequests: "采购申请",
|
||||
purchaseRequests: "采购",
|
||||
travelRequests: "差旅申请",
|
||||
budgets: "预算管理",
|
||||
paymentBatches: "付款",
|
||||
|
||||
在新工单中引用
屏蔽一个用户