From ac76af2ffe43d7174c1af0eaf8430fceb1b8d611 Mon Sep 17 00:00:00 2001 From: ali_mohaghegh Date: Wed, 2 Sep 2026 18:15:32 +0330 Subject: [PATCH] feat(frontend): fulfillment split, API-driven topics, hub-and-spoke nav, stock-count modal, FOUND ceiling, semantic colors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- frontend/src/components/TopicGrid.tsx | 15 +- frontend/src/lib/api.ts | 50 ++- frontend/src/lib/constants.ts | 36 +-- frontend/src/lib/topics.ts | 32 ++ frontend/src/lib/types.ts | 11 +- .../src/pages/Dashboard/DashboardPage.tsx | 5 +- .../src/pages/Dashboard/FilterToolbar.tsx | 6 +- frontend/src/pages/Dashboard/QuoteCard.tsx | 36 ++- frontend/src/pages/Dashboard/QuoteDetail.tsx | 57 +++- frontend/src/pages/Inventory/ArrivalsPage.tsx | 2 - .../pages/Inventory/ChangeInventoryPage.tsx | 292 ++++++++++++++---- .../src/pages/Inventory/DailyControlPage.tsx | 19 +- .../src/pages/Inventory/InventoryPage.tsx | 29 +- .../src/pages/Inventory/InventoryTopNav.tsx | 12 +- .../src/pages/Inventory/OrganizationPage.tsx | 7 +- .../src/pages/Inventory/WarehousesPage.tsx | 2 - frontend/src/pages/Projects/ProjectsPage.tsx | 4 +- frontend/src/pages/Suppliers/CategoryPage.tsx | 4 +- .../pages/Suppliers/SupplierDetailPage.tsx | 6 +- .../src/pages/Suppliers/SuppliersPage.tsx | 10 +- frontend/src/pages/Suppliers/TopicPage.tsx | 4 +- .../src/pages/Suppliers/supplierUtils.tsx | 12 +- frontend/src/pages/Units/UnitsPage.tsx | 29 +- .../src/pages/Wizard/Step1TopicSupplier.tsx | 8 +- frontend/src/pages/Wizard/WizardPage.tsx | 8 +- frontend/src/store/index.ts | 4 + frontend/src/styles/globals.css | 17 +- 27 files changed, 500 insertions(+), 217 deletions(-) create mode 100644 frontend/src/lib/topics.ts diff --git a/frontend/src/components/TopicGrid.tsx b/frontend/src/components/TopicGrid.tsx index 5132829..53e9532 100644 --- a/frontend/src/components/TopicGrid.tsx +++ b/frontend/src/components/TopicGrid.tsx @@ -1,20 +1,25 @@ -import { TOPICS } from "../lib/constants"; +import { EmptyState } from "./EmptyState"; +import type { Topic } from "../lib/types"; import { normalize } from "../lib/normalize"; import { faNum } from "../lib/formatters"; import { cn } from "../lib/utils"; export interface TopicGridProps { + topics: Topic[]; // از store (API) می‌آید selectedTopic?: string; - onSelect: (topicKey: string) => void; + onSelect: (topicName: string) => void; showCounts?: boolean; // تعداد تأمین‌کننده‌های هر تاپیک counts?: Record; // topic name → count } -// گرید ۴ ستونه‌ی ۸ تاپیک -export function TopicGrid({ selectedTopic, onSelect, showCounts = false, counts }: TopicGridProps) { +// گرید تاپیک‌ها (از /material-categories — نه لیست ثابت) +export function TopicGrid({ topics, selectedTopic, onSelect, showCounts = false, counts }: TopicGridProps) { + if (!topics.length) { + return ; + } return (
- {TOPICS.map((t) => { + {topics.map((t) => { const selected = selectedTopic != null && normalize(selectedTopic) === normalize(t.name); const count = counts?.[t.name] ?? 0; return ( diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 6b71dbd..03b48e5 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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; categoryIdToName: Map; categoryNameToId: Map; // کلید: نام نرمال‌شده + categorySlugToName: Map; // slug → نام دسته (تاپیک) brandNameToId: Map; // کلید: نام نرمال‌شده } @@ -396,54 +399,35 @@ function buildLookup(units: RawUnit[], categories: RawMaterialCategory[]): Looku const categoryIdToSlug = new Map(); const categoryIdToName = new Map(); const categoryNameToId = new Map(); + const categorySlugToName = new Map(); 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 = { - 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 { if (!_categoryIdCache) { try { const cats = await getData(`/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), }; }, diff --git a/frontend/src/lib/constants.ts b/frontend/src/lib/constants.ts index 9384bdb..250b425 100644 --- a/frontend/src/lib/constants.ts +++ b/frontend/src/lib/constants.ts @@ -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 = { 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); } - -// واحدهای شناخته‌شده از بک‌اند خوانده می‌شوند — این لیست حذف شد -// (واحدها فقط در دیتابیس تعریف می‌شوند و واحد جدید هنگام تایپ ساخته می‌شود) diff --git a/frontend/src/lib/topics.ts b/frontend/src/lib/topics.ts new file mode 100644 index 0000000..4803e4f --- /dev/null +++ b/frontend/src/lib/topics.ts @@ -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 = { + "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; +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 4ae9614..18534f6 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -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; } // --------------------------------------------------------------------------- diff --git a/frontend/src/pages/Dashboard/DashboardPage.tsx b/frontend/src/pages/Dashboard/DashboardPage.tsx index a7854a3..a9abc70 100644 --- a/frontend/src/pages/Dashboard/DashboardPage.tsx +++ b/frontend/src/pages/Dashboard/DashboardPage.tsx @@ -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(EMPTY_FILTERS); const [sortDir, setSortDir] = useState<"desc" | "asc">("desc"); @@ -43,8 +44,8 @@ export function DashboardPage() { const [editId, setEditId] = useState(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) — فیلترها بازنشانی نمی‌شوند diff --git a/frontend/src/pages/Dashboard/FilterToolbar.tsx b/frontend/src/pages/Dashboard/FilterToolbar.tsx index aed4c07..8021058 100644 --- a/frontend/src/pages/Dashboard/FilterToolbar.tsx +++ b/frontend/src/pages/Dashboard/FilterToolbar.tsx @@ -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 set({ status: it.label })} - items={["پیشنهاد شده", "پذیرفته شده", "رسیده"]} + items={STATUS_ITEMS} allowAdd={false} placeholder="همه" onInputChange={(v) => set({ status: v })} diff --git a/frontend/src/pages/Dashboard/QuoteCard.tsx b/frontend/src/pages/Dashboard/QuoteCard.tsx index e6a5a51..a7157a0 100644 --- a/frontend/src/pages/Dashboard/QuoteCard.tsx +++ b/frontend/src/pages/Dashboard/QuoteCard.tsx @@ -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 = ( + + ✓ تکمیل شد + + ); + } else if (anyReceived) { + fulfillmentBadge = ( + + رسید: {fmtNum(fulfilledItems)} از {fmtNum(activeItems.length)} قلم + + ); + } else { + fulfillmentBadge = ( + + ⏳ در راه + + ); + } + } let arrivedCell: React.ReactNode; if (q.status === "accepted") { @@ -109,8 +138,9 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit {sup.location &&
{sup.location}
}
- {STATUS_LABEL[q.status]} - {partiallyReceived && رسید جزیی} + {fulfillmentBadge ?? ( + {STATUS_LABEL[q.status]} + )}
+ + {/* مودال تأیید شمارش موجودی (اختلاف قبل از ثبت) */} + {countConfirm && ( +
!countBusy && setCountConfirm(null)}> +
e.stopPropagation()}> +

🔍 تأیید شمارش موجودی

+

+ موجودی هر ردیف به «تعداد شمارش‌شده» اصلاح می‌شود و اختلاف (کسری/مازاد) ثبت خواهد شد. +

+
+ {countConfirm.map((c, i) => { + const deficit = c.variance < 0; + const surplus = c.variance > 0; + return ( +
+
+ {c.material} + انبار: {c.warehouse} +
+
+ موجودی سیستم: {faNum(c.current)} {c.unit} + شمارش‌شده: {faNum(c.counted)} {c.unit} + + {deficit + ? `−${faNum(Math.abs(c.variance))} ${c.unit} (کسری انبار)` + : surplus + ? `+${faNum(c.variance)} ${c.unit} (مازاد انبار)` + : "بدون اختلاف ✓"} + +
+
+ ); + })} +
+
+ + +
+
+
+ )} +
{isTransfer ? "انتقال فقط بین انبارهای یک پروژه ممکن است. با ثبت، خروجی از مبدأ و ورودی به مقصد هم‌زمان ثبت می‌شود." : `تاریخ انتخابی: ${jalDateLabel(new Date(dateInput))} — با «${jalDateLabel(new Date())}» برابر است.`}
@@ -701,15 +870,15 @@ function ReturnTable({ rows, setRows, txs, whId, matTxt, unitNameFn }: { ); } -function FoundTable({ rows, setRows, txs, whId, unitNameFn }: { +function FoundTable({ rows, setRows, items, whId, unitNameFn }: { rows: RowDraft[]; setRows: (r: RowDraft[]) => void; - txs: InventoryTransaction[]; + items: { tx: InventoryTransaction; remaining: number }[]; whId: string; unitNameFn: (id: number | null | undefined) => string; }) { if (!whId) return
ابتدا انبار را انتخاب کنید.
; - if (!txs.length) return
مفقودی ثبت‌نشده‌ای (یا قابل پیداشدن) در این انبار وجود ندارد.
; + if (!items.length) return
مفقودی ثبت‌نشده‌ای (یا کاملاً پیداشده) در این انبار وجود ندارد.
; return (
@@ -719,40 +888,55 @@ function FoundTable({ rows, setRows, txs, whId, unitNameFn }: { - + - {txs.map((t, i) => ( - - - - - - - - - - ))} + {items.map(({ tx: t, remaining }, i) => { + const entered = parseNumLocal(rows[i]?.qty || ""); + const over = entered > remaining; + return ( + + + + + + + + + + ); + })}
مشخصه‌ها برند واحدمفقودیمانده‌ی مفقودی تعداد پیداشده یادداشت
{t.material?.name || "—"}{Object.entries(t.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}{t.brand?.name || "—"}{unitNameFn(t.unit?.id)}{faNum(Math.abs(t.quantity_number || 0))} - setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))} - /> - - setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))} - /> -
{t.material?.name || "—"}{Object.entries(t.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}{t.brand?.name || "—"}{unitNameFn(t.unit?.id)}{faNum(remaining)} + setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))} + /> + {over && ( +
+ تعداد پیداشده نمی‌تواند بیشتر از مقدار مفقودی باشد. +
+ )} +
+ setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))} + /> +
); } + +// تبدیل اعداد فارسی/اعشاری برای اعتبارسنجی نمایشی (بدون ارسال) +function parseNumLocal(s: string): number { + const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, "")); + return isFinite(v) ? v : 0; +} diff --git a/frontend/src/pages/Inventory/DailyControlPage.tsx b/frontend/src/pages/Inventory/DailyControlPage.tsx index e16c8ff..8257bb0 100644 --- a/frontend/src/pages/Inventory/DailyControlPage.tsx +++ b/frontend/src/pages/Inventory/DailyControlPage.tsx @@ -1,11 +1,10 @@ import { useMemo, useState } from "react"; -import { useLocation, useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { Warehouse as WarehouseIcon } from "lucide-react"; import { Header, PageLayout } from "../../components/layout/Header"; import { useAuthStore } from "../../store/auth"; import { PanelMenu } from "../../components/PanelMenu"; import { accountMenuItems, inventorySubheader, pmMenuItems, useLogout } from "./panelMenu"; -import { InventoryTopNav } from "./InventoryTopNav"; import { useInventoryData, unitName, whName } from "./useInventoryData"; import { faNum } from "../../lib/formatters"; import { gregorianToJalali, jalaliToGregorian, MONTHS, toFa } from "../../lib/jalali"; @@ -21,8 +20,7 @@ function todayKey(): string { // کنترل روزانه — پورت کامل تقویم شمسی نسخه‌ی ارسالی export function DailyControlPage() { - const location = useLocation(); - const logout = useLogout(); + const logout = useLogout(); const user = useAuthStore((s) => s.user); const data = useInventoryData(); const navigate = useNavigate(); @@ -128,7 +126,6 @@ export function DailyControlPage() { backLabel="بازگشت به داشبورد" icon={} menu={} - actions={} /> {isAdmin && ( @@ -193,7 +190,7 @@ export function DailyControlPage() {

