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
+21 -29
View File
@@ -13,6 +13,7 @@
// ============================================================
import { normalize } from "./normalize";
import { buildTopics } from "./topics";
import { useAuthStore } from "../store/auth";
import type {
Attachment,
@@ -279,6 +280,7 @@ interface RawReceipt {
received_qty: number;
received_at?: string | null;
on_time?: boolean | null;
allocations?: { warehouse_id: string; warehouse_name: string; quantity: number }[] | null;
}
interface RawLineItem {
id: string;
@@ -378,6 +380,7 @@ interface Lookup {
categoryIdToSlug: Map<string, string>;
categoryIdToName: Map<string, string>;
categoryNameToId: Map<string, string>; // کلید: نام نرمال‌شده
categorySlugToName: Map<string, string>; // slug → نام دسته (تاپیک)
brandNameToId: Map<string, string>; // کلید: نام نرمال‌شده
}
@@ -396,54 +399,35 @@ function buildLookup(units: RawUnit[], categories: RawMaterialCategory[]): Looku
const categoryIdToSlug = new Map<string, string>();
const categoryIdToName = new Map<string, string>();
const categoryNameToId = new Map<string, string>();
const categorySlugToName = new Map<string, string>();
for (const c of categories) {
categoryIdToSlug.set(c.id, c.slug);
categoryIdToName.set(c.id, c.name);
categoryNameToId.set(norm(c.name), c.id);
categorySlugToName.set(c.slug, c.name);
}
return { units, categories, unitIdToName, unitNameToId, categoryIdToSlug, categoryIdToName, categoryNameToId, brandNameToId: new Map() };
return { units, categories, unitIdToName, unitNameToId, categoryIdToSlug, categoryIdToName, categoryNameToId, categorySlugToName, brandNameToId: new Map() };
}
// ============================================================
// Adapters — بک‌اند → تایپ‌های جلوی برنامه
// ============================================================
// ۸ تاپیک ثابت جلوی برنامه ← از slug دسته‌بندی بک‌اند
const SLUG_TO_TOPIC: Record<string, string> = {
cement: "مصالح ساختمانی",
rebar: "فلزات و مقاطع فولادی",
"tile-ceramic": "نازک‌کاری و دکوراسیون داخلی",
paint: "نازک‌کاری و دکوراسیون داخلی",
"pipe-fitting": "تاسیسات مکانیکی و آبی",
"electrical-cable": "تجهیزات و لوازم برقی",
"electrical-panel": "تجهیزات و لوازم برقی",
insulation: "مواد شیمیایی ساختمانی",
drywall: "نازک‌کاری و دکوراسیون داخلی",
"concrete-additive": "مواد شیمیایی ساختمانی",
// v2.6 backend: دسته‌های ۸گانه‌ی ثابت (تاپیک = دسته)
"mechanical-installations-pipes": "تاسیسات مکانیکی و آبی",
"electrical-equipment-supplies": "تجهیزات و لوازم برقی",
"doors-windows-glass": "درب، پنجره و شیشه",
"metals-steel-sections": "فلزات و مقاطع فولادی",
"construction-materials": "مصالح ساختمانی",
"construction-chemicals": "مواد شیمیایی ساختمانی",
"finishing-interior-decoration": "نازک‌کاری و دکوراسیون داخلی",
"construction-tools-equipment": "ابزار و تجهیزات کارگاهی",
};
// v2.6 backend: بک‌اند برای ساخت تأمین‌کننده‌ی جدید category_id الزامی دارد.
// از روی تاپیکِ انتخاب‌شده، دسته‌ی متناظر پیدا می‌شود (تاپیک = دسته).
let _categoryIdCache: { slug: string; id: string }[] | null = null;
// دسته از روی نام تاپیکِ انتخاب‌شده پیدا می‌شود (تاپیک = دسته؛ نام‌ها از همان
// منبع /material-categories می‌آیند، پس مقایسه روی نام نرمال‌شده دقیق است).
let _categoryIdCache: { name: string; id: string }[] | null = null;
async function categoryIdForTopic(topic: string): Promise<string> {
if (!_categoryIdCache) {
try {
const cats = await getData<RawMaterialCategory[]>(`/material-categories?page_size=100`);
_categoryIdCache = cats.map((c) => ({ slug: c.slug, id: c.id }));
_categoryIdCache = cats.map((c) => ({ name: c.name, id: c.id }));
} catch {
_categoryIdCache = [];
}
}
const match = _categoryIdCache.find((c) => SLUG_TO_TOPIC[c.slug] === topic) || _categoryIdCache[0];
const wanted = norm(topic);
const match = _categoryIdCache.find((c) => norm(c.name) === wanted);
return match ? match.id : "";
}
@@ -452,7 +436,9 @@ function adaptMaterial(raw: RawMaterial, lookup: Lookup): Material {
id: raw.id,
name: raw.name,
// تاپیک ذخیره‌شده در بک‌اند اولویت دارد؛ وگرنه از slug دسته استنتاج می‌شود
topic: raw.topic || (raw.category && SLUG_TO_TOPIC[raw.category.slug]) || "",
topic:
raw.topic ||
(raw.category?.slug ? lookup.categorySlugToName.get(raw.category.slug) || "" : ""),
category: raw.category?.name || "",
brand: raw.brands?.[0]?.name || "",
// مشخصه‌ها از attribute های متریال خوانده می‌شوند (EAV)
@@ -481,6 +467,11 @@ function adaptLineItem(raw: RawLineItem): Quote["line_items"][number] {
rejected: raw.decision?.status === "rejected",
arrived_date: raw.receipt?.received_at ?? null,
received_qty: raw.receipt?.received_qty ?? null,
received_warehouses: (raw.receipt?.allocations || []).map((a) => ({
id: a.warehouse_id,
name: a.warehouse_name,
quantity: a.quantity,
})),
projects: (raw.projects || []).map((p) => ({ id: p.id, name: p.name })),
};
}
@@ -687,6 +678,7 @@ export const api = {
categoryUnits: cruRes.map(adaptCategoryUnit),
unitConversions: ucRes.map((uc) => adaptUnitConversion(uc, lookup)),
standardConversions: scuRes.map(adaptStandardConversion),
topics: buildTopics(categoriesRes),
};
},
+2 -34
View File
@@ -1,37 +1,8 @@
// ============================================================
// ثابت‌های سراسری
// ثابت‌های سراسری — برچسب‌های UI ثابت (وضعیت‌ها و ...)
// تاپیک‌ها/دسته‌ها اینجا نیستند — از API می‌آیند (lib/topics.ts)
// ============================================================
import { normalize } from "./normalize";
export interface Topic {
key: string;
name: string;
icon: string;
}
// ۸ تاپیک ثابت — HVAC و لوله‌کشی ادغام شدند؛ ابزار و تجهیزات ساخت اضافه شد
export const TOPICS: Topic[] = [
{ key: "hvac-plumb", name: "تاسیسات مکانیکی و آبی", icon: "/icons/topic-1.png" },
{ key: "electric", name: "تجهیزات و لوازم برقی", icon: "/icons/topic-2.png" },
{ key: "door-win", name: "درب، پنجره و شیشه", icon: "/icons/topic-3.png" },
{ key: "metal", name: "فلزات و مقاطع فولادی", icon: "/icons/topic-4.png" },
{ key: "build-mat", name: "مصالح ساختمانی", icon: "/icons/topic-6.png" },
{ key: "chem", name: "مواد شیمیایی ساختمانی", icon: "/icons/topic-7.png" },
{ key: "finish", name: "نازک‌کاری و دکوراسیون داخلی", icon: "/icons/topic-8.png" },
{ key: "tools", name: "ابزار و تجهیزات ساخت", icon: "/icons/topic-9.png" },
];
function topicEq(a: string, b: string): boolean {
return normalize(a) === normalize(b);
}
// یافتن تاپیک بر اساس نام (نرمال‌شده)
export function topicByName(name: string | null | undefined): Topic | null {
if (!name) return null;
return TOPICS.find((t) => topicEq(t.name, name)) || null;
}
export const STATUS_LABEL: Record<string, string> = {
offered: "پیشنهاد شده",
accepted: "پذیرفته شده",
@@ -47,6 +18,3 @@ export function statusOptionsFor(current: string): string[] {
const idx = STATUS_ORDER.indexOf(current);
return idx < 0 ? STATUS_ORDER : STATUS_ORDER.slice(0, idx + 1);
}
// واحدهای شناخته‌شده از بک‌اند خوانده می‌شوند — این لیست حذف شد
// (واحدها فقط در دیتابیس تعریف می‌شوند و واحد جدید هنگام تایپ ساخته می‌شود)
+32
View File
@@ -0,0 +1,32 @@
// ============================================================
// تاپیک‌ها (دسته‌بندی‌های ۸گانه) — دیگر هاردکد نیستند؛ از
// /material-categories بک‌اند می‌آیند (listAll → store.topics).
// فقط نقشه‌ی slug → آیکون اینجا ثابت است (UI chrome).
// ============================================================
import { normalize } from "./normalize";
import type { Topic } from "./types";
const TOPIC_ICONS: Record<string, string> = {
"mechanical-installations-pipes": "/icons/topic-1.png",
"electrical-equipment-supplies": "/icons/topic-2.png",
"doors-windows-glass": "/icons/topic-3.png",
"metals-steel-sections": "/icons/topic-4.png",
"construction-materials": "/icons/topic-6.png",
"construction-chemicals": "/icons/topic-7.png",
"finishing-interior-decoration": "/icons/topic-8.png",
"construction-tools-equipment": "/icons/topic-9.png",
};
export function buildTopics(categories: { slug: string; name: string }[]): Topic[] {
return categories.map((c) => ({
key: c.slug,
name: c.name,
icon: TOPIC_ICONS[c.slug] || "/icons/topic-1.png",
}));
}
export function topicByName(name: string | null | undefined, topics: Topic[]): Topic | null {
if (!name) return null;
return topics.find((t) => normalize(t.name) === normalize(name)) || null;
}
+10 -1
View File
@@ -68,8 +68,9 @@ export interface LineItem {
termsheet_ids: string[]; // به کدوم ترم‌شیت‌ها وصله
accepted_ts_id: string | null; // با کدوم ترم‌شیت پذیرش شده
rejected: boolean; // از معامله حذف شده؟
arrived_date: string | null; // تاریخ رسید
arrived_date: string | null; // تاریخ آخرین رسید
received_qty?: number | null; // مجموع رسیدهای ثبت‌شده (رسید جزیی) — null = هنوز داده‌ای نیست
received_warehouses?: { id: string; name: string; quantity: number }[] | null; // انبارهای مقصد رسیدها
projects?: { id: string; name: string }[]; // پروژه‌های تخصیص‌یافته به این قلم
}
@@ -128,6 +129,14 @@ export interface Store {
categoryUnits: CategoryUnit[];
unitConversions: UnitConversion[];
standardConversions: StandardConversion[];
topics: Topic[]; // دسته‌بندی‌های ۸گانه از /material-categories
}
// تاپیک (دسته‌بندی کالا) — از بک‌اند می‌آید؛ icon فقط نمایشی است
export interface Topic {
key: string; // slug دسته در بک‌اند
name: string;
icon: string;
}
// ---------------------------------------------------------------------------