6 Commits
Author SHA1 Message Date
Behrad cff39542b0 Update ERP configuration and line item handling 2026-09-28 00:28:34 +03:30
ZCode 253c922280 revert: restore production to the state before 730d0bd (PM/RBAC work moved to 'pm')
Their three commits (730d0bd, 5a9b543, 387edfb) reached production by push and
auto-deployed. 387edfb expects a users.username column that the live database does
not have, so POST /api/v1/auth/login was returning HTTP 500. Their schema changes
never applied either (projects still has only id, name, description, start_date,
pm_user_id, created_at), so nothing depends on the reverted code.

This commit sets the tree back to exactly a02b3ea — verified with git diff against
it — while keeping history linear: the work itself is preserved on 'pm' at 387edfb.
2026-09-26 17:41:07 +00:00
Milad 387edfb1ab feat(org,rbac): dedicate organization panel, enforce role permissions, subheader info, and fix supervisor project sync 2026-09-26 18:51:37 +03:30
Milad 5a9b543040 feat(rbac): enforce procurement write restrictions and support supervisor role in SSO 2026-09-26 12:32:01 +03:30
Milad 730d0bd496 feat: PM panel under ERP shell + project-supervisor role
- /panel/pm: the PM app (served on the same origin under /pm) is embedded as
  a full-height iframe; supervisors are funneled there from every route,
  ERP MobileTabBar hidden on that route
- Panels menu: «مدیریت پروژه (PM)» is an internal route (admin/pm/supervisor
  only — procurement managers do not see it); PM link is no longer external
- New system role 'supervisor' (user_role enum): label in users router,
  allowed in UserRoleUpdate schema, deny-by-default on all ERP routers;
  admin panel can grant/revoke it alongside procurement manager
- Removed the obsolete VITE_PM_URL build plumbing (same-origin now)
2026-09-26 01:39:48 +03:30
MMD a02b3ea7cc fix(edit-modal): restore the proforma upload section
The proforma FileList existed in prod_v5 (a181a87) but was dropped when the
prod_v2 EditModal redesign (de177f3) was merged — that branch predates the
feature and its version of the file was taken wholesale to keep the new
tabbed layout, silently discarding the proforma block.

