construction-gantt 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -7,8 +7,8 @@ function __insertCSS(code) {
7
7
  ;style.styleSheet ? (style.styleSheet.cssText = code) : style.appendChild(document.createTextNode(code))
8
8
  }
9
9
 
10
- import { useRef, useReducer, useMemo, forwardRef, useImperativeHandle } from 'react';
11
- import { jsx, jsxs } from 'react/jsx-runtime';
10
+ import { useRef, useReducer, useMemo, useState, useCallback, useEffect, forwardRef, useImperativeHandle } from 'react';
11
+ import { jsxs, jsx } from 'react/jsx-runtime';
12
12
  import { Gantt as Gantt$1 } from '@svar-ui/react-gantt';
13
13
  import '@svar-ui/react-gantt/style.css';
14
14
  import { XMLParser, XMLBuilder } from 'fast-xml-parser';
@@ -1907,7 +1907,182 @@ function reducer(state, action) {
1907
1907
  };
1908
1908
  }
1909
1909
 
1910
- __insertCSS(".construction-gantt-non-working{background:rgba(241,245,249,.65)}.construction-gantt-marker-today{background:#ef4444;color:#fff;font-weight:600}.construction-gantt-marker-milestone{background:#2563eb;color:#fff;font-weight:500}");
1910
+ __insertCSS(".construction-gantt-non-working{background:rgba(241,245,249,.65)}.construction-gantt-marker-today{background:#ef4444;color:#fff;font-weight:600}.construction-gantt-marker-milestone{background:#2563eb;color:#fff;font-weight:500}.bode-baseline-ghost{display:flex;align-items:center;gap:6px;height:60%;margin-top:15%;padding:0 8px;border-radius:3px;font-size:9px;font-style:italic;color:#fff;opacity:.85;white-space:nowrap}.bode-baseline-0{background:#6b7280}.bode-baseline-1{background:#3b82f6}.bode-baseline-2{background:#8b5cf6}.bode-baseline-3{background:#14b8a6;color:#0f172a}.bode-baseline-4{background:#f59e0b;color:#0f172a}.bode-baseline-5{background:#ec4899}.bode-baseline-6{background:#06b6d4;color:#0f172a}.bode-baseline-7{background:#84cc16;color:#0f172a}.bode-baseline-8{background:#f97316;color:#0f172a}.bode-baseline-9{background:#a855f7}.bode-baseline-10{background:#dc2626}.bode-edit-preview{height:60%;margin-top:15%;background:repeating-linear-gradient(45deg,rgba(59,130,246,.35) 0,rgba(59,130,246,.35) 6px,rgba(59,130,246,.15) 6px,rgba(59,130,246,.15) 12px);border:1.5px dashed rgba(59,130,246,.7);border-radius:3px;pointer-events:none}.construction-gantt-drag-handle{position:absolute;right:-5px;top:50%;transform:translateY(-50%);width:10px;height:10px;border-radius:50%;background:rgba(59,130,246,.85);cursor:crosshair;opacity:0;transition:opacity .15s;z-index:10}.wx-task:hover .construction-gantt-drag-handle{opacity:1}");
1911
+
1912
+ const DRAG_INITIAL = {
1913
+ status: 'idle'
1914
+ };
1915
+ function startDrag(sourceId, startX, startY) {
1916
+ return {
1917
+ status: 'dragging',
1918
+ sourceId,
1919
+ startX,
1920
+ startY,
1921
+ cursorX: startX,
1922
+ cursorY: startY
1923
+ };
1924
+ }
1925
+ function moveDrag(state, cursorX, cursorY) {
1926
+ if (state.status !== 'dragging') return state;
1927
+ return {
1928
+ ...state,
1929
+ cursorX,
1930
+ cursorY
1931
+ };
1932
+ }
1933
+ function cancelDrag(_state) {
1934
+ return DRAG_INITIAL;
1935
+ }
1936
+ function isDragInvalid(sourceId, targetId, existingLinks, summaryIds) {
1937
+ if (String(sourceId) === String(targetId)) return true;
1938
+ if ([
1939
+ ...summaryIds
1940
+ ].some((id)=>String(id) === String(targetId))) return true;
1941
+ return existingLinks.some((l)=>String(l.source) === String(sourceId) && String(l.target) === String(targetId));
1942
+ }
1943
+
1944
+ function parseFieldValue(field, value) {
1945
+ switch(field){
1946
+ case 'text':
1947
+ return {
1948
+ text: value
1949
+ };
1950
+ case 'start':
1951
+ {
1952
+ const d = new Date(`${value}T08:00:00`);
1953
+ if (Number.isNaN(d.getTime())) return {};
1954
+ return {
1955
+ start: d
1956
+ };
1957
+ }
1958
+ case 'end':
1959
+ {
1960
+ const d = new Date(`${value}T08:00:00`);
1961
+ if (Number.isNaN(d.getTime())) return {};
1962
+ return {
1963
+ end: d
1964
+ };
1965
+ }
1966
+ case 'duration':
1967
+ {
1968
+ const n = Number(value);
1969
+ if (!Number.isFinite(n) || n < 0) return {};
1970
+ return {
1971
+ duration: Math.round(n * 8 * 60)
1972
+ };
1973
+ }
1974
+ case 'progress':
1975
+ {
1976
+ const n = Number(value);
1977
+ if (!Number.isFinite(n)) return {};
1978
+ return {
1979
+ progress: Math.min(100, Math.max(0, n))
1980
+ };
1981
+ }
1982
+ case 'scheduleMode':
1983
+ {
1984
+ if (value !== 'auto' && value !== 'manual') return {};
1985
+ return {
1986
+ scheduleMode: value
1987
+ };
1988
+ }
1989
+ }
1990
+ }
1991
+ function useEditState() {
1992
+ const [state, setState] = useState({
1993
+ activeCell: null,
1994
+ dirtyValue: ''
1995
+ });
1996
+ // stateRef lets commitCell read the latest state without stale closures.
1997
+ const stateRef = useRef(state);
1998
+ stateRef.current = state;
1999
+ const activateCell = useCallback((taskId, field, initialValue)=>{
2000
+ setState({
2001
+ activeCell: {
2002
+ taskId,
2003
+ field
2004
+ },
2005
+ dirtyValue: initialValue
2006
+ });
2007
+ }, []);
2008
+ const setValue = useCallback((value)=>{
2009
+ setState((prev)=>({
2010
+ ...prev,
2011
+ dirtyValue: value
2012
+ }));
2013
+ }, []);
2014
+ const commitCell = useCallback((onTaskEdit)=>{
2015
+ const { activeCell, dirtyValue } = stateRef.current;
2016
+ if (activeCell === null) return;
2017
+ const patch = parseFieldValue(activeCell.field, dirtyValue);
2018
+ onTaskEdit?.(activeCell.taskId, patch);
2019
+ setState({
2020
+ activeCell: null,
2021
+ dirtyValue: ''
2022
+ });
2023
+ }, []);
2024
+ const cancelCell = useCallback(()=>{
2025
+ setState({
2026
+ activeCell: null,
2027
+ dirtyValue: ''
2028
+ });
2029
+ }, []);
2030
+ return {
2031
+ activeCell: state.activeCell,
2032
+ dirtyValue: state.dirtyValue,
2033
+ activateCell,
2034
+ setValue,
2035
+ commitCell,
2036
+ cancelCell
2037
+ };
2038
+ }
2039
+
2040
+ function usePreviewEngine(committed, activeCell, dirtyValue, debounceMs = 80) {
2041
+ const [ghostProject, setGhostProject] = useState(null);
2042
+ const timerRef = useRef(null);
2043
+ useEffect(()=>{
2044
+ const CPM_IRRELEVANT_FIELDS = new Set([
2045
+ 'text',
2046
+ 'progress',
2047
+ 'scheduleMode'
2048
+ ]);
2049
+ if (activeCell === null || CPM_IRRELEVANT_FIELDS.has(activeCell.field)) {
2050
+ setGhostProject(null);
2051
+ return;
2052
+ }
2053
+ if (timerRef.current !== null) clearTimeout(timerRef.current);
2054
+ timerRef.current = setTimeout(()=>{
2055
+ const patch = parseFieldValue(activeCell.field, dirtyValue);
2056
+ if (Object.keys(patch).length === 0) {
2057
+ setGhostProject(null); // invalid input — suppress ghost
2058
+ return;
2059
+ }
2060
+ const patchedTasks = committed.tasks.map((t)=>t.id === activeCell.taskId ? {
2061
+ ...t,
2062
+ ...patch
2063
+ } : t);
2064
+ setGhostProject(schedule({
2065
+ ...committed,
2066
+ tasks: patchedTasks
2067
+ }));
2068
+ }, debounceMs);
2069
+ return ()=>{
2070
+ if (timerRef.current !== null) clearTimeout(timerRef.current);
2071
+ };
2072
+ }, [
2073
+ committed,
2074
+ activeCell,
2075
+ dirtyValue,
2076
+ debounceMs
2077
+ ]);
2078
+ // Clear immediately when cell deactivates (don't wait for debounce timeout).
2079
+ useEffect(()=>{
2080
+ if (activeCell === null) setGhostProject(null);
2081
+ }, [
2082
+ activeCell
2083
+ ]);
2084
+ return ghostProject;
2085
+ }
1911
2086
 
