Stray braces in App.tsx / ChangeInventoryPage.tsx, an orphaned admin gate in ArrivalsPage.tsx, and a literal revert commit subject pasted into LoginPage.tsx — all made the committed tree unbuildable.
Reverting 253c922 first made this merge small — only two conflicts:
- MobileTabBar: supervisor null-check combined with v7's /review hiding
- ArrivalsPage: pm's PM+supervisor scoping combined with v8's 3-way fetch
(transfers) and its transfer-receive helpers, plus pm's canAccess gate
Reverting the production revert so the pm branch can merge cleanly on
top of the restored RBAC base. Conflicts against newer integration work
resolved by combining:
- App.tsx: v7's bot-draft wizard bypass + login returnUrl kept alongside
the supervisor gating (no-project block screen, panel scoping)
- LoginPage: returnUrl navigation kept, supervisor routing added
- migration.sql: bot_drafts table kept, unified-projects ALTERs restored
- /panel/pm: the PM app (served on the same origin under /pm) is embedded as
a full-height iframe; supervisors are funneled there from every route,
ERP MobileTabBar hidden on that route
- Panels menu: «مدیریت پروژه (PM)» is an internal route (admin/pm/supervisor
only — procurement managers do not see it); PM link is no longer external
- New system role 'supervisor' (user_role enum): label in users router,
allowed in UserRoleUpdate schema, deny-by-default on all ERP routers;
admin panel can grant/revoke it alongside procurement manager
- Removed the obsolete VITE_PM_URL build plumbing (same-origin now)
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.
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.
- 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.
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
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