8 Commits
14 changed files with 1657 additions and 940 deletions
+14 -51
View File
@@ -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:
+193 -16
View File
@@ -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>
); );
} }
+15 -4
View File
@@ -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>
)} )}
+48 -12
View File
@@ -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>
); );
} }
+11 -1
View File
@@ -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,
+90 -73
View File
@@ -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>
); );
+23 -9
View File
@@ -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}
+345 -161
View File
@@ -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}
> >
+43 -11
View File
@@ -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">
+52 -28
View File
@@ -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;
+288 -85
View File
@@ -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;
+28 -4
View File
@@ -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>
)} )}
+19 -5
View File
@@ -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 || "—"}