609 lines
32 KiB
TypeScript
609 lines
32 KiB
TypeScript
import { formatLocalMonthDayTime } from "@/shared/utils/date-time";
|
||
import { useCallback, useEffect, useRef, useState, type ReactNode, type RefObject } from "react";
|
||
import type { Map as MapLibreMap } from "maplibre-gl";
|
||
import {
|
||
CalendarClock,
|
||
LoaderCircle,
|
||
Pencil,
|
||
Plus,
|
||
RefreshCw,
|
||
Sparkles,
|
||
Trash2,
|
||
X
|
||
} from "lucide-react";
|
||
import { Button } from "@/shared/ui/button";
|
||
import { DateTimeField } from "@/shared/ui/date-time-field";
|
||
import { Disclosure } from "@/shared/ui/disclosure";
|
||
import { FieldSelect } from "@/shared/ui/field-select";
|
||
import { Input } from "@/shared/ui/input";
|
||
import { StatusBadge, type StatusTone } from "@/shared/ui/status";
|
||
import { Textarea } from "@/shared/ui/textarea";
|
||
import { WorkOrderReportPanel } from "../components/scheduled-condition-detail-panel";
|
||
import { WorkbenchNotice } from "../components/workbench-panel-ui";
|
||
import type { ScheduledWorkOrderItem } from "../types";
|
||
import type {
|
||
PeripheralContext,
|
||
PeripheralSampleClient,
|
||
PeripheralWorkOrder
|
||
} from "./client";
|
||
import {
|
||
EventLocationPicker,
|
||
type EventGpsCoordinate
|
||
} from "./event-location-picker";
|
||
import { mergeEventLocation } from "./event-location-coordinate";
|
||
import { useEventLocationAddress, type EventLocationAddressResolver } from "./use-event-location-address";
|
||
|
||
export function ManagedTestResources({ client, canWrite, capabilities, refreshGeneration, mapReady, mapRef, resolveAddress, onAgentTrigger }: {
|
||
client: PeripheralSampleClient;
|
||
canWrite: boolean;
|
||
capabilities: PeripheralContext["managed_resources"];
|
||
refreshGeneration?: number;
|
||
mapReady: boolean;
|
||
mapRef: RefObject<MapLibreMap | null>;
|
||
resolveAddress: EventLocationAddressResolver;
|
||
onAgentTrigger?: (workOrder: PeripheralWorkOrder) => Promise<void>;
|
||
}) {
|
||
const [orders, setOrders] = useState<PeripheralWorkOrder[]>([]);
|
||
const [busy, setBusy] = useState<string | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const refresh = useCallback(async () => {
|
||
if (capabilities.work_orders !== "available") return;
|
||
setBusy("refresh");
|
||
setError(null);
|
||
try {
|
||
setOrders((await client.listWorkOrders()).items);
|
||
} catch (nextError) {
|
||
setError(message(nextError));
|
||
} finally {
|
||
setBusy(null);
|
||
}
|
||
}, [capabilities.work_orders, client]);
|
||
|
||
useEffect(() => {
|
||
void refresh();
|
||
}, [refresh, refreshGeneration]);
|
||
|
||
const mutate = async (key: string, operation: () => Promise<void>) => {
|
||
setBusy(key);
|
||
setError(null);
|
||
try {
|
||
await operation();
|
||
await refresh();
|
||
} catch (nextError) {
|
||
setError(message(nextError));
|
||
setBusy(null);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<section aria-label="工单管理" className="space-y-4">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<h3 className="text-sm font-semibold">工单管理</h3>
|
||
<p className="mt-1 text-xs leading-5 text-muted-foreground">预约安排、投诉调查与爆管处置,按类型查看和维护。</p>
|
||
</div>
|
||
<Button size="icon-sm" variant="ghost" aria-label="刷新工单" disabled={Boolean(busy)} onClick={() => void refresh()}>
|
||
<RefreshCw size={14} className={busy === "refresh" ? "animate-spin" : ""} />
|
||
</Button>
|
||
</div>
|
||
{error ? <WorkbenchNotice role="alert" tone="danger">{error}</WorkbenchNotice> : null}
|
||
{capabilities.work_orders !== "available" ? (
|
||
<WorkbenchNotice tone="warning" title="工单服务未就绪">
|
||
当前项目尚未配置工单存储,请联系管理员。
|
||
</WorkbenchNotice>
|
||
) : (
|
||
<WorkOrders
|
||
orders={orders}
|
||
canWrite={canWrite}
|
||
busy={busy}
|
||
mutate={mutate}
|
||
client={client}
|
||
mapReady={mapReady}
|
||
mapRef={mapRef}
|
||
resolveAddress={resolveAddress}
|
||
onAgentTrigger={onAgentTrigger}
|
||
/>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function WorkOrders({ orders, canWrite, busy, mutate, client, mapReady, mapRef, resolveAddress, onAgentTrigger }: {
|
||
orders: PeripheralWorkOrder[];
|
||
canWrite: boolean;
|
||
busy: string | null;
|
||
mutate: (key: string, operation: () => Promise<void>) => Promise<void>;
|
||
client: PeripheralSampleClient;
|
||
mapReady: boolean;
|
||
mapRef: RefObject<MapLibreMap | null>;
|
||
resolveAddress: EventLocationAddressResolver;
|
||
onAgentTrigger?: (workOrder: PeripheralWorkOrder) => Promise<void>;
|
||
}) {
|
||
const [editorOpen, setEditorOpen] = useState(false);
|
||
const [editing, setEditing] = useState<PeripheralWorkOrder | null>(null);
|
||
const [title, setTitle] = useState("");
|
||
const [summary, setSummary] = useState("");
|
||
const [status, setStatus] = useState<PeripheralWorkOrder["status"]>("draft");
|
||
const [priority, setPriority] = useState<PeripheralWorkOrder["priority"]>("normal");
|
||
const [assignee, setAssignee] = useState("");
|
||
const [location, setLocation] = useState("");
|
||
const [scenarioCode, setScenarioCode] = useState("");
|
||
const [scheduledAt, setScheduledAt] = useState(() => toLocalInput(new Date(Date.now() + 60 * 60_000)));
|
||
const [feedbackRequirements, setFeedbackRequirements] = useState("");
|
||
const [progressNote, setProgressNote] = useState("");
|
||
const [feedbackAt, setFeedbackAt] = useState("");
|
||
const [eventEditorOpen, setEventEditorOpen] = useState(false);
|
||
const [eventKind, setEventKind] = useState<"water_quality_complaint" | "burst_report">("water_quality_complaint");
|
||
const [eventLocation, setEventLocation] = useState("");
|
||
const [eventCoordinate, setEventCoordinate] = useState<EventGpsCoordinate | null>(null);
|
||
const addressLookup = useEventLocationAddress({ active: eventEditorOpen, coordinate: eventCoordinate, resolveAddress, onChange: setEventLocation });
|
||
const [eventOccurredAt, setEventOccurredAt] = useState(toLocalInput(new Date()));
|
||
const [eventDescription, setEventDescription] = useState("");
|
||
const [agentTriggerFailure, setAgentTriggerFailure] = useState<{ message: string; workOrder: PeripheralWorkOrder } | null>(null);
|
||
const [deleteCandidateId, setDeleteCandidateId] = useState<string | null>(null);
|
||
const titleInputRef = useRef<HTMLInputElement>(null);
|
||
const editorTriggerRef = useRef<HTMLButtonElement>(null);
|
||
const editingWorkOrderId = editing?.work_order_id;
|
||
|
||
const clear = () => {
|
||
setEditing(null);
|
||
setEditorOpen(false);
|
||
setTitle("");
|
||
setSummary("");
|
||
setStatus("draft");
|
||
setPriority("normal");
|
||
setAssignee("");
|
||
setLocation("");
|
||
setScenarioCode("");
|
||
setScheduledAt(toLocalInput(new Date(Date.now() + 60 * 60_000)));
|
||
setFeedbackRequirements("");
|
||
setProgressNote("");
|
||
setFeedbackAt("");
|
||
};
|
||
|
||
const closeEditor = () => {
|
||
const trigger = editorTriggerRef.current;
|
||
const editTriggerId = editingWorkOrderId ? `work-order-edit-${editingWorkOrderId}` : null;
|
||
clear();
|
||
requestAnimationFrame(() => {
|
||
if (editTriggerId) document.getElementById(editTriggerId)?.focus();
|
||
else trigger?.focus();
|
||
});
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (!editorOpen || !titleInputRef.current) return;
|
||
titleInputRef.current.focus({ preventScroll: true });
|
||
const target = editingWorkOrderId
|
||
? titleInputRef.current.closest("article")
|
||
: titleInputRef.current;
|
||
target?.scrollIntoView({ block: editingWorkOrderId ? "start" : "nearest" });
|
||
}, [editorOpen, editingWorkOrderId]);
|
||
|
||
const create = (trigger: HTMLButtonElement) => {
|
||
editorTriggerRef.current = trigger;
|
||
clear();
|
||
setEditorOpen(true);
|
||
};
|
||
|
||
const edit = (item: PeripheralWorkOrder, trigger: HTMLButtonElement) => {
|
||
editorTriggerRef.current = trigger;
|
||
setDeleteCandidateId(null);
|
||
setEditing(item);
|
||
setEditorOpen(true);
|
||
setTitle(item.title);
|
||
setSummary(item.summary);
|
||
setStatus(item.status);
|
||
setPriority(item.priority);
|
||
setAssignee(item.assignee ?? "");
|
||
setLocation(item.location ?? "");
|
||
setScenarioCode(item.scenario_code ?? "");
|
||
setScheduledAt(item.scheduled_at ? toLocalInput(new Date(item.scheduled_at)) : "");
|
||
setFeedbackRequirements((item.feedback_requirements ?? []).join("\n"));
|
||
setProgressNote("");
|
||
setFeedbackAt(completedFeedbackTime(item));
|
||
};
|
||
|
||
const triggerAgent = async (workOrder: PeripheralWorkOrder) => {
|
||
if (!onAgentTrigger) {
|
||
setAgentTriggerFailure({ message: "当前工作台未连接 Agent 事件入口。", workOrder });
|
||
return;
|
||
}
|
||
try {
|
||
await onAgentTrigger(workOrder);
|
||
setAgentTriggerFailure(null);
|
||
} catch (error) {
|
||
setAgentTriggerFailure({ message: message(error), workOrder });
|
||
}
|
||
};
|
||
|
||
const createEventWorkOrder = () => void mutate("create-event-order", async () => {
|
||
const waterQuality = eventKind === "water_quality_complaint";
|
||
const submittedLocation = mergeEventLocation(eventLocation, eventCoordinate);
|
||
const workOrder = await client.createWorkOrder({
|
||
title: waterQuality ? `水质投诉:${submittedLocation}` : `爆管上报:${submittedLocation}`,
|
||
summary: eventDescription.trim(),
|
||
status: "open",
|
||
priority: waterQuality ? "high" : "urgent",
|
||
assignee: null,
|
||
scheduled_at: new Date(eventOccurredAt).toISOString(),
|
||
work_order_type: eventKind,
|
||
location: submittedLocation,
|
||
scenario_code: null,
|
||
origin: "system_data",
|
||
feedback_requirements: []
|
||
});
|
||
setEventEditorOpen(false);
|
||
setEventLocation("");
|
||
setEventCoordinate(null);
|
||
setEventDescription("");
|
||
await triggerAgent(workOrder);
|
||
});
|
||
|
||
const feedbackTimeChanged = feedbackAt !== (editing ? completedFeedbackTime(editing) : "");
|
||
const canSave = canWrite && !busy && title.trim() && summary.trim() && (!feedbackTimeChanged || progressNote.trim());
|
||
const editorInputClassName = "mt-1 h-9 rounded-lg border-border bg-card";
|
||
const editingEvent = editing?.work_order_type !== undefined && editing.work_order_type !== "general";
|
||
|
||
const orderEditor = editorOpen ? (
|
||
<section
|
||
id={editing ? `work-order-editor-${editing.work_order_id}` : "new-work-order-editor"}
|
||
aria-label={editing ? `编辑工单 ${editing.title}` : "新增预约工单"}
|
||
className="mt-4 border-t border-border pt-4"
|
||
onKeyDown={(event) => {
|
||
if (event.key === "Escape" && !busy && !event.defaultPrevented
|
||
&& event.currentTarget.contains(event.target as Node)) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
closeEditor();
|
||
}
|
||
}}
|
||
>
|
||
<EditorHeading title={editing ? "编辑工单" : "新增预约工单"} onClose={closeEditor} disabled={Boolean(busy)} />
|
||
<div className="mt-2 grid gap-2 sm:grid-cols-2">
|
||
<label className="block text-xs font-medium text-muted-foreground sm:col-span-2">标题<Input ref={titleInputRef} className={editorInputClassName} value={title} onChange={(event) => setTitle(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
|
||
<label className="block text-xs font-medium text-muted-foreground sm:col-span-2">说明<Textarea className="mt-1 min-h-16" value={summary} onChange={(event) => setSummary(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
|
||
<FieldSelect contentClassName="workbench-shadcn-surface" label="工单状态" value={status} onValueChange={(value) => setStatus(value as typeof status)} disabled={!canWrite || Boolean(busy)} options={WORK_ORDER_STATUS_OPTIONS} />
|
||
<FieldSelect contentClassName="workbench-shadcn-surface" label="优先级" value={priority} onValueChange={(value) => setPriority(value as typeof priority)} disabled={!canWrite || Boolean(busy)} options={PRIORITY_OPTIONS} />
|
||
<label className="block text-xs font-medium text-muted-foreground">负责人<Input className={editorInputClassName} value={assignee} onChange={(event) => setAssignee(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
|
||
<label className="block text-xs font-medium text-muted-foreground">位置<Input className={editorInputClassName} value={location} onChange={(event) => setLocation(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
|
||
<DateTimeField contentClassName="workbench-shadcn-surface" label={editingEvent ? "发生时间" : "预约时间"} value={scheduledAt} onChange={setScheduledAt} disabled={!canWrite || Boolean(busy)} />
|
||
<label className="block text-xs font-medium text-muted-foreground">场景编号<Input className={editorInputClassName} value={scenarioCode} onChange={(event) => setScenarioCode(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
|
||
</div>
|
||
{editing ? <p className="mt-2 text-[11px] text-muted-foreground">工单类型:{workOrderTypeLabel(editing.work_order_type)},创建后不可修改。</p> : null}
|
||
|
||
<label className="mt-3 block text-xs font-medium text-muted-foreground">回执要求(每行一项)<Textarea className="mt-1 min-h-20" value={feedbackRequirements} onChange={(event) => setFeedbackRequirements(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
|
||
{editing ? <label className="mt-3 block text-xs font-medium text-muted-foreground">{editingEvent ? "处置反馈(可选)" : "现场反馈(可选)"}<Textarea className="mt-1 min-h-20" value={progressNote} onChange={(event) => setProgressNote(event.target.value)} disabled={!canWrite || Boolean(busy)} placeholder="记录实际处置、复核或回执;留空则只更新工单信息" /></label> : null}
|
||
{editing ? <div className="mt-3"><DateTimeField contentClassName="workbench-shadcn-surface" label="现场反馈时间(可选)" value={feedbackAt} onChange={setFeedbackAt} disabled={!canWrite || Boolean(busy)} /><p className="mt-1 text-[11px] text-muted-foreground">选择实际反馈时间并填写现场反馈。工单完工后,Agent 从该时间起复核 SCADA;留空使用当前时间。</p></div> : null}
|
||
|
||
<div className="mt-3 flex gap-2">
|
||
<Button size="sm" disabled={!canSave} onClick={() => void mutate("save-order", async () => {
|
||
const requirements = feedbackRequirements.split("\n").map((item) => item.trim()).filter(Boolean);
|
||
if (editing) {
|
||
await client.updateWorkOrder(editing.work_order_id, {
|
||
expected_revision: editing.revision,
|
||
title: title.trim(),
|
||
summary: summary.trim(),
|
||
status,
|
||
priority,
|
||
assignee: assignee.trim() || null,
|
||
clear_assignee: !assignee.trim(),
|
||
scheduled_at: scheduledAt ? new Date(scheduledAt).toISOString() : null,
|
||
clear_scheduled_at: !scheduledAt,
|
||
location: location.trim() || null,
|
||
clear_location: !location.trim(),
|
||
scenario_code: scenarioCode.trim() || null,
|
||
clear_scenario_code: !scenarioCode.trim(),
|
||
feedback_requirements: requirements,
|
||
progress_note: progressNote.trim() || null,
|
||
occurred_at: progressNote.trim() && feedbackAt ? new Date(feedbackAt).toISOString() : null
|
||
});
|
||
} else {
|
||
await client.createWorkOrder({
|
||
title: title.trim(),
|
||
summary: summary.trim(),
|
||
status,
|
||
priority,
|
||
assignee: assignee.trim() || null,
|
||
scheduled_at: scheduledAt ? new Date(scheduledAt).toISOString() : null,
|
||
work_order_type: "general",
|
||
origin: "system_data",
|
||
location: location.trim() || null,
|
||
scenario_code: scenarioCode.trim() || null,
|
||
feedback_requirements: requirements
|
||
});
|
||
}
|
||
closeEditor();
|
||
})}>
|
||
{busy === "save-order" ? <LoaderCircle className="animate-spin" /> : editing ? <Pencil size={13} /> : <Plus size={13} />}
|
||
{editing ? "保存工单" : "创建预约工单"}
|
||
</Button>
|
||
<Button size="sm" variant="ghost" onClick={closeEditor} disabled={Boolean(busy)}>取消</Button>
|
||
</div>
|
||
</section>
|
||
) : null;
|
||
|
||
const orderGroups = [
|
||
{
|
||
id: "appointment",
|
||
title: "预约工单",
|
||
description: "按预约时间记录派发、执行、复令和现场反馈。",
|
||
items: orders.filter((item) => item.work_order_type === "general")
|
||
},
|
||
{
|
||
id: "complaint",
|
||
title: "投诉工单",
|
||
description: "记录水质投诉的发生信息,并触发独立的调查任务。",
|
||
items: orders.filter((item) => item.work_order_type === "water_quality_complaint")
|
||
},
|
||
{
|
||
id: "burst",
|
||
title: "爆管上报工单",
|
||
description: "记录爆管事件信息,并触发独立的调查任务。",
|
||
items: orders.filter((item) => item.work_order_type === "burst_report")
|
||
}
|
||
];
|
||
|
||
return (
|
||
<div>
|
||
<div className="space-y-2">
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button size="sm" disabled={!canWrite || Boolean(busy)} onClick={() => setEventEditorOpen((current) => !current)}>
|
||
<Sparkles size={13} />投诉与爆管上报
|
||
</Button>
|
||
<Button size="sm" variant="outline" disabled={!canWrite || Boolean(busy)} onClick={(event) => create(event.currentTarget)}>
|
||
<Plus size={13} />新增预约工单
|
||
</Button>
|
||
</div>
|
||
<p className="text-xs leading-5 text-muted-foreground">预约工单进入预约任务;投诉与爆管上报进入事件调查。</p>
|
||
</div>
|
||
|
||
{eventEditorOpen ? (
|
||
<div className="mt-4 rounded-lg border border-border bg-muted/30 p-4 sm:p-5">
|
||
<EditorHeading title="模拟事件上报" onClose={() => setEventEditorOpen(false)} />
|
||
<div className="mt-4 grid gap-4 sm:grid-cols-2 [&>div>label]:mb-2 [&>div>label]:leading-4">
|
||
<FieldSelect contentClassName="workbench-shadcn-surface" label="事件类型" value={eventKind} onValueChange={(value) => setEventKind(value as typeof eventKind)} disabled={!canWrite || Boolean(busy)} options={[{ value: "water_quality_complaint", label: "水质投诉" }, { value: "burst_report", label: "爆管上报" }]} />
|
||
<DateTimeField contentClassName="workbench-shadcn-surface" label="发生时间" value={eventOccurredAt} onChange={setEventOccurredAt} disabled={!canWrite || Boolean(busy)} />
|
||
</div>
|
||
<EventLocationPicker location={eventLocation} addressLookup={addressLookup} coordinate={eventCoordinate} onChange={setEventCoordinate} disabled={!canWrite || Boolean(busy)} mapReady={mapReady} mapRef={mapRef} />
|
||
<label className="mt-5 block text-xs font-medium text-muted-foreground">现场描述<Textarea className="mt-2 min-h-20" value={eventDescription} onChange={(event) => setEventDescription(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
|
||
<div className="mt-5 flex gap-2">
|
||
<Button size="sm" disabled={!canWrite || Boolean(busy) || addressLookup.pending || (!eventLocation.trim() && !eventCoordinate) || !eventDescription.trim()} onClick={createEventWorkOrder}>
|
||
{busy === "create-event-order" ? <LoaderCircle className="animate-spin" /> : <Sparkles size={13} />}创建事件工单
|
||
</Button>
|
||
<Button size="sm" variant="ghost" onClick={() => setEventEditorOpen(false)} disabled={Boolean(busy)}>取消</Button>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{agentTriggerFailure ? (
|
||
<WorkbenchNotice className="mt-3" tone="warning" title="工单已创建,调查任务待触发">
|
||
<p>{agentTriggerFailure.message}</p>
|
||
<Button className="mt-2" size="sm" variant="outline" disabled={Boolean(busy)} onClick={() => void mutate("retry-agent-trigger", () => triggerAgent(agentTriggerFailure.workOrder))}>
|
||
<RefreshCw size={13} />重试触发
|
||
</Button>
|
||
</WorkbenchNotice>
|
||
) : null}
|
||
|
||
{editorOpen && !editing ? orderEditor : null}
|
||
|
||
<div aria-label="分类工单列表" className="mt-4 divide-y divide-border">
|
||
{orderGroups.map((group) => (
|
||
<Disclosure
|
||
key={group.id}
|
||
aria-label={`${group.title}列表`}
|
||
initiallyOpen
|
||
className="py-3 [&>summary]:rounded-md [&>summary]:py-1"
|
||
label={(
|
||
<div className="flex min-w-0 items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<h5 className="text-sm font-semibold text-foreground">{group.title}</h5>
|
||
<p className="mt-0.5 text-[11px] font-normal leading-4 text-muted-foreground">{group.description}</p>
|
||
</div>
|
||
<StatusBadge tone="neutral">{group.items.length}</StatusBadge>
|
||
</div>
|
||
)}
|
||
>
|
||
{group.items.length ? (
|
||
<ol className="space-y-3 pt-3">
|
||
{group.items.map((item, index) => (
|
||
<li key={item.work_order_id}>
|
||
<WorkOrderCard
|
||
item={item}
|
||
index={index}
|
||
canWrite={canWrite}
|
||
busy={busy}
|
||
editing={editing?.work_order_id === item.work_order_id}
|
||
editor={editing?.work_order_id === item.work_order_id ? orderEditor : null}
|
||
onEdit={(trigger) => edit(item, trigger)}
|
||
confirmingDelete={deleteCandidateId === item.work_order_id}
|
||
onRequestDelete={() => setDeleteCandidateId(item.work_order_id)}
|
||
onCancelDelete={() => setDeleteCandidateId(null)}
|
||
onConfirmDelete={() => void mutate(`delete-order-${item.work_order_id}`, async () => {
|
||
await client.deleteWorkOrder(item.work_order_id, item.revision);
|
||
setDeleteCandidateId(null);
|
||
if (editing?.work_order_id === item.work_order_id) clear();
|
||
})}
|
||
/>
|
||
</li>
|
||
))}
|
||
</ol>
|
||
) : (
|
||
<p className="py-4 text-xs text-muted-foreground">暂无{group.title}</p>
|
||
)}
|
||
</Disclosure>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function WorkOrderCard({ item, index, canWrite, busy, editing, editor, onEdit, confirmingDelete, onRequestDelete, onCancelDelete, onConfirmDelete }: {
|
||
item: PeripheralWorkOrder;
|
||
index: number;
|
||
canWrite: boolean;
|
||
busy: string | null;
|
||
editing: boolean;
|
||
editor: ReactNode;
|
||
onEdit: (trigger: HTMLButtonElement) => void;
|
||
confirmingDelete: boolean;
|
||
onRequestDelete: () => void;
|
||
onCancelDelete: () => void;
|
||
onConfirmDelete: () => void;
|
||
}) {
|
||
const workOrder = item.work_order_type === "general" ? toScheduledWorkOrder(item) : null;
|
||
const deleteButtonRef = useRef<HTMLButtonElement>(null);
|
||
const cancelDelete = () => {
|
||
onCancelDelete();
|
||
deleteButtonRef.current?.focus();
|
||
};
|
||
return (
|
||
<article className="rounded-lg border border-border bg-card p-3 sm:p-4">
|
||
<div className="flex items-start gap-2">
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex flex-wrap items-center gap-1.5">
|
||
<span className="text-[10px] font-semibold text-muted-foreground">#{String(index + 1).padStart(2, "0")}</span>
|
||
<StatusBadge tone={orderStatusTone(item.status)}>{orderStatus(item.status)}</StatusBadge>
|
||
<StatusBadge tone={item.work_order_type === "burst_report" ? "danger" : item.work_order_type === "water_quality_complaint" ? "warning" : "neutral"}>{workOrderTypeLabel(item.work_order_type)}</StatusBadge>
|
||
</div>
|
||
<h6 className="mt-1 text-balance text-xs font-semibold text-foreground">{item.title}</h6>
|
||
<p className="mt-1 text-[11px] leading-4 text-muted-foreground">{item.summary}</p>
|
||
</div>
|
||
{!editing ? <div className="flex shrink-0 gap-0.5">
|
||
<Button id={`work-order-edit-${item.work_order_id}`} size="icon-sm" variant="ghost" aria-label={`编辑工单 ${item.title}`} disabled={!canWrite || Boolean(busy)} onClick={(event) => onEdit(event.currentTarget)}><Pencil size={14} /></Button>
|
||
<Button ref={deleteButtonRef} size="icon-sm" variant="ghost" className="text-rose-700" aria-label={`删除工单 ${item.title}`} aria-expanded={confirmingDelete} disabled={!canWrite || Boolean(busy) || confirmingDelete} onClick={onRequestDelete}><Trash2 size={14} /></Button>
|
||
</div> : null}
|
||
</div>
|
||
{confirmingDelete ? (
|
||
<div
|
||
role="group"
|
||
aria-label={`确认删除工单 ${item.title}`}
|
||
className="mt-3 rounded-lg bg-rose-50 p-3 ring-1 ring-inset ring-rose-200"
|
||
onKeyDown={(event) => {
|
||
if (event.key === "Escape" && !busy) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
cancelDelete();
|
||
}
|
||
}}
|
||
>
|
||
<p className="break-words text-balance text-xs font-semibold text-rose-900">删除“{item.title}”?</p>
|
||
<p className="mt-1 text-balance text-[11px] leading-4 text-rose-800">外围样板将移除这张工单,历史版本保留。</p>
|
||
<p className="mt-1 text-balance text-[11px] leading-4 text-rose-800">对应的工况任务将在后台同步后移出任务列表。</p>
|
||
<div className="mt-3 flex flex-wrap gap-2">
|
||
<Button type="button" size="sm" variant="destructive" disabled={Boolean(busy)} onClick={onConfirmDelete}>
|
||
{busy === `delete-order-${item.work_order_id}` ? <LoaderCircle className="animate-spin" /> : <Trash2 size={13} />}
|
||
确认删除
|
||
</Button>
|
||
<Button type="button" size="sm" variant="outline" autoFocus disabled={Boolean(busy)} onClick={cancelDelete}>取消</Button>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
{editor}
|
||
<div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 border-t border-border pt-2 text-[10px] text-muted-foreground">
|
||
<span>{priorityLabel(item.priority)}优先级</span>
|
||
{item.location ? <span>位置:{item.location}</span> : null}
|
||
{item.assignee ? <span>负责人:{item.assignee}</span> : null}
|
||
{item.scheduled_at ? <span className="inline-flex items-center gap-1"><CalendarClock size={11} />{item.work_order_type === "general" ? "预约" : "发生"}:{formatLocalMonthDayTime(item.scheduled_at)}</span> : null}
|
||
<span className="font-mono">r{item.revision}</span>
|
||
</div>
|
||
<div className="mt-3">
|
||
{workOrder ? (
|
||
<Disclosure aria-label={`工单详情 ${item.title}`} label="查看工单详情" className="border-t border-border pt-1">
|
||
<WorkOrderReportPanel condition={workOrder} />
|
||
</Disclosure>
|
||
) : <EventWorkOrderDetails item={item} />}
|
||
</div>
|
||
</article>
|
||
);
|
||
}
|
||
|
||
function EventWorkOrderDetails({ item }: { item: PeripheralWorkOrder }) {
|
||
return (
|
||
<section aria-label={`${workOrderTypeLabel(item.work_order_type)}信息`} className="border-t border-border pt-3">
|
||
<p className="text-[10px] font-semibold text-muted-foreground">事件信息</p>
|
||
<dl className="mt-2 grid grid-cols-2 gap-1.5 sm:grid-cols-3">
|
||
<EventWorkOrderField label="事件类型" value={workOrderTypeLabel(item.work_order_type)} />
|
||
<EventWorkOrderField label="发生时间" value={item.scheduled_at ? formatLocalMonthDayTime(item.scheduled_at) : "未记录"} />
|
||
<EventWorkOrderField label="位置" value={item.location ?? "未填写"} />
|
||
<EventWorkOrderField label="场景编号" value={item.scenario_code ?? "未填写"} />
|
||
<EventWorkOrderField label="处理人" value={item.assignee ?? "待分配"} />
|
||
<EventWorkOrderField label="活动记录" value={`${item.activity?.length ?? 0} 条`} />
|
||
</dl>
|
||
{item.activity?.length ? (
|
||
<ol aria-label="事件活动记录" className="mt-3 space-y-1.5 border-t border-border pt-3">
|
||
{item.activity.map((entry) => (
|
||
<li key={entry.sequence} className="rounded-lg bg-muted/50 px-2.5 py-2 text-[11px] leading-4 text-muted-foreground">
|
||
<span className="font-semibold text-foreground">{entry.title}</span>
|
||
<span className="ml-2">{entry.detail}</span>
|
||
<span className="mt-1 block text-[10px] text-muted-foreground">{formatLocalMonthDayTime(entry.occurred_at)} · {entry.actor}</span>
|
||
</li>
|
||
))}
|
||
</ol>
|
||
) : <p className="mt-3 border-t border-border pt-3 text-[11px] text-muted-foreground">暂无事件活动记录</p>}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function EventWorkOrderField({ label, value }: { label: string; value: string }) {
|
||
return <div className="min-w-0 rounded-lg bg-muted/50 px-2.5 py-2"><dt className="text-[10px] font-semibold text-muted-foreground">{label}</dt><dd className="mt-0.5 break-words text-[11px] font-medium text-foreground">{value}</dd></div>;
|
||
}
|
||
|
||
function toScheduledWorkOrder(item: PeripheralWorkOrder): ScheduledWorkOrderItem {
|
||
return {
|
||
id: item.work_order_id,
|
||
kind: "work_order",
|
||
code: item.scenario_code ?? item.work_order_id,
|
||
externalStatus: item.status,
|
||
workOrderScheduledAt: item.scheduled_at,
|
||
scheduledAt: item.scheduled_at ?? item.updated_at,
|
||
title: item.title,
|
||
summary: item.summary,
|
||
status: item.status === "in_progress" ? "running" : item.status === "awaiting_reply" ? "awaiting_reply" : item.status === "completed" ? "completed" : item.status === "cancelled" ? "cancelled" : "pending",
|
||
riskLevel: item.priority === "urgent" || item.priority === "high" ? "attention" : "normal",
|
||
updatedAt: Date.parse(item.updated_at),
|
||
detail: item.summary,
|
||
source: "外围工单测试",
|
||
dispatcher: "未记录",
|
||
priority: item.priority === "urgent" || item.priority === "high" ? "urgent" : "normal",
|
||
dataSource: "backend",
|
||
location: item.location ?? undefined,
|
||
assignee: item.assignee ?? undefined,
|
||
feedbackRequirements: item.feedback_requirements ?? [],
|
||
activity: (item.activity ?? []).map((entry) => ({ sequence: entry.sequence, occurredAt: entry.occurred_at,
|
||
actor: entry.actor, kind: entry.kind, title: entry.title, detail: entry.detail }))
|
||
};
|
||
}
|
||
|
||
function EditorHeading({ title, onClose, disabled = false }: { title: string; onClose: () => void; disabled?: boolean }) {
|
||
return <div className="flex items-center justify-between gap-2"><p className="text-xs font-semibold text-foreground">{title}</p><Button size="icon-sm" variant="ghost" aria-label="关闭编辑表单" disabled={disabled} onClick={onClose}><X size={14} /></Button></div>;
|
||
}
|
||
|
||
const WORK_ORDER_STATUS_OPTIONS = [
|
||
{ value: "draft", label: "草稿" },
|
||
{ value: "open", label: "待处理" },
|
||
{ value: "in_progress", label: "处理中" },
|
||
{ value: "awaiting_reply", label: "待复令" },
|
||
{ value: "completed", label: "已完成" },
|
||
{ value: "cancelled", label: "已取消" }
|
||
];
|
||
const PRIORITY_OPTIONS = [{ value: "low", label: "低" }, { value: "normal", label: "普通" }, { value: "high", label: "高" }, { value: "urgent", label: "紧急" }];
|
||
|
||
function toLocalInput(value: Date) {
|
||
const offset = value.getTimezoneOffset() * 60_000;
|
||
return new Date(value.getTime() - offset).toISOString().slice(0, 16);
|
||
}
|
||
|
||
function message(error: unknown) { return error instanceof Error ? error.message : "外围工单请求失败"; }
|
||
function orderStatus(value: PeripheralWorkOrder["status"]) { return ({ draft: "草稿", open: "待处理", in_progress: "处理中", awaiting_reply: "待复令", completed: "已完成", cancelled: "已取消" } satisfies Record<typeof value, string>)[value]; }
|
||
function orderStatusTone(value: PeripheralWorkOrder["status"]): StatusTone { return ({ draft: "neutral", open: "info", in_progress: "warning", awaiting_reply: "warning", completed: "success", cancelled: "neutral" } satisfies Record<typeof value, StatusTone>)[value]; }
|
||
function priorityLabel(value: PeripheralWorkOrder["priority"]) { return ({ low: "低", normal: "普通", high: "高", urgent: "紧急" } satisfies Record<typeof value, string>)[value]; }
|
||
function workOrderTypeLabel(value: PeripheralWorkOrder["work_order_type"]) { return ({ general: "预约工单", water_quality_complaint: "投诉工单", burst_report: "爆管上报工单" } satisfies Record<typeof value, string>)[value]; }
|
||
|
||
function completedFeedbackTime(item: PeripheralWorkOrder): string {
|
||
if (item.status !== "completed") return "";
|
||
const feedback = (item.activity ?? []).filter((entry) => entry.kind === "feedback").at(-1);
|
||
return feedback ? toLocalInput(new Date(feedback.occurred_at)) : "";
|
||
}
|