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:
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
*.log
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
@@ -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;"]
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Generated
+2650
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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
@@ -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)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
// آدرس سامانه ERP (تدارکات/انبار) برای منوی «پنلها» — خالی = پیشفرض لوکال
|
||||
readonly VITE_ERP_URL?: string;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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/, ""),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user