Compare commits
15
Commits
main
...
4e59f6e25c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e59f6e25c | ||
|
|
593c149478 | ||
|
|
e7f465f74f | ||
|
|
2573f1e583 | ||
|
|
a542e4177e | ||
|
|
c454b03b4d | ||
|
|
50f8d8e64e | ||
|
|
3b032d0460 | ||
|
|
f263cfb9e3 | ||
|
|
4609940856 | ||
|
|
3c3880aec9 | ||
|
|
545b5dd473 | ||
|
|
7528cc3bbe | ||
|
|
b45a6cd033 | ||
|
|
f0e424316e |
@@ -19,6 +19,9 @@ frontend/.env
|
||||
v1.2 - Copy/
|
||||
inv dash/
|
||||
|
||||
# ---- Machine-local compose overrides (LAN phone access, etc.) ----
|
||||
docker-compose.override.yml
|
||||
|
||||
# ---- Screenshots (root) ----
|
||||
*.png
|
||||
*.jpg
|
||||
|
||||
@@ -81,7 +81,7 @@ v1.3/
|
||||
## 🔐 Login
|
||||
|
||||
The whole system is behind a login page (`/login`). Login is **in-page**:
|
||||
the form (username / password / optional organization) is sent to
|
||||
the form (username / password) is sent to
|
||||
`POST /api/v1/auth/login`; the backend exchanges it with Casdoor
|
||||
(password grant), verifies the returned JWT, and starts the session —
|
||||
no browser redirect to Casdoor.
|
||||
@@ -113,6 +113,71 @@ Casdoor organization, application and users (idempotent). Full details —
|
||||
addresses, credentials, role model, failure scenarios and the checklist —
|
||||
are in [`docs/casdoor-auth.md`](docs/casdoor-auth.md).
|
||||
|
||||
> The **currently committed seed** (`backups/علی محقق/`) instead ships a
|
||||
> single admin — `ali.mohaghegh` («علی محقق») — see
|
||||
> [Restoring the committed seed data](#restoring-the-committed-seed-data).
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Working with the deployed system (server)
|
||||
|
||||
The production deployment of this stack runs on the server and is reachable
|
||||
at these public addresses (HTTPS):
|
||||
|
||||
| What | Address |
|
||||
|---|---|
|
||||
| **App (ERP)** | https://aramesh.bennu-api-bahrestan.ir/ |
|
||||
| **Casdoor admin panel** | https://casdoor.bennu-api-bahrestan.ir/ |
|
||||
|
||||
Log in to the app with your Casdoor account (org `inventory`); the role is
|
||||
assigned inside the app (`users.role` — see «Login» above and
|
||||
[`docs/casdoor-auth.md`](docs/casdoor-auth.md)).
|
||||
|
||||
### MinIO console on the server (SSH tunnel)
|
||||
|
||||
The MinIO console is deliberately **loopback-only** on the server
|
||||
(`127.0.0.1:9001`) — verified unreachable from the internet — so the way in
|
||||
is an SSH tunnel from your machine:
|
||||
|
||||
1. Open PowerShell and start the tunnel (**keep the window open** while you
|
||||
use the panel):
|
||||
|
||||
```powershell
|
||||
ssh -L 9001:127.0.0.1:9001 root@171.22.25.75
|
||||
```
|
||||
|
||||
You will also land in a normal SSH session — that is fine; the tunnel
|
||||
lives as long as the window stays open.
|
||||
|
||||
2. In your browser, open: **http://localhost:9001**
|
||||
|
||||
3. Log in with:
|
||||
|
||||
| Username | Password |
|
||||
|---|---|
|
||||
| `aramesh-minio` | `feb10fadb5441019dfc33bf12a9b0f74e7b6c478` |
|
||||
|
||||
You will see the **`procurement-files`** bucket — the invoices/proformas the
|
||||
ERP stores, browsable and downloadable from there.
|
||||
|
||||
⚠️ These credentials are **full admin access to the file storage**. Treat
|
||||
them as a production secret (this README is inside the repo, so anyone with
|
||||
repo access can read them — rotate them on the server if they ever leak).
|
||||
|
||||
Close the PowerShell window (or `Ctrl+C`) when you are done to drop the
|
||||
tunnel.
|
||||
|
||||
Notes from the server setup:
|
||||
|
||||
- The panel is published only on the server's loopback, so the internet
|
||||
cannot reach it at all — the tunnel is what carries it to your browser.
|
||||
- Host port `9000` stays reserved for the deploy-webhook receiver, which is
|
||||
why the console uses `9001`.
|
||||
- If you ever need the raw S3 API from your machine, or a clickable URL for
|
||||
the console (e.g. `storage.aramesh.bennu-api-bahrestan.ir` behind Traefik
|
||||
with a password prompt on top), that can be arranged on request — the
|
||||
tunnel remains the safer default.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites (target machine)
|
||||
@@ -197,26 +262,35 @@ docker exec -it procurement-postgres psql -U postgres -d procurement
|
||||
|
||||
---
|
||||
|
||||
## Restoring the demo data (for the team)
|
||||
## Restoring the committed seed data
|
||||
|
||||
The databases are **not** in git — Docker volumes hold the data. To let
|
||||
everyone see the same system state, the repo ships exact database dumps in
|
||||
`backups/`. After a fresh boot:
|
||||
The databases are **not** in git — Docker volumes hold the data. The repo
|
||||
ships recovery dumps in `backups/علی محقق/`:
|
||||
|
||||
| File | Contents |
|
||||
|---|---|
|
||||
| `casdoor.dump` | Casdoor schema + console admin (`admin` / `123`), org `inventory`, app `inventory-app`, the JWT signing cert, and the single org user `ali.mohaghegh` |
|
||||
| `procurement.dump` | Procurement schema + the single `users` row «علی محقق» with role `admin`; all other tables empty (categories/units re-seed at API startup) |
|
||||
|
||||
⚠️ This **replaces all data** in the `procurement` and `casdoor` databases.
|
||||
On a running stack:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\backups\restore.ps1 # Windows
|
||||
bash backups/restore.sh # Linux / Git Bash
|
||||
docker compose stop api casdoor
|
||||
docker cp "backups/علی محقق/procurement.dump" procurement-postgres:/tmp/
|
||||
docker cp "backups/علی محقق/casdoor.dump" procurement-postgres:/tmp/
|
||||
docker exec procurement-postgres pg_restore -U postgres -d procurement --clean --if-exists /tmp/procurement.dump
|
||||
docker exec procurement-postgres pg_restore -U postgres -d casdoor --clean --if-exists /tmp/casdoor.dump
|
||||
docker compose start casdoor api
|
||||
```
|
||||
|
||||
This **replaces all data** in the `procurement` and `casdoor` databases with
|
||||
the committed state — a clean, realistic demo: 3 projects × 2 warehouses,
|
||||
4 suppliers, 12 materials across all 8 categories, and 4 quotes covering
|
||||
every lifecycle state (offered / in-transit / partially received /
|
||||
completed) with matching stock.
|
||||
Login afterwards: `ali.mohaghegh` / `hulkfuck002` (Casdoor rejects spaces
|
||||
in usernames, so «ali mohaghegh» is not a valid login name).
|
||||
|
||||
Two alternative ways to get a clean state:
|
||||
Want demo data instead? Two seeders are still available (they create
|
||||
different users — see the Login table above):
|
||||
|
||||
1. **App seeder** (realistic demo data, keeps the Casdoor org as-is):
|
||||
1. **App seeder** (realistic demo dataset; keeps the Casdoor org as-is):
|
||||
```powershell
|
||||
docker exec procurement-api sh -c "cd /srv/backend && python -m tools.seed"
|
||||
```
|
||||
|
||||
@@ -31,7 +31,6 @@ router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
organization: str | None = None # optional override; defaults to CASDOOR_ORGANIZATION
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
|
||||
Binary file not shown.
@@ -1,44 +0,0 @@
|
||||
# ============================================================
|
||||
# Restore the committed demo-state dumps (backups/*.dump) into
|
||||
# the running stack. REPLACES all data in the `procurement` and
|
||||
# `casdoor` databases — run it on a fresh boot or when you want
|
||||
# to reset to the exact committed state.
|
||||
#
|
||||
# powershell -ExecutionPolicy Bypass -File .\backups\restore.ps1
|
||||
# ============================================================
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
Set-Location (Join-Path $here "..")
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Restoring demo data — this REPLACES the current DB content!" -ForegroundColor Yellow
|
||||
|
||||
docker compose ps postgres | Select-String "healthy" | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "postgres is not healthy — run 'docker compose up -d' first." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Stopping api + casdoor (they hold DB connections)..." -ForegroundColor Cyan
|
||||
docker compose stop api casdoor
|
||||
if ($LASTEXITCODE -ne 0) { exit 1 }
|
||||
|
||||
Write-Host "Restoring procurement DB..." -ForegroundColor Cyan
|
||||
docker cp "backups\procurement.dump" procurement-postgres:/tmp/procurement.dump
|
||||
docker exec procurement-postgres pg_restore -U postgres -d procurement --clean --if-exists --single-transaction /tmp/procurement.dump
|
||||
if ($LASTEXITCODE -ne 0) { Write-Host "procurement restore FAILED" -ForegroundColor Red; exit 1 }
|
||||
|
||||
Write-Host "Restoring casdoor DB..." -ForegroundColor Cyan
|
||||
docker cp "backups\casdoor.dump" procurement-postgres:/tmp/casdoor.dump
|
||||
docker exec procurement-postgres pg_restore -U postgres -d casdoor --clean --if-exists --single-transaction /tmp/casdoor.dump
|
||||
if ($LASTEXITCODE -ne 0) { Write-Host "casdoor restore FAILED" -ForegroundColor Red; exit 1 }
|
||||
|
||||
Write-Host "Starting services..." -ForegroundColor Cyan
|
||||
docker compose start casdoor api
|
||||
if ($LASTEXITCODE -ne 0) { exit 1 }
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Restore complete." -ForegroundColor Green
|
||||
Write-Host "App : http://localhost" -ForegroundColor Green
|
||||
Write-Host "Casdoor : http://localhost:8080 (admin / 123)" -ForegroundColor Green
|
||||
@@ -1,27 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Restore the committed demo-state dumps (backups/*.dump) into the running
|
||||
# stack (Linux/macOS/Git Bash). REPLACES all data in the `procurement` and
|
||||
# `casdoor` databases.
|
||||
#
|
||||
# bash backups/restore.sh
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
echo "Stopping api + casdoor (they hold DB connections)..."
|
||||
docker compose stop api casdoor
|
||||
|
||||
echo "Restoring procurement DB..."
|
||||
docker cp backups/procurement.dump procurement-postgres:/tmp/procurement.dump
|
||||
docker exec procurement-postgres pg_restore -U postgres -d procurement --clean --if-exists --single-transaction /tmp/procurement.dump
|
||||
|
||||
echo "Restoring casdoor DB..."
|
||||
docker cp backups/casdoor.dump procurement-postgres:/tmp/casdoor.dump
|
||||
docker exec procurement-postgres pg_restore -U postgres -d casdoor --clean --if-exists --single-transaction /tmp/casdoor.dump
|
||||
|
||||
echo "Starting services..."
|
||||
docker compose start casdoor api
|
||||
|
||||
echo ""
|
||||
echo "Restore complete."
|
||||
echo "App : http://localhost"
|
||||
echo "Casdoor : http://localhost:8080 (admin / 123)"
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,235 @@
|
||||
services:
|
||||
api:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: procurement-api
|
||||
depends_on:
|
||||
minio-init:
|
||||
condition: service_completed_successfully
|
||||
required: true
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
required: true
|
||||
environment:
|
||||
PROCUREMENT_CASDOOR_ADMIN_PASSWORD: "123"
|
||||
PROCUREMENT_CASDOOR_ADMIN_USER: admin
|
||||
PROCUREMENT_CASDOOR_APP_NAME: inventory-app
|
||||
PROCUREMENT_CASDOOR_AUDIENCE: inventory-app-client
|
||||
PROCUREMENT_CASDOOR_CLIENT_ID: inventory-app-client
|
||||
PROCUREMENT_CASDOOR_CLIENT_SECRET: inventory-app-secret-2026
|
||||
PROCUREMENT_CASDOOR_ENDPOINT: http://casdoor:8000
|
||||
PROCUREMENT_CASDOOR_ISSUER: https://${AUTH_HOST}
|
||||
PROCUREMENT_CASDOOR_ORGANIZATION: inventory
|
||||
PROCUREMENT_DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/procurement
|
||||
PROCUREMENT_MINIO_ACCESS_KEY: minioadmin
|
||||
PROCUREMENT_MINIO_BUCKET: procurement-files
|
||||
PROCUREMENT_MINIO_ENDPOINT: minio:9000
|
||||
PROCUREMENT_MINIO_SECRET_KEY: minioadmin
|
||||
PROCUREMENT_MINIO_SECURE: "false"
|
||||
labels:
|
||||
traefik.enable: "true"
|
||||
traefik.http.routers.api-redir.entrypoints: web
|
||||
traefik.http.routers.api-redir.middlewares: redirect-https@file
|
||||
traefik.http.routers.api-redir.rule: Host(`${API_HOST}`)
|
||||
traefik.http.routers.api.entrypoints: websecure
|
||||
traefik.http.routers.api.rule: Host(`${API_HOST}`)
|
||||
traefik.http.routers.api.tls: "true"
|
||||
traefik.http.routers.api.tls.certresolver: letsencrypt
|
||||
traefik.http.services.api.loadbalancer.server.port: "8000"
|
||||
networks:
|
||||
default: null
|
||||
traefik_network: null
|
||||
ports:
|
||||
- "8000"
|
||||
restart: unless-stopped
|
||||
|
||||
casdoor:
|
||||
container_name: procurement-casdoor
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
required: true
|
||||
environment:
|
||||
RUNNING_IN_DOCKER: "true"
|
||||
dataSourceName: "user=postgres password=postgres host=postgres port=5432 sslmode=disable dbname=casdoor"
|
||||
dbName: casdoor
|
||||
driverName: postgres
|
||||
origin: https://${AUTH_HOST}
|
||||
image: casbin/casdoor:latest
|
||||
labels:
|
||||
traefik.enable: "true"
|
||||
traefik.http.routers.casdoor-redir.entrypoints: web
|
||||
traefik.http.routers.casdoor-redir.middlewares: redirect-https@file
|
||||
traefik.http.routers.casdoor-redir.rule: Host(`${AUTH_HOST}`)
|
||||
traefik.http.routers.casdoor.entrypoints: websecure
|
||||
traefik.http.routers.casdoor.rule: Host(`${AUTH_HOST}`)
|
||||
traefik.http.routers.casdoor.tls: "true"
|
||||
traefik.http.routers.casdoor.tls.certresolver: letsencrypt
|
||||
traefik.http.services.casdoor.loadbalancer.server.port: "8000"
|
||||
networks:
|
||||
default: null
|
||||
traefik_network: null
|
||||
ports:
|
||||
- "8081:8000"
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
DEV_LOGIN_ENABLED: "true"
|
||||
container_name: procurement-frontend
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_started
|
||||
required: true
|
||||
labels:
|
||||
traefik.enable: "true"
|
||||
traefik.http.routers.frontend-redir.entrypoints: web
|
||||
traefik.http.routers.frontend-redir.middlewares: redirect-https@file
|
||||
traefik.http.routers.frontend-redir.rule: Host(`${APP_HOST}`)
|
||||
traefik.http.routers.frontend.entrypoints: websecure
|
||||
traefik.http.routers.frontend.rule: Host(`${APP_HOST}`)
|
||||
traefik.http.routers.frontend.tls: "true"
|
||||
traefik.http.routers.frontend.tls.certresolver: letsencrypt
|
||||
traefik.http.services.frontend.loadbalancer.server.port: "80"
|
||||
networks:
|
||||
default: null
|
||||
traefik_network: null
|
||||
restart: unless-stopped
|
||||
|
||||
minio:
|
||||
command:
|
||||
- server
|
||||
- /data
|
||||
- --console-address
|
||||
- :9001
|
||||
container_name: procurement-minio
|
||||
environment:
|
||||
MINIO_ROOT_PASSWORD: minioadmin
|
||||
MINIO_ROOT_USER: minioadmin
|
||||
image: minio/minio:latest
|
||||
labels:
|
||||
traefik.enable: "true"
|
||||
traefik.http.routers.minio-api-redir.entrypoints: web
|
||||
traefik.http.routers.minio-api-redir.middlewares: redirect-https@file
|
||||
traefik.http.routers.minio-api-redir.rule: Host(`${S3_HOST}`)
|
||||
traefik.http.routers.minio-api.entrypoints: websecure
|
||||
traefik.http.routers.minio-api.rule: Host(`${S3_HOST}`)
|
||||
traefik.http.routers.minio-api.service: minio-api-svc
|
||||
traefik.http.routers.minio-api.tls: "true"
|
||||
traefik.http.routers.minio-api.tls.certresolver: letsencrypt
|
||||
traefik.http.routers.minio-console-redir.entrypoints: web
|
||||
traefik.http.routers.minio-console-redir.middlewares: redirect-https@file
|
||||
traefik.http.routers.minio-console-redir.rule: Host(`${STORAGE_HOST}`)
|
||||
traefik.http.routers.minio-console.entrypoints: websecure
|
||||
traefik.http.routers.minio-console.rule: Host(`${STORAGE_HOST}`)
|
||||
traefik.http.routers.minio-console.service: minio-console-svc
|
||||
traefik.http.routers.minio-console.tls: "true"
|
||||
traefik.http.routers.minio-console.tls.certresolver: letsencrypt
|
||||
traefik.http.services.minio-api-svc.loadbalancer.server.port: "9000"
|
||||
traefik.http.services.minio-console-svc.loadbalancer.server.port: "9001"
|
||||
networks:
|
||||
default: null
|
||||
traefik_network: null
|
||||
ports:
|
||||
- "9000"
|
||||
- "9001"
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
|
||||
minio-init:
|
||||
container_name: procurement-minio-init
|
||||
depends_on:
|
||||
minio:
|
||||
condition: service_started
|
||||
required: true
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
until mc alias set local http://$${MINIO_ENDPOINT} $${MINIO_ROOT_USER} $${MINIO_ROOT_PASSWORD}; do
|
||||
echo 'waiting for minio server...';
|
||||
sleep 2;
|
||||
done;
|
||||
mc mb --ignore-existing local/$${MINIO_BUCKET};
|
||||
echo bucket $${MINIO_BUCKET} ready;
|
||||
environment:
|
||||
MINIO_BUCKET: procurement-files
|
||||
MINIO_ENDPOINT: minio:9000
|
||||
MINIO_ROOT_PASSWORD: minioadmin
|
||||
MINIO_ROOT_USER: minioadmin
|
||||
image: minio/mc:latest
|
||||
networks:
|
||||
default: null
|
||||
restart: 'no'
|
||||
|
||||
postgres:
|
||||
container_name: procurement-postgres
|
||||
environment:
|
||||
POSTGRES_DB: procurement
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_USER: postgres
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- pg_isready -U postgres -d procurement
|
||||
timeout: 5s
|
||||
interval: 5s
|
||||
retries: 10
|
||||
image: postgres:18
|
||||
networks:
|
||||
default: null
|
||||
ports:
|
||||
- "5433:5432"
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql
|
||||
- ./sql/migration.sql:/docker-entrypoint-initdb.d/01-migration.sql:ro
|
||||
- ./sql/02-casdoor-db.sql:/docker-entrypoint-initdb.d/02-casdoor-db.sql:ro
|
||||
|
||||
traefik:
|
||||
command:
|
||||
- --providers.docker=true
|
||||
- --providers.docker.exposedbydefault=false
|
||||
- --providers.docker.network=traefik_network
|
||||
- --providers.file.filename=/etc/traefik/dynamic.yml
|
||||
- --entrypoints.web.address=:80
|
||||
- --entrypoints.websecure.address=:443
|
||||
- --certificatesresolvers.letsencrypt.acme.email=${TRAEFIK_ACME_EMAIL}
|
||||
- --certificatesresolvers.letsencrypt.acme.storage=/certs/acme.json
|
||||
- --certificatesresolvers.letsencrypt.acme.httpchallenge=true
|
||||
- --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web
|
||||
- --log.level=INFO
|
||||
container_name: procurement-traefik
|
||||
environment:
|
||||
TRAEFIK_ACME_EMAIL: ${TRAEFIK_ACME_EMAIL}
|
||||
image: traefik:v3.6
|
||||
networks:
|
||||
traefik_network: null
|
||||
ports:
|
||||
- "80"
|
||||
- "443"
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./infra/traefik-dynamic.yml:/etc/traefik/dynamic.yml:ro
|
||||
- traefik_certs:/certs
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: v15_default
|
||||
traefik_network:
|
||||
name: traefik_network
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
minio_data:
|
||||
name: v15_minio_data
|
||||
postgres_data:
|
||||
name: v15_postgres_data
|
||||
traefik_certs:
|
||||
name: v15_traefik_certs
|
||||
@@ -35,7 +35,7 @@ to re-run).
|
||||
## 2. Login flow (in-page, no redirect)
|
||||
|
||||
```
|
||||
Browser ──► POST /api/v1/auth/login {username, password, organization?}
|
||||
Browser ──► POST /api/v1/auth/login {username, password}
|
||||
│
|
||||
▼
|
||||
Backend ──► POST http://casdoor:8000/api/login/oauth/access_token
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { useAuthStore } from "./store/auth";
|
||||
import { MobileTabBar } from "./components/layout/MobileTabBar";
|
||||
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||
|
||||
// هر صفحه با React.lazy لود میشود (code splitting)
|
||||
@@ -197,7 +196,6 @@ export default function App() {
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
<MobileTabBar />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,19 +8,17 @@ export interface TermsheetItem {
|
||||
}
|
||||
|
||||
export interface TermsheetCardProps {
|
||||
index: number; // 1-based → «گزینه N»
|
||||
index: number;
|
||||
milestones: Milestone[];
|
||||
onMilestonesChange: (ms: Milestone[]) => void;
|
||||
// اقلام قابل تیک (مرحله ۲)
|
||||
items?: TermsheetItem[];
|
||||
coveredIds?: string[]; // tempIdهایی که تیک دارند
|
||||
coveredIds?: string[];
|
||||
onCoverageChange?: (ids: string[]) => void;
|
||||
onRemove?: () => void;
|
||||
defaultAll?: boolean; // وقتی هیچ تیکی نیست، پیشفرض همه تیک بخورند
|
||||
defaultAll?: boolean;
|
||||
removable?: boolean;
|
||||
}
|
||||
|
||||
// کارت ترمشیت — هم در ویزارد (مرحله ۳) هم در مودال پذیرش/ویرایش
|
||||
export function TermsheetCard({
|
||||
index,
|
||||
milestones,
|
||||
@@ -33,7 +31,6 @@ export function TermsheetCard({
|
||||
removable = true,
|
||||
}: TermsheetCardProps) {
|
||||
const covered = new Set(coveredIds);
|
||||
// پیشفرض: وقتی هیچ قلمی تیک نخورده و defaultAll فعال است → همه تیک
|
||||
const effectiveCovered = covered.size === 0 && defaultAll ? items.map((i) => i.tempId) : coveredIds;
|
||||
|
||||
const toggle = (tempId: string, checked: boolean) => {
|
||||
@@ -49,17 +46,25 @@ export function TermsheetCard({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="variant-card">
|
||||
<div className="variant-head">
|
||||
<span className="variant-num">گزینه {faNum(index)}</span>
|
||||
<div className="rounded-xl border border-line bg-panel p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-bold text-[13px]">گزینه {faNum(index)}</span>
|
||||
{removable && onRemove && (
|
||||
<button type="button" className="variant-x" title="حذف" onClick={onRemove}>
|
||||
×
|
||||
<button
|
||||
type="button"
|
||||
className="text-gray-400 hover:text-red-500 p-1 rounded transition"
|
||||
title="حذف گزینه"
|
||||
onClick={onRemove}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-1.5 text-[12px] text-muted">مراحل پرداخت:</div>
|
||||
<div className="mb-1 flex flex-col gap-1.5">
|
||||
<div className="mb-2 flex flex-col gap-1.5">
|
||||
{milestones.map((m, i) => (
|
||||
<MilestoneRow
|
||||
key={i}
|
||||
@@ -69,33 +74,33 @@ export function TermsheetCard({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" className="btn-line-file mt-1" onClick={addMilestone}>
|
||||
+ مرحله پرداخت
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="w-full py-1.5 border border-dashed border-gray-300 hover:border-blue-500 hover:text-blue-600 rounded-lg text-[12px] text-gray-500 transition mb-3"
|
||||
onClick={addMilestone}
|
||||
>
|
||||
+ مرحله پرداخت
|
||||
</button>
|
||||
|
||||
{items.length > 0 && (
|
||||
<div className="ts-items-box">
|
||||
<div className="ts-items-title">این termsheet کدام اقلام (مرحله ۲) را پوشش میدهد؟</div>
|
||||
{items.length === 0 ? (
|
||||
<div className="ts-items-empty">هنوز قلمی در مرحلهی ۲ اضافه نشده.</div>
|
||||
) : (
|
||||
<div>
|
||||
{items.map((it) => (
|
||||
<label key={it.tempId} className="ts-item-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="ts-item-cb"
|
||||
data-item-temp-id={it.tempId}
|
||||
checked={effectiveCovered.includes(it.tempId)}
|
||||
onChange={(e) => toggle(it.tempId, e.target.checked)}
|
||||
/>
|
||||
{it.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="pt-2 border-t border-line/60">
|
||||
<div className="text-[12px] text-muted mb-1.5">این termsheet کدام اقلام (مرحله ۲) را پوشش میدهد؟</div>
|
||||
<div className="flex flex-col gap-1 text-[12px]">
|
||||
{items.map((it) => (
|
||||
<label key={it.tempId} className="flex items-center gap-2 cursor-pointer text-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-gray-300"
|
||||
checked={effectiveCovered.includes(it.tempId)}
|
||||
onChange={(e) => toggle(it.tempId, e.target.checked)}
|
||||
/>
|
||||
<span>{it.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,36 +5,65 @@ import { faNum } from "../lib/formatters";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
export interface TopicGridProps {
|
||||
topics: Topic[]; // از store (API) میآید
|
||||
topics: Topic[];
|
||||
selectedTopic?: string;
|
||||
onSelect: (topicName: string) => void;
|
||||
showCounts?: boolean; // تعداد تأمینکنندههای هر تاپیک
|
||||
counts?: Record<string, number>; // topic name → count
|
||||
showCounts?: boolean;
|
||||
counts?: Record<string, number>;
|
||||
}
|
||||
|
||||
// گرید تاپیکها (از /material-categories — نه لیست ثابت)
|
||||
export function TopicGrid({ topics, selectedTopic, onSelect, showCounts = false, counts }: TopicGridProps) {
|
||||
if (!topics.length) {
|
||||
return <EmptyState emoji="🗂️" text="در حال بارگذاری دستهبندیها…" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
<div className="grid grid-cols-2 gap-3 sm:gap-4 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{topics.map((t) => {
|
||||
const selected = selectedTopic != null && normalize(selectedTopic) === normalize(t.name);
|
||||
const count = counts?.[t.name] ?? 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={t.key}
|
||||
className={cn("topic-card", selected && "selected")}
|
||||
onClick={() => onSelect(t.name)}
|
||||
className={cn(
|
||||
"group relative flex flex-col items-center justify-between rounded-2xl border bg-white p-2.5 sm:p-4 text-center transition-all duration-200 cursor-pointer shadow-xs active:scale-[0.98] overflow-hidden",
|
||||
selected
|
||||
? "border-blue-600 ring-2 ring-blue-600/25 shadow-md bg-blue-50/20"
|
||||
: "border-slate-200 hover:border-blue-300 hover:shadow-md"
|
||||
)}
|
||||
>
|
||||
<img src={t.icon} alt="" />
|
||||
<div className="t-name">{t.name}</div>
|
||||
{showCounts && <div className="t-count">{faNum(count)} تأمینکننده</div>}
|
||||
{/* کادر تصویر بسیار بزرگتر شده برای پر کردن تمام فضای مربع */}
|
||||
<div className="flex h-36 sm:h-48 md:h-52 w-full items-center justify-center overflow-hidden rounded-xl bg-slate-50/50">
|
||||
<img
|
||||
src={t.icon}
|
||||
alt={t.name}
|
||||
className="h-full w-full object-contain transform scale-115 group-hover:scale-125 transition-transform duration-300 drop-shadow-xs"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* بخش عنوان و تعداد */}
|
||||
<div className="mt-3 w-full pb-1">
|
||||
<div
|
||||
className={cn(
|
||||
"truncate text-[13px] sm:text-[15px] font-bold transition-colors",
|
||||
selected ? "text-blue-600" : "text-slate-800 group-hover:text-blue-600"
|
||||
)}
|
||||
>
|
||||
{t.name}
|
||||
</div>
|
||||
{showCounts && (
|
||||
<div className="mt-0.5 truncate text-[11px] sm:text-xs text-slate-400 font-medium">
|
||||
{faNum(count)} تأمینکننده
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,6 @@ export function PageLayout({
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("mx-auto w-full max-w-[1200px] px-4 py-4 max-md:pb-24", className)}>{children}</div>
|
||||
<div className={cn("mx-auto w-full max-w-[1200px] px-4 py-4", className)}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { NavLink, useLocation } from "react-router-dom";
|
||||
import {
|
||||
ArrowLeftRight,
|
||||
Boxes,
|
||||
Building2,
|
||||
CalendarDays,
|
||||
LayoutDashboard,
|
||||
PackageSearch,
|
||||
Scale,
|
||||
Truck,
|
||||
UsersRound,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
|
||||
interface TabDef {
|
||||
to: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
end?: boolean;
|
||||
}
|
||||
|
||||
// تبهای دفتر تدارکات (ادمین / مدیر تدارکات)
|
||||
const PROC_TABS: TabDef[] = [
|
||||
{ to: "/", label: "داشبورد", icon: LayoutDashboard, end: true },
|
||||
{ to: "/suppliers", label: "تأمینکنندهها", icon: Truck },
|
||||
{ to: "/projects", label: "پروژهها", icon: Building2 },
|
||||
{ to: "/units", label: "ضرایب", icon: Scale },
|
||||
];
|
||||
|
||||
// تبهای پنل انبار برای مدیر پروژه
|
||||
const PM_INV_TABS: TabDef[] = [
|
||||
{ to: "/inventory", label: "انبار", icon: Boxes, end: true },
|
||||
{ to: "/inventory/change", label: "تغییر موجودی", icon: ArrowLeftRight },
|
||||
{ to: "/inventory/daily", label: "کنترل روزانه", icon: CalendarDays },
|
||||
{ to: "/inventory/arrivals", label: "کالاهای در راه", icon: PackageSearch },
|
||||
];
|
||||
|
||||
// تبهای پنل انبار برای ادمین / مدیر تدارکات (آینهی ناوبری بالای دسکتاپ)
|
||||
const ADMIN_INV_TABS: TabDef[] = [
|
||||
{ to: "/inventory", label: "انبار", icon: Boxes, end: true },
|
||||
{ to: "/inventory/daily", label: "کنترل روزانه", icon: CalendarDays },
|
||||
{ to: "/inventory/arrivals", label: "کالاهای در راه", icon: PackageSearch },
|
||||
{ to: "/inventory/org", label: "سازماندهی", icon: UsersRound },
|
||||
];
|
||||
|
||||
function TabBar({ tabs }: { tabs: TabDef[] }) {
|
||||
return (
|
||||
<nav
|
||||
className="fixed inset-x-0 bottom-0 z-40 border-t border-line bg-panel md:hidden"
|
||||
style={{ paddingBottom: "env(safe-area-inset-bottom)" }}
|
||||
>
|
||||
<div className="grid grid-cols-4">
|
||||
{tabs.map((t) => (
|
||||
<NavLink
|
||||
key={t.to}
|
||||
to={t.to}
|
||||
end={t.end}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"flex min-h-[56px] flex-col items-center justify-center gap-0.5 whitespace-nowrap px-1 text-[10.5px] font-medium transition-colors",
|
||||
isActive ? "text-accent" : "text-muted",
|
||||
)
|
||||
}
|
||||
>
|
||||
<t.icon className="h-5 w-5" strokeWidth={1.8} />
|
||||
<span>{t.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
// نوار تب پایینِ موبایل — فقط زیر 768px (md:hidden) و فقط در صفحاتِ نقش کاربر:
|
||||
// مدیر پروژه → تبهای انبار؛ ادمین/مدیر تدارکات → تبهای دفتر یا انبار (بر اساس مسیر).
|
||||
// در ویزارد نمایش داده نمیشود تا با جابهجایی تصادفی، کارِ کاربر از بین نرود.
|
||||
export function MobileTabBar() {
|
||||
const role = useAuthStore((s) => s.user?.role);
|
||||
const { pathname } = useLocation();
|
||||
|
||||
if (pathname === "/login" || pathname === "/admin") return null;
|
||||
const isPM = role === "pm";
|
||||
const isOffice = role === "admin" || role === "procurement_manager";
|
||||
|
||||
if (pathname.startsWith("/inventory")) {
|
||||
if (isPM) return <TabBar tabs={PM_INV_TABS} />;
|
||||
if (isOffice) return <TabBar tabs={ADMIN_INV_TABS} />;
|
||||
return null;
|
||||
}
|
||||
if (pathname === "/wizard") return null;
|
||||
const onOfficePage =
|
||||
pathname === "/" ||
|
||||
pathname === "/projects" ||
|
||||
pathname === "/units" ||
|
||||
pathname.startsWith("/suppliers");
|
||||
if (onOfficePage && isOffice) return <TabBar tabs={PROC_TABS} />;
|
||||
return null;
|
||||
}
|
||||
@@ -4,17 +4,17 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:pointer-events-none disabled:opacity-50 active:scale-[0.98] [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-accent text-white shadow-sm hover:bg-accent-dark",
|
||||
primary: "bg-accent text-white shadow-sm hover:bg-accent-dark",
|
||||
slate: "bg-slate-bg text-ink hover:bg-line",
|
||||
line: "border border-line bg-panel text-ink hover:bg-slate-bg",
|
||||
slate: "bg-slate-bg text-ink border border-transparent hover:brightness-95",
|
||||
line: "border border-line bg-panel text-ink shadow-sm hover:bg-slate-bg",
|
||||
ghost: "hover:bg-slate-bg text-ink",
|
||||
green: "bg-green text-white hover:brightness-95",
|
||||
danger: "bg-red-bg text-red hover:bg-red hover:text-white",
|
||||
green: "bg-green text-white shadow-sm hover:brightness-95",
|
||||
danger: "bg-red-bg text-red shadow-sm hover:bg-red hover:text-white",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
@@ -44,4 +44,4 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
export { Button, buttonVariants };
|
||||
@@ -1460,7 +1460,6 @@ export const apiV26 = {
|
||||
login: async (body: {
|
||||
username: string;
|
||||
password: string;
|
||||
organization?: string;
|
||||
}): Promise<{ access_token: string; expires_in: number | null; user: import("../store/auth").AuthUser }> => {
|
||||
const res = await request<{
|
||||
data: {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { ClipboardList, Loader2, Lock, ShieldCheck, Wrench, X } from "lucide-react";
|
||||
import { ClipboardList, Eye, EyeOff, Loader2, Lock, ShieldCheck, Wrench, X } from "lucide-react";
|
||||
import { api, apiV26 } from "../../lib/api";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import type { Project } from "../../lib/types";
|
||||
|
||||
// ============================================================
|
||||
// ورود به سامانه — Casdoor درونصفحهای (بدون ریدایرکت)
|
||||
// فرم اصلی: Username / Password / Organization (اختیاری) →
|
||||
// فرم اصلی: Username / Password →
|
||||
// POST /api/v1/auth/login (بکاند با grant_type=password از
|
||||
// Casdoor توکن میگیرد و نقش را از جدول users خودمان برمیگرداند)
|
||||
// حالت توسعه (فقط ادمین — سوپر یوزر) — چرخهی ۳ مرحلهای:
|
||||
@@ -36,7 +36,7 @@ export function LoginPage() {
|
||||
// ---- فرم اصلی (Casdoor) ----
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [organization, setOrganization] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// ---- پنل حالت توسعه (۳ مرحله) ----
|
||||
@@ -71,7 +71,6 @@ export function LoginPage() {
|
||||
const { access_token, user } = await apiV26.auth.login({
|
||||
username: username.trim(),
|
||||
password,
|
||||
...(organization.trim() ? { organization: organization.trim() } : {}),
|
||||
});
|
||||
if (!user.role) {
|
||||
toast.error("نقش شما هنوز در سیستم تعیین نشده است — با مدیر سیستم تماس بگیرید");
|
||||
@@ -101,7 +100,6 @@ export function LoginPage() {
|
||||
const { user: adminUser } = await apiV26.auth.login({
|
||||
username: devAdminUser.trim(),
|
||||
password: devAdminPass,
|
||||
...(organization.trim() ? { organization: organization.trim() } : {}),
|
||||
});
|
||||
if (adminUser.role !== "admin") {
|
||||
toast.error("حالت توسعه فقط برای ادمین (سوپر یوزر) در دسترس است");
|
||||
@@ -298,30 +296,33 @@ export function LoginPage() {
|
||||
<label className="mb-1 block text-[12px] font-semibold text-muted" htmlFor="login-password">
|
||||
رمز عبور
|
||||
</label>
|
||||
<input
|
||||
id="login-password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
dir="ltr"
|
||||
className="h-10 w-full rounded-lg border border-line bg-panel px-3 text-left text-sm shadow-sm placeholder:text-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
placeholder="••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="mb-1 block text-[12px] font-semibold text-muted" htmlFor="login-org">
|
||||
سازمان (اختیاری)
|
||||
</label>
|
||||
<input
|
||||
id="login-org"
|
||||
type="text"
|
||||
dir="ltr"
|
||||
className="h-10 w-full rounded-lg border border-line bg-panel px-3 text-left text-sm shadow-sm placeholder:text-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
placeholder="inventory"
|
||||
value={organization}
|
||||
onChange={(e) => setOrganization(e.target.value)}
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="login-password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="current-password"
|
||||
dir="ltr"
|
||||
className="h-10 w-full rounded-lg border border-line bg-panel px-3 pr-10 text-left text-sm shadow-sm placeholder:text-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
placeholder="••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
aria-pressed={showPassword}
|
||||
aria-controls="login-password"
|
||||
title={showPassword ? "پنهان کردن رمز" : "نمایش رمز"}
|
||||
className="absolute inset-y-0 right-0 flex cursor-pointer items-center rounded-lg px-3 text-muted transition-colors hover:text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4" aria-hidden="true" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -46,7 +46,7 @@ export function DashboardPage() {
|
||||
[quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions, topics],
|
||||
);
|
||||
|
||||
// فیلتر + مرتبسازی بر اساس زمان ساخت (created_at) — فیلترها بازنشانی نمیشوند
|
||||
// فیلتر + مرتبسازی بر اساس زمان ساخت (created_at)
|
||||
const filtered = useMemo(() => {
|
||||
const list = filterQuotes(store, filters);
|
||||
const createdTs = (q: (typeof store.quotes)[number]) => {
|
||||
@@ -79,24 +79,43 @@ export function DashboardPage() {
|
||||
brand
|
||||
menu={<AppSwitcherMenu activeModule="procurement" />}
|
||||
actions={
|
||||
<>
|
||||
<Link to="/suppliers" className="btn-line-file no-underline">
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
تأمینکنندهها
|
||||
<div className="flex items-center gap-2">
|
||||
{/* دکمه تأمینکنندهها */}
|
||||
<Link
|
||||
to="/suppliers"
|
||||
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-200 text-xs font-semibold hover:bg-gray-50 dark:hover:bg-zinc-700 shadow-2xs transition-all no-underline"
|
||||
>
|
||||
<Users className="h-4 w-4 text-gray-500 dark:text-zinc-400" />
|
||||
<span>تأمینکنندهها</span>
|
||||
</Link>
|
||||
<Link to="/projects" className="btn-line-file no-underline">
|
||||
<FolderKanban className="h-3.5 w-3.5" />
|
||||
پروژهها
|
||||
|
||||
{/* دکمه پروژهها */}
|
||||
<Link
|
||||
to="/projects"
|
||||
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-200 text-xs font-semibold hover:bg-gray-50 dark:hover:bg-zinc-700 shadow-2xs transition-all no-underline"
|
||||
>
|
||||
<FolderKanban className="h-4 w-4 text-gray-500 dark:text-zinc-400" />
|
||||
<span>پروژهها</span>
|
||||
</Link>
|
||||
<Link to="/units" className="btn-line-file no-underline">
|
||||
<Ruler className="h-3.5 w-3.5" />
|
||||
ضرایب تبدیل
|
||||
|
||||
{/* دکمه ضرایب تبدیل */}
|
||||
<Link
|
||||
to="/units"
|
||||
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-200 text-xs font-semibold hover:bg-gray-50 dark:hover:bg-zinc-700 shadow-2xs transition-all no-underline"
|
||||
>
|
||||
<Ruler className="h-4 w-4 text-gray-500 dark:text-zinc-400" />
|
||||
<span>ضرایب تبدیل</span>
|
||||
</Link>
|
||||
<Link to="/wizard" className="btn-line-file no-underline bg-accent text-white hover:bg-accent-dark">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
افزودن پیشنهاد جدید
|
||||
|
||||
{/* دکمه اصلی: افزودن پیشنهاد جدید */}
|
||||
<Link
|
||||
to="/wizard"
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-xs font-semibold shadow-sm hover:shadow transition-all no-underline"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span>افزودن پیشنهاد جدید</span>
|
||||
</Link>
|
||||
</>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<PageLayout className="flex flex-col gap-4">
|
||||
@@ -128,4 +147,4 @@ export function DashboardPage() {
|
||||
<EditModal quoteId={editId} onClose={() => setEditId(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ function InfoCell({ label, children }: { label: string; children: React.ReactNod
|
||||
);
|
||||
}
|
||||
|
||||
// کارت یک پیشنهاد در دفتر — جایگزین جدول ۱۱ ستونه
|
||||
// کارت ساده و یکدست یک پیشنهاد در دفتر
|
||||
export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit }: QuoteCardProps) {
|
||||
const sup = supplierById(store.suppliers, q.supplier_id);
|
||||
const expired = isExpired(q);
|
||||
@@ -46,11 +46,15 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
||||
const unitWarns = unitWarning(store, q);
|
||||
const summary = itemsSummary(store, q);
|
||||
|
||||
// ستون پذیرش (با ساعت)
|
||||
// دکمه یا وضعیت پذیرش
|
||||
let acceptedCell: React.ReactNode;
|
||||
if (q.status === "offered") {
|
||||
acceptedCell = (
|
||||
<button className="chip chip-act" onClick={onAccept}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAccept}
|
||||
className="px-2 py-0.5 rounded text-xs font-semibold bg-emerald-50 text-emerald-700 hover:bg-emerald-100 border border-emerald-200 transition-colors cursor-pointer"
|
||||
>
|
||||
پذیرش
|
||||
</button>
|
||||
);
|
||||
@@ -60,8 +64,6 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
||||
acceptedCell = <span className="text-muted">—</span>;
|
||||
}
|
||||
|
||||
// ستون رسید — v2.6: تأیید رسید فقط در «پنل مدیریت انبار» توسط مدیر پروژه انجام میشود؛
|
||||
// اینجا فقط وضعیت نمایش داده میشود. با رسید جزیی: «رسیده X از Y» + «در راه Z».
|
||||
const activeItems = (q.line_items || []).filter((li) => !li.rejected);
|
||||
const receivedSum = activeItems.reduce((s, li) => s + (li.received_qty || 0), 0);
|
||||
const orderedSum = activeItems.reduce((s, li) => s + (li.quantity || 0), 0);
|
||||
@@ -71,8 +73,6 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
||||
const anyReceived = receivedSum > 0;
|
||||
const fullyDelivered = activeItems.length > 0 && fulfilledItems === activeItems.length && anyReceived;
|
||||
|
||||
// نشان پیشرفت تکمیل در هدر کارت (جایگزین نشان وضعیت در حالت پذیرش/رسید):
|
||||
// در راه (آبی) → رسید: X از Y قلم (کهربایی) → تکمیل شد (سبز)
|
||||
let fulfillmentBadge: React.ReactNode = null;
|
||||
if (q.status === "accepted" || q.status === "arrived") {
|
||||
if (fullyDelivered || q.status === "arrived") {
|
||||
@@ -97,7 +97,6 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
||||
}
|
||||
|
||||
let arrivedCell: React.ReactNode;
|
||||
// آخرین لحظهی رسید ثبتشده بین قلمها (رسیدهای جزیی توسط مدیر پروژه)
|
||||
const lastReceiptAt = activeItems.reduce<Date | null>((acc, li) => {
|
||||
if (!li.arrived_date) return acc;
|
||||
const t = new Date(li.arrived_date).getTime();
|
||||
@@ -137,37 +136,49 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-lg border bg-panel transition-colors",
|
||||
expired ? "border-amber/40" : "border-line hover:border-accent/40",
|
||||
expanded && "border-accent/60",
|
||||
"overflow-hidden rounded-xl border bg-panel transition-all shadow-xs",
|
||||
expired ? "border-amber/40" : "border-line hover:border-gray-300",
|
||||
expanded && "border-blue-500/50 shadow-sm",
|
||||
)}
|
||||
>
|
||||
{/* سربرگ کارت */}
|
||||
<div className="flex items-center gap-3 border-b border-line bg-bg/40 px-4 py-2.5">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-accent-bg text-sm font-bold text-accent-dark">
|
||||
{/* هدر یکپارچه و تمیز بدون کادر تیره */}
|
||||
<div className="flex items-center gap-3 border-b border-line/60 px-4 py-3">
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gray-100 text-xs font-bold text-gray-700">
|
||||
{sup.name.slice(0, 1)}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
to={`/suppliers/${q.supplier_id}`}
|
||||
className="block truncate text-[13.5px] font-bold text-ink no-underline hover:text-accent"
|
||||
className="block truncate text-[13.5px] font-bold text-ink no-underline hover:text-blue-600"
|
||||
>
|
||||
{sup.name}
|
||||
</Link>
|
||||
{sup.location && <div className="truncate text-[11px] text-muted">{sup.location}</div>}
|
||||
</div>
|
||||
<div className="ms-auto flex shrink-0 items-center gap-1.5">
|
||||
|
||||
{/* دکمههای عملیاتی راست به چپ */}
|
||||
<div className="ms-auto flex shrink-0 items-center gap-2">
|
||||
{fulfillmentBadge ?? (
|
||||
<Badge variant={q.status as "offered" | "accepted" | "arrived"}>{STATUS_LABEL[q.status]}</Badge>
|
||||
)}
|
||||
<button className="chip" onClick={onEdit} title="ویرایش پیشنهاد">
|
||||
<Pencil className="h-3 w-3" />
|
||||
ویرایش
|
||||
</button>
|
||||
|
||||
{/* دکمه ویرایش مینیمال */}
|
||||
<button
|
||||
className="chip chip-expand"
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-gray-500 hover:text-gray-800 hover:bg-gray-100 text-xs transition cursor-pointer"
|
||||
onClick={onEdit}
|
||||
title="ویرایش پیشنهاد"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
<span>ویرایش</span>
|
||||
</button>
|
||||
|
||||
{/* فلش آکاردئون */}
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition cursor-pointer"
|
||||
onClick={onToggleExpand}
|
||||
title={expanded ? "جمع کردن" : "نمایش جریان"}
|
||||
title={expanded ? "جمع کردن" : "نمایش جزئیات"}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<ChevronDown className={cn("h-4 w-4 transition-transform duration-200", expanded && "rotate-180")} />
|
||||
@@ -175,25 +186,28 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* اطلاعات کلیدی */}
|
||||
<div className="grid grid-cols-2 gap-x-5 gap-y-3 px-4 py-3 md:grid-cols-4">
|
||||
{/* اطلاعات کلیدی کارت */}
|
||||
<div className="grid grid-cols-2 gap-x-5 gap-y-3 px-4 py-3.5 md:grid-cols-4">
|
||||
<InfoCell label="اقلام">
|
||||
{summary.count > 1 ? (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-semibold">{fmtNum(summary.count)} قلم</span>
|
||||
<div className="flex flex-col gap-1 text-[11.5px] leading-5 text-muted">
|
||||
{store.quotes.find((x) => x.id === q.id)?.line_items?.filter((li) => !li.rejected).map((li) => {
|
||||
const m = materialById(store.materials, li.material_id);
|
||||
return (
|
||||
<span key={li.id} className="flex items-center gap-1">
|
||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-accent/40" />
|
||||
{materialLabel(m)}
|
||||
<span className="text-[10px] text-muted/70">
|
||||
{fmtNum(li.quantity)} {li.unit}
|
||||
{store.quotes
|
||||
.find((x) => x.id === q.id)
|
||||
?.line_items?.filter((li) => !li.rejected)
|
||||
.map((li) => {
|
||||
const m = materialById(store.materials, li.material_id);
|
||||
return (
|
||||
<span key={li.id} className="flex items-center gap-1">
|
||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-blue-500/60" />
|
||||
{materialLabel(m)}
|
||||
<span className="text-[10px] text-muted/70">
|
||||
{fmtNum(li.quantity)} {li.unit}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -251,8 +265,8 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
||||
</InfoCell>
|
||||
</div>
|
||||
|
||||
{/* تاریخها و اقدامات */}
|
||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 border-t border-line bg-bg/40 px-4 py-2 text-[11.5px] text-muted">
|
||||
{/* پاورقی تمیز و روشن */}
|
||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-1.5 border-t border-line/60 px-4 py-2 text-[11.5px] text-muted">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Package className="h-3 w-3" />
|
||||
پیشنهاد: <b className="text-ink tabular-nums">{fmtDateTime(q.quote_date)}</b>
|
||||
@@ -277,4 +291,4 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
||||
{expanded && <QuoteDetail store={store} q={q} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,16 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNavigate, Link } from "react-router-dom";
|
||||
import {
|
||||
ChevronRight,
|
||||
ChevronLeft,
|
||||
Calendar as CalendarIcon,
|
||||
Building2,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
PackageX,
|
||||
Trash2,
|
||||
Clock
|
||||
} from "lucide-react";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { inventorySubheader } from "./panelMenu";
|
||||
@@ -16,9 +27,8 @@ function todayKey(): string {
|
||||
return gregKey(new Date());
|
||||
}
|
||||
|
||||
// کنترل روزانه — پورت کامل تقویم شمسی نسخهی ارسالی
|
||||
export function DailyControlPage() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const data = useInventoryData();
|
||||
const navigate = useNavigate();
|
||||
const isPM = user?.role === "pm";
|
||||
@@ -26,7 +36,6 @@ export function DailyControlPage() {
|
||||
const [anchor, setAnchor] = useState<Date>(new Date());
|
||||
const [sel, setSel] = useState<string>(todayKey());
|
||||
const [actRec, setActRec] = useState<string | null>(null);
|
||||
// فیلتر پروژه — فقط برای مدیر تدارکات/ادمین (مدیر پروژه به پروژه خودش محدود است)
|
||||
const isAdmin = user?.role === "admin" || user?.role === "procurement_manager";
|
||||
const [projFilter, setProjFilter] = useState("");
|
||||
|
||||
@@ -46,7 +55,6 @@ export function DailyControlPage() {
|
||||
return map;
|
||||
}, [data.transactions, data.warehouses, projFilter]);
|
||||
|
||||
// cells: 42 روز از شنبه قبلِ اولین روز ماه جلالی
|
||||
const cells = useMemo(() => {
|
||||
const [ty, tm] = gregorianToJalali(anchor.getFullYear(), anchor.getMonth() + 1, anchor.getDate());
|
||||
let first: Date | null = null;
|
||||
@@ -59,7 +67,7 @@ export function DailyControlPage() {
|
||||
}
|
||||
}
|
||||
if (!first) first = new Date(anchor.getTime() - 33 * 86400000);
|
||||
const offset = (first.getDay() + 1) % 7; // شنبه = 0
|
||||
const offset = (first.getDay() + 1) % 7;
|
||||
const gridStart = new Date(first.getTime() - offset * 86400000);
|
||||
const out: { d: Date; inMonth: boolean; isToday: boolean; n: number }[] = [];
|
||||
for (let c = 0; c < 42; c++) {
|
||||
@@ -79,7 +87,6 @@ export function DailyControlPage() {
|
||||
const [ty, tm] = gregorianToJalali(anchor.getFullYear(), anchor.getMonth() + 1, anchor.getDate());
|
||||
const monthTitle = `${toFa(ty)} ${MONTHS[tm - 1]}`;
|
||||
|
||||
// جابهجایی بین ماههای شمسی (نه ±۳۳ روز) — ماه قبل/بعد با سالشماری درست
|
||||
const shiftMonth = (delta: number) => {
|
||||
let nm = tm + delta;
|
||||
let ny = ty;
|
||||
@@ -90,18 +97,12 @@ export function DailyControlPage() {
|
||||
nm = 1;
|
||||
ny += 1;
|
||||
}
|
||||
// روزِ برگشتی را نگه میداریم (ماه شمسی از میانهی ماه میلادی شروع میشود:
|
||||
// مثلاً مهر ۱ = ۲۳ سپتامبر). اگر بهجای آن روز «۱» استفاده شود، لنگر در
|
||||
// اولِ ماهِ میلادی مینشیند و هنوز در همان ماه شمسی است — دکمه «ماه بعد»
|
||||
// ظاهراً هیچ کاری نمیکند.
|
||||
const [gy, gm, gd] = jalaliToGregorian(ny, nm, 1);
|
||||
setAnchor(new Date(gy, gm - 1, gd));
|
||||
};
|
||||
|
||||
const dayTx = txByDay.get(sel) || [];
|
||||
|
||||
// فعالسازی ناوبری ماه از چند مسیر (pointerup / click / کیبورد) با حذف
|
||||
// اجرای تکراری — اگر مرورگر رویداد click را بخورد، pointerup جواب میدهد.
|
||||
const navLast = useRef<{ key: string; at: number }>({ key: "", at: 0 });
|
||||
const navFire = (key: string, action: () => void) => {
|
||||
const now = Date.now();
|
||||
@@ -144,114 +145,219 @@ export function DailyControlPage() {
|
||||
const [sy, sm, sd] = gregorianToJalali(selDate.getFullYear(), selDate.getMonth() + 1, selDate.getDate());
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<div className="min-h-screen bg-slate-50/60">
|
||||
<Header
|
||||
title="کنترل روزانه"
|
||||
sub={inventorySubheader(user)}
|
||||
backTo="/inventory"
|
||||
backLabel="بازگشت به داشبورد"
|
||||
/>
|
||||
<PageLayout className="flex flex-col gap-3">
|
||||
{isAdmin && (
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-line bg-panel px-3 py-2 text-[12.5px]">
|
||||
<label className="font-bold">🏢 پروژه:</label>
|
||||
<select
|
||||
className="h-8 rounded-lg border border-line bg-panel px-2 text-[12.5px]"
|
||||
value={projFilter}
|
||||
onChange={(e) => setProjFilter(e.target.value)}
|
||||
>
|
||||
<option value="">همه پروژهها</option>
|
||||
{data.lookup.projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-[11.5px] text-muted">
|
||||
{projFilter
|
||||
? "نمایش کنترل روزانه فقط برای پروژه انتخابی"
|
||||
: "نمایش کنترل روزانه همه پروژهها"}
|
||||
<PageLayout className="flex flex-col gap-4">
|
||||
{/* نوار پروژه سفید و شفاف */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-slate-200 bg-white p-3 shadow-xs">
|
||||
<div className="flex flex-wrap items-center gap-2.5 text-xs">
|
||||
<span className="flex items-center gap-1.5 font-bold text-slate-800">
|
||||
<Building2 className="h-4 w-4 text-blue-600" />
|
||||
<span>پروژه:</span>
|
||||
</span>
|
||||
{isAdmin ? (
|
||||
<select
|
||||
className="h-8 rounded-lg border border-slate-200 bg-white px-2.5 text-xs font-medium text-slate-800 focus:outline-none focus:border-blue-500"
|
||||
value={projFilter}
|
||||
onChange={(e) => setProjFilter(e.target.value)}
|
||||
>
|
||||
<option value="">همه پروژهها</option>
|
||||
{data.lookup.projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<span className="font-semibold text-slate-800">پروژه فعال</span>
|
||||
)}
|
||||
<span className="text-[11.5px] text-slate-400">
|
||||
{projFilter ? "نمایش برای پروژه انتخابی" : "نمایش تمام پروژهها"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{/* تقویم */}
|
||||
<div className="rounded-xl border border-line bg-panel p-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="text-[14px] font-bold">📅 {monthTitle}</div>
|
||||
<div className="flex gap-1.5">
|
||||
<button className="cursor-pointer rounded-lg border border-line px-3 py-1 text-[12px] transition-colors hover:bg-slate-100" {...navBtnProps("prev", () => shiftMonth(-1))}>▶ ماه قبل</button>
|
||||
<button className="cursor-pointer rounded-lg border border-line px-3 py-1 text-[12px] transition-colors hover:bg-slate-100" {...navBtnProps("today", () => { setAnchor(new Date()); setSel(todayKey()); setActRec(null); })}>امروز</button>
|
||||
<button className="cursor-pointer rounded-lg border border-line px-3 py-1 text-[12px] transition-colors hover:bg-slate-100" {...navBtnProps("next", () => shiftMonth(1))}>◀ ماه بعد</button>
|
||||
|
||||
<Link
|
||||
to="/inventory/change"
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-xs font-semibold shadow-xs transition-colors no-underline"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
<span>ثبت خروج / تغییر موجودی</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2 items-start">
|
||||
{/* ستون تقویم — سفید، خوانا و بدون هدر تیره */}
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-xs">
|
||||
<div className="mb-4 flex items-center justify-between pb-3 border-b border-slate-100">
|
||||
<div className="flex items-center gap-2 text-[14px] font-bold text-slate-800">
|
||||
<CalendarIcon className="h-4 w-4 text-blue-600" />
|
||||
<span>{monthTitle}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
className="inline-flex items-center gap-1 h-7.5 px-2.5 rounded-md border border-slate-200 bg-white text-xs font-medium text-slate-700 hover:border-slate-300 transition-colors cursor-pointer"
|
||||
{...navBtnProps("prev", () => shiftMonth(-1))}
|
||||
>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
<span>ماه قبل</span>
|
||||
</button>
|
||||
<button
|
||||
className="h-7.5 px-3 rounded-md border border-blue-200 bg-blue-50 text-xs font-bold text-blue-700 hover:bg-blue-100 transition-colors cursor-pointer"
|
||||
{...navBtnProps("today", () => { setAnchor(new Date()); setSel(todayKey()); setActRec(null); })}
|
||||
>
|
||||
امروز
|
||||
</button>
|
||||
<button
|
||||
className="inline-flex items-center gap-1 h-7.5 px-2.5 rounded-md border border-slate-200 bg-white text-xs font-medium text-slate-700 hover:border-slate-300 transition-colors cursor-pointer"
|
||||
{...navBtnProps("next", () => shiftMonth(1))}
|
||||
>
|
||||
<span>ماه بعد</span>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
|
||||
{/* روزهای هفته */}
|
||||
<div className="grid grid-cols-7 gap-1 text-center">
|
||||
{["ش", "ی", "د", "س", "چ", "پ", "ج"].map((d) => (
|
||||
<div key={d} className="py-1 text-center text-[11px] font-bold text-muted">{d}</div>
|
||||
<div key={d} className="py-1 text-xs font-semibold text-slate-400">{d}</div>
|
||||
))}
|
||||
{cells.map((c, i) => {
|
||||
// gregorianToJalali → [سال، ماه، روز] — روزِ ماه در خانهی تقویم
|
||||
const [, , jd] = gregorianToJalali(c.d.getFullYear(), c.d.getMonth() + 1, c.d.getDate());
|
||||
const k = gregKey(c.d);
|
||||
const isSelected = sel === k;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => { setSel(k); setActRec(null); }}
|
||||
className={`relative rounded-lg py-2 text-[12px] ${!c.inMonth ? "text-muted/40" : ""} ${c.isToday ? "bg-accent font-bold text-white" : ""} ${sel === k ? "ring-2 ring-accent" : "hover:bg-accent/5"}`}
|
||||
className={`relative flex flex-col items-center justify-center h-9 rounded-lg text-xs transition-colors cursor-pointer ${
|
||||
!c.inMonth ? "text-slate-300" : "text-slate-700 font-medium"
|
||||
} ${
|
||||
isSelected
|
||||
? "bg-blue-600 text-white font-bold"
|
||||
: c.isToday
|
||||
? "border border-blue-500 font-bold text-blue-600 hover:bg-blue-50/50"
|
||||
: "hover:bg-slate-100/70"
|
||||
}`}
|
||||
>
|
||||
{toFa(jd)}
|
||||
<span>{toFa(jd)}</span>
|
||||
{c.n > 0 && (
|
||||
<span
|
||||
className={`absolute bottom-1 h-1 w-1 rounded-full ${
|
||||
isSelected ? "bg-white" : "bg-blue-600"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* برداشتهای روز */}
|
||||
<div className="rounded-xl border border-line bg-panel p-4">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<h2 className="text-[14px] font-bold">🕘 کنترل روزانه — {isToday ? "امروز " : ""}{toFa(sd)} {MONTHS[sm - 1]} {toFa(sy)}</h2>
|
||||
<span className="rounded-lg bg-rose-50 px-2 py-1 text-[11.5px] font-bold text-rose-600">▼ برداشتها: {faNum(wGroups.length)} قلم</span>
|
||||
{/* ستون جزئیات روز — سفید یکدست */}
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-xs">
|
||||
<div className="mb-2 flex items-center justify-between pb-3 border-b border-slate-100">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-blue-600" />
|
||||
<h2 className="text-sm font-bold text-slate-800">
|
||||
{isToday ? "امروز — " : "کنترل — "}
|
||||
{toFa(sd)} {MONTHS[sm - 1]} {toFa(sy)}
|
||||
</h2>
|
||||
</div>
|
||||
<span
|
||||
className={`rounded-md px-2 py-0.5 text-xs font-semibold ${
|
||||
wGroups.length > 0
|
||||
? "bg-amber-50 text-amber-700 border border-amber-200"
|
||||
: "text-slate-400 border border-slate-100"
|
||||
}`}
|
||||
>
|
||||
{faNum(wGroups.length)} برداشت
|
||||
</span>
|
||||
</div>
|
||||
<p className="mb-3 text-[11.5px] leading-5 text-muted">
|
||||
مواردی که این روز از انبار برداشت و به ساختمان برده شدهاند. روی هر مورد بزنید و برگشت / مفقودی / اتلاف را انتخاب کنید.
|
||||
|
||||
<p className="mb-3 text-[11.5px] leading-5 text-slate-500">
|
||||
اقلام برداشتشده از انبار در این تاریخ:
|
||||
</p>
|
||||
|
||||
{!wGroups.length ? (
|
||||
<div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">
|
||||
{isToday ? "امروز هنوز برداشتی ثبت نشده است." : "در این روز برداشتی ثبت نشده است."} هر برداشتی که در «تغییر موجودی» ثبت کنید، همینجا نمایش داده میشود.
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-slate-200 py-10 px-4 text-center">
|
||||
<span className="text-xs text-slate-400 mb-2">
|
||||
{isToday ? "امروز هنوز هیچ برداشتی ثبت نشده است." : "در این تاریخ برداشتی ثبت نشده است."}
|
||||
</span>
|
||||
<Link
|
||||
to="/inventory/change"
|
||||
className="inline-flex items-center gap-1 px-3 py-1 rounded text-blue-600 hover:bg-blue-50 text-xs font-semibold transition-colors no-underline"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
<span>ثبت برداشت جدید</span>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex flex-col gap-2">
|
||||
{wGroups.map((g) => (
|
||||
<div key={g.recId} className={`rounded-lg border p-2.5 ${actRec === g.recId ? "border-accent bg-accent/5" : "border-line bg-panel"}`}>
|
||||
<button type="button" className="flex w-full items-center gap-2 text-start" onClick={() => setActRec(actRec === g.recId ? null : g.recId)}>
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-rose-50 text-rose-600">➖</span>
|
||||
<div
|
||||
key={g.recId}
|
||||
className={`rounded-lg border p-3 transition-colors ${
|
||||
actRec === g.recId
|
||||
? "border-blue-500 bg-blue-50/20"
|
||||
: "border-slate-100 bg-white hover:border-slate-200"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-3 text-start cursor-pointer"
|
||||
onClick={() => setActRec(actRec === g.recId ? null : g.recId)}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[13px] font-semibold">{g.tx.material?.name || "—"}</span>
|
||||
<span className="block truncate text-[11px] text-muted">
|
||||
{[g.tx.brand?.name, Object.entries(g.tx.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · "), whName(data.warehouses, g.tx.warehouse_id)].filter(Boolean).join(" · ")}
|
||||
<span className="block truncate text-xs font-bold text-slate-800">
|
||||
{g.tx.material?.name || "—"}
|
||||
</span>
|
||||
<span className="block truncate text-[11px] text-slate-400 mt-0.5">
|
||||
{[
|
||||
g.tx.brand?.name,
|
||||
Object.entries(g.tx.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · "),
|
||||
whName(data.warehouses, g.tx.warehouse_id)
|
||||
].filter(Boolean).join(" · ")}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-[13px] font-bold text-rose-600">{faNum(g.q)} {unitName(data.lookup.units, g.tx.unit?.id)}</span>
|
||||
<span className="text-[10.5px] text-muted">{new Date(g.last).toLocaleTimeString("fa-IR", { hour: "2-digit", minute: "2-digit" })}</span>
|
||||
<span className="text-xs font-bold text-rose-600 tabular-nums">
|
||||
{faNum(g.q)} {unitName(data.lookup.units, g.tx.unit?.id)}
|
||||
</span>
|
||||
<span className="text-[11px] text-slate-400 tabular-nums">
|
||||
{new Date(g.last).toLocaleTimeString("fa-IR", { hour: "2-digit", minute: "2-digit" })}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{actRec === g.recId && isPM && (
|
||||
<div className="mt-2 flex gap-1.5 border-t border-line pt-2">
|
||||
<div className="mt-2.5 flex flex-wrap gap-2 border-t border-slate-100 pt-2">
|
||||
<button
|
||||
className="rounded-lg border border-emerald-300 bg-emerald-50 px-3 py-1 text-[11.5px] font-semibold text-emerald-700"
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded border border-emerald-300 bg-emerald-50 px-2.5 py-1 text-xs font-semibold text-emerald-700 hover:bg-emerald-100 transition-colors cursor-pointer"
|
||||
onClick={() => navigate(`/inventory/change?type=RETURN&wh=${g.tx.warehouse_id}&mat=${encodeURIComponent(g.tx.material?.name || "")}`)}
|
||||
>
|
||||
↩️ برگشت
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
<span>برگشت</span>
|
||||
</button>
|
||||
<button
|
||||
className="rounded-lg border border-rose-300 bg-rose-50 px-3 py-1 text-[11.5px] font-semibold text-rose-700"
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded border border-rose-300 bg-rose-50 px-2.5 py-1 text-xs font-semibold text-rose-700 hover:bg-rose-100 transition-colors cursor-pointer"
|
||||
onClick={() => navigate(`/inventory/change?type=LOSS&wh=${g.tx.warehouse_id}&mat=${encodeURIComponent(g.tx.material?.name || "")}`)}
|
||||
>
|
||||
📦 مفقودی
|
||||
<PackageX className="h-3 w-3" />
|
||||
<span>مفقودی</span>
|
||||
</button>
|
||||
<button
|
||||
className="rounded-lg border border-rose-300 bg-rose-50 px-3 py-1 text-[11.5px] font-semibold text-rose-700"
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded border border-slate-200 bg-white px-2.5 py-1 text-xs font-semibold text-slate-700 hover:bg-slate-50 transition-colors cursor-pointer"
|
||||
onClick={() => navigate(`/inventory/change?type=WASTE&wh=${g.tx.warehouse_id}&mat=${encodeURIComponent(g.tx.material?.name || "")}`)}
|
||||
>
|
||||
🗑️ اتلاف
|
||||
<Trash2 className="h-3 w-3" />
|
||||
<span>اتلاف</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -264,4 +370,4 @@ export function DailyControlPage() {
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link, useLocation, useSearchParams } from "react-router-dom";
|
||||
import { Plus, Warehouse as WarehouseIcon } from "lucide-react";
|
||||
import { Plus, Warehouse as WarehouseIcon, ArrowUpDown, ArrowUp, ArrowDown, X } from "lucide-react";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { EmptyState } from "../../components/EmptyState";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
|
||||
import { inventorySubheader } from "./panelMenu";
|
||||
@@ -52,20 +53,21 @@ export function InventoryPage() {
|
||||
const location = useLocation();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const { warehouses, inventory, transactions, lookup, loading } = useInventoryData();
|
||||
const isAdmin = user?.role === "admin" || user?.role === "procurement_manager"; // مدیر تدارکات = ادمین دفتر
|
||||
const isAdmin = user?.role === "admin" || user?.role === "procurement_manager";
|
||||
const isPM = user?.role === "pm";
|
||||
// پرش عمیق از «انبارها»: /inventory?warehouse=<id> → داشبورد همان انبار از پیش فیلتر شده
|
||||
const [params] = useSearchParams();
|
||||
|
||||
const [filters, setFilters] = useState<{ category: string; warehouse: string; material: string; project: string }>({
|
||||
category: "", warehouse: params.get("warehouse") || "", material: "", project: "",
|
||||
category: "",
|
||||
warehouse: params.get("warehouse") || "",
|
||||
material: "",
|
||||
project: "",
|
||||
});
|
||||
const [sort, setSort] = useState<"asc" | "desc" | "">("");
|
||||
const [expandedKey, setExpandedKey] = useState<string | null>(null);
|
||||
const [selTx, setSelTx] = useState<string | null>(null);
|
||||
const [expBrand, setExpBrand] = useState<string | null>(null);
|
||||
|
||||
// ادمین: انبارهای همه پروژهها یا پروژهی انتخابی
|
||||
const visibleWarehouses = useMemo(() => {
|
||||
if (isAdmin && filters.project) return warehouses.filter((w) => w.project_id === filters.project);
|
||||
if (isAdmin) return warehouses;
|
||||
@@ -86,7 +88,18 @@ export function InventoryPage() {
|
||||
const gk = `${whId}|${r.material.id}|${specsHash(r.specs)}`;
|
||||
let g = gmap.get(gk);
|
||||
if (!g) {
|
||||
g = { key: gk, whId, matId: r.material.id, specs: r.specs || {}, unitId: r.unit?.id ?? null, qty: 0, updatedAt: r.updated_at, recs: [], brands: {}, brandOrder: [] };
|
||||
g = {
|
||||
key: gk,
|
||||
whId,
|
||||
matId: r.material.id,
|
||||
specs: r.specs || {},
|
||||
unitId: r.unit?.id ?? null,
|
||||
qty: 0,
|
||||
updatedAt: r.updated_at,
|
||||
recs: [],
|
||||
brands: {},
|
||||
brandOrder: [],
|
||||
};
|
||||
gmap.set(gk, g);
|
||||
order.push(gk);
|
||||
}
|
||||
@@ -145,24 +158,23 @@ export function InventoryPage() {
|
||||
<>
|
||||
<InventoryTopNav pathname={location.pathname} variant={isAdmin ? "admin" : "pm"} />
|
||||
{isPM && (
|
||||
<Link
|
||||
to="/inventory/change"
|
||||
className="btn-line-file no-underline bg-accent text-white hover:bg-accent-dark"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
تغییر موجودی
|
||||
</Link>
|
||||
<Button asChild variant="primary">
|
||||
<Link to="/inventory/change" className="no-underline">
|
||||
<Plus className="h-4 w-4" />
|
||||
تغییر موجودی
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<PageLayout className="flex flex-col gap-3">
|
||||
{/* فیلترها */}
|
||||
<div className="flex flex-wrap items-end gap-2 rounded-xl border border-line bg-panel p-3">
|
||||
<div className="field">
|
||||
<label>دستهبندی</label>
|
||||
<PageLayout className="flex flex-col gap-4">
|
||||
{/* نوار فیلترها و ابزارها */}
|
||||
<div className="flex flex-wrap items-end gap-3 rounded-xl border border-line bg-panel p-3.5 shadow-sm">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-muted">دستهبندی</label>
|
||||
<select
|
||||
className="h-8 rounded-lg border border-line bg-panel px-2 text-[12.5px]"
|
||||
className="h-9 min-w-[150px] rounded-md border border-line bg-panel px-2.5 text-xs text-ink transition-all focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
|
||||
value={filters.category}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, category: e.target.value, material: "" }))}
|
||||
>
|
||||
@@ -172,11 +184,12 @@ export function InventoryPage() {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<div className="field">
|
||||
<label>پروژه</label>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-muted">پروژه</label>
|
||||
<select
|
||||
className="h-8 rounded-lg border border-line bg-panel px-2 text-[12.5px]"
|
||||
className="h-9 min-w-[140px] rounded-md border border-line bg-panel px-2.5 text-xs text-ink transition-all focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
|
||||
value={filters.project}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, project: e.target.value, warehouse: "", material: "" }))}
|
||||
>
|
||||
@@ -187,10 +200,11 @@ export function InventoryPage() {
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="field">
|
||||
<label>انبار</label>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-muted">انبار</label>
|
||||
<select
|
||||
className="h-8 rounded-lg border border-line bg-panel px-2 text-[12.5px]"
|
||||
className="h-9 min-w-[140px] rounded-md border border-line bg-panel px-2.5 text-xs text-ink transition-all focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
|
||||
value={filters.warehouse}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, warehouse: e.target.value, material: "" }))}
|
||||
>
|
||||
@@ -202,52 +216,82 @@ export function InventoryPage() {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>متریال</label>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-muted">متریال</label>
|
||||
<input
|
||||
className="h-8 w-44 rounded-lg border border-line bg-panel px-2 text-[12.5px]"
|
||||
className="h-9 w-48 rounded-md border border-line bg-panel px-3 text-xs text-ink transition-all placeholder:text-muted/70 focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
|
||||
placeholder="تایپ نام متریال…"
|
||||
value={filters.material}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, material: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="h-8 rounded-lg border border-line bg-panel px-3 text-[12px]"
|
||||
|
||||
<Button
|
||||
variant="line"
|
||||
onClick={() => setSort((s) => (s === "" ? "asc" : s === "asc" ? "desc" : ""))}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{sort === "asc" ? "تعداد: صعودی ▲" : sort === "desc" ? "تعداد: نزولی ▼" : "مرتبسازی تعداد"}
|
||||
</button>
|
||||
{sort === "asc" ? (
|
||||
<>
|
||||
<ArrowUp className="h-3.5 w-3.5 text-accent" />
|
||||
تعداد: صعودی
|
||||
</>
|
||||
) : sort === "desc" ? (
|
||||
<>
|
||||
<ArrowDown className="h-3.5 w-3.5 text-accent" />
|
||||
تعداد: نزولی
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ArrowUpDown className="h-3.5 w-3.5 text-muted" />
|
||||
مرتبسازی تعداد
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{hasFilter && (
|
||||
<button
|
||||
className="h-8 rounded-lg border border-red/30 bg-red/5 px-3 text-[12px] text-red"
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => setFilters({ category: "", warehouse: "", material: "", project: "" })}
|
||||
className="gap-1"
|
||||
>
|
||||
✕ پاک کردن فیلترها
|
||||
</button>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
پاک کردن فیلترها
|
||||
</Button>
|
||||
)}
|
||||
{filters.warehouse && (
|
||||
<Badge variant="offered">🏬 {whName(warehouses, filters.warehouse)}</Badge>
|
||||
)}
|
||||
<Badge variant="outline">{faNum(groups.length)} قلم کالا</Badge>
|
||||
|
||||
<div className="ms-auto flex items-center gap-2 self-end">
|
||||
{filters.warehouse && (
|
||||
<Badge variant="offered" className="rounded-md px-2.5 py-1 text-xs">
|
||||
🏬 {whName(warehouses, filters.warehouse)}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="outline" className="rounded-md border-line bg-slate-bg px-3 py-1.5 text-xs font-normal text-muted">
|
||||
{faNum(groups.length)} قلم کالا
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* جدول گروهبندیشده */}
|
||||
<div className="overflow-hidden rounded-xl border border-line bg-panel">
|
||||
{!groups.length ? (
|
||||
{/* بخش جدول یا وضعیت خالی */}
|
||||
{!groups.length ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-line bg-panel/40 py-16 px-4 shadow-xs">
|
||||
<EmptyState emoji="📋" text="هیچ رکورد موجودیای مطابق فیلترهای انتخابی یافت نشد." />
|
||||
) : (
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-xl border border-line bg-panel shadow-sm">
|
||||
<table className="w-full text-[12.5px]">
|
||||
<thead>
|
||||
<tr className="border-b border-line bg-panel text-right text-muted">
|
||||
<th className="w-8 py-2" />
|
||||
<th className="py-2 pe-3">متریال</th>
|
||||
<th className="py-2 pe-3">دستهبندی</th>
|
||||
<th className="py-2 pe-3">انبار</th>
|
||||
<th className="py-2 pe-3">مشخصه فنی</th>
|
||||
<th className="py-2 pe-3">تعداد</th>
|
||||
<th className="py-2 pe-3">واحد</th>
|
||||
<th className="py-2 pe-3">آخرین بهروزرسانی</th>
|
||||
<th className="w-8 py-2" />
|
||||
<tr className="border-b border-line bg-slate-bg/50 text-right text-muted font-medium">
|
||||
<th className="w-8 py-2.5" />
|
||||
<th className="py-2.5 pe-3">متریال</th>
|
||||
<th className="py-2.5 pe-3">دستهبندی</th>
|
||||
<th className="py-2.5 pe-3">انبار</th>
|
||||
<th className="py-2.5 pe-3">مشخصه فنی</th>
|
||||
<th className="py-2.5 pe-3">تعداد</th>
|
||||
<th className="py-2.5 pe-3">واحد</th>
|
||||
<th className="py-2.5 pe-3">آخرین بهروزرسانی</th>
|
||||
<th className="w-8 py-2.5" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -293,8 +337,9 @@ export function InventoryPage() {
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-[11px] text-muted">
|
||||
۱۰ نوع تراکنش استاندارد • سبز زمردی = ورود به انبار · رز = خروج/کاهش · آبی = انتقال و شمارش • کلیک روی هر سطر: خط زمانی با «مانده» لحظهبهلحظه و تفکیک برندها
|
||||
</div>
|
||||
@@ -317,25 +362,25 @@ function GroupRow({ g, isExp, isAdmin, matName, matCatName, wh, projName, unit,
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<tr className={`border-b border-line/60 cursor-pointer ${isExp ? "bg-accent/5" : "hover:bg-accent/5"}`} onClick={onToggle}>
|
||||
<td className="py-2 text-center text-muted">{isExp ? "▾" : "▸"}</td>
|
||||
<td className="py-2 pe-3 font-semibold">{matName}</td>
|
||||
<td className="py-2 pe-3">{matCatName}</td>
|
||||
<td className="py-2 pe-3">
|
||||
<tr className={`border-b border-line/60 cursor-pointer transition-colors duration-150 ${isExp ? "bg-accent/5" : "hover:bg-slate-bg/60"}`} onClick={onToggle}>
|
||||
<td className="py-2.5 text-center text-muted">{isExp ? "▾" : "▸"}</td>
|
||||
<td className="py-2.5 pe-3 font-semibold text-ink">{matName}</td>
|
||||
<td className="py-2.5 pe-3">{matCatName}</td>
|
||||
<td className="py-2.5 pe-3">
|
||||
{wh}
|
||||
{isAdmin && <small className="block text-muted">{projName}</small>}
|
||||
</td>
|
||||
<td className="py-2 pe-3 text-muted">
|
||||
<td className="py-2.5 pe-3 text-muted">
|
||||
{Object.entries(g.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}
|
||||
</td>
|
||||
<td className="py-2 pe-3 font-bold">{faNum(g.qty)}</td>
|
||||
<td className="py-2 pe-3">{unit}</td>
|
||||
<td className="py-2 pe-3 text-muted">{isoToJalaliStr(g.updatedAt)}</td>
|
||||
<td className="py-2 text-muted">🕘</td>
|
||||
<td className="py-2.5 pe-3 font-bold text-ink">{faNum(g.qty)}</td>
|
||||
<td className="py-2.5 pe-3">{unit}</td>
|
||||
<td className="py-2.5 pe-3 text-muted">{isoToJalaliStr(g.updatedAt)}</td>
|
||||
<td className="py-2.5 text-muted">🕘</td>
|
||||
</tr>
|
||||
{isExp && (
|
||||
<tr className="border-b border-line/40 bg-accent-bg/20">
|
||||
<td colSpan={9} className="p-3">{timeline}</td>
|
||||
<tr className="border-b border-line/40 bg-accent-bg/10">
|
||||
<td colSpan={9} className="p-3.5">{timeline}</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
@@ -367,7 +412,9 @@ function Timeline({ g, txs, allTxs, brandTxs, selTx, setSelTx, expBrand, setExpB
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => setSelTx(sel ? null : t.id)}
|
||||
className={`flex min-w-[120px] flex-col gap-0.5 rounded-lg border p-2 text-start ${sel ? "border-accent bg-accent/10" : "border-line bg-panel"}`}
|
||||
className={`flex min-w-[125px] flex-col gap-1 rounded-md border p-2.5 text-start transition-all duration-150 active:scale-[0.98] ${
|
||||
sel ? "border-accent bg-accent/10 shadow-xs" : "border-line bg-panel hover:border-slate-300"
|
||||
}`}
|
||||
style={{ borderTop: `3px solid ${meta.color}` }}
|
||||
>
|
||||
<span className="text-[11px] font-bold" style={{ color: meta.color }}>{meta.icon} {meta.label}</span>
|
||||
@@ -383,9 +430,9 @@ function Timeline({ g, txs, allTxs, brandTxs, selTx, setSelTx, expBrand, setExpB
|
||||
if (!txs.length) return <div className="text-[12px] text-muted">تراکنشی ثبت نشده است.</div>;
|
||||
|
||||
const brandSec = expBrand ? (
|
||||
<div className="mt-2">
|
||||
<div className="mb-1 text-[12px] font-semibold">جریان برند: {brandNameFn(expBrand)} ({faNum(brandTxs(g, expBrand).length)} تراکنش)</div>
|
||||
<div className="flex gap-1.5 overflow-x-auto">{track(brandTxs(g, expBrand))}</div>
|
||||
<div className="mt-2.5">
|
||||
<div className="mb-1.5 text-[12px] font-semibold">جریان برند: {brandNameFn(expBrand)} ({faNum(brandTxs(g, expBrand).length)} تراکنش)</div>
|
||||
<div className="flex gap-2 overflow-x-auto pb-1">{track(brandTxs(g, expBrand))}</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
@@ -395,7 +442,7 @@ function Timeline({ g, txs, allTxs, brandTxs, selTx, setSelTx, expBrand, setExpB
|
||||
const meta = TX_META[t.transaction_type] || { label: t.transaction_type, color: "#64748b", icon: "•" };
|
||||
const pairTx = t.transfer_pair_id ? allTxs.find((x) => x.transfer_pair_id === t.transfer_pair_id && x.id !== t.id) : null;
|
||||
return (
|
||||
<div className="mt-2 rounded-lg border p-2 text-[12px]" style={{ borderTop: `3px solid ${meta.color}` }}>
|
||||
<div className="mt-2.5 rounded-md border border-line bg-panel p-2.5 text-[12px] shadow-xs" style={{ borderTop: `3px solid ${meta.color}` }}>
|
||||
<b style={{ color: meta.color }}>{meta.label}</b>
|
||||
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-muted">
|
||||
<span>تاریخ و ساعت: <b>{isoToJalaliStr(t.transaction_date)}</b></span>
|
||||
@@ -422,14 +469,14 @@ function Timeline({ g, txs, allTxs, brandTxs, selTx, setSelTx, expBrand, setExpB
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1 flex flex-wrap items-center gap-2 text-[12px]">
|
||||
<div className="mb-1.5 flex flex-wrap items-center gap-2 text-[12px]">
|
||||
<b>خط زمانی — {matName}</b>
|
||||
{Object.entries(g.specs || {}).length > 0 && (
|
||||
<span className="rounded bg-panel px-1.5 py-0.5 text-[10.5px] text-muted">
|
||||
<span className="rounded bg-panel px-2 py-0.5 text-[10.5px] text-muted border border-line">
|
||||
{Object.entries(g.specs).map(([k, v]) => `${k}: ${v}`).join(" · ")}
|
||||
</span>
|
||||
)}
|
||||
<span className="rounded bg-panel px-1.5 py-0.5 text-[10.5px] text-muted">{faNum(g.brandOrder.length)} برند</span>
|
||||
<span className="rounded bg-panel px-2 py-0.5 text-[10.5px] text-muted border border-line">{faNum(g.brandOrder.length)} برند</span>
|
||||
</div>
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
{g.brandOrder.map((bid) => (
|
||||
@@ -437,16 +484,18 @@ function Timeline({ g, txs, allTxs, brandTxs, selTx, setSelTx, expBrand, setExpB
|
||||
key={bid}
|
||||
type="button"
|
||||
onClick={() => setExpBrand(expBrand === bid ? null : bid)}
|
||||
className={`rounded-lg border px-2 py-1 text-[11.5px] ${expBrand === bid ? "border-accent bg-accent/10 text-accent" : "border-line bg-panel"}`}
|
||||
className={`rounded-md border px-2.5 py-1 text-[11.5px] transition-all duration-150 active:scale-[0.98] ${
|
||||
expBrand === bid ? "border-accent bg-accent/10 font-semibold text-accent" : "border-line bg-panel hover:bg-slate-bg"
|
||||
}`}
|
||||
>
|
||||
{brandNameFn(bid)}: <b>{faNum(g.brands[bid])}</b> {unit}{expBrand === bid ? " ✕" : ""}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{brandSec}
|
||||
<div className="mb-1 mt-2 text-[12px] font-semibold">جریان کلی رکورد — ترکیب همه برندها ({faNum(txs.length)} تراکنش)</div>
|
||||
<div className="flex gap-1.5 overflow-x-auto pb-1">{track(txs)}</div>
|
||||
<div className="mb-1.5 mt-2.5 text-[12px] font-semibold">جریان کلی رکورد — ترکیب همه برندها ({faNum(txs.length)} تراکنش)</div>
|
||||
<div className="flex gap-2 overflow-x-auto pb-1">{track(txs)}</div>
|
||||
{selDetail}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,7 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Button } from "../../components/ui/button";
|
||||
|
||||
// ناوبری بالای صفحه در پنل انبار — فقط روی «داشبورد انبار» (هاب) دیده میشود؛
|
||||
// خودِ «داشبورد انبار» دکمه ندارد (ریشه/هاب است) و زیرصفحهها تب ندارند و
|
||||
// فقط با «بازگشت به داشبورد» به هاب برمیگردند.
|
||||
//
|
||||
// - variant="pm" (مدیر پروژه): کنترل روزانه، انبارها، کالاهای در راه —
|
||||
// ورود به «تغییر موجودی» از دکمهی بالای هاب است.
|
||||
// - variant="admin" (مدیر تدارکات/ادمین): کنترل روزانه، کالاهای در راه، سازماندهی.
|
||||
// ناوبری بالای صفحه در پنل انبار — هماهنگ با دیزاینسیستم دکمهها
|
||||
export function InventoryTopNav({
|
||||
pathname,
|
||||
variant = "pm",
|
||||
@@ -27,20 +21,20 @@ export function InventoryTopNav({
|
||||
{ label: "انبارها", to: "/inventory/warehouses", active: pathname.startsWith("/inventory/warehouses") },
|
||||
{ label: "کالاهای در راه", to: "/inventory/arrivals", active: pathname.startsWith("/inventory/arrivals") },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{items.map((it) => (
|
||||
<Link
|
||||
<Button
|
||||
key={it.to}
|
||||
to={it.to}
|
||||
className={cn(
|
||||
"btn-line-file no-underline",
|
||||
it.active && "border-accent bg-accent text-white hover:bg-accent-dark",
|
||||
)}
|
||||
asChild
|
||||
variant={it.active ? "primary" : "slate"}
|
||||
>
|
||||
{it.label}
|
||||
</Link>
|
||||
<Link to={it.to} className="no-underline">
|
||||
{it.label}
|
||||
</Link>
|
||||
</Button>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -294,11 +294,11 @@ export function OrganizationPage() {
|
||||
<div className="mb-2 flex flex-col gap-1.5">
|
||||
{whs.map((w) => (
|
||||
<div key={w.id} className="flex items-center justify-between gap-2 rounded-lg border border-line px-3 py-2 text-[12.5px]">
|
||||
<div className="min-w-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<b>{w.name}</b>
|
||||
<small className="block truncate text-muted">{w.description || ""}</small>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
||||
<button
|
||||
className="rounded-lg border border-accent/40 bg-accent/5 px-2 py-1 text-[10.5px] font-bold text-accent"
|
||||
onClick={() => navigate(`/inventory?warehouse=${w.id}`)}
|
||||
@@ -322,20 +322,20 @@ export function OrganizationPage() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<input
|
||||
className="h-8 w-44 rounded-lg border border-line bg-panel px-2 text-sm"
|
||||
className="h-8 w-full rounded-lg border border-line bg-panel px-2 text-sm sm:w-44"
|
||||
placeholder="نام انبار جدید"
|
||||
value={newWh[p.id] || ""}
|
||||
onChange={(e) => setNewWh((s) => ({ ...s, [p.id]: e.target.value }))}
|
||||
/>
|
||||
<input
|
||||
className="h-8 w-52 rounded-lg border border-line bg-panel px-2 text-sm"
|
||||
className="h-8 w-full rounded-lg border border-line bg-panel px-2 text-sm sm:w-52"
|
||||
placeholder="توضیحات (اختیاری)"
|
||||
value={newWhDesc[p.id] || ""}
|
||||
onChange={(e) => setNewWhDesc((s) => ({ ...s, [p.id]: e.target.value }))}
|
||||
/>
|
||||
<button className="rounded-lg bg-accent px-3 py-1.5 text-[12px] font-bold text-white" onClick={() => void addWarehouse(p.id)}>
|
||||
<button className="w-full rounded-lg bg-accent px-3 py-2 text-[12px] font-bold text-white sm:w-auto sm:py-1.5" onClick={() => void addWarehouse(p.id)}>
|
||||
+ افزودن انبار
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -41,9 +41,7 @@ export function itemRowLabel(it: ItemDraft): string {
|
||||
return qtyPart ? `${name} -- ${qtyPart}` : name;
|
||||
}
|
||||
|
||||
// نام پایهی متریال از نامِ ذخیرهشده: پیشوند برند و پسوند مشخصه حذف میشوند
|
||||
// («راجین سیم مسی» → «سیم») — پیشنهادهای فیلد «متریال» فقط خودِ متریال را نشان
|
||||
// میدهند؛ برند و مشخصه ستونهای جداگانه دارند.
|
||||
// نام پایهی متریال از نامِ ذخیرهشده
|
||||
function materialBaseName(name: string, brand: string, specs: string[]): string {
|
||||
let base = (name || "").trim();
|
||||
if (!base) return "";
|
||||
@@ -60,9 +58,7 @@ function materialBaseName(name: string, brand: string, specs: string[]): string
|
||||
return base || (name || "").trim();
|
||||
}
|
||||
|
||||
// مقادیر مجزای category/brand/spec داخل تاپیک — با فیلتر پلهبهپله (تاپیک → دسته → برند)
|
||||
// + مقادیر تایپشده در همهی سطرهای قبلی (suggestion از همهی فیلدها)
|
||||
// نکته: برند و واحد از همهی دستهها نمایش داده میشوند (issue #7)
|
||||
// مقادیر مجزای category/brand/spec داخل تاپیک
|
||||
export function materialOptions(
|
||||
materials: Material[],
|
||||
topic: string,
|
||||
@@ -75,7 +71,6 @@ export function materialOptions(
|
||||
const cat = item.cat.trim();
|
||||
const brand = item.brand.trim();
|
||||
|
||||
// مقادیر واردشده در سطرهای دیگر — برای پیشنهاد در سطرهای جدید
|
||||
const fromRows = (sel: (it: ItemDraft) => string | string[]): string[] => {
|
||||
const out = new Set<string>();
|
||||
allItems.forEach((it) => {
|
||||
@@ -94,7 +89,6 @@ export function materialOptions(
|
||||
}
|
||||
if (cat) list = list.filter((m) => normalize(m.category) === normalize(cat));
|
||||
if (field === "brand") {
|
||||
// همهی برندها از همهی materials + برندهای ثبتشده در بخش برندها (بکاند)
|
||||
const allBrands = new Set(materials.map((m) => m.brand).filter(Boolean));
|
||||
apiBrands.forEach((b) => b && allBrands.add(b));
|
||||
fromRows((it) => it.brand).forEach((b) => allBrands.add(b));
|
||||
@@ -110,21 +104,18 @@ export function materialOptions(
|
||||
export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember }: Step2Props) {
|
||||
const materials = useMaterials();
|
||||
|
||||
// واحدها از بکاند خوانده میشوند + واحدهای واردشده در سطرهای قبلی
|
||||
const [unitOptions, setUnitOptions] = useState<string[]>(() => {
|
||||
const all = new Set<string>();
|
||||
items.forEach((it) => { if (it.unit) all.add(it.unit); });
|
||||
return [...all];
|
||||
});
|
||||
// واحدهای موجود (دیتابیس) + واحدهای تایپشده در سطرهای دیگر
|
||||
|
||||
const allUnits = useMemo(() => {
|
||||
const out = new Set(unitOptions);
|
||||
items.forEach((it) => { if (it.unit) out.add(it.unit); });
|
||||
return [...out];
|
||||
}, [items, unitOptions]);
|
||||
|
||||
// نامهای پایهی یکتای همهی متریالهای کاتالوگ سراسری (فقط خود متریال،
|
||||
// بدون برند/مشخصه — آنها ستونهای خودشان را دارند)
|
||||
const allMaterialNames = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
materials.forEach((m) => {
|
||||
@@ -134,7 +125,6 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
||||
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
|
||||
}, [materials]);
|
||||
|
||||
// برندهای ثبتشده در بخش برندها (بکاند) — بهعلاوهی برندهای متریالها
|
||||
const [brandOptions, setBrandOptions] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -146,11 +136,11 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
||||
list.forEach((u) => { if (u.name_fa || u.code) merged.add(u.name_fa || u.code); });
|
||||
return [...merged];
|
||||
}))
|
||||
.catch(() => { /* بکاند در دسترس نیست */ });
|
||||
.catch(() => {});
|
||||
void api
|
||||
.brandsList()
|
||||
.then((list) => alive && setBrandOptions(list.map((b) => b.name).filter(Boolean)))
|
||||
.catch(() => { /* بکاند در دسترس نیست */ });
|
||||
.catch(() => {});
|
||||
return () => { alive = false; };
|
||||
}, []);
|
||||
|
||||
@@ -163,14 +153,12 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
||||
};
|
||||
|
||||
const add = () => {
|
||||
// سطر جدید — مقدارها خالی؛ پیشنهادها از همهی سطرهای قبلی میآیند
|
||||
onItemsChange([
|
||||
...items,
|
||||
{ tempId: "it" + Date.now() + Math.random().toString(36).slice(2, 6), cat: "", brand: "", specs: [], qty: null, unit: "", price: null, total: null },
|
||||
]);
|
||||
};
|
||||
|
||||
// یادآوری یک فیلد تایپشده برای پیشنهادهای آینده (داخل تاپیک)
|
||||
const rememberField = (field: "i-cat" | "i-brand" | "i-spec", it: ItemDraft) => {
|
||||
if (!selectedTopic) return;
|
||||
let rec: MaterialRec;
|
||||
@@ -198,10 +186,10 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-1 text-[15px] font-bold">
|
||||
<span className="me-1 inline-flex h-6 w-6 items-center justify-center rounded-full bg-accent text-[12px] font-bold text-white">۲</span>
|
||||
<span className="me-1 inline-flex h-6 w-6 items-center justify-center rounded-full bg-blue-600 text-[12px] font-bold text-white">۲</span>
|
||||
اقلام سفارش
|
||||
</h3>
|
||||
<div className="mb-3 text-[12px] text-muted" dangerouslySetInnerHTML={{ __html: 'هر سطر یک <b>قلم</b> است (متریال + برند + مشخصه + واحد + تعداد + قیمت). متریال، برند و مشخصه را میتوان از گزینهها انتخاب کرد یا تایپ کرد تا بهعنوان گزینهی جدید اضافه شود. شرایط پرداخت هر قلم را در مرحلهی بعد مشخص میکنید.' }} />
|
||||
<div className="mb-3 text-[12px] text-gray-500" dangerouslySetInnerHTML={{ __html: 'هر سطر یک <b>قلم</b> است (متریال + برند + مشخصه + واحد + تعداد + قیمت). متریال، برند و مشخصه را میتوان از گزینهها انتخاب کرد یا تایپ کرد تا بهعنوان گزینهی جدید اضافه شود. شرایط پرداخت هر قلم را در مرحلهی بعد مشخص میکنید.' }} />
|
||||
|
||||
<div className="item-row wizard-item-row item-row-head mb-1 pe-7">
|
||||
<span>متریال</span>
|
||||
@@ -215,12 +203,10 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{items.map((it) => (
|
||||
<div key={it.tempId} className="item-row wizard-item-row">
|
||||
<div key={it.tempId} className="item-row wizard-item-row items-center">
|
||||
<Combobox
|
||||
value={it.cat}
|
||||
onChange={(x) => patch(it.tempId, { cat: x.label })}
|
||||
// پیشنهادها: همهی متریالهای کاتالوگ سراسری با نام واقعیشان
|
||||
// (بدون فیلتر تاپیک) — انتخابِ یک متریال موجود در ذخیره، همان را reuse میکند
|
||||
items={allMaterialNames}
|
||||
allowAdd
|
||||
addSuffix="به عنوان متریال جدید"
|
||||
@@ -263,30 +249,22 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
||||
<Combobox
|
||||
value={it.unit}
|
||||
onChange={(x) => patch(it.tempId, { unit: x.label })}
|
||||
// متنِ خودِ همین سطر از پیشنهادها حذف میشود؛ وگرنه متن تایپشده
|
||||
// «تطبیق دقیق» پیدا میکرد و گزینهی «به عنوان واحد جدید» هرگز ظاهر نمیشد
|
||||
items={allUnits.filter((u) => normalize(u) !== normalize(it.unit))}
|
||||
allowAdd
|
||||
addSuffix="به عنوان واحد جدید"
|
||||
placeholder="واحد"
|
||||
onInputChange={(v) => patch(it.tempId, { unit: v })}
|
||||
onAdd={(v) => {
|
||||
// انتخاب گزینهی «+ [واحد] به عنوان واحد جدید» → ثبت فوری در دیتابیس؛
|
||||
// نامِ تایپشده هم روی همان قلم اعمال میشود (onInputChange بالا)
|
||||
const name = v.trim();
|
||||
if (!name) return;
|
||||
void apiV26.catalog
|
||||
.createUnit(name)
|
||||
.then(() => {
|
||||
// بعد از ثبت، واحد برای سطرهای بعدی هم پیشنهاد شود
|
||||
setUnitOptions((prev) =>
|
||||
prev.some((u) => normalize(u) === normalize(name)) ? prev : [...prev, name],
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
// اگر واحدِ همنام در همین لحظه ساخته شده باشد (تداخل 409) یا شبکه
|
||||
// خطا دهد، هنگام ذخیرهی پیشنهاد دوباره resolve میشود — خطای بلوککننده نیست
|
||||
});
|
||||
.catch(() => {});
|
||||
}}
|
||||
inputClassName="!text-[12px] !h-9"
|
||||
className="min-w-0"
|
||||
@@ -315,16 +293,31 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
||||
placeholder="قیمت کل"
|
||||
showWords
|
||||
/>
|
||||
<button type="button" className="cursor-pointer text-muted hover:text-red" title="حذف" onClick={() => remove(it.tempId)}>
|
||||
×
|
||||
|
||||
{/* دکمه حذف مدرن با آیکون سطل زباله */}
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg text-gray-400 hover:bg-red-50 hover:text-red-500 transition-colors"
|
||||
title="حذف قلم"
|
||||
onClick={() => remove(it.tempId)}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button type="button" className="btn-line-file mt-2.5" onClick={add}>
|
||||
+ افزودن قلم
|
||||
{/* دکمه افزودن قلم با استایل برجسته و خطچین */}
|
||||
<button
|
||||
type="button"
|
||||
className="mt-3 flex w-full items-center justify-center gap-2 rounded-xl border-2 border-dashed border-blue-300 bg-blue-50/40 py-2.5 px-4 text-[13px] font-semibold text-blue-600 hover:border-blue-500 hover:bg-blue-100/70 transition-all duration-150"
|
||||
onClick={add}
|
||||
>
|
||||
<span className="text-base font-bold leading-none">+</span>
|
||||
<span>افزودن قلم جدید</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { Paperclip } from "lucide-react";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import { groupCommas } from "../../lib/numutil";
|
||||
import { JalaliDatePicker } from "../../components/JalaliDatePicker";
|
||||
import { FileUpload } from "../../components/FileUpload";
|
||||
|
||||
export interface Step5Props {
|
||||
quoteDate: string | null;
|
||||
@@ -52,7 +51,7 @@ export function Step5Review({
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-3 text-[15px] font-bold">
|
||||
<span className="me-1 inline-flex h-6 w-6 items-center justify-center rounded-full bg-accent text-[12px] font-bold text-white">۵</span>
|
||||
<span className="me-1 inline-flex h-6 w-6 items-center justify-center rounded-full bg-blue-600 text-[12px] font-bold text-white">۵</span>
|
||||
تاریخ، یادداشت و بازبینی نهایی
|
||||
</h3>
|
||||
|
||||
@@ -78,19 +77,43 @@ export function Step5Review({
|
||||
/>
|
||||
</div>
|
||||
<div className="field col-span-2">
|
||||
<label>پیشفاکتور (اختیاری)</label>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-700">پیشفاکتور (اختیاری)</label>
|
||||
{proforma ? (
|
||||
<div className="inv-row">
|
||||
<span className="inv-name">
|
||||
<Paperclip className="me-1 inline h-3.5 w-3.5" />
|
||||
{proforma.name}
|
||||
</span>
|
||||
<button type="button" className="inv-x" title="حذف" onClick={() => onProforma(null)}>
|
||||
×
|
||||
/* وضعیت فایل انتخابشده با دکمه حذف تمیز */
|
||||
<div className="flex items-center justify-between rounded-xl border border-gray-200 bg-gray-50/70 px-3 py-2 text-xs">
|
||||
<div className="flex items-center gap-2 truncate text-gray-700">
|
||||
<Paperclip className="h-4 w-4 text-blue-600 shrink-0" />
|
||||
<span className="font-medium truncate">{proforma.name}</span>
|
||||
<span className="text-[11px] text-gray-400 shrink-0">
|
||||
({Math.round(proforma.size / 1024)} کیلوبایت)
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-lg text-gray-400 hover:bg-red-50 hover:text-red-500 transition-colors"
|
||||
title="حذف فایل"
|
||||
onClick={() => onProforma(null)}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<FileUpload onUpload={onProforma} label="انتخاب فایل پیشفاکتور" />
|
||||
/* دکمه آپلود مینیمال با حاشیه خطچین ملایم */
|
||||
<label className="inline-flex cursor-pointer items-center gap-2 rounded-xl border border-dashed border-gray-300 bg-gray-50/50 px-4 py-2 text-xs font-medium text-gray-600 hover:border-blue-500 hover:bg-blue-50/40 hover:text-blue-600 transition-all">
|
||||
<Paperclip className="h-4 w-4 text-gray-400" />
|
||||
<span>انتخاب یا پیوست فایل پیشفاکتور</span>
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept=".pdf,.png,.jpg,.jpeg,.xlsx,.xls,.docx"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) onProforma(f);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -114,4 +137,4 @@ export function Step5Review({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { ChevronRight, ChevronLeft, Check } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import type { LineItem, Quote } from "../../lib/types";
|
||||
import { api, nextId } from "../../lib/api";
|
||||
@@ -73,18 +74,15 @@ export function WizardPage() {
|
||||
const [assignment, setAssignment] = useState<Record<string, string[]>>({});
|
||||
|
||||
const [quoteDate, setQuoteDate] = useState<string | null>(() => {
|
||||
// تاریخ پیشنهاد: همین الان (ساعت و دقیقهی فعلی) — نه نیمهشب
|
||||
return new Date().toISOString();
|
||||
});
|
||||
const [expiryDate, setExpiryDate] = useState<string | null>(null);
|
||||
const [notes, setNotes] = useState("");
|
||||
const [proforma, setProforma] = useState<File | null>(null);
|
||||
// دیالوگ ذخیره با فیلدهای خالی (نام فیلد + مرحلهی مربوطه)
|
||||
const [emptyFields, setEmptyFields] = useState<{ label: string; step: number }[]>([]);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
|
||||
// ---------- prefill از لینکها (?topic=&supplier=) ----------
|
||||
// وقتی suppliers هنوز لود نشده، دوباره اجرا میشود
|
||||
useEffect(() => {
|
||||
const t = params.get("topic");
|
||||
if (!t) return;
|
||||
@@ -102,23 +100,17 @@ export function WizardPage() {
|
||||
}, [params, suppliers, topics]);
|
||||
|
||||
// ---------- اعتبارسنجی قبل از ترک هر مرحله ----------
|
||||
// مراحل تکمیلشده را بر اساس دادههای فعلی مشخص میکند
|
||||
const completedSteps = useMemo(() => {
|
||||
const s = new Set<number>();
|
||||
// مرحله ۱: تاپیک انتخاب شده + نام تأمینکننده پر شده
|
||||
if (selectedTopic && supplierName.trim()) s.add(1);
|
||||
// مرحله ۲: حداقل یک قلم با تعداد و قیمت
|
||||
if (items.length > 0 && items.some((it) => it.qty != null && it.qty !== 0 && it.price != null && it.price !== 0)) s.add(2);
|
||||
// مرحله ۳: حداقل یک termsheet با مایلستون واقعی (تاریخ یا مبلغ) — نه فقط کارت پیشفرض
|
||||
if (
|
||||
cards.length > 0 &&
|
||||
cards.some((card) => (card.milestones || []).some((m) => m.when_iso || (m.amount != null && m.amount !== 0)))
|
||||
) {
|
||||
s.add(3);
|
||||
}
|
||||
// مرحله ۴: همه اقلام حداقل به یک پروژه اختصاص داده شده
|
||||
if (items.length > 0 && items.every((it) => (assignment[it.tempId] || []).length > 0)) s.add(4);
|
||||
// مرحله ۵: همیشه بازدیدشده محسوب میشود
|
||||
if (highestVisited >= 5) s.add(5);
|
||||
return s;
|
||||
}, [selectedTopic, supplierName, items, cards, coverage, assignment, highestVisited]);
|
||||
@@ -200,144 +192,133 @@ export function WizardPage() {
|
||||
}, [items, assignment, cards, coverage, supplierName, selectedTopic, isOfficial]);
|
||||
|
||||
// ---------- ذخیره ----------
|
||||
// تابع ذخیرهی اصلی — با یا بدون فیلدهای خالی (انتخاب کاربر در دیالوگ)
|
||||
const doSave = async () => {
|
||||
// خطای بخش — با دکمهی «رفع خطا» که به همان بخش میبرد
|
||||
const sectionError = (step: number, title: string, err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : "خطای ناشناخته";
|
||||
toast.error(`${title}: ${msg}`, {
|
||||
action: { label: "رفع خطا", onClick: () => goToStep(step) },
|
||||
});
|
||||
};
|
||||
// مشخص کردن بخش خطا از روی متن پیام
|
||||
const guessStep = (msg: string): number => {
|
||||
if (/تأمین|تامین|supplier/i.test(msg)) return 1;
|
||||
if (/متریال|واحد|قلم|خط آیتم|item|دسته/i.test(msg)) return 2;
|
||||
if (/ترم|termsheet|پرداخت|پوشش/i.test(msg)) return 3;
|
||||
if (/پروژه|project/i.test(msg)) return 4;
|
||||
return 5;
|
||||
};
|
||||
const sectionError = (step: number, title: string, err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : "خطای ناشناخته";
|
||||
toast.error(`${title}: ${msg}`, {
|
||||
action: { label: "رفع خطا", onClick: () => goToStep(step) },
|
||||
});
|
||||
};
|
||||
const guessStep = (msg: string): number => {
|
||||
if (/تأمین|تامین|supplier/i.test(msg)) return 1;
|
||||
if (/متریال|واحد|قلم|خط آیتم|item|دسته/i.test(msg)) return 2;
|
||||
if (/ترم|termsheet|پرداخت|پوشش/i.test(msg)) return 3;
|
||||
if (/پروژه|project/i.test(msg)) return 4;
|
||||
return 5;
|
||||
};
|
||||
|
||||
// تأمینکننده
|
||||
const name = supplierName.trim();
|
||||
let supplierId = "";
|
||||
if (name) {
|
||||
const exist = suppliers.find((s) => normalize(s.name) === normalize(name));
|
||||
if (exist) {
|
||||
supplierId = exist.id;
|
||||
} else {
|
||||
try {
|
||||
const created = await addSupplier.mutateAsync({
|
||||
id: nextId("s", suppliers),
|
||||
topic: selectedTopic,
|
||||
categories: [],
|
||||
name,
|
||||
location: newSup.location.trim(),
|
||||
phone: newSup.phone.trim(),
|
||||
contact: newSup.contact.trim(),
|
||||
});
|
||||
supplierId = created.id;
|
||||
} catch (err) {
|
||||
sectionError(1, "خطا در بخش تأمینکننده", err);
|
||||
return;
|
||||
}
|
||||
const name = supplierName.trim();
|
||||
let supplierId = "";
|
||||
if (name) {
|
||||
const exist = suppliers.find((s) => normalize(s.name) === normalize(name));
|
||||
if (exist) {
|
||||
supplierId = exist.id;
|
||||
} else {
|
||||
try {
|
||||
const created = await addSupplier.mutateAsync({
|
||||
id: nextId("s", suppliers),
|
||||
topic: selectedTopic,
|
||||
categories: [],
|
||||
name,
|
||||
location: newSup.location.trim(),
|
||||
phone: newSup.phone.trim(),
|
||||
contact: newSup.contact.trim(),
|
||||
});
|
||||
supplierId = created.id;
|
||||
} catch (err) {
|
||||
sectionError(1, "خطا در بخش تأمینکننده", err);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const collected: { li: LineItem; tempId: string }[] = [];
|
||||
for (const it of items) {
|
||||
const cat = it.cat.trim();
|
||||
const brand = it.brand.trim();
|
||||
const specs = it.specs;
|
||||
const unit = it.unit.trim();
|
||||
const qty = it.qty;
|
||||
const price = it.price;
|
||||
const total = it.total;
|
||||
if (!cat && !brand && qty == null && price == null) continue;
|
||||
|
||||
const wantName = [brand, cat, ...specs].filter(Boolean).join(" ") || "کالای جدید";
|
||||
let material = materials.find((m) => normalize(m.name) === normalize(wantName));
|
||||
if (!material) {
|
||||
material = materials.find(
|
||||
(m) =>
|
||||
normalize(m.category) === normalize(cat) &&
|
||||
normalize(m.brand) === normalize(brand) &&
|
||||
specsKey(m.specs) === specsKey(specs),
|
||||
);
|
||||
}
|
||||
let materialId: string;
|
||||
if (material) {
|
||||
materialId = material.id;
|
||||
} else {
|
||||
try {
|
||||
const created = await addMaterial.mutateAsync({
|
||||
id: nextId("m", materials),
|
||||
name: wantName,
|
||||
topic: selectedTopic,
|
||||
category: cat,
|
||||
brand,
|
||||
specs,
|
||||
base_unit: unit,
|
||||
});
|
||||
materialId = created.id;
|
||||
} catch (err) {
|
||||
sectionError(2, "خطا در بخش اقلام (متریال)", err);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// اقلام
|
||||
const collected: { li: LineItem; tempId: string }[] = [];
|
||||
for (const it of items) {
|
||||
const cat = it.cat.trim();
|
||||
const brand = it.brand.trim();
|
||||
const specs = it.specs;
|
||||
const unit = it.unit.trim();
|
||||
const qty = it.qty;
|
||||
const price = it.price;
|
||||
const total = it.total;
|
||||
if (!cat && !brand && qty == null && price == null) continue;
|
||||
|
||||
// جستجوی متریال: اول با نامِ کامل سراسری (دیتابیس) — بدون قید دسته/تاپیک،
|
||||
// چون دستهی ذخیرهشدهی متریالها نام تاپیک است نه نام تایپشدهی کاربر
|
||||
const wantName = [brand, cat, ...specs].filter(Boolean).join(" ") || "کالای جدید";
|
||||
let material = materials.find((m) => normalize(m.name) === normalize(wantName));
|
||||
if (!material) {
|
||||
// جستجوی قبلی (برند و مشخصهی ساختارمند در همین نشست)
|
||||
material = materials.find(
|
||||
(m) =>
|
||||
normalize(m.category) === normalize(cat) &&
|
||||
normalize(m.brand) === normalize(brand) &&
|
||||
specsKey(m.specs) === specsKey(specs),
|
||||
);
|
||||
}
|
||||
let materialId: string;
|
||||
if (material) {
|
||||
materialId = material.id;
|
||||
} else {
|
||||
try {
|
||||
const created = await addMaterial.mutateAsync({
|
||||
id: nextId("m", materials),
|
||||
name: wantName,
|
||||
topic: selectedTopic,
|
||||
category: cat,
|
||||
brand,
|
||||
specs,
|
||||
base_unit: unit,
|
||||
});
|
||||
materialId = created.id; // UUID واقعی از بکاند
|
||||
} catch (err) {
|
||||
sectionError(2, "خطا در بخش اقلام (متریال)", err);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
collected.push({
|
||||
li: {
|
||||
id: "li-" + (collected.length + 1),
|
||||
material_id: materialId,
|
||||
quantity: qty,
|
||||
unit,
|
||||
unit_price: price,
|
||||
total_price: total,
|
||||
termsheet_ids: [],
|
||||
accepted_ts_id: null,
|
||||
rejected: false,
|
||||
arrived_date: null,
|
||||
},
|
||||
tempId: it.tempId,
|
||||
});
|
||||
}
|
||||
|
||||
const lineItems = collected.map((c) => c.li);
|
||||
|
||||
// termsheetها
|
||||
const termsheets = cards
|
||||
.map((card, i) => ({
|
||||
id: "new-ts-" + (i + 1),
|
||||
label: "گزینه " + faNum(i + 1),
|
||||
milestones: card.milestones.filter((m) => m.when_iso || m.amount != null),
|
||||
status: "offered" as const,
|
||||
}))
|
||||
.filter((t) => t.milestones.length);
|
||||
|
||||
// اتصال اقلام به termsheetها
|
||||
collected.forEach(({ li, tempId }) => {
|
||||
const ids = cards
|
||||
.map((card, i) =>
|
||||
effectiveCovered(cards, coverage, items, card.key).includes(tempId) ? "new-ts-" + (i + 1) : "",
|
||||
)
|
||||
.filter(Boolean);
|
||||
const valid = ids.filter((tid) => termsheets.some((t) => t.id === tid));
|
||||
li.termsheet_ids = valid.length ? valid : termsheets.length ? [termsheets[0].id] : [];
|
||||
collected.push({
|
||||
li: {
|
||||
id: "li-" + (collected.length + 1),
|
||||
material_id: materialId,
|
||||
quantity: qty,
|
||||
unit,
|
||||
unit_price: price,
|
||||
total_price: total,
|
||||
termsheet_ids: [],
|
||||
accepted_ts_id: null,
|
||||
rejected: false,
|
||||
arrived_date: null,
|
||||
},
|
||||
tempId: it.tempId,
|
||||
});
|
||||
}
|
||||
|
||||
// پروژهها
|
||||
const projSet = new Set<string>();
|
||||
collected.forEach(({ li, tempId }) => {
|
||||
const list = assignment[tempId] || [];
|
||||
li.projects = list.map((p) => ({ id: "", name: p }));
|
||||
list.forEach((p) => projSet.add(p));
|
||||
});
|
||||
const projectsList = [...projSet];
|
||||
const lineItems = collected.map((c) => c.li);
|
||||
|
||||
const termsheets = cards
|
||||
.map((card, i) => ({
|
||||
id: "new-ts-" + (i + 1),
|
||||
label: "گزینه " + faNum(i + 1),
|
||||
milestones: card.milestones.filter((m) => m.when_iso || m.amount != null),
|
||||
status: "offered" as const,
|
||||
}))
|
||||
.filter((t) => t.milestones.length);
|
||||
|
||||
collected.forEach(({ li, tempId }) => {
|
||||
const ids = cards
|
||||
.map((card, i) =>
|
||||
effectiveCovered(cards, coverage, items, card.key).includes(tempId) ? "new-ts-" + (i + 1) : "",
|
||||
)
|
||||
.filter(Boolean);
|
||||
const valid = ids.filter((tid) => termsheets.some((t) => t.id === tid));
|
||||
li.termsheet_ids = valid.length ? valid : termsheets.length ? [termsheets[0].id] : [];
|
||||
});
|
||||
|
||||
const projSet = new Set<string>();
|
||||
collected.forEach(({ li, tempId }) => {
|
||||
const list = assignment[tempId] || [];
|
||||
li.projects = list.map((p) => ({ id: "", name: p }));
|
||||
list.forEach((p) => projSet.add(p));
|
||||
});
|
||||
const projectsList = [...projSet];
|
||||
|
||||
const quoteIso = quoteDate || new Date().toISOString();
|
||||
const quote: Quote = {
|
||||
@@ -361,7 +342,6 @@ export function WizardPage() {
|
||||
|
||||
try {
|
||||
const created = await createQuote.mutateAsync(quote);
|
||||
// آپلود پیشفاکتور به MinIO بعد از ساخت پیشنهاد
|
||||
if (proforma) {
|
||||
try {
|
||||
await api.files.upload(created.id, "proforma", proforma);
|
||||
@@ -379,7 +359,6 @@ export function WizardPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// ذخیره — اگر بخشی ناقص است، دیالوگ تأیید با لینک فیلدهای خالی باز میشود
|
||||
const save = () => {
|
||||
const incomplete: { label: string; step: number }[] = [];
|
||||
if (!completedSteps.has(1)) incomplete.push({ label: "تأمینکننده", step: 1 });
|
||||
@@ -475,19 +454,54 @@ export function WizardPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* پاورقی ویزارد */}
|
||||
<div className="mt-4 flex items-center gap-2 rounded-xl border border-line bg-panel p-3 shadow-sm">
|
||||
{currentStep > 1 && (
|
||||
<Button variant="slate" onClick={() => goToStep(currentStep - 1)}>
|
||||
‹ قبلی
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<Link to="/" className="btn-line-file no-underline">
|
||||
انصراف
|
||||
</Link>
|
||||
{currentStep < TOTAL_STEPS && <Button onClick={next}>بعدی ›</Button>}
|
||||
{currentStep === TOTAL_STEPS && <Button onClick={() => void save()}>ذخیره پیشنهاد</Button>}
|
||||
{/* پاورقی ویزارد — با آیکونهای استاندارد و جهتگیری دقیق RTL */}
|
||||
<div className="mt-5 flex items-center justify-between rounded-xl border border-line bg-panel p-3.5 shadow-sm">
|
||||
{/* دکمه مرحله قبلی (فلش رو به راست = بازگشت در فارسی) */}
|
||||
<div>
|
||||
{currentStep > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => goToStep(currentStep - 1)}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl border border-slate-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-slate-700 dark:text-zinc-200 hover:bg-slate-50 dark:hover:bg-zinc-700 text-sm font-medium transition-colors shadow-xs cursor-pointer"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
<span>قبلی</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* دکمههای اقدام (انصراف + بعدی / ذخیره) */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex items-center justify-center px-4 py-2 rounded-xl text-sm font-medium text-slate-500 hover:text-slate-700 hover:bg-slate-100 dark:text-zinc-400 dark:hover:text-zinc-200 dark:hover:bg-zinc-800 transition-colors no-underline"
|
||||
>
|
||||
انصراف
|
||||
</Link>
|
||||
|
||||
{/* دکمه مرحله بعدی (فلش رو به چپ = پیشروی در فارسی) */}
|
||||
{currentStep < TOTAL_STEPS && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={next}
|
||||
className="inline-flex items-center gap-1.5 px-6 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-sm font-semibold shadow-sm hover:shadow transition-all duration-150 cursor-pointer"
|
||||
>
|
||||
<span>بعدی</span>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{currentStep === TOTAL_STEPS && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void save()}
|
||||
className="inline-flex items-center gap-1.5 px-6 py-2 rounded-xl bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white text-sm font-semibold shadow-sm hover:shadow transition-all duration-150 cursor-pointer"
|
||||
>
|
||||
<span>ذخیره پیشنهاد</span>
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* دیالوگ ذخیره با فیلدهای خالی */}
|
||||
@@ -539,4 +553,4 @@ export function WizardPage() {
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user