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/CHANGELOG.md +71 -0
- package/LICENSE.md +27 -0
- package/dist/index.cjs +761 -77
- package/dist/index.d.cts +31 -3
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +31 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +763 -79
- package/dist/{pdf-CBaoJRTI.js → pdf-BsFqo2UW.js} +1 -1
- package/dist/{pdf-CAQDrX0w.cjs → pdf-N2LwD-_F.cjs} +1 -1
- package/dist/{png-C8t74695.cjs → png-BloW1DDl.cjs} +1 -0
- package/dist/{png-DKZeKnRh.js → png-C2poOLQo.js} +1 -0
- package/package.json +2 -2
- package/src/Gantt.css +77 -0
- package/src/Gantt.tsx +564 -49
- package/src/editing/dragLink.ts +47 -0
- package/src/editing/useEditState.ts +98 -0
- package/src/editing/usePreviewEngine.ts +47 -0
- package/src/export/offscreen.tsx +2 -0
- package/src/export/pdf.ts +1 -0
- package/src/export/png.ts +1 -0
- package/src/index.ts +1 -0
- package/src/mspdi/parse.ts +37 -3
- package/src/mspdi/serialize.ts +50 -2
- package/src/visibility.ts +3 -5
package/src/Gantt.tsx
CHANGED
|
@@ -14,8 +14,32 @@ import {
|
|
|
14
14
|
} from '@svar-ui/react-gantt';
|
|
15
15
|
import '@svar-ui/react-gantt/style.css';
|
|
16
16
|
import './Gantt.css';
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
type FC,
|
|
19
|
+
forwardRef,
|
|
20
|
+
useCallback,
|
|
21
|
+
useEffect,
|
|
22
|
+
useImperativeHandle,
|
|
23
|
+
useMemo,
|
|
24
|
+
useRef,
|
|
25
|
+
useState,
|
|
26
|
+
} from 'react';
|
|
18
27
|
import { getTaskBaselineVariance } from './baseline';
|
|
28
|
+
import {
|
|
29
|
+
cancelDrag,
|
|
30
|
+
DRAG_INITIAL,
|
|
31
|
+
type DragLinkState,
|
|
32
|
+
isDragInvalid,
|
|
33
|
+
moveDrag,
|
|
34
|
+
startDrag,
|
|
35
|
+
} from './editing/dragLink.js';
|
|
36
|
+
import {
|
|
37
|
+
type EditableField,
|
|
38
|
+
type EditState,
|
|
39
|
+
type TaskEditPatch,
|
|
40
|
+
useEditState,
|
|
41
|
+
} from './editing/useEditState.js';
|
|
42
|
+
import { usePreviewEngine } from './editing/usePreviewEngine.js';
|
|
19
43
|
import type {
|
|
20
44
|
GanttHandle,
|
|
21
45
|
PdfExportOptions,
|
|
@@ -29,6 +53,7 @@ import type {
|
|
|
29
53
|
Calendar,
|
|
30
54
|
DependencyType,
|
|
31
55
|
Link,
|
|
56
|
+
LinkId,
|
|
32
57
|
Project,
|
|
33
58
|
Task,
|
|
34
59
|
TaskId,
|
|
@@ -98,6 +123,26 @@ export interface GanttProps {
|
|
|
98
123
|
*/
|
|
99
124
|
markers?: GanttMarker[];
|
|
100
125
|
/**
|
|
126
|
+
* Baselines to overlay as phantom ghost rows beneath each live task.
|
|
127
|
+
*
|
|
128
|
+
* - Single-baseline mode (length 1): the live bar carries the variance pill,
|
|
129
|
+
* matching the existing single-baseline behaviour.
|
|
130
|
+
* - Multi-baseline mode (length > 1): each phantom row carries its own
|
|
131
|
+
* variance pill against the live task; the live bar has no pill.
|
|
132
|
+
* - Indices not present on `project.baselines` are silently skipped (no
|
|
133
|
+
* throw) so consumers can pass a fixed shape regardless of how many
|
|
134
|
+
* baselines a particular project has captured.
|
|
135
|
+
*
|
|
136
|
+
* Phantom rows render in array order — consumers wanting chronological
|
|
137
|
+
* order should sort by `baseline.capturedAt` before passing.
|
|
138
|
+
*/
|
|
139
|
+
baselineIndices?: ReadonlyArray<BaselineIndex>;
|
|
140
|
+
/**
|
|
141
|
+
* @deprecated Use `baselineIndices: [N]`. Single-index convenience prop
|
|
142
|
+
* kept as a no-friction alias for v0.x consumers. Removed at v1.0.
|
|
143
|
+
* If both `baselineIndex` and `baselineIndices` are set, `baselineIndices`
|
|
144
|
+
* takes precedence.
|
|
145
|
+
*
|
|
101
146
|
* Show variance against this baseline index. If unset (or no matching
|
|
102
147
|
* baseline exists on `project.baselines`), bars render without variance
|
|
103
148
|
* pills. Construction-vertical use case (ADR-003): comparing the live
|
|
@@ -108,8 +153,9 @@ export interface GanttProps {
|
|
|
108
153
|
/**
|
|
109
154
|
* Render the baseline as a separate "ghost" bar beneath each live task.
|
|
110
155
|
* Matches the MS Project baseline-view idiom construction PMs expect
|
|
111
|
-
* when reviewing variation claims. Default true when
|
|
112
|
-
* is set; pass `false` to keep
|
|
156
|
+
* when reviewing variation claims. Default true when either
|
|
157
|
+
* `baselineIndex` or `baselineIndices` is set; pass `false` to keep
|
|
158
|
+
* variance shown only as in-bar pills.
|
|
113
159
|
*/
|
|
114
160
|
showBaselineBars?: boolean;
|
|
115
161
|
/**
|
|
@@ -141,8 +187,21 @@ export interface GanttProps {
|
|
|
141
187
|
* `visibility.ts` for the contract test.
|
|
142
188
|
*/
|
|
143
189
|
visibleTaskIds?: ReadonlySet<TaskId>;
|
|
190
|
+
// --- v0.4 editing ---
|
|
191
|
+
editMode?: boolean;
|
|
192
|
+
onTaskEdit?: (id: TaskId, patch: TaskEditPatch) => void;
|
|
193
|
+
onLinkCreate?: (source: TaskId, target: TaskId, type: DependencyType) => void;
|
|
194
|
+
onLinkDelete?: (linkId: LinkId) => void;
|
|
144
195
|
}
|
|
145
196
|
|
|
197
|
+
const DEFAULT_EDIT_COLUMNS: GanttColumn[] = [
|
|
198
|
+
{ id: 'text', header: 'Task Name', field: 'text', width: 220 },
|
|
199
|
+
{ id: 'start', header: 'Start', field: 'start', width: 100, align: 'center' },
|
|
200
|
+
{ id: 'end', header: 'Finish', field: 'end', width: 100, align: 'center' },
|
|
201
|
+
{ id: 'duration', header: 'Days', field: 'duration', width: 60, align: 'right' },
|
|
202
|
+
{ id: 'progress', header: '%', field: 'progress', width: 50, align: 'right' },
|
|
203
|
+
];
|
|
204
|
+
|
|
146
205
|
interface SvarTaskWithComputed extends ITask {
|
|
147
206
|
is_critical?: boolean;
|
|
148
207
|
is_late?: boolean;
|
|
@@ -155,6 +214,10 @@ interface SvarTaskWithComputed extends ITask {
|
|
|
155
214
|
is_ahead?: boolean;
|
|
156
215
|
/** True for phantom rows representing a baseline snapshot's position. */
|
|
157
216
|
is_baseline_ghost?: boolean;
|
|
217
|
+
/** When set, identifies which baseline (0..10) this phantom row mirrors. */
|
|
218
|
+
baseline_index?: BaselineIndex;
|
|
219
|
+
/** True for phantom rows representing a live recalc preview position. */
|
|
220
|
+
is_edit_preview?: boolean;
|
|
158
221
|
}
|
|
159
222
|
|
|
160
223
|
interface SvarMarker {
|
|
@@ -163,6 +226,58 @@ interface SvarMarker {
|
|
|
163
226
|
css?: string;
|
|
164
227
|
}
|
|
165
228
|
|
|
229
|
+
function useDragLink() {
|
|
230
|
+
const [dragState, setDragState] = useState<DragLinkState>(DRAG_INITIAL);
|
|
231
|
+
const dragRef = useRef(dragState);
|
|
232
|
+
dragRef.current = dragState;
|
|
233
|
+
|
|
234
|
+
const onBarMouseDown = useCallback((sourceId: TaskId, e: React.MouseEvent) => {
|
|
235
|
+
e.preventDefault();
|
|
236
|
+
setDragState(startDrag(sourceId, e.clientX, e.clientY));
|
|
237
|
+
}, []);
|
|
238
|
+
|
|
239
|
+
const onMouseMove = useCallback((e: MouseEvent) => {
|
|
240
|
+
setDragState((s) => moveDrag(s, e.clientX, e.clientY));
|
|
241
|
+
}, []);
|
|
242
|
+
|
|
243
|
+
const onMouseUp = useCallback(
|
|
244
|
+
(
|
|
245
|
+
e: MouseEvent,
|
|
246
|
+
project: Project,
|
|
247
|
+
onLinkCreate: ((s: TaskId, t: TaskId, type: DependencyType) => void) | undefined,
|
|
248
|
+
) => {
|
|
249
|
+
const current = dragRef.current;
|
|
250
|
+
if (current.status !== 'dragging') return;
|
|
251
|
+
const el = document.elementFromPoint(e.clientX, e.clientY);
|
|
252
|
+
const barEl = el?.closest('[data-task-id]');
|
|
253
|
+
const targetAttr = barEl?.getAttribute('data-task-id') ?? null;
|
|
254
|
+
// Resolve to a real task — rejects phantom row ids (e.g. "t1__baseline_0")
|
|
255
|
+
// and preserves the original typed TaskId (fixes numeric id loss via DOM attribute).
|
|
256
|
+
const targetTask = targetAttr
|
|
257
|
+
? project.tasks.find((t) => String(t.id) === targetAttr)
|
|
258
|
+
: undefined;
|
|
259
|
+
if (!targetTask) {
|
|
260
|
+
setDragState(cancelDrag(current));
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const summaryIds = new Set<TaskId>(
|
|
264
|
+
project.tasks.filter((t) => t.type === 'summary').map((t) => t.id),
|
|
265
|
+
);
|
|
266
|
+
if (isDragInvalid(current.sourceId, targetTask.id, project.links, summaryIds)) {
|
|
267
|
+
setDragState(cancelDrag(current));
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
onLinkCreate?.(current.sourceId, targetTask.id, 'FS');
|
|
271
|
+
setDragState(DRAG_INITIAL);
|
|
272
|
+
},
|
|
273
|
+
[],
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
const cancelDragLink = useCallback(() => setDragState(DRAG_INITIAL), []);
|
|
277
|
+
|
|
278
|
+
return { dragState, onBarMouseDown, onMouseMove, onMouseUp, cancelDragLink };
|
|
279
|
+
}
|
|
280
|
+
|
|
166
281
|
export const Gantt = forwardRef<GanttHandle, GanttProps>(function Gantt(
|
|
167
282
|
{
|
|
168
283
|
project,
|
|
@@ -172,14 +287,24 @@ export const Gantt = forwardRef<GanttHandle, GanttProps>(function Gantt(
|
|
|
172
287
|
preScheduled = false,
|
|
173
288
|
markers,
|
|
174
289
|
baselineIndex,
|
|
290
|
+
baselineIndices,
|
|
175
291
|
showBaselineBars,
|
|
176
292
|
columns,
|
|
177
293
|
visibleTaskIds,
|
|
294
|
+
editMode = false,
|
|
295
|
+
onTaskEdit,
|
|
296
|
+
onLinkCreate,
|
|
297
|
+
onLinkDelete,
|
|
178
298
|
},
|
|
179
299
|
ref,
|
|
180
300
|
) {
|
|
181
301
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
182
302
|
|
|
303
|
+
const effectiveBaselineIndices = useMemo<ReadonlyArray<BaselineIndex>>(
|
|
304
|
+
() => resolveEffectiveBaselineIndices(baselineIndices, baselineIndex),
|
|
305
|
+
[baselineIndices, baselineIndex],
|
|
306
|
+
);
|
|
307
|
+
|
|
183
308
|
const scheduled = useMemo(
|
|
184
309
|
() => (preScheduled ? project : schedule(project)),
|
|
185
310
|
[project, preScheduled],
|
|
@@ -197,29 +322,53 @@ export const Gantt = forwardRef<GanttHandle, GanttProps>(function Gantt(
|
|
|
197
322
|
[scheduled.calendars, scheduled.defaultCalendarId],
|
|
198
323
|
);
|
|
199
324
|
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
325
|
+
const editState = useEditState();
|
|
326
|
+
const editStateRef = useRef<typeof editState>(editState);
|
|
327
|
+
editStateRef.current = editState;
|
|
328
|
+
const onTaskEditRef = useRef(onTaskEdit);
|
|
329
|
+
onTaskEditRef.current = onTaskEdit;
|
|
330
|
+
const editGhostProject = usePreviewEngine(scheduled, editState.activeCell, editState.dirtyValue);
|
|
204
331
|
|
|
205
|
-
const
|
|
332
|
+
const { dragState, onBarMouseDown, onMouseMove, onMouseUp, cancelDragLink } = useDragLink();
|
|
206
333
|
|
|
207
|
-
|
|
208
|
-
if (!
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
}, [
|
|
334
|
+
useEffect(() => {
|
|
335
|
+
if (!editMode || dragState.status !== 'dragging') return;
|
|
336
|
+
const handleMove = (e: MouseEvent) => onMouseMove(e);
|
|
337
|
+
const handleUp = (e: MouseEvent) => onMouseUp(e, scheduled, onLinkCreate);
|
|
338
|
+
const handleEsc = (e: KeyboardEvent) => {
|
|
339
|
+
if (e.key === 'Escape') cancelDragLink();
|
|
340
|
+
};
|
|
341
|
+
window.addEventListener('mousemove', handleMove);
|
|
342
|
+
window.addEventListener('mouseup', handleUp);
|
|
343
|
+
window.addEventListener('keydown', handleEsc);
|
|
344
|
+
return () => {
|
|
345
|
+
window.removeEventListener('mousemove', handleMove);
|
|
346
|
+
window.removeEventListener('mouseup', handleUp);
|
|
347
|
+
window.removeEventListener('keydown', handleEsc);
|
|
348
|
+
};
|
|
349
|
+
}, [editMode, dragState.status, onMouseMove, onMouseUp, cancelDragLink, scheduled, onLinkCreate]);
|
|
350
|
+
|
|
351
|
+
// Resolve effective indices → actual Baseline records, dropping any that
|
|
352
|
+
// don't exist on the project. Preserves caller order so phantom rows
|
|
353
|
+
// render in the array order the consumer passed.
|
|
354
|
+
const resolvedBaselines = useMemo<Baseline[]>(
|
|
355
|
+
() => resolveBaselines(scheduled.baselines, effectiveBaselineIndices),
|
|
356
|
+
[scheduled.baselines, effectiveBaselineIndices],
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
const ghostBarsEnabled = resolvedBaselines.length > 0 && (showBaselineBars ?? true);
|
|
360
|
+
|
|
361
|
+
const svarTasks: ITask[] = useMemo(
|
|
362
|
+
() =>
|
|
363
|
+
buildSvarTasks(
|
|
364
|
+
renderableTasks,
|
|
365
|
+
resolvedBaselines,
|
|
366
|
+
calendar,
|
|
367
|
+
ghostBarsEnabled,
|
|
368
|
+
editGhostProject ?? undefined,
|
|
369
|
+
),
|
|
370
|
+
[renderableTasks, resolvedBaselines, calendar, ghostBarsEnabled, editGhostProject],
|
|
371
|
+
);
|
|
223
372
|
const svarLinks: ILink[] = useMemo(() => scheduled.links.map(toSvarLink), [scheduled.links]);
|
|
224
373
|
|
|
225
374
|
const projectEnd = useMemo(() => getProjectEnd(scheduled), [scheduled]);
|
|
@@ -237,11 +386,51 @@ export const Gantt = forwardRef<GanttHandle, GanttProps>(function Gantt(
|
|
|
237
386
|
// undefined → don't pass columns to SVAR (use SVAR defaults).
|
|
238
387
|
// [] → pass false to SVAR (hide grid entirely).
|
|
239
388
|
// [...] → convert each column.
|
|
389
|
+
// In editMode, inject interactive cell renderers for known editable fields.
|
|
390
|
+
// 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
|
|
240
391
|
const svarColumns: IColumnConfig[] | false | undefined = useMemo(() => {
|
|
241
|
-
if (
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
392
|
+
if (!editMode) {
|
|
393
|
+
if (columns === undefined) return undefined;
|
|
394
|
+
if (columns.length === 0) return false;
|
|
395
|
+
return columns.map(toSvarColumn);
|
|
396
|
+
}
|
|
397
|
+
const effectiveCols = columns ?? DEFAULT_EDIT_COLUMNS;
|
|
398
|
+
if (effectiveCols.length === 0) return false;
|
|
399
|
+
return effectiveCols.map((col) => {
|
|
400
|
+
if (col.render || !col.field || !EDITABLE_FIELDS.has(col.field as string)) {
|
|
401
|
+
return toSvarColumn(col);
|
|
402
|
+
}
|
|
403
|
+
const base = toSvarColumn(col);
|
|
404
|
+
return {
|
|
405
|
+
...base,
|
|
406
|
+
cell: buildEditableCell(col.field as EditableField, editStateRef, onTaskEditRef),
|
|
407
|
+
};
|
|
408
|
+
});
|
|
409
|
+
}, [columns, editMode, editState.activeCell]);
|
|
410
|
+
|
|
411
|
+
const taskTemplate = useMemo(() => {
|
|
412
|
+
if (!editMode) return ConstructionBar as FC<{ data: ITask }>;
|
|
413
|
+
// Wrap ConstructionBar with a drag handle at the right edge of each task bar.
|
|
414
|
+
const EditableBar: FC<{ data: SvarTaskWithComputed }> = ({ data }) => (
|
|
415
|
+
<div
|
|
416
|
+
data-task-id={data.id !== undefined ? String(data.id) : undefined}
|
|
417
|
+
style={{ position: 'relative', width: '100%', height: '100%' }}
|
|
418
|
+
>
|
|
419
|
+
<ConstructionBar data={data} />
|
|
420
|
+
{!data.is_baseline_ghost && !data.is_edit_preview && (
|
|
421
|
+
// biome-ignore lint/a11y/noStaticElementInteractions: drag handle — pointer-down initiates link drag; keyboard alternative (Escape) handled at window level
|
|
422
|
+
<div
|
|
423
|
+
className="construction-gantt-drag-handle"
|
|
424
|
+
onMouseDown={(e) => {
|
|
425
|
+
if (data.id !== undefined) onBarMouseDown(data.id, e);
|
|
426
|
+
}}
|
|
427
|
+
title="Drag to create link"
|
|
428
|
+
/>
|
|
429
|
+
)}
|
|
430
|
+
</div>
|
|
431
|
+
);
|
|
432
|
+
return EditableBar as FC<{ data: ITask }>;
|
|
433
|
+
}, [editMode, onBarMouseDown]);
|
|
245
434
|
|
|
246
435
|
useImperativeHandle(
|
|
247
436
|
ref,
|
|
@@ -255,6 +444,7 @@ export const Gantt = forwardRef<GanttHandle, GanttProps>(function Gantt(
|
|
|
255
444
|
cellHeight,
|
|
256
445
|
markers,
|
|
257
446
|
baselineIndex,
|
|
447
|
+
baselineIndices,
|
|
258
448
|
showBaselineBars,
|
|
259
449
|
columns,
|
|
260
450
|
height,
|
|
@@ -272,6 +462,7 @@ export const Gantt = forwardRef<GanttHandle, GanttProps>(function Gantt(
|
|
|
272
462
|
cellHeight,
|
|
273
463
|
markers,
|
|
274
464
|
baselineIndex,
|
|
465
|
+
baselineIndices,
|
|
275
466
|
showBaselineBars,
|
|
276
467
|
columns,
|
|
277
468
|
height,
|
|
@@ -291,6 +482,7 @@ export const Gantt = forwardRef<GanttHandle, GanttProps>(function Gantt(
|
|
|
291
482
|
cellHeight,
|
|
292
483
|
markers,
|
|
293
484
|
baselineIndex,
|
|
485
|
+
baselineIndices,
|
|
294
486
|
showBaselineBars,
|
|
295
487
|
columns,
|
|
296
488
|
height,
|
|
@@ -299,7 +491,25 @@ export const Gantt = forwardRef<GanttHandle, GanttProps>(function Gantt(
|
|
|
299
491
|
);
|
|
300
492
|
|
|
301
493
|
return (
|
|
302
|
-
|
|
494
|
+
// biome-ignore lint/a11y/noStaticElementInteractions: gantt container — link-delete click is an optional editing affordance, not a primary interaction target
|
|
495
|
+
// biome-ignore lint/a11y/useKeyWithClickEvents: keyboard alternative (Delete key) is out of scope for v0.4; Escape already handled in drag listener
|
|
496
|
+
<div
|
|
497
|
+
ref={containerRef}
|
|
498
|
+
style={{ position: 'relative', height }}
|
|
499
|
+
onClick={
|
|
500
|
+
editMode && onLinkDelete
|
|
501
|
+
? (e) => {
|
|
502
|
+
// SVAR-internal: dependency arrows render as <polyline class="wx-line ..."> with
|
|
503
|
+
// data-link-id holding the link id. Re-verify on SVAR upgrades by searching
|
|
504
|
+
// node_modules/@svar-ui/react-gantt/dist/index.es.js for "wx-line" + "data-link-id".
|
|
505
|
+
const el = (e.target as Element).closest('.wx-line');
|
|
506
|
+
if (!el) return;
|
|
507
|
+
const linkId = el.getAttribute('data-link-id');
|
|
508
|
+
if (linkId) onLinkDelete(linkId);
|
|
509
|
+
}
|
|
510
|
+
: undefined
|
|
511
|
+
}
|
|
512
|
+
>
|
|
303
513
|
<SvarGantt
|
|
304
514
|
tasks={svarTasks}
|
|
305
515
|
links={svarLinks}
|
|
@@ -309,35 +519,85 @@ export const Gantt = forwardRef<GanttHandle, GanttProps>(function Gantt(
|
|
|
309
519
|
cellHeight={cellHeight}
|
|
310
520
|
markers={svarMarkers}
|
|
311
521
|
highlightTime={highlightTime}
|
|
312
|
-
taskTemplate={
|
|
522
|
+
taskTemplate={taskTemplate}
|
|
313
523
|
{...(svarColumns !== undefined ? { columns: svarColumns as IColumnConfig[] } : {})}
|
|
314
524
|
/>
|
|
525
|
+
{editMode && dragState.status === 'dragging' && (
|
|
526
|
+
<svg
|
|
527
|
+
aria-hidden="true"
|
|
528
|
+
style={{
|
|
529
|
+
position: 'fixed',
|
|
530
|
+
inset: 0,
|
|
531
|
+
width: '100vw',
|
|
532
|
+
height: '100vh',
|
|
533
|
+
pointerEvents: 'none',
|
|
534
|
+
zIndex: 9999,
|
|
535
|
+
}}
|
|
536
|
+
>
|
|
537
|
+
<line
|
|
538
|
+
x1={dragState.startX}
|
|
539
|
+
y1={dragState.startY}
|
|
540
|
+
x2={dragState.cursorX}
|
|
541
|
+
y2={dragState.cursorY}
|
|
542
|
+
stroke="rgba(59,130,246,0.8)"
|
|
543
|
+
strokeWidth={2}
|
|
544
|
+
strokeDasharray="6 3"
|
|
545
|
+
/>
|
|
546
|
+
</svg>
|
|
547
|
+
)}
|
|
315
548
|
</div>
|
|
316
549
|
);
|
|
317
550
|
});
|
|
318
551
|
|
|
319
|
-
const ConstructionBar: FC<{ data: SvarTaskWithComputed }> = ({ data }) => {
|
|
552
|
+
export const ConstructionBar: FC<{ data: SvarTaskWithComputed }> = ({ data }) => {
|
|
553
|
+
if (data.is_edit_preview) {
|
|
554
|
+
return <div className="bode-edit-preview" />;
|
|
555
|
+
}
|
|
320
556
|
// Phantom baseline row — render a slim outlined ghost bar.
|
|
321
557
|
if (data.is_baseline_ghost) {
|
|
558
|
+
const baselineIdx = data.baseline_index ?? 0;
|
|
559
|
+
const phantomSlipped = data.is_slipped ?? false;
|
|
560
|
+
const phantomAhead = data.is_ahead ?? false;
|
|
322
561
|
return (
|
|
323
562
|
<div
|
|
324
|
-
|
|
325
|
-
height: '60%',
|
|
326
|
-
marginTop: '15%',
|
|
327
|
-
border: '1.5px dashed #94a3b8',
|
|
328
|
-
background: 'transparent',
|
|
329
|
-
borderRadius: 3,
|
|
330
|
-
fontSize: 9,
|
|
331
|
-
color: '#64748b',
|
|
332
|
-
display: 'flex',
|
|
333
|
-
alignItems: 'center',
|
|
334
|
-
padding: '0 6px',
|
|
335
|
-
fontStyle: 'italic',
|
|
336
|
-
whiteSpace: 'nowrap',
|
|
337
|
-
}}
|
|
563
|
+
className={`bode-baseline-ghost bode-baseline-${baselineIdx}`}
|
|
338
564
|
title="Baseline position — where this task was when the baseline was captured"
|
|
339
565
|
>
|
|
340
|
-
|
|
566
|
+
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>{data.text}</span>
|
|
567
|
+
{phantomSlipped && (
|
|
568
|
+
<span
|
|
569
|
+
style={{
|
|
570
|
+
padding: '0 6px',
|
|
571
|
+
background: '#fed7aa',
|
|
572
|
+
color: '#7c2d12',
|
|
573
|
+
borderRadius: 3,
|
|
574
|
+
fontSize: 10,
|
|
575
|
+
fontWeight: 700,
|
|
576
|
+
lineHeight: '16px',
|
|
577
|
+
whiteSpace: 'nowrap',
|
|
578
|
+
}}
|
|
579
|
+
title="Drifted later than the baseline"
|
|
580
|
+
>
|
|
581
|
+
+{workingMinutesToShortLabel(data.start_variance ?? 0)}
|
|
582
|
+
</span>
|
|
583
|
+
)}
|
|
584
|
+
{phantomAhead && (
|
|
585
|
+
<span
|
|
586
|
+
style={{
|
|
587
|
+
padding: '0 6px',
|
|
588
|
+
background: '#bbf7d0',
|
|
589
|
+
color: '#14532d',
|
|
590
|
+
borderRadius: 3,
|
|
591
|
+
fontSize: 10,
|
|
592
|
+
fontWeight: 700,
|
|
593
|
+
lineHeight: '16px',
|
|
594
|
+
whiteSpace: 'nowrap',
|
|
595
|
+
}}
|
|
596
|
+
title="Ahead of the baseline"
|
|
597
|
+
>
|
|
598
|
+
−{workingMinutesToShortLabel(data.start_variance ?? 0)}
|
|
599
|
+
</span>
|
|
600
|
+
)}
|
|
341
601
|
</div>
|
|
342
602
|
);
|
|
343
603
|
}
|
|
@@ -496,12 +756,18 @@ function toSvarTask(
|
|
|
496
756
|
return base;
|
|
497
757
|
}
|
|
498
758
|
|
|
499
|
-
function makeBaselinePhantom(
|
|
759
|
+
function makeBaselinePhantom(
|
|
760
|
+
t: Task,
|
|
761
|
+
baseline: Baseline,
|
|
762
|
+
calendar: Calendar | undefined,
|
|
763
|
+
): SvarTaskWithComputed | null {
|
|
500
764
|
const snap = baseline.tasks.get(t.id);
|
|
501
765
|
if (!snap) return null;
|
|
766
|
+
const variance = calendar ? getTaskBaselineVariance(t, baseline, calendar) : undefined;
|
|
767
|
+
const startVariance = variance?.startVariance ?? 0;
|
|
502
768
|
return {
|
|
503
|
-
id: `${t.id}
|
|
504
|
-
text:
|
|
769
|
+
id: `${t.id}__baseline_${baseline.index}`,
|
|
770
|
+
text: formatBaselineLabel(baseline),
|
|
505
771
|
start: snap.start,
|
|
506
772
|
end: snap.end,
|
|
507
773
|
duration: snap.duration,
|
|
@@ -509,6 +775,25 @@ function makeBaselinePhantom(t: Task, baseline: Baseline): SvarTaskWithComputed
|
|
|
509
775
|
type: 'task',
|
|
510
776
|
parent: t.parent,
|
|
511
777
|
is_baseline_ghost: true,
|
|
778
|
+
baseline_index: baseline.index,
|
|
779
|
+
start_variance: startVariance,
|
|
780
|
+
is_slipped: startVariance >= 30,
|
|
781
|
+
is_ahead: startVariance <= -30,
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function makeEditPreviewPhantom(liveTask: Task, ghostTask: Task): SvarTaskWithComputed {
|
|
786
|
+
return {
|
|
787
|
+
id: `${liveTask.id}__edit_preview`,
|
|
788
|
+
text: 'Preview',
|
|
789
|
+
start: ghostTask.start,
|
|
790
|
+
end: ghostTask.end,
|
|
791
|
+
duration: ghostTask.duration,
|
|
792
|
+
progress: 0,
|
|
793
|
+
type: 'task',
|
|
794
|
+
parent: liveTask.parent,
|
|
795
|
+
is_baseline_ghost: true,
|
|
796
|
+
is_edit_preview: true,
|
|
512
797
|
};
|
|
513
798
|
}
|
|
514
799
|
|
|
@@ -535,6 +820,30 @@ function dependencyTypeToSvar(t: DependencyType): ILink['type'] {
|
|
|
535
820
|
}
|
|
536
821
|
}
|
|
537
822
|
|
|
823
|
+
/**
|
|
824
|
+
* Format a baseline's metadata for display in the phantom row's label.
|
|
825
|
+
* Returns "${name ?? `Baseline ${index}`} — captured ${formatShortDate(capturedAt)}".
|
|
826
|
+
*
|
|
827
|
+
* Exported for testing. Not part of the public surface.
|
|
828
|
+
*/
|
|
829
|
+
export function formatBaselineLabel(baseline: Baseline): string {
|
|
830
|
+
const name = baseline.name ?? `Baseline ${baseline.index}`;
|
|
831
|
+
return `${name} — captured ${formatShortDate(baseline.capturedAt)}`;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* Format a Date as YYYY-MM-DD using local-time components. Deterministic
|
|
836
|
+
* across locales (no Intl.DateTimeFormat — those vary by host locale).
|
|
837
|
+
*
|
|
838
|
+
* Exported for testing. Not part of the public surface.
|
|
839
|
+
*/
|
|
840
|
+
export function formatShortDate(d: Date): string {
|
|
841
|
+
const year = d.getFullYear();
|
|
842
|
+
const month = String(d.getMonth() + 1).padStart(2, '0');
|
|
843
|
+
const day = String(d.getDate()).padStart(2, '0');
|
|
844
|
+
return `${year}-${month}-${day}`;
|
|
845
|
+
}
|
|
846
|
+
|
|
538
847
|
function getProjectEnd(p: Project): Date {
|
|
539
848
|
if (p.end) return p.end;
|
|
540
849
|
let latestMs = Number.NEGATIVE_INFINITY;
|
|
@@ -546,6 +855,108 @@ function getProjectEnd(p: Project): Date {
|
|
|
546
855
|
return Number.isFinite(latestMs) ? new Date(latestMs + cushion) : new Date(p.start);
|
|
547
856
|
}
|
|
548
857
|
|
|
858
|
+
/**
|
|
859
|
+
* Resolve the effective baseline indices from the two GanttProps inputs.
|
|
860
|
+
*
|
|
861
|
+
* - `baselineIndices` takes precedence when set (including when empty —
|
|
862
|
+
* passing `[]` is an explicit opt-out signal).
|
|
863
|
+
* - When `baselineIndices` is undefined, fall back to wrapping `baselineIndex`
|
|
864
|
+
* in a single-element array.
|
|
865
|
+
* - When both are undefined, return an empty array.
|
|
866
|
+
*
|
|
867
|
+
* Exported for testing. Not part of the public surface; consumers don't
|
|
868
|
+
* call this directly.
|
|
869
|
+
*/
|
|
870
|
+
export function resolveEffectiveBaselineIndices(
|
|
871
|
+
baselineIndices: ReadonlyArray<BaselineIndex> | undefined,
|
|
872
|
+
baselineIndex: BaselineIndex | undefined,
|
|
873
|
+
): ReadonlyArray<BaselineIndex> {
|
|
874
|
+
return baselineIndices ?? (baselineIndex !== undefined ? [baselineIndex] : []);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/**
|
|
878
|
+
* Map effective baseline indices to actual Baseline records on a project,
|
|
879
|
+
* preserving caller order. Indices not present on the project are silently
|
|
880
|
+
* dropped (per spec — consumers can pass a fixed shape across projects
|
|
881
|
+
* with varying baseline counts).
|
|
882
|
+
*
|
|
883
|
+
* Exported for testing. Not part of the public surface.
|
|
884
|
+
*/
|
|
885
|
+
export function resolveBaselines(
|
|
886
|
+
allBaselines: Baseline[],
|
|
887
|
+
effectiveIndices: ReadonlyArray<BaselineIndex>,
|
|
888
|
+
): Baseline[] {
|
|
889
|
+
if (effectiveIndices.length === 0) return [];
|
|
890
|
+
const byIndex = new Map(allBaselines.map((b) => [b.index, b]));
|
|
891
|
+
const out: Baseline[] = [];
|
|
892
|
+
for (const idx of effectiveIndices) {
|
|
893
|
+
const b = byIndex.get(idx);
|
|
894
|
+
if (b) out.push(b);
|
|
895
|
+
}
|
|
896
|
+
return out;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* Convert the rendered tasks + resolved baselines into the SVAR ITask[]
|
|
901
|
+
* shape, including phantom ghost rows for each (task × baseline) pair
|
|
902
|
+
* when ghost bars are enabled.
|
|
903
|
+
*
|
|
904
|
+
* In single-baseline mode (resolvedBaselines.length === 1), the live row
|
|
905
|
+
* carries the variance pill (`start_variance` / `is_slipped` / `is_ahead`
|
|
906
|
+
* are populated relative to that one baseline). In multi-baseline mode
|
|
907
|
+
* (length > 1), the live row gets no variance fields (would require a
|
|
908
|
+
* "primary" baseline concept that the spec rejects); each phantom row
|
|
909
|
+
* gets its own.
|
|
910
|
+
*
|
|
911
|
+
* Exported for testing. Not part of the public surface.
|
|
912
|
+
*/
|
|
913
|
+
export function buildSvarTasks(
|
|
914
|
+
renderableTasks: Task[],
|
|
915
|
+
resolvedBaselines: Baseline[],
|
|
916
|
+
calendar: Calendar | undefined,
|
|
917
|
+
ghostBarsEnabled: boolean,
|
|
918
|
+
editGhostProject?: Project,
|
|
919
|
+
): SvarTaskWithComputed[] {
|
|
920
|
+
const ghostById = new Map<TaskId, Task>(editGhostProject?.tasks.map((t) => [t.id, t]) ?? []);
|
|
921
|
+
|
|
922
|
+
if (!ghostBarsEnabled || resolvedBaselines.length === 0) {
|
|
923
|
+
const primary = resolvedBaselines[0];
|
|
924
|
+
const out = renderableTasks.map((t) => toSvarTask(t, primary, calendar));
|
|
925
|
+
if (ghostById.size > 0) {
|
|
926
|
+
for (const t of renderableTasks) {
|
|
927
|
+
if (t.type === 'summary') continue;
|
|
928
|
+
const ghost = ghostById.get(t.id);
|
|
929
|
+
if (
|
|
930
|
+
ghost &&
|
|
931
|
+
(ghost.start.getTime() !== t.start.getTime() || ghost.end.getTime() !== t.end.getTime())
|
|
932
|
+
) {
|
|
933
|
+
out.push(makeEditPreviewPhantom(t, ghost));
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
return out;
|
|
938
|
+
}
|
|
939
|
+
const out: SvarTaskWithComputed[] = [];
|
|
940
|
+
for (const t of renderableTasks) {
|
|
941
|
+
const liveBarBaseline = resolvedBaselines.length === 1 ? resolvedBaselines[0] : undefined;
|
|
942
|
+
out.push(toSvarTask(t, liveBarBaseline, calendar));
|
|
943
|
+
if (t.type === 'summary') continue;
|
|
944
|
+
for (const b of resolvedBaselines) {
|
|
945
|
+
const phantom = makeBaselinePhantom(t, b, calendar);
|
|
946
|
+
if (phantom) out.push(phantom);
|
|
947
|
+
}
|
|
948
|
+
// Edit preview phantom goes last (renders below baseline phantoms).
|
|
949
|
+
const ghost = ghostById.get(t.id);
|
|
950
|
+
if (
|
|
951
|
+
ghost &&
|
|
952
|
+
(ghost.start.getTime() !== t.start.getTime() || ghost.end.getTime() !== t.end.getTime())
|
|
953
|
+
) {
|
|
954
|
+
out.push(makeEditPreviewPhantom(t, ghost));
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
return out;
|
|
958
|
+
}
|
|
959
|
+
|
|
549
960
|
function resolveMarkers(
|
|
550
961
|
userMarkers: GanttMarker[] | undefined,
|
|
551
962
|
projectStart: Date,
|
|
@@ -608,6 +1019,110 @@ function toSvarColumn(c: GanttColumn): IColumnConfig {
|
|
|
608
1019
|
return config;
|
|
609
1020
|
}
|
|
610
1021
|
|
|
1022
|
+
function getInputType(field: EditableField): string {
|
|
1023
|
+
if (field === 'start' || field === 'end') return 'date';
|
|
1024
|
+
if (field === 'duration' || field === 'progress') return 'number';
|
|
1025
|
+
return 'text';
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
function getInputValue(task: Task, field: EditableField): string {
|
|
1029
|
+
switch (field) {
|
|
1030
|
+
case 'text':
|
|
1031
|
+
return task.text;
|
|
1032
|
+
case 'start':
|
|
1033
|
+
return formatShortDate(task.start);
|
|
1034
|
+
case 'end':
|
|
1035
|
+
return formatShortDate(task.end);
|
|
1036
|
+
case 'duration':
|
|
1037
|
+
return String(Math.round(task.duration / 60 / 8));
|
|
1038
|
+
case 'progress':
|
|
1039
|
+
return String(task.progress);
|
|
1040
|
+
case 'scheduleMode':
|
|
1041
|
+
return task.scheduleMode;
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
const EDITABLE_FIELDS = new Set<string>([
|
|
1046
|
+
'text',
|
|
1047
|
+
'start',
|
|
1048
|
+
'end',
|
|
1049
|
+
'duration',
|
|
1050
|
+
'progress',
|
|
1051
|
+
'scheduleMode',
|
|
1052
|
+
]);
|
|
1053
|
+
|
|
1054
|
+
function buildEditableCell(
|
|
1055
|
+
field: EditableField,
|
|
1056
|
+
editStateRef: { readonly current: EditState },
|
|
1057
|
+
onTaskEditRef: { readonly current: GanttProps['onTaskEdit'] },
|
|
1058
|
+
): IColumnConfig['cell'] {
|
|
1059
|
+
return ({ row }: { row: unknown }) => {
|
|
1060
|
+
const editState = editStateRef.current;
|
|
1061
|
+
const task = row as SvarTaskWithComputed;
|
|
1062
|
+
if (task.is_baseline_ghost || (task as { is_edit_preview?: boolean }).is_edit_preview) {
|
|
1063
|
+
return <span />;
|
|
1064
|
+
}
|
|
1065
|
+
const isReadOnly =
|
|
1066
|
+
task.type === 'summary' && (field === 'start' || field === 'end' || field === 'duration');
|
|
1067
|
+
|
|
1068
|
+
const isActive =
|
|
1069
|
+
editState.activeCell?.taskId === task.id && editState.activeCell?.field === field;
|
|
1070
|
+
|
|
1071
|
+
if (isActive) {
|
|
1072
|
+
return (
|
|
1073
|
+
<input
|
|
1074
|
+
// biome-ignore lint/a11y/noAutofocus: intentional — cell was clicked
|
|
1075
|
+
autoFocus
|
|
1076
|
+
key={`${editState.activeCell?.taskId}-${field}`}
|
|
1077
|
+
type={getInputType(field)}
|
|
1078
|
+
defaultValue={editState.dirtyValue}
|
|
1079
|
+
style={{ width: '100%', boxSizing: 'border-box' }}
|
|
1080
|
+
onChange={(e) => editStateRef.current.setValue(e.target.value)}
|
|
1081
|
+
onBlur={() => editStateRef.current.commitCell(onTaskEditRef.current)}
|
|
1082
|
+
onKeyDown={(e) => {
|
|
1083
|
+
if (e.key === 'Enter' || e.key === 'Tab') {
|
|
1084
|
+
e.preventDefault();
|
|
1085
|
+
editStateRef.current.commitCell(onTaskEditRef.current);
|
|
1086
|
+
} else if (e.key === 'Escape') {
|
|
1087
|
+
editStateRef.current.cancelCell();
|
|
1088
|
+
}
|
|
1089
|
+
}}
|
|
1090
|
+
/>
|
|
1091
|
+
);
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
const displayValue = (() => {
|
|
1095
|
+
if (field === 'start' || field === 'end') {
|
|
1096
|
+
const d = task[field as 'start' | 'end'];
|
|
1097
|
+
return d instanceof Date ? formatShortDate(d) : '';
|
|
1098
|
+
}
|
|
1099
|
+
if (field === 'duration') return String(Math.round((task.duration ?? 0) / 60 / 8));
|
|
1100
|
+
if (field === 'progress') return String(task.progress ?? 0);
|
|
1101
|
+
return String((task as Record<string, unknown>)[field] ?? '');
|
|
1102
|
+
})();
|
|
1103
|
+
|
|
1104
|
+
if (isReadOnly || task.id === undefined) {
|
|
1105
|
+
return <span>{displayValue}</span>;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
const taskId = task.id as TaskId;
|
|
1109
|
+
const activate = () =>
|
|
1110
|
+
editStateRef.current.activateCell(
|
|
1111
|
+
taskId,
|
|
1112
|
+
field,
|
|
1113
|
+
getInputValue(task as unknown as Task, field),
|
|
1114
|
+
);
|
|
1115
|
+
|
|
1116
|
+
return (
|
|
1117
|
+
// biome-ignore lint/a11y/useKeyWithClickEvents: cell activation via keyboard handled by the input that renders on activate
|
|
1118
|
+
// biome-ignore lint/a11y/noStaticElementInteractions: grid cell — role="gridcell" would be on the parent SVAR element
|
|
1119
|
+
<span style={{ cursor: 'text', display: 'block', width: '100%' }} onClick={activate}>
|
|
1120
|
+
{displayValue}
|
|
1121
|
+
</span>
|
|
1122
|
+
);
|
|
1123
|
+
};
|
|
1124
|
+
}
|
|
1125
|
+
|
|
611
1126
|
function buildHighlightTime(
|
|
612
1127
|
calendar: Calendar | undefined,
|
|
613
1128
|
): ((date: Date, unit: 'day' | 'hour') => string) | undefined {
|