Fix date validations, datepicker overflow, and QC components
This commit is contained in:
@@ -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
@@ -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 ? "در حال ذخیره..." : "ذخیره تغییرات"}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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)}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user