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:
ali_mohaghegh
2026-09-02 18:15:32 +03:30
parent ca84d8664e
commit ac76af2ffe
27 changed files with 500 additions and 217 deletions
@@ -5,6 +5,7 @@ import { Inbox, Plus } from "lucide-react";
import type { Supplier } from "../../lib/types";
import { nextId } from "../../lib/api";
import { useAddSupplier, useMaterials, useQuotes, useRemoveSupplier, useStoreData, useSuppliers } from "../../hooks";
import { useAppStore } from "../../store";
import { normalize } from "../../lib/normalize";
import { Header, PageLayout } from "../../components/layout/Header";
import { EmptyState } from "../../components/EmptyState";
@@ -23,8 +24,9 @@ export function CategoryPage() {
const quotes = useQuotes();
const addSupplier = useAddSupplier();
const removeSupplier = useRemoveSupplier();
const topics = useAppStore((s) => s.topics);
const topic = useMemo(() => topicFromParam(topicParam), [topicParam]);
const topic = useMemo(() => topicFromParam(topicParam, topics), [topicParam, topics]);
const matched = useMemo(
() => (topic ? topicCategories(topic, materials, suppliers).find((c) => normalize(c) === normalize(catParam)) : undefined),
[topic, catParam, materials, suppliers],
@@ -3,7 +3,8 @@ import { Link, useParams } from "react-router-dom";
import { MapPin, Phone, User, Plus, Package, X } from "lucide-react";
import { toast } from "sonner";
import { useMaterials, useQuotes, useStoreData, useSuppliers } from "../../hooks";
import { topicByName } from "../../lib/constants";
import { topicByName } from "../../lib/topics";
import { useAppStore } from "../../store";
import { api } from "../../lib/api";
import { Header, PageLayout } from "../../components/layout/Header";
import { EmptyState } from "../../components/EmptyState";
@@ -20,6 +21,7 @@ export function SupplierDetailPage() {
const suppliers = useSuppliers();
const quotes = useQuotes();
const materials = useMaterials();
const topics = useAppStore((s) => s.topics);
const s = useMemo(() => suppliers.find((x) => x.id === id) || null, [suppliers, id]);
const [pendingRemove, setPendingRemove] = useState<string | null>(null);
@@ -72,7 +74,7 @@ export function SupplierDetailPage() {
);
}
const topic = topicByName(s.topic);
const topic = topicByName(s.topic, topics);
const topicName = topic ? topic.name : s.topic;
const firstCat = (s.categories || [])[0] || "";
const breadcrumb = `${topicName ? topicName + " — " : ""}${firstCat ? firstCat + " — " : ""}${s.name}`;
@@ -1,24 +1,25 @@
import { useMemo } from "react";
import { useStoreData, useSuppliers } from "../../hooks";
import { useAppStore } from "../../store";
import { normalize } from "../../lib/normalize";
import { TOPICS } from "../../lib/constants";
import { Header, PageLayout } from "../../components/layout/Header";
import { TopicGrid } from "../../components/TopicGrid";
import { useNavigate } from "react-router-dom";
// صفحه‌ی تأمین‌کننده‌ها — انتخاب موضوع
// صفحه‌ی تأمین‌کننده‌ها — انتخاب موضوع (دسته‌ها از API می‌آیند)
export function SuppliersPage() {
useStoreData();
const suppliers = useSuppliers();
const topics = useAppStore((s) => s.topics);
const navigate = useNavigate();
const counts = useMemo(() => {
const out: Record<string, number> = {};
TOPICS.forEach((t) => {
topics.forEach((t) => {
out[t.name] = suppliers.filter((s) => normalize(s.topic) === normalize(t.name)).length;
});
return out;
}, [suppliers]);
}, [topics, suppliers]);
return (
<div className="min-h-screen">
@@ -30,6 +31,7 @@ export function SuppliersPage() {
/>
<PageLayout>
<TopicGrid
topics={topics}
onSelect={(topic) => navigate(`/topics/${encodeURIComponent(topic)}`)}
showCounts
counts={counts}
+3 -1
View File
@@ -5,6 +5,7 @@ import { FolderOpen, Plus } from "lucide-react";
import type { Material } from "../../lib/types";
import { nextId } from "../../lib/api";
import { useAddMaterial, useMaterials, useQuotes, useRemoveSupplier, useStoreData, useSuppliers } from "../../hooks";
import { useAppStore } from "../../store";
import { normalize } from "../../lib/normalize";
import { faNum } from "../../lib/formatters";
import { Header, PageLayout } from "../../components/layout/Header";
@@ -23,8 +24,9 @@ export function TopicPage() {
const quotes = useQuotes();
const addMaterial = useAddMaterial();
const removeSupplier = useRemoveSupplier();
const topics = useAppStore((s) => s.topics);
const topic = useMemo(() => topicFromParam(topicParam), [topicParam]);
const topic = useMemo(() => topicFromParam(topicParam, topics), [topicParam, topics]);
const [newCatName, setNewCatName] = useState("");
const cats = useMemo(
@@ -1,10 +1,8 @@
import { Link } from "react-router-dom";
import { MapPin, Phone, User, Plus } from "lucide-react";
import type { Material, Supplier } from "../../lib/types";
import type { Material, Supplier, Topic } from "../../lib/types";
import { normalize } from "../../lib/normalize";
import { topicByName } from "../../lib/constants";
// دسته‌های یک تاپیک (اتحاد materials + categories تأمین‌کننده‌ها)
import { topicByName } from "../../lib/topics";
export function topicCategories(topic: string, materials: Material[], suppliers: Supplier[]): string[] {
const out = new Set<string>();
materials
@@ -22,9 +20,9 @@ export function suppliersOfCategory(topic: string, cat: string, suppliers: Suppl
);
}
export function topicFromParam(param: string | null): string | null {
export function topicFromParam(param: string | null, topics: Topic[]): string | null {
if (!param) return null;
const t = topicByName(param);
const t = topicByName(param, topics);
return t ? t.name : null;
}
@@ -85,4 +83,4 @@ export function SupplierCard({
</div>
</div>
);
}
}