/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ /** * ScheduleCard — surface 4D / construction-schedule data in the Inspector. * * Two complementary views, picked automatically based on the selection: * • Selected entity is a *product* controlled by one or more IfcTasks → * "Construction Schedule" card listing each controlling task with its * start/finish/duration and parent work-schedule name. * • Selected entity is itself an IfcTask / IfcWorkSchedule (rare in * practice — these typically aren't pickable in the 3D view) → show * its time data directly. * * The card pulls from the viewer's `scheduleSlice` (which holds both parsed * and locally-generated schedules), so it lights up automatically the moment * the user generates a schedule via the Gantt panel — no separate fetch. */ import { useMemo } from 'react'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { CalendarClock, Diamond, Flag } from 'lucide-react'; import type { ScheduleExtraction, ScheduleTaskInfo } from '@ifc-lite/parser'; interface ScheduleCardProps { /** Schedule data from the viewer's slice (parsed or generated). */ scheduleData: ScheduleExtraction | null; /** Selected entity's local express ID. */ selectedExpressId: number | null; /** Selected entity's globalId (used as a fallback when expressId === 0). */ selectedGlobalId?: string | null; /** * When true, the schedule was created via the Gantt panel's "Generate * from storeys" dialog and isn't yet baked into the source IFC. We render * a small "Generated locally" badge so users know the schedule will be * spliced in on the next IFC export. */ isGenerated: boolean; } export function ScheduleCard({ scheduleData, selectedExpressId, selectedGlobalId, isGenerated, }: ScheduleCardProps) { const tasks = useMemo( () => findControllingTasks(scheduleData, selectedExpressId, selectedGlobalId), [scheduleData, selectedExpressId, selectedGlobalId], ); const scheduleNames = useMemo( () => buildScheduleNameLookup(scheduleData), [scheduleData], ); if (tasks.length === 0) return null; return ( Construction Schedule {isGenerated && ( Pending )} {tasks.length} {tasks.length === 1 ? 'task' : 'tasks'}
{isGenerated && (
Generated locally — will be spliced into the next IFC export.
)}
{tasks.map((task) => ( ))}
); } interface TaskRowProps { task: ScheduleTaskInfo; scheduleNames: Map; } function TaskRow({ task, scheduleNames }: TaskRowProps) { const start = formatDate(task.taskTime?.scheduleStart); const finish = formatDate(task.taskTime?.scheduleFinish); const duration = task.taskTime?.scheduleDuration; const completion = task.taskTime?.completion; const isCritical = task.taskTime?.isCritical === true; const scheduleLabels = task.controllingScheduleGlobalIds .map(gid => scheduleNames.get(gid)) .filter((s): s is string => Boolean(s)); return (
{task.isMilestone ? ( ) : isCritical ? ( ) : null} {task.name || task.identification || task.globalId.slice(0, 12)} {task.predefinedType && ( {task.predefinedType} )}
{start && ( <> Start {start} )} {finish && ( <> Finish {finish} )} {duration && ( <> Duration {duration} )} {completion !== undefined && ( <> Complete {Math.round(completion)}% )} {scheduleLabels.length > 0 && ( <> Schedule {scheduleLabels.join(', ')} )}
); } /** * Find all tasks whose products include the selected entity. * * Federation-aware: prefer `productGlobalIds` whenever we know the entity's * globalId (and the task carries globalIds of its own) — local expressIds can * collide across federated models, so matching by globalId first is the safe * default. Fall back to `productExpressIds` only for schedules that never * recorded globalIds (legacy / headless extraction paths). */ function findControllingTasks( data: ScheduleExtraction | null, selectedExpressId: number | null, selectedGlobalId: string | null | undefined, ): ScheduleTaskInfo[] { if (!data || data.tasks.length === 0) return []; if (selectedExpressId === null && !selectedGlobalId) return []; const out: ScheduleTaskInfo[] = []; for (const task of data.tasks) { const taskHasGlobalIds = task.productGlobalIds.some(Boolean); if (selectedGlobalId && taskHasGlobalIds) { if (task.productGlobalIds.includes(selectedGlobalId)) out.push(task); // When globalIds are the authoritative side, do NOT also match on // expressId — a collision across models would produce a false positive. continue; } if (selectedExpressId !== null && selectedExpressId > 0 && task.productExpressIds.includes(selectedExpressId)) { out.push(task); } } return out; } function buildScheduleNameLookup(data: ScheduleExtraction | null): Map { const map = new Map(); if (!data) return map; for (const ws of data.workSchedules) { if (ws.globalId && ws.name) map.set(ws.globalId, ws.name); } return map; } function formatDate(iso: string | undefined): string | undefined { if (!iso) return undefined; const t = Date.parse(iso); if (Number.isNaN(t)) return iso; return new Date(t).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric', }); }