diff --git a/AGENTS.md b/AGENTS.md index 142e2c0..f86161c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,6 +63,7 @@ CI(`ci.yml`)对每个指向 main 的 PR 跑以下全部检查。 - 图标用 `app/components/icons.tsx` 的 `Icon`,缺的图标加进那个文件,不用 Unicode 字符充当图标。设备没有照片字段,按类别用 `categoryIcon` 显示图标。 - 角色导航以 `app/lib/roles.ts` 的 `navItemsForRole` 为准;`tests/rendered-html.test.mjs` 里有一份对照,改导航时两处一起改。 - 筛选项(下拉选项、标签列表)不要从当前页、已筛选的列表数据里推导:换页或加筛选后选项会消失。由后端单独接口提供,例如设备类别用 `GET /api/equipment/categories`。测试要覆盖「筛选后」和「数据在第一页之外」两种情况。 +- 设备名录这类只用来装饰行的数据,不要和页面自己的数据放进同一个 `Promise.all`:名录失败会连带已经成功的行一起丢掉。用 `page.tsx` 的 `settle` 单独等待,失败时行回退到编号并单独提示;测试要覆盖「主数据成功、名录失败」。 - 菜单、下拉层等浮层不要渲染在 `overflow` 滚动容器里(表格外层的 `.table-scroll` 会裁掉纵向溢出),用 portal 挂到 `body` 再按触发按钮定位,参照 `EquipmentView` 的 `MoreMenu`。测试至少覆盖一行、两行和末行三种情况。 ## 编辑方式 diff --git a/frontend/app/components/table.tsx b/frontend/app/components/table.tsx new file mode 100644 index 0000000..6606dec --- /dev/null +++ b/frontend/app/components/table.tsx @@ -0,0 +1,109 @@ +import { useEffect, useLayoutEffect, useRef } from "react"; +import { createPortal } from "react-dom"; +import { statusLabels } from "../lib/labels"; +import type { Equipment } from "../lib/types"; +import { categoryIcon, categoryTone, Icon } from "./icons"; + +/** + * Building blocks shared by the list tables (equipment, reservations, work orders), + * so every table gets the same status dots, device cell and row menu. + */ + +export type StateTone = "ok" | "info" | "warn" | "danger" | "idle"; + +/** + * Colour of every equipment, reservation and work-order status. A status missing + * here renders grey; tests/table-parts.test.mjs fails if one is missing. + */ +export const STATE_TONES: Record = { + // equipment + AVAILABLE: "ok", IN_USE: "info", MAINTENANCE: "warn", RETIRED: "idle", + // reservations + PENDING: "warn", APPROVED: "info", AWAITING_PAYMENT: "warn", PAID: "info", REFUNDING: "warn", + COMPLETED: "ok", REJECTED: "danger", CANCELLED: "idle", EXPIRED: "idle", + // work orders + SUBMITTED: "warn", ASSIGNED: "info", IN_PROGRESS: "info", RESOLVED: "ok", CLOSED: "idle", +}; + +export function StateBadge({ status }: { status: string }) { + return {statusLabels[status] ?? status}; +} + +export function CategoryTag({ category }: { category: string }) { + return {category}; +} + +/** Device icon, name and code; falls back to the id when the device is not in the lookup. */ +export function EquipmentCell({ equipment, equipmentId }: { equipment?: Equipment; equipmentId: number }) { + return
+ +
{equipment?.name ?? `设备 #${equipmentId}`}{equipment?.code ?? "—"}
+
; +} + +export type MenuEntry = { key: string; label: string; danger?: boolean; onClick: () => void }; + +/** Builds a row menu from conditional entries: `cond && { ... }` items that are false are dropped. */ +export function menuEntries(...entries: (MenuEntry | false | null | undefined)[]): MenuEntry[] { + return entries.filter((entry): entry is MenuEntry => Boolean(entry)); +} + +const MENU_GAP = 6; + +/** + * The table sits in a sideways-scrolling container, and overflow-x: auto clips + * vertical overflow too. The list is therefore portalled to and placed + * with fixed coordinates next to its button, so no row count can clip it. + * It opens upwards only when the viewport has no room below. + */ +export function MoreMenu({ id, open, label, entries, onToggle }: { id: number; open: boolean; label: string; entries: MenuEntry[]; onToggle: (open: boolean) => void }) { + const button = useRef(null); + const list = useRef(null); + + useLayoutEffect(() => { + if (!open) return; + // Fixed coordinates: re-place on scroll and resize so the list stays next to its button. + const place = () => { + const menu = list.current; + const anchor = button.current?.getBoundingClientRect(); + if (!menu || !anchor) return; + const fitsBelow = anchor.bottom + MENU_GAP + menu.offsetHeight <= window.innerHeight; + menu.style.top = `${fitsBelow ? anchor.bottom + MENU_GAP : Math.max(MENU_GAP, anchor.top - MENU_GAP - menu.offsetHeight)}px`; + menu.style.left = `${Math.max(MENU_GAP, anchor.right - menu.offsetWidth)}px`; + menu.style.visibility = "visible"; + }; + place(); + window.addEventListener("resize", place); + window.addEventListener("scroll", place, true); + return () => { + window.removeEventListener("resize", place); + window.removeEventListener("scroll", place, true); + }; + }, [open]); + + useEffect(() => { + if (!open) return; + const close = () => onToggle(false); + const onOutside = (event: MouseEvent) => { + const target = event.target as Node; + if (!button.current?.contains(target) && !list.current?.contains(target)) close(); + }; + const onEscape = (event: KeyboardEvent) => { if (event.key === "Escape") close(); }; + document.addEventListener("mousedown", onOutside); + document.addEventListener("keydown", onEscape); + return () => { + document.removeEventListener("mousedown", onOutside); + document.removeEventListener("keydown", onEscape); + }; + }, [open, onToggle]); + + return
+ + {open && createPortal(
+ {entries.map((entry) => )} +
, document.body)} +
; +} diff --git a/frontend/app/components/views/EquipmentView.tsx b/frontend/app/components/views/EquipmentView.tsx index 95da48c..c4bed3c 100644 --- a/frontend/app/components/views/EquipmentView.tsx +++ b/frontend/app/components/views/EquipmentView.tsx @@ -1,25 +1,14 @@ -import { useEffect, useLayoutEffect, useRef, useState } from "react"; -import { createPortal } from "react-dom"; -import { isReservableStatus, statusLabels } from "../../lib/labels"; +import { useState } from "react"; +import { isReservableStatus } from "../../lib/labels"; import type { Equipment, EquipmentFilter, PageData } from "../../lib/types"; -import { categoryIcon, categoryTone, Icon, KNOWN_CATEGORIES } from "../icons"; +import { categoryIcon, Icon, KNOWN_CATEGORIES } from "../icons"; +import { CategoryTag, menuEntries, MoreMenu, StateBadge } from "../table"; import { Empty, Modal } from "../ui"; -/** Equipment states shown as a coloured dot, matching the dashboard's status chart. */ -const STATE_TONE: Record = { AVAILABLE: "ok", IN_USE: "info", MAINTENANCE: "warn", RETIRED: "idle" }; - /** Derived from isReservableStatus so the table, the detail view and the booking modal never disagree. */ const reserveBlockedReason = (status: string) => isReservableStatus(status) ? undefined : status === "RETIRED" ? "设备已退役,不能预约" : "设备维护中,暂不能预约"; -function StateBadge({ status }: { status: string }) { - return {statusLabels[status] ?? status}; -} - -function CategoryTag({ category }: { category: string }) { - return {category}; -} - export function EquipmentView({ equipment, categories, filter, canManage, canReserve, onFilterChange, onLoad, onReset, onCreate, onReserve, onReport, onRetire, onRestore }: { equipment: PageData | null; /** Every category in use (GET /api/equipment/categories); null while loading or if that request failed. */ @@ -78,11 +67,11 @@ export function EquipmentView({ equipment, categories, filter, canManage, canRes 设备名称设备编号类别所在位置负责人状态操作 {items.map((item) => { const blocked = reserveBlockedReason(item.status); - const menu = [ + const menu = menuEntries( item.status !== "RETIRED" && { key: "report", label: "故障报修", onClick: () => onReport(item) }, canManage && item.status === "AVAILABLE" && onRetire && { key: "retire", label: "退役", danger: true, onClick: () => onRetire(item) }, canManage && item.status === "RETIRED" && onRestore && { key: "restore", label: "恢复可用", onClick: () => onRestore(item) }, - ].filter((entry): entry is MenuEntry => Boolean(entry)); + ); return
@@ -113,68 +102,6 @@ export function EquipmentView({ equipment, categories, filter, canManage, canRes
; } -type MenuEntry = { key: string; label: string; danger?: boolean; onClick: () => void }; - -const MENU_GAP = 6; - -/** - * The table sits in a sideways-scrolling container, and overflow-x: auto clips - * vertical overflow too. The list is therefore portalled to and placed - * with fixed coordinates next to its button, so no row count can clip it. - * It opens upwards only when the viewport has no room below. - */ -function MoreMenu({ id, open, label, entries, onToggle }: { id: number; open: boolean; label: string; entries: MenuEntry[]; onToggle: (open: boolean) => void }) { - const button = useRef(null); - const list = useRef(null); - - useLayoutEffect(() => { - if (!open) return; - // Fixed coordinates: re-place on scroll and resize so the list stays next to its button. - const place = () => { - const menu = list.current; - const anchor = button.current?.getBoundingClientRect(); - if (!menu || !anchor) return; - const fitsBelow = anchor.bottom + MENU_GAP + menu.offsetHeight <= window.innerHeight; - menu.style.top = `${fitsBelow ? anchor.bottom + MENU_GAP : Math.max(MENU_GAP, anchor.top - MENU_GAP - menu.offsetHeight)}px`; - menu.style.left = `${Math.max(MENU_GAP, anchor.right - menu.offsetWidth)}px`; - menu.style.visibility = "visible"; - }; - place(); - window.addEventListener("resize", place); - window.addEventListener("scroll", place, true); - return () => { - window.removeEventListener("resize", place); - window.removeEventListener("scroll", place, true); - }; - }, [open]); - - useEffect(() => { - if (!open) return; - const close = () => onToggle(false); - const onOutside = (event: MouseEvent) => { - const target = event.target as Node; - if (!button.current?.contains(target) && !list.current?.contains(target)) close(); - }; - const onEscape = (event: KeyboardEvent) => { if (event.key === "Escape") close(); }; - document.addEventListener("mousedown", onOutside); - document.addEventListener("keydown", onEscape); - return () => { - document.removeEventListener("mousedown", onOutside); - document.removeEventListener("keydown", onEscape); - }; - }, [open, onToggle]); - - return
- - {open && createPortal(
- {entries.map((entry) => )} -
, document.body)} -
; -} - function EquipmentDetail({ item, canReserve, onClose, onReserve, onReport }: { item: Equipment; canReserve: boolean; onClose: () => void; onReserve: () => void; onReport: () => void; }) { diff --git a/frontend/app/components/views/ReservationsView.tsx b/frontend/app/components/views/ReservationsView.tsx index 3e5b681..e2ef6b4 100644 --- a/frontend/app/components/views/ReservationsView.tsx +++ b/frontend/app/components/views/ReservationsView.tsx @@ -1,9 +1,10 @@ import { useState } from "react"; +import { EquipmentCell, menuEntries, MoreMenu, StateBadge } from "../table"; import { conflictSummary } from "../../lib/conflicts"; import { formatDate } from "../../lib/labels"; import { buildWeekSchedule, type ScheduleBlock } from "../../lib/schedule"; import type { Equipment, PageData, Reservation, UserProfile } from "../../lib/types"; -import { Empty, StatusBadge } from "../ui"; +import { Empty } from "../ui"; import { Icon } from "../icons"; /** Tall hours when the day is short; a full 24-hour day shrinks so the week stays on screen. */ @@ -96,6 +97,7 @@ export function ReservationsView({ user, reservations, scheduleReservations, equ onCancel: (id: number) => void; onComplete: (id: number) => void; }) { + const [menuFor, setMenuFor] = useState(null); const equipmentList = equipment?.content ?? []; const equipmentById = new Map(equipmentList.map((item) => [item.id, item])); return
@@ -111,35 +113,42 @@ export function ReservationsView({ user, reservations, scheduleReservations, equ

预约记录

共 {reservations?.totalElements ?? 0} 条
-
- + {Boolean(reservations?.content.length) &&
用途与申请人使用时段设备状态操作
+ {reservations?.content.map((item) => { - const device = equipmentById.get(item.equipmentId); const conflict = conflictSummary(item); - return - - - + const menu = menuEntries( + item.requesterId === user.id && ["PENDING", "APPROVED"].includes(item.status) + && { key: "cancel", label: "取消预约", danger: true, onClick: () => onCancel(item.id) }, + ); + return + + + - ; })} -
设备用途与申请人使用时段状态操作
{item.purpose}{item.requesterName} · #{item.id}
{formatDate(item.startTime)}至 {formatDate(item.endTime)}{device ? <>{device.name}{device.code} : #{item.equipmentId}}
{item.purpose}{item.requesterName} · #{item.id}
{formatDate(item.startTime)}至 {formatDate(item.endTime)} - + {conflict && {conflict.text}}
+
{canApprove && item.status === "PENDING" && <> - + } - {item.requesterId === user.id && ["PENDING", "APPROVED"].includes(item.status) && } - {canApprove && (item.status === "APPROVED" || item.status === "PAID") && } + {canApprove && (item.status === "APPROVED" || item.status === "PAID") + && } + {menu.length > 0 && setMenuFor(open ? item.id : null)} />}
{!reservations?.content.length && }
+
} + {!reservations?.content.length && } ; } diff --git a/frontend/app/components/views/WorkOrdersView.tsx b/frontend/app/components/views/WorkOrdersView.tsx index edca914..db36f80 100644 --- a/frontend/app/components/views/WorkOrdersView.tsx +++ b/frontend/app/components/views/WorkOrdersView.tsx @@ -1,11 +1,15 @@ import { useState } from "react"; -import { formatDate } from "../../lib/labels"; -import type { PageData, Technician, UserProfile, WorkOrder } from "../../lib/types"; -import { Empty, StatusBadge } from "../ui"; +import { formatDate, statusLabels } from "../../lib/labels"; +import type { Equipment, PageData, Technician, UserProfile, WorkOrder } from "../../lib/types"; +import { Icon } from "../icons"; +import { EquipmentCell, menuEntries, MoreMenu, StateBadge } from "../table"; +import { Empty } from "../ui"; -export function WorkOrdersView({ user, workorders, technicians, filter, canAssign, canClaim, canProcess, onFilterChange, onLoad, onCreate, onTransition, onClaim }: { +export function WorkOrdersView({ user, workorders, equipment, technicians, filter, canAssign, canClaim, canProcess, onFilterChange, onLoad, onCreate, onTransition, onClaim }: { user: UserProfile; workorders: PageData | null; + /** Full, unfiltered name lookup; never the equipment page's filtered list. */ + equipment: PageData | null; technicians: Technician[]; filter: string; canAssign: boolean; @@ -18,11 +22,25 @@ export function WorkOrdersView({ user, workorders, technicians, filter, canAssig onClaim: (item: WorkOrder) => void; }) { const [selectedAssignees, setSelectedAssignees] = useState>({}); - const technicianName = (id: number | null) => technicians.find((item) => item.id === id)?.displayName - || (id ? `维修员 #${id}` : "待分派"); + const [menuFor, setMenuFor] = useState(null); + const equipmentById = new Map((equipment?.content ?? []).map((item) => [item.id, item])); + // The technician list is only loaded for admins, so a technician's own orders would otherwise read "维修员 #id". + const technicianName = (id: number | null) => (id === user.id ? user.displayName : undefined) + ?? technicians.find((item) => item.id === id)?.displayName + ?? (id ? `维修员 #${id}` : "待分派"); const isOwn = (item: WorkOrder) => item.assigneeId === user.id; const canActOn = (item: WorkOrder) => canAssign || (canProcess && isOwn(item)); + /** The one forward step for this row, shown as the solid button; everything else goes in the menu. */ + const primaryStep = (item: WorkOrder): { key: string; label: string; onClick: () => void } | null => { + if (canClaim && item.status === "SUBMITTED") return { key: "claim", label: "接单", onClick: () => onClaim(item) }; + if (!canActOn(item)) return null; + if (item.status === "ASSIGNED") return { key: "start", label: "开始处理", onClick: () => onTransition(item, "IN_PROGRESS") }; + if (item.status === "IN_PROGRESS") return { key: "resolve", label: "标记解决", onClick: () => onTransition(item, "RESOLVED") }; + if (item.status === "RESOLVED") return { key: "close", label: "关闭", onClick: () => onTransition(item, "CLOSED") }; + return null; + }; + return
- +
@@ -47,48 +65,40 @@ export function WorkOrdersView({ user, workorders, technicians, filter, canAssig · 共 {workorders?.totalElements ?? 0} 条
-
- + {Boolean(workorders?.content.length) &&
故障描述优先级设备 ID处理人员状态更新时间操作
+ {workorders?.content.map((item) => { const selected = selectedAssignees[item.id] || String(technicians[0]?.id || ""); - return - - - - - - - + + + + + + ; })} -
故障描述设备处理人员状态更新时间操作
!
{item.title}{item.reporterName} · {item.description}
EQ-{item.equipmentId}{technicianName(item.assigneeId)}{formatDate(item.updatedAt)}
- {canAssign && item.status === "SUBMITTED" && ( -
- - -
- )} - {canClaim && item.status === "SUBMITTED" && ( - - )} - {canAssign && (item.status === "SUBMITTED" || item.status === "ASSIGNED") && ( - - )} - {canActOn(item) && item.status === "ASSIGNED" && ( - - )} - {canActOn(item) && item.status === "IN_PROGRESS" && ( - - )} - {canActOn(item) && item.status === "RESOLVED" && ( - <> - - - - )} + const primary = primaryStep(item); + const menu = menuEntries( + canActOn(item) && item.status === "RESOLVED" && { key: "reopen", label: "重新打开", onClick: () => onTransition(item, "IN_PROGRESS") }, + canAssign && (item.status === "SUBMITTED" || item.status === "ASSIGNED") + && { key: "cancel", label: "取消工单", danger: true, onClick: () => onTransition(item, "CANCELLED") }, + ); + return
+ {item.title}{statusLabels[item.priority] ?? item.priority} + {item.reporterName} · {item.description} +
{technicianName(item.assigneeId)}{formatDate(item.updatedAt)}
+ {canAssign && item.status === "SUBMITTED" &&
+ + +
} + {primary && } + {menu.length > 0 && setMenuFor(open ? item.id : null)} />}
{!workorders?.content.length && }
+
} + {!workorders?.content.length && } ; } diff --git a/frontend/app/globals.css b/frontend/app/globals.css index d9684b7..344da7b 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -296,6 +296,22 @@ label { display: grid; gap: 7px; color: #4d4641; font-size: 13px; font-weight: 6 .more-menu-list button:hover { background: #f4f5f7; } .more-menu-list button.danger { color: var(--danger); } .more-menu-list button.danger:hover { background: #fff3f2; } +/* Reservation and work-order tables share the equipment table's look (see components/table.tsx). */ +.list-table td { padding: 12px 15px; } +.list-table tbody tr:hover { background: #fafbfc; } +.list-table .equipment-name { min-width: 200px; } +.list-table td.nowrap, .equipment-table td.nowrap { white-space: nowrap; } +.list-table td.muted { color: #9aa0a8; } +.fault-cell { min-width: 220px; max-width: 360px; } +.fault-cell b { display: flex; align-items: center; gap: 8px; color: var(--ink); font-size: 14px; font-weight: 700; } +.fault-cell small { display: block; margin-top: 4px; overflow: hidden; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; font-size: 12px; } +.list-actions { display: flex; align-items: center; gap: 8px; white-space: nowrap; } +.list-actions .solid-button, .list-actions .ghost-button { height: 32px; padding: 0 14px; } +.list-actions .text-link { height: 32px; padding: 0 6px; border: 0; color: #6b7079; background: transparent; font-size: 12px; font-weight: 600; } +.list-actions .text-link:hover { color: #b4332a; text-decoration: underline; } +.list-actions .solid-button:disabled { color: #9aa0a8; background: #eceef1; box-shadow: none; cursor: not-allowed; } +.list-actions .assign-control select { height: 32px; } +.state-danger { color: #b4332a; background: #fdeeed; } .table-note { margin: 0; padding: 12px 20px; border-top: 1px solid var(--line); color: var(--muted); font-size: 12px; } .equipment-detail { display: grid; gap: 16px; padding: 22px 24px; } .equipment-detail-head { display: flex; align-items: center; gap: 10px; } @@ -311,13 +327,6 @@ table { width: 100%; border-collapse: collapse; } th { height: 42px; padding: 0 15px; border-bottom: 1px solid var(--line); color: #5b6068; background: #f7f8fa; text-align: left; font-size: 12px; font-weight: 650; letter-spacing: .3px; white-space: nowrap; } td { min-height: 62px; padding: 13px 15px; border-bottom: 1px solid #e8e5e1; color: #443f3a; font-size: 13px; vertical-align: middle; } tbody tr:hover { background: #faf8f5; } -.entity { display: flex; align-items: center; gap: 10px; min-width: 190px; } -.entity > span { display: grid; flex: 0 0 auto; place-items: center; width: 33px; height: 33px; border-radius: 8px; color: var(--accent-text); background: var(--soft-accent); font-size: 14px; } -.entity > span.warn { color: #c4412f; background: #fdeeed; } -.entity div { min-width: 0; } -.entity b, .entity small { display: block; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.entity b { color: var(--ink); font-size: 13px; } -.entity small { margin-top: 4px; color: #736c66; font-size: 11px; } .mono { font-family: Consolas, "SF Mono", Menlo, monospace; font-size: 12px; } .block { display: block; margin-top: 3px; color: #7d766f; } .status { display: inline-flex; min-height: 26px; padding: 0 10px; border: 1px solid transparent; border-radius: 999px; align-items: center; font-size: 11px; font-weight: 700; white-space: nowrap; } diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 8131a26..4f914f6 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -29,6 +29,22 @@ function scheduleQuery(now = new Date()) { return query; } +const EQUIPMENT_LOOKUP = "/api/equipment?size=100&sort=name,asc"; +const LOOKUP_FAILED = "设备名录加载失败,设备列暂时只显示编号"; + +/** + * Device names and icons only decorate a page's own rows, so the lookup settles on its own: + * a failed lookup must never hide rows that did load. Rows fall back to "设备 #id" and the + * page says why, instead of staying empty or stale. + */ +async function settle(request: Promise): Promise<{ ok: true; data: T } | { ok: false }> { + try { + return { ok: true, data: await request }; + } catch { + return { ok: false }; + } +} + // The calendar filters locally, so publish the week only after every page succeeds. async function loadSchedule(token: string, signal: AbortSignal): Promise { const query = scheduleQuery(); @@ -139,22 +155,32 @@ export default function Home() { activeSessionRequests.current.add(controller); const isCurrentSessionRequest = () => generation === sessionGeneration.current && !controller.signal.aborted; + // Publishes a separately settled lookup after the page's own rows are already on screen. + const applyLookup = async ( + lookup: Promise<{ ok: true; data: PageData } | { ok: false }>, + apply: (data: PageData) => void, + ) => { + const result = await lookup; + if (!isCurrentSessionRequest()) return; + if (result.ok) apply(result.data); + else notify(LOOKUP_FAILED, true); + }; setPageLoading(true); try { if (target === "dashboard") { void loadTrends(token); - const [statsData, reservationData, workorderData, equipmentData] = await Promise.all([ + // Only used to show equipment names and category icons next to reservations / work orders. + const lookup = settle(api>(EQUIPMENT_LOOKUP, token, { signal: controller.signal })); + const [statsData, reservationData, workorderData] = await Promise.all([ api("/api/dashboard/stats", token, { signal: controller.signal }), api>("/api/reservations?size=12&sort=createdAt,desc", token, { signal: controller.signal }), api>("/api/work-orders?size=12&sort=createdAt,desc", token, { signal: controller.signal }), - // Only used to show equipment names and category icons next to reservations / work orders. - api>("/api/equipment?size=100&sort=name,asc", token, { signal: controller.signal }), ]); if (!isCurrentSessionRequest()) return; setStats(statsData); setReservations(reservationData); setWorkorders(workorderData); - setDashboardEquipment(equipmentData); + await applyLookup(lookup, setDashboardEquipment); } else if (target === "equipment") { const requestSequence = ++equipmentRequestSequence.current; const filter = appliedEquipmentFilter.current; @@ -174,20 +200,23 @@ export default function Home() { } else if (target === "reservations") { const query = new URLSearchParams({ size: "30", sort: "createdAt,desc" }); if (reservationFilter) query.set("status", reservationFilter); - const [reservationData, scheduleData, equipmentData] = await Promise.all([ + const lookup = settle(api>(EQUIPMENT_LOOKUP, token, { signal: controller.signal })); + const [reservationData, scheduleData] = await Promise.all([ api>(`/api/reservations?${query}`, token, { signal: controller.signal }), loadSchedule(token, controller.signal), - api>("/api/equipment?size=100&sort=name,asc", token, { signal: controller.signal }), ]); if (!isCurrentSessionRequest()) return; setReservations(reservationData); setScheduleReservations(scheduleData); - setEquipment(equipmentData); + await applyLookup(lookup, setEquipment); } else if (target === "workorders") { const query = new URLSearchParams({ size: "30", sort: "createdAt,desc" }); if (workorderFilter) query.set("status", workorderFilter); + // Name lookup for the device column; the equipment page's list is filtered, so it is not reused. + const lookup = settle(api>(EQUIPMENT_LOOKUP, token, { signal: controller.signal })); const workorderData = await api>(`/api/work-orders?${query}`, token, { signal: controller.signal }); if (isCurrentSessionRequest()) setWorkorders(workorderData); + await applyLookup(lookup, setDashboardEquipment); } else if (target === "audit") { const auditData = await api>("/api/audit-logs?size=40&sort=createdAt,desc", token, { signal: controller.signal }); if (isCurrentSessionRequest()) setAuditLogs(auditData); @@ -390,7 +419,7 @@ export default function Home() { {activeView === "dashboard" && void openCreate(kind)} onDecide={(id, decision) => void decideReservation(id, decision)} onRefresh={() => void load("dashboard")} />} {activeView === "equipment" && void openCreate("equipment")} onReserve={reserveEquipment} onReport={reportEquipment} onRetire={(item) => void mutate(`/api/equipment/${item.id}/retire`, { method: "PATCH" }, `设备 ${item.code} 已退役`)} onRestore={(item) => void mutate(`/api/equipment/${item.id}/restore`, { method: "PATCH" }, `设备 ${item.code} 已恢复`)} />} {activeView === "reservations" && void load("reservations")} onCreate={() => void openCreate("reservation")} onDecide={(id, decision) => void decideReservation(id, decision)} onCancel={(id) => void mutate(`/api/reservations/${id}/cancel`, { method: "PATCH" }, "预约已取消")} onComplete={(id) => void mutate(`/api/reservations/${id}/complete`, { method: "PATCH" }, "预约已完成")} />} - {activeView === "workorders" && user && void load("workorders")} onCreate={() => void openCreate("workorder")} onTransition={(item, target, assigneeId) => void transitionWorkorder(item, target, assigneeId)} onClaim={(item) => void claimWorkorder(item)} />} + {activeView === "workorders" && user && void load("workorders")} onCreate={() => void openCreate("workorder")} onTransition={(item, target, assigneeId) => void transitionWorkorder(item, target, assigneeId)} onClaim={(item) => void claimWorkorder(item)} />} {activeView === "audit" && } setModal(null)} onEquipmentChange={setEquipmentForm} onReservationChange={setReservationForm} onWorkorderChange={setWorkorderForm} onSubmitEquipment={submitEquipment} onSubmitReservation={submitReservation} onSubmitWorkorder={submitWorkorder} /> diff --git a/frontend/tests/reservation-schedule.test.mjs b/frontend/tests/reservation-schedule.test.mjs index 4bc4ba9..c2176a1 100644 --- a/frontend/tests/reservation-schedule.test.mjs +++ b/frontend/tests/reservation-schedule.test.mjs @@ -108,8 +108,8 @@ test("a conflicting request cannot be approved from the list; reject stays avail reservation("2026-10-20T11:00:00", "2026-10-20T13:00:00", "PENDING", { conflictsWith: [clash] }), reservation("2026-10-20T14:00:00", "2026-10-20T15:00:00", "PENDING", { conflictsWith: [] }), ] }); - assert.equal(html.match(/