Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`。测试至少覆盖一行、两行和末行三种情况。

## 编辑方式
Expand Down
109 changes: 109 additions & 0 deletions frontend/app/components/table.tsx
Original file line number Diff line number Diff line change
@@ -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<string, StateTone> = {
// 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 <span className={`state state-${STATE_TONES[status] ?? "idle"}`}><i />{statusLabels[status] ?? status}</span>;
}

export function CategoryTag({ category }: { category: string }) {
return <span className={`cat-tag tone-${categoryTone(category)}`}>{category}</span>;
}

/** 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 <div className="equipment-name">
<span className="equip-thumb" title={equipment?.category}><Icon name={categoryIcon(equipment?.category)} size={22} /></span>
<div><b>{equipment?.name ?? `设备 #${equipmentId}`}</b><small className="mono">{equipment?.code ?? "—"}</small></div>
</div>;
}

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 <body> 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<HTMLButtonElement>(null);
const list = useRef<HTMLDivElement>(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 <div className="more-menu">
<button ref={button} type="button" className="icon-button" data-action="more" aria-label={label} aria-haspopup="menu" aria-expanded={open} onClick={() => onToggle(!open)}>
<Icon name="more" size={18} />
</button>
{open && createPortal(<div ref={list} className="more-menu-list" role="menu" aria-label={label} data-menu-for={id}>
{entries.map((entry) => <button key={entry.key} type="button" role="menuitem" data-menu={entry.key} className={entry.danger ? "danger" : ""}
onClick={() => { onToggle(false); entry.onClick(); }}>{entry.label}</button>)}
</div>, document.body)}
</div>;
}
85 changes: 6 additions & 79 deletions frontend/app/components/views/EquipmentView.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string> = { 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 <span className={`state state-${STATE_TONE[status] ?? "idle"}`}><i />{statusLabels[status] ?? status}</span>;
}

function CategoryTag({ category }: { category: string }) {
return <span className={`cat-tag tone-${categoryTone(category)}`}>{category}</span>;
}

