From b5dc3e585af7d6b83091fafde4785259a31a2320 Mon Sep 17 00:00:00 2001 From: Behrad Date: Sat, 19 Sep 2026 17:44:21 +0330 Subject: [PATCH] fix(ui): fix layout alignment in AcceptModal and MilestoneRow --- docker-compose.yml | 67 +-- frontend/src/components/MilestoneRow.tsx | 33 +- frontend/src/pages/Dashboard/AcceptModal.tsx | 403 ++++++++++--------- 3 files changed, 247 insertions(+), 256 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index af5bd7e..9403615 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,6 @@ services: postgres: image: postgres:18 - container_name: procurement-postgres environment: POSTGRES_DB: procurement @@ -9,7 +8,7 @@ services: POSTGRES_PASSWORD: postgres ports: - - "5433:5432" + - "5435:5432" volumes: - postgres_data:/var/lib/postgresql @@ -24,24 +23,13 @@ services: 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: - # v3.6+ required: Traefik ≤3.3 fails against Docker Engine 29.x - # ("Error response from daemon" — Docker API version mismatch). image: traefik:v3.6 - container_name: procurement-traefik restart: unless-stopped command: - --providers.docker=true - --providers.docker.exposedbydefault=false - # Containers are on several networks; Traefik must use the dedicated - # proxy network IP (otherwise it may pick the default-network IP → 504). - - --providers.docker.network=traefik_network + - --providers.docker.network=erp_production_network - --providers.file.filename=/etc/traefik/dynamic.yml - --entrypoints.web.address=:80 - --entrypoints.websecure.address=:443 @@ -51,11 +39,9 @@ services: - --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web - --log.level=INFO ports: - - "80:80" - - "443:443" + - "8084:80" + - "8445:443" 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 - ./infra/traefik-dynamic.yml:/etc/traefik/dynamic.yml:ro - traefik_certs:/certs @@ -64,7 +50,6 @@ services: minio: image: minio/minio:latest - container_name: procurement-minio command: server /data --console-address ":9001" @@ -73,20 +58,18 @@ services: MINIO_ROOT_PASSWORD: minioadmin ports: - - "9000:9000" # S3 API - - "9001:9001" # Web console + - "9004:9000" # S3 API + - "9005:9001" # Web console volumes: - minio_data:/data labels: - traefik.enable=true - # S3 API - traefik.http.routers.minio-api.rule=Host(`${S3_HOST:-s3.localhost}`) - traefik.http.routers.minio-api.entrypoints=web - traefik.http.routers.minio-api.service=minio-api-svc - 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.entrypoints=web - traefik.http.routers.minio-console.service=minio-console-svc @@ -98,11 +81,8 @@ services: 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: image: minio/mc:latest - container_name: procurement-minio-init depends_on: minio: @@ -125,20 +105,8 @@ services: 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: image: casbin/casdoor:latest - container_name: procurement-casdoor depends_on: postgres: condition: service_healthy @@ -147,9 +115,9 @@ services: driverName: postgres dataSourceName: "user=casdoor password=casdoor host=postgres port=5432 sslmode=disable dbname=casdoor" dbName: casdoor - origin: "http://localhost:8080" + origin: "http://localhost:8083" ports: - - "8080:8000" + - "8083:8000" labels: - traefik.enable=true @@ -167,7 +135,6 @@ services: build: context: ./backend dockerfile: Dockerfile - container_name: procurement-api environment: PROCUREMENT_DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/procurement @@ -176,21 +143,18 @@ services: PROCUREMENT_MINIO_SECRET_KEY: minioadmin PROCUREMENT_MINIO_BUCKET: procurement-files 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_ISSUER: http://localhost:8080 + PROCUREMENT_CASDOOR_ISSUER: http://localhost:8083 PROCUREMENT_CASDOOR_CLIENT_ID: inventory-app-client PROCUREMENT_CASDOOR_CLIENT_SECRET: inventory-app-secret-2026 PROCUREMENT_CASDOOR_ORGANIZATION: inventory PROCUREMENT_CASDOOR_APP_NAME: inventory-app PROCUREMENT_CASDOOR_AUDIENCE: inventory-app-client - # Console admin used by POST /users/sync (identity mirroring). PROCUREMENT_CASDOOR_ADMIN_USER: admin PROCUREMENT_CASDOOR_ADMIN_PASSWORD: "123" ports: - - "8000:8000" + - "8001:8000" labels: - traefik.enable=true @@ -214,9 +178,10 @@ services: build: context: ./frontend args: - # Developer Mode panel on the login page — set false for production. DEV_LOGIN_ENABLED: ${DEV_LOGIN_ENABLED:-true} - container_name: procurement-frontend + + ports: + - "8085:80" labels: - traefik.enable=true @@ -234,13 +199,11 @@ services: restart: unless-stopped networks: - # شبکه‌ی عمومی: فقط ترافیک و سرویس‌های عمومی. postgres روی شبکه‌ی - # پیش‌فرض می‌ماند و از بیرون قابل دسترسی نیست. traefik_network: - name: traefik_network + name: erp_production_network driver: bridge volumes: postgres_data: minio_data: - traefik_certs: + traefik_certs: \ No newline at end of file diff --git a/frontend/src/components/MilestoneRow.tsx b/frontend/src/components/MilestoneRow.tsx index 995f234..9561b43 100644 --- a/frontend/src/components/MilestoneRow.tsx +++ b/frontend/src/components/MilestoneRow.tsx @@ -12,15 +12,19 @@ export interface MilestoneRowProps { // یک ردیف مرحله‌ی پرداخت: تاریخ + مبلغ + دکمه‌ی حذف export function MilestoneRow({ milestone, onChange, onRemove, disabled = false }: MilestoneRowProps) { return ( -
- onChange({ ...milestone, when_iso: iso })} - placeholder="تاریخ" - className="w-36 shrink-0" - disabled={disabled} - /> -
+
+ {/* کادر انتخاب تاریخ */} +
+ onChange({ ...milestone, when_iso: iso })} + placeholder="تاریخ" + disabled={disabled} + /> +
+ + {/* کادر ورود مبلغ و حروف زیر آن */} +
onChange({ ...milestone, amount })} @@ -29,11 +33,18 @@ export function MilestoneRow({ milestone, onChange, onRemove, disabled = false } disabled={disabled} />
+ + {/* دکمه حذف */} {!disabled && ( - )}
); -} +} \ No newline at end of file diff --git a/frontend/src/pages/Dashboard/AcceptModal.tsx b/frontend/src/pages/Dashboard/AcceptModal.tsx index d9dccca..12642bc 100644 --- a/frontend/src/pages/Dashboard/AcceptModal.tsx +++ b/frontend/src/pages/Dashboard/AcceptModal.tsx @@ -36,7 +36,6 @@ export interface AcceptModalProps { onClose: () => void; } -// مودال پذیرش: انتخاب/ویرایش شرایط پرداخت + تخصیص قلم‌ها + فاکتورها export function AcceptModal({ quoteId, onClose }: AcceptModalProps) { const quotes = useQuotes(); const suppliers = useSuppliers(); @@ -51,13 +50,10 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) { const [proformas, setProformas] = useState([]); const [removedItems, setRemovedItems] = useState>(new Set()); const [assignments, setAssignments] = useState>({}); - // تخصیص هر قلم به پروژه‌ها (نام‌ها) const [itemProjectsSel, setItemProjectsSel] = useState>({}); - // تاریخ و زمان پذیرش — پیش‌فرض الان const [acceptDate, setAcceptDate] = useState(() => new Date().toISOString()); const [ready, setReady] = useState(false); - // وقتی quote باز شد، state اولیه را بساز useMemo(() => { if (!q) return; const offered = q.termsheets.filter((t) => t.status === "offered"); @@ -70,7 +66,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) { }); setRemovedItems(rem); setAssignments({}); - // پروژه‌ی هر قلم — تخصیصِ تکی: از تخصیص قبلی قلم، وگرنه اولین پروژه‌ی پیشنهاد const projSel: Record = {}; q.line_items.forEach((li) => { const names = (li.projects || []).map((p) => p.name).filter(Boolean); @@ -86,7 +81,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) { setReady(true); }, [q]); - // پیوست‌ها مستقیماً از سرور (MinIO) خوانده می‌شوند — نه از state محلی useEffect(() => { if (!quoteId) return; let alive = true; @@ -109,7 +103,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) { const allItems = q.line_items || []; const active = activeItems(q); - // گزینه‌های پروژه — پروژه‌های ثبت‌شده + پروژه‌های این پیشنهاد + تخصیص‌های قبلی قلم‌ها const projectOptions = (() => { const set = new Set(); projects.forEach((p) => p.name && set.add(p.name)); @@ -147,10 +140,8 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) { const confirm = () => { if (!q) return; - // پچ نهایی — همه‌ی تغییرات روی همین کلون اعمال می‌شود (نه روی آبجکت store) const next: Quote = JSON.parse(JSON.stringify(q)); - // فقط کارت‌هایی با حداقل یک مرحله‌ی پرداخت const cardMeta = new Map(); cards.forEach((c, idx) => { 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) { - toast.error("حداقل یک conditions با حداقل یک مرحله پرداخت لازم است."); + toast.error("حداقل یک شرایط پرداخت با حداقل یک مرحله پرداخت لازم است."); return; } - // رد کردن قلم‌ها — در داده می‌مانند تا با بازگشت وضعیت برگردند next.line_items.forEach((li) => { if (removedItems.has(li.id)) li.rejected = true; }); @@ -178,7 +168,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) { return; } - // فقط termsheetهایی که حداقل یک قلم انتخاب کرده پذیرفته می‌شوند const fallbackDom = [...cardMeta.keys()][0]; const chosenDomIds = new Set(); active.forEach((li) => { @@ -200,7 +189,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) { } }); - // قلم‌ها: اتصال‌های offered حفظ می‌شوند + جفت پذیرفته‌شده ثبت می‌شود active.forEach((li) => { const dom = assignments[li.id] || fallbackDom; const finalId = domToFinal[dom]; @@ -210,7 +198,6 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) { li.accepted_ts_id = finalId || null; }); - // termsheet پذیرفته‌شده‌ای که هیچ قلمی انتخابش نکرده → برمی‌گردد به offered const usedIds = new Set(); active.forEach((li) => { 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"; }); - // پروژه‌ی هر قلم (تکی هدف خرید) + همگام‌سازی پروژه‌های سطح پیشنهاد const projUnion = new Set(); next.line_items.forEach((li) => { if (li.rejected) return; const name = itemProjectsSel[li.id]; if (name === undefined) return; li.target_project = name ? { id: "", name } : null; - // توجه: li.projects دست‌نخورده باقی می‌ماند تا پروژه‌های کاندیدای اولیه حفظ شوند if (name) projUnion.add(name); }); if (projUnion.size > 0) { @@ -248,198 +233,230 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) { return ( !o && onClose()}> - - - پذیرش پیشنهاد — {sup.name} - + +
+ + {/* هدر ثابت و هم‌تراز */} +
+ + + پذیرش پیشنهاد — {sup.name} + + +

