feat(frontend): fulfillment split, API-driven topics, hub-and-spoke nav, stock-count modal, FOUND ceiling, semantic colors
- bug1: quote header fulfillment counter («رسید: X از Y قلم» amber / «تکمیل شد» green / «در راه»); expanded line items visually split into the received part (رسید شده ✓ + date + target warehouse from the receipt allocations) and the in-transit remainder — quantities always sum to the original line (invariant D holds, display-only split) - bug2: 8-topic grids (suppliers/wizard/units) now come from /material-categories via the store (new lib/topics.ts; fixed the mismatched «ابزار و تجهیزات ساخت» label); suppliers topic->category resolution no longer falls back to an arbitrary category; duplicate month/status lists reuse the shared dictionaries; TopicGrid gained a loading EmptyState - bug3: hub-and-spoke — the tab strip renders only on the warehouse dashboard; sub-views hide it and navigate back via «بازگشت به داشبورد»; «افزایش موجود» became the «تغییر موجودی» entry button - bug4: stock count goes through a confirmation modal (current vs counted vs variance with کسری/مازاد badges) before posting STOCK_COUNT; numbers parsed defensively - bug5: found qty is capped client-side (red input + inline warning, submit disabled) and backend returns 422 when exceeding the missing balance; fully-recovered losses drop off the pending list - bug6+7: quantities render as absolute values with semantic color coding (emerald inflow / rose outflow / blue neutral) across dashboard timeline, daily control and the change-inventory form
This commit is contained in:
@@ -36,6 +36,7 @@ export function DashboardPage() {
|
||||
|
||||
const expandedQuoteIds = useAppStore((s) => s.expandedQuoteIds);
|
||||
const toggleExpand = useAppStore((s) => s.toggleExpand);
|
||||
const topics = useAppStore((s) => s.topics);
|
||||
|
||||
const [filters, setFilters] = useState<Filters>(EMPTY_FILTERS);
|
||||
const [sortDir, setSortDir] = useState<"desc" | "asc">("desc");
|
||||
@@ -43,8 +44,8 @@ export function DashboardPage() {
|
||||
const [editId, setEditId] = useState<string | null>(null);
|
||||
|
||||
const store = useMemo(
|
||||
() => ({ quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions }),
|
||||
[quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions],
|
||||
() => ({ quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions, topics }),
|
||||
[quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions, topics],
|
||||
);
|
||||
|
||||
// فیلتر + مرتبسازی بر اساس زمان ساخت (created_at) — فیلترها بازنشانی نمیشوند
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Combobox } from "../../components/Combobox";
|
||||
import { STATUS_LABEL, STATUS_ORDER } from "../../lib/constants";
|
||||
|
||||
export interface Filters {
|
||||
category: string;
|
||||
@@ -7,6 +8,9 @@ export interface Filters {
|
||||
search: string;
|
||||
}
|
||||
|
||||
// لیست وضعیتها از dictionary واحد (constants) — نه آرایهی تکراری
|
||||
const STATUS_ITEMS = STATUS_ORDER.map((s) => STATUS_LABEL[s]);
|
||||
|
||||
export interface FilterToolbarProps {
|
||||
filters: Filters;
|
||||
onChange: (f: Filters) => void;
|
||||
@@ -49,7 +53,7 @@ export function FilterToolbar({ filters, onChange, categories, suppliers, sortDi
|
||||
<Combobox
|
||||
value={filters.status}
|
||||
onChange={(it: { id: string; label: string }) => set({ status: it.label })}
|
||||
items={["پیشنهاد شده", "پذیرفته شده", "رسیده"]}
|
||||
items={STATUS_ITEMS}
|
||||
allowAdd={false}
|
||||
placeholder="همه"
|
||||
onInputChange={(v) => set({ status: v })}
|
||||
|
||||
@@ -65,7 +65,36 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
||||
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;
|
||||
const fulfilledItems = activeItems.filter(
|
||||
(li) => li.quantity != null && li.quantity > 0 && (li.received_qty || 0) >= li.quantity,
|
||||
).length;
|
||||
const anyReceived = receivedSum > 0;
|
||||
const fullyDelivered = activeItems.length > 0 && fulfilledItems === activeItems.length && anyReceived;
|
||||
|
||||
// نشان پیشرفت تکمیل در هدر کارت (جایگزین نشان وضعیت در حالت پذیرش/رسید):
|
||||
// در راه (آبی) → رسید: X از Y قلم (کهربایی) → تکمیل شد (سبز)
|
||||
let fulfillmentBadge: React.ReactNode = null;
|
||||
if (q.status === "accepted" || q.status === "arrived") {
|
||||
if (fullyDelivered || q.status === "arrived") {
|
||||
fulfillmentBadge = (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-emerald-200 bg-emerald-50 px-2 py-0.5 text-[11px] font-bold text-emerald-700">
|
||||
✓ تکمیل شد
|
||||
</span>
|
||||
);
|
||||
} else if (anyReceived) {
|
||||
fulfillmentBadge = (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-[11px] font-bold text-amber-700">
|
||||
رسید: {fmtNum(fulfilledItems)} از {fmtNum(activeItems.length)} قلم
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
fulfillmentBadge = (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-blue-200 bg-blue-50 px-2 py-0.5 text-[11px] font-bold text-blue-700">
|
||||
⏳ در راه
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let arrivedCell: React.ReactNode;
|
||||
if (q.status === "accepted") {
|
||||
@@ -109,8 +138,9 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
||||
{sup.location && <div className="truncate text-[11px] text-muted">{sup.location}</div>}
|
||||
</div>
|
||||
<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>
|
||||
{partiallyReceived && <Badge variant="offered">رسید جزیی</Badge>}
|
||||
{fulfillmentBadge ?? (
|
||||
<Badge variant={q.status as "offered" | "accepted" | "arrived"}>{STATUS_LABEL[q.status]}</Badge>
|
||||
)}
|
||||
<button className="chip" onClick={onEdit} title="ویرایش پیشنهاد">
|
||||
<Pencil className="h-3 w-3" />
|
||||
ویرایش
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
refConverted,
|
||||
specsLabel,
|
||||
} from "../../lib/quoteUtils";
|
||||
import { fmtDate, fmtNum } from "../../lib/formatters";
|
||||
import { fmtDate, fmtDateTime, fmtNum } from "../../lib/formatters";
|
||||
import { groupCommas } from "../../lib/numutil";
|
||||
import { FlowDiagram, type FlowRefs } from "./FlowDiagram";
|
||||
import { itemProjects } from "../../lib/quoteUtils";
|
||||
@@ -69,6 +69,15 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
|
||||
const projs = itemProjects(q, li);
|
||||
const conv = isRejected ? null : refConverted(store, li);
|
||||
const specText = specsLabel(materialSpecs(m));
|
||||
|
||||
// رسید جزیی → قلم به دو بخش «رسید شده» و «در راه» شکسته میشود (نمایشی):
|
||||
// مجموع دو بخش همیشه با تعداد/مبلغ اصلی برابر است (عدم تغییر جمع کل پیشنهاد).
|
||||
const qty = li.quantity ?? 0;
|
||||
const recv = !isRejected ? li.received_qty || 0 : 0;
|
||||
const remaining = Math.max(0, qty - recv);
|
||||
const whNames = (li.received_warehouses || []).map((w) => w.name);
|
||||
const showSplit = !isRejected && q.status !== "offered" && qty > 0;
|
||||
const fullyReceived = showSplit && recv >= qty;
|
||||
return (
|
||||
<div
|
||||
key={li.id}
|
||||
@@ -81,27 +90,47 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
|
||||
flowRefs.items.delete(li.id);
|
||||
}
|
||||
}}
|
||||
className={`flow-box flow-item${isRejected ? " rejected" : ""}${li.arrived_date ? " deal" : ""}`}
|
||||
className={`flow-box flow-item${isRejected ? " rejected" : ""}${li.arrived_date ? " deal" : ""}${
|
||||
fullyReceived ? " fb-fully-received" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="fb-name">
|
||||
<Package className="me-1 inline h-3.5 w-3.5 text-muted" />
|
||||
<Package className="me-1 inline h-3 w-3 text-muted" />
|
||||
{m.category} {m.brand}
|
||||
{specText ? ` · ${specText}` : ""}
|
||||
</div>
|
||||
<div className="fb-meta">
|
||||
<Building2 className="me-1 inline h-3 w-3" /> {projs.length ? projs.join("، ") : "—"}
|
||||
</div>
|
||||
<div className="fb-nums">
|
||||
<span>
|
||||
{fmtNum(li.quantity)} {li.unit || ""}
|
||||
</span>
|
||||
<span>× {groupCommas(li.unit_price, true)}</span>
|
||||
<span className="fb-total">= {total != null ? groupCommas(total, true) + " تومان" : "—"}</span>
|
||||
</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))}
|
||||
{showSplit ? (
|
||||
<div className="fb-nums">
|
||||
{recv > 0 && (
|
||||
<span className="pill-recvd">
|
||||
رسید شده ✓ {fmtNum(recv)} {li.unit || ""}
|
||||
</span>
|
||||
)}
|
||||
{remaining > 0 && (
|
||||
<span className="pill-pend">
|
||||
در راه ⏳ {fmtNum(remaining)} {li.unit || ""}
|
||||
</span>
|
||||
)}
|
||||
<span>× {groupCommas(li.unit_price, true)}</span>
|
||||
<span className="fb-total">= {total != null ? groupCommas(total, true) + " تومان" : "—"}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="fb-nums">
|
||||
<span>
|
||||
{fmtNum(li.quantity)} {li.unit || ""}
|
||||
</span>
|
||||
<span>× {groupCommas(li.unit_price, true)}</span>
|
||||
<span className="fb-total">= {total != null ? groupCommas(total, true) + " تومان" : "—"}</span>
|
||||
</div>
|
||||
)}
|
||||
{showSplit && recv > 0 && (
|
||||
<div className="fb-receipt-meta">
|
||||
<span className="pill-recvd-mini">✓ {fmtNum(recv)} {li.unit || ""}</span>
|
||||
{li.arrived_date && <span>تاریخ رسید: {fmtDateTime(li.arrived_date)}</span>}
|
||||
{whNames.length > 0 && <span>به انبار: {whNames.join("، ")}</span>}
|
||||
</div>
|
||||
)}
|
||||
{conv && (
|
||||
|
||||
Reference in New Issue
Block a user