From c5997ac243b58aafb91d78254140b24fc54ba7ad Mon Sep 17 00:00:00 2001 From: ali_mohaghegh Date: Tue, 1 Sep 2026 17:28:19 +0330 Subject: [PATCH] feat(frontend): unified warehouse UI, calendar fix, partial receipts, RBAC views, admin panel, dev-mode 3-step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- frontend/src/App.tsx | 11 ++ frontend/src/components/layout/Header.tsx | 15 +- frontend/src/lib/api.ts | 28 ++- frontend/src/lib/types.ts | 1 + frontend/src/pages/Admin/AdminPanel.tsx | 181 ++++++++++++++++++ frontend/src/pages/Auth/LoginPage.tsx | 174 +++++++++++++---- frontend/src/pages/Dashboard/QuoteCard.tsx | 22 ++- frontend/src/pages/Dashboard/QuoteDetail.tsx | 6 + frontend/src/pages/Inventory/ArrivalsPage.tsx | 140 +++++++++----- .../pages/Inventory/ChangeInventoryPage.tsx | 11 +- .../src/pages/Inventory/DailyControlPage.tsx | 41 +++- .../src/pages/Inventory/InventoryPage.tsx | 30 ++- .../src/pages/Inventory/InventoryTopNav.tsx | 21 +- .../src/pages/Inventory/OrganizationPage.tsx | 148 ++++++-------- .../src/pages/Inventory/WarehousesPage.tsx | 11 +- frontend/src/pages/Inventory/panelMenu.ts | 33 +++- frontend/src/styles/globals.css | 3 + 17 files changed, 648 insertions(+), 228 deletions(-) create mode 100644 frontend/src/pages/Admin/AdminPanel.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 054acb0..54267f1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -38,6 +38,9 @@ const WarehousesPage = lazy(() => const OrganizationPage = lazy(() => import("./pages/Inventory/OrganizationPage").then((m) => ({ default: m.OrganizationPage })), ); +const AdminPanelPage = lazy(() => + import("./pages/Admin/AdminPanel").then((m) => ({ default: m.AdminPanel })), +); function Loading() { return ( @@ -178,6 +181,14 @@ export default function App() { } /> + + + + } + /> } /> diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx index bbda9b4..54c7a29 100644 --- a/frontend/src/components/layout/Header.tsx +++ b/frontend/src/components/layout/Header.tsx @@ -10,11 +10,12 @@ interface HeaderProps { backLabel?: string; actions?: ReactNode; // دکمه‌های سمت چپ brand?: boolean; // نمایش آیکون دفتر کنار عنوان + icon?: ReactNode; // آیکون سفارشی جعبه‌ی کنار منو (پیش‌فرض: ClipboardList) menu?: ReactNode; // منوی همبرگری بالای راست (ناوبری بین پنل‌ها) } -// نوار بالای هر صفحه: منو (همبرگری) + دکمه‌ی برگشت (یک مرحله‌ی قبل) + عنوان + اکشن‌ها -export function Header({ title, sub, backTo, backLabel = "بازگشت", actions, brand, menu }: HeaderProps) { +// نوار بالای هر صفحه: منو (همبرگری) + جعبه‌ی آیکون + عنوان + اکشن‌ها +export function Header({ title, sub, backTo, backLabel = "بازگشت", actions, brand, icon, menu }: HeaderProps) { const navigate = useNavigate(); const location = useLocation(); @@ -28,6 +29,11 @@ export function Header({ title, sub, backTo, backLabel = "بازگشت", actions return (
{menu &&
{menu}
} + {(icon || brand) && ( + + {icon || } + + )} {backTo && ( + } + /> + +
+ 🛡️ وظیفه‌ی اصلی ادمین، مدیریت مدیران تدارکات است: با دکمه‌ی هر کاربر می‌توانید نقش + «مدیر تدارکات» را اعطا یا لغو کنید. تخصیص پروژه به مدیران پروژه در بخش «سازمان‌دهی» انجام می‌شود + (این پنل فقط نقش‌ها را مدیریت می‌کند). +
+ + {loading ? ( +
در حال بارگذاری کاربران…
+ ) : !users.length ? ( + + ) : ( +
+ + + + + + + + + + + {users.map((u) => { + const isPMgr = u.role === "procurement_manager"; + return ( + + + + + + + ); + })} + +
نام کاملشناسه Casdoorنقش فعلیمدیریت مدیر تدارکات
+ {u.first_name} {u.last_name} + {u.casdoor_id} + + {u.role ? ROLE_LABELS[u.role] : "بدون نقش"} + + + +
+
+ {faNum(users.length)} کاربر — نقش‌ها فقط توسط ادمین قابل تغییرند (RBAC). +
+
+ )} + + +
+ + ); +} diff --git a/frontend/src/pages/Auth/LoginPage.tsx b/frontend/src/pages/Auth/LoginPage.tsx index 2274dd5..9331c9a 100644 --- a/frontend/src/pages/Auth/LoginPage.tsx +++ b/frontend/src/pages/Auth/LoginPage.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { toast } from "sonner"; -import { ClipboardList, Loader2, Wrench, X } from "lucide-react"; +import { ClipboardList, Loader2, Lock, ShieldCheck, Wrench, X } from "lucide-react"; import { api, apiV26 } from "../../lib/api"; import { useAuthStore } from "../../store/auth"; import type { Project } from "../../lib/types"; @@ -11,7 +11,11 @@ import type { Project } from "../../lib/types"; // فرم اصلی: Username / Password / Organization (اختیاری) → // POST /api/v1/auth/login (بک‌اند با grant_type=password از // Casdoor توکن می‌گیرد و نقش را از جدول users خودمان برمی‌گرداند) -// حالت توسعه: دکمه‌ی ثابت بالا-چپ + پنل تست (نقش/پروژه). +// حالت توسعه (فقط ادمین — سوپر یوزر) — چرخه‌ی ۳ مرحله‌ای: +// مرحله ۱: درگاه احراز هویت ادمین (نام کاربری/رمز — فقط ادمین رد می‌شود) +// مرحله ۲: هاب انتخاب نقش (ادمین / مدیر تدارکات / مدیر پروژه + پروژه) +// مرحله ۳: ورود با نقش انتخاب‌شده — ادمین → پنل مدیریت ادمین، +// مدیر تدارکات → دفتر، مدیر پروژه → پنل انبار پروژه‌ی خودش // با VITE_ENABLE_DEV_LOGIN=false غیرفعال می‌شود. // ============================================================ @@ -35,8 +39,12 @@ export function LoginPage() { const [organization, setOrganization] = useState(""); const [loading, setLoading] = useState(false); - // ---- پنل حالت توسعه ---- + // ---- پنل حالت توسعه (۳ مرحله) ---- const [devOpen, setDevOpen] = useState(false); + const [devStep, setDevStep] = useState<"auth" | "hub">("auth"); + const [devAdminUser, setDevAdminUser] = useState(""); + const [devAdminPass, setDevAdminPass] = useState(""); + const [devAuthLoading, setDevAuthLoading] = useState(false); const [projects, setProjects] = useState([]); const [devRole, setDevRole] = useState<"pm" | "procurement_manager" | "admin">("procurement_manager"); const [devProjectId, setDevProjectId] = useState(""); @@ -81,6 +89,33 @@ export function LoginPage() { } }; + // ---- مرحله ۱ حالت توسعه: درگاه ادمین ---- + const devAuth = async (e: React.FormEvent) => { + e.preventDefault(); + if (!devAdminUser.trim() || !devAdminPass) { + toast.error("نام کاربری و رمز عبور ادمین را وارد کنید"); + return; + } + setDevAuthLoading(true); + try { + const { user: adminUser } = await apiV26.auth.login({ + username: devAdminUser.trim(), + password: devAdminPass, + ...(organization.trim() ? { organization: organization.trim() } : {}), + }); + if (adminUser.role !== "admin") { + toast.error("حالت توسعه فقط برای ادمین (سوپر یوزر) در دسترس است"); + return; + } + setDevStep("hub"); + } catch (err) { + toast.error(err instanceof Error ? err.message : "خطا در ورود ادمین"); + } finally { + setDevAuthLoading(false); + } + }; + + // ---- مرحله ۳ حالت توسعه: ورود با نقش انتخاب‌شده ---- const devLogin = () => { if (devRole === "pm" && !devProjectId) { toast.error("برای نقش مدیر پروژه، پروژه را انتخاب کنید"); @@ -88,7 +123,17 @@ export function LoginPage() { } setDevUser(devRole, devRole === "pm" ? devProjectId : null, devRole === "pm" ? devProject?.name || null : null); toast.success(`ورود آزمایشی انجام شد — نقش: ${DEV_ROLE_LABELS[devRole]}`); - navigate(devRole === "pm" ? "/inventory" : "/"); + // ادمین → پنل مدیریت ادمین؛ مدیر تدارکات → دفتر؛ مدیر پروژه → پنل انبار + if (devRole === "admin") navigate("/admin"); + else if (devRole === "pm") navigate("/inventory"); + else navigate("/"); + }; + + const closeDev = () => { + setDevOpen(false); + setDevStep("auth"); + setDevAdminUser(""); + setDevAdminPass(""); }; return ( @@ -113,55 +158,106 @@ export function LoginPage() { {devOpen && (
-

پنل توسعه (ورود آزمایشی)

+

+ {devStep === "auth" ? "مرحله ۱ — درگاه ادمین (سوپر یوزر)" : "مرحله ۲ — انتخاب نقش"} +

-
-
- - -
- {devRole === "pm" && ( + + {devStep === "auth" ? ( +
+

+ ورود به حالت توسعه فقط با حساب ادمین (سوپر یوزر) ممکن است — سپس نقش دلخواه را + برای تست انتخاب کنید. +

- + + setDevAdminUser(e.target.value)} + /> +
+
+ + setDevAdminPass(e.target.value)} + /> +
+ +
+ ) : ( +
+
+
- )} - -
+ {devRole === "pm" && ( +
+ + +
+ )} + + +
+ )}
)} diff --git a/frontend/src/pages/Dashboard/QuoteCard.tsx b/frontend/src/pages/Dashboard/QuoteCard.tsx index c8587fc..e6a5a51 100644 --- a/frontend/src/pages/Dashboard/QuoteCard.tsx +++ b/frontend/src/pages/Dashboard/QuoteCard.tsx @@ -61,19 +61,24 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit } // ستون رسید — v2.6: تأیید رسید فقط در «پنل مدیریت انبار» توسط مدیر پروژه انجام می‌شود؛ - // اینجا فقط وضعیت نمایش داده می‌شود. + // اینجا فقط وضعیت نمایش داده می‌شود. با رسید جزیی: «رسیده X از Y» + «در راه Z». + const activeItems = (q.line_items || []).filter((li) => !li.rejected); + const receivedSum = activeItems.reduce((s, li) => s + (li.received_qty || 0), 0); + const orderedSum = activeItems.reduce((s, li) => s + (li.quantity || 0), 0); + const partiallyReceived = q.status === "accepted" && receivedSum > 0 && receivedSum < orderedSum; + let arrivedCell: React.ReactNode; if (q.status === "accepted") { if (q.arrived_date) { arrivedCell = {fmtDateTime(q.arrived_date)}; - } else { - const items = (q.line_items || []).filter((li) => !li.rejected); - const arrivedCount = items.filter((li) => li.arrived_date).length; - arrivedCell = arrivedCount ? ( - {fmtNum(arrivedCount)}/{fmtNum(items.length)} - ) : ( - در انتظار PM + } else if (receivedSum > 0) { + arrivedCell = ( + + رسیده {fmtNum(receivedSum)} از {fmtNum(orderedSum)} — در راه {fmtNum(Math.max(0, orderedSum - receivedSum))} + ); + } else { + arrivedCell = در انتظار PM; } } else if (q.arrived_date) { arrivedCell = {fmtDateTime(q.arrived_date)}; @@ -105,6 +110,7 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
{STATUS_LABEL[q.status]} + {partiallyReceived && رسید جزیی}
+ {li.received_qty != null && li.received_qty > 0 && li.quantity != null && ( +
+ + رسیده: {fmtNum(li.received_qty)} از {fmtNum(li.quantity)} — در راه {fmtNum(Math.max(0, li.quantity - li.received_qty))} +
+ )} {conv && (
≈ {groupCommas(conv.qty, true)} {conv.unit} diff --git a/frontend/src/pages/Inventory/ArrivalsPage.tsx b/frontend/src/pages/Inventory/ArrivalsPage.tsx index 6538491..2eb085f 100644 --- a/frontend/src/pages/Inventory/ArrivalsPage.tsx +++ b/frontend/src/pages/Inventory/ArrivalsPage.tsx @@ -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() {
: undefined} - actions={isAdmin || isPM ? : undefined} + sub={inventorySubheader(user)} + backTo="/inventory" + backLabel="بازگشت به داشبورد" + icon={} + menu={} + actions={} />
🚚 این بخش به مرحله «رسید» سفارش‌های خرید متصل است: تأیید پیشنهاد و پذیرش در سیستم تدارکات انجام شده و - اینجا فقط رسید و تقسیم بین انبارها ثبت می‌شود. هر سفارش را باز کنید، تعداد واقعیِ رسیده هر قلم را وارد کنید - و آن را بین انبارهای پروژه تقسیم کنید — تا «مانده تخصیص» به صفر برسد. + اینجا فقط رسید و تقسیم بین انبارها ثبت می‌شود. رسید جزیی (قلمی و مقداری) مجاز است — هر سفارش را باز + کنید، تعداد واقعیِ رسیده هر قلم را وارد کنید و آن را بین انبارهای پروژه تقسیم کنید؛ قلم‌هایی که مقداری برایشان + وارد نشود در صف «در راه» می‌مانند. مجموع تخصیص هر قلم باید دقیقاً با تعداد رسیده‌ی همان قلم برابر باشد و از + مانده‌ی سفارش بیشتر نشود.
{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 (
@@ -227,8 +263,8 @@ export function ArrivalsPage() {
🧾 {first.supplier.name} {faNum(its.length)} قلم - - {first.state === "partially_received" ? "رسید ناقص" : "در راه"} + + {anyPartial ? "رسید جزیی" : "در راه"} {days !== null && ( = 7 ? "offered" : "muted"}> @@ -251,7 +287,12 @@ export function ArrivalsPage() {
{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() { سفارش: {faNum(parseFloat(it.ordered_qty))} {it.unit?.name_fa || it.unit?.code || ""} - 0 ? "offered" : "outline"}> - رسیده: {faNum(parseFloat(it.received_qty))} + 0 || draftReceived > 0 ? "offered" : "outline"}> + رسیده: {faNum(parseFloat(it.received_qty) + draftReceived)} / {faNum(parseFloat(it.ordered_qty))} + 0 ? "muted" : "outline"}> + در راه: {faNum(Math.max(0, inTransit))} + + {it.state === "partially_received" && رسید ناقص} {itemDays !== null && ( روز گذشته: {faNum(itemDays)} )} @@ -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)} />
@@ -326,17 +371,22 @@ export function ArrivalsPage() {
- - مانده تخصیص:{" "} - - {faNum(remaining)} {it.unit?.name_fa || it.unit?.code || ""} - + + {draftReceived > 0 ? (submittable ? "✓ آماده‌ی ثبت" : "در حال تکمیل…") : "بدون مقدار — در راه می‌ماند"} - {parseFloat(d.received) > parseFloat(it.ordered_qty) && ( - تعداد کالای بیشتری نسبت به کالای رسیده انتخاب کردید + {errs.ceiling && ( + + تعداد رسیده نمی‌تواند از مانده‌ی سفارش ({faNum(remaining)}) بیشتر باشد + + )} + {errs.sumMismatch && ( + + مجموع تخصیص انبارها ({faNum(allocSum)}) باید با تعداد رسیده ({faNum(draftReceived)}) برابر باشد + + )} + {errs.emptyWh && ( + برای ردیف‌های دارای تعداد، انبار را انتخاب کنید )} - {remaining < 0 && تخصیص بیش از تعداد رسیده است!} - {remaining > 0 && با ردیف‌های تخصیص، مانده را به صفر برسانید.}
); @@ -344,9 +394,9 @@ export function ArrivalsPage() {
- برای ثبت رسید کل سفارش، همه اقلام باید «مانده تخصیص صفر» داشته باشند. + رسید جزیی مجاز است: حداقل یک قلم با تعداد معتبر کافی است؛ قلم‌های بدون مقدار در «در راه» می‌مانند. -
diff --git a/frontend/src/pages/Inventory/ChangeInventoryPage.tsx b/frontend/src/pages/Inventory/ChangeInventoryPage.tsx index d4e94c5..e7eb12a 100644 --- a/frontend/src/pages/Inventory/ChangeInventoryPage.tsx +++ b/frontend/src/pages/Inventory/ChangeInventoryPage.tsx @@ -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() {
} + sub={inventorySubheader(user)} + backTo="/inventory" + backLabel="بازگشت به داشبورد" + icon={} + menu={} + actions={} /> {/* انتخاب نوع تراکنش */} diff --git a/frontend/src/pages/Inventory/DailyControlPage.tsx b/frontend/src/pages/Inventory/DailyControlPage.tsx index d1e430a..e16c8ff 100644 --- a/frontend/src/pages/Inventory/DailyControlPage.tsx +++ b/frontend/src/pages/Inventory/DailyControlPage.tsx @@ -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 (
: undefined} - actions={isAdmin || isPM ? : undefined} + sub={inventorySubheader(user)} + backTo="/inventory" + backLabel="بازگشت به داشبورد" + icon={} + menu={} + actions={} /> {isAdmin && ( @@ -137,9 +157,9 @@ export function DailyControlPage() {
📅 {monthTitle}
- + - +
@@ -147,14 +167,15 @@ export function DailyControlPage() {
{d}
))} {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 ( - - )} ); } diff --git a/frontend/src/pages/Inventory/OrganizationPage.tsx b/frontend/src/pages/Inventory/OrganizationPage.tsx index 4a702f3..7961237 100644 --- a/frontend/src/pages/Inventory/OrganizationPage.tsx +++ b/frontend/src/pages/Inventory/OrganizationPage.tsx @@ -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([]); const [loadingUsers, setLoadingUsers] = useState(true); + const [syncing, setSyncing] = useState(false); const [busyId, setBusyId] = useState(null); const [newWh, setNewWh] = useState>({}); const [newWhDesc, setNewWhDesc] = useState>({}); - // کاربر جدید (تعریف مدیر پروژه) - 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 (
-
} /> +
} />
این بخش فقط برای ادمین / مدیر تدارکات است.
@@ -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() {
} menu={} - actions={} + actions={} />
- ℹ️ پروژه‌ها از سیستم تدارکات می‌آیند. اینجا مشخص می‌کنید هر پروژه با کدام مدیر و انبار(ها) کار می‌کند؛ + ℹ️ کاربران از Casdoor همگام می‌شوند و نقش‌ها اینجا (فقط توسط ادمین) تعیین می‌شوند؛ + مدیر تدارکات فقط پروژه‌ی مدیران پروژه را تعیین/جابه‌جا می‌کند. پروژه‌ها از سیستم تدارکات می‌آیند و هر مدیر پروژه به همه انبارهای پروژه خودش دسترسی دارد. همه تغییرات فوراً در داشبورد و فرم‌ها اعمال می‌شود.
{/* ---------- کاربران و مدیران پروژه ---------- */}
-

👥 کاربران و مدیران پروژه

- - {/* تعریف کاربر جدید */} -
-
- - setNewUserFirst(e.target.value)} - /> -
-
- - setNewUserLast(e.target.value)} - /> -
-
- - -
-
- - setNewUserCasdoor(e.target.value)} - /> -
-
@@ -224,7 +189,7 @@ export function OrganizationPage() { ) : !users.length ? (
کاربری ثبت نشده است — کاربران پس از اولین ورود (Casdoor) به‌صورت خودکار ساخته می‌شوند؛ - یا از فرم بالا یک مدیر پروژه تعریف کنید. + یا دکمه‌ی «همگام‌سازی از Casdoor» را بزنید.
) : (
@@ -245,17 +210,26 @@ export function OrganizationPage() { {u.casdoor_id} - + {canChangeRoles ? ( + + ) : ( + + {u.role ? ROLE_LABELS[u.role] : "بدون نقش"} + + )} + {!canChangeRoles && u.role && ( + (تغییر نقش فقط توسط ادمین) + )} {u.role === "pm" ? ( diff --git a/frontend/src/pages/Inventory/WarehousesPage.tsx b/frontend/src/pages/Inventory/WarehousesPage.tsx index 0ada359..5e8acbc 100644 --- a/frontend/src/pages/Inventory/WarehousesPage.tsx +++ b/frontend/src/pages/Inventory/WarehousesPage.tsx @@ -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() {
} + sub={inventorySubheader(user)} + backTo="/inventory" + backLabel="بازگشت به داشبورد" + icon={} + menu={} + actions={} /> {isPM && ( diff --git a/frontend/src/pages/Inventory/panelMenu.ts b/frontend/src/pages/Inventory/panelMenu.ts index a2ee630..cde5e6e 100644 --- a/frontend/src/pages/Inventory/panelMenu.ts +++ b/frontend/src/pages/Inventory/panelMenu.ts @@ -9,6 +9,23 @@ export function useLogout() { }; } +const ROLE_LABELS: Record = { + 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 }); diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css index 0daf5e4..e762ace 100644 --- a/frontend/src/styles/globals.css +++ b/frontend/src/styles/globals.css @@ -113,6 +113,9 @@ button:disabled { .fb-conv { @apply mt-0.5 flex items-center gap-1 text-[10.5px] text-muted; } +.fb-receipt { + @apply mt-0.5 flex items-center gap-1 text-[10.5px] font-semibold text-green; +} .fb-inv { @apply mt-0.5 inline-flex items-center gap-1 text-[11px] text-accent underline underline-offset-2; }