17 Commits
Author SHA1 Message Date
whqreza 1f16e44874 feat(pm): multi-project PMs (1:N) with project switching, scoping validation and org multi-select
Task 1 — PM 1:N + access-loss handling:
- projects.pm_user_id had no UNIQUE constraint (verified live + in schema); added a lookup index via init_db
- auth: UserContext.project_ids = every project of a PM (oldest first); project_id stays the first for compatibility
- GET /users/me/projects — assigned projects of the caller (drives the switcher + revocation validation)
- login / /users/me / /users list now return the full projects array per user
- /inventory/* and /pm/* scopes accept an explicit ?project_id=… and verify the PM manages it (403 otherwise); without the param they fall back to the first project; admin/manager use it as an optional filter
- POST /users/{id}/project is multi-aware: assigns one project (replacing that project's previous PM) and KEEPS the user's other assignments; null clears all
- PATCH /projects/{id} accepts pm_user_id (admin / procurement manager only)
- frontend: auth store holds projects + the active project (persisted via the existing localStorage store); RequirePmProjects re-validates on load, falls back when the active project was revoked, and shows a dedicated no-projects screen with logout (no redirect loop, no re-auth)
- Panels menu: «مدیریت انبار» gets a ChevronDown accordion listing the PM's projects with «✓ فعلی»; selecting one switches only the active project and reloads the section data
- inventory/pm API calls now send the active project_id explicitly

Task 2 — Organization page:
- new searchable ProjectMultiSelect combobox (chips with ×, live Persian filtering via normalize, assigned projects hidden) replaces the single select for PM users
- add = PATCH project pm_user_id=<user>; × = pm_user_id=null; table and project cards sync immediately; RBAC unchanged (role column admin-only, combobox editable for admin + procurement manager)

Verified locally: 17 scripted checks — multi-project assignment without unique conflicts, /users/me/projects, explicit project_id authorization (foreign project 403 on /inventory, /pm/on-the-way, /pm/receipts), PM blocked from pm_user_id changes, legacy assign endpoint keeps other assignments, revocation removes the project from the list, zero-project PM gets an empty list and a guarded inventory endpoint. All test data cleaned up afterwards.
2026-09-15 19:23:50 +03:30
whqreza 3f4bbee6de fix(files): downloads with Persian filenames no longer 500
Both download endpoints (invoice/proforma) put the stored file_name raw into the Content-Disposition header. HTTP headers are latin-1, so any non-ASCII name (i.e. every Persian filename) raised UnicodeEncodeError before the body was sent -> HTTP 500. Uploads were unaffected.

Content-Disposition is now built by a helper following RFC 6266 / RFC 5987: the UTF-8 name is percent-encoded in filename*=UTF-8''..., with a header-safe ASCII fallback in filename= (quotes and control characters stripped; a name that folds away entirely becomes attachment<ext>).

Verified locally after reproducing the exact production error (same latin-1 positions 22-27): Persian proforma + invoice downloads 500 -> 200 with filename*= carrying the full Persian name and bytes sha256-matching the MinIO object; ASCII filename regression 200; helper edge cases (CRLF/quote injection stripped, empty name -> attachment, pure-Persian png -> attachment.png); no UnicodeEncodeError in the api logs after the fix.
2026-09-15 17:38:47 +03:30
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
42 changed files with 1786 additions and 829 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"
``` ```
+34 -7
View File
@@ -103,15 +103,17 @@ async def _verify_token(token: str) -> dict:
class UserContext: class UserContext:
user: User user: User
role: str | None role: str | None
project_id: object | None # uuid.UUID | None project_id: object | None # uuid.UUID | None — default (first) project of a PM
project_ids: list # every project this PM manages (1:N — empty for non-PMs)
display_name: str display_name: str
async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext: async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
"""Auto-provision the `users` row from token claims; resolve role + PM project. """Auto-provision the `users` row from token claims; resolve role + PM projects.
First login: creates the row with role NULL (an admin assigns it later). First login: creates the row with role NULL (an admin assigns it later).
PMs: resolves their single project via projects.pm_user_id. PMs: resolves EVERY project they manage (projects.pm_user_id = user);
`project_id` stays the first one (oldest) for backward compatibility.
""" """
casdoor_id = str(claims.get("sub") or claims.get("id") or "") casdoor_id = str(claims.get("sub") or claims.get("id") or "")
if not casdoor_id: if not casdoor_id:
@@ -134,13 +136,38 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
db.add(user) db.add(user)
await db.flush() await db.flush()
project_id = None project_ids: list = []
if user.role == "pm": if user.role == "pm":
project = await db.scalar(select(Project).where(Project.pm_user_id == user.id)) projects = (
project_id = project.id if project else None await db.execute(
select(Project)
.where(Project.pm_user_id == user.id)
.order_by(Project.created_at, Project.id)
)
).scalars().all()
project_ids = [p.id for p in projects]
project_id = project_ids[0] if project_ids else None
display_name = f"{user.first_name} {user.last_name}".strip() or user.casdoor_id display_name = f"{user.first_name} {user.last_name}".strip() or user.casdoor_id
return UserContext(user=user, role=user.role, project_id=project_id, display_name=display_name) return UserContext(
user=user,
role=user.role,
project_id=project_id,
project_ids=project_ids,
display_name=display_name,
)
async def pm_project_ids(db: AsyncSession, user: User) -> list:
"""Every project id managed by the given PM user (oldest first)."""
rows = (
await db.execute(
select(Project.id)
.where(Project.pm_user_id == user.id)
.order_by(Project.created_at, Project.id)
)
).scalars().all()
return list(rows)
async def get_optional_user(request: Request, db: AsyncSession) -> UserContext | None: async def get_optional_user(request: Request, db: AsyncSession) -> UserContext | None:
+5
View File
@@ -302,6 +302,11 @@ _V26_DDL = [
""" """
CREATE INDEX IF NOT EXISTS idx_suppliers_category_id ON suppliers(category_id) CREATE INDEX IF NOT EXISTS idx_suppliers_category_id ON suppliers(category_id)
""", """,
# PM → projects is 1:N (a PM may manage several projects). No UNIQUE on
# pm_user_id — just a lookup index for "projects of this PM" queries.
"""
CREATE INDEX IF NOT EXISTS idx_projects_pm_user_id ON projects(pm_user_id)
""",
# supplier_materials (v2.5) + its triggers (functions are CREATE OR REPLACE below). # supplier_materials (v2.5) + its triggers (functions are CREATE OR REPLACE below).
""" """
CREATE TABLE IF NOT EXISTS supplier_materials ( CREATE TABLE IF NOT EXISTS supplier_materials (
+3 -4
View File
@@ -20,7 +20,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import _verify_token, context_from_claims from app.auth import _verify_token, context_from_claims
from app.config import settings from app.config import settings
from app.db.engine import get_db from app.db.engine import get_db
from app.routers.users import _user_project, user_to_dict from app.routers.users import _user_projects, user_to_dict
from app.utils.errors import ServiceUnavailableError, UnauthorizedError from app.utils.errors import ServiceUnavailableError, UnauthorizedError
logger = logging.getLogger("procurement.auth") logger = logging.getLogger("procurement.auth")
@@ -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")
@@ -75,12 +74,12 @@ async def login(payload: LoginRequest, db: AsyncSession = Depends(get_db)):
# the app-side user (auto-provision on first login). # the app-side user (auto-provision on first login).
claims = await _verify_token(access_token) claims = await _verify_token(access_token)
ctx = await context_from_claims(claims, db) ctx = await context_from_claims(claims, db)
project = await _user_project(db, ctx.user) if ctx.role == "pm" else None projects = await _user_projects(db, ctx.user)
return { return {
"data": { "data": {
"access_token": access_token, "access_token": access_token,
"token_type": "bearer", "token_type": "bearer",
"expires_in": body.get("expires_in"), "expires_in": body.get("expires_in"),
"user": user_to_dict(ctx.user, project), "user": user_to_dict(ctx.user, projects),
} }
} }
+24 -2
View File
@@ -13,7 +13,9 @@ The same four endpoints exist under /proforma. Bytes live in MinIO
Both invoice and proforma uploads are allowed in every quote status (the Both invoice and proforma uploads are allowed in every quote status (the
user can attach the invoice from the edit page at any time). user can attach the invoice from the edit page at any time).
""" """
import os
import uuid import uuid
from urllib.parse import quote
from fastapi import APIRouter, Depends, File, Response, UploadFile from fastapi import APIRouter, Depends, File, Response, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -34,6 +36,26 @@ ALLOWED_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".pdf"}
MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024 MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024
def _content_disposition(file_name: str) -> str:
"""Header-safe Content-Disposition for non-ASCII (e.g. Persian) filenames.
HTTP headers are encoded as latin-1, so a Persian name raises
UnicodeEncodeError and kills the response. Per RFC 6266 / RFC 5987 the
UTF-8 name goes percent-encoded in ``filename*``, with an ASCII fallback
in ``filename`` for old clients.
"""
raw = file_name.encode("ascii", "ignore").decode("ascii")
# keep the fallback header-safe: no quotes, no control characters
fallback = "".join(ch for ch in raw if ch >= " " and ch != '"').strip()
ext = os.path.splitext(file_name)[1]
stem = fallback[: -len(ext)].strip() if ext and fallback.lower().endswith(ext.lower()) else fallback
if not stem or not any(ch.isalnum() for ch in stem):
# nothing meaningful survived ASCII-folding (e.g. a pure-Persian
# name): use a generic attachment name that keeps the extension
fallback = f"attachment{ext}" if ext else "attachment"
return f"attachment; filename=\"{fallback}\"; filename*=UTF-8''{quote(file_name)}"
async def _quote_or_404(db: AsyncSession, quote_id: uuid.UUID) -> Quote: async def _quote_or_404(db: AsyncSession, quote_id: uuid.UUID) -> Quote:
quote = await db.get(Quote, quote_id) quote = await db.get(Quote, quote_id)
if quote is None: if quote is None:
@@ -92,7 +114,7 @@ async def download_invoice(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: As
content=content, content=content,
media_type=attachment.mime_type, media_type=attachment.mime_type,
headers={ headers={
"Content-Disposition": f'attachment; filename="{attachment.file_name}"', "Content-Disposition": _content_disposition(attachment.file_name),
"X-File-Id": str(attachment.id), "X-File-Id": str(attachment.id),
}, },
) )
@@ -137,7 +159,7 @@ async def download_proforma(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: A
content=content, content=content,
media_type=attachment.mime_type, media_type=attachment.mime_type,
headers={ headers={
"Content-Disposition": f'attachment; filename="{attachment.file_name}"', "Content-Disposition": _content_disposition(attachment.file_name),
"X-File-Id": str(attachment.id), "X-File-Id": str(attachment.id),
}, },
) )
+29 -15
View File
@@ -59,10 +59,18 @@ class Scope:
actor: str | None actor: str | None
async def inventory_scope(request: Request, db: AsyncSession = Depends(get_db)) -> Scope: async def inventory_scope(
"""Resolve the caller role/project — Casdoor JWT first, dev headers as fallback.""" request: Request,
# Primary path: Casdoor JWT. Role from users.role; PM project from project_id: uuid.UUID | None = Query(default=None, description="پروژه فعال (PM: داده‌های همین پروژه)"),
# projects.pm_user_id (single PM per project). db: AsyncSession = Depends(get_db),
) -> Scope:
"""Resolve the caller role/project — Casdoor JWT first, dev headers as fallback.
The active project is explicit via `?project_id=…`. For a PM the backend
verifies they manage that project (projects.pm_user_id, 1:N); without the
param it falls back to their first assigned project. Admin / procurement
manager may pass it as a filter (no param = all projects).
"""
if request.headers.get("authorization"): if request.headers.get("authorization"):
user: UserContext = await get_optional_user(request, db) user: UserContext = await get_optional_user(request, db)
if user is not None: if user is not None:
@@ -70,16 +78,21 @@ async def inventory_scope(request: Request, db: AsyncSession = Depends(get_db))
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است") raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
# v2.6: مدیر تدارکات = ادمین دفتر — دسترسی فقط‌خواندنی به پنل انبار. # v2.6: مدیر تدارکات = ادمین دفتر — دسترسی فقط‌خواندنی به پنل انبار.
if user.role == "procurement_manager": if user.role == "procurement_manager":
return Scope(role="admin", project_id=None, actor=user.display_name) return Scope(role="admin", project_id=project_id, actor=user.display_name)
if user.role not in ("pm", "admin"): if user.role not in ("pm", "admin"):
raise ValidationError("نقش نامعتبر است — فقط pm یا admin برای موجودی", field="role") raise ValidationError("نقش نامعتبر است — فقط pm یا admin برای موجودی", field="role")
if user.role == "pm": if user.role == "pm":
if user.project_id is None: allowed = list(user.project_ids or [])
if project_id is not None:
if project_id not in allowed:
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
return Scope(role="pm", project_id=project_id, actor=user.display_name)
if not allowed:
raise ValidationError( raise ValidationError(
"کاربر PM به هیچ پروژه‌ای متصل نیست (projects.pm_user_id)", field="project_id" "کاربر PM به هیچ پروژه‌ای متصل نیست (projects.pm_user_id)", field="project_id"
) )
return Scope(role="pm", project_id=user.project_id, actor=user.display_name) return Scope(role="pm", project_id=allowed[0], actor=user.display_name)
return Scope(role="admin", project_id=None, actor=user.display_name) return Scope(role="admin", project_id=project_id, actor=user.display_name)
# Dev fallback: header-based identity (fork's placeholder mode) — only # Dev fallback: header-based identity (fork's placeholder mode) — only
# when an explicit X-User-Role header is sent. No auth info at all → 401. # when an explicit X-User-Role header is sent. No auth info at all → 401.
@@ -90,13 +103,14 @@ async def inventory_scope(request: Request, db: AsyncSession = Depends(get_db))
role = ROLE_ADMIN # مدیر تدارکات = ادمین (فقط‌خواندنی) role = ROLE_ADMIN # مدیر تدارکات = ادمین (فقط‌خواندنی)
if role not in (ROLE_PM, ROLE_ADMIN): if role not in (ROLE_PM, ROLE_ADMIN):
raise ValidationError("نقش نامعتبر است — فقط pm یا admin", field="X-User-Role") raise ValidationError("نقش نامعتبر است — فقط pm یا admin", field="X-User-Role")
raw_project = request.headers.get("x-project-id") or settings.INVENTORY_DEFAULT_PROJECT_ID # The explicit query param wins; otherwise the dev header carries it.
project_id = None if project_id is None:
if raw_project: raw_project = request.headers.get("x-project-id") or settings.INVENTORY_DEFAULT_PROJECT_ID
try: if raw_project:
project_id = uuid.UUID(str(raw_project)) try:
except ValueError: project_id = uuid.UUID(str(raw_project))
raise ValidationError("شناسه پروژه نامعتبر است", field="X-Project-Id") except ValueError:
raise ValidationError("شناسه پروژه نامعتبر است", field="X-Project-Id")
if role == ROLE_PM and project_id is None: if role == ROLE_PM and project_id is None:
raise ValidationError( raise ValidationError(
"برای نقش PM هدر X-Project-Id (پروژه مسئول) الزامی است", field="X-Project-Id" "برای نقش PM هدر X-Project-Id (پروژه مسئول) الزامی است", field="X-Project-Id"
+30 -17
View File
@@ -29,7 +29,7 @@ from contextlib import nullcontext
from dataclasses import dataclass from dataclasses import dataclass
from decimal import Decimal from decimal import Decimal
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -66,14 +66,20 @@ NULL_BRAND_SENTINEL = "00000000-0000-0000-0000-000000000000"
class ArrivalScope: class ArrivalScope:
role: str # "pm" | "admin" (admin = admin or procurement_manager) role: str # "pm" | "admin" (admin = admin or procurement_manager)
user_id: uuid.UUID | None user_id: uuid.UUID | None
project_id: uuid.UUID | None # PM's single project; None for admin project_id: uuid.UUID | None # PM's active project; admin: optional filter
display_name: str display_name: str
async def arrivals_scope(request: Request, db: AsyncSession = Depends(get_db)) -> ArrivalScope: async def arrivals_scope(
request: Request,
project_id: uuid.UUID | None = Query(default=None, description="پروژه فعال (PM: الزامی برای داده‌های پروژه)"),
db: AsyncSession = Depends(get_db),
) -> ArrivalScope:
"""Resolve the arrivals caller — Casdoor JWT first, dev headers as fallback. """Resolve the arrivals caller — Casdoor JWT first, dev headers as fallback.
PM → scoped to his own project. Admin / procurement manager → all projects. PM → scoped to the requested project, which must be one they manage
(projects.pm_user_id, 1:N); without the param, their first project.
Admin / procurement manager → all projects (param = optional filter).
""" """
if request.headers.get("authorization"): if request.headers.get("authorization"):
ctx: UserContext | None = await get_optional_user(request, db) ctx: UserContext | None = await get_optional_user(request, db)
@@ -81,11 +87,16 @@ async def arrivals_scope(request: Request, db: AsyncSession = Depends(get_db)) -
if ctx.role is None: if ctx.role is None:
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است") raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
if ctx.role == "pm": if ctx.role == "pm":
if ctx.project_id is None: allowed = list(ctx.project_ids or [])
if project_id is not None:
if project_id not in allowed:
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name)
if not allowed:
raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)") raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)")
return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=ctx.project_id, display_name=ctx.display_name) return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=allowed[0], display_name=ctx.display_name)
if ctx.role in ("admin", "procurement_manager"): if ctx.role in ("admin", "procurement_manager"):
return ArrivalScope(role="admin", user_id=ctx.user.id, project_id=None, display_name=ctx.display_name) return ArrivalScope(role="admin", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name)
raise ForbiddenError("این بخش فقط برای مدیر پروژه یا مدیر تدارکات است") raise ForbiddenError("این بخش فقط برای مدیر پروژه یا مدیر تدارکات است")
# Dev fallback: X-User-Role: pm | admin (procurement_manager maps to admin) # Dev fallback: X-User-Role: pm | admin (procurement_manager maps to admin)
if not request.headers.get("x-user-role"): if not request.headers.get("x-user-role"):
@@ -95,8 +106,8 @@ async def arrivals_scope(request: Request, db: AsyncSession = Depends(get_db)) -
role = "admin" role = "admin"
if role not in ("pm", "admin"): if role not in ("pm", "admin"):
raise ForbiddenError("این بخش فقط برای مدیر پروژه یا مدیر تدارکات است — هدر X-User-Role را بفرستید") raise ForbiddenError("این بخش فقط برای مدیر پروژه یا مدیر تدارکات است — هدر X-User-Role را بفرستید")
project_id = None # The explicit query param wins; otherwise the dev header carries it.
if role == "pm": if project_id is None and role == "pm":
raw_project = request.headers.get("x-project-id") raw_project = request.headers.get("x-project-id")
if not raw_project: if not raw_project:
raise ForbiddenError("هدر X-Project-Id (پروژه مسئول) الزامی است") raise ForbiddenError("هدر X-Project-Id (پروژه مسئول) الزامی است")
@@ -172,9 +183,10 @@ async def pm_on_the_way(
.outerjoin(Project, Project.id == ItemDecision.project_id) .outerjoin(Project, Project.id == ItemDecision.project_id)
.where(ItemDecision.status == "chosen") .where(ItemDecision.status == "chosen")
) )
if ctx.role == "pm": if ctx.role == "pm" and ctx.project_id is None:
if ctx.project_id is None: raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)")
raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)") if ctx.project_id is not None:
# PM: active project; admin/manager: optional project filter
stmt = stmt.where(ItemDecision.project_id == ctx.project_id) stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
decisions = (await db.execute(stmt.order_by(ItemDecision.decided_at.desc().nulls_last()))).all() decisions = (await db.execute(stmt.order_by(ItemDecision.decided_at.desc().nulls_last()))).all()
@@ -238,8 +250,8 @@ async def pm_confirm_receipt(
decision = await db.get(ItemDecision, payload.item_decision_id) decision = await db.get(ItemDecision, payload.item_decision_id)
if decision is None or decision.status != "chosen": if decision is None or decision.status != "chosen":
raise NotFoundError("تصمیم خرید یافت نشد", field="item_decision_id") raise NotFoundError("تصمیم خرید یافت نشد", field="item_decision_id")
if ctx.role == "pm" and decision.project_id != ctx.project_id: if ctx.project_id is not None and decision.project_id != ctx.project_id:
raise ForbiddenError("این قلم متعلق به پروژه شما نیست") raise ForbiddenError("این قلم متعلق به پروژه فعال نیست")
li = await db.get(LineItem, decision.line_item_id) li = await db.get(LineItem, decision.line_item_id)
quote = await db.get(Quote, li.quote_id) quote = await db.get(Quote, li.quote_id)
@@ -436,9 +448,10 @@ async def pm_receipts(
.join(Quote, Quote.id == LineItem.quote_id) .join(Quote, Quote.id == LineItem.quote_id)
.join(Project, Project.id == ItemDecision.project_id) .join(Project, Project.id == ItemDecision.project_id)
) )
if ctx.role == "pm": if ctx.role == "pm" and ctx.project_id is None:
if ctx.project_id is None: raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)")
raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)") if ctx.project_id is not None:
# PM: active project; admin/manager: optional project filter
stmt = stmt.where(ItemDecision.project_id == ctx.project_id) stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
rows = (await db.execute(stmt.order_by(ItemReceipt.received_at.desc()))).all() rows = (await db.execute(stmt.order_by(ItemReceipt.received_at.desc()))).all()
receipts = [r.ItemReceipt for r in rows] receipts = [r.ItemReceipt for r in rows]
+30 -3
View File
@@ -1,19 +1,25 @@
"""Projects CRUD + per-project stats.""" """Projects CRUD + per-project stats."""
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import UserContext, get_optional_user
from app.db.engine import get_db from app.db.engine import get_db
from app.models.procurement import Project, Quote, User from app.models.procurement import Project, Quote, User
from app.schemas.procurement import ProjectCreate, ProjectUpdate from app.schemas.procurement import ProjectCreate, ProjectUpdate
from app.services.quote_service import project_stats from app.services.quote_service import project_stats
from app.utils.errors import ConflictError, NotFoundError from app.utils.errors import ConflictError, ForbiddenError, NotFoundError, ValidationError
from app.utils.pagination import paginate, sort_query from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_date, to_iso from app.utils.serialization import to_date, to_iso
router = APIRouter(prefix="/projects", tags=["projects"]) router = APIRouter(prefix="/projects", tags=["projects"])
async def _optional_ctx(request: Request, db: AsyncSession = Depends(get_db)) -> UserContext | None:
"""JWT caller when present — used for RBAC on PM assignment (PATCH)."""
return await get_optional_user(request, db)
async def _pm_map(db: AsyncSession, projects: list[Project]) -> dict: async def _pm_map(db: AsyncSession, projects: list[Project]) -> dict:
"""project_id -> pm brief (v2.6: the single PM of the project).""" """project_id -> pm brief (v2.6: the single PM of the project)."""
pm_ids = {p.pm_user_id for p in projects if p.pm_user_id} pm_ids = {p.pm_user_id for p in projects if p.pm_user_id}
@@ -91,7 +97,13 @@ async def get_project(project_id, db: AsyncSession = Depends(get_db)):
@router.patch("/{project_id}") @router.patch("/{project_id}")
async def update_project(project_id, payload: ProjectUpdate, db: AsyncSession = Depends(get_db)): async def update_project(
project_id,
payload: ProjectUpdate,
request: Request,
ctx: UserContext | None = Depends(_optional_ctx),
db: AsyncSession = Depends(get_db),
):
project = await db.get(Project, project_id) project = await db.get(Project, project_id)
if project is None: if project is None:
raise NotFoundError("پروژه یافت نشد") raise NotFoundError("پروژه یافت نشد")
@@ -101,6 +113,21 @@ async def update_project(project_id, payload: ProjectUpdate, db: AsyncSession =
project.description = payload.description project.description = payload.description
if payload.start_date is not None: if payload.start_date is not None:
project.start_date = payload.start_date project.start_date = payload.start_date
# PM assignment (1:N): one PM per project, a PM may own many projects.
# Admin / procurement manager only (same rule as the Organization panel).
if "pm_user_id" in payload.model_fields_set:
role = ctx.role if ctx else (request.headers.get("x-user-role") or "").strip().lower()
if role not in ("admin", "procurement_manager"):
raise ForbiddenError("تعیین مدیر پروژه فقط برای ادمین / مدیر تدارکات مجاز است")
if payload.pm_user_id is None:
project.pm_user_id = None
else:
pm_user = await db.get(User, payload.pm_user_id)
if pm_user is None:
raise NotFoundError("کاربر یافت نشد", field="pm_user_id")
if pm_user.role != "pm":
raise ValidationError("ابتدا نقش این کاربر را «مدیر پروژه» کنید", field="pm_user_id")
project.pm_user_id = pm_user.id
await db.flush() await db.flush()
return {"data": await project_to_dict(db, project)} return {"data": await project_to_dict(db, project)}
+78 -30
View File
@@ -1,12 +1,14 @@
"""Users — profile + role assignment + organization management (v2.6, Casdoor-backed). """Users — profile + role assignment + organization management (v2.6, Casdoor-backed).
Organization panel endpoints (admin / procurement manager — same person): Organization panel endpoints (admin / procurement manager — same person):
- GET /users — list every user with role + assigned project. - GET /users — list every user with role + assigned projects.
- POST /users — define a new user (manual identity, casdoor_id optional). - POST /users — define a new user (manual identity, casdoor_id optional).
- PATCH /users/{id} — set/clear role; clearing (or switching away from - PATCH /users/{id} — set/clear role; clearing (or switching away from
«pm») also unassigns the user from their project. «pm») also unassigns the user from every project.
- POST /users/{id}/project — assign/unassign the user's single project - POST /users/{id}/project — assign/unassign ONE project (multi-project:
(project_id=None unassigns; one PM per project). other assignments are kept; project_id=None
clears all; one PM per project).
- GET /users/me/projects — every project assigned to the caller (PM).
Role management is open to BOTH «admin» and «procurement_manager» (they are Role management is open to BOTH «admin» and «procurement_manager» (they are
the same person in this system), unlike the plain require_role("admin"). the same person in this system), unlike the plain require_role("admin").
@@ -41,7 +43,11 @@ USER_ROLE_LABELS = {
} }
def user_to_dict(user: User, project: Project | None = None) -> dict: def user_to_dict(user: User, projects: list[Project] | None = None) -> dict:
"""Serialize a user. `projects` = every project the PM manages (1:N);
project_id/project_name stay as the first (oldest) one for compatibility."""
projects = projects or []
first = projects[0] if projects else None
return { return {
"id": str(user.id), "id": str(user.id),
"casdoor_id": user.casdoor_id, "casdoor_id": user.casdoor_id,
@@ -50,8 +56,9 @@ def user_to_dict(user: User, project: Project | None = None) -> dict:
"phone": user.phone, "phone": user.phone,
"role": user.role, "role": user.role,
"role_label": USER_ROLE_LABELS.get(user.role) if user.role else None, "role_label": USER_ROLE_LABELS.get(user.role) if user.role else None,
"project_id": str(project.id) if project else None, "project_id": str(first.id) if first else None,
"project_name": project.name if project else None, "project_name": first.name if first else None,
"projects": [{"id": str(p.id), "name": p.name} for p in projects],
"created_at": to_iso(user.created_at), "created_at": to_iso(user.created_at),
} }
@@ -63,14 +70,28 @@ async def _org_manager(ctx: UserContext = Depends(get_current_user)) -> UserCont
return ctx return ctx
async def _user_project(db: AsyncSession, user: User) -> Project | None: async def _user_projects(db: AsyncSession, user: User) -> list[Project]:
"""Every project the user manages as PM (oldest first; [] for non-PMs)."""
if user.role != "pm": if user.role != "pm":
return None return []
return await db.scalar(select(Project).where(Project.pm_user_id == user.id)) rows = (
await db.execute(
select(Project)
.where(Project.pm_user_id == user.id)
.order_by(Project.created_at, Project.id)
)
).scalars().all()
return list(rows)
async def _user_project(db: AsyncSession, user: User) -> Project | None:
"""First (oldest) project of a PM — legacy single-project view."""
projects = await _user_projects(db, user)
return projects[0] if projects else None
async def _unassign_projects(db: AsyncSession, user_id) -> None: async def _unassign_projects(db: AsyncSession, user_id) -> None:
"""Clear the user's pm assignment on every project (one project per PM).""" """Clear the user's pm assignment on every project (role revocation)."""
await db.execute( await db.execute(
update(Project).where(Project.pm_user_id == user_id).values(pm_user_id=None) update(Project).where(Project.pm_user_id == user_id).values(pm_user_id=None)
) )
@@ -104,8 +125,19 @@ async def _casdoor_users() -> list[dict]:
@router.get("/me") @router.get("/me")
async def me(ctx: UserContext = Depends(get_current_user), db: AsyncSession = Depends(get_db)): async def me(ctx: UserContext = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
project = await _user_project(db, ctx.user) if ctx.role == "pm" else None projects = await _user_projects(db, ctx.user)
return {"data": user_to_dict(ctx.user, project)} return {"data": user_to_dict(ctx.user, projects)}
@router.get("/me/projects")
async def my_projects(ctx: UserContext = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
"""Every project assigned to the caller (empty list for non-PM roles).
Drives the PM project switcher: the frontend validates its persisted
active project against this list and falls back gracefully when a
project is revoked."""
projects = await _user_projects(db, ctx.user)
return {"data": [{"id": str(p.id), "name": p.name} for p in projects]}
@router.get("") @router.get("")
@@ -113,15 +145,22 @@ async def list_users(
_: UserContext = Depends(_org_manager), _: UserContext = Depends(_org_manager),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Every user (with role + assigned project) — Organization panel.""" """Every user (with role + assigned projects) — Organization panel."""
users = (await db.execute(select(User).order_by(User.created_at))).scalars().all() users = (await db.execute(select(User).order_by(User.created_at))).scalars().all()
pm_ids = {u.id for u in users if u.role == "pm"} pm_ids = {u.id for u in users if u.role == "pm"}
projects: dict = {} by_user: dict = {}
if pm_ids: if pm_ids:
projs = (await db.execute(select(Project).where(Project.pm_user_id.in_(pm_ids)))).scalars().all() projs = (
projects = {p.pm_user_id: p for p in projs} await db.execute(
select(Project)
.where(Project.pm_user_id.in_(pm_ids))
.order_by(Project.created_at, Project.id)
)
).scalars().all()
for p in projs:
by_user.setdefault(p.pm_user_id, []).append(p)
return { return {
"data": [user_to_dict(u, projects.get(u.id)) for u in users], "data": [user_to_dict(u, by_user.get(u.id, [])) for u in users],
"meta": {"total": len(users)}, "meta": {"total": len(users)},
} }
@@ -166,8 +205,8 @@ async def update_user_role(
await _unassign_projects(db, user.id) await _unassign_projects(db, user.id)
user.role = payload.role user.role = payload.role
await db.flush() await db.flush()
project = await _user_project(db, user) projects = await _user_projects(db, user)
return {"data": user_to_dict(user, project)} return {"data": user_to_dict(user, projects)}
@router.post("/sync") @router.post("/sync")
@@ -204,12 +243,19 @@ async def sync_casdoor_users(
users = (await db.execute(select(User).order_by(User.created_at))).scalars().all() users = (await db.execute(select(User).order_by(User.created_at))).scalars().all()
pm_ids = {u.id for u in users if u.role == "pm"} pm_ids = {u.id for u in users if u.role == "pm"}
projects: dict = {} by_user: dict = {}
if pm_ids: if pm_ids:
projs = (await db.execute(select(Project).where(Project.pm_user_id.in_(pm_ids)))).scalars().all() projs = (
projects = {p.pm_user_id: p for p in projs} await db.execute(
select(Project)
.where(Project.pm_user_id.in_(pm_ids))
.order_by(Project.created_at, Project.id)
)
).scalars().all()
for p in projs:
by_user.setdefault(p.pm_user_id, []).append(p)
return { return {
"data": [user_to_dict(u, projects.get(u.id)) for u in users], "data": [user_to_dict(u, by_user.get(u.id, [])) for u in users],
"meta": {"total": len(users), "created": created}, "meta": {"total": len(users), "created": created},
} }
@@ -221,11 +267,13 @@ async def assign_user_project(
_: UserContext = Depends(_org_manager), _: UserContext = Depends(_org_manager),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Assign the user as PM of one project (project_id=None unassigns). """Assign the user as PM of ONE project (multi-project aware).
Rules: the user must hold the «pm» role; a PM manages ONE project at a - project_id given: that project gets this PM (a previous PM there is
time (assignment on other projects is cleared); assigning a new PM to a replaced); the user's OTHER project assignments are kept.
project replaces the previous PM there. - project_id=None: clears ALL of the user's project assignments.
Rules: the user must hold the «pm» role; a project has at most one PM.
""" """
user = await db.get(User, user_id) user = await db.get(User, user_id)
if user is None: if user is None:
@@ -233,8 +281,8 @@ async def assign_user_project(
if user.role != "pm": if user.role != "pm":
raise ValidationError("ابتدا نقش این کاربر را «مدیر پروژه» کنید", field="role") raise ValidationError("ابتدا نقش این کاربر را «مدیر پروژه» کنید", field="role")
await _unassign_projects(db, user.id)
if payload.project_id is None: if payload.project_id is None:
await _unassign_projects(db, user.id)
await db.flush() await db.flush()
return {"data": user_to_dict(user)} return {"data": user_to_dict(user)}
@@ -243,4 +291,4 @@ async def assign_user_project(
raise NotFoundError("پروژه یافت نشد", field="project_id") raise NotFoundError("پروژه یافت نشد", field="project_id")
project.pm_user_id = user.id project.pm_user_id = user.id
await db.flush() await db.flush()
return {"data": user_to_dict(user, project)} return {"data": user_to_dict(user, await _user_projects(db, user))}
+8 -1
View File
@@ -52,6 +52,9 @@ class ProjectUpdate(APIModel):
name: str | None = Field(default=None, min_length=1) name: str | None = Field(default=None, min_length=1)
description: str | None = None description: str | None = None
start_date: date | None = None start_date: date | None = None
# PM assignment (1:N — one PM per project, a PM may own many projects).
# Admin / procurement manager only; None unassigns this project's PM.
pm_user_id: UUID | None = None
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -210,7 +213,11 @@ class UserRoleUpdate(APIModel):
class UserProjectUpdate(APIModel): class UserProjectUpdate(APIModel):
"""Assign/unassign the user's single project (project_id=None unassigns).""" """Assign the user to one project (multi-project aware).
project_id given → that project gets this PM (previous PM replaced,
the user's other projects kept); None → clears every assignment.
"""
project_id: UUID | None = None project_id: UUID | None = None
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
+19 -8
View File
@@ -1,8 +1,8 @@
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"; import { ErrorBoundary } from "./components/ErrorBoundary";
import { RequirePmProjects } from "./components/RequirePmProjects";
// هر صفحه با React.lazy لود می‌شود (code splitting) // هر صفحه با React.lazy لود می‌شود (code splitting)
const DashboardPage = lazy(() => const DashboardPage = lazy(() =>
@@ -142,7 +142,9 @@ export default function App() {
path="/inventory" path="/inventory"
element={ element={
<RequireAuth> <RequireAuth>
<InventoryPage /> <RequirePmProjects>
<InventoryPage />
</RequirePmProjects>
</RequireAuth> </RequireAuth>
} }
/> />
@@ -150,7 +152,9 @@ export default function App() {
path="/inventory/arrivals" path="/inventory/arrivals"
element={ element={
<RequireAuth> <RequireAuth>
<ArrivalsPage /> <RequirePmProjects>
<ArrivalsPage />
</RequirePmProjects>
</RequireAuth> </RequireAuth>
} }
/> />
@@ -158,7 +162,9 @@ export default function App() {
path="/inventory/change" path="/inventory/change"
element={ element={
<RequireAuth> <RequireAuth>
<ChangeInventoryPage /> <RequirePmProjects>
<ChangeInventoryPage />
</RequirePmProjects>
</RequireAuth> </RequireAuth>
} }
/> />
@@ -166,7 +172,9 @@ export default function App() {
path="/inventory/daily" path="/inventory/daily"
element={ element={
<RequireAuth> <RequireAuth>
<DailyControlPage /> <RequirePmProjects>
<DailyControlPage />
</RequirePmProjects>
</RequireAuth> </RequireAuth>
} }
/> />
@@ -174,7 +182,9 @@ export default function App() {
path="/inventory/warehouses" path="/inventory/warehouses"
element={ element={
<RequireAuth> <RequireAuth>
<WarehousesPage /> <RequirePmProjects>
<WarehousesPage />
</RequirePmProjects>
</RequireAuth> </RequireAuth>
} }
/> />
@@ -182,7 +192,9 @@ export default function App() {
path="/inventory/org" path="/inventory/org"
element={ element={
<RequireAuth> <RequireAuth>
<OrganizationPage /> <RequirePmProjects>
<OrganizationPage />
</RequirePmProjects>
</RequireAuth> </RequireAuth>
} }
/> />
@@ -197,7 +209,6 @@ export default function App() {
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</Suspense> </Suspense>
<MobileTabBar />
</ErrorBoundary> </ErrorBoundary>
); );
} }
+23 -3
View File
@@ -1,9 +1,14 @@
import { PanelMenu } from "./PanelMenu"; import { useLocation } from "react-router-dom";
import { PanelMenu, type MenuItem } from "./PanelMenu";
import { useLogout } from "../pages/Inventory/panelMenu"; import { useLogout } from "../pages/Inventory/panelMenu";
import { useAuthStore } from "../store/auth";
// منوی همبرگری مشترک بین دو ماژول (پنل انبار / دفتر تدارکات) — // منوی همبرگری مشترک بین دو ماژول (پنل انبار / دفتر تدارکات) —
// دقیقاً مثل منوی دفتر: عنوان «پنل‌ها»، ماژول فعلی با استایل فعال (پر) // دقیقاً مثل منوی دفتر: عنوان «پنل‌ها»، ماژول فعلی با استایل فعال (پر)
// + «✓ فعلی»، ماژول دیگر به‌عنوان سوییچر، و در پایان جداکننده + خروج. // + «✓ فعلی»، ماژول دیگر به‌عنوان سوییچر، و در پایان جداکننده + خروج.
// مدیر پروژه (1:N): زیر «مدیریت انبار» یک آکاردئون باز می‌شود که همه‌ی
// پروژه‌های تخصیص‌یافته را نشان می‌دهد؛ پروژه‌ی فعال با «✓ فعلی» مشخص است و
// انتخاب هر پروژه فقط «پروژه‌ی فعال» را عوض می‌کند (نشست/توکن دست‌نخورده).
export function AppSwitcherMenu({ export function AppSwitcherMenu({
activeModule, activeModule,
canSwitchToProcurement = true, canSwitchToProcurement = true,
@@ -12,13 +17,28 @@ export function AppSwitcherMenu({
canSwitchToProcurement?: boolean; // مدیر پروژه به دفتر تدارکات دسترسی ندارد canSwitchToProcurement?: boolean; // مدیر پروژه به دفتر تدارکات دسترسی ندارد
}) { }) {
const logout = useLogout(); const logout = useLogout();
const items = []; const { pathname } = useLocation();
const user = useAuthStore((s) => s.user);
const setActiveProject = useAuthStore((s) => s.setActiveProject);
const items: MenuItem[] = [];
const isPm = user?.role === "pm";
const pmProjects = isPm ? user?.projects ?? [] : [];
const projectChildren: MenuItem[] | undefined =
activeModule === "warehouse" && isPm
? pmProjects.map((p) => ({
label: p.name,
to: pathname, // همان بخش فعلی؛ فقط پروژه عوض می‌شود
active: p.id === user?.project_id,
onClick: () => setActiveProject(p.id),
}))
: undefined;
if (activeModule === "procurement") { if (activeModule === "procurement") {
items.push({ label: "مدیریت انبار", to: "/inventory" }); items.push({ label: "مدیریت انبار", to: "/inventory" });
items.push({ label: "مدیریت تدارکات", to: "/", active: true }); items.push({ label: "مدیریت تدارکات", to: "/", active: true });
} else { } else {
items.push({ label: "مدیریت انبار", to: "/inventory", active: true }); items.push({ label: "مدیریت انبار", to: "/inventory", active: true, children: projectChildren });
if (canSwitchToProcurement) { if (canSwitchToProcurement) {
items.push({ label: "مدیریت تدارکات", to: "/" }); items.push({ label: "مدیریت تدارکات", to: "/" });
} }
+75 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Menu, X } from "lucide-react"; import { ChevronDown, Menu, X } from "lucide-react";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
export interface MenuItem { export interface MenuItem {
@@ -9,11 +9,14 @@ export interface MenuItem {
active?: boolean; active?: boolean;
divider?: boolean; divider?: boolean;
onClick?: () => void; onClick?: () => void;
// آیتم بازشو (آکاردئونی) — مثلاً فهرست پروژه‌های مدیر پروژه زیر «مدیریت انبار»
children?: MenuItem[];
} }
// منوی همبرگری — بالای راست صفحات؛ نمایش پنل فعلی + ناوبری بین پنل‌ها // منوی همبرگری — بالای راست صفحات؛ نمایش پنل فعلی + ناوبری بین پنل‌ها
export function PanelMenu({ items, title }: { items: MenuItem[]; title?: string }) { export function PanelMenu({ items, title }: { items: MenuItem[]; title?: string }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [expanded, setExpanded] = useState<number | null>(null);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
@@ -42,11 +45,81 @@ export function PanelMenu({ items, title }: { items: MenuItem[]; title?: string
{items.map((it, i) => {items.map((it, i) =>
it.divider ? ( it.divider ? (
<div key={i} className="my-1 border-t border-line" /> <div key={i} className="my-1 border-t border-line" />
) : it.children?.length ? (
<div key={i}>
<div className={cn("flex items-center rounded-lg", it.active && "bg-accent")}>
{it.to ? (
<Link
to={it.to}
onClick={() => {
setOpen(false);
it.onClick?.();
}}
className={cn(
"flex flex-1 items-center justify-between gap-3 rounded-lg px-3 py-2 text-[13px] no-underline",
it.active ? "font-bold text-white" : "text-ink hover:bg-slate-bg",
)}
>
{it.label}
{it.active && <span className="text-[10px] opacity-80">✓ فعلی</span>}
</Link>
) : (
<button
type="button"
onClick={() => {
setOpen(false);
it.onClick?.();
}}
className="flex flex-1 items-center gap-3 rounded-lg px-3 py-2 text-start text-[13px] text-ink hover:bg-slate-bg"
>
{it.label}
</button>
)}
<button
type="button"
aria-label={expanded === i ? "بستن فهرست پروژه‌ها" : "نمایش پروژه‌ها"}
aria-expanded={expanded === i}
onClick={() => setExpanded(expanded === i ? null : i)}
className={cn(
"me-1 inline-flex h-7 w-7 shrink-0 cursor-pointer items-center justify-center rounded-md",
it.active ? "text-white hover:bg-white/15" : "text-muted hover:bg-slate-bg",
)}
>
<ChevronDown
className={cn("h-4 w-4 transition-transform", expanded === i && "rotate-180")}
/>
</button>
</div>
{expanded === i && (
<div className="mt-0.5 flex flex-col gap-0.5 border-s border-line ps-2 ms-3">
{it.children.map((c, j) => (
<Link
key={j}
to={c.to ?? "#"}
onClick={() => {
setOpen(false);
c.onClick?.();
}}
className={cn(
"flex items-center justify-between gap-2 rounded-lg px-2.5 py-1.5 text-[12.5px] no-underline",
c.active ? "bg-accent-bg font-bold text-accent" : "text-ink hover:bg-slate-bg",
)}
>
<span className="truncate">{c.label}</span>
{c.active && <span className="shrink-0 text-[10px]">✓ فعلی</span>}
</Link>
))}
</div>
)}
</div>
) : it.to ? ( ) : it.to ? (
<Link <Link
key={i} key={i}
to={it.to} to={it.to}
onClick={() => setOpen(false)} onClick={() => {
setOpen(false);
it.onClick?.();
}}
className={cn( className={cn(
"flex items-center justify-between gap-3 rounded-lg px-3 py-2 text-[13px] no-underline", "flex items-center justify-between gap-3 rounded-lg px-3 py-2 text-[13px] no-underline",
it.active ? "bg-accent font-bold text-white" : "text-ink hover:bg-slate-bg", it.active ? "bg-accent font-bold text-white" : "text-ink hover:bg-slate-bg",
@@ -0,0 +1,129 @@
import { useMemo, useRef, useState } from "react";
import { X } from "lucide-react";
import { normalize } from "../lib/normalize";
import { cn } from "../lib/utils";
import { useClickOutside } from "./useClickOutside";
export interface ProjectOption {
id: string;
name: string;
}
// چند-انتخابیِ جست‌وجوپذیرِ پروژه‌ها (1:N — مدیر پروژه ↔ پروژه‌ها):
// - چیپ‌های پروژه با دکمه‌ی × (جداسازی فوری مدیر از پروژه)
// - جست‌وجوی زنده‌ی فارسی (normalize: ی/ك عربی، نیم‌فاصله و فاصله‌ها)
// - پروژه‌های انتخاب‌شده از فهرست پنهان می‌شوند تا انتخاب تکراری رخ ندهد
export function ProjectMultiSelect({
options,
values,
onChange,
placeholder = "انتخاب پروژه(ها)...",
disabled = false,
}: {
options: ProjectOption[];
values: string[];
onChange: (ids: string[]) => void;
placeholder?: string;
disabled?: boolean;
}) {
const [text, setText] = useState("");
const [open, setOpen] = useState(false);
const inputRef = useRef<HTMLInputElement | null>(null);
const ref = useClickOutside<HTMLDivElement>(() => {
setOpen(false);
setText("");
});
const byId = useMemo(() => new Map(options.map((o) => [o.id, o])), [options]);
const selected = values.map((id) => byId.get(id)).filter(Boolean) as ProjectOption[];
const q = normalize(text);
const available = useMemo(
() =>
options.filter((o) => !values.includes(o.id) && (!q || normalize(o.name).includes(q))),
[options, values, q],
);
const add = (id: string) => {
onChange([...values, id]);
setText("");
inputRef.current?.focus();
};
const remove = (id: string) => onChange(values.filter((v) => v !== id));
return (
<div ref={ref} className="relative min-w-52">
<div
className={cn(
"flex min-h-9 flex-wrap items-center gap-1 rounded-lg border border-line bg-panel px-2 py-1 text-[12px]",
disabled ? "opacity-60" : "cursor-text",
)}
onClick={() => {
if (!disabled) {
setOpen(true);
inputRef.current?.focus();
}
}}
>
{selected.map((p) => (
<span
key={p.id}
className="inline-flex max-w-full items-center gap-1 rounded-md bg-accent-bg px-2 py-0.5 font-semibold text-accent"
>
<span className="truncate">{p.name}</span>
{!disabled && (
<button
type="button"
aria-label={`جدا کردن ${p.name}`}
onClick={(e) => {
e.stopPropagation();
remove(p.id);
}}
className="cursor-pointer rounded p-0.5 hover:bg-accent/10"
>
<X className="h-3 w-3" />
</button>
)}
</span>
))}
<input
ref={inputRef}
type="text"
dir="rtl"
disabled={disabled}
value={text}
onChange={(e) => {
setText(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
placeholder={selected.length ? "" : placeholder}
className="h-7 min-w-24 flex-1 border-0 bg-transparent px-1 text-[12px] outline-none placeholder:text-muted/70"
/>
</div>
{open && !disabled && (
<div className="absolute z-40 mt-1 max-h-56 w-full min-w-52 overflow-auto rounded-xl border border-line bg-panel p-1 shadow-lg">
{available.length ? (
available.map((o) => (
<button
key={o.id}
type="button"
onClick={() => add(o.id)}
className="flex w-full cursor-pointer items-center rounded-lg px-2.5 py-1.5 text-start text-[12.5px] text-ink hover:bg-slate-bg"
>
{o.name}
</button>
))
) : (
<div className="px-2.5 py-2 text-[11.5px] text-muted">
{options.length === 0
? "پروژه‌ای وجود ندارد"
: q
? "پروژه‌ای یافت نشد"
: "همه‌ی پروژه‌ها متصل شده‌اند"}
</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,80 @@
import { useEffect, useState, type ReactNode } from "react";
import { apiV26 } from "../lib/api";
import { useAuthStore } from "../store/auth";
import { useLogout } from "../pages/Inventory/panelMenu";
// گیتِ پروژه‌های مدیر پروژه (PM → 1:N):
// - لیست پروژه‌های تخصیص‌یافته را از بک‌اند می‌گیرد و پروژه‌ی فعالِ ذخیره‌شده را
// اعتبارسنجی می‌کند؛ اگر پروژه باطل شده باشد به اولین پروژه برمی‌گردد
// (localStorage هم با همان store به‌روز می‌شود).
// - اگر هیچ پروژه‌ای تخصیص نیافته باشد: صفحه‌ی خالی با پیام + دکمه‌ی خروج
// (بدون حلقه‌ی ریدایرکت و بدون باطل‌کردن نشست).
// - حالت توسعه (بدون توکن) از همین پروژه‌ی انتخابیِ dev استفاده می‌کند.
export function RequirePmProjects({ children }: { children: ReactNode }) {
const user = useAuthStore((s) => s.user);
const isDev = useAuthStore((s) => s.isDev);
const setProjects = useAuthStore((s) => s.setProjects);
const logout = useLogout();
const isPm = user?.role === "pm";
const [state, setState] = useState<"loading" | "ok" | "empty">(isPm ? "loading" : "ok");
useEffect(() => {
if (!user || user.role !== "pm") {
setState("ok");
return;
}
if (isDev) {
setState(user.project_id ? "ok" : "empty");
return;
}
let alive = true;
setState("loading");
void apiV26.users
.myProjects()
.then((list) => {
if (!alive) return;
setProjects(list); // پروژه‌ی فعال را اعتبارسنجی/جایگزین می‌کند
setState(list.length ? "ok" : "empty");
})
.catch(() => {
// خطای شبکه نباید کاربر را قفل کند — با وضعیت فعلی ادامه می‌دهیم
if (alive) setState("ok");
});
return () => {
alive = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user?.id, user?.role, isDev]);
if (state === "loading") {
return (
<div className="flex min-h-screen items-center justify-center text-sm text-muted">
در حال بارگذاری پروژه‌ها…
</div>
);
}
if (state === "empty") {
return (
<div className="flex min-h-screen items-center justify-center bg-bg px-4">
<div className="w-full max-w-md rounded-2xl border border-line bg-panel p-8 text-center shadow-sm">
<h1 className="text-[16px] font-extrabold">هیچ پروژه‌ای به شما تخصیص داده نشده است</h1>
<p className="mt-2 text-[12.5px] leading-6 text-muted">
در حال حاضر هیچ پروژه‌ای به شما تخصیص داده نشده است. برای دسترسی به پنل انبار، از مدیر
سیستم بخواهید پروژه‌ای را به شما اختصاص دهد.
</p>
<button
type="button"
onClick={logout}
className="mt-5 inline-flex h-10 w-full cursor-pointer items-center justify-center rounded-lg bg-accent px-4 text-[13px] font-bold text-white transition-colors hover:bg-accent-dark"
>
خروج از حساب
</button>
</div>
</div>
);
}
return <>{children}</>;
}
+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 };
+53 -14
View File
@@ -69,6 +69,19 @@ function authHeaders(init?: RequestInit): Record<string, string> {
return headers; return headers;
} }
// پروژه‌ی فعالِ مدیر پروژه (چند-پروژه‌ای، 1:N): برای همه‌ی مسیرهای پنل انبار
// صریحاً ارسال می‌شود تا بک‌اند داده‌های همان پروژه را برگرداند/اعتبارسنجی کند.
// ادمین/مدیر تدارکات بدون پارامتر (همه‌ی پروژه‌ها) باقی می‌ماند.
function activeProjectId(): string | null {
const { user } = useAuthStore.getState();
return user?.role === "pm" ? user.project_id : null;
}
function projectQuery(projectId?: string | null): string {
const pid = projectId ?? activeProjectId();
return pid ? `?project_id=${pid}` : "";
}
// ترجمه‌ی پیام‌های رایج اعتبارسنجی FastAPI (422) به فارسی // ترجمه‌ی پیام‌های رایج اعتبارسنجی FastAPI (422) به فارسی
const VALIDATION_FA = (msg: string): string => { const VALIDATION_FA = (msg: string): string => {
const lower = msg.toLowerCase(); const lower = msg.toLowerCase();
@@ -1450,6 +1463,8 @@ export interface UserRow {
role_label: string | null; role_label: string | null;
project_id: string | null; project_id: string | null;
project_name: string | null; project_name: string | null;
// 1:N — every project this PM manages (oldest first).
projects?: { id: string; name: string }[];
created_at: string; created_at: string;
} }
@@ -1460,7 +1475,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: {
@@ -1477,6 +1491,7 @@ export const apiV26 = {
role_label: string | null; role_label: string | null;
project_id: string | null; project_id: string | null;
project_name: string | null; project_name: string | null;
projects?: { id: string; name: string }[];
}; };
}; };
}>("/auth/login", { method: "POST", body: JSON.stringify(body) }); }>("/auth/login", { method: "POST", body: JSON.stringify(body) });
@@ -1508,6 +1523,7 @@ export const apiV26 = {
id: string; casdoor_id: string; first_name: string; last_name: string; phone: string | null; id: string; casdoor_id: string; first_name: string; last_name: string; phone: string | null;
role: "pm" | "procurement_manager" | "admin" | null; role_label: string | null; role: "pm" | "procurement_manager" | "admin" | null; role_label: string | null;
project_id: string | null; project_name: string | null; project_id: string | null; project_name: string | null;
projects?: { id: string; name: string }[];
}>("/users/me"); }>("/users/me");
return { return {
id: data.id, id: data.id,
@@ -1519,9 +1535,13 @@ export const apiV26 = {
role_label: data.role_label, role_label: data.role_label,
project_id: data.project_id, project_id: data.project_id,
project_name: data.project_name, project_name: data.project_name,
projects: data.projects ?? [],
display_name: `${data.first_name} ${data.last_name}`.trim() || data.casdoor_id, display_name: `${data.first_name} ${data.last_name}`.trim() || data.casdoor_id,
}; };
}, },
// پروژه‌های تخصیص‌یافته به کاربر جاری (PM) — مبنای سوییچر پروژه + اعتبارسنجی
myProjects: async (): Promise<{ id: string; name: string }[]> =>
getData<{ id: string; name: string }[]>("/users/me/projects"),
// --- Organization panel (admin / procurement manager) --- // --- Organization panel (admin / procurement manager) ---
list: async (): Promise<UserRow[]> => { list: async (): Promise<UserRow[]> => {
const data = await getData<UserRow[]>("/users"); const data = await getData<UserRow[]>("/users");
@@ -1544,33 +1564,44 @@ export const apiV26 = {
}, },
}, },
// 1:N تخصیص مدیر پروژه ↔ پروژه: هر پروژه حداکثر یک PM دارد؛
// pm_user_id=null یعنی جداسازی مدیر از پروژه. فقط ادمین/مدیر تدارکات.
projects: {
setPm: async (projectId: string, userId: string | null): Promise<void> => {
await patchData(`/projects/${projectId}`, { pm_user_id: userId });
},
},
warehouses: { warehouses: {
list: async (projectId?: string): Promise<Warehouse[]> => { list: async (projectId?: string): Promise<Warehouse[]> => {
const qs = projectId ? `?project_id=${projectId}&page_size=100` : "?page_size=100"; const pid = projectId ?? activeProjectId() ?? undefined;
const qs = pid ? `?project_id=${pid}&page_size=100` : "?page_size=100";
const data = await getData<Parameters<typeof rawWarehouse>[0][]>(`/inventory/warehouses${qs}`); const data = await getData<Parameters<typeof rawWarehouse>[0][]>(`/inventory/warehouses${qs}`);
return data.map(rawWarehouse); return data.map(rawWarehouse);
}, },
create: async (body: { project_id: string; name: string; description?: string | null }): Promise<Warehouse> => { create: async (body: { project_id: string; name: string; description?: string | null }): Promise<Warehouse> => {
const d = await postData<Parameters<typeof rawWarehouse>[0]>("/inventory/warehouses", body); const d = await postData<Parameters<typeof rawWarehouse>[0]>(`/inventory/warehouses${projectQuery()}`, body);
return rawWarehouse(d); return rawWarehouse(d);
}, },
update: async (id: string, body: { name?: string; description?: string | null; project_id?: string }): Promise<Warehouse> => { update: async (id: string, body: { name?: string; description?: string | null; project_id?: string }): Promise<Warehouse> => {
const d = await patchData<Parameters<typeof rawWarehouse>[0]>(`/inventory/warehouses/${id}`, body); const d = await patchData<Parameters<typeof rawWarehouse>[0]>(`/inventory/warehouses/${id}${projectQuery()}`, body);
return rawWarehouse(d); return rawWarehouse(d);
}, },
delete: async (id: string): Promise<void> => { delete: async (id: string): Promise<void> => {
await deleteData(`/inventory/warehouses/${id}`); await deleteData(`/inventory/warehouses/${id}${projectQuery()}`);
}, },
}, },
inventory: { inventory: {
list: async (params: { project_id?: string; category_id?: string; warehouse_id?: string; material_id?: string; sort?: string } = {}): Promise<InventoryRow[]> => { list: async (params: { project_id?: string; category_id?: string; warehouse_id?: string; material_id?: string; sort?: string } = {}): Promise<InventoryRow[]> => {
const qs = new URLSearchParams({ page_size: "100", ...cleanParams(params) }).toString(); const pid = params.project_id ?? activeProjectId() ?? undefined;
const qs = new URLSearchParams({ page_size: "100", ...cleanParams({ ...params, project_id: pid }) }).toString();
const data = await getData<Parameters<typeof rawInventory>[0][]>(`/inventory?${qs}`); const data = await getData<Parameters<typeof rawInventory>[0][]>(`/inventory?${qs}`);
return data.map(rawInventory); return data.map(rawInventory);
}, },
transactions: async (params: { project_id?: string; warehouse_id?: string; transaction_type?: string; date_from?: string; date_to?: string; sort?: string } = {}): Promise<InventoryTransaction[]> => { transactions: async (params: { project_id?: string; warehouse_id?: string; transaction_type?: string; date_from?: string; date_to?: string; sort?: string } = {}): Promise<InventoryTransaction[]> => {
const qs = new URLSearchParams({ page_size: "100", ...cleanParams(params) }).toString(); const pid = params.project_id ?? activeProjectId() ?? undefined;
const qs = new URLSearchParams({ page_size: "100", ...cleanParams({ ...params, project_id: pid }) }).toString();
return getData<InventoryTransaction[]>(`/inventory/transactions?${qs}`); return getData<InventoryTransaction[]>(`/inventory/transactions?${qs}`);
}, },
createTransaction: async (body: { createTransaction: async (body: {
@@ -1579,26 +1610,34 @@ export const apiV26 = {
physical_qty?: number | null; unit_id?: number | null; specs?: Record<string, string> | null; physical_qty?: number | null; unit_id?: number | null; specs?: Record<string, string> | null;
note?: string | null; transaction_date?: string | null; note?: string | null; transaction_date?: string | null;
}): Promise<InventoryTransaction[]> => { }): Promise<InventoryTransaction[]> => {
return postData<InventoryTransaction[]>("/inventory/transactions", body); return postData<InventoryTransaction[]>(`/inventory/transactions${projectQuery()}`, body);
}, },
types: async (): Promise<{ type: TransactionType; label: string }[]> => { types: async (): Promise<{ type: TransactionType; label: string }[]> => {
return getData<{ type: TransactionType; label: string }[]>("/inventory/meta/types"); return getData<{ type: TransactionType; label: string }[]>("/inventory/meta/types");
}, },
materialOptions: async (category_id: string, warehouse_id?: string): Promise<{ id: string; name: string; brands: { id: string; name: string }[] }[]> => { materialOptions: async (category_id: string, warehouse_id?: string): Promise<{ id: string; name: string; brands: { id: string; name: string }[] }[]> => {
const qs = warehouse_id ? `?category_id=${category_id}&warehouse_id=${warehouse_id}` : `?category_id=${category_id}`; const params: Record<string, string> = { category_id };
return getData<{ id: string; name: string; brands: { id: string; name: string }[] }[]>(`/inventory/materials/options${qs}`); if (warehouse_id) params.warehouse_id = warehouse_id;
const pid = activeProjectId();
if (pid) params.project_id = pid;
return getData<{ id: string; name: string; brands: { id: string; name: string }[] }[]>(
`/inventory/materials/options?${new URLSearchParams(params).toString()}`,
);
}, },
}, },
pm: { pm: {
onTheWay: async (): Promise<PendingArrival[]> => getData<PendingArrival[]>("/pm/on-the-way"), // پروژه فعال به‌صورت صریح ارسال می‌شود؛ بک‌اند تعلق آن به PM را بررسی می‌کند
onTheWay: async (projectId?: string | null): Promise<PendingArrival[]> =>
getData<PendingArrival[]>(projectId ? `/pm/on-the-way?project_id=${projectId}` : "/pm/on-the-way"),
confirmReceipt: async (body: { confirmReceipt: async (body: {
item_decision_id: string; received_qty: number; item_decision_id: string; received_qty: number;
allocations: { warehouse_id: string; quantity: number }[]; allocations: { warehouse_id: string; quantity: number }[];
received_at?: string | null; on_time?: boolean | null; quality_note?: string | null; received_at?: string | null; on_time?: boolean | null; quality_note?: string | null;
}): Promise<{ receipt_id: string; state: string; posted_transactions: string[] }> => }, projectId?: string | null): Promise<{ receipt_id: string; state: string; posted_transactions: string[] }> =>
postData("/pm/receipts", body), postData(projectId ? `/pm/receipts?project_id=${projectId}` : "/pm/receipts", body),
receipts: async (): Promise<ConfirmedReceipt[]> => getData<ConfirmedReceipt[]>("/pm/receipts"), receipts: async (projectId?: string | null): Promise<ConfirmedReceipt[]> =>
getData<ConfirmedReceipt[]>(projectId ? `/pm/receipts?project_id=${projectId}` : "/pm/receipts"),
}, },
// ساخت موجودیت‌های کاتالوگ سراسری از داخل پنل انبار (موجودی اولیه): // ساخت موجودیت‌های کاتالوگ سراسری از داخل پنل انبار (موجودی اولیه):
+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"
+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>
); );
} }
+52 -38
View File
@@ -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,7 +97,6 @@ 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) => { const lastReceiptAt = activeItems.reduce<Date | null>((acc, li) => {
if (!li.arrived_date) return acc; if (!li.arrived_date) return acc;
const t = new Date(li.arrived_date).getTime(); const t = new Date(li.arrived_date).getTime();
@@ -137,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")} />
@@ -175,25 +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)
return ( ?.line_items?.filter((li) => !li.rejected)
<span key={li.id} className="flex items-center gap-1"> .map((li) => {
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-accent/40" /> const m = materialById(store.materials, li.material_id);
{materialLabel(m)} return (
<span className="text-[10px] text-muted/70"> <span key={li.id} className="flex items-center gap-1">
{fmtNum(li.quantity)} {li.unit} <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-blue-500/60" />
{materialLabel(m)}
<span className="text-[10px] text-muted/70">
{fmtNum(li.quantity)} {li.unit}
</span>
</span> </span>
</span> );
); })}
})}
</div> </div>
</div> </div>
) : ( ) : (
@@ -251,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>
@@ -277,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>
); );
} }
@@ -33,9 +33,11 @@ export function ArrivalsPage() {
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
const activeProject = isPM ? user?.project_id || undefined : undefined;
const [arr, whs] = await Promise.all([ const [arr, whs] = await Promise.all([
apiV26.pm.onTheWay(), // پروژه‌ی فعال صریحاً ارسال می‌شود؛ بک‌اند تخصیص PM را بررسی می‌کند
apiV26.warehouses.list(isPM ? user?.project_id || undefined : undefined), apiV26.pm.onTheWay(activeProject),
apiV26.warehouses.list(activeProject),
]); ]);
setItems(arr); setItems(arr);
setWarehouses(whs); setWarehouses(whs);
@@ -186,7 +188,7 @@ export function ArrivalsPage() {
item_decision_id: it.item_decision_id, item_decision_id: it.item_decision_id,
received_qty: draftReceivedOf(it), received_qty: draftReceivedOf(it),
allocations, allocations,
}); }, isPM ? user?.project_id || undefined : undefined);
} }
toast.success("رسید (کامل یا جزیی) ثبت شد و به انبارها وارد شد ✓ — اقلام باقی‌مانده همچنان در راه هستند"); toast.success("رسید (کامل یا جزیی) ثبت شد و به انبارها وارد شد ✓ — اقلام باقی‌مانده همچنان در راه هستند");
// همگام‌سازی با دفتر تدارکات: store پیشنهادها را تازه کن تا کارت‌های // همگام‌سازی با دفتر تدارکات: store پیشنهادها را تازه کن تا کارت‌های
+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>
))} ))}
</> </>
); );
} }
@@ -8,6 +8,7 @@ import { apiV26, type UserRow } from "../../lib/api";
import { refreshProcurementStore } from "../../hooks"; import { refreshProcurementStore } from "../../hooks";
import { useInventoryData } from "./useInventoryData"; import { useInventoryData } from "./useInventoryData";
import { faNum } from "../../lib/formatters"; import { faNum } from "../../lib/formatters";
import { ProjectMultiSelect } from "../../components/ProjectMultiSelect";
type Role = "pm" | "procurement_manager" | "admin" | null; type Role = "pm" | "procurement_manager" | "admin" | null;
@@ -96,17 +97,36 @@ export function OrganizationPage() {
} }
}; };
const changeProject = async (u: UserRow, projectId: string) => { // پروژه‌های متصل به یک کاربر (1:N) — با پشتیبانی از شکل قدیمیِ تک‌پروژه‌ای
const assignedProjectsOf = (u: UserRow) =>
u.projects ?? (u.project_id ? [{ id: u.project_id, name: u.project_name ?? "" }] : []);
// همگام‌سازی چند-انتخابیِ پروژه‌ها: افزودن/حذف هر اختلاف با یک PATCH
// (pm_user_id = کاربر برای اتصال، null برای جداسازی؛ مدیر قبلی جایگزین می‌شود)
const syncUserProjects = async (u: UserRow, nextIds: string[]) => {
const current = assignedProjectsOf(u).map((p) => p.id);
const added = nextIds.filter((id) => !current.includes(id));
const removed = current.filter((id) => !nextIds.includes(id));
if (!added.length && !removed.length) return;
setBusyId(u.id); setBusyId(u.id);
try { try {
const project = data.lookup.projects.find((p) => p.id === projectId); for (const pid of added) await apiV26.projects.setPm(pid, u.id);
await apiV26.users.assignProject(u.id, projectId || null); for (const pid of removed) await apiV26.projects.setPm(pid, null);
toast.success(project ? `«${u.first_name} ${u.last_name}» به پروژه «${project.name}» متصل شد ✓` : "مدیر پروژه از پروژه جدا شد"); const names = nextIds
.map((id) => data.lookup.projects.find((p) => p.id === id)?.name)
.filter(Boolean)
.join("، ");
toast.success(
nextIds.length
? `پروژه‌های «${u.first_name} ${u.last_name}» به‌روزرسانی شد: ${names} ✓`
: `«${u.first_name} ${u.last_name}» از همه‌ی پروژه‌ها جدا شد`,
);
await loadUsers(); await loadUsers();
await data.refresh(); await data.refresh();
refreshProcurementStore(); refreshProcurementStore();
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در اتصال مدیر به پروژه"); toast.error(err instanceof Error ? err.message : "خطا در به‌روزرسانی پروژه‌ها");
await loadUsers(); // وضعیت واقعی سرور را برگردان
} finally { } finally {
setBusyId(null); setBusyId(null);
} }
@@ -149,7 +169,13 @@ export function OrganizationPage() {
} }
}; };
const pmOf = (projectId: string) => users.find((u) => u.role === "pm" && u.project_id === projectId) || null; // مدیرِ مسئول یک پروژه (1:N — کاربر ممکن است مدیر چند پروژه باشد)
const pmOf = (projectId: string) =>
users.find(
(u) =>
u.role === "pm" &&
(u.projects?.some((p) => p.id === projectId) || u.project_id === projectId),
) || null;
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
@@ -226,19 +252,14 @@ export function OrganizationPage() {
<small className="block text-[10px] text-muted">(تغییر نقش فقط توسط ادمین)</small> <small className="block text-[10px] text-muted">(تغییر نقش فقط توسط ادمین)</small>
)} )}
</td> </td>
<td className="py-1.5 pe-3"> <td className="min-w-[240px] py-1.5 pe-3 align-top">
{u.role === "pm" ? ( {u.role === "pm" ? (
<select <ProjectMultiSelect
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px]" options={data.lookup.projects.map((p) => ({ id: p.id, name: p.name }))}
value={u.project_id || ""} values={assignedProjectsOf(u).map((p) => p.id)}
onChange={(ids) => void syncUserProjects(u, ids)}
disabled={busyId === u.id} disabled={busyId === u.id}
onChange={(e) => void changeProject(u, e.target.value)} />
>
<option value="">— بدون پروژه —</option>
{data.lookup.projects.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
) : ( ) : (
<span className="text-[11.5px] text-muted">—</span> <span className="text-[11.5px] text-muted">—</span>
)} )}
@@ -253,7 +274,9 @@ export function OrganizationPage() {
</tbody> </tbody>
</table> </table>
<div className="mt-1 text-[11px] text-muted"> <div className="mt-1 text-[11px] text-muted">
«بدون نقش» = حذف مدیر پروژه (از پروژه هم جدا می‌شود). اتصال به یک پروژه، مدیر قبلی آن پروژه را جابه‌جا می‌کند. «بدون نقش» = حذف مدیر پروژه (از همه‌ی پروژه‌ها هم جدا می‌شود). هر مدیر پروژه می‌تواند به
چند پروژه متصل باشد؛ حذف هر چیپ (×)، فقط همان پروژه را جدا می‌کند و اتصال به یک پروژه، مدیرِ
قبلیِ آن پروژه را جابه‌جا می‌کند.
</div> </div>
</div> </div>
)} )}
@@ -294,11 +317,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 +345,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>
@@ -54,6 +54,9 @@ export function useInventoryData(): InventoryData {
setWarehouses(whs); setWarehouses(whs);
setInventory(inv); setInventory(inv);
setTransactions(txs); setTransactions(txs);
} catch {
// پروژه‌ی فعال ممکن است همین حالا باطل شده باشد — گیتِ پروژه در سطح مسیر
// (RequirePmProjects) پروژه‌ی جایگزین/صفحه‌ی خالی را نشان می‌دهد.
} finally { } finally {
setLoading(false); setLoading(false);
} }
+29 -36
View File
@@ -41,9 +41,7 @@ export function itemRowLabel(it: ItemDraft): string {
return qtyPart ? `${name} -- ${qtyPart}` : name; return qtyPart ? `${name} -- ${qtyPart}` : name;
} }
// نام پایه‌ی متریال از نامِ ذخیره‌شده: پیشوند برند و پسوند مشخصه حذف می‌شوند // نام پایه‌ی متریال از نامِ ذخیره‌شده
// («راجین سیم مسی» → «سیم») — پیشنهادهای فیلد «متریال» فقط خودِ متریال را نشان
// می‌دهند؛ برند و مشخصه ستون‌های جداگانه دارند.
function materialBaseName(name: string, brand: string, specs: string[]): string { function materialBaseName(name: string, brand: string, specs: string[]): string {
let base = (name || "").trim(); let base = (name || "").trim();
if (!base) return ""; if (!base) return "";
@@ -60,9 +58,7 @@ function materialBaseName(name: string, brand: string, specs: string[]): string
return base || (name || "").trim(); return base || (name || "").trim();
} }
// مقادیر مجزای category/brand/spec داخل تاپیک — با فیلتر پله‌به‌پله (تاپیک → دسته → برند) // مقادیر مجزای category/brand/spec داخل تاپیک
// + مقادیر تایپ‌شده در همه‌ی سطرهای قبلی (suggestion از همه‌ی فیلدها)
// نکته: برند و واحد از همه‌ی دسته‌ها نمایش داده می‌شوند (issue #7)
export function materialOptions( export function materialOptions(
materials: Material[], materials: Material[],
topic: string, topic: string,
@@ -75,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) => {
@@ -94,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));
@@ -110,21 +104,18 @@ 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 allMaterialNames = useMemo(() => {
const set = new Set<string>(); const set = new Set<string>();
materials.forEach((m) => { materials.forEach((m) => {
@@ -134,7 +125,6 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
return [...set].sort((a, b) => a.localeCompare(b, "fa")); return [...set].sort((a, b) => a.localeCompare(b, "fa"));
}, [materials]); }, [materials]);
// برندهای ثبت‌شده در بخش برندها (بک‌اند) — به‌علاوه‌ی برندهای متریال‌ها
const [brandOptions, setBrandOptions] = useState<string[]>([]); const [brandOptions, setBrandOptions] = useState<string[]>([]);
useEffect(() => { useEffect(() => {
@@ -146,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; };
}, []); }, []);
@@ -163,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;
@@ -198,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>
@@ -215,12 +203,10 @@ 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 })}
// پیشنهادها: همه‌ی متریال‌های کاتالوگ سراسری با نام واقعی‌شان
// (بدون فیلتر تاپیک) — انتخابِ یک متریال موجود در ذخیره، همان را reuse می‌کند
items={allMaterialNames} items={allMaterialNames}
allowAdd allowAdd
addSuffix="به عنوان متریال جدید" addSuffix="به عنوان متریال جدید"
@@ -263,30 +249,22 @@ 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.filter((u) => normalize(u) !== normalize(it.unit))} 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) => { onAdd={(v) => {
// انتخاب گزینه‌ی «+ [واحد] به عنوان واحد جدید» → ثبت فوری در دیتابیس؛
// نامِ تایپ‌شده هم روی همان قلم اعمال می‌شود (onInputChange بالا)
const name = v.trim(); const name = v.trim();
if (!name) return; if (!name) return;
void apiV26.catalog void apiV26.catalog
.createUnit(name) .createUnit(name)
.then(() => { .then(() => {
// بعد از ثبت، واحد برای سطرهای بعدی هم پیشنهاد شود
setUnitOptions((prev) => setUnitOptions((prev) =>
prev.some((u) => normalize(u) === normalize(name)) ? prev : [...prev, name], prev.some((u) => normalize(u) === normalize(name)) ? prev : [...prev, name],
); );
}) })
.catch(() => { .catch(() => {});
// اگر واحدِ همنام در همین لحظه ساخته شده باشد (تداخل 409) یا شبکه
// خطا دهد، هنگام ذخیره‌ی پیشنهاد دوباره resolve می‌شود — خطای بلوک‌کننده نیست
});
}} }}
inputClassName="!text-[12px] !h-9" inputClassName="!text-[12px] !h-9"
className="min-w-0" className="min-w-0"
@@ -315,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>
); );
} }
+170 -156
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>(() => {
// تاریخ پیشنهاد: همین الان (ساعت و دقیقه‌ی فعلی) — نه نیمه‌شب
return new Date().toISOString(); return new Date().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,144 +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 }[] = [];
for (const it of items) {
const cat = it.cat.trim();
const brand = it.brand.trim();
const specs = it.specs;
const unit = it.unit.trim();
const qty = it.qty;
const price = it.price;
const total = it.total;
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));
if (!material) {
material = materials.find(
(m) =>
normalize(m.category) === normalize(cat) &&
normalize(m.brand) === normalize(brand) &&
specsKey(m.specs) === specsKey(specs),
);
}
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;
} catch (err) {
sectionError(2, "خطا در بخش اقلام (متریال)", err);
return;
} }
} }
// اقلام collected.push({
const collected: { li: LineItem; tempId: string }[] = []; li: {
for (const it of items) { id: "li-" + (collected.length + 1),
const cat = it.cat.trim(); material_id: materialId,
const brand = it.brand.trim(); quantity: qty,
const specs = it.specs; unit,
const unit = it.unit.trim(); unit_price: price,
const qty = it.qty; total_price: total,
const price = it.price; termsheet_ids: [],
const total = it.total; accepted_ts_id: null,
if (!cat && !brand && qty == null && price == null) continue; rejected: false,
arrived_date: null,
// جستجوی متریال: اول با نامِ کامل سراسری (دیتابیس) — بدون قید دسته/تاپیک، },
// چون دسته‌ی ذخیره‌شده‌ی متریال‌ها نام تاپیک است نه نام تایپ‌شده‌ی کاربر tempId: it.tempId,
const wantName = [brand, cat, ...specs].filter(Boolean).join(" ") || "کالای جدید";
let material = materials.find((m) => normalize(m.name) === normalize(wantName));
if (!material) {
// جستجوی قبلی (برند و مشخصه‌ی ساختارمند در همین نشست)
material = materials.find(
(m) =>
normalize(m.category) === normalize(cat) &&
normalize(m.brand) === normalize(brand) &&
specsKey(m.specs) === specsKey(specs),
);
}
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);
// termsheetها
const termsheets = cards
.map((card, i) => ({
id: "new-ts-" + (i + 1),
label: "گزینه " + faNum(i + 1),
milestones: card.milestones.filter((m) => m.when_iso || m.amount != null),
status: "offered" as const,
}))
.filter((t) => t.milestones.length);
// اتصال اقلام به termsheetها
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 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 = {
@@ -361,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);
@@ -379,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 });
@@ -475,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>
{/* دیالوگ ذخیره با فیلدهای خالی */} {/* دیالوگ ذخیره با فیلدهای خالی */}
@@ -539,4 +553,4 @@ export function WizardPage() {
</PageLayout> </PageLayout>
</div> </div>
); );
} }
+28 -1
View File
@@ -16,8 +16,9 @@ export interface AuthUser {
phone: string | null; phone: string | null;
role: "pm" | "procurement_manager" | "admin" | null; role: "pm" | "procurement_manager" | "admin" | null;
role_label: string | null; role_label: string | null;
project_id: string | null; project_id: string | null; // پروژه‌ی فعال (PM) — با سوییچر عوض می‌شود
project_name: string | null; project_name: string | null;
projects?: { id: string; name: string }[]; // همه‌ی پروژه‌های تخصیص‌یافته (1:N)
display_name: string; display_name: string;
} }
@@ -28,6 +29,11 @@ interface AuthStore {
setToken: (token: string | null) => void; setToken: (token: string | null) => void;
setUser: (user: AuthUser | null) => void; setUser: (user: AuthUser | null) => void;
setDevUser: (role: "pm" | "admin" | "procurement_manager", projectId: string | null, projectName: string | null) => void; setDevUser: (role: "pm" | "admin" | "procurement_manager", projectId: string | null, projectName: string | null) => void;
// پروژه‌های تخصیص‌یافته را می‌نشاند و پروژه‌ی فعال را اعتبارسنجی می‌کند:
// اگر پروژه‌ی ذخیره‌شده (localStorage) باطل شده باشد، به اولین پروژه برمی‌گردد.
setProjects: (projects: { id: string; name: string }[]) => void;
// جابه‌جایی پروژه‌ی فعال بدون هیچ تغییری در نشست/توکن (بدون احراز هویت مجدد)
setActiveProject: (projectId: string) => void;
logout: () => void; logout: () => void;
} }
@@ -53,9 +59,30 @@ export const useAuthStore = create<AuthStore>()(
role_label: role === "pm" ? "مدیر پروژه (آزمایشی)" : role === "procurement_manager" ? "مدیر تدارکات (آزمایشی)" : "ادمین (آزمایشی)", role_label: role === "pm" ? "مدیر پروژه (آزمایشی)" : role === "procurement_manager" ? "مدیر تدارکات (آزمایشی)" : "ادمین (آزمایشی)",
project_id: projectId, project_id: projectId,
project_name: projectName, project_name: projectName,
projects: projectId ? [{ id: projectId, name: projectName || "" }] : [],
display_name: projectId ? `کاربر آزمایشی — ${projectName || ""}` : "کاربر آزمایشی", display_name: projectId ? `کاربر آزمایشی — ${projectName || ""}` : "کاربر آزمایشی",
}, },
}), }),
setProjects: (projects) =>
set((s) => {
if (!s.user) return {};
const active = projects.find((p) => p.id === s.user!.project_id) ?? projects[0] ?? null;
return {
user: {
...s.user,
projects,
project_id: active?.id ?? null,
project_name: active?.name ?? null,
},
};
}),
setActiveProject: (projectId) =>
set((s) => {
if (!s.user) return {};
const target = (s.user.projects ?? []).find((p) => p.id === projectId);
if (!target) return {};
return { user: { ...s.user, project_id: target.id, project_name: target.name } };
}),
logout: () => set({ token: null, user: null, isDev: false }), logout: () => set({ token: null, user: null, isDev: false }),
}), }),
{ name: "procurement-auth-v1" }, { name: "procurement-auth-v1" },