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

- header: icon box + dynamic subheader '[user] | [role] – [project/همه پروژهها]'
  on every warehouse page; module context kept in the hamburger (items 1-2)
- project manager view: office button + inline logout removed; hamburger
  contains only «خروج از حساب»; sub-views get «بازگشت به داشبورد» (Header
  backTo/goBack); «افزایش موجود» primary button on the dashboard (item 8)
- daily control: cells render the day-of-month (was the Jalali year),
  month nav uses real Jalali months, header is pure Jalali
  «امروز ۸ شهریور ۱۴۰۵», today gets a blue accent (item 3)
- arrivals: partial (item/qty) receipt — any valid item is enough to submit,
  ceiling + allocation-sum validation with inline errors, Received/In-Transit
  badges; ledger shows «رسیده X از Y» / «در راه Z» + «رسید جزیی» badge (item 4)
- api client: multipart uploads no longer send Content-Type json (fixes the
  invoice/proforma 400 «دادهی ارسالشده نامعتبر است»); undefined query
  params are dropped (fixes empty admin project filter, item 5)
- organization: user-creation card removed, role column read-only for
  procurement manager, Casdoor sync button (item 6)
- admin panel (/admin): Casdoor user sync + grant/revoke «مدیر تدارکات»;
  Developer Mode is a 3-step admin-gated flow on the login page (item 7)
This commit is contained in:
ali_mohaghegh
2026-09-01 17:28:19 +03:30
parent 83f4fb0747
commit c5997ac243
17 changed files with 648 additions and 228 deletions
+181
View File
@@ -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>
);
}