From 97bda79de95f363e2ff616786ec6f0330e4433fa Mon Sep 17 00:00:00 2001 From: Behrad Date: Thu, 15 Oct 2026 15:23:18 +0330 Subject: [PATCH] improve gantt chart 2 --- pm/frontend/src/components/GanttChart.tsx | 291 +++++++++++++--------- 1 file changed, 170 insertions(+), 121 deletions(-) diff --git a/pm/frontend/src/components/GanttChart.tsx b/pm/frontend/src/components/GanttChart.tsx index 24d1143..5d28594 100644 --- a/pm/frontend/src/components/GanttChart.tsx +++ b/pm/frontend/src/components/GanttChart.tsx @@ -2,9 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useMutation, useQueryClient } from "@tanstack/react-query"; import { AlertTriangle, - Calendar, CalendarDays, - CheckCircle2, ChevronDown, ChevronUp, Clock, @@ -64,6 +62,46 @@ function getPriorityInfo(priority?: string | null) { } } +// ───── رنگ اولویت‌ها: از صورتی (کم‌اهمیت) تا زرشکی (بسیار مهم) ───── +type PriorityWeight = 1 | 2 | 5 | 13; + +type PaletteId = "pink" | "blue"; +type PriorityStep = { weight: PriorityWeight; label: string; solid: string; tint: string }; + +// دو پالت برای مقایسه: صورتی تا زرشکی، و آبی کم‌رنگ تا سرمه‌ای +const PRIORITY_PALETTES: Record = { + pink: [ + { weight: 1, label: "پایین", solid: "#F9A8D4", tint: "#F9A8D455" }, + { weight: 2, label: "عادی", solid: "#EC4899", tint: "#EC489944" }, + { weight: 5, label: "بالا", solid: "#BE185D", tint: "#BE185D40" }, + { weight: 13, label: "فوری", solid: "#881337", tint: "#88133740" }, + ], + blue: [ + { weight: 1, label: "پایین", solid: "#93C5FD", tint: "#93C5FD55" }, + { weight: 2, label: "عادی", solid: "#3B82F6", tint: "#3B82F644" }, + { weight: 5, label: "بالا", solid: "#1E40AF", tint: "#1E40AF40" }, + { weight: 13, label: "فوری", solid: "#0B1F4B", tint: "#0B1F4B40" }, + ], +}; + +function getPriorityWeight(priority?: string | number | null): PriorityWeight { + const p = String(priority ?? "").trim().toLowerCase(); + if (p === "urgent" || p === "13") return 13; + if (p === "high" || p === "5") return 5; + if (p === "low" || p === "1") return 1; + return 2; +} + +function getPriorityColor(priority: string | number | null | undefined, palette: PaletteId) { + const w = getPriorityWeight(priority); + const steps = PRIORITY_PALETTES[palette]; + return steps.find((st) => st.weight === w) || steps[1]; +} + +const ROW_HEIGHT = 40; +const BAR_TOP = 11; +const BAR_HEIGHT = 18; + interface TimelineDay { dateObj: Date; dateKey: string; @@ -151,6 +189,7 @@ export function GanttChart({ const [colWidth, setColWidth] = useState(18); const [criticalOnly, setCriticalOnly] = useState(false); + const [palette, setPalette] = useState("pink"); const [selectedTaskId, setSelectedTaskId] = useState(null); const [drawerOpen, setDrawerOpen] = useState(false); @@ -423,74 +462,36 @@ export function GanttChart({ [getDayDiff, colWidth] ); + // رنگ نوار فقط از روی اولویت تعیین می‌شود؛ بحرانی/تاخیر با آیکون کوچک زیر نوار نشان داده می‌شود const getBarMeta = (task: GanttTask) => { - const isCritical = task.is_critical || (task.id && critical_path.includes(task.id)); + const isCritical = Boolean(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); - - if (isCritical) { - 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 { - 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 { - 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, - }; - } + const isOverdue = Boolean( + rawEnd && !isComplete && new Date(rawEnd).getTime() < new Date().setHours(0, 0, 0, 0) + ); return { - 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, + color: getPriorityColor(task.priority, palette), + isCritical, + isOverdue, }; }; - const handleWheel = (e: React.WheelEvent) => { - e.preventDefault(); - if (e.deltaY !== 0) { - setColWidth((prev) => Math.min(Math.max(prev + (e.deltaY < 0 ? 3 : -3), 2), 54)); - } - }; + // زوم فقط با نگه داشتن Ctrl و چرخاندن چرخ ماوس؛ چرخ ماوس به‌تنهایی فقط صفحه را بالا و پایین می‌برد + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const onWheel = (e: WheelEvent) => { + if (!(e.ctrlKey || e.metaKey)) return; + e.preventDefault(); + if (e.deltaY !== 0) { + setColWidth((prev) => Math.min(Math.max(prev + (e.deltaY < 0 ? 3 : -3), 2), 54)); + } + }; + el.addEventListener("wheel", onWheel, { passive: false }); + return () => el.removeEventListener("wheel", onWheel); + }, []); const handleMouseDown = (e: React.MouseEvent) => { if (e.button !== 0 && e.button !== 1) return; @@ -621,7 +622,7 @@ export function GanttChart({ }, [todayIndex]); return ( -
+
+ + {/* راهنمای رنگ اولویت‌ها */} +
+ اولویت: + {PRIORITY_PALETTES[palette].map((step) => ( + + + {toFa(step.weight)} + {step.label} + + ))} +
+ {([ + { id: "pink", label: "صورتی" }, + { id: "blue", label: "آبی" }, + ] as { id: PaletteId; label: string }[]).map((opt) => ( + + ))} +
+
@@ -759,7 +795,7 @@ export function GanttChart({ )}
-
+
{timelineDays.map((day, i) => (
{ const coords = getTaskBarCoords(t); const isSelected = selectedTaskId === t.id; - const barMeta = getBarMeta(t); - const progressVal = t.progress ?? t.percentage_done ?? 0; - const isWideEnough = coords && coords.width > 95; + const meta = getBarMeta(t); + const progressVal = Math.min(Math.max(t.progress ?? t.percentage_done ?? 0, 0), 100); + const taskName = t.title || t.subject; + + const openTask = (e: React.MouseEvent) => { + if ( + isSpacePressed || + wasPanningRef.current || + mousePanRef.current.hasMoved || + touchPanRef.current?.hasMoved + ) { + e.stopPropagation(); + return; + } + setSelectedTaskId(t.id || null); + setDrawerOpen(true); + }; + + const cursorClass = isPanning + ? "cursor-grabbing" + : isSpacePressed + ? "cursor-grab" + : "cursor-pointer"; return (
- {coords && ( - <> + {/* نوار: رنگ روشن = کل مدت، رنگ پررنگ = درصد پیشرفت */} +
+ {progressVal > 0 && (
{ - if ( - isSpacePressed || - wasPanningRef.current || - mousePanRef.current.hasMoved || - touchPanRef.current?.hasMoved - ) { - e.stopPropagation(); - return; - } - setSelectedTaskId(t.id || null); - setDrawerOpen(true); + style={{ + width: `${progressVal}%`, + backgroundColor: meta.color.solid, }} - className={`absolute h-8 rounded-xl border shadow-xs flex items-center overflow-hidden transition-all active:scale-95 ${barMeta.bg} ${barMeta.border} ${ - isPanning - ? "cursor-grabbing" - : isSpacePressed - ? "cursor-grab" - : "cursor-pointer" - } ${isSelected ? `ring-2 ring-offset-2 ${barMeta.ring}` : ""}`} - title={`${t.title || t.subject} (${toFa(progressVal)}٪)`} - > - {progressVal > 0 && ( -
- )} + className="absolute inset-y-0 left-0 pointer-events-none transition-all duration-300" + /> + )} +
- {isWideEnough && ( -
- {t.title || t.subject} - {toFa(progressVal)}٪ -
- )} -
- - {!isWideEnough && ( -
- - {t.title || t.subject} - {progressVal > 0 && ( - {toFa(progressVal)}٪ - )} - -
- )} - - )} + {/* نام وظیفه کنار نوار */} +
+ + {taskName} + + {toFa(progressVal)}٪ + + {meta.isCritical && } + {meta.isOverdue && } +
); })}