+ شرایط پرداخت را ویرایش کنید. سپس هر قلم را به بخش و پروژه دلخواه تخصیص دهید. +

+
-
- شرایط پرداخت را انتخاب یا ویرایش کنید. سپس هر قلم را به conditions دلخواه تخصیص دهید. -
- - {/* کارت‌های termsheet */} -
- {cards.map((c, idx) => ( -
-
- {cardLabel(c, idx)} - -
-
- {c.milestones.map((m, i) => ( - { - const ms = [...c.milestones]; - ms[i] = nextM; - updateCard(c.domId, { milestones: ms }); - }} - onRemove={() => updateCard(c.domId, { milestones: c.milestones.filter((_, j) => j !== i) })} - /> + {/* بدنه اسکرول‌پذیر */} +
+ + {/* بخش ۱: تنظیم شرایط پرداخت */} +
+

۱. تنظیم شرایط پرداخت

+
+ {cards.map((c, idx) => ( +
+
+ {cardLabel(c, idx)} + +
+
+ {c.milestones.map((m, i) => ( + { + const ms = [...c.milestones]; + ms[i] = nextM; + updateCard(c.domId, { milestones: ms }); + }} + onRemove={() => updateCard(c.domId, { milestones: c.milestones.filter((_, j) => j !== i) })} + /> + ))} +
+ +
))}
- -
- ))} -
- + - {/* تخصیص قلم‌ها */} -
-
تخصیص قلم‌ها به conditions:
-
- قلمی که می‌خرید را تیک بزنید؛ قلمی که نمی‌خرید را بدون تیک رها کنید تا از معامله حذف شود. -
-
- {allItems.map((li) => { - const removed = removedItems.has(li.id); - const m = materialById(materials, li.material_id); - return ( -
- { - const next = new Set(removedItems); - if (e.target.checked) next.delete(li.id); - else next.add(li.id); - setRemovedItems(next); - }} - /> - - {formatMaterialTitle(m)} - - - {fmtNum(li.quantity)} {li.unit} × {groupCommas(li.unit_price, true)} - - -
- ); - })} - {active.length === 0 && ( -
همه‌ی قلم‌ها رد شده‌اند — حداقل یک قلم را تیک بزنید.
- )} -
-
- - {/* تخصیص قلم‌ها به پروژه */} -
-
پروژه‌ی هر قلم:
-
- برای هر متریال فقط یک پروژه انتخاب می‌شود — مثل شرایط پرداخت. -
-
- {allItems - .filter((li) => !removedItems.has(li.id)) - .map((li) => { - const m = materialById(materials, li.material_id); - return ( -
-
- - {formatMaterialTitle(m)} - - - {fmtNum(li.quantity)} {li.unit} - -
- {projectOptions.length ? ( + {/* بخش ۲: تخصیص اقلام */} +
+

۲. تخصیص اقلام به شرایط پرداخت

+
+ قلمی که می‌خرید را تیک بزنید؛ قلمی که نمی‌خرید را بدون تیک رها کنید تا از معامله حذف شود. +
+
+ {allItems.map((li) => { + const removed = removedItems.has(li.id); + const m = materialById(materials, li.material_id); + return ( +
+ { + const next = new Set(removedItems); + if (e.target.checked) next.delete(li.id); + else next.add(li.id); + setRemovedItems(next); + }} + /> +
+
+ {formatMaterialTitle(m)} +
+
+ {fmtNum(li.quantity)} {li.unit} × {groupCommas(li.unit_price, true)} +
+
+ - ) : ( -
پروژه‌ای ثبت نشده است.
- )} +
+ ); + })} + {active.length === 0 && ( +
+ ⚠️ همه‌ی قلم‌ها رد شده‌اند — حداقل یک قلم را تیک بزنید.
- ); - })} + )} +
+
+ + {/* بخش ۳: تخصیص اقلام به پروژه */} +
+

