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),
|
postData("/pm/receipts", body),
|
||||||
receipts: async (): Promise<ConfirmedReceipt[]> => getData<ConfirmedReceipt[]>("/pm/receipts"),
|
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 { useSearchParams } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Header, PageLayout } from "../../components/layout/Header";
|
import { Header, PageLayout } from "../../components/layout/Header";
|
||||||
|
import { Combobox } from "../../components/Combobox";
|
||||||
import { useAuthStore } from "../../store/auth";
|
import { useAuthStore } from "../../store/auth";
|
||||||
import { inventorySubheader } from "./panelMenu";
|
import { inventorySubheader } from "./panelMenu";
|
||||||
import { apiV26 } from "../../lib/api";
|
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 }[] = [
|
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: "WITHDRAWAL", label: "برداشت", dir: "کاهش", cls: "out", color: "#e11d48", desc: "انبار را انتخاب و نام متریال را تایپ کنید؛ همه مدلها (برند و مشخصه متفاوت) نمایش داده میشوند. برداشتها در «کنترل روزانه» امروز دیده میشوند." },
|
||||||
{ t: "RETURN", label: "برگشت", dir: "افزایش", cls: "in", color: "#10b981", desc: "برگشت از مصالحی که امروز برداشت شدهاند — نام متریال را تایپ کنید و مقدار برگشتی هر ردیف را بزنید." },
|
{ t: "RETURN", label: "برگشت", dir: "افزایش", cls: "in", color: "#10b981", desc: "برگشت از مصالحی که امروز برداشت شدهاند — نام متریال را تایپ کنید و مقدار برگشتی هر ردیف را بزنید." },
|
||||||
{ t: "TRANSFER", label: "انتقال", dir: "جابهجایی", cls: "mv", color: "#2563eb", desc: "جابهجایی بین انبارهای پروژه — از انبار مبدأ، مقداری از هر مدل را به انبار مقصد منتقل کنید." },
|
{ t: "TRANSFER", label: "انتقال", dir: "جابهجایی", cls: "mv", color: "#2563eb", desc: "جابهجایی بین انبارهای پروژه — از انبار مبدأ، مقداری از هر مدل را به انبار مقصد منتقل کنید." },
|
||||||
@@ -202,22 +203,44 @@ export function ChangeInventoryPage() {
|
|||||||
|
|
||||||
// ---------- submit handlers ----------
|
// ---------- submit handlers ----------
|
||||||
const submitPurchase = async () => {
|
const submitPurchase = async () => {
|
||||||
const m = matByName(matTxt);
|
const matName = matTxt.trim();
|
||||||
const bid = brandIdByName(brandTxt);
|
const brandName = brandTxt.trim();
|
||||||
const uid = unitIdByName(unitTxt);
|
const unitName = unitTxt.trim();
|
||||||
const n = parseNum(qty);
|
const n = parseNum(qty);
|
||||||
if (!whId) return toast.error("انبار را انتخاب کنید");
|
if (!whId) return toast.error("انبار را انتخاب کنید");
|
||||||
if (!m) return toast.error("متریال در فهرست نیست — نام را دقیق تایپ کنید");
|
if (!matName) return toast.error("نام متریال را وارد کنید");
|
||||||
if (!bid) return toast.error("برند را انتخاب کنید");
|
if (!brandName) return toast.error("نام برند را وارد کنید");
|
||||||
if (!uid) return toast.error("واحد را انتخاب کنید");
|
if (!unitName) return toast.error("نام واحد را وارد کنید");
|
||||||
if (!(n > 0)) return toast.error("تعداد باید بزرگتر از صفر باشد");
|
if (!(n > 0)) return toast.error("تعداد باید بزرگتر از صفر باشد");
|
||||||
if (!specsComplete) 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> = {
|
const op: Record<string, unknown> = {
|
||||||
transaction_type: "PURCHASE", warehouse_id: whId, material_id: m.id, brand_id: bid,
|
transaction_type: "PURCHASE", warehouse_id: whId, material_id: m.id, brand_id: bid,
|
||||||
quantity: n, unit_id: uid, specs: specsFromRows(), note,
|
quantity: n, unit_id: uid, specs: specsFromRows(), note,
|
||||||
transaction_date: toServerIso(),
|
transaction_date: toServerIso(),
|
||||||
};
|
};
|
||||||
await postSeq([op]);
|
await postSeq([op]);
|
||||||
|
} catch (err) {
|
||||||
|
setBusy(false);
|
||||||
|
toast.error(err instanceof Error ? err.message : "خطا در ثبت موجودی اولیه");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitList = async (t: TransactionType) => {
|
const submitList = async (t: TransactionType) => {
|
||||||
@@ -355,7 +378,8 @@ export function ChangeInventoryPage() {
|
|||||||
|
|
||||||
const canSubmit = (): boolean => {
|
const canSubmit = (): boolean => {
|
||||||
if (type === "PURCHASE") {
|
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 === "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));
|
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>)}
|
{whOpts.map((w) => <option key={w.id} value={w.id}>{w.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<div className="field">
|
||||||
<label>{type === "FOUND" ? "متریال (خالی = همه مفقودیها)" : "متریال *"}</label>
|
<label>{type === "FOUND" ? "متریال (خالی = همه مفقودیها)" : "متریال *"}</label>
|
||||||
<input
|
<input
|
||||||
@@ -518,34 +542,49 @@ export function ChangeInventoryPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* فرم خرید */}
|
{/* فرم خرید / موجودی اولیه */}
|
||||||
{type === "PURCHASE" && (
|
{type === "PURCHASE" && (
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
<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">
|
<div className="field">
|
||||||
<label>برند *</label>
|
<label>برند *</label>
|
||||||
<input
|
<Combobox
|
||||||
list="dl-brand"
|
|
||||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
|
|
||||||
placeholder="نام برند — از پیشنهادها یا جدید…"
|
|
||||||
value={brandTxt}
|
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>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>واحد *</label>
|
<label>واحد *</label>
|
||||||
<input
|
<Combobox
|
||||||
list="dl-unit"
|
|
||||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
|
|
||||||
placeholder="واحد اندازهگیری…"
|
|
||||||
value={unitTxt}
|
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>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>تعداد *</label>
|
<label>تعداد *</label>
|
||||||
@@ -566,13 +605,6 @@ export function ChangeInventoryPage() {
|
|||||||
withTime
|
withTime
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="field md:col-span-2">
|
||||||
<label>مشخصههای فنی *</label>
|
<label>مشخصههای فنی *</label>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
@@ -631,7 +663,7 @@ export function ChangeInventoryPage() {
|
|||||||
emptyMsg={
|
emptyMsg={
|
||||||
!whId ? "برای نمایش رکوردها، ابتدا انبار را انتخاب کنید."
|
!whId ? "برای نمایش رکوردها، ابتدا انبار را انتخاب کنید."
|
||||||
: !matTxt.trim() ? "نام متریال را تایپ کنید تا همه مدلهای آن (با برند و مشخصه متفاوت) نمایش داده شود."
|
: !matTxt.trim() ? "نام متریال را تایپ کنید تا همه مدلهای آن (با برند و مشخصه متفاوت) نمایش داده شود."
|
||||||
: !formRecords.length ? "برای این نام متریال در انبار انتخابی رکوردی وجود ندارد. ابتدا خرید ثبت کنید."
|
: !formRecords.length ? "برای این نام متریال در انبار انتخابی رکوردی وجود ندارد. ابتدا «موجودی اولیه» ثبت کنید."
|
||||||
: ""
|
: ""
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user