22 Commits
Author SHA1 Message Date
Behrad 14a4a29431 feat(procurement): sync status pipeline cards, add supplier lead time badges, and enhance badge contrast 2026-09-20 15:10:10 +03:30
Behrad d2b9af5278 feat(dashboard): filter quotes by clicking status cards in StatsBar 2026-09-20 14:40:27 +03:30
Behrad 3c7ae80b02 feat(ui): add inline preview modal for invoice files in FileList 2026-09-19 20:09:27 +03:30
Behrad de177f3520 style(dashboard): redesign EditModal with solid light theme and tabs 2026-09-19 18:57:18 +03:30
Behrad b5dc3e585a fix(ui): fix layout alignment in AcceptModal and MilestoneRow 2026-09-19 17:44:21 +03:30
Behrad c395fba8dd fix(inventory): replace native select with touch chips in ChangeInventoryPage 2026-09-17 14:04:01 +03:30
Behrad 66216c2b2f fix(inventory): optimize mobile layout with card view and chip filters 2026-09-17 13:53:24 +03:30
Behrad 435e6cbb21 fix(procurement): improve mobile layout for QuoteCard and EditModal 2026-09-17 13:19:14 +03:30
MMD 7b3ae3cb60 feat(wizard): cache مشخصه globally instead of per topic/category/brand
The specs combobox drew only from materials in the selected topic, further
narrowed by the row's category and brand, so a spec was offered only when
some material in that topic shared the same category AND brand. On a new
quote the user therefore lost specs recorded in earlier quotes.

- api.specsList(): new global source reading the attribute-definitions
  registry (topic/category/brand independent)
- materialOptions('spec'): unions the registry, specs of all materials,
  and specs typed in sibling rows — no topic/category/brand filter
- EditModal: same global registry added to its spec options

Verified the registry endpoint returns labels and that the union includes
specs absent from material attributes (e.g. 'مسی').
2026-09-16 20:31:01 +03:30
MMD ae977e5e66 fix(wizard): store material name cleanly instead of brand+category+specs
Adding a new item wrote the material name as 'brand + category + specs'
(e.g. 'پارس لوله لوله PE100 سایز ۱۶'), while specs were also stored
correctly in EAV attributes. The wizard then tried to recover the base
name by stripping brand and specs out of that string, but never stripped
the injected category — so the spec stayed baked into the cached material
name and the مشخصه appeared as part of the material name on later visits.

- api.ts / WizardPage / EditModal: build name from the material itself
  only (brand goes to brand_ids, specs to attributes)
- materialBaseName: strip category too, never strip a part that would
  empty the name, and reject a spec-only result; legacy rows whose name
  cannot be disentangled fall back to the stored name rather than a guess
- WizardPage: match existing materials on category+brand so legacy rows
  are still reused instead of duplicated
2026-09-16 20:24:45 +03:30
MMD b65938ef06 Merge prod_v3 (quote locking, per-item target project, title dedup) into production
Brings in prod_v3 5084623 on top of production 9e8a236. Merged without
conflicts: prod_v3's base (7ead60c) is an ancestor of production, and
production's extra tab-bar refinements (17c7ae6) touch disjoint regions.
2026-09-16 19:43:25 +03:30
whqreza 5084623be5 feat(quotes): lock quotes with posted receipts; per-item target project + title dedup
Quotes that already have warehouse receipts can no longer be edited or rolled back:
- backend: quote_has_receipts() guard — replace_quote_children and every backward status transition now return 409 with «این پیشنهاد به‌دلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.»
- reopening to «offered» additionally clears item_decisions.project_id (the purchased-project link belongs to acceptance, not to a reopened offer)
- frontend EditModal: hasReceipts locks the whole form — disabled inputs and date pickers (JalaliDatePicker/NumberInput/MilestoneRow/MultiSelectCombobox gained disabled support), add/remove controls for items, termsheets and milestones hidden, status change blocked, amber warning banner; quote deletion now goes through a confirmation dialog (useDeleteQuote)
- date-only semantics: JalaliDatePicker keeps/emits YYYY-MM-DD when withTime=false (typing, «امروز», day selection) and toDateOnly no longer mangles pure date strings

Per-item target project (candidate projects stay intact):
- expand_service exposes the decision target project (decision.project + line_item.target_project)
- the accept payload derives item_decisions.project_id from that target project and sends item_projects: [] so line_item_projects keeps the original candidates untouched
- AcceptModal/EditModal select the target project per line item

Material titles:
- quoteUtils.formatMaterialTitle() composes one normalized, deduplicated «name + brand + specs» title, replacing materialLabel/materialSpecs/specsLabel usage across dashboard cards, quote detail and the modals

Verified: python compile + api/frontend image builds (tsc + vite) clean.
2026-09-16 19:14:06 +03:30
MMD 9e8a2368ee Merge main_v2 mobile tabbar/inventory UI refinements into production
Brings in main_v2 17c7ae6 (tab bar sizing, inventory actions, date picker
proportions, stats lead time).

Conflicts resolved in favour of main_v2, which deliberately removed the
'تغییر موجودی' top button (nav lives in the tab bar / InventoryTopNav now)
and rewrote the proforma date field. Dropped the now-unused Button import.
2026-09-16 17:42:46 +03:30
Behrad 17c7ae63c8 fix(ui): improve mobile tabbar sizing, inventory actions, date picker proportions and stats lead time 2026-09-16 16:17:57 +03:30
MMD 7ead60cd5c Merge main_v2 mobile UI into production, restore mobile bottom tab bar
Merges main_v2 a110741 (mobile UI optimizations) into production and
reinstates the mobile bottom navigation that f0e4243 had removed.

Conflicts in DashboardPage, QuoteCard and InventoryPage resolved in
favour of main_v2's mobile-first markup, keeping production's newer
design system (Button variant="primary" on InventoryPage).

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

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

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

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

