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 {
|
||||
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) {
|
||||
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<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,73 +828,86 @@ 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;
|
||||
|
||||
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 (
|
||||
<div
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
{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
|
||||
style={{ left: `${coords.left}px`, width: `${coords.width}px` }}
|
||||
onClick={(e) => {
|
||||
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 && (
|
||||
<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`}
|
||||
/>
|
||||
)}
|
||||
className="absolute inset-y-0 left-0 pointer-events-none transition-all duration-300"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{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` }}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* نام وظیفه کنار نوار */}
|
||||
<div
|
||||
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="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>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user