feat: build collaboration workspaces
This commit is contained in:
325
modules/devices/components/DevicesWorkspaceClient.tsx
Normal file
325
modules/devices/components/DevicesWorkspaceClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
218
modules/devices/server/operations.ts
Normal file
218
modules/devices/server/operations.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||
|
||||
import { getDatabase } from "@/modules/core/server/db";
|
||||
import { deviceAssets, maintenanceTickets } from "@/modules/core/server/schema";
|
||||
import type { DeviceAsset, DeviceAssetInput, DeviceOperationsData, MaintenanceTicket, MaintenanceTicketInput } from "@/modules/devices/types";
|
||||
|
||||
export type DeviceMutationFailure = {
|
||||
success: false;
|
||||
reason: string;
|
||||
status: number;
|
||||
};
|
||||
|
||||
export function isDeviceMutationFailure(value: unknown): value is DeviceMutationFailure {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"success" in value &&
|
||||
(value as { success?: unknown }).success === false
|
||||
);
|
||||
}
|
||||
|
||||
function iso(value: Date): string {
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
function optionalIso(value: Date | null): string | undefined {
|
||||
return value ? iso(value) : undefined;
|
||||
}
|
||||
|
||||
function toDeviceAsset(row: typeof deviceAssets.$inferSelect): DeviceAsset {
|
||||
return {
|
||||
id: row.id,
|
||||
organizationId: row.organizationId,
|
||||
name: row.name,
|
||||
code: row.code,
|
||||
category: row.category,
|
||||
location: row.location,
|
||||
status: row.status,
|
||||
lastInspectedAt: optionalIso(row.lastInspectedAt),
|
||||
notes: row.notes,
|
||||
createdAt: iso(row.createdAt),
|
||||
updatedAt: iso(row.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
function toMaintenanceTicket(row: typeof maintenanceTickets.$inferSelect, deviceNameById: Map<string, string>): MaintenanceTicket {
|
||||
const deviceId = row.deviceId ?? undefined;
|
||||
return {
|
||||
id: row.id,
|
||||
organizationId: row.organizationId,
|
||||
deviceId,
|
||||
deviceName: deviceId ? deviceNameById.get(deviceId) ?? "" : "",
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
priority: row.priority,
|
||||
status: row.status,
|
||||
assigneeLabel: row.assigneeLabel,
|
||||
resolutionNotes: row.resolutionNotes,
|
||||
resolvedAt: optionalIso(row.resolvedAt),
|
||||
createdAt: iso(row.createdAt),
|
||||
updatedAt: iso(row.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listDeviceOperationsData(organizationId: string): Promise<DeviceOperationsData> {
|
||||
const database = getDatabase();
|
||||
const [deviceRows, ticketRows] = await Promise.all([
|
||||
database.select().from(deviceAssets).where(eq(deviceAssets.organizationId, organizationId)).orderBy(desc(deviceAssets.updatedAt)),
|
||||
database.select().from(maintenanceTickets).where(eq(maintenanceTickets.organizationId, organizationId)).orderBy(desc(maintenanceTickets.updatedAt)),
|
||||
]);
|
||||
const deviceNameById = new Map(deviceRows.map((device) => [device.id, device.name]));
|
||||
|
||||
return {
|
||||
metrics: {
|
||||
activeDevices: deviceRows.filter((device) => device.status === "active").length,
|
||||
maintenanceDevices: deviceRows.filter((device) => device.status === "maintenance").length,
|
||||
openTickets: ticketRows.filter((ticket) => ticket.status === "open" || ticket.status === "assigned").length,
|
||||
urgentTickets: ticketRows.filter((ticket) => ticket.priority === "urgent").length,
|
||||
},
|
||||
devices: deviceRows.map(toDeviceAsset),
|
||||
tickets: ticketRows.map((ticket) => toMaintenanceTicket(ticket, deviceNameById)),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createDeviceAsset(input: DeviceAssetInput & { organizationId: string }): Promise<DeviceAsset | DeviceMutationFailure> {
|
||||
const database = getDatabase();
|
||||
const rows = await database
|
||||
.insert(deviceAssets)
|
||||
.values({
|
||||
organizationId: input.organizationId,
|
||||
name: input.name,
|
||||
code: input.code,
|
||||
category: input.category,
|
||||
location: input.location,
|
||||
status: input.status,
|
||||
lastInspectedAt: input.lastInspectedAt ? new Date(input.lastInspectedAt) : undefined,
|
||||
notes: input.notes,
|
||||
})
|
||||
.returning();
|
||||
const device = rows[0];
|
||||
return device ? toDeviceAsset(device) : { success: false, reason: "设备创建失败", status: 500 };
|
||||
}
|
||||
|
||||
export async function updateDeviceAsset(input: DeviceAssetInput & { id: string; organizationId: string }): Promise<DeviceAsset | DeviceMutationFailure> {
|
||||
const database = getDatabase();
|
||||
const rows = await database
|
||||
.update(deviceAssets)
|
||||
.set({
|
||||
name: input.name,
|
||||
code: input.code,
|
||||
category: input.category,
|
||||
location: input.location,
|
||||
status: input.status,
|
||||
lastInspectedAt: input.lastInspectedAt ? new Date(input.lastInspectedAt) : null,
|
||||
notes: input.notes,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(deviceAssets.id, input.id), eq(deviceAssets.organizationId, input.organizationId)))
|
||||
.returning();
|
||||
const device = rows[0];
|
||||
return device ? toDeviceAsset(device) : { success: false, reason: "设备不存在", status: 404 };
|
||||
}
|
||||
|
||||
export async function deleteDeviceAsset(input: { id: string; organizationId: string }): Promise<DeviceAsset | DeviceMutationFailure> {
|
||||
const database = getDatabase();
|
||||
const rows = await database
|
||||
.delete(deviceAssets)
|
||||
.where(and(eq(deviceAssets.id, input.id), eq(deviceAssets.organizationId, input.organizationId)))
|
||||
.returning();
|
||||
const device = rows[0];
|
||||
return device ? toDeviceAsset(device) : { success: false, reason: "设备不存在", status: 404 };
|
||||
}
|
||||
|
||||
async function getDeviceNameById(organizationId: string, deviceId: string | undefined): Promise<Map<string, string> | DeviceMutationFailure> {
|
||||
if (!deviceId) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const database = getDatabase();
|
||||
const rows = await database
|
||||
.select({ id: deviceAssets.id, name: deviceAssets.name })
|
||||
.from(deviceAssets)
|
||||
.where(and(eq(deviceAssets.id, deviceId), eq(deviceAssets.organizationId, organizationId)));
|
||||
const device = rows[0];
|
||||
return device ? new Map([[device.id, device.name]]) : { success: false, reason: "设备不存在", status: 404 };
|
||||
}
|
||||
|
||||
export async function createMaintenanceTicket(input: MaintenanceTicketInput & { organizationId: string }): Promise<MaintenanceTicket | DeviceMutationFailure> {
|
||||
const deviceNameById = await getDeviceNameById(input.organizationId, input.deviceId);
|
||||
if (isDeviceMutationFailure(deviceNameById)) {
|
||||
return deviceNameById;
|
||||
}
|
||||
|
||||
const database = getDatabase();
|
||||
const resolvedAt = input.status === "resolved" || input.status === "closed" ? new Date() : undefined;
|
||||
const rows = await database
|
||||
.insert(maintenanceTickets)
|
||||
.values({
|
||||
organizationId: input.organizationId,
|
||||
deviceId: input.deviceId,
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
priority: input.priority,
|
||||
status: input.status,
|
||||
assigneeLabel: input.assigneeLabel,
|
||||
resolutionNotes: input.resolutionNotes,
|
||||
resolvedAt,
|
||||
})
|
||||
.returning();
|
||||
const ticket = rows[0];
|
||||
return ticket ? toMaintenanceTicket(ticket, deviceNameById) : { success: false, reason: "维修工单创建失败", status: 500 };
|
||||
}
|
||||
|
||||
export async function updateMaintenanceTicket(input: MaintenanceTicketInput & { id: string; organizationId: string }): Promise<MaintenanceTicket | DeviceMutationFailure> {
|
||||
const deviceNameById = await getDeviceNameById(input.organizationId, input.deviceId);
|
||||
if (isDeviceMutationFailure(deviceNameById)) {
|
||||
return deviceNameById;
|
||||
}
|
||||
|
||||
const database = getDatabase();
|
||||
const resolvedAt = input.status === "resolved" || input.status === "closed" ? new Date() : null;
|
||||
const rows = await database
|
||||
.update(maintenanceTickets)
|
||||
.set({
|
||||
deviceId: input.deviceId ?? null,
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
priority: input.priority,
|
||||
status: input.status,
|
||||
assigneeLabel: input.assigneeLabel,
|
||||
resolutionNotes: input.resolutionNotes,
|
||||
resolvedAt,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(maintenanceTickets.id, input.id), eq(maintenanceTickets.organizationId, input.organizationId)))
|
||||
.returning();
|
||||
const ticket = rows[0];
|
||||
return ticket ? toMaintenanceTicket(ticket, deviceNameById) : { success: false, reason: "维修工单不存在", status: 404 };
|
||||
}
|
||||
|
||||
export async function deleteMaintenanceTicket(input: { id: string; organizationId: string }): Promise<MaintenanceTicket | DeviceMutationFailure> {
|
||||
const database = getDatabase();
|
||||
const rows = await database
|
||||
.delete(maintenanceTickets)
|
||||
.where(and(eq(maintenanceTickets.id, input.id), eq(maintenanceTickets.organizationId, input.organizationId)))
|
||||
.returning();
|
||||
const ticket = rows[0];
|
||||
if (!ticket) {
|
||||
return { success: false, reason: "维修工单不存在", status: 404 };
|
||||
}
|
||||
|
||||
const deviceIds = ticket.deviceId ? [ticket.deviceId] : [];
|
||||
const deviceRows =
|
||||
deviceIds.length > 0
|
||||
? await database.select({ id: deviceAssets.id, name: deviceAssets.name }).from(deviceAssets).where(inArray(deviceAssets.id, deviceIds))
|
||||
: [];
|
||||
return toMaintenanceTicket(ticket, new Map(deviceRows.map((device) => [device.id, device.name])));
|
||||
}
|
||||
200
modules/devices/types.ts
Normal file
200
modules/devices/types.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
export const DEVICE_ASSET_STATUS_VALUES = ["active", "maintenance", "disabled", "retired"] as const;
|
||||
export type DeviceAssetStatus = (typeof DEVICE_ASSET_STATUS_VALUES)[number];
|
||||
|
||||
export const MAINTENANCE_TICKET_STATUS_VALUES = ["open", "assigned", "resolved", "closed", "cancelled"] as const;
|
||||
export type MaintenanceTicketStatus = (typeof MAINTENANCE_TICKET_STATUS_VALUES)[number];
|
||||
|
||||
export const MAINTENANCE_TICKET_PRIORITY_VALUES = ["low", "normal", "high", "urgent"] as const;
|
||||
export type MaintenanceTicketPriority = (typeof MAINTENANCE_TICKET_PRIORITY_VALUES)[number];
|
||||
|
||||
export const DEVICE_ASSET_STATUS_LABELS: Record<DeviceAssetStatus, string> = {
|
||||
active: "运行中",
|
||||
maintenance: "维护中",
|
||||
disabled: "停用",
|
||||
retired: "已退役",
|
||||
};
|
||||
|
||||
export const MAINTENANCE_TICKET_STATUS_LABELS: Record<MaintenanceTicketStatus, string> = {
|
||||
open: "待处理",
|
||||
assigned: "已派单",
|
||||
resolved: "已解决",
|
||||
closed: "已关闭",
|
||||
cancelled: "已取消",
|
||||
};
|
||||
|
||||
export const MAINTENANCE_TICKET_PRIORITY_LABELS: Record<MaintenanceTicketPriority, string> = {
|
||||
low: "低",
|
||||
normal: "普通",
|
||||
high: "高",
|
||||
urgent: "紧急",
|
||||
};
|
||||
|
||||
export type DeviceAsset = {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
name: string;
|
||||
code: string;
|
||||
category: string;
|
||||
location: string;
|
||||
status: DeviceAssetStatus;
|
||||
lastInspectedAt?: string;
|
||||
notes: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type MaintenanceTicket = {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
deviceId?: string;
|
||||
deviceName: string;
|
||||
title: string;
|
||||
description: string;
|
||||
priority: MaintenanceTicketPriority;
|
||||
status: MaintenanceTicketStatus;
|
||||
assigneeLabel: string;
|
||||
resolutionNotes: string;
|
||||
resolvedAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type DeviceOperationsData = {
|
||||
metrics: {
|
||||
activeDevices: number;
|
||||
maintenanceDevices: number;
|
||||
openTickets: number;
|
||||
urgentTickets: number;
|
||||
};
|
||||
devices: DeviceAsset[];
|
||||
tickets: MaintenanceTicket[];
|
||||
};
|
||||
|
||||
export type DeviceAssetInput = {
|
||||
name: string;
|
||||
code: string;
|
||||
category: string;
|
||||
location: string;
|
||||
status: DeviceAssetStatus;
|
||||
lastInspectedAt?: string;
|
||||
notes: string;
|
||||
};
|
||||
|
||||
export type MaintenanceTicketInput = {
|
||||
deviceId?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
priority: MaintenanceTicketPriority;
|
||||
status: MaintenanceTicketStatus;
|
||||
assigneeLabel: string;
|
||||
resolutionNotes: string;
|
||||
};
|
||||
|
||||
type ValidationSuccess<T> = {
|
||||
success: true;
|
||||
data: T;
|
||||
};
|
||||
|
||||
type ValidationFailure = {
|
||||
success: false;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type ValidationResult<T> = ValidationSuccess<T> | ValidationFailure;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readString(source: Record<string, unknown>, key: string): string {
|
||||
const value = source[key];
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function readOptionalString(source: Record<string, unknown>, key: string): string | undefined {
|
||||
const value = readString(source, key);
|
||||
return value || undefined;
|
||||
}
|
||||
|
||||
function readEnum<T extends string>(value: unknown, values: readonly T[]): T | null {
|
||||
return typeof value === "string" ? values.find((item) => item === value) ?? null : null;
|
||||
}
|
||||
|
||||
function validateIsoDate(value: string | undefined, label: string): ValidationResult<string | undefined> {
|
||||
if (!value) {
|
||||
return { success: true, data: undefined };
|
||||
}
|
||||
|
||||
return Number.isNaN(new Date(value).getTime())
|
||||
? { success: false, reason: `${label}无效` }
|
||||
: { success: true, data: value };
|
||||
}
|
||||
|
||||
export function validateDeviceAssetInput(value: unknown): ValidationResult<DeviceAssetInput> {
|
||||
if (!isRecord(value)) {
|
||||
return { success: false, reason: "请求数据格式无效" };
|
||||
}
|
||||
|
||||
const name = readString(value, "name");
|
||||
const code = readString(value, "code");
|
||||
if (!name || !code) {
|
||||
return { success: false, reason: "设备名称和编号不能为空" };
|
||||
}
|
||||
|
||||
const status = readEnum(value.status, DEVICE_ASSET_STATUS_VALUES);
|
||||
if (!status) {
|
||||
return { success: false, reason: "设备状态无效" };
|
||||
}
|
||||
|
||||
const lastInspectedAt = validateIsoDate(readOptionalString(value, "lastInspectedAt"), "最近巡检时间");
|
||||
if (!lastInspectedAt.success) {
|
||||
return lastInspectedAt;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
name,
|
||||
code,
|
||||
category: readString(value, "category"),
|
||||
location: readString(value, "location"),
|
||||
status,
|
||||
lastInspectedAt: lastInspectedAt.data,
|
||||
notes: readString(value, "notes"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function validateMaintenanceTicketInput(value: unknown): ValidationResult<MaintenanceTicketInput> {
|
||||
if (!isRecord(value)) {
|
||||
return { success: false, reason: "请求数据格式无效" };
|
||||
}
|
||||
|
||||
const title = readString(value, "title");
|
||||
if (!title) {
|
||||
return { success: false, reason: "工单标题不能为空" };
|
||||
}
|
||||
|
||||
const priority = readEnum(value.priority, MAINTENANCE_TICKET_PRIORITY_VALUES);
|
||||
if (!priority) {
|
||||
return { success: false, reason: "工单优先级无效" };
|
||||
}
|
||||
|
||||
const status = readEnum(value.status, MAINTENANCE_TICKET_STATUS_VALUES);
|
||||
if (!status) {
|
||||
return { success: false, reason: "工单状态无效" };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
deviceId: readOptionalString(value, "deviceId"),
|
||||
title,
|
||||
description: readString(value, "description"),
|
||||
priority,
|
||||
status,
|
||||
assigneeLabel: readString(value, "assigneeLabel"),
|
||||
resolutionNotes: readString(value, "resolutionNotes"),
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user