31 Commits
Author SHA1 Message Date
whqreza 4e59f6e25c docs(readme): add server section with MinIO tunnel; refresh stale sections
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.
2026-09-15 16:57:28 +03:30
whqreza 593c149478 feat(auth): add password visibility toggle and remove the unused Organization field
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.
2026-09-15 16:29:54 +03:30
MMD e7f465f74f style(ui): maximize topic card images and fix RTL wizard arrows
Frontend-only application of prod_v2 (55e959b, 9e99b46).
Dev docker-compose.yml changes excluded.
2026-09-15 14:30:43 +03:30
MMD 2573f1e583 refactor(ui): modernize proposal wizard, dashboard cards and daily control calendar
Frontend-only application of prod_v2 (7bc6845, a29eafc). Dev
docker-compose.yml change (b11cb36) and backups churn excluded.
2026-09-13 18:07:27 +03:30
whqreza a542e4177e chore(backups): add admin-only seed dumps (علی محقق)
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).
2026-09-13 15:46:09 +03:30
whqreza c454b03b4d chore(backups): remove mock-data dumps and restore scripts
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).
2026-09-13 15:46:09 +03:30
MMD 50f8d8e64e feat(ui): modernize button design system and update Dashboard and Inventory pages
Frontend-only cherry-pick of prod_v2 3bdb05f; the dev compose
api port change (8000 -> 8001) is intentionally excluded.
2026-09-13 00:13:57 +03:30
whqreza 3b032d0460 idk anymore what going on...! 2026-09-10 11:52:48 +00:00
whqreza f263cfb9e3 update docker for coolify 2026-09-10 10:45:18 +00:00
whqreza 4609940856 fix(infra): make the Coolify compose file Linux-safe
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.
2026-09-08 19:51:55 +03:30
whqreza 3c3880aec9 fix(infra): point PROCUREMENT_CASDOOR_ISSUER at the public auth host
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.
2026-09-08 18:10:37 +03:30
whqreza 545b5dd473 chore(infra): add merged compose file for Coolify deployment
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.
2026-09-08 18:10:10 +03:30
whqreza 7528cc3bbe chore: ignore the machine-local compose override
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.
2026-09-08 16:04:05 +03:30
whqreza b45a6cd033 fix(inventory): keep Organization page actions within mobile bounds
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.
2026-09-08 16:04:02 +03:30
whqreza f0e424316e fix(web): remove the mobile bottom navigation bar
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.
2026-09-08 16:03:56 +03:30
whqreza c10c10a425 data: refresh casdoor dump (org users: razaei, mousavi, mohaghegh, tester) 2026-09-08 15:07:58 +03:30
whqreza f8deb0c6b9 fix(quotes): show latest receipt time on partial arrivals
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.
2026-09-08 14:51:36 +03:30
whqreza c64718ef70 fix(quotes): show real material names instead of category in accept modal & detail
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.
2026-09-08 14:51:36 +03:30
whqreza 529eb67d01 feat(warehouse): wizard-style spec chips + unique unit options
- «مشخصه فنی» 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.
2026-09-08 14:42:24 +03:30
whqreza eb1043cc3a feat(quotes-accept): single-project select per line item
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.
2026-09-08 14:42:24 +03:30
whqreza dbe53a727c fix(wizard): global material suggestions + unit create-option fixes
- متریال 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).
2026-09-08 14:42:16 +03:30
whqreza 58ff8dc496 feat(units): global-material conversion workspace (no category landing)
- «ضرایب تبدیل» 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.
2026-09-08 14:42:16 +03:30
whqreza 8e2afcec59 fix(api): no hardcoded unit defaults; material_units/brand/catalog APIs; accept sends decision project
- 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).
2026-09-08 14:42:06 +03:30
whqreza e8154ae6a6 fix(quotes): derive decision.project_id from accept item_projects
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.
2026-09-08 14:41:57 +03:30
whqreza 7f31fb5105 fix(materials): remove nested db.begin — material PATCH was 500
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.
2026-09-08 14:41:57 +03:30
whqreza 468ce64d29 feat(inventory): «موجودی اولیه» rebrand + create-on-the-fly global catalog entries
- 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.
2026-09-06 19:03:42 +03:30
whqreza fd1831b3fc fix(catalog): show real material names, not categories, across quote views
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).
2026-09-06 19:03:35 +03:30
whqreza 1f123a0a05 fix(web): blank-screen hardening — stale chunk recovery, error boundary, no-cache index.html
- 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.
2026-09-06 19:03:27 +03:30
whqreza 4dd60d7cd9 fix(quotes): lock status on arrived quotes + trim modal helper copy
- 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).
2026-09-06 19:03:19 +03:30
whqreza 3bdb0027f3 fix(quotes): quote date (تاریخ پیشنهاد) defaults to current time, not midnight
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).
2026-09-06 19:03:10 +03:30
whqreza c5bbbbbaaa fix(wizard): allow global materials without a canonical category
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.
2026-09-06 15:39:09 +03:30
44 changed files with 2334 additions and 1423 deletions
+3
View File
@@ -19,6 +19,9 @@ frontend/.env
v1.2 - Copy/ v1.2 - Copy/
inv dash/ inv dash/
# ---- Machine-local compose overrides (LAN phone access, etc.) ----
docker-compose.override.yml
# ---- Screenshots (root) ---- # ---- Screenshots (root) ----
*.png *.png
*.jpg *.jpg
+88 -14
View File
@@ -81,7 +81,7 @@ v1.3/
## 🔐 Login ## 🔐 Login
The whole system is behind a login page (`/login`). Login is **in-page**: The whole system is behind a login page (`/login`). Login is **in-page**:
the form (username / password / optional organization) is sent to the form (username / password) is sent to
`POST /api/v1/auth/login`; the backend exchanges it with Casdoor `POST /api/v1/auth/login`; the backend exchanges it with Casdoor
(password grant), verifies the returned JWT, and starts the session — (password grant), verifies the returned JWT, and starts the session —
no browser redirect to Casdoor. no browser redirect to Casdoor.
@@ -113,6 +113,71 @@ Casdoor organization, application and users (idempotent). Full details —
addresses, credentials, role model, failure scenarios and the checklist — addresses, credentials, role model, failure scenarios and the checklist —
are in [`docs/casdoor-auth.md`](docs/casdoor-auth.md). are in [`docs/casdoor-auth.md`](docs/casdoor-auth.md).
> The **currently committed seed** (`backups/علی محقق/`) instead ships a
> single admin — `ali.mohaghegh` («علی محقق») — see
> [Restoring the committed seed data](#restoring-the-committed-seed-data).
---
## 🌐 Working with the deployed system (server)
The production deployment of this stack runs on the server and is reachable
at these public addresses (HTTPS):
| What | Address |
|---|---|
| **App (ERP)** | https://aramesh.bennu-api-bahrestan.ir/ |
| **Casdoor admin panel** | https://casdoor.bennu-api-bahrestan.ir/ |
Log in to the app with your Casdoor account (org `inventory`); the role is
assigned inside the app (`users.role` — see «Login» above and
[`docs/casdoor-auth.md`](docs/casdoor-auth.md)).
### MinIO console on the server (SSH tunnel)
The MinIO console is deliberately **loopback-only** on the server
(`127.0.0.1:9001`) — verified unreachable from the internet — so the way in
is an SSH tunnel from your machine:
1. Open PowerShell and start the tunnel (**keep the window open** while you
use the panel):
```powershell
ssh -L 9001:127.0.0.1:9001 root@171.22.25.75
```
You will also land in a normal SSH session — that is fine; the tunnel
lives as long as the window stays open.
2. In your browser, open: **http://localhost:9001**
3. Log in with:
| Username | Password |
|---|---|
| `aramesh-minio` | `feb10fadb5441019dfc33bf12a9b0f74e7b6c478` |
You will see the **`procurement-files`** bucket — the invoices/proformas the
ERP stores, browsable and downloadable from there.
⚠️ These credentials are **full admin access to the file storage**. Treat
them as a production secret (this README is inside the repo, so anyone with
repo access can read them — rotate them on the server if they ever leak).
Close the PowerShell window (or `Ctrl+C`) when you are done to drop the
tunnel.
Notes from the server setup:
- The panel is published only on the server's loopback, so the internet
cannot reach it at all — the tunnel is what carries it to your browser.
- Host port `9000` stays reserved for the deploy-webhook receiver, which is
why the console uses `9001`.
- If you ever need the raw S3 API from your machine, or a clickable URL for
the console (e.g. `storage.aramesh.bennu-api-bahrestan.ir` behind Traefik
with a password prompt on top), that can be arranged on request — the
tunnel remains the safer default.
--- ---
## Prerequisites (target machine) ## Prerequisites (target machine)
@@ -197,26 +262,35 @@ docker exec -it procurement-postgres psql -U postgres -d procurement
--- ---
## Restoring the demo data (for the team) ## Restoring the committed seed data
The databases are **not** in git — Docker volumes hold the data. To let The databases are **not** in git — Docker volumes hold the data. The repo
everyone see the same system state, the repo ships exact database dumps in ships recovery dumps in `backups/علی محقق/`:
`backups/`. After a fresh boot:
| File | Contents |
|---|---|
| `casdoor.dump` | Casdoor schema + console admin (`admin` / `123`), org `inventory`, app `inventory-app`, the JWT signing cert, and the single org user `ali.mohaghegh` |
| `procurement.dump` | Procurement schema + the single `users` row «علی محقق» with role `admin`; all other tables empty (categories/units re-seed at API startup) |
⚠️ This **replaces all data** in the `procurement` and `casdoor` databases.
On a running stack:
```powershell ```powershell
powershell -ExecutionPolicy Bypass -File .\backups\restore.ps1 # Windows docker compose stop api casdoor
bash backups/restore.sh # Linux / Git Bash docker cp "backups/علی محقق/procurement.dump" procurement-postgres:/tmp/
docker cp "backups/علی محقق/casdoor.dump" procurement-postgres:/tmp/
docker exec procurement-postgres pg_restore -U postgres -d procurement --clean --if-exists /tmp/procurement.dump
docker exec procurement-postgres pg_restore -U postgres -d casdoor --clean --if-exists /tmp/casdoor.dump
docker compose start casdoor api
``` ```
This **replaces all data** in the `procurement` and `casdoor` databases with Login afterwards: `ali.mohaghegh` / `hulkfuck002` (Casdoor rejects spaces
the committed state — a clean, realistic demo: 3 projects × 2 warehouses, in usernames, so «ali mohaghegh» is not a valid login name).
4 suppliers, 12 materials across all 8 categories, and 4 quotes covering
every lifecycle state (offered / in-transit / partially received /
completed) with matching stock.
Two alternative ways to get a clean state: Want demo data instead? Two seeders are still available (they create
different users — see the Login table above):
1. **App seeder** (realistic demo data, keeps the Casdoor org as-is): 1. **App seeder** (realistic demo dataset; keeps the Casdoor org as-is):
```powershell ```powershell
docker exec procurement-api sh -c "cd /srv/backend && python -m tools.seed" docker exec procurement-api sh -c "cd /srv/backend && python -m tools.seed"
``` ```
-1
View File
@@ -31,7 +31,6 @@ router = APIRouter(prefix="/auth", tags=["auth"])
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
username: str username: str
password: str password: str
organization: str | None = None # optional override; defaults to CASDOOR_ORGANIZATION
@router.post("/login") @router.post("/login")
+23 -19
View File
@@ -160,27 +160,31 @@ async def update_material(db: AsyncSession, material: Material, payload: Materia
v2.5/2.6: category is DERIVED (from the first linked supplier) — it is v2.5/2.6: category is DERIVED (from the first linked supplier) — it is
no longer part of the update payload. no longer part of the update payload.
NOTE: no ``db.begin()`` here — the router already starts the transaction
(it loads the row first), and a nested begin raises
"A transaction is already begun on this Session" (500). The request
session commits via the get_db dependency.
""" """
async with db.begin(): if payload.name is not None:
if payload.name is not None: duplicate = await db.scalar(
duplicate = await db.scalar( select(Material.id).where(
select(Material.id).where( Material.name == payload.name,
Material.name == payload.name, Material.id != material.id,
Material.id != material.id,
)
) )
if duplicate is not None: )
raise ConflictError("متریالی با این نام در کل کاتالوگ قبلاً ثبت شده است", field="name") if duplicate is not None:
if payload.name is not None: raise ConflictError("متریالی با این نام در کل کاتالوگ قبلاً ثبت شده است", field="name")
material.name = payload.name if payload.name is not None:
if payload.topic is not None: material.name = payload.name
material.topic = payload.topic if payload.topic is not None:
if payload.canonical_unit_id is not None and payload.canonical_unit_id != material.canonical_unit_id: material.topic = payload.topic
if not await db.get(Unit, payload.canonical_unit_id): if payload.canonical_unit_id is not None and payload.canonical_unit_id != material.canonical_unit_id:
raise NotFoundError("واحد مرجع یافت نشد", field="canonical_unit_id") if not await db.get(Unit, payload.canonical_unit_id):
material.canonical_unit_id = payload.canonical_unit_id raise NotFoundError("واحد مرجع یافت نشد", field="canonical_unit_id")
if payload.status is not None: material.canonical_unit_id = payload.canonical_unit_id
material.status = payload.status if payload.status is not None:
material.status = payload.status
return material return material
+25 -13
View File
@@ -416,6 +416,9 @@ async def accept_quote(db: AsyncSession, quote_id: UUID, payload: AcceptQuoteIn,
# --- process item decisions (UPSERT) --- # --- process item decisions (UPSERT) ---
chosen_ts_ids: set[UUID] = set() chosen_ts_ids: set[UUID] = set()
# پروژه‌های ارسالی در payload.item_projects — منبع اصلی برای
# decision.project_id (مدیر تدارکات برای هر قلم یک پروژه انتخاب می‌کند)
proj_by_line: dict[UUID, list[UUID]] = {e.line_item_id: list(e.project_ids) for e in payload.item_projects}
for decision_in in payload.item_decisions: for decision_in in payload.item_decisions:
decision = await db.scalar( decision = await db.scalar(
select(ItemDecision).where(ItemDecision.line_item_id == decision_in.line_item_id).with_for_update() select(ItemDecision).where(ItemDecision.line_item_id == decision_in.line_item_id).with_for_update()
@@ -460,22 +463,31 @@ async def accept_quote(db: AsyncSession, quote_id: UUID, payload: AcceptQuoteIn,
decision.adjusted_unit_price = decision_in.adjusted_unit_price decision.adjusted_unit_price = decision_in.adjusted_unit_price
decision.adjusted_notes = decision_in.adjusted_notes decision.adjusted_notes = decision_in.adjusted_notes
decision.decided_at = utcnow() decision.decided_at = utcnow()
# v2.6: the SINGLE purchased project (proc manager picks it; # v2.6: the SINGLE purchased project (proc manager picks it).
# auto-filled when the offer had exactly one project). # Derivation order:
# 1) explicit decision_in.project_id
# 2) payload.item_projects for this line item (exactly one)
# 3) existing line_item_projects (exactly one)
# otherwise NULL — the item stays visible only to the office
# until a project is assigned (never wrongly visible to a PM).
if decision_in.project_id is not None: if decision_in.project_id is not None:
decision.project_id = decision_in.project_id decision.project_id = decision_in.project_id
else: else:
line_projs = ( accept_projs = proj_by_line.get(decision_in.line_item_id) or []
(await db.execute( if len(accept_projs) == 1:
select(LineItemProjects.project_id).where( decision.project_id = accept_projs[0]
LineItemProjects.line_item_id == decision_in.line_item_id else:
) line_projs = (
)) (await db.execute(
.scalars() select(LineItemProjects.project_id).where(
.all() LineItemProjects.line_item_id == decision_in.line_item_id
) )
if len(line_projs) == 1: ))
decision.project_id = line_projs[0] .scalars()
.all()
)
if len(line_projs) == 1:
decision.project_id = line_projs[0]
if decision.project_id is not None: if decision.project_id is not None:
if await db.get(Project, decision.project_id) is None: if await db.get(Project, decision.project_id) is None:
raise NotFoundError("پروژه یافت نشد", field="item_decisions.project_id") raise NotFoundError("پروژه یافت نشد", field="item_decisions.project_id")
Binary file not shown.
-44
View File
@@ -1,44 +0,0 @@
# ============================================================
# Restore the committed demo-state dumps (backups/*.dump) into
# the running stack. REPLACES all data in the `procurement` and
# `casdoor` databases — run it on a fresh boot or when you want
# to reset to the exact committed state.
#
# powershell -ExecutionPolicy Bypass -File .\backups\restore.ps1
# ============================================================
$ErrorActionPreference = "Stop"
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
Set-Location (Join-Path $here "..")
Write-Host ""
Write-Host "Restoring demo data — this REPLACES the current DB content!" -ForegroundColor Yellow
docker compose ps postgres | Select-String "healthy" | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-Host "postgres is not healthy — run 'docker compose up -d' first." -ForegroundColor Red
exit 1
}
Write-Host "Stopping api + casdoor (they hold DB connections)..." -ForegroundColor Cyan
docker compose stop api casdoor
if ($LASTEXITCODE -ne 0) { exit 1 }
Write-Host "Restoring procurement DB..." -ForegroundColor Cyan
docker cp "backups\procurement.dump" procurement-postgres:/tmp/procurement.dump
docker exec procurement-postgres pg_restore -U postgres -d procurement --clean --if-exists --single-transaction /tmp/procurement.dump
if ($LASTEXITCODE -ne 0) { Write-Host "procurement restore FAILED" -ForegroundColor Red; exit 1 }
Write-Host "Restoring casdoor DB..." -ForegroundColor Cyan
docker cp "backups\casdoor.dump" procurement-postgres:/tmp/casdoor.dump
docker exec procurement-postgres pg_restore -U postgres -d casdoor --clean --if-exists --single-transaction /tmp/casdoor.dump
if ($LASTEXITCODE -ne 0) { Write-Host "casdoor restore FAILED" -ForegroundColor Red; exit 1 }
Write-Host "Starting services..." -ForegroundColor Cyan
docker compose start casdoor api
if ($LASTEXITCODE -ne 0) { exit 1 }
Write-Host ""
Write-Host "Restore complete." -ForegroundColor Green
Write-Host "App : http://localhost" -ForegroundColor Green
Write-Host "Casdoor : http://localhost:8080 (admin / 123)" -ForegroundColor Green
-27
View File
@@ -1,27 +0,0 @@
#!/usr/bin/env bash
# Restore the committed demo-state dumps (backups/*.dump) into the running
# stack (Linux/macOS/Git Bash). REPLACES all data in the `procurement` and
# `casdoor` databases.
#
# bash backups/restore.sh
set -euo pipefail
cd "$(dirname "$0")/.."
echo "Stopping api + casdoor (they hold DB connections)..."
docker compose stop api casdoor
echo "Restoring procurement DB..."
docker cp backups/procurement.dump procurement-postgres:/tmp/procurement.dump
docker exec procurement-postgres pg_restore -U postgres -d procurement --clean --if-exists --single-transaction /tmp/procurement.dump
echo "Restoring casdoor DB..."
docker cp backups/casdoor.dump procurement-postgres:/tmp/casdoor.dump
docker exec procurement-postgres pg_restore -U postgres -d casdoor --clean --if-exists --single-transaction /tmp/casdoor.dump
echo "Starting services..."
docker compose start casdoor api
echo ""
echo "Restore complete."
echo "App : http://localhost"
echo "Casdoor : http://localhost:8080 (admin / 123)"
Binary file not shown.
+235
View File
@@ -0,0 +1,235 @@
services:
api:
build:
context: ./backend
dockerfile: Dockerfile
container_name: procurement-api
depends_on:
minio-init:
condition: service_completed_successfully
required: true
postgres:
condition: service_healthy
required: true
environment:
PROCUREMENT_CASDOOR_ADMIN_PASSWORD: "123"
PROCUREMENT_CASDOOR_ADMIN_USER: admin
PROCUREMENT_CASDOOR_APP_NAME: inventory-app
PROCUREMENT_CASDOOR_AUDIENCE: inventory-app-client
PROCUREMENT_CASDOOR_CLIENT_ID: inventory-app-client
PROCUREMENT_CASDOOR_CLIENT_SECRET: inventory-app-secret-2026
PROCUREMENT_CASDOOR_ENDPOINT: http://casdoor:8000
PROCUREMENT_CASDOOR_ISSUER: https://${AUTH_HOST}
PROCUREMENT_CASDOOR_ORGANIZATION: inventory
PROCUREMENT_DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/procurement
PROCUREMENT_MINIO_ACCESS_KEY: minioadmin
PROCUREMENT_MINIO_BUCKET: procurement-files
PROCUREMENT_MINIO_ENDPOINT: minio:9000
PROCUREMENT_MINIO_SECRET_KEY: minioadmin
PROCUREMENT_MINIO_SECURE: "false"
labels:
traefik.enable: "true"
traefik.http.routers.api-redir.entrypoints: web
traefik.http.routers.api-redir.middlewares: redirect-https@file
traefik.http.routers.api-redir.rule: Host(`${API_HOST}`)
traefik.http.routers.api.entrypoints: websecure
traefik.http.routers.api.rule: Host(`${API_HOST}`)
traefik.http.routers.api.tls: "true"
traefik.http.routers.api.tls.certresolver: letsencrypt
traefik.http.services.api.loadbalancer.server.port: "8000"
networks:
default: null
traefik_network: null
ports:
- "8000"
restart: unless-stopped
casdoor:
container_name: procurement-casdoor
depends_on:
postgres:
condition: service_healthy
required: true
environment:
RUNNING_IN_DOCKER: "true"
dataSourceName: "user=postgres password=postgres host=postgres port=5432 sslmode=disable dbname=casdoor"
dbName: casdoor
driverName: postgres
origin: https://${AUTH_HOST}
image: casbin/casdoor:latest
labels:
traefik.enable: "true"
traefik.http.routers.casdoor-redir.entrypoints: web
traefik.http.routers.casdoor-redir.middlewares: redirect-https@file
traefik.http.routers.casdoor-redir.rule: Host(`${AUTH_HOST}`)
traefik.http.routers.casdoor.entrypoints: websecure
traefik.http.routers.casdoor.rule: Host(`${AUTH_HOST}`)
traefik.http.routers.casdoor.tls: "true"
traefik.http.routers.casdoor.tls.certresolver: letsencrypt
traefik.http.services.casdoor.loadbalancer.server.port: "8000"
networks:
default: null
traefik_network: null
ports:
- "8081:8000"
restart: unless-stopped
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
args:
DEV_LOGIN_ENABLED: "true"
container_name: procurement-frontend
depends_on:
api:
condition: service_started
required: true
labels:
traefik.enable: "true"
traefik.http.routers.frontend-redir.entrypoints: web
traefik.http.routers.frontend-redir.middlewares: redirect-https@file
traefik.http.routers.frontend-redir.rule: Host(`${APP_HOST}`)
traefik.http.routers.frontend.entrypoints: websecure
traefik.http.routers.frontend.rule: Host(`${APP_HOST}`)
traefik.http.routers.frontend.tls: "true"
traefik.http.routers.frontend.tls.certresolver: letsencrypt
traefik.http.services.frontend.loadbalancer.server.port: "80"
networks:
default: null
traefik_network: null
restart: unless-stopped
minio:
command:
- server
- /data
- --console-address
- :9001
container_name: procurement-minio
environment:
MINIO_ROOT_PASSWORD: minioadmin
MINIO_ROOT_USER: minioadmin
image: minio/minio:latest
labels:
traefik.enable: "true"
traefik.http.routers.minio-api-redir.entrypoints: web
traefik.http.routers.minio-api-redir.middlewares: redirect-https@file
traefik.http.routers.minio-api-redir.rule: Host(`${S3_HOST}`)
traefik.http.routers.minio-api.entrypoints: websecure
traefik.http.routers.minio-api.rule: Host(`${S3_HOST}`)
traefik.http.routers.minio-api.service: minio-api-svc
traefik.http.routers.minio-api.tls: "true"
traefik.http.routers.minio-api.tls.certresolver: letsencrypt
traefik.http.routers.minio-console-redir.entrypoints: web
traefik.http.routers.minio-console-redir.middlewares: redirect-https@file
traefik.http.routers.minio-console-redir.rule: Host(`${STORAGE_HOST}`)
traefik.http.routers.minio-console.entrypoints: websecure
traefik.http.routers.minio-console.rule: Host(`${STORAGE_HOST}`)
traefik.http.routers.minio-console.service: minio-console-svc
traefik.http.routers.minio-console.tls: "true"
traefik.http.routers.minio-console.tls.certresolver: letsencrypt
traefik.http.services.minio-api-svc.loadbalancer.server.port: "9000"
traefik.http.services.minio-console-svc.loadbalancer.server.port: "9001"
networks:
default: null
traefik_network: null
ports:
- "9000"
- "9001"
restart: unless-stopped
volumes:
- minio_data:/data
minio-init:
container_name: procurement-minio-init
depends_on:
minio:
condition: service_started
required: true
entrypoint:
- /bin/sh
- -c
- |
until mc alias set local http://$${MINIO_ENDPOINT} $${MINIO_ROOT_USER} $${MINIO_ROOT_PASSWORD}; do
echo 'waiting for minio server...';
sleep 2;
done;
mc mb --ignore-existing local/$${MINIO_BUCKET};
echo bucket $${MINIO_BUCKET} ready;
environment:
MINIO_BUCKET: procurement-files
MINIO_ENDPOINT: minio:9000
MINIO_ROOT_PASSWORD: minioadmin
MINIO_ROOT_USER: minioadmin
image: minio/mc:latest
networks:
default: null
restart: 'no'
postgres:
container_name: procurement-postgres
environment:
POSTGRES_DB: procurement
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
healthcheck:
test:
- CMD-SHELL
- pg_isready -U postgres -d procurement
timeout: 5s
interval: 5s
retries: 10
image: postgres:18
networks:
default: null
ports:
- "5433:5432"
restart: unless-stopped
volumes:
- postgres_data:/var/lib/postgresql
- ./sql/migration.sql:/docker-entrypoint-initdb.d/01-migration.sql:ro
- ./sql/02-casdoor-db.sql:/docker-entrypoint-initdb.d/02-casdoor-db.sql:ro
traefik:
command:
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --providers.docker.network=traefik_network
- --providers.file.filename=/etc/traefik/dynamic.yml
- --entrypoints.web.address=:80
- --entrypoints.websecure.address=:443
- --certificatesresolvers.letsencrypt.acme.email=${TRAEFIK_ACME_EMAIL}
- --certificatesresolvers.letsencrypt.acme.storage=/certs/acme.json
- --certificatesresolvers.letsencrypt.acme.httpchallenge=true
- --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web
- --log.level=INFO
container_name: procurement-traefik
environment:
TRAEFIK_ACME_EMAIL: ${TRAEFIK_ACME_EMAIL}
image: traefik:v3.6
networks:
traefik_network: null
ports:
- "80"
- "443"
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./infra/traefik-dynamic.yml:/etc/traefik/dynamic.yml:ro
- traefik_certs:/certs
networks:
default:
name: v15_default
traefik_network:
name: traefik_network
driver: bridge
volumes:
minio_data:
name: v15_minio_data
postgres_data:
name: v15_postgres_data
traefik_certs:
name: v15_traefik_certs
+1 -1
View File
@@ -35,7 +35,7 @@ to re-run).
## 2. Login flow (in-page, no redirect) ## 2. Login flow (in-page, no redirect)
``` ```
Browser ──► POST /api/v1/auth/login {username, password, organization?} Browser ──► POST /api/v1/auth/login {username, password}
│ │
▼ ▼
Backend ──► POST http://casdoor:8000/api/login/oauth/access_token Backend ──► POST http://casdoor:8000/api/login/oauth/access_token
+8 -1
View File
@@ -34,7 +34,14 @@ server {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
# Static assets cache # index.html itself must never be cached: after a redeploy the hashed
# /assets/* names change, and a stale cached HTML would 404 on the old
# chunks (blank page). Content-hashed assets below stay immutable.
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# Static assets cache — hashed filenames are content-addressed
location /assets/ { location /assets/ {
expires 30d; expires 30d;
add_header Cache-Control "public, immutable"; add_header Cache-Control "public, immutable";
+3 -4
View File
@@ -1,7 +1,7 @@
import { lazy, Suspense } from "react"; import { lazy, Suspense } from "react";
import { Navigate, Route, Routes } from "react-router-dom"; import { Navigate, Route, Routes } from "react-router-dom";
import { useAuthStore } from "./store/auth"; import { useAuthStore } from "./store/auth";
import { MobileTabBar } from "./components/layout/MobileTabBar"; import { ErrorBoundary } from "./components/ErrorBoundary";
// هر صفحه با React.lazy لود می‌شود (code splitting) // هر صفحه با React.lazy لود می‌شود (code splitting)
const DashboardPage = lazy(() => const DashboardPage = lazy(() =>
@@ -66,7 +66,7 @@ function HomeRoute() {
export default function App() { export default function App() {
return ( return (
<> <ErrorBoundary>
<Suspense fallback={<Loading />}> <Suspense fallback={<Loading />}>
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
@@ -196,7 +196,6 @@ export default function App() {
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</Suspense> </Suspense>
<MobileTabBar /> </ErrorBoundary>
</>
); );
} }
+48
View File
@@ -0,0 +1,48 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
interface Props {
children: ReactNode;
}
interface State {
error: Error | null;
}
// خطای رندر/لود ماژول نباید کل DOM را خالی کند — با این باکس، به‌جای صفحه‌ی
// سفید، پیام + دکمه‌ی «تلاش مجدد» (بارگذاری مجدد صفحه) نمایش داده می‌شود.
export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error("ErrorBoundary caught:", error, info);
}
private retry = () => {
this.setState({ error: null });
window.location.reload();
};
render() {
if (!this.state.error) return this.props.children;
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-bg px-6 text-center text-ink">
<div className="text-4xl">⚠️</div>
<h1 className="text-lg font-bold">خطایی در نمایش صفحه رخ داد</h1>
<p className="max-w-md text-sm text-muted">
{this.state.error.message || "مشکلی پیش آمد — لطفاً دوباره تلاش کنید."}
</p>
<button
type="button"
onClick={this.retry}
className="cursor-pointer rounded-lg bg-accent px-5 py-2.5 text-sm font-bold text-white transition-colors hover:bg-accent-dark"
>
تلاش مجدد
</button>
</div>
);
}
}
+9 -2
View File
@@ -39,10 +39,17 @@ function buildIso(jy: number, jm: number, jd: number, h: number, m: number): str
return new Date(gy, gm - 1, gd, h, m).toISOString(); return new Date(gy, gm - 1, gd, h, m).toISOString();
} }
// وقتی هنوز زمان مشخصی ست نشده، ساعت/دقیقه‌ی فعلی سیستم پیش‌فرض است
// (نه ۰۰:۰۰) — دکمه‌ی «امروز» و انتخاب روز، همین زمان را نگه می‌دارند.
function nowTime(): { h: number; m: number } {
const d = new Date();
return { h: d.getHours(), m: d.getMinutes() };
}
function timeOf(iso: string | null): { h: number; m: number } { function timeOf(iso: string | null): { h: number; m: number } {
if (!iso) return { h: 0, m: 0 }; if (!iso) return nowTime();
const d = new Date(iso); const d = new Date(iso);
if (Number.isNaN(d.getTime())) return { h: 0, m: 0 }; if (Number.isNaN(d.getTime())) return nowTime();
return { h: d.getHours(), m: d.getMinutes() }; return { h: d.getHours(), m: d.getMinutes() };
} }
+40 -35
View File
@@ -8,19 +8,17 @@ export interface TermsheetItem {
} }
export interface TermsheetCardProps { export interface TermsheetCardProps {
index: number; // 1-based → «گزینه N» index: number;
milestones: Milestone[]; milestones: Milestone[];
onMilestonesChange: (ms: Milestone[]) => void; onMilestonesChange: (ms: Milestone[]) => void;
// اقلام قابل تیک (مرحله ۲)
items?: TermsheetItem[]; items?: TermsheetItem[];
coveredIds?: string[]; // tempIdهایی که تیک دارند coveredIds?: string[];
onCoverageChange?: (ids: string[]) => void; onCoverageChange?: (ids: string[]) => void;
onRemove?: () => void; onRemove?: () => void;
defaultAll?: boolean; // وقتی هیچ تیکی نیست، پیش‌فرض همه تیک بخورند defaultAll?: boolean;
removable?: boolean; removable?: boolean;
} }
// کارت ترم‌شیت — هم در ویزارد (مرحله ۳) هم در مودال پذیرش/ویرایش
export function TermsheetCard({ export function TermsheetCard({
index, index,
milestones, milestones,
@@ -33,7 +31,6 @@ export function TermsheetCard({
removable = true, removable = true,
}: TermsheetCardProps) { }: TermsheetCardProps) {
const covered = new Set(coveredIds); const covered = new Set(coveredIds);
// پیش‌فرض: وقتی هیچ قلمی تیک نخورده و defaultAll فعال است → همه تیک
const effectiveCovered = covered.size === 0 && defaultAll ? items.map((i) => i.tempId) : coveredIds; const effectiveCovered = covered.size === 0 && defaultAll ? items.map((i) => i.tempId) : coveredIds;
const toggle = (tempId: string, checked: boolean) => { const toggle = (tempId: string, checked: boolean) => {
@@ -49,17 +46,25 @@ export function TermsheetCard({
}; };
return ( return (
<div className="variant-card"> <div className="rounded-xl border border-line bg-panel p-4">
<div className="variant-head"> <div className="flex items-center justify-between mb-2">
<span className="variant-num">گزینه {faNum(index)}</span> <span className="font-bold text-[13px]">گزینه {faNum(index)}</span>
{removable && onRemove && ( {removable && onRemove && (
<button type="button" className="variant-x" title="حذف" onClick={onRemove}> <button
× type="button"
className="text-gray-400 hover:text-red-500 p-1 rounded transition"
title="حذف گزینه"
onClick={onRemove}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button> </button>
)} )}
</div> </div>
<div className="mb-1.5 text-[12px] text-muted">مراحل پرداخت:</div> <div className="mb-1.5 text-[12px] text-muted">مراحل پرداخت:</div>
<div className="mb-1 flex flex-col gap-1.5"> <div className="mb-2 flex flex-col gap-1.5">
{milestones.map((m, i) => ( {milestones.map((m, i) => (
<MilestoneRow <MilestoneRow
key={i} key={i}
@@ -69,33 +74,33 @@ export function TermsheetCard({
/> />
))} ))}
</div> </div>
<button type="button" className="btn-line-file mt-1" onClick={addMilestone}>
+ مرحله پرداخت <button
type="button"
className="w-full py-1.5 border border-dashed border-gray-300 hover:border-blue-500 hover:text-blue-600 rounded-lg text-[12px] text-gray-500 transition mb-3"
onClick={addMilestone}
>
+ مرحله پرداخت
</button> </button>
{items.length > 0 && ( {items.length > 0 && (
<div className="ts-items-box"> <div className="pt-2 border-t border-line/60">
<div className="ts-items-title">این termsheet کدام اقلام (مرحله ۲) را پوشش می‌دهد؟</div> <div className="text-[12px] text-muted mb-1.5">این termsheet کدام اقلام (مرحله ۲) را پوشش می‌دهد؟</div>
{items.length === 0 ? ( <div className="flex flex-col gap-1 text-[12px]">
<div className="ts-items-empty">هنوز قلمی در مرحله‌ی ۲ اضافه نشده.</div> {items.map((it) => (
) : ( <label key={it.tempId} className="flex items-center gap-2 cursor-pointer text-gray-700">
<div> <input
{items.map((it) => ( type="checkbox"
<label key={it.tempId} className="ts-item-label"> className="rounded border-gray-300"
<input checked={effectiveCovered.includes(it.tempId)}
type="checkbox" onChange={(e) => toggle(it.tempId, e.target.checked)}
className="ts-item-cb" />
data-item-temp-id={it.tempId} <span>{it.label}</span>
checked={effectiveCovered.includes(it.tempId)} </label>
onChange={(e) => toggle(it.tempId, e.target.checked)} ))}
/> </div>
{it.label}
</label>
))}
</div>
)}
</div> </div>
)} )}
</div> </div>
); );
} }
+39 -10
View File
@@ -5,36 +5,65 @@ import { faNum } from "../lib/formatters";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
export interface TopicGridProps { export interface TopicGridProps {
topics: Topic[]; // از store (API) می‌آید topics: Topic[];
selectedTopic?: string; selectedTopic?: string;
onSelect: (topicName: string) => void; onSelect: (topicName: string) => void;
showCounts?: boolean; // تعداد تأمین‌کننده‌های هر تاپیک showCounts?: boolean;
counts?: Record<string, number>; // topic name → count counts?: Record<string, number>;
} }
// گرید تاپیک‌ها (از /material-categories — نه لیست ثابت)
export function TopicGrid({ topics, selectedTopic, onSelect, showCounts = false, counts }: TopicGridProps) { export function TopicGrid({ topics, selectedTopic, onSelect, showCounts = false, counts }: TopicGridProps) {
if (!topics.length) { if (!topics.length) {
return <EmptyState emoji="🗂️" text="در حال بارگذاری دسته‌بندی‌ها…" />; return <EmptyState emoji="🗂️" text="در حال بارگذاری دسته‌بندی‌ها…" />;
} }
return ( return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4"> <div className="grid grid-cols-2 gap-3 sm:gap-4 sm:grid-cols-3 lg:grid-cols-4">
{topics.map((t) => { {topics.map((t) => {
const selected = selectedTopic != null && normalize(selectedTopic) === normalize(t.name); const selected = selectedTopic != null && normalize(selectedTopic) === normalize(t.name);
const count = counts?.[t.name] ?? 0; const count = counts?.[t.name] ?? 0;
return ( return (
<button <button
type="button" type="button"
key={t.key} key={t.key}
className={cn("topic-card", selected && "selected")}
onClick={() => onSelect(t.name)} onClick={() => onSelect(t.name)}
className={cn(
"group relative flex flex-col items-center justify-between rounded-2xl border bg-white p-2.5 sm:p-4 text-center transition-all duration-200 cursor-pointer shadow-xs active:scale-[0.98] overflow-hidden",
selected
? "border-blue-600 ring-2 ring-blue-600/25 shadow-md bg-blue-50/20"
: "border-slate-200 hover:border-blue-300 hover:shadow-md"
)}
> >
<img src={t.icon} alt="" /> {/* کادر تصویر بسیار بزرگ‌تر شده برای پر کردن تمام فضای مربع */}
<div className="t-name">{t.name}</div> <div className="flex h-36 sm:h-48 md:h-52 w-full items-center justify-center overflow-hidden rounded-xl bg-slate-50/50">
{showCounts && <div className="t-count">{faNum(count)} تأمین‌کننده</div>} <img
src={t.icon}
alt={t.name}
className="h-full w-full object-contain transform scale-115 group-hover:scale-125 transition-transform duration-300 drop-shadow-xs"
loading="lazy"
/>
</div>
{/* بخش عنوان و تعداد */}
<div className="mt-3 w-full pb-1">
<div
className={cn(
"truncate text-[13px] sm:text-[15px] font-bold transition-colors",
selected ? "text-blue-600" : "text-slate-800 group-hover:text-blue-600"
)}
>
{t.name}
</div>
{showCounts && (
<div className="mt-0.5 truncate text-[11px] sm:text-xs text-slate-400 font-medium">
{faNum(count)} تأمین‌کننده
</div>
)}
</div>
</button> </button>
); );
})} })}
</div> </div>
); );
} }
+1 -1
View File
@@ -64,6 +64,6 @@ export function PageLayout({
className?: string; className?: string;
}) { }) {
return ( return (
<div className={cn("mx-auto w-full max-w-[1200px] px-4 py-4 max-md:pb-24", className)}>{children}</div> <div className={cn("mx-auto w-full max-w-[1200px] px-4 py-4", className)}>{children}</div>
); );
} }
@@ -1,100 +0,0 @@
import { NavLink, useLocation } from "react-router-dom";
import {
ArrowLeftRight,
Boxes,
Building2,
CalendarDays,
LayoutDashboard,
PackageSearch,
Scale,
Truck,
UsersRound,
type LucideIcon,
} from "lucide-react";
import { cn } from "../../lib/utils";
import { useAuthStore } from "../../store/auth";
interface TabDef {
to: string;
label: string;
icon: LucideIcon;
end?: boolean;
}
// تب‌های دفتر تدارکات (ادمین / مدیر تدارکات)
const PROC_TABS: TabDef[] = [
{ to: "/", label: "داشبورد", icon: LayoutDashboard, end: true },
{ to: "/suppliers", label: "تأمین‌کننده‌ها", icon: Truck },
{ to: "/projects", label: "پروژه‌ها", icon: Building2 },
{ to: "/units", label: "ضرایب", icon: Scale },
];
// تب‌های پنل انبار برای مدیر پروژه
const PM_INV_TABS: TabDef[] = [
{ to: "/inventory", label: "انبار", icon: Boxes, end: true },
{ to: "/inventory/change", label: "تغییر موجودی", icon: ArrowLeftRight },
{ to: "/inventory/daily", label: "کنترل روزانه", icon: CalendarDays },
{ to: "/inventory/arrivals", label: "کالاهای در راه", icon: PackageSearch },
];
// تب‌های پنل انبار برای ادمین / مدیر تدارکات (آینه‌ی ناوبری بالای دسکتاپ)
const ADMIN_INV_TABS: TabDef[] = [
{ to: "/inventory", label: "انبار", icon: Boxes, end: true },
{ to: "/inventory/daily", label: "کنترل روزانه", icon: CalendarDays },
{ to: "/inventory/arrivals", label: "کالاهای در راه", icon: PackageSearch },
{ to: "/inventory/org", label: "سازمان‌دهی", icon: UsersRound },
];
function TabBar({ tabs }: { tabs: TabDef[] }) {
return (
<nav
className="fixed inset-x-0 bottom-0 z-40 border-t border-line bg-panel md:hidden"
style={{ paddingBottom: "env(safe-area-inset-bottom)" }}
>
<div className="grid grid-cols-4">
{tabs.map((t) => (
<NavLink
key={t.to}
to={t.to}
end={t.end}
className={({ isActive }) =>
cn(
"flex min-h-[56px] flex-col items-center justify-center gap-0.5 whitespace-nowrap px-1 text-[10.5px] font-medium transition-colors",
isActive ? "text-accent" : "text-muted",
)
}
>
<t.icon className="h-5 w-5" strokeWidth={1.8} />
<span>{t.label}</span>
</NavLink>
))}
</div>
</nav>
);
}
// نوار تب پایینِ موبایل — فقط زیر 768px (md:hidden) و فقط در صفحاتِ نقش کاربر:
// مدیر پروژه → تب‌های انبار؛ ادمین/مدیر تدارکات → تب‌های دفتر یا انبار (بر اساس مسیر).
// در ویزارد نمایش داده نمی‌شود تا با جابه‌جایی تصادفی، کارِ کاربر از بین نرود.
export function MobileTabBar() {
const role = useAuthStore((s) => s.user?.role);
const { pathname } = useLocation();
if (pathname === "/login" || pathname === "/admin") return null;
const isPM = role === "pm";
const isOffice = role === "admin" || role === "procurement_manager";
if (pathname.startsWith("/inventory")) {
if (isPM) return <TabBar tabs={PM_INV_TABS} />;
if (isOffice) return <TabBar tabs={ADMIN_INV_TABS} />;
return null;
}
if (pathname === "/wizard") return null;
const onOfficePage =
pathname === "/" ||
pathname === "/projects" ||
pathname === "/units" ||
pathname.startsWith("/suppliers");
if (onOfficePage && isOffice) return <TabBar tabs={PROC_TABS} />;
return null;
}
+6 -6
View File
@@ -4,17 +4,17 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0", "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:pointer-events-none disabled:opacity-50 active:scale-[0.98] [&_svg]:pointer-events-none [&_svg]:shrink-0",
{ {
variants: { variants: {
variant: { variant: {
default: "bg-accent text-white shadow-sm hover:bg-accent-dark", default: "bg-accent text-white shadow-sm hover:bg-accent-dark",
primary: "bg-accent text-white shadow-sm hover:bg-accent-dark", primary: "bg-accent text-white shadow-sm hover:bg-accent-dark",
slate: "bg-slate-bg text-ink hover:bg-line", slate: "bg-slate-bg text-ink border border-transparent hover:brightness-95",
line: "border border-line bg-panel text-ink hover:bg-slate-bg", line: "border border-line bg-panel text-ink shadow-sm hover:bg-slate-bg",
ghost: "hover:bg-slate-bg text-ink", ghost: "hover:bg-slate-bg text-ink",
green: "bg-green text-white hover:brightness-95", green: "bg-green text-white shadow-sm hover:brightness-95",
danger: "bg-red-bg text-red hover:bg-red hover:text-white", danger: "bg-red-bg text-red shadow-sm hover:bg-red hover:text-white",
}, },
size: { size: {
default: "h-9 px-4 py-2", default: "h-9 px-4 py-2",
@@ -44,4 +44,4 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
); );
Button.displayName = "Button"; Button.displayName = "Button";
export { Button, buttonVariants }; export { Button, buttonVariants };
+162 -23
View File
@@ -357,6 +357,57 @@ interface RawMaterialCategory {
name: string; name: string;
} }
// یک ردیف material_units از بک‌اند (با expand=unit)
interface RawMaterialUnit {
id: string;
material_id: string;
brand_material_id: string | null;
unit_id: number;
conversion_factor: number;
is_default: boolean;
unit?: { id: number; code: string; name_fa: string | null } | null;
}
export interface MaterialUnitRow {
id: string;
material_id: string;
brand_material_id: string | null;
unit_id: number;
conversion_factor: number;
is_default: boolean;
unit: { id: number; code: string; name_fa: string | null } | null;
}
function adaptMaterialUnit(raw: RawMaterialUnit): MaterialUnitRow {
return {
id: raw.id,
material_id: raw.material_id,
brand_material_id: raw.brand_material_id,
unit_id: raw.unit_id,
conversion_factor: raw.conversion_factor,
is_default: raw.is_default,
unit: raw.unit ?? null,
};
}
// اتصال برند ↔ متریال (brand_materials) برای قید برند در ضریب‌های تبدیل
interface RawBrandLink {
id: string;
brand_id: string;
material_id: string;
brand?: { id: string; name: string } | null;
}
export interface BrandLinkRow {
id: string;
brand_id: string;
brand_name: string;
}
function adaptBrandLink(raw: RawBrandLink): BrandLinkRow {
return { id: raw.id, brand_id: raw.brand_id, brand_name: raw.brand?.name || "" };
}
// فایل پیوست — پاسخ بک‌اند (metadata؛ بایت‌ها از endpoint دانلود می‌آیند) // فایل پیوست — پاسخ بک‌اند (metadata؛ بایت‌ها از endpoint دانلود می‌آیند)
interface RawAttachment { interface RawAttachment {
id: string; id: string;
@@ -703,10 +754,11 @@ export const api = {
quantity: li.quantity ?? 1, quantity: li.quantity ?? 1,
project_ids: [], project_ids: [],
})); }));
// واحدهایی که در بک‌اند نیستند را بساز (issue: واحد جدید) — واحد خالی = «عدد» // واحدِ تایپ‌شده‌ی کاربر در بک‌اند نیست → همان موقع ساخته می‌شود (بدون هیچ
// مقدار پیش‌فرض/واحد جادویی؛ واحد خالی یعنی خطای صریح «واحد مشخص نشده»)
// پروژه‌های هر قلم هم به id واقعی ترجمه می‌شوند (در صورت نبودن ساخته می‌شوند) // پروژه‌های هر قلم هم به id واقعی ترجمه می‌شوند (در صورت نبودن ساخته می‌شوند)
for (let i = 0; i < lineItems.length; i++) { for (let i = 0; i < lineItems.length; i++) {
lineItems[i].unit_id = await unitIdByNameOrCreate(lookup, q.line_items[i].unit || "عدد"); lineItems[i].unit_id = await unitIdByNameOrCreate(lookup, q.line_items[i].unit);
for (const p of q.line_items[i].projects || []) { for (const p of q.line_items[i].projects || []) {
const pid = looksLikeUuid(p.id) ? p.id : await projectIdByNameOrCreate(p.name); const pid = looksLikeUuid(p.id) ? p.id : await projectIdByNameOrCreate(p.name);
if (pid) lineItems[i].project_ids.push(pid); if (pid) lineItems[i].project_ids.push(pid);
@@ -822,10 +874,16 @@ export const api = {
item_decisions: (patch.line_items || []).map((li) => { item_decisions: (patch.line_items || []).map((li) => {
const idx = li.accepted_ts_id ? acceptedTs.findIndex((t) => t.id === li.accepted_ts_id) : -1; const idx = li.accepted_ts_id ? acceptedTs.findIndex((t) => t.id === li.accepted_ts_id) : -1;
const chosen = !li.rejected && idx >= 0; const chosen = !li.rejected && idx >= 0;
// پروژه‌ی واحدِ انتخاب‌شده برای این قلم — مستقیم داخل decision می‌رود تا
// «کالاهای در راه» همان مدیر پروژه را (با item_decisions.project_id)
// نشان دهد؛ قبلاً فقط item_projects جداگانه می‌رفت و decision بدون
// پروژه می‌ماند (قلم‌ها در پنل هیچ مدیر پروژه‌ای دیده نمی‌شدند)
const chosenProjId = itemProjects.find((p) => p.line_item_id === li.id)?.project_ids[0];
return { return {
line_item_id: li.id, line_item_id: li.id,
status: chosen ? "chosen" : "rejected", status: chosen ? "chosen" : "rejected",
...(chosen ? { accepted_termsheet_index: idx } : {}), ...(chosen ? { accepted_termsheet_index: idx } : {}),
...(chosen && chosenProjId ? { project_id: chosenProjId } : {}),
...(li.unit_price != null ? { adjusted_unit_price: li.unit_price } : {}), ...(li.unit_price != null ? { adjusted_unit_price: li.unit_price } : {}),
}; };
}), }),
@@ -959,19 +1017,18 @@ export const api = {
}, },
create: async (m: Material): Promise<Material> => { create: async (m: Material): Promise<Material> => {
const lookup = await fetchLookup(); const lookup = await fetchLookup();
// v2.5: category_id اختیاری است — اگر نامِ تایپ‌شده یکی از ۸ دسته‌ی ثابت
// نباشد، متریال به‌صورت سراسری (بدون دسته) ساخته می‌شود و دسته‌اش بعداً
// از اولین تأمین‌کننده‌ی مرتبط استنتاج می‌شود (first-link-wins).
// دسته‌ی rogue دیگر هرگز ساخته نمی‌شود — فقط تطبیق با ۸ دسته‌ی موجود؛
// در صورت نبود تطبیق، خطا نمی‌دهیم و category_id را null می‌فرستیم.
const categoryId = m.category ? lookup.categoryNameToId.get(norm(m.category)) : undefined; const categoryId = m.category ? lookup.categoryNameToId.get(norm(m.category)) : undefined;
// دسته‌ها همان ۸ دسته‌ی ثابت سیستم‌اند و هرگز به‌صورت خودکار ساخته // واحد مرجع — بدون مقدار پیش‌فرض؛ متریال بدون واحدِ صریح ساخته نمی‌شود
// نمی‌شوند — قبلاً این‌جا برای هر نامِ ناشناخته یک رکورد material_categories // (بک‌اند canonical_unit_id را الزامی می‌داند)
// می‌ساخت و باگ «پریز» (دسته‌ی rogue) را به وجود آورد. const unitName = (m.base_unit || "").trim();
if (!categoryId) { if (!unitName) {
throw new Error( throw new Error("واحد مرجع متریال مشخص نشده است — واحد را وارد کنید");
m.category
? `دسته «${m.category}» در سامانه تعریف نشده است — یکی از ۸ دسته‌ی موجود را انتخاب کنید`
: "دسته‌ی متریال مشخص نیست — یکی از ۸ دسته‌ی موجود را انتخاب کنید",
);
} }
// واحد مرجع — اگر تعریف نشده، بساز (بک‌اند canonical_unit_id الزامی دارد)
const unitName = m.base_unit || "عدد";
const unitId = await unitIdByNameOrCreate(lookup, unitName); const unitId = await unitIdByNameOrCreate(lookup, unitName);
// برند — اگر تعریف نشده، در جدول brands ساخته و به متریال متصل می‌شود // برند — اگر تعریف نشده، در جدول brands ساخته و به متریال متصل می‌شود
const brandId = await brandIdByNameOrCreate(lookup, m.brand); const brandId = await brandIdByNameOrCreate(lookup, m.brand);
@@ -988,7 +1045,7 @@ export const api = {
if (defId) attrs.push({ attribute_definition_id: defId, value: spec }); if (defId) attrs.push({ attribute_definition_id: defId, value: spec });
} }
const raw = await postData<RawMaterial>("/materials", { const raw = await postData<RawMaterial>("/materials", {
category_id: categoryId, category_id: categoryId ?? null,
name, name,
topic: m.topic || null, topic: m.topic || null,
canonical_unit_id: unitId, canonical_unit_id: unitId,
@@ -1000,6 +1057,10 @@ export const api = {
tempIdToRealId.set(m.id, raw.id); tempIdToRealId.set(m.id, raw.id);
return adaptMaterial(raw, lookup); return adaptMaterial(raw, lookup);
}, },
// تغییر واحد مرجع متریال (صفحه‌ی ضرایب تبدیل)
updateCanonicalUnit: async (id: string, canonical_unit_id: number): Promise<void> => {
await patchData(`/materials/${id}`, { canonical_unit_id });
},
}, },
projects: { projects: {
@@ -1071,6 +1132,52 @@ export const api = {
}, },
}, },
// ---------- واحدهای هر متریال (material_units — ضریب تبدیل نسبت به واحد مرجع) ----------
materialUnits: {
list: async (materialId: string): Promise<MaterialUnitRow[]> => {
const data = await getData<RawMaterialUnit[]>(
`/materials/${materialId}/units?page_size=100&expand=unit,brand_material`,
);
return data.map(adaptMaterialUnit);
},
create: async (
materialId: string,
body: {
brand_material_id?: string | null;
unit_id: number;
conversion_factor: number;
is_default?: boolean;
},
): Promise<MaterialUnitRow> => {
const raw = await postData<RawMaterialUnit>(`/materials/${materialId}/units`, body);
return adaptMaterialUnit(raw);
},
update: async (
materialId: string,
muId: string,
body: { conversion_factor?: number; is_default?: boolean },
): Promise<MaterialUnitRow> => {
const raw = await patchData<RawMaterialUnit>(`/materials/${materialId}/units/${muId}`, body);
return adaptMaterialUnit(raw);
},
remove: async (materialId: string, muId: string): Promise<void> => {
await deleteData(`/materials/${materialId}/units/${muId}`);
},
},
// ---------- اتصال برند↔متریال (برای قید برند در ضریب‌های تبدیل) ----------
brandMaterials: {
listByMaterial: async (materialId: string): Promise<BrandLinkRow[]> => {
const data = await getData<RawBrandLink[]>(
`/brand-materials?material_id=${materialId}&page_size=100&expand=brand`,
);
return data.map(adaptBrandLink);
},
link: async (materialId: string, brandId: string): Promise<void> => {
await postData("/brand-materials", { brand_id: brandId, material_id: materialId });
},
},
// ---------- پیوست‌ها (invoice / proforma) — بایت‌ها در MinIO ---------- // ---------- پیوست‌ها (invoice / proforma) — بایت‌ها در MinIO ----------
files: { files: {
upload: (quoteId: string, kind: FileKind, file: File): Promise<Attachment> => upload: (quoteId: string, kind: FileKind, file: File): Promise<Attachment> =>
@@ -1151,7 +1258,7 @@ async function buildChildrenPayload(
project_ids: [], project_ids: [],
})); }));
for (let i = 0; i < lis.length; i++) { for (let i = 0; i < lis.length; i++) {
lis[i].unit_id = await unitIdByNameOrCreate(lookup, lineItems[i].unit || "عدد"); lis[i].unit_id = await unitIdByNameOrCreate(lookup, lineItems[i].unit);
for (const p of lineItems[i].projects || []) { for (const p of lineItems[i].projects || []) {
const pid = looksLikeUuid(p.id) ? p.id : await projectIdByNameOrCreate(p.name); const pid = looksLikeUuid(p.id) ? p.id : await projectIdByNameOrCreate(p.name);
if (pid) lis[i].project_ids.push(pid); if (pid) lis[i].project_ids.push(pid);
@@ -1180,20 +1287,23 @@ async function buildChildrenPayload(
return { line_items: lis, termsheets: tss, item_termsheets: itemTerms }; return { line_items: lis, termsheets: tss, item_termsheets: itemTerms };
} }
// واحد را پیدا می‌کند؛ اگر وجود نداشت، در بک‌اند می‌سازد و id برمی‌گرداند // واحد را پیدا می‌کند؛ اگر وجود نداشت (با نامِ دقیقِ تایپ‌شده‌ی کاربر)، در
// بک‌اند می‌سازد و id برمی‌گرداند. هیچ واحد پیش‌فرضی تزریق نمی‌شود — نام
// خالی/بی‌معنی یعنی خطای صریح.
async function unitIdByNameOrCreate(lookup: Lookup, name: string): Promise<number> { async function unitIdByNameOrCreate(lookup: Lookup, name: string): Promise<number> {
if (!name) throw new Error("واحد قلم مشخص نشده است"); const key = norm(name);
const existing = lookup.unitNameToId.get(norm(name)); if (!key) throw new Error("واحد قلم مشخص نشده است — واحد را وارد کنید");
const existing = lookup.unitNameToId.get(key);
if (existing != null) return existing; if (existing != null) return existing;
// واحد جدید — ساخت در دیتابیس (code = نام نرمال‌شده) // واحد جدید — ساخت در دیتابیس (code = نام نرمال‌شده‌ی همان واحد تایپ‌شده)
const raw = await postData<RawUnit>("/units", { const raw = await postData<RawUnit>("/units", {
code: norm(name) || "unit", code: key,
name_fa: name, name_fa: name.trim(),
dimension: null, dimension: null,
si_factor: 1, si_factor: 1,
is_base: false, is_base: false,
}); });
lookup.unitNameToId.set(norm(name), raw.id); lookup.unitNameToId.set(key, raw.id);
return raw.id; return raw.id;
} }
@@ -1350,7 +1460,6 @@ export const apiV26 = {
login: async (body: { login: async (body: {
username: string; username: string;
password: string; password: string;
organization?: string;
}): Promise<{ access_token: string; expires_in: number | null; user: import("../store/auth").AuthUser }> => { }): Promise<{ access_token: string; expires_in: number | null; user: import("../store/auth").AuthUser }> => {
const res = await request<{ const res = await request<{
data: { data: {
@@ -1490,4 +1599,34 @@ export const apiV26 = {
postData("/pm/receipts", body), postData("/pm/receipts", body),
receipts: async (): Promise<ConfirmedReceipt[]> => getData<ConfirmedReceipt[]>("/pm/receipts"), receipts: async (): Promise<ConfirmedReceipt[]> => getData<ConfirmedReceipt[]>("/pm/receipts"),
}, },
// ساخت موجودیت‌های کاتالوگ سراسری از داخل پنل انبار (موجودی اولیه):
// متریال/برند/واحد جدید بدون وابستگی به دفتر تدارکات ساخته می‌شوند.
catalog: {
createUnit: async (name: string): Promise<{ id: number; code: string; name_fa: string | null }> => {
const key = norm(name);
if (!key) throw new Error("نام واحد معتبر نیست");
return postData("/units", {
code: key,
name_fa: name.trim(),
dimension: null,
si_factor: 1,
is_base: false,
});
},
createBrand: async (name: string): Promise<{ id: string; name: string }> =>
postData("/brands", { name, country: null }),
createMaterial: async (opts: {
name: string; canonical_unit_id: number; brand_ids: string[];
}): Promise<{ id: string; name: string }> =>
postData("/materials", {
name: opts.name,
category_id: null, // دسته از اولین تأمین‌کننده‌ی مرتبط استنتاج می‌شود (v2.5)
topic: null,
canonical_unit_id: opts.canonical_unit_id,
status: "active",
brand_ids: opts.brand_ids,
attributes: [],
}),
},
}; };
+17 -2
View File
@@ -40,6 +40,22 @@ export function specsLabel(specs: string[]): string {
return specs.join(" · "); return specs.join(" · ");
} }
// نام نمایشی متریال — نام سراسری واقعی (materials.name)، نه دسته.
// برند و مشخصه فقط وقتی به نام اضافه می‌شوند که داخلش نیامده باشند (بدون تکرار).
export function materialLabel(m: Pick<Material, "name" | "brand" | "specs" | "category">): string {
const name = (m.name || "").trim();
const specs = (m.specs || []).filter(Boolean);
if (!name && !m.brand && !specs.length) return m.category === "؟" ? "؟" : "کالا";
if (!name) {
return [m.brand, ...specs].filter(Boolean).join(" ") || "کالا";
}
const parts = [name];
if (m.brand && !name.includes(m.brand)) parts.push(m.brand);
const missing = specs.filter((s) => !name.includes(s));
if (missing.length) parts.push(`(${missing.join("، ")})`);
return parts.join(" ");
}
// آرایه‌ی termsheet_ids یک قلم (هر دو فرمت قدیم/جدید) // آرایه‌ی termsheet_ids یک قلم (هر دو فرمت قدیم/جدید)
export function itemTermsheetIds(li: LineItem): string[] { export function itemTermsheetIds(li: LineItem): string[] {
if (li.termsheet_ids) return li.termsheet_ids; if (li.termsheet_ids) return li.termsheet_ids;
@@ -157,8 +173,7 @@ export function projectCell(q: Quote): string {
export function itemsSummary(store: Store, q: Quote): { text: string; count: number } { export function itemsSummary(store: Store, q: Quote): { text: string; count: number } {
const items = activeItems(q).map((li) => { const items = activeItems(q).map((li) => {
const m = materialById(store.materials, li.material_id); const m = materialById(store.materials, li.material_id);
const specs = materialSpecs(m); return materialLabel(m);
return `${m.category} ${m.brand}${specs.length ? " " + specs.join(" · ") : ""}`;
}); });
return { text: items.join("، "), count: items.length }; return { text: items.join("، "), count: items.length };
} }
+27
View File
@@ -0,0 +1,27 @@
import { normalize } from "./normalize";
export interface UnitLike {
id: number;
code: string;
name_fa: string | null;
}
export function unitDisplayName(u: UnitLike): string {
return u.name_fa || u.code;
}
// واحدها با «نام نمایشی» یکتا می‌شوند: وقتی دو ردیف نام فارسی یکسان دارند
// (مثلاً ردیف سفارشی «عدد» و ردیف پایه‌ی piece/عدد)، ردیفی که کد استاندارد
// دارد (code ≠ نام فارسی، مثل kg یا piece) ارجح است.
export function uniqueUnits(units: UnitLike[]): UnitLike[] {
const best = new Map<string, UnitLike>();
units.forEach((u) => {
const display = unitDisplayName(u);
const key = normalize(display);
if (!key) return;
const cur = best.get(key);
const isCanonical = u.code !== display;
if (!cur || (isCanonical && cur.code === display)) best.set(key, u);
});
return [...best.values()].sort((a, b) => unitDisplayName(a).localeCompare(unitDisplayName(b), "fa"));
}
+8
View File
@@ -7,6 +7,14 @@ import App from "./App";
import "./styles/globals.css"; import "./styles/globals.css";
import { queryClient } from "./lib/queryClient"; import { queryClient } from "./lib/queryClient";
// بعد از استقرار نسخه‌ی جدید، مرورگری که index.html قدیمی را باز کرده برای
// بارگذاری chunkهای حذف‌شده 404 می‌گیرد و صفحه خالی می‌ماند. این شنونده،
// همان لحظه صفحه را دوباره بارگذاری می‌کند تا نسخه‌ی تازه (با نام فایل‌های
// درست) گرفته شود.
window.addEventListener("vite:preloadError", () => {
window.location.reload();
});
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
+30 -29
View File
@@ -1,14 +1,14 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { ClipboardList, Loader2, Lock, ShieldCheck, Wrench, X } from "lucide-react"; import { ClipboardList, Eye, EyeOff, Loader2, Lock, ShieldCheck, Wrench, X } from "lucide-react";
import { api, apiV26 } from "../../lib/api"; import { api, apiV26 } from "../../lib/api";
import { useAuthStore } from "../../store/auth"; import { useAuthStore } from "../../store/auth";
import type { Project } from "../../lib/types"; import type { Project } from "../../lib/types";
// ============================================================ // ============================================================
// ورود به سامانه — Casdoor درون‌صفحه‌ای (بدون ریدایرکت) // ورود به سامانه — Casdoor درون‌صفحه‌ای (بدون ریدایرکت)
// فرم اصلی: Username / Password / Organization (اختیاری) → // فرم اصلی: Username / Password →
// POST /api/v1/auth/login (بک‌اند با grant_type=password از // POST /api/v1/auth/login (بک‌اند با grant_type=password از
// Casdoor توکن می‌گیرد و نقش را از جدول users خودمان برمی‌گرداند) // Casdoor توکن می‌گیرد و نقش را از جدول users خودمان برمی‌گرداند)
// حالت توسعه (فقط ادمین — سوپر یوزر) — چرخه‌ی ۳ مرحله‌ای: // حالت توسعه (فقط ادمین — سوپر یوزر) — چرخه‌ی ۳ مرحله‌ای:
@@ -36,7 +36,7 @@ export function LoginPage() {
// ---- فرم اصلی (Casdoor) ---- // ---- فرم اصلی (Casdoor) ----
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [organization, setOrganization] = useState(""); const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
// ---- پنل حالت توسعه (۳ مرحله) ---- // ---- پنل حالت توسعه (۳ مرحله) ----
@@ -71,7 +71,6 @@ export function LoginPage() {
const { access_token, user } = await apiV26.auth.login({ const { access_token, user } = await apiV26.auth.login({
username: username.trim(), username: username.trim(),
password, password,
...(organization.trim() ? { organization: organization.trim() } : {}),
}); });
if (!user.role) { if (!user.role) {
toast.error("نقش شما هنوز در سیستم تعیین نشده است — با مدیر سیستم تماس بگیرید"); toast.error("نقش شما هنوز در سیستم تعیین نشده است — با مدیر سیستم تماس بگیرید");
@@ -101,7 +100,6 @@ export function LoginPage() {
const { user: adminUser } = await apiV26.auth.login({ const { user: adminUser } = await apiV26.auth.login({
username: devAdminUser.trim(), username: devAdminUser.trim(),
password: devAdminPass, password: devAdminPass,
...(organization.trim() ? { organization: organization.trim() } : {}),
}); });
if (adminUser.role !== "admin") { if (adminUser.role !== "admin") {
toast.error("حالت توسعه فقط برای ادمین (سوپر یوزر) در دسترس است"); toast.error("حالت توسعه فقط برای ادمین (سوپر یوزر) در دسترس است");
@@ -298,30 +296,33 @@ export function LoginPage() {
<label className="mb-1 block text-[12px] font-semibold text-muted" htmlFor="login-password"> <label className="mb-1 block text-[12px] font-semibold text-muted" htmlFor="login-password">
رمز عبور رمز عبور
</label> </label>
<input <div className="relative">
id="login-password" <input
type="password" id="login-password"
autoComplete="current-password" type={showPassword ? "text" : "password"}
dir="ltr" autoComplete="current-password"
className="h-10 w-full rounded-lg border border-line bg-panel px-3 text-left text-sm shadow-sm placeholder:text-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40" dir="ltr"
placeholder="••••••" className="h-10 w-full rounded-lg border border-line bg-panel px-3 pr-10 text-left text-sm shadow-sm placeholder:text-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
value={password} placeholder="••••••"
onChange={(e) => setPassword(e.target.value)} value={password}
/> onChange={(e) => setPassword(e.target.value)}
</div> />
<div className="field"> <button
<label className="mb-1 block text-[12px] font-semibold text-muted" htmlFor="login-org"> type="button"
سازمان (اختیاری) onClick={() => setShowPassword((v) => !v)}
</label> aria-label={showPassword ? "Hide password" : "Show password"}
<input aria-pressed={showPassword}
id="login-org" aria-controls="login-password"
type="text" title={showPassword ? "پنهان کردن رمز" : "نمایش رمز"}
dir="ltr" className="absolute inset-y-0 right-0 flex cursor-pointer items-center rounded-lg px-3 text-muted transition-colors hover:text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
className="h-10 w-full rounded-lg border border-line bg-panel px-3 text-left text-sm shadow-sm placeholder:text-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40" >
placeholder="inventory" {showPassword ? (
value={organization} <EyeOff className="h-4 w-4" aria-hidden="true" />
onChange={(e) => setOrganization(e.target.value)} ) : (
/> <Eye className="h-4 w-4" aria-hidden="true" />
)}
</button>
</div>
</div> </div>
<button <button
type="submit" type="submit"
+39 -31
View File
@@ -7,6 +7,7 @@ import {
activeItems, activeItems,
itemTermsheetIds, itemTermsheetIds,
materialById, materialById,
materialLabel,
materialSpecs, materialSpecs,
specsLabel, specsLabel,
supplierById, supplierById,
@@ -24,7 +25,6 @@ import {
import { MilestoneRow } from "../../components/MilestoneRow"; import { MilestoneRow } from "../../components/MilestoneRow";
import { FileList } from "../../components/FileList"; import { FileList } from "../../components/FileList";
import { JalaliDatePicker } from "../../components/JalaliDatePicker"; import { JalaliDatePicker } from "../../components/JalaliDatePicker";
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
interface CardDraft { interface CardDraft {
domId: string; domId: string;
@@ -54,7 +54,7 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
const [removedItems, setRemovedItems] = useState<Set<string>>(new Set()); const [removedItems, setRemovedItems] = useState<Set<string>>(new Set());
const [assignments, setAssignments] = useState<Record<string, string>>({}); const [assignments, setAssignments] = useState<Record<string, string>>({});
// تخصیص هر قلم به پروژه‌ها (نام‌ها) // تخصیص هر قلم به پروژه‌ها (نام‌ها)
const [itemProjectsSel, setItemProjectsSel] = useState<Record<string, string[]>>({}); const [itemProjectsSel, setItemProjectsSel] = useState<Record<string, string>>({});
// تاریخ و زمان پذیرش — پیش‌فرض الان // تاریخ و زمان پذیرش — پیش‌فرض الان
const [acceptDate, setAcceptDate] = useState<string | null>(() => new Date().toISOString()); const [acceptDate, setAcceptDate] = useState<string | null>(() => new Date().toISOString());
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
@@ -72,17 +72,15 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
}); });
setRemovedItems(rem); setRemovedItems(rem);
setAssignments({}); setAssignments({});
// پروژه‌های هر قلم — از تخصیص‌های قبلی، وگرنه پروژه‌های سطح پیشنهاد // پروژه‌ی هر قلم — تخصیصِ تکی: از تخصیص قبلی قلم، وگرنه اولین پروژه‌ی پیشنهاد
const projSel: Record<string, string[]> = {}; const projSel: Record<string, string> = {};
q.line_items.forEach((li) => { q.line_items.forEach((li) => {
const names = (li.projects || []).map((p) => p.name).filter(Boolean); const names = (li.projects || []).map((p) => p.name).filter(Boolean);
projSel[li.id] = names.length projSel[li.id] = names.length
? names ? names[0]
: q.projects && q.projects.length : q.projects && q.projects.length
? [...q.projects] ? q.projects[0]
: q.project : q.project || "";
? [q.project]
: [];
}); });
setItemProjectsSel(projSel); setItemProjectsSel(projSel);
setReady(true); setReady(true);
@@ -221,14 +219,14 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
if (t.status === "accepted" && !usedIds.has(t.id)) t.status = "offered"; if (t.status === "accepted" && !usedIds.has(t.id)) t.status = "offered";
}); });
// پروژه‌های هر قلم + همگام‌سازی پروژه‌های سطح پیشنهاد // پروژه‌ی هر قلم (تکی) + همگام‌سازی پروژه‌های سطح پیشنهاد
const projUnion = new Set<string>(); const projUnion = new Set<string>();
next.line_items.forEach((li) => { next.line_items.forEach((li) => {
if (li.rejected) return; if (li.rejected) return;
const names = itemProjectsSel[li.id]; const name = itemProjectsSel[li.id];
if (names === undefined) return; if (name === undefined) return;
li.projects = names.map((n) => ({ id: "", name: n })); li.projects = name ? [{ id: "", name }] : [];
li.projects.forEach((p) => projUnion.add(p.name)); if (name) projUnion.add(name);
}); });
next.projects = [...projUnion]; next.projects = [...projUnion];
next.project = next.projects[0] || ""; next.project = next.projects[0] || "";
@@ -316,7 +314,7 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
}} }}
/> />
<span className="flex-1 text-[13px]"> <span className="flex-1 text-[13px]">
{m.category} {m.brand} {materialLabel(m)}
{specText ? ` · ${specText}` : ""} {specText ? ` · ${specText}` : ""}
</span> </span>
<span className="text-[12px] text-muted"> <span className="text-[12px] text-muted">
@@ -342,11 +340,11 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
</div> </div>
</div> </div>
{/* تخصیص قلم‌ها به پروژه‌ها */} {/* تخصیص قلم‌ها به پروژه */}
<div className="border-t border-line pt-3"> <div className="border-t border-line pt-3">
<div className="mb-1 text-[13px] font-bold">تخصیص قلم‌ها به پروژه‌ها:</div> <div className="mb-1 text-[13px] font-bold">پروژه‌ی هر قلم:</div>
<div className="mb-2 text-[11.5px] text-muted"> <div className="mb-2 text-[11.5px] text-muted">
برای هر متریال مشخص کنید به کدام پروژه (ها) تعلق می‌گیرد. برای هر متریال فقط یک پروژه انتخاب می‌شود — مثل شرایط پرداخت.
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{allItems {allItems
@@ -358,19 +356,35 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
<div key={li.id} className="rounded-lg border border-line p-2.5"> <div key={li.id} className="rounded-lg border border-line p-2.5">
<div className="mb-1.5 text-[12.5px]"> <div className="mb-1.5 text-[12.5px]">
<span className="font-semibold"> <span className="font-semibold">
{m.category} {m.brand} {materialLabel(m)}
</span> </span>
{specText ? <span className="text-muted"> · {specText}</span> : ""} {specText ? <span className="text-muted"> · {specText}</span> : ""}
<span className="ms-2 text-[11px] text-muted"> <span className="ms-2 text-[11px] text-muted">
{fmtNum(li.quantity)} {li.unit} {fmtNum(li.quantity)} {li.unit}
</span> </span>
</div> </div>
<MultiSelectCombobox {projectOptions.length ? (
values={itemProjectsSel[li.id] || []} <select
onChange={(names) => setItemProjectsSel((s) => ({ ...s, [li.id]: names }))} className="min-w-[180px] rounded-md border border-line bg-panel px-2 py-1.5 font-inherit text-[12px]"
options={projectOptions} value={itemProjectsSel[li.id] ?? ""}
placeholder="انتخاب پروژه..." onChange={(e) =>
/> setItemProjectsSel((s) => ({ ...s, [li.id]: e.target.value }))
}
>
{(itemProjectsSel[li.id] ? [] : [{ name: "" }]).map((_, i) => (
<option key={i} value="">
انتخاب پروژه...
</option>
))}
{projectOptions.map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
) : (
<div className="text-[12px] text-muted">پروژه‌ای ثبت نشده است.</div>
)}
</div> </div>
); );
})} })}
@@ -380,9 +394,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
{/* فاکتورهای اصلی */} {/* فاکتورهای اصلی */}
<div className="border-t border-line pt-3"> <div className="border-t border-line pt-3">
<div className="mb-1 text-[13px] font-bold">فاکتورهای اصلی (چند فایل):</div> <div className="mb-1 text-[13px] font-bold">فاکتورهای اصلی (چند فایل):</div>
<div className="mb-2 text-[11.5px] text-muted">
چند فاکتور را با هم انتخاب کنید؛ هر فایل همان لحظه روی سرور (MinIO) ذخیره می‌شود.
</div>
<FileList <FileList
files={invoices} files={invoices}
onChange={setInvoices} onChange={setInvoices}
@@ -400,9 +411,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
{/* پیش‌فاکتورها */} {/* پیش‌فاکتورها */}
<div className="border-t border-line pt-3"> <div className="border-t border-line pt-3">
<div className="mb-1 text-[13px] font-bold">پیش‌فاکتورها (چند فایل):</div> <div className="mb-1 text-[13px] font-bold">پیش‌فاکتورها (چند فایل):</div>
<div className="mb-2 text-[11.5px] text-muted">
پیش‌فاکتورهای دریافتی از تأمین‌کننده؛ هر فایل همان لحظه روی سرور (MinIO) ذخیره می‌شود.
</div>
<FileList <FileList
files={proformas} files={proformas}
onChange={setProformas} onChange={setProformas}
+35 -16
View File
@@ -46,7 +46,7 @@ export function DashboardPage() {
[quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions, topics], [quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions, topics],
); );
// فیلتر + مرتب‌سازی بر اساس زمان ساخت (created_at) — فیلترها بازنشانی نمی‌شوند // فیلتر + مرتب‌سازی بر اساس زمان ساخت (created_at)
const filtered = useMemo(() => { const filtered = useMemo(() => {
const list = filterQuotes(store, filters); const list = filterQuotes(store, filters);
const createdTs = (q: (typeof store.quotes)[number]) => { const createdTs = (q: (typeof store.quotes)[number]) => {
@@ -79,24 +79,43 @@ export function DashboardPage() {
brand brand
menu={<AppSwitcherMenu activeModule="procurement" />} menu={<AppSwitcherMenu activeModule="procurement" />}
actions={ actions={
<> <div className="flex items-center gap-2">
<Link to="/suppliers" className="btn-line-file no-underline"> {/* دکمه تأمین‌کننده‌ها */}
<Users className="h-3.5 w-3.5" /> <Link
تأمین‌کننده‌ها to="/suppliers"
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-200 text-xs font-semibold hover:bg-gray-50 dark:hover:bg-zinc-700 shadow-2xs transition-all no-underline"
>
<Users className="h-4 w-4 text-gray-500 dark:text-zinc-400" />
<span>تأمین‌کننده‌ها</span>
</Link> </Link>
<Link to="/projects" className="btn-line-file no-underline">
<FolderKanban className="h-3.5 w-3.5" /> {/* دکمه پروژه‌ها */}
پروژه‌ها <Link
to="/projects"
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-200 text-xs font-semibold hover:bg-gray-50 dark:hover:bg-zinc-700 shadow-2xs transition-all no-underline"
>
<FolderKanban className="h-4 w-4 text-gray-500 dark:text-zinc-400" />
<span>پروژه‌ها</span>
</Link> </Link>
<Link to="/units" className="btn-line-file no-underline">
<Ruler className="h-3.5 w-3.5" /> {/* دکمه ضرایب تبدیل */}
ضرایب تبدیل <Link
to="/units"
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-200 text-xs font-semibold hover:bg-gray-50 dark:hover:bg-zinc-700 shadow-2xs transition-all no-underline"
>
<Ruler className="h-4 w-4 text-gray-500 dark:text-zinc-400" />
<span>ضرایب تبدیل</span>
</Link> </Link>
<Link to="/wizard" className="btn-line-file no-underline bg-accent text-white hover:bg-accent-dark">
<Plus className="h-3.5 w-3.5" /> {/* دکمه اصلی: افزودن پیشنهاد جدید */}
افزودن پیشنهاد جدید <Link
to="/wizard"
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-xs font-semibold shadow-sm hover:shadow transition-all no-underline"
>
<Plus className="h-4 w-4" />
<span>افزودن پیشنهاد جدید</span>
</Link> </Link>
</> </div>
} }
/> />
<PageLayout className="flex flex-col gap-4"> <PageLayout className="flex flex-col gap-4">
@@ -128,4 +147,4 @@ export function DashboardPage() {
<EditModal quoteId={editId} onClose={() => setEditId(null)} /> <EditModal quoteId={editId} onClose={() => setEditId(null)} />
</div> </div>
); );
} }
+7 -4
View File
@@ -589,9 +589,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
{/* فاکتورها */} {/* فاکتورها */}
<div className="border-t border-line pt-3"> <div className="border-t border-line pt-3">
<div className="mb-1 text-[13px] font-bold">فاکتورهای اصلی (چند فایل):</div> <div className="mb-1 text-[13px] font-bold">فاکتورهای اصلی (چند فایل):</div>
<div className="mb-2 text-[11.5px] text-muted">
چند فاکتور را با هم انتخاب کنید؛ هر فایل همان لحظه روی سرور (MinIO) ذخیره می‌شود.
</div>
<FileList <FileList
files={invoices} files={invoices}
onChange={setInvoices} onChange={setInvoices}
@@ -629,8 +626,9 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
<div className="field col-span-2"> <div className="field col-span-2">
<label>وضعیت (فقط بازگشت به مرحله‌های قبل)</label> <label>وضعیت (فقط بازگشت به مرحله‌های قبل)</label>
<select <select
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40" className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
value={status} value={status}
disabled={q.status === "arrived"}
onChange={(e) => setStatus(e.target.value)} onChange={(e) => setStatus(e.target.value)}
> >
{statusOptionsFor(q.status).map((st) => ( {statusOptionsFor(q.status).map((st) => (
@@ -639,6 +637,11 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
</option> </option>
))} ))}
</select> </select>
{q.status === "arrived" && (
<div className="mt-1.5 rounded-md bg-amber-bg px-2.5 py-1.5 text-[11.5px] font-medium text-amber">
به دلیل ثبت ورود اقلام به انبار، امکان تغییر وضعیت پیشنهاد وجود ندارد.
</div>
)}
</div> </div>
</div> </div>
</div> </div>
+73 -41
View File
@@ -5,7 +5,7 @@ import {
isExpired, isExpired,
itemsSummary, itemsSummary,
materialById, materialById,
materialSpecs, materialLabel,
projectCell, projectCell,
quoteTotal, quoteTotal,
supplierById, supplierById,
@@ -36,7 +36,7 @@ function InfoCell({ label, children }: { label: string; children: React.ReactNod
); );
} }
// کارت یک پیشنهاد در دفتر — جایگزین جدول ۱۱ ستونه // کارت ساده و یکدست یک پیشنهاد در دفتر
export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit }: QuoteCardProps) { export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit }: QuoteCardProps) {
const sup = supplierById(store.suppliers, q.supplier_id); const sup = supplierById(store.suppliers, q.supplier_id);
const expired = isExpired(q); const expired = isExpired(q);
@@ -46,11 +46,15 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
const unitWarns = unitWarning(store, q); const unitWarns = unitWarning(store, q);
const summary = itemsSummary(store, q); const summary = itemsSummary(store, q);
// ستون پذیرش (با ساعت) // دکمه یا وضعیت پذیرش
let acceptedCell: React.ReactNode; let acceptedCell: React.ReactNode;
if (q.status === "offered") { if (q.status === "offered") {
acceptedCell = ( acceptedCell = (
<button className="chip chip-act" onClick={onAccept}> <button
type="button"
onClick={onAccept}
className="px-2 py-0.5 rounded text-xs font-semibold bg-emerald-50 text-emerald-700 hover:bg-emerald-100 border border-emerald-200 transition-colors cursor-pointer"
>
پذیرش پذیرش
</button> </button>
); );
@@ -60,8 +64,6 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
acceptedCell = <span className="text-muted">—</span>; acceptedCell = <span className="text-muted">—</span>;
} }
// ستون رسید — v2.6: تأیید رسید فقط در «پنل مدیریت انبار» توسط مدیر پروژه انجام می‌شود؛
// اینجا فقط وضعیت نمایش داده می‌شود. با رسید جزیی: «رسیده X از Y» + «در راه Z».
const activeItems = (q.line_items || []).filter((li) => !li.rejected); const activeItems = (q.line_items || []).filter((li) => !li.rejected);
const receivedSum = activeItems.reduce((s, li) => s + (li.received_qty || 0), 0); const receivedSum = activeItems.reduce((s, li) => s + (li.received_qty || 0), 0);
const orderedSum = activeItems.reduce((s, li) => s + (li.quantity || 0), 0); const orderedSum = activeItems.reduce((s, li) => s + (li.quantity || 0), 0);
@@ -71,8 +73,6 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
const anyReceived = receivedSum > 0; const anyReceived = receivedSum > 0;
const fullyDelivered = activeItems.length > 0 && fulfilledItems === activeItems.length && anyReceived; const fullyDelivered = activeItems.length > 0 && fulfilledItems === activeItems.length && anyReceived;
// نشان پیشرفت تکمیل در هدر کارت (جایگزین نشان وضعیت در حالت پذیرش/رسید):
// در راه (آبی) → رسید: X از Y قلم (کهربایی) → تکمیل شد (سبز)
let fulfillmentBadge: React.ReactNode = null; let fulfillmentBadge: React.ReactNode = null;
if (q.status === "accepted" || q.status === "arrived") { if (q.status === "accepted" || q.status === "arrived") {
if (fullyDelivered || q.status === "arrived") { if (fullyDelivered || q.status === "arrived") {
@@ -97,6 +97,19 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
} }
let arrivedCell: React.ReactNode; let arrivedCell: React.ReactNode;
const lastReceiptAt = activeItems.reduce<Date | null>((acc, li) => {
if (!li.arrived_date) return acc;
const t = new Date(li.arrived_date).getTime();
if (Number.isNaN(t)) return acc;
return !acc || t > acc.getTime() ? new Date(li.arrived_date) : acc;
}, null);
const receiptTimeLine =
lastReceiptAt != null ? (
<span className="block text-[10.5px] font-normal text-muted">
آخرین رسید: {fmtDateTime(lastReceiptAt.toISOString())}
</span>
) : null;
if (q.status === "accepted") { if (q.status === "accepted") {
if (q.arrived_date) { if (q.arrived_date) {
arrivedCell = <span className="tabular-nums">{fmtDateTime(q.arrived_date)}</span>; arrivedCell = <span className="tabular-nums">{fmtDateTime(q.arrived_date)}</span>;
@@ -104,13 +117,18 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
arrivedCell = ( arrivedCell = (
<span className="tabular-nums"> <span className="tabular-nums">
رسیده {fmtNum(receivedSum)} از {fmtNum(orderedSum)} — در راه {fmtNum(Math.max(0, orderedSum - receivedSum))} رسیده {fmtNum(receivedSum)} از {fmtNum(orderedSum)} — در راه {fmtNum(Math.max(0, orderedSum - receivedSum))}
{receiptTimeLine}
</span> </span>
); );
} else { } else {
arrivedCell = <span className="text-muted">در انتظار PM</span>; arrivedCell = <span className="text-muted">در انتظار PM</span>;
} }
} else if (q.arrived_date) { } else if (q.status === "arrived") {
arrivedCell = <span className="tabular-nums">{fmtDateTime(q.arrived_date)}</span>; arrivedCell = (
<span className="tabular-nums">
{fmtDateTime(q.arrived_date || (lastReceiptAt ? lastReceiptAt.toISOString() : ""))}
</span>
);
} else { } else {
arrivedCell = <span className="text-muted">—</span>; arrivedCell = <span className="text-muted">—</span>;
} }
@@ -118,37 +136,49 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
return ( return (
<div <div
className={cn( className={cn(
"overflow-hidden rounded-lg border bg-panel transition-colors", "overflow-hidden rounded-xl border bg-panel transition-all shadow-xs",
expired ? "border-amber/40" : "border-line hover:border-accent/40", expired ? "border-amber/40" : "border-line hover:border-gray-300",
expanded && "border-accent/60", expanded && "border-blue-500/50 shadow-sm",
)} )}
> >
{/* سربرگ کارت */} {/* هدر یکپارچه و تمیز بدون کادر تیره */}
<div className="flex items-center gap-3 border-b border-line bg-bg/40 px-4 py-2.5"> <div className="flex items-center gap-3 border-b border-line/60 px-4 py-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-accent-bg text-sm font-bold text-accent-dark"> <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gray-100 text-xs font-bold text-gray-700">
{sup.name.slice(0, 1)} {sup.name.slice(0, 1)}
</span> </span>
<div className="min-w-0"> <div className="min-w-0">
<Link <Link
to={`/suppliers/${q.supplier_id}`} to={`/suppliers/${q.supplier_id}`}
className="block truncate text-[13.5px] font-bold text-ink no-underline hover:text-accent" className="block truncate text-[13.5px] font-bold text-ink no-underline hover:text-blue-600"
> >
{sup.name} {sup.name}
</Link> </Link>
{sup.location && <div className="truncate text-[11px] text-muted">{sup.location}</div>} {sup.location && <div className="truncate text-[11px] text-muted">{sup.location}</div>}
</div> </div>
<div className="ms-auto flex shrink-0 items-center gap-1.5">
{/* دکمه‌های عملیاتی راست به چپ */}
<div className="ms-auto flex shrink-0 items-center gap-2">
{fulfillmentBadge ?? ( {fulfillmentBadge ?? (
<Badge variant={q.status as "offered" | "accepted" | "arrived"}>{STATUS_LABEL[q.status]}</Badge> <Badge variant={q.status as "offered" | "accepted" | "arrived"}>{STATUS_LABEL[q.status]}</Badge>
)} )}
<button className="chip" onClick={onEdit} title="ویرایش پیشنهاد">
<Pencil className="h-3 w-3" /> {/* دکمه ویرایش مینیمال */}
ویرایش
</button>
<button <button
className="chip chip-expand" type="button"
className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-gray-500 hover:text-gray-800 hover:bg-gray-100 text-xs transition cursor-pointer"
onClick={onEdit}
title="ویرایش پیشنهاد"
>
<Pencil className="h-3 w-3" />
<span>ویرایش</span>
</button>
{/* فلش آکاردئون */}
<button
type="button"
className="flex h-6 w-6 items-center justify-center rounded-md text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition cursor-pointer"
onClick={onToggleExpand} onClick={onToggleExpand}
title={expanded ? "جمع کردن" : "نمایش جریان"} title={expanded ? "جمع کردن" : "نمایش جزئیات"}
aria-expanded={expanded} aria-expanded={expanded}
> >
<ChevronDown className={cn("h-4 w-4 transition-transform duration-200", expanded && "rotate-180")} /> <ChevronDown className={cn("h-4 w-4 transition-transform duration-200", expanded && "rotate-180")} />
@@ -156,26 +186,28 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
</div> </div>
</div> </div>
{/* اطلاعات کلیدی */} {/* اطلاعات کلیدی کارت */}
<div className="grid grid-cols-2 gap-x-5 gap-y-3 px-4 py-3 md:grid-cols-4"> <div className="grid grid-cols-2 gap-x-5 gap-y-3 px-4 py-3.5 md:grid-cols-4">
<InfoCell label="اقلام"> <InfoCell label="اقلام">
{summary.count > 1 ? ( {summary.count > 1 ? (
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<span className="font-semibold">{fmtNum(summary.count)} قلم</span> <span className="font-semibold">{fmtNum(summary.count)} قلم</span>
<div className="flex flex-col gap-1 text-[11.5px] leading-5 text-muted"> <div className="flex flex-col gap-1 text-[11.5px] leading-5 text-muted">
{store.quotes.find((x) => x.id === q.id)?.line_items?.filter((li) => !li.rejected).map((li) => { {store.quotes
const m = materialById(store.materials, li.material_id); .find((x) => x.id === q.id)
const specs = materialSpecs(m); ?.line_items?.filter((li) => !li.rejected)
return ( .map((li) => {
<span key={li.id} className="flex items-center gap-1"> const m = materialById(store.materials, li.material_id);
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-accent/40" /> return (
{m.category} {m.brand}{specs.length ? ` · ${specs.join(" · ")}` : ""} <span key={li.id} className="flex items-center gap-1">
<span className="text-[10px] text-muted/70"> <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-blue-500/60" />
{fmtNum(li.quantity)} {li.unit} {materialLabel(m)}
<span className="text-[10px] text-muted/70">
{fmtNum(li.quantity)} {li.unit}
</span>
</span> </span>
</span> );
); })}
})}
</div> </div>
</div> </div>
) : ( ) : (
@@ -233,8 +265,8 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
</InfoCell> </InfoCell>
</div> </div>
{/* تاریخ‌ها و اقدامات */} {/* پاورقی تمیز و روشن */}
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 border-t border-line bg-bg/40 px-4 py-2 text-[11.5px] text-muted"> <div className="flex flex-wrap items-center gap-x-5 gap-y-1.5 border-t border-line/60 px-4 py-2 text-[11.5px] text-muted">
<span className="inline-flex items-center gap-1.5"> <span className="inline-flex items-center gap-1.5">
<Package className="h-3 w-3" /> <Package className="h-3 w-3" />
پیشنهاد: <b className="text-ink tabular-nums">{fmtDateTime(q.quote_date)}</b> پیشنهاد: <b className="text-ink tabular-nums">{fmtDateTime(q.quote_date)}</b>
@@ -259,4 +291,4 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
{expanded && <QuoteDetail store={store} q={q} />} {expanded && <QuoteDetail store={store} q={q} />}
</div> </div>
); );
} }
+2 -1
View File
@@ -6,6 +6,7 @@ import {
itemTermsheetIds, itemTermsheetIds,
lineItemTotal, lineItemTotal,
materialById, materialById,
materialLabel,
materialSpecs, materialSpecs,
refConverted, refConverted,
specsLabel, specsLabel,
@@ -96,7 +97,7 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
> >
<div className="fb-name"> <div className="fb-name">
<Package className="me-1 inline h-3 w-3 text-muted" /> <Package className="me-1 inline h-3 w-3 text-muted" />
{m.category} {m.brand} {materialLabel(m)}
{specText ? ` · ${specText}` : ""} {specText ? ` · ${specText}` : ""}
</div> </div>
<div className="fb-meta"> <div className="fb-meta">
@@ -2,12 +2,15 @@ import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { Header, PageLayout } from "../../components/layout/Header"; import { Header, PageLayout } from "../../components/layout/Header";
import { Combobox } from "../../components/Combobox";
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
import { useAuthStore } from "../../store/auth"; import { useAuthStore } from "../../store/auth";
import { inventorySubheader } from "./panelMenu"; import { inventorySubheader } from "./panelMenu";
import { apiV26 } from "../../lib/api"; import { apiV26 } from "../../lib/api";
import { notifyDataChanged } from "../../lib/crossTab"; import { notifyDataChanged } from "../../lib/crossTab";
import { useInventoryData, unitName, whName } from "./useInventoryData"; import { useInventoryData, unitName, whName } from "./useInventoryData";
import { JalaliDatePicker } from "../../components/JalaliDatePicker"; import { JalaliDatePicker } from "../../components/JalaliDatePicker";
import { uniqueUnits } from "../../lib/units";
import type { InventoryTransaction, TransactionType } from "../../lib/types"; import type { InventoryTransaction, TransactionType } from "../../lib/types";
import { faNum } from "../../lib/formatters"; import { faNum } from "../../lib/formatters";
import { gregorianToJalali, MONTHS, toFa } from "../../lib/jalali"; import { gregorianToJalali, MONTHS, toFa } from "../../lib/jalali";
@@ -16,7 +19,7 @@ type FormType = TransactionType | "TRANSFER";
const FORM_TYPES: { t: FormType; label: string; dir: string; cls: "in" | "out" | "mv" | "cnt"; color: string; desc: string }[] = [ const FORM_TYPES: { t: FormType; label: string; dir: string; cls: "in" | "out" | "mv" | "cnt"; color: string; desc: string }[] = [
// رنگ‌بندی معنایی: سبز زمردی = ورود، رز = خروج، آبی = خنثی/جابه‌جایی/شمارش // رنگ‌بندی معنایی: سبز زمردی = ورود، رز = خروج، آبی = خنثی/جابه‌جایی/شمارش
{ t: "PURCHASE", label: "خرید", dir: "افزایش", cls: "in", color: "#10b981", desc: "ثبت خرید کالای جدید یا موجود — دسته‌بندی فقط برای کالای جدید لازم است. ثبت حداقل یک مشخصه فنی (نام و مقدار) الزامی است." }, { t: "PURCHASE", label: "موجودی اولیه", dir: "افزایش", cls: "in", color: "#10b981", desc: "ثبت موجودی اولیه — متریال/برند/واحد را از فهرست انتخاب کنید؛ اگر نامی در فهرست نبود، همان نام تایپ‌شده هنگام ثبت به‌صورت خودکار در کاتالوگ سراسری ساخته می‌شود. ثبت حداقل یک مشخصه فنی الزامی است." },
{ t: "WITHDRAWAL", label: "برداشت", dir: "کاهش", cls: "out", color: "#e11d48", desc: "انبار را انتخاب و نام متریال را تایپ کنید؛ همه مدل‌ها (برند و مشخصه متفاوت) نمایش داده می‌شوند. برداشت‌ها در «کنترل روزانه» امروز دیده می‌شوند." }, { t: "WITHDRAWAL", label: "برداشت", dir: "کاهش", cls: "out", color: "#e11d48", desc: "انبار را انتخاب و نام متریال را تایپ کنید؛ همه مدل‌ها (برند و مشخصه متفاوت) نمایش داده می‌شوند. برداشت‌ها در «کنترل روزانه» امروز دیده می‌شوند." },
{ t: "RETURN", label: "برگشت", dir: "افزایش", cls: "in", color: "#10b981", desc: "برگشت از مصالحی که امروز برداشت شده‌اند — نام متریال را تایپ کنید و مقدار برگشتی هر ردیف را بزنید." }, { t: "RETURN", label: "برگشت", dir: "افزایش", cls: "in", color: "#10b981", desc: "برگشت از مصالحی که امروز برداشت شده‌اند — نام متریال را تایپ کنید و مقدار برگشتی هر ردیف را بزنید." },
{ t: "TRANSFER", label: "انتقال", dir: "جابه‌جایی", cls: "mv", color: "#2563eb", desc: "جابه‌جایی بین انبارهای پروژه — از انبار مبدأ، مقداری از هر مدل را به انبار مقصد منتقل کنید." }, { t: "TRANSFER", label: "انتقال", dir: "جابه‌جایی", cls: "mv", color: "#2563eb", desc: "جابه‌جایی بین انبارهای پروژه — از انبار مبدأ، مقداری از هر مدل را به انبار مقصد منتقل کنید." },
@@ -59,9 +62,8 @@ export function ChangeInventoryPage() {
const [qty, setQty] = useState(""); const [qty, setQty] = useState("");
// تاریخ و ساعت به صورت ISO (تقویم شمسی — JalaliDatePicker) // تاریخ و ساعت به صورت ISO (تقویم شمسی — JalaliDatePicker)
const [dateInput, setDateInput] = useState<string>(() => new Date().toISOString()); const [dateInput, setDateInput] = useState<string>(() => new Date().toISOString());
const [specsRows, setSpecsRows] = useState<{ rid: number; label: string; value: string }[]>([ // مشخصه‌های فنی — چیپ‌های چندتایی، دقیقاً مثل فیلد «مشخصه» در ویزارد دفتر تدارکات
{ rid: 1, label: "", value: "" }, const [specsChips, setSpecsChips] = useState<string[]>([]);
]);
const [note, setNote] = useState(""); const [note, setNote] = useState("");
const [rows, setRows] = useState<RowDraft[]>([]); const [rows, setRows] = useState<RowDraft[]>([]);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -113,14 +115,17 @@ export function ChangeInventoryPage() {
return new Date().toISOString(); return new Date().toISOString();
}; };
const specsFromRows = () => { // هر چیپ یک مشخصه است؛ در specs انبار به‌صورت کلید با مقدار خالی ذخیره می‌شود
// تا رکوردهای انبارِ دارای مشخصه‌های متفاوت از هم جدا بمانند
const specsFromChips = () => {
const out: Record<string, string> = {}; const out: Record<string, string> = {};
specsRows.forEach((r) => { specsChips.forEach((c) => {
if (r.label.trim() && r.value.trim()) out[r.label.trim()] = r.value.trim(); const k = c.trim();
if (k) out[k] = "";
}); });
return out; return out;
}; };
const specsComplete = specsRows.some((r) => r.label.trim() && r.value.trim()); const specsComplete = specsChips.some((c) => c.trim());
const todayWithdrawals = useMemo(() => { const todayWithdrawals = useMemo(() => {
const tk = todayKey(); const tk = todayKey();
@@ -190,7 +195,7 @@ export function ChangeInventoryPage() {
setBrandTxt(""); setBrandTxt("");
setUnitTxt(""); setUnitTxt("");
setQty(""); setQty("");
setSpecsRows([{ rid: Date.now(), label: "", value: "" }]); setSpecsChips([]);
setNote(""); setNote("");
await data.refresh(); await data.refresh();
} catch (err) { } catch (err) {
@@ -202,22 +207,44 @@ export function ChangeInventoryPage() {
// ---------- submit handlers ---------- // ---------- submit handlers ----------
const submitPurchase = async () => { const submitPurchase = async () => {
const m = matByName(matTxt); const matName = matTxt.trim();
const bid = brandIdByName(brandTxt); const brandName = brandTxt.trim();
const uid = unitIdByName(unitTxt); const unitName = unitTxt.trim();
const n = parseNum(qty); const n = parseNum(qty);
if (!whId) return toast.error("انبار را انتخاب کنید"); if (!whId) return toast.error("انبار را انتخاب کنید");
if (!m) return toast.error("متریال در فهرست نیست — نام را دقیق تایپ کنید"); if (!matName) return toast.error("نام متریال را وارد کنید");
if (!bid) return toast.error("برند را انتخاب کنید"); if (!brandName) return toast.error("نام برند را وارد کنید");
if (!uid) return toast.error("واحد را انتخاب کنید"); if (!unitName) return toast.error("نام واحد را وارد کنید");
if (!(n > 0)) return toast.error("تعداد باید بزرگ‌تر از صفر باشد"); if (!(n > 0)) return toast.error("تعداد باید بزرگ‌تر از صفر باشد");
if (!specsComplete) return toast.error("حداقل یک مشخصه فنی (نام و مقدار) ثبت کنید"); if (!specsComplete) return toast.error("حداقل یک مشخصه فنی ثبت کنید (نام را بنویسید و Enter بزنید)");
const op: Record<string, unknown> = {
transaction_type: "PURCHASE", warehouse_id: whId, material_id: m.id, brand_id: bid, try {
quantity: n, unit_id: uid, specs: specsFromRows(), note, // ساختِ به‌هنگام موجودیت‌های ناشناخته در کاتالوگ سراسری (بدون پیش‌نیاز تدارکات)
transaction_date: toServerIso(), let uid = unitIdByName(unitName);
}; if (!uid) {
await postSeq([op]); const created = await apiV26.catalog.createUnit(unitName);
uid = created.id;
}
let bid = brandIdByName(brandName);
if (!bid) {
const created = await apiV26.catalog.createBrand(brandName);
bid = created.id;
}
let m = matByName(matName);
if (!m) {
const created = await apiV26.catalog.createMaterial({ name: matName, canonical_unit_id: uid, brand_ids: bid ? [bid] : [] });
m = { id: created.id, name: created.name, category: null, canonical_unit: null };
}
const op: Record<string, unknown> = {
transaction_type: "PURCHASE", warehouse_id: whId, material_id: m.id, brand_id: bid,
quantity: n, unit_id: uid, specs: specsFromChips(), note,
transaction_date: toServerIso(),
};
await postSeq([op]);
} catch (err) {
setBusy(false);
toast.error(err instanceof Error ? err.message : "خطا در ثبت موجودی اولیه");
}
}; };
const submitList = async (t: TransactionType) => { const submitList = async (t: TransactionType) => {
@@ -355,7 +382,8 @@ export function ChangeInventoryPage() {
const canSubmit = (): boolean => { const canSubmit = (): boolean => {
if (type === "PURCHASE") { if (type === "PURCHASE") {
return !!(whId && matByName(matTxt) && brandIdByName(brandTxt) && unitIdByName(unitTxt) && parseNum(qty) > 0 && specsComplete); // نام‌های تایپ‌شده لازم نیست از قبل در فهرست باشند — در submit ساخته می‌شوند
return !!(whId && matTxt.trim() && brandTxt.trim() && unitTxt.trim() && parseNum(qty) > 0 && specsComplete);
} }
if (type === "TRANSFER") return !!(fromId && toId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0)); if (type === "TRANSFER") return !!(fromId && toId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
if (type === "RETURN") return !!(whId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0)); if (type === "RETURN") return !!(whId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
@@ -461,7 +489,7 @@ export function ChangeInventoryPage() {
{whOpts.map((w) => <option key={w.id} value={w.id}>{w.name}</option>)} {whOpts.map((w) => <option key={w.id} value={w.id}>{w.name}</option>)}
</select> </select>
</div> </div>
{(type === "PURCHASE" || type === "WITHDRAWAL" || type === "DAMAGE" || type === "WASTE" || type === "LOSS" || type === "STOCK_COUNT" || type === "RETURN" || type === "FOUND") && ( {(type === "WITHDRAWAL" || type === "DAMAGE" || type === "WASTE" || type === "LOSS" || type === "STOCK_COUNT" || type === "RETURN" || type === "FOUND") && (
<div className="field"> <div className="field">
<label>{type === "FOUND" ? "متریال (خالی = همه مفقودی‌ها)" : "متریال *"}</label> <label>{type === "FOUND" ? "متریال (خالی = همه مفقودی‌ها)" : "متریال *"}</label>
<input <input
@@ -518,34 +546,49 @@ export function ChangeInventoryPage() {
</div> </div>
)} )}
{/* فرم خرید */} {/* فرم خرید / موجودی اولیه */}
{type === "PURCHASE" && ( {type === "PURCHASE" && (
<div className="grid gap-3 md:grid-cols-2"> <div className="grid gap-3 md:grid-cols-2">
<div className="field md:col-span-2">
<label>متریال *</label>
<Combobox
value={matTxt}
items={data.lookup.materials.map((mm) => mm.name)}
allowAdd
addSuffix="به عنوان متریال جدید"
placeholder="نام متریال — از فهرست انتخاب یا نام جدید تایپ کنید"
onInputChange={(v) => { setMatTxt(v); setRows([]); }}
onChange={(it) => { setMatTxt(it.label); setRows([]); }}
/>
{!matByName(matTxt) && matTxt.trim() && (
<div className="mt-1.5 rounded-lg border border-accent/25 bg-accent-bg/60 px-3 py-2 text-[12px] text-accent-dark">
«{matTxt.trim()}» در فهرست نیست — هنگام ثبت، به‌صورت خودکار در کاتالوگ سراسری ساخته می‌شود.
</div>
)}
</div>
<div className="field"> <div className="field">
<label>برند *</label> <label>برند *</label>
<input <Combobox
list="dl-brand"
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
placeholder="نام برند — از پیشنهادها یا جدید…"
value={brandTxt} value={brandTxt}
onChange={(e) => setBrandTxt(e.target.value)} items={data.lookup.brands.map((b) => b.name)}
allowAdd
addSuffix="به عنوان برند جدید"
placeholder="نام برند — از فهرست انتخاب یا نام جدید تایپ کنید"
onInputChange={setBrandTxt}
onChange={(it) => setBrandTxt(it.label)}
/> />
<datalist id="dl-brand">
{data.lookup.brands.map((b) => <option key={b.id} value={b.name} />)}
</datalist>
</div> </div>
<div className="field"> <div className="field">
<label>واحد *</label> <label>واحد *</label>
<input <Combobox
list="dl-unit"
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
placeholder="واحد اندازه‌گیری…"
value={unitTxt} value={unitTxt}
onChange={(e) => setUnitTxt(e.target.value)} items={uniqueUnits(data.lookup.units).map((u) => u.name_fa || u.code)}
allowAdd
addSuffix="به عنوان واحد جدید"
placeholder="واحد اندازه‌گیری — از فهرست یا نام جدید"
onInputChange={setUnitTxt}
onChange={(it) => setUnitTxt(it.label)}
/> />
<datalist id="dl-unit">
{data.lookup.units.map((u) => <option key={u.id} value={u.name_fa || u.code} />)}
</datalist>
</div> </div>
<div className="field"> <div className="field">
<label>تعداد *</label> <label>تعداد *</label>
@@ -566,47 +609,14 @@ export function ChangeInventoryPage() {
withTime withTime
/> />
</div> </div>
{!matByName(matTxt) && matTxt.trim() && (
<div className="field md:col-span-2">
<div className="rounded-lg border border-amber/30 bg-amber/5 px-3 py-2 text-[12px] text-amber">
این متریال در فهرست نیست — ابتدا متریال را بسازید (مثلاً هنگام ثبت پیشنهاد در دفتر تدارکات)، سپس خرید را ثبت کنید.
</div>
</div>
)}
<div className="field md:col-span-2"> <div className="field md:col-span-2">
<label>مشخصه‌های فنی *</label> <label>مشخصه‌های فنی *</label>
<div className="flex flex-col gap-1.5"> <MultiSelectCombobox
{specsRows.map((r) => ( values={specsChips}
<div key={r.rid} className="flex gap-2"> onChange={setSpecsChips}
<input placeholder="نام مشخصه را تایپ کنید و Enter بزنید (مثلاً سایز)"
className="h-8 w-1/2 rounded-lg border border-line bg-panel px-3 text-sm" className="w-full"
placeholder="نام مشخصه (مثلاً سایز)" />
value={r.label}
onChange={(e) => setSpecsRows((rs) => rs.map((x) => (x.rid === r.rid ? { ...x, label: e.target.value } : x)))}
/>
<input
className="h-8 w-1/2 rounded-lg border border-line bg-panel px-3 text-sm"
placeholder="مقدار (مثلاً ۱۶)"
value={r.value}
onChange={(e) => setSpecsRows((rs) => rs.map((x) => (x.rid === r.rid ? { ...x, value: e.target.value } : x)))}
/>
<button
type="button"
className="h-8 px-2 text-muted hover:text-red"
onClick={() => setSpecsRows((rs) => (rs.length > 1 ? rs.filter((x) => x.rid !== r.rid) : rs))}
>
✕
</button>
</div>
))}
<button
type="button"
className="w-fit rounded-lg border border-dashed border-accent/50 px-3 py-1 text-[12px] text-accent"
onClick={() => setSpecsRows((rs) => [...rs, { rid: Date.now(), label: "", value: "" }])}
>
+ افزودن مشخصه فنی
</button>
</div>
</div> </div>
<div className="field md:col-span-2"> <div className="field md:col-span-2">
<label>یادداشت</label> <label>یادداشت</label>
@@ -631,7 +641,7 @@ export function ChangeInventoryPage() {
emptyMsg={ emptyMsg={
!whId ? "برای نمایش رکوردها، ابتدا انبار را انتخاب کنید." !whId ? "برای نمایش رکوردها، ابتدا انبار را انتخاب کنید."
: !matTxt.trim() ? "نام متریال را تایپ کنید تا همه مدل‌های آن (با برند و مشخصه متفاوت) نمایش داده شود." : !matTxt.trim() ? "نام متریال را تایپ کنید تا همه مدل‌های آن (با برند و مشخصه متفاوت) نمایش داده شود."
: !formRecords.length ? "برای این نام متریال در انبار انتخابی رکوردی وجود ندارد. ابتدا خرید ثبت کنید." : !formRecords.length ? "برای این نام متریال در انبار انتخابی رکوردی وجود ندارد. ابتدا «موجودی اولیه» ثبت کنید."
: "" : ""
} }
/> />
+179 -73
View File
@@ -1,5 +1,16 @@
import { useMemo, useRef, useState } from "react"; import { useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate, Link } from "react-router-dom";
import {
ChevronRight,
ChevronLeft,
Calendar as CalendarIcon,
Building2,
Plus,
RotateCcw,
PackageX,
Trash2,
Clock
} from "lucide-react";
import { Header, PageLayout } from "../../components/layout/Header"; import { Header, PageLayout } from "../../components/layout/Header";
import { useAuthStore } from "../../store/auth"; import { useAuthStore } from "../../store/auth";
import { inventorySubheader } from "./panelMenu"; import { inventorySubheader } from "./panelMenu";
@@ -16,9 +27,8 @@ function todayKey(): string {
return gregKey(new Date()); return gregKey(new Date());
} }
// کنترل روزانه — پورت کامل تقویم شمسی نسخه‌ی ارسالی
export function DailyControlPage() { export function DailyControlPage() {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const data = useInventoryData(); const data = useInventoryData();
const navigate = useNavigate(); const navigate = useNavigate();
const isPM = user?.role === "pm"; const isPM = user?.role === "pm";
@@ -26,7 +36,6 @@ export function DailyControlPage() {
const [anchor, setAnchor] = useState<Date>(new Date()); const [anchor, setAnchor] = useState<Date>(new Date());
const [sel, setSel] = useState<string>(todayKey()); const [sel, setSel] = useState<string>(todayKey());
const [actRec, setActRec] = useState<string | null>(null); const [actRec, setActRec] = useState<string | null>(null);
// فیلتر پروژه — فقط برای مدیر تدارکات/ادمین (مدیر پروژه به پروژه خودش محدود است)
const isAdmin = user?.role === "admin" || user?.role === "procurement_manager"; const isAdmin = user?.role === "admin" || user?.role === "procurement_manager";
const [projFilter, setProjFilter] = useState(""); const [projFilter, setProjFilter] = useState("");
@@ -46,7 +55,6 @@ export function DailyControlPage() {
return map; return map;
}, [data.transactions, data.warehouses, projFilter]); }, [data.transactions, data.warehouses, projFilter]);
// cells: 42 روز از شنبه قبلِ اولین روز ماه جلالی
const cells = useMemo(() => { const cells = useMemo(() => {
const [ty, tm] = gregorianToJalali(anchor.getFullYear(), anchor.getMonth() + 1, anchor.getDate()); const [ty, tm] = gregorianToJalali(anchor.getFullYear(), anchor.getMonth() + 1, anchor.getDate());
let first: Date | null = null; let first: Date | null = null;
@@ -59,7 +67,7 @@ export function DailyControlPage() {
} }
} }
if (!first) first = new Date(anchor.getTime() - 33 * 86400000); if (!first) first = new Date(anchor.getTime() - 33 * 86400000);
const offset = (first.getDay() + 1) % 7; // شنبه = 0 const offset = (first.getDay() + 1) % 7;
const gridStart = new Date(first.getTime() - offset * 86400000); const gridStart = new Date(first.getTime() - offset * 86400000);
const out: { d: Date; inMonth: boolean; isToday: boolean; n: number }[] = []; const out: { d: Date; inMonth: boolean; isToday: boolean; n: number }[] = [];
for (let c = 0; c < 42; c++) { for (let c = 0; c < 42; c++) {
@@ -79,7 +87,6 @@ export function DailyControlPage() {
const [ty, tm] = gregorianToJalali(anchor.getFullYear(), anchor.getMonth() + 1, anchor.getDate()); const [ty, tm] = gregorianToJalali(anchor.getFullYear(), anchor.getMonth() + 1, anchor.getDate());
const monthTitle = `${toFa(ty)} ${MONTHS[tm - 1]}`; const monthTitle = `${toFa(ty)} ${MONTHS[tm - 1]}`;
// جابه‌جایی بین ماه‌های شمسی (نه ±۳۳ روز) — ماه قبل/بعد با سال‌شماری درست
const shiftMonth = (delta: number) => { const shiftMonth = (delta: number) => {
let nm = tm + delta; let nm = tm + delta;
let ny = ty; let ny = ty;
@@ -90,18 +97,12 @@ export function DailyControlPage() {
nm = 1; nm = 1;
ny += 1; ny += 1;
} }
// روزِ برگشتی را نگه می‌داریم (ماه شمسی از میانه‌ی ماه میلادی شروع می‌شود:
// مثلاً مهر ۱ = ۲۳ سپتامبر). اگر به‌جای آن روز «۱» استفاده شود، لنگر در
// اولِ ماهِ میلادی می‌نشیند و هنوز در همان ماه شمسی است — دکمه «ماه بعد»
// ظاهراً هیچ کاری نمی‌کند.
const [gy, gm, gd] = jalaliToGregorian(ny, nm, 1); const [gy, gm, gd] = jalaliToGregorian(ny, nm, 1);
setAnchor(new Date(gy, gm - 1, gd)); setAnchor(new Date(gy, gm - 1, gd));
}; };
const dayTx = txByDay.get(sel) || []; const dayTx = txByDay.get(sel) || [];
// فعال‌سازی ناوبری ماه از چند مسیر (pointerup / click / کیبورد) با حذف
// اجرای تکراری — اگر مرورگر رویداد click را بخورد، pointerup جواب می‌دهد.
const navLast = useRef<{ key: string; at: number }>({ key: "", at: 0 }); const navLast = useRef<{ key: string; at: number }>({ key: "", at: 0 });
const navFire = (key: string, action: () => void) => { const navFire = (key: string, action: () => void) => {
const now = Date.now(); const now = Date.now();
@@ -144,114 +145,219 @@ export function DailyControlPage() {
const [sy, sm, sd] = gregorianToJalali(selDate.getFullYear(), selDate.getMonth() + 1, selDate.getDate()); const [sy, sm, sd] = gregorianToJalali(selDate.getFullYear(), selDate.getMonth() + 1, selDate.getDate());
return ( return (
<div className="min-h-screen"> <div className="min-h-screen bg-slate-50/60">
<Header <Header
title="کنترل روزانه" title="کنترل روزانه"
sub={inventorySubheader(user)} sub={inventorySubheader(user)}
backTo="/inventory" backTo="/inventory"
backLabel="بازگشت به داشبورد" backLabel="بازگشت به داشبورد"
/> />
<PageLayout className="flex flex-col gap-3"> <PageLayout className="flex flex-col gap-4">
{isAdmin && ( {/* نوار پروژه سفید و شفاف */}
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-line bg-panel px-3 py-2 text-[12.5px]"> <div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-slate-200 bg-white p-3 shadow-xs">
<label className="font-bold">🏢 پروژه:</label> <div className="flex flex-wrap items-center gap-2.5 text-xs">
<select <span className="flex items-center gap-1.5 font-bold text-slate-800">
className="h-8 rounded-lg border border-line bg-panel px-2 text-[12.5px]" <Building2 className="h-4 w-4 text-blue-600" />
value={projFilter} <span>پروژه:</span>
onChange={(e) => setProjFilter(e.target.value)} </span>
> {isAdmin ? (
<option value="">همه پروژه‌ها</option> <select
{data.lookup.projects.map((p) => ( className="h-8 rounded-lg border border-slate-200 bg-white px-2.5 text-xs font-medium text-slate-800 focus:outline-none focus:border-blue-500"
<option key={p.id} value={p.id}>{p.name}</option> value={projFilter}
))} onChange={(e) => setProjFilter(e.target.value)}
</select> >
<span className="text-[11.5px] text-muted"> <option value="">همه پروژه‌ها</option>
{projFilter {data.lookup.projects.map((p) => (
? "نمایش کنترل روزانه فقط برای پروژه انتخابی" <option key={p.id} value={p.id}>{p.name}</option>
: "نمایش کنترل روزانه همه پروژه‌ها"} ))}
</select>
) : (
<span className="font-semibold text-slate-800">پروژه فعال</span>
)}
<span className="text-[11.5px] text-slate-400">
{projFilter ? "نمایش برای پروژه انتخابی" : "نمایش تمام پروژه‌ها"}
</span> </span>
</div> </div>
)}
<div className="grid gap-4 lg:grid-cols-2"> <Link
{/* تقویم */} to="/inventory/change"
<div className="rounded-xl border border-line bg-panel p-4"> className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-xs font-semibold shadow-xs transition-colors no-underline"
<div className="mb-2 flex items-center justify-between"> >
<div className="text-[14px] font-bold">📅 {monthTitle}</div> <Plus className="h-3.5 w-3.5" />
<div className="flex gap-1.5"> <span>ثبت خروج / تغییر موجودی</span>
<button className="cursor-pointer rounded-lg border border-line px-3 py-1 text-[12px] transition-colors hover:bg-slate-100" {...navBtnProps("prev", () => shiftMonth(-1))}>▶ ماه قبل</button> </Link>
<button className="cursor-pointer rounded-lg border border-line px-3 py-1 text-[12px] transition-colors hover:bg-slate-100" {...navBtnProps("today", () => { setAnchor(new Date()); setSel(todayKey()); setActRec(null); })}>امروز</button> </div>
<button className="cursor-pointer rounded-lg border border-line px-3 py-1 text-[12px] transition-colors hover:bg-slate-100" {...navBtnProps("next", () => shiftMonth(1))}>◀ ماه بعد</button>
<div className="grid gap-4 lg:grid-cols-2 items-start">
{/* ستون تقویم — سفید، خوانا و بدون هدر تیره */}
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-xs">
<div className="mb-4 flex items-center justify-between pb-3 border-b border-slate-100">
<div className="flex items-center gap-2 text-[14px] font-bold text-slate-800">
<CalendarIcon className="h-4 w-4 text-blue-600" />
<span>{monthTitle}</span>
</div>
<div className="flex items-center gap-1.5">
<button
className="inline-flex items-center gap-1 h-7.5 px-2.5 rounded-md border border-slate-200 bg-white text-xs font-medium text-slate-700 hover:border-slate-300 transition-colors cursor-pointer"
{...navBtnProps("prev", () => shiftMonth(-1))}
>
<ChevronRight className="h-3.5 w-3.5" />
<span>ماه قبل</span>
</button>
<button
className="h-7.5 px-3 rounded-md border border-blue-200 bg-blue-50 text-xs font-bold text-blue-700 hover:bg-blue-100 transition-colors cursor-pointer"
{...navBtnProps("today", () => { setAnchor(new Date()); setSel(todayKey()); setActRec(null); })}
>
امروز
</button>
<button
className="inline-flex items-center gap-1 h-7.5 px-2.5 rounded-md border border-slate-200 bg-white text-xs font-medium text-slate-700 hover:border-slate-300 transition-colors cursor-pointer"
{...navBtnProps("next", () => shiftMonth(1))}
>
<span>ماه بعد</span>
<ChevronLeft className="h-3.5 w-3.5" />
</button>
</div> </div>
</div> </div>
<div className="grid grid-cols-7 gap-1">
{/* روزهای هفته */}
<div className="grid grid-cols-7 gap-1 text-center">
{["ش", "ی", "د", "س", "چ", "پ", "ج"].map((d) => ( {["ش", "ی", "د", "س", "چ", "پ", "ج"].map((d) => (
<div key={d} className="py-1 text-center text-[11px] font-bold text-muted">{d}</div> <div key={d} className="py-1 text-xs font-semibold text-slate-400">{d}</div>
))} ))}
{cells.map((c, i) => { {cells.map((c, i) => {
// gregorianToJalali → [سال، ماه، روز] — روزِ ماه در خانه‌ی تقویم
const [, , jd] = gregorianToJalali(c.d.getFullYear(), c.d.getMonth() + 1, c.d.getDate()); const [, , jd] = gregorianToJalali(c.d.getFullYear(), c.d.getMonth() + 1, c.d.getDate());
const k = gregKey(c.d); const k = gregKey(c.d);
const isSelected = sel === k;
return ( return (
<button <button
key={i} key={i}
type="button" type="button"
onClick={() => { setSel(k); setActRec(null); }} onClick={() => { setSel(k); setActRec(null); }}
className={`relative rounded-lg py-2 text-[12px] ${!c.inMonth ? "text-muted/40" : ""} ${c.isToday ? "bg-accent font-bold text-white" : ""} ${sel === k ? "ring-2 ring-accent" : "hover:bg-accent/5"}`} className={`relative flex flex-col items-center justify-center h-9 rounded-lg text-xs transition-colors cursor-pointer ${
!c.inMonth ? "text-slate-300" : "text-slate-700 font-medium"
} ${
isSelected
? "bg-blue-600 text-white font-bold"
: c.isToday
? "border border-blue-500 font-bold text-blue-600 hover:bg-blue-50/50"
: "hover:bg-slate-100/70"
}`}
> >
{toFa(jd)} <span>{toFa(jd)}</span>
{c.n > 0 && (
<span
className={`absolute bottom-1 h-1 w-1 rounded-full ${
isSelected ? "bg-white" : "bg-blue-600"
}`}
/>
)}
</button> </button>
); );
})} })}
</div> </div>
</div> </div>
{/* برداشت‌های روز */} {/* ستون جزئیات روز — سفید یکدست */}
<div className="rounded-xl border border-line bg-panel p-4"> <div className="rounded-xl border border-slate-200 bg-white p-4 shadow-xs">
<div className="mb-1 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between pb-3 border-b border-slate-100">
<h2 className="text-[14px] font-bold">🕘 کنترل روزانه — {isToday ? "امروز " : ""}{toFa(sd)} {MONTHS[sm - 1]} {toFa(sy)}</h2> <div className="flex items-center gap-2">
<span className="rounded-lg bg-rose-50 px-2 py-1 text-[11.5px] font-bold text-rose-600">▼ برداشت‌ها: {faNum(wGroups.length)} قلم</span> <Clock className="h-4 w-4 text-blue-600" />
<h2 className="text-sm font-bold text-slate-800">
{isToday ? "امروز — " : "کنترل — "}
{toFa(sd)} {MONTHS[sm - 1]} {toFa(sy)}
</h2>
</div>
<span
className={`rounded-md px-2 py-0.5 text-xs font-semibold ${
wGroups.length > 0
? "bg-amber-50 text-amber-700 border border-amber-200"
: "text-slate-400 border border-slate-100"
}`}
>
{faNum(wGroups.length)} برداشت
</span>
</div> </div>
<p className="mb-3 text-[11.5px] leading-5 text-muted">
مواردی که این روز از انبار برداشت و به ساختمان برده شده‌اند. روی هر مورد بزنید و برگشت / مفقودی / اتلاف را انتخاب کنید. <p className="mb-3 text-[11.5px] leading-5 text-slate-500">
اقلام برداشت‌شده از انبار در این تاریخ:
</p> </p>
{!wGroups.length ? ( {!wGroups.length ? (
<div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted"> <div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-slate-200 py-10 px-4 text-center">
{isToday ? "امروز هنوز برداشتی ثبت نشده است." : "در این روز برداشتی ثبت نشده است."} هر برداشتی که در «تغییر موجودی» ثبت کنید، همین‌جا نمایش داده می‌شود. <span className="text-xs text-slate-400 mb-2">
{isToday ? "امروز هنوز هیچ برداشتی ثبت نشده است." : "در این تاریخ برداشتی ثبت نشده است."}
</span>
<Link
to="/inventory/change"
className="inline-flex items-center gap-1 px-3 py-1 rounded text-blue-600 hover:bg-blue-50 text-xs font-semibold transition-colors no-underline"
>
<Plus className="h-3 w-3" />
<span>ثبت برداشت جدید</span>
</Link>
</div> </div>
) : ( ) : (
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-2">
{wGroups.map((g) => ( {wGroups.map((g) => (
<div key={g.recId} className={`rounded-lg border p-2.5 ${actRec === g.recId ? "border-accent bg-accent/5" : "border-line bg-panel"}`}> <div
<button type="button" className="flex w-full items-center gap-2 text-start" onClick={() => setActRec(actRec === g.recId ? null : g.recId)}> key={g.recId}
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-rose-50 text-rose-600">➖</span> className={`rounded-lg border p-3 transition-colors ${
actRec === g.recId
? "border-blue-500 bg-blue-50/20"
: "border-slate-100 bg-white hover:border-slate-200"
}`}
>
<button
type="button"
className="flex w-full items-center gap-3 text-start cursor-pointer"
onClick={() => setActRec(actRec === g.recId ? null : g.recId)}
>
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-semibold">{g.tx.material?.name || "—"}</span> <span className="block truncate text-xs font-bold text-slate-800">
<span className="block truncate text-[11px] text-muted"> {g.tx.material?.name || "—"}
{[g.tx.brand?.name, Object.entries(g.tx.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · "), whName(data.warehouses, g.tx.warehouse_id)].filter(Boolean).join(" · ")} </span>
<span className="block truncate text-[11px] text-slate-400 mt-0.5">
{[
g.tx.brand?.name,
Object.entries(g.tx.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · "),
whName(data.warehouses, g.tx.warehouse_id)
].filter(Boolean).join(" · ")}
</span> </span>
</span> </span>
<span className="text-[13px] font-bold text-rose-600">{faNum(g.q)} {unitName(data.lookup.units, g.tx.unit?.id)}</span> <span className="text-xs font-bold text-rose-600 tabular-nums">
<span className="text-[10.5px] text-muted">{new Date(g.last).toLocaleTimeString("fa-IR", { hour: "2-digit", minute: "2-digit" })}</span> {faNum(g.q)} {unitName(data.lookup.units, g.tx.unit?.id)}
</span>
<span className="text-[11px] text-slate-400 tabular-nums">
{new Date(g.last).toLocaleTimeString("fa-IR", { hour: "2-digit", minute: "2-digit" })}
</span>
</button> </button>
{actRec === g.recId && isPM && ( {actRec === g.recId && isPM && (
<div className="mt-2 flex gap-1.5 border-t border-line pt-2"> <div className="mt-2.5 flex flex-wrap gap-2 border-t border-slate-100 pt-2">
<button <button
className="rounded-lg border border-emerald-300 bg-emerald-50 px-3 py-1 text-[11.5px] font-semibold text-emerald-700" type="button"
className="inline-flex items-center gap-1 rounded border border-emerald-300 bg-emerald-50 px-2.5 py-1 text-xs font-semibold text-emerald-700 hover:bg-emerald-100 transition-colors cursor-pointer"
onClick={() => navigate(`/inventory/change?type=RETURN&wh=${g.tx.warehouse_id}&mat=${encodeURIComponent(g.tx.material?.name || "")}`)} onClick={() => navigate(`/inventory/change?type=RETURN&wh=${g.tx.warehouse_id}&mat=${encodeURIComponent(g.tx.material?.name || "")}`)}
> >
↩️ برگشت <RotateCcw className="h-3 w-3" />
<span>برگشت</span>
</button> </button>
<button <button
className="rounded-lg border border-rose-300 bg-rose-50 px-3 py-1 text-[11.5px] font-semibold text-rose-700" type="button"
className="inline-flex items-center gap-1 rounded border border-rose-300 bg-rose-50 px-2.5 py-1 text-xs font-semibold text-rose-700 hover:bg-rose-100 transition-colors cursor-pointer"
onClick={() => navigate(`/inventory/change?type=LOSS&wh=${g.tx.warehouse_id}&mat=${encodeURIComponent(g.tx.material?.name || "")}`)} onClick={() => navigate(`/inventory/change?type=LOSS&wh=${g.tx.warehouse_id}&mat=${encodeURIComponent(g.tx.material?.name || "")}`)}
> >
📦 مفقودی <PackageX className="h-3 w-3" />
<span>مفقودی</span>
</button> </button>
<button <button
className="rounded-lg border border-rose-300 bg-rose-50 px-3 py-1 text-[11.5px] font-semibold text-rose-700" type="button"
className="inline-flex items-center gap-1 rounded border border-slate-200 bg-white px-2.5 py-1 text-xs font-semibold text-slate-700 hover:bg-slate-50 transition-colors cursor-pointer"
onClick={() => navigate(`/inventory/change?type=WASTE&wh=${g.tx.warehouse_id}&mat=${encodeURIComponent(g.tx.material?.name || "")}`)} onClick={() => navigate(`/inventory/change?type=WASTE&wh=${g.tx.warehouse_id}&mat=${encodeURIComponent(g.tx.material?.name || "")}`)}
> >
🗑️ اتلاف <Trash2 className="h-3 w-3" />
<span>اتلاف</span>
</button> </button>
</div> </div>
)} )}
@@ -264,4 +370,4 @@ export function DailyControlPage() {
</PageLayout> </PageLayout>
</div> </div>
); );
} }
+129 -80
View File
@@ -1,9 +1,10 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Link, useLocation, useSearchParams } from "react-router-dom"; import { Link, useLocation, useSearchParams } from "react-router-dom";
import { Plus, Warehouse as WarehouseIcon } from "lucide-react"; import { Plus, Warehouse as WarehouseIcon, ArrowUpDown, ArrowUp, ArrowDown, X } from "lucide-react";
import { Header, PageLayout } from "../../components/layout/Header"; import { Header, PageLayout } from "../../components/layout/Header";
import { EmptyState } from "../../components/EmptyState"; import { EmptyState } from "../../components/EmptyState";
import { Badge } from "../../components/ui/badge"; import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import { useAuthStore } from "../../store/auth"; import { useAuthStore } from "../../store/auth";
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu"; import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
import { inventorySubheader } from "./panelMenu"; import { inventorySubheader } from "./panelMenu";
@@ -52,20 +53,21 @@ export function InventoryPage() {
const location = useLocation(); const location = useLocation();
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const { warehouses, inventory, transactions, lookup, loading } = useInventoryData(); const { warehouses, inventory, transactions, lookup, loading } = useInventoryData();
const isAdmin = user?.role === "admin" || user?.role === "procurement_manager"; // مدیر تدارکات = ادمین دفتر const isAdmin = user?.role === "admin" || user?.role === "procurement_manager";
const isPM = user?.role === "pm"; const isPM = user?.role === "pm";
// پرش عمیق از «انبارها»: /inventory?warehouse=<id> → داشبورد همان انبار از پیش فیلتر شده
const [params] = useSearchParams(); const [params] = useSearchParams();
const [filters, setFilters] = useState<{ category: string; warehouse: string; material: string; project: string }>({ const [filters, setFilters] = useState<{ category: string; warehouse: string; material: string; project: string }>({
category: "", warehouse: params.get("warehouse") || "", material: "", project: "", category: "",
warehouse: params.get("warehouse") || "",
material: "",
project: "",
}); });
const [sort, setSort] = useState<"asc" | "desc" | "">(""); const [sort, setSort] = useState<"asc" | "desc" | "">("");
const [expandedKey, setExpandedKey] = useState<string | null>(null); const [expandedKey, setExpandedKey] = useState<string | null>(null);
const [selTx, setSelTx] = useState<string | null>(null); const [selTx, setSelTx] = useState<string | null>(null);
const [expBrand, setExpBrand] = useState<string | null>(null); const [expBrand, setExpBrand] = useState<string | null>(null);
// ادمین: انبارهای همه پروژه‌ها یا پروژه‌ی انتخابی
const visibleWarehouses = useMemo(() => { const visibleWarehouses = useMemo(() => {
if (isAdmin && filters.project) return warehouses.filter((w) => w.project_id === filters.project); if (isAdmin && filters.project) return warehouses.filter((w) => w.project_id === filters.project);
if (isAdmin) return warehouses; if (isAdmin) return warehouses;
@@ -86,7 +88,18 @@ export function InventoryPage() {
const gk = `${whId}|${r.material.id}|${specsHash(r.specs)}`; const gk = `${whId}|${r.material.id}|${specsHash(r.specs)}`;
let g = gmap.get(gk); let g = gmap.get(gk);
if (!g) { if (!g) {
g = { key: gk, whId, matId: r.material.id, specs: r.specs || {}, unitId: r.unit?.id ?? null, qty: 0, updatedAt: r.updated_at, recs: [], brands: {}, brandOrder: [] }; g = {
key: gk,
whId,
matId: r.material.id,
specs: r.specs || {},
unitId: r.unit?.id ?? null,
qty: 0,
updatedAt: r.updated_at,
recs: [],
brands: {},
brandOrder: [],
};
gmap.set(gk, g); gmap.set(gk, g);
order.push(gk); order.push(gk);
} }
@@ -145,24 +158,23 @@ export function InventoryPage() {
<> <>
<InventoryTopNav pathname={location.pathname} variant={isAdmin ? "admin" : "pm"} /> <InventoryTopNav pathname={location.pathname} variant={isAdmin ? "admin" : "pm"} />
{isPM && ( {isPM && (
<Link <Button asChild variant="primary">
to="/inventory/change" <Link to="/inventory/change" className="no-underline">
className="btn-line-file no-underline bg-accent text-white hover:bg-accent-dark" <Plus className="h-4 w-4" />
> تغییر موجودی
<Plus className="h-3.5 w-3.5" /> </Link>
تغییر موجودی </Button>
</Link>
)} )}
</> </>
} }
/> />
<PageLayout className="flex flex-col gap-3"> <PageLayout className="flex flex-col gap-4">
{/* فیلترها */} {/* نوار فیلترها و ابزارها */}
<div className="flex flex-wrap items-end gap-2 rounded-xl border border-line bg-panel p-3"> <div className="flex flex-wrap items-end gap-3 rounded-xl border border-line bg-panel p-3.5 shadow-sm">
<div className="field"> <div className="flex flex-col gap-1.5">
<label>دسته‌بندی</label> <label className="text-xs font-medium text-muted">دسته‌بندی</label>
<select <select
className="h-8 rounded-lg border border-line bg-panel px-2 text-[12.5px]" className="h-9 min-w-[150px] rounded-md border border-line bg-panel px-2.5 text-xs text-ink transition-all focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
value={filters.category} value={filters.category}
onChange={(e) => setFilters((f) => ({ ...f, category: e.target.value, material: "" }))} onChange={(e) => setFilters((f) => ({ ...f, category: e.target.value, material: "" }))}
> >
@@ -172,11 +184,12 @@ export function InventoryPage() {
))} ))}
</select> </select>
</div> </div>
{isAdmin && ( {isAdmin && (
<div className="field"> <div className="flex flex-col gap-1.5">
<label>پروژه</label> <label className="text-xs font-medium text-muted">پروژه</label>
<select <select
className="h-8 rounded-lg border border-line bg-panel px-2 text-[12.5px]" className="h-9 min-w-[140px] rounded-md border border-line bg-panel px-2.5 text-xs text-ink transition-all focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
value={filters.project} value={filters.project}
onChange={(e) => setFilters((f) => ({ ...f, project: e.target.value, warehouse: "", material: "" }))} onChange={(e) => setFilters((f) => ({ ...f, project: e.target.value, warehouse: "", material: "" }))}
> >
@@ -187,10 +200,11 @@ export function InventoryPage() {
</select> </select>
</div> </div>
)} )}
<div className="field">
<label>انبار</label> <div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-muted">انبار</label>
<select <select
className="h-8 rounded-lg border border-line bg-panel px-2 text-[12.5px]" className="h-9 min-w-[140px] rounded-md border border-line bg-panel px-2.5 text-xs text-ink transition-all focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
value={filters.warehouse} value={filters.warehouse}
onChange={(e) => setFilters((f) => ({ ...f, warehouse: e.target.value, material: "" }))} onChange={(e) => setFilters((f) => ({ ...f, warehouse: e.target.value, material: "" }))}
> >
@@ -202,52 +216,82 @@ export function InventoryPage() {
))} ))}
</select> </select>
</div> </div>
<div className="field">
<label>متریال</label> <div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-muted">متریال</label>
<input <input
className="h-8 w-44 rounded-lg border border-line bg-panel px-2 text-[12.5px]" className="h-9 w-48 rounded-md border border-line bg-panel px-3 text-xs text-ink transition-all placeholder:text-muted/70 focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
placeholder="تایپ نام متریال…" placeholder="تایپ نام متریال…"
value={filters.material} value={filters.material}
onChange={(e) => setFilters((f) => ({ ...f, material: e.target.value }))} onChange={(e) => setFilters((f) => ({ ...f, material: e.target.value }))}
/> />
</div> </div>
<button
className="h-8 rounded-lg border border-line bg-panel px-3 text-[12px]" <Button
variant="line"
onClick={() => setSort((s) => (s === "" ? "asc" : s === "asc" ? "desc" : ""))} onClick={() => setSort((s) => (s === "" ? "asc" : s === "asc" ? "desc" : ""))}
className="gap-1.5"
> >
{sort === "asc" ? "تعداد: صعودی ▲" : sort === "desc" ? "تعداد: نزولی ▼" : "مرتب‌سازی تعداد"} {sort === "asc" ? (
</button> <>
<ArrowUp className="h-3.5 w-3.5 text-accent" />
تعداد: صعودی
</>
) : sort === "desc" ? (
<>
<ArrowDown className="h-3.5 w-3.5 text-accent" />
تعداد: نزولی
</>
) : (
<>
<ArrowUpDown className="h-3.5 w-3.5 text-muted" />
مرتب‌سازی تعداد
</>
)}
</Button>
{hasFilter && ( {hasFilter && (
<button <Button
className="h-8 rounded-lg border border-red/30 bg-red/5 px-3 text-[12px] text-red" variant="danger"
onClick={() => setFilters({ category: "", warehouse: "", material: "", project: "" })} onClick={() => setFilters({ category: "", warehouse: "", material: "", project: "" })}
className="gap-1"
> >
✕ پاک کردن فیلترها <X className="h-3.5 w-3.5" />
</button> پاک کردن فیلترها
</Button>
)} )}
{filters.warehouse && (
<Badge variant="offered">🏬 {whName(warehouses, filters.warehouse)}</Badge> <div className="ms-auto flex items-center gap-2 self-end">
)} {filters.warehouse && (
<Badge variant="outline">{faNum(groups.length)} قلم کالا</Badge> <Badge variant="offered" className="rounded-md px-2.5 py-1 text-xs">
🏬 {whName(warehouses, filters.warehouse)}
</Badge>
)}
<Badge variant="outline" className="rounded-md border-line bg-slate-bg px-3 py-1.5 text-xs font-normal text-muted">
{faNum(groups.length)} قلم کالا
</Badge>
</div>
</div> </div>
{/* جدول گروه‌بندی‌شده */} {/* بخش جدول یا وضعیت خالی */}
<div className="overflow-hidden rounded-xl border border-line bg-panel"> {!groups.length ? (
{!groups.length ? ( <div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-line bg-panel/40 py-16 px-4 shadow-xs">
<EmptyState emoji="📋" text="هیچ رکورد موجودی‌ای مطابق فیلترهای انتخابی یافت نشد." /> <EmptyState emoji="📋" text="هیچ رکورد موجودی‌ای مطابق فیلترهای انتخابی یافت نشد." />
) : ( </div>
) : (
<div className="overflow-hidden rounded-xl border border-line bg-panel shadow-sm">
<table className="w-full text-[12.5px]"> <table className="w-full text-[12.5px]">
<thead> <thead>
<tr className="border-b border-line bg-panel text-right text-muted"> <tr className="border-b border-line bg-slate-bg/50 text-right text-muted font-medium">
<th className="w-8 py-2" /> <th className="w-8 py-2.5" />
<th className="py-2 pe-3">متریال</th> <th className="py-2.5 pe-3">متریال</th>
<th className="py-2 pe-3">دسته‌بندی</th> <th className="py-2.5 pe-3">دسته‌بندی</th>
<th className="py-2 pe-3">انبار</th> <th className="py-2.5 pe-3">انبار</th>
<th className="py-2 pe-3">مشخصه فنی</th> <th className="py-2.5 pe-3">مشخصه فنی</th>
<th className="py-2 pe-3">تعداد</th> <th className="py-2.5 pe-3">تعداد</th>
<th className="py-2 pe-3">واحد</th> <th className="py-2.5 pe-3">واحد</th>
<th className="py-2 pe-3">آخرین به‌روزرسانی</th> <th className="py-2.5 pe-3">آخرین به‌روزرسانی</th>
<th className="w-8 py-2" /> <th className="w-8 py-2.5" />
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -293,8 +337,9 @@ export function InventoryPage() {
})} })}
</tbody> </tbody>
</table> </table>
)} </div>
</div> )}
<div className="text-[11px] text-muted"> <div className="text-[11px] text-muted">
۱۰ نوع تراکنش استاندارد • سبز زمردی = ورود به انبار · رز = خروج/کاهش · آبی = انتقال و شمارش • کلیک روی هر سطر: خط زمانی با «مانده» لحظه‌به‌لحظه و تفکیک برندها ۱۰ نوع تراکنش استاندارد • سبز زمردی = ورود به انبار · رز = خروج/کاهش · آبی = انتقال و شمارش • کلیک روی هر سطر: خط زمانی با «مانده» لحظه‌به‌لحظه و تفکیک برندها
</div> </div>
@@ -317,25 +362,25 @@ function GroupRow({ g, isExp, isAdmin, matName, matCatName, wh, projName, unit,
}) { }) {
return ( return (
<> <>
<tr className={`border-b border-line/60 cursor-pointer ${isExp ? "bg-accent/5" : "hover:bg-accent/5"}`} onClick={onToggle}> <tr className={`border-b border-line/60 cursor-pointer transition-colors duration-150 ${isExp ? "bg-accent/5" : "hover:bg-slate-bg/60"}`} onClick={onToggle}>
<td className="py-2 text-center text-muted">{isExp ? "▾" : "▸"}</td> <td className="py-2.5 text-center text-muted">{isExp ? "▾" : "▸"}</td>
<td className="py-2 pe-3 font-semibold">{matName}</td> <td className="py-2.5 pe-3 font-semibold text-ink">{matName}</td>
<td className="py-2 pe-3">{matCatName}</td> <td className="py-2.5 pe-3">{matCatName}</td>
<td className="py-2 pe-3"> <td className="py-2.5 pe-3">
{wh} {wh}
{isAdmin && <small className="block text-muted">{projName}</small>} {isAdmin && <small className="block text-muted">{projName}</small>}
</td> </td>
<td className="py-2 pe-3 text-muted"> <td className="py-2.5 pe-3 text-muted">
{Object.entries(g.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"} {Object.entries(g.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}
</td> </td>
<td className="py-2 pe-3 font-bold">{faNum(g.qty)}</td> <td className="py-2.5 pe-3 font-bold text-ink">{faNum(g.qty)}</td>
<td className="py-2 pe-3">{unit}</td> <td className="py-2.5 pe-3">{unit}</td>
<td className="py-2 pe-3 text-muted">{isoToJalaliStr(g.updatedAt)}</td> <td className="py-2.5 pe-3 text-muted">{isoToJalaliStr(g.updatedAt)}</td>
<td className="py-2 text-muted">🕘</td> <td className="py-2.5 text-muted">🕘</td>
</tr> </tr>
{isExp && ( {isExp && (
<tr className="border-b border-line/40 bg-accent-bg/20"> <tr className="border-b border-line/40 bg-accent-bg/10">
<td colSpan={9} className="p-3">{timeline}</td> <td colSpan={9} className="p-3.5">{timeline}</td>
</tr> </tr>
)} )}
</> </>
@@ -367,7 +412,9 @@ function Timeline({ g, txs, allTxs, brandTxs, selTx, setSelTx, expBrand, setExpB
key={t.id} key={t.id}
type="button" type="button"
onClick={() => setSelTx(sel ? null : t.id)} onClick={() => setSelTx(sel ? null : t.id)}
className={`flex min-w-[120px] flex-col gap-0.5 rounded-lg border p-2 text-start ${sel ? "border-accent bg-accent/10" : "border-line bg-panel"}`} className={`flex min-w-[125px] flex-col gap-1 rounded-md border p-2.5 text-start transition-all duration-150 active:scale-[0.98] ${
sel ? "border-accent bg-accent/10 shadow-xs" : "border-line bg-panel hover:border-slate-300"
}`}
style={{ borderTop: `3px solid ${meta.color}` }} style={{ borderTop: `3px solid ${meta.color}` }}
> >
<span className="text-[11px] font-bold" style={{ color: meta.color }}>{meta.icon} {meta.label}</span> <span className="text-[11px] font-bold" style={{ color: meta.color }}>{meta.icon} {meta.label}</span>
@@ -383,9 +430,9 @@ function Timeline({ g, txs, allTxs, brandTxs, selTx, setSelTx, expBrand, setExpB
if (!txs.length) return <div className="text-[12px] text-muted">تراکنشی ثبت نشده است.</div>; if (!txs.length) return <div className="text-[12px] text-muted">تراکنشی ثبت نشده است.</div>;
const brandSec = expBrand ? ( const brandSec = expBrand ? (
<div className="mt-2"> <div className="mt-2.5">
<div className="mb-1 text-[12px] font-semibold">جریان برند: {brandNameFn(expBrand)} ({faNum(brandTxs(g, expBrand).length)} تراکنش)</div> <div className="mb-1.5 text-[12px] font-semibold">جریان برند: {brandNameFn(expBrand)} ({faNum(brandTxs(g, expBrand).length)} تراکنش)</div>
<div className="flex gap-1.5 overflow-x-auto">{track(brandTxs(g, expBrand))}</div> <div className="flex gap-2 overflow-x-auto pb-1">{track(brandTxs(g, expBrand))}</div>
</div> </div>
) : null; ) : null;
@@ -395,7 +442,7 @@ function Timeline({ g, txs, allTxs, brandTxs, selTx, setSelTx, expBrand, setExpB
const meta = TX_META[t.transaction_type] || { label: t.transaction_type, color: "#64748b", icon: "•" }; const meta = TX_META[t.transaction_type] || { label: t.transaction_type, color: "#64748b", icon: "•" };
const pairTx = t.transfer_pair_id ? allTxs.find((x) => x.transfer_pair_id === t.transfer_pair_id && x.id !== t.id) : null; const pairTx = t.transfer_pair_id ? allTxs.find((x) => x.transfer_pair_id === t.transfer_pair_id && x.id !== t.id) : null;
return ( return (
<div className="mt-2 rounded-lg border p-2 text-[12px]" style={{ borderTop: `3px solid ${meta.color}` }}> <div className="mt-2.5 rounded-md border border-line bg-panel p-2.5 text-[12px] shadow-xs" style={{ borderTop: `3px solid ${meta.color}` }}>
<b style={{ color: meta.color }}>{meta.label}</b> <b style={{ color: meta.color }}>{meta.label}</b>
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-muted"> <div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-muted">
<span>تاریخ و ساعت: <b>{isoToJalaliStr(t.transaction_date)}</b></span> <span>تاریخ و ساعت: <b>{isoToJalaliStr(t.transaction_date)}</b></span>
@@ -422,14 +469,14 @@ function Timeline({ g, txs, allTxs, brandTxs, selTx, setSelTx, expBrand, setExpB
return ( return (
<div> <div>
<div className="mb-1 flex flex-wrap items-center gap-2 text-[12px]"> <div className="mb-1.5 flex flex-wrap items-center gap-2 text-[12px]">
<b>خط زمانی — {matName}</b> <b>خط زمانی — {matName}</b>
{Object.entries(g.specs || {}).length > 0 && ( {Object.entries(g.specs || {}).length > 0 && (
<span className="rounded bg-panel px-1.5 py-0.5 text-[10.5px] text-muted"> <span className="rounded bg-panel px-2 py-0.5 text-[10.5px] text-muted border border-line">
{Object.entries(g.specs).map(([k, v]) => `${k}: ${v}`).join(" · ")} {Object.entries(g.specs).map(([k, v]) => `${k}: ${v}`).join(" · ")}
</span> </span>
)} )}
<span className="rounded bg-panel px-1.5 py-0.5 text-[10.5px] text-muted">{faNum(g.brandOrder.length)} برند</span> <span className="rounded bg-panel px-2 py-0.5 text-[10.5px] text-muted border border-line">{faNum(g.brandOrder.length)} برند</span>
</div> </div>
<div className="mb-2 flex flex-wrap gap-1.5"> <div className="mb-2 flex flex-wrap gap-1.5">
{g.brandOrder.map((bid) => ( {g.brandOrder.map((bid) => (
@@ -437,16 +484,18 @@ function Timeline({ g, txs, allTxs, brandTxs, selTx, setSelTx, expBrand, setExpB
key={bid} key={bid}
type="button" type="button"
onClick={() => setExpBrand(expBrand === bid ? null : bid)} onClick={() => setExpBrand(expBrand === bid ? null : bid)}
className={`rounded-lg border px-2 py-1 text-[11.5px] ${expBrand === bid ? "border-accent bg-accent/10 text-accent" : "border-line bg-panel"}`} className={`rounded-md border px-2.5 py-1 text-[11.5px] transition-all duration-150 active:scale-[0.98] ${
expBrand === bid ? "border-accent bg-accent/10 font-semibold text-accent" : "border-line bg-panel hover:bg-slate-bg"
}`}
> >
{brandNameFn(bid)}: <b>{faNum(g.brands[bid])}</b> {unit}{expBrand === bid ? " ✕" : ""} {brandNameFn(bid)}: <b>{faNum(g.brands[bid])}</b> {unit}{expBrand === bid ? " ✕" : ""}
</button> </button>
))} ))}
</div> </div>
{brandSec} {brandSec}
<div className="mb-1 mt-2 text-[12px] font-semibold">جریان کلی رکورد — ترکیب همه برندها ({faNum(txs.length)} تراکنش)</div> <div className="mb-1.5 mt-2.5 text-[12px] font-semibold">جریان کلی رکورد — ترکیب همه برندها ({faNum(txs.length)} تراکنش)</div>
<div className="flex gap-1.5 overflow-x-auto pb-1">{track(txs)}</div> <div className="flex gap-2 overflow-x-auto pb-1">{track(txs)}</div>
{selDetail} {selDetail}
</div> </div>
); );
} }
@@ -1,13 +1,7 @@
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { cn } from "../../lib/utils"; import { Button } from "../../components/ui/button";
// ناوبری بالای صفحه در پنل انبار — فقط روی «داشبورد انبار» (هاب) دیده می‌شود؛ // ناوبری بالای صفحه در پنل انبار — هماهنگ با دیزاین‌سیستم دکمه‌ها
// خودِ «داشبورد انبار» دکمه ندارد (ریشه/هاب است) و زیرصفحه‌ها تب ندارند و
// فقط با «بازگشت به داشبورد» به هاب برمی‌گردند.
//
// - variant="pm" (مدیر پروژه): کنترل روزانه، انبارها، کالاهای در راه —
// ورود به «تغییر موجودی» از دکمه‌ی بالای هاب است.
// - variant="admin" (مدیر تدارکات/ادمین): کنترل روزانه، کالاهای در راه، سازمان‌دهی.
export function InventoryTopNav({ export function InventoryTopNav({
pathname, pathname,
variant = "pm", variant = "pm",
@@ -27,20 +21,20 @@ export function InventoryTopNav({
{ label: "انبارها", to: "/inventory/warehouses", active: pathname.startsWith("/inventory/warehouses") }, { label: "انبارها", to: "/inventory/warehouses", active: pathname.startsWith("/inventory/warehouses") },
{ label: "کالاهای در راه", to: "/inventory/arrivals", active: pathname.startsWith("/inventory/arrivals") }, { label: "کالاهای در راه", to: "/inventory/arrivals", active: pathname.startsWith("/inventory/arrivals") },
]; ];
return ( return (
<> <>
{items.map((it) => ( {items.map((it) => (
<Link <Button
key={it.to} key={it.to}
to={it.to} asChild
className={cn( variant={it.active ? "primary" : "slate"}
"btn-line-file no-underline",
it.active && "border-accent bg-accent text-white hover:bg-accent-dark",
)}
> >
{it.label} <Link to={it.to} className="no-underline">
</Link> {it.label}
</Link>
</Button>
))} ))}
</> </>
); );
} }
@@ -294,11 +294,11 @@ export function OrganizationPage() {
<div className="mb-2 flex flex-col gap-1.5"> <div className="mb-2 flex flex-col gap-1.5">
{whs.map((w) => ( {whs.map((w) => (
<div key={w.id} className="flex items-center justify-between gap-2 rounded-lg border border-line px-3 py-2 text-[12.5px]"> <div key={w.id} className="flex items-center justify-between gap-2 rounded-lg border border-line px-3 py-2 text-[12.5px]">
<div className="min-w-0"> <div className="min-w-0 flex-1">
<b>{w.name}</b> <b>{w.name}</b>
<small className="block truncate text-muted">{w.description || ""}</small> <small className="block truncate text-muted">{w.description || ""}</small>
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex flex-wrap items-center justify-end gap-1.5">
<button <button
className="rounded-lg border border-accent/40 bg-accent/5 px-2 py-1 text-[10.5px] font-bold text-accent" className="rounded-lg border border-accent/40 bg-accent/5 px-2 py-1 text-[10.5px] font-bold text-accent"
onClick={() => navigate(`/inventory?warehouse=${w.id}`)} onClick={() => navigate(`/inventory?warehouse=${w.id}`)}
@@ -322,20 +322,20 @@ export function OrganizationPage() {
))} ))}
</div> </div>
)} )}
<div className="flex gap-2"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<input <input
className="h-8 w-44 rounded-lg border border-line bg-panel px-2 text-sm" className="h-8 w-full rounded-lg border border-line bg-panel px-2 text-sm sm:w-44"
placeholder="نام انبار جدید" placeholder="نام انبار جدید"
value={newWh[p.id] || ""} value={newWh[p.id] || ""}
onChange={(e) => setNewWh((s) => ({ ...s, [p.id]: e.target.value }))} onChange={(e) => setNewWh((s) => ({ ...s, [p.id]: e.target.value }))}
/> />
<input <input
className="h-8 w-52 rounded-lg border border-line bg-panel px-2 text-sm" className="h-8 w-full rounded-lg border border-line bg-panel px-2 text-sm sm:w-52"
placeholder="توضیحات (اختیاری)" placeholder="توضیحات (اختیاری)"
value={newWhDesc[p.id] || ""} value={newWhDesc[p.id] || ""}
onChange={(e) => setNewWhDesc((s) => ({ ...s, [p.id]: e.target.value }))} onChange={(e) => setNewWhDesc((s) => ({ ...s, [p.id]: e.target.value }))}
/> />
<button className="rounded-lg bg-accent px-3 py-1.5 text-[12px] font-bold text-white" onClick={() => void addWarehouse(p.id)}> <button className="w-full rounded-lg bg-accent px-3 py-2 text-[12px] font-bold text-white sm:w-auto sm:py-1.5" onClick={() => void addWarehouse(p.id)}>
+ افزودن انبار + افزودن انبار
</button> </button>
</div> </div>
+2 -4
View File
@@ -19,7 +19,7 @@ import {
activeItems, activeItems,
lineItemTotal, lineItemTotal,
materialById, materialById,
materialSpecs, materialLabel,
refConverted, refConverted,
supplierById, supplierById,
} from "../../lib/quoteUtils"; } from "../../lib/quoteUtils";
@@ -154,9 +154,7 @@ function renderProjectDetail(store: Store, name: string): React.ReactElement {
const matName = (mid: string) => { const matName = (mid: string) => {
const m = materialById(store.materials, mid); const m = materialById(store.materials, mid);
if (m.category === "؟") return "؟"; return materialLabel(m);
const specs = materialSpecs(m);
return `${m.category} ${m.brand}${specs.length ? " · " + specs.join(" · ") : ""}`;
}; };
const supName = (sid: string) => { const supName = (sid: string) => {
@@ -11,6 +11,7 @@ import { Header, PageLayout } from "../../components/layout/Header";
import { EmptyState } from "../../components/EmptyState"; import { EmptyState } from "../../components/EmptyState";
import { Combobox } from "../../components/Combobox"; import { Combobox } from "../../components/Combobox";
import { SupplierCard, suppliersOfCategory } from "./supplierUtils"; import { SupplierCard, suppliersOfCategory } from "./supplierUtils";
import { materialLabel } from "../../lib/quoteUtils";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
// صفحه‌ی تأمین‌کننده‌های یک دسته + فرم افزودن تأمین‌کننده // صفحه‌ی تأمین‌کننده‌های یک دسته + فرم افزودن تأمین‌کننده
@@ -53,7 +54,7 @@ export function CategoryPage() {
if (li.rejected) return; if (li.rejected) return;
const m = materials.find((x) => x.id === li.material_id); const m = materials.find((x) => x.id === li.material_id);
if (!m) return; if (!m) return;
const label = `${m.category}${m.brand ? " " + m.brand : ""}${m.specs.length ? " (" + m.specs.join("، ") + ")" : ""}`; const label = materialLabel(m);
if (!map.has(q.supplier_id)) map.set(q.supplier_id, []); if (!map.has(q.supplier_id)) map.set(q.supplier_id, []);
const list = map.get(q.supplier_id)!; const list = map.get(q.supplier_id)!;
if (!list.includes(label)) list.push(label); if (!list.includes(label)) list.push(label);
@@ -3,7 +3,7 @@ import { Link } from "react-router-dom";
import { Building2, Paperclip, Plus } from "lucide-react"; import { Building2, Paperclip, Plus } from "lucide-react";
import type { Attachment, Material, Quote, Supplier } from "../../lib/types"; import type { Attachment, Material, Quote, Supplier } from "../../lib/types";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import { activeItems, materialById, materialSpecs, quoteTotal, specsLabel } from "../../lib/quoteUtils"; import { activeItems, materialById, materialLabel, quoteTotal } from "../../lib/quoteUtils";
import { fmtDate, faNum } from "../../lib/formatters"; import { fmtDate, faNum } from "../../lib/formatters";
import { groupCommas } from "../../lib/numutil"; import { groupCommas } from "../../lib/numutil";
import { STATUS_LABEL } from "../../lib/constants"; import { STATUS_LABEL } from "../../lib/constants";
@@ -85,8 +85,7 @@ export function PurchaseHistory({ supplier, quotes, materials }: PurchaseHistory
{purchases.map((q) => { {purchases.map((q) => {
const items = activeItems(q).map((li) => { const items = activeItems(q).map((li) => {
const m = materialById(materials, li.material_id); const m = materialById(materials, li.material_id);
const specs = materialSpecs(m); return materialLabel(m);
return `${m.category} ${m.brand}${specs.length ? " " + specsLabel(specs) : ""}`.trim();
}); });
const total = quoteTotal(q); const total = quoteTotal(q);
const attach = attachments[q.id]; const attach = attachments[q.id];
File diff suppressed because it is too large Load Diff
@@ -171,9 +171,6 @@ export function Step1TopicSupplier({
onChange={(e) => onOfficial(e.target.checked)} onChange={(e) => onOfficial(e.target.checked)}
/> />
فاکتور رسمی فاکتور رسمی
<span className="text-[11px] text-muted">
(تأمین‌کننده برای این معامله فاکتور رسمی صادر می‌کند — نه فقط توافق تلفنی)
</span>
</label> </label>
)} )}
</div> </div>
+69 -24
View File
@@ -6,7 +6,7 @@ import { specsKey } from "../../lib/quoteUtils";
import { Combobox } from "../../components/Combobox"; import { Combobox } from "../../components/Combobox";
import { NumberInput } from "../../components/NumberInput"; import { NumberInput } from "../../components/NumberInput";
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox"; import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
import { api } from "../../lib/api"; import { api, apiV26 } from "../../lib/api";
export interface ItemDraft { export interface ItemDraft {
tempId: string; tempId: string;
@@ -41,9 +41,24 @@ export function itemRowLabel(it: ItemDraft): string {
return qtyPart ? `${name} -- ${qtyPart}` : name; return qtyPart ? `${name} -- ${qtyPart}` : name;
} }
// مقادیر مجزای category/brand/spec داخل تاپیک — با فیلتر پله‌به‌پله (تاپیک → دسته → برند) // نام پایه‌ی متریال از نامِ ذخیره‌شده
// + مقادیر تایپ‌شده در همه‌ی سطرهای قبلی (suggestion از همه‌ی فیلدها) function materialBaseName(name: string, brand: string, specs: string[]): string {
// نکته: برند و واحد از همه‌ی دسته‌ها نمایش داده می‌شوند (issue #7) let base = (name || "").trim();
if (!base) return "";
if (brand) {
if (base.startsWith(brand)) base = base.slice(brand.length);
else if (base.includes(brand)) base = base.split(brand).join(" ");
}
specs
.filter((s) => s && s.trim())
.forEach((s) => {
if (base.includes(s)) base = base.split(s).join(" ");
});
base = base.replace(/\s+/g, " ").trim();
return base || (name || "").trim();
}
// مقادیر مجزای category/brand/spec داخل تاپیک
export function materialOptions( export function materialOptions(
materials: Material[], materials: Material[],
topic: string, topic: string,
@@ -56,7 +71,6 @@ export function materialOptions(
const cat = item.cat.trim(); const cat = item.cat.trim();
const brand = item.brand.trim(); const brand = item.brand.trim();
// مقادیر واردشده در سطرهای دیگر — برای پیشنهاد در سطرهای جدید
const fromRows = (sel: (it: ItemDraft) => string | string[]): string[] => { const fromRows = (sel: (it: ItemDraft) => string | string[]): string[] => {
const out = new Set<string>(); const out = new Set<string>();
allItems.forEach((it) => { allItems.forEach((it) => {
@@ -75,7 +89,6 @@ export function materialOptions(
} }
if (cat) list = list.filter((m) => normalize(m.category) === normalize(cat)); if (cat) list = list.filter((m) => normalize(m.category) === normalize(cat));
if (field === "brand") { if (field === "brand") {
// همه‌ی برندها از همه‌ی materials + برندهای ثبت‌شده در بخش برندها (بک‌اند)
const allBrands = new Set(materials.map((m) => m.brand).filter(Boolean)); const allBrands = new Set(materials.map((m) => m.brand).filter(Boolean));
apiBrands.forEach((b) => b && allBrands.add(b)); apiBrands.forEach((b) => b && allBrands.add(b));
fromRows((it) => it.brand).forEach((b) => allBrands.add(b)); fromRows((it) => it.brand).forEach((b) => allBrands.add(b));
@@ -91,20 +104,27 @@ export function materialOptions(
export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember }: Step2Props) { export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember }: Step2Props) {
const materials = useMaterials(); const materials = useMaterials();
// واحدها از بک‌اند خوانده می‌شوند + واحدهای واردشده در سطرهای قبلی
const [unitOptions, setUnitOptions] = useState<string[]>(() => { const [unitOptions, setUnitOptions] = useState<string[]>(() => {
const all = new Set<string>(); const all = new Set<string>();
items.forEach((it) => { if (it.unit) all.add(it.unit); }); items.forEach((it) => { if (it.unit) all.add(it.unit); });
return [...all]; return [...all];
}); });
// واحدهای تایپ‌شده در سطرهای قبلی
const allUnits = useMemo(() => { const allUnits = useMemo(() => {
const out = new Set(unitOptions); const out = new Set(unitOptions);
items.forEach((it) => { if (it.unit) out.add(it.unit); }); items.forEach((it) => { if (it.unit) out.add(it.unit); });
return [...out]; return [...out];
}, [items, unitOptions]); }, [items, unitOptions]);
// برندهای ثبت‌شده در بخش برندها (بک‌اند) — به‌علاوه‌ی برندهای متریال‌ها const allMaterialNames = useMemo(() => {
const set = new Set<string>();
materials.forEach((m) => {
const base = materialBaseName(m.name, m.brand, m.specs);
if (base) set.add(base);
});
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
}, [materials]);
const [brandOptions, setBrandOptions] = useState<string[]>([]); const [brandOptions, setBrandOptions] = useState<string[]>([]);
useEffect(() => { useEffect(() => {
@@ -116,11 +136,11 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
list.forEach((u) => { if (u.name_fa || u.code) merged.add(u.name_fa || u.code); }); list.forEach((u) => { if (u.name_fa || u.code) merged.add(u.name_fa || u.code); });
return [...merged]; return [...merged];
})) }))
.catch(() => { /* بک‌اند در دسترس نیست */ }); .catch(() => {});
void api void api
.brandsList() .brandsList()
.then((list) => alive && setBrandOptions(list.map((b) => b.name).filter(Boolean))) .then((list) => alive && setBrandOptions(list.map((b) => b.name).filter(Boolean)))
.catch(() => { /* بک‌اند در دسترس نیست */ }); .catch(() => {});
return () => { alive = false; }; return () => { alive = false; };
}, []); }, []);
@@ -133,14 +153,12 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
}; };
const add = () => { const add = () => {
// سطر جدید — مقدارها خالی؛ پیشنهادها از همه‌ی سطرهای قبلی می‌آیند
onItemsChange([ onItemsChange([
...items, ...items,
{ tempId: "it" + Date.now() + Math.random().toString(36).slice(2, 6), cat: "", brand: "", specs: [], qty: null, unit: "", price: null, total: null }, { tempId: "it" + Date.now() + Math.random().toString(36).slice(2, 6), cat: "", brand: "", specs: [], qty: null, unit: "", price: null, total: null },
]); ]);
}; };
// یادآوری یک فیلد تایپ‌شده برای پیشنهادهای آینده (داخل تاپیک)
const rememberField = (field: "i-cat" | "i-brand" | "i-spec", it: ItemDraft) => { const rememberField = (field: "i-cat" | "i-brand" | "i-spec", it: ItemDraft) => {
if (!selectedTopic) return; if (!selectedTopic) return;
let rec: MaterialRec; let rec: MaterialRec;
@@ -168,10 +186,10 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
return ( return (
<div> <div>
<h3 className="mb-1 text-[15px] font-bold"> <h3 className="mb-1 text-[15px] font-bold">
<span className="me-1 inline-flex h-6 w-6 items-center justify-center rounded-full bg-accent text-[12px] font-bold text-white">۲</span> <span className="me-1 inline-flex h-6 w-6 items-center justify-center rounded-full bg-blue-600 text-[12px] font-bold text-white">۲</span>
اقلام سفارش اقلام سفارش
</h3> </h3>
<div className="mb-3 text-[12px] text-muted" dangerouslySetInnerHTML={{ __html: 'هر سطر یک <b>قلم</b> است (متریال + برند + مشخصه + واحد + تعداد + قیمت). متریال، برند و مشخصه را می‌توان از گزینه‌ها انتخاب کرد یا تایپ کرد تا به‌عنوان گزینه‌ی جدید اضافه شود. شرایط پرداخت هر قلم را در مرحله‌ی بعد مشخص می‌کنید.' }} /> <div className="mb-3 text-[12px] text-gray-500" dangerouslySetInnerHTML={{ __html: 'هر سطر یک <b>قلم</b> است (متریال + برند + مشخصه + واحد + تعداد + قیمت). متریال، برند و مشخصه را می‌توان از گزینه‌ها انتخاب کرد یا تایپ کرد تا به‌عنوان گزینه‌ی جدید اضافه شود. شرایط پرداخت هر قلم را در مرحله‌ی بعد مشخص می‌کنید.' }} />
<div className="item-row wizard-item-row item-row-head mb-1 pe-7"> <div className="item-row wizard-item-row item-row-head mb-1 pe-7">
<span>متریال</span> <span>متریال</span>
@@ -185,11 +203,11 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{items.map((it) => ( {items.map((it) => (
<div key={it.tempId} className="item-row wizard-item-row"> <div key={it.tempId} className="item-row wizard-item-row items-center">
<Combobox <Combobox
value={it.cat} value={it.cat}
onChange={(x) => patch(it.tempId, { cat: x.label })} onChange={(x) => patch(it.tempId, { cat: x.label })}
items={materialOptions(materials, selectedTopic, it, "cat", items)} items={allMaterialNames}
allowAdd allowAdd
addSuffix="به عنوان متریال جدید" addSuffix="به عنوان متریال جدید"
placeholder="متریال" placeholder="متریال"
@@ -231,11 +249,23 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
<Combobox <Combobox
value={it.unit} value={it.unit}
onChange={(x) => patch(it.tempId, { unit: x.label })} onChange={(x) => patch(it.tempId, { unit: x.label })}
items={allUnits} items={allUnits.filter((u) => normalize(u) !== normalize(it.unit))}
allowAdd allowAdd
addSuffix="اضافه کردن واحد جدید" addSuffix="به عنوان واحد جدید"
placeholder="واحد" placeholder="واحد"
onInputChange={(v) => patch(it.tempId, { unit: v })} onInputChange={(v) => patch(it.tempId, { unit: v })}
onAdd={(v) => {
const name = v.trim();
if (!name) return;
void apiV26.catalog
.createUnit(name)
.then(() => {
setUnitOptions((prev) =>
prev.some((u) => normalize(u) === normalize(name)) ? prev : [...prev, name],
);
})
.catch(() => {});
}}
inputClassName="!text-[12px] !h-9" inputClassName="!text-[12px] !h-9"
className="min-w-0" className="min-w-0"
/> />
@@ -263,16 +293,31 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
placeholder="قیمت کل" placeholder="قیمت کل"
showWords showWords
/> />
<button type="button" className="cursor-pointer text-muted hover:text-red" title="حذف" onClick={() => remove(it.tempId)}>
× {/* دکمه حذف مدرن با آیکون سطل زباله */}
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-lg text-gray-400 hover:bg-red-50 hover:text-red-500 transition-colors"
title="حذف قلم"
onClick={() => remove(it.tempId)}
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button> </button>
</div> </div>
))} ))}
</div> </div>
<button type="button" className="btn-line-file mt-2.5" onClick={add}> {/* دکمه افزودن قلم با استایل برجسته و خط‌چین */}
+ افزودن قلم <button
type="button"
className="mt-3 flex w-full items-center justify-center gap-2 rounded-xl border-2 border-dashed border-blue-300 bg-blue-50/40 py-2.5 px-4 text-[13px] font-semibold text-blue-600 hover:border-blue-500 hover:bg-blue-100/70 transition-all duration-150"
onClick={add}
>
<span className="text-base font-bold leading-none">+</span>
<span>افزودن قلم جدید</span>
</button> </button>
</div> </div>
); );
} }
+35 -12
View File
@@ -2,7 +2,6 @@ import { Paperclip } from "lucide-react";
import { faNum } from "../../lib/formatters"; import { faNum } from "../../lib/formatters";
import { groupCommas } from "../../lib/numutil"; import { groupCommas } from "../../lib/numutil";
import { JalaliDatePicker } from "../../components/JalaliDatePicker"; import { JalaliDatePicker } from "../../components/JalaliDatePicker";
import { FileUpload } from "../../components/FileUpload";
export interface Step5Props { export interface Step5Props {
quoteDate: string | null; quoteDate: string | null;
@@ -52,7 +51,7 @@ export function Step5Review({
return ( return (
<div> <div>
<h3 className="mb-3 text-[15px] font-bold"> <h3 className="mb-3 text-[15px] font-bold">
<span className="me-1 inline-flex h-6 w-6 items-center justify-center rounded-full bg-accent text-[12px] font-bold text-white">۵</span> <span className="me-1 inline-flex h-6 w-6 items-center justify-center rounded-full bg-blue-600 text-[12px] font-bold text-white">۵</span>
تاریخ، یادداشت و بازبینی نهایی تاریخ، یادداشت و بازبینی نهایی
</h3> </h3>
@@ -78,19 +77,43 @@ export function Step5Review({
/> />
</div> </div>
<div className="field col-span-2"> <div className="field col-span-2">
<label>پیش‌فاکتور (اختیاری)</label> <label className="mb-1.5 block text-xs font-medium text-gray-700">پیش‌فاکتور (اختیاری)</label>
{proforma ? ( {proforma ? (
<div className="inv-row"> /* وضعیت فایل انتخاب‌شده با دکمه حذف تمیز */
<span className="inv-name"> <div className="flex items-center justify-between rounded-xl border border-gray-200 bg-gray-50/70 px-3 py-2 text-xs">
<Paperclip className="me-1 inline h-3.5 w-3.5" /> <div className="flex items-center gap-2 truncate text-gray-700">
{proforma.name} <Paperclip className="h-4 w-4 text-blue-600 shrink-0" />
</span> <span className="font-medium truncate">{proforma.name}</span>
<button type="button" className="inv-x" title="حذف" onClick={() => onProforma(null)}> <span className="text-[11px] text-gray-400 shrink-0">
× ({Math.round(proforma.size / 1024)} کیلوبایت)
</span>
</div>
<button
type="button"
className="flex h-7 w-7 items-center justify-center rounded-lg text-gray-400 hover:bg-red-50 hover:text-red-500 transition-colors"
title="حذف فایل"
onClick={() => onProforma(null)}
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button> </button>
</div> </div>
) : ( ) : (
<FileUpload onUpload={onProforma} label="انتخاب فایل پیش‌فاکتور" /> /* دکمه آپلود مینیمال با حاشیه خط‌چین ملایم */
<label className="inline-flex cursor-pointer items-center gap-2 rounded-xl border border-dashed border-gray-300 bg-gray-50/50 px-4 py-2 text-xs font-medium text-gray-600 hover:border-blue-500 hover:bg-blue-50/40 hover:text-blue-600 transition-all">
<Paperclip className="h-4 w-4 text-gray-400" />
<span>انتخاب یا پیوست فایل پیش‌فاکتور</span>
<input
type="file"
className="hidden"
accept=".pdf,.png,.jpg,.jpeg,.xlsx,.xls,.docx"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) onProforma(f);
}}
/>
</label>
)} )}
</div> </div>
</div> </div>
@@ -114,4 +137,4 @@ export function Step5Review({
</div> </div>
</div> </div>
); );
} }
+166 -154
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom"; import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { ChevronRight, ChevronLeft, Check } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import type { LineItem, Quote } from "../../lib/types"; import type { LineItem, Quote } from "../../lib/types";
import { api, nextId } from "../../lib/api"; import { api, nextId } from "../../lib/api";
@@ -73,18 +74,15 @@ export function WizardPage() {
const [assignment, setAssignment] = useState<Record<string, string[]>>({}); const [assignment, setAssignment] = useState<Record<string, string[]>>({});
const [quoteDate, setQuoteDate] = useState<string | null>(() => { const [quoteDate, setQuoteDate] = useState<string | null>(() => {
const d = new Date(); return new Date().toISOString();
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).toISOString();
}); });
const [expiryDate, setExpiryDate] = useState<string | null>(null); const [expiryDate, setExpiryDate] = useState<string | null>(null);
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
const [proforma, setProforma] = useState<File | null>(null); const [proforma, setProforma] = useState<File | null>(null);
// دیالوگ ذخیره با فیلدهای خالی (نام فیلد + مرحلهی مربوطه)
const [emptyFields, setEmptyFields] = useState<{ label: string; step: number }[]>([]); const [emptyFields, setEmptyFields] = useState<{ label: string; step: number }[]>([]);
const [confirmOpen, setConfirmOpen] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false);
// ---------- prefill از لینک‌ها (?topic=&supplier=) ---------- // ---------- prefill از لینک‌ها (?topic=&supplier=) ----------
// وقتی suppliers هنوز لود نشده، دوباره اجرا می‌شود
useEffect(() => { useEffect(() => {
const t = params.get("topic"); const t = params.get("topic");
if (!t) return; if (!t) return;
@@ -102,23 +100,17 @@ export function WizardPage() {
}, [params, suppliers, topics]); }, [params, suppliers, topics]);
// ---------- اعتبارسنجی قبل از ترک هر مرحله ---------- // ---------- اعتبارسنجی قبل از ترک هر مرحله ----------
// مراحل تکمیل‌شده را بر اساس داده‌های فعلی مشخص می‌کند
const completedSteps = useMemo(() => { const completedSteps = useMemo(() => {
const s = new Set<number>(); const s = new Set<number>();
// مرحله ۱: تاپیک انتخاب شده + نام تأمین‌کننده پر شده
if (selectedTopic && supplierName.trim()) s.add(1); if (selectedTopic && supplierName.trim()) s.add(1);
// مرحله ۲: حداقل یک قلم با تعداد و قیمت
if (items.length > 0 && items.some((it) => it.qty != null && it.qty !== 0 && it.price != null && it.price !== 0)) s.add(2); if (items.length > 0 && items.some((it) => it.qty != null && it.qty !== 0 && it.price != null && it.price !== 0)) s.add(2);
// مرحله ۳: حداقل یک termsheet با مایل‌ستون واقعی (تاریخ یا مبلغ) — نه فقط کارت پیش‌فرض
if ( if (
cards.length > 0 && cards.length > 0 &&
cards.some((card) => (card.milestones || []).some((m) => m.when_iso || (m.amount != null && m.amount !== 0))) cards.some((card) => (card.milestones || []).some((m) => m.when_iso || (m.amount != null && m.amount !== 0)))
) { ) {
s.add(3); s.add(3);
} }
// مرحله ۴: همه اقلام حداقل به یک پروژه اختصاص داده شده
if (items.length > 0 && items.every((it) => (assignment[it.tempId] || []).length > 0)) s.add(4); if (items.length > 0 && items.every((it) => (assignment[it.tempId] || []).length > 0)) s.add(4);
// مرحله ۵: همیشه بازدیدشده محسوب می‌شود
if (highestVisited >= 5) s.add(5); if (highestVisited >= 5) s.add(5);
return s; return s;
}, [selectedTopic, supplierName, items, cards, coverage, assignment, highestVisited]); }, [selectedTopic, supplierName, items, cards, coverage, assignment, highestVisited]);
@@ -200,146 +192,133 @@ export function WizardPage() {
}, [items, assignment, cards, coverage, supplierName, selectedTopic, isOfficial]); }, [items, assignment, cards, coverage, supplierName, selectedTopic, isOfficial]);
// ---------- ذخیره ---------- // ---------- ذخیره ----------
// تابع ذخیره‌ی اصلی — با یا بدون فیلدهای خالی (انتخاب کاربر در دیالوگ)
const doSave = async () => { const doSave = async () => {
// خطای بخش — با دکمه‌ی «رفع خطا» که به همان بخش می‌برد const sectionError = (step: number, title: string, err: unknown) => {
const sectionError = (step: number, title: string, err: unknown) => { const msg = err instanceof Error ? err.message : "خطای ناشناخته";
const msg = err instanceof Error ? err.message : "خطای ناشناخته"; toast.error(`${title}: ${msg}`, {
toast.error(`${title}: ${msg}`, { action: { label: "رفع خطا", onClick: () => goToStep(step) },
action: { label: "رفع خطا", onClick: () => goToStep(step) }, });
}); };
}; const guessStep = (msg: string): number => {
// مشخص کردن بخش خطا از روی متن پیام if (/تأمین|تامین|supplier/i.test(msg)) return 1;
const guessStep = (msg: string): number => { if (/متریال|واحد|قلم|خط آیتم|item|دسته/i.test(msg)) return 2;
if (/تأمین|تامین|supplier/i.test(msg)) return 1; if (/ترم|termsheet|پرداخت|پوشش/i.test(msg)) return 3;
if (/متریال|واحد|قلم|خط آیتم|item|دسته/i.test(msg)) return 2; if (/پروژه|project/i.test(msg)) return 4;
if (/ترم|termsheet|پرداخت|پوشش/i.test(msg)) return 3; return 5;
if (/پروژه|project/i.test(msg)) return 4; };
return 5;
};
// تأمین‌کننده const name = supplierName.trim();
const name = supplierName.trim(); let supplierId = "";
let supplierId = ""; if (name) {
if (name) { const exist = suppliers.find((s) => normalize(s.name) === normalize(name));
const exist = suppliers.find((s) => normalize(s.name) === normalize(name)); if (exist) {
if (exist) { supplierId = exist.id;
supplierId = exist.id; } else {
} else { try {
try { const created = await addSupplier.mutateAsync({
const created = await addSupplier.mutateAsync({ id: nextId("s", suppliers),
id: nextId("s", suppliers), topic: selectedTopic,
topic: selectedTopic, categories: [],
categories: [], name,
name, location: newSup.location.trim(),
location: newSup.location.trim(), phone: newSup.phone.trim(),
phone: newSup.phone.trim(), contact: newSup.contact.trim(),
contact: newSup.contact.trim(), });
}); supplierId = created.id;
supplierId = created.id; } catch (err) {
} catch (err) { sectionError(1, "خطا در بخش تأمین‌کننده", err);
sectionError(1, "خطا در بخش تأمین‌کننده", err); return;
return;
}
} }
} }
}
// اقلام const collected: { li: LineItem; tempId: string }[] = [];
const collected: { li: LineItem; tempId: string }[] = []; for (const it of items) {
for (const it of items) { const cat = it.cat.trim();
const cat = it.cat.trim(); const brand = it.brand.trim();
const brand = it.brand.trim(); const specs = it.specs;
const specs = it.specs; const unit = it.unit.trim();
const unit = it.unit.trim(); const qty = it.qty;
const qty = it.qty; const price = it.price;
const price = it.price; const total = it.total;
const total = it.total; if (!cat && !brand && qty == null && price == null) continue;
if (!cat && !brand && qty == null && price == null) continue;
// جستجوی متریال: اول با نام (مثل بک‌اند ساخته شده) — بعد از رفرش، const wantName = [brand, cat, ...specs].filter(Boolean).join(" ") || "کالای جدید";
// برند/مشخصه در حافظه نیستند ولی نام کامل در دیتابیس ذخیره شده let material = materials.find((m) => normalize(m.name) === normalize(wantName));
const wantName = [brand, cat, ...specs].filter(Boolean).join(" ") || "کالای جدید"; if (!material) {
let material = materials.find( material = materials.find(
(m) => normalize(m.category) === normalize(cat) && normalize(m.name) === normalize(wantName), (m) =>
normalize(m.category) === normalize(cat) &&
normalize(m.brand) === normalize(brand) &&
specsKey(m.specs) === specsKey(specs),
); );
if (!material) { }
// جستجوی قبلی (برند و مشخصه‌ی ساختارمند در همین نشست) let materialId: string;
material = materials.find( if (material) {
(m) => materialId = material.id;
normalize(m.category) === normalize(cat) && } else {
normalize(m.brand) === normalize(brand) && try {
specsKey(m.specs) === specsKey(specs), const created = await addMaterial.mutateAsync({
); id: nextId("m", materials),
name: wantName,
topic: selectedTopic,
category: cat,
brand,
specs,
base_unit: unit,
});
materialId = created.id;
} catch (err) {
sectionError(2, "خطا در بخش اقلام (متریال)", err);
return;
} }
let materialId: string;
if (material) {
materialId = material.id;
} else {
try {
const created = await addMaterial.mutateAsync({
id: nextId("m", materials),
name: wantName,
topic: selectedTopic,
category: cat,
brand,
specs,
base_unit: unit,
});
materialId = created.id; // UUID واقعی از بک‌اند
} catch (err) {
sectionError(2, "خطا در بخش اقلام (متریال)", err);
return;
}
}
collected.push({
li: {
id: "li-" + (collected.length + 1),
material_id: materialId,
quantity: qty,
unit,
unit_price: price,
total_price: total,
termsheet_ids: [],
accepted_ts_id: null,
rejected: false,
arrived_date: null,
},
tempId: it.tempId,
});
} }
const lineItems = collected.map((c) => c.li); collected.push({
li: {
// termsheetها id: "li-" + (collected.length + 1),
const termsheets = cards material_id: materialId,
.map((card, i) => ({ quantity: qty,
id: "new-ts-" + (i + 1), unit,
label: "گزینه " + faNum(i + 1), unit_price: price,
milestones: card.milestones.filter((m) => m.when_iso || m.amount != null), total_price: total,
status: "offered" as const, termsheet_ids: [],
})) accepted_ts_id: null,
.filter((t) => t.milestones.length); rejected: false,
arrived_date: null,
// اتصال اقلام به termsheetها },
collected.forEach(({ li, tempId }) => { tempId: it.tempId,
const ids = cards
.map((card, i) =>
effectiveCovered(cards, coverage, items, card.key).includes(tempId) ? "new-ts-" + (i + 1) : "",
)
.filter(Boolean);
const valid = ids.filter((tid) => termsheets.some((t) => t.id === tid));
li.termsheet_ids = valid.length ? valid : termsheets.length ? [termsheets[0].id] : [];
}); });
}
// پروژه‌ها const lineItems = collected.map((c) => c.li);
const projSet = new Set<string>();
collected.forEach(({ li, tempId }) => { const termsheets = cards
const list = assignment[tempId] || []; .map((card, i) => ({
li.projects = list.map((p) => ({ id: "", name: p })); id: "new-ts-" + (i + 1),
list.forEach((p) => projSet.add(p)); label: "گزینه " + faNum(i + 1),
}); milestones: card.milestones.filter((m) => m.when_iso || m.amount != null),
const projectsList = [...projSet]; status: "offered" as const,
}))
.filter((t) => t.milestones.length);
collected.forEach(({ li, tempId }) => {
const ids = cards
.map((card, i) =>
effectiveCovered(cards, coverage, items, card.key).includes(tempId) ? "new-ts-" + (i + 1) : "",
)
.filter(Boolean);
const valid = ids.filter((tid) => termsheets.some((t) => t.id === tid));
li.termsheet_ids = valid.length ? valid : termsheets.length ? [termsheets[0].id] : [];
});
const projSet = new Set<string>();
collected.forEach(({ li, tempId }) => {
const list = assignment[tempId] || [];
li.projects = list.map((p) => ({ id: "", name: p }));
list.forEach((p) => projSet.add(p));
});
const projectsList = [...projSet];
const quoteIso = quoteDate || new Date().toISOString(); const quoteIso = quoteDate || new Date().toISOString();
const quote: Quote = { const quote: Quote = {
@@ -363,7 +342,6 @@ export function WizardPage() {
try { try {
const created = await createQuote.mutateAsync(quote); const created = await createQuote.mutateAsync(quote);
// آپلود پیش‌فاکتور به MinIO بعد از ساخت پیشنهاد
if (proforma) { if (proforma) {
try { try {
await api.files.upload(created.id, "proforma", proforma); await api.files.upload(created.id, "proforma", proforma);
@@ -381,7 +359,6 @@ export function WizardPage() {
} }
}; };
// ذخیره — اگر بخشی ناقص است، دیالوگ تأیید با لینک فیلدهای خالی باز می‌شود
const save = () => { const save = () => {
const incomplete: { label: string; step: number }[] = []; const incomplete: { label: string; step: number }[] = [];
if (!completedSteps.has(1)) incomplete.push({ label: "تأمین‌کننده", step: 1 }); if (!completedSteps.has(1)) incomplete.push({ label: "تأمین‌کننده", step: 1 });
@@ -477,19 +454,54 @@ export function WizardPage() {
</div> </div>
)} )}
{/* پاورقی ویزارد */} {/* پاورقی ویزارد — با آیکون‌های استاندارد و جهت‌گیری دقیق RTL */}
<div className="mt-4 flex items-center gap-2 rounded-xl border border-line bg-panel p-3 shadow-sm"> <div className="mt-5 flex items-center justify-between rounded-xl border border-line bg-panel p-3.5 shadow-sm">
{currentStep > 1 && ( {/* دکمه مرحله قبلی (فلش رو به راست = بازگشت در فارسی) */}
<Button variant="slate" onClick={() => goToStep(currentStep - 1)}> <div>
‹ قبلی {currentStep > 1 && (
</Button> <button
)} type="button"
<div className="flex-1" /> onClick={() => goToStep(currentStep - 1)}
<Link to="/" className="btn-line-file no-underline"> className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl border border-slate-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-slate-700 dark:text-zinc-200 hover:bg-slate-50 dark:hover:bg-zinc-700 text-sm font-medium transition-colors shadow-xs cursor-pointer"
انصراف >
</Link> <ChevronRight className="h-4 w-4" />
{currentStep < TOTAL_STEPS && <Button onClick={next}>بعدی ›</Button>} <span>قبلی</span>
{currentStep === TOTAL_STEPS && <Button onClick={() => void save()}>ذخیره پیشنهاد</Button>} </button>
)}
</div>
{/* دکمه‌های اقدام (انصراف + بعدی / ذخیره) */}
<div className="flex items-center gap-3">
<Link
to="/"
className="inline-flex items-center justify-center px-4 py-2 rounded-xl text-sm font-medium text-slate-500 hover:text-slate-700 hover:bg-slate-100 dark:text-zinc-400 dark:hover:text-zinc-200 dark:hover:bg-zinc-800 transition-colors no-underline"
>
انصراف
</Link>
{/* دکمه مرحله بعدی (فلش رو به چپ = پیشروی در فارسی) */}
{currentStep < TOTAL_STEPS && (
<button
type="button"
onClick={next}
className="inline-flex items-center gap-1.5 px-6 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-sm font-semibold shadow-sm hover:shadow transition-all duration-150 cursor-pointer"
>
<span>بعدی</span>
<ChevronLeft className="h-4 w-4" />
</button>
)}
{currentStep === TOTAL_STEPS && (
<button
type="button"
onClick={() => void save()}
className="inline-flex items-center gap-1.5 px-6 py-2 rounded-xl bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white text-sm font-semibold shadow-sm hover:shadow transition-all duration-150 cursor-pointer"
>
<span>ذخیره پیشنهاد</span>
<Check className="h-4 w-4" />
</button>
)}
</div>
</div> </div>
{/* دیالوگ ذخیره با فیلدهای خالی */} {/* دیالوگ ذخیره با فیلدهای خالی */}
@@ -541,4 +553,4 @@ export function WizardPage() {
</PageLayout> </PageLayout>
</div> </div>
); );
} }