۳. تخصیص اقلام به پروژه‌ها

+
+ برای هر متریال خریداری شده، می‌توانید پروژه مربوط به آن را مشخص کنید. +
+
+ {allItems + .filter((li) => !removedItems.has(li.id)) + .map((li) => { + const m = materialById(materials, li.material_id); + return ( +
+
+ + {formatMaterialTitle(m)} + + + {fmtNum(li.quantity)} {li.unit} + +
+ {projectOptions.length ? ( + + ) : ( +
پروژه‌ای در سیستم ثبت نشده است.
+ )} +
+ ); + })} +
+
+ + {/* بخش ۴: فاکتورها، پیش‌فاکتورها و تاریخ */} +
+
+

فاکتورهای اصلی

+ 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)} + /> +
+ +
+ +
+

پیش‌فاکتورها

+ 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="افزودن پیش‌فاکتور" + /> +
+ +
+ +
+ + +
+
+
-
- {/* فاکتورهای اصلی */} -
-
فاکتورهای اصلی (چند فایل):
- 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)} - /> -
- - {/* پیش‌فاکتورها */} -
-
پیش‌فاکتورها (چند فایل):
- 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="افزودن پیش‌فاکتور" - /> -
- - {/* تاریخ و زمان پذیرش */} -
-
- - + {/* فوتر ثابت */} +
+ + + +
-
- - - - +
); -} +} \ No newline at end of file