From 4f7e58f7b7cd94d9e56d3a0c032c517dc3f068fe Mon Sep 17 00:00:00 2001 From: Behrad Date: Wed, 21 Oct 2026 13:39:38 +0330 Subject: [PATCH] fix(pm): improve gantt zoom, day labels, long titles and add hr to task modal --- docker-compose.yml | 2 +- pm/docker-compose.yml | 47 +- pm/frontend/src/components/GanttChart.tsx | 770 ++++++++++++------- pm/frontend/src/components/TaskEditModal.tsx | 111 ++- pm/frontend/src/pages/ProjectDetailPage.tsx | 7 +- 5 files changed, 634 insertions(+), 303 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index db99d37..46bbf0d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -302,7 +302,7 @@ services: labels: - traefik.enable=true - - traefik.http.routers.frontend.rule=Host(`${APP_HOST:-localhost}`) + - "traefik.http.routers.frontend.rule=Host(`${APP_HOST:-localhost}`) || Host(`192.168.100.8`)" - traefik.http.routers.frontend.entrypoints=web - traefik.http.services.frontend.loadbalancer.server.port=80 diff --git a/pm/docker-compose.yml b/pm/docker-compose.yml index ab54168..1b784b5 100644 --- a/pm/docker-compose.yml +++ b/pm/docker-compose.yml @@ -1,54 +1,29 @@ -# PM — Construction Project Management Platform services: - pm-postgres: - image: postgres:18 - container_name: pm-postgres - environment: - POSTGRES_DB: pm - POSTGRES_USER: pm - POSTGRES_PASSWORD: ${PM_DB_PASSWORD:-pm_secret_2026} - ports: - - "5434:5432" - volumes: - - pm_postgres_data:/var/lib/postgresql - - ./sql/init.sql:/docker-entrypoint-initdb.d/01-init.sql:ro - healthcheck: - test: ["CMD-SHELL", "pg_isready -U pm -d pm"] - interval: 5s - timeout: 5s - retries: 10 - restart: unless-stopped - pm-api: build: context: ./backend dockerfile: Dockerfile container_name: pm-api environment: - PM_DATABASE_URL: postgresql+asyncpg://pm:${PM_DB_PASSWORD:-pm_secret_2026}@pm-postgres:5432/pm + PM_DATABASE_URL: postgresql+asyncpg://postgres:postgres@procurement-postgres:5432/procurement PM_SESSION_SECRET: ${PM_SESSION_SECRET:-12278fc281bd0e22950e76364e1a18cea61fbc6a24604fdd79690a817ec36ac8} PM_ADMIN_USER: ${PM_ADMIN_USER:-admin} PM_ADMIN_PASSWORD: ${PM_ADMIN_PASSWORD:-pm123456} PM_FILES_DIR: /data/files PM_DEV_ROLE_SWITCH: ${PM_DEV_ROLE_SWITCH:-false} - # ERP integration (read-only service account) PM_ERP_BASE_URL: http://procurement-api:8000 PM_ERP_CASDOOR_ENDPOINT: http://casdoor:8000 PM_ERP_CASDOOR_CLIENT_ID: ${PM_ERP_CASDOOR_CLIENT_ID:-inventory-app-client} PM_ERP_CASDOOR_CLIENT_SECRET: ${PM_ERP_CASDOOR_CLIENT_SECRET:-inventory-app-secret-2026} PM_ERP_SERVICE_USERNAME: ${PM_ERP_SERVICE_USERNAME:-pm-service} PM_ERP_SERVICE_PASSWORD: ${PM_ERP_SERVICE_PASSWORD:-pm-service-2026} - # Casdoor SSO — همان نشست ERP؛ پیش‌فرض لوکال، origin کسدور لوکال است PM_CASDOOR_ISSUER: ${PM_CASDOOR_ISSUER:-http://localhost:8080} volumes: - pm_files:/data/files - ./backend/app:/srv/pm/app - depends_on: - pm-postgres: - condition: service_healthy networks: - default - - traefik_network + - erp_network restart: unless-stopped pm-frontend: @@ -60,29 +35,25 @@ services: - "8083:80" depends_on: - pm-api - # پنل «مدیریت پروژه» روی همان دامنه ERP زیر مسیر /pm سرو می‌شود — - # Traefikِ استک ERP این کانتینر را با قاعده PathPrefix کشف می‌کند labels: - - traefik.enable=true - - traefik.http.routers.pm.rule=PathPrefix(`/pm`) - - traefik.http.routers.pm.entrypoints=web - # اولویت بالاتر از روتر Host(`localhost`) فرانت ERP — وگرنه /pm/api - # ممکن است به SPA فرانت ERP برسد (POST → 405) - - traefik.http.routers.pm.priority=100 - - traefik.http.services.pm.loadbalancer.server.port=80 + - "traefik.enable=true" + - "traefik.http.routers.pm.rule=PathPrefix(`/pm`)" + - "traefik.http.routers.pm.entrypoints=web" + - "traefik.http.routers.pm.priority=100" + - "traefik.http.services.pm.loadbalancer.server.port=80" networks: - default - traefik_network restart: unless-stopped volumes: - pm_postgres_data: pm_files: networks: default: name: pm_default - erp_default: + erp_network: + name: all-together_default external: true traefik_network: external: true \ No newline at end of file diff --git a/pm/frontend/src/components/GanttChart.tsx b/pm/frontend/src/components/GanttChart.tsx index 5d28594..3f01255 100644 --- a/pm/frontend/src/components/GanttChart.tsx +++ b/pm/frontend/src/components/GanttChart.tsx @@ -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 = { - 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" > @@ -189,7 +177,6 @@ export function GanttChart({ const [colWidth, setColWidth] = useState(18); const [criticalOnly, setCriticalOnly] = useState(false); - const [palette, setPalette] = useState("pink"); const [selectedTaskId, setSelectedTaskId] = useState(null); const [drawerOpen, setDrawerOpen] = useState(false); @@ -202,8 +189,10 @@ export function GanttChart({ const containerRef = useRef(null); const scrollRef = useRef(null); - const touchDistanceRef = useRef(null); const hasAutoScrolledToday = useRef(false); + const colWidthRef = useRef(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) => { @@ -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({
@@ -663,13 +789,13 @@ export function GanttChart({
- {ZOOM_PRESETS.map((preset) => { - const isActive = Math.abs(colWidth - preset.width) < 5; + {zoomPresets.map((preset) => { + const isActive = activePresetId === preset.id; return ( - ))} -
-
-
-
- {!showMonths && ( -
+
+ {/* ستون نام وظیفه‌ها */} +
+
+ وظایف +
+ {displayTasks.map((t) => { + const meta = getBarMeta(t); + const taskName = t.title || t.subject; + const isSelected = selectedTaskId === t.id; + return ( +
{ + 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" + }`} + > + + + {meta.isCritical && } + {meta.isOverdue && } + + + {taskName} + +
+ ); + })} +
+ + {/* خود نمودار */} +
+
+ {/* سرستون زمان: سال، بعد ماه یا فصل، بعد روز */} +
+
{yearGroups.map((yg, i) => (
{yg.label}
))}
- )} - {showMonths && ( -
- {monthGroups.map((mg, i) => ( -
- {mg.count * colWidth > 40 ? mg.label : mg.count * colWidth > 20 ? mg.shortLabel : ""} -
- ))} +
+ {timeHeaderMode === "month" + ? monthGroups.map((mg, i) => ( +
+ {mg.shortLabel} +
+ )) + : timeHeaderMode === "season" + ? seasonGroups.map((sg, i) => { + const cellWidth = sg.count * colWidth; + return ( +
+ + {cellWidth >= 35 ? sg.label : ""} + +
+ ); + }) + : 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 ( +
+ + {text} + +
+ ); + })}
- )} - {showDays && ( -
- {timelineDays.map((day, i) => ( -
- {toFa(day.jd)} -
- ))} -
- )} -
- -
-
- {timelineDays.map((day, i) => ( + {showDays && (
- ))} + 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 ( +
+ {showLabel && ( + + {label} + + )} +
+ ); + })} +
+ )}
- {todayIndex !== -1 && ( -
- - امروز - -
- )} - - {displayTasks.length === 0 && ( -
- هیچ وظیفه‌ای برای نمایش در این فیلتر وجود ندارد -
- )} - - {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 ( -
- {/* نوار: رنگ روشن = کل مدت، رنگ پررنگ = درصد پیشرفت */} -
- {progressVal > 0 && ( +
+ {/* خطوط پس‌زمینه: روزها در زوم نزدیک و متوسط، ماه‌ها در زوم دور */} +
+ {colWidth >= 5 + ? timelineDays.map((day, i) => (
- )} -
+ )) + : monthGroups.map((mg, i) => ( +
+ ))} +
- {/* نام وظیفه کنار نوار */} -
- - {taskName} - - {toFa(progressVal)}٪ - - {meta.isCritical && } - {meta.isOverdue && } -
+ {todayIndex !== -1 && ( +
+ + امروز +
- ); - })} + )} + + {displayTasks.length === 0 && ( +
+ هیچ وظیفه‌ای برای نمایش در این فیلتر وجود ندارد +
+ )} + + {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 ( +
+ {/* نوار: رنگ روشن = کل مدت، رنگ پررنگ = درصد پیشرفت */} +
+ {progressVal > 0 && ( +
+ )} +
+
+ ); + })} +
{selectedTask && ( -
+
setDrawerOpen(!drawerOpen)} @@ -926,18 +1170,20 @@ export function GanttChart({
-
-
+
+
-
-

{selectedTask.title || selectedTask.subject}

-
+
+

+ {selectedTask.title || selectedTask.subject} +

+
{selectedTask.is_critical && بحرانی} {(selectedTask.progress ?? selectedTask.percentage_done ?? 0) >= 100 @@ -949,7 +1195,7 @@ export function GanttChart({
-
+
{canManageTasks && onEditTask && (
- {relError &&
{relError}
} + {relError &&
{relError}
}
diff --git a/pm/frontend/src/components/TaskEditModal.tsx b/pm/frontend/src/components/TaskEditModal.tsx index 9636c3e..06fca77 100644 --- a/pm/frontend/src/components/TaskEditModal.tsx +++ b/pm/frontend/src/components/TaskEditModal.tsx @@ -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>; +}) { + 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 ( +
+
+ + + نیروی انسانی مورد نیاز + + +
+ + {humanResources.length === 0 ? ( +

+ ردیفی برای نیروی انسانی تعریف نشده است. +

+ ) : ( +
+ {humanResources.map((item, idx) => ( +
+
+ + 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" + /> +
+ +
+ +
+ + 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" + /> + + نفر + +
+
+ +
+ +
+
+ ))} +
+ )} +
+ ); +} + interface TaskEditModalProps { open: boolean; task: Task | TaskDetail | null; @@ -558,6 +662,11 @@ export function TaskEditModal({ setMaterials={setMaterials} /> + +
diff --git a/pm/frontend/src/pages/ProjectDetailPage.tsx b/pm/frontend/src/pages/ProjectDetailPage.tsx index d6dc98f..8674592 100644 --- a/pm/frontend/src/pages/ProjectDetailPage.tsx +++ b/pm/frontend/src/pages/ProjectDetailPage.tsx @@ -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} /> + +