construction-gantt 0.2.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 +57 -0
- package/dist/index.cjs +603 -36
- package/dist/index.d.cts +8 -1
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +8 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +605 -38
- package/package.json +1 -1
- package/src/Gantt.css +38 -0
- package/src/Gantt.tsx +366 -10
- package/src/editing/dragLink.ts +47 -0
- package/src/editing/useEditState.ts +98 -0
- package/src/editing/usePreviewEngine.ts +47 -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "construction-gantt",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "MIT React Gantt for construction project management — PRO-equivalent scheduling engine + construction-vertical extensions on free SVAR React Gantt",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/Gantt.css
CHANGED
|
@@ -60,3 +60,41 @@
|
|
|
60
60
|
.bode-baseline-8 { background: #f97316; color: #0f172a; } /* orange */
|
|
61
61
|
.bode-baseline-9 { background: #a855f7; } /* purple */
|
|
62
62
|
.bode-baseline-10 { background: #dc2626; } /* red */
|
|
63
|
+
|
|
64
|
+
/* Edit preview ghost bar — shows live CPM cascade preview while editing.
|
|
65
|
+
* Matches baseline ghost sizing (60% height, 15% top margin) so preview
|
|
66
|
+
* phantoms read as secondary/tentative alongside the live bar. */
|
|
67
|
+
.bode-edit-preview {
|
|
68
|
+
height: 60%;
|
|
69
|
+
margin-top: 15%;
|
|
70
|
+
background: repeating-linear-gradient(
|
|
71
|
+
45deg,
|
|
72
|
+
rgba(59, 130, 246, 0.35) 0px,
|
|
73
|
+
rgba(59, 130, 246, 0.35) 6px,
|
|
74
|
+
rgba(59, 130, 246, 0.15) 6px,
|
|
75
|
+
rgba(59, 130, 246, 0.15) 12px
|
|
76
|
+
);
|
|
77
|
+
border: 1.5px dashed rgba(59, 130, 246, 0.7);
|
|
78
|
+
border-radius: 3px;
|
|
79
|
+
pointer-events: none;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/* Drag handle — appears at right edge of task bar in editMode */
|
|
83
|
+
.construction-gantt-drag-handle {
|
|
84
|
+
position: absolute;
|
|
85
|
+
right: -5px;
|
|
86
|
+
top: 50%;
|
|
87
|
+
transform: translateY(-50%);
|
|
88
|
+
width: 10px;
|
|
89
|
+
height: 10px;
|
|
90
|
+
border-radius: 50%;
|
|
91
|
+
background: rgba(59, 130, 246, 0.85);
|
|
92
|
+
cursor: crosshair;
|
|
93
|
+
opacity: 0;
|
|
94
|
+
transition: opacity 0.15s;
|
|
95
|
+
z-index: 10;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.wx-task:hover .construction-gantt-drag-handle {
|
|
99
|
+
opacity: 1;
|
|
100
|
+
}
|
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,
|
|
@@ -162,8 +187,21 @@ export interface GanttProps {
|
|
|
162
187
|
* `visibility.ts` for the contract test.
|
|
163
188
|
*/
|
|
164
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;
|
|
165
195
|
}
|
|
166
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
|
+
|
|
167
205
|
interface SvarTaskWithComputed extends ITask {
|
|
168
206
|
is_critical?: boolean;
|
|
169
207
|
is_late?: boolean;
|
|
@@ -178,6 +216,8 @@ interface SvarTaskWithComputed extends ITask {
|
|
|
178
216
|
is_baseline_ghost?: boolean;
|
|
179
217
|
/** When set, identifies which baseline (0..10) this phantom row mirrors. */
|
|
180
218
|
baseline_index?: BaselineIndex;
|
|
219
|
+
/** True for phantom rows representing a live recalc preview position. */
|
|
220
|
+
is_edit_preview?: boolean;
|
|
181
221
|
}
|
|
182
222
|
|
|
183
223
|
interface SvarMarker {
|
|
@@ -186,6 +226,58 @@ interface SvarMarker {
|
|
|
186
226
|
css?: string;
|
|
187
227
|
}
|
|
188
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
|
+
|
|
189
281
|
export const Gantt = forwardRef<GanttHandle, GanttProps>(function Gantt(
|
|
190
282
|
{
|
|
191
283
|
project,
|
|
@@ -199,6 +291,10 @@ export const Gantt = forwardRef<GanttHandle, GanttProps>(function Gantt(
|
|
|
199
291
|
showBaselineBars,
|
|
200
292
|
columns,
|
|
201
293
|
visibleTaskIds,
|
|
294
|
+
editMode = false,
|
|
295
|
+
onTaskEdit,
|
|
296
|
+
onLinkCreate,
|
|
297
|
+
onLinkDelete,
|
|
202
298
|
},
|
|
203
299
|
ref,
|
|
204
300
|
) {
|
|
@@ -226,6 +322,32 @@ export const Gantt = forwardRef<GanttHandle, GanttProps>(function Gantt(
|
|
|
226
322
|
[scheduled.calendars, scheduled.defaultCalendarId],
|
|
227
323
|
);
|
|
228
324
|
|
|
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);
|
|
331
|
+
|
|
332
|
+
const { dragState, onBarMouseDown, onMouseMove, onMouseUp, cancelDragLink } = useDragLink();
|
|
333
|
+
|
|
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
|
+
|
|
229
351
|
// Resolve effective indices → actual Baseline records, dropping any that
|
|
230
352
|
// don't exist on the project. Preserves caller order so phantom rows
|
|
231
353
|
// render in the array order the consumer passed.
|
|
@@ -237,8 +359,15 @@ export const Gantt = forwardRef<GanttHandle, GanttProps>(function Gantt(
|
|
|
237
359
|
const ghostBarsEnabled = resolvedBaselines.length > 0 && (showBaselineBars ?? true);
|
|
238
360
|
|
|
239
361
|
const svarTasks: ITask[] = useMemo(
|
|
240
|
-
() =>
|
|
241
|
-
|
|
362
|
+
() =>
|
|
363
|
+
buildSvarTasks(
|
|
364
|
+
renderableTasks,
|
|
365
|
+
resolvedBaselines,
|
|
366
|
+
calendar,
|
|
367
|
+
ghostBarsEnabled,
|
|
368
|
+
editGhostProject ?? undefined,
|
|
369
|
+
),
|
|
370
|
+
[renderableTasks, resolvedBaselines, calendar, ghostBarsEnabled, editGhostProject],
|
|
242
371
|
);
|
|
243
372
|
const svarLinks: ILink[] = useMemo(() => scheduled.links.map(toSvarLink), [scheduled.links]);
|
|
244
373
|
|
|
@@ -257,11 +386,51 @@ export const Gantt = forwardRef<GanttHandle, GanttProps>(function Gantt(
|
|
|
257
386
|
// undefined → don't pass columns to SVAR (use SVAR defaults).
|
|
258
387
|
// [] → pass false to SVAR (hide grid entirely).
|
|
259
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
|
|
260
391
|
const svarColumns: IColumnConfig[] | false | undefined = useMemo(() => {
|
|
261
|
-
if (
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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]);
|
|
265
434
|
|
|
266
435
|
useImperativeHandle(
|
|
267
436
|
ref,
|
|
@@ -322,7 +491,25 @@ export const Gantt = forwardRef<GanttHandle, GanttProps>(function Gantt(
|
|
|
322
491
|
);
|
|
323
492
|
|
|
324
493
|
return (
|
|
325
|
-
|
|
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
|
+
>
|
|
326
513
|
<SvarGantt
|
|
327
514
|
tasks={svarTasks}
|
|
328
515
|
links={svarLinks}
|
|
@@ -332,14 +519,40 @@ export const Gantt = forwardRef<GanttHandle, GanttProps>(function Gantt(
|
|
|
332
519
|
cellHeight={cellHeight}
|
|
333
520
|
markers={svarMarkers}
|
|
334
521
|
highlightTime={highlightTime}
|
|
335
|
-
taskTemplate={
|
|
522
|
+
taskTemplate={taskTemplate}
|
|
336
523
|
{...(svarColumns !== undefined ? { columns: svarColumns as IColumnConfig[] } : {})}
|
|
337
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
|
+
)}
|
|
338
548
|
</div>
|
|
339
549
|
);
|
|
340
550
|
});
|
|
341
551
|
|
|
342
552
|
export const ConstructionBar: FC<{ data: SvarTaskWithComputed }> = ({ data }) => {
|
|
553
|
+
if (data.is_edit_preview) {
|
|
554
|
+
return <div className="bode-edit-preview" />;
|
|
555
|
+
}
|
|
343
556
|
// Phantom baseline row — render a slim outlined ghost bar.
|
|
344
557
|
if (data.is_baseline_ghost) {
|
|
345
558
|
const baselineIdx = data.baseline_index ?? 0;
|
|
@@ -569,6 +782,21 @@ function makeBaselinePhantom(
|
|
|
569
782
|
};
|
|
570
783
|
}
|
|
571
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,
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
|
|
572
800
|
function toSvarLink(l: Link): ILink {
|
|
573
801
|
return {
|
|
574
802
|
id: l.id,
|
|
@@ -687,10 +915,26 @@ export function buildSvarTasks(
|
|
|
687
915
|
resolvedBaselines: Baseline[],
|
|
688
916
|
calendar: Calendar | undefined,
|
|
689
917
|
ghostBarsEnabled: boolean,
|
|
918
|
+
editGhostProject?: Project,
|
|
690
919
|
): SvarTaskWithComputed[] {
|
|
920
|
+
const ghostById = new Map<TaskId, Task>(editGhostProject?.tasks.map((t) => [t.id, t]) ?? []);
|
|
921
|
+
|
|
691
922
|
if (!ghostBarsEnabled || resolvedBaselines.length === 0) {
|
|
692
923
|
const primary = resolvedBaselines[0];
|
|
693
|
-
|
|
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;
|
|
694
938
|
}
|
|
695
939
|
const out: SvarTaskWithComputed[] = [];
|
|
696
940
|
for (const t of renderableTasks) {
|
|
@@ -701,6 +945,14 @@ export function buildSvarTasks(
|
|
|
701
945
|
const phantom = makeBaselinePhantom(t, b, calendar);
|
|
702
946
|
if (phantom) out.push(phantom);
|
|
703
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
|
+
}
|
|
704
956
|
}
|
|
705
957
|
return out;
|
|
706
958
|
}
|
|
@@ -767,6 +1019,110 @@ function toSvarColumn(c: GanttColumn): IColumnConfig {
|
|
|
767
1019
|
return config;
|
|
768
1020
|
}
|
|
769
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
|
+
|
|
770
1126
|
function buildHighlightTime(
|
|
771
1127
|
calendar: Calendar | undefined,
|
|
772
1128
|
): ((date: Date, unit: 'day' | 'hour') => string) | undefined {
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Link, TaskId } from '../types.js';
|
|
2
|
+
|
|
3
|
+
export type DragLinkState =
|
|
4
|
+
| { status: 'idle' }
|
|
5
|
+
| {
|
|
6
|
+
status: 'dragging';
|
|
7
|
+
sourceId: TaskId;
|
|
8
|
+
startX: number;
|
|
9
|
+
startY: number;
|
|
10
|
+
cursorX: number;
|
|
11
|
+
cursorY: number;
|
|
12
|
+
}
|
|
13
|
+
| { status: 'dropped'; sourceId: TaskId; targetId: TaskId };
|
|
14
|
+
|
|
15
|
+
export const DRAG_INITIAL: DragLinkState = { status: 'idle' };
|
|
16
|
+
|
|
17
|
+
export function startDrag(sourceId: TaskId, startX: number, startY: number): DragLinkState {
|
|
18
|
+
return { status: 'dragging', sourceId, startX, startY, cursorX: startX, cursorY: startY };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function moveDrag(state: DragLinkState, cursorX: number, cursorY: number): DragLinkState {
|
|
22
|
+
if (state.status !== 'dragging') return state;
|
|
23
|
+
return { ...state, cursorX, cursorY };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function completeDrop(state: DragLinkState, targetId: TaskId | null): DragLinkState {
|
|
27
|
+
if (state.status !== 'dragging') return DRAG_INITIAL;
|
|
28
|
+
if (targetId === null) return DRAG_INITIAL;
|
|
29
|
+
return { status: 'dropped', sourceId: state.sourceId, targetId };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function cancelDrag(_state: DragLinkState): DragLinkState {
|
|
33
|
+
return DRAG_INITIAL;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function isDragInvalid(
|
|
37
|
+
sourceId: TaskId,
|
|
38
|
+
targetId: TaskId,
|
|
39
|
+
existingLinks: Link[],
|
|
40
|
+
summaryIds: Set<TaskId>,
|
|
41
|
+
): boolean {
|
|
42
|
+
if (String(sourceId) === String(targetId)) return true;
|
|
43
|
+
if ([...summaryIds].some((id) => String(id) === String(targetId))) return true;
|
|
44
|
+
return existingLinks.some(
|
|
45
|
+
(l) => String(l.source) === String(sourceId) && String(l.target) === String(targetId),
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { useCallback, useRef, useState } from 'react';
|
|
2
|
+
// biome-ignore lint/correctness/noUnusedImports: ScheduleMode is implicitly used in TaskEditPatch return type
|
|
3
|
+
import type { ScheduleMode, Task, TaskId } from '../types.js';
|
|
4
|
+
|
|
5
|
+
export type EditableField = 'text' | 'start' | 'end' | 'duration' | 'progress' | 'scheduleMode';
|
|
6
|
+
|
|
7
|
+
export interface ActiveCell {
|
|
8
|
+
taskId: TaskId;
|
|
9
|
+
field: EditableField;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type TaskEditPatch = Partial<
|
|
13
|
+
Pick<Task, 'text' | 'start' | 'end' | 'duration' | 'progress' | 'scheduleMode'>
|
|
14
|
+
>;
|
|
15
|
+
|
|
16
|
+
export interface EditState {
|
|
17
|
+
activeCell: ActiveCell | null;
|
|
18
|
+
dirtyValue: string;
|
|
19
|
+
activateCell(taskId: TaskId, field: EditableField, initialValue: string): void;
|
|
20
|
+
setValue(value: string): void;
|
|
21
|
+
commitCell(onTaskEdit?: (id: TaskId, patch: TaskEditPatch) => void): void;
|
|
22
|
+
cancelCell(): void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function parseFieldValue(field: EditableField, value: string): TaskEditPatch {
|
|
26
|
+
switch (field) {
|
|
27
|
+
case 'text':
|
|
28
|
+
return { text: value };
|
|
29
|
+
case 'start': {
|
|
30
|
+
const d = new Date(`${value}T08:00:00`);
|
|
31
|
+
if (Number.isNaN(d.getTime())) return {};
|
|
32
|
+
return { start: d };
|
|
33
|
+
}
|
|
34
|
+
case 'end': {
|
|
35
|
+
const d = new Date(`${value}T08:00:00`);
|
|
36
|
+
if (Number.isNaN(d.getTime())) return {};
|
|
37
|
+
return { end: d };
|
|
38
|
+
}
|
|
39
|
+
case 'duration': {
|
|
40
|
+
const n = Number(value);
|
|
41
|
+
if (!Number.isFinite(n) || n < 0) return {};
|
|
42
|
+
return { duration: Math.round(n * 8 * 60) };
|
|
43
|
+
}
|
|
44
|
+
case 'progress': {
|
|
45
|
+
const n = Number(value);
|
|
46
|
+
if (!Number.isFinite(n)) return {};
|
|
47
|
+
return { progress: Math.min(100, Math.max(0, n)) };
|
|
48
|
+
}
|
|
49
|
+
case 'scheduleMode': {
|
|
50
|
+
if (value !== 'auto' && value !== 'manual') return {};
|
|
51
|
+
return { scheduleMode: value };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface EditStateInternal {
|
|
57
|
+
activeCell: ActiveCell | null;
|
|
58
|
+
dirtyValue: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function useEditState(): EditState {
|
|
62
|
+
const [state, setState] = useState<EditStateInternal>({
|
|
63
|
+
activeCell: null,
|
|
64
|
+
dirtyValue: '',
|
|
65
|
+
});
|
|
66
|
+
// stateRef lets commitCell read the latest state without stale closures.
|
|
67
|
+
const stateRef = useRef(state);
|
|
68
|
+
stateRef.current = state;
|
|
69
|
+
|
|
70
|
+
const activateCell = useCallback((taskId: TaskId, field: EditableField, initialValue: string) => {
|
|
71
|
+
setState({ activeCell: { taskId, field }, dirtyValue: initialValue });
|
|
72
|
+
}, []);
|
|
73
|
+
|
|
74
|
+
const setValue = useCallback((value: string) => {
|
|
75
|
+
setState((prev) => ({ ...prev, dirtyValue: value }));
|
|
76
|
+
}, []);
|
|
77
|
+
|
|
78
|
+
const commitCell = useCallback((onTaskEdit?: (id: TaskId, patch: TaskEditPatch) => void) => {
|
|
79
|
+
const { activeCell, dirtyValue } = stateRef.current;
|
|
80
|
+
if (activeCell === null) return;
|
|
81
|
+
const patch = parseFieldValue(activeCell.field, dirtyValue);
|
|
82
|
+
onTaskEdit?.(activeCell.taskId, patch);
|
|
83
|
+
setState({ activeCell: null, dirtyValue: '' });
|
|
84
|
+
}, []);
|
|
85
|
+
|
|
86
|
+
const cancelCell = useCallback(() => {
|
|
87
|
+
setState({ activeCell: null, dirtyValue: '' });
|
|
88
|
+
}, []);
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
activeCell: state.activeCell,
|
|
92
|
+
dirtyValue: state.dirtyValue,
|
|
93
|
+
activateCell,
|
|
94
|
+
setValue,
|
|
95
|
+
commitCell,
|
|
96
|
+
cancelCell,
|
|
97
|
+
};
|
|
98
|
+
}
|