8 Commits
14 changed files with 1657 additions and 940 deletions
+15 -52
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,13 +199,11 @@ 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:
postgres_data: postgres_data:
minio_data: minio_data:
traefik_certs: traefik_certs:
+196 -19
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>
{urlOf?.(inv) && (
<a className="inv-open" href={urlOf(inv)!} download={inv.name} title="دانلود">
↓
</a>
)}
<button type="button" className="inv-x" title="حذف" onClick={() => remove(i)}>
×
</button>
</div> </div>
))} )}
{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>
</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> </button>
</div> </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>
</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> </div>
); );
} }
+22 -11
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">
<JalaliDatePicker {/* کادر انتخاب تاریخ */}
value={milestone.when_iso} <div className="w-36 shrink-0">
onChange={(iso) => onChange({ ...milestone, when_iso: iso })} <JalaliDatePicker
placeholder="تاریخ" value={milestone.when_iso}
className="w-36 shrink-0" onChange={(iso) => onChange({ ...milestone, when_iso: iso })}
disabled={disabled} placeholder="تاریخ"
/> 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,11 +33,18 @@ 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>
)} )}
</div> </div>
); );
} }
+70 -34
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,48 +54,69 @@ 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) => {
<div const isClickable = Boolean(onFilterChange);
key={s.label} const isActive = activeFilter === s.key;
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"
>
{/* عنوان کارت */}
<div className="flex items-center justify-center sm:justify-between">
<span className="truncate text-[10px] font-medium text-muted sm:text-[11px]">
<span className="sm:hidden">{s.shortLabel}</span>
<span className="hidden sm:inline">{s.label}</span>
</span>
{/* آیکون در دسکتاپ */} return (
<span className="hidden h-6 w-6 shrink-0 items-center justify-center rounded-md bg-slate-bg text-muted sm:flex">
<s.icon className="h-3.5 w-3.5" strokeWidth={2} />
</span>
</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}`} key={s.label}
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"
}`}
> >
<span>{s.value}</span> {/* عنوان کارت */}
{s.unit && ( <div className="flex items-center justify-center sm:justify-between">
<span className="text-[10px] font-medium text-muted sm:text-xs"> <span className="truncate text-[10px] font-medium text-muted sm:text-[11px]">
{s.unit} <span className="sm:hidden">{s.shortLabel}</span>
<span className="hidden sm:inline">{s.label}</span>
</span> </span>
)}
{/* آیکون در دسکتاپ */}
<span className="hidden h-6 w-6 shrink-0 items-center justify-center rounded-md bg-slate-bg text-muted sm:flex">
<s.icon className="h-3.5 w-3.5" strokeWidth={2} />
</span>
</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}`}
>
<span>{s.value}</span>
{s.unit && (
<span className="text-[10px] font-medium text-muted sm:text-xs">
{s.unit}
</span>
)}
</div>
</div> </div>
</div> );
))} })}
</section> </section>
); );
} }
+12 -2
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,
@@ -347,4 +357,4 @@ export function termSheetBlocks(
}); });
return { ts, isAccepted, isUsed, itemsDesc, milestones }; return { ts, isAccepted, isUsed, itemsDesc, milestones };
}); });
} }
+210 -193
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,198 +233,230 @@ 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="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 className="text-[12px] text-muted"> {/* بدنه اسکرول‌پذیر */}
شرایط پرداخت را انتخاب یا ویرایش کنید. سپس هر قلم را به conditions دلخواه تخصیص دهید. <div className="flex-1 overflow-y-auto p-4 md:p-6 space-y-5">
</div>
{/* بخش ۱: تنظیم شرایط پرداخت */}
{/* کارت‌های termsheet */} <section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5">
<div className="flex flex-col gap-2"> <h3 className="mb-4 text-[14px] font-bold text-slate-800">۱. تنظیم شرایط پرداخت</h3>
{cards.map((c, idx) => ( <div className="flex flex-col gap-4">
<div key={c.domId} className="modal-variant"> {cards.map((c, idx) => (
<div className="modal-variant-head"> <div key={c.domId} className="modal-variant bg-slate-50/50 rounded-lg border border-line p-3">
<span className="variant-num">{cardLabel(c, idx)}</span> <div className="modal-variant-head mb-2 flex items-center justify-between">
<button type="button" className="variant-x" title="حذف" onClick={() => removeCard(c.domId)}> <span className="variant-num font-semibold text-primary">{cardLabel(c, idx)}</span>
× <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> ×
</div> </button>
<div className="flex flex-col gap-1.5"> </div>
{c.milestones.map((m, i) => ( <div className="flex flex-col gap-2">
<MilestoneRow {c.milestones.map((m, i) => (
key={i} <MilestoneRow
milestone={m} key={i}
onChange={(nextM) => { milestone={m}
const ms = [...c.milestones]; onChange={(nextM) => {
ms[i] = nextM; const ms = [...c.milestones];
updateCard(c.domId, { milestones: ms }); ms[i] = nextM;
}} updateCard(c.domId, { milestones: ms });
onRemove={() => updateCard(c.domId, { milestones: c.milestones.filter((_, j) => j !== i) })} }}
/> onRemove={() => updateCard(c.domId, { milestones: c.milestones.filter((_, j) => j !== i) })}
/>
))}
</div>
<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>
</div>
))} ))}
</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="line" size="sm" className="mt-4 border-dashed border-primary/50 text-primary hover:bg-primary/5" onClick={addNewCard}>
+ مرحله پرداخت + افزودن شرایط پرداخت جدید
</Button> </Button>
</div> </section>
))}
</div>
<Button variant="line" size="sm" className="self-start" onClick={addNewCard}>
+ نوشتن conditions جدید
</Button>
{/* تخصیص قلم‌ها */} {/* بخش ۲: تخصیص اقلام */}
<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
<input key={li.id}
type="checkbox" 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"}`}
className="accent-accent" >
checked={!removed} <input
title="تیک = در معامله می‌ماند" type="checkbox"
onChange={(e) => { className="accent-primary w-4 h-4 rounded cursor-pointer shrink-0"
const next = new Set(removedItems); checked={!removed}
if (e.target.checked) next.delete(li.id); title="تیک = در معامله می‌ماند"
else next.add(li.id); onChange={(e) => {
setRemovedItems(next); const next = new Set(removedItems);
}} if (e.target.checked) next.delete(li.id);
/> else next.add(li.id);
<span className="flex-1 text-[13px]"> setRemovedItems(next);
{formatMaterialTitle(m)} }}
</span> />
<span className="text-[12px] text-muted"> <div className="flex-1 min-w-[120px]">
{fmtNum(li.quantity)} {li.unit} × {groupCommas(li.unit_price, true)} <div className="text-[13.5px] font-medium text-slate-800">
</span> {formatMaterialTitle(m)}
<select </div>
className="min-w-[140px] rounded-md border border-line bg-panel px-2 py-1.5 font-inherit text-[12px]" <div className="text-[12px] text-muted mt-0.5">
value={assignments[li.id] || cards[0]?.domId || ""} {fmtNum(li.quantity)} {li.unit} × {groupCommas(li.unit_price, true)}
onChange={(e) => assign(li.id, e.target.value)} </div>
> </div>
{cards.map((c, idx) => (
<option key={c.domId} value={c.domId}>
{cardLabel(c, idx)}
</option>
))}
</select>
</div>
);
})}
{active.length === 0 && (
<div className="mb-1 text-[12px] text-red">همه‌ی قلم‌ها رد شده‌اند — حداقل یک قلم را تیک بزنید.</div>
)}
</div>
</div>
{/* تخصیص قلم‌ها به پروژه */}
<div className="border-t border-line pt-3">
<div className="mb-1 text-[13px] font-bold">پروژه‌ی هر قلم:</div>
<div className="mb-2 text-[11.5px] text-muted">
برای هر متریال فقط یک پروژه انتخاب می‌شود — مثل شرایط پرداخت.
</div>
<div className="flex flex-col gap-2">
{allItems
.filter((li) => !removedItems.has(li.id))
.map((li) => {
const m = materialById(materials, li.material_id);
return (
<div key={li.id} className="rounded-lg border border-line p-2.5">
<div className="mb-1.5 text-[12.5px]">
<span className="font-semibold">
{formatMaterialTitle(m)}
</span>
<span className="ms-2 text-[11px] text-muted">
{fmtNum(li.quantity)} {li.unit}
</span>
</div>
{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={`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={itemProjectsSel[li.id] ?? ""} value={assignments[li.id] || cards[0]?.domId || ""}
onChange={(e) => onChange={(e) => assign(li.id, e.target.value)}
setItemProjectsSel((s) => ({ ...s, [li.id]: e.target.value })) disabled={removed}
}
> >
{(itemProjectsSel[li.id] ? [] : [{ name: "" }]).map((_, i) => ( {cards.map((c, idx) => (
<option key={i} value=""> <option key={c.domId} value={c.domId}>
انتخاب پروژه... {cardLabel(c, idx)}
</option>
))}
{projectOptions.map((n) => (
<option key={n} value={n}>
{n}
</option> </option>
))} ))}
</select> </select>
) : ( </div>
<div className="text-[12px] text-muted">پروژه‌ای ثبت نشده است.</div> );
)} })}
{active.length === 0 && (
<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>
</section>
{/* بخش ۳: تخصیص اقلام به پروژه */}
<section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5">
<h3 className="mb-1 text-[14px] font-bold text-slate-800">۳. تخصیص اقلام به پروژه‌ها</h3>
<div className="mb-4 text-[12px] text-muted">
برای هر متریال خریداری شده، می‌توانید پروژه مربوط به آن را مشخص کنید.
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{allItems
.filter((li) => !removedItems.has(li.id))
.map((li) => {
const m = materialById(materials, li.material_id);
return (
<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-3 flex items-center justify-between">
<span className="text-[13px] font-semibold text-slate-800 truncate">
{formatMaterialTitle(m)}
</span>
<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}
</span>
</div>
{projectOptions.length ? (
<select
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] ?? ""}
onChange={(e) =>
setItemProjectsSel((s) => ({ ...s, [li.id]: e.target.value }))
}
>
{(itemProjectsSel[li.id] ? [] : [{ name: "" }]).map((_, i) => (
<option key={i} value="">
انتخاب پروژه...
</option>
))}
{projectOptions.map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
) : (
<div className="text-[12px] text-muted bg-slate-50 p-2 rounded-md text-center">پروژه‌ای در سیستم ثبت نشده است.</div>
)}
</div>
);
})}
</div>
</section>
{/* بخش ۴: فاکتورها، پیش‌فاکتورها و تاریخ */}
<section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5 space-y-6">
<div>
<h3 className="mb-2 text-[14px] font-bold text-slate-800">فاکتورهای اصلی</h3>
<FileList
files={invoices}
onChange={setInvoices}
upload={(f) => api.files.upload(q.id, "invoice", f)}
onDelete={(a) =>
a.id &&
void api.files.remove(q.id, "invoice", a.id).catch((err) =>
toast.error(err instanceof Error ? err.message : "حذف فاکتور ناموفق بود"),
)
}
urlOf={(a) => (a.id ? api.files.downloadUrl(q.id, "invoice", a.id) : null)}
/>
</div>
<div className="h-px bg-line w-full"></div>
<div>
<h3 className="mb-2 text-[14px] font-bold text-slate-800">پیش‌فاکتورها</h3>
<FileList
files={proformas}
onChange={setProformas}
upload={(f) => api.files.upload(q.id, "proforma", f)}
onDelete={(a) =>
a.id &&
void api.files.remove(q.id, "proforma", a.id).catch((err) =>
toast.error(err instanceof Error ? err.message : "حذف پیش‌فاکتور ناموفق بود"),
)
}
urlOf={(a) => (a.id ? api.files.downloadUrl(q.id, "proforma", a.id) : null)}
label="افزودن پیش‌فاکتور"
/>
</div>
<div className="h-px bg-line w-full"></div>
<div className="max-w-xs">
<label className="block mb-2 text-[13px] font-bold text-slate-800">تاریخ و زمان پذیرش</label>
<JalaliDatePicker value={acceptDate} onChange={setAcceptDate} placeholder="تاریخ و ساعت" withTime />
</div>
</section>
</div> </div>
</div>
{/* فاکتورهای اصلی */} {/* فوتر ثابت */}
<div className="border-t border-line pt-3"> <div className="px-6 py-4 bg-white border-t border-line shrink-0 z-10">
<div className="mb-1 text-[13px] font-bold">فاکتورهای اصلی (چند فایل):</div> <DialogFooter className="flex w-full gap-3 sm:justify-end">
<FileList <Button variant="slate" onClick={onClose} className="w-full sm:w-auto">
files={invoices} انصراف
onChange={setInvoices} </Button>
upload={(f) => api.files.upload(q.id, "invoice", f)} <Button onClick={confirm} className="w-full sm:w-auto">
onDelete={(a) => ثبت معامله بسته‌شده
a.id && </Button>
void api.files.remove(q.id, "invoice", a.id).catch((err) => </DialogFooter>
toast.error(err instanceof Error ? err.message : "حذف فاکتور ناموفق بود"),
)
}
urlOf={(a) => (a.id ? api.files.downloadUrl(q.id, "invoice", a.id) : null)}
/>
</div>
{/* پیش‌فاکتورها */}
<div className="border-t border-line pt-3">
<div className="mb-1 text-[13px] font-bold">پیش‌فاکتورها (چند فایل):</div>
<FileList
files={proformas}
onChange={setProformas}
upload={(f) => api.files.upload(q.id, "proforma", f)}
onDelete={(a) =>
a.id &&
void api.files.remove(q.id, "proforma", a.id).catch((err) =>
toast.error(err instanceof Error ? err.message : "حذف پیش‌فاکتور ناموفق بود"),
)
}
urlOf={(a) => (a.id ? api.files.downloadUrl(q.id, "proforma", a.id) : null)}
label="افزودن پیش‌فاکتور"
/>
</div>
{/* تاریخ و زمان پذیرش */}
<div className="border-t border-line pt-3">
<div className="field max-w-xs">
<label>تاریخ و زمان پذیرش</label>
<JalaliDatePicker value={acceptDate} onChange={setAcceptDate} placeholder="تاریخ و ساعت" withTime />
</div> </div>
</div>
<DialogFooter> </div>
<Button variant="slate" onClick={onClose}>
انصراف
</Button>
<Button onClick={confirm}>ثبت معامله بسته‌شده</Button>
</DialogFooter>
</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}
File diff suppressed because it is too large Load Diff
+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">
+57 -33
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>
const m = materialById(store.materials, li.material_id); <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]">
return ( {store.quotes
<span key={li.id} className="truncate"> .find((x) => x.id === q.id)
• {materialLabel(m)} ({fmtNum(li.quantity)} {li.unit}) ?.line_items?.filter((li) => !li.rejected)
</span> .slice(0, 3)
); .map((li) => {
})} const m = materialById(store.materials, li.material_id);
return (
<div
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 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"
<option value="">— انتخاب کنید —</option> onClick={() => { setWhId(w.id); setMatTxt(""); setRows([]); }}
{whOpts.map((w) => <option key={w.id} value={w.id}>{w.name}</option>)} className={cn(
</select> "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"
)}
>
{w.name}
</button>
))}
</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"
<option value="">— انتخاب انبار مبدأ —</option> onClick={() => { setFromId(w.id); setToId(""); setMatTxt(""); setRows([]); }}
{whOpts.map((w) => <option key={w.id} value={w.id}>{w.name}</option>)} className={cn(
</select> "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"
)}
>
{w.name}
</button>
))}
</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
disabled={!fromId} key={w.id}
onChange={(e) => { setToId(e.target.value); setMatTxt(""); setRows([]); }} type="button"
> disabled={!fromId}
<option value="">— انتخاب انبار مقصد —</option> onClick={() => { setToId(w.id); setMatTxt(""); setRows([]); }}
{whOpts.filter((w) => w.id !== fromId).map((w) => <option key={w.id} value={w.id}>{w.name}</option>)} className={cn(
</select> "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"
)}
>
{w.name}
</button>
))}
</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,8 +951,7 @@ 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;
} }
+290 -87
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
<div className="text-[11px] text-muted ms-auto sm:hidden"> key={w.id}
نمایش {faNum(groups.length)} قلم کالا 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>
</div> )}
{/* فیلترهای پیشرفته — برچسب‌های لمسی بدون سلکت و بدون بیرون‌زدگی */}
{showMobileFilters && (
<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>
{/* پروژه‌ها (مخصوص ادمین) */}
{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>
{/* جدول موجودی انبار با اسکرول افقی محافظت‌شده */} {/* ۱. نمای مخصوص موبایل: کارت‌های عمودی کاملاً ریسپانسیو */}
<div className="overflow-x-auto rounded-xl border border-line bg-panel shadow-2xs [scrollbar-width:thin]"> <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;
+29 -5
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>
)} )}
@@ -161,4 +185,4 @@ export function CategoryPage() {
</PageLayout> </PageLayout>
</div> </div>
); );
} }
+24 -10
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,32 +21,44 @@ 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>
{deletable && onDelete && ( <div className="flex items-center gap-1.5 shrink-0">
<button type="button" className="sup-del" title="حذف تأمین‌کننده" onClick={() => onDelete(s.id)}> {avgLeadTime != null && (
× <span
</button> 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 && (
<button type="button" className="sup-del" title="حذف تأمین‌کننده" onClick={() => onDelete(s.id)}>
×
</button>
)}
</div>
</div> </div>
<div className="sc-meta"> <div className="sc-meta">
<span> <span>