MIF7T-1/MIF7T-7 收口真机反馈并统一移动体验
这个提交包含在:
@@ -1,4 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import { Button } from "../ui/button";
|
||||
@@ -26,14 +28,26 @@ export function MobileDetailHeader({ title, subtitle, status, fallbackTo, testId
|
||||
const currentPath = `${location.pathname}${location.search}`;
|
||||
const from = isSafeMobileSource(state?.from, currentPath) ? state?.from : null;
|
||||
|
||||
useEffect(() => {
|
||||
const scrollToTop = () => {
|
||||
window.scrollTo({ top: 0, behavior: "auto" });
|
||||
document.querySelector<HTMLElement>(".hyb-mobile-shell .overflow-y-auto")?.scrollTo({ top: 0, behavior: "auto" });
|
||||
};
|
||||
|
||||
scrollToTop();
|
||||
const frameId = window.requestAnimationFrame(scrollToTop);
|
||||
return () => window.cancelAnimationFrame(frameId);
|
||||
}, [currentPath]);
|
||||
|
||||
const handleBack = () => {
|
||||
navigate(from ?? fallbackTo, { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="mobile-detail-header flex min-h-12 items-center gap-3" data-testid={testId}>
|
||||
<Button className="shrink-0 px-3" size="sm" type="button" variant="secondary" onClick={handleBack}>
|
||||
返回
|
||||
<Button className="min-h-11 shrink-0 gap-1 px-3" size="sm" type="button" variant="secondary" onClick={handleBack}>
|
||||
<ArrowLeft aria-hidden="true" className="size-4" />
|
||||
<span>返回</span>
|
||||
</Button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-base font-semibold text-zinc-900">{title}</div>
|
||||
|
||||
@@ -12,8 +12,10 @@ import {
|
||||
IdCard,
|
||||
Network,
|
||||
PackageSearch,
|
||||
QrCode,
|
||||
ReceiptText,
|
||||
Settings,
|
||||
ShoppingCart,
|
||||
UserCog,
|
||||
UserRound,
|
||||
Users,
|
||||
@@ -34,9 +36,11 @@ const MOBILE_ICON_MAP: Record<string, LucideIcon> = {
|
||||
notifications: Bell,
|
||||
person: UserRound,
|
||||
picture_as_pdf: FileText,
|
||||
qr_code_scanner: QrCode,
|
||||
receipt_long: ReceiptText,
|
||||
request_quote: FileCheck2,
|
||||
settings: Settings,
|
||||
shopping_cart: ShoppingCart,
|
||||
workspaces: BriefcaseBusiness,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export function PageScaffold({ className, children }: { className?: string; children: ReactNode }) {
|
||||
return <div className={cn("page-scaffold", className)}>{children}</div>;
|
||||
type PageScaffoldProps = ComponentPropsWithoutRef<"div">;
|
||||
|
||||
export function PageScaffold({ className, children, ...props }: PageScaffoldProps) {
|
||||
return (
|
||||
<div className={cn("page-scaffold", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,8 +15,21 @@ export const MOBILE_ASSET_SCAN_PERMISSIONS = [
|
||||
"asset.inventory",
|
||||
] as const;
|
||||
|
||||
export const MOBILE_HOME_ASSET_SCAN_PERMISSIONS = [
|
||||
"asset.manage",
|
||||
"asset.transfer",
|
||||
"asset.inventory",
|
||||
] as const;
|
||||
|
||||
export const MOBILE_LEAVE_PERMISSIONS = ["leave.view_own", "leave.view_all"] as const;
|
||||
|
||||
export const MOBILE_PURCHASE_PERMISSIONS = [
|
||||
"procurement.create",
|
||||
"procurement.view_own",
|
||||
"procurement.view_all",
|
||||
"procurement.manage",
|
||||
] as const;
|
||||
|
||||
export const MOBILE_REIMBURSEMENT_PERMISSIONS = ["expense.view_own", "expense.view_all"] as const;
|
||||
|
||||
export type MobileNavItem = {
|
||||
|
||||
@@ -29,7 +29,8 @@ const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [
|
||||
{ path: "/mobile/capture", title: "移动 PDF 发票上传", group: "移动工作台" },
|
||||
{ path: "/mobile/leave", title: "移动请假", group: "移动工作台" },
|
||||
{ path: "/mobile/me", title: "移动我的", group: "移动工作台" },
|
||||
{ path: "/mobile/purchase-requests", title: "移动采购详情", group: "移动工作台" },
|
||||
{ path: "/mobile/purchase-requests/", title: "移动采购详情", group: "移动工作台" },
|
||||
{ path: "/mobile/purchase-requests", title: "移动采购", group: "移动工作台" },
|
||||
{ path: "/mobile/reimbursements", title: "移动报销", group: "移动工作台" },
|
||||
{ path: "/mobile", title: "移动首页", group: "移动工作台" },
|
||||
{ path: "/settings/profile", title: "个人设置", group: "账号设置" },
|
||||
|
||||
@@ -128,7 +128,7 @@ export function resolveMobileEquivalentPath(pathname: string, search = "") {
|
||||
if (purchaseRequestId) {
|
||||
return `/mobile/purchase-requests/${encodeSegment(purchaseRequestId)}`;
|
||||
}
|
||||
return "/mobile";
|
||||
return "/mobile/purchase-requests";
|
||||
}
|
||||
|
||||
const purchaseRequestDetailMatch = normalizedPath.match(/^\/purchase-requests\/([^/]+)$/);
|
||||
|
||||
@@ -940,6 +940,7 @@ export function MobileApprovalsPage() {
|
||||
const listTitle = taskTab === "pending" ? "待我审批" : "已处理";
|
||||
const allVisibleSelected =
|
||||
filteredTaskCards.length > 0 && filteredTaskCards.every((task) => selectedBatchTaskIds.includes(task.id));
|
||||
const shouldShowDraftQueue = !isOnline || currentQueueItems.length > 0;
|
||||
|
||||
return (
|
||||
<PageScaffold className="space-y-4">
|
||||
@@ -1271,10 +1272,33 @@ export function MobileApprovalsPage() {
|
||||
</div>
|
||||
<StatusBadge status={selectedTask.task.status} label={workflowStatusLabel(selectedTask.task.status)} />
|
||||
</div>
|
||||
<div className="mt-3 space-y-1 text-zinc-600">
|
||||
<div>{joinBusinessMeta([`类型:${entityLabel(selectedTask.workflowInstance?.entityType)}`, `业务状态:${businessStatusLabel(selectedTaskCard?.businessStatus ?? selectedTask.workflowInstance?.currentStatus)}`])}</div>
|
||||
<div>{joinBusinessMeta([selectedApplicantName ? `申请人:${selectedApplicantName}` : null, selectedMetric ? `金额/天数:${selectedMetric}` : null])}</div>
|
||||
{selectedTask.task.completedAt ? <div>完成时间:{formatDateTime(selectedTask.task.completedAt)}</div> : null}
|
||||
<div className="mobile-field-list mt-3">
|
||||
<div className="mobile-field-row">
|
||||
<div className="mobile-field-label">类型</div>
|
||||
<div className="mobile-field-value">{entityLabel(selectedTask.workflowInstance?.entityType)}</div>
|
||||
</div>
|
||||
<div className="mobile-field-row">
|
||||
<div className="mobile-field-label">业务状态</div>
|
||||
<div className="mobile-field-value">{businessStatusLabel(selectedTaskCard?.businessStatus ?? selectedTask.workflowInstance?.currentStatus)}</div>
|
||||
</div>
|
||||
{selectedApplicantName ? (
|
||||
<div className="mobile-field-row">
|
||||
<div className="mobile-field-label">申请人</div>
|
||||
<div className="mobile-field-value">{selectedApplicantName}</div>
|
||||
</div>
|
||||
) : null}
|
||||
{selectedMetric ? (
|
||||
<div className="mobile-field-row">
|
||||
<div className="mobile-field-label">金额/天数</div>
|
||||
<div className="mobile-field-value">{selectedMetric}</div>
|
||||
</div>
|
||||
) : null}
|
||||
{selectedTask.task.completedAt ? (
|
||||
<div className="mobile-field-row">
|
||||
<div className="mobile-field-label">完成时间</div>
|
||||
<div className="mobile-field-value">{formatDateTime(selectedTask.task.completedAt)}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{selectedTask.reimbursement ? (
|
||||
<div className="mt-3 rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
|
||||
@@ -1404,7 +1428,7 @@ export function MobileApprovalsPage() {
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{!isPushDetail ? (
|
||||
{!isPushDetail && shouldShowDraftQueue ? (
|
||||
<section className="mobile-card space-y-3" data-testid="mobile-approvals-queue">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-base font-semibold text-zinc-900">离线草稿</h3>
|
||||
|
||||
+226
-143
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link, useLocation, useParams, useSearchParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
} from "../lib/approvalDisplay";
|
||||
|
||||
type LeaveStatusFilter = "all" | LeaveRequestStatus;
|
||||
type LeaveView = "form" | "records";
|
||||
|
||||
const STATUS_TABS: Array<{ value: LeaveStatusFilter; label: string }> = [
|
||||
{ value: "all", label: "全部" },
|
||||
@@ -53,6 +54,14 @@ function leaveProgressFallback(status: LeaveRequestStatus | undefined) {
|
||||
return "/mobile/approvals?progress=reviewing";
|
||||
}
|
||||
|
||||
function isLeaveStatusFilter(value: string | null): value is LeaveStatusFilter {
|
||||
return ["all", "draft", "reviewing", "approved", "rejected", "cancelled"].includes(value ?? "");
|
||||
}
|
||||
|
||||
function resolveLeaveView(value: string | null): LeaveView {
|
||||
return value === "form" ? "form" : "records";
|
||||
}
|
||||
|
||||
function todayDate() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
@@ -70,20 +79,23 @@ function LeaveRecordCard({
|
||||
isSaving,
|
||||
onEdit,
|
||||
onSubmit,
|
||||
detailTo,
|
||||
currentMobileSource,
|
||||
showActions = true,
|
||||
}: {
|
||||
item: LeaveRequestItem;
|
||||
isSaving: boolean;
|
||||
onEdit: (item: LeaveRequestItem) => void;
|
||||
onSubmit: (item: LeaveRequestItem) => void;
|
||||
detailTo?: string;
|
||||
currentMobileSource?: string;
|
||||
showActions?: boolean;
|
||||
}) {
|
||||
const canEdit = item.status === "draft" || item.status === "rejected";
|
||||
const canSubmit = item.status === "draft" || item.status === "rejected";
|
||||
const approvalHistory = item.approvalHistory ?? [];
|
||||
|
||||
return (
|
||||
<article className="mobile-card" data-testid="mobile-leave-record-card">
|
||||
const content = (
|
||||
<>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-zinc-900">{item.leaveType?.name ?? "请假"}</div>
|
||||
@@ -115,16 +127,26 @@ function LeaveRecordCard({
|
||||
{formatApprovalHistoryTime(approvalHistory[0].createdAt)}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<article className="mobile-card" data-testid="mobile-leave-record-card">
|
||||
{detailTo ? (
|
||||
<Link className="block" to={detailTo} state={{ from: currentMobileSource }}>
|
||||
{content}
|
||||
</Link>
|
||||
) : content}
|
||||
|
||||
{showActions && (canEdit || canSubmit) ? (
|
||||
<div className="mobile-two-action-grid mt-4">
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{canEdit ? (
|
||||
<Button variant="secondary" type="button" disabled={isSaving} onClick={() => onEdit(item)}>
|
||||
<Button size="sm" variant="secondary" type="button" disabled={isSaving} onClick={() => onEdit(item)}>
|
||||
编辑
|
||||
</Button>
|
||||
) : null}
|
||||
{canSubmit ? (
|
||||
<Button type="button" disabled={isSaving} onClick={() => onSubmit(item)}>
|
||||
<Button size="sm" type="button" disabled={isSaving} onClick={() => onSubmit(item)}>
|
||||
提交
|
||||
</Button>
|
||||
) : null}
|
||||
@@ -136,10 +158,16 @@ function LeaveRecordCard({
|
||||
|
||||
export function MobileLeavePage() {
|
||||
const queryClient = useQueryClient();
|
||||
const location = useLocation();
|
||||
const { leaveRequestId = "" } = useParams();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { permissions } = useAuth();
|
||||
const canCreateLeave = hasPermission(permissions, "leave.create");
|
||||
const [statusFilter, setStatusFilter] = useState<LeaveStatusFilter>("all");
|
||||
const [view, setView] = useState<LeaveView>(() => resolveLeaveView(searchParams.get("view")));
|
||||
const [statusFilter, setStatusFilter] = useState<LeaveStatusFilter>(() => {
|
||||
const queryStatus = searchParams.get("status");
|
||||
return isLeaveStatusFilter(queryStatus) ? queryStatus : "all";
|
||||
});
|
||||
const [editingItem, setEditingItem] = useState<LeaveRequestItem | null>(null);
|
||||
const [leaveTypeId, setLeaveTypeId] = useState("");
|
||||
const [startDate, setStartDate] = useState(todayDate);
|
||||
@@ -149,6 +177,19 @@ export function MobileLeavePage() {
|
||||
const [reason, setReason] = useState("");
|
||||
const [attachmentFile, setAttachmentFile] = useState<File | null>(null);
|
||||
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const currentMobileSource = useMemo(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.set("view", view);
|
||||
params.set("status", statusFilter);
|
||||
const query = params.toString();
|
||||
return `${location.pathname}${query ? `?${query}` : ""}`;
|
||||
}, [location.pathname, location.search, statusFilter, view]);
|
||||
|
||||
useEffect(() => {
|
||||
setView(resolveLeaveView(searchParams.get("view")));
|
||||
const queryStatus = searchParams.get("status");
|
||||
setStatusFilter(isLeaveStatusFilter(queryStatus) ? queryStatus : "all");
|
||||
}, [searchParams]);
|
||||
|
||||
const leaveTypesQuery = useQuery({
|
||||
queryKey: ["mobile", "leave", "types"],
|
||||
@@ -273,6 +314,11 @@ export function MobileLeavePage() {
|
||||
const isSaving = createMutation.isPending || updateMutation.isPending || submitMutation.isPending;
|
||||
|
||||
const startEditing = (item: LeaveRequestItem) => {
|
||||
setView("form");
|
||||
const nextParams = new URLSearchParams(searchParams);
|
||||
nextParams.set("view", "form");
|
||||
nextParams.set("status", statusFilter);
|
||||
setSearchParams(nextParams, { replace: true });
|
||||
setEditingItem(item);
|
||||
setLeaveTypeId(item.leaveType?.id ?? "");
|
||||
setStartDate(item.startDate);
|
||||
@@ -284,6 +330,26 @@ export function MobileLeavePage() {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
};
|
||||
|
||||
function handleViewChange(value: string) {
|
||||
const nextView = resolveLeaveView(value);
|
||||
setView(nextView);
|
||||
const nextParams = new URLSearchParams(searchParams);
|
||||
nextParams.set("view", nextView);
|
||||
nextParams.set("status", statusFilter);
|
||||
setSearchParams(nextParams, { replace: true });
|
||||
}
|
||||
|
||||
function handleStatusChange(value: string) {
|
||||
if (!isLeaveStatusFilter(value)) {
|
||||
return;
|
||||
}
|
||||
setStatusFilter(value);
|
||||
const nextParams = new URLSearchParams(searchParams);
|
||||
nextParams.set("view", "records");
|
||||
nextParams.set("status", value);
|
||||
setSearchParams(nextParams, { replace: true });
|
||||
}
|
||||
|
||||
if (leaveRequestId) {
|
||||
const detail = detailQuery.data;
|
||||
|
||||
@@ -318,158 +384,172 @@ export function MobileLeavePage() {
|
||||
<PageScaffold className="space-y-3 pb-4">
|
||||
<PageHeader title="请假" />
|
||||
|
||||
<section className="mobile-card" data-testid="mobile-leave-summary">
|
||||
<div className="mobile-metric-grid text-sm">
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">可用</div>
|
||||
<div className="mt-1 text-2xl font-semibold text-zinc-900">{balanceSummary.available.toFixed(1)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">冻结</div>
|
||||
<div className="mt-1 text-2xl font-semibold text-zinc-900">{balanceSummary.reserved.toFixed(1)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">已用</div>
|
||||
<div className="mt-1 text-2xl font-semibold text-zinc-900">{balanceSummary.used.toFixed(1)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<section className="mobile-card">
|
||||
<Tabs value={view} onValueChange={handleViewChange}>
|
||||
<TabsList className="grid h-auto w-full grid-cols-2">
|
||||
<TabsTrigger value="records" data-testid="mobile-leave-view-records">请假记录</TabsTrigger>
|
||||
<TabsTrigger value="form" data-testid="mobile-leave-view-form">新建请假</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</section>
|
||||
|
||||
<section className="mobile-card" data-testid="mobile-leave-form">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<h3 className="text-base font-semibold text-zinc-900">{editingItem ? "编辑请假" : "新建请假"}</h3>
|
||||
{editingItem ? (
|
||||
<Button variant="secondary" size="sm" type="button" onClick={resetForm}>
|
||||
取消
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{view === "form" ? (
|
||||
<>
|
||||
<section className="mobile-card" data-testid="mobile-leave-summary">
|
||||
<div className="mobile-metric-grid text-sm">
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">可用</div>
|
||||
<div className="mt-1 text-2xl font-semibold text-zinc-900">{balanceSummary.available.toFixed(1)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">冻结</div>
|
||||
<div className="mt-1 text-2xl font-semibold text-zinc-900">{balanceSummary.reserved.toFixed(1)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">已用</div>
|
||||
<div className="mt-1 text-2xl font-semibold text-zinc-900">{balanceSummary.used.toFixed(1)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="block space-y-2 text-sm text-zinc-700">
|
||||
<span>假期类型</span>
|
||||
<select className="select-std" value={leaveTypeId} onChange={(event) => setLeaveTypeId(event.target.value)}>
|
||||
<option value="">请选择</option>
|
||||
{leaveTypes.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<section className="mobile-card" data-testid="mobile-leave-form">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<h3 className="text-base font-semibold text-zinc-900">{editingItem ? "编辑请假" : "新建请假"}</h3>
|
||||
{editingItem ? (
|
||||
<Button variant="secondary" size="sm" type="button" onClick={resetForm}>
|
||||
取消
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mobile-info-grid">
|
||||
<label className="block space-y-2 text-sm text-zinc-700">
|
||||
<span>开始日期</span>
|
||||
<input className="input-std" type="date" value={startDate} onChange={(event) => setStartDate(event.target.value)} />
|
||||
</label>
|
||||
<label className="block space-y-2 text-sm text-zinc-700">
|
||||
<span>结束日期</span>
|
||||
<input className="input-std" type="date" value={endDate} onChange={(event) => setEndDate(event.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<label className="block space-y-2 text-sm text-zinc-700">
|
||||
<span>假期类型</span>
|
||||
<select className="select-std" value={leaveTypeId} onChange={(event) => setLeaveTypeId(event.target.value)}>
|
||||
<option value="">请选择</option>
|
||||
{leaveTypes.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="mobile-info-grid">
|
||||
<label className="block space-y-2 text-sm text-zinc-700">
|
||||
<span>开始时段</span>
|
||||
<select className="select-std" value={startSession} onChange={(event) => setStartSession(event.target.value as LeaveSession)}>
|
||||
<option value="am">上午</option>
|
||||
<option value="pm">下午</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="block space-y-2 text-sm text-zinc-700">
|
||||
<span>结束时段</span>
|
||||
<select className="select-std" value={endSession} onChange={(event) => setEndSession(event.target.value as LeaveSession)}>
|
||||
<option value="am">上午</option>
|
||||
<option value="pm">下午</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mobile-info-grid">
|
||||
<label className="block space-y-2 text-sm text-zinc-700">
|
||||
<span>开始日期</span>
|
||||
<input className="input-std" type="date" value={startDate} onChange={(event) => setStartDate(event.target.value)} />
|
||||
</label>
|
||||
<label className="block space-y-2 text-sm text-zinc-700">
|
||||
<span>结束日期</span>
|
||||
<input className="input-std" type="date" value={endDate} onChange={(event) => setEndDate(event.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="block space-y-2 text-sm text-zinc-700">
|
||||
<span>说明</span>
|
||||
<textarea
|
||||
className="input-std h-24 resize-none"
|
||||
placeholder="请输入请假原因"
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="mobile-info-grid">
|
||||
<label className="block space-y-2 text-sm text-zinc-700">
|
||||
<span>开始时段</span>
|
||||
<select className="select-std" value={startSession} onChange={(event) => setStartSession(event.target.value as LeaveSession)}>
|
||||
<option value="am">上午</option>
|
||||
<option value="pm">下午</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="block space-y-2 text-sm text-zinc-700">
|
||||
<span>结束时段</span>
|
||||
<select className="select-std" value={endSession} onChange={(event) => setEndSession(event.target.value as LeaveSession)}>
|
||||
<option value="am">上午</option>
|
||||
<option value="pm">下午</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="block space-y-2 text-sm text-zinc-700">
|
||||
<span>说明</span>
|
||||
<textarea
|
||||
className="input-std h-24 resize-none"
|
||||
placeholder="请输入请假原因"
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block space-y-2 text-sm text-zinc-700">
|
||||
<span>附件</span>
|
||||
<input
|
||||
ref={attachmentInputRef}
|
||||
className="hidden"
|
||||
type="file"
|
||||
onChange={(event) => setAttachmentFile(event.target.files?.[0] ?? null)}
|
||||
/>
|
||||
<div className="flex items-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 py-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => attachmentInputRef.current?.click()}
|
||||
>
|
||||
选择附件
|
||||
</Button>
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-zinc-500" data-testid="mobile-leave-attachment-name">
|
||||
{attachmentFile?.name ?? "未选择文件"}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="block space-y-2 text-sm text-zinc-700">
|
||||
<span>附件</span>
|
||||
<input
|
||||
ref={attachmentInputRef}
|
||||
className="hidden"
|
||||
type="file"
|
||||
onChange={(event) => setAttachmentFile(event.target.files?.[0] ?? null)}
|
||||
/>
|
||||
<div className="flex items-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 py-2">
|
||||
<Button
|
||||
className="min-h-12 w-full"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => attachmentInputRef.current?.click()}
|
||||
disabled={!canCreateLeave || isSaving || !leaveTypeId || !startDate || !endDate}
|
||||
onClick={() => {
|
||||
if (editingItem) {
|
||||
updateMutation.mutate();
|
||||
return;
|
||||
}
|
||||
createMutation.mutate();
|
||||
}}
|
||||
data-testid="mobile-leave-save"
|
||||
>
|
||||
选择附件
|
||||
{editingItem ? "保存修改" : "创建草稿"}
|
||||
</Button>
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-zinc-500" data-testid="mobile-leave-attachment-name">
|
||||
{attachmentFile?.name ?? "未选择文件"}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<Button
|
||||
className="min-h-12 w-full"
|
||||
type="button"
|
||||
disabled={!canCreateLeave || isSaving || !leaveTypeId || !startDate || !endDate}
|
||||
onClick={() => {
|
||||
if (editingItem) {
|
||||
updateMutation.mutate();
|
||||
return;
|
||||
}
|
||||
createMutation.mutate();
|
||||
}}
|
||||
data-testid="mobile-leave-save"
|
||||
>
|
||||
{editingItem ? "保存修改" : "创建草稿"}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mobile-card" data-testid="mobile-leave-balances">
|
||||
<h3 className="text-base font-semibold text-zinc-900">额度</h3>
|
||||
<div className="mt-3 space-y-2">
|
||||
{balancesQuery.isLoading ? <div className="text-sm text-zinc-500">加载中...</div> : null}
|
||||
{!balancesQuery.isLoading && !balances.length ? <div className="text-sm text-zinc-500">暂无额度</div> : null}
|
||||
{balances.slice(0, 3).map((item) => (
|
||||
<div key={item.leaveType.id} className="mobile-list-card">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="font-medium text-zinc-900">{item.leaveType.name}</div>
|
||||
<span className="badge-success">启用</span>
|
||||
</div>
|
||||
<div className="mobile-metric-grid mt-3 text-sm">
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">可用</div>
|
||||
<div className="font-semibold text-zinc-900">{item.available}</div>
|
||||
<section className="mobile-card" data-testid="mobile-leave-balances">
|
||||
<h3 className="text-base font-semibold text-zinc-900">额度</h3>
|
||||
<div className="mt-3 space-y-2">
|
||||
{balancesQuery.isLoading ? <div className="text-sm text-zinc-500">加载中...</div> : null}
|
||||
{!balancesQuery.isLoading && !balances.length ? <div className="text-sm text-zinc-500">暂无额度</div> : null}
|
||||
{balances.slice(0, 3).map((item) => (
|
||||
<div key={item.leaveType.id} className="mobile-list-card">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="font-medium text-zinc-900">{item.leaveType.name}</div>
|
||||
<span className="badge-success">启用</span>
|
||||
</div>
|
||||
<div className="mobile-metric-grid mt-3 text-sm">
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">可用</div>
|
||||
<div className="font-semibold text-zinc-900">{item.available}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">冻结</div>
|
||||
<div className="font-semibold text-zinc-900">{item.reserved}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">已用</div>
|
||||
<div className="font-semibold text-zinc-900">{item.used}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">冻结</div>
|
||||
<div className="font-semibold text-zinc-900">{item.reserved}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">已用</div>
|
||||
<div className="font-semibold text-zinc-900">{item.used}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{view === "records" ? (
|
||||
<section className="mobile-card" data-testid="mobile-leave-records">
|
||||
<h3 className="text-base font-semibold text-zinc-900">请假记录</h3>
|
||||
<Tabs value={statusFilter} onValueChange={(value) => setStatusFilter(value as LeaveStatusFilter)}>
|
||||
<Tabs value={statusFilter} onValueChange={handleStatusChange}>
|
||||
<TabsList className="mt-3 grid h-auto w-full grid-cols-5">
|
||||
{STATUS_TABS.map((item) => (
|
||||
<TabsTrigger key={item.value} value={item.value} data-testid={`mobile-leave-tab-${item.value}`}>
|
||||
@@ -491,6 +571,8 @@ export function MobileLeavePage() {
|
||||
) : null}
|
||||
{records.map((item) => (
|
||||
<LeaveRecordCard
|
||||
currentMobileSource={currentMobileSource}
|
||||
detailTo={`/mobile/leave/${encodeURIComponent(item.id)}`}
|
||||
item={item}
|
||||
isSaving={isSaving}
|
||||
key={item.id}
|
||||
@@ -500,6 +582,7 @@ export function MobileLeavePage() {
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</PageScaffold>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { MobileIcon } from "../components/mobile/MobileIcon";
|
||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
import { SectionPanel } from "../components/patterns/SectionPanel";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { MOBILE_PURCHASE_PERMISSIONS } from "../features/mobile/navigation";
|
||||
|
||||
function resolveErrorMessage(error: unknown) {
|
||||
if (isApiError(error)) {
|
||||
@@ -62,6 +63,7 @@ export function MobileMePage() {
|
||||
]);
|
||||
const canAccessAssets = hasAnyPermission(permissions, ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]);
|
||||
const canAccessProjects = hasAnyPermission(permissions, ["project.view_own", "project.view_all", "project.manage"]);
|
||||
const canAccessPurchase = hasAnyPermission(permissions, [...MOBILE_PURCHASE_PERMISSIONS]);
|
||||
const canAccessApprovals = hasBusinessApproverRole(me?.roleCodes, permissions);
|
||||
|
||||
const profileQuery = useQuery({
|
||||
@@ -124,6 +126,7 @@ export function MobileMePage() {
|
||||
<MobileActionLink to="/mobile/notifications" label="通知中心" icon="notifications" />
|
||||
<MobileActionLink to="/mobile/leave" label="请假" icon="event_available" />
|
||||
<MobileActionLink to="/mobile/reimbursements" label="报销" icon="request_quote" />
|
||||
{canAccessPurchase ? <MobileActionLink to="/mobile/purchase-requests" label="采购" icon="shopping_cart" /> : null}
|
||||
<MobileActionLink to="/mobile/approvals" label={canAccessApprovals ? "审批" : "审批进度"} icon="fact_check" />
|
||||
{canAccessAssets ? <MobileActionLink to="/mobile/assets/scan" label="资产扫码" icon="inventory_2" /> : null}
|
||||
{canAccessProjects ? <MobileActionLink to="/mobile" label="项目动态" icon="workspaces" /> : null}
|
||||
|
||||
@@ -23,7 +23,9 @@ import { Button } from "../components/ui/button";
|
||||
import {
|
||||
MOBILE_CAPTURE_PERMISSIONS,
|
||||
MOBILE_ASSET_SCAN_PERMISSIONS,
|
||||
MOBILE_HOME_ASSET_SCAN_PERMISSIONS,
|
||||
MOBILE_LEAVE_PERMISSIONS,
|
||||
MOBILE_PURCHASE_PERMISSIONS,
|
||||
MOBILE_REIMBURSEMENT_PERMISSIONS,
|
||||
} from "../features/mobile/navigation";
|
||||
|
||||
@@ -166,7 +168,9 @@ export function MobilePortalPage() {
|
||||
const canUseFinance = hasFinanceRole(me?.roleCodes, permissions);
|
||||
const canAccessCapture = hasAnyPermission(permissions, [...MOBILE_CAPTURE_PERMISSIONS]);
|
||||
const canAccessAssetScan = hasAnyPermission(permissions, [...MOBILE_ASSET_SCAN_PERMISSIONS]);
|
||||
const canShowAssetScanOnHome = hasAnyPermission(permissions, [...MOBILE_HOME_ASSET_SCAN_PERMISSIONS]);
|
||||
const canAccessLeave = hasAnyPermission(permissions, [...MOBILE_LEAVE_PERMISSIONS]);
|
||||
const canAccessPurchase = hasAnyPermission(permissions, [...MOBILE_PURCHASE_PERMISSIONS]);
|
||||
const canAccessReimbursements = hasAnyPermission(permissions, [...MOBILE_REIMBURSEMENT_PERMISSIONS]);
|
||||
const canCreateExpense = hasAnyPermission(permissions, ["expense.create"]);
|
||||
const currentMobileSource = `${location.pathname}${location.search}`;
|
||||
@@ -297,10 +301,19 @@ export function MobilePortalPage() {
|
||||
label: "请假",
|
||||
meta: "提交申请",
|
||||
icon: "event_available",
|
||||
to: "/mobile/leave",
|
||||
to: "/mobile/leave?view=form",
|
||||
}
|
||||
: null,
|
||||
canAccessAssetScan
|
||||
canAccessPurchase
|
||||
? {
|
||||
key: "purchase",
|
||||
label: "采购",
|
||||
meta: "查看申请",
|
||||
icon: "shopping_cart",
|
||||
to: "/mobile/purchase-requests",
|
||||
}
|
||||
: null,
|
||||
canAccessAssetScan && canShowAssetScanOnHome
|
||||
? {
|
||||
key: "asset-scan",
|
||||
label: "资产扫码",
|
||||
@@ -349,7 +362,6 @@ export function MobilePortalPage() {
|
||||
<div className="min-w-0 truncate font-medium text-zinc-900">
|
||||
{reference ? `${businessEntityLabel(entityType, { noun: true })} ${reference}` : businessEntityLabel(entityType, { noun: true })}
|
||||
</div>
|
||||
<span className="badge-warning shrink-0">待处理</span>
|
||||
</div>
|
||||
<div className="mt-1 truncate text-xs text-zinc-500">
|
||||
{joinBusinessMeta([task.businessSummary?.applicantName ? `申请人:${task.businessSummary.applicantName}` : null, task.businessSummary?.amount ?? task.businessSummary?.duration, `节点:${task.stepName}`])}
|
||||
|
||||
@@ -103,11 +103,37 @@ export function MobilePurchaseRequestDetailPage() {
|
||||
|
||||
<section className="mobile-card" data-testid="mobile-purchase-detail-fields">
|
||||
<h3 className="text-base font-semibold text-zinc-900">业务信息</h3>
|
||||
<div className="mt-3 space-y-2 text-sm text-zinc-700">
|
||||
<div>{joinBusinessMeta([`供应商:${detail.supplier?.name ?? "-"}`, `期望到货:${formatDate(detail.expectedDeliveryDate)}`])}</div>
|
||||
<div>{joinBusinessMeta([`法体:${detail.legalEntity?.name ?? "-"}`, `成本中心:${detail.costCenter?.name ?? "-"}`])}</div>
|
||||
<div>{joinBusinessMeta([`预算池:${detail.budgetPool?.name ?? "未匹配"}`, `当前节点:${detail.pendingWorkflow?.task?.stepName ?? "-"}`])}</div>
|
||||
{detail.reason ? <p className="leading-6 text-zinc-500">{detail.reason}</p> : null}
|
||||
<div className="mobile-field-list mt-3">
|
||||
<div className="mobile-field-row">
|
||||
<div className="mobile-field-label">供应商</div>
|
||||
<div className="mobile-field-value">{detail.supplier?.name ?? "-"}</div>
|
||||
</div>
|
||||
<div className="mobile-field-row">
|
||||
<div className="mobile-field-label">期望到货</div>
|
||||
<div className="mobile-field-value">{formatDate(detail.expectedDeliveryDate)}</div>
|
||||
</div>
|
||||
<div className="mobile-field-row">
|
||||
<div className="mobile-field-label">法体</div>
|
||||
<div className="mobile-field-value">{detail.legalEntity?.name ?? "-"}</div>
|
||||
</div>
|
||||
<div className="mobile-field-row">
|
||||
<div className="mobile-field-label">成本中心</div>
|
||||
<div className="mobile-field-value">{detail.costCenter?.name ?? "-"}</div>
|
||||
</div>
|
||||
<div className="mobile-field-row">
|
||||
<div className="mobile-field-label">预算池</div>
|
||||
<div className="mobile-field-value">{detail.budgetPool?.name ?? "未匹配"}</div>
|
||||
</div>
|
||||
<div className="mobile-field-row">
|
||||
<div className="mobile-field-label">当前节点</div>
|
||||
<div className="mobile-field-value">{detail.pendingWorkflow?.task?.stepName ?? "-"}</div>
|
||||
</div>
|
||||
{detail.reason ? (
|
||||
<div className="mobile-field-row">
|
||||
<div className="mobile-field-label">申请说明</div>
|
||||
<div className="mobile-field-value">{detail.reason}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link, useLocation, useSearchParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
fetchPurchaseRequests,
|
||||
type PurchaseRequestStatus,
|
||||
type PurchaseRequestSummary,
|
||||
} from "../api/purchaseRequests";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||
import { resolvePurchaseDisplayCode } from "../lib/approvalDisplay";
|
||||
import { joinBusinessMeta } from "../lib/businessDisplay";
|
||||
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
|
||||
|
||||
const STATUS_TABS: Array<{ value: PurchaseRequestStatus; label: string }> = [
|
||||
{ value: "draft", label: "草稿" },
|
||||
{ value: "reviewing", label: "审批中" },
|
||||
{ value: "approved", label: "已通过" },
|
||||
{ value: "rejected", label: "已驳回" },
|
||||
{ value: "cancelled", label: "已取消" },
|
||||
];
|
||||
|
||||
function isPurchaseStatus(value: string | null): value is PurchaseRequestStatus {
|
||||
return ["draft", "reviewing", "approved", "rejected", "cancelled"].includes(value ?? "");
|
||||
}
|
||||
|
||||
function formatCurrency(value: string | null | undefined, currencyCode = "CNY") {
|
||||
const amount = Number(value ?? 0);
|
||||
if (!Number.isFinite(amount)) {
|
||||
return "-";
|
||||
}
|
||||
return new Intl.NumberFormat("zh-CN", { style: "currency", currency: currencyCode }).format(amount);
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined) {
|
||||
return value ? value.slice(0, 10) : "-";
|
||||
}
|
||||
|
||||
function PurchaseRequestCard({
|
||||
item,
|
||||
currentMobileSource,
|
||||
}: {
|
||||
item: PurchaseRequestSummary;
|
||||
currentMobileSource: string;
|
||||
}) {
|
||||
const displayCode = resolvePurchaseDisplayCode(item);
|
||||
|
||||
return (
|
||||
<Link
|
||||
className="mobile-list-card block"
|
||||
data-testid="mobile-purchase-request-card"
|
||||
state={{ from: currentMobileSource }}
|
||||
to={`/mobile/purchase-requests/${encodeURIComponent(item.id)}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-zinc-900">{displayCode}</div>
|
||||
<div className="mt-1 truncate text-xs text-zinc-500">
|
||||
{joinBusinessMeta([
|
||||
item.title || null,
|
||||
item.applicant?.name ? `申请人:${item.applicant.name}` : null,
|
||||
item.updatedAt ? `更新:${formatDate(item.updatedAt)}` : null,
|
||||
])}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge className="shrink-0" status={item.status} />
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-zinc-600">
|
||||
{joinBusinessMeta([
|
||||
`金额:${formatCurrency(item.totalAmount, item.currencyCode)}`,
|
||||
item.supplier?.name ? `供应商:${item.supplier.name}` : null,
|
||||
item.expectedDeliveryDate ? `到货:${formatDate(item.expectedDeliveryDate)}` : null,
|
||||
])}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobilePurchaseRequestsPage() {
|
||||
const location = useLocation();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [status, setStatus] = useState<PurchaseRequestStatus>(() => {
|
||||
const queryStatus = searchParams.get("status");
|
||||
return isPurchaseStatus(queryStatus) ? queryStatus : "draft";
|
||||
});
|
||||
|
||||
const currentMobileSource = useMemo(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.set("status", status);
|
||||
const query = params.toString();
|
||||
return `${location.pathname}${query ? `?${query}` : ""}`;
|
||||
}, [location.pathname, location.search, status]);
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: ["mobile", "purchase-requests", status],
|
||||
queryFn: () => fetchPurchaseRequests({ mine: true, status, page: 1, pageSize: 20 }),
|
||||
});
|
||||
|
||||
function handleStatusChange(value: string) {
|
||||
if (!isPurchaseStatus(value)) {
|
||||
return;
|
||||
}
|
||||
setStatus(value);
|
||||
const nextParams = new URLSearchParams(searchParams);
|
||||
nextParams.set("status", value);
|
||||
setSearchParams(nextParams, { replace: true });
|
||||
}
|
||||
|
||||
const items = listQuery.data?.items ?? [];
|
||||
const totalAmount = useMemo(
|
||||
() => items.reduce((sum, item) => sum + Number(item.totalAmount ?? 0), 0),
|
||||
[items],
|
||||
);
|
||||
|
||||
return (
|
||||
<PageScaffold className="space-y-4">
|
||||
<PageHeader title="采购" />
|
||||
|
||||
<section className="mobile-card">
|
||||
<div className="mobile-info-grid">
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">当前列表</div>
|
||||
<div className="mt-1 text-2xl font-semibold text-zinc-900">{listQuery.data?.total ?? 0}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">当前金额</div>
|
||||
<div className="mt-1 text-2xl font-semibold text-zinc-900">{formatCurrency(String(totalAmount))}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mobile-card" data-testid="mobile-purchase-requests-list">
|
||||
<Tabs value={status} onValueChange={handleStatusChange}>
|
||||
<TabsList className="grid h-auto w-full grid-cols-3 gap-1 min-[390px]:grid-cols-5">
|
||||
{STATUS_TABS.map((item) => (
|
||||
<TabsTrigger key={item.value} value={item.value} data-testid={`mobile-purchase-tab-${item.value}`}>
|
||||
{item.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
{listQuery.isLoading ? <div className="mobile-empty-state">加载中...</div> : null}
|
||||
{listQuery.isError ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">
|
||||
{resolveErrorMessage(listQuery.error)}
|
||||
</div>
|
||||
) : null}
|
||||
{!listQuery.isLoading && !listQuery.isError && !items.length ? (
|
||||
<div className="mobile-empty-state">暂无采购申请</div>
|
||||
) : null}
|
||||
{items.map((item) => (
|
||||
<PurchaseRequestCard currentMobileSource={currentMobileSource} item={item} key={item.id} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</PageScaffold>
|
||||
);
|
||||
}
|
||||
|
||||
export default MobilePurchaseRequestsPage;
|
||||
@@ -67,51 +67,45 @@ function ReimbursementCard({
|
||||
isDeleting: boolean;
|
||||
onDelete: (item: ReimbursementListItem) => void;
|
||||
}) {
|
||||
const actionLabel = item.status === "draft" || item.status === "rejected" ? "继续编辑" : "查看详情";
|
||||
const displayCode = resolveReimbursementDisplayCode(item);
|
||||
|
||||
return (
|
||||
<article className="mobile-card" data-testid="mobile-reimbursement-card">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-zinc-900">{displayCode}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">{formatDate(item.reimbursementDate)}</div>
|
||||
<Link className="block" to={`/mobile/reimbursements/${item.id}`} state={{ from: currentMobileSource }}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-zinc-900">{displayCode}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">{formatDate(item.reimbursementDate)}</div>
|
||||
</div>
|
||||
<span className="badge-neutral shrink-0">{STATUS_LABEL[item.status]}</span>
|
||||
</div>
|
||||
<span className="badge-neutral shrink-0">{STATUS_LABEL[item.status]}</span>
|
||||
</div>
|
||||
|
||||
<div className="mobile-info-grid mt-4 text-sm">
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">金额</div>
|
||||
<div className="mt-1 text-lg font-semibold text-zinc-900">{formatCurrency(item.totalAmount)}</div>
|
||||
<div className="mt-3 text-sm text-zinc-600">
|
||||
金额:{formatCurrency(item.totalAmount)}
|
||||
{" · "}
|
||||
报销人:{item.owner?.name ?? item.submitter?.name ?? "-"}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">报销人</div>
|
||||
<div className="mt-1 truncate text-zinc-900">{item.owner?.name ?? item.submitter?.name ?? "-"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{item.description ? <p className="mt-3 line-clamp-2 text-sm text-zinc-500">{item.description}</p> : null}
|
||||
{item.description ? <p className="mt-2 line-clamp-2 text-sm text-zinc-500">{item.description}</p> : null}
|
||||
</Link>
|
||||
|
||||
<div className={canDeleteDraft ? "mobile-two-action-grid mt-4" : "mt-4"}>
|
||||
<Button asChild className="w-full" type="button">
|
||||
<Link to={`/mobile/reimbursements/${item.id}`} state={{ from: currentMobileSource }}>
|
||||
{actionLabel}
|
||||
</Link>
|
||||
</Button>
|
||||
{canDeleteDraft ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
data-testid="mobile-reimbursement-delete"
|
||||
disabled={isDeleting}
|
||||
onClick={() => onDelete(item)}
|
||||
type="button"
|
||||
variant="destructive"
|
||||
>
|
||||
删除草稿
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{canDeleteDraft ? (
|
||||
<details className="mt-3 text-sm">
|
||||
<summary className="cursor-pointer select-none text-zinc-500">更多</summary>
|
||||
<div className="mt-2">
|
||||
<Button
|
||||
data-testid="mobile-reimbursement-delete"
|
||||
disabled={isDeleting}
|
||||
onClick={() => onDelete(item)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="destructive"
|
||||
>
|
||||
删除草稿
|
||||
</Button>
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
MobilePortalPage,
|
||||
MobileProfilePage,
|
||||
MobilePurchaseRequestDetailPage,
|
||||
MobilePurchaseRequestsPage,
|
||||
MobileReimbursementDetailPage,
|
||||
MobileReimbursementsPage,
|
||||
NotificationChannelsPage,
|
||||
@@ -355,6 +356,17 @@ export function AppRouter() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="mobile/purchase-requests"
|
||||
element={
|
||||
<RequirePermission
|
||||
anyOf={["procurement.create", "procurement.view_own", "procurement.view_all", "procurement.manage"]}
|
||||
>
|
||||
{withRouteFallback(<MobilePurchaseRequestsPage />)}
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="mobile/purchase-requests/:purchaseRequestId"
|
||||
element={
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { MobilePurchaseRequestsPage as default } from "../../pages/MobilePurchaseRequestsPage";
|
||||
@@ -24,6 +24,7 @@ export const MobileNotificationsPage = lazy(() => import("./MobileNotificationsP
|
||||
export const MobilePortalPage = lazy(() => import("./MobilePortalPage"));
|
||||
export const MobileProfilePage = lazy(() => import("./MobileProfilePage"));
|
||||
export const MobilePurchaseRequestDetailPage = lazy(() => import("./MobilePurchaseRequestDetailPage"));
|
||||
export const MobilePurchaseRequestsPage = lazy(() => import("./MobilePurchaseRequestsPage"));
|
||||
export const MobileReimbursementDetailPage = lazy(() => import("./MobileReimbursementDetailPage"));
|
||||
export const MobileReimbursementsPage = lazy(() => import("./MobileReimbursementsPage"));
|
||||
export const NotificationChannelsPage = lazy(() => import("./NotificationChannelsPage"));
|
||||
|
||||
@@ -248,6 +248,32 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mobile-field-list {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mobile-field-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(4.5rem, 34%) minmax(0, 1fr);
|
||||
gap: 0.5rem;
|
||||
align-items: start;
|
||||
min-width: 0;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
|
||||
.mobile-field-label {
|
||||
color: rgb(113 113 122);
|
||||
}
|
||||
|
||||
.mobile-field-value {
|
||||
min-width: 0;
|
||||
color: rgb(39 39 42);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.mobile-metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 5.75rem), 1fr));
|
||||
|
||||
在新工单中引用
屏蔽一个用户