Fix date validations, datepicker overflow, and QC components

This commit is contained in:
Behrad
2026-10-14 15:24:11 +03:30
parent 707e40e840
commit ce1421d038
7 changed files with 667 additions and 1005 deletions
+48 -51
View File
@@ -15,11 +15,11 @@ import { cn } from "../lib/utils";
import { useClickOutside } from "./useClickOutside"; import { useClickOutside } from "./useClickOutside";
export interface JalaliDatePickerProps { export interface JalaliDatePickerProps {
value: string | null; // ISO date (یا YYYY-MM-DD با time اختیاری) value: string | null;
onChange: (val: string | null) => void; onChange: (val: string | null) => void;
placeholder?: string; placeholder?: string;
className?: string; className?: string;
withTime?: boolean; // نمایش ساعت:دقیقه هم (پیشنهاد/پذیرش/رسید) withTime?: boolean;
disabled?: boolean; disabled?: boolean;
} }
@@ -42,20 +42,16 @@ function isoToJalali(iso: string | null): [number, number, number] | null {
return gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate()); return gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate());
} }
// ساخت ISO با زمان محلی (تاریخ شمسی + ساعت:دقیقه)
function buildIso(jy: number, jm: number, jd: number, h: number, m: number): string { function buildIso(jy: number, jm: number, jd: number, h: number, m: number): string {
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd); const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
return new Date(gy, gm - 1, gd, h, m).toISOString(); return new Date(gy, gm - 1, gd, h, m).toISOString();
} }
// ساخت تاریخ استاندارد YYYY-MM-DD بدون مؤلفه زمان
function buildDateOnly(jy: number, jm: number, jd: number): string { function buildDateOnly(jy: number, jm: number, jd: number): string {
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd); const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
return `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`; return `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
} }
// وقتی هنوز زمان مشخصی ست نشده، ساعت/دقیقه‌ی فعلی سیستم پیش‌فرض است
// (نه ۰۰:۰۰) — دکمه‌ی «امروز» و انتخاب روز، همین زمان را نگه می‌دارند.
function nowTime(): { h: number; m: number } { function nowTime(): { h: number; m: number } {
const d = new Date(); const d = new Date();
return { h: d.getHours(), m: d.getMinutes() }; return { h: d.getHours(), m: d.getMinutes() };
@@ -68,7 +64,6 @@ function timeOf(iso: string | null): { h: number; m: number } {
return { h: d.getHours(), m: d.getMinutes() }; return { h: d.getHours(), m: d.getMinutes() };
} }
// تقویم شمسی — پورت Jalali.buildPicker به React (با پشتیبانی ساعت:دقیقه)
export function JalaliDatePicker({ export function JalaliDatePicker({
value, value,
onChange, onChange,
@@ -78,6 +73,7 @@ export function JalaliDatePicker({
disabled = false, disabled = false,
}: JalaliDatePickerProps) { }: JalaliDatePickerProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [alignment, setAlignment] = useState<"start" | "end">("start"); // تراز هوشمند
const [curJ, setCurJ] = useState<[number, number, number]>(() => isoToJalali(value) ?? todayJalali()); const [curJ, setCurJ] = useState<[number, number, number]>(() => isoToJalali(value) ?? todayJalali());
const initialTime = timeOf(value); const initialTime = timeOf(value);
const [hourText, setHourText] = useState<string>(() => faNum(String(initialTime.h).padStart(2, "0"))); const [hourText, setHourText] = useState<string>(() => faNum(String(initialTime.h).padStart(2, "0")));
@@ -111,7 +107,6 @@ export function JalaliDatePicker({
const mm = String(t.m).padStart(2, "0"); const mm = String(t.m).padStart(2, "0");
setText(`${j} ${faNum(hh + ":" + mm)}`); setText(`${j} ${faNum(hh + ":" + mm)}`);
// Only update local input texts if numeric values differ, to avoid wiping in-progress typing
const curH = parseInt(toAsciiDigits(hourText) || "0", 10); const curH = parseInt(toAsciiDigits(hourText) || "0", 10);
if (curH !== t.h) { if (curH !== t.h) {
setHourText(faNum(hh)); setHourText(faNum(hh));
@@ -127,7 +122,18 @@ export function JalaliDatePicker({
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false)); const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
// تاریخ انتخاب‌شده به صورت محلی (نه UTC) برای مقایسه با سلول‌های تقویم // تابع تشخیص موقعیت فیلد و تنظیم تراز تقویم
const handleOpen = () => {
if (disabled) return;
if (ref.current) {
const rect = ref.current.getBoundingClientRect();
const isLeftHalf = rect.left + rect.width / 2 < window.innerWidth / 2;
// اگر فیلد در نیمه چپ صفحه بود، تراز را از لبه چپ (end) می‌بندیم تا به سمت راست باز شود
setAlignment(isLeftHalf ? "end" : "start");
}
setOpen(true);
};
function selectedLocalDate(): string | null { function selectedLocalDate(): string | null {
if (!value) return null; if (!value) return null;
const m = String(value).match(/^(\d{4})-(\d{2})-(\d{2})$/); const m = String(value).match(/^(\d{4})-(\d{2})-(\d{2})$/);
@@ -141,7 +147,7 @@ export function JalaliDatePicker({
const grid = useMemo(() => { const grid = useMemo(() => {
const [jy, jm] = curJ; const [jy, jm] = curJ;
const firstG = jalaliToGregorian(jy, jm, 1); const firstG = jalaliToGregorian(jy, jm, 1);
const firstWd = (new Date(firstG[0], firstG[1] - 1, firstG[2]).getDay() + 1) % 7; // شنبه = 0 const firstWd = (new Date(firstG[0], firstG[1] - 1, firstG[2]).getDay() + 1) % 7;
const dim = daysInMonth(jy, jm); const dim = daysInMonth(jy, jm);
const today = new Date(); const today = new Date();
const tIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`; const tIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
@@ -190,11 +196,8 @@ export function JalaliDatePicker({
const handleHourChange = (raw: string) => { const handleHourChange = (raw: string) => {
let digits = toAsciiDigits(raw).replace(/\D/g, ""); let digits = toAsciiDigits(raw).replace(/\D/g, "");
if (digits.length === 3) { if (digits.length === 3) digits = digits.slice(-1);
digits = digits.slice(-1); else if (digits.length > 3) digits = digits.slice(0, 2);
} else if (digits.length > 3) {
digits = digits.slice(0, 2);
}
if (!digits) { if (!digits) {
setHourText(""); setHourText("");
return; return;
@@ -228,11 +231,8 @@ export function JalaliDatePicker({
const handleMinuteChange = (raw: string) => { const handleMinuteChange = (raw: string) => {
let digits = toAsciiDigits(raw).replace(/\D/g, ""); let digits = toAsciiDigits(raw).replace(/\D/g, "");
if (digits.length === 3) { if (digits.length === 3) digits = digits.slice(-1);
digits = digits.slice(-1); else if (digits.length > 3) digits = digits.slice(0, 2);
} else if (digits.length > 3) {
digits = digits.slice(0, 2);
}
if (!digits) { if (!digits) {
setMinuteText(""); setMinuteText("");
return; return;
@@ -269,11 +269,7 @@ export function JalaliDatePicker({
const [jy, jm, jd] = todayJalali(); const [jy, jm, jd] = todayJalali();
const nh = now.getHours(); const nh = now.getHours();
const nm = now.getMinutes(); const nm = now.getMinutes();
setHourText(faNum(String(nh).padStart(2, "0"))); const val = withTime ? buildIso(jy, jm, jd, nh, nm) : buildDateOnly(jy, jm, jd);
setMinuteText(faNum(String(nm).padStart(2, "0")));
const val = withTime
? buildIso(jy, jm, jd, nh, nm)
: buildDateOnly(jy, jm, jd);
onChange(val); onChange(val);
setText( setText(
withTime withTime
@@ -364,7 +360,7 @@ export function JalaliDatePicker({
}; };
return ( return (
<div ref={ref} className={cn("relative", className)}> <div ref={ref} className={cn("relative w-full", className)}>
<input <input
type="text" type="text"
disabled={disabled} disabled={disabled}
@@ -372,43 +368,45 @@ export function JalaliDatePicker({
value={text} value={text}
placeholder={placeholder ?? "۱۴۰۳/۰۵/۱۲"} placeholder={placeholder ?? "۱۴۰۳/۰۵/۱۲"}
autoComplete="off" autoComplete="off"
onFocus={() => !disabled && setOpen(true)} onFocus={handleOpen}
onClick={() => !disabled && setOpen(true)} onClick={handleOpen}
onChange={(e) => onInputChange(e.target.value)} onChange={(e) => onInputChange(e.target.value)}
onBlur={handleInputBlur} onBlur={handleInputBlur}
/> />
{open && !disabled && ( {open && !disabled && (
<div <div
className={cn( className={cn(
"absolute inset-x-0 top-full z-30 mt-1 rounded-md border border-line bg-panel shadow-md", // پاپ‌آپ مطلق که بر اساس کلاس تراز تنظیم می‌شود
// حداکثر عرض = viewport (موبایل باریک سرریز نمی‌شود؛ دسکتاپ بی‌تغییر) "absolute top-full z-[100] mt-1",
"max-w-[calc(100vw-2rem)]", alignment === "start" ? "start-0" : "end-0", // اگر فیلد چپ بود -> end-0 (تراز چپ) | اگر راست بود -> start-0 (تراز راست)
withTime ? "w-80 p-3" : "w-64 p-2", "rounded-xl border border-line bg-panel shadow-2xl p-3.5",
withTime ? "w-80" : "w-[280px]",
"max-w-[calc(100vw-2rem)]" // حاشیه امنیت برای صفحه نمایش خیلی کوچک
)} )}
> >
{/* 1. Header: Month / Year Navigation */} {/* 1. Header: Month / Year Navigation */}
<div className="mb-1.5 flex items-center justify-between gap-1"> <div className="mb-2 flex items-center justify-between gap-1">
<button type="button" className="cursor-pointer rounded px-1.5 text-sm hover:bg-slate-bg" onClick={() => nav("y+1")}> <button type="button" className="cursor-pointer rounded p-1 text-sm hover:bg-slate-bg" onClick={() => nav("y+1")}>
« «
</button> </button>
<button type="button" className="cursor-pointer rounded px-1.5 text-sm hover:bg-slate-bg" onClick={() => nav("m+1")}> <button type="button" className="cursor-pointer rounded p-1 text-sm hover:bg-slate-bg" onClick={() => nav("m+1")}>
‹ ‹
</button> </button>
<span className="text-[13px] font-bold"> <span className="text-sm font-bold">
{MONTHS[curJ[1] - 1]} {toFa(curJ[0])} {MONTHS[curJ[1] - 1]} {toFa(curJ[0])}
</span> </span>
<button type="button" className="cursor-pointer rounded px-1.5 text-sm hover:bg-slate-bg" onClick={() => nav("m-1")}> <button type="button" className="cursor-pointer rounded p-1 text-sm hover:bg-slate-bg" onClick={() => nav("m-1")}>
› ›
</button> </button>
<button type="button" className="cursor-pointer rounded px-1.5 text-sm hover:bg-slate-bg" onClick={() => nav("y-1")}> <button type="button" className="cursor-pointer rounded p-1 text-sm hover:bg-slate-bg" onClick={() => nav("y-1")}>
» »
</button> </button>
</div> </div>
{/* 2. Day of Week Header & Days Grid */} {/* 2. Day of Week Header & Days Grid */}
<div className="grid grid-cols-7 gap-0.5 text-center"> <div className="grid grid-cols-7 gap-1 text-center">
{WEEKDAYS.map((w) => ( {WEEKDAYS.map((w) => (
<div key={w} className="py-0.5 text-[10px] font-bold text-muted"> <div key={w} className="py-1 text-[11px] font-bold text-muted">
{w} {w}
</div> </div>
))} ))}
@@ -420,7 +418,7 @@ export function JalaliDatePicker({
key={i} key={i}
type="button" type="button"
className={cn( className={cn(
"cursor-pointer rounded py-0.5 text-[12px] hover:bg-slate-bg max-md:py-1.5", "cursor-pointer rounded-lg py-2 text-[13px] hover:bg-slate-bg transition-colors",
cell.isToday && !selectedIso && "bg-blue-bg font-bold text-[#1d4ed8]", cell.isToday && !selectedIso && "bg-blue-bg font-bold text-[#1d4ed8]",
cell.isToday && selectedIso && "font-bold text-[#1d4ed8]", cell.isToday && selectedIso && "font-bold text-[#1d4ed8]",
cell.iso === selectedIso && "bg-accent text-white hover:bg-accent-dark", cell.iso === selectedIso && "bg-accent text-white hover:bg-accent-dark",
@@ -433,44 +431,43 @@ export function JalaliDatePicker({
)} )}
</div> </div>
{/* 3. Time Inputs Row (Positioned BELOW the calendar grid) — Hour on left, Minute on right */} {/* 3. Time Inputs Row */}
{withTime && ( {withTime && (
<div className="mt-2 flex items-center justify-center gap-3 rounded-md border border-line/70 bg-slate-bg/40 py-1.5"> <div className="mt-3 flex items-center justify-center gap-3 rounded-lg border border-line/70 bg-slate-bg/40 py-2">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<input <input
type="text" type="text"
className="num-input h-7 w-12 rounded-md border border-line bg-panel px-1 text-center text-[13px] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 max-md:h-9 max-md:w-14" className="num-input h-8 w-14 rounded-md border border-line bg-panel px-1 text-center text-sm tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
value={minuteText} value={minuteText}
inputMode="numeric" inputMode="numeric"
onFocus={(e) => e.target.select()} onFocus={(e) => e.target.select()}
onChange={(e) => handleMinuteChange(e.target.value)} onChange={(e) => handleMinuteChange(e.target.value)}
onBlur={handleMinuteBlur} onBlur={handleMinuteBlur}
/> />
<span className="text-[11.5px] font-semibold text-muted">دقیقه</span> <span className="text-xs font-semibold text-muted">دقیقه</span>
</div> </div>
<span className="font-bold text-muted">:</span> <span className="font-bold text-muted">:</span>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<input <input
type="text" type="text"
className="num-input h-7 w-12 rounded-md border border-line bg-panel px-1 text-center text-[13px] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 max-md:h-9 max-md:w-14" className="num-input h-8 w-14 rounded-md border border-line bg-panel px-1 text-center text-sm tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
value={hourText} value={hourText}
inputMode="numeric" inputMode="numeric"
onFocus={(e) => e.target.select()} onFocus={(e) => e.target.select()}
onChange={(e) => handleHourChange(e.target.value)} onChange={(e) => handleHourChange(e.target.value)}
onBlur={handleHourBlur} onBlur={handleHourBlur}
/> />
<span className="text-[11.5px] font-semibold text-muted">ساعت</span> <span className="text-xs font-semibold text-muted">ساعت</span>
</div> </div>
</div> </div>
)} )}
{/* 4. Action Buttons Footer */} {/* 4. Action Buttons Footer */}
<div className="mt-2 flex justify-between border-t border-line pt-2"> <div className="mt-3 flex justify-between border-t border-line pt-2">
<button type="button" className="cursor-pointer rounded px-2 py-0.5 text-[11.5px] text-accent hover:bg-slate-bg" onClick={pickToday}> <button type="button" className="cursor-pointer rounded px-3 py-1 text-xs font-medium text-accent hover:bg-slate-bg" onClick={pickToday}>
امروز امروز
</button> </button>
<button type="button" className="cursor-pointer rounded px-2 py-0.5 text-[11.5px] text-muted hover:bg-slate-bg" onClick={clear}> <button type="button" className="cursor-pointer rounded px-3 py-1 text-xs font-medium text-muted hover:bg-slate-bg" onClick={clear}>
پاک کردن پاک کردن
</button> </button>
</div> </div>
File diff suppressed because it is too large Load Diff
+22 -17
View File
@@ -87,6 +87,15 @@ export function TaskEditModal({
const [errorMsg, setErrorMsg] = useState(""); const [errorMsg, setErrorMsg] = useState("");
const [showConfirmDelete, setShowConfirmDelete] = useState(false); const [showConfirmDelete, setShowConfirmDelete] = useState(false);
// بررسی زنده برای کوچکتر بودن تاریخ پایان از شروع
const isDateRangeInvalid = Boolean(
plannedStart &&
plannedEnd &&
isoToApiDate(plannedStart) &&
isoToApiDate(plannedEnd) &&
isoToApiDate(plannedStart)! > isoToApiDate(plannedEnd)!
);
useEffect(() => { useEffect(() => {
if (open && task) { if (open && task) {
setTitle(task.title || task.subject || ""); setTitle(task.title || task.subject || "");
@@ -112,7 +121,7 @@ export function TaskEditModal({
const taskId = task ? String(task.id || task.op_wp_id) : ""; const taskId = task ? String(task.id || task.op_wp_id) : "";
// لیست والدهای مجاز (حذف خود تسک و تمام زیرتسک‌های آن برای جلوگیری از حلقه) // لیست والدهای مجاز
const validParents = useMemo(() => { const validParents = useMemo(() => {
if (!taskId) return allTasks; if (!taskId) return allTasks;
const descendantIds = getDescendantIds(allTasks, taskId); const descendantIds = getDescendantIds(allTasks, taskId);
@@ -122,12 +131,11 @@ export function TaskEditModal({
}); });
}, [allTasks, taskId]); }, [allTasks, taskId]);
// لیست وظایف مجاز جهت برقراری رابطه وابستگی (حذف خود تسک) // لیست وظایف مجاز جهت برقراری رابطه وابستگی
const validRelationTargets = useMemo(() => { const validRelationTargets = useMemo(() => {
return allTasks.filter((t) => String(t.id || t.op_wp_id) !== taskId); return allTasks.filter((t) => String(t.id || t.op_wp_id) !== taskId);
}, [allTasks, taskId]); }, [allTasks, taskId]);
// کوئری روابط پروژه جهت استخراج روابط ورودی و خروجی این وظیفه
const relationsQuery = useQuery({ const relationsQuery = useQuery({
queryKey: ["project-relations", projectId], queryKey: ["project-relations", projectId],
queryFn: () => getProjectRelations(projectId), queryFn: () => getProjectRelations(projectId),
@@ -161,7 +169,7 @@ export function TaskEditModal({
void queryClient.invalidateQueries({ queryKey: ["task", taskId] }); void queryClient.invalidateQueries({ queryKey: ["task", taskId] });
}, },
onError: (err: any) => { onError: (err: any) => {
toast.error(err?.message || "خطا در ایجاد رابطه وابستگی (احتمال وجود دور یا ورودی تکراری)"); toast.error(err?.message || "خطا در ایجاد رابطه وابستگی");
}, },
}); });
@@ -179,12 +187,9 @@ export function TaskEditModal({
}, },
}); });
/** Save the whole form. Progress goes to its own endpoint (see below). */
const updateMutation = useMutation({ const updateMutation = useMutation({
mutationFn: async (payload: UpdateTaskPayload) => { mutationFn: async (payload: UpdateTaskPayload) => {
await updateTask(task!.id || task!.op_wp_id, payload); await updateTask(task!.id || task!.op_wp_id, payload);
// progress is NOT part of the task patch schema — it has its own endpoint,
// which also enforces the QC gating rules server-side
if (typeof progress === "number" && progress !== (task!.progress ?? task!.percentage_done ?? 0)) { if (typeof progress === "number" && progress !== (task!.progress ?? task!.percentage_done ?? 0)) {
await updateTaskProgress(task!.id || task!.op_wp_id, progress); await updateTaskProgress(task!.id || task!.op_wp_id, progress);
} }
@@ -232,7 +237,6 @@ export function TaskEditModal({
const startIso = isoToApiDate(plannedStart); const startIso = isoToApiDate(plannedStart);
const endIso = isoToApiDate(plannedEnd); const endIso = isoToApiDate(plannedEnd);
// ۱. اجباری بودن تاریخ شروع و پایان
if (!startIso) { if (!startIso) {
setErrorMsg("وارد کردن تاریخ شروع وظیفه الزامی است"); setErrorMsg("وارد کردن تاریخ شروع وظیفه الزامی است");
return; return;
@@ -242,13 +246,11 @@ export function TaskEditModal({
return; return;
} }
// ۲. تاریخ شروع نباید از تاریخ پایان جلوتر باشد
if (startIso > endIso) { if (startIso > endIso) {
setErrorMsg("تاریخ شروع وظیفه نمی‌تواند بعد از تاریخ اتمام آن باشد"); setErrorMsg("تاریخ پایان نمی‌تواند کمتر از تاریخ شروع باشد");
return; return;
} }
// ۲. محدودیت نسبت به والد انتخابی
if (parentId) { if (parentId) {
const selectedParent = allTasks.find( const selectedParent = allTasks.find(
(t) => String(t.id || t.op_wp_id) === String(parentId) (t) => String(t.id || t.op_wp_id) === String(parentId)
@@ -281,7 +283,6 @@ export function TaskEditModal({
} }
} }
// ۳. محدودیت نسبت به فرزندان موجود
const children = allTasks.filter((t) => { const children = allTasks.filter((t) => {
const p = t.parent_id const p = t.parent_id
? String(t.parent_id) ? String(t.parent_id)
@@ -443,7 +444,6 @@ export function TaskEditModal({
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div> <div>
<label className="mb-1 block text-xs font-semibold text-slate-600"> <label className="mb-1 block text-xs font-semibold text-slate-600">
@@ -483,6 +483,14 @@ export function TaskEditModal({
</div> </div>
</div> </div>
{/* کادر اخطار بلادرنگ در صورت نامعتبر بودن بازه زمانی */}
{isDateRangeInvalid && (
<div className="flex items-center gap-2 rounded-xl border border-rose-200 bg-rose-50 p-2.5 text-xs font-bold text-rose-600">
<AlertTriangle size={15} className="shrink-0 text-rose-500" />
<span>تاریخ پایان نمی‌تواند کمتر از تاریخ شروع باشد</span>
</div>
)}
<div className="rounded-xl border border-slate-200 bg-slate-50 p-2.5 text-xs"> <div className="rounded-xl border border-slate-200 bg-slate-50 p-2.5 text-xs">
<p className="font-semibold text-slate-700 mb-1">تاریخ‌های واقعی (محاسبه و ثبت خودکار):</p> <p className="font-semibold text-slate-700 mb-1">تاریخ‌های واقعی (محاسبه و ثبت خودکار):</p>
<div className="grid grid-cols-2 gap-2 text-slate-600"> <div className="grid grid-cols-2 gap-2 text-slate-600">
@@ -550,7 +558,6 @@ export function TaskEditModal({
setMaterials={setMaterials} setMaterials={setMaterials}
/> />
{/* بخش روابط پیش‌نیازی و وابستگی‌ها (FS, SS, FF, SF) */}
<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">
@@ -564,7 +571,6 @@ export function TaskEditModal({
)} )}
</div> </div>
{/* لیست وابستگی‌های موجود */}
<div className="space-y-1.5"> <div className="space-y-1.5">
{currentTaskRelations.length === 0 ? ( {currentTaskRelations.length === 0 ? (
<p className="py-2 text-center text-xs text-slate-400"> <p className="py-2 text-center text-xs text-slate-400">
@@ -619,7 +625,6 @@ export function TaskEditModal({
)} )}
</div> </div>
{/* فرم افزودن رابطه جدید */}
<div className="pt-2 border-t border-slate-200/80 space-y-2"> <div className="pt-2 border-t border-slate-200/80 space-y-2">
<span className="text-[11px] font-bold text-slate-700 block">افزودن رابطه وابستگی جدید</span> <span className="text-[11px] font-bold text-slate-700 block">افزودن رابطه وابستگی جدید</span>
<div className="grid grid-cols-1 sm:grid-cols-12 gap-2"> <div className="grid grid-cols-1 sm:grid-cols-12 gap-2">
@@ -723,7 +728,7 @@ export function TaskEditModal({
</button> </button>
<button <button
type="submit" type="submit"
disabled={updateMutation.isPending} disabled={updateMutation.isPending || isDateRangeInvalid}
className={btnPrimary} className={btnPrimary}
> >
{updateMutation.isPending ? "در حال ذخیره..." : "ذخیره تغییرات"} {updateMutation.isPending ? "در حال ذخیره..." : "ذخیره تغییرات"}
+91 -34
View File
@@ -1,4 +1,4 @@
import { useState } from "react"; import { useState, useMemo } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
@@ -48,7 +48,7 @@ export function TaskQcSection({ task, canExecuteQc, canDefineQc }: TaskQcSection
const [defectToResolve, setDefectToResolve] = useState<Defect | null>(null); const [defectToResolve, setDefectToResolve] = useState<Defect | null>(null);
const [resolutionNotes, setResolutionNotes] = useState(""); const [resolutionNotes, setResolutionNotes] = useState("");
// Load Task QC Data // دریافت اطلاعات QC وظیفه
const qcQuery = useQuery({ const qcQuery = useQuery({
queryKey: ["task-qc", taskId], queryKey: ["task-qc", taskId],
queryFn: () => getTaskQc(taskId), queryFn: () => getTaskQc(taskId),
@@ -66,7 +66,50 @@ export function TaskQcSection({ task, canExecuteQc, canDefineQc }: TaskQcSection
const questions = definitions.flatMap((d) => d.questions); const questions = definitions.flatMap((d) => d.questions);
const selectedTemplate = (templatesQuery.data ?? []).find((t) => t.id === selectedTemplateId) ?? null; const selectedTemplate = (templatesQuery.data ?? []).find((t) => t.id === selectedTemplateId) ?? null;
// Mutations // محاسبه زنده و دقیق آمار هر مرحله بر اساس لیست واقعی سوالات
const preStats = useMemo(() => {
if (questions.length > 0) {
const list = questions.filter((q) => q.phase === "pre");
return {
total: list.length,
passed: list.filter((q) => q.result?.answer === "pass" || q.result?.answer === "na").length,
};
}
return {
total: readiness?.pre?.total ?? 0,
passed: readiness?.pre?.passed ?? 0,
};
}, [questions, readiness?.pre]);
const duringStats = useMemo(() => {
if (questions.length > 0) {
const list = questions.filter((q) => q.phase === "during");
return {
total: list.length,
passed: list.filter((q) => q.result?.answer === "pass" || q.result?.answer === "na").length,
};
}
return {
total: readiness?.exec?.total ?? 0,
passed: readiness?.exec?.passed ?? 0,
};
}, [questions, readiness?.exec]);
const afterStats = useMemo(() => {
if (questions.length > 0) {
const list = questions.filter((q) => q.phase === "after");
return {
total: list.length,
passed: list.filter((q) => q.result?.answer === "pass" || q.result?.answer === "na").length,
};
}
return {
total: (readiness as any)?.after?.total ?? 0,
passed: (readiness as any)?.after?.passed ?? 0,
};
}, [questions, readiness]);
// جهش‌ها (Mutations)
const invalidateQc = () => { const invalidateQc = () => {
void queryClient.invalidateQueries({ queryKey: ["task-qc", taskId] }); void queryClient.invalidateQueries({ queryKey: ["task-qc", taskId] });
void queryClient.invalidateQueries({ queryKey: ["task", taskId] }); void queryClient.invalidateQueries({ queryKey: ["task", taskId] });
@@ -93,7 +136,6 @@ export function TaskQcSection({ task, canExecuteQc, canDefineQc }: TaskQcSection
const handleToggleAnswer = (questionId: string, currentAnswer: string | null | undefined, newAnswer: AnswerValue) => { const handleToggleAnswer = (questionId: string, currentAnswer: string | null | undefined, newAnswer: AnswerValue) => {
if (!canExecuteQc || answerMutation.isPending) return; if (!canExecuteQc || answerMutation.isPending) return;
if (currentAnswer === newAnswer) { if (currentAnswer === newAnswer) {
// کلیک مجدد: لغو انتخاب قبلی
answerMutation.mutate({ questionId, answer: null }); answerMutation.mutate({ questionId, answer: null });
} else { } else {
answerMutation.mutate({ questionId, answer: newAnswer }); answerMutation.mutate({ questionId, answer: newAnswer });
@@ -158,18 +200,15 @@ export function TaskQcSection({ task, canExecuteQc, canDefineQc }: TaskQcSection
}, },
}); });
// Calculate Progress // محاسبه پیشرفت کل چک‌لیست
const totalQuestions = readiness?.all.total ?? questions.length; const totalQuestions = questions.length > 0 ? questions.length : (readiness?.all.total ?? 0);
const pendingCount = readiness?.all.pending ?? 0; const answeredCount = questions.length > 0
const answeredCount = totalQuestions - pendingCount; ? questions.filter((q) => q.result?.answer != null).length
: (totalQuestions - (readiness?.all.pending ?? 0));
const qcPercent = totalQuestions > 0 ? Math.round((answeredCount / totalQuestions) * 100) : 0; const qcPercent = totalQuestions > 0 ? Math.round((answeredCount / totalQuestions) * 100) : 0;
const currentPhaseQuestions = questions.filter((q) => q.phase === activePhase); const currentPhaseQuestions = questions.filter((q) => q.phase === activePhase);
// Group stats by phase
const preStats = readiness?.pre;
const execStats = readiness?.exec;
return ( return (
<div className="space-y-3.5"> <div className="space-y-3.5">
{/* 1. بنر وضعیت آمادگی شروع و اتمام کار */} {/* 1. بنر وضعیت آمادگی شروع و اتمام کار */}
@@ -206,12 +245,13 @@ export function TaskQcSection({ task, canExecuteQc, canDefineQc }: TaskQcSection
</div> </div>
)} )}
{/* نوار پیشرفت کلی QC */}
{totalQuestions > 0 && ( {totalQuestions > 0 && (
<div className="mt-2.5"> <div className="mt-2.5">
<div className="flex items-center justify-between text-[11px] text-slate-500 mb-1"> <div className="flex items-center justify-between text-[11px] text-slate-500 mb-1">
<span>پیشرفت بازرسی‌های کیفی</span> <span>پیشرفت بازرسی‌های کیفی</span>
<span className="font-bold tabular-nums text-slate-700">{qcPercent}٪ ({answeredCount} از {totalQuestions})</span> <span className="font-bold tabular-nums text-slate-700">
{qcPercent}٪ ({answeredCount} از {totalQuestions})
</span>
</div> </div>
<ProgressBar value={qcPercent} /> <ProgressBar value={qcPercent} />
</div> </div>
@@ -252,52 +292,72 @@ export function TaskQcSection({ task, canExecuteQc, canDefineQc }: TaskQcSection
</div> </div>
</div> </div>
{/* ۳ تب فازها: پیش از اجرا / حین اجرا / پس از اجرا */} {/* ۳ تب فازها با عرض مساوی و شمارنده دقیق */}
<div className="flex rounded-lg bg-slate-200/80 p-0.5 text-xs font-semibold text-slate-600"> <div className="grid grid-cols-3 gap-1 rounded-lg bg-slate-200/80 p-1 text-xs font-semibold text-slate-600">
<button <button
type="button" type="button"
onClick={() => setActivePhase("pre")} onClick={() => setActivePhase("pre")}
className={`flex-1 rounded-md py-1.5 text-center transition-all ${ className={`min-w-0 rounded-md py-1.5 px-1 text-center transition-all flex items-center justify-center gap-1 ${
activePhase === "pre" activePhase === "pre"
? "bg-white text-amber-800 shadow-sm font-bold" ? "bg-white text-amber-800 shadow-sm font-bold"
: "text-slate-600 hover:text-slate-800" : "text-slate-600 hover:text-slate-800"
}`} }`}
> >
<span>۱. پیش از اجرا</span> <span className="truncate">۱. پیش از اجرا</span>
{preStats && preStats.total > 0 && ( <span
<span className={`ms-1.5 rounded-full px-1.5 py-0.2 text-[10px] ${activePhase === "pre" ? "bg-amber-100 text-amber-800" : "bg-slate-300 text-slate-700"}`}> className={`rounded-full px-1.5 py-0.2 text-[10px] tabular-nums shrink-0 ${
activePhase === "pre"
? "bg-amber-100 text-amber-800 font-bold"
: "bg-slate-300 text-slate-700"
}`}
>
{preStats.passed}/{preStats.total} {preStats.passed}/{preStats.total}
</span> </span>
)}
</button> </button>
<button <button
type="button" type="button"
onClick={() => setActivePhase("during")} onClick={() => setActivePhase("during")}
className={`flex-1 rounded-md py-1.5 text-center transition-all ${ className={`min-w-0 rounded-md py-1.5 px-1 text-center transition-all flex items-center justify-center gap-1 ${
activePhase === "during" activePhase === "during"
? "bg-white text-sky-800 shadow-sm font-bold" ? "bg-white text-sky-800 shadow-sm font-bold"
: "text-slate-600 hover:text-slate-800" : "text-slate-600 hover:text-slate-800"
}`} }`}
> >
<span>۲. حین اجرا</span> <span className="truncate">۲. حین اجرا</span>
{execStats && execStats.total > 0 && ( <span
<span className={`ms-1.5 rounded-full px-1.5 py-0.2 text-[10px] ${activePhase === "during" ? "bg-sky-100 text-sky-800" : "bg-slate-300 text-slate-700"}`}> className={`rounded-full px-1.5 py-0.2 text-[10px] tabular-nums shrink-0 ${
{execStats.passed}/{execStats.total} activePhase === "during"
? "bg-sky-100 text-sky-800 font-bold"
: "bg-slate-300 text-slate-700"
}`}
>
{duringStats.passed}/{duringStats.total}
</span> </span>
)}
</button> </button>
<button <button
type="button" type="button"
onClick={() => setActivePhase("after")} onClick={() => setActivePhase("after")}
className={`flex-1 rounded-md py-1.5 text-center transition-all ${ className={`min-w-0 rounded-md py-1.5 px-1 text-center transition-all flex items-center justify-center gap-1 ${
activePhase === "after" activePhase === "after"
? "bg-white text-emerald-800 shadow-sm font-bold" ? "bg-white text-emerald-800 shadow-sm font-bold"
: "text-slate-600 hover:text-slate-800" : "text-slate-600 hover:text-slate-800"
}`} }`}
> >
<span>۳. تحویل و پس از اجرا</span> <span className="truncate">
<span className="sm:hidden">۳. پس از اجرا</span>
<span className="hidden sm:inline">۳. تحویل و پس از اجرا</span>
</span>
<span
className={`rounded-full px-1.5 py-0.2 text-[10px] tabular-nums shrink-0 ${
activePhase === "after"
? "bg-emerald-100 text-emerald-800 font-bold"
: "bg-slate-300 text-slate-700"
}`}
>
{afterStats.passed}/{afterStats.total}
</span>
</button> </button>
</div> </div>
@@ -363,12 +423,11 @@ export function TaskQcSection({ task, canExecuteQc, canDefineQc }: TaskQcSection
)} )}
</div> </div>
{/* دکمه‌های پاسخ فوری ۳ گانه */} {/* دکمه‌های ۳ گانه پاسخ */}
<div className="mt-2.5 flex items-center justify-between gap-2 pt-2 border-t border-slate-100"> <div className="mt-2.5 flex items-center justify-between gap-2 pt-2 border-t border-slate-100">
<span className="text-[11px] text-slate-500">وضعیت بازرسی:</span> <span className="text-[11px] text-slate-500">وضعیت بازرسی:</span>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{/* دکمه قبول / Pass */}
<button <button
type="button" type="button"
disabled={!canExecuteQc || answerMutation.isPending} disabled={!canExecuteQc || answerMutation.isPending}
@@ -383,7 +442,6 @@ export function TaskQcSection({ task, canExecuteQc, canDefineQc }: TaskQcSection
<span>قبول</span> <span>قبول</span>
</button> </button>
{/* دکمه رد / Fail */}
<button <button
type="button" type="button"
disabled={!canExecuteQc || answerMutation.isPending} disabled={!canExecuteQc || answerMutation.isPending}
@@ -398,7 +456,6 @@ export function TaskQcSection({ task, canExecuteQc, canDefineQc }: TaskQcSection
<span>مردود</span> <span>مردود</span>
</button> </button>
{/* دکمه مطرح نیست */}
<button <button
type="button" type="button"
disabled={!canExecuteQc || answerMutation.isPending} disabled={!canExecuteQc || answerMutation.isPending}
@@ -415,7 +472,7 @@ export function TaskQcSection({ task, canExecuteQc, canDefineQc }: TaskQcSection
</div> </div>
</div> </div>
{/* در صورت قبول بودن: اکشن آپلود عکس تأییدیه */} {/* در صورت قبول بودن: امکان آپلود تصویر مستند */}
{latestAns === "pass" && ( {latestAns === "pass" && (
<div className="mt-2 rounded-lg bg-emerald-50/70 p-2 text-xs border border-emerald-100 space-y-1.5"> <div className="mt-2 rounded-lg bg-emerald-50/70 p-2 text-xs border border-emerald-100 space-y-1.5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
+58 -301
View File
@@ -3,6 +3,7 @@ import { Link, useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
AlertTriangle,
BarChart3, BarChart3,
Calendar, Calendar,
ChevronDown, ChevronDown,
@@ -15,21 +16,15 @@ import {
RefreshCw, RefreshCw,
Search, Search,
Trash2, Trash2,
UserPlus,
Users,
} from "lucide-react"; } from "lucide-react";
import { import {
addProjectMember,
createTask, createTask,
createTaskRelation, createTaskRelation,
deleteTaskRelation, deleteTaskRelation,
getProject, getProject,
getProjectGantt, getProjectGantt,
getTasks, getTasks,
getUsers,
removeProjectMember,
rescheduleProject, rescheduleProject,
updateProject,
updateTask, updateTask,
updateTaskRelation, updateTaskRelation,
} from "../lib/api"; } from "../lib/api";
@@ -38,7 +33,6 @@ import type {
CreateTaskPayload, CreateTaskPayload,
HumanResourceItem, HumanResourceItem,
MaterialItem, MaterialItem,
ProjectMember,
Task, Task,
TaskRelation, TaskRelation,
UpdateRelationPayload, UpdateRelationPayload,
@@ -50,7 +44,6 @@ import {
Chip, Chip,
EmptyState, EmptyState,
ErrorState, ErrorState,
InitialAvatar,
Modal, Modal,
PageHeader, PageHeader,
ProgressBar, ProgressBar,
@@ -142,7 +135,6 @@ function TreeRow({
const displayTitle = t.title || t.subject; const displayTitle = t.title || t.subject;
const targetUrl = `/tasks/${t.id || t.op_wp_id}`; const targetUrl = `/tasks/${t.id || t.op_wp_id}`;
const progress = t.progress ?? t.percentage_done ?? 0; const progress = t.progress ?? t.percentage_done ?? 0;
// رنگ درصد: ۱۰۰٪ سبز، در حال انجام آبی، صفر خاکستری
const progressCls = const progressCls =
progress >= 100 ? "text-emerald-600" : progress > 0 ? "text-sky-600" : "text-slate-400"; progress >= 100 ? "text-emerald-600" : progress > 0 ? "text-sky-600" : "text-slate-400";
@@ -171,7 +163,6 @@ function TreeRow({
<span className="min-w-0 truncate text-[13px] font-medium leading-5 text-slate-800 group-hover:text-sky-600 transition-colors"> <span className="min-w-0 truncate text-[13px] font-medium leading-5 text-slate-800 group-hover:text-sky-600 transition-colors">
{displayTitle} {displayTitle}
</span> </span>
{/* درصد تکمیل — بلافاصله سمت چپ نام و بالای نوار پیشرفت */}
<span className={`shrink-0 text-[13px] font-bold tabular-nums ${progressCls}`}> <span className={`shrink-0 text-[13px] font-bold tabular-nums ${progressCls}`}>
{progress}٪ {progress}٪
</span> </span>
@@ -260,7 +251,6 @@ function TaskCreateModal({
const [materials, setMaterials] = useState<MaterialItem[]>([]); const [materials, setMaterials] = useState<MaterialItem[]>([]);
const [errorMsg, setErrorMsg] = useState(""); const [errorMsg, setErrorMsg] = useState("");
// روابط پیش‌نیازی و وابستگی‌ها (FS, SS, FF, SF)
interface DraftRelation { interface DraftRelation {
target_task_id: string; target_task_id: string;
target_task_title: string; target_task_title: string;
@@ -272,7 +262,16 @@ function TaskCreateModal({
const [relTargetId, setRelTargetId] = useState(""); const [relTargetId, setRelTargetId] = useState("");
const [relDirection, setRelDirection] = useState<"predecessor" | "successor">("predecessor"); const [relDirection, setRelDirection] = useState<"predecessor" | "successor">("predecessor");
const [relType, setRelType] = useState<"FS" | "SS" | "FF" | "SF">("FS"); const [relType, setRelType] = useState<"FS" | "SS" | "FF" | "SF">("FS");
const [relLag, setRelLag] = useState<number>(0); const [relLag, setRelLag] = useState<number | "">("");
// بررسی زنده برای کوچکتر بودن تاریخ پایان از شروع
const isDateRangeInvalid = Boolean(
plannedStart &&
plannedEnd &&
isoToApiDate(plannedStart) &&
isoToApiDate(plannedEnd) &&
isoToApiDate(plannedStart)! > isoToApiDate(plannedEnd)!
);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
@@ -280,7 +279,7 @@ function TaskCreateModal({
setSelectedChildIds([]); setSelectedChildIds([]);
setDraftRelations([]); setDraftRelations([]);
setRelTargetId(""); setRelTargetId("");
setRelLag(0); setRelLag("");
setErrorMsg(""); setErrorMsg("");
} }
}, [open, parentTask]); }, [open, parentTask]);
@@ -290,7 +289,6 @@ function TaskCreateModal({
const created = await createTask(payload); const created = await createTask(payload);
const newTaskId = String(created.id || (created as any).data?.id || (created as any).op_wp_id); const newTaskId = String(created.id || (created as any).data?.id || (created as any).op_wp_id);
// ۱. انتساب وظایف انتخاب شده به عنوان فرزندان (زیرتسک‌ها)
if (selectedChildIds.length > 0 && newTaskId) { if (selectedChildIds.length > 0 && newTaskId) {
for (const childId of selectedChildIds) { for (const childId of selectedChildIds) {
try { try {
@@ -301,7 +299,6 @@ function TaskCreateModal({
} }
} }
// ۲. ایجاد روابط وابستگی تعریف شده (FS, SS, FF, SF)
if (draftRelations.length > 0 && newTaskId) { if (draftRelations.length > 0 && newTaskId) {
for (const rel of draftRelations) { for (const rel of draftRelations) {
try { try {
@@ -356,7 +353,6 @@ function TaskCreateModal({
const startIso = isoToApiDate(plannedStart); const startIso = isoToApiDate(plannedStart);
const endIso = isoToApiDate(plannedEnd); const endIso = isoToApiDate(plannedEnd);
// ۱. اجباری بودن تاریخ شروع و پایان
if (!startIso) { if (!startIso) {
setErrorMsg("وارد کردن تاریخ شروع وظیفه الزامی است"); setErrorMsg("وارد کردن تاریخ شروع وظیفه الزامی است");
return; return;
@@ -366,13 +362,11 @@ function TaskCreateModal({
return; return;
} }
// ۲. تاریخ شروع نباید از تاریخ پایان جلوتر باشد
if (startIso > endIso) { if (startIso > endIso) {
setErrorMsg("تاریخ شروع وظیفه نمی‌تواند بعد از تاریخ اتمام آن باشد"); setErrorMsg("تاریخ پایان نمی‌تواند کمتر از تاریخ شروع باشد");
return; return;
} }
// ۳. انطباق با تاریخ‌های والد انتخابی
const activeParent = selectedParentId const activeParent = selectedParentId
? allTasks.find((t) => String(t.id || t.op_wp_id) === selectedParentId) ? allTasks.find((t) => String(t.id || t.op_wp_id) === selectedParentId)
: null; : null;
@@ -419,7 +413,6 @@ function TaskCreateModal({
}); });
}; };
// وظایف کاندید برای فرزند بودن (به جز والدی که هم‌اکنون انتخاب شده)
const candidateChildren = allTasks.filter( const candidateChildren = allTasks.filter(
(t) => String(t.id || t.op_wp_id) !== selectedParentId (t) => String(t.id || t.op_wp_id) !== selectedParentId
); );
@@ -444,7 +437,6 @@ function TaskCreateModal({
/> />
</div> </div>
{/* ۱. تعیین وظیفه والد (PARENT) */}
<div> <div>
<label className="mb-1 block text-xs font-semibold text-slate-600"> <label className="mb-1 block text-xs font-semibold text-slate-600">
وظیفه والد (سطح بالاتر - Parent) وظیفه والد (سطح بالاتر - Parent)
@@ -472,7 +464,6 @@ function TaskCreateModal({
</select> </select>
</div> </div>
{/* ۲. تعیین زیرتسک‌ها و فرزندان (CHILD) */}
<div className="rounded-xl border border-slate-200 bg-slate-50/60 p-3 space-y-2"> <div className="rounded-xl border border-slate-200 bg-slate-50/60 p-3 space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<label className="text-xs font-bold text-slate-700"> <label className="text-xs font-bold text-slate-700">
@@ -576,6 +567,14 @@ function TaskCreateModal({
</div> </div>
</div> </div>
{/* ارور در صورت کمتر بودن تاریخ پایان از شروع */}
{isDateRangeInvalid && (
<div className="flex items-center gap-2 rounded-xl border border-rose-200 bg-rose-50 p-2.5 text-xs font-bold text-rose-600">
<AlertTriangle size={15} className="shrink-0 text-rose-500" />
<span>تاریخ پایان نمی‌تواند کمتر از تاریخ شروع باشد</span>
</div>
)}
<div> <div>
<label className="mb-1 block text-xs font-semibold text-slate-600">مدت زمان برآورد شده (روز)</label> <label className="mb-1 block text-xs font-semibold text-slate-600">مدت زمان برآورد شده (روز)</label>
<input <input
@@ -595,8 +594,9 @@ function TaskCreateModal({
setPlannedEnd(next.end); setPlannedEnd(next.end);
setDurationDays(next.days); setDurationDays(next.days);
}} }}
onFocus={(e) => e.target.select()}
placeholder="مثال: ۵" placeholder="مثال: ۵"
className={inputCls} className={`${inputCls} [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none`}
/> />
<p className="mt-1 text-[11px] leading-4 text-slate-400"> <p className="mt-1 text-[11px] leading-4 text-slate-400">
روز، تاریخ پایان را می‌سازد: شروع ۱۶ مرداد و ۴ روز ⇒ پایان ۲۰ مرداد. روز، تاریخ پایان را می‌سازد: شروع ۱۶ مرداد و ۴ روز ⇒ پایان ۲۰ مرداد.
@@ -621,7 +621,6 @@ function TaskCreateModal({
setMaterials={setMaterials} setMaterials={setMaterials}
/> />
{/* ۳. تعریف روابط وابستگی (FS, SS, FF, SF + lag) در تعریف وظیفه */}
<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">
@@ -635,7 +634,6 @@ function TaskCreateModal({
)} )}
</div> </div>
{/* لیست روابط پیش‌نویس */}
{draftRelations.length > 0 && ( {draftRelations.length > 0 && (
<div className="space-y-1.5"> <div className="space-y-1.5">
{draftRelations.map((r, idx) => ( {draftRelations.map((r, idx) => (
@@ -672,7 +670,6 @@ function TaskCreateModal({
</div> </div>
)} )}
{/* فرم افزودن رابطه پیش‌نویس */}
<div className="grid grid-cols-1 sm:grid-cols-12 gap-2 pt-1 border-t border-slate-200/80"> <div className="grid grid-cols-1 sm:grid-cols-12 gap-2 pt-1 border-t border-slate-200/80">
<div className="sm:col-span-4"> <div className="sm:col-span-4">
<label className="text-[10px] font-medium text-slate-500 block mb-0.5">وظیفه مرتبط</label> <label className="text-[10px] font-medium text-slate-500 block mb-0.5">وظیفه مرتبط</label>
@@ -724,8 +721,13 @@ function TaskCreateModal({
<input <input
type="number" type="number"
value={relLag} value={relLag}
onChange={(e) => setRelLag(parseInt(e.target.value) || 0)} onChange={(e) => {
className="h-8 w-full rounded-md border border-slate-300 px-2 text-xs text-slate-800 focus:border-sky-500 focus:outline-none" const val = e.target.value;
setRelLag(val === "" ? "" : Number(val));
}}
onFocus={(e) => e.target.select()}
placeholder="0"
className="h-8 w-full rounded-md border border-slate-300 px-2 text-xs text-slate-800 focus:border-sky-500 focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/> />
</div> </div>
</div> </div>
@@ -747,11 +749,11 @@ function TaskCreateModal({
target_task_title: targetTask?.title || targetTask?.subject || "وظیفه", target_task_title: targetTask?.title || targetTask?.subject || "وظیفه",
direction: relDirection, direction: relDirection,
relation_type: relType, relation_type: relType,
lag_days: relLag, lag_days: Number(relLag) || 0,
}, },
]); ]);
setRelTargetId(""); setRelTargetId("");
setRelLag(0); setRelLag("");
}} }}
className="inline-flex h-7 items-center justify-center gap-1 rounded-lg border border-sky-300 bg-sky-50 px-2.5 text-[11px] font-semibold text-sky-700 hover:bg-sky-100 transition-colors" className="inline-flex h-7 items-center justify-center gap-1 rounded-lg border border-sky-300 bg-sky-50 px-2.5 text-[11px] font-semibold text-sky-700 hover:bg-sky-100 transition-colors"
> >
@@ -764,7 +766,11 @@ function TaskCreateModal({
<button type="button" onClick={onClose} className={btnSecondary}> <button type="button" onClick={onClose} className={btnSecondary}>
انصراف انصراف
</button> </button>
<button type="submit" disabled={createMutation.isPending} className={btnPrimary}> <button
type="submit"
disabled={createMutation.isPending || isDateRangeInvalid}
className={btnPrimary}
>
{createMutation.isPending ? "در حال ثبت..." : "افزودن وظیفه"} {createMutation.isPending ? "در حال ثبت..." : "افزودن وظیفه"}
</button> </button>
</div> </div>
@@ -785,7 +791,6 @@ function CreateRelationModal({
projectId: string; projectId: string;
tasks: Task[]; tasks: Task[];
initialFromTaskId: string | null; initialFromTaskId: string | null;
/** در حالت ویرایش: وابستگی موجود؛ دو سر وابستگی تغییرناپذیرند */
editRelation?: TaskRelation | null; editRelation?: TaskRelation | null;
onClose: () => void; onClose: () => void;
}) { }) {
@@ -794,24 +799,32 @@ function CreateRelationModal({
const [fromTaskId, setFromTaskId] = useState(initialFromTaskId || ""); const [fromTaskId, setFromTaskId] = useState(initialFromTaskId || "");
const [toTaskId, setToTaskId] = useState(""); const [toTaskId, setToTaskId] = useState("");
const [relationType, setRelationType] = useState<"FS" | "SS" | "FF" | "SF">("FS"); const [relationType, setRelationType] = useState<"FS" | "SS" | "FF" | "SF">("FS");
const [lagDays, setLagDays] = useState(0); const [lagDays, setLagDays] = useState<number | "">("");
const [errorMsg, setErrorMsg] = useState(""); const [errorMsg, setErrorMsg] = useState("");
useEffect(() => { useEffect(() => {
if (initialFromTaskId) setFromTaskId(initialFromTaskId); if (initialFromTaskId) setFromTaskId(initialFromTaskId);
}, [initialFromTaskId]); }, [initialFromTaskId]);
// در حالت ویرایش، فرم با مقادیر فعلی وابستگی پر می‌شود
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
if (editRelation) { if (editRelation) {
setFromTaskId(editRelation.from_task_id); setFromTaskId(editRelation.from_task_id);
setToTaskId(editRelation.to_task_id); setToTaskId(editRelation.to_task_id);
setRelationType(editRelation.relation_type); setRelationType(editRelation.relation_type);
setLagDays(editRelation.lag_days || 0); setLagDays(
editRelation.lag_days !== undefined && editRelation.lag_days !== null
? editRelation.lag_days
: ""
);
} else {
setFromTaskId(initialFromTaskId || "");
setToTaskId("");
setRelationType("FS");
setLagDays("");
} }
setErrorMsg(""); setErrorMsg("");
}, [open, editRelation]); }, [open, editRelation, initialFromTaskId]);
const invalidate = () => { const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: ["project-gantt", projectId] }); void queryClient.invalidateQueries({ queryKey: ["project-gantt", projectId] });
@@ -824,7 +837,7 @@ function CreateRelationModal({
invalidate(); invalidate();
onClose(); onClose();
setToTaskId(""); setToTaskId("");
setLagDays(0); setLagDays("");
setErrorMsg(""); setErrorMsg("");
}, },
onError: (err: any) => { onError: (err: any) => {
@@ -933,9 +946,13 @@ function CreateRelationModal({
<input <input
type="number" type="number"
value={lagDays} value={lagDays}
onChange={(e) => setLagDays(Number(e.target.value))} onChange={(e) => {
placeholder="مثلاً ۲ (گیرش بتن)" const val = e.target.value;
className={inputCls} setLagDays(val === "" ? "" : Number(val));
}}
onFocus={(e) => e.target.select()}
placeholder="0"
className={`${inputCls} [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none`}
/> />
</div> </div>
</div> </div>
@@ -967,111 +984,6 @@ function CreateRelationModal({
); );
} }
function AddMemberModal({
open,
projectId,
onClose,
}: {
open: boolean;
projectId: string;
onClose: () => void;
}) {
const queryClient = useQueryClient();
const [selectedUserId, setSelectedUserId] = useState("");
const [memberRole, setMemberRole] = useState("supervisor");
const [notes, setNotes] = useState("");
const [errorMsg, setErrorMsg] = useState("");
const usersQuery = useQuery({ queryKey: ["users"], queryFn: getUsers });
const addMutation = useMutation({
mutationFn: () =>
addProjectMember(projectId, {
user_id: selectedUserId,
role: memberRole,
notes: notes.trim() || undefined,
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["project", projectId] });
void queryClient.invalidateQueries({ queryKey: ["projects"] });
onClose();
setSelectedUserId("");
setNotes("");
setErrorMsg("");
},
onError: (err: any) => {
setErrorMsg(err?.message || "خطا در افزودن عضو به پروژه");
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!selectedUserId) {
setErrorMsg("لطفاً یک کاربر را انتخاب کنید");
return;
}
addMutation.mutate();
};
return (
<Modal open={open} title="افزودن عضو جدید به تیم پروژه" onClose={onClose}>
<form onSubmit={handleSubmit} className="space-y-3">
{errorMsg && <div className="rounded-xl bg-red-50 p-2.5 text-xs text-red-600">{errorMsg}</div>}
<div>
<label className="mb-1 block text-xs font-semibold text-slate-600">انتخاب کاربر *</label>
<select
value={selectedUserId}
onChange={(e) => setSelectedUserId(e.target.value)}
className={inputCls}
required
>
<option value="">-- انتخاب کاربر --</option>
{(usersQuery.data ?? []).map((u) => (
<option key={u.id} value={u.id}>
{u.display_name} ({u.username}) — {u.role_label}
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-slate-600">نقش در پروژه</label>
<select
value={memberRole}
onChange={(e) => setMemberRole(e.target.value)}
className={inputCls}
>
<option value="supervisor">ناظر اجرایی</option>
<option value="manager">مدیر پروژه</option>
<option value="qc_inspector">بازرس کیفیت (QC)</option>
<option value="subcontractor">پیمانکار جزء</option>
</select>
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-slate-600">یادداشت / حدود اختیارات</label>
<input
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="یادداشت اختیاری"
className={inputCls}
/>
</div>
<div className="flex items-center justify-end gap-2 pt-2">
<button type="button" onClick={onClose} className={btnSecondary}>
انصراف
</button>
<button type="submit" disabled={addMutation.isPending} className={btnPrimary}>
{addMutation.isPending ? "در حال ثبت..." : "افزودن به تیم"}
</button>
</div>
</form>
</Modal>
);
}
export default function ProjectDetailPage() { export default function ProjectDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -1082,17 +994,15 @@ export default function ProjectDetailPage() {
const canManageProjects = !isSupervisor && hasPermission(user, Perm.projectsUpdate); const canManageProjects = !isSupervisor && hasPermission(user, Perm.projectsUpdate);
const projectId = id ?? ""; const projectId = id ?? "";
const [activeTab, setActiveTab] = useState<"tasks" | "gantt" | "members">("tasks"); const [activeTab, setActiveTab] = useState<"tasks" | "gantt">("tasks");
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [expanded, setExpanded] = useState<Set<string>>(new Set()); const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [showTaskModal, setShowTaskModal] = useState(false); const [showTaskModal, setShowTaskModal] = useState(false);
const [parentTaskForModal, setParentTaskForModal] = useState<Task | null>(null); const [parentTaskForModal, setParentTaskForModal] = useState<Task | null>(null);
const [showEditModal, setShowEditModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false);
const [taskToEdit, setTaskToEdit] = useState<Task | null>(null); const [taskToEdit, setTaskToEdit] = useState<Task | null>(null);
const [showMemberModal, setShowMemberModal] = useState(false);
const [showRelationModal, setShowRelationModal] = useState(false); const [showRelationModal, setShowRelationModal] = useState(false);
const [relationFromTaskId, setRelationFromTaskId] = useState<string | null>(null); const [relationFromTaskId, setRelationFromTaskId] = useState<string | null>(null);
/** وابستگی در حال ویرایش (null = حالت تعریف جدید) */
const [relationToEdit, setRelationToEdit] = useState<TaskRelation | null>(null); const [relationToEdit, setRelationToEdit] = useState<TaskRelation | null>(null);
const initializedFor = useRef<string | null>(null); const initializedFor = useRef<string | null>(null);
@@ -1157,21 +1067,12 @@ export default function ProjectDetailPage() {
setShowRelationModal(true); setShowRelationModal(true);
}; };
/** ویرایش یک وابستگی موجود از داخل کشوی تسک */
const handleEditRelation = (relation: TaskRelation) => { const handleEditRelation = (relation: TaskRelation) => {
setRelationFromTaskId(null); setRelationFromTaskId(null);
setRelationToEdit(relation); setRelationToEdit(relation);
setShowRelationModal(true); setShowRelationModal(true);
}; };
const removeMemberMutation = useMutation({
mutationFn: (memberId: string) => removeProjectMember(projectId, memberId),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["project", projectId] });
void queryClient.invalidateQueries({ queryKey: ["projects"] });
},
});
const deleteRelationMutation = useMutation({ const deleteRelationMutation = useMutation({
mutationFn: (relId: string) => deleteTaskRelation(projectId, relId), mutationFn: (relId: string) => deleteTaskRelation(projectId, relId),
onSuccess: () => { onSuccess: () => {
@@ -1189,18 +1090,6 @@ export default function ProjectDetailPage() {
}, },
}); });
const updateStatusMutation = useMutation({
mutationFn: (newStatus: string) => updateProject(projectId, { status: newStatus }),
onSuccess: () => {
toast.success("وضعیت پروژه با موفقیت تغییر کرد");
void queryClient.invalidateQueries({ queryKey: ["project", projectId] });
void queryClient.invalidateQueries({ queryKey: ["projects"] });
},
onError: (err: any) => {
toast.error(err?.message || "خطا در تغییر وضعیت پروژه");
},
});
const refresh = () => { const refresh = () => {
void projectQuery.refetch(); void projectQuery.refetch();
void tasksQuery.refetch(); void tasksQuery.refetch();
@@ -1247,58 +1136,6 @@ export default function ProjectDetailPage() {
<p className="text-xs font-mono font-semibold text-sky-600">{project.identifier}</p> <p className="text-xs font-mono font-semibold text-sky-600">{project.identifier}</p>
<h2 className="text-base font-bold text-slate-800 mt-0.5">{project.name}</h2> <h2 className="text-base font-bold text-slate-800 mt-0.5">{project.name}</h2>
</div> </div>
<div className="flex items-center gap-1.5">
<span className="text-xs text-slate-500 font-medium">وضعیت:</span>
{canManageProjects ? (
<select
value={project.status?.toUpperCase() || "ACTIVE"}
onChange={(e) => updateStatusMutation.mutate(e.target.value)}
disabled={updateStatusMutation.isPending}
aria-label="تغییر وضعیت پروژه"
className={`rounded-lg px-2.5 py-1 text-xs font-semibold border transition-all cursor-pointer shadow-sm ${
(project.status || "").toUpperCase() === "ACTIVE"
? "bg-emerald-50 text-emerald-700 border-emerald-300 focus:ring-emerald-200"
: (project.status || "").toUpperCase() === "COMPLETED"
? "bg-sky-50 text-sky-700 border-sky-300 focus:ring-sky-200"
: (project.status || "").toUpperCase() === "ON_HOLD"
? "bg-amber-50 text-amber-700 border-amber-300 focus:ring-amber-200"
: (project.status || "").toUpperCase() === "PLANNED" || (project.status || "").toUpperCase() === "PLANNING"
? "bg-amber-50 text-amber-700 border-amber-300 focus:ring-amber-200"
: "bg-slate-50 text-slate-700 border-slate-300 focus:ring-slate-200"
}`}
>
<option value="ACTIVE">در حال اجرا (ACTIVE)</option>
<option value="PLANNED">برنامه‌ریزی (PLANNED)</option>
<option value="ON_HOLD">متوقف (ON_HOLD)</option>
<option value="COMPLETED">تکمیل‌شده (COMPLETED)</option>
<option value="CANCELLED">لغو شده (CANCELLED)</option>
</select>
) : (
<Chip
tone={
project.status === "ACTIVE"
? "green"
: project.status === "COMPLETED"
? "sky"
: project.status === "ON_HOLD" || project.status === "PLANNED"
? "amber"
: "slate"
}
>
{project.status === "ACTIVE"
? "در حال اجرا"
: project.status === "PLANNED" || project.status === "PLANNING"
? "برنامه‌ریزی"
: project.status === "ON_HOLD"
? "متوقف"
: project.status === "COMPLETED"
? "تکمیل‌شده"
: project.status === "CANCELLED"
? "لغو شده"
: project.status}
</Chip>
)}
</div>
</div> </div>
{project.location && ( {project.location && (
@@ -1348,7 +1185,6 @@ export default function ProjectDetailPage() {
</Card> </Card>
)} )}
{/* ۳ تب: ساختار شکست کار / گانت و زمان‌بندی / اعضای تیم */}
<div className="mb-3 flex rounded-xl bg-slate-200/80 p-1 text-xs font-semibold text-slate-600"> <div className="mb-3 flex rounded-xl bg-slate-200/80 p-1 text-xs font-semibold text-slate-600">
<button <button
type="button" type="button"
@@ -1368,15 +1204,6 @@ export default function ProjectDetailPage() {
> >
نمودار گانت و زمان‌بندی نمودار گانت و زمان‌بندی
</button> </button>
<button
type="button"
onClick={() => setActiveTab("members")}
className={`flex-1 rounded-lg py-1.5 text-center transition-all ${
activeTab === "members" ? "bg-white text-sky-700 shadow-sm" : "hover:text-slate-800"
}`}
>
اعضای تیم ({project?.members?.length ?? project?.member_count ?? 0})
</button>
</div> </div>
{activeTab === "tasks" && ( {activeTab === "tasks" && (
@@ -1489,69 +1316,6 @@ export default function ProjectDetailPage() {
)} )}
</div> </div>
)} )}
{activeTab === "members" && (
<div>
<div className="mb-3 flex items-center justify-between">
<h3 className="text-xs font-bold text-slate-700">اعضای اختصاص‌یافته به این پروژه</h3>
{canManageProjects && (
<button
type="button"
onClick={() => setShowMemberModal(true)}
className="inline-flex h-8 items-center gap-1 rounded-lg bg-sky-50 px-2.5 text-xs font-semibold text-sky-700 hover:bg-sky-100 transition-colors"
>
<UserPlus size={14} />
<span>افزودن عضو</span>
</button>
)}
</div>
{(!project?.members || project.members.length === 0) ? (
<Card>
<EmptyState
icon={Users}
title="عضوی ثبت نشده است"
description="برای تخصیص ناظران یا کارشناسان به این پروژه، دکمه «افزودن عضو» را بزنید."
/>
</Card>
) : (
<div className="space-y-2">
{project.members.map((m: ProjectMember) => (
<Card key={m.id} className="p-3 flex items-center justify-between gap-2">
<div className="flex items-center gap-2.5 min-w-0">
<InitialAvatar name={m.display_name || m.username} className="h-8 w-8" />
<div className="min-w-0">
<p className="text-xs font-bold text-slate-800 truncate">
{m.display_name || m.username}
</p>
<p className="text-[11px] text-slate-400">
{m.role === "manager"
? "مدیر پروژه"
: m.role === "supervisor"
? "ناظر اجرایی"
: m.role === "qc_inspector"
? "بازرس QC"
: m.role}
</p>
</div>
</div>
{canManageProjects && (
<button
type="button"
onClick={() => removeMemberMutation.mutate(m.id)}
disabled={removeMemberMutation.isPending}
title="حذف عضو"
className="flex h-8 w-8 items-center justify-center rounded-lg text-slate-400 hover:bg-red-50 hover:text-red-600 transition-colors"
>
<Trash2 size={15} />
</button>
)}
</Card>
))}
</div>
)}
</div>
)}
</div> </div>
<TaskCreateModal <TaskCreateModal
@@ -1588,13 +1352,6 @@ export default function ProjectDetailPage() {
setRelationToEdit(null); setRelationToEdit(null);
}} }}
/> />
<AddMemberModal
open={showMemberModal}
projectId={projectId}
onClose={() => setShowMemberModal(false)}
/>
</> </>
); );
} }
+131 -97
View File
@@ -62,6 +62,16 @@ import { isoToApiDate } from "../lib/jalali";
const quickValues = [0, 25, 50, 75, 100]; const quickValues = [0, 25, 50, 75, 100];
function parseCleanNumber(raw: string): number {
if (!raw) return 0;
const clean = raw
.replace(/[۰-۹]/g, (d) => "۰۱۲۳۴۵۶۷۸۹".indexOf(d).toString())
.replace(/[٠-٩]/g, (d) => "٠١٢٣٤٥٦٧٨٩".indexOf(d).toString())
.replace(/[^0-9.]/g, "");
const val = parseFloat(clean);
return isNaN(val) ? 0 : Math.max(0, val);
}
function SectionCard({ title, children }: { title: ReactNode; children: ReactNode }) { function SectionCard({ title, children }: { title: ReactNode; children: ReactNode }) {
return ( return (
<Card className="p-4"> <Card className="p-4">
@@ -71,10 +81,6 @@ function SectionCard({ title, children }: { title: ReactNode; children: ReactNod
); );
} }
// ----------------------------------------
// صفحه جزئیات وظیفه
// ----------------------------------------
export default function TaskDetailPage() { export default function TaskDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -99,7 +105,6 @@ export default function TaskDetailPage() {
const [showEditModal, setShowEditModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false);
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
/** ISO میلادی — ارسال با isoToApiDate */
const [startDate, setStartDate] = useState<string | null>(null); const [startDate, setStartDate] = useState<string | null>(null);
const [dueDate, setDueDate] = useState<string | null>(null); const [dueDate, setDueDate] = useState<string | null>(null);
@@ -109,7 +114,14 @@ export default function TaskDetailPage() {
const [consumedMaterials, setConsumedMaterials] = useState<MaterialConsumption[]>([]); const [consumedMaterials, setConsumedMaterials] = useState<MaterialConsumption[]>([]);
const [progressNote, setProgressNote] = useState(""); const [progressNote, setProgressNote] = useState("");
// محاسبه مجموع مصارف ثبت‌شده قبلی از سوابق گزارش‌های وظیفه const isDateRangeInvalid = Boolean(
startDate &&
dueDate &&
isoToApiDate(startDate) &&
isoToApiDate(dueDate) &&
isoToApiDate(startDate)! > isoToApiDate(dueDate)!
);
const pastConsumptionMap = useMemo(() => { const pastConsumptionMap = useMemo(() => {
const map = new Map<string, { total: number; unit: string }>(); const map = new Map<string, { total: number; unit: string }>();
if (!task?.progress_logs) return map; if (!task?.progress_logs) return map;
@@ -148,7 +160,6 @@ export default function TaskDetailPage() {
setConsumedHr([]); setConsumedHr([]);
} }
// یکپارچه‌سازی مصالح تخصیص‌یافته و مصارف قبلی
const items: MaterialConsumption[] = []; const items: MaterialConsumption[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
@@ -158,6 +169,7 @@ export default function TaskDetailPage() {
seen.add(key); seen.add(key);
const past = pastConsumptionMap.get(key)?.total || 0; const past = pastConsumptionMap.get(key)?.total || 0;
const alloc = Number(m.count) || 0; const alloc = Number(m.count) || 0;
const isOver = alloc > 0 && past > alloc;
items.push({ items.push({
name: m.name, name: m.name,
unit: m.unit || "", unit: m.unit || "",
@@ -166,14 +178,13 @@ export default function TaskDetailPage() {
previous_consumed: past, previous_consumed: past,
total_consumed: past, total_consumed: past,
remaining_amount: Math.max(0, alloc - past), remaining_amount: Math.max(0, alloc - past),
is_over_allocated: alloc > 0 ? past > alloc : past > 0, is_over_allocated: isOver,
over_allocated_amount: Math.max(0, past - alloc), over_allocated_amount: isOver ? past - alloc : 0,
note: "", note: "",
}); });
} }
} }
// مصالحی که در سوابق قبل مصرف شده‌اند ولی در تخصیص اولیه نبوده‌اند
pastConsumptionMap.forEach((val, key) => { pastConsumptionMap.forEach((val, key) => {
if (!seen.has(key)) { if (!seen.has(key)) {
items.push({ items.push({
@@ -184,8 +195,8 @@ export default function TaskDetailPage() {
previous_consumed: val.total, previous_consumed: val.total,
total_consumed: val.total, total_consumed: val.total,
remaining_amount: 0, remaining_amount: 0,
is_over_allocated: val.total > 0, is_over_allocated: false,
over_allocated_amount: val.total, over_allocated_amount: 0,
note: "خارج از سقف مصوب", note: "خارج از سقف مصوب",
}); });
} }
@@ -197,14 +208,12 @@ export default function TaskDetailPage() {
const invalidateTask = () => { const invalidateTask = () => {
void queryClient.invalidateQueries({ queryKey: ["task", taskIdParam] }); void queryClient.invalidateQueries({ queryKey: ["task", taskIdParam] });
// کلید QC با همان شناسهای است که صفحههای QC استفاده میکنند
void queryClient.invalidateQueries({ queryKey: ["task-qc", currentTaskId] }); void queryClient.invalidateQueries({ queryKey: ["task-qc", currentTaskId] });
void queryClient.invalidateQueries({ queryKey: ["today"] }); void queryClient.invalidateQueries({ queryKey: ["today"] });
void queryClient.invalidateQueries({ queryKey: ["tasks"] }); void queryClient.invalidateQueries({ queryKey: ["tasks"] });
}; };
const progressMutation = useMutation({ const progressMutation = useMutation({
// ثبت پیشرفت کار همراه با مصرف واقعی نیروی انسانی و مصالح
mutationFn: (payload: TaskProgressPayload) => updateTaskProgress(currentTaskId, payload), mutationFn: (payload: TaskProgressPayload) => updateTaskProgress(currentTaskId, payload),
onSuccess: () => { onSuccess: () => {
toast.success("پیشرفت کار و گزارش مصرف منابع با موفقیت ثبت شد"); toast.success("پیشرفت کار و گزارش مصرف منابع با موفقیت ثبت شد");
@@ -215,8 +224,7 @@ export default function TaskDetailPage() {
}); });
const handleMaterialAmountChange = (idx: number, rawVal: string) => { const handleMaterialAmountChange = (idx: number, rawVal: string) => {
const val = parseFloat(rawVal); const amt = parseCleanNumber(rawVal);
const amt = Math.max(0, isNaN(val) ? 0 : val);
setConsumedMaterials((prev) => { setConsumedMaterials((prev) => {
const next = [...prev]; const next = [...prev];
const item = { ...next[idx] }; const item = { ...next[idx] };
@@ -224,8 +232,8 @@ export default function TaskDetailPage() {
const prevC = item.previous_consumed ?? 0; const prevC = item.previous_consumed ?? 0;
const tot = prevC + amt; const tot = prevC + amt;
const rem = Math.max(0, alloc - tot); const rem = Math.max(0, alloc - tot);
const isOver = alloc > 0 ? tot > alloc : tot > 0; const isOver = alloc > 0 && tot > alloc;
const overAmt = Math.max(0, tot - alloc); const overAmt = isOver ? tot - alloc : 0;
item.amount = amt; item.amount = amt;
item.total_consumed = tot; item.total_consumed = tot;
@@ -250,7 +258,7 @@ export default function TaskDetailPage() {
...prev, ...prev,
{ {
name: "", name: "",
unit: "", unit: "عدد",
amount: 0, amount: 0,
allocated_amount: 0, allocated_amount: 0,
previous_consumed: 0, previous_consumed: 0,
@@ -258,7 +266,7 @@ export default function TaskDetailPage() {
remaining_amount: 0, remaining_amount: 0,
is_over_allocated: false, is_over_allocated: false,
over_allocated_amount: 0, over_allocated_amount: 0,
note: "مصالح مازاد / خارج از برنامه", note: "مصالح خارج از برنامه",
}, },
]); ]);
}; };
@@ -273,7 +281,7 @@ export default function TaskDetailPage() {
const start = isoToApiDate(startDate); const start = isoToApiDate(startDate);
const due = isoToApiDate(dueDate); const due = isoToApiDate(dueDate);
if (start && due && start > due) { if (start && due && start > due) {
throw new ApiError("تاریخ شروع یک وظیفه نمی‌تواند بعد از تاریخ اتمام آن باشد", 400, "VALIDATION"); throw new ApiError("تاریخ پایان نمی‌تواند کمتر از تاریخ شروع باشد", 400, "VALIDATION");
} }
if (start) payload.start_date = start; if (start) payload.start_date = start;
if (due) payload.due_date = due; if (due) payload.due_date = due;
@@ -286,7 +294,6 @@ export default function TaskDetailPage() {
onError: (err) => toast.error(err instanceof ApiError ? err.message : "ثبت تاریخ‌ها ناموفق بود"), onError: (err) => toast.error(err instanceof ApiError ? err.message : "ثبت تاریخ‌ها ناموفق بود"),
}); });
// ۱. وضعیت و جهش ویرایش منابع تخصیص‌یافته به وظیفه
const [showEditResourcesModal, setShowEditResourcesModal] = useState(false); const [showEditResourcesModal, setShowEditResourcesModal] = useState(false);
const [editMaterials, setEditMaterials] = useState<MaterialItem[]>([]); const [editMaterials, setEditMaterials] = useState<MaterialItem[]>([]);
@@ -310,7 +317,6 @@ export default function TaskDetailPage() {
toast.error(err instanceof ApiError ? err.message : "خطا در به‌روزرسانی منابع وظیفه"), toast.error(err instanceof ApiError ? err.message : "خطا در به‌روزرسانی منابع وظیفه"),
}); });
// ۲. وضعیت و جهش‌های مدیریت روابط وابستگی (FS, SS, FF, SF)
const [showAddRelationModal, setShowAddRelationModal] = useState(false); const [showAddRelationModal, setShowAddRelationModal] = useState(false);
const [relTargetId, setRelTargetId] = useState(""); const [relTargetId, setRelTargetId] = useState("");
const [relDirection, setRelDirection] = useState<"predecessor" | "successor">("predecessor"); const [relDirection, setRelDirection] = useState<"predecessor" | "successor">("predecessor");
@@ -336,7 +342,7 @@ export default function TaskDetailPage() {
} }
}, },
onError: (err: any) => { onError: (err: any) => {
toast.error(err?.message || "خطا در ایجاد رابطه وابستگی (احتمال وجود دور یا ورودی نامعتبر)"); toast.error(err?.message || "خطا در ایجاد رابطه وابستگی");
}, },
}); });
@@ -399,8 +405,6 @@ export default function TaskDetailPage() {
const projectLabel = project?.name ?? "—"; const projectLabel = project?.name ?? "—";
const overdue = isOverdue(task.due_date); const overdue = isOverdue(task.due_date);
// مجوزهای نقش: ناظر پیشرفت را ثبت و بازرسی را اجرا می‌کند، اما ساختار QC و
// تاریخ‌های برنامه و ویرایش تسک را تغییر نمی‌دهد (سرور هم مستقل همین را بررسی می‌کند).
const isSupervisor = (user?.role as string) === "project_supervisor" || (user?.role as string) === "supervisor"; const isSupervisor = (user?.role as string) === "project_supervisor" || (user?.role as string) === "supervisor";
const canDefineQc = !isSupervisor && hasPermission(user, Perm.qcDefine); const canDefineQc = !isSupervisor && hasPermission(user, Perm.qcDefine);
const canExecuteQc = hasPermission(user, Perm.qcExecute) || isSupervisor; const canExecuteQc = hasPermission(user, Perm.qcExecute) || isSupervisor;
@@ -419,7 +423,6 @@ export default function TaskDetailPage() {
const preStatus = qcSummaryStatusMeta(readiness?.pre?.status || "none"); const preStatus = qcSummaryStatusMeta(readiness?.pre?.status || "none");
const execStatus = qcSummaryStatusMeta(readiness?.exec?.status || "none"); const execStatus = qcSummaryStatusMeta(readiness?.exec?.status || "none");
// پیش‌بینی قواعد سرور: شروع نیازمند تأیید «پیش از اجرا» و ۱۰۰٪ نیازمند تأیید همه بازرسی‌ها
const blockedByStart = progress > 0 && !readiness.can_start; const blockedByStart = progress > 0 && !readiness.can_start;
const blockedByComplete = progress >= 100 && !readiness.can_complete; const blockedByComplete = progress >= 100 && !readiness.can_complete;
const progressBlocked = blockedByStart || blockedByComplete; const progressBlocked = blockedByStart || blockedByComplete;
@@ -451,7 +454,6 @@ export default function TaskDetailPage() {
/> />
<div className="space-y-3 px-4 pb-28 pt-4"> <div className="space-y-3 px-4 pb-28 pt-4">
{/* کارت اطلاعات اصلی */}
<Card className="p-4"> <Card className="p-4">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<p className="text-base font-bold leading-6 text-slate-800">{task.subject}</p> <p className="text-base font-bold leading-6 text-slate-800">{task.subject}</p>
@@ -493,7 +495,7 @@ export default function TaskDetailPage() {
</div> </div>
</Card> </Card>
{/* کارت یکپارچه پیشرفت، تخصیص و گزارش مصرف مصالح و نیروی انسانی */} {/* کارت پیشرفت و مدیریت یکپارچه مصالح */}
<SectionCard <SectionCard
title={ title={
<div className="flex items-center justify-between w-full"> <div className="flex items-center justify-between w-full">
@@ -538,7 +540,6 @@ export default function TaskDetailPage() {
className="mt-3 w-full accent-sky-600" className="mt-3 w-full accent-sky-600"
aria-label="درصد پیشرفت" aria-label="درصد پیشرفت"
/> />
{/* dir=ltr فقط برای ترتیب چشمی دکمه‌ها: ۰ تا ۱۰۰ از چپ به راست */}
<div dir="ltr" className="mt-2 grid grid-cols-5 gap-1.5"> <div dir="ltr" className="mt-2 grid grid-cols-5 gap-1.5">
{quickValues.map((v) => ( {quickValues.map((v) => (
<button <button
@@ -572,7 +573,7 @@ export default function TaskDetailPage() {
</p> </p>
)} )}
{/* ۱. بخش مصالح و تجهیزات: تخصیص، مصارف قبلی، مصرف جاری و مانده */} {/* بخش مصالح و تجهیزات مصرفی */}
<div className="mt-4 rounded-xl border border-slate-200 bg-white p-3 space-y-3 shadow-2xs"> <div className="mt-4 rounded-xl border border-slate-200 bg-white p-3 space-y-3 shadow-2xs">
<div className="flex items-center justify-between border-b border-slate-100 pb-2 gap-2"> <div className="flex items-center justify-between border-b border-slate-100 pb-2 gap-2">
<span className="text-xs font-bold text-slate-900 flex items-center gap-1.5 min-w-0"> <span className="text-xs font-bold text-slate-900 flex items-center gap-1.5 min-w-0">
@@ -582,9 +583,9 @@ export default function TaskDetailPage() {
<button <button
type="button" type="button"
onClick={handleAddCustomMaterial} onClick={handleAddCustomMaterial}
className="shrink-0 text-[10px] font-semibold text-amber-800 hover:text-amber-950 bg-amber-50 border border-amber-200 rounded-lg px-2 py-1 flex items-center gap-1 shadow-2xs hover:bg-amber-100 transition-colors" className="shrink-0 text-[11px] font-semibold text-amber-800 hover:text-amber-950 bg-amber-50 border border-amber-200 rounded-lg px-2.5 py-1 flex items-center gap-1 shadow-2xs hover:bg-amber-100 transition-colors"
> >
<Plus size={12} /> <Plus size={13} />
<span>افزودن مصالح خارج برنامه</span> <span>افزودن مصالح خارج برنامه</span>
</button> </button>
</div> </div>
@@ -594,15 +595,15 @@ export default function TaskDetailPage() {
مصالح یا تجهیزاتی برای این وظیفه تعیین نشده است. می‌توانید با دکمه بالا مصالح جدید اضافه کنید. مصالح یا تجهیزاتی برای این وظیفه تعیین نشده است. می‌توانید با دکمه بالا مصالح جدید اضافه کنید.
</div> </div>
) : ( ) : (
<div className="space-y-2.5"> <div className="space-y-3">
{consumedMaterials.map((item, idx) => { {consumedMaterials.map((item, idx) => {
const alloc = item.allocated_amount || 0; const alloc = item.allocated_amount || 0;
const prevC = item.previous_consumed || 0; const prevC = item.previous_consumed || 0;
const currentC = item.amount || 0; const currentC = item.amount || 0;
const totC = prevC + currentC; const totC = prevC + currentC;
const rem = Math.max(0, alloc - totC); const rem = Math.max(0, alloc - totC);
const isOver = alloc > 0 ? totC > alloc : totC > 0; const isOver = alloc > 0 && totC > alloc;
const overAmt = Math.max(0, totC - alloc); const overAmt = isOver ? totC - alloc : 0;
const pctConsumed = alloc > 0 ? Math.min(100, Math.round((totC / alloc) * 100)) : 100; const pctConsumed = alloc > 0 ? Math.min(100, Math.round((totC / alloc) * 100)) : 100;
const specs = const specs =
task?.materials?.find( task?.materials?.find(
@@ -612,40 +613,50 @@ export default function TaskDetailPage() {
return ( return (
<div <div
key={idx} key={idx}
className={`rounded-xl border p-2.5 space-y-2.5 bg-white shadow-2xs transition-all ${ className={`rounded-xl border p-3 space-y-3 bg-white shadow-2xs transition-all ${
isOver ? "border-rose-300 ring-1 ring-rose-200 bg-rose-50/10" : "border-slate-200" isOver ? "border-rose-300 ring-1 ring-rose-200 bg-rose-50/10" : "border-slate-200"
}`} }`}
> >
{/* ۱. سطر عنوان، واحد و مشخصات کالا */}
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
{alloc === 0 ? ( {alloc === 0 ? (
<div className="flex items-center gap-1.5 flex-wrap"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-2 w-full">
<div>
<label className="text-[10px] font-semibold text-slate-500 block mb-0.5">
نام مصالح یا کالا
</label>
<input <input
value={item.name} value={item.name}
onChange={(e) => handleMaterialFieldChange(idx, "name", e.target.value)} onChange={(e) => handleMaterialFieldChange(idx, "name", e.target.value)}
placeholder="نام مصالح..." placeholder="مثلاً: آجر فشاری، سیمان..."
className="h-7 w-36 rounded border border-amber-300 bg-amber-50/40 px-2 text-xs font-bold text-slate-800 focus:outline-none focus:ring-1 focus:ring-amber-500" className="h-8 w-full rounded-lg border border-amber-300 bg-amber-50/30 px-2.5 text-xs font-bold text-slate-800 focus:outline-none focus:ring-1 focus:ring-amber-500"
/> />
</div>
<div>
<label className="text-[10px] font-semibold text-slate-500 block mb-0.5">
واحد سنجش
</label>
<input <input
value={item.unit} value={item.unit}
onChange={(e) => handleMaterialFieldChange(idx, "unit", e.target.value)} onChange={(e) => handleMaterialFieldChange(idx, "unit", e.target.value)}
placeholder="واحد" placeholder="مثلاً: عدد، تن، پاکت..."
className="h-7 w-16 rounded border border-amber-300 bg-amber-50/40 px-2 text-xs text-slate-700 focus:outline-none focus:ring-1 focus:ring-amber-500" className="h-8 w-full rounded-lg border border-amber-300 bg-amber-50/30 px-2.5 text-xs text-slate-800 focus:outline-none focus:ring-1 focus:ring-amber-500"
/> />
</div> </div>
</div>
) : ( ) : (
<div className="flex items-baseline gap-1.5"> <div className="flex items-baseline gap-1.5">
<span className="font-extrabold text-slate-900 text-[13px]">{item.name}</span> <span className="font-extrabold text-slate-900 text-sm">{item.name}</span>
{item.unit && <span className="text-[11px] text-slate-400 font-medium">({item.unit})</span>} {item.unit && <span className="text-xs text-slate-500 font-medium">({item.unit})</span>}
</div> </div>
)} )}
{specs.length > 0 && ( {specs.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1"> <div className="mt-1.5 flex flex-wrap gap-1">
{specs.map((s, si) => ( {specs.map((s, si) => (
<span <span
key={si} key={si}
className="rounded bg-slate-100 text-slate-600 px-1.5 py-0.5 text-[9.5px] border border-slate-200" className="rounded bg-slate-100 text-slate-600 px-1.5 py-0.5 text-[10px] border border-slate-200"
> >
{s} {s}
</span> </span>
@@ -654,17 +665,21 @@ export default function TaskDetailPage() {
)} )}
</div> </div>
{/* نشانگر وضعیت سقف یا دکمه حذف */} <div className="shrink-0 pt-0.5">
<div className="shrink-0">
{alloc === 0 ? ( {alloc === 0 ? (
<div className="flex items-center gap-1.5">
<span className="rounded-md px-2 py-0.5 text-[10px] font-bold border bg-amber-50 text-amber-700 border-amber-200">
خارج از برنامه
</span>
<button <button
type="button" type="button"
onClick={() => handleRemoveMaterialRow(idx)} onClick={() => handleRemoveMaterialRow(idx)}
className="text-slate-400 hover:text-rose-600 p-1 rounded-md hover:bg-rose-50 transition-colors" className="text-slate-400 hover:text-rose-600 p-1 rounded-md hover:bg-rose-50 transition-colors"
title="حذف ردیف" title="حذف ردیف"
> >
<Trash2 size={14} /> <Trash2 size={15} />
</button> </button>
</div>
) : ( ) : (
<span <span
className={`rounded-md px-2 py-0.5 text-[10px] font-bold border ${ className={`rounded-md px-2 py-0.5 text-[10px] font-bold border ${
@@ -673,74 +688,94 @@ export default function TaskDetailPage() {
: "bg-emerald-50 text-emerald-700 border-emerald-200" : "bg-emerald-50 text-emerald-700 border-emerald-200"
}`} }`}
> >
{isOver ? "⚠️ مازاد" : "مجاز"} {isOver ? "⚠️ مازاد بر سقف" : "مجاز"}
</span> </span>
)} )}
</div> </div>
</div> </div>
{/* ۲. جدول ۳بخشی منظم حسابداری مصالح (مقدار کل مصوب، مصرف تاکنون، مانده قابل مصرف) */} <div className="grid grid-cols-3 divide-x divide-x-reverse divide-slate-200 rounded-lg bg-slate-50 border border-slate-200 py-2 text-center text-xs">
<div className="grid grid-cols-3 divide-x divide-x-reverse divide-slate-200 rounded-lg bg-slate-50 border border-slate-200 py-1.5 text-center">
<div className="px-1"> <div className="px-1">
<span className="text-slate-400 block text-[9.5px]">مقدار کل (سقف)</span> <span className="text-slate-400 block text-[10px]">سقف مصوب</span>
<span className="font-bold text-slate-800 text-[11.5px] mt-0.5 block"> <span className="font-bold text-slate-800 text-xs mt-0.5 block">
{alloc > 0 ? `${alloc} ${item.unit || ""}` : "نامحدود"} {alloc > 0 ? (
<>
<span className="tabular-nums">{alloc}</span>{" "}
<span className="text-slate-500 font-normal">{item.unit}</span>
</>
) : (
<span className="text-slate-500 font-medium">بدون سقف</span>
)}
</span> </span>
</div> </div>
<div className="px-1"> <div className="px-1">
<span className="text-slate-400 block text-[9.5px]">مصرف تاکنون</span> <span className="text-slate-400 block text-[10px]">مصرف تاکنون</span>
<span className="font-bold text-slate-700 text-[11.5px] mt-0.5 block"> <span className="font-bold text-slate-700 text-xs mt-0.5 block">
{prevC} {item.unit || ""} <span className="tabular-nums">{prevC}</span>{" "}
<span className="text-slate-500 font-normal">{item.unit}</span>
</span> </span>
</div> </div>
<div className="px-1"> <div className="px-1">
<span className={`block text-[9.5px] ${isOver ? "text-rose-600 font-bold" : "text-emerald-700 font-bold"}`}> <span
{isOver ? "مازاد سقف" : "مانده قابل مصرف"} className={`block text-[10px] font-bold ${
isOver ? "text-rose-600" : alloc > 0 ? "text-emerald-700" : "text-sky-700"
}`}
>
{isOver ? "مازاد سقف" : alloc > 0 ? "مانده مجاز" : "کل مصرفی"}
</span> </span>
<span className={`font-black text-[11.5px] mt-0.5 block ${isOver ? "text-rose-600" : "text-emerald-700"}`}> <span
{isOver ? `+${overAmt} ${item.unit || ""}` : `${rem} ${item.unit || ""}`} className={`font-black text-xs mt-0.5 block ${
isOver ? "text-rose-600" : alloc > 0 ? "text-emerald-700" : "text-sky-700"
}`}
>
{isOver ? (
<span dir="ltr">+{overAmt} {item.unit}</span>
) : alloc > 0 ? (
<span>{rem} {item.unit}</span>
) : (
<span>{totC} {item.unit}</span>
)}
</span> </span>
</div> </div>
</div> </div>
{/* ۳. ورودی شاخص و مستقل «مصرف این گزارش *» */} <div className="rounded-lg bg-amber-50/60 border border-amber-200 p-2.5">
<div className="space-y-1 rounded-lg bg-amber-50/70 border border-amber-200 p-2"> <div className="flex items-center justify-between gap-3">
<div className="flex items-center justify-between gap-2"> <label className="text-xs font-bold text-amber-950 flex items-center gap-1.5 shrink-0">
<label className="text-[11.5px] font-black text-amber-950 flex items-center gap-1.5 shrink-0">
<span className="h-2 w-2 rounded-full bg-amber-500 animate-pulse" /> <span className="h-2 w-2 rounded-full bg-amber-500 animate-pulse" />
<span>مصرف این گزارش *:</span> <span>مصرف این گزارش *:</span>
</label> </label>
<div className="flex items-center rounded-lg border-2 border-amber-400 bg-white shadow-xs focus-within:border-amber-600 focus-within:ring-2 focus-within:ring-amber-200"> <div className="relative flex items-center w-36">
<input <input
type="number" type="text"
step="any" inputMode="decimal"
min={0}
value={item.amount === 0 ? "" : item.amount} value={item.amount === 0 ? "" : item.amount}
onFocus={(e) => e.target.select()}
onChange={(e) => handleMaterialAmountChange(idx, e.target.value)} onChange={(e) => handleMaterialAmountChange(idx, e.target.value)}
placeholder="۰" placeholder="۰"
className="h-8 w-24 bg-transparent px-2 text-center text-sm font-black text-slate-900 focus:outline-none" className="h-8 w-full rounded-lg border-2 border-amber-400 bg-white px-3 pe-12 text-center text-sm font-black text-slate-900 focus:border-amber-600 focus:outline-none focus:ring-2 focus:ring-amber-200"
/> />
{item.unit && ( {item.unit && (
<span className="shrink-0 bg-amber-100/90 border-s border-amber-300 px-2 py-1 text-[11px] font-bold text-amber-900 select-none"> <span className="absolute left-2 text-[11px] font-bold text-slate-500 pointer-events-none">
{item.unit} {item.unit}
</span> </span>
)} )}
</div> </div>
</div> </div>
{currentC > 0 && ( {currentC > 0 && (
<div className="pt-1 border-t border-amber-200/60 flex items-center justify-between text-[10px] text-amber-900"> <div className="mt-2 pt-1.5 border-t border-amber-200/60 flex items-center justify-between text-[11px] text-amber-900">
<span>مانده پس از ثبت این گزارش:</span> <span>مجموع پس از ثبت:</span>
<b className={`font-black ${isOver ? "text-rose-600" : "text-emerald-700"}`}> <b className="font-black text-slate-800">
{isOver ? `+${overAmt} ${item.unit} (مازاد سقف)` : `${rem} ${item.unit}`} {totC} {item.unit}
</b> </b>
</div> </div>
)} )}
</div> </div>
{/* ۴. نوار پیشرفت مصرف به سقف */}
{alloc > 0 && ( {alloc > 0 && (
<div className="space-y-1 pt-0.5"> <div className="space-y-1 pt-0.5">
<div className="flex items-center justify-between text-[9.5px] text-slate-500"> <div className="flex items-center justify-between text-[10px] text-slate-500">
<span>کل مصرف: <b className="text-slate-800">{totC}</b> از {alloc} {item.unit}</span> <span>کل مصرف: <b className="text-slate-800">{totC}</b> از {alloc} {item.unit}</span>
<span className="font-bold"> <span className="font-bold">
{totC > alloc ? `اضافه مصرف (${Math.round((totC / alloc) * 100)}٪)` : `${pctConsumed}٪ از سقف`} {totC > alloc ? `اضافه مصرف (${Math.round((totC / alloc) * 100)}٪)` : `${pctConsumed}٪ از سقف`}
@@ -757,14 +792,13 @@ export default function TaskDetailPage() {
</div> </div>
)} )}
{/* ۵. هشدار اضافه مصرف جهت ثبت در سوابق و استعلام */}
{isOver && ( {isOver && (
<p className="text-[10px] text-rose-700 font-medium flex items-center gap-1 bg-rose-50 p-1.5 rounded-lg border border-rose-200"> <div className="flex items-start gap-1.5 rounded-lg border border-rose-200 bg-rose-50 p-2 text-[11px] text-rose-700">
<AlertTriangle size={12} className="shrink-0 text-rose-600" /> <AlertTriangle size={14} className="shrink-0 text-rose-600 mt-0.5" />
<span> <span>
میزان مصرف ({totC} {item.unit}) از سقف تخصیص ({alloc}) فراتر رفته است. مازاد (+{overAmt} {item.unit}) در استعلام‌های آتی قابل رهگیری است. میزان مصرف (<strong className="tabular-nums">{totC}</strong> {item.unit}) از سقف مصوب (<strong className="tabular-nums">{alloc}</strong> {item.unit}) بیشتر است. (<span dir="ltr" className="font-bold">+{overAmt}</span> {item.unit} مازاد)
</span> </span>
</p> </div>
)} )}
</div> </div>
); );
@@ -773,7 +807,7 @@ export default function TaskDetailPage() {
)} )}
</div> </div>
{/* ۲. نیروی انسانی تحویل‌شده و حاضر در کارگاه */} {/* نیروی انسانی */}
<div className="mt-3 rounded-xl border border-sky-200 bg-sky-50/50 p-3 space-y-2.5"> <div className="mt-3 rounded-xl border border-sky-200 bg-sky-50/50 p-3 space-y-2.5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs font-bold text-sky-950 flex items-center gap-1"> <span className="text-xs font-bold text-sky-950 flex items-center gap-1">
@@ -862,7 +896,6 @@ export default function TaskDetailPage() {
)} )}
</div> </div>
{/* ۳. توضیحات و شرح گزارش روزانه کارگاه */}
<div className="mt-3 pt-2 border-t border-slate-200"> <div className="mt-3 pt-2 border-t border-slate-200">
<label className="text-[10px] font-medium text-slate-600 block mb-1"> <label className="text-[10px] font-medium text-slate-600 block mb-1">
توضیحات و گزارش روزانه کارگاه (اختیاری) توضیحات و گزارش روزانه کارگاه (اختیاری)
@@ -903,7 +936,7 @@ export default function TaskDetailPage() {
)} )}
</SectionCard> </SectionCard>
{/* کارت تاریخچه ثبت پیشرفت و مصرف منابع */} {/* کارت تاریخچه ثبت پیشرفت */}
{task.progress_logs && task.progress_logs.length > 0 && ( {task.progress_logs && task.progress_logs.length > 0 && (
<SectionCard title={`تاریخچه گزارش‌های پیشرفت و مصرف منابع (${task.progress_logs.length})`}> <SectionCard title={`تاریخچه گزارش‌های پیشرفت و مصرف منابع (${task.progress_logs.length})`}>
<div className="space-y-2.5"> <div className="space-y-2.5">
@@ -921,11 +954,10 @@ export default function TaskDetailPage() {
<div className="text-[10px] text-slate-500 flex items-center gap-1"> <div className="text-[10px] text-slate-500 flex items-center gap-1">
<Clock size={11} className="text-slate-400" /> <Clock size={11} className="text-slate-400" />
<span>{relativeTime(log.created_at)}</span> <span>{relativeTime(log.created_at)}</span>
{log.logged_by && <span>• توسط {log.logged_by}</span>} {log.logged_by && <span>توسط {log.logged_by}</span>}
</div> </div>
</div> </div>
{/* نیروی کار حاضر در این گزارش */}
{log.human_resources_used && log.human_resources_used.length > 0 && ( {log.human_resources_used && log.human_resources_used.length > 0 && (
<div> <div>
<span className="text-[10px] font-bold text-slate-600 block mb-1">نیروی کار حاضر:</span> <span className="text-[10px] font-bold text-slate-600 block mb-1">نیروی کار حاضر:</span>
@@ -944,7 +976,6 @@ export default function TaskDetailPage() {
</div> </div>
)} )}
{/* مصالح مصرفی در این گزارش */}
{log.materials_used && log.materials_used.length > 0 && ( {log.materials_used && log.materials_used.length > 0 && (
<div> <div>
<span className="text-[10px] font-bold text-slate-600 block mb-1">مصالح مصرف‌شده:</span> <span className="text-[10px] font-bold text-slate-600 block mb-1">مصالح مصرف‌شده:</span>
@@ -972,7 +1003,6 @@ export default function TaskDetailPage() {
</div> </div>
)} )}
{/* توضیحات در صورت وجود */}
{log.description && ( {log.description && (
<div className="rounded-lg bg-white p-2 border border-slate-100 text-[11px] text-slate-700 leading-relaxed"> <div className="rounded-lg bg-white p-2 border border-slate-100 text-[11px] text-slate-700 leading-relaxed">
{log.description} {log.description}
@@ -984,7 +1014,7 @@ export default function TaskDetailPage() {
</SectionCard> </SectionCard>
)} )}
{/* کارت تاریخ‌ها — برنامه‌ریزی‌شده و واقعی */} {/* کارت تاریخ‌های وظیفه */}
<SectionCard title="تاریخ‌های وظیفه"> <SectionCard title="تاریخ‌های وظیفه">
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
@@ -1001,9 +1031,17 @@ export default function TaskDetailPage() {
<JalaliDatePicker value={dueDate} onChange={setDueDate} placeholder="۱۴۰۵/۰۸/۲۶" /> <JalaliDatePicker value={dueDate} onChange={setDueDate} placeholder="۱۴۰۵/۰۸/۲۶" />
</div> </div>
</div> </div>
{isDateRangeInvalid && (
<div className="mt-2.5 flex items-center gap-2 rounded-xl border border-rose-200 bg-rose-50 p-2.5 text-xs font-bold text-rose-600">
<AlertTriangle size={15} className="shrink-0 text-rose-500" />
<span>تاریخ پایان نمی‌تواند کمتر از تاریخ شروع باشد</span>
</div>
)}
<button <button
type="button" type="button"
disabled={datesMutation.isPending} disabled={datesMutation.isPending || isDateRangeInvalid}
onClick={() => datesMutation.mutate()} onClick={() => datesMutation.mutate()}
className={`${btnSecondary} mt-2.5 w-full`} className={`${btnSecondary} mt-2.5 w-full`}
> >
@@ -1063,8 +1101,6 @@ export default function TaskDetailPage() {
/> />
</SectionCard> </SectionCard>
{/* کارت ارتباطات */} {/* کارت ارتباطات */}
<SectionCard <SectionCard
title={ title={
@@ -1215,7 +1251,6 @@ export default function TaskDetailPage() {
onUpdated={() => invalidateTask()} onUpdated={() => invalidateTask()}
/> />
{/* مودال ویرایش منابع تخصیص‌یافته به وظیفه */}
{showEditResourcesModal && ( {showEditResourcesModal && (
<Modal <Modal
open={showEditResourcesModal} open={showEditResourcesModal}
@@ -1248,7 +1283,6 @@ export default function TaskDetailPage() {
</Modal> </Modal>
)} )}
{/* مودال افزودن رابطه وابستگی جدید */}
{showAddRelationModal && ( {showAddRelationModal && (
<Modal <Modal
open={showAddRelationModal} open={showAddRelationModal}
BIN
View File
Binary file not shown.