Verified locally after reproducing the exact production error (same latin-1 positions 22-27): Persian proforma + invoice downloads 500 -> 200 with filename*= carrying the full Persian name and bytes sha256-matching the MinIO object; ASCII filename regression 200; helper edge cases (CRLF/quote injection stripped, empty name -> attachment, pure-Persian png -> attachment.png); no UnicodeEncodeError in the api logs after the fix.
2026-09-15 17:38:47 +03:30
whqreza 4e59f6e25c docs(readme): add server section with MinIO tunnel; refresh stale sections
New section 'Working with the deployed system (server)': public app and Casdoor panel URLs (aramesh / casdoor .bennu-api-bahrestan.ir) and step-by-step MinIO console access via an SSH tunnel (ssh -L 9001:127.0.0.1:9001 root@171.22.25.75 -> http://localhost:9001), including the aramesh-minio credentials, the procurement-files bucket, why the console is loopback-only (port 9000 is reserved for the deploy-webhook receiver), the secret-handling warning and the tunnel teardown.

Also refreshed sections that went stale this session: the login form description no longer mentions the removed optional organization field; the restore section now documents the committed admin-only seed dumps in backups/علی محقق/ (the old restore.ps1/sh scripts and demo dumps were deleted) with the current login and the still-available demo seeders.
2026-09-15 16:57:28 +03:30
whqreza 593c149478 feat(auth): add password visibility toggle and remove the unused Organization field
Part 1 — show/hide password: an eye/eye-off button inside the login password input switches its type between password and text. It sits on the right (the input is LTR) with pr-10 clearance so dots never overlap the icon; type=button so a click cannot submit the form, aria-label Show/Hide password, aria-pressed + aria-controls, plus a Persian tooltip.

Part 2 — Organization field removed end-to-end after the audit proved it was a no-op: LoginPage input/state/payload spread (main + dev-mode admin login), api.ts payload type, backend LoginRequest.organization (declared but never forwarded to Casdoor — a bogus org value still logged in successfully), and the casdoor-auth.md flow diagram. The organization actually used remains CASDOOR_ORGANIZATION=inventory for /users/sync.

Verified: api + frontend images rebuilt and redeployed; login works with {username, password}; the served LoginPage chunk contains the toggle and zero org-field leftovers.
2026-09-15 16:29:54 +03:30
MMD e7f465f74f style(ui): maximize topic card images and fix RTL wizard arrows
Frontend-only application of prod_v2 (55e959b, 9e99b46).
Dev docker-compose.yml changes excluded.
2026-09-15 14:30:43 +03:30
MMD 2573f1e583 refactor(ui): modernize proposal wizard, dashboard cards and daily control calendar
Frontend-only application of prod_v2 (7bc6845, a29eafc). Dev
docker-compose.yml change (b11cb36) and backups churn excluded.
2026-09-13 18:07:27 +03:30
54 changed files with 3924 additions and 1718 deletions
+88 -14
View File
@@ -81,7 +81,7 @@ v1.3/
## 🔐 Login
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
(password grant), verifies the returned JWT, and starts the session —
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 —
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)
@@ -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
everyone see the same system state, the repo ships exact database dumps in
`backups/`. After a fresh boot:
The databases are **not** in git — Docker volumes hold the data. The repo
ships recovery dumps in `backups/علی محقق/`:
| 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 -ExecutionPolicy Bypass -File .\backups\restore.ps1 # Windows
bash backups/restore.sh # Linux / Git Bash
docker compose stop api casdoor
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
the committed state — a clean, realistic demo: 3 projects × 2 warehouses,
4 suppliers, 12 materials across all 8 categories, and 4 quotes covering
every lifecycle state (offered / in-transit / partially received /
completed) with matching stock.
Login afterwards: `ali.mohaghegh` / `hulkfuck002` (Casdoor rejects spaces
in usernames, so «ali mohaghegh» is not a valid login name).
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
docker exec procurement-api sh -c "cd /srv/backend && python -m tools.seed"
```
+34 -7
View File
@@ -103,15 +103,17 @@ async def _verify_token(token: str) -> dict:
class UserContext:
user: User
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
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).
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 "")
if not casdoor_id:
@@ -134,13 +136,38 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
db.add(user)
await db.flush()
project_id = None
project_ids: list = []
if user.role == "pm":
project = await db.scalar(select(Project).where(Project.pm_user_id == user.id))
project_id = project.id if project else None
projects = (
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
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:
+5
View File
@@ -302,6 +302,11 @@ _V26_DDL = [
"""
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).
"""
CREATE TABLE IF NOT EXISTS supplier_materials (
+3 -4
View File
@@ -20,7 +20,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import _verify_token, context_from_claims
from app.config import settings
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
logger = logging.getLogger("procurement.auth")
@@ -31,7 +31,6 @@ router = APIRouter(prefix="/auth", tags=["auth"])
class LoginRequest(BaseModel):
username: str
password: str
organization: str | None = None # optional override; defaults to CASDOOR_ORGANIZATION
@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).
claims = await _verify_token(access_token)
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 {
"data": {
"access_token": access_token,
"token_type": "bearer",
"expires_in": body.get("expires_in"),
"user": user_to_dict(ctx.user, project),
"user": user_to_dict(ctx.user, projects),
}
}
+24 -2
View File
@@ -13,7 +13,9 @@ The same four endpoints exist under /proforma. Bytes live in MinIO
Both invoice and proforma uploads are allowed in every quote status (the
user can attach the invoice from the edit page at any time).
"""
import os
import uuid
from urllib.parse import quote
from fastapi import APIRouter, Depends, File, Response, UploadFile
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
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:
quote = await db.get(Quote, quote_id)
if quote is None:
@@ -92,7 +114,7 @@ async def download_invoice(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: As
content=content,
media_type=attachment.mime_type,
headers={
"Content-Disposition": f'attachment; filename="{attachment.file_name}"',
"Content-Disposition": _content_disposition(attachment.file_name),
"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,
media_type=attachment.mime_type,
headers={
"Content-Disposition": f'attachment; filename="{attachment.file_name}"',
"Content-Disposition": _content_disposition(attachment.file_name),
"X-File-Id": str(attachment.id),
},
)
+23 -9
View File
@@ -59,10 +59,18 @@ class Scope:
actor: str | None
async def inventory_scope(request: Request, db: AsyncSession = Depends(get_db)) -> Scope:
"""Resolve the caller role/project — Casdoor JWT first, dev headers as fallback."""
# Primary path: Casdoor JWT. Role from users.role; PM project from
# projects.pm_user_id (single PM per project).
async def inventory_scope(
request: Request,
project_id: uuid.UUID | None = Query(default=None, description="پروژه فعال (PM: داده‌های همین پروژه)"),
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"):
user: UserContext = await get_optional_user(request, db)
if user is not None:
@@ -70,16 +78,21 @@ async def inventory_scope(request: Request, db: AsyncSession = Depends(get_db))
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
# v2.6: مدیر تدارکات = ادمین دفتر — دسترسی فقط‌خواندنی به پنل انبار.
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"):
raise ValidationError("نقش نامعتبر است — فقط pm یا admin برای موجودی", field="role")
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(
"کاربر PM به هیچ پروژه‌ای متصل نیست (projects.pm_user_id)", field="project_id"
)
return Scope(role="pm", project_id=user.project_id, actor=user.display_name)
return Scope(role="admin", project_id=None, actor=user.display_name)
return Scope(role="pm", project_id=allowed[0], 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
# when an explicit X-User-Role header is sent. No auth info at all → 401.
@@ -90,8 +103,9 @@ async def inventory_scope(request: Request, db: AsyncSession = Depends(get_db))
role = ROLE_ADMIN # مدیر تدارکات = ادمین (فقط‌خواندنی)
if role not in (ROLE_PM, ROLE_ADMIN):
raise ValidationError("نقش نامعتبر است — فقط pm یا admin", field="X-User-Role")
# The explicit query param wins; otherwise the dev header carries it.
if project_id is None:
raw_project = request.headers.get("x-project-id") or settings.INVENTORY_DEFAULT_PROJECT_ID
project_id = None
if raw_project:
try:
project_id = uuid.UUID(str(raw_project))
+28 -15
View File
@@ -29,7 +29,7 @@ from contextlib import nullcontext
from dataclasses import dataclass
from decimal import Decimal
from fastapi import APIRouter, Depends, Request
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import func, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
@@ -66,14 +66,20 @@ NULL_BRAND_SENTINEL = "00000000-0000-0000-0000-000000000000"
class ArrivalScope:
role: str # "pm" | "admin" (admin = admin or procurement_manager)
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
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.
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"):
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:
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
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)")
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"):
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("این بخش فقط برای مدیر پروژه یا مدیر تدارکات است")
# Dev fallback: X-User-Role: pm | admin (procurement_manager maps to admin)
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"
if role not in ("pm", "admin"):
raise ForbiddenError("این بخش فقط برای مدیر پروژه یا مدیر تدارکات است — هدر X-User-Role را بفرستید")
project_id = None
if role == "pm":
# The explicit query param wins; otherwise the dev header carries it.
if project_id is None and role == "pm":
raw_project = request.headers.get("x-project-id")
if not raw_project:
raise ForbiddenError("هدر X-Project-Id (پروژه مسئول) الزامی است")
@@ -172,9 +183,10 @@ async def pm_on_the_way(
.outerjoin(Project, Project.id == ItemDecision.project_id)
.where(ItemDecision.status == "chosen")
)
if ctx.role == "pm":
if ctx.project_id is None:
if ctx.role == "pm" and ctx.project_id is None:
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)
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)
if decision is None or decision.status != "chosen":
raise NotFoundError("تصمیم خرید یافت نشد", field="item_decision_id")
if ctx.role == "pm" and decision.project_id != ctx.project_id:
raise ForbiddenError("این قلم متعلق به پروژه شما نیست")
if ctx.project_id is not None and decision.project_id != ctx.project_id:
raise ForbiddenError("این قلم متعلق به پروژه فعال نیست")
li = await db.get(LineItem, decision.line_item_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(Project, Project.id == ItemDecision.project_id)
)
if ctx.role == "pm":
if ctx.project_id is None:
if ctx.role == "pm" and ctx.project_id is None:
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)
rows = (await db.execute(stmt.order_by(ItemReceipt.received_at.desc()))).all()
receipts = [r.ItemReceipt for r in rows]
+30 -3
View File
@@ -1,19 +1,25 @@
"""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.ext.asyncio import AsyncSession
from app.auth import UserContext, get_optional_user
from app.db.engine import get_db
from app.models.procurement import Project, Quote, User
from app.schemas.procurement import ProjectCreate, ProjectUpdate
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.serialization import to_date, to_iso
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:
"""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}
@@ -91,7 +97,13 @@ async def get_project(project_id, db: AsyncSession = Depends(get_db)):
@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)
if project is None:
raise NotFoundError("پروژه یافت نشد")
@@ -101,6 +113,21 @@ async def update_project(project_id, payload: ProjectUpdate, db: AsyncSession =
project.description = payload.description
if payload.start_date is not None:
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()
return {"data": await project_to_dict(db, project)}
+78 -30
View File
@@ -1,12 +1,14 @@
"""Users — profile + role assignment + organization management (v2.6, Casdoor-backed).
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).
- PATCH /users/{id} — set/clear role; clearing (or switching away from
«pm») also unassigns the user from their project.
- POST /users/{id}/project — assign/unassign the user's single project
(project_id=None unassigns; one PM per project).
«pm») also unassigns the user from every project.
- POST /users/{id}/project — assign/unassign ONE project (multi-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
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 {
"id": str(user.id),
"casdoor_id": user.casdoor_id,
@@ -50,8 +56,9 @@ def user_to_dict(user: User, project: Project | None = None) -> dict:
"phone": user.phone,
"role": user.role,
"role_label": USER_ROLE_LABELS.get(user.role) if user.role else None,
"project_id": str(project.id) if project else None,
"project_name": project.name if project else None,
"project_id": str(first.id) if first 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),
}
@@ -63,14 +70,28 @@ async def _org_manager(ctx: UserContext = Depends(get_current_user)) -> UserCont
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":
return None
return await db.scalar(select(Project).where(Project.pm_user_id == user.id))
return []
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:
"""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(
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")
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
return {"data": user_to_dict(ctx.user, project)}
projects = await _user_projects(db, ctx.user)
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("")
@@ -113,15 +145,22 @@ async def list_users(
_: UserContext = Depends(_org_manager),
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()
pm_ids = {u.id for u in users if u.role == "pm"}
projects: dict = {}
by_user: dict = {}
if pm_ids:
projs = (await db.execute(select(Project).where(Project.pm_user_id.in_(pm_ids)))).scalars().all()
projects = {p.pm_user_id: p for p in projs}
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 {
"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)},
}
@@ -166,8 +205,8 @@ async def update_user_role(
await _unassign_projects(db, user.id)
user.role = payload.role
await db.flush()
project = await _user_project(db, user)
return {"data": user_to_dict(user, project)}
projects = await _user_projects(db, user)
return {"data": user_to_dict(user, projects)}
@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()
pm_ids = {u.id for u in users if u.role == "pm"}
projects: dict = {}
by_user: dict = {}
if pm_ids:
projs = (await db.execute(select(Project).where(Project.pm_user_id.in_(pm_ids)))).scalars().all()
projects = {p.pm_user_id: p for p in projs}
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 {
"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},
}
@@ -221,11 +267,13 @@ async def assign_user_project(
_: UserContext = Depends(_org_manager),
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
time (assignment on other projects is cleared); assigning a new PM to a
project replaces the previous PM there.
- project_id given: that project gets this PM (a previous PM there is
replaced); the user's OTHER project assignments are kept.
- 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)
if user is None:
@@ -233,8 +281,8 @@ async def assign_user_project(
if user.role != "pm":
raise ValidationError("ابتدا نقش این کاربر را «مدیر پروژه» کنید", field="role")
await _unassign_projects(db, user.id)
if payload.project_id is None:
await _unassign_projects(db, user.id)
await db.flush()
return {"data": user_to_dict(user)}
@@ -243,4 +291,4 @@ async def assign_user_project(
raise NotFoundError("پروژه یافت نشد", field="project_id")
project.pm_user_id = user.id
await db.flush()
return {"data": user_to_dict(user, project)}
return {"data": user_to_dict(user, await _user_projects(db, user))}
+8 -1
View File
@@ -52,6 +52,9 @@ class ProjectUpdate(APIModel):
name: str | None = Field(default=None, min_length=1)
description: str | 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):
"""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
+13 -2
View File
@@ -77,13 +77,14 @@ def project_brief(project: Project | None) -> dict | None:
return {"id": str(project.id), "name": project.name}
def decision_brief(d: ItemDecision) -> dict:
def decision_brief(d: ItemDecision, project: Project | None = None) -> dict:
"""Shape used inside expanded line items (P0-14)."""
return {
"id": str(d.id),
"status": d.status,
"chosen_item_termsheet_id": str(d.chosen_item_termsheet_id) if d.chosen_item_termsheet_id else None,
"project_id": str(d.project_id) if d.project_id else None, # v2.6: the single purchased project
"project": project_brief(project) if project is not None else None,
"adjusted_unit_price": float(d.adjusted_unit_price) if d.adjusted_unit_price is not None else None,
"decided_at": to_iso(d.decided_at),
}
@@ -313,6 +314,7 @@ class _LineItemData:
self.item_termsheets_by_line: dict[uuid.UUID, list[ItemTermsheet]] = {}
self.item_termsheets_by_id: dict[uuid.UUID, ItemTermsheet] = {}
self.projects_by_line: dict[uuid.UUID, list[Project]] = {}
self.projects_by_id: dict[uuid.UUID, Project] = {}
async def _load_line_item_data(db: AsyncSession, line_items: list[LineItem], expand: set[str]) -> _LineItemData:
@@ -375,6 +377,13 @@ async def _load_line_item_data(db: AsyncSession, line_items: list[LineItem], exp
if "decision" in expand or "receipt" in expand:
decisions = (await db.execute(select(ItemDecision).where(ItemDecision.line_item_id.in_(li_ids)))).scalars().all()
data.decisions = {d.line_item_id: d for d in decisions}
dec_proj_ids = {d.project_id for d in decisions if d.project_id is not None}
missing_pids = dec_proj_ids - set(proj_map.keys())
if missing_pids:
extra_projs = (await db.execute(select(Project).where(Project.id.in_(missing_pids)))).scalars().all()
for p in extra_projs:
proj_map[p.id] = p
data.projects_by_id = proj_map
dec_ids = [d.id for d in decisions]
if dec_ids:
receipts = (await db.execute(select(ItemReceipt).where(ItemReceipt.item_decision_id.in_(dec_ids)))).scalars().all()
@@ -469,8 +478,10 @@ def _line_item_expanded(li: LineItem, expand: set[str], data: _LineItemData, inc
data.specs_by_line.get(li.id, []), data.attr_defs, data.units, include_data_type=False
)
decision = data.decisions.get(li.id)
dec_proj = data.projects_by_id.get(decision.project_id) if decision and decision.project_id else None
d["target_project"] = project_brief(dec_proj) if dec_proj is not None else None
if "decision" in expand:
d["decision"] = decision_brief(decision) if decision is not None else None
d["decision"] = decision_brief(decision, dec_proj) if decision is not None else None
# chosen_item_termsheet_id یک id از جدول item_termsheets است؛ برای مقایسه با
# termsheet_ids در جلوی برنامه، id ترم‌شیت واقعی هم ارسال می‌شود.
if decision is not None and decision.chosen_item_termsheet_id is not None:
+39 -4
View File
@@ -279,13 +279,23 @@ async def update_quote(db: AsyncSession, quote_id: UUID, payload: QuoteUpdate, a
return quote
async def quote_has_receipts(db: AsyncSession, quote_id: UUID) -> bool:
"""Returns True if any warehouse receipt has been posted for the quote's items."""
count = await db.scalar(
select(func.count(ItemReceipt.id))
.join(ItemDecision, ItemReceipt.item_decision_id == ItemDecision.id)
.join(LineItem, ItemDecision.line_item_id == LineItem.id)
.where(LineItem.quote_id == quote_id, ItemReceipt.received_qty > 0)
)
return (count or 0) > 0
async def replace_quote_children(
db: AsyncSession, quote_id: UUID, payload: QuoteChildrenReplaceIn, actor: str | None = None
) -> Quote:
"""Full replacement of a quote's line items + termsheets (edit modal).
Only allowed while the quote is still offered — acceptance data
(decisions/receipts) would otherwise be destroyed.
Only allowed while the quote is still offered and has no warehouse receipts.
"""
async with db.begin():
quote = await get_quote_or_404(db, quote_id)
@@ -294,6 +304,11 @@ async def replace_quote_children(
"برای تغییر اقلام یا شرایط پرداخت، ابتدا وضعیت را به «پیشنهاد شده» برگردانید",
field="status",
)
if await quote_has_receipts(db, quote.id):
raise ConflictError(
"این پیشنهاد به‌دلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.",
field="status",
)
# children cascade via DDL: item_termsheets, item_decisions, item_receipts, schedule_steps
await db.execute(
delete(LineItem).where(LineItem.quote_id == quote.id).execution_options(synchronize_session=False)
@@ -312,6 +327,12 @@ async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: s
if new_status not in BACKWARD_TRANSITIONS.get(current, set()):
raise ConflictError("انتقال وضعیت مجاز نیست — فقط انتقال‌های عقب‌گرد پذیرفته می‌شود", field="status")
if await quote_has_receipts(db, quote.id):
raise ConflictError(
"این پیشنهاد به‌دلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.",
field="status",
)
if new_status == "offered":
# Reopen: clear lifecycle dates, demote accepted termsheets, reset decisions.
quote.accepted_date = None
@@ -332,10 +353,11 @@ async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: s
chosen_item_termsheet_id=None,
adjusted_unit_price=None,
adjusted_notes=None,
project_id=None,
)
.execution_options(synchronize_session=False)
)
# item_receipts are deliberately NOT deleted (contract P0-17).
# LineItemProjects (candidate projects) are preserved untouched!
elif new_status == "accepted": # arrived -> accepted
quote.arrived_date = None
@@ -692,7 +714,8 @@ async def expire_quote(db: AsyncSession, quote_id: UUID, actor: str | None = Non
async def delete_quote(db: AsyncSession, quote_id: UUID) -> dict:
async with db.begin():
tx = db.begin_nested() if db.in_transaction() else db.begin()
async with tx:
quote = await get_quote_or_404(db, quote_id)
if quote.status in ("accepted", "arrived"):
raise ConflictError("پیشنهادهای پذیرفته‌شده یا رسیده قابل حذف نیستند")
@@ -712,6 +735,17 @@ async def delete_quote(db: AsyncSession, quote_id: UUID) -> dict:
termsheets = (await db.execute(select(Termsheet).where(Termsheet.quote_id == quote.id))).scalars().all()
events = (await db.execute(select(QuoteEvent).where(QuoteEvent.quote_id == quote.id))).scalars().all()
# clean up attachments in MinIO before cascade
from app.models.files import QuoteAttachment
from app.services import storage
attachments = (await db.execute(select(QuoteAttachment).where(QuoteAttachment.quote_id == quote.id))).scalars().all()
for att in attachments:
try:
await storage.remove_object(att.object_key)
except Exception:
logger.warning("Failed to remove MinIO object %s for attachment %s", att.object_key, att.id)
await db.delete(quote)
return {
"id": str(quote.id),
@@ -722,6 +756,7 @@ async def delete_quote(db: AsyncSession, quote_id: UUID) -> dict:
"events": len(events),
"decisions": len(decisions),
"receipts": len(receipts),
"attachments": len(attachments),
},
}
+14 -51
View File
@@ -1,7 +1,6 @@
services:
postgres:
image: postgres:18
container_name: procurement-postgres
environment:
POSTGRES_DB: procurement
@@ -9,7 +8,7 @@ services:
POSTGRES_PASSWORD: postgres
ports:
- "5433:5432"
- "5435:5432"
volumes:
- postgres_data:/var/lib/postgresql
@@ -24,24 +23,13 @@ services:
restart: unless-stopped
# ==========================================================================
# Traefik v3 — public reverse proxy (replaces nginx as the edge)
# App is served on http://localhost (port 80) instead of :3000.
# Production: see docker-compose.prod.yml (real domains + HTTPS via
# Let's Encrypt, HTTP→HTTPS redirect).
# ==========================================================================
traefik:
# v3.6+ required: Traefik ≤3.3 fails against Docker Engine 29.x
# ("Error response from daemon" — Docker API version mismatch).
image: traefik:v3.6
container_name: procurement-traefik
restart: unless-stopped
command:
- --providers.docker=true
- --providers.docker.exposedbydefault=false
# Containers are on several networks; Traefik must use the dedicated
# proxy network IP (otherwise it may pick the default-network IP → 504).
- --providers.docker.network=traefik_network
- --providers.docker.network=erp_production_network
- --providers.file.filename=/etc/traefik/dynamic.yml
- --entrypoints.web.address=:80
- --entrypoints.websecure.address=:443
@@ -51,11 +39,9 @@ services:
- --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web
- --log.level=INFO
ports:
- "80:80"
- "443:443"
- "8084:80"
- "8445:443"
volumes:
# NOTE: mounted read-write — Docker Desktop rejects read-only
# /var/run/docker.sock mounts ("Error response from daemon").
- /var/run/docker.sock:/var/run/docker.sock
- ./infra/traefik-dynamic.yml:/etc/traefik/dynamic.yml:ro
- traefik_certs:/certs
@@ -64,7 +50,6 @@ services:
minio:
image: minio/minio:latest
container_name: procurement-minio
command: server /data --console-address ":9001"
@@ -73,20 +58,18 @@ services:
MINIO_ROOT_PASSWORD: minioadmin
ports:
- "9000:9000" # S3 API
- "9001:9001" # Web console
- "9004:9000" # S3 API
- "9005:9001" # Web console
volumes:
- minio_data:/data
labels:
- traefik.enable=true
# S3 API
- traefik.http.routers.minio-api.rule=Host(`${S3_HOST:-s3.localhost}`)
- traefik.http.routers.minio-api.entrypoints=web
- traefik.http.routers.minio-api.service=minio-api-svc
- traefik.http.services.minio-api-svc.loadbalancer.server.port=9000
# Web console
- traefik.http.routers.minio-console.rule=Host(`${STORAGE_HOST:-storage.localhost}`)
- traefik.http.routers.minio-console.entrypoints=web
- traefik.http.routers.minio-console.service=minio-console-svc
@@ -98,11 +81,8 @@ services:
restart: unless-stopped
# MinIO Client (mc): waits for the server, creates the bucket, exits.
# Use for ad-hoc ops too: docker compose run --rm minio-init ...
minio-init:
image: minio/mc:latest
container_name: procurement-minio-init
depends_on:
minio:
@@ -125,20 +105,8 @@ services:
restart: "no"
# ==========================================================================
# v2.6 — Casdoor (OIDC identity provider)
# Console: http://localhost:8080 (admin / 123)
# Application "inventory-app" (org "inventory", client inventory-app-client)
# is created by backend/tools/seed_casdoor.py — run it once after the
# first boot:
# docker compose run --rm -e CASDOOR_BASE=http://casdoor:8000 api python tools/seed_casdoor.py
#
# Casdoor uses the SAME postgres instance as the app (database `casdoor`,
# created by sql/02-casdoor-db.sql on a fresh volume).
# ==========================================================================
casdoor:
image: casbin/casdoor:latest
container_name: procurement-casdoor
depends_on:
postgres:
condition: service_healthy
@@ -147,9 +115,9 @@ services:
driverName: postgres
dataSourceName: "user=casdoor password=casdoor host=postgres port=5432 sslmode=disable dbname=casdoor"
dbName: casdoor
origin: "http://localhost:8080"
origin: "http://localhost:8083"
ports:
- "8080:8000"
- "8083:8000"
labels:
- traefik.enable=true
@@ -167,7 +135,6 @@ services:
build:
context: ./backend
dockerfile: Dockerfile
container_name: procurement-api
environment:
PROCUREMENT_DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/procurement
@@ -176,21 +143,18 @@ services:
PROCUREMENT_MINIO_SECRET_KEY: minioadmin
PROCUREMENT_MINIO_BUCKET: procurement-files
PROCUREMENT_MINIO_SECURE: "false"
# Casdoor: endpoint is the in-network service URL; the issuer must match
# the origin Casdoor advertises to browsers (its OIDC discovery doc).
PROCUREMENT_CASDOOR_ENDPOINT: http://casdoor:8000
PROCUREMENT_CASDOOR_ISSUER: http://localhost:8080
PROCUREMENT_CASDOOR_ISSUER: http://localhost:8083
PROCUREMENT_CASDOOR_CLIENT_ID: inventory-app-client
PROCUREMENT_CASDOOR_CLIENT_SECRET: inventory-app-secret-2026
PROCUREMENT_CASDOOR_ORGANIZATION: inventory
PROCUREMENT_CASDOOR_APP_NAME: inventory-app
PROCUREMENT_CASDOOR_AUDIENCE: inventory-app-client
# Console admin used by POST /users/sync (identity mirroring).
PROCUREMENT_CASDOOR_ADMIN_USER: admin
PROCUREMENT_CASDOOR_ADMIN_PASSWORD: "123"
ports:
- "8000:8000"
- "8001:8000"
labels:
- traefik.enable=true
@@ -214,9 +178,10 @@ services:
build:
context: ./frontend
args:
# Developer Mode panel on the login page — set false for production.
DEV_LOGIN_ENABLED: ${DEV_LOGIN_ENABLED:-true}
container_name: procurement-frontend
ports:
- "8085:80"
labels:
- traefik.enable=true
@@ -234,10 +199,8 @@ services:
restart: unless-stopped
networks:
# شبکه‌ی عمومی: فقط ترافیک و سرویس‌های عمومی. postgres روی شبکه‌ی
# پیش‌فرض می‌ماند و از بیرون قابل دسترسی نیست.
traefik_network:
name: traefik_network
name: erp_production_network
driver: bridge
volumes:
+1 -1
View File
@@ -35,7 +35,7 @@ to re-run).
## 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
+15
View File
@@ -2,6 +2,8 @@ import { lazy, Suspense } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { useAuthStore } from "./store/auth";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { RequirePmProjects } from "./components/RequirePmProjects";
import { MobileTabBar } from "./components/layout/MobileTabBar";
// هر صفحه با React.lazy لود می‌شود (code splitting)
const DashboardPage = lazy(() =>
@@ -141,7 +143,9 @@ export default function App() {
path="/inventory"
element={
<RequireAuth>
<RequirePmProjects>
<InventoryPage />
</RequirePmProjects>
</RequireAuth>
}
/>
@@ -149,7 +153,9 @@ export default function App() {
path="/inventory/arrivals"
element={
<RequireAuth>
<RequirePmProjects>
<ArrivalsPage />
</RequirePmProjects>
</RequireAuth>
}
/>
@@ -157,7 +163,9 @@ export default function App() {
path="/inventory/change"
element={
<RequireAuth>
<RequirePmProjects>
<ChangeInventoryPage />
</RequirePmProjects>
</RequireAuth>
}
/>
@@ -165,7 +173,9 @@ export default function App() {
path="/inventory/daily"
element={
<RequireAuth>
<RequirePmProjects>
<DailyControlPage />
</RequirePmProjects>
</RequireAuth>
}
/>
@@ -173,7 +183,9 @@ export default function App() {
path="/inventory/warehouses"
element={
<RequireAuth>
<RequirePmProjects>
<WarehousesPage />
</RequirePmProjects>
</RequireAuth>
}
/>
@@ -181,7 +193,9 @@ export default function App() {
path="/inventory/org"
element={
<RequireAuth>
<RequirePmProjects>
<OrganizationPage />
</RequirePmProjects>
</RequireAuth>
}
/>
@@ -196,6 +210,7 @@ export default function App() {
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
<MobileTabBar />
</ErrorBoundary>
);
}
+23 -3
View File
@@ -1,9 +1,14 @@
import { PanelMenu } from "./PanelMenu";
import { useLocation } from "react-router-dom";
import { PanelMenu, type MenuItem } from "./PanelMenu";
import { useLogout } from "../pages/Inventory/panelMenu";
import { useAuthStore } from "../store/auth";
// منوی همبرگری مشترک بین دو ماژول (پنل انبار / دفتر تدارکات) —
// دقیقاً مثل منوی دفتر: عنوان «پنل‌ها»، ماژول فعلی با استایل فعال (پر)
// + «✓ فعلی»، ماژول دیگر به‌عنوان سوییچر، و در پایان جداکننده + خروج.
// مدیر پروژه (1:N): زیر «مدیریت انبار» یک آکاردئون باز می‌شود که همه‌ی
// پروژه‌های تخصیص‌یافته را نشان می‌دهد؛ پروژه‌ی فعال با «✓ فعلی» مشخص است و
// انتخاب هر پروژه فقط «پروژه‌ی فعال» را عوض می‌کند (نشست/توکن دست‌نخورده).
export function AppSwitcherMenu({
activeModule,
canSwitchToProcurement = true,
@@ -12,13 +17,28 @@ export function AppSwitcherMenu({
canSwitchToProcurement?: boolean; // مدیر پروژه به دفتر تدارکات دسترسی ندارد
}) {
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") {
items.push({ label: "مدیریت انبار", to: "/inventory" });
items.push({ label: "مدیریت تدارکات", to: "/", active: true });
} else {
items.push({ label: "مدیریت انبار", to: "/inventory", active: true });
items.push({ label: "مدیریت انبار", to: "/inventory", active: true, children: projectChildren });
if (canSwitchToProcurement) {
items.push({ label: "مدیریت تدارکات", to: "/" });
}
+193 -16
View File
@@ -1,5 +1,6 @@
import { useRef } from "react";
import { useRef, useState } from "react";
import { toast } from "sonner";
import { Eye, Download, Trash2, X, FileText, Image as ImageIcon, ExternalLink } from "lucide-react";
import type { Attachment } from "../lib/types";
export interface FileListProps {
@@ -14,9 +15,18 @@ export interface FileListProps {
label?: string;
}
// مدیریت چند فاکتور: آپلود مستقیم به سرور (MinIO) + دانلود/حذف فایل‌های ذخیره‌شده
const isImageFile = (name: string = "") => {
return /\.(jpe?g|png|webp|gif|svg|bmp)$/i.test(name.toLowerCase());
};
const isPdfFile = (name: string = "") => {
return /\.pdf$/i.test(name.toLowerCase());
};
// مدیریت چند فاکتور: آپلود مستقیم، پیش‌نمایش آنلاین تصویر/PDF و دانلود
export function FileList({ files, onChange, upload, onDelete, urlOf, label = "افزودن فاکتور" }: FileListProps) {
const inputRef = useRef<HTMLInputElement | null>(null);
const [previewItem, setPreviewItem] = useState<{ url: string; name: string } | null>(null);
const remove = (i: number) => {
const removed = files[i];
@@ -39,22 +49,94 @@ export function FileList({ files, onChange, upload, onDelete, urlOf, label = "ا
if (added.length) onChange([...files, ...added]);
};
const handleOpenPreview = (inv: Attachment) => {
const url = urlOf?.(inv);
if (!url) {
toast.error("فایل هنوز به طور کامل در سرور ذخیره نشده است");
return;
}
setPreviewItem({ url, name: inv.name });
};
return (
<div>
{!files.length && <div className="inv-empty">هنوز فایلی انتخاب نشده است.</div>}
{files.map((inv, i) => (
<div key={i} className="inv-row">
<span className="inv-name">📎 {inv.name}</span>
{urlOf?.(inv) && (
<a className="inv-open" href={urlOf(inv)!} download={inv.name} title="دانلود">
↓
</a>
<div className="space-y-2">
{!files.length && (
<div className="rounded-lg border border-dashed border-gray-200 bg-gray-50/60 p-3 text-center text-xs text-gray-500">
هنوز فاکتور یا سندی بارگذاری نشده است.
</div>
)}
<button type="button" className="inv-x" title="حذف" onClick={() => remove(i)}>
×
{files.length > 0 && (
<div className="flex flex-col gap-1.5">
{files.map((inv, i) => {
const url = urlOf?.(inv);
const isImg = isImageFile(inv.name);
const isPdf = isPdfFile(inv.name);
return (
<div
key={inv.id || i}
className="flex items-center justify-between rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs transition-colors hover:border-gray-300 shadow-xs"
>
{/* نام و آیکون فایل — با قابلیت کلیک برای پیش‌نمایش */}
<div
className={`flex items-center gap-2 min-w-0 flex-1 ${url ? "cursor-pointer group" : ""}`}
onClick={() => url && handleOpenPreview(inv)}
title={url ? "مشاهده پیش‌نمایش" : inv.name}
>
{isImg ? (
<ImageIcon className="h-4 w-4 shrink-0 text-blue-600" />
) : isPdf ? (
<FileText className="h-4 w-4 shrink-0 text-rose-600" />
) : (
<FileText className="h-4 w-4 shrink-0 text-gray-400" />
)}
<span className="truncate font-medium text-gray-700 group-hover:text-blue-600 transition-colors">
{inv.name}
</span>
</div>
{/* دکمه‌های عملیات: پیش‌نمایش، دانلود مستقیم، حذف */}
<div className="flex items-center gap-1 shrink-0 me-1">
{url && (
<>
<button
type="button"
onClick={() => handleOpenPreview(inv)}
className="flex h-7 w-7 items-center justify-center rounded-md text-gray-500 hover:bg-blue-50 hover:text-blue-600 transition-colors"
title="مشاهده فاکتور"
>
<Eye className="h-3.5 w-3.5" />
</button>
<a
href={url}
download={inv.name}
target="_blank"
rel="noreferrer"
className="flex h-7 w-7 items-center justify-center rounded-md text-gray-500 hover:bg-gray-100 hover:text-gray-900 transition-colors"
title="دانلود فایل"
>
<Download className="h-3.5 w-3.5" />
</a>
</>
)}
<button
type="button"
className="flex h-7 w-7 items-center justify-center rounded-md text-gray-400 hover:bg-red-50 hover:text-red-600 transition-colors"
title="حذف فایل"
onClick={() => remove(i)}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
);
})}
</div>
)}
<div>
<input
ref={inputRef}
@@ -64,10 +146,105 @@ export function FileList({ files, onChange, upload, onDelete, urlOf, label = "ا
className="hidden"
onChange={handleChange}
/>
<button type="button" className="btn-line-file" onClick={() => inputRef.current?.click()}>
+ {label}
<button
type="button"
className="inline-flex items-center gap-1.5 rounded-lg border border-dashed border-gray-300 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 hover:border-blue-500 hover:bg-blue-50/40 hover:text-blue-600 transition-all"
onClick={() => inputRef.current?.click()}
>
<span>+</span>
<span>{label}</span>
</button>
</div>
{/* پنجره مودال پیش‌نمایش (Preview Modal) */}
{previewItem && (
<div
className="fixed inset-0 z-[80] flex items-center justify-center bg-black/60 p-3 sm:p-5 backdrop-blur-xs animate-in fade-in duration-200"
onClick={() => setPreviewItem(null)}
>
<div
className="flex flex-col w-full max-w-4xl max-h-[90vh] rounded-2xl border border-gray-200 bg-white shadow-2xl overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* هدر پیش‌نمایش */}
<div className="flex items-center justify-between border-b border-gray-100 px-4 py-3 bg-white">
<div className="flex items-center gap-2 min-w-0">
{isImageFile(previewItem.name) ? (
<ImageIcon className="h-4 w-4 shrink-0 text-blue-600" />
) : (
<FileText className="h-4 w-4 shrink-0 text-rose-600" />
)}
<span className="text-xs sm:text-sm font-bold text-gray-900 truncate">
{previewItem.name}
</span>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<a
href={previewItem.url}
download={previewItem.name}
className="inline-flex items-center gap-1 rounded-lg border border-gray-200 bg-white px-2.5 py-1 text-xs font-semibold text-gray-700 hover:bg-gray-50 hover:text-gray-900 transition-colors shadow-2xs"
title="دانلود فاکتور"
>
<Download className="h-3.5 w-3.5" />
<span className="hidden xs:inline">دانلود فاکتور</span>
</a>
<a
href={previewItem.url}
target="_blank"
rel="noreferrer"
className="flex h-7 w-7 items-center justify-center rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 hover:text-gray-900 transition-colors shadow-2xs"
title="باز کردن در برگه جدید"
>
<ExternalLink className="h-3.5 w-3.5" />
</a>
<button
type="button"
onClick={() => setPreviewItem(null)}
className="flex h-7 w-7 items-center justify-center rounded-lg text-gray-400 hover:bg-gray-100 hover:text-gray-700 transition-colors ms-1"
title="بستن"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
{/* بدنه نمایش فایل */}
<div className="flex-1 overflow-auto p-3 sm:p-4 bg-gray-50/70 flex items-center justify-center min-h-[300px]">
{isImageFile(previewItem.name) ? (
<img
src={previewItem.url}
alt={previewItem.name}
className="max-h-[75vh] w-auto max-w-full rounded-lg object-contain shadow-xs border border-gray-200 bg-white"
/>
) : isPdfFile(previewItem.name) ? (
<iframe
src={previewItem.url}
title={previewItem.name}
className="h-[75vh] w-full rounded-lg border border-gray-200 bg-white shadow-xs"
/>
) : (
<div className="flex flex-col items-center justify-center gap-3 p-8 text-center bg-white rounded-xl border border-gray-200 shadow-xs">
<FileText className="h-12 w-12 text-gray-400" />
<div className="text-xs text-gray-600">
امکان نمایش پیش‌نمایش زنده برای این فرمت فایل وجود ندارد.
</div>
<a
href={previewItem.url}
download={previewItem.name}
className="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-3.5 py-1.5 text-xs font-semibold text-white hover:bg-blue-700 transition-colors"
>
<Download className="h-3.5 w-3.5" />
دانلود فایل
</a>
</div>
)}
</div>
</div>
</div>
)}
</div>
);
}
+67 -14
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import {
daysInMonth,
gregorianToJalali,
@@ -14,11 +14,12 @@ import { cn } from "../lib/utils";
import { useClickOutside } from "./useClickOutside";
export interface JalaliDatePickerProps {
value: string | null; // ISO date (با time اختیاری)
onChange: (iso: string | null) => void;
value: string | null; // ISO date (یا YYYY-MM-DD با time اختیاری)
onChange: (val: string | null) => void;
placeholder?: string;
className?: string;
withTime?: boolean; // نمایش ساعت:دقیقه هم (پیشنهاد/پذیرش/رسید)
disabled?: boolean;
}
function todayJalali(): [number, number, number] {
@@ -28,6 +29,13 @@ function todayJalali(): [number, number, number] {
function isoToJalali(iso: string | null): [number, number, number] | null {
if (!iso) return null;
const m = String(iso).match(/^(\d{4})-(\d{2})-(\d{2})/);
if (m) {
const gy = parseInt(m[1], 10);
const gm = parseInt(m[2], 10);
const gd = parseInt(m[3], 10);
return gregorianToJalali(gy, gm, gd);
}
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return null;
return gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate());
@@ -39,6 +47,12 @@ function buildIso(jy: number, jm: number, jd: number, h: number, m: number): str
return new Date(gy, gm - 1, gd, h, m).toISOString();
}
// ساخت تاریخ استاندارد YYYY-MM-DD بدون مؤلفه زمان
function buildDateOnly(jy: number, jm: number, jd: number): string {
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
return `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
}
// وقتی هنوز زمان مشخصی ست نشده، ساعت/دقیقه‌ی فعلی سیستم پیش‌فرض است
// (نه ۰۰:۰۰) — دکمه‌ی «امروز» و انتخاب روز، همین زمان را نگه می‌دارند.
function nowTime(): { h: number; m: number } {
@@ -60,6 +74,7 @@ export function JalaliDatePicker({
placeholder,
className,
withTime = false,
disabled = false,
}: JalaliDatePickerProps) {
const [open, setOpen] = useState(false);
const [curJ, setCurJ] = useState<[number, number, number]>(() => isoToJalali(value) ?? todayJalali());
@@ -74,11 +89,35 @@ export function JalaliDatePicker({
return `${j} ${faNum(hh + ":" + mm)}`;
});
useEffect(() => {
if (!value) {
setText("");
return;
}
const j = isoToJalaliStr(value);
if (!withTime) {
setText(j);
} else {
const d = new Date(value);
if (Number.isNaN(d.getTime())) {
setText(j);
} else {
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
setText(`${j} ${faNum(hh + ":" + mm)}`);
}
}
const jParts = isoToJalali(value);
if (jParts) setCurJ(jParts);
}, [value, withTime]);
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
// تاریخ انتخاب‌شده به صورت محلی (نه UTC) برای مقایسه با سلول‌های تقویم
function selectedLocalDate(): string | null {
if (!value) return null;
const m = String(value).match(/^(\d{4})-(\d{2})-(\d{2})/);
if (m) return `${m[1]}-${m[2]}-${m[3]}`;
const d = new Date(value);
if (Number.isNaN(d.getTime())) return null;
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
@@ -124,9 +163,11 @@ export function JalaliDatePicker({
};
const selectDay = (jy: number, jm: number, jd: number) => {
const isoWithTime = buildIso(jy, jm, jd, time.h, time.m);
onChange(isoWithTime);
setText(`${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))}${withTime ? " " + faNum(String(time.h).padStart(2, "0") + ":" + String(time.m).padStart(2, "0")) : ""}`);
const val = withTime ? buildIso(jy, jm, jd, time.h, time.m) : buildDateOnly(jy, jm, jd);
onChange(val);
setText(
`${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))}${withTime ? " " + faNum(String(time.h).padStart(2, "0") + ":" + String(time.m).padStart(2, "0")) : ""}`
);
setOpen(false);
};
@@ -145,11 +186,10 @@ export function JalaliDatePicker({
const pickToday = () => {
const now = new Date();
const [jy, jm, jd] = todayJalali();
// بدون withTime فقط تاریخ امروز ثبت می‌شود — ساعت همیشه نیمه‌شب
const isoWithTime = withTime
const val = withTime
? buildIso(jy, jm, jd, now.getHours(), now.getMinutes())
: buildIso(jy, jm, jd, 0, 0);
onChange(isoWithTime);
: buildDateOnly(jy, jm, jd);
onChange(val);
setText(
withTime
? `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))} ${faNum(String(now.getHours()).padStart(2, "0") + ":" + String(now.getMinutes()).padStart(2, "0"))}`
@@ -168,7 +208,19 @@ export function JalaliDatePicker({
setText(v);
const iso = jalaliStrToIso(v);
if (iso) {
if (!withTime) {
const nums = String(v)
.split(/[/\-.]/)
.map((x) => parseInt(String(x).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))), 10));
if (nums.length >= 3 && !nums.some((n) => Number.isNaN(n))) {
const [gy, gm, gd] = jalaliToGregorian(nums[0], nums[1], nums[2]);
onChange(`${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`);
} else {
onChange(iso.slice(0, 10));
}
} else {
onChange(iso);
}
const j = isoToJalali(iso);
if (j) setCurJ([j[0], j[1], j[2]]);
}
@@ -178,15 +230,16 @@ export function JalaliDatePicker({
<div ref={ref} className={cn("relative", className)}>
<input
type="text"
className="flex h-9 w-full rounded-md border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
disabled={disabled}
className="flex h-9 w-full rounded-md border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
value={text}
placeholder={placeholder ?? "۱۴۰۳/۰۵/۱۲"}
autoComplete="off"
onFocus={() => setOpen(true)}
onClick={() => setOpen(true)}
onFocus={() => !disabled && setOpen(true)}
onClick={() => !disabled && setOpen(true)}
onChange={(e) => onInputChange(e.target.value)}
/>
{open && (
{open && !disabled && (
<div
className={cn(
"absolute inset-x-0 top-full z-30 mt-1 rounded-md border border-line bg-panel shadow-md",
+21 -5
View File
@@ -6,29 +6,45 @@ export interface MilestoneRowProps {
milestone: Milestone;
onChange: (m: Milestone) => void;
onRemove: () => void;
disabled?: boolean;
}
// یک ردیف مرحله‌ی پرداخت: تاریخ + مبلغ + دکمه‌ی حذف
export function MilestoneRow({ milestone, onChange, onRemove }: MilestoneRowProps) {
export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }: MilestoneRowProps) {
return (
<div className="milestone-row">
<div className="flex items-start gap-3 w-full my-2">
{/* کادر انتخاب تاریخ */}
<div className="w-36 shrink-0">
<JalaliDatePicker
value={milestone.when_iso}
onChange={(iso) => onChange({ ...milestone, when_iso: iso })}
placeholder="تاریخ"
className="w-36 shrink-0"
disabled={disabled}
/>
<div className="ms-amount-wrap">
</div>
{/* کادر ورود مبلغ و حروف زیر آن */}
<div className="flex-1 min-w-0">
<NumberInput
value={milestone.amount}
onChange={(amount) => onChange({ ...milestone, amount })}
placeholder="مبلغ (تومان)"
showWords
disabled={disabled}
/>
</div>
<button type="button" className="ms-x" title="حذف" onClick={onRemove}>
{/* دکمه حذف */}
{!disabled && (
<button
type="button"
className="mt-2 text-red-500 hover:text-red-700 text-lg font-bold shrink-0 px-1 transition-colors"
title="حذف"
onClick={onRemove}
>
×
</button>
)}
</div>
);
}
@@ -12,6 +12,7 @@ export interface MultiSelectComboboxProps {
className?: string;
inputClassName?: string;
allowCreate?: boolean; // اجازه‌ی تایپ مقدار جدید (پیش‌فرض true)
disabled?: boolean;
}
// چند-انتخابی خلاق (creatable multi-select) با چیپ‌ها:
@@ -27,6 +28,7 @@ export function MultiSelectCombobox({
className,
inputClassName,
allowCreate = true,
disabled = false,
}: MultiSelectComboboxProps) {
const [text, setText] = useState("");
const [open, setOpen] = useState(false);
@@ -105,12 +107,16 @@ export function MultiSelectCombobox({
return (
<div ref={ref} className={cn("relative", className)}>
<div
className="flex min-h-9 w-full flex-wrap items-center gap-1 rounded-lg border border-line bg-panel px-2 py-1 shadow-sm transition-colors focus-within:ring-2 focus-within:ring-accent/40"
onClick={() => inputRef.current?.focus()}
className={cn(
"flex min-h-9 w-full flex-wrap items-center gap-1 rounded-lg border border-line bg-panel px-2 py-1 shadow-sm transition-colors focus-within:ring-2 focus-within:ring-accent/40",
disabled && "cursor-not-allowed opacity-60 bg-canvas/40",
)}
onClick={() => !disabled && inputRef.current?.focus()}
>
{values.map((v) => (
<span key={v} className="spec-chip">
<span className="max-w-[140px] truncate">{v}</span>
{!disabled && (
<button
type="button"
className="spec-chip-x"
@@ -122,29 +128,35 @@ export function MultiSelectCombobox({
>
×
</button>
)}
</span>
))}
<input
ref={inputRef}
type="text"
disabled={disabled}
className={cn(
"min-w-[80px] flex-1 border-0 bg-transparent px-0.5 py-0.5 text-[12px] text-ink caret-accent outline-none placeholder:text-muted",
"min-w-[80px] flex-1 border-0 bg-transparent px-0.5 py-0.5 text-[12px] text-ink caret-accent outline-none placeholder:text-muted disabled:cursor-not-allowed",
inputClassName,
)}
value={text}
placeholder={values.length ? "" : placeholder}
autoComplete="off"
onChange={(e) => {
if (disabled) return;
setText(e.target.value);
setHighlight(-1);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onKeyDown={onKeyDown}
onFocus={() => !disabled && setOpen(true)}
onKeyDown={(e) => {
if (disabled) return;
onKeyDown(e);
}}
/>
</div>
{open && (
{open && !disabled && (
<div className="absolute inset-x-0 top-full z-30 mt-1 max-h-56 overflow-y-auto rounded-lg border border-line bg-panel py-1 shadow-lg">
{listCount === 0 && (
<div className="px-3 py-1.5 text-[12px] text-muted">
+4 -1
View File
@@ -10,6 +10,7 @@ export interface NumberInputProps {
showWords?: boolean; // نمایش معادل فارسی زیر input
inputMode?: "numeric" | "decimal" | undefined;
maxDecimals?: number; // حداکثر رقم اعشار (مثلاً ۲ برای قیمت واحد)
disabled?: boolean;
}
// ورودی عددی با فرمت‌دهی خودکار (groupCommas) و نمایش کلمات فارسی
@@ -21,6 +22,7 @@ export function NumberInput({
showWords = false,
inputMode = "numeric",
maxDecimals,
disabled = false,
}: NumberInputProps) {
// فرمت با رعایت حداکثر اعشار (گرد کردن)
const formatValue = (n: number): string => {
@@ -62,7 +64,8 @@ export function NumberInput({
<div className={cn("min-w-0", className)}>
<input
type="text"
className="num-input h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
disabled={disabled}
className="num-input h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
value={text}
placeholder={placeholder}
inputMode={inputMode}
+75 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { Menu, X } from "lucide-react";
import { ChevronDown, Menu, X } from "lucide-react";
import { cn } from "../lib/utils";
export interface MenuItem {
@@ -9,11 +9,14 @@ export interface MenuItem {
active?: boolean;
divider?: boolean;
onClick?: () => void;
// آیتم بازشو (آکاردئونی) — مثلاً فهرست پروژه‌های مدیر پروژه زیر «مدیریت انبار»
children?: MenuItem[];
}
// منوی همبرگری — بالای راست صفحات؛ نمایش پنل فعلی + ناوبری بین پنل‌ها
export function PanelMenu({ items, title }: { items: MenuItem[]; title?: string }) {
const [open, setOpen] = useState(false);
const [expanded, setExpanded] = useState<number | null>(null);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -42,11 +45,81 @@ export function PanelMenu({ items, title }: { items: MenuItem[]; title?: string
{items.map((it, i) =>
it.divider ? (
<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 ? (
<Link
key={i}
to={it.to}
onClick={() => setOpen(false)}
onClick={() => {
setOpen(false);
it.onClick?.();
}}
className={cn(
"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",
@@ -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}</>;
}
+102 -18
View File
@@ -1,38 +1,122 @@
import { ClipboardList, Inbox, CheckCircle2, Timer, type LucideIcon } from "lucide-react";
import { ClipboardList, Inbox, CheckCircle2, PackageCheck, type LucideIcon } from "lucide-react";
import { fmtNum } from "../lib/formatters";
export type StatusFilter = "all" | "offered" | "accepted" | "arrived";
export interface StatsBarProps {
total: number;
offered: number;
accepted: number;
avgLeadTime: number | null; // روز
arrived: number;
activeFilter?: StatusFilter;
onFilterChange?: (filter: StatusFilter) => void;
}
// نوار ۴ ستونه‌ی آمار
export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProps) {
const stats: { label: string; value: string; color: string; icon: LucideIcon }[] = [
{ label: "کل پیشنهادها", value: fmtNum(total), color: "text-ink", icon: ClipboardList },
// پیشنهاد باز = نارنجی، پذیرفته‌شده = آبی، رسیده = سبز
{ label: "پیشنهاد باز", value: fmtNum(offered), color: "text-amber", icon: Inbox },
{ label: "پذیرفته‌شده", value: fmtNum(accepted), color: "text-[#1d4ed8]", icon: CheckCircle2 },
{ label: "میانگین lead time (روز)", value: avgLeadTime == null ? "—" : fmtNum(avgLeadTime), color: "text-green", icon: Timer },
export function StatsBar({
total,
offered,
accepted,
arrived,
activeFilter = "all",
onFilterChange,
}: StatsBarProps) {
const stats: {
key: StatusFilter;
label: string;
shortLabel: string;
value: string;
unit?: string;
color: string;
icon: LucideIcon;
}[] = [
{
key: "all",
label: "کل پیشنهادها",
shortLabel: "کل",
value: fmtNum(total),
color: "text-ink",
icon: ClipboardList,
},
{
key: "offered",
label: "پیشنهاد باز",
shortLabel: "باز",
value: fmtNum(offered),
color: "text-amber",
icon: Inbox,
},
{
key: "accepted",
label: "پذیرفته‌شده",
shortLabel: "پذیرفته",
value: fmtNum(accepted),
color: "text-[#1d4ed8]",
icon: CheckCircle2,
},
{
key: "arrived",
label: "تحویل‌شده به کارگاه",
shortLabel: "تحویل",
value: fmtNum(arrived),
color: "text-green",
icon: PackageCheck,
},
];
const handleClick = (key: StatusFilter) => {
if (!onFilterChange) return;
if (key === "all") {
onFilterChange("all");
} else {
onFilterChange(activeFilter === key ? "all" : key);
}
};
return (
<section className="grid grid-cols-4 gap-1.5 sm:gap-3">
{stats.map((s) => {
const isClickable = Boolean(onFilterChange);
const isActive = activeFilter === s.key;
return (
<section className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{stats.map((s) => (
<div
key={s.label}
className="rounded-lg border border-line bg-panel p-3.5 transition-colors hover:border-accent/40"
onClick={() => handleClick(s.key)}
className={`rounded-xl border p-2 text-center transition-all sm:rounded-lg sm:p-3.5 sm:text-right ${
isClickable ? "cursor-pointer select-none" : ""
} ${
isActive
? "border-accent ring-2 ring-accent/20 bg-accent/5 shadow-sm"
: "border-line bg-panel hover:border-accent/40"
}`}
>
<div className="flex items-center justify-between">
<div className="text-[11px] font-medium text-muted">{s.label}</div>
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-slate-bg text-muted">
{/* عنوان کارت */}
<div className="flex items-center justify-center sm:justify-between">
<span className="truncate text-[10px] font-medium text-muted sm:text-[11px]">
<span className="sm:hidden">{s.shortLabel}</span>
<span className="hidden sm:inline">{s.label}</span>
</span>
{/* آیکون در دسکتاپ */}
<span className="hidden h-6 w-6 shrink-0 items-center justify-center rounded-md bg-slate-bg text-muted sm:flex">
<s.icon className="h-3.5 w-3.5" strokeWidth={2} />
</span>
</div>
<div className={`mt-1 text-[22px] font-extrabold leading-8 tabular-nums ${s.color}`}>{s.value}</div>
{/* مقدار عددی */}
<div
className={`mt-0.5 flex items-baseline justify-center sm:justify-start gap-1 text-base font-extrabold leading-5 tabular-nums sm:mt-1 sm:text-[22px] sm:leading-8 ${s.color}`}
>
<span>{s.value}</span>
{s.unit && (
<span className="text-[10px] font-medium text-muted sm:text-xs">
{s.unit}
</span>
)}
</div>
))}
</div>
);
})}
</section>
);
}
+30 -25
View File
@@ -8,19 +8,17 @@ export interface TermsheetItem {
}
export interface TermsheetCardProps {
index: number; // 1-based → «گزینه N»
index: number;
milestones: Milestone[];
onMilestonesChange: (ms: Milestone[]) => void;
// اقلام قابل تیک (مرحله ۲)
items?: TermsheetItem[];
coveredIds?: string[]; // tempIdهایی که تیک دارند
coveredIds?: string[];
onCoverageChange?: (ids: string[]) => void;
onRemove?: () => void;
defaultAll?: boolean; // وقتی هیچ تیکی نیست، پیش‌فرض همه تیک بخورند
defaultAll?: boolean;
removable?: boolean;
}
// کارت ترم‌شیت — هم در ویزارد (مرحله ۳) هم در مودال پذیرش/ویرایش
export function TermsheetCard({
index,
milestones,
@@ -33,7 +31,6 @@ export function TermsheetCard({
removable = true,
}: TermsheetCardProps) {
const covered = new Set(coveredIds);
// پیش‌فرض: وقتی هیچ قلمی تیک نخورده و defaultAll فعال است → همه تیک
const effectiveCovered = covered.size === 0 && defaultAll ? items.map((i) => i.tempId) : coveredIds;
const toggle = (tempId: string, checked: boolean) => {
@@ -49,17 +46,25 @@ export function TermsheetCard({
};
return (
<div className="variant-card">
<div className="variant-head">
<span className="variant-num">گزینه {faNum(index)}</span>
<div className="rounded-xl border border-line bg-panel p-4">
<div className="flex items-center justify-between mb-2">
<span className="font-bold text-[13px]">گزینه {faNum(index)}</span>
{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>
)}
</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) => (
<MilestoneRow
key={i}
@@ -69,31 +74,31 @@ export function TermsheetCard({
/>
))}
</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>
{items.length > 0 && (
<div className="ts-items-box">
<div className="ts-items-title">این termsheet کدام اقلام (مرحله ۲) را پوشش می‌دهد؟</div>
{items.length === 0 ? (
<div className="ts-items-empty">هنوز قلمی در مرحله‌ی ۲ اضافه نشده.</div>
) : (
<div>
<div className="pt-2 border-t border-line/60">
<div className="text-[12px] text-muted mb-1.5">این termsheet کدام اقلام (مرحله ۲) را پوشش می‌دهد؟</div>
<div className="flex flex-col gap-1 text-[12px]">
{items.map((it) => (
<label key={it.tempId} className="ts-item-label">
<label key={it.tempId} className="flex items-center gap-2 cursor-pointer text-gray-700">
<input
type="checkbox"
className="ts-item-cb"
data-item-temp-id={it.tempId}
className="rounded border-gray-300"
checked={effectiveCovered.includes(it.tempId)}
onChange={(e) => toggle(it.tempId, e.target.checked)}
/>
{it.label}
<span>{it.label}</span>
</label>
))}
</div>
)}
</div>
)}
</div>
+38 -9
View File
@@ -5,33 +5,62 @@ import { faNum } from "../lib/formatters";
import { cn } from "../lib/utils";
export interface TopicGridProps {
topics: Topic[]; // از store (API) می‌آید
topics: Topic[];
selectedTopic?: string;
onSelect: (topicName: string) => void;
showCounts?: boolean; // تعداد تأمین‌کننده‌های هر تاپیک
counts?: Record<string, number>; // topic name → count
showCounts?: boolean;
counts?: Record<string, number>;
}
// گرید تاپیک‌ها (از /material-categories — نه لیست ثابت)
export function TopicGrid({ topics, selectedTopic, onSelect, showCounts = false, counts }: TopicGridProps) {
if (!topics.length) {
return <EmptyState emoji="🗂️" text="در حال بارگذاری دسته‌بندی‌ها…" />;
}
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) => {
const selected = selectedTopic != null && normalize(selectedTopic) === normalize(t.name);
const count = counts?.[t.name] ?? 0;
return (
<button
type="button"
key={t.key}
className={cn("topic-card", selected && "selected")}
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>
{showCounts && <div className="t-count">{faNum(count)} تأمین‌کننده</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">
<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>
);
})}
+2 -2
View File
@@ -38,7 +38,7 @@ export function Header({ title, sub, backTo, backLabel = "بازگشت", actions
<button
type="button"
onClick={goBack}
className="inline-flex cursor-pointer items-center gap-0.5 rounded-md px-2 py-1 text-[13px] text-muted transition-colors hover:bg-slate-bg hover:text-ink max-md:px-2.5 max-md:py-2"
className="hidden md:inline-flex cursor-pointer items-center gap-0.5 rounded-md px-2 py-1 text-[13px] text-muted transition-colors hover:bg-slate-bg hover:text-ink"
>
<ChevronRight className="h-4 w-4" />
{backLabel}
@@ -64,6 +64,6 @@ export function PageLayout({
className?: string;
}) {
return (
<div className={cn("mx-auto w-full max-w-[1200px] px-4 py-4", className)}>{children}</div>
<div className={cn("mx-auto w-full max-w-[1200px] px-4 py-4 max-md:pb-24", className)}>{children}</div>
);
}
@@ -0,0 +1,100 @@
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/95 backdrop-blur-md 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-[66px] flex-col items-center justify-center gap-1.5 whitespace-nowrap px-1 py-1 transition-all active:scale-95",
isActive ? "text-accent font-bold" : "text-muted hover:text-ink font-medium",
)
}
>
<t.icon className="h-6.5 w-6.5" strokeWidth={2.2} />
<span className="text-[12px] leading-tight tracking-tight">{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;
}
+87 -39
View File
@@ -69,6 +69,19 @@ function authHeaders(init?: RequestInit): Record<string, string> {
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) به فارسی
const VALIDATION_FA = (msg: string): string => {
const lower = msg.toLowerCase();
@@ -272,6 +285,8 @@ interface RawDecision {
id: string;
status: string;
chosen_item_termsheet_id?: string | null;
project_id?: string | null;
project?: RawProject | null;
adjusted_unit_price?: number | null;
decided_at?: string | null;
}
@@ -296,6 +311,7 @@ interface RawLineItem {
termsheet_ids?: string[] | null;
accepted_termsheet_id?: string | null; // id ترم‌شیت پذیرفته‌شده (task: خط آبی)
projects?: RawProject[] | null; // پروژه‌های تخصیص‌یافته به این قلم
target_project?: RawProject | null;
}
interface RawTermsheet {
id: string;
@@ -504,6 +520,7 @@ function adaptMaterial(raw: RawMaterial, lookup: Lookup): Material {
}
function adaptLineItem(raw: RawLineItem): Quote["line_items"][number] {
const targetProj = raw.target_project || raw.decision?.project || null;
return {
id: raw.id,
material_id: raw.material?.id || "",
@@ -524,6 +541,8 @@ function adaptLineItem(raw: RawLineItem): Quote["line_items"][number] {
quantity: a.quantity,
})),
projects: (raw.projects || []).map((p) => ({ id: p.id, name: p.name })),
target_project: targetProj ? { id: targetProj.id, name: targetProj.name } : null,
target_project_id: raw.decision?.project_id || targetProj?.id || null,
};
}
@@ -649,10 +668,10 @@ function adaptAttachment(raw: RawAttachment): Attachment {
function toDateOnly(v: string | null | undefined): string | null {
if (!v) return null;
const m = String(v).match(/^(\d{4}-\d{2}-\d{2})/);
if (m) return m[1];
const str = String(v).trim();
if (/^\d{4}-\d{2}-\d{2}$/.test(str)) return str;
// datetime کامل → تاریخ به وقت محلی (نه UTC که یک روز عقب می‌افتد)
const d = new Date(v);
const d = new Date(str);
if (Number.isNaN(d.getTime())) return null;
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
@@ -842,23 +861,17 @@ export const api = {
if (becomingAccepted) {
const acceptedTs = (patch.termsheets || []).filter((t) => t.status === "accepted");
// تخصیص پروژه به هر قلم — نام‌ها به id واقعی ترجمه می‌شوند
const itemProjects: { line_item_id: string; project_ids: string[] }[] = [];
const allProjectIds: string[] = [];
const seenPids = new Set<string>();
// پروژه‌ی واحدِ انتخاب‌شده برای هر قلم (item_decisions.project_id)
const targetProjByLine = new Map<string, string>();
for (const li of patch.line_items || []) {
const ids: string[] = [];
for (const p of li.projects || []) {
const pid = looksLikeUuid(p.id) ? p.id : await projectIdByNameOrCreate(p.name);
if (!pid) continue;
ids.push(pid);
if (!seenPids.has(pid)) {
seenPids.add(pid);
allProjectIds.push(pid);
const tp = li.target_project;
if (tp && tp.name) {
const pid = looksLikeUuid(tp.id) ? tp.id : await projectIdByNameOrCreate(tp.name);
if (pid) targetProjByLine.set(li.id, pid);
}
}
itemProjects.push({ line_item_id: li.id, project_ids: ids });
}
const allProjectIds = [...new Set([...targetProjByLine.values()])];
const payload = {
accepted_termsheets: acceptedTs.map((t) => ({
id: looksLikeUuid(t.id) ? t.id : undefined,
@@ -874,11 +887,7 @@ export const api = {
item_decisions: (patch.line_items || []).map((li) => {
const idx = li.accepted_ts_id ? acceptedTs.findIndex((t) => t.id === li.accepted_ts_id) : -1;
const chosen = !li.rejected && idx >= 0;
// پروژه‌ی واحدِ انتخاب‌شده برای این قلم — مستقیم داخل decision می‌رود تا
// «کالاهای در راه» همان مدیر پروژه را (با item_decisions.project_id)
// نشان دهد؛ قبلاً فقط item_projects جداگانه می‌رفت و decision بدون
// پروژه می‌ماند (قلم‌ها در پنل هیچ مدیر پروژه‌ای دیده نمی‌شدند)
const chosenProjId = itemProjects.find((p) => p.line_item_id === li.id)?.project_ids[0];
const chosenProjId = targetProjByLine.get(li.id);
return {
line_item_id: li.id,
status: chosen ? "chosen" : "rejected",
@@ -887,7 +896,7 @@ export const api = {
...(li.unit_price != null ? { adjusted_unit_price: li.unit_price } : {}),
};
}),
item_projects: itemProjects,
item_projects: [], // کاندیداهای قبلی در line_item_projects کاملاً حفظ می‌شوند
};
await patchData(`/quotes/${id}/accept`, payload);
// پروژه‌های سطح پیشنهاد با اتحاد پروژه‌های اقلام همگام می‌شوند
@@ -1032,8 +1041,10 @@ export const api = {
const unitId = await unitIdByNameOrCreate(lookup, unitName);
// برند — اگر تعریف نشده، در جدول brands ساخته و به متریال متصل می‌شود
const brandId = await brandIdByNameOrCreate(lookup, m.brand);
// تایپ جلوی برنامه name ندارد — از متریال/برند/مشخصه ساخته می‌شود
const name = [m.brand, m.category, ...(m.specs || [])].filter(Boolean).join(" ") || "کالای جدید";
// name فقط نامِ خودِ متریال است؛ برند در brand_ids و مشخصه‌ها در
// attributes (EAV) ذخیره می‌شوند. اگر برند/مشخصه داخل name ریخته شود،
// در ویزارد به‌عنوان «نامِ متریال» کش می‌شود و مشخصه‌ها در نام ظاهر می‌شوند.
const name = (m.category || m.brand || (m.specs || []).join(" ") || "").trim() || "کالای جدید";
// مشخصه‌ها به‌صورت attribute (EAV) ذخیره می‌شوند تا بعد از رفرش دیده شوند
const attrs: { attribute_definition_id: string; value: string }[] = [];
const seen = new Set<string>();
@@ -1207,6 +1218,17 @@ export const api = {
brandsList: async (): Promise<{ id: string; name: string }[]> => {
return getData<{ id: string; name: string }[]>(`/brands?page_size=${PAGE_SIZE}`);
},
// مشخصه‌ها — رجیستری سراسری (attribute-definitions)، مستقل از تاپیک/دسته/برند.
// ویزارد از این لیست به‌عنوان منبع سراسری مشخصه‌ها استفاده می‌کند تا مشخصه‌های
// ثبت‌شده در پیشنهادهای قبلی همیشه در دسترس باشند.
specsList: async (): Promise<string[]> => {
const raw = await getData<{ label?: string; attribute_key?: string }[]>(
`/attribute-definitions?page_size=${PAGE_SIZE}`,
);
return raw
.map((d) => (d.label || "").trim() || (d.attribute_key || "").replace(/^spec_/, "").trim())
.filter(Boolean);
},
// ---------- واحدها (از بک‌اند) ----------
unitsList: async (): Promise<RawUnit[]> => {
@@ -1450,6 +1472,8 @@ export interface UserRow {
role_label: string | null;
project_id: string | null;
project_name: string | null;
// 1:N — every project this PM manages (oldest first).
projects?: { id: string; name: string }[];
created_at: string;
}
@@ -1460,7 +1484,6 @@ export const apiV26 = {
login: async (body: {
username: string;
password: string;
organization?: string;
}): Promise<{ access_token: string; expires_in: number | null; user: import("../store/auth").AuthUser }> => {
const res = await request<{
data: {
@@ -1477,6 +1500,7 @@ export const apiV26 = {
role_label: string | null;
project_id: string | null;
project_name: string | null;
projects?: { id: string; name: string }[];
};
};
}>("/auth/login", { method: "POST", body: JSON.stringify(body) });
@@ -1508,6 +1532,7 @@ export const apiV26 = {
id: string; casdoor_id: string; first_name: string; last_name: string; phone: string | null;
role: "pm" | "procurement_manager" | "admin" | null; role_label: string | null;
project_id: string | null; project_name: string | null;
projects?: { id: string; name: string }[];
}>("/users/me");
return {
id: data.id,
@@ -1519,9 +1544,13 @@ export const apiV26 = {
role_label: data.role_label,
project_id: data.project_id,
project_name: data.project_name,
projects: data.projects ?? [],
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) ---
list: async (): Promise<UserRow[]> => {
const data = await getData<UserRow[]>("/users");
@@ -1544,33 +1573,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: {
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}`);
return data.map(rawWarehouse);
},
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);
},
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);
},
delete: async (id: string): Promise<void> => {
await deleteData(`/inventory/warehouses/${id}`);
await deleteData(`/inventory/warehouses/${id}${projectQuery()}`);
},
},
inventory: {
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}`);
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[]> => {
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}`);
},
createTransaction: async (body: {
@@ -1579,26 +1619,34 @@ export const apiV26 = {
physical_qty?: number | null; unit_id?: number | null; specs?: Record<string, string> | null;
note?: string | null; transaction_date?: string | null;
}): Promise<InventoryTransaction[]> => {
return postData<InventoryTransaction[]>("/inventory/transactions", body);
return postData<InventoryTransaction[]>(`/inventory/transactions${projectQuery()}`, body);
},
types: async (): Promise<{ type: TransactionType; label: string }[]> => {
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 }[] }[]> => {
const qs = warehouse_id ? `?category_id=${category_id}&warehouse_id=${warehouse_id}` : `?category_id=${category_id}`;
return getData<{ id: string; name: string; brands: { id: string; name: string }[] }[]>(`/inventory/materials/options${qs}`);
const params: Record<string, string> = { category_id };
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: {
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: {
item_decision_id: string; received_qty: number;
allocations: { warehouse_id: string; quantity: number }[];
received_at?: string | null; on_time?: boolean | null; quality_note?: string | null;
}): Promise<{ receipt_id: string; state: string; posted_transactions: string[] }> =>
postData("/pm/receipts", body),
receipts: async (): Promise<ConfirmedReceipt[]> => getData<ConfirmedReceipt[]>("/pm/receipts"),
}, projectId?: string | null): Promise<{ receipt_id: string; state: string; posted_transactions: string[] }> =>
postData(projectId ? `/pm/receipts?project_id=${projectId}` : "/pm/receipts", body),
receipts: async (projectId?: string | null): Promise<ConfirmedReceipt[]> =>
getData<ConfirmedReceipt[]>(projectId ? `/pm/receipts?project_id=${projectId}` : "/pm/receipts"),
},
// ساخت موجودیت‌های کاتالوگ سراسری از داخل پنل انبار (موجودی اولیه):
+94 -17
View File
@@ -40,20 +40,58 @@ export function specsLabel(specs: string[]): string {
return specs.join(" · ");
}
// نام نمایشی متریال — نام سراسری واقعی (materials.name)، نه دسته.
// برند و مشخصه فقط وقتی به نام اضافه می‌شوند که داخلش نیامده باشند (بدون تکرار).
export function materialLabel(m: Pick<Material, "name" | "brand" | "specs" | "category">): string {
const name = (m.name || "").trim();
const specs = (m.specs || []).filter(Boolean);
if (!name && !m.brand && !specs.length) return m.category === "؟" ? "؟" : "کالا";
if (!name) {
return [m.brand, ...specs].filter(Boolean).join(" ") || "کالا";
/**
* عنوان تمیز و بدون تکرار کالا:
* ترکیب هوشمندانه‌ی نام اصلی (materials.name)، برند، و مشخصه‌ها بدون تکرار کلمات مشترک.
*/
export function formatMaterialTitle(
m: Pick<Material, "name" | "brand" | "specs" | "category"> | null | undefined,
extraSpecs?: string[],
): string {
if (!m) return "کالا";
const rawName = (m.name || "").trim();
const rawBrand = (m.brand || "").trim();
const rawSpecs = [...(m.specs || []), ...(extraSpecs || [])]
.map((s) => (s || "").trim())
.filter(Boolean);
const normName = normalize(rawName);
const normBrand = normalize(rawBrand);
if (!rawName) {
if (rawBrand && rawSpecs.length) return `${rawBrand} · ${rawSpecs.join(" · ")}`;
if (rawBrand) return rawBrand;
if (rawSpecs.length) return rawSpecs.join(" · ");
return m.category && m.category !== "؟" ? m.category : "کالا";
}
const parts = [name];
if (m.brand && !name.includes(m.brand)) parts.push(m.brand);
const missing = specs.filter((s) => !name.includes(s));
if (missing.length) parts.push(`(${missing.join("، ")})`);
return parts.join(" ");
let title = rawName;
if (normBrand && !normName.includes(normBrand)) {
title = `${rawBrand} ${title}`;
}
const normTitle = normalize(title);
const distinctSpecs: string[] = [];
const seenNormSpecs = new Set<string>();
for (const s of rawSpecs) {
const ns = normalize(s);
if (!ns || seenNormSpecs.has(ns)) continue;
seenNormSpecs.add(ns);
// اگر مشخصه قبلاً داخل عنوان یا نام کالا آمده باشد، تکرار نمی‌کنیم
if (normTitle.includes(ns)) continue;
distinctSpecs.push(s);
}
if (distinctSpecs.length > 0) {
return `${title} · ${distinctSpecs.join(" · ")}`;
}
return title;
}
export function materialLabel(m: Pick<Material, "name" | "brand" | "specs" | "category"> | null | undefined): string {
return formatMaterialTitle(m);
}
// آرایه‌ی termsheet_ids یک قلم (هر دو فرمت قدیم/جدید)
@@ -104,8 +142,21 @@ export function lineItemTotal(li: LineItem): number | null {
return null;
}
// پروژه‌های یک قلم — فقط پروژه‌های تخصیص‌یافته به همین قلم (بدون fallback به سطح پیشنهاد)
export function itemProjects(_q: Quote, li: LineItem): string[] {
// پروژه‌های یک قلم:
// - در وضعیت offered: تمام پروژه‌های کاندیدا (line_item_projects)
// - در وضعیت accepted یا arrived: فقط تک پروژه‌ی انتخاب‌شده در خرید (item_decisions.project_id)
export function itemProjects(q: Quote, li: LineItem): string[] {
if (q.status === "accepted" || q.status === "arrived") {
if (li.target_project?.name) {
return [li.target_project.name];
}
// اگر target_project هنوز آبجکت نام نداشت، بررسی نام در صورت تک‌پروژه بودن
if (li.projects && li.projects.length === 1) {
const p = li.projects[0];
return [typeof p === "string" ? p : p.name];
}
return [];
}
return (li.projects || []).map((p) => (typeof p === "string" ? p : p.name));
}
@@ -163,8 +214,24 @@ export function unitWarning(_store: Store, _q: Quote): { unit: string; cat: stri
return [];
}
// ستون پروژه
// ستون پروژه:
// در وضعیت‌های پذیرفته‌شده یا رسیده، فقط پروژه‌های هدف خرید اقلام نشان داده می‌شوند
export function projectCell(q: Quote): string {
if (q.status === "accepted" || q.status === "arrived") {
const targets = (q.line_items || [])
.filter((li) => !li.rejected)
.map((li) =>
li.target_project?.name ||
(li.projects && li.projects.length === 1
? typeof li.projects[0] === "string"
? li.projects[0]
: li.projects[0].name
: null),
)
.filter(Boolean) as string[];
const unique = [...new Set(targets)];
if (unique.length) return unique.join("، ");
}
if (q.projects && q.projects.length) return q.projects.join("، ");
return q.project || "—";
}
@@ -221,8 +288,13 @@ export function filterQuotes(
"پیشنهاد شده": "offered",
"پذیرفته شده": "accepted",
"رسیده": "arrived",
"باز": "offered",
"پذیرفته": "accepted",
"offered": "offered",
"accepted": "accepted",
"arrived": "arrived",
};
const stKey = STATUS_BY_LABEL[stVal] || "";
const stKey = STATUS_BY_LABEL[stVal] || stVal;
return store.quotes.filter((quote) => {
const supObj = supplierById(store.suppliers, quote.supplier_id);
@@ -246,11 +318,16 @@ export function filterQuotes(
export function isExpired(q: Quote): boolean {
return (
q.status === "offered" &&
!quoteHasArrivedOrAccepted(q) &&
!!q.expiry_date &&
new Date(q.expiry_date) < new Date(new Date().toDateString())
);
}
function quoteHasArrivedOrAccepted(q: Quote): boolean {
return q.status === "accepted" || q.status === "arrived";
}
// آماده‌سازی نمایش ترم‌شیت در حالت expanded
export function termSheetBlocks(
store: Store,
+3 -1
View File
@@ -71,7 +71,9 @@ export interface LineItem {
arrived_date: string | null; // تاریخ آخرین رسید
received_qty?: number | null; // مجموع رسیدهای ثبت‌شده (رسید جزیی) — null = هنوز داده‌ای نیست
received_warehouses?: { id: string; name: string; quantity: number }[] | null; // انبارهای مقصد رسیدها
projects?: { id: string; name: string }[]; // پروژه‌های تخصیص‌یافته به این قلم
projects?: { id: string; name: string }[]; // پروژه‌های کاندیدای تخصیص‌یافته به این قلم (line_item_projects)
target_project?: { id: string; name: string } | null; // پروژه‌ی نهایی انتخاب‌شده در هنگام خرید (item_decisions.project_id)
target_project_id?: string | null;
}
// پیشنهاد خرید (اصلی‌ترین entity)
+21 -20
View File
@@ -1,14 +1,14 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
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 { useAuthStore } from "../../store/auth";
import type { Project } from "../../lib/types";
// ============================================================
// ورود به سامانه — Casdoor درون‌صفحه‌ای (بدون ریدایرکت)
// فرم اصلی: Username / Password / Organization (اختیاری) →
// فرم اصلی: Username / Password →
// POST /api/v1/auth/login (بک‌اند با grant_type=password از
// Casdoor توکن می‌گیرد و نقش را از جدول users خودمان برمی‌گرداند)
// حالت توسعه (فقط ادمین — سوپر یوزر) — چرخه‌ی ۳ مرحله‌ای:
@@ -36,7 +36,7 @@ export function LoginPage() {
// ---- فرم اصلی (Casdoor) ----
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [organization, setOrganization] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
// ---- پنل حالت توسعه (۳ مرحله) ----
@@ -71,7 +71,6 @@ export function LoginPage() {
const { access_token, user } = await apiV26.auth.login({
username: username.trim(),
password,
...(organization.trim() ? { organization: organization.trim() } : {}),
});
if (!user.role) {
toast.error("نقش شما هنوز در سیستم تعیین نشده است — با مدیر سیستم تماس بگیرید");
@@ -101,7 +100,6 @@ export function LoginPage() {
const { user: adminUser } = await apiV26.auth.login({
username: devAdminUser.trim(),
password: devAdminPass,
...(organization.trim() ? { organization: organization.trim() } : {}),
});
if (adminUser.role !== "admin") {
toast.error("حالت توسعه فقط برای ادمین (سوپر یوزر) در دسترس است");
@@ -298,30 +296,33 @@ export function LoginPage() {
<label className="mb-1 block text-[12px] font-semibold text-muted" htmlFor="login-password">
رمز عبور
</label>
<div className="relative">
<input
id="login-password"
type="password"
type={showPassword ? "text" : "password"}
autoComplete="current-password"
dir="ltr"
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"
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"
placeholder="••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
aria-label={showPassword ? "Hide password" : "Show password"}
aria-pressed={showPassword}
aria-controls="login-password"
title={showPassword ? "پنهان کردن رمز" : "نمایش رمز"}
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"
>
{showPassword ? (
<EyeOff className="h-4 w-4" aria-hidden="true" />
) : (
<Eye className="h-4 w-4" aria-hidden="true" />
)}
</button>
</div>
<div className="field">
<label className="mb-1 block text-[12px] font-semibold text-muted" htmlFor="login-org">
سازمان (اختیاری)
</label>
<input
id="login-org"
type="text"
dir="ltr"
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"
value={organization}
onChange={(e) => setOrganization(e.target.value)}
/>
</div>
<button
type="submit"
+99 -83
View File
@@ -5,11 +5,9 @@ import { api, nextDashedId } from "../../lib/api";
import { useMaterials, useProjects, useQuotes, useUpdateQuote, useSuppliers } from "../../hooks";
import {
activeItems,
formatMaterialTitle,
itemTermsheetIds,
materialById,
materialLabel,
materialSpecs,
specsLabel,
supplierById,
} from "../../lib/quoteUtils";
import { faNum, fmtNum } from "../../lib/formatters";
@@ -38,7 +36,6 @@ export interface AcceptModalProps {
onClose: () => void;
}
// مودال پذیرش: انتخاب/ویرایش شرایط پرداخت + تخصیص قلم‌ها + فاکتورها
export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
const quotes = useQuotes();
const suppliers = useSuppliers();
@@ -53,13 +50,10 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
const [proformas, setProformas] = useState<Attachment[]>([]);
const [removedItems, setRemovedItems] = useState<Set<string>>(new Set());
const [assignments, setAssignments] = useState<Record<string, string>>({});
// تخصیص هر قلم به پروژه‌ها (نام‌ها)
const [itemProjectsSel, setItemProjectsSel] = useState<Record<string, string>>({});
// تاریخ و زمان پذیرش — پیش‌فرض الان
const [acceptDate, setAcceptDate] = useState<string | null>(() => new Date().toISOString());
const [ready, setReady] = useState(false);
// وقتی quote باز شد، state اولیه را بساز
useMemo(() => {
if (!q) return;
const offered = q.termsheets.filter((t) => t.status === "offered");
@@ -72,11 +66,12 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
});
setRemovedItems(rem);
setAssignments({});
// پروژه‌ی هر قلم — تخصیصِ تکی: از تخصیص قبلی قلم، وگرنه اولین پروژه‌ی پیشنهاد
const projSel: Record<string, string> = {};
q.line_items.forEach((li) => {
const names = (li.projects || []).map((p) => p.name).filter(Boolean);
projSel[li.id] = names.length
projSel[li.id] = li.target_project?.name
? li.target_project.name
: names.length
? names[0]
: q.projects && q.projects.length
? q.projects[0]
@@ -86,7 +81,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
setReady(true);
}, [q]);
// پیوست‌ها مستقیماً از سرور (MinIO) خوانده می‌شوند — نه از state محلی
useEffect(() => {
if (!quoteId) return;
let alive = true;
@@ -109,7 +103,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
const allItems = q.line_items || [];
const active = activeItems(q);
// گزینه‌های پروژه — پروژه‌های ثبت‌شده + پروژه‌های این پیشنهاد + تخصیص‌های قبلی قلم‌ها
const projectOptions = (() => {
const set = new Set<string>();
projects.forEach((p) => p.name && set.add(p.name));
@@ -147,10 +140,8 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
const confirm = () => {
if (!q) return;
// پچ نهایی — همه‌ی تغییرات روی همین کلون اعمال می‌شود (نه روی آبجکت store)
const next: Quote = JSON.parse(JSON.stringify(q));
// فقط کارت‌هایی با حداقل یک مرحله‌ی پرداخت
const cardMeta = new Map<string, { milestones: Milestone[]; label: string; existing: Quote["termsheets"][number] | undefined }>();
cards.forEach((c, idx) => {
const milestones = c.milestones.filter((m) => m.when_iso || m.amount != null);
@@ -164,11 +155,10 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
});
if (!cardMeta.size) {
toast.error("حداقل یک conditions با حداقل یک مرحله پرداخت لازم است.");
toast.error("حداقل یک شرایط پرداخت با حداقل یک مرحله پرداخت لازم است.");
return;
}
// رد کردن قلم‌ها — در داده می‌مانند تا با بازگشت وضعیت برگردند
next.line_items.forEach((li) => {
if (removedItems.has(li.id)) li.rejected = true;
});
@@ -178,7 +168,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
return;
}
// فقط termsheetهایی که حداقل یک قلم انتخاب کرده پذیرفته می‌شوند
const fallbackDom = [...cardMeta.keys()][0];
const chosenDomIds = new Set<string>();
active.forEach((li) => {
@@ -200,7 +189,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
}
});
// قلم‌ها: اتصال‌های offered حفظ می‌شوند + جفت پذیرفته‌شده ثبت می‌شود
active.forEach((li) => {
const dom = assignments[li.id] || fallbackDom;
const finalId = domToFinal[dom];
@@ -210,7 +198,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
li.accepted_ts_id = finalId || null;
});
// termsheet پذیرفته‌شده‌ای که هیچ قلمی انتخابش نکرده → برمی‌گردد به offered
const usedIds = new Set<string>();
active.forEach((li) => {
if (li.accepted_ts_id) usedIds.add(li.accepted_ts_id);
@@ -219,17 +206,18 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
if (t.status === "accepted" && !usedIds.has(t.id)) t.status = "offered";
});
// پروژه‌ی هر قلم (تکی) + همگام‌سازی پروژه‌های سطح پیشنهاد
const projUnion = new Set<string>();
next.line_items.forEach((li) => {
if (li.rejected) return;
const name = itemProjectsSel[li.id];
if (name === undefined) return;
li.projects = name ? [{ id: "", name }] : [];
li.target_project = name ? { id: "", name } : null;
if (name) projUnion.add(name);
});
if (projUnion.size > 0) {
next.projects = [...projUnion];
next.project = next.projects[0] || "";
}
const now = new Date().toISOString();
next.accepted_date = acceptDate || now;
@@ -245,26 +233,37 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
return (
<Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>پذیرش پیشنهاد — {sup.name}</DialogTitle>
</DialogHeader>
<DialogContent className="max-w-3xl p-0 gap-0 overflow-hidden bg-slate-50 border-none shadow-2xl rounded-xl">
<div className="flex flex-col max-h-[85vh] w-full">
<div className="text-[12px] text-muted">
شرایط پرداخت را انتخاب یا ویرایش کنید. سپس هر قلم را به conditions دلخواه تخصیص دهید.
{/* هدر ثابت و هم‌تراز */}
<div className="px-6 py-4 bg-white border-b border-line shrink-0 z-10 text-right">
<DialogHeader className="p-0 text-right">
<DialogTitle className="text-right text-lg font-bold text-slate-800">
پذیرش پیشنهاد — {sup.name}
</DialogTitle>
</DialogHeader>
<p className="text-[13px] text-muted mt-1 text-right">
شرایط پرداخت را ویرایش کنید. سپس هر قلم را به بخش و پروژه دلخواه تخصیص دهید.
</p>
</div>
{/* کارت‌های termsheet */}
<div className="flex flex-col gap-2">
{/* بدنه اسکرول‌پذیر */}
<div className="flex-1 overflow-y-auto p-4 md:p-6 space-y-5">
{/* بخش ۱: تنظیم شرایط پرداخت */}
<section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5">
<h3 className="mb-4 text-[14px] font-bold text-slate-800">۱. تنظیم شرایط پرداخت</h3>
<div className="flex flex-col gap-4">
{cards.map((c, idx) => (
<div key={c.domId} className="modal-variant">
<div className="modal-variant-head">
<span className="variant-num">{cardLabel(c, idx)}</span>
<button type="button" className="variant-x" title="حذف" onClick={() => removeCard(c.domId)}>
<div key={c.domId} className="modal-variant bg-slate-50/50 rounded-lg border border-line p-3">
<div className="modal-variant-head mb-2 flex items-center justify-between">
<span className="variant-num font-semibold text-primary">{cardLabel(c, idx)}</span>
<button type="button" className="text-red-500 hover:text-red-700 hover:bg-red-50 rounded-full w-6 h-6 flex items-center justify-center transition-colors" title="حذف" onClick={() => removeCard(c.domId)}>
×
</button>
</div>
<div className="flex flex-col gap-1.5">
<div className="flex flex-col gap-2">
{c.milestones.map((m, i) => (
<MilestoneRow
key={i}
@@ -278,32 +277,35 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
/>
))}
</div>
<Button variant="line" size="sm" className="mt-1.5" onClick={() => updateCard(c.domId, { milestones: [...c.milestones, { when_iso: null, amount: null }] })}>
+ مرحله پرداخت
<Button variant="ghost" size="sm" className="mt-3 text-xs text-primary bg-primary/5 hover:bg-primary/10" onClick={() => updateCard(c.domId, { milestones: [...c.milestones, { when_iso: null, amount: null }] })}>
+ مرحله پرداخت جدید
</Button>
</div>
))}
</div>
<Button variant="line" size="sm" className="self-start" onClick={addNewCard}>
+ نوشتن conditions جدید
<Button variant="line" size="sm" className="mt-4 border-dashed border-primary/50 text-primary hover:bg-primary/5" onClick={addNewCard}>
+ افزودن شرایط پرداخت جدید
</Button>
</section>
{/* تخصیص قلم‌ها */}
<div className="border-t border-line pt-3">
<div className="mb-1 text-[13px] font-bold">تخصیص قلم‌ها به conditions:</div>
<div className="mb-2 text-[11.5px] text-muted">
{/* بخش ۲: تخصیص اقلام */}
<section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5">
<h3 className="mb-1 text-[14px] font-bold text-slate-800">۲. تخصیص اقلام به شرایط پرداخت</h3>
<div className="mb-4 text-[12px] text-muted">
قلمی که می‌خرید را تیک بزنید؛ قلمی که نمی‌خرید را بدون تیک رها کنید تا از معامله حذف شود.
</div>
<div>
<div className="flex flex-col gap-2">
{allItems.map((li) => {
const removed = removedItems.has(li.id);
const m = materialById(materials, li.material_id);
const specText = specsLabel(materialSpecs(m));
return (
<div key={li.id} className={`accept-item-assign-row ${removed ? "rejected" : ""}`}>
<div
key={li.id}
className={`accept-item-assign-row flex flex-wrap sm:flex-nowrap items-center gap-3 p-3 rounded-lg border border-line transition-all ${removed ? "bg-slate-50 opacity-60 grayscale" : "bg-white hover:border-primary/30"}`}
>
<input
type="checkbox"
className="accent-accent"
className="accent-primary w-4 h-4 rounded cursor-pointer shrink-0"
checked={!removed}
title="تیک = در معامله می‌ماند"
onChange={(e) => {
@@ -313,17 +315,20 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
setRemovedItems(next);
}}
/>
<span className="flex-1 text-[13px]">
{materialLabel(m)}
{specText ? ` · ${specText}` : ""}
</span>
<span className="text-[12px] text-muted">
<div className="flex-1 min-w-[120px]">
<div className="text-[13.5px] font-medium text-slate-800">
{formatMaterialTitle(m)}
</div>
<div className="text-[12px] text-muted mt-0.5">
{fmtNum(li.quantity)} {li.unit} × {groupCommas(li.unit_price, true)}
</span>
</div>
</div>
<select
className="min-w-[140px] rounded-md border border-line bg-panel px-2 py-1.5 font-inherit text-[12px]"
className={`min-w-[160px] h-9 rounded-md border border-line bg-panel px-2.5 py-1.5 font-inherit text-[12px] outline-none transition-all ${!removed ? 'focus:border-primary focus:ring-1 focus:ring-primary' : 'cursor-not-allowed'}`}
value={assignments[li.id] || cards[0]?.domId || ""}
onChange={(e) => assign(li.id, e.target.value)}
disabled={removed}
>
{cards.map((c, idx) => (
<option key={c.domId} value={c.domId}>
@@ -335,37 +340,37 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
);
})}
{active.length === 0 && (
<div className="mb-1 text-[12px] text-red">همه‌ی قلم‌ها رد شده‌اند — حداقل یک قلم را تیک بزنید.</div>
<div className="mt-2 p-3 bg-red-50 text-red-600 rounded-md text-[13px] font-medium flex items-center gap-2">
<span>⚠️</span> همه‌ی قلم‌ها رد شده‌اند — حداقل یک قلم را تیک بزنید.
</div>
)}
</div>
</div>
</section>
{/* تخصیص قلم‌ها به پروژه */}
<div className="border-t border-line pt-3">
<div className="mb-1 text-[13px] font-bold">پروژه‌ی هر قلم:</div>
<div className="mb-2 text-[11.5px] text-muted">
برای هر متریال فقط یک پروژه انتخاب می‌شود — مثل شرایط پرداخت.
{/* بخش ۳: تخصیص اقلام به پروژه */}
<section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5">
<h3 className="mb-1 text-[14px] font-bold text-slate-800">۳. تخصیص اقلام به پروژه‌ها</h3>
<div className="mb-4 text-[12px] text-muted">
برای هر متریال خریداری شده، می‌توانید پروژه مربوط به آن را مشخص کنید.
</div>
<div className="flex flex-col gap-2">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{allItems
.filter((li) => !removedItems.has(li.id))
.map((li) => {
const m = materialById(materials, li.material_id);
const specText = specsLabel(materialSpecs(m));
return (
<div key={li.id} className="rounded-lg border border-line p-2.5">
<div className="mb-1.5 text-[12.5px]">
<span className="font-semibold">
{materialLabel(m)}
<div key={li.id} className="rounded-lg border border-line p-3 flex flex-col justify-between hover:border-primary/30 transition-colors">
<div className="mb-3 flex items-center justify-between">
<span className="text-[13px] font-semibold text-slate-800 truncate">
{formatMaterialTitle(m)}
</span>
{specText ? <span className="text-muted"> · {specText}</span> : ""}
<span className="ms-2 text-[11px] text-muted">
<span className="ms-2 text-[11px] text-muted bg-slate-100 px-2 py-0.5 rounded-full whitespace-nowrap">
{fmtNum(li.quantity)} {li.unit}
</span>
</div>
{projectOptions.length ? (
<select
className="min-w-[180px] rounded-md border border-line bg-panel px-2 py-1.5 font-inherit text-[12px]"
className="w-full h-9 rounded-md border border-line bg-panel px-2.5 py-1.5 font-inherit text-[12px] outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
value={itemProjectsSel[li.id] ?? ""}
onChange={(e) =>
setItemProjectsSel((s) => ({ ...s, [li.id]: e.target.value }))
@@ -383,17 +388,18 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
))}
</select>
) : (
<div className="text-[12px] text-muted">پروژه‌ای ثبت نشده است.</div>
<div className="text-[12px] text-muted bg-slate-50 p-2 rounded-md text-center">پروژه‌ای در سیستم ثبت نشده است.</div>
)}
</div>
);
})}
</div>
</div>
</section>
{/* فاکتورهای اصلی */}
<div className="border-t border-line pt-3">
<div className="mb-1 text-[13px] font-bold">فاکتورهای اصلی (چند فایل):</div>
{/* بخش ۴: فاکتورها، پیش‌فاکتورها و تاریخ */}
<section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5 space-y-6">
<div>
<h3 className="mb-2 text-[14px] font-bold text-slate-800">فاکتورهای اصلی</h3>
<FileList
files={invoices}
onChange={setInvoices}
@@ -408,9 +414,10 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
/>
</div>
{/* پیش‌فاکتورها */}
<div className="border-t border-line pt-3">
<div className="mb-1 text-[13px] font-bold">پیش‌فاکتورها (چند فایل):</div>
<div className="h-px bg-line w-full"></div>
<div>
<h3 className="mb-2 text-[14px] font-bold text-slate-800">پیش‌فاکتورها</h3>
<FileList
files={proformas}
onChange={setProformas}
@@ -426,20 +433,29 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
/>
</div>
{/* تاریخ و زمان پذیرش */}
<div className="border-t border-line pt-3">
<div className="field max-w-xs">
<label>تاریخ و زمان پذیرش</label>
<div className="h-px bg-line w-full"></div>
<div className="max-w-xs">
<label className="block mb-2 text-[13px] font-bold text-slate-800">تاریخ و زمان پذیرش</label>
<JalaliDatePicker value={acceptDate} onChange={setAcceptDate} placeholder="تاریخ و ساعت" withTime />
</div>
</section>
</div>
<DialogFooter>
<Button variant="slate" onClick={onClose}>
{/* فوتر ثابت */}
<div className="px-6 py-4 bg-white border-t border-line shrink-0 z-10">
<DialogFooter className="flex w-full gap-3 sm:justify-end">
<Button variant="slate" onClick={onClose} className="w-full sm:w-auto">
انصراف
</Button>
<Button onClick={confirm}>ثبت معامله بسته‌شده</Button>
<Button onClick={confirm} className="w-full sm:w-auto">
ثبت معامله بسته‌شده
</Button>
</DialogFooter>
</div>
</div>
</DialogContent>
</Dialog>
);
+2 -4
View File
@@ -3,7 +3,7 @@ import { toast } from "sonner";
import { Package } from "lucide-react";
import type { Quote } from "../../lib/types";
import { useMaterials, useQuotes, useSuppliers, useUpdateQuote } from "../../hooks";
import { activeItems, materialById, materialSpecs, supplierById } from "../../lib/quoteUtils";
import { activeItems, formatMaterialTitle, materialById, supplierById } from "../../lib/quoteUtils";
import { fmtNum } from "../../lib/formatters";
import { groupCommas } from "../../lib/numutil";
import { Button } from "../../components/ui/button";
@@ -86,7 +86,6 @@ export function ArriveModal({ quoteId, onClose }: ArriveModalProps) {
<div className="flex flex-col gap-1.5">
{items.map((li) => {
const m = materialById(materials, li.material_id);
const specText = materialSpecs(m).join(" · ");
return (
<label key={li.id} className={`arrive-row${li.arrived_date ? " arrived" : ""}`}>
<input
@@ -102,8 +101,7 @@ export function ArriveModal({ quoteId, onClose }: ArriveModalProps) {
/>
<span>
<Package className="me-1 inline h-3.5 w-3.5 text-muted" />
{m.category} {m.brand}
{specText ? ` · ${specText}` : ""}
{formatMaterialTitle(m)}
</span>
<span className="arrive-meta">
{fmtNum(li.quantity)} {li.unit || ""} × {groupCommas(li.unit_price, true)}
+60 -34
View File
@@ -1,7 +1,6 @@
import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { Users, FolderKanban, Ruler, Plus } from "lucide-react";
import { Button } from "../../components/ui/button";
import {
useMaterials,
useProjects,
@@ -12,9 +11,8 @@ import {
} from "../../hooks";
import { useAppStore } from "../../store";
import { filterQuotes } from "../../lib/quoteUtils";
import { daysBetween } from "../../lib/formatters";
import { Header, PageLayout } from "../../components/layout/Header";
import { StatsBar } from "../../components/StatsBar";
import { StatsBar, type StatusFilter } from "../../components/StatsBar";
import { FilterToolbar, type Filters } from "./FilterToolbar";
import { LedgerTable } from "./LedgerTable";
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
@@ -23,7 +21,6 @@ import { EditModal } from "./EditModal";
const EMPTY_FILTERS: Filters = { category: "", supplier: "", status: "", search: "" };
// صفحه‌ی اصلی دفتر پیشنهادهای خرید
export function DashboardPage() {
useStoreData();
@@ -47,7 +44,7 @@ export function DashboardPage() {
[quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions, topics],
);
// فیلتر + مرتب‌سازی بر اساس زمان ساخت (created_at) — فیلترها بازنشانی نمی‌شوند
// فیلتر + مرتب‌سازی بر اساس زمان ساخت (created_at)
const filtered = useMemo(() => {
const list = filterQuotes(store, filters);
const createdTs = (q: (typeof store.quotes)[number]) => {
@@ -58,65 +55,85 @@ export function DashboardPage() {
return [...list].sort((a, b) => (sortDir === "desc" ? createdTs(b) - createdTs(a) : createdTs(a) - createdTs(b)));
}, [store, filters, sortDir]);
// شمارش تفکیکی پیشنهادها بر اساس پایپ‌لاین خرید
const stats = useMemo(() => {
const total = quotes.length;
const offered = quotes.filter((q) => q.status === "offered").length;
const accepted = quotes.filter((q) => q.status === "accepted").length;
const leadTimes = quotes
.filter((q) => q.status === "arrived" && q.accepted_date && q.arrived_date)
.map((q) => daysBetween(q.arrived_date, q.accepted_date))
.filter((x): x is number => x != null);
const avgLead = leadTimes.length ? Math.round(leadTimes.reduce((a, b) => a + b, 0) / leadTimes.length) : null;
return { total, offered, accepted, avgLead };
const arrived = quotes.filter((q) => q.status === "arrived").length;
return { total, offered, accepted, arrived };
}, [quotes]);
// وضعیت فعال در StatsBar بر اساس مقدار فعلی filters.status
const activeStatusFilter: StatusFilter = useMemo(() => {
if (filters.status === "offered") return "offered";
if (filters.status === "accepted") return "accepted";
if (filters.status === "arrived") return "arrived";
return "all";
}, [filters.status]);
// تغییر فیلتر وضعیت هنگام کلیک روی هر یک از ۴ کارت
const handleStatusFilterChange = (status: StatusFilter) => {
setFilters((prev) => ({
...prev,
status: status === "all" ? "" : status,
}));
};
const categoryOptions = useMemo(() => [...new Set(materials.map((m) => m.category))], [materials]);
const supplierOptions = useMemo(() => suppliers.map((s) => ({ id: s.id, name: s.name })), [suppliers]);
return (
<div className="min-h-screen">
<div className="min-h-screen pb-20 md:pb-6">
<Header
title="دفتر پیشنهادهای خرید"
brand
menu={<AppSwitcherMenu activeModule="procurement" />}
actions={
<>
<Button asChild variant="slate">
<Link to="/suppliers" className="no-underline">
<Users className="h-4 w-4" />
<div className="hidden md:flex items-center gap-2">
<Link to="/suppliers" className="btn-line-file no-underline">
<Users className="h-3.5 w-3.5" />
تأمین‌کننده‌ها
</Link>
</Button>
<Button asChild variant="slate">
<Link to="/projects" className="no-underline">
<FolderKanban className="h-4 w-4" />
پروژه‌ها
{/* دکمه پروژه‌ها */}
<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>
</Button>
<Button asChild variant="line">
<Link to="/units" className="no-underline">
<Ruler className="h-4 w-4" />
ضرایب تبدیل
{/* دکمه ضرایب تبدیل */}
<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>
</Button>
<Button asChild variant="primary">
<Link to="/wizard" className="no-underline">
{/* دکمه اصلی: افزودن پیشنهاد جدید */}
<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>
</Button>
</>
</div>
}
/>
<PageLayout className="flex flex-col gap-4">
<PageLayout className="flex flex-col gap-3 px-3 md:px-6">
<StatsBar
total={stats.total}
offered={stats.offered}
accepted={stats.accepted}
avgLeadTime={stats.avgLead}
arrived={stats.arrived}
activeFilter={activeStatusFilter}
onFilterChange={handleStatusFilterChange}
/>
<FilterToolbar
filters={filters}
@@ -136,6 +153,15 @@ export function DashboardPage() {
/>
</PageLayout>
{/* دکمه شناور اکشن (FAB) مخصوص نسخه موبایل */}
<Link
to="/wizard"
aria-label="افزودن پیشنهاد جدید"
className="md:hidden fixed bottom-20 left-4 z-40 flex h-14 w-14 items-center justify-center rounded-full bg-accent text-white shadow-xl shadow-accent/30 active:scale-95 transition-transform"
>
<Plus className="h-7 w-7" />
</Link>
<AcceptModal quoteId={acceptId} onClose={() => setAcceptId(null)} />
<EditModal quoteId={editId} onClose={() => setEditId(null)} />
</div>
+455 -150
View File
@@ -1,14 +1,16 @@
import { useEffect, useMemo, useState } from "react";
import { Trash2 } from "lucide-react";
import { Trash2, AlertCircle, Plus, Package, CreditCard, Calendar } from "lucide-react";
import { toast } from "sonner";
import type { Attachment, LineItem, Milestone, Quote, Termsheet } from "../../lib/types";
import { api, nextDashedId, nextId } from "../../lib/api";
import { cn } from "../../lib/utils";
import {
useMaterials,
useProjects,
useQuotes,
useSuppliers,
useUpdateQuote,
useDeleteQuote,
useAddSupplier,
useAddMaterial,
} from "../../hooks";
@@ -50,7 +52,7 @@ interface EditItemRow {
interface EditTsCard {
key: string;
domId: string; // "" یا "new-ts-..." برای کارت جدید
domId: string;
milestones: Milestone[];
}
@@ -59,19 +61,28 @@ export interface EditModalProps {
onClose: () => void;
}
// مودال ویرایش کامل پیشنهاد
export function EditModal({ quoteId, onClose }: EditModalProps) {
const quotes = useQuotes();
const suppliers = useSuppliers();
const materials = useMaterials();
const projects = useProjects();
const updateQuote = useUpdateQuote();
const deleteQuote = useDeleteQuote();
const addSupplier = useAddSupplier();
const addMaterial = useAddMaterial();
const q = useMemo(() => quotes.find((x) => x.id === quoteId) || null, [quotes, quoteId]);
const hasReceipts = useMemo(() => {
if (!q) return false;
if (q.status === "arrived") return true;
return (q.line_items || []).some(
(li) => (li.received_qty != null && li.received_qty > 0) || !!li.arrived_date,
);
}, [q]);
const [ready, setReady] = useState(false);
const [activeTab, setActiveTab] = useState<"items" | "terms" | "dates">("items");
const [supplierName, setSupplierName] = useState("");
const [selectedProjects, setSelectedProjects] = useState<string[]>([]);
const [notes, setNotes] = useState("");
@@ -83,16 +94,44 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
const [acceptedDate, setAcceptedDate] = useState<string | null>(null);
const [arrivedDate, setArrivedDate] = useState<string | null>(null);
const [status, setStatus] = useState("offered");
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleting, setDeleting] = useState(false);
const handleDelete = async () => {
if (!q) return;
setDeleting(true);
try {
await deleteQuote.mutateAsync(q.id);
toast.success("پیشنهاد خرید با موفقیت حذف شد");
setShowDeleteConfirm(false);
onClose();
} catch (err) {
toast.error(err instanceof Error ? err.message : "حذف پیشنهاد ناموفق بود");
} finally {
setDeleting(false);
}
};
const [globalSpecs, setGlobalSpecs] = useState<string[]>([]);
useEffect(() => {
let alive = true;
void api
.specsList()
.then((list) => alive && setGlobalSpecs(list))
.catch(() => {});
return () => {
alive = false;
};
}, []);
// گزینه‌های مشخصه — همه‌ی مشخصه‌های ثبت‌شده (کش)
const specOptions = useMemo(() => {
const set = new Set<string>();
globalSpecs.forEach((s) => s && set.add(s));
materials.forEach((m) => (m.specs || []).forEach((s) => s && set.add(s)));
itemRows.forEach((r) => r.specs.forEach((s) => s && set.add(s)));
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
}, [materials, itemRows]);
}, [materials, itemRows, globalSpecs]);
// گزینه‌های پروژه — پروژه‌های ثبت‌شده + پروژه‌های خود پیشنهاد
const projectOptions = useMemo(() => {
const set = new Set<string>();
projects.forEach((p) => p.name && set.add(p.name));
@@ -144,7 +183,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
setReady(true);
}, [q, suppliers, materials]);
// فاکتورها مستقیماً از سرور (MinIO) خوانده می‌شوند — نه از state محلی
useEffect(() => {
if (!quoteId) return;
let alive = true;
@@ -180,7 +218,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
unit: "",
price: null,
total: null,
tsDomIds: [],
tsDomIds: tsCards[0] ? [tsCards[0].domId || tsCards[0].key] : [],
acceptedTsId: null,
rejected: false,
restore: true,
@@ -204,9 +242,13 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
};
const confirm = async () => {
if (hasReceipts && status !== q.status) {
toast.error("این پیشنهاد به‌دلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.");
return;
}
const next: Quote = JSON.parse(JSON.stringify(q));
// تأمین‌کننده — موجود یا جدید
if (supplierName.trim()) {
const norm = normalize(supplierName);
const exist = suppliers.find((s) => normalize(s.name) === norm);
@@ -215,7 +257,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
} else {
const newSup = { id: nextId("s", suppliers), name: supplierName.trim(), location: "", phone: "", contact: "", topic: "", categories: [] };
const created = await addSupplier.mutateAsync(newSup);
next.supplier_id = created.id; // UUID واقعی از بک‌اند
next.supplier_id = created.id;
}
}
@@ -224,7 +266,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
next.notes = notes.trim();
next.invoices = invoices;
// termsheetها — id موجود حفظ می‌شود، جدیدها ساخته می‌شوند
const tsIdMap: Record<string, string> = {};
const newTermsheets: Termsheet[] = [];
tsCards.forEach((card, i) => {
@@ -241,7 +282,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
});
next.termsheets = newTermsheets;
// اقلام
const newItems: LineItem[] = [];
const materialsCopy = [...materials];
for (const row of itemRows) {
@@ -252,7 +292,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
const qty = row.qty;
const price = row.price;
const total = row.total;
if (!cat && !brand && qty == null && price == null) continue; // ردیف خالی
if (!cat && !brand && qty == null && price == null) continue;
let material = materialsCopy.find(
(m) =>
@@ -266,7 +306,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
} else {
const nm = {
id: nextId("m", materialsCopy),
name: [brand, cat, ...specs].filter(Boolean).join(" ") || "کالای جدید",
name: (cat || brand || specs.join(" ") || "").trim() || "کالای جدید",
topic: materialById(materials, row.origLi?.material_id || "").topic || "",
category: cat,
brand,
@@ -274,7 +314,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
base_unit: unit,
};
materialsCopy.push(nm);
// منتظر ساخت متریال می‌مانیم تا id واقعی (UUID) به قلم وصل شود
const created = await addMaterial.mutateAsync(nm);
materialId = created.id;
}
@@ -316,11 +355,12 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
rejected,
arrived_date: row.origLi?.arrived_date ?? null,
projects: row.origLi?.projects ?? [],
target_project: status === "offered" ? null : (row.origLi?.target_project ?? null),
target_project_id: status === "offered" ? null : (row.origLi?.target_project_id ?? null),
});
}
next.line_items = newItems.length ? newItems : next.line_items;
// ترم‌شیت بدون قلم بی‌معنی است — حذف
const referencedTs = new Set<string>();
next.line_items.forEach((li) => (li.termsheet_ids || []).forEach((tid) => referencedTs.add(tid)));
next.termsheets = next.termsheets.filter((t) => referencedTs.has(t.id));
@@ -330,14 +370,12 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
}
});
// تاریخ‌ها و وضعیت
next.quote_date = quoteDate || q.quote_date;
next.expiry_date = expiryDate || "";
next.expiry_date = expiryDate ? (expiryDate.length > 10 ? expiryDate.slice(0, 10) : expiryDate) : "";
next.accepted_date = acceptedDate;
next.arrived_date = arrivedDate;
next.status = status as Quote["status"];
// پاک‌سازی خودکار در بازگشت وضعیت
if (next.status === "offered") {
next.accepted_date = null;
next.arrived_date = null;
@@ -348,6 +386,8 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
li.rejected = false;
li.arrived_date = null;
li.accepted_ts_id = null;
li.target_project = null;
li.target_project_id = null;
});
} else if (next.status === "accepted") {
next.arrived_date = null;
@@ -367,15 +407,105 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
};
return (
<>
<Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>ویرایش پیشنهاد</DialogTitle>
<DialogContent className="flex h-[88vh] max-h-[850px] w-[95vw] max-w-3xl flex-col p-0 overflow-hidden rounded-2xl border border-gray-200 bg-white text-gray-900 shadow-2xl">
{/* هدر سفید و تمیز */}
<DialogHeader className="border-b border-gray-100 bg-white px-4 py-3 sm:px-6 sm:py-4 shrink-0 text-right">
<DialogTitle className="text-base font-bold text-gray-900 sm:text-lg">ویرایش پیشنهاد خرید</DialogTitle>
<div className="text-[12px] text-gray-500 truncate mt-0.5">
{supplierName ? `تأمین‌کننده: ${supplierName}` : "تنظیم اقلام، مبالغ و شرایط پرداخت"}
</div>
</DialogHeader>
<div className="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<div className="field">
<label>تأمین‌کننده</label>
{hasReceipts && (
<div className="flex items-center gap-2 border-b border-amber-200 bg-amber-50 px-4 py-2 text-[11.5px] font-medium text-amber-800 shrink-0">
<AlertCircle className="h-4 w-4 shrink-0 text-amber-600" />
<span>این پیشنهاد به‌دلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.</span>
</div>
)}
{/* تب‌بار سفید و استاندارد */}
<div className="flex items-center border-b border-gray-100 bg-gray-50/70 px-3 py-2 sm:px-6 gap-2 shrink-0">
<button
type="button"
onClick={() => setActiveTab("items")}
className={cn(
"flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-xs font-semibold transition-all",
activeTab === "items"
? "bg-white text-blue-600 shadow-xs border border-gray-200"
: "text-gray-600 hover:text-gray-900 hover:bg-white/60",
)}
>
<Package className="h-3.5 w-3.5" />
<span>اقلام و سفارش</span>
<span
className={cn(
"rounded-full px-1.5 py-0.2 text-[10.5px] font-mono",
activeTab === "items" ? "bg-blue-50 text-blue-600" : "bg-gray-200/70 text-gray-600",
)}
>
{faNum(itemRows.length)}
</span>
</button>
<button
type="button"
onClick={() => setActiveTab("terms")}
className={cn(
"flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-xs font-semibold transition-all",
activeTab === "terms"
? "bg-white text-blue-600 shadow-xs border border-gray-200"
: "text-gray-600 hover:text-gray-900 hover:bg-white/60",
)}
>
<CreditCard className="h-3.5 w-3.5" />
<span>شرایط پرداخت</span>
<span
className={cn(
"rounded-full px-1.5 py-0.2 text-[10.5px] font-mono",
activeTab === "terms" ? "bg-blue-50 text-blue-600" : "bg-gray-200/70 text-gray-600",
)}
>
{faNum(tsCards.length)}
</span>
</button>
<button
type="button"
onClick={() => setActiveTab("dates")}
className={cn(
"flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-xs font-semibold transition-all",
activeTab === "dates"
? "bg-white text-blue-600 shadow-xs border border-gray-200"
: "text-gray-600 hover:text-gray-900 hover:bg-white/60",
)}
>
<Calendar className="h-3.5 w-3.5" />
<span>وضعیت و اسناد</span>
{invoices.length > 0 && (
<span
className={cn(
"rounded-full px-1.5 py-0.2 text-[10.5px] font-mono",
activeTab === "dates" ? "bg-blue-50 text-blue-600" : "bg-gray-200/70 text-gray-600",
)}
>
{faNum(invoices.length)}
</span>
)}
</button>
</div>
{/* محتوای مودال — پس‌زمینه کاملاً سفید */}
<div className="flex-1 overflow-y-auto p-3.5 sm:p-5 space-y-4 bg-white text-gray-800">
{/* تب ۱: اقلام و مشخصات */}
{activeTab === "items" && (
<div className="space-y-4">
{/* کارت تأمین‌کننده و پروژه */}
<div className="rounded-xl border border-gray-200 bg-gray-50/50 p-3.5 sm:p-4 space-y-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="flex min-w-0 flex-col gap-1">
<label className="text-xs font-semibold text-gray-600">تأمین‌کننده</label>
<Combobox
value={supplierName}
onChange={(it: { id: string; label: string }) => setSupplierName(it.label)}
@@ -386,8 +516,9 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
onInputChange={(v) => setSupplierName(v)}
/>
</div>
<div className="field">
<label>پروژه</label>
<div className="flex min-w-0 flex-col gap-1">
<label className="text-xs font-semibold text-gray-600">پروژه</label>
<MultiSelectCombobox
values={selectedProjects}
onChange={setSelectedProjects}
@@ -395,63 +526,111 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
placeholder="انتخاب پروژه..."
/>
</div>
<div className="field col-span-2">
<label>یادداشت</label>
</div>
<div className="flex min-w-0 flex-col gap-1">
<label className="text-xs font-semibold text-gray-600">یادداشت</label>
<textarea
rows={2}
className="w-full rounded-lg border border-line bg-panel px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
className="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs sm:text-sm text-gray-800 shadow-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30"
value={notes}
placeholder="یادداشت‌های داخلی این معامله..."
onChange={(e) => setNotes(e.target.value)}
/>
</div>
</div>
{/* اقلام */}
<div className="border-t border-line pt-3">
<div className="mb-2 text-[13px] font-bold">اقلام سفارش:</div>
<div className="flex flex-col gap-3">
{itemRows.map((row) => (
<div key={row.key} className={`rounded-lg border p-3 ${row.rejected ? "border-dashed border-line opacity-60" : "border-line"}`}>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<div className="field">
<label>متریال</label>
{/* لیست اقلام */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-gray-800">اقلام سفارش ({faNum(itemRows.length)})</span>
</div>
{itemRows.map((row, idx) => (
<div
key={row.key}
className={cn(
"rounded-xl border p-3.5 sm:p-4 transition-all bg-white",
row.rejected ? "border-dashed border-gray-300 opacity-60 bg-gray-50/40" : "border-gray-200 shadow-xs",
)}
>
<div className="flex items-center justify-between pb-2 mb-3 border-b border-gray-100">
<div className="flex items-center gap-1.5 min-w-0">
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-blue-50 text-[11px] font-bold text-blue-600">
{faNum(idx + 1)}
</span>
<span className="text-xs font-bold text-gray-900 truncate">
{row.cat || "قلم جدید"} {row.brand ? `• ${row.brand}` : ""}
</span>
</div>
{!hasReceipts && (
<button
type="button"
className="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs text-red-600 hover:bg-red-50 transition-colors"
title="حذف قلم"
onClick={() => setItemRows((rows) => rows.filter((r) => r.key !== row.key))}
>
<Trash2 className="h-3.5 w-3.5" />
<span className="text-[11px]">حذف</span>
</button>
)}
</div>
<div className="space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
<div className="flex flex-col gap-1">
<label className="text-[11px] font-semibold text-gray-600">متریال / دسته</label>
<input
className="h-9 w-full rounded-md border border-line bg-panel px-2.5 text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
placeholder="متریال"
disabled={hasReceipts}
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
placeholder="نام متریال"
value={row.cat}
onChange={(e) => patchItem(row.key, { cat: e.target.value })}
/>
</div>
<div className="field">
<label>برند</label>
<div className="flex flex-col gap-1">
<label className="text-[11px] font-semibold text-gray-600">برند</label>
<input
className="h-9 w-full rounded-md border border-line bg-panel px-2.5 text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
disabled={hasReceipts}
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
placeholder="برند"
value={row.brand}
onChange={(e) => patchItem(row.key, { brand: e.target.value })}
/>
</div>
<div className="field">
<label>مشخصه</label>
</div>
<div className="grid grid-cols-2 gap-2.5">
<div className="flex flex-col gap-1">
<label className="text-[11px] font-semibold text-gray-600">مشخصه</label>
<MultiSelectCombobox
disabled={hasReceipts}
values={row.specs}
onChange={(specs) => patchItem(row.key, { specs })}
options={specOptions}
placeholder="مشخصه"
/>
</div>
<div className="field">
<label>واحد</label>
<div className="flex flex-col gap-1">
<label className="text-[11px] font-semibold text-gray-600">واحد</label>
<input
className="h-9 w-full rounded-md border border-line bg-panel px-2.5 text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
placeholder="واحد"
disabled={hasReceipts}
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
placeholder="واحد سنجش"
value={row.unit}
onChange={(e) => patchItem(row.key, { unit: e.target.value })}
/>
</div>
<div className="field">
<label>تعداد</label>
</div>
{/* جعبه ۳ عددی مقادیر */}
<div className="grid grid-cols-3 gap-2 rounded-lg bg-gray-50 p-2.5 border border-gray-200">
<div className="flex flex-col gap-1">
<label className="text-[10.5px] font-semibold text-gray-600">تعداد</label>
<NumberInput
disabled={hasReceipts}
value={row.qty}
onChange={(qty) => {
patchItem(row.key, {
@@ -462,9 +641,11 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
placeholder="تعداد"
/>
</div>
<div className="field">
<label>قیمت واحد</label>
<div className="flex flex-col gap-1">
<label className="text-[10.5px] font-semibold text-gray-600">قیمت واحد</label>
<NumberInput
disabled={hasReceipts}
value={row.price}
onChange={(price) => {
patchItem(row.key, {
@@ -472,13 +653,15 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
total: row.qty != null && price != null ? Math.round(row.qty * price * 100) / 100 : row.total,
});
}}
placeholder="قیمت واحد"
placeholder="واحد"
maxDecimals={2}
/>
</div>
<div className="field">
<label>قیمت کل</label>
<div className="flex flex-col gap-1">
<label className="text-[10.5px] font-semibold text-gray-600">قیمت کل</label>
<NumberInput
disabled={hasReceipts}
value={row.total}
onChange={(t) => {
patchItem(row.key, {
@@ -487,42 +670,45 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
t != null && row.qty != null && row.qty > 0 ? Math.round((t / row.qty) * 100) / 100 : row.price,
});
}}
placeholder="قیمت کل"
placeholder="جمع"
/>
</div>
<div className="flex items-end justify-end">
<button
type="button"
className="inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-[12px] text-muted transition-colors hover:bg-red-bg hover:text-red"
title="حذف قلم"
onClick={() => setItemRows((rows) => rows.filter((r) => r.key !== row.key))}
>
<Trash2 className="h-3.5 w-3.5" />
حذف
</button>
</div>
</div>
{/* تخصیص termsheet / بازگرداندن */}
<div className="mt-2.5 border-t border-line pt-2">
{/* شرایط پرداخت برای این قلم */}
<div className="pt-2 border-t border-gray-100">
{row.rejected ? (
<label className="ts-cb-label e-restore">
<label className="inline-flex items-center gap-1.5 text-xs font-semibold text-blue-600 cursor-pointer">
<input
type="checkbox"
className="accent-blue-600 h-4 w-4 rounded"
checked={row.restore}
onChange={(e) => patchItem(row.key, { restore: e.target.checked })}
/>
بازگرداندن به معامله
بازگرداندن این قلم به معامله
</label>
) : (
<>
<div className="mb-1 text-[10.5px] font-semibold text-muted">این قلم تحت کدام termsheet هاست؟</div>
<div className="flex flex-col gap-1.5">
<span className="text-[10.5px] font-semibold text-gray-500">شرایط پرداخت مرتبط:</span>
<div className="flex flex-wrap gap-1.5">
{tsCards.map((card, idx) => (
<label key={card.key} className="chip cursor-pointer">
{tsCards.map((card, cIdx) => {
const isSelected = row.tsDomIds.includes(card.domId || card.key);
return (
<label
key={card.key}
className={cn(
"inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs cursor-pointer select-none transition-colors",
isSelected
? "border-blue-500 bg-blue-50 font-semibold text-blue-700"
: "border-gray-200 bg-white text-gray-600 hover:text-gray-900",
hasReceipts && "cursor-not-allowed opacity-60",
)}
>
<input
type="checkbox"
className="me-1 accent-accent"
checked={row.tsDomIds.includes(card.domId || card.key)}
disabled={hasReceipts}
className="accent-blue-600 h-3.5 w-3.5 rounded"
checked={isSelected}
onChange={(e) => {
const next = new Set(row.tsDomIds);
const domId = card.domId || card.key;
@@ -531,64 +717,170 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
patchItem(row.key, { tsDomIds: [...next] });
}}
/>
{tsLabel(card, idx)}
<span>{tsLabel(card, cIdx)}</span>
</label>
))}
);
})}
</div>
</div>
</>
)}
</div>
</div>
))}
</div>
<Button variant="line" size="sm" className="mt-2" onClick={addItemRow}>
+ افزودن قلم
))}
{!hasReceipts && (
<Button
type="button"
variant="line"
size="sm"
className="w-full border-dashed border-gray-300 py-2 text-xs font-medium text-gray-700 gap-1 hover:border-blue-500 hover:text-blue-600 hover:bg-blue-50/50"
onClick={addItemRow}
>
<Plus className="h-3.5 w-3.5" />
<span>افزودن قلم جدید</span>
</Button>
)}
</div>
</div>
)}
{/* تب ۲: شرایط پرداخت */}
{activeTab === "terms" && (
<div className="space-y-3.5">
<div className="flex items-center justify-between">
<div className="text-xs font-bold text-gray-800">گزینه‌های شرایط پرداخت</div>
{!hasReceipts && (
<Button
type="button"
variant="line"
size="sm"
className="gap-1 text-xs text-gray-700"
onClick={addTsCard}
>
<Plus className="h-3.5 w-3.5" />
<span>افزودن گزینه جدید</span>
</Button>
)}
</div>
{/* termsheetها */}
<div className="border-t border-line pt-3">
<div className="mb-2 text-[13px] font-bold">termsheet ها (شرایط پرداخت):</div>
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-3">
{tsCards.map((card, idx) => (
<div key={card.key} className="variant-card">
<div className="variant-head">
<span className="variant-num">{tsLabel(card, idx)}</span>
<button type="button" className="variant-x" title="حذف" onClick={() => removeTsCard(card.key)}>
×
</button>
<div
key={card.key}
className="rounded-xl border border-gray-200 bg-white p-3.5 sm:p-4 shadow-xs"
>
<div className="flex items-center justify-between border-b border-gray-100 pb-2 mb-2.5">
<div className="flex items-center gap-1.5">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-blue-50 text-[11px] font-bold text-blue-600">
{faNum(idx + 1)}
</span>
<span className="text-xs font-bold text-gray-900">{tsLabel(card, idx)}</span>
<span className="text-[11px] text-gray-500 font-medium">
({faNum(card.milestones.length)} مرحله)
</span>
</div>
<div className="mb-1 flex flex-col gap-1.5">
{!hasReceipts && tsCards.length > 1 && (
<button
type="button"
className="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs text-red-600 hover:bg-red-50 transition-colors"
title="حذف گزینه"
onClick={() => removeTsCard(card.key)}
>
<Trash2 className="h-3.5 w-3.5" />
<span className="text-[11px]">حذف</span>
</button>
)}
</div>
<div className="flex flex-col gap-2 mb-3">
{card.milestones.map((ms, i) => (
<MilestoneRow
key={i}
milestone={ms}
disabled={hasReceipts}
onChange={(nextMs) =>
updateTs(card.key, { milestones: card.milestones.map((x, j) => (j === i ? nextMs : x)) })
updateTs(card.key, {
milestones: card.milestones.map((x, j) => (j === i ? nextMs : x)),
})
}
onRemove={() =>
updateTs(card.key, {
milestones: card.milestones.filter((_, j) => j !== i),
})
}
onRemove={() => updateTs(card.key, { milestones: card.milestones.filter((_, j) => j !== i) })}
/>
))}
</div>
{!hasReceipts && (
<Button
type="button"
variant="line"
size="sm"
className="mt-1"
onClick={() => updateTs(card.key, { milestones: [...card.milestones, { when_iso: null, amount: null }] })}
className="w-full sm:w-auto text-xs text-gray-700"
onClick={() =>
updateTs(card.key, {
milestones: [...card.milestones, { when_iso: null, amount: null }],
})
}
>
+ مرحله پرداخت
<Plus className="h-3.5 w-3.5 me-1" />
افزودن مرحله پرداخت
</Button>
)}
</div>
))}
</div>
<Button variant="line" size="sm" className="mt-2" onClick={addTsCard}>
+ افزودن termsheet
</Button>
</div>
)}
{/* تب ۳: وضعیت و اسناد */}
{activeTab === "dates" && (
<div className="space-y-3.5">
<div className="rounded-xl border border-gray-200 bg-white p-3.5 sm:p-4 space-y-2 shadow-xs">
<label className="text-xs font-bold text-gray-900 block">وضعیت فعلی پیشنهاد</label>
<select
className="h-9 w-full rounded-lg border border-gray-200 bg-white px-3 text-xs sm:text-sm text-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
value={status}
disabled={hasReceipts}
onChange={(e) => setStatus(e.target.value)}
>
{statusOptionsFor(q.status).map((st) => (
<option key={st} value={st}>
{STATUS_LABEL[st]}
</option>
))}
</select>
<div className="text-[11px] text-gray-500">
* تغییر وضعیت برای عقب بردن مرحله معامله کاربرد دارد.
</div>
</div>
{/* فاکتورها */}
<div className="border-t border-line pt-3">
<div className="mb-1 text-[13px] font-bold">فاکتورهای اصلی (چند فایل):</div>
<div className="rounded-xl border border-gray-200 bg-white p-3.5 sm:p-4 space-y-2.5 shadow-xs">
<div className="text-xs font-bold text-gray-900">تاریخ‌های چرخه خرید</div>
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2">
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold text-gray-600">تاریخ پیشنهاد</label>
<JalaliDatePicker value={quoteDate} onChange={setQuoteDate} placeholder="تاریخ" withTime />
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold text-gray-600">تاریخ انقضا</label>
<JalaliDatePicker value={expiryDate} onChange={setExpiryDate} placeholder="تاریخ" />
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold text-gray-600">تاریخ پذیرش</label>
<JalaliDatePicker value={acceptedDate} onChange={setAcceptedDate} placeholder="تاریخ" withTime />
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold text-gray-600">تاریخ رسید</label>
<JalaliDatePicker value={arrivedDate} onChange={setArrivedDate} placeholder="تاریخ" withTime />
</div>
</div>
</div>
<div className="rounded-xl border border-gray-200 bg-white p-3.5 sm:p-4 space-y-2 shadow-xs">
<div className="text-xs font-bold text-gray-900">فاکتورهای اصلی و پیوست‌ها</div>
<FileList
files={invoices}
onChange={setInvoices}
@@ -602,57 +894,70 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
urlOf={(a) => (a.id ? api.files.downloadUrl(q.id, "invoice", a.id) : null)}
/>
</div>
{/* تاریخ‌ها و وضعیت */}
<div className="border-t border-line pt-3">
<div className="mb-2 text-[12px] text-muted">تاریخ‌ها و وضعیت:</div>
<div className="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<div className="field">
<label>تاریخ پیشنهاد</label>
<JalaliDatePicker value={quoteDate} onChange={setQuoteDate} placeholder="تاریخ" withTime />
</div>
<div className="field">
<label>تاریخ انقضا</label>
<JalaliDatePicker value={expiryDate} onChange={setExpiryDate} placeholder="تاریخ" withTime />
</div>
<div className="field">
<label>تاریخ پذیرش</label>
<JalaliDatePicker value={acceptedDate} onChange={setAcceptedDate} placeholder="تاریخ" withTime />
</div>
<div className="field">
<label>تاریخ رسید</label>
<JalaliDatePicker value={arrivedDate} onChange={setArrivedDate} placeholder="تاریخ" withTime />
</div>
<div className="field col-span-2">
<label>وضعیت (فقط بازگشت به مرحله‌های قبل)</label>
<select
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
value={status}
disabled={q.status === "arrived"}
onChange={(e) => setStatus(e.target.value)}
>
{statusOptionsFor(q.status).map((st) => (
<option key={st} value={st}>
{STATUS_LABEL[st]}
</option>
))}
</select>
{q.status === "arrived" && (
<div className="mt-1.5 rounded-md bg-amber-bg px-2.5 py-1.5 text-[11.5px] font-medium text-amber">
به دلیل ثبت ورود اقلام به انبار، امکان تغییر وضعیت پیشنهاد وجود ندارد.
</div>
)}
</div>
</div>
</div>
<DialogFooter>
<Button variant="slate" onClick={onClose}>
{/* فوتر سفید، محکم و چسبان */}
<DialogFooter className="border-t border-gray-200 bg-white px-4 py-2.5 sm:px-6 sm:py-3 shrink-0 flex flex-row items-center justify-between gap-2">
{q.status === "offered" ? (
<Button
type="button"
variant="danger"
size="sm"
onClick={() => setShowDeleteConfirm(true)}
className="gap-1 px-2.5 sm:px-3 text-xs shrink-0"
>
<Trash2 className="h-3.5 w-3.5" />
<span className="hidden sm:inline">حذف</span>
</Button>
) : (
<div />
)}
<div className="flex items-center gap-2">
<Button variant="slate" size="sm" onClick={onClose} className="px-3.5 text-xs">
انصراف
</Button>
<Button onClick={() => void confirm()}>ذخیره تغییرات</Button>
<Button size="sm" onClick={() => void confirm()} className="px-4 text-xs">
ذخیره تغییرات
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
{/* مودال تایید حذف سفید */}
<Dialog open={showDeleteConfirm} onOpenChange={(open) => !deleting && setShowDeleteConfirm(open)}>
<DialogContent className="z-[60] max-w-md p-4 sm:p-6 bg-white border border-gray-200 text-gray-900">
<DialogHeader>
<DialogTitle className="text-gray-900">حذف پیشنهاد خرید</DialogTitle>
</DialogHeader>
<div className="py-2 text-xs sm:text-sm text-gray-600">
آیا از حذف کامل این پیشنهاد اطمینان دارید؟ این عملیات غیرقابل بازگشت است.
</div>
<DialogFooter className="flex items-center justify-end gap-2 pt-2">
<Button
type="button"
variant="slate"
size="sm"
onClick={() => setShowDeleteConfirm(false)}
disabled={deleting}
>
انصراف
</Button>
<Button
type="button"
variant="danger"
size="sm"
onClick={() => void handleDelete()}
disabled={deleting}
>
{deleting ? "در حال حذف..." : "حذف نهایی"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
+189 -10
View File
@@ -1,5 +1,6 @@
import { useState } from "react";
import { SlidersHorizontal, X, ArrowUpDown, RotateCcw } from "lucide-react";
import { Combobox } from "../../components/Combobox";
import { STATUS_LABEL, STATUS_ORDER } from "../../lib/constants";
export interface Filters {
category: string;
@@ -8,8 +9,33 @@ export interface Filters {
search: string;
}
// لیست وضعیت‌ها از dictionary واحد (constants) — نه آرایه‌ی تکراری
const STATUS_ITEMS = STATUS_ORDER.map((s) => STATUS_LABEL[s]);
// گزینه‌های استاندارد و یکپارچه وضعیت هماهنگ با کارت‌های بالای صفحه
const STATUS_OPTIONS = [
{ id: "offered", label: "پیشنهاد باز" },
{ id: "accepted", label: "پذیرفته‌شده" },
{ id: "arrived", label: "تحویل‌شده" },
];
const STATUS_MAP: Record<string, string> = {
offered: "پیشنهاد باز",
accepted: "پذیرفته‌شده",
arrived: "تحویل‌شده",
};
const STATUS_TO_KEY: Record<string, string> = {
"پیشنهاد باز": "offered",
"پذیرفته‌شده": "accepted",
"تحویل‌شده": "arrived",
"پیشنهاد شده": "offered",
"پذیرفته شده": "accepted",
"رسیده": "arrived",
"باز": "offered",
"پذیرفته": "accepted",
"تحویل": "arrived",
offered: "offered",
accepted: "accepted",
arrived: "arrived",
};
export interface FilterToolbarProps {
filters: Filters;
@@ -20,12 +46,164 @@ export interface FilterToolbarProps {
onToggleSort: () => void;
}
// نوار فیلتر دفتر: ۳ کامبوباکس + جستجوی آزاد + دکمه‌ی مرتب‌سازی ⇅
export function FilterToolbar({ filters, onChange, categories, suppliers, sortDir, onToggleSort }: FilterToolbarProps) {
export function FilterToolbar({
filters,
onChange,
categories,
suppliers,
sortDir,
onToggleSort,
}: FilterToolbarProps) {
const [isOpen, setIsOpen] = useState(false);
const set = (patch: Partial<Filters>) => onChange({ ...filters, ...patch });
// شمارش تعداد فیلترهای فعال به جز سرچ
const activeFilterCount = [filters.category, filters.supplier, filters.status].filter(Boolean).length;
const clearFilters = () => {
onChange({ ...filters, category: "", supplier: "", status: "" });
};
// تبدیل کلید انگلیسی به لیبل فارسی برای نمایش داخل کامبوباکس
const statusDisplayValue = STATUS_MAP[filters.status] || filters.status;
const handleStatusChange = (val: string) => {
const key = STATUS_TO_KEY[val] || val;
set({ status: key });
};
return (
<div className="flex flex-wrap items-end gap-3">
<>
{/* ===== نمای موبایل (صفحه‌نمایش‌های زیر md) ===== */}
<div className="flex md:hidden items-center gap-2">
{/* اینپوت جستجوی متنی */}
<div className="relative flex-1">
<input
type="search"
className="h-10 w-full rounded-xl border border-line bg-panel px-3 text-sm shadow-sm placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
placeholder="جستجو در نام، برند، پروژه..."
value={filters.search}
onChange={(e) => set({ search: e.target.value })}
/>
</div>
{/* دکمه فیلترها با بج تعداد انتخاب‌شده */}
<button
type="button"
onClick={() => setIsOpen(true)}
className={`relative inline-flex h-10 items-center justify-center gap-1.5 rounded-xl border px-3 text-xs font-semibold shadow-sm transition-colors ${
activeFilterCount > 0
? "border-accent bg-accent-bg text-accent"
: "border-line bg-panel text-ink hover:bg-slate-bg"
}`}
title="فیلترها"
>
<SlidersHorizontal className="h-4 w-4" />
<span>فیلتر</span>
{activeFilterCount > 0 && (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-accent text-[11px] font-bold text-white">
{activeFilterCount}
</span>
)}
</button>
{/* دکمه تغییر جهت سورت */}
<button
type="button"
onClick={onToggleSort}
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-line bg-panel text-ink shadow-sm hover:bg-slate-bg"
title={sortDir === "desc" ? "جدیدترین اول" : "قدیمی‌ترین اول"}
>
<ArrowUpDown className="h-4 w-4 text-accent" />
</button>
</div>
{/* باتم‌شیت کشویی فیلترها برای موبایل */}
{isOpen && (
<div className="fixed inset-0 z-50 flex flex-col justify-end bg-black/40 backdrop-blur-xs md:hidden">
<div className="fixed inset-0" onClick={() => setIsOpen(false)} aria-hidden="true" />
<div className="relative z-10 max-h-[85vh] w-full overflow-y-auto rounded-t-2xl border-t border-line bg-panel p-4 shadow-2xl">
{/* هدر کشو */}
<div className="mb-4 flex items-center justify-between border-b border-line pb-3">
<div className="flex items-center gap-2">
<SlidersHorizontal className="h-4 w-4 text-accent" />
<span className="text-sm font-bold text-ink">فیلترهای پیشرفته</span>
</div>
<div className="flex items-center gap-3">
{activeFilterCount > 0 && (
<button
type="button"
onClick={clearFilters}
className="inline-flex items-center gap-1 text-xs text-rose-500 hover:underline"
>
<RotateCcw className="h-3 w-3" />
پاک کردن
</button>
)}
<button
type="button"
onClick={() => setIsOpen(false)}
className="rounded-lg p-1 text-muted hover:bg-slate-bg hover:text-ink"
aria-label="بستن"
>
<X className="h-5 w-5" />
</button>
</div>
</div>
{/* دراپ‌داون‌ها داخل کشو */}
<div className="flex flex-col gap-3.5">
<div className="field">
<label className="mb-1 block text-xs font-semibold text-muted">دسته</label>
<Combobox
value={filters.category}
onChange={(it: { id: string; label: string }) => set({ category: it.label })}
items={categories}
allowAdd={false}
placeholder="همه دسته‌ها"
onInputChange={(v) => set({ category: v })}
/>
</div>
<div className="field">
<label className="mb-1 block text-xs font-semibold text-muted">تأمین‌کننده</label>
<Combobox
value={filters.supplier}
onChange={(it: { id: string; label: string }) => set({ supplier: it.label })}
items={suppliers}
allowAdd={false}
placeholder="همه تأمین‌کنندگان"
onInputChange={(v) => set({ supplier: v })}
/>
</div>
<div className="field">
<label className="mb-1 block text-xs font-semibold text-muted">وضعیت</label>
<Combobox
value={statusDisplayValue}
onChange={(it: { id: string; label: string }) => handleStatusChange(it.label)}
items={STATUS_OPTIONS}
allowAdd={false}
placeholder="همه وضعیت‌ها"
onInputChange={handleStatusChange}
/>
</div>
</div>
{/* دکمه تأیید و بستن */}
<button
type="button"
onClick={() => setIsOpen(false)}
className="mt-5 flex h-11 w-full items-center justify-center rounded-xl bg-accent text-sm font-bold text-white shadow-md shadow-accent/20 active:scale-[0.99]"
>
مشاهده نتایج
</button>
</div>
</div>
)}
{/* ===== نمای دسکتاپ (md به بالا) ===== */}
<div className="hidden md:flex flex-wrap items-end gap-3">
<div className="field min-w-[150px] flex-1">
<label>دسته</label>
<Combobox
@@ -51,12 +229,12 @@ export function FilterToolbar({ filters, onChange, categories, suppliers, sortDi
<div className="field min-w-[150px] flex-1">
<label>وضعیت</label>
<Combobox
value={filters.status}
onChange={(it: { id: string; label: string }) => set({ status: it.label })}
items={STATUS_ITEMS}
value={statusDisplayValue}
onChange={(it: { id: string; label: string }) => handleStatusChange(it.label)}
items={STATUS_OPTIONS}
allowAdd={false}
placeholder="همه"
onInputChange={(v) => set({ status: v })}
onInputChange={handleStatusChange}
/>
</div>
<div className="field min-w-[200px] flex-1">
@@ -82,5 +260,6 @@ export function FilterToolbar({ filters, onChange, categories, suppliers, sortDi
</button>
</div>
</div>
</>
);
}
+4 -4
View File
@@ -12,7 +12,7 @@ export interface LedgerTableProps {
onEdit: (id: string) => void;
}
// دفتر پیشنهادها — فهرست کارت‌ها (هر پیشنهاد یک کارت خوانا)
// دفتر پیشنهادها — فهرست کارت‌ها (واکنش‌گرا و بهینه برای موبایل)
export function LedgerTable({
store,
quotes,
@@ -23,14 +23,14 @@ export function LedgerTable({
}: LedgerTableProps) {
if (!quotes.length) {
return (
<div className="rounded-lg border border-line bg-panel">
<div className="rounded-xl border border-line bg-panel p-2 sm:rounded-lg">
<EmptyState icon={Inbox} text="پیشنهادی مطابق فیلترها یافت نشد" />
</div>
);
}
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2.5 sm:gap-3">
{quotes.map((q) => (
<QuoteCard
key={q.id}
@@ -42,7 +42,7 @@ export function LedgerTable({
onEdit={() => onEdit(q.id)}
/>
))}
<div className="py-1 text-center text-[11px] text-muted">
<div className="px-2 py-3 text-center text-[10.5px] leading-5 text-muted sm:py-1 sm:text-[11px]">
هشدار «منقضی» = قیمت پیشنهادی از تاریخ انقضا گذشته؛ برای پیش‌بینی قیمت، شمارش نمی‌شود.
</div>
</div>
+119 -92
View File
@@ -1,5 +1,5 @@
import { Link } from "react-router-dom";
import { AlarmClock, ChevronDown, Pencil, TriangleAlert, Building2, Package, CreditCard } from "lucide-react";
import { AlarmClock, ChevronDown, Pencil, TriangleAlert, Building2, Package, CreditCard, Check } from "lucide-react";
import type { Quote, Store } from "../../lib/types";
import {
isExpired,
@@ -27,16 +27,16 @@ export interface QuoteCardProps {
onEdit: () => void;
}
function InfoCell({ label, children }: { label: string; children: React.ReactNode }) {
function InfoCell({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) {
return (
<div className="min-w-0">
<div className="mb-0.5 text-[10.5px] font-semibold text-muted">{label}</div>
<div className="min-w-0 text-[13px] leading-6">{children}</div>
<div className={cn("min-w-0", className)}>
<div className="mb-0.5 text-[10px] font-semibold text-muted sm:text-[10.5px]">{label}</div>
<div className="min-w-0 text-xs leading-5 text-ink sm:text-[13px] sm:leading-6">{children}</div>
</div>
);
}
// کارت یک پیشنهاد در دفتر — جایگزین جدول ۱۱ ستونه
// کارت پیشنهاد خرید — بهینه‌شده برای کاربری سرپا با موبایل
export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit }: QuoteCardProps) {
const sup = supplierById(store.suppliers, q.supplier_id);
const expired = isExpired(q);
@@ -46,22 +46,25 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
const unitWarns = unitWarning(store, q);
const summary = itemsSummary(store, q);
// ستون پذیرش (با ساعت)
// ستون / دکمه پذیرش
let acceptedCell: React.ReactNode;
if (q.status === "offered") {
acceptedCell = (
<button className="chip chip-act" onClick={onAccept}>
پذیرش
<button
onClick={onAccept}
className="inline-flex h-7 items-center justify-center gap-1 rounded-md bg-emerald-600 px-2.5 text-xs font-bold text-white shadow-xs active:scale-95 transition-transform"
>
<Check className="h-3.5 w-3.5" />
<span>پذیرش</span>
</button>
);
} else if (q.accepted_date) {
acceptedCell = <span className="tabular-nums">{fmtDateTime(q.accepted_date)}</span>;
acceptedCell = <span className="tabular-nums font-medium">{fmtDateTime(q.accepted_date)}</span>;
} else {
acceptedCell = <span className="text-muted">—</span>;
}
// ستون رسید — v2.6: تأیید رسید فقط در «پنل مدیریت انبار» توسط مدیر پروژه انجام می‌شود؛
// اینجا فقط وضعیت نمایش داده می‌شود. با رسید جزیی: «رسیده X از Y» + «در راه Z».
// وضعیت رسید کالا
const activeItems = (q.line_items || []).filter((li) => !li.rejected);
const receivedSum = activeItems.reduce((s, li) => s + (li.received_qty || 0), 0);
const orderedSum = activeItems.reduce((s, li) => s + (li.quantity || 0), 0);
@@ -71,25 +74,24 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
const anyReceived = receivedSum > 0;
const fullyDelivered = activeItems.length > 0 && fulfilledItems === activeItems.length && anyReceived;
// نشان پیشرفت تکمیل در هدر کارت (جایگزین نشان وضعیت در حالت پذیرش/رسید):
// در راه (آبی) → رسید: X از Y قلم (کهربایی) → تکمیل شد (سبز)
// نشان پیشرفت تکمیل
let fulfillmentBadge: React.ReactNode = null;
if (q.status === "accepted" || q.status === "arrived") {
if (fullyDelivered || q.status === "arrived") {
fulfillmentBadge = (
<span className="inline-flex items-center gap-1 rounded-full border border-emerald-200 bg-emerald-50 px-2 py-0.5 text-[11px] font-bold text-emerald-700">
✓ تکمیل شد
<span className="inline-flex items-center gap-1 rounded-full border border-emerald-200 bg-emerald-50 px-2 py-0.5 text-[10.5px] font-bold text-emerald-700 sm:text-[11px]">
✓ تکمیل
</span>
);
} else if (anyReceived) {
fulfillmentBadge = (
<span className="inline-flex items-center gap-1 rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-[11px] font-bold text-amber-700">
رسید: {fmtNum(fulfilledItems)} از {fmtNum(activeItems.length)} قلم
<span className="inline-flex items-center gap-1 rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-[10.5px] font-bold text-amber-700 sm:text-[11px]">
رسید: {fmtNum(fulfilledItems)}/{fmtNum(activeItems.length)}
</span>
);
} else {
fulfillmentBadge = (
<span className="inline-flex items-center gap-1 rounded-full border border-blue-200 bg-blue-50 px-2 py-0.5 text-[11px] font-bold text-blue-700">
<span className="inline-flex items-center gap-1 rounded-full border border-blue-200 bg-blue-50 px-2 py-0.5 text-[10.5px] font-bold text-blue-700 sm:text-[11px]">
⏳ در راه
</span>
);
@@ -97,19 +99,12 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
}
let arrivedCell: React.ReactNode;
// آخرین لحظه‌ی رسید ثبت‌شده بین قلم‌ها (رسیدهای جزیی توسط مدیر پروژه)
const lastReceiptAt = activeItems.reduce<Date | null>((acc, li) => {
if (!li.arrived_date) return acc;
const t = new Date(li.arrived_date).getTime();
if (Number.isNaN(t)) return acc;
return !acc || t > acc.getTime() ? new Date(li.arrived_date) : acc;
}, null);
const receiptTimeLine =
lastReceiptAt != null ? (
<span className="block text-[10.5px] font-normal text-muted">
آخرین رسید: {fmtDateTime(lastReceiptAt.toISOString())}
</span>
) : null;
if (q.status === "accepted") {
if (q.arrived_date) {
@@ -117,12 +112,11 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
} else if (receivedSum > 0) {
arrivedCell = (
<span className="tabular-nums">
رسیده {fmtNum(receivedSum)} از {fmtNum(orderedSum)} — در راه {fmtNum(Math.max(0, orderedSum - receivedSum))}
{receiptTimeLine}
{fmtNum(receivedSum)} از {fmtNum(orderedSum)}
</span>
);
} else {
arrivedCell = <span className="text-muted">در انتظار PM</span>;
arrivedCell = <span className="text-muted">در انتظار انبار</span>;
}
} else if (q.status === "arrived") {
arrivedCell = (
@@ -137,37 +131,44 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
return (
<div
className={cn(
"overflow-hidden rounded-lg border bg-panel transition-colors",
"overflow-hidden rounded-xl border bg-panel transition-colors shadow-2xs sm:rounded-lg",
expired ? "border-amber/40" : "border-line hover:border-accent/40",
expanded && "border-accent/60",
expanded && "border-accent/60 ring-1 ring-accent/20",
)}
>
{/* سربرگ کارت */}
<div className="flex items-center gap-3 border-b border-line bg-bg/40 px-4 py-2.5">
<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">
<div className="flex items-center gap-2.5 border-b border-line bg-bg/40 px-3 py-2 sm:gap-3 sm:px-4 sm:py-2.5">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-accent-bg text-xs font-bold text-accent-dark sm:h-9 sm:w-9 sm:text-sm">
{sup.name.slice(0, 1)}
</span>
<div className="min-w-0">
<div className="min-w-0 flex-1">
<Link
to={`/suppliers/${q.supplier_id}`}
className="block truncate text-[13.5px] font-bold text-ink no-underline hover:text-accent"
className="block truncate text-xs font-bold text-ink no-underline hover:text-accent sm:text-[13.5px]"
>
{sup.name}
</Link>
{sup.location && <div className="truncate text-[11px] text-muted">{sup.location}</div>}
{sup.location && <div className="truncate text-[10px] text-muted sm:text-[11px]">{sup.location}</div>}
</div>
<div className="ms-auto flex shrink-0 items-center gap-1.5">
<div className="ms-auto flex shrink-0 items-center gap-1 sm:gap-1.5">
{fulfillmentBadge ?? (
<Badge variant={q.status as "offered" | "accepted" | "arrived"}>{STATUS_LABEL[q.status]}</Badge>
<Badge variant={q.status as "offered" | "accepted" | "arrived"} className="text-[10px] sm:text-xs">
{STATUS_LABEL[q.status]}
</Badge>
)}
<button className="chip" onClick={onEdit} title="ویرایش پیشنهاد">
<button
className="chip h-7 px-2 sm:h-auto sm:px-2.5"
onClick={onEdit}
title="ویرایش پیشنهاد"
aria-label="ویرایش"
>
<Pencil className="h-3 w-3" />
ویرایش
<span className="hidden sm:inline">ویرایش</span>
</button>
<button
className="chip chip-expand"
className="chip chip-expand h-7 w-7 p-0 sm:h-auto sm:w-auto sm:p-1.5"
onClick={onToggleExpand}
title={expanded ? "جمع کردن" : "نمایش جریان"}
title={expanded ? "جمع کردن" : "نمایش جزئیات"}
aria-expanded={expanded}
>
<ChevronDown className={cn("h-4 w-4 transition-transform duration-200", expanded && "rotate-180")} />
@@ -175,75 +176,95 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
</div>
</div>
{/* اطلاعات کلیدی */}
<div className="grid grid-cols-2 gap-x-5 gap-y-3 px-4 py-3 md:grid-cols-4">
<InfoCell label="اقلام">
{/* اطلاعات کلیدی — چیدمان بهینه‌شده موبایل */}
<div className="grid grid-cols-2 gap-x-3 gap-y-3 p-3 sm:gap-x-5 sm:gap-y-3.5 sm:p-4 md:grid-cols-4">
{/* ۱. پروژه — تمام‌عرض در موبایل */}
<InfoCell label="پروژه" className="col-span-2 md:col-span-1">
<div className="flex items-center gap-1.5 text-xs font-semibold text-ink sm:text-[13px]">
<Building2 className="h-3.5 w-3.5 shrink-0 text-muted" />
<span className="truncate">
{q.projects && q.projects.length > 0 ? q.projects.join("، ") : projectCell(q)}
</span>
</div>
</InfoCell>
{/* ۲. اقلام — تمام‌عرض زیر پروژه با شکستن متن بدون سه‌نقطه */}
<InfoCell label="اقلام" className="col-span-2 md:col-span-1">
{summary.count > 1 ? (
<div className="flex flex-col gap-0.5">
<span className="font-semibold">{fmtNum(summary.count)} قلم</span>
<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) => {
<div className="flex flex-col gap-1.5">
<span className="font-bold text-xs text-ink sm:text-sm">
{fmtNum(summary.count)} قلم کالا
</span>
<div className="flex flex-col gap-1.5 rounded-lg border border-line/60 bg-bg/50 p-2.5 text-[11px] leading-5 text-muted sm:text-[11.5px]">
{store.quotes
.find((x) => x.id === q.id)
?.line_items?.filter((li) => !li.rejected)
.slice(0, 3)
.map((li) => {
const m = materialById(store.materials, li.material_id);
return (
<span key={li.id} className="flex items-center gap-1">
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-accent/40" />
{materialLabel(m)}
<span className="text-[10px] text-muted/70">
{fmtNum(li.quantity)} {li.unit}
<div
key={li.id}
className="flex items-start justify-between gap-2 border-b border-line/30 pb-1.5 last:border-0 last:pb-0"
>
<span className="min-w-0 flex-1 font-medium text-ink leading-snug break-words">
• {materialLabel(m)}
</span>
<span className="shrink-0 font-semibold tabular-nums text-ink text-[10.5px] pt-0.5">
{fmtNum(li.quantity)}{" "}
<span className="font-normal text-muted text-[9.5px]">{li.unit}</span>
</span>
</div>
);
})}
{(q.line_items?.length || 0) > 3 && (
<span className="pt-0.5 text-[10px] text-muted">
و {(q.line_items?.length || 0) - 3} قلم دیگر...
</span>
)}
</div>
</div>
) : (
summary.text
<div className="flex items-start gap-1.5 text-xs font-medium leading-5 text-ink sm:text-[13px]">
<span className="break-words">{summary.text}</span>
</div>
)}
{unitWarns.map((w) => (
<Link
key={w.unit}
to={`/units?cat=${encodeURIComponent(w.cat)}&unit=${encodeURIComponent(w.unit)}`}
className="unit-warn mt-1"
className="unit-warn mt-1 inline-flex items-center gap-1 text-[10px]"
title={`ضریب تبدیل «${esc(w.unit)}» را در داشبورد واحدها ثبت کنید`}
>
<TriangleAlert className="h-2.5 w-2.5" />
<TriangleAlert className="h-2.5 w-2.5 shrink-0" />
واحد جدید: {w.unit}
</Link>
))}
</InfoCell>
<InfoCell label="پروژه">
<span className="inline-flex items-center gap-1.5">
<Building2 className="h-3.5 w-3.5 shrink-0 text-muted" />
{q.projects && q.projects.length > 0 ? (
<span className="flex flex-col gap-0.5">
{q.projects.map((p, i) => (
<span key={i}>{p}</span>
))}
</span>
) : (
projectCell(q)
)}
</span>
</InfoCell>
<InfoCell label="مجموع">
{/* ۳. مجموع مبلغ */}
<InfoCell label="مجموع مبلغ" className="col-span-1">
{pc ? (
<span className="font-bold tabular-nums">
{pc.num} <span className="num-words">({pc.words} تومان)</span>
</span>
<div>
<div className="text-sm font-extrabold tabular-nums text-ink sm:text-base">
{pc.num} <span className="text-[10px] font-normal text-muted sm:text-xs">تومان</span>
</div>
<div className="hidden text-[11px] text-muted sm:block">({pc.words})</div>
</div>
) : (
"—"
)}
</InfoCell>
<InfoCell label="معامله">
{/* ۴. شرایط معامله */}
<InfoCell label="شرایط معامله" className="col-span-1">
{ts.title !== "—" ? (
<div>
<div className="font-semibold" style={ts.color ? { color: ts.color } : undefined}>
<div className="text-xs font-semibold sm:text-[13px]" style={ts.color ? { color: ts.color } : undefined}>
{ts.title}
</div>
{ts.sub && <div className="text-[11px] text-muted">{ts.sub}</div>}
{ts.sub && <div className="truncate text-[10px] text-muted sm:text-[11px]">{ts.sub}</div>}
</div>
) : (
<span className="text-muted">—</span>
@@ -251,29 +272,35 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
</InfoCell>
</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">
<span className="inline-flex items-center gap-1.5">
<Package className="h-3 w-3" />
پیشنهاد: <b className="text-ink tabular-nums">{fmtDateTime(q.quote_date)}</b>
{/* تاریخ‌ها و اکشن‌ها */}
<div className="grid grid-cols-2 gap-2 border-t border-line bg-bg/40 p-2.5 text-[11px] text-muted sm:flex sm:flex-wrap sm:items-center sm:gap-x-5 sm:gap-y-2 sm:px-4 sm:py-2 sm:text-[11.5px]">
<span className="inline-flex items-center gap-1.5 truncate">
<Package className="h-3 w-3 shrink-0" />
<span>تاریخ:</span>
<b className="tabular-nums text-ink">{fmtDate(q.quote_date)}</b>
</span>
<span className="inline-flex items-center gap-1.5">
<CreditCard className="h-3 w-3" />
معتبر تا:{" "}
<span className="inline-flex items-center gap-1.5 truncate">
<CreditCard className="h-3 w-3 shrink-0" />
<span>اعتبار:</span>
<b className={cn("tabular-nums", expired ? "text-amber" : "text-ink")}>
{fmtDate(q.expiry_date)}
</b>
{expired && <AlarmClock className="h-3 w-3 text-amber" aria-label="منقضی" />}
{expired && <AlarmClock className="h-3 w-3 shrink-0 text-amber" aria-label="منقضی" />}
</span>
<span className="inline-flex items-center gap-1.5">
پذیرش: {acceptedCell}
<span>پذیرش:</span>
{acceptedCell}
</span>
<span className="inline-flex items-center gap-1.5">
رسید: {arrivedCell}
<span className="inline-flex items-center gap-1.5 truncate">
<span>رسید:</span>
{arrivedCell}
</span>
</div>
{/* جریان (expand) */}
{/* جزئیات تکمیلی بازشونده */}
{expanded && <QuoteDetail store={store} q={q} />}
</div>
);
+2 -6
View File
@@ -3,13 +3,11 @@ import { Package, Scale, CreditCard, CalendarDays, Paperclip, Building2, Ban } f
import type { Attachment, Quote, Store } from "../../lib/types";
import { api } from "../../lib/api";
import {
formatMaterialTitle,
itemTermsheetIds,
lineItemTotal,
materialById,
materialLabel,
materialSpecs,
refConverted,
specsLabel,
} from "../../lib/quoteUtils";
import { fmtDate, fmtDateTime, fmtNum } from "../../lib/formatters";
import { groupCommas } from "../../lib/numutil";
@@ -69,7 +67,6 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
const total = lineItemTotal(li);
const projs = itemProjects(q, li);
const conv = isRejected ? null : refConverted(store, li);
const specText = specsLabel(materialSpecs(m));
// رسید جزیی → قلم به دو بخش «رسید شده» و «در راه» شکسته می‌شود (نمایشی):
// مجموع دو بخش همیشه با تعداد/مبلغ اصلی برابر است (عدم تغییر جمع کل پیشنهاد).
@@ -97,8 +94,7 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
>
<div className="fb-name">
<Package className="me-1 inline h-3 w-3 text-muted" />
{materialLabel(m)}
{specText ? ` · ${specText}` : ""}
{formatMaterialTitle(m)}
</div>
<div className="fb-meta">
<Building2 className="me-1 inline h-3 w-3" /> {projs.length ? projs.join("، ") : "—"}
+2 -3
View File
@@ -2,10 +2,10 @@ import { Link } from "react-router-dom";
import { AlarmClock, ChevronDown, TriangleAlert } from "lucide-react";
import type { Quote, Store } from "../../lib/types";
import {
formatMaterialTitle,
isExpired,
itemsSummary,
materialById,
materialSpecs,
projectCell,
quoteTotal,
supplierById,
@@ -92,11 +92,10 @@ export function QuoteRow({ store, q, expanded, onToggleExpand, onAccept, onEdit
<div className="flex flex-col gap-0.5 text-[11px] text-muted">
{(q.line_items || []).filter((li) => !li.rejected).map((li) => {
const m = materialById(store.materials, li.material_id);
const specs = materialSpecs(m);
return (
<span key={li.id} className="flex items-center gap-1">
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-accent/40" />
{m.category} {m.brand}{specs.length ? ` · ${specs.join(" · ")}` : ""}
{formatMaterialTitle(m)}
<span className="text-[10px] text-muted/70">
{fmtNum(li.quantity)} {li.unit}
</span>
@@ -33,9 +33,11 @@ export function ArrivalsPage() {
const load = useCallback(async () => {
try {
const activeProject = isPM ? user?.project_id || undefined : undefined;
const [arr, whs] = await Promise.all([
apiV26.pm.onTheWay(),
apiV26.warehouses.list(isPM ? user?.project_id || undefined : undefined),
// پروژه‌ی فعال صریحاً ارسال می‌شود؛ بک‌اند تخصیص PM را بررسی می‌کند
apiV26.pm.onTheWay(activeProject),
apiV26.warehouses.list(activeProject),
]);
setItems(arr);
setWarehouses(whs);
@@ -186,7 +188,7 @@ export function ArrivalsPage() {
item_decision_id: it.item_decision_id,
received_qty: draftReceivedOf(it),
allocations,
});
}, isPM ? user?.project_id || undefined : undefined);
}
toast.success("رسید (کامل یا جزیی) ثبت شد و به انبارها وارد شد ✓ — اقلام باقی‌مانده همچنان در راه هستند");
// همگام‌سازی با دفتر تدارکات: store پیشنهادها را تازه کن تا کارت‌های
@@ -14,11 +14,11 @@ import { uniqueUnits } from "../../lib/units";
import type { InventoryTransaction, TransactionType } from "../../lib/types";
import { faNum } from "../../lib/formatters";
import { gregorianToJalali, MONTHS, toFa } from "../../lib/jalali";
import { cn } from "../../lib/utils";
type FormType = TransactionType | "TRANSFER";
const FORM_TYPES: { t: FormType; label: string; dir: string; cls: "in" | "out" | "mv" | "cnt"; color: string; desc: string }[] = [
// رنگ‌بندی معنایی: سبز زمردی = ورود، رز = خروج، آبی = خنثی/جابه‌جایی/شمارش
{ t: "PURCHASE", label: "موجودی اولیه", dir: "افزایش", cls: "in", color: "#10b981", desc: "ثبت موجودی اولیه — متریال/برند/واحد را از فهرست انتخاب کنید؛ اگر نامی در فهرست نبود، همان نام تایپ‌شده هنگام ثبت به‌صورت خودکار در کاتالوگ سراسری ساخته می‌شود. ثبت حداقل یک مشخصه فنی الزامی است." },
{ t: "WITHDRAWAL", label: "برداشت", dir: "کاهش", cls: "out", color: "#e11d48", desc: "انبار را انتخاب و نام متریال را تایپ کنید؛ همه مدل‌ها (برند و مشخصه متفاوت) نمایش داده می‌شوند. برداشت‌ها در «کنترل روزانه» امروز دیده می‌شوند." },
{ t: "RETURN", label: "برگشت", dir: "افزایش", cls: "in", color: "#10b981", desc: "برگشت از مصالحی که امروز برداشت شده‌اند — نام متریال را تایپ کنید و مقدار برگشتی هر ردیف را بزنید." },
@@ -46,7 +46,6 @@ function jalDateLabel(d: Date): string {
return `${toFa(jd)} ${MONTHS[jm - 1]} ${toFa(jy)}`;
}
// تغییر موجودی — پورت کامل صفحه‌ی ارسالی (همه انواع تراکنش)
export function ChangeInventoryPage() {
const user = useAuthStore((s) => s.user);
const data = useInventoryData();
@@ -60,15 +59,12 @@ export function ChangeInventoryPage() {
const [brandTxt, setBrandTxt] = useState("");
const [unitTxt, setUnitTxt] = useState("");
const [qty, setQty] = useState("");
// تاریخ و ساعت به صورت ISO (تقویم شمسی — JalaliDatePicker)
const [dateInput, setDateInput] = useState<string>(() => new Date().toISOString());
// مشخصه‌های فنی — چیپ‌های چندتایی، دقیقاً مثل فیلد «مشخصه» در ویزارد دفتر تدارکات
const [specsChips, setSpecsChips] = useState<string[]>([]);
const [note, setNote] = useState("");
const [rows, setRows] = useState<RowDraft[]>([]);
const [busy, setBusy] = useState(false);
// prefill از URL (پرش از کنترل روزانه)
useEffect(() => {
const t = params.get("type");
const w = params.get("wh");
@@ -91,7 +87,6 @@ export function ChangeInventoryPage() {
const formRecords = useMemo(() => {
const txt = matTxt.trim();
// انتقال از انبار مبدأ استفاده می‌کند، بقیه از انبار انتخابی
const wid = type === "TRANSFER" ? fromId : whId;
return data.inventory.filter((r) => {
if (r.warehouse?.id !== wid) return false;
@@ -105,8 +100,6 @@ export function ChangeInventoryPage() {
const unitIdByName = (name: string) => data.lookup.units.find((u) => (u.name_fa || u.code) === name)?.id || null;
const parseNum = (s: string) => { const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, "")); return isFinite(v) ? v : NaN; };
// تبدیل امن تاریخ فرم به ISO — اگر فیلد خالی/نامعتبر بود (که قبلاً submit را
// بی‌صدا می‌شکست) به «هم‌اکنون» برمی‌گردد تا ثبت هیچ‌وقت قفل نشود.
const toServerIso = () => {
try {
const d = new Date(dateInput);
@@ -115,8 +108,6 @@ export function ChangeInventoryPage() {
return new Date().toISOString();
};
// هر چیپ یک مشخصه است؛ در specs انبار به‌صورت کلید با مقدار خالی ذخیره می‌شود
// تا رکوردهای انبارِ دارای مشخصه‌های متفاوت از هم جدا بمانند
const specsFromChips = () => {
const out: Record<string, string> = {};
specsChips.forEach((c) => {
@@ -151,9 +142,6 @@ export function ChangeInventoryPage() {
.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime());
}, [data.transactions, whId, matTxt]);
// --- پیدا شده: مانده‌ی مفقودی هر ردیف = مفقودی − مجموع پیداشده‌های قبلیِ همان رکورد
const lossKey = (t: InventoryTransaction) =>
[t.warehouse_id, t.material?.id || "", t.brand?.id || "", JSON.stringify(t.specs || {})].join("|");
const foundRemaining = useMemo(() => {
const foundByKey = new Map<string, number>();
data.transactions
@@ -170,7 +158,9 @@ export function ChangeInventoryPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lossTxs, data.transactions, whId, matTxt]);
// مفقودی‌های کاملاً پیداشده از فهرست «در انتظار پیدا شدن» حذف می‌شوند
const lossKey = (t: InventoryTransaction) =>
[t.warehouse_id, t.material?.id || "", t.brand?.id || "", JSON.stringify(t.specs || {})].join("|");
const pendingLosses = useMemo(
() =>
lossTxs
@@ -188,7 +178,6 @@ export function ChangeInventoryPage() {
try {
for (const op of ops) await postOne(op);
toast.success("ثبت شد ✓");
// اعلام به تب‌های دیگر (داشبورد/کنترل روزانه/... باز خودشان را تازه می‌کنند)
notifyDataChanged("inventory");
setRows([]);
setMatTxt("");
@@ -205,7 +194,6 @@ export function ChangeInventoryPage() {
}
};
// ---------- submit handlers ----------
const submitPurchase = async () => {
const matName = matTxt.trim();
const brandName = brandTxt.trim();
@@ -216,10 +204,9 @@ export function ChangeInventoryPage() {
if (!brandName) return toast.error("نام برند را وارد کنید");
if (!unitName) return toast.error("نام واحد را وارد کنید");
if (!(n > 0)) return toast.error("تعداد باید بزرگ‌تر از صفر باشد");
if (!specsComplete) return toast.error("حداقل یک مشخصه فنی ثبت کنید (نام را بنویسید و Enter بزنید)");
if (!specsComplete) return toast.error("حداقل یک مشخصه فنی ثبت کنید");
try {
// ساختِ به‌هنگام موجودیت‌های ناشناخته در کاتالوگ سراسری (بدون پیش‌نیاز تدارکات)
let uid = unitIdByName(unitName);
if (!uid) {
const created = await apiV26.catalog.createUnit(unitName);
@@ -264,7 +251,6 @@ export function ChangeInventoryPage() {
await postSeq(ops);
};
// ---------- شمارش موجودی: پیش‌نمایش اختلاف + تأیید قبل از ثبت ----------
interface CountConfirmRow {
material: string;
unit: string;
@@ -358,7 +344,7 @@ export function ChangeInventoryPage() {
if (!(n > 0)) return null;
const item = pendingLosses[i];
if (!item) return null;
if (n > item.remaining) return null; // سقف سمت کلاینت (بک‌اند هم رد می‌کند)
if (n > item.remaining) return null;
const t = item.tx;
return { transaction_type: "FOUND", warehouse_id: whId, material_id: t.material?.id, brand_id: t.brand?.id || null, quantity: n, unit_id: t.unit?.id ?? null, specs: t.specs || {}, note: r.note, transaction_date: toServerIso() };
})
@@ -382,12 +368,10 @@ export function ChangeInventoryPage() {
const canSubmit = (): boolean => {
if (type === "PURCHASE") {
// نام‌های تایپ‌شده لازم نیست از قبل در فهرست باشند — در submit ساخته می‌شوند
return !!(whId && matTxt.trim() && brandTxt.trim() && unitTxt.trim() && parseNum(qty) > 0 && specsComplete);
}
if (type === "TRANSFER") return !!(fromId && toId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
if (type === "RETURN") return !!(whId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
// پیدا شده: هر مقدار واردشده باید داخل مانده‌ی مفقودی همان ردیف باشد
if (type === "FOUND") {
return !!(
whId &&
@@ -402,7 +386,6 @@ export function ChangeInventoryPage() {
return !!(whId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
};
// sync rows when the record list changes
useEffect(() => {
if (type === "RETURN") {
setRows(todayWithdrawals.map((t) => ({ key: t.inventory_id, qty: "", note: "" })));
@@ -447,54 +430,68 @@ export function ChangeInventoryPage() {
const isTransfer = type === "TRANSFER";
return (
<div className="min-h-screen">
<div className="min-h-screen overflow-x-hidden pb-20">
<Header
title="تغییر موجودی"
sub={inventorySubheader(user)}
backTo="/inventory"
backLabel="بازگشت به داشبورد"
/>
<PageLayout className="flex flex-col gap-3">
{/* انتخاب نوع تراکنش */}
<div className="rounded-xl border border-line bg-panel p-3">
<PageLayout className="flex flex-col gap-3 px-3 md:px-6 w-full max-w-full">
{/* انتخاب نوع تغییر موجودی — چیپ‌های لمسی مرتب در موبایل */}
<div className="rounded-xl border border-line bg-panel p-3 w-full shadow-2xs">
<h3 className="mb-2 text-[13px] font-bold">نوع تغییر موجودی</h3>
<div className="flex flex-wrap gap-1.5">
<div className="grid grid-cols-2 gap-1.5 sm:flex sm:flex-wrap">
{FORM_TYPES.map((f) => (
<button
key={f.t}
type="button"
onClick={() => { setType(f.t); setRows([]); setMatTxt(""); setWhId(""); setFromId(""); setToId(""); }}
className={`rounded-lg border px-3 py-2 text-[12.5px] ${type === f.t ? "font-bold text-white" : "border-line bg-panel"}`}
className={cn(
"rounded-lg border px-3 py-2 text-xs sm:text-[12.5px] text-start sm:text-center transition-all",
type === f.t ? "font-bold text-white shadow-xs" : "border-line bg-panel text-ink hover:bg-bg"
)}
style={type === f.t ? { background: f.color, borderColor: f.color } : undefined}
>
{f.label} <span className="text-[10.5px] opacity-80">({f.dir})</span>
<div>{f.label}</div>
<span className="text-[10px] opacity-80">({f.dir})</span>
</button>
))}
</div>
<p className="mt-2 text-[11.5px] leading-5 text-muted">{meta.desc}</p>
<p className="mt-2.5 text-[11.5px] leading-5 text-muted">{meta.desc}</p>
</div>
<div className="rounded-xl border border-line bg-panel p-4">
{/* انتخاب انبارها */}
<div className="rounded-xl border border-line bg-panel p-3 sm:p-4 w-full shadow-2xs">
{/* انتخاب انبارها (چیپ‌های لمسی به جای سلکت خام) */}
{!isTransfer ? (
<div className="mb-3 grid gap-3 md:grid-cols-2">
<div className="field">
<label>انبار *</label>
<select
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
value={whId}
onChange={(e) => { setWhId(e.target.value); setMatTxt(""); setRows([]); }}
<div className="mb-3 flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold text-muted">انبار *</label>
<div className="flex flex-wrap gap-1.5">
{whOpts.map((w) => (
<button
key={w.id}
type="button"
onClick={() => { setWhId(w.id); setMatTxt(""); setRows([]); }}
className={cn(
"rounded-lg px-3 py-1.5 text-xs font-medium transition-colors",
whId === w.id
? "bg-accent text-white font-bold shadow-xs"
: "border border-line bg-bg/50 text-ink hover:bg-panel"
)}
>
<option value="">— انتخاب کنید —</option>
{whOpts.map((w) => <option key={w.id} value={w.id}>{w.name}</option>)}
</select>
{w.name}
</button>
))}
</div>
</div>
{(type === "WITHDRAWAL" || type === "DAMAGE" || type === "WASTE" || type === "LOSS" || type === "STOCK_COUNT" || type === "RETURN" || type === "FOUND") && (
<div className="field">
<label>{type === "FOUND" ? "متریال (خالی = همه مفقودی‌ها)" : "متریال *"}</label>
<label className="text-xs font-semibold text-muted">{type === "FOUND" ? "متریال (خالی = همه مفقودی‌ها)" : "متریال *"}</label>
<input
list="dl-mat"
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm"
placeholder={type === "RETURN" ? "نام متریال برداشت‌شده امروز…" : "نام متریال — از پیشنهادها یا جدید…"}
value={matTxt}
onChange={(e) => { setMatTxt(e.target.value); setRows([]); }}
@@ -508,33 +505,51 @@ export function ChangeInventoryPage() {
) : (
<div className="mb-3 grid gap-3 md:grid-cols-2">
<div className="field">
<label>از (انبار مبدأ) *</label>
<select
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
value={fromId}
onChange={(e) => { setFromId(e.target.value); setToId(""); setMatTxt(""); setRows([]); }}
<label className="text-xs font-semibold text-muted">از (انبار مبدأ) *</label>
<div className="flex flex-wrap gap-1.5 mt-1">
{whOpts.map((w) => (
<button
key={w.id}
type="button"
onClick={() => { setFromId(w.id); setToId(""); setMatTxt(""); setRows([]); }}
className={cn(
"rounded-lg px-2.5 py-1 text-xs font-medium transition-colors",
fromId === w.id
? "bg-accent text-white font-bold shadow-xs"
: "border border-line bg-bg/50 text-ink hover:bg-panel"
)}
>
<option value="">— انتخاب انبار مبدأ —</option>
{whOpts.map((w) => <option key={w.id} value={w.id}>{w.name}</option>)}
</select>
{w.name}
</button>
))}
</div>
</div>
<div className="field">
<label>به (انبار مقصد) *</label>
<select
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
value={toId}
<label className="text-xs font-semibold text-muted">به (انبار مقصد) *</label>
<div className="flex flex-wrap gap-1.5 mt-1">
{whOpts.filter((w) => w.id !== fromId).map((w) => (
<button
key={w.id}
type="button"
disabled={!fromId}
onChange={(e) => { setToId(e.target.value); setMatTxt(""); setRows([]); }}
onClick={() => { setToId(w.id); setMatTxt(""); setRows([]); }}
className={cn(
"rounded-lg px-2.5 py-1 text-xs font-medium transition-colors disabled:opacity-40",
toId === w.id
? "bg-accent text-white font-bold shadow-xs"
: "border border-line bg-bg/50 text-ink hover:bg-panel"
)}
>
<option value="">— انتخاب انبار مقصد —</option>
{whOpts.filter((w) => w.id !== fromId).map((w) => <option key={w.id} value={w.id}>{w.name}</option>)}
</select>
{w.name}
</button>
))}
</div>
</div>
<div className="field md:col-span-2">
<label>متریال *</label>
<label className="text-xs font-semibold text-muted">متریال *</label>
<input
list="dl-mat2"
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm"
placeholder="نام متریال را تایپ کنید — همه مدل‌ها نمایش داده می‌شوند…"
value={matTxt}
onChange={(e) => { setMatTxt(e.target.value); setRows([]); }}
@@ -550,7 +565,7 @@ export function ChangeInventoryPage() {
{type === "PURCHASE" && (
<div className="grid gap-3 md:grid-cols-2">
<div className="field md:col-span-2">
<label>متریال *</label>
<label className="text-xs font-semibold text-muted">متریال *</label>
<Combobox
value={matTxt}
items={data.lookup.materials.map((mm) => mm.name)}
@@ -567,7 +582,7 @@ export function ChangeInventoryPage() {
)}
</div>
<div className="field">
<label>برند *</label>
<label className="text-xs font-semibold text-muted">برند *</label>
<Combobox
value={brandTxt}
items={data.lookup.brands.map((b) => b.name)}
@@ -579,7 +594,7 @@ export function ChangeInventoryPage() {
/>
</div>
<div className="field">
<label>واحد *</label>
<label className="text-xs font-semibold text-muted">واحد *</label>
<Combobox
value={unitTxt}
items={uniqueUnits(data.lookup.units).map((u) => u.name_fa || u.code)}
@@ -591,17 +606,17 @@ export function ChangeInventoryPage() {
/>
</div>
<div className="field">
<label>تعداد *</label>
<label className="text-xs font-semibold text-muted">تعداد *</label>
<input
inputMode="decimal"
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm ltr"
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm ltr"
placeholder="مثلاً ۲۰۰"
value={qty}
onChange={(e) => setQty(e.target.value)}
/>
</div>
<div className="field">
<label>تاریخ و زمان (شمسی)</label>
<label className="text-xs font-semibold text-muted">تاریخ و زمان (شمسی)</label>
<JalaliDatePicker
value={dateInput}
onChange={(iso) => setDateInput(iso ?? new Date().toISOString())}
@@ -610,7 +625,7 @@ export function ChangeInventoryPage() {
/>
</div>
<div className="field md:col-span-2">
<label>مشخصه‌های فنی *</label>
<label className="text-xs font-semibold text-muted">مشخصه‌های فنی *</label>
<MultiSelectCombobox
values={specsChips}
onChange={setSpecsChips}
@@ -619,9 +634,9 @@ export function ChangeInventoryPage() {
/>
</div>
<div className="field md:col-span-2">
<label>یادداشت</label>
<label className="text-xs font-semibold text-muted">یادداشت</label>
<textarea
className="w-full rounded-lg border border-line bg-panel px-3 py-2 text-sm"
className="w-full rounded-lg border border-line bg-panel px-3 py-2 text-xs sm:text-sm"
placeholder="شماره فاکتور، تأمین‌کننده، توضیحات…"
value={note}
onChange={(e) => setNote(e.target.value)}
@@ -647,12 +662,10 @@ export function ChangeInventoryPage() {
/>
)}
{/* برگشت — برداشت‌های امروز */}
{type === "RETURN" && (
<ReturnTable rows={rows} setRows={setRows} txs={todayWithdrawals} whId={whId} matTxt={matTxt} unitNameFn={(id) => unitName(data.lookup.units, id)} />
)}
{/* انتقال */}
{type === "TRANSFER" && (
<RowTable
isCount={false}
@@ -669,7 +682,6 @@ export function ChangeInventoryPage() {
/>
)}
{/* پیدا شده — مفقودی‌ها (با سقف: پیدا شده ≤ مانده‌ی مفقودی) */}
{type === "FOUND" && (
<FoundTable
rows={rows}
@@ -681,13 +693,13 @@ export function ChangeInventoryPage() {
)}
{/* دکمه ثبت */}
<div className="mt-4 flex items-center justify-between border-t border-line pt-3">
<span className="text-[12px] text-muted">👤 ثبت با نام: <b>{user?.display_name}</b></span>
<div className="mt-4 flex flex-col-reverse sm:flex-row items-center justify-between gap-3 border-t border-line pt-3">
<span className="text-[11.5px] text-muted">👤 ثبت با نام: <b>{user?.display_name}</b></span>
<button
type="button"
disabled={!canSubmit() || busy}
onClick={doSubmit}
className="rounded-lg px-5 py-2 text-[13px] font-bold text-white disabled:opacity-40"
className="w-full sm:w-auto rounded-lg px-5 py-2.5 text-xs sm:text-[13px] font-bold text-white disabled:opacity-40 shadow-xs transition-transform active:scale-95"
style={{ background: meta.color }}
>
✓ {busy ? "در حال ثبت…" : meta.label === "انتقال" ? "ثبت انتقال" : meta.label === "برگشت" ? "ثبت برگشت" : meta.label === "پیدا شده" ? "ثبت پیدا شدن" : `ثبت ${meta.label}`}
@@ -695,10 +707,10 @@ export function ChangeInventoryPage() {
</div>
</div>
{/* مودال تأیید شمارش موجودی (اختلاف قبل از ثبت) */}
{/* مودال تأیید شمارش موجودی */}
{countConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4" onClick={() => !countBusy && setCountConfirm(null)}>
<div className="w-full max-w-lg rounded-xl border border-line bg-panel p-5 shadow-xl" onClick={(e) => e.stopPropagation()}>
<div className="w-full max-w-lg rounded-xl border border-line bg-panel p-4 sm:p-5 shadow-xl" onClick={(e) => e.stopPropagation()}>
<h3 className="mb-1 text-[14px] font-bold">🔍 تأیید شمارش موجودی</h3>
<p className="mb-3 text-[11.5px] leading-5 text-muted">
موجودی هر ردیف به «تعداد شمارش‌شده» اصلاح می‌شود و اختلاف (کسری/مازاد) ثبت خواهد شد.
@@ -767,7 +779,6 @@ export function ChangeInventoryPage() {
}
function RowTable({ isCount, rows, setRows, records, unitNameFn, emptyMsg }: {
isCount: boolean;
rows: RowDraft[];
setRows: (r: RowDraft[]) => void;
@@ -778,7 +789,7 @@ function RowTable({ isCount, rows, setRows, records, unitNameFn, emptyMsg }: {
if (emptyMsg) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">{emptyMsg}</div>;
return (
<div className="overflow-x-auto">
<table className="w-full text-[12.5px]">
<table className="w-full text-[12.5px] min-w-[500px]">
<thead>
<tr className="border-b border-line text-right text-muted">
<th className="py-2 pe-3">مشخصه‌ها</th>
@@ -792,22 +803,22 @@ function RowTable({ isCount, rows, setRows, records, unitNameFn, emptyMsg }: {
<tbody>
{records.map((r, i) => (
<tr key={r.id} className="border-b border-line/50">
<td className="py-1.5 pe-3 text-muted">{Object.entries(r.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}</td>
<td className="py-1.5 pe-3">{r.brand?.name || "—"}</td>
<td className="py-1.5 pe-3">{unitNameFn(r.unit?.id)}</td>
<td className="py-1.5 pe-3 font-bold">{faNum(r.quantity_number)}</td>
<td className="py-1.5 pe-3">
<td className="py-2 pe-3 text-muted">{Object.entries(r.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}</td>
<td className="py-2 pe-3">{r.brand?.name || "—"}</td>
<td className="py-2 pe-3">{unitNameFn(r.unit?.id)}</td>
<td className="py-2 pe-3 font-bold">{faNum(r.quantity_number)}</td>
<td className="py-2 pe-3">
<input
inputMode="decimal"
className="h-7 w-24 rounded-lg border border-line bg-panel px-2 text-sm ltr"
className="h-8 w-24 rounded-lg border border-line bg-panel px-2 text-sm ltr"
placeholder={isCount ? "شمارش‌شده" : "۰"}
value={isCount && rows[i]?.qty === "" ? String(r.quantity_number) : (rows[i]?.qty || "")}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
/>
</td>
<td className="py-1.5">
<td className="py-2">
<input
className="h-7 w-32 rounded-lg border border-line bg-panel px-2 text-sm"
className="h-8 w-32 rounded-lg border border-line bg-panel px-2 text-sm"
placeholder="توضیح (اختیاری)"
value={rows[i]?.note || ""}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
@@ -817,7 +828,7 @@ function RowTable({ isCount, rows, setRows, records, unitNameFn, emptyMsg }: {
))}
</tbody>
</table>
<div className="mt-1 text-[11.5px] text-muted">ستون «موجودی فعلی» مقدار موجود هر رکورد است. برای هر ردیف تعداد و یادداشت (در صورت نیاز) وارد کنید.</div>
<div className="mt-2 text-[11px] text-muted">ستون «موجودی فعلی» مقدار موجود هر رکورد است. برای هر ردیف تعداد و یادداشت وارد کنید.</div>
</div>
);
}
@@ -831,10 +842,10 @@ function ReturnTable({ rows, setRows, txs, whId, matTxt, unitNameFn }: {
unitNameFn: (id: number | null | undefined) => string;
}) {
if (!whId || !matTxt.trim()) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">برای مشاهده برداشت‌های امروز، انبار و نام متریال را وارد کنید.</div>;
if (!txs.length) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">امروز برداشتی برای این متریال در انبار انتخابی ثبت نشده است — چیزی برای برگشت وجود ندارد.</div>;
if (!txs.length) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">امروز برداشتی برای این متریال در انبار انتخابی ثبت نشده است.</div>;
return (
<div className="overflow-x-auto">
<table className="w-full text-[12.5px]">
<table className="w-full text-[12.5px] min-w-[500px]">
<thead>
<tr className="border-b border-line text-right text-muted">
<th className="py-2 pe-3">مشخصه‌ها</th>
@@ -848,22 +859,22 @@ function ReturnTable({ rows, setRows, txs, whId, matTxt, unitNameFn }: {
<tbody>
{txs.map((t, i) => (
<tr key={t.inventory_id + i} className="border-b border-line/50">
<td className="py-1.5 pe-3 text-muted">{Object.entries(t.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}</td>
<td className="py-1.5 pe-3">{t.brand?.name || "—"}</td>
<td className="py-1.5 pe-3">{unitNameFn(t.unit?.id)}</td>
<td className="py-1.5 pe-3 font-bold">{faNum(Math.abs(t.quantity_number || 0))}</td>
<td className="py-1.5 pe-3">
<td className="py-2 pe-3 text-muted">{Object.entries(t.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}</td>
<td className="py-2 pe-3">{t.brand?.name || "—"}</td>
<td className="py-2 pe-3">{unitNameFn(t.unit?.id)}</td>
<td className="py-2 pe-3 font-bold">{faNum(Math.abs(t.quantity_number || 0))}</td>
<td className="py-2 pe-3">
<input
inputMode="decimal"
className="h-7 w-24 rounded-lg border border-line bg-panel px-2 text-sm ltr"
className="h-8 w-24 rounded-lg border border-line bg-panel px-2 text-sm ltr"
placeholder="۰"
value={rows[i]?.qty || ""}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
/>
</td>
<td className="py-1.5">
<td className="py-2">
<input
className="h-7 w-32 rounded-lg border border-line bg-panel px-2 text-sm"
className="h-8 w-32 rounded-lg border border-line bg-panel px-2 text-sm"
placeholder="توضیح برگشت"
value={rows[i]?.note || ""}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
@@ -885,17 +896,17 @@ function FoundTable({ rows, setRows, items, whId, unitNameFn }: {
unitNameFn: (id: number | null | undefined) => string;
}) {
if (!whId) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">ابتدا انبار را انتخاب کنید.</div>;
if (!items.length) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">مفقودی ثبت‌نشده‌ای (یا کاملاً پیداشده) در این انبار وجود ندارد.</div>;
if (!items.length) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">مفقودی ثبت‌نشده‌ای در این انبار وجود ندارد.</div>;
return (
<div className="overflow-x-auto">
<table className="w-full text-[12.5px]">
<table className="w-full text-[12.5px] min-w-[550px]">
<thead>
<tr className="border-b border-line text-right text-muted">
<th className="py-2 pe-3">متریال</th>
<th className="py-2 pe-3">مشخصه‌ها</th>
<th className="py-2 pe-3">برند</th>
<th className="py-2 pe-3">واحد</th>
<th className="py-2 pe-3">مانده‌ی مفقودی</th>
<th className="py-2 pe-3">مانده مفقودی</th>
<th className="py-2 pe-3">تعداد پیداشده</th>
<th className="py-2">یادداشت</th>
</tr>
@@ -906,28 +917,26 @@ function FoundTable({ rows, setRows, items, whId, unitNameFn }: {
const over = entered > remaining;
return (
<tr key={t.inventory_id + i} className={`border-b border-line/50 ${over ? "bg-rose-50/40" : ""}`}>
<td className="py-1.5 pe-3 font-semibold">{t.material?.name || "—"}</td>
<td className="py-1.5 pe-3 text-muted">{Object.entries(t.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}</td>
<td className="py-1.5 pe-3">{t.brand?.name || "—"}</td>
<td className="py-1.5 pe-3">{unitNameFn(t.unit?.id)}</td>
<td className="py-1.5 pe-3 font-bold text-rose-600">{faNum(remaining)}</td>
<td className="py-1.5 pe-3">
<td className="py-2 pe-3 font-semibold">{t.material?.name || "—"}</td>
<td className="py-2 pe-3 text-muted">{Object.entries(t.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}</td>
<td className="py-2 pe-3">{t.brand?.name || "—"}</td>
<td className="py-2 pe-3">{unitNameFn(t.unit?.id)}</td>
<td className="py-2 pe-3 font-bold text-rose-600">{faNum(remaining)}</td>
<td className="py-2 pe-3">
<input
inputMode="decimal"
className={`h-7 w-24 rounded-lg border bg-panel px-2 text-sm ltr ${over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line"}`}
className={`h-8 w-24 rounded-lg border bg-panel px-2 text-sm ltr ${over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line"}`}
placeholder="۰"
value={rows[i]?.qty || ""}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
/>
{over && (
<div className="mt-0.5 text-[10.5px] font-bold text-rose-700">
تعداد پیداشده نمی‌تواند بیشتر از مقدار مفقودی باشد.
</div>
<div className="mt-0.5 text-[10px] font-bold text-rose-700">بیشتر از مفقودی!</div>
)}
</td>
<td className="py-1.5">
<td className="py-2">
<input
className="h-7 w-32 rounded-lg border border-line bg-panel px-2 text-sm"
className="h-8 w-32 rounded-lg border border-line bg-panel px-2 text-sm"
placeholder="توضیح"
value={rows[i]?.note || ""}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
@@ -942,7 +951,6 @@ function FoundTable({ rows, setRows, items, whId, unitNameFn }: {
);
}
// تبدیل اعداد فارسی/اعشاری برای اعتبارسنجی نمایشی (بدون ارسال)
function parseNumLocal(s: string): number {
const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, ""));
return isFinite(v) ? v : 0;
+173 -67
View File
@@ -1,5 +1,16 @@
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 { useAuthStore } from "../../store/auth";
import { inventorySubheader } from "./panelMenu";
@@ -16,7 +27,6 @@ function todayKey(): string {
return gregKey(new Date());
}
// کنترل روزانه — پورت کامل تقویم شمسی نسخه‌ی ارسالی
export function DailyControlPage() {
const user = useAuthStore((s) => s.user);
const data = useInventoryData();
@@ -26,7 +36,6 @@ export function DailyControlPage() {
const [anchor, setAnchor] = useState<Date>(new Date());
const [sel, setSel] = useState<string>(todayKey());
const [actRec, setActRec] = useState<string | null>(null);
// فیلتر پروژه — فقط برای مدیر تدارکات/ادمین (مدیر پروژه به پروژه خودش محدود است)
const isAdmin = user?.role === "admin" || user?.role === "procurement_manager";
const [projFilter, setProjFilter] = useState("");
@@ -46,7 +55,6 @@ export function DailyControlPage() {
return map;
}, [data.transactions, data.warehouses, projFilter]);
// cells: 42 روز از شنبه قبلِ اولین روز ماه جلالی
const cells = useMemo(() => {
const [ty, tm] = gregorianToJalali(anchor.getFullYear(), anchor.getMonth() + 1, anchor.getDate());
let first: Date | null = null;
@@ -59,7 +67,7 @@ export function DailyControlPage() {
}
}
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 out: { d: Date; inMonth: boolean; isToday: boolean; n: number }[] = [];
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 monthTitle = `${toFa(ty)} ${MONTHS[tm - 1]}`;
// جابه‌جایی بین ماه‌های شمسی (نه ±۳۳ روز) — ماه قبل/بعد با سال‌شماری درست
const shiftMonth = (delta: number) => {
let nm = tm + delta;
let ny = ty;
@@ -90,18 +97,12 @@ export function DailyControlPage() {
nm = 1;
ny += 1;
}
// روزِ برگشتی را نگه می‌داریم (ماه شمسی از میانه‌ی ماه میلادی شروع می‌شود:
// مثلاً مهر ۱ = ۲۳ سپتامبر). اگر به‌جای آن روز «۱» استفاده شود، لنگر در
// اولِ ماهِ میلادی می‌نشیند و هنوز در همان ماه شمسی است — دکمه «ماه بعد»
// ظاهراً هیچ کاری نمی‌کند.
const [gy, gm, gd] = jalaliToGregorian(ny, nm, 1);
setAnchor(new Date(gy, gm - 1, gd));
};
const dayTx = txByDay.get(sel) || [];
// فعال‌سازی ناوبری ماه از چند مسیر (pointerup / click / کیبورد) با حذف
// اجرای تکراری — اگر مرورگر رویداد click را بخورد، pointerup جواب می‌دهد.
const navLast = useRef<{ key: string; at: number }>({ key: "", at: 0 });
const navFire = (key: string, action: () => void) => {
const now = Date.now();
@@ -144,19 +145,24 @@ export function DailyControlPage() {
const [sy, sm, sd] = gregorianToJalali(selDate.getFullYear(), selDate.getMonth() + 1, selDate.getDate());
return (
<div className="min-h-screen">
<div className="min-h-screen bg-slate-50/60">
<Header
title="کنترل روزانه"
sub={inventorySubheader(user)}
backTo="/inventory"
backLabel="بازگشت به داشبورد"
/>
<PageLayout className="flex flex-col gap-3">
{isAdmin && (
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-line bg-panel px-3 py-2 text-[12.5px]">
<label className="font-bold">🏢 پروژه:</label>
<PageLayout className="flex flex-col gap-4">
{/* نوار پروژه سفید و شفاف */}
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-slate-200 bg-white p-3 shadow-xs">
<div className="flex flex-wrap items-center gap-2.5 text-xs">
<span className="flex items-center gap-1.5 font-bold text-slate-800">
<Building2 className="h-4 w-4 text-blue-600" />
<span>پروژه:</span>
</span>
{isAdmin ? (
<select
className="h-8 rounded-lg border border-line bg-panel px-2 text-[12.5px]"
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"
value={projFilter}
onChange={(e) => setProjFilter(e.target.value)}
>
@@ -165,93 +171,193 @@ export function DailyControlPage() {
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
<span className="text-[11.5px] text-muted">
{projFilter
? "نمایش کنترل روزانه فقط برای پروژه انتخابی"
: "نمایش کنترل روزانه همه پروژه‌ها"}
) : (
<span className="font-semibold text-slate-800">پروژه فعال</span>
)}
<span className="text-[11.5px] text-slate-400">
{projFilter ? "نمایش برای پروژه انتخابی" : "نمایش تمام پروژه‌ها"}
</span>
</div>
)}
<div className="grid gap-4 lg:grid-cols-2">
{/* تقویم */}
<div className="rounded-xl border border-line bg-panel p-4">
<div className="mb-2 flex items-center justify-between">
<div className="text-[14px] font-bold">📅 {monthTitle}</div>
<div className="flex gap-1.5">
<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>
<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>
<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>
<Link
to="/inventory/change"
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"
>
<Plus className="h-3.5 w-3.5" />
<span>ثبت خروج / تغییر موجودی</span>
</Link>
</div>
<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 className="grid grid-cols-7 gap-1">
{/* روزهای هفته */}
<div className="grid grid-cols-7 gap-1 text-center">
{["ش", "ی", "د", "س", "چ", "پ", "ج"].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) => {
// gregorianToJalali → [سال، ماه، روز] — روزِ ماه در خانه‌ی تقویم
const [, , jd] = gregorianToJalali(c.d.getFullYear(), c.d.getMonth() + 1, c.d.getDate());
const k = gregKey(c.d);
const isSelected = sel === k;
return (
<button
key={i}
type="button"
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>
);
})}
</div>
</div>
{/* برداشت‌های روز */}
<div className="rounded-xl border border-line bg-panel p-4">
<div className="mb-1 flex items-center justify-between">
<h2 className="text-[14px] font-bold">🕘 کنترل روزانه — {isToday ? "امروز " : ""}{toFa(sd)} {MONTHS[sm - 1]} {toFa(sy)}</h2>
<span className="rounded-lg bg-rose-50 px-2 py-1 text-[11.5px] font-bold text-rose-600">▼ برداشت‌ها: {faNum(wGroups.length)} قلم</span>
{/* ستون جزئیات روز — سفید یکدست */}
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-xs">
<div className="mb-2 flex items-center justify-between pb-3 border-b border-slate-100">
<div className="flex items-center gap-2">
<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>
<p className="mb-3 text-[11.5px] leading-5 text-muted">
مواردی که این روز از انبار برداشت و به ساختمان برده شده‌اند. روی هر مورد بزنید و برگشت / مفقودی / اتلاف را انتخاب کنید.
<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>
<p className="mb-3 text-[11.5px] leading-5 text-slate-500">
اقلام برداشت‌شده از انبار در این تاریخ:
</p>
{!wGroups.length ? (
<div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">
{isToday ? "امروز هنوز برداشتی ثبت نشده است." : "در این روز برداشتی ثبت نشده است."} هر برداشتی که در «تغییر موجودی» ثبت کنید، همین‌جا نمایش داده می‌شود.
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-slate-200 py-10 px-4 text-center">
<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 className="flex flex-col gap-1.5">
<div className="flex flex-col gap-2">
{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"}`}>
<button type="button" className="flex w-full items-center gap-2 text-start" onClick={() => setActRec(actRec === g.recId ? null : g.recId)}>
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-rose-50 text-rose-600">➖</span>
<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-[11px] text-muted">
{[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 className="text-[13px] font-bold text-rose-600">{faNum(g.q)} {unitName(data.lookup.units, g.tx.unit?.id)}</span>
<span className="text-[10.5px] text-muted">{new Date(g.last).toLocaleTimeString("fa-IR", { hour: "2-digit", minute: "2-digit" })}</span>
</button>
{actRec === g.recId && isPM && (
<div className="mt-2 flex gap-1.5 border-t border-line pt-2">
<div
key={g.recId}
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
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="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="block truncate text-xs font-bold text-slate-800">
{g.tx.material?.name || "—"}
</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 className="text-xs font-bold text-rose-600 tabular-nums">
{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>
{actRec === g.recId && isPM && (
<div className="mt-2.5 flex flex-wrap gap-2 border-t border-slate-100 pt-2">
<button
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 || "")}`)}
>
↩️ برگشت
<RotateCcw className="h-3 w-3" />
<span>برگشت</span>
</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 || "")}`)}
>
📦 مفقودی
<PackageX className="h-3 w-3" />
<span>مفقودی</span>
</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 || "")}`)}
>
🗑️ اتلاف
<Trash2 className="h-3 w-3" />
<span>اتلاف</span>
</button>
</div>
)}
+379 -157
View File
@@ -1,10 +1,8 @@
import { useMemo, useState } from "react";
import { Link, useLocation, useSearchParams } from "react-router-dom";
import { Plus, Warehouse as WarehouseIcon, ArrowUpDown, ArrowUp, ArrowDown, X } from "lucide-react";
import { Plus, Warehouse as WarehouseIcon, SlidersHorizontal, ArrowUpDown, X, ChevronDown } from "lucide-react";
import { Header, PageLayout } from "../../components/layout/Header";
import { EmptyState } from "../../components/EmptyState";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import { useAuthStore } from "../../store/auth";
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
import { inventorySubheader } from "./panelMenu";
@@ -13,8 +11,9 @@ import { useInventoryData, brandName, catName, materialName, unitName, whName }
import type { InventoryRow, InventoryTransaction, TransactionType } from "../../lib/types";
import { faNum } from "../../lib/formatters";
import { isoToJalaliStr } from "../../lib/jalali";
import { cn } from "../../lib/utils";
// رنگ‌بندی معنایی: سبز زمردی = ورود/افزایش، رز = خروج/کاهش، آبی = خنثی/جابه‌جایی
// رنگ‌بندی معنایی تراکنش‌ها
const TX_META: Record<TransactionType, { label: string; color: string; icon: string }> = {
PURCHASE: { label: "خرید", color: "#10b981", icon: "🛒" },
WITHDRAWAL: { label: "برداشت", color: "#e11d48", icon: "➖" },
@@ -48,7 +47,6 @@ function specsHash(specs: Record<string, string> | null | undefined): string {
return keys.map((k) => `${k}=${s[k]}`).join("|");
}
// داشبورد انبار — پورت کامل نسخه‌ی ارسالی (گروه‌بندی + خط زمانی + فیلترها)
export function InventoryPage() {
const location = useLocation();
const user = useAuthStore((s) => s.user);
@@ -64,6 +62,7 @@ export function InventoryPage() {
project: "",
});
const [sort, setSort] = useState<"asc" | "desc" | "">("");
const [showMobileFilters, setShowMobileFilters] = useState(false);
const [expandedKey, setExpandedKey] = useState<string | null>(null);
const [selTx, setSelTx] = useState<string | null>(null);
const [expBrand, setExpBrand] = useState<string | null>(null);
@@ -133,7 +132,7 @@ export function InventoryPage() {
.slice()
.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime());
const hasFilter = !!(filters.category || filters.warehouse || filters.material || (isAdmin && filters.project));
const hasFilter = !!(filters.category || filters.warehouse || (isAdmin && filters.project));
if (loading) return <div className="min-h-screen" />;
if (!user || (!isAdmin && !isPM)) {
@@ -148,149 +147,263 @@ export function InventoryPage() {
}
return (
<div className="min-h-screen">
<div className="min-h-screen pb-24 md:pb-6 overflow-x-hidden">
<Header
title="سامانه مدیریت انبار"
sub={inventorySubheader(user)}
icon={<WarehouseIcon className="h-4.5 w-4.5" strokeWidth={2} />}
menu={<AppSwitcherMenu activeModule="warehouse" canSwitchToProcurement={isAdmin} />}
actions={
<>
<div className="hidden md:flex items-center gap-2">
<InventoryTopNav pathname={location.pathname} variant={isAdmin ? "admin" : "pm"} />
{isPM && (
<Button asChild variant="primary">
<Link to="/inventory/change" className="no-underline">
<Plus className="h-4 w-4" />
تغییر موجودی
</Link>
</Button>
)}
</>
</div>
}
/>
<PageLayout className="flex flex-col gap-4">
{/* نوار فیلترها و ابزارها */}
<div className="flex flex-wrap items-end gap-3 rounded-xl border border-line bg-panel p-3.5 shadow-sm">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-muted">دسته‌بندی</label>
<select
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}
onChange={(e) => setFilters((f) => ({ ...f, category: e.target.value, material: "" }))}
>
<option value="">همه دسته‌بندی‌ها</option>
{lookup.categories.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
{isAdmin && (
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-muted">پروژه</label>
<select
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}
onChange={(e) => setFilters((f) => ({ ...f, project: e.target.value, warehouse: "", material: "" }))}
>
<option value="">همه پروژه‌ها</option>
{lookup.projects.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
)}
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-muted">انبار</label>
<select
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}
onChange={(e) => setFilters((f) => ({ ...f, warehouse: e.target.value, material: "" }))}
>
<option value="">همه انبارها</option>
{visibleWarehouses.map((w) => (
<option key={w.id} value={w.id}>
{w.name}{isAdmin ? ` — ${lookup.projects.find((p) => p.id === w.project_id)?.name || ""}` : ""}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-muted">متریال</label>
<PageLayout className="flex flex-col gap-3 px-3 md:px-6 w-full max-w-full">
{/* جعبه فیلترها و جستجو */}
<div className="rounded-xl border border-line bg-panel p-2.5 sm:p-3 w-full shadow-2xs">
<div className="flex items-center gap-2 w-full">
<div className="relative flex-1 min-w-0">
<input
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="تایپ نام متریال…"
className="h-9 w-full rounded-lg border border-line bg-bg/50 px-3 text-xs focus:border-accent focus:bg-panel focus:outline-none"
placeholder="جستجوی نام متریال…"
value={filters.material}
onChange={(e) => setFilters((f) => ({ ...f, material: e.target.value }))}
/>
</div>
<Button
variant="line"
onClick={() => setSort((s) => (s === "" ? "asc" : s === "asc" ? "desc" : ""))}
className="gap-1.5"
>
{sort === "asc" ? (
<>
<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 && (
<Button
variant="danger"
onClick={() => setFilters({ category: "", warehouse: "", material: "", project: "" })}
className="gap-1"
{filters.material && (
<button
type="button"
onClick={() => setFilters((f) => ({ ...f, material: "" }))}
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted hover:text-ink"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
<button
type="button"
onClick={() => setShowMobileFilters((s) => !s)}
className={cn(
"inline-flex shrink-0 h-9 items-center gap-1.5 rounded-lg border px-2.5 text-xs font-medium transition-colors",
showMobileFilters || hasFilter
? "border-accent bg-accent/10 text-accent font-bold"
: "border-line bg-panel text-ink hover:bg-bg"
)}
>
<SlidersHorizontal className="h-3.5 w-3.5" />
<span>فیلترها</span>
{hasFilter && <span className="h-1.5 w-1.5 rounded-full bg-accent" />}
</button>
<button
type="button"
className={cn(
"inline-flex shrink-0 h-9 items-center gap-1 rounded-lg border px-2.5 text-xs transition-colors",
sort ? "border-accent bg-accent/5 text-accent font-semibold" : "border-line bg-panel text-ink hover:bg-bg"
)}
onClick={() => setSort((s) => (s === "" ? "asc" : s === "asc" ? "desc" : ""))}
title="مرتب‌سازی تعداد"
>
<ArrowUpDown className="h-3.5 w-3.5" />
<span className="hidden sm:inline">
{sort === "asc" ? "تعداد: صعودی" : sort === "desc" ? "تعداد: نزولی" : "مرتب‌سازی"}
</span>
</button>
</div>
{/* فیلتر سریع انبارها (چیپ‌های افقی لمسی) */}
{visibleWarehouses.length > 1 && (
<div className="mt-2.5 flex items-center gap-1.5 overflow-x-auto pb-1 pt-0.5 [scrollbar-width:none]">
<button
type="button"
onClick={() => setFilters((f) => ({ ...f, warehouse: "" }))}
className={cn(
"shrink-0 rounded-lg px-2.5 py-1 text-xs font-medium transition-colors",
!filters.warehouse
? "bg-accent text-white shadow-xs font-bold"
: "border border-line bg-bg/40 text-muted hover:bg-panel hover:text-ink"
)}
>
همه انبارها
</button>
{visibleWarehouses.map((w) => (
<button
key={w.id}
type="button"
onClick={() => setFilters((f) => ({ ...f, warehouse: f.warehouse === w.id ? "" : w.id }))}
className={cn(
"shrink-0 rounded-lg px-2.5 py-1 text-xs font-medium transition-colors",
filters.warehouse === w.id
? "bg-accent text-white shadow-xs font-bold"
: "border border-line bg-bg/40 text-muted hover:bg-panel hover:text-ink"
)}
>
{w.name}
</button>
))}
</div>
)}
{/* فیلترهای پیشرفته — برچسب‌های لمسی بدون سلکت و بدون بیرون‌زدگی */}
{showMobileFilters && (
<div className="mt-2.5 border-t border-line/60 pt-2.5 flex flex-col gap-2.5">
{/* دسته‌بندی‌ها */}
<div>
<div className="mb-1.5 text-[11px] font-bold text-muted">دسته‌بندی متریال:</div>
<div className="flex flex-wrap gap-1.5">
<button
type="button"
onClick={() => setFilters((f) => ({ ...f, category: "" }))}
className={cn(
"rounded-lg px-2.5 py-1 text-[11.5px] transition-colors",
!filters.category
? "bg-accent text-white font-bold"
: "border border-line bg-bg/40 text-ink hover:bg-panel"
)}
>
همه دسته‌بندی‌ها
</button>
{lookup.categories.map((c) => (
<button
key={c.id}
type="button"
onClick={() => setFilters((f) => ({ ...f, category: f.category === c.id ? "" : c.id }))}
className={cn(
"rounded-lg px-2.5 py-1 text-[11.5px] transition-colors",
filters.category === c.id
? "bg-accent text-white font-bold"
: "border border-line bg-bg/40 text-ink hover:bg-panel"
)}
>
{c.name}
</button>
))}
</div>
</div>
{/* پروژه‌ها (مخصوص ادمین) */}
{isAdmin && lookup.projects.length > 0 && (
<div>
<div className="mb-1.5 text-[11px] font-bold text-muted">پروژه:</div>
<div className="flex flex-wrap gap-1.5">
<button
type="button"
onClick={() => setFilters((f) => ({ ...f, project: "" }))}
className={cn(
"rounded-lg px-2.5 py-1 text-[11.5px] transition-colors",
!filters.project
? "bg-accent text-white font-bold"
: "border border-line bg-bg/40 text-ink hover:bg-panel"
)}
>
همه پروژه‌ها
</button>
{lookup.projects.map((p) => (
<button
key={p.id}
type="button"
onClick={() => setFilters((f) => ({ ...f, project: f.project === p.id ? "" : p.id }))}
className={cn(
"rounded-lg px-2.5 py-1 text-[11.5px] transition-colors",
filters.project === p.id
? "bg-accent text-white font-bold"
: "border border-line bg-bg/40 text-ink hover:bg-panel"
)}
>
{p.name}
</button>
))}
</div>
</div>
)}
{/* دکمه پاک کردن فیلترها */}
{hasFilter && (
<div className="pt-1 flex items-center justify-between">
<button
type="button"
className="text-xs text-red font-medium hover:underline inline-flex items-center gap-1"
onClick={() => setFilters({ category: "", warehouse: "", material: "", project: "" })}
>
<X className="h-3 w-3" />
پاک کردن فیلترها
</Button>
)}
<div className="ms-auto flex items-center gap-2 self-end">
{filters.warehouse && (
<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>
</button>
<span className="text-[11px] text-muted">
نمایش {faNum(groups.length)} قلم کالا
</span>
</div>
)}
</div>
)}
</div>
{/* بخش جدول یا وضعیت خالی */}
{/* ۱. نمای مخصوص موبایل: کارت‌های عمودی کاملاً ریسپانسیو */}
<div className="flex flex-col gap-2.5 md:hidden w-full">
{!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">
<div className="rounded-xl border border-line bg-panel p-4">
<EmptyState emoji="📋" text="هیچ رکورد موجودی‌ای مطابق فیلترهای انتخابی یافت نشد." />
</div>
) : (
<div className="overflow-hidden rounded-xl border border-line bg-panel shadow-sm">
<table className="w-full text-[12.5px]">
groups.map((g) => {
const isExp = expandedKey === g.key;
const matCat = lookup.materials.find((m) => m.id === g.matId)?.category;
return (
<InventoryMobileCard
key={g.key}
g={g}
isExp={isExp}
isAdmin={isAdmin}
matName={materialName(lookup.materials, g.matId)}
matCatName={matCat ? catName(lookup.categories, matCat.id) : "—"}
wh={whName(warehouses, g.whId)}
projName={lookup.projects.find((p) => p.id === warehouses.find((w) => w.id === g.whId)?.project_id)?.name || ""}
unit={unitName(lookup.units, g.unitId)}
onToggle={() => {
setExpandedKey(isExp ? null : g.key);
setSelTx(null);
setExpBrand(null);
}}
timeline={
isExp ? (
<Timeline
g={g}
txs={groupTxs(g)}
allTxs={transactions}
brandTxs={brandTxs}
selTx={selTx}
setSelTx={setSelTx}
expBrand={expBrand}
setExpBrand={setExpBrand}
matName={materialName(lookup.materials, g.matId)}
unit={unitName(lookup.units, g.unitId)}
brandNameFn={(id) => brandName(lookup.brands, id)}
whNameFn={(id) => whName(warehouses, id)}
/>
) : null
}
/>
);
})
)}
</div>
{/* ۲. نمای دسکتاپ: جدول سنتی */}
<div className="hidden md:block overflow-x-auto rounded-xl border border-line bg-panel shadow-2xs [scrollbar-width:thin]">
{!groups.length ? (
<EmptyState emoji="📋" text="هیچ رکورد موجودی‌ای مطابق فیلترهای انتخابی یافت نشد." />
) : (
<table className="w-full min-w-[640px] text-[12.5px]">
<thead>
<tr className="border-b border-line bg-slate-bg/50 text-right text-muted font-medium">
<tr className="border-b border-line bg-bg/40 text-right text-muted">
<th className="w-8 py-2.5" />
<th className="py-2.5 pe-3">متریال</th>
<th className="py-2.5 pe-3">دسته‌بندی</th>
<th className="py-2.5 pe-3">انبار</th>
<th className="py-2.5 pe-3">مشخصه فنی</th>
<th className="py-2.5 pe-3">تعداد</th>
<th className="py-2.5 pe-3">واحد</th>
<th className="py-2.5 pe-3">آخرین به‌روزرسانی</th>
<th className="py-2.5 pe-3 font-semibold">متریال</th>
<th className="py-2.5 pe-3 font-semibold">دسته‌بندی</th>
<th className="py-2.5 pe-3 font-semibold">انبار</th>
<th className="py-2.5 pe-3 font-semibold">مشخصه فنی</th>
<th className="py-2.5 pe-3 font-semibold">تعداد</th>
<th className="py-2.5 pe-3 font-semibold">واحد</th>
<th className="py-2.5 pe-3 font-semibold">آخرین به‌روزرسانی</th>
<th className="w-8 py-2.5" />
</tr>
</thead>
@@ -337,13 +450,126 @@ export function InventoryPage() {
})}
</tbody>
</table>
</div>
)}
<div className="text-[11px] text-muted">
۱۰ نوع تراکنش استاندارد • سبز زمردی = ورود به انبار · رز = خروج/کاهش · آبی = انتقال و شمارش • کلیک روی هر سطر: خط زمانی با «مانده» لحظه‌به‌لحظه و تفکیک برندها
</div>
</PageLayout>
{/* دکمه شناور تغییر موجودی */}
{isPM && (
<Link
to="/inventory/change"
className="fixed bottom-20 left-5 z-40 flex h-14 w-14 items-center justify-center rounded-full bg-blue-600 text-white shadow-xl shadow-blue-600/40 transition-all duration-200 hover:bg-blue-700 hover:scale-105 active:scale-90 md:bottom-8 md:left-8"
title="تغییر موجودی"
aria-label="تغییر موجودی"
>
<Plus className="h-7 w-7" strokeWidth={2.5} />
</Link>
)}
</div>
);
}
// کامپوننت کارت انبار در موبایل
function InventoryMobileCard({
g,
isExp,
isAdmin,
matName,
matCatName,
wh,
projName,
unit,
onToggle,
timeline,
}: {
g: Group;
isExp: boolean;
isAdmin: boolean;
matName: string;
matCatName: string;
wh: string;
projName: string;
unit: string;
onToggle: () => void;
timeline: React.ReactNode;
}) {
const specsText = Object.entries(g.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ");
return (
<div
className={cn(
"overflow-hidden rounded-xl border bg-panel transition-all shadow-2xs w-full",
isExp ? "border-accent/60 ring-1 ring-accent/20" : "border-line hover:border-line/80"
)}
>
<div onClick={onToggle} className="cursor-pointer p-3 active:bg-bg/40 transition-colors">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<h3 className="text-[13.5px] font-bold leading-snug text-ink break-words">
{matName}
</h3>
{matCatName !== "—" && (
<span className="mt-0.5 inline-block text-[11px] text-muted">
{matCatName}
</span>
)}
</div>
<div
className={cn(
"flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-bg text-muted transition-transform duration-200",
isExp && "rotate-180 bg-accent/10 text-accent"
)}
>
<ChevronDown className="h-4 w-4" />
</div>
</div>
{/* کادر شاخص موجودی و انبار */}
<div className="my-2.5 flex items-center justify-between gap-3 rounded-lg border border-line/60 bg-bg/50 p-2.5">
<div className="flex flex-col">
<span className="text-[10px] font-semibold text-muted">موجودی انبار</span>
<div className="flex items-baseline gap-1 pt-0.5">
<span className="text-lg font-black tabular-nums text-ink">
{faNum(g.qty)}
</span>
<span className="text-[11px] font-medium text-muted">{unit}</span>
</div>
</div>
<div className="flex flex-col items-end text-end min-w-0">
<span className="text-[10px] font-semibold text-muted">محل نگهداری</span>
<div className="flex items-center gap-1 text-xs font-bold text-accent truncate pt-0.5">
<WarehouseIcon className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{wh}</span>
</div>
{isAdmin && projName && (
<span className="truncate text-[10px] text-muted max-w-[130px]">{projName}</span>
)}
</div>
</div>
{/* مشخصات فنی و تاریخ */}
<div className="flex items-center justify-between gap-2 text-[10.5px] text-muted pt-0.5">
<div className="min-w-0 flex-1 truncate">
{specsText ? (
<span className="rounded bg-bg px-1.5 py-0.5 text-[10px] font-medium text-ink">
{specsText}
</span>
) : (
<span className="text-muted/60">—</span>
)}
</div>
<span className="shrink-0 tabular-nums">
{isoToJalaliStr(g.updatedAt)}
</span>
</div>
</div>
{isExp && (
<div className="border-t border-line/70 bg-accent-bg/10 p-3">
{timeline}
</div>
)}
</div>
);
}
@@ -362,20 +588,20 @@ function GroupRow({ g, isExp, isAdmin, matName, matCatName, wh, projName, unit,
}) {
return (
<>
<tr className={`border-b border-line/60 cursor-pointer transition-colors duration-150 ${isExp ? "bg-accent/5" : "hover:bg-slate-bg/60"}`} onClick={onToggle}>
<tr className={`border-b border-line/60 cursor-pointer transition-colors ${isExp ? "bg-accent/5" : "hover:bg-accent/5"}`} onClick={onToggle}>
<td className="py-2.5 text-center text-muted">{isExp ? "▾" : "▸"}</td>
<td className="py-2.5 pe-3 font-semibold text-ink">{matName}</td>
<td className="py-2.5 pe-3">{matCatName}</td>
<td className="py-2.5 pe-3 font-bold text-ink">{matName}</td>
<td className="py-2.5 pe-3 text-muted">{matCatName}</td>
<td className="py-2.5 pe-3">
{wh}
{isAdmin && <small className="block text-muted">{projName}</small>}
<div>{wh}</div>
{isAdmin && <span className="block text-[10.5px] text-muted">{projName}</span>}
</td>
<td className="py-2.5 pe-3 text-muted">
{Object.entries(g.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}
</td>
<td className="py-2.5 pe-3 font-bold text-ink">{faNum(g.qty)}</td>
<td className="py-2.5 pe-3 font-extrabold text-ink tabular-nums">{faNum(g.qty)}</td>
<td className="py-2.5 pe-3">{unit}</td>
<td className="py-2.5 pe-3 text-muted">{isoToJalaliStr(g.updatedAt)}</td>
<td className="py-2.5 pe-3 text-muted tabular-nums">{isoToJalaliStr(g.updatedAt)}</td>
<td className="py-2.5 text-muted">🕘</td>
</tr>
{isExp && (
@@ -412,16 +638,14 @@ function Timeline({ g, txs, allTxs, brandTxs, selTx, setSelTx, expBrand, setExpB
key={t.id}
type="button"
onClick={() => setSelTx(sel ? null : t.id)}
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"
}`}
className={`flex min-w-[120px] flex-col gap-0.5 rounded-lg border p-2 text-start transition-all ${sel ? "border-accent bg-accent/10 shadow-xs" : "border-line bg-panel hover:border-accent/40"}`}
style={{ borderTop: `3px solid ${meta.color}` }}
>
<span className="text-[11px] font-bold" style={{ color: meta.color }}>{meta.icon} {meta.label}</span>
<span className="text-[12px] font-bold" style={{ color: meta.color }}>
<span className="text-[12px] font-bold tabular-nums" style={{ color: meta.color }}>
{faNum(Math.abs(t.quantity_number))} {unit}
</span>
<span className="text-[10.5px] text-muted">مانده: <b>{faNum(bal)}</b> {unit}</span>
<span className="text-[10.5px] text-muted">مانده: <b className="tabular-nums">{faNum(bal)}</b> {unit}</span>
</button>
);
});
@@ -430,9 +654,9 @@ function Timeline({ g, txs, allTxs, brandTxs, selTx, setSelTx, expBrand, setExpB
if (!txs.length) return <div className="text-[12px] text-muted">تراکنشی ثبت نشده است.</div>;
const brandSec = expBrand ? (
<div className="mt-2.5">
<div className="mb-1.5 text-[12px] font-semibold">جریان برند: {brandNameFn(expBrand)} ({faNum(brandTxs(g, expBrand).length)} تراکنش)</div>
<div className="flex gap-2 overflow-x-auto pb-1">{track(brandTxs(g, expBrand))}</div>
<div className="mt-2">
<div className="mb-1 text-[12px] font-semibold">جریان برند: {brandNameFn(expBrand)} ({faNum(brandTxs(g, expBrand).length)} تراکنش)</div>
<div className="flex gap-1.5 overflow-x-auto pb-1 [scrollbar-width:thin]">{track(brandTxs(g, expBrand))}</div>
</div>
) : null;
@@ -442,21 +666,21 @@ 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 pairTx = t.transfer_pair_id ? allTxs.find((x) => x.transfer_pair_id === t.transfer_pair_id && x.id !== t.id) : null;
return (
<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}` }}>
<div className="mt-2 rounded-lg border p-2.5 text-[12px] bg-panel shadow-2xs" style={{ borderTop: `3px solid ${meta.color}` }}>
<b style={{ color: meta.color }}>{meta.label}</b>
<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 className="text-ink">{isoToJalaliStr(t.transaction_date)}</b></span>
{t.transaction_type === "TRANSFER_OUT" || t.transaction_type === "TRANSFER_IN" ? (
<>
<span>از انبار: <b>{t.transaction_type === "TRANSFER_OUT" ? whNameFn(t.warehouse_id) : whNameFn(pairTx?.warehouse_id)}</b></span>
<span>به انبار: <b>{t.transaction_type === "TRANSFER_IN" ? whNameFn(t.warehouse_id) : whNameFn(pairTx?.warehouse_id)}</b></span>
<span>از انبار: <b className="text-ink">{t.transaction_type === "TRANSFER_OUT" ? whNameFn(t.warehouse_id) : whNameFn(pairTx?.warehouse_id)}</b></span>
<span>به انبار: <b className="text-ink">{t.transaction_type === "TRANSFER_IN" ? whNameFn(t.warehouse_id) : whNameFn(pairTx?.warehouse_id)}</b></span>
</>
) : (
<span>انبار: <b>{whNameFn(t.warehouse_id)}</b></span>
<span>انبار: <b className="text-ink">{whNameFn(t.warehouse_id)}</b></span>
)}
{t.brand?.name && <span>برند: <b>{t.brand.name}</b></span>}
{t.performed_by && <span>انجام‌دهنده: <b>{t.performed_by}</b></span>}
{t.note && <span>یادداشت: <b>{t.note}</b></span>}
{t.brand?.name && <span>برند: <b className="text-ink">{t.brand.name}</b></span>}
{t.performed_by && <span>انجام‌دهنده: <b className="text-ink">{t.performed_by}</b></span>}
{t.note && <span>یادداشت: <b className="text-ink">{t.note}</b></span>}
{t.transaction_type === "STOCK_COUNT" && t.recorded_qty != null && t.physical_qty != null && (
<span>
ثبت‌شده: {faNum(t.recorded_qty)} • شمارش‌شده: {faNum(t.physical_qty)} • اختلاف: {faNum(t.physical_qty - t.recorded_qty)}
@@ -484,17 +708,15 @@ function Timeline({ g, txs, allTxs, brandTxs, selTx, setSelTx, expBrand, setExpB
key={bid}
type="button"
onClick={() => setExpBrand(expBrand === bid ? null : bid)}
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"
}`}
className={`rounded-lg border px-2 py-1 text-[11.5px] transition-colors ${expBrand === bid ? "border-accent bg-accent/10 text-accent font-bold" : "border-line bg-panel hover:bg-bg"}`}
>
{brandNameFn(bid)}: <b>{faNum(g.brands[bid])}</b> {unit}{expBrand === bid ? " ✕" : ""}
{brandNameFn(bid)}: <b className="tabular-nums">{faNum(g.brands[bid])}</b> {unit}{expBrand === bid ? " ✕" : ""}
</button>
))}
</div>
{brandSec}
<div className="mb-1.5 mt-2.5 text-[12px] font-semibold">جریان کلی رکورد — ترکیب همه برندها ({faNum(txs.length)} تراکنش)</div>
<div className="flex gap-2 overflow-x-auto pb-1">{track(txs)}</div>
<div className="mb-1 mt-2 text-[12px] font-semibold">جریان کلی رکورد — ترکیب همه برندها ({faNum(txs.length)} تراکنش)</div>
<div className="flex gap-1.5 overflow-x-auto pb-1 [scrollbar-width:thin]">{track(txs)}</div>
{selDetail}
</div>
);
@@ -8,6 +8,7 @@ import { apiV26, type UserRow } from "../../lib/api";
import { refreshProcurementStore } from "../../hooks";
import { useInventoryData } from "./useInventoryData";
import { faNum } from "../../lib/formatters";
import { ProjectMultiSelect } from "../../components/ProjectMultiSelect";
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);
try {
const project = data.lookup.projects.find((p) => p.id === projectId);
await apiV26.users.assignProject(u.id, projectId || null);
toast.success(project ? `«${u.first_name} ${u.last_name}» به پروژه «${project.name}» متصل شد ✓` : "مدیر پروژه از پروژه جدا شد");
for (const pid of added) await apiV26.projects.setPm(pid, u.id);
for (const pid of removed) await apiV26.projects.setPm(pid, null);
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 data.refresh();
refreshProcurementStore();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در اتصال مدیر به پروژه");
toast.error(err instanceof Error ? err.message : "خطا در به‌روزرسانی پروژه‌ها");
await loadUsers(); // وضعیت واقعی سرور را برگردان
} finally {
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 (
<div className="min-h-screen">
@@ -226,19 +252,14 @@ export function OrganizationPage() {
<small className="block text-[10px] text-muted">(تغییر نقش فقط توسط ادمین)</small>
)}
</td>
<td className="py-1.5 pe-3">
<td className="min-w-[240px] py-1.5 pe-3 align-top">
{u.role === "pm" ? (
<select
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px]"
value={u.project_id || ""}
<ProjectMultiSelect
options={data.lookup.projects.map((p) => ({ id: p.id, name: p.name }))}
values={assignedProjectsOf(u).map((p) => p.id)}
onChange={(ids) => void syncUserProjects(u, ids)}
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>
)}
@@ -253,7 +274,9 @@ export function OrganizationPage() {
</tbody>
</table>
<div className="mt-1 text-[11px] text-muted">
«بدون نقش» = حذف مدیر پروژه (از پروژه هم جدا می‌شود). اتصال به یک پروژه، مدیر قبلی آن پروژه را جابه‌جا می‌کند.
«بدون نقش» = حذف مدیر پروژه (از همه‌ی پروژه‌ها هم جدا می‌شود). هر مدیر پروژه می‌تواند به
چند پروژه متصل باشد؛ حذف هر چیپ (×)، فقط همان پروژه را جدا می‌کند و اتصال به یک پروژه، مدیرِ
قبلیِ آن پروژه را جابه‌جا می‌کند.
</div>
</div>
)}
@@ -54,6 +54,9 @@ export function useInventoryData(): InventoryData {
setWarehouses(whs);
setInventory(inv);
setTransactions(txs);
} catch {
// پروژه‌ی فعال ممکن است همین حالا باطل شده باشد — گیتِ پروژه در سطح مسیر
// (RequirePmProjects) پروژه‌ی جایگزین/صفحه‌ی خالی را نشان می‌دهد.
} finally {
setLoading(false);
}
+28 -4
View File
@@ -7,6 +7,7 @@ import { nextId } from "../../lib/api";
import { useAddSupplier, useMaterials, useQuotes, useRemoveSupplier, useStoreData, useSuppliers } from "../../hooks";
import { useAppStore } from "../../store";
import { topicByName } from "../../lib/topics";
import { daysBetween } from "../../lib/formatters";
import { Header, PageLayout } from "../../components/layout/Header";
import { EmptyState } from "../../components/EmptyState";
import { Combobox } from "../../components/Combobox";
@@ -15,7 +16,6 @@ import { materialLabel } from "../../lib/quoteUtils";
import { Button } from "../../components/ui/button";
// صفحه‌ی تأمین‌کننده‌های یک دسته + فرم افزودن تأمین‌کننده
// (سلسله‌مراتب دامنه: دسته → تأمین‌کننده؛ در v2.6 دسته = تاپیک)
export function CategoryPage() {
useStoreData();
const { category: catParam = "" } = useParams();
@@ -36,12 +36,29 @@ export function CategoryPage() {
contact: "",
});
// همه‌ی تأمین‌کننده‌ها برای انتخاب سریع (task: کومبوکس کش‌شده)
// همه‌ی تأمین‌کننده‌ها برای انتخاب سریع
const supplierOptions = useMemo(
() => suppliers.map((s) => ({ id: s.id, name: s.name })),
[suppliers],
);
// محاسبه میانگین زمان تحویل اختصاصی هر تأمین‌کننده بر اساس سفارش‌های تحویل‌شده
const supLeadTimes = useMemo(() => {
const map = new Map<string, number | null>();
suppliers.forEach((s) => {
const supQuotes = quotes.filter(
(q) => q.supplier_id === s.id && q.status === "arrived" && q.accepted_date && q.arrived_date
);
const times = supQuotes
.map((q) => daysBetween(q.arrived_date, q.accepted_date))
.filter((x): x is number => x != null);
const avg = times.length ? Math.round(times.reduce((a, b) => a + b, 0) / times.length) : null;
map.set(s.id, avg);
});
return map;
}, [quotes, suppliers]);
if (!cat) return <Navigate to="/suppliers" replace />;
const sups = suppliersOfCategory(cat, cat, suppliers);
@@ -69,7 +86,6 @@ export function CategoryPage() {
toast.error("نام تأمین‌کننده را وارد کنید.");
return;
}
// تأمین‌کننده دقیقاً زیر همین دسته ثبت می‌شود (۱ دسته به ازای هر تأمین‌کننده)
const s: Supplier = {
id: nextId("s", suppliers),
topic: cat,
@@ -101,7 +117,15 @@ export function CategoryPage() {
) : (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{sups.map((s) => (
<SupplierCard key={s.id} s={s} topic={cat} deletable onDelete={removeSup} materials={supMats.get(s.id) || []} />
<SupplierCard
key={s.id}
s={s}
topic={cat}
deletable
onDelete={removeSup}
materials={supMats.get(s.id) || []}
avgLeadTime={supLeadTimes.get(s.id)}
/>
))}
</div>
)}
+19 -5
View File
@@ -1,7 +1,9 @@
import { Link } from "react-router-dom";
import { MapPin, Phone, User, Plus } from "lucide-react";
import { MapPin, Phone, User, Plus, Timer } from "lucide-react";
import type { Material, Supplier } from "../../lib/types";
import { normalize } from "../../lib/normalize";
import { fmtNum } from "../../lib/formatters";
export function topicCategories(topic: string, materials: Material[], suppliers: Supplier[]): string[] {
const out = new Set<string>();
materials
@@ -19,33 +21,45 @@ export function suppliersOfCategory(topic: string, cat: string, suppliers: Suppl
);
}
// کارت تأمین‌کننده
// کارت تأمین‌کننده همراه با برچسب میانگین زمان تحویل
export function SupplierCard({
s,
topic,
deletable,
onDelete,
materials = [],
avgLeadTime,
}: {
s: Supplier;
topic: string;
deletable?: boolean;
onDelete?: (id: string) => void;
// متریال‌های خریداری‌شده از این تأمین‌کننده (به‌جای دسته‌بندی)
materials?: string[];
avgLeadTime?: number | null;
}) {
return (
<div className="sup-card">
<div className="sc-head">
<Link to={`/suppliers/${s.id}`} className="sc-name-link">
<div className="sc-head flex items-start justify-between gap-2">
<Link to={`/suppliers/${s.id}`} className="sc-name-link min-w-0 flex-1 leading-snug">
{s.name}
</Link>
<div className="flex items-center gap-1.5 shrink-0">
{avgLeadTime != null && (
<span
className="inline-flex items-center gap-1 whitespace-nowrap rounded-md bg-emerald-100 border border-emerald-300/80 px-2 py-0.5 text-xs font-bold text-emerald-900 shadow-2xs dark:bg-emerald-950 dark:border-emerald-800 dark:text-emerald-200 tabular-nums"
title="میانگین زمان تحویل سفارش‌ها"
>
<Timer className="h-3.5 w-3.5 text-emerald-700 dark:text-emerald-400" />
<span>{fmtNum(avgLeadTime)} روز</span>
</span>
)}
{deletable && onDelete && (
<button type="button" className="sup-del" title="حذف تأمین‌کننده" onClick={() => onDelete(s.id)}>
×
</button>
)}
</div>
</div>
<div className="sc-meta">
<span>
<MapPin className="me-1 inline h-3 w-3" /> {s.location || "—"}
+67 -54
View File
@@ -41,28 +41,33 @@ export function itemRowLabel(it: ItemDraft): string {
return qtyPart ? `${name} -- ${qtyPart}` : name;
}
// نام پایه‌ی متریال از نامِ ذخیره‌شده: پیشوند برند و پسوند مشخصه حذف می‌شوند
// («راجین سیم مسی» → «سیم») — پیشنهادهای فیلد «متریال» فقط خودِ متریال را نشان
// می‌دهند؛ برند و مشخصه ستون‌های جداگانه دارند.
function materialBaseName(name: string, brand: string, specs: string[]): string {
let base = (name || "").trim();
if (!base) return "";
if (brand) {
if (base.startsWith(brand)) base = base.slice(brand.length);
else if (base.includes(brand)) base = base.split(brand).join(" ");
// نام پایه‌ی متریال از نامِ ذخیره‌شده.
// متریال‌های جدید فقط نامِ خودِ متریال را در name دارند (برند در brand_ids و
// مشخصه‌ها در attributes ذخیره می‌شوند) و دست‌نخورده برمی‌گردند. رکوردهای قدیمی
// name را به شکل «برند + دسته + مشخصه» ذخیره کرده‌اند؛ آن قطعات جدا می‌شوند تا
// نامِ پایه باقی بماند.
function materialBaseName(name: string, brand: string, specs: string[], category = ""): string {
const fallback = (name || "").trim();
if (!fallback) return "";
let base = fallback;
// بلندترین قطعه اول حذف می‌شود تا حذف‌های جزئی، رشته را ناقص نکنند
const parts = [brand, category, ...specs]
.map((p) => (p || "").trim())
.filter(Boolean)
.sort((a, b) => b.length - a.length);
for (const p of parts) {
if (!base.includes(p)) continue;
const next = base.split(p).join(" ").replace(/\s+/g, " ").trim();
// اگر حذفِ این قطعه کلِ نام را خالی کند، یعنی همان نامِ متریال بوده — نگهش دار
if (!next) continue;
base = next;
}
specs
.filter((s) => s && s.trim())
.forEach((s) => {
if (base.includes(s)) base = base.split(s).join(" ");
});
base = base.replace(/\s+/g, " ").trim();
return base || (name || "").trim();
// شکلِ قدیمیِ خراب می‌تواند فقط یک مشخصه باقی بگذارد؛ آن دیگر نامِ متریال نیست
if (parts.some((p) => p === base)) return fallback;
return base || fallback;
}
// مقادیر مجزای category/brand/spec داخل تاپیک — با فیلتر پله‌به‌پله (تاپیک → دسته → برند)
// + مقادیر تایپ‌شده در همه‌ی سطرهای قبلی (suggestion از همه‌ی فیلدها)
// نکته: برند و واحد از همه‌ی دسته‌ها نمایش داده می‌شوند (issue #7)
// مقادیر مجزای category/brand/spec داخل تاپیک
export function materialOptions(
materials: Material[],
topic: string,
@@ -70,12 +75,11 @@ export function materialOptions(
field: "cat" | "brand" | "spec",
allItems: ItemDraft[] = [],
apiBrands: string[] = [],
apiSpecs: string[] = [],
): string[] {
let list = materials.filter((m) => topic && normalize(m.topic) === normalize(topic));
const cat = item.cat.trim();
const brand = item.brand.trim();
// مقادیر واردشده در سطرهای دیگر — برای پیشنهاد در سطرهای جدید
const fromRows = (sel: (it: ItemDraft) => string | string[]): string[] => {
const out = new Set<string>();
allItems.forEach((it) => {
@@ -92,16 +96,21 @@ export function materialOptions(
fromRows((it) => it.cat).forEach((c) => base.add(c));
return [...base].sort((a, b) => a.localeCompare(b, "fa"));
}
if (cat) list = list.filter((m) => normalize(m.category) === normalize(cat));
if (field === "brand") {
// همه‌ی برندها از همه‌ی materials + برندهای ثبت‌شده در بخش برندها (بک‌اند)
// برند سراسری است — مستقل از تاپیک و دسته
const allBrands = new Set(materials.map((m) => m.brand).filter(Boolean));
apiBrands.forEach((b) => b && allBrands.add(b));
fromRows((it) => it.brand).forEach((b) => allBrands.add(b));
return [...allBrands].sort((a, b) => a.localeCompare(b, "fa"));
}
if (brand) list = list.filter((m) => normalize(m.brand) === normalize(brand));
const base = new Set(list.flatMap((m) => (m.specs || []).filter(Boolean)));
if (cat) list = list.filter((m) => normalize(m.category) === normalize(cat));
// مشخصه‌ها سراسری هستند: رجیستری attribute-definitions + مشخصه‌های همه‌ی
// متریال‌ها + مشخصه‌های تایپ‌شده در سایر ردیف‌ها. به تاپیک/دسته/برندِ قلمِ
// فعلی گره نمی‌خورند تا هنگام افزودن پیشنهاد جدید همیشه در دسترس باشند.
const base = new Set<string>();
apiSpecs.forEach((s) => s && base.add(s));
materials.forEach((m) => (m.specs || []).forEach((s) => s && base.add(s)));
list.forEach((m) => (m.specs || []).forEach((s) => s && base.add(s)));
fromRows((it) => it.specs).forEach((s) => base.add(s));
return [...base].sort((a, b) => a.localeCompare(b, "fa"));
}
@@ -110,32 +119,29 @@ export function materialOptions(
export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember }: Step2Props) {
const materials = useMaterials();
// واحدها از بک‌اند خوانده می‌شوند + واحدهای واردشده در سطرهای قبلی
const [unitOptions, setUnitOptions] = useState<string[]>(() => {
const all = new Set<string>();
items.forEach((it) => { if (it.unit) all.add(it.unit); });
return [...all];
});
// واحدهای موجود (دیتابیس) + واحدهای تایپ‌شده در سطرهای دیگر
const allUnits = useMemo(() => {
const out = new Set(unitOptions);
items.forEach((it) => { if (it.unit) out.add(it.unit); });
return [...out];
}, [items, unitOptions]);
// نام‌های پایه‌ی یکتای همه‌ی متریال‌های کاتالوگ سراسری (فقط خود متریال،
// بدون برند/مشخصه — آن‌ها ستون‌های خودشان را دارند)
const allMaterialNames = useMemo(() => {
const set = new Set<string>();
materials.forEach((m) => {
const base = materialBaseName(m.name, m.brand, m.specs);
const base = materialBaseName(m.name, m.brand, m.specs, m.category);
if (base) set.add(base);
});
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
}, [materials]);
// برندهای ثبت‌شده در بخش برندها (بک‌اند) — به‌علاوه‌ی برندهای متریال‌ها
const [brandOptions, setBrandOptions] = useState<string[]>([]);
const [specOptions, setSpecOptions] = useState<string[]>([]);
useEffect(() => {
let alive = true;
@@ -146,11 +152,15 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
list.forEach((u) => { if (u.name_fa || u.code) merged.add(u.name_fa || u.code); });
return [...merged];
}))
.catch(() => { /* بک‌اند در دسترس نیست */ });
.catch(() => {});
void api
.brandsList()
.then((list) => alive && setBrandOptions(list.map((b) => b.name).filter(Boolean)))
.catch(() => { /* بک‌اند در دسترس نیست */ });
.catch(() => {});
void api
.specsList()
.then((list) => alive && setSpecOptions(list))
.catch(() => {});
return () => { alive = false; };
}, []);
@@ -163,14 +173,12 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
};
const add = () => {
// سطر جدید — مقدارها خالی؛ پیشنهادها از همه‌ی سطرهای قبلی می‌آیند
onItemsChange([
...items,
{ 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) => {
if (!selectedTopic) return;
let rec: MaterialRec;
@@ -198,10 +206,10 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
return (
<div>
<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>
<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">
<span>متریال</span>
@@ -215,12 +223,10 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
<div className="flex flex-col gap-2">
{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
value={it.cat}
onChange={(x) => patch(it.tempId, { cat: x.label })}
// پیشنهادها: همه‌ی متریال‌های کاتالوگ سراسری با نام واقعی‌شان
// (بدون فیلتر تاپیک) — انتخابِ یک متریال موجود در ذخیره، همان را reuse می‌کند
items={allMaterialNames}
allowAdd
addSuffix="به عنوان متریال جدید"
@@ -245,7 +251,7 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
<MultiSelectCombobox
values={it.specs}
onChange={(specs) => patch(it.tempId, { specs })}
options={materialOptions(materials, selectedTopic, it, "spec", items)}
options={materialOptions(materials, selectedTopic, it, "spec", items, [], specOptions)}
placeholder="مشخصه"
className="min-w-0"
/>
@@ -263,30 +269,22 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
<Combobox
value={it.unit}
onChange={(x) => patch(it.tempId, { unit: x.label })}
// متنِ خودِ همین سطر از پیشنهادها حذف می‌شود؛ وگرنه متن تایپ‌شده
// «تطبیق دقیق» پیدا می‌کرد و گزینه‌ی «به عنوان واحد جدید» هرگز ظاهر نمی‌شد
items={allUnits.filter((u) => normalize(u) !== normalize(it.unit))}
allowAdd
addSuffix="به عنوان واحد جدید"
placeholder="واحد"
onInputChange={(v) => patch(it.tempId, { unit: v })}
onAdd={(v) => {
// انتخاب گزینه‌ی «+ [واحد] به عنوان واحد جدید» → ثبت فوری در دیتابیس؛
// نامِ تایپ‌شده هم روی همان قلم اعمال می‌شود (onInputChange بالا)
const name = v.trim();
if (!name) return;
void apiV26.catalog
.createUnit(name)
.then(() => {
// بعد از ثبت، واحد برای سطرهای بعدی هم پیشنهاد شود
setUnitOptions((prev) =>
prev.some((u) => normalize(u) === normalize(name)) ? prev : [...prev, name],
);
})
.catch(() => {
// اگر واحدِ همنام در همین لحظه ساخته شده باشد (تداخل 409) یا شبکه
// خطا دهد، هنگام ذخیره‌ی پیشنهاد دوباره resolve می‌شود — خطای بلوک‌کننده نیست
});
.catch(() => {});
}}
inputClassName="!text-[12px] !h-9"
className="min-w-0"
@@ -315,15 +313,30 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
placeholder="قیمت کل"
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>
</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>
</div>
);
+42 -14
View File
@@ -2,7 +2,6 @@ import { Paperclip } from "lucide-react";
import { faNum } from "../../lib/formatters";
import { groupCommas } from "../../lib/numutil";
import { JalaliDatePicker } from "../../components/JalaliDatePicker";
import { FileUpload } from "../../components/FileUpload";
export interface Step5Props {
quoteDate: string | null;
@@ -52,22 +51,26 @@ export function Step5Review({
return (
<div>
<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>
<div className="grid max-w-2xl grid-cols-2 gap-3 max-md:grid-cols-1">
<div className="field">
<div className="grid max-w-2xl grid-cols-12 gap-3">
{/* تاریخ پیشنهاد: فضای بزرگتر (۷ ستون از ۱۲) به همراه ساعت */}
<div className="field col-span-7 [&>div]:w-full [&_input]:w-full">
<label>تاریخ پیشنهاد</label>
<JalaliDatePicker value={quoteDate} onChange={onQuoteDate} placeholder="۱۴۰۳/۰۵/۱۲" withTime />
<div className="hint">پیش‌فرض امروز</div>
</div>
<div className="field">
{/* تاریخ انقضای پیشنهاد: فضای کوچکتر (۵ ستون از ۱۲) */}
<div className="field col-span-5 [&>div]:w-full [&_input]:w-full">
<label>تاریخ انقضای پیشنهاد</label>
<JalaliDatePicker value={expiryDate} onChange={onExpiryDate} placeholder="۱۴۰۳/۰۶/۱۲" />
<div className="hint">تا چه زمانی این قیمت معتبر است؟</div>
</div>
<div className="field col-span-2">
<div className="field col-span-12">
<label>یادداشت (اختیاری)</label>
<textarea
rows={2}
@@ -77,20 +80,45 @@ export function Step5Review({
onChange={(e) => onNotes(e.target.value)}
/>
</div>
<div className="field col-span-2">
<div className="field col-span-12">
<label>پیش‌فاکتور (اختیاری)</label>
{proforma ? (
<div className="inv-row">
<span className="inv-name">
<Paperclip className="me-1 inline h-3.5 w-3.5" />
{proforma.name}
/* وضعیت فایل انتخاب‌شده با دکمه حذف تمیز */
<div className="flex items-center justify-between rounded-xl border border-gray-200 bg-gray-50/70 px-3 py-2 text-xs">
<div className="flex items-center gap-2 truncate text-gray-700">
<Paperclip className="h-4 w-4 text-blue-600 shrink-0" />
<span className="font-medium truncate">{proforma.name}</span>
<span className="text-[11px] text-gray-400 shrink-0">
({Math.round(proforma.size / 1024)} کیلوبایت)
</span>
<button type="button" className="inv-x" title="حذف" onClick={() => onProforma(null)}>
×
</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>
</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>
+55 -34
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { ChevronRight, ChevronLeft, Check } from "lucide-react";
import { toast } from "sonner";
import type { LineItem, Quote } from "../../lib/types";
import { api, nextId } from "../../lib/api";
@@ -73,18 +74,15 @@ export function WizardPage() {
const [assignment, setAssignment] = useState<Record<string, string[]>>({});
const [quoteDate, setQuoteDate] = useState<string | null>(() => {
// تاریخ پیشنهاد: همین الان (ساعت و دقیقه‌ی فعلی) — نه نیمه‌شب
return new Date().toISOString();
});
const [expiryDate, setExpiryDate] = useState<string | null>(null);
const [notes, setNotes] = useState("");
const [proforma, setProforma] = useState<File | null>(null);
// دیالوگ ذخیره با فیلدهای خالی (نام فیلد + مرحلهی مربوطه)
const [emptyFields, setEmptyFields] = useState<{ label: string; step: number }[]>([]);
const [confirmOpen, setConfirmOpen] = useState(false);
// ---------- prefill از لینک‌ها (?topic=&supplier=) ----------
// وقتی suppliers هنوز لود نشده، دوباره اجرا می‌شود
useEffect(() => {
const t = params.get("topic");
if (!t) return;
@@ -102,23 +100,17 @@ export function WizardPage() {
}, [params, suppliers, topics]);
// ---------- اعتبارسنجی قبل از ترک هر مرحله ----------
// مراحل تکمیل‌شده را بر اساس داده‌های فعلی مشخص می‌کند
const completedSteps = useMemo(() => {
const s = new Set<number>();
// مرحله ۱: تاپیک انتخاب شده + نام تأمین‌کننده پر شده
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);
// مرحله ۳: حداقل یک termsheet با مایل‌ستون واقعی (تاریخ یا مبلغ) — نه فقط کارت پیش‌فرض
if (
cards.length > 0 &&
cards.some((card) => (card.milestones || []).some((m) => m.when_iso || (m.amount != null && m.amount !== 0)))
) {
s.add(3);
}
// مرحله ۴: همه اقلام حداقل به یک پروژه اختصاص داده شده
if (items.length > 0 && items.every((it) => (assignment[it.tempId] || []).length > 0)) s.add(4);
// مرحله ۵: همیشه بازدیدشده محسوب می‌شود
if (highestVisited >= 5) s.add(5);
return s;
}, [selectedTopic, supplierName, items, cards, coverage, assignment, highestVisited]);
@@ -136,7 +128,7 @@ export function WizardPage() {
const rememberMaterial = (rec: MaterialRec) => {
void addMaterial.mutateAsync({
id: nextId("m", materials),
name: [rec.brand, rec.category, ...rec.specs].filter(Boolean).join(" ") || "کالای جدید",
name: (rec.category || rec.brand || rec.specs.join(" ") || "").trim() || "کالای جدید",
topic: rec.topic,
category: rec.category,
brand: rec.brand,
@@ -200,16 +192,13 @@ export function WizardPage() {
}, [items, assignment, cards, coverage, supplierName, selectedTopic, isOfficial]);
// ---------- ذخیره ----------
// تابع ذخیره‌ی اصلی — با یا بدون فیلدهای خالی (انتخاب کاربر در دیالوگ)
const doSave = async () => {
// خطای بخش — با دکمه‌ی «رفع خطا» که به همان بخش می‌برد
const sectionError = (step: number, title: string, err: unknown) => {
const msg = err instanceof Error ? err.message : "خطای ناشناخته";
toast.error(`${title}: ${msg}`, {
action: { label: "رفع خطا", onClick: () => goToStep(step) },
});
};
// مشخص کردن بخش خطا از روی متن پیام
const guessStep = (msg: string): number => {
if (/تأمین|تامین|supplier/i.test(msg)) return 1;
if (/متریال|واحد|قلم|خط آیتم|item|دسته/i.test(msg)) return 2;
@@ -218,7 +207,6 @@ export function WizardPage() {
return 5;
};
// تأمین‌کننده
const name = supplierName.trim();
let supplierId = "";
if (name) {
@@ -244,7 +232,6 @@ export function WizardPage() {
}
}
// اقلام
const collected: { li: LineItem; tempId: string }[] = [];
for (const it of items) {
const cat = it.cat.trim();
@@ -256,12 +243,9 @@ export function WizardPage() {
const total = it.total;
if (!cat && !brand && qty == null && price == null) continue;
// جستجوی متریال: اول با نامِ کامل سراسری (دیتابیس) — بدون قید دسته/تاپیک،
// چون دسته‌ی ذخیره‌شده‌ی متریال‌ها نام تاپیک است نه نام تایپ‌شده‌ی کاربر
const wantName = [brand, cat, ...specs].filter(Boolean).join(" ") || "کالای جدید";
const wantName = (cat || brand || specs.join(" ") || "").trim() || "کالای جدید";
let material = materials.find((m) => normalize(m.name) === normalize(wantName));
if (!material) {
// جستجوی قبلی (برند و مشخصه‌ی ساختارمند در همین نشست)
material = materials.find(
(m) =>
normalize(m.category) === normalize(cat) &&
@@ -269,6 +253,13 @@ export function WizardPage() {
specsKey(m.specs) === specsKey(specs),
);
}
// رکوردهای قدیمی name را به شکل «برند + دسته + مشخصه» ذخیره کرده‌اند؛
// تطبیق دوم بر اساس category/brand/specs انجام می‌شود تا آن‌ها هم پیدا شوند.
if (!material && cat) {
material = materials.find(
(m) => normalize(m.category) === normalize(cat) && normalize(m.brand) === normalize(brand),
);
}
let materialId: string;
if (material) {
materialId = material.id;
@@ -283,7 +274,7 @@ export function WizardPage() {
specs,
base_unit: unit,
});
materialId = created.id; // UUID واقعی از بک‌اند
materialId = created.id;
} catch (err) {
sectionError(2, "خطا در بخش اقلام (متریال)", err);
return;
@@ -309,7 +300,6 @@ export function WizardPage() {
const lineItems = collected.map((c) => c.li);
// termsheetها
const termsheets = cards
.map((card, i) => ({
id: "new-ts-" + (i + 1),
@@ -319,7 +309,6 @@ export function WizardPage() {
}))
.filter((t) => t.milestones.length);
// اتصال اقلام به termsheetها
collected.forEach(({ li, tempId }) => {
const ids = cards
.map((card, i) =>
@@ -330,7 +319,6 @@ export function WizardPage() {
li.termsheet_ids = valid.length ? valid : termsheets.length ? [termsheets[0].id] : [];
});
// پروژه‌ها
const projSet = new Set<string>();
collected.forEach(({ li, tempId }) => {
const list = assignment[tempId] || [];
@@ -361,7 +349,6 @@ export function WizardPage() {
try {
const created = await createQuote.mutateAsync(quote);
// آپلود پیش‌فاکتور به MinIO بعد از ساخت پیشنهاد
if (proforma) {
try {
await api.files.upload(created.id, "proforma", proforma);
@@ -379,7 +366,6 @@ export function WizardPage() {
}
};
// ذخیره — اگر بخشی ناقص است، دیالوگ تأیید با لینک فیلدهای خالی باز می‌شود
const save = () => {
const incomplete: { label: string; step: number }[] = [];
if (!completedSteps.has(1)) incomplete.push({ label: "تأمین‌کننده", step: 1 });
@@ -475,19 +461,54 @@ export function WizardPage() {
</div>
)}
{/* پاورقی ویزارد */}
<div className="mt-4 flex items-center gap-2 rounded-xl border border-line bg-panel p-3 shadow-sm">
{/* پاورقی ویزارد — با آیکون‌های استاندارد و جهت‌گیری دقیق RTL */}
<div className="mt-5 flex items-center justify-between rounded-xl border border-line bg-panel p-3.5 shadow-sm">
{/* دکمه مرحله قبلی (فلش رو به راست = بازگشت در فارسی) */}
<div>
{currentStep > 1 && (
<Button variant="slate" onClick={() => goToStep(currentStep - 1)}>
‹ قبلی
</Button>
<button
type="button"
onClick={() => goToStep(currentStep - 1)}
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"
>
<ChevronRight className="h-4 w-4" />
<span>قبلی</span>
</button>
)}
<div className="flex-1" />
<Link to="/" className="btn-line-file no-underline">
</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 onClick={next}>بعدی ›</Button>}
{currentStep === TOTAL_STEPS && <Button onClick={() => void save()}>ذخیره پیشنهاد</Button>}
{/* دکمه مرحله بعدی (فلش رو به چپ = پیشروی در فارسی) */}
{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>
{/* دیالوگ ذخیره با فیلدهای خالی */}
+28 -1
View File
@@ -16,8 +16,9 @@ export interface AuthUser {
phone: string | null;
role: "pm" | "procurement_manager" | "admin" | null;
role_label: string | null;
project_id: string | null;
project_id: string | null; // پروژه‌ی فعال (PM) — با سوییچر عوض می‌شود
project_name: string | null;
projects?: { id: string; name: string }[]; // همه‌ی پروژه‌های تخصیص‌یافته (1:N)
display_name: string;
}
@@ -28,6 +29,11 @@ interface AuthStore {
setToken: (token: string | null) => void;
setUser: (user: AuthUser | 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;
}
@@ -53,9 +59,30 @@ export const useAuthStore = create<AuthStore>()(
role_label: role === "pm" ? "مدیر پروژه (آزمایشی)" : role === "procurement_manager" ? "مدیر تدارکات (آزمایشی)" : "ادمین (آزمایشی)",
project_id: projectId,
project_name: projectName,
projects: projectId ? [{ id: projectId, name: 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 }),
}),
{ name: "procurement-auth-v1" },