export function EquipmentView({ equipment, categories, filter, canManage, canReserve, onFilterChange, onLoad, onReset, onCreate, onReserve, onReport, onRetire, onRestore }: {
equipment: PageData<Equipment> | null;
/** Every category in use (GET /api/equipment/categories); null while loading or if that request failed. */
Expand Down Expand Up @@ -78,11 +67,11 @@ export function EquipmentView({ equipment, categories, filter, canManage, canRes
<thead><tr><th>设备名称</th><th>设备编号</th><th>类别</th><th>所在位置</th><th>负责人</th><th>状态</th><th>操作</th></tr></thead>
<tbody>{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 <tr key={item.id} data-equipment-id={item.id}>
<td><div className="equipment-name">
<span className="equip-thumb" title={item.category}><Icon name={categoryIcon(item.category)} size={22} /></span>
Expand Down Expand Up @@ -113,68 +102,6 @@ export function EquipmentView({ equipment, categories, filter, canManage, canRes
</div>;
}

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 <body> 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<HTMLButtonElement>(null);
const list = useRef<HTMLDivElement>(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 <div className="more-menu">
<button ref={button} type="button" className="icon-button" data-action="more" aria-label={label} aria-haspopup="menu" aria-expanded={open} onClick={() => onToggle(!open)}>
<Icon name="more" size={18} />
</button>
{open && createPortal(<div ref={list} className="more-menu-list" role="menu" aria-label={label} data-menu-for={id}>
{entries.map((entry) => <button key={entry.key} type="button" role="menuitem" data-menu={entry.key} className={entry.danger ? "danger" : ""}
onClick={() => { onToggle(false); entry.onClick(); }}>{entry.label}</button>)}
</div>, document.body)}
</div>;
}

function EquipmentDetail({ item, canReserve, onClose, onReserve, onReport }: {
item: Equipment; canReserve: boolean; onClose: () => void; onReserve: () => void; onReport: () => void;
}) {
Expand Down
39 changes: 24 additions & 15 deletions frontend/app/components/views/ReservationsView.tsx
Original file line number Diff line number Diff line change
@@ -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. */
Expand Down Expand Up @@ -96,6 +97,7 @@ export function ReservationsView({ user, reservations, scheduleReservations, equ
onCancel: (id: number) => void;
onComplete: (id: number) => void;
}) {
const [menuFor, setMenuFor] = useState<number | null>(null);
const equipmentList = equipment?.content ?? [];
const equipmentById = new Map(equipmentList.map((item) => [item.id, item]));
return <div className="view-stack">
Expand All @@ -111,35 +113,42 @@ export function ReservationsView({ user, reservations, scheduleReservations, equ
<ReservationSchedule schedule={scheduleReservations} equipment={equipmentList} now={new Date()} />
<section className="panel table-panel">
<header><h3>预约记录</h3><small>共 {reservations?.totalElements ?? 0} 条</small></header>
<div className="table-scroll"><table>
<thead><tr><th>用途与申请人</th><th>使用时段</th><th>设备</th><th>状态</th><th>操作</th></tr></thead>
{Boolean(reservations?.content.length) && <div className="table-scroll"><table className="list-table">
<thead><tr><th>设备</th><th>用途与申请人</th><th>使用时段</th><th>状态</th><th>操作</th></tr></thead>
<tbody>{reservations?.content.map((item) => {
const device = equipmentById.get(item.equipmentId);
const conflict = conflictSummary(item);
return <tr key={item.id}>
<td><div className="entity"><span><Icon name="calendar" size={17} /></span><div><b>{item.purpose}</b><small>{item.requesterName} · #{item.id}</small></div></div></td>
<td><b>{formatDate(item.startTime)}</b><small className="block">至 {formatDate(item.endTime)}</small></td>
<td>{device ? <><b>{device.name}</b><small className="block mono">{device.code}</small></> : <span className="mono">#{item.equipmentId}</span>}</td>
const menu = menuEntries(
item.requesterId === user.id && ["PENDING", "APPROVED"].includes(item.status)
&& { key: "cancel", label: "取消预约", danger: true, onClick: () => onCancel(item.id) },
);
return <tr key={item.id} data-reservation-id={item.id}>
<td><EquipmentCell equipment={equipmentById.get(item.equipmentId)} equipmentId={item.equipmentId} /></td>
<td><div className="fault-cell"><b>{item.purpose}</b><small>{item.requesterName} · #{item.id}</small></div></td>
<td className="nowrap"><b>{formatDate(item.startTime)}</b><small className="block">至 {formatDate(item.endTime)}</small></td>
<td>
<StatusBadge value={item.status} />
<StateBadge status={item.status} />
{conflict && <small className="conflict-note" title={conflict.detail}><Icon name="alert" size={13} />{conflict.text}</small>}
</td>
<td><div className="row-actions">
<td><div className="list-actions">
{canApprove && item.status === "PENDING" && <>
<button
className="approve"
type="button"
className="approve solid-button"
disabled={Boolean(conflict)}
title={conflict?.detail}
onClick={() => onDecide(item.id, "APPROVED")}
>批准</button>
<button className="text-link" onClick={() => onDecide(item.id, "REJECTED")}>拒绝</button>
<button type="button" className="text-link" onClick={() => onDecide(item.id, "REJECTED")}>拒绝</button>
</>}
{item.requesterId === user.id && ["PENDING", "APPROVED"].includes(item.status) && <button onClick={() => onCancel(item.id)}>取消</button>}
{canApprove && (item.status === "APPROVED" || item.status === "PAID") && <button onClick={() => onComplete(item.id)}>完成</button>}
{canApprove && (item.status === "APPROVED" || item.status === "PAID")
&& <button type="button" className="solid-button" data-action="complete" onClick={() => onComplete(item.id)}>完成</button>}
{menu.length > 0 && <MoreMenu id={item.id} open={menuFor === item.id} label={`预约 #${item.id} 更多操作`} entries={menu}
onToggle={(open) => setMenuFor(open ? item.id : null)} />}
</div></td>
</tr>;
})}</tbody>
</table>{!reservations?.content.length && <Empty text="没有符合条件的预约" />}</div>
</table></div>}
{!reservations?.content.length && <Empty text="没有符合条件的预约" />}
</section>
</div>;
}
Loading
Loading