improve gantt chart 2
This commit is contained in:
@@ -2,9 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Calendar,
|
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
CheckCircle2,
|
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
Clock,
|
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 {
|
interface TimelineDay {
|
||||||
dateObj: Date;
|
dateObj: Date;
|
||||||
dateKey: string;
|
dateKey: string;
|
||||||
@@ -151,6 +189,7 @@ export function GanttChart({
|
|||||||
|
|
||||||
const [colWidth, setColWidth] = useState<number>(18);
|
const [colWidth, setColWidth] = useState<number>(18);
|
||||||
const [criticalOnly, setCriticalOnly] = useState(false);
|
const [criticalOnly, setCriticalOnly] = useState(false);
|
||||||
|
const [palette, setPalette] = useState<PaletteId>("pink");
|
||||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
|
||||||
@@ -423,74 +462,36 @@ export function GanttChart({
|
|||||||
[getDayDiff, colWidth]
|
[getDayDiff, colWidth]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// رنگ نوار فقط از روی اولویت تعیین میشود؛ بحرانی/تاخیر با آیکون کوچک زیر نوار نشان داده میشود
|
||||||
const getBarMeta = (task: GanttTask) => {
|
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 progressVal = task.progress ?? task.percentage_done ?? 0;
|
||||||
const isComplete = progressVal >= 100 || task.status === "COMPLETED";
|
const isComplete = progressVal >= 100 || task.status === "COMPLETED";
|
||||||
const rawEnd = task.planned_end || task.due_date;
|
const rawEnd = task.planned_end || task.due_date;
|
||||||
const isOverdue = rawEnd && !isComplete && new Date(rawEnd).getTime() < new Date().setHours(0, 0, 0, 0);
|
const isOverdue = Boolean(
|
||||||
|
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
bg: "bg-gradient-to-r from-slate-400 to-slate-500 shadow-slate-200",
|
color: getPriorityColor(task.priority, palette),
|
||||||
border: "border-slate-400/80",
|
isCritical,
|
||||||
ring: "ring-slate-400",
|
isOverdue,
|
||||||
progress: "bg-black/20",
|
|
||||||
tag: "برنامهریزی شده",
|
|
||||||
tone: "slate" as const,
|
|
||||||
icon: Calendar,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleWheel = (e: React.WheelEvent) => {
|
// زوم فقط با نگه داشتن Ctrl و چرخاندن چرخ ماوس؛ چرخ ماوس بهتنهایی فقط صفحه را بالا و پایین میبرد
|
||||||
e.preventDefault();
|
useEffect(() => {
|
||||||
if (e.deltaY !== 0) {
|
const el = containerRef.current;
|
||||||
setColWidth((prev) => Math.min(Math.max(prev + (e.deltaY < 0 ? 3 : -3), 2), 54));
|
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>) => {
|
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
if (e.button !== 0 && e.button !== 1) return;
|
if (e.button !== 0 && e.button !== 1) return;
|
||||||
@@ -621,7 +622,7 @@ export function GanttChart({
|
|||||||
}, [todayIndex]);
|
}, [todayIndex]);
|
||||||
|
|
||||||
return (
|
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 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">
|
<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 ${
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<Card className="relative overflow-hidden border-slate-200/90 shadow-sm bg-white p-0 rounded-2xl">
|
<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>
|
||||||
|
|
||||||
<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">
|
<div className="absolute inset-0 flex pointer-events-none">
|
||||||
{timelineDays.map((day, i) => (
|
{timelineDays.map((day, i) => (
|
||||||
<div
|
<div
|
||||||
@@ -792,73 +828,86 @@ export function GanttChart({
|
|||||||
{displayTasks.map((t) => {
|
{displayTasks.map((t) => {
|
||||||
const coords = getTaskBarCoords(t);
|
const coords = getTaskBarCoords(t);
|
||||||
const isSelected = selectedTaskId === t.id;
|
const isSelected = selectedTaskId === t.id;
|
||||||
const barMeta = getBarMeta(t);
|
const meta = getBarMeta(t);
|
||||||
const progressVal = t.progress ?? t.percentage_done ?? 0;
|
const progressVal = Math.min(Math.max(t.progress ?? t.percentage_done ?? 0, 0), 100);
|
||||||
const isWideEnough = coords && coords.width > 95;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={t.id}
|
key={t.id}
|
||||||
className={`h-13 relative flex items-center transition-colors ${
|
style={{ height: `${ROW_HEIGHT}px` }}
|
||||||
|
className={`relative transition-colors ${
|
||||||
isSelected ? "bg-sky-50/50" : "hover:bg-slate-50/60"
|
isSelected ? "bg-sky-50/50" : "hover:bg-slate-50/60"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{coords && (
|
{/* نوار: رنگ روشن = کل مدت، رنگ پررنگ = درصد پیشرفت */}
|
||||||
<>
|
<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
|
<div
|
||||||
style={{ left: `${coords.left}px`, width: `${coords.width}px` }}
|
style={{
|
||||||
onClick={(e) => {
|
width: `${progressVal}%`,
|
||||||
if (
|
backgroundColor: meta.color.solid,
|
||||||
isSpacePressed ||
|
|
||||||
wasPanningRef.current ||
|
|
||||||
mousePanRef.current.hasMoved ||
|
|
||||||
touchPanRef.current?.hasMoved
|
|
||||||
) {
|
|
||||||
e.stopPropagation();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
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} ${
|
className="absolute inset-y-0 left-0 pointer-events-none transition-all duration-300"
|
||||||
isPanning
|
/>
|
||||||
? "cursor-grabbing"
|
)}
|
||||||
: isSpacePressed
|
</div>
|
||||||
? "cursor-grab"
|
|
||||||
: "cursor-pointer"
|
|
||||||
} ${isSelected ? `ring-2 ring-offset-2 ${barMeta.ring}` : ""}`}
|
|
||||||
title={`${t.title || t.subject} (${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`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{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">
|
<div
|
||||||
<span className="truncate max-w-[80%]">{t.title || t.subject}</span>
|
dir="rtl"
|
||||||
<span className="text-[11px] opacity-95 tabular-nums">{toFa(progressVal)}٪</span>
|
onClick={openTask}
|
||||||
</div>
|
style={{
|
||||||
)}
|
left: `${coords.left + coords.width + 8}px`,
|
||||||
</div>
|
}}
|
||||||
|
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}`}
|
||||||
{!isWideEnough && (
|
>
|
||||||
<div
|
<span
|
||||||
className="absolute flex items-center gap-1 pointer-events-none z-10"
|
className="inline-block h-2 w-2 shrink-0 rounded-full"
|
||||||
style={{ left: `${coords.left + coords.width + 6}px` }}
|
style={{ backgroundColor: meta.color.solid }}
|
||||||
>
|
/>
|
||||||
<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">
|
<span>{taskName}</span>
|
||||||
{t.title || t.subject}
|
<span className="font-semibold text-slate-400 tabular-nums">
|
||||||
{progressVal > 0 && (
|
{toFa(progressVal)}٪
|
||||||
<span className="ms-1.5 text-slate-500 font-semibold">{toFa(progressVal)}٪</span>
|
</span>
|
||||||
)}
|
{meta.isCritical && <Flame size={11} className="text-rose-600" />}
|
||||||
</span>
|
{meta.isOverdue && <AlertTriangle size={11} className="text-amber-600" />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
Reference in New Issue
Block a user