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:
@@ -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 ? "برای این نام متریال در انبار انتخابی رکوردی وجود ندارد. ابتدا «موجودی اولیه» ثبت کنید."
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user