Restores the state, the load effect, the saved patch field and the upload
UI (on the تاریخ tab, next to the invoice card), using the current
authenticated download/preview helpers rather than the removed urlOf.
2026-09-23 22:01:14 +03:30
5 changed files with 105 additions and 129 deletions
+27 -106
View File
@@ -6,8 +6,6 @@ services:
environment: environment:
POSTGRES_DB: procurement POSTGRES_DB: procurement
POSTGRES_USER: postgres POSTGRES_USER: postgres
# Local dev default; production overrides via .env (required by the
# prod overlay) — never commit real passwords.
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
ports: ports:
@@ -15,7 +13,7 @@ services:
networks: networks:
- default - default
- shared # monitoring stack scrapes it (postgres-exporter) - shared
volumes: volumes:
- postgres_data:/var/lib/postgresql - postgres_data:/var/lib/postgresql
@@ -31,15 +29,7 @@ services:
restart: unless-stopped restart: unless-stopped
mem_limit: 1g 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: 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 container_name: procurement-traefik
restart: unless-stopped restart: unless-stopped
@@ -47,8 +37,6 @@ services:
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
# proxy network IP (otherwise it may pick the default-network IP → 504).
- --providers.docker.network=traefik_network - --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
@@ -62,8 +50,6 @@ services:
- "80:80" - "80:80"
- "443:443" - "443: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
@@ -81,24 +67,21 @@ services:
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
ports: ports:
- "9000:9000" # S3 API - "9000:9000"
- "9001:9001" # Web console - "9001:9001"
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.services.minio-api-svc.loadbalancer.server.port=9000"
- traefik.http.routers.minio-api.service=minio-api-svc - "traefik.http.routers.minio-console.rule=Host(`${STORAGE_HOST:-storage.localhost}`)"
- traefik.http.services.minio-api-svc.loadbalancer.server.port=9000 - "traefik.http.routers.minio-console.entrypoints=web"
# Web console - "traefik.http.routers.minio-console.service=minio-console-svc"
- traefik.http.routers.minio-console.rule=Host(`${STORAGE_HOST:-storage.localhost}`) - "traefik.http.services.minio-console-svc.loadbalancer.server.port=9001"
- 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: networks:
- traefik_network - traefik_network
@@ -107,39 +90,20 @@ services:
restart: unless-stopped restart: unless-stopped
mem_limit: 512m 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: minio-init:
image: minio/mc:latest image: minio/mc:latest
container_name: procurement-minio-init container_name: procurement-minio-init
depends_on: depends_on:
minio: minio:
condition: service_started condition: service_started
environment: environment:
MINIO_ENDPOINT: minio:9000 MINIO_ENDPOINT: minio:9000
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
MINIO_BUCKET: procurement-files 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 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 $${MINIO_BUCKET} ready;
"
restart: "no" 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: db-backup:
image: postgres:18 image: postgres:18
container_name: procurement-db-backup container_name: procurement-db-backup
@@ -152,33 +116,9 @@ services:
BACKUP_KEEP_DAYS: ${BACKUP_KEEP_DAYS:-14} BACKUP_KEEP_DAYS: ${BACKUP_KEEP_DAYS:-14}
volumes: volumes:
- ${BACKUP_DIR:-./backups}:/backups - ${BACKUP_DIR:-./backups}:/backups
entrypoint: > 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"]
/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
"
restart: unless-stopped restart: unless-stopped
mem_limit: 256m 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: casdoor:
image: casbin/casdoor:latest image: casbin/casdoor:latest
container_name: procurement-casdoor container_name: procurement-casdoor
@@ -195,10 +135,10 @@ services:
- "8080:8000" - "8080:8000"
labels: labels:
- traefik.enable=true - "traefik.enable=true"
- traefik.http.routers.casdoor.rule=Host(`${AUTH_HOST:-auth.localhost}`) - "traefik.http.routers.casdoor.rule=Host(`${AUTH_HOST:-auth.localhost}`)"
- traefik.http.routers.casdoor.entrypoints=web - "traefik.http.routers.casdoor.entrypoints=web"
- traefik.http.services.casdoor.loadbalancer.server.port=8000 - "traefik.http.services.casdoor.loadbalancer.server.port=8000"
networks: networks:
- traefik_network - traefik_network
@@ -220,14 +160,9 @@ services:
PROCUREMENT_MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-minioadmin} PROCUREMENT_MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-minioadmin}
PROCUREMENT_MINIO_BUCKET: procurement-files PROCUREMENT_MINIO_BUCKET: procurement-files
PROCUREMENT_MINIO_SECURE: "false" 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_ENVIRONMENT: ${ENVIRONMENT:-dev}
PROCUREMENT_DEV_LOGIN_ENABLED: ${DEV_LOGIN_ENABLED:-false} 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,http://192.168.1.104}
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_CASDOOR_ENDPOINT: http://casdoor:8000 PROCUREMENT_CASDOOR_ENDPOINT: http://casdoor:8000
PROCUREMENT_CASDOOR_ISSUER: ${CASDOOR_ISSUER:-http://localhost:8080} PROCUREMENT_CASDOOR_ISSUER: ${CASDOOR_ISSUER:-http://localhost:8080}
PROCUREMENT_CASDOOR_CLIENT_ID: inventory-app-client PROCUREMENT_CASDOOR_CLIENT_ID: inventory-app-client
@@ -235,10 +170,8 @@ services:
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: ${CASDOOR_ADMIN_PASSWORD:-123} PROCUREMENT_CASDOOR_ADMIN_PASSWORD: ${CASDOOR_ADMIN_PASSWORD:-123}
# Observability (Phase 2) — see monitoring/README.md
PROCUREMENT_LOG_FORMAT: ${LOG_FORMAT:-text} PROCUREMENT_LOG_FORMAT: ${LOG_FORMAT:-text}
PROCUREMENT_SENTRY_DSN: ${SENTRY_DSN_BACKEND:-} PROCUREMENT_SENTRY_DSN: ${SENTRY_DSN_BACKEND:-}
PROCUREMENT_OPENPANEL_API_URL: ${OPENPANEL_API_URL:-} PROCUREMENT_OPENPANEL_API_URL: ${OPENPANEL_API_URL:-}
@@ -251,7 +184,7 @@ services:
networks: networks:
- traefik_network - traefik_network
- default - default
- shared # monitoring stack scrapes api:8000/metrics - shared
healthcheck: healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request as u; u.urlopen('http://127.0.0.1:8000/health', timeout=3)\""] 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 start_period: 40s
labels: labels:
- traefik.enable=true - "traefik.enable=true"
- traefik.http.routers.api.rule=Host(`${API_HOST:-api.localhost}`) - "traefik.http.routers.api.rule=Host(`${API_HOST:-api.localhost}`)"
- traefik.http.routers.api.entrypoints=web - "traefik.http.routers.api.entrypoints=web"
- traefik.http.services.api.loadbalancer.server.port=8000 - "traefik.http.services.api.loadbalancer.server.port=8000"
depends_on: depends_on:
postgres: postgres:
@@ -279,27 +212,19 @@ services:
build: build:
context: ./frontend context: ./frontend
args: 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} DEV_LOGIN_ENABLED: ${DEV_LOGIN_ENABLED:-false}
# Registry overridable for networks where npmmirror is unreachable.
NPM_REGISTRY: ${NPM_REGISTRY:-https://registry.npmmirror.com} NPM_REGISTRY: ${NPM_REGISTRY:-https://registry.npmmirror.com}
# Observability (build-time baked; empty = disabled)
VITE_OPENPANEL_URL: ${OPENPANEL_PUBLIC_URL:-} VITE_OPENPANEL_URL: ${OPENPANEL_PUBLIC_URL:-}
VITE_OPENPANEL_CLIENT_ID: ${OPENPANEL_CLIENT_ID:-} VITE_OPENPANEL_CLIENT_ID: ${OPENPANEL_CLIENT_ID:-}
VITE_SENTRY_DSN: ${SENTRY_DSN_FRONTEND:-} VITE_SENTRY_DSN: ${SENTRY_DSN_FRONTEND:-}
container_name: procurement-frontend container_name: procurement-frontend
# از entrypoint پیش‌فرض image nginx رد می‌شویم: آن اسکریپت `apk manifest`
# اجرا می‌کند و اگر دسترسی به مخزن بسته‌ها نباشد بی‌نهایت معلق می‌ماند
# (سرویس بالا نمی‌آید و traefik خطای 502 می‌دهد). کانفیگ ما مستقل است و
# نیازی به آن اسکریپت‌ها ندارد.
entrypoint: ["nginx", "-g", "daemon off;"] entrypoint: ["nginx", "-g", "daemon off;"]
labels: labels:
- traefik.enable=true - "traefik.enable=true"
- traefik.http.routers.frontend.rule=Host(`${APP_HOST:-localhost}`) - "traefik.http.routers.frontend.rule=PathPrefix(`/`)"
- traefik.http.routers.frontend.entrypoints=web - "traefik.http.routers.frontend.entrypoints=web"
- traefik.http.services.frontend.loadbalancer.server.port=80 - "traefik.http.services.frontend.loadbalancer.server.port=80"
networks: networks:
- traefik_network - traefik_network
@@ -312,13 +237,9 @@ services:
mem_limit: 128m mem_limit: 128m
networks: networks:
# شبکه‌ی عمومی: فقط ترافیک و سرویس‌های عمومی. postgres روی شبکه‌ی
# پیش‌فرض می‌ماند و از بیرون قابل دسترسی نیست.
traefik_network: traefik_network:
name: traefik_network name: traefik_network
driver: bridge driver: bridge
# Fixed-name network shared with the monitoring stack (monitoring compose
# joins it as external) — carries /metrics scrapes and postgres-exporter.
shared: shared:
name: erp_shared name: erp_shared
driver: bridge driver: bridge
+4 -6
View File
@@ -8,14 +8,13 @@ export interface NumberInputProps {
placeholder?: string; placeholder?: string;
className?: string; className?: string;
inputClassName?: string; inputClassName?: string;
align?: "right" | "center" | "left"; // تنظیم جهت متن و اعداد (پیش‌فرض: راست‌چین) align?: "right" | "center" | "left";
showWords?: boolean; // نمایش معادل فارسی زیر input showWords?: boolean;
inputMode?: "numeric" | "decimal" | undefined; inputMode?: "numeric" | "decimal" | undefined;
maxDecimals?: number; // حداکثر رقم اعشار maxDecimals?: number;
disabled?: boolean; disabled?: boolean;
} }
// ورودی عددی با فرمت‌دهی خودکار (groupCommas) و نمایش کلمات فارسی
export function NumberInput({ export function NumberInput({
value, value,
onChange, onChange,
@@ -28,7 +27,6 @@ export function NumberInput({
maxDecimals, maxDecimals,
disabled = false, disabled = false,
}: NumberInputProps) { }: NumberInputProps) {
// فرمت با رعایت حداکثر اعشار (گرد کردن)
const formatValue = (n: number): string => { const formatValue = (n: number): string => {
let v = n; let v = n;
if (maxDecimals != null) { if (maxDecimals != null) {
@@ -75,7 +73,7 @@ export function NumberInput({
type="text" type="text"
disabled={disabled} disabled={disabled}
className={cn( 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, alignClass,
inputClassName, inputClassName,
)} )}
@@ -86,6 +86,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
const [selectedProjects, setSelectedProjects] = useState<string[]>([]); const [selectedProjects, setSelectedProjects] = useState<string[]>([]);
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
const [invoices, setInvoices] = useState<Attachment[]>([]); const [invoices, setInvoices] = useState<Attachment[]>([]);
const [proformas, setProformas] = useState<Attachment[]>([]);
const [itemRows, setItemRows] = useState<EditItemRow[]>([]); const [itemRows, setItemRows] = useState<EditItemRow[]>([]);
const [tsCards, setTsCards] = useState<EditTsCard[]>([]); const [tsCards, setTsCards] = useState<EditTsCard[]>([]);
const [quoteDate, setQuoteDate] = useState<string | null>(null); const [quoteDate, setQuoteDate] = useState<string | null>(null);
@@ -189,6 +190,10 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
.list(quoteId, "invoice") .list(quoteId, "invoice")
.then((list) => alive && setInvoices(list)) .then((list) => alive && setInvoices(list))
.catch(() => alive && setInvoices([])); .catch(() => alive && setInvoices([]));
void api.files
.list(quoteId, "proforma")
.then((list) => alive && setProformas(list))
.catch(() => alive && setProformas([]));
return () => { return () => {
alive = false; alive = false;
}; };
@@ -314,6 +319,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
next.projects = [...selectedProjects]; next.projects = [...selectedProjects];
next.notes = notes.trim(); next.notes = notes.trim();
next.invoices = invoices; next.invoices = invoices;
next.proformas = proformas;
const tsIdMap: Record<string, string> = {}; const tsIdMap: Record<string, string> = {};
const newTermsheets: Termsheet[] = []; const newTermsheets: Termsheet[] = [];
@@ -865,6 +871,25 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
loadPreview={(a) => api.files.previewUrl(q.id, "invoice", a.id!)} loadPreview={(a) => api.files.previewUrl(q.id, "invoice", a.id!)}
/> />
</div> </div>
{/* پیش‌فاکتورها — در همه‌ی مراحل چرخه‌ی پیشنهاد قابل افزودن/ویرایش است */}
<div className="rounded-xl border border-gray-200 bg-white p-3.5 sm:p-4 space-y-2 shadow-xs">
<div className="text-xs font-bold text-gray-900">پیش‌فاکتورها</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 : "حذف پیش‌فاکتور ناموفق بود"),
)
}
onDownload={(a) => api.files.download(q.id, "proforma", a.id!, a.name)}
loadPreview={(a) => api.files.previewUrl(q.id, "proforma", a.id!)}
label="افزودن پیش‌فاکتور"
/>
</div>
</div> </div>
)} )}
</div> </div>
+41 -9
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react"; 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 type { Material } from "../../lib/types";
import { normalize } from "../../lib/normalize"; import { normalize } from "../../lib/normalize";
import { useMaterials } from "../../hooks"; import { useMaterials } from "../../hooks";
@@ -35,7 +35,6 @@ export interface Step2Props {
onRemember: (rec: MaterialRec) => void; onRemember: (rec: MaterialRec) => void;
} }
// برچسب انسانی یک قلم (برای termsheet، درگ‌اندروپ و بازبینی)
export function itemRowLabel(it: ItemDraft): string { export function itemRowLabel(it: ItemDraft): string {
const parts = [it.cat, it.brand, it.specs.join(" - ")].filter(Boolean); const parts = [it.cat, it.brand, it.specs.join(" - ")].filter(Boolean);
const name = parts.length ? parts.join(" - ") : "(قلم بدون‌نام)"; const name = parts.length ? parts.join(" - ") : "(قلم بدون‌نام)";
@@ -43,7 +42,6 @@ export function itemRowLabel(it: ItemDraft): string {
return qtyPart ? `${name} -- ${qtyPart}` : name; return qtyPart ? `${name} -- ${qtyPart}` : name;
} }
// مقادیر مجزای name/brand/spec در کاتالوگ سراسری
export function materialOptions( export function materialOptions(
materials: Material[], materials: Material[],
_topic: string, _topic: string,
@@ -87,7 +85,6 @@ export function materialOptions(
return [...base].sort((a, b) => a.localeCompare(b, "fa")); return [...base].sort((a, b) => a.localeCompare(b, "fa"));
} }
// مرحله ۲ — مدیریت اقلام سفارش
export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember }: Step2Props) { export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember }: Step2Props) {
const materials = useMaterials(); 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) => { const rememberField = (field: "i-cat" | "i-brand" | "i-spec", it: ItemDraft) => {
if (!selectedTopic) return; if (!selectedTopic) return;
let rec: MaterialRec; let rec: MaterialRec;
@@ -169,6 +182,8 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
onRemember(rec); onRemember(rec);
}; };
const commonInputClass = "!h-9 !text-xs font-normal text-right placeholder:text-xs placeholder:font-normal";
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* هدر */} {/* هدر */}
@@ -208,6 +223,19 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
{it.cat || "قلم جدید"} {it.brand ? `• ${it.brand}` : ""} {it.cat || "قلم جدید"} {it.brand ? `• ${it.brand}` : ""}
</span> </span>
</div> </div>
{/* اکشن‌های کارت: کپی و حذف */}
<div className="flex items-center gap-1">
<button
type="button"
className="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs text-muted hover:text-ink hover:bg-line/50 transition-colors"
title="تکرار این قلم"
onClick={() => duplicate(idx)}
>
<Copy className="h-3.5 w-3.5" />
<span className="text-[11px]">کپی</span>
</button>
{items.length > 1 && ( {items.length > 1 && (
<button <button
type="button" type="button"
@@ -220,6 +248,7 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
</button> </button>
)} )}
</div> </div>
</div>
{/* ردیف ۱: متریال و برند */} {/* ردیف ۱: متریال و برند */}
<div className="grid grid-cols-2 gap-2.5"> <div className="grid grid-cols-2 gap-2.5">
@@ -234,7 +263,7 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
placeholder="نام متریال" placeholder="نام متریال"
onInputChange={(v) => patch(it.tempId, { cat: v })} onInputChange={(v) => patch(it.tempId, { cat: v })}
onBlur={() => rememberField("i-cat", it)} onBlur={() => rememberField("i-cat", it)}
inputClassName="!text-xs !h-9 text-right" inputClassName={commonInputClass}
className="min-w-0" className="min-w-0"
/> />
</div> </div>
@@ -250,7 +279,7 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
placeholder="برند" placeholder="برند"
onInputChange={(v) => patch(it.tempId, { brand: v })} onInputChange={(v) => patch(it.tempId, { brand: v })}
onBlur={() => rememberField("i-brand", it)} onBlur={() => rememberField("i-brand", it)}
inputClassName="!text-xs !h-9 text-right" inputClassName={commonInputClass}
className="min-w-0" className="min-w-0"
/> />
</div> </div>
@@ -284,6 +313,7 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
}} }}
placeholder="مقدار" placeholder="مقدار"
className="!text-right text-right" className="!text-right text-right"
inputClassName={commonInputClass}
/> />
</div> </div>
@@ -309,13 +339,13 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
}) })
.catch(() => {}); .catch(() => {});
}} }}
inputClassName="!text-xs !h-9 text-right" inputClassName={commonInputClass}
className="min-w-0" className="min-w-0"
/> />
</div> </div>
</div> </div>
{/* ردیف ۴: قیمت هر واحد (راست‌چین شده) */} {/* ردیف ۴: قیمت هر واحد */}
<div className="flex flex-col gap-1 min-w-0 border-t border-line/60 pt-2.5"> <div className="flex flex-col gap-1 min-w-0 border-t border-line/60 pt-2.5">
<label className="text-[10.5px] font-semibold text-muted text-right"> <label className="text-[10.5px] font-semibold text-muted text-right">
{it.unit ? `قیمت هر ${it.unit} (تومان)` : "قیمت هر واحد (تومان)"} {it.unit ? `قیمت هر ${it.unit} (تومان)` : "قیمت هر واحد (تومان)"}
@@ -331,10 +361,11 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
placeholder="قیمت هر واحد" placeholder="قیمت هر واحد"
showWords showWords
className="!text-right text-right" className="!text-right text-right"
inputClassName={commonInputClass}
/> />
</div> </div>
{/* ردیف ۵: قیمت کل قلم (راست‌چین شده) */} {/* ردیف ۵: قیمت کل قلم */}
<div className="flex flex-col gap-1 min-w-0 border-t border-line/60 pt-2.5"> <div className="flex flex-col gap-1 min-w-0 border-t border-line/60 pt-2.5">
<label className="text-[10.5px] font-semibold text-muted text-right">قیمت کل این قلم (تومان)</label> <label className="text-[10.5px] font-semibold text-muted text-right">قیمت کل این قلم (تومان)</label>
<NumberInput <NumberInput
@@ -349,6 +380,7 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
placeholder="قیمت کل قلم" placeholder="قیمت کل قلم"
showWords showWords
className="!text-right text-right" className="!text-right text-right"
inputClassName={commonInputClass}
/> />
</div> </div>
</div> </div>
BIN
View File
Binary file not shown.