fix(pm): improve gantt zoom, day labels, long titles and add hr to task modal
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user