1912
2087
  // Render-only visibility filter for <Gantt> tasks.
1913
2088
  //
@@ -1916,11 +2091,9 @@ __insertCSS(".construction-gantt-non-working{background:rgba(241,245,249,.65)}.c
1916
2091
  // This is the canonical example: the engine always runs on the full task
1917
2092
  // set; the visibility filter applies only to the rendered output.
1918
2093
  //
1919
- // Direct lift from the CM domain rule (FrappeGanttView.tsx:366-371 in
1920
- // Bode-Builds): "Hiding a predecessor would otherwise make a dependent
1921
- // task's early-start collapse to 0, which is wrong." We honour the rule
1922
- // by structure — the filter sits AFTER schedule() in the pipeline, so
1923
- // computed fields on the visible tasks reflect the full schedule.
2094
+ // The filter sits AFTER schedule() in the pipeline so computed fields on
2095
+ // the visible tasks reflect the full schedule — hiding a predecessor must
2096
+ // not cause a dependent task's early-start to collapse to 0.
1924
2097
  /**
1925
2098
  * Return the subset of `tasks` whose ids appear in `visibleTaskIds`.
1926
2099
  *
@@ -1938,8 +2111,89 @@ __insertCSS(".construction-gantt-non-working{background:rgba(241,245,249,.65)}.c
1938
2111
  return tasks.filter((t)=>visibleTaskIds.has(t.id));
1939
2112
  }
1940
2113
 
1941
- const Gantt = /*#__PURE__*/ forwardRef(function Gantt({ project, height = 500, cellWidth = 48, cellHeight = 42, preScheduled = false, markers, baselineIndex, showBaselineBars, columns, visibleTaskIds }, ref) {
2114
+ const DEFAULT_EDIT_COLUMNS = [
2115
+ {
2116
+ id: 'text',
2117
+ header: 'Task Name',
2118
+ field: 'text',
2119
+ width: 220
2120
+ },
2121
+ {
2122
+ id: 'start',
2123
+ header: 'Start',
2124
+ field: 'start',
2125
+ width: 100,
2126
+ align: 'center'
2127
+ },
2128
+ {
2129
+ id: 'end',
2130
+ header: 'Finish',
2131
+ field: 'end',
2132
+ width: 100,
2133
+ align: 'center'
2134
+ },
2135
+ {
2136
+ id: 'duration',
2137
+ header: 'Days',
2138
+ field: 'duration',
2139
+ width: 60,
2140
+ align: 'right'
2141
+ },
2142
+ {
2143
+ id: 'progress',
2144
+ header: '%',
2145
+ field: 'progress',
2146
+ width: 50,
2147
+ align: 'right'
2148
+ }
2149
+ ];
2150
+ function useDragLink() {
2151
+ const [dragState, setDragState] = useState(DRAG_INITIAL);
2152
+ const dragRef = useRef(dragState);
2153
+ dragRef.current = dragState;
2154
+ const onBarMouseDown = useCallback((sourceId, e)=>{
2155
+ e.preventDefault();
2156
+ setDragState(startDrag(sourceId, e.clientX, e.clientY));
2157
+ }, []);
2158
+ const onMouseMove = useCallback((e)=>{
2159
+ setDragState((s)=>moveDrag(s, e.clientX, e.clientY));
2160
+ }, []);
2161
+ const onMouseUp = useCallback((e, project, onLinkCreate)=>{
2162
+ const current = dragRef.current;
2163
+ if (current.status !== 'dragging') return;
2164
+ const el = document.elementFromPoint(e.clientX, e.clientY);
2165
+ const barEl = el?.closest('[data-task-id]');
2166
+ const targetAttr = barEl?.getAttribute('data-task-id') ?? null;
2167
+ // Resolve to a real task — rejects phantom row ids (e.g. "t1__baseline_0")
2168
+ // and preserves the original typed TaskId (fixes numeric id loss via DOM attribute).
2169
+ const targetTask = targetAttr ? project.tasks.find((t)=>String(t.id) === targetAttr) : undefined;
2170
+ if (!targetTask) {
2171
+ setDragState(cancelDrag());
2172
+ return;
2173
+ }
2174
+ const summaryIds = new Set(project.tasks.filter((t)=>t.type === 'summary').map((t)=>t.id));
2175
+ if (isDragInvalid(current.sourceId, targetTask.id, project.links, summaryIds)) {
2176
+ setDragState(cancelDrag());
2177
+ return;
2178
+ }
2179
+ onLinkCreate?.(current.sourceId, targetTask.id, 'FS');
2180
+ setDragState(DRAG_INITIAL);
2181
+ }, []);
2182
+ const cancelDragLink = useCallback(()=>setDragState(DRAG_INITIAL), []);
2183
+ return {
2184
+ dragState,
2185
+ onBarMouseDown,
2186
+ onMouseMove,
2187
+ onMouseUp,
2188
+ cancelDragLink
2189
+ };
2190
+ }
2191
+ const Gantt = /*#__PURE__*/ forwardRef(function Gantt({ project, height = 500, cellWidth = 48, cellHeight = 42, preScheduled = false, markers, baselineIndex, baselineIndices, showBaselineBars, columns, visibleTaskIds, editMode = false, onTaskEdit, onLinkCreate, onLinkDelete }, ref) {
1942
2192
  const containerRef = useRef(null);
2193
+ const effectiveBaselineIndices = useMemo(()=>resolveEffectiveBaselineIndices(baselineIndices, baselineIndex), [
2194
+ baselineIndices,
2195
+ baselineIndex
2196
+ ]);
1943
2197
  const scheduled = useMemo(()=>preScheduled ? project : schedule(project), [
1944
2198
  project,
1945
2199
  preScheduled
@@ -1954,34 +2208,51 @@ const Gantt = /*#__PURE__*/ forwardRef(function Gantt({ project, height = 500, c
1954
2208
  scheduled.calendars,
1955
2209
  scheduled.defaultCalendarId
1956
2210
  ]);
1957
- const baseline = useMemo(()=>{
1958
- if (baselineIndex === undefined) return undefined;
1959
- return scheduled.baselines.find((b)=>b.index === baselineIndex);
2211
+ const editState = useEditState();
2212
+ const editStateRef = useRef(editState);
2213
+ editStateRef.current = editState;
2214
+ const onTaskEditRef = useRef(onTaskEdit);
2215
+ onTaskEditRef.current = onTaskEdit;
2216
+ const editGhostProject = usePreviewEngine(scheduled, editState.activeCell, editState.dirtyValue);
2217
+ const { dragState, onBarMouseDown, onMouseMove, onMouseUp, cancelDragLink } = useDragLink();
2218
+ useEffect(()=>{
2219
+ if (!editMode || dragState.status !== 'dragging') return;
2220
+ const handleMove = (e)=>onMouseMove(e);
2221
+ const handleUp = (e)=>onMouseUp(e, scheduled, onLinkCreate);
2222
+ const handleEsc = (e)=>{
2223
+ if (e.key === 'Escape') cancelDragLink();
2224
+ };
2225
+ window.addEventListener('mousemove', handleMove);
2226
+ window.addEventListener('mouseup', handleUp);
2227
+ window.addEventListener('keydown', handleEsc);
2228
+ return ()=>{
2229
+ window.removeEventListener('mousemove', handleMove);
2230
+ window.removeEventListener('mouseup', handleUp);
2231
+ window.removeEventListener('keydown', handleEsc);
2232
+ };
1960
2233
  }, [
2234
+ editMode,
2235
+ dragState.status,
2236
+ onMouseMove,
2237
+ onMouseUp,
2238
+ cancelDragLink,
2239
+ scheduled,
2240
+ onLinkCreate
2241
+ ]);
2242
+ // Resolve effective indices → actual Baseline records, dropping any that
2243
+ // don't exist on the project. Preserves caller order so phantom rows
2244
+ // render in the array order the consumer passed.
2245
+ const resolvedBaselines = useMemo(()=>resolveBaselines(scheduled.baselines, effectiveBaselineIndices), [
1961
2246
  scheduled.baselines,
1962
- baselineIndex
2247
+ effectiveBaselineIndices
1963
2248
  ]);
1964
- const ghostBarsEnabled = baseline !== undefined && (showBaselineBars ?? true);
1965
- const svarTasks = useMemo(()=>{
1966
- if (!ghostBarsEnabled || !baseline) {
1967
- return renderableTasks.map((t)=>toSvarTask(t, baseline, calendar));
1968
- }
1969
- // Interleave each real task with its baseline-snapshot ghost row.
1970
- // Phantoms share the real task's `parent` so they stay grouped under
1971
- // the same summary in hierarchy views.
1972
- const out = [];
1973
- for (const t of renderableTasks){
1974
- out.push(toSvarTask(t, baseline, calendar));
1975
- if (t.type === 'summary') continue;
1976
- const phantom = makeBaselinePhantom(t, baseline);
1977
- if (phantom) out.push(phantom);
1978
- }
1979
- return out;
1980
- }, [
2249
+ const ghostBarsEnabled = resolvedBaselines.length > 0 && (showBaselineBars ?? true);
2250
+ const svarTasks = useMemo(()=>buildSvarTasks(renderableTasks, resolvedBaselines, calendar, ghostBarsEnabled, editGhostProject ?? undefined), [
1981
2251
  renderableTasks,
1982
- baseline,
2252
+ resolvedBaselines,
1983
2253
  calendar,
1984
- ghostBarsEnabled
2254
+ ghostBarsEnabled,
2255
+ editGhostProject
1985
2256
  ]);
1986
2257
  const svarLinks = useMemo(()=>scheduled.links.map(toSvarLink), [
1987
2258
  scheduled.links
@@ -2001,16 +2272,63 @@ const Gantt = /*#__PURE__*/ forwardRef(function Gantt({ project, height = 500, c
2001
2272
  // undefined → don't pass columns to SVAR (use SVAR defaults).
2002
2273
  // [] → pass false to SVAR (hide grid entirely).
2003
2274
  // [...] → convert each column.
2275
+ // In editMode, inject interactive cell renderers for known editable fields.
2276
+ // biome-ignore lint/correctness/useExhaustiveDependencies: editState.activeCell is intentionally used as a dep — editStateRef/onTaskEditRef are accessed at event time via refs, so only activeCell (which changes cell-to-cell) drives column recompute
2004
2277
  const svarColumns = useMemo(()=>{
2005
- if (columns === undefined) return undefined;
2006
- if (columns.length === 0) return false;
2007
- return columns.map(toSvarColumn);
2278
+ if (!editMode) {
2279
+ if (columns === undefined) return undefined;
2280
+ if (columns.length === 0) return false;
2281
+ return columns.map(toSvarColumn);
2282
+ }
2283
+ const effectiveCols = columns ?? DEFAULT_EDIT_COLUMNS;
2284
+ if (effectiveCols.length === 0) return false;
2285
+ return effectiveCols.map((col)=>{
2286
+ if (col.render || !col.field || !EDITABLE_FIELDS.has(col.field)) {
2287
+ return toSvarColumn(col);
2288
+ }
2289
+ const base = toSvarColumn(col);
2290
+ return {
2291
+ ...base,
2292
+ cell: buildEditableCell(col.field, editStateRef, onTaskEditRef)
2293
+ };
2294
+ });
2295
+ }, [
2296
+ columns,
2297
+ editMode,
2298
+ editState.activeCell
2299
+ ]);
2300
+ const taskTemplate = useMemo(()=>{
2301
+ if (!editMode) return ConstructionBar;
2302
+ // Wrap ConstructionBar with a drag handle at the right edge of each task bar.
2303
+ const EditableBar = ({ data })=>/*#__PURE__*/ jsxs("div", {
2304
+ "data-task-id": data.id !== undefined ? String(data.id) : undefined,
2305
+ style: {
2306
+ position: 'relative',
2307
+ width: '100%',
2308
+ height: '100%'
2309
+ },
2310
+ children: [
2311
+ /*#__PURE__*/ jsx(ConstructionBar, {
2312
+ data: data
2313
+ }),
2314
+ !data.is_baseline_ghost && !data.is_edit_preview && // biome-ignore lint/a11y/noStaticElementInteractions: drag handle — pointer-down initiates link drag; keyboard alternative (Escape) handled at window level
2315
+ /*#__PURE__*/ jsx("div", {
2316
+ className: "construction-gantt-drag-handle",
2317
+ onMouseDown: (e)=>{
2318
+ if (data.id !== undefined) onBarMouseDown(data.id, e);
2319
+ },
2320
+ title: "Drag to create link"
2321
+ })
2322
+ ]
2323
+ });
2324
+ return EditableBar;
2008
2325
  }, [
2009
- columns
2326
+ editMode,
2327
+ onBarMouseDown
2010
2328
  ]);
2011
2329
  useImperativeHandle(ref, ()=>({
2012
2330
  async exportPNG (options) {
2013
- const { exportPNG } = await import('./png-DKZeKnRh.js');
2331
+ const { exportPNG } = await import('./png-C2poOLQo.js');
2014
2332
  return exportPNG({
2015
2333
  scheduled,
2016
2334
  ganttProps: {
@@ -2018,6 +2336,7 @@ const Gantt = /*#__PURE__*/ forwardRef(function Gantt({ project, height = 500, c
2018
2336
  cellHeight,
2019
2337
  markers,
2020
2338
  baselineIndex,
2339
+ baselineIndices,
2021
2340
  showBaselineBars,
2022
2341
  columns,
2023
2342
  height,
@@ -2027,7 +2346,7 @@ const Gantt = /*#__PURE__*/ forwardRef(function Gantt({ project, height = 500, c
2027
2346
  });
2028
2347
  },
2029
2348
  async exportPDF (options) {
2030
- const { exportPDF } = await import('./pdf-CBaoJRTI.js');
2349
+ const { exportPDF } = await import('./pdf-BsFqo2UW.js');
2031
2350
  return exportPDF({
2032
2351
  scheduled,
2033
2352
  ganttProps: {
@@ -2035,6 +2354,7 @@ const Gantt = /*#__PURE__*/ forwardRef(function Gantt({ project, height = 500, c
2035
2354
  cellHeight,
2036
2355
  markers,
2037
2356
  baselineIndex,
2357
+ baselineIndices,
2038
2358
  showBaselineBars,
2039
2359
  columns,
2040
2360
  height,
@@ -2056,52 +2376,125 @@ const Gantt = /*#__PURE__*/ forwardRef(function Gantt({ project, height = 500, c
2056
2376
  cellHeight,
2057
2377
  markers,
2058
2378
  baselineIndex,
2379
+ baselineIndices,
2059
2380
  showBaselineBars,
2060
2381
  columns,
2061
2382
  height,
2062
2383
  visibleTaskIds
2063
2384
  ]);
2064
- return /*#__PURE__*/ jsx("div", {
2385
+ return(// biome-ignore lint/a11y/noStaticElementInteractions: gantt container — link-delete click is an optional editing affordance, not a primary interaction target
2386
+ // biome-ignore lint/a11y/useKeyWithClickEvents: keyboard alternative (Delete key) is out of scope for v0.4; Escape already handled in drag listener
2387
+ /*#__PURE__*/ jsxs("div", {
2065
2388
  ref: containerRef,
2066
2389
  style: {
2390
+ position: 'relative',
2067
2391
  height
2068
2392
  },
2069
- children: /*#__PURE__*/ jsx(Gantt$1, {
2070
- tasks: svarTasks,
2071
- links: svarLinks,
2072
- start: scheduled.start,
2073
- end: projectEnd,
2074
- cellWidth: cellWidth,
2075
- cellHeight: cellHeight,
2076
- markers: svarMarkers,
2077
- highlightTime: highlightTime,
2078
- taskTemplate: ConstructionBar,
2079
- ...svarColumns !== undefined ? {
2080
- columns: svarColumns
2081
- } : {}
2082
- })
2083
- });
2393
+ onClick: editMode && onLinkDelete ? (e)=>{
2394
+ // SVAR-internal: dependency arrows render as <polyline class="wx-line ..."> with
2395
+ // data-link-id holding the link id. Re-verify on SVAR upgrades by searching
2396
+ // node_modules/@svar-ui/react-gantt/dist/index.es.js for "wx-line" + "data-link-id".
2397
+ const el = e.target.closest('.wx-line');
2398
+ if (!el) return;
2399
+ const linkId = el.getAttribute('data-link-id');
2400
+ if (linkId) onLinkDelete(linkId);
2401
+ } : undefined,
2402
+ children: [
2403
+ /*#__PURE__*/ jsx(Gantt$1, {
2404
+ tasks: svarTasks,
2405
+ links: svarLinks,
2406
+ start: scheduled.start,
2407
+ end: projectEnd,
2408
+ cellWidth: cellWidth,
2409
+ cellHeight: cellHeight,
2410
+ markers: svarMarkers,
2411
+ highlightTime: highlightTime,
2412
+ taskTemplate: taskTemplate,
2413
+ ...svarColumns !== undefined ? {
2414
+ columns: svarColumns
2415
+ } : {}
2416
+ }),
2417
+ editMode && dragState.status === 'dragging' && /*#__PURE__*/ jsx("svg", {
2418
+ "aria-hidden": "true",
2419
+ style: {
2420
+ position: 'fixed',
2421
+ inset: 0,
2422
+ width: '100vw',
2423
+ height: '100vh',
2424
+ pointerEvents: 'none',
2425
+ zIndex: 9999
2426
+ },
2427
+ children: /*#__PURE__*/ jsx("line", {
2428
+ x1: dragState.startX,
2429
+ y1: dragState.startY,
2430
+ x2: dragState.cursorX,
2431
+ y2: dragState.cursorY,
2432
+ stroke: "rgba(59,130,246,0.8)",
2433
+ strokeWidth: 2,
2434
+ strokeDasharray: "6 3"
2435
+ })
2436
+ })
2437
+ ]
2438
+ }));
2084
2439
  });
2085
2440
  const ConstructionBar = ({ data })=>{
2441
+ if (data.is_edit_preview) {
2442
+ return /*#__PURE__*/ jsx("div", {
2443
+ className: "bode-edit-preview"
2444
+ });
2445
+ }
2086
2446
  // Phantom baseline row — render a slim outlined ghost bar.
2087
2447
  if (data.is_baseline_ghost) {
2088
- return /*#__PURE__*/ jsx("div", {
2089
- style: {
2090
- height: '60%',
2091
- marginTop: '15%',
2092
- border: '1.5px dashed #94a3b8',
2093
- background: 'transparent',
2094
- borderRadius: 3,
2095
- fontSize: 9,
2096
- color: '#64748b',
2097
- display: 'flex',
2098
- alignItems: 'center',
2099
- padding: '0 6px',
2100
- fontStyle: 'italic',
2101
- whiteSpace: 'nowrap'
2102
- },
2448
+ const baselineIdx = data.baseline_index ?? 0;
2449
+ const phantomSlipped = data.is_slipped ?? false;
2450
+ const phantomAhead = data.is_ahead ?? false;
2451
+ return /*#__PURE__*/ jsxs("div", {
2452
+ className: `bode-baseline-ghost bode-baseline-${baselineIdx}`,
2103
2453
  title: "Baseline position — where this task was when the baseline was captured",
2104
- children: "baseline"
2454
+ children: [
2455
+ /*#__PURE__*/ jsx("span", {
2456
+ style: {
2457
+ flex: 1,
2458
+ overflow: 'hidden',
2459
+ textOverflow: 'ellipsis'
2460
+ },
2461
+ children: data.text
2462
+ }),
2463
+ phantomSlipped && /*#__PURE__*/ jsxs("span", {
2464
+ style: {
2465
+ padding: '0 6px',
2466
+ background: '#fed7aa',
2467
+ color: '#7c2d12',
2468
+ borderRadius: 3,
2469
+ fontSize: 10,
2470
+ fontWeight: 700,
2471
+ lineHeight: '16px',
2472
+ whiteSpace: 'nowrap'
2473
+ },
2474
+ title: "Drifted later than the baseline",
2475
+ children: [
2476
+ "+",
2477
+ workingMinutesToShortLabel(data.start_variance ?? 0)
2478
+ ]
2479
+ }),
2480
+ phantomAhead && /*#__PURE__*/ jsxs("span", {
2481
+ style: {
2482
+ padding: '0 6px',
2483
+ background: '#bbf7d0',
2484
+ color: '#14532d',
2485
+ borderRadius: 3,
2486
+ fontSize: 10,
2487
+ fontWeight: 700,
2488
+ lineHeight: '16px',
2489
+ whiteSpace: 'nowrap'
2490
+ },
2491
+ title: "Ahead of the baseline",
2492
+ children: [
2493
+ "−",
2494
+ workingMinutesToShortLabel(data.start_variance ?? 0)
2495
+ ]
2496
+ })
2497
+ ]
2105
2498
  });
2106
2499
  }
2107
2500
  const isCritical = data.is_critical ?? false;
@@ -2252,19 +2645,39 @@ function toSvarTask(t, baseline, calendar) {
2252
2645
  if (t.type === 'summary') base.open = t.open ?? true;
2253
2646
  return base;
2254
2647
  }
2255
- function makeBaselinePhantom(t, baseline) {
2648
+ function makeBaselinePhantom(t, baseline, calendar) {
2256
2649
  const snap = baseline.tasks.get(t.id);
2257
2650
  if (!snap) return null;
2651
+ const variance = calendar ? getTaskBaselineVariance(t, baseline, calendar) : undefined;
2652
+ const startVariance = variance?.startVariance ?? 0;
2258
2653
  return {
2259
- id: `${t.id}-baseline-${baseline.index}`,
2260
- text: '(baseline)',
2654
+ id: `${t.id}__baseline_${baseline.index}`,
2655
+ text: formatBaselineLabel(baseline),
2261
2656
  start: snap.start,
2262
2657
  end: snap.end,
2263
2658
  duration: snap.duration,
2264
2659
  progress: 0,
2265
2660
  type: 'task',
2266
2661
  parent: t.parent,
2267
- is_baseline_ghost: true
2662
+ is_baseline_ghost: true,
2663
+ baseline_index: baseline.index,
2664
+ start_variance: startVariance,
2665
+ is_slipped: startVariance >= 30,
2666
+ is_ahead: startVariance <= -30
2667
+ };
2668
+ }
2669
+ function makeEditPreviewPhantom(liveTask, ghostTask) {
2670
+ return {
2671
+ id: `${liveTask.id}__edit_preview`,
2672
+ text: 'Preview',
2673
+ start: ghostTask.start,
2674
+ end: ghostTask.end,
2675
+ duration: ghostTask.duration,
2676
+ progress: 0,
2677
+ type: 'task',
2678
+ parent: liveTask.parent,
2679
+ is_baseline_ghost: true,
2680
+ is_edit_preview: true
2268
2681
  };
2269
2682
  }
2270
2683
  function toSvarLink(l) {
@@ -2288,6 +2701,26 @@ function dependencyTypeToSvar(t) {
2288
2701
  return 's2e';
2289
2702
  }
2290
2703
  }
2704
+ /**
2705
+ * Format a baseline's metadata for display in the phantom row's label.
2706
+ * Returns "${name ?? `Baseline ${index}`} — captured ${formatShortDate(capturedAt)}".
2707
+ *
2708
+ * Exported for testing. Not part of the public surface.
2709
+ */ function formatBaselineLabel(baseline) {
2710
+ const name = baseline.name ?? `Baseline ${baseline.index}`;
2711
+ return `${name} — captured ${formatShortDate(baseline.capturedAt)}`;
2712
+ }
2713
+ /**
2714
+ * Format a Date as YYYY-MM-DD using local-time components. Deterministic
2715
+ * across locales (no Intl.DateTimeFormat — those vary by host locale).
2716
+ *
2717
+ * Exported for testing. Not part of the public surface.
2718
+ */ function formatShortDate(d) {
2719
+ const year = d.getFullYear();
2720
+ const month = String(d.getMonth() + 1).padStart(2, '0');
2721
+ const day = String(d.getDate()).padStart(2, '0');
2722
+ return `${year}-${month}-${day}`;
2723
+ }
2291
2724
  function getProjectEnd(p) {
2292
2725
  if (p.end) return p.end;
2293
2726
  let latestMs = Number.NEGATIVE_INFINITY;
@@ -2298,6 +2731,91 @@ function getProjectEnd(p) {
2298
2731
  const cushion = 24 * 60 * 60 * 1000; // 1 day
2299
2732
  return Number.isFinite(latestMs) ? new Date(latestMs + cushion) : new Date(p.start);
2300
2733
  }
2734
+ /**
2735
+ * Resolve the effective baseline indices from the two GanttProps inputs.
2736
+ *
2737
+ * - `baselineIndices` takes precedence when set (including when empty —
2738
+ * passing `[]` is an explicit opt-out signal).
2739
+ * - When `baselineIndices` is undefined, fall back to wrapping `baselineIndex`
2740
+ * in a single-element array.
2741
+ * - When both are undefined, return an empty array.
2742
+ *
2743
+ * Exported for testing. Not part of the public surface; consumers don't
2744
+ * call this directly.
2745
+ */ function resolveEffectiveBaselineIndices(baselineIndices, baselineIndex) {
2746
+ return baselineIndices ?? (baselineIndex !== undefined ? [
2747
+ baselineIndex
2748
+ ] : []);
2749
+ }
2750
+ /**
2751
+ * Map effective baseline indices to actual Baseline records on a project,
2752
+ * preserving caller order. Indices not present on the project are silently
2753
+ * dropped (per spec — consumers can pass a fixed shape across projects
2754
+ * with varying baseline counts).
2755
+ *
2756
+ * Exported for testing. Not part of the public surface.
2757
+ */ function resolveBaselines(allBaselines, effectiveIndices) {
2758
+ if (effectiveIndices.length === 0) return [];
2759
+ const byIndex = new Map(allBaselines.map((b)=>[
2760
+ b.index,
2761
+ b
2762
+ ]));
2763
+ const out = [];
2764
+ for (const idx of effectiveIndices){
2765
+ const b = byIndex.get(idx);
2766
+ if (b) out.push(b);
2767
+ }
2768
+ return out;
2769
+ }
2770
+ /**
2771
+ * Convert the rendered tasks + resolved baselines into the SVAR ITask[]
2772
+ * shape, including phantom ghost rows for each (task × baseline) pair
2773
+ * when ghost bars are enabled.
2774
+ *
2775
+ * In single-baseline mode (resolvedBaselines.length === 1), the live row
2776
+ * carries the variance pill (`start_variance` / `is_slipped` / `is_ahead`
2777
+ * are populated relative to that one baseline). In multi-baseline mode
2778
+ * (length > 1), the live row gets no variance fields (would require a
2779
+ * "primary" baseline concept that the spec rejects); each phantom row
2780
+ * gets its own.
2781
+ *
2782
+ * Exported for testing. Not part of the public surface.
2783
+ */ function buildSvarTasks(renderableTasks, resolvedBaselines, calendar, ghostBarsEnabled, editGhostProject) {
2784
+ const ghostById = new Map(editGhostProject?.tasks.map((t)=>[
2785
+ t.id,
2786
+ t
2787
+ ]) ?? []);
2788
+ if (!ghostBarsEnabled || resolvedBaselines.length === 0) {
2789
+ const primary = resolvedBaselines[0];
2790
+ const out = renderableTasks.map((t)=>toSvarTask(t, primary, calendar));
2791
+ if (ghostById.size > 0) {
2792
+ for (const t of renderableTasks){
2793
+ if (t.type === 'summary') continue;
2794
+ const ghost = ghostById.get(t.id);
2795
+ if (ghost && (ghost.start.getTime() !== t.start.getTime() || ghost.end.getTime() !== t.end.getTime())) {
2796
+ out.push(makeEditPreviewPhantom(t, ghost));
2797
+ }
2798
+ }
2799
+ }
2800
+ return out;
2801
+ }
2802
+ const out = [];
2803
+ for (const t of renderableTasks){
2804
+ const liveBarBaseline = resolvedBaselines.length === 1 ? resolvedBaselines[0] : undefined;
2805
+ out.push(toSvarTask(t, liveBarBaseline, calendar));
2806
+ if (t.type === 'summary') continue;
2807
+ for (const b of resolvedBaselines){
2808
+ const phantom = makeBaselinePhantom(t, b, calendar);
2809
+ if (phantom) out.push(phantom);
2810
+ }
2811
+ // Edit preview phantom goes last (renders below baseline phantoms).
2812
+ const ghost = ghostById.get(t.id);
2813
+ if (ghost && (ghost.start.getTime() !== t.start.getTime() || ghost.end.getTime() !== t.end.getTime())) {
2814
+ out.push(makeEditPreviewPhantom(t, ghost));
2815
+ }
2816
+ }
2817
+ return out;
2818
+ }
2301
2819
  function resolveMarkers(userMarkers, projectStart, projectEnd) {
2302
2820
  if (userMarkers) return userMarkers.map(toSvarMarker);
2303
2821
  // Default: today line, only if today falls within the project window.
@@ -2366,6 +2884,95 @@ function toSvarMarker(m) {
2366
2884
  };
2367
2885
  return config;
2368
2886
  }
2887
+ function getInputType(field) {
2888
+ if (field === 'start' || field === 'end') return 'date';
2889
+ if (field === 'duration' || field === 'progress') return 'number';
2890
+ return 'text';
2891
+ }
2892
+ function getInputValue(task, field) {
2893
+ switch(field){
2894
+ case 'text':
2895
+ return task.text;
2896
+ case 'start':
2897
+ return formatShortDate(task.start);
2898
+ case 'end':
2899
+ return formatShortDate(task.end);
2900
+ case 'duration':
2901
+ return String(Math.round(task.duration / 60 / 8));
2902
+ case 'progress':
2903
+ return String(task.progress);
2904
+ case 'scheduleMode':
2905
+ return task.scheduleMode;
2906
+ }
2907
+ }
2908
+ const EDITABLE_FIELDS = new Set([
2909
+ 'text',
2910
+ 'start',
2911
+ 'end',
2912
+ 'duration',
2913
+ 'progress',
2914
+ 'scheduleMode'
2915
+ ]);
2916
+ function buildEditableCell(field, editStateRef, onTaskEditRef) {
2917
+ return ({ row })=>{
2918
+ const editState = editStateRef.current;
2919
+ const task = row;
2920
+ if (task.is_baseline_ghost || task.is_edit_preview) {
2921
+ return /*#__PURE__*/ jsx("span", {});
2922
+ }
2923
+ const isReadOnly = task.type === 'summary' && (field === 'start' || field === 'end' || field === 'duration');
2924
+ const isActive = editState.activeCell?.taskId === task.id && editState.activeCell?.field === field;
2925
+ if (isActive) {
2926
+ return /*#__PURE__*/ jsx("input", {
2927
+ // biome-ignore lint/a11y/noAutofocus: intentional — cell was clicked
2928
+ autoFocus: true,
2929
+ type: getInputType(field),
2930
+ defaultValue: editState.dirtyValue,
2931
+ style: {
2932
+ width: '100%',
2933
+ boxSizing: 'border-box'
2934
+ },
2935
+ onChange: (e)=>editStateRef.current.setValue(e.target.value),
2936
+ onBlur: ()=>editStateRef.current.commitCell(onTaskEditRef.current),
2937
+ onKeyDown: (e)=>{
2938
+ if (e.key === 'Enter' || e.key === 'Tab') {
2939
+ e.preventDefault();
2940
+ editStateRef.current.commitCell(onTaskEditRef.current);
2941
+ } else if (e.key === 'Escape') {
2942
+ editStateRef.current.cancelCell();
2943
+ }
2944
+ }
2945
+ }, `${editState.activeCell?.taskId}-${field}`);
2946
+ }
2947
+ const displayValue = (()=>{
2948
+ if (field === 'start' || field === 'end') {
2949
+ const d = task[field];
2950
+ return d instanceof Date ? formatShortDate(d) : '';
2951
+ }
2952
+ if (field === 'duration') return String(Math.round((task.duration ?? 0) / 60 / 8));
2953
+ if (field === 'progress') return String(task.progress ?? 0);
2954
+ return String(task[field] ?? '');
2955
+ })();
2956
+ if (isReadOnly || task.id === undefined) {
2957
+ return /*#__PURE__*/ jsx("span", {
2958
+ children: displayValue
2959
+ });
2960
+ }
2961
+ const taskId = task.id;
2962
+ const activate = ()=>editStateRef.current.activateCell(taskId, field, getInputValue(task, field));
2963
+ return(// biome-ignore lint/a11y/useKeyWithClickEvents: cell activation via keyboard handled by the input that renders on activate
2964
+ // biome-ignore lint/a11y/noStaticElementInteractions: grid cell — role="gridcell" would be on the parent SVAR element
2965
+ /*#__PURE__*/ jsx("span", {
2966
+ style: {
2967
+ cursor: 'text',
2968
+ display: 'block',
2969
+ width: '100%'
2970
+ },
2971
+ onClick: activate,
2972
+ children: displayValue
2973
+ }));
2974
+ };
2975
+ }
2369
2976
  function buildHighlightTime(calendar) {
2370
2977
  if (!calendar) return undefined;
2371
2978
  return (date, unit)=>{
@@ -2411,6 +3018,7 @@ const KNOWN_TASK_FIELDS = new Set([
2411
3018
  'Finish',
2412
3019
  'Duration',
2413
3020
  'ConstraintType',
3021
+ 'ConstraintDate',
2414
3022
  'Milestone',
2415
3023
  'Summary',
2416
3024
  'OutlineLevel',
@@ -2594,6 +3202,7 @@ function parseMspdi(xml) {
2594
3202
  }
2595
3203
  const tasks = [];
2596
3204
  const links = [];
3205
+ const outlineStack = [];
2597
3206
  // Per-task baseline snapshots, keyed by baseline Number (BaselineIndex).
2598
3207
  // Flattened into project.baselines below.
2599
3208
  const baselineAccum = new Map();
@@ -2614,7 +3223,10 @@ function parseMspdi(xml) {
2614
3223
  const isMilestone = String(raw.Milestone ?? '0') === '1';
2615
3224
  const isSummary = String(raw.Summary ?? '0') === '1';
2616
3225
  const taskType = isSummary ? 'summary' : isMilestone ? 'milestone' : 'task';
2617
- tasks.push({
3226
+ const outlineLevel = Math.max(1, Number(raw.OutlineLevel ?? '1'));
3227
+ const parentId = outlineLevel > 1 ? outlineStack[outlineLevel - 2] : undefined;
3228
+ const constraint = parseConstraint(raw);
3229
+ const parsedTask = {
2618
3230
  id: uid,
2619
3231
  text: name,
2620
3232
  type: taskType,
@@ -2622,8 +3234,13 @@ function parseMspdi(xml) {
2622
3234
  duration,
2623
3235
  start,
2624
3236
  end,
2625
- progress: 0
2626
- });
3237
+ progress: Number(raw.PercentComplete ?? '0')
3238
+ };
3239
+ if (parentId !== undefined) parsedTask.parent = parentId;
3240
+ if (constraint !== undefined) parsedTask.constraint = constraint;
3241
+ tasks.push(parsedTask);
3242
+ outlineStack[outlineLevel - 1] = uid;
3243
+ outlineStack.length = outlineLevel;
2627
3244
  // Walk predecessor links nested inside the task.
2628
3245
  const preds = Array.isArray(raw.PredecessorLink) ? raw.PredecessorLink : raw.PredecessorLink !== undefined ? [
2629
3246
  raw.PredecessorLink
@@ -3025,9 +3642,31 @@ const MSPDI_TYPE_TO_DEPENDENCY = {
3025
3642
  2: 'SF',
3026
3643
  3: 'SS'
3027
3644
  };
3645
+ const MSPDI_CONSTRAINT_TO_INTERNAL = {
3646
+ 0: 'ASAP',
3647
+ 1: 'ALAP',
3648
+ 2: 'MSO',
3649
+ 3: 'MFO',
3650
+ 4: 'SNET',
3651
+ 5: 'SNLT',
3652
+ 6: 'FNET',
3653
+ 7: 'FNLT'
3654
+ };
3028
3655
  function mspdiTypeToDependencyType(t) {
3029
3656
  return MSPDI_TYPE_TO_DEPENDENCY[t] ?? 'FS';
3030
3657
  }
3658
+ function parseConstraint(raw) {
3659
+ const value = Number(raw.ConstraintType ?? '0');
3660
+ const type = MSPDI_CONSTRAINT_TO_INTERNAL[value];
3661
+ if (!type || type === 'ASAP') return undefined;
3662
+ const constraint = {
3663
+ type
3664
+ };
3665
+ if (raw.ConstraintDate !== undefined && raw.ConstraintDate !== '') {
3666
+ constraint.date = parseMspdiDate(String(raw.ConstraintDate));
3667
+ }
3668
+ return constraint;
3669
+ }
3031
3670
  function parseMspdiDate(s) {
3032
3671
  // MSPDI emits ISO 8601 like `2026-01-05T08:00:00` (no timezone in
3033
3672
  // practice; MS Project writes local time). We treat it as local.
@@ -3137,6 +3776,7 @@ function serializeMspdi(project, options = {}) {
3137
3776
  // Group baselines by taskId. Each task may have one snapshot per baseline
3138
3777
  // (0-10), emitted as <Baseline> children nested inside the task.
3139
3778
  const baselinesByTask = buildBaselinesByTask(project.baselines);
3779
+ const outlineLevelByTask = buildOutlineLevels(project.tasks);
3140
3780
  const tasksOut = project.tasks.map((t, idx)=>{
3141
3781
  const taskOut = {
3142
3782
  UID: String(t.id),
@@ -3145,11 +3785,15 @@ function serializeMspdi(project, options = {}) {
3145
3785
  Start: formatMspdiDate(t.start),
3146
3786
  Finish: formatMspdiDate(t.end),
3147
3787
  Duration: formatMspdiDuration(t.duration),
3148
- ConstraintType: 0,
3788
+ ConstraintType: constraintTypeToMspdi(t.constraint?.type),
3149
3789
  Milestone: t.type === 'milestone' ? 1 : 0,
3150
3790
  Summary: t.type === 'summary' ? 1 : 0,
3151
- OutlineLevel: 1
3791
+ OutlineLevel: outlineLevelByTask.get(t.id) ?? 1,
3792
+ PercentComplete: t.progress
3152
3793
  };
3794
+ if (t.constraint?.date) {
3795
+ taskOut.ConstraintDate = formatMspdiDate(t.constraint.date);
3796
+ }
3153
3797
  const incoming = linksByTarget.get(String(t.id));
3154
3798
  if (incoming?.length) {
3155
3799
  taskOut.PredecessorLink = incoming.map(toMspdiLink);
@@ -3226,6 +3870,46 @@ function dependencyTypeToMspdi(t) {
3226
3870
  return 3;
3227
3871
  }
3228
3872
  }
3873
+ function constraintTypeToMspdi(t) {
3874
+ switch(t){
3875
+ case undefined:
3876
+ case 'ASAP':
3877
+ return 0;
3878
+ case 'ALAP':
3879
+ return 1;
3880
+ case 'MSO':
3881
+ return 2;
3882
+ case 'MFO':
3883
+ return 3;
3884
+ case 'SNET':
3885
+ return 4;
3886
+ case 'SNLT':
3887
+ return 5;
3888
+ case 'FNET':
3889
+ return 6;
3890
+ case 'FNLT':
3891
+ return 7;
3892
+ }
3893
+ }
3894
+ function buildOutlineLevels(tasks) {
3895
+ const parentById = new Map(tasks.map((t)=>[
3896
+ t.id,
3897
+ t.parent
3898
+ ]));
3899
+ const cache = new Map();
3900
+ function depth(taskId) {
3901
+ const cached = cache.get(taskId);
3902
+ if (cached !== undefined) return cached;
3903
+ const parentId = parentById.get(taskId);
3904
+ const value = parentId === undefined ? 1 : depth(parentId) + 1;
3905
+ cache.set(taskId, value);
3906
+ return value;
3907
+ }
3908
+ return new Map(tasks.map((t)=>[
3909
+ t.id,
3910
+ depth(t.id)
3911
+ ]));
3912
+ }
3229
3913
  function formatMspdiDate(d) {
3230
3914
  // MSPDI emits local time without timezone (e.g. `2026-01-05T08:00:00`).
3231
3915
  const pad = (n)=>String(n).padStart(2, '0');