统一编辑模板并移除首批台账工具(05-31-business-edit-template-remove-ledger-tools-wave1)

这个提交包含在:
2026-05-31 14:10:10 +08:00
父节点 22527ae37b
当前提交 1428cc4ffa
共修改 13 个文件,包含 556 行新增和 205 行删除
@@ -0,0 +1,162 @@
import type { ReactNode } from "react";
import { Link } from "react-router-dom";
import { cn } from "../../lib/utils";
import { Button } from "../ui/button";
import { WorkspaceHeader } from "./ListWorkspace";
import { SectionPanel } from "./SectionPanel";
export function BusinessEditTemplate({
eyebrow,
title,
backTo,
backLabel = "返回",
status,
actions,
meta,
validation,
bottomActions,
children,
className,
testId,
}: {
eyebrow?: string;
title: string;
backTo?: string;
backLabel?: string;
status?: ReactNode;
actions?: ReactNode;
meta?: ReactNode;
validation?: ReactNode;
bottomActions?: ReactNode;
children: ReactNode;
className?: string;
testId?: string;
}) {
const headerActions = actions || status || backTo ? (
<div className="flex flex-wrap items-center justify-end gap-2">
{status ? <div className="shrink-0">{status}</div> : null}
{actions}
{backTo ? (
<Button asChild type="button" variant="secondary">
<Link to={backTo}>{backLabel}</Link>
</Button>
) : null}
</div>
) : null;
return (
<div className={cn("page-scaffold", bottomActions && "pb-24 md:pb-0", className)} data-testid={testId}>
<WorkspaceHeader eyebrow={eyebrow} title={title} meta={meta} actions={headerActions} />
{validation ? <BusinessEditValidationSummary>{validation}</BusinessEditValidationSummary> : null}
{children}
{bottomActions ? <BusinessEditActionBar>{bottomActions}</BusinessEditActionBar> : null}
</div>
);
}
export function BusinessEditWorkspace({
children,
className,
testId,
}: {
children: ReactNode;
className?: string;
testId?: string;
}) {
return (
<div className={cn("detail-workspace-grid", className)} data-testid={testId}>
{children}
</div>
);
}
export function BusinessEditMain({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return <div className={cn("space-y-5", className)}>{children}</div>;
}
export function BusinessEditAside({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return <div className={cn("space-y-5", className)}>{children}</div>;
}
export function BusinessEditSection({
title,
description,
actions,
children,
className,
}: {
title: string;
description?: string;
actions?: ReactNode;
children: ReactNode;
className?: string;
}) {
return (
<SectionPanel title={title} description={description} actions={actions} className={className}>
{children}
</SectionPanel>
);
}
export function BusinessEditFormGrid({
children,
className,
columns = 2,
}: {
children: ReactNode;
className?: string;
columns?: 1 | 2 | 3;
}) {
const columnClass = {
1: "grid-cols-1",
2: "md:grid-cols-2",
3: "md:grid-cols-2 xl:grid-cols-3",
}[columns];
return <div className={cn("grid gap-4", columnClass, className)}>{children}</div>;
}
export function BusinessEditValidationSummary({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<div className={cn("rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700", className)}>
{children}
</div>
);
}
export function BusinessEditActionBar({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"mobile-sticky-action-surface fixed inset-x-0 bottom-0 z-50 border-t border-zinc-200 bg-white/95 shadow-[0_-10px_24px_rgba(15,23,42,0.10)] backdrop-blur md:static md:inset-auto md:mt-5 md:rounded-lg md:border md:bg-white md:p-3 md:shadow-none",
className,
)}
>
<div className="mobile-sticky-action-grid md:flex md:justify-end md:gap-2">{children}</div>
</div>
);
}
+103 -62
查看文件
@@ -226,7 +226,8 @@ export function AssetRegistryPage() {
const [assigneeFilter, setAssigneeFilter] = useState("");
const [assetSearch, setAssetSearch] = useState("");
const [mine, setMine] = useState(!canViewAll);
const [showLedgerTools, setShowLedgerTools] = useState(false);
const [showCategoryManager, setShowCategoryManager] = useState(false);
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false);
const [selectedAssetId, setSelectedAssetId] = useState(searchParams.get("id") ?? "");
const [isCreatingAsset, setIsCreatingAsset] = useState(false);
@@ -377,6 +378,14 @@ export function AssetRegistryPage() {
const currentAsset = assetDetailQuery.data?.asset ?? null;
const canEditCurrent = canManage && (isCreatingAsset || currentAsset?.permissions.canEdit);
const hasAdvancedAssetFilters = Boolean(
sensitivityFilter ||
categoryFilter ||
legalEntityFilter ||
costCenterFilter ||
assigneeFilter,
);
const showAssetAdvancedFilters = showAdvancedFilters || hasAdvancedAssetFilters;
function resetCategoryEditor(category?: AssetCategoryItem) {
if (!category) {
@@ -733,9 +742,15 @@ export function AssetRegistryPage() {
<Button type="button" variant="secondary" onClick={() => navigate("/mobile/assets/scan") }>
移动扫码查找
</Button>
<Button type="button" variant={showLedgerTools ? "default" : "secondary"} onClick={() => setShowLedgerTools((value) => !value)}>
台账工具
</Button>
{canManage ? (
<Button
type="button"
variant={showCategoryManager ? "default" : "secondary"}
onClick={() => setShowCategoryManager((value) => !value)}
>
资产分类
</Button>
) : null}
</>
)}
</div>
@@ -744,8 +759,8 @@ export function AssetRegistryPage() {
{!isAssetDetailMode ? (
<div className="grid gap-6">
{showLedgerTools ? (
<div className="card-std space-y-4 p-4 md:p-5" data-testid="asset-ledger-tools">
{showCategoryManager && canManage ? (
<div className="card-std space-y-4 p-4 md:p-5" data-testid="asset-category-manager">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-zinc-900">资产分类</h3>
{canManage ? (
@@ -786,56 +801,6 @@ export function AssetRegistryPage() {
) : null}
</div>
<div className="space-y-3 rounded-lg border border-zinc-200 bg-white p-3">
<div>
<h4 className="text-sm font-semibold text-zinc-900">高级筛选</h4>
</div>
<div className="grid gap-3">
<select className="select-std" value={sensitivityFilter} onChange={(event) => setSensitivityFilter(event.target.value as AssetSensitivityLevel | "") }>
{sensitivityOptions().map((item) => (
<option key={item.value || "all"} value={item.value}>
{item.label}
</option>
))}
</select>
<select className="select-std" value={categoryFilter} onChange={(event) => setCategoryFilter(event.target.value)}>
<option value="">全部分类</option>
{(categoriesQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{formatTextWithCompactLongNumbers(item.name)}
</option>
))}
</select>
<select className="select-std" value={legalEntityFilter} onChange={(event) => {
setLegalEntityFilter(event.target.value);
setCostCenterFilter("");
}}>
<option value="">全部法体</option>
{(legalEntitiesQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{formatTextWithCompactLongNumbers(item.name)}
</option>
))}
</select>
<select className="select-std" value={costCenterFilter} onChange={(event) => setCostCenterFilter(event.target.value)}>
<option value="">全部成本中心</option>
{filteredFilterCostCenters.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
<select className="select-std" value={assigneeFilter} onChange={(event) => setAssigneeFilter(event.target.value)} disabled={!canViewAll}>
<option value="">全部归属人</option>
{(assigneesQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</div>
</div>
{categoriesQuery.isLoading ? <TableSkeleton columns={3} rows={5} /> : null}
{categoriesQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{resolveError(categoriesQuery.error)}</p>
@@ -903,15 +868,91 @@ export function AssetRegistryPage() {
<div className="grid gap-4">
<BusinessListToolbar
actions={canViewAll ? (
actions={(
<>
<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)}>
全范围
{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}
<Button
type="button"
size="sm"
variant={showAssetAdvancedFilters ? "default" : "secondary"}
data-testid="asset-filter-toggle"
onClick={() => setShowAdvancedFilters((value) => !value)}
>
筛选
</Button>
</>
)}
advanced={showAssetAdvancedFilters ? (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
<select className="select-std" value={sensitivityFilter} onChange={(event) => setSensitivityFilter(event.target.value as AssetSensitivityLevel | "") }>
{sensitivityOptions().map((item) => (
<option key={item.value || "all"} value={item.value}>
{item.label}
</option>
))}
</select>
<select className="select-std" value={categoryFilter} onChange={(event) => setCategoryFilter(event.target.value)}>
<option value="">全部分类</option>
{(categoriesQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{formatTextWithCompactLongNumbers(item.name)}
</option>
))}
</select>
<select className="select-std" value={legalEntityFilter} onChange={(event) => {
setLegalEntityFilter(event.target.value);
setCostCenterFilter("");
}}>
<option value="">全部法体</option>
{(legalEntitiesQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{formatTextWithCompactLongNumbers(item.name)}
</option>
))}
</select>
<select className="select-std" value={costCenterFilter} onChange={(event) => setCostCenterFilter(event.target.value)}>
<option value="">全部成本中心</option>
{filteredFilterCostCenters.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
<select className="select-std" value={assigneeFilter} onChange={(event) => setAssigneeFilter(event.target.value)} disabled={!canViewAll}>
<option value="">全部归属人</option>
{(assigneesQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
<div className="flex items-center md:col-span-2 xl:col-span-5">
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => {
setSensitivityFilter("");
setCategoryFilter("");
setLegalEntityFilter("");
setCostCenterFilter("");
setAssigneeFilter("");
setShowAdvancedFilters(false);
}}
>
重置筛选
</Button>
</div>
</div>
) : null}
>
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_180px_190px]">
+113 -116
查看文件
@@ -1,7 +1,6 @@
import { useMemo, useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Wrench } from "lucide-react";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchMyDelegations } from "../api/delegations";
@@ -154,7 +153,6 @@ export function ReimbursementsPage() {
const [proxyOwnerEmployeeId, setProxyOwnerEmployeeId] = useState("");
const [deleteTarget, setDeleteTarget] = useState<ReimbursementListItem | null>(null);
const [exportConfirmFormat, setExportConfirmFormat] = useState<ExportFormat | null>(null);
const [showLedgerTools, setShowLedgerTools] = useState(false);
const status = (searchParams.get("status") as ReimbursementStatus | null) ?? "draft";
const page = Number(searchParams.get("page") ?? "1") || 1;
@@ -170,9 +168,9 @@ export function ReimbursementsPage() {
hasPermission(permissions, "invoice.manage");
const isAdministration = (me?.roleCodes ?? []).includes("administration");
const showProxyOwnerSelector = canProxySubmit && isAdministration;
const canShowLedgerEntry = canViewAll || showProxyOwnerSelector || canUseFinanceTools;
const showAdvancedTools = canShowLedgerEntry && (showLedgerTools || scope === "all" || showProxyOwnerSelector);
const shouldLoadCreateOptions = showAdvancedTools && canCreateExpense;
const canShowListActions = canViewAll || showProxyOwnerSelector || canUseFinanceTools;
const showAdvancedTools = scope === "all" || showProxyOwnerSelector;
const shouldLoadCreateOptions = canCreateExpense;
const listQuery = useQuery({
queryKey: ["reimbursements", "list", status, page, scope],
@@ -284,100 +282,27 @@ export function ReimbursementsPage() {
title={scope === "all" ? "报销台账" : "我的报销"}
actions={
canCreateExpense ? (
<Button
data-testid="reimbursements-create"
disabled={createMutation.isPending}
onClick={() => startCreateReimbursement()}
type="button"
>
{createMutation.isPending ? "创建中..." : "新建报销"}
</Button>
) : null
}
/>
{createMutation.error && isApiError(createMutation.error) ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{createMutation.error.code}:
{mapApiErrorMessage(
createMutation.error.code,
createMutation.error.details,
createMutation.error.message,
)}
</p>
) : null}
{exportError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{exportError}
</p>
) : null}
<BusinessListToolbar
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
}
advanced={showAdvancedTools ? (
<>
<div className="grid gap-3 text-sm md:grid-cols-4">
<div>
<div className="text-xs text-zinc-500">当前列表</div>
<div className="mt-1 font-semibold text-zinc-900">{stats.currentPageCount} 条 / 共 {listQuery.data?.total ?? 0} 条</div>
</div>
<div>
<div className="text-xs text-zinc-500">当前金额</div>
<div className="mt-1 font-semibold text-zinc-900">{formatCurrency(stats.currentPageAmount)}</div>
</div>
<div>
<div className="text-xs text-zinc-500">审批中</div>
<div className={stats.reviewing > 0 ? "mt-1 font-semibold text-amber-700" : "mt-1 font-semibold text-zinc-900"}>{stats.reviewing} 条</div>
</div>
<div>
<div className="text-xs text-zinc-500">已支付</div>
<div className="mt-1 font-semibold text-zinc-900">{stats.paid} 条</div>
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
{showProxyOwnerSelector ? (
<select
className="select-sm"
data-testid="reimbursements-owner-select"
onChange={(event) => setProxyOwnerEmployeeId(event.target.value)}
value={proxyOwnerEmployeeId}
>
<option value="">自己</option>
{(proxyDelegationsQuery.data?.received ?? [])
.filter((item) => item.status === "active")
.map((item) => (
<option key={item.id} value={item.principalEmployeeId}>
{item.principal?.name ?? item.principalEmployeeId}
</option>
))}
</select>
) : null}
{canShowCreateOptionsMenu ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="secondary" type="button">
更多创建方式
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80">
<div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-zinc-400">
从模板创建
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
data-testid="reimbursements-create"
disabled={createMutation.isPending}
type="button"
>
{createMutation.isPending ? "创建中..." : "新建报销"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80">
<DropdownMenuItem onSelect={() => startCreateReimbursement()}>
空白报销
</DropdownMenuItem>
{canShowCreateOptionsMenu ? (
<>
<DropdownMenuSeparator />
<div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-zinc-400">
从模板创建
</div>
{templateOptions.length ? (
templateOptions.map((template) => (
<DropdownMenuItem
@@ -404,13 +329,39 @@ export function ReimbursementsPage() {
{travelRequest.requestNo ?? travelRequest.id} · {travelRequest.purpose}
</DropdownMenuItem>
))
) : (
<DropdownMenuItem disabled>当前无可关联申请</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
) : null}
) : (
<DropdownMenuItem disabled>当前无可关联申请</DropdownMenuItem>
)}
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</div>
) : null
}
/>
{createMutation.error && isApiError(createMutation.error) ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{createMutation.error.code}:
{mapApiErrorMessage(
createMutation.error.code,
createMutation.error.details,
createMutation.error.message,
)}
</p>
) : null}
{exportError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{exportError}
</p>
) : null}
<BusinessListToolbar
actions={
canShowListActions ? (
<>
{canViewAll ? (
<>
<Button
@@ -420,7 +371,7 @@ export function ReimbursementsPage() {
onClick={() => setSearchParams({ status: "reviewing", page: "1", scope: "all" })}
type="button"
>
审批中台账
审批中
</Button>
<Button
data-testid="reimbursements-scope-own"
@@ -441,22 +392,22 @@ export function ReimbursementsPage() {
全范围
</Button>
<Button asChild size="sm" variant="secondary" type="button">
<Link to="/reimbursements/dashboard">统计分析</Link>
<Link to="/reimbursements/dashboard">统计</Link>
</Button>
</>
) : null}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
data-testid="reimbursements-export"
size="sm"
variant="secondary"
type="button"
disabled={exportMutation.isPending}
>
{exportMutation.isPending ? "导出中..." : "导出"}
</Button>
<Button
data-testid="reimbursements-export"
size="sm"
variant="secondary"
type="button"
disabled={exportMutation.isPending}
>
{exportMutation.isPending ? "导出中..." : "导出"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
@@ -473,7 +424,53 @@ export function ReimbursementsPage() {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
) : null
}
advanced={showAdvancedTools ? (
<>
{scope === "all" ? (
<div className="grid gap-3 text-sm md:grid-cols-4">
<div>
<div className="text-xs text-zinc-500">当前列表</div>
<div className="mt-1 font-semibold text-zinc-900">{stats.currentPageCount} 条 / 共 {listQuery.data?.total ?? 0} 条</div>
</div>
<div>
<div className="text-xs text-zinc-500">当前金额</div>
<div className="mt-1 font-semibold text-zinc-900">{formatCurrency(stats.currentPageAmount)}</div>
</div>
<div>
<div className="text-xs text-zinc-500">审批中</div>
<div className={stats.reviewing > 0 ? "mt-1 font-semibold text-amber-700" : "mt-1 font-semibold text-zinc-900"}>{stats.reviewing} 条</div>
</div>
<div>
<div className="text-xs text-zinc-500">已支付</div>
<div className="mt-1 font-semibold text-zinc-900">{stats.paid} 条</div>
</div>
</div>
) : null}
{showProxyOwnerSelector ? (
<div className={scope === "all" ? "mt-3 flex flex-wrap items-center gap-2" : "flex flex-wrap items-center gap-2"}>
{showProxyOwnerSelector ? (
<select
className="select-sm"
data-testid="reimbursements-owner-select"
onChange={(event) => setProxyOwnerEmployeeId(event.target.value)}
value={proxyOwnerEmployeeId}
>
<option value="">自己</option>
{(proxyDelegationsQuery.data?.received ?? [])
.filter((item) => item.status === "active")
.map((item) => (
<option key={item.id} value={item.principalEmployeeId}>
{item.principal?.name ?? item.principalEmployeeId}
</option>
))}
</select>
) : null}
</div>
) : null}
</>
) : null}
>