feat: build collaboration workspaces

This commit is contained in:
2026-07-03 03:02:36 -07:00
parent bf3dd256ab
commit 4ba2a11b88
53 changed files with 9557 additions and 29 deletions

View File

@@ -0,0 +1,325 @@
"use client";
import { FormEvent, useMemo, useState } from "react";
import { AlertTriangle, CheckCircle2, Search, Wrench, XCircle } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog } from "@/components/ui/dialog";
import { Input, Textarea } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import { Table } from "@/components/ui/table";
import type { ApiResult } from "@/modules/core/server/api";
import type { DeviceAsset, DeviceAssetInput, DeviceAssetStatus, DeviceOperationsData, MaintenanceTicket, MaintenanceTicketInput, MaintenanceTicketPriority, MaintenanceTicketStatus } from "@/modules/devices/types";
import { DEVICE_ASSET_STATUS_LABELS, DEVICE_ASSET_STATUS_VALUES, MAINTENANCE_TICKET_PRIORITY_LABELS, MAINTENANCE_TICKET_PRIORITY_VALUES, MAINTENANCE_TICKET_STATUS_LABELS, MAINTENANCE_TICKET_STATUS_VALUES } from "@/modules/devices/types";
type DevicesWorkspaceClientProps = {
canManage: boolean;
initialData: DeviceOperationsData;
};
type DialogState =
| { kind: "device"; mode: "create"; value: DeviceAssetInput }
| { kind: "device"; mode: "edit"; id: string; value: DeviceAssetInput }
| { kind: "ticket"; mode: "create"; value: MaintenanceTicketInput }
| { kind: "ticket"; mode: "edit"; id: string; value: MaintenanceTicketInput };
const emptyDevice: DeviceAssetInput = {
name: "",
code: "",
category: "",
location: "",
status: "active",
lastInspectedAt: undefined,
notes: "",
};
const emptyTicket: MaintenanceTicketInput = {
deviceId: undefined,
title: "",
description: "",
priority: "normal",
status: "open",
assigneeLabel: "",
resolutionNotes: "",
};
function formatDateTime(value: string | undefined): string {
return value ? new Date(value).toLocaleString("zh-CN") : "-";
}
function deviceToInput(device: DeviceAsset): DeviceAssetInput {
return {
name: device.name,
code: device.code,
category: device.category,
location: device.location,
status: device.status,
lastInspectedAt: device.lastInspectedAt,
notes: device.notes,
};
}
function ticketToInput(ticket: MaintenanceTicket): MaintenanceTicketInput {
return {
deviceId: ticket.deviceId,
title: ticket.title,
description: ticket.description,
priority: ticket.priority,
status: ticket.status,
assigneeLabel: ticket.assigneeLabel,
resolutionNotes: ticket.resolutionNotes,
};
}
function badgeVariant(status: string): "danger" | "secondary" | "success" | "warning" {
if (status === "active" || status === "resolved" || status === "closed") {
return "success";
}
if (status === "maintenance" || status === "open" || status === "assigned" || status === "urgent") {
return "warning";
}
if (status === "disabled" || status === "retired" || status === "cancelled") {
return "danger";
}
return "secondary";
}
export function DevicesWorkspaceClient({ canManage, initialData }: DevicesWorkspaceClientProps): React.ReactElement {
const [data, setData] = useState(initialData);
const [query, setQuery] = useState("");
const [deviceStatus, setDeviceStatus] = useState<"all" | DeviceAssetStatus>("all");
const [ticketStatus, setTicketStatus] = useState<"all" | MaintenanceTicketStatus>("all");
const [dialog, setDialog] = useState<DialogState | undefined>();
const [message, setMessage] = useState("");
const [isPending, setIsPending] = useState(false);
const normalizedQuery = query.trim().toLowerCase();
const filteredDevices = useMemo(
() =>
data.devices.filter((device) => {
const matchesQuery = !normalizedQuery || [device.name, device.code, device.category, device.location, device.notes].join(" ").toLowerCase().includes(normalizedQuery);
const matchesStatus = deviceStatus === "all" || device.status === deviceStatus;
return matchesQuery && matchesStatus;
}),
[data.devices, deviceStatus, normalizedQuery],
);
const filteredTickets = useMemo(
() =>
data.tickets.filter((ticket) => {
const matchesQuery = !normalizedQuery || [ticket.title, ticket.description, ticket.deviceName, ticket.assigneeLabel].join(" ").toLowerCase().includes(normalizedQuery);
const matchesStatus = ticketStatus === "all" || ticket.status === ticketStatus;
return matchesQuery && matchesStatus;
}),
[data.tickets, normalizedQuery, ticketStatus],
);
async function refreshData(): Promise<void> {
const response = await fetch("/api/devices/assets");
const result = (await response.json()) as ApiResult<{ data: DeviceOperationsData }>;
if (result.success) {
setData(result.data);
}
}
async function submitDialog(event: FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
if (!dialog || !canManage) {
setMessage("当前角色无权维护设备运维");
return;
}
const isCreate = dialog.mode === "create";
const path =
dialog.kind === "device"
? isCreate
? "/api/devices/assets"
: `/api/devices/assets/${dialog.id}`
: isCreate
? "/api/devices/tickets"
: `/api/devices/tickets/${dialog.id}`;
setIsPending(true);
const response = await fetch(path, {
method: isCreate ? "POST" : "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(dialog.value),
});
const result = (await response.json()) as ApiResult<Record<string, unknown>>;
setIsPending(false);
setMessage(result.reason);
if (result.success) {
setDialog(undefined);
await refreshData();
}
}
async function remove(kind: "device" | "ticket", id: string): Promise<void> {
if (!canManage) {
setMessage("当前角色无权删除设备运维记录");
return;
}
const path = kind === "device" ? `/api/devices/assets/${id}` : `/api/devices/tickets/${id}`;
setIsPending(true);
const response = await fetch(path, { method: "DELETE" });
const result = (await response.json()) as ApiResult<Record<string, unknown>>;
setIsPending(false);
setMessage(result.reason);
if (result.success) {
await refreshData();
}
}
return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<MetricCard icon={CheckCircle2} label="运行设备" value={data.metrics.activeDevices} />
<MetricCard icon={Wrench} label="维护中设备" value={data.metrics.maintenanceDevices} />
<MetricCard icon={AlertTriangle} label="待处理工单" value={data.metrics.openTickets} />
<MetricCard icon={XCircle} label="紧急工单" value={data.metrics.urgentTickets} />
</section>
<section className="flex flex-col gap-3 border-b pb-4 xl:flex-row xl:items-center xl:justify-between">
<div>
<h1 className="text-3xl font-semibold tracking-normal"></h1>
<p className="mt-2 text-sm text-muted-foreground"></p>
</div>
<div className="grid gap-2 sm:grid-cols-2 xl:flex xl:items-center">
<label className="relative block sm:col-span-2 xl:col-span-1">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input className="w-full pl-9 xl:w-72" onChange={(event) => setQuery(event.target.value)} placeholder="搜索设备、工单或位置" value={query} />
</label>
<Select aria-label="设备状态" onValueChange={(value) => setDeviceStatus(value as "all" | DeviceAssetStatus)} options={[{ value: "all", label: "全部设备状态" }, ...DEVICE_ASSET_STATUS_VALUES.map((value) => ({ value, label: DEVICE_ASSET_STATUS_LABELS[value] }))]} value={deviceStatus} />
<Select aria-label="工单状态" onValueChange={(value) => setTicketStatus(value as "all" | MaintenanceTicketStatus)} options={[{ value: "all", label: "全部工单状态" }, ...MAINTENANCE_TICKET_STATUS_VALUES.map((value) => ({ value, label: MAINTENANCE_TICKET_STATUS_LABELS[value] }))]} value={ticketStatus} />
<Button disabled={!canManage} onClick={() => setDialog({ kind: "device", mode: "create", value: emptyDevice })} type="button"></Button>
<Button disabled={!canManage} onClick={() => setDialog({ kind: "ticket", mode: "create", value: emptyTicket })} type="button" variant="outline"></Button>
</div>
</section>
{message ? <p className="rounded-md bg-secondary px-3 py-2 text-sm text-secondary-foreground" role="status">{message}</p> : null}
<Card>
<CardHeader><CardTitle></CardTitle></CardHeader>
<CardContent className="overflow-x-auto">
<Table className="w-full min-w-[900px] text-sm">
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
<Table.Row>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3 text-right"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body className="divide-y bg-card">
{filteredDevices.map((device) => (
<Table.Row key={device.id}>
<Table.Cell className="px-4 py-3"><p className="font-medium">{device.name}</p><p className="text-xs text-muted-foreground">{device.code} / {device.notes || "-"}</p></Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{device.category || "-"}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{device.location || "-"}</Table.Cell>
<Table.Cell className="px-4 py-3"><Badge variant={badgeVariant(device.status)}>{DEVICE_ASSET_STATUS_LABELS[device.status]}</Badge></Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{formatDateTime(device.lastInspectedAt)}</Table.Cell>
<Table.Cell className="px-4 py-3"><div className="flex justify-end gap-2"><Button disabled={!canManage || isPending} onClick={() => setDialog({ kind: "device", mode: "edit", id: device.id, value: deviceToInput(device) })} size="sm" type="button" variant="outline"></Button><Button disabled={!canManage || isPending} onClick={() => remove("device", device.id)} size="sm" type="button" variant="outline"></Button></div></Table.Cell>
</Table.Row>
))}
{filteredDevices.length === 0 ? <Table.Row><Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={6}></Table.Cell></Table.Row> : null}
</Table.Body>
</Table>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle></CardTitle></CardHeader>
<CardContent className="overflow-x-auto">
<Table className="w-full min-w-[980px] text-sm">
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
<Table.Row>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3"></Table.Head>
<Table.Head className="px-4 py-3 text-right"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body className="divide-y bg-card">
{filteredTickets.map((ticket) => (
<Table.Row key={ticket.id}>
<Table.Cell className="px-4 py-3"><p className="font-medium">{ticket.title}</p><p className="text-xs text-muted-foreground">{ticket.description || ticket.resolutionNotes || "-"}</p></Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{ticket.deviceName || "-"}</Table.Cell>
<Table.Cell className="px-4 py-3"><Badge variant={badgeVariant(ticket.priority)}>{MAINTENANCE_TICKET_PRIORITY_LABELS[ticket.priority]}</Badge></Table.Cell>
<Table.Cell className="px-4 py-3"><Badge variant={badgeVariant(ticket.status)}>{MAINTENANCE_TICKET_STATUS_LABELS[ticket.status]}</Badge></Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{ticket.assigneeLabel || "-"}</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">{formatDateTime(ticket.updatedAt)}</Table.Cell>
<Table.Cell className="px-4 py-3"><div className="flex justify-end gap-2"><Button disabled={!canManage || isPending} onClick={() => setDialog({ kind: "ticket", mode: "edit", id: ticket.id, value: ticketToInput(ticket) })} size="sm" type="button" variant="outline"></Button><Button disabled={!canManage || isPending} onClick={() => remove("ticket", ticket.id)} size="sm" type="button" variant="outline"></Button></div></Table.Cell>
</Table.Row>
))}
{filteredTickets.length === 0 ? <Table.Row><Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={7}></Table.Cell></Table.Row> : null}
</Table.Body>
</Table>
</CardContent>
</Card>
<Dialog onClose={() => setDialog(undefined)} open={dialog !== undefined} title={dialog?.kind === "device" ? "设备台账" : "维修工单"} width="lg">
{dialog ? (
<form className="grid gap-3" onSubmit={submitDialog}>
{dialog.kind === "device" ? <DeviceForm dialog={dialog} setDialog={setDialog} /> : <TicketForm devices={data.devices} dialog={dialog} setDialog={setDialog} />}
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button disabled={isPending} onClick={() => setDialog(undefined)} type="button" variant="outline"></Button>
<Button disabled={isPending || !canManage} type="submit"></Button>
</div>
</form>
) : null}
</Dialog>
</div>
);
}
function DeviceForm({ dialog, setDialog }: { dialog: Extract<DialogState, { kind: "device" }>; setDialog: (dialog: DialogState) => void }): React.ReactElement {
const value = dialog.value;
function update(next: Partial<DeviceAssetInput>): void {
setDialog({ ...dialog, value: { ...value, ...next } });
}
return (
<>
<Input label="设备名称" onChange={(event) => update({ name: event.target.value })} required value={value.name} />
<Input label="设备编号" onChange={(event) => update({ code: event.target.value })} required value={value.code} />
<Input label="分类" onChange={(event) => update({ category: event.target.value })} value={value.category} />
<Input label="位置" onChange={(event) => update({ location: event.target.value })} value={value.location} />
<Select aria-label="设备状态" onValueChange={(status) => update({ status: status as DeviceAssetStatus })} options={DEVICE_ASSET_STATUS_VALUES.map((status) => ({ value: status, label: DEVICE_ASSET_STATUS_LABELS[status] }))} value={value.status} />
<Input label="最近巡检时间" onChange={(event) => update({ lastInspectedAt: event.target.value || undefined })} type="datetime-local" value={value.lastInspectedAt ? value.lastInspectedAt.slice(0, 16) : ""} />
<Textarea label="备注" onChange={(event) => update({ notes: event.target.value })} value={value.notes} />
</>
);
}
function TicketForm({ devices, dialog, setDialog }: { devices: DeviceAsset[]; dialog: Extract<DialogState, { kind: "ticket" }>; setDialog: (dialog: DialogState) => void }): React.ReactElement {
const value = dialog.value;
function update(next: Partial<MaintenanceTicketInput>): void {
setDialog({ ...dialog, value: { ...value, ...next } });
}
return (
<>
<Select aria-label="关联设备" onValueChange={(deviceId) => update({ deviceId: deviceId || undefined })} options={[{ value: "", label: "不关联设备" }, ...devices.map((device) => ({ value: device.id, label: `${device.name} / ${device.code}` }))]} value={value.deviceId ?? ""} />
<Input label="工单标题" onChange={(event) => update({ title: event.target.value })} required value={value.title} />
<Textarea label="问题描述" onChange={(event) => update({ description: event.target.value })} value={value.description} />
<Select aria-label="优先级" onValueChange={(priority) => update({ priority: priority as MaintenanceTicketPriority })} options={MAINTENANCE_TICKET_PRIORITY_VALUES.map((priority) => ({ value: priority, label: MAINTENANCE_TICKET_PRIORITY_LABELS[priority] }))} value={value.priority} />
<Select aria-label="工单状态" onValueChange={(status) => update({ status: status as MaintenanceTicketStatus })} options={MAINTENANCE_TICKET_STATUS_VALUES.map((status) => ({ value: status, label: MAINTENANCE_TICKET_STATUS_LABELS[status] }))} value={value.status} />
<Input label="负责人" onChange={(event) => update({ assigneeLabel: event.target.value })} value={value.assigneeLabel} />
<Textarea label="处理记录" onChange={(event) => update({ resolutionNotes: event.target.value })} value={value.resolutionNotes} />
</>
);
}
function MetricCard({ icon: Icon, label, value }: { icon: React.ComponentType<{ "aria-hidden"?: boolean; className?: string }>; label: string; value: number }): React.ReactElement {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between p-4">
<div><p className="text-sm text-muted-foreground">{label}</p><CardTitle className="mt-2 text-3xl">{value}</CardTitle></div>
<Icon className="size-5 text-primary" aria-hidden={true} />
</CardHeader>
</Card>
);
}