fix(pm): improve gantt zoom, day labels, long titles and add hr to task modal
This commit is contained in:
+1
-1
@@ -302,7 +302,7 @@ services:
|
|||||||
|
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- 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.routers.frontend.entrypoints=web
|
||||||
- traefik.http.services.frontend.loadbalancer.server.port=80
|
- traefik.http.services.frontend.loadbalancer.server.port=80
|
||||||
|
|
||||||
|
|||||||
+9
-38
@@ -1,54 +1,29 @@
|
|||||||
# PM — Construction Project Management Platform
|
|
||||||
services:
|
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:
|
pm-api:
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: pm-api
|
container_name: pm-api
|
||||||
environment:
|
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_SESSION_SECRET: ${PM_SESSION_SECRET:-12278fc281bd0e22950e76364e1a18cea61fbc6a24604fdd79690a817ec36ac8}
|
||||||
PM_ADMIN_USER: ${PM_ADMIN_USER:-admin}
|
PM_ADMIN_USER: ${PM_ADMIN_USER:-admin}
|
||||||
PM_ADMIN_PASSWORD: ${PM_ADMIN_PASSWORD:-pm123456}
|
PM_ADMIN_PASSWORD: ${PM_ADMIN_PASSWORD:-pm123456}
|
||||||
PM_FILES_DIR: /data/files
|
PM_FILES_DIR: /data/files
|
||||||
PM_DEV_ROLE_SWITCH: ${PM_DEV_ROLE_SWITCH:-false}
|
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_BASE_URL: http://procurement-api:8000
|
||||||
PM_ERP_CASDOOR_ENDPOINT: http://casdoor: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_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_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_USERNAME: ${PM_ERP_SERVICE_USERNAME:-pm-service}
|
||||||
PM_ERP_SERVICE_PASSWORD: ${PM_ERP_SERVICE_PASSWORD:-pm-service-2026}
|
PM_ERP_SERVICE_PASSWORD: ${PM_ERP_SERVICE_PASSWORD:-pm-service-2026}
|
||||||
# Casdoor SSO — همان نشست ERP؛ پیشفرض لوکال، origin کسدور لوکال است
|
|
||||||
PM_CASDOOR_ISSUER: ${PM_CASDOOR_ISSUER:-http://localhost:8080}
|
PM_CASDOOR_ISSUER: ${PM_CASDOOR_ISSUER:-http://localhost:8080}
|
||||||
volumes:
|
volumes:
|
||||||
- pm_files:/data/files
|
- pm_files:/data/files
|
||||||
- ./backend/app:/srv/pm/app
|
- ./backend/app:/srv/pm/app
|
||||||
depends_on:
|
|
||||||
pm-postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
networks:
|
networks:
|
||||||
- default
|
- default
|
||||||
- traefik_network
|
- erp_network
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
pm-frontend:
|
pm-frontend:
|
||||||
@@ -60,29 +35,25 @@ services:
|
|||||||
- "8083:80"
|
- "8083:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
- pm-api
|
- pm-api
|
||||||
# پنل «مدیریت پروژه» روی همان دامنه ERP زیر مسیر /pm سرو میشود —
|
|
||||||
# Traefikِ استک ERP این کانتینر را با قاعده PathPrefix کشف میکند
|
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- "traefik.enable=true"
|
||||||
- traefik.http.routers.pm.rule=PathPrefix(`/pm`)
|
- "traefik.http.routers.pm.rule=PathPrefix(`/pm`)"
|
||||||
- traefik.http.routers.pm.entrypoints=web
|
- "traefik.http.routers.pm.entrypoints=web"
|
||||||
# اولویت بالاتر از روتر Host(`localhost`) فرانت ERP — وگرنه /pm/api
|
- "traefik.http.routers.pm.priority=100"
|
||||||
# ممکن است به SPA فرانت ERP برسد (POST → 405)
|
- "traefik.http.services.pm.loadbalancer.server.port=80"
|
||||||
- traefik.http.routers.pm.priority=100
|
|
||||||
- traefik.http.services.pm.loadbalancer.server.port=80
|
|
||||||
networks:
|
networks:
|
||||||
- default
|
- default
|
||||||
- traefik_network
|
- traefik_network
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pm_postgres_data:
|
|
||||||
pm_files:
|
pm_files:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
default:
|
default:
|
||||||
name: pm_default
|
name: pm_default
|
||||||
erp_default:
|
erp_network:
|
||||||
|
name: all-together_default
|
||||||
external: true
|
external: true
|
||||||
traefik_network:
|
traefik_network:
|
||||||
external: true
|
external: true
|
||||||
@@ -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 { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
@@ -62,27 +62,16 @@ function getPriorityInfo(priority?: string | null) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ───── رنگ اولویتها: از صورتی (کماهمیت) تا زرشکی (بسیار مهم) ─────
|
// ───── رنگ اولویتها: از آبی کمرنگ (کماهمیت) تا سرمهای (بسیار مهم) ─────
|
||||||
type PriorityWeight = 1 | 2 | 5 | 13;
|
type PriorityWeight = 1 | 2 | 5 | 13;
|
||||||
|
|
||||||
type PaletteId = "pink" | "blue";
|
|
||||||
type PriorityStep = { weight: PriorityWeight; label: string; solid: string; tint: string };
|
type PriorityStep = { weight: PriorityWeight; label: string; solid: string; tint: string };
|
||||||
|
|
||||||
// دو پالت برای مقایسه: صورتی تا زرشکی، و آبی کمرنگ تا سرمهای
|
const PRIORITY_STEPS: PriorityStep[] = [
|
||||||
const PRIORITY_PALETTES: Record<PaletteId, PriorityStep[]> = {
|
{ weight: 1, label: "پایین", solid: "#93C5FD", tint: "#93C5FD55" },
|
||||||
pink: [
|
{ weight: 2, label: "عادی", solid: "#3B82F6", tint: "#3B82F644" },
|
||||||
{ weight: 1, label: "پایین", solid: "#F9A8D4", tint: "#F9A8D455" },
|
{ weight: 5, label: "بالا", solid: "#1E40AF", tint: "#1E40AF40" },
|
||||||
{ weight: 2, label: "عادی", solid: "#EC4899", tint: "#EC489944" },
|
{ weight: 13, label: "فوری", solid: "#0B1F4B", tint: "#0B1F4B40" },
|
||||||
{ weight: 5, label: "بالا", solid: "#BE185D", tint: "#BE185D40" },
|
];
|
||||||
{ weight: 13, label: "فوری", solid: "#881337", tint: "#88133740" },
|
|
||||||
],
|
|
||||||
blue: [
|
|
||||||
{ weight: 1, label: "پایین", solid: "#93C5FD", tint: "#93C5FD55" },
|
|
||||||
{ weight: 2, label: "عادی", solid: "#3B82F6", tint: "#3B82F644" },
|
|
||||||
{ weight: 5, label: "بالا", solid: "#1E40AF", tint: "#1E40AF40" },
|
|
||||||
{ weight: 13, label: "فوری", solid: "#0B1F4B", tint: "#0B1F4B40" },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
function getPriorityWeight(priority?: string | number | null): PriorityWeight {
|
function getPriorityWeight(priority?: string | number | null): PriorityWeight {
|
||||||
const p = String(priority ?? "").trim().toLowerCase();
|
const p = String(priority ?? "").trim().toLowerCase();
|
||||||
@@ -92,15 +81,21 @@ function getPriorityWeight(priority?: string | number | null): PriorityWeight {
|
|||||||
return 2;
|
return 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPriorityColor(priority: string | number | null | undefined, palette: PaletteId) {
|
function getPriorityColor(priority?: string | number | null) {
|
||||||
const w = getPriorityWeight(priority);
|
const w = getPriorityWeight(priority);
|
||||||
const steps = PRIORITY_PALETTES[palette];
|
return PRIORITY_STEPS.find((st) => st.weight === w) || PRIORITY_STEPS[1];
|
||||||
return steps.find((st) => st.weight === w) || steps[1];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ROW_HEIGHT = 40;
|
const ROW_HEIGHT = 40;
|
||||||
const BAR_TOP = 11;
|
const BAR_TOP = 11;
|
||||||
const BAR_HEIGHT = 18;
|
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 {
|
interface TimelineDay {
|
||||||
dateObj: Date;
|
dateObj: Date;
|
||||||
@@ -112,13 +107,6 @@ interface TimelineDay {
|
|||||||
isToday: boolean;
|
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({
|
function RelationRow({
|
||||||
title,
|
title,
|
||||||
kindLabel,
|
kindLabel,
|
||||||
@@ -162,7 +150,7 @@ function RelationRow({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onDelete(relation.id)}
|
onClick={() => onDelete(relation.id)}
|
||||||
aria-label="حذف وابستگی"
|
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} />
|
<Trash2 size={13} />
|
||||||
</button>
|
</button>
|
||||||
@@ -189,7 +177,6 @@ export function GanttChart({
|
|||||||
|
|
||||||
const [colWidth, setColWidth] = useState<number>(18);
|
const [colWidth, setColWidth] = useState<number>(18);
|
||||||
const [criticalOnly, setCriticalOnly] = useState(false);
|
const [criticalOnly, setCriticalOnly] = useState(false);
|
||||||
const [palette, setPalette] = useState<PaletteId>("pink");
|
|
||||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
|
||||||
@@ -202,8 +189,10 @@ export function GanttChart({
|
|||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const touchDistanceRef = useRef<number | null>(null);
|
|
||||||
const hasAutoScrolledToday = useRef<boolean>(false);
|
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 [isSpacePressed, setIsSpacePressed] = useState(false);
|
||||||
const [isPanning, setIsPanning] = useState(false);
|
const [isPanning, setIsPanning] = useState(false);
|
||||||
@@ -340,7 +329,7 @@ export function GanttChart({
|
|||||||
|
|
||||||
if (dates.length === 0) {
|
if (dates.length === 0) {
|
||||||
const d = new Date();
|
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())));
|
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);
|
max = new Date(max.getTime() + 10 * 86400000);
|
||||||
|
|
||||||
const days = Math.ceil(Math.abs(max.getTime() - min.getTime()) / 86400000) + 1;
|
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]);
|
}, [execTasks, project]);
|
||||||
|
|
||||||
const timelineDays: TimelineDay[] = useMemo(() => {
|
const timelineDays: TimelineDay[] = useMemo(() => {
|
||||||
@@ -425,10 +414,72 @@ export function GanttChart({
|
|||||||
return groups;
|
return groups;
|
||||||
}, [timelineDays]);
|
}, [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 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(
|
const getDayDiff = useCallback(
|
||||||
(dateStr: string | null | undefined): number => {
|
(dateStr: string | null | undefined): number => {
|
||||||
@@ -455,7 +506,7 @@ export function GanttChart({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const left = Math.max(startDiff * colWidth, 0);
|
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 };
|
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)
|
rawEnd && !isComplete && new Date(rawEnd).getTime() < new Date().setHours(0, 0, 0, 0)
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
color: getPriorityColor(task.priority, palette),
|
color: getPriorityColor(task.priority),
|
||||||
isCritical,
|
isCritical,
|
||||||
isOverdue,
|
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(() => {
|
useEffect(() => {
|
||||||
const el = containerRef.current;
|
const el = scrollRef.current;
|
||||||
if (!el) return;
|
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) => {
|
const onWheel = (e: WheelEvent) => {
|
||||||
if (!(e.ctrlKey || e.metaKey)) return;
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (e.deltaY !== 0) {
|
if (e.deltaY === 0) return;
|
||||||
setColWidth((prev) => Math.min(Math.max(prev + (e.deltaY < 0 ? 3 : -3), 2), 54));
|
// ضریب زوم پیوسته و نرم بدون پرش ناگهانی
|
||||||
|
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 });
|
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>) => {
|
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
@@ -511,37 +653,22 @@ export function GanttChart({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleTouchStart = (e: React.TouchEvent) => {
|
const handleTouchStart = (e: React.TouchEvent) => {
|
||||||
if (e.touches.length === 2) {
|
if (e.touches.length === 1) {
|
||||||
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) {
|
|
||||||
const t = e.touches[0];
|
const t = e.touches[0];
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
touchDistanceRef.current = null;
|
|
||||||
touchPanRef.current = {
|
touchPanRef.current = {
|
||||||
startX: t.clientX,
|
startX: t.clientX,
|
||||||
startY: t.clientY,
|
startY: t.clientY,
|
||||||
scrollLeft: el?.scrollLeft || 0,
|
scrollLeft: el?.scrollLeft || 0,
|
||||||
hasMoved: false,
|
hasMoved: false,
|
||||||
};
|
};
|
||||||
|
} else {
|
||||||
|
touchPanRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTouchMove = (e: React.TouchEvent) => {
|
const handleTouchMove = (e: React.TouchEvent) => {
|
||||||
if (e.touches.length === 2 && touchDistanceRef.current !== null) {
|
if (e.touches.length === 1 && touchPanRef.current) {
|
||||||
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) {
|
|
||||||
const t = e.touches[0];
|
const t = e.touches[0];
|
||||||
const dx = t.clientX - touchPanRef.current.startX;
|
const dx = t.clientX - touchPanRef.current.startX;
|
||||||
const dy = t.clientY - touchPanRef.current.startY;
|
const dy = t.clientY - touchPanRef.current.startY;
|
||||||
@@ -553,7 +680,6 @@ export function GanttChart({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleTouchEnd = () => {
|
const handleTouchEnd = () => {
|
||||||
touchDistanceRef.current = null;
|
|
||||||
if (touchPanRef.current?.hasMoved) {
|
if (touchPanRef.current?.hasMoved) {
|
||||||
if (clearWasPanningTimerRef.current) clearTimeout(clearWasPanningTimerRef.current);
|
if (clearWasPanningTimerRef.current) clearTimeout(clearWasPanningTimerRef.current);
|
||||||
clearWasPanningTimerRef.current = setTimeout(() => {
|
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 flex-col gap-2 rounded-2xl bg-white p-2.5 border border-slate-200/90 shadow-xs">
|
||||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||||
<label className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-bold cursor-pointer select-none transition-all active:scale-95 ${
|
<label className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-bold cursor-pointer select-none transition-all active:scale-95 ${
|
||||||
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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -634,7 +760,7 @@ export function GanttChart({
|
|||||||
onChange={(e) => setCriticalOnly(e.target.checked)}
|
onChange={(e) => setCriticalOnly(e.target.checked)}
|
||||||
className="sr-only"
|
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>
|
<span>مسیر بحرانی ({toFa(critical_path.length)})</span>
|
||||||
</label>
|
</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 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">
|
<div className="flex items-center gap-1 bg-slate-100 p-0.5 rounded-xl w-full sm:w-auto">
|
||||||
{ZOOM_PRESETS.map((preset) => {
|
{zoomPresets.map((preset) => {
|
||||||
const isActive = Math.abs(colWidth - preset.width) < 5;
|
const isActive = activePresetId === preset.id;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={preset.id}
|
key={preset.id}
|
||||||
type="button"
|
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 ${
|
className={`flex-1 sm:flex-none px-3 py-1 rounded-lg text-xs font-bold transition-all ${
|
||||||
isActive
|
isActive
|
||||||
? "bg-white text-sky-700 shadow-xs"
|
? "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">
|
<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>
|
<span className="text-[11px] font-bold text-slate-500">اولویت:</span>
|
||||||
{PRIORITY_PALETTES[palette].map((step) => (
|
{PRIORITY_STEPS.map((step) => (
|
||||||
<span
|
<span
|
||||||
key={step.weight}
|
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"
|
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 className="font-medium text-slate-400">{step.label}</span>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
<div className="ms-auto flex items-center gap-1 bg-slate-100 p-0.5 rounded-lg">
|
|
||||||
{([
|
|
||||||
{ id: "pink", label: "صورتی" },
|
|
||||||
{ id: "blue", label: "آبی" },
|
|
||||||
] as { id: PaletteId; label: string }[]).map((opt) => (
|
|
||||||
<button
|
|
||||||
key={opt.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setPalette(opt.id)}
|
|
||||||
className={`px-2.5 py-0.5 rounded-md text-[11px] font-bold transition-all ${
|
|
||||||
palette === opt.id ? "bg-white text-slate-800 shadow-xs" : "text-slate-500"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{opt.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="relative overflow-hidden border-slate-200/90 shadow-sm bg-white p-0 rounded-2xl">
|
<Card className="relative overflow-hidden border-slate-200/90 shadow-sm bg-white p-0 rounded-2xl">
|
||||||
<div
|
<div dir="ltr" className="flex">
|
||||||
ref={scrollRef}
|
{/* ستون نام وظیفهها */}
|
||||||
dir="ltr"
|
<div
|
||||||
className={`w-full overflow-x-auto select-none [&::-webkit-scrollbar]:hidden ${
|
className="relative z-20 shrink-0 border-e border-slate-300 bg-white shadow-xs"
|
||||||
isPanning
|
style={{ width: `${NAME_COL_WIDTH}px` }}
|
||||||
? "cursor-grabbing"
|
>
|
||||||
: isSpacePressed
|
<div
|
||||||
? "cursor-grab"
|
dir="rtl"
|
||||||
: "cursor-default"
|
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` }}
|
||||||
style={{
|
>
|
||||||
WebkitOverflowScrolling: "touch",
|
وظایف
|
||||||
scrollbarWidth: "none",
|
</div>
|
||||||
touchAction: "pan-x pan-y",
|
{displayTasks.map((t) => {
|
||||||
}}
|
const meta = getBarMeta(t);
|
||||||
onMouseDown={handleMouseDown}
|
const taskName = t.title || t.subject;
|
||||||
onTouchStart={handleTouchStart}
|
const isSelected = selectedTaskId === t.id;
|
||||||
onTouchMove={handleTouchMove}
|
return (
|
||||||
onTouchEnd={handleTouchEnd}
|
<div
|
||||||
onTouchCancel={handleTouchEnd}
|
key={t.id}
|
||||||
>
|
dir="rtl"
|
||||||
<div style={{ width: `${totalTimelineWidth}px` }} className="relative min-w-full">
|
title={taskName}
|
||||||
<div className="sticky top-0 z-20 border-b border-slate-200 bg-slate-50/98 backdrop-blur-md">
|
onClick={() => {
|
||||||
{!showMonths && (
|
setSelectedTaskId(t.id || null);
|
||||||
<div className="flex h-8 border-b border-slate-200 text-xs font-bold text-slate-700">
|
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) => (
|
{yearGroups.map((yg, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
|
dir="rtl"
|
||||||
style={{ width: `${yg.count * colWidth}px` }}
|
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}
|
{yg.label}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{showMonths && (
|
<div
|
||||||
<div className="flex h-7.5 border-b border-slate-200 text-xs font-bold text-slate-700">
|
className="flex border-b border-slate-200 text-[10px] font-bold text-slate-600"
|
||||||
{monthGroups.map((mg, i) => (
|
style={{ height: `${HEADER_ROW_HEIGHT}px` }}
|
||||||
<div
|
>
|
||||||
key={i}
|
{timeHeaderMode === "month"
|
||||||
style={{ width: `${mg.count * colWidth}px` }}
|
? monthGroups.map((mg, i) => (
|
||||||
className="border-e border-slate-200 flex items-center justify-center truncate bg-slate-100/60 px-2 text-[11px] text-slate-600"
|
<div
|
||||||
>
|
key={i}
|
||||||
{mg.count * colWidth > 40 ? mg.label : mg.count * colWidth > 20 ? mg.shortLabel : ""}
|
dir="rtl"
|
||||||
</div>
|
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>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{showDays && (
|
{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) => (
|
|
||||||
<div
|
<div
|
||||||
key={i}
|
className="flex border-b border-slate-200 font-mono text-[10px] text-slate-400 overflow-hidden"
|
||||||
style={{ width: `${colWidth}px` }}
|
style={{ height: `${HEADER_ROW_HEIGHT}px` }}
|
||||||
className={`border-e border-slate-100/60 shrink-0 h-full ${
|
>
|
||||||
day.isFriday ? "bg-rose-500/5" : day.isToday ? "bg-sky-500/5" : ""
|
{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>
|
</div>
|
||||||
|
|
||||||
{todayIndex !== -1 && (
|
<div className="relative">
|
||||||
<div
|
{/* خطوط پسزمینه: روزها در زوم نزدیک و متوسط، ماهها در زوم دور */}
|
||||||
style={{ left: `${todayIndex * colWidth + colWidth / 2}px` }}
|
<div className="pointer-events-none absolute inset-0 flex">
|
||||||
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"
|
{colWidth >= 5
|
||||||
>
|
? timelineDays.map((day, i) => (
|
||||||
<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
|
<div
|
||||||
style={{
|
key={i}
|
||||||
width: `${progressVal}%`,
|
style={{ width: `${colWidth}px` }}
|
||||||
backgroundColor: meta.color.solid,
|
className={`h-full shrink-0 border-e border-slate-100/60 ${
|
||||||
}}
|
day.isFriday ? "bg-slate-500/5" : day.isToday ? "bg-sky-500/5" : ""
|
||||||
className="absolute inset-y-0 left-0 pointer-events-none transition-all duration-300"
|
}`}
|
||||||
/>
|
/>
|
||||||
)}
|
))
|
||||||
</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>
|
||||||
|
|
||||||
{/* نام وظیفه کنار نوار */}
|
{todayIndex !== -1 && (
|
||||||
<div
|
<div
|
||||||
dir="rtl"
|
style={{ left: `${todayIndex * colWidth + colWidth / 2}px` }}
|
||||||
onClick={openTask}
|
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)]"
|
||||||
style={{
|
>
|
||||||
left: `${coords.left + coords.width + 8}px`,
|
<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">
|
||||||
}}
|
امروز
|
||||||
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>
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="inline-block h-2 w-2 shrink-0 rounded-full"
|
|
||||||
style={{ backgroundColor: meta.color.solid }}
|
|
||||||
/>
|
|
||||||
<span>{taskName}</span>
|
|
||||||
<span className="font-semibold text-slate-400 tabular-nums">
|
|
||||||
{toFa(progressVal)}٪
|
|
||||||
</span>
|
|
||||||
{meta.isCritical && <Flame size={11} className="text-rose-600" />}
|
|
||||||
{meta.isOverdue && <AlertTriangle size={11} className="text-amber-600" />}
|
|
||||||
</div>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{selectedTask && (
|
{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">
|
<Card className="border-sky-300 shadow-xl bg-white rounded-t-3xl rounded-b-2xl">
|
||||||
<div
|
<div
|
||||||
onClick={() => setDrawerOpen(!drawerOpen)}
|
onClick={() => setDrawerOpen(!drawerOpen)}
|
||||||
@@ -926,18 +1170,20 @@ export function GanttChart({
|
|||||||
<span className="h-1.5 w-12 rounded-full bg-slate-300" />
|
<span className="h-1.5 w-12 rounded-full bg-slate-300" />
|
||||||
</div>
|
</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-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-center gap-2 min-w-0">
|
<div className="flex items-start gap-2 min-w-0 flex-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setDrawerOpen(!drawerOpen)}
|
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} />}
|
{drawerOpen ? <ChevronDown size={18} /> : <ChevronUp size={18} />}
|
||||||
</button>
|
</button>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<h3 className="text-sm font-black text-slate-900 truncate">{selectedTask.title || selectedTask.subject}</h3>
|
<h3 className="text-sm font-black text-slate-900 break-words [overflow-wrap:anywhere] whitespace-normal leading-snug">
|
||||||
<div className="flex items-center gap-1.5 mt-0.5">
|
{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>}
|
{selectedTask.is_critical && <Chip tone="red"><Flame size={10} className="me-1 inline" />بحرانی</Chip>}
|
||||||
<Chip tone="sky">
|
<Chip tone="sky">
|
||||||
{(selectedTask.progress ?? selectedTask.percentage_done ?? 0) >= 100
|
{(selectedTask.progress ?? selectedTask.percentage_done ?? 0) >= 100
|
||||||
@@ -949,7 +1195,7 @@ export function GanttChart({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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 && (
|
{canManageTasks && onEditTask && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1003,7 +1249,7 @@ export function GanttChart({
|
|||||||
<span className="text-xs font-bold text-sky-900">تعریف وابستگی جدید:</span>
|
<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>
|
<button type="button" onClick={() => setShowAddRelationInline(false)} className="text-slate-400 hover:text-slate-600"><X size={15} /></button>
|
||||||
</div>
|
</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 className="grid grid-cols-1 sm:grid-cols-4 gap-2 text-xs">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[11px] font-semibold text-slate-600 mb-1">نوع</label>
|
<label className="block text-[11px] font-semibold text-slate-600 mb-1">نوع</label>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
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 { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
createTaskRelation,
|
createTaskRelation,
|
||||||
@@ -24,6 +24,110 @@ import { isoToApiDate } from "../lib/jalali";
|
|||||||
import { reconcileDates } from "../lib/dates";
|
import { reconcileDates } from "../lib/dates";
|
||||||
import { formatDate } from "../lib/format";
|
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 {
|
interface TaskEditModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
task: Task | TaskDetail | null;
|
task: Task | TaskDetail | null;
|
||||||
@@ -558,6 +662,11 @@ export function TaskEditModal({
|
|||||||
setMaterials={setMaterials}
|
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="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">
|
<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">
|
<span className="text-xs font-bold text-slate-700 flex items-center gap-1.5">
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ import {
|
|||||||
} from "../components/ui";
|
} from "../components/ui";
|
||||||
import { JalaliDatePicker } from "../components/JalaliDatePicker";
|
import { JalaliDatePicker } from "../components/JalaliDatePicker";
|
||||||
import { GanttChart } from "../components/GanttChart";
|
import { GanttChart } from "../components/GanttChart";
|
||||||
import { TaskEditModal } from "../components/TaskEditModal";
|
import { TaskEditModal, HumanResourceInputs } from "../components/TaskEditModal";
|
||||||
import { formatDate, isOverdue, qcStatusMeta } from "../lib/format";
|
import { formatDate, isOverdue, qcStatusMeta } from "../lib/format";
|
||||||
import { isoToApiDate } from "../lib/jalali";
|
import { isoToApiDate } from "../lib/jalali";
|
||||||
import { reconcileDates } from "../lib/dates";
|
import { reconcileDates } from "../lib/dates";
|
||||||
@@ -621,6 +621,11 @@ function TaskCreateModal({
|
|||||||
setMaterials={setMaterials}
|
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="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">
|
<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">
|
<span className="text-xs font-bold text-slate-700 flex items-center gap-1.5">
|
||||||
|
|||||||
Reference in New Issue
Block a user