feat(org,rbac): dedicate organization panel, enforce role permissions, subheader info, and fix supervisor project sync

This commit is contained in:
2026-09-26 18:51:37 +03:30
parent 5a9b543040
commit 387edfb1ab
31 changed files with 921 additions and 643 deletions
+76 -17
View File
@@ -38,7 +38,7 @@ const WarehousesPage = lazy(() =>
import("./pages/Inventory/WarehousesPage").then((m) => ({ default: m.WarehousesPage })),
);
const OrganizationPage = lazy(() =>
import("./pages/Inventory/OrganizationPage").then((m) => ({ default: m.OrganizationPage })),
import("./pages/Organization/OrganizationPage").then((m) => ({ default: m.OrganizationPage })),
);
const AdminPanelPage = lazy(() =>
import("./pages/Admin/AdminPanel").then((m) => ({ default: m.AdminPanel })),
@@ -53,35 +53,60 @@ function Loading() {
}
// همه‌ی صفحات پشت ورود هستند — بدون نشست، به صفحه‌ی ورود می‌رویم.
// سرپرست پروژه (فقطِ پنل PM) از هر مسیری به پنل PM هدایت می‌شود.
function RequireAuth({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
const { pathname } = useLocation();
if (!user) return <Navigate to="/login" replace />;
if (user.role === "supervisor" && pathname !== "/panel/pm") {
return <Navigate to="/panel/pm" replace />;
if (user.role === "supervisor") {
// اگر سرپرست پروژه‌ای نداشته باشد، دسترسی مسدود می‌شود
if (!user.projects || user.projects.length === 0) {
return (
<div className="flex min-h-screen items-center justify-center bg-bg px-4">
<div className="w-full max-w-md rounded-2xl border border-line bg-panel p-8 text-center shadow-sm">
<h1 className="text-[16px] font-extrabold text-red">پروژه تخصیص داده نشده است</h1>
<p className="mt-2 text-[12.5px] leading-6 text-muted">
شما به عنوان سرپرست پروژه در سیستم ثبت شده‌اید اما هنوز هیچ پروژه‌ای به شما تخصیص داده نشده است.
تا زمان تخصیص پروژه توسط مدیر سیستم، اجازه ورود به سامانه را ندارید.
</p>
<button
type="button"
onClick={() => {
useAuthStore.getState().logout();
window.location.href = "/login";
}}
className="mt-5 inline-flex h-10 w-full cursor-pointer items-center justify-center rounded-lg bg-accent px-4 text-[13px] font-bold text-white transition-colors hover:bg-accent-dark"
>
خروج از حساب
</button>
</div>
</div>
);
}
// سرپرست پروژه فقط به انبار و PM دسترسی دارد
if (!pathname.startsWith("/inventory") && pathname !== "/panel/pm") {
return <Navigate to="/panel/pm" replace />;
}
}
return <>{children}</>;
}
// صفحه‌ی اصلی بر اساس نقش: مدیر پروژه → پنل انبار؛ مدیر تدارکات/ادمین → دفتر
// صفحه‌ی اصلی بر اساس نقش: سرپرست پروژه → پنل مدیریت پروژه (PM)؛ مدیران و ادمین → دفتر تدارکات
function HomeRoute() {
const user = useAuthStore((s) => s.user);
if (user?.role === "pm") return <Navigate to="/inventory" replace />;
if (user?.role === "supervisor") return <Navigate to="/panel/pm" replace />;
return <DashboardPage />;
}
// دسترسی ایجاد استعلام/پیشنهاد (ویزارد) فقط برای مدیر تدارکات و ادمین
// دسترسی ایجاد استعلام/پیشنهاد (ویزارد): مدیر تدارکات، مدیر پروژه و ادمین
function RequireProcurementWrite({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
if (user?.role !== "admin" && user?.role !== "procurement_manager") {
return <Navigate to={user?.role === "pm" ? "/inventory" : "/"} replace />;
if (user?.role !== "admin" && user?.role !== "procurement_manager" && user?.role !== "pm") {
return <Navigate to={user?.role === "supervisor" ? "/inventory" : "/"} replace />;
}
return <>{children}</>;
}
// پنل «مدیریت پروژه (PM)» — اپ PM روی همان دامنه زیر مسیر /pm سرو می‌شود و
// اینجا به‌صورت iframe تمام‌صفحه داخل پوسته ERP نمایش داده می‌شود.
// پنل «مدیریت پروژه (PM)» — اپ PM روی همان دامنه زیر مسیر /pm سرو می‌شود
function PmPanelPage() {
return (
<iframe
@@ -92,14 +117,40 @@ function PmPanelPage() {
);
}
// دسترسی پنل PM: ادمین، مدیر پروژه و سرپرست پروژه — مدیر تدارکات ندارد
// دسترسی پنل PM: همه نقش‌ها دسترسی دارند
function RequirePmPanel({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
const allowed = user?.role === "admin" || user?.role === "pm" || user?.role === "supervisor";
const allowed = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm" || user?.role === "supervisor";
if (!allowed) return <Navigate to="/" replace />;
return <>{children}</>;
}
// دسترسی پنل سازمان‌دهی: فقط ادمین، مدیر تدارکات و مدیر پروژه
function RequireOrganization({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
const allowed = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm";
if (!allowed) return <Navigate to={user?.role === "supervisor" ? "/inventory" : "/"} replace />;
return <>{children}</>;
}
// دسترسی تغییر موجودی انبار: فقط سرپرست پروژه و ادمین (مدیر تدارکات و مدیر پروژه نمی‌توانند موجودی تغییر دهند)
function RequireInventoryChange({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
if (user?.role !== "admin" && user?.role !== "supervisor") {
return <Navigate to="/inventory" replace />;
}
return <>{children}</>;
}
// دسترسی مدیریت انبارها: فقط مدیران و ادمین (سرپرست پروژه فقط می‌تواند موجودی تغییر دهد)
function RequireWarehouseManage({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
if (user?.role === "supervisor") {
return <Navigate to="/inventory" replace />;
}
return <>{children}</>;
}
export default function App() {
return (
<ErrorBoundary>
@@ -200,7 +251,9 @@ export default function App() {
element={
<RequireAuth>
<RequirePmProjects>
<ChangeInventoryPage />
<RequireInventoryChange>
<ChangeInventoryPage />
</RequireInventoryChange>
</RequirePmProjects>
</RequireAuth>
}
@@ -220,18 +273,24 @@ export default function App() {
element={
<RequireAuth>
<RequirePmProjects>
<WarehousesPage />
<RequireWarehouseManage>
<WarehousesPage />
</RequireWarehouseManage>
</RequirePmProjects>
</RequireAuth>
}
/>
<Route
path="/inventory/org"
element={<Navigate to="/organization" replace />}
/>
<Route
path="/organization"
element={
<RequireAuth>
<RequirePmProjects>
<RequireOrganization>
<OrganizationPage />
</RequirePmProjects>
</RequireOrganization>
</RequireAuth>
}
/>
+53 -42
View File
@@ -1,61 +1,72 @@
import { useLocation } from "react-router-dom";
import { PanelMenu, type MenuItem } from "./PanelMenu";
import { useLogout } from "../pages/Inventory/panelMenu";
import { useAuthStore } from "../store/auth";
// منوی همبرگری مشترک بین ماژول‌ها (پنل انبار / دفتر تدارکات / مدیریت پروژه) —
// دقیقاً مثل منوی دفتر: عنوان «پنل‌ها»، ماژول فعلی با استایل فعال (پر)
// + «✓ فعلی»، بقیه به‌عنوان سوییچر، و در پایان جداکننده + خروج.
// مدیر پروژه (1:N): زیر «مدیریت انبار» یک آکاردئون باز می‌شود که همه‌ی
// پروژه‌های تخصیص‌یافته را نشان می‌دهد؛ پروژه‌ی فعال با «✓ فعلی» مشخص است و
// انتخاب هر پروژه فقط «پروژه‌ی فعال» را عوض می‌کند (نشست/توکن دست‌نخورده).
// «مدیریت پروژه (PM)» مسیر داخلی /panel/pm است (اپ PM روی همین دامنه زیر /pm
// سرو می‌شود و داخل پوسته ERP نمایش داده می‌شود) — فقط برای ادمین و مدیر پروژه؛
// مدیر تدارکات این آیتم را نمی‌بیند.
// منوی همبرگری مشترک بین ۴ پنل اصلی سامانه:
// ۱. مدیریت انبار (/inventory)
// ۲. مدیریت تدارکات (/)
// ۳. مدیریت پروژه (/panel/pm)
// ۴. سازمان‌دهی (/organization)
export function AppSwitcherMenu({
activeModule,
canSwitchToProcurement = true,
}: {
activeModule: "warehouse" | "procurement";
activeModule: "warehouse" | "procurement" | "organization";
canSwitchToProcurement?: boolean;
}) {
const logout = useLogout();
const { pathname } = useLocation();
const user = useAuthStore((s) => s.user);
const setActiveProject = useAuthStore((s) => s.setActiveProject);
const items: MenuItem[] = [];
const isPm = user?.role === "pm";
const isSupervisor = user?.role === "supervisor";
const canSeePmPanel = user?.role === "admin" || isPm || isSupervisor;
const pmProjects = isPm ? user?.projects ?? [] : [];
const projectChildren: MenuItem[] | undefined =
activeModule === "warehouse" && isPm
? pmProjects.map((p) => ({
label: p.name,
to: pathname, // همان بخش فعلی؛ فقط پروژه عوض می‌شود
active: p.id === user?.project_id,
onClick: () => setActiveProject(p.id),
}))
: undefined;
// سرپرست پروژه: منحصراً دو پنل «انبار» و «مدیریت پروژه» نمایش داده می‌شود
if (isSupervisor) {
// سرپرست پروژه فقط پنل PM را می‌بیند
items.push({ label: "مدیریت پروژه (PM)", to: "/panel/pm", active: true });
} else {
if (activeModule === "procurement") {
items.push({ label: "مدیریت انبار", to: "/inventory" });
items.push({ label: "مدیریت تدارکات", to: "/", active: true });
} else {
items.push({ label: "مدیریت انبار", to: "/inventory", active: true, children: projectChildren });
if (canSwitchToProcurement) {
items.push({ label: "مدیریت تدارکات", to: "/" });
}
}
if (canSeePmPanel) {
items.push({ label: "مدیریت پروژه (PM)", to: "/panel/pm" });
}
const supervisorItems: MenuItem[] = [
{
label: "انبار",
to: "/inventory",
active: activeModule === "warehouse",
},
{
label: "مدیریت پروژه",
to: "/panel/pm",
},
{ divider: true },
{ label: "خروج از حساب", onClick: logout },
];
return <PanelMenu title="پنل‌ها" items={supervisorItems} />;
}
const canManage = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm";
const items: MenuItem[] = [
{
label: "انبار",
to: "/inventory",
active: activeModule === "warehouse",
},
];
if (canSwitchToProcurement) {
items.push({
label: "تدارکات",
to: "/",
active: activeModule === "procurement",
});
}
items.push({
label: "مدیریت پروژه",
to: "/panel/pm",
});
if (canManage) {
items.push({
label: "سازمان‌دهی",
to: "/organization",
active: activeModule === "organization",
});
}
items.push({ divider: true });
items.push({ label: "خروج از حساب", onClick: logout });
return <PanelMenu title="پنل‌ها" items={items} />;
@@ -16,11 +16,11 @@ export function RequirePmProjects({ children }: { children: ReactNode }) {
const setProjects = useAuthStore((s) => s.setProjects);
const logout = useLogout();
const isPm = user?.role === "pm";
const [state, setState] = useState<"loading" | "ok" | "empty">(isPm ? "loading" : "ok");
const isSupervisor = user?.role === "supervisor";
const [state, setState] = useState<"loading" | "ok" | "empty">(isSupervisor ? "loading" : "ok");
useEffect(() => {
if (!user || user.role !== "pm") {
if (!user || user.role !== "supervisor") {
setState("ok");
return;
}
+22 -1
View File
@@ -2,6 +2,23 @@ import type { ReactNode } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { ChevronRight, ClipboardList } from "lucide-react";
import { cn } from "../../lib/utils";
import { useAuthStore, type AuthUser } from "../../store/auth";
const ROLE_LABELS: Record<string, string> = {
admin: "ادمین",
procurement_manager: "مدیر تدارکات",
pm: "مدیر پروژه",
supervisor: "سرپرست پروژه",
};
export function userSubtitle(user: AuthUser | null | undefined): string {
if (!user) return "";
const fullName = `${user.first_name || ""} ${user.last_name || ""}`.trim() || user.display_name || "کاربر";
const username = user.username || user.casdoor_id || "-";
const role = user.role_label || (user.role ? ROLE_LABELS[user.role] : "بدون نقش");
const proj = user.role === "supervisor" && user.project_name ? ` – پروژه: ${user.project_name}` : "";
return `${fullName} | نام کاربری: ${username} | نقش: ${role}${proj}`;
}
interface HeaderProps {
title: string;
@@ -18,6 +35,10 @@ interface HeaderProps {
export function Header({ title, sub, backTo, backLabel = "بازگشت", actions, brand, icon, menu }: HeaderProps) {
const navigate = useNavigate();
const location = useLocation();
const user = useAuthStore((s) => s.user);
const userSub = userSubtitle(user);
const displaySub = sub !== undefined ? sub : userSub;
// اگر کاربر از داخل برنامه به این صفحه آمده، یک قدم برمی‌گردیم؛
// اگر مستقیم باز شده (بدون سابقه‌ی داخلی)، به backTo می‌رویم.
@@ -47,7 +68,7 @@ export function Header({ title, sub, backTo, backLabel = "بازگشت", actions
<div className="flex min-w-0 items-center gap-2.5">
<div className="min-w-0">
<h1 className="text-[17px] font-extrabold leading-6">{title}</h1>
{sub && <div className="text-[11px] text-muted">{sub}</div>}
{displaySub && <div className="text-[11.5px] text-muted font-normal mt-0.5">{displaySub}</div>}
</div>
</div>
<div className="ms-auto flex flex-wrap items-center gap-2">{actions}</div>
@@ -8,7 +8,6 @@ import {
PackageSearch,
Scale,
Truck,
UsersRound,
type LucideIcon,
} from "lucide-react";
import { cn } from "../../lib/utils";
@@ -37,12 +36,12 @@ const PM_INV_TABS: TabDef[] = [
{ to: "/inventory/arrivals", label: "کالاهای در راه", icon: PackageSearch },
];
// تب‌های پنل انبار برای ادمین / مدیر تدارکات (آینه‌ی ناوبری بالای دسکتاپ)
// تب‌های پنل انبار برای ادمین / مدیر تدارکات / مدیر پروژه (آینه‌ی ناوبری بالای دسکتاپ)
const ADMIN_INV_TABS: TabDef[] = [
{ to: "/inventory", label: "انبار", icon: Boxes, end: true },
{ to: "/inventory/daily", label: "کنترل روزانه", icon: CalendarDays },
{ to: "/inventory/warehouses", label: "انبارها", icon: Building2 },
{ to: "/inventory/arrivals", label: "کالاهای در راه", icon: PackageSearch },
{ to: "/inventory/org", label: "سازمان‌دهی", icon: UsersRound },
];
function TabBar({ tabs }: { tabs: TabDef[] }) {
+1 -1
View File
@@ -2,5 +2,5 @@ export { useStoreData, useInvalidateStore, refreshProcurementStore } from "./use
export { useQuotes, useCreateQuote, useUpdateQuote, useDeleteQuote } from "./useQuotes";
export { useSuppliers, useAddSupplier, useUpdateSupplier, useRemoveSupplier } from "./useSuppliers";
export { useMaterials, useAddMaterial } from "./useMaterials";
export { useProjects, useAddProject } from "./useProjects";
export { useProjects, useAddProject, useDeleteProject } from "./useProjects";
export { useUnits, useSaveStandardConversion, useDeleteStandardConversion } from "./useUnits";
+12
View File
@@ -21,3 +21,15 @@ export function useAddProject() {
onSettled: () => invalidate(),
});
}
export function useDeleteProject() {
const deleteProject = useAppStore((s) => s.deleteProject);
const invalidate = useInvalidateStore();
return useMutation({
mutationFn: (id: string) => api.projects.delete(id),
onMutate: (id) => deleteProject(id),
onSettled: () => invalidate(),
});
}
+42 -7
View File
@@ -87,7 +87,7 @@ function handle401(path: string, res: Response): void {
// ادمین/مدیر تدارکات بدون پارامتر (همه‌ی پروژه‌ها) باقی می‌ماند.
function activeProjectId(): string | null {
const { user } = useAuthStore.getState();
return user?.role === "pm" ? user.project_id : null;
return user?.role === "supervisor" || user?.role === "pm" ? user.project_id : null;
}
function projectQuery(projectId?: string | null): string {
@@ -323,6 +323,12 @@ interface RawSupplier {
interface RawProject {
id: string;
name: string;
location?: string | null;
project_manager?: string | null;
description?: string | null;
start_date?: string | null;
status?: string | null;
progress?: number | null;
}
interface RawSpec {
attribute_key: string;
@@ -1131,11 +1137,36 @@ export const api = {
projects: {
list: async (): Promise<Project[]> => {
const raw = await getData<RawProject[]>(`/projects?page_size=${PAGE_SIZE}`);
return raw.map((p) => ({ id: p.id, name: p.name }));
return raw.map((p) => ({
id: p.id,
name: p.name,
location: p.location || "",
project_manager: p.project_manager || "",
description: p.description || "",
start_date: p.start_date,
status: p.status || "ACTIVE",
progress: p.progress != null ? Number(p.progress) : 0,
}));
},
create: async (p: Project): Promise<Project> => {
const raw = await postData<RawProject>("/projects", { name: p.name });
return { id: raw.id, name: raw.name };
const raw = await postData<RawProject>("/projects", {
name: p.name,
location: p.location || "",
project_manager: p.project_manager || "",
description: p.description || "",
});
return {
id: raw.id,
name: raw.name,
location: raw.location || "",
project_manager: raw.project_manager || "",
description: raw.description || "",
status: raw.status || "ACTIVE",
progress: raw.progress != null ? Number(raw.progress) : 0,
};
},
delete: async (id: string): Promise<void> => {
await deleteData(`/projects/${id}`);
},
},
@@ -1525,6 +1556,7 @@ interface RawUnitL { id: number; code: string; name_fa: string | null; }
export interface UserRow {
id: string;
casdoor_id: string;
username?: string | null;
first_name: string;
last_name: string;
phone: string | null;
@@ -1553,10 +1585,11 @@ export const apiV26 = {
user: {
id: string;
casdoor_id: string;
username?: string;
first_name: string;
last_name: string;
phone: string | null;
role: "pm" | "procurement_manager" | "admin" | null;
role: "pm" | "procurement_manager" | "admin" | "supervisor" | null;
role_label: string | null;
project_id: string | null;
project_name: string | null;
@@ -1570,6 +1603,7 @@ export const apiV26 = {
expires_in: res.data.expires_in,
user: {
...u,
username: u.username || u.casdoor_id,
display_name: `${u.first_name} ${u.last_name}`.trim() || u.casdoor_id,
},
};
@@ -1589,14 +1623,15 @@ export const apiV26 = {
users: {
me: async (): Promise<import("../store/auth").AuthUser> => {
const data = await getData<{
id: string; casdoor_id: string; first_name: string; last_name: string; phone: string | null;
role: "pm" | "procurement_manager" | "admin" | null; role_label: string | null;
id: string; casdoor_id: string; username?: string; first_name: string; last_name: string; phone: string | null;
role: "pm" | "procurement_manager" | "admin" | "supervisor" | null; role_label: string | null;
project_id: string | null; project_name: string | null;
projects?: { id: string; name: string }[];
}>("/users/me");
return {
id: data.id,
casdoor_id: data.casdoor_id,
username: data.username || data.casdoor_id,
first_name: data.first_name,
last_name: data.last_name,
phone: data.phone,
+6
View File
@@ -31,6 +31,12 @@ export interface Material {
export interface Project {
id: string;
name: string;
location?: string;
project_manager?: string;
description?: string;
start_date?: string | null;
status?: string;
progress?: number;
}
// مرحله‌ی پرداخت
+6 -2
View File
@@ -77,14 +77,18 @@ export function LoginPage() {
toast.error("نقش شما هنوز در سیستم تعیین نشده است — با مدیر سیستم تماس بگیرید");
return;
}
if (user.role === "supervisor" && (!user.projects || user.projects.length === 0)) {
toast.error("پروژه تخصیص داده نشده است — تا زمان تخصیص پروژه اجازه ورود ندارید");
return;
}
setToken(access_token);
setUser(user);
// تحلیل رفتار (no-op بدون تنظیمات VITE_OPENPANEL_*)
identifyUser({ id: user.id, name: user.display_name, role: user.role });
track("login_success", { role: user.role });
toast.success(`خوش آمدید، ${user.display_name}`);
// مدیر پروژه → پنل انبار؛ مدیر تدارکات/ادمین → دفتر پیشنهادهای خرید
navigate(user.role === "pm" ? "/inventory" : "/");
// سرپرست پروژه → پنل مدیریت پروژه (PM)؛ بقیه نقش‌ها → دفتر پیشنهادهای خرید
navigate(user.role === "supervisor" ? "/panel/pm" : "/");
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در ورود");
} finally {
@@ -89,7 +89,7 @@ export function DashboardPage() {
return (
<div className="min-h-screen pb-20 md:pb-6">
<Header
title="دفتر پیشنهادهای خرید"
title="مدیریت تدارکات"
brand
menu={<AppSwitcherMenu activeModule="procurement" />}
actions={
@@ -76,8 +76,9 @@ export function ChangeInventoryPage() {
}
}, [params]);
const isAdmin = user?.role === "admin" || user?.role === "procurement_manager";
const isPM = user?.role === "pm";
const isAdmin = user?.role === "admin";
const isSupervisor = user?.role === "supervisor";
const canModify = isSupervisor || isAdmin;
const warehouses = data.warehouses;
const whOpts = useMemo(
@@ -401,7 +402,7 @@ export function ChangeInventoryPage() {
const meta = FORM_TYPES.find((f) => f.t === type)!;
if (isAdmin) {
if (!canModify) {
return (
<div className="min-h-screen">
<Header title="تغییر موجودی" backTo="/inventory" backLabel="بازگشت به داشبورد" />
@@ -409,22 +410,12 @@ export function ChangeInventoryPage() {
<div className="rounded-xl border border-line bg-panel p-8 text-center">
<div className="text-3xl">🔒</div>
<h3 className="mt-2 text-[15px] font-bold">دسترسی فقط‌خواندنی</h3>
<p className="mt-1 text-[12.5px] text-muted">نقش ادمین اجازه ثبت تغییر موجودی ندارد. این بخش برای مدیران پروژه فعال است.</p>
<p className="mt-1 text-[12.5px] text-muted">امکان تغییر موجودی انبار منحصراً در اختیار سرپرست پروژه و ادمین است.</p>
</div>
</PageLayout>
</div>
);
}
if (!isPM) {
return (
<div className="min-h-screen">
<Header title="تغییر موجودی" backTo="/inventory" backLabel="بازگشت به داشبورد" />
<PageLayout>
<div className="text-[13px] text-muted">این بخش فقط برای مدیر پروژه است.</div>
</PageLayout>
</div>
);
}
const isCount = type === "STOCK_COUNT";
const isTransfer = type === "TRANSFER";
+14 -12
View File
@@ -51,8 +51,10 @@ export function InventoryPage() {
const location = useLocation();
const user = useAuthStore((s) => s.user);
const { warehouses, inventory, transactions, lookup, loading } = useInventoryData();
const isAdmin = user?.role === "admin" || user?.role === "procurement_manager";
const isPM = user?.role === "pm";
const isManager = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm";
const isSupervisor = user?.role === "supervisor";
const isAdmin = user?.role === "admin";
const canModify = isSupervisor || isAdmin;
const [params] = useSearchParams();
const [filters, setFilters] = useState<{ warehouse: string; material: string; project: string }>({
@@ -67,10 +69,10 @@ export function InventoryPage() {
const [expBrand, setExpBrand] = useState<string | null>(null);
const visibleWarehouses = useMemo(() => {
if (isAdmin && filters.project) return warehouses.filter((w) => w.project_id === filters.project);
if (isAdmin) return warehouses;
if (isManager && filters.project) return warehouses.filter((w) => w.project_id === filters.project);
if (isManager) return warehouses;
return warehouses.filter((w) => String(w.project_id) === String(user?.project_id || ""));
}, [warehouses, isAdmin, filters.project, user?.project_id]);
}, [warehouses, isManager, filters.project, user?.project_id]);
const visibleIds = useMemo(() => new Set(visibleWarehouses.map((w) => w.id)), [visibleWarehouses]);
@@ -133,12 +135,12 @@ export function InventoryPage() {
const hasFilter = !!(filters.warehouse || (isAdmin && filters.project));
if (loading) return <div className="min-h-screen" />;
if (!user || (!isAdmin && !isPM)) {
if (!user) {
return (
<div className="min-h-screen">
<Header title="داشبورد انبار" backTo="/inventory" backLabel="بازگشت به داشبورد" />
<Header title="داشبورد انبار" backTo="/" backLabel="بازگشت" />
<PageLayout>
<EmptyState emoji="🔒" text="دسترسی به انبار فقط برای مدیر پروژه یا ادمین است." />
<EmptyState emoji="🔒" text="لطفاً برای دسترسی به سامانه انبار وارد شوید." />
</PageLayout>
</div>
);
@@ -153,7 +155,7 @@ export function InventoryPage() {
menu={<AppSwitcherMenu activeModule="warehouse" />}
actions={
<div className="hidden md:flex items-center gap-2">
<InventoryTopNav pathname={location.pathname} variant={isAdmin ? "admin" : "pm"} />
<InventoryTopNav pathname={location.pathname} />
</div>
}
/>
@@ -246,8 +248,8 @@ export function InventoryPage() {
{/* فیلترهای پیشرفته — برچسب‌های لمسی بدون سلکت و بدون بیرون‌زدگی */}
{showMobileFilters && (
<div className="mt-2.5 border-t border-line/60 pt-2.5 flex flex-col gap-2.5">
{/* پروژه‌ها (مخصوص ادمین) */}
{isAdmin && lookup.projects.length > 0 && (
{/* پروژه‌ها (مدیران و ادمین) */}
{isManager && lookup.projects.length > 0 && (
<div>
<div className="mb-1.5 text-[11px] font-bold text-muted">پروژه:</div>
<div className="flex flex-wrap gap-1.5">
@@ -415,7 +417,7 @@ export function InventoryPage() {
</PageLayout>
{/* دکمه شناور تغییر موجودی */}
{isPM && (
{canModify && (
<Link
to="/inventory/change"
className="fixed bottom-20 left-5 z-40 flex h-14 w-14 items-center justify-center rounded-full bg-blue-600 text-white shadow-xl shadow-blue-600/40 transition-all duration-200 hover:bg-blue-700 hover:scale-105 active:scale-90 md:bottom-8 md:left-8"
@@ -1,26 +1,32 @@
import { Link } from "react-router-dom";
import { Button } from "../../components/ui/button";
import { useAuthStore } from "../../store/auth";
// ناوبری بالای صفحه در پنل انبار — هماهنگ با دیزاین‌سیستم دکمه‌ها
export function InventoryTopNav({
pathname,
variant = "pm",
}: {
pathname: string;
variant?: "pm" | "admin";
}) {
const items =
variant === "admin"
? [
{ label: "کنترل روزانه", to: "/inventory/daily", active: pathname.startsWith("/inventory/daily") },
{ label: "کالاهای در راه", to: "/inventory/arrivals", active: pathname.startsWith("/inventory/arrivals") },
{ label: "سازمان‌دهی", to: "/inventory/org", active: pathname.startsWith("/inventory/org") },
]
: [
{ label: "کنترل روزانه", to: "/inventory/daily", active: pathname.startsWith("/inventory/daily") },
{ label: "انبارها", to: "/inventory/warehouses", active: pathname.startsWith("/inventory/warehouses") },
{ label: "کالاهای در راه", to: "/inventory/arrivals", active: pathname.startsWith("/inventory/arrivals") },
];
const user = useAuthStore((s) => s.user);
const isAdmin = user?.role === "admin";
const isManager = user?.role === "procurement_manager" || user?.role === "pm" || isAdmin;
const items: { label: string; to: string; active: boolean }[] = [];
// سرپرست پروژه از دکمه شناور گرد (+) در پایین صفحه استفاده می‌کند و دکمه مستطیلی بالای صفحه برای او نمایش داده نمی‌شود
if (isAdmin) {
items.push({
label: "تغییر موجودی",
to: "/inventory/change",
active: pathname.startsWith("/inventory/change"),
});
}
if (isManager) {
items.push({ label: "کنترل روزانه", to: "/inventory/daily", active: pathname.startsWith("/inventory/daily") });
items.push({ label: "انبارها", to: "/inventory/warehouses", active: pathname.startsWith("/inventory/warehouses") });
items.push({ label: "کالاهای در راه", to: "/inventory/arrivals", active: pathname.startsWith("/inventory/arrivals") });
}
return (
<>
@@ -1,354 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { Header, PageLayout } from "../../components/layout/Header";
import { useAuthStore } from "../../store/auth";
import { inventorySubheader } from "./panelMenu";
import { apiV26, type UserRow } from "../../lib/api";
import { refreshProcurementStore } from "../../hooks";
import { useInventoryData } from "./useInventoryData";
import { faNum } from "../../lib/formatters";
import { ProjectMultiSelect } from "../../components/ProjectMultiSelect";
type Role = "pm" | "procurement_manager" | "admin" | null;
const ROLE_LABELS: Record<string, string> = {
pm: "مدیر پروژه",
procurement_manager: "مدیر تدارکات",
admin: "ادمین",
};
// سازمان‌دهی (فقط ادمین / مدیر تدارکات) — کاربران، مدیران پروژه و انبارهای هر پروژه
export function OrganizationPage() {
const navigate = useNavigate();
const user = useAuthStore((s) => s.user);
const data = useInventoryData();
const [users, setUsers] = useState<UserRow[]>([]);
const [loadingUsers, setLoadingUsers] = useState(true);
const [syncing, setSyncing] = useState(false);
const [busyId, setBusyId] = useState<string | null>(null);
const [newWh, setNewWh] = useState<Record<string, string>>({});
const [newWhDesc, setNewWhDesc] = useState<Record<string, string>>({});
const canManage = user?.role === "admin" || user?.role === "procurement_manager";
// فقط ادمین می‌تواند نقش‌ها را تغییر دهد/ارتقا دهد (RBAC) — مدیر تدارکات فقط
// پروژه‌ی مدیران پروژه را تعیین/جابه‌جا می‌کند.
const canChangeRoles = user?.role === "admin";
const loadUsers = useCallback(async () => {
try {
const list = await apiV26.users.list();
setUsers(list);
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در دریافت کاربران");
} finally {
setLoadingUsers(false);
}
}, []);
useEffect(() => {
if (canManage) void loadUsers();
}, [canManage, loadUsers]);
const syncUsers = async () => {
setSyncing(true);
try {
const { users: synced, created } = await apiV26.users.sync();
setUsers(synced);
toast.success(
created > 0
? `همگام‌سازی انجام شد — ${faNum(created)} کاربر جدید از Casdoor اضافه شد ✓`
: "همگام‌سازی انجام شد — کاربر جدیدی یافت نشد",
);
await data.refresh();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در همگام‌سازی با Casdoor");
} finally {
setSyncing(false);
}
};
if (!canManage) {
return (
<div className="min-h-screen">
<Header title="سازمان‌دهی" backTo="/inventory" backLabel="بازگشت به داشبورد" />
<PageLayout>
<div className="text-[13px] text-muted">این بخش فقط برای ادمین / مدیر تدارکات است.</div>
</PageLayout>
</div>
);
}
// ---------- actions: users ----------
const changeRole = async (u: UserRow, role: Role) => {
setBusyId(u.id);
try {
await apiV26.users.updateRole(u.id, role);
toast.success(`نقش «${ROLE_LABELS[role ?? ""] ?? "بدون نقش"}» برای ${u.first_name} ${u.last_name} ثبت شد ✓`);
await loadUsers();
refreshProcurementStore();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در تغییر نقش");
} finally {
setBusyId(null);
}
};
// پروژه‌های متصل به یک کاربر (1:N) — با پشتیبانی از شکل قدیمیِ تک‌پروژه‌ای
const assignedProjectsOf = (u: UserRow) =>
u.projects ?? (u.project_id ? [{ id: u.project_id, name: u.project_name ?? "" }] : []);
// همگام‌سازی چند-انتخابیِ پروژه‌ها: افزودن/حذف هر اختلاف با یک PATCH
// (pm_user_id = کاربر برای اتصال، null برای جداسازی؛ مدیر قبلی جایگزین می‌شود)
const syncUserProjects = async (u: UserRow, nextIds: string[]) => {
const current = assignedProjectsOf(u).map((p) => p.id);
const added = nextIds.filter((id) => !current.includes(id));
const removed = current.filter((id) => !nextIds.includes(id));
if (!added.length && !removed.length) return;
setBusyId(u.id);
try {
for (const pid of added) await apiV26.projects.setPm(pid, u.id);
for (const pid of removed) await apiV26.projects.setPm(pid, null);
const names = nextIds
.map((id) => data.lookup.projects.find((p) => p.id === id)?.name)
.filter(Boolean)
.join("، ");
toast.success(
nextIds.length
? `پروژه‌های «${u.first_name} ${u.last_name}» به‌روزرسانی شد: ${names} ✓`
: `«${u.first_name} ${u.last_name}» از همه‌ی پروژه‌ها جدا شد`,
);
await loadUsers();
await data.refresh();
refreshProcurementStore();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در به‌روزرسانی پروژه‌ها");
await loadUsers(); // وضعیت واقعی سرور را برگردان
} finally {
setBusyId(null);
}
};
// ---------- actions: warehouses ----------
const addWarehouse = async (projectId: string) => {
const name = (newWh[projectId] || "").trim();
if (!name) return toast.error("نام انبار الزامی است");
try {
await apiV26.warehouses.create({ project_id: projectId, name, description: (newWhDesc[projectId] || "").trim() || null });
setNewWh((s) => ({ ...s, [projectId]: "" }));
setNewWhDesc((s) => ({ ...s, [projectId]: "" }));
toast.success(`انبار «${name}» اضافه شد ✓`);
await data.refresh();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در افزودن انبار");
}
};
// انبار برای همیشه به پروژه‌ی والدش تعلق دارد — انتقال به پروژه‌ی دیگر وجود ندارد؛
// انبار فقط با حذف و ساخت دوباره زیر پروژه‌ی موردنظر جابه‌جا می‌شود.
const removeWarehouse = async (id: string) => {
if (!confirm("انبار حذف شود؟ (انبارهای دارای سابقه تراکنش قابل حذف نیستند)")) return;
try {
await apiV26.warehouses.delete(id);
toast.success("انبار حذف شد");
await data.refresh();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در حذف انبار");
}
};
// مدیرِ مسئول یک پروژه (1:N — کاربر ممکن است مدیر چند پروژه باشد)
const pmOf = (projectId: string) =>
users.find(
(u) =>
u.role === "pm" &&
(u.projects?.some((p) => p.id === projectId) || u.project_id === projectId),
) || null;
return (
<div className="min-h-screen">
<Header
title="سازمان‌دهی"
sub={inventorySubheader(user)}
backTo="/inventory"
backLabel="بازگشت به داشبورد"
/>
<PageLayout className="flex flex-col gap-3">
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted">
ℹ️ کاربران از Casdoor همگام می‌شوند و نقش‌ها اینجا (فقط توسط ادمین) تعیین می‌شوند؛
مدیر تدارکات فقط پروژه‌ی مدیران پروژه را تعیین/جابه‌جا می‌کند. پروژه‌ها از سیستم تدارکات می‌آیند و
هر مدیر پروژه به همه انبارهای پروژه خودش دسترسی دارد. همه تغییرات فوراً در داشبورد و فرم‌ها اعمال می‌شود.
</div>
{/* ---------- کاربران و مدیران پروژه ---------- */}
<div className="rounded-xl border border-line bg-panel p-4">
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<h3 className="text-[14px] font-bold">👥 کاربران و مدیران پروژه</h3>
<button
onClick={() => void syncUsers()}
disabled={syncing}
className="rounded-lg border border-accent/40 bg-accent/5 px-3 py-1.5 text-[12px] font-bold text-accent disabled:opacity-50"
>
{syncing ? "در حال همگام‌سازی…" : "🔄 همگام‌سازی از Casdoor"}
</button>
</div>
{loadingUsers ? (
<div className="py-3 text-[12.5px] text-muted">در حال بارگذاری کاربران…</div>
) : !users.length ? (
<div className="rounded-lg border border-dashed border-line px-3 py-3 text-[12px] text-muted">
کاربری ثبت نشده است — کاربران پس از اولین ورود (Casdoor) به‌صورت خودکار ساخته می‌شوند؛
یا دکمه‌ی «همگام‌سازی از Casdoor» را بزنید.
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-[12.5px]">
<thead>
<tr className="border-b border-line text-right text-muted">
<th className="py-2 pe-3">نام</th>
<th className="py-2 pe-3">نقش</th>
<th className="py-2 pe-3">پروژه (مدیر پروژه)</th>
<th className="py-2">عملیات</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id} className="border-b border-line/50">
<td className="py-1.5 pe-3">
<b>{u.first_name} {u.last_name}</b>
<small className="block text-[10.5px] text-muted ltr">{u.casdoor_id}</small>
</td>
<td className="py-1.5 pe-3">
{canChangeRoles ? (
<select
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px]"
value={u.role ?? ""}
disabled={busyId === u.id}
onChange={(e) => void changeRole(u, (e.target.value || null) as Role)}
>
<option value="">— بدون نقش —</option>
<option value="pm">مدیر پروژه</option>
<option value="procurement_manager">مدیر تدارکات</option>
<option value="admin">ادمین</option>
</select>
) : (
<span className={`rounded-lg px-2 py-1 text-[11px] font-bold ${u.role ? "bg-accent-bg text-accent" : "bg-slate-bg text-muted"}`}>
{u.role ? ROLE_LABELS[u.role] : "بدون نقش"}
</span>
)}
{!canChangeRoles && u.role && (
<small className="block text-[10px] text-muted">(تغییر نقش فقط توسط ادمین)</small>
)}
</td>
<td className="min-w-[240px] py-1.5 pe-3 align-top">
{u.role === "pm" ? (
<ProjectMultiSelect
options={data.lookup.projects.map((p) => ({ id: p.id, name: p.name }))}
values={assignedProjectsOf(u).map((p) => p.id)}
onChange={(ids) => void syncUserProjects(u, ids)}
disabled={busyId === u.id}
/>
) : (
<span className="text-[11.5px] text-muted">—</span>
)}
</td>
<td className="py-1.5">
{u.role === "pm" && u.project_id && (
<span className="rounded-lg bg-green/10 px-2 py-1 text-[10.5px] font-bold text-green">فعال</span>
)}
</td>
</tr>
))}
</tbody>
</table>
<div className="mt-1 text-[11px] text-muted">
«بدون نقش» = حذف مدیر پروژه (از همه‌ی پروژه‌ها هم جدا می‌شود). هر مدیر پروژه می‌تواند به
چند پروژه متصل باشد؛ حذف هر چیپ (×)، فقط همان پروژه را جدا می‌کند و اتصال به یک پروژه، مدیرِ
قبلیِ آن پروژه را جابه‌جا می‌کند.
</div>
</div>
)}
</div>
{/* ---------- پروژه‌ها و انبارها ---------- */}
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{data.lookup.projects.map((p) => {
const whs = data.warehouses.filter((w) => w.project_id === p.id);
const pm = pmOf(p.id);
return (
<div key={p.id} className="rounded-xl border border-line bg-panel p-4">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-[14px] font-bold">🏢 {p.name}</h3>
<span className="rounded-lg bg-panel px-2 py-1 text-[11px] text-muted">
{faNum(whs.length)} انبار
</span>
</div>
<div className="mb-3">
<h4 className="mb-1 text-[12px] font-bold text-muted">👤 مدیر پروژه</h4>
{pm ? (
<div className="flex items-center gap-2 rounded-lg border border-line px-3 py-2 text-[12.5px]">
<span className="font-semibold">{pm.first_name} {pm.last_name}</span>
<span className="text-[10.5px] text-muted">(مدیر مسئول پروژه)</span>
</div>
) : (
<div className="rounded-lg border border-dashed border-line px-3 py-2 text-[11.5px] text-muted">
مدیری تعیین نشده است — از جدول «کاربران و مدیران پروژه» بالا، نقش مدیر پروژه را بدهید و به این پروژه متصل کنید.
</div>
)}
</div>
<h4 className="mb-1 text-[12px] font-bold text-muted">🏬 انبارهای پروژه</h4>
{!whs.length ? (
<div className="rounded-lg border border-dashed border-line px-3 py-2 text-[11.5px] text-muted">انباری ندارد — از فرم زیر اضافه کنید.</div>
) : (
<div className="mb-2 flex flex-col gap-1.5">
{whs.map((w) => (
<div key={w.id} className="flex items-center justify-between gap-2 rounded-lg border border-line px-3 py-2 text-[12.5px]">
<div className="min-w-0 flex-1">
<b>{w.name}</b>
<small className="block truncate text-muted">{w.description || ""}</small>
</div>
<div className="flex flex-wrap items-center justify-end gap-1.5">
<button
className="rounded-lg border border-accent/40 bg-accent/5 px-2 py-1 text-[10.5px] font-bold text-accent"
onClick={() => navigate(`/inventory?warehouse=${w.id}`)}
title="داشبورد این انبار"
>
📊 داشبورد
</button>
<button className="text-muted hover:text-red" onClick={() => void removeWarehouse(w.id)} title="حذف انبار">✕</button>
</div>
</div>
))}
</div>
)}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<input
className="h-8 w-full rounded-lg border border-line bg-panel px-2 text-sm sm:w-44"
placeholder="نام انبار جدید"
value={newWh[p.id] || ""}
onChange={(e) => setNewWh((s) => ({ ...s, [p.id]: e.target.value }))}
/>
<input
className="h-8 w-full rounded-lg border border-line bg-panel px-2 text-sm sm:w-52"
placeholder="توضیحات (اختیاری)"
value={newWhDesc[p.id] || ""}
onChange={(e) => setNewWhDesc((s) => ({ ...s, [p.id]: e.target.value }))}
/>
<button className="w-full rounded-lg bg-accent px-3 py-2 text-[12px] font-bold text-white sm:w-auto sm:py-1.5" onClick={() => void addWarehouse(p.id)}>
+ افزودن انبار
</button>
</div>
</div>
);
})}
</div>
</PageLayout>
</div>
);
}
+25 -23
View File
@@ -9,52 +9,41 @@ import { useInventoryData, projName } from "./useInventoryData";
import { faNum } from "../../lib/formatters";
import { isoToJalaliStr } from "../../lib/jalali";
// انبارها — مدیر پروژه: مشاهده انبارهای پروژه خودش + افزودن/ویرایش/حذف (ادمین دسترسی ندارد)
// انبارها — مدیریت انبارها برای مدیران (ادمین، مدیر تدارکات، مدیر پروژه)
export function WarehousesPage() {
const navigate = useNavigate();
const user = useAuthStore((s) => s.user);
const data = useInventoryData();
const isAdmin = user?.role === "admin" || user?.role === "procurement_manager";
const isPM = user?.role === "pm";
const canManage = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm";
const [newName, setNewName] = useState("");
const [newDesc, setNewDesc] = useState("");
const [selectedProjectId, setSelectedProjectId] = useState<string>("");
const [editId, setEditId] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const [editDesc, setEditDesc] = useState("");
// ادمین / مدیر تدارکات به بخش «انبارها» دسترسی ندارد — مدیریت انبارها از «سازمان‌دهی»
if (isAdmin) {
if (!canManage) {
return (
<div className="min-h-screen">
<Header title="انبارها" backTo="/inventory" backLabel="بازگشت به داشبورد" />
<PageLayout>
<div className="rounded-xl border border-line bg-panel p-8 text-center">
<div className="text-3xl">🔒</div>
<h3 className="mt-2 text-[15px] font-bold">دسترسی ندارد</h3>
<h3 className="mt-2 text-[15px] font-bold">دسترسی محدود</h3>
<p className="mt-1 text-[12.5px] text-muted">
بخش «انبارها» مخصوص مدیران پروژه است. مدیریت انبارها از بخش «سازمان‌دهی» انجام می‌شود.
مدیریت و تعریف انبارها مخصوص مدیران سیستم است. سرپرست پروژه فقط به ثبت تغییرات موجودی دسترسی دارد.
</p>
</div>
</PageLayout>
</div>
);
}
if (!isPM) {
return (
<div className="min-h-screen">
<Header title="انبارها" backTo="/inventory" backLabel="بازگشت به داشبورد" />
<PageLayout>
<div className="text-[13px] text-muted">این بخش فقط برای مدیر پروژه است.</div>
</PageLayout>
</div>
);
}
const addWarehouse = async () => {
const name = newName.trim();
if (!name) return toast.error("نام انبار الزامی است");
const projectId = user?.project_id;
const projectId = selectedProjectId || user?.project_id || data.lookup.projects[0]?.id;
if (!projectId) return toast.error("پروژه انبار را انتخاب کنید");
try {
await apiV26.warehouses.create({ project_id: projectId, name, description: newDesc.trim() || null });
@@ -99,13 +88,26 @@ export function WarehousesPage() {
backLabel="بازگشت به داشبورد"
/>
<PageLayout className="flex flex-col gap-3">
{isPM && (
{canManage && (
<div className="rounded-xl border border-line bg-panel p-3">
<h3 className="mb-2 text-[13px] font-bold">+ افزودن انبار جدید</h3>
<div className="flex flex-wrap gap-2">
<div className="flex flex-wrap items-center gap-2">
{data.lookup.projects.length > 1 && (
<select
className="h-9 rounded-lg border border-line bg-panel px-3 text-sm"
value={selectedProjectId || user?.project_id || data.lookup.projects[0]?.id || ""}
onChange={(e) => setSelectedProjectId(e.target.value)}
>
{data.lookup.projects.map((p) => (
<option key={p.id} value={p.id}>
پروژه: {p.name}
</option>
))}
</select>
)}
<input
className="h-9 w-52 rounded-lg border border-line bg-panel px-3 text-sm"
placeholder="نام انبار (مثلاً انبار موقت)"
placeholder="نام انبار (مثلاً انبار مرکزی)"
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
@@ -148,7 +150,7 @@ export function WarehousesPage() {
>
📊 مشاهده داشبورد انبار
</button>
{isPM && (
{canManage && (
<>
<button
className="rounded-lg border border-line px-3 py-1 text-[11.5px]"
@@ -162,7 +164,7 @@ export function WarehousesPage() {
</>
)}
</div>
{editId === w.id && isPM && (
{editId === w.id && canManage && (
<div className="mt-2 flex flex-col gap-1.5 rounded-lg border border-dashed border-accent/40 p-2" onClick={(e) => e.stopPropagation()}>
<input
className="h-8 rounded-lg border border-line bg-panel px-2 text-sm"
+21 -29
View File
@@ -10,20 +10,22 @@ export function useLogout() {
}
const ROLE_LABELS: Record<string, string> = {
pm: "مدیر پروژه",
procurement_manager: "مدیر تدارکات",
admin: "ادمین",
procurement_manager: "مدیر تدارکات",
pm: "مدیر پروژه",
supervisor: "سرپرست پروژه",
};
// زیرعنوان پویای سربرگ پنل انبار:
// [نام کاربر] | [نقش] – [پروژه / همه پروژه‌ها]
// مدیر پروژه → نام پروژه‌ی خودش؛ مدیر تدارکات/ادمین → «همه پروژه‌ها».
export function inventorySubheader(user: { display_name?: string; first_name: string; last_name: string; role: string | null; role_label: string | null; project_name: string | null } | null | undefined): string {
// سرپرست پروژه → نام پروژه‌ی خودش؛ مدیران/ادمین → «همه پروژه‌ها».
export function inventorySubheader(user: { username?: string; casdoor_id?: string; display_name?: string; first_name: string; last_name: string; role: string | null; role_label: string | null; project_name: string | null } | null | undefined): string {
if (!user) return "در حال بارگذاری…";
const name = user.display_name || `${user.first_name} ${user.last_name}`.trim() || "کاربر";
const name = `${user.first_name || ""} ${user.last_name || ""}`.trim() || user.display_name || "کاربر";
const uname = user.username || user.casdoor_id || "-";
const role = user.role_label || (user.role ? ROLE_LABELS[user.role] : "بدون نقش");
const proj = user.role === "pm" ? user.project_name || "پروژه نامشخص" : "همه پروژه‌ها";
return `${name} | ${role} – ${proj}`;
const proj = user.role === "supervisor" && user.project_name ? ` – پروژه: ${user.project_name}` : "";
return `${name} | نام کاربری: ${uname} | نقش: ${role}${proj}`;
}
// منوی همبرگریِ مختصر برای مدیر تدارکات/ادمین: بخش‌ها بالای صفحه هستند و
@@ -37,8 +39,7 @@ export function accountMenuItems(onLogout?: () => void): MenuItem[] {
return items;
}
// منوی همبرگری مدیر پروژه در پنل انبار — فقط «خروج از حساب»
// (مدیر پروژه به دفتر تدارکات دسترسی ندارد و دکمه‌ی خروجِ تکی هم حذف شده).
// منوی همبرگری سرپرست پروژه در پنل انبار
export function pmMenuItems(onLogout?: () => void): MenuItem[] {
const items: MenuItem[] = [];
if (onLogout) {
@@ -47,33 +48,24 @@ export function pmMenuItems(onLogout?: () => void): MenuItem[] {
return items;
}
// آیتم‌های منوی همبرگری پنل مدیریت انبار — با نشانگر پنل فعلی + «بازگشت به دفتر» + خروج
//
// دسترسی‌ها (v2.6):
// - ادمین / مدیر تدارکات (یک نفر): داشبورد، کنترل روزانه، کالاهای در راه، سازمان‌دهی —
// بدون «تغییر موجودی» و بدون «انبارها».
// - مدیر پروژه: داشبورد، تغییر موجودی، کنترل روزانه، انبارها، کالاهای در راه —
// بدون «سازمان‌دهی».
// آیتم‌های بخش‌های داخلی انبار
export function panelMenuItems(pathname: string, role: string | null | undefined, onLogout?: () => void): MenuItem[] {
const isAdmin = role === "admin" || role === "procurement_manager";
const isPM = role === "pm";
const isAdmin = role === "admin";
const isManager = role === "procurement_manager" || role === "pm" || isAdmin;
const isSupervisor = role === "supervisor" || isAdmin;
const items: MenuItem[] = [
{ label: "داشبورد انبار", to: "/inventory", active: pathname === "/inventory" },
];
if (isPM) {
// فقط سرپرست پروژه و ادمین می‌توانند تغییر موجودی بدهند
if (isSupervisor) {
items.push({ label: "تغییر موجودی", to: "/inventory/change", active: pathname.startsWith("/inventory/change") });
}
items.push({ label: "کنترل روزانه", to: "/inventory/daily", active: pathname.startsWith("/inventory/daily") });
if (isPM) {
// کنترل روزانه و انبارها و کالاهای در راه برای مدیران
if (isManager) {
items.push({ label: "کنترل روزانه", to: "/inventory/daily", active: pathname.startsWith("/inventory/daily") });
items.push({ label: "انبارها", to: "/inventory/warehouses", active: pathname.startsWith("/inventory/warehouses") });
}
items.push({ label: "کالاهای در راه", to: "/inventory/arrivals", active: pathname.startsWith("/inventory/arrivals") });
if (isAdmin) {
items.push({ label: "سازمان‌دهی", to: "/inventory/org", active: pathname.startsWith("/inventory/org") });
}
if (isAdmin) {
items.push({ divider: true });
items.push({ label: "بازگشت به دفتر", to: "/" });
items.push({ label: "کالاهای در راه", to: "/inventory/arrivals", active: pathname.startsWith("/inventory/arrivals") });
}
if (onLogout) {
items.push({ divider: true });
@@ -26,8 +26,8 @@ export interface InventoryData {
export function useInventoryData(): InventoryData {
const user = useAuthStore((s) => s.user);
const isPM = user?.role === "pm";
const projectId = isPM ? user.project_id || undefined : undefined;
const isSupervisor = user?.role === "supervisor";
const projectId = isSupervisor || user?.role === "pm" ? user?.project_id || undefined : undefined;
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
const [inventory, setInventory] = useState<InventoryRow[]>([]);
@@ -0,0 +1,291 @@
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { Header, PageLayout } from "../../components/layout/Header";
import { useAuthStore } from "../../store/auth";
import { apiV26, type UserRow } from "../../lib/api";
import { refreshProcurementStore, useProjects } from "../../hooks";
import { faNum } from "../../lib/formatters";
import { ProjectMultiSelect } from "../../components/ProjectMultiSelect";
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
type Role = "pm" | "procurement_manager" | "admin" | "supervisor" | null;
const ROLE_LABELS: Record<string, string> = {
admin: "ادمین",
procurement_manager: "مدیر تدارکات",
pm: "مدیر پروژه",
supervisor: "سرپرست پروژه",
};
// پنل مستقل سازمان‌دهی (ادمین، مدیر تدارکات، مدیر پروژه) — مدیریت کاربران، نقش‌ها و تخصیص پروژه‌ها به سرپرستان
export function OrganizationPage() {
const user = useAuthStore((s) => s.user);
const projects = useProjects();
const [users, setUsers] = useState<UserRow[]>([]);
const [loadingUsers, setLoadingUsers] = useState(true);
const [syncing, setSyncing] = useState(false);
const [busyId, setBusyId] = useState<string | null>(null);
const canManage = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm";
// فقط ادمین می‌تواند نقش‌ها را تغییر دهد؛ مدیران می‌توانند پروژه‌ها را به سرپرستان تخصیص دهند
const canChangeRoles = user?.role === "admin";
const loadUsers = useCallback(async () => {
try {
const list = await apiV26.users.list();
setUsers(list);
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در دریافت کاربران");
} finally {
setLoadingUsers(false);
}
}, []);
useEffect(() => {
if (canManage) void loadUsers();
}, [canManage, loadUsers]);
const syncUsers = async () => {
setSyncing(true);
try {
const { users: synced, created } = await apiV26.users.sync();
setUsers(synced);
toast.success(
created > 0
? `همگام‌سازی انجام شد — ${faNum(created)} کاربر جدید از Casdoor اضافه شد ✓`
: "همگام‌سازی انجام شد — کاربر جدیدی یافت نشد",
);
refreshProcurementStore();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در همگام‌سازی با Casdoor");
} finally {
setSyncing(false);
}
};
if (!canManage) {
return (
<div className="min-h-screen">
<Header title="سازمان‌دهی" backTo="/" backLabel="بازگشت به دفتر" />
<PageLayout>
<div className="text-[13px] text-muted">این بخش فقط برای ادمین، مدیر تدارکات و مدیر پروژه است.</div>
</PageLayout>
</div>
);
}
// ---------- actions: users ----------
const changeRole = async (u: UserRow, role: Role) => {
setBusyId(u.id);
try {
await apiV26.users.updateRole(u.id, role);
toast.success(`نقش «${ROLE_LABELS[role ?? ""] ?? "بدون نقش"}» برای ${u.first_name} ${u.last_name} ثبت شد ✓`);
await loadUsers();
refreshProcurementStore();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در تغییر نقش");
} finally {
setBusyId(null);
}
};
const assignedProjectsOf = (u: UserRow) =>
u.projects ?? (u.project_id ? [{ id: u.project_id, name: u.project_name ?? "" }] : []);
const syncUserProjects = async (u: UserRow, nextIds: string[]) => {
const current = assignedProjectsOf(u).map((p) => p.id);
const added = nextIds.filter((id) => !current.includes(id));
const removed = current.filter((id) => !nextIds.includes(id));
if (!added.length && !removed.length) return;
setBusyId(u.id);
try {
for (const pid of added) await apiV26.projects.setPm(pid, u.id);
for (const pid of removed) await apiV26.projects.setPm(pid, null);
const names = nextIds
.map((id) => projects.find((p) => p.id === id)?.name)
.filter(Boolean)
.join("، ");
toast.success(
nextIds.length
? `پروژه‌های «${u.first_name} ${u.last_name}» به‌روزرسانی شد: ${names} ✓`
: `«${u.first_name} ${u.last_name}» از همه‌ی پروژه‌ها جدا شد`,
);
await loadUsers();
refreshProcurementStore();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در به‌روزرسانی پروژه‌ها");
await loadUsers();
} finally {
setBusyId(null);
}
};
// سرپرستان تخصیص‌یافته به یک پروژه
const supervisorsOf = (projectId: string) =>
users.filter(
(u) =>
(u.role === "supervisor" || u.role === "pm") &&
(u.projects?.some((p) => p.id === projectId) || u.project_id === projectId),
);
return (
<div className="min-h-screen">
<Header
title="سازمان‌دهی"
brand
menu={<AppSwitcherMenu activeModule="organization" />}
/>
<PageLayout className="flex flex-col gap-4">
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted">
ℹ️ کاربران در Casdoor تعریف شده و از طریق دکمه «همگام‌سازی» اضافه می‌شوند. نقش‌ها در این پنل توسط ادمین تعیین می‌گردند.
مدیران پروژه و تدارکات می‌توانند پروژه‌ها را به سرپرستان پروژه تخصیص دهند.
</div>
{/* ---------- بخش ۱: جدول کاربران و نقش‌ها ---------- */}
<div className="rounded-xl border border-line bg-panel p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="text-[14px] font-bold">👥 کاربران و نقش‌ها</h3>
<p className="mt-0.5 text-[11.5px] text-muted">
تعیین نقش کاربران و تخصیص پروژه‌ها به سرپرستان پروژه
</p>
</div>
<button
onClick={() => void syncUsers()}
disabled={syncing}
className="rounded-lg border border-accent/40 bg-accent/5 px-3 py-1.5 text-[12px] font-bold text-accent disabled:opacity-50"
>
{syncing ? "در حال همگام‌سازی…" : "🔄 همگام‌سازی از Casdoor"}
</button>
</div>
{loadingUsers ? (
<div className="py-3 text-[12.5px] text-muted">در حال بارگذاری کاربران…</div>
) : !users.length ? (
<div className="rounded-lg border border-dashed border-line px-3 py-3 text-[12px] text-muted">
کاربری ثبت نشده است — دکمه‌ی «همگام‌سازی از Casdoor» را بزنید.
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-[12.5px]">
<thead>
<tr className="border-b border-line text-right text-muted">
<th className="py-2 pe-3">نام</th>
<th className="py-2 pe-3">نام کاربری</th>
<th className="py-2 pe-3">نقش</th>
<th className="py-2 pe-3">پروژه‌های تخصیص‌یافته (سرپرست پروژه)</th>
<th className="py-2">وضعیت</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id} className="border-b border-line/50">
<td className="py-2.5 pe-3">
<b>{u.first_name} {u.last_name}</b>
</td>
<td className="py-2.5 pe-3 font-mono text-[11px] text-muted">
{u.username || "—"}
</td>
<td className="py-2.5 pe-3">
{canChangeRoles ? (
<select
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px]"
value={u.role ?? ""}
disabled={busyId === u.id}
onChange={(e) => void changeRole(u, (e.target.value || null) as Role)}
>
<option value="">— بدون نقش —</option>
<option value="admin">ادمین</option>
<option value="procurement_manager">مدیر تدارکات</option>
<option value="pm">مدیر پروژه</option>
<option value="supervisor">سرپرست پروژه</option>
</select>
) : (
<span className="rounded bg-panel px-2 py-0.5 text-[11px] font-semibold">
{ROLE_LABELS[u.role ?? ""] ?? "بدون نقش"}
</span>
)}
</td>
<td className="py-2.5 pe-3">
{u.role === "supervisor" || u.role === "pm" ? (
<div className="w-72">
<ProjectMultiSelect
options={projects.map((p) => ({ id: p.id, name: p.name }))}
values={assignedProjectsOf(u).map((p) => p.id)}
onChange={(nextIds) => void syncUserProjects(u, nextIds)}
disabled={busyId === u.id}
/>
</div>
) : (
<span className="text-[11px] text-muted">
{u.role === "admin"
? "دسترسی کامل به همه پروژه‌ها"
: u.role === "procurement_manager"
? "دسترسی کامل مدیریتی"
: "—"}
</span>
)}
</td>
<td className="py-2.5 text-[11px] text-muted">
{busyId === u.id ? (
<span className="text-accent">در حال ذخیره…</span>
) : (
<span className="text-emerald-600">✓ ثبت‌شده</span>
)}
</td>
</tr>
))}
</tbody>
</table>
<div className="mt-2 text-[11px] text-muted">
سرپرستان پروژه‌ای که پروژه‌ای به آن‌ها تخصیص داده نشده باشد، تا زمان تخصیص پروژه اجازه ورود به سامانه را نخواهند داشت.
</div>
</div>
)}
</div>
{/* ---------- بخش ۲: کارت‌های پروژه‌ها و سرپرستان مسئول ---------- */}
<div>
<h3 className="mb-2 text-[14px] font-bold">🏢 پروژه‌ها و سرپرستان مسئول</h3>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
{projects.map((p) => {
const sups = supervisorsOf(p.id);
return (
<div key={p.id} className="rounded-xl border border-line bg-panel p-4">
<div className="mb-2 flex items-center justify-between">
<h4 className="text-[14px] font-bold">🏢 {p.name}</h4>
<span className="rounded-lg bg-panel px-2 py-0.5 text-[11px] text-muted">
{p.status || "پروژه فعال"}
</span>
</div>
{p.description && (
<p className="mb-3 text-[12px] text-muted line-clamp-2">{p.description}</p>
)}
<div>
<h5 className="mb-1.5 text-[11.5px] font-bold text-muted">👤 سرپرست(های) پروژه:</h5>
{!sups.length ? (
<div className="rounded-lg border border-dashed border-line px-3 py-2 text-[11.5px] text-amber-600">
⚠️ سرپرستی تعیین نشده است — از جدول بالا پروژه را به سرپرست تخصیص دهید.
</div>
) : (
<div className="flex flex-col gap-1">
{sups.map((s) => (
<div key={s.id} className="flex items-center gap-2 rounded-lg border border-line px-2.5 py-1.5 text-[12px]">
<span className="font-semibold">{s.first_name} {s.last_name}</span>
<span className="text-[10px] text-muted font-mono">({s.username})</span>
</div>
))}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
</PageLayout>
</div>
);
}
+165 -66
View File
@@ -1,11 +1,11 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import { toast } from "sonner";
import { Building2, Inbox, CheckCircle2, Truck, Phone, FolderKanban } from "lucide-react";
import { Building2, Inbox, CheckCircle2, Truck, Phone, FolderKanban, MapPin, UserCircle2, Trash2, Plus, X } from "lucide-react";
import type { Project, Quote, Store } from "../../lib/types";
import { nextId } from "../../lib/api";
import {
useAddProject,
useDeleteProject,
useMaterials,
useProjects,
useQuotes,
@@ -40,23 +40,14 @@ function projectQuotes(quotes: Quote[], name: string): Quote[] {
);
}
// آیا این پیشنهاد برای «این» پروژه پذیرفته شده است؟
// ملاک، پروژه‌ی هدفِ تصمیم خریدِ اقلام است (item_decisions.project_id) — نه
// وضعیت سراسری پیشنهاد؛ پیشنهادی که فقط برای پروژه‌ی دیگری پذیرفته شده،
// برای این پروژه همچنان «پیشنهاد شده» است.
function acceptedForProject(q: Quote, name: string): boolean {
return (q.line_items || []).some((li) => !li.rejected && li.target_project?.name === name);
}
// آیا اصلاً برای این پیشنهاد پروژه‌ی هدفی ثبت شده؟ (پیشنهادهای قدیمیِ
// پیش از era تصمیم‌پروژه، طبق وضعیت سراسری دسته‌بندی می‌شوند)
function hasTargets(q: Quote): boolean {
return (q.line_items || []).some((li) => !!li.target_project);
}
// اقلام مربوط به این پروژه در یک quote:
// اولویت با اقلامِ پذیرفته‌شده برای همین پروژه (تصمیم خرید)؛ سپس کاندیداهای
// همین پروژه؛ و اگر هیچ اتصال قلم‌به‌قلمی نبود، همه‌ی اقلام فعال.
function itemsForProject(q: Quote, name: string): Quote["line_items"] {
const items = (q.line_items || []).filter((li) => !li.rejected);
const targeted = items.filter((li) => li.target_project?.name === name);
@@ -74,6 +65,97 @@ function daysSince(iso: string | null): number | null {
return Math.floor((Date.now() - d.getTime()) / 86400000);
}
// ----- مودال افزودن پروژه -----
function AddProjectModal({
open,
onClose,
onSubmit,
loading,
}: {
open: boolean;
onClose: () => void;
onSubmit: (p: Omit<Project, "id">) => void;
loading: boolean;
}) {
const [name, setName] = useState("");
const [location, setLocation] = useState("");
const [projectManager, setProjectManager] = useState("");
const [description, setDescription] = useState("");
const [error, setError] = useState("");
const inputCls =
"w-full rounded-lg border border-line bg-surface px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40";
const reset = () => {
setName(""); setLocation(""); setProjectManager(""); setDescription(""); setError("");
};
const handleClose = () => { reset(); onClose(); };
const handleSubmit = () => {
if (!name.trim()) { setError("نام پروژه الزامی است"); return; }
setError("");
onSubmit({ name: name.trim(), location: location.trim(), project_manager: projectManager.trim(), description: description.trim() });
};
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm" onClick={handleClose}>
<div
className="w-full max-w-lg rounded-2xl bg-panel shadow-2xl border border-line p-6"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<h2 className="text-base font-bold">افزودن پروژه جدید</h2>
<button type="button" onClick={handleClose} className="text-muted hover:text-on-surface transition-colors">
<X size={18} />
</button>
</div>
<div className="space-y-3">
{error && <p className="rounded-lg bg-red-50 border border-red-200 text-red-700 px-3 py-2 text-xs">{error}</p>}
<div>
<label className="mb-1 block text-xs font-semibold text-muted">نام پروژه *</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleSubmit(); }}
placeholder="مثلاً: برج مسکونی آسمان"
className={inputCls}
autoFocus
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="mb-1 block text-xs font-semibold text-muted">محل پروژه</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-muted">مدیر / سرپرست پروژه</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-muted">توضیحات</label>
<textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={2} placeholder="توضیحات تکمیلی پروژه..." className={`${inputCls} resize-none`} />
</div>
<div className="flex justify-end gap-2 pt-2">
<button type="button" onClick={handleClose} className="h-9 rounded-lg border border-line px-4 text-sm text-muted hover:bg-surface transition-colors">انصراف</button>
<button type="button" disabled={loading} onClick={handleSubmit} className="h-9 rounded-lg bg-accent px-4 text-sm font-semibold text-white hover:bg-accent/90 disabled:opacity-50 transition-colors">
{loading ? "در حال ذخیره..." : "افزودن پروژه"}
</button>
</div>
</div>
</div>
</div>
);
}
// صفحه‌ی پروژه‌ها — لیست + پنل جزئیات ۳ ستونه
export function ProjectsPage() {
useStoreData();
@@ -83,6 +165,7 @@ export function ProjectsPage() {
const materials = useMaterials();
const { standardConversions } = useUnits();
const addProject = useAddProject();
const deleteProject = useDeleteProject();
const topics = useAppStore((s) => s.topics);
const user = useAuthStore((s) => s.user);
const canManage = user?.role === "admin" || user?.role === "procurement_manager";
@@ -90,53 +173,46 @@ export function ProjectsPage() {
const store: Store = { quotes, suppliers, materials, projects, standardConversions, topics };
const [openProject, setOpenProject] = useState<string | null>(null);
const [newName, setNewName] = useState("");
const [showAddModal, setShowAddModal] = useState(false);
const addProj = () => {
if (!canManage) return;
const name = newName.trim();
if (!name) return;
const dup = projects.some((p) => normalize(p.name) === normalize(name));
if (dup) {
toast.error("این پروژه قبلاً ثبت شده است.");
return;
const handleAddProject = async (data: Omit<Project, "id">) => {
const dup = projects.some((p) => normalize(p.name) === normalize(data.name));
if (dup) { toast.error("این پروژه قبلاً ثبت شده است."); return; }
try {
await addProject.mutateAsync({ id: "", ...data });
setShowAddModal(false);
setOpenProject(data.name);
toast.success("پروژه با موفقیت اضافه شد.");
} catch {
toast.error("خطا در ثبت پروژه");
}
};
const handleDeleteProject = async (p: Project, e: React.MouseEvent) => {
e.stopPropagation();
if (!confirm(`آیا از حذف پروژه «${p.name}» مطمئن هستید؟`)) return;
try {
await deleteProject.mutateAsync(p.id);
toast.success("پروژه حذف شد.");
if (openProject === p.name) setOpenProject(null);
} catch (err: any) {
toast.error(err?.message || "این پروژه دارای پیشنهاد است و قابل حذف نیست");
}
const p: Project = { id: nextId("p", projects), name };
void addProject.mutateAsync(p);
setNewName("");
setOpenProject(name);
toast.success("پروژه افزوده شد.");
};
return (
<div className="min-h-screen">
<Header title="پروژه‌ها" sub="روی یک پروژه کلیک کنید تا جزئیات آن باز شود" backTo="/" backLabel="بازگشت به دفتر" />
<Header title="پروژه‌ها" sub="مدیریت پروژه‌های مشترک سامانه" backTo="/" backLabel="بازگشت به دفتر" />
<PageLayout className="flex flex-col gap-4">
{canManage && (
<div className="add-panel">
<h4>+ افزودن پروژه‌ی جدید</h4>
<div className="grid max-w-md grid-cols-[1fr_auto] items-end gap-2">
<div className="field">
<label>نام پروژه *</label>
<input
type="text"
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
placeholder="مثلاً برج آیتا - فاز ۳"
autoComplete="off"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addProj();
}
}}
/>
</div>
<Button onClick={addProj}>افزودن پروژه</Button>
</div>
</div>
)}
<div className="add-panel flex items-center justify-between">
<h4>پروژه‌های سامانه</h4>
{canManage && (
<Button onClick={() => setShowAddModal(true)} className="flex items-center gap-1.5">
<Plus size={15} />
افزودن پروژه
</Button>
)}
</div>
{!projects.length && !quotes.length ? (
<EmptyState icon={FolderKanban} text="هنوز پروژه‌ای ثبت نشده است." />
@@ -147,16 +223,37 @@ export function ProjectsPage() {
const open = openProject === p.name;
return (
<div key={p.id} className="flex flex-col gap-1.5">
<button
type="button"
className={`proj-item ${open ? "open" : ""}`}
onClick={() => setOpenProject(open ? null : p.name)}
>
<span className={open ? "text-accent" : "text-muted"}>{open ? "▾" : "▸"}</span>
<span className="font-medium">{p.name}</span>
<span className="proj-count">{faNum(count)} پیشنهاد</span>
</button>
{/* جزئیات دقیقاً زیر همان پروژه باز می‌شود */}
<div className={`proj-item flex items-center gap-2 ${open ? "open" : ""}`}>
<button
type="button"
className="flex flex-1 items-center gap-2 min-w-0 text-right"
onClick={() => setOpenProject(open ? null : p.name)}
>
<span className={open ? "text-accent" : "text-muted"}>{open ? "▾" : "▸"}</span>
<span className="font-medium truncate">{p.name}</span>
{p.location && (
<span className="flex items-center gap-0.5 text-[11px] text-muted shrink-0">
<MapPin size={10} />{p.location}
</span>
)}
{p.project_manager && (
<span className="flex items-center gap-0.5 text-[11px] text-muted shrink-0">
<UserCircle2 size={10} />{p.project_manager}
</span>
)}
<span className="proj-count ms-auto shrink-0">{faNum(count)} پیشنهاد</span>
</button>
{canManage && (
<button
type="button"
onClick={(e) => handleDeleteProject(p, e)}
title="حذف پروژه"
className="text-muted hover:text-red-600 transition-colors p-1 rounded shrink-0"
>
<Trash2 size={13} />
</button>
)}
</div>
{open && <div className="ms-2">{renderProjectDetail(store, p.name)}</div>}
</div>
);
@@ -164,6 +261,13 @@ export function ProjectsPage() {
</div>
)}
</PageLayout>
<AddProjectModal
open={showAddModal}
onClose={() => setShowAddModal(false)}
onSubmit={handleAddProject}
loading={addProject.isPending}
/>
</div>
);
}
@@ -172,7 +276,6 @@ export function ProjectsPage() {
function renderProjectDetail(store: Store, name: string): React.ReactElement {
const quotes = projectQuotes(store.quotes, name);
// جمع مبلغ هر ستون فقط روی اقلامِ همین پروژه
const itemsTotal = (q: Quote) =>
itemsForProject(q, name).reduce((s, li) => s + (lineItemTotal(li) || 0), 0);
const sumBy = (list: Quote[]) => list.reduce((s, q) => s + itemsTotal(q), 0);
@@ -202,10 +305,6 @@ function renderProjectDetail(store: Store, name: string): React.ReactElement {
);
};
// دسته‌بندی ایزوله per-project:
// - پذیرفته/رسیده فقط وقتی حداقل یک قلمِ غیرردشده برای «این» پروژه پذیرفته شده باشد؛
// - پیشنهادی که صرفاً کاندیداست (یا فقط برای پروژه‌ی دیگری پذیرفته شده) → پیشنهاد شده.
// - پیشنهادهای قدیمیِ بدون پروژه‌ی هدف، طبق وضعیت سراسری می‌مانند.
const offered = quotes
.filter(
(q) =>
+1
View File
@@ -11,6 +11,7 @@ import { persist } from "zustand/middleware";
export interface AuthUser {
id: string;
casdoor_id: string;
username?: string;
first_name: string;
last_name: string;
phone: string | null;
+2
View File
@@ -41,6 +41,7 @@ interface AppStore {
deleteSupplier: (id: string) => void;
addMaterial: (m: Material) => void;
addProject: (p: Project) => void;
deleteProject: (id: string) => void;
setStandardConversion: (sc: StandardConversion) => void;
deleteStandardConversion: (id: string) => void;
@@ -85,6 +86,7 @@ export const useAppStore = create<AppStore>()((set) => ({
deleteSupplier: (id) => set((s) => ({ suppliers: s.suppliers.filter((x) => x.id !== id) })),
addMaterial: (m) => set((s) => ({ materials: [...s.materials, m] })),
addProject: (p) => set((s) => ({ projects: [...s.projects, p] })),
deleteProject: (id) => set((s) => ({ projects: s.projects.filter((p) => p.id !== id) })),
setStandardConversion: (sc) =>
set((s) => {
const idx = s.standardConversions.findIndex((x) => x.id === sc.id);