收敛报销详情并展示五阶段进度(07-27-yang-jun-reimbursement-readiness)
这个提交包含在:
@@ -0,0 +1,82 @@
|
||||
import { AlertCircle, Check, Circle, Clock, ExternalLink } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import type { ReimbursementProcessingProgress as ProcessingProgress } from "../../api/reimbursements";
|
||||
import { formatDisplayDateTime } from "../../lib/displayFormat";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
type Props = {
|
||||
progress: ProcessingProgress;
|
||||
mobile?: boolean;
|
||||
};
|
||||
|
||||
const STATE_TEXT = {
|
||||
completed: "已完成",
|
||||
current: "处理中",
|
||||
pending: "待处理",
|
||||
exception: "需处理",
|
||||
} as const;
|
||||
|
||||
export function ReimbursementProcessingProgress({ progress, mobile = false }: Props) {
|
||||
return (
|
||||
<ol
|
||||
className="space-y-0"
|
||||
data-testid={mobile ? "mobile-reimbursement-processing-progress" : "reimbursement-workflow-progress"}
|
||||
aria-label="报销处理进度"
|
||||
>
|
||||
{progress.stages.map((stage, index) => {
|
||||
const expanded = stage.state === "current" || stage.state === "exception" || Boolean(stage.comment);
|
||||
const Icon = stage.state === "completed"
|
||||
? Check
|
||||
: stage.state === "current"
|
||||
? Clock
|
||||
: stage.state === "exception"
|
||||
? AlertCircle
|
||||
: Circle;
|
||||
const facts = [stage.actorName, stage.occurredAt ? formatDisplayDateTime(stage.occurredAt) : null, stage.result]
|
||||
.filter(Boolean);
|
||||
|
||||
return (
|
||||
<li className="relative flex gap-3" data-state={stage.state} key={stage.key}>
|
||||
{index < progress.stages.length - 1 ? (
|
||||
<span className="absolute left-[15px] top-8 h-[calc(100%-1rem)] w-px bg-zinc-200" aria-hidden="true" />
|
||||
) : null}
|
||||
<span
|
||||
className={cn(
|
||||
"relative z-10 mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full border bg-white",
|
||||
stage.state === "completed" && "border-emerald-300 text-emerald-700",
|
||||
stage.state === "current" && "border-blue-300 bg-blue-50 text-blue-700",
|
||||
stage.state === "exception" && "border-red-300 bg-red-50 text-red-700",
|
||||
stage.state === "pending" && "border-zinc-300 text-zinc-400",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
<div className={cn("min-w-0 flex-1 pb-5", mobile ? "pt-1" : "pt-1.5")}>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-sm">
|
||||
<span className="font-semibold text-zinc-900">{stage.label}</span>
|
||||
{facts.length ? <span className="text-zinc-500">· {facts.join(" · ")}</span> : null}
|
||||
{!stage.result ? <span className="text-xs text-zinc-500">· {STATE_TEXT[stage.state]}</span> : null}
|
||||
</div>
|
||||
{expanded && stage.detail ? <p className="mt-1 text-sm text-zinc-600">{stage.detail}</p> : null}
|
||||
{stage.comment ? (
|
||||
<blockquote className="mt-2 rounded-md border-l-2 border-zinc-300 bg-zinc-50 px-3 py-2 text-sm leading-5 text-zinc-700">
|
||||
{stage.comment}
|
||||
</blockquote>
|
||||
) : null}
|
||||
{stage.targetHref ? (
|
||||
<Link
|
||||
className="mt-2 inline-flex min-h-11 items-center gap-1 text-sm font-medium text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2"
|
||||
to={stage.targetHref}
|
||||
>
|
||||
查看所在付款批次
|
||||
<ExternalLink aria-hidden="true" className="size-4" />
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
@@ -7,10 +7,10 @@ import { isTravelReimbursement } from "../../lib/reimbursementTravel";
|
||||
import { Button } from "../ui/button";
|
||||
import { Input } from "../ui/input";
|
||||
|
||||
type Props = { detail: ReimbursementDetail; reimbursementId: string; editable: boolean; mobile?: boolean };
|
||||
type Props = { detail: ReimbursementDetail; reimbursementId: string; editable: boolean; mobile?: boolean; embedded?: boolean };
|
||||
const emptyForm: ReimbursementTravelSummaryPayload = { travelPurpose: null, travelLocation: null, travelStartDate: null, travelEndDate: null };
|
||||
|
||||
export function ReimbursementTravelSummary({ detail, reimbursementId, editable, mobile = false }: Props) {
|
||||
export function ReimbursementTravelSummary({ detail, reimbursementId, editable, mobile = false, embedded = false }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
@@ -22,7 +22,10 @@ export function ReimbursementTravelSummary({ detail, reimbursementId, editable,
|
||||
travelStartDate: detail.travelSummary?.startDate ?? null,
|
||||
travelEndDate: detail.travelSummary?.endDate ?? null,
|
||||
});
|
||||
}, [detail.travelSummary]);
|
||||
if (editable && !detail.travelSummary?.purpose?.trim()) setEditing(true);
|
||||
else if (editable && !detail.travelSummary?.location?.trim()) setEditing(true);
|
||||
else if (editable && (!detail.travelSummary?.startDate || !detail.travelSummary?.endDate)) setEditing(true);
|
||||
}, [detail.travelSummary, editable]);
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => updateReimbursementTravelSummary(reimbursementId, form),
|
||||
onSuccess: async () => {
|
||||
@@ -34,10 +37,11 @@ export function ReimbursementTravelSummary({ detail, reimbursementId, editable,
|
||||
});
|
||||
if (!isTravel) return null;
|
||||
const summary = detail.travelSummary ?? { purpose: null, location: null, startDate: null, endDate: null };
|
||||
const Container = embedded ? "div" : "section";
|
||||
return (
|
||||
<section className={mobile ? "mobile-card" : "rounded-xl border border-zinc-200 bg-white p-5"} data-testid={mobile ? "mobile-reimbursement-travel-summary" : "reimbursement-travel-summary"}>
|
||||
<Container className={embedded ? "mt-4 border-t border-zinc-200 pt-4" : mobile ? "mobile-card" : "rounded-xl border border-zinc-200 bg-white p-5"} data-testid={mobile ? "mobile-reimbursement-travel-summary" : "reimbursement-travel-summary"}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-base font-semibold text-zinc-900">差旅概要</h2>
|
||||
<h3 className="text-sm font-semibold text-zinc-900">出差信息</h3>
|
||||
{editable && !editing ? <Button size="sm" type="button" variant="secondary" onClick={() => setEditing(true)}>编辑</Button> : null}
|
||||
</div>
|
||||
{editing ? (
|
||||
@@ -61,6 +65,6 @@ export function ReimbursementTravelSummary({ detail, reimbursementId, editable,
|
||||
<div><dt className="text-xs text-zinc-500">结束日期</dt><dd className="mt-1 text-zinc-900">{summary.endDate || "未填写"}</dd></div>
|
||||
</dl>
|
||||
)}
|
||||
</section>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户