Compare commits
27
Commits
v1.4
...
a542e4177e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a542e4177e | ||
|
|
c454b03b4d | ||
|
|
50f8d8e64e | ||
|
|
3b032d0460 | ||
|
|
f263cfb9e3 | ||
|
|
4609940856 | ||
|
|
3c3880aec9 | ||
|
|
545b5dd473 | ||
|
|
7528cc3bbe | ||
|
|
b45a6cd033 | ||
|
|
f0e424316e | ||
|
|
c10c10a425 | ||
|
|
f8deb0c6b9 | ||
|
|
c64718ef70 | ||
|
|
529eb67d01 | ||
|
|
eb1043cc3a | ||
|
|
dbe53a727c | ||
|
|
58ff8dc496 | ||
|
|
8e2afcec59 | ||
|
|
e8154ae6a6 | ||
|
|
7f31fb5105 | ||
|
|
468ce64d29 | ||
|
|
fd1831b3fc | ||
|
|
1f123a0a05 | ||
|
|
4dd60d7cd9 | ||
|
|
3bdb0027f3 | ||
|
|
c5bbbbbaaa |
@@ -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
|
||||
|
||||
@@ -160,27 +160,31 @@ async def update_material(db: AsyncSession, material: Material, payload: Materia
|
||||
|
||||
v2.5/2.6: category is DERIVED (from the first linked supplier) — it is
|
||||
no longer part of the update payload.
|
||||
|
||||
NOTE: no ``db.begin()`` here — the router already starts the transaction
|
||||
(it loads the row first), and a nested begin raises
|
||||
"A transaction is already begun on this Session" (500). The request
|
||||
session commits via the get_db dependency.
|
||||
"""
|
||||
async with db.begin():
|
||||
if payload.name is not None:
|
||||
duplicate = await db.scalar(
|
||||
select(Material.id).where(
|
||||
Material.name == payload.name,
|
||||
Material.id != material.id,
|
||||
)
|
||||
if payload.name is not None:
|
||||
duplicate = await db.scalar(
|
||||
select(Material.id).where(
|
||||
Material.name == payload.name,
|
||||
Material.id != material.id,
|
||||
)
|
||||
if duplicate is not None:
|
||||
raise ConflictError("متریالی با این نام در کل کاتالوگ قبلاً ثبت شده است", field="name")
|
||||
if payload.name is not None:
|
||||
material.name = payload.name
|
||||
if payload.topic is not None:
|
||||
material.topic = payload.topic
|
||||
if payload.canonical_unit_id is not None and payload.canonical_unit_id != material.canonical_unit_id:
|
||||
if not await db.get(Unit, payload.canonical_unit_id):
|
||||
raise NotFoundError("واحد مرجع یافت نشد", field="canonical_unit_id")
|
||||
material.canonical_unit_id = payload.canonical_unit_id
|
||||
if payload.status is not None:
|
||||
material.status = payload.status
|
||||
)
|
||||
if duplicate is not None:
|
||||
raise ConflictError("متریالی با این نام در کل کاتالوگ قبلاً ثبت شده است", field="name")
|
||||
if payload.name is not None:
|
||||
material.name = payload.name
|
||||
if payload.topic is not None:
|
||||
material.topic = payload.topic
|
||||
if payload.canonical_unit_id is not None and payload.canonical_unit_id != material.canonical_unit_id:
|
||||
if not await db.get(Unit, payload.canonical_unit_id):
|
||||
raise NotFoundError("واحد مرجع یافت نشد", field="canonical_unit_id")
|
||||
material.canonical_unit_id = payload.canonical_unit_id
|
||||
if payload.status is not None:
|
||||
material.status = payload.status
|
||||
return material
|
||||
|
||||
|
||||
|
||||
@@ -416,6 +416,9 @@ async def accept_quote(db: AsyncSession, quote_id: UUID, payload: AcceptQuoteIn,
|
||||
|
||||
# --- process item decisions (UPSERT) ---
|
||||
chosen_ts_ids: set[UUID] = set()
|
||||
# پروژههای ارسالی در payload.item_projects — منبع اصلی برای
|
||||
# decision.project_id (مدیر تدارکات برای هر قلم یک پروژه انتخاب میکند)
|
||||
proj_by_line: dict[UUID, list[UUID]] = {e.line_item_id: list(e.project_ids) for e in payload.item_projects}
|
||||
for decision_in in payload.item_decisions:
|
||||
decision = await db.scalar(
|
||||
select(ItemDecision).where(ItemDecision.line_item_id == decision_in.line_item_id).with_for_update()
|
||||
@@ -460,22 +463,31 @@ async def accept_quote(db: AsyncSession, quote_id: UUID, payload: AcceptQuoteIn,
|
||||
decision.adjusted_unit_price = decision_in.adjusted_unit_price
|
||||
decision.adjusted_notes = decision_in.adjusted_notes
|
||||
decision.decided_at = utcnow()
|
||||
# v2.6: the SINGLE purchased project (proc manager picks it;
|
||||
# auto-filled when the offer had exactly one project).
|
||||
# v2.6: the SINGLE purchased project (proc manager picks it).
|
||||
# Derivation order:
|
||||
# 1) explicit decision_in.project_id
|
||||
# 2) payload.item_projects for this line item (exactly one)
|
||||
# 3) existing line_item_projects (exactly one)
|
||||
# otherwise NULL — the item stays visible only to the office
|
||||
# until a project is assigned (never wrongly visible to a PM).
|
||||
if decision_in.project_id is not None:
|
||||
decision.project_id = decision_in.project_id
|
||||
else:
|
||||
line_projs = (
|
||||
(await db.execute(
|
||||
select(LineItemProjects.project_id).where(
|
||||
LineItemProjects.line_item_id == decision_in.line_item_id
|
||||
)
|
||||
))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
if len(line_projs) == 1:
|
||||
decision.project_id = line_projs[0]
|
||||
accept_projs = proj_by_line.get(decision_in.line_item_id) or []
|
||||
if len(accept_projs) == 1:
|
||||
decision.project_id = accept_projs[0]
|
||||
else:
|
||||
line_projs = (
|
||||
(await db.execute(
|
||||
select(LineItemProjects.project_id).where(
|
||||
LineItemProjects.line_item_id == decision_in.line_item_id
|
||||
)
|
||||
))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
if len(line_projs) == 1:
|
||||
decision.project_id = line_projs[0]
|
||||
if decision.project_id is not None:
|
||||
if await db.get(Project, decision.project_id) is None:
|
||||
raise NotFoundError("پروژه یافت نشد", field="item_decisions.project_id")
|
||||
|
||||
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
|
||||
+8
-1
@@ -34,7 +34,14 @@ server {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Static assets cache
|
||||
# index.html itself must never be cached: after a redeploy the hashed
|
||||
# /assets/* names change, and a stale cached HTML would 404 on the old
|
||||
# chunks (blank page). Content-hashed assets below stay immutable.
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
}
|
||||
|
||||
# Static assets cache — hashed filenames are content-addressed
|
||||
location /assets/ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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)
|
||||
const DashboardPage = lazy(() =>
|
||||
@@ -66,7 +66,7 @@ function HomeRoute() {
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<>
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<Loading />}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
@@ -196,7 +196,6 @@ export default function App() {
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
<MobileTabBar />
|
||||
</>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
// خطای رندر/لود ماژول نباید کل DOM را خالی کند — با این باکس، بهجای صفحهی
|
||||
// سفید، پیام + دکمهی «تلاش مجدد» (بارگذاری مجدد صفحه) نمایش داده میشود.
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
state: State = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error("ErrorBoundary caught:", error, info);
|
||||
}
|
||||
|
||||
private retry = () => {
|
||||
this.setState({ error: null });
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
render() {
|
||||
if (!this.state.error) return this.props.children;
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-bg px-6 text-center text-ink">
|
||||
<div className="text-4xl">⚠️</div>
|
||||
<h1 className="text-lg font-bold">خطایی در نمایش صفحه رخ داد</h1>
|
||||
<p className="max-w-md text-sm text-muted">
|
||||
{this.state.error.message || "مشکلی پیش آمد — لطفاً دوباره تلاش کنید."}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={this.retry}
|
||||
className="cursor-pointer rounded-lg bg-accent px-5 py-2.5 text-sm font-bold text-white transition-colors hover:bg-accent-dark"
|
||||
>
|
||||
تلاش مجدد
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -39,10 +39,17 @@ function buildIso(jy: number, jm: number, jd: number, h: number, m: number): str
|
||||
return new Date(gy, gm - 1, gd, h, m).toISOString();
|
||||
}
|
||||
|
||||
// وقتی هنوز زمان مشخصی ست نشده، ساعت/دقیقهی فعلی سیستم پیشفرض است
|
||||
// (نه ۰۰:۰۰) — دکمهی «امروز» و انتخاب روز، همین زمان را نگه میدارند.
|
||||
function nowTime(): { h: number; m: number } {
|
||||
const d = new Date();
|
||||
return { h: d.getHours(), m: d.getMinutes() };
|
||||
}
|
||||
|
||||
function timeOf(iso: string | null): { h: number; m: number } {
|
||||
if (!iso) return { h: 0, m: 0 };
|
||||
if (!iso) return nowTime();
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return { h: 0, m: 0 };
|
||||
if (Number.isNaN(d.getTime())) return nowTime();
|
||||
return { h: d.getHours(), m: d.getMinutes() };
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
+162
-22
@@ -357,6 +357,57 @@ interface RawMaterialCategory {
|
||||
name: string;
|
||||
}
|
||||
|
||||
// یک ردیف material_units از بکاند (با expand=unit)
|
||||
interface RawMaterialUnit {
|
||||
id: string;
|
||||
material_id: string;
|
||||
brand_material_id: string | null;
|
||||
unit_id: number;
|
||||
conversion_factor: number;
|
||||
is_default: boolean;
|
||||
unit?: { id: number; code: string; name_fa: string | null } | null;
|
||||
}
|
||||
|
||||
export interface MaterialUnitRow {
|
||||
id: string;
|
||||
material_id: string;
|
||||
brand_material_id: string | null;
|
||||
unit_id: number;
|
||||
conversion_factor: number;
|
||||
is_default: boolean;
|
||||
unit: { id: number; code: string; name_fa: string | null } | null;
|
||||
}
|
||||
|
||||
function adaptMaterialUnit(raw: RawMaterialUnit): MaterialUnitRow {
|
||||
return {
|
||||
id: raw.id,
|
||||
material_id: raw.material_id,
|
||||
brand_material_id: raw.brand_material_id,
|
||||
unit_id: raw.unit_id,
|
||||
conversion_factor: raw.conversion_factor,
|
||||
is_default: raw.is_default,
|
||||
unit: raw.unit ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// اتصال برند ↔ متریال (brand_materials) برای قید برند در ضریبهای تبدیل
|
||||
interface RawBrandLink {
|
||||
id: string;
|
||||
brand_id: string;
|
||||
material_id: string;
|
||||
brand?: { id: string; name: string } | null;
|
||||
}
|
||||
|
||||
export interface BrandLinkRow {
|
||||
id: string;
|
||||
brand_id: string;
|
||||
brand_name: string;
|
||||
}
|
||||
|
||||
function adaptBrandLink(raw: RawBrandLink): BrandLinkRow {
|
||||
return { id: raw.id, brand_id: raw.brand_id, brand_name: raw.brand?.name || "" };
|
||||
}
|
||||
|
||||
// فایل پیوست — پاسخ بکاند (metadata؛ بایتها از endpoint دانلود میآیند)
|
||||
interface RawAttachment {
|
||||
id: string;
|
||||
@@ -703,10 +754,11 @@ export const api = {
|
||||
quantity: li.quantity ?? 1,
|
||||
project_ids: [],
|
||||
}));
|
||||
// واحدهایی که در بکاند نیستند را بساز (issue: واحد جدید) — واحد خالی = «عدد»
|
||||
// واحدِ تایپشدهی کاربر در بکاند نیست → همان موقع ساخته میشود (بدون هیچ
|
||||
// مقدار پیشفرض/واحد جادویی؛ واحد خالی یعنی خطای صریح «واحد مشخص نشده»)
|
||||
// پروژههای هر قلم هم به id واقعی ترجمه میشوند (در صورت نبودن ساخته میشوند)
|
||||
for (let i = 0; i < lineItems.length; i++) {
|
||||
lineItems[i].unit_id = await unitIdByNameOrCreate(lookup, q.line_items[i].unit || "عدد");
|
||||
lineItems[i].unit_id = await unitIdByNameOrCreate(lookup, q.line_items[i].unit);
|
||||
for (const p of q.line_items[i].projects || []) {
|
||||
const pid = looksLikeUuid(p.id) ? p.id : await projectIdByNameOrCreate(p.name);
|
||||
if (pid) lineItems[i].project_ids.push(pid);
|
||||
@@ -822,10 +874,16 @@ export const api = {
|
||||
item_decisions: (patch.line_items || []).map((li) => {
|
||||
const idx = li.accepted_ts_id ? acceptedTs.findIndex((t) => t.id === li.accepted_ts_id) : -1;
|
||||
const chosen = !li.rejected && idx >= 0;
|
||||
// پروژهی واحدِ انتخابشده برای این قلم — مستقیم داخل decision میرود تا
|
||||
// «کالاهای در راه» همان مدیر پروژه را (با item_decisions.project_id)
|
||||
// نشان دهد؛ قبلاً فقط item_projects جداگانه میرفت و decision بدون
|
||||
// پروژه میماند (قلمها در پنل هیچ مدیر پروژهای دیده نمیشدند)
|
||||
const chosenProjId = itemProjects.find((p) => p.line_item_id === li.id)?.project_ids[0];
|
||||
return {
|
||||
line_item_id: li.id,
|
||||
status: chosen ? "chosen" : "rejected",
|
||||
...(chosen ? { accepted_termsheet_index: idx } : {}),
|
||||
...(chosen && chosenProjId ? { project_id: chosenProjId } : {}),
|
||||
...(li.unit_price != null ? { adjusted_unit_price: li.unit_price } : {}),
|
||||
};
|
||||
}),
|
||||
@@ -959,19 +1017,18 @@ export const api = {
|
||||
},
|
||||
create: async (m: Material): Promise<Material> => {
|
||||
const lookup = await fetchLookup();
|
||||
// v2.5: category_id اختیاری است — اگر نامِ تایپشده یکی از ۸ دستهی ثابت
|
||||
// نباشد، متریال بهصورت سراسری (بدون دسته) ساخته میشود و دستهاش بعداً
|
||||
// از اولین تأمینکنندهی مرتبط استنتاج میشود (first-link-wins).
|
||||
// دستهی rogue دیگر هرگز ساخته نمیشود — فقط تطبیق با ۸ دستهی موجود؛
|
||||
// در صورت نبود تطبیق، خطا نمیدهیم و category_id را null میفرستیم.
|
||||
const categoryId = m.category ? lookup.categoryNameToId.get(norm(m.category)) : undefined;
|
||||
// دستهها همان ۸ دستهی ثابت سیستماند و هرگز بهصورت خودکار ساخته
|
||||
// نمیشوند — قبلاً اینجا برای هر نامِ ناشناخته یک رکورد material_categories
|
||||
// میساخت و باگ «پریز» (دستهی rogue) را به وجود آورد.
|
||||
if (!categoryId) {
|
||||
throw new Error(
|
||||
m.category
|
||||
? `دسته «${m.category}» در سامانه تعریف نشده است — یکی از ۸ دستهی موجود را انتخاب کنید`
|
||||
: "دستهی متریال مشخص نیست — یکی از ۸ دستهی موجود را انتخاب کنید",
|
||||
);
|
||||
// واحد مرجع — بدون مقدار پیشفرض؛ متریال بدون واحدِ صریح ساخته نمیشود
|
||||
// (بکاند canonical_unit_id را الزامی میداند)
|
||||
const unitName = (m.base_unit || "").trim();
|
||||
if (!unitName) {
|
||||
throw new Error("واحد مرجع متریال مشخص نشده است — واحد را وارد کنید");
|
||||
}
|
||||
// واحد مرجع — اگر تعریف نشده، بساز (بکاند canonical_unit_id الزامی دارد)
|
||||
const unitName = m.base_unit || "عدد";
|
||||
const unitId = await unitIdByNameOrCreate(lookup, unitName);
|
||||
// برند — اگر تعریف نشده، در جدول brands ساخته و به متریال متصل میشود
|
||||
const brandId = await brandIdByNameOrCreate(lookup, m.brand);
|
||||
@@ -988,7 +1045,7 @@ export const api = {
|
||||
if (defId) attrs.push({ attribute_definition_id: defId, value: spec });
|
||||
}
|
||||
const raw = await postData<RawMaterial>("/materials", {
|
||||
category_id: categoryId,
|
||||
category_id: categoryId ?? null,
|
||||
name,
|
||||
topic: m.topic || null,
|
||||
canonical_unit_id: unitId,
|
||||
@@ -1000,6 +1057,10 @@ export const api = {
|
||||
tempIdToRealId.set(m.id, raw.id);
|
||||
return adaptMaterial(raw, lookup);
|
||||
},
|
||||
// تغییر واحد مرجع متریال (صفحهی ضرایب تبدیل)
|
||||
updateCanonicalUnit: async (id: string, canonical_unit_id: number): Promise<void> => {
|
||||
await patchData(`/materials/${id}`, { canonical_unit_id });
|
||||
},
|
||||
},
|
||||
|
||||
projects: {
|
||||
@@ -1071,6 +1132,52 @@ export const api = {
|
||||
},
|
||||
},
|
||||
|
||||
// ---------- واحدهای هر متریال (material_units — ضریب تبدیل نسبت به واحد مرجع) ----------
|
||||
materialUnits: {
|
||||
list: async (materialId: string): Promise<MaterialUnitRow[]> => {
|
||||
const data = await getData<RawMaterialUnit[]>(
|
||||
`/materials/${materialId}/units?page_size=100&expand=unit,brand_material`,
|
||||
);
|
||||
return data.map(adaptMaterialUnit);
|
||||
},
|
||||
create: async (
|
||||
materialId: string,
|
||||
body: {
|
||||
brand_material_id?: string | null;
|
||||
unit_id: number;
|
||||
conversion_factor: number;
|
||||
is_default?: boolean;
|
||||
},
|
||||
): Promise<MaterialUnitRow> => {
|
||||
const raw = await postData<RawMaterialUnit>(`/materials/${materialId}/units`, body);
|
||||
return adaptMaterialUnit(raw);
|
||||
},
|
||||
update: async (
|
||||
materialId: string,
|
||||
muId: string,
|
||||
body: { conversion_factor?: number; is_default?: boolean },
|
||||
): Promise<MaterialUnitRow> => {
|
||||
const raw = await patchData<RawMaterialUnit>(`/materials/${materialId}/units/${muId}`, body);
|
||||
return adaptMaterialUnit(raw);
|
||||
},
|
||||
remove: async (materialId: string, muId: string): Promise<void> => {
|
||||
await deleteData(`/materials/${materialId}/units/${muId}`);
|
||||
},
|
||||
},
|
||||
|
||||
// ---------- اتصال برند↔متریال (برای قید برند در ضریبهای تبدیل) ----------
|
||||
brandMaterials: {
|
||||
listByMaterial: async (materialId: string): Promise<BrandLinkRow[]> => {
|
||||
const data = await getData<RawBrandLink[]>(
|
||||
`/brand-materials?material_id=${materialId}&page_size=100&expand=brand`,
|
||||
);
|
||||
return data.map(adaptBrandLink);
|
||||
},
|
||||
link: async (materialId: string, brandId: string): Promise<void> => {
|
||||
await postData("/brand-materials", { brand_id: brandId, material_id: materialId });
|
||||
},
|
||||
},
|
||||
|
||||
// ---------- پیوستها (invoice / proforma) — بایتها در MinIO ----------
|
||||
files: {
|
||||
upload: (quoteId: string, kind: FileKind, file: File): Promise<Attachment> =>
|
||||
@@ -1151,7 +1258,7 @@ async function buildChildrenPayload(
|
||||
project_ids: [],
|
||||
}));
|
||||
for (let i = 0; i < lis.length; i++) {
|
||||
lis[i].unit_id = await unitIdByNameOrCreate(lookup, lineItems[i].unit || "عدد");
|
||||
lis[i].unit_id = await unitIdByNameOrCreate(lookup, lineItems[i].unit);
|
||||
for (const p of lineItems[i].projects || []) {
|
||||
const pid = looksLikeUuid(p.id) ? p.id : await projectIdByNameOrCreate(p.name);
|
||||
if (pid) lis[i].project_ids.push(pid);
|
||||
@@ -1180,20 +1287,23 @@ async function buildChildrenPayload(
|
||||
return { line_items: lis, termsheets: tss, item_termsheets: itemTerms };
|
||||
}
|
||||
|
||||
// واحد را پیدا میکند؛ اگر وجود نداشت، در بکاند میسازد و id برمیگرداند
|
||||
// واحد را پیدا میکند؛ اگر وجود نداشت (با نامِ دقیقِ تایپشدهی کاربر)، در
|
||||
// بکاند میسازد و id برمیگرداند. هیچ واحد پیشفرضی تزریق نمیشود — نام
|
||||
// خالی/بیمعنی یعنی خطای صریح.
|
||||
async function unitIdByNameOrCreate(lookup: Lookup, name: string): Promise<number> {
|
||||
if (!name) throw new Error("واحد قلم مشخص نشده است");
|
||||
const existing = lookup.unitNameToId.get(norm(name));
|
||||
const key = norm(name);
|
||||
if (!key) throw new Error("واحد قلم مشخص نشده است — واحد را وارد کنید");
|
||||
const existing = lookup.unitNameToId.get(key);
|
||||
if (existing != null) return existing;
|
||||
// واحد جدید — ساخت در دیتابیس (code = نام نرمالشده)
|
||||
// واحد جدید — ساخت در دیتابیس (code = نام نرمالشدهی همان واحد تایپشده)
|
||||
const raw = await postData<RawUnit>("/units", {
|
||||
code: norm(name) || "unit",
|
||||
name_fa: name,
|
||||
code: key,
|
||||
name_fa: name.trim(),
|
||||
dimension: null,
|
||||
si_factor: 1,
|
||||
is_base: false,
|
||||
});
|
||||
lookup.unitNameToId.set(norm(name), raw.id);
|
||||
lookup.unitNameToId.set(key, raw.id);
|
||||
return raw.id;
|
||||
}
|
||||
|
||||
@@ -1490,4 +1600,34 @@ export const apiV26 = {
|
||||
postData("/pm/receipts", body),
|
||||
receipts: async (): Promise<ConfirmedReceipt[]> => getData<ConfirmedReceipt[]>("/pm/receipts"),
|
||||
},
|
||||
|
||||
// ساخت موجودیتهای کاتالوگ سراسری از داخل پنل انبار (موجودی اولیه):
|
||||
// متریال/برند/واحد جدید بدون وابستگی به دفتر تدارکات ساخته میشوند.
|
||||
catalog: {
|
||||
createUnit: async (name: string): Promise<{ id: number; code: string; name_fa: string | null }> => {
|
||||
const key = norm(name);
|
||||
if (!key) throw new Error("نام واحد معتبر نیست");
|
||||
return postData("/units", {
|
||||
code: key,
|
||||
name_fa: name.trim(),
|
||||
dimension: null,
|
||||
si_factor: 1,
|
||||
is_base: false,
|
||||
});
|
||||
},
|
||||
createBrand: async (name: string): Promise<{ id: string; name: string }> =>
|
||||
postData("/brands", { name, country: null }),
|
||||
createMaterial: async (opts: {
|
||||
name: string; canonical_unit_id: number; brand_ids: string[];
|
||||
}): Promise<{ id: string; name: string }> =>
|
||||
postData("/materials", {
|
||||
name: opts.name,
|
||||
category_id: null, // دسته از اولین تأمینکنندهی مرتبط استنتاج میشود (v2.5)
|
||||
topic: null,
|
||||
canonical_unit_id: opts.canonical_unit_id,
|
||||
status: "active",
|
||||
brand_ids: opts.brand_ids,
|
||||
attributes: [],
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -40,6 +40,22 @@ export function specsLabel(specs: string[]): string {
|
||||
return specs.join(" · ");
|
||||
}
|
||||
|
||||
// نام نمایشی متریال — نام سراسری واقعی (materials.name)، نه دسته.
|
||||
// برند و مشخصه فقط وقتی به نام اضافه میشوند که داخلش نیامده باشند (بدون تکرار).
|
||||
export function materialLabel(m: Pick<Material, "name" | "brand" | "specs" | "category">): string {
|
||||
const name = (m.name || "").trim();
|
||||
const specs = (m.specs || []).filter(Boolean);
|
||||
if (!name && !m.brand && !specs.length) return m.category === "؟" ? "؟" : "کالا";
|
||||
if (!name) {
|
||||
return [m.brand, ...specs].filter(Boolean).join(" ") || "کالا";
|
||||
}
|
||||
const parts = [name];
|
||||
if (m.brand && !name.includes(m.brand)) parts.push(m.brand);
|
||||
const missing = specs.filter((s) => !name.includes(s));
|
||||
if (missing.length) parts.push(`(${missing.join("، ")})`);
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
// آرایهی termsheet_ids یک قلم (هر دو فرمت قدیم/جدید)
|
||||
export function itemTermsheetIds(li: LineItem): string[] {
|
||||
if (li.termsheet_ids) return li.termsheet_ids;
|
||||
@@ -157,8 +173,7 @@ export function projectCell(q: Quote): string {
|
||||
export function itemsSummary(store: Store, q: Quote): { text: string; count: number } {
|
||||
const items = activeItems(q).map((li) => {
|
||||
const m = materialById(store.materials, li.material_id);
|
||||
const specs = materialSpecs(m);
|
||||
return `${m.category} ${m.brand}${specs.length ? " " + specs.join(" · ") : ""}`;
|
||||
return materialLabel(m);
|
||||
});
|
||||
return { text: items.join("، "), count: items.length };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { normalize } from "./normalize";
|
||||
|
||||
export interface UnitLike {
|
||||
id: number;
|
||||
code: string;
|
||||
name_fa: string | null;
|
||||
}
|
||||
|
||||
export function unitDisplayName(u: UnitLike): string {
|
||||
return u.name_fa || u.code;
|
||||
}
|
||||
|
||||
// واحدها با «نام نمایشی» یکتا میشوند: وقتی دو ردیف نام فارسی یکسان دارند
|
||||
// (مثلاً ردیف سفارشی «عدد» و ردیف پایهی piece/عدد)، ردیفی که کد استاندارد
|
||||
// دارد (code ≠ نام فارسی، مثل kg یا piece) ارجح است.
|
||||
export function uniqueUnits(units: UnitLike[]): UnitLike[] {
|
||||
const best = new Map<string, UnitLike>();
|
||||
units.forEach((u) => {
|
||||
const display = unitDisplayName(u);
|
||||
const key = normalize(display);
|
||||
if (!key) return;
|
||||
const cur = best.get(key);
|
||||
const isCanonical = u.code !== display;
|
||||
if (!cur || (isCanonical && cur.code === display)) best.set(key, u);
|
||||
});
|
||||
return [...best.values()].sort((a, b) => unitDisplayName(a).localeCompare(unitDisplayName(b), "fa"));
|
||||
}
|
||||
@@ -7,6 +7,14 @@ import App from "./App";
|
||||
import "./styles/globals.css";
|
||||
import { queryClient } from "./lib/queryClient";
|
||||
|
||||
// بعد از استقرار نسخهی جدید، مرورگری که index.html قدیمی را باز کرده برای
|
||||
// بارگذاری chunkهای حذفشده 404 میگیرد و صفحه خالی میماند. این شنونده،
|
||||
// همان لحظه صفحه را دوباره بارگذاری میکند تا نسخهی تازه (با نام فایلهای
|
||||
// درست) گرفته شود.
|
||||
window.addEventListener("vite:preloadError", () => {
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
activeItems,
|
||||
itemTermsheetIds,
|
||||
materialById,
|
||||
materialLabel,
|
||||
materialSpecs,
|
||||
specsLabel,
|
||||
supplierById,
|
||||
@@ -24,7 +25,6 @@ import {
|
||||
import { MilestoneRow } from "../../components/MilestoneRow";
|
||||
import { FileList } from "../../components/FileList";
|
||||
import { JalaliDatePicker } from "../../components/JalaliDatePicker";
|
||||
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
|
||||
|
||||
interface CardDraft {
|
||||
domId: string;
|
||||
@@ -54,7 +54,7 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
const [removedItems, setRemovedItems] = useState<Set<string>>(new Set());
|
||||
const [assignments, setAssignments] = useState<Record<string, string>>({});
|
||||
// تخصیص هر قلم به پروژهها (نامها)
|
||||
const [itemProjectsSel, setItemProjectsSel] = useState<Record<string, string[]>>({});
|
||||
const [itemProjectsSel, setItemProjectsSel] = useState<Record<string, string>>({});
|
||||
// تاریخ و زمان پذیرش — پیشفرض الان
|
||||
const [acceptDate, setAcceptDate] = useState<string | null>(() => new Date().toISOString());
|
||||
const [ready, setReady] = useState(false);
|
||||
@@ -72,17 +72,15 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
});
|
||||
setRemovedItems(rem);
|
||||
setAssignments({});
|
||||
// پروژههای هر قلم — از تخصیصهای قبلی، وگرنه پروژههای سطح پیشنهاد
|
||||
const projSel: Record<string, string[]> = {};
|
||||
// پروژهی هر قلم — تخصیصِ تکی: از تخصیص قبلی قلم، وگرنه اولین پروژهی پیشنهاد
|
||||
const projSel: Record<string, string> = {};
|
||||
q.line_items.forEach((li) => {
|
||||
const names = (li.projects || []).map((p) => p.name).filter(Boolean);
|
||||
projSel[li.id] = names.length
|
||||
? names
|
||||
? names[0]
|
||||
: q.projects && q.projects.length
|
||||
? [...q.projects]
|
||||
: q.project
|
||||
? [q.project]
|
||||
: [];
|
||||
? q.projects[0]
|
||||
: q.project || "";
|
||||
});
|
||||
setItemProjectsSel(projSel);
|
||||
setReady(true);
|
||||
@@ -221,14 +219,14 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
if (t.status === "accepted" && !usedIds.has(t.id)) t.status = "offered";
|
||||
});
|
||||
|
||||
// پروژههای هر قلم + همگامسازی پروژههای سطح پیشنهاد
|
||||
// پروژهی هر قلم (تکی) + همگامسازی پروژههای سطح پیشنهاد
|
||||
const projUnion = new Set<string>();
|
||||
next.line_items.forEach((li) => {
|
||||
if (li.rejected) return;
|
||||
const names = itemProjectsSel[li.id];
|
||||
if (names === undefined) return;
|
||||
li.projects = names.map((n) => ({ id: "", name: n }));
|
||||
li.projects.forEach((p) => projUnion.add(p.name));
|
||||
const name = itemProjectsSel[li.id];
|
||||
if (name === undefined) return;
|
||||
li.projects = name ? [{ id: "", name }] : [];
|
||||
if (name) projUnion.add(name);
|
||||
});
|
||||
next.projects = [...projUnion];
|
||||
next.project = next.projects[0] || "";
|
||||
@@ -316,7 +314,7 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
}}
|
||||
/>
|
||||
<span className="flex-1 text-[13px]">
|
||||
{m.category} {m.brand}
|
||||
{materialLabel(m)}
|
||||
{specText ? ` · ${specText}` : ""}
|
||||
</span>
|
||||
<span className="text-[12px] text-muted">
|
||||
@@ -342,11 +340,11 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* تخصیص قلمها به پروژهها */}
|
||||
{/* تخصیص قلمها به پروژه */}
|
||||
<div className="border-t border-line pt-3">
|
||||
<div className="mb-1 text-[13px] font-bold">تخصیص قلمها به پروژهها:</div>
|
||||
<div className="mb-1 text-[13px] font-bold">پروژهی هر قلم:</div>
|
||||
<div className="mb-2 text-[11.5px] text-muted">
|
||||
برای هر متریال مشخص کنید به کدام پروژه (ها) تعلق میگیرد.
|
||||
برای هر متریال فقط یک پروژه انتخاب میشود — مثل شرایط پرداخت.
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{allItems
|
||||
@@ -358,19 +356,35 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
<div key={li.id} className="rounded-lg border border-line p-2.5">
|
||||
<div className="mb-1.5 text-[12.5px]">
|
||||
<span className="font-semibold">
|
||||
{m.category} {m.brand}
|
||||
{materialLabel(m)}
|
||||
</span>
|
||||
{specText ? <span className="text-muted"> · {specText}</span> : ""}
|
||||
<span className="ms-2 text-[11px] text-muted">
|
||||
{fmtNum(li.quantity)} {li.unit}
|
||||
</span>
|
||||
</div>
|
||||
<MultiSelectCombobox
|
||||
values={itemProjectsSel[li.id] || []}
|
||||
onChange={(names) => setItemProjectsSel((s) => ({ ...s, [li.id]: names }))}
|
||||
options={projectOptions}
|
||||
placeholder="انتخاب پروژه..."
|
||||
/>
|
||||
{projectOptions.length ? (
|
||||
<select
|
||||
className="min-w-[180px] rounded-md border border-line bg-panel px-2 py-1.5 font-inherit text-[12px]"
|
||||
value={itemProjectsSel[li.id] ?? ""}
|
||||
onChange={(e) =>
|
||||
setItemProjectsSel((s) => ({ ...s, [li.id]: e.target.value }))
|
||||
}
|
||||
>
|
||||
{(itemProjectsSel[li.id] ? [] : [{ name: "" }]).map((_, i) => (
|
||||
<option key={i} value="">
|
||||
انتخاب پروژه...
|
||||
</option>
|
||||
))}
|
||||
{projectOptions.map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<div className="text-[12px] text-muted">پروژهای ثبت نشده است.</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -380,9 +394,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
{/* فاکتورهای اصلی */}
|
||||
<div className="border-t border-line pt-3">
|
||||
<div className="mb-1 text-[13px] font-bold">فاکتورهای اصلی (چند فایل):</div>
|
||||
<div className="mb-2 text-[11.5px] text-muted">
|
||||
چند فاکتور را با هم انتخاب کنید؛ هر فایل همان لحظه روی سرور (MinIO) ذخیره میشود.
|
||||
</div>
|
||||
<FileList
|
||||
files={invoices}
|
||||
onChange={setInvoices}
|
||||
@@ -400,9 +411,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
{/* پیشفاکتورها */}
|
||||
<div className="border-t border-line pt-3">
|
||||
<div className="mb-1 text-[13px] font-bold">پیشفاکتورها (چند فایل):</div>
|
||||
<div className="mb-2 text-[11.5px] text-muted">
|
||||
پیشفاکتورهای دریافتی از تأمینکننده؛ هر فایل همان لحظه روی سرور (MinIO) ذخیره میشود.
|
||||
</div>
|
||||
<FileList
|
||||
files={proformas}
|
||||
onChange={setProformas}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Users, FolderKanban, Ruler, Plus } from "lucide-react";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
useMaterials,
|
||||
useProjects,
|
||||
@@ -80,22 +81,33 @@ export function DashboardPage() {
|
||||
menu={<AppSwitcherMenu activeModule="procurement" />}
|
||||
actions={
|
||||
<>
|
||||
<Link to="/suppliers" className="btn-line-file no-underline">
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
تأمینکنندهها
|
||||
</Link>
|
||||
<Link to="/projects" className="btn-line-file no-underline">
|
||||
<FolderKanban className="h-3.5 w-3.5" />
|
||||
پروژهها
|
||||
</Link>
|
||||
<Link to="/units" className="btn-line-file no-underline">
|
||||
<Ruler className="h-3.5 w-3.5" />
|
||||
ضرایب تبدیل
|
||||
</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>
|
||||
<Button asChild variant="slate">
|
||||
<Link to="/suppliers" className="no-underline">
|
||||
<Users className="h-4 w-4" />
|
||||
تأمینکنندهها
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button asChild variant="slate">
|
||||
<Link to="/projects" className="no-underline">
|
||||
<FolderKanban className="h-4 w-4" />
|
||||
پروژهها
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button asChild variant="line">
|
||||
<Link to="/units" className="no-underline">
|
||||
<Ruler className="h-4 w-4" />
|
||||
ضرایب تبدیل
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button asChild variant="primary">
|
||||
<Link to="/wizard" className="no-underline">
|
||||
<Plus className="h-4 w-4" />
|
||||
افزودن پیشنهاد جدید
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -589,9 +589,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
{/* فاکتورها */}
|
||||
<div className="border-t border-line pt-3">
|
||||
<div className="mb-1 text-[13px] font-bold">فاکتورهای اصلی (چند فایل):</div>
|
||||
<div className="mb-2 text-[11.5px] text-muted">
|
||||
چند فاکتور را با هم انتخاب کنید؛ هر فایل همان لحظه روی سرور (MinIO) ذخیره میشود.
|
||||
</div>
|
||||
<FileList
|
||||
files={invoices}
|
||||
onChange={setInvoices}
|
||||
@@ -629,8 +626,9 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
<div className="field col-span-2">
|
||||
<label>وضعیت (فقط بازگشت به مرحلههای قبل)</label>
|
||||
<select
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
value={status}
|
||||
disabled={q.status === "arrived"}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
>
|
||||
{statusOptionsFor(q.status).map((st) => (
|
||||
@@ -639,6 +637,11 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{q.status === "arrived" && (
|
||||
<div className="mt-1.5 rounded-md bg-amber-bg px-2.5 py-1.5 text-[11.5px] font-medium text-amber">
|
||||
به دلیل ثبت ورود اقلام به انبار، امکان تغییر وضعیت پیشنهاد وجود ندارد.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
isExpired,
|
||||
itemsSummary,
|
||||
materialById,
|
||||
materialSpecs,
|
||||
materialLabel,
|
||||
projectCell,
|
||||
quoteTotal,
|
||||
supplierById,
|
||||
@@ -97,6 +97,20 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
||||
}
|
||||
|
||||
let arrivedCell: React.ReactNode;
|
||||
// آخرین لحظهی رسید ثبتشده بین قلمها (رسیدهای جزیی توسط مدیر پروژه)
|
||||
const lastReceiptAt = activeItems.reduce<Date | null>((acc, li) => {
|
||||
if (!li.arrived_date) return acc;
|
||||
const t = new Date(li.arrived_date).getTime();
|
||||
if (Number.isNaN(t)) return acc;
|
||||
return !acc || t > acc.getTime() ? new Date(li.arrived_date) : acc;
|
||||
}, null);
|
||||
const receiptTimeLine =
|
||||
lastReceiptAt != null ? (
|
||||
<span className="block text-[10.5px] font-normal text-muted">
|
||||
آخرین رسید: {fmtDateTime(lastReceiptAt.toISOString())}
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
if (q.status === "accepted") {
|
||||
if (q.arrived_date) {
|
||||
arrivedCell = <span className="tabular-nums">{fmtDateTime(q.arrived_date)}</span>;
|
||||
@@ -104,13 +118,18 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
||||
arrivedCell = (
|
||||
<span className="tabular-nums">
|
||||
رسیده {fmtNum(receivedSum)} از {fmtNum(orderedSum)} — در راه {fmtNum(Math.max(0, orderedSum - receivedSum))}
|
||||
{receiptTimeLine}
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
arrivedCell = <span className="text-muted">در انتظار PM</span>;
|
||||
}
|
||||
} else if (q.arrived_date) {
|
||||
arrivedCell = <span className="tabular-nums">{fmtDateTime(q.arrived_date)}</span>;
|
||||
} else if (q.status === "arrived") {
|
||||
arrivedCell = (
|
||||
<span className="tabular-nums">
|
||||
{fmtDateTime(q.arrived_date || (lastReceiptAt ? lastReceiptAt.toISOString() : ""))}
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
arrivedCell = <span className="text-muted">—</span>;
|
||||
}
|
||||
@@ -165,11 +184,10 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
||||
<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);
|
||||
const specs = materialSpecs(m);
|
||||
return (
|
||||
<span key={li.id} className="flex items-center gap-1">
|
||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-accent/40" />
|
||||
{m.category} {m.brand}{specs.length ? ` · ${specs.join(" · ")}` : ""}
|
||||
{materialLabel(m)}
|
||||
<span className="text-[10px] text-muted/70">
|
||||
{fmtNum(li.quantity)} {li.unit}
|
||||
</span>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
itemTermsheetIds,
|
||||
lineItemTotal,
|
||||
materialById,
|
||||
materialLabel,
|
||||
materialSpecs,
|
||||
refConverted,
|
||||
specsLabel,
|
||||
@@ -96,7 +97,7 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
|
||||
>
|
||||
<div className="fb-name">
|
||||
<Package className="me-1 inline h-3 w-3 text-muted" />
|
||||
{m.category} {m.brand}
|
||||
{materialLabel(m)}
|
||||
{specText ? ` · ${specText}` : ""}
|
||||
</div>
|
||||
<div className="fb-meta">
|
||||
|
||||
@@ -2,12 +2,15 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { Combobox } from "../../components/Combobox";
|
||||
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { inventorySubheader } from "./panelMenu";
|
||||
import { apiV26 } from "../../lib/api";
|
||||
import { notifyDataChanged } from "../../lib/crossTab";
|
||||
import { useInventoryData, unitName, whName } from "./useInventoryData";
|
||||
import { JalaliDatePicker } from "../../components/JalaliDatePicker";
|
||||
import { uniqueUnits } from "../../lib/units";
|
||||
import type { InventoryTransaction, TransactionType } from "../../lib/types";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import { gregorianToJalali, MONTHS, toFa } from "../../lib/jalali";
|
||||
@@ -16,7 +19,7 @@ type FormType = TransactionType | "TRANSFER";
|
||||
|
||||
const FORM_TYPES: { t: FormType; label: string; dir: string; cls: "in" | "out" | "mv" | "cnt"; color: string; desc: string }[] = [
|
||||
// رنگبندی معنایی: سبز زمردی = ورود، رز = خروج، آبی = خنثی/جابهجایی/شمارش
|
||||
{ t: "PURCHASE", label: "خرید", dir: "افزایش", cls: "in", color: "#10b981", desc: "ثبت خرید کالای جدید یا موجود — دستهبندی فقط برای کالای جدید لازم است. ثبت حداقل یک مشخصه فنی (نام و مقدار) الزامی است." },
|
||||
{ t: "PURCHASE", label: "موجودی اولیه", dir: "افزایش", cls: "in", color: "#10b981", desc: "ثبت موجودی اولیه — متریال/برند/واحد را از فهرست انتخاب کنید؛ اگر نامی در فهرست نبود، همان نام تایپشده هنگام ثبت بهصورت خودکار در کاتالوگ سراسری ساخته میشود. ثبت حداقل یک مشخصه فنی الزامی است." },
|
||||
{ t: "WITHDRAWAL", label: "برداشت", dir: "کاهش", cls: "out", color: "#e11d48", desc: "انبار را انتخاب و نام متریال را تایپ کنید؛ همه مدلها (برند و مشخصه متفاوت) نمایش داده میشوند. برداشتها در «کنترل روزانه» امروز دیده میشوند." },
|
||||
{ t: "RETURN", label: "برگشت", dir: "افزایش", cls: "in", color: "#10b981", desc: "برگشت از مصالحی که امروز برداشت شدهاند — نام متریال را تایپ کنید و مقدار برگشتی هر ردیف را بزنید." },
|
||||
{ t: "TRANSFER", label: "انتقال", dir: "جابهجایی", cls: "mv", color: "#2563eb", desc: "جابهجایی بین انبارهای پروژه — از انبار مبدأ، مقداری از هر مدل را به انبار مقصد منتقل کنید." },
|
||||
@@ -59,9 +62,8 @@ export function ChangeInventoryPage() {
|
||||
const [qty, setQty] = useState("");
|
||||
// تاریخ و ساعت به صورت ISO (تقویم شمسی — JalaliDatePicker)
|
||||
const [dateInput, setDateInput] = useState<string>(() => new Date().toISOString());
|
||||
const [specsRows, setSpecsRows] = useState<{ rid: number; label: string; value: string }[]>([
|
||||
{ rid: 1, label: "", value: "" },
|
||||
]);
|
||||
// مشخصههای فنی — چیپهای چندتایی، دقیقاً مثل فیلد «مشخصه» در ویزارد دفتر تدارکات
|
||||
const [specsChips, setSpecsChips] = useState<string[]>([]);
|
||||
const [note, setNote] = useState("");
|
||||
const [rows, setRows] = useState<RowDraft[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -113,14 +115,17 @@ export function ChangeInventoryPage() {
|
||||
return new Date().toISOString();
|
||||
};
|
||||
|
||||
const specsFromRows = () => {
|
||||
// هر چیپ یک مشخصه است؛ در specs انبار بهصورت کلید با مقدار خالی ذخیره میشود
|
||||
// تا رکوردهای انبارِ دارای مشخصههای متفاوت از هم جدا بمانند
|
||||
const specsFromChips = () => {
|
||||
const out: Record<string, string> = {};
|
||||
specsRows.forEach((r) => {
|
||||
if (r.label.trim() && r.value.trim()) out[r.label.trim()] = r.value.trim();
|
||||
specsChips.forEach((c) => {
|
||||
const k = c.trim();
|
||||
if (k) out[k] = "";
|
||||
});
|
||||
return out;
|
||||
};
|
||||
const specsComplete = specsRows.some((r) => r.label.trim() && r.value.trim());
|
||||
const specsComplete = specsChips.some((c) => c.trim());
|
||||
|
||||
const todayWithdrawals = useMemo(() => {
|
||||
const tk = todayKey();
|
||||
@@ -190,7 +195,7 @@ export function ChangeInventoryPage() {
|
||||
setBrandTxt("");
|
||||
setUnitTxt("");
|
||||
setQty("");
|
||||
setSpecsRows([{ rid: Date.now(), label: "", value: "" }]);
|
||||
setSpecsChips([]);
|
||||
setNote("");
|
||||
await data.refresh();
|
||||
} catch (err) {
|
||||
@@ -202,22 +207,44 @@ export function ChangeInventoryPage() {
|
||||
|
||||
// ---------- submit handlers ----------
|
||||
const submitPurchase = async () => {
|
||||
const m = matByName(matTxt);
|
||||
const bid = brandIdByName(brandTxt);
|
||||
const uid = unitIdByName(unitTxt);
|
||||
const matName = matTxt.trim();
|
||||
const brandName = brandTxt.trim();
|
||||
const unitName = unitTxt.trim();
|
||||
const n = parseNum(qty);
|
||||
if (!whId) return toast.error("انبار را انتخاب کنید");
|
||||
if (!m) return toast.error("متریال در فهرست نیست — نام را دقیق تایپ کنید");
|
||||
if (!bid) return toast.error("برند را انتخاب کنید");
|
||||
if (!uid) return toast.error("واحد را انتخاب کنید");
|
||||
if (!matName) return toast.error("نام متریال را وارد کنید");
|
||||
if (!brandName) return toast.error("نام برند را وارد کنید");
|
||||
if (!unitName) return toast.error("نام واحد را وارد کنید");
|
||||
if (!(n > 0)) return toast.error("تعداد باید بزرگتر از صفر باشد");
|
||||
if (!specsComplete) return toast.error("حداقل یک مشخصه فنی (نام و مقدار) ثبت کنید");
|
||||
const op: Record<string, unknown> = {
|
||||
transaction_type: "PURCHASE", warehouse_id: whId, material_id: m.id, brand_id: bid,
|
||||
quantity: n, unit_id: uid, specs: specsFromRows(), note,
|
||||
transaction_date: toServerIso(),
|
||||
};
|
||||
await postSeq([op]);
|
||||
if (!specsComplete) return toast.error("حداقل یک مشخصه فنی ثبت کنید (نام را بنویسید و Enter بزنید)");
|
||||
|
||||
try {
|
||||
// ساختِ بههنگام موجودیتهای ناشناخته در کاتالوگ سراسری (بدون پیشنیاز تدارکات)
|
||||
let uid = unitIdByName(unitName);
|
||||
if (!uid) {
|
||||
const created = await apiV26.catalog.createUnit(unitName);
|
||||
uid = created.id;
|
||||
}
|
||||
let bid = brandIdByName(brandName);
|
||||
if (!bid) {
|
||||
const created = await apiV26.catalog.createBrand(brandName);
|
||||
bid = created.id;
|
||||
}
|
||||
let m = matByName(matName);
|
||||
if (!m) {
|
||||
const created = await apiV26.catalog.createMaterial({ name: matName, canonical_unit_id: uid, brand_ids: bid ? [bid] : [] });
|
||||
m = { id: created.id, name: created.name, category: null, canonical_unit: null };
|
||||
}
|
||||
const op: Record<string, unknown> = {
|
||||
transaction_type: "PURCHASE", warehouse_id: whId, material_id: m.id, brand_id: bid,
|
||||
quantity: n, unit_id: uid, specs: specsFromChips(), note,
|
||||
transaction_date: toServerIso(),
|
||||
};
|
||||
await postSeq([op]);
|
||||
} catch (err) {
|
||||
setBusy(false);
|
||||
toast.error(err instanceof Error ? err.message : "خطا در ثبت موجودی اولیه");
|
||||
}
|
||||
};
|
||||
|
||||
const submitList = async (t: TransactionType) => {
|
||||
@@ -355,7 +382,8 @@ export function ChangeInventoryPage() {
|
||||
|
||||
const canSubmit = (): boolean => {
|
||||
if (type === "PURCHASE") {
|
||||
return !!(whId && matByName(matTxt) && brandIdByName(brandTxt) && unitIdByName(unitTxt) && parseNum(qty) > 0 && specsComplete);
|
||||
// نامهای تایپشده لازم نیست از قبل در فهرست باشند — در submit ساخته میشوند
|
||||
return !!(whId && matTxt.trim() && brandTxt.trim() && unitTxt.trim() && parseNum(qty) > 0 && specsComplete);
|
||||
}
|
||||
if (type === "TRANSFER") return !!(fromId && toId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
|
||||
if (type === "RETURN") return !!(whId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
|
||||
@@ -461,7 +489,7 @@ export function ChangeInventoryPage() {
|
||||
{whOpts.map((w) => <option key={w.id} value={w.id}>{w.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
{(type === "PURCHASE" || type === "WITHDRAWAL" || type === "DAMAGE" || type === "WASTE" || type === "LOSS" || type === "STOCK_COUNT" || type === "RETURN" || type === "FOUND") && (
|
||||
{(type === "WITHDRAWAL" || type === "DAMAGE" || type === "WASTE" || type === "LOSS" || type === "STOCK_COUNT" || type === "RETURN" || type === "FOUND") && (
|
||||
<div className="field">
|
||||
<label>{type === "FOUND" ? "متریال (خالی = همه مفقودیها)" : "متریال *"}</label>
|
||||
<input
|
||||
@@ -518,34 +546,49 @@ export function ChangeInventoryPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* فرم خرید */}
|
||||
{/* فرم خرید / موجودی اولیه */}
|
||||
{type === "PURCHASE" && (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="field md:col-span-2">
|
||||
<label>متریال *</label>
|
||||
<Combobox
|
||||
value={matTxt}
|
||||
items={data.lookup.materials.map((mm) => mm.name)}
|
||||
allowAdd
|
||||
addSuffix="به عنوان متریال جدید"
|
||||
placeholder="نام متریال — از فهرست انتخاب یا نام جدید تایپ کنید"
|
||||
onInputChange={(v) => { setMatTxt(v); setRows([]); }}
|
||||
onChange={(it) => { setMatTxt(it.label); setRows([]); }}
|
||||
/>
|
||||
{!matByName(matTxt) && matTxt.trim() && (
|
||||
<div className="mt-1.5 rounded-lg border border-accent/25 bg-accent-bg/60 px-3 py-2 text-[12px] text-accent-dark">
|
||||
«{matTxt.trim()}» در فهرست نیست — هنگام ثبت، بهصورت خودکار در کاتالوگ سراسری ساخته میشود.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>برند *</label>
|
||||
<input
|
||||
list="dl-brand"
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
|
||||
placeholder="نام برند — از پیشنهادها یا جدید…"
|
||||
<Combobox
|
||||
value={brandTxt}
|
||||
onChange={(e) => setBrandTxt(e.target.value)}
|
||||
items={data.lookup.brands.map((b) => b.name)}
|
||||
allowAdd
|
||||
addSuffix="به عنوان برند جدید"
|
||||
placeholder="نام برند — از فهرست انتخاب یا نام جدید تایپ کنید"
|
||||
onInputChange={setBrandTxt}
|
||||
onChange={(it) => setBrandTxt(it.label)}
|
||||
/>
|
||||
<datalist id="dl-brand">
|
||||
{data.lookup.brands.map((b) => <option key={b.id} value={b.name} />)}
|
||||
</datalist>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>واحد *</label>
|
||||
<input
|
||||
list="dl-unit"
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
|
||||
placeholder="واحد اندازهگیری…"
|
||||
<Combobox
|
||||
value={unitTxt}
|
||||
onChange={(e) => setUnitTxt(e.target.value)}
|
||||
items={uniqueUnits(data.lookup.units).map((u) => u.name_fa || u.code)}
|
||||
allowAdd
|
||||
addSuffix="به عنوان واحد جدید"
|
||||
placeholder="واحد اندازهگیری — از فهرست یا نام جدید"
|
||||
onInputChange={setUnitTxt}
|
||||
onChange={(it) => setUnitTxt(it.label)}
|
||||
/>
|
||||
<datalist id="dl-unit">
|
||||
{data.lookup.units.map((u) => <option key={u.id} value={u.name_fa || u.code} />)}
|
||||
</datalist>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>تعداد *</label>
|
||||
@@ -566,47 +609,14 @@ export function ChangeInventoryPage() {
|
||||
withTime
|
||||
/>
|
||||
</div>
|
||||
{!matByName(matTxt) && matTxt.trim() && (
|
||||
<div className="field md:col-span-2">
|
||||
<div className="rounded-lg border border-amber/30 bg-amber/5 px-3 py-2 text-[12px] text-amber">
|
||||
این متریال در فهرست نیست — ابتدا متریال را بسازید (مثلاً هنگام ثبت پیشنهاد در دفتر تدارکات)، سپس خرید را ثبت کنید.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="field md:col-span-2">
|
||||
<label>مشخصههای فنی *</label>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{specsRows.map((r) => (
|
||||
<div key={r.rid} className="flex gap-2">
|
||||
<input
|
||||
className="h-8 w-1/2 rounded-lg border border-line bg-panel px-3 text-sm"
|
||||
placeholder="نام مشخصه (مثلاً سایز)"
|
||||
value={r.label}
|
||||
onChange={(e) => setSpecsRows((rs) => rs.map((x) => (x.rid === r.rid ? { ...x, label: e.target.value } : x)))}
|
||||
/>
|
||||
<input
|
||||
className="h-8 w-1/2 rounded-lg border border-line bg-panel px-3 text-sm"
|
||||
placeholder="مقدار (مثلاً ۱۶)"
|
||||
value={r.value}
|
||||
onChange={(e) => setSpecsRows((rs) => rs.map((x) => (x.rid === r.rid ? { ...x, value: e.target.value } : x)))}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="h-8 px-2 text-muted hover:text-red"
|
||||
onClick={() => setSpecsRows((rs) => (rs.length > 1 ? rs.filter((x) => x.rid !== r.rid) : rs))}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="w-fit rounded-lg border border-dashed border-accent/50 px-3 py-1 text-[12px] text-accent"
|
||||
onClick={() => setSpecsRows((rs) => [...rs, { rid: Date.now(), label: "", value: "" }])}
|
||||
>
|
||||
+ افزودن مشخصه فنی
|
||||
</button>
|
||||
</div>
|
||||
<MultiSelectCombobox
|
||||
values={specsChips}
|
||||
onChange={setSpecsChips}
|
||||
placeholder="نام مشخصه را تایپ کنید و Enter بزنید (مثلاً سایز)"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="field md:col-span-2">
|
||||
<label>یادداشت</label>
|
||||
@@ -631,7 +641,7 @@ export function ChangeInventoryPage() {
|
||||
emptyMsg={
|
||||
!whId ? "برای نمایش رکوردها، ابتدا انبار را انتخاب کنید."
|
||||
: !matTxt.trim() ? "نام متریال را تایپ کنید تا همه مدلهای آن (با برند و مشخصه متفاوت) نمایش داده شود."
|
||||
: !formRecords.length ? "برای این نام متریال در انبار انتخابی رکوردی وجود ندارد. ابتدا خرید ثبت کنید."
|
||||
: !formRecords.length ? "برای این نام متریال در انبار انتخابی رکوردی وجود ندارد. ابتدا «موجودی اولیه» ثبت کنید."
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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,15 +484,17 @@ 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,19 +21,19 @@ 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>
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
activeItems,
|
||||
lineItemTotal,
|
||||
materialById,
|
||||
materialSpecs,
|
||||
materialLabel,
|
||||
refConverted,
|
||||
supplierById,
|
||||
} from "../../lib/quoteUtils";
|
||||
@@ -154,9 +154,7 @@ function renderProjectDetail(store: Store, name: string): React.ReactElement {
|
||||
|
||||
const matName = (mid: string) => {
|
||||
const m = materialById(store.materials, mid);
|
||||
if (m.category === "؟") return "؟";
|
||||
const specs = materialSpecs(m);
|
||||
return `${m.category} ${m.brand}${specs.length ? " · " + specs.join(" · ") : ""}`;
|
||||
return materialLabel(m);
|
||||
};
|
||||
|
||||
const supName = (sid: string) => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { EmptyState } from "../../components/EmptyState";
|
||||
import { Combobox } from "../../components/Combobox";
|
||||
import { SupplierCard, suppliersOfCategory } from "./supplierUtils";
|
||||
import { materialLabel } from "../../lib/quoteUtils";
|
||||
import { Button } from "../../components/ui/button";
|
||||
|
||||
// صفحهی تأمینکنندههای یک دسته + فرم افزودن تأمینکننده
|
||||
@@ -53,7 +54,7 @@ export function CategoryPage() {
|
||||
if (li.rejected) return;
|
||||
const m = materials.find((x) => x.id === li.material_id);
|
||||
if (!m) return;
|
||||
const label = `${m.category}${m.brand ? " " + m.brand : ""}${m.specs.length ? " (" + m.specs.join("، ") + ")" : ""}`;
|
||||
const label = materialLabel(m);
|
||||
if (!map.has(q.supplier_id)) map.set(q.supplier_id, []);
|
||||
const list = map.get(q.supplier_id)!;
|
||||
if (!list.includes(label)) list.push(label);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Link } from "react-router-dom";
|
||||
import { Building2, Paperclip, Plus } from "lucide-react";
|
||||
import type { Attachment, Material, Quote, Supplier } from "../../lib/types";
|
||||
import { api } from "../../lib/api";
|
||||
import { activeItems, materialById, materialSpecs, quoteTotal, specsLabel } from "../../lib/quoteUtils";
|
||||
import { activeItems, materialById, materialLabel, quoteTotal } from "../../lib/quoteUtils";
|
||||
import { fmtDate, faNum } from "../../lib/formatters";
|
||||
import { groupCommas } from "../../lib/numutil";
|
||||
import { STATUS_LABEL } from "../../lib/constants";
|
||||
@@ -85,8 +85,7 @@ export function PurchaseHistory({ supplier, quotes, materials }: PurchaseHistory
|
||||
{purchases.map((q) => {
|
||||
const items = activeItems(q).map((li) => {
|
||||
const m = materialById(materials, li.material_id);
|
||||
const specs = materialSpecs(m);
|
||||
return `${m.category} ${m.brand}${specs.length ? " " + specsLabel(specs) : ""}`.trim();
|
||||
return materialLabel(m);
|
||||
});
|
||||
const total = quoteTotal(q);
|
||||
const attach = attachments[q.id];
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -171,9 +171,6 @@ export function Step1TopicSupplier({
|
||||
onChange={(e) => onOfficial(e.target.checked)}
|
||||
/>
|
||||
فاکتور رسمی
|
||||
<span className="text-[11px] text-muted">
|
||||
(تأمینکننده برای این معامله فاکتور رسمی صادر میکند — نه فقط توافق تلفنی)
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { specsKey } from "../../lib/quoteUtils";
|
||||
import { Combobox } from "../../components/Combobox";
|
||||
import { NumberInput } from "../../components/NumberInput";
|
||||
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
|
||||
import { api } from "../../lib/api";
|
||||
import { api, apiV26 } from "../../lib/api";
|
||||
|
||||
export interface ItemDraft {
|
||||
tempId: string;
|
||||
@@ -41,6 +41,25 @@ export function itemRowLabel(it: ItemDraft): string {
|
||||
return qtyPart ? `${name} -- ${qtyPart}` : name;
|
||||
}
|
||||
|
||||
// نام پایهی متریال از نامِ ذخیرهشده: پیشوند برند و پسوند مشخصه حذف میشوند
|
||||
// («راجین سیم مسی» → «سیم») — پیشنهادهای فیلد «متریال» فقط خودِ متریال را نشان
|
||||
// میدهند؛ برند و مشخصه ستونهای جداگانه دارند.
|
||||
function materialBaseName(name: string, brand: string, specs: string[]): string {
|
||||
let base = (name || "").trim();
|
||||
if (!base) return "";
|
||||
if (brand) {
|
||||
if (base.startsWith(brand)) base = base.slice(brand.length);
|
||||
else if (base.includes(brand)) base = base.split(brand).join(" ");
|
||||
}
|
||||
specs
|
||||
.filter((s) => s && s.trim())
|
||||
.forEach((s) => {
|
||||
if (base.includes(s)) base = base.split(s).join(" ");
|
||||
});
|
||||
base = base.replace(/\s+/g, " ").trim();
|
||||
return base || (name || "").trim();
|
||||
}
|
||||
|
||||
// مقادیر مجزای category/brand/spec داخل تاپیک — با فیلتر پلهبهپله (تاپیک → دسته → برند)
|
||||
// + مقادیر تایپشده در همهی سطرهای قبلی (suggestion از همهی فیلدها)
|
||||
// نکته: برند و واحد از همهی دستهها نمایش داده میشوند (issue #7)
|
||||
@@ -97,13 +116,24 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
||||
items.forEach((it) => { if (it.unit) all.add(it.unit); });
|
||||
return [...all];
|
||||
});
|
||||
// واحدهای تایپشده در سطرهای قبلی
|
||||
// واحدهای موجود (دیتابیس) + واحدهای تایپشده در سطرهای دیگر
|
||||
const allUnits = useMemo(() => {
|
||||
const out = new Set(unitOptions);
|
||||
items.forEach((it) => { if (it.unit) out.add(it.unit); });
|
||||
return [...out];
|
||||
}, [items, unitOptions]);
|
||||
|
||||
// نامهای پایهی یکتای همهی متریالهای کاتالوگ سراسری (فقط خود متریال،
|
||||
// بدون برند/مشخصه — آنها ستونهای خودشان را دارند)
|
||||
const allMaterialNames = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
materials.forEach((m) => {
|
||||
const base = materialBaseName(m.name, m.brand, m.specs);
|
||||
if (base) set.add(base);
|
||||
});
|
||||
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
|
||||
}, [materials]);
|
||||
|
||||
// برندهای ثبتشده در بخش برندها (بکاند) — بهعلاوهی برندهای متریالها
|
||||
const [brandOptions, setBrandOptions] = useState<string[]>([]);
|
||||
|
||||
@@ -189,7 +219,9 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
||||
<Combobox
|
||||
value={it.cat}
|
||||
onChange={(x) => patch(it.tempId, { cat: x.label })}
|
||||
items={materialOptions(materials, selectedTopic, it, "cat", items)}
|
||||
// پیشنهادها: همهی متریالهای کاتالوگ سراسری با نام واقعیشان
|
||||
// (بدون فیلتر تاپیک) — انتخابِ یک متریال موجود در ذخیره، همان را reuse میکند
|
||||
items={allMaterialNames}
|
||||
allowAdd
|
||||
addSuffix="به عنوان متریال جدید"
|
||||
placeholder="متریال"
|
||||
@@ -231,11 +263,31 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
||||
<Combobox
|
||||
value={it.unit}
|
||||
onChange={(x) => patch(it.tempId, { unit: x.label })}
|
||||
items={allUnits}
|
||||
// متنِ خودِ همین سطر از پیشنهادها حذف میشود؛ وگرنه متن تایپشده
|
||||
// «تطبیق دقیق» پیدا میکرد و گزینهی «به عنوان واحد جدید» هرگز ظاهر نمیشد
|
||||
items={allUnits.filter((u) => normalize(u) !== normalize(it.unit))}
|
||||
allowAdd
|
||||
addSuffix="اضافه کردن واحد جدید"
|
||||
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 میشود — خطای بلوککننده نیست
|
||||
});
|
||||
}}
|
||||
inputClassName="!text-[12px] !h-9"
|
||||
className="min-w-0"
|
||||
/>
|
||||
|
||||
@@ -73,8 +73,8 @@ export function WizardPage() {
|
||||
const [assignment, setAssignment] = useState<Record<string, string[]>>({});
|
||||
|
||||
const [quoteDate, setQuoteDate] = useState<string | null>(() => {
|
||||
const d = new Date();
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).toISOString();
|
||||
// تاریخ پیشنهاد: همین الان (ساعت و دقیقهی فعلی) — نه نیمهشب
|
||||
return new Date().toISOString();
|
||||
});
|
||||
const [expiryDate, setExpiryDate] = useState<string | null>(null);
|
||||
const [notes, setNotes] = useState("");
|
||||
@@ -256,12 +256,10 @@ export function WizardPage() {
|
||||
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.category) === normalize(cat) && normalize(m.name) === normalize(wantName),
|
||||
);
|
||||
let material = materials.find((m) => normalize(m.name) === normalize(wantName));
|
||||
if (!material) {
|
||||
// جستجوی قبلی (برند و مشخصهی ساختارمند در همین نشست)
|
||||
material = materials.find(
|
||||
|
||||
Reference in New Issue
Block a user