Revert 253c922: restore the PM/RBAC work to the integration

Reverting the production revert so the pm branch can merge cleanly on
top of the restored RBAC base. Conflicts against newer integration work
resolved by combining:
- App.tsx: v7's bot-draft wizard bypass + login returnUrl kept alongside
  the supervisor gating (no-project block screen, panel scoping)
- LoginPage: returnUrl navigation kept, supervisor routing added
- migration.sql: bot_drafts table kept, unified-projects ALTERs restored
This commit is contained in:
MMD
2026-10-01 15:24:56 +03:30
parent a84b33dde3
commit 0ea0322967
56 changed files with 1481 additions and 753 deletions
+114 -8
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 })),
@@ -68,16 +68,101 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
const returnUrl = encodeURIComponent(location.pathname + location.search);
return <Navigate to={`/login?returnUrl=${returnUrl}`} 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 (!location.pathname.startsWith("/inventory") && location.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" && user?.role !== "pm") {
return <Navigate to={user?.role === "supervisor" ? "/inventory" : "/"} replace />;
}
return <>{children}</>;
}
// پنل «مدیریت پروژه (PM)» — اپ PM روی همان دامنه زیر مسیر /pm سرو می‌شود
function PmPanelPage() {
return (
<iframe
src="/pm/"
title="مدیریت پروژه (PM)"
className="h-dvh w-full border-0"
/>
);
}
// دسترسی پنل PM: همه نقش‌ها دسترسی دارند
function RequirePmPanel({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
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>
@@ -97,7 +182,9 @@ export default function App() {
path="/wizard"
element={
<RequireAuth>
<WizardPage />
<RequireProcurementWrite>
<WizardPage />
</RequireProcurementWrite>
</RequireAuth>
}
/>
@@ -177,7 +264,9 @@ export default function App() {
element={
<RequireAuth>
<RequirePmProjects>
<ChangeInventoryPage />
<RequireInventoryChange>
<ChangeInventoryPage />
</RequireInventoryChange>
</RequirePmProjects>
</RequireAuth>
}
@@ -197,18 +286,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>
}
/>
@@ -220,6 +315,17 @@ export default function App() {
</RequireAuth>
}
/>
{/* پنل مدیریت پروژه — فقط ادمین و مدیر پروژه */}
<Route
path="/panel/pm"
element={
<RequireAuth>
<RequirePmPanel>
<PmPanelPage />
</RequirePmPanel>
</RequireAuth>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
+56 -32
View File
@@ -1,48 +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): زیر «مدیریت انبار» یک آکاردئون باز می‌شود که همه‌ی
// پروژه‌های تخصیص‌یافته را نشان می‌دهد؛ پروژه‌ی فعال با «✓ فعلی» مشخص است و
// انتخاب هر پروژه فقط «پروژه‌ی فعال» را عوض می‌کند (نشست/توکن دست‌نخورده).
// منوی همبرگری مشترک بین ۴ پنل اصلی سامانه:
// ۱. مدیریت انبار (/inventory)
// ۲. مدیریت تدارکات (/)
// ۳. مدیریت پروژه (/panel/pm)
// ۴. سازمان‌دهی (/organization)
export function AppSwitcherMenu({
activeModule,
canSwitchToProcurement = true,
}: {
activeModule: "warehouse" | "procurement";
canSwitchToProcurement?: boolean; // مدیر پروژه به دفتر تدارکات دسترسی ندارد
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 isSupervisor = user?.role === "supervisor";
const isPm = user?.role === "pm";
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 (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 (isSupervisor) {
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} />;
+17
View File
@@ -6,6 +6,8 @@ import { cn } from "../lib/utils";
export interface MenuItem {
label?: string;
to?: string;
// لینک خارجی (اپ جداگانه، مثل سامانه PM) — در تب جدید باز می‌شود
href?: string;
active?: boolean;
divider?: boolean;
onClick?: () => void;
@@ -112,6 +114,21 @@ export function PanelMenu({ items, title }: { items: MenuItem[]; title?: string
</div>
)}
</div>
) : it.href ? (
<a
key={i}
href={it.href}
target="_blank"
rel="noopener noreferrer"
onClick={() => setOpen(false)}
className={cn(
"flex items-center justify-between gap-3 rounded-lg px-3 py-2 text-[13px] no-underline",
it.active ? "bg-accent font-bold text-white" : "text-ink hover:bg-slate-bg",
)}
>
{it.label}
<span aria-hidden className="text-[10px] opacity-60">↗</span>
</a>
) : it.to ? (
<Link
key={i}
@@ -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[] }) {
@@ -81,6 +80,10 @@ export function MobileTabBar() {
const { pathname } = useLocation();
if (pathname === "/login" || pathname === "/admin") return null;
// پنل مدیریت پروژه: iframe تمام‌صفحه‌ی PM است و خودش تب‌بار داخلی دارد
if (pathname === "/panel/pm") return null;
// سرپرست پروژه فقطِ پنل PM است و هیچ تب ERP ندارد
if (role === "supervisor") return null;
const isPM = role === "pm";
const isOffice = role === "admin" || role === "procurement_manager";
+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(),
});
}
+44 -9
View File
@@ -91,7 +91,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 {
@@ -339,6 +339,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;
@@ -1231,11 +1237,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}`);
},
},
@@ -1625,10 +1656,11 @@ 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;
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;
@@ -1653,10 +1685,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;
@@ -1670,6 +1703,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,
},
};
@@ -1689,14 +1723,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,
@@ -1720,7 +1755,7 @@ export const apiV26 = {
first_name: string; last_name?: string; phone?: string | null;
casdoor_id?: string | null; role?: "pm" | "procurement_manager" | "admin" | null;
}): Promise<UserRow> => postData<UserRow>("/users", body),
updateRole: async (id: string, role: "pm" | "procurement_manager" | "admin" | null): Promise<UserRow> =>
updateRole: async (id: string, role: "pm" | "procurement_manager" | "admin" | "supervisor" | null): Promise<UserRow> =>
patchData<UserRow>(`/users/${id}`, { role }),
assignProject: async (id: string, projectId: string | null): Promise<UserRow> =>
postData<UserRow>(`/users/${id}/project`, { project_id: projectId }),
+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;
}
// مرحله‌ی پرداخت
+47 -12
View File
@@ -15,6 +15,7 @@ const ROLE_LABELS: Record<string, string> = {
pm: "مدیر پروژه",
procurement_manager: "مدیر تدارکات",
admin: "ادمین",
supervisor: "سرپرست پروژه",
};
// پنل مدیریت ادمین (فقط ادمین) — همگام‌سازی کاربران از Casdoor و
@@ -80,6 +81,26 @@ export function AdminPanel() {
}
};
// اعطا/لغو نقش «سرپرست پروژه» — کاربر فقطِ پنل مدیریت پروژه (PM)
const toggleSupervisor = async (u: UserRow) => {
setBusyId(u.id);
const target = u.role === "supervisor" ? null : "supervisor";
try {
await apiV26.users.updateRole(u.id, target as "supervisor" | null);
toast.success(
target === "supervisor"
? `«${u.first_name} ${u.last_name}» سرپرست پروژه شد ✓`
: `نقش سرپرست پروژه از «${u.first_name} ${u.last_name}» گرفته شد`,
);
await load();
refreshProcurementStore();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در تغییر نقش");
} finally {
setBusyId(null);
}
};
if (!isAdmin) {
return (
<div className="min-h-screen">
@@ -129,12 +150,13 @@ export function AdminPanel() {
<th className="py-2 pe-3">نام کامل</th>
<th className="py-2 pe-3">شناسه Casdoor</th>
<th className="py-2 pe-3">نقش فعلی</th>
<th className="py-2">مدیریت مدیر تدارکات</th>
<th className="py-2">مدیریت نقش‌ها</th>
</tr>
</thead>
<tbody>
{users.map((u) => {
const isPMgr = u.role === "procurement_manager";
const isSup = u.role === "supervisor";
return (
<tr key={u.id} className="border-b border-line/50">
<td className="py-2 pe-3 font-semibold">
@@ -147,17 +169,30 @@ export function AdminPanel() {
</span>
</td>
<td className="py-2">
<button
disabled={busyId === u.id}
onClick={() => void toggleProcurementManager(u)}
className={`rounded-lg border px-3 py-1 text-[11.5px] font-bold ${
isPMgr
? "border-red/30 bg-red/5 text-red hover:bg-red/10"
: "border-green/30 bg-green/5 text-green hover:bg-green/10"
} disabled:opacity-50`}
>
{busyId === u.id ? "…" : isPMgr ? "لغو نقش مدیر تدارکات" : "اعطای نقش مدیر تدارکات"}
</button>
<div className="flex flex-wrap items-center gap-1.5">
<button
disabled={busyId === u.id}
onClick={() => void toggleProcurementManager(u)}
className={`rounded-lg border px-3 py-1 text-[11.5px] font-bold ${
isPMgr
? "border-red/30 bg-red/5 text-red hover:bg-red/10"
: "border-green/30 bg-green/5 text-green hover:bg-green/10"
} disabled:opacity-50`}
>
{busyId === u.id ? "…" : isPMgr ? "لغو نقش مدیر تدارکات" : "اعطای نقش مدیر تدارکات"}
</button>
<button
disabled={busyId === u.id}
onClick={() => void toggleSupervisor(u)}
className={`rounded-lg border px-3 py-1 text-[11.5px] font-bold ${
isSup
? "border-red/30 bg-red/5 text-red hover:bg-red/10"
: "border-green/30 bg-green/5 text-green hover:bg-green/10"
} disabled:opacity-50`}
>
{busyId === u.id ? "…" : isSup ? "لغو نقش سرپرست پروژه" : "اعطای نقش سرپرست پروژه"}
</button>
</div>
</td>
</tr>
);
+8 -3
View File
@@ -79,6 +79,10 @@ 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_*)
@@ -88,9 +92,10 @@ export function LoginPage() {
if (returnUrl) {
navigate(decodeURIComponent(returnUrl));
} else {
// مدیر پروژه → پنل انبار؛ مدیر تدارکات/ادمین → دفتر پیشنهادهای خرید
navigate(user.role === "pm" ? "/inventory" : "/");
}
// سرپرست پروژه → پنل مدیریت پروژه (PM)؛ مدیر پروژه → پنل انبار؛ بقیه → دفتر
if (user.role === "supervisor") navigate("/panel/pm");
else navigate(user.role === "pm" ? "/inventory" : "/");
} (revert: restore production to the state before 730d0bd (PM/RBAC work moved to 'pm'))
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در ورود");
} finally {
+24 -15
View File
@@ -18,12 +18,16 @@ import { LedgerTable } from "./LedgerTable";
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
import { AcceptModal } from "./AcceptModal";
import { EditModal } from "./EditModal";
import { useAuthStore } from "../../store/auth";
const EMPTY_FILTERS: Filters = { supplier: "", status: "", search: "" };
export function DashboardPage() {
useStoreData();
const user = useAuthStore((s) => s.user);
const canWrite = user?.role === "admin" || user?.role === "procurement_manager";
const quotes = useQuotes();
const suppliers = useSuppliers();
const materials = useMaterials();
@@ -85,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={
@@ -114,13 +118,15 @@ export function DashboardPage() {
</Link>
{/* دکمه اصلی: افزودن پیشنهاد جدید */}
<Link
to="/wizard"
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-xs font-semibold shadow-sm hover:shadow transition-all no-underline"
>
<Plus className="h-4 w-4" />
<span>افزودن پیشنهاد جدید</span>
</Link>
{canWrite && (
<Link
to="/wizard"
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-xs font-semibold shadow-sm hover:shadow transition-all no-underline"
>
<Plus className="h-4 w-4" />
<span>افزودن پیشنهاد جدید</span>
</Link>
)}
</div>
}
/>
@@ -148,17 +154,20 @@ export function DashboardPage() {
onToggleExpand={toggleExpand}
onAccept={setAcceptId}
onEdit={setEditId}
canWrite={canWrite}
/>
</PageLayout>
{/* دکمه شناور اکشن (FAB) مخصوص نسخه موبایل */}
<Link
to="/wizard"
aria-label="افزودن پیشنهاد جدید"
className="md:hidden fixed bottom-20 left-4 z-40 flex h-14 w-14 items-center justify-center rounded-full bg-accent text-white shadow-xl shadow-accent/30 active:scale-95 transition-transform"
>
<Plus className="h-7 w-7" />
</Link>
{canWrite && (
<Link
to="/wizard"
aria-label="افزودن پیشنهاد جدید"
className="md:hidden fixed bottom-20 left-4 z-40 flex h-14 w-14 items-center justify-center rounded-full bg-accent text-white shadow-xl shadow-accent/30 active:scale-95 transition-transform"
>
<Plus className="h-7 w-7" />
</Link>
)}
<AcceptModal quoteId={acceptId} onClose={() => setAcceptId(null)} />
<EditModal quoteId={editId} onClose={() => setEditId(null)} />
@@ -10,6 +10,7 @@ export interface LedgerTableProps {
onToggleExpand: (id: string) => void;
onAccept: (id: string) => void;
onEdit: (id: string) => void;
canWrite?: boolean;
}
// دفتر پیشنهادها — فهرست کارت‌ها (واکنش‌گرا و بهینه برای موبایل)
@@ -20,6 +21,7 @@ export function LedgerTable({
onToggleExpand,
onAccept,
onEdit,
canWrite = true,
}: LedgerTableProps) {
if (!quotes.length) {
return (
@@ -40,6 +42,7 @@ export function LedgerTable({
onToggleExpand={() => onToggleExpand(q.id)}
onAccept={() => onAccept(q.id)}
onEdit={() => onEdit(q.id)}
canWrite={canWrite}
/>
))}
<div className="px-2 py-3 text-center text-[10.5px] leading-5 text-muted sm:py-1 sm:text-[11px]">
+20 -15
View File
@@ -24,6 +24,7 @@ export interface QuoteCardProps {
onToggleExpand: () => void;
onAccept: () => void;
onEdit: () => void;
canWrite?: boolean;
}
function InfoCell({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) {
@@ -35,7 +36,7 @@ function InfoCell({ label, children, className }: { label: string; children: Rea
);
}
export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit }: QuoteCardProps) {
export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit, canWrite = true }: QuoteCardProps) {
const sup = supplierById(store.suppliers, q.supplier_id);
const expired = isExpired(q);
const total = quoteTotal(q);
@@ -46,7 +47,7 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
// ستون / دکمه پذیرش
let acceptedCell: React.ReactNode;
if (q.status === "offered") {
acceptedCell = (
acceptedCell = canWrite ? (
<button
onClick={(e) => {
e.stopPropagation();
@@ -57,6 +58,8 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
<Check className="h-3.5 w-3.5" />
<span>پذیرش</span>
</button>
) : (
<span className="text-muted text-xs font-medium">در انتظار پذیرش</span>
);
} else if (q.accepted_date) {
acceptedCell = <span className="tabular-nums font-medium">{fmtDateTime(q.accepted_date)}</span>;
@@ -185,19 +188,21 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
)}
{/* دکمه ویرایش */}
<button
type="button"
className="inline-flex h-7 items-center justify-center gap-1 rounded-md border border-line bg-panel px-2 text-xs font-medium text-ink hover:bg-slate-50 dark:hover:bg-zinc-800 transition-colors sm:h-8 sm:px-2.5"
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
title="ویرایش پیشنهاد"
aria-label="ویرایش"
>
<Pencil className="h-3 w-3 text-muted" />
<span className="hidden sm:inline">ویرایش</span>
</button>
{canWrite && (
<button
type="button"
className="inline-flex h-7 items-center justify-center gap-1 rounded-md border border-line bg-panel px-2 text-xs font-medium text-ink hover:bg-slate-50 dark:hover:bg-zinc-800 transition-colors sm:h-8 sm:px-2.5"
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
title="ویرایش پیشنهاد"
aria-label="ویرایش"
>
<Pencil className="h-3 w-3 text-muted" />
<span className="hidden sm:inline">ویرایش</span>
</button>
)}
{/* دکمه فلش اکسپند در هدر */}
<button
@@ -94,8 +94,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(
@@ -604,7 +605,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="بازگشت به داشبورد" />
@@ -612,22 +613,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";
+15 -13
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>
);
@@ -150,10 +152,10 @@ export function InventoryPage() {
title="سامانه مدیریت انبار"
sub={inventorySubheader(user)}
icon={<WarehouseIcon className="h-4.5 w-4.5" strokeWidth={2} />}
menu={<AppSwitcherMenu activeModule="warehouse" canSwitchToProcurement={isAdmin} />}
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>
);
}
+167 -62
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,
@@ -25,6 +25,7 @@ import { faNum, fmtDate } from "../../lib/formatters";
import { Header, PageLayout } from "../../components/layout/Header";
import { EmptyState } from "../../components/EmptyState";
import { Button } from "../../components/ui/button";
import { useAuthStore } from "../../store/auth";
function projectQuotes(quotes: Quote[], name: string): Quote[] {
return quotes.filter(
@@ -39,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);
@@ -73,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();
@@ -82,54 +165,53 @@ 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";
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 = () => {
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">
<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 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 ? (
@@ -141,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>
);
@@ -158,6 +261,13 @@ export function ProjectsPage() {
</div>
)}
</PageLayout>
<AddProjectModal
open={showAddModal}
onClose={() => setShowAddModal(false)}
onSubmit={handleAddProject}
loading={addProject.isPending}
/>
</div>
);
}
@@ -166,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);
@@ -196,10 +305,6 @@ function renderProjectDetail(store: Store, name: string): React.ReactElement {
);
};
// دسته‌بندی ایزوله per-project:
// - پذیرفته/رسیده فقط وقتی حداقل یک قلمِ غیرردشده برای «این» پروژه پذیرفته شده باشد؛
// - پیشنهادی که صرفاً کاندیداست (یا فقط برای پروژه‌ی دیگری پذیرفته شده) → پیشنهاد شده.
// - پیشنهادهای قدیمیِ بدون پروژه‌ی هدف، طبق وضعیت سراسری می‌مانند.
const offered = quotes
.filter(
(q) =>
+10 -5
View File
@@ -14,6 +14,7 @@ import { Combobox } from "../../components/Combobox";
import { SupplierCard, suppliersOfCategory } from "./supplierUtils";
import { materialLabel } from "../../lib/quoteUtils";
import { Button } from "../../components/ui/button";
import { useAuthStore } from "../../store/auth";
// صفحه‌ی تأمین‌کننده‌های یک دسته + فرم افزودن تأمین‌کننده
export function CategoryPage() {
@@ -26,6 +27,8 @@ export function CategoryPage() {
const addSupplier = useAddSupplier();
const removeSupplier = useRemoveSupplier();
const topics = useAppStore((s) => s.topics);
const user = useAuthStore((s) => s.user);
const canManage = user?.role === "admin" || user?.role === "procurement_manager";
const cat = useMemo(() => topicByName(catParam, topics)?.name ?? null, [catParam, topics]);
@@ -121,7 +124,7 @@ export function CategoryPage() {
key={s.id}
s={s}
topic={cat}
deletable
deletable={canManage}
onDelete={removeSup}
materials={supMats.get(s.id) || []}
avgLeadTime={supLeadTimes.get(s.id)}
@@ -130,10 +133,11 @@ export function CategoryPage() {
</div>
)}
<div className="add-panel">
<h4>
<Plus className="me-1 inline h-4 w-4 text-accent" /> افزودن تأمین‌کننده‌ی جدید
</h4>
{canManage && (
<div className="add-panel">
<h4>
<Plus className="me-1 inline h-4 w-4 text-accent" /> افزودن تأمین‌کننده‌ی جدید
</h4>
<div className="grid max-w-2xl grid-cols-2 gap-3 max-md:grid-cols-1">
<div className="field">
<label>نام تأمین‌کننده *</label>
@@ -182,6 +186,7 @@ export function CategoryPage() {
ذخیره تأمین‌کننده
</Button>
</div>
)}
</PageLayout>
</div>
);
@@ -14,6 +14,7 @@ import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
import { ConfirmDeleteDialog } from "../../components/ConfirmDeleteDialog";
import { normalize } from "../../lib/normalize";
import { PurchaseHistory } from "./PurchaseHistory";
import { useAuthStore } from "../../store/auth";
// تمیزکاری و استانداردسازی شماره برای شماره‌گیر خودکار موبایل (اندروید و iOS)
function cleanPhoneForTel(phone?: string | null): string {
@@ -32,6 +33,8 @@ export function SupplierDetailPage() {
const quotes = useQuotes();
const materials = useMaterials();
const topics = useAppStore((s) => s.topics);
const user = useAuthStore((s) => s.user);
const canManage = user?.role === "admin" || user?.role === "procurement_manager";
const s = useMemo(() => suppliers.find((x) => x.id === id) || null, [suppliers, id]);
const [pendingRemove, setPendingRemove] = useState<string | null>(null);
@@ -184,13 +187,15 @@ export function SupplierDetailPage() {
</div>
<div className="mt-4 flex items-center gap-2">
<Link
to={`/wizard?topic=${encodeURIComponent(topicName)}&supplier=${encodeURIComponent(s.id)}`}
className="btn-line-file no-underline bg-accent text-white hover:bg-accent-dark"
>
<Plus className="h-3.5 w-3.5" />
پیشنهاد جدید
</Link>
{canManage && (
<Link
to={`/wizard?topic=${encodeURIComponent(topicName)}&supplier=${encodeURIComponent(s.id)}`}
className="btn-line-file no-underline bg-accent text-white hover:bg-accent-dark"
>
<Plus className="h-3.5 w-3.5" />
پیشنهاد جدید
</Link>
)}
{firstCat ? (
<Link to={`/suppliers/category/${encodeURIComponent(firstCat)}`} className="btn-line-file no-underline">
‹ بازگشت به دسته
@@ -216,19 +221,23 @@ export function SupplierDetailPage() {
{linked.map((c) => (
<span key={c} className="spec-chip">
{c}
<button type="button" className="spec-chip-x" title="حذف متریال" onClick={() => setPendingRemove(c)}>
<X className="h-3 w-3" />
</button>
{canManage && (
<button type="button" className="spec-chip-x" title="حذف متریال" onClick={() => setPendingRemove(c)}>
<X className="h-3 w-3" />
</button>
)}
</span>
))}
</div>
)}
<MultiSelectCombobox
values={[]}
onChange={(cats) => saveCategories(cats)}
options={materialOptions}
placeholder="افزودن متریال..."
/>
{canManage && (
<MultiSelectCombobox
values={[]}
onChange={(cats) => saveCategories(cats)}
options={materialOptions}
placeholder="افزودن متریال..."
/>
)}
{purchased.length > 0 && (
<div className="mt-3 border-t border-line pt-2">
<div className="mb-1 text-[11px] font-semibold text-muted">متریال‌های خریداری‌شده (از پیشنهادها):</div>
+2 -1
View File
@@ -11,10 +11,11 @@ import { persist } from "zustand/middleware";
export interface AuthUser {
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; // پروژه‌ی فعال (PM) — با سوییچر عوض می‌شود
project_name: 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);