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/dist/index.cjs
CHANGED
|
@@ -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] = react.useState({
|
|
1993
|
+
activeCell: null,
|
|
1994
|
+
dirtyValue: ''
|
|
1995
|
+
});
|
|
1996
|
+
// stateRef lets commitCell read the latest state without stale closures.
|
|
1997
|
+
const stateRef = react.useRef(state);
|
|
1998
|
+
stateRef.current = state;
|
|
1999
|
+
const activateCell = react.useCallback((taskId, field, initialValue)=>{
|
|
2000
|
+
setState({
|
|
2001
|
+
activeCell: {
|
|
2002
|
+
taskId,
|
|
2003
|
+
field
|
|
2004
|
+
},
|
|
2005
|
+
dirtyValue: initialValue
|
|
2006
|
+
});
|
|
2007
|
+
}, []);
|
|
2008
|
+
const setValue = react.useCallback((value)=>{
|
|
2009
|
+
setState((prev)=>({
|
|
2010
|
+
...prev,
|
|
2011
|
+
dirtyValue: value
|
|
2012
|
+
}));
|
|
2013
|
+
}, []);
|
|
2014
|
+
const commitCell = react.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 = react.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] = react.useState(null);
|
|
2042
|
+
const timerRef = react.useRef(null);
|
|
2043
|
+
react.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
|
+
react.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
|
-
//
|
|
1920
|
-
//
|
|
1921
|
-
// task's early-start collapse to 0
|
|
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
|
|
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] = react.useState(DRAG_INITIAL);
|
|
2152
|
+
const dragRef = react.useRef(dragState);
|
|
2153
|
+
dragRef.current = dragState;
|
|
2154
|
+
const onBarMouseDown = react.useCallback((sourceId, e)=>{
|
|
2155
|
+
e.preventDefault();
|
|
2156
|
+
setDragState(startDrag(sourceId, e.clientX, e.clientY));
|
|
2157
|
+
}, []);
|
|
2158
|
+
const onMouseMove = react.useCallback((e)=>{
|
|
2159
|
+
setDragState((s)=>moveDrag(s, e.clientX, e.clientY));
|
|
2160
|
+
}, []);
|
|
2161
|
+
const onMouseUp = react.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 = react.useCallback(()=>setDragState(DRAG_INITIAL), []);
|
|
2183
|
+
return {
|
|
2184
|
+
dragState,
|
|
2185
|
+
onBarMouseDown,
|
|
2186
|
+
onMouseMove,
|
|
2187
|
+
onMouseUp,
|
|
2188
|
+
cancelDragLink
|
|
2189
|
+
};
|
|
2190
|
+
}
|
|
2191
|
+
const Gantt = /*#__PURE__*/ react.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 = react.useRef(null);
|
|
2193
|
+
const effectiveBaselineIndices = react.useMemo(()=>resolveEffectiveBaselineIndices(baselineIndices, baselineIndex), [
|
|
2194
|
+
baselineIndices,
|
|
2195
|
+
baselineIndex
|
|
2196
|
+
]);
|
|
1943
2197
|
const scheduled = react.useMemo(()=>preScheduled ? project : schedule(project), [
|
|
1944
2198
|
project,
|
|
1945
2199
|
preScheduled
|
|
@@ -1954,34 +2208,51 @@ const Gantt = /*#__PURE__*/ react.forwardRef(function Gantt({ project, height =
|
|
|
1954
2208
|
scheduled.calendars,
|
|
1955
2209
|
scheduled.defaultCalendarId
|
|
1956
2210
|
]);
|
|
1957
|
-
const
|
|
1958
|
-
|
|
1959
|
-
|
|
2211
|
+
const editState = useEditState();
|
|
2212
|
+
const editStateRef = react.useRef(editState);
|
|
2213
|
+
editStateRef.current = editState;
|
|
2214
|
+
const onTaskEditRef = react.useRef(onTaskEdit);
|
|
2215
|
+
onTaskEditRef.current = onTaskEdit;
|
|
2216
|
+
const editGhostProject = usePreviewEngine(scheduled, editState.activeCell, editState.dirtyValue);
|
|
2217
|
+
const { dragState, onBarMouseDown, onMouseMove, onMouseUp, cancelDragLink } = useDragLink();
|
|
2218
|
+
react.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 = react.useMemo(()=>resolveBaselines(scheduled.baselines, effectiveBaselineIndices), [
|
|
1961
2246
|
scheduled.baselines,
|
|
1962
|
-
|
|
2247
|
+
effectiveBaselineIndices
|
|
1963
2248
|
]);
|
|
1964
|
-
const ghostBarsEnabled =
|
|
1965
|
-
const svarTasks = react.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 = react.useMemo(()=>buildSvarTasks(renderableTasks, resolvedBaselines, calendar, ghostBarsEnabled, editGhostProject ?? undefined), [
|
|
1981
2251
|
renderableTasks,
|
|
1982
|
-
|
|
2252
|
+
resolvedBaselines,
|
|
1983
2253
|
calendar,
|
|
1984
|
-
ghostBarsEnabled
|
|
2254
|
+
ghostBarsEnabled,
|
|
2255
|
+
editGhostProject
|
|
1985
2256
|
]);
|
|
1986
2257
|
const svarLinks = react.useMemo(()=>scheduled.links.map(toSvarLink), [
|
|
1987
2258
|
scheduled.links
|
|
@@ -2001,16 +2272,63 @@ const Gantt = /*#__PURE__*/ react.forwardRef(function Gantt({ project, height =
|
|
|
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 = react.useMemo(()=>{
|
|
2005
|
-
if (
|
|
2006
|
-
|
|
2007
|
-
|
|
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 = react.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__*/ jsxRuntime.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__*/ jsxRuntime.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__*/ jsxRuntime.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
|
-
|
|
2326
|
+
editMode,
|
|
2327
|
+
onBarMouseDown
|
|
2010
2328
|
]);
|
|
2011
2329
|
react.useImperativeHandle(ref, ()=>({
|
|
2012
2330
|
async exportPNG (options) {
|
|
2013
|
-
const { exportPNG } = await Promise.resolve().then(function () { return require('./png-
|
|
2331
|
+
const { exportPNG } = await Promise.resolve().then(function () { return require('./png-BloW1DDl.cjs'); });
|
|
2014
2332
|
return exportPNG({
|
|
2015
2333
|
scheduled,
|
|
2016
2334
|
ganttProps: {
|
|
@@ -2018,6 +2336,7 @@ const Gantt = /*#__PURE__*/ react.forwardRef(function Gantt({ project, height =
|
|
|
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__*/ react.forwardRef(function Gantt({ project, height =
|
|
|
2027
2346
|
});
|
|
2028
2347
|
},
|
|
2029
2348
|
async exportPDF (options) {
|
|
2030
|
-
const { exportPDF } = await Promise.resolve().then(function () { return require('./pdf-
|
|
2349
|
+
const { exportPDF } = await Promise.resolve().then(function () { return require('./pdf-N2LwD-_F.cjs'); });
|
|
2031
2350
|
return exportPDF({
|
|
2032
2351
|
scheduled,
|
|
2033
2352
|
ganttProps: {
|
|
@@ -2035,6 +2354,7 @@ const Gantt = /*#__PURE__*/ react.forwardRef(function Gantt({ project, height =
|
|
|
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__*/ react.forwardRef(function Gantt({ project, height =
|
|
|
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
|
|
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__*/ jsxRuntime.jsxs("div", {
|
|
2065
2388
|
ref: containerRef,
|
|
2066
2389
|
style: {
|
|
2390
|
+
position: 'relative',
|
|
2067
2391
|
height
|
|
2068
2392
|
},
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
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__*/ jsxRuntime.jsx(reactGantt.Gantt, {
|
|
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__*/ jsxRuntime.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__*/ jsxRuntime.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__*/ jsxRuntime.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
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
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__*/ jsxRuntime.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:
|
|
2454
|
+
children: [
|
|
2455
|
+
/*#__PURE__*/ jsxRuntime.jsx("span", {
|
|
2456
|
+
style: {
|
|
2457
|
+
flex: 1,
|
|
2458
|
+
overflow: 'hidden',
|
|
2459
|
+
textOverflow: 'ellipsis'
|
|
2460
|
+
},
|
|
2461
|
+
children: data.text
|
|
2462
|
+
}),
|
|
2463
|
+
phantomSlipped && /*#__PURE__*/ jsxRuntime.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__*/ jsxRuntime.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}
|
|
2260
|
-
text:
|
|
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__*/ jsxRuntime.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__*/ jsxRuntime.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__*/ jsxRuntime.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__*/ jsxRuntime.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
|
-
|
|
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:
|
|
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');
|