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 (