完成五人团队正式内用最小收口(07-18-small-team-internal-readiness)
这个提交包含在:
@@ -241,10 +241,14 @@ function SectionError({ message }: { message: string }) {
|
||||
export function ApOperationsPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { permissions } = useAuth();
|
||||
const { me, permissions } = useAuth();
|
||||
|
||||
const canManageBatch = hasPermission(permissions, "payment.batch.manage");
|
||||
const canReviewRisk = hasPermission(permissions, "risk.review.manage");
|
||||
const canExportAp = canManageBatch || canReviewRisk;
|
||||
const canReviewPayment =
|
||||
hasPermission(permissions, "payment.batch.review") &&
|
||||
(me?.roleCodes.includes("manager") || me?.roleCodes.includes("ceo"));
|
||||
const canManageTravelPayments = hasPermission(permissions, "travel.payment.manage");
|
||||
|
||||
const [activeTab, setActiveTab] = useState<OperationsTab>("pending");
|
||||
@@ -264,6 +268,7 @@ export function ApOperationsPage() {
|
||||
const [reviewTarget, setReviewTarget] = useState<PaymentBatch | null>(null);
|
||||
const [reviewComment, setReviewComment] = useState("");
|
||||
const [cancelTarget, setCancelTarget] = useState<PaymentBatch | null>(null);
|
||||
const [cancelReason, setCancelReason] = useState("");
|
||||
const [retryTarget, setRetryTarget] = useState<FailedPaymentItemRow | null>(null);
|
||||
|
||||
const [failedDetailTarget, setFailedDetailTarget] = useState<FailedPaymentItemRow | null>(null);
|
||||
@@ -445,11 +450,7 @@ export function ApOperationsPage() {
|
||||
await refreshWorkbench();
|
||||
setReviewTarget(null);
|
||||
setReviewComment("");
|
||||
toast.success(
|
||||
item.reviewRequirement.nextStep === "ready_for_execute"
|
||||
? "付款批次复核完成"
|
||||
: "付款批次已完成首轮复核",
|
||||
);
|
||||
toast.success(item.reviewRequirement.nextStep === "ready_for_execute" ? "付款批次复核完成" : "付款批次复核已提交");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveErrorMessage(error, "快捷复核失败"));
|
||||
@@ -457,10 +458,11 @@ export function ApOperationsPage() {
|
||||
});
|
||||
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: (batchId: string) => cancelPaymentBatch(batchId),
|
||||
mutationFn: ({ batchId, reason }: { batchId: string; reason: string }) => cancelPaymentBatch(batchId, reason),
|
||||
onSuccess: async () => {
|
||||
await refreshWorkbench();
|
||||
setCancelTarget(null);
|
||||
setCancelReason("");
|
||||
toast.success("付款批次已取消");
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -628,27 +630,29 @@ export function ApOperationsPage() {
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
data-testid="ap-export"
|
||||
type="button"
|
||||
size="sm"
|
||||
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>
|
||||
{canExportAp ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
data-testid="ap-export"
|
||||
type="button"
|
||||
size="sm"
|
||||
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>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -939,11 +943,9 @@ export function ApOperationsPage() {
|
||||
{pendingBatches.length ? (
|
||||
pendingBatches.map((item) => {
|
||||
const canReviewSelectedBatch =
|
||||
canReviewRisk &&
|
||||
((item.status === "draft" &&
|
||||
item.reviewRequirement.nextStep === "first_review") ||
|
||||
(item.status === "reviewed" &&
|
||||
item.reviewRequirement.nextStep === "second_review"));
|
||||
canReviewPayment &&
|
||||
item.status === "draft" &&
|
||||
item.reviewRequirement.nextStep === "first_review";
|
||||
const canCancelSelectedBatch =
|
||||
canManageBatch && !["executed", "cancelled"].includes(item.status);
|
||||
|
||||
@@ -984,7 +986,7 @@ export function ApOperationsPage() {
|
||||
>
|
||||
查看批次
|
||||
</Button>
|
||||
{canReviewRisk ? (
|
||||
{canReviewPayment ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
@@ -1003,7 +1005,10 @@ export function ApOperationsPage() {
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={!canCancelSelectedBatch}
|
||||
onClick={() => setCancelTarget(item)}
|
||||
onClick={() => {
|
||||
setCancelTarget(item);
|
||||
setCancelReason("");
|
||||
}}
|
||||
>
|
||||
快捷取消
|
||||
</Button>
|
||||
@@ -1326,7 +1331,7 @@ export function ApOperationsPage() {
|
||||
</DialogHeader>
|
||||
{reviewTarget?.highRisk ? (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
|
||||
此批次为高风险,需二次复核。
|
||||
此批次金额较高,请重点核对付款对象、金额和回单;本次独立复核后不再增加第二审批层级。
|
||||
</div>
|
||||
) : null}
|
||||
<textarea
|
||||
@@ -1568,28 +1573,50 @@ export function ApOperationsPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
<Dialog
|
||||
open={Boolean(cancelTarget)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setCancelTarget(null);
|
||||
setCancelReason("");
|
||||
}
|
||||
}}
|
||||
title="确认取消付款批次"
|
||||
description={
|
||||
cancelTarget
|
||||
? `将取消 ${cancelTarget.batchNo},该操作与付款批次页保持一致。`
|
||||
: undefined
|
||||
}
|
||||
variant="destructive"
|
||||
confirmLabel="确认取消"
|
||||
onConfirm={async () => {
|
||||
if (!cancelTarget) {
|
||||
return;
|
||||
}
|
||||
await cancelMutation.mutateAsync(cancelTarget.id);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认取消付款批次</DialogTitle>
|
||||
<DialogDescription>
|
||||
{cancelTarget
|
||||
? `将取消 ${cancelTarget.batchNo} 的未执行付款项;已执行项不会回滚。`
|
||||
: "请填写取消原因。"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<textarea
|
||||
className="min-h-28 w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm"
|
||||
aria-label="批次取消原因"
|
||||
placeholder="请输入 2–500 字取消原因"
|
||||
maxLength={500}
|
||||
value={cancelReason}
|
||||
onChange={(event) => setCancelReason(event.target.value)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => {
|
||||
setCancelTarget(null);
|
||||
setCancelReason("");
|
||||
}}>
|
||||
返回
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={!cancelTarget || cancelReason.trim().length < 2 || cancelMutation.isPending}
|
||||
onClick={() => cancelTarget && cancelMutation.mutate({ batchId: cancelTarget.id, reason: cancelReason.trim() })}
|
||||
>
|
||||
{cancelMutation.isPending ? "取消中..." : "确认取消"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(retryTarget)}
|
||||
|
||||
@@ -26,7 +26,6 @@ const ROLE_OPTIONS: Array<{ code: ApprovalRoleCode; label: string }> = [
|
||||
{ code: "ceo", label: "ceo" },
|
||||
{ code: "finance", label: "finance" },
|
||||
{ code: "finance_mgr", label: "finance_mgr" },
|
||||
{ code: "admin", label: "admin" },
|
||||
];
|
||||
const OWNER_SELF_FALLBACK_ROLE_OPTIONS = ROLE_OPTIONS.filter(
|
||||
(role) => role.code === "finance_mgr",
|
||||
|
||||
@@ -27,6 +27,7 @@ import { isApiError } from "../api/types";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasAnyPermission, hasPermission } from "../auth/permissions";
|
||||
import { DateTextInput } from "../components/common/DateTextInput";
|
||||
import { useUnsavedChangesGuard } from "../components/common/UnsavedChangesGuard";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { TableSkeleton } from "../components/common/TableSkeleton";
|
||||
import {
|
||||
@@ -233,6 +234,7 @@ export function AssetRegistryPage() {
|
||||
const [selectedAssetId, setSelectedAssetId] = useState(searchParams.get("id") ?? "");
|
||||
const [isCreatingAsset, setIsCreatingAsset] = useState(false);
|
||||
const [assetForm, setAssetForm] = useState(initialAssetForm);
|
||||
const [savedAssetFormSignature, setSavedAssetFormSignature] = useState(() => JSON.stringify(initialAssetForm()));
|
||||
const [assetFormError, setAssetFormError] = useState("");
|
||||
const [movementEmployeeId, setMovementEmployeeId] = useState("");
|
||||
const [movementLocation, setMovementLocation] = useState("");
|
||||
@@ -331,7 +333,7 @@ export function AssetRegistryPage() {
|
||||
return;
|
||||
}
|
||||
const asset = assetDetailQuery.data.asset;
|
||||
setAssetForm({
|
||||
const nextAssetForm = {
|
||||
name: asset.name,
|
||||
assetType: asset.assetType,
|
||||
sensitivityLevel: asset.sensitivityLevel,
|
||||
@@ -355,7 +357,9 @@ export function AssetRegistryPage() {
|
||||
expiresAt: asset.expiresAt ?? "",
|
||||
activatedAt: asset.activatedAt ?? "",
|
||||
retiredAt: asset.retiredAt ?? "",
|
||||
});
|
||||
};
|
||||
setAssetForm(nextAssetForm);
|
||||
setSavedAssetFormSignature(JSON.stringify(nextAssetForm));
|
||||
setMovementEmployeeId("");
|
||||
setMovementLocation(asset.location ?? "");
|
||||
setMovementNote("");
|
||||
@@ -379,6 +383,10 @@ export function AssetRegistryPage() {
|
||||
|
||||
const currentAsset = assetDetailQuery.data?.asset ?? null;
|
||||
const canEditCurrent = canManage && (isCreatingAsset || currentAsset?.permissions.canEdit);
|
||||
const runGuarded = useUnsavedChangesGuard(
|
||||
Boolean(isCreatingAsset || selectedAssetId) && Boolean(canEditCurrent) && JSON.stringify(assetForm) !== savedAssetFormSignature,
|
||||
"资产表单",
|
||||
);
|
||||
const hasAdvancedAssetFilters = Boolean(
|
||||
sensitivityFilter ||
|
||||
categoryFilter ||
|
||||
@@ -404,10 +412,14 @@ export function AssetRegistryPage() {
|
||||
}
|
||||
|
||||
function resetAssetEditor() {
|
||||
setIsCreatingAsset(true);
|
||||
setSelectedAssetId("");
|
||||
setAssetForm(initialAssetForm());
|
||||
setAssetFormError("");
|
||||
runGuarded(() => {
|
||||
const nextAssetForm = initialAssetForm();
|
||||
setIsCreatingAsset(true);
|
||||
setSelectedAssetId("");
|
||||
setAssetForm(nextAssetForm);
|
||||
setSavedAssetFormSignature(JSON.stringify(nextAssetForm));
|
||||
setAssetFormError("");
|
||||
});
|
||||
}
|
||||
|
||||
function parseAssetMetadata() {
|
||||
@@ -555,6 +567,7 @@ export function AssetRegistryPage() {
|
||||
queryClient.invalidateQueries({ queryKey: ["assets", "detail"] }),
|
||||
]);
|
||||
setAssetFormError("");
|
||||
setSavedAssetFormSignature(JSON.stringify(assetForm));
|
||||
setSelectedAssetId(data.asset.id);
|
||||
setIsCreatingAsset(false);
|
||||
toast.success(isCreatingAsset ? "资产卡片已创建" : "资产卡片已更新");
|
||||
@@ -711,15 +724,19 @@ export function AssetRegistryPage() {
|
||||
});
|
||||
}, [assetItems]);
|
||||
const selectAsset = (assetId: string) => {
|
||||
setIsCreatingAsset(false);
|
||||
setSelectedAssetId(assetId);
|
||||
runGuarded(() => {
|
||||
setIsCreatingAsset(false);
|
||||
setSelectedAssetId(assetId);
|
||||
});
|
||||
};
|
||||
const returnToAssetList = () => {
|
||||
setIsCreatingAsset(false);
|
||||
setSelectedAssetId("");
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete("id");
|
||||
setSearchParams(next, { replace: true });
|
||||
runGuarded(() => {
|
||||
setIsCreatingAsset(false);
|
||||
setSelectedAssetId("");
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete("id");
|
||||
setSearchParams(next, { replace: true });
|
||||
});
|
||||
};
|
||||
const isAssetDetailMode = isCreatingAsset || Boolean(selectedAssetId);
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import { isApiError } from "../api/types";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasAnyPermission } from "../auth/permissions";
|
||||
import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog";
|
||||
import { useUnsavedChangesGuard } from "../components/common/UnsavedChangesGuard";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { DateTextInput } from "../components/common/DateTextInput";
|
||||
@@ -203,6 +204,7 @@ export function ContractsRegistryPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialProjectId = searchParams.get("projectId") ?? "";
|
||||
const canBrowseEmployees = hasAnyPermission(permissions, ["system.users", "report.dashboard.view"]);
|
||||
const canManageContracts = hasAnyPermission(permissions, ["project.manage", "project.view_all", "system.settings"]);
|
||||
|
||||
const [filters, setFilters] = useState<FiltersState>({
|
||||
projectId: initialProjectId,
|
||||
@@ -214,6 +216,7 @@ export function ContractsRegistryPage() {
|
||||
});
|
||||
const [editorId, setEditorId] = useState<string | null>(null);
|
||||
const [editor, setEditor] = useState<EditorState>(() => buildEmptyEditor(initialProjectId));
|
||||
const [savedEditorSignature, setSavedEditorSignature] = useState(() => JSON.stringify(buildEmptyEditor(initialProjectId)));
|
||||
const [showExpiring, setShowExpiring] = useState(false);
|
||||
const [showTrialData, setShowTrialData] = useState(false);
|
||||
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false);
|
||||
@@ -262,6 +265,10 @@ export function ContractsRegistryPage() {
|
||||
: "默认当前用户";
|
||||
const amountInvalid = Boolean(editor.amount.trim()) && !AMOUNT_PATTERN.test(editor.amount.trim());
|
||||
const expiringContractCount = expiringQuery.data?.total ?? expiringItems.length;
|
||||
const runGuarded = useUnsavedChangesGuard(
|
||||
showEditor && JSON.stringify(editor) !== savedEditorSignature,
|
||||
"合同表单",
|
||||
);
|
||||
|
||||
const refreshAll = async () => {
|
||||
await Promise.all([
|
||||
@@ -271,14 +278,20 @@ export function ContractsRegistryPage() {
|
||||
};
|
||||
|
||||
const resetEditor = () => {
|
||||
const nextEditor = buildEmptyEditor(filters.projectId);
|
||||
setEditorId(null);
|
||||
setEditor(buildEmptyEditor(filters.projectId));
|
||||
setEditor(nextEditor);
|
||||
setSavedEditorSignature(JSON.stringify(nextEditor));
|
||||
};
|
||||
|
||||
const openEditor = (item: ContractRegistryItem) => {
|
||||
setEditorId(item.id);
|
||||
setEditor(loadEditorFromItem(item));
|
||||
setShowEditor(true);
|
||||
runGuarded(() => {
|
||||
const nextEditor = loadEditorFromItem(item);
|
||||
setEditorId(item.id);
|
||||
setEditor(nextEditor);
|
||||
setSavedEditorSignature(JSON.stringify(nextEditor));
|
||||
setShowEditor(true);
|
||||
});
|
||||
};
|
||||
|
||||
const saveMutation = useMutation({
|
||||
@@ -335,15 +348,19 @@ export function ContractsRegistryPage() {
|
||||
title="合同"
|
||||
actions={
|
||||
<HeaderActionGroup>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
resetEditor();
|
||||
setShowEditor(true);
|
||||
}}
|
||||
>
|
||||
新增合同
|
||||
</Button>
|
||||
{canManageContracts ? (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
runGuarded(() => {
|
||||
resetEditor();
|
||||
setShowEditor(true);
|
||||
});
|
||||
}}
|
||||
>
|
||||
新增合同
|
||||
</Button>
|
||||
) : null}
|
||||
</HeaderActionGroup>
|
||||
}
|
||||
/>
|
||||
@@ -359,9 +376,11 @@ export function ContractsRegistryPage() {
|
||||
<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>
|
||||
{canManageContracts ? (
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => openEditor(item)}>
|
||||
编辑
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{!expiringQuery.isLoading && !expiringItems.length ? (
|
||||
@@ -382,7 +401,7 @@ export function ContractsRegistryPage() {
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{editorId ? (
|
||||
<Button type="button" variant="secondary" size="sm" onClick={resetEditor}>
|
||||
<Button type="button" variant="secondary" size="sm" onClick={() => runGuarded(resetEditor)}>
|
||||
切换到新建
|
||||
</Button>
|
||||
) : null}
|
||||
@@ -391,8 +410,10 @@ export function ContractsRegistryPage() {
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowEditor(false);
|
||||
resetEditor();
|
||||
runGuarded(() => {
|
||||
setShowEditor(false);
|
||||
resetEditor();
|
||||
});
|
||||
}}
|
||||
>
|
||||
关闭
|
||||
@@ -705,17 +726,21 @@ export function ContractsRegistryPage() {
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setFilters({
|
||||
projectId: initialProjectId,
|
||||
status: "",
|
||||
contractType: "",
|
||||
counterpartyName: "",
|
||||
expiryDateFrom: "",
|
||||
expiryDateTo: "",
|
||||
runGuarded(() => {
|
||||
const nextEditor = buildEmptyEditor(initialProjectId);
|
||||
setFilters({
|
||||
projectId: initialProjectId,
|
||||
status: "",
|
||||
contractType: "",
|
||||
counterpartyName: "",
|
||||
expiryDateFrom: "",
|
||||
expiryDateTo: "",
|
||||
});
|
||||
setShowEditor(false);
|
||||
setEditorId(null);
|
||||
setEditor(nextEditor);
|
||||
setSavedEditorSignature(JSON.stringify(nextEditor));
|
||||
});
|
||||
setShowEditor(false);
|
||||
setEditorId(null);
|
||||
setEditor(buildEmptyEditor(initialProjectId));
|
||||
}}
|
||||
>
|
||||
重置筛选
|
||||
@@ -810,9 +835,11 @@ export function ContractsRegistryPage() {
|
||||
{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>
|
||||
{canManageContracts ? (
|
||||
<Button className="mt-3" type="button" size="sm" variant="secondary" onClick={() => openEditor(item)}>
|
||||
编辑
|
||||
</Button>
|
||||
) : null}
|
||||
</article>
|
||||
))}
|
||||
{!contractsQuery.isLoading && !visibleContracts.length ? (
|
||||
@@ -860,9 +887,12 @@ export function ContractsRegistryPage() {
|
||||
</td>
|
||||
<td className="table-td">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => openEditor(item)}>
|
||||
编辑
|
||||
</Button>
|
||||
{canManageContracts ? (
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => openEditor(item)}>
|
||||
编辑
|
||||
</Button>
|
||||
) : null}
|
||||
{canManageContracts || item.project ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" size="sm" variant="secondary">
|
||||
@@ -870,25 +900,30 @@ export function ContractsRegistryPage() {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
disabled={renewMutation.isPending || !["active", "expired"].includes(item.status)}
|
||||
onSelect={() => renewMutation.mutate(item)}
|
||||
>
|
||||
续约
|
||||
</DropdownMenuItem>
|
||||
{canManageContracts ? (
|
||||
<DropdownMenuItem
|
||||
disabled={renewMutation.isPending || !["active", "expired"].includes(item.status)}
|
||||
onSelect={() => renewMutation.mutate(item)}
|
||||
>
|
||||
续约
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{item.project ? (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={`/projects/${item.project.id}`}>查看项目</Link>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
disabled={toggleStatusMutation.isPending}
|
||||
onSelect={() => setConfirmStatusTarget(item)}
|
||||
>
|
||||
{item.persistedStatus === "archived" ? "恢复生效" : "归档"}
|
||||
</DropdownMenuItem>
|
||||
{canManageContracts ? (
|
||||
<DropdownMenuItem
|
||||
disabled={toggleStatusMutation.isPending}
|
||||
onSelect={() => setConfirmStatusTarget(item)}
|
||||
>
|
||||
{item.persistedStatus === "archived" ? "恢复生效" : "归档"}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -37,6 +37,7 @@ import { useAuth } from "../auth/AuthContext";
|
||||
import { hasPermission } from "../auth/permissions";
|
||||
import { EmployeeAvatar } from "../components/common/EmployeeAvatar";
|
||||
import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog";
|
||||
import { AttachmentDeleteConfirmDialog } from "../components/common/AttachmentDeleteConfirmDialog";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { EmployeePerformancePanel } from "../components/people/EmployeePerformancePanel";
|
||||
import { EmployeeSalaryPanel } from "../components/people/EmployeeSalaryPanel";
|
||||
@@ -184,6 +185,8 @@ export function EmployeeProfilePage() {
|
||||
});
|
||||
const canManageEmployeeProfile = hasPermission(permissions, "system.users");
|
||||
const canManageSensitiveHr = hasPermission(permissions, "system.settings");
|
||||
const canManageEmployeeContracts = hasPermission(permissions, "employee.contract.manage");
|
||||
const canManagePayroll = hasPermission(permissions, "payroll.manage");
|
||||
const canEditSelfProfile = me?.id === employeeId;
|
||||
const canEditBaseFields = canManageEmployeeProfile || canEditSelfProfile;
|
||||
const canEditExtendedFields = canManageEmployeeProfile;
|
||||
@@ -215,7 +218,7 @@ export function EmployeeProfilePage() {
|
||||
});
|
||||
|
||||
const contractsQuery = useQuery({
|
||||
enabled: Boolean(employeeId) && canManageEmployeeProfile,
|
||||
enabled: Boolean(employeeId) && canManageEmployeeContracts,
|
||||
queryKey: ["employee-contracts", employeeId],
|
||||
queryFn: () => fetchEmployeeContracts(employeeId),
|
||||
});
|
||||
@@ -267,7 +270,7 @@ export function EmployeeProfilePage() {
|
||||
}, [employeeId, requestedTab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === "salary" && !canManageSensitiveHr && !canEditSelfProfile) {
|
||||
if (activeTab === "salary" && !canManagePayroll && !canEditSelfProfile) {
|
||||
updateActiveTab("profile");
|
||||
return;
|
||||
}
|
||||
@@ -275,11 +278,11 @@ export function EmployeeProfilePage() {
|
||||
updateActiveTab("profile");
|
||||
return;
|
||||
}
|
||||
if (canManageEmployeeProfile || canEditSelfProfile || activeTab === "profile") {
|
||||
if (canManageEmployeeProfile || canManageEmployeeContracts || canEditSelfProfile || activeTab === "profile") {
|
||||
return;
|
||||
}
|
||||
updateActiveTab("profile");
|
||||
}, [activeTab, canEditSelfProfile, canManageEmployeeProfile, canManageSensitiveHr]);
|
||||
}, [activeTab, canEditSelfProfile, canManageEmployeeContracts, canManageEmployeeProfile, canManagePayroll, canManageSensitiveHr]);
|
||||
|
||||
useEffect(() => {
|
||||
setAvatarFile(null);
|
||||
@@ -464,10 +467,11 @@ export function EmployeeProfilePage() {
|
||||
});
|
||||
|
||||
const deleteContractMutation = useMutation({
|
||||
mutationFn: (contractId: string) => deleteEmployeeContract(employeeId, contractId),
|
||||
onSuccess: async (_result, contractId) => {
|
||||
mutationFn: ({ contractId, reason }: { contractId: string; reason: string }) => deleteEmployeeContract(employeeId, contractId, reason),
|
||||
onSuccess: async (_result, variables) => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["employee-contracts", employeeId] });
|
||||
if (editingContractId === contractId) {
|
||||
setDeleteContractTarget(null);
|
||||
if (editingContractId === variables.contractId) {
|
||||
setEditingContractId(null);
|
||||
setContractForm(buildEmptyContractForm());
|
||||
setContractAttachmentFile(null);
|
||||
@@ -538,7 +542,7 @@ export function EmployeeProfilePage() {
|
||||
const performanceOnlyMode = activeTab === "performance" && profilePermissionDenied && Boolean(performanceQuery.data);
|
||||
const showPerformanceTab =
|
||||
canManageSensitiveHr || canEditSelfProfile || performanceOnlyMode || performanceQuery.data?.viewerRole === "manager";
|
||||
const showSalaryTab = canManageSensitiveHr || canEditSelfProfile;
|
||||
const showSalaryTab = canManagePayroll || canEditSelfProfile;
|
||||
|
||||
const performanceContent =
|
||||
performanceQuery.isLoading ? (
|
||||
@@ -555,7 +559,7 @@ export function EmployeeProfilePage() {
|
||||
]}
|
||||
/>
|
||||
);
|
||||
const salaryContent = <EmployeeSalaryPanel employeeId={employeeId} canManage={canManageSensitiveHr} isSelf={canEditSelfProfile} />;
|
||||
const salaryContent = <EmployeeSalaryPanel employeeId={employeeId} canManage={canManagePayroll} isSelf={canEditSelfProfile} />;
|
||||
|
||||
if (profileQuery.isLoading || (activeTab === "performance" && profilePermissionDenied && performanceQuery.isLoading)) {
|
||||
return <div className="card-std p-6 text-sm text-zinc-500">加载员工档案中...</div>;
|
||||
@@ -737,7 +741,7 @@ export function EmployeeProfilePage() {
|
||||
<Tabs value={activeTab} onValueChange={(value) => updateActiveTab(value as ProfileTab)}>
|
||||
<TabsList className="flex w-full flex-wrap justify-start gap-1">
|
||||
<TabsTrigger value="profile">档案</TabsTrigger>
|
||||
{canManageEmployeeProfile ? <TabsTrigger value="contracts">合同</TabsTrigger> : null}
|
||||
{canManageEmployeeContracts ? <TabsTrigger value="contracts">合同</TabsTrigger> : null}
|
||||
{canManageEmployeeProfile ? <TabsTrigger value="trainingCertificates">培训与证书</TabsTrigger> : null}
|
||||
{showPerformanceTab ? <TabsTrigger value="performance">绩效</TabsTrigger> : null}
|
||||
{showSalaryTab ? <TabsTrigger value="salary">薪资</TabsTrigger> : null}
|
||||
@@ -1064,7 +1068,7 @@ export function EmployeeProfilePage() {
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{canManageEmployeeProfile ? (
|
||||
{canManageEmployeeContracts ? (
|
||||
<TabsContent value="contracts" className="space-y-6">
|
||||
{contractsQuery.isError ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
@@ -1115,7 +1119,7 @@ export function EmployeeProfilePage() {
|
||||
<TableCell className="max-w-[220px]">
|
||||
{contract.attachmentUrl ? (
|
||||
<div className="space-y-2">
|
||||
<div className="truncate text-xs text-zinc-500">{contract.attachmentPath ?? "-"}</div>
|
||||
<div className="text-xs text-zinc-500">{contract.hasAttachment ? "已上传附件" : "未上传附件"}</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
@@ -1203,7 +1207,7 @@ export function EmployeeProfilePage() {
|
||||
<div className="rounded-2xl border border-zinc-200 bg-zinc-50 p-4">
|
||||
<div className="text-sm font-medium text-zinc-900">附件管理</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
当前附件:{contracts.find((item) => item.id === editingContractId)?.attachmentPath ?? "未上传"}
|
||||
当前附件:{contracts.find((item) => item.id === editingContractId)?.hasAttachment ? "已上传" : "未上传"}
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3">
|
||||
<Input
|
||||
@@ -1520,17 +1524,18 @@ export function EmployeeProfilePage() {
|
||||
deleteAvatarMutation.mutate();
|
||||
}}
|
||||
/>
|
||||
<ActionConfirmDialog
|
||||
<AttachmentDeleteConfirmDialog
|
||||
open={Boolean(deleteContractTarget)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleteContractTarget(null);
|
||||
}}
|
||||
kind="delete"
|
||||
title="确认删除员工合同?"
|
||||
targetName={deleteContractTarget ? `合同 ${deleteContractTarget.contractNo}` : undefined}
|
||||
description="删除后该合同记录和附件引用将从员工档案中移除,生效中的合同不能删除。"
|
||||
onConfirm={async () => {
|
||||
description={deleteContractTarget ? `对象:合同 ${deleteContractTarget.contractNo}。删除后合同记录与附件引用将从员工档案移除,且无法在 OA 内恢复。` : undefined}
|
||||
pending={deleteContractMutation.isPending}
|
||||
onConfirm={async (reason) => {
|
||||
if (deleteContractTarget) {
|
||||
deleteContractMutation.mutate(deleteContractTarget.id);
|
||||
deleteContractMutation.mutate({ contractId: deleteContractTarget.id, reason });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -482,12 +482,14 @@ export function InvoicePoolPage() {
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setInvoiceActionPanel("upload")}
|
||||
>
|
||||
上传票据
|
||||
</Button>
|
||||
{canUploadInvoice ? (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setInvoiceActionPanel("upload")}
|
||||
>
|
||||
上传票据
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</HeaderActionGroup>
|
||||
@@ -547,14 +549,16 @@ export function InvoicePoolPage() {
|
||||
listQuery={listQuery}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={invoiceActionPanel === "bind" ? "default" : "secondary"}
|
||||
onClick={() => setInvoiceActionPanel((current) => (current === "bind" ? null : "bind"))}
|
||||
>
|
||||
绑定
|
||||
</Button>
|
||||
{canUploadInvoice || canManageTravelPayments ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={invoiceActionPanel === "bind" ? "default" : "secondary"}
|
||||
onClick={() => setInvoiceActionPanel((current) => (current === "bind" ? null : "bind"))}
|
||||
>
|
||||
绑定
|
||||
</Button>
|
||||
) : null}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
|
||||
@@ -360,7 +360,7 @@ export function MobilePortalPage() {
|
||||
</Button>
|
||||
) : null}
|
||||
<Button asChild className="w-full" size="sm" type="button" variant="secondary">
|
||||
<Link to="/attendance">查看考勤汇总</Link>
|
||||
<Link to="/mobile/attendance">查看考勤汇总</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -43,6 +43,7 @@ function roleLabel(roleCode: string) {
|
||||
employee: "员工",
|
||||
finance: "财务",
|
||||
finance_mgr: "财务负责人",
|
||||
system_admin: "OA 系统管理员",
|
||||
hr: "人事",
|
||||
manager: "审批人",
|
||||
};
|
||||
|
||||
@@ -32,6 +32,7 @@ import type { PrecheckResponse } from "../api/feeControl";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasPermission } from "../auth/permissions";
|
||||
import { AttachmentGovernanceBadges } from "../components/common/AttachmentGovernanceBadges";
|
||||
import { AttachmentDeleteConfirmDialog } from "../components/common/AttachmentDeleteConfirmDialog";
|
||||
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
|
||||
import { ConfirmDialog } from "../components/common/ConfirmDialog";
|
||||
import { MobileIcon } from "../components/mobile/MobileIcon";
|
||||
@@ -211,6 +212,7 @@ export function MobileReimbursementDetailPage() {
|
||||
const [confirmDeleteItemId, setConfirmDeleteItemId] = useState("");
|
||||
const [confirmDeleteInvoiceId, setConfirmDeleteInvoiceId] = useState("");
|
||||
const [confirmDeleteReimbursement, setConfirmDeleteReimbursement] = useState(false);
|
||||
const [deleteAttachmentTarget, setDeleteAttachmentTarget] = useState<{ id: string; name: string } | null>(null);
|
||||
const [submitConfirmPrecheck, setSubmitConfirmPrecheck] = useState<PrecheckResponse | null>(null);
|
||||
const [isInvoiceDialogOpen, setIsInvoiceDialogOpen] = useState(false);
|
||||
const [invoicePanel, setInvoicePanel] = useState<InvoicePanel>("upload");
|
||||
@@ -692,9 +694,11 @@ export function MobileReimbursementDetailPage() {
|
||||
});
|
||||
|
||||
const deleteAttachmentMutation = useMutation({
|
||||
mutationFn: (attachmentId: string) => deleteReimbursementAttachment(reimbursementId, attachmentId),
|
||||
mutationFn: ({ attachmentId, reason }: { attachmentId: string; reason: string }) =>
|
||||
deleteReimbursementAttachment(reimbursementId, attachmentId, reason),
|
||||
onSuccess: async () => {
|
||||
await invalidateDetail();
|
||||
setDeleteAttachmentTarget(null);
|
||||
toast.success("附件材料已删除");
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -1157,7 +1161,7 @@ export function MobileReimbursementDetailPage() {
|
||||
variant="secondary"
|
||||
type="button"
|
||||
disabled={deleteAttachmentMutation.isPending}
|
||||
onClick={() => deleteAttachmentMutation.mutate(attachment.id)}
|
||||
onClick={() => setDeleteAttachmentTarget({ id: attachment.id, name: attachment.originalName })}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
@@ -1561,6 +1565,20 @@ export function MobileReimbursementDetailPage() {
|
||||
confirmLabel="删除草稿"
|
||||
onConfirm={() => deleteReimbursementMutation.mutate()}
|
||||
/>
|
||||
|
||||
<AttachmentDeleteConfirmDialog
|
||||
open={Boolean(deleteAttachmentTarget)}
|
||||
targetName={deleteAttachmentTarget?.name}
|
||||
pending={deleteAttachmentMutation.isPending}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleteAttachmentTarget(null);
|
||||
}}
|
||||
onConfirm={(reason) => {
|
||||
if (deleteAttachmentTarget) {
|
||||
deleteAttachmentMutation.mutate({ attachmentId: deleteAttachmentTarget.id, reason });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</PageScaffold>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,17 +4,17 @@ import { useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
cancelPaymentBatch,
|
||||
createPaymentBatch,
|
||||
deletePaymentBatchReceiptAttachment,
|
||||
downloadPaymentBatchReceiptAttachment,
|
||||
executePaymentBatch,
|
||||
fetchPaymentBatchCandidates,
|
||||
fetchPaymentBatchReceiptAttachments,
|
||||
fetchPaymentBatches,
|
||||
reviewPaymentBatch,
|
||||
updatePaymentBatchItem,
|
||||
uploadPaymentBatchReceiptAttachments,
|
||||
voidPaymentBatchReceiptAttachment,
|
||||
type PaymentBatch,
|
||||
type PaymentBatchReceiptAttachment,
|
||||
type PaymentBatchEntityType,
|
||||
type PaymentChannel,
|
||||
} from "../api/paymentBatches";
|
||||
import { isApiError } from "../api/types";
|
||||
@@ -33,6 +33,15 @@ import { useAttachmentDownloadReasonDialog } from "../components/common/useAttac
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Label } from "../components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../components/ui/dialog";
|
||||
import { Textarea } from "../components/ui/textarea";
|
||||
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { DateTextInput } from "../components/common/DateTextInput";
|
||||
@@ -78,8 +87,7 @@ function paymentItemStatusLabel(value: string) {
|
||||
}
|
||||
|
||||
function reviewNextStepLabel(value: PaymentBatch["reviewRequirement"]["nextStep"]) {
|
||||
if (value === "first_review") return "等待首轮复核";
|
||||
if (value === "second_review") return "等待二次复核";
|
||||
if (value === "first_review") return "等待独立复核";
|
||||
return "可执行付款标记";
|
||||
}
|
||||
|
||||
@@ -105,13 +113,15 @@ function resolveError(error: unknown) {
|
||||
|
||||
export function PaymentBatchesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { permissions } = useAuth();
|
||||
const { me, permissions } = useAuth();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const canManageBatch = hasPermission(permissions, "payment.batch.manage");
|
||||
const canReviewBatch = hasPermission(permissions, "risk.review.manage");
|
||||
const [entityType, setEntityType] = useState<PaymentBatchEntityType>("reimbursement");
|
||||
const canReviewBatch =
|
||||
hasPermission(permissions, "payment.batch.review") &&
|
||||
(me?.roleCodes.includes("manager") || me?.roleCodes.includes("ceo"));
|
||||
const [name, setName] = useState("");
|
||||
const [entityIds, setEntityIds] = useState("");
|
||||
const [candidateSearch, setCandidateSearch] = useState("");
|
||||
const [selectedCandidateIds, setSelectedCandidateIds] = useState<string[]>([]);
|
||||
const [defaultPaymentDate, setDefaultPaymentDate] = useState("");
|
||||
const [defaultPaymentChannel, setDefaultPaymentChannel] = useState<PaymentChannel | "">("");
|
||||
const [voucherPrefix, setVoucherPrefix] = useState("");
|
||||
@@ -120,15 +130,28 @@ export function PaymentBatchesPage() {
|
||||
const [error, setError] = useState("");
|
||||
const [showCreatePanel, setShowCreatePanel] = useState(false);
|
||||
const [confirmPaymentAction, setConfirmPaymentAction] = useState<{
|
||||
kind: "financeReview" | "financePayment" | "cancel";
|
||||
kind: "financeReview" | "financePayment";
|
||||
batchId: string;
|
||||
batchNo: string;
|
||||
highRisk: boolean;
|
||||
totalAmount: string;
|
||||
} | null>(null);
|
||||
const [reasonAction, setReasonAction] = useState<
|
||||
| { kind: "cancel"; batchId: string; batchNo: string }
|
||||
| { kind: "voidReceipt"; batchId: string; batchNo: string; attachmentId: string; attachmentName: string }
|
||||
| null
|
||||
>(null);
|
||||
const [actionReason, setActionReason] = useState("");
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: ["payment-batches"],
|
||||
queryFn: () => fetchPaymentBatches(),
|
||||
});
|
||||
const candidatesQuery = useQuery({
|
||||
queryKey: ["payment-batch-candidates", candidateSearch],
|
||||
enabled: showCreatePanel && canManageBatch,
|
||||
queryFn: () => fetchPaymentBatchCandidates({ q: candidateSearch, page: 1, pageSize: 50 }),
|
||||
});
|
||||
|
||||
const selectedBatchId = searchParams.get("id") ?? "";
|
||||
const batches = useMemo(() => listQuery.data?.items ?? [], [listQuery.data?.items]);
|
||||
@@ -148,13 +171,12 @@ export function PaymentBatchesPage() {
|
||||
const canEditReceiptAttachments =
|
||||
canManageBatch &&
|
||||
!!selectedBatch &&
|
||||
!["executed", "cancelled"].includes(selectedBatch.status);
|
||||
selectedBatch.status !== "cancelled";
|
||||
const canReviewSelectedBatch =
|
||||
canReviewBatch &&
|
||||
!!selectedBatch &&
|
||||
(
|
||||
(selectedBatch.status === "draft" && selectedBatch.reviewRequirement.nextStep === "first_review") ||
|
||||
(selectedBatch.status === "reviewed" && selectedBatch.reviewRequirement.nextStep === "second_review")
|
||||
selectedBatch.status === "draft" && selectedBatch.reviewRequirement.nextStep === "first_review"
|
||||
);
|
||||
const canExecuteSelectedBatch =
|
||||
canManageBatch &&
|
||||
@@ -168,6 +190,7 @@ export function PaymentBatchesPage() {
|
||||
|
||||
const refresh = async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["payment-batches"] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["payment-batch-candidates"] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["payment-batch-receipts"] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["ap-operations"] });
|
||||
};
|
||||
@@ -181,15 +204,17 @@ export function PaymentBatchesPage() {
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async () =>
|
||||
createPaymentBatch({
|
||||
entityType,
|
||||
name: name.trim() || `${entityType}-batch`,
|
||||
entityIds: entityIds.split(/[\s,]+/).map((item) => item.trim()).filter(Boolean),
|
||||
entityType: "reimbursement",
|
||||
name: name.trim() || "报销付款批次",
|
||||
entityIds: selectedCandidateIds,
|
||||
defaultPaymentDate: defaultPaymentDate || undefined,
|
||||
defaultPaymentChannel: defaultPaymentChannel || undefined,
|
||||
voucherPrefix: voucherPrefix || undefined,
|
||||
}),
|
||||
onSuccess: async ({ item }) => {
|
||||
setError("");
|
||||
setSelectedCandidateIds([]);
|
||||
setCandidateSearch("");
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.set("id", item.id);
|
||||
setSearchParams(next, { replace: true });
|
||||
@@ -236,9 +261,11 @@ export function PaymentBatchesPage() {
|
||||
});
|
||||
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: cancelPaymentBatch,
|
||||
mutationFn: ({ batchId, reason }: { batchId: string; reason: string }) => cancelPaymentBatch(batchId, reason),
|
||||
onSuccess: async () => {
|
||||
setError("");
|
||||
setReasonAction(null);
|
||||
setActionReason("");
|
||||
await refresh();
|
||||
toast.success("付款批次已取消");
|
||||
},
|
||||
@@ -322,17 +349,16 @@ export function PaymentBatchesPage() {
|
||||
downloadReceiptMutation.mutate({ attachment, reason });
|
||||
}
|
||||
|
||||
const deleteReceiptMutation = useMutation({
|
||||
mutationFn: async (attachmentId: string) => {
|
||||
if (!selectedBatch) {
|
||||
throw new Error("请先选择付款批次");
|
||||
}
|
||||
return deletePaymentBatchReceiptAttachment(selectedBatch.id, attachmentId);
|
||||
const voidReceiptMutation = useMutation({
|
||||
mutationFn: async ({ batchId, attachmentId, reason }: { batchId: string; attachmentId: string; reason: string }) => {
|
||||
return voidPaymentBatchReceiptAttachment(batchId, attachmentId, reason);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setError("");
|
||||
setReasonAction(null);
|
||||
setActionReason("");
|
||||
await refresh();
|
||||
toast.success("银行回单附件已删除");
|
||||
toast.success("银行回单已留痕作废");
|
||||
},
|
||||
onError: (err) => {
|
||||
const message = resolveError(err);
|
||||
@@ -353,7 +379,7 @@ export function PaymentBatchesPage() {
|
||||
返回列表
|
||||
</Button>
|
||||
) : null}
|
||||
{!selectedBatchId ? (
|
||||
{!selectedBatchId && canManageBatch ? (
|
||||
<Button type="button" onClick={() => setShowCreatePanel(true)}>
|
||||
新建批次
|
||||
</Button>
|
||||
@@ -374,41 +400,64 @@ export function PaymentBatchesPage() {
|
||||
当前账号仅可查看批次并执行复核,创建与编辑入口仅对付款批次管理员开放。
|
||||
</div>
|
||||
) : null}
|
||||
<div className="grid gap-3 lg:grid-cols-[180px_minmax(0,1fr)]">
|
||||
<div className="space-y-2">
|
||||
<Label>实体类型</Label>
|
||||
<select
|
||||
className="select-std"
|
||||
data-testid="payment-batch-create-entity-type"
|
||||
value={entityType}
|
||||
disabled={!canManageBatch}
|
||||
onChange={(event) => setEntityType(event.target.value as PaymentBatchEntityType)}
|
||||
>
|
||||
<option value="reimbursement">报销付款</option>
|
||||
<option value="travel_order">差旅订单付款</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>批次名称</Label>
|
||||
<Input
|
||||
data-testid="payment-batch-create-name"
|
||||
value={name}
|
||||
disabled={!canManageBatch}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="例如:2026Q2-财务付款批次-01"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>批次名称</Label>
|
||||
<Input
|
||||
data-testid="payment-batch-create-name"
|
||||
value={name}
|
||||
disabled={!canManageBatch}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="例如:2026Q2-财务付款批次-01"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>待付款单据 ID</Label>
|
||||
<textarea
|
||||
className="min-h-24 w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm disabled:bg-zinc-50"
|
||||
data-testid="payment-batch-create-entity-ids"
|
||||
value={entityIds}
|
||||
<Label>选择待付款报销</Label>
|
||||
<Input
|
||||
data-testid="payment-batch-candidate-search"
|
||||
value={candidateSearch}
|
||||
disabled={!canManageBatch}
|
||||
onChange={(event) => setEntityIds(event.target.value)}
|
||||
placeholder="输入报销或差旅待付款单据 ID,多个用逗号或换行分隔"
|
||||
onChange={(event) => setCandidateSearch(event.target.value)}
|
||||
placeholder="按报销编号、人员或摘要搜索"
|
||||
/>
|
||||
<div className="max-h-72 space-y-2 overflow-y-auto rounded-lg border border-zinc-200 p-2" data-testid="payment-batch-candidates">
|
||||
{candidatesQuery.isLoading ? <div className="px-2 py-3 text-sm text-zinc-500">正在加载待付款报销...</div> : null}
|
||||
{candidatesQuery.isError ? <div className="px-2 py-3 text-sm text-red-600">{resolveError(candidatesQuery.error)}</div> : null}
|
||||
{(candidatesQuery.data?.items ?? []).map((candidate) => {
|
||||
const selected = selectedCandidateIds.includes(candidate.entityId);
|
||||
return (
|
||||
<label
|
||||
key={candidate.entityId}
|
||||
className="flex cursor-pointer items-start gap-3 rounded-lg border border-zinc-200 bg-white px-3 py-3 hover:border-zinc-300"
|
||||
data-testid="payment-batch-candidate-row"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 h-4 w-4"
|
||||
checked={selected}
|
||||
onChange={(event) => setSelectedCandidateIds((current) =>
|
||||
event.target.checked
|
||||
? Array.from(new Set([...current, candidate.entityId]))
|
||||
: current.filter((id) => id !== candidate.entityId),
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex flex-wrap items-center justify-between gap-2 text-sm font-medium text-zinc-900">
|
||||
<span>{candidate.businessNo} · {candidate.employee.name}</span>
|
||||
<span>{candidate.amount} {candidate.currencyCode}</span>
|
||||
</span>
|
||||
<span className="mt-1 block text-xs text-zinc-500">
|
||||
{formatDisplayDate(candidate.businessDate)}
|
||||
{candidate.summary ? ` · ${candidate.summary}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{!candidatesQuery.isLoading && !candidatesQuery.isError && !(candidatesQuery.data?.items ?? []).length ? (
|
||||
<div className="px-2 py-3 text-sm text-zinc-500">没有可加入批次的已审批报销。</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-xs text-zinc-500">已选择 {selectedCandidateIds.length} 项;创建时服务端会再次核对审批和付款状态。</div>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
@@ -444,7 +493,7 @@ export function PaymentBatchesPage() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="button" disabled={createMutation.isPending || !canManageBatch} onClick={() => createMutation.mutate()}>
|
||||
<Button type="button" disabled={createMutation.isPending || !canManageBatch || selectedCandidateIds.length === 0} onClick={() => createMutation.mutate()}>
|
||||
{createMutation.isPending ? "创建中..." : "创建批次"}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -474,9 +523,11 @@ export function PaymentBatchesPage() {
|
||||
kind: "financeReview",
|
||||
batchId: selectedBatch.id,
|
||||
batchNo: selectedBatch.batchNo,
|
||||
highRisk: selectedBatch.highRisk,
|
||||
totalAmount: selectedBatch.totalAmount,
|
||||
})}
|
||||
>
|
||||
{selectedBatch.reviewRequirement.nextStep === "second_review" ? "二次复核" : "复核"}
|
||||
复核
|
||||
</Button>
|
||||
) : null}
|
||||
{canManageBatch ? (
|
||||
@@ -487,6 +538,8 @@ export function PaymentBatchesPage() {
|
||||
kind: "financePayment",
|
||||
batchId: selectedBatch.id,
|
||||
batchNo: selectedBatch.batchNo,
|
||||
highRisk: selectedBatch.highRisk,
|
||||
totalAmount: selectedBatch.totalAmount,
|
||||
})}
|
||||
>
|
||||
执行
|
||||
@@ -497,11 +550,14 @@ export function PaymentBatchesPage() {
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={cancelMutation.isPending || !canCancelSelectedBatch}
|
||||
onClick={() => setConfirmPaymentAction({
|
||||
onClick={() => {
|
||||
setActionReason("");
|
||||
setReasonAction({
|
||||
kind: "cancel",
|
||||
batchId: selectedBatch.id,
|
||||
batchNo: selectedBatch.batchNo,
|
||||
})}
|
||||
});
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
@@ -517,10 +573,11 @@ export function PaymentBatchesPage() {
|
||||
<div className="detail-fact-card"><div className="detail-fact-card__label">复核进度</div><div className="detail-fact-card__value">{selectedBatch.reviewRequirement.completedReviewCount}/{selectedBatch.reviewRequirement.requiredReviewCount}</div></div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-3 text-sm text-zinc-600">
|
||||
当前要求:{selectedBatch.reviewRequirement.requiredReviewCount === 2 ? `批次金额达到 ${selectedBatch.reviewRequirement.thresholdAmount},需两轮复核` : "单轮复核后可执行"}。
|
||||
当前要求:单次独立复核后可执行。
|
||||
{selectedBatch.highRisk ? ` 批次金额达到 ${selectedBatch.reviewRequirement.thresholdAmount},执行前需再次核对付款对象、金额和回单,但不增加审批层级。` : ""}
|
||||
下一步:{reviewNextStepLabel(selectedBatch.reviewRequirement.nextStep)}。
|
||||
{selectedBatch.reviewedBy ? ` 首轮复核人:${reviewerDisplayName(selectedBatch.reviewedBy)}` : ""}
|
||||
{selectedBatch.secondaryReviewedBy ? ` 二次复核人:${reviewerDisplayName(selectedBatch.secondaryReviewedBy)}` : ""}
|
||||
{selectedBatch.reviewedBy ? ` 复核人:${reviewerDisplayName(selectedBatch.reviewedBy)}` : ""}
|
||||
{selectedBatch.cancelledReason ? ` 取消原因:${selectedBatch.cancelledReason}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -586,7 +643,7 @@ export function PaymentBatchesPage() {
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={downloadReceiptMutation.isPending}
|
||||
disabled={downloadReceiptMutation.isPending || Boolean(attachment.archivedAt)}
|
||||
onClick={() => void handleDownloadReceipt(attachment)}
|
||||
>
|
||||
下载
|
||||
@@ -596,10 +653,19 @@ export function PaymentBatchesPage() {
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={!canEditReceiptAttachments || deleteReceiptMutation.isPending}
|
||||
onClick={() => deleteReceiptMutation.mutate(attachment.id)}
|
||||
disabled={selectedBatch.status === "cancelled" || Boolean(attachment.archivedAt) || voidReceiptMutation.isPending}
|
||||
onClick={() => {
|
||||
setActionReason("");
|
||||
setReasonAction({
|
||||
kind: "voidReceipt",
|
||||
batchId: selectedBatch.id,
|
||||
batchNo: selectedBatch.batchNo,
|
||||
attachmentId: attachment.id,
|
||||
attachmentName: attachment.originalName,
|
||||
});
|
||||
}}
|
||||
>
|
||||
删除
|
||||
{attachment.archivedAt ? "已作废" : "作废"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -764,18 +830,87 @@ export function PaymentBatchesPage() {
|
||||
targetName={confirmPaymentAction?.batchNo}
|
||||
description={
|
||||
confirmPaymentAction?.kind === "financeReview"
|
||||
? "确认后将写入付款批次复核记录;高风险批次仍需满足双人复核要求。"
|
||||
: confirmPaymentAction?.kind === "cancel"
|
||||
? "取消后该付款批次不能继续执行,历史记录会保留。"
|
||||
: "确认后将执行付款批次并影响付款状态,请确认批次、金额和复核要求均已核对。"
|
||||
? confirmPaymentAction.highRisk
|
||||
? `该批次金额为 ${confirmPaymentAction.totalAmount},请核对付款对象和金额。确认后完成一次独立复核,不增加第二审批层级。`
|
||||
: "确认后将完成一次独立复核并写入付款批次审计记录。"
|
||||
: confirmPaymentAction?.highRisk
|
||||
? `这是高额付款批次,金额 ${confirmPaymentAction.totalAmount}。请再次核对付款对象、金额、渠道和回单;确认后将更新源单付款状态。`
|
||||
: "确认后将执行付款批次并影响付款状态,请确认批次、金额和回单均已核对。"
|
||||
}
|
||||
onConfirm={async () => {
|
||||
if (!confirmPaymentAction) return;
|
||||
if (confirmPaymentAction.kind === "financeReview") reviewMutation.mutate(confirmPaymentAction.batchId);
|
||||
if (confirmPaymentAction.kind === "financePayment") executeMutation.mutate(confirmPaymentAction.batchId);
|
||||
if (confirmPaymentAction.kind === "cancel") cancelMutation.mutate(confirmPaymentAction.batchId);
|
||||
}}
|
||||
/>
|
||||
<Dialog
|
||||
open={Boolean(reasonAction)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setReasonAction(null);
|
||||
setActionReason("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-md p-5">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{reasonAction?.kind === "voidReceipt" ? "作废银行回单" : "取消付款批次"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{reasonAction?.kind === "voidReceipt"
|
||||
? `回单:${reasonAction.attachmentName}。文件记录会保留并标记为已作废。`
|
||||
: reasonAction
|
||||
? `批次:${reasonAction.batchNo}。只取消尚未执行的付款项,已执行项不会回滚。`
|
||||
: "请填写原因。"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="mt-4 space-y-2">
|
||||
<Textarea
|
||||
value={actionReason}
|
||||
onChange={(event) => setActionReason(event.target.value)}
|
||||
aria-label={reasonAction?.kind === "voidReceipt" ? "回单作废原因" : "批次取消原因"}
|
||||
rows={4}
|
||||
maxLength={500}
|
||||
placeholder={reasonAction?.kind === "voidReceipt" ? "说明回单为何作废" : "说明批次为何不再继续"}
|
||||
/>
|
||||
<div className="flex items-center justify-between text-xs text-zinc-500">
|
||||
<span>原因需填写 2–500 个字符,并写入审计记录。</span>
|
||||
<span>{actionReason.trim().length}/500</span>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => {
|
||||
setReasonAction(null);
|
||||
setActionReason("");
|
||||
}}>
|
||||
返回
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={
|
||||
actionReason.trim().length < 2 ||
|
||||
cancelMutation.isPending ||
|
||||
voidReceiptMutation.isPending
|
||||
}
|
||||
onClick={() => {
|
||||
if (!reasonAction) return;
|
||||
const reason = actionReason.trim();
|
||||
if (reasonAction.kind === "cancel") {
|
||||
cancelMutation.mutate({ batchId: reasonAction.batchId, reason });
|
||||
} else {
|
||||
voidReceiptMutation.mutate({
|
||||
batchId: reasonAction.batchId,
|
||||
attachmentId: reasonAction.attachmentId,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{reasonAction?.kind === "voidReceipt" ? "确认作废" : "确认取消"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</ListWorkspace>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -140,6 +140,7 @@ export function PeoplePage() {
|
||||
const { permissions } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const canManageUsers = hasPermission(permissions, "system.users");
|
||||
const canManagePayroll = hasPermission(permissions, "payroll.manage");
|
||||
const canViewStats = canManageUsers || hasPermission(permissions, "report.dashboard.view");
|
||||
|
||||
const [q, setQ] = useState("");
|
||||
@@ -185,6 +186,7 @@ export function PeoplePage() {
|
||||
hireDateFrom: hireDateFrom || undefined,
|
||||
hireDateTo: hireDateTo || undefined,
|
||||
employmentStatus: employmentStatus || undefined,
|
||||
scope: "management",
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -429,7 +431,7 @@ export function PeoplePage() {
|
||||
subtitle="按员工档案、账号状态和企业微信镜像做筛选、批量处理和审计留痕。"
|
||||
actions={
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{canManageUsers ? (
|
||||
{canManagePayroll ? (
|
||||
<Button asChild variant="secondary" type="button">
|
||||
<Link to="/payroll">薪资管理</Link>
|
||||
</Button>
|
||||
|
||||
@@ -50,6 +50,7 @@ function roleLabel(roleCode: string) {
|
||||
employee: "员工",
|
||||
finance: "财务",
|
||||
finance_mgr: "财务负责人",
|
||||
system_admin: "OA 系统管理员",
|
||||
hr: "人事",
|
||||
manager: "审批人",
|
||||
};
|
||||
|
||||
@@ -35,8 +35,10 @@ import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
|
||||
import { AiSuggestionCard } from "../components/ai/AiSuggestionCard";
|
||||
import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog";
|
||||
import { AttachmentGovernanceBadges } from "../components/common/AttachmentGovernanceBadges";
|
||||
import { AttachmentDeleteConfirmDialog } from "../components/common/AttachmentDeleteConfirmDialog";
|
||||
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
|
||||
import { DateTextInput } from "../components/common/DateTextInput";
|
||||
import { useUnsavedChangesGuard } from "../components/common/UnsavedChangesGuard";
|
||||
import { Pagination } from "../components/common/Pagination";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { TableSkeleton } from "../components/common/TableSkeleton";
|
||||
@@ -77,6 +79,21 @@ type DraftItem = {
|
||||
isAssetCandidate: boolean;
|
||||
};
|
||||
|
||||
function purchaseDraftSignature(value: {
|
||||
title: string;
|
||||
reason: string;
|
||||
currencyCode: string;
|
||||
legalEntityId: string;
|
||||
costCenterId: string;
|
||||
supplierId: string;
|
||||
documentCategoryId: string;
|
||||
projectId: string;
|
||||
expectedDeliveryDate: string;
|
||||
items: DraftItem[];
|
||||
}) {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function resolveError(error: unknown) {
|
||||
if (isApiError(error)) {
|
||||
return mapApiErrorMessage(error.code, error.details, error.message);
|
||||
@@ -318,9 +335,24 @@ export function PurchaseRequestsPage() {
|
||||
const [projectId, setProjectId] = useState("");
|
||||
const [expectedDeliveryDate, setExpectedDeliveryDate] = useState("");
|
||||
const [items, setItems] = useState<DraftItem[]>([]);
|
||||
const [savedDraftSignature, setSavedDraftSignature] = useState(() =>
|
||||
purchaseDraftSignature({
|
||||
title: "",
|
||||
reason: "",
|
||||
currencyCode: "CNY",
|
||||
legalEntityId: "",
|
||||
costCenterId: "",
|
||||
supplierId: "",
|
||||
documentCategoryId: "",
|
||||
projectId: "",
|
||||
expectedDeliveryDate: "",
|
||||
items: [],
|
||||
}),
|
||||
);
|
||||
const [archivePoNo, setArchivePoNo] = useState("");
|
||||
const [archiveContractNo, setArchiveContractNo] = useState("");
|
||||
const [selectedAttachmentName, setSelectedAttachmentName] = useState("");
|
||||
const [deleteAttachmentTarget, setDeleteAttachmentTarget] = useState<{ id: string; originalName: string } | null>(null);
|
||||
const [submitPrecheckConfirm, setSubmitPrecheckConfirm] = useState<string | null>(null);
|
||||
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
@@ -403,17 +435,17 @@ export function PurchaseRequestsPage() {
|
||||
if (!detailQuery.data || detailQuery.data.id !== selectedId || hydratedDetailId === detailQuery.data.id) {
|
||||
return;
|
||||
}
|
||||
setTitle(detailQuery.data.title || "");
|
||||
setReason(detailQuery.data.reason || "");
|
||||
setCurrencyCode(detailQuery.data.currencyCode || "CNY");
|
||||
setLegalEntityId(detailQuery.data.legalEntityId || "");
|
||||
setCostCenterId(detailQuery.data.costCenterId || "");
|
||||
setSupplierId(detailQuery.data.supplierId || "");
|
||||
setDocumentCategoryId(detailQuery.data.documentCategoryId || "");
|
||||
setProjectId(detailQuery.data.projectId || "");
|
||||
setExpectedDeliveryDate(detailQuery.data.expectedDeliveryDate || "");
|
||||
setItems(
|
||||
detailQuery.data.items.map((item) => ({
|
||||
const nextDraft = {
|
||||
title: detailQuery.data.title || "",
|
||||
reason: detailQuery.data.reason || "",
|
||||
currencyCode: detailQuery.data.currencyCode || "CNY",
|
||||
legalEntityId: detailQuery.data.legalEntityId || "",
|
||||
costCenterId: detailQuery.data.costCenterId || "",
|
||||
supplierId: detailQuery.data.supplierId || "",
|
||||
documentCategoryId: detailQuery.data.documentCategoryId || "",
|
||||
projectId: detailQuery.data.projectId || "",
|
||||
expectedDeliveryDate: detailQuery.data.expectedDeliveryDate || "",
|
||||
items: detailQuery.data.items.map((item) => ({
|
||||
id: item.id,
|
||||
description: item.description,
|
||||
specification: item.specification || "",
|
||||
@@ -421,7 +453,18 @@ export function PurchaseRequestsPage() {
|
||||
unitPrice: item.unitPrice,
|
||||
isAssetCandidate: item.isAssetCandidate,
|
||||
})),
|
||||
);
|
||||
};
|
||||
setTitle(nextDraft.title);
|
||||
setReason(nextDraft.reason);
|
||||
setCurrencyCode(nextDraft.currencyCode);
|
||||
setLegalEntityId(nextDraft.legalEntityId);
|
||||
setCostCenterId(nextDraft.costCenterId);
|
||||
setSupplierId(nextDraft.supplierId);
|
||||
setDocumentCategoryId(nextDraft.documentCategoryId);
|
||||
setProjectId(nextDraft.projectId);
|
||||
setExpectedDeliveryDate(nextDraft.expectedDeliveryDate);
|
||||
setItems(nextDraft.items);
|
||||
setSavedDraftSignature(purchaseDraftSignature(nextDraft));
|
||||
setArchivePoNo(detailQuery.data.archiveOrder?.poNo || "");
|
||||
setArchiveContractNo(detailQuery.data.archiveOrder?.contractNo || "");
|
||||
setFormError("");
|
||||
@@ -576,6 +619,7 @@ export function PurchaseRequestsPage() {
|
||||
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "detail", selectedId] }),
|
||||
]);
|
||||
setFormError("");
|
||||
setSavedDraftSignature(currentDraftSignature);
|
||||
toast.success("采购草稿已保存");
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -716,14 +760,15 @@ export function PurchaseRequestsPage() {
|
||||
});
|
||||
|
||||
const deleteAttachmentMutation = useMutation({
|
||||
mutationFn: async (attachmentId: string) => {
|
||||
mutationFn: async ({ attachmentId, reason }: { attachmentId: string; reason: string }) => {
|
||||
if (!selectedId) {
|
||||
throw new Error("请先选择采购单");
|
||||
}
|
||||
return deletePurchaseAttachment(selectedId, attachmentId);
|
||||
return deletePurchaseAttachment(selectedId, attachmentId, reason);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["purchase-requests", "detail", selectedId] });
|
||||
setDeleteAttachmentTarget(null);
|
||||
toast.success("附件已删除");
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -772,6 +817,7 @@ export function PurchaseRequestsPage() {
|
||||
setFormError("");
|
||||
validateBeforeSubmit();
|
||||
await updatePurchaseRequest(selectedId, buildPayload());
|
||||
setSavedDraftSignature(currentDraftSignature);
|
||||
const precheck = await precheckPurchaseRequest(selectedId);
|
||||
const message = buildPrecheckDialogMessage(precheck);
|
||||
|
||||
@@ -796,6 +842,37 @@ export function PurchaseRequestsPage() {
|
||||
|
||||
const showArchiveSection = Boolean(detail && (detail.status === "approved" || archiveOrder));
|
||||
const canEditDraft = Boolean(detail?.permissions.canEditDraft);
|
||||
const currentDraftSignature = useMemo(
|
||||
() =>
|
||||
purchaseDraftSignature({
|
||||
title,
|
||||
reason,
|
||||
currencyCode,
|
||||
legalEntityId,
|
||||
costCenterId,
|
||||
supplierId,
|
||||
documentCategoryId,
|
||||
projectId,
|
||||
expectedDeliveryDate,
|
||||
items,
|
||||
}),
|
||||
[
|
||||
costCenterId,
|
||||
currencyCode,
|
||||
documentCategoryId,
|
||||
expectedDeliveryDate,
|
||||
items,
|
||||
legalEntityId,
|
||||
projectId,
|
||||
reason,
|
||||
supplierId,
|
||||
title,
|
||||
],
|
||||
);
|
||||
const runGuarded = useUnsavedChangesGuard(
|
||||
Boolean(selectedId) && canEditDraft && currentDraftSignature !== savedDraftSignature,
|
||||
"采购表单",
|
||||
);
|
||||
const canSubmit = Boolean(detail?.permissions.canSubmit);
|
||||
const canWithdraw = Boolean(detail?.permissions.canWithdraw);
|
||||
const canArchiveOrder = Boolean(detail?.permissions.canArchiveOrder && canManage);
|
||||
@@ -813,15 +890,19 @@ export function PurchaseRequestsPage() {
|
||||
const detailNextStepText = detail ? resolvePurchaseNextStep(detail) : "";
|
||||
const isPurchaseDetailMode = Boolean(selectedId);
|
||||
const openPurchaseDetail = (id: string) => {
|
||||
setSelectedId(id);
|
||||
setHydratedDetailId("");
|
||||
runGuarded(() => {
|
||||
setSelectedId(id);
|
||||
setHydratedDetailId("");
|
||||
});
|
||||
};
|
||||
const returnToPurchaseList = () => {
|
||||
setSelectedId("");
|
||||
setHydratedDetailId("");
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete("id");
|
||||
setSearchParams(next, { replace: true });
|
||||
runGuarded(() => {
|
||||
setSelectedId("");
|
||||
setHydratedDetailId("");
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete("id");
|
||||
setSearchParams(next, { replace: true });
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -1670,7 +1751,7 @@ export function PurchaseRequestsPage() {
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={!canEditDraft || deleteAttachmentMutation.isPending}
|
||||
onClick={() => deleteAttachmentMutation.mutate(attachment.id)}
|
||||
onClick={() => setDeleteAttachmentTarget({ id: attachment.id, originalName: attachment.originalName })}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
@@ -1720,7 +1801,7 @@ export function PurchaseRequestsPage() {
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={!canEditDraft || deleteAttachmentMutation.isPending}
|
||||
onClick={() => deleteAttachmentMutation.mutate(attachment.id)}
|
||||
onClick={() => setDeleteAttachmentTarget({ id: attachment.id, originalName: attachment.originalName })}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
@@ -1877,6 +1958,18 @@ export function PurchaseRequestsPage() {
|
||||
submitMutation.mutate();
|
||||
}}
|
||||
/>
|
||||
<AttachmentDeleteConfirmDialog
|
||||
open={Boolean(deleteAttachmentTarget)}
|
||||
targetName={deleteAttachmentTarget?.originalName}
|
||||
pending={deleteAttachmentMutation.isPending}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleteAttachmentTarget(null);
|
||||
}}
|
||||
onConfirm={(deleteReason) => {
|
||||
if (!deleteAttachmentTarget) return;
|
||||
deleteAttachmentMutation.mutate({ attachmentId: deleteAttachmentTarget.id, reason: deleteReason });
|
||||
}}
|
||||
/>
|
||||
</ListWorkspace>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,9 @@ import { useAuth } from "../auth/AuthContext";
|
||||
import { hasPermission } from "../auth/permissions";
|
||||
import { CardSkeleton } from "../components/common/CardSkeleton";
|
||||
import { ConfirmDialog } from "../components/common/ConfirmDialog";
|
||||
import { useUnsavedChangesGuard } from "../components/common/UnsavedChangesGuard";
|
||||
import { AttachmentGovernanceBadges } from "../components/common/AttachmentGovernanceBadges";
|
||||
import { AttachmentDeleteConfirmDialog } from "../components/common/AttachmentDeleteConfirmDialog";
|
||||
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
|
||||
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
@@ -161,6 +163,7 @@ export function ReimbursementDetailPage() {
|
||||
const [selectedInvoices, setSelectedInvoices] = useState<SelectedInvoiceMap>({});
|
||||
const [selectedInvoiceFiles, setSelectedInvoiceFiles] = useState<File[]>([]);
|
||||
const [selectedAttachmentFiles, setSelectedAttachmentFiles] = useState<File[]>([]);
|
||||
const [deleteAttachmentTarget, setDeleteAttachmentTarget] = useState<{ id: string; originalName: string } | null>(null);
|
||||
const [invoiceActionError, setInvoiceActionError] = useState("");
|
||||
const [pendingOcrInvoiceIds, setPendingOcrInvoiceIds] = useState<string[]>([]);
|
||||
const [invoiceUploadProgress, setInvoiceUploadProgress] = useState({ completed: 0, total: 0 });
|
||||
@@ -170,6 +173,13 @@ export function ReimbursementDetailPage() {
|
||||
amount: "",
|
||||
description: "",
|
||||
});
|
||||
const runGuarded = useUnsavedChangesGuard(
|
||||
Boolean(itemEditor) ||
|
||||
selectedInvoiceFiles.length > 0 ||
|
||||
selectedAttachmentFiles.length > 0 ||
|
||||
Object.keys(selectedInvoices).length > 0,
|
||||
"报销表单",
|
||||
);
|
||||
|
||||
const detailQuery = useQuery({
|
||||
enabled: Boolean(reimbursementId),
|
||||
@@ -665,9 +675,11 @@ export function ReimbursementDetailPage() {
|
||||
});
|
||||
|
||||
const deleteAttachmentMutation = useMutation({
|
||||
mutationFn: (attachmentId: string) => deleteReimbursementAttachment(reimbursementId, attachmentId),
|
||||
mutationFn: ({ attachmentId, reason }: { attachmentId: string; reason: string }) =>
|
||||
deleteReimbursementAttachment(reimbursementId, attachmentId, reason),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] });
|
||||
setDeleteAttachmentTarget(null);
|
||||
toast.success("附件材料已删除");
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -796,15 +808,19 @@ export function ReimbursementDetailPage() {
|
||||
};
|
||||
|
||||
const startCreateItem = () => {
|
||||
setItemEditor({ mode: "create" });
|
||||
setEditorInitialValues(computeEditorDefaults());
|
||||
setItemError("");
|
||||
runGuarded(() => {
|
||||
setItemEditor({ mode: "create" });
|
||||
setEditorInitialValues(computeEditorDefaults());
|
||||
setItemError("");
|
||||
});
|
||||
};
|
||||
|
||||
const startEditItem = (item: ReimbursementItem) => {
|
||||
setItemEditor({ mode: "edit", itemId: item.id });
|
||||
setEditorInitialValues(computeEditorDefaults(item));
|
||||
setItemError("");
|
||||
runGuarded(() => {
|
||||
setItemEditor({ mode: "edit", itemId: item.id });
|
||||
setEditorInitialValues(computeEditorDefaults(item));
|
||||
setItemError("");
|
||||
});
|
||||
};
|
||||
|
||||
const handleItemSave = (data: ItemEditorSaveData) => {
|
||||
@@ -812,8 +828,10 @@ export function ReimbursementDetailPage() {
|
||||
};
|
||||
|
||||
const handleItemCancel = () => {
|
||||
setItemEditor(null);
|
||||
setItemError("");
|
||||
runGuarded(() => {
|
||||
setItemEditor(null);
|
||||
setItemError("");
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmitAction = async () => {
|
||||
@@ -1539,7 +1557,7 @@ export function ReimbursementDetailPage() {
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={deleteAttachmentMutation.isPending}
|
||||
onClick={() => deleteAttachmentMutation.mutate(attachment.id)}
|
||||
onClick={() => setDeleteAttachmentTarget({ id: attachment.id, originalName: attachment.originalName })}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
@@ -1991,6 +2009,18 @@ export function ReimbursementDetailPage() {
|
||||
confirmLabel="删除草稿"
|
||||
onConfirm={() => { deleteReimbursementMutation.mutate(); }}
|
||||
/>
|
||||
<AttachmentDeleteConfirmDialog
|
||||
open={Boolean(deleteAttachmentTarget)}
|
||||
targetName={deleteAttachmentTarget?.originalName}
|
||||
pending={deleteAttachmentMutation.isPending}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleteAttachmentTarget(null);
|
||||
}}
|
||||
onConfirm={(deleteReason) => {
|
||||
if (!deleteAttachmentTarget) return;
|
||||
deleteAttachmentMutation.mutate({ attachmentId: deleteAttachmentTarget.id, reason: deleteReason });
|
||||
}}
|
||||
/>
|
||||
</PageScaffold>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,9 +13,9 @@ type ManagementItem = {
|
||||
};
|
||||
|
||||
const ITEMS: ManagementItem[] = [
|
||||
{ to: "/people", icon: "badge", title: "人员", description: "员工档案、入转调离和人员状态。", group: "人员与组织", anyOf: ["system.users", "report.dashboard.view"] },
|
||||
{ to: "/people", icon: "badge", title: "人员", description: "员工档案、入转调离和人员状态。", group: "人员与组织", anyOf: ["system.users"] },
|
||||
{ to: "/organization", icon: "account_tree", title: "组织", description: "组织结构、部门和汇报关系。", group: "人员与组织", anyOf: ["system.users", "report.dashboard.view"] },
|
||||
{ to: "/system/users", icon: "manage_accounts", title: "账号", description: "账号、角色和登录权限。", group: "人员与组织", anyOf: ["system.users"] },
|
||||
{ to: "/system/users", icon: "manage_accounts", title: "账号", description: "账号、角色和登录权限。", group: "人员与组织", anyOf: ["system.accounts.manage", "system.roles.manage"] },
|
||||
{ to: "/system/departments", icon: "account_tree", title: "部门", description: "部门基础数据维护。", group: "人员与组织", anyOf: ["system.users"] },
|
||||
{ to: "/system/approval-rules", icon: "rule", title: "审批规则", description: "金额段、审批层级和处理规则。", group: "流程与规则", anyOf: ["system.settings"] },
|
||||
{ to: "/system/workflow-definitions", icon: "schema", title: "流程定义", description: "流程模板和节点治理。", group: "流程与规则", anyOf: ["system.settings"] },
|
||||
|
||||
@@ -25,26 +25,50 @@ import { Pagination } from "../components/common/Pagination";
|
||||
import { CreateUserDialog, type CreateUserFormData } from "./user-management/CreateUserDialog";
|
||||
import { UserImportDialog } from "./user-management/UserImportDialog";
|
||||
import { UserRoleDialog } from "./user-management/UserRoleDialog";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
const ROLE_OPTIONS: Array<{ code: string; label: string }> = [
|
||||
{ code: "employee", label: "employee" },
|
||||
{ code: "manager", label: "manager" },
|
||||
{ code: "ceo", label: "ceo" },
|
||||
{ code: "finance", label: "finance" },
|
||||
{ code: "finance_mgr", label: "finance_mgr" },
|
||||
{ code: "administration", label: "administration" },
|
||||
{ code: "hr", label: "hr" },
|
||||
{ code: "admin", label: "admin" },
|
||||
];
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
employee: "员工",
|
||||
manager: "直属经理",
|
||||
ceo: "公司负责人",
|
||||
finance: "财务",
|
||||
system_admin: "OA 系统管理员",
|
||||
finance_mgr: "财务负责人(兼容)",
|
||||
administration: "行政(兼容)",
|
||||
hr: "人事(兼容)",
|
||||
admin: "应急超级管理员",
|
||||
};
|
||||
|
||||
const ORDINARY_ROLE_OPTIONS = [
|
||||
"employee",
|
||||
"manager",
|
||||
"ceo",
|
||||
"finance",
|
||||
"system_admin",
|
||||
"finance_mgr",
|
||||
"administration",
|
||||
"hr",
|
||||
] as const;
|
||||
|
||||
function formatRoleCodes(roleCodes: string[]) {
|
||||
return roleCodes.length ? roleCodes.join(", ") : "-";
|
||||
return roleCodes.length ? roleCodes.map((roleCode) => ROLE_LABELS[roleCode] ?? roleCode).join("、") : "-";
|
||||
}
|
||||
|
||||
function isProtectedUserAccount(user: UserSummary) {
|
||||
return user.roleCodes.some((roleCode) => roleCode === "admin" || roleCode === "system_admin");
|
||||
}
|
||||
|
||||
export function UserManagementPage() {
|
||||
const { me } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const isEmergencyAdmin = (me?.roleCodes ?? []).includes("admin");
|
||||
const roleOptions = ORDINARY_ROLE_OPTIONS.map((code) => ({
|
||||
code,
|
||||
label: ROLE_LABELS[code],
|
||||
disabled: code === "system_admin" && !isEmergencyAdmin,
|
||||
}));
|
||||
const [status, setStatus] = useState<UserStatus | "">("");
|
||||
const [q, setQ] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
@@ -190,7 +214,7 @@ export function UserManagementPage() {
|
||||
employeeNo: item.employeeNo,
|
||||
})),
|
||||
}}
|
||||
roleOptions={ROLE_OPTIONS}
|
||||
roleOptions={roleOptions}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
@@ -280,8 +304,16 @@ export function UserManagementPage() {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.length ? (
|
||||
users.map((item) => (
|
||||
<TableRow data-testid="users-row" key={item.id}>
|
||||
users.map((item) => {
|
||||
const protectedAccount = isProtectedUserAccount(item);
|
||||
const canManageAccount = isEmergencyAdmin || !protectedAccount;
|
||||
const isCurrentAccount = item.id === me?.id;
|
||||
const canToggleAccount = canManageAccount && !isCurrentAccount;
|
||||
const canResetPassword = canManageAccount;
|
||||
const canEditRoles = canManageAccount && !isCurrentAccount;
|
||||
|
||||
return (
|
||||
<TableRow data-testid="users-row" key={item.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium text-zinc-900">
|
||||
{item.employeeNo} / {item.name}
|
||||
@@ -296,42 +328,58 @@ export function UserManagementPage() {
|
||||
<TableCell className="text-xs">{formatRoleCodes(item.roleCodes)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
data-testid="users-toggle"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={toggleMutation.isPending}
|
||||
onClick={() => toggleMutation.mutate(item)}
|
||||
type="button"
|
||||
>
|
||||
{item.status === "active" ? "禁用" : "启用"}
|
||||
</Button>
|
||||
{canToggleAccount ? (
|
||||
<Button
|
||||
data-testid="users-toggle"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={toggleMutation.isPending}
|
||||
onClick={() => toggleMutation.mutate(item)}
|
||||
type="button"
|
||||
>
|
||||
{item.status === "active" ? "禁用" : "启用"}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
data-testid="users-reset-password"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={resetMutation.isPending}
|
||||
onClick={() => resetMutation.mutate(item)}
|
||||
type="button"
|
||||
>
|
||||
重置密码
|
||||
</Button>
|
||||
{canResetPassword ? (
|
||||
<Button
|
||||
data-testid="users-reset-password"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={resetMutation.isPending}
|
||||
onClick={() => resetMutation.mutate(item)}
|
||||
type="button"
|
||||
>
|
||||
重置密码
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
data-testid="users-edit-roles"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={rolesMutation.isPending}
|
||||
onClick={() => setRoleDialogUser(item)}
|
||||
type="button"
|
||||
>
|
||||
角色
|
||||
</Button>
|
||||
{canEditRoles ? (
|
||||
<Button
|
||||
data-testid="users-edit-roles"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={rolesMutation.isPending}
|
||||
onClick={() => setRoleDialogUser(item)}
|
||||
type="button"
|
||||
>
|
||||
{item.roleCodes.includes("admin") ? "应急账号" : "角色"}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{protectedAccount && !isEmergencyAdmin ? (
|
||||
<span
|
||||
className="inline-flex items-center text-xs text-zinc-500"
|
||||
data-testid="users-protected-account"
|
||||
>
|
||||
受保护账号
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="py-3 text-zinc-500">
|
||||
@@ -359,7 +407,7 @@ export function UserManagementPage() {
|
||||
}
|
||||
}}
|
||||
user={roleDialogUser}
|
||||
roleOptions={ROLE_OPTIONS}
|
||||
roleOptions={roleOptions}
|
||||
onSubmit={(roles) => {
|
||||
if (roleDialogUser) {
|
||||
rolesMutation.mutate({ userId: roleDialogUser.id, roles });
|
||||
|
||||
@@ -37,9 +37,9 @@ const STATUS_OPTIONS: Array<{ value: "" | WecomApprovalArchiveStatus; label: str
|
||||
|
||||
const FINDING_OPTIONS: Array<{ value: "" | WecomApprovalArchiveFinding; label: string }> = [
|
||||
{ value: "", label: "全部对账" },
|
||||
{ value: "open", label: "仅未关闭 finding" },
|
||||
{ value: "resolved", label: "已关闭 finding" },
|
||||
{ value: "any", label: "有 finding" },
|
||||
{ value: "open", label: "仅未关闭问题" },
|
||||
{ value: "resolved", label: "已关闭问题" },
|
||||
{ value: "any", label: "有对账问题" },
|
||||
];
|
||||
|
||||
function resolveError(error: unknown, fallback = "读取企微审批历史失败") {
|
||||
@@ -121,7 +121,7 @@ function ArchiveRow({
|
||||
<td className="px-3 py-3 align-top text-sm text-zinc-600">{formatTime(item.statusObservedAt || item.completedAt || item.appliedAt)}</td>
|
||||
<td className="px-3 py-3 align-top text-sm text-zinc-600">
|
||||
<div>{sourceLabel(item.lastSource)}</div>
|
||||
{item.openFindingCount ? <div className="mt-1 text-xs text-amber-700">finding {item.openFindingCount} 项</div> : null}
|
||||
{item.openFindingCount ? <div className="mt-1 text-xs text-amber-700">对账问题 {item.openFindingCount} 项</div> : null}
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top text-right">
|
||||
<Button type="button" variant="secondary" onClick={onSelect}>查看历史</Button>
|
||||
@@ -156,7 +156,7 @@ export function WecomApprovalArchivePage() {
|
||||
const detail = detailQuery.data?.item ?? null;
|
||||
|
||||
return (
|
||||
<div className="page-shell space-y-6" data-testid="wecom-approval-archive-page">
|
||||
<div className="page-shell min-w-0 max-w-full space-y-6" data-testid="wecom-approval-archive-page">
|
||||
<PageHeader
|
||||
eyebrow="企微审批历史"
|
||||
title="企微审批历史"
|
||||
@@ -219,7 +219,7 @@ export function WecomApprovalArchivePage() {
|
||||
{listQuery.isError ? <div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{resolveError(listQuery.error)}</div> : null}
|
||||
{!listQuery.isLoading && !listQuery.isError && !items.length ? (
|
||||
<div className="rounded-md border border-dashed border-slate-300 bg-slate-50 px-4 py-8 text-center text-sm text-slate-500">
|
||||
暂无已归档的企微审批历史。若公网 OA DB 还没有留存数据,验收记录为 PARTIAL_ARCHIVE_NEEDS_DATA。
|
||||
暂无已归档的企微审批历史;如数据尚未同步,可稍后重试或联系管理员核对归档状态。
|
||||
</div>
|
||||
) : null}
|
||||
{!listQuery.isLoading && !listQuery.isError && items.length ? (
|
||||
@@ -245,17 +245,17 @@ export function WecomApprovalArchivePage() {
|
||||
</div>
|
||||
<div className="divide-y divide-slate-100 md:hidden">
|
||||
{items.map((item) => (
|
||||
<article className="py-3" key={item.id}>
|
||||
<article className="min-w-0 py-3" key={item.id}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-zinc-900">{typeLabel(item.templateType)}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">单号前缀:{item.spNoPrefix || "-"}</div>
|
||||
<div className="mt-1 break-all text-xs text-zinc-500">单号前缀:{item.spNoPrefix || "-"}</div>
|
||||
</div>
|
||||
<StatusBadge status={item.normalizedStatus} />
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-zinc-600">{employeeLabel(item.applicant)} · {formatTime(item.statusObservedAt || item.completedAt || item.appliedAt)}</div>
|
||||
<div className="mt-2 break-words text-sm text-zinc-600 [overflow-wrap:anywhere]">{employeeLabel(item.applicant)} · {formatTime(item.statusObservedAt || item.completedAt || item.appliedAt)}</div>
|
||||
<div className="mt-2 flex items-center justify-between gap-3">
|
||||
<span className="text-xs text-zinc-500">{sourceLabel(item.lastSource)}{item.openFindingCount ? ` · finding ${item.openFindingCount}` : ""}</span>
|
||||
<span className="min-w-0 break-words text-xs text-zinc-500 [overflow-wrap:anywhere]">{sourceLabel(item.lastSource)}{item.openFindingCount ? ` · 对账问题 ${item.openFindingCount}` : ""}</span>
|
||||
<Button type="button" variant="secondary" onClick={() => setSelectedId(item.id)}>查看历史</Button>
|
||||
</div>
|
||||
</article>
|
||||
@@ -273,30 +273,30 @@ export function WecomApprovalArchivePage() {
|
||||
{selectedId && detailQuery.isLoading ? <TableSkeleton columns={1} rows={3} /> : null}
|
||||
{selectedId && detailQuery.isError ? <div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{resolveError(detailQuery.error)}</div> : null}
|
||||
{detail ? (
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<div className="space-y-3 lg:col-span-1">
|
||||
<div className="grid min-w-0 max-w-full gap-4 lg:grid-cols-3">
|
||||
<div className="min-w-0 space-y-3 lg:col-span-1">
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">状态</div>
|
||||
<div className="mt-1"><StatusBadge status={detail.normalizedStatus} /></div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">申请人</div>
|
||||
<div className="mt-1 text-sm text-zinc-800">{employeeLabel(detail.applicant)}</div>
|
||||
<div className="mt-1 break-words text-sm text-zinc-800 [overflow-wrap:anywhere]">{employeeLabel(detail.applicant)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">单号前缀</div>
|
||||
<div className="mt-1 text-sm text-zinc-800">{detail.spNoPrefix || "-"}</div>
|
||||
<div className="mt-1 break-all text-sm text-zinc-800">{detail.spNoPrefix || "-"}</div>
|
||||
</div>
|
||||
{detail.singleTapApproveRisk ? <div className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">企微同意可能单击即通过,OA 仅留存动作来源和历史审计。</div> : null}
|
||||
</div>
|
||||
<div className="space-y-4 lg:col-span-2">
|
||||
<div className="min-w-0 space-y-4 lg:col-span-2">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-zinc-900">字段摘要</h4>
|
||||
<div className="mt-2 divide-y divide-slate-100 rounded-md border border-slate-200">
|
||||
{detail.fields.length ? detail.fields.map((field) => (
|
||||
<div className="grid gap-1 px-3 py-2 text-sm md:grid-cols-3" key={field.id}>
|
||||
<span className="font-medium text-zinc-800">{field.fieldName || field.fieldKey}</span>
|
||||
<span className="text-zinc-500">{field.fieldType || "-"}</span>
|
||||
<div className="grid min-w-0 gap-1 px-3 py-2 text-sm md:grid-cols-3" key={field.id}>
|
||||
<span className="min-w-0 break-words font-medium text-zinc-800 [overflow-wrap:anywhere]">{field.fieldName || field.fieldKey}</span>
|
||||
<span className="min-w-0 break-words text-zinc-500 [overflow-wrap:anywhere]">{field.fieldType || "-"}</span>
|
||||
<span className="text-zinc-500">{field.sensitiveLevel === "restricted" ? "受限字段" : "普通字段"}</span>
|
||||
</div>
|
||||
)) : <div className="px-3 py-3 text-sm text-zinc-500">暂无字段摘要。</div>}
|
||||
@@ -309,7 +309,7 @@ export function WecomApprovalArchivePage() {
|
||||
{detail.participants.length ? detail.participants.map((item) => (
|
||||
<div className="border-b border-slate-100 px-3 py-2 text-sm last:border-b-0" key={item.id}>
|
||||
<span className="font-medium text-zinc-800">{roleLabel(item.role)}</span>
|
||||
<span className="ml-2 text-zinc-600">{employeeLabel(item.employee)}</span>
|
||||
<span className="ml-2 break-words text-zinc-600 [overflow-wrap:anywhere]">{employeeLabel(item.employee)}</span>
|
||||
</div>
|
||||
)) : <div className="px-3 py-3 text-sm text-zinc-500">暂无映射参与人。</div>}
|
||||
</div>
|
||||
@@ -317,7 +317,7 @@ export function WecomApprovalArchivePage() {
|
||||
{detail.actions.length ? detail.actions.map((item) => (
|
||||
<div className="border-b border-slate-100 px-3 py-2 text-sm last:border-b-0" key={item.id}>
|
||||
<span className="font-medium text-zinc-800">{actionLabel(item.actionType)}</span>
|
||||
<span className="ml-2 text-zinc-600">{employeeLabel(item.operator)}</span>
|
||||
<span className="ml-2 break-words text-zinc-600 [overflow-wrap:anywhere]">{employeeLabel(item.operator)}</span>
|
||||
<div className="mt-1 text-xs text-zinc-500">{formatTime(item.actionTime)} · {sourceLabel(item.source)}</div>
|
||||
</div>
|
||||
)) : <div className="px-3 py-3 text-sm text-zinc-500">暂无动作历史。</div>}
|
||||
@@ -325,14 +325,14 @@ export function WecomApprovalArchivePage() {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-zinc-900">对账 finding</h4>
|
||||
<h4 className="text-sm font-semibold text-zinc-900">对账问题</h4>
|
||||
<div className="mt-2 divide-y divide-slate-100 rounded-md border border-slate-200">
|
||||
{detail.findings.length ? detail.findings.map((item) => (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 px-3 py-2 text-sm" key={item.id}>
|
||||
<span className="font-medium text-zinc-800">{item.findingType}</span>
|
||||
<span className="text-zinc-500">{item.status} · {item.severity} · {formatTime(item.detectedAt)}</span>
|
||||
<span className="min-w-0 break-words font-medium text-zinc-800 [overflow-wrap:anywhere]">{item.findingType}</span>
|
||||
<span className="min-w-0 break-words text-zinc-500 [overflow-wrap:anywhere]">{item.status} · {item.severity} · {formatTime(item.detectedAt)}</span>
|
||||
</div>
|
||||
)) : <div className="px-3 py-3 text-sm text-zinc-500">暂无对账 finding。</div>}
|
||||
)) : <div className="px-3 py-3 text-sm text-zinc-500">暂无对账问题。</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -27,14 +27,14 @@ export interface CreateUserDialogProps {
|
||||
isPending: boolean;
|
||||
departments: { isLoading: boolean; items: Array<{ id: string; name: string }> };
|
||||
managers: { isLoading: boolean; items: Array<{ id: string; name: string; employeeNo: string }> };
|
||||
roleOptions: Array<{ code: string; label: string }>;
|
||||
roleOptions: Array<{ code: string; label: string; disabled?: boolean }>;
|
||||
}
|
||||
|
||||
const ROLE_PRESETS = [
|
||||
{ key: "owner", label: "老板", roles: ["ceo"] },
|
||||
{ key: "admin", label: "管理员", roles: ["admin"] },
|
||||
{ key: "employee", label: "员工", roles: ["employee"] },
|
||||
{ key: "manager", label: "主管", roles: ["manager"] },
|
||||
{ key: "manager", label: "直属经理", roles: ["employee", "manager"] },
|
||||
{ key: "owner", label: "公司负责人", roles: ["employee", "ceo"] },
|
||||
{ key: "finance", label: "财务", roles: ["employee", "finance"] },
|
||||
];
|
||||
|
||||
export function CreateUserDialog({
|
||||
@@ -192,6 +192,7 @@ export function CreateUserDialog({
|
||||
<input
|
||||
data-testid={`users-create-role-${item.code}`}
|
||||
type="checkbox"
|
||||
disabled={item.disabled}
|
||||
checked={createRoles.has(item.code)}
|
||||
onChange={(event) => {
|
||||
setCreateRoles((prev) => {
|
||||
@@ -205,7 +206,7 @@ export function CreateUserDialog({
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<span>{item.label}</span>
|
||||
<span className={item.disabled ? "text-zinc-400" : undefined}>{item.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -13,18 +13,25 @@ export interface UserRoleDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
user: UserSummary | null;
|
||||
roleOptions: Array<{ code: string; label: string }>;
|
||||
roleOptions: Array<{ code: string; label: string; disabled?: boolean }>;
|
||||
onSubmit: (roles: string[]) => void;
|
||||
isPending: boolean;
|
||||
}
|
||||
|
||||
const ROLE_PRESETS = [
|
||||
{ key: "owner", label: "老板", roles: ["ceo"] },
|
||||
{ key: "admin", label: "管理员", roles: ["admin"] },
|
||||
{ key: "employee", label: "员工", roles: ["employee"] },
|
||||
{ key: "manager", label: "主管", roles: ["manager"] },
|
||||
{ key: "manager", label: "直属经理", roles: ["employee", "manager"] },
|
||||
{ key: "owner", label: "公司负责人", roles: ["employee", "ceo"] },
|
||||
{ key: "finance", label: "财务", roles: ["employee", "finance"] },
|
||||
];
|
||||
|
||||
const DAILY_ROLE_CODES = new Set(["employee", "manager", "ceo", "finance"]);
|
||||
|
||||
export function mergeDailyRolePreset(currentRoles: Iterable<string>, presetRoles: string[]) {
|
||||
const preservedRoles = Array.from(currentRoles).filter((roleCode) => !DAILY_ROLE_CODES.has(roleCode));
|
||||
return new Set([...preservedRoles, ...presetRoles]);
|
||||
}
|
||||
|
||||
export function UserRoleDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -47,6 +54,10 @@ export function UserRoleDialog({
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
function applyPreset(roles: string[]) {
|
||||
setRoleDraft((current) => mergeDailyRolePreset(current, roles));
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
@@ -67,7 +78,7 @@ export function UserRoleDialog({
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{ROLE_PRESETS.map((preset) => (
|
||||
<Button key={preset.key} size="sm" variant="secondary" type="button" onClick={() => setRoleDraft(new Set(preset.roles))}>
|
||||
<Button key={preset.key} size="sm" variant="secondary" type="button" onClick={() => applyPreset(preset.roles)}>
|
||||
预设:{preset.label}
|
||||
</Button>
|
||||
))}
|
||||
@@ -79,6 +90,7 @@ export function UserRoleDialog({
|
||||
<input
|
||||
data-testid={`users-role-${item.code}`}
|
||||
type="checkbox"
|
||||
disabled={item.disabled}
|
||||
checked={roleDraft.has(item.code)}
|
||||
onChange={(event) => {
|
||||
setRoleDraft((prev) => {
|
||||
@@ -92,7 +104,7 @@ export function UserRoleDialog({
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<span>{item.label}</span>
|
||||
<span className={item.disabled ? "text-zinc-400" : undefined}>{item.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
在新工单中引用
屏蔽一个用户