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.
Both download endpoints (invoice/proforma) put the stored file_name raw into the Content-Disposition header. HTTP headers are latin-1, so any non-ASCII name (i.e. every Persian filename) raised UnicodeEncodeError before the body was sent -> HTTP 500. Uploads were unaffected.
Content-Disposition is now built by a helper following RFC 6266 / RFC 5987: the UTF-8 name is percent-encoded in filename*=UTF-8''..., with a header-safe ASCII fallback in filename= (quotes and control characters stripped; a name that folds away entirely becomes attachment<ext>).
Verified locally after reproducing the exact production error (same latin-1 positions 22-27): Persian proforma + invoice downloads 500 -> 200 with filename*= carrying the full Persian name and bytes sha256-matching the MinIO object; ASCII filename regression 200; helper edge cases (CRLF/quote injection stripped, empty name -> attachment, pure-Persian png -> attachment.png); no UnicodeEncodeError in the api logs after the fix.
New section 'Working with the deployed system (server)': public app and Casdoor panel URLs (aramesh / casdoor .bennu-api-bahrestan.ir) and step-by-step MinIO console access via an SSH tunnel (ssh -L 9001:127.0.0.1:9001 root@171.22.25.75 -> http://localhost:9001), including the aramesh-minio credentials, the procurement-files bucket, why the console is loopback-only (port 9000 is reserved for the deploy-webhook receiver), the secret-handling warning and the tunnel teardown.
Also refreshed sections that went stale this session: the login form description no longer mentions the removed optional organization field; the restore section now documents the committed admin-only seed dumps in backups/علی محقق/ (the old restore.ps1/sh scripts and demo dumps were deleted) with the current login and the still-available demo seeders.
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.
Replaces the removed mock-data snapshots with minimal recovery seeds
that provision exactly one super user:
- casdoor.dump: full casdoor schema + built-in console admin (admin/123),
org 'inventory', app 'inventory-app', the JWT signing cert, and the
single org user ali.mohaghegh (علی محقق, plain password) — tokens,
sessions and records cleared.
- procurement.dump: full procurement schema + the single users row
(علی محقق, role=admin) linked by casdoor_id — all other tables empty;
init_db re-seeds categories/units at startup.
Login: ali.mohaghegh (Casdoor rejects spaces in usernames, so the
spaceful 'ali mohaghegh' is not a valid login name).
Restore on a fresh volume with:
docker compose stop api casdoor
docker cp 'backups/علی محقق/casdoor.dump' procurement-postgres:/tmp/
docker cp 'backups/علی محقق/procurement.dump' procurement-postgres:/tmp/
docker exec procurement-postgres pg_restore -U postgres -d casdoor --clean --if-exists /tmp/casdoor.dump
docker exec procurement-postgres pg_restore -U postgres -d procurement --clean --if-exists /tmp/procurement.dump
docker compose start casdoor api
Both dumps were round-trip validated (pg_restore into scratch DBs:
casdoor = 2 users / 2 orgs / 2 apps / 1 cert; procurement = 1 admin user,
all other tables empty).
The committed casdoor.dump/procurement.dump snapshots carried the demo
dataset (razaei/mousavi/mohaghegh/tester + demo quotes/stock) and the
restore scripts existed only to load them. Recovery now uses the new
admin-only seed dump (see the following commit).
docker compose config absolutized bind mounts to the Windows dev path
(C:\Users\Reza\Desktop\...), which cannot exist on the Linux Coolify
server and broke parsing. Also normalized the expanded structures back
to compact, Coolify-friendly syntax:
- bind volume sources are now repo-relative (./sql/..., ./infra/...)
- long-syntax volumes (type/source/target/volume objects) -> short
'src:target[:ro]'
- long-syntax ports (mode:ingress/target/published/protocol objects) ->
short 'host:container' strings
- dropped the compose-generated top-level 'name:' key
Verified: 'docker compose -f docker-compose.coolify.yml config' resolves
hosts, TLS, and healthchecks with no warnings; minio-init's escaped
shell variables ($${MINIO_*}) are preserved.
The base file bakes http://localhost:8080 as the JWT issuer; casdoor's
production origin is https://${AUTH_HOST}, so token iss checks would
reject every login. The merged Coolify file now derives the issuer from
${AUTH_HOST} like the casdoor origin.
Coolify applies a single self-contained compose file, but the repo splits
production config across docker-compose.yml (base) and
docker-compose.prod.yml (overlay). This is the result of
docker compose -f docker-compose.yml -f docker-compose.prod.yml config
with build contexts made relative and the production host variables kept
as ${APP_HOST}/${API_HOST}/${AUTH_HOST}/${S3_HOST}/${STORAGE_HOST}/
${TRAEFIK_ACME_EMAIL} placeholders for Coolify's environment settings
(minio-init's $${MINIO_*} shell escapes are preserved).
Verified: 'docker compose -f docker-compose.coolify.yml config' renders
Host(...) routers, the Let's Encrypt resolver email, and the casdoor
origin (https://${AUTH_HOST}) from sample values.
docker-compose.override.yml publishes the frontend on an extra host
port for LAN/phone access; it is machine-specific, so compose keeps
auto-loading it locally while other machines' stacks stay unchanged.
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).
Multi-project quotes: the manager picks one project per line item in the
accept modal (item_projects), but accept_quote only auto-filled
decision.project_id from existing line_item_projects (≤1). For items
without a single existing line-project the decision stayed NULL, so
/ pm/on-the-way (filtered by ItemDecision.project_id) never showed those
items to any project manager — they accumulated only in the global admin
list. Now derivation order is: explicit decision.project_id →
payload.item_projects (single) → existing line_item_projects (single) →
NULL (office-only, never misattributed). Verified E2E: two items of one
quote accepted to two projects appear in exactly their own PM's
on-the-way.
The router loads the material row (starting a transaction) before calling
material_service.update_material, which then opened its own
async with db.begin() → SQLAlchemy 'A transaction is already begun on
this Session' → INTERNAL_ERROR on every PATCH (incl. changing the
reference unit from ضرایب تبدیل). Mutations now run inside the request
transaction committed by the get_db dependency.
- 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.
User-provided export of the final integrated procurement + inventory ER
(DBDatalyzer/dbdata-diagram format). Kept at the root as placed; the older
ERD lives in docs/NewERD.txt.
- 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.
The topic grids (PM system + wizard) showed extra categories like «سیم»,
«کابل», «ثبثب», «dsfg». Those rows were leftover junk in material_categories
from earlier testing — previously invisible because the frontend hardcoded
the 8-topic list; the API-driven topic grid surfaced them. The rows are
deleted (they had no references) and the seeder now deletes any category
outside the 8 canonical slugs, so a re-seed always yields exactly the same
8 categories as before. Dump refreshed from the clean state.
- backups/procurement.dump regenerated from the clean seeded state (was the
junk test data); README documents both the app seeder and the restore path
- docs/casdoor-auth.md: later-batch changelog (fulfillment split, demo
seeder, API-driven topics, hub-and-spoke, 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
- tools/seed.py: wipes app tables and seeds a clean realistic demo (3
projects x 2 warehouses, 4 suppliers, 12 materials across all 8 topic
categories, 4 quotes covering offered / in-transit / partial / completed
with receipts + matching stock); user roles bound to Casdoor identities
by username (seed_casdoor.py first)
- expand_service: the line-item receipt aggregate now carries the warehouse
allocations (target warehouse per received part) for the ledger's
«رسید شده» split
- inventory_service: FOUND («پیدا شده») is guarded so recovery cannot exceed
the missing balance for the same (warehouse, material, brand, specs) —
422 with the remaining balance; STOCK_COUNT semantics unchanged (absolute
counted qty, auto-diff)
The databases live in Docker volumes (not in git), so teammates on a fresh
boot saw an empty system. backups/procurement.dump + casdoor.dump are exact
pg_dump snapshots of the current demo state (users/roles, projects,
warehouses, quotes with receipts, inventory rows; Casdoor org/app/users);
restore.ps1 / restore.sh replace both DBs with the committed state
(--clean --if-exists, verified by a round-trip restore into throwaway DBs).
README documents how to restore and how to take a fresh dump.