🕘 کنترل روزانه — {isToday ? "امروز " : ""}{toFa(sd)} {MONTHS[sm - 1]} {toFa(sy)}

- ▼ برداشت‌ها: {faNum(wGroups.length)} قلم + ▼ برداشت‌ها: {faNum(wGroups.length)} قلم

مواردی که این روز از انبار برداشت و به ساختمان برده شده‌اند. روی هر مورد بزنید و برگشت / مفقودی / اتلاف را انتخاب کنید. @@ -207,32 +204,32 @@ export function DailyControlPage() { {wGroups.map((g) => (

{actRec === g.recId && isPM && (
- ۱۰ نوع تراکنش استاندارد • افزایش موجودی سبز، کاهش آبی • کلیک روی هر سطر: خط زمانی با «مانده» لحظه‌به‌لحظه و تفکیک برندها + ۱۰ نوع تراکنش استاندارد • سبز زمردی = ورود به انبار · رز = خروج/کاهش · آبی = انتقال و شمارش • کلیک روی هر سطر: خط زمانی با «مانده» لحظه‌به‌لحظه و تفکیک برندها
@@ -377,7 +378,7 @@ function Timeline({ g, txs, allTxs, brandTxs, selTx, setSelTx, expBrand, setExpB > {meta.icon} {meta.label} - {t.quantity_number >= 0 ? "+" : "−"}{faNum(Math.abs(t.quantity_number))} {unit} + {faNum(Math.abs(t.quantity_number))} {unit} مانده: {faNum(bal)} {unit} diff --git a/frontend/src/pages/Inventory/InventoryTopNav.tsx b/frontend/src/pages/Inventory/InventoryTopNav.tsx index 7c45c95..82252f8 100644 --- a/frontend/src/pages/Inventory/InventoryTopNav.tsx +++ b/frontend/src/pages/Inventory/InventoryTopNav.tsx @@ -1,12 +1,13 @@ import { Link } from "react-router-dom"; import { cn } from "../../lib/utils"; -// ناوبری بالای صفحه در پنل انبار — جایگزین لینک‌های منوی همبرگری -// (مثل سیستم تدارکات: لینک‌ها در بالای صفحه) +// ناوبری بالای صفحه در پنل انبار — فقط روی «داشبورد انبار» (هاب) دیده می‌شود؛ +// زیرصفحه‌ها تب ندارند و فقط با «بازگشت به داشبورد» به هاب برمی‌گردند. // -// - variant="pm" (مدیر پروژه): همه بخش‌های مدیر پروژه — بدون «دفتر تدارکات» -// (مدیر پروژه به سیستم تدارکات دسترسی ندارد) و بدون دکمه‌ی خروج تکی -// (خروج از حساب فقط در منوی همبرگری است). +// - variant="pm" (مدیر پروژه): همه بخش‌ها — بدون «تغییر موجودی» (ورود آن از +// دکمه‌ی «تغییر موجودی» بالای هاب است)، بدون «دفتر تدارکات» (مدیر پروژه +// به سیستم تدارکات دسترسی ندارد) و بدون دکمه‌ی خروج تکی (خروج از حساب +// فقط در منوی همبرگری است). // - variant="admin" (مدیر تدارکات/ادمین): فقط بخش‌های مدیر تدارکات — // «بازگشت به دفتر» و «خروج از حساب» در منوی همبرگری می‌مانند. export function InventoryTopNav({ @@ -26,7 +27,6 @@ export function InventoryTopNav({ ] : [ { label: "داشبورد انبار", to: "/inventory", active: pathname === "/inventory" }, - { label: "تغییر موجودی", to: "/inventory/change", active: pathname.startsWith("/inventory/change") }, { label: "کنترل روزانه", to: "/inventory/daily", active: pathname.startsWith("/inventory/daily") }, { label: "انبارها", to: "/inventory/warehouses", active: pathname.startsWith("/inventory/warehouses") }, { label: "کالاهای در راه", to: "/inventory/arrivals", active: pathname.startsWith("/inventory/arrivals") }, diff --git a/frontend/src/pages/Inventory/OrganizationPage.tsx b/frontend/src/pages/Inventory/OrganizationPage.tsx index 7961237..8e7f50b 100644 --- a/frontend/src/pages/Inventory/OrganizationPage.tsx +++ b/frontend/src/pages/Inventory/OrganizationPage.tsx @@ -1,12 +1,11 @@ import { useCallback, useEffect, useState } from "react"; -import { useLocation, useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { Warehouse as WarehouseIcon } from "lucide-react"; import { toast } from "sonner"; import { Header, PageLayout } from "../../components/layout/Header"; import { useAuthStore } from "../../store/auth"; import { PanelMenu } from "../../components/PanelMenu"; import { accountMenuItems, inventorySubheader, useLogout } from "./panelMenu"; -import { InventoryTopNav } from "./InventoryTopNav"; import { apiV26, type UserRow } from "../../lib/api"; import { useInventoryData } from "./useInventoryData"; import { faNum } from "../../lib/formatters"; @@ -21,8 +20,7 @@ const ROLE_LABELS: Record = { // سازمان‌دهی (فقط ادمین / مدیر تدارکات) — کاربران، مدیران پروژه و انبارهای هر پروژه export function OrganizationPage() { - const location = useLocation(); - const navigate = useNavigate(); + const navigate = useNavigate(); const logout = useLogout(); const user = useAuthStore((s) => s.user); const data = useInventoryData(); @@ -162,7 +160,6 @@ export function OrganizationPage() { backLabel="بازگشت به داشبورد" icon={} menu={} - actions={} />
diff --git a/frontend/src/pages/Inventory/WarehousesPage.tsx b/frontend/src/pages/Inventory/WarehousesPage.tsx index 5e8acbc..7bada64 100644 --- a/frontend/src/pages/Inventory/WarehousesPage.tsx +++ b/frontend/src/pages/Inventory/WarehousesPage.tsx @@ -6,7 +6,6 @@ import { Header, PageLayout } from "../../components/layout/Header"; import { useAuthStore } from "../../store/auth"; import { PanelMenu } from "../../components/PanelMenu"; import { inventorySubheader, panelMenuItems, pmMenuItems, useLogout } from "./panelMenu"; -import { InventoryTopNav } from "./InventoryTopNav"; import { apiV26 } from "../../lib/api"; import { useInventoryData, projName } from "./useInventoryData"; import { faNum } from "../../lib/formatters"; @@ -104,7 +103,6 @@ export function WarehousesPage() { backLabel="بازگشت به داشبورد" icon={} menu={} - actions={} /> {isPM && ( diff --git a/frontend/src/pages/Projects/ProjectsPage.tsx b/frontend/src/pages/Projects/ProjectsPage.tsx index af11b35..ef296d3 100644 --- a/frontend/src/pages/Projects/ProjectsPage.tsx +++ b/frontend/src/pages/Projects/ProjectsPage.tsx @@ -14,6 +14,7 @@ import { useUnits, } from "../../hooks"; import { normalize } from "../../lib/normalize"; +import { useAppStore } from "../../store"; import { activeItems, lineItemTotal, @@ -63,8 +64,9 @@ export function ProjectsPage() { const materials = useMaterials(); const { categoryUnits, unitConversions, standardConversions } = useUnits(); const addProject = useAddProject(); + const topics = useAppStore((s) => s.topics); - const store: Store = { quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions }; + const store: Store = { quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions, topics }; const [openProject, setOpenProject] = useState(null); const [newName, setNewName] = useState(""); diff --git a/frontend/src/pages/Suppliers/CategoryPage.tsx b/frontend/src/pages/Suppliers/CategoryPage.tsx index 76eebec..33c6af8 100644 --- a/frontend/src/pages/Suppliers/CategoryPage.tsx +++ b/frontend/src/pages/Suppliers/CategoryPage.tsx @@ -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], diff --git a/frontend/src/pages/Suppliers/SupplierDetailPage.tsx b/frontend/src/pages/Suppliers/SupplierDetailPage.tsx index f965f24..fe5510c 100644 --- a/frontend/src/pages/Suppliers/SupplierDetailPage.tsx +++ b/frontend/src/pages/Suppliers/SupplierDetailPage.tsx @@ -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(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}`; diff --git a/frontend/src/pages/Suppliers/SuppliersPage.tsx b/frontend/src/pages/Suppliers/SuppliersPage.tsx index 2f85d8c..348ca79 100644 --- a/frontend/src/pages/Suppliers/SuppliersPage.tsx +++ b/frontend/src/pages/Suppliers/SuppliersPage.tsx @@ -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 = {}; - 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 (
@@ -30,6 +31,7 @@ export function SuppliersPage() { /> navigate(`/topics/${encodeURIComponent(topic)}`)} showCounts counts={counts} diff --git a/frontend/src/pages/Suppliers/TopicPage.tsx b/frontend/src/pages/Suppliers/TopicPage.tsx index b2d0b70..fd2528c 100644 --- a/frontend/src/pages/Suppliers/TopicPage.tsx +++ b/frontend/src/pages/Suppliers/TopicPage.tsx @@ -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( diff --git a/frontend/src/pages/Suppliers/supplierUtils.tsx b/frontend/src/pages/Suppliers/supplierUtils.tsx index aa2b470..3452f95 100644 --- a/frontend/src/pages/Suppliers/supplierUtils.tsx +++ b/frontend/src/pages/Suppliers/supplierUtils.tsx @@ -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(); 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({
); -} +} \ No newline at end of file diff --git a/frontend/src/pages/Units/UnitsPage.tsx b/frontend/src/pages/Units/UnitsPage.tsx index 778a4a9..145417e 100644 --- a/frontend/src/pages/Units/UnitsPage.tsx +++ b/frontend/src/pages/Units/UnitsPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useSearchParams } from "react-router-dom"; import { toast } from "sonner"; import { Scale, Repeat, TriangleAlert, Link2, Pencil, Trash2, Plus } from "lucide-react"; @@ -17,7 +17,8 @@ import { useUnits, } from "../../hooks"; import { normalize } from "../../lib/normalize"; -import { TOPICS } from "../../lib/constants"; +import { topicByName } from "../../lib/topics"; +import { useAppStore } from "../../store"; import { faNum } from "../../lib/formatters"; import { parseNum } from "../../lib/numutil"; import { findConversion, materialById } from "../../lib/quoteUtils"; @@ -42,6 +43,7 @@ export function UnitsPage() { const suppliers = useSuppliers(); const materials = useMaterials(); const quotes = useQuotes(); + const topics = useAppStore((s) => s.topics); const { categoryUnits, unitConversions, standardConversions } = useUnits(); const saveRefUnit = useSaveRefUnit(); const saveConversion = useSaveConversion(); @@ -154,11 +156,11 @@ export function UnitsPage() { const topicCounts = useMemo(() => { const out: Record = {}; - TOPICS.forEach((t) => { + topics.forEach((t) => { out[t.name] = Object.keys(catTopicMap).filter((c) => normalize(catTopicMap[c]) === normalize(t.name)).length; }); return out; - }, [catTopicMap]); + }, [topics, catTopicMap]); // ---------- actions ---------- const saveRef = () => { @@ -282,7 +284,11 @@ export function UnitsPage() { }; // ---------- پیش‌فرض از URL ---------- - useMemo(() => { + // یک بار به‌ازای هر پارامتر اعمال می‌شود (وقتی داده‌ی لازم لود شد) + const appliedParams = useRef(""); + useEffect(() => { + const key = `${params.get("cat") ?? ""}|${params.get("topic") ?? ""}`; + if (appliedParams.current === key) return; const cat = params.get("cat"); const topicParam = params.get("topic"); const unitParam = params.get("unit"); @@ -295,14 +301,17 @@ export function UnitsPage() { } } if (!catFound && topicParam) { - const t = TOPICS.find((x) => normalize(x.name) === normalize(topicParam)); - if (t) setSelectedTopic(t.name); + const t = topicByName(topicParam, topics); + if (t) { + setSelectedTopic(t.name); + catFound = ""; + } } if (unitParam && catFound) { setFormFrom(unitParam); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + if (catFound !== null) appliedParams.current = key; + }, [params, topics, catTopicMap]); // ---------- render ---------- const ref = selectedCategory ? refUnitFor(selectedCategory) : ""; @@ -329,7 +338,7 @@ export function UnitsPage() {
اول موضوع را انتخاب کنید تا فقط دسته‌های همان موضوع بیاید
- setSelectedTopic(t)} showCounts counts={topicCounts} /> + setSelectedTopic(t)} showCounts counts={topicCounts} />
) : (
diff --git a/frontend/src/pages/Wizard/Step1TopicSupplier.tsx b/frontend/src/pages/Wizard/Step1TopicSupplier.tsx index 5a318f2..8896c7d 100644 --- a/frontend/src/pages/Wizard/Step1TopicSupplier.tsx +++ b/frontend/src/pages/Wizard/Step1TopicSupplier.tsx @@ -1,8 +1,8 @@ import { useMemo, useState } from "react"; import type { Material, Supplier } from "../../lib/types"; -import { TOPICS } from "../../lib/constants"; import { normalize } from "../../lib/normalize"; import { useSuppliers } from "../../hooks"; +import { useAppStore } from "../../store"; import { TopicGrid } from "../../components/TopicGrid"; import { Combobox } from "../../components/Combobox"; import { cn } from "../../lib/utils"; @@ -32,6 +32,7 @@ export function Step1TopicSupplier({ invalid, }: Step1Props) { const suppliers = useSuppliers(); + const topics = useAppStore((s) => s.topics); const [selectedSupId, setSelectedSupId] = useState(null); const topicSups = useMemo( @@ -41,11 +42,11 @@ export function Step1TopicSupplier({ const counts = useMemo(() => { const out: Record = {}; - TOPICS.forEach((t) => { + topics.forEach((t) => { out[t.name] = suppliers.filter((s) => normalize(s.topic) === normalize(t.name)).length; }); return out; - }, [suppliers]); + }, [topics, suppliers]); // آیا تأمین‌کننده‌ی فعلی در لیست هست؟ const matched = useMemo(() => { @@ -71,6 +72,7 @@ export function Step1TopicSupplier({ اول موضوع کالای موردنیاز را از بین تصاویر انتخاب کنید. تأمین‌کننده‌ها بر اساس موضوع فیلتر می‌شوند.
s.topics); const createQuote = useCreateQuote(); const addSupplier = useAddSupplier(); const addMaterial = useAddMaterial(); @@ -86,7 +88,7 @@ export function WizardPage() { useEffect(() => { const t = params.get("topic"); if (!t) return; - const topic = TOPICS.find((x) => normalize(x.name) === normalize(t)); + const topic = topicByName(t, topics); if (!topic) return; setSelectedTopic(topic.name); const sid = params.get("supplier"); @@ -97,7 +99,7 @@ export function WizardPage() { setSupplierInvalid(false); } } - }, [params, suppliers]); + }, [params, suppliers, topics]); // ---------- اعتبارسنجی قبل از ترک هر مرحله ---------- // مراحل تکمیل‌شده را بر اساس داده‌های فعلی مشخص می‌کند diff --git a/frontend/src/store/index.ts b/frontend/src/store/index.ts index cf06a62..a216351 100644 --- a/frontend/src/store/index.ts +++ b/frontend/src/store/index.ts @@ -14,6 +14,7 @@ import type { StandardConversion, Store, Supplier, + Topic, UnitConversion, } from "../lib/types"; @@ -26,6 +27,7 @@ interface AppStore { categoryUnits: CategoryUnit[]; unitConversions: UnitConversion[]; standardConversions: StandardConversion[]; + topics: Topic[]; // دسته‌بندی‌های ۸گانه — از /material-categories // UI state expandedQuoteIds: Set; @@ -63,6 +65,7 @@ export const useAppStore = create()((set) => ({ categoryUnits: [], unitConversions: [], standardConversions: [], + topics: [], expandedQuoteIds: new Set(), wizardStep: 1, @@ -77,6 +80,7 @@ export const useAppStore = create()((set) => ({ categoryUnits: store.categoryUnits, unitConversions: store.unitConversions, standardConversions: store.standardConversions, + topics: store.topics, }), addQuote: (q) => set((s) => ({ quotes: [...s.quotes, q] })), updateQuote: (id, patch) => diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css index e762ace..c4f683d 100644 --- a/frontend/src/styles/globals.css +++ b/frontend/src/styles/globals.css @@ -113,8 +113,21 @@ button:disabled { .fb-conv { @apply mt-0.5 flex items-center gap-1 text-[10.5px] text-muted; } -.fb-receipt { - @apply mt-0.5 flex items-center gap-1 text-[10.5px] font-semibold text-green; +/* رسید جزیی — شکست نمایشی قلم به «رسید شده» (سبز زمردی) و «در راه» (کهربایی) */ +.pill-recvd { + @apply inline-flex items-center gap-1 rounded-full border border-emerald-200 bg-emerald-50 px-1.5 py-px text-[10.5px] font-bold text-emerald-700; +} +.pill-pend { + @apply inline-flex items-center gap-1 rounded-full border border-amber-200 bg-amber-50 px-1.5 py-px text-[10.5px] font-bold text-amber-700; +} +.pill-recvd-mini { + @apply inline-flex items-center rounded-md bg-emerald-50 px-1 py-px text-[10px] font-bold text-emerald-700; +} +.fb-receipt-meta { + @apply mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[10.5px] text-muted; +} +.fb-fully-received { + @apply border-emerald-300 bg-emerald-50/30; } .fb-inv { @apply mt-0.5 inline-flex items-center gap-1 text-[11px] text-accent underline underline-offset-2;