MIF7T-1/MIF7T-7 收口真机反馈并统一移动体验
这个提交包含在:
+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>
|
||||
);
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户