fix(ui): fix layout alignment in AcceptModal and MilestoneRow
This commit is contained in:
+15
-52
@@ -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:
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user