feat(rbac): enforce procurement write restrictions and support supervisor role in SSO

This commit is contained in:
2026-09-26 12:32:01 +03:30
parent 730d0bd496
commit 5a9b543040
31 changed files with 480 additions and 139 deletions
+12 -1
View File
@@ -71,6 +71,15 @@ function HomeRoute() {
return <DashboardPage />;
}
// دسترسی ایجاد استعلام/پیشنهاد (ویزارد) فقط برای مدیر تدارکات و ادمین
function RequireProcurementWrite({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
if (user?.role !== "admin" && user?.role !== "procurement_manager") {
return <Navigate to={user?.role === "pm" ? "/inventory" : "/"} replace />;
}
return <>{children}</>;
}
// پنل «مدیریت پروژه (PM)» — اپ PM روی همان دامنه زیر مسیر /pm سرو می‌شود و
// اینجا به‌صورت iframe تمام‌صفحه داخل پوسته ERP نمایش داده می‌شود.
function PmPanelPage() {
@@ -109,7 +118,9 @@ export default function App() {
path="/wizard"
element={
<RequireAuth>
<WizardPage />
<RequireProcurementWrite>
<WizardPage />
</RequireProcurementWrite>
</RequireAuth>
}
/>
+1 -1
View File
@@ -17,7 +17,7 @@ export function AppSwitcherMenu({
canSwitchToProcurement = true,
}: {
activeModule: "warehouse" | "procurement";
canSwitchToProcurement?: boolean; // مدیر پروژه به دفتر تدارکات دسترسی ندارد
canSwitchToProcurement?: boolean;
}) {
const logout = useLogout();
const { pathname } = useLocation();
+23 -14
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();
@@ -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
@@ -150,7 +150,7 @@ 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"} />
+27 -21
View File
@@ -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(
@@ -83,6 +84,8 @@ export function ProjectsPage() {
const { standardConversions } = useUnits();
const addProject = useAddProject();
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 };
@@ -90,6 +93,7 @@ export function ProjectsPage() {
const [newName, setNewName] = useState("");
const addProj = () => {
if (!canManage) return;
const name = newName.trim();
if (!name) return;
const dup = projects.some((p) => normalize(p.name) === normalize(name));
@@ -108,29 +112,31 @@ export function ProjectsPage() {
<div className="min-h-screen">
<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();
}
}}
/>
{canManage && (
<div className="add-panel">
<h4>+ افزودن پروژه‌ی جدید</h4>
<div className="grid max-w-md grid-cols-[1fr_auto] items-end gap-2">
<div className="field">
<label>نام پروژه *</label>
<input
type="text"
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
placeholder="مثلاً برج آیتا - فاز ۳"
autoComplete="off"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addProj();
}
}}
/>
</div>
<Button onClick={addProj}>افزودن پروژه</Button>
</div>
<Button onClick={addProj}>افزودن پروژه</Button>
</div>
</div>
)}
{!projects.length && !quotes.length ? (
<EmptyState icon={FolderKanban} text="هنوز پروژه‌ای ثبت نشده است." />
+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>