Files
next-tjwater-drainage-frontend/features/workbench/components/scheduled-condition-feed.tsx
T

755 lines
29 KiB
TypeScript

"use client";
import {
CalendarClock,
ChevronDown,
ChevronRight,
ClipboardList,
History,
XCircle
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { cn } from "@/lib/utils";
import {
MAP_READABLE_SURFACE_STRONG_CLASS_NAME
} from "@/features/map/core/components/map-control-styles";
import { showMapNotice } from "@/features/map/core/components/notice";
import { isGeneratedScheduledConditionSessionId } from "@/features/workbench/data/scheduled-conditions";
import { createConditionConversationPrompt } from "@/features/workbench/utils/scheduled-condition-prompts";
import type { ScheduledConditionItem } from "@/features/workbench/types";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger
} from "@/shared/ui/dropdown-menu";
import { ConditionDetailPanel, StatusPill } from "./scheduled-condition-detail-panel";
import {
CONDITION_FILTER_ALL,
createStatusFilterOptions,
createTaskFilterOptions,
formatTime,
getConditionTime,
getStatusIcon,
getStatusIconClassName,
groupScheduledConditions,
type ConditionStatusFilterOption,
type ConditionStatusFilterValue,
type ConditionTaskFilterOption,
type ConditionTaskFilterValue
} from "./scheduled-condition-feed-utils";
type ScheduledConditionFeedProps = {
conditions?: ScheduledConditionItem[];
expanded?: boolean;
focusRequest?: ScheduledConditionFeedFocusRequest | null;
loading?: boolean;
selectedConditionId?: string | null;
onExpandedChange?: (expanded: boolean) => void;
onFocusRequestHandled?: (requestId: number) => void;
onSelectedConditionChange?: (conditionId: string | null) => void;
onExpandAgent: () => void;
onLoadHistorySession: (sessionId: string) => void | Promise<void>;
onSubmitPrompt: (prompt: string) => void | Promise<void>;
};
type ScheduledConditionFeedFocusRequest = {
conditionId: string;
requestId: number;
};
export function ScheduledConditionFeed({
conditions = [],
expanded: controlledExpanded,
focusRequest,
loading = false,
selectedConditionId: controlledSelectedConditionId,
onExpandedChange,
onFocusRequestHandled,
onSelectedConditionChange,
onExpandAgent,
onLoadHistorySession,
onSubmitPrompt
}: ScheduledConditionFeedProps) {
const [uncontrolledExpanded, setUncontrolledExpanded] = useState(false);
const [uncontrolledSelectedConditionId, setUncontrolledSelectedConditionId] = useState<string | null>(null);
const [taskFilter, setTaskFilter] = useState<ConditionTaskFilterValue>(CONDITION_FILTER_ALL);
const [statusFilter, setStatusFilter] = useState<ConditionStatusFilterValue>(CONDITION_FILTER_ALL);
const expanded = controlledExpanded ?? uncontrolledExpanded;
const selectedConditionId = controlledSelectedConditionId ?? uncontrolledSelectedConditionId;
const { recentConditions: allRecentConditions, futureWorkOrders: allFutureWorkOrders } = useMemo(
() => groupScheduledConditions(conditions),
[conditions]
);
const allTimelineConditions = useMemo(
() => [...allRecentConditions, ...allFutureWorkOrders],
[allFutureWorkOrders, allRecentConditions]
);
const taskFilterOptions = useMemo(() => createTaskFilterOptions(conditions), [conditions]);
const taskFilteredConditions = useMemo(
() => conditions.filter((condition) => taskFilter === CONDITION_FILTER_ALL || condition.title === taskFilter),
[conditions, taskFilter]
);
const statusFilterOptions = useMemo(() => createStatusFilterOptions(taskFilteredConditions), [taskFilteredConditions]);
const taskFilteredConditionCount = taskFilteredConditions.length;
const filteredConditions = useMemo(
() =>
taskFilteredConditions.filter(
(condition) => statusFilter === CONDITION_FILTER_ALL || condition.status === statusFilter
),
[statusFilter, taskFilteredConditions]
);
const { recentConditions, futureWorkOrders } = useMemo(() => groupScheduledConditions(filteredConditions), [filteredConditions]);
const timelineConditions = useMemo(
() => [...recentConditions, ...futureWorkOrders],
[futureWorkOrders, recentConditions]
);
const filterActive = taskFilter !== CONDITION_FILTER_ALL || statusFilter !== CONDITION_FILTER_ALL;
const totalConditionCount = allRecentConditions.length + allFutureWorkOrders.length;
const filteredConditionCount = recentConditions.length + futureWorkOrders.length;
const headerSummary = loading
? "正在加载工况任务"
: expanded && filterActive
? `筛选 ${filteredConditionCount}/${totalConditionCount} 条 · 预约 ${futureWorkOrders.length}/${allFutureWorkOrders.length} 条`
: expanded
? `最近 ${recentConditions.length} 条 · 预约 ${futureWorkOrders.length} 条`
: `最近 ${recentConditions.length} 条`;
const selectedCondition =
timelineConditions.find((condition) => condition.id === selectedConditionId) ??
recentConditions[0] ??
futureWorkOrders[0] ??
null;
const focusConditionId = focusRequest?.conditionId;
const focusRequestId = focusRequest?.requestId;
const updateExpanded = useCallback(
(nextExpanded: boolean) => {
if (controlledExpanded === undefined) {
setUncontrolledExpanded(nextExpanded);
}
onExpandedChange?.(nextExpanded);
},
[controlledExpanded, onExpandedChange]
);
const updateSelectedConditionId = useCallback(
(nextConditionId: string | null) => {
if (controlledSelectedConditionId === undefined) {
setUncontrolledSelectedConditionId(nextConditionId);
}
onSelectedConditionChange?.(nextConditionId);
},
[controlledSelectedConditionId, onSelectedConditionChange]
);
useEffect(() => {
if (taskFilter === CONDITION_FILTER_ALL) {
return;
}
if (!taskFilterOptions.some((option) => option.value === taskFilter)) {
setTaskFilter(CONDITION_FILTER_ALL);
}
}, [taskFilter, taskFilterOptions]);
useEffect(() => {
if (!expanded) {
return;
}
const nextSelectedConditionId = selectedCondition?.id ?? null;
if (selectedConditionId !== nextSelectedConditionId) {
updateSelectedConditionId(nextSelectedConditionId);
}
}, [expanded, selectedCondition?.id, selectedConditionId, updateSelectedConditionId]);
useEffect(() => {
if (!focusConditionId || focusRequestId === undefined) {
return;
}
const targetCondition = allTimelineConditions.find((condition) => condition.id === focusConditionId);
if (!targetCondition) {
return;
}
setTaskFilter(CONDITION_FILTER_ALL);
setStatusFilter(CONDITION_FILTER_ALL);
updateSelectedConditionId(targetCondition.id);
updateExpanded(true);
onFocusRequestHandled?.(focusRequestId);
}, [allTimelineConditions, focusConditionId, focusRequestId, onFocusRequestHandled, updateExpanded, updateSelectedConditionId]);
function handleToggleExpanded() {
updateExpanded(!expanded);
updateSelectedConditionId(selectedConditionId ?? recentConditions[0]?.id ?? futureWorkOrders[0]?.id ?? null);
}
function handleSelectTimelineCondition(conditionId: string) {
updateSelectedConditionId(conditionId);
if (!expanded) {
updateExpanded(true);
}
}
function handleContinueConversation(condition: ScheduledConditionItem) {
if (condition.kind === "work_order") {
return;
}
onExpandAgent();
if (!condition.sessionId) {
showMapNotice({
id: "scheduled-condition-missing-session",
tone: "error",
title: "无法打开历史会话",
message: "这条工况没有可加载的会话记录。"
});
return;
}
if (isGeneratedScheduledConditionSessionId(condition.sessionId)) {
void Promise.resolve(onSubmitPrompt(createConditionConversationPrompt(condition)))
.then(() => {
showMapNotice({
id: "scheduled-condition-generated-session",
tone: "success",
title: "已发起工况对话",
message: "已将当前工况上下文发送到 Agent 面板。"
});
})
.catch((submitError) => {
showMapNotice({
id: "scheduled-condition-generated-session",
tone: "error",
title: "工况对话发起失败",
message: submitError instanceof Error ? submitError.message : "无法将当前工况发送到 Agent。"
});
});
return;
}
void Promise.resolve(onLoadHistorySession(condition.sessionId));
}
return (
<section
aria-label="工况任务"
className={cn(
"scheduled-feed-panel-shell pointer-events-auto max-w-[calc(100vw-6rem)] overflow-hidden p-3 motion-reduce:transition-none",
expanded ? "flex max-h-[calc(100dvh-8rem)] w-[880px] flex-col 2xl:w-[960px]" : "w-[432px]",
"rounded-2xl border",
expanded ? "glass-panel" : "glass-control"
)}
>
<header className={cn("flex items-center gap-2.5 px-2.5 py-2", "rounded-xl", expanded ? "bg-white/30" : MAP_READABLE_SURFACE_STRONG_CLASS_NAME)}>
<span className={cn("grid h-8 w-8 shrink-0 place-items-center bg-blue-50 text-blue-700", "rounded-lg")}>
<CalendarClock size={17} aria-hidden="true" />
</span>
<div className="min-w-0 flex-1">
<h2 className="truncate text-sm font-semibold leading-tight text-slate-950">工况任务</h2>
<p className="mt-0.5 truncate text-xs text-slate-500">{headerSummary}</p>
</div>
<button
type="button"
aria-expanded={expanded}
onClick={handleToggleExpanded}
className={cn(
"grid h-8 w-8 shrink-0 place-items-center text-slate-500 transition hover:bg-blue-50 hover:text-blue-700",
"rounded-lg"
)}
>
<ChevronDown
size={17}
aria-hidden="true"
className={cn("transition-transform duration-150", expanded && "rotate-180")}
/>
<span className="sr-only">{expanded ? "收起工况任务" : "展开工况任务"}</span>
</button>
</header>
<div
className={cn(
"scheduled-feed-layout mt-3 grid min-h-0",
expanded ? "scheduled-feed-layout-expanded" : "scheduled-feed-layout-collapsed"
)}
>
<div
className={cn(
"min-h-0 min-w-0 overflow-hidden",
expanded ? "scheduled-feed-detail-enter" : "pointer-events-none opacity-0"
)}
aria-hidden={!expanded}
>
{loading && expanded ? (
<ConditionDetailSkeleton />
) : expanded && selectedCondition ? (
<ConditionDetailPanel
condition={selectedCondition}
onContinueConversation={() => handleContinueConversation(selectedCondition)}
/>
) : null}
</div>
<div className="min-h-0 min-w-0 overflow-hidden">
<ConditionTimelinePanel
expanded={expanded}
loading={loading}
recentConditions={recentConditions}
futureWorkOrders={futureWorkOrders}
taskFilter={taskFilter}
statusFilter={statusFilter}
taskFilterOptions={taskFilterOptions}
statusFilterOptions={statusFilterOptions}
selectedConditionId={expanded ? selectedCondition?.id ?? null : null}
totalConditionCount={totalConditionCount}
taskFilteredConditionCount={taskFilteredConditionCount}
filteredConditionCount={filteredConditionCount}
onTaskFilterChange={setTaskFilter}
onStatusFilterChange={setStatusFilter}
onResetFilters={() => {
setTaskFilter(CONDITION_FILTER_ALL);
setStatusFilter(CONDITION_FILTER_ALL);
}}
onSelectCondition={handleSelectTimelineCondition}
/>
</div>
</div>
</section>
);
}
type ConditionTimelinePanelProps = {
expanded: boolean;
loading: boolean;
recentConditions: ScheduledConditionItem[];
futureWorkOrders: ScheduledConditionItem[];
taskFilter: ConditionTaskFilterValue;
statusFilter: ConditionStatusFilterValue;
taskFilterOptions: ConditionTaskFilterOption[];
statusFilterOptions: ConditionStatusFilterOption[];
selectedConditionId: string | null;
totalConditionCount: number;
taskFilteredConditionCount: number;
filteredConditionCount: number;
onTaskFilterChange: (value: ConditionTaskFilterValue) => void;
onStatusFilterChange: (value: ConditionStatusFilterValue) => void;
onResetFilters: () => void;
onSelectCondition: (conditionId: string) => void;
};
function ConditionTimelinePanel({
expanded,
loading,
recentConditions,
futureWorkOrders,
taskFilter,
statusFilter,
taskFilterOptions,
statusFilterOptions,
selectedConditionId,
totalConditionCount,
taskFilteredConditionCount,
filteredConditionCount,
onTaskFilterChange,
onStatusFilterChange,
onResetFilters,
onSelectCondition
}: ConditionTimelinePanelProps) {
const filterActive = taskFilter !== CONDITION_FILTER_ALL || statusFilter !== CONDITION_FILTER_ALL;
return (
<section
aria-busy={loading}
className={cn(
"flex min-h-0 flex-col overflow-hidden p-2.5",
expanded ? "h-[calc(100dvh-14rem)] max-h-[calc(100dvh-14rem)]" : "h-[420px] max-h-[420px]",
"rounded-xl",
expanded
? "border border-white/30 bg-white/10"
: MAP_READABLE_SURFACE_STRONG_CLASS_NAME
)}
>
{expanded && !loading ? (
<ConditionFilterBar
taskFilter={taskFilter}
statusFilter={statusFilter}
taskFilterOptions={taskFilterOptions}
statusFilterOptions={statusFilterOptions}
filteredConditionCount={filteredConditionCount}
totalConditionCount={totalConditionCount}
taskFilteredConditionCount={taskFilteredConditionCount}
onTaskFilterChange={onTaskFilterChange}
onStatusFilterChange={onStatusFilterChange}
onResetFilters={onResetFilters}
/>
) : null}
{expanded && !loading && futureWorkOrders.length > 0 ? (
<section className="mb-2 rounded-xl border border-blue-100 bg-blue-50/55 p-2">
<ConditionGroupHeader icon={ClipboardList} title="预约任务" count={futureWorkOrders.length} compact />
<div className="scheduled-feed-scroll max-h-[132px] overflow-y-auto pr-1">
<ConditionRows
conditions={futureWorkOrders}
selectedConditionId={selectedConditionId}
onSelectCondition={onSelectCondition}
/>
</div>
</section>
) : null}
<ConditionGroupHeader icon={History} title="最近工况" count={loading ? 0 : recentConditions.length} />
<div className="scheduled-feed-scroll -mr-2 min-h-0 flex-1 overflow-y-auto pb-4 pl-0.5 pr-2 pt-0.5">
{loading ? (
<ConditionTimelineSkeleton rows={expanded ? 7 : 6} />
) : recentConditions.length > 0 ? (
<ConditionRows
conditions={recentConditions}
selectedConditionId={selectedConditionId}
onSelectCondition={onSelectCondition}
/>
) : (
<ConditionEmptyState
title={filterActive ? "没有符合筛选的最近工况" : "暂无最近工况"}
description={filterActive ? "调整任务或状态筛选后查看其他工况记录。" : "新的工况任务完成后会出现在这里。"}
/>
)}
</div>
</section>
);
}
type ConditionFilterBarProps = {
taskFilter: ConditionTaskFilterValue;
statusFilter: ConditionStatusFilterValue;
taskFilterOptions: ConditionTaskFilterOption[];
statusFilterOptions: ConditionStatusFilterOption[];
filteredConditionCount: number;
totalConditionCount: number;
taskFilteredConditionCount: number;
onTaskFilterChange: (value: ConditionTaskFilterValue) => void;
onStatusFilterChange: (value: ConditionStatusFilterValue) => void;
onResetFilters: () => void;
};
function ConditionFilterBar({
taskFilter,
statusFilter,
taskFilterOptions,
statusFilterOptions,
filteredConditionCount,
totalConditionCount,
taskFilteredConditionCount,
onTaskFilterChange,
onStatusFilterChange,
onResetFilters
}: ConditionFilterBarProps) {
const filterActive = taskFilter !== CONDITION_FILTER_ALL || statusFilter !== CONDITION_FILTER_ALL;
return (
<div className="mb-2 rounded-xl border border-slate-200/70 bg-slate-50/95 p-2 ring-1 ring-white/80">
<div className="mb-2 flex items-center justify-between gap-2 px-1">
<span className="text-xs font-semibold text-slate-700">筛选</span>
<span className="shrink-0 text-xs font-semibold text-slate-400">
{filteredConditionCount}/{totalConditionCount}
</span>
</div>
<div className="grid grid-cols-2 gap-2">
<ConditionFilterDropdown
label="任务"
value={taskFilter}
onValueChange={onTaskFilterChange}
options={[
{ value: CONDITION_FILTER_ALL, label: "全部任务", count: totalConditionCount },
...taskFilterOptions
]}
/>
<ConditionFilterDropdown
label="状态"
value={statusFilter}
onValueChange={(value) => onStatusFilterChange(value as ConditionStatusFilterValue)}
options={[
{ value: CONDITION_FILTER_ALL, label: "全部状态", count: taskFilteredConditionCount },
...statusFilterOptions
]}
/>
</div>
{filterActive ? (
<button
type="button"
onClick={onResetFilters}
className="mt-2 inline-flex h-7 w-full items-center justify-center gap-1.5 rounded-lg border border-blue-100 bg-white px-2 text-xs font-semibold text-blue-700 shadow-sm transition hover:border-blue-200 hover:bg-blue-50"
>
<XCircle size={12} aria-hidden="true" />
重置筛选
</button>
) : null}
</div>
);
}
type ConditionFilterDropdownProps<TValue extends string> = {
label: string;
value: TValue;
options: {
value: TValue;
label: string;
count: number;
}[];
onValueChange: (value: TValue) => void;
};
function ConditionFilterDropdown<TValue extends string>({
label,
value,
options,
onValueChange
}: ConditionFilterDropdownProps<TValue>) {
const selectedOption = options.find((option) => option.value === value) ?? options[0];
return (
<div className="min-w-0">
<span className="mb-1 block text-xs font-semibold text-slate-400">{label}</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
"group flex h-9 w-full min-w-0 items-center gap-2 border border-white/80 bg-white px-2 text-left text-xs shadow-sm outline-none transition hover:border-blue-100 hover:bg-blue-50/35 focus-visible:border-blue-200 focus-visible:ring-2 focus-visible:ring-blue-100 data-[state=open]:border-blue-200 data-[state=open]:bg-blue-50/60 data-[state=open]:ring-2 data-[state=open]:ring-blue-100",
"rounded-xl"
)}
>
<span className="min-w-0 flex-1">
<span className="block truncate font-semibold text-slate-800" title={selectedOption?.label}>
{selectedOption?.label ?? "全部"}
</span>
<span className="block text-xs leading-3 text-slate-400">{selectedOption?.count ?? 0} </span>
</span>
<ChevronDown
size={13}
aria-hidden="true"
className="shrink-0 text-slate-400 transition group-hover:text-blue-600 group-data-[state=open]:rotate-180 group-data-[state=open]:text-blue-600"
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
sideOffset={6}
className="glass-menu scheduled-feed-scroll z-50 max-h-[280px] w-[var(--radix-dropdown-menu-trigger-width)] overflow-y-auto rounded-xl border p-1.5 text-slate-700"
>
<DropdownMenuRadioGroup value={value} onValueChange={(nextValue) => onValueChange(nextValue as TValue)}>
{options.map((option) => (
<DropdownMenuRadioItem
key={option.value}
value={option.value}
className={cn(
"my-0.5 min-h-9 gap-2 border border-transparent py-1.5 pl-7 pr-2 text-xs transition focus:bg-blue-50/80 focus:text-blue-800 data-[state=checked]:border-blue-100 data-[state=checked]:bg-blue-50/90 data-[state=checked]:text-blue-800",
"rounded-xl"
)}
>
<span className="min-w-0 flex-1 truncate font-semibold">{option.label}</span>
<span className="shrink-0 rounded-full border border-slate-200 bg-white px-1.5 py-0.5 text-xs font-semibold leading-4 text-slate-500">
{option.count}
</span>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
function ConditionEmptyState({ title, description }: { title: string; description: string }) {
return (
<div className="flex min-h-full flex-col items-center justify-center rounded-xl border border-dashed border-slate-200 bg-[var(--glass-readable)] px-4 py-5 text-center">
<span className="grid h-8 w-8 place-items-center rounded-lg bg-slate-100 text-slate-400">
<ChevronRight size={15} aria-hidden="true" />
</span>
<p className="mt-2 text-xs font-semibold text-slate-700">{title}</p>
<p className="mt-1 text-xs leading-4 text-slate-500">{description}</p>
</div>
);
}
function ConditionTimelineSkeleton({ rows }: { rows: number }) {
return (
<div className="space-y-1.5" role="status" aria-label="工况任务加载中">
{Array.from({ length: rows }).map((_, index) => (
<div
key={index}
className="grid min-h-[60px] grid-cols-[42px_24px_minmax(0,1fr)] gap-2 rounded-xl px-1 py-1"
>
<span className="mt-2 h-4 w-9 animate-pulse rounded-lg bg-slate-200/80" />
<span className="relative flex min-h-12 justify-center pt-1.5">
{index < rows - 1 ? (
<span className="pointer-events-none absolute bottom-[-0.5rem] top-8 w-px bg-slate-200" aria-hidden="true" />
) : null}
<span className="relative z-10 h-6 w-6 animate-pulse rounded-full bg-slate-200 ring-4 ring-white" />
</span>
<span className="min-w-0 rounded-xl border border-transparent bg-[var(--glass-menu)] px-2 py-1.5">
<span className="flex min-w-0 items-center gap-2">
<span className="min-w-0 flex-1 space-y-1.5">
<span className="block h-3.5 w-24 animate-pulse rounded-full bg-slate-200/90" />
<span className="block h-3 w-full max-w-[260px] animate-pulse rounded-full bg-slate-100" />
</span>
<span className="h-5 w-12 shrink-0 animate-pulse rounded-full bg-slate-100" />
</span>
</span>
</div>
))}
</div>
);
}
function ConditionDetailSkeleton() {
return (
<DetailScroll>
<div
className={cn(
"relative min-h-[420px] overflow-hidden border border-slate-200/60 bg-white/30 px-4 py-3",
"rounded-xl"
)}
role="status"
aria-label="工况详情加载中"
>
<div className="absolute inset-y-0 left-0 w-1 bg-blue-300" aria-hidden="true" />
<div className="flex gap-2.5">
<span className={cn("h-9 w-9 shrink-0 animate-pulse bg-slate-100", "rounded-lg")} />
<div className="min-w-0 flex-1 space-y-2">
<div className="flex gap-2">
<span className="h-4 w-12 animate-pulse rounded-full bg-slate-200" />
<span className="h-5 w-14 animate-pulse rounded-full bg-blue-100" />
</div>
<span className="block h-4 w-44 animate-pulse rounded-full bg-slate-200" />
<span className="block h-3 w-full animate-pulse rounded-full bg-slate-100" />
<span className="block h-3 w-4/5 animate-pulse rounded-full bg-slate-100" />
</div>
</div>
<div className="mt-4 grid gap-2">
{Array.from({ length: 5 }).map((_, index) => (
<div key={index} className="rounded-xl border border-slate-200/70 bg-slate-50/80 px-3 py-3">
<span className="block h-3.5 w-28 animate-pulse rounded-full bg-slate-200" />
<span className="mt-2 block h-3 w-full animate-pulse rounded-full bg-slate-100" />
<span className="mt-1.5 block h-3 w-3/4 animate-pulse rounded-full bg-slate-100" />
</div>
))}
</div>
</div>
</DetailScroll>
);
}
function DetailScroll({ children }: { children: React.ReactNode }) {
return (
<section className="scheduled-feed-scroll scheduled-feed-detail-scroll max-h-[calc(100dvh-14rem)] min-h-0 overflow-y-scroll py-0.5 pl-0.5 pr-3">
{children}
</section>
);
}
type ConditionGroupHeaderProps = {
icon: typeof History;
title: string;
count: number;
compact?: boolean;
};
function ConditionGroupHeader({ icon: Icon, title, count, compact = false }: ConditionGroupHeaderProps) {
return (
<div className={cn("flex items-center justify-between px-1 py-1 text-xs font-semibold text-slate-500", compact ? "mb-1" : "mb-2")}>
<span className="flex items-center gap-1.5">
<Icon size={13} aria-hidden="true" />
<span>{title}</span>
</span>
<span>{count}</span>
</div>
);
}
type ConditionRowsProps = {
conditions: ScheduledConditionItem[];
selectedConditionId: string | null;
onSelectCondition: (conditionId: string) => void;
};
function ConditionRows({ conditions, selectedConditionId, onSelectCondition }: ConditionRowsProps) {
return (
<div className="space-y-1.5">
{conditions.map((condition, index) => (
<ConditionTimelineRow
key={condition.id}
condition={condition}
selected={selectedConditionId === condition.id}
isLast={index === conditions.length - 1}
onSelect={() => onSelectCondition(condition.id)}
/>
))}
</div>
);
}
type ConditionTimelineRowProps = {
condition: ScheduledConditionItem;
selected: boolean;
isLast: boolean;
onSelect: () => void;
};
function ConditionTimelineRow({ condition, selected, isLast, onSelect }: ConditionTimelineRowProps) {
const StatusIcon = getStatusIcon(condition.status);
const isWorkOrder = condition.kind === "work_order";
return (
<button
type="button"
aria-pressed={selected}
onClick={onSelect}
className={cn(
"group grid min-h-[60px] w-full grid-cols-[42px_24px_minmax(0,1fr)] gap-2 rounded-xl px-1 py-1 text-left outline-none transition-colors focus-visible:bg-blue-50/40 focus-visible:ring-2 focus-visible:ring-blue-200/80 focus-visible:ring-offset-1 focus-visible:ring-offset-white",
selected ? "bg-blue-50/70" : "hover:bg-blue-50/45"
)}
>
<span className="pt-2 font-mono text-xs font-semibold leading-4 text-slate-500">{formatTime(condition.scheduledAt)}</span>
<span
className="relative flex min-h-12 justify-center pt-1.5"
>
{!isLast ? (
<span
className="pointer-events-none absolute bottom-[-0.5rem] top-8 w-px bg-slate-200 transition-colors group-hover:bg-blue-200 group-focus-visible:bg-blue-300"
aria-hidden="true"
/>
) : null}
<span
className={cn(
"relative z-10 grid h-6 w-6 place-items-center rounded-full ring-4 ring-white",
selected
? "bg-blue-600 text-white group-focus-visible:ring-blue-100"
: isWorkOrder
? "bg-blue-50 text-blue-700 group-focus-visible:bg-blue-100 group-focus-visible:text-blue-800"
: getStatusIconClassName(condition.status)
)}
>
<StatusIcon size={12} aria-hidden="true" className={condition.status === "running" ? "animate-spin" : undefined} />
</span>
</span>
<span
className={cn(
"min-w-0 rounded-xl border px-2 py-1.5 transition-colors group-focus-visible:border-blue-300 group-focus-visible:bg-white",
selected
? "border-blue-300 bg-white text-blue-900 shadow-sm shadow-blue-600/10 ring-1 ring-blue-300/40"
: "border-transparent bg-[var(--glass-menu)] text-slate-700 group-hover:border-blue-100 group-hover:bg-white"
)}
>
<span className="flex min-w-0 items-center gap-2">
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-semibold leading-4">{condition.title}</span>
<span className="block truncate text-xs leading-4 text-slate-500">{condition.summary}</span>
</span>
<StatusPill condition={condition} className="shrink-0 text-xs" />
</span>
</span>
</button>
);
}