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:
+21
-29
@@ -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),
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
// واحدهای شناختهشده از بکاند خوانده میشوند — این لیست حذف شد
|
||||
// (واحدها فقط در دیتابیس تعریف میشوند و واحد جدید هنگام تایپ ساخته میشود)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user