Warehouses now belong strictly and exclusively to the project they were created under; cross-project reassignment is removed end-to-end.
Backend: PATCH /inventory/warehouses/{id} no longer applies project_id — any attempt to move a warehouse to a different project returns 400 with «پروژهی انبار قابل تغییر نیست — انبار برای همیشه به پروژهی والدش تعلق دارد» (sending the current project stays a harmless no-op). The WarehouseUpdate schema documents project_id as accept-only-to-reject; creation still requires project_id and delete is unchanged.
Frontend: the Organization page's per-warehouse project dropdown («انتقال به پروژه دیگر») and its moveWarehouse handler are removed — warehouses render statically inside their project card with only the dashboard link and delete; the warehouses.update API type no longer accepts project_id. WarehousesPage already had no move UI (rename/describe only).
Verified end-to-end against the running stack: rename 200; same-project project_id no-op 200; cross-project reassignment 400 with the guard message; warehouse remains in its original project; test data cleaned up. python compile + api/frontend builds clean.
355 lines
18 KiB
TypeScript
355 lines
18 KiB
TypeScript
import { useCallback, useEffect, useState } from "react";
|
||
import { useNavigate } from "react-router-dom";
|
||
import { toast } from "sonner";
|
||
import { Header, PageLayout } from "../../components/layout/Header";
|
||
import { useAuthStore } from "../../store/auth";
|
||
import { inventorySubheader } from "./panelMenu";
|
||
import { apiV26, type UserRow } from "../../lib/api";
|
||
import { refreshProcurementStore } from "../../hooks";
|
||
import { useInventoryData } from "./useInventoryData";
|
||
import { faNum } from "../../lib/formatters";
|
||
import { ProjectMultiSelect } from "../../components/ProjectMultiSelect";
|
||
|
||
type Role = "pm" | "procurement_manager" | "admin" | null;
|
||
|
||
const ROLE_LABELS: Record<string, string> = {
|
||
pm: "مدیر پروژه",
|
||
procurement_manager: "مدیر تدارکات",
|
||
admin: "ادمین",
|
||
};
|
||
|
||
// سازماندهی (فقط ادمین / مدیر تدارکات) — کاربران، مدیران پروژه و انبارهای هر پروژه
|
||
export function OrganizationPage() {
|
||
const navigate = useNavigate();
|
||
const user = useAuthStore((s) => s.user);
|
||
const data = useInventoryData();
|
||
|
||
const [users, setUsers] = useState<UserRow[]>([]);
|
||
const [loadingUsers, setLoadingUsers] = useState(true);
|
||
const [syncing, setSyncing] = useState(false);
|
||
const [busyId, setBusyId] = useState<string | null>(null);
|
||
|
||
const [newWh, setNewWh] = useState<Record<string, string>>({});
|
||
const [newWhDesc, setNewWhDesc] = useState<Record<string, string>>({});
|
||
|
||
const canManage = user?.role === "admin" || user?.role === "procurement_manager";
|
||
// فقط ادمین میتواند نقشها را تغییر دهد/ارتقا دهد (RBAC) — مدیر تدارکات فقط
|
||
// پروژهی مدیران پروژه را تعیین/جابهجا میکند.
|
||
const canChangeRoles = user?.role === "admin";
|
||
|
||
const loadUsers = useCallback(async () => {
|
||
try {
|
||
const list = await apiV26.users.list();
|
||
setUsers(list);
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : "خطا در دریافت کاربران");
|
||
} finally {
|
||
setLoadingUsers(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (canManage) void loadUsers();
|
||
}, [canManage, loadUsers]);
|
||
|
||
const syncUsers = async () => {
|
||
setSyncing(true);
|
||
try {
|
||
const { users: synced, created } = await apiV26.users.sync();
|
||
setUsers(synced);
|
||
toast.success(
|
||
created > 0
|
||
? `همگامسازی انجام شد — ${faNum(created)} کاربر جدید از Casdoor اضافه شد ✓`
|
||
: "همگامسازی انجام شد — کاربر جدیدی یافت نشد",
|
||
);
|
||
await data.refresh();
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : "خطا در همگامسازی با Casdoor");
|
||
} finally {
|
||
setSyncing(false);
|
||
}
|
||
};
|
||
|
||
if (!canManage) {
|
||
return (
|
||
<div className="min-h-screen">
|
||
<Header title="سازماندهی" backTo="/inventory" backLabel="بازگشت به داشبورد" />
|
||
<PageLayout>
|
||
<div className="text-[13px] text-muted">این بخش فقط برای ادمین / مدیر تدارکات است.</div>
|
||
</PageLayout>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ---------- actions: users ----------
|
||
|
||
const changeRole = async (u: UserRow, role: Role) => {
|
||
setBusyId(u.id);
|
||
try {
|
||
await apiV26.users.updateRole(u.id, role);
|
||
toast.success(`نقش «${ROLE_LABELS[role ?? ""] ?? "بدون نقش"}» برای ${u.first_name} ${u.last_name} ثبت شد ✓`);
|
||
await loadUsers();
|
||
refreshProcurementStore();
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : "خطا در تغییر نقش");
|
||
} finally {
|
||
setBusyId(null);
|
||
}
|
||
};
|
||
|
||
// پروژههای متصل به یک کاربر (1:N) — با پشتیبانی از شکل قدیمیِ تکپروژهای
|
||
const assignedProjectsOf = (u: UserRow) =>
|
||
u.projects ?? (u.project_id ? [{ id: u.project_id, name: u.project_name ?? "" }] : []);
|
||
|
||
// همگامسازی چند-انتخابیِ پروژهها: افزودن/حذف هر اختلاف با یک PATCH
|
||
// (pm_user_id = کاربر برای اتصال، null برای جداسازی؛ مدیر قبلی جایگزین میشود)
|
||
const syncUserProjects = async (u: UserRow, nextIds: string[]) => {
|
||
const current = assignedProjectsOf(u).map((p) => p.id);
|
||
const added = nextIds.filter((id) => !current.includes(id));
|
||
const removed = current.filter((id) => !nextIds.includes(id));
|
||
if (!added.length && !removed.length) return;
|
||
setBusyId(u.id);
|
||
try {
|
||
for (const pid of added) await apiV26.projects.setPm(pid, u.id);
|
||
for (const pid of removed) await apiV26.projects.setPm(pid, null);
|
||
const names = nextIds
|
||
.map((id) => data.lookup.projects.find((p) => p.id === id)?.name)
|
||
.filter(Boolean)
|
||
.join("، ");
|
||
toast.success(
|
||
nextIds.length
|
||
? `پروژههای «${u.first_name} ${u.last_name}» بهروزرسانی شد: ${names} ✓`
|
||
: `«${u.first_name} ${u.last_name}» از همهی پروژهها جدا شد`,
|
||
);
|
||
await loadUsers();
|
||
await data.refresh();
|
||
refreshProcurementStore();
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : "خطا در بهروزرسانی پروژهها");
|
||
await loadUsers(); // وضعیت واقعی سرور را برگردان
|
||
} finally {
|
||
setBusyId(null);
|
||
}
|
||
};
|
||
|
||
// ---------- actions: warehouses ----------
|
||
|
||
const addWarehouse = async (projectId: string) => {
|
||
const name = (newWh[projectId] || "").trim();
|
||
if (!name) return toast.error("نام انبار الزامی است");
|
||
try {
|
||
await apiV26.warehouses.create({ project_id: projectId, name, description: (newWhDesc[projectId] || "").trim() || null });
|
||
setNewWh((s) => ({ ...s, [projectId]: "" }));
|
||
setNewWhDesc((s) => ({ ...s, [projectId]: "" }));
|
||
toast.success(`انبار «${name}» اضافه شد ✓`);
|
||
await data.refresh();
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : "خطا در افزودن انبار");
|
||
}
|
||
};
|
||
|
||
// انبار برای همیشه به پروژهی والدش تعلق دارد — انتقال به پروژهی دیگر وجود ندارد؛
|
||
// انبار فقط با حذف و ساخت دوباره زیر پروژهی موردنظر جابهجا میشود.
|
||
const removeWarehouse = async (id: string) => {
|
||
if (!confirm("انبار حذف شود؟ (انبارهای دارای سابقه تراکنش قابل حذف نیستند)")) return;
|
||
try {
|
||
await apiV26.warehouses.delete(id);
|
||
toast.success("انبار حذف شد");
|
||
await data.refresh();
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : "خطا در حذف انبار");
|
||
}
|
||
};
|
||
|
||
// مدیرِ مسئول یک پروژه (1:N — کاربر ممکن است مدیر چند پروژه باشد)
|
||
const pmOf = (projectId: string) =>
|
||
users.find(
|
||
(u) =>
|
||
u.role === "pm" &&
|
||
(u.projects?.some((p) => p.id === projectId) || u.project_id === projectId),
|
||
) || null;
|
||
|
||
return (
|
||
<div className="min-h-screen">
|
||
<Header
|
||
title="سازماندهی"
|
||
sub={inventorySubheader(user)}
|
||
backTo="/inventory"
|
||
backLabel="بازگشت به داشبورد"
|
||
/>
|
||
<PageLayout className="flex flex-col gap-3">
|
||
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted">
|
||
ℹ️ کاربران از Casdoor همگام میشوند و نقشها اینجا (فقط توسط ادمین) تعیین میشوند؛
|
||
مدیر تدارکات فقط پروژهی مدیران پروژه را تعیین/جابهجا میکند. پروژهها از سیستم تدارکات میآیند و
|
||
هر مدیر پروژه به همه انبارهای پروژه خودش دسترسی دارد. همه تغییرات فوراً در داشبورد و فرمها اعمال میشود.
|
||
</div>
|
||
|
||
{/* ---------- کاربران و مدیران پروژه ---------- */}
|
||
<div className="rounded-xl border border-line bg-panel p-4">
|
||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||
<h3 className="text-[14px] font-bold">👥 کاربران و مدیران پروژه</h3>
|
||
<button
|
||
onClick={() => void syncUsers()}
|
||
disabled={syncing}
|
||
className="rounded-lg border border-accent/40 bg-accent/5 px-3 py-1.5 text-[12px] font-bold text-accent disabled:opacity-50"
|
||
>
|
||
{syncing ? "در حال همگامسازی…" : "🔄 همگامسازی از Casdoor"}
|
||
</button>
|
||
</div>
|
||
|
||
{loadingUsers ? (
|
||
<div className="py-3 text-[12.5px] text-muted">در حال بارگذاری کاربران…</div>
|
||
) : !users.length ? (
|
||
<div className="rounded-lg border border-dashed border-line px-3 py-3 text-[12px] text-muted">
|
||
کاربری ثبت نشده است — کاربران پس از اولین ورود (Casdoor) بهصورت خودکار ساخته میشوند؛
|
||
یا دکمهی «همگامسازی از Casdoor» را بزنید.
|
||
</div>
|
||
) : (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-[12.5px]">
|
||
<thead>
|
||
<tr className="border-b border-line text-right text-muted">
|
||
<th className="py-2 pe-3">نام</th>
|
||
<th className="py-2 pe-3">نقش</th>
|
||
<th className="py-2 pe-3">پروژه (مدیر پروژه)</th>
|
||
<th className="py-2">عملیات</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{users.map((u) => (
|
||
<tr key={u.id} className="border-b border-line/50">
|
||
<td className="py-1.5 pe-3">
|
||
<b>{u.first_name} {u.last_name}</b>
|
||
<small className="block text-[10.5px] text-muted ltr">{u.casdoor_id}</small>
|
||
</td>
|
||
<td className="py-1.5 pe-3">
|
||
{canChangeRoles ? (
|
||
<select
|
||
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px]"
|
||
value={u.role ?? ""}
|
||
disabled={busyId === u.id}
|
||
onChange={(e) => void changeRole(u, (e.target.value || null) as Role)}
|
||
>
|
||
<option value="">— بدون نقش —</option>
|
||
<option value="pm">مدیر پروژه</option>
|
||
<option value="procurement_manager">مدیر تدارکات</option>
|
||
<option value="admin">ادمین</option>
|
||
</select>
|
||
) : (
|
||
<span className={`rounded-lg px-2 py-1 text-[11px] font-bold ${u.role ? "bg-accent-bg text-accent" : "bg-slate-bg text-muted"}`}>
|
||
{u.role ? ROLE_LABELS[u.role] : "بدون نقش"}
|
||
</span>
|
||
)}
|
||
{!canChangeRoles && u.role && (
|
||
<small className="block text-[10px] text-muted">(تغییر نقش فقط توسط ادمین)</small>
|
||
)}
|
||
</td>
|
||
<td className="min-w-[240px] py-1.5 pe-3 align-top">
|
||
{u.role === "pm" ? (
|
||
<ProjectMultiSelect
|
||
options={data.lookup.projects.map((p) => ({ id: p.id, name: p.name }))}
|
||
values={assignedProjectsOf(u).map((p) => p.id)}
|
||
onChange={(ids) => void syncUserProjects(u, ids)}
|
||
disabled={busyId === u.id}
|
||
/>
|
||
) : (
|
||
<span className="text-[11.5px] text-muted">—</span>
|
||
)}
|
||
</td>
|
||
<td className="py-1.5">
|
||
{u.role === "pm" && u.project_id && (
|
||
<span className="rounded-lg bg-green/10 px-2 py-1 text-[10.5px] font-bold text-green">فعال</span>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
<div className="mt-1 text-[11px] text-muted">
|
||
«بدون نقش» = حذف مدیر پروژه (از همهی پروژهها هم جدا میشود). هر مدیر پروژه میتواند به
|
||
چند پروژه متصل باشد؛ حذف هر چیپ (×)، فقط همان پروژه را جدا میکند و اتصال به یک پروژه، مدیرِ
|
||
قبلیِ آن پروژه را جابهجا میکند.
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* ---------- پروژهها و انبارها ---------- */}
|
||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||
{data.lookup.projects.map((p) => {
|
||
const whs = data.warehouses.filter((w) => w.project_id === p.id);
|
||
const pm = pmOf(p.id);
|
||
return (
|
||
<div key={p.id} className="rounded-xl border border-line bg-panel p-4">
|
||
<div className="mb-2 flex items-center justify-between">
|
||
<h3 className="text-[14px] font-bold">🏢 {p.name}</h3>
|
||
<span className="rounded-lg bg-panel px-2 py-1 text-[11px] text-muted">
|
||
{faNum(whs.length)} انبار
|
||
</span>
|
||
</div>
|
||
|
||
<div className="mb-3">
|
||
<h4 className="mb-1 text-[12px] font-bold text-muted">👤 مدیر پروژه</h4>
|
||
{pm ? (
|
||
<div className="flex items-center gap-2 rounded-lg border border-line px-3 py-2 text-[12.5px]">
|
||
<span className="font-semibold">{pm.first_name} {pm.last_name}</span>
|
||
<span className="text-[10.5px] text-muted">(مدیر مسئول پروژه)</span>
|
||
</div>
|
||
) : (
|
||
<div className="rounded-lg border border-dashed border-line px-3 py-2 text-[11.5px] text-muted">
|
||
مدیری تعیین نشده است — از جدول «کاربران و مدیران پروژه» بالا، نقش مدیر پروژه را بدهید و به این پروژه متصل کنید.
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<h4 className="mb-1 text-[12px] font-bold text-muted">🏬 انبارهای پروژه</h4>
|
||
{!whs.length ? (
|
||
<div className="rounded-lg border border-dashed border-line px-3 py-2 text-[11.5px] text-muted">انباری ندارد — از فرم زیر اضافه کنید.</div>
|
||
) : (
|
||
<div className="mb-2 flex flex-col gap-1.5">
|
||
{whs.map((w) => (
|
||
<div key={w.id} className="flex items-center justify-between gap-2 rounded-lg border border-line px-3 py-2 text-[12.5px]">
|
||
<div className="min-w-0 flex-1">
|
||
<b>{w.name}</b>
|
||
<small className="block truncate text-muted">{w.description || ""}</small>
|
||
</div>
|
||
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
||
<button
|
||
className="rounded-lg border border-accent/40 bg-accent/5 px-2 py-1 text-[10.5px] font-bold text-accent"
|
||
onClick={() => navigate(`/inventory?warehouse=${w.id}`)}
|
||
title="داشبورد این انبار"
|
||
>
|
||
📊 داشبورد
|
||
</button>
|
||
<button className="text-muted hover:text-red" onClick={() => void removeWarehouse(w.id)} title="حذف انبار">✕</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||
<input
|
||
className="h-8 w-full rounded-lg border border-line bg-panel px-2 text-sm sm:w-44"
|
||
placeholder="نام انبار جدید"
|
||
value={newWh[p.id] || ""}
|
||
onChange={(e) => setNewWh((s) => ({ ...s, [p.id]: e.target.value }))}
|
||
/>
|
||
<input
|
||
className="h-8 w-full rounded-lg border border-line bg-panel px-2 text-sm sm:w-52"
|
||
placeholder="توضیحات (اختیاری)"
|
||
value={newWhDesc[p.id] || ""}
|
||
onChange={(e) => setNewWhDesc((s) => ({ ...s, [p.id]: e.target.value }))}
|
||
/>
|
||
<button className="w-full rounded-lg bg-accent px-3 py-2 text-[12px] font-bold text-white sm:w-auto sm:py-1.5" onClick={() => void addWarehouse(p.id)}>
|
||
+ افزودن انبار
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</PageLayout>
|
||
</div>
|
||
);
|
||
}
|