feat(inventory): «موجودی اولیه» rebrand + create-on-the-fly global catalog entries

- PURCHASE rebranded to «موجودی اولیه» in the تغییر موجودی screen
  (tab, description, empty-state copy; submit button reads
  «ثبت موجودی اولیه» automatically).
- Material/brand/unit inputs of that form are now searchable Comboboxes
  with «…به عنوان جدید» affordance; submitting an unknown name creates
  the entry on the fly in the global catalogs (new apiV26.catalog.* →
  POST /materials with null category, /brands, /units) so initial stock
  no longer requires a procurement prerequisite.
- The hub timeline chip for PURCHASE stays «خرید» because real deliveries
  from کالاهای در راه also post as PURCHASE.
This commit is contained in:
2026-09-06 19:03:42 +03:30
parent fd1831b3fc
commit 468ce64d29
2 changed files with 99 additions and 40 deletions
+27
View File
@@ -1485,4 +1485,31 @@ export const apiV26 = {
postData("/pm/receipts", body),
receipts: async (): Promise<ConfirmedReceipt[]> => getData<ConfirmedReceipt[]>("/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: [],
}),
},
};
@@ -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("حداقل یک مشخصه فنی (نام و مقدار) ثبت کنید");
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<string, unknown> = {
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) => <option key={w.id} value={w.id}>{w.name}</option>)}
</select>
</div>
{(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") && (
<div className="field">
<label>{type === "FOUND" ? "متریال (خالی = همه مفقودی‌ها)" : "متریال *"}</label>
<input
@@ -518,34 +542,49 @@ export function ChangeInventoryPage() {
</div>
)}
{/* فرم خرید */}
{/* فرم خرید / موجودی اولیه */}
{type === "PURCHASE" && (
<div className="grid gap-3 md:grid-cols-2">
<div className="field md:col-span-2">
<label>متریال *</label>
<Combobox
value={matTxt}
items={data.lookup.materials.map((mm) => mm.name)}
allowAdd
addSuffix="به عنوان متریال جدید"
placeholder="نام متریال — از فهرست انتخاب یا نام جدید تایپ کنید"
onInputChange={(v) => { setMatTxt(v); setRows([]); }}
onChange={(it) => { setMatTxt(it.label); setRows([]); }}
/>
{!matByName(matTxt) && matTxt.trim() && (
<div className="mt-1.5 rounded-lg border border-accent/25 bg-accent-bg/60 px-3 py-2 text-[12px] text-accent-dark">
«{matTxt.trim()}» در فهرست نیست — هنگام ثبت، به‌صورت خودکار در کاتالوگ سراسری ساخته می‌شود.
</div>
)}
</div>
<div className="field">
<label>برند *</label>
<input
list="dl-brand"
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
placeholder="نام برند — از پیشنهادها یا جدید…"
<Combobox
value={brandTxt}
onChange={(e) => setBrandTxt(e.target.value)}
items={data.lookup.brands.map((b) => b.name)}
allowAdd
addSuffix="به عنوان برند جدید"
placeholder="نام برند — از فهرست انتخاب یا نام جدید تایپ کنید"
onInputChange={setBrandTxt}
onChange={(it) => setBrandTxt(it.label)}
/>
<datalist id="dl-brand">
{data.lookup.brands.map((b) => <option key={b.id} value={b.name} />)}
</datalist>
</div>
<div className="field">
<label>واحد *</label>
<input
list="dl-unit"
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
placeholder="واحد اندازه‌گیری…"
<Combobox
value={unitTxt}
onChange={(e) => 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)}
/>
<datalist id="dl-unit">
{data.lookup.units.map((u) => <option key={u.id} value={u.name_fa || u.code} />)}
</datalist>
</div>
<div className="field">
<label>تعداد *</label>
@@ -566,13 +605,6 @@ export function ChangeInventoryPage() {
withTime
/>
</div>
{!matByName(matTxt) && matTxt.trim() && (
<div className="field md:col-span-2">
<div className="rounded-lg border border-amber/30 bg-amber/5 px-3 py-2 text-[12px] text-amber">
این متریال در فهرست نیست — ابتدا متریال را بسازید (مثلاً هنگام ثبت پیشنهاد در دفتر تدارکات)، سپس خرید را ثبت کنید.
</div>
</div>
)}
<div className="field md:col-span-2">
<label>مشخصه‌های فنی *</label>
<div className="flex flex-col gap-1.5">
@@ -631,7 +663,7 @@ export function ChangeInventoryPage() {
emptyMsg={
!whId ? "برای نمایش رکوردها، ابتدا انبار را انتخاب کنید."
: !matTxt.trim() ? "نام متریال را تایپ کنید تا همه مدل‌های آن (با برند و مشخصه متفاوت) نمایش داده شود."
: !formRecords.length ? "برای این نام متریال در انبار انتخابی رکوردی وجود ندارد. ابتدا خرید ثبت کنید."
: !formRecords.length ? "برای این نام متریال در انبار انتخابی رکوردی وجود ندارد. ابتدا «موجودی اولیه» ثبت کنید."
: ""
}
/>