chore(pm): vendor the Aramesh/PM platform under pm/ (squash of Aramesh/PM @ 91f3271)

The PM panel (PmPanelPage -> iframe /pm/) needs the project-management
platform served at /pm/ on the ERP domain. Source: org repo Aramesh/PM,
branch feature/project-management-platform. Update via:
git subtree pull --prefix=pm aramesh-pm feature/project-management-platform --squash
This commit is contained in:
MMD
2026-10-07 15:14:20 +03:30
87 changed files with 23922 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
.git
*.log
Dockerfile
.dockerignore
+17
View File
@@ -0,0 +1,17 @@
# ---- مرحله ساخت ----
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# ---- مرحله اجرا ----
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+23
View File
@@ -0,0 +1,23 @@
<!doctype html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#f8fafc" />
<link
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%230284c7' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='8' y='2' width='8' height='4' rx='1'/%3E%3Cpath d='M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2'/%3E%3Cpath d='m9 14 2 2 4-4'/%3E%3C/svg%3E"
/>
<title>PM — مدیریت پروژه</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400..800&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+58
View File
@@ -0,0 +1,58 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
client_max_body_size 15m;
# PM روی همان دامنه ERP زیر پیشوند /pm سرو می‌شود (پنل «مدیریت پروژه»).
# پروکسی API به بک‌اند PM — هم زیر /pm/api (ادغام با ERP) هم /api (دسترسی مستقیم)
location /pm/api/ {
proxy_pass http://pm-api:8000/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api/ {
proxy_pass http://pm-api:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# SPA زیر /pm/ — نقطه ورود پنل «مدیریت پروژه» روی دامنه ERP
location /pm/ {
alias /usr/share/nginx/html/;
try_files $uri $uri/ /pm/index.html;
}
location = /pm {
return 301 /pm/;
}
location = /pm/index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
alias /usr/share/nginx/html/index.html;
}
# SPA fallback (دسترسی مستقیم به فرانت PM بدون پیشوند)
location / {
try_files $uri $uri/ /index.html;
}
# index.html همیشه بدون کش
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
try_files $uri =404;
}
# فایل‌های استاتیک هش‌دار با کش بلندمدت
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
}
+2650
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "pm-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.62.0",
"lucide-react": "^0.469.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.0",
"sonner": "^1.7.1",
"zustand": "^5.0.2"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.0",
"typescript": "~5.7.2",
"vite": "^6.0.0"
}
}
+139
View File
@@ -0,0 +1,139 @@
import { useEffect, useRef } from "react";
import type { ReactNode } from "react";
import { BrowserRouter, Navigate, Outlet, Route, Routes } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import { getMe } from "./lib/api";
import { Perm, hasPermission } from "./lib/roles";
import { sessionNeedsRefresh, useAuthStore } from "./store/auth";
import { PhoneShell, TabBar } from "./components/ui";
import LoginPage from "./pages/LoginPage";
import TodayPage from "./pages/TodayPage";
import ProjectsPage from "./pages/ProjectsPage";
import ProjectDetailPage from "./pages/ProjectDetailPage";
import TaskDetailPage from "./pages/TaskDetailPage";
import TaskQcDefinePage from "./pages/TaskQcDefinePage";
import TaskQcExecutePage from "./pages/TaskQcExecutePage";
import QualityPage from "./pages/QualityPage";
import SettingsPage from "./pages/SettingsPage";
/** ستون موبایلی برنامه — روی همه دستگاه‌ها به شکل یک اپ موبایل رندر می‌شود */
function AppLayout() {
return (
<PhoneShell>
<Outlet />
<TabBar />
</PhoneShell>
);
}
function LoadingScreen() {
return (
<PhoneShell>
<div className="flex min-h-dvh items-center justify-center text-xs text-slate-400">
در حال بارگذاری...
</div>
</PhoneShell>
);
}
/**
* نگهبان مسیرها: توکن لازم است و کاربر باید اطلاعات نقش/مجوز داشته باشد.
* نشست‌های ذخیره‌شده پیش از RBAC یک‌بار از سرور تازه می‌شوند.
*/
function RequireAuth() {
const token = useAuthStore((s) => s.token);
const user = useAuthStore((s) => s.user);
const setUser = useAuthStore((s) => s.setUser);
const queryClient = useQueryClient();
useEffect(() => {
if (!token) return;
let cancelled = false;
void getMe()
.then((fresh) => {
if (!cancelled) setUser(fresh);
})
.catch((err: { status?: number }) => {
if (err?.status === 403) {
if (window.top !== window.self) {
window.top!.location.assign("/");
} else {
window.location.assign("/");
}
}
});
return () => {
cancelled = true;
};
}, [token, setUser]);
// با عوض شدن حساب، داده‌های نشست قبلی نباید در کش بمانند.
//
// نکتهٔ مهم: این افکت نباید در mount اولیه اجرا شود. اگر با اولین رندر
// (وقتی identity تازه ساخته می‌شود) کش پاک شود، کوئری‌ای که صفحهٔ زیرین
// همین حالا شروع کرده لغو می‌شود و برای همیشه در حالت «در حال بارگذاری»
// می‌ماند. پس مقدار قبلی را نگه می‌داریم و فقط با تغییر واقعی پاک می‌کنیم.
const identity = `${user?.username ?? ""}:${user?.role ?? ""}`;
const lastIdentity = useRef<string | null>(null);
useEffect(() => {
const previous = lastIdentity.current;
lastIdentity.current = identity;
if (previous !== null && previous !== identity) {
queryClient.clear();
}
}, [identity, queryClient]);
if (!token) {
// تک‌احراز هویت با ERP/Casdoor: داخل پوسته ERP فرم ورود PM نداریم —
// سطح کل صفحه به ورود یکپارچه برمی‌گردد. مستقل (8083) همان ورود PM.
if (window.top !== window.self) {
window.top!.location.assign("/login");
return <LoadingScreen />;
}
return <Navigate to="/login" replace />;
}
if (sessionNeedsRefresh(user)) return <LoadingScreen />;
return <AppLayout />;
}
/** مسیرهایی که فقط با مجوز مشخص باز می‌شوند (سرور هم مستقل بررسی می‌کند) */
function RequirePermission({ permission, children }: { permission: string; children: ReactNode }) {
const user = useAuthStore((s) => s.user);
if (!hasPermission(user, permission)) return <Navigate to="/" replace />;
return <>{children}</>;
}
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<RequireAuth />}>
<Route path="/" element={<TodayPage />} />
<Route path="/projects" element={<ProjectsPage />} />
<Route path="/projects/:id" element={<ProjectDetailPage />} />
<Route path="/tasks/:id" element={<TaskDetailPage />} />
<Route
path="/tasks/:id/qc/define"
element={
<RequirePermission permission={Perm.qcDefine}>
<TaskQcDefinePage />
</RequirePermission>
}
/>
<Route path="/tasks/:id/qc/execute" element={<TaskQcExecutePage />} />
<Route
path="/quality"
element={
<RequirePermission permission={Perm.qcDefine}>
<QualityPage />
</RequirePermission>
}
/>
<Route path="/settings" element={<SettingsPage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
);
}
@@ -0,0 +1,56 @@
import { Component } from "react";
import type { ErrorInfo, ReactNode } from "react";
import { AlertTriangle } from "lucide-react";
interface Props {
children: ReactNode;
}
interface State {
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error("ErrorBoundary:", error, info.componentStack);
}
render() {
if (this.state.error) {
return (
<div className="flex min-h-dvh flex-col items-center justify-center gap-4 bg-slate-50 p-6 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-red-100 text-red-600">
<AlertTriangle size={26} />
</div>
<div>
<p className="text-base font-bold text-slate-800">خطای غیرمنتظره</p>
<p className="mt-1 text-xs leading-5 text-slate-500">
مشکلی در نمایش برنامه پیش آمد. لطفا دوباره تلاش کنید.
</p>
{this.state.error && (
<div className="mt-3 max-w-sm rounded-xl border border-red-200 bg-red-50 p-3 text-left" dir="ltr">
<p className="text-[11px] font-mono font-bold text-red-700">
{this.state.error.name}: {this.state.error.message}
</p>
</div>
)}
</div>
<button
type="button"
onClick={() => this.setState({ error: null })}
className="h-11 rounded-xl bg-sky-600 px-6 text-sm font-semibold text-white active:bg-sky-700"
>
تلاش مجدد
</button>
</div>
);
}
return this.props.children;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,243 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
daysInMonth,
gregorianToJalali,
isoToJalaliStr,
jalaliStrToIso,
jalaliToGregorian,
MONTHS,
toFa,
WEEKDAYS,
} from "../lib/jalali";
import { useClickOutside } from "./useClickOutside";
export interface JalaliDatePickerProps {
/** مقدار ISO (میلادی) — ممکن است زمان داشته باشد */
value: string | null;
onChange: (iso: string | null) => void;
placeholder?: string;
className?: string;
}
function todayJalali(): [number, number, number] {
const now = new Date();
return gregorianToJalali(now.getFullYear(), now.getMonth() + 1, now.getDate());
}
function isoToJalali(iso: string | null): [number, number, number] | null {
if (!iso) return null;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return null;
return gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate());
}
/** ISO با نیمه‌شب محلی — فیلدهای تاریخ‌محور PM فقط روز را حمل می‌کنند */
function buildIso(jy: number, jm: number, jd: number): string {
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
return new Date(gy, gm - 1, gd, 0, 0, 0).toISOString();
}
/** تاریخ انتخاب‌شده بر مبنای تقویم محلی (نه UTC) برای مقایسه با سلول‌های تقویم */
function localDateKey(iso: string | null): string | null {
if (!iso) return null;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return null;
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
/** تقویم شمسی — ورودی/خروجی ISO میلادی */
export function JalaliDatePicker({ value, onChange, placeholder, className = "" }: JalaliDatePickerProps) {
const [open, setOpen] = useState(false);
const [curJ, setCurJ] = useState<[number, number, number]>(() => isoToJalali(value) ?? todayJalali());
const [text, setText] = useState<string>(() => isoToJalaliStr(value));
// مقداری که خودمان تولید کرده‌ایم — تا وقتی والد از بیرون مقدار را عوض کند، متن همگام شود
const emittedRef = useRef<string | null>(value);
useEffect(() => {
if (value === emittedRef.current) return;
emittedRef.current = value;
setText(isoToJalaliStr(value));
const j = isoToJalali(value);
if (j) setCurJ(j);
}, [value]);
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
const selectedKey = localDateKey(value);
const grid = useMemo(() => {
const [jy, jm] = curJ;
const firstG = jalaliToGregorian(jy, jm, 1);
const firstWd = (new Date(firstG[0], firstG[1] - 1, firstG[2]).getDay() + 1) % 7; // شنبه = ۰
const dim = daysInMonth(jy, jm);
const today = new Date();
const todayKey = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(
today.getDate(),
).padStart(2, "0")}`;
const cells: { d: number; iso: string; isToday: boolean }[] = [];
for (let i = 0; i < firstWd; i++) cells.push({ d: 0, iso: "", isToday: false });
for (let d = 1; d <= dim; d++) {
const [gy, gm, gd] = jalaliToGregorian(jy, jm, d);
const iso = `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
cells.push({ d, iso, isToday: iso === todayKey });
}
return cells;
}, [curJ]);
const nav = (dir: "prev-month" | "next-month" | "prev-year" | "next-year") => {
setCurJ(([jy, jm]) => {
let ny = jy;
let nm = jm;
if (dir === "next-month") nm += 1;
if (dir === "prev-month") nm -= 1;
if (dir === "next-year") ny += 1;
if (dir === "prev-year") ny -= 1;
if (nm < 1) {
nm = 12;
ny -= 1;
}
if (nm > 12) {
nm = 1;
ny += 1;
}
return [ny, nm, 1];
});
};
const apply = (jy: number, jm: number, jd: number) => {
const iso = buildIso(jy, jm, jd);
emittedRef.current = iso;
onChange(iso);
setText(isoToJalaliStr(iso));
setCurJ([jy, jm, jd]);
setOpen(false);
};
const pickToday = () => {
const [jy, jm, jd] = todayJalali();
apply(jy, jm, jd);
};
const clear = () => {
emittedRef.current = null;
onChange(null);
setText("");
setOpen(false);
};
const onInputChange = (v: string) => {
setText(v);
const iso = jalaliStrToIso(v);
if (iso) {
emittedRef.current = iso;
onChange(iso);
const j = isoToJalali(iso);
if (j) setCurJ(j);
}
};
const cellBase = "flex h-8 items-center justify-center rounded-lg text-[12px] transition-colors";
return (
<div ref={ref} className={`relative ${className}`}>
<input
type="text"
dir="ltr"
inputMode="numeric"
autoComplete="off"
value={text}
placeholder={placeholder ?? "۱۴۰۵/۰۶/۲۳"}
onFocus={() => setOpen(true)}
onClick={() => setOpen(true)}
onChange={(e) => onInputChange(e.target.value)}
className="h-11 w-full rounded-xl border border-slate-300 bg-white px-3 text-left text-sm text-slate-800 placeholder:text-slate-400 focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-100"
/>
{open && (
<div className="absolute start-0 top-full z-30 mt-1 w-[264px] max-w-[calc(100vw-2rem)] rounded-2xl border border-slate-200 bg-white p-2 shadow-lg">
<div className="mb-1 flex items-center justify-between gap-0.5">
<button
type="button"
aria-label="سال قبل"
onClick={() => nav("prev-year")}
className="flex h-8 w-8 items-center justify-center rounded-lg text-sm text-slate-500 active:bg-slate-100"
>
«
</button>
<button
type="button"
aria-label="ماه قبل"
onClick={() => nav("prev-month")}
className="flex h-8 w-8 items-center justify-center rounded-lg text-base text-slate-500 active:bg-slate-100"
>
‹
</button>
<span className="text-[13px] font-bold text-slate-700">
{MONTHS[curJ[1] - 1]} {toFa(curJ[0])}
</span>
<button
type="button"
aria-label="ماه بعد"
onClick={() => nav("next-month")}
className="flex h-8 w-8 items-center justify-center rounded-lg text-base text-slate-500 active:bg-slate-100"
>
›
</button>
<button
type="button"
aria-label="سال بعد"
onClick={() => nav("next-year")}
className="flex h-8 w-8 items-center justify-center rounded-lg text-sm text-slate-500 active:bg-slate-100"
>
»
</button>
</div>
<div className="grid grid-cols-7 gap-0.5 text-center">
{WEEKDAYS.map((w) => (
<div key={w} className="py-0.5 text-[10px] font-bold text-slate-400">
{w}
</div>
))}
{grid.map((cell, i) =>
cell.d === 0 ? (
<div key={`empty-${i}`} />
) : (
<button
key={cell.iso}
type="button"
onClick={() => apply(curJ[0], curJ[1], cell.d)}
className={`${cellBase} ${
cell.iso === selectedKey
? "bg-sky-600 font-bold text-white"
: cell.isToday
? "bg-sky-50 font-bold text-sky-700 active:bg-sky-100"
: "text-slate-600 active:bg-slate-100"
}`}
>
{toFa(cell.d)}
</button>
),
)}
</div>
<div className="mt-2 flex items-center justify-between border-t border-slate-100 pt-2">
<button
type="button"
onClick={pickToday}
className="rounded-lg px-2 py-1 text-[11.5px] font-semibold text-sky-600 active:bg-sky-50"
>
امروز
</button>
<button
type="button"
onClick={clear}
className="rounded-lg px-2 py-1 text-[11.5px] font-medium text-slate-500 active:bg-slate-100"
>
پاک کردن
</button>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,194 @@
import { useState } from "react";
import { Plus, Trash2, Layers, X } from "lucide-react";
import type { HumanResourceItem, MaterialItem } from "../lib/api";
interface ResourceSectionInputsProps {
humanResources?: HumanResourceItem[];
setHumanResources?: (hrs: HumanResourceItem[]) => void;
materials: MaterialItem[];
setMaterials: (mats: MaterialItem[]) => void;
}
function TagInput({
tags,
onChange,
placeholder = "افزودن مشخصه...",
}: {
tags: string[];
onChange: (tags: string[]) => void;
placeholder?: string;
}) {
const [val, setVal] = useState("");
const addTag = () => {
const trimmed = val.trim();
if (trimmed && !tags.includes(trimmed)) {
onChange([...tags, trimmed]);
setVal("");
}
};
const removeTag = (index: number) => {
onChange(tags.filter((_, i) => i !== index));
};
return (
<div className="space-y-1.5">
<div className="flex gap-1">
<input
value={val}
onChange={(e) => setVal(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addTag();
}
}}
placeholder={placeholder}
className="h-8 flex-1 rounded-lg border border-slate-300 bg-white px-2.5 text-xs text-slate-800 placeholder:text-slate-400 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-200"
/>
<button
type="button"
onClick={addTag}
className="inline-flex h-8 items-center justify-center rounded-lg bg-slate-100 px-2.5 text-xs font-semibold text-slate-700 hover:bg-slate-200 transition-colors"
>
<Plus size={14} />
</button>
</div>
{tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{tags.map((tag, i) => (
<span
key={i}
className="inline-flex items-center gap-1 rounded-md bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-700 border border-slate-200"
>
{tag}
<button
type="button"
onClick={() => removeTag(i)}
className="text-slate-400 hover:text-rose-600 transition-colors"
aria-label="حذف مشخصه"
>
<X size={12} />
</button>
</span>
))}
</div>
)}
</div>
);
}
export function ResourceSectionInputs({
materials,
setMaterials,
}: ResourceSectionInputsProps) {
// --- Materials handlers ---
const addMaterial = () => {
setMaterials([...materials, { name: "", unit: "", specifications: [], count: 1 }]);
};
const updateMaterial = (index: number, field: keyof MaterialItem, value: any) => {
const next = [...materials];
next[index] = { ...next[index], [field]: value };
setMaterials(next);
};
const removeMaterial = (index: number) => {
setMaterials(materials.filter((_, i) => i !== index));
};
return (
<div className="rounded-xl border border-slate-200 bg-slate-50/60 p-3 space-y-3">
<div className="flex items-center justify-between border-b border-slate-200 pb-2">
<span className="text-xs font-bold text-slate-700 flex items-center gap-1.5">
<Layers size={14} className="text-amber-600" />
<span>مصالح و تجهیزات وظیفه</span>
</span>
{materials.length > 0 && (
<span className="rounded-full bg-amber-100 text-amber-700 px-2 py-0.5 text-[10px] font-bold">
{materials.length} قلم
</span>
)}
</div>
{/* --- Materials & Equipment Content --- */}
<div className="space-y-2.5">
{materials.length === 0 ? (
<p className="py-2 text-center text-xs text-slate-400">
هیچ مصالح یا تجهیزاتی تعریف نشده است.
</p>
) : (
materials.map((m, i) => (
<div
key={i}
className="rounded-lg border border-slate-200 bg-white p-2.5 shadow-sm space-y-2"
>
<div className="flex items-start gap-2">
<div className="flex-1">
<label className="mb-0.5 block text-[10px] font-medium text-slate-500">نام مصالح، کالا یا تجهیزات</label>
<input
value={m.name}
onChange={(e) => updateMaterial(i, "name", e.target.value)}
placeholder="مثلاً: سیمان تیپ ۲، میلگرد ۱۶، پمپ بتن، تاور کرین"
className="h-8 w-full rounded-md border border-slate-300 px-2 text-xs text-slate-800 focus:border-amber-500 focus:outline-none"
/>
</div>
<div className="w-24">
<label className="mb-0.5 block text-[10px] font-medium text-slate-500">واحد سنجش</label>
<input
value={m.unit || ""}
onChange={(e) => updateMaterial(i, "unit", e.target.value)}
placeholder="تن، متر، دستگاه، شاخه"
className="h-8 w-full rounded-md border border-slate-300 px-2 text-xs text-slate-800 focus:border-amber-500 focus:outline-none"
/>
</div>
<div className="w-20">
<label className="mb-0.5 block text-[10px] font-medium text-slate-500">مقدار</label>
<input
type="number"
step="any"
min={0}
value={m.count}
onChange={(e) => updateMaterial(i, "count", Math.max(0, parseFloat(e.target.value) || 0))}
className="h-8 w-full rounded-md border border-slate-300 px-2 text-xs text-slate-800 focus:border-amber-500 focus:outline-none"
/>
</div>
<button
type="button"
onClick={() => removeMaterial(i)}
className="mt-3.5 flex h-8 w-8 items-center justify-center rounded-md text-slate-400 hover:bg-rose-50 hover:text-rose-600 transition-colors"
title="حذف این ردیف"
>
<Trash2 size={15} />
</button>
</div>
<div>
<label className="mb-0.5 block text-[10px] font-medium text-slate-500">مشخصه‌ها، استانداردها و مدل</label>
<TagInput
tags={m.specifications || []}
onChange={(tags) => updateMaterial(i, "specifications", tags)}
placeholder="مثال: استاندارد A3، عیار ۳۵۰، بوم ۳۶ متری (Enter بزنید)"
/>
</div>
</div>
))
)}
<button
type="button"
onClick={addMaterial}
className="inline-flex h-8 w-full items-center justify-center gap-1.5 rounded-lg border border-dashed border-amber-300 bg-amber-50/60 text-xs font-semibold text-amber-800 hover:bg-amber-100/70 transition-colors"
>
<Plus size={14} />
<span>افزودن مصالح و تجهیزات</span>
</button>
</div>
</div>
);
}
@@ -0,0 +1,736 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Link2, Plus, Trash2 } from "lucide-react";
import { toast } from "sonner";
import {
createTaskRelation,
deleteTask,
deleteTaskRelation,
getProjectRelations,
updateTask,
updateTaskProgress,
} from "../lib/api";
import type {
HumanResourceItem,
MaterialItem,
Task,
TaskDetail,
UpdateTaskPayload,
} from "../lib/api";
import { Modal, btnPrimary, btnSecondary, inputCls } from "./ui";
import { JalaliDatePicker } from "./JalaliDatePicker";
import { ResourceSectionInputs } from "./ResourceSectionInputs";
import { isoToApiDate } from "../lib/jalali";
import { reconcileDates } from "../lib/dates";
import { formatDate } from "../lib/format";
interface TaskEditModalProps {
open: boolean;
task: Task | TaskDetail | null;
allTasks?: Task[];
projectId: string | number;
onClose: () => void;
onDeleted?: () => void;
onUpdated?: () => void;
}
function getDescendantIds(allTasks: Task[], targetTaskId: string): Set<string> {
const descendants = new Set<string>();
const childrenMap = new Map<string, string[]>();
for (const t of allTasks) {
const pId = t.parent_id
? String(t.parent_id)
: t.parent_op_wp_id != null
? String(t.parent_op_wp_id)
: null;
if (pId) {
const list = childrenMap.get(pId) || [];
list.push(String(t.id || t.op_wp_id));
childrenMap.set(pId, list);
}
}
const stack = [...(childrenMap.get(targetTaskId) || [])];
while (stack.length > 0) {
const current = stack.pop()!;
if (!descendants.has(current)) {
descendants.add(current);
const kids = childrenMap.get(current) || [];
for (const k of kids) stack.push(k);
}
}
return descendants;
}
export function TaskEditModal({
open,
task,
allTasks = [],
projectId,
onClose,
onDeleted,
onUpdated,
}: TaskEditModalProps) {
const queryClient = useQueryClient();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [parentId, setParentId] = useState("");
const [status, setStatus] = useState("TODO");
const [priority, setPriority] = useState("normal");
const [plannedStart, setPlannedStart] = useState<string | null>(null);
const [plannedEnd, setPlannedEnd] = useState<string | null>(null);
const [durationDays, setDurationDays] = useState<number | "">("");
const [progress, setProgress] = useState<number | "">("");
const [humanResources, setHumanResources] = useState<HumanResourceItem[]>([]);
const [materials, setMaterials] = useState<MaterialItem[]>([]);
const [errorMsg, setErrorMsg] = useState("");
const [showConfirmDelete, setShowConfirmDelete] = useState(false);
useEffect(() => {
if (open && task) {
setTitle(task.title || task.subject || "");
setDescription((task as any).description || "");
const pId = task.parent_id
? String(task.parent_id)
: task.parent_op_wp_id != null
? String(task.parent_op_wp_id)
: "";
setParentId(pId);
setStatus(task.status || "TODO");
setPriority(task.priority || "normal");
setPlannedStart(task.planned_start || task.start_date || null);
setPlannedEnd(task.planned_end || task.due_date || null);
setDurationDays(task.duration || task.duration_days || "");
setProgress(task.progress ?? task.percentage_done ?? 0);
setHumanResources((task as any).human_resources || []);
setMaterials((task as any).materials || []);
setErrorMsg("");
setShowConfirmDelete(false);
}
}, [open, task]);
const taskId = task ? String(task.id || task.op_wp_id) : "";
// لیست والدهای مجاز (حذف خود تسک و تمام زیرتسک‌های آن برای جلوگیری از حلقه)
const validParents = useMemo(() => {
if (!taskId) return allTasks;
const descendantIds = getDescendantIds(allTasks, taskId);
return allTasks.filter((t) => {
const tKey = String(t.id || t.op_wp_id);
return tKey !== taskId && !descendantIds.has(tKey);
});
}, [allTasks, taskId]);
// لیست وظایف مجاز جهت برقراری رابطه وابستگی (حذف خود تسک)
const validRelationTargets = useMemo(() => {
return allTasks.filter((t) => String(t.id || t.op_wp_id) !== taskId);
}, [allTasks, taskId]);
// کوئری روابط پروژه جهت استخراج روابط ورودی و خروجی این وظیفه
const relationsQuery = useQuery({
queryKey: ["project-relations", projectId],
queryFn: () => getProjectRelations(projectId),
enabled: open && Boolean(projectId),
});
const projectRelations = relationsQuery.data || [];
const currentTaskRelations = useMemo(() => {
return projectRelations.filter((r) => r.from_task_id === taskId || r.to_task_id === taskId);
}, [projectRelations, taskId]);
const [relTargetId, setRelTargetId] = useState("");
const [relDirection, setRelDirection] = useState<"predecessor" | "successor">("predecessor");
const [relType, setRelType] = useState<"FS" | "SS" | "FF" | "SF">("FS");
const [relLag, setRelLag] = useState<number>(0);
const addRelationMutation = useMutation({
mutationFn: (payload: {
from_task_id: string;
to_task_id: string;
relation_type: "FS" | "SS" | "FF" | "SF";
lag_days: number;
}) => createTaskRelation(projectId, payload),
onSuccess: () => {
toast.success("رابطه وابستگی با موفقیت ایجاد شد");
setRelTargetId("");
setRelLag(0);
void queryClient.invalidateQueries({ queryKey: ["project-relations", projectId] });
void queryClient.invalidateQueries({ queryKey: ["project-gantt", projectId] });
void queryClient.invalidateQueries({ queryKey: ["tasks", projectId] });
void queryClient.invalidateQueries({ queryKey: ["task", taskId] });
},
onError: (err: any) => {
toast.error(err?.message || "خطا در ایجاد رابطه وابستگی (احتمال وجود دور یا ورودی تکراری)");
},
});
const deleteRelationMutation = useMutation({
mutationFn: (relationId: string) => deleteTaskRelation(projectId, relationId),
onSuccess: () => {
toast.success("رابطه وابستگی با موفقیت حذف شد");
void queryClient.invalidateQueries({ queryKey: ["project-relations", projectId] });
void queryClient.invalidateQueries({ queryKey: ["project-gantt", projectId] });
void queryClient.invalidateQueries({ queryKey: ["tasks", projectId] });
void queryClient.invalidateQueries({ queryKey: ["task", taskId] });
},
onError: (err: any) => {
toast.error(err?.message || "خطا در حذف وابستگی");
},
});
/** Save the whole form. Progress goes to its own endpoint (see below). */
const updateMutation = useMutation({
mutationFn: async (payload: UpdateTaskPayload) => {
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)) {
await updateTaskProgress(task!.id || task!.op_wp_id, progress);
}
},
onSuccess: () => {
toast.success("اطلاعات وظیفه با موفقیت به‌روزرسانی شد");
void queryClient.invalidateQueries({ queryKey: ["tasks", projectId] });
void queryClient.invalidateQueries({ queryKey: ["project-gantt", projectId] });
void queryClient.invalidateQueries({ queryKey: ["project", projectId] });
void queryClient.invalidateQueries({ queryKey: ["task", taskId] });
void queryClient.invalidateQueries({ queryKey: ["today"] });
onClose();
onUpdated?.();
},
onError: (err: any) => {
setErrorMsg(err?.message || "خطا در ویرایش وظیفه");
},
});
const deleteMutation = useMutation({
mutationFn: () => deleteTask(task!.id || task!.op_wp_id),
onSuccess: () => {
toast.success("وظیفه با موفقیت حذف شد");
void queryClient.invalidateQueries({ queryKey: ["tasks", projectId] });
void queryClient.invalidateQueries({ queryKey: ["project-gantt", projectId] });
void queryClient.invalidateQueries({ queryKey: ["project", projectId] });
void queryClient.invalidateQueries({ queryKey: ["today"] });
onClose();
onDeleted?.();
},
onError: (err: any) => {
setErrorMsg(err?.message || "خطا در حذف وظیفه");
setShowConfirmDelete(false);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!task) return;
if (!title.trim()) {
setErrorMsg("عنوان وظیفه الزامی است");
return;
}
const startIso = isoToApiDate(plannedStart);
const endIso = isoToApiDate(plannedEnd);
// ۱. اجباری بودن تاریخ شروع و پایان
if (!startIso) {
setErrorMsg("وارد کردن تاریخ شروع وظیفه الزامی است");
return;
}
if (!endIso) {
setErrorMsg("وارد کردن تاریخ پایان وظیفه الزامی است");
return;
}
// ۲. تاریخ شروع نباید از تاریخ پایان جلوتر باشد
if (startIso > endIso) {
setErrorMsg("تاریخ شروع وظیفه نمی‌تواند بعد از تاریخ اتمام آن باشد");
return;
}
// ۲. محدودیت نسبت به والد انتخابی
if (parentId) {
const selectedParent = allTasks.find(
(t) => String(t.id || t.op_wp_id) === String(parentId)
);
if (selectedParent) {
const pStart = selectedParent.planned_start || selectedParent.start_date;
const pEnd = selectedParent.planned_end || selectedParent.due_date;
if (pStart && startIso && startIso < pStart) {
setErrorMsg(
`تاریخ شروع وظیفه فرزند نمی‌تواند قبل از تاریخ شروع وظیفه والد (${(selectedParent as any).planned_start_jalali || pStart}) باشد`
);
return;
}
if (pEnd && endIso && endIso > pEnd) {
setErrorMsg(
`تاریخ پایان وظیفه فرزند نمی‌تواند بعد از تاریخ پایان وظیفه والد (${(selectedParent as any).planned_end_jalali || pEnd}) باشد`
);
return;
}
if (
typeof selectedParent.duration === "number" &&
typeof durationDays === "number" &&
durationDays > selectedParent.duration
) {
setErrorMsg(
`مدت زمان وظیفه والد (${selectedParent.duration} روز) باید بیشتر یا مساوی مدت زمان وظیفه فرزند (${durationDays} روز) باشد`
);
return;
}
}
}
// ۳. محدودیت نسبت به فرزندان موجود
const children = allTasks.filter((t) => {
const p = t.parent_id
? String(t.parent_id)
: t.parent_op_wp_id != null
? String(t.parent_op_wp_id)
: null;
return p === taskId;
});
for (const c of children) {
const cStart = c.planned_start || c.start_date;
const cEnd = c.planned_end || c.due_date;
const cTitle = c.title || c.subject || "فرزند";
if (cStart && startIso && startIso > cStart) {
setErrorMsg(
`تاریخ شروع وظیفه والد نمی‌تواند بعد از تاریخ شروع وظیفه فرزند «${cTitle}» باشد`
);
return;
}
if (cEnd && endIso && endIso < cEnd) {
setErrorMsg(
`تاریخ پایان وظیفه والد نمی‌تواند قبل از تاریخ پایان وظیفه فرزند «${cTitle}» باشد`
);
return;
}
if (
typeof durationDays === "number" &&
typeof c.duration === "number" &&
durationDays < c.duration
) {
setErrorMsg(
`مدت زمان وظیفه والد (${durationDays} روز) باید بیشتر یا مساوی مدت زمان وظیفه فرزند «${cTitle}» (${c.duration} روز) باشد`
);
return;
}
}
updateMutation.mutate({
title: title.trim(),
description: description.trim(),
parent_id: parentId.trim() ? parentId.trim() : "none",
status,
priority,
planned_start: startIso,
planned_end: endIso,
duration_days: typeof durationDays === "number" ? durationDays : undefined,
human_resources: humanResources.filter((h) => h.role.trim()),
materials: materials.filter((m) => m.name.trim()),
});
};
if (!task) return null;
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 leading-5">
{errorMsg}
</div>
)}
{showConfirmDelete && (
<div className="rounded-xl bg-red-50 border border-red-200 p-3 space-y-2">
<div className="flex items-center gap-1.5 text-xs font-bold text-red-700">
<AlertTriangle size={15} />
<span>تأیید حذف وظیفه</span>
</div>
<p className="text-[11px] text-red-600 leading-4">
آیا از حذف «{task.title || task.subject}» و تمام زیرتسک‌های مرتبط با آن اطمینان دارید؟
</p>
<div className="flex items-center justify-end gap-2 pt-1">
<button
type="button"
onClick={() => setShowConfirmDelete(false)}
className={btnSecondary}
>
انصراف
</button>
<button
type="button"
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate()}
className="inline-flex h-9 items-center justify-center rounded-xl bg-red-600 px-3 text-xs font-semibold text-white transition-colors hover:bg-red-700 active:bg-red-800"
>
{deleteMutation.isPending ? "در حال حذف..." : "بله، حذف شود"}
</button>
</div>
</div>
)}
<div>
<label className="mb-1 block text-xs font-semibold text-slate-600">
عنوان وظیفه *
</label>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="عنوان وظیفه"
required
className={inputCls}
/>
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-slate-600">
وظیفه والد (سطح بالاتر)
</label>
<select
value={parentId}
onChange={(e) => setParentId(e.target.value)}
className={inputCls}
>
<option value="">بدون والد (فعالیت اصلی / سطح ۱)</option>
{validParents.map((p) => {
const pKey = String(p.id || p.op_wp_id);
return (
<option key={pKey} value={pKey}>
{p.title || p.subject}
</option>
);
})}
</select>
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-slate-600">
پیشرفت (٪)
</label>
<input
type="number"
min={0}
max={100}
dir="ltr"
value={progress}
onChange={(e) => {
const raw = e.target.value;
setProgress(raw === "" ? "" : Math.max(0, Math.min(100, Number(raw))));
}}
placeholder="۰"
className={`${inputCls} text-left`}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="mb-1 block text-xs font-semibold text-slate-600">
اولویت
</label>
<select
value={priority}
onChange={(e) => setPriority(e.target.value)}
className={inputCls}
>
<option value="low">پایین (درجه اهمیت = 1)</option>
<option value="normal">عادی (درجه اهمیت = 2)</option>
<option value="high">بالا (درجه اهمیت = 5)</option>
<option value="urgent">فوری (درجه اهمیت = 13)</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="mb-1 block text-xs font-semibold text-slate-600">
تاریخ شروع برنامه‌ریزی‌شده <span className="text-rose-500">*</span>
</label>
<JalaliDatePicker
value={plannedStart}
onChange={(iso) => {
const next = reconcileDates(
{ start: iso, end: plannedEnd, days: durationDays },
"start",
);
setPlannedStart(next.start);
setPlannedEnd(next.end);
setDurationDays(next.days);
}}
placeholder="انتخاب تاریخ"
/>
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-slate-600">
تاریخ پایان برنامه‌ریزی‌شده <span className="text-rose-500">*</span>
</label>
<JalaliDatePicker
value={plannedEnd}
onChange={(iso) => {
const next = reconcileDates(
{ start: plannedStart, end: iso, days: durationDays },
"end",
);
setPlannedStart(next.start);
setPlannedEnd(next.end);
setDurationDays(next.days);
}}
placeholder="انتخاب تاریخ"
/>
</div>
</div>
<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>
<div className="grid grid-cols-2 gap-2 text-slate-600">
<div>
<span className="text-slate-400">شروع واقعی: </span>
<span className="font-medium text-slate-700">
{(task as any)?.actual_start_jalali || ((task as any)?.actual_start ? formatDate((task as any)?.actual_start) : "شروع نشده")}
</span>
</div>
<div>
<span className="text-slate-400">پایان واقعی: </span>
<span className="font-medium text-slate-700">
{(task as any)?.actual_end_jalali || ((task as any)?.actual_end ? formatDate((task as any)?.actual_end) : "تکمیل نشده")}
</span>
</div>
</div>
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-slate-600">
مدت زمان برآورد شده (روز)
</label>
<input
type="number"
min={0}
value={durationDays}
onChange={(e) => {
const next = reconcileDates(
{
start: plannedStart,
end: plannedEnd,
days: e.target.value ? Number(e.target.value) : "",
},
"days",
);
setPlannedStart(next.start);
setPlannedEnd(next.end);
setDurationDays(next.days);
}}
placeholder="مثال: ۵"
className={inputCls}
/>
<p className="mt-1 text-[11px] leading-4 text-slate-400">
روز، تاریخ پایان را می‌سازد: شروع ۱۶ مرداد و ۴ روز ⇒ پایان ۲۰ مرداد.
</p>
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-slate-600">
توضیحات
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
placeholder="مشخصات و الزامات کار..."
className="w-full rounded-xl border border-slate-300 p-2.5 text-sm text-slate-800 focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-100"
/>
</div>
<ResourceSectionInputs
humanResources={humanResources}
setHumanResources={setHumanResources}
materials={materials}
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="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">
<Link2 size={14} className="text-sky-600" />
<span>وابستگی‌ها و روابط پیش‌نیازی (FS, SS, FF, SF)</span>
</span>
{currentTaskRelations.length > 0 && (
<span className="rounded-full bg-sky-100 text-sky-700 px-2 py-0.5 text-[10px] font-bold">
{currentTaskRelations.length} رابطه
</span>
)}
</div>
{/* لیست وابستگی‌های موجود */}
<div className="space-y-1.5">
{currentTaskRelations.length === 0 ? (
<p className="py-2 text-center text-xs text-slate-400">
هیچ رابطه وابستگی برای این وظیفه ثبت نشده است.
</p>
) : (
currentTaskRelations.map((r) => {
const isIncoming = r.to_task_id === taskId;
const otherTaskId = isIncoming ? r.from_task_id : r.to_task_id;
const otherTask = allTasks.find(
(t) => String(t.id || t.op_wp_id) === otherTaskId
);
const otherTitle =
(isIncoming ? r.from_task_title : r.to_task_title) ||
otherTask?.title ||
otherTask?.subject ||
"وظیفه مرتبط";
return (
<div
key={r.id}
className="flex items-center justify-between gap-2 rounded-lg border border-slate-200 bg-white p-2 text-xs shadow-2xs"
>
<div className="flex items-center gap-2 min-w-0">
<span className="rounded bg-sky-100 px-1.5 py-0.5 text-[10px] font-bold text-sky-800 border border-sky-200">
{r.relation_type}
</span>
<span className="text-[11px] text-slate-500">
{isIncoming ? "پیش‌نیاز این وظیفه:" : "پس‌نیاز این وظیفه:"}
</span>
<span className="font-semibold text-slate-800 truncate max-w-[200px]">
{otherTitle}
</span>
{r.lag_days !== 0 && (
<span className="text-[10px] text-slate-400">
(تاخیر: {r.lag_days > 0 ? `+${r.lag_days}` : r.lag_days} روز)
</span>
)}
</div>
<button
type="button"
disabled={deleteRelationMutation.isPending}
onClick={() => deleteRelationMutation.mutate(r.id)}
className="text-slate-400 hover:text-red-500 p-1 transition-colors"
title="حذف رابطه"
>
<Trash2 size={14} />
</button>
</div>
);
})
)}
</div>
{/* فرم افزودن رابطه جدید */}
<div className="pt-2 border-t border-slate-200/80 space-y-2">
<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="sm:col-span-4">
<label className="text-[10px] font-medium text-slate-500 block mb-0.5">وظیفه مرتبط</label>
<select
value={relTargetId}
onChange={(e) => setRelTargetId(e.target.value)}
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"
>
<option value="">انتخاب وظیفه...</option>
{validRelationTargets.map((t) => {
const tKey = String(t.id || t.op_wp_id);
return (
<option key={tKey} value={tKey}>
{t.title || t.subject}
</option>
);
})}
</select>
</div>
<div className="sm:col-span-3">
<label className="text-[10px] font-medium text-slate-500 block mb-0.5">نقش رابطه</label>
<select
value={relDirection}
onChange={(e) => setRelDirection(e.target.value as any)}
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"
>
<option value="predecessor">پیش‌نیاز این وظیفه است</option>
<option value="successor">پس‌نیاز این وظیفه است</option>
</select>
</div>
<div className="sm:col-span-3">
<label className="text-[10px] font-medium text-slate-500 block mb-0.5">نوع رابطه</label>
<select
value={relType}
onChange={(e) => setRelType(e.target.value as any)}
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"
>
<option value="FS">FS (پایان به شروع)</option>
<option value="SS">SS (شروع به شروع)</option>
<option value="FF">FF (پایان به پایان)</option>
<option value="SF">SF (شروع به پایان)</option>
</select>
</div>
<div className="sm:col-span-2">
<label className="text-[10px] font-medium text-slate-500 block mb-0.5">تاخیر (روز)</label>
<input
type="number"
value={relLag}
onChange={(e) => setRelLag(parseInt(e.target.value) || 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"
/>
</div>
</div>
<button
type="button"
disabled={addRelationMutation.isPending || !relTargetId}
onClick={() => {
if (!relTargetId) {
toast.error("لطفاً یک وظیفه مرتبط انتخاب کنید");
return;
}
const from_task_id = relDirection === "predecessor" ? relTargetId : taskId;
const to_task_id = relDirection === "predecessor" ? taskId : relTargetId;
addRelationMutation.mutate({
from_task_id,
to_task_id,
relation_type: relType,
lag_days: relLag,
});
}}
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 disabled:opacity-50 transition-colors"
>
<Plus size={13} />
<span>{addRelationMutation.isPending ? "در حال ثبت رابطه..." : "ثبت رابطه جدید"}</span>
</button>
</div>
</div>
<div className="flex items-center justify-between pt-2 border-t border-slate-100">
<div>
{!showConfirmDelete && (
<button
type="button"
onClick={() => setShowConfirmDelete(true)}
className="inline-flex h-9 items-center gap-1 rounded-xl text-xs font-medium text-red-600 hover:bg-red-50 px-2 transition-colors"
>
<Trash2 size={14} />
<span>حذف وظیفه</span>
</button>
)}
</div>
<div className="flex items-center gap-2">
<button type="button" onClick={onClose} className={btnSecondary}>
انصراف
</button>
<button
type="submit"
disabled={updateMutation.isPending}
className={btnPrimary}
>
{updateMutation.isPending ? "در حال ذخیره..." : "ذخیره تغییرات"}
</button>
</div>
</div>
</form>
</Modal>
);
}
@@ -0,0 +1,935 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
AlertTriangle,
Camera,
Check,
CheckCircle2,
ClipboardCheck,
FileDown,
Minus,
Plus,
ShieldAlert,
ShieldCheck,
Trash2,
X,
} from "lucide-react";
import {
addTaskQcQuestion,
createDefect,
createTaskQcDefinition,
deleteTaskQcQuestion,
getQcTemplates,
getTaskQc,
putTaskQcResult,
resolveDefect,
uploadFile,
} from "../lib/api";
import type { AnswerValue, Defect, QcPhase, TaskDetail, TaskQcQuestion } from "../lib/api";
import { Chip, ProgressBar, Modal, btnPrimary, btnSecondary, inputCls, Combobox } from "./ui";
import { defectStatusMeta, relativeTime, severityMeta, severityOptions } from "../lib/format";
interface TaskQcSectionProps {
task: TaskDetail;
canExecuteQc: boolean;
canDefineQc: boolean;
}
export function TaskQcSection({ task, canExecuteQc, canDefineQc }: TaskQcSectionProps) {
const taskId = String(task.id || task.op_wp_id || "");
const queryClient = useQueryClient();
const [activePhase, setActivePhase] = useState<QcPhase>("pre");
const [showTemplateModal, setShowTemplateModal] = useState(false);
const [selectedTemplateId, setSelectedTemplateId] = useState<string>("");
const [showAddQuestionModal, setShowAddQuestionModal] = useState(false);
const [defectTargetQuestion, setDefectTargetQuestion] = useState<TaskQcQuestion | null>(null);
const [defectToResolve, setDefectToResolve] = useState<Defect | null>(null);
const [resolutionNotes, setResolutionNotes] = useState("");
// Load Task QC Data
const qcQuery = useQuery({
queryKey: ["task-qc", taskId],
queryFn: () => getTaskQc(taskId),
});
const templatesQuery = useQuery({
queryKey: ["qc-templates"],
queryFn: getQcTemplates,
enabled: showTemplateModal,
});
const qcState = qcQuery.data;
const readiness = qcState?.readiness;
const definitions = qcState?.definitions ?? [];
const questions = definitions.flatMap((d) => d.questions);
const selectedTemplate = (templatesQuery.data ?? []).find((t) => t.id === selectedTemplateId) ?? null;
// Mutations
const invalidateQc = () => {
void queryClient.invalidateQueries({ queryKey: ["task-qc", taskId] });
void queryClient.invalidateQueries({ queryKey: ["task", taskId] });
void queryClient.invalidateQueries({ queryKey: ["tasks"] });
void queryClient.invalidateQueries({ queryKey: ["today"] });
};
const answerMutation = useMutation({
mutationFn: ({ questionId, answer, photoId }: { questionId: string; answer: AnswerValue | null; photoId?: string | null }) =>
putTaskQcResult(questionId, { answer, photo_id: photoId }),
onSuccess: (_, variables) => {
invalidateQc();
if (variables.answer === null) {
toast.success("انتخاب بازرسی لغو شد");
} else {
toast.success("نتیجه بازرسی ثبت شد");
}
},
onError: (err: any) => {
toast.error(err?.message || "خطا در ثبت نتیجه بازرسی");
},
});
const handleToggleAnswer = (questionId: string, currentAnswer: string | null | undefined, newAnswer: AnswerValue) => {
if (!canExecuteQc || answerMutation.isPending) return;
if (currentAnswer === newAnswer) {
// کلیک مجدد: لغو انتخاب قبلی
answerMutation.mutate({ questionId, answer: null });
} else {
answerMutation.mutate({ questionId, answer: newAnswer });
}
};
const applyTemplateMutation = useMutation({
mutationFn: (templateId: string) =>
createTaskQcDefinition(taskId, { template_id: templateId }),
onSuccess: () => {
invalidateQc();
setShowTemplateModal(false);
setSelectedTemplateId("");
toast.success("چک‌لیست از قالب بارگذاری شد");
},
onError: (err: any) => {
toast.error(err?.message || "خطا در بارگذاری قالب");
},
});
const addQuestionMutation = useMutation({
mutationFn: async ({ text, phase, required }: { text: string; phase: QcPhase; required: boolean }) => {
let definitionId = definitions[0]?.id;
if (!definitionId) {
const newDef = await createTaskQcDefinition(taskId, { name: "چک‌لیست کنترل کیفیت" });
definitionId = newDef.id;
}
return addTaskQcQuestion(definitionId, { text, phase, required });
},
onSuccess: () => {
invalidateQc();
setShowAddQuestionModal(false);
toast.success("سؤال جدید به چک‌لیست اضافه شد");
},
onError: (err: any) => {
toast.error(err?.message || "خطا در افزودن سؤال");
},
});
const deleteQuestionMutation = useMutation({
mutationFn: (questionId: string) => deleteTaskQcQuestion(questionId),
onSuccess: () => {
invalidateQc();
toast.success("سؤال با موفقیت حذف شد");
},
onError: (err: any) => {
toast.error(err?.message || "خطا در حذف سؤال");
},
});
const resolveDefectMutation = useMutation({
mutationFn: ({ defectId, notes }: { defectId: string; notes: string }) =>
resolveDefect(defectId, notes),
onSuccess: () => {
invalidateQc();
setDefectToResolve(null);
setResolutionNotes("");
toast.success("عیب به عنوان رفع‌شده ثبت گردید");
},
onError: (err: any) => {
toast.error(err?.message || "خطا در رفع عیب");
},
});
// Calculate Progress
const totalQuestions = readiness?.all.total ?? questions.length;
const pendingCount = readiness?.all.pending ?? 0;
const answeredCount = totalQuestions - pendingCount;
const qcPercent = totalQuestions > 0 ? Math.round((answeredCount / totalQuestions) * 100) : 0;
const currentPhaseQuestions = questions.filter((q) => q.phase === activePhase);
// Group stats by phase
const preStats = readiness?.pre;
const execStats = readiness?.exec;
return (
<div className="space-y-3.5">
{/* 1. بنر وضعیت آمادگی شروع و اتمام کار */}
{readiness && (
<div>
{!readiness.can_start ? (
<div className="rounded-xl border border-amber-300 bg-amber-50/90 p-3 text-amber-900 shadow-sm">
<div className="flex items-center gap-2 font-bold text-xs">
<AlertTriangle size={16} className="text-amber-600 shrink-0" />
<span>شروع اجرای وظیفه نیازمند بازرسی پیش از اجرا است</span>
</div>
{readiness.block_reasons.length > 0 && (
<ul className="mt-1.5 list-inside list-disc text-[11px] leading-5 text-amber-800 ps-1">
{readiness.block_reasons.map((reason, idx) => (
<li key={idx}>{reason}</li>
))}
</ul>
)}
</div>
) : readiness.all.total > 0 && readiness.all.pending === 0 && readiness.all.failed === 0 ? (
<div className="rounded-xl border border-emerald-200 bg-emerald-50/80 p-3 text-emerald-900 flex items-center gap-2 text-xs font-bold">
<CheckCircle2 size={16} className="text-emerald-600 shrink-0" />
<span>تمام بازرسی‌های کیفی با موفقیت تأیید شدند — پیشرفت ۱۰۰٪ مجاز است</span>
</div>
) : (
<div className="rounded-xl border border-sky-200 bg-sky-50/70 p-3 text-sky-900 flex items-center justify-between text-xs font-semibold">
<div className="flex items-center gap-2">
<ShieldCheck size={16} className="text-sky-600 shrink-0" />
<span>آمادگی شروع تأیید شده است</span>
</div>
<span className="text-[11px] text-sky-700 font-bold tabular-nums">
{answeredCount} از {totalQuestions} بازرسی انجام شد
</span>
</div>
)}
{/* نوار پیشرفت کلی QC */}
{totalQuestions > 0 && (
<div className="mt-2.5">
<div className="flex items-center justify-between text-[11px] text-slate-500 mb-1">
<span>پیشرفت بازرسی‌های کیفی</span>
<span className="font-bold tabular-nums text-slate-700">{qcPercent}٪ ({answeredCount} از {totalQuestions})</span>
</div>
<ProgressBar value={qcPercent} />
</div>
)}
</div>
)}
{/* 2. هدر ابزارهای چک‌لیست و تب‌های ۳ مرحله */}
<div className="rounded-xl border border-slate-200 bg-slate-50/70 p-3 space-y-3">
<div className="flex items-center justify-between gap-2 flex-wrap border-b border-slate-200 pb-2.5">
<div className="flex items-center gap-1.5">
<ClipboardCheck size={16} className="text-sky-600" />
<span className="text-xs font-bold text-slate-800">چک‌لیست بازرسی کیفی</span>
</div>
<div className="flex items-center gap-1.5">
{canDefineQc && (
<>
<button
type="button"
onClick={() => setShowTemplateModal(true)}
className="inline-flex h-8 items-center gap-1 rounded-lg bg-white border border-slate-200 px-2.5 text-[11px] font-semibold text-slate-700 hover:bg-slate-50 active:bg-slate-100 transition-colors shadow-sm"
>
<FileDown size={13} className="text-sky-600" />
<span>بارگذاری از قالب</span>
</button>
<button
type="button"
onClick={() => setShowAddQuestionModal(true)}
className="inline-flex h-8 items-center gap-1 rounded-lg bg-sky-600 px-2.5 text-[11px] font-semibold text-white hover:bg-sky-700 transition-colors shadow-sm"
>
<Plus size={13} />
<span>افزودن سؤال</span>
</button>
</>
)}
</div>
</div>
{/* ۳ تب فازها: پیش از اجرا / حین اجرا / پس از اجرا */}
<div className="flex rounded-lg bg-slate-200/80 p-0.5 text-xs font-semibold text-slate-600">
<button
type="button"
onClick={() => setActivePhase("pre")}
className={`flex-1 rounded-md py-1.5 text-center transition-all ${
activePhase === "pre"
? "bg-white text-amber-800 shadow-sm font-bold"
: "text-slate-600 hover:text-slate-800"
}`}
>
<span>۱. پیش از اجرا</span>
{preStats && preStats.total > 0 && (
<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"}`}>
{preStats.passed}/{preStats.total}
</span>
)}
</button>
<button
type="button"
onClick={() => setActivePhase("during")}
className={`flex-1 rounded-md py-1.5 text-center transition-all ${
activePhase === "during"
? "bg-white text-sky-800 shadow-sm font-bold"
: "text-slate-600 hover:text-slate-800"
}`}
>
<span>۲. حین اجرا</span>
{execStats && execStats.total > 0 && (
<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"}`}>
{execStats.passed}/{execStats.total}
</span>
)}
</button>
<button
type="button"
onClick={() => setActivePhase("after")}
className={`flex-1 rounded-md py-1.5 text-center transition-all ${
activePhase === "after"
? "bg-white text-emerald-800 shadow-sm font-bold"
: "text-slate-600 hover:text-slate-800"
}`}
>
<span>۳. تحویل و پس از اجرا</span>
</button>
</div>
{/* لیست سؤالات فاز انتخاب‌شده */}
{currentPhaseQuestions.length === 0 ? (
<div className="py-6 text-center text-xs text-slate-400">
<p>سؤالی در این مرحله تعریف نشده است.</p>
{canDefineQc && (
<button
type="button"
onClick={() => setShowAddQuestionModal(true)}
className="mt-2 text-sky-600 hover:underline text-xs inline-flex items-center gap-1 font-semibold"
>
<Plus size={13} />
افزودن اولین سؤال
</button>
)}
</div>
) : (
<div className="space-y-2">
{currentPhaseQuestions.map((q, idx) => {
const latestAns = q.result?.answer;
const hasFailed = latestAns === "fail";
const isSatisfied = latestAns === "pass" || latestAns === "na";
return (
<div
key={q.id}
className={`rounded-xl border bg-white p-3 shadow-xs transition-all ${
hasFailed
? "border-red-200 bg-red-50/20"
: isSatisfied
? "border-emerald-100"
: "border-slate-200"
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-slate-100 text-[10px] font-bold text-slate-600">
{idx + 1}
</span>
<p className="text-xs font-bold text-slate-800 leading-5">{q.text}</p>
</div>
{q.result?.evaluated_by && (
<p className="mt-1 text-[10px] text-slate-400 ms-6">
بررسی توسط {q.result.evaluated_by} — {relativeTime(q.result.evaluated_at)}
</p>
)}
</div>
{canDefineQc && (
<button
type="button"
onClick={() => deleteQuestionMutation.mutate(q.id)}
disabled={deleteQuestionMutation.isPending}
title="حذف سؤال"
className="text-slate-300 hover:text-red-500 p-1 rounded transition-colors"
>
<Trash2 size={13} />
</button>
)}
</div>
{/* دکمه‌های پاسخ فوری ۳ گانه */}
<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>
<div className="flex items-center gap-1">
{/* دکمه قبول / Pass */}
<button
type="button"
disabled={!canExecuteQc || answerMutation.isPending}
onClick={() => handleToggleAnswer(q.id, latestAns, "pass")}
className={`inline-flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs font-bold transition-all ${
latestAns === "pass"
? "bg-emerald-600 text-white shadow-sm ring-2 ring-emerald-200"
: "bg-slate-100 text-slate-600 hover:bg-emerald-50 hover:text-emerald-700"
}`}
>
<Check size={13} strokeWidth={2.2} />
<span>قبول</span>
</button>
{/* دکمه رد / Fail */}
<button
type="button"
disabled={!canExecuteQc || answerMutation.isPending}
onClick={() => handleToggleAnswer(q.id, latestAns, "fail")}
className={`inline-flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs font-bold transition-all ${
latestAns === "fail"
? "bg-red-600 text-white shadow-sm ring-2 ring-red-200"
: "bg-slate-100 text-slate-600 hover:bg-red-50 hover:text-red-700"
}`}
>
<X size={13} strokeWidth={2.2} />
<span>مردود</span>
</button>
{/* دکمه مطرح نیست */}
<button
type="button"
disabled={!canExecuteQc || answerMutation.isPending}
onClick={() => handleToggleAnswer(q.id, latestAns, "na")}
className={`inline-flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs font-bold transition-all ${
latestAns === "na"
? "bg-slate-700 text-white shadow-sm ring-2 ring-slate-300"
: "bg-slate-100 text-slate-600 hover:bg-slate-200"
}`}
>
<Minus size={15} strokeWidth={3} />
<span>مطرح نیست</span>
</button>
</div>
</div>
{/* در صورت قبول بودن: اکشن آپلود عکس تأییدیه */}
{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="flex items-center justify-between">
<span className="text-[11px] font-medium text-emerald-800 flex items-center gap-1">
<Check size={13} className="text-emerald-600" />
<span>این مورد تأیید شده است</span>
</span>
<label className="inline-flex items-center gap-1 rounded-md bg-emerald-600 px-2 py-1 text-[11px] font-bold text-white hover:bg-emerald-700 transition-colors shadow-xs cursor-pointer">
<Camera size={12} />
<span>{q.result?.photo_url ? "تغییر تصویر مستند" : "آپلود تصویر مستند (قبول)"}</span>
<input
type="file"
accept="image/*"
className="hidden"
disabled={!canExecuteQc || answerMutation.isPending}
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const res = await uploadFile(file);
answerMutation.mutate({ questionId: q.id, answer: "pass", photoId: res.id });
toast.success("تصویر مستند تأییدیه ذخیره شد");
} catch (err: any) {
toast.error(err?.message || "خطا در بارگذاری تصویر");
}
}}
/>
</label>
</div>
{q.result?.photo_url && (
<div className="flex items-center justify-between gap-2 pt-1 border-t border-emerald-200/60">
<div className="flex items-center gap-2">
<a href={q.result.photo_url} target="_blank" rel="noreferrer" className="block shrink-0">
<img src={q.result.photo_url} alt="مستند قبولی" className="h-10 w-10 rounded object-cover border border-emerald-200" />
</a>
<a href={q.result.photo_url} target="_blank" rel="noreferrer" className="text-[10px] text-emerald-700 hover:underline">
مشاهده تصویر پیوست
</a>
</div>
<button
type="button"
disabled={!canExecuteQc || answerMutation.isPending}
onClick={() => answerMutation.mutate({ questionId: q.id, answer: "pass", photoId: "" })}
className="text-[10px] text-red-500 hover:underline"
>
حذف تصویر
</button>
</div>
)}
</div>
)}
{/* در صورت مردود بودن: اکشن ثبت عیب */}
{hasFailed && (
<div className="mt-2 rounded-lg bg-red-50 p-2 flex items-center justify-between text-xs border border-red-100">
<span className="text-[11px] font-medium text-red-700">این مورد مردود شده است</span>
<button
type="button"
onClick={() => setDefectTargetQuestion(q)}
className="inline-flex items-center gap-1 rounded-md bg-red-600 px-2 py-0.5 text-[11px] font-bold text-white hover:bg-red-700 transition-colors shadow-xs"
>
<ShieldAlert size={12} />
<span>ثبت عدم انطباق (عیب)</span>
</button>
</div>
)}
</div>
);
})}
</div>
)}
</div>
{/* 3. لیست عیوب و عدم انطباق‌های وظیفه */}
{task.defects && task.defects.length > 0 && (
<div className="rounded-xl border border-slate-200 bg-white p-3 space-y-2.5">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-slate-800 flex items-center gap-1.5">
<ShieldAlert size={14} className="text-red-600" />
<span>عدم انطباق‌ها و عیوب ثبت‌شده ({task.defects.length})</span>
</span>
{task.open_defects != null && task.open_defects > 0 && (
<span className="rounded-full bg-red-100 text-red-700 px-2 py-0.5 text-[10px] font-bold">
{task.open_defects} مورد باز
</span>
)}
</div>
<div className="space-y-2">
{task.defects.map((defect) => {
const isResolved = defect.status === "resolved";
const sev = severityMeta(defect.severity);
const dst = defectStatusMeta(defect.status);
return (
<div
key={defect.id}
className={`rounded-lg border p-2.5 text-xs transition-colors ${
isResolved ? "border-slate-100 bg-slate-50/60" : "border-red-200 bg-red-50/30"
}`}
>
<div className="flex items-start justify-between gap-2">
<div>
<span className="font-bold text-slate-800 text-xs block">{defect.title}</span>
<div className="mt-1 flex flex-wrap items-center gap-1.5">
<Chip tone={dst.tone}>{dst.label}</Chip>
<Chip tone={sev.tone}>{sev.label}</Chip>
<span className="text-[10px] text-slate-400">{relativeTime(defect.created_at)}</span>
</div>
</div>
{!isResolved && canExecuteQc && (
<button
type="button"
onClick={() => setDefectToResolve(defect)}
className="rounded-lg bg-emerald-600 text-white px-2.5 py-1 text-[11px] font-bold hover:bg-emerald-700 transition-colors shadow-xs shrink-0"
>
رفع شد
</button>
)}
</div>
{defect.corrective_action && (
<p className="mt-1.5 text-[11px] leading-4 text-slate-600 bg-white/80 p-1.5 rounded border border-slate-100">
<span className="font-semibold text-slate-700">اقدام اصلاحی: </span>
{defect.corrective_action}
</p>
)}
{defect.resolution_notes && (
<p className="mt-1 text-[11px] text-emerald-700 font-medium">
توضیح رفع: {defect.resolution_notes}
</p>
)}
</div>
);
})}
</div>
</div>
)}
{/* --- Modal: بارگذاری از قالب سراسری --- */}
{showTemplateModal && (
<Modal
open
title="بارگذاری چک‌لیست از قالب‌های سراسری"
onClose={() => {
setShowTemplateModal(false);
setSelectedTemplateId("");
}}
>
<div className="space-y-3.5">
<p className="text-xs text-slate-500 leading-5">
با انتخاب قالب، تمام سؤالات آن در این وظیفه کپی می‌شوند و می‌توانید آزادانه آن‌ها را تغییر دهید.
</p>
<div>
<label className="mb-1.5 block text-xs font-semibold text-slate-700">
انتخاب قالب چک‌لیست
</label>
{templatesQuery.isPending ? (
<p className="py-3 text-center text-xs text-slate-400">در حال دریافت قالب‌ها...</p>
) : (templatesQuery.data ?? []).length === 0 ? (
<p className="py-3 text-center text-xs text-slate-400">هنوز هیچ قالبی در سیستم تعریف نشده است.</p>
) : (
<Combobox
value={selectedTemplateId}
onChange={setSelectedTemplateId}
placeholder="یک قالب را انتخاب کنید..."
searchPlaceholder="جستجوی نام قالب یا نوع فعالیت..."
emptyMessage="قالبی با این عنوان پیدا نشد"
options={(templatesQuery.data ?? []).map((tpl) => ({
value: tpl.id,
label: tpl.name,
hint: tpl.activity_type ? `${tpl.activity_type} (${tpl.questions.length} سؤال)` : `${tpl.questions.length} سؤال`,
}))}
/>
)}
</div>
{selectedTemplate && (
<div className="rounded-xl border border-sky-200/80 bg-sky-50/60 p-3 text-xs space-y-2">
<div className="flex items-center justify-between">
<span className="font-bold text-sky-950">{selectedTemplate.name}</span>
<span className="rounded bg-sky-100 px-2 py-0.5 font-semibold text-sky-800 text-[11px]">
{selectedTemplate.questions.length} سؤال
</span>
</div>
{selectedTemplate.activity_type && (
<p className="text-[11px] text-slate-600">
نوع فعالیت: <span className="font-semibold text-slate-800">{selectedTemplate.activity_type}</span>
</p>
)}
{selectedTemplate.description && (
<p className="text-[11px] text-slate-500 leading-4">{selectedTemplate.description}</p>
)}
<div className="flex flex-wrap gap-1.5 pt-1">
<span className="rounded-md bg-white border border-sky-100 px-2 py-0.5 text-[10px] text-slate-600">
پیش از اجرا: {selectedTemplate.questions.filter((q) => q.phase === "pre").length}
</span>
<span className="rounded-md bg-white border border-sky-100 px-2 py-0.5 text-[10px] text-slate-600">
حین اجرا: {selectedTemplate.questions.filter((q) => q.phase === "during").length}
</span>
<span className="rounded-md bg-white border border-sky-100 px-2 py-0.5 text-[10px] text-slate-600">
پس از اجرا: {selectedTemplate.questions.filter((q) => q.phase === "after").length}
</span>
</div>
</div>
)}
<div className="flex justify-end gap-2 pt-2 border-t border-slate-100">
<button
type="button"
onClick={() => {
setShowTemplateModal(false);
setSelectedTemplateId("");
}}
className={btnSecondary}
>
انصراف
</button>
<button
type="button"
disabled={!selectedTemplateId || applyTemplateMutation.isPending}
onClick={() => {
if (selectedTemplateId) {
applyTemplateMutation.mutate(selectedTemplateId);
}
}}
className={btnPrimary}
>
{applyTemplateMutation.isPending ? "در حال بارگذاری..." : "بارگذاری قالب"}
</button>
</div>
</div>
</Modal>
)}
{/* --- Modal: افزودن سؤال جدید --- */}
{showAddQuestionModal && (
<AddQuestionModal
open={showAddQuestionModal}
defaultPhase={activePhase}
pending={addQuestionMutation.isPending}
onClose={() => setShowAddQuestionModal(false)}
onSubmit={(data) => addQuestionMutation.mutate(data)}
/>
)}
{/* --- Modal: ثبت عدم انطباق (عیب) --- */}
{defectTargetQuestion && (
<CreateDefectModal
open={Boolean(defectTargetQuestion)}
taskId={taskId}
question={defectTargetQuestion}
onClose={() => setDefectTargetQuestion(null)}
onCreated={() => {
setDefectTargetQuestion(null);
invalidateQc();
}}
/>
)}
{/* --- Modal: رفع عیب --- */}
{defectToResolve && (
<Modal open title="ثبت رفع عدم انطباق" onClose={() => setDefectToResolve(null)}>
<div className="space-y-3">
<p className="text-xs text-slate-600">
آیا عیب «<span className="font-bold">{defectToResolve.title}</span>» برطرف شده است؟
</p>
<div>
<label className="block text-[11px] font-medium text-slate-600 mb-1">
توضیحات اقدام اصلاحی انجام‌شده (اختیاری)
</label>
<textarea
value={resolutionNotes}
onChange={(e) => setResolutionNotes(e.target.value)}
placeholder="توضیح دهید چه اقدامی برای رفع این عیب انجام شد..."
className={`${inputCls} h-20 py-2`}
/>
</div>
<div className="grid grid-cols-2 gap-2 pt-2">
<button
type="button"
onClick={() => setDefectToResolve(null)}
className={btnSecondary}
>
انصراف
</button>
<button
type="button"
disabled={resolveDefectMutation.isPending}
onClick={() =>
resolveDefectMutation.mutate({
defectId: defectToResolve.id,
notes: resolutionNotes,
})
}
className={btnPrimary}
>
{resolveDefectMutation.isPending ? "در حال ثبت..." : "تأیید رفع عیب"}
</button>
</div>
</div>
</Modal>
)}
</div>
);
}
function AddQuestionModal({
open,
defaultPhase,
pending,
onClose,
onSubmit,
}: {
open: boolean;
defaultPhase: QcPhase;
pending: boolean;
onClose: () => void;
onSubmit: (data: { text: string; phase: QcPhase; required: boolean }) => void;
}) {
const [text, setText] = useState("");
const [phase, setPhase] = useState<QcPhase>(defaultPhase);
const [error, setError] = useState("");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!text.trim()) {
setError("متن سؤال الزامی است");
return;
}
onSubmit({ text: text.trim(), phase, required: true });
};
return (
<Modal open={open} title="افزودن سؤال بازرسی کیفی" onClose={onClose}>
<form onSubmit={handleSubmit} className="space-y-3">
{error && <p className="text-xs text-red-600 bg-red-50 p-2 rounded-lg">{error}</p>}
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1">مرحله بازرسی</label>
<select
value={phase}
onChange={(e) => setPhase(e.target.value as QcPhase)}
className={inputCls}
>
<option value="pre">۱. پیش از اجرا (Pre-Execution)</option>
<option value="during">۲. حین اجرا (During-Execution)</option>
<option value="after">۳. تحویل و پس از اجرا (Post-Execution)</option>
</select>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1">متن سؤال / معیار بازرسی *</label>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="مثال: آیا ابعاد و فاصله میلگردها طبق نقشه اجرایی رعایت شده است؟"
className={`${inputCls} h-20 py-2`}
required
/>
</div>
<div className="grid grid-cols-2 gap-2 pt-2">
<button type="button" onClick={onClose} className={btnSecondary}>
انصراف
</button>
<button type="submit" disabled={pending} className={btnPrimary}>
{pending ? "در حال ثبت..." : "افزودن سؤال"}
</button>
</div>
</form>
</Modal>
);
}
function CreateDefectModal({
open,
taskId,
question,
onClose,
onCreated,
}: {
open: boolean;
taskId: string;
question: TaskQcQuestion;
onClose: () => void;
onCreated: () => void;
}) {
const [title, setTitle] = useState(`عدم انطباق: ${question.text}`);
const [description, setDescription] = useState("");
const [severity, setSeverity] = useState("medium");
const [correctiveAction, setCorrectiveAction] = useState("");
const [photoId, setPhotoId] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState("");
const defectMutation = useMutation({
mutationFn: (payload: any) => createDefect(payload),
onSuccess: () => {
toast.success("عدم انطباق با موفقیت ثبت شد");
onCreated();
},
onError: (err: any) => {
setError(err?.message || "خطا در ثبت عدم انطباق");
},
});
const handlePhotoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
const res = await uploadFile(file);
setPhotoId(res.id);
toast.success("تصویر ضمیمه شد");
} catch (err: any) {
toast.error(err?.message || "خطا در بارگذاری تصویر");
} finally {
setUploading(false);
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim()) {
setError("عنوان عدم انطباق الزامی است");
return;
}
defectMutation.mutate({
task_ref: taskId,
question_id: question.id,
title: title.trim(),
description: description.trim(),
severity,
corrective_action: correctiveAction.trim(),
photo_id: photoId,
});
};
return (
<Modal open={open} title="ثبت عدم انطباق کیفی (Defect)" onClose={onClose}>
<form onSubmit={handleSubmit} className="space-y-3">
{error && <p className="text-xs text-red-600 bg-red-50 p-2 rounded-lg">{error}</p>}
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1">عنوان عدم انطباق *</label>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
className={inputCls}
required
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1">شرح و جزئیات مشکل</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="توضیحات بیشتر درباره مورد مشاهده‌شده..."
className={`${inputCls} h-14 py-2`}
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1">شدت عدم انطباق</label>
<select
value={severity}
onChange={(e) => setSeverity(e.target.value)}
className={inputCls}
>
{severityOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1">اقدام اصلاحی لازم</label>
<textarea
value={correctiveAction}
onChange={(e) => setCorrectiveAction(e.target.value)}
placeholder="چه اصلاحی باید انجام شود تا تأیید مجدد صادر شود..."
className={`${inputCls} h-16 py-2`}
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1">تصویر مستند</label>
<input
type="file"
accept="image/*"
onChange={handlePhotoUpload}
disabled={uploading}
className="text-xs text-slate-600 file:mr-2 file:py-1 file:px-2.5 file:rounded-md file:border-0 file:text-xs file:font-semibold file:bg-sky-50 file:text-sky-700 hover:file:bg-sky-100"
/>
{uploading && <span className="text-[11px] text-sky-600 block mt-1">در حال آپلود...</span>}
</div>
<div className="grid grid-cols-2 gap-2 pt-2">
<button type="button" onClick={onClose} className={btnSecondary}>
انصراف
</button>
<button type="submit" disabled={defectMutation.isPending || uploading} className={btnPrimary}>
{defectMutation.isPending ? "در حال ثبت..." : "ثبت عدم انطباق"}
</button>
</div>
</form>
</Modal>
);
}
+630
View File
@@ -0,0 +1,630 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { NavLink, useNavigate } from "react-router-dom";
import {
AlertTriangle,
Check,
ChevronDown,
ChevronRight,
Menu,
Search,
X,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { toast } from "sonner";
import { ApiError } from "../lib/api";
import { useAuthStore } from "../store/auth";
import { visibleTabs } from "../lib/roles";
export type Tone = "red" | "amber" | "green" | "sky" | "slate";
export function apiErrorToast(err: unknown, fallback: string): void {
toast.error(err instanceof ApiError ? err.message : fallback);
}
// ----------------------------------------
// کلاس‌های مشترک
// ----------------------------------------
export const btnPrimary =
"inline-flex h-11 items-center justify-center gap-2 rounded-xl bg-sky-600 px-4 text-sm font-semibold text-white transition-colors active:bg-sky-700 disabled:cursor-not-allowed disabled:opacity-50";
export const btnSecondary =
"inline-flex h-11 items-center justify-center gap-2 rounded-xl border border-slate-300 bg-white px-4 text-sm font-semibold text-slate-700 transition-colors active:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-50";
export const btnDanger =
"inline-flex h-11 items-center justify-center gap-2 rounded-xl bg-red-600 px-4 text-sm font-semibold text-white transition-colors active:bg-red-700 disabled:cursor-not-allowed disabled:opacity-50";
export const inputCls =
"h-11 w-full rounded-xl border border-slate-300 bg-white px-3 text-sm text-slate-800 placeholder:text-slate-400 focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-100 disabled:opacity-60";
// ----------------------------------------
// پوسته ستون موبایلی
// ----------------------------------------
export function PhoneShell({ children }: { children: ReactNode }) {
return (
<div className="flex min-h-dvh justify-center bg-slate-200">
<div className="relative w-full min-h-dvh max-w-[420px] bg-slate-50 shadow-xl">{children}</div>
</div>
);
}
// ----------------------------------------
// هدر چسبان
// ----------------------------------------
export function PageHeader({
title,
sub,
onBack,
right,
menu,
}: {
title: string;
sub?: ReactNode;
onBack?: () => void;
right?: ReactNode;
/** منوی همبرگری پنل‌ها (PanelsMenu) — مثل ERP در گوشه بالا-راست هدر */
menu?: ReactNode;
}) {
const user = useAuthStore((s) => s.user);
const fullName = user?.display_name || user?.username || "کاربر";
const username = user?.username || "-";
const role =
user?.role_label ||
((user?.role as string) === "project_supervisor" || (user?.role as string) === "supervisor"
? "سرپرست پروژه"
: (user?.role as string) === "admin"
? "مدیر ارشد"
: "مدیر پروژه");
const userSub = user ? `${fullName} | نام کاربری: ${username} | نقش: ${role}` : null;
const displaySub = sub !== undefined ? sub : userSub;
return (
<header className="sticky top-0 z-20 flex min-h-[56px] items-center gap-1.5 border-b border-slate-200 bg-white/95 px-2 py-1.5 backdrop-blur">
{menu && <div className="flex shrink-0 items-center">{menu}</div>}
{onBack && (
<button
type="button"
onClick={onBack}
aria-label="بازگشت"
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-slate-600 active:bg-slate-100"
>
<ChevronRight size={22} />
</button>
)}
<div className="min-w-0 flex-1 px-1">
<h1 className="truncate text-base font-bold text-slate-800 leading-tight">{title}</h1>
{displaySub && <div className="truncate text-[11px] text-slate-500 font-normal mt-0.5">{displaySub}</div>}
</div>
{right}
</header>
);
}
// ----------------------------------------
// منوی پنل‌ها — ناوبری بین سامانه‌های مجموعه
// (ERP: انبار/تدارکات — PM: همین اپ) مثل منوی پنل‌های ERP
// ----------------------------------------
/** پیشوند سامانه ERP — پیش‌فرض همان دامنه (PM زیر /pm روی دامنه ERP سرو می‌شود) */
const ERP_URL =
(import.meta.env?.VITE_ERP_URL as string | undefined)?.trim() || "";
type PanelMenuItem =
| { kind: "divider" }
| { kind: "link"; label: string; href: string }
| { kind: "current"; label: string }
| { kind: "button"; label: string; onClick: () => void };
export function PanelsMenu() {
const navigate = useNavigate();
const clear = useAuthStore((s) => s.clear);
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [open]);
const logout = () => {
clear();
navigate("/login", { replace: true });
};
const user = useAuthStore((s) => s.user);
let role = (user?.role as string) || "";
let erpRole = "";
let erpPanels: Record<string, string> = {};
try {
const raw = localStorage.getItem("procurement-auth-v1");
if (raw) {
const parsed = JSON.parse(raw);
erpRole = (parsed?.state?.user?.role as string) || "";
erpPanels = (parsed?.state?.user?.panels as Record<string, string>) || {};
if (!role) role = erpRole;
}
} catch {}
const isAdmin = erpRole === "admin" || role === "admin";
const hasPanelsConfig = Boolean(erpPanels && Object.keys(erpPanels).length > 0);
const hasProcurement =
isAdmin ||
(hasPanelsConfig
? Boolean(erpPanels.procurement)
: erpRole === "procurement_manager" || role === "project_manager");
const hasInventory =
isAdmin ||
(hasPanelsConfig
? Boolean(erpPanels.inventory)
: erpRole === "pm" || erpRole === "supervisor" || role === "project_supervisor");
const hasPm =
isAdmin ||
(hasPanelsConfig
? Boolean(erpPanels.pm)
: erpRole === "pm" || erpRole === "supervisor" || role === "project_manager" || role === "project_supervisor");
const canManageOrg =
isAdmin ||
erpRole === "procurement_manager" ||
erpRole === "pm" ||
(role === "project_manager" && erpRole !== "supervisor");
const items: PanelMenuItem[] = [];
if (hasInventory) {
items.push({ kind: "link", label: "انبار", href: `${ERP_URL}/inventory` });
}
if (hasProcurement) {
const procHref =
erpPanels.procurement === "limited" && !isAdmin
? `${ERP_URL}/wizard`
: `${ERP_URL}/`;
items.push({ kind: "link", label: "تدارکات", href: procHref });
}
if (hasPm) {
items.push({ kind: "current", label: "مدیریت پروژه" });
}
if (canManageOrg) {
items.push({ kind: "link", label: "سازمان‌دهی", href: `${ERP_URL}/organization` });
}
items.push({ kind: "divider" });
items.push({ kind: "button", label: "خروج از حساب", onClick: logout });
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-label="منو"
title="منو"
className="flex h-11 w-11 items-center justify-center rounded-full text-slate-600 active:bg-slate-100"
>
{open ? <X size={22} /> : <Menu size={22} />}
</button>
{open && (
<div className="absolute top-12 start-0 z-50 min-w-56 rounded-xl border border-slate-200 bg-white p-1.5 shadow-lg">
<div className="px-3 py-1.5 text-[11px] font-bold text-slate-500">پنل‌ها</div>
{items.map((it, i) =>
it.kind === "divider" ? (
<div key={i} className="my-1 border-t border-slate-100" />
) : it.kind === "link" ? (
<a
key={i}
href={it.href}
/* همان‌تب و در سطح کل صفحه — PM ممکن است داخل iframe پنل ERP باشد */
target="_top"
onClick={(e) => {
setOpen(false);
if (window.top && window.top !== window) {
e.preventDefault();
window.top.location.href = it.href;
}
}}
className="flex items-center justify-between gap-3 rounded-lg px-3 py-2 text-[13px] no-underline text-slate-700 hover:bg-slate-50 active:bg-slate-100"
>
{it.label}
</a>
) : it.kind === "current" ? (
<div
key={i}
className="flex items-center justify-between gap-3 rounded-lg bg-sky-600 px-3 py-2 text-[13px] font-bold text-white"
>
{it.label}
<span className="text-[10px] opacity-80">✓ فعلی</span>
</div>
) : (
<button
key={i}
type="button"
onClick={() => {
setOpen(false);
it.onClick();
}}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-start text-[13px] text-slate-700 hover:bg-slate-50 active:bg-slate-100"
>
{it.label}
</button>
),
)}
</div>
)}
</div>
);
}
// ----------------------------------------
// نوار تب پایین — تب‌ها از مجوزهای کاربر ساخته می‌شوند
// ----------------------------------------
export function TabBar() {
const user = useAuthStore((s) => s.user);
const tabs = visibleTabs(user);
return (
<nav className="fixed bottom-0 left-1/2 z-30 w-full max-w-[420px] -translate-x-1/2 border-t border-slate-200 bg-white/95 pb-[env(safe-area-inset-bottom)] backdrop-blur">
<div className="flex h-16 items-stretch">
{tabs.map((tab) => (
<NavLink
key={tab.to}
to={tab.to}
end={tab.end}
className={({ isActive }) =>
`flex flex-1 flex-col items-center justify-center gap-0.5 text-[10px] font-medium transition-colors ${
isActive ? "text-sky-600" : "text-slate-400"
}`
}
>
{({ isActive }) => (
<>
<tab.icon size={22} strokeWidth={isActive ? 2.3 : 1.8} />
<span>{tab.label}</span>
</>
)}
</NavLink>
))}
</div>
</nav>
);
}
// ----------------------------------------
// کارت و چیپ
// ----------------------------------------
export function Card({ children, className = "" }: { children: ReactNode; className?: string }) {
return <div className={`rounded-2xl border border-slate-200/70 bg-white shadow-sm ${className}`}>{children}</div>;
}
const toneClasses: Record<Tone, string> = {
red: "bg-red-100 text-red-700",
amber: "bg-amber-100 text-amber-700",
green: "bg-emerald-100 text-emerald-700",
sky: "bg-sky-100 text-sky-700",
slate: "bg-slate-200 text-slate-600",
};
export function Chip({
tone = "slate",
children,
className = "",
title,
}: {
tone?: Tone;
children: ReactNode;
className?: string;
title?: string;
}) {
return (
<span
title={title}
className={`inline-flex max-w-full items-center gap-1 overflow-hidden rounded-full px-2 py-0.5 text-[11px] font-medium ${toneClasses[tone]} ${className}`}
>
<span className="truncate">{children}</span>
</span>
);
}
// ----------------------------------------
// نوار پیشرفت
// ----------------------------------------
export function ProgressBar({ value, className = "" }: { value: number; className?: string }) {
const v = Math.max(0, Math.min(100, Math.round(value)));
return (
<div className={`h-1.5 w-full overflow-hidden rounded-full bg-slate-200 ${className}`}>
<div
className={`h-full rounded-full ${v >= 100 ? "bg-emerald-500" : "bg-sky-500"}`}
style={{ width: `${v}%` }}
/>
</div>
);
}
// ----------------------------------------
// حالت خالی و خطا
// ----------------------------------------
export function EmptyState({
icon: Icon,
title,
description,
}: {
icon: LucideIcon;
title: string;
description?: string;
}) {
return (
<div className="flex flex-col items-center justify-center gap-2 py-10 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-slate-100 text-slate-400">
<Icon size={22} />
</div>
<p className="text-sm font-medium text-slate-500">{title}</p>
{description && <p className="px-6 text-xs leading-5 text-slate-400">{description}</p>}
</div>
);
}
export function ErrorState({ message, onRetry }: { message?: string; onRetry: () => void }) {
return (
<div className="flex flex-col items-center justify-center gap-3 py-10 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-red-50 text-red-500">
<AlertTriangle size={22} />
</div>
<p className="px-6 text-sm font-medium text-slate-600">{message ?? "خطا در دریافت اطلاعات"}</p>
<button type="button" onClick={onRetry} className={`${btnSecondary} h-10 px-5 text-xs`}>
تلاش مجدد
</button>
</div>
);
}
// ----------------------------------------
// اسکلتون بارگذاری
// ----------------------------------------
export function Skeleton({ className = "" }: { className?: string }) {
return <div className={`animate-pulse rounded-lg bg-slate-200/80 ${className}`} />;
}
export function CardSkeleton() {
return (
<div className="rounded-2xl border border-slate-200/70 bg-white p-4 shadow-sm">
<Skeleton className="h-4 w-2/3" />
<Skeleton className="mt-2 h-3 w-1/3" />
<Skeleton className="mt-3 h-1.5 w-full" />
</div>
);
}
// ----------------------------------------
// کومبوباکس (سلکت با قابلیت جستجو)
// ----------------------------------------
export interface ComboboxOption {
value: string;
label: string;
hint?: string;
}
export function Combobox({
value,
options,
onChange,
placeholder = "انتخاب کنید...",
searchPlaceholder = "جستجو...",
emptyMessage = "موردی یافت نشد",
disabled = false,
}: {
value: string;
options: ComboboxOption[];
onChange: (value: string) => void;
placeholder?: string;
searchPlaceholder?: string;
emptyMessage?: string;
disabled?: boolean;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onPointerDown = (e: MouseEvent) => {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onPointerDown);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("mousedown", onPointerDown);
document.removeEventListener("keydown", onKeyDown);
};
}, [open]);
const selected = options.find((o) => o.value === value) ?? null;
const normalized = query.trim().toLowerCase();
const filtered = normalized
? options.filter(
(o) =>
o.label.toLowerCase().includes(normalized) ||
(o.hint ?? "").toLowerCase().includes(normalized),
)
: options;
const pick = (v: string) => {
onChange(v);
setOpen(false);
};
return (
<div ref={rootRef} className="relative">
<button
type="button"
disabled={disabled}
onClick={() => {
setOpen((v) => !v);
setQuery("");
}}
className={`${inputCls} flex items-center justify-between gap-2 text-right`}
>
<span className={`min-w-0 flex-1 truncate ${selected ? "" : "text-slate-400"}`}>
{selected?.label ?? placeholder}
</span>
<ChevronDown size={16} className={`shrink-0 text-slate-400 transition-transform ${open ? "rotate-180" : ""}`} />
</button>
{open && (
<div className="absolute inset-x-0 top-full z-40 mt-1.5 overflow-hidden rounded-xl border border-slate-200 bg-white shadow-lg">
<div className="border-b border-slate-100 p-2">
<div className="relative">
<Search size={14} className="pointer-events-none absolute start-2.5 top-1/2 -translate-y-1/2 text-slate-400" />
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && filtered.length > 0) {
e.preventDefault();
pick(filtered[0].value);
}
}}
placeholder={searchPlaceholder}
className={`${inputCls} h-9 ps-8 text-xs`}
/>
</div>
</div>
<div className="max-h-56 overflow-y-auto p-1">
{filtered.length === 0 ? (
<p className="py-4 text-center text-xs text-slate-400">{emptyMessage}</p>
) : (
filtered.map((o) => (
<button
key={o.value}
type="button"
onClick={() => pick(o.value)}
className={`flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-right text-xs transition-colors ${
o.value === value
? "bg-sky-50 font-bold text-sky-700"
: "text-slate-700 hover:bg-slate-50 active:bg-slate-100"
}`}
>
<span className="min-w-0 flex-1 truncate">{o.label}</span>
{o.hint && <span className="shrink-0 text-[10px] text-slate-400">{o.hint}</span>}
{o.value === value && <Check size={14} className="shrink-0 text-sky-600" />}
</button>
))
)}
</div>
</div>
)}
</div>
);
}
// ----------------------------------------
// مودال (بات‌شیت موبایلی)
// ----------------------------------------
export function Modal({
open,
title,
onClose,
children,
}: {
open: boolean;
title: string;
onClose: () => void;
children: ReactNode;
}) {
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-end justify-center bg-slate-900/50 sm:items-center sm:p-4"
onClick={onClose}
>
<div
className="max-h-[85dvh] w-full max-w-[420px] overflow-y-auto rounded-t-2xl bg-white p-4 pb-8 shadow-xl sm:rounded-2xl sm:pb-4"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-bold text-slate-800">{title}</h3>
<button
type="button"
onClick={onClose}
aria-label="بستن"
className="flex h-9 w-9 items-center justify-center rounded-full text-slate-400 active:bg-slate-100"
>
<X size={18} />
</button>
</div>
{children}
</div>
</div>
);
}
// ----------------------------------------
// آواتار حرف اول
// ----------------------------------------
export function InitialAvatar({ name, className = "" }: { name: string | null | undefined; className?: string }) {
const initial = (name ?? "").trim().charAt(0).toUpperCase();
return (
<span
className={`flex shrink-0 items-center justify-center rounded-full bg-sky-100 text-[10px] font-bold text-sky-700 ${className || "h-6 w-6"}`}
>
{initial || "؟"}
</span>
);
}
export function ConfirmSheet({
open,
title,
message,
confirmLabel = "حذف",
pending,
onClose,
onConfirm,
}: {
open: boolean;
title: string;
message: string;
confirmLabel?: string;
pending: boolean;
onClose: () => void;
onConfirm: () => void;
}) {
return (
<Modal open={open} title={title} onClose={onClose}>
<p className="text-xs leading-6 text-slate-600">{message}</p>
<div className="mt-4 grid grid-cols-2 gap-2">
<button type="button" onClick={onClose} className={btnSecondary}>
انصراف
</button>
<button type="button" disabled={pending} onClick={onConfirm} className={btnDanger}>
{pending ? "در حال حذف..." : confirmLabel}
</button>
</div>
</Modal>
);
}
export { ChevronDown };
@@ -0,0 +1,14 @@
import { useEffect, useRef } from "react";
/** صدا زدن callback هنگام کلیک بیرون از عنصر */
export function useClickOutside<T extends HTMLElement>(onOutside: () => void): React.RefObject<T | null> {
const ref = useRef<T | null>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onOutside();
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [onOutside]);
return ref;
}
+352
View File
@@ -0,0 +1,352 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Folder, Plus, ShieldCheck, UserPlus } from "lucide-react";
import {
createUser,
getProjects,
getUsers,
setUserProjects,
updateUser,
type ManagedUser,
type Role,
} from "../lib/api";
import {
Card,
Chip,
ErrorState,
Modal,
Skeleton,
btnPrimary,
btnSecondary,
inputCls,
apiErrorToast,
} from "./ui";
import { useAuthStore } from "../store/auth";
import { roleLabels } from "../lib/roles";
/**
* مدیریت کاربران و محدوده دسترسی آن‌ها (فقط مدیر پروژه).
*
* «پروژه‌های اختصاص‌یافته» همان چیزی است که سرپرست پروژه اجازه دیدن و ثبت
* اجرا روی آن‌ها را دارد؛ بدون انتخاب پروژه، سرپرست هیچ داده‌ای نمی‌بیند.
*/
export function UsersAccessPanel() {
const queryClient = useQueryClient();
const me = useAuthStore((s) => s.user);
const usersQuery = useQuery({ queryKey: ["users"], queryFn: getUsers });
const [projectsTarget, setProjectsTarget] = useState<ManagedUser | null>(null);
const [showCreate, setShowCreate] = useState(false);
const invalidate = () => void queryClient.invalidateQueries({ queryKey: ["users"] });
const roleMutation = useMutation({
mutationFn: (args: { userId: string; role: Role }) => updateUser(args.userId, { role: args.role }),
onSuccess: (user) => {
toast.success(`نقش ${user.display_name || user.username} به «${user.role_label}» تغییر کرد`);
invalidate();
},
onError: (err) => apiErrorToast(err, "تغییر نقش ناموفق بود"),
});
const users = usersQuery.data ?? [];
return (
<>
<Card className="p-4">
<div className="flex items-center justify-between gap-2">
<h2 className="flex items-center gap-1.5 text-sm font-bold text-slate-700">
<ShieldCheck size={15} className="text-sky-500" />
کاربران و دسترسی‌ها
</h2>
<button
type="button"
onClick={() => setShowCreate(true)}
className="inline-flex h-8 items-center gap-1 rounded-full bg-sky-50 px-2.5 text-[11px] font-semibold text-sky-700 active:bg-sky-100"
>
<UserPlus size={13} />
کاربر جدید
</button>
</div>
<p className="mt-2 text-[11px] leading-5 text-slate-400">
«مدیر پروژه» به همه پروژه‌ها دسترسی دارد؛ «سرپرست پروژه» فقط پروژه‌های اختصاص‌یافته را می‌بیند و می‌تواند پیشرفت و
نتیجه بازرسی‌ها را ثبت کند.
</p>
{usersQuery.isPending ? (
<div className="mt-3 space-y-2">
<Skeleton className="h-14 w-full" />
<Skeleton className="h-14 w-full" />
</div>
) : usersQuery.isError ? (
<div className="mt-3">
<ErrorState message="خطا در دریافت کاربران" onRetry={() => void usersQuery.refetch()} />
</div>
) : (
<div className="mt-3 divide-y divide-slate-100">
{users.map((user) => (
<div key={user.id} className="py-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-bold text-slate-800">
{user.display_name || user.username}
</p>
<p dir="ltr" className="mt-0.5 text-right text-[10px] text-slate-400">
{user.username}
</p>
</div>
<Chip tone={user.scoped ? "green" : "sky"}>{user.role_label}</Chip>
</div>
<div className="mt-2 flex items-center gap-2">
<select
aria-label={`نقش ${user.username}`}
value={user.role}
disabled={roleMutation.isPending || user.id === me?.id}
onChange={(e) =>
roleMutation.mutate({ userId: user.id, role: e.target.value as Role })
}
className={`${inputCls} h-9 min-w-0 flex-1 px-2 text-[11px]`}
>
{(Object.keys(roleLabels) as Role[]).map((role) => (
<option key={role} value={role}>
{roleLabels[role]}
</option>
))}
</select>
{user.scoped && (
<button
type="button"
onClick={() => setProjectsTarget(user)}
className={`${btnSecondary} h-9 shrink-0 px-2.5 text-[11px]`}
>
<Folder size={13} />
پروژه‌ها ({user.project_ids.length})
</button>
)}
</div>
{user.id === me?.id && (
<p className="mt-1 text-[10px] text-slate-400">نقش حساب فعلی خود را نمی‌توانید تغییر دهید.</p>
)}
</div>
))}
</div>
)}
</Card>
{projectsTarget && (
<ProjectsSheet
user={projectsTarget}
onClose={() => setProjectsTarget(null)}
onDone={() => {
setProjectsTarget(null);
invalidate();
}}
/>
)}
{showCreate && (
<CreateUserSheet
onClose={() => setShowCreate(false)}
onDone={() => {
setShowCreate(false);
invalidate();
}}
/>
)}
</>
);
}
// ----------------------------------------
// تخصیص پروژه به یک کاربر
// ----------------------------------------
function ProjectsSheet({
user,
onClose,
onDone,
}: {
user: ManagedUser;
onClose: () => void;
onDone: () => void;
}) {
const projectsQuery = useQuery({ queryKey: ["projects"], queryFn: getProjects });
const [selected, setSelected] = useState<Set<number>>(() => new Set(user.project_ids));
const saveMutation = useMutation({
mutationFn: () => setUserProjects(user.id, [...selected].sort((a, b) => a - b)),
onSuccess: (updated) => {
toast.success(`محدوده دسترسی ${updated.display_name || updated.username} به‌روزرسانی شد`);
onDone();
},
onError: (err) => apiErrorToast(err, "ذخیره پروژه‌ها ناموفق بود"),
});
const toggle = (opProjectId: number) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(opProjectId)) next.delete(opProjectId);
else next.add(opProjectId);
return next;
});
};
const projects = projectsQuery.data ?? [];
return (
<Modal open title={`پروژه‌های ${user.display_name || user.username}`} onClose={onClose}>
<p className="mb-3 text-[11px] leading-5 text-slate-500">
سرپرست پروژه فقط می‌تواند پروژه‌های انتخاب‌شده را ببیند و روی آن‌ها اجرا ثبت کند. بدون انتخاب، هیچ پروژه‌ای در
دسترس او نیست.
</p>
{projectsQuery.isPending ? (
<div className="space-y-2">
<Skeleton className="h-11 w-full" />
<Skeleton className="h-11 w-full" />
</div>
) : projectsQuery.isError ? (
<ErrorState message="خطا در دریافت پروژه‌ها" onRetry={() => void projectsQuery.refetch()} />
) : projects.length === 0 ? (
<p className="py-3 text-center text-[11px] text-slate-400">پروژه‌ای در دسترس نیست</p>
) : (
<div className="max-h-72 space-y-2 overflow-y-auto">
{projects.map((p) => {
const checked = selected.has(p.op_project_id);
return (
<button
key={p.op_project_id}
type="button"
onClick={() => toggle(p.op_project_id)}
className={`flex w-full items-center justify-between gap-2 rounded-xl border px-3 py-2.5 text-right text-xs font-medium ${
checked
? "border-sky-300 bg-sky-50 text-sky-800"
: "border-slate-200 text-slate-700 active:bg-slate-100"
}`}
>
<span className="min-w-0 flex-1 truncate">{p.name}</span>
{checked && <Chip tone="sky">انتخاب شده</Chip>}
</button>
);
})}
</div>
)}
<div className="mt-4 grid grid-cols-2 gap-2">
<button type="button" onClick={onClose} className={btnSecondary}>
انصراف
</button>
<button
type="button"
disabled={saveMutation.isPending}
onClick={() => saveMutation.mutate()}
className={btnPrimary}
>
{saveMutation.isPending ? "در حال ذخیره..." : "ذخیره"}
</button>
</div>
</Modal>
);
}
// ----------------------------------------
// ساخت کاربر جدید
// ----------------------------------------
function CreateUserSheet({ onClose, onDone }: { onClose: () => void; onDone: () => void }) {
const [username, setUsername] = useState("");
const [displayName, setDisplayName] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState<Role>("project_supervisor");
const [error, setError] = useState<string | null>(null);
const createMutation = useMutation({
mutationFn: () =>
createUser({
username: username.trim(),
password,
display_name: displayName.trim(),
role,
}),
onSuccess: (user) => {
toast.success(`کاربر ${user.display_name || user.username} ساخته شد`);
onDone();
},
onError: (err) => apiErrorToast(err, "ساخت کاربر ناموفق بود"),
});
const submit = () => {
setError(null);
if (!username.trim()) return setError("نام کاربری را وارد کنید");
if (password.length < 6) return setError("رمز عبور باید حداقل ۶ نویسه باشد");
createMutation.mutate();
};
return (
<Modal open title="کاربر جدید" onClose={onClose}>
<label htmlFor="nu-username" className="mb-1 block text-[11px] font-medium text-slate-500">
نام کاربری *
</label>
<input
id="nu-username"
dir="ltr"
value={username}
onChange={(e) => setUsername(e.target.value)}
className={`${inputCls} text-left`}
/>
<label htmlFor="nu-name" className="mb-1 mt-3 block text-[11px] font-medium text-slate-500">
نام نمایشی
</label>
<input id="nu-name" value={displayName} onChange={(e) => setDisplayName(e.target.value)} className={inputCls} />
<label htmlFor="nu-password" className="mb-1 mt-3 block text-[11px] font-medium text-slate-500">
رمز عبور *
</label>
<input
id="nu-password"
type="password"
dir="ltr"
value={password}
onChange={(e) => setPassword(e.target.value)}
className={`${inputCls} text-left`}
/>
<label htmlFor="nu-role" className="mb-1 mt-3 block text-[11px] font-medium text-slate-500">
نقش *
</label>
<select
id="nu-role"
value={role}
onChange={(e) => setRole(e.target.value as Role)}
className={inputCls}
>
{(Object.keys(roleLabels) as Role[]).map((r) => (
<option key={r} value={r}>
{roleLabels[r]}
</option>
))}
</select>
{role === "project_supervisor" && (
<p className="mt-2 flex items-start gap-1.5 text-[11px] leading-5 text-slate-500">
<Plus size={13} className="mt-1 shrink-0 text-slate-400" />
پس از ساخت، پروژه‌های این سرپرست را از دکمه «پروژه‌ها» در فهرست کاربران اختصاص دهید.
</p>
)}
{error && <p className="mt-2 rounded-lg bg-red-50 p-2 text-[11px] text-red-600">{error}</p>}
<div className="mt-4 grid grid-cols-2 gap-2">
<button type="button" onClick={onClose} className={btnSecondary}>
انصراف
</button>
<button type="button" disabled={createMutation.isPending} onClick={submit} className={btnPrimary}>
{createMutation.isPending ? "در حال ساخت..." : "ساخت کاربر"}
</button>
</div>
</Modal>
);
};
+90
View File
@@ -0,0 +1,90 @@
@import "tailwindcss";
@theme {
--font-sans: "Vazirmatn", "Tahoma", ui-sans-serif, system-ui, sans-serif;
}
html {
font-family: "Vazirmatn", "Tahoma", ui-sans-serif, system-ui, sans-serif;
}
body {
background-color: var(--color-slate-200);
-webkit-tap-highlight-color: transparent;
}
/* اسلایدر پیشرفت کمی بلندتر برای لمس راحت */
input[type="range"] {
height: 1.75rem;
}
/* ---- اسلایدر زوم گانت (عمودی) ------------------------------------------
قاعدهٔ بالا height همهٔ اسلایدرها را ۱.۷۵rem می‌کند و اسلایدر عمودی را به یک
نوار خیلی کوتاه تبدیل می‌کند؛ پس ارتفاع و ریل اینجا صریح تعیین می‌شود.
نکتهٔ وسط‌چینی دستگیره: در اسلایدر عمودی، محور «عرض» برای دستگیره همان
محور بلوکی است. پس برای این‌که دایره روی ریل وسط بنشیند باید هم عرض ریل و
هم ضخامت دستگیره صریح تعیین شوند و دستگیره با margin در محور بلوکی
وسط‌چین شود. اگر این‌ها را به مرورگر بسپاریم، دستگیره یک‌طرف ریل می‌افتد. */
input[type="range"].zoom-range {
--zoom-track: 0.5rem; /* عرض ریل */
--zoom-thumb: 1.375rem; /* قطر دایره */
--zoom-shift: calc((var(--zoom-thumb) - var(--zoom-track)) / 2);
height: 8rem;
width: var(--zoom-thumb); /* جا برای دایره؛ ریل باریک‌تر است */
writing-mode: vertical-lr;
direction: rtl;
accent-color: var(--color-sky-600);
background: transparent;
/* خود عنصر نباید روی محور بلوکی padding داشته باشد تا جمع عرض‌ها دقیق بماند */
padding: 0;
border: 0;
}
/* ریل: دقیقاً وسط عرض عنصر */
input[type="range"].zoom-range::-webkit-slider-runnable-track {
width: var(--zoom-track);
margin-inline: auto;
border-radius: 9999px;
background: var(--color-slate-200);
}
/* دستگیره: وسط‌چین روی ریل. مرورگر دستگیره را نسبت به لبهٔ ریل می‌چسباند،
پس به اندازهٔ نصف اختلاف، به سمت داخل کشیده می‌شود. */
input[type="range"].zoom-range::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
box-sizing: border-box;
width: var(--zoom-thumb);
height: var(--zoom-thumb);
margin-block-start: calc(var(--zoom-shift) * 1);
margin-inline-start: calc(var(--zoom-shift) * 1);
border-radius: 9999px;
background: var(--color-sky-600);
border: 3px solid #fff;
box-shadow: 0 1px 4px rgb(15 23 42 / 0.35);
cursor: grab;
}
input[type="range"].zoom-range::-webkit-slider-thumb:active {
cursor: grabbing;
}
input[type="range"].zoom-range::-moz-range-track {
width: var(--zoom-track);
border-radius: 9999px;
background: var(--color-slate-200);
}
input[type="range"].zoom-range::-moz-range-thumb {
box-sizing: border-box;
width: var(--zoom-thumb);
height: var(--zoom-thumb);
margin-inline-start: calc(var(--zoom-shift) * -1);
border-radius: 9999px;
background: var(--color-sky-600);
border: 3px solid #fff;
box-shadow: 0 1px 4px rgb(15 23 42 / 0.35);
cursor: grab;
}
File diff suppressed because it is too large Load Diff
+123
View File
@@ -0,0 +1,123 @@
/**
* سازگاری «تاریخ شروع + تاریخ پایان + تعداد روز» — یک قاعده برای همهٔ فرم‌ها.
*
* قاعده: مدت **فاصلهٔ بین دو تاریخ** است و روزِ پایان در آن حساب نمی‌شود،
* یعنی `پایان = شروع + روز`.
* مثال: شروع ۱۶ مرداد و ۴ روز ⇒ پایان ۲۰ مرداد.
*
* همین قاعده در سرور هم پیاده شده است (`backend/app/utils/date_service.py`:
* `calculate_duration` و `compute_end_date`) و این دو تابع معکوس هم‌اند، پس
* عددی که کاربر وارد می‌کند بعد از ذخیره و بارگذاری مجدد همان می‌ماند.
*
* هر فرمی که این سه فیلد را دارد باید ازین ماژول استفاده کند تا رفتار
* همه‌جا یکسان بماند (فرم تسک/زیرتسک، ویرایش تسک، کشوی گانت).
*
* تصمیم‌های طراحی:
* - هر مقدار ISO که با «تاریخ» کار دارد با `isoToApiDate` به «YYYY-MM-DD»
* تبدیل می‌شود؛ هرگز مستقیم از `new Date(iso)` برای اختلاف روز استفاده
* نکنید (تغییر ساعت تابستانی می‌تواند اختلاف را یک روز جابه‌جا کند).
* - «آخرین فیلدی که کاربر دست زده» تعیین می‌کند کدام مقدار مبنا باشد.
* - شروع و پایان هم‌روز ⇒ مدت صفر (هم‌راستا با سرور).
*/
import { isoToApiDate } from "./jalali";
/** فاصلهٔ دو تاریخ به روز (روز پایان حساب نمی‌شود). */
export function daysBetweenInclusive(
startIso: string | null | undefined,
endIso: string | null | undefined,
): number | null {
const start = isoToApiDate(startIso ?? null);
const end = isoToApiDate(endIso ?? null);
if (!start || !end) return null;
const s = new Date(`${start}T00:00:00`);
const e = new Date(`${end}T00:00:00`);
if (Number.isNaN(s.getTime()) || Number.isNaN(e.getTime())) return null;
// هم‌روز = صفر (مثل سرور)؛ بازهٔ معکوس هم صفر می‌شود
return Math.max(0, Math.round((e.getTime() - s.getTime()) / 86400000));
}
/** تاریخ پایان ISO را از شروع + فاصلهٔ روز می‌سازد. */
export function endFromStartAndDays(
startIso: string | null | undefined,
days: number,
): string | null {
const start = isoToApiDate(startIso ?? null);
if (!start) return null;
const d = new Date(`${start}T00:00:00`);
if (Number.isNaN(d.getTime())) return null;
d.setDate(d.getDate() + Math.max(0, days));
return d.toISOString();
}
/** تاریخ شروع ISO را از پایان و فاصلهٔ روز می‌سازد. */
export function startFromEndAndDays(
endIso: string | null | undefined,
days: number,
): string | null {
const end = isoToApiDate(endIso ?? null);
if (!end) return null;
const d = new Date(`${end}T00:00:00`);
if (Number.isNaN(d.getTime())) return null;
d.setDate(d.getDate() - Math.max(0, days));
return d.toISOString();
}
/** فیلدی که کاربر آخر از همه تغییر داده — مبناي محاسبهٔ بقیه است. */
export type DateField = "start" | "end" | "days";
export interface DateTriple {
start: string | null;
end: string | null;
days: number | "";
}
/**
* با تغییر هر یک از سه فیلد، دو فیلد دیگر را سازگار می‌کند.
* `field` همان فیلدی است که کاربر تغییر داده و مبنای محاسبه است.
*/
export function reconcileDates(
next: DateTriple,
field: DateField,
): DateTriple {
const hasDays = typeof next.days === "number" && next.days > 0;
if (field === "days") {
if (!hasDays) return next;
const days = next.days as number;
// مبنا: اگر پایان را دست زده بودند همان می‌ماند، وگرنه شروع
if (next.end && !next.start) {
return { ...next, start: startFromEndAndDays(next.end, days) };
}
if (next.start) {
return { ...next, end: endFromStartAndDays(next.start, days) };
}
return next;
}
// تغییر یکی از دو تاریخ: اگر «تعداد روز» وارد شده، آن را مبنای هم‌سانی می‌گیریم
const derived = hasDays
? field === "start"
? { ...next, end: endFromStartAndDays(next.start, next.days as number) }
: { ...next, start: startFromEndAndDays(next.end, next.days as number) }
: next;
// و اگر هر دو تاریخ موجودند، تعداد روز خودش را هم‌سان می‌کند
const computed = daysBetweenInclusive(derived.start, derived.end);
return computed !== null ? { ...derived, days: computed } : derived;
}
/**
* اگر یکی از تاریخ‌ها خالی باشد و تعداد روز وارد شده باشد، آن را پر می‌کند
* (برای فرم‌هایی که روی ذخیره این کار را می‌کنند).
*/
export function fillMissingDate(
startIso: string | null,
endIso: string | null,
days: number | "",
): { start: string | null; end: string | null } {
const hasDays = typeof days === "number" && days > 0;
if (!hasDays) return { start: startIso, end: endIso };
if (startIso && !endIso) return { start: startIso, end: endFromStartAndDays(startIso, days) };
if (endIso && !startIso) return { start: startFromEndAndDays(endIso, days), end: endIso };
return { start: startIso, end: endIso };
}
+274
View File
@@ -0,0 +1,274 @@
import type { Tone } from "../components/ui";
import { isoToJalaliLong, isoToJalaliStr, toFa } from "./jalali";
// ----------------------------------------
// تاریخ و زمان
// ----------------------------------------
/**
* نمایش تاریخ به شکل شمسی: «۱۴۰۵/۰۶/۲۳»
*
* مقایسه‌ها (isOverdue و…) همیشه روی مقدار خام ISO انجام می‌شوند، نه روی این رشته.
*/
export function formatDate(value: string | null | undefined): string {
if (!value) return "—";
const text = isoToJalaliStr(value);
return text || "—";
}
/** نمایش «۲۳ شهریور ۱۴۰۵» — خوانا تر برای زیرنویس‌ها */
export function formatDateLong(value: string | null | undefined): string {
if (!value) return "—";
const text = isoToJalaliLong(value);
return text || "—";
}
export function formatTime(value: string | null | undefined): string {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "—";
return toFa(`${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`);
}
/** تاریخ شمسی + ساعت — مثال: «۱۴۰۵/۰۶/۲۳ ۱۴:۰۵» */
export function formatDateTime(value: string | null | undefined): string {
if (!value) return "—";
const date = formatDate(value);
if (date === "—") return "—";
const time = formatTime(value);
return time === "—" ? date : `${date} ${time}`;
}
/** زمان نسبی به سبک فارسی: «۵ دقیقه پیش» / «۳ ساعت دیگر» */
export function relativeTime(value: string | null | undefined): string {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "—";
const diffMs = Date.now() - date.getTime();
const abs = Math.abs(diffMs);
const suffix = diffMs < 0 ? "دیگر" : "پیش";
const MINUTE = 60_000;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
if (abs < MINUTE) return "همین حالا";
if (abs < HOUR) return `${Math.round(abs / MINUTE)} دقیقه ${suffix}`;
if (abs < DAY) return `${Math.round(abs / HOUR)} ساعت ${suffix}`;
if (abs < 30 * DAY) return `${Math.round(abs / DAY)} روز ${suffix}`;
return formatDate(value);
}
/** آیا تاریخ سررسید گذشته است؟ */
export function isOverdue(dueDate: string | null | undefined): boolean {
if (!dueDate) return false;
const date = new Date(dueDate);
if (Number.isNaN(date.getTime())) return false;
const today = new Date();
const due = new Date(date.getFullYear(), date.getMonth(), date.getDate());
const now = new Date(today.getFullYear(), today.getMonth(), today.getDate());
return due.getTime() < now.getTime();
}
// ----------------------------------------
// برچسب‌های فارسی
// ----------------------------------------
export function qcStatusMeta(status: string | null | undefined): { label: string; tone: Tone } {
switch (status) {
case "pre_qc_pending":
return { label: "منتظر بازرسی پیش از اجرا", tone: "amber" };
case "pre_qc_failed":
return { label: "بازرسی پیش از اجرا مردود", tone: "red" };
case "ready":
return { label: "آماده اجرا", tone: "sky" };
case "exec_qc_pending":
return { label: "منتظر بازرسی اجرا", tone: "amber" };
case "qc_failed_rework":
return { label: "QC مردود — نیاز به اصلاح", tone: "red" };
case "final_passed":
return { label: "تأیید نهایی", tone: "green" };
case "in_progress":
return { label: "در حال اجرا", tone: "sky" };
case "awaiting_qc":
return { label: "منتظر QC", tone: "amber" };
case "qc_passed":
return { label: "QC قبول", tone: "green" };
case "final_qc":
return { label: "QC نهایی", tone: "amber" };
case "blocked":
return { label: "مسدود", tone: "slate" };
default:
return { label: "بدون QC", tone: "slate" };
}
}
export function taskStatusLabel(status: string | null | undefined): string {
if (!status) return "—";
const key = status.toLowerCase().replace(/[\s-]+/g, "_");
const map: Record<string, string> = {
new: "جدید",
in_progress: "در حال اجرا",
closed: "بسته",
locked: "قفل شده",
rejected: "رد شده",
};
return map[key] ?? status;
}
export function typeLabel(type: string | null | undefined): string {
if (!type) return "—";
const map: Record<string, string> = {
task: "وظیفه",
phase: "فاز",
project: "پروژه",
};
return map[type.toLowerCase()] ?? type;
}
export function priorityMeta(priority: string | null | undefined): { label: string; tone: Tone } | null {
switch ((priority ?? "").toLowerCase()) {
case "high":
return { label: "اولویت بالا", tone: "red" };
case "medium":
return { label: "اولویت متوسط", tone: "amber" };
case "low":
return { label: "اولویت پایین", tone: "slate" };
default:
return null;
}
}
/** سه مرحله QC — «پیش از اجرا» اول می‌آید */
export const qcPhases: Array<{ value: string; label: string }> = [
{ value: "pre", label: "پیش از اجرا" },
{ value: "during", label: "هنگام اجرا" },
{ value: "after", label: "پس از اجرا" },
];
export function phaseLabel(phase: string | null | undefined): string {
switch (phase) {
case "pre":
return "پیش از اجرا";
case "after":
return "پس از اجرا";
case "during":
return "هنگام اجرا";
default:
return phase ?? "—";
}
}
export function phaseTone(phase: string | null | undefined): Tone {
if (phase === "pre") return "amber";
if (phase === "during") return "sky";
return "slate";
}
/** وضعیت محاسبه‌شده در سرور برای یک فاز یا کل QC */
export function qcSummaryStatusMeta(status: string | null | undefined): { label: string; tone: Tone } {
switch (status) {
case "passed":
return { label: "تأیید شده", tone: "green" };
case "failed":
return { label: "مردود", tone: "red" };
case "pending":
return { label: "در انتظار بازرسی", tone: "amber" };
default:
return { label: "بدون سؤال", tone: "slate" };
}
}
export function answerMeta(answer: string | null | undefined): { label: string; tone: Tone } {
switch (answer) {
case "pass":
return { label: "قبول", tone: "green" };
case "fail":
return { label: "مردود", tone: "red" };
case "na":
return { label: "مطرح نیست", tone: "slate" };
default:
return { label: "بدون پاسخ", tone: "amber" };
}
}
export function severityMeta(severity: string): { label: string; tone: Tone } {
switch (severity) {
case "high":
return { label: "شدت زیاد", tone: "red" };
case "medium":
return { label: "شدت متوسط", tone: "amber" };
default:
return { label: "شدت کم", tone: "slate" };
}
}
export function severityLabel(severity: string): string {
switch (severity) {
case "high":
return "زیاد";
case "medium":
return "متوسط";
default:
return "کم";
}
}
/** گزینه‌های شدت عیب (برای بات‌شیت ثبت عیب) */
export const severityOptions: Array<{ value: string; label: string }> = [
{ value: "low", label: "کم" },
{ value: "medium", label: "متوسط" },
{ value: "high", label: "زیاد" },
];
/** گزینه‌های سه‌گانه پاسخ بازرسی */
export const answerOptions: Array<{ value: string; label: string; activeCls: string }> = [
{ value: "pass", label: "قبول", activeCls: "bg-emerald-500 text-white shadow-sm" },
{ value: "fail", label: "مردود", activeCls: "bg-red-500 text-white shadow-sm" },
{ value: "na", label: "مطرح نیست", activeCls: "bg-slate-500 text-white shadow-sm" },
];
export function defectStatusMeta(status: string): { label: string; tone: Tone } {
return status === "resolved" ? { label: "رفع شده", tone: "green" } : { label: "باز", tone: "red" };
}
export function syncDirectionLabel(direction: string): string {
if (direction === "push") return "ارسال";
if (direction === "pull") return "دریافت";
return direction;
}
export function logLevelClass(level: string): string {
switch (level.toLowerCase()) {
case "error":
return "text-red-600";
case "warn":
case "warning":
return "text-amber-600";
default:
return "text-slate-500";
}
}
// ----------------------------------------
// فاز ۲: مصالح و منابع
// ----------------------------------------
/** عدد بدون صفر اضافی: ۲۵۰.۰ → «۲۵۰» */
export function formatQty(value: number | null | undefined): string {
if (value === null || value === undefined || Number.isNaN(value)) return "—";
return String(Math.round(value * 1000) / 1000);
}
/** سطح موجودی محاسبه‌شده در سرور */
export function availabilityMeta(level: string | null | undefined): { label: string; tone: Tone } {
switch (level) {
case "ok":
return { label: "موجود", tone: "green" };
case "partial":
return { label: "کمبود جزیی", tone: "amber" };
case "shortage":
return { label: "ناموجود", tone: "red" };
default:
return { label: "نامشخص", tone: "slate" };
}
}
+175
View File
@@ -0,0 +1,175 @@
// ============================================================
// ابزار تقویم شمسی — پورت jalali.js به TypeScript
// تبدیل‌ها با الگوریتم استاندارد Kazimierz M. Borkowski
// (کپی مستقل برای PM — بدون وابستگی بیرونی)
// ============================================================
const FA_DIGITS = ["۰", "۱", "۲", "۳", "۴", "۵", "۶", "۷", "۸", "۹"];
export function div(a: number, b: number): number {
return Math.floor(a / b);
}
export function jalaliToGregorian(jy: number, jm: number, jd: number): [number, number, number] {
jy = +jy;
jm = +jm;
jd = +jd;
let gy = jy <= 979 ? 621 : 1600;
jy -= jy <= 979 ? 0 : 979;
let days =
365 * jy +
div(jy, 33) * 8 +
div((jy % 33) + 3, 4) +
78 +
jd +
(jm < 7 ? (jm - 1) * 31 : (jm - 7) * 30 + 186);
gy += 400 * div(days, 146097);
days %= 146097;
if (days > 36524) {
gy += 100 * div(--days, 36524);
if (days === 36524) days = 0;
days++;
}
gy += 4 * div(days, 1461);
days %= 1461;
if (days > 365) {
gy += div(days - 1, 365);
days = (days - 1) % 365;
}
let gd = days + 1;
const sal_a = [
0,
31,
(gy % 4 === 0 && gy % 100 !== 0) || gy % 400 === 0 ? 29 : 28,
31,
30,
31,
30,
31,
31,
30,
31,
30,
31,
];
let gm = 0;
for (; gm < 13 && gd > sal_a[gm]; gm++) gd -= sal_a[gm];
return [gy, gm, gd];
}
export function gregorianToJalali(gy: number, gm: number, gd: number): [number, number, number] {
gy = +gy;
gm = +gm;
gd = +gd;
const g_d_m = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
let jy = gy <= 1600 ? 0 : 979;
gy -= gy <= 1600 ? 621 : 1600;
let days =
365 * gy +
div(gy + 3, 4) -
div(gy + 99, 100) +
div(gy + 399, 400) -
80 +
gd +
g_d_m[gm - 1];
jy += 33 * div(days, 12053);
days %= 12053;
jy += 4 * div(days, 1461);
days %= 1461;
if (days > 365) {
jy += div(days - 1, 365);
days = (days - 1) % 365;
}
const jm = days < 186 ? 1 + div(days, 31) : 7 + div(days - 186, 30);
const jd = 1 + (days < 186 ? days % 31 : (days - 186) % 30);
return [jy, jm, jd];
}
export function isLeapJalali(jy: number): boolean {
const breaks = [-61, 9, 38, 199, 426, 686, 756, 818, 1111, 1181, 1210, 1635, 2060, 2097, 2192, 2262, 2324, 2394, 2456, 3178];
const jp = breaks[0];
const jm = breaks[1];
let jump = 0;
let jp2 = jp;
for (const jl of breaks) {
jump = jl - jp2;
if (jy <= jl) break;
jp2 = jl;
}
let n = jy - jm;
if (n < jump) n = n + jump + 1;
return (n % 33) % 4 === 1;
}
// ---------- قالب‌بندی با ارقام فارسی ----------
export function toFa(s: string | number): string {
return String(s).replace(/[0-9]/g, (d) => FA_DIGITS[Number(d)]);
}
/** ارقام فارسی/عربی → لاتین */
export function toEn(s: string | number): string {
return String(s)
.replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d)))
.replace(/[٠-٩]/g, (d) => String("٠١٢٣٤٥٦٧٨٩".indexOf(d)));
}
export const MONTHS = [
"فروردین", "اردیبهشت", "خرداد", "تیر", "مرداد", "شهریور",
"مهر", "آبان", "آذر", "دی", "بهمن", "اسفند",
];
export const WEEKDAYS = ["ش", "ی", "د", "س", "چ", "پ", "ج"];
// تبدیل ISO میلادی به «۱۴۰۳/۰۵/۱۲»
export function isoToJalaliStr(iso: string | null | undefined): string {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
const [jy, jm, jd] = gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate());
return `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))}`;
}
/** نمایش بلند: «۱۸ مرداد ۱۴۰۴» */
export function isoToJalaliLong(iso: string | null | undefined): string {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
const [jy, jm, jd] = gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate());
return `${toFa(jd)} ${MONTHS[jm - 1]} ${toFa(jy)}`;
}
// تبدیل «۱۴۰۳/۰۵/۱۲» به ISO میلادی (نیمه‌شب محلی)
export function jalaliStrToIso(str: string | null | undefined): string | null {
if (!str) return null;
const nums = toEn(String(str))
.split(/[/\-.]/)
.map((x) => parseInt(x, 10));
if (nums.length < 3 || nums.some((n) => Number.isNaN(n))) return null;
const [jy, jm, jd] = nums;
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
const dt = new Date(gy, gm - 1, gd);
return Number.isNaN(dt.getTime()) ? null : dt.toISOString();
}
/**
* UI ISO (ممکن است زمان داشته باشد) → «YYYY-MM-DD» بر مبنای تقویم محلی
* (همان چیزی که API برای فیلدهای تاریخ‌محور انتظار دارد).
*
* مهم: هرگز از `iso.slice(0, 10)` استفاده نکنید — `new Date(y, m, d).toISOString()`
* در UTC+3:30 روز میلادی قبلی را برمی‌گرداند.
*/
export function isoToApiDate(iso: string | null): string | null {
if (!iso) return null;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return null;
const y = d.getFullYear();
const mo = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${mo}-${day}`;
}
export function daysInMonth(jy: number, jm: number): number {
if (jm <= 6) return 31;
if (jm <= 11) return 30;
return isLeapJalali(jy) ? 30 : 29;
}
+78
View File
@@ -0,0 +1,78 @@
import type { LucideIcon } from "lucide-react";
import { ClipboardCheck, Folder, Home, Settings } from "lucide-react";
import type { Role, User } from "./api";
/**
* آینه سمت کلاینت از مجوزهای بک‌اند (`backend/app/authz.py`).
*
* مرجع نهایی همان `user.permissions` است که سرور برمی‌گرداند؛ ثابت‌های زیر فقط
* برای اشاره به نام مجوزها و ساخت ناوبری به کار می‌روند. سرور مستقل از این
* فایل، همه عملیات را دوباره بررسی می‌کند؛ پنهان‌کردن دکمه هرگز جایگزین
* کنترل دسترسی نیست.
*/
export const Perm = {
dashboardView: "dashboard.view",
projectsView: "projects.view",
projectsCreate: "projects.create",
projectsUpdate: "projects.update",
tasksView: "tasks.view",
tasksCreate: "tasks.create",
tasksUpdate: "tasks.update",
tasksDelete: "tasks.delete",
tasksProgress: "tasks.progress",
tasksDates: "tasks.dates",
qcView: "qc.view",
qcExecute: "qc.execute",
qcDefine: "qc.define",
materialsView: "materials.view",
materialsManage: "materials.manage",
usersManage: "users.manage",
filesUpload: "files.upload",
} as const;
export type Permission = (typeof Perm)[keyof typeof Perm];
export const roleLabels: Record<Role, string> = {
project_manager: "مدیر پروژه",
project_supervisor: "سرپرست پروژه",
};
export function hasPermission(user: User | null | undefined, permission: Permission | string): boolean {
return user?.permissions?.includes(permission) ?? false;
}
export function isManager(user: User | null | undefined): boolean {
return user?.role === "project_manager";
}
/** برچسب کوتاه محدوده دسترسی — برای هدر داشبورد و کارت کاربر */
export function scopeLabel(user: User | null | undefined): string {
if (!user) return "";
if (user.scope?.all_projects) return "همه پروژه‌ها";
const count = user.scope?.project_count ?? 0;
return count > 0 ? `${count} پروژه اختصاص‌یافته` : "بدون پروژه اختصاص‌یافته";
}
// ----------------------------------------
// ناوبری بر پایه مجوز — تب‌ها خودکار با نقش عوض می‌شوند
// ----------------------------------------
export interface NavTab {
to: string;
label: string;
icon: LucideIcon;
end?: boolean;
/** فقط برای کاربری نمایش داده می‌شود که این مجوز را داشته باشد */
permission: Permission;
}
export const navTabs: NavTab[] = [
{ to: "/", label: "امروز", icon: Home, end: true, permission: Perm.dashboardView },
{ to: "/projects", label: "پروژه‌ها", icon: Folder, permission: Perm.projectsView },
{ to: "/quality", label: "کیفیت", icon: ClipboardCheck, permission: Perm.qcDefine },
{ to: "/settings", label: "تنظیمات", icon: Settings, permission: Perm.dashboardView },
];
export function visibleTabs(user: User | null | undefined): NavTab[] {
return navTabs.filter((tab) => hasPermission(user, tab.permission));
}
+28
View File
@@ -0,0 +1,28 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Toaster } from "sonner";
import App from "./App";
import { ErrorBoundary } from "./components/ErrorBoundary";
import "./index.css";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 1,
refetchOnWindowFocus: false,
},
},
});
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<ErrorBoundary>
<App />
</ErrorBoundary>
<Toaster position="top-center" dir="rtl" richColors closeButton />
</QueryClientProvider>
</React.StrictMode>,
);
+95
View File
@@ -0,0 +1,95 @@
import { useState } from "react";
import type { FormEvent } from "react";
import { Navigate, useNavigate } from "react-router-dom";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { ClipboardCheck } from "lucide-react";
import { ApiError, login } from "../lib/api";
import { useAuthStore } from "../store/auth";
import { PhoneShell, btnPrimary, inputCls } from "../components/ui";
export default function LoginPage() {
const navigate = useNavigate();
const token = useAuthStore((s) => s.token);
const setAuth = useAuthStore((s) => s.setAuth);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const loginMutation = useMutation({
mutationFn: () => login(username.trim(), password),
onSuccess: (data) => {
setAuth(data.token, data.user);
toast.success(`خوش آمدید، ${data.user.display_name || data.user.username}`);
navigate("/", { replace: true });
},
onError: (err) => {
setError(err instanceof ApiError ? err.message : "ورود ناموفق بود");
},
});
if (token) return <Navigate to="/" replace />;
const onSubmit = (e: FormEvent) => {
e.preventDefault();
setError(null);
if (!username.trim() || !password) {
setError("نام کاربری و رمز عبور را وارد کنید");
return;
}
loginMutation.mutate();
};
return (
<PhoneShell>
<div className="flex min-h-dvh flex-col justify-center px-6 py-10">
<div className="mb-8 flex flex-col items-center gap-3 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-sky-600 text-white shadow-lg shadow-sky-200">
<ClipboardCheck size={30} />
</div>
<div>
<h1 className="text-xl font-extrabold text-slate-800">PM</h1>
<p className="mt-1 text-xs text-slate-500">سامانه مدیریت عملیاتی پروژه</p>
</div>
</div>
<form onSubmit={onSubmit} className="rounded-2xl border border-slate-200/70 bg-white p-5 shadow-sm">
<label htmlFor="username" className="mb-1.5 block text-xs font-semibold text-slate-600">
نام کاربری
</label>
<input
id="username"
dir="ltr"
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className={`${inputCls} text-left`}
placeholder="username"
/>
<label htmlFor="password" className="mb-1.5 mt-4 block text-xs font-semibold text-slate-600">
رمز عبور
</label>
<input
id="password"
type="password"
dir="ltr"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className={`${inputCls} text-left`}
placeholder="••••••••"
/>
{error && <p className="mt-3 rounded-xl bg-red-50 px-3 py-2 text-xs leading-5 text-red-600">{error}</p>}
<button type="submit" disabled={loginMutation.isPending} className={`${btnPrimary} mt-5 w-full`}>
{loginMutation.isPending ? "در حال ورود..." : "ورود"}
</button>
</form>
<p className="mt-8 text-center text-[11px] text-slate-400">PM v0.1</p>
</div>
</PhoneShell>
);
}
File diff suppressed because it is too large Load Diff
+293
View File
@@ -0,0 +1,293 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Folder, Plus, RefreshCw, Users, MapPin, Calendar } from "lucide-react";
import { createProject, getProjects } from "../lib/api";
import type { CreateProjectPayload, Project } from "../lib/api";
import {
Card,
CardSkeleton,
Chip,
EmptyState,
ErrorState,
Modal,
PageHeader,
PanelsMenu,
ProgressBar,
btnPrimary,
btnSecondary,
inputCls,
} from "../components/ui";
import { useAuthStore } from "../store/auth";
import { Perm, hasPermission } from "../lib/roles";
function ProjectCard({ project }: { project: Project }) {
const targetId = project.id || project.op_project_id;
const statusLabels: Record<string, { label: string; tone: "green" | "sky" | "amber" | "slate" }> = {
PLANNING: { label: "برنامه‌ریزی", tone: "amber" },
PLANNED: { label: "برنامه‌ریزی", tone: "amber" },
ACTIVE: { label: "در حال اجرا", tone: "green" },
ON_HOLD: { label: "متوقف", tone: "amber" },
COMPLETED: { label: "تکمیل‌شده", tone: "sky" },
CANCELLED: { label: "لغو شده", tone: "slate" },
ARCHIVED: { label: "بایگانی", tone: "slate" },
};
const currentStatus = project.status ? statusLabels[project.status.toUpperCase()] : null;
return (
<Link to={`/projects/${targetId}`} className="block">
<Card className="p-4 active:bg-slate-100/60 transition-all hover:border-slate-300">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="min-w-0 truncate text-sm font-bold text-slate-800">{project.name}</p>
{currentStatus && <Chip tone={currentStatus.tone}>{currentStatus.label}</Chip>}
</div>
<p dir="ltr" className="mt-0.5 text-right font-mono text-[11px] text-slate-400">
{project.identifier}
</p>
</div>
</div>
{project.location && (
<div className="mt-2 flex flex-wrap items-center gap-3 text-xs text-slate-500">
<span className="inline-flex items-center gap-1">
<MapPin size={13} className="text-slate-400" />
{project.location}
</span>
</div>
)}
{(project.planned_start_jalali || project.planned_end_jalali || project.actual_start_jalali) && (
<div className="mt-2 flex flex-wrap items-center gap-2 text-[11px] text-slate-500">
{(project.planned_start_jalali || project.planned_end_jalali) && (
<span className="inline-flex items-center gap-1">
<Calendar size={12} className="text-slate-400" />
<span>برنامه:</span>
<span className="font-medium text-slate-700">
{project.planned_start_jalali || "—"} تا {project.planned_end_jalali || "—"}
</span>
</span>
)}
{project.actual_start_jalali && (
<span className="inline-flex items-center gap-1 rounded bg-emerald-50 px-1.5 py-0.5 text-emerald-700">
<span>واقعی:</span>
<span>{project.actual_start_jalali}</span>
{project.actual_end_jalali && <span> تا {project.actual_end_jalali}</span>}
</span>
)}
</div>
)}
{project.progress !== undefined && (
<div className="mt-3">
<div className="flex items-center justify-between text-[11px] text-slate-500 mb-1">
<span>پیشرفت پروژه</span>
<span className="font-bold tabular-nums text-slate-700">{Math.round(project.progress)}٪</span>
</div>
<ProgressBar value={project.progress} />
</div>
)}
<div className="mt-3 flex flex-wrap items-center gap-1.5">
<Chip tone="sky">کل: {project.stats.total}</Chip>
<Chip tone="amber">انتظار QC: {project.stats.awaiting_qc}</Chip>
{project.stats.failed_qc > 0 && <Chip tone="red">مردود: {project.stats.failed_qc}</Chip>}
{project.stats.overdue > 0 && <Chip tone="red">عقب‌افتاده: {project.stats.overdue}</Chip>}
{project.member_count !== undefined && project.member_count > 0 && (
<span className="ms-auto inline-flex items-center gap-1 text-[11px] text-slate-400">
<Users size={12} />
{project.member_count} عضو
</span>
)}
</div>
</Card>
</Link>
);
}
function CreateProjectModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const queryClient = useQueryClient();
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [location, setLocation] = useState("");
const [projectManager, setProjectManager] = useState("");
const [errorMsg, setErrorMsg] = useState("");
const createMutation = useMutation({
mutationFn: (payload: CreateProjectPayload) => createProject(payload),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["projects"] });
onClose();
setName("");
setDescription("");
setLocation("");
setProjectManager("");
setErrorMsg("");
},
onError: (err: any) => {
setErrorMsg(err?.message || "خطا در ثبت پروژه");
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) {
setErrorMsg("نام پروژه الزامی است");
return;
}
setErrorMsg("");
createMutation.mutate({
name: name.trim(),
description: description.trim(),
location: location.trim(),
project_manager: projectManager.trim(),
});
};
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>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="مثال: برج مسکونی آسمان"
required
className={inputCls}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div>
<label className="mb-1 block text-xs font-semibold text-slate-600">محل پروژه</label>
<input
value={location}
onChange={(e) => setLocation(e.target.value)}
placeholder="موقعیت جغرافیایی / شهر"
className={inputCls}
/>
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-slate-600">مدیر / سرپرست پروژه</label>
<input
value={projectManager}
onChange={(e) => setProjectManager(e.target.value)}
placeholder="نام سرپرست"
className={inputCls}
/>
</div>
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-slate-600">توضیحات</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
placeholder="توضیحات تکمیلی پروژه..."
className="w-full rounded-xl border border-slate-300 p-2.5 text-sm text-slate-800 focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-100"
/>
</div>
<div className="flex items-center justify-end gap-2 pt-2">
<button type="button" onClick={onClose} className={btnSecondary}>
انصراف
</button>
<button type="submit" disabled={createMutation.isPending} className={btnPrimary}>
{createMutation.isPending ? "در حال ثبت..." : "ایجاد پروژه"}
</button>
</div>
</form>
</Modal>
);
}
export default function ProjectsPage() {
const queryClient = useQueryClient();
const user = useAuthStore((s) => s.user);
const isSupervisor = (user?.role as string) === "project_supervisor" || (user?.role as string) === "supervisor";
const canCreate = !isSupervisor && hasPermission(user, Perm.projectsCreate);
const [showCreateModal, setShowCreateModal] = useState(false);
const projectsQuery = useQuery({ queryKey: ["projects"], queryFn: getProjects });
const refresh = () => {
void queryClient.invalidateQueries({ queryKey: ["projects"] });
};
return (
<>
<PageHeader
title="پروژه‌ها"
menu={<PanelsMenu />}
right={
<div className="flex items-center gap-1">
{canCreate && (
<button
type="button"
onClick={() => setShowCreateModal(true)}
className="inline-flex h-9 items-center gap-1.5 rounded-lg bg-sky-600 px-3 text-xs font-semibold text-white transition-colors active:bg-sky-700"
>
<Plus size={16} />
<span>پروژه جدید</span>
</button>
)}
<button
type="button"
onClick={refresh}
aria-label="به‌روزرسانی"
className="flex h-11 w-11 items-center justify-center rounded-full text-slate-500 active:bg-slate-100"
>
<RefreshCw size={20} className={projectsQuery.isFetching ? "animate-spin" : ""} />
</button>
</div>
}
/>
<div className="px-4 pb-28 pt-4">
{projectsQuery.isPending ? (
<div className="space-y-3">
<CardSkeleton />
<CardSkeleton />
<CardSkeleton />
</div>
) : projectsQuery.isError ? (
<ErrorState onRetry={refresh} />
) : (
<>
{(projectsQuery.data ?? []).length === 0 ? (
<Card>
<EmptyState
icon={Folder}
title={canCreate ? "پروژه‌ای ثبت نشده است" : "پروژه‌ای به شما اختصاص داده نشده است"}
description={
canCreate
? "برای شروع کار، روی دکمه «پروژه جدید» در بالا بزنید."
: "در حال حاضر هیچ پروژه‌ای به شما تخصیص نیافته است. مدیر پروژه باید از بخش کاربران دسترسی شما را تعریف کند."
}
/>
</Card>
) : (
<div className="space-y-3">
{(projectsQuery.data ?? []).map((p) => (
<ProjectCard key={p.id || p.op_project_id} project={p} />
))}
</div>
)}
</>
)}
</div>
<CreateProjectModal open={showCreateModal} onClose={() => setShowCreateModal(false)} />
</>
);
}
+956
View File
@@ -0,0 +1,956 @@
import { useRef, useState, useMemo } from "react";
import { Link } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
CheckCircle2,
ChevronDown,
ChevronLeft,
ClipboardCheck,
ExternalLink,
Layers,
Pencil,
Plus,
Search,
ShieldAlert,
Trash2,
Wrench,
X,
} from "lucide-react";
import {
createQcTemplate,
deleteQcQuestion,
deleteQcTemplate,
getDefects,
getQcTemplates,
replaceQcTemplateQuestions,
resolveDefect,
updateQcTemplate,
} from "../lib/api";
import type { Defect, QcPhase, QcQuestionInput, QcTemplate, QcTemplateQuestion } from "../lib/api";
import {
Card,
CardSkeleton,
Chip,
EmptyState,
ErrorState,
Modal,
PageHeader,
PanelsMenu,
btnPrimary,
btnSecondary,
inputCls,
ConfirmSheet,
apiErrorToast,
} from "../components/ui";
import { defectStatusMeta, formatDate, phaseTone, qcPhases, relativeTime, severityMeta } from "../lib/format";
import { useAuthStore } from "../store/auth";
import { Perm, hasPermission } from "../lib/roles";
// ----------------------------------------
// شناسه یکتای پایدار برای ردیف‌های ویرایشگر
// ----------------------------------------
let keyCounter = 0;
function newRowKey(): string {
keyCounter += 1;
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `row-${keyCounter}-${Date.now()}`;
}
interface DraftQuestion {
key: string;
phase: QcPhase;
text: string;
required: boolean;
}
function draftFrom(questions: QcTemplateQuestion[]): DraftQuestion[] {
return questions.map((q) => ({
key: newRowKey(),
phase: q.phase,
text: q.text,
required: q.required,
}));
}
function toPayload(rows: DraftQuestion[]): QcQuestionInput[] {
const out: QcQuestionInput[] = [];
for (const phase of qcPhases) {
const inPhase = rows.filter((r) => r.phase === phase.value && r.text.trim());
inPhase.forEach((r, index) => {
out.push({ phase: r.phase, text: r.text.trim(), required: r.required, sort_order: index });
});
}
return out;
}
// ----------------------------------------
// ویرایشگر قالب QC
// ----------------------------------------
function QcTemplateEditor({
template,
onClose,
}: {
template: QcTemplate | null;
onClose: () => void;
}) {
const queryClient = useQueryClient();
const [name, setName] = useState(() => template?.name ?? "");
const [activityType, setActivityType] = useState(() => template?.activity_type ?? "");
const [description, setDescription] = useState(() => template?.description ?? "");
const [rows, setRows] = useState<DraftQuestion[]>(() => {
const initial = template ? draftFrom(template.questions) : [];
return initial.length > 0 ? initial : [{ key: newRowKey(), phase: "pre", text: "", required: true }];
});
const [formError, setFormError] = useState<string | null>(null);
const patchRow = (key: string, patch: Partial<DraftQuestion>) => {
setRows((prev) => prev.map((row) => (row.key === key ? { ...row, ...patch } : row)));
};
const addRow = (phase: QcPhase = "pre") => {
setRows((prev) => [...prev, { key: newRowKey(), phase, text: "", required: true }]);
};
const removeRow = (key: string) => {
setRows((prev) => prev.filter((row) => row.key !== key));
};
const invalidate = () => void queryClient.invalidateQueries({ queryKey: ["qc-templates"] });
const createMutation = useMutation({
mutationFn: (payload: { name: string; activity_type: string; description: string; questions: QcQuestionInput[] }) =>
createQcTemplate(payload),
onSuccess: () => {
invalidate();
onClose();
},
onError: (err) => apiErrorToast(err, "ایجاد قالب ناموفق بود"),
});
const updateMutation = useMutation({
mutationFn: async (payload: {
name: string;
activity_type: string;
description: string;
questions: QcQuestionInput[];
}) => {
if (!template) return;
await updateQcTemplate(template.id, {
name: payload.name,
activity_type: payload.activity_type,
description: payload.description,
});
await replaceQcTemplateQuestions(template.id, payload.questions);
},
onSuccess: () => {
invalidate();
onClose();
},
onError: (err) => apiErrorToast(err, "ذخیره قالب ناموفق بود"),
});
const pending = createMutation.isPending || updateMutation.isPending;
const submit = () => {
setFormError(null);
const trimmedName = name.trim();
if (!trimmedName) {
setFormError("نام قالب را وارد کنید");
return;
}
const questions = toPayload(rows);
if (questions.length === 0) {
setFormError("حداقل متن یک سؤال را وارد کنید");
return;
}
const payload = {
name: trimmedName,
activity_type: activityType.trim(),
description: description.trim(),
questions,
};
if (template) updateMutation.mutate(payload);
else createMutation.mutate(payload);
};
const grouped = qcPhases.map((phase) => ({
phase: phase.value as QcPhase,
label: phase.label,
rows: rows.filter((r) => r.phase === phase.value),
}));
return (
<Modal open title={template ? `ویرایش «${template.name}»` : "قالب جدید بازرسی کیفی"} onClose={onClose}>
<label htmlFor="qc-name" className="mb-1 block text-[11px] font-medium text-slate-500">
نام قالب *
</label>
<input id="qc-name" value={name} onChange={(e) => setName(e.target.value)} className={inputCls} placeholder="مثال: کنترل کیفیت اجرای فونداسیون" />
<label htmlFor="qc-activity" className="mb-1 mt-3 block text-[11px] font-medium text-slate-500">
نوع فعالیت / دسته
</label>
<input
id="qc-activity"
value={activityType}
onChange={(e) => setActivityType(e.target.value)}
className={inputCls}
placeholder="مثلاً: سازه، تأسیسات، نازک‌کاری"
/>
<label htmlFor="qc-desc" className="mb-1 mt-3 block text-[11px] font-medium text-slate-500">
توضیحات و دستورالعمل
</label>
<textarea
id="qc-desc"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
className={`${inputCls} h-auto py-2`}
placeholder="توضیحات تکمیلی یا مراجع آیین‌نامه‌ای..."
/>
<p className="mb-1.5 mt-4 text-[11px] font-medium text-slate-500">سؤالات و چک‌لیست مراحل *</p>
<div className="space-y-4 max-h-[50vh] overflow-y-auto px-1">
{grouped.map((group) => (
<div key={group.phase} className="rounded-xl border border-slate-200/80 bg-slate-50/50 p-2.5">
<div className="mb-2 flex items-center justify-between gap-2">
<Chip tone={phaseTone(group.phase)}>{group.label}</Chip>
<button
type="button"
onClick={() => addRow(group.phase)}
className="inline-flex h-7 items-center gap-1 rounded-lg bg-white border border-slate-200 px-2.5 text-[11px] font-semibold text-sky-700 hover:bg-sky-50 shadow-sm"
>
<Plus size={12} />
افزودن سؤال
</button>
</div>
{group.rows.length === 0 ? (
<p className="py-1 text-[11px] text-slate-400">سؤالی در این مرحله نیست</p>
) : (
<div className="space-y-2">
{group.rows.map((row, indexInPhase) => (
<div key={row.key} className="flex items-center gap-2">
<input
value={row.text}
onChange={(e) => patchRow(row.key, { text: e.target.value })}
placeholder={`متن بازرسی ${indexInPhase + 1}`}
className={`${inputCls} h-9 text-xs flex-1`}
/>
<button
type="button"
onClick={() => removeRow(row.key)}
aria-label="حذف سؤال"
title="حذف سؤال"
className="flex h-9 w-8 shrink-0 items-center justify-center rounded-lg text-red-500 hover:bg-red-50 transition-colors"
>
<Trash2 size={15} />
</button>
</div>
))}
</div>
)}
</div>
))}
</div>
{formError && <p className="mt-2 rounded-lg bg-red-50 p-2 text-[11px] text-red-600">{formError}</p>}
<div className="mt-4 grid grid-cols-2 gap-2">
<button type="button" onClick={onClose} className={btnSecondary}>
انصراف
</button>
<button type="button" disabled={pending} onClick={submit} className={btnPrimary}>
{pending ? "در حال ذخیره..." : template ? "ذخیره تغییرات" : "ایجاد قالب"}
</button>
</div>
</Modal>
);
}
// ----------------------------------------
// جزئیات قالب بازرسی
// ----------------------------------------
function TemplateQuestions({ template }: { template: QcTemplate }) {
const [deleteTarget, setDeleteTarget] = useState<QcTemplateQuestion | null>(null);
const queryClient = useQueryClient();
const deleteMutation = useMutation({
mutationFn: (questionId: string) => deleteQcQuestion(questionId),
onSuccess: () => {
setDeleteTarget(null);
void queryClient.invalidateQueries({ queryKey: ["qc-templates"] });
},
onError: (err) => apiErrorToast(err, "حذف سؤال ناموفق بود"),
});
return (
<>
<div className="space-y-3 pt-1">
{qcPhases.map((phase) => {
const questions = template.questions.filter((q) => q.phase === phase.value);
if (questions.length === 0) return null;
return (
<div key={phase.value} className="rounded-lg border border-slate-100 bg-slate-50/70 p-2.5">
<div className="mb-1 flex items-center justify-between">
<Chip tone={phaseTone(phase.value)}>{phase.label}</Chip>
<span className="text-[10px] text-slate-400 font-medium">{questions.length} بازرسی</span>
</div>
<div className="mt-1 divide-y divide-slate-200/60">
{questions.map((q) => (
<div key={q.id} className="flex items-start justify-between gap-2 py-1.5">
<p className="min-w-0 flex-1 text-xs leading-5 text-slate-700">{q.text}</p>
<div className="flex items-center gap-1.5 shrink-0">
<button
type="button"
onClick={() => setDeleteTarget(q)}
aria-label="حذف سؤال"
title="حذف سؤال"
className="flex h-7 w-7 items-center justify-center rounded-lg text-slate-400 hover:text-red-600 hover:bg-red-50 transition-colors"
>
<Trash2 size={13} />
</button>
</div>
</div>
))}
</div>
</div>
);
})}
</div>
{deleteTarget && (
<ConfirmSheet
open
title="حذف سؤال از قالب"
message={`آیا سؤال «${deleteTarget.text}» از این قالب حذف شود؟`}
pending={deleteMutation.isPending}
onClose={() => setDeleteTarget(null)}
onConfirm={() => deleteMutation.mutate(deleteTarget.id)}
/>
)}
</>
);
}
// ----------------------------------------
// مودال ثبت رفع عدم انطباق (Defect Resolution Modal)
// ----------------------------------------
function DefectResolutionModal({
defect,
onClose,
onResolved,
}: {
defect: Defect;
onClose: () => void;
onResolved: () => void;
}) {
const [notes, setNotes] = useState("");
const resolveMutation = useMutation({
mutationFn: () => resolveDefect(defect.id, notes),
onSuccess: () => {
onResolved();
onClose();
},
onError: (err) => apiErrorToast(err, "ثبت رفع عیب ناموفق بود"),
});
return (
<Modal open title="ثبت رفع عدم انطباق" onClose={onClose}>
<div className="space-y-3">
<div className="rounded-xl border border-slate-200 bg-slate-50/80 p-3">
<span className="text-[11px] font-semibold text-slate-500 block mb-0.5">موضوع عدم انطباق:</span>
<p className="text-xs font-bold text-slate-800">{defect.title}</p>
{defect.task_title && (
<p className="text-[11px] text-slate-500 mt-1">وظیفه: {defect.task_title}</p>
)}
</div>
<div>
<label htmlFor="defect-resolution-notes" className="mb-1 block text-xs font-medium text-slate-600">
اقدامات انجام‌شده و توضیحات رفع عیب *
</label>
<textarea
id="defect-resolution-notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
className={`${inputCls} h-auto py-2`}
placeholder="شرح اقدامات اصلاحی صورت گرفته جهت رفع مشکل..."
/>
</div>
<div className="grid grid-cols-2 gap-2 pt-2">
<button type="button" onClick={onClose} className={btnSecondary}>
انصراف
</button>
<button
type="button"
disabled={resolveMutation.isPending}
onClick={() => resolveMutation.mutate()}
className={btnPrimary}
>
{resolveMutation.isPending ? "در حال ثبت..." : "تأیید و رفع عیب"}
</button>
</div>
</div>
</Modal>
);
}
// ----------------------------------------
// تابلوی جامع عدم انطباق‌ها و عیوب (Defects Board)
// ----------------------------------------
function DefectsBoard({ canExecuteQc }: { canExecuteQc: boolean }) {
const queryClient = useQueryClient();
const [statusFilter, setStatusFilter] = useState<"all" | "open" | "resolved">("all");
const [severityFilter, setSeverityFilter] = useState<"all" | "high" | "medium" | "low">("all");
const [search, setSearch] = useState("");
const [activeDefectToResolve, setActiveDefectToResolve] = useState<Defect | null>(null);
const defectsQuery = useQuery({
queryKey: ["qc-defects", statusFilter === "all" ? undefined : statusFilter],
queryFn: () => getDefects(statusFilter === "all" ? undefined : { status: statusFilter }),
});
const allDefects = defectsQuery.data ?? [];
// محاسبه آمار سریع
const stats = useMemo(() => {
const total = allDefects.length;
const open = allDefects.filter((d) => d.status !== "resolved").length;
const resolved = allDefects.filter((d) => d.status === "resolved").length;
const high = allDefects.filter((d) => d.severity === "high" && d.status !== "resolved").length;
return { total, open, resolved, high };
}, [allDefects]);
// فیلتر کلاینت برای جستجو و شدت
const filteredDefects = useMemo(() => {
return allDefects.filter((d) => {
if (severityFilter !== "all" && d.severity !== severityFilter) return false;
if (search.trim()) {
const query = search.trim().toLowerCase();
const matchesTitle = d.title.toLowerCase().includes(query);
const matchesDesc = (d.description ?? "").toLowerCase().includes(query);
const matchesAction = (d.corrective_action ?? "").toLowerCase().includes(query);
const matchesTask = (d.task_title ?? "").toLowerCase().includes(query);
if (!matchesTitle && !matchesDesc && !matchesAction && !matchesTask) return false;
}
return true;
});
}, [allDefects, severityFilter, search]);
const refreshDefects = () => {
void queryClient.invalidateQueries({ queryKey: ["qc-defects"] });
void queryClient.invalidateQueries({ queryKey: ["task-qc"] });
void queryClient.invalidateQueries({ queryKey: ["tasks"] });
};
return (
<div className="space-y-4">
{/* ردیف کارت‌های خلاصه وضعیت */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
<div className="rounded-xl border border-slate-200 bg-white p-3 shadow-xs">
<span className="text-[11px] text-slate-500 font-medium block">کل موارد ثبت‌شده</span>
<span className="text-lg font-bold text-slate-800 tabular-nums">{stats.total}</span>
</div>
<div className="rounded-xl border border-amber-200 bg-amber-50/60 p-3 shadow-xs">
<span className="text-[11px] text-amber-700 font-medium block">در انتظار رفع (باز)</span>
<span className="text-lg font-bold text-amber-800 tabular-nums">{stats.open}</span>
</div>
<div className="rounded-xl border border-emerald-200 bg-emerald-50/60 p-3 shadow-xs">
<span className="text-[11px] text-emerald-700 font-medium block">رفع‌شده</span>
<span className="text-lg font-bold text-emerald-800 tabular-nums">{stats.resolved}</span>
</div>
<div className="rounded-xl border border-red-200 bg-red-50/60 p-3 shadow-xs">
<span className="text-[11px] text-red-700 font-medium block">موارد بحرانی باز</span>
<span className="text-lg font-bold text-red-800 tabular-nums">{stats.high}</span>
</div>
</div>
{/* نوار جستجو و فیلترها */}
<div className="flex flex-col sm:flex-row gap-2.5 items-stretch sm:items-center justify-between">
<div className="relative flex-1">
<Search size={14} className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="جستجو در موضوع، شرح عیب، اقدام اصلاحی یا وظیفه..."
className={`${inputCls} pr-8.5 h-9 text-xs`}
/>
{search && (
<button
type="button"
onClick={() => setSearch("")}
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
>
<X size={13} />
</button>
)}
</div>
<div className="flex items-center gap-1.5 flex-wrap">
{/* فیلتر وضعیت */}
<div className="flex rounded-lg bg-slate-200/70 p-0.5 text-xs font-medium text-slate-600">
<button
type="button"
onClick={() => setStatusFilter("all")}
className={`rounded-md px-2.5 py-1 transition-all ${
statusFilter === "all" ? "bg-white text-slate-800 shadow-xs font-bold" : "hover:text-slate-900"
}`}
>
همه ({stats.total})
</button>
<button
type="button"
onClick={() => setStatusFilter("open")}
className={`rounded-md px-2.5 py-1 transition-all ${
statusFilter === "open" ? "bg-white text-amber-800 shadow-xs font-bold" : "hover:text-slate-900"
}`}
>
باز ({stats.open})
</button>
<button
type="button"
onClick={() => setStatusFilter("resolved")}
className={`rounded-md px-2.5 py-1 transition-all ${
statusFilter === "resolved" ? "bg-white text-emerald-800 shadow-xs font-bold" : "hover:text-slate-900"
}`}
>
رفع‌شده ({stats.resolved})
</button>
</div>
{/* فیلتر شدت */}
<select
value={severityFilter}
onChange={(e) => setSeverityFilter(e.target.value as any)}
className="h-8.5 rounded-lg border border-slate-200 bg-white px-2 text-xs font-medium text-slate-700 shadow-xs outline-hidden"
>
<option value="all">تمام شدت‌ها</option>
<option value="high">بحرانی (زیاد)</option>
<option value="medium">متوسط</option>
<option value="low">جزئی (کم)</option>
</select>
</div>
</div>
{/* لیست عدم انطباق‌ها */}
{defectsQuery.isPending ? (
<CardSkeleton />
) : defectsQuery.isError ? (
<Card>
<ErrorState onRetry={() => void defectsQuery.refetch()} />
</Card>
) : filteredDefects.length === 0 ? (
<Card>
<EmptyState
icon={CheckCircle2}
title="هیچ عدم انطباقی یافت نشد"
description={
search || severityFilter !== "all" || statusFilter !== "all"
? "با فیلترهای فعلی موردی یافت نشد؛ فیلترها را بازنشانی کنید"
: "تمام بازرسی‌ها بدون اشکال انجام شده و عدم انطباق ثبت‌شده‌ای وجود ندارد"
}
/>
</Card>
) : (
<div className="space-y-2.5">
{filteredDefects.map((defect) => {
const isResolved = defect.status === "resolved";
const sev = severityMeta(defect.severity);
const stMeta = defectStatusMeta(defect.status);
return (
<div
key={defect.id}
className={`rounded-xl border p-3.5 transition-all shadow-xs ${
isResolved
? "border-slate-200 bg-white opacity-85"
: defect.severity === "high"
? "border-red-200 bg-red-50/20"
: "border-slate-200 bg-white"
}`}
>
{/* سرتیتر کارت */}
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="text-xs font-bold text-slate-900">{defect.title}</h3>
<Chip tone={stMeta.tone}>{stMeta.label}</Chip>
<Chip tone={sev.tone}>{sev.label}</Chip>
</div>
{/* وظیفه مرتبط */}
<div className="mt-1.5 flex items-center gap-1.5 text-[11px] text-slate-500">
<Layers size={13} className="text-slate-400 shrink-0" />
<span>وظیفه:</span>
<Link
to={`/tasks/${defect.task_id || defect.task_op_wp_id}`}
className="font-semibold text-sky-700 hover:text-sky-800 hover:underline flex items-center gap-1"
>
<span>{defect.task_title || `تسک #${defect.task_op_wp_id ?? ""}`}</span>
<ExternalLink size={10} />
</Link>
</div>
</div>
<span className="text-[10px] text-slate-400 shrink-0 font-medium">
{relativeTime(defect.created_at)}
</span>
</div>
{/* شرح عیب */}
{defect.description && (
<p className="mt-2 text-xs leading-5 text-slate-600 bg-slate-50/60 rounded-lg p-2 border border-slate-100">
{defect.description}
</p>
)}
{/* اقدام اصلاحی */}
{defect.corrective_action && (
<div className="mt-2 flex items-start gap-1.5 rounded-lg border border-amber-200/80 bg-amber-50/50 p-2 text-xs text-amber-900">
<Wrench size={14} className="text-amber-600 shrink-0 mt-0.5" />
<div>
<span className="font-bold text-[11px] text-amber-800 block">اقدام اصلاحی لازم:</span>
<span className="text-[11px] leading-5">{defect.corrective_action}</span>
</div>
</div>
)}
{/* تصویر پیوست */}
{defect.photo_url && (
<div className="mt-2.5">
<a
href={defect.photo_url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 text-xs text-sky-700 hover:underline font-medium"
>
<img
src={defect.photo_url}
alt="تصویر عدم انطباق"
className="h-14 w-14 rounded-lg object-cover border border-slate-200 shadow-xs"
/>
<span>مشاهده تصویر مستند</span>
</a>
</div>
)}
{/* وضعیت رفع یا دکمه اقدام */}
<div className="mt-3 pt-2.5 border-t border-slate-100 flex items-center justify-between gap-2 flex-wrap">
{isResolved ? (
<div className="flex items-center gap-1.5 text-[11px] text-emerald-700 font-medium">
<CheckCircle2 size={15} className="text-emerald-600 shrink-0" />
<span>
رفع‌شده در تاریخ {formatDate(defect.resolved_at)}
{defect.resolution_notes ? ` — ${defect.resolution_notes}` : ""}
</span>
</div>
) : (
<>
<div className="flex items-center gap-1.5 text-[11px] text-amber-700 font-medium">
<AlertTriangle size={14} className="text-amber-600 shrink-0" />
<span>این مورد باز است و مانع تأیید نهایی یا شروع کار می‌شود</span>
</div>
{canExecuteQc && (
<button
type="button"
onClick={() => setActiveDefectToResolve(defect)}
className="inline-flex h-7.5 items-center gap-1.5 rounded-lg bg-emerald-600 px-3 text-xs font-semibold text-white hover:bg-emerald-700 transition-colors shadow-xs"
>
<CheckCircle2 size={13} />
<span>ثبت رفع عیب</span>
</button>
)}
</>
)}
</div>
</div>
);
})}
</div>
)}
{activeDefectToResolve && (
<DefectResolutionModal
defect={activeDefectToResolve}
onClose={() => setActiveDefectToResolve(null)}
onResolved={refreshDefects}
/>
)}
</div>
);
}
// ----------------------------------------
// صفحه اصلی کنترل کیفیت (Quality Page)
// ----------------------------------------
export default function QualityPage() {
const queryClient = useQueryClient();
const user = useAuthStore((s) => s.user);
const isSupervisor = (user?.role as string) === "project_supervisor" || (user?.role as string) === "supervisor";
const canDefineQc = !isSupervisor && hasPermission(user, Perm.qcDefine);
const canExecuteQc = hasPermission(user, Perm.qcExecute) || isSupervisor;
const [activeTab, setActiveTab] = useState<"templates" | "defects">("templates");
const [templateSearch, setTemplateSearch] = useState("");
const [expandedTemplateId, setExpandedTemplateId] = useState<string | null>(null);
const [editor, setEditor] = useState<{ template: QcTemplate | null } | null>(null);
const [deleteTarget, setDeleteTarget] = useState<QcTemplate | null>(null);
const editorKey = useRef(0);
// واکشی قالب‌ها
const qcQuery = useQuery({ queryKey: ["qc-templates"], queryFn: getQcTemplates });
// واکشی تعداد عیوب باز برای بج ناوبری
const openDefectsQuery = useQuery({
queryKey: ["qc-defects", "open"],
queryFn: () => getDefects({ status: "open" }),
});
const deleteMutation = useMutation({
mutationFn: (templateId: string) => deleteQcTemplate(templateId),
onSuccess: () => {
setDeleteTarget(null);
void queryClient.invalidateQueries({ queryKey: ["qc-templates"] });
},
onError: (err) => apiErrorToast(err, "حذف قالب ناموفق بود"),
});
const templates = qcQuery.data ?? [];
const openDefectsCount = openDefectsQuery.data?.length ?? 0;
const filteredTemplates = useMemo(() => {
if (!templateSearch.trim()) return templates;
const q = templateSearch.trim().toLowerCase();
return templates.filter(
(t) =>
t.name.toLowerCase().includes(q) ||
(t.activity_type ?? "").toLowerCase().includes(q) ||
(t.description ?? "").toLowerCase().includes(q)
);
}, [templates, templateSearch]);
const openEditor = (template: QcTemplate | null) => {
editorKey.current += 1;
setEditor({ template });
};
return (
<>
<PageHeader title="کنترل کیفیت (QC)" menu={<PanelsMenu />} />
<div className="space-y-4 px-4 pb-28 pt-4">
{/* نوار ناوبری دو تب اصلی */}
<div className="flex rounded-xl bg-slate-200/80 p-1 text-xs font-semibold text-slate-600 shadow-inner">
<button
type="button"
onClick={() => setActiveTab("templates")}
className={`flex-1 flex items-center justify-center gap-2 rounded-lg py-2 transition-all ${
activeTab === "templates"
? "bg-white text-slate-800 shadow-sm font-bold"
: "hover:text-slate-900"
}`}
>
<ClipboardCheck size={16} className={activeTab === "templates" ? "text-sky-600" : "text-slate-400"} />
<span>قالب‌های استاندارد بازرسی</span>
<span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-600 font-bold tabular-nums">
{templates.length}
</span>
</button>
<button
type="button"
onClick={() => setActiveTab("defects")}
className={`flex-1 flex items-center justify-center gap-2 rounded-lg py-2 transition-all ${
activeTab === "defects"
? "bg-white text-slate-800 shadow-sm font-bold"
: "hover:text-slate-900"
}`}
>
<ShieldAlert size={16} className={activeTab === "defects" ? "text-amber-600" : "text-slate-400"} />
<span>تابلوی عدم انطباق‌ها و عیوب</span>
{openDefectsCount > 0 && (
<span className="rounded-full bg-amber-500 text-white px-1.5 py-0.5 text-[10px] font-bold tabular-nums shadow-xs">
{openDefectsCount}
</span>
)}
</button>
</div>
{/* محتوای تب ۱: قالب‌های بازرسی کیفی */}
{activeTab === "templates" && (
<div className="space-y-3.5">
<div className="flex items-center justify-between gap-2 flex-wrap">
<div>
<h2 className="text-xs font-bold text-slate-800">قالب‌های چک‌لیست QC</h2>
<p className="text-[11px] text-slate-400 mt-0.5">
قالب‌های از پیش تعریف‌شده برای استانداردسازی بازرسی وظایف عمرانی
</p>
</div>
{canDefineQc && (
<button
type="button"
onClick={() => openEditor(null)}
className="inline-flex h-8.5 items-center gap-1.5 rounded-lg bg-sky-600 px-3 text-xs font-semibold text-white hover:bg-sky-700 transition-colors shadow-xs"
>
<Plus size={14} />
<span>قالب جدید</span>
</button>
)}
</div>
{/* جستجو در قالب‌ها */}
{templates.length > 3 && (
<div className="relative">
<Search size={14} className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input
value={templateSearch}
onChange={(e) => setTemplateSearch(e.target.value)}
placeholder="جستجو بر اساس نام قالب یا نوع فعالیت..."
className={`${inputCls} pr-8.5 h-8.5 text-xs`}
/>
</div>
)}
{qcQuery.isPending ? (
<CardSkeleton />
) : qcQuery.isError ? (
<Card>
<ErrorState onRetry={() => void qcQuery.refetch()} />
</Card>
) : filteredTemplates.length === 0 ? (
<Card>
<EmptyState
icon={ClipboardCheck}
title="قالبی یافت نشد"
description={
templateSearch
? "با عبارت جستجو شده قالبی پیدا نشد"
: "با دکمه «قالب جدید» اولین چک‌لیست استاندارد کیفیت را بسازید"
}
/>
</Card>
) : (
<div className="space-y-2">
{filteredTemplates.map((t) => {
const expanded = expandedTemplateId === t.id;
const preCount = t.questions.filter((q) => q.phase === "pre").length;
const duringCount = t.questions.filter((q) => q.phase === "during").length;
const afterCount = t.questions.filter((q) => q.phase === "after").length;
return (
<Card key={t.id} className="p-0 overflow-hidden border-slate-200/90 shadow-xs">
<button
type="button"
onClick={() => setExpandedTemplateId(expanded ? null : t.id)}
className="flex w-full items-center justify-between gap-3 p-3.5 text-right hover:bg-slate-50/60 transition-colors"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="block truncate text-xs font-bold text-slate-900">{t.name}</span>
{t.activity_type && <Chip tone="slate">{t.activity_type}</Chip>}
</div>
<div className="mt-2 flex flex-wrap items-center gap-1.5 text-[10px]">
<Chip tone="sky">{t.questions.length} سؤال</Chip>
{preCount > 0 && <Chip tone="amber">{preCount} پیش از اجرا</Chip>}
{duringCount > 0 && <Chip tone="sky">{duringCount} حین اجرا</Chip>}
{afterCount > 0 && <Chip tone="slate">{afterCount} پس از اجرا</Chip>}
</div>
</div>
{expanded ? (
<ChevronDown size={16} className="shrink-0 text-slate-400" />
) : (
<ChevronLeft size={16} className="shrink-0 text-slate-400" />
)}
</button>
{expanded && (
<div className="border-t border-slate-100 bg-slate-50/30 px-3.5 py-3">
{t.description && (
<p className="pb-2 text-[11px] leading-5 text-slate-600">{t.description}</p>
)}
{t.questions.length === 0 ? (
<p className="py-2 text-xs text-slate-400">سؤالی برای این قالب ثبت نشده است</p>
) : (
<TemplateQuestions template={t} />
)}
<div className="mt-3 pt-2.5 border-t border-slate-200/60 flex items-center justify-between gap-2 flex-wrap">
<p className="text-[10px] text-slate-400">
بارگذاری این قالب در وظیفه‌ها، یک نسخهٔ مستقل می‌سازد و ویرایش قالب روی کارهای جاری اثر ندارد.
</p>
{canDefineQc && (
<div className="flex items-center gap-1.5 shrink-0">
<button
type="button"
onClick={() => openEditor(t)}
className="inline-flex h-7.5 items-center gap-1 rounded-lg border border-slate-200 bg-white px-2.5 text-xs font-medium text-slate-700 hover:bg-slate-50 transition-colors shadow-xs"
>
<Pencil size={12} />
<span>ویرایش قالب</span>
</button>
<button
type="button"
onClick={() => setDeleteTarget(t)}
className="inline-flex h-7.5 items-center gap-1 rounded-lg border border-red-200 bg-red-50/50 px-2.5 text-xs font-medium text-red-600 hover:bg-red-50 transition-colors"
>
<Trash2 size={12} />
<span>حذف</span>
</button>
</div>
)}
</div>
</div>
)}
</Card>
);
})}
</div>
)}
</div>
)}
{/* محتوای تب ۲: تابلوی عدم انطباق‌ها و عیوب */}
{activeTab === "defects" && <DefectsBoard canExecuteQc={canExecuteQc} />}
</div>
{editor && (
<QcTemplateEditor
key={`qc-editor-${editorKey.current}`}
template={editor.template}
onClose={() => setEditor(null)}
/>
)}
{deleteTarget && (
<ConfirmSheet
open
title="حذف قالب بازرسی کیفی"
message={`آیا قالب «${deleteTarget.name}» با ${deleteTarget.questions.length} سؤال حذف شود؟ این کار روی بازرسی‌های ساخته‌شده از آن در وظیفه‌ها اثری ندارد.`}
pending={deleteMutation.isPending}
onClose={() => setDeleteTarget(null)}
onConfirm={() => deleteMutation.mutate(deleteTarget.id)}
/>
)}
</>
);
}
+52
View File
@@ -0,0 +1,52 @@
import { useNavigate } from "react-router-dom";
import { LogOut } from "lucide-react";
import { roleLabels, scopeLabel } from "../lib/roles";
import { useAuthStore } from "../store/auth";
import { Card, Chip, InitialAvatar, PageHeader, PanelsMenu, btnDanger } from "../components/ui";
export default function SettingsPage() {
const navigate = useNavigate();
const user = useAuthStore((s) => s.user);
const clear = useAuthStore((s) => s.clear);
const logout = () => {
clear();
navigate("/login", { replace: true });
};
return (
<>
<PageHeader title="تنظیمات" menu={<PanelsMenu />} />
<div className="space-y-3 px-4 pb-28 pt-4">
{/* کارت کاربر */}
<Card className="p-4">
<div className="flex items-center gap-3">
<InitialAvatar name={user?.display_name ?? user?.username} className="h-12 w-12 text-base" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-bold text-slate-800">
{user?.display_name || user?.username || "کاربر"}
</p>
<p dir="ltr" className="mt-0.5 text-right text-[11px] text-slate-400">
{user?.username ?? "—"}
</p>
</div>
</div>
<div className="mt-3 flex flex-wrap gap-1.5">
{user && <Chip tone="sky">{roleLabels[user.role]}</Chip>}
<Chip tone="slate">{scopeLabel(user)}</Chip>
</div>
</Card>
{/* خروج */}
<button type="button" onClick={logout} className={`${btnDanger} w-full`}>
<LogOut size={16} />
خروج از حساب
</button>
<p className="pt-4 text-center text-[11px] text-slate-400">PM v0.1</p>
</div>
</>
);
}
File diff suppressed because it is too large Load Diff
+665
View File
@@ -0,0 +1,665 @@
import { useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronDown, ChevronLeft, ClipboardList, Info, Pencil, Plus, Trash2 } from "lucide-react";
import {
addTaskQcQuestion,
createTaskQcDefinition,
deleteTaskQcDefinition,
deleteTaskQcQuestion,
getQcTemplates,
getTaskQc,
updateTaskQcDefinition,
updateTaskQcQuestion,
} from "../lib/api";
import type { QcPhase, QcQuestionPatch, QcTemplate, TaskQcDefinition, TaskQcQuestion } from "../lib/api";
import {
Card,
CardSkeleton,
Chip,
Combobox,
EmptyState,
ErrorState,
Modal,
PageHeader,
btnPrimary,
btnSecondary,
inputCls,
ConfirmSheet,
apiErrorToast,
} from "../components/ui";
import { phaseTone, qcPhases, qcSummaryStatusMeta } from "../lib/format";
const qcKey = (taskId: string | number) => ["task-qc", taskId];
// ----------------------------------------
// بات‌شیت انتخاب قالب سراسری
// ----------------------------------------
function TemplatePickerSheet({
templates,
pending,
onClose,
onSubmit,
}: {
templates: QcTemplate[];
pending: boolean;
onClose: () => void;
onSubmit: (templateId: string | undefined, name?: string) => void;
}) {
const [selected, setSelected] = useState<string>("");
const [customName, setCustomName] = useState("");
const template = templates.find((t) => t.id === selected) ?? null;
const copyCount = template?.questions.length ?? 0;
return (
<Modal open title="تعریف QC برای این وظیفه" onClose={onClose}>
<p className="text-[11px] leading-5 text-slate-500">
سؤالات قالب انتخابی به صورت کپی مستقل روی این وظیفه ساخته می‌شود؛ ویرایش آن‌ها قالب سراسری را تغییر نمی‌دهد.
</p>
<label className="mb-1 mt-3 block text-[11px] font-medium text-slate-500">
قالب سراسری
</label>
<Combobox
value={selected}
onChange={setSelected}
options={[
{ value: "", label: "بدون قالب (تعریف خالی)" },
...templates.map((t) => ({
value: t.id,
label: t.name,
hint: `${t.questions.length} سؤال`,
})),
]}
/>
<label htmlFor="qc-defname" className="mb-1 mt-3 block text-[11px] font-medium text-slate-500">
نام تعریف (اختیاری)
</label>
<input
id="qc-defname"
value={customName}
onChange={(e) => setCustomName(e.target.value)}
className={inputCls}
placeholder={template?.name ?? "QC تسک"}
/>
{template && (
<div className="mt-3 rounded-xl border border-slate-200 p-3">
<p className="text-[11px] font-semibold text-slate-600">
{copyCount} سؤال کپی می‌شود:
</p>
<div className="mt-2 space-y-2">
{qcPhases.map((phase) => {
const questions = template.questions.filter((q) => q.phase === phase.value);
if (questions.length === 0) return null;
return (
<div key={phase.value}>
<Chip tone={phaseTone(phase.value)}>{phase.label}</Chip>
<ul className="mt-1 list-inside list-disc space-y-0.5">
{questions.map((q) => (
<li key={q.id} className="text-[11px] leading-4 text-slate-600">
{q.text}
</li>
))}
</ul>
</div>
);
})}
</div>
</div>
)}
<div className="mt-4 grid grid-cols-2 gap-2">
<button type="button" onClick={onClose} className={btnSecondary}>
انصراف
</button>
<button
type="button"
disabled={pending}
onClick={() => onSubmit(selected || undefined, customName.trim() || undefined)}
className={btnPrimary}
>
{pending ? "در حال ساخت..." : "ایجاد تعریف"}
</button>
</div>
</Modal>
);
}
// ----------------------------------------
// بات‌شیت افزودن/ویرایش سؤال تسک
// ----------------------------------------
function QuestionSheet({
question,
pending,
onClose,
onSubmit,
}: {
/** null یعنی سؤال جدید */
question: TaskQcQuestion | null;
pending: boolean;
onClose: () => void;
onSubmit: (payload: { text: string; phase: QcPhase; required: boolean }) => void;
}) {
const [text, setText] = useState(() => question?.text ?? "");
const [phase, setPhase] = useState<QcPhase>(() => question?.phase ?? "pre");
const submit = () => {
if (!text.trim()) return;
onSubmit({ text: text.trim(), phase, required: true });
};
return (
<Modal open title={question ? "ویرایش سؤال" : "افزودن سؤال"} onClose={onClose}>
<label htmlFor="tq-text" className="mb-1 block text-[11px] font-medium text-slate-500">
متن سؤال *
</label>
<textarea
id="tq-text"
value={text}
onChange={(e) => setText(e.target.value)}
rows={2}
className={`${inputCls} h-auto py-2`}
placeholder="مثلاً: مصالح مورد نیاز موجود است؟"
/>
<label htmlFor="tq-phase" className="mb-1 mt-3 block text-[11px] font-medium text-slate-500">
مرحله
</label>
<select id="tq-phase" value={phase} onChange={(e) => setPhase(e.target.value as QcPhase)} className={inputCls}>
{qcPhases.map((p) => (
<option key={p.value} value={p.value}>
{p.label}
</option>
))}
</select>
<div className="mt-4 grid grid-cols-2 gap-2">
<button type="button" onClick={onClose} className={btnSecondary}>
انصراف
</button>
<button type="button" disabled={pending || !text.trim()} onClick={submit} className={btnPrimary}>
{pending ? "در حال ذخیره..." : question ? "ذخیره" : "افزودن"}
</button>
</div>
</Modal>
);
}
// ----------------------------------------
// یک تعریف QC (نام + سؤالات)
// ----------------------------------------
function DefinitionCard({
definition,
taskId,
onRename,
onDelete,
}: {
definition: TaskQcDefinition;
taskId: string;
onRename: (name: string) => void;
onDelete: () => void;
}) {
const queryClient = useQueryClient();
const [expanded, setExpanded] = useState(true);
const [addPhase, setAddPhase] = useState<QcPhase | null>(null);
const [editTarget, setEditTarget] = useState<TaskQcQuestion | null>(null);
const [deleteQuestion, setDeleteQuestion] = useState<TaskQcQuestion | null>(null);
const [renaming, setRenaming] = useState(false);
const [nameDraft, setNameDraft] = useState(definition.name);
const invalidate = () => void queryClient.invalidateQueries({ queryKey: qcKey(taskId) });
const addMutation = useMutation({
mutationFn: (payload: { text: string; phase: QcPhase; required: boolean }) =>
addTaskQcQuestion(definition.id, payload),
onSuccess: () => {
setAddPhase(null);
invalidate();
},
onError: (err) => apiErrorToast(err, "افزودن سؤال ناموفق بود"),
});
const editMutation = useMutation({
mutationFn: (args: { questionId: string; patch: QcQuestionPatch }) =>
updateTaskQcQuestion(args.questionId, args.patch),
onSuccess: () => {
setEditTarget(null);
invalidate();
},
onError: (err) => apiErrorToast(err, "ویرایش سؤال ناموفق بود"),
});
const reorderMutation = useMutation({
mutationFn: async (swap: { a: TaskQcQuestion; b: TaskQcQuestion }) => {
await updateTaskQcQuestion(swap.a.id, { sort_order: swap.b.sort_order });
await updateTaskQcQuestion(swap.b.id, { sort_order: swap.a.sort_order });
},
onSuccess: invalidate,
onError: (err) => apiErrorToast(err, "جابه‌جایی ناموفق بود"),
});
const deleteQuestionMutation = useMutation({
mutationFn: (questionId: string) => deleteTaskQcQuestion(questionId),
onSuccess: () => {
setDeleteQuestion(null);
invalidate();
},
onError: (err) => apiErrorToast(err, "حذف سؤال ناموفق بود"),
});
const move = (question: TaskQcQuestion, direction: -1 | 1) => {
const inPhase = definition.questions
.filter((q) => q.phase === question.phase)
.sort((a, b) => a.sort_order - b.sort_order);
const pos = inPhase.findIndex((q) => q.id === question.id);
const other = inPhase[pos + direction];
if (!other) return;
reorderMutation.mutate({ a: question, b: other });
};
return (
<Card className="p-0">
<div className="flex items-start justify-between gap-2 p-4">
{renaming ? (
<div className="min-w-0 flex-1">
<input value={nameDraft} onChange={(e) => setNameDraft(e.target.value)} className={`${inputCls} h-10`} />
<div className="mt-2 flex gap-2">
<button
type="button"
onClick={() => {
const next = nameDraft.trim();
if (!next) return;
onRename(next);
setRenaming(false);
}}
className={`${btnPrimary} h-9 flex-1 text-xs`}
>
ذخیره نام
</button>
<button
type="button"
onClick={() => {
setNameDraft(definition.name);
setRenaming(false);
}}
className={`${btnSecondary} h-9 flex-1 text-xs`}
>
انصراف
</button>
</div>
</div>
) : (
<>
<button type="button" onClick={() => setExpanded((v) => !v)} className="min-w-0 flex-1 text-right">
<span className="block truncate text-[13px] font-bold text-slate-800">{definition.name}</span>
<span className="mt-1.5 flex flex-wrap gap-1.5">
<Chip tone="sky">{definition.questions.length} سؤال</Chip>
{definition.source_template_id && <Chip tone="slate">از قالب سراسری</Chip>}
</span>
</button>
<div className="flex shrink-0 items-center gap-0.5">
<button
type="button"
onClick={() => {
setNameDraft(definition.name);
setRenaming(true);
}}
aria-label="تغییر نام"
title="تغییر نام"
className="flex h-9 w-9 items-center justify-center rounded-lg text-slate-500 active:bg-slate-100"
>
<Pencil size={15} />
</button>
<button
type="button"
onClick={onDelete}
aria-label="حذف تعریف"
title="حذف تعریف"
className="flex h-9 w-9 items-center justify-center rounded-lg text-red-500 active:bg-red-50"
>
<Trash2 size={15} />
</button>
<button
type="button"
onClick={() => setExpanded((v) => !v)}
aria-label="باز و بسته کردن"
className="flex h-9 w-9 items-center justify-center rounded-lg text-slate-400 active:bg-slate-100"
>
{expanded ? <ChevronDown size={16} /> : <ChevronLeft size={16} />}
</button>
</div>
</>
)}
</div>
{expanded && (
<div className="border-t border-slate-100 px-4 py-3">
{definition.questions.length === 0 && (
<p className="py-1 text-[11px] text-slate-400">
این تعریف سؤالی ندارد؛ با «افزودن سؤال» شروع کنید.
</p>
)}
<div className="space-y-3">
{qcPhases.map((phase) => {
const questions = definition.questions
.filter((q) => q.phase === phase.value)
.sort((a, b) => a.sort_order - b.sort_order);
return (
<div key={phase.value}>
<div className="mb-1.5 flex items-center justify-between gap-2">
<Chip tone={phaseTone(phase.value)}>{phase.label}</Chip>
<button
type="button"
onClick={() => setAddPhase(phase.value as QcPhase)}
className="inline-flex h-7 items-center gap-1 rounded-full bg-sky-50 px-2 text-[11px] font-semibold text-sky-700 active:bg-sky-100"
>
<Plus size={12} />
افزودن
</button>
</div>
{questions.length === 0 ? (
<p className="py-1 text-[11px] text-slate-400">سؤالی در این مرحله نیست</p>
) : (
<div className="space-y-1.5">
{questions.map((q, indexInPhase) => (
<div key={q.id} className="flex items-start justify-between gap-2 rounded-xl border border-slate-200 p-2.5">
<div className="min-w-0 flex-1">
<p className="text-xs leading-5 text-slate-700">{q.text}</p>
{q.result && (
<div className="mt-1 flex flex-wrap items-center gap-1.5">
<Chip tone="sky">پاسخ ثبت شده</Chip>
</div>
)}
</div>
<div className="flex shrink-0 flex-col gap-0.5">
<button
type="button"
onClick={() => move(q, -1)}
disabled={indexInPhase === 0 || reorderMutation.isPending}
aria-label="انتقال به بالا"
title="انتقال به بالا"
className="flex h-6 w-7 items-center justify-center rounded-md text-slate-400 active:bg-slate-100 disabled:opacity-30"
>
▲
</button>
<button
type="button"
onClick={() => move(q, 1)}
disabled={indexInPhase === questions.length - 1 || reorderMutation.isPending}
aria-label="انتقال به پایین"
title="انتقال به پایین"
className="flex h-6 w-7 items-center justify-center rounded-md text-slate-400 active:bg-slate-100 disabled:opacity-30"
>
▼
</button>
</div>
<div className="flex shrink-0 flex-col gap-0.5">
<button
type="button"
onClick={() => setEditTarget(q)}
aria-label="ویرایش سؤال"
title="ویرایش سؤال"
className="flex h-7 w-7 items-center justify-center rounded-lg text-slate-500 active:bg-slate-100"
>
<Pencil size={14} />
</button>
<button
type="button"
onClick={() => setDeleteQuestion(q)}
aria-label="حذف سؤال"
title="حذف سؤال"
className="flex h-7 w-7 items-center justify-center rounded-lg text-red-500 active:bg-red-50"
>
<Trash2 size={14} />
</button>
</div>
</div>
))}
</div>
)}
</div>
);
})}
</div>
</div>
)}
{addPhase && (
<QuestionSheet
question={null}
pending={addMutation.isPending}
onClose={() => setAddPhase(null)}
onSubmit={(payload) => addMutation.mutate(payload)}
/>
)}
{editTarget && (
<QuestionSheet
question={editTarget}
pending={editMutation.isPending}
onClose={() => setEditTarget(null)}
onSubmit={(payload) =>
editMutation.mutate({
questionId: editTarget.id,
patch: { text: payload.text, phase: payload.phase, required: payload.required },
})
}
/>
)}
{deleteQuestion && (
<ConfirmSheet
open
title="حذف سؤال"
message={`آیا «${deleteQuestion.text}» از تعریف این وظیفه حذف شود؟`}
pending={deleteQuestionMutation.isPending}
onClose={() => setDeleteQuestion(null)}
onConfirm={() => deleteQuestionMutation.mutate(deleteQuestion.id)}
/>
)}
</Card>
);
}
// ----------------------------------------
// صفحه تعریف QC
// ----------------------------------------
export default function TaskQcDefinePage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
// شناسهٔ تسک میتواند UUID بومی باشد یا شناسهٔ عددی میراث — هر دو رشتهاند
const taskId = (id ?? "").trim();
const validId = taskId.length > 0;
const qcQuery = useQuery({ queryKey: qcKey(taskId), queryFn: () => getTaskQc(taskId), enabled: validId });
const templatesQuery = useQuery({ queryKey: ["qc-templates"], queryFn: getQcTemplates });
const [showPicker, setShowPicker] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<TaskQcDefinition | null>(null);
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: qcKey(taskId) });
void queryClient.invalidateQueries({ queryKey: ["task", taskId] });
void queryClient.invalidateQueries({ queryKey: ["today"] });
void queryClient.invalidateQueries({ queryKey: ["tasks"] });
};
const createMutation = useMutation({
mutationFn: (args: { templateId?: string; name?: string }) =>
createTaskQcDefinition(taskId, { template_id: args.templateId, name: args.name }),
onSuccess: () => {
setShowPicker(false);
invalidate();
},
onError: (err) => apiErrorToast(err, "ساخت تعریف ناموفق بود"),
});
const renameMutation = useMutation({
mutationFn: (args: { definitionId: string; name: string }) =>
updateTaskQcDefinition(args.definitionId, args.name),
onSuccess: invalidate,
onError: (err) => apiErrorToast(err, "تغییر نام ناموفق بود"),
});
const deleteMutation = useMutation({
mutationFn: (definitionId: string) => deleteTaskQcDefinition(definitionId),
onSuccess: () => {
setDeleteTarget(null);
invalidate();
},
onError: (err) => apiErrorToast(err, "حذف تعریف ناموفق بود"),
});
if (!validId) {
return (
<>
<PageHeader title="تعریف QC" onBack={() => navigate("/")} />
<Card className="m-4">
<ErrorState message="شناسه وظیفه نامعتبر است" onRetry={() => navigate("/")} />
</Card>
</>
);
}
if (qcQuery.isPending) {
return (
<>
<PageHeader title="تعریف QC" onBack={() => navigate(`/tasks/${taskId}`)} />
<div className="space-y-3 px-4 pb-28 pt-4">
<CardSkeleton />
<CardSkeleton />
</div>
</>
);
}
if (qcQuery.isError || !qcQuery.data) {
return (
<>
<PageHeader title="تعریف QC" onBack={() => navigate(`/tasks/${taskId}`)} />
<Card className="m-4">
<ErrorState message="خطا در دریافت تعریف‌های QC" onRetry={() => void qcQuery.refetch()} />
</Card>
</>
);
}
const data = qcQuery.data;
const templates = templatesQuery.data ?? [];
return (
<>
<PageHeader
title="تعریف QC"
onBack={() => navigate(`/tasks/${taskId}`)}
right={
<button
type="button"
onClick={() => setShowPicker(true)}
className="inline-flex h-9 items-center gap-1 rounded-full bg-sky-50 px-3 text-xs font-semibold text-sky-700 active:bg-sky-100"
>
<Plus size={14} />
تعریف از قالب
</button>
}
/>
<div className="space-y-3 px-4 pb-28 pt-4">
<Card className="p-4">
<p className="text-[11px] text-slate-400">تعریف QC برای وظیفه</p>
<p className="mt-0.5 text-[13px] font-bold text-slate-800">{data.task.subject}</p>
<div className="mt-2 flex flex-wrap gap-1.5">
<Chip tone="sky">{data.definitions.length} تعریف</Chip>
<Chip tone="slate">{data.readiness.all.total} سؤال</Chip>
<Chip tone={qcSummaryStatusMeta(data.readiness.pre.status).tone}>
پیش از اجرا: {qcSummaryStatusMeta(data.readiness.pre.status).label}
</Chip>
</div>
</Card>
<div className="flex items-start gap-2 rounded-xl bg-sky-50 p-3">
<Info size={15} className="mt-0.5 shrink-0 text-sky-600" />
<p className="text-[11px] leading-5 text-sky-800">
تغییرات این صفحه فقط روی همین وظیفه اثر دارد و هرگز قالب سراسری را تغییر نمی‌دهد. ارزیابی (قبول/مردود) در
صفحه «اجرای QC» ثبت می‌شود.
</p>
</div>
{data.definitions.length === 0 ? (
<Card>
<EmptyState
icon={ClipboardList}
title="تعریفی ساخته نشده است"
description="با «تعریف از قالب» یک تعریف بسازید یا تعریف خالی ایجاد کنید"
/>
<div className="px-4 pb-4">
<button
type="button"
disabled={createMutation.isPending}
onClick={() => createMutation.mutate({})}
className={`${btnSecondary} w-full text-xs`}
>
{createMutation.isPending ? "در حال ساخت..." : "ایجاد تعریف خالی"}
</button>
</div>
</Card>
) : (
data.definitions.map((definition) => (
<DefinitionCard
key={definition.id}
definition={definition}
taskId={taskId}
onRename={(name) => renameMutation.mutate({ definitionId: definition.id, name })}
onDelete={() => setDeleteTarget(definition)}
/>
))
)}
{data.definitions.length > 0 && (
<div className="grid grid-cols-2 gap-2 pt-1">
<Link to={`/tasks/${taskId}/qc/execute`} className={btnPrimary}>
اجرای QC
</Link>
<button
type="button"
disabled={createMutation.isPending}
onClick={() => createMutation.mutate({})}
className={btnSecondary}
>
تعریف خالی
</button>
</div>
)}
</div>
{showPicker && (
<TemplatePickerSheet
templates={templates}
pending={createMutation.isPending}
onClose={() => setShowPicker(false)}
onSubmit={(templateId, name) => createMutation.mutate({ templateId, name })}
/>
)}
{deleteTarget && (
<ConfirmSheet
open
title="حذف تعریف QC"
message={`آیا «${deleteTarget.name}» با ${deleteTarget.questions.length} سؤال از این وظیفه حذف شود؟`}
pending={deleteMutation.isPending}
onClose={() => setDeleteTarget(null)}
onConfirm={() => deleteMutation.mutate(deleteTarget.id)}
/>
)}
</>
);
}
+586
View File
@@ -0,0 +1,586 @@
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { AlertTriangle, Camera, CheckCircle2, ClipboardList, ImagePlus, ShieldCheck } from "lucide-react";
import { ApiError, createDefect, getTask, getTaskQc, putTaskQcResult, uploadFile } from "../lib/api";
import type { AnswerValue, QcState, TaskQcQuestion, TaskQcState } from "../lib/api";
import {
Card,
CardSkeleton,
Chip,
EmptyState,
ErrorState,
Modal,
PageHeader,
ProgressBar,
btnPrimary,
btnSecondary,
inputCls,
apiErrorToast,
} from "../components/ui";
import { useAuthStore } from "../store/auth";
import { Perm, hasPermission } from "../lib/roles";
import { answerMeta, answerOptions, phaseLabel, phaseTone, qcPhases, qcSummaryStatusMeta, severityOptions } from "../lib/format";
const qcKey = (taskId: string | number) => ["task-qc", taskId];
/** خلاصه بالا: پیشرفت QC + وضعیت پیش از اجرا + وضعیت اجرا */
function SummaryCard({ readiness, taskSubject }: { readiness: QcState; taskSubject: string }) {
const answered = readiness.all.total - readiness.all.pending;
const percent = readiness.all.total > 0 ? Math.round((answered / readiness.all.total) * 100) : 0;
const pre = qcSummaryStatusMeta(readiness.pre.status);
const exec = qcSummaryStatusMeta(readiness.exec.status);
return (
<Card className="p-4">
<p className="text-[11px] text-slate-400">اجرای QC برای وظیفه</p>
<p className="mt-0.5 text-[13px] font-bold text-slate-800">{taskSubject}</p>
<div className="mt-3 flex items-center justify-between">
<span className="text-[11px] font-semibold text-slate-500">پیشرفت QC</span>
<span className="text-xs font-bold text-sky-700">
{answered} از {readiness.all.total} سؤال
</span>
</div>
<ProgressBar value={percent} className="mt-2" />
<div className="mt-3 grid grid-cols-2 gap-2">
<div className="rounded-xl border border-slate-200 p-2.5">
<p className="text-[10px] font-medium text-slate-400">وضعیت پیش از اجرا</p>
<div className="mt-1.5">
<Chip tone={pre.tone}>{pre.label}</Chip>
</div>
<p className="mt-1 text-[10px] text-slate-400">
قبول {readiness.pre.passed} / مردود {readiness.pre.failed} / بدون پاسخ {readiness.pre.pending}
</p>
</div>
<div className="rounded-xl border border-slate-200 p-2.5">
<p className="text-[10px] font-medium text-slate-400">وضعیت اجرا</p>
<div className="mt-1.5">
<Chip tone={exec.tone}>{exec.label}</Chip>
</div>
<p className="mt-1 text-[10px] text-slate-400">
قبول {readiness.exec.passed} / مردود {readiness.exec.failed} / بدون پاسخ {readiness.exec.pending}
</p>
</div>
</div>
{!readiness.can_start && readiness.block_reasons.length > 0 && (
<div className="mt-3 rounded-xl bg-amber-50 p-2.5">
<p className="flex items-center gap-1.5 text-[11px] font-bold text-amber-800">
<AlertTriangle size={14} />
بازرسی پیش از اجرا تأیید نشده است
</p>
<ul className="mt-1 list-inside list-disc space-y-0.5">
{readiness.block_reasons.map((reason) => (
<li key={reason} className="text-[11px] leading-4 text-amber-700">
{reason}
</li>
))}
</ul>
</div>
)}
{readiness.all.total > 0 && readiness.all.pending === 0 && readiness.all.failed === 0 && (
<div className="mt-3 flex items-center gap-2 rounded-xl bg-emerald-50 p-2.5">
<CheckCircle2 size={16} className="shrink-0 text-emerald-600" />
<p className="text-[11px] font-bold text-emerald-700">
همه بازرسی‌ها انجام شد — پیشرفت ۱۰۰٪ مجاز است
</p>
</div>
)}
</Card>
);
}
// ----------------------------------------
// بات‌شیت ثبت عیب
// ----------------------------------------
function DefectSheet({
question,
taskId,
onClose,
onDone,
}: {
question: TaskQcQuestion;
taskId: string;
onClose: () => void;
onDone: () => void;
}) {
const [title, setTitle] = useState(() => question.text);
const [description, setDescription] = useState("");
const [severity, setSeverity] = useState("medium");
const [correctiveAction, setCorrectiveAction] = useState("");
const [photoFile, setPhotoFile] = useState<File | null>(null);
const [formError, setFormError] = useState<string | null>(null);
const saveMutation = useMutation({
mutationFn: async () => {
let photoId: string | undefined;
if (photoFile) {
photoId = (await uploadFile(photoFile)).id;
}
return createDefect({
task_ref: taskId,
question_id: question.id,
title: title.trim(),
description: description.trim() || undefined,
severity: severity as "low" | "medium" | "high",
corrective_action: correctiveAction.trim() || undefined,
photo_id: photoId,
});
},
onSuccess: () => {
toast.success("عیب ثبت شد");
onDone();
},
onError: (err) => apiErrorToast(err, "ثبت عیب ناموفق بود"),
});
const submit = () => {
setFormError(null);
if (!title.trim()) {
setFormError("عنوان عیب الزامی است");
return;
}
saveMutation.mutate();
};
return (
<Modal open title="ثبت عیب" onClose={onClose}>
<p className="rounded-lg bg-red-50 p-2 text-[11px] leading-4 text-red-600">
سؤال مردود: {question.text}
</p>
<label htmlFor="d-title" className="mb-1 mt-3 block text-[11px] font-medium text-slate-500">
عنوان عیب *
</label>
<input id="d-title" value={title} onChange={(e) => setTitle(e.target.value)} className={inputCls} />
<label htmlFor="d-desc" className="mb-1 mt-3 block text-[11px] font-medium text-slate-500">
توضیحات
</label>
<textarea
id="d-desc"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
className={`${inputCls} h-auto py-2`}
/>
<label htmlFor="d-severity" className="mb-1 mt-3 block text-[11px] font-medium text-slate-500">
شدت عیب
</label>
<select id="d-severity" value={severity} onChange={(e) => setSeverity(e.target.value)} className={inputCls}>
{severityOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<label htmlFor="d-action" className="mb-1 mt-3 block text-[11px] font-medium text-slate-500">
اقدام اصلاحی
</label>
<textarea
id="d-action"
value={correctiveAction}
onChange={(e) => setCorrectiveAction(e.target.value)}
rows={2}
className={`${inputCls} h-auto py-2`}
/>
<p className="mb-1 mt-3 text-[11px] font-medium text-slate-500">عکس عیب (اختیاری)</p>
<div className="flex items-center gap-2">
<label className={`${btnSecondary} h-10 cursor-pointer px-3 text-xs`}>
<ImagePlus size={14} />
انتخاب عکس
<input
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={(e) => setPhotoFile(e.target.files?.[0] ?? null)}
/>
</label>
{photoFile && <span className="min-w-0 flex-1 truncate text-[11px] text-slate-500">{photoFile.name}</span>}
</div>
{formError && <p className="mt-2 rounded-lg bg-red-50 p-2 text-[11px] text-red-600">{formError}</p>}
<div className="mt-4 grid grid-cols-2 gap-2">
<button type="button" onClick={onClose} className={btnSecondary}>
انصراف
</button>
<button type="button" disabled={saveMutation.isPending} onClick={submit} className={btnPrimary}>
{saveMutation.isPending ? "در حال ثبت..." : "ثبت عیب"}
</button>
</div>
</Modal>
);
}
// ----------------------------------------
// ردیف سؤال: انتخاب سه‌گانه + توضیح
// ----------------------------------------
function QuestionRow({
question,
hasOpenDefect,
onAnswer,
onComment,
onDefect,
saving,
}: {
question: TaskQcQuestion;
hasOpenDefect: boolean;
onAnswer: (answer: AnswerValue | null, comment: string, photoId?: string | null) => void;
onComment: (answer: AnswerValue | null, comment: string) => void;
onDefect: () => void;
saving: boolean;
}) {
const savedAnswer = question.result?.answer ?? null;
const [comment, setComment] = useState(() => question.result?.comment ?? "");
const [warned, setWarned] = useState(false);
const [uploadingPhoto, setUploadingPhoto] = useState(false);
const current = answerMeta(savedAnswer);
const commentDirty = comment.trim() !== (question.result?.comment ?? "").trim();
return (
<div className="py-3">
<div className="flex items-start justify-between gap-2">
<p className="min-w-0 flex-1 text-[13px] font-medium leading-5 text-slate-800">{question.text}</p>
<span className="flex shrink-0 flex-col items-end gap-1">
<Chip tone={current.tone}>{current.label}</Chip>
</span>
</div>
{(hasOpenDefect || question.history_count > 1) && (
<div className="mt-1.5 flex flex-wrap gap-1.5">
{hasOpenDefect && <Chip tone="amber">عیب باز</Chip>}
{question.history_count > 1 && <Chip tone="slate">{question.history_count} ارزیابی ثبت شده</Chip>}
</div>
)}
<div className="mt-2 grid grid-cols-3 gap-1 rounded-xl bg-slate-100 p-1">
{answerOptions.map((o) => {
const selected = savedAnswer === o.value;
return (
<button
key={o.value}
type="button"
disabled={saving || uploadingPhoto}
onClick={() => {
setWarned(false);
if (selected) {
onAnswer(null, comment);
} else {
onAnswer(o.value as AnswerValue, comment);
}
}}
className={`h-9 rounded-lg text-[11px] font-semibold transition-colors disabled:opacity-60 ${
selected ? o.activeCls : "text-slate-500"
}`}
>
{o.label}
</button>
);
})}
</div>
<div className="mt-2 flex items-center gap-2">
<input
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="توضیح (اختیاری)"
className={`${inputCls} h-10 min-w-0 flex-1 text-xs`}
/>
<button
type="button"
disabled={!commentDirty || !savedAnswer || saving || !comment.trim()}
onClick={() => {
if (!savedAnswer) {
setWarned(true);
return;
}
onComment(savedAnswer, comment);
}}
className={`${btnSecondary} h-10 shrink-0 px-3 text-xs`}
>
ثبت توضیح
</button>
</div>
{warned && <p className="mt-1 text-[10px] text-red-500">ابتدا یکی از حالت‌های قبول/مردود/مطرح نیست را بزنید.</p>}
{/* بخش آپلود مستند تصویری در حالت قبولی (مشابه بخش ثبت عیب) */}
{savedAnswer === "pass" && (
<div className="mt-2 rounded-xl border border-emerald-200 bg-emerald-50/60 p-2.5">
<div className="flex items-center justify-between gap-2">
<span className="flex items-center gap-1.5 text-xs font-semibold text-emerald-800">
<Camera size={14} className="text-emerald-600" />
<span>تصویر مستند تأییدیه (قبول)</span>
</span>
<label className="flex h-8 cursor-pointer items-center gap-1.5 rounded-lg border border-emerald-300 bg-white px-2.5 text-xs font-medium text-emerald-700 hover:bg-emerald-50 transition-colors shadow-xs">
<ImagePlus size={13} />
<span>{uploadingPhoto ? "در حال آپلود..." : question.result?.photo_url ? "تغییر تصویر" : "انتخاب و آپلود تصویر..."}</span>
<input
type="file"
accept="image/*"
className="hidden"
disabled={uploadingPhoto || saving}
onChange={async (e) => {
const f = e.target.files?.[0];
if (!f) return;
setUploadingPhoto(true);
try {
const up = await uploadFile(f);
onAnswer(savedAnswer, comment, up.id);
toast.success("تصویر تأییدیه با موفقیت ضمیمه شد");
} catch (err: any) {
toast.error(err?.message || "خطا در بارگذاری تصویر");
} finally {
setUploadingPhoto(false);
}
}}
/>
</label>
</div>
{question.result?.photo_url && (
<div className="mt-2 flex items-center gap-2.5 pt-2 border-t border-emerald-200/70">
<a href={question.result.photo_url} target="_blank" rel="noreferrer" className="block shrink-0">
<img
src={question.result.photo_url}
alt="مستند قبولی"
className="h-14 w-14 rounded-lg object-cover border border-emerald-300 shadow-xs hover:opacity-90 transition-opacity"
/>
</a>
<div className="flex-1 min-w-0">
<p className="text-[11px] font-medium text-emerald-900 truncate">تصویر پیوست شده</p>
<a
href={question.result.photo_url}
target="_blank"
rel="noreferrer"
className="text-[10px] text-emerald-600 hover:underline"
>
مشاهده در اندازه کامل
</a>
</div>
<button
type="button"
disabled={saving || uploadingPhoto}
onClick={() => onAnswer(savedAnswer, comment, "")}
className="text-xs text-red-500 hover:text-red-700 hover:underline px-1 py-0.5"
title="حذف تصویر پیوست"
>
حذف تصویر
</button>
</div>
)}
</div>
)}
{savedAnswer === "fail" && !hasOpenDefect && (
<button
type="button"
onClick={onDefect}
className={`${btnSecondary} mt-2 h-9 w-full text-xs text-red-600`}
>
<AlertTriangle size={13} />
ثبت عیب
</button>
)}
{question.result?.evaluated_at && (
<p className="mt-1.5 text-[10px] text-slate-400">
آخرین ارزیابی: {question.result.evaluated_by || "—"}
</p>
)}
</div>
);
}
// ----------------------------------------
// صفحه اجرای QC
// ----------------------------------------
export default function TaskQcExecutePage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const user = useAuthStore((s) => s.user);
const canDefineQc = hasPermission(user, Perm.qcDefine);
// شناسهٔ تسک میتواند UUID بومی باشد یا شناسهٔ عددی میراث — هر دو رشتهاند
const taskId = (id ?? "").trim();
const validId = taskId.length > 0;
const qcQuery = useQuery({ queryKey: qcKey(taskId), queryFn: () => getTaskQc(taskId), enabled: validId });
/** عیب‌های باز وظیفه — برای نشان دادن «عیب باز» روی سؤال‌های مردود */
const taskQuery = useQuery({ queryKey: ["task", taskId], queryFn: () => getTask(taskId), enabled: validId });
const [defectQuestion, setDefectQuestion] = useState<TaskQcQuestion | null>(null);
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: qcKey(taskId) });
void queryClient.invalidateQueries({ queryKey: ["task", taskId] });
void queryClient.invalidateQueries({ queryKey: ["today"] });
void queryClient.invalidateQueries({ queryKey: ["tasks"] });
};
/** readiness برگشتی از PUT — تا رسیدن داده تازه سرور نمایش داده می‌شود */
const [live, setLive] = useState<QcState | null>(null);
const dataUpdatedAt = qcQuery.dataUpdatedAt;
useEffect(() => {
setLive(null);
}, [dataUpdatedAt]);
const answerMutation = useMutation({
mutationFn: (args: { questionId: string; answer: AnswerValue | null; comment: string; photoId?: string | null }) =>
putTaskQcResult(args.questionId, { answer: args.answer, comment: args.comment, photo_id: args.photoId }),
onSuccess: (data, variables) => {
setLive(data.readiness);
invalidate();
if (variables.answer === null) {
toast.success("انتخاب بازرسی لغو شد");
}
},
onError: (err) => toast.error(err instanceof ApiError ? err.message : "ثبت پاسخ ناموفق بود"),
});
if (!validId) {
return (
<>
<PageHeader title="اجرای QC" onBack={() => navigate("/")} />
<Card className="m-4">
<ErrorState message="شناسه وظیفه نامعتبر است" onRetry={() => navigate("/")} />
</Card>
</>
);
}
if (qcQuery.isPending) {
return (
<>
<PageHeader title="اجرای QC" onBack={() => navigate(`/tasks/${taskId}`)} />
<div className="space-y-3 px-4 pb-28 pt-4">
<CardSkeleton />
<CardSkeleton />
</div>
</>
);
}
if (qcQuery.isError || !qcQuery.data) {
return (
<>
<PageHeader title="اجرای QC" onBack={() => navigate(`/tasks/${taskId}`)} />
<Card className="m-4">
<ErrorState message="خطا در دریافت بازرسی‌های وظیفه" onRetry={() => void qcQuery.refetch()} />
</Card>
</>
);
}
const data: TaskQcState = qcQuery.data;
const state = live ?? data.readiness;
const openDefectQuestionIds = new Set(
(taskQuery.data?.defects ?? [])
.filter((d) => d.status !== "resolved" && d.question_id)
.map((d) => d.question_id as string),
);
const questions = data.definitions.flatMap((definition) =>
definition.questions.map((q) => ({ question: q, definitionName: definition.name })),
);
const saving = answerMutation.isPending;
const save = (question: TaskQcQuestion, answer: AnswerValue | null, comment: string, photoId?: string | null) => {
answerMutation.mutate({ questionId: question.id, answer, comment, photoId });
};
return (
<>
<PageHeader title="اجرای QC" onBack={() => navigate(`/tasks/${taskId}`)} />
<div className="space-y-3 px-4 pb-28 pt-4">
<SummaryCard readiness={state} taskSubject={data.task.subject} />
{questions.length === 0 ? (
<Card>
<EmptyState
icon={ClipboardList}
title="سؤالی برای بازرسی تعریف نشده است"
description={
canDefineQc
? "ابتدا در صفحه «تعریف QC» سؤالات این وظیفه را بسازید"
: "ساختار بازرسی این وظیفه توسط مدیر پروژه تعریف نشده است"
}
/>
{canDefineQc && (
<div className="px-4 pb-4">
<Link to={`/tasks/${taskId}/qc/define`} className={`${btnPrimary} w-full`}>
رفتن به تعریف QC
</Link>
</div>
)}
</Card>
) : (
<>
{qcPhases.map((phase) => {
const rows = questions.filter((item) => item.question.phase === phase.value);
if (rows.length === 0) return null;
const summary = phase.value === "pre" ? state.pre : null;
return (
<Card key={phase.value} className="p-0">
<div className="flex items-center justify-between gap-2 border-b border-slate-100 px-4 py-3">
<span className="flex items-center gap-1.5">
<Chip tone={phaseTone(phase.value)}>{phaseLabel(phase.value)}</Chip>
{phase.value === "pre" && <Chip tone="slate">شرط شروع اجرا</Chip>}
</span>
{summary && (
<span className="text-[10px] text-slate-400">
قبول {summary.passed} / مردود {summary.failed} / بدون پاسخ {summary.pending}
</span>
)}
</div>
<div className="divide-y divide-slate-100 px-4">
{rows.map(({ question }) => (
<QuestionRow
key={question.id}
question={question}
saving={saving}
hasOpenDefect={openDefectQuestionIds.has(question.id)}
onAnswer={(answer, comment, photoId) => save(question, answer, comment, photoId)}
onComment={(answer, comment) => save(question, answer, comment)}
onDefect={() => setDefectQuestion(question)}
/>
))}
</div>
</Card>
);
})}
<div className="flex items-center gap-2 rounded-xl bg-slate-100 p-3">
<ShieldCheck size={16} className="shrink-0 text-slate-500" />
<p className="text-[11px] leading-5 text-slate-600">
پاسخ‌ها به صورت تاریخچه ثبت می‌شوند؛ در صورت اصلاح عیب می‌توانید همان سؤال را دوباره بازرسی کنید.
</p>
</div>
</>
)}
</div>
{defectQuestion && (
<DefectSheet
question={defectQuestion}
taskId={taskId}
onClose={() => setDefectQuestion(null)}
onDone={() => {
setDefectQuestion(null);
invalidate();
}}
/>
)}
</>
);
}
+321
View File
@@ -0,0 +1,321 @@
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { CheckCircle2, ChevronDown, ChevronUp, Folder, RefreshCw } from "lucide-react";
import { dashboardTaskKey, devLogin, getDashboardToday, getDevRoles } from "../lib/api";
import type { DashboardTask, Project } from "../lib/api";
import { Card, CardSkeleton, Chip, EmptyState, ErrorState, PageHeader, PanelsMenu, ProgressBar } from "../components/ui";
import { formatDate, isOverdue } from "../lib/format";
import { roleLabels, scopeLabel } from "../lib/roles";
import { useAuthStore } from "../store/auth";
type SectionKey = "overdue" | "due_today" | "awaiting_qc" | "failed_qc" | "blocked" | "upcoming";
interface SectionMeta {
key: SectionKey;
title: string;
tone: "red" | "amber" | "sky" | "slate";
}
/**
* بخش‌های داشبورد به تفکیک نقش:
* مدیر پروژه نگاه مدیریتی دارد (تأخیرها و QC همه پروژه‌ها) و سرپرست پروژه نگاه
* عملیاتی (کار امروز و کاری که باید همین حالا اجرا/بازرسی شود).
*/
const managerSections: SectionMeta[] = [
{ key: "overdue", title: "عقب‌افتاده", tone: "red" },
{ key: "due_today", title: "کارهای امروز", tone: "sky" },
{ key: "awaiting_qc", title: "منتظر QC", tone: "amber" },
{ key: "failed_qc", title: "QC مردود", tone: "red" },
{ key: "blocked", title: "مسدود شده", tone: "slate" },
{ key: "upcoming", title: "هفته آینده", tone: "sky" },
];
const supervisorSections: SectionMeta[] = [
{ key: "due_today", title: "کارهای امروز", tone: "sky" },
{ key: "overdue", title: "عقب‌افتاده", tone: "red" },
{ key: "awaiting_qc", title: "منتظر اجرای بازرسی", tone: "amber" },
{ key: "failed_qc", title: "بازرسی مردود", tone: "red" },
{ key: "blocked", title: "مسدود شده (پیش‌نیاز ناتمام)", tone: "slate" },
{ key: "upcoming", title: "هفته آینده", tone: "sky" },
];
function TaskRow({ task, showBlockedBy = false }: { task: DashboardTask; showBlockedBy?: boolean }) {
const overdue = isOverdue(task.due_date);
// شناسهٔ پایدار: UUID تسک بومی، وگرنه op_wp_id میراث
const taskKey = dashboardTaskKey(task);
return (
<Link to={`/tasks/${taskKey}`} className="block py-3">
<div className="flex items-start justify-between gap-2">
<p className="min-w-0 flex-1 text-[13px] font-medium leading-5 text-slate-800">{task.subject}</p>
<span className={`shrink-0 text-[11px] ${overdue ? "font-semibold text-red-600" : "text-slate-400"}`}>
{formatDate(task.due_date)}
</span>
</div>
<div className="mt-2 flex items-center gap-2">
<ProgressBar value={task.percentage_done} className="flex-1" />
<span className="w-9 shrink-0 text-left text-[10px] text-slate-400">{task.percentage_done}٪</span>
</div>
<div className="mt-1.5 flex items-center gap-2 text-[11px] text-slate-400">
<span className="truncate">{task.project_name}</span>
{showBlockedBy && task.blocked_by && (
<span className="shrink-0 truncate text-slate-500">مسدود توسط: {task.blocked_by}</span>
)}
</div>
</Link>
);
}
function SummaryBox({ tone, label, value }: { tone: "red" | "amber" | "green"; label: string; value: number }) {
const cls = {
red: "border-red-100 bg-red-50 text-red-600",
amber: "border-amber-100 bg-amber-50 text-amber-600",
green: "border-emerald-100 bg-emerald-50 text-emerald-600",
}[tone];
return (
<div className={`rounded-2xl border p-3 text-center ${cls}`}>
<p className="text-xl font-extrabold">{value}</p>
<p className="mt-0.5 text-[11px] font-medium opacity-80">{label}</p>
</div>
);
}
/** کاشی مدیریتی یک پروژه — فقط در داشبورد مدیر پروژه */
function ProjectTile({ project }: { project: Project }) {
return (
<Link to={`/projects/${project.op_project_id}`} className="block">
<div className="rounded-xl border border-slate-200 p-3 active:bg-slate-50">
<p className="truncate text-xs font-bold text-slate-800">{project.name}</p>
<div className="mt-1.5 flex flex-wrap gap-1.5">
<Chip tone="sky">{project.stats.total} فعالیت</Chip>
{project.stats.overdue > 0 && <Chip tone="red">{project.stats.overdue} عقب‌افتاده</Chip>}
{project.stats.awaiting_qc > 0 && <Chip tone="amber">{project.stats.awaiting_qc} منتظر QC</Chip>}
{project.stats.failed_qc > 0 && <Chip tone="red">{project.stats.failed_qc} مردود</Chip>}
</div>
</div>
</Link>
);
}
/** انتخابگر نقش — فقط در فاز توسعه (سرور آن را با PM_DEV_ROLE_SWITCH کنترل می‌کند) */
function DevRoleSwitcher() {
const user = useAuthStore((s) => s.user);
const setAuth = useAuthStore((s) => s.setAuth);
const navigate = useNavigate();
const queryClient = useQueryClient();
const isDevMode = !import.meta.env.PROD;
const rolesQuery = useQuery({
queryKey: ["dev-roles"],
queryFn: getDevRoles,
staleTime: 60_000,
retry: false,
enabled: isDevMode,
});
const switchMutation = useMutation({
mutationFn: (username: string) => devLogin(username),
onSuccess: (data) => {
queryClient.clear();
setAuth(data.token, data.user);
navigate("/", { replace: true });
},
onError: () => {
queryClient.invalidateQueries({ queryKey: ["dev-roles"] });
},
});
if (!isDevMode || rolesQuery.isError || !rolesQuery.data || rolesQuery.data.length === 0) {
return null;
}
const roles = rolesQuery.data;
return (
<select
aria-label="تغییر نقش (فاز توسعه)"
value={user?.username ?? ""}
disabled={switchMutation.isPending}
onChange={(e) => switchMutation.mutate(e.target.value)}
className="h-9 max-w-[9.5rem] shrink-0 rounded-lg border border-dashed border-amber-300 bg-amber-50 px-2 text-[11px] font-medium text-amber-800 focus:border-amber-500 focus:outline-none disabled:opacity-60"
>
{roles.map((r) => (
<option key={r.username} value={r.username}>
{roleLabels[r.role]} — {r.all_projects ? "همه پروژه‌ها" : `${r.project_count} پروژه`}
</option>
))}
</select>
);
}
export default function TodayPage() {
const queryClient = useQueryClient();
const user = useAuthStore((s) => s.user);
const [showUpcoming, setShowUpcoming] = useState(false);
const todayQuery = useQuery({ queryKey: ["today"], queryFn: getDashboardToday });
const data = todayQuery.data;
const refresh = () => {
void queryClient.invalidateQueries({ queryKey: ["today"] });
};
const isManager = user?.role === "project_manager";
const role = data?.role ?? user?.role ?? "project_supervisor";
const sections = isManager ? managerSections : supervisorSections;
const projects = data?.projects ?? [];
const totalItems = data
? Object.values(data.sections).reduce((sum, list) => sum + list.length, 0)
: 0;
return (
<>
<PageHeader
title="مدیریت پروژه"
menu={<PanelsMenu />}
right={
<span className="flex items-center gap-1">
<DevRoleSwitcher />
<button
type="button"
onClick={refresh}
aria-label="به‌روزرسانی"
className="flex h-11 w-11 items-center justify-center rounded-full text-slate-500 active:bg-slate-100"
>
<RefreshCw size={20} className={todayQuery.isFetching ? "animate-spin" : ""} />
</button>
</span>
}
/>
<div className="px-4 pb-28 pt-4">
{/* هویت نقش و محدوده دسترسی */}
<div className="mb-3 flex flex-wrap items-center gap-1.5">
<Chip tone={isManager ? "sky" : "green"}>{roleLabels[role]}</Chip>
<Chip tone="slate">{scopeLabel(user)}</Chip>
<span className="text-[11px] text-slate-400">
{isManager
? "نمای مدیریتی همه پروژه‌ها"
: "نمای عملیاتی پروژه‌های اختصاص‌یافته"}
</span>
</div>
{todayQuery.isPending ? (
<div className="space-y-3">
<div className="grid grid-cols-3 gap-2">
<div className="h-[70px] animate-pulse rounded-2xl bg-slate-200/80" />
<div className="h-[70px] animate-pulse rounded-2xl bg-slate-200/80" />
<div className="h-[70px] animate-pulse rounded-2xl bg-slate-200/80" />
</div>
<CardSkeleton />
<CardSkeleton />
<CardSkeleton />
</div>
) : todayQuery.isError ? (
<ErrorState onRetry={refresh} />
) : data ? (
<>
<div className="grid grid-cols-3 gap-2">
<SummaryBox
tone="red"
label={isManager ? "مشکلات" : "بحرانی"}
value={data.summary.problems}
/>
<SummaryBox
tone="amber"
label={isManager ? "هشدارها" : "نیازمند پیگیری"}
value={data.summary.warnings}
/>
<SummaryBox tone="green" label="سالم" value={data.summary.ok} />
</div>
{/* نمای مدیریتی پروژه‌ها — فقط مدیر پروژه */}
{isManager && projects.length > 0 && (
<Card className="mt-4 p-4">
<div className="mb-2.5 flex items-center justify-between gap-2">
<h2 className="text-sm font-bold text-slate-700">وضعیت پروژه‌ها</h2>
<Link to="/projects" className="text-[11px] font-semibold text-sky-600">
همه پروژه‌ها
</Link>
</div>
<div className="space-y-2">
{projects.map((p) => (
<ProjectTile key={p.op_project_id} project={p} />
))}
</div>
</Card>
)}
{/* ناظر بدون پروژه اختصاص‌یافته */}
{!isManager && (data?.scope?.project_count ?? user?.scope?.project_count ?? 0) === 0 && totalItems === 0 && (
<Card className="mt-4">
<EmptyState
icon={Folder}
title="پروژه‌ای به شما اختصاص داده نشده است"
description="برای مشاهده و ثبت اجرا، مدیر پروژه باید پروژه‌ها را از بخش «کاربران و دسترسی‌ها» به شما اختصاص دهد"
/>
</Card>
)}
{totalItems === 0 && (isManager || (data?.scope?.project_count ?? user?.scope?.project_count ?? 0) > 0) ? (
<Card className="mt-4">
<EmptyState
icon={CheckCircle2}
title="همه چیز مرتب است"
description="در حال حاضر موردی برای پیگیری وجود ندارد"
/>
</Card>
) : (
<div className="mt-4 space-y-3">
{sections.map(({ key, title, tone }) => {
const items = data.sections[key];
if (!items || items.length === 0) return null;
const isUpcoming = key === "upcoming";
if (isUpcoming && !showUpcoming) {
return (
<button key={key} type="button" onClick={() => setShowUpcoming(true)} className="block w-full">
<Card className="flex items-center justify-between p-4">
<span className="text-sm font-bold text-slate-700">{title}</span>
<span className="flex items-center gap-2">
<Chip tone="sky">{items.length} مورد</Chip>
<ChevronDown size={16} className="text-slate-400" />
</span>
</Card>
</button>
);
}
return (
<Card key={key} className="p-0">
<div className="flex items-center justify-between border-b border-slate-100 px-4 py-3">
<h2 className="text-sm font-bold text-slate-700">{title}</h2>
<span className="flex items-center gap-2">
<Chip tone={tone}>{items.length} مورد</Chip>
{isUpcoming && (
<button
type="button"
onClick={() => setShowUpcoming(false)}
aria-label="بستن بخش"
className="text-slate-400"
>
<ChevronUp size={16} />
</button>
)}
</span>
</div>
<div className="divide-y divide-slate-100 px-4">
{items.map((t) => (
<TaskRow key={dashboardTaskKey(t)} task={t} showBlockedBy={key === "blocked"} />
))}
</div>
</Card>
);
})}
</div>
)}
</>
) : null}
</div>
</>
);
}
+133
View File
@@ -0,0 +1,133 @@
import { create } from "zustand";
import type { User } from "../lib/api";
const TOKEN_KEY = "pm_token";
const USER_KEY = "pm_user";
/** نشست ERP روی همین دامنه (zustand persist) — منبع تک‌احراز هویت مجموعه */
const ERP_SESSION_KEY = "procurement-auth-v1";
function readErpToken(): string | null {
try {
const raw = localStorage.getItem(ERP_SESSION_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as { state?: { token?: string | null } };
return parsed.state?.token ?? null;
} catch {
return null;
}
}
function readToken(): string | null {
try {
const legacy = localStorage.getItem(TOKEN_KEY);
if (legacy) return legacy;
// SSO — توکن Casdoorِ نشست ERP همان نشست PM است
return readErpToken();
} catch {
return null;
}
}
interface AuthState {
token: string | null;
user: User | null;
setAuth: (token: string, user: User) => void;
/** به‌روزرسانی کاربر جاری (بعد از تغییر نقش/دسترسی‌ها یا خواندن /auth/me) */
setUser: (user: User) => void;
clear: () => void;
}
function readErpUser(): User | null {
try {
const raw = localStorage.getItem(ERP_SESSION_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as {
state?: {
user?: {
first_name?: string;
last_name?: string;
display_name?: string;
username?: string;
casdoor_id?: string;
role?: string;
role_label?: string;
} | null;
};
};
const eu = parsed.state?.user;
if (!eu) return null;
const role: import("../lib/api").Role =
eu.role === "supervisor" ? "project_supervisor" : "project_manager";
return {
id: eu.username || eu.casdoor_id || "sso",
username: eu.username || eu.casdoor_id || "user",
display_name: eu.display_name || `${eu.first_name || ""} ${eu.last_name || ""}`.trim() || eu.username || "کاربر",
role,
role_label: eu.role_label || (role === "project_supervisor" ? "سرپرست پروژه" : "مدیر پروژه"),
permissions: [],
scope: { all_projects: role === "project_manager", project_count: null },
};
} catch {
return null;
}
}
function readUser(): User | null {
try {
const erpUser = readErpUser();
const raw = localStorage.getItem(USER_KEY);
if (raw) {
const pmUser = JSON.parse(raw) as User;
if (erpUser && (pmUser.username !== erpUser.username || pmUser.role !== erpUser.role)) {
localStorage.removeItem(USER_KEY);
return erpUser;
}
return pmUser;
}
return erpUser;
} catch {
return null;
}
}
function writeUser(user: User | null): void {
try {
if (user) localStorage.setItem(USER_KEY, JSON.stringify(user));
else localStorage.removeItem(USER_KEY);
} catch {
/* حافظه در دسترس نیست */
}
}
/** وضعیت احراز هویت — توکن در localStorage نگه‌داری می‌شود */
export const useAuthStore = create<AuthState>()((set) => ({
token: readToken(),
user: readUser(),
setAuth: (token, user) => {
try {
localStorage.setItem(TOKEN_KEY, token);
} catch {
/* حافظه در دسترس نیست */
}
writeUser(user);
set({ token, user });
},
setUser: (user) => {
writeUser(user);
set({ user });
},
clear: () => {
try {
localStorage.removeItem(TOKEN_KEY);
} catch {
/* حافظه در دسترس نیست */
}
writeUser(null);
set({ token: null, user: null });
},
}));
/** آیا نشست ذخیره‌شده اطلاعات نقش را دارد؟ (نشست‌های پیش از RBAC ندارند) */
export function sessionNeedsRefresh(user: User | null): boolean {
return !user || !user.role || !Array.isArray(user.permissions);
}
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
// آدرس سامانه ERP (تدارکات/انبار) برای منوی «پنل‌ها» — خالی = پیش‌فرض لوکال
readonly VITE_ERP_URL?: string;
}
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true
},
"include": ["vite.config.ts"]
}
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
// PM زیر مسیر /pm/ روی همان دامنه ERP سرو می‌شود (پنل «مدیریت پروژه» داخل ERP)
export default defineConfig({
plugins: [react(), tailwindcss()],
base: "/pm/",
server: {
port: 5173,
// در حالت dev، درخواست‌های /pm/api به بک‌اند PM (پورت 8083) پراکسی می‌شوند
proxy: {
"/pm/api": {
target: "http://localhost:8083",
changeOrigin: true,
rewrite: (p) => p.replace(/^\/pm/, ""),
},
},
},
});