diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index ce90d74..d420281 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1485,4 +1485,31 @@ export const apiV26 = { postData("/pm/receipts", body), receipts: async (): Promise => getData("/pm/receipts"), }, + + // ساخت موجودیت‌های کاتالوگ سراسری از داخل پنل انبار (موجودی اولیه): + // متریال/برند/واحد جدید بدون وابستگی به دفتر تدارکات ساخته می‌شوند. + catalog: { + createUnit: async (name: string): Promise<{ id: number; code: string; name_fa: string | null }> => + postData("/units", { + code: norm(name) || "unit", + name_fa: name, + dimension: null, + si_factor: 1, + is_base: false, + }), + createBrand: async (name: string): Promise<{ id: string; name: string }> => + postData("/brands", { name, country: null }), + createMaterial: async (opts: { + name: string; canonical_unit_id: number; brand_ids: string[]; + }): Promise<{ id: string; name: string }> => + postData("/materials", { + name: opts.name, + category_id: null, // دسته از اولین تأمین‌کننده‌ی مرتبط استنتاج می‌شود (v2.5) + topic: null, + canonical_unit_id: opts.canonical_unit_id, + status: "active", + brand_ids: opts.brand_ids, + attributes: [], + }), + }, }; diff --git a/frontend/src/pages/Inventory/ChangeInventoryPage.tsx b/frontend/src/pages/Inventory/ChangeInventoryPage.tsx index b3be8f9..6602b7b 100644 --- a/frontend/src/pages/Inventory/ChangeInventoryPage.tsx +++ b/frontend/src/pages/Inventory/ChangeInventoryPage.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import { useSearchParams } from "react-router-dom"; import { toast } from "sonner"; import { Header, PageLayout } from "../../components/layout/Header"; +import { Combobox } from "../../components/Combobox"; import { useAuthStore } from "../../store/auth"; import { inventorySubheader } from "./panelMenu"; import { apiV26 } from "../../lib/api"; @@ -16,7 +17,7 @@ type FormType = TransactionType | "TRANSFER"; const FORM_TYPES: { t: FormType; label: string; dir: string; cls: "in" | "out" | "mv" | "cnt"; color: string; desc: string }[] = [ // رنگ‌بندی معنایی: سبز زمردی = ورود، رز = خروج، آبی = خنثی/جابه‌جایی/شمارش - { t: "PURCHASE", label: "خرید", dir: "افزایش", cls: "in", color: "#10b981", desc: "ثبت خرید کالای جدید یا موجود — دسته‌بندی فقط برای کالای جدید لازم است. ثبت حداقل یک مشخصه فنی (نام و مقدار) الزامی است." }, + { t: "PURCHASE", label: "موجودی اولیه", dir: "افزایش", cls: "in", color: "#10b981", desc: "ثبت موجودی اولیه — متریال/برند/واحد را از فهرست انتخاب کنید؛ اگر نامی در فهرست نبود، همان نام تایپ‌شده هنگام ثبت به‌صورت خودکار در کاتالوگ سراسری ساخته می‌شود. ثبت حداقل یک مشخصه فنی (نام و مقدار) الزامی است." }, { t: "WITHDRAWAL", label: "برداشت", dir: "کاهش", cls: "out", color: "#e11d48", desc: "انبار را انتخاب و نام متریال را تایپ کنید؛ همه مدل‌ها (برند و مشخصه متفاوت) نمایش داده می‌شوند. برداشت‌ها در «کنترل روزانه» امروز دیده می‌شوند." }, { t: "RETURN", label: "برگشت", dir: "افزایش", cls: "in", color: "#10b981", desc: "برگشت از مصالحی که امروز برداشت شده‌اند — نام متریال را تایپ کنید و مقدار برگشتی هر ردیف را بزنید." }, { t: "TRANSFER", label: "انتقال", dir: "جابه‌جایی", cls: "mv", color: "#2563eb", desc: "جابه‌جایی بین انبارهای پروژه — از انبار مبدأ، مقداری از هر مدل را به انبار مقصد منتقل کنید." }, @@ -202,22 +203,44 @@ export function ChangeInventoryPage() { // ---------- submit handlers ---------- const submitPurchase = async () => { - const m = matByName(matTxt); - const bid = brandIdByName(brandTxt); - const uid = unitIdByName(unitTxt); + const matName = matTxt.trim(); + const brandName = brandTxt.trim(); + const unitName = unitTxt.trim(); const n = parseNum(qty); if (!whId) return toast.error("انبار را انتخاب کنید"); - if (!m) return toast.error("متریال در فهرست نیست — نام را دقیق تایپ کنید"); - if (!bid) return toast.error("برند را انتخاب کنید"); - if (!uid) return toast.error("واحد را انتخاب کنید"); + if (!matName) return toast.error("نام متریال را وارد کنید"); + if (!brandName) return toast.error("نام برند را وارد کنید"); + if (!unitName) return toast.error("نام واحد را وارد کنید"); if (!(n > 0)) return toast.error("تعداد باید بزرگ‌تر از صفر باشد"); if (!specsComplete) return toast.error("حداقل یک مشخصه فنی (نام و مقدار) ثبت کنید"); - const op: Record = { - transaction_type: "PURCHASE", warehouse_id: whId, material_id: m.id, brand_id: bid, - quantity: n, unit_id: uid, specs: specsFromRows(), note, - transaction_date: toServerIso(), - }; - await postSeq([op]); + + try { + // ساختِ به‌هنگام موجودیت‌های ناشناخته در کاتالوگ سراسری (بدون پیش‌نیاز تدارکات) + let uid = unitIdByName(unitName); + if (!uid) { + const created = await apiV26.catalog.createUnit(unitName); + uid = created.id; + } + let bid = brandIdByName(brandName); + if (!bid) { + const created = await apiV26.catalog.createBrand(brandName); + bid = created.id; + } + let m = matByName(matName); + if (!m) { + const created = await apiV26.catalog.createMaterial({ name: matName, canonical_unit_id: uid, brand_ids: bid ? [bid] : [] }); + m = { id: created.id, name: created.name, category: null, canonical_unit: null }; + } + const op: Record = { + transaction_type: "PURCHASE", warehouse_id: whId, material_id: m.id, brand_id: bid, + quantity: n, unit_id: uid, specs: specsFromRows(), note, + transaction_date: toServerIso(), + }; + await postSeq([op]); + } catch (err) { + setBusy(false); + toast.error(err instanceof Error ? err.message : "خطا در ثبت موجودی اولیه"); + } }; const submitList = async (t: TransactionType) => { @@ -355,7 +378,8 @@ export function ChangeInventoryPage() { const canSubmit = (): boolean => { if (type === "PURCHASE") { - return !!(whId && matByName(matTxt) && brandIdByName(brandTxt) && unitIdByName(unitTxt) && parseNum(qty) > 0 && specsComplete); + // نام‌های تایپ‌شده لازم نیست از قبل در فهرست باشند — در submit ساخته می‌شوند + return !!(whId && matTxt.trim() && brandTxt.trim() && unitTxt.trim() && parseNum(qty) > 0 && specsComplete); } if (type === "TRANSFER") return !!(fromId && toId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0)); if (type === "RETURN") return !!(whId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0)); @@ -461,7 +485,7 @@ export function ChangeInventoryPage() { {whOpts.map((w) => )} - {(type === "PURCHASE" || type === "WITHDRAWAL" || type === "DAMAGE" || type === "WASTE" || type === "LOSS" || type === "STOCK_COUNT" || type === "RETURN" || type === "FOUND") && ( + {(type === "WITHDRAWAL" || type === "DAMAGE" || type === "WASTE" || type === "LOSS" || type === "STOCK_COUNT" || type === "RETURN" || type === "FOUND") && (
)} - {/* فرم خرید */} + {/* فرم خرید / موجودی اولیه */} {type === "PURCHASE" && (
+
+ + mm.name)} + allowAdd + addSuffix="به عنوان متریال جدید" + placeholder="نام متریال — از فهرست انتخاب یا نام جدید تایپ کنید" + onInputChange={(v) => { setMatTxt(v); setRows([]); }} + onChange={(it) => { setMatTxt(it.label); setRows([]); }} + /> + {!matByName(matTxt) && matTxt.trim() && ( +
+ «{matTxt.trim()}» در فهرست نیست — هنگام ثبت، به‌صورت خودکار در کاتالوگ سراسری ساخته می‌شود. +
+ )} +
- setBrandTxt(e.target.value)} + items={data.lookup.brands.map((b) => b.name)} + allowAdd + addSuffix="به عنوان برند جدید" + placeholder="نام برند — از فهرست انتخاب یا نام جدید تایپ کنید" + onInputChange={setBrandTxt} + onChange={(it) => setBrandTxt(it.label)} /> - - {data.lookup.brands.map((b) =>
- setUnitTxt(e.target.value)} + items={data.lookup.units.map((u) => u.name_fa || u.code)} + allowAdd + addSuffix="به عنوان واحد جدید" + placeholder="واحد اندازه‌گیری — از فهرست یا نام جدید" + onInputChange={setUnitTxt} + onChange={(it) => setUnitTxt(it.label)} /> - - {data.lookup.units.map((u) =>
@@ -566,13 +605,6 @@ export function ChangeInventoryPage() { withTime />
- {!matByName(matTxt) && matTxt.trim() && ( -
-
- این متریال در فهرست نیست — ابتدا متریال را بسازید (مثلاً هنگام ثبت پیشنهاد در دفتر تدارکات)، سپس خرید را ثبت کنید. -
-
- )}
@@ -631,7 +663,7 @@ export function ChangeInventoryPage() { emptyMsg={ !whId ? "برای نمایش رکوردها، ابتدا انبار را انتخاب کنید." : !matTxt.trim() ? "نام متریال را تایپ کنید تا همه مدل‌های آن (با برند و مشخصه متفاوت) نمایش داده شود." - : !formRecords.length ? "برای این نام متریال در انبار انتخابی رکوردی وجود ندارد. ابتدا خرید ثبت کنید." + : !formRecords.length ? "برای این نام متریال در انبار انتخابی رکوردی وجود ندارد. ابتدا «موجودی اولیه» ثبت کنید." : "" } />