v2.7 dropped materials.category_id and the API started returning category: null for
every material, but the frontend still read material.category as the material's label.
Effect: the edit page's material field rendered blank for every row, the dashboard
category filter listed nothing (and matched nothing), the wizard's category suggestions
were empty, and the conversion lookups keyed by category (refConverted →
categoryUnits/unitConversions) silently returned null, dropping the per-reference-unit
price. Saving an edited quote matched materials on an always-true category term, so two
materials sharing brand+specs were interchangeable.
Auditing the dropped column also turned up four places still dereferencing it, one
request away from a 500 and hidden only because the inventory is empty:
GET /inventory (serializer), GET /inventory?category_id=…, GET /inventory/materials/options
and DELETE /material-categories/{id}.
Backend:
- expand_service: category_by_material() resolves a material's display category from its
FIRST linked supplier (oldest supplier_materials row — the v2.5 trigger's rule), used by
the material and line-item payloads; category_id is populated again when not expanded.
- inventory: list filter, row serializer and material-options filter match through
supplier_materials ⋈ suppliers, mirroring GET /materials?category_id=…
- material_categories: the delete guard blocks on suppliers (the RESTRICT FK) and says so.
Frontend:
- Material.category → Material.supplier_category (supplier-derived, documented); the
rename pushed every read site through tsc.
- Labels use the material's own name; the edit modal field is «متریال» and prefills by
name; formatMaterialTitle no longer falls back to a category.
- Identity matching (edit modal, wizard, material memory) is name + brand + specs via a
shared matchMaterial(), so same-brand same-spec materials are not conflated; legacy
composed names are still found through materialBaseName().
- Filters read the supplier's category (dashboard options + quote filter, consistent with
the backend's quote filtering); supplier categories now come from the supplier FK
instead of the removed JSONB column. Conversion keys use supplier_category.
- Removed dead code carrying the old logic: two uncalled topicCategories copies and
materialOptions' unused "cat" branch.
Verified: both images build clean (tsc -b && vite build); the API was exercised in-process
against a restore of the current production dump with an inventory row added — material
and line-item payloads carry the supplier category, the inventory endpoints that used to
raise return 200, and the category delete guard returns 409 instead of 500.
Wizard:
- failed saves now route to the exact step+field: a new data-field attribute on every required section (supplier_id, line_items, termsheets, project, quote_date, expiry_date, notes, proforma) plus resolveErrorTarget() that maps ApiError.field / message keywords (supplier, line item/material/unit/quantity, termsheet/schedule/milestone, project, dates, notes, file) to the offending step — the «رفع خطا» toast action scrolls/focuses the right control instead of guessing
- material records are no longer created on the fly while typing (rememberMaterial no longer fires addMaterial per blur); materials/specs/units are written once during the final save, avoiding half-created catalog rows
Accept flow:
- quote_service no longer rewrites LineItemProjects on accept — those candidate-project rows stay 100% untouched and only item_decisions.project_id is written, matching the per-item target-project model and the UI payload (item_projects: [])
Dates/time:
- JalaliDatePicker: hour/minute are now editable text fields (Persian digits, clamping, 3-digit retype handling) with a controlled useEffect that only rewrites the inputs when the numeric value actually changes — no more wiping in-progress typing; date-only detection tightened to a full YYYY-MM-DD match so datetime strings are not misread
Also: /api validation logging (RequestValidationError) now includes the path and error list for faster diagnosis.
Verified: api + frontend image builds (python, tsc, vite) clean on top of origin/production (incl. the team's mobile layout merges).
- Edit modal: for arrived (رسیده) quotes the status select is disabled with
«به دلیل ثبت ورود اقلام به انبار، امکان تغییر وضعیت پیشنهاد وجود ندارد.»
so no rollback to accepted/offered is possible; metadata stays editable.
- Remove superfluous helper subtexts: parenthetical after «فاکتور رسمی»
(wizard step 1) and the MinIO upload explanations under the
invoice/proforma sections (Edit + Accept modals).
All changes are gated behind max-md:/md:hidden so the >=768px desktop layout is untouched (verified with lint + build + live checks at 375px).
- globals.css: one mobile block — 16px inputs (no iOS auto-zoom), units sidebar stacks above content, wizard item rows become 2-col (text fields full-width, delete button full-width), dd-panels / flow diagram / conv-form stack, larger touch targets
- ui/dialog.tsx: dialogs become full-width bottom sheets on mobile (all modals inherit it); desktop keeps the centered dialog
- Header: sticky on mobile only + PageLayout bottom clearance
- New MobileTabBar: role-aware bottom tabs (office admin/manager, inventory PM, inventory admin); hidden on /wizard
- Wizard step 4: TouchSensor (150ms delay) so touch drag works without stealing scroll
- JalaliDatePicker: dropdown capped to viewport; bigger day cells and time inputs on mobile
- PWA: manifest.webmanifest + 192/512 icons (scripts/gen-pwa-icons.ps1) + theme-color/manifest links in index.html
- New docs/mobile-responsive.md documenting the full change set
- 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