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:
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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[]>([]);
|
||||
|
||||
Reference in New Issue
Block a user