统一业务列表 Ant Design 风格(05-31-unify-business-list-ant-design)
这个提交包含在:
@@ -0,0 +1,108 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { FilterBar, PrimaryTable, WorkspaceEmptyState } from "./ListWorkspace";
|
||||
|
||||
type BusinessListToolbarProps = {
|
||||
children: ReactNode;
|
||||
actions?: ReactNode;
|
||||
advanced?: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function BusinessListToolbar({ children, actions, advanced, className }: BusinessListToolbarProps) {
|
||||
return (
|
||||
<FilterBar className={cn("ops-command-bar space-y-3 !p-3 md:!p-3.5", className)}>
|
||||
<div className="ops-command-bar__row">
|
||||
<div className="min-w-0">{children}</div>
|
||||
{actions ? <div className="ops-command-bar__actions">{actions}</div> : null}
|
||||
</div>
|
||||
{advanced ? <div className="rounded-lg border border-slate-200 bg-slate-50/70 p-3">{advanced}</div> : null}
|
||||
</FilterBar>
|
||||
);
|
||||
}
|
||||
|
||||
export function BusinessTable({
|
||||
children,
|
||||
className,
|
||||
testId,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
testId?: string;
|
||||
}) {
|
||||
return (
|
||||
<PrimaryTable className={cn("hidden md:block ops-table-focus", className)}>
|
||||
<table className="w-full border-collapse text-left" data-testid={testId}>
|
||||
{children}
|
||||
</table>
|
||||
</PrimaryTable>
|
||||
);
|
||||
}
|
||||
|
||||
export function BusinessTableFrame({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <PrimaryTable className={cn("hidden md:block ops-table-focus", className)}>{children}</PrimaryTable>;
|
||||
}
|
||||
|
||||
export function BusinessMobileList({
|
||||
children,
|
||||
className,
|
||||
testId,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
testId?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("space-y-3 md:hidden", className)} data-testid={testId}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BusinessEmptyRow({
|
||||
colSpan,
|
||||
title = "暂无数据",
|
||||
description,
|
||||
action,
|
||||
}: {
|
||||
colSpan: number;
|
||||
title?: ReactNode;
|
||||
description?: ReactNode;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<tr>
|
||||
<td className="py-8 text-center text-sm text-zinc-500" colSpan={colSpan}>
|
||||
<div className="mx-auto max-w-sm py-3">
|
||||
<div className="font-semibold text-zinc-900">{title}</div>
|
||||
{description ? <div className="mt-1 leading-6 text-zinc-500">{description}</div> : null}
|
||||
{action ? <div className="mt-3">{action}</div> : null}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export function BusinessEmptyState({
|
||||
title = "暂无数据",
|
||||
description,
|
||||
action,
|
||||
}: {
|
||||
title?: ReactNode;
|
||||
description?: ReactNode;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<WorkspaceEmptyState>
|
||||
<div className="font-semibold text-zinc-900">{title}</div>
|
||||
{description ? <div className="mt-1 leading-6 text-zinc-500">{description}</div> : null}
|
||||
{action ? <div className="mt-3">{action}</div> : null}
|
||||
</WorkspaceEmptyState>
|
||||
);
|
||||
}
|
||||
@@ -30,7 +30,12 @@ import { DateTextInput } from "../components/common/DateTextInput";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { TableSkeleton } from "../components/common/TableSkeleton";
|
||||
import { DetailSideMeta } from "../components/patterns/DetailSideMeta";
|
||||
import { FilterBar, ListWorkspace, PrimaryTable, WorkspaceHeader } from "../components/patterns/ListWorkspace";
|
||||
import { ListWorkspace, PrimaryTable, WorkspaceHeader } from "../components/patterns/ListWorkspace";
|
||||
import {
|
||||
BusinessListToolbar,
|
||||
BusinessMobileList,
|
||||
BusinessTable,
|
||||
} from "../components/patterns/BusinessListTemplate";
|
||||
import { SectionPanel } from "../components/patterns/SectionPanel";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
@@ -890,9 +895,20 @@ export function AssetRegistryPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-6">
|
||||
<FilterBar className="space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_180px_190px_auto]">
|
||||
<div className="grid gap-4">
|
||||
<BusinessListToolbar
|
||||
actions={canViewAll ? (
|
||||
<>
|
||||
<Button type="button" size="sm" variant={mine ? "default" : "secondary"} onClick={() => setMine(true)}>
|
||||
我相关
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant={!mine ? "default" : "secondary"} onClick={() => setMine(false)}>
|
||||
全范围
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
>
|
||||
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_180px_190px]">
|
||||
<Input
|
||||
placeholder="搜索编号/名称/hash/VER"
|
||||
value={assetSearch}
|
||||
@@ -912,17 +928,8 @@ export function AssetRegistryPage() {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{canViewAll ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant={mine ? "default" : "secondary"} onClick={() => setMine(true)}>
|
||||
我相关
|
||||
</Button>
|
||||
<Button type="button" variant={!mine ? "default" : "secondary"} onClick={() => setMine(false)}>
|
||||
全范围
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</BusinessListToolbar>
|
||||
|
||||
{assetsQuery.isLoading ? <TableSkeleton columns={4} rows={6} /> : null}
|
||||
{assetsQuery.isError ? (
|
||||
@@ -930,7 +937,7 @@ export function AssetRegistryPage() {
|
||||
) : null}
|
||||
{!assetsQuery.isLoading && !assetsQuery.isError ? (
|
||||
<>
|
||||
<div className="space-y-3 md:hidden">
|
||||
<BusinessMobileList>
|
||||
{orderedAssetItems.length ? (
|
||||
orderedAssetItems.map((item) => (
|
||||
<button
|
||||
@@ -977,9 +984,8 @@ export function AssetRegistryPage() {
|
||||
) : (
|
||||
<div className="rounded-lg border border-zinc-200 p-4 text-sm text-zinc-500">暂无资产卡片</div>
|
||||
)}
|
||||
</div>
|
||||
<PrimaryTable className="hidden md:block ops-table-focus">
|
||||
<table className="w-full border-collapse text-left" data-testid="assets-table">
|
||||
</BusinessMobileList>
|
||||
<BusinessTable testId="assets-table">
|
||||
<thead className="table-header">
|
||||
<tr>
|
||||
<th className="table-th">资产</th>
|
||||
@@ -1032,11 +1038,9 @@ export function AssetRegistryPage() {
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</PrimaryTable>
|
||||
</BusinessTable>
|
||||
</>
|
||||
) : null}
|
||||
</FilterBar>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -26,8 +26,19 @@ import { PageHeader } from "../components/common/PageHeader";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { DateTextInput } from "../components/common/DateTextInput";
|
||||
import { WorkspaceNotice } from "../components/patterns/ListWorkspace";
|
||||
import {
|
||||
BusinessListToolbar,
|
||||
BusinessMobileList,
|
||||
BusinessTable,
|
||||
} from "../components/patterns/BusinessListTemplate";
|
||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
import { Button } from "../components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "../components/ui/dropdown-menu";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Label } from "../components/ui/label";
|
||||
import { Textarea } from "../components/ui/textarea";
|
||||
@@ -206,6 +217,7 @@ export function ContractsRegistryPage() {
|
||||
const [showExpiring, setShowExpiring] = useState(false);
|
||||
const [showTrialData, setShowTrialData] = useState(false);
|
||||
const [showLedgerTools, setShowLedgerTools] = useState(false);
|
||||
const [showEditor, setShowEditor] = useState(false);
|
||||
const [confirmExportFormat, setConfirmExportFormat] = useState<ContractRegistryExportFormat | null>(null);
|
||||
const [confirmStatusTarget, setConfirmStatusTarget] = useState<ContractRegistryItem | null>(null);
|
||||
|
||||
@@ -264,9 +276,9 @@ export function ContractsRegistryPage() {
|
||||
};
|
||||
|
||||
const openEditor = (item: ContractRegistryItem) => {
|
||||
setShowLedgerTools(true);
|
||||
setEditorId(item.id);
|
||||
setEditor(loadEditorFromItem(item));
|
||||
setShowEditor(true);
|
||||
};
|
||||
|
||||
const saveMutation = useMutation({
|
||||
@@ -279,6 +291,7 @@ export function ContractsRegistryPage() {
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await refreshAll();
|
||||
setShowEditor(false);
|
||||
resetEditor();
|
||||
toast.success(editorId ? "合同台账已更新" : "合同台账已创建");
|
||||
},
|
||||
@@ -323,10 +336,19 @@ export function ContractsRegistryPage() {
|
||||
actions={
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{initialProjectId ? (
|
||||
<Button asChild variant="secondary" type="button">
|
||||
<Link to="/projects">返回项目</Link>
|
||||
</Button>
|
||||
<Button asChild variant="secondary" type="button">
|
||||
<Link to="/projects">返回项目</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
resetEditor();
|
||||
setShowEditor(true);
|
||||
}}
|
||||
>
|
||||
新增合同
|
||||
</Button>
|
||||
<Button type="button" variant={showLedgerTools ? "default" : "secondary"} onClick={() => setShowLedgerTools((value) => !value)}>
|
||||
台账工具
|
||||
</Button>
|
||||
@@ -399,21 +421,34 @@ export function ContractsRegistryPage() {
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-6">
|
||||
{showLedgerTools || editorId ? (
|
||||
<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>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
补齐合同类型、金额、签署/生效/到期日期,续约将自动创建新的草稿合同。
|
||||
</p>
|
||||
{showEditor ? (
|
||||
<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>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
补齐合同类型、金额、签署/生效/到期日期,续约将自动创建新的草稿合同。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{editorId ? (
|
||||
<Button type="button" variant="secondary" size="sm" onClick={resetEditor}>
|
||||
切换到新建
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowEditor(false);
|
||||
resetEditor();
|
||||
}}
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{editorId ? (
|
||||
<Button type="button" variant="secondary" size="sm" onClick={resetEditor}>
|
||||
切换到新建
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{editingItem?.status === "expired" ? (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900">
|
||||
@@ -646,103 +681,96 @@ export function ContractsRegistryPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="card-std space-y-4 p-6">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-zinc-900">台账列表</h3>
|
||||
<div className="mt-1 text-sm text-zinc-500">{filters.projectId ? "已按项目过滤" : "显示全部可见合同"}</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{!showLedgerTools && !editorId ? (
|
||||
<Button type="button" variant="secondary" onClick={() => setShowLedgerTools(true)}>
|
||||
新建合同
|
||||
<>
|
||||
<BusinessListToolbar
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setFilters({
|
||||
projectId: initialProjectId,
|
||||
status: "",
|
||||
contractType: "",
|
||||
counterpartyName: "",
|
||||
expiryDateFrom: "",
|
||||
expiryDateTo: "",
|
||||
});
|
||||
setShowEditor(false);
|
||||
setEditorId(null);
|
||||
setEditor(buildEmptyEditor(initialProjectId));
|
||||
}}
|
||||
>
|
||||
重置筛选
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setFilters({
|
||||
projectId: initialProjectId,
|
||||
status: "",
|
||||
contractType: "",
|
||||
counterpartyName: "",
|
||||
expiryDateFrom: "",
|
||||
expiryDateTo: "",
|
||||
});
|
||||
setEditorId(null);
|
||||
setEditor(buildEmptyEditor(initialProjectId));
|
||||
}}
|
||||
>
|
||||
重置筛选
|
||||
</Button>
|
||||
{showLedgerTools ? (
|
||||
<Button type="button" variant={showTrialData ? "default" : "secondary"} onClick={() => setShowTrialData((value) => !value)}>
|
||||
{showTrialData ? "隐藏测试数据" : "显示测试数据"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{showLedgerTools ? (
|
||||
<Button type="button" variant={showTrialData ? "default" : "secondary"} onClick={() => setShowTrialData((value) => !value)}>
|
||||
{showTrialData ? "隐藏测试数据" : "显示测试数据"}
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-3 xl:grid-cols-[minmax(180px,1fr)_160px_160px_minmax(220px,1fr)]">
|
||||
<div>
|
||||
<select
|
||||
className="select-std"
|
||||
aria-label="项目"
|
||||
value={filters.projectId}
|
||||
onChange={(event) => setFilters((current) => ({ ...current, projectId: event.target.value }))}
|
||||
>
|
||||
<option value="">全部项目</option>
|
||||
{projectOptions.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.projectNo} · {item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 xl:grid-cols-[minmax(180px,1fr)_160px_160px_minmax(220px,1fr)]">
|
||||
<div>
|
||||
<select
|
||||
className="select-std"
|
||||
aria-label="项目"
|
||||
value={filters.projectId}
|
||||
onChange={(event) => setFilters((current) => ({ ...current, projectId: event.target.value }))}
|
||||
>
|
||||
<option value="">全部项目</option>
|
||||
{projectOptions.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.projectNo} · {item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<select
|
||||
className="select-std"
|
||||
aria-label="状态"
|
||||
value={filters.status}
|
||||
onChange={(event) => setFilters((current) => ({ ...current, status: event.target.value as FiltersState["status"] }))}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
{FILTER_STATUS_OPTIONS.map((item) => (
|
||||
<option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<select
|
||||
className="select-std"
|
||||
aria-label="状态"
|
||||
value={filters.status}
|
||||
onChange={(event) => setFilters((current) => ({ ...current, status: event.target.value as FiltersState["status"] }))}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
{FILTER_STATUS_OPTIONS.map((item) => (
|
||||
<option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<select
|
||||
className="select-std"
|
||||
aria-label="合同类型"
|
||||
value={filters.contractType}
|
||||
onChange={(event) => setFilters((current) => ({ ...current, contractType: event.target.value as FiltersState["contractType"] }))}
|
||||
>
|
||||
<option value="">全部类型</option>
|
||||
{CONTRACT_TYPE_OPTIONS.map((item) => (
|
||||
<option key={item} value={item}>
|
||||
{contractTypeLabel(item)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<select
|
||||
className="select-std"
|
||||
aria-label="合同类型"
|
||||
value={filters.contractType}
|
||||
onChange={(event) => setFilters((current) => ({ ...current, contractType: event.target.value as FiltersState["contractType"] }))}
|
||||
>
|
||||
<option value="">全部类型</option>
|
||||
{CONTRACT_TYPE_OPTIONS.map((item) => (
|
||||
<option key={item} value={item}>
|
||||
{contractTypeLabel(item)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div>
|
||||
<Input
|
||||
aria-label="相对方"
|
||||
value={filters.counterpartyName}
|
||||
onChange={(event) => setFilters((current) => ({ ...current, counterpartyName: event.target.value }))}
|
||||
placeholder="搜索相对方"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Input
|
||||
aria-label="相对方"
|
||||
value={filters.counterpartyName}
|
||||
onChange={(event) => setFilters((current) => ({ ...current, counterpartyName: event.target.value }))}
|
||||
placeholder="搜索相对方"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</BusinessListToolbar>
|
||||
|
||||
{contractsQuery.isLoading ? <div className="text-sm text-zinc-500">加载中...</div> : null}
|
||||
{contractsQuery.isError ? (
|
||||
@@ -755,79 +783,114 @@ export function ContractsRegistryPage() {
|
||||
</WorkspaceNotice>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-3">
|
||||
<BusinessMobileList>
|
||||
{visibleContracts.map((item) => (
|
||||
<div key={item.id} className="detail-evidence-card bg-white" data-testid="contracts-registry-row">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="font-semibold text-zinc-900">{item.contractNo}</div>
|
||||
<StatusBadge status={item.status} label={contractStatusLabel(item.status)} />
|
||||
<span className="rounded-full bg-zinc-100 px-2 py-1 text-xs text-zinc-600">
|
||||
{confidentialityLabel(item.confidentialityLevel)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-zinc-900">{item.counterpartyName}</div>
|
||||
<div className="text-sm text-zinc-600">{item.summary}</div>
|
||||
<div className="text-xs text-zinc-500">
|
||||
{contractTypeLabel(item.contractType)}
|
||||
{item.amount ? ` · ${item.amount} ${item.currency}` : " · 金额未录入"}
|
||||
{item.expiryDate ? ` · 到期日 ${formatDisplayDate(item.expiryDate)}` : " · 未设置到期日"}
|
||||
</div>
|
||||
<div className="text-xs text-zinc-500">
|
||||
项目:{item.project ? `${item.project.projectNo} · ${item.project.name}` : "未关联"} · 责任人:
|
||||
{item.owner?.name ?? "-"}
|
||||
</div>
|
||||
<div className="text-xs text-zinc-500">
|
||||
签署:{formatDisplayDate(item.signedDate)} · 生效:{formatDisplayDate(item.effectiveDate)} · 归档:{item.archiveLocation}
|
||||
{item.offlineArchiveNo ? ` · 离线档案号 ${item.offlineArchiveNo}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 md:max-w-[260px] md:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
openEditor(item);
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={renewMutation.isPending || !["active", "expired"].includes(item.status)}
|
||||
onClick={() => renewMutation.mutate(item)}
|
||||
>
|
||||
续约
|
||||
</Button>
|
||||
{item.project ? (
|
||||
<Button asChild size="sm" variant="secondary" type="button">
|
||||
<Link to={`/projects/${item.project.id}`}>查看项目</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={toggleStatusMutation.isPending}
|
||||
onClick={() => setConfirmStatusTarget(item)}
|
||||
>
|
||||
{item.persistedStatus === "archived" ? "恢复生效" : "归档"}
|
||||
</Button>
|
||||
<article key={item.id} className="rounded-lg border border-zinc-200 bg-white p-3 text-sm" data-testid="contracts-registry-row">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-semibold text-zinc-900">{item.contractNo}</div>
|
||||
<div className="mt-1 truncate text-zinc-600">{item.counterpartyName}</div>
|
||||
</div>
|
||||
<StatusBadge status={item.status} label={contractStatusLabel(item.status)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-zinc-500">
|
||||
{contractTypeLabel(item.contractType)}
|
||||
{item.amount ? ` · ${item.amount} ${item.currency}` : ""}
|
||||
{item.expiryDate ? ` · ${formatDisplayDate(item.expiryDate)}` : ""}
|
||||
</div>
|
||||
<Button className="mt-3" type="button" size="sm" variant="secondary" onClick={() => openEditor(item)}>
|
||||
编辑
|
||||
</Button>
|
||||
</article>
|
||||
))}
|
||||
|
||||
{!contractsQuery.isLoading && !visibleContracts.length ? (
|
||||
<div className="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}
|
||||
</div>
|
||||
</div>
|
||||
</BusinessMobileList>
|
||||
|
||||
<BusinessTable testId="contracts-registry-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-32">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-50">
|
||||
{visibleContracts.length ? (
|
||||
visibleContracts.map((item) => (
|
||||
<tr key={item.id} className="table-row" data-testid="contracts-registry-row">
|
||||
<td className="table-td">
|
||||
<div className="font-semibold text-zinc-900">{item.contractNo}</div>
|
||||
<div className="text-xs text-zinc-500">{contractTypeLabel(item.contractType)}</div>
|
||||
<div className="text-xs text-zinc-500">{confidentialityLabel(item.confidentialityLevel)}</div>
|
||||
</td>
|
||||
<td className="table-td">
|
||||
<div className="font-medium text-zinc-900">{item.counterpartyName}</div>
|
||||
<div className="line-clamp-2 text-xs text-zinc-500">{item.summary}</div>
|
||||
</td>
|
||||
<td className="table-td">
|
||||
<div className="font-medium text-zinc-900">
|
||||
{item.project ? `${item.project.projectNo} · ${item.project.name}` : "-"}
|
||||
</div>
|
||||
<div className="text-xs text-zinc-500">{item.owner?.name ?? "-"}</div>
|
||||
</td>
|
||||
<td className="table-td">
|
||||
<div className="font-semibold text-zinc-900">{item.amount ? `${item.amount} ${item.currency}` : "-"}</div>
|
||||
<div className="text-xs text-zinc-500">到期 {formatDisplayDate(item.expiryDate)}</div>
|
||||
<div className="text-xs text-zinc-500">签署 {formatDisplayDate(item.signedDate)}</div>
|
||||
</td>
|
||||
<td className="table-td">
|
||||
<StatusBadge status={item.status} label={contractStatusLabel(item.status)} />
|
||||
</td>
|
||||
<td className="table-td">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => openEditor(item)}>
|
||||
编辑
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" size="sm" variant="secondary">
|
||||
更多
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
disabled={renewMutation.isPending || !["active", "expired"].includes(item.status)}
|
||||
onSelect={() => renewMutation.mutate(item)}
|
||||
>
|
||||
续约
|
||||
</DropdownMenuItem>
|
||||
{item.project ? (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={`/projects/${item.project.id}`}>查看项目</Link>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
disabled={toggleStatusMutation.isPending}
|
||||
onSelect={() => setConfirmStatusTarget(item)}
|
||||
>
|
||||
{item.persistedStatus === "archived" ? "恢复生效" : "归档"}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td className="py-8 text-center text-sm text-zinc-500" colSpan={6}>
|
||||
暂无合同台账
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</BusinessTable>
|
||||
</>
|
||||
</div>
|
||||
<ActionConfirmDialog
|
||||
open={Boolean(confirmExportFormat)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { isApiError } from "../api/types";
|
||||
@@ -454,16 +454,6 @@ export function InvoicePoolPage() {
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE));
|
||||
const detail = detailQuery.data;
|
||||
const invoiceStats = useMemo(() => {
|
||||
const items = listQuery.data?.items ?? [];
|
||||
return {
|
||||
total: listQuery.data?.total ?? 0,
|
||||
pending: items.filter((item) => item.status === "pending_ocr").length,
|
||||
failed: items.filter((item) => item.status === "ocr_failed").length,
|
||||
available: items.filter((item) => item.status === "available").length,
|
||||
bound: items.filter((item) => item.status === "bound").length,
|
||||
};
|
||||
}, [listQuery.data?.items, listQuery.data?.total]);
|
||||
const canEditDetail = Boolean(
|
||||
detail &&
|
||||
(canManageInvoice ||
|
||||
@@ -568,22 +558,6 @@ export function InvoicePoolPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isInvoiceDetailMode ? (
|
||||
<div className="flex flex-wrap gap-2" data-testid="invoice-pool-summary">
|
||||
{[
|
||||
["全部", invoiceStats.total],
|
||||
["识别中", invoiceStats.pending],
|
||||
["识别失败", invoiceStats.failed],
|
||||
["可绑定", invoiceStats.available],
|
||||
["已绑定", invoiceStats.bound],
|
||||
].map(([label, value]) => (
|
||||
<span key={label} className="rounded-full border border-zinc-200 bg-white px-3 py-1 text-sm text-zinc-700">
|
||||
{label} <span className="font-semibold text-zinc-900">{value}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isInvoiceDetailMode ? (
|
||||
<InvoiceListCard
|
||||
listQuery={listQuery}
|
||||
|
||||
@@ -22,6 +22,10 @@ import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasPermission } from "../auth/permissions";
|
||||
import { ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace";
|
||||
import {
|
||||
BusinessMobileList,
|
||||
BusinessTable,
|
||||
} from "../components/patterns/BusinessListTemplate";
|
||||
import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog";
|
||||
import { AttachmentGovernanceBadges } from "../components/common/AttachmentGovernanceBadges";
|
||||
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
|
||||
@@ -130,23 +134,6 @@ export function PaymentBatchesPage() {
|
||||
const selectedBatch = useMemo<PaymentBatch | null>(() => {
|
||||
return batches.find((item) => item.id === selectedBatchId) ?? null;
|
||||
}, [batches, selectedBatchId]);
|
||||
const batchStats = useMemo(() => {
|
||||
return {
|
||||
total: batches.length,
|
||||
needsReview: batches.filter((item) => item.reviewRequirement.nextStep !== "ready_for_execute" && ["draft", "reviewed"].includes(item.status)).length,
|
||||
readyToPay: batches.filter((item) => item.reviewRequirement.nextStep === "ready_for_execute" && ["reviewed", "failed"].includes(item.status)).length,
|
||||
needsReceipt: batches.filter((item) =>
|
||||
["reviewed", "failed"].includes(item.status) &&
|
||||
(item.receiptAttachmentCount ?? 0) === 0 &&
|
||||
item.items.some((entry) =>
|
||||
["pending", "failed"].includes(entry.status) &&
|
||||
(entry.paymentChannelOverride ?? item.defaultPaymentChannel) === "bank",
|
||||
),
|
||||
).length,
|
||||
exceptions: batches.filter((item) => item.highRisk || item.status === "failed").length,
|
||||
};
|
||||
}, [batches]);
|
||||
|
||||
const receiptAttachmentsQuery = useQuery({
|
||||
queryKey: ["payment-batch-receipts", selectedBatch?.id],
|
||||
enabled: Boolean(selectedBatch?.id),
|
||||
@@ -379,22 +366,6 @@ 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 ? (
|
||||
<div className="flex flex-wrap gap-2" data-testid="payment-batches-summary">
|
||||
{[
|
||||
["全部", batchStats.total],
|
||||
["待复核", batchStats.needsReview],
|
||||
["待付款", batchStats.readyToPay],
|
||||
["待补回单", batchStats.needsReceipt],
|
||||
["异常", batchStats.exceptions],
|
||||
].map(([label, value]) => (
|
||||
<span key={label} className="rounded-full border border-zinc-200 bg-white px-3 py-1 text-sm text-zinc-700">
|
||||
{label} <span className="font-semibold text-zinc-900">{value}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!selectedBatchId && showBatchTools ? (
|
||||
<div className="finance-command-panel space-y-4" data-testid="payment-batch-create-panel">
|
||||
<div>
|
||||
@@ -691,17 +662,13 @@ export function PaymentBatchesPage() {
|
||||
<div className="finance-command-panel text-sm text-zinc-500">未找到付款批次</div>
|
||||
)
|
||||
) : (
|
||||
<div className="finance-command-panel" data-testid="payment-batches-list-panel">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<h3 className="text-base font-semibold text-zinc-900">批次列表</h3>
|
||||
<span className="text-sm text-zinc-500">共 {batches.length} 条</span>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
<>
|
||||
<BusinessMobileList testId="payment-batches-mobile-list">
|
||||
{batches.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="finance-list-card"
|
||||
className="w-full rounded-lg border border-zinc-200 bg-white p-3 text-left"
|
||||
onClick={() => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.set("id", item.id);
|
||||
@@ -718,12 +685,66 @@ export function PaymentBatchesPage() {
|
||||
{item.highRisk ? " · 高风险" : ""}
|
||||
{` · 复核 ${item.reviewRequirement.completedReviewCount}/${item.reviewRequirement.requiredReviewCount}`}
|
||||
</div>
|
||||
<div className="mt-3 text-sm font-medium text-primary">查看详情</div>
|
||||
</button>
|
||||
))}
|
||||
{!listQuery.data?.items?.length ? <div className="text-sm text-zinc-500">暂无付款批次</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
{!listQuery.data?.items?.length ? <div className="rounded-lg border border-zinc-200 bg-white p-4 text-sm text-zinc-500">暂无付款批次</div> : null}
|
||||
</BusinessMobileList>
|
||||
<BusinessTable testId="payment-batches-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-28">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-50">
|
||||
{batches.length ? (
|
||||
batches.map((item) => (
|
||||
<tr className="table-row" key={item.id}>
|
||||
<td className="table-td">
|
||||
<div className="font-semibold text-zinc-900">{item.batchNo}</div>
|
||||
<div className="text-xs text-zinc-500">{item.name}</div>
|
||||
</td>
|
||||
<td className="table-td">{entityTypeLabel(item.entityType)}</td>
|
||||
<td className="table-td-strong">{item.totalAmount}</td>
|
||||
<td className="table-td">
|
||||
{item.reviewRequirement.completedReviewCount}/{item.reviewRequirement.requiredReviewCount}
|
||||
</td>
|
||||
<td className="table-td">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge status={item.status} label={batchStatusLabel(item.status)} />
|
||||
{item.highRisk ? <span className="badge-warning">高风险</span> : null}
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-td">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.set("id", item.id);
|
||||
setSearchParams(next, { replace: true });
|
||||
}}
|
||||
>
|
||||
查看详情
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td className="py-8 text-center text-sm text-zinc-500" colSpan={6}>
|
||||
暂无付款批次
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</BusinessTable>
|
||||
</>
|
||||
)}
|
||||
<ActionConfirmDialog
|
||||
open={Boolean(confirmPaymentAction)}
|
||||
|
||||
@@ -14,7 +14,12 @@ import { useAuth } from "../auth/AuthContext";
|
||||
import { hasAnyPermission } from "../auth/permissions";
|
||||
import { DateTextInput } from "../components/common/DateTextInput";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { FilterBar, ListWorkspace, WorkspaceHeader, WorkspaceNotice } from "../components/patterns/ListWorkspace";
|
||||
import { ListWorkspace, WorkspaceHeader, WorkspaceNotice } from "../components/patterns/ListWorkspace";
|
||||
import {
|
||||
BusinessListToolbar,
|
||||
BusinessMobileList,
|
||||
BusinessTable,
|
||||
} from "../components/patterns/BusinessListTemplate";
|
||||
import { SectionPanel } from "../components/patterns/SectionPanel";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
@@ -255,8 +260,15 @@ export function ProjectsPage() {
|
||||
</SectionPanel>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-6">
|
||||
<FilterBar className="grid gap-3 md:grid-cols-[minmax(0,1fr),180px,160px]">
|
||||
<div className="space-y-4">
|
||||
<BusinessListToolbar
|
||||
actions={
|
||||
<Button type="button" size="sm" variant={mine ? "default" : "secondary"} onClick={() => updateSearch({ mine: !mine })}>
|
||||
{mine ? "我相关" : "全范围"}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr),180px]">
|
||||
<Input placeholder="搜索项目编号、项目名、客户名" value={q} onChange={(event) => updateSearch({ q: event.target.value })} />
|
||||
<select className="select-std" value={status} onChange={(event) => updateSearch({ status: event.target.value })}>
|
||||
{STATUS_OPTIONS.map((item) => (
|
||||
@@ -265,10 +277,8 @@ export function ProjectsPage() {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button type="button" variant={mine ? "default" : "secondary"} onClick={() => updateSearch({ mine: !mine })}>
|
||||
{mine ? "我相关" : "全范围"}
|
||||
</Button>
|
||||
</FilterBar>
|
||||
</div>
|
||||
</BusinessListToolbar>
|
||||
|
||||
{!showTrialData && hiddenTrialProjectCount > 0 ? (
|
||||
<WorkspaceNotice title={`已隐藏 ${hiddenTrialProjectCount} 条 TEST/SMOKE 项目`} tone="neutral">
|
||||
@@ -276,18 +286,16 @@ export function ProjectsPage() {
|
||||
</WorkspaceNotice>
|
||||
) : null}
|
||||
|
||||
<SectionPanel title="项目列表">
|
||||
<div className="space-y-4">
|
||||
{listQuery.isLoading ? <div className="text-sm text-zinc-500">项目加载中...</div> : null}
|
||||
{listQuery.isError ? <div className="text-sm text-red-600">{resolveError(listQuery.error, "加载项目失败")}</div> : null}
|
||||
|
||||
<div className="grid gap-4">
|
||||
<BusinessMobileList>
|
||||
{visibleProjectItems.map((item) => (
|
||||
<Link key={item.id} to={`/projects/${item.id}`} className="card-std block space-y-3 p-5 hover:border-primary/40">
|
||||
<Link key={item.id} to={`/projects/${item.id}`} className="block rounded-lg border border-zinc-200 bg-white p-3 text-sm">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-lg font-semibold text-zinc-900">{item.projectNo}</div>
|
||||
<div className="font-semibold text-zinc-900">{item.projectNo}</div>
|
||||
<StatusBadge status={item.status} />
|
||||
{item.isConfidential ? <span className="rounded-full bg-amber-100 px-2 py-1 text-xs text-amber-700">涉密</span> : null}
|
||||
</div>
|
||||
@@ -305,13 +313,61 @@ export function ProjectsPage() {
|
||||
</Link>
|
||||
))}
|
||||
{!listQuery.isLoading && !visibleProjectItems.length ? (
|
||||
<div className="rounded-xl border border-dashed border-zinc-200 bg-zinc-50 px-4 py-8 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}
|
||||
</div>
|
||||
</div>
|
||||
</SectionPanel>
|
||||
</BusinessMobileList>
|
||||
|
||||
<BusinessTable testId="projects-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-28">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-50">
|
||||
{visibleProjectItems.length ? (
|
||||
visibleProjectItems.map((item) => (
|
||||
<tr key={item.id} className="table-row">
|
||||
<td className="table-td">
|
||||
<div className="font-semibold text-zinc-900">{item.projectNo}</div>
|
||||
<div className="text-xs text-zinc-500">{item.name}</div>
|
||||
{item.isConfidential ? <div className="text-xs text-amber-700">涉密</div> : null}
|
||||
</td>
|
||||
<td className="table-td">
|
||||
<div className="font-medium text-zinc-900">{item.clientAlias || item.clientName}</div>
|
||||
<div className="line-clamp-2 text-xs text-zinc-500">{item.summary || "-"}</div>
|
||||
</td>
|
||||
<td className="table-td">
|
||||
<div className="font-medium text-zinc-900">{item.owner?.name ?? "-"}</div>
|
||||
<div className="text-xs text-zinc-500">项目经理 {item.manager?.name ?? "-"}</div>
|
||||
</td>
|
||||
<td className="table-td">
|
||||
<div className="text-xs text-zinc-500">{summaryLine(item)}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">目标 {formatDisplayDate(item.targetEndAt)}</div>
|
||||
</td>
|
||||
<td className="table-td">
|
||||
<StatusBadge status={item.status} />
|
||||
</td>
|
||||
<td className="table-td">
|
||||
<Button asChild type="button" size="sm" variant="secondary">
|
||||
<Link to={`/projects/${item.id}`}>查看详情</Link>
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td className="py-8 text-center text-sm text-zinc-500" colSpan={6}>
|
||||
暂无项目
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</BusinessTable>
|
||||
</div>
|
||||
</ListWorkspace>
|
||||
);
|
||||
|
||||
@@ -45,7 +45,12 @@ import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
import { Textarea } from "../components/ui/textarea";
|
||||
import { FilterBar, ListWorkspace, PrimaryTable, WorkspaceHeader } from "../components/patterns/ListWorkspace";
|
||||
import { ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace";
|
||||
import {
|
||||
BusinessListToolbar,
|
||||
BusinessMobileList,
|
||||
BusinessTableFrame,
|
||||
} from "../components/patterns/BusinessListTemplate";
|
||||
import { SectionPanel } from "../components/patterns/SectionPanel";
|
||||
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||
import type { AttachmentGovernanceFields } from "../lib/attachmentGovernance";
|
||||
@@ -842,9 +847,97 @@ export function PurchaseRequestsPage() {
|
||||
|
||||
{!isPurchaseDetailMode ? (
|
||||
<>
|
||||
<FilterBar className="ops-command-bar space-y-3 !p-3 md:!p-3.5">
|
||||
<div className="ops-command-bar__row">
|
||||
<div className="min-w-0">
|
||||
<BusinessListToolbar
|
||||
actions={
|
||||
<span className="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600">
|
||||
{mine ? "我相关" : "全范围"}
|
||||
</span>
|
||||
}
|
||||
advanced={showAdvancedTools ? (
|
||||
<div className="grid gap-3 md:grid-cols-3 xl:grid-cols-4" data-testid="purchase-ledger-tools-panel">
|
||||
<select
|
||||
className="select-std"
|
||||
value={supplierFilter}
|
||||
onChange={(event) => {
|
||||
setSupplierFilter(event.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">全部供应商</option>
|
||||
{(suppliersQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
className="select-std"
|
||||
value={legalEntityFilter}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
setLegalEntityFilter(nextValue);
|
||||
if (costCenterFilter && !filterCostCenters.some((item) => item.id === costCenterFilter && item.legalEntityId === nextValue)) {
|
||||
setCostCenterFilter("");
|
||||
}
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">全部法体</option>
|
||||
{(legalEntitiesQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
className="select-std"
|
||||
value={costCenterFilter}
|
||||
onChange={(event) => {
|
||||
setCostCenterFilter(event.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">全部成本中心</option>
|
||||
{filterCostCenters.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{canViewAll ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
data-testid="purchase-scope-mine"
|
||||
size="sm"
|
||||
type="button"
|
||||
variant={mine ? "default" : "secondary"}
|
||||
onClick={() => {
|
||||
setMine(true);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
我相关
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="purchase-scope-all"
|
||||
size="sm"
|
||||
type="button"
|
||||
variant={!mine ? "default" : "secondary"}
|
||||
onClick={() => {
|
||||
setMine(false);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
全范围
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
>
|
||||
<Tabs
|
||||
data-testid="purchase-status-tabs"
|
||||
value={status}
|
||||
@@ -865,113 +958,7 @@ export function PurchaseRequestsPage() {
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div className="ops-command-bar__actions">
|
||||
<span className="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600">
|
||||
{mine ? "我相关" : "全范围"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showAdvancedTools ? (
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50/70 p-3" data-testid="purchase-ledger-tools-panel">
|
||||
<div className="grid gap-3 md:grid-cols-3 xl:grid-cols-4">
|
||||
<select
|
||||
className="select-std"
|
||||
value={supplierFilter}
|
||||
onChange={(event) => {
|
||||
setSupplierFilter(event.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">全部供应商</option>
|
||||
{(suppliersQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
className="select-std"
|
||||
value={legalEntityFilter}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
setLegalEntityFilter(nextValue);
|
||||
if (costCenterFilter && !filterCostCenters.some((item) => item.id === costCenterFilter && item.legalEntityId === nextValue)) {
|
||||
setCostCenterFilter("");
|
||||
}
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">全部法体</option>
|
||||
{(legalEntitiesQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
className="select-std"
|
||||
value={costCenterFilter}
|
||||
onChange={(event) => {
|
||||
setCostCenterFilter(event.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">全部成本中心</option>
|
||||
{filterCostCenters.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{canViewAll ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
data-testid="purchase-scope-mine"
|
||||
size="sm"
|
||||
type="button"
|
||||
variant={mine ? "default" : "secondary"}
|
||||
onClick={() => {
|
||||
setMine(true);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
我相关
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="purchase-scope-all"
|
||||
size="sm"
|
||||
type="button"
|
||||
variant={!mine ? "default" : "secondary"}
|
||||
onClick={() => {
|
||||
setMine(false);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
全范围
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg bg-white px-3 py-2 text-sm text-zinc-500">
|
||||
当前仅显示与你相关的采购。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</FilterBar>
|
||||
|
||||
<div className="grid gap-6">
|
||||
<div className="card-std space-y-4 p-4 md:p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold text-zinc-900">采购单列表</h3>
|
||||
<span className="text-sm text-zinc-500">共 {listQuery.data?.total ?? 0} 条</span>
|
||||
</div>
|
||||
</BusinessListToolbar>
|
||||
|
||||
{listQuery.isLoading ? <TableSkeleton columns={4} rows={6} /> : null}
|
||||
|
||||
@@ -983,7 +970,7 @@ export function PurchaseRequestsPage() {
|
||||
|
||||
{!listQuery.isLoading && !listQuery.isError ? (
|
||||
<>
|
||||
<div className="space-y-3 md:hidden">
|
||||
<BusinessMobileList>
|
||||
{purchaseItems.length ? (
|
||||
purchaseItems.map((item) => {
|
||||
const evidenceHint = resolvePurchaseEvidenceHint(item);
|
||||
@@ -1033,13 +1020,13 @@ export function PurchaseRequestsPage() {
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="rounded-lg border border-zinc-200 p-4 text-sm text-zinc-500">
|
||||
<div className="rounded-lg border border-zinc-200 bg-white p-4 text-sm text-zinc-500">
|
||||
<div className="font-semibold text-zinc-900">{emptyState.title}</div>
|
||||
<div className="mt-1 leading-6">{emptyState.description}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<PrimaryTable className="hidden md:block">
|
||||
</BusinessMobileList>
|
||||
<BusinessTableFrame>
|
||||
<Table data-testid="purchase-requests-table">
|
||||
<TableHeader>
|
||||
<tr>
|
||||
@@ -1087,7 +1074,6 @@ export function PurchaseRequestsPage() {
|
||||
) : (
|
||||
<span className="text-zinc-500">未关联项目</span>
|
||||
)}
|
||||
<div className="mt-1 text-xs text-zinc-500">合同:详情页登记归档摘要</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={item.status} />
|
||||
@@ -1122,7 +1108,7 @@ export function PurchaseRequestsPage() {
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</PrimaryTable>
|
||||
</BusinessTableFrame>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
@@ -1132,8 +1118,6 @@ export function PurchaseRequestsPage() {
|
||||
onPageChange={setPage}
|
||||
testIdPrefix="purchase-requests"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="card-std space-y-5 p-4 md:p-5">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Clock3, FilePenLine, RotateCcw, Wrench } from "lucide-react";
|
||||
import { Wrench } from "lucide-react";
|
||||
import { isApiError } from "../api/types";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { fetchMyDelegations } from "../api/delegations";
|
||||
@@ -25,7 +25,14 @@ import { Pagination } from "../components/common/Pagination";
|
||||
import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { TableSkeleton } from "../components/common/TableSkeleton";
|
||||
import { FilterBar, ListWorkspace, PrimaryTable, WorkspaceHeader } from "../components/patterns/ListWorkspace";
|
||||
import { ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace";
|
||||
import {
|
||||
BusinessEmptyRow,
|
||||
BusinessEmptyState,
|
||||
BusinessListToolbar,
|
||||
BusinessMobileList,
|
||||
BusinessTable,
|
||||
} from "../components/patterns/BusinessListTemplate";
|
||||
import { Button } from "../components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -49,32 +56,6 @@ const REIMBURSEMENT_STATUS_OPTIONS: Array<{ value: ReimbursementStatus; label: s
|
||||
{ value: "cancelled", label: "已取消" },
|
||||
];
|
||||
|
||||
const REIMBURSEMENT_TASK_STATUS_OPTIONS = [
|
||||
{
|
||||
value: "rejected",
|
||||
title: "被驳回待补正",
|
||||
icon: RotateCcw,
|
||||
accentClass: "border-red-200 bg-red-50 text-red-700",
|
||||
},
|
||||
{
|
||||
value: "draft",
|
||||
title: "草稿待完善",
|
||||
icon: FilePenLine,
|
||||
accentClass: "border-amber-200 bg-amber-50 text-amber-700",
|
||||
},
|
||||
{
|
||||
value: "reviewing",
|
||||
title: "审批中进度",
|
||||
icon: Clock3,
|
||||
accentClass: "border-sky-200 bg-sky-50 text-sky-700",
|
||||
},
|
||||
] satisfies Array<{
|
||||
value: ReimbursementStatus;
|
||||
title: string;
|
||||
icon: typeof RotateCcw;
|
||||
accentClass: string;
|
||||
}>;
|
||||
|
||||
function formatCurrency(value: number) {
|
||||
return new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format(value);
|
||||
}
|
||||
@@ -198,21 +179,6 @@ export function ReimbursementsPage() {
|
||||
queryFn: () => fetchReimbursements({ status, page, pageSize: PAGE_SIZE, scope }),
|
||||
});
|
||||
|
||||
const rejectedSummaryQuery = useQuery({
|
||||
queryKey: ["reimbursements", "task-summary", "own", "rejected"],
|
||||
queryFn: () => fetchReimbursements({ status: "rejected", page: 1, pageSize: 1, scope: "own" }),
|
||||
});
|
||||
|
||||
const draftSummaryQuery = useQuery({
|
||||
queryKey: ["reimbursements", "task-summary", "own", "draft"],
|
||||
queryFn: () => fetchReimbursements({ status: "draft", page: 1, pageSize: 1, scope: "own" }),
|
||||
});
|
||||
|
||||
const reviewingSummaryQuery = useQuery({
|
||||
queryKey: ["reimbursements", "task-summary", "own", "reviewing"],
|
||||
queryFn: () => fetchReimbursements({ status: "reviewing", page: 1, pageSize: 1, scope: "own" }),
|
||||
});
|
||||
|
||||
const proxyDelegationsQuery = useQuery({
|
||||
enabled: showProxyOwnerSelector,
|
||||
queryKey: ["delegations", "mine", "active"],
|
||||
@@ -251,11 +217,6 @@ export function ReimbursementsPage() {
|
||||
const templateOptions = templateOptionsQuery.data?.items ?? [];
|
||||
const linkableTravelRequests = linkableTravelRequestsQuery.data?.items ?? [];
|
||||
|
||||
const taskSummaryTotalByStatus = {
|
||||
rejected: rejectedSummaryQuery.data?.total,
|
||||
draft: draftSummaryQuery.data?.total,
|
||||
reviewing: reviewingSummaryQuery.data?.total,
|
||||
} satisfies Record<(typeof REIMBURSEMENT_TASK_STATUS_OPTIONS)[number]["value"], number | undefined>;
|
||||
const canShowCreateOptionsMenu =
|
||||
canCreateExpense && shouldLoadCreateOptions && (templateOptions.length > 0 || linkableTravelRequests.length > 0);
|
||||
const emptyState = resolveEmptyStateCopy(status);
|
||||
@@ -352,73 +313,22 @@ export function ReimbursementsPage() {
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<section className="flex flex-wrap gap-2" aria-label="我的报销任务">
|
||||
{REIMBURSEMENT_TASK_STATUS_OPTIONS.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const count = taskSummaryTotalByStatus[item.value];
|
||||
const active = status === item.value && scope === "own";
|
||||
const displayCount = typeof count === "number" ? count : "-";
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`inline-flex min-h-10 items-center gap-2 rounded-full border px-3 py-1.5 text-sm transition hover:border-primary/40 hover:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-200 ${
|
||||
active ? "border-primary/40 bg-primary/5 text-primary" : "border-zinc-200 bg-white text-zinc-700"
|
||||
}`}
|
||||
data-testid={`reimbursements-task-card-${item.value}`}
|
||||
key={item.value}
|
||||
onClick={() => setSearchParams({ status: item.value, page: "1", scope: "own" })}
|
||||
<BusinessListToolbar
|
||||
actions={
|
||||
canShowLedgerEntry ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={showAdvancedTools ? "default" : "secondary"}
|
||||
onClick={() => setShowLedgerTools((current) => !current)}
|
||||
type="button"
|
||||
>
|
||||
<span className={`flex size-7 items-center justify-center rounded-full border ${item.accentClass}`}>
|
||||
<Icon aria-hidden className="size-3.5" />
|
||||
</span>
|
||||
<span className="font-medium">{item.title}</span>
|
||||
<span className="font-semibold tabular-nums text-zinc-950">{displayCount}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
|
||||
<FilterBar className="ops-command-bar space-y-3 !p-3 md:!p-3.5">
|
||||
<div className="ops-command-bar__row">
|
||||
<div className="min-w-0 space-y-2">
|
||||
<div className="text-xs font-semibold text-zinc-500">报销记录</div>
|
||||
<Tabs
|
||||
data-testid="reimbursements-status-tabs"
|
||||
value={status}
|
||||
onValueChange={(value) => setSearchParams({ status: value, page: "1", scope })}
|
||||
>
|
||||
<TabsList className="ops-command-bar__tabs !h-auto !justify-start !bg-slate-50">
|
||||
{REIMBURSEMENT_STATUS_OPTIONS.map((item) => (
|
||||
<TabsTrigger
|
||||
data-testid={`reimbursements-tab-${item.value}`}
|
||||
key={item.value}
|
||||
value={item.value}
|
||||
>
|
||||
{item.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div className="ops-command-bar__actions">
|
||||
{canShowLedgerEntry ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={showAdvancedTools ? "default" : "secondary"}
|
||||
onClick={() => setShowLedgerTools((current) => !current)}
|
||||
type="button"
|
||||
>
|
||||
<Wrench aria-hidden className="mr-1 size-4" />
|
||||
台账工具
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showAdvancedTools ? (
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50/70 p-3">
|
||||
<Wrench aria-hidden className="mr-1 size-4" />
|
||||
台账工具
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
advanced={showAdvancedTools ? (
|
||||
<>
|
||||
<div className="grid gap-3 text-sm md:grid-cols-4">
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">当前列表</div>
|
||||
@@ -563,11 +473,28 @@ export function ReimbursementsPage() {
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</FilterBar>
|
||||
>
|
||||
<Tabs
|
||||
data-testid="reimbursements-status-tabs"
|
||||
value={status}
|
||||
onValueChange={(value) => setSearchParams({ status: value, page: "1", scope })}
|
||||
>
|
||||
<TabsList className="ops-command-bar__tabs !h-auto !justify-start !bg-slate-50">
|
||||
{REIMBURSEMENT_STATUS_OPTIONS.map((item) => (
|
||||
<TabsTrigger
|
||||
data-testid={`reimbursements-tab-${item.value}`}
|
||||
key={item.value}
|
||||
value={item.value}
|
||||
>
|
||||
{item.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</BusinessListToolbar>
|
||||
|
||||
{listQuery.isLoading ? <TableSkeleton columns={6} rows={5} /> : null}
|
||||
|
||||
@@ -585,7 +512,7 @@ export function ReimbursementsPage() {
|
||||
|
||||
{!listQuery.isLoading && !listQuery.isError ? (
|
||||
<>
|
||||
<div className="space-y-3 md:hidden" data-testid="reimbursements-mobile-list">
|
||||
<BusinessMobileList testId="reimbursements-mobile-list">
|
||||
{items.length ? (
|
||||
items.map((item) => {
|
||||
const listAction = resolveListAction(item);
|
||||
@@ -669,12 +596,11 @@ export function ReimbursementsPage() {
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-zinc-300 px-4 py-8 text-center text-sm text-zinc-500">
|
||||
<div className="font-semibold text-zinc-900">{emptyState.title}</div>
|
||||
<div className="mt-1 leading-6 text-zinc-500">{emptyState.description}</div>
|
||||
{status === "draft" && canCreateExpense ? (
|
||||
<BusinessEmptyState
|
||||
title={emptyState.title}
|
||||
description={emptyState.description}
|
||||
action={status === "draft" && canCreateExpense ? (
|
||||
<Button
|
||||
className="mt-3"
|
||||
disabled={createMutation.isPending}
|
||||
onClick={() => startCreateReimbursement()}
|
||||
size="sm"
|
||||
@@ -683,12 +609,11 @@ export function ReimbursementsPage() {
|
||||
{createMutation.isPending ? "创建中..." : "新建报销"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</BusinessMobileList>
|
||||
|
||||
<PrimaryTable className="hidden md:block ops-table-focus">
|
||||
<table className="w-full border-collapse text-left" data-testid="reimbursements-table">
|
||||
<BusinessTable testId="reimbursements-table">
|
||||
<thead className="table-header">
|
||||
<tr>
|
||||
<th className="table-th">单号</th>
|
||||
@@ -783,29 +708,24 @@ export function ReimbursementsPage() {
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<tr>
|
||||
<td className="py-8 text-center text-sm text-zinc-500" colSpan={6}>
|
||||
<div className="mx-auto max-w-sm py-3">
|
||||
<div className="font-semibold text-zinc-900">{emptyState.title}</div>
|
||||
<div className="mt-1 leading-6 text-zinc-500">{emptyState.description}</div>
|
||||
{status === "draft" && canCreateExpense ? (
|
||||
<Button
|
||||
className="mt-3"
|
||||
disabled={createMutation.isPending}
|
||||
onClick={() => startCreateReimbursement()}
|
||||
size="sm"
|
||||
type="button"
|
||||
>
|
||||
{createMutation.isPending ? "创建中..." : "新建报销"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<BusinessEmptyRow
|
||||
colSpan={6}
|
||||
title={emptyState.title}
|
||||
description={emptyState.description}
|
||||
action={status === "draft" && canCreateExpense ? (
|
||||
<Button
|
||||
disabled={createMutation.isPending}
|
||||
onClick={() => startCreateReimbursement()}
|
||||
size="sm"
|
||||
type="button"
|
||||
>
|
||||
{createMutation.isPending ? "创建中..." : "新建报销"}
|
||||
</Button>
|
||||
) : null}
|
||||
/>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</PrimaryTable>
|
||||
</BusinessTable>
|
||||
|
||||
<div className="mt-4">
|
||||
<Pagination
|
||||
|
||||
@@ -3,6 +3,11 @@ import type { InvoicePoolListResponse, InvoicePoolStatus } from "../../api/invoi
|
||||
import { StatusBadge } from "../../components/common/StatusBadge";
|
||||
import { TableSkeleton } from "../../components/common/TableSkeleton";
|
||||
import { Pagination } from "../../components/common/Pagination";
|
||||
import {
|
||||
BusinessListToolbar,
|
||||
BusinessMobileList,
|
||||
BusinessTable,
|
||||
} from "../../components/patterns/BusinessListTemplate";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../../components/ui/dropdown-menu";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
@@ -90,9 +95,8 @@ export function InvoiceListCard({
|
||||
signedUrlMutation,
|
||||
}: InvoiceListCardProps) {
|
||||
return (
|
||||
<div className="card-std space-y-4 p-4 md:p-5">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<h3 className="card-title">票据列表</h3>
|
||||
<div className="space-y-4">
|
||||
<BusinessListToolbar>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<select
|
||||
className="select-sm"
|
||||
@@ -124,12 +128,12 @@ export function InvoiceListCard({
|
||||
<option value="all">查看全部</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</BusinessListToolbar>
|
||||
|
||||
{listQuery.isLoading ? <TableSkeleton columns={5} rows={5} /> : null}
|
||||
{!listQuery.isLoading ? (
|
||||
<>
|
||||
<div className="space-y-3 md:hidden" data-testid="invoice-pool-mobile-list">
|
||||
<BusinessMobileList testId="invoice-pool-mobile-list">
|
||||
{(listQuery.data?.items ?? []).length ? (
|
||||
listQuery.data?.items.map((item) => (
|
||||
<article className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm" data-testid="invoice-mobile-row" key={item.id}>
|
||||
@@ -193,10 +197,9 @@ export function InvoiceListCard({
|
||||
暂无票据数据
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</BusinessMobileList>
|
||||
|
||||
<div className="table-container hidden md:block">
|
||||
<table className="w-full text-left text-sm text-zinc-600" data-testid="invoice-pool-table">
|
||||
<BusinessTable testId="invoice-pool-table">
|
||||
<thead className="bg-zinc-50/50">
|
||||
<tr>
|
||||
<th className="table-th w-[280px]">票据名称</th>
|
||||
@@ -260,8 +263,7 @@ export function InvoiceListCard({
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</BusinessTable>
|
||||
|
||||
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} testIdPrefix="invoice" />
|
||||
</>
|
||||
|
||||
@@ -1099,14 +1099,17 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
|
||||
|
||||
await page.goto("/contracts-registry?projectId=project-wave3");
|
||||
await expect(page.getByRole("heading", { name: "合同台账", exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId("contracts-registry-row")).toBeVisible();
|
||||
await expect(page.getByTestId("contracts-registry-table").getByTestId("contracts-registry-row")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "导出 CSV" })).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "台账工具" }).click();
|
||||
await expect(page.getByTestId("contracts-ledger-tools")).toBeVisible();
|
||||
await expect(page.getByTestId("contracts-editor-panel")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "导出 CSV" })).toBeVisible();
|
||||
await expect(page.getByTestId("contracts-registry-row")).toBeVisible();
|
||||
await page.getByTestId("contracts-registry-row").getByRole("button", { name: "归档" }).click();
|
||||
await page.getByRole("button", { name: "新增合同" }).click();
|
||||
await expect(page.getByTestId("contracts-editor-panel")).toBeVisible();
|
||||
await page.getByRole("button", { name: "关闭" }).click();
|
||||
await expect(page.getByTestId("contracts-registry-table").getByTestId("contracts-registry-row")).toBeVisible();
|
||||
await page.getByTestId("contracts-registry-table").getByRole("button", { name: "更多" }).first().click();
|
||||
await page.getByRole("menuitem", { name: "归档" }).click();
|
||||
await expect(page.getByTestId("action-confirm-dialog")).toContainText("确认归档");
|
||||
await page.getByTestId("action-confirm-cancel").click();
|
||||
await expect(page.getByTestId("action-confirm-dialog")).toHaveCount(0);
|
||||
|
||||
@@ -1000,9 +1000,9 @@ async function capture(page: Page, name: string) {
|
||||
}
|
||||
|
||||
const pages = [
|
||||
{ path: "/payment-batches", summary: "payment-batches-summary", proof: "text=批次列表", screenshot: "01-payment-batches", expectStatusStrip: false },
|
||||
{ path: "/payment-batches", proof: "data-testid=payment-batches-table", screenshot: "01-payment-batches", expectStatusStrip: false },
|
||||
{ path: "/ap-operations", proof: "text=异常付款", screenshot: "02-ap-operations", expectStatusStrip: false },
|
||||
{ path: "/invoice-pool", summary: "invoice-pool-summary", proof: "data-testid=invoice-pool-table", screenshot: "03-invoice-pool", expectStatusStrip: false },
|
||||
{ path: "/invoice-pool", proof: "data-testid=invoice-pool-table", screenshot: "03-invoice-pool", expectStatusStrip: false },
|
||||
{ path: "/system/approval-rules", summary: "approval-rules-summary", proof: "data-testid=approval-rules-table", screenshot: "04-approval-rules" },
|
||||
{ path: "/people", summary: "people-summary", proof: "data-testid=people-table", screenshot: "05-people" },
|
||||
{ path: "/organization", summary: "organization-summary", proof: "text=研发部", screenshot: "06-organization" },
|
||||
|
||||
@@ -320,7 +320,7 @@ test.describe("R102 reimbursement main flow", () => {
|
||||
await page.setViewportSize({ width: 1440, height: 960 });
|
||||
await page.goto("/reimbursements?status=draft");
|
||||
|
||||
await expect(page.getByTestId("reimbursements-task-card-draft")).toBeVisible();
|
||||
await expect(page.getByTestId("reimbursements-tab-draft")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "台账工具" })).toHaveCount(0);
|
||||
await expect(page.getByTestId("reimbursement-row").getByText(formNumber)).toBeVisible();
|
||||
await expect(page.getByText(reimbursementId)).toHaveCount(0);
|
||||
|
||||
在新工单中引用
屏蔽一个用户