Compare commits
18
Commits
v1.5
...
1f16e44874
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f16e44874 | ||
|
|
3f4bbee6de | ||
|
|
4e59f6e25c | ||
|
|
593c149478 | ||
|
|
e7f465f74f | ||
|
|
2573f1e583 | ||
|
|
a542e4177e | ||
|
|
c454b03b4d | ||
|
|
50f8d8e64e | ||
|
|
3b032d0460 | ||
|
|
f263cfb9e3 | ||
|
|
4609940856 | ||
|
|
3c3880aec9 | ||
|
|
545b5dd473 | ||
|
|
7528cc3bbe | ||
|
|
b45a6cd033 | ||
|
|
f0e424316e | ||
|
|
c10c10a425 |
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
@@ -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]
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|
||||||
|
|||||||
@@ -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))}
|
||||||
|
|||||||
@@ -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.
@@ -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
|
|
||||||
@@ -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.
Binary file not shown.
@@ -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
|
||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: "/" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}</>;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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"),
|
||||||
},
|
},
|
||||||
|
|
||||||
// ساخت موجودیتهای کاتالوگ سراسری از داخل پنل انبار (موجودی اولیه):
|
// ساخت موجودیتهای کاتالوگ سراسری از داخل پنل انبار (موجودی اولیه):
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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 پیشنهادها را تازه کن تا کارتهای
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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" },
|
||||||
|
|||||||
Reference in New Issue
Block a user