improve gantt chart 2

This commit is contained in:
Behrad
2026-10-15 15:23:18 +03:30
parent 67fd0222c7
commit 97bda79de9
+149 -100
View File
@@ -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<PaletteId, PriorityStep[]> = {
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<number>(18);
const [criticalOnly, setCriticalOnly] = useState(false);
const [palette, setPalette] = useState<PaletteId>("pink");
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(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) {
const isOverdue = Boolean(
rawEnd && !isComplete && new Date(rawEnd).getTime() < new Date().setHours(0, 0, 0, 0)
);
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,
};
}
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) => {
// زوم فقط با نگه داشتن 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<HTMLDivElement>) => {
if (e.button !== 0 && e.button !== 1) return;
@@ -621,7 +622,7 @@ export function GanttChart({
}, [todayIndex]);
return (
<div className="space-y-3" ref={containerRef} onWheel={handleWheel}>
<div className="space-y-3" ref={containerRef}>
<div className="flex flex-col gap-2 rounded-2xl bg-white p-2.5 border border-slate-200/90 shadow-xs">
<div className="flex items-center justify-between gap-2 flex-wrap">
<label className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-bold cursor-pointer select-none transition-all active:scale-95 ${
@@ -687,6 +688,41 @@ export function GanttChart({
</span>
</div>
</div>
{/* راهنمای رنگ اولویت‌ها */}
<div className="flex items-center gap-2 flex-wrap border-t border-slate-100 pt-2">
<span className="text-[11px] font-bold text-slate-500">اولویت:</span>
{PRIORITY_PALETTES[palette].map((step) => (
<span
key={step.weight}
className="inline-flex items-center gap-1 rounded-lg bg-slate-50 border border-slate-200/80 px-2 py-0.5 text-[11px] font-bold text-slate-700"
>
<span
className="inline-block h-2.5 w-2.5 rounded-sm"
style={{ backgroundColor: step.solid }}
/>
{toFa(step.weight)}
<span className="font-medium text-slate-400">{step.label}</span>
</span>
))}
<div className="ms-auto flex items-center gap-1 bg-slate-100 p-0.5 rounded-lg">
{([
{ id: "pink", label: "صورتی" },
{ id: "blue", label: "آبی" },
] as { id: PaletteId; label: string }[]).map((opt) => (
<button
key={opt.id}
type="button"
onClick={() => setPalette(opt.id)}
className={`px-2.5 py-0.5 rounded-md text-[11px] font-bold transition-all ${
palette === opt.id ? "bg-white text-slate-800 shadow-xs" : "text-slate-500"
}`}
>
{opt.label}
</button>
))}
</div>
</div>
</div>
<Card className="relative overflow-hidden border-slate-200/90 shadow-sm bg-white p-0 rounded-2xl">
@@ -759,7 +795,7 @@ export function GanttChart({
)}
</div>
<div className="relative divide-y divide-slate-100/80 py-1.5">
<div className="relative divide-y divide-slate-200 py-1">
<div className="absolute inset-0 flex pointer-events-none">
{timelineDays.map((day, i) => (
<div
@@ -792,22 +828,11 @@ export function GanttChart({
{displayTasks.map((t) => {
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;
return (
<div
key={t.id}
className={`h-13 relative flex items-center transition-colors ${
isSelected ? "bg-sky-50/50" : "hover:bg-slate-50/60"
}`}
>
{coords && (
<>
<div
style={{ left: `${coords.left}px`, width: `${coords.width}px` }}
onClick={(e) => {
const openTask = (e: React.MouseEvent) => {
if (
isSpacePressed ||
wasPanningRef.current ||
@@ -819,46 +844,70 @@ export function GanttChart({
}
setSelectedTaskId(t.id || null);
setDrawerOpen(true);
}}
className={`absolute h-8 rounded-xl border shadow-xs flex items-center overflow-hidden transition-all active:scale-95 ${barMeta.bg} ${barMeta.border} ${
isPanning
};
const cursorClass = isPanning
? "cursor-grabbing"
: isSpacePressed
? "cursor-grab"
: "cursor-pointer"
} ${isSelected ? `ring-2 ring-offset-2 ${barMeta.ring}` : ""}`}
title={`${t.title || t.subject} (${toFa(progressVal)}٪)`}
: "cursor-pointer";
return (
<div
key={t.id}
style={{ height: `${ROW_HEIGHT}px` }}
className={`relative transition-colors ${
isSelected ? "bg-sky-50/50" : "hover:bg-slate-50/60"
}`}
>
{/* نوار: رنگ روشن = کل مدت، رنگ پررنگ = درصد پیشرفت */}
<div
style={{
left: `${coords.left}px`,
width: `${coords.width}px`,
top: `${BAR_TOP}px`,
height: `${BAR_HEIGHT}px`,
backgroundColor: meta.color.tint,
border: `1px solid ${meta.color.solid}`,
boxShadow: isSelected
? `0 0 0 2px #ffffff, 0 0 0 4px ${meta.color.solid}`
: undefined,
}}
onClick={openTask}
className={`absolute overflow-hidden rounded-lg transition-all active:scale-95 ${cursorClass}`}
title={`${taskName} (${toFa(progressVal)}٪)`}
>
{progressVal > 0 && (
<div
style={{ width: `${Math.min(progressVal, 100)}%` }}
className={`absolute inset-y-0 left-0 ${barMeta.progress} rounded-s-xl pointer-events-none transition-all duration-300`}
style={{
width: `${progressVal}%`,
backgroundColor: meta.color.solid,
}}
className="absolute inset-y-0 left-0 pointer-events-none transition-all duration-300"
/>
)}
{isWideEnough && (
<div className="relative z-10 flex w-full items-center justify-between px-2.5 text-xs font-bold text-white drop-shadow-xs pointer-events-none">
<span className="truncate max-w-[80%]">{t.title || t.subject}</span>
<span className="text-[11px] opacity-95 tabular-nums">{toFa(progressVal)}٪</span>
</div>
)}
</div>
{!isWideEnough && (
{/* نام وظیفه کنار نوار */}
<div
className="absolute flex items-center gap-1 pointer-events-none z-10"
style={{ left: `${coords.left + coords.width + 6}px` }}
dir="rtl"
onClick={openTask}
style={{
left: `${coords.left + coords.width + 8}px`,
}}
className={`absolute top-0 bottom-0 z-10 flex items-center gap-1 whitespace-nowrap text-[11px] font-bold leading-tight text-slate-700 ${cursorClass}`}
>
<span className="text-xs font-bold text-slate-800 whitespace-nowrap leading-tight bg-white/95 px-2 py-0.5 rounded-lg border border-slate-200/60 shadow-2xs">
{t.title || t.subject}
{progressVal > 0 && (
<span className="ms-1.5 text-slate-500 font-semibold">{toFa(progressVal)}٪</span>
)}
<span
className="inline-block h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: meta.color.solid }}
/>
<span>{taskName}</span>
<span className="font-semibold text-slate-400 tabular-nums">
{toFa(progressVal)}٪
</span>
{meta.isCritical && <Flame size={11} className="text-rose-600" />}
{meta.isOverdue && <AlertTriangle size={11} className="text-amber-600" />}
</div>
)}
</>
)}
</div>
);
})}