fix(ui): fix layout alignment in AcceptModal and MilestoneRow

This commit is contained in:
Behrad
2026-09-19 17:44:21 +03:30
parent c395fba8dd
commit b5dc3e585a
3 changed files with 247 additions and 256 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:
+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>
); );
} }
+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>
); );
} }