Brings in prod_v3 5084623 on top of production 9e8a236. Merged without
conflicts: prod_v3's base (7ead60c) is an ancestor of production, and
production's extra tab-bar refinements (17c7ae6) touch disjoint regions.
Quotes that already have warehouse receipts can no longer be edited or rolled back:
- backend: quote_has_receipts() guard — replace_quote_children and every backward status transition now return 409 with «این پیشنهاد بهدلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.»
- reopening to «offered» additionally clears item_decisions.project_id (the purchased-project link belongs to acceptance, not to a reopened offer)
- frontend EditModal: hasReceipts locks the whole form — disabled inputs and date pickers (JalaliDatePicker/NumberInput/MilestoneRow/MultiSelectCombobox gained disabled support), add/remove controls for items, termsheets and milestones hidden, status change blocked, amber warning banner; quote deletion now goes through a confirmation dialog (useDeleteQuote)
- date-only semantics: JalaliDatePicker keeps/emits YYYY-MM-DD when withTime=false (typing, «امروز», day selection) and toDateOnly no longer mangles pure date strings
Per-item target project (candidate projects stay intact):
- expand_service exposes the decision target project (decision.project + line_item.target_project)
- the accept payload derives item_decisions.project_id from that target project and sends item_projects: [] so line_item_projects keeps the original candidates untouched
- AcceptModal/EditModal select the target project per line item
Material titles:
- quoteUtils.formatMaterialTitle() composes one normalized, deduplicated «name + brand + specs» title, replacing materialLabel/materialSpecs/specsLabel usage across dashboard cards, quote detail and the modals
Verified: python compile + api/frontend image builds (tsc + vite) clean.
Brings in main_v2 17c7ae6 (tab bar sizing, inventory actions, date picker
proportions, stats lead time).
Conflicts resolved in favour of main_v2, which deliberately removed the
'تغییر موجودی' top button (nav lives in the tab bar / InventoryTopNav now)
and rewrote the proforma date field. Dropped the now-unused Button import.
Merges main_v2 a110741 (mobile UI optimizations) into production and
reinstates the mobile bottom navigation that f0e4243 had removed.
Conflicts in DashboardPage, QuoteCard and InventoryPage resolved in
favour of main_v2's mobile-first markup, keeping production's newer
design system (Button variant="primary" on InventoryPage).
f0e4243's removal was reverted in effect: MobileTabBar is restored and
remounted in App.tsx, and PageLayout regains max-md:pb-24 clearance so
content is not hidden behind the fixed bar.
Task 1 — PM 1:N + access-loss handling:
- projects.pm_user_id had no UNIQUE constraint (verified live + in schema); added a lookup index via init_db
- auth: UserContext.project_ids = every project of a PM (oldest first); project_id stays the first for compatibility
- GET /users/me/projects — assigned projects of the caller (drives the switcher + revocation validation)
- login / /users/me / /users list now return the full projects array per user
- /inventory/* and /pm/* scopes accept an explicit ?project_id=… and verify the PM manages it (403 otherwise); without the param they fall back to the first project; admin/manager use it as an optional filter
- POST /users/{id}/project is multi-aware: assigns one project (replacing that project's previous PM) and KEEPS the user's other assignments; null clears all
- PATCH /projects/{id} accepts pm_user_id (admin / procurement manager only)
- frontend: auth store holds projects + the active project (persisted via the existing localStorage store); RequirePmProjects re-validates on load, falls back when the active project was revoked, and shows a dedicated no-projects screen with logout (no redirect loop, no re-auth)
- Panels menu: «مدیریت انبار» gets a ChevronDown accordion listing the PM's projects with «✓ فعلی»; selecting one switches only the active project and reloads the section data
- inventory/pm API calls now send the active project_id explicitly
Task 2 — Organization page:
- new searchable ProjectMultiSelect combobox (chips with ×, live Persian filtering via normalize, assigned projects hidden) replaces the single select for PM users
- add = PATCH project pm_user_id=<user>; × = pm_user_id=null; table and project cards sync immediately; RBAC unchanged (role column admin-only, combobox editable for admin + procurement manager)
Verified locally: 17 scripted checks — multi-project assignment without unique conflicts, /users/me/projects, explicit project_id authorization (foreign project 403 on /inventory, /pm/on-the-way, /pm/receipts), PM blocked from pm_user_id changes, legacy assign endpoint keeps other assignments, revocation removes the project from the list, zero-project PM gets an empty list and a guarded inventory endpoint. All test data cleaned up afterwards.
Part 1 — show/hide password: an eye/eye-off button inside the login password input switches its type between password and text. It sits on the right (the input is LTR) with pr-10 clearance so dots never overlap the icon; type=button so a click cannot submit the form, aria-label Show/Hide password, aria-pressed + aria-controls, plus a Persian tooltip.
Part 2 — Organization field removed end-to-end after the audit proved it was a no-op: LoginPage input/state/payload spread (main + dev-mode admin login), api.ts payload type, backend LoginRequest.organization (declared but never forwarded to Casdoor — a bogus org value still logged in successfully), and the casdoor-auth.md flow diagram. The organization actually used remains CASDOOR_ORGANIZATION=inventory for /users/sync.
Verified: api + frontend images rebuilt and redeployed; login works with {username, password}; the served LoginPage chunk contains the toggle and zero org-field leftovers.
On /inventory/org two container rows could not shrink or wrap and
overflowed past the left edge on phones (RTL), forcing horizontal
scrolling:
- the add-warehouse form was a non-wrapping flex row of fixed-width
inputs (w-44 + w-52) plus the button (~500px total); it now stacks
vertically below sm (full-width fields and button) and returns to the
inline layout at sm+
- each warehouse row's action cluster (dashboard / move-project select /
delete) is now flex-wrap with justify-end, and the name block is
flex-1 so a long select option can no longer push the buttons out of
the card on narrow viewports
Desktop layout is unchanged; containerized rows stay within the page.
The fixed bottom tab bar (MobileTabBar) was rendered app-wide on
screens <768px, duplicating navigation that already exists in the
header/hamburger menus.
- unmount <MobileTabBar /> in App.tsx and drop its import
- delete components/layout/MobileTabBar.tsx (no remaining references)
- drop the max-md:pb-24 clearance in PageLayout (Header.tsx) that only
existed to keep page content above the removed fixed bar
Desktop and header-menu navigation are unaffected; mobile pages keep a
full-height viewport without dead bottom padding.
For accepted quotes with PM-confirmed (partial) receipts the dashboard row
only showed «رسیده X از Y — در راه Z» with no timestamp (quote header
arrived_date is set only when everything arrives). The cell now also shows
«آخرین رسید: <date time>» computed from the newest item receipt; arrived
quotes keep the arrival date with a last-receipt fallback.
Line-item labels in the accept modal («پروژهی هر قلم» and the keep/reject
list) and the quote-detail flow boxes printed category + brand (e.g. the
topic name). Both now use materialLabel() → the material's actual stored
name, consistent with the rest of the dashboard.
- «مشخصه فنی» in موجودی اولیه is now the same MultiSelectCombobox chip
control as the wizard's «مشخصه» (name-only chips, stored as spec keys
with empty values so stock variants stay separate).
- Unit combobox options deduped via uniqueUnits (canonical base preferred).
- Description/validation copy updated accordingly.
Replaces the MultiSelectCombobox project picker in the accept modal with a
single-select dropdown styled like the payment-terms select. Each kept item
defaults to its previous project (or the quote's first), carries exactly one
project into item_projects AND into the decision payload on accept —
matching the backend's single purchased-project model.
- متریال field suggests unique BASE material names from the whole catalog
(brand/spec prefixes stripped; «راجین سیم مسی» → «سیم») — brand/specs
have their own columns.
- Save path reuses an existing material by full name only (no category
equality constraint, which never matched topic-named categories) so
selecting an existing material never duplicates it.
- Unit field: suggestion list no longer includes the row's own typed text,
which previously suppressed the create option; typing an unknown unit now
shows «+ [value] به عنوان واحد جدید» (registered immediately via
POST /units and added to suggestions).
- «ضرایب تبدیل» opens directly on the global materials list (no topic/category
page); sidebar accordion: unique base materials (brand/spec prefixes
stripped) with expandable spec variants; search toolbar filters live.
- Detail binds to the selected material: reference unit (editable combobox
over global units, changes canonical_unit_id), conversion factors from
material_units incl. optional brand scope (restored برند field), add/edit/
delete with live preview (canonical-first wording), unassigned-units
warning section, global standard conversions kept.
- Self-referencing children excluded (only base+spec variants listed);
groups without variants selectable via their representative material.
- New lib/units.ts uniqueUnits(): unit dropdowns show each name once,
preferring the canonical coded row (kg/piece) over duplicate custom rows
with identical Persian names.
- Remove all || «عدد» / || "unit" fallbacks: materials/line items without
an explicit unit now fail with a clear Persian error instead of silently
creating an «عدد» unit row (POST /units happens only for typed names).
- unitIdByNameOrCreate/createUnit guard empty names; code = normalized
typed name only.
- Add api.materialUnits (list/create/update/remove with brand_material_id),
api.brandMaterials (list by material + link), materials.updateCanonicalUnit,
apiV26.catalog.* unit/brand/material creation helpers (shared usage).
- Accept payload: each chosen item_decision now carries the selected single
project_id so backend can bind decision.project_id (PM on-the-way
segregation).
- PURCHASE rebranded to «موجودی اولیه» in the تغییر موجودی screen
(tab, description, empty-state copy; submit button reads
«ثبت موجودی اولیه» automatically).
- Material/brand/unit inputs of that form are now searchable Comboboxes
with «…به عنوان جدید» affordance; submitting an unknown name creates
the entry on the fly in the global catalogs (new apiV26.catalog.* →
POST /materials with null category, /brands, /units) so initial stock
no longer requires a procurement prerequisite.
- The hub timeline chip for PURCHASE stays «خرید» because real deliveries
from کالاهای در راه also post as PURCHASE.
New shared materialLabel() renders the global materials.name with brand/specs
appended only when not already part of the name (deduped; «؟» fallback kept).
Replaces the category-first composer in five places: supplier card badges
(CategoryPage), dashboard item lines (QuoteCard), ledger items summary,
project rows and purchase history. Also fixes the stale Units subtitle
«ضریب تبدیل همان دسته» → «ضریب تبدیل متریال» (category model kept).
- main.tsx: on vite:preloadError (stale index.html referencing removed
hashed chunks after a redeploy) reload the page immediately.
- New ErrorBoundary around the whole router: render errors show a Persian
fallback with «تلاش مجدد» instead of wiping the DOM.
- nginx: index.html served with Cache-Control no-cache,no-store,
must-revalidate (verified live); /assets/* stays public, immutable.
- 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).
Wizard Step 5 initializes the quote date with the client's current local
time instead of 00:00, and the Jalali datepicker's hour/minute controls
default to the current clock whenever no explicit time is configured
(clicking «امروز» already used the current time). Date-only consumers
are unaffected (times are stripped at the API layer).
Quote creation was blocked for any new material whose typed name is not
one of the 8 canonical category names: api.materials.create threw
«دسته ... در سامانه تعریف نشده است» and the failed create cascaded a
bogus temp material_id into the quote payload (invalid-payload error).
v2.5+ model: materials are global (name UNIQUE) and the category
attaches via the supplier (first-link-wins). The backend already
accepts category_id=null (MaterialCreate schema + material_service).
This removes the frontend-only hard block: unknown category names now
create the material with category_id null, and the category is derived
automatically from the first linked supplier on quote save. The 8
canonical categories are still never auto-created (no rogue-category
regression).
Verified end-to-end through the real API: create «سیم» (category null)
→ supplier → quote via material_id → 201/201/201 with
material.category_id derived from the supplier; lint + build green.
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
The month-shift anchor discarded the day returned by jalaliToGregorian:
new Date(gy, gm-1, 1) instead of new Date(gy, gm-1, gd). Jalali months
start mid-Gregorian-month (مهر ۱ = 2026-09-23), so the anchor landed on
the 1st of the Gregorian month — still inside the SAME Jalali month
(شهریور ۱۰) — and «ماه بعد» appeared dead while «ماه قبل» only seemed
to work because its wrong target showed a different label. This was also
the source of the reported ~22-day date mismatches.
- shiftMonth now keeps the converted day (gy, gm, gd)
- nav buttons fire on pointerup/click/keyboard with a dedupe guard so the
action survives browsers that swallow the click event
- buttons got hover feedback (cursor-pointer, hover:bg-slate-100,
transition-colors)
Verified against the library math: from شهریور ۱۴۰۵, next → مهر ۱۴۰۵,
prev → مرداد ۱۴۰۵; confirmed in the deployed bundle.
- shared AppSwitcherMenu ('پنلها' title, active filled item with ✓ فعلی,
«مدیریت انبار»/«مدیریت تدارکات» switcher, divider + خروج از حساب) used by
both the procurement dashboard and the warehouse hub (PM variant keeps
logout-only); replaced the old raw «بازگشت به دفتر» link
- warehouse header mirrors the procurement pattern: icon + hamburger only
on the hub dashboard; sub-views (تغییر موجودی، کنترل روزانه، انبارها،
کالاهای در راه، سازماندهی) render a minimal header with just the title
and «بازگشت به داشبورد»
- removed the self-referencing «داشبورد انبار» tab from the hub strip
- removed the red per-day transaction-count badges from the daily-control
calendar (backend untouched — purely visual)
- removed the test subtitle «دادهها در پایگاه داده ذخیره میشوند» from the
procurement dashboard header
- procurement store refreshes right after a warehouse receipt (arrivals) and
after role/project changes (organization/admin), so the ledger reflects
warehouse actions immediately
- refetchOnWindowFocus enabled on the shared query client
- BroadcastChannel (lib/crossTab.ts): mutations broadcast 'quotes' /
'inventory'; all warehouse pages + the store hook resync open tabs
automatically (receipt in one tab updates the ledger in another)
- warehouse mutations (stock changes) notify other tabs; same-page refresh
behavior unchanged
Domain model (v2.6): suppliers.category_id NOT NULL (1:N from the 8 fixed
categories), materials are global and link to suppliers via
supplier_materials. This fixes three regressions of the old inverted tree:
- remove the intermediate 'یک متریال را انتخاب کنید' screen: a category
card now routes straight to that category's supplier list
(/suppliers/category/:category); TopicPage is deleted and legacy
/topics/:topic[/:category] URLs redirect to the new hierarchy
- kill the accidental-category bug: api.materials.create silently POSTed
/material-categories for any unknown 'category' (a material name!), which
is how the rogue «پریز» category (+ junk material) appeared. Unknown
categories now fail loudly instead of creating catalog rows (DB cleaned:
exactly the 8 canonical categories remain)
- fix inverted wording: supplier list is «تأمینکنندههای این دسته» with
category-first breadcrumbs; the legacy material multi-select on the
add-supplier form is gone (a supplier gets the page's category)
- supplier details screen logic untouched — only its breadcrumb link
targets were updated to the new routes
After the api container restarts it gets a new IP; nginx cached the old one
at startup and kept proxying /api/* to the stale address — which by then
belonged to another service (casdoor), so login returned Casdoor's error
envelope and the frontend crashed with "Cannot read properties of null
(reading 'user')". proxy_pass now uses a variable + resolver 127.0.0.11 so
the upstream re-resolves per request. Verified: login works, and still works
after restarting the api container.
- 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
- redesigned /login (Tailwind, RTL): centered username/password/organization
form posting to POST /api/v1/auth/login — session starts without leaving
the page; PM lands on the inventory panel, others on the procurement office
- Developer Mode button fixed top-left opens a role/project test panel
(admin role added) gated by VITE_ENABLE_DEV_LOGIN (DEV_LOGIN_ENABLED build
arg in Docker, default enabled)
- removed the old Casdoor redirect button, the PKCE ?code= callback, and the
public casdoor-config.js — the client secret is no longer shipped to the
browser; backend mediates the password grant instead