完成五人团队正式内用最小收口(07-18-small-team-internal-readiness)
这个提交包含在:
@@ -0,0 +1,99 @@
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import { Button } from "../ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../ui/dialog";
|
||||
import { Textarea } from "../ui/textarea";
|
||||
|
||||
type AttachmentDeleteConfirmDialogProps = {
|
||||
open: boolean;
|
||||
targetName?: string;
|
||||
title?: string;
|
||||
description?: ReactNode;
|
||||
pending?: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: (reason: string) => void | Promise<void>;
|
||||
};
|
||||
|
||||
export function AttachmentDeleteConfirmDialog({
|
||||
open,
|
||||
targetName,
|
||||
title = "确认删除附件?",
|
||||
description,
|
||||
pending = false,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
}: AttachmentDeleteConfirmDialogProps) {
|
||||
const [reason, setReason] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setReason("");
|
||||
setError("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const confirm = async () => {
|
||||
const normalizedReason = reason.trim();
|
||||
if (normalizedReason.length < 2) {
|
||||
setError("请填写至少 2 个字的删除理由");
|
||||
return;
|
||||
}
|
||||
await onConfirm(normalizedReason);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md p-5" data-testid="attachment-delete-confirm-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{description ?? (
|
||||
<>
|
||||
{targetName ? `附件:${targetName}。` : "该附件"}
|
||||
删除后将从当前业务单据移除,且无法在 OA 内恢复。
|
||||
</>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="mt-4 space-y-2">
|
||||
<Textarea
|
||||
aria-label="删除理由"
|
||||
value={reason}
|
||||
rows={3}
|
||||
maxLength={200}
|
||||
placeholder="例如:重复上传、文件有误,需重新提供"
|
||||
onChange={(event) => {
|
||||
setReason(event.target.value);
|
||||
if (error) setError("");
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className={error ? "text-red-600" : "text-zinc-500"}>{error || "对象、后果和理由会随删除操作留痕。"}</span>
|
||||
<span className="text-zinc-400">{reason.trim().length}/200</span>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" disabled={pending} onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={pending}
|
||||
data-testid="attachment-delete-confirm-submit"
|
||||
onClick={() => void confirm()}
|
||||
>
|
||||
{pending ? "删除中..." : "确认删除"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import {
|
||||
createContext,
|
||||
type PropsWithChildren,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useBlocker } from "react-router-dom";
|
||||
import { ConfirmDialog } from "./ConfirmDialog";
|
||||
|
||||
type GuardRegistration = {
|
||||
dirty: boolean;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type UnsavedChangesContextValue = {
|
||||
register: (id: string, registration: GuardRegistration | null) => void;
|
||||
runGuarded: (action: () => void) => void;
|
||||
};
|
||||
|
||||
const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(null);
|
||||
|
||||
export function UnsavedChangesProvider({ children }: PropsWithChildren) {
|
||||
const registrationsRef = useRef(new Map<string, GuardRegistration>());
|
||||
const pendingActionRef = useRef<(() => void) | null>(null);
|
||||
const allowNextNavigationRef = useRef(false);
|
||||
const confirmingRef = useRef(false);
|
||||
const [revision, setRevision] = useState(0);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
|
||||
const dirtyRegistrations = useMemo(
|
||||
() => [...registrationsRef.current.values()].filter((registration) => registration.dirty),
|
||||
[revision],
|
||||
);
|
||||
const hasDirtyChanges = dirtyRegistrations.length > 0;
|
||||
const dirtyLabel = dirtyRegistrations[0]?.label || "当前表单";
|
||||
const hasDirtyChangesRef = useRef(hasDirtyChanges);
|
||||
|
||||
useEffect(() => {
|
||||
hasDirtyChangesRef.current = hasDirtyChanges;
|
||||
}, [hasDirtyChanges]);
|
||||
|
||||
const shouldBlock = useCallback(
|
||||
() => hasDirtyChangesRef.current && !allowNextNavigationRef.current,
|
||||
[],
|
||||
);
|
||||
const blocker = useBlocker(shouldBlock);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.unsavedChanges = hasDirtyChanges ? "true" : "false";
|
||||
return () => {
|
||||
delete document.documentElement.dataset.unsavedChanges;
|
||||
};
|
||||
}, [hasDirtyChanges]);
|
||||
|
||||
const register = useCallback((id: string, registration: GuardRegistration | null) => {
|
||||
if (registration) {
|
||||
registrationsRef.current.set(id, registration);
|
||||
} else {
|
||||
registrationsRef.current.delete(id);
|
||||
}
|
||||
setRevision((current) => current + 1);
|
||||
}, []);
|
||||
|
||||
const runGuarded = useCallback((action: () => void) => {
|
||||
if (!hasDirtyChangesRef.current) {
|
||||
action();
|
||||
return;
|
||||
}
|
||||
pendingActionRef.current = action;
|
||||
setDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (blocker.state === "blocked") {
|
||||
setDialogOpen(true);
|
||||
}
|
||||
}, [blocker.state]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
if (!hasDirtyChangesRef.current) return;
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
};
|
||||
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
return () => {
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({ register, runGuarded }), [register, runGuarded]);
|
||||
|
||||
return (
|
||||
<UnsavedChangesContext.Provider value={value}>
|
||||
{children}
|
||||
<ConfirmDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen(open);
|
||||
if (open) return;
|
||||
if (confirmingRef.current) {
|
||||
confirmingRef.current = false;
|
||||
return;
|
||||
}
|
||||
pendingActionRef.current = null;
|
||||
if (blocker.state === "blocked") blocker.reset();
|
||||
}}
|
||||
title="放弃未保存的修改?"
|
||||
description={`${dirtyLabel}尚未保存。离开后,本次修改不会保留。`}
|
||||
confirmLabel="放弃并离开"
|
||||
cancelLabel="继续编辑"
|
||||
variant="destructive"
|
||||
testId="unsaved-changes-dialog"
|
||||
confirmTestId="unsaved-changes-discard"
|
||||
cancelTestId="unsaved-changes-continue"
|
||||
onConfirm={() => {
|
||||
confirmingRef.current = true;
|
||||
const action = pendingActionRef.current;
|
||||
pendingActionRef.current = null;
|
||||
if (blocker.state === "blocked") {
|
||||
blocker.proceed();
|
||||
return;
|
||||
}
|
||||
if (!action) return;
|
||||
allowNextNavigationRef.current = true;
|
||||
try {
|
||||
action();
|
||||
} finally {
|
||||
queueMicrotask(() => {
|
||||
allowNextNavigationRef.current = false;
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</UnsavedChangesContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useUnsavedChangesGuard(dirty: boolean, label: string) {
|
||||
const context = useContext(UnsavedChangesContext);
|
||||
const id = useId();
|
||||
if (!context) {
|
||||
throw new Error("useUnsavedChangesGuard 必须在 UnsavedChangesProvider 内使用");
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
context.register(id, { dirty, label });
|
||||
return () => context.register(id, null);
|
||||
}, [context, dirty, id, label]);
|
||||
|
||||
return context.runGuarded;
|
||||
}
|
||||
@@ -33,8 +33,8 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
{ to: "/purchase-requests", label: "采购", icon: "shopping_cart", anyOf: ["procurement.create", "procurement.view_own", "procurement.view_all", "procurement.manage"] },
|
||||
{ to: "/travel-requests", label: "差旅申请", icon: "flight", anyOf: ["travel.view_all", "travel.manage", "travel.payment.manage"] },
|
||||
{ to: "/budgets", label: "预算管理", icon: "account_balance", anyOf: ["budget.view", "budget.manage"] },
|
||||
{ to: "/payment-batches", label: "付款", icon: "payments", anyOf: ["payment.batch.manage", "risk.review.manage"] },
|
||||
{ to: "/ap-operations", label: "应付账款", icon: "account_balance_wallet", anyOf: ["payment.batch.manage", "risk.review.manage"] },
|
||||
{ to: "/payment-batches", label: "付款", icon: "payments", anyOf: ["payment.batch.manage", "risk.review.manage", "payment.batch.review"] },
|
||||
{ to: "/ap-operations", label: "应付账款", icon: "account_balance_wallet", anyOf: ["payment.batch.manage", "risk.review.manage", "payment.batch.review"] },
|
||||
{ to: "/invoice-pool", label: "票据", icon: "receipt_long", anyOf: ["invoice.manage"] },
|
||||
{ to: "/inventory-checks", label: "资产盘点", icon: "inventory", anyOf: ["asset.inventory"] },
|
||||
{ to: "/dashboard", label: "数据看板", icon: "dashboard", anyOf: ["report.dashboard.view"] },
|
||||
@@ -65,6 +65,12 @@ const MANAGEMENT_ACTIVE_PATHS = [
|
||||
"/audit-logs",
|
||||
];
|
||||
const TEAM_NAVIGATION_ROLE_CODES = ["manager", "ceo", "finance_mgr", "hr", "admin"];
|
||||
const PAYMENT_REVIEWER_ROLE_CODES = ["manager", "ceo"];
|
||||
|
||||
function hasPaymentReviewRole(roleCodes: string[], permissions: string[]) {
|
||||
return roleCodes.some((roleCode) => PAYMENT_REVIEWER_ROLE_CODES.includes(roleCode)) &&
|
||||
hasAnyPermission(permissions, ["payment.batch.review"]);
|
||||
}
|
||||
|
||||
type ResolvedNavItem = {
|
||||
key?: string;
|
||||
@@ -142,13 +148,15 @@ function renderBackupSection(items: ResolvedNavItem[], pathname: string, onNavig
|
||||
}
|
||||
|
||||
function buildFallbackItems(permissions: string[], roleCodes: string[]) {
|
||||
const financeOnlyPaths = new Set(["/payment-batches", "/ap-operations", "/invoice-pool"]);
|
||||
const paymentPaths = new Set(["/payment-batches", "/ap-operations"]);
|
||||
const financeOnlyPaths = new Set(["/invoice-pool"]);
|
||||
const backupOnlyPaths = new Set(BACKUP_NAV_ORDER);
|
||||
const mapItems = (paths: string[]) =>
|
||||
NAV_ITEMS.filter((item) => paths.includes(item.to))
|
||||
.filter((item) => item.to !== "/workflow/tasks" || hasBusinessApproverRole(roleCodes, permissions))
|
||||
.filter((item) => item.to !== "/team" || roleCodes.some((roleCode) => TEAM_NAVIGATION_ROLE_CODES.includes(roleCode)))
|
||||
.filter((item) => !financeOnlyPaths.has(item.to) || hasFinanceRole(roleCodes, permissions) || roleCodes.includes("admin"))
|
||||
.filter((item) => !paymentPaths.has(item.to) || hasFinanceRole(roleCodes, permissions) || hasPaymentReviewRole(roleCodes, permissions))
|
||||
.filter((item) => !financeOnlyPaths.has(item.to) || hasFinanceRole(roleCodes, permissions))
|
||||
.filter((item) => !item.anyOf || hasAnyPermission(permissions, item.anyOf))
|
||||
.filter((item) => paths === BACKUP_NAV_ORDER || !backupOnlyPaths.has(item.to))
|
||||
.map((item) => ({ key: item.to, href: item.to, label: item.label, icon: item.icon }));
|
||||
|
||||
@@ -90,7 +90,17 @@ export function ActionToolbar({ className, children }: WorkspaceChildrenProps) {
|
||||
}
|
||||
|
||||
export function PrimaryTable({ className, children }: WorkspaceChildrenProps) {
|
||||
return <section className={cn("table-container ops-table-focus p-0", className)}>{children}</section>;
|
||||
return (
|
||||
<section
|
||||
className={cn("table-container ops-table-focus max-w-full p-0", className)}
|
||||
role="region"
|
||||
aria-label="数据表格,可横向滚动"
|
||||
tabIndex={0}
|
||||
data-testid="business-table-scroll"
|
||||
>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkspaceEmptyState({
|
||||
|
||||
在新工单中引用
屏蔽一个用户