fix(pm): improve gantt zoom, day labels, long titles and add hr to task modal

This commit is contained in:
Behrad
2026-10-21 13:39:38 +03:30
parent 97bda79de9
commit 4f7e58f7b7
5 changed files with 634 additions and 303 deletions
+508 -262
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
@@ -62,27 +62,16 @@ 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" },
],
};
const PRIORITY_STEPS: PriorityStep[] = [
{ 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();
@@ -92,15 +81,21 @@ function getPriorityWeight(priority?: string | number | null): PriorityWeight {
return 2;
}
function getPriorityColor(priority: string | number | null | undefined, palette: PaletteId) {
function getPriorityColor(priority?: string | number | null) {
const w = getPriorityWeight(priority);
const steps = PRIORITY_PALETTES[palette];
return steps.find((st) => st.weight === w) || steps[1];
return PRIORITY_STEPS.find((st) => st.weight === w) || PRIORITY_STEPS[1];
}
const ROW_HEIGHT = 40;
const BAR_TOP = 11;
const BAR_HEIGHT = 18;
const NAME_COL_WIDTH = 88;
const HEADER_ROW_HEIGHT = 26;
const MIN_COL_WIDTH = 0.3;
const MAX_COL_WIDTH = 54;
const MIN_TIMELINE_DAYS = 730;
const SEASONS = ["بهار", "تابستان", "پاییز", "زمستان"];
interface TimelineDay {
dateObj: Date;
@@ -112,13 +107,6 @@ interface TimelineDay {
isToday: boolean;
}
const ZOOM_PRESETS = [
{ id: "day", label: "روز", width: 34 },
{ id: "week", label: "هفته", width: 18 },
{ id: "month", label: "ماه", width: 8 },
{ id: "year", label: "سال", width: 3 },
];
function RelationRow({
title,
kindLabel,
@@ -162,7 +150,7 @@ function RelationRow({
type="button"
onClick={() => onDelete(relation.id)}
aria-label="حذف وابستگی"
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"
className="flex h-8 w-8 items-center justify-center rounded-lg text-slate-400 hover:text-red-600 hover:bg-red-50 active:bg-red-100 transition-colors"
>
<Trash2 size={13} />
</button>
@@ -189,7 +177,6 @@ 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);
@@ -202,8 +189,10 @@ export function GanttChart({
const containerRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const touchDistanceRef = useRef<number | null>(null);
const hasAutoScrolledToday = useRef<boolean>(false);
const colWidthRef = useRef<number>(colWidth);
colWidthRef.current = colWidth;
const zoomAnchorRef = useRef<{ day: number; offset: number } | null>(null);
const [isSpacePressed, setIsSpacePressed] = useState(false);
const [isPanning, setIsPanning] = useState(false);
@@ -340,7 +329,7 @@ export function GanttChart({
if (dates.length === 0) {
const d = new Date();
return { minDate: d, totalDays: 365 };
return { minDate: d, totalDays: MIN_TIMELINE_DAYS };
}
let min = new Date(Math.min(...dates.map((d) => d.getTime())));
@@ -358,7 +347,7 @@ export function GanttChart({
max = new Date(max.getTime() + 10 * 86400000);
const days = Math.ceil(Math.abs(max.getTime() - min.getTime()) / 86400000) + 1;
return { minDate: min, totalDays: Math.max(days, 365) };
return { minDate: min, totalDays: Math.max(days, MIN_TIMELINE_DAYS) };
}, [execTasks, project]);
const timelineDays: TimelineDay[] = useMemo(() => {
@@ -425,10 +414,72 @@ export function GanttChart({
return groups;
}, [timelineDays]);
const seasonGroups = useMemo(() => {
const groups: { label: string; count: number }[] = [];
let currentKey = "";
let currentCount = 0;
let currentName = "";
for (const day of timelineDays) {
const season = Math.floor((day.jm - 1) / 3);
const key = `${day.jy}-${season}`;
if (key !== currentKey) {
if (currentKey) groups.push({ label: currentName, count: currentCount });
currentKey = key;
currentName = SEASONS[season];
currentCount = 1;
} else {
currentCount++;
}
}
if (currentKey) groups.push({ label: currentName, count: currentCount });
return groups;
}, [timelineDays]);
const halfYearGroups = useMemo(() => {
const groups: { label: string; shortLabel: string; count: number }[] = [];
let currentKey = "";
let currentCount = 0;
let currentLabel = "";
let currentShort = "";
for (const day of timelineDays) {
const half = day.jm <= 6 ? 1 : 2;
const key = `${day.jy}-${half}`;
if (key !== currentKey) {
if (currentKey) groups.push({ label: currentLabel, shortLabel: currentShort, count: currentCount });
currentKey = key;
currentLabel = half === 1 ? "نیم سال اول" : "نیم سال دوم";
currentShort = half === 1 ? "نیم‌سال ۱" : "نیم‌سال ۲";
currentCount = 1;
} else {
currentCount++;
}
}
if (currentKey) groups.push({ label: currentLabel, shortLabel: currentShort, count: currentCount });
return groups;
}, [timelineDays]);
const totalTimelineWidth = totalDays * colWidth;
const showDays = colWidth >= 18;
const showMonths = colWidth >= 6;
// روزها در زوم نزدیک یا متوسط (عرض ستون ۵ به بالا)
const showDays = colWidth >= 5;
const headerRows = showDays ? 3 : 2;
const headerHeight = headerRows * HEADER_ROW_HEIGHT;
// در زوم متوسط/نزدیک ماه، در زوم دور فصل، در زوم خیلی دور نیم سال اول و دوم
const timeHeaderMode: "month" | "season" | "halfYear" =
colWidth >= 2 ? "month" : colWidth >= 0.7 ? "season" : "halfYear";
const viewWidth = scrollRef.current?.clientWidth || 300;
const twoYearWidth = Math.max(MIN_COL_WIDTH, Math.round((viewWidth / 730) * 100) / 100);
const zoomPresets = [
{ id: "day", label: "روز", width: 34 },
{ id: "week", label: "هفته", width: 18 },
{ id: "month", label: "ماه", width: 8 },
{ id: "year", label: "۲ سال", width: twoYearWidth },
];
const activePresetId = zoomPresets.reduce((best, pr) =>
Math.abs(Math.log(colWidth / pr.width)) < Math.abs(Math.log(colWidth / best.width)) ? pr : best
).id;
const getDayDiff = useCallback(
(dateStr: string | null | undefined): number => {
@@ -455,7 +506,7 @@ export function GanttChart({
);
const left = Math.max(startDiff * colWidth, 0);
const width = Math.max(durationDays * colWidth - 4, 22);
const width = Math.max(durationDays * colWidth - Math.min(4, colWidth * 0.5), 6);
return { left, width, startDiff, durationDays };
},
@@ -472,25 +523,116 @@ export function GanttChart({
rawEnd && !isComplete && new Date(rawEnd).getTime() < new Date().setHours(0, 0, 0, 0)
);
return {
color: getPriorityColor(task.priority, palette),
color: getPriorityColor(task.priority),
isCritical,
isOverdue,
};
};
// زوم فقط با نگه داشتن Ctrl و چرخاندن چرخ ماوس؛ چرخ ماوس به‌تنهایی فقط صفحه را بالا و پایین می‌برد
const handlePresetClick = (targetWidth: number) => {
const el = scrollRef.current;
if (!el) {
setColWidth(targetWidth);
return;
}
const centerOffset = el.clientWidth / 2;
const cur = colWidthRef.current;
const centerDay = (el.scrollLeft + centerOffset) / cur;
zoomAnchorRef.current = { day: centerDay, offset: centerOffset };
colWidthRef.current = targetWidth;
setColWidth(targetWidth);
};
// بعد از هر زوم، نقطه‌ای که زیر ماوس (یا بین دو انگشت) بود سر جایش بماند
useLayoutEffect(() => {
const el = scrollRef.current;
const anchor = zoomAnchorRef.current;
if (!el || !anchor) return;
el.scrollLeft = Math.max(0, anchor.day * colWidth - anchor.offset);
zoomAnchorRef.current = null;
}, [colWidth]);
// زوم با چرخ ماوس روی نمودار، و زوم با دو انگشت در موبایل
useEffect(() => {
const el = containerRef.current;
const el = scrollRef.current;
if (!el) return;
const zoomTo = (newWidth: number, clientX: number) => {
const next = Math.min(Math.max(newWidth, MIN_COL_WIDTH), MAX_COL_WIDTH);
const cur = colWidthRef.current;
if (Math.abs(next - cur) < 0.005) return;
const offset = clientX - el.getBoundingClientRect().left;
zoomAnchorRef.current = { day: (el.scrollLeft + offset) / cur, offset };
colWidthRef.current = next;
setColWidth(next);
};
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));
if (e.deltaY === 0) return;
// ضریب زوم پیوسته و نرم بدون پرش ناگهانی
const zoomFactor = Math.min(Math.max(1 - e.deltaY * 0.0015, 0.88), 1.14);
zoomTo(colWidthRef.current * zoomFactor, e.clientX);
};
let pinch: { dist: number; width: number; anchorDay: number } | null = null;
const getDist = (e: TouchEvent) =>
Math.hypot(
e.touches[1].clientX - e.touches[0].clientX,
e.touches[1].clientY - e.touches[0].clientY
);
const onTouchStart = (e: TouchEvent) => {
if (e.touches.length === 2) {
const dist = getDist(e) || 1;
const midX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
const offset = midX - el.getBoundingClientRect().left;
const cur = colWidthRef.current;
const anchorDay = (el.scrollLeft + offset) / cur;
pinch = { dist, width: cur, anchorDay };
}
};
const onTouchMove = (e: TouchEvent) => {
if (pinch && e.touches.length === 2) {
e.preventDefault();
wasPanningRef.current = true;
const currentDist = getDist(e);
const scale = currentDist / pinch.dist;
const next = Math.min(Math.max(pinch.width * scale, MIN_COL_WIDTH), MAX_COL_WIDTH);
if (Math.abs(next - colWidthRef.current) < 0.005) return;
const curMidX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
const curOffset = curMidX - el.getBoundingClientRect().left;
zoomAnchorRef.current = { day: pinch.anchorDay, offset: curOffset };
colWidthRef.current = next;
setColWidth(next);
}
};
const onTouchEnd = (e: TouchEvent) => {
if (pinch && e.touches.length < 2) {
pinch = null;
if (clearWasPanningTimerRef.current) clearTimeout(clearWasPanningTimerRef.current);
clearWasPanningTimerRef.current = setTimeout(() => {
wasPanningRef.current = false;
}, 150);
}
};
el.addEventListener("wheel", onWheel, { passive: false });
return () => el.removeEventListener("wheel", onWheel);
el.addEventListener("touchstart", onTouchStart, { passive: true });
el.addEventListener("touchmove", onTouchMove, { passive: false });
el.addEventListener("touchend", onTouchEnd);
el.addEventListener("touchcancel", onTouchEnd);
return () => {
el.removeEventListener("wheel", onWheel);
el.removeEventListener("touchstart", onTouchStart);
el.removeEventListener("touchmove", onTouchMove);
el.removeEventListener("touchend", onTouchEnd);
el.removeEventListener("touchcancel", onTouchEnd);
};
}, []);
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
@@ -511,37 +653,22 @@ export function GanttChart({
};
const handleTouchStart = (e: React.TouchEvent) => {
if (e.touches.length === 2) {
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) {
if (e.touches.length === 1) {
const t = e.touches[0];
const el = scrollRef.current;
touchDistanceRef.current = null;
touchPanRef.current = {
startX: t.clientX,
startY: t.clientY,
scrollLeft: el?.scrollLeft || 0,
hasMoved: false,
};
} else {
touchPanRef.current = null;
}
};
const handleTouchMove = (e: React.TouchEvent) => {
if (e.touches.length === 2 && touchDistanceRef.current !== null) {
const dist = Math.hypot(
e.touches[1].clientX - e.touches[0].clientX,
e.touches[1].clientY - e.touches[0].clientY
);
const delta = dist - touchDistanceRef.current;
if (Math.abs(delta) > 6) {
setColWidth((prev) => Math.min(Math.max(prev + (delta > 0 ? 2 : -2), 2), 54));
touchDistanceRef.current = dist;
}
} else if (e.touches.length === 1 && touchPanRef.current) {
if (e.touches.length === 1 && touchPanRef.current) {
const t = e.touches[0];
const dx = t.clientX - touchPanRef.current.startX;
const dy = t.clientY - touchPanRef.current.startY;
@@ -553,7 +680,6 @@ export function GanttChart({
};
const handleTouchEnd = () => {
touchDistanceRef.current = null;
if (touchPanRef.current?.hasMoved) {
if (clearWasPanningTimerRef.current) clearTimeout(clearWasPanningTimerRef.current);
clearWasPanningTimerRef.current = setTimeout(() => {
@@ -626,7 +752,7 @@ export function GanttChart({
<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 ${
criticalOnly ? "bg-rose-600 text-white shadow-xs" : "bg-rose-50 text-rose-700 border border-rose-200 hover:bg-rose-100/70"
criticalOnly ? "bg-red-600 text-white shadow-xs" : "bg-red-50 text-red-700 border border-red-200 hover:bg-red-100/70"
}`}>
<input
type="checkbox"
@@ -634,7 +760,7 @@ export function GanttChart({
onChange={(e) => setCriticalOnly(e.target.checked)}
className="sr-only"
/>
<Flame size={13} className={criticalOnly ? "text-white" : "text-rose-600"} />
<Flame size={13} className={criticalOnly ? "text-white" : "text-red-600"} />
<span>مسیر بحرانی ({toFa(critical_path.length)})</span>
</label>
@@ -663,13 +789,13 @@ export function GanttChart({
<div className="flex items-center justify-between gap-1.5 border-t border-slate-100 pt-2">
<div className="flex items-center gap-1 bg-slate-100 p-0.5 rounded-xl w-full sm:w-auto">
{ZOOM_PRESETS.map((preset) => {
const isActive = Math.abs(colWidth - preset.width) < 5;
{zoomPresets.map((preset) => {
const isActive = activePresetId === preset.id;
return (
<button
key={preset.id}
type="button"
onClick={() => setColWidth(preset.width)}
onClick={() => handlePresetClick(preset.width)}
className={`flex-1 sm:flex-none px-3 py-1 rounded-lg text-xs font-bold transition-all ${
isActive
? "bg-white text-sky-700 shadow-xs"
@@ -692,7 +818,7 @@ export function GanttChart({
{/* راهنمای رنگ اولویت‌ها */}
<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) => (
{PRIORITY_STEPS.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"
@@ -705,219 +831,337 @@ export function GanttChart({
<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">
<div
ref={scrollRef}
dir="ltr"
className={`w-full overflow-x-auto select-none [&::-webkit-scrollbar]:hidden ${
isPanning
? "cursor-grabbing"
: isSpacePressed
? "cursor-grab"
: "cursor-default"
}`}
style={{
WebkitOverflowScrolling: "touch",
scrollbarWidth: "none",
touchAction: "pan-x pan-y",
}}
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onTouchCancel={handleTouchEnd}
>
<div style={{ width: `${totalTimelineWidth}px` }} className="relative min-w-full">
<div className="sticky top-0 z-20 border-b border-slate-200 bg-slate-50/98 backdrop-blur-md">
{!showMonths && (
<div className="flex h-8 border-b border-slate-200 text-xs font-bold text-slate-700">
<div dir="ltr" className="flex">
{/* ستون نام وظیفه‌ها */}
<div
className="relative z-20 shrink-0 border-e border-slate-300 bg-white shadow-xs"
style={{ width: `${NAME_COL_WIDTH}px` }}
>
<div
dir="rtl"
className="flex items-end border-b border-slate-200 bg-slate-50 px-1.5 pb-1 text-[11px] font-bold text-slate-500"
style={{ height: `${headerHeight}px` }}
>
وظایف
</div>
{displayTasks.map((t) => {
const meta = getBarMeta(t);
const taskName = t.title || t.subject;
const isSelected = selectedTaskId === t.id;
return (
<div
key={t.id}
dir="rtl"
title={taskName}
onClick={() => {
setSelectedTaskId(t.id || null);
setDrawerOpen(true);
}}
style={{ height: `${ROW_HEIGHT}px` }}
className={`flex cursor-pointer items-center gap-1 border-b border-slate-200 px-1.5 transition-colors ${
isSelected ? "bg-sky-50" : "hover:bg-slate-50"
}`}
>
<span className="flex shrink-0 flex-col items-center gap-0.5">
<span
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: meta.color.solid }}
/>
{meta.isCritical && <Flame size={10} className="text-red-600" />}
{meta.isOverdue && <AlertTriangle size={10} className="text-amber-600" />}
</span>
<span className="min-w-0 flex-1 line-clamp-2 text-[11px] font-bold leading-[14px] text-slate-700">
{taskName}
</span>
</div>
);
})}
</div>
{/* خود نمودار */}
<div
ref={scrollRef}
dir="ltr"
className={`min-w-0 flex-1 overflow-x-auto select-none [&::-webkit-scrollbar]:hidden ${
isPanning
? "cursor-grabbing"
: isSpacePressed
? "cursor-grab"
: "cursor-default"
}`}
style={{
WebkitOverflowScrolling: "touch",
scrollbarWidth: "none",
touchAction: "pan-x pan-y",
}}
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onTouchCancel={handleTouchEnd}
>
<div style={{ width: `${totalTimelineWidth}px` }} className="relative min-w-full">
{/* سرستون زمان: سال، بعد ماه یا فصل، بعد روز */}
<div className="bg-slate-50" style={{ height: `${headerHeight}px` }}>
<div
className="flex border-b border-slate-200 text-[11px] font-bold text-slate-700"
style={{ height: `${HEADER_ROW_HEIGHT}px` }}
>
{yearGroups.map((yg, i) => (
<div
key={i}
dir="rtl"
style={{ width: `${yg.count * colWidth}px` }}
className="border-e border-slate-200 flex items-center justify-center truncate bg-slate-100/70 px-1 text-[11px]"
className={`flex min-w-0 items-center justify-center truncate border-e border-slate-300 px-1 overflow-hidden select-none ${
i % 2 === 0 ? "bg-slate-100" : "bg-slate-50"
}`}
>
{yg.label}
</div>
))}
</div>
)}
{showMonths && (
<div className="flex h-7.5 border-b border-slate-200 text-xs font-bold text-slate-700">
{monthGroups.map((mg, i) => (
<div
key={i}
style={{ width: `${mg.count * colWidth}px` }}
className="border-e border-slate-200 flex items-center justify-center truncate bg-slate-100/60 px-2 text-[11px] text-slate-600"
>
{mg.count * colWidth > 40 ? mg.label : mg.count * colWidth > 20 ? mg.shortLabel : ""}
</div>
))}
<div
className="flex border-b border-slate-200 text-[10px] font-bold text-slate-600"
style={{ height: `${HEADER_ROW_HEIGHT}px` }}
>
{timeHeaderMode === "month"
? monthGroups.map((mg, i) => (
<div
key={i}
dir="rtl"
style={{ width: `${mg.count * colWidth}px` }}
className={`flex min-w-0 items-center justify-center truncate border-e border-slate-200 px-0.5 overflow-hidden select-none ${
i % 2 === 0 ? "bg-white" : "bg-slate-50"
}`}
title={mg.label}
>
{mg.shortLabel}
</div>
))
: timeHeaderMode === "season"
? seasonGroups.map((sg, i) => {
const cellWidth = sg.count * colWidth;
return (
<div
key={i}
dir="rtl"
style={{ width: `${cellWidth}px` }}
className={`flex min-w-0 items-center justify-center border-e border-slate-200 px-0.5 overflow-hidden select-none ${
i % 2 === 0 ? "bg-white" : "bg-slate-50"
}`}
title={sg.label}
>
<span className="truncate whitespace-nowrap text-[10px] font-bold text-slate-600">
{cellWidth >= 35 ? sg.label : ""}
</span>
</div>
);
})
: halfYearGroups.map((hg, i) => {
const cellWidth = hg.count * colWidth;
let text = "";
if (cellWidth >= 75) {
text = hg.label; // "نیم سال اول" / "نیم سال دوم"
} else if (cellWidth >= 48) {
text = hg.label.replace("نیم سال", "نیمه"); // "نیمه اول" / "نیمه دوم"
} else if (cellWidth >= 28) {
text = hg.shortLabel; // "نیم‌سال ۱" / "نیم‌سال ۲"
} else if (cellWidth >= 16) {
text = hg.shortLabel.replace("نیم‌سال ", "ن"); // "ن۱" / "ن۲"
} else {
text = ""; // باریک‌تر از آن است که متن تکه‌تکه نشان داده شود
}
return (
<div
key={i}
dir="rtl"
style={{ width: `${cellWidth}px` }}
className={`flex min-w-0 items-center justify-center border-e border-slate-200 px-0.5 overflow-hidden select-none ${
i % 2 === 0 ? "bg-white" : "bg-slate-50"
}`}
title={hg.label}
>
<span className="truncate whitespace-nowrap text-[10px] font-bold text-slate-700">
{text}
</span>
</div>
);
})}
</div>
)}
{showDays && (
<div className="flex h-6.5 text-[10px] text-slate-400 font-mono">
{timelineDays.map((day, i) => (
<div
key={i}
style={{ width: `${colWidth}px` }}
className={`border-e border-slate-100 flex items-center justify-center shrink-0 ${
day.isFriday ? "bg-rose-50/70 text-rose-600 font-bold" :
day.isToday ? "bg-sky-100 text-sky-900 font-extrabold" : ""
}`}
>
{toFa(day.jd)}
</div>
))}
</div>
)}
</div>
<div className="relative divide-y divide-slate-200 py-1">
<div className="absolute inset-0 flex pointer-events-none">
{timelineDays.map((day, i) => (
{showDays && (
<div
key={i}
style={{ width: `${colWidth}px` }}
className={`border-e border-slate-100/60 shrink-0 h-full ${
day.isFriday ? "bg-rose-500/5" : day.isToday ? "bg-sky-500/5" : ""
}`}
/>
))}
className="flex border-b border-slate-200 font-mono text-[10px] text-slate-400 overflow-hidden"
style={{ height: `${HEADER_ROW_HEIGHT}px` }}
>
{timelineDays.map((day, i) => {
let showLabel = false;
let label = "";
if (colWidth >= 18) {
showLabel = true;
label = toFa(day.jd);
} else if (colWidth >= 5) {
// در زوم میانی فقط مضارب ۵ نمایش داده می‌شوند تا هیچ تداخلی با روزهای مجاور رخ ندهد
if (day.jd % 5 === 0) {
showLabel = true;
label = toFa(day.jd);
}
}
return (
<div
key={i}
style={{ width: `${colWidth}px` }}
className={`relative flex shrink-0 items-center justify-center border-e border-slate-100 ${
day.isFriday
? "bg-slate-200/70 font-bold text-slate-600"
: day.isToday
? "bg-sky-100 font-extrabold text-sky-900"
: ""
}`}
>
{showLabel && (
<span
className={`pointer-events-none select-none ${
colWidth < 18
? "absolute left-1/2 -translate-x-1/2 whitespace-nowrap text-[9px] font-bold"
: ""
} ${
day.isToday
? "text-sky-900 font-black"
: day.isFriday
? "text-slate-700 font-bold"
: "text-slate-500"
}`}
>
{label}
</span>
)}
</div>
);
})}
</div>
)}
</div>
{todayIndex !== -1 && (
<div
style={{ left: `${todayIndex * colWidth + colWidth / 2}px` }}
className="absolute top-0 bottom-0 z-10 w-0.5 bg-sky-500 shadow-[0_0_8px_rgba(14,165,233,0.7)] pointer-events-none"
>
<span className="absolute -top-1 -translate-x-1/2 rounded-full bg-sky-600 text-[9px] font-black text-white px-1.5 py-0.5 shadow-md">
امروز
</span>
</div>
)}
{displayTasks.length === 0 && (
<div className="h-32 flex items-center justify-center text-sm text-slate-400 font-medium">
هیچ وظیفه‌ای برای نمایش در این فیلتر وجود ندارد
</div>
)}
{displayTasks.map((t) => {
const coords = getTaskBarCoords(t);
const isSelected = selectedTaskId === t.id;
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}
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 className="relative">
{/* خطوط پس‌زمینه: روزها در زوم نزدیک و متوسط، ماه‌ها در زوم دور */}
<div className="pointer-events-none absolute inset-0 flex">
{colWidth >= 5
? timelineDays.map((day, i) => (
<div
style={{
width: `${progressVal}%`,
backgroundColor: meta.color.solid,
}}
className="absolute inset-y-0 left-0 pointer-events-none transition-all duration-300"
key={i}
style={{ width: `${colWidth}px` }}
className={`h-full shrink-0 border-e border-slate-100/60 ${
day.isFriday ? "bg-slate-500/5" : day.isToday ? "bg-sky-500/5" : ""
}`}
/>
)}
</div>
))
: monthGroups.map((mg, i) => (
<div
key={i}
style={{ width: `${mg.count * colWidth}px` }}
className="h-full shrink-0 border-e border-slate-200/70"
/>
))}
</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>
{todayIndex !== -1 && (
<div
style={{ left: `${todayIndex * colWidth + colWidth / 2}px` }}
className="pointer-events-none absolute bottom-0 top-0 z-10 w-0.5 bg-sky-500 shadow-[0_0_8px_rgba(14,165,233,0.7)]"
>
<span className="absolute top-0 -translate-x-1/2 rounded-full bg-sky-600 px-1.5 py-0.5 text-[9px] font-black text-white shadow-md">
امروز
</span>
</div>
);
})}
)}
{displayTasks.length === 0 && (
<div className="flex h-32 items-center justify-center text-sm font-medium text-slate-400">
هیچ وظیفه‌ای برای نمایش در این فیلتر وجود ندارد
</div>
)}
{displayTasks.map((t) => {
const coords = getTaskBarCoords(t);
const isSelected = selectedTaskId === t.id;
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}
style={{ height: `${ROW_HEIGHT}px` }}
className={`relative border-b border-slate-200 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 active:scale-95 transition-[box-shadow,transform] ${cursorClass}`}
title={`${taskName} (${toFa(progressVal)}٪)`}
>
{progressVal > 0 && (
<div
style={{
width: `${progressVal}%`,
backgroundColor: meta.color.solid,
}}
className="pointer-events-none absolute inset-y-0 left-0"
/>
)}
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
</Card>
{selectedTask && (
<div className={`overflow-hidden transition-all duration-300 ease-in-out ${drawerOpen ? "max-h-[85vh] opacity-100" : "max-h-12 opacity-95"}`}>
<div className={`overflow-hidden transition-all duration-300 ease-in-out ${drawerOpen ? "max-h-[85vh] opacity-100" : "max-h-48 opacity-95"}`}>
<Card className="border-sky-300 shadow-xl bg-white rounded-t-3xl rounded-b-2xl">
<div
onClick={() => setDrawerOpen(!drawerOpen)}
@@ -926,18 +1170,20 @@ export function GanttChart({
<span className="h-1.5 w-12 rounded-full bg-slate-300" />
</div>
<div className="flex items-center justify-between gap-2 px-4 py-2.5 bg-gradient-to-r from-slate-50 to-sky-50/60 border-b border-slate-100">
<div className="flex items-center gap-2 min-w-0">
<div className="flex items-start justify-between gap-2 px-4 py-2.5 bg-gradient-to-r from-slate-50 to-sky-50/60 border-b border-slate-100">
<div className="flex items-start gap-2 min-w-0 flex-1">
<button
type="button"
onClick={() => setDrawerOpen(!drawerOpen)}
className="flex h-8 w-8 items-center justify-center rounded-lg text-slate-500 hover:bg-slate-200 active:bg-slate-300 transition-colors"
className="flex h-8 w-8 items-center justify-center rounded-lg text-slate-500 hover:bg-slate-200 active:bg-slate-300 transition-colors shrink-0 mt-0.5"
>
{drawerOpen ? <ChevronDown size={18} /> : <ChevronUp size={18} />}
</button>
<div className="min-w-0">
<h3 className="text-sm font-black text-slate-900 truncate">{selectedTask.title || selectedTask.subject}</h3>
<div className="flex items-center gap-1.5 mt-0.5">
<div className="min-w-0 flex-1">
<h3 className="text-sm font-black text-slate-900 break-words [overflow-wrap:anywhere] whitespace-normal leading-snug">
{selectedTask.title || selectedTask.subject}
</h3>
<div className="flex items-center gap-1.5 mt-1 flex-wrap">
{selectedTask.is_critical && <Chip tone="red"><Flame size={10} className="me-1 inline" />بحرانی</Chip>}
<Chip tone="sky">
{(selectedTask.progress ?? selectedTask.percentage_done ?? 0) >= 100
@@ -949,7 +1195,7 @@ export function GanttChart({
</div>
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<div className="flex items-center gap-1.5 shrink-0 mt-0.5">
{canManageTasks && onEditTask && (
<button
type="button"
@@ -1003,7 +1249,7 @@ export function GanttChart({
<span className="text-xs font-bold text-sky-900">تعریف وابستگی جدید:</span>
<button type="button" onClick={() => setShowAddRelationInline(false)} className="text-slate-400 hover:text-slate-600"><X size={15} /></button>
</div>
{relError && <div className="text-xs text-rose-600 bg-rose-50 p-2 rounded-lg border border-rose-200">{relError}</div>}
{relError && <div className="text-xs text-red-600 bg-red-50 p-2 rounded-lg border border-red-200">{relError}</div>}
<div className="grid grid-cols-1 sm:grid-cols-4 gap-2 text-xs">
<div>
<label className="block text-[11px] font-semibold text-slate-600 mb-1">نوع</label>
+110 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Link2, Plus, Trash2 } from "lucide-react";
import { AlertTriangle, Link2, Plus, Trash2, Users } from "lucide-react";
import { toast } from "sonner";
import {
createTaskRelation,
@@ -24,6 +24,110 @@ import { isoToApiDate } from "../lib/jalali";
import { reconcileDates } from "../lib/dates";
import { formatDate } from "../lib/format";
export function HumanResourceInputs({
humanResources,
setHumanResources,
}: {
humanResources: HumanResourceItem[];
setHumanResources: React.Dispatch<React.SetStateAction<HumanResourceItem[]>>;
}) {
const handleAdd = () => {
setHumanResources([...humanResources, { role: "", count: 1 }]);
};
const handleRemove = (index: number) => {
setHumanResources(humanResources.filter((_, i) => i !== index));
};
const handleChange = (index: number, field: "role" | "count", val: string | number) => {
const next = [...humanResources];
if (field === "role") {
next[index] = { ...next[index], role: String(val) };
} else {
next[index] = { ...next[index], count: Number(val) || 0 };
}
setHumanResources(next);
};
return (
<div className="rounded-xl border border-sky-200 bg-sky-50/50 p-3 space-y-2.5">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-sky-950 flex items-center gap-1.5">
<Users size={14} className="text-sky-600" />
<span>نیروی انسانی مورد نیاز</span>
</span>
<button
type="button"
onClick={handleAdd}
className="text-[10px] font-semibold text-sky-700 hover:text-sky-900 bg-white border border-sky-200 rounded-lg px-2.5 py-1 flex items-center gap-1 shadow-2xs hover:bg-sky-50 transition-colors"
>
<Plus size={12} />
<span>افزودن ردیف نیرو</span>
</button>
</div>
{humanResources.length === 0 ? (
<p className="text-center py-2 text-xs text-slate-400">
ردیفی برای نیروی انسانی تعریف نشده است.
</p>
) : (
<div className="space-y-2">
{humanResources.map((item, idx) => (
<div
key={idx}
className="rounded-lg border border-slate-200/80 bg-white p-2.5 text-xs shadow-2xs flex items-center gap-2"
>
<div className="flex-1 min-w-0">
<label className="text-[10px] font-semibold text-slate-500 block mb-1">
نقش / رده شغلی
</label>
<input
value={item.role}
onChange={(e) => handleChange(idx, "role", e.target.value)}
placeholder="مثلاً: آرماتوربند، کارگر ساده..."
className="h-8 w-full rounded-lg border border-slate-200 px-2.5 text-xs text-slate-800 focus:border-sky-500 focus:outline-none"
/>
</div>
<div className="w-24 shrink-0">
<label className="text-[10px] font-semibold text-slate-500 block mb-1">
تعداد درخواستی
</label>
<div className="relative flex items-center">
<input
type="number"
min={1}
value={item.count ? item.count : ""}
onChange={(e) =>
handleChange(idx, "count", e.target.value ? parseInt(e.target.value) : 0)
}
placeholder="نفر"
className="h-8 w-full rounded-lg border border-slate-200 px-2 pe-7 text-xs text-center font-bold text-slate-800 focus:border-sky-500 focus:outline-none"
/>
<span className="absolute left-2 text-[10px] text-slate-400 pointer-events-none">
نفر
</span>
</div>
</div>
<div className="shrink-0 pt-4">
<button
type="button"
onClick={() => handleRemove(idx)}
className="p-1.5 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
title="حذف ردیف"
>
<Trash2 size={15} />
</button>
</div>
</div>
))}
</div>
)}
</div>
);
}
interface TaskEditModalProps {
open: boolean;
task: Task | TaskDetail | null;
@@ -558,6 +662,11 @@ export function TaskEditModal({
setMaterials={setMaterials}
/>
<HumanResourceInputs
humanResources={humanResources}
setHumanResources={setHumanResources}
/>
<div className="rounded-xl border border-slate-200 bg-slate-50/60 p-3 space-y-3">
<div className="flex items-center justify-between border-b border-slate-200 pb-2">
<span className="text-xs font-bold text-slate-700 flex items-center gap-1.5">
+6 -1
View File
@@ -53,7 +53,7 @@ import {
} from "../components/ui";
import { JalaliDatePicker } from "../components/JalaliDatePicker";
import { GanttChart } from "../components/GanttChart";
import { TaskEditModal } from "../components/TaskEditModal";
import { TaskEditModal, HumanResourceInputs } from "../components/TaskEditModal";
import { formatDate, isOverdue, qcStatusMeta } from "../lib/format";
import { isoToApiDate } from "../lib/jalali";
import { reconcileDates } from "../lib/dates";
@@ -621,6 +621,11 @@ function TaskCreateModal({
setMaterials={setMaterials}
/>
<HumanResourceInputs
humanResources={humanResources}
setHumanResources={setHumanResources}
/>
<div className="rounded-xl border border-slate-200 bg-slate-50/60 p-3 space-y-3">
<div className="flex items-center justify-between border-b border-slate-200 pb-2">
<span className="text-xs font-bold text-slate-700 flex items-center gap-1.5">