diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 83fb5f1c3..a00bab6bf 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -509,6 +509,14 @@ const en = { confirm: 'Auto-reschedule', cancel: 'Keep as is', }, + autoScheduleDlg: { + title: 'Auto-schedule', + body: 'Shift {count} task(s) later to satisfy dependency links?', + skipped: '{count} locked task(s) also violate links and were skipped.', + confirm: 'Apply', + cancel: 'Cancel', + none: 'All dependencies satisfied — nothing to reschedule.', + }, resource: { header: 'Resource', peak: 'Peak', diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 6cbff3828..dfcf02bdc 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -585,6 +585,14 @@ const zh = { confirm: '自动重新排程', cancel: '保持不变', }, + autoScheduleDlg: { + title: '自动排程', + body: '将顺延 {count} 个任务以满足依赖约束,是否执行?', + skipped: '另有 {count} 项因锁定/无权限跳过。', + confirm: '执行', + cancel: '取消', + none: '依赖均满足,无需排程', + }, resource: { header: '资源', peak: '峰值', diff --git a/packages/plugin-gantt/src/GanttView.autoscheduledlg.test.tsx b/packages/plugin-gantt/src/GanttView.autoscheduledlg.test.tsx new file mode 100644 index 000000000..fcb23c68b --- /dev/null +++ b/packages/plugin-gantt/src/GanttView.autoscheduledlg.test.tsx @@ -0,0 +1,91 @@ +/** + * Toolbar auto-schedule confirmation flow (自动排程确认弹窗): compute first, + * ask before the bulk write, transient notice when nothing violates. + */ +import React from 'react'; +import { render, act, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { GanttView, type GanttTask } from './GanttView'; + +const D = (s: string) => new Date(s); + +function makeTasks(violating: boolean): GanttTask[] { + return [ + { id: 'P', title: 'Pred', start: D('2024-06-01'), end: D('2024-06-10'), progress: 0, dependencies: [] }, + { + id: 'B', + title: 'Succ', + // Violating: starts before P ends. Clean: starts after. + start: violating ? D('2024-06-05') : D('2024-06-11'), + end: violating ? D('2024-06-08') : D('2024-06-14'), + progress: 0, + dependencies: ['P'], + }, + ]; +} + +function renderView(opts: { violating: boolean; onTaskUpdate: (t: GanttTask, c: any) => void }) { + return render( +
+ +
+ ); +} + +const clickWand = (container: HTMLElement) => { + const btn = container.querySelector('[data-testid="gantt-auto-schedule"]') as HTMLElement; + expect(btn).toBeTruthy(); + act(() => { fireEvent.click(btn); }); +}; + +describe('auto-schedule confirmation dialog', () => { + it('computes first and asks — nothing is written before confirm', () => { + const onTaskUpdate = vi.fn(); + const { container, baseElement } = renderView({ violating: true, onTaskUpdate }); + clickWand(container); + const dlg = baseElement.querySelector('[data-testid="gantt-autoschedule-dialog"]'); + expect(dlg).toBeTruthy(); + expect(dlg!.textContent).toContain('1'); + expect(onTaskUpdate).not.toHaveBeenCalled(); + }); + + it('confirm applies the shift', async () => { + const onTaskUpdate = vi.fn(); + const { container, baseElement } = renderView({ violating: true, onTaskUpdate }); + clickWand(container); + await act(async () => { + fireEvent.click(baseElement.querySelector('[data-testid="gantt-autoschedule-confirm"]') as HTMLElement); + }); + expect(onTaskUpdate).toHaveBeenCalledTimes(1); + const [task, changes] = onTaskUpdate.mock.calls[0]; + expect(task.id).toBe('B'); + expect(changes.start.toISOString()).toBe(D('2024-06-10').toISOString()); + expect(baseElement.querySelector('[data-testid="gantt-autoschedule-dialog"]')).toBeFalsy(); + }); + + it('cancel discards without writing', () => { + const onTaskUpdate = vi.fn(); + const { container, baseElement } = renderView({ violating: true, onTaskUpdate }); + clickWand(container); + act(() => { + fireEvent.click(baseElement.querySelector('[data-testid="gantt-autoschedule-cancel"]') as HTMLElement); + }); + expect(onTaskUpdate).not.toHaveBeenCalled(); + expect(baseElement.querySelector('[data-testid="gantt-autoschedule-dialog"]')).toBeFalsy(); + }); + + it('a clean graph shows the transient notice instead of the dialog', () => { + const onTaskUpdate = vi.fn(); + const { container, baseElement } = renderView({ violating: false, onTaskUpdate }); + clickWand(container); + expect(baseElement.querySelector('[data-testid="gantt-autoschedule-dialog"]')).toBeFalsy(); + expect(baseElement.querySelector('[data-testid="gantt-autoschedule-clean"]')).toBeTruthy(); + expect(onTaskUpdate).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/plugin-gantt/src/GanttView.tsx b/packages/plugin-gantt/src/GanttView.tsx index 9b1c98ce2..9d00766ac 100644 --- a/packages/plugin-gantt/src/GanttView.tsx +++ b/packages/plugin-gantt/src/GanttView.tsx @@ -41,7 +41,7 @@ import { Separator, useResizeObserver, } from "@object-ui/components" -import { computeCriticalPath, computeProjectReschedule, wouldCreateDependencyCycle, type WorkingCalendar, type RescheduleChange } from "./scheduling" +import { computeCriticalPath, computeProjectRescheduleDetailed, wouldCreateDependencyCycle, type WorkingCalendar, type RescheduleChange, type RescheduleOptions } from "./scheduling" import { shiftDayStart, type NormShiftSegments } from "./shifts" import { useGanttTranslation } from "./useGanttTranslation" @@ -205,6 +205,53 @@ export const NOMINAL_DAYS: Record = { export const MS_PER_DAY = 1000 * 60 * 60 * 24; +/** Offset (ms east of UTC) of an IANA time zone at a given instant. */ +function tzOffsetMs(timeZone: string, at: Date): number { + const dtf = new Intl.DateTimeFormat('en-US', { + timeZone, + hour12: false, + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', + }); + const get = (type: string) => Number(dtf.formatToParts(at).find((p) => p.type === type)?.value ?? 0); + const asUTC = Date.UTC(get('year'), get('month') - 1, get('day'), get('hour') % 24, get('minute'), get('second')); + return asUTC - Math.floor(at.getTime() / 1000) * 1000; +} + +/** + * "Shifted clock" for business-time-zone rendering (业务时区渲染): translate + * real instants into a display space where the browser's local clock reads + * the CONFIGURED zone's wall time. All existing local-clock logic — shift + * bands, day columns, snapping, the today line, date labels — then renders + * that zone correctly for every viewer; writes translate back so persisted + * data stays real instants. Per-instant offsets keep DST zones close; + * fixed-offset zones (Asia/Shanghai) are exact. + */ +export function makeTzShift(timeZone?: string): { + delta: number; + to: (d: Date) => Date; + from: (d: Date) => Date; + now: () => Date; +} { + const identity = { delta: 0, to: (d: Date) => d, from: (d: Date) => d, now: () => new Date() }; + if (!timeZone) return identity; + try { + tzOffsetMs(timeZone, new Date()); // validate the IANA name early + } catch { + console.warn(`[GanttView] invalid timeZone "${timeZone}" — falling back to the browser zone`); + return identity; + } + const deltaAt = (d: Date) => tzOffsetMs(timeZone, d) - -d.getTimezoneOffset() * 60000; + const probe = deltaAt(new Date()); + if (probe === 0) return identity; + return { + delta: probe, + to: (d: Date) => new Date(d.getTime() + deltaAt(d)), + from: (d: Date) => new Date(d.getTime() - deltaAt(d)), + now: () => new Date(Date.now() + deltaAt(new Date())), + }; +} + /** Floor a date to the start of its column unit (Monday for weeks). */ export function startOfUnit(date: Date, mode: GanttViewMode): Date { const d = new Date(date); @@ -431,6 +478,23 @@ export interface GanttViewProps { onRefresh?: () => void | Promise /** Disables the refresh button while a host-driven reload is in flight. */ refreshing?: boolean + /** + * Business time zone for rendering (业务时区), an IANA name like + * 'Asia/Shanghai'. The chart's calendar math — shift bands, day columns, + * drag snapping, the today line, start/end labels — renders this zone's + * wall time for EVERY viewer instead of the browser's; without it, a + * viewer in another zone sees bands and dates shifted (班次错位). Writes + * still persist real instants. Note: dates handed to `onBeforeTaskUpdate` + * are in this display space (durations/deltas are unaffected). + */ + timeZone?: string + /** + * Base name for exported files (导出文件名), e.g. the view or object label — + * "排班计划甘特图" exports as `排班计划甘特图-20260719-1530.png`. Falls back + * to "gantt". The timestamp suffix keeps repeated exports from silently + * overwriting each other in the Downloads folder. + */ + exportFileName?: string /** * Per-interaction switches (交互开关). Omit for all-on. See * {@link GanttInteractions}: e.g. `{ resize: false }` keeps bars movable and @@ -590,11 +654,11 @@ function writeSavedLayout(key: string, layout: GanttLayout): void { } export function GanttView({ - tasks, + tasks: tasksProp, viewMode: viewModeProp, - startDate, - endDate, - markers, + startDate: startDateProp, + endDate: endDateProp, + markers: markersProp, onTaskClick, taskUrl, onTaskUpdate: onTaskUpdateProp, @@ -622,15 +686,45 @@ export function GanttView({ onLayoutChange, onRefresh, refreshing = false, + timeZone, + exportFileName, interactions, onBeforeTaskUpdate, }: GanttViewProps) { + // Business-time-zone shim (业务时区): translate every incoming date into + // the display space where the browser clock reads the configured zone's + // wall time; translate emitted date changes back to real instants. All + // calendar logic below runs unchanged and becomes zone-correct. + const tzShift = React.useMemo(() => makeTzShift(timeZone), [timeZone]); + const tasks = React.useMemo(() => { + if (tzShift.delta === 0) return tasksProp; + return tasksProp.map((tk) => ({ + ...tk, + start: tzShift.to(tk.start), + end: tzShift.to(tk.end), + baselineStart: tk.baselineStart ? tzShift.to(tk.baselineStart) : tk.baselineStart, + baselineEnd: tk.baselineEnd ? tzShift.to(tk.baselineEnd) : tk.baselineEnd, + })); + }, [tasksProp, tzShift]); + const startDate = React.useMemo( + () => (startDateProp && tzShift.delta !== 0 ? tzShift.to(startDateProp) : startDateProp), + [startDateProp, tzShift], + ); + const endDate = React.useMemo( + () => (endDateProp && tzShift.delta !== 0 ? tzShift.to(endDateProp) : endDateProp), + [endDateProp, tzShift], + ); + const markers = React.useMemo(() => { + if (!markersProp || tzShift.delta === 0) return markersProp; + return markersProp.map((m) => ({ ...m, date: tzShift.to(new Date(m.date)) })); + }, [markersProp, tzShift]); + const { t, language } = useGanttTranslation(); // Locale for every user-facing date label. Falls back to the runtime default // (browser locale) when no I18nProvider supplies a language, so standalone // embeds and tests behave exactly as before. const dateLocale = language || undefined; - const [currentDate, setCurrentDate] = React.useState(new Date()); + const [currentDate, setCurrentDate] = React.useState(() => tzShift.now()); const containerRef = React.useRef(null); const { width: containerWidth } = useResizeObserver(containerRef); const effectiveWidth = containerWidth || (typeof window !== 'undefined' ? window.innerWidth : 1024); @@ -650,7 +744,18 @@ export function GanttView({ const ixResize = interactions?.resize !== false; const ixProgress = interactions?.progress !== false; const ixLink = interactions?.link !== false; - const onTaskUpdate = effectiveReadOnly ? undefined : onTaskUpdateProp; + // Emitted date changes are display-space — translate back to real instants + // before they reach the host (writes must persist real time). + const onTaskUpdate = React.useMemo(() => { + if (effectiveReadOnly || !onTaskUpdateProp) return undefined; + if (tzShift.delta === 0) return onTaskUpdateProp; + return (task: GanttTask, changes: Partial>) => { + const out = { ...changes }; + if (out.start instanceof Date) out.start = tzShift.from(out.start); + if (out.end instanceof Date) out.end = tzShift.from(out.end); + onTaskUpdateProp(task, out); + }; + }, [effectiveReadOnly, onTaskUpdateProp, tzShift]); const onTaskDelete = effectiveReadOnly ? undefined : onTaskDeleteProp; const onDependencyCreate = effectiveReadOnly || !ixLink ? undefined : onDependencyCreateProp; const onDependencyDelete = effectiveReadOnly || !ixLink ? undefined : onDependencyDeleteProp; @@ -987,6 +1092,38 @@ export function GanttView({ // from what the drag itself applied — that delta is the conflict we surface. const [pendingConflict, setPendingConflict] = React.useState(null); + // Snap a rescheduled start onto the next shift-band boundary (班次边界) so + // a pushed task never starts mid-band — the reschedule twin of the drag + // snapping. Only meaningful when shift segments are configured. + const snapToBandStart = React.useCallback( + (ms: number): number => { + if (!shiftSegments) return ms; + const base = new Date(ms); + base.setHours(0, 0, 0, 0); + // Walk band boundaries from the previous calendar day (a cross-midnight + // band's shift-day starts the day before) until one lands at/after ms. + for (let dayOff = -1; dayOff <= 1; dayOff++) { + let t = base.getTime() + dayOff * MS_PER_DAY + shiftSegments.dayStartMin * 60000; + for (const b of shiftSegments.bands) { + if (t >= ms) return t; + t += b.durMs; + } + } + return ms; + }, + [shiftSegments], + ); + // Reschedule semantics shared by BOTH cascade paths (toolbar auto-schedule + // and the post-drag conflict dialog): self-dated summaries participate, and + // pushed starts snap to shift bands. + const reschedOpts = React.useMemo( + () => ({ + summaryExtent, + snapStart: shiftSegments ? snapToBandStart : undefined, + }), + [summaryExtent, shiftSegments, snapToBandStart], + ); + const maybeFlagConflict = React.useCallback( (applied: Array<{ task: GanttTask; changes: { start?: Date; end?: Date } }>) => { if (!rescheduleOnConflict) return; @@ -1001,7 +1138,7 @@ export function GanttView({ const o = overrides.get(String(t.id)); return o ? { ...t, start: o.start, end: o.end } : t; }); - const changes = computeProjectReschedule(candidate, workingCalendar); + const { changes } = computeProjectRescheduleDetailed(candidate, workingCalendar, reschedOpts); // A change that merely restates the drag override is not a conflict. const conflict = changes.filter((c) => { const o = overrides.get(c.id); @@ -1009,7 +1146,7 @@ export function GanttView({ }); if (conflict.length) setPendingConflict(conflict); }, - [rescheduleOnConflict, workingCalendar], + [rescheduleOnConflict, workingCalendar, reschedOpts], ); const applyReschedule = React.useCallback(() => { @@ -1629,8 +1766,8 @@ export function GanttView({ // Calculate timeline range const timelineRange = React.useMemo(() => { - let start = startDate ? new Date(startDate) : new Date(); - let end = endDate ? new Date(endDate) : new Date(); + let start = startDate ? new Date(startDate) : tzShift.now(); + let end = endDate ? new Date(endDate) : tzShift.now(); if (!startDate && tasks.length > 0) { // Find min start date @@ -1664,7 +1801,7 @@ export function GanttView({ // and `fitColumnWidth` stretches the column width so a short project still // fills the area — the industry "zoom to fit" approach. return { start, end }; - }, [startDate, endDate, tasks, viewMode, shiftSegments]); + }, [startDate, endDate, tasks, viewMode, shiftSegments, tzShift]); // Non-linear working-time axis (非线性工作时间轴). In day mode, when a working // calendar marks weekends/holidays as non-working, those columns are DROPPED @@ -2161,10 +2298,10 @@ export function GanttView({ // Compute the index (and pixel offset) of "today" within the timeline so // we can render a sticky marker AND scroll to it on demand. const todayLeftPx = React.useMemo(() => { - const now = new Date(); + const now = tzShift.now(); if (now < timelineRange.start || now > timelineRange.end) return null; return Math.round(dateToX(now)); - }, [timelineRange, dateToX]); + }, [timelineRange, dateToX, tzShift]); const jumpToToday = React.useCallback(() => { if (todayLeftPx == null || !scrollAreaRef.current) return; const target = Math.max(0, todayLeftPx - scrollAreaRef.current.clientWidth / 2); @@ -2288,12 +2425,12 @@ export function GanttView({ [], ); const jumpToWeek = React.useCallback( - () => scrollToDate(startOfUnit(new Date(), 'week')), - [scrollToDate], + () => scrollToDate(startOfUnit(tzShift.now(), 'week')), + [scrollToDate, tzShift], ); const jumpToMonth = React.useCallback( - () => scrollToDate(startOfUnit(new Date(), 'month')), - [scrollToDate], + () => scrollToDate(startOfUnit(tzShift.now(), 'month')), + [scrollToDate, tzShift], ); // --- Always-visible horizontal scrollbar (自绘水平滚动条) ---------------- @@ -2726,16 +2863,41 @@ export function GanttView({ // One-shot dependency-driven reschedule (顺延): push successors later until // their link constraints hold, preserving durations, then persist each // changed task through onTaskUpdate (as one undoable batch). + // Toolbar auto-schedule is a bulk server write, so it CONFIRMS first (the + // same contract as the post-drag conflict dialog): compute → show "shift N + // tasks? (M locked skipped)" → apply only on confirm. A clean graph gets a + // transient "nothing to reschedule" notice instead of a silent no-op. + const [pendingAutoSchedule, setPendingAutoSchedule] = React.useState<{ + changes: RescheduleChange[]; + skipped: number; + } | null>(null); + const [autoScheduleClean, setAutoScheduleClean] = React.useState(false); + React.useEffect(() => { + if (!autoScheduleClean) return; + const timer = setTimeout(() => setAutoScheduleClean(false), 2500); + return () => clearTimeout(timer); + }, [autoScheduleClean]); + const runAutoSchedule = React.useCallback(() => { if (!onTaskUpdate) return; - const changes = computeProjectReschedule(tasks, workingCalendar); + const { changes, skippedLocked } = computeProjectRescheduleDetailed(tasks, workingCalendar, reschedOpts); + if (!changes.length) { + setAutoScheduleClean(true); + return; + } + setPendingAutoSchedule({ changes, skipped: skippedLocked.length }); + }, [tasks, onTaskUpdate, workingCalendar, reschedOpts]); + + const applyAutoSchedule = React.useCallback(() => { + if (!pendingAutoSchedule) return; const updates: Array<{ task: GanttTask; changes: Partial> }> = []; - for (const c of changes) { + for (const c of pendingAutoSchedule.changes) { const task = tasks.find((tk) => String(tk.id) === c.id); if (task) updates.push({ task, changes: { start: c.start, end: c.end } }); } - commitTaskUpdates(updates); - }, [tasks, onTaskUpdate, workingCalendar, commitTaskUpdates]); + if (updates.length) commitTaskUpdates(updates); + setPendingAutoSchedule(null); + }, [pendingAutoSchedule, tasks, commitTaskUpdates]); // --- Export PNG (Phase 6) --------------------------------------------- // Self-contained: re-draw the WHOLE chart (every row, unaffected by row @@ -2791,9 +2953,28 @@ export function GanttView({ }); parts.push(``); + // Fit a bar label into `w` px: per-char width estimate (CJK ≈ 9px, latin + // ≈ 5.5px at font-size 9) with an ellipsis; empty when nothing fits. + const fitText = (s: string, w: number): string => { + const budget = w - 12; + if (budget <= 9) return ''; + let used = 0; + let out = ''; + for (const ch of s) { + const cw = ch.charCodeAt(0) > 255 ? 9 : 5.5; + if (used + cw > budget) return out.length < s.length ? `${out.slice(0, -1)}…` : out; + used += cw; + out += ch; + } + return out; + }; + // Timeline: bars / milestones / links / today line. parts.push(``); rows.forEach((row, i) => { + // 分组层级 (项目/产品): a pure tree header — the live chart draws NO + // bar for these rows, so the export must not invent a rollup one. + if (row.task.type === 'group') return; const y = i * rowHeight; const { left, width } = styleFor(row.start, row.end); const crit = isCriticalTask(row.task.id); @@ -2814,12 +2995,22 @@ export function GanttView({ parts.push(``); const pw = (width * Math.min(100, Math.max(0, row.progress))) / 100; parts.push(``); + // In-bar title (条上标题), matching the live summary bar. + const label = fitText(row.task.title, width); + if (label) { + parts.push(`${esc(label)}`); + } } else { const fill = row.task.color || '#3b82f6'; parts.push(``); const pw = (width * Math.min(100, Math.max(0, row.progress))) / 100; parts.push(``); - if (width >= 24) { + // In-bar title like the live leaf bar (bar_label, e.g. the executor); + // narrow bars fall back to the bare progress number when it fits. + const label = fitText(row.task.title, width); + if (label) { + parts.push(`${esc(label)}`); + } else if (width >= 24) { parts.push(`${Math.round(row.progress)}%`); } } @@ -2853,13 +3044,25 @@ export function GanttView({ return { svg, W, H }; }, [tasks, rows, links, linkPath, styleFor, isCriticalTask, critical, timeColumns, colOffsets, totalWidth, taskListWidth, rowHeight, barTop, barHeight, summaryBarTop, summaryBarHeight, milestoneSize, todayLeftPx, viewMode, showBaselines, baselineTop, baselineHeight, BASELINE_FILL, BASELINE_BORDER, resolvedMarkers, headerGroups]); + // 导出文件名: `-.` — carries the + // business context instead of an opaque `gantt-week`, and the timestamp + // keeps repeated exports from overwriting each other. Filesystem-hostile + // characters are stripped from the label. + const exportFileBase = React.useCallback(() => { + const base = (exportFileName ?? '').replace(/[\\/:*?"<>|\s]+/g, ' ').trim() || 'gantt'; + const d = new Date(); + const p = (n: number) => String(n).padStart(2, '0'); + const ts = `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}`; + return `${base}-${ts}`; + }, [exportFileName]); + const exportPng = React.useCallback(async () => { const built = buildExportSvg(); if (!built) return; const canvas = await rasterizeSvg(built.svg, built.W, built.H); if (!canvas) return; - canvas.toBlob((png) => { if (png) downloadBlob(png, `gantt-${viewMode}.png`); }, 'image/png'); - }, [buildExportSvg, viewMode]); + canvas.toBlob((png) => { if (png) downloadBlob(png, `${exportFileBase()}.png`); }, 'image/png'); + }, [buildExportSvg, exportFileBase]); const exportPdf = React.useCallback(async () => { const built = buildExportSvg(); @@ -2869,8 +3072,8 @@ export function GanttView({ // JPEG keeps the embedded image small and embeds directly via DCTDecode. const jpeg = dataUrlToBytes(canvas.toDataURL('image/jpeg', 0.92)); const pdf = buildJpegPdf(jpeg, canvas.width, canvas.height); - downloadBlob(pdf, `gantt-${viewMode}.pdf`); - }, [buildExportSvg, viewMode]); + downloadBlob(pdf, `${exportFileBase()}.pdf`); + }, [buildExportSvg, exportFileBase]); // Snapshot the current layout (granularity + zoom + list state), persist it // under persistLayoutKey, and notify onLayoutChange. The persisted columnWidth @@ -4788,6 +4991,68 @@ export function GanttView({ {/* 拖拽冲突 → 顺延确认 (Group 2). A centered modal lists how many tasks would shift and offers to auto-reschedule (自动顺延) or keep the manual placement (取消保留). */} + {/* 自动排程确认 — the toolbar wand computes first, then asks before the + bulk write; mirrors the conflict dialog's interaction contract. */} + {pendingAutoSchedule && ( +
setPendingAutoSchedule(null)} + > +
e.stopPropagation()} + > +
+ {t('gantt.autoScheduleDlg.title')} +
+
+ {t('gantt.autoScheduleDlg.body').replace('{count}', String(pendingAutoSchedule.changes.length))} + {pendingAutoSchedule.skipped > 0 && ( + <> + {' '} + + {t('gantt.autoScheduleDlg.skipped').replace('{count}', String(pendingAutoSchedule.skipped))} + + + )} +
+
+ + +
+
+
+ )} + + {/* 无需排程 transient notice — every link already holds. */} + {autoScheduleClean && ( +
+ {t('gantt.autoScheduleDlg.none')} +
+ )} + {pendingConflict && (
{ + it('no timeZone → identity', () => { + const s = makeTzShift(undefined); + expect(s.delta).toBe(0); + const d = new Date('2026-07-20T00:00:00Z'); + expect(s.to(d)).toBe(d); + expect(s.from(d)).toBe(d); + }); + + it('invalid IANA name → identity fallback (no throw)', () => { + const s = makeTzShift('Not/AZone'); + expect(s.delta).toBe(0); + }); + + it('to/from round-trips to the exact original instant', () => { + const s = makeTzShift('Asia/Shanghai'); + for (const iso of ['2026-07-20T00:00:00Z', '2026-01-05T13:37:11Z', '2026-12-31T23:59:59Z']) { + const d = new Date(iso); + expect(s.from(s.to(d)).getTime()).toBe(d.getTime()); + } + }); + + it('display space reads the configured zone wall time', () => { + const s = makeTzShift('Asia/Shanghai'); + // 2026-07-20T00:00Z = 08:00 Beijing. Its display-space Date must read + // 08:00 on the BROWSER-LOCAL clock (that is the whole trick). + const disp = s.to(new Date('2026-07-20T00:00:00Z')); + expect(disp.getHours()).toBe(8); + expect(disp.getDate()).toBe(20); + }); + + it('DST zone round-trips across both regimes', () => { + const s = makeTzShift('America/New_York'); + for (const iso of ['2026-01-15T12:00:00Z', '2026-07-15T12:00:00Z']) { + const d = new Date(iso); + expect(s.from(s.to(d)).getTime()).toBe(d.getTime()); + expect(s.to(d).getHours()).toBe(iso.startsWith('2026-01') ? 7 : 8); + } + }); +}); diff --git a/packages/plugin-gantt/src/ObjectGantt.tsx b/packages/plugin-gantt/src/ObjectGantt.tsx index 14e077365..09d6eb2a8 100644 --- a/packages/plugin-gantt/src/ObjectGantt.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.tsx @@ -169,6 +169,21 @@ type GanttConfigEx = GanttConfig & { * (unfiltered) task set while filtering only hides bars. */ autoZoomToFilter?: boolean; + /** + * Business time zone (业务时区), IANA name like 'Asia/Shanghai'. Renders the + * chart's calendar — shift bands, day columns, snapping, today line, date + * labels — in this zone's wall time for every viewer, instead of the + * browser's zone (which misplaces 班次 for viewers elsewhere). Persisted + * data stays real instants. Forwarded to {@link GanttView}. + */ + timeZone?: string; + /** + * Base name for exported PNG/PDF files (导出文件名), e.g. the view's display + * label — the host's view schema often reaches this component stripped of + * `label`, so views declare it here. Falls back to the object schema label, + * then the object API name. A timestamp suffix is always appended. + */ + exportFileName?: string; /** * Per-interaction switches (交互开关): `move` / `resize` / `progress` / `link`, * each defaulting to true. Metadata-drivable so a view can e.g. allow bar @@ -369,6 +384,8 @@ function getGanttConfig(schema: ObjectGridSchema | any): GanttConfigEx | null { autoZoomToFilter: schema.autoZoomToFilter, timeSegments: schema.timeSegments, interactions: schema.interactions, + exportFileName: schema.exportFileName, + timeZone: schema.timeZone, }; return config; } @@ -1422,7 +1439,16 @@ export const ObjectGantt: React.FC = ({ defaultCollapsedDepth={ganttConfig?.defaultCollapsedDepth} summaryExtent={ganttConfig?.summaryExtent} interactions={ganttConfig?.interactions} + timeZone={ganttConfig?.timeZone} onBeforeTaskUpdate={onBeforeTaskUpdate} + exportFileName={ + // Explicit view config first (排班计划甘特图) — the host strips + // `label` off the schema it hands us — then the bound object's + // label, then its API name. + String( + ganttConfig?.exportFileName ?? (schema as any).label ?? objectSchema?.label ?? schema.objectName ?? '' + ) || undefined + } inlineEdit onRefresh={ // Only meaningful when there's a live source to re-read (object or diff --git a/packages/plugin-gantt/src/scheduling.selfextent.test.ts b/packages/plugin-gantt/src/scheduling.selfextent.test.ts new file mode 100644 index 000000000..2ac1326b7 --- /dev/null +++ b/packages/plugin-gantt/src/scheduling.selfextent.test.ts @@ -0,0 +1,103 @@ +/** + * computeProjectRescheduleDetailed — self-extent summaries, locked skipping, + * child cascade and shift-band snapping (自主日期汇总条参与顺延). + */ +import { describe, it, expect } from 'vitest'; +import { computeProjectReschedule, computeProjectRescheduleDetailed, type SchedulableTask } from './scheduling'; + +const D = (s: string) => new Date(s); + +/** A(summary, own dates, children c1 locked + c2 free) ← fs — P(pred). */ +function makeTree(): SchedulableTask[] { + return [ + { id: 'P', start: D('2024-06-01'), end: D('2024-06-10') }, + { + id: 'A', + start: D('2024-06-05'), + end: D('2024-06-12'), + dependencies: ['P'], + hasOwnDates: true, + }, + { id: 'c1', start: D('2024-06-05'), end: D('2024-06-07'), parent: 'A', locked: true }, + { id: 'c2', start: D('2024-06-07'), end: D('2024-06-09'), parent: 'A' }, + ]; +} + +describe('self-extent summary participation', () => { + it("default ('children') semantics: a violated summary stays put (back-compat)", () => { + const changes = computeProjectReschedule(makeTree()); + expect(changes.find((c) => c.id === 'A')).toBeUndefined(); + }); + + it("'self': a violated own-dates summary is pushed to its predecessor's end", () => { + const { changes } = computeProjectRescheduleDetailed(makeTree(), undefined, { summaryExtent: 'self' }); + const a = changes.find((c) => c.id === 'A'); + expect(a).toBeTruthy(); + // FS: A.start >= P.end (06-10); duration 7d preserved. + expect(a!.start.toISOString()).toBe(D('2024-06-10').toISOString()); + expect(a!.end.toISOString()).toBe(D('2024-06-17').toISOString()); + }); + + it('unlocked descendants ride along by the same delta; locked ones stay', () => { + const { changes } = computeProjectRescheduleDetailed(makeTree(), undefined, { summaryExtent: 'self' }); + // delta = +5 days. + const c2 = changes.find((c) => c.id === 'c2'); + expect(c2).toBeTruthy(); + expect(c2!.start.toISOString()).toBe(D('2024-06-12').toISOString()); + expect(c2!.end.toISOString()).toBe(D('2024-06-14').toISOString()); + expect(changes.find((c) => c.id === 'c1')).toBeUndefined(); + }); + + it('a descendant already pushed further by its own links keeps the later position', () => { + const tasks = makeTree(); + // Late external predecessor forces c2 to 06-20 — beyond the +5d cascade. + tasks.push({ id: 'X', start: D('2024-06-15'), end: D('2024-06-20') }); + (tasks.find((t) => t.id === 'c2') as SchedulableTask).dependencies = ['X']; + const { changes } = computeProjectRescheduleDetailed(tasks, undefined, { summaryExtent: 'self' }); + const c2 = changes.find((c) => c.id === 'c2'); + expect(c2!.start.toISOString()).toBe(D('2024-06-20').toISOString()); + }); + + it("a date-less grouping summary (hasOwnDates false) still never moves in 'self' mode", () => { + const tasks = makeTree(); + (tasks.find((t) => t.id === 'A') as SchedulableTask).hasOwnDates = false; + const { changes } = computeProjectRescheduleDetailed(tasks, undefined, { summaryExtent: 'self' }); + expect(changes.find((c) => c.id === 'A')).toBeUndefined(); + }); + + it('a locked violated task is reported in skippedLocked and left in place', () => { + const tasks: SchedulableTask[] = [ + { id: 'P', start: D('2024-06-01'), end: D('2024-06-10') }, + { id: 'B', start: D('2024-06-05'), end: D('2024-06-08'), dependencies: ['P'], locked: true }, + ]; + const { changes, skippedLocked } = computeProjectRescheduleDetailed(tasks); + expect(changes).toHaveLength(0); + expect(skippedLocked).toEqual(['B']); + }); + + it('a satisfied locked task is NOT reported', () => { + const tasks: SchedulableTask[] = [ + { id: 'P', start: D('2024-06-01'), end: D('2024-06-03') }, + { id: 'B', start: D('2024-06-05'), end: D('2024-06-08'), dependencies: ['P'], locked: true }, + ]; + const { skippedLocked } = computeProjectRescheduleDetailed(tasks); + expect(skippedLocked).toHaveLength(0); + }); + + it('snapStart pushes a moved task onto the grid but never touches satisfied tasks', () => { + const tasks: SchedulableTask[] = [ + { id: 'P', start: D('2024-06-01T00:00:00Z'), end: D('2024-06-10T17:00:00Z') }, + { id: 'B', start: D('2024-06-05T08:00:00Z'), end: D('2024-06-08T08:00:00Z'), dependencies: ['P'] }, + { id: 'C', start: D('2024-06-02T09:30:00Z'), end: D('2024-06-03T09:30:00Z') }, + ]; + // Snap to the next whole hour divisible by 12h. + const TWELVE_H = 12 * 3600 * 1000; + const snap = (ms: number) => Math.ceil(ms / TWELVE_H) * TWELVE_H; + const { changes } = computeProjectRescheduleDetailed(tasks, undefined, { snapStart: snap }); + const b = changes.find((c) => c.id === 'B'); + // Required 06-10T17:00 → snapped to 06-11T00:00. + expect(b!.start.toISOString()).toBe(D('2024-06-11T00:00:00Z').toISOString()); + // C is satisfied: not moved, not snapped. + expect(changes.find((c) => c.id === 'C')).toBeUndefined(); + }); +}); diff --git a/packages/plugin-gantt/src/scheduling.ts b/packages/plugin-gantt/src/scheduling.ts index bf73c6f8a..ae0909206 100644 --- a/packages/plugin-gantt/src/scheduling.ts +++ b/packages/plugin-gantt/src/scheduling.ts @@ -113,6 +113,17 @@ export interface SchedulableTask { dependencies?: Array; parent?: string | number | null; type?: string; + /** + * Row-level lock (仅查看). A locked task is never moved by the reschedule; + * when it VIOLATES a link it is reported in `skippedLocked` instead. + */ + locked?: boolean; + /** + * Whether the task's own start/end are real data (mirrors + * GanttTask.hasOwnDates). Only consulted for summaries under + * `summaryExtent: 'self'` — a date-less grouping level still never moves. + */ + hasOwnDates?: boolean; } interface Edge { @@ -302,29 +313,79 @@ export interface RescheduleChange { end: Date; } +export interface RescheduleOptions { + /** + * How summary (parent) tasks participate. `'children'` (default): their + * dates are derived rollups — they constrain successors but are never + * moved. `'self'`: a summary whose dates are its OWN (`hasOwnDates !== + * false`, not locked) shifts like a task, and its unlocked descendants + * ride along by the same delta (the group-drag semantics). + */ + summaryExtent?: 'children' | 'self'; + /** + * Snap a computed start instant onto a grid — e.g. shift-band boundaries + * (班次边界), so a pushed task never starts mid-band. Must return an + * instant `>=` the input (this is 顺延; snapping may only push later). + * Applied only to tasks that actually move, and only in non-calendar mode + * (a WorkingCalendar already owns the day-granularity snapping). + */ + snapStart?: (ms: number) => number; +} + +export interface RescheduleResult { + changes: RescheduleChange[]; + /** + * Tasks that VIOLATE a link but stayed put because they are locked + * (仅查看). Lets the UI report "N skipped" honestly instead of implying + * the schedule is fully repaired. + */ + skippedLocked: string[]; +} + /** * Dependency-driven forward reschedule. Walks the graph in topological order * and pushes each task as late as its predecessors require, preserving its - * duration and never moving it earlier than its current start. Summary tasks - * (parents) are treated as fixed rollup nodes — they constrain successors but - * are not themselves moved. Returns only the tasks whose dates change. + * duration and never moving it earlier than its current start. + * + * Summary handling follows {@link RescheduleOptions.summaryExtent}: rollup + * summaries are fixed predecessor nodes; self-dated summaries move and carry + * their unlocked subtree (a descendant already pushed further by its own + * links keeps the later position — 顺延 only, so max wins). The cascade is a + * single forward pass: carried positions don't re-propagate through links, + * matching the function's minimal-repair pragmatism (no constraint solver). * * When a {@link WorkingCalendar} is supplied, each task's duration is measured * in working days and rescheduled tasks are snapped to start (and finish) on * working days only — weekends/holidays are stepped over rather than consumed. * - * Returns an empty array when the graph has a cycle (ambiguous ordering). + * Returns empty results when the graph has a cycle (ambiguous ordering). */ -export function computeProjectReschedule(tasks: SchedulableTask[], cal?: WorkingCalendar): RescheduleChange[] { - if (!tasks.length) return []; +export function computeProjectRescheduleDetailed( + tasks: SchedulableTask[], + cal?: WorkingCalendar, + opts?: RescheduleOptions, +): RescheduleResult { + if (!tasks.length) return { changes: [], skippedLocked: [] }; const edges = buildEdges(tasks); const allIds = tasks.map((t) => key(t.id)); const { order, hasCycle } = topoOrder(allIds, edges); - if (hasCycle) return []; + if (hasCycle) return { changes: [], skippedLocked: [] }; const summaries = parentIds(tasks); const byId = new Map(); for (const t of tasks) byId.set(key(t.id), t); + const byParent = new Map(); + for (const t of tasks) { + if (t.parent == null || t.parent === '') continue; + const pid = key(t.parent); + const arr = byParent.get(pid) ?? []; + arr.push(t); + byParent.set(pid, arr); + } + + const selfExtent = opts?.summaryExtent === 'self'; + const movableSummary = (t: SchedulableTask) => + selfExtent && t.hasOwnDates !== false && !t.locked; const preds = new Map(); for (const id of allIds) preds.set(id, []); @@ -338,6 +399,8 @@ export function computeProjectReschedule(tasks: SchedulableTask[], cal?: Working endMs.set(key(t.id), t.end.getTime()); } + const skippedLocked: string[] = []; + for (const id of order) { const t = byId.get(id); if (!t) continue; @@ -371,9 +434,18 @@ export function computeProjectReschedule(tasks: SchedulableTask[], cal?: Working } if (candidate > reqStart) reqStart = candidate; } - // Summaries are derived rollups: keep them where they are, only let them - // act as predecessors. Everything else shifts to satisfy its links. - if (summaries.has(id)) continue; + const wantsMove = reqStart > origStart; + // Rollup summaries stay fixed predecessors; self-dated summaries fall + // through and shift like tasks. + if (summaries.has(id) && !movableSummary(t)) continue; + // A locked task never moves — but a violated one is worth reporting. + if (t.locked) { + if (wantsMove) skippedLocked.push(id); + continue; + } + if (wantsMove && !cal && opts?.snapStart) { + reqStart = Math.max(reqStart, opts.snapStart(reqStart)); + } if (cal) { const s = nextWorkingDay(reqStart, cal); startMs.set(id, s); @@ -384,19 +456,51 @@ export function computeProjectReschedule(tasks: SchedulableTask[], cal?: Working } } + // Cascade: a shifted self-dated summary carries its subtree along — the + // same semantics as dragging its bar — skipping locked descendants. A + // descendant already pushed further by its OWN links keeps the later + // position (max wins; this stays 顺延-only). + if (selfExtent) { + for (const t of tasks) { + const id = key(t.id); + if (!summaries.has(id) || !movableSummary(t)) continue; + const delta = startMs.get(id)! - t.start.getTime(); + if (delta <= 0) continue; + const stack = [...(byParent.get(id) ?? [])]; + while (stack.length) { + const d = stack.pop()!; + const did = key(d.id); + stack.push(...(byParent.get(did) ?? [])); + if (d.locked) continue; + const candStart = d.start.getTime() + delta; + if (candStart > startMs.get(did)!) { + const dur = endMs.get(did)! - startMs.get(did)!; + startMs.set(did, candStart); + endMs.set(did, candStart + dur); + } + } + } + } + const changes: RescheduleChange[] = []; for (const t of tasks) { const id = key(t.id); - if (summaries.has(id)) continue; const ns = startMs.get(id)!; const ne = endMs.get(id)!; - // In calendar mode a task can keep its start yet have its finish snapped off - // a weekend, so report end-only moves too. + // Untouched categories (locked rows, rollup summaries) keep their seeded + // values and drop out here naturally. In calendar mode a task can keep + // its start yet have its finish snapped off a weekend, so end-only moves + // are reported too. if (ns !== t.start.getTime() || ne !== t.end.getTime()) { changes.push({ id, start: new Date(ns), end: new Date(ne) }); } } - return changes; + return { changes, skippedLocked }; +} + +/** Back-compat wrapper: changes only, default (rollup-summary) semantics. */ +export function computeProjectReschedule(tasks: SchedulableTask[], cal?: WorkingCalendar): RescheduleChange[] { + return computeProjectRescheduleDetailed(tasks, cal).changes; } // --------------------------------------------------------------------------- diff --git a/packages/plugin-gantt/src/useGanttTranslation.ts b/packages/plugin-gantt/src/useGanttTranslation.ts index 69624f43f..ec9db7565 100644 --- a/packages/plugin-gantt/src/useGanttTranslation.ts +++ b/packages/plugin-gantt/src/useGanttTranslation.ts @@ -79,6 +79,12 @@ export const GANTT_DEFAULT_TRANSLATIONS: Record = { 'gantt.conflict.body': 'This move conflicts with dependency constraints. Auto-reschedule {count} affected task(s)?', 'gantt.conflict.confirm': 'Auto-reschedule', 'gantt.conflict.cancel': 'Keep as is', + 'gantt.autoScheduleDlg.title': 'Auto-schedule', + 'gantt.autoScheduleDlg.body': 'Shift {count} task(s) later to satisfy dependency links?', + 'gantt.autoScheduleDlg.skipped': '{count} locked task(s) also violate links and were skipped.', + 'gantt.autoScheduleDlg.confirm': 'Apply', + 'gantt.autoScheduleDlg.cancel': 'Cancel', + 'gantt.autoScheduleDlg.none': 'All dependencies satisfied — nothing to reschedule.', 'gantt.resource.header': 'Resource', 'gantt.resource.peak': 'Peak', 'gantt.resource.over': 'overloaded',