R5-M1-M5 收口正式审批规则与移动反馈
这个提交包含在:
@@ -163,6 +163,18 @@ export function mapApiErrorMessage(
|
||||
return "不能将任务转审给自己。";
|
||||
}
|
||||
|
||||
if (details?.workflow === "self_approval_not_allowed") {
|
||||
return "自己的申请不能审批,请等待下一审批人处理。";
|
||||
}
|
||||
|
||||
if (details?.workflow === "business_approval_role_required") {
|
||||
return "系统管理员不自动获得业务审批权,请配置正式审批角色后处理。";
|
||||
}
|
||||
|
||||
if (details?.workflow === "no_actionable_approver") {
|
||||
return "当前审批规则未找到审批人,请联系管理员修正规则。";
|
||||
}
|
||||
|
||||
if (details?.toApproverId === "target_not_approver") {
|
||||
return "目标人员不具备审批权限。";
|
||||
}
|
||||
|
||||
@@ -16,12 +16,17 @@ function buildInitials(name: string) {
|
||||
return "员工";
|
||||
}
|
||||
|
||||
const cjkMatch = trimmed.match(/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u);
|
||||
if (cjkMatch?.[0]) {
|
||||
return cjkMatch[0];
|
||||
}
|
||||
|
||||
if (/^[A-Za-z0-9 ]+$/.test(trimmed)) {
|
||||
const segments = trimmed.split(/\s+/).filter(Boolean);
|
||||
return segments.slice(0, 2).map((item) => item[0]?.toUpperCase() ?? "").join("") || trimmed.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
return trimmed.slice(0, 2);
|
||||
return Array.from(trimmed).slice(0, 2).join("");
|
||||
}
|
||||
|
||||
export function EmployeeAvatar({
|
||||
|
||||
@@ -20,7 +20,7 @@ export const MOBILE_LEAVE_PERMISSIONS = ["leave.view_own", "leave.view_all"] as
|
||||
export const MOBILE_REIMBURSEMENT_PERMISSIONS = ["expense.view_own", "expense.view_all"] as const;
|
||||
|
||||
export type MobileNavItem = {
|
||||
key: "home" | "approvals" | "capture" | "me";
|
||||
key: "home" | "approvals" | "reimbursements" | "me";
|
||||
to: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
@@ -30,6 +30,6 @@ export type MobileNavItem = {
|
||||
export const MOBILE_NAV_ITEMS: MobileNavItem[] = [
|
||||
{ key: "home", to: "/mobile", label: "首页", icon: "home" },
|
||||
{ key: "approvals", to: "/mobile/approvals", label: "审批", icon: "fact_check", anyOf: MOBILE_APPROVAL_PERMISSIONS },
|
||||
{ key: "capture", to: "/mobile/capture", label: "票据", icon: "receipt_long", anyOf: MOBILE_CAPTURE_PERMISSIONS },
|
||||
{ key: "reimbursements", to: "/mobile/reimbursements", label: "报销", icon: "request_quote", anyOf: MOBILE_REIMBURSEMENT_PERMISSIONS },
|
||||
{ key: "me", to: "/mobile/me", label: "我的", icon: "person" },
|
||||
];
|
||||
|
||||
@@ -11,6 +11,8 @@ import { resolveMobileEquivalentPath, shouldPreferMobileWorkplace } from "../lib
|
||||
export function AppShellLayout() {
|
||||
const location = useLocation();
|
||||
const isMobileRoute = location.pathname.startsWith("/mobile");
|
||||
const isMobileDeepRoute = /^\/mobile\/reimbursements\/[^/]+$/.test(location.pathname);
|
||||
const showMobileBottomNav = isMobileRoute && !isMobileDeepRoute;
|
||||
const mobileEquivalentPath = !isMobileRoute && shouldPreferMobileWorkplace()
|
||||
? resolveMobileEquivalentPath(location.pathname, location.search)
|
||||
: null;
|
||||
@@ -42,7 +44,7 @@ export function AppShellLayout() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isMobileRoute ? <MobileBottomNav /> : null}
|
||||
{showMobileBottomNav ? <MobileBottomNav /> : null}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -758,9 +758,12 @@ export function MobileReimbursementDetailPage() {
|
||||
}
|
||||
|
||||
const displayCode = resolveReimbursementDisplayCode(detail);
|
||||
const showSubmitAction = Boolean(canEdit && canSubmitExpense);
|
||||
const showStickyActions = Boolean(showSubmitAction || canOpenInvoiceDialog);
|
||||
const showSecondaryActions = Boolean(actionError || canDeleteDraft || !canEdit);
|
||||
|
||||
return (
|
||||
<PageScaffold className="space-y-4 pb-4" data-testid="mobile-reimbursement-detail">
|
||||
<PageScaffold className={cn("space-y-4", showStickyActions ? "pb-28" : "pb-6")} data-testid="mobile-reimbursement-detail">
|
||||
<header className="flex items-center gap-3" data-testid="mobile-reimbursement-detail">
|
||||
<Button asChild size="sm" variant="secondary">
|
||||
<Link to="/mobile/reimbursements">返回</Link>
|
||||
@@ -1058,39 +1061,60 @@ export function MobileReimbursementDetailPage() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-reimbursement-actions">
|
||||
<h2 className="text-base font-semibold text-zinc-900">操作</h2>
|
||||
{actionError ? (
|
||||
<div className="mt-3 whitespace-pre-wrap rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{actionError}
|
||||
{showSecondaryActions ? (
|
||||
<section className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-reimbursement-actions">
|
||||
<h2 className="text-base font-semibold text-zinc-900">操作</h2>
|
||||
{actionError ? (
|
||||
<div className="mt-3 whitespace-pre-wrap rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{actionError}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-3 grid gap-2">
|
||||
{canDeleteDraft ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={deleteReimbursementMutation.isPending}
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => setConfirmDeleteReimbursement(true)}
|
||||
>
|
||||
删除草稿
|
||||
</Button>
|
||||
) : null}
|
||||
{!canEdit ? <div className="text-sm text-zinc-500">当前状态不可编辑。</div> : null}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{showStickyActions ? (
|
||||
<div
|
||||
className="sticky bottom-0 z-20 -mx-4 border-t border-zinc-200 bg-white/95 px-4 pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3 shadow-[0_-10px_24px_rgba(15,23,42,0.10)] backdrop-blur"
|
||||
data-testid="mobile-reimbursement-sticky-actions"
|
||||
>
|
||||
<div className={cn("grid gap-2", canOpenInvoiceDialog && showSubmitAction ? "grid-cols-2" : "grid-cols-1")}>
|
||||
{canOpenInvoiceDialog ? (
|
||||
<Button
|
||||
data-testid="mobile-reimbursement-sticky-invoice-add"
|
||||
variant={showSubmitAction ? "secondary" : "default"}
|
||||
type="button"
|
||||
onClick={() => closeInvoiceDialog(true)}
|
||||
>
|
||||
添加发票
|
||||
</Button>
|
||||
) : null}
|
||||
{showSubmitAction ? (
|
||||
<Button
|
||||
data-testid={TEST_IDS.reimbursement.submit}
|
||||
disabled={submitMutation.isPending}
|
||||
type="button"
|
||||
onClick={() => void handleSubmit()}
|
||||
>
|
||||
{submitMutation.isPending ? "提交中..." : "提交审批"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-3 grid gap-2">
|
||||
{canEdit && canSubmitExpense ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
data-testid={TEST_IDS.reimbursement.submit}
|
||||
disabled={submitMutation.isPending}
|
||||
type="button"
|
||||
onClick={() => void handleSubmit()}
|
||||
>
|
||||
{submitMutation.isPending ? "提交中..." : "提交审批"}
|
||||
</Button>
|
||||
) : null}
|
||||
{canDeleteDraft ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={deleteReimbursementMutation.isPending}
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => setConfirmDeleteReimbursement(true)}
|
||||
>
|
||||
删除草稿
|
||||
</Button>
|
||||
) : null}
|
||||
{!canEdit ? <div className="text-sm text-zinc-500">当前状态不可编辑。</div> : null}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<Dialog open={isInvoiceDialogOpen} onOpenChange={closeInvoiceDialog}>
|
||||
<DialogContent
|
||||
|
||||
在新工单中引用
屏蔽一个用户