Author SHA1 Message Date
fatemeh sane 416683b3d7 fix: resolve typescript configs and finish merge 2026-10-07 12:23:42 +03:30
fatemeh sane bc86d35e1e Update UI and logic for supplier name and call button 2026-10-07 12:07:40 +03:30
fatemeh sane 8c5f4ab770 feat: show material suppliers 2026-10-01 11:16:46 +03:30
ZCode 253c922280 revert: restore production to the state before 730d0bd (PM/RBAC work moved to 'pm')
Their three commits (730d0bd, 5a9b543, 387edfb) reached production by push and
auto-deployed. 387edfb expects a users.username column that the live database does
not have, so POST /api/v1/auth/login was returning HTTP 500. Their schema changes
never applied either (projects still has only id, name, description, start_date,
pm_user_id, created_at), so nothing depends on the reverted code.

This commit sets the tree back to exactly a02b3ea — verified with git diff against
it — while keeping history linear: the work itself is preserved on 'pm' at 387edfb.
2026-09-26 17:41:07 +00:00
Milad 387edfb1ab feat(org,rbac): dedicate organization panel, enforce role permissions, subheader info, and fix supervisor project sync 2026-09-26 18:51:37 +03:30
Milad 5a9b543040 feat(rbac): enforce procurement write restrictions and support supervisor role in SSO 2026-09-26 12:32:01 +03:30
Milad 730d0bd496 feat: PM panel under ERP shell + project-supervisor role
- /panel/pm: the PM app (served on the same origin under /pm) is embedded as
  a full-height iframe; supervisors are funneled there from every route,
  ERP MobileTabBar hidden on that route
- Panels menu: «مدیریت پروژه (PM)» is an internal route (admin/pm/supervisor
  only — procurement managers do not see it); PM link is no longer external
- New system role 'supervisor' (user_role enum): label in users router,
  allowed in UserRoleUpdate schema, deny-by-default on all ERP routers;
  admin panel can grant/revoke it alongside procurement manager
- Removed the obsolete VITE_PM_URL build plumbing (same-origin now)
2026-09-26 01:39:48 +03:30
MMD a02b3ea7cc fix(edit-modal): restore the proforma upload section
The proforma FileList existed in prod_v5 (a181a87) but was dropped when the
prod_v2 EditModal redesign (de177f3) was merged — that branch predates the
feature and its version of the file was taken wholesale to keep the new
tabbed layout, silently discarding the proforma block.

