From ce1421d038be42b46a250f7fdedfef7bb5c023b4 Mon Sep 17 00:00:00 2001 From: Behrad Date: Wed, 14 Oct 2026 15:24:11 +0330 Subject: [PATCH] Fix date validations, datepicker overflow, and QC components --- frontend/src/components/JalaliDatePicker.tsx | 101 ++- pm/frontend/src/components/GanttChart.tsx | 744 +++++++------------ pm/frontend/src/components/TaskEditModal.tsx | 69 +- pm/frontend/src/components/TaskQcSection.tsx | 133 +++- pm/frontend/src/pages/ProjectDetailPage.tsx | 365 ++------- pm/frontend/src/pages/TaskDetailPage.tsx | 260 ++++--- tree.txt | Bin 0 -> 1262868 bytes 7 files changed, 667 insertions(+), 1005 deletions(-) create mode 100644 tree.txt diff --git a/frontend/src/components/JalaliDatePicker.tsx b/frontend/src/components/JalaliDatePicker.tsx index 17b3f1d..327b820 100644 --- a/frontend/src/components/JalaliDatePicker.tsx +++ b/frontend/src/components/JalaliDatePicker.tsx @@ -15,11 +15,11 @@ import { cn } from "../lib/utils"; import { useClickOutside } from "./useClickOutside"; export interface JalaliDatePickerProps { - value: string | null; // ISO date (یا YYYY-MM-DD با time اختیاری) + value: string | null; onChange: (val: string | null) => void; placeholder?: string; className?: string; - withTime?: boolean; // نمایش ساعت:دقیقه هم (پیشنهاد/پذیرش/رسید) + withTime?: boolean; disabled?: boolean; } @@ -42,20 +42,16 @@ function isoToJalali(iso: string | null): [number, number, number] | null { return gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate()); } -// ساخت ISO با زمان محلی (تاریخ شمسی + ساعت:دقیقه) function buildIso(jy: number, jm: number, jd: number, h: number, m: number): string { const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd); return new Date(gy, gm - 1, gd, h, m).toISOString(); } -// ساخت تاریخ استاندارد YYYY-MM-DD بدون مؤلفه زمان function buildDateOnly(jy: number, jm: number, jd: number): string { const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd); return `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`; } -// وقتی هنوز زمان مشخصی ست نشده، ساعت/دقیقه‌ی فعلی سیستم پیش‌فرض است -// (نه ۰۰:۰۰) — دکمه‌ی «امروز» و انتخاب روز، همین زمان را نگه می‌دارند. function nowTime(): { h: number; m: number } { const d = new Date(); return { h: d.getHours(), m: d.getMinutes() }; @@ -68,7 +64,6 @@ function timeOf(iso: string | null): { h: number; m: number } { return { h: d.getHours(), m: d.getMinutes() }; } -// تقویم شمسی — پورت Jalali.buildPicker به React (با پشتیبانی ساعت:دقیقه) export function JalaliDatePicker({ value, onChange, @@ -78,6 +73,7 @@ export function JalaliDatePicker({ disabled = false, }: JalaliDatePickerProps) { const [open, setOpen] = useState(false); + const [alignment, setAlignment] = useState<"start" | "end">("start"); // تراز هوشمند const [curJ, setCurJ] = useState<[number, number, number]>(() => isoToJalali(value) ?? todayJalali()); const initialTime = timeOf(value); const [hourText, setHourText] = useState(() => faNum(String(initialTime.h).padStart(2, "0"))); @@ -111,7 +107,6 @@ export function JalaliDatePicker({ const mm = String(t.m).padStart(2, "0"); setText(`${j} ${faNum(hh + ":" + mm)}`); - // Only update local input texts if numeric values differ, to avoid wiping in-progress typing const curH = parseInt(toAsciiDigits(hourText) || "0", 10); if (curH !== t.h) { setHourText(faNum(hh)); @@ -127,7 +122,18 @@ export function JalaliDatePicker({ const ref = useClickOutside(() => setOpen(false)); - // تاریخ انتخاب‌شده به صورت محلی (نه UTC) برای مقایسه با سلول‌های تقویم + // تابع تشخیص موقعیت فیلد و تنظیم تراز تقویم + const handleOpen = () => { + if (disabled) return; + if (ref.current) { + const rect = ref.current.getBoundingClientRect(); + const isLeftHalf = rect.left + rect.width / 2 < window.innerWidth / 2; + // اگر فیلد در نیمه چپ صفحه بود، تراز را از لبه چپ (end) می‌بندیم تا به سمت راست باز شود + setAlignment(isLeftHalf ? "end" : "start"); + } + setOpen(true); + }; + function selectedLocalDate(): string | null { if (!value) return null; const m = String(value).match(/^(\d{4})-(\d{2})-(\d{2})$/); @@ -141,7 +147,7 @@ export function JalaliDatePicker({ const grid = useMemo(() => { const [jy, jm] = curJ; const firstG = jalaliToGregorian(jy, jm, 1); - const firstWd = (new Date(firstG[0], firstG[1] - 1, firstG[2]).getDay() + 1) % 7; // شنبه = 0 + const firstWd = (new Date(firstG[0], firstG[1] - 1, firstG[2]).getDay() + 1) % 7; const dim = daysInMonth(jy, jm); const today = new Date(); const tIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`; @@ -190,11 +196,8 @@ export function JalaliDatePicker({ const handleHourChange = (raw: string) => { let digits = toAsciiDigits(raw).replace(/\D/g, ""); - if (digits.length === 3) { - digits = digits.slice(-1); - } else if (digits.length > 3) { - digits = digits.slice(0, 2); - } + if (digits.length === 3) digits = digits.slice(-1); + else if (digits.length > 3) digits = digits.slice(0, 2); if (!digits) { setHourText(""); return; @@ -228,11 +231,8 @@ export function JalaliDatePicker({ const handleMinuteChange = (raw: string) => { let digits = toAsciiDigits(raw).replace(/\D/g, ""); - if (digits.length === 3) { - digits = digits.slice(-1); - } else if (digits.length > 3) { - digits = digits.slice(0, 2); - } + if (digits.length === 3) digits = digits.slice(-1); + else if (digits.length > 3) digits = digits.slice(0, 2); if (!digits) { setMinuteText(""); return; @@ -269,11 +269,7 @@ export function JalaliDatePicker({ const [jy, jm, jd] = todayJalali(); const nh = now.getHours(); const nm = now.getMinutes(); - setHourText(faNum(String(nh).padStart(2, "0"))); - setMinuteText(faNum(String(nm).padStart(2, "0"))); - const val = withTime - ? buildIso(jy, jm, jd, nh, nm) - : buildDateOnly(jy, jm, jd); + const val = withTime ? buildIso(jy, jm, jd, nh, nm) : buildDateOnly(jy, jm, jd); onChange(val); setText( withTime @@ -364,7 +360,7 @@ export function JalaliDatePicker({ }; return ( -
+
!disabled && setOpen(true)} - onClick={() => !disabled && setOpen(true)} + onFocus={handleOpen} + onClick={handleOpen} onChange={(e) => onInputChange(e.target.value)} onBlur={handleInputBlur} /> {open && !disabled && (
end-0 (تراز چپ) | اگر راست بود -> start-0 (تراز راست) + "rounded-xl border border-line bg-panel shadow-2xl p-3.5", + withTime ? "w-80" : "w-[280px]", + "max-w-[calc(100vw-2rem)]" // حاشیه امنیت برای صفحه نمایش خیلی کوچک )} > {/* 1. Header: Month / Year Navigation */} -
- - - + {MONTHS[curJ[1] - 1]} {toFa(curJ[0])} - -
{/* 2. Day of Week Header & Days Grid */} -
+
{WEEKDAYS.map((w) => ( -
+
{w}
))} @@ -420,7 +418,7 @@ export function JalaliDatePicker({ key={i} type="button" className={cn( - "cursor-pointer rounded py-0.5 text-[12px] hover:bg-slate-bg max-md:py-1.5", + "cursor-pointer rounded-lg py-2 text-[13px] hover:bg-slate-bg transition-colors", cell.isToday && !selectedIso && "bg-blue-bg font-bold text-[#1d4ed8]", cell.isToday && selectedIso && "font-bold text-[#1d4ed8]", cell.iso === selectedIso && "bg-accent text-white hover:bg-accent-dark", @@ -433,44 +431,43 @@ export function JalaliDatePicker({ )}
- {/* 3. Time Inputs Row (Positioned BELOW the calendar grid) — Hour on left, Minute on right */} + {/* 3. Time Inputs Row */} {withTime && ( -
+
e.target.select()} onChange={(e) => handleMinuteChange(e.target.value)} onBlur={handleMinuteBlur} /> - دقیقه + دقیقه
:
e.target.select()} onChange={(e) => handleHourChange(e.target.value)} onBlur={handleHourBlur} /> - ساعت + ساعت
)} - {/* 4. Action Buttons Footer */} -
- -
@@ -478,4 +475,4 @@ export function JalaliDatePicker({ )}
); -} +} \ No newline at end of file diff --git a/pm/frontend/src/components/GanttChart.tsx b/pm/frontend/src/components/GanttChart.tsx index 40b8dfc..82ff1bf 100644 --- a/pm/frontend/src/components/GanttChart.tsx +++ b/pm/frontend/src/components/GanttChart.tsx @@ -6,8 +6,6 @@ import { CalendarDays, CheckCircle2, ChevronDown, - ChevronLeft, - ChevronRight, ChevronUp, Clock, Flame, @@ -17,11 +15,8 @@ import { Pencil, Plus, RefreshCw, - Search, Trash2, X, - ZoomIn, - ZoomOut, } from "lucide-react"; import { toast } from "sonner"; import { @@ -70,7 +65,6 @@ function getPriorityInfo(priority?: string | null) { } } - interface TimelineDay { dateObj: Date; dateKey: string; @@ -81,31 +75,13 @@ interface TimelineDay { isToday: boolean; } -// Zoom thresholds — colWidth pixel ranges determine what granularity to show -// Min: 2px/day → ~1 year visible at once -// Max: 54px/day → very detailed daily view -const ZOOM_MIN = 2; -const ZOOM_MAX = 54; -const ZOOM_DEFAULT = 28; - -// Neutral color palette for task bars — one single grey for every bar, and for -// every status (critical / complete / overdue), so the chart reads as one -// consistent grid regardless of how many tasks are on screen. -const BAR_COLORS: { bg: string; border: string; ring: string; progress: string }[] = [ - { bg: "bg-gradient-to-r from-slate-500 to-slate-600", border: "border-slate-600", ring: "ring-slate-400", progress: "bg-slate-900/35" }, +const ZOOM_PRESETS = [ + { id: "day", label: "روز", width: 34 }, + { id: "week", label: "هفته", width: 18 }, + { id: "month", label: "ماه", width: 8 }, + { id: "year", label: "سال", width: 3 }, ]; -function getBarColor(_index: number) { - return BAR_COLORS[0]; -} - -/** - * یک ردیف وابستگی در کشوی تسک. - * - * عنوان نمایش‌داده‌شده همیشه «فعالیت دیگر» است: برای پیش‌نیاز، تسک مبدأ و - * برای پس‌نیاز، تسک مقصد — نه تسک انتخاب‌شده. اگر عنوان از سرور نیامده باشد - * از فهرست تسک‌ها پر می‌شود تا ردیف هیچ‌وقت بی‌نام نماند. - */ function RelationRow({ title, kindLabel, @@ -124,25 +100,24 @@ function RelationRow({ onDelete?: (relationId: string) => void; }) { return ( -
-
+
+
{kindLabel} - {title} + {title}
-
+
{relation.relation_type} - {relation.lag_days ? ` +${toFa(relation.lag_days)}ر` : ""} + {relation.lag_days ? ` +${toFa(relation.lag_days)} روز` : ""} {canManage && onEdit && ( )} {canManage && onDelete && ( @@ -150,10 +125,9 @@ function RelationRow({ type="button" onClick={() => onDelete(relation.id)} aria-label="حذف وابستگی" - title="حذف وابستگی" - className="flex h-7 w-7 items-center justify-center rounded-lg text-slate-400 hover:text-rose-600 hover:bg-rose-50 transition-colors" + className="flex h-8 w-8 items-center justify-center rounded-lg text-slate-400 hover:text-rose-600 hover:bg-rose-50 active:bg-rose-100 transition-colors" > - + )}
@@ -176,32 +150,11 @@ export function GanttChart({ }: GanttChartProps) { const queryClient = useQueryClient(); - const [colWidth, setColWidth] = useState(ZOOM_DEFAULT); + const [colWidth, setColWidth] = useState(18); const [criticalOnly, setCriticalOnly] = useState(false); const [selectedTaskId, setSelectedTaskId] = useState(null); const [drawerOpen, setDrawerOpen] = useState(false); - const [zoomPanelOpen, setZoomPanelOpen] = useState(false); - const zoomPanelRef = useRef(null); - useEffect(() => { - if (!zoomPanelOpen) return; - const handleClickOutside = (e: MouseEvent) => { - if (zoomPanelRef.current && !zoomPanelRef.current.contains(e.target as Node)) { - setZoomPanelOpen(false); - } - }; - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") setZoomPanelOpen(false); - }; - document.addEventListener("mousedown", handleClickOutside); - document.addEventListener("keydown", handleKeyDown); - return () => { - document.removeEventListener("mousedown", handleClickOutside); - document.removeEventListener("keydown", handleKeyDown); - }; - }, [zoomPanelOpen]); - - // Quick relation add state in drawer const [showAddRelationInline, setShowAddRelationInline] = useState(false); const [newRelDirection, setNewRelDirection] = useState<"predecessor" | "successor">("predecessor"); const [newRelTargetId, setNewRelTargetId] = useState(""); @@ -210,11 +163,10 @@ export function GanttChart({ const [relError, setRelError] = useState(""); const containerRef = useRef(null); - /** ناحیهٔ اسکرول افقی نمودار — برای پرش به امروز و پیمایش Panning */ const scrollRef = useRef(null); const touchDistanceRef = useRef(null); + const hasAutoScrolledToday = useRef(false); - // حالت‌های پیمایش دستی (Panning) برای کاربر دسکتاپ و موبایل const [isPanMode, setIsPanMode] = useState(false); const [isSpacePressed, setIsSpacePressed] = useState(false); const [isPanning, setIsPanning] = useState(false); @@ -241,7 +193,6 @@ export function GanttChart({ const wasPanningRef = useRef(false); const clearWasPanningTimerRef = useRef | null>(null); - // کلید Space برای پیمایش موقت و کلید H برای تغییر وضعیت حالت دست (Pan Mode) useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { const tag = (e.target as HTMLElement)?.tagName?.toLowerCase(); @@ -270,7 +221,6 @@ export function GanttChart({ }; }, []); - // گوش دادن به جابجایی ماوس در سطح صفحه برای درگ روان نمودار (Drag-to-Pan) useEffect(() => { const onMouseMove = (e: MouseEvent) => { if (!mousePanRef.current.isDown) return; @@ -316,21 +266,16 @@ export function GanttChart({ const { tasks, relations, critical_path, project } = data; - // --------------------------------------------------------------- - // FILTER: only show task_type === 'task' (وظیفه اجرایی) in Gantt - // --------------------------------------------------------------- const execTasks = useMemo( () => tasks.filter((t) => !t.task_type || t.task_type === "task"), [tasks] ); - // Selected task object (from execTasks only) const selectedTask = useMemo(() => { if (!selectedTaskId) return null; return execTasks.find((t) => t.id === selectedTaskId) || null; }, [execTasks, selectedTaskId]); - // Sync drawer state when selectedTask changes useEffect(() => { if (selectedTask) { setShowAddRelationInline(false); @@ -338,13 +283,11 @@ export function GanttChart({ } }, [selectedTask]); - // Filter tasks if criticalOnly const displayTasks = useMemo(() => { if (!criticalOnly) return execTasks; return execTasks.filter((t) => t.is_critical || (t.id && critical_path.includes(t.id))); }, [execTasks, criticalOnly, critical_path]); - // Date boundaries const { minDate, totalDays } = useMemo(() => { const dates: Date[] = []; @@ -372,7 +315,6 @@ export function GanttChart({ min = new Date(min.getFullYear(), min.getMonth(), min.getDate()); max = new Date(max.getFullYear(), max.getMonth(), max.getDate()); - // Always extend so the chart spans at least 1 full year, with margins const yearStart = new Date(min.getFullYear(), 0, 1); const yearEnd = new Date(max.getFullYear(), 11, 31); if (yearStart < min) min = yearStart; @@ -385,7 +327,6 @@ export function GanttChart({ return { minDate: min, totalDays: Math.max(days, 365) }; }, [execTasks, project]); - // Build timeline days const timelineDays: TimelineDay[] = useMemo(() => { const result: TimelineDay[] = []; const today = new Date(); @@ -408,7 +349,6 @@ export function GanttChart({ return result; }, [minDate, totalDays]); - // Jalali month groups for header const monthGroups = useMemo(() => { const groups: { label: string; shortLabel: string; count: number }[] = []; let currentKey = ""; @@ -434,7 +374,6 @@ export function GanttChart({ return groups; }, [timelineDays]); - // Jalali year groups for ultra-zoomed-out header const yearGroups = useMemo(() => { const groups: { label: string; count: number }[] = []; let currentYear = 0; @@ -454,10 +393,6 @@ export function GanttChart({ const totalTimelineWidth = totalDays * colWidth; - // Zoom level determines header granularity - // < 6px/day → show years only - // 6-18px/day → show months only - // > 18px/day → show months + individual days const showDays = colWidth >= 18; const showMonths = colWidth >= 6; @@ -478,7 +413,6 @@ export function GanttChart({ const rawEnd = task.planned_end || task.due_date; const startDiff = getDayDiff(rawStart); - // فاصلهٔ روزها (روز پایان حساب نمی‌شود) — با سرور و فرم‌ها یکسان است const durationDays = Math.max( task.duration || task.duration_days || @@ -487,54 +421,85 @@ export function GanttChart({ ); const left = Math.max(startDiff * colWidth, 0); - const width = Math.max(durationDays * colWidth - 4, 16); + const width = Math.max(durationDays * colWidth - 4, 22); return { left, width, startDiff, durationDays }; }, [getDayDiff, colWidth] ); - // One grey for every bar — status no longer changes the colour. - const getBarMeta = (task: GanttTask, index: number) => { + const getBarMeta = (task: GanttTask) => { const isCritical = task.is_critical || (task.id && critical_path.includes(task.id)); const progressVal = task.progress ?? task.percentage_done ?? 0; const isComplete = progressVal >= 100 || task.status === "COMPLETED"; const rawEnd = task.planned_end || task.due_date; const isOverdue = rawEnd && !isComplete && new Date(rawEnd).getTime() < new Date().setHours(0, 0, 0, 0); - const bar = getBarColor(index); if (isCritical) { - return { ...bar, tag: "مسیر بحرانی", tone: "red" as const, icon: Flame }; + return { + bg: "bg-gradient-to-r from-rose-500 via-rose-600 to-amber-600 shadow-rose-200", + border: "border-rose-400/80", + ring: "ring-rose-500", + progress: "bg-black/25", + tag: "مسیر بحرانی", + tone: "red" as const, + icon: Flame, + }; } if (isComplete) { - return { ...bar, tag: "تکمیل شده", tone: "green" as const, icon: CheckCircle2 }; + return { + bg: "bg-gradient-to-r from-emerald-500 to-teal-600 shadow-emerald-200", + border: "border-emerald-400/80", + ring: "ring-emerald-500", + progress: "bg-black/20", + tag: "تکمیل شده", + tone: "green" as const, + icon: CheckCircle2, + }; } if (isOverdue) { - return { ...bar, tag: "تاخیر دارد", tone: "amber" as const, icon: AlertTriangle }; + return { + bg: "bg-gradient-to-r from-amber-500 to-orange-600 shadow-amber-200", + border: "border-amber-400/80", + ring: "ring-amber-500", + progress: "bg-black/25", + tag: "تاخیر دارد", + tone: "amber" as const, + icon: AlertTriangle, + }; + } + if (progressVal > 0) { + return { + bg: "bg-gradient-to-r from-sky-500 via-sky-600 to-indigo-600 shadow-sky-200", + border: "border-sky-400/80", + ring: "ring-sky-500", + progress: "bg-black/25", + tag: "در حال انجام", + tone: "sky" as const, + icon: Clock, + }; } return { - ...bar, - tag: progressVal > 0 ? "در حال انجام" : "برنامه‌ریزی شده", - tone: "sky" as const, - icon: progressVal > 0 ? Clock : Calendar, + bg: "bg-gradient-to-r from-slate-400 to-slate-500 shadow-slate-200", + border: "border-slate-400/80", + ring: "ring-slate-400", + progress: "bg-black/20", + tag: "برنامه‌ریزی شده", + tone: "slate" as const, + icon: Calendar, }; }; - // ------------ Zoom controls ------------ - const handleZoomIn = () => setColWidth((prev) => Math.min(prev + 4, ZOOM_MAX)); - const handleZoomOut = () => setColWidth((prev) => Math.max(prev - 4, ZOOM_MIN)); - const handleWheel = (e: React.WheelEvent) => { if (e.ctrlKey || e.metaKey) { e.preventDefault(); - setColWidth((prev) => Math.min(Math.max(prev + (e.deltaY < 0 ? 3 : -3), ZOOM_MIN), ZOOM_MAX)); + setColWidth((prev) => Math.min(Math.max(prev + (e.deltaY < 0 ? 3 : -3), 2), 54)); } else if (e.shiftKey && e.deltaY && !e.deltaX && scrollRef.current) { scrollRef.current.scrollLeft += e.deltaY; } }; const handleMouseDown = (e: React.MouseEvent) => { - // کلیک چپ یا کلیک با غلتک میانی ماوس if (e.button !== 0 && e.button !== 1) return; const el = scrollRef.current; if (!el) return; @@ -553,14 +518,12 @@ export function GanttChart({ const handleTouchStart = (e: React.TouchEvent) => { if (e.touches.length === 2) { - // زوم دو انگشتی (Pinch to zoom) touchDistanceRef.current = Math.hypot( e.touches[1].clientX - e.touches[0].clientX, e.touches[1].clientY - e.touches[0].clientY ); touchPanRef.current = null; } else if (e.touches.length === 1) { - // پیمایش تک‌انگشتی لمسی const t = e.touches[0]; const el = scrollRef.current; touchDistanceRef.current = null; @@ -581,7 +544,7 @@ export function GanttChart({ ); const delta = dist - touchDistanceRef.current; if (Math.abs(delta) > 6) { - setColWidth((prev) => Math.min(Math.max(prev + (delta > 0 ? 2 : -2), ZOOM_MIN), ZOOM_MAX)); + setColWidth((prev) => Math.min(Math.max(prev + (delta > 0 ? 2 : -2), 2), 54)); touchDistanceRef.current = dist; } } else if (e.touches.length === 1 && touchPanRef.current) { @@ -606,13 +569,6 @@ export function GanttChart({ } }; - const handlePanBy = (offset: number) => { - const el = scrollRef.current; - if (!el) return; - el.scrollBy({ left: offset, behavior: "smooth" }); - }; - - // ------------ Relations: inline create ------------ const availableTargetTasks = allTasks.length > 0 ? allTasks : execTasks; const createRelMutation = useMutation({ @@ -640,13 +596,6 @@ export function GanttChart({ const todayIndex = timelineDays.findIndex((d) => d.isToday); - /** - * وابستگی‌های تسک انتخاب‌شده، یک‌جا: پیش‌نیازها اول و بعد پس‌نیازها. - * - * عنوان هر ردیف «فعالیت دیگر» است — برای پیش‌نیاز تسک مبدأ و برای پس‌نیاز - * تسک مقصد. اگر سرور عنوان را نداده باشد از فهرست تسک‌ها پر می‌شود، پس - * ردیف هیچ‌وقت بی‌نام نمایش داده نمی‌شود. - */ const { myPredecessors, mySuccessors } = useMemo(() => { if (!selectedTask) return { myPredecessors: [], mySuccessors: [] }; const titleOf = (id: string, fallback?: string) => @@ -662,15 +611,6 @@ export function GanttChart({ }; }, [relations, selectedTask, execTasks]); - /** - * اسکرول افقی به امروز، طوری که نشانگر امروز حدود یکسوم از سمت چپ دیده شود - * (نه دقیقاً چسبیده به لبه، تا کارهای بعد از امروز هم دیده شوند). - * - * ظرف نمودار عمداً `dir="ltr"` است (نوار زمان چپ به راست)، پس `scrollLeft` - * مثل LTR معمولی از ۰ به سمت مثبت می‌رود و نیازی به تصحیح منفی نیست. - * مقدار را با offset واقعی همان لحظه حساب می‌کنیم تا اگر جهت عوض شد، - * جابه‌جایی درست بماند. - */ const scrollToToday = () => { const el = scrollRef.current; if (!el || todayIndex === -1) return; @@ -680,187 +620,108 @@ export function GanttChart({ el.scrollTo({ left: Math.min(target, maxScroll), behavior: "smooth" }); }; - // ================================================================ - // RENDER - // ================================================================ + useEffect(() => { + if (!hasAutoScrolledToday.current && todayIndex !== -1 && scrollRef.current) { + hasAutoScrolledToday.current = true; + setTimeout(scrollToToday, 250); + } + }, [todayIndex]); + return (
- - {/* -------- Top Controls Bar -------- */} -
-
-