Compare commits
10
Commits
d69ec7f7a9
...
prod_v8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cff39542b0 | ||
|
|
253c922280 | ||
|
|
387edfb1ab | ||
|
|
5a9b543040 | ||
|
|
730d0bd496 | ||
|
|
a02b3ea7cc | ||
|
|
49dd159fb1 | ||
|
|
972b1b49cb | ||
|
|
b176f0f5ff | ||
|
|
9386d4846f |
+28
-107
@@ -6,8 +6,6 @@ services:
|
||||
environment:
|
||||
POSTGRES_DB: procurement
|
||||
POSTGRES_USER: postgres
|
||||
# Local dev default; production overrides via .env (required by the
|
||||
# prod overlay) — never commit real passwords.
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||
|
||||
ports:
|
||||
@@ -15,7 +13,7 @@ services:
|
||||
|
||||
networks:
|
||||
- default
|
||||
- shared # monitoring stack scrapes it (postgres-exporter)
|
||||
- shared
|
||||
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql
|
||||
@@ -31,15 +29,7 @@ services:
|
||||
restart: unless-stopped
|
||||
mem_limit: 1g
|
||||
|
||||
# ==========================================================================
|
||||
# Traefik v3 — public reverse proxy (replaces nginx as the edge)
|
||||
# App is served on http://localhost (port 80) instead of :3000.
|
||||
# Production: see docker-compose.prod.yml (real domains + HTTPS via
|
||||
# Let's Encrypt, HTTP→HTTPS redirect).
|
||||
# ==========================================================================
|
||||
traefik:
|
||||
# v3.6+ required: Traefik ≤3.3 fails against Docker Engine 29.x
|
||||
# ("Error response from daemon" — Docker API version mismatch).
|
||||
image: traefik:v3.6
|
||||
container_name: procurement-traefik
|
||||
restart: unless-stopped
|
||||
@@ -47,8 +37,6 @@ services:
|
||||
command:
|
||||
- --providers.docker=true
|
||||
- --providers.docker.exposedbydefault=false
|
||||
# Containers are on several networks; Traefik must use the dedicated
|
||||
# proxy network IP (otherwise it may pick the default-network IP → 504).
|
||||
- --providers.docker.network=traefik_network
|
||||
- --providers.file.filename=/etc/traefik/dynamic.yml
|
||||
- --entrypoints.web.address=:80
|
||||
@@ -62,8 +50,6 @@ services:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
# NOTE: mounted read-write — Docker Desktop rejects read-only
|
||||
# /var/run/docker.sock mounts ("Error response from daemon").
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./infra/traefik-dynamic.yml:/etc/traefik/dynamic.yml:ro
|
||||
- traefik_certs:/certs
|
||||
@@ -81,24 +67,21 @@ services:
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
|
||||
|
||||
ports:
|
||||
- "9000:9000" # S3 API
|
||||
- "9001:9001" # Web console
|
||||
- "9000:9000"
|
||||
- "9001:9001"
|
||||
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
# S3 API
|
||||
- traefik.http.routers.minio-api.rule=Host(`${S3_HOST:-s3.localhost}`)
|
||||
- traefik.http.routers.minio-api.entrypoints=web
|
||||
- traefik.http.routers.minio-api.service=minio-api-svc
|
||||
- traefik.http.services.minio-api-svc.loadbalancer.server.port=9000
|
||||
# Web console
|
||||
- traefik.http.routers.minio-console.rule=Host(`${STORAGE_HOST:-storage.localhost}`)
|
||||
- traefik.http.routers.minio-console.entrypoints=web
|
||||
- traefik.http.routers.minio-console.service=minio-console-svc
|
||||
- traefik.http.services.minio-console-svc.loadbalancer.server.port=9001
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.minio-api.rule=Host(`${S3_HOST:-s3.localhost}`)"
|
||||
- "traefik.http.routers.minio-api.entrypoints=web"
|
||||
- "traefik.http.services.minio-api-svc.loadbalancer.server.port=9000"
|
||||
- "traefik.http.routers.minio-console.rule=Host(`${STORAGE_HOST:-storage.localhost}`)"
|
||||
- "traefik.http.routers.minio-console.entrypoints=web"
|
||||
- "traefik.http.routers.minio-console.service=minio-console-svc"
|
||||
- "traefik.http.services.minio-console-svc.loadbalancer.server.port=9001"
|
||||
|
||||
networks:
|
||||
- traefik_network
|
||||
@@ -107,39 +90,20 @@ services:
|
||||
restart: unless-stopped
|
||||
mem_limit: 512m
|
||||
|
||||
# MinIO Client (mc): waits for the server, creates the bucket, exits.
|
||||
# Use for ad-hoc ops too: docker compose run --rm minio-init ...
|
||||
minio-init:
|
||||
image: minio/mc:latest
|
||||
container_name: procurement-minio-init
|
||||
|
||||
depends_on:
|
||||
minio:
|
||||
condition: service_started
|
||||
|
||||
environment:
|
||||
MINIO_ENDPOINT: minio:9000
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
|
||||
MINIO_BUCKET: procurement-files
|
||||
|
||||
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;
|
||||
"
|
||||
|
||||
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 ready;"]
|
||||
restart: "no"
|
||||
|
||||
# ==========================================================================
|
||||
# Scheduled logical backups of BOTH databases (procurement + casdoor).
|
||||
# Writes pg_dump -Fc archives to a bind mount (default ./backups, point
|
||||
# BACKUP_DIR at /srv/erp/backups on the VPS) and prunes archives older
|
||||
# than BACKUP_KEEP_DAYS. Runs on the same postgres:18 image already pulled.
|
||||
# ==========================================================================
|
||||
db-backup:
|
||||
image: postgres:18
|
||||
container_name: procurement-db-backup
|
||||
@@ -152,33 +116,9 @@ services:
|
||||
BACKUP_KEEP_DAYS: ${BACKUP_KEEP_DAYS:-14}
|
||||
volumes:
|
||||
- ${BACKUP_DIR:-./backups}:/backups
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
echo 'db-backup: dumping every $${BACKUP_INTERVAL_SECONDS}s, keeping $${BACKUP_KEEP_DAYS} days';
|
||||
while true; do
|
||||
STAMP=$$(date +%Y%m%d-%H%M%S);
|
||||
pg_dump -h postgres -U postgres -d procurement -Fc -f /backups/procurement-$${STAMP}.dump &&
|
||||
echo \"db-backup: procurement -> /backups/procurement-$${STAMP}.dump\";
|
||||
pg_dump -h postgres -U postgres -d casdoor -Fc -f /backups/casdoor-$${STAMP}.dump &&
|
||||
echo \"db-backup: casdoor -> /backups/casdoor-$${STAMP}.dump\";
|
||||
find /backups -name '*.dump' -mtime +$${BACKUP_KEEP_DAYS} -delete;
|
||||
sleep $${BACKUP_INTERVAL_SECONDS};
|
||||
done
|
||||
"
|
||||
entrypoint: ["/bin/sh", "-c", "while true; do STAMP=$$(date +%Y%m%d-%H%M%S); pg_dump -h postgres -U postgres -d procurement -Fc -f /backups/procurement-$${STAMP}.dump; pg_dump -h postgres -U postgres -d casdoor -Fc -f /backups/casdoor-$${STAMP}.dump; find /backups -name '*.dump' -mtime +$${BACKUP_KEEP_DAYS} -delete; sleep $${BACKUP_INTERVAL_SECONDS}; done"]
|
||||
restart: unless-stopped
|
||||
mem_limit: 256m
|
||||
|
||||
# ==========================================================================
|
||||
# v2.6 — Casdoor (OIDC identity provider)
|
||||
# Console: http://localhost:8080 (admin / 123)
|
||||
# Application "inventory-app" (org "inventory", client inventory-app-client)
|
||||
# is created by backend/tools/seed_casdoor.py — run it once after the
|
||||
# first boot:
|
||||
# docker compose run --rm -e CASDOOR_BASE=http://casdoor:8000 api python tools/seed_casdoor.py
|
||||
#
|
||||
# Casdoor uses the SAME postgres instance as the app (database `casdoor`,
|
||||
# created by sql/02-casdoor-db.sql on a fresh volume).
|
||||
# ==========================================================================
|
||||
casdoor:
|
||||
image: casbin/casdoor:latest
|
||||
container_name: procurement-casdoor
|
||||
@@ -195,10 +135,10 @@ services:
|
||||
- "8080:8000"
|
||||
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.casdoor.rule=Host(`${AUTH_HOST:-auth.localhost}`)
|
||||
- traefik.http.routers.casdoor.entrypoints=web
|
||||
- traefik.http.services.casdoor.loadbalancer.server.port=8000
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.casdoor.rule=Host(`${AUTH_HOST:-auth.localhost}`)"
|
||||
- "traefik.http.routers.casdoor.entrypoints=web"
|
||||
- "traefik.http.services.casdoor.loadbalancer.server.port=8000"
|
||||
|
||||
networks:
|
||||
- traefik_network
|
||||
@@ -220,14 +160,9 @@ services:
|
||||
PROCUREMENT_MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-minioadmin}
|
||||
PROCUREMENT_MINIO_BUCKET: procurement-files
|
||||
PROCUREMENT_MINIO_SECURE: "false"
|
||||
# "dev" enables the local defaults; "prod" turns on the fail-fast
|
||||
# startup checks (no wildcard CORS, no default credentials).
|
||||
PROCUREMENT_ENVIRONMENT: ${ENVIRONMENT:-dev}
|
||||
PROCUREMENT_DEV_LOGIN_ENABLED: ${DEV_LOGIN_ENABLED:-false}
|
||||
# Comma-separated browser origins (JSON-free .env friendly).
|
||||
PROCUREMENT_CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost,http://localhost:3000,http://localhost:5173}
|
||||
# Casdoor: endpoint is the in-network service URL; the issuer must match
|
||||
# the origin Casdoor advertises to browsers (its OIDC discovery doc).
|
||||
PROCUREMENT_CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost,http://localhost:3000,http://localhost:5173,http://192.168.1.104}
|
||||
PROCUREMENT_CASDOOR_ENDPOINT: http://casdoor:8000
|
||||
PROCUREMENT_CASDOOR_ISSUER: ${CASDOOR_ISSUER:-http://localhost:8080}
|
||||
PROCUREMENT_CASDOOR_CLIENT_ID: inventory-app-client
|
||||
@@ -235,10 +170,8 @@ services:
|
||||
PROCUREMENT_CASDOOR_ORGANIZATION: inventory
|
||||
PROCUREMENT_CASDOOR_APP_NAME: inventory-app
|
||||
PROCUREMENT_CASDOOR_AUDIENCE: inventory-app-client
|
||||
# Console admin used by POST /users/sync (identity mirroring).
|
||||
PROCUREMENT_CASDOOR_ADMIN_USER: admin
|
||||
PROCUREMENT_CASDOOR_ADMIN_PASSWORD: ${CASDOOR_ADMIN_PASSWORD:-123}
|
||||
# Observability (Phase 2) — see monitoring/README.md
|
||||
PROCUREMENT_LOG_FORMAT: ${LOG_FORMAT:-text}
|
||||
PROCUREMENT_SENTRY_DSN: ${SENTRY_DSN_BACKEND:-}
|
||||
PROCUREMENT_OPENPANEL_API_URL: ${OPENPANEL_API_URL:-}
|
||||
@@ -251,7 +184,7 @@ services:
|
||||
networks:
|
||||
- traefik_network
|
||||
- default
|
||||
- shared # monitoring stack scrapes api:8000/metrics
|
||||
- shared
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "python -c \"import urllib.request as u; u.urlopen('http://127.0.0.1:8000/health', timeout=3)\""]
|
||||
@@ -261,10 +194,10 @@ services:
|
||||
start_period: 40s
|
||||
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.api.rule=Host(`${API_HOST:-api.localhost}`)
|
||||
- traefik.http.routers.api.entrypoints=web
|
||||
- traefik.http.services.api.loadbalancer.server.port=8000
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.api.rule=Host(`${API_HOST:-api.localhost}`)"
|
||||
- "traefik.http.routers.api.entrypoints=web"
|
||||
- "traefik.http.services.api.loadbalancer.server.port=8000"
|
||||
|
||||
depends_on:
|
||||
postgres:
|
||||
@@ -279,27 +212,19 @@ services:
|
||||
build:
|
||||
context: ./frontend
|
||||
args:
|
||||
# Developer Mode panel on the login page — dev-only aid. Production
|
||||
# builds MUST leave this false (identity comes from Casdoor tokens).
|
||||
DEV_LOGIN_ENABLED: ${DEV_LOGIN_ENABLED:-false}
|
||||
# Registry overridable for networks where npmmirror is unreachable.
|
||||
NPM_REGISTRY: ${NPM_REGISTRY:-https://registry.npmmirror.com}
|
||||
# Observability (build-time baked; empty = disabled)
|
||||
VITE_OPENPANEL_URL: ${OPENPANEL_PUBLIC_URL:-}
|
||||
VITE_OPENPANEL_CLIENT_ID: ${OPENPANEL_CLIENT_ID:-}
|
||||
VITE_SENTRY_DSN: ${SENTRY_DSN_FRONTEND:-}
|
||||
container_name: procurement-frontend
|
||||
# از entrypoint پیشفرض image nginx رد میشویم: آن اسکریپت `apk manifest`
|
||||
# اجرا میکند و اگر دسترسی به مخزن بستهها نباشد بینهایت معلق میماند
|
||||
# (سرویس بالا نمیآید و traefik خطای 502 میدهد). کانفیگ ما مستقل است و
|
||||
# نیازی به آن اسکریپتها ندارد.
|
||||
entrypoint: ["nginx", "-g", "daemon off;"]
|
||||
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.frontend.rule=Host(`${APP_HOST:-localhost}`)
|
||||
- traefik.http.routers.frontend.entrypoints=web
|
||||
- traefik.http.services.frontend.loadbalancer.server.port=80
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.frontend.rule=PathPrefix(`/`)"
|
||||
- "traefik.http.routers.frontend.entrypoints=web"
|
||||
- "traefik.http.services.frontend.loadbalancer.server.port=80"
|
||||
|
||||
networks:
|
||||
- traefik_network
|
||||
@@ -312,13 +237,9 @@ services:
|
||||
mem_limit: 128m
|
||||
|
||||
networks:
|
||||
# شبکهی عمومی: فقط ترافیک و سرویسهای عمومی. postgres روی شبکهی
|
||||
# پیشفرض میماند و از بیرون قابل دسترسی نیست.
|
||||
traefik_network:
|
||||
name: traefik_network
|
||||
driver: bridge
|
||||
# Fixed-name network shared with the monitoring stack (monitoring compose
|
||||
# joins it as external) — carries /metrics scrapes and postgres-exporter.
|
||||
shared:
|
||||
name: erp_shared
|
||||
driver: bridge
|
||||
@@ -326,4 +247,4 @@ networks:
|
||||
volumes:
|
||||
postgres_data:
|
||||
minio_data:
|
||||
traefik_certs:
|
||||
traefik_certs:
|
||||
@@ -12,9 +12,9 @@ export interface MilestoneRowProps {
|
||||
// یک ردیف مرحلهی پرداخت: تاریخ + مبلغ + دکمهی حذف
|
||||
export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }: MilestoneRowProps) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 w-full my-2">
|
||||
{/* کادر انتخاب تاریخ */}
|
||||
<div className="w-36 shrink-0">
|
||||
<div className="flex items-start gap-2 w-full my-2">
|
||||
{/* کادر انتخاب تاریخ — جمعوجور و دقیقاً متناسب با فرمت تاریخ */}
|
||||
<div className="w-28 shrink-0">
|
||||
<JalaliDatePicker
|
||||
value={milestone.when_iso}
|
||||
onChange={(iso) => onChange({ ...milestone, when_iso: iso })}
|
||||
@@ -23,7 +23,7 @@ export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* کادر ورود مبلغ و حروف زیر آن */}
|
||||
{/* کادر ورود مبلغ — کشیده و پرکننده حداکثری عرض صفحه */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<NumberInput
|
||||
value={milestone.amount}
|
||||
|
||||
@@ -7,24 +7,26 @@ export interface NumberInputProps {
|
||||
onChange: (n: number | null) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
showWords?: boolean; // نمایش معادل فارسی زیر input
|
||||
inputClassName?: string;
|
||||
align?: "right" | "center" | "left";
|
||||
showWords?: boolean;
|
||||
inputMode?: "numeric" | "decimal" | undefined;
|
||||
maxDecimals?: number; // حداکثر رقم اعشار (مثلاً ۲ برای قیمت واحد)
|
||||
maxDecimals?: number;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// ورودی عددی با فرمتدهی خودکار (groupCommas) و نمایش کلمات فارسی
|
||||
export function NumberInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
className,
|
||||
inputClassName,
|
||||
align = "right",
|
||||
showWords = false,
|
||||
inputMode = "numeric",
|
||||
maxDecimals,
|
||||
disabled = false,
|
||||
}: NumberInputProps) {
|
||||
// فرمت با رعایت حداکثر اعشار (گرد کردن)
|
||||
const formatValue = (n: number): string => {
|
||||
let v = n;
|
||||
if (maxDecimals != null) {
|
||||
@@ -36,8 +38,6 @@ export function NumberInput({
|
||||
|
||||
const [text, setText] = useState<string>(() => (value != null ? formatValue(value) : ""));
|
||||
|
||||
// همگامسازی وقتی value از بیرون عوض میشود (مثلاً محاسبهی خودکار قیمت کل).
|
||||
// null از بیرون متن کاربر را پاک نمیکند — تا تایپ میانی مثل «0.» از بین نرود.
|
||||
useEffect(() => {
|
||||
const cur = parseNum(text);
|
||||
if (value != null && value !== cur) {
|
||||
@@ -60,19 +60,28 @@ export function NumberInput({
|
||||
|
||||
const n = parseNum(text);
|
||||
|
||||
const alignClass =
|
||||
align === "center"
|
||||
? "!text-center text-center"
|
||||
: align === "left"
|
||||
? "!text-left text-left"
|
||||
: "!text-right text-right";
|
||||
|
||||
return (
|
||||
<div className={cn("min-w-0", className)}>
|
||||
<div className={cn("min-w-0 w-full", className)}>
|
||||
<input
|
||||
type="text"
|
||||
disabled={disabled}
|
||||
className="num-input h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
className={cn(
|
||||
"num-input h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-xs font-normal text-ink shadow-xs transition-colors placeholder:text-xs placeholder:font-normal placeholder:text-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60",
|
||||
alignClass,
|
||||
inputClassName,
|
||||
)}
|
||||
value={text}
|
||||
placeholder={placeholder}
|
||||
inputMode={inputMode}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
// فرمتدهی خودکار هنگام تایپ — اما جداکنندهی اعشارِ وسط تایپ
|
||||
// («0.» یا «1.») دستنخورده میماند تا کاربر بتواند اعشار بزند
|
||||
const parsed = parseNum(raw);
|
||||
const trailingSep = /[.,،/٬٫]$/.test(raw);
|
||||
if (parsed != null && !trailingSep) {
|
||||
@@ -85,17 +94,12 @@ export function NumberInput({
|
||||
}}
|
||||
onBlur={() => commit(text)}
|
||||
/>
|
||||
{showWords && (
|
||||
<span className={cn("num-pill", n == null && "empty")}>
|
||||
{n != null ? (
|
||||
<>
|
||||
{groupCommas(n, true)} <span className="num-words">({toWords(n)})</span>
|
||||
</>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</span>
|
||||
{showWords && n != null && (
|
||||
<div className="mt-1 block text-right text-[11px] text-muted">
|
||||
<span className="tabular-nums font-semibold text-ink/80">{groupCommas(n, true)}</span>{" "}
|
||||
<span className="text-muted">({toWords(n)})</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import { JalaliDatePicker } from "../../components/JalaliDatePicker";
|
||||
import { NumberInput } from "../../components/NumberInput";
|
||||
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
|
||||
import { FileList } from "../../components/FileList";
|
||||
import { MilestoneRow } from "../../components/MilestoneRow";
|
||||
import { TermsheetCard } from "../../components/TermsheetCard";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -86,6 +86,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
const [selectedProjects, setSelectedProjects] = useState<string[]>([]);
|
||||
const [notes, setNotes] = useState("");
|
||||
const [invoices, setInvoices] = useState<Attachment[]>([]);
|
||||
const [proformas, setProformas] = useState<Attachment[]>([]);
|
||||
const [itemRows, setItemRows] = useState<EditItemRow[]>([]);
|
||||
const [tsCards, setTsCards] = useState<EditTsCard[]>([]);
|
||||
const [quoteDate, setQuoteDate] = useState<string | null>(null);
|
||||
@@ -153,8 +154,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
return {
|
||||
key: li.id,
|
||||
origLi: li,
|
||||
// «متریال» این ردیف = نامِ خودِ متریال (v2.7: متریال دسته ندارد؛
|
||||
// دسته روی تأمینکننده است و دیگر برچسبِ این فیلد نیست)
|
||||
cat: m.name || "",
|
||||
brand: m.brand || "",
|
||||
specs: materialSpecs(m),
|
||||
@@ -191,27 +190,65 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
.list(quoteId, "invoice")
|
||||
.then((list) => alive && setInvoices(list))
|
||||
.catch(() => alive && setInvoices([]));
|
||||
void api.files
|
||||
.list(quoteId, "proforma")
|
||||
.then((list) => alive && setProformas(list))
|
||||
.catch(() => alive && setProformas([]));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [quoteId]);
|
||||
|
||||
if (!q || !ready) return null;
|
||||
const itemOptions = useMemo(() => {
|
||||
return itemRows
|
||||
.filter((r) => !r.rejected || r.restore)
|
||||
.map((r, idx) => ({
|
||||
tempId: r.key,
|
||||
label: `${faNum(idx + 1)}. ${r.cat || "قلم جدید"}${r.brand ? ` • ${r.brand}` : ""}`,
|
||||
}));
|
||||
}, [itemRows]);
|
||||
|
||||
const tsLabel = (card: EditTsCard, idx: number) => {
|
||||
const orig = q.termsheets.find((t) => t.id === card.domId);
|
||||
return orig?.label || `گزینه ${faNum(idx + 1)}`;
|
||||
const getCoveredIds = (card: EditTsCard) => {
|
||||
const cardId = card.domId || card.key;
|
||||
const explicit = itemRows
|
||||
.filter((r) => (!r.rejected || r.restore) && r.tsDomIds.includes(cardId))
|
||||
.map((r) => r.key);
|
||||
|
||||
if (!explicit.length && tsCards[0]?.key === card.key) {
|
||||
return itemRows.filter((r) => !r.rejected || r.restore).map((r) => r.key);
|
||||
}
|
||||
return explicit;
|
||||
};
|
||||
|
||||
const handleCoverageChange = (cardKey: string, newCoveredKeys: string[]) => {
|
||||
const card = tsCards.find((c) => c.key === cardKey);
|
||||
const cardId = card?.domId || cardKey;
|
||||
setItemRows((rows) =>
|
||||
rows.map((row) => {
|
||||
const shouldHave = newCoveredKeys.includes(row.key);
|
||||
const exists = row.tsDomIds.includes(cardId);
|
||||
if (shouldHave === exists) return row;
|
||||
const nextTsDomIds = shouldHave
|
||||
? [...row.tsDomIds, cardId]
|
||||
: row.tsDomIds.filter((id) => id !== cardId);
|
||||
return { ...row, tsDomIds: nextTsDomIds };
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
if (!q || !ready) return null;
|
||||
|
||||
const patchItem = (key: string, patch: Partial<EditItemRow>) => {
|
||||
setItemRows((rows) => rows.map((r) => (r.key === key ? { ...r, ...patch } : r)));
|
||||
};
|
||||
|
||||
const addItemRow = () => {
|
||||
const newKey = "new-" + Math.random().toString(36).slice(2, 9);
|
||||
const defaultTsId = tsCards[0] ? tsCards[0].domId || tsCards[0].key : "";
|
||||
setItemRows((rows) => [
|
||||
...rows,
|
||||
{
|
||||
key: "new-" + Math.random().toString(36).slice(2, 9),
|
||||
key: newKey,
|
||||
cat: "",
|
||||
brand: "",
|
||||
specs: [],
|
||||
@@ -219,7 +256,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
unit: "",
|
||||
price: null,
|
||||
total: null,
|
||||
tsDomIds: tsCards[0] ? [tsCards[0].domId || tsCards[0].key] : [],
|
||||
tsDomIds: defaultTsId ? [defaultTsId] : [],
|
||||
acceptedTsId: null,
|
||||
rejected: false,
|
||||
restore: true,
|
||||
@@ -234,12 +271,28 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
const addTsCard = () => {
|
||||
setTsCards((cards) => [
|
||||
...cards,
|
||||
{ key: "new-" + Math.random().toString(36).slice(2, 9), domId: "", milestones: [{ when_iso: null, amount: null }] },
|
||||
{
|
||||
key: "new-ts-" + Date.now() + Math.random().toString(36).slice(2, 6),
|
||||
domId: "",
|
||||
milestones: [{ when_iso: null, amount: null }],
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const removeTsCard = (key: string) => {
|
||||
if (tsCards.length <= 1) {
|
||||
toast.error("حداقل یک روش شرایط پرداخت باید وجود داشته باشد.");
|
||||
return;
|
||||
}
|
||||
setTsCards((cards) => cards.filter((c) => c.key !== key));
|
||||
const card = tsCards.find((c) => c.key === key);
|
||||
const cardId = card?.domId || key;
|
||||
setItemRows((rows) =>
|
||||
rows.map((r) => ({
|
||||
...r,
|
||||
tsDomIds: r.tsDomIds.filter((id) => id !== cardId && id !== key),
|
||||
})),
|
||||
);
|
||||
};
|
||||
|
||||
const confirm = async () => {
|
||||
@@ -266,6 +319,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
next.projects = [...selectedProjects];
|
||||
next.notes = notes.trim();
|
||||
next.invoices = invoices;
|
||||
next.proformas = proformas;
|
||||
|
||||
const tsIdMap: Record<string, string> = {};
|
||||
const newTermsheets: Termsheet[] = [];
|
||||
@@ -276,6 +330,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
? domId
|
||||
: nextDashedId("ts", newTermsheets.length ? newTermsheets : next.termsheets);
|
||||
tsIdMap[domId] = realId;
|
||||
tsIdMap[card.key] = realId;
|
||||
const orig = next.termsheets.find((t) => t.id === realId);
|
||||
const label = orig?.label || `گزینه ${faNum(i + 1)}`;
|
||||
const st = orig?.status || "offered";
|
||||
@@ -295,7 +350,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
const total = row.total;
|
||||
if (!cat && !brand && qty == null && price == null) continue;
|
||||
|
||||
// هویت متریال = نام + برند + مشخصه (دیگر دستهای برای تطبیق وجود ندارد)
|
||||
let material = matchMaterial(materialsCopy, cat, brand, specs);
|
||||
let materialId: string;
|
||||
if (material) {
|
||||
@@ -322,7 +376,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
if (!rejected) {
|
||||
row.tsDomIds.forEach((domId) => {
|
||||
const realId = tsIdMap[domId] || domId;
|
||||
if (realId) termsheet_ids.push(realId);
|
||||
if (realId && !termsheet_ids.includes(realId)) termsheet_ids.push(realId);
|
||||
});
|
||||
if (!termsheet_ids.length && next.termsheets.length) {
|
||||
termsheet_ids.push(next.termsheets[0].id);
|
||||
@@ -359,7 +413,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
|
||||
const referencedTs = new Set<string>();
|
||||
next.line_items.forEach((li) => (li.termsheet_ids || []).forEach((tid) => referencedTs.add(tid)));
|
||||
next.termsheets = next.termsheets.filter((t) => referencedTs.has(t.id));
|
||||
next.termsheets = next.termsheets.filter((t, idx) => referencedTs.has(t.id) || idx === 0);
|
||||
next.line_items.forEach((li) => {
|
||||
if (li.accepted_ts_id && !next.termsheets.some((t) => t.id === li.accepted_ts_id)) {
|
||||
li.accepted_ts_id = null;
|
||||
@@ -406,7 +460,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
<>
|
||||
<Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="flex h-[88vh] max-h-[850px] w-[95vw] max-w-3xl flex-col p-0 overflow-hidden rounded-2xl border border-gray-200 bg-white text-gray-900 shadow-2xl">
|
||||
{/* هدر سفید و تمیز */}
|
||||
{/* هدر */}
|
||||
<DialogHeader className="border-b border-gray-100 bg-white px-4 py-3 sm:px-6 sm:py-4 shrink-0 text-right">
|
||||
<DialogTitle className="text-base font-bold text-gray-900 sm:text-lg">ویرایش پیشنهاد خرید</DialogTitle>
|
||||
<div className="text-[12px] text-gray-500 truncate mt-0.5">
|
||||
@@ -421,7 +475,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* تببار سفید و استاندارد */}
|
||||
{/* تببار */}
|
||||
<div className="flex items-center border-b border-gray-100 bg-gray-50/70 px-3 py-2 sm:px-6 gap-2 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
@@ -492,12 +546,11 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* محتوای مودال — پسزمینه کاملاً سفید */}
|
||||
{/* محتوا */}
|
||||
<div className="flex-1 overflow-y-auto p-3.5 sm:p-5 space-y-4 bg-white text-gray-800">
|
||||
{/* تب ۱: اقلام و مشخصات */}
|
||||
{/* تب ۱: اقلام */}
|
||||
{activeTab === "items" && (
|
||||
<div className="space-y-4">
|
||||
{/* کارت تأمینکننده و پروژه */}
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-50/50 p-3.5 sm:p-4 space-y-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
@@ -536,7 +589,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* لیست اقلام */}
|
||||
{/* لیست اقلام — چیدمان ۵ سطری هماهنگ با ویزارد */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-gray-800">اقلام سفارش ({faNum(itemRows.length)})</span>
|
||||
@@ -573,23 +626,24 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[11px] font-semibold text-gray-600">متریال</label>
|
||||
{/* ردیف ۱: متریال و برند */}
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<label className="text-[11px] font-semibold text-gray-600 text-right">متریال</label>
|
||||
<input
|
||||
disabled={hasReceipts}
|
||||
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
|
||||
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 text-right focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
|
||||
placeholder="نام متریال"
|
||||
value={row.cat}
|
||||
onChange={(e) => patchItem(row.key, { cat: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[11px] font-semibold text-gray-600">برند</label>
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<label className="text-[11px] font-semibold text-gray-600 text-right">برند</label>
|
||||
<input
|
||||
disabled={hasReceipts}
|
||||
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
|
||||
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 text-right focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
|
||||
placeholder="برند"
|
||||
value={row.brand}
|
||||
onChange={(e) => patchItem(row.key, { brand: e.target.value })}
|
||||
@@ -597,65 +651,75 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[11px] font-semibold text-gray-600">مشخصه</label>
|
||||
<MultiSelectCombobox
|
||||
disabled={hasReceipts}
|
||||
values={row.specs}
|
||||
onChange={(specs) => patchItem(row.key, { specs })}
|
||||
options={specOptions}
|
||||
placeholder="مشخصه"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[11px] font-semibold text-gray-600">واحد</label>
|
||||
<input
|
||||
disabled={hasReceipts}
|
||||
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
|
||||
placeholder="واحد سنجش"
|
||||
value={row.unit}
|
||||
onChange={(e) => patchItem(row.key, { unit: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
{/* ردیف ۲: مشخصه فنی (تمامعرض با فضای کافی برای تگها) */}
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<label className="text-[11px] font-semibold text-gray-600 text-right">مشخصه فنی / ابعاد</label>
|
||||
<MultiSelectCombobox
|
||||
disabled={hasReceipts}
|
||||
values={row.specs}
|
||||
onChange={(specs) => patchItem(row.key, { specs })}
|
||||
options={specOptions}
|
||||
placeholder="انتخاب مشخصات..."
|
||||
className="min-w-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* جعبه ۳ عددی مقادیر */}
|
||||
<div className="grid grid-cols-3 gap-2 rounded-lg bg-gray-50 p-2.5 border border-gray-200">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10.5px] font-semibold text-gray-600">تعداد</label>
|
||||
<NumberInput
|
||||
disabled={hasReceipts}
|
||||
value={row.qty}
|
||||
onChange={(qty) => {
|
||||
patchItem(row.key, {
|
||||
qty,
|
||||
total: qty != null && row.price != null ? Math.round(qty * row.price * 100) / 100 : row.total,
|
||||
});
|
||||
}}
|
||||
placeholder="تعداد"
|
||||
/>
|
||||
{/* بخش مقادیر و محاسبات مالی */}
|
||||
<div className="rounded-lg bg-gray-50/70 p-2.5 border border-gray-200 space-y-3">
|
||||
{/* ردیف ۳: تعداد + واحد سنجش */}
|
||||
<div className="grid grid-cols-[100px_1fr] sm:grid-cols-[120px_1fr] gap-2.5">
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<label className="text-[10.5px] font-semibold text-gray-600 text-right">تعداد</label>
|
||||
<NumberInput
|
||||
disabled={hasReceipts}
|
||||
value={row.qty}
|
||||
onChange={(qty) => {
|
||||
patchItem(row.key, {
|
||||
qty,
|
||||
total: qty != null && row.price != null ? Math.round(qty * row.price) : row.total,
|
||||
});
|
||||
}}
|
||||
placeholder="مقدار"
|
||||
className="!text-right text-right"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<label className="text-[10.5px] font-semibold text-gray-600 text-right">واحد سنجش</label>
|
||||
<input
|
||||
disabled={hasReceipts}
|
||||
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 text-right focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
|
||||
placeholder="واحد (شاخه، متر، کیلو...)"
|
||||
value={row.unit}
|
||||
onChange={(e) => patchItem(row.key, { unit: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10.5px] font-semibold text-gray-600">قیمت واحد</label>
|
||||
{/* ردیف ۴: قیمت هر واحد (تمامعرض با نمایش حروف) */}
|
||||
<div className="flex flex-col gap-1 min-w-0 border-t border-gray-200/80 pt-2.5">
|
||||
<label className="text-[10.5px] font-semibold text-gray-600 text-right">
|
||||
{row.unit ? `قیمت هر ${row.unit} (تومان)` : "قیمت هر واحد (تومان)"}
|
||||
</label>
|
||||
<NumberInput
|
||||
disabled={hasReceipts}
|
||||
value={row.price}
|
||||
onChange={(price) => {
|
||||
patchItem(row.key, {
|
||||
price,
|
||||
total: row.qty != null && price != null ? Math.round(row.qty * price * 100) / 100 : row.total,
|
||||
total: row.qty != null && price != null ? Math.round(row.qty * price) : row.total,
|
||||
});
|
||||
}}
|
||||
placeholder="واحد"
|
||||
placeholder="قیمت هر واحد"
|
||||
showWords
|
||||
maxDecimals={2}
|
||||
className="!text-right text-right"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10.5px] font-semibold text-gray-600">قیمت کل</label>
|
||||
{/* ردیف ۵: قیمت کل این قلم (تمامعرض با نمایش حروف) */}
|
||||
<div className="flex flex-col gap-1 min-w-0 border-t border-gray-200/80 pt-2.5">
|
||||
<label className="text-[10.5px] font-semibold text-gray-600 text-right">قیمت کل این قلم (تومان)</label>
|
||||
<NumberInput
|
||||
disabled={hasReceipts}
|
||||
value={row.total}
|
||||
@@ -666,14 +730,15 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
t != null && row.qty != null && row.qty > 0 ? Math.round((t / row.qty) * 100) / 100 : row.price,
|
||||
});
|
||||
}}
|
||||
placeholder="جمع"
|
||||
placeholder="قیمت کل قلم"
|
||||
showWords
|
||||
className="!text-right text-right"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* شرایط پرداخت برای این قلم */}
|
||||
<div className="pt-2 border-t border-gray-100">
|
||||
{row.rejected ? (
|
||||
{row.rejected && (
|
||||
<div className="pt-2 border-t border-gray-100">
|
||||
<label className="inline-flex items-center gap-1.5 text-xs font-semibold text-blue-600 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -683,44 +748,8 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
/>
|
||||
بازگرداندن این قلم به معامله
|
||||
</label>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-[10.5px] font-semibold text-gray-500">شرایط پرداخت مرتبط:</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{tsCards.map((card, cIdx) => {
|
||||
const isSelected = row.tsDomIds.includes(card.domId || card.key);
|
||||
return (
|
||||
<label
|
||||
key={card.key}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs cursor-pointer select-none transition-colors",
|
||||
isSelected
|
||||
? "border-blue-500 bg-blue-50 font-semibold text-blue-700"
|
||||
: "border-gray-200 bg-white text-gray-600 hover:text-gray-900",
|
||||
hasReceipts && "cursor-not-allowed opacity-60",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={hasReceipts}
|
||||
className="accent-blue-600 h-3.5 w-3.5 rounded"
|
||||
checked={isSelected}
|
||||
onChange={(e) => {
|
||||
const next = new Set(row.tsDomIds);
|
||||
const domId = card.domId || card.key;
|
||||
if (e.target.checked) next.add(domId);
|
||||
else next.delete(domId);
|
||||
patchItem(row.key, { tsDomIds: [...next] });
|
||||
}}
|
||||
/>
|
||||
<span>{tsLabel(card, cIdx)}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -741,11 +770,16 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* تب ۲: شرایط پرداخت */}
|
||||
{/* تب ۲: شرایط پرداخت با قالب TermsheetCard ویزارد */}
|
||||
{activeTab === "terms" && (
|
||||
<div className="space-y-3.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs font-bold text-gray-800">گزینههای شرایط پرداخت</div>
|
||||
<div>
|
||||
<div className="text-xs font-bold text-gray-800">گزینههای شرایط پرداخت</div>
|
||||
<div className="text-[11.5px] text-gray-500 mt-0.5">
|
||||
روشهای پرداخت و اقلام تحت پوشش هر کدام را در کارت خودش مشخص کنید.
|
||||
</div>
|
||||
</div>
|
||||
{!hasReceipts && (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -762,70 +796,16 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{tsCards.map((card, idx) => (
|
||||
<div
|
||||
<TermsheetCard
|
||||
key={card.key}
|
||||
className="rounded-xl border border-gray-200 bg-white p-3.5 sm:p-4 shadow-xs"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-gray-100 pb-2 mb-2.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-blue-50 text-[11px] font-bold text-blue-600">
|
||||
{faNum(idx + 1)}
|
||||
</span>
|
||||
<span className="text-xs font-bold text-gray-900">{tsLabel(card, idx)}</span>
|
||||
<span className="text-[11px] text-gray-500 font-medium">
|
||||
({faNum(card.milestones.length)} مرحله)
|
||||
</span>
|
||||
</div>
|
||||
{!hasReceipts && tsCards.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs text-red-600 hover:bg-red-50 transition-colors"
|
||||
title="حذف گزینه"
|
||||
onClick={() => removeTsCard(card.key)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
<span className="text-[11px]">حذف</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 mb-3">
|
||||
{card.milestones.map((ms, i) => (
|
||||
<MilestoneRow
|
||||
key={i}
|
||||
milestone={ms}
|
||||
disabled={hasReceipts}
|
||||
onChange={(nextMs) =>
|
||||
updateTs(card.key, {
|
||||
milestones: card.milestones.map((x, j) => (j === i ? nextMs : x)),
|
||||
})
|
||||
}
|
||||
onRemove={() =>
|
||||
updateTs(card.key, {
|
||||
milestones: card.milestones.filter((_, j) => j !== i),
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!hasReceipts && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="line"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto text-xs text-gray-700"
|
||||
onClick={() =>
|
||||
updateTs(card.key, {
|
||||
milestones: [...card.milestones, { when_iso: null, amount: null }],
|
||||
})
|
||||
}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 me-1" />
|
||||
افزودن مرحله پرداخت
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
index={idx + 1}
|
||||
milestones={card.milestones}
|
||||
onMilestonesChange={(ms) => updateTs(card.key, { milestones: ms })}
|
||||
items={itemOptions}
|
||||
coveredIds={getCoveredIds(card)}
|
||||
onCoverageChange={(ids) => handleCoverageChange(card.key, ids)}
|
||||
onRemove={() => removeTsCard(card.key)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -891,11 +871,30 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
loadPreview={(a) => api.files.previewUrl(q.id, "invoice", a.id!)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* پیشفاکتورها — در همهی مراحل چرخهی پیشنهاد قابل افزودن/ویرایش است */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-3.5 sm:p-4 space-y-2 shadow-xs">
|
||||
<div className="text-xs font-bold text-gray-900">پیشفاکتورها</div>
|
||||
<FileList
|
||||
files={proformas}
|
||||
onChange={setProformas}
|
||||
upload={(f) => api.files.upload(q.id, "proforma", f)}
|
||||
onDelete={(a) =>
|
||||
a.id &&
|
||||
void api.files.remove(q.id, "proforma", a.id).catch((err) =>
|
||||
toast.error(err instanceof Error ? err.message : "حذف پیشفاکتور ناموفق بود"),
|
||||
)
|
||||
}
|
||||
onDownload={(a) => api.files.download(q.id, "proforma", a.id!, a.name)}
|
||||
loadPreview={(a) => api.files.previewUrl(q.id, "proforma", a.id!)}
|
||||
label="افزودن پیشفاکتور"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* فوتر سفید، محکم و چسبان */}
|
||||
{/* فوتر */}
|
||||
<DialogFooter className="border-t border-gray-200 bg-white px-4 py-2.5 sm:px-6 sm:py-3 shrink-0 flex flex-row items-center justify-between gap-2">
|
||||
{q.status === "offered" ? (
|
||||
<Button
|
||||
@@ -924,7 +923,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* مودال تایید حذف سفید */}
|
||||
{/* مودال تایید حذف */}
|
||||
<Dialog open={showDeleteConfirm} onOpenChange={(open) => !deleting && setShowDeleteConfirm(open)}>
|
||||
<DialogContent className="z-[60] max-w-md p-4 sm:p-6 bg-white border border-gray-200 text-gray-900">
|
||||
<DialogHeader>
|
||||
|
||||
@@ -35,7 +35,6 @@ function InfoCell({ label, children, className }: { label: string; children: Rea
|
||||
);
|
||||
}
|
||||
|
||||
// کارت پیشنهاد خرید — بهینهشده برای کاربری سرپا با موبایل
|
||||
export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit }: QuoteCardProps) {
|
||||
const sup = supplierById(store.suppliers, q.supplier_id);
|
||||
const expired = isExpired(q);
|
||||
@@ -49,7 +48,10 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
||||
if (q.status === "offered") {
|
||||
acceptedCell = (
|
||||
<button
|
||||
onClick={onAccept}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAccept();
|
||||
}}
|
||||
className="inline-flex h-7 items-center justify-center gap-1 rounded-md bg-emerald-600 px-2.5 text-xs font-bold text-white shadow-xs active:scale-95 transition-transform"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
@@ -134,53 +136,81 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
||||
expanded && "border-accent/60 ring-1 ring-accent/20",
|
||||
)}
|
||||
>
|
||||
{/* سربرگ کارت */}
|
||||
<div className="flex items-center gap-2.5 border-b border-line bg-bg/40 px-3 py-2 sm:gap-3 sm:px-4 sm:py-2.5">
|
||||
{/* سربرگ کارت — با کلیک روی فضای خالی اکسپند میشود */}
|
||||
<div
|
||||
onClick={onToggleExpand}
|
||||
className="flex cursor-pointer items-center gap-2.5 border-b border-line bg-bg/40 px-3 py-2 sm:gap-3 sm:px-4 sm:py-2.5 select-none"
|
||||
>
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-accent-bg text-xs font-bold text-accent-dark sm:h-9 sm:w-9 sm:text-sm">
|
||||
{sup.name.slice(0, 1)}
|
||||
</span>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
{/* خط ۱: فقط خود اسم تأمینکننده لینک است */}
|
||||
<div className="flex items-center">
|
||||
<Link
|
||||
to={`/suppliers/${q.supplier_id}`}
|
||||
className="min-w-0 flex-1 truncate text-xs font-bold text-ink no-underline hover:text-accent sm:text-[13.5px]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="inline-block max-w-full truncate text-xs font-bold text-ink no-underline hover:text-accent hover:underline sm:text-[13.5px]"
|
||||
>
|
||||
{sup.name}
|
||||
</Link>
|
||||
{/* فاکتور رسمی / غیررسمی — از چکباکس «فاکتور رسمی» هنگام ثبت پیشنهاد */}
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded px-1.5 py-px text-[9.5px] font-bold leading-4",
|
||||
q.is_official
|
||||
? "bg-green-bg text-green"
|
||||
: "bg-slate-bg text-muted",
|
||||
)}
|
||||
title={q.is_official ? "فاکتور رسمی" : "فاکتور غیررسمی"}
|
||||
>
|
||||
{q.is_official ? "رسمی" : "غیررسمی"}
|
||||
</span>
|
||||
</div>
|
||||
{sup.location && <div className="truncate text-[10px] text-muted sm:text-[11px]">{sup.location}</div>}
|
||||
|
||||
{/* خط ۲: موقعیت مکانی و برچسب هشدار فاکتور رسمی */}
|
||||
<div className="mt-0.5 flex min-w-0 items-center gap-1.5">
|
||||
{sup.location && (
|
||||
<span className="truncate text-[10px] text-muted sm:text-[11px]">
|
||||
{sup.location}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{q.is_official && (
|
||||
<span
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded bg-rose-500/10 px-1.5 py-0.5 text-[9.5px] font-bold text-rose-600 dark:text-rose-400 border border-rose-500/25 select-none"
|
||||
title="فاکتور رسمی (مشمول ارزش افزوده و مالیات)"
|
||||
>
|
||||
<TriangleAlert className="h-2.5 w-2.5 shrink-0 stroke-[2.2] text-rose-500 dark:text-rose-400" />
|
||||
<span>فاکتور رسمی</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ms-auto flex shrink-0 items-center gap-1 sm:gap-1.5">
|
||||
|
||||
<div className="ms-auto flex shrink-0 items-center gap-1.5">
|
||||
{fulfillmentBadge ?? (
|
||||
<Badge variant={q.status as "offered" | "accepted" | "arrived"} className="text-[10px] sm:text-xs">
|
||||
{STATUS_LABEL[q.status]}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* دکمه ویرایش */}
|
||||
<button
|
||||
className="chip h-7 px-2 sm:h-auto sm:px-2.5"
|
||||
onClick={onEdit}
|
||||
type="button"
|
||||
className="inline-flex h-7 items-center justify-center gap-1 rounded-md border border-line bg-panel px-2 text-xs font-medium text-ink hover:bg-slate-50 dark:hover:bg-zinc-800 transition-colors sm:h-8 sm:px-2.5"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
title="ویرایش پیشنهاد"
|
||||
aria-label="ویرایش"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
<Pencil className="h-3 w-3 text-muted" />
|
||||
<span className="hidden sm:inline">ویرایش</span>
|
||||
</button>
|
||||
|
||||
{/* دکمه فلش اکسپند در هدر */}
|
||||
<button
|
||||
className="chip chip-expand h-7 w-7 p-0 sm:h-auto sm:w-auto sm:p-1.5"
|
||||
onClick={onToggleExpand}
|
||||
title={expanded ? "جمع کردن" : "نمایش جزئیات"}
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex h-7 w-7 items-center justify-center rounded-md border border-line bg-panel text-ink transition-colors hover:bg-slate-50 dark:hover:bg-zinc-800 sm:h-8 sm:w-8",
|
||||
expanded && "bg-accent-bg text-accent-dark border-accent/40",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleExpand();
|
||||
}}
|
||||
title={expanded ? "جمع کردن توضیحات" : "نمایش توضیحات و جزئیات"}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<ChevronDown className={cn("h-4 w-4 transition-transform duration-200", expanded && "rotate-180")} />
|
||||
@@ -188,9 +218,12 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* اطلاعات کلیدی — چیدمان بهینهشده موبایل */}
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-3 p-3 sm:gap-x-5 sm:gap-y-3.5 sm:p-4 md:grid-cols-4">
|
||||
{/* ۱. پروژه — تمامعرض در موبایل */}
|
||||
{/* اطلاعات کلیدی — لمس این بخش هم کارت را اکسپند میکند */}
|
||||
<div
|
||||
onClick={onToggleExpand}
|
||||
className="cursor-pointer grid grid-cols-2 gap-x-3 gap-y-3 p-3 select-none sm:gap-x-5 sm:gap-y-3.5 sm:p-4 md:grid-cols-4"
|
||||
>
|
||||
{/* ۱. پروژه */}
|
||||
<InfoCell label="پروژه" className="col-span-2 md:col-span-1">
|
||||
<div className="flex items-center gap-1.5 text-xs font-semibold text-ink sm:text-[13px]">
|
||||
<Building2 className="h-3.5 w-3.5 shrink-0 text-muted" />
|
||||
@@ -200,7 +233,7 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
||||
</div>
|
||||
</InfoCell>
|
||||
|
||||
{/* ۲. اقلام — نمایش عمودی چندخطی */}
|
||||
{/* ۲. اقلام */}
|
||||
<InfoCell label="اقلام" className="col-span-2 md:col-span-1">
|
||||
<div className="flex flex-col gap-1 text-xs font-medium leading-5 text-ink sm:text-[13px]">
|
||||
{activeItems.length > 0 ? (
|
||||
@@ -221,6 +254,7 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
||||
<Link
|
||||
key={w.unit}
|
||||
to={`/units?cat=${encodeURIComponent(w.cat)}&unit=${encodeURIComponent(w.unit)}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="unit-warn mt-1 inline-flex items-center gap-1 text-[10px]"
|
||||
title={`ضریب تبدیل «${esc(w.unit)}» را در داشبورد واحدها ثبت کنید`}
|
||||
>
|
||||
@@ -230,6 +264,7 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
||||
))}
|
||||
</InfoCell>
|
||||
|
||||
{/* ۳. مجموع مبلغ */}
|
||||
<InfoCell label="مجموع مبلغ" className="col-span-1">
|
||||
{pc ? (
|
||||
<div>
|
||||
@@ -286,8 +321,22 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* جزئیات تکمیلی بازشونده */}
|
||||
{expanded && <QuoteDetail store={store} q={q} />}
|
||||
{/* دکمه تمامعرض توضیحات بیشتر */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleExpand}
|
||||
className="flex w-full items-center justify-center gap-1.5 border-t border-line bg-bg/20 py-2 text-xs font-semibold text-muted hover:text-accent hover:bg-bg/50 transition-colors select-none"
|
||||
>
|
||||
<span>{expanded ? "بستن توضیحات و جزئیات" : "توضیحات بیشتر و جزئیات پیشنهاد"}</span>
|
||||
<ChevronDown className={cn("h-3.5 w-3.5 transition-transform duration-200", expanded && "rotate-180 text-accent")} />
|
||||
</button>
|
||||
|
||||
{/* جزئیات تکمیلی بازشونده — کلیک داخل آن مانع بسته شدن ناخواسته میشود */}
|
||||
{expanded && (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<QuoteDetail store={store} q={q} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { MapPin, Phone, User, Plus, Package, X } from "lucide-react";
|
||||
import { MapPin, Phone, User, Plus, Package, X, Clock } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useMaterials, useQuotes, useStoreData, useSuppliers } from "../../hooks";
|
||||
import { topicByName } from "../../lib/topics";
|
||||
import { useAppStore } from "../../store";
|
||||
import { api } from "../../lib/api";
|
||||
import { daysBetween } from "../../lib/formatters";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { EmptyState } from "../../components/EmptyState";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
@@ -14,6 +15,15 @@ import { ConfirmDeleteDialog } from "../../components/ConfirmDeleteDialog";
|
||||
import { normalize } from "../../lib/normalize";
|
||||
import { PurchaseHistory } from "./PurchaseHistory";
|
||||
|
||||
// تمیزکاری و استانداردسازی شماره برای شمارهگیر خودکار موبایل (اندروید و iOS)
|
||||
function cleanPhoneForTel(phone?: string | null): string {
|
||||
if (!phone) return "";
|
||||
const enDigits = phone
|
||||
.replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d)))
|
||||
.replace(/[٠-٩]/g, (d) => String("٠١٢٣٤٥٦٧٨٩".indexOf(d)));
|
||||
return enDigits.replace(/(?!^\+)[^\d]/g, "");
|
||||
}
|
||||
|
||||
// صفحهی مشخصات کامل تأمینکننده + سابقه خرید + مدیریت متریالهای تأمینشده
|
||||
export function SupplierDetailPage() {
|
||||
useStoreData();
|
||||
@@ -29,6 +39,19 @@ export function SupplierDetailPage() {
|
||||
const [linked, setLinked] = useState<string[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// محاسبه میانگین زمان تحویل این تأمینکننده بر اساس خریدهای تحویلشده
|
||||
const avgLeadTime = useMemo(() => {
|
||||
if (!s?.id) return null;
|
||||
const supQuotes = quotes.filter(
|
||||
(q) => q.supplier_id === s.id && q.status === "arrived" && q.accepted_date && q.arrived_date
|
||||
);
|
||||
const times = supQuotes
|
||||
.map((q) => daysBetween(q.arrived_date, q.accepted_date))
|
||||
.filter((x): x is number => x != null);
|
||||
|
||||
return times.length ? Math.round(times.reduce((a, b) => a + b, 0) / times.length) : null;
|
||||
}, [quotes, s?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
let alive = true;
|
||||
@@ -68,7 +91,6 @@ export function SupplierDetailPage() {
|
||||
const breadcrumb = `${topicName ? topicName + " — " : ""}${firstCat ? firstCat + " — " : ""}${s.name}`;
|
||||
|
||||
const saveCategories = (names: string[]) => {
|
||||
// v2.6: اتصال واقعی تأمینکننده ↔ متریال (کاتالوگ) — با نام متریال
|
||||
if (!id || busy) return;
|
||||
setBusy(true);
|
||||
void (async () => {
|
||||
@@ -116,21 +138,51 @@ export function SupplierDetailPage() {
|
||||
<Header title="اطلاعات تماس تأمینکننده" sub={breadcrumb} backTo="/suppliers" backLabel="تأمینکنندهها" />
|
||||
<PageLayout>
|
||||
<div className="max-w-xl rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-xl font-extrabold">{s.name}</div>
|
||||
<Badge variant="outline">{topicName}</Badge>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-xl font-extrabold">{s.name}</div>
|
||||
<Badge variant="outline">{topicName}</Badge>
|
||||
</div>
|
||||
{avgLeadTime != null && (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-md border border-emerald-800/60 bg-emerald-950 px-2.5 py-1 text-xs font-semibold text-emerald-400">
|
||||
<Clock className="h-3.5 w-3.5 text-emerald-400" />
|
||||
<span>{avgLeadTime} روز</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-col gap-1.5 text-[13.5px]">
|
||||
|
||||
<div className="mt-3 flex flex-col gap-2 text-[13.5px]">
|
||||
<span>
|
||||
<MapPin className="me-1.5 inline h-4 w-4 text-muted" /> <b>موقعیت:</b> {s.location || "—"}
|
||||
</span>
|
||||
<span>
|
||||
<Phone className="me-1.5 inline h-4 w-4 text-muted" /> <b>تلفن:</b> {s.phone || "—"}
|
||||
</span>
|
||||
|
||||
{/* بخش تلفن — همراه با قابلیت تماس مستقیم با یک لمس */}
|
||||
<div className="flex items-center">
|
||||
<Phone className="me-1.5 inline h-4 w-4 text-muted" />
|
||||
<b className="me-1">تلفن:</b>
|
||||
{s.phone ? (
|
||||
<a
|
||||
href={`tel:${cleanPhoneForTel(s.phone)}`}
|
||||
className="inline-flex items-center gap-1 font-semibold text-accent hover:underline active:opacity-80"
|
||||
dir="ltr"
|
||||
title="تماس مستقیم با تأمینکننده"
|
||||
>
|
||||
<span className="tabular-nums">{s.phone}</span>
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-muted">—</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span>
|
||||
<User className="me-1.5 inline h-4 w-4 text-muted" /> <b>شخص رابط:</b> {s.contact || "—"}
|
||||
</span>
|
||||
<span>
|
||||
<Clock className="me-1.5 inline h-4 w-4 text-muted" /> <b>میانگین زمان تحویل:</b>{" "}
|
||||
{avgLeadTime != null ? `${avgLeadTime} روز` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<Link
|
||||
to={`/wizard?topic=${encodeURIComponent(topicName)}&supplier=${encodeURIComponent(s.id)}`}
|
||||
@@ -151,7 +203,7 @@ export function SupplierDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* متریالهای این تأمینکننده — افزودن/حذف + مشاهدهی همه */}
|
||||
{/* متریالهای این تأمینکننده */}
|
||||
<div className="mt-4 max-w-xl rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||
<h4 className="mb-1 text-[13.5px] font-bold">
|
||||
<Package className="me-1 inline h-4 w-4 text-accent" /> متریالهای تأمینشده
|
||||
@@ -191,7 +243,7 @@ export function SupplierDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* سابقه خرید از این تأمینکننده — همهی پیشنهادها + فاکتور/پیشفاکتور */}
|
||||
{/* سابقه خرید از این تأمینکننده */}
|
||||
<div className="mt-4 max-w-4xl">
|
||||
<PurchaseHistory supplier={s} quotes={quotes} materials={materials} />
|
||||
</div>
|
||||
@@ -208,4 +260,4 @@ export function SupplierDetailPage() {
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,15 @@ import type { Supplier } from "../../lib/types";
|
||||
import { normalize } from "../../lib/normalize";
|
||||
import { fmtNum } from "../../lib/formatters";
|
||||
|
||||
// تمیزکاری و استانداردسازی شماره برای شمارهگیر خودکار موبایل (اندروید و iOS)
|
||||
function cleanPhoneForTel(phone?: string | null): string {
|
||||
if (!phone) return "";
|
||||
const enDigits = phone
|
||||
.replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d)))
|
||||
.replace(/[٠-٩]/g, (d) => String("٠١٢٣٤٥٦٧٨٩".indexOf(d)));
|
||||
return enDigits.replace(/(?!^\+)[^\d]/g, "");
|
||||
}
|
||||
|
||||
export function suppliersOfCategory(topic: string, cat: string, suppliers: Supplier[]): Supplier[] {
|
||||
return suppliers.filter(
|
||||
(s) => normalize(s.topic) === normalize(topic) && (s.categories || []).some((c) => normalize(c) === normalize(cat)),
|
||||
@@ -29,7 +38,11 @@ export function SupplierCard({
|
||||
return (
|
||||
<div className="sup-card">
|
||||
<div className="sc-head flex items-start justify-between gap-2">
|
||||
<Link to={`/suppliers/${s.id}`} className="sc-name-link min-w-0 flex-1 leading-snug">
|
||||
{/* فقط متن نام تأمینکننده لینک است و به کل کارت گسترش پیدا نمیکند */}
|
||||
<Link
|
||||
to={`/suppliers/${s.id}`}
|
||||
className="min-w-0 flex-1 text-sm font-bold text-primary hover:text-accent hover:underline active:opacity-70 leading-snug py-0.5"
|
||||
>
|
||||
{s.name}
|
||||
</Link>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
@@ -49,17 +62,34 @@ export function SupplierCard({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sc-meta">
|
||||
<span>
|
||||
<MapPin className="me-1 inline h-3 w-3" /> {s.location || "—"}
|
||||
</span>
|
||||
<span>
|
||||
<Phone className="me-1 inline h-3 w-3" /> {s.phone || "—"}
|
||||
</span>
|
||||
|
||||
{/* شماره تماس قابل کلیک برای تماس مستقیم در موبایل */}
|
||||
{s.phone ? (
|
||||
<a
|
||||
href={`tel:${cleanPhoneForTel(s.phone)}`}
|
||||
className="inline-flex items-center text-primary hover:text-accent hover:underline active:opacity-75 transition-colors"
|
||||
title="تماس مستقیم با تأمینکننده"
|
||||
dir="ltr"
|
||||
>
|
||||
<Phone className="me-1 inline h-3 w-3 text-muted shrink-0" />
|
||||
<span className="tabular-nums">{s.phone}</span>
|
||||
</a>
|
||||
) : (
|
||||
<span>
|
||||
<Phone className="me-1 inline h-3 w-3 text-muted" /> —
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span>
|
||||
<User className="me-1 inline h-3 w-3" /> {s.contact || "—"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{materials.length > 0 && (
|
||||
<div className="mt-2 border-t border-line pt-1.5">
|
||||
<div className="mb-1 flex flex-wrap gap-1">
|
||||
@@ -71,6 +101,7 @@ export function SupplierCard({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="sc-actions">
|
||||
<Link to={`/wizard?topic=${encodeURIComponent(topic)}&supplier=${encodeURIComponent(s.id)}`} className="btn-line-file no-underline">
|
||||
<Plus className="h-3 w-3" />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Trash2, Plus, Copy } from "lucide-react";
|
||||
import type { Material } from "../../lib/types";
|
||||
import { normalize } from "../../lib/normalize";
|
||||
import { useMaterials } from "../../hooks";
|
||||
@@ -7,6 +8,7 @@ import { Combobox } from "../../components/Combobox";
|
||||
import { NumberInput } from "../../components/NumberInput";
|
||||
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
|
||||
import { api, apiV26 } from "../../lib/api";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
|
||||
export interface ItemDraft {
|
||||
tempId: string;
|
||||
@@ -33,7 +35,6 @@ export interface Step2Props {
|
||||
onRemember: (rec: MaterialRec) => void;
|
||||
}
|
||||
|
||||
// برچسب انسانی یک قلم (برای termsheet، درگاندروپ و بازبینی)
|
||||
export function itemRowLabel(it: ItemDraft): string {
|
||||
const parts = [it.cat, it.brand, it.specs.join(" - ")].filter(Boolean);
|
||||
const name = parts.length ? parts.join(" - ") : "(قلم بدوننام)";
|
||||
@@ -41,7 +42,6 @@ export function itemRowLabel(it: ItemDraft): string {
|
||||
return qtyPart ? `${name} -- ${qtyPart}` : name;
|
||||
}
|
||||
|
||||
// مقادیر مجزای name/brand/spec در کاتالوگ سراسری
|
||||
export function materialOptions(
|
||||
materials: Material[],
|
||||
_topic: string,
|
||||
@@ -51,7 +51,6 @@ export function materialOptions(
|
||||
apiBrands: string[] = [],
|
||||
apiSpecs: string[] = [],
|
||||
): string[] {
|
||||
// متریالها سراسری هستند — مستقل از تاپیک
|
||||
let list = materials;
|
||||
const cat = item.cat.trim();
|
||||
|
||||
@@ -67,21 +66,17 @@ export function materialOptions(
|
||||
};
|
||||
|
||||
if (field === "brand") {
|
||||
// برند سراسری است — مستقل از تاپیک و متریال
|
||||
const allBrands = new Set(materials.map((m) => m.brand).filter(Boolean));
|
||||
apiBrands.forEach((b) => b && allBrands.add(b));
|
||||
fromRows((it) => it.brand).forEach((b) => allBrands.add(b));
|
||||
return [...allBrands].sort((a, b) => a.localeCompare(b, "fa"));
|
||||
}
|
||||
// فیلدِ اولِ هر ردیف «نام متریال» است (نه دسته) — مشخصههای متریالِ
|
||||
// همنام پیشنهاد میشوند.
|
||||
|
||||
if (cat)
|
||||
list = list.filter(
|
||||
(m) => normalize(materialBaseName(m.name, m.brand, m.specs)) === normalize(cat),
|
||||
);
|
||||
// مشخصهها سراسری هستند: رجیستری attribute-definitions + مشخصههای همهی
|
||||
// متریالها + مشخصههای تایپشده در سایر ردیفها. به تاپیک/دسته/برندِ قلمِ
|
||||
// فعلی گره نمیخورند تا هنگام افزودن پیشنهاد جدید همیشه در دسترس باشند.
|
||||
|
||||
const base = new Set<string>();
|
||||
apiSpecs.forEach((s) => s && base.add(s));
|
||||
materials.forEach((m) => (m.specs || []).forEach((s) => s && base.add(s)));
|
||||
@@ -90,7 +85,6 @@ export function materialOptions(
|
||||
return [...base].sort((a, b) => a.localeCompare(b, "fa"));
|
||||
}
|
||||
|
||||
// مرحله ۲ — جدول پویای اقلام سفارش
|
||||
export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember }: Step2Props) {
|
||||
const materials = useMaterials();
|
||||
|
||||
@@ -154,6 +148,22 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
||||
]);
|
||||
};
|
||||
|
||||
// قابلیت تکرار قلم در ردیف بعد با کپی عمیق فیلدها و شناسه یکتا
|
||||
const duplicate = (index: number) => {
|
||||
const target = items[index];
|
||||
if (!target) return;
|
||||
|
||||
const clonedItem: ItemDraft = {
|
||||
...target,
|
||||
tempId: "it" + Date.now() + Math.random().toString(36).slice(2, 6),
|
||||
specs: [...target.specs],
|
||||
};
|
||||
|
||||
const nextItems = [...items];
|
||||
nextItems.splice(index + 1, 0, clonedItem);
|
||||
onItemsChange(nextItems);
|
||||
};
|
||||
|
||||
const rememberField = (field: "i-cat" | "i-brand" | "i-spec", it: ItemDraft) => {
|
||||
if (!selectedTopic) return;
|
||||
let rec: MaterialRec;
|
||||
@@ -167,146 +177,224 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
||||
if (!it.specs.length) return;
|
||||
rec = { topic: selectedTopic, category: it.cat.trim(), brand: it.brand.trim(), specs: it.specs };
|
||||
}
|
||||
// rec.category در واقع نامِ متریال است (فیلدِ اولِ ردیف) — تطبیق بر اساس
|
||||
// همان هویتِ متریال انجام میشود، نه دسته.
|
||||
const dup = !!matchMaterial(materials, rec.category, rec.brand, rec.specs);
|
||||
if (dup) return;
|
||||
onRemember(rec);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-1 text-[15px] font-bold">
|
||||
<span className="me-1 inline-flex h-6 w-6 items-center justify-center rounded-full bg-blue-600 text-[12px] font-bold text-white">۲</span>
|
||||
اقلام سفارش
|
||||
</h3>
|
||||
<div className="mb-3 text-[12px] text-gray-500" dangerouslySetInnerHTML={{ __html: 'هر سطر یک <b>قلم</b> است (متریال + برند + مشخصه + واحد + تعداد + قیمت). متریال، برند و مشخصه را میتوان از گزینهها انتخاب کرد یا تایپ کرد تا بهعنوان گزینهی جدید اضافه شود. شرایط پرداخت هر قلم را در مرحلهی بعد مشخص میکنید.' }} />
|
||||
const commonInputClass = "!h-9 !text-xs font-normal text-right placeholder:text-xs placeholder:font-normal";
|
||||
|
||||
<div className="item-row wizard-item-row item-row-head mb-1 pe-7">
|
||||
<span>متریال</span>
|
||||
<span>برند</span>
|
||||
<span>مشخصه</span>
|
||||
<span>تعداد</span>
|
||||
<span>واحد</span>
|
||||
<span>قیمت واحد</span>
|
||||
<span>قیمت کل</span>
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* هدر */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white shadow-xs">
|
||||
۲
|
||||
</span>
|
||||
<h3 className="text-sm sm:text-base font-bold text-ink">
|
||||
اقلام سفارش
|
||||
</h3>
|
||||
</div>
|
||||
<span className="rounded-full bg-blue-500/10 px-2.5 py-0.5 text-[11px] font-semibold text-blue-500 border border-blue-500/20">
|
||||
{faNum(items.length)} قلم
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11.5px] sm:text-xs text-muted leading-relaxed">
|
||||
مشخصات، تعداد و قیمت هر قلم را ثبت کنید. گزینههای جدید با تایپ ذخیره میشوند.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div data-field="line_items" className="flex flex-col gap-2">
|
||||
{items.map((it) => (
|
||||
<div key={it.tempId} className="item-row wizard-item-row items-center">
|
||||
<Combobox
|
||||
value={it.cat}
|
||||
onChange={(x) => patch(it.tempId, { cat: x.label })}
|
||||
items={allMaterialNames}
|
||||
allowAdd
|
||||
addSuffix="به عنوان متریال جدید"
|
||||
placeholder="متریال"
|
||||
onInputChange={(v) => patch(it.tempId, { cat: v })}
|
||||
onBlur={() => rememberField("i-cat", it)}
|
||||
inputClassName="!text-[12px] !h-9"
|
||||
className="min-w-0"
|
||||
/>
|
||||
<Combobox
|
||||
value={it.brand}
|
||||
onChange={(x) => patch(it.tempId, { brand: x.label })}
|
||||
items={materialOptions(materials, selectedTopic, it, "brand", items, brandOptions)}
|
||||
allowAdd
|
||||
addSuffix="به عنوان برند جدید"
|
||||
placeholder="برند"
|
||||
onInputChange={(v) => patch(it.tempId, { brand: v })}
|
||||
onBlur={() => rememberField("i-brand", it)}
|
||||
inputClassName="!text-[12px] !h-9"
|
||||
className="min-w-0"
|
||||
/>
|
||||
<MultiSelectCombobox
|
||||
values={it.specs}
|
||||
onChange={(specs) => patch(it.tempId, { specs })}
|
||||
options={materialOptions(materials, selectedTopic, it, "spec", items, [], specOptions)}
|
||||
placeholder="مشخصه"
|
||||
className="min-w-0"
|
||||
/>
|
||||
<NumberInput
|
||||
value={it.qty}
|
||||
onChange={(qty) => {
|
||||
patch(it.tempId, {
|
||||
qty,
|
||||
total: qty != null && it.price != null ? Math.round(qty * it.price * 100) / 100 : it.total,
|
||||
});
|
||||
}}
|
||||
placeholder="تعداد"
|
||||
showWords
|
||||
/>
|
||||
<Combobox
|
||||
value={it.unit}
|
||||
onChange={(x) => patch(it.tempId, { unit: x.label })}
|
||||
items={allUnits.filter((u) => normalize(u) !== normalize(it.unit))}
|
||||
allowAdd
|
||||
addSuffix="به عنوان واحد جدید"
|
||||
placeholder="واحد"
|
||||
onInputChange={(v) => patch(it.tempId, { unit: v })}
|
||||
onAdd={(v) => {
|
||||
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(() => {});
|
||||
}}
|
||||
inputClassName="!text-[12px] !h-9"
|
||||
className="min-w-0"
|
||||
/>
|
||||
<NumberInput
|
||||
value={it.price}
|
||||
onChange={(price) => {
|
||||
patch(it.tempId, {
|
||||
price,
|
||||
total: it.qty != null && price != null ? Math.round(it.qty * price * 100) / 100 : it.total,
|
||||
});
|
||||
}}
|
||||
placeholder="قیمت واحد"
|
||||
showWords
|
||||
maxDecimals={2}
|
||||
/>
|
||||
<NumberInput
|
||||
value={it.total}
|
||||
onChange={(total) => {
|
||||
patch(it.tempId, {
|
||||
total,
|
||||
price:
|
||||
total != null && it.qty != null && it.qty > 0 ? Math.round((total / it.qty) * 100) / 100 : it.price,
|
||||
});
|
||||
}}
|
||||
placeholder="قیمت کل"
|
||||
showWords
|
||||
/>
|
||||
|
||||
{/* دکمه حذف مدرن با آیکون سطل زباله */}
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg text-gray-400 hover:bg-red-50 hover:text-red-500 transition-colors"
|
||||
title="حذف قلم"
|
||||
onClick={() => remove(it.tempId)}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
{/* لیست کارتهای اقلام */}
|
||||
<div data-field="line_items" className="flex flex-col gap-3">
|
||||
{items.map((it, idx) => (
|
||||
<div
|
||||
key={it.tempId}
|
||||
className="rounded-xl border border-line bg-panel p-3.5 sm:p-4 shadow-2xs space-y-3 transition-all"
|
||||
>
|
||||
{/* سربرگ قلم */}
|
||||
<div className="flex items-center justify-between border-b border-line pb-2">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-blue-500/10 text-[11px] font-bold text-blue-500">
|
||||
{faNum(idx + 1)}
|
||||
</span>
|
||||
<span className="text-xs font-bold text-ink truncate">
|
||||
{it.cat || "قلم جدید"} {it.brand ? `• ${it.brand}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* اکشنهای کارت: کپی و حذف */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs text-muted hover:text-ink hover:bg-line/50 transition-colors"
|
||||
title="تکرار این قلم"
|
||||
onClick={() => duplicate(idx)}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
<span className="text-[11px]">کپی</span>
|
||||
</button>
|
||||
|
||||
{items.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs text-rose-500 hover:bg-rose-500/10 transition-colors"
|
||||
title="حذف قلم"
|
||||
onClick={() => remove(it.tempId)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
<span className="text-[11px]">حذف</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ردیف ۱: متریال و برند */}
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<label className="text-[11px] font-semibold text-muted text-right">متریال</label>
|
||||
<Combobox
|
||||
value={it.cat}
|
||||
onChange={(x) => patch(it.tempId, { cat: x.label })}
|
||||
items={allMaterialNames}
|
||||
allowAdd
|
||||
addSuffix="به عنوان متریال جدید"
|
||||
placeholder="نام متریال"
|
||||
onInputChange={(v) => patch(it.tempId, { cat: v })}
|
||||
onBlur={() => rememberField("i-cat", it)}
|
||||
inputClassName={commonInputClass}
|
||||
className="min-w-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<label className="text-[11px] font-semibold text-muted text-right">برند</label>
|
||||
<Combobox
|
||||
value={it.brand}
|
||||
onChange={(x) => patch(it.tempId, { brand: x.label })}
|
||||
items={materialOptions(materials, selectedTopic, it, "brand", items, brandOptions)}
|
||||
allowAdd
|
||||
addSuffix="به عنوان برند جدید"
|
||||
placeholder="برند"
|
||||
onInputChange={(v) => patch(it.tempId, { brand: v })}
|
||||
onBlur={() => rememberField("i-brand", it)}
|
||||
inputClassName={commonInputClass}
|
||||
className="min-w-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ردیف ۲: مشخصه فنی */}
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<label className="text-[11px] font-semibold text-muted text-right">مشخصه فنی / ابعاد</label>
|
||||
<MultiSelectCombobox
|
||||
values={it.specs}
|
||||
onChange={(specs) => patch(it.tempId, { specs })}
|
||||
options={materialOptions(materials, selectedTopic, it, "spec", items, [], specOptions)}
|
||||
placeholder="انتخاب مشخصات (مثلاً ضخامت، رنگ، تیپ...)"
|
||||
className="min-w-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* بخش مقادیر و محاسبات مالی */}
|
||||
<div className="rounded-lg bg-bg/50 p-2.5 border border-line space-y-3">
|
||||
{/* ردیف ۳: تعداد + واحد سنجش */}
|
||||
<div className="grid grid-cols-[100px_1fr] sm:grid-cols-[120px_1fr] gap-2.5">
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<label className="text-[10.5px] font-semibold text-muted text-right">تعداد</label>
|
||||
<NumberInput
|
||||
value={it.qty}
|
||||
onChange={(qty) => {
|
||||
patch(it.tempId, {
|
||||
qty,
|
||||
total: qty != null && it.price != null ? Math.round(qty * it.price) : it.total,
|
||||
});
|
||||
}}
|
||||
placeholder="مقدار"
|
||||
className="!text-right text-right"
|
||||
inputClassName={commonInputClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<label className="text-[10.5px] font-semibold text-muted text-right">واحد سنجش</label>
|
||||
<Combobox
|
||||
value={it.unit}
|
||||
onChange={(x) => patch(it.tempId, { unit: x.label })}
|
||||
items={allUnits.filter((u) => normalize(u) !== normalize(it.unit))}
|
||||
allowAdd
|
||||
addSuffix="به عنوان واحد جدید"
|
||||
placeholder="واحد (شاخه، متر، کیلو...)"
|
||||
onInputChange={(v) => patch(it.tempId, { unit: v })}
|
||||
onAdd={(v) => {
|
||||
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(() => {});
|
||||
}}
|
||||
inputClassName={commonInputClass}
|
||||
className="min-w-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ردیف ۴: قیمت هر واحد */}
|
||||
<div className="flex flex-col gap-1 min-w-0 border-t border-line/60 pt-2.5">
|
||||
<label className="text-[10.5px] font-semibold text-muted text-right">
|
||||
{it.unit ? `قیمت هر ${it.unit} (تومان)` : "قیمت هر واحد (تومان)"}
|
||||
</label>
|
||||
<NumberInput
|
||||
value={it.price}
|
||||
onChange={(price) => {
|
||||
patch(it.tempId, {
|
||||
price,
|
||||
total: it.qty != null && price != null ? Math.round(it.qty * price) : it.total,
|
||||
});
|
||||
}}
|
||||
placeholder="قیمت هر واحد"
|
||||
showWords
|
||||
className="!text-right text-right"
|
||||
inputClassName={commonInputClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ردیف ۵: قیمت کل قلم */}
|
||||
<div className="flex flex-col gap-1 min-w-0 border-t border-line/60 pt-2.5">
|
||||
<label className="text-[10.5px] font-semibold text-muted text-right">قیمت کل این قلم (تومان)</label>
|
||||
<NumberInput
|
||||
value={it.total}
|
||||
onChange={(total) => {
|
||||
patch(it.tempId, {
|
||||
total,
|
||||
price:
|
||||
total != null && it.qty != null && it.qty > 0 ? Math.round(total / it.qty) : it.price,
|
||||
});
|
||||
}}
|
||||
placeholder="قیمت کل قلم"
|
||||
showWords
|
||||
className="!text-right text-right"
|
||||
inputClassName={commonInputClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* دکمه افزودن قلم با استایل برجسته و خطچین */}
|
||||
{/* دکمه افزودن قلم جدید */}
|
||||
<button
|
||||
type="button"
|
||||
className="mt-3 flex w-full items-center justify-center gap-2 rounded-xl border-2 border-dashed border-blue-300 bg-blue-50/40 py-2.5 px-4 text-[13px] font-semibold text-blue-600 hover:border-blue-500 hover:bg-blue-100/70 transition-all duration-150"
|
||||
className="flex w-full items-center justify-center gap-1.5 rounded-xl border border-dashed border-line bg-panel py-2.5 text-xs font-semibold text-ink hover:border-accent hover:text-accent active:scale-[0.99] transition-all"
|
||||
onClick={add}
|
||||
>
|
||||
<span className="text-base font-bold leading-none">+</span>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span>افزودن قلم جدید</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -9,43 +9,61 @@ export interface StepperProps {
|
||||
onGoto: (step: number) => void;
|
||||
}
|
||||
|
||||
const STEPS = ["تأمینکننده", "اقلام", "شرایط پرداخت", "پروژه", "بازبینی و ثبت"];
|
||||
const STEPS = [
|
||||
{ id: 1, label: "تأمینکننده" },
|
||||
{ id: 2, label: "اقلام" },
|
||||
{ id: 3, label: "شرایط پرداخت" },
|
||||
{ id: 4, label: "پروژه" },
|
||||
{ id: 5, label: "بازبینی و ثبت" },
|
||||
];
|
||||
|
||||
// نوار ۵ مرحلهای ویزارد — کلیک روی مراحل بازدیدشده مجاز است
|
||||
// مراحل تکمیلشده سبز، مرحلهی فعلی آبی
|
||||
// نوار ۵ مرحلهای ویزارد — واکنشگرا و ضدشکستگی در موبایل
|
||||
export function Stepper({ current, completedSteps, onGoto }: StepperProps) {
|
||||
return (
|
||||
<div className="mb-4 flex flex-wrap gap-2 rounded-xl border border-line bg-panel p-2 shadow-sm">
|
||||
{STEPS.map((label, i) => {
|
||||
const step = i + 1;
|
||||
<div className="mb-4 flex items-center justify-between gap-1 sm:gap-2 rounded-xl border border-line bg-panel p-1.5 sm:p-2 shadow-xs select-none">
|
||||
{STEPS.map(({ id: step, label }) => {
|
||||
const isCurrent = step === current;
|
||||
const isComplete = completedSteps.has(step);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={step}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-lg px-3 py-2 text-[12.5px] transition-colors",
|
||||
"flex items-center justify-center gap-1.5 rounded-lg py-1.5 px-2 sm:px-3 text-xs transition-all duration-150 cursor-pointer",
|
||||
// در موبایل مرحله جاری فضا میگیرد و بقیه جمعوجور میمانند تا سطر نشکند
|
||||
isCurrent
|
||||
? "bg-[#2563eb] font-bold text-white"
|
||||
: isComplete
|
||||
? "bg-green-bg font-medium text-green"
|
||||
: "hover:bg-slate-bg",
|
||||
? "flex-1 sm:flex-1 bg-blue-600 font-bold text-white shadow-xs"
|
||||
: "flex-initial sm:flex-1 text-muted hover:text-foreground hover:bg-slate-50 dark:hover:bg-zinc-800/60",
|
||||
isComplete && !isCurrent && "font-medium text-emerald-600 dark:text-emerald-400",
|
||||
)}
|
||||
onClick={() => onGoto(step)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-5 w-5 items-center justify-center rounded-full text-[11px] font-bold",
|
||||
isCurrent ? "bg-white/25" : isComplete ? "bg-green-bg text-green" : "bg-slate-bg text-muted",
|
||||
"flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[11px] font-bold transition-colors",
|
||||
isCurrent
|
||||
? "bg-white/20 text-white"
|
||||
: isComplete
|
||||
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950/80 dark:text-emerald-300"
|
||||
: "bg-muted/15 text-muted",
|
||||
)}
|
||||
>
|
||||
{isComplete ? <Check className="h-3 w-3" /> : faNum(step)}
|
||||
{isComplete ? <Check className="h-3 w-3 stroke-[2.5]" /> : faNum(step)}
|
||||
</span>
|
||||
|
||||
{/* در موبایل فقط نام مرحله جاری باز است؛ در صفحه بزرگتر همه نامها نمایش دارند */}
|
||||
<span
|
||||
className={cn(
|
||||
"whitespace-nowrap truncate text-[11.5px] sm:text-xs",
|
||||
!isCurrent && "hidden md:inline",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -127,11 +127,9 @@ export function WizardPage() {
|
||||
goToStep(currentStep + 1);
|
||||
};
|
||||
|
||||
const rememberMaterial = (_rec: MaterialRec) => {
|
||||
// رکوردهای متریال به همراه واحد و مشخصهها در زمان ذخیره نهایی ساخته میشوند
|
||||
};
|
||||
const rememberMaterial = (_rec: MaterialRec) => {};
|
||||
|
||||
// ---------- پروژهها: از مجموعه + نامهای قدیمی quoteها ----------
|
||||
// ---------- پروژهها ----------
|
||||
const projectNames = useMemo(() => {
|
||||
const names = new Set<string>(projects.map((p) => p.name));
|
||||
quotes.forEach((q) => {
|
||||
@@ -159,7 +157,7 @@ export function WizardPage() {
|
||||
});
|
||||
};
|
||||
|
||||
// ---------- خلاصهی مرحله ۵ ----------
|
||||
// ---------- خلاصه مرحله ۵ ----------
|
||||
const review = useMemo(() => {
|
||||
let grandTotal = 0;
|
||||
items.forEach((it) => {
|
||||
@@ -202,23 +200,14 @@ export function WizardPage() {
|
||||
|
||||
const combined = `${field} ${msg}`.toLowerCase();
|
||||
|
||||
// ۱. تأمینکننده
|
||||
if (/supplier|تأمین|تامین/i.test(combined)) return { step: 1, field: "supplier_id" };
|
||||
|
||||
// ۲. اقلام / متریال / واحد / تعداد / قیمت
|
||||
if (/line_item|material|canonical_unit|unit_id|quantity|متریال|واحد|قلم|تعداد/i.test(combined)) {
|
||||
return { step: 2, field: field || "line_items" };
|
||||
}
|
||||
|
||||
// ۳. شرایط پرداخت / termsheet / مراحل پرداخت / سررسید
|
||||
if (/termsheet|item_termsheet|schedule_step|due_date|milestone|ترم|پرداخت|پوشش|سررسید/i.test(combined)) {
|
||||
return { step: 3, field: field || "termsheets" };
|
||||
}
|
||||
|
||||
// ۴. پروژه
|
||||
if (/project|پروژه/i.test(combined)) return { step: 4, field: "project" };
|
||||
|
||||
// ۵. تاریخها، یادداشت، پیشفاکتور
|
||||
if (/expiry_date|تاریخ انقضا/i.test(combined)) return { step: 5, field: "expiry_date" };
|
||||
if (/quote_date|تاریخ پیشنهاد/i.test(combined)) return { step: 5, field: "quote_date" };
|
||||
if (/proforma|پیشفاکتور/i.test(combined)) return { step: 5, field: "proforma" };
|
||||
@@ -291,10 +280,6 @@ export function WizardPage() {
|
||||
if (!cat && !brand && qty == null && price == null) continue;
|
||||
|
||||
const wantName = (cat || brand || specs.join(" ") || "").trim() || "کالای جدید";
|
||||
// هویت متریال = نام + برند + مشخصه. پیش از v2.7 دسته هم در تطبیق بود؛
|
||||
// دسته دیگر روی متریال نیست و تکیه بر نام جلوی اشتباهگرفتن دو متریال با
|
||||
// برند و مشخصهی یکسان را میگیرد (matchMaterial نامِ پایه را هم میبیند
|
||||
// تا رکوردهای قدیمی با نامِ «برند + دسته + مشخصه» پیدا شوند).
|
||||
let material = materials.find((m) => looksLikeUuid(m.id) && normalize(m.name) === normalize(wantName));
|
||||
if (!material) {
|
||||
const matched = matchMaterial(
|
||||
@@ -426,7 +411,7 @@ export function WizardPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<div className="min-h-screen pb-10">
|
||||
<Header
|
||||
title="افزودن پیشنهاد جدید"
|
||||
sub="اطلاعات را از تماس با تأمینکننده وارد کنید"
|
||||
@@ -434,10 +419,12 @@ export function WizardPage() {
|
||||
backLabel="بازگشت به دفتر"
|
||||
/>
|
||||
<PageLayout>
|
||||
{/* نوار مراحل استپر */}
|
||||
<Stepper current={currentStep} highestVisited={highestVisited} completedSteps={completedSteps} onGoto={goToStep} />
|
||||
|
||||
{/* محتوای مراحل با پدینگ ریسپانسیو و بهینه برای موبایل */}
|
||||
{currentStep === 1 && (
|
||||
<div data-step="1" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||
<div data-step="1" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
|
||||
<Step1TopicSupplier
|
||||
selectedTopic={selectedTopic}
|
||||
onTopicSelect={(t) => {
|
||||
@@ -461,12 +448,12 @@ export function WizardPage() {
|
||||
</div>
|
||||
)}
|
||||
{currentStep === 2 && (
|
||||
<div data-step="2" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||
<div data-step="2" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
|
||||
<Step2LineItems items={items} onItemsChange={setItems} selectedTopic={selectedTopic} onRemember={rememberMaterial} />
|
||||
</div>
|
||||
)}
|
||||
{currentStep === 3 && (
|
||||
<div data-step="3" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||
<div data-step="3" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
|
||||
<Step3Termsheets
|
||||
cards={cards}
|
||||
onCardsChange={setCards}
|
||||
@@ -477,7 +464,7 @@ export function WizardPage() {
|
||||
</div>
|
||||
)}
|
||||
{currentStep === 4 && (
|
||||
<div data-step="4" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||
<div data-step="4" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
|
||||
<Step4Projects
|
||||
items={items}
|
||||
projects={projectNames}
|
||||
@@ -491,7 +478,7 @@ export function WizardPage() {
|
||||
</div>
|
||||
)}
|
||||
{currentStep === 5 && (
|
||||
<div data-step="5" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||
<div data-step="5" className="rounded-xl border border-line bg-panel p-3.5 sm:p-5 shadow-xs">
|
||||
<Step5Review
|
||||
quoteDate={quoteDate}
|
||||
onQuoteDate={setQuoteDate}
|
||||
@@ -506,15 +493,14 @@ export function WizardPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* پاورقی ویزارد — با آیکونهای استاندارد و جهتگیری دقیق RTL */}
|
||||
<div className="mt-5 flex items-center justify-between rounded-xl border border-line bg-panel p-3.5 shadow-sm">
|
||||
{/* دکمه مرحله قبلی (فلش رو به راست = بازگشت در فارسی) */}
|
||||
{/* پاورقی کنترل مراحل */}
|
||||
<div className="mt-4 flex items-center justify-between rounded-xl border border-line bg-panel p-3 sm:p-3.5 shadow-xs">
|
||||
<div>
|
||||
{currentStep > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => goToStep(currentStep - 1)}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl border border-slate-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-slate-700 dark:text-zinc-200 hover:bg-slate-50 dark:hover:bg-zinc-700 text-sm font-medium transition-colors shadow-xs cursor-pointer"
|
||||
className="inline-flex items-center gap-1 px-3 sm:px-4 py-2 rounded-xl border border-slate-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-slate-700 dark:text-zinc-200 hover:bg-slate-50 text-xs sm:text-sm font-medium transition-colors shadow-2xs"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
<span>قبلی</span>
|
||||
@@ -522,21 +508,19 @@ export function WizardPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* دکمههای اقدام (انصراف + بعدی / ذخیره) */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex items-center justify-center px-4 py-2 rounded-xl text-sm font-medium text-slate-500 hover:text-slate-700 hover:bg-slate-100 dark:text-zinc-400 dark:hover:text-zinc-200 dark:hover:bg-zinc-800 transition-colors no-underline"
|
||||
className="inline-flex items-center justify-center px-3 sm:px-4 py-2 rounded-xl text-xs sm:text-sm font-medium text-slate-500 hover:text-slate-700 no-underline"
|
||||
>
|
||||
انصراف
|
||||
</Link>
|
||||
|
||||
{/* دکمه مرحله بعدی (فلش رو به چپ = پیشروی در فارسی) */}
|
||||
{currentStep < TOTAL_STEPS && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={next}
|
||||
className="inline-flex items-center gap-1.5 px-6 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-sm font-semibold shadow-sm hover:shadow transition-all duration-150 cursor-pointer"
|
||||
className="inline-flex items-center gap-1 px-4 sm:px-6 py-2 rounded-xl bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-xs sm:text-sm font-semibold shadow-xs transition-all"
|
||||
>
|
||||
<span>بعدی</span>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
@@ -547,7 +531,7 @@ export function WizardPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void save()}
|
||||
className="inline-flex items-center gap-1.5 px-6 py-2 rounded-xl bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white text-sm font-semibold shadow-sm hover:shadow transition-all duration-150 cursor-pointer"
|
||||
className="inline-flex items-center gap-1 px-4 sm:px-6 py-2 rounded-xl bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white text-xs sm:text-sm font-semibold shadow-xs transition-all"
|
||||
>
|
||||
<span>ذخیره پیشنهاد</span>
|
||||
<Check className="h-4 w-4" />
|
||||
@@ -556,21 +540,21 @@ export function WizardPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* دیالوگ ذخیره با فیلدهای خالی */}
|
||||
{/* دیالوگ فیلدهای خالی */}
|
||||
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>فیلدهای خالی</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="text-[13.5px] leading-7">
|
||||
هنوز تعدادی از فیلد ها خالی هستند. آیا مطمئنی که می خواهید تغییرات را ذخیره کنید؟
|
||||
<div className="text-[13.5px] leading-7 text-gray-700">
|
||||
هنوز تعدادی از فیلدها خالی هستند. آیا مطمئنید که میخواهید ادامه دهید؟
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[13px]">
|
||||
{emptyFields.map((f) => (
|
||||
<button
|
||||
key={f.step}
|
||||
type="button"
|
||||
className="cursor-pointer text-accent underline underline-offset-2 hover:text-accent-dark"
|
||||
className="cursor-pointer text-blue-600 underline underline-offset-2 hover:text-blue-800"
|
||||
onClick={() => {
|
||||
setConfirmOpen(false);
|
||||
goToStep(f.step);
|
||||
@@ -597,7 +581,7 @@ export function WizardPage() {
|
||||
goToStep(emptyFields[0]?.step ?? 1);
|
||||
}}
|
||||
>
|
||||
تکمیل فیلد ها
|
||||
تکمیل فیلدها
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user