Restores the state, the load effect, the saved patch field and the upload
UI (on the تاریخ tab, next to the invoice card), using the current
authenticated download/preview helpers rather than the removed urlOf.
2026-09-23 22:01:14 +03:30
Behrad 49dd159fb1 feat: improve line items layout, number inputs and supplier click-to-call 2026-09-22 23:06:17 +03:30
Behrad 972b1b49cb fix(dashboard): restore expand toggle on mobile quote cards and add explicit expand action bar 2026-09-21 19:57:15 +03:30
Behrad b176f0f5ff fix(wizard): optimize stepper and line items layout for mobile 2026-09-21 19:39:12 +03:30
Behrad 9386d4846f feat: add lead time badge, refine mobile supplier click, and integrate termsheet cards in edit modal 2026-09-21 19:26:52 +03:30
16 changed files with 1222 additions and 674 deletions
+45 -29
View File
@@ -1,3 +1,4 @@
# Procurement Stack v1.3 — Dockerized (PostgreSQL + FastAPI + React + MinIO)
Self-contained ship package: everything needed to run the stack is in this
@@ -12,7 +13,9 @@ folder — no external files or sibling folders required.
| **frontend** | `procurement-frontend` | React 19 + Vite, served by nginx (internal) | via Traefik |
| **traefik** | `procurement-traefik` | Traefik v3 — public reverse proxy (edge) | `80` (web), `443` (websecure) |
```
Browser ──► Traefik :80 (edge)
│
├── Host(localhost) ──► frontend (nginx SPA :80)
@@ -25,6 +28,7 @@ Browser ──► Traefik :80 (edge)
│
├── postgres :5432 (procurement + casdoor DBs)
└── minio :9000 (invoice/proforma files)
```
---
@@ -74,6 +78,7 @@ v1.3/
├── Projects/ (projects)
├── Units/ (unit conversions)
└── Inventory/ (warehouse management panel — React port)
```
---
@@ -86,9 +91,9 @@ the form (username / password) is sent to
(password grant), verifies the returned JWT, and starts the session —
no browser redirect to Casdoor.
- **مدیر پروژه (PM)** → lands in the inventory panel (their project) — can record
* **مدیر پروژه (PM)** → lands in the inventory panel (their project) — can record
stock changes, manage the project's warehouses and confirm arrivals («کالاهای در راه»).
- **مدیر تدارکات / ادمین** → lands in the procurement office — can manage
* **مدیر تدارکات / ادمین** → lands in the procurement office — can manage
quotes, suppliers, projects, and in the inventory panel: dashboard, daily control,
«کالاهای در راه» (receiving included) and «سازمان‌دهی» (users, PMs, warehouses).
The «انبارها» and «تغییر موجودی» sections are PM-only.
@@ -111,11 +116,11 @@ for production builds).
Run `backend/tools/seed_casdoor.py` once after the first boot to create the
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).
are in [`docs/casdoor-auth.md`](https://www.google.com/search?q=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).
> [Restoring the committed seed data](https://www.google.com/search?q=%23restoring-the-committed-seed-data).
---
@@ -131,7 +136,7 @@ at these public addresses (HTTPS):
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)).
[`docs/casdoor-auth.md`](https://www.google.com/search?q=docs/casdoor-auth.md)).
### MinIO console on the server (SSH tunnel)
@@ -141,20 +146,19 @@ 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 <user>@<server-host>
```
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 the production MinIO root credentials from the server's
`/srv/erp/.env` (`MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD`) — they are
**never** written in this repo or its history. See
[`docs/SECRET-MANAGEMENT.md`](docs/SECRET-MANAGEMENT.md).
[`docs/SECRET-MANAGEMENT.md`](https://www.google.com/search?q=docs/SECRET-MANAGEMENT.md).
You will see the **`procurement-files`** bucket — the invoices/proformas the
ERP stores, browsable and downloadable from there.
@@ -168,11 +172,11 @@ tunnel.
Notes from the server setup:
- The panel is published only on the server's loopback, so the internet
* 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
* 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
* 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.
@@ -181,9 +185,9 @@ Notes from the server setup:
## Prerequisites (target machine)
- **Docker Desktop** (Windows) or Docker Engine + Compose plugin (Linux/macOS)
- ~3–4 GB free disk for images (`postgres:18`, `python:3.13-slim`, `node:22-alpine`, `nginx:alpine`, `minio/minio`, `minio/mc`)
- Internet access on first build (image + `npm ci` downloads)
* **Docker Desktop** (Windows) or Docker Engine + Compose plugin (Linux/macOS)
* ~3–4 GB free disk for images (`postgres:18`, `python:3.13-slim`, `node:22-alpine`, `nginx:alpine`, `minio/minio`, `minio/mc`)
* Internet access on first build (image + `npm ci` downloads)
---
@@ -192,6 +196,7 @@ Notes from the server setup:
```powershell
cd <this-folder>
docker compose up -d --build
```
First run downloads base images and builds the frontend (takes a few minutes).
@@ -201,6 +206,7 @@ Wait until all services are up:
```powershell
docker compose ps
```
All should show `Up` (postgres also `(healthy)`).
@@ -225,11 +231,12 @@ Automated end-to-end checks (authenticates via `SMOKE_USER` /
```powershell
backend\tools\smoke_test.py # read paths
backend\tools\smoke_test_write.py / smoke_test_misc.py
```
> Every API route now requires a Casdoor JWT — a bare
> `curl http://localhost/api/v1/quotes` returns **401** by design.
> Day-2 operations (deploys, backups, monitoring): [`docs/OPERATIONS.md`](docs/OPERATIONS.md).
> Day-2 operations (deploys, backups, monitoring): [`docs/OPERATIONS.md`](https://www.google.com/search?q=docs/OPERATIONS.md).
---
@@ -241,7 +248,7 @@ backend\tools\smoke_test_write.py / smoke_test_misc.py
| Web console | http://localhost:9001 |
| Root user / password | `minioadmin` / `minioadmin` (dev — change for production) |
| Bucket | `procurement-files` (created automatically by the `minio-init` container) |
| Object key layout | `{quote_id}/{kind}/{attachment_id}` where kind = `invoice` \| `proforma` |
| Object key layout | `{quote_id}/{kind}/{attachment_id}` where kind = `invoice` | `proforma` |
File **bytes** live in MinIO; the `quote_attachments` table holds the metadata.
@@ -249,6 +256,7 @@ Ad-hoc bucket inspection from the host (see `mc.cmd`):
```powershell
.\mc.cmd ls --recursive
```
---
@@ -261,10 +269,12 @@ Port: 5433
Database: procurement
Username: postgres
Password: postgres
```
```powershell
docker exec -it procurement-postgres psql -U postgres -d procurement
```
---
@@ -277,22 +287,24 @@ material). Two supported ways to bring a fresh stack up:
1. **Empty boot** — schema v2.6 auto-applies on the first boot of an empty
`postgres_data` volume; then run the seeders:
```powershell
# Casdoor org + app + users (prints/generates credentials — do not commit them)
docker compose run --rm -e CASDOOR_BASE=http://casdoor:8000 api python tools/seed_casdoor.py
# Demo dataset (optional):
docker exec procurement-api sh -c "cd /srv/backend && python -m tools.seed"
```
2. **Restore your own dump** — create dumps on the server (kept server-side,
e.g. `/srv/erp/backups/`), never in git:
```powershell
docker exec procurement-postgres pg_dump -U postgres -d procurement -Fc > /srv/erp/backups/procurement-$(date +%F).dump
docker exec procurement-postgres pg_dump -U postgres -d casdoor -Fc > /srv/erp/backups/casdoor-$(date +%F).dump
```
Restore with `pg_restore --clean --if-exists` (stop `api` + `casdoor`
first — this **replaces all data**). The scheduled backup sidecar
(see `docs/OPERATIONS.md`) automates this.
@@ -304,40 +316,44 @@ docker compose logs -f api # see logs
docker compose down # stop (keep data)
docker compose up -d --build # rebuild after code changes
docker compose down -v # ⚠️ full reset — wipes DB + MinIO volumes
```
---
## Known behavior
- Authentication is Casdoor-based (JWT verified via JWKS) and **required on
* Authentication is Casdoor-based (JWT verified via JWKS) and **required on
every API route** except `/health`, `/health/ready` and `/auth/login`
(401 without a token, 403 without a role). The former dev-header fallback
(`X-User-Role` / `X-User-Name-B64`) is disabled unless
`PROCUREMENT_DEV_LOGIN_ENABLED=true` — never enable it in production.
- Roles live in the app's `users` table (Casdoor is identity-only): `pm`,
* Roles live in the app's `users` table (Casdoor is identity-only): `pm`,
`procurement_manager`, `admin`. Role changes are **admin-only** (RBAC);
the procurement manager can only assign projects to project managers.
The admin has a dedicated panel at `/admin` (user sync from Casdoor +
grant/revoke «مدیر تدارکات»).
- **Partial goods receipt** («رسید جزیی») is supported in «کالاهای در راه»:
* **Partial goods receipt** («رسید جزیی») is supported in «کالاهای در راه»:
at least one item with a valid quantity is enough to submit; other items
stay in transit. The procurement ledger shows «رسیده X از Y» / «در راه Z»
badges and a «رسید جزیی» badge on the quote.
- **One PostgreSQL instance** serves both the app (`procurement`) and Casdoor
* **One PostgreSQL instance** serves both the app (`procurement`) and Casdoor
(`casdoor`) — the separate casdoor-postgres container is gone (data was
dumped/restored; see `docs/casdoor-auth.md`).
- **Traefik v3** is the public edge (ports 80/443); the frontend nginx only
* **Traefik v3** is the public edge (ports 80/443); the frontend nginx only
serves the SPA and proxies `/api` internally. Production (real domains,
HTTPS via Let's Encrypt, HTTP→HTTPS redirect):
`docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build`
(see the prod file header for the required env vars).
- Schema runs automatically ONLY on the first boot of an empty `postgres_data` volume
* Schema runs automatically ONLY on the first boot of an empty `postgres_data` volume
(see `docker-entrypoint-initdb.d`); the API also runs `CREATE TABLE IF NOT EXISTS`
for the units-dashboard tables and `quote_attachments` at startup.
- Invoice upload requires an `accepted`/`arrived` quote; proforma has no restriction.
- The supplier detail page (`/suppliers/:id`) shows the purchase history with
* Invoice upload requires an `accepted`/`arrived` quote; proforma has no restriction.
* The supplier detail page (`/suppliers/:id`) shows the purchase history with
invoice/proforma download links from MinIO.
- If builds fail with disk-full errors: `docker system prune -a`, then rebuild.
- If the frontend shows «اتصال به سرور برقرار نشد» → the api container is not
* **Material-Supplier Lookup**: Clicking a material in the catalog (e.g., on the `/units` page) dynamically fetches its assigned suppliers (`GET /api/materials/{id}/suppliers`). The backend uses an atomic JOIN to guarantee accurate supplier data (avoiding SQLAlchemy identity-map mismatches), and the UI displays a clean empty state («تامین‌کننده‌ای برای این متریال ثبت نشده است») if none exist.
* If builds fail with disk-full errors: `docker system prune -a`, then rebuild.
* If the frontend shows «اتصال به سرور برقرار نشد» → the api container is not
reachable; check `docker compose ps` and `docker compose logs api`.
```
+214 -24
View File
@@ -2,10 +2,12 @@
GET/POST /suppliers/{id}/materials — manage a supplier's catalog.
DELETE /suppliers/{id}/materials/{material_id} — unlink.
GET /materials/{id}/suppliers — price-comparison data source.
GET /materials/{id}/suppliers — suppliers linked to a material.
"""
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
@@ -20,29 +22,51 @@ from app.utils.serialization import to_iso
router = APIRouter(tags=["supplier-materials"])
def link_to_dict(link: SupplierMaterial, material: Material | None = None,
def link_to_dict(
link: SupplierMaterial,
material: Material | None = None,
category: MaterialCategory | None = None,
supplier: Supplier | None = None) -> dict:
supplier: Supplier | None = None,
) -> dict:
<<<<<<< Updated upstream
"""Serialize a SupplierMaterial ORM row to a plain dict for the API envelope."""
=======
"""Serialize a SupplierMaterial ORM row to a plain dict."""
>>>>>>> Stashed changes
return {
"id": str(link.id),
"supplier_id": str(link.supplier_id),
"material_id": str(link.material_id),
"is_active": link.is_active,
"last_quoted_price": float(link.last_quoted_price) if link.last_quoted_price is not None else None,
"last_quoted_price": (
float(link.last_quoted_price) if link.last_quoted_price is not None else None
),
"last_quoted_at": to_iso(link.last_quoted_at) if link.last_quoted_at else None,
"created_at": to_iso(link.created_at),
"material": {
"material": (
{
"id": str(material.id),
"name": material.name,
"category": {"id": str(category.id), "name": category.name} if category else None,
} if material else None,
"supplier": {"id": str(supplier.id), "name": supplier.name} if supplier else None,
"category": (
{"id": str(category.id), "name": category.name} if category else None
),
}
if material
else None
),
"supplier": (
{"id": str(supplier.id), "name": supplier.name} if supplier else None
),
}
# ---------------------------------------------------------------------------
# GET /suppliers/{supplier_id}/materials
# ---------------------------------------------------------------------------
@router.get("/suppliers/{supplier_id}/materials")
async def list_supplier_materials(
supplier_id,
supplier_id: UUID,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
@@ -56,44 +80,210 @@ async def list_supplier_materials(
.order_by(SupplierMaterial.created_at)
)
items, meta = await paginate(stmt, page, page_size, db)
mat_ids = {l.material_id for l in items}
materials = {m.id: m for m in (await db.execute(select(Material).where(Material.id.in_(mat_ids)))).scalars()}
return {"data": [link_to_dict(l, materials.get(l.material_id), None) for l in items], "meta": meta}
mat_ids = [lnk.material_id for lnk in items]
materials: dict = {}
if mat_ids:
result = await db.execute(select(Material).where(Material.id.in_(mat_ids)))
materials = {m.id: m for m in result.scalars()}
return {
"data": [link_to_dict(lnk, materials.get(lnk.material_id)) for lnk in items],
"meta": meta,
}
# ---------------------------------------------------------------------------
# POST /suppliers/{supplier_id}/materials
# ---------------------------------------------------------------------------
@router.post("/suppliers/{supplier_id}/materials", status_code=201)
async def create_supplier_material(
supplier_id, payload: SupplierMaterialCreate, db: AsyncSession = Depends(get_db)
supplier_id: UUID,
payload: SupplierMaterialCreate,
db: AsyncSession = Depends(get_db),
):
async with db.begin():
link = await link_supplier_material(db, supplier_id, payload.material_id)
material = await db.get(Material, payload.material_id)
return {"data": link_to_dict(link, material, None)}
return {"data": link_to_dict(link, material)}
# ---------------------------------------------------------------------------
# DELETE /suppliers/{supplier_id}/materials/{material_id}
# ---------------------------------------------------------------------------
@router.delete("/suppliers/{supplier_id}/materials/{material_id}")
async def delete_supplier_material(supplier_id, material_id, db: AsyncSession = Depends(get_db)):
async def delete_supplier_material(
supplier_id: UUID,
material_id: UUID,
<<<<<<< Updated upstream
db: AsyncSession = Depends(get_db),
):
async with db.begin():
await unlink_supplier_material(db, supplier_id, material_id)
return {"data": {"supplier_id": str(supplier_id), "material_id": str(material_id), "deleted": True}}
return {
"data": {
"supplier_id": str(supplier_id),
"material_id": str(material_id),
"deleted": True,
}
}
@router.get("/materials/{material_id}/suppliers")
# ---------------------------------------------------------------------------
# GET /materials/{material_id}/suppliers
# ---------------------------------------------------------------------------
@router.get(
"/materials/{material_id}/suppliers",
summary="List all suppliers linked to a material",
)
async def list_material_suppliers(
material_id,
material_id: UUID,
db: AsyncSession = Depends(get_db),
):
"""Return the suppliers linked to a material, straight from PostgreSQL.
The path parameter is typed as UUID so FastAPI validates and coerces it
before the handler runs; a malformed id is rejected with 422, an unknown
material with 404.
Atomic JOIN — a supplier is returned only through its supplier_materials
link, so the response can never contain a supplier that is not actually
linked to this material:
select(Supplier)
.join(SupplierMaterial, Supplier.id == SupplierMaterial.supplier_id)
.where(SupplierMaterial.material_id == material_id)
Response envelope (frontend MaterialSupplier binds these fields directly):
{
"data": [
{
"id": "<supplier_uuid>",
"name": "فولاد ناب آریا",
"phone": "021-..." | null,
"contact_person": "علی محمدی" | null,
"location": "تهران" | null
}
]
=======
db: AsyncSession = Depends(get_db),
):
async with db.begin():
await unlink_supplier_material(db, supplier_id, material_id)
return {
"data": {
"supplier_id": str(supplier_id),
"material_id": str(material_id),
"deleted": True,
}
}
# ---------------------------------------------------------------------------
# GET /materials/{material_id}/suppliers
# ---------------------------------------------------------------------------
@router.get(
"/materials/{material_id}/suppliers",
summary="List all suppliers linked to a material",
)
async def list_material_suppliers(
material_id: UUID,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
):
"""Return a paginated list of suppliers for a given material.
Uses explicit column-level SELECT with named labels so SQLAlchemy returns
plain Row tuples — no ORM identity-map involvement — eliminating the
string-vs-UUID comparison bug and the stale-entity collision that occurred
when selecting two mapped entities in the same session.
Response shape (matched by the frontend RawMaterialSupplier interface):
{
"data": [
{
"id": "<supplier_uuid>",
"name": "فولاد ناب آریا",
"phone": "021-..." | null,
"contact_person": "علی محمدی" | null,
"location": "تهران" | null
}
],
"meta": {"total": 1, "page": 1, "page_size": 20}
>>>>>>> Stashed changes
}
"""
material = await db.get(Material, material_id)
if material is None:
raise NotFoundError("متریال یافت نشد")
<<<<<<< Updated upstream
stmt = (
select(SupplierMaterial)
select(Supplier)
.join(SupplierMaterial, Supplier.id == SupplierMaterial.supplier_id)
.where(SupplierMaterial.material_id == material_id)
.order_by(Supplier.name)
)
suppliers = (await db.execute(stmt)).scalars().all()
return {
"data": [
{
"id": str(supplier.id),
"name": supplier.name,
"phone": supplier.phone,
"contact_person": supplier.contact_person,
"location": supplier.location,
}
for supplier in suppliers
]
=======
total: int = (
await db.scalar(
select(func.count(SupplierMaterial.id)).where(
SupplierMaterial.material_id == material_id
)
)
) or 0
if total == 0:
return {"data": [], "meta": {"total": 0, "page": page, "page_size": page_size}}
# Explicit column-level SELECT — returns plain Row objects keyed by label,
# never ORM entities, so no identity-map collision is possible.
stmt = (
select(
Supplier.id.label("sup_id"),
Supplier.name.label("sup_name"),
Supplier.phone.label("sup_phone"),
Supplier.contact_person.label("sup_contact_person"),
Supplier.location.label("sup_location"),
)
.join(SupplierMaterial, Supplier.id == SupplierMaterial.supplier_id)
.where(SupplierMaterial.material_id == material_id)
.order_by(SupplierMaterial.last_quoted_at.desc().nulls_last())
.offset((page - 1) * page_size)
.limit(page_size)
)
items, meta = await paginate(stmt, page, page_size, db)
sup_ids = {l.supplier_id for l in items}
suppliers = {s.id: s for s in (await db.execute(select(Supplier).where(Supplier.id.in_(sup_ids)))).scalars()}
return {"data": [link_to_dict(l, supplier=suppliers.get(l.supplier_id)) for l in items], "meta": meta}
rows = (await db.execute(stmt)).all()
data = [
{
"id": str(row.sup_id),
"name": row.sup_name,
"phone": row.sup_phone,
"contact_person": row.sup_contact_person,
"location": row.sup_location,
}
for row in rows
]
return {
"data": data,
"meta": {"total": total, "page": page, "page_size": page_size},
>>>>>>> Stashed changes
}
+4 -4
View File
@@ -12,9 +12,9 @@ export interface MilestoneRowProps {
// یک ردیف مرحله‌ی پرداخت: تاریخ + مبلغ + دکمه‌ی حذف
export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }: MilestoneRowProps) {
return (
<div className="flex items-start gap-3 w-full my-2">
{/* کادر انتخاب تاریخ */}
<div className="w-36 shrink-0">
<div className="flex items-start gap-2 w-full my-2">
{/* کادر انتخاب تاریخ — جمع‌وجور و دقیقاً متناسب با فرمت تاریخ */}
<div className="w-28 shrink-0">
<JalaliDatePicker
value={milestone.when_iso}
onChange={(iso) => onChange({ ...milestone, when_iso: iso })}
@@ -23,7 +23,7 @@ export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }
/>
</div>
{/* کادر ورود مبلغ و حروف زیر آن */}
{/* کادر ورود مبلغ — کشیده و پرکننده حداکثری عرض صفحه */}
<div className="flex-1 min-w-0">
<NumberInput
value={milestone.amount}
+23 -17
View File
@@ -7,9 +7,11 @@ export interface NumberInputProps {
onChange: (n: number | null) => void;
placeholder?: string;
className?: string;
inputClassName?: string;
align?: "right" | "center" | "left"; // تنظیم جهت متن و اعداد (پیش‌فرض: راست‌چین)
showWords?: boolean; // نمایش معادل فارسی زیر input
inputMode?: "numeric" | "decimal" | undefined;
maxDecimals?: number; // حداکثر رقم اعشار (مثلاً ۲ برای قیمت واحد)
maxDecimals?: number; // حداکثر رقم اعشار
disabled?: boolean;
}
@@ -19,6 +21,8 @@ export function NumberInput({
onChange,
placeholder,
className,
inputClassName,
align = "right",
showWords = false,
inputMode = "numeric",
maxDecimals,
@@ -36,8 +40,6 @@ export function NumberInput({
const [text, setText] = useState<string>(() => (value != null ? formatValue(value) : ""));
// همگام‌سازی وقتی value از بیرون عوض می‌شود (مثلاً محاسبه‌ی خودکار قیمت کل).
// null از بیرون متن کاربر را پاک نمی‌کند — تا تایپ میانی مثل «0.» از بین نرود.
useEffect(() => {
const cur = parseNum(text);
if (value != null && value !== cur) {
@@ -60,19 +62,28 @@ export function NumberInput({
const n = parseNum(text);
const alignClass =
align === "center"
? "!text-center text-center"
: align === "left"
? "!text-left text-left"
: "!text-right text-right";
return (
<div className={cn("min-w-0", className)}>
<div className={cn("min-w-0 w-full", className)}>
<input
type="text"
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"
className={cn(
"num-input h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-xs 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",
alignClass,
inputClassName,
)}
value={text}
placeholder={placeholder}
inputMode={inputMode}
onChange={(e) => {
const raw = e.target.value;
// فرمت‌دهی خودکار هنگام تایپ — اما جداکننده‌ی اعشارِ وسط تایپ
// («0.» یا «1.») دست‌نخورده می‌ماند تا کاربر بتواند اعشار بزند
const parsed = parseNum(raw);
const trailingSep = /[.,،/٬٫]$/.test(raw);
if (parsed != null && !trailingSep) {
@@ -85,16 +96,11 @@ export function NumberInput({
}}
onBlur={() => commit(text)}
/>
{showWords && (
<span className={cn("num-pill", n == null && "empty")}>
{n != null ? (
<>
{groupCommas(n, true)} <span className="num-words">({toWords(n)})</span>
</>
) : (
""
)}
</span>
{showWords && n != null && (
<div className="mt-1 block text-right text-[11px] text-muted">
<span className="tabular-nums font-semibold text-ink/80">{groupCommas(n, true)}</span>{" "}
<span className="text-muted">({toWords(n)})</span>
</div>
)}
</div>
);
+37
View File
@@ -458,6 +458,36 @@ function adaptBrandLink(raw: RawBrandLink): BrandLinkRow {
return { id: raw.id, brand_id: raw.brand_id, brand_name: raw.brand?.name || "" };
}
// ── تأمین‌کننده‌های متریال — پاسخ GET /materials/{id}/suppliers ──────────────
// Raw shape mirrors the backend dict exactly. The backend uses an atomic
// column-level JOIN so `name` always comes from the suppliers table.
interface RawMaterialSupplier {
id: string;
name: string;
phone: string | null;
contact_person: string | null;
location: string | null;
}
/** Clean UI-facing type for one supplier linked to a material. */
export interface MaterialSupplier {
id: string;
name: string;
phone: string | null;
contact_person: string | null;
location: string | null;
}
function adaptMaterialSupplier(raw: RawMaterialSupplier): MaterialSupplier {
return {
id: raw.id,
name: raw.name,
phone: raw.phone,
contact_person: raw.contact_person,
location: raw.location,
};
}
// فایل پیوست — پاسخ بک‌اند (metadata؛ بایت‌ها از endpoint دانلود می‌آیند)
interface RawAttachment {
id: string;
@@ -1126,6 +1156,13 @@ export const api = {
updateCanonicalUnit: async (id: string, canonical_unit_id: number): Promise<void> => {
await patchData(`/materials/${id}`, { canonical_unit_id });
},
// فهرست تأمین‌کننده‌های متصل به یک متریال — GET /materials/{id}/suppliers
listSuppliers: async (materialId: string): Promise<MaterialSupplier[]> => {
const raw = await getData<RawMaterialSupplier[]>(
`/materials/${materialId}/suppliers?page_size=100`,
);
return raw.map(adaptMaterialSupplier);
},
},
projects: {
@@ -110,7 +110,7 @@ export function DashboardPage() {
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>
<span>اقلام و ضرایب</span>
</Link>
{/* دکمه اصلی: افزودن پیشنهاد جدید */}
+157 -158
View File
@@ -23,7 +23,7 @@ import { JalaliDatePicker } from "../../components/JalaliDatePicker";
import { NumberInput } from "../../components/NumberInput";
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
import { FileList } from "../../components/FileList";
import { MilestoneRow } from "../../components/MilestoneRow";
import { TermsheetCard } from "../../components/TermsheetCard";
import { Button } from "../../components/ui/button";
import {
Dialog,
@@ -86,6 +86,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
const [selectedProjects, setSelectedProjects] = useState<string[]>([]);
const [notes, setNotes] = useState("");
const [invoices, setInvoices] = useState<Attachment[]>([]);
const [proformas, setProformas] = useState<Attachment[]>([]);
const [itemRows, setItemRows] = useState<EditItemRow[]>([]);
const [tsCards, setTsCards] = useState<EditTsCard[]>([]);
const [quoteDate, setQuoteDate] = useState<string | null>(null);
@@ -153,8 +154,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
return {
key: li.id,
origLi: li,
// «متریال» این ردیف = نامِ خودِ متریال (v2.7: متریال دسته ندارد؛
// دسته روی تأمین‌کننده است و دیگر برچسبِ این فیلد نیست)
cat: m.name || "",
brand: m.brand || "",
specs: materialSpecs(m),
@@ -191,27 +190,65 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
.list(quoteId, "invoice")
.then((list) => alive && setInvoices(list))
.catch(() => alive && setInvoices([]));
void api.files
.list(quoteId, "proforma")
.then((list) => alive && setProformas(list))
.catch(() => alive && setProformas([]));
return () => {
alive = false;
};
}, [quoteId]);
if (!q || !ready) return null;
const itemOptions = useMemo(() => {
return itemRows
.filter((r) => !r.rejected || r.restore)
.map((r, idx) => ({
tempId: r.key,
label: `${faNum(idx + 1)}. ${r.cat || "قلم جدید"}${r.brand ? ` • ${r.brand}` : ""}`,
}));
}, [itemRows]);
const tsLabel = (card: EditTsCard, idx: number) => {
const orig = q.termsheets.find((t) => t.id === card.domId);
return orig?.label || `گزینه ${faNum(idx + 1)}`;
const getCoveredIds = (card: EditTsCard) => {
const cardId = card.domId || card.key;
const explicit = itemRows
.filter((r) => (!r.rejected || r.restore) && r.tsDomIds.includes(cardId))
.map((r) => r.key);
if (!explicit.length && tsCards[0]?.key === card.key) {
return itemRows.filter((r) => !r.rejected || r.restore).map((r) => r.key);
}
return explicit;
};
const handleCoverageChange = (cardKey: string, newCoveredKeys: string[]) => {
const card = tsCards.find((c) => c.key === cardKey);
const cardId = card?.domId || cardKey;
setItemRows((rows) =>
rows.map((row) => {
const shouldHave = newCoveredKeys.includes(row.key);
const exists = row.tsDomIds.includes(cardId);
if (shouldHave === exists) return row;
const nextTsDomIds = shouldHave
? [...row.tsDomIds, cardId]
: row.tsDomIds.filter((id) => id !== cardId);
return { ...row, tsDomIds: nextTsDomIds };
}),
);
};
if (!q || !ready) return null;
const patchItem = (key: string, patch: Partial<EditItemRow>) => {
setItemRows((rows) => rows.map((r) => (r.key === key ? { ...r, ...patch } : r)));
};
const addItemRow = () => {
const newKey = "new-" + Math.random().toString(36).slice(2, 9);
const defaultTsId = tsCards[0] ? tsCards[0].domId || tsCards[0].key : "";
setItemRows((rows) => [
...rows,
{
key: "new-" + Math.random().toString(36).slice(2, 9),
key: newKey,
cat: "",
brand: "",
specs: [],
@@ -219,7 +256,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
unit: "",
price: null,
total: null,
tsDomIds: tsCards[0] ? [tsCards[0].domId || tsCards[0].key] : [],
tsDomIds: defaultTsId ? [defaultTsId] : [],
acceptedTsId: null,
rejected: false,
restore: true,
@@ -234,12 +271,28 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
const addTsCard = () => {
setTsCards((cards) => [
...cards,
{ key: "new-" + Math.random().toString(36).slice(2, 9), domId: "", milestones: [{ when_iso: null, amount: null }] },
{
key: "new-ts-" + Date.now() + Math.random().toString(36).slice(2, 6),
domId: "",
milestones: [{ when_iso: null, amount: null }],
},
]);
};
const removeTsCard = (key: string) => {
if (tsCards.length <= 1) {
toast.error("حداقل یک روش شرایط پرداخت باید وجود داشته باشد.");
return;
}
setTsCards((cards) => cards.filter((c) => c.key !== key));
const card = tsCards.find((c) => c.key === key);
const cardId = card?.domId || key;
setItemRows((rows) =>
rows.map((r) => ({
...r,
tsDomIds: r.tsDomIds.filter((id) => id !== cardId && id !== key),
})),
);
};
const confirm = async () => {
@@ -266,6 +319,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
next.projects = [...selectedProjects];
next.notes = notes.trim();
next.invoices = invoices;
next.proformas = proformas;
const tsIdMap: Record<string, string> = {};
const newTermsheets: Termsheet[] = [];
@@ -276,6 +330,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
? domId
: nextDashedId("ts", newTermsheets.length ? newTermsheets : next.termsheets);
tsIdMap[domId] = realId;
tsIdMap[card.key] = realId;
const orig = next.termsheets.find((t) => t.id === realId);
const label = orig?.label || `گزینه ${faNum(i + 1)}`;
const st = orig?.status || "offered";
@@ -295,7 +350,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
const total = row.total;
if (!cat && !brand && qty == null && price == null) continue;
// هویت متریال = نام + برند + مشخصه (دیگر دسته‌ای برای تطبیق وجود ندارد)
let material = matchMaterial(materialsCopy, cat, brand, specs);
let materialId: string;
if (material) {
@@ -322,7 +376,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
if (!rejected) {
row.tsDomIds.forEach((domId) => {
const realId = tsIdMap[domId] || domId;
if (realId) termsheet_ids.push(realId);
if (realId && !termsheet_ids.includes(realId)) termsheet_ids.push(realId);
});
if (!termsheet_ids.length && next.termsheets.length) {
termsheet_ids.push(next.termsheets[0].id);
@@ -359,7 +413,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
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));
next.termsheets = next.termsheets.filter((t, idx) => referencedTs.has(t.id) || idx === 0);
next.line_items.forEach((li) => {
if (li.accepted_ts_id && !next.termsheets.some((t) => t.id === li.accepted_ts_id)) {
li.accepted_ts_id = null;
@@ -406,7 +460,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
<>
<Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}>
<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">
@@ -421,7 +475,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
</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"
@@ -492,12 +546,11 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
</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">
@@ -536,7 +589,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
</div>
</div>
{/* لیست اقلام */}
{/* لیست اقلام — چیدمان ۵ سطری هماهنگ با ویزارد */}
<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>
@@ -573,23 +626,24 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
</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>
{/* ردیف ۱: متریال و برند */}
<div className="grid grid-cols-2 gap-2.5">
<div className="flex flex-col gap-1 min-w-0">
<label className="text-[11px] font-semibold text-gray-600 text-right">متریال</label>
<input
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"
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 text-right 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="flex flex-col gap-1">
<label className="text-[11px] font-semibold text-gray-600">برند</label>
<div className="flex flex-col gap-1 min-w-0">
<label className="text-[11px] font-semibold text-gray-600 text-right">برند</label>
<input
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"
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 text-right 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 })}
@@ -597,65 +651,75 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
</div>
</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>
{/* ردیف ۲: مشخصه فنی (تمام‌عرض با فضای کافی برای تگ‌ها) */}
<div className="flex flex-col gap-1 min-w-0">
<label className="text-[11px] font-semibold text-gray-600 text-right">مشخصه فنی / ابعاد</label>
<MultiSelectCombobox
disabled={hasReceipts}
values={row.specs}
onChange={(specs) => patchItem(row.key, { specs })}
options={specOptions}
placeholder="مشخصه"
placeholder="انتخاب مشخصات..."
className="min-w-0"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[11px] font-semibold text-gray-600">واحد</label>
<input
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>
{/* جعبه ۳ عددی مقادیر */}
<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>
{/* بخش مقادیر و محاسبات مالی */}
<div className="rounded-lg bg-gray-50/70 p-2.5 border border-gray-200 space-y-3">
{/* ردیف ۳: تعداد + واحد سنجش */}
<div className="grid grid-cols-[100px_1fr] sm:grid-cols-[120px_1fr] gap-2.5">
<div className="flex flex-col gap-1 min-w-0">
<label className="text-[10.5px] font-semibold text-gray-600 text-right">تعداد</label>
<NumberInput
disabled={hasReceipts}
value={row.qty}
onChange={(qty) => {
patchItem(row.key, {
qty,
total: qty != null && row.price != null ? Math.round(qty * row.price * 100) / 100 : row.total,
total: qty != null && row.price != null ? Math.round(qty * row.price) : row.total,
});
}}
placeholder="تعداد"
placeholder="مقدار"
className="!text-right text-right"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10.5px] font-semibold text-gray-600">قیمت واحد</label>
<div className="flex flex-col gap-1 min-w-0">
<label className="text-[10.5px] font-semibold text-gray-600 text-right">واحد سنجش</label>
<input
disabled={hasReceipts}
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 text-right 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>
{/* ردیف ۴: قیمت هر واحد (تمام‌عرض با نمایش حروف) */}
<div className="flex flex-col gap-1 min-w-0 border-t border-gray-200/80 pt-2.5">
<label className="text-[10.5px] font-semibold text-gray-600 text-right">
{row.unit ? `قیمت هر ${row.unit} (تومان)` : "قیمت هر واحد (تومان)"}
</label>
<NumberInput
disabled={hasReceipts}
value={row.price}
onChange={(price) => {
patchItem(row.key, {
price,
total: row.qty != null && price != null ? Math.round(row.qty * price * 100) / 100 : row.total,
total: row.qty != null && price != null ? Math.round(row.qty * price) : row.total,
});
}}
placeholder="واحد"
placeholder="قیمت هر واحد"
showWords
maxDecimals={2}
className="!text-right text-right"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10.5px] font-semibold text-gray-600">قیمت کل</label>
{/* ردیف ۵: قیمت کل این قلم (تمام‌عرض با نمایش حروف) */}
<div className="flex flex-col gap-1 min-w-0 border-t border-gray-200/80 pt-2.5">
<label className="text-[10.5px] font-semibold text-gray-600 text-right">قیمت کل این قلم (تومان)</label>
<NumberInput
disabled={hasReceipts}
value={row.total}
@@ -666,14 +730,15 @@ 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="قیمت کل قلم"
showWords
className="!text-right text-right"
/>
</div>
</div>
{/* شرایط پرداخت برای این قلم */}
{row.rejected && (
<div className="pt-2 border-t border-gray-100">
{row.rejected ? (
<label className="inline-flex items-center gap-1.5 text-xs font-semibold text-blue-600 cursor-pointer">
<input
type="checkbox"
@@ -683,46 +748,10 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
/>
بازگرداندن این قلم به معامله
</label>
) : (
<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, 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"
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;
if (e.target.checked) next.add(domId);
else next.delete(domId);
patchItem(row.key, { tsDomIds: [...next] });
}}
/>
<span>{tsLabel(card, cIdx)}</span>
</label>
);
})}
</div>
</div>
)}
</div>
</div>
</div>
))}
{!hasReceipts && (
@@ -741,11 +770,16 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
</div>
)}
{/* تب ۲: شرایط پرداخت */}
{/* تب ۲: شرایط پرداخت با قالب TermsheetCard ویزارد */}
{activeTab === "terms" && (
<div className="space-y-3.5">
<div className="flex items-center justify-between">
<div>
<div className="text-xs font-bold text-gray-800">گزینه‌های شرایط پرداخت</div>
<div className="text-[11.5px] text-gray-500 mt-0.5">
روش‌های پرداخت و اقلام تحت پوشش هر کدام را در کارت خودش مشخص کنید.
</div>
</div>
{!hasReceipts && (
<Button
type="button"
@@ -762,72 +796,18 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
<div className="flex flex-col gap-3">
{tsCards.map((card, idx) => (
<div
<TermsheetCard
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>
{!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)),
})
}
onRemove={() =>
updateTs(card.key, {
milestones: card.milestones.filter((_, j) => j !== i),
})
}
index={idx + 1}
milestones={card.milestones}
onMilestonesChange={(ms) => updateTs(card.key, { milestones: ms })}
items={itemOptions}
coveredIds={getCoveredIds(card)}
onCoverageChange={(ids) => handleCoverageChange(card.key, ids)}
onRemove={() => removeTsCard(card.key)}
/>
))}
</div>
{!hasReceipts && (
<Button
type="button"
variant="line"
size="sm"
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>
</div>
)}
@@ -891,11 +871,30 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
loadPreview={(a) => api.files.previewUrl(q.id, "invoice", a.id!)}
/>
</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={proformas}
onChange={setProformas}
upload={(f) => api.files.upload(q.id, "proforma", f)}
onDelete={(a) =>
a.id &&
void api.files.remove(q.id, "proforma", a.id).catch((err) =>
toast.error(err instanceof Error ? err.message : "حذف پیش‌فاکتور ناموفق بود"),
)
}
onDownload={(a) => api.files.download(q.id, "proforma", a.id!, a.name)}
loadPreview={(a) => api.files.previewUrl(q.id, "proforma", a.id!)}
label="افزودن پیش‌فاکتور"
/>
</div>
</div>
)}
</div>
{/* فوتر سفید، محکم و چسبان */}
{/* فوتر */}
<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
@@ -924,7 +923,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
</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>
+80 -31
View File
@@ -35,7 +35,6 @@ function InfoCell({ label, children, className }: { label: string; children: Rea
);
}
// کارت پیشنهاد خرید — بهینه‌شده برای کاربری سرپا با موبایل
export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit }: QuoteCardProps) {
const sup = supplierById(store.suppliers, q.supplier_id);
const expired = isExpired(q);
@@ -49,7 +48,10 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
if (q.status === "offered") {
acceptedCell = (
<button
onClick={onAccept}
onClick={(e) => {
e.stopPropagation();
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" />
@@ -134,53 +136,81 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
expanded && "border-accent/60 ring-1 ring-accent/20",
)}
>
{/* سربرگ کارت */}
<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">
{/* سربرگ کارت — با کلیک روی فضای خالی اکسپند می‌شود */}
<div
onClick={onToggleExpand}
className="flex cursor-pointer 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 select-none"
>
<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 flex-1">
<div className="flex min-w-0 items-center gap-1.5">
{/* خط ۱: فقط خود اسم تأمین‌کننده لینک است */}
<div className="flex items-center">
<Link
to={`/suppliers/${q.supplier_id}`}
className="min-w-0 flex-1 truncate text-xs font-bold text-ink no-underline hover:text-accent sm:text-[13.5px]"
onClick={(e) => e.stopPropagation()}
className="inline-block max-w-full truncate text-xs font-bold text-ink no-underline hover:text-accent hover:underline sm:text-[13.5px]"
>
{sup.name}
</Link>
{/* فاکتور رسمی / غیررسمی — از چک‌باکس «فاکتور رسمی» هنگام ثبت پیشنهاد */}
<span
className={cn(
"shrink-0 rounded px-1.5 py-px text-[9.5px] font-bold leading-4",
q.is_official
? "bg-green-bg text-green"
: "bg-slate-bg text-muted",
)}
title={q.is_official ? "فاکتور رسمی" : "فاکتور غیررسمی"}
>
{q.is_official ? "رسمی" : "غیررسمی"}
</div>
{/* خط ۲: موقعیت مکانی و برچسب هشدار فاکتور رسمی */}
<div className="mt-0.5 flex min-w-0 items-center gap-1.5">
{sup.location && (
<span className="truncate text-[10px] text-muted sm:text-[11px]">
{sup.location}
</span>
)}
{q.is_official && (
<span
className="inline-flex shrink-0 items-center gap-1 rounded bg-rose-500/10 px-1.5 py-0.5 text-[9.5px] font-bold text-rose-600 dark:text-rose-400 border border-rose-500/25 select-none"
title="فاکتور رسمی (مشمول ارزش افزوده و مالیات)"
>
<TriangleAlert className="h-2.5 w-2.5 shrink-0 stroke-[2.2] text-rose-500 dark:text-rose-400" />
<span>فاکتور رسمی</span>
</span>
)}
</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 sm:gap-1.5">
<div className="ms-auto flex shrink-0 items-center gap-1.5">
{fulfillmentBadge ?? (
<Badge variant={q.status as "offered" | "accepted" | "arrived"} className="text-[10px] sm:text-xs">
{STATUS_LABEL[q.status]}
</Badge>
)}
{/* دکمه ویرایش */}
<button
className="chip h-7 px-2 sm:h-auto sm:px-2.5"
onClick={onEdit}
type="button"
className="inline-flex h-7 items-center justify-center gap-1 rounded-md border border-line bg-panel px-2 text-xs font-medium text-ink hover:bg-slate-50 dark:hover:bg-zinc-800 transition-colors sm:h-8 sm:px-2.5"
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
title="ویرایش پیشنهاد"
aria-label="ویرایش"
>
<Pencil className="h-3 w-3" />
<Pencil className="h-3 w-3 text-muted" />
<span className="hidden sm:inline">ویرایش</span>
</button>
{/* دکمه فلش اکسپند در هدر */}
<button
className="chip chip-expand h-7 w-7 p-0 sm:h-auto sm:w-auto sm:p-1.5"
onClick={onToggleExpand}
title={expanded ? "جمع کردن" : "نمایش جزئیات"}
type="button"
className={cn(
"inline-flex h-7 w-7 items-center justify-center rounded-md border border-line bg-panel text-ink transition-colors hover:bg-slate-50 dark:hover:bg-zinc-800 sm:h-8 sm:w-8",
expanded && "bg-accent-bg text-accent-dark border-accent/40",
)}
onClick={(e) => {
e.stopPropagation();
onToggleExpand();
}}
title={expanded ? "جمع کردن توضیحات" : "نمایش توضیحات و جزئیات"}
aria-expanded={expanded}
>
<ChevronDown className={cn("h-4 w-4 transition-transform duration-200", expanded && "rotate-180")} />
@@ -188,9 +218,12 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
</div>
</div>
{/* اطلاعات کلیدی — چیدمان بهینه‌شده موبایل */}
<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">
{/* ۱. پروژه — تمام‌عرض در موبایل */}
{/* اطلاعات کلیدی — لمس این بخش هم کارت را اکسپند می‌کند */}
<div
onClick={onToggleExpand}
className="cursor-pointer grid grid-cols-2 gap-x-3 gap-y-3 p-3 select-none 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" />
@@ -200,7 +233,7 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
</div>
</InfoCell>
{/* ۲. اقلام — نمایش عمودی چندخطی */}
{/* ۲. اقلام */}
<InfoCell label="اقلام" className="col-span-2 md:col-span-1">
<div className="flex flex-col gap-1 text-xs font-medium leading-5 text-ink sm:text-[13px]">
{activeItems.length > 0 ? (
@@ -221,6 +254,7 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
<Link
key={w.unit}
to={`/units?cat=${encodeURIComponent(w.cat)}&unit=${encodeURIComponent(w.unit)}`}
onClick={(e) => e.stopPropagation()}
className="unit-warn mt-1 inline-flex items-center gap-1 text-[10px]"
title={`ضریب تبدیل «${esc(w.unit)}» را در داشبورد واحدها ثبت کنید`}
>
@@ -230,6 +264,7 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
))}
</InfoCell>
{/* ۳. مجموع مبلغ */}
<InfoCell label="مجموع مبلغ" className="col-span-1">
{pc ? (
<div>
@@ -286,8 +321,22 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
</span>
</div>
{/* جزئیات تکمیلی بازشونده */}
{expanded && <QuoteDetail store={store} q={q} />}
{/* دکمه تمام‌عرض توضیحات بیشتر */}
<button
type="button"
onClick={onToggleExpand}
className="flex w-full items-center justify-center gap-1.5 border-t border-line bg-bg/20 py-2 text-xs font-semibold text-muted hover:text-accent hover:bg-bg/50 transition-colors select-none"
>
<span>{expanded ? "بستن توضیحات و جزئیات" : "توضیحات بیشتر و جزئیات پیشنهاد"}</span>
<ChevronDown className={cn("h-3.5 w-3.5 transition-transform duration-200", expanded && "rotate-180 text-accent")} />
</button>
{/* جزئیات تکمیلی بازشونده — کلیک داخل آن مانع بسته شدن ناخواسته می‌شود */}
{expanded && (
<div onClick={(e) => e.stopPropagation()}>
<QuoteDetail store={store} q={q} />
</div>
)}
</div>
);
}
@@ -1,11 +1,12 @@
import { useEffect, useMemo, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { MapPin, Phone, User, Plus, Package, X } from "lucide-react";
import { MapPin, Phone, User, Plus, Package, X, Clock } from "lucide-react";
import { toast } from "sonner";
import { useMaterials, useQuotes, useStoreData, useSuppliers } from "../../hooks";
import { topicByName } from "../../lib/topics";
import { useAppStore } from "../../store";
import { api } from "../../lib/api";
import { daysBetween } from "../../lib/formatters";
import { Header, PageLayout } from "../../components/layout/Header";
import { EmptyState } from "../../components/EmptyState";
import { Badge } from "../../components/ui/badge";
@@ -14,6 +15,15 @@ import { ConfirmDeleteDialog } from "../../components/ConfirmDeleteDialog";
import { normalize } from "../../lib/normalize";
import { PurchaseHistory } from "./PurchaseHistory";
// تمیزکاری و استانداردسازی شماره برای شماره‌گیر خودکار موبایل (اندروید و iOS)
function cleanPhoneForTel(phone?: string | null): string {
if (!phone) return "";
const enDigits = phone
.replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d)))
.replace(/[٠-٩]/g, (d) => String("٠١٢٣٤٥٦٧٨٩".indexOf(d)));
return enDigits.replace(/(?!^\+)[^\d]/g, "");
}
// صفحه‌ی مشخصات کامل تأمین‌کننده + سابقه خرید + مدیریت متریال‌های تأمین‌شده
export function SupplierDetailPage() {
useStoreData();
@@ -29,6 +39,19 @@ export function SupplierDetailPage() {
const [linked, setLinked] = useState<string[]>([]);
const [busy, setBusy] = useState(false);
// محاسبه میانگین زمان تحویل این تأمین‌کننده بر اساس خریدهای تحویل‌شده
const avgLeadTime = useMemo(() => {
if (!s?.id) return null;
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);
return times.length ? Math.round(times.reduce((a, b) => a + b, 0) / times.length) : null;
}, [quotes, s?.id]);
useEffect(() => {
if (!id) return;
let alive = true;
@@ -68,7 +91,6 @@ export function SupplierDetailPage() {
const breadcrumb = `${topicName ? topicName + " — " : ""}${firstCat ? firstCat + " — " : ""}${s.name}`;
const saveCategories = (names: string[]) => {
// v2.6: اتصال واقعی تأمین‌کننده ↔ متریال (کاتالوگ) — با نام متریال
if (!id || busy) return;
setBusy(true);
void (async () => {
@@ -116,21 +138,51 @@ export function SupplierDetailPage() {
<Header title="اطلاعات تماس تأمین‌کننده" sub={breadcrumb} backTo="/suppliers" backLabel="تأمین‌کننده‌ها" />
<PageLayout>
<div className="max-w-xl rounded-xl border border-line bg-panel p-5 shadow-sm">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="text-xl font-extrabold">{s.name}</div>
<Badge variant="outline">{topicName}</Badge>
</div>
<div className="mt-3 flex flex-col gap-1.5 text-[13.5px]">
{avgLeadTime != null && (
<span className="inline-flex items-center gap-1.5 rounded-md border border-emerald-800/60 bg-emerald-950 px-2.5 py-1 text-xs font-semibold text-emerald-400">
<Clock className="h-3.5 w-3.5 text-emerald-400" />
<span>{avgLeadTime} روز</span>
</span>
)}
</div>
<div className="mt-3 flex flex-col gap-2 text-[13.5px]">
<span>
<MapPin className="me-1.5 inline h-4 w-4 text-muted" /> <b>موقعیت:</b> {s.location || "—"}
</span>
<span>
<Phone className="me-1.5 inline h-4 w-4 text-muted" /> <b>تلفن:</b> {s.phone || "—"}
</span>
{/* بخش تلفن — همراه با قابلیت تماس مستقیم با یک لمس */}
<div className="flex items-center">
<Phone className="me-1.5 inline h-4 w-4 text-muted" />
<b className="me-1">تلفن:</b>
{s.phone ? (
<a
href={`tel:${cleanPhoneForTel(s.phone)}`}
className="inline-flex items-center gap-1 font-semibold text-accent hover:underline active:opacity-80"
dir="ltr"
title="تماس مستقیم با تأمین‌کننده"
>
<span className="tabular-nums">{s.phone}</span>
</a>
) : (
<span className="text-muted">—</span>
)}
</div>
<span>
<User className="me-1.5 inline h-4 w-4 text-muted" /> <b>شخص رابط:</b> {s.contact || "—"}
</span>
<span>
<Clock className="me-1.5 inline h-4 w-4 text-muted" /> <b>میانگین زمان تحویل:</b>{" "}
{avgLeadTime != null ? `${avgLeadTime} روز` : "—"}
</span>
</div>
<div className="mt-4 flex items-center gap-2">
<Link
to={`/wizard?topic=${encodeURIComponent(topicName)}&supplier=${encodeURIComponent(s.id)}`}
@@ -151,7 +203,7 @@ export function SupplierDetailPage() {
</div>
</div>
{/* متریال‌های این تأمین‌کننده — افزودن/حذف + مشاهده‌ی همه */}
{/* متریال‌های این تأمین‌کننده */}
<div className="mt-4 max-w-xl rounded-xl border border-line bg-panel p-5 shadow-sm">
<h4 className="mb-1 text-[13.5px] font-bold">
<Package className="me-1 inline h-4 w-4 text-accent" /> متریال‌های تأمین‌شده
@@ -191,7 +243,7 @@ export function SupplierDetailPage() {
)}
</div>
{/* سابقه خرید از این تأمین‌کننده — همه‌ی پیشنهادها + فاکتور/پیش‌فاکتور */}
{/* سابقه خرید از این تأمین‌کننده */}
<div className="mt-4 max-w-4xl">
<PurchaseHistory supplier={s} quotes={quotes} materials={materials} />
</div>
+33 -2
View File
@@ -4,6 +4,15 @@ import type { Supplier } from "../../lib/types";
import { normalize } from "../../lib/normalize";
import { fmtNum } from "../../lib/formatters";
// تمیزکاری و استانداردسازی شماره برای شماره‌گیر خودکار موبایل (اندروید و iOS)
function cleanPhoneForTel(phone?: string | null): string {
if (!phone) return "";
const enDigits = phone
.replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d)))
.replace(/[٠-٩]/g, (d) => String("٠١٢٣٤٥٦٧٨٩".indexOf(d)));
return enDigits.replace(/(?!^\+)[^\d]/g, "");
}
export function suppliersOfCategory(topic: string, cat: string, suppliers: Supplier[]): Supplier[] {
return suppliers.filter(
(s) => normalize(s.topic) === normalize(topic) && (s.categories || []).some((c) => normalize(c) === normalize(cat)),
@@ -29,7 +38,11 @@ export function SupplierCard({
return (
<div className="sup-card">
<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">
{/* فقط متن نام تأمین‌کننده لینک است و به کل کارت گسترش پیدا نمی‌کند */}
<Link
to={`/suppliers/${s.id}`}
className="min-w-0 flex-1 text-sm font-bold text-primary hover:text-accent hover:underline active:opacity-70 leading-snug py-0.5"
>
{s.name}
</Link>
<div className="flex items-center gap-1.5 shrink-0">
@@ -49,17 +62,34 @@ export function SupplierCard({
)}
</div>
</div>
<div className="sc-meta">
<span>
<MapPin className="me-1 inline h-3 w-3" /> {s.location || "—"}
</span>
{/* شماره تماس قابل کلیک برای تماس مستقیم در موبایل */}
{s.phone ? (
<a
href={`tel:${cleanPhoneForTel(s.phone)}`}
className="inline-flex items-center text-primary hover:text-accent hover:underline active:opacity-75 transition-colors"
title="تماس مستقیم با تأمین‌کننده"
dir="ltr"
>
<Phone className="me-1 inline h-3 w-3 text-muted shrink-0" />
<span className="tabular-nums">{s.phone}</span>
</a>
) : (
<span>
<Phone className="me-1 inline h-3 w-3" /> {s.phone || "—"}
<Phone className="me-1 inline h-3 w-3 text-muted" /> —
</span>
)}
<span>
<User className="me-1 inline h-3 w-3" /> {s.contact || "—"}
</span>
</div>
{materials.length > 0 && (
<div className="mt-2 border-t border-line pt-1.5">
<div className="mb-1 flex flex-wrap gap-1">
@@ -71,6 +101,7 @@ export function SupplierCard({
</div>
</div>
)}
<div className="sc-actions">
<Link to={`/wizard?topic=${encodeURIComponent(topic)}&supplier=${encodeURIComponent(s.id)}`} className="btn-line-file no-underline">
<Plus className="h-3 w-3" />
+224 -115
View File
@@ -1,6 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { toast } from "sonner";
import {
Building2,
ChevronDown,
Link2,
Pencil,
@@ -12,7 +14,7 @@ import {
TriangleAlert,
} from "lucide-react";
import type { StandardConversion } from "../../lib/types";
import { api, apiV26, nextId, type BrandLinkRow, type MaterialUnitRow } from "../../lib/api";
import { api, apiV26, nextId, type BrandLinkRow, type MaterialSupplier, type MaterialUnitRow } from "../../lib/api";
import {
refreshProcurementStore,
useDeleteStandardConversion,
@@ -102,6 +104,12 @@ export function UnitsPage() {
const [rowsLoading, setRowsLoading] = useState(false);
const [brandLinks, setBrandLinks] = useState<BrandLinkRow[]>([]);
// تأمین‌کننده‌های متریال انتخاب‌شده — داده از GET /materials/{id}/suppliers
const [materialSuppliers, setMaterialSuppliers] = useState<MaterialSupplier[]>([]);
const [suppliersLoading, setSuppliersLoading] = useState(false);
// کلید پیشگیری از flicker: قبل از رسیدن پاسخ جدید، empty state نشان نمی‌دهیم
const [suppliersLoadedId, setSuppliersLoadedId] = useState<string | null>(null);
// واحدها و برندهای سراسری (فهرست دیتابیس)
const [units, setUnits] = useState<UnitLite[]>([]);
const [allBrands, setAllBrands] = useState<string[]>([]);
@@ -253,6 +261,7 @@ export function UnitsPage() {
if (!materialId) {
setRows([]);
setBrandLinks([]);
setMaterialSuppliers([]);
return;
}
void loadRows(materialId);
@@ -263,6 +272,33 @@ export function UnitsPage() {
setFormBrand("");
}, [materialId, loadRows]);
// واکشی تأمین‌کننده‌های متصل به متریال انتخاب‌شده
useEffect(() => {
if (!materialId) {
setMaterialSuppliers([]);
setSuppliersLoadedId(null);
return;
}
let alive = true;
setSuppliersLoading(true);
api.materials
.listSuppliers(materialId)
.then((list) => {
if (alive) {
setMaterialSuppliers(list);
setSuppliersLoadedId(materialId);
}
})
.catch((err: unknown) => {
if (!alive) return;
setMaterialSuppliers([]);
setSuppliersLoadedId(materialId);
toast.error(err instanceof Error ? err.message : "خواندن تأمین‌کننده‌های متریال ناموفق بود");
})
.finally(() => { if (alive) setSuppliersLoading(false); });
return () => { alive = false; };
}, [materialId]);
const brandNameFor = (row: MaterialUnitRow): string => {
if (!row.brand_material_id) return "";
return brandLinks.find((b) => b.id === row.brand_material_id)?.brand_name || "";
@@ -449,7 +485,7 @@ export function UnitsPage() {
return (
<div className="min-h-screen">
<Header
title="ضرایب تبدیل"
title="اقلام و ضرایب"
sub="متریال را انتخاب کنید — واحد مرجع و ضریب تبدیل همان متریال"
backTo="/"
backLabel="بازگشت به دفتر"
@@ -546,6 +582,77 @@ export function UnitsPage() {
</div>
) : (
<>
{/* ── تامین‌کنندگان متریال ── */}
<div className="mb-5 overflow-hidden rounded-xl border border-line bg-panel shadow-sm">
{/* سرصفحه — همیشه نمایش داده می‌شود */}
<div className="border-b border-line px-4 py-3">
<h4 className="text-[14px] font-bold text-gray-900">تامین‌کنندگان متریال</h4>
</div>
{/* محتوا — min-h برای جلوگیری از layout shift */}
<div className="min-h-[80px] p-4 transition-opacity duration-300 ease-in-out">
{suppliersLoading || materialId !== suppliersLoadedId ? (
/* اسپینر — نه empty state — تا پاسخ API برسد */
<div className="flex h-[80px] items-center justify-center">
<span className="h-5 w-5 animate-spin rounded-full border-2 border-muted border-t-transparent" />
</div>
) : materialSuppliers.length === 0 ? (
<p className="flex h-[80px] items-center justify-center text-sm text-muted">
تامین‌کننده‌ای برای این متریال ثبت نشده است
</p>
) : (
<div className="flex flex-col gap-3 mt-4 max-h-[320px] overflow-y-auto">
{materialSuppliers.map((s) => (
<div
key={s.id}
className="flex flex-col md:flex-row md:items-center justify-between p-4 bg-white border border-gray-200 rounded-xl shadow-sm gap-4 w-full"
>
{/* نام تامین‌کننده */}
<div className="flex-1 flex flex-col gap-1 text-right">
<span className="text-xs font-medium text-gray-900">نام تامین‌کننده</span>
<Link
to={`/suppliers/${s.id}`}
className="font-medium text-accent hover:underline decoration-dashed"
>
{s.name}
</Link>
</div>
{/* نام مسئول */}
<div className="flex-1 flex flex-col gap-1 text-right">
<span className="text-xs font-medium text-gray-900">نام مسئول</span>
<span className="text-gray-500">
{s.contact_person || <span className="text-gray-500">ثبت نشده</span>}
</span>
</div>
{/* تلفن */}
<div className="flex-1 flex flex-col gap-1 text-right">
<span className="text-xs font-medium text-gray-900">تلفن</span>
{s.phone ? (
<a
href={`tel:${s.phone}`}
dir="ltr"
className="font-medium text-accent hover:underline decoration-dashed"
>
{s.phone}
</a>
) : (
<span className="text-gray-500">ثبت نشده</span>
)}
</div>
{/* محل */}
<div className="flex-1 flex flex-col gap-1 text-right">
<span className="text-xs font-medium text-gray-900">محل</span>
<span className="text-gray-500">
{s.location || <span className="text-gray-500">ثبت نشده</span>}
</span>
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* واحد مرجع متریال */}
<div className="ref-unit-box">
<div className="ref-title">
@@ -587,119 +694,6 @@ export function UnitsPage() {
</div>
</div>
{/* ضرایب متریال */}
<h4 className="sec-title">
<Repeat className="me-1 inline h-4 w-4 text-accent" /> ضریب‌های تبدیل
</h4>
{rowsLoading ? (
<div className="conv-empty">در حال بارگذاری…</div>
) : !rows.length ? (
<div className="conv-empty">هنوز ضریبی برای این متریال ثبت نشده است.</div>
) : (
rows.map((r) => (
<div key={r.id}>
<div className="cg-head">
{unitDisplay(r.unit) || `واحد #${r.unit_id}`} ← {ref}
</div>
<div className="conv-row">
<span className="cv-brand">{brandNameFor(r) || "همه برندها"}</span>
<span className="cv-factor">{faNum(r.conversion_factor)}</span>
<span className="cv-arrow">×</span>
<span className="cv-unit">{ref}</span>
<span className="conv-actions">
<button type="button" title="ویرایش" onClick={() => startEdit(r)}>
<Pencil className="h-3.5 w-3.5" />
</button>
<button type="button" title="حذف" onClick={() => void deleteMu(r)}>
<Trash2 className="h-3.5 w-3.5" />
</button>
</span>
</div>
</div>
))
)}
{/* فرم افزودن ضریب */}
<div className="add-conv">
<h4 className="mb-2 text-[13.5px] font-bold">
{editingId ? (
<>
<Pencil className="me-1 inline h-3.5 w-3.5" /> ویرایش ضریب تبدیل
</>
) : (
<>
<Plus className="me-1 inline h-3.5 w-3.5" /> افزودن ضریب تبدیل
</>
)}
</h4>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[1.2fr_0.6fr_1fr_0.8fr_auto] sm:items-end">
<div className="field">
<label>واحد پیشنهادشده</label>
<Combobox
value={formUnit}
onChange={(it) => setFormUnit(it.label)}
items={units.map((u) => unitDisplay(u)).filter(Boolean)}
allowAdd
addSuffix="به عنوان واحد جدید"
placeholder="مثلاً حلقه"
onInputChange={setFormUnit}
/>
</div>
<div className="field">
<label>ضریب</label>
<NumberInput
value={factorNum()}
onChange={(n) => setFormFactor(n != null ? String(n) : "")}
placeholder="مثلاً 0.98"
inputMode="decimal"
/>
</div>
<div className="field">
<label>برند (خالی = همه برندها)</label>
<Combobox
value={formBrand}
onChange={(it) => setFormBrand(it.label)}
items={allBrands}
allowAdd
disabled={!!editingId}
addSuffix="به عنوان برند جدید"
placeholder="مثلاً راجین"
onInputChange={setFormBrand}
/>
</div>
<div className="field">
<label>به واحد (مرجع)</label>
<input
type="text"
className="h-9 w-full rounded-lg border border-line bg-slate-bg px-3 text-sm text-muted"
value={ref || "مرجع"}
placeholder="مرجع"
readOnly
/>
</div>
<div className="flex items-center gap-1.5">
<Button size="sm" onClick={() => void saveConv()}>
{editingId ? "ذخیره تغییرات" : "افزودن"}
</Button>
{editingId && (
<Button
size="sm"
variant="slate"
onClick={() => {
setEditingId(null);
setFormUnit("");
setFormFactor("");
setFormBrand("");
}}
>
انصراف
</Button>
)}
</div>
</div>
<div className="conv-live">{convLive}</div>
</div>
{/* واحدهای ناشناخته */}
{unknownUnits.length > 0 && (
<>
@@ -829,6 +823,121 @@ export function UnitsPage() {
تبدیل ثابت برای همه‌ی متریال‌ها اعمال می‌شود؛ ضریب یعنی: ۱ از واحد = ضریب × به واحد
</div>
</div>
{/* ضرایب متریال */}
<h4 className="sec-title">
<Repeat className="me-1 inline h-4 w-4 text-accent" /> ضریب‌های تبدیل
</h4>
{rowsLoading ? (
<div className="conv-empty">در حال بارگذاری…</div>
) : !rows.length ? (
<div className="conv-empty">هنوز ضریبی برای این متریال ثبت نشده است.</div>
) : (
rows.map((r) => (
<div key={r.id}>
<div className="cg-head">
{unitDisplay(r.unit) || `واحد #${r.unit_id}`} ← {ref}
</div>
<div className="conv-row">
<span className="cv-brand">{brandNameFor(r) || "همه برندها"}</span>
<span className="cv-factor">{faNum(r.conversion_factor)}</span>
<span className="cv-arrow">×</span>
<span className="cv-unit">{ref}</span>
<span className="conv-actions">
<button type="button" title="ویرایش" onClick={() => startEdit(r)}>
<Pencil className="h-3.5 w-3.5" />
</button>
<button type="button" title="حذف" onClick={() => void deleteMu(r)}>
<Trash2 className="h-3.5 w-3.5" />
</button>
</span>
</div>
</div>
))
)}
{/* فرم افزودن ضریب */}
<div className="add-conv">
<h4 className="mb-2 text-[13.5px] font-bold">
{editingId ? (
<>
<Pencil className="me-1 inline h-3.5 w-3.5" /> ویرایش ضریب تبدیل
</>
) : (
<>
<Plus className="me-1 inline h-3.5 w-3.5" /> افزودن ضریب تبدیل
</>
)}
</h4>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[1.2fr_0.6fr_1fr_0.8fr_auto] sm:items-end">
<div className="field">
<label>واحد پیشنهادشده</label>
<Combobox
value={formUnit}
onChange={(it) => setFormUnit(it.label)}
items={units.map((u) => unitDisplay(u)).filter(Boolean)}
allowAdd
addSuffix="به عنوان واحد جدید"
placeholder="مثلاً حلقه"
onInputChange={setFormUnit}
/>
</div>
<div className="field">
<label>ضریب</label>
<NumberInput
value={factorNum()}
onChange={(n) => setFormFactor(n != null ? String(n) : "")}
placeholder="مثلاً 0.98"
inputMode="decimal"
/>
</div>
<div className="field">
<label>برند (خالی = همه برندها)</label>
<Combobox
value={formBrand}
onChange={(it) => setFormBrand(it.label)}
items={allBrands}
allowAdd
disabled={!!editingId}
addSuffix="به عنوان برند جدید"
placeholder="مثلاً راجین"
onInputChange={setFormBrand}
/>
</div>
<div className="field">
<label>به واحد (مرجع)</label>
<input
type="text"
className="h-9 w-full rounded-lg border border-line bg-slate-bg px-3 text-sm text-muted"
value={ref || "مرجع"}
placeholder="مرجع"
readOnly
/>
</div>
<div className="flex items-center gap-1.5">
<Button size="sm" onClick={() => void saveConv()}>
{editingId ? "ذخیره تغییرات" : "افزودن"}
</Button>
{editingId && (
<Button
size="sm"
variant="slate"
onClick={() => {
setEditingId(null);
setFormUnit("");
setFormFactor("");
setFormBrand("");
}}
>
انصراف
</Button>
)}
</div>
</div>
<div className="conv-live">{convLive}</div>
</div>
</>
)}
</section>
+111 -55
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import { Trash2, Plus } from "lucide-react";
import type { Material } from "../../lib/types";
import { normalize } from "../../lib/normalize";
import { useMaterials } from "../../hooks";
@@ -7,6 +8,7 @@ import { Combobox } from "../../components/Combobox";
import { NumberInput } from "../../components/NumberInput";
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
import { api, apiV26 } from "../../lib/api";
import { faNum } from "../../lib/formatters";
export interface ItemDraft {
tempId: string;
@@ -51,7 +53,6 @@ export function materialOptions(
apiBrands: string[] = [],
apiSpecs: string[] = [],
): string[] {
// متریال‌ها سراسری هستند — مستقل از تاپیک
let list = materials;
const cat = item.cat.trim();
@@ -67,21 +68,17 @@ export function materialOptions(
};
if (field === "brand") {
// برند سراسری است — مستقل از تاپیک و متریال
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 (cat)
list = list.filter(
(m) => normalize(materialBaseName(m.name, m.brand, m.specs)) === 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)));
@@ -90,7 +87,7 @@ export function materialOptions(
return [...base].sort((a, b) => a.localeCompare(b, "fa"));
}
// مرحله ۲ — جدول پویای اقلام سفارش
// مرحله ۲ — مدیریت اقلام سفارش
export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember }: Step2Props) {
const materials = useMaterials();
@@ -167,46 +164,83 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
if (!it.specs.length) return;
rec = { topic: selectedTopic, category: it.cat.trim(), brand: it.brand.trim(), specs: it.specs };
}
// rec.category در واقع نامِ متریال است (فیلدِ اولِ ردیف) — تطبیق بر اساس
// همان هویتِ متریال انجام می‌شود، نه دسته.
const dup = !!matchMaterial(materials, rec.category, rec.brand, rec.specs);
if (dup) return;
onRemember(rec);
};
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-blue-600 text-[12px] font-bold text-white">۲</span>
<div className="space-y-4">
{/* هدر */}
<div className="space-y-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white shadow-xs">
۲
</span>
<h3 className="text-sm sm:text-base font-bold text-ink">
اقلام سفارش
</h3>
<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>
<span>برند</span>
<span>مشخصه</span>
<span>تعداد</span>
<span>واحد</span>
<span>قیمت واحد</span>
<span>قیمت کل</span>
</div>
<span className="rounded-full bg-blue-500/10 px-2.5 py-0.5 text-[11px] font-semibold text-blue-500 border border-blue-500/20">
{faNum(items.length)} قلم
</span>
</div>
<p className="text-[11.5px] sm:text-xs text-muted leading-relaxed">
مشخصات، تعداد و قیمت هر قلم را ثبت کنید. گزینه‌های جدید با تایپ ذخیره می‌شوند.
</p>
</div>
<div data-field="line_items" className="flex flex-col gap-2">
{items.map((it) => (
<div key={it.tempId} className="item-row wizard-item-row items-center">
{/* لیست کارت‌های اقلام */}
<div data-field="line_items" className="flex flex-col gap-3">
{items.map((it, idx) => (
<div
key={it.tempId}
className="rounded-xl border border-line bg-panel p-3.5 sm:p-4 shadow-2xs space-y-3 transition-all"
>
{/* سربرگ قلم */}
<div className="flex items-center justify-between border-b border-line pb-2">
<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-500/10 text-[11px] font-bold text-blue-500">
{faNum(idx + 1)}
</span>
<span className="text-xs font-bold text-ink truncate">
{it.cat || "قلم جدید"} {it.brand ? `• ${it.brand}` : ""}
</span>
</div>
{items.length > 1 && (
<button
type="button"
className="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs text-rose-500 hover:bg-rose-500/10 transition-colors"
title="حذف قلم"
onClick={() => remove(it.tempId)}
>
<Trash2 className="h-3.5 w-3.5" />
<span className="text-[11px]">حذف</span>
</button>
)}
</div>
{/* ردیف ۱: متریال و برند */}
<div className="grid grid-cols-2 gap-2.5">
<div className="flex flex-col gap-1 min-w-0">
<label className="text-[11px] font-semibold text-muted text-right">متریال</label>
<Combobox
value={it.cat}
onChange={(x) => patch(it.tempId, { cat: x.label })}
items={allMaterialNames}
allowAdd
addSuffix="به عنوان متریال جدید"
placeholder="متریال"
placeholder="نام متریال"
onInputChange={(v) => patch(it.tempId, { cat: v })}
onBlur={() => rememberField("i-cat", it)}
inputClassName="!text-[12px] !h-9"
inputClassName="!text-xs !h-9 text-right"
className="min-w-0"
/>
</div>
<div className="flex flex-col gap-1 min-w-0">
<label className="text-[11px] font-semibold text-muted text-right">برند</label>
<Combobox
value={it.brand}
onChange={(x) => patch(it.tempId, { brand: x.label })}
@@ -216,34 +250,52 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
placeholder="برند"
onInputChange={(v) => patch(it.tempId, { brand: v })}
onBlur={() => rememberField("i-brand", it)}
inputClassName="!text-[12px] !h-9"
inputClassName="!text-xs !h-9 text-right"
className="min-w-0"
/>
</div>
</div>
{/* ردیف ۲: مشخصه فنی */}
<div className="flex flex-col gap-1 min-w-0">
<label className="text-[11px] font-semibold text-muted text-right">مشخصه فنی / ابعاد</label>
<MultiSelectCombobox
values={it.specs}
onChange={(specs) => patch(it.tempId, { specs })}
options={materialOptions(materials, selectedTopic, it, "spec", items, [], specOptions)}
placeholder="مشخصه"
placeholder="انتخاب مشخصات (مثلاً ضخامت، رنگ، تیپ...)"
className="min-w-0"
/>
</div>
{/* بخش مقادیر و محاسبات مالی */}
<div className="rounded-lg bg-bg/50 p-2.5 border border-line space-y-3">
{/* ردیف ۳: تعداد + واحد سنجش */}
<div className="grid grid-cols-[100px_1fr] sm:grid-cols-[120px_1fr] gap-2.5">
<div className="flex flex-col gap-1 min-w-0">
<label className="text-[10.5px] font-semibold text-muted text-right">تعداد</label>
<NumberInput
value={it.qty}
onChange={(qty) => {
patch(it.tempId, {
qty,
total: qty != null && it.price != null ? Math.round(qty * it.price * 100) / 100 : it.total,
total: qty != null && it.price != null ? Math.round(qty * it.price) : it.total,
});
}}
placeholder="تعداد"
showWords
placeholder="مقدار"
className="!text-right text-right"
/>
</div>
<div className="flex flex-col gap-1 min-w-0">
<label className="text-[10.5px] font-semibold text-muted text-right">واحد سنجش</label>
<Combobox
value={it.unit}
onChange={(x) => patch(it.tempId, { unit: x.label })}
items={allUnits.filter((u) => normalize(u) !== normalize(it.unit))}
allowAdd
addSuffix="به عنوان واحد جدید"
placeholder="واحد"
placeholder="واحد (شاخه، متر، کیلو...)"
onInputChange={(v) => patch(it.tempId, { unit: v })}
onAdd={(v) => {
const name = v.trim();
@@ -257,56 +309,60 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
})
.catch(() => {});
}}
inputClassName="!text-[12px] !h-9"
inputClassName="!text-xs !h-9 text-right"
className="min-w-0"
/>
</div>
</div>
{/* ردیف ۴: قیمت هر واحد (راست‌چین شده) */}
<div className="flex flex-col gap-1 min-w-0 border-t border-line/60 pt-2.5">
<label className="text-[10.5px] font-semibold text-muted text-right">
{it.unit ? `قیمت هر ${it.unit} (تومان)` : "قیمت هر واحد (تومان)"}
</label>
<NumberInput
value={it.price}
onChange={(price) => {
patch(it.tempId, {
price,
total: it.qty != null && price != null ? Math.round(it.qty * price * 100) / 100 : it.total,
total: it.qty != null && price != null ? Math.round(it.qty * price) : it.total,
});
}}
placeholder="قیمت واحد"
placeholder="قیمت هر واحد"
showWords
maxDecimals={2}
className="!text-right text-right"
/>
</div>
{/* ردیف ۵: قیمت کل قلم (راست‌چین شده) */}
<div className="flex flex-col gap-1 min-w-0 border-t border-line/60 pt-2.5">
<label className="text-[10.5px] font-semibold text-muted text-right">قیمت کل این قلم (تومان)</label>
<NumberInput
value={it.total}
onChange={(total) => {
patch(it.tempId, {
total,
price:
total != null && it.qty != null && it.qty > 0 ? Math.round((total / it.qty) * 100) / 100 : it.price,
total != null && it.qty != null && it.qty > 0 ? Math.round(total / it.qty) : it.price,
});
}}
placeholder="قیمت کل"
placeholder="قیمت کل قلم"
showWords
className="!text-right text-right"
/>
{/* دکمه حذف مدرن با آیکون سطل زباله */}
<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>
</div>
))}
</div>
{/* دکمه افزودن قلم با استایل برجسته و خط‌چین */}
{/* دکمه افزودن قلم جدید */}
<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"
className="flex w-full items-center justify-center gap-1.5 rounded-xl border border-dashed border-line bg-panel py-2.5 text-xs font-semibold text-ink hover:border-accent hover:text-accent active:scale-[0.99] transition-all"
onClick={add}
>
<span className="text-base font-bold leading-none">+</span>
<Plus className="h-4 w-4" />
<span>افزودن قلم جدید</span>
</button>
</div>
+32 -14
View File
@@ -9,40 +9,58 @@ export interface StepperProps {
onGoto: (step: number) => void;
}
const STEPS = ["تأمین‌کننده", "اقلام", "شرایط پرداخت", "پروژه", "بازبینی و ثبت"];
const STEPS = [
{ id: 1, label: "تأمین‌کننده" },
{ id: 2, label: "اقلام" },
{ id: 3, label: "شرایط پرداخت" },
{ id: 4, label: "پروژه" },
{ id: 5, label: "بازبینی و ثبت" },
];
// نوار ۵ مرحله‌ای ویزارد — کلیک روی مراحل بازدیدشده مجاز است
// مراحل تکمیل‌شده سبز، مرحله‌ی فعلی آبی
// نوار ۵ مرحله‌ای ویزارد — واکنش‌گرا و ضدشکستگی در موبایل
export function Stepper({ current, completedSteps, onGoto }: StepperProps) {
return (
<div className="mb-4 flex flex-wrap gap-2 rounded-xl border border-line bg-panel p-2 shadow-sm">
{STEPS.map((label, i) => {
const step = i + 1;
<div className="mb-4 flex items-center justify-between gap-1 sm:gap-2 rounded-xl border border-line bg-panel p-1.5 sm:p-2 shadow-xs select-none">
{STEPS.map(({ id: step, label }) => {
const isCurrent = step === current;
const isComplete = completedSteps.has(step);
return (
<button
key={step}
type="button"
className={cn(
"flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-lg px-3 py-2 text-[12.5px] transition-colors",
"flex items-center justify-center gap-1.5 rounded-lg py-1.5 px-2 sm:px-3 text-xs transition-all duration-150 cursor-pointer",
// در موبایل مرحله جاری فضا می‌گیرد و بقیه جمع‌وجور می‌مانند تا سطر نشکند
isCurrent
? "bg-[#2563eb] font-bold text-white"
: isComplete
? "bg-green-bg font-medium text-green"
: "hover:bg-slate-bg",
? "flex-1 sm:flex-1 bg-blue-600 font-bold text-white shadow-xs"
: "flex-initial sm:flex-1 text-muted hover:text-foreground hover:bg-slate-50 dark:hover:bg-zinc-800/60",
isComplete && !isCurrent && "font-medium text-emerald-600 dark:text-emerald-400",
)}
onClick={() => onGoto(step)}
>
<span
className={cn(
"flex h-5 w-5 items-center justify-center rounded-full text-[11px] font-bold",
isCurrent ? "bg-white/25" : isComplete ? "bg-green-bg text-green" : "bg-slate-bg text-muted",
"flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[11px] font-bold transition-colors",
isCurrent
? "bg-white/20 text-white"
: isComplete
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950/80 dark:text-emerald-300"
: "bg-muted/15 text-muted",
)}
>
{isComplete ? <Check className="h-3 w-3" /> : faNum(step)}
{isComplete ? <Check className="h-3 w-3 stroke-[2.5]" /> : faNum(step)}
</span>
{/* در موبایل فقط نام مرحله جاری باز است؛ در صفحه بزرگتر همه نام‌ها نمایش دارند */}
<span
className={cn(
"whitespace-nowrap truncate text-[11.5px] sm:text-xs",
!isCurrent && "hidden md:inline",
)}
>
{label}
</span>
</button>
);
})}
+22 -38
View File
@@ -127,11 +127,9 @@ export function WizardPage() {
goToStep(currentStep + 1);
};
const rememberMaterial = (_rec: MaterialRec) => {
// رکوردهای متریال به همراه واحد و مشخصه‌ها در زمان ذخیره نهایی ساخته می‌شوند
};
const rememberMaterial = (_rec: MaterialRec) => {};
// ---------- پروژه‌ها: از مجموعه + نام‌های قدیمی quoteها ----------
// ---------- پروژه‌ها ----------
const projectNames = useMemo(() => {
const names = new Set<string>(projects.map((p) => p.name));
quotes.forEach((q) => {
@@ -159,7 +157,7 @@ export function WizardPage() {
});
};
// ---------- خلاصه‌ی مرحله ۵ ----------
// ---------- خلاصه مرحله ۵ ----------
const review = useMemo(() => {
let grandTotal = 0;
items.forEach((it) => {
@@ -202,23 +200,14 @@ export function WizardPage() {
const combined = `${field} ${msg}`.toLowerCase();
// ۱. تأمین‌کننده
if (/supplier|تأمین|تامین/i.test(combined)) return { step: 1, field: "supplier_id" };
// ۲. اقلام / متریال / واحد / تعداد / قیمت
if (/line_item|material|canonical_unit|unit_id|quantity|متریال|واحد|قلم|تعداد/i.test(combined)) {
return { step: 2, field: field || "line_items" };
}
// ۳. شرایط پرداخت / termsheet / مراحل پرداخت / سررسید
if (/termsheet|item_termsheet|schedule_step|due_date|milestone|ترم|پرداخت|پوشش|سررسید/i.test(combined)) {
return { step: 3, field: field || "termsheets" };
}
// ۴. پروژه
if (/project|پروژه/i.test(combined)) return { step: 4, field: "project" };
// ۵. تاریخ‌ها، یادداشت، پیش‌فاکتور
if (/expiry_date|تاریخ انقضا/i.test(combined)) return { step: 5, field: "expiry_date" };
if (/quote_date|تاریخ پیشنهاد/i.test(combined)) return { step: 5, field: "quote_date" };
if (/proforma|پیش‌فاکتور/i.test(combined)) return { step: 5, field: "proforma" };
@@ -291,10 +280,6 @@ export function WizardPage() {
if (!cat && !brand && qty == null && price == null) continue;
const wantName = (cat || brand || specs.join(" ") || "").trim() || "کالای جدید";
// هویت متریال = نام + برند + مشخصه. پیش از v2.7 دسته هم در تطبیق بود؛
// دسته دیگر روی متریال نیست و تکیه بر نام جلوی اشتباه‌گرفتن دو متریال با
// برند و مشخصه‌ی یکسان را می‌گیرد (matchMaterial نامِ پایه را هم می‌بیند
// تا رکوردهای قدیمی با نامِ «برند + دسته + مشخصه» پیدا شوند).
let material = materials.find((m) => looksLikeUuid(m.id) && normalize(m.name) === normalize(wantName));
if (!material) {
const matched = matchMaterial(
@@ -426,7 +411,7 @@ export function WizardPage() {
};
return (
<div className="min-h-screen">
<div className="min-h-screen pb-10">
<Header
title="افزودن پیشنهاد جدید"
sub="اطلاعات را از تماس با تأمین‌کننده وارد کنید"
@@ -434,10 +419,12 @@ export function WizardPage() {
backLabel="بازگشت به دفتر"
/>
<PageLayout>
{/* نوار مراحل استپر */}
<Stepper current={currentStep} highestVisited={highestVisited} completedSteps={completedSteps} onGoto={goToStep} />
{/* محتوای مراحل با پدینگ ریسپانسیو و بهینه برای موبایل */}
{currentStep === 1 && (
<div data-step="1" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
<div data-step="1" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
<Step1TopicSupplier
selectedTopic={selectedTopic}
onTopicSelect={(t) => {
@@ -461,12 +448,12 @@ export function WizardPage() {
</div>
)}
{currentStep === 2 && (
<div data-step="2" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
<div data-step="2" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
<Step2LineItems items={items} onItemsChange={setItems} selectedTopic={selectedTopic} onRemember={rememberMaterial} />
</div>
)}
{currentStep === 3 && (
<div data-step="3" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
<div data-step="3" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
<Step3Termsheets
cards={cards}
onCardsChange={setCards}
@@ -477,7 +464,7 @@ export function WizardPage() {
</div>
)}
{currentStep === 4 && (
<div data-step="4" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
<div data-step="4" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
<Step4Projects
items={items}
projects={projectNames}
@@ -491,7 +478,7 @@ export function WizardPage() {
</div>
)}
{currentStep === 5 && (
<div data-step="5" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
<div data-step="5" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
<Step5Review
quoteDate={quoteDate}
onQuoteDate={setQuoteDate}
@@ -506,15 +493,14 @@ export function WizardPage() {
</div>
)}
{/* پاورقی ویزارد — با آیکون‌های استاندارد و جهت‌گیری دقیق RTL */}
<div className="mt-5 flex items-center justify-between rounded-xl border border-line bg-panel p-3.5 shadow-sm">
{/* دکمه مرحله قبلی (فلش رو به راست = بازگشت در فارسی) */}
{/* پاورقی کنترل مراحل */}
<div className="mt-4 flex items-center justify-between rounded-xl border border-line bg-panel p-3 sm:p-3.5 shadow-xs">
<div>
{currentStep > 1 && (
<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"
className="inline-flex items-center gap-1 px-3 sm: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 text-xs sm:text-sm font-medium transition-colors shadow-2xs"
>
<ChevronRight className="h-4 w-4" />
<span>قبلی</span>
@@ -522,21 +508,19 @@ export function WizardPage() {
)}
</div>
{/* دکمه‌های اقدام (انصراف + بعدی / ذخیره) */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 sm: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"
className="inline-flex items-center justify-center px-3 sm:px-4 py-2 rounded-xl text-xs sm:text-sm font-medium text-slate-500 hover:text-slate-700 no-underline"
>
انصراف
</Link>
{/* دکمه مرحله بعدی (فلش رو به چپ = پیشروی در فارسی) */}
{currentStep < TOTAL_STEPS && (
<button
type="button"
onClick={next}
className="inline-flex items-center gap-1.5 px-6 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-sm font-semibold shadow-sm hover:shadow transition-all duration-150 cursor-pointer"
className="inline-flex items-center gap-1 px-4 sm:px-6 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-xs sm:text-sm font-semibold shadow-xs transition-all"
>
<span>بعدی</span>
<ChevronLeft className="h-4 w-4" />
@@ -547,7 +531,7 @@ export function WizardPage() {
<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"
className="inline-flex items-center gap-1 px-4 sm:px-6 py-2 rounded-xl bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white text-xs sm:text-sm font-semibold shadow-xs transition-all"
>
<span>ذخیره پیشنهاد</span>
<Check className="h-4 w-4" />
@@ -556,21 +540,21 @@ export function WizardPage() {
</div>
</div>
{/* دیالوگ ذخیره با فیلدهای خالی */}
{/* دیالوگ فیلدهای خالی */}
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>فیلدهای خالی</DialogTitle>
</DialogHeader>
<div className="text-[13.5px] leading-7">
هنوز تعدادی از فیلد ها خالی هستند. آیا مطمئنی که می خواهید تغییرات را ذخیره کنید؟
<div className="text-[13.5px] leading-7 text-gray-700">
هنوز تعدادی از فیلدها خالی هستند. آیا مطمئنید که می‌خواهید ادامه دهید؟
</div>
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[13px]">
{emptyFields.map((f) => (
<button
key={f.step}
type="button"
className="cursor-pointer text-accent underline underline-offset-2 hover:text-accent-dark"
className="cursor-pointer text-blue-600 underline underline-offset-2 hover:text-blue-800"
onClick={() => {
setConfirmOpen(false);
goToStep(f.step);
+3 -2
View File
@@ -1,10 +1,11 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"target": "ESNext",
"lib": ["ESNext", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"strict": true,
"allowArbitraryExtensions": true,
"skipLibCheck": true,
View File