feat(frontend): unified warehouse UI, calendar fix, partial receipts, RBAC views, admin panel, dev-mode 3-step

- header: icon box + dynamic subheader '[user] | [role] – [project/همه پروژهها]'
  on every warehouse page; module context kept in the hamburger (items 1-2)
- project manager view: office button + inline logout removed; hamburger
  contains only «خروج از حساب»; sub-views get «بازگشت به داشبورد» (Header
  backTo/goBack); «افزایش موجود» primary button on the dashboard (item 8)
- daily control: cells render the day-of-month (was the Jalali year),
  month nav uses real Jalali months, header is pure Jalali
  «امروز ۸ شهریور ۱۴۰۵», today gets a blue accent (item 3)
- arrivals: partial (item/qty) receipt — any valid item is enough to submit,
  ceiling + allocation-sum validation with inline errors, Received/In-Transit
  badges; ledger shows «رسیده X از Y» / «در راه Z» + «رسید جزیی» badge (item 4)
- api client: multipart uploads no longer send Content-Type json (fixes the
  invoice/proforma 400 «دادهی ارسالشده نامعتبر است»); undefined query
  params are dropped (fixes empty admin project filter, item 5)
- organization: user-creation card removed, role column read-only for
  procurement manager, Casdoor sync button (item 6)
- admin panel (/admin): Casdoor user sync + grant/revoke «مدیر تدارکات»;
  Developer Mode is a 3-step admin-gated flow on the login page (item 7)
