diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..e29a23f --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,7 @@ +node_modules +dist +.git +.env +*.log +build-out.txt +tsc-out.txt diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..ab32a7b --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,28 @@ +# Frontend image — multi-stage build +# Stage 1: build the Vite app with Node.js +# Stage 2: serve static files with nginx (which also proxies /api -> api:8000) + +FROM node:22-alpine AS build + +WORKDIR /app + +# Dependencies first (layer caching) +# NPM_REGISTRY overridable; default points at npmmirror because +# registry.npmjs.org is unreachable from this network. +ARG NPM_REGISTRY=https://registry.npmmirror.com +COPY package.json package-lock.json ./ +RUN npm config set registry ${NPM_REGISTRY} && npm ci + +# Source code + build +COPY . . +RUN npm run build + +# ---------- runtime ---------- +FROM nginx:alpine + +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..86905be --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,19 @@ + + +
+ + + + + +}) => + api.quotes.update(args.id, args.patch), + onMutate: (args) => updateQuote(args.id, args.patch), + onSettled: () => invalidate(), + }); +} + +export function useDeleteQuote() { + const deleteQuote = useAppStore((s) => s.deleteQuote); + const invalidate = useInvalidateStore(); + return useMutation({ + mutationFn: (id: string) => api.quotes.delete(id), + onMutate: (id) => deleteQuote(id), + onSettled: () => invalidate(), + }); +} diff --git a/frontend/src/hooks/useStoreData.ts b/frontend/src/hooks/useStoreData.ts new file mode 100644 index 0000000..ce7b241 --- /dev/null +++ b/frontend/src/hooks/useStoreData.ts @@ -0,0 +1,32 @@ +// ============================================================ +// پایهی داده: یک query واحد که کل store را لود میکند +// ============================================================ + +import { useEffect } from "react"; +import { useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { api } from "../lib/api"; +import { useAppStore } from "../store"; + +export const STORE_KEY = ["store"] as const; + +// یک بار در ریشهی برنامه صدا زده میشود: لود + mirror در Zustand +export function useStoreData(): void { + const setData = useAppStore((s) => s.setData); + const { data } = useQuery({ + queryKey: STORE_KEY, + queryFn: () => api.listAll(), + staleTime: Infinity, + }); + useEffect(() => { + if (data) setData(data); + }, [data, setData]); +} + +// بعد از هر mutation برای همگرایی با حقیقت +export function useInvalidateStore(): () => void { + const qc = useQueryClient(); + return useCallback(() => { + void qc.invalidateQueries({ queryKey: STORE_KEY }); + }, [qc]); +} diff --git a/frontend/src/hooks/useSuppliers.ts b/frontend/src/hooks/useSuppliers.ts new file mode 100644 index 0000000..93097e6 --- /dev/null +++ b/frontend/src/hooks/useSuppliers.ts @@ -0,0 +1,43 @@ +// ============================================================ +// hooks تأمینکنندهها و دستهها +// ============================================================ + +import { useMutation } from "@tanstack/react-query"; +import type { Supplier } from "../lib/types"; +import { api } from "../lib/api"; +import { useAppStore } from "../store"; +import { useInvalidateStore } from "./useStoreData"; + +export function useSuppliers(): Supplier[] { + return useAppStore((s) => s.suppliers); +} + +export function useAddSupplier() { + const addSupplier = useAppStore((s) => s.addSupplier); + const invalidate = useInvalidateStore(); + return useMutation({ + mutationFn: (s: Supplier) => api.suppliers.create(s), + onMutate: (s) => addSupplier(s), + onSettled: () => invalidate(), + }); +} + +export function useUpdateSupplier() { + const updateSupplier = useAppStore((s) => s.updateSupplier); + const invalidate = useInvalidateStore(); + return useMutation({ + mutationFn: (args: { id: string; patch: Partial}) => api.suppliers.update(args.id, args.patch), + onMutate: (args) => updateSupplier(args.id, args.patch), + onSettled: () => invalidate(), + }); +} + +export function useRemoveSupplier() { + const deleteSupplier = useAppStore((s) => s.deleteSupplier); + const invalidate = useInvalidateStore(); + return useMutation({ + mutationFn: (id: string) => api.suppliers.delete(id), + onMutate: (id) => deleteSupplier(id), + onSettled: () => invalidate(), + }); +} diff --git a/frontend/src/hooks/useUnits.ts b/frontend/src/hooks/useUnits.ts new file mode 100644 index 0000000..94d7b80 --- /dev/null +++ b/frontend/src/hooks/useUnits.ts @@ -0,0 +1,70 @@ +// ============================================================ +// hooks واحدها — واحد مرجع، ضرایب دستهای و ثابت +// ============================================================ + +import { useMutation } from "@tanstack/react-query"; +import type { CategoryUnit, StandardConversion, UnitConversion } from "../lib/types"; +import { api } from "../lib/api"; +import { useAppStore } from "../store"; +import { useInvalidateStore } from "./useStoreData"; + +export function useUnits(): { + categoryUnits: CategoryUnit[]; + unitConversions: UnitConversion[]; + standardConversions: StandardConversion[]; +} { + const categoryUnits = useAppStore((s) => s.categoryUnits); + const unitConversions = useAppStore((s) => s.unitConversions); + const standardConversions = useAppStore((s) => s.standardConversions); + return { categoryUnits, unitConversions, standardConversions }; +} + +export function useSaveRefUnit() { + const setCategoryUnit = useAppStore((s) => s.setCategoryUnit); + const invalidate = useInvalidateStore(); + return useMutation({ + mutationFn: (cu: CategoryUnit) => api.units.saveCategoryUnit(cu), + onMutate: (cu) => setCategoryUnit(cu), + onSettled: () => invalidate(), + }); +} + +export function useSaveConversion() { + const setConversion = useAppStore((s) => s.setConversion); + const invalidate = useInvalidateStore(); + return useMutation({ + mutationFn: (c: UnitConversion) => api.units.saveConversion(c), + onMutate: (c) => setConversion(c), + onSettled: () => invalidate(), + }); +} + +export function useDeleteConversion() { + const deleteConversion = useAppStore((s) => s.deleteConversion); + const invalidate = useInvalidateStore(); + return useMutation({ + mutationFn: (id: string) => api.units.deleteConversion(id), + onMutate: (id) => deleteConversion(id), + onSettled: () => invalidate(), + }); +} + +export function useSaveStandardConversion() { + const setStandardConversion = useAppStore((s) => s.setStandardConversion); + const invalidate = useInvalidateStore(); + return useMutation({ + mutationFn: (sc: StandardConversion) => api.units.saveStandardConversion(sc), + onMutate: (sc) => setStandardConversion(sc), + onSettled: () => invalidate(), + }); +} + +export function useDeleteStandardConversion() { + const deleteStandardConversion = useAppStore((s) => s.deleteStandardConversion); + const invalidate = useInvalidateStore(); + return useMutation({ + mutationFn: (id: string) => api.units.deleteStandardConversion(id), + onMutate: (id) => deleteStandardConversion(id), + onSettled: () => invalidate(), + }); +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..b59153f --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,1417 @@ +// ============================================================ +// API client — HTTP روی بکاند FastAPI (/api/v1) +// +// بکاند واقعی (PostgreSQL) منبع حقیقت است؛ این ماژول: +// - همهی دادهها را با fetch میخواند/مینویسد (localStorage حذف شد) +// - شکل پاسخ بکاند (UUIDها، expand و ...) را به تایپهای جلوی +// برنامه (types.ts) تبدیل میکند (adapter) +// - خطاهای بکاند (envelope {error:{code,message,field}}) را به +// Error با پیام فارسی تبدیل میکند +// +// BASE_URL: در Docker خالی است (پروکسی nginx → /api/v1)؛ در حالت +// dev بدون پروکسی میتوان VITE_API_BASE_URL را ست کرد. +// ============================================================ + +import { normalize } from "./normalize"; +import { useAuthStore } from "../store/auth"; +import type { + Attachment, + CategoryUnit, + ConfirmedReceipt, + InventoryRow, + InventoryTransaction, + Material, + PendingArrival, + Project, + Quote, + StandardConversion, + Store, + Supplier, + TransactionType, + UnitConversion, + Warehouse, +} from "./types"; + +const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined)?.trim() || "/api/v1"; +const PAGE_SIZE = 100; + +// v2.6: توکن Casdoor (Bearer) یا هدرهای توسعه (X-User-*) — بکاند هر دو را میپذیرد. +function authHeaders(init?: RequestInit): Record { + const { token, user, isDev } = useAuthStore.getState(); + const headers: Record = { + "Content-Type": "application/json", + ...((init?.headers as Record ) || {}), + }; + if (token) { + headers.Authorization = `Bearer ${token}`; + } else if (isDev && user) { + if (user.role) headers["X-User-Role"] = user.role; + if (user.project_id) headers["X-Project-Id"] = user.project_id; + if (user.display_name) { + try { + headers["X-User-Name-B64"] = btoa(unescape(encodeURIComponent(user.display_name))); + } catch { + /* نام غیر قابل encode — نادیده بگیر */ + } + } + } + return headers; +} + +// ترجمهی پیامهای رایج اعتبارسنجی FastAPI (422) به فارسی +const VALIDATION_FA = (msg: string): string => { + const lower = msg.toLowerCase(); + if (lower.includes("field required")) return "فیلد اجباری خالی است"; + if (lower.includes("extra fields")) return "فیلد ناشناخته ارسال شده است"; + if (lower.includes("value is not a valid uuid") || lower.includes("input should be a valid uuid")) return "شناسهی ارسالشده معتبر نیست (UUID)"; + if (lower.includes("not a valid") && lower.includes("date")) return "تاریخ واردشده معتبر نیست"; + if (lower.includes("input should be a valid datetime")) return "تاریخ و ساعت واردشده معتبر نیست"; + if (lower.includes("input should be a valid date")) return "تاریخ واردشده معتبر نیست"; + if (lower.includes("input should be a valid integer")) return "عدد صحیح وارد کنید"; + if (lower.includes("input should be a valid number")) return "عدد معتبر وارد کنید"; + if (lower.includes("input should be a valid boolean")) return "مقدار درست/نادرست (بولی) وارد کنید"; + if (lower.includes("not a valid") && lower.includes("email")) return "ایمیل واردشده معتبر نیست"; + if (lower.includes("greater than or equal to")) return "مقدار از حداقل مجاز کوچکتر است"; + if (lower.includes("less than or equal to")) return "مقدار از حداکثر مجاز بزرگتر است"; + if (lower.includes("greater than 0")) return "مقدار باید بزرگتر از صفر باشد"; + if (lower.includes("string too short") || lower.includes("at least 1 character")) return "مقدار خیلی کوتاه است"; + if (lower.includes("string too long")) return "مقدار خیلی طولانی است"; + if (lower.includes("must be a valid")) return "مقدار واردشده معتبر نیست"; + if (lower.includes("input should be")) return "نوع دادهی ارسالشده درست نیست"; + if (lower.includes("value error")) return "مقدار واردشده نامعتبر است"; + return msg; +}; + +// ترجمهی نام فیلدهای انگلیسی بکاند به فارسی (برای پیامهای اعتبارسنجی) +const FIELD_FA: Record = { + supplier_id: "تأمینکننده", + project_id: "پروژه", + project_ids: "پروژهها", + line_items: "اقلام", + termsheets: "شرایط پرداخت", + item_termsheets: "قیمت اقلام", + quantity: "تعداد", + unit_price: "قیمت واحد", + total_price: "قیمت کل", + unit_id: "واحد", + material_id: "متریال", + category_id: "دستهبندی", + brand: "برند", + name: "نام", + label: "عنوان", + notes: "یادداشت", + factor: "ضریب تبدیل", + from_unit: "واحد مبدأ", + to_unit: "واحد مقصد", + ref_unit: "واحد مرجع", + status: "وضعیت", + categories: "متریالها", + file: "فایل", + quote_date: "تاریخ پیشنهاد", + expiry_date: "تاریخ انقضا", + accepted_date: "تاریخ پذیرش", + arrived_date: "تاریخ رسید", +}; + +const faField = (field: string): string => { + const last = field.split(".").filter(Boolean).pop() || field; + return FIELD_FA[last] || FIELD_FA[field] || field; +}; + +// ترجمهی خطای HTTP بر اساس کد وضعیت +const faHttpError = (status: number, fallback: string): string => { + if (status === 413) return "فایل انتخابشده از حد مجاز (۵ مگابایت) بزرگتر است"; + if (status === 404) return "مورد درخواستی یافت نشد"; + if (status === 409) return "تعارض داده — رکورد تکراری یا وضعیت نامعتبر"; + if (status === 400) return "دادهی ارسالشده نامعتبر است"; + return fallback; +}; + +// ============================================================ +// HTTP helpers — envelope بکاند: {"data": ..., "meta": ...} +// ============================================================ + +async function request (path: string, init?: RequestInit): Promise { + let res: Response; + try { + res = await fetch(BASE_URL + path, { + ...init, + headers: authHeaders(init), + }); + } catch { + throw new Error("اتصال به سرور برقرار نشد — آیا بکاند در دسترس است؟"); + } + if (!res.ok) { + let msg = `خطای سرور (${res.status})`; + try { + const body = await res.json(); + if (body?.error?.message) { + msg = body.error.message; + } else if (Array.isArray(body?.detail)) { + // FastAPI 422 — آرایهی خطاهای اعتبارسنجی با پیام انگلیسی + const first = body.detail[0]; + const field = first?.loc?.length ? String(first.loc[first.loc.length - 1]) : ""; + const fa = first?.msg ? VALIDATION_FA(String(first.msg)) : "دادهی ارسالشده نامعتبر است"; + msg = field ? `دادهی «${faField(field)}» نامعتبر است: ${fa}` : fa; + } else if (body?.detail) { + msg = String(body.detail); + } + } catch { + /* بدنۀ خطا JSON نیست */ + } + throw new Error(faHttpError(res.status, msg)); + } + return (await res.json()) as T; +} + +async function getData (path: string): Promise { + const res = await request<{ data: T }>(path); + return res.data; +} +async function postData (path: string, body: unknown): Promise { + const res = await request<{ data: T }>(path, { method: "POST", body: JSON.stringify(body) }); + return res.data; +} +async function patchData (path: string, body: unknown): Promise { + const res = await request<{ data: T }>(path, { method: "PATCH", body: JSON.stringify(body) }); + return res.data; +} +async function putData (path: string, body: unknown): Promise { + const res = await request<{ data: T }>(path, { method: "PUT", body: JSON.stringify(body) }); + return res.data; +} +async function deleteData (path: string): Promise { + const res = await request<{ data: T }>(path, { method: "DELETE" }); + return res.data; +} + +// آپلود چندبخشی (multipart) — بدون هدر Content-Type دستی تا مرورگر +// boundary درست بگذارد (JSON بودن هدر request، آپلود را خراب میکند). +async function uploadFile(path: string, file: File): Promise { + const form = new FormData(); + form.append("file", file); + let res: Response; + try { + res = await fetch(BASE_URL + path, { method: "POST", body: form, headers: authHeaders() }); + } catch { + throw new Error("اتصال به سرور برقرار نشد — آیا بکاند در دسترس است؟"); + } + if (!res.ok) { + let msg = `خطای سرور (${res.status})`; + try { + const body = await res.json(); + if (body?.error?.message) msg = body.error.message; + } catch { + /* بدنۀ خطا JSON نیست */ + } + throw new Error(faHttpError(res.status, msg)); + } + const body = (await res.json()) as { data: RawAttachment }; + return adaptAttachment(body.data); +} + +// ============================================================ +// شکل خام پاسخ بکاند (فقط فیلدهایی که adapter استفاده میکند) +// ============================================================ + +interface RawUnit { + id: number; + code: string; + name_fa: string | null; +} +interface RawCategory { + id: string; + slug: string; + name: string; +} +interface RawMaterial { + id: string; + name: string; + topic?: string | null; + category?: RawCategory | null; + brands?: { id: string; name: string }[] | null; + canonical_unit?: RawUnit | null; + canonical_unit_id?: number | null; + attributes?: RawSpec[] | null; // مشخصهها (EAV) +} +interface RawSupplier { + id: string; + name: string; + location?: string | null; + phone?: string | null; + contact_person?: string | null; + tax_id?: string | null; + categories?: string[] | null; // متریالهای تأمینشده +} +interface RawProject { + id: string; + name: string; +} +interface RawSpec { + attribute_key: string; + label: string; + value: unknown; + unit: { id: number; code: string } | null; +} +interface RawDecision { + id: string; + status: string; + chosen_item_termsheet_id?: string | null; + adjusted_unit_price?: number | null; + decided_at?: string | null; +} +interface RawReceipt { + id: string; + received_qty: number; + received_at?: string | null; + on_time?: boolean | null; +} +interface RawLineItem { + id: string; + material?: RawMaterial | null; + brand_material_id?: string | null; + unit?: RawUnit | null; + quantity: number | null; + unit_price?: number | null; + total_price?: number | null; + specs?: RawSpec[] | null; + decision?: RawDecision | null; + receipt?: RawReceipt | null; + termsheet_ids?: string[] | null; + accepted_termsheet_id?: string | null; // id ترمشیت پذیرفتهشده (task: خط آبی) + projects?: RawProject[] | null; // پروژههای تخصیصیافته به این قلم +} +interface RawTermsheet { + id: string; + label: string; + status: string; + discount_type?: string; + discount_value?: number; + schedule_steps?: { id: string; step_no: number; due_date: string | null; amount: number; note: string | null }[] | null; +} +interface RawEvent { + id: string; + event_type: string; + occurred_at: string; + actor?: string | null; +} +interface RawQuote { + id: string; + supplier_id: string; + project_id?: string | null; + quote_date: string; + expiry_date?: string | null; + accepted_date?: string | null; + arrived_date?: string | null; + created_at?: string | null; + status: string; + is_official?: boolean; + notes?: string | null; + supplier?: RawSupplier | null; + project?: RawProject | null; + projects?: RawProject[] | null; // چند پروژه (quote_projects) + events?: RawEvent[] | null; + line_items?: RawLineItem[] | null; + termsheets?: RawTermsheet[] | null; +} +interface RawCategoryUnit { + id: string; + category_id: string; + ref_unit: string; + category?: RawCategory | null; +} +interface RawUnitConversion { + id: string; + category_id: string; + brand?: string | null; + from_unit: string; + to_unit: string; + factor: number; + category?: RawCategory | null; +} +interface RawStandardConversion { + id: string; + from_unit: string; + to_unit: string; + factor: number; +} +interface RawMaterialCategory { + id: string; + slug: string; + name: string; +} + +// فایل پیوست — پاسخ بکاند (metadata؛ بایتها از endpoint دانلود میآیند) +interface RawAttachment { + id: string; + quote_id: string; + kind: string; + file_name: string; + mime_type: string; + size_bytes: number; + uploaded_at: string; +} + +// ============================================================ +// Lookup tables (ساختهشده در listAll از units / categories) +// ============================================================ + +interface Lookup { + units: RawUnit[]; + categories: RawMaterialCategory[]; + unitIdToName: Map ; + unitNameToId: Map ; // کلید: نام نرمالشده (بدون فاصله) + categoryIdToSlug: Map ; + categoryIdToName: Map ; + categoryNameToId: Map ; // کلید: نام نرمالشده + brandNameToId: Map ; // کلید: نام نرمالشده +} + +function norm(s: string): string { + return normalize(s).replace(/\s+/g, ""); +} + +function buildLookup(units: RawUnit[], categories: RawMaterialCategory[]): Lookup { + const unitIdToName = new Map (); + const unitNameToId = new Map (); + for (const u of units) { + unitIdToName.set(u.id, u.name_fa || u.code); + unitNameToId.set(norm(u.name_fa || u.code), u.id); + unitNameToId.set(norm(u.code), u.id); + } + const categoryIdToSlug = new Map (); + const categoryIdToName = new Map (); + const categoryNameToId = new Map (); + for (const c of categories) { + categoryIdToSlug.set(c.id, c.slug); + categoryIdToName.set(c.id, c.name); + categoryNameToId.set(norm(c.name), c.id); + } + return { units, categories, unitIdToName, unitNameToId, categoryIdToSlug, categoryIdToName, categoryNameToId, brandNameToId: new Map() }; +} + +// ============================================================ +// Adapters — بکاند → تایپهای جلوی برنامه +// ============================================================ + +// ۸ تاپیک ثابت جلوی برنامه ← از slug دستهبندی بکاند +const SLUG_TO_TOPIC: Record = { + cement: "مصالح ساختمانی", + rebar: "فلزات و مقاطع فولادی", + "tile-ceramic": "نازککاری و دکوراسیون داخلی", + paint: "نازککاری و دکوراسیون داخلی", + "pipe-fitting": "تاسیسات مکانیکی و آبی", + "electrical-cable": "تجهیزات و لوازم برقی", + "electrical-panel": "تجهیزات و لوازم برقی", + insulation: "مواد شیمیایی ساختمانی", + drywall: "نازککاری و دکوراسیون داخلی", + "concrete-additive": "مواد شیمیایی ساختمانی", + // v2.6 backend: دستههای ۸گانهی ثابت (تاپیک = دسته) + "mechanical-installations-pipes": "تاسیسات مکانیکی و آبی", + "electrical-equipment-supplies": "تجهیزات و لوازم برقی", + "doors-windows-glass": "درب، پنجره و شیشه", + "metals-steel-sections": "فلزات و مقاطع فولادی", + "construction-materials": "مصالح ساختمانی", + "construction-chemicals": "مواد شیمیایی ساختمانی", + "finishing-interior-decoration": "نازککاری و دکوراسیون داخلی", + "construction-tools-equipment": "ابزار و تجهیزات کارگاهی", +}; + +// v2.6 backend: بکاند برای ساخت تأمینکنندهی جدید category_id الزامی دارد. +// از روی تاپیکِ انتخابشده، دستهی متناظر پیدا میشود (تاپیک = دسته). +let _categoryIdCache: { slug: string; id: string }[] | null = null; +async function categoryIdForTopic(topic: string): Promise { + if (!_categoryIdCache) { + try { + const cats = await getData (`/material-categories?page_size=100`); + _categoryIdCache = cats.map((c) => ({ slug: c.slug, id: c.id })); + } catch { + _categoryIdCache = []; + } + } + const match = _categoryIdCache.find((c) => SLUG_TO_TOPIC[c.slug] === topic) || _categoryIdCache[0]; + return match ? match.id : ""; +} + +function adaptMaterial(raw: RawMaterial, lookup: Lookup): Material { + return { + id: raw.id, + name: raw.name, + // تاپیک ذخیرهشده در بکاند اولویت دارد؛ وگرنه از slug دسته استنتاج میشود + topic: raw.topic || (raw.category && SLUG_TO_TOPIC[raw.category.slug]) || "", + category: raw.category?.name || "", + brand: raw.brands?.[0]?.name || "", + // مشخصهها از attribute های متریال خوانده میشوند (EAV) + specs: (raw.attributes || []) + .map((a) => String(a.value ?? a.label ?? "")) + .filter(Boolean), + base_unit: + raw.canonical_unit?.name_fa || + raw.canonical_unit?.code || + (raw.canonical_unit_id != null ? lookup.unitIdToName.get(raw.canonical_unit_id) || "" : ""), + }; +} + +function adaptLineItem(raw: RawLineItem): Quote["line_items"][number] { + return { + id: raw.id, + material_id: raw.material?.id || "", + quantity: raw.quantity ?? null, + unit: raw.unit?.name_fa || raw.unit?.code || "", + unit_price: raw.decision?.adjusted_unit_price ?? raw.unit_price ?? null, + total_price: raw.total_price ?? null, + termsheet_ids: raw.termsheet_ids || [], + // بکاند decision.chosen_item_termsheet_id یک id از item_termsheets است؛ + // id ترمشیت واقعی از فیلد جدید accepted_termsheet_id میآید (task: خط آبی) + accepted_ts_id: raw.accepted_termsheet_id ?? raw.decision?.chosen_item_termsheet_id ?? null, + rejected: raw.decision?.status === "rejected", + arrived_date: raw.receipt?.received_at ?? null, + projects: (raw.projects || []).map((p) => ({ id: p.id, name: p.name })), + }; +} + +function adaptTermsheet(raw: RawTermsheet): Quote["termsheets"][number] { + return { + id: raw.id, + label: raw.label, + milestones: (raw.schedule_steps || []).map((s) => ({ + when_iso: s.due_date || null, + amount: s.amount ?? null, + })), + status: raw.status as Quote["termsheets"][number]["status"], + }; +} + +function adaptQuote(raw: RawQuote): Quote { + return { + id: raw.id, + supplier_id: raw.supplier_id, + project: raw.project?.name || "", + projects: + raw.projects && raw.projects.length + ? raw.projects.map((p) => p.name) + : raw.project + ? [raw.project.name] + : [], + quote_date: raw.quote_date || "", + created_at: raw.created_at || raw.quote_date || null, + expiry_date: raw.expiry_date || null, + accepted_date: raw.accepted_date || null, + arrived_date: raw.arrived_date || null, + status: raw.status as Quote["status"], + is_official: raw.is_official ?? true, + // پیوستها از endpoint جداگانه (api.files.list) خوانده میشوند + proformas: [], + invoices: [], + events: (raw.events || []).map((e) => ({ type: e.event_type, occurred_at: e.occurred_at })), + notes: raw.notes || "", + line_items: (raw.line_items || []).map((li) => adaptLineItem(li)), + termsheets: (raw.termsheets || []).map((t) => adaptTermsheet(t)), + }; +} + +function adaptSupplier(raw: RawSupplier, topic: string, categories: string[]): Supplier { + return { + id: raw.id, + topic, + // دستههای ذخیرهشده روی تأمینکننده (بکاند) اولویت دارند؛ وگرنه از quotes استنتاج میشود + categories: Array.isArray(raw.categories) && raw.categories.length ? raw.categories : categories, + name: raw.name, + location: raw.location || "", + phone: raw.phone || "", + contact: raw.contact_person || "", + }; +} + +// تأمینکنندهها در بکاند تاپیک/دسته ندارند — از قلمهای پیشنهادهایشان +// (دستهی جنسهای پیشنهادشده) استنتاج میشود. +// تأمینکنندههای جدید که هنوز پیشنهادی ندارند از supplierTopicRegistry +// topic و category خود را میگیرند. +function deriveSupplierTopics( + rawSuppliers: RawSupplier[], + quotes: Quote[], + materialsById: Map , +): Supplier[] { + const topics = new Map >(); + const cats = new Map >(); + for (const q of quotes) { + for (const li of q.line_items) { + const m = materialsById.get(li.material_id); + if (!m) continue; + if (!topics.has(q.supplier_id)) topics.set(q.supplier_id, new Set()); + if (!cats.has(q.supplier_id)) cats.set(q.supplier_id, new Set()); + if (m.topic) topics.get(q.supplier_id)!.add(m.topic); + if (m.category) cats.get(q.supplier_id)!.add(m.category); + } + } + return rawSuppliers.map((s) => { + const t = topics.get(s.id); + const c = cats.get(s.id); + // اگر از quotes topic/category پیدا نشد، registry را چک کن + if (!t || t.size === 0) { + const reg = supplierTopicRegistry.get(s.id); + if (reg) { + return adaptSupplier(s, reg.topic, reg.categories); + } + } + return adaptSupplier(s, t ? [...t][0] : "", c ? [...c].sort((a, b) => a.localeCompare(b, "fa")) : []); + }); +} + +function adaptCategoryUnit(raw: RawCategoryUnit): CategoryUnit { + return { id: raw.id, category: raw.category?.name || "", ref_unit: raw.ref_unit }; +} +function adaptUnitConversion(raw: RawUnitConversion, lookup: Lookup): UnitConversion { + return { + id: raw.id, + category: raw.category?.name || lookup.categoryIdToName.get(raw.category_id) || "", + brand: raw.brand || "", + from_unit: raw.from_unit, + to_unit: raw.to_unit, + factor: raw.factor, + }; +} +function adaptStandardConversion(raw: RawStandardConversion): StandardConversion { + return { id: raw.id, from_unit: raw.from_unit, to_unit: raw.to_unit, factor: raw.factor }; +} + +// بکاند → Attachment (فایلهای ذخیرهشده در MinIO) +function adaptAttachment(raw: RawAttachment): Attachment { + return { + id: raw.id, + name: raw.file_name, + mime_type: raw.mime_type, + size_bytes: raw.size_bytes, + uploaded_at: raw.uploaded_at, + }; +} + +// ============================================================ +// Date helpers — بکاند datetime/date را جدا میپذیرد +// ============================================================ + +function toDateOnly(v: string | null | undefined): string | null { + if (!v) return null; + const m = String(v).match(/^(\d{4}-\d{2}-\d{2})/); + if (m) return m[1]; + // datetime کامل → تاریخ به وقت محلی (نه UTC که یک روز عقب میافتد) + const d = new Date(v); + if (Number.isNaN(d.getTime())) return null; + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; +} +function toDateTime(v: string | null | undefined): string | null { + const d = toDateOnly(v); + if (!d) return null; + return /^\d{4}-\d{2}-\d{2}$/.test(v as string) ? d + "T00:00:00Z" : (v as string); +} +function todayIso(): string { + return new Date().toISOString().slice(0, 10); +} +function looksLikeUuid(v: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v); +} + +// ============================================================ +// Registry موقت: material ساختهشده در همین نشست (id موقت → UUID واقعی) +// ویزارد id موقت ("m10") را در قلم ذخیره میکند؛ هنگام ساخت پیشنهاد +// باید به UUID واقعی بکاند ترجمه شود. +// ============================================================ + +const tempIdToRealId = new Map (); + +function realMaterialId(id: string): string { + return tempIdToRealId.get(id) || id; +} + +// ============================================================ +// Supplier topic/category tracking — بکاند topic و category +// برای تأمینکننده ذخیره نمیکند. این Map اطلاعات topic و +// categories را برای تأمینکنندههای جدید (بدون پیشنهاد) نگه +// میدارد تا در deriveSupplierTopics استفاده شود. +// ============================================================ + +const supplierTopicRegistry = new Map (); + +function registerSupplierTopic(id: string, topic: string, categories: string[]): void { + if (topic || categories.length) { + supplierTopicRegistry.set(id, { topic, categories }); + } +} + +// ============================================================ +// API +// ============================================================ + +export const api = { + // ---------- کل store (یک بار در ریشه) ---------- + async listAll(): Promise { + const [quotesRes, suppliersRes, materialsRes, projectsRes, unitsRes, categoriesRes, cruRes, ucRes, scuRes] = + await Promise.all([ + getData (`/quotes?expand=supplier,project,events,line_items,termsheets,decision,receipt&page_size=${PAGE_SIZE}`), + getData (`/suppliers?page_size=${PAGE_SIZE}`), + getData (`/materials?expand=category,brands,canonical_unit,attributes&page_size=${PAGE_SIZE}`), + getData (`/projects?page_size=${PAGE_SIZE}`), + getData (`/units?page_size=${PAGE_SIZE}`), + getData (`/material-categories?page_size=${PAGE_SIZE}`), + getData (`/category-reference-units?expand=category&page_size=${PAGE_SIZE}`), + getData (`/unit-conversions?expand=category&page_size=${PAGE_SIZE}`), + getData (`/standard-conversions?page_size=${PAGE_SIZE}`), + ]); + + const lookup = buildLookup(unitsRes, categoriesRes); + const materials = materialsRes.map((m) => adaptMaterial(m, lookup)); + const materialsById = new Map(materials.map((m) => [m.id, m])); + const quotes = quotesRes.map((q) => adaptQuote(q)); + const suppliers = deriveSupplierTopics(suppliersRes, quotes, materialsById); + + return { + suppliers, + materials, + projects: projectsRes.map((p) => ({ id: p.id, name: p.name })), + quotes, + categoryUnits: cruRes.map(adaptCategoryUnit), + unitConversions: ucRes.map((uc) => adaptUnitConversion(uc, lookup)), + standardConversions: scuRes.map(adaptStandardConversion), + }; + }, + + quotes: { + list: async (): Promise => { + const raw = await getData( + `/quotes?expand=supplier,project,events,line_items,termsheets,decision,receipt&page_size=${PAGE_SIZE}`, + ); + return raw.map((q) => adaptQuote(q)); + }, + get: async (id: string): Promise => { + const raw = await getData(`/quotes/${id}?expand=supplier,project,events,line_items,termsheets`); + return adaptQuote(raw); + }, + create: async (q: Quote): Promise => { + const lookup = await fetchLookup(); + const lineItems: { temp_id: string; material_id: string; unit_id: number; quantity: number; project_ids: string[] }[] = + (q.line_items || []).map((li) => ({ + temp_id: li.id, + material_id: realMaterialId(li.material_id), + unit_id: 0, // placeholder — بعداً resolve میشود + quantity: li.quantity ?? 1, + project_ids: [], + })); + // واحدهایی که در بکاند نیستند را بساز (issue: واحد جدید) — واحد خالی = «عدد» + // پروژههای هر قلم هم به id واقعی ترجمه میشوند (در صورت نبودن ساخته میشوند) + for (let i = 0; i < lineItems.length; i++) { + lineItems[i].unit_id = await unitIdByNameOrCreate(lookup, q.line_items[i].unit || "عدد"); + for (const p of q.line_items[i].projects || []) { + const pid = looksLikeUuid(p.id) ? p.id : await projectIdByNameOrCreate(p.name); + if (pid) lineItems[i].project_ids.push(pid); + } + } + const termsheets = (q.termsheets || []).map((t) => ({ + temp_id: t.id, + label: t.label, + discount_type: "none", + discount_value: 0, + schedule_steps: (t.milestones || []).map((m, i) => ({ + step_no: i + 1, + due_date: toDateOnly(m.when_iso) || todayIso(), + amount: m.amount ?? 0, + })), + })); + // فقط اقلامِ دارای قیمت به termsheet لینک میشوند — ذخیرهی ناقص مجاز است + const itemTerms = (q.line_items || []).flatMap((li) => + li.unit_price != null && li.unit_price > 0 + ? (li.termsheet_ids || []).map((tsId) => ({ + line_item_temp_id: li.id, + termsheet_temp_id: tsId, + unit_price: li.unit_price!, + })) + : [], + ); + // همهی پروژهها (نه فقط اولی) — ساخته میشوند اگر در بکاند نیستند + const projectNames = q.projects?.length ? q.projects : q.project ? [q.project] : []; + const projectIds: string[] = []; + for (const pname of projectNames) { + const pid = await projectIdByNameOrCreate(pname); + if (pid) projectIds.push(pid); + } + const payload = { + supplier_id: q.supplier_id, + project_id: projectIds[0] || null, + project_ids: projectIds, + quote_date: toDateTime(q.quote_date), + expiry_date: toDateOnly(q.expiry_date), + is_official: q.is_official ?? true, + notes: q.notes || null, + line_items: lineItems, + termsheets: termsheets, + item_termsheets: itemTerms, + }; + const raw = await postData("/quotes", payload); + return adaptQuote(raw); + }, + update: async (id: string, patch: Partial ): Promise=> { + const current = await api.quotes.get(id); + + // --- تشخیص مقصد: رسید (arrive) / پذیرش (accept) / ویرایش ساده --- + const curByLi = new Map(current.line_items.map((li) => [li.id, li])); + const patchedLis = patch.line_items || []; + const arrivalChanged = patchedLis.some( + (li) => (li.arrived_date ?? null) !== (curByLi.get(li.id)?.arrived_date ?? null), + ); + const becomingArrived = patch.status === "arrived" && current.status !== "arrived"; + const becomingAccepted = patch.status === "accepted" && current.status !== "accepted"; + + if (becomingArrived || arrivalChanged) { + // هر قلم با arrived_date جدید → receipt؛ قلمی که حذف شده → DELETE receipt + const arrive = patchedLis + .filter((li) => li.arrived_date) + .map((li) => ({ + line_item_id: li.id, + received_qty: li.quantity ?? 1, + received_at: li.arrived_date, + on_time: true, + })); + if (arrive.length) { + await postData(`/quotes/${id}/arrive-items`, { items: arrive }); + } + for (const li of patchedLis) { + if (!li.arrived_date && curByLi.get(li.id)?.arrived_date) { + await deleteData(`/quotes/${id}/line-items/${li.id}/decision/receipt`).catch(() => undefined); + } + } + return { ...current, ...patch } as Quote; + } + + if (becomingAccepted) { + const acceptedTs = (patch.termsheets || []).filter((t) => t.status === "accepted"); + // تخصیص پروژه به هر قلم — نامها به id واقعی ترجمه میشوند + const itemProjects: { line_item_id: string; project_ids: string[] }[] = []; + const allProjectIds: string[] = []; + const seenPids = new Set(); + for (const li of patch.line_items || []) { + const ids: string[] = []; + for (const p of li.projects || []) { + const pid = looksLikeUuid(p.id) ? p.id : await projectIdByNameOrCreate(p.name); + if (!pid) continue; + ids.push(pid); + if (!seenPids.has(pid)) { + seenPids.add(pid); + allProjectIds.push(pid); + } + } + itemProjects.push({ line_item_id: li.id, project_ids: ids }); + } + const payload = { + accepted_termsheets: acceptedTs.map((t) => ({ + id: looksLikeUuid(t.id) ? t.id : undefined, + label: t.label, + discount_type: "none", + discount_value: 0, + schedule_steps: (t.milestones || []).map((m, i) => ({ + step_no: i + 1, + due_date: toDateOnly(m.when_iso) || todayIso(), + amount: m.amount ?? 0, + })), + })), + item_decisions: (patch.line_items || []).map((li) => { + const idx = li.accepted_ts_id ? acceptedTs.findIndex((t) => t.id === li.accepted_ts_id) : -1; + const chosen = !li.rejected && idx >= 0; + return { + line_item_id: li.id, + status: chosen ? "chosen" : "rejected", + ...(chosen ? { accepted_termsheet_index: idx } : {}), + ...(li.unit_price != null ? { adjusted_unit_price: li.unit_price } : {}), + }; + }), + item_projects: itemProjects, + }; + await patchData(`/quotes/${id}/accept`, payload); + // پروژههای سطح پیشنهاد با اتحاد پروژههای اقلام همگام میشوند + if (allProjectIds.length) { + await patchData(`/quotes/${id}`, { project_ids: allProjectIds }); + } + return { ...current, ...patch } as Quote; + } + + // --- ویرایش ساده: اقلام/termsheetها (جایگزینی کامل) + فیلدهای metadata --- + const childKey = (li: Quote["line_items"][number]) => + JSON.stringify({ + id: li.id, + material_id: li.material_id, + quantity: li.quantity, + unit: li.unit, + unit_price: li.unit_price, + total_price: li.total_price, + termsheet_ids: li.termsheet_ids || [], + rejected: !!li.rejected, + accepted_ts_id: li.accepted_ts_id ?? null, + }); + const childrenChanged = + JSON.stringify(patchedLis.map(childKey)) !== JSON.stringify(current.line_items.map(childKey)) || + JSON.stringify(patch.termsheets || []) !== JSON.stringify(current.termsheets || []); + + if (childrenChanged) { + if (patch.status === "accepted" || patch.status === "arrived") { + throw new Error("برای تغییر اقلام یا شرایط پرداخت، ابتدا وضعیت را به «پیشنهاد شده» برگردانید"); + } + // اگر هنوز offered نیست (بازگشت وضعیت)، اول وضعیت را برمیگردانیم + if (current.status !== "offered") { + await patchData(`/quotes/${id}`, { status: "offered" }); + } + await patchData(`/quotes/${id}/children`, await buildChildrenPayload(patchedLis, patch.termsheets || [])); + } + + // --- فیلدهای metadata که بکاند میپذیرد --- + const body: Record = {}; + if (patch.supplier_id !== undefined) body.supplier_id = patch.supplier_id; + const projNames = patch.projects?.length ? patch.projects : patch.project ? [patch.project] : []; + if (patch.projects !== undefined || patch.project !== undefined) { + const projectIds: string[] = []; + for (const pname of projNames) { + const pid = await projectIdByNameOrCreate(pname); + if (pid) projectIds.push(pid); + } + body.project_ids = projectIds; + } + if (patch.quote_date !== undefined) body.quote_date = toDateTime(patch.quote_date); + if (patch.expiry_date !== undefined) body.expiry_date = toDateOnly(patch.expiry_date); + if (patch.is_official !== undefined) body.is_official = patch.is_official; + if (patch.notes !== undefined) body.notes = patch.notes; + if (patch.status !== undefined) body.status = patch.status; + await patchData(`/quotes/${id}`, body); + return { ...current, ...patch } as Quote; + }, + delete: async (id: string): Promise => { + await deleteData(`/quotes/${id}`); + }, + }, + + suppliers: { + list: async (): Promise => { + const raw = await getData (`/suppliers?page_size=${PAGE_SIZE}`); + const { quotes } = await api.listAll(); + const materialsById = new Map((await api.materials.list()).map((m) => [m.id, m])); + return deriveSupplierTopics(raw, quotes, materialsById); + }, + create: async (s: Supplier): Promise => { + // v2.6 backend: category_id الزامی است — از تاپیکِ انتخابشده استنتاج میشود + const category_id = await categoryIdForTopic(s.topic || ""); + const raw = await postData ("/suppliers", { + name: s.name, + category_id, + location: s.location || null, + phone: s.phone || null, + contact_person: s.contact || null, + }); + // ثبت topic/category برای این تأمینکننده تا در لیستها دیده شود + registerSupplierTopic(raw.id, s.topic || "", s.categories || []); + return adaptSupplier(raw, s.topic || "", s.categories || []); + }, + update: async (id: string, patch: Partial ): Promise => { + const raw = await patchData (`/suppliers/${id}`, { + ...(patch.name !== undefined ? { name: patch.name } : {}), + ...(patch.location !== undefined ? { location: patch.location } : {}), + ...(patch.phone !== undefined ? { phone: patch.phone } : {}), + ...(patch.contact !== undefined ? { contact_person: patch.contact } : {}), + }); + if (patch.categories !== undefined) { + registerSupplierTopic(id, patch.topic || "", patch.categories || []); + } + return adaptSupplier(raw, patch.topic || "", patch.categories || []); + }, + delete: async (id: string): Promise => { + await deleteData(`/suppliers/${id}`); + }, + }, + + // v2.6 backend: اتصال واقعی تأمینکننده ↔ متریال (کاتالوگ) + supplierMaterials: { + list: async (supplierId: string): Promise<{ id: string; material_id: string; material_name: string }[]> => { + const data = await getData< + { id: string; material_id: string; material: { id: string; name: string } | null }[] + >(`/suppliers/${supplierId}/materials?page_size=100`); + return data.map((l) => ({ + id: l.id, + material_id: l.material_id, + material_name: l.material?.name || "", + })); + }, + create: async (supplierId: string, materialId: string): Promise => { + await postData(`/suppliers/${supplierId}/materials`, { material_id: materialId }); + }, + remove: async (supplierId: string, materialId: string): Promise => { + await deleteData(`/suppliers/${supplierId}/materials/${materialId}`); + }, + }, + + materials: { + list: async (): Promise => { + const raw = await getData (`/materials?expand=category,brands,canonical_unit,attributes&page_size=${PAGE_SIZE}`); + const units = await getData (`/units?page_size=${PAGE_SIZE}`); + const categories = await getData (`/material-categories?page_size=${PAGE_SIZE}`); + return raw.map((m) => adaptMaterial(m, buildLookup(units, categories))); + }, + create: async (m: Material): Promise => { + const lookup = await fetchLookup(); + let categoryId = m.category ? lookup.categoryNameToId.get(norm(m.category)) : undefined; + // اگر دسته در بکاند نیست (مثلاً بعد از حذف seed) — بساز + if (!categoryId && m.category) { + const cat = await postData ("/material-categories", { + slug: norm(m.category) || "cat", + name: m.category, + is_active: true, + }); + categoryId = cat.id; + lookup.categoryNameToId.set(norm(m.category), cat.id); + } + if (!categoryId) { + throw new Error(`متریال «${m.category}» در بکاند تعریف نشده است`); + } + // واحد مرجع — اگر تعریف نشده، بساز (بکاند canonical_unit_id الزامی دارد) + const unitName = m.base_unit || "عدد"; + const unitId = await unitIdByNameOrCreate(lookup, unitName); + // برند — اگر تعریف نشده، در جدول brands ساخته و به متریال متصل میشود + const brandId = await brandIdByNameOrCreate(lookup, m.brand); + // تایپ جلوی برنامه name ندارد — از متریال/برند/مشخصه ساخته میشود + const name = [m.brand, m.category, ...(m.specs || [])].filter(Boolean).join(" ") || "کالای جدید"; + // مشخصهها بهصورت attribute (EAV) ذخیره میشوند تا بعد از رفرش دیده شوند + const attrs: { attribute_definition_id: string; value: string }[] = []; + const seen = new Set (); + for (const spec of m.specs || []) { + const key = norm(spec); + if (!key || seen.has(key)) continue; + seen.add(key); + const defId = await specDefinitionId(spec); + if (defId) attrs.push({ attribute_definition_id: defId, value: spec }); + } + const raw = await postData ("/materials", { + category_id: categoryId, + name, + topic: m.topic || null, + canonical_unit_id: unitId, + status: "active", + brand_ids: brandId ? [brandId] : [], + attributes: attrs, + }); + // ویزارد با id موقت به قلم ارجاع میدهد — ثبت نگاشت برای ساخت پیشنهاد + tempIdToRealId.set(m.id, raw.id); + return adaptMaterial(raw, lookup); + }, + }, + + projects: { + list: async (): Promise => { + const raw = await getData (`/projects?page_size=${PAGE_SIZE}`); + return raw.map((p) => ({ id: p.id, name: p.name })); + }, + create: async (p: Project): Promise => { + const raw = await postData ("/projects", { name: p.name }); + return { id: raw.id, name: raw.name }; + }, + }, + + units: { + getCategoryUnits: async (): Promise => { + const raw = await getData (`/category-reference-units?expand=category&page_size=${PAGE_SIZE}`); + return raw.map(adaptCategoryUnit); + }, + saveCategoryUnit: async (cu: CategoryUnit): Promise => { + const lookup = await fetchLookup(); + const categoryId = lookup.categoryNameToId.get(norm(cu.category)); + if (!categoryId) throw new Error(`دستهبندی «${cu.category}» یافت نشد`); + await putData(`/category-reference-units/${categoryId}`, { ref_unit: cu.ref_unit }); + }, + getConversions: async (): Promise => { + const lookup = await fetchLookup(); + const raw = await getData (`/unit-conversions?expand=category&page_size=${PAGE_SIZE}`); + return raw.map((uc) => adaptUnitConversion(uc, lookup)); + }, + saveConversion: async (c: UnitConversion): Promise => { + const lookup = await fetchLookup(); + const categoryId = lookup.categoryNameToId.get(norm(c.category)); + if (!categoryId) throw new Error(`دستهبندی «${c.category}» یافت نشد`); + const body = { + category_id: categoryId, + brand: c.brand || null, + from_unit: c.from_unit, + to_unit: c.to_unit, + factor: c.factor, + }; + if (looksLikeUuid(c.id)) { + await patchData(`/unit-conversions/${c.id}`, { + brand: c.brand || null, + from_unit: c.from_unit, + to_unit: c.to_unit, + factor: c.factor, + }); + } else { + await postData("/unit-conversions", body); + } + }, + deleteConversion: async (id: string): Promise => { + await deleteData(`/unit-conversions/${id}`); + }, + getStandardConversions: async (): Promise => { + const raw = await getData (`/standard-conversions?page_size=${PAGE_SIZE}`); + return raw.map(adaptStandardConversion); + }, + saveStandardConversion: async (c: StandardConversion): Promise => { + const body = { from_unit: c.from_unit, to_unit: c.to_unit, factor: c.factor }; + if (looksLikeUuid(c.id)) { + await patchData(`/standard-conversions/${c.id}`, body); + } else { + await postData("/standard-conversions", body); + } + }, + deleteStandardConversion: async (id: string): Promise => { + await deleteData(`/standard-conversions/${id}`); + }, + }, + + // ---------- پیوستها (invoice / proforma) — بایتها در MinIO ---------- + files: { + upload: (quoteId: string, kind: FileKind, file: File): Promise => + uploadFile(`/quotes/${quoteId}/${kind}`, file), + list: async (quoteId: string, kind: FileKind): Promise => { + const raw = await getData (`/quotes/${quoteId}/${kind}`); + return raw.map(adaptAttachment); + }, + remove: async (quoteId: string, kind: FileKind, id: string): Promise => { + await deleteData(`/quotes/${quoteId}/${kind}/${id}`); + }, + downloadUrl: (quoteId: string, kind: FileKind, id: string): string => + `${BASE_URL}/quotes/${quoteId}/${kind}/${id}`, + }, + + // ---------- برندها (از بکاند) ---------- + brands: { + list: async (): Promise<{ id: string; name: string }[]> => { + const raw = await getData<{ id: string; name: string }[]>(`/brands?page_size=${PAGE_SIZE}`); + return raw.map((b) => ({ id: b.id, name: b.name })); + }, + create: async (name: string): Promise<{ id: string; name: string }> => { + const raw = await postData<{ id: string; name: string }>("/brands", { name, country: null }); + return { id: raw.id, name: raw.name }; + }, + }, + brandsList: async (): Promise<{ id: string; name: string }[]> => { + return getData<{ id: string; name: string }[]>(`/brands?page_size=${PAGE_SIZE}`); + }, + + // ---------- واحدها (از بکاند) ---------- + unitsList: async (): Promise => { + return getData (`/units?page_size=${PAGE_SIZE}`); + }, + + // بکاند endpoint بازنشانی ندارد — دادهها دوباره از سرور خوانده میشوند + reset: () => api.listAll(), +}; + +// ============================================================ +// Helpers داخلی +// ============================================================ + +async function fetchLookup(): Promise { + const [units, categories] = await Promise.all([ + getData (`/units?page_size=${PAGE_SIZE}`), + getData (`/material-categories?page_size=${PAGE_SIZE}`), + ]); + return buildLookup(units, categories); +} + +async function fetchProjects(): Promise { + return getData (`/projects?page_size=${PAGE_SIZE}`); +} + +// پیمودن پروژهها: پیدا کردن id پروژه؛ اگر نبود، در بکاند میسازد +async function projectIdByNameOrCreate(name: string | null | undefined): Promise { + if (!name) return null; + const projects = await fetchProjects(); + const pid = projectIdByName(projects, name); + if (pid) return pid; + const raw = await postData ("/projects", { name }); + return raw.id; +} + +// پیمودن اقلام/termsheetها برای PATCH /quotes/{id}/children (جایگزینی کامل) +async function buildChildrenPayload( + lineItems: Quote["line_items"][number][], + termsheets: Quote["termsheets"][number][], +): Promise<{ line_items: unknown[]; termsheets: unknown[]; item_termsheets: unknown[] }> { + const lookup = await fetchLookup(); + const lis: { temp_id: string; material_id: string; unit_id: number; quantity: number; project_ids: string[] }[] = + lineItems.map((li) => ({ + temp_id: li.id, + material_id: realMaterialId(li.material_id), + unit_id: 0, // placeholder — بعداً resolve میشود + quantity: li.quantity ?? 1, + project_ids: [], + })); + for (let i = 0; i < lis.length; i++) { + lis[i].unit_id = await unitIdByNameOrCreate(lookup, lineItems[i].unit || "عدد"); + for (const p of lineItems[i].projects || []) { + const pid = looksLikeUuid(p.id) ? p.id : await projectIdByNameOrCreate(p.name); + if (pid) lis[i].project_ids.push(pid); + } + } + const tss = termsheets.map((t) => ({ + temp_id: t.id, + label: t.label, + discount_type: "none", + discount_value: 0, + schedule_steps: (t.milestones || []).map((m, i) => ({ + step_no: i + 1, + due_date: toDateOnly(m.when_iso) || todayIso(), + amount: m.amount ?? 0, + })), + })); + const itemTerms = lineItems.flatMap((li) => + li.unit_price != null && li.unit_price > 0 + ? (li.termsheet_ids || []).map((tsId) => ({ + line_item_temp_id: li.id, + termsheet_temp_id: tsId, + unit_price: li.unit_price!, + })) + : [], + ); + return { line_items: lis, termsheets: tss, item_termsheets: itemTerms }; +} + +// واحد را پیدا میکند؛ اگر وجود نداشت، در بکاند میسازد و id برمیگرداند +async function unitIdByNameOrCreate(lookup: Lookup, name: string): Promise { + if (!name) throw new Error("واحد قلم مشخص نشده است"); + const existing = lookup.unitNameToId.get(norm(name)); + if (existing != null) return existing; + // واحد جدید — ساخت در دیتابیس (code = نام نرمالشده) + const raw = await postData ("/units", { + code: norm(name) || "unit", + name_fa: name, + dimension: null, + si_factor: 1, + is_base: false, + }); + lookup.unitNameToId.set(norm(name), raw.id); + return raw.id; +} + +// برند: پیدا کردن برند موجود (در حافظه یا بکاند) و اگر نبود، ساخت آن +// تا material با brand_ids واقعی ساخته شود و بعد از رفرش برند دیده شود +async function brandIdByNameOrCreate(lookup: Lookup, name: string): Promise { + if (!name) return null; + const key = norm(name); + const cached = lookup.brandNameToId.get(key); + if (cached) return cached; + // جستجو در بکاند (ممکن است برند قبلاً ساخته شده باشد) + const found = await getData<{ id: string; name: string }[]>(`/brands?q=${encodeURIComponent(name)}&page_size=100`); + const hit = found.find((b) => norm(b.name) === key); + if (hit) { + lookup.brandNameToId.set(key, hit.id); + return hit.id; + } + const raw = await postData<{ id: string; name: string }>("/brands", { name, country: null }); + lookup.brandNameToId.set(key, raw.id); + return raw.id; +} + +function projectIdByName(projects: RawProject[], name: string | undefined | null): string | null { + if (!name) return null; + const p = projects.find((x) => norm(x.name) === norm(name)); + return p ? p.id : null; +} + +// --------------------------------------------------------------------------- +// مشخصهها (specs) — هر مشخصه یک attribute definition (EAV) است +// --------------------------------------------------------------------------- + +const specDefCache = new Map (); // norm(spec) → attribute_definition_id + +// پیدا کردن/ساخت attribute definition برای یک مشخصهی متنی +async function specDefinitionId(spec: string): Promise { + const key = norm(spec); + if (!key) return null; + const cached = specDefCache.get(key); + if (cached) return cached; + try { + const raw = await postData<{ id: string }>("/attribute-definitions", { + attribute_key: "spec_" + key.slice(0, 60), + label: spec, + data_type: "string", + }); + specDefCache.set(key, raw.id); + return raw.id; + } catch { + // تکراری یا ناسازگار — با برچسب جستجو کن + const list = await getData<{ id: string; label: string }[]>( + `/attribute-definitions?q=${encodeURIComponent(spec)}&page_size=20`, + ); + const hit = list.find((d) => d.label === spec); + if (hit) { + specDefCache.set(key, hit.id); + return hit.id; + } + return null; + } +} + +// utility برای ساخت id موقت جدید (ویزارد — قبل از ذخیره در بکاند) +export function nextId(prefix: string, items: { id: string }[]): string { + const max = items + .map((x) => parseInt(String(x.id).replace(/\D/g, ""), 10)) + .filter((n) => !Number.isNaN(n)) + .reduce((a, b) => Math.max(a, b), 0); + return prefix + (max + 1); +} + +// id ترتیبی با خط تیره، مثلاً "li-3", "ts-4" +export function nextDashedId(prefix: string, items: { id: string }[]): string { + const max = items + .map((x) => { + const m = String(x.id).match(new RegExp("^" + prefix + "-(\\d+)$")); + return m ? parseInt(m[1], 10) : 0; + }) + .reduce((a, b) => Math.max(a, b), 0); + return prefix + "-" + (max + 1); +} + +// helper برای خواندن فایل به صورت dataUrl — حذف شد: آپلودها مستقیم به +// سرور (MinIO) میروند؛ بایتها هرگز در حافظهی مرورگر نمیمانند. + +// kind پیوست: فاکتور یا پیشفاکتور +export type FileKind = "invoice" | "proforma"; + +// ============================================================ +// v2.6 — inventory + PM arrival API (React port of the shipped dash) +// ============================================================ + +const rawWarehouse = (w: { + id: string; project_id: string; name: string; description: string | null; + project?: { id: string; name: string } | null; items_count?: number; +}): Warehouse => ({ + id: w.id, + project_id: w.project_id, + name: w.name, + description: w.description, + project: w.project ?? null, + items_count: w.items_count, +}); + +const rawInventory = (r: { + id: string; + warehouse: { id: string; project_id: string; name: string; description: string | null }; + material: { id: string; name: string; category: { id: string; name: string } | null }; + brand: { id: string; name: string } | null; + quantity: string; + quantity_number: number; + unit: { id: number; code: string; name_fa: string | null } | null; + specs: Record ; + updated_at: string; +}): InventoryRow => ({ + id: r.id, + warehouse: { + id: r.warehouse.id, + project_id: r.warehouse.project_id, + name: r.warehouse.name, + description: r.warehouse.description, + }, + material: r.material, + brand: r.brand, + quantity: r.quantity, + quantity_number: r.quantity_number, + unit: r.unit, + specs: r.specs, + updated_at: r.updated_at, +}); + +// Lookup tables for the inventory dash (thin wrappers over existing endpoints). +interface RawCat { id: string; slug: string; name: string; } +interface RawUnitL { id: number; code: string; name_fa: string | null; } + +export const apiV26 = { + lookup: { + categories: async (): Promise => getData ("/material-categories?page_size=100"), + units: async (): Promise => getData ("/units?page_size=100"), + brands: async (): Promise<{ id: string; name: string }[]> => getData<{ id: string; name: string }[]>("/brands?page_size=100"), + materials: async (): Promise<{ id: string; name: string; category: { id: string; name: string } | null; canonical_unit: { id: number; code: string; name_fa: string | null } | null }[]> => + getData("/materials?expand=category,canonical_unit&page_size=100"), + projects: async (): Promise<{ id: string; name: string; pm_user_id: string | null; pm_name?: string | null }[]> => + getData("/projects?page_size=100"), + }, + + users: { + me: async (): Promise => { + const data = await getData<{ + id: string; casdoor_id: string; first_name: string; last_name: string; phone: string | null; + role: "pm" | "procurement_manager" | "admin" | null; role_label: string | null; + project_id: string | null; project_name: string | null; + }>("/users/me"); + return { + id: data.id, + casdoor_id: data.casdoor_id, + first_name: data.first_name, + last_name: data.last_name, + phone: data.phone, + role: data.role, + role_label: data.role_label, + project_id: data.project_id, + project_name: data.project_name, + display_name: `${data.first_name} ${data.last_name}`.trim() || data.casdoor_id, + }; + }, + }, + + warehouses: { + list: async (projectId?: string): Promise => { + const qs = projectId ? `?project_id=${projectId}&page_size=100` : "?page_size=100"; + const data = await getData [0][]>(`/inventory/warehouses${qs}`); + return data.map(rawWarehouse); + }, + create: async (body: { project_id: string; name: string; description?: string | null }): Promise => { + const d = await postData [0]>("/inventory/warehouses", body); + return rawWarehouse(d); + }, + update: async (id: string, body: { name?: string; description?: string | null; project_id?: string }): Promise => { + const d = await patchData [0]>(`/inventory/warehouses/${id}`, body); + return rawWarehouse(d); + }, + delete: async (id: string): Promise => { + await deleteData(`/inventory/warehouses/${id}`); + }, + }, + + inventory: { + list: async (params: { project_id?: string; category_id?: string; warehouse_id?: string; material_id?: string; sort?: string } = {}): Promise => { + const qs = new URLSearchParams({ page_size: "100", ...(params as Record ) }).toString(); + const data = await getData [0][]>(`/inventory?${qs}`); + return data.map(rawInventory); + }, + transactions: async (params: { project_id?: string; warehouse_id?: string; transaction_type?: string; date_from?: string; date_to?: string; sort?: string } = {}): Promise => { + const qs = new URLSearchParams({ page_size: "100", ...(params as Record ) }).toString(); + return getData (`/inventory/transactions?${qs}`); + }, + createTransaction: async (body: { + transaction_type: TransactionType; warehouse_id: string; dest_warehouse_id?: string | null; + material_id: string; brand_id?: string | null; quantity?: number | null; + physical_qty?: number | null; unit_id?: number | null; specs?: Record | null; + note?: string | null; transaction_date?: string | null; + }): Promise => { + return postData ("/inventory/transactions", body); + }, + types: async (): Promise<{ type: TransactionType; label: string }[]> => { + return getData<{ type: TransactionType; label: string }[]>("/inventory/meta/types"); + }, + materialOptions: async (category_id: string, warehouse_id?: string): Promise<{ id: string; name: string; brands: { id: string; name: string }[] }[]> => { + const qs = warehouse_id ? `?category_id=${category_id}&warehouse_id=${warehouse_id}` : `?category_id=${category_id}`; + return getData<{ id: string; name: string; brands: { id: string; name: string }[] }[]>(`/inventory/materials/options${qs}`); + }, + }, + + pm: { + onTheWay: async (): Promise => getData ("/pm/on-the-way"), + confirmReceipt: async (body: { + item_decision_id: string; received_qty: number; + allocations: { warehouse_id: string; quantity: number }[]; + received_at?: string | null; on_time?: boolean | null; quality_note?: string | null; + }): Promise<{ receipt_id: string; state: string; posted_transactions: string[] }> => + postData("/pm/receipts", body), + receipts: async (): Promise => getData ("/pm/receipts"), + }, +}; diff --git a/frontend/src/lib/casdoor.ts b/frontend/src/lib/casdoor.ts new file mode 100644 index 0000000..38b2348 --- /dev/null +++ b/frontend/src/lib/casdoor.ts @@ -0,0 +1,79 @@ +// ============================================================ +// Casdoor OIDC + PKCE login — v2.6 +// Config lives in /casdoor-config.js (window.CASDOOR_CONFIG), served +// statically next to index.html (same values as the shipped dash). +// ============================================================ + +const CASDOOR_CONFIG = (window as unknown as { CASDOOR_CONFIG?: CasdoorConfig }).CASDOOR_CONFIG; + +export interface CasdoorConfig { + serverUrl: string; + clientId: string; + clientSecret: string; + organization: string; + appName: string; + redirectUri: string; +} + +function b64url(bytes: Uint8Array): string { + let s = ""; + for (const b of bytes) s += String.fromCharCode(b); + return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +function randomBytes(n: number): Uint8Array { + const arr = new Uint8Array(n); + crypto.getRandomValues(arr); + return arr; +} + +async function sha256b64url(text: string): Promise { + const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)); + return b64url(new Uint8Array(buf)); +} + +export function startCasdoorLogin(): void { + if (!CASDOOR_CONFIG) { + alert("تنظیمات Casdoor یافت نشد — فایل casdoor-config.js را بررسی کنید"); + return; + } + const verifier = b64url(randomBytes(64)); + const state = b64url(randomBytes(16)); + sessionStorage.setItem("casdoor-pkce", JSON.stringify({ verifier, state })); + void sha256b64url(verifier).then((challenge) => { + const params = new URLSearchParams({ + client_id: CASDOOR_CONFIG.clientId, + redirect_uri: CASDOOR_CONFIG.redirectUri, + response_type: "code", + scope: "read", + state, + organization: CASDOOR_CONFIG.organization, + application: CASDOOR_CONFIG.appName, + code_challenge: challenge, + code_challenge_method: "S256", + }); + window.location.href = `${CASDOOR_CONFIG.serverUrl}/login/oauth/authorize?${params.toString()}`; + }); +} + +/** Exchange the OIDC authorization code for a token (called on /callback). */ +export async function exchangeCodeForToken(code: string): Promise<{ access_token: string; id_token?: string }> { + if (!CASDOOR_CONFIG) throw new Error("تنظیمات Casdoor یافت نشد"); + const pkce = JSON.parse(sessionStorage.getItem("casdoor-pkce") || "{}") as { verifier?: string }; + const resp = await fetch(`${CASDOOR_CONFIG.serverUrl}/api/login/oauth/access_token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: CASDOOR_CONFIG.clientId, + client_secret: CASDOOR_CONFIG.clientSecret, + code, + redirect_uri: CASDOOR_CONFIG.redirectUri, + code_verifier: pkce.verifier || "", + }), + }); + if (!resp.ok) throw new Error("خطا در دریافت توکن از Casdoor"); + return resp.json(); +} + +export { CASDOOR_CONFIG }; diff --git a/frontend/src/lib/constants.ts b/frontend/src/lib/constants.ts new file mode 100644 index 0000000..9384bdb --- /dev/null +++ b/frontend/src/lib/constants.ts @@ -0,0 +1,52 @@ +// ============================================================ +// ثابتهای سراسری +// ============================================================ + +import { normalize } from "./normalize"; + +export interface Topic { + key: string; + name: string; + icon: string; +} + +// ۸ تاپیک ثابت — HVAC و لولهکشی ادغام شدند؛ ابزار و تجهیزات ساخت اضافه شد +export const TOPICS: Topic[] = [ + { key: "hvac-plumb", name: "تاسیسات مکانیکی و آبی", icon: "/icons/topic-1.png" }, + { key: "electric", name: "تجهیزات و لوازم برقی", icon: "/icons/topic-2.png" }, + { key: "door-win", name: "درب، پنجره و شیشه", icon: "/icons/topic-3.png" }, + { key: "metal", name: "فلزات و مقاطع فولادی", icon: "/icons/topic-4.png" }, + { key: "build-mat", name: "مصالح ساختمانی", icon: "/icons/topic-6.png" }, + { key: "chem", name: "مواد شیمیایی ساختمانی", icon: "/icons/topic-7.png" }, + { key: "finish", name: "نازککاری و دکوراسیون داخلی", icon: "/icons/topic-8.png" }, + { key: "tools", name: "ابزار و تجهیزات ساخت", icon: "/icons/topic-9.png" }, +]; + +function topicEq(a: string, b: string): boolean { + return normalize(a) === normalize(b); +} + +// یافتن تاپیک بر اساس نام (نرمالشده) +export function topicByName(name: string | null | undefined): Topic | null { + if (!name) return null; + return TOPICS.find((t) => topicEq(t.name, name)) || null; +} + +export const STATUS_LABEL: Record = { + offered: "پیشنهاد شده", + accepted: "پذیرفته شده", + arrived: "رسیده", + rejected: "رد شده", + expired: "منقضی شده", +}; + +export const STATUS_ORDER = ["offered", "accepted", "arrived"]; + +// در ویرایش، وضعیت فقط میتواند به مراحل قبلی برگردد +export function statusOptionsFor(current: string): string[] { + const idx = STATUS_ORDER.indexOf(current); + return idx < 0 ? STATUS_ORDER : STATUS_ORDER.slice(0, idx + 1); +} + +// واحدهای شناختهشده از بکاند خوانده میشوند — این لیست حذف شد +// (واحدها فقط در دیتابیس تعریف میشوند و واحد جدید هنگام تایپ ساخته میشود) diff --git a/frontend/src/lib/formatters.ts b/frontend/src/lib/formatters.ts new file mode 100644 index 0000000..9cdfb5a --- /dev/null +++ b/frontend/src/lib/formatters.ts @@ -0,0 +1,68 @@ +// ============================================================ +// فرمتکنندههای نمایشی +// ============================================================ + +import { parseNum, groupCommas, toWords } from "./numutil"; +import { isoToJalaliStr } from "./jalali"; + +// عدد → "۵۲٬۸۰۰" یا "—" برای null +export function fmtNum(n: unknown): string { + if (n == null || n === "") return "—"; + const num = parseNum(n); + if (num == null) return "—"; + return groupCommas(num, true); +} + +// ISO → تاریخ شمسی یا "—" +export function fmtDate(iso: string | null | undefined): string { + if (!iso) return "—"; + const j = isoToJalaliStr(iso); + if (j) return j; + try { + return new Date(iso).toLocaleDateString("fa-IR"); + } catch { + return iso; + } +} + +// ISO → تاریخ شمسی + ساعت:دقیقه (مثلاً «۱۴۰۵/۰۴/۲۹ ۲۲:۱۵») +export function fmtDateTime(iso: string | null | undefined): string { + if (!iso) return "—"; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return fmtDate(iso); + const j = isoToJalaliStr(iso); + const hh = String(d.getHours()).padStart(2, "0"); + const mm = String(d.getMinutes()).padStart(2, "0"); + return `${j} ${faNum(hh + ":" + mm)}`; +} + +// HTML escape (برای جاهایی که dangerouslySetInnerHTML لازم است) +export function esc(s: unknown): string { + return String(s == null ? "" : s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +// ارقام انگلیسی → فارسی +export function faNum(n: unknown): string { + return String(n).replace(/\d/g, (d) => "۰۱۲۳۴۵۶۷۸۹"[Number(d)]); +} + +// نمایش قیمت با کلمات +export function priceCell(v: unknown): { num: string; words: string } | null { + if (v == null || v === "") return null; + const n = parseNum(v); + if (n == null) return null; + return { num: groupCommas(n, true), words: toWords(n) }; +} + +// فاصلهی روز بین دو تاریخ ISO +export function daysBetween(aIso: string | null, bIso: string | null): number | null { + if (!aIso || !bIso) return null; + const ms = new Date(aIso).getTime() - new Date(bIso).getTime(); + if (Number.isNaN(ms)) return null; + return Math.round(ms / 86400000); +} diff --git a/frontend/src/lib/jalali.ts b/frontend/src/lib/jalali.ts new file mode 100644 index 0000000..1a17af4 --- /dev/null +++ b/frontend/src/lib/jalali.ts @@ -0,0 +1,141 @@ +// ============================================================ +// ابزار تقویم شمسی — پورت jalali.js به TypeScript +// تبدیلها با الگوریتم استاندارد Kazimierz M. Borkowski +// ============================================================ + +const FA_DIGITS = ["۰", "۱", "۲", "۳", "۴", "۵", "۶", "۷", "۸", "۹"]; + +export function div(a: number, b: number): number { + return Math.floor(a / b); +} + +export function jalaliToGregorian(jy: number, jm: number, jd: number): [number, number, number] { + jy = +jy; + jm = +jm; + jd = +jd; + let gy = jy <= 979 ? 621 : 1600; + jy -= jy <= 979 ? 0 : 979; + let days = + 365 * jy + + div(jy, 33) * 8 + + div((jy % 33) + 3, 4) + + 78 + + jd + + (jm < 7 ? (jm - 1) * 31 : (jm - 7) * 30 + 186); + gy += 400 * div(days, 146097); + days %= 146097; + if (days > 36524) { + gy += 100 * div(--days, 36524); + if (days === 36524) days = 0; + days++; + } + gy += 4 * div(days, 1461); + days %= 1461; + if (days > 365) { + gy += div(days - 1, 365); + days = (days - 1) % 365; + } + let gd = days + 1; + const sal_a = [0, 31, (gy % 4 === 0 && gy % 100 !== 0) || gy % 400 === 0 ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + let gm = 0; + for (; gm < 13 && gd > sal_a[gm]; gm++) gd -= sal_a[gm]; + return [gy, gm, gd]; +} + +export function gregorianToJalali(gy: number, gm: number, gd: number): [number, number, number] { + gy = +gy; + gm = +gm; + gd = +gd; + const g_d_m = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334]; + let jy = gy <= 1600 ? 0 : 979; + gy -= gy <= 1600 ? 621 : 1600; + let days = + 365 * gy + + div(gy + 3, 4) - + div(gy + 99, 100) + + div(gy + 399, 400) - + 80 + + gd + + g_d_m[gm - 1]; + jy += 33 * div(days, 12053); + days %= 12053; + jy += 4 * div(days, 1461); + days %= 1461; + if (days > 365) { + jy += div(days - 1, 365); + days = (days - 1) % 365; + } + const jm = days < 186 ? 1 + div(days, 31) : 7 + div(days - 186, 30); + const jd = 1 + (days < 186 ? days % 31 : (days - 186) % 30); + return [jy, jm, jd]; +} + +export function isLeapJalali(jy: number): boolean { + const breaks = [-61, 9, 38, 199, 426, 686, 756, 818, 1111, 1181, 1210, 1635, 2060, 2097, 2192, 2262, 2324, 2394, 2456, 3178]; + const jp = breaks[0]; + const jm = breaks[1]; + let jump = 0; + let jp2 = jp; + for (const jl of breaks) { + jump = jl - jp2; + if (jy <= jl) break; + jp2 = jl; + } + let n = jy - jm; + if (n < jump) n = n + jump + 1; + return (n % 33) % 4 === 1; +} + +// ---------- formatting (Persian digits) ---------- +export function toFa(s: string | number): string { + return String(s).replace(/[0-9]/g, (d) => FA_DIGITS[Number(d)]); +} + +// ماههای شمسی (نسخهی درستشده — «مهر» بهجای «آذر» تکراری) +export const MONTHS = [ + "فروردین", "اردیبهشت", "خرداد", "تیر", "مرداد", "شهریور", + "مهر", "آبان", "آذر", "دی", "بهمن", "اسفند", +]; + +export const WEEKDAYS = ["ش", "ی", "د", "س", "چ", "پ", "ج"]; + +function toEnSafe(s: string): string { + return String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))); +} + +// format an ISO (gregorian) string to "۱۴۰۳/۰۵/۱۲" +export function isoToJalaliStr(iso: string | null | undefined): string { + if (!iso) return ""; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ""; + const [jy, jm, jd] = gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate()); + return `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))}`; +} + +// format with month name: "۱۸ مرداد ۱۴۰۴" +export function isoToJalaliLong(iso: string | null | undefined): string { + if (!iso) return ""; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ""; + const [jy, jm, jd] = gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate()); + return `${toFa(jd)} ${MONTHS[jm - 1]} ${toFa(jy)}`; +} + +// parse a jalali "yyyy/m/d" string to a JS Date (gregorian) at local midnight +export function jalaliStrToIso(str: string | null | undefined): string | null { + if (!str) return null; + const nums = String(str) + .split(/[/\-.]/) + .map((x) => parseInt(toEnSafe(x), 10)); + if (nums.length < 3 || nums.some((n) => Number.isNaN(n))) return null; + const [jy, jm, jd] = nums; + const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd); + const dt = new Date(gy, gm - 1, gd); + return Number.isNaN(dt.getTime()) ? null : dt.toISOString(); +} + +export function daysInMonth(jy: number, jm: number): number { + if (jm <= 6) return 31; + if (jm <= 11) return 30; + return isLeapJalali(jy) ? 30 : 29; +} diff --git a/frontend/src/lib/normalize.ts b/frontend/src/lib/normalize.ts new file mode 100644 index 0000000..3f5dd1a --- /dev/null +++ b/frontend/src/lib/normalize.ts @@ -0,0 +1,19 @@ +// ============================================================ +// نرمالسازی متن برای جستجو/مقایسهی بخشنده +// ============================================================ + +const FA_DIGITS = ["۰", "۱", "۲", "۳", "۴", "۵", "۶", "۷", "۸", "۹"]; + +function toEn(s: string): string { + return String(s).replace(/[۰-۹]/g, (d) => String(FA_DIGITS.indexOf(d))); +} + +// trim, lowercase, یکسانسازی ی/ک فارسی و عربی، حذف zero-width non-joiner +export function normalize(s: unknown): string { + return toEn(String(s == null ? "" : s)) + .replace(/\u200c/g, "") + .trim() + .toLowerCase() + .replace(/ي/g, "ی") + .replace(/ك/g, "ک"); +} diff --git a/frontend/src/lib/numutil.ts b/frontend/src/lib/numutil.ts new file mode 100644 index 0000000..4650279 --- /dev/null +++ b/frontend/src/lib/numutil.ts @@ -0,0 +1,103 @@ +// ============================================================ +// ابزار اعداد فارسی — پورت numutil.js به TypeScript +// ============================================================ + +const FA_DIGITS = ["۰", "۱", "۲", "۳", "۴", "۵", "۶", "۷", "۸", "۹"]; + +export function toFa(s: string | number): string { + return String(s).replace(/[0-9]/g, (d) => FA_DIGITS[Number(d)]); +} + +export function toEn(s: string | number): string { + return String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))); +} + +// strip commas / spaces / Persian digits and parse as a clean number +export function parseNum(v: unknown): number | null { + if (v == null) return null; + const cleaned = toEn(String(v)).replace(/[,،\s]/g, ""); + if (cleaned === "") return null; + const n = Number(cleaned); + return Number.isFinite(n) ? n : null; +} + +// groupCommas with full decimal support (up to 4 decimal places) +export function groupCommas(n: number | string | null | undefined, fa = false): string { + if (n == null || (typeof n !== "number" && parseNum(n) == null)) return ""; + const num = typeof n === "number" ? n : (parseNum(n) as number); + const sep = fa ? "٬" : ","; + const parts = num.toString().split("."); + const intPart = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, sep); + const decPart = parts.length > 1 ? "." + parts[1].slice(0, 4) : ""; + const out = intPart + decPart; + return fa ? toFa(out) : out; +} + +// ---------- Persian number to words ---------- +const ONES = ["", "یک", "دو", "سه", "چهار", "پنج", "شش", "هفت", "هشت", "نه"]; +const TEN = ["ده", "یازده", "دوازده", "سیزده", "چهارده", "پانزده", "شانزده", "هفده", "هجده", "نوزده"]; +const TENS = ["", "", "بیست", "سی", "چهل", "پنجاه", "شصت", "هفتاد", "هشتاد", "نود"]; +const HUNDREDS = ["", "صد", "دویست", "سیصد", "چهارصد", "پانصد", "ششصد", "هفتصد", "هشتصد", "نهصد"]; +const SCALES = [ + { n: 1, s: "" }, + { n: 1e3, s: " هزار" }, + { n: 1e6, s: " میلیون" }, + { n: 1e9, s: " میلیارد" }, + { n: 1e12, s: " تریلیون" }, +]; + +function threeDigitToWords(n: number): string { + // n: 0..999 + const parts: string[] = []; + const h = Math.floor(n / 100); + const rem = n % 100; + if (h) parts.push(HUNDREDS[h]); + if (rem >= 10 && rem < 20) { + parts.push(TEN[rem - 10]); + } else { + const t = Math.floor(rem / 10); + const o = rem % 10; + if (t) parts.push(TENS[t]); + if (o) parts.push(ONES[o]); + } + return parts.join(" و "); +} + +export function numToWordsFa(n: number): string { + n = Math.round(n); + if (n === 0) return "صفر"; + if (n < 0) return "منفی " + numToWordsFa(-n); + + // break into 3-digit groups, smallest first + const groups: number[] = []; + let w = n; + while (w > 0) { + groups.push(w % 1000); + w = Math.floor(w / 1000); + } + const words: string[] = []; + for (let i = groups.length - 1; i >= 0; i--) { + if (groups[i] === 0) continue; + const sc = SCALES[i] || { s: "" }; + words.push(threeDigitToWords(groups[i]) + sc.s); + } + return words.join(" و "); +} + +// "52800" -> "پنجاه و دو هزار و هشتصد" +export function toWords(v: unknown): string { + const n = parseNum(v); + if (n == null) return ""; + return numToWordsFa(n); +} + +// full price label: comma number + words in parentheses +// "52800" -> "۵۲٬۸۰۰ (پنجاه و دو هزار و هشتصد)" +export function priceLabel(v: unknown, unit?: string): string { + const n = parseNum(v); + if (n == null) return ""; + const num = groupCommas(n, true); + const words = toWords(n); + const suffix = unit ? ` ${unit}` : ""; + return `${num}${suffix} (${words})`; +} diff --git a/frontend/src/lib/quoteUtils.ts b/frontend/src/lib/quoteUtils.ts new file mode 100644 index 0000000..003eb3a --- /dev/null +++ b/frontend/src/lib/quoteUtils.ts @@ -0,0 +1,268 @@ +// ============================================================ +// منطق بیزنس مشترک پیشنهادها — پورت از app.js +// ============================================================ + +import type { LineItem, Material, Quote, StandardConversion, Store, Supplier, UnitConversion } from "./types"; +import { normalize } from "./normalize"; +import { groupCommas } from "./numutil"; +import { fmtDate } from "./formatters"; + +export function supplierById(suppliers: Supplier[], id: string): Supplier { + return suppliers.find((x) => x.id === id) || { id: "", name: "؟", location: "", topic: "", categories: [], phone: "", contact: "" }; +} + +export function materialById(materials: Material[], id: string): Material { + return ( + materials.find((x) => x.id === id) || { + id: "", + name: "", + topic: "", + category: "؟", + brand: "", + specs: [], + base_unit: "", + } + ); +} + +// spec chips یک material (دادههای قدیمی ممکن است spec رشتهای داشته باشند) +export function materialSpecs(m: Material | null | undefined): string[] { + return (m && m.specs) || []; +} + +// کلید متعارف یک مجموعه spec — ترتیب مهم نیست، متن نرمالشده +export function specsKey(specs: string[]): string { + return (specs || []).map((s) => normalize(s)).filter(Boolean).sort().join("|"); +} + +// specها با جداکنندهی « · » +export function specsLabel(specs: string[]): string { + return specs.join(" · "); +} + +// آرایهی termsheet_ids یک قلم (هر دو فرمت قدیم/جدید) +export function itemTermsheetIds(li: LineItem): string[] { + if (li.termsheet_ids) return li.termsheet_ids; + return []; +} + +// اقلام داخل معامله (ردشدهها نگه داشته میشوند ولی هرگز شمرده نمیشوند) +export function activeItems(q: Quote): LineItem[] { + return (q.line_items || []).filter((li) => !li.rejected); +} + +// جمع کل اقلام فعال (total_price اگر هست، وگرنه unit*qty) +export function quoteTotal(q: Quote): number { + return activeItems(q).reduce((sum, li) => { + const t = + li.total_price != null + ? li.total_price + : li.unit_price != null && li.quantity != null + ? li.unit_price * li.quantity + : 0; + return sum + (t || 0); + }, 0); +} + +// ترمشیت مؤثر: پذیرفتهشده، وگرنه اولین offered +export function effectiveTermsheet(q: Quote): Quote["termsheets"][number] | null { + if (!q.termsheets || !q.termsheets.length) return null; + return q.termsheets.find((t) => t.status === "accepted") || q.termsheets[0]; +} + +// id ترمشیتهایی که واقعاً توسط اقلام فعال ارجاع شدهاند +export function usedTermsheetIds(q: Quote): string[] { + const ids = new Set (); + activeItems(q).forEach((li) => { + itemTermsheetIds(li).forEach((tid) => { + if (tid) ids.add(tid); + }); + }); + return [...ids]; +} + +// مجموع هر قلم +export function lineItemTotal(li: LineItem): number | null { + if (li.total_price != null) return li.total_price; + if (li.unit_price != null && li.quantity != null) return li.unit_price * li.quantity; + return null; +} + +// پروژههای یک قلم — فقط پروژههای تخصیصیافته به همین قلم (بدون fallback به سطح پیشنهاد) +export function itemProjects(_q: Quote, li: LineItem): string[] { + return (li.projects || []).map((p) => (typeof p === "string" ? p : p.name)); +} + +// ---------- واحد مرجع و ضریب تبدیل ---------- + +// واحد مرجع یک دسته +export function refUnitForCategory(store: Pick , category: string): string { + const cu = (store.categoryUnits || []).find((c) => normalize(c.category) === normalize(category)); + return cu ? cu.ref_unit : ""; +} + +// ضریب تبدیل با اولویت: دستهای (برند مشخص > همه برندها) > تبدیل ثابت جهانی +export function findConversion( + store: Pick , + category: string, + brand: string, + fromUnit: string, + toUnit: string, +): UnitConversion | StandardConversion | null { + const list = (store.unitConversions || []).filter( + (c) => + normalize(c.category) === normalize(category) && + normalize(c.from_unit) === normalize(fromUnit) && + normalize(c.to_unit) === normalize(toUnit) && + (!c.brand || normalize(c.brand) === normalize(brand)), + ); + list.sort((a, b) => (b.brand ? 1 : 0) - (a.brand ? 1 : 0)); + if (list[0]) return list[0]; + return ( + (store.standardConversions || []).find( + (c) => normalize(c.from_unit) === normalize(fromUnit) && normalize(c.to_unit) === normalize(toUnit), + ) || null + ); +} + +// تبدیل یک قلم به واحد مرجع دسته؛ null وقتی ضریبی نیست یا واحد از قبل مرجع است +export function refConverted( + store: Pick , + li: LineItem, +): { qty: number; unit: string; perRef: number | null } | null { + const m = materialById(store.materials, li.material_id); + const ref = refUnitForCategory(store, m.category); + if (!ref || !li.unit || normalize(ref) === normalize(li.unit)) return null; + const conv = findConversion(store, m.category, m.brand, li.unit, ref); + if (!conv) return null; + const qty = (li.quantity || 0) * conv.factor; + const total = li.total_price != null ? li.total_price : (li.unit_price || 0) * (li.quantity || 0); + const perRef = qty > 0 ? Math.round(total / qty) : null; + return { qty, unit: ref, perRef }; +} + +// واحدهای ناشناختهی استفادهشده در یک quote → لینک به داشبورد واحدها +// واحدها دیگر hardcode نیستند — همه از بکاند میآیند؛ هشداری نمایش داده نمیشود +export function unitWarning(_store: Store, _q: Quote): { unit: string; cat: string }[] { + return []; +} + +// ستون پروژه +export function projectCell(q: Quote): string { + if (q.projects && q.projects.length) return q.projects.join("، "); + return q.project || "—"; +} + +// خلاصهی اقلام (ردیف جمعشده) +export function itemsSummary(store: Store, q: Quote): { text: string; count: number } { + const items = activeItems(q).map((li) => { + const m = materialById(store.materials, li.material_id); + const specs = materialSpecs(m); + return `${m.category} ${m.brand}${specs.length ? " " + specs.join(" · ") : ""}`; + }); + return { text: items.join("، "), count: items.length }; +} + +// ستون معامله — نمایش فشرده برای ردیف جمعشده +export function termsheetSummary(store: Store, q: Quote): { title: string; sub: string; color: string } { + if (!q.termsheets || !q.termsheets.length) return { title: "—", sub: "", color: "" }; + const used = usedTermsheetIds(q); + const accepted = q.termsheets.find((t) => t.status === "accepted"); + if (accepted) { + const items = activeItems(q).filter((li) => itemTermsheetIds(li).includes(accepted.id)); + const itemsDesc = + items.length === q.line_items.length + ? "همه اقلام" + : items + .map((li) => { + const m = materialById(store.materials, li.material_id); + const specText = specsLabel(materialSpecs(m)); + return specText || m.brand; + }) + .join("، "); + return { + title: accepted.label, + sub: `${itemsDesc} · ${accepted.milestones.length} مرحله`, + color: "var(--green)", + }; + } + if (used.length === 1) { + return { title: q.termsheets[0].label, sub: `${q.termsheets.length} گزینه`, color: "" }; + } + return { title: `${used.length} termsheet جدا · ${q.termsheets.length} گزینه`, sub: "", color: "" }; +} + +// فیلترهای دفتر +export function filterQuotes( + store: Store, + filters: { category: string; supplier: string; status: string; search: string }, +): Quote[] { + const catVal = filters.category.trim(); + const supVal = filters.supplier.trim(); + const stVal = filters.status.trim(); + const q = filters.search.trim().toLowerCase(); + + const STATUS_BY_LABEL: Record = { + "پیشنهاد شده": "offered", + "پذیرفته شده": "accepted", + "رسیده": "arrived", + }; + const stKey = STATUS_BY_LABEL[stVal] || ""; + + return store.quotes.filter((quote) => { + const supObj = supplierById(store.suppliers, quote.supplier_id); + if (supVal && supObj.name !== supVal) return false; + if (stKey && quote.status !== stKey) return false; + + const matInfo = activeItems(quote).map((li) => materialById(store.materials, li.material_id)); + if (catVal && !matInfo.some((m) => m.category === catVal)) return false; + + if (q) { + const hay = + `${supObj.name} ${quote.project || ""} ` + + matInfo.map((m) => `${m.category} ${m.brand} ${materialSpecs(m).join(" ")}`).join(" "); + if (!hay.toLowerCase().includes(q)) return false; + } + return true; + }); +} + +// وضعیت منقضی +export function isExpired(q: Quote): boolean { + return ( + q.status === "offered" && + !!q.expiry_date && + new Date(q.expiry_date) < new Date(new Date().toDateString()) + ); +} + +// آمادهسازی نمایش ترمشیت در حالت expanded +export function termSheetBlocks( + store: Store, + q: Quote, +): { ts: Quote["termsheets"][number]; isAccepted: boolean; isUsed: boolean; itemsDesc: string; milestones: string[] }[] { + const used = usedTermsheetIds(q); + const active = activeItems(q); + return q.termsheets.map((ts) => { + const isAccepted = ts.status === "accepted"; + const isUsed = used.includes(ts.id); + const items = active.filter((li) => itemTermsheetIds(li).includes(ts.id)); + const itemsDesc = + items.length === active.length + ? "همه اقلام" + : items.length + ? items + .map((li) => { + const m = materialById(store.materials, li.material_id); + return specsLabel(materialSpecs(m)) || m.brand; + }) + .join("، ") + : "بدون قلم"; + const milestones = ts.milestones.map((m) => { + const dateLabel = m.when_iso ? fmtDate(m.when_iso) : "?"; + const amt = m.amount != null ? groupCommas(m.amount, true) : "?"; + return `${dateLabel} · ${amt}`; + }); + return { ts, isAccepted, isUsed, itemsDesc, milestones }; + }); +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts new file mode 100644 index 0000000..52d95f6 --- /dev/null +++ b/frontend/src/lib/types.ts @@ -0,0 +1,203 @@ +// ============================================================ +// انواع دادهی برنامه — مدل پیشنهاد خرید +// (همان شکلهایی که بکاند واقعی برمیگرداند) +// ============================================================ + +// وضعیتهای یک پیشنهاد — بکاند rejected/expired را هم برمیگرداند +export type QuoteStatus = "offered" | "accepted" | "arrived" | "rejected" | "expired"; + +// تأمینکننده +export interface Supplier { + id: string; // "s1", "s2", ... + topic: string; // نام یکی از ۸ تاپیک + categories: string[]; // دستهها (مثلاً ["میلگرد", "پروفیل"]) + name: string; + location: string; + phone: string; + contact: string; // نام شخص تماس +} + +// ماده/کالا +export interface Material { + id: string; // "m1", "m2", ... + name: string; // نام کامل ذخیرهشده در بکاند (برند + دسته + مشخصه) + topic: string; + category: string; // مثلاً "میلگرد" + brand: string; // مثلاً "ذوب آهن" + specs: string[]; // آرایهای از مشخصهها (مثلاً ["A2"]) + base_unit: string; // واحد پایه +} + +// پروژه +export interface Project { + id: string; + name: string; +} + +// مرحلهی پرداخت +export interface Milestone { + when_iso: string | null; // تاریخ ISO + amount: number | null; // مبلغ +} + +// شرایط پرداخت (ترمشیت) +export interface Termsheet { + id: string; + label: string; // "گزینه ۱", "گزینه ۲", ... + milestones: Milestone[]; + status: "offered" | "accepted"; +} + +// فایل پیوست — روی سرور (MinIO) ذخیره میشود؛ داشتن id یعنی آپلود انجام شده +export interface Attachment { + id?: string; + name: string; + mime_type?: string; + size_bytes?: number; + uploaded_at?: string; +} + +// قلم سفارش +export interface LineItem { + id: string; + material_id: string; + quantity: number | null; + unit: string; + unit_price: number | null; + total_price: number | null; + termsheet_ids: string[]; // به کدوم ترمشیتها وصله + accepted_ts_id: string | null; // با کدوم ترمشیت پذیرش شده + rejected: boolean; // از معامله حذف شده؟ + arrived_date: string | null; // تاریخ رسید + projects?: { id: string; name: string }[]; // پروژههای تخصیصیافته به این قلم +} + +// پیشنهاد خرید (اصلیترین entity) +export interface Quote { + id: string; + supplier_id: string; + project: string; // legacy + projects: string[]; // آرایهی جدید + created_at?: string | null; // زمان ساخت (برای مرتبسازی) + quote_date: string; // ISO + expiry_date: string | null; // ISO, null = بدون انقضا + accepted_date: string | null; + arrived_date: string | null; + status: QuoteStatus; + is_official: boolean; + proformas: Attachment[]; // پیشفاکتورها — چند فایل + invoices: Attachment[]; // فاکتورهای اصلی — چند فایل + events: { type: string; occurred_at: string }[]; + notes: string; + line_items: LineItem[]; + termsheets: Termsheet[]; +} + +// واحد مرجع هر دسته +export interface CategoryUnit { + id: string; + category: string; + ref_unit: string; +} + +// ضریب تبدیل (دستهای) +export interface UnitConversion { + id: string; + category: string; + brand: string; // خالی = همه برندها + from_unit: string; + to_unit: string; + factor: number; // 1 from_unit = factor * to_unit +} + +// ضریب تبدیل استاندارد (جهانی) +export interface StandardConversion { + id: string; + from_unit: string; + to_unit: string; + factor: number; +} + +// کل store +export interface Store { + suppliers: Supplier[]; + materials: Material[]; + projects: Project[]; + quotes: Quote[]; + categoryUnits: CategoryUnit[]; + unitConversions: UnitConversion[]; + standardConversions: StandardConversion[]; +} + +// --------------------------------------------------------------------------- +// v2.6 — inventory domain (React port of the shipped dash) +// --------------------------------------------------------------------------- + +export interface Warehouse { + id: string; + project_id: string; + name: string; + description: string | null; + project?: { id: string; name: string } | null; + items_count?: number; + created_at?: string; +} + +export interface InventoryRow { + id: string; + warehouse: Warehouse; + material: { id: string; name: string; category: { id: string; name: string } | null }; + brand: { id: string; name: string } | null; + quantity: string; + quantity_number: number; + unit: { id: number; code: string; name_fa: string | null } | null; + specs: Record ; + updated_at: string; +} + +export type TransactionType = + | "PURCHASE" | "WITHDRAWAL" | "RETURN" | "TRANSFER_IN" | "TRANSFER_OUT" + | "DAMAGE" | "WASTE" | "LOSS" | "FOUND" | "STOCK_COUNT"; + +export interface InventoryTransaction { + id: string; + inventory_id: string; + warehouse_id: string; + warehouse?: { id: string; name: string } | null; + transaction_type: TransactionType; + quantity: string; + quantity_number: number; + recorded_qty?: number | null; + physical_qty?: number | null; + transfer_pair_id?: string | null; + performed_by?: string | null; + transaction_date: string; + note?: string | null; + material?: { id: string; name: string } | null; + brand?: { id: string; name: string } | null; + specs?: Record ; + unit?: { id: number; code: string; name_fa: string | null } | null; +} + +export interface PendingArrival { + item_decision_id: string; + line_item_id: string; + quote_id: string; + quote_status: string; + material: { id: string; name: string; category: { id: string; name: string } | null }; + unit: { id: number; code: string; name_fa: string | null } | null; + ordered_qty: string; + received_qty: string; + state: "on_the_way" | "partially_received" | "arrived"; +} + +export interface ConfirmedReceipt { + receipt_id: string; + line_item_id: string; + material: { id: string; name: string }; + received_at: string; + received_qty: string; + on_time: boolean | null; + quality_note: string | null; + allocations: { warehouse_id: string; warehouse_name: string; quantity: string }[]; +} diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 0000000..b500fb1 --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]): string { + return twMerge(clsx(inputs)); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..57ab0c4 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,27 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { Toaster } from "sonner"; +import App from "./App"; +import "./styles/globals.css"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + refetchOnWindowFocus: false, + }, + }, +}); + +createRoot(document.getElementById("root")!).render( + + , +); diff --git a/frontend/src/pages/Auth/LoginPage.tsx b/frontend/src/pages/Auth/LoginPage.tsx new file mode 100644 index 0000000..1767b6c --- /dev/null +++ b/frontend/src/pages/Auth/LoginPage.tsx @@ -0,0 +1,120 @@ +import { useEffect, useMemo, useState } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { toast } from "sonner"; +import { Button } from "../../components/ui/button"; +import { Header, PageLayout } from "../../components/layout/Header"; +import { startCasdoorLogin } from "../../lib/casdoor"; +import { api } from "../../lib/api"; +import { useAuthStore } from "../../store/auth"; +import type { Project } from "../../lib/types"; + +// ورود به سامانه — Casdoor (OIDC + PKCE) + ورود آزمایشی برای توسعه +export function LoginPage() { + const setToken = useAuthStore((s) => s.setToken); + const setUser = useAuthStore((s) => s.setUser); + const setDevUser = useAuthStore((s) => s.setDevUser); + const navigate = useNavigate(); + const [params] = useSearchParams(); + + // ورود آزمایشی: پروژهها از بکاند خوانده میشوند + const [projects, setProjects] = useState+ ++ ++ + ([]); + const [devRole, setDevRole] = useState<"pm" | "admin" | "procurement_manager">("pm"); + const [devProjectId, setDevProjectId] = useState(""); + + useEffect(() => { + void api.projects.list().then(setProjects).catch(() => setProjects([])); + }, []); + + const devProject = useMemo( + () => projects.find((p) => p.id === devProjectId) || null, + [projects, devProjectId], + ); + + // Callback: ?code=...&state=... + useEffect(() => { + const code = params.get("code"); + if (!code) return; + void (async () => { + try { + const { exchangeCodeForToken } = await import("../../lib/casdoor"); + const tok = await exchangeCodeForToken(code); + setToken(tok.access_token); + // fetch profile from our backend + const { apiV26 } = await import("../../lib/api"); + const profile = await apiV26.users.me(); + setUser(profile); + toast.success(`خوش آمدید، ${profile.display_name}`); + // مدیر پروژه → پنل انبار؛ بقیه → دفتر پیشنهادهای خرید + navigate(profile.role === "pm" ? "/inventory" : "/"); + } catch (err) { + toast.error(`خطا در ورود: ${err instanceof Error ? err.message : "خطای ناشناخته"}`); + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [params]); + + const devLogin = () => { + setDevUser(devRole, devRole === "pm" ? devProjectId || null : null, devProject?.name || null); + toast.success("ورود آزمایشی انجام شد — نقش: " + (devRole === "pm" ? "مدیر پروژه" : devRole === "procurement_manager" ? "مدیر تدارکات" : "ادمین")); + // مدیر پروژه → پنل انبار؛ مدیر تدارکات/ادمین → دفتر پیشنهادهای خرید + navigate(devRole === "pm" ? "/inventory" : "/"); + }; + + return ( + ++ ); +} diff --git a/frontend/src/pages/Dashboard/AcceptModal.tsx b/frontend/src/pages/Dashboard/AcceptModal.tsx new file mode 100644 index 0000000..33b6e57 --- /dev/null +++ b/frontend/src/pages/Dashboard/AcceptModal.tsx @@ -0,0 +1,438 @@ +import { useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; +import type { Attachment, Milestone, Quote } from "../../lib/types"; +import { api, nextDashedId } from "../../lib/api"; +import { useMaterials, useProjects, useQuotes, useUpdateQuote, useSuppliers } from "../../hooks"; +import { + activeItems, + itemTermsheetIds, + materialById, + materialSpecs, + specsLabel, + supplierById, +} from "../../lib/quoteUtils"; +import { faNum, fmtNum } from "../../lib/formatters"; +import { groupCommas } from "../../lib/numutil"; +import { Button } from "../../components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "../../components/ui/dialog"; +import { MilestoneRow } from "../../components/MilestoneRow"; +import { FileList } from "../../components/FileList"; +import { JalaliDatePicker } from "../../components/JalaliDatePicker"; +import { MultiSelectCombobox } from "../../components/MultiSelectCombobox"; + +interface CardDraft { + domId: string; + isNew: boolean; + milestones: Milestone[]; + label: string | null; // null = پیشفرض «گزینه N» +} + +export interface AcceptModalProps { + quoteId: string | null; + onClose: () => void; +} + +// مودال پذیرش: انتخاب/ویرایش شرایط پرداخت + تخصیص قلمها + فاکتورها +export function AcceptModal({ quoteId, onClose }: AcceptModalProps) { + const quotes = useQuotes(); + const suppliers = useSuppliers(); + const materials = useMaterials(); + const projects = useProjects(); + const updateQuote = useUpdateQuote(); + + const q = useMemo(() => quotes.find((x) => x.id === quoteId) || null, [quotes, quoteId]); + + const [cards, setCards] = useState+ + +++ +ورود با حساب سازمانی
++ برای ورود به سامانه تدارکات و انبار، با حساب سازمانی خود وارد شوید. پس از ورود، نقش شما + (مدیر پروژه / مدیر تدارکات / ادمین) مشخص و دسترسیها بر اساس آن فعال میشود. +
+ +++ورود آزمایشی (توسعه)
++ بدون Casdoor وارد شوید — نقش و پروژه از طریق هدرهای توسعه به بکاند ارسال میشود + (فقط برای توسعه و تست محلی). +
++++ + ++ {devRole === "pm" && ( ++ + ++ )} + +([]); + const [invoices, setInvoices] = useState ([]); + 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"); + setCards( + offered.map((t) => ({ domId: t.id, isNew: false, milestones: JSON.parse(JSON.stringify(t.milestones)), label: t.label })), + ); + const rem = new Set (); + q.line_items.forEach((li) => { + if (li.rejected) rem.add(li.id); + }); + setRemovedItems(rem); + setAssignments({}); + // پروژههای هر قلم — از تخصیصهای قبلی، وگرنه پروژههای سطح پیشنهاد + const projSel: Record = {}; + q.line_items.forEach((li) => { + const names = (li.projects || []).map((p) => p.name).filter(Boolean); + projSel[li.id] = names.length + ? names + : q.projects && q.projects.length + ? [...q.projects] + : q.project + ? [q.project] + : []; + }); + setItemProjectsSel(projSel); + setReady(true); + }, [q]); + + // پیوستها مستقیماً از سرور (MinIO) خوانده میشوند — نه از state محلی + useEffect(() => { + if (!quoteId) return; + let alive = true; + void api.files + .list(quoteId, "invoice") + .then((list) => alive && setInvoices(list)) + .catch(() => alive && setInvoices([])); + void api.files + .list(quoteId, "proforma") + .then((list) => alive && setProformas(list)) + .catch(() => alive && setProformas([])); + return () => { + alive = false; + }; + }, [quoteId]); + + if (!q || !ready) return null; + + const sup = supplierById(suppliers, q.supplier_id); + const allItems = q.line_items || []; + const active = activeItems(q); + + // گزینههای پروژه — پروژههای ثبتشده + پروژههای این پیشنهاد + تخصیصهای قبلی قلمها + const projectOptions = (() => { + const set = new Set (); + projects.forEach((p) => p.name && set.add(p.name)); + (q.projects || []).forEach((n) => set.add(n)); + if (q.project) set.add(q.project); + allItems.forEach((li) => (li.projects || []).forEach((p) => p.name && set.add(p.name))); + return [...set].sort((a, b) => a.localeCompare(b, "fa")); + })(); + + const cardLabel = (c: CardDraft, idx: number) => c.label || `گزینه ${faNum(idx + 1)}`; + + const updateCard = (domId: string, patch: Partial ) => { + setCards((cs) => cs.map((c) => (c.domId === domId ? { ...c, ...patch } : c))); + }; + + const addNewCard = () => { + setCards((cs) => [...cs, { domId: "new-ts-" + Date.now(), isNew: true, milestones: [{ when_iso: null, amount: null }], label: null }]); + }; + + const removeCard = (domId: string) => { + setCards((cs) => cs.filter((c) => c.domId !== domId)); + setAssignments((a) => { + const next: Record = {}; + Object.entries(a).forEach(([liId, dom]) => { + if (dom !== domId) next[liId] = dom; + }); + return next; + }); + }; + + const assign = (liId: string, domId: string) => { + setAssignments((a) => ({ ...a, [liId]: domId })); + }; + + 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); + if (!milestones.length) return; + const existing = next.termsheets.find((t) => t.id === c.domId); + cardMeta.set(c.domId, { + milestones, + label: c.label || `گزینه ${faNum(idx + 1)}`, + existing, + }); + }); + + if (!cardMeta.size) { + toast.error("حداقل یک conditions با حداقل یک مرحله پرداخت لازم است."); + return; + } + + // رد کردن قلمها — در داده میمانند تا با بازگشت وضعیت برگردند + next.line_items.forEach((li) => { + if (removedItems.has(li.id)) li.rejected = true; + }); + const active = activeItems(next); + if (!active.length) { + toast.error("همهی قلمها رد شدهاند؛ حداقل یک قلم لازم است."); + return; + } + + // فقط termsheetهایی که حداقل یک قلم انتخاب کرده پذیرفته میشوند + const fallbackDom = [...cardMeta.keys()][0]; + const chosenDomIds = new Set (); + active.forEach((li) => { + const dom = assignments[li.id] || fallbackDom; + if (dom && cardMeta.has(dom)) chosenDomIds.add(dom); + }); + + const domToFinal: Record = {}; + chosenDomIds.forEach((dom) => { + const meta = cardMeta.get(dom)!; + if (meta.existing) { + meta.existing.milestones = meta.milestones; + meta.existing.status = "accepted"; + domToFinal[dom] = dom; + } else { + const newId = nextDashedId("ts", next.termsheets); + next.termsheets.push({ id: newId, label: meta.label, milestones: meta.milestones, status: "accepted" }); + domToFinal[dom] = newId; + } + }); + + // قلمها: اتصالهای offered حفظ میشوند + جفت پذیرفتهشده ثبت میشود + active.forEach((li) => { + const dom = assignments[li.id] || fallbackDom; + const finalId = domToFinal[dom]; + const current = itemTermsheetIds(li).filter((tid) => next.termsheets.some((t) => t.id === tid)); + if (finalId && !current.includes(finalId)) current.push(finalId); + li.termsheet_ids = current.length ? current : finalId ? [finalId] : current; + 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); + }); + next.termsheets.forEach((t) => { + 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 names = itemProjectsSel[li.id]; + if (names === undefined) return; + li.projects = names.map((n) => ({ id: "", name: n })); + li.projects.forEach((p) => projUnion.add(p.name)); + }); + next.projects = [...projUnion]; + next.project = next.projects[0] || ""; + + const now = new Date().toISOString(); + next.accepted_date = acceptDate || now; + next.status = "accepted"; + next.events.push({ type: "accepted", occurred_at: now }); + next.invoices = invoices; + next.proformas = proformas; + + void updateQuote.mutateAsync({ id: q.id, patch: next }); + setReady(false); + onClose(); + }; + + return ( + + ); +} diff --git a/frontend/src/pages/Dashboard/ArriveModal.tsx b/frontend/src/pages/Dashboard/ArriveModal.tsx new file mode 100644 index 0000000..e48a3c3 --- /dev/null +++ b/frontend/src/pages/Dashboard/ArriveModal.tsx @@ -0,0 +1,132 @@ +import { useMemo, useState } from "react"; +import { toast } from "sonner"; +import { Package } from "lucide-react"; +import type { Quote } from "../../lib/types"; +import { useMaterials, useQuotes, useSuppliers, useUpdateQuote } from "../../hooks"; +import { activeItems, materialById, materialSpecs, supplierById } from "../../lib/quoteUtils"; +import { fmtNum } from "../../lib/formatters"; +import { groupCommas } from "../../lib/numutil"; +import { Button } from "../../components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "../../components/ui/dialog"; +import { JalaliDatePicker } from "../../components/JalaliDatePicker"; + +export interface ArriveModalProps { + quoteId: string | null; + onClose: () => void; +} + +// مودال رسید: تیک بزنید کدام قلمها رسیدهاند؛ تا همه نرسیده باشند quote پذیرفته میماند +export function ArriveModal({ quoteId, onClose }: ArriveModalProps) { + const quotes = useQuotes(); + const suppliers = useSuppliers(); + const materials = useMaterials(); + const updateQuote = useUpdateQuote(); + + const q = useMemo(() => quotes.find((x) => x.id === quoteId) || null, [quotes, quoteId]); + + const [checked, setChecked] = useState >(new Set()); + // تاریخ و زمان رسید — پیشفرض الان + const [arriveDate, setArriveDate] = useState (() => new Date().toISOString()); + const [ready, setReady] = useState(false); + + useMemo(() => { + if (!q) return; + const set = new Set (); + activeItems(q).forEach((li) => { + if (li.arrived_date) set.add(li.id); + }); + setChecked(set); + setReady(true); + }, [q]); + + if (!q || !ready) return null; + + const sup = supplierById(suppliers, q.supplier_id); + const items = activeItems(q); + + const confirm = () => { + if (!checked.size) { + toast.error("حداقل یک قلم را برای رسید انتخاب کنید."); + return; + } + const next: Quote = JSON.parse(JSON.stringify(q)); + const arrivedIso = arriveDate || new Date().toISOString(); + const now = new Date().toISOString(); + next.line_items.forEach((li) => { + li.arrived_date = checked.has(li.id) ? arrivedIso : null; + }); + if (activeItems(next).every((li) => li.arrived_date)) { + next.status = "arrived"; + next.arrived_date = arrivedIso; + next.events.push({ type: "arrived", occurred_at: now }); + } else { + next.arrived_date = null; + } + void updateQuote.mutateAsync({ id: q.id, patch: next }); + setReady(false); + onClose(); + }; + + return ( + + ); +} diff --git a/frontend/src/pages/Dashboard/DashboardPage.tsx b/frontend/src/pages/Dashboard/DashboardPage.tsx new file mode 100644 index 0000000..a7854a3 --- /dev/null +++ b/frontend/src/pages/Dashboard/DashboardPage.tsx @@ -0,0 +1,143 @@ +import { useMemo, useState } from "react"; +import { Link } from "react-router-dom"; +import { Users, FolderKanban, Ruler, Plus } from "lucide-react"; +import { + useMaterials, + useProjects, + useQuotes, + useStoreData, + useSuppliers, + useUnits, +} from "../../hooks"; +import { useAppStore } from "../../store"; +import { filterQuotes } from "../../lib/quoteUtils"; +import { daysBetween } from "../../lib/formatters"; +import { Header, PageLayout } from "../../components/layout/Header"; +import { StatsBar } from "../../components/StatsBar"; +import { FilterToolbar, type Filters } from "./FilterToolbar"; +import { LedgerTable } from "./LedgerTable"; +import { PanelMenu } from "../../components/PanelMenu"; +import { useLogout } from "../Inventory/panelMenu"; +import { AcceptModal } from "./AcceptModal"; +import { EditModal } from "./EditModal"; + +const EMPTY_FILTERS: Filters = { category: "", supplier: "", status: "", search: "" }; + +// صفحهی اصلی دفتر پیشنهادهای خرید +export function DashboardPage() { + useStoreData(); + const logout = useLogout(); + + const quotes = useQuotes(); + const suppliers = useSuppliers(); + const materials = useMaterials(); + const projects = useProjects(); + const { categoryUnits, unitConversions, standardConversions } = useUnits(); + + const expandedQuoteIds = useAppStore((s) => s.expandedQuoteIds); + const toggleExpand = useAppStore((s) => s.toggleExpand); + + const [filters, setFilters] = useState (EMPTY_FILTERS); + const [sortDir, setSortDir] = useState<"desc" | "asc">("desc"); + const [acceptId, setAcceptId] = useState (null); + const [editId, setEditId] = useState (null); + + const store = useMemo( + () => ({ quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions }), + [quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions], + ); + + // فیلتر + مرتبسازی بر اساس زمان ساخت (created_at) — فیلترها بازنشانی نمیشوند + const filtered = useMemo(() => { + const list = filterQuotes(store, filters); + const createdTs = (q: (typeof store.quotes)[number]) => { + const iso = q.created_at || q.quote_date; + const ts = iso ? new Date(iso).getTime() : 0; + return Number.isFinite(ts) ? ts : 0; + }; + return [...list].sort((a, b) => (sortDir === "desc" ? createdTs(b) - createdTs(a) : createdTs(a) - createdTs(b))); + }, [store, filters, sortDir]); + + const stats = useMemo(() => { + const total = quotes.length; + const offered = quotes.filter((q) => q.status === "offered").length; + const accepted = quotes.filter((q) => q.status === "accepted").length; + const leadTimes = quotes + .filter((q) => q.status === "arrived" && q.accepted_date && q.arrived_date) + .map((q) => daysBetween(q.arrived_date, q.accepted_date)) + .filter((x): x is number => x != null); + const avgLead = leadTimes.length ? Math.round(leadTimes.reduce((a, b) => a + b, 0) / leadTimes.length) : null; + return { total, offered, accepted, avgLead }; + }, [quotes]); + + const categoryOptions = useMemo(() => [...new Set(materials.map((m) => m.category))], [materials]); + const supplierOptions = useMemo(() => suppliers.map((s) => ({ id: s.id, name: s.name })), [suppliers]); + + return ( + ++ ); +} diff --git a/frontend/src/pages/Dashboard/EditModal.tsx b/frontend/src/pages/Dashboard/EditModal.tsx new file mode 100644 index 0000000..6522aa4 --- /dev/null +++ b/frontend/src/pages/Dashboard/EditModal.tsx @@ -0,0 +1,655 @@ +import { useEffect, useMemo, useState } from "react"; +import { Trash2 } from "lucide-react"; +import { toast } from "sonner"; +import type { Attachment, LineItem, Milestone, Quote, Termsheet } from "../../lib/types"; +import { api, nextDashedId, nextId } from "../../lib/api"; +import { + useMaterials, + useProjects, + useQuotes, + useSuppliers, + useUpdateQuote, + useAddSupplier, + useAddMaterial, +} from "../../hooks"; +import { itemTermsheetIds, materialById, materialSpecs, supplierById } from "../../lib/quoteUtils"; +import { STATUS_LABEL, statusOptionsFor } from "../../lib/constants"; +import { faNum } from "../../lib/formatters"; +import { normalize } from "../../lib/normalize"; +import { specsKey } from "../../lib/quoteUtils"; +import { Combobox } from "../../components/Combobox"; +import { JalaliDatePicker } from "../../components/JalaliDatePicker"; +import { NumberInput } from "../../components/NumberInput"; +import { MultiSelectCombobox } from "../../components/MultiSelectCombobox"; +import { FileList } from "../../components/FileList"; +import { MilestoneRow } from "../../components/MilestoneRow"; +import { Button } from "../../components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "../../components/ui/dialog"; + +interface EditItemRow { + key: string; + origLi?: LineItem; + cat: string; + brand: string; + specs: string[]; + qty: number | null; + unit: string; + price: number | null; + total: number | null; + tsDomIds: string[]; + acceptedTsId: string | null; + rejected: boolean; + restore: boolean; +} + +interface EditTsCard { + key: string; + domId: string; // "" یا "new-ts-..." برای کارت جدید + milestones: Milestone[]; +} + +export interface EditModalProps { + quoteId: string | null; + onClose: () => void; +} + +// مودال ویرایش کامل پیشنهاد +export function EditModal({ quoteId, onClose }: EditModalProps) { + const quotes = useQuotes(); + const suppliers = useSuppliers(); + const materials = useMaterials(); + const projects = useProjects(); + const updateQuote = useUpdateQuote(); + const addSupplier = useAddSupplier(); + const addMaterial = useAddMaterial(); + + const q = useMemo(() => quotes.find((x) => x.id === quoteId) || null, [quotes, quoteId]); + + const [ready, setReady] = useState(false); + const [supplierName, setSupplierName] = useState(""); + const [selectedProjects, setSelectedProjects] = useState+ } + actions={ + <> + + + تأمینکنندهها + + + + پروژهها + + + + ضرایب تبدیل + + + + افزودن پیشنهاد جدید + + > + } + /> + + + ++ setSortDir((d) => (d === "desc" ? "asc" : "desc"))} + /> + + setAcceptId(null)} /> + setEditId(null)} /> + ([]); + const [notes, setNotes] = useState(""); + const [invoices, setInvoices] = useState ([]); + const [itemRows, setItemRows] = useState ([]); + const [tsCards, setTsCards] = useState ([]); + const [quoteDate, setQuoteDate] = useState (null); + const [expiryDate, setExpiryDate] = useState (null); + const [acceptedDate, setAcceptedDate] = useState (null); + const [arrivedDate, setArrivedDate] = useState (null); + const [status, setStatus] = useState("offered"); + + // گزینههای مشخصه — همهی مشخصههای ثبتشده (کش) + const specOptions = useMemo(() => { + const set = new Set (); + materials.forEach((m) => (m.specs || []).forEach((s) => s && set.add(s))); + itemRows.forEach((r) => r.specs.forEach((s) => s && set.add(s))); + return [...set].sort((a, b) => a.localeCompare(b, "fa")); + }, [materials, itemRows]); + + // گزینههای پروژه — پروژههای ثبتشده + پروژههای خود پیشنهاد + const projectOptions = useMemo(() => { + const set = new Set (); + projects.forEach((p) => p.name && set.add(p.name)); + if (q) { + (q.projects || []).forEach((n) => set.add(n)); + if (q.project) set.add(q.project); + } + return [...set].sort((a, b) => a.localeCompare(b, "fa")); + }, [projects, q]); + + useMemo(() => { + if (!q) return; + const sup = supplierById(suppliers, q.supplier_id); + setSupplierName(sup.name); + setSelectedProjects(q.projects && q.projects.length ? [...q.projects] : q.project ? [q.project] : []); + setNotes(q.notes || ""); + setItemRows( + q.line_items.map((li) => { + const m = materialById(materials, li.material_id); + return { + key: li.id, + origLi: li, + cat: m.category || "", + brand: m.brand || "", + specs: materialSpecs(m), + qty: li.quantity, + unit: li.unit || "", + price: li.unit_price, + total: li.total_price, + tsDomIds: itemTermsheetIds(li).filter((tid) => q.termsheets.some((t) => t.id === tid)), + acceptedTsId: li.accepted_ts_id, + rejected: !!li.rejected, + restore: true, + }; + }), + ); + setTsCards( + q.termsheets.map((t) => ({ + key: t.id, + domId: t.id, + milestones: t.milestones.map((m) => ({ ...m })), + })), + ); + setQuoteDate(q.quote_date || null); + setExpiryDate(q.expiry_date || null); + setAcceptedDate(q.accepted_date || null); + setArrivedDate(q.arrived_date || null); + setStatus(q.status); + setReady(true); + }, [q, suppliers, materials]); + + // فاکتورها مستقیماً از سرور (MinIO) خوانده میشوند — نه از state محلی + useEffect(() => { + if (!quoteId) return; + let alive = true; + void api.files + .list(quoteId, "invoice") + .then((list) => alive && setInvoices(list)) + .catch(() => alive && setInvoices([])); + return () => { + alive = false; + }; + }, [quoteId]); + + if (!q || !ready) return null; + + const tsLabel = (card: EditTsCard, idx: number) => { + const orig = q.termsheets.find((t) => t.id === card.domId); + return orig?.label || `گزینه ${faNum(idx + 1)}`; + }; + + const patchItem = (key: string, patch: Partial ) => { + setItemRows((rows) => rows.map((r) => (r.key === key ? { ...r, ...patch } : r))); + }; + + const addItemRow = () => { + setItemRows((rows) => [ + ...rows, + { + key: "new-" + Math.random().toString(36).slice(2, 9), + cat: "", + brand: "", + specs: [], + qty: null, + unit: "", + price: null, + total: null, + tsDomIds: [], + acceptedTsId: null, + rejected: false, + restore: true, + }, + ]); + }; + + const updateTs = (key: string, patch: Partial ) => { + setTsCards((cards) => cards.map((c) => (c.key === key ? { ...c, ...patch } : c))); + }; + + const addTsCard = () => { + setTsCards((cards) => [ + ...cards, + { key: "new-" + Math.random().toString(36).slice(2, 9), domId: "", milestones: [{ when_iso: null, amount: null }] }, + ]); + }; + + const removeTsCard = (key: string) => { + setTsCards((cards) => cards.filter((c) => c.key !== key)); + }; + + const confirm = async () => { + const next: Quote = JSON.parse(JSON.stringify(q)); + + // تأمینکننده — موجود یا جدید + if (supplierName.trim()) { + const norm = normalize(supplierName); + const exist = suppliers.find((s) => normalize(s.name) === norm); + if (exist) { + next.supplier_id = exist.id; + } else { + const newSup = { id: nextId("s", suppliers), name: supplierName.trim(), location: "", phone: "", contact: "", topic: "", categories: [] }; + const created = await addSupplier.mutateAsync(newSup); + next.supplier_id = created.id; // UUID واقعی از بکاند + } + } + + next.project = selectedProjects[0] || ""; + next.projects = [...selectedProjects]; + next.notes = notes.trim(); + next.invoices = invoices; + + // termsheetها — id موجود حفظ میشود، جدیدها ساخته میشوند + const tsIdMap: Record = {}; + const newTermsheets: Termsheet[] = []; + tsCards.forEach((card, i) => { + const domId = card.domId || "new-ts-" + i; + const realId = + domId && !domId.startsWith("new-ts-") + ? domId + : nextDashedId("ts", newTermsheets.length ? newTermsheets : next.termsheets); + tsIdMap[domId] = realId; + const orig = next.termsheets.find((t) => t.id === realId); + const label = orig?.label || `گزینه ${faNum(i + 1)}`; + const st = orig?.status || "offered"; + newTermsheets.push({ id: realId, label, milestones: card.milestones, status: st }); + }); + next.termsheets = newTermsheets; + + // اقلام + const newItems: LineItem[] = []; + const materialsCopy = [...materials]; + for (const row of itemRows) { + const cat = row.cat.trim(); + const brand = row.brand.trim(); + const specs = row.specs; + const unit = row.unit.trim(); + const qty = row.qty; + const price = row.price; + const total = row.total; + if (!cat && !brand && qty == null && price == null) continue; // ردیف خالی + + let material = materialsCopy.find( + (m) => + normalize(m.category) === normalize(cat) && + normalize(m.brand) === normalize(brand) && + specsKey(m.specs) === specsKey(specs), + ); + let materialId: string; + if (material) { + materialId = material.id; + } else { + const nm = { + id: nextId("m", materialsCopy), + name: [brand, cat, ...specs].filter(Boolean).join(" ") || "کالای جدید", + topic: materialById(materials, row.origLi?.material_id || "").topic || "", + category: cat, + brand, + specs, + base_unit: unit, + }; + materialsCopy.push(nm); + // منتظر ساخت متریال میمانیم تا id واقعی (UUID) به قلم وصل شود + const created = await addMaterial.mutateAsync(nm); + materialId = created.id; + } + + const wasRejected = row.rejected; + const rejected = wasRejected && !row.restore; + + const termsheet_ids: string[] = []; + let acceptedTsId = row.acceptedTsId; + if (!rejected) { + row.tsDomIds.forEach((domId) => { + const realId = tsIdMap[domId] || domId; + if (realId) termsheet_ids.push(realId); + }); + if (!termsheet_ids.length && next.termsheets.length) { + termsheet_ids.push(next.termsheets[0].id); + } + if (wasRejected) { + const acc = next.termsheets.find((t) => t.status === "accepted"); + if (acc) { + termsheet_ids.length = 0; + termsheet_ids.push(acc.id); + } + acceptedTsId = acc ? acc.id : null; + } + } else { + acceptedTsId = null; + } + + newItems.push({ + id: "li-" + (newItems.length + 1), + material_id: materialId, + quantity: qty, + unit, + unit_price: price, + total_price: total, + termsheet_ids, + accepted_ts_id: acceptedTsId, + rejected, + arrived_date: row.origLi?.arrived_date ?? null, + projects: row.origLi?.projects ?? [], + }); + } + next.line_items = newItems.length ? newItems : next.line_items; + + // ترمشیت بدون قلم بیمعنی است — حذف + const referencedTs = new Set (); + next.line_items.forEach((li) => (li.termsheet_ids || []).forEach((tid) => referencedTs.add(tid))); + next.termsheets = next.termsheets.filter((t) => referencedTs.has(t.id)); + next.line_items.forEach((li) => { + if (li.accepted_ts_id && !next.termsheets.some((t) => t.id === li.accepted_ts_id)) { + li.accepted_ts_id = null; + } + }); + + // تاریخها و وضعیت + next.quote_date = quoteDate || q.quote_date; + next.expiry_date = expiryDate || ""; + next.accepted_date = acceptedDate; + next.arrived_date = arrivedDate; + next.status = status as Quote["status"]; + + // پاکسازی خودکار در بازگشت وضعیت + if (next.status === "offered") { + next.accepted_date = null; + next.arrived_date = null; + next.termsheets.forEach((t) => { + t.status = "offered"; + }); + next.line_items.forEach((li) => { + li.rejected = false; + li.arrived_date = null; + li.accepted_ts_id = null; + }); + } else if (next.status === "accepted") { + next.arrived_date = null; + next.line_items.forEach((li) => { + li.arrived_date = null; + }); + } + + try { + await updateQuote.mutateAsync({ id: q.id, patch: next }); + } catch (err) { + toast.error(err instanceof Error ? err.message : "ذخیرهی تغییرات ناموفق بود"); + return; + } + setReady(false); + onClose(); + }; + + return ( +