diff --git a/docker-compose.yml b/docker-compose.yml index b2187e0..909a32b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,8 +6,6 @@ services: environment: POSTGRES_DB: procurement POSTGRES_USER: postgres - # Local dev default; production overrides via .env (required by the - # prod overlay) — never commit real passwords. POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} ports: @@ -15,7 +13,7 @@ services: networks: - default - - shared # monitoring stack scrapes it (postgres-exporter) + - shared volumes: - postgres_data:/var/lib/postgresql @@ -31,15 +29,7 @@ services: restart: unless-stopped mem_limit: 1g - # ========================================================================== - # 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 @@ -47,8 +37,6 @@ services: 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.file.filename=/etc/traefik/dynamic.yml - --entrypoints.web.address=:80 @@ -62,8 +50,6 @@ services: - "80:80" - "443: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 @@ -81,24 +67,21 @@ services: MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} ports: - - "9000:9000" # S3 API - - "9001:9001" # Web console + - "9000:9000" + - "9001:9001" 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 - - traefik.http.services.minio-console-svc.loadbalancer.server.port=9001 + - "traefik.enable=true" + - "traefik.http.routers.minio-api.rule=Host(`${S3_HOST:-s3.localhost}`)" + - "traefik.http.routers.minio-api.entrypoints=web" + - "traefik.http.services.minio-api-svc.loadbalancer.server.port=9000" + - "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" + - "traefik.http.services.minio-console-svc.loadbalancer.server.port=9001" networks: - traefik_network @@ -107,39 +90,20 @@ services: restart: unless-stopped mem_limit: 512m - # 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: condition: service_started - environment: MINIO_ENDPOINT: minio:9000 MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} MINIO_BUCKET: procurement-files - - entrypoint: > - /bin/sh -c " - until mc alias set local http://$${MINIO_ENDPOINT} $${MINIO_ROOT_USER} $${MINIO_ROOT_PASSWORD}; do - echo 'waiting for minio server...'; sleep 2; - done; - mc mb --ignore-existing local/$${MINIO_BUCKET}; - echo bucket $${MINIO_BUCKET} ready; - " - + entrypoint: ["/bin/sh", "-c", "until mc alias set local http://$${MINIO_ENDPOINT}$${MINIO_ROOT_USER} $${MINIO_ROOT_PASSWORD}; do echo 'waiting for minio server...'; sleep 2; done; mc mb --ignore-existing local/$${MINIO_BUCKET}; echo bucket ready;"] restart: "no" - # ========================================================================== - # Scheduled logical backups of BOTH databases (procurement + casdoor). - # Writes pg_dump -Fc archives to a bind mount (default ./backups, point - # BACKUP_DIR at /srv/erp/backups on the VPS) and prunes archives older - # than BACKUP_KEEP_DAYS. Runs on the same postgres:18 image already pulled. - # ========================================================================== db-backup: image: postgres:18 container_name: procurement-db-backup @@ -152,33 +116,9 @@ services: BACKUP_KEEP_DAYS: ${BACKUP_KEEP_DAYS:-14} volumes: - ${BACKUP_DIR:-./backups}:/backups - entrypoint: > - /bin/sh -c " - echo 'db-backup: dumping every $${BACKUP_INTERVAL_SECONDS}s, keeping $${BACKUP_KEEP_DAYS} days'; - while true; do - STAMP=$$(date +%Y%m%d-%H%M%S); - pg_dump -h postgres -U postgres -d procurement -Fc -f /backups/procurement-$${STAMP}.dump && - echo \"db-backup: procurement -> /backups/procurement-$${STAMP}.dump\"; - pg_dump -h postgres -U postgres -d casdoor -Fc -f /backups/casdoor-$${STAMP}.dump && - echo \"db-backup: casdoor -> /backups/casdoor-$${STAMP}.dump\"; - find /backups -name '*.dump' -mtime +$${BACKUP_KEEP_DAYS} -delete; - sleep $${BACKUP_INTERVAL_SECONDS}; - done - " + entrypoint: ["/bin/sh", "-c", "while true; do STAMP=$$(date +%Y%m%d-%H%M%S); pg_dump -h postgres -U postgres -d procurement -Fc -f /backups/procurement-$${STAMP}.dump; pg_dump -h postgres -U postgres -d casdoor -Fc -f /backups/casdoor-$${STAMP}.dump; find /backups -name '*.dump' -mtime +$${BACKUP_KEEP_DAYS} -delete; sleep $${BACKUP_INTERVAL_SECONDS}; done"] restart: unless-stopped mem_limit: 256m - - # ========================================================================== - # 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 @@ -195,10 +135,10 @@ services: - "8080:8000" labels: - - traefik.enable=true - - traefik.http.routers.casdoor.rule=Host(`${AUTH_HOST:-auth.localhost}`) - - traefik.http.routers.casdoor.entrypoints=web - - traefik.http.services.casdoor.loadbalancer.server.port=8000 + - "traefik.enable=true" + - "traefik.http.routers.casdoor.rule=Host(`${AUTH_HOST:-auth.localhost}`)" + - "traefik.http.routers.casdoor.entrypoints=web" + - "traefik.http.services.casdoor.loadbalancer.server.port=8000" networks: - traefik_network @@ -220,14 +160,9 @@ services: PROCUREMENT_MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-minioadmin} PROCUREMENT_MINIO_BUCKET: procurement-files PROCUREMENT_MINIO_SECURE: "false" - # "dev" enables the local defaults; "prod" turns on the fail-fast - # startup checks (no wildcard CORS, no default credentials). PROCUREMENT_ENVIRONMENT: ${ENVIRONMENT:-dev} PROCUREMENT_DEV_LOGIN_ENABLED: ${DEV_LOGIN_ENABLED:-false} - # Comma-separated browser origins (JSON-free .env friendly). - PROCUREMENT_CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost,http://localhost:3000,http://localhost:5173} - # Casdoor: endpoint is the in-network service URL; the issuer must match - # the origin Casdoor advertises to browsers (its OIDC discovery doc). + PROCUREMENT_CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost,http://localhost:3000,http://localhost:5173,http://192.168.1.104} PROCUREMENT_CASDOOR_ENDPOINT: http://casdoor:8000 PROCUREMENT_CASDOOR_ISSUER: ${CASDOOR_ISSUER:-http://localhost:8080} PROCUREMENT_CASDOOR_CLIENT_ID: inventory-app-client @@ -235,10 +170,8 @@ services: 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: ${CASDOOR_ADMIN_PASSWORD:-123} - # Observability (Phase 2) — see monitoring/README.md PROCUREMENT_LOG_FORMAT: ${LOG_FORMAT:-text} PROCUREMENT_SENTRY_DSN: ${SENTRY_DSN_BACKEND:-} PROCUREMENT_OPENPANEL_API_URL: ${OPENPANEL_API_URL:-} @@ -251,7 +184,7 @@ services: networks: - traefik_network - default - - shared # monitoring stack scrapes api:8000/metrics + - shared healthcheck: test: ["CMD-SHELL", "python -c \"import urllib.request as u; u.urlopen('http://127.0.0.1:8000/health', timeout=3)\""] @@ -261,10 +194,10 @@ services: start_period: 40s labels: - - traefik.enable=true - - traefik.http.routers.api.rule=Host(`${API_HOST:-api.localhost}`) - - traefik.http.routers.api.entrypoints=web - - traefik.http.services.api.loadbalancer.server.port=8000 + - "traefik.enable=true" + - "traefik.http.routers.api.rule=Host(`${API_HOST:-api.localhost}`)" + - "traefik.http.routers.api.entrypoints=web" + - "traefik.http.services.api.loadbalancer.server.port=8000" depends_on: postgres: @@ -279,27 +212,19 @@ services: build: context: ./frontend args: - # Developer Mode panel on the login page — dev-only aid. Production - # builds MUST leave this false (identity comes from Casdoor tokens). DEV_LOGIN_ENABLED: ${DEV_LOGIN_ENABLED:-false} - # Registry overridable for networks where npmmirror is unreachable. NPM_REGISTRY: ${NPM_REGISTRY:-https://registry.npmmirror.com} - # Observability (build-time baked; empty = disabled) VITE_OPENPANEL_URL: ${OPENPANEL_PUBLIC_URL:-} VITE_OPENPANEL_CLIENT_ID: ${OPENPANEL_CLIENT_ID:-} VITE_SENTRY_DSN: ${SENTRY_DSN_FRONTEND:-} container_name: procurement-frontend - # از entrypoint پیش‌فرض image nginx رد می‌شویم: آن اسکریپت `apk manifest` - # اجرا می‌کند و اگر دسترسی به مخزن بسته‌ها نباشد بی‌نهایت معلق می‌ماند - # (سرویس بالا نمی‌آید و traefik خطای 502 می‌دهد). کانفیگ ما مستقل است و - # نیازی به آن اسکریپت‌ها ندارد. entrypoint: ["nginx", "-g", "daemon off;"] labels: - - traefik.enable=true - - traefik.http.routers.frontend.rule=Host(`${APP_HOST:-localhost}`) - - traefik.http.routers.frontend.entrypoints=web - - traefik.http.services.frontend.loadbalancer.server.port=80 + - "traefik.enable=true" + - "traefik.http.routers.frontend.rule=PathPrefix(`/`)" + - "traefik.http.routers.frontend.entrypoints=web" + - "traefik.http.services.frontend.loadbalancer.server.port=80" networks: - traefik_network @@ -312,13 +237,9 @@ services: mem_limit: 128m networks: - # شبکه‌ی عمومی: فقط ترافیک و سرویس‌های عمومی. postgres روی شبکه‌ی - # پیش‌فرض می‌ماند و از بیرون قابل دسترسی نیست. traefik_network: name: traefik_network driver: bridge - # Fixed-name network shared with the monitoring stack (monitoring compose - # joins it as external) — carries /metrics scrapes and postgres-exporter. shared: name: erp_shared driver: bridge @@ -326,4 +247,4 @@ networks: volumes: postgres_data: minio_data: - traefik_certs: + traefik_certs: \ No newline at end of file diff --git a/frontend/src/components/NumberInput.tsx b/frontend/src/components/NumberInput.tsx index b71bad9..f6466df 100644 --- a/frontend/src/components/NumberInput.tsx +++ b/frontend/src/components/NumberInput.tsx @@ -8,14 +8,13 @@ export interface NumberInputProps { placeholder?: string; className?: string; inputClassName?: string; - align?: "right" | "center" | "left"; // تنظیم جهت متن و اعداد (پیش‌فرض: راست‌چین) - showWords?: boolean; // نمایش معادل فارسی زیر input + align?: "right" | "center" | "left"; + showWords?: boolean; inputMode?: "numeric" | "decimal" | undefined; - maxDecimals?: number; // حداکثر رقم اعشار + maxDecimals?: number; disabled?: boolean; } -// ورودی عددی با فرمت‌دهی خودکار (groupCommas) و نمایش کلمات فارسی export function NumberInput({ value, onChange, @@ -28,7 +27,6 @@ export function NumberInput({ maxDecimals, disabled = false, }: NumberInputProps) { - // فرمت با رعایت حداکثر اعشار (گرد کردن) const formatValue = (n: number): string => { let v = n; if (maxDecimals != null) { @@ -75,7 +73,7 @@ export function NumberInput({ type="text" disabled={disabled} className={cn( - "num-input h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-xs transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60", + "num-input h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-xs font-normal text-ink shadow-xs transition-colors placeholder:text-xs placeholder:font-normal placeholder:text-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60", alignClass, inputClassName, )} diff --git a/frontend/src/pages/Wizard/Step2LineItems.tsx b/frontend/src/pages/Wizard/Step2LineItems.tsx index 1749b2f..de1cef2 100644 --- a/frontend/src/pages/Wizard/Step2LineItems.tsx +++ b/frontend/src/pages/Wizard/Step2LineItems.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from "react"; -import { Trash2, Plus } from "lucide-react"; +import { Trash2, Plus, Copy } from "lucide-react"; import type { Material } from "../../lib/types"; import { normalize } from "../../lib/normalize"; import { useMaterials } from "../../hooks"; @@ -35,7 +35,6 @@ export interface Step2Props { onRemember: (rec: MaterialRec) => void; } -// برچسب انسانی یک قلم (برای termsheet، درگ‌اندروپ و بازبینی) export function itemRowLabel(it: ItemDraft): string { const parts = [it.cat, it.brand, it.specs.join(" - ")].filter(Boolean); const name = parts.length ? parts.join(" - ") : "(قلم بدون‌نام)"; @@ -43,7 +42,6 @@ export function itemRowLabel(it: ItemDraft): string { return qtyPart ? `${name} -- ${qtyPart}` : name; } -// مقادیر مجزای name/brand/spec در کاتالوگ سراسری export function materialOptions( materials: Material[], _topic: string, @@ -87,7 +85,6 @@ export function materialOptions( return [...base].sort((a, b) => a.localeCompare(b, "fa")); } -// مرحله ۲ — مدیریت اقلام سفارش export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember }: Step2Props) { const materials = useMaterials(); @@ -151,6 +148,22 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember ]); }; + // قابلیت تکرار قلم در ردیف بعد با کپی عمیق فیلدها و شناسه یکتا + const duplicate = (index: number) => { + const target = items[index]; + if (!target) return; + + const clonedItem: ItemDraft = { + ...target, + tempId: "it" + Date.now() + Math.random().toString(36).slice(2, 6), + specs: [...target.specs], + }; + + const nextItems = [...items]; + nextItems.splice(index + 1, 0, clonedItem); + onItemsChange(nextItems); + }; + const rememberField = (field: "i-cat" | "i-brand" | "i-spec", it: ItemDraft) => { if (!selectedTopic) return; let rec: MaterialRec; @@ -169,6 +182,8 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember onRemember(rec); }; + const commonInputClass = "!h-9 !text-xs font-normal text-right placeholder:text-xs placeholder:font-normal"; + return (
{/* هدر */} @@ -208,17 +223,31 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember {it.cat || "قلم جدید"} {it.brand ? `• ${it.brand}` : ""}
- {items.length > 1 && ( + + {/* اکشن‌های کارت: کپی و حذف */} +
- )} + + {items.length > 1 && ( + + )} +
{/* ردیف ۱: متریال و برند */} @@ -234,7 +263,7 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember placeholder="نام متریال" onInputChange={(v) => patch(it.tempId, { cat: v })} onBlur={() => rememberField("i-cat", it)} - inputClassName="!text-xs !h-9 text-right" + inputClassName={commonInputClass} className="min-w-0" /> @@ -250,7 +279,7 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember placeholder="برند" onInputChange={(v) => patch(it.tempId, { brand: v })} onBlur={() => rememberField("i-brand", it)} - inputClassName="!text-xs !h-9 text-right" + inputClassName={commonInputClass} className="min-w-0" /> @@ -284,6 +313,7 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember }} placeholder="مقدار" className="!text-right text-right" + inputClassName={commonInputClass} /> @@ -309,13 +339,13 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember }) .catch(() => {}); }} - inputClassName="!text-xs !h-9 text-right" + inputClassName={commonInputClass} className="min-w-0" /> - {/* ردیف ۴: قیمت هر واحد (راست‌چین شده) */} + {/* ردیف ۴: قیمت هر واحد */}
- {/* ردیف ۵: قیمت کل قلم (راست‌چین شده) */} + {/* ردیف ۵: قیمت کل قلم */}
diff --git a/structure.txt b/structure.txt new file mode 100644 index 0000000..e6dad7d Binary files /dev/null and b/structure.txt differ