Compare commits
8
Commits
7b3ae3cb60
...
prod_v2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14a4a29431 | ||
|
|
d2b9af5278 | ||
|
|
3c7ae80b02 | ||
|
|
de177f3520 | ||
|
|
b5dc3e585a | ||
|
|
c395fba8dd | ||
|
|
66216c2b2f | ||
|
|
435e6cbb21 |
+14
-51
@@ -1,7 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:18
|
image: postgres:18
|
||||||
container_name: procurement-postgres
|
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: procurement
|
POSTGRES_DB: procurement
|
||||||
@@ -9,7 +8,7 @@ services:
|
|||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD: postgres
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- "5433:5432"
|
- "5435:5432"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql
|
- postgres_data:/var/lib/postgresql
|
||||||
@@ -24,24 +23,13 @@ services:
|
|||||||
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
# ==========================================================================
|
|
||||||
# Traefik v3 — public reverse proxy (replaces nginx as the edge)
|
|
||||||
# App is served on http://localhost (port 80) instead of :3000.
|
|
||||||
# Production: see docker-compose.prod.yml (real domains + HTTPS via
|
|
||||||
# Let's Encrypt, HTTP→HTTPS redirect).
|
|
||||||
# ==========================================================================
|
|
||||||
traefik:
|
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
|
image: traefik:v3.6
|
||||||
container_name: procurement-traefik
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command:
|
command:
|
||||||
- --providers.docker=true
|
- --providers.docker=true
|
||||||
- --providers.docker.exposedbydefault=false
|
- --providers.docker.exposedbydefault=false
|
||||||
# Containers are on several networks; Traefik must use the dedicated
|
- --providers.docker.network=erp_production_network
|
||||||
# proxy network IP (otherwise it may pick the default-network IP → 504).
|
|
||||||
- --providers.docker.network=traefik_network
|
|
||||||
- --providers.file.filename=/etc/traefik/dynamic.yml
|
- --providers.file.filename=/etc/traefik/dynamic.yml
|
||||||
- --entrypoints.web.address=:80
|
- --entrypoints.web.address=:80
|
||||||
- --entrypoints.websecure.address=:443
|
- --entrypoints.websecure.address=:443
|
||||||
@@ -51,11 +39,9 @@ services:
|
|||||||
- --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web
|
- --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web
|
||||||
- --log.level=INFO
|
- --log.level=INFO
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "8084:80"
|
||||||
- "443:443"
|
- "8445:443"
|
||||||
volumes:
|
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
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
- ./infra/traefik-dynamic.yml:/etc/traefik/dynamic.yml:ro
|
- ./infra/traefik-dynamic.yml:/etc/traefik/dynamic.yml:ro
|
||||||
- traefik_certs:/certs
|
- traefik_certs:/certs
|
||||||
@@ -64,7 +50,6 @@ services:
|
|||||||
|
|
||||||
minio:
|
minio:
|
||||||
image: minio/minio:latest
|
image: minio/minio:latest
|
||||||
container_name: procurement-minio
|
|
||||||
|
|
||||||
command: server /data --console-address ":9001"
|
command: server /data --console-address ":9001"
|
||||||
|
|
||||||
@@ -73,20 +58,18 @@ services:
|
|||||||
MINIO_ROOT_PASSWORD: minioadmin
|
MINIO_ROOT_PASSWORD: minioadmin
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- "9000:9000" # S3 API
|
- "9004:9000" # S3 API
|
||||||
- "9001:9001" # Web console
|
- "9005:9001" # Web console
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
- minio_data:/data
|
- minio_data:/data
|
||||||
|
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- traefik.enable=true
|
||||||
# S3 API
|
|
||||||
- traefik.http.routers.minio-api.rule=Host(`${S3_HOST:-s3.localhost}`)
|
- traefik.http.routers.minio-api.rule=Host(`${S3_HOST:-s3.localhost}`)
|
||||||
- traefik.http.routers.minio-api.entrypoints=web
|
- traefik.http.routers.minio-api.entrypoints=web
|
||||||
- traefik.http.routers.minio-api.service=minio-api-svc
|
- traefik.http.routers.minio-api.service=minio-api-svc
|
||||||
- traefik.http.services.minio-api-svc.loadbalancer.server.port=9000
|
- 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.rule=Host(`${STORAGE_HOST:-storage.localhost}`)
|
||||||
- traefik.http.routers.minio-console.entrypoints=web
|
- traefik.http.routers.minio-console.entrypoints=web
|
||||||
- traefik.http.routers.minio-console.service=minio-console-svc
|
- traefik.http.routers.minio-console.service=minio-console-svc
|
||||||
@@ -98,11 +81,8 @@ services:
|
|||||||
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
# MinIO Client (mc): waits for the server, creates the bucket, exits.
|
|
||||||
# Use for ad-hoc ops too: docker compose run --rm minio-init ...
|
|
||||||
minio-init:
|
minio-init:
|
||||||
image: minio/mc:latest
|
image: minio/mc:latest
|
||||||
container_name: procurement-minio-init
|
|
||||||
|
|
||||||
depends_on:
|
depends_on:
|
||||||
minio:
|
minio:
|
||||||
@@ -125,20 +105,8 @@ services:
|
|||||||
|
|
||||||
restart: "no"
|
restart: "no"
|
||||||
|
|
||||||
# ==========================================================================
|
|
||||||
# v2.6 — Casdoor (OIDC identity provider)
|
|
||||||
# Console: http://localhost:8080 (admin / 123)
|
|
||||||
# Application "inventory-app" (org "inventory", client inventory-app-client)
|
|
||||||
# is created by backend/tools/seed_casdoor.py — run it once after the
|
|
||||||
# first boot:
|
|
||||||
# docker compose run --rm -e CASDOOR_BASE=http://casdoor:8000 api python tools/seed_casdoor.py
|
|
||||||
#
|
|
||||||
# Casdoor uses the SAME postgres instance as the app (database `casdoor`,
|
|
||||||
# created by sql/02-casdoor-db.sql on a fresh volume).
|
|
||||||
# ==========================================================================
|
|
||||||
casdoor:
|
casdoor:
|
||||||
image: casbin/casdoor:latest
|
image: casbin/casdoor:latest
|
||||||
container_name: procurement-casdoor
|
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -147,9 +115,9 @@ services:
|
|||||||
driverName: postgres
|
driverName: postgres
|
||||||
dataSourceName: "user=casdoor password=casdoor host=postgres port=5432 sslmode=disable dbname=casdoor"
|
dataSourceName: "user=casdoor password=casdoor host=postgres port=5432 sslmode=disable dbname=casdoor"
|
||||||
dbName: casdoor
|
dbName: casdoor
|
||||||
origin: "http://localhost:8080"
|
origin: "http://localhost:8083"
|
||||||
ports:
|
ports:
|
||||||
- "8080:8000"
|
- "8083:8000"
|
||||||
|
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- traefik.enable=true
|
||||||
@@ -167,7 +135,6 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: procurement-api
|
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
PROCUREMENT_DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/procurement
|
PROCUREMENT_DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/procurement
|
||||||
@@ -176,21 +143,18 @@ services:
|
|||||||
PROCUREMENT_MINIO_SECRET_KEY: minioadmin
|
PROCUREMENT_MINIO_SECRET_KEY: minioadmin
|
||||||
PROCUREMENT_MINIO_BUCKET: procurement-files
|
PROCUREMENT_MINIO_BUCKET: procurement-files
|
||||||
PROCUREMENT_MINIO_SECURE: "false"
|
PROCUREMENT_MINIO_SECURE: "false"
|
||||||
# Casdoor: endpoint is the in-network service URL; the issuer must match
|
|
||||||
# the origin Casdoor advertises to browsers (its OIDC discovery doc).
|
|
||||||
PROCUREMENT_CASDOOR_ENDPOINT: http://casdoor:8000
|
PROCUREMENT_CASDOOR_ENDPOINT: http://casdoor:8000
|
||||||
PROCUREMENT_CASDOOR_ISSUER: http://localhost:8080
|
PROCUREMENT_CASDOOR_ISSUER: http://localhost:8083
|
||||||
PROCUREMENT_CASDOOR_CLIENT_ID: inventory-app-client
|
PROCUREMENT_CASDOOR_CLIENT_ID: inventory-app-client
|
||||||
PROCUREMENT_CASDOOR_CLIENT_SECRET: inventory-app-secret-2026
|
PROCUREMENT_CASDOOR_CLIENT_SECRET: inventory-app-secret-2026
|
||||||
PROCUREMENT_CASDOOR_ORGANIZATION: inventory
|
PROCUREMENT_CASDOOR_ORGANIZATION: inventory
|
||||||
PROCUREMENT_CASDOOR_APP_NAME: inventory-app
|
PROCUREMENT_CASDOOR_APP_NAME: inventory-app
|
||||||
PROCUREMENT_CASDOOR_AUDIENCE: inventory-app-client
|
PROCUREMENT_CASDOOR_AUDIENCE: inventory-app-client
|
||||||
# Console admin used by POST /users/sync (identity mirroring).
|
|
||||||
PROCUREMENT_CASDOOR_ADMIN_USER: admin
|
PROCUREMENT_CASDOOR_ADMIN_USER: admin
|
||||||
PROCUREMENT_CASDOOR_ADMIN_PASSWORD: "123"
|
PROCUREMENT_CASDOOR_ADMIN_PASSWORD: "123"
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8001:8000"
|
||||||
|
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- traefik.enable=true
|
||||||
@@ -214,9 +178,10 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
args:
|
args:
|
||||||
# Developer Mode panel on the login page — set false for production.
|
|
||||||
DEV_LOGIN_ENABLED: ${DEV_LOGIN_ENABLED:-true}
|
DEV_LOGIN_ENABLED: ${DEV_LOGIN_ENABLED:-true}
|
||||||
container_name: procurement-frontend
|
|
||||||
|
ports:
|
||||||
|
- "8085:80"
|
||||||
|
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- traefik.enable=true
|
||||||
@@ -234,10 +199,8 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
# شبکهی عمومی: فقط ترافیک و سرویسهای عمومی. postgres روی شبکهی
|
|
||||||
# پیشفرض میماند و از بیرون قابل دسترسی نیست.
|
|
||||||
traefik_network:
|
traefik_network:
|
||||||
name: traefik_network
|
name: erp_production_network
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useRef } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { Eye, Download, Trash2, X, FileText, Image as ImageIcon, ExternalLink } from "lucide-react";
|
||||||
import type { Attachment } from "../lib/types";
|
import type { Attachment } from "../lib/types";
|
||||||
|
|
||||||
export interface FileListProps {
|
export interface FileListProps {
|
||||||
@@ -14,9 +15,18 @@ export interface FileListProps {
|
|||||||
label?: string;
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// مدیریت چند فاکتور: آپلود مستقیم به سرور (MinIO) + دانلود/حذف فایلهای ذخیرهشده
|
const isImageFile = (name: string = "") => {
|
||||||
|
return /\.(jpe?g|png|webp|gif|svg|bmp)$/i.test(name.toLowerCase());
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPdfFile = (name: string = "") => {
|
||||||
|
return /\.pdf$/i.test(name.toLowerCase());
|
||||||
|
};
|
||||||
|
|
||||||
|
// مدیریت چند فاکتور: آپلود مستقیم، پیشنمایش آنلاین تصویر/PDF و دانلود
|
||||||
export function FileList({ files, onChange, upload, onDelete, urlOf, label = "افزودن فاکتور" }: FileListProps) {
|
export function FileList({ files, onChange, upload, onDelete, urlOf, label = "افزودن فاکتور" }: FileListProps) {
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const [previewItem, setPreviewItem] = useState<{ url: string; name: string } | null>(null);
|
||||||
|
|
||||||
const remove = (i: number) => {
|
const remove = (i: number) => {
|
||||||
const removed = files[i];
|
const removed = files[i];
|
||||||
@@ -39,22 +49,94 @@ export function FileList({ files, onChange, upload, onDelete, urlOf, label = "ا
|
|||||||
if (added.length) onChange([...files, ...added]);
|
if (added.length) onChange([...files, ...added]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOpenPreview = (inv: Attachment) => {
|
||||||
|
const url = urlOf?.(inv);
|
||||||
|
if (!url) {
|
||||||
|
toast.error("فایل هنوز به طور کامل در سرور ذخیره نشده است");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPreviewItem({ url, name: inv.name });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
{!files.length && <div className="inv-empty">هنوز فایلی انتخاب نشده است.</div>}
|
{!files.length && (
|
||||||
{files.map((inv, i) => (
|
<div className="rounded-lg border border-dashed border-gray-200 bg-gray-50/60 p-3 text-center text-xs text-gray-500">
|
||||||
<div key={i} className="inv-row">
|
هنوز فاکتور یا سندی بارگذاری نشده است.
|
||||||
<span className="inv-name">📎 {inv.name}</span>
|
</div>
|
||||||
{urlOf?.(inv) && (
|
|
||||||
<a className="inv-open" href={urlOf(inv)!} download={inv.name} title="دانلود">
|
|
||||||
↓
|
|
||||||
</a>
|
|
||||||
)}
|
)}
|
||||||
<button type="button" className="inv-x" title="حذف" onClick={() => remove(i)}>
|
|
||||||
×
|
{files.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{files.map((inv, i) => {
|
||||||
|
const url = urlOf?.(inv);
|
||||||
|
const isImg = isImageFile(inv.name);
|
||||||
|
const isPdf = isPdfFile(inv.name);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={inv.id || i}
|
||||||
|
className="flex items-center justify-between rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs transition-colors hover:border-gray-300 shadow-xs"
|
||||||
|
>
|
||||||
|
{/* نام و آیکون فایل — با قابلیت کلیک برای پیشنمایش */}
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 min-w-0 flex-1 ${url ? "cursor-pointer group" : ""}`}
|
||||||
|
onClick={() => url && handleOpenPreview(inv)}
|
||||||
|
title={url ? "مشاهده پیشنمایش" : inv.name}
|
||||||
|
>
|
||||||
|
{isImg ? (
|
||||||
|
<ImageIcon className="h-4 w-4 shrink-0 text-blue-600" />
|
||||||
|
) : isPdf ? (
|
||||||
|
<FileText className="h-4 w-4 shrink-0 text-rose-600" />
|
||||||
|
) : (
|
||||||
|
<FileText className="h-4 w-4 shrink-0 text-gray-400" />
|
||||||
|
)}
|
||||||
|
<span className="truncate font-medium text-gray-700 group-hover:text-blue-600 transition-colors">
|
||||||
|
{inv.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* دکمههای عملیات: پیشنمایش، دانلود مستقیم، حذف */}
|
||||||
|
<div className="flex items-center gap-1 shrink-0 me-1">
|
||||||
|
{url && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleOpenPreview(inv)}
|
||||||
|
className="flex h-7 w-7 items-center justify-center rounded-md text-gray-500 hover:bg-blue-50 hover:text-blue-600 transition-colors"
|
||||||
|
title="مشاهده فاکتور"
|
||||||
|
>
|
||||||
|
<Eye className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
download={inv.name}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="flex h-7 w-7 items-center justify-center rounded-md text-gray-500 hover:bg-gray-100 hover:text-gray-900 transition-colors"
|
||||||
|
title="دانلود فایل"
|
||||||
|
>
|
||||||
|
<Download className="h-3.5 w-3.5" />
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-7 w-7 items-center justify-center rounded-md text-gray-400 hover:bg-red-50 hover:text-red-600 transition-colors"
|
||||||
|
title="حذف فایل"
|
||||||
|
onClick={() => remove(i)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
@@ -64,10 +146,105 @@ export function FileList({ files, onChange, upload, onDelete, urlOf, label = "ا
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
/>
|
/>
|
||||||
<button type="button" className="btn-line-file" onClick={() => inputRef.current?.click()}>
|
<button
|
||||||
+ {label}
|
type="button"
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-lg border border-dashed border-gray-300 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 hover:border-blue-500 hover:bg-blue-50/40 hover:text-blue-600 transition-all"
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<span>+</span>
|
||||||
|
<span>{label}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* پنجره مودال پیشنمایش (Preview Modal) */}
|
||||||
|
{previewItem && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[80] flex items-center justify-center bg-black/60 p-3 sm:p-5 backdrop-blur-xs animate-in fade-in duration-200"
|
||||||
|
onClick={() => setPreviewItem(null)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex flex-col w-full max-w-4xl max-h-[90vh] rounded-2xl border border-gray-200 bg-white shadow-2xl overflow-hidden"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* هدر پیشنمایش */}
|
||||||
|
<div className="flex items-center justify-between border-b border-gray-100 px-4 py-3 bg-white">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
{isImageFile(previewItem.name) ? (
|
||||||
|
<ImageIcon className="h-4 w-4 shrink-0 text-blue-600" />
|
||||||
|
) : (
|
||||||
|
<FileText className="h-4 w-4 shrink-0 text-rose-600" />
|
||||||
|
)}
|
||||||
|
<span className="text-xs sm:text-sm font-bold text-gray-900 truncate">
|
||||||
|
{previewItem.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
|
<a
|
||||||
|
href={previewItem.url}
|
||||||
|
download={previewItem.name}
|
||||||
|
className="inline-flex items-center gap-1 rounded-lg border border-gray-200 bg-white px-2.5 py-1 text-xs font-semibold text-gray-700 hover:bg-gray-50 hover:text-gray-900 transition-colors shadow-2xs"
|
||||||
|
title="دانلود فاکتور"
|
||||||
|
>
|
||||||
|
<Download className="h-3.5 w-3.5" />
|
||||||
|
<span className="hidden xs:inline">دانلود فاکتور</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={previewItem.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="flex h-7 w-7 items-center justify-center rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 hover:text-gray-900 transition-colors shadow-2xs"
|
||||||
|
title="باز کردن در برگه جدید"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPreviewItem(null)}
|
||||||
|
className="flex h-7 w-7 items-center justify-center rounded-lg text-gray-400 hover:bg-gray-100 hover:text-gray-700 transition-colors ms-1"
|
||||||
|
title="بستن"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* بدنه نمایش فایل */}
|
||||||
|
<div className="flex-1 overflow-auto p-3 sm:p-4 bg-gray-50/70 flex items-center justify-center min-h-[300px]">
|
||||||
|
{isImageFile(previewItem.name) ? (
|
||||||
|
<img
|
||||||
|
src={previewItem.url}
|
||||||
|
alt={previewItem.name}
|
||||||
|
className="max-h-[75vh] w-auto max-w-full rounded-lg object-contain shadow-xs border border-gray-200 bg-white"
|
||||||
|
/>
|
||||||
|
) : isPdfFile(previewItem.name) ? (
|
||||||
|
<iframe
|
||||||
|
src={previewItem.url}
|
||||||
|
title={previewItem.name}
|
||||||
|
className="h-[75vh] w-full rounded-lg border border-gray-200 bg-white shadow-xs"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-3 p-8 text-center bg-white rounded-xl border border-gray-200 shadow-xs">
|
||||||
|
<FileText className="h-12 w-12 text-gray-400" />
|
||||||
|
<div className="text-xs text-gray-600">
|
||||||
|
امکان نمایش پیشنمایش زنده برای این فرمت فایل وجود ندارد.
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={previewItem.url}
|
||||||
|
download={previewItem.name}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-3.5 py-1.5 text-xs font-semibold text-white hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Download className="h-3.5 w-3.5" />
|
||||||
|
دانلود فایل
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -12,15 +12,19 @@ export interface MilestoneRowProps {
|
|||||||
// یک ردیف مرحلهی پرداخت: تاریخ + مبلغ + دکمهی حذف
|
// یک ردیف مرحلهی پرداخت: تاریخ + مبلغ + دکمهی حذف
|
||||||
export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }: MilestoneRowProps) {
|
export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }: MilestoneRowProps) {
|
||||||
return (
|
return (
|
||||||
<div className="milestone-row">
|
<div className="flex items-start gap-3 w-full my-2">
|
||||||
|
{/* کادر انتخاب تاریخ */}
|
||||||
|
<div className="w-36 shrink-0">
|
||||||
<JalaliDatePicker
|
<JalaliDatePicker
|
||||||
value={milestone.when_iso}
|
value={milestone.when_iso}
|
||||||
onChange={(iso) => onChange({ ...milestone, when_iso: iso })}
|
onChange={(iso) => onChange({ ...milestone, when_iso: iso })}
|
||||||
placeholder="تاریخ"
|
placeholder="تاریخ"
|
||||||
className="w-36 shrink-0"
|
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
<div className="ms-amount-wrap">
|
</div>
|
||||||
|
|
||||||
|
{/* کادر ورود مبلغ و حروف زیر آن */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
<NumberInput
|
<NumberInput
|
||||||
value={milestone.amount}
|
value={milestone.amount}
|
||||||
onChange={(amount) => onChange({ ...milestone, amount })}
|
onChange={(amount) => onChange({ ...milestone, amount })}
|
||||||
@@ -29,8 +33,15 @@ export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* دکمه حذف */}
|
||||||
{!disabled && (
|
{!disabled && (
|
||||||
<button type="button" className="ms-x" title="حذف" onClick={onRemove}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mt-2 text-red-500 hover:text-red-700 text-lg font-bold shrink-0 px-1 transition-colors"
|
||||||
|
title="حذف"
|
||||||
|
onClick={onRemove}
|
||||||
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,15 +1,27 @@
|
|||||||
import { ClipboardList, Inbox, CheckCircle2, Timer, type LucideIcon } from "lucide-react";
|
import { ClipboardList, Inbox, CheckCircle2, PackageCheck, type LucideIcon } from "lucide-react";
|
||||||
import { fmtNum } from "../lib/formatters";
|
import { fmtNum } from "../lib/formatters";
|
||||||
|
|
||||||
|
export type StatusFilter = "all" | "offered" | "accepted" | "arrived";
|
||||||
|
|
||||||
export interface StatsBarProps {
|
export interface StatsBarProps {
|
||||||
total: number;
|
total: number;
|
||||||
offered: number;
|
offered: number;
|
||||||
accepted: number;
|
accepted: number;
|
||||||
avgLeadTime: number | null; // روز
|
arrived: number;
|
||||||
|
activeFilter?: StatusFilter;
|
||||||
|
onFilterChange?: (filter: StatusFilter) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProps) {
|
export function StatsBar({
|
||||||
|
total,
|
||||||
|
offered,
|
||||||
|
accepted,
|
||||||
|
arrived,
|
||||||
|
activeFilter = "all",
|
||||||
|
onFilterChange,
|
||||||
|
}: StatsBarProps) {
|
||||||
const stats: {
|
const stats: {
|
||||||
|
key: StatusFilter;
|
||||||
label: string;
|
label: string;
|
||||||
shortLabel: string;
|
shortLabel: string;
|
||||||
value: string;
|
value: string;
|
||||||
@@ -18,6 +30,7 @@ export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProp
|
|||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
}[] = [
|
}[] = [
|
||||||
{
|
{
|
||||||
|
key: "all",
|
||||||
label: "کل پیشنهادها",
|
label: "کل پیشنهادها",
|
||||||
shortLabel: "کل",
|
shortLabel: "کل",
|
||||||
value: fmtNum(total),
|
value: fmtNum(total),
|
||||||
@@ -25,6 +38,7 @@ export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProp
|
|||||||
icon: ClipboardList,
|
icon: ClipboardList,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
key: "offered",
|
||||||
label: "پیشنهاد باز",
|
label: "پیشنهاد باز",
|
||||||
shortLabel: "باز",
|
shortLabel: "باز",
|
||||||
value: fmtNum(offered),
|
value: fmtNum(offered),
|
||||||
@@ -32,6 +46,7 @@ export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProp
|
|||||||
icon: Inbox,
|
icon: Inbox,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
key: "accepted",
|
||||||
label: "پذیرفتهشده",
|
label: "پذیرفتهشده",
|
||||||
shortLabel: "پذیرفته",
|
shortLabel: "پذیرفته",
|
||||||
value: fmtNum(accepted),
|
value: fmtNum(accepted),
|
||||||
@@ -39,21 +54,41 @@ export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProp
|
|||||||
icon: CheckCircle2,
|
icon: CheckCircle2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "میانگین زمان تحویل",
|
key: "arrived",
|
||||||
shortLabel: "میانگین تحویل",
|
label: "تحویلشده به کارگاه",
|
||||||
value: avgLeadTime == null ? "—" : fmtNum(avgLeadTime),
|
shortLabel: "تحویل",
|
||||||
unit: avgLeadTime != null ? "روز" : undefined,
|
value: fmtNum(arrived),
|
||||||
color: "text-green",
|
color: "text-green",
|
||||||
icon: Timer,
|
icon: PackageCheck,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const handleClick = (key: StatusFilter) => {
|
||||||
|
if (!onFilterChange) return;
|
||||||
|
if (key === "all") {
|
||||||
|
onFilterChange("all");
|
||||||
|
} else {
|
||||||
|
onFilterChange(activeFilter === key ? "all" : key);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="grid grid-cols-4 gap-1.5 sm:gap-3">
|
<section className="grid grid-cols-4 gap-1.5 sm:gap-3">
|
||||||
{stats.map((s) => (
|
{stats.map((s) => {
|
||||||
|
const isClickable = Boolean(onFilterChange);
|
||||||
|
const isActive = activeFilter === s.key;
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={s.label}
|
key={s.label}
|
||||||
className="rounded-xl border border-line bg-panel p-2 text-center transition-colors hover:border-accent/40 sm:rounded-lg sm:p-3.5 sm:text-right"
|
onClick={() => handleClick(s.key)}
|
||||||
|
className={`rounded-xl border p-2 text-center transition-all sm:rounded-lg sm:p-3.5 sm:text-right ${
|
||||||
|
isClickable ? "cursor-pointer select-none" : ""
|
||||||
|
} ${
|
||||||
|
isActive
|
||||||
|
? "border-accent ring-2 ring-accent/20 bg-accent/5 shadow-sm"
|
||||||
|
: "border-line bg-panel hover:border-accent/40"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{/* عنوان کارت */}
|
{/* عنوان کارت */}
|
||||||
<div className="flex items-center justify-center sm:justify-between">
|
<div className="flex items-center justify-center sm:justify-between">
|
||||||
@@ -68,7 +103,7 @@ export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProp
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* مقدار عددی به همراه واحد روز */}
|
{/* مقدار عددی */}
|
||||||
<div
|
<div
|
||||||
className={`mt-0.5 flex items-baseline justify-center sm:justify-start gap-1 text-base font-extrabold leading-5 tabular-nums sm:mt-1 sm:text-[22px] sm:leading-8 ${s.color}`}
|
className={`mt-0.5 flex items-baseline justify-center sm:justify-start gap-1 text-base font-extrabold leading-5 tabular-nums sm:mt-1 sm:text-[22px] sm:leading-8 ${s.color}`}
|
||||||
>
|
>
|
||||||
@@ -80,7 +115,8 @@ export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProp
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -288,8 +288,13 @@ export function filterQuotes(
|
|||||||
"پیشنهاد شده": "offered",
|
"پیشنهاد شده": "offered",
|
||||||
"پذیرفته شده": "accepted",
|
"پذیرفته شده": "accepted",
|
||||||
"رسیده": "arrived",
|
"رسیده": "arrived",
|
||||||
|
"باز": "offered",
|
||||||
|
"پذیرفته": "accepted",
|
||||||
|
"offered": "offered",
|
||||||
|
"accepted": "accepted",
|
||||||
|
"arrived": "arrived",
|
||||||
};
|
};
|
||||||
const stKey = STATUS_BY_LABEL[stVal] || "";
|
const stKey = STATUS_BY_LABEL[stVal] || stVal;
|
||||||
|
|
||||||
return store.quotes.filter((quote) => {
|
return store.quotes.filter((quote) => {
|
||||||
const supObj = supplierById(store.suppliers, quote.supplier_id);
|
const supObj = supplierById(store.suppliers, quote.supplier_id);
|
||||||
@@ -313,11 +318,16 @@ export function filterQuotes(
|
|||||||
export function isExpired(q: Quote): boolean {
|
export function isExpired(q: Quote): boolean {
|
||||||
return (
|
return (
|
||||||
q.status === "offered" &&
|
q.status === "offered" &&
|
||||||
|
!quoteHasArrivedOrAccepted(q) &&
|
||||||
!!q.expiry_date &&
|
!!q.expiry_date &&
|
||||||
new Date(q.expiry_date) < new Date(new Date().toDateString())
|
new Date(q.expiry_date) < new Date(new Date().toDateString())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function quoteHasArrivedOrAccepted(q: Quote): boolean {
|
||||||
|
return q.status === "accepted" || q.status === "arrived";
|
||||||
|
}
|
||||||
|
|
||||||
// آمادهسازی نمایش ترمشیت در حالت expanded
|
// آمادهسازی نمایش ترمشیت در حالت expanded
|
||||||
export function termSheetBlocks(
|
export function termSheetBlocks(
|
||||||
store: Store,
|
store: Store,
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ export interface AcceptModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// مودال پذیرش: انتخاب/ویرایش شرایط پرداخت + تخصیص قلمها + فاکتورها
|
|
||||||
export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||||
const quotes = useQuotes();
|
const quotes = useQuotes();
|
||||||
const suppliers = useSuppliers();
|
const suppliers = useSuppliers();
|
||||||
@@ -51,13 +50,10 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
const [proformas, setProformas] = useState<Attachment[]>([]);
|
const [proformas, setProformas] = useState<Attachment[]>([]);
|
||||||
const [removedItems, setRemovedItems] = useState<Set<string>>(new Set());
|
const [removedItems, setRemovedItems] = useState<Set<string>>(new Set());
|
||||||
const [assignments, setAssignments] = useState<Record<string, string>>({});
|
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 [acceptDate, setAcceptDate] = useState<string | null>(() => new Date().toISOString());
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
// وقتی quote باز شد، state اولیه را بساز
|
|
||||||
useMemo(() => {
|
useMemo(() => {
|
||||||
if (!q) return;
|
if (!q) return;
|
||||||
const offered = q.termsheets.filter((t) => t.status === "offered");
|
const offered = q.termsheets.filter((t) => t.status === "offered");
|
||||||
@@ -70,7 +66,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
});
|
});
|
||||||
setRemovedItems(rem);
|
setRemovedItems(rem);
|
||||||
setAssignments({});
|
setAssignments({});
|
||||||
// پروژهی هر قلم — تخصیصِ تکی: از تخصیص قبلی قلم، وگرنه اولین پروژهی پیشنهاد
|
|
||||||
const projSel: Record<string, string> = {};
|
const projSel: Record<string, string> = {};
|
||||||
q.line_items.forEach((li) => {
|
q.line_items.forEach((li) => {
|
||||||
const names = (li.projects || []).map((p) => p.name).filter(Boolean);
|
const names = (li.projects || []).map((p) => p.name).filter(Boolean);
|
||||||
@@ -86,7 +81,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
setReady(true);
|
setReady(true);
|
||||||
}, [q]);
|
}, [q]);
|
||||||
|
|
||||||
// پیوستها مستقیماً از سرور (MinIO) خوانده میشوند — نه از state محلی
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!quoteId) return;
|
if (!quoteId) return;
|
||||||
let alive = true;
|
let alive = true;
|
||||||
@@ -109,7 +103,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
const allItems = q.line_items || [];
|
const allItems = q.line_items || [];
|
||||||
const active = activeItems(q);
|
const active = activeItems(q);
|
||||||
|
|
||||||
// گزینههای پروژه — پروژههای ثبتشده + پروژههای این پیشنهاد + تخصیصهای قبلی قلمها
|
|
||||||
const projectOptions = (() => {
|
const projectOptions = (() => {
|
||||||
const set = new Set<string>();
|
const set = new Set<string>();
|
||||||
projects.forEach((p) => p.name && set.add(p.name));
|
projects.forEach((p) => p.name && set.add(p.name));
|
||||||
@@ -147,10 +140,8 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
const confirm = () => {
|
const confirm = () => {
|
||||||
if (!q) return;
|
if (!q) return;
|
||||||
|
|
||||||
// پچ نهایی — همهی تغییرات روی همین کلون اعمال میشود (نه روی آبجکت store)
|
|
||||||
const next: Quote = JSON.parse(JSON.stringify(q));
|
const next: Quote = JSON.parse(JSON.stringify(q));
|
||||||
|
|
||||||
// فقط کارتهایی با حداقل یک مرحلهی پرداخت
|
|
||||||
const cardMeta = new Map<string, { milestones: Milestone[]; label: string; existing: Quote["termsheets"][number] | undefined }>();
|
const cardMeta = new Map<string, { milestones: Milestone[]; label: string; existing: Quote["termsheets"][number] | undefined }>();
|
||||||
cards.forEach((c, idx) => {
|
cards.forEach((c, idx) => {
|
||||||
const milestones = c.milestones.filter((m) => m.when_iso || m.amount != null);
|
const milestones = c.milestones.filter((m) => m.when_iso || m.amount != null);
|
||||||
@@ -164,11 +155,10 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!cardMeta.size) {
|
if (!cardMeta.size) {
|
||||||
toast.error("حداقل یک conditions با حداقل یک مرحله پرداخت لازم است.");
|
toast.error("حداقل یک شرایط پرداخت با حداقل یک مرحله پرداخت لازم است.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// رد کردن قلمها — در داده میمانند تا با بازگشت وضعیت برگردند
|
|
||||||
next.line_items.forEach((li) => {
|
next.line_items.forEach((li) => {
|
||||||
if (removedItems.has(li.id)) li.rejected = true;
|
if (removedItems.has(li.id)) li.rejected = true;
|
||||||
});
|
});
|
||||||
@@ -178,7 +168,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// فقط termsheetهایی که حداقل یک قلم انتخاب کرده پذیرفته میشوند
|
|
||||||
const fallbackDom = [...cardMeta.keys()][0];
|
const fallbackDom = [...cardMeta.keys()][0];
|
||||||
const chosenDomIds = new Set<string>();
|
const chosenDomIds = new Set<string>();
|
||||||
active.forEach((li) => {
|
active.forEach((li) => {
|
||||||
@@ -200,7 +189,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// قلمها: اتصالهای offered حفظ میشوند + جفت پذیرفتهشده ثبت میشود
|
|
||||||
active.forEach((li) => {
|
active.forEach((li) => {
|
||||||
const dom = assignments[li.id] || fallbackDom;
|
const dom = assignments[li.id] || fallbackDom;
|
||||||
const finalId = domToFinal[dom];
|
const finalId = domToFinal[dom];
|
||||||
@@ -210,7 +198,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
li.accepted_ts_id = finalId || null;
|
li.accepted_ts_id = finalId || null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// termsheet پذیرفتهشدهای که هیچ قلمی انتخابش نکرده → برمیگردد به offered
|
|
||||||
const usedIds = new Set<string>();
|
const usedIds = new Set<string>();
|
||||||
active.forEach((li) => {
|
active.forEach((li) => {
|
||||||
if (li.accepted_ts_id) usedIds.add(li.accepted_ts_id);
|
if (li.accepted_ts_id) usedIds.add(li.accepted_ts_id);
|
||||||
@@ -219,14 +206,12 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
if (t.status === "accepted" && !usedIds.has(t.id)) t.status = "offered";
|
if (t.status === "accepted" && !usedIds.has(t.id)) t.status = "offered";
|
||||||
});
|
});
|
||||||
|
|
||||||
// پروژهی هر قلم (تکی هدف خرید) + همگامسازی پروژههای سطح پیشنهاد
|
|
||||||
const projUnion = new Set<string>();
|
const projUnion = new Set<string>();
|
||||||
next.line_items.forEach((li) => {
|
next.line_items.forEach((li) => {
|
||||||
if (li.rejected) return;
|
if (li.rejected) return;
|
||||||
const name = itemProjectsSel[li.id];
|
const name = itemProjectsSel[li.id];
|
||||||
if (name === undefined) return;
|
if (name === undefined) return;
|
||||||
li.target_project = name ? { id: "", name } : null;
|
li.target_project = name ? { id: "", name } : null;
|
||||||
// توجه: li.projects دستنخورده باقی میماند تا پروژههای کاندیدای اولیه حفظ شوند
|
|
||||||
if (name) projUnion.add(name);
|
if (name) projUnion.add(name);
|
||||||
});
|
});
|
||||||
if (projUnion.size > 0) {
|
if (projUnion.size > 0) {
|
||||||
@@ -248,26 +233,37 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}>
|
<Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}>
|
||||||
<DialogContent className="max-w-2xl">
|
<DialogContent className="max-w-3xl p-0 gap-0 overflow-hidden bg-slate-50 border-none shadow-2xl rounded-xl">
|
||||||
<DialogHeader>
|
<div className="flex flex-col max-h-[85vh] w-full">
|
||||||
<DialogTitle>پذیرش پیشنهاد — {sup.name}</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="text-[12px] text-muted">
|
{/* هدر ثابت و همتراز */}
|
||||||
شرایط پرداخت را انتخاب یا ویرایش کنید. سپس هر قلم را به conditions دلخواه تخصیص دهید.
|
<div className="px-6 py-4 bg-white border-b border-line shrink-0 z-10 text-right">
|
||||||
|
<DialogHeader className="p-0 text-right">
|
||||||
|
<DialogTitle className="text-right text-lg font-bold text-slate-800">
|
||||||
|
پذیرش پیشنهاد — {sup.name}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<p className="text-[13px] text-muted mt-1 text-right">
|
||||||
|
شرایط پرداخت را ویرایش کنید. سپس هر قلم را به بخش و پروژه دلخواه تخصیص دهید.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* کارتهای termsheet */}
|
{/* بدنه اسکرولپذیر */}
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex-1 overflow-y-auto p-4 md:p-6 space-y-5">
|
||||||
|
|
||||||
|
{/* بخش ۱: تنظیم شرایط پرداخت */}
|
||||||
|
<section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5">
|
||||||
|
<h3 className="mb-4 text-[14px] font-bold text-slate-800">۱. تنظیم شرایط پرداخت</h3>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
{cards.map((c, idx) => (
|
{cards.map((c, idx) => (
|
||||||
<div key={c.domId} className="modal-variant">
|
<div key={c.domId} className="modal-variant bg-slate-50/50 rounded-lg border border-line p-3">
|
||||||
<div className="modal-variant-head">
|
<div className="modal-variant-head mb-2 flex items-center justify-between">
|
||||||
<span className="variant-num">{cardLabel(c, idx)}</span>
|
<span className="variant-num font-semibold text-primary">{cardLabel(c, idx)}</span>
|
||||||
<button type="button" className="variant-x" title="حذف" onClick={() => removeCard(c.domId)}>
|
<button type="button" className="text-red-500 hover:text-red-700 hover:bg-red-50 rounded-full w-6 h-6 flex items-center justify-center transition-colors" title="حذف" onClick={() => removeCard(c.domId)}>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-2">
|
||||||
{c.milestones.map((m, i) => (
|
{c.milestones.map((m, i) => (
|
||||||
<MilestoneRow
|
<MilestoneRow
|
||||||
key={i}
|
key={i}
|
||||||
@@ -281,31 +277,35 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Button variant="line" size="sm" className="mt-1.5" onClick={() => updateCard(c.domId, { milestones: [...c.milestones, { when_iso: null, amount: null }] })}>
|
<Button variant="ghost" size="sm" className="mt-3 text-xs text-primary bg-primary/5 hover:bg-primary/10" onClick={() => updateCard(c.domId, { milestones: [...c.milestones, { when_iso: null, amount: null }] })}>
|
||||||
+ مرحله پرداخت
|
+ مرحله پرداخت جدید
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Button variant="line" size="sm" className="self-start" onClick={addNewCard}>
|
<Button variant="line" size="sm" className="mt-4 border-dashed border-primary/50 text-primary hover:bg-primary/5" onClick={addNewCard}>
|
||||||
+ نوشتن conditions جدید
|
+ افزودن شرایط پرداخت جدید
|
||||||
</Button>
|
</Button>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* تخصیص قلمها */}
|
{/* بخش ۲: تخصیص اقلام */}
|
||||||
<div className="border-t border-line pt-3">
|
<section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5">
|
||||||
<div className="mb-1 text-[13px] font-bold">تخصیص قلمها به conditions:</div>
|
<h3 className="mb-1 text-[14px] font-bold text-slate-800">۲. تخصیص اقلام به شرایط پرداخت</h3>
|
||||||
<div className="mb-2 text-[11.5px] text-muted">
|
<div className="mb-4 text-[12px] text-muted">
|
||||||
قلمی که میخرید را تیک بزنید؛ قلمی که نمیخرید را بدون تیک رها کنید تا از معامله حذف شود.
|
قلمی که میخرید را تیک بزنید؛ قلمی که نمیخرید را بدون تیک رها کنید تا از معامله حذف شود.
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex flex-col gap-2">
|
||||||
{allItems.map((li) => {
|
{allItems.map((li) => {
|
||||||
const removed = removedItems.has(li.id);
|
const removed = removedItems.has(li.id);
|
||||||
const m = materialById(materials, li.material_id);
|
const m = materialById(materials, li.material_id);
|
||||||
return (
|
return (
|
||||||
<div key={li.id} className={`accept-item-assign-row ${removed ? "rejected" : ""}`}>
|
<div
|
||||||
|
key={li.id}
|
||||||
|
className={`accept-item-assign-row flex flex-wrap sm:flex-nowrap items-center gap-3 p-3 rounded-lg border border-line transition-all ${removed ? "bg-slate-50 opacity-60 grayscale" : "bg-white hover:border-primary/30"}`}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="accent-accent"
|
className="accent-primary w-4 h-4 rounded cursor-pointer shrink-0"
|
||||||
checked={!removed}
|
checked={!removed}
|
||||||
title="تیک = در معامله میماند"
|
title="تیک = در معامله میماند"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -315,16 +315,20 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
setRemovedItems(next);
|
setRemovedItems(next);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="flex-1 text-[13px]">
|
<div className="flex-1 min-w-[120px]">
|
||||||
|
<div className="text-[13.5px] font-medium text-slate-800">
|
||||||
{formatMaterialTitle(m)}
|
{formatMaterialTitle(m)}
|
||||||
</span>
|
</div>
|
||||||
<span className="text-[12px] text-muted">
|
<div className="text-[12px] text-muted mt-0.5">
|
||||||
{fmtNum(li.quantity)} {li.unit} × {groupCommas(li.unit_price, true)}
|
{fmtNum(li.quantity)} {li.unit} × {groupCommas(li.unit_price, true)}
|
||||||
</span>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<select
|
<select
|
||||||
className="min-w-[140px] rounded-md border border-line bg-panel px-2 py-1.5 font-inherit text-[12px]"
|
className={`min-w-[160px] h-9 rounded-md border border-line bg-panel px-2.5 py-1.5 font-inherit text-[12px] outline-none transition-all ${!removed ? 'focus:border-primary focus:ring-1 focus:ring-primary' : 'cursor-not-allowed'}`}
|
||||||
value={assignments[li.id] || cards[0]?.domId || ""}
|
value={assignments[li.id] || cards[0]?.domId || ""}
|
||||||
onChange={(e) => assign(li.id, e.target.value)}
|
onChange={(e) => assign(li.id, e.target.value)}
|
||||||
|
disabled={removed}
|
||||||
>
|
>
|
||||||
{cards.map((c, idx) => (
|
{cards.map((c, idx) => (
|
||||||
<option key={c.domId} value={c.domId}>
|
<option key={c.domId} value={c.domId}>
|
||||||
@@ -336,35 +340,37 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{active.length === 0 && (
|
{active.length === 0 && (
|
||||||
<div className="mb-1 text-[12px] text-red">همهی قلمها رد شدهاند — حداقل یک قلم را تیک بزنید.</div>
|
<div className="mt-2 p-3 bg-red-50 text-red-600 rounded-md text-[13px] font-medium flex items-center gap-2">
|
||||||
|
<span>⚠️</span> همهی قلمها رد شدهاند — حداقل یک قلم را تیک بزنید.
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
{/* تخصیص قلمها به پروژه */}
|
{/* بخش ۳: تخصیص اقلام به پروژه */}
|
||||||
<div className="border-t border-line pt-3">
|
<section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5">
|
||||||
<div className="mb-1 text-[13px] font-bold">پروژهی هر قلم:</div>
|
<h3 className="mb-1 text-[14px] font-bold text-slate-800">۳. تخصیص اقلام به پروژهها</h3>
|
||||||
<div className="mb-2 text-[11.5px] text-muted">
|
<div className="mb-4 text-[12px] text-muted">
|
||||||
برای هر متریال فقط یک پروژه انتخاب میشود — مثل شرایط پرداخت.
|
برای هر متریال خریداری شده، میتوانید پروژه مربوط به آن را مشخص کنید.
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
{allItems
|
{allItems
|
||||||
.filter((li) => !removedItems.has(li.id))
|
.filter((li) => !removedItems.has(li.id))
|
||||||
.map((li) => {
|
.map((li) => {
|
||||||
const m = materialById(materials, li.material_id);
|
const m = materialById(materials, li.material_id);
|
||||||
return (
|
return (
|
||||||
<div key={li.id} className="rounded-lg border border-line p-2.5">
|
<div key={li.id} className="rounded-lg border border-line p-3 flex flex-col justify-between hover:border-primary/30 transition-colors">
|
||||||
<div className="mb-1.5 text-[12.5px]">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<span className="font-semibold">
|
<span className="text-[13px] font-semibold text-slate-800 truncate">
|
||||||
{formatMaterialTitle(m)}
|
{formatMaterialTitle(m)}
|
||||||
</span>
|
</span>
|
||||||
<span className="ms-2 text-[11px] text-muted">
|
<span className="ms-2 text-[11px] text-muted bg-slate-100 px-2 py-0.5 rounded-full whitespace-nowrap">
|
||||||
{fmtNum(li.quantity)} {li.unit}
|
{fmtNum(li.quantity)} {li.unit}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{projectOptions.length ? (
|
{projectOptions.length ? (
|
||||||
<select
|
<select
|
||||||
className="min-w-[180px] rounded-md border border-line bg-panel px-2 py-1.5 font-inherit text-[12px]"
|
className="w-full h-9 rounded-md border border-line bg-panel px-2.5 py-1.5 font-inherit text-[12px] outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
||||||
value={itemProjectsSel[li.id] ?? ""}
|
value={itemProjectsSel[li.id] ?? ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setItemProjectsSel((s) => ({ ...s, [li.id]: e.target.value }))
|
setItemProjectsSel((s) => ({ ...s, [li.id]: e.target.value }))
|
||||||
@@ -382,17 +388,18 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-[12px] text-muted">پروژهای ثبت نشده است.</div>
|
<div className="text-[12px] text-muted bg-slate-50 p-2 rounded-md text-center">پروژهای در سیستم ثبت نشده است.</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
{/* فاکتورهای اصلی */}
|
{/* بخش ۴: فاکتورها، پیشفاکتورها و تاریخ */}
|
||||||
<div className="border-t border-line pt-3">
|
<section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5 space-y-6">
|
||||||
<div className="mb-1 text-[13px] font-bold">فاکتورهای اصلی (چند فایل):</div>
|
<div>
|
||||||
|
<h3 className="mb-2 text-[14px] font-bold text-slate-800">فاکتورهای اصلی</h3>
|
||||||
<FileList
|
<FileList
|
||||||
files={invoices}
|
files={invoices}
|
||||||
onChange={setInvoices}
|
onChange={setInvoices}
|
||||||
@@ -407,9 +414,10 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* پیشفاکتورها */}
|
<div className="h-px bg-line w-full"></div>
|
||||||
<div className="border-t border-line pt-3">
|
|
||||||
<div className="mb-1 text-[13px] font-bold">پیشفاکتورها (چند فایل):</div>
|
<div>
|
||||||
|
<h3 className="mb-2 text-[14px] font-bold text-slate-800">پیشفاکتورها</h3>
|
||||||
<FileList
|
<FileList
|
||||||
files={proformas}
|
files={proformas}
|
||||||
onChange={setProformas}
|
onChange={setProformas}
|
||||||
@@ -425,20 +433,29 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* تاریخ و زمان پذیرش */}
|
<div className="h-px bg-line w-full"></div>
|
||||||
<div className="border-t border-line pt-3">
|
|
||||||
<div className="field max-w-xs">
|
<div className="max-w-xs">
|
||||||
<label>تاریخ و زمان پذیرش</label>
|
<label className="block mb-2 text-[13px] font-bold text-slate-800">تاریخ و زمان پذیرش</label>
|
||||||
<JalaliDatePicker value={acceptDate} onChange={setAcceptDate} placeholder="تاریخ و ساعت" withTime />
|
<JalaliDatePicker value={acceptDate} onChange={setAcceptDate} placeholder="تاریخ و ساعت" withTime />
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
{/* فوتر ثابت */}
|
||||||
<Button variant="slate" onClick={onClose}>
|
<div className="px-6 py-4 bg-white border-t border-line shrink-0 z-10">
|
||||||
|
<DialogFooter className="flex w-full gap-3 sm:justify-end">
|
||||||
|
<Button variant="slate" onClick={onClose} className="w-full sm:w-auto">
|
||||||
انصراف
|
انصراف
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={confirm}>ثبت معامله بستهشده</Button>
|
<Button onClick={confirm} className="w-full sm:w-auto">
|
||||||
|
ثبت معامله بستهشده
|
||||||
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,9 +11,8 @@ import {
|
|||||||
} from "../../hooks";
|
} from "../../hooks";
|
||||||
import { useAppStore } from "../../store";
|
import { useAppStore } from "../../store";
|
||||||
import { filterQuotes } from "../../lib/quoteUtils";
|
import { filterQuotes } from "../../lib/quoteUtils";
|
||||||
import { daysBetween } from "../../lib/formatters";
|
|
||||||
import { Header, PageLayout } from "../../components/layout/Header";
|
import { Header, PageLayout } from "../../components/layout/Header";
|
||||||
import { StatsBar } from "../../components/StatsBar";
|
import { StatsBar, type StatusFilter } from "../../components/StatsBar";
|
||||||
import { FilterToolbar, type Filters } from "./FilterToolbar";
|
import { FilterToolbar, type Filters } from "./FilterToolbar";
|
||||||
import { LedgerTable } from "./LedgerTable";
|
import { LedgerTable } from "./LedgerTable";
|
||||||
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
|
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
|
||||||
@@ -56,18 +55,31 @@ export function DashboardPage() {
|
|||||||
return [...list].sort((a, b) => (sortDir === "desc" ? createdTs(b) - createdTs(a) : createdTs(a) - createdTs(b)));
|
return [...list].sort((a, b) => (sortDir === "desc" ? createdTs(b) - createdTs(a) : createdTs(a) - createdTs(b)));
|
||||||
}, [store, filters, sortDir]);
|
}, [store, filters, sortDir]);
|
||||||
|
|
||||||
|
// شمارش تفکیکی پیشنهادها بر اساس پایپلاین خرید
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
const total = quotes.length;
|
const total = quotes.length;
|
||||||
const offered = quotes.filter((q) => q.status === "offered").length;
|
const offered = quotes.filter((q) => q.status === "offered").length;
|
||||||
const accepted = quotes.filter((q) => q.status === "accepted").length;
|
const accepted = quotes.filter((q) => q.status === "accepted").length;
|
||||||
const leadTimes = quotes
|
const arrived = quotes.filter((q) => q.status === "arrived").length;
|
||||||
.filter((q) => q.status === "arrived" && q.accepted_date && q.arrived_date)
|
return { total, offered, accepted, arrived };
|
||||||
.map((q) => daysBetween(q.arrived_date, q.accepted_date))
|
|
||||||
.filter((x): x is number => x != null);
|
|
||||||
const avgLead = leadTimes.length ? Math.round(leadTimes.reduce((a, b) => a + b, 0) / leadTimes.length) : null;
|
|
||||||
return { total, offered, accepted, avgLead };
|
|
||||||
}, [quotes]);
|
}, [quotes]);
|
||||||
|
|
||||||
|
// وضعیت فعال در StatsBar بر اساس مقدار فعلی filters.status
|
||||||
|
const activeStatusFilter: StatusFilter = useMemo(() => {
|
||||||
|
if (filters.status === "offered") return "offered";
|
||||||
|
if (filters.status === "accepted") return "accepted";
|
||||||
|
if (filters.status === "arrived") return "arrived";
|
||||||
|
return "all";
|
||||||
|
}, [filters.status]);
|
||||||
|
|
||||||
|
// تغییر فیلتر وضعیت هنگام کلیک روی هر یک از ۴ کارت
|
||||||
|
const handleStatusFilterChange = (status: StatusFilter) => {
|
||||||
|
setFilters((prev) => ({
|
||||||
|
...prev,
|
||||||
|
status: status === "all" ? "" : status,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
const categoryOptions = useMemo(() => [...new Set(materials.map((m) => m.category))], [materials]);
|
const categoryOptions = useMemo(() => [...new Set(materials.map((m) => m.category))], [materials]);
|
||||||
const supplierOptions = useMemo(() => suppliers.map((s) => ({ id: s.id, name: s.name })), [suppliers]);
|
const supplierOptions = useMemo(() => suppliers.map((s) => ({ id: s.id, name: s.name })), [suppliers]);
|
||||||
|
|
||||||
@@ -119,7 +131,9 @@ export function DashboardPage() {
|
|||||||
total={stats.total}
|
total={stats.total}
|
||||||
offered={stats.offered}
|
offered={stats.offered}
|
||||||
accepted={stats.accepted}
|
accepted={stats.accepted}
|
||||||
avgLeadTime={stats.avgLead}
|
arrived={stats.arrived}
|
||||||
|
activeFilter={activeStatusFilter}
|
||||||
|
onFilterChange={handleStatusFilterChange}
|
||||||
/>
|
/>
|
||||||
<FilterToolbar
|
<FilterToolbar
|
||||||
filters={filters}
|
filters={filters}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Trash2, AlertCircle } from "lucide-react";
|
import { Trash2, AlertCircle, Plus, Package, CreditCard, Calendar } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { Attachment, LineItem, Milestone, Quote, Termsheet } from "../../lib/types";
|
import type { Attachment, LineItem, Milestone, Quote, Termsheet } from "../../lib/types";
|
||||||
import { api, nextDashedId, nextId } from "../../lib/api";
|
import { api, nextDashedId, nextId } from "../../lib/api";
|
||||||
@@ -52,7 +52,7 @@ interface EditItemRow {
|
|||||||
|
|
||||||
interface EditTsCard {
|
interface EditTsCard {
|
||||||
key: string;
|
key: string;
|
||||||
domId: string; // "" یا "new-ts-..." برای کارت جدید
|
domId: string;
|
||||||
milestones: Milestone[];
|
milestones: Milestone[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +61,6 @@ export interface EditModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// مودال ویرایش کامل پیشنهاد
|
|
||||||
export function EditModal({ quoteId, onClose }: EditModalProps) {
|
export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||||
const quotes = useQuotes();
|
const quotes = useQuotes();
|
||||||
const suppliers = useSuppliers();
|
const suppliers = useSuppliers();
|
||||||
@@ -83,6 +82,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
}, [q]);
|
}, [q]);
|
||||||
|
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState<"items" | "terms" | "dates">("items");
|
||||||
const [supplierName, setSupplierName] = useState("");
|
const [supplierName, setSupplierName] = useState("");
|
||||||
const [selectedProjects, setSelectedProjects] = useState<string[]>([]);
|
const [selectedProjects, setSelectedProjects] = useState<string[]>([]);
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
@@ -112,7 +112,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// گزینههای مشخصه — همهی مشخصههای ثبتشده (کش) + رجیستری سراسری
|
|
||||||
const [globalSpecs, setGlobalSpecs] = useState<string[]>([]);
|
const [globalSpecs, setGlobalSpecs] = useState<string[]>([]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
@@ -120,7 +119,9 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
.specsList()
|
.specsList()
|
||||||
.then((list) => alive && setGlobalSpecs(list))
|
.then((list) => alive && setGlobalSpecs(list))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
return () => { alive = false; };
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const specOptions = useMemo(() => {
|
const specOptions = useMemo(() => {
|
||||||
@@ -131,7 +132,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
|
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
|
||||||
}, [materials, itemRows, globalSpecs]);
|
}, [materials, itemRows, globalSpecs]);
|
||||||
|
|
||||||
// گزینههای پروژه — پروژههای ثبتشده + پروژههای خود پیشنهاد
|
|
||||||
const projectOptions = useMemo(() => {
|
const projectOptions = useMemo(() => {
|
||||||
const set = new Set<string>();
|
const set = new Set<string>();
|
||||||
projects.forEach((p) => p.name && set.add(p.name));
|
projects.forEach((p) => p.name && set.add(p.name));
|
||||||
@@ -183,7 +183,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
setReady(true);
|
setReady(true);
|
||||||
}, [q, suppliers, materials]);
|
}, [q, suppliers, materials]);
|
||||||
|
|
||||||
// فاکتورها مستقیماً از سرور (MinIO) خوانده میشوند — نه از state محلی
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!quoteId) return;
|
if (!quoteId) return;
|
||||||
let alive = true;
|
let alive = true;
|
||||||
@@ -219,7 +218,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
unit: "",
|
unit: "",
|
||||||
price: null,
|
price: null,
|
||||||
total: null,
|
total: null,
|
||||||
tsDomIds: [],
|
tsDomIds: tsCards[0] ? [tsCards[0].domId || tsCards[0].key] : [],
|
||||||
acceptedTsId: null,
|
acceptedTsId: null,
|
||||||
rejected: false,
|
rejected: false,
|
||||||
restore: true,
|
restore: true,
|
||||||
@@ -250,7 +249,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
|
|
||||||
const next: Quote = JSON.parse(JSON.stringify(q));
|
const next: Quote = JSON.parse(JSON.stringify(q));
|
||||||
|
|
||||||
// تأمینکننده — موجود یا جدید
|
|
||||||
if (supplierName.trim()) {
|
if (supplierName.trim()) {
|
||||||
const norm = normalize(supplierName);
|
const norm = normalize(supplierName);
|
||||||
const exist = suppliers.find((s) => normalize(s.name) === norm);
|
const exist = suppliers.find((s) => normalize(s.name) === norm);
|
||||||
@@ -259,7 +257,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
} else {
|
} else {
|
||||||
const newSup = { id: nextId("s", suppliers), name: supplierName.trim(), location: "", phone: "", contact: "", topic: "", categories: [] };
|
const newSup = { id: nextId("s", suppliers), name: supplierName.trim(), location: "", phone: "", contact: "", topic: "", categories: [] };
|
||||||
const created = await addSupplier.mutateAsync(newSup);
|
const created = await addSupplier.mutateAsync(newSup);
|
||||||
next.supplier_id = created.id; // UUID واقعی از بکاند
|
next.supplier_id = created.id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,7 +266,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
next.notes = notes.trim();
|
next.notes = notes.trim();
|
||||||
next.invoices = invoices;
|
next.invoices = invoices;
|
||||||
|
|
||||||
// termsheetها — id موجود حفظ میشود، جدیدها ساخته میشوند
|
|
||||||
const tsIdMap: Record<string, string> = {};
|
const tsIdMap: Record<string, string> = {};
|
||||||
const newTermsheets: Termsheet[] = [];
|
const newTermsheets: Termsheet[] = [];
|
||||||
tsCards.forEach((card, i) => {
|
tsCards.forEach((card, i) => {
|
||||||
@@ -285,7 +282,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
});
|
});
|
||||||
next.termsheets = newTermsheets;
|
next.termsheets = newTermsheets;
|
||||||
|
|
||||||
// اقلام
|
|
||||||
const newItems: LineItem[] = [];
|
const newItems: LineItem[] = [];
|
||||||
const materialsCopy = [...materials];
|
const materialsCopy = [...materials];
|
||||||
for (const row of itemRows) {
|
for (const row of itemRows) {
|
||||||
@@ -296,7 +292,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
const qty = row.qty;
|
const qty = row.qty;
|
||||||
const price = row.price;
|
const price = row.price;
|
||||||
const total = row.total;
|
const total = row.total;
|
||||||
if (!cat && !brand && qty == null && price == null) continue; // ردیف خالی
|
if (!cat && !brand && qty == null && price == null) continue;
|
||||||
|
|
||||||
let material = materialsCopy.find(
|
let material = materialsCopy.find(
|
||||||
(m) =>
|
(m) =>
|
||||||
@@ -318,7 +314,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
base_unit: unit,
|
base_unit: unit,
|
||||||
};
|
};
|
||||||
materialsCopy.push(nm);
|
materialsCopy.push(nm);
|
||||||
// منتظر ساخت متریال میمانیم تا id واقعی (UUID) به قلم وصل شود
|
|
||||||
const created = await addMaterial.mutateAsync(nm);
|
const created = await addMaterial.mutateAsync(nm);
|
||||||
materialId = created.id;
|
materialId = created.id;
|
||||||
}
|
}
|
||||||
@@ -366,7 +361,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
}
|
}
|
||||||
next.line_items = newItems.length ? newItems : next.line_items;
|
next.line_items = newItems.length ? newItems : next.line_items;
|
||||||
|
|
||||||
// ترمشیت بدون قلم بیمعنی است — حذف
|
|
||||||
const referencedTs = new Set<string>();
|
const referencedTs = new Set<string>();
|
||||||
next.line_items.forEach((li) => (li.termsheet_ids || []).forEach((tid) => referencedTs.add(tid)));
|
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) => referencedTs.has(t.id));
|
||||||
@@ -376,14 +370,12 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// تاریخها و وضعیت
|
|
||||||
next.quote_date = quoteDate || q.quote_date;
|
next.quote_date = quoteDate || q.quote_date;
|
||||||
next.expiry_date = expiryDate ? (expiryDate.length > 10 ? expiryDate.slice(0, 10) : expiryDate) : "";
|
next.expiry_date = expiryDate ? (expiryDate.length > 10 ? expiryDate.slice(0, 10) : expiryDate) : "";
|
||||||
next.accepted_date = acceptedDate;
|
next.accepted_date = acceptedDate;
|
||||||
next.arrived_date = arrivedDate;
|
next.arrived_date = arrivedDate;
|
||||||
next.status = status as Quote["status"];
|
next.status = status as Quote["status"];
|
||||||
|
|
||||||
// پاکسازی خودکار در بازگشت وضعیت
|
|
||||||
if (next.status === "offered") {
|
if (next.status === "offered") {
|
||||||
next.accepted_date = null;
|
next.accepted_date = null;
|
||||||
next.arrived_date = null;
|
next.arrived_date = null;
|
||||||
@@ -417,21 +409,103 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}>
|
<Dialog open={!!quoteId} onOpenChange={(o) => !o && onClose()}>
|
||||||
<DialogContent className="max-w-3xl">
|
<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>
|
{/* هدر سفید و تمیز */}
|
||||||
<DialogTitle>ویرایش پیشنهاد</DialogTitle>
|
<DialogHeader className="border-b border-gray-100 bg-white px-4 py-3 sm:px-6 sm:py-4 shrink-0 text-right">
|
||||||
|
<DialogTitle className="text-base font-bold text-gray-900 sm:text-lg">ویرایش پیشنهاد خرید</DialogTitle>
|
||||||
|
<div className="text-[12px] text-gray-500 truncate mt-0.5">
|
||||||
|
{supplierName ? `تأمینکننده: ${supplierName}` : "تنظیم اقلام، مبالغ و شرایط پرداخت"}
|
||||||
|
</div>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{hasReceipts && (
|
{hasReceipts && (
|
||||||
<div className="flex items-center gap-2 rounded-lg border border-amber/30 bg-amber-bg px-3 py-2 text-[12.5px] font-medium text-amber">
|
<div className="flex items-center gap-2 border-b border-amber-200 bg-amber-50 px-4 py-2 text-[11.5px] font-medium text-amber-800 shrink-0">
|
||||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
<AlertCircle className="h-4 w-4 shrink-0 text-amber-600" />
|
||||||
<span>این پیشنهاد بهدلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.</span>
|
<span>این پیشنهاد بهدلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 max-md:grid-cols-1">
|
{/* تببار سفید و استاندارد */}
|
||||||
<div className="field">
|
<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">
|
||||||
<label>تأمینکننده</label>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab("items")}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-xs font-semibold transition-all",
|
||||||
|
activeTab === "items"
|
||||||
|
? "bg-white text-blue-600 shadow-xs border border-gray-200"
|
||||||
|
: "text-gray-600 hover:text-gray-900 hover:bg-white/60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Package className="h-3.5 w-3.5" />
|
||||||
|
<span>اقلام و سفارش</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"rounded-full px-1.5 py-0.2 text-[10.5px] font-mono",
|
||||||
|
activeTab === "items" ? "bg-blue-50 text-blue-600" : "bg-gray-200/70 text-gray-600",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{faNum(itemRows.length)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab("terms")}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-xs font-semibold transition-all",
|
||||||
|
activeTab === "terms"
|
||||||
|
? "bg-white text-blue-600 shadow-xs border border-gray-200"
|
||||||
|
: "text-gray-600 hover:text-gray-900 hover:bg-white/60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CreditCard className="h-3.5 w-3.5" />
|
||||||
|
<span>شرایط پرداخت</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"rounded-full px-1.5 py-0.2 text-[10.5px] font-mono",
|
||||||
|
activeTab === "terms" ? "bg-blue-50 text-blue-600" : "bg-gray-200/70 text-gray-600",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{faNum(tsCards.length)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab("dates")}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-xs font-semibold transition-all",
|
||||||
|
activeTab === "dates"
|
||||||
|
? "bg-white text-blue-600 shadow-xs border border-gray-200"
|
||||||
|
: "text-gray-600 hover:text-gray-900 hover:bg-white/60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Calendar className="h-3.5 w-3.5" />
|
||||||
|
<span>وضعیت و اسناد</span>
|
||||||
|
{invoices.length > 0 && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"rounded-full px-1.5 py-0.2 text-[10.5px] font-mono",
|
||||||
|
activeTab === "dates" ? "bg-blue-50 text-blue-600" : "bg-gray-200/70 text-gray-600",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{faNum(invoices.length)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* محتوای مودال — پسزمینه کاملاً سفید */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-3.5 sm:p-5 space-y-4 bg-white text-gray-800">
|
||||||
|
{/* تب ۱: اقلام و مشخصات */}
|
||||||
|
{activeTab === "items" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* کارت تأمینکننده و پروژه */}
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-gray-50/50 p-3.5 sm:p-4 space-y-3">
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
|
<label className="text-xs font-semibold text-gray-600">تأمینکننده</label>
|
||||||
<Combobox
|
<Combobox
|
||||||
value={supplierName}
|
value={supplierName}
|
||||||
onChange={(it: { id: string; label: string }) => setSupplierName(it.label)}
|
onChange={(it: { id: string; label: string }) => setSupplierName(it.label)}
|
||||||
@@ -442,8 +516,9 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
onInputChange={(v) => setSupplierName(v)}
|
onInputChange={(v) => setSupplierName(v)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
|
||||||
<label>پروژه</label>
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
|
<label className="text-xs font-semibold text-gray-600">پروژه</label>
|
||||||
<MultiSelectCombobox
|
<MultiSelectCombobox
|
||||||
values={selectedProjects}
|
values={selectedProjects}
|
||||||
onChange={setSelectedProjects}
|
onChange={setSelectedProjects}
|
||||||
@@ -451,46 +526,84 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
placeholder="انتخاب پروژه..."
|
placeholder="انتخاب پروژه..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="field col-span-2">
|
</div>
|
||||||
<label>یادداشت</label>
|
|
||||||
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
|
<label className="text-xs font-semibold text-gray-600">یادداشت</label>
|
||||||
<textarea
|
<textarea
|
||||||
rows={2}
|
rows={2}
|
||||||
className="w-full rounded-lg border border-line bg-panel px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
className="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs sm:text-sm text-gray-800 shadow-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30"
|
||||||
value={notes}
|
value={notes}
|
||||||
|
placeholder="یادداشتهای داخلی این معامله..."
|
||||||
onChange={(e) => setNotes(e.target.value)}
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* اقلام */}
|
{/* لیست اقلام */}
|
||||||
<div className="border-t border-line pt-3">
|
<div className="space-y-3">
|
||||||
<div className="mb-2 text-[13px] font-bold">اقلام سفارش:</div>
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex flex-col gap-3">
|
<span className="text-xs font-bold text-gray-800">اقلام سفارش ({faNum(itemRows.length)})</span>
|
||||||
{itemRows.map((row) => (
|
</div>
|
||||||
<div key={row.key} className={`rounded-lg border p-3 ${row.rejected ? "border-dashed border-line opacity-60" : "border-line"}`}>
|
|
||||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
{itemRows.map((row, idx) => (
|
||||||
<div className="field">
|
<div
|
||||||
<label>متریال</label>
|
key={row.key}
|
||||||
|
className={cn(
|
||||||
|
"rounded-xl border p-3.5 sm:p-4 transition-all bg-white",
|
||||||
|
row.rejected ? "border-dashed border-gray-300 opacity-60 bg-gray-50/40" : "border-gray-200 shadow-xs",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between pb-2 mb-3 border-b border-gray-100">
|
||||||
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
|
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-blue-50 text-[11px] font-bold text-blue-600">
|
||||||
|
{faNum(idx + 1)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-bold text-gray-900 truncate">
|
||||||
|
{row.cat || "قلم جدید"} {row.brand ? `• ${row.brand}` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!hasReceipts && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs text-red-600 hover:bg-red-50 transition-colors"
|
||||||
|
title="حذف قلم"
|
||||||
|
onClick={() => setItemRows((rows) => rows.filter((r) => r.key !== row.key))}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
<span className="text-[11px]">حذف</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[11px] font-semibold text-gray-600">متریال / دسته</label>
|
||||||
<input
|
<input
|
||||||
disabled={hasReceipts}
|
disabled={hasReceipts}
|
||||||
className="h-9 w-full rounded-md border border-line bg-panel px-2.5 text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
|
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="متریال"
|
placeholder="نام متریال"
|
||||||
value={row.cat}
|
value={row.cat}
|
||||||
onChange={(e) => patchItem(row.key, { cat: e.target.value })}
|
onChange={(e) => patchItem(row.key, { cat: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
|
||||||
<label>برند</label>
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[11px] font-semibold text-gray-600">برند</label>
|
||||||
<input
|
<input
|
||||||
disabled={hasReceipts}
|
disabled={hasReceipts}
|
||||||
className="h-9 w-full rounded-md border border-line bg-panel px-2.5 text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
|
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="برند"
|
placeholder="برند"
|
||||||
value={row.brand}
|
value={row.brand}
|
||||||
onChange={(e) => patchItem(row.key, { brand: e.target.value })}
|
onChange={(e) => patchItem(row.key, { brand: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
</div>
|
||||||
<label>مشخصه</label>
|
|
||||||
|
<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
|
<MultiSelectCombobox
|
||||||
disabled={hasReceipts}
|
disabled={hasReceipts}
|
||||||
values={row.specs}
|
values={row.specs}
|
||||||
@@ -499,18 +612,23 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
placeholder="مشخصه"
|
placeholder="مشخصه"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
|
||||||
<label>واحد</label>
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[11px] font-semibold text-gray-600">واحد</label>
|
||||||
<input
|
<input
|
||||||
disabled={hasReceipts}
|
disabled={hasReceipts}
|
||||||
className="h-9 w-full rounded-md border border-line bg-panel px-2.5 text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
|
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="واحد"
|
placeholder="واحد سنجش"
|
||||||
value={row.unit}
|
value={row.unit}
|
||||||
onChange={(e) => patchItem(row.key, { unit: e.target.value })}
|
onChange={(e) => patchItem(row.key, { unit: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
</div>
|
||||||
<label>تعداد</label>
|
|
||||||
|
{/* جعبه ۳ عددی مقادیر */}
|
||||||
|
<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
|
<NumberInput
|
||||||
disabled={hasReceipts}
|
disabled={hasReceipts}
|
||||||
value={row.qty}
|
value={row.qty}
|
||||||
@@ -523,8 +641,9 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
placeholder="تعداد"
|
placeholder="تعداد"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
|
||||||
<label>قیمت واحد</label>
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[10.5px] font-semibold text-gray-600">قیمت واحد</label>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
disabled={hasReceipts}
|
disabled={hasReceipts}
|
||||||
value={row.price}
|
value={row.price}
|
||||||
@@ -534,12 +653,13 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
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 * 100) / 100 : row.total,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
placeholder="قیمت واحد"
|
placeholder="واحد"
|
||||||
maxDecimals={2}
|
maxDecimals={2}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
|
||||||
<label>قیمت کل</label>
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[10.5px] font-semibold text-gray-600">قیمت کل</label>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
disabled={hasReceipts}
|
disabled={hasReceipts}
|
||||||
value={row.total}
|
value={row.total}
|
||||||
@@ -550,45 +670,45 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
t != null && row.qty != null && row.qty > 0 ? Math.round((t / row.qty) * 100) / 100 : row.price,
|
t != null && row.qty != null && row.qty > 0 ? Math.round((t / row.qty) * 100) / 100 : row.price,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
placeholder="قیمت کل"
|
placeholder="جمع"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-end justify-end">
|
|
||||||
{!hasReceipts && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-[12px] text-muted transition-colors hover:bg-red-bg hover:text-red"
|
|
||||||
title="حذف قلم"
|
|
||||||
onClick={() => setItemRows((rows) => rows.filter((r) => r.key !== row.key))}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
حذف
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{/* تخصیص termsheet / بازگرداندن */}
|
{/* شرایط پرداخت برای این قلم */}
|
||||||
<div className="mt-2.5 border-t border-line pt-2">
|
<div className="pt-2 border-t border-gray-100">
|
||||||
{row.rejected ? (
|
{row.rejected ? (
|
||||||
<label className="ts-cb-label e-restore">
|
<label className="inline-flex items-center gap-1.5 text-xs font-semibold text-blue-600 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
className="accent-blue-600 h-4 w-4 rounded"
|
||||||
checked={row.restore}
|
checked={row.restore}
|
||||||
onChange={(e) => patchItem(row.key, { restore: e.target.checked })}
|
onChange={(e) => patchItem(row.key, { restore: e.target.checked })}
|
||||||
/>
|
/>
|
||||||
بازگرداندن به معامله
|
بازگرداندن این قلم به معامله
|
||||||
</label>
|
</label>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className="flex flex-col gap-1.5">
|
||||||
<div className="mb-1 text-[10.5px] font-semibold text-muted">این قلم تحت کدام termsheet هاست؟</div>
|
<span className="text-[10.5px] font-semibold text-gray-500">شرایط پرداخت مرتبط:</span>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{tsCards.map((card, idx) => (
|
{tsCards.map((card, cIdx) => {
|
||||||
<label key={card.key} className={cn("chip cursor-pointer", hasReceipts && "cursor-not-allowed opacity-60")}>
|
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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
disabled={hasReceipts}
|
disabled={hasReceipts}
|
||||||
className="me-1 accent-accent disabled:cursor-not-allowed"
|
className="accent-blue-600 h-3.5 w-3.5 rounded"
|
||||||
checked={row.tsDomIds.includes(card.domId || card.key)}
|
checked={isSelected}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const next = new Set(row.tsDomIds);
|
const next = new Set(row.tsDomIds);
|
||||||
const domId = card.domId || card.key;
|
const domId = card.domId || card.key;
|
||||||
@@ -597,73 +717,170 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
patchItem(row.key, { tsDomIds: [...next] });
|
patchItem(row.key, { tsDomIds: [...next] });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{tsLabel(card, idx)}
|
<span>{tsLabel(card, cIdx)}</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
{!hasReceipts && (
|
{!hasReceipts && (
|
||||||
<Button variant="line" size="sm" className="mt-2" onClick={addItemRow}>
|
<Button
|
||||||
+ افزودن قلم
|
type="button"
|
||||||
|
variant="line"
|
||||||
|
size="sm"
|
||||||
|
className="w-full border-dashed border-gray-300 py-2 text-xs font-medium text-gray-700 gap-1 hover:border-blue-500 hover:text-blue-600 hover:bg-blue-50/50"
|
||||||
|
onClick={addItemRow}
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
<span>افزودن قلم جدید</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* تب ۲: شرایط پرداخت */}
|
||||||
|
{activeTab === "terms" && (
|
||||||
|
<div className="space-y-3.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-xs font-bold text-gray-800">گزینههای شرایط پرداخت</div>
|
||||||
|
{!hasReceipts && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="line"
|
||||||
|
size="sm"
|
||||||
|
className="gap-1 text-xs text-gray-700"
|
||||||
|
onClick={addTsCard}
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
<span>افزودن گزینه جدید</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* termsheetها */}
|
<div className="flex flex-col gap-3">
|
||||||
<div className="border-t border-line pt-3">
|
|
||||||
<div className="mb-2 text-[13px] font-bold">termsheet ها (شرایط پرداخت):</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{tsCards.map((card, idx) => (
|
{tsCards.map((card, idx) => (
|
||||||
<div key={card.key} className="variant-card">
|
<div
|
||||||
<div className="variant-head">
|
key={card.key}
|
||||||
<span className="variant-num">{tsLabel(card, idx)}</span>
|
className="rounded-xl border border-gray-200 bg-white p-3.5 sm:p-4 shadow-xs"
|
||||||
{!hasReceipts && (
|
>
|
||||||
<button type="button" className="variant-x" title="حذف" onClick={() => removeTsCard(card.key)}>
|
<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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-1 flex flex-col gap-1.5">
|
|
||||||
|
<div className="flex flex-col gap-2 mb-3">
|
||||||
{card.milestones.map((ms, i) => (
|
{card.milestones.map((ms, i) => (
|
||||||
<MilestoneRow
|
<MilestoneRow
|
||||||
key={i}
|
key={i}
|
||||||
milestone={ms}
|
milestone={ms}
|
||||||
disabled={hasReceipts}
|
disabled={hasReceipts}
|
||||||
onChange={(nextMs) =>
|
onChange={(nextMs) =>
|
||||||
updateTs(card.key, { milestones: card.milestones.map((x, j) => (j === i ? nextMs : x)) })
|
updateTs(card.key, {
|
||||||
|
milestones: card.milestones.map((x, j) => (j === i ? nextMs : x)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onRemove={() =>
|
||||||
|
updateTs(card.key, {
|
||||||
|
milestones: card.milestones.filter((_, j) => j !== i),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
onRemove={() => updateTs(card.key, { milestones: card.milestones.filter((_, j) => j !== i) })}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!hasReceipts && (
|
{!hasReceipts && (
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
variant="line"
|
variant="line"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="mt-1"
|
className="w-full sm:w-auto text-xs text-gray-700"
|
||||||
onClick={() => updateTs(card.key, { milestones: [...card.milestones, { when_iso: null, amount: null }] })}
|
onClick={() =>
|
||||||
|
updateTs(card.key, {
|
||||||
|
milestones: [...card.milestones, { when_iso: null, amount: null }],
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
+ مرحله پرداخت
|
<Plus className="h-3.5 w-3.5 me-1" />
|
||||||
|
افزودن مرحله پرداخت
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{!hasReceipts && (
|
</div>
|
||||||
<Button variant="line" size="sm" className="mt-2" onClick={addTsCard}>
|
|
||||||
+ افزودن termsheet
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* تب ۳: وضعیت و اسناد */}
|
||||||
|
{activeTab === "dates" && (
|
||||||
|
<div className="space-y-3.5">
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-3.5 sm:p-4 space-y-2 shadow-xs">
|
||||||
|
<label className="text-xs font-bold text-gray-900 block">وضعیت فعلی پیشنهاد</label>
|
||||||
|
<select
|
||||||
|
className="h-9 w-full rounded-lg border border-gray-200 bg-white px-3 text-xs sm:text-sm text-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
|
||||||
|
value={status}
|
||||||
|
disabled={hasReceipts}
|
||||||
|
onChange={(e) => setStatus(e.target.value)}
|
||||||
|
>
|
||||||
|
{statusOptionsFor(q.status).map((st) => (
|
||||||
|
<option key={st} value={st}>
|
||||||
|
{STATUS_LABEL[st]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div className="text-[11px] text-gray-500">
|
||||||
|
* تغییر وضعیت برای عقب بردن مرحله معامله کاربرد دارد.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* فاکتورها */}
|
<div className="rounded-xl border border-gray-200 bg-white p-3.5 sm:p-4 space-y-2.5 shadow-xs">
|
||||||
<div className="border-t border-line pt-3">
|
<div className="text-xs font-bold text-gray-900">تاریخهای چرخه خرید</div>
|
||||||
<div className="mb-1 text-[13px] font-bold">فاکتورهای اصلی (چند فایل):</div>
|
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-xs font-semibold text-gray-600">تاریخ پیشنهاد</label>
|
||||||
|
<JalaliDatePicker value={quoteDate} onChange={setQuoteDate} placeholder="تاریخ" withTime />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-xs font-semibold text-gray-600">تاریخ انقضا</label>
|
||||||
|
<JalaliDatePicker value={expiryDate} onChange={setExpiryDate} placeholder="تاریخ" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-xs font-semibold text-gray-600">تاریخ پذیرش</label>
|
||||||
|
<JalaliDatePicker value={acceptedDate} onChange={setAcceptedDate} placeholder="تاریخ" withTime />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-xs font-semibold text-gray-600">تاریخ رسید</label>
|
||||||
|
<JalaliDatePicker value={arrivedDate} onChange={setArrivedDate} placeholder="تاریخ" withTime />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-white p-3.5 sm:p-4 space-y-2 shadow-xs">
|
||||||
|
<div className="text-xs font-bold text-gray-900">فاکتورهای اصلی و پیوستها</div>
|
||||||
<FileList
|
<FileList
|
||||||
files={invoices}
|
files={invoices}
|
||||||
onChange={setInvoices}
|
onChange={setInvoices}
|
||||||
@@ -677,87 +894,53 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
urlOf={(a) => (a.id ? api.files.downloadUrl(q.id, "invoice", a.id) : null)}
|
urlOf={(a) => (a.id ? api.files.downloadUrl(q.id, "invoice", a.id) : null)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* تاریخها و وضعیت */}
|
|
||||||
<div className="border-t border-line pt-3">
|
|
||||||
<div className="mb-2 text-[12px] text-muted">تاریخها و وضعیت:</div>
|
|
||||||
<div className="grid grid-cols-2 gap-3 max-md:grid-cols-1">
|
|
||||||
<div className="field">
|
|
||||||
<label>تاریخ پیشنهاد</label>
|
|
||||||
<JalaliDatePicker value={quoteDate} onChange={setQuoteDate} placeholder="تاریخ" withTime />
|
|
||||||
</div>
|
|
||||||
<div className="field">
|
|
||||||
<label>تاریخ انقضا</label>
|
|
||||||
<JalaliDatePicker value={expiryDate} onChange={setExpiryDate} placeholder="تاریخ" />
|
|
||||||
</div>
|
|
||||||
<div className="field">
|
|
||||||
<label>تاریخ پذیرش</label>
|
|
||||||
<JalaliDatePicker value={acceptedDate} onChange={setAcceptedDate} placeholder="تاریخ" withTime />
|
|
||||||
</div>
|
|
||||||
<div className="field">
|
|
||||||
<label>تاریخ رسید</label>
|
|
||||||
<JalaliDatePicker value={arrivedDate} onChange={setArrivedDate} placeholder="تاریخ" withTime />
|
|
||||||
</div>
|
|
||||||
<div className="field col-span-2">
|
|
||||||
<label>وضعیت (فقط بازگشت به مرحلههای قبل)</label>
|
|
||||||
<select
|
|
||||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60"
|
|
||||||
value={status}
|
|
||||||
disabled={hasReceipts}
|
|
||||||
onChange={(e) => setStatus(e.target.value)}
|
|
||||||
>
|
|
||||||
{statusOptionsFor(q.status).map((st) => (
|
|
||||||
<option key={st} value={st}>
|
|
||||||
{STATUS_LABEL[st]}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{hasReceipts && (
|
|
||||||
<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>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="flex items-center justify-between gap-2 max-sm:flex-col-reverse">
|
{/* فوتر سفید، محکم و چسبان */}
|
||||||
|
<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" ? (
|
{q.status === "offered" ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
onClick={() => setShowDeleteConfirm(true)}
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
className="gap-1.5"
|
className="gap-1 px-2.5 sm:px-3 text-xs shrink-0"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
<span>حذف پیشنهاد</span>
|
<span className="hidden sm:inline">حذف</span>
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<div />
|
<div />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="slate" onClick={onClose}>
|
<Button variant="slate" size="sm" onClick={onClose} className="px-3.5 text-xs">
|
||||||
انصراف
|
انصراف
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => void confirm()}>ذخیره تغییرات</Button>
|
<Button size="sm" onClick={() => void confirm()} className="px-4 text-xs">
|
||||||
|
ذخیره تغییرات
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* مدال تأیید حذف پیشنهاد */}
|
{/* مودال تایید حذف سفید */}
|
||||||
<Dialog open={showDeleteConfirm} onOpenChange={(open) => !deleting && setShowDeleteConfirm(open)}>
|
<Dialog open={showDeleteConfirm} onOpenChange={(open) => !deleting && setShowDeleteConfirm(open)}>
|
||||||
<DialogContent className="z-[60] max-w-md">
|
<DialogContent className="z-[60] max-w-md p-4 sm:p-6 bg-white border border-gray-200 text-gray-900">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>حذف پیشنهاد خرید</DialogTitle>
|
<DialogTitle className="text-gray-900">حذف پیشنهاد خرید</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="py-2 text-sm text-muted">
|
<div className="py-2 text-xs sm:text-sm text-gray-600">
|
||||||
آیا از حذف کامل این پیشنهاد اطمینان دارید؟ این عملیات غیرقابل بازگشت است.
|
آیا از حذف کامل این پیشنهاد اطمینان دارید؟ این عملیات غیرقابل بازگشت است.
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter className="flex items-center justify-end gap-2">
|
<DialogFooter className="flex items-center justify-end gap-2 pt-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="slate"
|
variant="slate"
|
||||||
|
size="sm"
|
||||||
onClick={() => setShowDeleteConfirm(false)}
|
onClick={() => setShowDeleteConfirm(false)}
|
||||||
disabled={deleting}
|
disabled={deleting}
|
||||||
>
|
>
|
||||||
@@ -766,6 +949,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
onClick={() => void handleDelete()}
|
onClick={() => void handleDelete()}
|
||||||
disabled={deleting}
|
disabled={deleting}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { SlidersHorizontal, X, ArrowUpDown, RotateCcw } from "lucide-react";
|
import { SlidersHorizontal, X, ArrowUpDown, RotateCcw } from "lucide-react";
|
||||||
import { Combobox } from "../../components/Combobox";
|
import { Combobox } from "../../components/Combobox";
|
||||||
import { STATUS_LABEL, STATUS_ORDER } from "../../lib/constants";
|
|
||||||
|
|
||||||
export interface Filters {
|
export interface Filters {
|
||||||
category: string;
|
category: string;
|
||||||
@@ -10,8 +9,33 @@ export interface Filters {
|
|||||||
search: string;
|
search: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// لیست وضعیتها از dictionary واحد (constants) — نه آرایهی تکراری
|
// گزینههای استاندارد و یکپارچه وضعیت هماهنگ با کارتهای بالای صفحه
|
||||||
const STATUS_ITEMS = STATUS_ORDER.map((s) => STATUS_LABEL[s]);
|
const STATUS_OPTIONS = [
|
||||||
|
{ id: "offered", label: "پیشنهاد باز" },
|
||||||
|
{ id: "accepted", label: "پذیرفتهشده" },
|
||||||
|
{ id: "arrived", label: "تحویلشده" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATUS_MAP: Record<string, string> = {
|
||||||
|
offered: "پیشنهاد باز",
|
||||||
|
accepted: "پذیرفتهشده",
|
||||||
|
arrived: "تحویلشده",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_TO_KEY: Record<string, string> = {
|
||||||
|
"پیشنهاد باز": "offered",
|
||||||
|
"پذیرفتهشده": "accepted",
|
||||||
|
"تحویلشده": "arrived",
|
||||||
|
"پیشنهاد شده": "offered",
|
||||||
|
"پذیرفته شده": "accepted",
|
||||||
|
"رسیده": "arrived",
|
||||||
|
"باز": "offered",
|
||||||
|
"پذیرفته": "accepted",
|
||||||
|
"تحویل": "arrived",
|
||||||
|
offered: "offered",
|
||||||
|
accepted: "accepted",
|
||||||
|
arrived: "arrived",
|
||||||
|
};
|
||||||
|
|
||||||
export interface FilterToolbarProps {
|
export interface FilterToolbarProps {
|
||||||
filters: Filters;
|
filters: Filters;
|
||||||
@@ -40,6 +64,14 @@ export function FilterToolbar({
|
|||||||
onChange({ ...filters, category: "", supplier: "", status: "" });
|
onChange({ ...filters, category: "", supplier: "", status: "" });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// تبدیل کلید انگلیسی به لیبل فارسی برای نمایش داخل کامبوباکس
|
||||||
|
const statusDisplayValue = STATUS_MAP[filters.status] || filters.status;
|
||||||
|
|
||||||
|
const handleStatusChange = (val: string) => {
|
||||||
|
const key = STATUS_TO_KEY[val] || val;
|
||||||
|
set({ status: key });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* ===== نمای موبایل (صفحهنمایشهای زیر md) ===== */}
|
{/* ===== نمای موبایل (صفحهنمایشهای زیر md) ===== */}
|
||||||
@@ -148,12 +180,12 @@ export function FilterToolbar({
|
|||||||
<div className="field">
|
<div className="field">
|
||||||
<label className="mb-1 block text-xs font-semibold text-muted">وضعیت</label>
|
<label className="mb-1 block text-xs font-semibold text-muted">وضعیت</label>
|
||||||
<Combobox
|
<Combobox
|
||||||
value={filters.status}
|
value={statusDisplayValue}
|
||||||
onChange={(it: { id: string; label: string }) => set({ status: it.label })}
|
onChange={(it: { id: string; label: string }) => handleStatusChange(it.label)}
|
||||||
items={STATUS_ITEMS}
|
items={STATUS_OPTIONS}
|
||||||
allowAdd={false}
|
allowAdd={false}
|
||||||
placeholder="همه وضعیتها"
|
placeholder="همه وضعیتها"
|
||||||
onInputChange={(v) => set({ status: v })}
|
onInputChange={handleStatusChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -197,12 +229,12 @@ export function FilterToolbar({
|
|||||||
<div className="field min-w-[150px] flex-1">
|
<div className="field min-w-[150px] flex-1">
|
||||||
<label>وضعیت</label>
|
<label>وضعیت</label>
|
||||||
<Combobox
|
<Combobox
|
||||||
value={filters.status}
|
value={statusDisplayValue}
|
||||||
onChange={(it: { id: string; label: string }) => set({ status: it.label })}
|
onChange={(it: { id: string; label: string }) => handleStatusChange(it.label)}
|
||||||
items={STATUS_ITEMS}
|
items={STATUS_OPTIONS}
|
||||||
allowAdd={false}
|
allowAdd={false}
|
||||||
placeholder="همه"
|
placeholder="همه"
|
||||||
onInputChange={(v) => set({ status: v })}
|
onInputChange={handleStatusChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="field min-w-[200px] flex-1">
|
<div className="field min-w-[200px] flex-1">
|
||||||
|
|||||||
@@ -176,29 +176,60 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* اطلاعات کلیدی (گرید ۲ ستونه موبایل / ۴ ستونه دسکتاپ) */}
|
{/* اطلاعات کلیدی — چیدمان بهینهشده موبایل */}
|
||||||
<div className="grid grid-cols-2 gap-x-3 gap-y-2.5 p-3 sm:gap-x-5 sm:gap-y-3 sm:p-4 md:grid-cols-4">
|
<div className="grid grid-cols-2 gap-x-3 gap-y-3 p-3 sm:gap-x-5 sm:gap-y-3.5 sm:p-4 md:grid-cols-4">
|
||||||
<InfoCell label="اقلام">
|
{/* ۱. پروژه — تمامعرض در موبایل */}
|
||||||
|
<InfoCell label="پروژه" className="col-span-2 md:col-span-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-semibold text-ink sm:text-[13px]">
|
||||||
|
<Building2 className="h-3.5 w-3.5 shrink-0 text-muted" />
|
||||||
|
<span className="truncate">
|
||||||
|
{q.projects && q.projects.length > 0 ? q.projects.join("، ") : projectCell(q)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</InfoCell>
|
||||||
|
|
||||||
|
{/* ۲. اقلام — تمامعرض زیر پروژه با شکستن متن بدون سهنقطه */}
|
||||||
|
<InfoCell label="اقلام" className="col-span-2 md:col-span-1">
|
||||||
{summary.count > 1 ? (
|
{summary.count > 1 ? (
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<span className="font-semibold text-xs sm:text-sm">{fmtNum(summary.count)} قلم کالا</span>
|
<span className="font-bold text-xs text-ink sm:text-sm">
|
||||||
<div className="flex flex-col gap-0.5 text-[11px] leading-4 text-muted sm:text-[11.5px] sm:leading-5">
|
{fmtNum(summary.count)} قلم کالا
|
||||||
{store.quotes.find((x) => x.id === q.id)?.line_items?.filter((li) => !li.rejected).slice(0, 3).map((li) => {
|
</span>
|
||||||
|
<div className="flex flex-col gap-1.5 rounded-lg border border-line/60 bg-bg/50 p-2.5 text-[11px] leading-5 text-muted sm:text-[11.5px]">
|
||||||
|
{store.quotes
|
||||||
|
.find((x) => x.id === q.id)
|
||||||
|
?.line_items?.filter((li) => !li.rejected)
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((li) => {
|
||||||
const m = materialById(store.materials, li.material_id);
|
const m = materialById(store.materials, li.material_id);
|
||||||
return (
|
return (
|
||||||
<span key={li.id} className="truncate">
|
<div
|
||||||
• {materialLabel(m)} ({fmtNum(li.quantity)} {li.unit})
|
key={li.id}
|
||||||
|
className="flex items-start justify-between gap-2 border-b border-line/30 pb-1.5 last:border-0 last:pb-0"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1 font-medium text-ink leading-snug break-words">
|
||||||
|
• {materialLabel(m)}
|
||||||
</span>
|
</span>
|
||||||
|
<span className="shrink-0 font-semibold tabular-nums text-ink text-[10.5px] pt-0.5">
|
||||||
|
{fmtNum(li.quantity)}{" "}
|
||||||
|
<span className="font-normal text-muted text-[9.5px]">{li.unit}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{(q.line_items?.length || 0) > 3 && (
|
{(q.line_items?.length || 0) > 3 && (
|
||||||
<span className="text-[10px] text-muted">و {(q.line_items?.length || 0) - 3} قلم دیگر...</span>
|
<span className="pt-0.5 text-[10px] text-muted">
|
||||||
|
و {(q.line_items?.length || 0) - 3} قلم دیگر...
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className="truncate">{summary.text}</span>
|
<div className="flex items-start gap-1.5 text-xs font-medium leading-5 text-ink sm:text-[13px]">
|
||||||
|
<span className="break-words">{summary.text}</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{unitWarns.map((w) => (
|
{unitWarns.map((w) => (
|
||||||
<Link
|
<Link
|
||||||
key={w.unit}
|
key={w.unit}
|
||||||
@@ -206,25 +237,17 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
className="unit-warn mt-1 inline-flex items-center gap-1 text-[10px]"
|
className="unit-warn mt-1 inline-flex items-center gap-1 text-[10px]"
|
||||||
title={`ضریب تبدیل «${esc(w.unit)}» را در داشبورد واحدها ثبت کنید`}
|
title={`ضریب تبدیل «${esc(w.unit)}» را در داشبورد واحدها ثبت کنید`}
|
||||||
>
|
>
|
||||||
<TriangleAlert className="h-2.5 w-2.5" />
|
<TriangleAlert className="h-2.5 w-2.5 shrink-0" />
|
||||||
واحد جدید: {w.unit}
|
واحد جدید: {w.unit}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</InfoCell>
|
</InfoCell>
|
||||||
|
|
||||||
<InfoCell label="پروژه">
|
{/* ۳. مجموع مبلغ */}
|
||||||
<span className="inline-flex items-center gap-1 text-xs sm:text-[13px]">
|
<InfoCell label="مجموع مبلغ" className="col-span-1">
|
||||||
<Building2 className="h-3.5 w-3.5 shrink-0 text-muted" />
|
|
||||||
<span className="truncate">
|
|
||||||
{q.projects && q.projects.length > 0 ? q.projects.join("، ") : projectCell(q)}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</InfoCell>
|
|
||||||
|
|
||||||
<InfoCell label="مجموع مبلغ">
|
|
||||||
{pc ? (
|
{pc ? (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-extrabold text-ink tabular-nums sm:text-base">
|
<div className="text-sm font-extrabold tabular-nums text-ink sm:text-base">
|
||||||
{pc.num} <span className="text-[10px] font-normal text-muted sm:text-xs">تومان</span>
|
{pc.num} <span className="text-[10px] font-normal text-muted sm:text-xs">تومان</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden text-[11px] text-muted sm:block">({pc.words})</div>
|
<div className="hidden text-[11px] text-muted sm:block">({pc.words})</div>
|
||||||
@@ -234,10 +257,11 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
)}
|
)}
|
||||||
</InfoCell>
|
</InfoCell>
|
||||||
|
|
||||||
<InfoCell label="شرایط معامله">
|
{/* ۴. شرایط معامله */}
|
||||||
|
<InfoCell label="شرایط معامله" className="col-span-1">
|
||||||
{ts.title !== "—" ? (
|
{ts.title !== "—" ? (
|
||||||
<div>
|
<div>
|
||||||
<div className="font-semibold text-xs sm:text-[13px]" style={ts.color ? { color: ts.color } : undefined}>
|
<div className="text-xs font-semibold sm:text-[13px]" style={ts.color ? { color: ts.color } : undefined}>
|
||||||
{ts.title}
|
{ts.title}
|
||||||
</div>
|
</div>
|
||||||
{ts.sub && <div className="truncate text-[10px] text-muted sm:text-[11px]">{ts.sub}</div>}
|
{ts.sub && <div className="truncate text-[10px] text-muted sm:text-[11px]">{ts.sub}</div>}
|
||||||
@@ -248,12 +272,12 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
</InfoCell>
|
</InfoCell>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* تاریخها و اکشنها (۲ ستونه در موبایل، نواری در دسکتاپ) */}
|
{/* تاریخها و اکشنها */}
|
||||||
<div className="grid grid-cols-2 gap-2 border-t border-line bg-bg/40 p-2.5 text-[11px] text-muted sm:flex sm:flex-wrap sm:items-center sm:gap-x-5 sm:gap-y-2 sm:px-4 sm:py-2 sm:text-[11.5px]">
|
<div className="grid grid-cols-2 gap-2 border-t border-line bg-bg/40 p-2.5 text-[11px] text-muted sm:flex sm:flex-wrap sm:items-center sm:gap-x-5 sm:gap-y-2 sm:px-4 sm:py-2 sm:text-[11.5px]">
|
||||||
<span className="inline-flex items-center gap-1.5 truncate">
|
<span className="inline-flex items-center gap-1.5 truncate">
|
||||||
<Package className="h-3 w-3 shrink-0" />
|
<Package className="h-3 w-3 shrink-0" />
|
||||||
<span>تاریخ:</span>
|
<span>تاریخ:</span>
|
||||||
<b className="text-ink tabular-nums">{fmtDate(q.quote_date)}</b>
|
<b className="tabular-nums text-ink">{fmtDate(q.quote_date)}</b>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span className="inline-flex items-center gap-1.5 truncate">
|
<span className="inline-flex items-center gap-1.5 truncate">
|
||||||
@@ -262,7 +286,7 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
<b className={cn("tabular-nums", expired ? "text-amber" : "text-ink")}>
|
<b className={cn("tabular-nums", expired ? "text-amber" : "text-ink")}>
|
||||||
{fmtDate(q.expiry_date)}
|
{fmtDate(q.expiry_date)}
|
||||||
</b>
|
</b>
|
||||||
{expired && <AlarmClock className="h-3 w-3 text-amber shrink-0" aria-label="منقضی" />}
|
{expired && <AlarmClock className="h-3 w-3 shrink-0 text-amber" aria-label="منقضی" />}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ import { uniqueUnits } from "../../lib/units";
|
|||||||
import type { InventoryTransaction, TransactionType } from "../../lib/types";
|
import type { InventoryTransaction, TransactionType } from "../../lib/types";
|
||||||
import { faNum } from "../../lib/formatters";
|
import { faNum } from "../../lib/formatters";
|
||||||
import { gregorianToJalali, MONTHS, toFa } from "../../lib/jalali";
|
import { gregorianToJalali, MONTHS, toFa } from "../../lib/jalali";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
type FormType = TransactionType | "TRANSFER";
|
type FormType = TransactionType | "TRANSFER";
|
||||||
|
|
||||||
const FORM_TYPES: { t: FormType; label: string; dir: string; cls: "in" | "out" | "mv" | "cnt"; color: string; desc: string }[] = [
|
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: "WITHDRAWAL", label: "برداشت", dir: "کاهش", cls: "out", color: "#e11d48", desc: "انبار را انتخاب و نام متریال را تایپ کنید؛ همه مدلها (برند و مشخصه متفاوت) نمایش داده میشوند. برداشتها در «کنترل روزانه» امروز دیده میشوند." },
|
||||||
{ t: "RETURN", label: "برگشت", dir: "افزایش", cls: "in", color: "#10b981", desc: "برگشت از مصالحی که امروز برداشت شدهاند — نام متریال را تایپ کنید و مقدار برگشتی هر ردیف را بزنید." },
|
{ t: "RETURN", label: "برگشت", dir: "افزایش", cls: "in", color: "#10b981", desc: "برگشت از مصالحی که امروز برداشت شدهاند — نام متریال را تایپ کنید و مقدار برگشتی هر ردیف را بزنید." },
|
||||||
@@ -46,7 +46,6 @@ function jalDateLabel(d: Date): string {
|
|||||||
return `${toFa(jd)} ${MONTHS[jm - 1]} ${toFa(jy)}`;
|
return `${toFa(jd)} ${MONTHS[jm - 1]} ${toFa(jy)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// تغییر موجودی — پورت کامل صفحهی ارسالی (همه انواع تراکنش)
|
|
||||||
export function ChangeInventoryPage() {
|
export function ChangeInventoryPage() {
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const data = useInventoryData();
|
const data = useInventoryData();
|
||||||
@@ -60,15 +59,12 @@ export function ChangeInventoryPage() {
|
|||||||
const [brandTxt, setBrandTxt] = useState("");
|
const [brandTxt, setBrandTxt] = useState("");
|
||||||
const [unitTxt, setUnitTxt] = useState("");
|
const [unitTxt, setUnitTxt] = useState("");
|
||||||
const [qty, setQty] = useState("");
|
const [qty, setQty] = useState("");
|
||||||
// تاریخ و ساعت به صورت ISO (تقویم شمسی — JalaliDatePicker)
|
|
||||||
const [dateInput, setDateInput] = useState<string>(() => new Date().toISOString());
|
const [dateInput, setDateInput] = useState<string>(() => new Date().toISOString());
|
||||||
// مشخصههای فنی — چیپهای چندتایی، دقیقاً مثل فیلد «مشخصه» در ویزارد دفتر تدارکات
|
|
||||||
const [specsChips, setSpecsChips] = useState<string[]>([]);
|
const [specsChips, setSpecsChips] = useState<string[]>([]);
|
||||||
const [note, setNote] = useState("");
|
const [note, setNote] = useState("");
|
||||||
const [rows, setRows] = useState<RowDraft[]>([]);
|
const [rows, setRows] = useState<RowDraft[]>([]);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
// prefill از URL (پرش از کنترل روزانه)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t = params.get("type");
|
const t = params.get("type");
|
||||||
const w = params.get("wh");
|
const w = params.get("wh");
|
||||||
@@ -91,7 +87,6 @@ export function ChangeInventoryPage() {
|
|||||||
|
|
||||||
const formRecords = useMemo(() => {
|
const formRecords = useMemo(() => {
|
||||||
const txt = matTxt.trim();
|
const txt = matTxt.trim();
|
||||||
// انتقال از انبار مبدأ استفاده میکند، بقیه از انبار انتخابی
|
|
||||||
const wid = type === "TRANSFER" ? fromId : whId;
|
const wid = type === "TRANSFER" ? fromId : whId;
|
||||||
return data.inventory.filter((r) => {
|
return data.inventory.filter((r) => {
|
||||||
if (r.warehouse?.id !== wid) return false;
|
if (r.warehouse?.id !== wid) return false;
|
||||||
@@ -105,8 +100,6 @@ export function ChangeInventoryPage() {
|
|||||||
const unitIdByName = (name: string) => data.lookup.units.find((u) => (u.name_fa || u.code) === name)?.id || null;
|
const unitIdByName = (name: string) => data.lookup.units.find((u) => (u.name_fa || u.code) === name)?.id || null;
|
||||||
const parseNum = (s: string) => { const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, "")); return isFinite(v) ? v : NaN; };
|
const parseNum = (s: string) => { const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, "")); return isFinite(v) ? v : NaN; };
|
||||||
|
|
||||||
// تبدیل امن تاریخ فرم به ISO — اگر فیلد خالی/نامعتبر بود (که قبلاً submit را
|
|
||||||
// بیصدا میشکست) به «هماکنون» برمیگردد تا ثبت هیچوقت قفل نشود.
|
|
||||||
const toServerIso = () => {
|
const toServerIso = () => {
|
||||||
try {
|
try {
|
||||||
const d = new Date(dateInput);
|
const d = new Date(dateInput);
|
||||||
@@ -115,8 +108,6 @@ export function ChangeInventoryPage() {
|
|||||||
return new Date().toISOString();
|
return new Date().toISOString();
|
||||||
};
|
};
|
||||||
|
|
||||||
// هر چیپ یک مشخصه است؛ در specs انبار بهصورت کلید با مقدار خالی ذخیره میشود
|
|
||||||
// تا رکوردهای انبارِ دارای مشخصههای متفاوت از هم جدا بمانند
|
|
||||||
const specsFromChips = () => {
|
const specsFromChips = () => {
|
||||||
const out: Record<string, string> = {};
|
const out: Record<string, string> = {};
|
||||||
specsChips.forEach((c) => {
|
specsChips.forEach((c) => {
|
||||||
@@ -151,9 +142,6 @@ export function ChangeInventoryPage() {
|
|||||||
.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime());
|
.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime());
|
||||||
}, [data.transactions, whId, matTxt]);
|
}, [data.transactions, whId, matTxt]);
|
||||||
|
|
||||||
// --- پیدا شده: ماندهی مفقودی هر ردیف = مفقودی − مجموع پیداشدههای قبلیِ همان رکورد
|
|
||||||
const lossKey = (t: InventoryTransaction) =>
|
|
||||||
[t.warehouse_id, t.material?.id || "", t.brand?.id || "", JSON.stringify(t.specs || {})].join("|");
|
|
||||||
const foundRemaining = useMemo(() => {
|
const foundRemaining = useMemo(() => {
|
||||||
const foundByKey = new Map<string, number>();
|
const foundByKey = new Map<string, number>();
|
||||||
data.transactions
|
data.transactions
|
||||||
@@ -170,7 +158,9 @@ export function ChangeInventoryPage() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [lossTxs, data.transactions, whId, matTxt]);
|
}, [lossTxs, data.transactions, whId, matTxt]);
|
||||||
|
|
||||||
// مفقودیهای کاملاً پیداشده از فهرست «در انتظار پیدا شدن» حذف میشوند
|
const lossKey = (t: InventoryTransaction) =>
|
||||||
|
[t.warehouse_id, t.material?.id || "", t.brand?.id || "", JSON.stringify(t.specs || {})].join("|");
|
||||||
|
|
||||||
const pendingLosses = useMemo(
|
const pendingLosses = useMemo(
|
||||||
() =>
|
() =>
|
||||||
lossTxs
|
lossTxs
|
||||||
@@ -188,7 +178,6 @@ export function ChangeInventoryPage() {
|
|||||||
try {
|
try {
|
||||||
for (const op of ops) await postOne(op);
|
for (const op of ops) await postOne(op);
|
||||||
toast.success("ثبت شد ✓");
|
toast.success("ثبت شد ✓");
|
||||||
// اعلام به تبهای دیگر (داشبورد/کنترل روزانه/... باز خودشان را تازه میکنند)
|
|
||||||
notifyDataChanged("inventory");
|
notifyDataChanged("inventory");
|
||||||
setRows([]);
|
setRows([]);
|
||||||
setMatTxt("");
|
setMatTxt("");
|
||||||
@@ -205,7 +194,6 @@ export function ChangeInventoryPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------- submit handlers ----------
|
|
||||||
const submitPurchase = async () => {
|
const submitPurchase = async () => {
|
||||||
const matName = matTxt.trim();
|
const matName = matTxt.trim();
|
||||||
const brandName = brandTxt.trim();
|
const brandName = brandTxt.trim();
|
||||||
@@ -216,10 +204,9 @@ export function ChangeInventoryPage() {
|
|||||||
if (!brandName) return toast.error("نام برند را وارد کنید");
|
if (!brandName) return toast.error("نام برند را وارد کنید");
|
||||||
if (!unitName) return toast.error("نام واحد را وارد کنید");
|
if (!unitName) return toast.error("نام واحد را وارد کنید");
|
||||||
if (!(n > 0)) return toast.error("تعداد باید بزرگتر از صفر باشد");
|
if (!(n > 0)) return toast.error("تعداد باید بزرگتر از صفر باشد");
|
||||||
if (!specsComplete) return toast.error("حداقل یک مشخصه فنی ثبت کنید (نام را بنویسید و Enter بزنید)");
|
if (!specsComplete) return toast.error("حداقل یک مشخصه فنی ثبت کنید");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// ساختِ بههنگام موجودیتهای ناشناخته در کاتالوگ سراسری (بدون پیشنیاز تدارکات)
|
|
||||||
let uid = unitIdByName(unitName);
|
let uid = unitIdByName(unitName);
|
||||||
if (!uid) {
|
if (!uid) {
|
||||||
const created = await apiV26.catalog.createUnit(unitName);
|
const created = await apiV26.catalog.createUnit(unitName);
|
||||||
@@ -264,7 +251,6 @@ export function ChangeInventoryPage() {
|
|||||||
await postSeq(ops);
|
await postSeq(ops);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------- شمارش موجودی: پیشنمایش اختلاف + تأیید قبل از ثبت ----------
|
|
||||||
interface CountConfirmRow {
|
interface CountConfirmRow {
|
||||||
material: string;
|
material: string;
|
||||||
unit: string;
|
unit: string;
|
||||||
@@ -358,7 +344,7 @@ export function ChangeInventoryPage() {
|
|||||||
if (!(n > 0)) return null;
|
if (!(n > 0)) return null;
|
||||||
const item = pendingLosses[i];
|
const item = pendingLosses[i];
|
||||||
if (!item) return null;
|
if (!item) return null;
|
||||||
if (n > item.remaining) return null; // سقف سمت کلاینت (بکاند هم رد میکند)
|
if (n > item.remaining) return null;
|
||||||
const t = item.tx;
|
const t = item.tx;
|
||||||
return { transaction_type: "FOUND", warehouse_id: whId, material_id: t.material?.id, brand_id: t.brand?.id || null, quantity: n, unit_id: t.unit?.id ?? null, specs: t.specs || {}, note: r.note, transaction_date: toServerIso() };
|
return { transaction_type: "FOUND", warehouse_id: whId, material_id: t.material?.id, brand_id: t.brand?.id || null, quantity: n, unit_id: t.unit?.id ?? null, specs: t.specs || {}, note: r.note, transaction_date: toServerIso() };
|
||||||
})
|
})
|
||||||
@@ -382,12 +368,10 @@ export function ChangeInventoryPage() {
|
|||||||
|
|
||||||
const canSubmit = (): boolean => {
|
const canSubmit = (): boolean => {
|
||||||
if (type === "PURCHASE") {
|
if (type === "PURCHASE") {
|
||||||
// نامهای تایپشده لازم نیست از قبل در فهرست باشند — در submit ساخته میشوند
|
|
||||||
return !!(whId && matTxt.trim() && brandTxt.trim() && unitTxt.trim() && parseNum(qty) > 0 && specsComplete);
|
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 === "TRANSFER") return !!(fromId && toId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
|
||||||
if (type === "RETURN") return !!(whId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
|
if (type === "RETURN") return !!(whId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
|
||||||
// پیدا شده: هر مقدار واردشده باید داخل ماندهی مفقودی همان ردیف باشد
|
|
||||||
if (type === "FOUND") {
|
if (type === "FOUND") {
|
||||||
return !!(
|
return !!(
|
||||||
whId &&
|
whId &&
|
||||||
@@ -402,7 +386,6 @@ export function ChangeInventoryPage() {
|
|||||||
return !!(whId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
|
return !!(whId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
|
||||||
};
|
};
|
||||||
|
|
||||||
// sync rows when the record list changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (type === "RETURN") {
|
if (type === "RETURN") {
|
||||||
setRows(todayWithdrawals.map((t) => ({ key: t.inventory_id, qty: "", note: "" })));
|
setRows(todayWithdrawals.map((t) => ({ key: t.inventory_id, qty: "", note: "" })));
|
||||||
@@ -447,54 +430,68 @@ export function ChangeInventoryPage() {
|
|||||||
const isTransfer = type === "TRANSFER";
|
const isTransfer = type === "TRANSFER";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen overflow-x-hidden pb-20">
|
||||||
<Header
|
<Header
|
||||||
title="تغییر موجودی"
|
title="تغییر موجودی"
|
||||||
sub={inventorySubheader(user)}
|
sub={inventorySubheader(user)}
|
||||||
backTo="/inventory"
|
backTo="/inventory"
|
||||||
backLabel="بازگشت به داشبورد"
|
backLabel="بازگشت به داشبورد"
|
||||||
/>
|
/>
|
||||||
<PageLayout className="flex flex-col gap-3">
|
<PageLayout className="flex flex-col gap-3 px-3 md:px-6 w-full max-w-full">
|
||||||
{/* انتخاب نوع تراکنش */}
|
{/* انتخاب نوع تغییر موجودی — چیپهای لمسی مرتب در موبایل */}
|
||||||
<div className="rounded-xl border border-line bg-panel p-3">
|
<div className="rounded-xl border border-line bg-panel p-3 w-full shadow-2xs">
|
||||||
<h3 className="mb-2 text-[13px] font-bold">نوع تغییر موجودی</h3>
|
<h3 className="mb-2 text-[13px] font-bold">نوع تغییر موجودی</h3>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="grid grid-cols-2 gap-1.5 sm:flex sm:flex-wrap">
|
||||||
{FORM_TYPES.map((f) => (
|
{FORM_TYPES.map((f) => (
|
||||||
<button
|
<button
|
||||||
key={f.t}
|
key={f.t}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setType(f.t); setRows([]); setMatTxt(""); setWhId(""); setFromId(""); setToId(""); }}
|
onClick={() => { setType(f.t); setRows([]); setMatTxt(""); setWhId(""); setFromId(""); setToId(""); }}
|
||||||
className={`rounded-lg border px-3 py-2 text-[12.5px] ${type === f.t ? "font-bold text-white" : "border-line bg-panel"}`}
|
className={cn(
|
||||||
|
"rounded-lg border px-3 py-2 text-xs sm:text-[12.5px] text-start sm:text-center transition-all",
|
||||||
|
type === f.t ? "font-bold text-white shadow-xs" : "border-line bg-panel text-ink hover:bg-bg"
|
||||||
|
)}
|
||||||
style={type === f.t ? { background: f.color, borderColor: f.color } : undefined}
|
style={type === f.t ? { background: f.color, borderColor: f.color } : undefined}
|
||||||
>
|
>
|
||||||
{f.label} <span className="text-[10.5px] opacity-80">({f.dir})</span>
|
<div>{f.label}</div>
|
||||||
|
<span className="text-[10px] opacity-80">({f.dir})</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-[11.5px] leading-5 text-muted">{meta.desc}</p>
|
<p className="mt-2.5 text-[11.5px] leading-5 text-muted">{meta.desc}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-xl border border-line bg-panel p-4">
|
<div className="rounded-xl border border-line bg-panel p-3 sm:p-4 w-full shadow-2xs">
|
||||||
{/* انتخاب انبارها */}
|
{/* انتخاب انبارها (چیپهای لمسی به جای سلکت خام) */}
|
||||||
{!isTransfer ? (
|
{!isTransfer ? (
|
||||||
<div className="mb-3 grid gap-3 md:grid-cols-2">
|
<div className="mb-3 flex flex-col gap-3">
|
||||||
<div className="field">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label>انبار *</label>
|
<label className="text-xs font-semibold text-muted">انبار *</label>
|
||||||
<select
|
<div className="flex flex-wrap gap-1.5">
|
||||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
|
{whOpts.map((w) => (
|
||||||
value={whId}
|
<button
|
||||||
onChange={(e) => { setWhId(e.target.value); setMatTxt(""); setRows([]); }}
|
key={w.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setWhId(w.id); setMatTxt(""); setRows([]); }}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg px-3 py-1.5 text-xs font-medium transition-colors",
|
||||||
|
whId === w.id
|
||||||
|
? "bg-accent text-white font-bold shadow-xs"
|
||||||
|
: "border border-line bg-bg/50 text-ink hover:bg-panel"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<option value="">— انتخاب کنید —</option>
|
{w.name}
|
||||||
{whOpts.map((w) => <option key={w.id} value={w.id}>{w.name}</option>)}
|
</button>
|
||||||
</select>
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{(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">
|
<div className="field">
|
||||||
<label>{type === "FOUND" ? "متریال (خالی = همه مفقودیها)" : "متریال *"}</label>
|
<label className="text-xs font-semibold text-muted">{type === "FOUND" ? "متریال (خالی = همه مفقودیها)" : "متریال *"}</label>
|
||||||
<input
|
<input
|
||||||
list="dl-mat"
|
list="dl-mat"
|
||||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
|
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm"
|
||||||
placeholder={type === "RETURN" ? "نام متریال برداشتشده امروز…" : "نام متریال — از پیشنهادها یا جدید…"}
|
placeholder={type === "RETURN" ? "نام متریال برداشتشده امروز…" : "نام متریال — از پیشنهادها یا جدید…"}
|
||||||
value={matTxt}
|
value={matTxt}
|
||||||
onChange={(e) => { setMatTxt(e.target.value); setRows([]); }}
|
onChange={(e) => { setMatTxt(e.target.value); setRows([]); }}
|
||||||
@@ -508,33 +505,51 @@ export function ChangeInventoryPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="mb-3 grid gap-3 md:grid-cols-2">
|
<div className="mb-3 grid gap-3 md:grid-cols-2">
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>از (انبار مبدأ) *</label>
|
<label className="text-xs font-semibold text-muted">از (انبار مبدأ) *</label>
|
||||||
<select
|
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
|
{whOpts.map((w) => (
|
||||||
value={fromId}
|
<button
|
||||||
onChange={(e) => { setFromId(e.target.value); setToId(""); setMatTxt(""); setRows([]); }}
|
key={w.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setFromId(w.id); setToId(""); setMatTxt(""); setRows([]); }}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg px-2.5 py-1 text-xs font-medium transition-colors",
|
||||||
|
fromId === w.id
|
||||||
|
? "bg-accent text-white font-bold shadow-xs"
|
||||||
|
: "border border-line bg-bg/50 text-ink hover:bg-panel"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<option value="">— انتخاب انبار مبدأ —</option>
|
{w.name}
|
||||||
{whOpts.map((w) => <option key={w.id} value={w.id}>{w.name}</option>)}
|
</button>
|
||||||
</select>
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>به (انبار مقصد) *</label>
|
<label className="text-xs font-semibold text-muted">به (انبار مقصد) *</label>
|
||||||
<select
|
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
|
{whOpts.filter((w) => w.id !== fromId).map((w) => (
|
||||||
value={toId}
|
<button
|
||||||
|
key={w.id}
|
||||||
|
type="button"
|
||||||
disabled={!fromId}
|
disabled={!fromId}
|
||||||
onChange={(e) => { setToId(e.target.value); setMatTxt(""); setRows([]); }}
|
onClick={() => { setToId(w.id); setMatTxt(""); setRows([]); }}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg px-2.5 py-1 text-xs font-medium transition-colors disabled:opacity-40",
|
||||||
|
toId === w.id
|
||||||
|
? "bg-accent text-white font-bold shadow-xs"
|
||||||
|
: "border border-line bg-bg/50 text-ink hover:bg-panel"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<option value="">— انتخاب انبار مقصد —</option>
|
{w.name}
|
||||||
{whOpts.filter((w) => w.id !== fromId).map((w) => <option key={w.id} value={w.id}>{w.name}</option>)}
|
</button>
|
||||||
</select>
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="field md:col-span-2">
|
<div className="field md:col-span-2">
|
||||||
<label>متریال *</label>
|
<label className="text-xs font-semibold text-muted">متریال *</label>
|
||||||
<input
|
<input
|
||||||
list="dl-mat2"
|
list="dl-mat2"
|
||||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
|
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm"
|
||||||
placeholder="نام متریال را تایپ کنید — همه مدلها نمایش داده میشوند…"
|
placeholder="نام متریال را تایپ کنید — همه مدلها نمایش داده میشوند…"
|
||||||
value={matTxt}
|
value={matTxt}
|
||||||
onChange={(e) => { setMatTxt(e.target.value); setRows([]); }}
|
onChange={(e) => { setMatTxt(e.target.value); setRows([]); }}
|
||||||
@@ -550,7 +565,7 @@ export function ChangeInventoryPage() {
|
|||||||
{type === "PURCHASE" && (
|
{type === "PURCHASE" && (
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
<div className="field md:col-span-2">
|
<div className="field md:col-span-2">
|
||||||
<label>متریال *</label>
|
<label className="text-xs font-semibold text-muted">متریال *</label>
|
||||||
<Combobox
|
<Combobox
|
||||||
value={matTxt}
|
value={matTxt}
|
||||||
items={data.lookup.materials.map((mm) => mm.name)}
|
items={data.lookup.materials.map((mm) => mm.name)}
|
||||||
@@ -567,7 +582,7 @@ export function ChangeInventoryPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>برند *</label>
|
<label className="text-xs font-semibold text-muted">برند *</label>
|
||||||
<Combobox
|
<Combobox
|
||||||
value={brandTxt}
|
value={brandTxt}
|
||||||
items={data.lookup.brands.map((b) => b.name)}
|
items={data.lookup.brands.map((b) => b.name)}
|
||||||
@@ -579,7 +594,7 @@ export function ChangeInventoryPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>واحد *</label>
|
<label className="text-xs font-semibold text-muted">واحد *</label>
|
||||||
<Combobox
|
<Combobox
|
||||||
value={unitTxt}
|
value={unitTxt}
|
||||||
items={uniqueUnits(data.lookup.units).map((u) => u.name_fa || u.code)}
|
items={uniqueUnits(data.lookup.units).map((u) => u.name_fa || u.code)}
|
||||||
@@ -591,17 +606,17 @@ export function ChangeInventoryPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>تعداد *</label>
|
<label className="text-xs font-semibold text-muted">تعداد *</label>
|
||||||
<input
|
<input
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm ltr"
|
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm ltr"
|
||||||
placeholder="مثلاً ۲۰۰"
|
placeholder="مثلاً ۲۰۰"
|
||||||
value={qty}
|
value={qty}
|
||||||
onChange={(e) => setQty(e.target.value)}
|
onChange={(e) => setQty(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>تاریخ و زمان (شمسی)</label>
|
<label className="text-xs font-semibold text-muted">تاریخ و زمان (شمسی)</label>
|
||||||
<JalaliDatePicker
|
<JalaliDatePicker
|
||||||
value={dateInput}
|
value={dateInput}
|
||||||
onChange={(iso) => setDateInput(iso ?? new Date().toISOString())}
|
onChange={(iso) => setDateInput(iso ?? new Date().toISOString())}
|
||||||
@@ -610,7 +625,7 @@ export function ChangeInventoryPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="field md:col-span-2">
|
<div className="field md:col-span-2">
|
||||||
<label>مشخصههای فنی *</label>
|
<label className="text-xs font-semibold text-muted">مشخصههای فنی *</label>
|
||||||
<MultiSelectCombobox
|
<MultiSelectCombobox
|
||||||
values={specsChips}
|
values={specsChips}
|
||||||
onChange={setSpecsChips}
|
onChange={setSpecsChips}
|
||||||
@@ -619,9 +634,9 @@ export function ChangeInventoryPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="field md:col-span-2">
|
<div className="field md:col-span-2">
|
||||||
<label>یادداشت</label>
|
<label className="text-xs font-semibold text-muted">یادداشت</label>
|
||||||
<textarea
|
<textarea
|
||||||
className="w-full rounded-lg border border-line bg-panel px-3 py-2 text-sm"
|
className="w-full rounded-lg border border-line bg-panel px-3 py-2 text-xs sm:text-sm"
|
||||||
placeholder="شماره فاکتور، تأمینکننده، توضیحات…"
|
placeholder="شماره فاکتور، تأمینکننده، توضیحات…"
|
||||||
value={note}
|
value={note}
|
||||||
onChange={(e) => setNote(e.target.value)}
|
onChange={(e) => setNote(e.target.value)}
|
||||||
@@ -647,12 +662,10 @@ export function ChangeInventoryPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* برگشت — برداشتهای امروز */}
|
|
||||||
{type === "RETURN" && (
|
{type === "RETURN" && (
|
||||||
<ReturnTable rows={rows} setRows={setRows} txs={todayWithdrawals} whId={whId} matTxt={matTxt} unitNameFn={(id) => unitName(data.lookup.units, id)} />
|
<ReturnTable rows={rows} setRows={setRows} txs={todayWithdrawals} whId={whId} matTxt={matTxt} unitNameFn={(id) => unitName(data.lookup.units, id)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* انتقال */}
|
|
||||||
{type === "TRANSFER" && (
|
{type === "TRANSFER" && (
|
||||||
<RowTable
|
<RowTable
|
||||||
isCount={false}
|
isCount={false}
|
||||||
@@ -669,7 +682,6 @@ export function ChangeInventoryPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* پیدا شده — مفقودیها (با سقف: پیدا شده ≤ ماندهی مفقودی) */}
|
|
||||||
{type === "FOUND" && (
|
{type === "FOUND" && (
|
||||||
<FoundTable
|
<FoundTable
|
||||||
rows={rows}
|
rows={rows}
|
||||||
@@ -681,13 +693,13 @@ export function ChangeInventoryPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* دکمه ثبت */}
|
{/* دکمه ثبت */}
|
||||||
<div className="mt-4 flex items-center justify-between border-t border-line pt-3">
|
<div className="mt-4 flex flex-col-reverse sm:flex-row items-center justify-between gap-3 border-t border-line pt-3">
|
||||||
<span className="text-[12px] text-muted">👤 ثبت با نام: <b>{user?.display_name}</b></span>
|
<span className="text-[11.5px] text-muted">👤 ثبت با نام: <b>{user?.display_name}</b></span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!canSubmit() || busy}
|
disabled={!canSubmit() || busy}
|
||||||
onClick={doSubmit}
|
onClick={doSubmit}
|
||||||
className="rounded-lg px-5 py-2 text-[13px] font-bold text-white disabled:opacity-40"
|
className="w-full sm:w-auto rounded-lg px-5 py-2.5 text-xs sm:text-[13px] font-bold text-white disabled:opacity-40 shadow-xs transition-transform active:scale-95"
|
||||||
style={{ background: meta.color }}
|
style={{ background: meta.color }}
|
||||||
>
|
>
|
||||||
✓ {busy ? "در حال ثبت…" : meta.label === "انتقال" ? "ثبت انتقال" : meta.label === "برگشت" ? "ثبت برگشت" : meta.label === "پیدا شده" ? "ثبت پیدا شدن" : `ثبت ${meta.label}`}
|
✓ {busy ? "در حال ثبت…" : meta.label === "انتقال" ? "ثبت انتقال" : meta.label === "برگشت" ? "ثبت برگشت" : meta.label === "پیدا شده" ? "ثبت پیدا شدن" : `ثبت ${meta.label}`}
|
||||||
@@ -695,10 +707,10 @@ export function ChangeInventoryPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* مودال تأیید شمارش موجودی (اختلاف قبل از ثبت) */}
|
{/* مودال تأیید شمارش موجودی */}
|
||||||
{countConfirm && (
|
{countConfirm && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4" onClick={() => !countBusy && setCountConfirm(null)}>
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4" onClick={() => !countBusy && setCountConfirm(null)}>
|
||||||
<div className="w-full max-w-lg rounded-xl border border-line bg-panel p-5 shadow-xl" onClick={(e) => e.stopPropagation()}>
|
<div className="w-full max-w-lg rounded-xl border border-line bg-panel p-4 sm:p-5 shadow-xl" onClick={(e) => e.stopPropagation()}>
|
||||||
<h3 className="mb-1 text-[14px] font-bold">🔍 تأیید شمارش موجودی</h3>
|
<h3 className="mb-1 text-[14px] font-bold">🔍 تأیید شمارش موجودی</h3>
|
||||||
<p className="mb-3 text-[11.5px] leading-5 text-muted">
|
<p className="mb-3 text-[11.5px] leading-5 text-muted">
|
||||||
موجودی هر ردیف به «تعداد شمارششده» اصلاح میشود و اختلاف (کسری/مازاد) ثبت خواهد شد.
|
موجودی هر ردیف به «تعداد شمارششده» اصلاح میشود و اختلاف (کسری/مازاد) ثبت خواهد شد.
|
||||||
@@ -767,7 +779,6 @@ export function ChangeInventoryPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function RowTable({ isCount, rows, setRows, records, unitNameFn, emptyMsg }: {
|
function RowTable({ isCount, rows, setRows, records, unitNameFn, emptyMsg }: {
|
||||||
|
|
||||||
isCount: boolean;
|
isCount: boolean;
|
||||||
rows: RowDraft[];
|
rows: RowDraft[];
|
||||||
setRows: (r: RowDraft[]) => void;
|
setRows: (r: RowDraft[]) => void;
|
||||||
@@ -778,7 +789,7 @@ function RowTable({ isCount, rows, setRows, records, unitNameFn, emptyMsg }: {
|
|||||||
if (emptyMsg) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">{emptyMsg}</div>;
|
if (emptyMsg) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">{emptyMsg}</div>;
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-[12.5px]">
|
<table className="w-full text-[12.5px] min-w-[500px]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-line text-right text-muted">
|
<tr className="border-b border-line text-right text-muted">
|
||||||
<th className="py-2 pe-3">مشخصهها</th>
|
<th className="py-2 pe-3">مشخصهها</th>
|
||||||
@@ -792,22 +803,22 @@ function RowTable({ isCount, rows, setRows, records, unitNameFn, emptyMsg }: {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{records.map((r, i) => (
|
{records.map((r, i) => (
|
||||||
<tr key={r.id} className="border-b border-line/50">
|
<tr key={r.id} className="border-b border-line/50">
|
||||||
<td className="py-1.5 pe-3 text-muted">{Object.entries(r.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}</td>
|
<td className="py-2 pe-3 text-muted">{Object.entries(r.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}</td>
|
||||||
<td className="py-1.5 pe-3">{r.brand?.name || "—"}</td>
|
<td className="py-2 pe-3">{r.brand?.name || "—"}</td>
|
||||||
<td className="py-1.5 pe-3">{unitNameFn(r.unit?.id)}</td>
|
<td className="py-2 pe-3">{unitNameFn(r.unit?.id)}</td>
|
||||||
<td className="py-1.5 pe-3 font-bold">{faNum(r.quantity_number)}</td>
|
<td className="py-2 pe-3 font-bold">{faNum(r.quantity_number)}</td>
|
||||||
<td className="py-1.5 pe-3">
|
<td className="py-2 pe-3">
|
||||||
<input
|
<input
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
className="h-7 w-24 rounded-lg border border-line bg-panel px-2 text-sm ltr"
|
className="h-8 w-24 rounded-lg border border-line bg-panel px-2 text-sm ltr"
|
||||||
placeholder={isCount ? "شمارششده" : "۰"}
|
placeholder={isCount ? "شمارششده" : "۰"}
|
||||||
value={isCount && rows[i]?.qty === "" ? String(r.quantity_number) : (rows[i]?.qty || "")}
|
value={isCount && rows[i]?.qty === "" ? String(r.quantity_number) : (rows[i]?.qty || "")}
|
||||||
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
|
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-1.5">
|
<td className="py-2">
|
||||||
<input
|
<input
|
||||||
className="h-7 w-32 rounded-lg border border-line bg-panel px-2 text-sm"
|
className="h-8 w-32 rounded-lg border border-line bg-panel px-2 text-sm"
|
||||||
placeholder="توضیح (اختیاری)"
|
placeholder="توضیح (اختیاری)"
|
||||||
value={rows[i]?.note || ""}
|
value={rows[i]?.note || ""}
|
||||||
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
|
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
|
||||||
@@ -817,7 +828,7 @@ function RowTable({ isCount, rows, setRows, records, unitNameFn, emptyMsg }: {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div className="mt-1 text-[11.5px] text-muted">ستون «موجودی فعلی» مقدار موجود هر رکورد است. برای هر ردیف تعداد و یادداشت (در صورت نیاز) وارد کنید.</div>
|
<div className="mt-2 text-[11px] text-muted">ستون «موجودی فعلی» مقدار موجود هر رکورد است. برای هر ردیف تعداد و یادداشت وارد کنید.</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -831,10 +842,10 @@ function ReturnTable({ rows, setRows, txs, whId, matTxt, unitNameFn }: {
|
|||||||
unitNameFn: (id: number | null | undefined) => string;
|
unitNameFn: (id: number | null | undefined) => string;
|
||||||
}) {
|
}) {
|
||||||
if (!whId || !matTxt.trim()) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">برای مشاهده برداشتهای امروز، انبار و نام متریال را وارد کنید.</div>;
|
if (!whId || !matTxt.trim()) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">برای مشاهده برداشتهای امروز، انبار و نام متریال را وارد کنید.</div>;
|
||||||
if (!txs.length) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">امروز برداشتی برای این متریال در انبار انتخابی ثبت نشده است — چیزی برای برگشت وجود ندارد.</div>;
|
if (!txs.length) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">امروز برداشتی برای این متریال در انبار انتخابی ثبت نشده است.</div>;
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-[12.5px]">
|
<table className="w-full text-[12.5px] min-w-[500px]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-line text-right text-muted">
|
<tr className="border-b border-line text-right text-muted">
|
||||||
<th className="py-2 pe-3">مشخصهها</th>
|
<th className="py-2 pe-3">مشخصهها</th>
|
||||||
@@ -848,22 +859,22 @@ function ReturnTable({ rows, setRows, txs, whId, matTxt, unitNameFn }: {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{txs.map((t, i) => (
|
{txs.map((t, i) => (
|
||||||
<tr key={t.inventory_id + i} className="border-b border-line/50">
|
<tr key={t.inventory_id + i} className="border-b border-line/50">
|
||||||
<td className="py-1.5 pe-3 text-muted">{Object.entries(t.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}</td>
|
<td className="py-2 pe-3 text-muted">{Object.entries(t.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}</td>
|
||||||
<td className="py-1.5 pe-3">{t.brand?.name || "—"}</td>
|
<td className="py-2 pe-3">{t.brand?.name || "—"}</td>
|
||||||
<td className="py-1.5 pe-3">{unitNameFn(t.unit?.id)}</td>
|
<td className="py-2 pe-3">{unitNameFn(t.unit?.id)}</td>
|
||||||
<td className="py-1.5 pe-3 font-bold">{faNum(Math.abs(t.quantity_number || 0))}</td>
|
<td className="py-2 pe-3 font-bold">{faNum(Math.abs(t.quantity_number || 0))}</td>
|
||||||
<td className="py-1.5 pe-3">
|
<td className="py-2 pe-3">
|
||||||
<input
|
<input
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
className="h-7 w-24 rounded-lg border border-line bg-panel px-2 text-sm ltr"
|
className="h-8 w-24 rounded-lg border border-line bg-panel px-2 text-sm ltr"
|
||||||
placeholder="۰"
|
placeholder="۰"
|
||||||
value={rows[i]?.qty || ""}
|
value={rows[i]?.qty || ""}
|
||||||
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
|
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-1.5">
|
<td className="py-2">
|
||||||
<input
|
<input
|
||||||
className="h-7 w-32 rounded-lg border border-line bg-panel px-2 text-sm"
|
className="h-8 w-32 rounded-lg border border-line bg-panel px-2 text-sm"
|
||||||
placeholder="توضیح برگشت"
|
placeholder="توضیح برگشت"
|
||||||
value={rows[i]?.note || ""}
|
value={rows[i]?.note || ""}
|
||||||
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
|
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
|
||||||
@@ -885,17 +896,17 @@ function FoundTable({ rows, setRows, items, whId, unitNameFn }: {
|
|||||||
unitNameFn: (id: number | null | undefined) => string;
|
unitNameFn: (id: number | null | undefined) => string;
|
||||||
}) {
|
}) {
|
||||||
if (!whId) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">ابتدا انبار را انتخاب کنید.</div>;
|
if (!whId) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">ابتدا انبار را انتخاب کنید.</div>;
|
||||||
if (!items.length) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">مفقودی ثبتنشدهای (یا کاملاً پیداشده) در این انبار وجود ندارد.</div>;
|
if (!items.length) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">مفقودی ثبتنشدهای در این انبار وجود ندارد.</div>;
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-[12.5px]">
|
<table className="w-full text-[12.5px] min-w-[550px]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-line text-right text-muted">
|
<tr className="border-b border-line text-right text-muted">
|
||||||
<th className="py-2 pe-3">متریال</th>
|
<th className="py-2 pe-3">متریال</th>
|
||||||
<th className="py-2 pe-3">مشخصهها</th>
|
<th className="py-2 pe-3">مشخصهها</th>
|
||||||
<th className="py-2 pe-3">برند</th>
|
<th className="py-2 pe-3">برند</th>
|
||||||
<th className="py-2 pe-3">واحد</th>
|
<th className="py-2 pe-3">واحد</th>
|
||||||
<th className="py-2 pe-3">ماندهی مفقودی</th>
|
<th className="py-2 pe-3">مانده مفقودی</th>
|
||||||
<th className="py-2 pe-3">تعداد پیداشده</th>
|
<th className="py-2 pe-3">تعداد پیداشده</th>
|
||||||
<th className="py-2">یادداشت</th>
|
<th className="py-2">یادداشت</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -906,28 +917,26 @@ function FoundTable({ rows, setRows, items, whId, unitNameFn }: {
|
|||||||
const over = entered > remaining;
|
const over = entered > remaining;
|
||||||
return (
|
return (
|
||||||
<tr key={t.inventory_id + i} className={`border-b border-line/50 ${over ? "bg-rose-50/40" : ""}`}>
|
<tr key={t.inventory_id + i} className={`border-b border-line/50 ${over ? "bg-rose-50/40" : ""}`}>
|
||||||
<td className="py-1.5 pe-3 font-semibold">{t.material?.name || "—"}</td>
|
<td className="py-2 pe-3 font-semibold">{t.material?.name || "—"}</td>
|
||||||
<td className="py-1.5 pe-3 text-muted">{Object.entries(t.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}</td>
|
<td className="py-2 pe-3 text-muted">{Object.entries(t.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}</td>
|
||||||
<td className="py-1.5 pe-3">{t.brand?.name || "—"}</td>
|
<td className="py-2 pe-3">{t.brand?.name || "—"}</td>
|
||||||
<td className="py-1.5 pe-3">{unitNameFn(t.unit?.id)}</td>
|
<td className="py-2 pe-3">{unitNameFn(t.unit?.id)}</td>
|
||||||
<td className="py-1.5 pe-3 font-bold text-rose-600">{faNum(remaining)}</td>
|
<td className="py-2 pe-3 font-bold text-rose-600">{faNum(remaining)}</td>
|
||||||
<td className="py-1.5 pe-3">
|
<td className="py-2 pe-3">
|
||||||
<input
|
<input
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
className={`h-7 w-24 rounded-lg border bg-panel px-2 text-sm ltr ${over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line"}`}
|
className={`h-8 w-24 rounded-lg border bg-panel px-2 text-sm ltr ${over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line"}`}
|
||||||
placeholder="۰"
|
placeholder="۰"
|
||||||
value={rows[i]?.qty || ""}
|
value={rows[i]?.qty || ""}
|
||||||
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
|
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
|
||||||
/>
|
/>
|
||||||
{over && (
|
{over && (
|
||||||
<div className="mt-0.5 text-[10.5px] font-bold text-rose-700">
|
<div className="mt-0.5 text-[10px] font-bold text-rose-700">بیشتر از مفقودی!</div>
|
||||||
تعداد پیداشده نمیتواند بیشتر از مقدار مفقودی باشد.
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-1.5">
|
<td className="py-2">
|
||||||
<input
|
<input
|
||||||
className="h-7 w-32 rounded-lg border border-line bg-panel px-2 text-sm"
|
className="h-8 w-32 rounded-lg border border-line bg-panel px-2 text-sm"
|
||||||
placeholder="توضیح"
|
placeholder="توضیح"
|
||||||
value={rows[i]?.note || ""}
|
value={rows[i]?.note || ""}
|
||||||
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
|
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
|
||||||
@@ -942,7 +951,6 @@ function FoundTable({ rows, setRows, items, whId, unitNameFn }: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// تبدیل اعداد فارسی/اعشاری برای اعتبارسنجی نمایشی (بدون ارسال)
|
|
||||||
function parseNumLocal(s: string): number {
|
function parseNumLocal(s: string): number {
|
||||||
const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, ""));
|
const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, ""));
|
||||||
return isFinite(v) ? v : 0;
|
return isFinite(v) ? v : 0;
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Link, useLocation, useSearchParams } from "react-router-dom";
|
import { Link, useLocation, useSearchParams } from "react-router-dom";
|
||||||
import { Plus, Warehouse as WarehouseIcon, SlidersHorizontal, ArrowUpDown, X } from "lucide-react";
|
import { Plus, Warehouse as WarehouseIcon, SlidersHorizontal, ArrowUpDown, X, ChevronDown } from "lucide-react";
|
||||||
import { Header, PageLayout } from "../../components/layout/Header";
|
import { Header, PageLayout } from "../../components/layout/Header";
|
||||||
import { EmptyState } from "../../components/EmptyState";
|
import { EmptyState } from "../../components/EmptyState";
|
||||||
import { Badge } from "../../components/ui/badge";
|
|
||||||
import { useAuthStore } from "../../store/auth";
|
import { useAuthStore } from "../../store/auth";
|
||||||
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
|
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
|
||||||
import { inventorySubheader } from "./panelMenu";
|
import { inventorySubheader } from "./panelMenu";
|
||||||
@@ -14,7 +13,7 @@ import { faNum } from "../../lib/formatters";
|
|||||||
import { isoToJalaliStr } from "../../lib/jalali";
|
import { isoToJalaliStr } from "../../lib/jalali";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
// رنگبندی معنایی: سبز زمردی = ورود/افزایش، رز = خروج/کاهش، آبی = خنثی/جابهجایی
|
// رنگبندی معنایی تراکنشها
|
||||||
const TX_META: Record<TransactionType, { label: string; color: string; icon: string }> = {
|
const TX_META: Record<TransactionType, { label: string; color: string; icon: string }> = {
|
||||||
PURCHASE: { label: "خرید", color: "#10b981", icon: "🛒" },
|
PURCHASE: { label: "خرید", color: "#10b981", icon: "🛒" },
|
||||||
WITHDRAWAL: { label: "برداشت", color: "#e11d48", icon: "➖" },
|
WITHDRAWAL: { label: "برداشت", color: "#e11d48", icon: "➖" },
|
||||||
@@ -148,24 +147,23 @@ export function InventoryPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen pb-24 md:pb-6">
|
<div className="min-h-screen pb-24 md:pb-6 overflow-x-hidden">
|
||||||
<Header
|
<Header
|
||||||
title="سامانه مدیریت انبار"
|
title="سامانه مدیریت انبار"
|
||||||
sub={inventorySubheader(user)}
|
sub={inventorySubheader(user)}
|
||||||
icon={<WarehouseIcon className="h-4.5 w-4.5" strokeWidth={2} />}
|
icon={<WarehouseIcon className="h-4.5 w-4.5" strokeWidth={2} />}
|
||||||
menu={<AppSwitcherMenu activeModule="warehouse" canSwitchToProcurement={isAdmin} />}
|
menu={<AppSwitcherMenu activeModule="warehouse" canSwitchToProcurement={isAdmin} />}
|
||||||
actions={
|
actions={
|
||||||
/* تبهای ناوبری فقط در دسکتاپ — دکمه بالایی به طور کامل حذف شد */
|
|
||||||
<div className="hidden md:flex items-center gap-2">
|
<div className="hidden md:flex items-center gap-2">
|
||||||
<InventoryTopNav pathname={location.pathname} variant={isAdmin ? "admin" : "pm"} />
|
<InventoryTopNav pathname={location.pathname} variant={isAdmin ? "admin" : "pm"} />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<PageLayout className="flex flex-col gap-3 px-3 md:px-6">
|
<PageLayout className="flex flex-col gap-3 px-3 md:px-6 w-full max-w-full">
|
||||||
{/* نوار فیلتر و جستجوی واکنشگرا */}
|
{/* جعبه فیلترها و جستجو */}
|
||||||
<div className="rounded-xl border border-line bg-panel p-2.5 sm:p-3">
|
<div className="rounded-xl border border-line bg-panel p-2.5 sm:p-3 w-full shadow-2xs">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 w-full">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1 min-w-0">
|
||||||
<input
|
<input
|
||||||
className="h-9 w-full rounded-lg border border-line bg-bg/50 px-3 text-xs focus:border-accent focus:bg-panel focus:outline-none"
|
className="h-9 w-full rounded-lg border border-line bg-bg/50 px-3 text-xs focus:border-accent focus:bg-panel focus:outline-none"
|
||||||
placeholder="جستجوی نام متریال…"
|
placeholder="جستجوی نام متریال…"
|
||||||
@@ -187,8 +185,10 @@ export function InventoryPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowMobileFilters((s) => !s)}
|
onClick={() => setShowMobileFilters((s) => !s)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex h-9 items-center gap-1.5 rounded-lg border px-2.5 text-xs font-medium transition-colors md:hidden",
|
"inline-flex shrink-0 h-9 items-center gap-1.5 rounded-lg border px-2.5 text-xs font-medium transition-colors",
|
||||||
hasFilter ? "border-accent bg-accent/10 text-accent font-bold" : "border-line bg-panel text-ink hover:bg-bg"
|
showMobileFilters || hasFilter
|
||||||
|
? "border-accent bg-accent/10 text-accent font-bold"
|
||||||
|
: "border-line bg-panel text-ink hover:bg-bg"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<SlidersHorizontal className="h-3.5 w-3.5" />
|
<SlidersHorizontal className="h-3.5 w-3.5" />
|
||||||
@@ -199,7 +199,7 @@ export function InventoryPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex h-9 items-center gap-1 rounded-lg border px-2.5 text-xs transition-colors",
|
"inline-flex shrink-0 h-9 items-center gap-1 rounded-lg border px-2.5 text-xs transition-colors",
|
||||||
sort ? "border-accent bg-accent/5 text-accent font-semibold" : "border-line bg-panel text-ink hover:bg-bg"
|
sort ? "border-accent bg-accent/5 text-accent font-semibold" : "border-line bg-panel text-ink hover:bg-bg"
|
||||||
)}
|
)}
|
||||||
onClick={() => setSort((s) => (s === "" ? "asc" : s === "asc" ? "desc" : ""))}
|
onClick={() => setSort((s) => (s === "" ? "asc" : s === "asc" ? "desc" : ""))}
|
||||||
@@ -210,83 +210,186 @@ export function InventoryPage() {
|
|||||||
{sort === "asc" ? "تعداد: صعودی" : sort === "desc" ? "تعداد: نزولی" : "مرتبسازی"}
|
{sort === "asc" ? "تعداد: صعودی" : sort === "desc" ? "تعداد: نزولی" : "مرتبسازی"}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Badge variant="outline" className="hidden sm:inline-flex h-9 items-center px-2.5 text-xs">
|
|
||||||
{faNum(groups.length)} قلم
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* گزینههای فیلتر پیشرفته */}
|
{/* فیلتر سریع انبارها (چیپهای افقی لمسی) */}
|
||||||
<div
|
{visibleWarehouses.length > 1 && (
|
||||||
className={cn(
|
<div className="mt-2.5 flex items-center gap-1.5 overflow-x-auto pb-1 pt-0.5 [scrollbar-width:none]">
|
||||||
"mt-3 grid grid-cols-1 gap-2.5 border-t border-line/60 pt-3 sm:grid-cols-2 md:flex md:flex-wrap md:items-end",
|
|
||||||
!showMobileFilters && "hidden md:flex"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="field flex-1 min-w-[140px]">
|
|
||||||
<label className="text-[11px] font-semibold text-muted">دستهبندی</label>
|
|
||||||
<select
|
|
||||||
className="h-8 w-full rounded-lg border border-line bg-panel px-2 text-xs"
|
|
||||||
value={filters.category}
|
|
||||||
onChange={(e) => setFilters((f) => ({ ...f, category: e.target.value, material: "" }))}
|
|
||||||
>
|
|
||||||
<option value="">همه دستهبندیها</option>
|
|
||||||
{lookup.categories.map((c) => (
|
|
||||||
<option key={c.id} value={c.id}>{c.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isAdmin && (
|
|
||||||
<div className="field flex-1 min-w-[140px]">
|
|
||||||
<label className="text-[11px] font-semibold text-muted">پروژه</label>
|
|
||||||
<select
|
|
||||||
className="h-8 w-full rounded-lg border border-line bg-panel px-2 text-xs"
|
|
||||||
value={filters.project}
|
|
||||||
onChange={(e) => setFilters((f) => ({ ...f, project: e.target.value, warehouse: "", material: "" }))}
|
|
||||||
>
|
|
||||||
<option value="">همه پروژهها</option>
|
|
||||||
{lookup.projects.map((p) => (
|
|
||||||
<option key={p.id} value={p.id}>{p.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="field flex-1 min-w-[140px]">
|
|
||||||
<label className="text-[11px] font-semibold text-muted">انبار</label>
|
|
||||||
<select
|
|
||||||
className="h-8 w-full rounded-lg border border-line bg-panel px-2 text-xs"
|
|
||||||
value={filters.warehouse}
|
|
||||||
onChange={(e) => setFilters((f) => ({ ...f, warehouse: e.target.value, material: "" }))}
|
|
||||||
>
|
|
||||||
<option value="">همه انبارها</option>
|
|
||||||
{visibleWarehouses.map((w) => (
|
|
||||||
<option key={w.id} value={w.id}>
|
|
||||||
{w.name}{isAdmin ? ` — ${lookup.projects.find((p) => p.id === w.project_id)?.name || ""}` : ""}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasFilter && (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="h-8 rounded-lg border border-red/30 bg-red/5 px-3 text-xs text-red hover:bg-red/10 transition-colors"
|
onClick={() => setFilters((f) => ({ ...f, warehouse: "" }))}
|
||||||
onClick={() => setFilters({ category: "", warehouse: "", material: "", project: "" })}
|
className={cn(
|
||||||
|
"shrink-0 rounded-lg px-2.5 py-1 text-xs font-medium transition-colors",
|
||||||
|
!filters.warehouse
|
||||||
|
? "bg-accent text-white shadow-xs font-bold"
|
||||||
|
: "border border-line bg-bg/40 text-muted hover:bg-panel hover:text-ink"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
✕ پاک کردن فیلترها
|
همه انبارها
|
||||||
</button>
|
</button>
|
||||||
|
{visibleWarehouses.map((w) => (
|
||||||
|
<button
|
||||||
|
key={w.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFilters((f) => ({ ...f, warehouse: f.warehouse === w.id ? "" : w.id }))}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 rounded-lg px-2.5 py-1 text-xs font-medium transition-colors",
|
||||||
|
filters.warehouse === w.id
|
||||||
|
? "bg-accent text-white shadow-xs font-bold"
|
||||||
|
: "border border-line bg-bg/40 text-muted hover:bg-panel hover:text-ink"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{w.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="text-[11px] text-muted ms-auto sm:hidden">
|
{/* فیلترهای پیشرفته — برچسبهای لمسی بدون سلکت و بدون بیرونزدگی */}
|
||||||
نمایش {faNum(groups.length)} قلم کالا
|
{showMobileFilters && (
|
||||||
</div>
|
<div className="mt-2.5 border-t border-line/60 pt-2.5 flex flex-col gap-2.5">
|
||||||
|
{/* دستهبندیها */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-1.5 text-[11px] font-bold text-muted">دستهبندی متریال:</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFilters((f) => ({ ...f, category: "" }))}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg px-2.5 py-1 text-[11.5px] transition-colors",
|
||||||
|
!filters.category
|
||||||
|
? "bg-accent text-white font-bold"
|
||||||
|
: "border border-line bg-bg/40 text-ink hover:bg-panel"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
همه دستهبندیها
|
||||||
|
</button>
|
||||||
|
{lookup.categories.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFilters((f) => ({ ...f, category: f.category === c.id ? "" : c.id }))}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg px-2.5 py-1 text-[11.5px] transition-colors",
|
||||||
|
filters.category === c.id
|
||||||
|
? "bg-accent text-white font-bold"
|
||||||
|
: "border border-line bg-bg/40 text-ink hover:bg-panel"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{c.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* جدول موجودی انبار با اسکرول افقی محافظتشده */}
|
{/* پروژهها (مخصوص ادمین) */}
|
||||||
<div className="overflow-x-auto rounded-xl border border-line bg-panel shadow-2xs [scrollbar-width:thin]">
|
{isAdmin && lookup.projects.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="mb-1.5 text-[11px] font-bold text-muted">پروژه:</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFilters((f) => ({ ...f, project: "" }))}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg px-2.5 py-1 text-[11.5px] transition-colors",
|
||||||
|
!filters.project
|
||||||
|
? "bg-accent text-white font-bold"
|
||||||
|
: "border border-line bg-bg/40 text-ink hover:bg-panel"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
همه پروژهها
|
||||||
|
</button>
|
||||||
|
{lookup.projects.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFilters((f) => ({ ...f, project: f.project === p.id ? "" : p.id }))}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg px-2.5 py-1 text-[11.5px] transition-colors",
|
||||||
|
filters.project === p.id
|
||||||
|
? "bg-accent text-white font-bold"
|
||||||
|
: "border border-line bg-bg/40 text-ink hover:bg-panel"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{p.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* دکمه پاک کردن فیلترها */}
|
||||||
|
{hasFilter && (
|
||||||
|
<div className="pt-1 flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-xs text-red font-medium hover:underline inline-flex items-center gap-1"
|
||||||
|
onClick={() => setFilters({ category: "", warehouse: "", material: "", project: "" })}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
پاک کردن فیلترها
|
||||||
|
</button>
|
||||||
|
<span className="text-[11px] text-muted">
|
||||||
|
نمایش {faNum(groups.length)} قلم کالا
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ۱. نمای مخصوص موبایل: کارتهای عمودی کاملاً ریسپانسیو */}
|
||||||
|
<div className="flex flex-col gap-2.5 md:hidden w-full">
|
||||||
|
{!groups.length ? (
|
||||||
|
<div className="rounded-xl border border-line bg-panel p-4">
|
||||||
|
<EmptyState emoji="📋" text="هیچ رکورد موجودیای مطابق فیلترهای انتخابی یافت نشد." />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
groups.map((g) => {
|
||||||
|
const isExp = expandedKey === g.key;
|
||||||
|
const matCat = lookup.materials.find((m) => m.id === g.matId)?.category;
|
||||||
|
return (
|
||||||
|
<InventoryMobileCard
|
||||||
|
key={g.key}
|
||||||
|
g={g}
|
||||||
|
isExp={isExp}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
matName={materialName(lookup.materials, g.matId)}
|
||||||
|
matCatName={matCat ? catName(lookup.categories, matCat.id) : "—"}
|
||||||
|
wh={whName(warehouses, g.whId)}
|
||||||
|
projName={lookup.projects.find((p) => p.id === warehouses.find((w) => w.id === g.whId)?.project_id)?.name || ""}
|
||||||
|
unit={unitName(lookup.units, g.unitId)}
|
||||||
|
onToggle={() => {
|
||||||
|
setExpandedKey(isExp ? null : g.key);
|
||||||
|
setSelTx(null);
|
||||||
|
setExpBrand(null);
|
||||||
|
}}
|
||||||
|
timeline={
|
||||||
|
isExp ? (
|
||||||
|
<Timeline
|
||||||
|
g={g}
|
||||||
|
txs={groupTxs(g)}
|
||||||
|
allTxs={transactions}
|
||||||
|
brandTxs={brandTxs}
|
||||||
|
selTx={selTx}
|
||||||
|
setSelTx={setSelTx}
|
||||||
|
expBrand={expBrand}
|
||||||
|
setExpBrand={setExpBrand}
|
||||||
|
matName={materialName(lookup.materials, g.matId)}
|
||||||
|
unit={unitName(lookup.units, g.unitId)}
|
||||||
|
brandNameFn={(id) => brandName(lookup.brands, id)}
|
||||||
|
whNameFn={(id) => whName(warehouses, id)}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ۲. نمای دسکتاپ: جدول سنتی */}
|
||||||
|
<div className="hidden md:block overflow-x-auto rounded-xl border border-line bg-panel shadow-2xs [scrollbar-width:thin]">
|
||||||
{!groups.length ? (
|
{!groups.length ? (
|
||||||
<EmptyState emoji="📋" text="هیچ رکورد موجودیای مطابق فیلترهای انتخابی یافت نشد." />
|
<EmptyState emoji="📋" text="هیچ رکورد موجودیای مطابق فیلترهای انتخابی یافت نشد." />
|
||||||
) : (
|
) : (
|
||||||
@@ -349,14 +452,9 @@ export function InventoryPage() {
|
|||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* متن راهنما فقط در دسکتاپ نمایش داده میشود */}
|
|
||||||
<div className="hidden md:block text-[11px] text-muted">
|
|
||||||
۱۰ نوع تراکنش استاندارد • سبز زمردی = ورود به انبار · رز = خروج/کاهش · آبی = انتقال و شمارش • کلیک روی هر سطر: خط زمانی با «مانده» لحظهبهلحظه و تفکیک برندها
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
|
|
||||||
{/* دکمه شناور تغییر موجودی (FAB دایرهای آبی با علامت جمع) */}
|
{/* دکمه شناور تغییر موجودی */}
|
||||||
{isPM && (
|
{isPM && (
|
||||||
<Link
|
<Link
|
||||||
to="/inventory/change"
|
to="/inventory/change"
|
||||||
@@ -371,6 +469,111 @@ export function InventoryPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// کامپوننت کارت انبار در موبایل
|
||||||
|
function InventoryMobileCard({
|
||||||
|
g,
|
||||||
|
isExp,
|
||||||
|
isAdmin,
|
||||||
|
matName,
|
||||||
|
matCatName,
|
||||||
|
wh,
|
||||||
|
projName,
|
||||||
|
unit,
|
||||||
|
onToggle,
|
||||||
|
timeline,
|
||||||
|
}: {
|
||||||
|
g: Group;
|
||||||
|
isExp: boolean;
|
||||||
|
isAdmin: boolean;
|
||||||
|
matName: string;
|
||||||
|
matCatName: string;
|
||||||
|
wh: string;
|
||||||
|
projName: string;
|
||||||
|
unit: string;
|
||||||
|
onToggle: () => void;
|
||||||
|
timeline: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const specsText = Object.entries(g.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden rounded-xl border bg-panel transition-all shadow-2xs w-full",
|
||||||
|
isExp ? "border-accent/60 ring-1 ring-accent/20" : "border-line hover:border-line/80"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div onClick={onToggle} className="cursor-pointer p-3 active:bg-bg/40 transition-colors">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h3 className="text-[13.5px] font-bold leading-snug text-ink break-words">
|
||||||
|
{matName}
|
||||||
|
</h3>
|
||||||
|
{matCatName !== "—" && (
|
||||||
|
<span className="mt-0.5 inline-block text-[11px] text-muted">
|
||||||
|
{matCatName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-bg text-muted transition-transform duration-200",
|
||||||
|
isExp && "rotate-180 bg-accent/10 text-accent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* کادر شاخص موجودی و انبار */}
|
||||||
|
<div className="my-2.5 flex items-center justify-between gap-3 rounded-lg border border-line/60 bg-bg/50 p-2.5">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-[10px] font-semibold text-muted">موجودی انبار</span>
|
||||||
|
<div className="flex items-baseline gap-1 pt-0.5">
|
||||||
|
<span className="text-lg font-black tabular-nums text-ink">
|
||||||
|
{faNum(g.qty)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] font-medium text-muted">{unit}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-end text-end min-w-0">
|
||||||
|
<span className="text-[10px] font-semibold text-muted">محل نگهداری</span>
|
||||||
|
<div className="flex items-center gap-1 text-xs font-bold text-accent truncate pt-0.5">
|
||||||
|
<WarehouseIcon className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span className="truncate">{wh}</span>
|
||||||
|
</div>
|
||||||
|
{isAdmin && projName && (
|
||||||
|
<span className="truncate text-[10px] text-muted max-w-[130px]">{projName}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* مشخصات فنی و تاریخ */}
|
||||||
|
<div className="flex items-center justify-between gap-2 text-[10.5px] text-muted pt-0.5">
|
||||||
|
<div className="min-w-0 flex-1 truncate">
|
||||||
|
{specsText ? (
|
||||||
|
<span className="rounded bg-bg px-1.5 py-0.5 text-[10px] font-medium text-ink">
|
||||||
|
{specsText}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted/60">—</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 tabular-nums">
|
||||||
|
{isoToJalaliStr(g.updatedAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isExp && (
|
||||||
|
<div className="border-t border-line/70 bg-accent-bg/10 p-3">
|
||||||
|
{timeline}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function GroupRow({ g, isExp, isAdmin, matName, matCatName, wh, projName, unit, onToggle, timeline }: {
|
function GroupRow({ g, isExp, isAdmin, matName, matCatName, wh, projName, unit, onToggle, timeline }: {
|
||||||
g: Group;
|
g: Group;
|
||||||
isExp: boolean;
|
isExp: boolean;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { nextId } from "../../lib/api";
|
|||||||
import { useAddSupplier, useMaterials, useQuotes, useRemoveSupplier, useStoreData, useSuppliers } from "../../hooks";
|
import { useAddSupplier, useMaterials, useQuotes, useRemoveSupplier, useStoreData, useSuppliers } from "../../hooks";
|
||||||
import { useAppStore } from "../../store";
|
import { useAppStore } from "../../store";
|
||||||
import { topicByName } from "../../lib/topics";
|
import { topicByName } from "../../lib/topics";
|
||||||
|
import { daysBetween } from "../../lib/formatters";
|
||||||
import { Header, PageLayout } from "../../components/layout/Header";
|
import { Header, PageLayout } from "../../components/layout/Header";
|
||||||
import { EmptyState } from "../../components/EmptyState";
|
import { EmptyState } from "../../components/EmptyState";
|
||||||
import { Combobox } from "../../components/Combobox";
|
import { Combobox } from "../../components/Combobox";
|
||||||
@@ -15,7 +16,6 @@ import { materialLabel } from "../../lib/quoteUtils";
|
|||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
|
|
||||||
// صفحهی تأمینکنندههای یک دسته + فرم افزودن تأمینکننده
|
// صفحهی تأمینکنندههای یک دسته + فرم افزودن تأمینکننده
|
||||||
// (سلسلهمراتب دامنه: دسته → تأمینکننده؛ در v2.6 دسته = تاپیک)
|
|
||||||
export function CategoryPage() {
|
export function CategoryPage() {
|
||||||
useStoreData();
|
useStoreData();
|
||||||
const { category: catParam = "" } = useParams();
|
const { category: catParam = "" } = useParams();
|
||||||
@@ -36,12 +36,29 @@ export function CategoryPage() {
|
|||||||
contact: "",
|
contact: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
// همهی تأمینکنندهها برای انتخاب سریع (task: کومبوکس کششده)
|
// همهی تأمینکنندهها برای انتخاب سریع
|
||||||
const supplierOptions = useMemo(
|
const supplierOptions = useMemo(
|
||||||
() => suppliers.map((s) => ({ id: s.id, name: s.name })),
|
() => suppliers.map((s) => ({ id: s.id, name: s.name })),
|
||||||
[suppliers],
|
[suppliers],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// محاسبه میانگین زمان تحویل اختصاصی هر تأمینکننده بر اساس سفارشهای تحویلشده
|
||||||
|
const supLeadTimes = useMemo(() => {
|
||||||
|
const map = new Map<string, number | null>();
|
||||||
|
suppliers.forEach((s) => {
|
||||||
|
const supQuotes = quotes.filter(
|
||||||
|
(q) => q.supplier_id === s.id && q.status === "arrived" && q.accepted_date && q.arrived_date
|
||||||
|
);
|
||||||
|
const times = supQuotes
|
||||||
|
.map((q) => daysBetween(q.arrived_date, q.accepted_date))
|
||||||
|
.filter((x): x is number => x != null);
|
||||||
|
|
||||||
|
const avg = times.length ? Math.round(times.reduce((a, b) => a + b, 0) / times.length) : null;
|
||||||
|
map.set(s.id, avg);
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [quotes, suppliers]);
|
||||||
|
|
||||||
if (!cat) return <Navigate to="/suppliers" replace />;
|
if (!cat) return <Navigate to="/suppliers" replace />;
|
||||||
|
|
||||||
const sups = suppliersOfCategory(cat, cat, suppliers);
|
const sups = suppliersOfCategory(cat, cat, suppliers);
|
||||||
@@ -69,7 +86,6 @@ export function CategoryPage() {
|
|||||||
toast.error("نام تأمینکننده را وارد کنید.");
|
toast.error("نام تأمینکننده را وارد کنید.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// تأمینکننده دقیقاً زیر همین دسته ثبت میشود (۱ دسته به ازای هر تأمینکننده)
|
|
||||||
const s: Supplier = {
|
const s: Supplier = {
|
||||||
id: nextId("s", suppliers),
|
id: nextId("s", suppliers),
|
||||||
topic: cat,
|
topic: cat,
|
||||||
@@ -101,7 +117,15 @@ export function CategoryPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||||
{sups.map((s) => (
|
{sups.map((s) => (
|
||||||
<SupplierCard key={s.id} s={s} topic={cat} deletable onDelete={removeSup} materials={supMats.get(s.id) || []} />
|
<SupplierCard
|
||||||
|
key={s.id}
|
||||||
|
s={s}
|
||||||
|
topic={cat}
|
||||||
|
deletable
|
||||||
|
onDelete={removeSup}
|
||||||
|
materials={supMats.get(s.id) || []}
|
||||||
|
avgLeadTime={supLeadTimes.get(s.id)}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { MapPin, Phone, User, Plus } from "lucide-react";
|
import { MapPin, Phone, User, Plus, Timer } from "lucide-react";
|
||||||
import type { Material, Supplier } from "../../lib/types";
|
import type { Material, Supplier } from "../../lib/types";
|
||||||
import { normalize } from "../../lib/normalize";
|
import { normalize } from "../../lib/normalize";
|
||||||
|
import { fmtNum } from "../../lib/formatters";
|
||||||
|
|
||||||
export function topicCategories(topic: string, materials: Material[], suppliers: Supplier[]): string[] {
|
export function topicCategories(topic: string, materials: Material[], suppliers: Supplier[]): string[] {
|
||||||
const out = new Set<string>();
|
const out = new Set<string>();
|
||||||
materials
|
materials
|
||||||
@@ -19,33 +21,45 @@ export function suppliersOfCategory(topic: string, cat: string, suppliers: Suppl
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// کارت تأمینکننده
|
// کارت تأمینکننده همراه با برچسب میانگین زمان تحویل
|
||||||
export function SupplierCard({
|
export function SupplierCard({
|
||||||
s,
|
s,
|
||||||
topic,
|
topic,
|
||||||
deletable,
|
deletable,
|
||||||
onDelete,
|
onDelete,
|
||||||
materials = [],
|
materials = [],
|
||||||
|
avgLeadTime,
|
||||||
}: {
|
}: {
|
||||||
s: Supplier;
|
s: Supplier;
|
||||||
topic: string;
|
topic: string;
|
||||||
deletable?: boolean;
|
deletable?: boolean;
|
||||||
onDelete?: (id: string) => void;
|
onDelete?: (id: string) => void;
|
||||||
// متریالهای خریداریشده از این تأمینکننده (بهجای دستهبندی)
|
|
||||||
materials?: string[];
|
materials?: string[];
|
||||||
|
avgLeadTime?: number | null;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="sup-card">
|
<div className="sup-card">
|
||||||
<div className="sc-head">
|
<div className="sc-head flex items-start justify-between gap-2">
|
||||||
<Link to={`/suppliers/${s.id}`} className="sc-name-link">
|
<Link to={`/suppliers/${s.id}`} className="sc-name-link min-w-0 flex-1 leading-snug">
|
||||||
{s.name}
|
{s.name}
|
||||||
</Link>
|
</Link>
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
|
{avgLeadTime != null && (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 whitespace-nowrap rounded-md bg-emerald-100 border border-emerald-300/80 px-2 py-0.5 text-xs font-bold text-emerald-900 shadow-2xs dark:bg-emerald-950 dark:border-emerald-800 dark:text-emerald-200 tabular-nums"
|
||||||
|
title="میانگین زمان تحویل سفارشها"
|
||||||
|
>
|
||||||
|
<Timer className="h-3.5 w-3.5 text-emerald-700 dark:text-emerald-400" />
|
||||||
|
<span>{fmtNum(avgLeadTime)} روز</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{deletable && onDelete && (
|
{deletable && onDelete && (
|
||||||
<button type="button" className="sup-del" title="حذف تأمینکننده" onClick={() => onDelete(s.id)}>
|
<button type="button" className="sup-del" title="حذف تأمینکننده" onClick={() => onDelete(s.id)}>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="sc-meta">
|
<div className="sc-meta">
|
||||||
<span>
|
<span>
|
||||||
<MapPin className="me-1 inline h-3 w-3" /> {s.location || "—"}
|
<MapPin className="me-1 inline h-3 w-3" /> {s.location || "—"}
|
||||||
|
|||||||
Reference in New Issue
Block a user