This commit is contained in:
ali_mohaghegh
2026-09-01 17:28:19 +03:30
parent 83f4fb0747
commit c5997ac243
17 changed files with 648 additions and 228 deletions
+95 -45
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useLocation } from "react-router-dom";
import { Warehouse as WarehouseIcon } from "lucide-react";
import { toast } from "sonner";
import { Header, PageLayout } from "../../components/layout/Header";
import { EmptyState } from "../../components/EmptyState";
@@ -7,7 +8,7 @@ import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import { useAuthStore } from "../../store/auth";
import { PanelMenu } from "../../components/PanelMenu";
import { panelMenuItems, accountMenuItems, useLogout } from "./panelMenu";
import { accountMenuItems, inventorySubheader, panelMenuItems, pmMenuItems, useLogout } from "./panelMenu";
import { InventoryTopNav } from "./InventoryTopNav";
import { apiV26 } from "../../lib/api";
import type { PendingArrival, Warehouse } from "../../lib/types";
@@ -121,53 +122,81 @@ export function ArrivalsPage() {
return { ...d, [decisionId]: { ...cur, allocs: cur.allocs.filter((a) => a.key !== key) } };
});
const remainingOf = (it: PendingArrival): number => {
const d = draftOf(it);
const received = parseFloat(d.received) || 0;
const allocated = d.allocs.reduce((s, a) => s + ((parseFloat(a.qty) || 0) > 0 ? parseFloat(a.qty) || 0 : 0), 0);
return Math.round((received - allocated) * 10000) / 10000;
};
const round4 = (n: number) => Math.round(n * 10000) / 10000;
const itemReady = (it: PendingArrival): boolean => {
// مانده‌ی واقعیِ قابل‌دریافت برای این قلم: سفارش − قبلاً‌رسیده (− پیش‌نویس فعلی)
const remainingToReceive = (it: PendingArrival): number =>
round4(parseFloat(it.ordered_qty) - parseFloat(it.received_qty));
const draftReceivedOf = (it: PendingArrival): number => parseFloat(draftOf(it).received) || 0;
const allocSumOf = (it: PendingArrival): number =>
round4(draftOf(it).allocs.reduce((s, a) => s + ((parseFloat(a.qty) || 0) > 0 ? parseFloat(a.qty) || 0 : 0), 0));
// یک قلم برای ثبت «رسید جزیی» آماده است: تعداد > ۰، داخل سقف باقی‌مانده،
// ردیف‌های تخصیص معتبر و مجموع تخصیص == تعداد رسیده.
const itemSubmittable = (it: PendingArrival): boolean => {
const d = draftOf(it);
const received = parseFloat(d.received);
const received = draftReceivedOf(it);
if (!(received > 0)) return false;
// محدودیت: تعداد رسیده نمی‌تواند از تعداد سفارش‌شده بیشتر باشد
if (received > parseFloat(it.ordered_qty)) return false;
if (d.allocs.some((a) => !a.whId)) return false;
return remainingOf(it) === 0;
if (received > remainingToReceive(it)) return false; // سقف: بیش از مانده‌ی سفارش نه
if (d.allocs.some((a) => (parseFloat(a.qty) || 0) > 0 && !a.whId)) return false;
return allocSumOf(it) === received;
};
const orderReady = (quoteId: string): boolean => {
const its = orderItems(quoteId);
return its.length > 0 && its.every((it) => it.state === "arrived" || itemReady(it));
const itemHasErrors = (it: PendingArrival): { ceiling: boolean; sumMismatch: boolean; emptyWh: boolean } => {
const d = draftOf(it);
const received = draftReceivedOf(it);
return {
ceiling: received > remainingToReceive(it),
sumMismatch: received > 0 && allocSumOf(it) !== received,
emptyWh: d.allocs.some((a) => (parseFloat(a.qty) || 0) > 0 && !a.whId),
};
};
// برای ثبت رسید، کافی است حداقل یک قلم مقدار معتبر داشته باشد؛
// قلم‌های بدون مقدار «در راه» می‌مانند (رسید جزیی مجاز است).
const orderHasSubmittable = (quoteId: string): boolean => {
const its = orderItems(quoteId);
return its.length > 0 && its.some((it) => it.state !== "arrived" && itemSubmittable(it));
};
const orderHasErrors = (quoteId: string): boolean =>
orderItems(quoteId).some((it) => {
const errs = itemHasErrors(it);
return errs.ceiling || errs.sumMismatch || errs.emptyWh;
});
// ---------- submit ----------
const submitOrder = async (quoteId: string) => {
const its = orderItems(quoteId).filter((it) => it.state !== "arrived");
if (!orderReady(quoteId)) {
toast.error("برای هر قلم: تعداد رسیده را وارد کنید و تا صفر شدن «مانده تخصیص» ردیف‌ها را پر کنید.");
const submittable = its.filter(itemSubmittable);
if (!submittable.length) {
toast.error("حداقل یک قلم با تعداد معتبر (داخل مانده‌ی سفارش و تخصیصِ برابر با تعداد رسیده) وارد کنید.");
return;
}
if (orderHasErrors(quoteId)) {
toast.error("برخی قلم‌ها خطای اعتبارسنجی دارند — ابتدا سقف رسید و مجموع تخصیص انبارها را اصلاح کنید.");
return;
}
setSaving(quoteId);
try {
for (const it of its) {
for (const it of submittable) {
const d = draftOf(it);
const allocations = d.allocs
.filter((a) => (parseFloat(a.qty) || 0) > 0 && a.whId)
.map((a) => ({ warehouse_id: a.whId, quantity: parseFloat(a.qty) }));
await apiV26.pm.confirmReceipt({
item_decision_id: it.item_decision_id,
received_qty: parseFloat(d.received),
received_qty: draftReceivedOf(it),
allocations,
});
}
toast.success("رسید سفارش ثبت شد و به انبارها وارد شد ✓");
toast.success("رسید (کامل یا جزیی) ثبت شد و به انبارها وارد شد ✓ — اقلام باقی‌مانده همچنان در راه هستند");
setDrafts((d) => {
const next = { ...d };
its.forEach((it) => delete next[it.item_decision_id]);
submittable.forEach((it) => delete next[it.item_decision_id]);
return next;
});
setExpanded((s) => {
@@ -198,15 +227,20 @@ export function ArrivalsPage() {
<div className="min-h-screen">
<Header
title="کالاهای در راه"
sub={isPM ? user?.project_name || "" : "همه پروژه‌ها — مدیر تدارکات"}
menu={isAdmin ? <PanelMenu title="پنل مدیریت انبار" items={accountMenuItems(logout)} /> : undefined}
actions={isAdmin || isPM ? <InventoryTopNav pathname={location.pathname} onLogout={logout} variant={isAdmin ? "admin" : "pm"} /> : undefined}
sub={inventorySubheader(user)}
backTo="/inventory"
backLabel="بازگشت به داشبورد"
icon={<WarehouseIcon className="h-4.5 w-4.5" strokeWidth={2} />}
menu={<PanelMenu title="پنل مدیریت انبار" items={isAdmin ? accountMenuItems(logout) : pmMenuItems(logout)} />}
actions={<InventoryTopNav pathname={location.pathname} variant={isAdmin ? "admin" : "pm"} />}
/>
<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">
🚚 این بخش به مرحله «رسید» سفارش‌های خرید متصل است: تأیید پیشنهاد و پذیرش در سیستم تدارکات انجام شده و
اینجا فقط رسید و تقسیم بین انبارها ثبت می‌شود. هر سفارش را باز کنید، تعداد واقعیِ رسیده هر قلم را وارد کنید
و آن را بین انبارهای پروژه تقسیم کنید — تا «مانده تخصیص» به صفر برسد.
اینجا فقط رسید و تقسیم بین انبارها ثبت می‌شود. <b>رسید جزیی (قلمی و مقداری) مجاز است</b> — هر سفارش را باز
کنید، تعداد واقعیِ رسیده هر قلم را وارد کنید و آن را بین انبارهای پروژه تقسیم کنید؛ قلم‌هایی که مقداری برایشان
وارد نشود در صف «در راه» می‌مانند. مجموع تخصیص هر قلم باید دقیقاً با تعداد رسیده‌ی همان قلم برابر باشد و از
مانده‌ی سفارش بیشتر نشود.
</div>
{loading ? (
@@ -217,7 +251,9 @@ export function ArrivalsPage() {
orders.map(([quoteId, its]) => {
const first = its[0];
const open = expanded.has(quoteId);
const ready = orderReady(quoteId);
const hasSubmittable = orderHasSubmittable(quoteId);
const hasErrors = orderHasErrors(quoteId);
const anyPartial = its.some((it) => it.state === "partially_received" || draftReceivedOf(it) > 0);
const days = orderDaysElapsed(its);
return (
<div key={quoteId} className="overflow-hidden rounded-xl border border-line bg-panel shadow-sm">
@@ -227,8 +263,8 @@ export function ArrivalsPage() {
<div className="flex flex-wrap items-center gap-2">
<span className="text-[14px] font-bold">🧾 {first.supplier.name}</span>
<Badge variant="outline">{faNum(its.length)} قلم</Badge>
<Badge variant={first.state === "partially_received" ? "offered" : "outline"}>
{first.state === "partially_received" ? "رسید ناقص" : "در راه"}
<Badge variant={anyPartial ? "offered" : "outline"}>
{anyPartial ? "رسید جزیی" : "در راه"}
</Badge>
{days !== null && (
<Badge variant={days >= 7 ? "offered" : "muted"}>
@@ -251,7 +287,12 @@ export function ArrivalsPage() {
<div className="divide-y divide-line/60">
{its.map((it) => {
const d = draftOf(it);
const remaining = remainingOf(it);
const remaining = remainingToReceive(it);
const draftReceived = draftReceivedOf(it);
const inTransit = round4(remaining - draftReceived);
const allocSum = allocSumOf(it);
const errs = itemHasErrors(it);
const submittable = itemSubmittable(it);
const whs = whsOfProject(it.project?.id);
const itemDays = daysElapsed(it.decided_at);
return (
@@ -261,9 +302,13 @@ export function ArrivalsPage() {
<Badge variant="outline">
سفارش: {faNum(parseFloat(it.ordered_qty))} {it.unit?.name_fa || it.unit?.code || ""}
</Badge>
<Badge variant={parseFloat(it.received_qty) > 0 ? "offered" : "outline"}>
رسیده: {faNum(parseFloat(it.received_qty))}
<Badge variant={parseFloat(it.received_qty) > 0 || draftReceived > 0 ? "offered" : "outline"}>
رسیده: {faNum(parseFloat(it.received_qty) + draftReceived)} / {faNum(parseFloat(it.ordered_qty))}
</Badge>
<Badge variant={inTransit > 0 ? "muted" : "outline"}>
در راه: {faNum(Math.max(0, inTransit))}
</Badge>
{it.state === "partially_received" && <Badge variant="offered">رسید ناقص</Badge>}
{itemDays !== null && (
<span className="text-[11px] text-muted">روز گذشته: <b className="text-foreground">{faNum(itemDays)}</b></span>
)}
@@ -278,7 +323,7 @@ export function ArrivalsPage() {
className="h-8 w-full rounded-lg border border-line bg-panel px-2 text-sm ltr"
value={d.received}
onChange={(e) => setReceived(it.item_decision_id, e.target.value)}
placeholder={it.ordered_qty}
placeholder={String(remaining)}
/>
</div>
<div className="field mb-0">
@@ -326,17 +371,22 @@ export function ArrivalsPage() {
</div>
<div className="mt-2 flex flex-wrap items-center gap-2 text-[12px]">
<span>
مانده تخصیص:{" "}
<b className={remaining === 0 ? "text-green" : "text-red"}>
{faNum(remaining)} {it.unit?.name_fa || it.unit?.code || ""}
</b>
<span className={submittable ? "text-green" : "text-muted"}>
{draftReceived > 0 ? (submittable ? "✓ آماده‌ی ثبت" : "در حال تکمیل…") : "بدون مقدار — در راه می‌ماند"}
</span>
{parseFloat(d.received) > parseFloat(it.ordered_qty) && (
<span className="text-[11px] font-bold text-red">تعداد کالای بیشتری نسبت به کالای رسیده انتخاب کردید</span>
{errs.ceiling && (
<span className="text-[11px] font-bold text-red">
تعداد رسیده نمی‌تواند از مانده‌ی سفارش ({faNum(remaining)}) بیشتر باشد
</span>
)}
{errs.sumMismatch && (
<span className="text-[11px] font-bold text-red">
مجموع تخصیص انبارها ({faNum(allocSum)}) باید با تعداد رسیده ({faNum(draftReceived)}) برابر باشد
</span>
)}
{errs.emptyWh && (
<span className="text-[11px] font-bold text-red">برای ردیف‌های دارای تعداد، انبار را انتخاب کنید</span>
)}
{remaining < 0 && <span className="text-[11px] text-red">تخصیص بیش از تعداد رسیده است!</span>}
{remaining > 0 && <span className="text-[11px] text-muted">با ردیف‌های تخصیص، مانده را به صفر برسانید.</span>}
</div>
</div>
);
@@ -344,9 +394,9 @@ export function ArrivalsPage() {
<div className="flex items-center justify-between gap-2 bg-panel px-4 py-3">
<span className="text-[11.5px] text-muted">
برای ثبت رسید کل سفارش، همه اقلام باید «مانده تخصیص صفر» داشته باشند.
رسید جزیی مجاز است: حداقل یک قلم با تعداد معتبر کافی است؛ قلم‌های بدون مقدار در «در راه» می‌مانند.
</span>
<Button size="sm" variant="green" disabled={!ready || saving === quoteId} onClick={() => void submitOrder(quoteId)}>
<Button size="sm" variant="green" disabled={!hasSubmittable || hasErrors || saving === quoteId} onClick={() => void submitOrder(quoteId)}>
{saving === quoteId ? "در حال ثبت…" : "✓ ثبت رسید و ورود به انبار"}
</Button>
</div>
@@ -1,10 +1,11 @@
import { useEffect, useMemo, useState } from "react";
import { useLocation, useSearchParams } from "react-router-dom";
import { Warehouse as WarehouseIcon } from "lucide-react";
import { toast } from "sonner";
import { Header, PageLayout } from "../../components/layout/Header";
import { useAuthStore } from "../../store/auth";
import { PanelMenu } from "../../components/PanelMenu";
import { panelMenuItems, useLogout } from "./panelMenu";
import { inventorySubheader, panelMenuItems, pmMenuItems, useLogout } from "./panelMenu";
import { InventoryTopNav } from "./InventoryTopNav";
import { apiV26 } from "../../lib/api";
import { useInventoryData, unitName } from "./useInventoryData";
@@ -322,8 +323,12 @@ export function ChangeInventoryPage() {
<div className="min-h-screen">
<Header
title="تغییر موجودی"
sub={user?.project_name || ""}
actions={<InventoryTopNav pathname={location.pathname} onLogout={logout} />}
sub={inventorySubheader(user)}
backTo="/inventory"
backLabel="بازگشت به داشبورد"
icon={<WarehouseIcon className="h-4.5 w-4.5" strokeWidth={2} />}
menu={<PanelMenu title="پنل مدیریت انبار" items={pmMenuItems(logout)} />}
actions={<InventoryTopNav pathname={location.pathname} variant="pm" />}
/>
<PageLayout className="flex flex-col gap-3">
{/* انتخاب نوع تراکنش */}
@@ -1,13 +1,14 @@
import { useMemo, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { Warehouse as WarehouseIcon } from "lucide-react";
import { Header, PageLayout } from "../../components/layout/Header";
import { useAuthStore } from "../../store/auth";
import { PanelMenu } from "../../components/PanelMenu";
import { accountMenuItems, useLogout } from "./panelMenu";
import { accountMenuItems, inventorySubheader, pmMenuItems, useLogout } from "./panelMenu";
import { InventoryTopNav } from "./InventoryTopNav";
import { useInventoryData, unitName, whName } from "./useInventoryData";
import { faNum } from "../../lib/formatters";
import { gregorianToJalali, MONTHS, toFa } from "../../lib/jalali";
import { gregorianToJalali, jalaliToGregorian, MONTHS, toFa } from "../../lib/jalali";
import type { InventoryTransaction } from "../../lib/types";
function gregKey(d: Date): string {
@@ -83,6 +84,21 @@ export function DailyControlPage() {
const [ty, tm] = gregorianToJalali(anchor.getFullYear(), anchor.getMonth() + 1, anchor.getDate());
const monthTitle = `${toFa(ty)} ${MONTHS[tm - 1]}`;
// جابه‌جایی بین ماه‌های شمسی (نه ±۳۳ روز) — ماه قبل/بعد با سال‌شماری درست
const shiftMonth = (delta: number) => {
let nm = tm + delta;
let ny = ty;
if (nm < 1) {
nm = 12;
ny -= 1;
} else if (nm > 12) {
nm = 1;
ny += 1;
}
const [gy, gm] = jalaliToGregorian(ny, nm, 1);
setAnchor(new Date(gy, gm - 1, 1));
};
const dayTx = txByDay.get(sel) || [];
const withdrawals = dayTx.filter((t) => t.transaction_type === "WITHDRAWAL");
const wGroups = useMemo(() => {
@@ -101,14 +117,18 @@ export function DailyControlPage() {
const isToday = sel === todayKey();
const selDate = new Date(Number(sel.split("-")[0]), Number(sel.split("-")[1]) - 1, Number(sel.split("-")[2]));
const [sy, sm, sd] = gregorianToJalali(selDate.getFullYear(), selDate.getMonth() + 1, selDate.getDate());
return (
<div className="min-h-screen">
<Header
title="کنترل روزانه"
sub={user?.project_name || ""}
menu={isAdmin ? <PanelMenu title="پنل مدیریت انبار" items={accountMenuItems(logout)} /> : undefined}
actions={isAdmin || isPM ? <InventoryTopNav pathname={location.pathname} onLogout={logout} variant={isAdmin ? "admin" : "pm"} /> : undefined}
sub={inventorySubheader(user)}
backTo="/inventory"
backLabel="بازگشت به داشبورد"
icon={<WarehouseIcon className="h-4.5 w-4.5" strokeWidth={2} />}
menu={<PanelMenu title="پنل مدیریت انبار" items={isAdmin ? accountMenuItems(logout) : pmMenuItems(logout)} />}
actions={<InventoryTopNav pathname={location.pathname} variant={isAdmin ? "admin" : "pm"} />}
/>
<PageLayout className="flex flex-col gap-3">
{isAdmin && (
@@ -137,9 +157,9 @@ export function DailyControlPage() {
<div className="mb-2 flex items-center justify-between">
<div className="text-[14px] font-bold">📅 {monthTitle}</div>
<div className="flex gap-1.5">
<button className="rounded-lg border border-line px-3 py-1 text-[12px]" onClick={() => setAnchor(new Date(anchor.getTime() - 33 * 86400000))}>▶ ماه قبل</button>
<button className="rounded-lg border border-line px-3 py-1 text-[12px]" onClick={() => shiftMonth(-1)}>▶ ماه قبل</button>
<button className="rounded-lg border border-line px-3 py-1 text-[12px]" onClick={() => { setAnchor(new Date()); setSel(todayKey()); setActRec(null); }}>امروز</button>
<button className="rounded-lg border border-line px-3 py-1 text-[12px]" onClick={() => setAnchor(new Date(anchor.getTime() + 33 * 86400000))}>◀ ماه بعد</button>
<button className="rounded-lg border border-line px-3 py-1 text-[12px]" onClick={() => shiftMonth(1)}>◀ ماه بعد</button>
</div>
</div>
<div className="grid grid-cols-7 gap-1">
@@ -147,14 +167,15 @@ export function DailyControlPage() {
<div key={d} className="py-1 text-center text-[11px] font-bold text-muted">{d}</div>
))}
{cells.map((c, i) => {
const [jd] = gregorianToJalali(c.d.getFullYear(), c.d.getMonth() + 1, c.d.getDate());
// gregorianToJalali → [سال، ماه، روز] — روزِ ماه در خانه‌ی تقویم
const [, , jd] = gregorianToJalali(c.d.getFullYear(), c.d.getMonth() + 1, c.d.getDate());
const k = gregKey(c.d);
return (
<button
key={i}
type="button"
onClick={() => { setSel(k); setActRec(null); }}
className={`relative rounded-lg py-2 text-[12px] ${!c.inMonth ? "text-muted/40" : ""} ${c.isToday ? "bg-accent/15 font-bold text-accent" : ""} ${sel === k ? "ring-2 ring-accent" : "hover:bg-accent/5"}`}
className={`relative rounded-lg py-2 text-[12px] ${!c.inMonth ? "text-muted/40" : ""} ${c.isToday ? "bg-accent font-bold text-white" : ""} ${sel === k ? "ring-2 ring-accent" : "hover:bg-accent/5"}`}
>
{toFa(jd)}
{c.n > 0 && (
@@ -171,7 +192,7 @@ export function DailyControlPage() {
{/* برداشت‌های روز */}
<div className="rounded-xl border border-line bg-panel p-4">
<div className="mb-1 flex items-center justify-between">
<h2 className="text-[14px] font-bold">🕘 کنترل روزانه — {isToday ? "امروز" : ""} {faNum(selDate.getDate())} {MONTHS[selDate.getMonth()]} {toFa(selDate.getFullYear())}</h2>
<h2 className="text-[14px] font-bold">🕘 کنترل روزانه — {isToday ? "امروز " : ""}{toFa(sd)} {MONTHS[sm - 1]} {toFa(sy)}</h2>
<span className="rounded-lg bg-blue/10 px-2 py-1 text-[11.5px] font-bold text-blue">▼ برداشت‌ها: {faNum(wGroups.length)} قلم</span>
</div>
<p className="mb-3 text-[11.5px] leading-5 text-muted">
+25 -5
View File
@@ -1,11 +1,12 @@
import { useMemo, useState } from "react";
import { useLocation, useSearchParams } from "react-router-dom";
import { Link, useLocation, useSearchParams } from "react-router-dom";
import { Plus, Warehouse as WarehouseIcon } from "lucide-react";
import { Header, PageLayout } from "../../components/layout/Header";
import { EmptyState } from "../../components/EmptyState";
import { Badge } from "../../components/ui/badge";
import { useAuthStore } from "../../store/auth";
import { PanelMenu } from "../../components/PanelMenu";
import { panelMenuItems, accountMenuItems, useLogout } from "./panelMenu";
import { accountMenuItems, inventorySubheader, panelMenuItems, pmMenuItems, useLogout } from "./panelMenu";
import { InventoryTopNav } from "./InventoryTopNav";
import { useInventoryData, brandName, catName, materialName, unitName, whName } from "./useInventoryData";
import type { InventoryRow, InventoryTransaction, TransactionType } from "../../lib/types";
@@ -137,9 +138,28 @@ export function InventoryPage() {
<div className="min-h-screen">
<Header
title="سامانه مدیریت انبار"
sub={isAdmin ? "مدیر تدارکات — همه پروژه‌ها" : user?.project_name ? `مدیر پروژه — ${user.project_name}` : "مدیر پروژه"}
menu={isAdmin ? <PanelMenu title="پنل مدیریت انبار" items={accountMenuItems(logout)} /> : undefined}
actions={isAdmin || isPM ? <InventoryTopNav pathname={location.pathname} onLogout={logout} variant={isAdmin ? "admin" : "pm"} /> : undefined}
sub={inventorySubheader(user)}
icon={<WarehouseIcon className="h-4.5 w-4.5" strokeWidth={2} />}
menu={
<PanelMenu
title="پنل مدیریت انبار"
items={isAdmin ? accountMenuItems(logout) : pmMenuItems(logout)}
/>
}
actions={
<>
<InventoryTopNav pathname={location.pathname} variant={isAdmin ? "admin" : "pm"} />
{isPM && (
<Link
to="/inventory/change?type=PURCHASE"
className="btn-line-file no-underline bg-accent text-white hover:bg-accent-dark"
>
<Plus className="h-3.5 w-3.5" />
افزایش موجود
</Link>
)}
</>
}
/>
<PageLayout className="flex flex-col gap-3">
{/* فیلترها */}
@@ -4,17 +4,16 @@ import { cn } from "../../lib/utils";
// ناوبری بالای صفحه در پنل انبار — جایگزین لینک‌های منوی همبرگری
// (مثل سیستم تدارکات: لینک‌ها در بالای صفحه)
//
// - variant="pm": همه بخش‌های مدیر پروژه + «دفتر تدارکات» و «خروج از حساب»
// (منوی همبرگری حذف می‌شود).
// - variant="admin": فقط بخش‌های مدیر تدارکات/ادمین — «بازگشت به دفتر» و
// «خروج از حساب» در منوی همبرگری می‌مانند.
// - variant="pm" (مدیر پروژه): همه بخش‌های مدیر پروژه — بدون «دفتر تدارکات»
// (مدیر پروژه به سیستم تدارکات دسترسی ندارد) و بدون دکمه‌ی خروج تکی
// (خروج از حساب فقط در منوی همبرگری است).
// - variant="admin" (مدیر تدارکات/ادمین): فقط بخش‌های مدیر تدارکات —
// «بازگشت به دفتر» و «خروج از حساب» در منوی همبرگری می‌مانند.
export function InventoryTopNav({
pathname,
onLogout,
variant = "pm",
}: {
pathname: string;
onLogout: () => void;
variant?: "pm" | "admin";
}) {
const items =
@@ -46,16 +45,6 @@ export function InventoryTopNav({
{it.label}
</Link>
))}
{variant === "pm" && (
<>
<Link to="/" className="btn-line-file no-underline">
دفتر تدارکات
</Link>
<button type="button" onClick={onLogout} className="btn-line-file cursor-pointer border-red/30 text-red hover:bg-red/10">
خروج از حساب
</button>
</>
)}
</>
);
}
@@ -1,10 +1,11 @@
import { useCallback, useEffect, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { Warehouse as WarehouseIcon } from "lucide-react";
import { toast } from "sonner";
import { Header, PageLayout } from "../../components/layout/Header";
import { useAuthStore } from "../../store/auth";
import { PanelMenu } from "../../components/PanelMenu";
import { panelMenuItems, accountMenuItems, useLogout } from "./panelMenu";
import { accountMenuItems, inventorySubheader, useLogout } from "./panelMenu";
import { InventoryTopNav } from "./InventoryTopNav";
import { apiV26, type UserRow } from "../../lib/api";
import { useInventoryData } from "./useInventoryData";
@@ -28,18 +29,16 @@ export function OrganizationPage() {
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 [newUserFirst, setNewUserFirst] = useState("");
const [newUserLast, setNewUserLast] = useState("");
const [newUserCasdoor, setNewUserCasdoor] = useState("");
const [newUserRole, setNewUserRole] = useState<"pm" | "procurement_manager">("pm");
const canManage = user?.role === "admin" || user?.role === "procurement_manager";
// فقط ادمین می‌تواند نقش‌ها را تغییر دهد/ارتقا دهد (RBAC) — مدیر تدارکات فقط
// پروژه‌ی مدیران پروژه را تعیین/جابه‌جا می‌کند.
const canChangeRoles = user?.role === "admin";
const loadUsers = useCallback(async () => {
try {
@@ -56,10 +55,28 @@ export function OrganizationPage() {
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="سازمان‌دهی" menu={<PanelMenu title="پنل مدیریت انبار" items={panelMenuItems(location.pathname, user?.role, logout)} />} />
<Header title="سازمان‌دهی" menu={<PanelMenu title="پنل مدیریت انبار" items={accountMenuItems(logout)} />} />
<PageLayout>
<div className="text-[13px] text-muted">این بخش فقط برای ادمین / مدیر تدارکات است.</div>
</PageLayout>
@@ -69,26 +86,6 @@ export function OrganizationPage() {
// ---------- actions: users ----------
const addUser = async () => {
const first = newUserFirst.trim();
if (!first) return toast.error("نام کاربر الزامی است");
try {
await apiV26.users.create({
first_name: first,
last_name: newUserLast.trim() || "",
casdoor_id: newUserCasdoor.trim() || null,
role: newUserRole,
});
toast.success(`کاربر «${first}» تعریف شد ✓`);
setNewUserFirst("");
setNewUserLast("");
setNewUserCasdoor("");
await loadUsers();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در تعریف کاربر");
}
};
const changeRole = async (u: UserRow, role: Role) => {
setBusyId(u.id);
try {
@@ -160,62 +157,30 @@ export function OrganizationPage() {
<div className="min-h-screen">
<Header
title="سازمان‌دهی"
sub="ادمین / مدیر تدارکات"
sub={inventorySubheader(user)}
backTo="/inventory"
backLabel="بازگشت به داشبورد"
icon={<WarehouseIcon className="h-4.5 w-4.5" strokeWidth={2} />}
menu={<PanelMenu title="پنل مدیریت انبار" items={accountMenuItems(logout)} />}
actions={<InventoryTopNav pathname={location.pathname} onLogout={logout} variant="admin" />}
actions={<InventoryTopNav pathname={location.pathname} variant="admin" />}
/>
<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">
<h3 className="mb-2 text-[14px] font-bold">👥 کاربران و مدیران پروژه</h3>
{/* تعریف کاربر جدید */}
<div className="mb-3 flex flex-wrap items-end gap-2 rounded-lg border border-dashed border-accent/40 bg-accent/5 p-3">
<div className="field mb-0">
<label>نام *</label>
<input
className="h-8 w-36 rounded-lg border border-line bg-panel px-2 text-sm"
placeholder="مثلاً رضایی"
value={newUserFirst}
onChange={(e) => setNewUserFirst(e.target.value)}
/>
</div>
<div className="field mb-0">
<label>نام خانوادگی</label>
<input
className="h-8 w-36 rounded-lg border border-line bg-panel px-2 text-sm"
placeholder="(اختیاری)"
value={newUserLast}
onChange={(e) => setNewUserLast(e.target.value)}
/>
</div>
<div className="field mb-0">
<label>نقش</label>
<select
className="h-8 rounded-lg border border-line bg-panel px-2 text-sm"
value={newUserRole ?? ""}
onChange={(e) => setNewUserRole(e.target.value as "pm" | "procurement_manager")}
>
<option value="pm">مدیر پروژه</option>
<option value="procurement_manager">مدیر تدارکات</option>
</select>
</div>
<div className="field mb-0">
<label>شناسه Casdoor</label>
<input
className="h-8 w-44 rounded-lg border border-line bg-panel px-2 text-sm ltr"
placeholder="(اختیاری — برای کاربر واقعی)"
value={newUserCasdoor}
onChange={(e) => setNewUserCasdoor(e.target.value)}
/>
</div>
<button className="rounded-lg bg-accent px-3 py-1.5 text-[12px] font-bold text-white" onClick={() => void addUser()}>
+ تعریف کاربر
<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>
@@ -224,7 +189,7 @@ export function OrganizationPage() {
) : !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">
@@ -245,17 +210,26 @@ export function OrganizationPage() {
<small className="block text-[10.5px] text-muted ltr">{u.casdoor_id}</small>
</td>
<td className="py-1.5 pe-3">
<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>
{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="py-1.5 pe-3">
{u.role === "pm" ? (
@@ -1,10 +1,11 @@
import { useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { Warehouse as WarehouseIcon } from "lucide-react";
import { toast } from "sonner";
import { Header, PageLayout } from "../../components/layout/Header";
import { useAuthStore } from "../../store/auth";
import { PanelMenu } from "../../components/PanelMenu";
import { panelMenuItems, useLogout } from "./panelMenu";
import { inventorySubheader, panelMenuItems, pmMenuItems, useLogout } from "./panelMenu";
import { InventoryTopNav } from "./InventoryTopNav";
import { apiV26 } from "../../lib/api";
import { useInventoryData, projName } from "./useInventoryData";
@@ -98,8 +99,12 @@ export function WarehousesPage() {
<div className="min-h-screen">
<Header
title="انبارها"
sub={user?.project_name || ""}
actions={<InventoryTopNav pathname={location.pathname} onLogout={logout} />}
sub={inventorySubheader(user)}
backTo="/inventory"
backLabel="بازگشت به داشبورد"
icon={<WarehouseIcon className="h-4.5 w-4.5" strokeWidth={2} />}
menu={<PanelMenu title="پنل مدیریت انبار" items={pmMenuItems(logout)} />}
actions={<InventoryTopNav pathname={location.pathname} variant="pm" />}
/>
<PageLayout className="flex flex-col gap-3">
{isPM && (
+31 -2
View File
@@ -9,6 +9,23 @@ export function useLogout() {
};
}
const ROLE_LABELS: Record<string, string> = {
pm: "مدیر پروژه",
procurement_manager: "مدیر تدارکات",
admin: "ادمین",
};
// زیرعنوان پویای سربرگ پنل انبار:
// [نام کاربر] | [نقش] – [پروژه / همه پروژه‌ها]
// مدیر پروژه → نام پروژه‌ی خودش؛ مدیر تدارکات/ادمین → «همه پروژه‌ها».
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 {
if (!user) return "در حال بارگذاری…";
const name = user.display_name || `${user.first_name} ${user.last_name}`.trim() || "کاربر";
const role = user.role_label || (user.role ? ROLE_LABELS[user.role] : "بدون نقش");
const proj = user.role === "pm" ? user.project_name || "پروژه نامشخص" : "همه پروژه‌ها";
return `${name} | ${role} – ${proj}`;
}
// منوی همبرگریِ مختصر برای مدیر تدارکات/ادمین: بخش‌ها بالای صفحه هستند و
// اینجا فقط «بازگشت به دفتر» و «خروج از حساب» می‌ماند.
export function accountMenuItems(onLogout?: () => void): MenuItem[] {
@@ -20,6 +37,16 @@ export function accountMenuItems(onLogout?: () => void): MenuItem[] {
return items;
}
// منوی همبرگری مدیر پروژه در پنل انبار — فقط «خروج از حساب»
// (مدیر پروژه به دفتر تدارکات دسترسی ندارد و دکمه‌ی خروجِ تکی هم حذف شده).
export function pmMenuItems(onLogout?: () => void): MenuItem[] {
const items: MenuItem[] = [];
if (onLogout) {
items.push({ label: "خروج از حساب", onClick: onLogout });
}
return items;
}
// آیتم‌های منوی همبرگری پنل مدیریت انبار — با نشانگر پنل فعلی + «بازگشت به دفتر» + خروج
//
// دسترسی‌ها (v2.6):
@@ -44,8 +71,10 @@ export function panelMenuItems(pathname: string, role: string | null | undefined
if (isAdmin) {
items.push({ label: "سازمان‌دهی", to: "/inventory/org", active: pathname.startsWith("/inventory/org") });
}
items.push({ divider: true });
items.push({ label: "بازگشت به دفتر", to: "/" });
if (isAdmin) {
items.push({ divider: true });
items.push({ label: "بازگشت به دفتر", to: "/" });
}
if (onLogout) {
items.push({ divider: true });
items.push({ label: "خروج از حساب", onClick: onLogout });