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:
@@ -38,6 +38,9 @@ const WarehousesPage = lazy(() =>
|
|||||||
const OrganizationPage = lazy(() =>
|
const OrganizationPage = lazy(() =>
|
||||||
import("./pages/Inventory/OrganizationPage").then((m) => ({ default: m.OrganizationPage })),
|
import("./pages/Inventory/OrganizationPage").then((m) => ({ default: m.OrganizationPage })),
|
||||||
);
|
);
|
||||||
|
const AdminPanelPage = lazy(() =>
|
||||||
|
import("./pages/Admin/AdminPanel").then((m) => ({ default: m.AdminPanel })),
|
||||||
|
);
|
||||||
|
|
||||||
function Loading() {
|
function Loading() {
|
||||||
return (
|
return (
|
||||||
@@ -178,6 +181,14 @@ export default function App() {
|
|||||||
</RequireAuth>
|
</RequireAuth>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<AdminPanelPage />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@@ -10,11 +10,12 @@ interface HeaderProps {
|
|||||||
backLabel?: string;
|
backLabel?: string;
|
||||||
actions?: ReactNode; // دکمههای سمت چپ
|
actions?: ReactNode; // دکمههای سمت چپ
|
||||||
brand?: boolean; // نمایش آیکون دفتر کنار عنوان
|
brand?: boolean; // نمایش آیکون دفتر کنار عنوان
|
||||||
|
icon?: ReactNode; // آیکون سفارشی جعبهی کنار منو (پیشفرض: ClipboardList)
|
||||||
menu?: ReactNode; // منوی همبرگری بالای راست (ناوبری بین پنلها)
|
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 navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
@@ -28,6 +29,11 @@ export function Header({ title, sub, backTo, backLabel = "بازگشت", actions
|
|||||||
return (
|
return (
|
||||||
<header className="flex flex-wrap items-center gap-3 border-b border-line bg-panel px-5 py-2.5">
|
<header className="flex flex-wrap items-center gap-3 border-b border-line bg-panel px-5 py-2.5">
|
||||||
{menu && <div className="flex shrink-0 items-center">{menu}</div>}
|
{menu && <div className="flex shrink-0 items-center">{menu}</div>}
|
||||||
|
{(icon || brand) && (
|
||||||
|
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-accent-bg text-accent">
|
||||||
|
{icon || <ClipboardList className="h-4.5 w-4.5" strokeWidth={2} />}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{backTo && (
|
{backTo && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -39,11 +45,6 @@ export function Header({ title, sub, backTo, backLabel = "بازگشت", actions
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="flex min-w-0 items-center gap-2.5">
|
<div className="flex min-w-0 items-center gap-2.5">
|
||||||
{brand && (
|
|
||||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-accent-bg text-accent">
|
|
||||||
<ClipboardList className="h-4.5 w-4.5" strokeWidth={2} />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h1 className="text-[17px] font-extrabold leading-6">{title}</h1>
|
<h1 className="text-[17px] font-extrabold leading-6">{title}</h1>
|
||||||
{sub && <div className="text-[11px] text-muted">{sub}</div>}
|
{sub && <div className="text-[11px] text-muted">{sub}</div>}
|
||||||
|
|||||||
+25
-3
@@ -35,6 +35,16 @@ import type {
|
|||||||
const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined)?.trim() || "/api/v1";
|
const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined)?.trim() || "/api/v1";
|
||||||
const PAGE_SIZE = 100;
|
const PAGE_SIZE = 100;
|
||||||
|
|
||||||
|
// حذف کلیدهای خالی/undefined از پارامترهای کوئری — URLSearchParams مقدار
|
||||||
|
// undefined را به رشتهی "undefined" تبدیل میکند و بکاند ۴۰۰ برمیگرداند.
|
||||||
|
function cleanParams(params: Record<string, unknown>): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const [k, v] of Object.entries(params)) {
|
||||||
|
if (v !== undefined && v !== null && v !== "") out[k] = String(v);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// v2.6: توکن Casdoor (Bearer) یا هدرهای توسعه (X-User-*) — بکاند هر دو را میپذیرد.
|
// v2.6: توکن Casdoor (Bearer) یا هدرهای توسعه (X-User-*) — بکاند هر دو را میپذیرد.
|
||||||
function authHeaders(init?: RequestInit): Record<string, string> {
|
function authHeaders(init?: RequestInit): Record<string, string> {
|
||||||
const { token, user, isDev } = useAuthStore.getState();
|
const { token, user, isDev } = useAuthStore.getState();
|
||||||
@@ -192,7 +202,11 @@ async function uploadFile(path: string, file: File): Promise<Attachment> {
|
|||||||
form.append("file", file);
|
form.append("file", file);
|
||||||
let res: Response;
|
let res: Response;
|
||||||
try {
|
try {
|
||||||
res = await fetch(BASE_URL + path, { method: "POST", body: form, headers: authHeaders() });
|
// Content-Type را حذف میکنیم — مرورگر باید multipart boundary را خودش
|
||||||
|
// بگذارد؛ وگرنه FastAPI فرم را نمیخواند و ۴۰۰/۴۲۲ برمیگردد.
|
||||||
|
const headers = authHeaders();
|
||||||
|
delete headers["Content-Type"];
|
||||||
|
res = await fetch(BASE_URL + path, { method: "POST", body: form, headers });
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error("اتصال به سرور برقرار نشد — آیا بکاند در دسترس است؟");
|
throw new Error("اتصال به سرور برقرار نشد — آیا بکاند در دسترس است؟");
|
||||||
}
|
}
|
||||||
@@ -466,6 +480,7 @@ function adaptLineItem(raw: RawLineItem): Quote["line_items"][number] {
|
|||||||
accepted_ts_id: raw.accepted_termsheet_id ?? raw.decision?.chosen_item_termsheet_id ?? null,
|
accepted_ts_id: raw.accepted_termsheet_id ?? raw.decision?.chosen_item_termsheet_id ?? null,
|
||||||
rejected: raw.decision?.status === "rejected",
|
rejected: raw.decision?.status === "rejected",
|
||||||
arrived_date: raw.receipt?.received_at ?? null,
|
arrived_date: raw.receipt?.received_at ?? null,
|
||||||
|
received_qty: raw.receipt?.received_qty ?? null,
|
||||||
projects: (raw.projects || []).map((p) => ({ id: p.id, name: p.name })),
|
projects: (raw.projects || []).map((p) => ({ id: p.id, name: p.name })),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1421,6 +1436,13 @@ export const apiV26 = {
|
|||||||
patchData<UserRow>(`/users/${id}`, { role }),
|
patchData<UserRow>(`/users/${id}`, { role }),
|
||||||
assignProject: async (id: string, projectId: string | null): Promise<UserRow> =>
|
assignProject: async (id: string, projectId: string | null): Promise<UserRow> =>
|
||||||
postData<UserRow>(`/users/${id}/project`, { project_id: projectId }),
|
postData<UserRow>(`/users/${id}/project`, { project_id: projectId }),
|
||||||
|
// همگامسازی هویتها از Casdoor (ادمین/مدیر تدارکات) — نقشها دستنخورده میمانند
|
||||||
|
sync: async (): Promise<{ users: UserRow[]; created: number }> => {
|
||||||
|
const res = await request<{ data: UserRow[]; meta: { total: number; created: number } }>("/users/sync", {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
return { users: res.data, created: res.meta?.created ?? 0 };
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
warehouses: {
|
warehouses: {
|
||||||
@@ -1444,12 +1466,12 @@ export const apiV26 = {
|
|||||||
|
|
||||||
inventory: {
|
inventory: {
|
||||||
list: async (params: { project_id?: string; category_id?: string; warehouse_id?: string; material_id?: string; sort?: string } = {}): Promise<InventoryRow[]> => {
|
list: async (params: { project_id?: string; category_id?: string; warehouse_id?: string; material_id?: string; sort?: string } = {}): Promise<InventoryRow[]> => {
|
||||||
const qs = new URLSearchParams({ page_size: "100", ...(params as Record<string, string>) }).toString();
|
const qs = new URLSearchParams({ page_size: "100", ...cleanParams(params) }).toString();
|
||||||
const data = await getData<Parameters<typeof rawInventory>[0][]>(`/inventory?${qs}`);
|
const data = await getData<Parameters<typeof rawInventory>[0][]>(`/inventory?${qs}`);
|
||||||
return data.map(rawInventory);
|
return data.map(rawInventory);
|
||||||
},
|
},
|
||||||
transactions: async (params: { project_id?: string; warehouse_id?: string; transaction_type?: string; date_from?: string; date_to?: string; sort?: string } = {}): Promise<InventoryTransaction[]> => {
|
transactions: async (params: { project_id?: string; warehouse_id?: string; transaction_type?: string; date_from?: string; date_to?: string; sort?: string } = {}): Promise<InventoryTransaction[]> => {
|
||||||
const qs = new URLSearchParams({ page_size: "100", ...(params as Record<string, string>) }).toString();
|
const qs = new URLSearchParams({ page_size: "100", ...cleanParams(params) }).toString();
|
||||||
return getData<InventoryTransaction[]>(`/inventory/transactions?${qs}`);
|
return getData<InventoryTransaction[]>(`/inventory/transactions?${qs}`);
|
||||||
},
|
},
|
||||||
createTransaction: async (body: {
|
createTransaction: async (body: {
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export interface LineItem {
|
|||||||
accepted_ts_id: string | null; // با کدوم ترمشیت پذیرش شده
|
accepted_ts_id: string | null; // با کدوم ترمشیت پذیرش شده
|
||||||
rejected: boolean; // از معامله حذف شده؟
|
rejected: boolean; // از معامله حذف شده؟
|
||||||
arrived_date: string | null; // تاریخ رسید
|
arrived_date: string | null; // تاریخ رسید
|
||||||
|
received_qty?: number | null; // مجموع رسیدهای ثبتشده (رسید جزیی) — null = هنوز دادهای نیست
|
||||||
projects?: { id: string; name: string }[]; // پروژههای تخصیصیافته به این قلم
|
projects?: { id: string; name: string }[]; // پروژههای تخصیصیافته به این قلم
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { ShieldCheck, Warehouse as WarehouseIcon } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Header, PageLayout } from "../../components/layout/Header";
|
||||||
|
import { EmptyState } from "../../components/EmptyState";
|
||||||
|
import { useAuthStore } from "../../store/auth";
|
||||||
|
import { PanelMenu } from "../../components/PanelMenu";
|
||||||
|
import { accountMenuItems, inventorySubheader, useLogout } from "../Inventory/panelMenu";
|
||||||
|
import { apiV26, type UserRow } from "../../lib/api";
|
||||||
|
import { faNum } from "../../lib/formatters";
|
||||||
|
|
||||||
|
const ROLE_LABELS: Record<string, string> = {
|
||||||
|
pm: "مدیر پروژه",
|
||||||
|
procurement_manager: "مدیر تدارکات",
|
||||||
|
admin: "ادمین",
|
||||||
|
};
|
||||||
|
|
||||||
|
// پنل مدیریت ادمین (فقط ادمین) — همگامسازی کاربران از Casdoor و
|
||||||
|
// اعطا/لغو نقش «مدیر تدارکات». تخصیص پروژه در این پنل نیست
|
||||||
|
// (مدیریت پروژهها در «سازماندهی» توسط مدیر تدارکات انجام میشود).
|
||||||
|
export function AdminPanel() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const logout = useLogout();
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
|
||||||
|
const [users, setUsers] = useState<UserRow[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [syncing, setSyncing] = useState(false);
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const isAdmin = user?.role === "admin";
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setUsers(await apiV26.users.list());
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "خطا در دریافت کاربران");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAdmin) void load();
|
||||||
|
}, [isAdmin, load]);
|
||||||
|
|
||||||
|
const sync = async () => {
|
||||||
|
setSyncing(true);
|
||||||
|
try {
|
||||||
|
const { users: synced, created } = await apiV26.users.sync();
|
||||||
|
setUsers(synced);
|
||||||
|
toast.success(
|
||||||
|
created > 0 ? `همگامسازی انجام شد — ${faNum(created)} کاربر جدید از Casdoor اضافه شد ✓` : "همگامسازی انجام شد — کاربر جدیدی یافت نشد",
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "خطا در همگامسازی با Casdoor");
|
||||||
|
} finally {
|
||||||
|
setSyncing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleProcurementManager = async (u: UserRow) => {
|
||||||
|
setBusyId(u.id);
|
||||||
|
const target = u.role === "procurement_manager" ? null : "procurement_manager";
|
||||||
|
try {
|
||||||
|
await apiV26.users.updateRole(u.id, target as "procurement_manager" | null);
|
||||||
|
toast.success(
|
||||||
|
target === "procurement_manager"
|
||||||
|
? `«${u.first_name} ${u.last_name}» مدیر تدارکات شد ✓`
|
||||||
|
: `نقش مدیر تدارکات از «${u.first_name} ${u.last_name}» گرفته شد`,
|
||||||
|
);
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "خطا در تغییر نقش");
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
<Header title="پنل مدیریت ادمین" menu={<PanelMenu title="پنل مدیریت انبار" items={accountMenuItems(logout)} />} />
|
||||||
|
<PageLayout>
|
||||||
|
<EmptyState emoji="🔒" text="این پنل فقط برای ادمین (سوپر یوزر) است." />
|
||||||
|
</PageLayout>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
<Header
|
||||||
|
title="پنل مدیریت ادمین"
|
||||||
|
sub={inventorySubheader(user)}
|
||||||
|
backTo="/inventory"
|
||||||
|
backLabel="بازگشت به داشبورد انبار"
|
||||||
|
icon={<ShieldCheck className="h-4.5 w-4.5" strokeWidth={2} />}
|
||||||
|
menu={<PanelMenu title="پنل مدیریت انبار" items={accountMenuItems(logout)} />}
|
||||||
|
actions={
|
||||||
|
<button
|
||||||
|
onClick={() => void sync()}
|
||||||
|
disabled={syncing}
|
||||||
|
className="btn-line-file cursor-pointer disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{syncing ? "در حال همگامسازی…" : "🔄 همگامسازی از Casdoor"}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<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">
|
||||||
|
🛡️ وظیفهی اصلی ادمین، مدیریت مدیران تدارکات است: با دکمهی هر کاربر میتوانید نقش
|
||||||
|
«مدیر تدارکات» را اعطا یا لغو کنید. تخصیص پروژه به مدیران پروژه در بخش «سازماندهی» انجام میشود
|
||||||
|
(این پنل فقط نقشها را مدیریت میکند).
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="py-6 text-center text-[12.5px] text-muted">در حال بارگذاری کاربران…</div>
|
||||||
|
) : !users.length ? (
|
||||||
|
<EmptyState emoji="👥" text="کاربری یافت نشد — دکمهی «همگامسازی از Casdoor» را بزنید." />
|
||||||
|
) : (
|
||||||
|
<div className="overflow-hidden rounded-xl border border-line bg-panel">
|
||||||
|
<table className="w-full text-[12.5px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-line bg-panel text-right text-muted">
|
||||||
|
<th className="py-2 pe-3">نام کامل</th>
|
||||||
|
<th className="py-2 pe-3">شناسه Casdoor</th>
|
||||||
|
<th className="py-2 pe-3">نقش فعلی</th>
|
||||||
|
<th className="py-2">مدیریت مدیر تدارکات</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map((u) => {
|
||||||
|
const isPMgr = u.role === "procurement_manager";
|
||||||
|
return (
|
||||||
|
<tr key={u.id} className="border-b border-line/50">
|
||||||
|
<td className="py-2 pe-3 font-semibold">
|
||||||
|
{u.first_name} {u.last_name}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pe-3 text-muted ltr">{u.casdoor_id}</td>
|
||||||
|
<td className="py-2 pe-3">
|
||||||
|
<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>
|
||||||
|
</td>
|
||||||
|
<td className="py-2">
|
||||||
|
<button
|
||||||
|
disabled={busyId === u.id}
|
||||||
|
onClick={() => void toggleProcurementManager(u)}
|
||||||
|
className={`rounded-lg border px-3 py-1 text-[11.5px] font-bold ${
|
||||||
|
isPMgr
|
||||||
|
? "border-red/30 bg-red/5 text-red hover:bg-red/10"
|
||||||
|
: "border-green/30 bg-green/5 text-green hover:bg-green/10"
|
||||||
|
} disabled:opacity-50`}
|
||||||
|
>
|
||||||
|
{busyId === u.id ? "…" : isPMgr ? "لغو نقش مدیر تدارکات" : "اعطای نقش مدیر تدارکات"}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div className="border-t border-line bg-panel px-4 py-2 text-[11px] text-muted">
|
||||||
|
{faNum(users.length)} کاربر — نقشها فقط توسط ادمین قابل تغییرند (RBAC).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => navigate("/inventory")}
|
||||||
|
className="flex w-fit cursor-pointer items-center gap-1.5 rounded-lg border border-line bg-panel px-3 py-1.5 text-[12px] text-muted hover:bg-slate-bg hover:text-ink"
|
||||||
|
>
|
||||||
|
<WarehouseIcon className="h-3.5 w-3.5" />
|
||||||
|
بازگشت به داشبورد انبار
|
||||||
|
</button>
|
||||||
|
</PageLayout>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
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 { api, apiV26 } from "../../lib/api";
|
||||||
import { useAuthStore } from "../../store/auth";
|
import { useAuthStore } from "../../store/auth";
|
||||||
import type { Project } from "../../lib/types";
|
import type { Project } from "../../lib/types";
|
||||||
@@ -11,7 +11,11 @@ import type { Project } from "../../lib/types";
|
|||||||
// فرم اصلی: Username / Password / Organization (اختیاری) →
|
// فرم اصلی: Username / Password / Organization (اختیاری) →
|
||||||
// POST /api/v1/auth/login (بکاند با grant_type=password از
|
// POST /api/v1/auth/login (بکاند با grant_type=password از
|
||||||
// Casdoor توکن میگیرد و نقش را از جدول users خودمان برمیگرداند)
|
// Casdoor توکن میگیرد و نقش را از جدول users خودمان برمیگرداند)
|
||||||
// حالت توسعه: دکمهی ثابت بالا-چپ + پنل تست (نقش/پروژه).
|
// حالت توسعه (فقط ادمین — سوپر یوزر) — چرخهی ۳ مرحلهای:
|
||||||
|
// مرحله ۱: درگاه احراز هویت ادمین (نام کاربری/رمز — فقط ادمین رد میشود)
|
||||||
|
// مرحله ۲: هاب انتخاب نقش (ادمین / مدیر تدارکات / مدیر پروژه + پروژه)
|
||||||
|
// مرحله ۳: ورود با نقش انتخابشده — ادمین → پنل مدیریت ادمین،
|
||||||
|
// مدیر تدارکات → دفتر، مدیر پروژه → پنل انبار پروژهی خودش
|
||||||
// با VITE_ENABLE_DEV_LOGIN=false غیرفعال میشود.
|
// با VITE_ENABLE_DEV_LOGIN=false غیرفعال میشود.
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
@@ -35,8 +39,12 @@ export function LoginPage() {
|
|||||||
const [organization, setOrganization] = useState("");
|
const [organization, setOrganization] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
// ---- پنل حالت توسعه ----
|
// ---- پنل حالت توسعه (۳ مرحله) ----
|
||||||
const [devOpen, setDevOpen] = 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<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
const [devRole, setDevRole] = useState<"pm" | "procurement_manager" | "admin">("procurement_manager");
|
const [devRole, setDevRole] = useState<"pm" | "procurement_manager" | "admin">("procurement_manager");
|
||||||
const [devProjectId, setDevProjectId] = useState("");
|
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 = () => {
|
const devLogin = () => {
|
||||||
if (devRole === "pm" && !devProjectId) {
|
if (devRole === "pm" && !devProjectId) {
|
||||||
toast.error("برای نقش مدیر پروژه، پروژه را انتخاب کنید");
|
toast.error("برای نقش مدیر پروژه، پروژه را انتخاب کنید");
|
||||||
@@ -88,7 +123,17 @@ export function LoginPage() {
|
|||||||
}
|
}
|
||||||
setDevUser(devRole, devRole === "pm" ? devProjectId : null, devRole === "pm" ? devProject?.name || null : null);
|
setDevUser(devRole, devRole === "pm" ? devProjectId : null, devRole === "pm" ? devProject?.name || null : null);
|
||||||
toast.success(`ورود آزمایشی انجام شد — نقش: ${DEV_ROLE_LABELS[devRole]}`);
|
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 (
|
return (
|
||||||
@@ -113,55 +158,106 @@ export function LoginPage() {
|
|||||||
{devOpen && (
|
{devOpen && (
|
||||||
<div className="absolute left-0 top-full mt-2 w-80 rounded-xl border border-dashed border-amber/40 bg-panel p-4 shadow-lg">
|
<div className="absolute left-0 top-full mt-2 w-80 rounded-xl border border-dashed border-amber/40 bg-panel p-4 shadow-lg">
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<h3 className="text-[13px] font-bold text-amber">پنل توسعه (ورود آزمایشی)</h3>
|
<h3 className="text-[13px] font-bold text-amber">
|
||||||
|
{devStep === "auth" ? "مرحله ۱ — درگاه ادمین (سوپر یوزر)" : "مرحله ۲ — انتخاب نقش"}
|
||||||
|
</h3>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setDevOpen(false)}
|
onClick={closeDev}
|
||||||
className="cursor-pointer rounded-md p-1 text-muted hover:bg-slate-bg hover:text-ink"
|
className="cursor-pointer rounded-md p-1 text-muted hover:bg-slate-bg hover:text-ink"
|
||||||
aria-label="بستن پنل توسعه"
|
aria-label="بستن پنل توسعه"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-3">
|
|
||||||
<div className="field">
|
{devStep === "auth" ? (
|
||||||
<label className="mb-1 block text-[12px] font-semibold text-muted">نقش</label>
|
<form onSubmit={devAuth} className="grid gap-3">
|
||||||
<select
|
<p className="text-[11.5px] leading-5 text-muted">
|
||||||
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"
|
ورود به حالت توسعه فقط با حساب <b>ادمین</b> (سوپر یوزر) ممکن است — سپس نقش دلخواه را
|
||||||
value={devRole}
|
برای تست انتخاب کنید.
|
||||||
onChange={(e) => setDevRole(e.target.value as "pm" | "procurement_manager" | "admin")}
|
</p>
|
||||||
>
|
|
||||||
<option value="procurement_manager">مدیر تدارکات</option>
|
|
||||||
<option value="admin">ادمین</option>
|
|
||||||
<option value="pm">مدیر پروژه</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{devRole === "pm" && (
|
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label className="mb-1 block text-[12px] font-semibold text-muted">پروژه</label>
|
<label className="mb-1 block text-[12px] font-semibold text-muted">نام کاربری ادمین</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
dir="ltr"
|
||||||
|
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-left text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||||
|
placeholder="admin"
|
||||||
|
value={devAdminUser}
|
||||||
|
onChange={(e) => setDevAdminUser(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label className="mb-1 block text-[12px] font-semibold text-muted">رمز عبور ادمین</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
dir="ltr"
|
||||||
|
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-left text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||||
|
placeholder="••••••"
|
||||||
|
value={devAdminPass}
|
||||||
|
onChange={(e) => setDevAdminPass(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={devAuthLoading}
|
||||||
|
className="inline-flex h-9 cursor-pointer items-center justify-center gap-2 rounded-lg bg-amber px-3 text-[13px] font-bold text-white transition-colors hover:bg-amber/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{devAuthLoading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
<Lock className="h-3.5 w-3.5" />
|
||||||
|
تأیید هویت ادمین
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<div className="field">
|
||||||
|
<label className="mb-1 block text-[12px] font-semibold text-muted">نقش</label>
|
||||||
<select
|
<select
|
||||||
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"
|
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"
|
||||||
value={devProjectId}
|
value={devRole}
|
||||||
onChange={(e) => setDevProjectId(e.target.value)}
|
onChange={(e) => setDevRole(e.target.value as "pm" | "procurement_manager" | "admin")}
|
||||||
>
|
>
|
||||||
<option value="">— انتخاب پروژه —</option>
|
<option value="admin">ادمین — پنل مدیریت ادمین</option>
|
||||||
{projects.map((p) => (
|
<option value="procurement_manager">مدیر تدارکات</option>
|
||||||
<option key={p.id} value={p.id}>
|
<option value="pm">مدیر پروژه</option>
|
||||||
{p.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
{devRole === "pm" && (
|
||||||
<button
|
<div className="field">
|
||||||
type="button"
|
<label className="mb-1 block text-[12px] font-semibold text-muted">پروژه</label>
|
||||||
onClick={devLogin}
|
<select
|
||||||
disabled={devRole === "pm" && !devProjectId}
|
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"
|
||||||
className="inline-flex h-9 cursor-pointer items-center justify-center rounded-lg bg-amber px-3 text-[13px] font-bold text-white transition-colors hover:bg-amber/90 disabled:cursor-not-allowed disabled:opacity-50"
|
value={devProjectId}
|
||||||
>
|
onChange={(e) => setDevProjectId(e.target.value)}
|
||||||
ورود آزمایشی
|
>
|
||||||
</button>
|
<option value="">— انتخاب پروژه —</option>
|
||||||
</div>
|
{projects.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={devLogin}
|
||||||
|
disabled={devRole === "pm" && !devProjectId}
|
||||||
|
className="inline-flex h-9 cursor-pointer items-center justify-center gap-2 rounded-lg bg-amber px-3 text-[13px] font-bold text-white transition-colors hover:bg-amber/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<ShieldCheck className="h-3.5 w-3.5" />
|
||||||
|
ورود به حالت توسعه
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDevStep("auth")}
|
||||||
|
className="cursor-pointer text-center text-[11.5px] text-muted hover:text-ink"
|
||||||
|
>
|
||||||
|
→ بازگشت به درگاه ادمین
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -61,19 +61,24 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ستون رسید — v2.6: تأیید رسید فقط در «پنل مدیریت انبار» توسط مدیر پروژه انجام میشود؛
|
// ستون رسید — 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;
|
let arrivedCell: React.ReactNode;
|
||||||
if (q.status === "accepted") {
|
if (q.status === "accepted") {
|
||||||
if (q.arrived_date) {
|
if (q.arrived_date) {
|
||||||
arrivedCell = <span className="tabular-nums">{fmtDateTime(q.arrived_date)}</span>;
|
arrivedCell = <span className="tabular-nums">{fmtDateTime(q.arrived_date)}</span>;
|
||||||
} else {
|
} else if (receivedSum > 0) {
|
||||||
const items = (q.line_items || []).filter((li) => !li.rejected);
|
arrivedCell = (
|
||||||
const arrivedCount = items.filter((li) => li.arrived_date).length;
|
<span className="tabular-nums">
|
||||||
arrivedCell = arrivedCount ? (
|
رسیده {fmtNum(receivedSum)} از {fmtNum(orderedSum)} — در راه {fmtNum(Math.max(0, orderedSum - receivedSum))}
|
||||||
<span className="tabular-nums">{fmtNum(arrivedCount)}/{fmtNum(items.length)}</span>
|
</span>
|
||||||
) : (
|
|
||||||
<span className="text-muted">در انتظار PM</span>
|
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
arrivedCell = <span className="text-muted">در انتظار PM</span>;
|
||||||
}
|
}
|
||||||
} else if (q.arrived_date) {
|
} else if (q.arrived_date) {
|
||||||
arrivedCell = <span className="tabular-nums">{fmtDateTime(q.arrived_date)}</span>;
|
arrivedCell = <span className="tabular-nums">{fmtDateTime(q.arrived_date)}</span>;
|
||||||
@@ -105,6 +110,7 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
</div>
|
</div>
|
||||||
<div className="ms-auto flex shrink-0 items-center gap-1.5">
|
<div className="ms-auto flex shrink-0 items-center gap-1.5">
|
||||||
<Badge variant={q.status as "offered" | "accepted" | "arrived"}>{STATUS_LABEL[q.status]}</Badge>
|
<Badge variant={q.status as "offered" | "accepted" | "arrived"}>{STATUS_LABEL[q.status]}</Badge>
|
||||||
|
{partiallyReceived && <Badge variant="offered">رسید جزیی</Badge>}
|
||||||
<button className="chip" onClick={onEdit} title="ویرایش پیشنهاد">
|
<button className="chip" onClick={onEdit} title="ویرایش پیشنهاد">
|
||||||
<Pencil className="h-3 w-3" />
|
<Pencil className="h-3 w-3" />
|
||||||
ویرایش
|
ویرایش
|
||||||
|
|||||||
@@ -98,6 +98,12 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
|
|||||||
<span>× {groupCommas(li.unit_price, true)}</span>
|
<span>× {groupCommas(li.unit_price, true)}</span>
|
||||||
<span className="fb-total">= {total != null ? groupCommas(total, true) + " تومان" : "—"}</span>
|
<span className="fb-total">= {total != null ? groupCommas(total, true) + " تومان" : "—"}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{li.received_qty != null && li.received_qty > 0 && li.quantity != null && (
|
||||||
|
<div className="fb-receipt">
|
||||||
|
<Package className="me-1 inline h-3 w-3" />
|
||||||
|
رسیده: {fmtNum(li.received_qty)} از {fmtNum(li.quantity)} — در راه {fmtNum(Math.max(0, li.quantity - li.received_qty))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{conv && (
|
{conv && (
|
||||||
<div className="fb-conv">
|
<div className="fb-conv">
|
||||||
<Scale className="me-1 inline h-3 w-3" /> ≈ {groupCommas(conv.qty, true)} {conv.unit}
|
<Scale className="me-1 inline h-3 w-3" /> ≈ {groupCommas(conv.qty, true)} {conv.unit}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
|
import { Warehouse as WarehouseIcon } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Header, PageLayout } from "../../components/layout/Header";
|
import { Header, PageLayout } from "../../components/layout/Header";
|
||||||
import { EmptyState } from "../../components/EmptyState";
|
import { EmptyState } from "../../components/EmptyState";
|
||||||
@@ -7,7 +8,7 @@ import { Badge } from "../../components/ui/badge";
|
|||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import { useAuthStore } from "../../store/auth";
|
import { useAuthStore } from "../../store/auth";
|
||||||
import { PanelMenu } from "../../components/PanelMenu";
|
import { PanelMenu } from "../../components/PanelMenu";
|
||||||
import { panelMenuItems, accountMenuItems, useLogout } from "./panelMenu";
|
import { accountMenuItems, inventorySubheader, panelMenuItems, pmMenuItems, useLogout } from "./panelMenu";
|
||||||
import { InventoryTopNav } from "./InventoryTopNav";
|
import { InventoryTopNav } from "./InventoryTopNav";
|
||||||
import { apiV26 } from "../../lib/api";
|
import { apiV26 } from "../../lib/api";
|
||||||
import type { PendingArrival, Warehouse } from "../../lib/types";
|
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) } };
|
return { ...d, [decisionId]: { ...cur, allocs: cur.allocs.filter((a) => a.key !== key) } };
|
||||||
});
|
});
|
||||||
|
|
||||||
const remainingOf = (it: PendingArrival): number => {
|
const round4 = (n: number) => Math.round(n * 10000) / 10000;
|
||||||
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 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 d = draftOf(it);
|
||||||
const received = parseFloat(d.received);
|
const received = draftReceivedOf(it);
|
||||||
if (!(received > 0)) return false;
|
if (!(received > 0)) return false;
|
||||||
// محدودیت: تعداد رسیده نمیتواند از تعداد سفارششده بیشتر باشد
|
if (received > remainingToReceive(it)) return false; // سقف: بیش از ماندهی سفارش نه
|
||||||
if (received > parseFloat(it.ordered_qty)) return false;
|
if (d.allocs.some((a) => (parseFloat(a.qty) || 0) > 0 && !a.whId)) return false;
|
||||||
if (d.allocs.some((a) => !a.whId)) return false;
|
return allocSumOf(it) === received;
|
||||||
return remainingOf(it) === 0;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const orderReady = (quoteId: string): boolean => {
|
const itemHasErrors = (it: PendingArrival): { ceiling: boolean; sumMismatch: boolean; emptyWh: boolean } => {
|
||||||
const its = orderItems(quoteId);
|
const d = draftOf(it);
|
||||||
return its.length > 0 && its.every((it) => it.state === "arrived" || itemReady(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 ----------
|
// ---------- submit ----------
|
||||||
|
|
||||||
const submitOrder = async (quoteId: string) => {
|
const submitOrder = async (quoteId: string) => {
|
||||||
const its = orderItems(quoteId).filter((it) => it.state !== "arrived");
|
const its = orderItems(quoteId).filter((it) => it.state !== "arrived");
|
||||||
if (!orderReady(quoteId)) {
|
const submittable = its.filter(itemSubmittable);
|
||||||
toast.error("برای هر قلم: تعداد رسیده را وارد کنید و تا صفر شدن «مانده تخصیص» ردیفها را پر کنید.");
|
if (!submittable.length) {
|
||||||
|
toast.error("حداقل یک قلم با تعداد معتبر (داخل ماندهی سفارش و تخصیصِ برابر با تعداد رسیده) وارد کنید.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (orderHasErrors(quoteId)) {
|
||||||
|
toast.error("برخی قلمها خطای اعتبارسنجی دارند — ابتدا سقف رسید و مجموع تخصیص انبارها را اصلاح کنید.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSaving(quoteId);
|
setSaving(quoteId);
|
||||||
try {
|
try {
|
||||||
for (const it of its) {
|
for (const it of submittable) {
|
||||||
const d = draftOf(it);
|
const d = draftOf(it);
|
||||||
const allocations = d.allocs
|
const allocations = d.allocs
|
||||||
.filter((a) => (parseFloat(a.qty) || 0) > 0 && a.whId)
|
.filter((a) => (parseFloat(a.qty) || 0) > 0 && a.whId)
|
||||||
.map((a) => ({ warehouse_id: a.whId, quantity: parseFloat(a.qty) }));
|
.map((a) => ({ warehouse_id: a.whId, quantity: parseFloat(a.qty) }));
|
||||||
await apiV26.pm.confirmReceipt({
|
await apiV26.pm.confirmReceipt({
|
||||||
item_decision_id: it.item_decision_id,
|
item_decision_id: it.item_decision_id,
|
||||||
received_qty: parseFloat(d.received),
|
received_qty: draftReceivedOf(it),
|
||||||
allocations,
|
allocations,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
toast.success("رسید سفارش ثبت شد و به انبارها وارد شد ✓");
|
toast.success("رسید (کامل یا جزیی) ثبت شد و به انبارها وارد شد ✓ — اقلام باقیمانده همچنان در راه هستند");
|
||||||
setDrafts((d) => {
|
setDrafts((d) => {
|
||||||
const next = { ...d };
|
const next = { ...d };
|
||||||
its.forEach((it) => delete next[it.item_decision_id]);
|
submittable.forEach((it) => delete next[it.item_decision_id]);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
setExpanded((s) => {
|
setExpanded((s) => {
|
||||||
@@ -198,15 +227,20 @@ export function ArrivalsPage() {
|
|||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<Header
|
<Header
|
||||||
title="کالاهای در راه"
|
title="کالاهای در راه"
|
||||||
sub={isPM ? user?.project_name || "" : "همه پروژهها — مدیر تدارکات"}
|
sub={inventorySubheader(user)}
|
||||||
menu={isAdmin ? <PanelMenu title="پنل مدیریت انبار" items={accountMenuItems(logout)} /> : undefined}
|
backTo="/inventory"
|
||||||
actions={isAdmin || isPM ? <InventoryTopNav pathname={location.pathname} onLogout={logout} variant={isAdmin ? "admin" : "pm"} /> : undefined}
|
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">
|
<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">
|
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted">
|
||||||
🚚 این بخش به مرحله «رسید» سفارشهای خرید متصل است: تأیید پیشنهاد و پذیرش در سیستم تدارکات انجام شده و
|
🚚 این بخش به مرحله «رسید» سفارشهای خرید متصل است: تأیید پیشنهاد و پذیرش در سیستم تدارکات انجام شده و
|
||||||
اینجا فقط رسید و تقسیم بین انبارها ثبت میشود. هر سفارش را باز کنید، تعداد واقعیِ رسیده هر قلم را وارد کنید
|
اینجا فقط رسید و تقسیم بین انبارها ثبت میشود. <b>رسید جزیی (قلمی و مقداری) مجاز است</b> — هر سفارش را باز
|
||||||
و آن را بین انبارهای پروژه تقسیم کنید — تا «مانده تخصیص» به صفر برسد.
|
کنید، تعداد واقعیِ رسیده هر قلم را وارد کنید و آن را بین انبارهای پروژه تقسیم کنید؛ قلمهایی که مقداری برایشان
|
||||||
|
وارد نشود در صف «در راه» میمانند. مجموع تخصیص هر قلم باید دقیقاً با تعداد رسیدهی همان قلم برابر باشد و از
|
||||||
|
ماندهی سفارش بیشتر نشود.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -217,7 +251,9 @@ export function ArrivalsPage() {
|
|||||||
orders.map(([quoteId, its]) => {
|
orders.map(([quoteId, its]) => {
|
||||||
const first = its[0];
|
const first = its[0];
|
||||||
const open = expanded.has(quoteId);
|
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);
|
const days = orderDaysElapsed(its);
|
||||||
return (
|
return (
|
||||||
<div key={quoteId} className="overflow-hidden rounded-xl border border-line bg-panel shadow-sm">
|
<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">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="text-[14px] font-bold">🧾 {first.supplier.name}</span>
|
<span className="text-[14px] font-bold">🧾 {first.supplier.name}</span>
|
||||||
<Badge variant="outline">{faNum(its.length)} قلم</Badge>
|
<Badge variant="outline">{faNum(its.length)} قلم</Badge>
|
||||||
<Badge variant={first.state === "partially_received" ? "offered" : "outline"}>
|
<Badge variant={anyPartial ? "offered" : "outline"}>
|
||||||
{first.state === "partially_received" ? "رسید ناقص" : "در راه"}
|
{anyPartial ? "رسید جزیی" : "در راه"}
|
||||||
</Badge>
|
</Badge>
|
||||||
{days !== null && (
|
{days !== null && (
|
||||||
<Badge variant={days >= 7 ? "offered" : "muted"}>
|
<Badge variant={days >= 7 ? "offered" : "muted"}>
|
||||||
@@ -251,7 +287,12 @@ export function ArrivalsPage() {
|
|||||||
<div className="divide-y divide-line/60">
|
<div className="divide-y divide-line/60">
|
||||||
{its.map((it) => {
|
{its.map((it) => {
|
||||||
const d = draftOf(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 whs = whsOfProject(it.project?.id);
|
||||||
const itemDays = daysElapsed(it.decided_at);
|
const itemDays = daysElapsed(it.decided_at);
|
||||||
return (
|
return (
|
||||||
@@ -261,9 +302,13 @@ export function ArrivalsPage() {
|
|||||||
<Badge variant="outline">
|
<Badge variant="outline">
|
||||||
سفارش: {faNum(parseFloat(it.ordered_qty))} {it.unit?.name_fa || it.unit?.code || ""}
|
سفارش: {faNum(parseFloat(it.ordered_qty))} {it.unit?.name_fa || it.unit?.code || ""}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge variant={parseFloat(it.received_qty) > 0 ? "offered" : "outline"}>
|
<Badge variant={parseFloat(it.received_qty) > 0 || draftReceived > 0 ? "offered" : "outline"}>
|
||||||
رسیده: {faNum(parseFloat(it.received_qty))}
|
رسیده: {faNum(parseFloat(it.received_qty) + draftReceived)} / {faNum(parseFloat(it.ordered_qty))}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
<Badge variant={inTransit > 0 ? "muted" : "outline"}>
|
||||||
|
در راه: {faNum(Math.max(0, inTransit))}
|
||||||
|
</Badge>
|
||||||
|
{it.state === "partially_received" && <Badge variant="offered">رسید ناقص</Badge>}
|
||||||
{itemDays !== null && (
|
{itemDays !== null && (
|
||||||
<span className="text-[11px] text-muted">روز گذشته: <b className="text-foreground">{faNum(itemDays)}</b></span>
|
<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"
|
className="h-8 w-full rounded-lg border border-line bg-panel px-2 text-sm ltr"
|
||||||
value={d.received}
|
value={d.received}
|
||||||
onChange={(e) => setReceived(it.item_decision_id, e.target.value)}
|
onChange={(e) => setReceived(it.item_decision_id, e.target.value)}
|
||||||
placeholder={it.ordered_qty}
|
placeholder={String(remaining)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="field mb-0">
|
<div className="field mb-0">
|
||||||
@@ -326,17 +371,22 @@ export function ArrivalsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-[12px]">
|
<div className="mt-2 flex flex-wrap items-center gap-2 text-[12px]">
|
||||||
<span>
|
<span className={submittable ? "text-green" : "text-muted"}>
|
||||||
مانده تخصیص:{" "}
|
{draftReceived > 0 ? (submittable ? "✓ آمادهی ثبت" : "در حال تکمیل…") : "بدون مقدار — در راه میماند"}
|
||||||
<b className={remaining === 0 ? "text-green" : "text-red"}>
|
|
||||||
{faNum(remaining)} {it.unit?.name_fa || it.unit?.code || ""}
|
|
||||||
</b>
|
|
||||||
</span>
|
</span>
|
||||||
{parseFloat(d.received) > parseFloat(it.ordered_qty) && (
|
{errs.ceiling && (
|
||||||
<span className="text-[11px] font-bold text-red">تعداد کالای بیشتری نسبت به کالای رسیده انتخاب کردید</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -344,9 +394,9 @@ export function ArrivalsPage() {
|
|||||||
|
|
||||||
<div className="flex items-center justify-between gap-2 bg-panel px-4 py-3">
|
<div className="flex items-center justify-between gap-2 bg-panel px-4 py-3">
|
||||||
<span className="text-[11.5px] text-muted">
|
<span className="text-[11.5px] text-muted">
|
||||||
برای ثبت رسید کل سفارش، همه اقلام باید «مانده تخصیص صفر» داشته باشند.
|
رسید جزیی مجاز است: حداقل یک قلم با تعداد معتبر کافی است؛ قلمهای بدون مقدار در «در راه» میمانند.
|
||||||
</span>
|
</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 ? "در حال ثبت…" : "✓ ثبت رسید و ورود به انبار"}
|
{saving === quoteId ? "در حال ثبت…" : "✓ ثبت رسید و ورود به انبار"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useLocation, useSearchParams } from "react-router-dom";
|
import { useLocation, useSearchParams } from "react-router-dom";
|
||||||
|
import { Warehouse as WarehouseIcon } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Header, PageLayout } from "../../components/layout/Header";
|
import { Header, PageLayout } from "../../components/layout/Header";
|
||||||
import { useAuthStore } from "../../store/auth";
|
import { useAuthStore } from "../../store/auth";
|
||||||
import { PanelMenu } from "../../components/PanelMenu";
|
import { PanelMenu } from "../../components/PanelMenu";
|
||||||
import { panelMenuItems, useLogout } from "./panelMenu";
|
import { inventorySubheader, panelMenuItems, pmMenuItems, useLogout } from "./panelMenu";
|
||||||
import { InventoryTopNav } from "./InventoryTopNav";
|
import { InventoryTopNav } from "./InventoryTopNav";
|
||||||
import { apiV26 } from "../../lib/api";
|
import { apiV26 } from "../../lib/api";
|
||||||
import { useInventoryData, unitName } from "./useInventoryData";
|
import { useInventoryData, unitName } from "./useInventoryData";
|
||||||
@@ -322,8 +323,12 @@ export function ChangeInventoryPage() {
|
|||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<Header
|
<Header
|
||||||
title="تغییر موجودی"
|
title="تغییر موجودی"
|
||||||
sub={user?.project_name || ""}
|
sub={inventorySubheader(user)}
|
||||||
actions={<InventoryTopNav pathname={location.pathname} onLogout={logout} />}
|
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">
|
<PageLayout className="flex flex-col gap-3">
|
||||||
{/* انتخاب نوع تراکنش */}
|
{/* انتخاب نوع تراکنش */}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import { Warehouse as WarehouseIcon } from "lucide-react";
|
||||||
import { Header, PageLayout } from "../../components/layout/Header";
|
import { Header, PageLayout } from "../../components/layout/Header";
|
||||||
import { useAuthStore } from "../../store/auth";
|
import { useAuthStore } from "../../store/auth";
|
||||||
import { PanelMenu } from "../../components/PanelMenu";
|
import { PanelMenu } from "../../components/PanelMenu";
|
||||||
import { accountMenuItems, useLogout } from "./panelMenu";
|
import { accountMenuItems, inventorySubheader, pmMenuItems, useLogout } from "./panelMenu";
|
||||||
import { InventoryTopNav } from "./InventoryTopNav";
|
import { InventoryTopNav } from "./InventoryTopNav";
|
||||||
import { useInventoryData, unitName, whName } from "./useInventoryData";
|
import { useInventoryData, unitName, whName } from "./useInventoryData";
|
||||||
import { faNum } from "../../lib/formatters";
|
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";
|
import type { InventoryTransaction } from "../../lib/types";
|
||||||
|
|
||||||
function gregKey(d: Date): string {
|
function gregKey(d: Date): string {
|
||||||
@@ -83,6 +84,21 @@ export function DailyControlPage() {
|
|||||||
const [ty, tm] = gregorianToJalali(anchor.getFullYear(), anchor.getMonth() + 1, anchor.getDate());
|
const [ty, tm] = gregorianToJalali(anchor.getFullYear(), anchor.getMonth() + 1, anchor.getDate());
|
||||||
const monthTitle = `${toFa(ty)} ${MONTHS[tm - 1]}`;
|
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 dayTx = txByDay.get(sel) || [];
|
||||||
const withdrawals = dayTx.filter((t) => t.transaction_type === "WITHDRAWAL");
|
const withdrawals = dayTx.filter((t) => t.transaction_type === "WITHDRAWAL");
|
||||||
const wGroups = useMemo(() => {
|
const wGroups = useMemo(() => {
|
||||||
@@ -101,14 +117,18 @@ export function DailyControlPage() {
|
|||||||
|
|
||||||
const isToday = sel === todayKey();
|
const isToday = sel === todayKey();
|
||||||
const selDate = new Date(Number(sel.split("-")[0]), Number(sel.split("-")[1]) - 1, Number(sel.split("-")[2]));
|
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 (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<Header
|
<Header
|
||||||
title="کنترل روزانه"
|
title="کنترل روزانه"
|
||||||
sub={user?.project_name || ""}
|
sub={inventorySubheader(user)}
|
||||||
menu={isAdmin ? <PanelMenu title="پنل مدیریت انبار" items={accountMenuItems(logout)} /> : undefined}
|
backTo="/inventory"
|
||||||
actions={isAdmin || isPM ? <InventoryTopNav pathname={location.pathname} onLogout={logout} variant={isAdmin ? "admin" : "pm"} /> : undefined}
|
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">
|
<PageLayout className="flex flex-col gap-3">
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
@@ -137,9 +157,9 @@ export function DailyControlPage() {
|
|||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<div className="text-[14px] font-bold">📅 {monthTitle}</div>
|
<div className="text-[14px] font-bold">📅 {monthTitle}</div>
|
||||||
<div className="flex gap-1.5">
|
<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()); 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>
|
</div>
|
||||||
<div className="grid grid-cols-7 gap-1">
|
<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>
|
<div key={d} className="py-1 text-center text-[11px] font-bold text-muted">{d}</div>
|
||||||
))}
|
))}
|
||||||
{cells.map((c, i) => {
|
{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);
|
const k = gregKey(c.d);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setSel(k); setActRec(null); }}
|
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)}
|
{toFa(jd)}
|
||||||
{c.n > 0 && (
|
{c.n > 0 && (
|
||||||
@@ -171,7 +192,7 @@ export function DailyControlPage() {
|
|||||||
{/* برداشتهای روز */}
|
{/* برداشتهای روز */}
|
||||||
<div className="rounded-xl border border-line bg-panel p-4">
|
<div className="rounded-xl border border-line bg-panel p-4">
|
||||||
<div className="mb-1 flex items-center justify-between">
|
<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>
|
<span className="rounded-lg bg-blue/10 px-2 py-1 text-[11.5px] font-bold text-blue">▼ برداشتها: {faNum(wGroups.length)} قلم</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mb-3 text-[11.5px] leading-5 text-muted">
|
<p className="mb-3 text-[11.5px] leading-5 text-muted">
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useMemo, useState } from "react";
|
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 { Header, PageLayout } from "../../components/layout/Header";
|
||||||
import { EmptyState } from "../../components/EmptyState";
|
import { EmptyState } from "../../components/EmptyState";
|
||||||
import { Badge } from "../../components/ui/badge";
|
import { Badge } from "../../components/ui/badge";
|
||||||
import { useAuthStore } from "../../store/auth";
|
import { useAuthStore } from "../../store/auth";
|
||||||
import { PanelMenu } from "../../components/PanelMenu";
|
import { PanelMenu } from "../../components/PanelMenu";
|
||||||
import { panelMenuItems, accountMenuItems, useLogout } from "./panelMenu";
|
import { accountMenuItems, inventorySubheader, panelMenuItems, pmMenuItems, useLogout } from "./panelMenu";
|
||||||
import { InventoryTopNav } from "./InventoryTopNav";
|
import { InventoryTopNav } from "./InventoryTopNav";
|
||||||
import { useInventoryData, brandName, catName, materialName, unitName, whName } from "./useInventoryData";
|
import { useInventoryData, brandName, catName, materialName, unitName, whName } from "./useInventoryData";
|
||||||
import type { InventoryRow, InventoryTransaction, TransactionType } from "../../lib/types";
|
import type { InventoryRow, InventoryTransaction, TransactionType } from "../../lib/types";
|
||||||
@@ -137,9 +138,28 @@ export function InventoryPage() {
|
|||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<Header
|
<Header
|
||||||
title="سامانه مدیریت انبار"
|
title="سامانه مدیریت انبار"
|
||||||
sub={isAdmin ? "مدیر تدارکات — همه پروژهها" : user?.project_name ? `مدیر پروژه — ${user.project_name}` : "مدیر پروژه"}
|
sub={inventorySubheader(user)}
|
||||||
menu={isAdmin ? <PanelMenu title="پنل مدیریت انبار" items={accountMenuItems(logout)} /> : undefined}
|
icon={<WarehouseIcon className="h-4.5 w-4.5" strokeWidth={2} />}
|
||||||
actions={isAdmin || isPM ? <InventoryTopNav pathname={location.pathname} onLogout={logout} variant={isAdmin ? "admin" : "pm"} /> : undefined}
|
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">
|
<PageLayout className="flex flex-col gap-3">
|
||||||
{/* فیلترها */}
|
{/* فیلترها */}
|
||||||
|
|||||||
@@ -4,17 +4,16 @@ import { cn } from "../../lib/utils";
|
|||||||
// ناوبری بالای صفحه در پنل انبار — جایگزین لینکهای منوی همبرگری
|
// ناوبری بالای صفحه در پنل انبار — جایگزین لینکهای منوی همبرگری
|
||||||
// (مثل سیستم تدارکات: لینکها در بالای صفحه)
|
// (مثل سیستم تدارکات: لینکها در بالای صفحه)
|
||||||
//
|
//
|
||||||
// - variant="pm": همه بخشهای مدیر پروژه + «دفتر تدارکات» و «خروج از حساب»
|
// - variant="pm" (مدیر پروژه): همه بخشهای مدیر پروژه — بدون «دفتر تدارکات»
|
||||||
// (منوی همبرگری حذف میشود).
|
// (مدیر پروژه به سیستم تدارکات دسترسی ندارد) و بدون دکمهی خروج تکی
|
||||||
// - variant="admin": فقط بخشهای مدیر تدارکات/ادمین — «بازگشت به دفتر» و
|
// (خروج از حساب فقط در منوی همبرگری است).
|
||||||
// «خروج از حساب» در منوی همبرگری میمانند.
|
// - variant="admin" (مدیر تدارکات/ادمین): فقط بخشهای مدیر تدارکات —
|
||||||
|
// «بازگشت به دفتر» و «خروج از حساب» در منوی همبرگری میمانند.
|
||||||
export function InventoryTopNav({
|
export function InventoryTopNav({
|
||||||
pathname,
|
pathname,
|
||||||
onLogout,
|
|
||||||
variant = "pm",
|
variant = "pm",
|
||||||
}: {
|
}: {
|
||||||
pathname: string;
|
pathname: string;
|
||||||
onLogout: () => void;
|
|
||||||
variant?: "pm" | "admin";
|
variant?: "pm" | "admin";
|
||||||
}) {
|
}) {
|
||||||
const items =
|
const items =
|
||||||
@@ -46,16 +45,6 @@ export function InventoryTopNav({
|
|||||||
{it.label}
|
{it.label}
|
||||||
</Link>
|
</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 { useCallback, useEffect, useState } from "react";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import { Warehouse as WarehouseIcon } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Header, PageLayout } from "../../components/layout/Header";
|
import { Header, PageLayout } from "../../components/layout/Header";
|
||||||
import { useAuthStore } from "../../store/auth";
|
import { useAuthStore } from "../../store/auth";
|
||||||
import { PanelMenu } from "../../components/PanelMenu";
|
import { PanelMenu } from "../../components/PanelMenu";
|
||||||
import { panelMenuItems, accountMenuItems, useLogout } from "./panelMenu";
|
import { accountMenuItems, inventorySubheader, useLogout } from "./panelMenu";
|
||||||
import { InventoryTopNav } from "./InventoryTopNav";
|
import { InventoryTopNav } from "./InventoryTopNav";
|
||||||
import { apiV26, type UserRow } from "../../lib/api";
|
import { apiV26, type UserRow } from "../../lib/api";
|
||||||
import { useInventoryData } from "./useInventoryData";
|
import { useInventoryData } from "./useInventoryData";
|
||||||
@@ -28,18 +29,16 @@ export function OrganizationPage() {
|
|||||||
|
|
||||||
const [users, setUsers] = useState<UserRow[]>([]);
|
const [users, setUsers] = useState<UserRow[]>([]);
|
||||||
const [loadingUsers, setLoadingUsers] = useState(true);
|
const [loadingUsers, setLoadingUsers] = useState(true);
|
||||||
|
const [syncing, setSyncing] = useState(false);
|
||||||
const [busyId, setBusyId] = useState<string | null>(null);
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
|
|
||||||
const [newWh, setNewWh] = useState<Record<string, string>>({});
|
const [newWh, setNewWh] = useState<Record<string, string>>({});
|
||||||
const [newWhDesc, setNewWhDesc] = 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";
|
const canManage = user?.role === "admin" || user?.role === "procurement_manager";
|
||||||
|
// فقط ادمین میتواند نقشها را تغییر دهد/ارتقا دهد (RBAC) — مدیر تدارکات فقط
|
||||||
|
// پروژهی مدیران پروژه را تعیین/جابهجا میکند.
|
||||||
|
const canChangeRoles = user?.role === "admin";
|
||||||
|
|
||||||
const loadUsers = useCallback(async () => {
|
const loadUsers = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -56,10 +55,28 @@ export function OrganizationPage() {
|
|||||||
if (canManage) void loadUsers();
|
if (canManage) void loadUsers();
|
||||||
}, [canManage, 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) {
|
if (!canManage) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<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>
|
<PageLayout>
|
||||||
<div className="text-[13px] text-muted">این بخش فقط برای ادمین / مدیر تدارکات است.</div>
|
<div className="text-[13px] text-muted">این بخش فقط برای ادمین / مدیر تدارکات است.</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
@@ -69,26 +86,6 @@ export function OrganizationPage() {
|
|||||||
|
|
||||||
// ---------- actions: users ----------
|
// ---------- 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) => {
|
const changeRole = async (u: UserRow, role: Role) => {
|
||||||
setBusyId(u.id);
|
setBusyId(u.id);
|
||||||
try {
|
try {
|
||||||
@@ -160,62 +157,30 @@ export function OrganizationPage() {
|
|||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<Header
|
<Header
|
||||||
title="سازماندهی"
|
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)} />}
|
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">
|
<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">
|
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted">
|
||||||
ℹ️ پروژهها از سیستم تدارکات میآیند. اینجا مشخص میکنید هر پروژه با کدام مدیر و انبار(ها) کار میکند؛
|
ℹ️ کاربران از Casdoor همگام میشوند و نقشها اینجا (فقط توسط ادمین) تعیین میشوند؛
|
||||||
|
مدیر تدارکات فقط پروژهی مدیران پروژه را تعیین/جابهجا میکند. پروژهها از سیستم تدارکات میآیند و
|
||||||
هر مدیر پروژه به همه انبارهای پروژه خودش دسترسی دارد. همه تغییرات فوراً در داشبورد و فرمها اعمال میشود.
|
هر مدیر پروژه به همه انبارهای پروژه خودش دسترسی دارد. همه تغییرات فوراً در داشبورد و فرمها اعمال میشود.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ---------- کاربران و مدیران پروژه ---------- */}
|
{/* ---------- کاربران و مدیران پروژه ---------- */}
|
||||||
<div className="rounded-xl border border-line bg-panel p-4">
|
<div className="rounded-xl border border-line bg-panel p-4">
|
||||||
<h3 className="mb-2 text-[14px] font-bold">👥 کاربران و مدیران پروژه</h3>
|
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<h3 className="text-[14px] font-bold">👥 کاربران و مدیران پروژه</h3>
|
||||||
{/* تعریف کاربر جدید */}
|
<button
|
||||||
<div className="mb-3 flex flex-wrap items-end gap-2 rounded-lg border border-dashed border-accent/40 bg-accent/5 p-3">
|
onClick={() => void syncUsers()}
|
||||||
<div className="field mb-0">
|
disabled={syncing}
|
||||||
<label>نام *</label>
|
className="rounded-lg border border-accent/40 bg-accent/5 px-3 py-1.5 text-[12px] font-bold text-accent disabled:opacity-50"
|
||||||
<input
|
>
|
||||||
className="h-8 w-36 rounded-lg border border-line bg-panel px-2 text-sm"
|
{syncing ? "در حال همگامسازی…" : "🔄 همگامسازی از Casdoor"}
|
||||||
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()}>
|
|
||||||
+ تعریف کاربر
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -224,7 +189,7 @@ export function OrganizationPage() {
|
|||||||
) : !users.length ? (
|
) : !users.length ? (
|
||||||
<div className="rounded-lg border border-dashed border-line px-3 py-3 text-[12px] text-muted">
|
<div className="rounded-lg border border-dashed border-line px-3 py-3 text-[12px] text-muted">
|
||||||
کاربری ثبت نشده است — کاربران پس از اولین ورود (Casdoor) بهصورت خودکار ساخته میشوند؛
|
کاربری ثبت نشده است — کاربران پس از اولین ورود (Casdoor) بهصورت خودکار ساخته میشوند؛
|
||||||
یا از فرم بالا یک مدیر پروژه تعریف کنید.
|
یا دکمهی «همگامسازی از Casdoor» را بزنید.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<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>
|
<small className="block text-[10.5px] text-muted ltr">{u.casdoor_id}</small>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-1.5 pe-3">
|
<td className="py-1.5 pe-3">
|
||||||
<select
|
{canChangeRoles ? (
|
||||||
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px]"
|
<select
|
||||||
value={u.role ?? ""}
|
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px]"
|
||||||
disabled={busyId === u.id}
|
value={u.role ?? ""}
|
||||||
onChange={(e) => void changeRole(u, (e.target.value || null) as 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="">— بدون نقش —</option>
|
||||||
<option value="procurement_manager">مدیر تدارکات</option>
|
<option value="pm">مدیر پروژه</option>
|
||||||
<option value="admin">ادمین</option>
|
<option value="procurement_manager">مدیر تدارکات</option>
|
||||||
</select>
|
<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>
|
||||||
<td className="py-1.5 pe-3">
|
<td className="py-1.5 pe-3">
|
||||||
{u.role === "pm" ? (
|
{u.role === "pm" ? (
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import { Warehouse as WarehouseIcon } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Header, PageLayout } from "../../components/layout/Header";
|
import { Header, PageLayout } from "../../components/layout/Header";
|
||||||
import { useAuthStore } from "../../store/auth";
|
import { useAuthStore } from "../../store/auth";
|
||||||
import { PanelMenu } from "../../components/PanelMenu";
|
import { PanelMenu } from "../../components/PanelMenu";
|
||||||
import { panelMenuItems, useLogout } from "./panelMenu";
|
import { inventorySubheader, panelMenuItems, pmMenuItems, useLogout } from "./panelMenu";
|
||||||
import { InventoryTopNav } from "./InventoryTopNav";
|
import { InventoryTopNav } from "./InventoryTopNav";
|
||||||
import { apiV26 } from "../../lib/api";
|
import { apiV26 } from "../../lib/api";
|
||||||
import { useInventoryData, projName } from "./useInventoryData";
|
import { useInventoryData, projName } from "./useInventoryData";
|
||||||
@@ -98,8 +99,12 @@ export function WarehousesPage() {
|
|||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<Header
|
<Header
|
||||||
title="انبارها"
|
title="انبارها"
|
||||||
sub={user?.project_name || ""}
|
sub={inventorySubheader(user)}
|
||||||
actions={<InventoryTopNav pathname={location.pathname} onLogout={logout} />}
|
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">
|
<PageLayout className="flex flex-col gap-3">
|
||||||
{isPM && (
|
{isPM && (
|
||||||
|
|||||||
@@ -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[] {
|
export function accountMenuItems(onLogout?: () => void): MenuItem[] {
|
||||||
@@ -20,6 +37,16 @@ export function accountMenuItems(onLogout?: () => void): MenuItem[] {
|
|||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// منوی همبرگری مدیر پروژه در پنل انبار — فقط «خروج از حساب»
|
||||||
|
// (مدیر پروژه به دفتر تدارکات دسترسی ندارد و دکمهی خروجِ تکی هم حذف شده).
|
||||||
|
export function pmMenuItems(onLogout?: () => void): MenuItem[] {
|
||||||
|
const items: MenuItem[] = [];
|
||||||
|
if (onLogout) {
|
||||||
|
items.push({ label: "خروج از حساب", onClick: onLogout });
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
// آیتمهای منوی همبرگری پنل مدیریت انبار — با نشانگر پنل فعلی + «بازگشت به دفتر» + خروج
|
// آیتمهای منوی همبرگری پنل مدیریت انبار — با نشانگر پنل فعلی + «بازگشت به دفتر» + خروج
|
||||||
//
|
//
|
||||||
// دسترسیها (v2.6):
|
// دسترسیها (v2.6):
|
||||||
@@ -44,8 +71,10 @@ export function panelMenuItems(pathname: string, role: string | null | undefined
|
|||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
items.push({ label: "سازماندهی", to: "/inventory/org", active: pathname.startsWith("/inventory/org") });
|
items.push({ label: "سازماندهی", to: "/inventory/org", active: pathname.startsWith("/inventory/org") });
|
||||||
}
|
}
|
||||||
items.push({ divider: true });
|
if (isAdmin) {
|
||||||
items.push({ label: "بازگشت به دفتر", to: "/" });
|
items.push({ divider: true });
|
||||||
|
items.push({ label: "بازگشت به دفتر", to: "/" });
|
||||||
|
}
|
||||||
if (onLogout) {
|
if (onLogout) {
|
||||||
items.push({ divider: true });
|
items.push({ divider: true });
|
||||||
items.push({ label: "خروج از حساب", onClick: onLogout });
|
items.push({ label: "خروج از حساب", onClick: onLogout });
|
||||||
|
|||||||
@@ -113,6 +113,9 @@ button:disabled {
|
|||||||
.fb-conv {
|
.fb-conv {
|
||||||
@apply mt-0.5 flex items-center gap-1 text-[10.5px] text-muted;
|
@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 {
|
.fb-inv {
|
||||||
@apply mt-0.5 inline-flex items-center gap-1 text-[11px] text-accent underline underline-offset-2;
|
@apply mt-0.5 inline-flex items-center gap-1 text-[11px] text-accent underline underline-offset-2;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user