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:
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { Warehouse as WarehouseIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { EmptyState } from "../../components/EmptyState";
|
||||
@@ -7,7 +8,7 @@ import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { PanelMenu } from "../../components/PanelMenu";
|
||||
import { panelMenuItems, accountMenuItems, useLogout } from "./panelMenu";
|
||||
import { accountMenuItems, inventorySubheader, panelMenuItems, pmMenuItems, useLogout } from "./panelMenu";
|
||||
import { InventoryTopNav } from "./InventoryTopNav";
|
||||
import { apiV26 } from "../../lib/api";
|
||||
import type { PendingArrival, Warehouse } from "../../lib/types";
|
||||
@@ -121,53 +122,81 @@ export function ArrivalsPage() {
|
||||
return { ...d, [decisionId]: { ...cur, allocs: cur.allocs.filter((a) => a.key !== key) } };
|
||||
});
|
||||
|
||||
const remainingOf = (it: PendingArrival): number => {
|
||||
const d = draftOf(it);
|
||||
const received = parseFloat(d.received) || 0;
|
||||
const allocated = d.allocs.reduce((s, a) => s + ((parseFloat(a.qty) || 0) > 0 ? parseFloat(a.qty) || 0 : 0), 0);
|
||||
return Math.round((received - allocated) * 10000) / 10000;
|
||||
};
|
||||
const round4 = (n: number) => Math.round(n * 10000) / 10000;
|
||||
|
||||
const itemReady = (it: PendingArrival): boolean => {
|
||||
// ماندهی واقعیِ قابلدریافت برای این قلم: سفارش − قبلاًرسیده (− پیشنویس فعلی)
|
||||
const remainingToReceive = (it: PendingArrival): number =>
|
||||
round4(parseFloat(it.ordered_qty) - parseFloat(it.received_qty));
|
||||
|
||||
const draftReceivedOf = (it: PendingArrival): number => parseFloat(draftOf(it).received) || 0;
|
||||
|
||||
const allocSumOf = (it: PendingArrival): number =>
|
||||
round4(draftOf(it).allocs.reduce((s, a) => s + ((parseFloat(a.qty) || 0) > 0 ? parseFloat(a.qty) || 0 : 0), 0));
|
||||
|
||||
// یک قلم برای ثبت «رسید جزیی» آماده است: تعداد > ۰، داخل سقف باقیمانده،
|
||||
// ردیفهای تخصیص معتبر و مجموع تخصیص == تعداد رسیده.
|
||||
const itemSubmittable = (it: PendingArrival): boolean => {
|
||||
const d = draftOf(it);
|
||||
const received = parseFloat(d.received);
|
||||
const received = draftReceivedOf(it);
|
||||
if (!(received > 0)) return false;
|
||||
// محدودیت: تعداد رسیده نمیتواند از تعداد سفارششده بیشتر باشد
|
||||
if (received > parseFloat(it.ordered_qty)) return false;
|
||||
if (d.allocs.some((a) => !a.whId)) return false;
|
||||
return remainingOf(it) === 0;
|
||||
if (received > remainingToReceive(it)) return false; // سقف: بیش از ماندهی سفارش نه
|
||||
if (d.allocs.some((a) => (parseFloat(a.qty) || 0) > 0 && !a.whId)) return false;
|
||||
return allocSumOf(it) === received;
|
||||
};
|
||||
|
||||
const orderReady = (quoteId: string): boolean => {
|
||||
const its = orderItems(quoteId);
|
||||
return its.length > 0 && its.every((it) => it.state === "arrived" || itemReady(it));
|
||||
const itemHasErrors = (it: PendingArrival): { ceiling: boolean; sumMismatch: boolean; emptyWh: boolean } => {
|
||||
const d = draftOf(it);
|
||||
const received = draftReceivedOf(it);
|
||||
return {
|
||||
ceiling: received > remainingToReceive(it),
|
||||
sumMismatch: received > 0 && allocSumOf(it) !== received,
|
||||
emptyWh: d.allocs.some((a) => (parseFloat(a.qty) || 0) > 0 && !a.whId),
|
||||
};
|
||||
};
|
||||
|
||||
// برای ثبت رسید، کافی است حداقل یک قلم مقدار معتبر داشته باشد؛
|
||||
// قلمهای بدون مقدار «در راه» میمانند (رسید جزیی مجاز است).
|
||||
const orderHasSubmittable = (quoteId: string): boolean => {
|
||||
const its = orderItems(quoteId);
|
||||
return its.length > 0 && its.some((it) => it.state !== "arrived" && itemSubmittable(it));
|
||||
};
|
||||
|
||||
const orderHasErrors = (quoteId: string): boolean =>
|
||||
orderItems(quoteId).some((it) => {
|
||||
const errs = itemHasErrors(it);
|
||||
return errs.ceiling || errs.sumMismatch || errs.emptyWh;
|
||||
});
|
||||
|
||||
// ---------- submit ----------
|
||||
|
||||
const submitOrder = async (quoteId: string) => {
|
||||
const its = orderItems(quoteId).filter((it) => it.state !== "arrived");
|
||||
if (!orderReady(quoteId)) {
|
||||
toast.error("برای هر قلم: تعداد رسیده را وارد کنید و تا صفر شدن «مانده تخصیص» ردیفها را پر کنید.");
|
||||
const submittable = its.filter(itemSubmittable);
|
||||
if (!submittable.length) {
|
||||
toast.error("حداقل یک قلم با تعداد معتبر (داخل ماندهی سفارش و تخصیصِ برابر با تعداد رسیده) وارد کنید.");
|
||||
return;
|
||||
}
|
||||
if (orderHasErrors(quoteId)) {
|
||||
toast.error("برخی قلمها خطای اعتبارسنجی دارند — ابتدا سقف رسید و مجموع تخصیص انبارها را اصلاح کنید.");
|
||||
return;
|
||||
}
|
||||
setSaving(quoteId);
|
||||
try {
|
||||
for (const it of its) {
|
||||
for (const it of submittable) {
|
||||
const d = draftOf(it);
|
||||
const allocations = d.allocs
|
||||
.filter((a) => (parseFloat(a.qty) || 0) > 0 && a.whId)
|
||||
.map((a) => ({ warehouse_id: a.whId, quantity: parseFloat(a.qty) }));
|
||||
await apiV26.pm.confirmReceipt({
|
||||
item_decision_id: it.item_decision_id,
|
||||
received_qty: parseFloat(d.received),
|
||||
received_qty: draftReceivedOf(it),
|
||||
allocations,
|
||||
});
|
||||
}
|
||||
toast.success("رسید سفارش ثبت شد و به انبارها وارد شد ✓");
|
||||
toast.success("رسید (کامل یا جزیی) ثبت شد و به انبارها وارد شد ✓ — اقلام باقیمانده همچنان در راه هستند");
|
||||
setDrafts((d) => {
|
||||
const next = { ...d };
|
||||
its.forEach((it) => delete next[it.item_decision_id]);
|
||||
submittable.forEach((it) => delete next[it.item_decision_id]);
|
||||
return next;
|
||||
});
|
||||
setExpanded((s) => {
|
||||
@@ -198,15 +227,20 @@ export function ArrivalsPage() {
|
||||
<div className="min-h-screen">
|
||||
<Header
|
||||
title="کالاهای در راه"
|
||||
sub={isPM ? user?.project_name || "" : "همه پروژهها — مدیر تدارکات"}
|
||||
menu={isAdmin ? <PanelMenu title="پنل مدیریت انبار" items={accountMenuItems(logout)} /> : undefined}
|
||||
actions={isAdmin || isPM ? <InventoryTopNav pathname={location.pathname} onLogout={logout} variant={isAdmin ? "admin" : "pm"} /> : undefined}
|
||||
sub={inventorySubheader(user)}
|
||||
backTo="/inventory"
|
||||
backLabel="بازگشت به داشبورد"
|
||||
icon={<WarehouseIcon className="h-4.5 w-4.5" strokeWidth={2} />}
|
||||
menu={<PanelMenu title="پنل مدیریت انبار" items={isAdmin ? accountMenuItems(logout) : pmMenuItems(logout)} />}
|
||||
actions={<InventoryTopNav pathname={location.pathname} variant={isAdmin ? "admin" : "pm"} />}
|
||||
/>
|
||||
<PageLayout className="flex flex-col gap-4">
|
||||
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted">
|
||||
🚚 این بخش به مرحله «رسید» سفارشهای خرید متصل است: تأیید پیشنهاد و پذیرش در سیستم تدارکات انجام شده و
|
||||
اینجا فقط رسید و تقسیم بین انبارها ثبت میشود. هر سفارش را باز کنید، تعداد واقعیِ رسیده هر قلم را وارد کنید
|
||||
و آن را بین انبارهای پروژه تقسیم کنید — تا «مانده تخصیص» به صفر برسد.
|
||||
اینجا فقط رسید و تقسیم بین انبارها ثبت میشود. <b>رسید جزیی (قلمی و مقداری) مجاز است</b> — هر سفارش را باز
|
||||
کنید، تعداد واقعیِ رسیده هر قلم را وارد کنید و آن را بین انبارهای پروژه تقسیم کنید؛ قلمهایی که مقداری برایشان
|
||||
وارد نشود در صف «در راه» میمانند. مجموع تخصیص هر قلم باید دقیقاً با تعداد رسیدهی همان قلم برابر باشد و از
|
||||
ماندهی سفارش بیشتر نشود.
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
@@ -217,7 +251,9 @@ export function ArrivalsPage() {
|
||||
orders.map(([quoteId, its]) => {
|
||||
const first = its[0];
|
||||
const open = expanded.has(quoteId);
|
||||
const ready = orderReady(quoteId);
|
||||
const hasSubmittable = orderHasSubmittable(quoteId);
|
||||
const hasErrors = orderHasErrors(quoteId);
|
||||
const anyPartial = its.some((it) => it.state === "partially_received" || draftReceivedOf(it) > 0);
|
||||
const days = orderDaysElapsed(its);
|
||||
return (
|
||||
<div key={quoteId} className="overflow-hidden rounded-xl border border-line bg-panel shadow-sm">
|
||||
@@ -227,8 +263,8 @@ export function ArrivalsPage() {
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-[14px] font-bold">🧾 {first.supplier.name}</span>
|
||||
<Badge variant="outline">{faNum(its.length)} قلم</Badge>
|
||||
<Badge variant={first.state === "partially_received" ? "offered" : "outline"}>
|
||||
{first.state === "partially_received" ? "رسید ناقص" : "در راه"}
|
||||
<Badge variant={anyPartial ? "offered" : "outline"}>
|
||||
{anyPartial ? "رسید جزیی" : "در راه"}
|
||||
</Badge>
|
||||
{days !== null && (
|
||||
<Badge variant={days >= 7 ? "offered" : "muted"}>
|
||||
@@ -251,7 +287,12 @@ export function ArrivalsPage() {
|
||||
<div className="divide-y divide-line/60">
|
||||
{its.map((it) => {
|
||||
const d = draftOf(it);
|
||||
const remaining = remainingOf(it);
|
||||
const remaining = remainingToReceive(it);
|
||||
const draftReceived = draftReceivedOf(it);
|
||||
const inTransit = round4(remaining - draftReceived);
|
||||
const allocSum = allocSumOf(it);
|
||||
const errs = itemHasErrors(it);
|
||||
const submittable = itemSubmittable(it);
|
||||
const whs = whsOfProject(it.project?.id);
|
||||
const itemDays = daysElapsed(it.decided_at);
|
||||
return (
|
||||
@@ -261,9 +302,13 @@ export function ArrivalsPage() {
|
||||
<Badge variant="outline">
|
||||
سفارش: {faNum(parseFloat(it.ordered_qty))} {it.unit?.name_fa || it.unit?.code || ""}
|
||||
</Badge>
|
||||
<Badge variant={parseFloat(it.received_qty) > 0 ? "offered" : "outline"}>
|
||||
رسیده: {faNum(parseFloat(it.received_qty))}
|
||||
<Badge variant={parseFloat(it.received_qty) > 0 || draftReceived > 0 ? "offered" : "outline"}>
|
||||
رسیده: {faNum(parseFloat(it.received_qty) + draftReceived)} / {faNum(parseFloat(it.ordered_qty))}
|
||||
</Badge>
|
||||
<Badge variant={inTransit > 0 ? "muted" : "outline"}>
|
||||
در راه: {faNum(Math.max(0, inTransit))}
|
||||
</Badge>
|
||||
{it.state === "partially_received" && <Badge variant="offered">رسید ناقص</Badge>}
|
||||
{itemDays !== null && (
|
||||
<span className="text-[11px] text-muted">روز گذشته: <b className="text-foreground">{faNum(itemDays)}</b></span>
|
||||
)}
|
||||
@@ -278,7 +323,7 @@ export function ArrivalsPage() {
|
||||
className="h-8 w-full rounded-lg border border-line bg-panel px-2 text-sm ltr"
|
||||
value={d.received}
|
||||
onChange={(e) => setReceived(it.item_decision_id, e.target.value)}
|
||||
placeholder={it.ordered_qty}
|
||||
placeholder={String(remaining)}
|
||||
/>
|
||||
</div>
|
||||
<div className="field mb-0">
|
||||
@@ -326,17 +371,22 @@ export function ArrivalsPage() {
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-[12px]">
|
||||
<span>
|
||||
مانده تخصیص:{" "}
|
||||
<b className={remaining === 0 ? "text-green" : "text-red"}>
|
||||
{faNum(remaining)} {it.unit?.name_fa || it.unit?.code || ""}
|
||||
</b>
|
||||
<span className={submittable ? "text-green" : "text-muted"}>
|
||||
{draftReceived > 0 ? (submittable ? "✓ آمادهی ثبت" : "در حال تکمیل…") : "بدون مقدار — در راه میماند"}
|
||||
</span>
|
||||
{parseFloat(d.received) > parseFloat(it.ordered_qty) && (
|
||||
<span className="text-[11px] font-bold text-red">تعداد کالای بیشتری نسبت به کالای رسیده انتخاب کردید</span>
|
||||
{errs.ceiling && (
|
||||
<span className="text-[11px] font-bold text-red">
|
||||
تعداد رسیده نمیتواند از ماندهی سفارش ({faNum(remaining)}) بیشتر باشد
|
||||
</span>
|
||||
)}
|
||||
{errs.sumMismatch && (
|
||||
<span className="text-[11px] font-bold text-red">
|
||||
مجموع تخصیص انبارها ({faNum(allocSum)}) باید با تعداد رسیده ({faNum(draftReceived)}) برابر باشد
|
||||
</span>
|
||||
)}
|
||||
{errs.emptyWh && (
|
||||
<span className="text-[11px] font-bold text-red">برای ردیفهای دارای تعداد، انبار را انتخاب کنید</span>
|
||||
)}
|
||||
{remaining < 0 && <span className="text-[11px] text-red">تخصیص بیش از تعداد رسیده است!</span>}
|
||||
{remaining > 0 && <span className="text-[11px] text-muted">با ردیفهای تخصیص، مانده را به صفر برسانید.</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -344,9 +394,9 @@ export function ArrivalsPage() {
|
||||
|
||||
<div className="flex items-center justify-between gap-2 bg-panel px-4 py-3">
|
||||
<span className="text-[11.5px] text-muted">
|
||||
برای ثبت رسید کل سفارش، همه اقلام باید «مانده تخصیص صفر» داشته باشند.
|
||||
رسید جزیی مجاز است: حداقل یک قلم با تعداد معتبر کافی است؛ قلمهای بدون مقدار در «در راه» میمانند.
|
||||
</span>
|
||||
<Button size="sm" variant="green" disabled={!ready || saving === quoteId} onClick={() => void submitOrder(quoteId)}>
|
||||
<Button size="sm" variant="green" disabled={!hasSubmittable || hasErrors || saving === quoteId} onClick={() => void submitOrder(quoteId)}>
|
||||
{saving === quoteId ? "در حال ثبت…" : "✓ ثبت رسید و ورود به انبار"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user