fix(suppliers): enforce Category → Supplier hierarchy, remove rogue-category bug
Domain model (v2.6): suppliers.category_id NOT NULL (1:N from the 8 fixed categories), materials are global and link to suppliers via supplier_materials. This fixes three regressions of the old inverted tree: - remove the intermediate 'یک متریال را انتخاب کنید' screen: a category card now routes straight to that category's supplier list (/suppliers/category/:category); TopicPage is deleted and legacy /topics/:topic[/:category] URLs redirect to the new hierarchy - kill the accidental-category bug: api.materials.create silently POSTed /material-categories for any unknown 'category' (a material name!), which is how the rogue «پریز» category (+ junk material) appeared. Unknown categories now fail loudly instead of creating catalog rows (DB cleaned: exactly the 8 canonical categories remain) - fix inverted wording: supplier list is «تأمینکنندههای این دسته» with category-first breadcrumbs; the legacy material multi-select on the add-supplier form is gone (a supplier gets the page's category) - supplier details screen logic untouched — only its breadcrumb link targets were updated to the new routes
This commit is contained in:
+12
-10
@@ -10,7 +10,6 @@ const WizardPage = lazy(() => import("./pages/Wizard/WizardPage").then((m) => ({
|
|||||||
const SuppliersPage = lazy(() =>
|
const SuppliersPage = lazy(() =>
|
||||||
import("./pages/Suppliers/SuppliersPage").then((m) => ({ default: m.SuppliersPage })),
|
import("./pages/Suppliers/SuppliersPage").then((m) => ({ default: m.SuppliersPage })),
|
||||||
);
|
);
|
||||||
const TopicPage = lazy(() => import("./pages/Suppliers/TopicPage").then((m) => ({ default: m.TopicPage })));
|
|
||||||
const CategoryPage = lazy(() =>
|
const CategoryPage = lazy(() =>
|
||||||
import("./pages/Suppliers/CategoryPage").then((m) => ({ default: m.CategoryPage })),
|
import("./pages/Suppliers/CategoryPage").then((m) => ({ default: m.CategoryPage })),
|
||||||
);
|
);
|
||||||
@@ -93,22 +92,25 @@ export default function App() {
|
|||||||
</RequireAuth>
|
</RequireAuth>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{/* سلسلهمراتب دامنه: دسته → تأمینکننده. کارت دسته مستقیم به
|
||||||
|
فهرست تأمینکنندههای همان دسته میرود (بدون صفحهی واسط متریال). */}
|
||||||
<Route
|
<Route
|
||||||
path="/topics/:topic"
|
path="/suppliers/category/:category"
|
||||||
element={
|
|
||||||
<RequireAuth>
|
|
||||||
<TopicPage />
|
|
||||||
</RequireAuth>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/topics/:topic/:category"
|
|
||||||
element={
|
element={
|
||||||
<RequireAuth>
|
<RequireAuth>
|
||||||
<CategoryPage />
|
<CategoryPage />
|
||||||
</RequireAuth>
|
</RequireAuth>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{/* مسیرهای قدیمی (درخت معکوس topic → material → supplier) — ریدایرکت */}
|
||||||
|
<Route
|
||||||
|
path="/topics/:topic/:category"
|
||||||
|
element={<Navigate to="/suppliers/category/:category" replace />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/topics/:topic"
|
||||||
|
element={<Navigate to="/suppliers" replace />}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/suppliers/:id"
|
path="/suppliers/:id"
|
||||||
element={
|
element={
|
||||||
|
|||||||
+9
-12
@@ -959,19 +959,16 @@ export const api = {
|
|||||||
},
|
},
|
||||||
create: async (m: Material): Promise<Material> => {
|
create: async (m: Material): Promise<Material> => {
|
||||||
const lookup = await fetchLookup();
|
const lookup = await fetchLookup();
|
||||||
let categoryId = m.category ? lookup.categoryNameToId.get(norm(m.category)) : undefined;
|
const categoryId = m.category ? lookup.categoryNameToId.get(norm(m.category)) : undefined;
|
||||||
// اگر دسته در بکاند نیست (مثلاً بعد از حذف seed) — بساز
|
// دستهها همان ۸ دستهی ثابت سیستماند و هرگز بهصورت خودکار ساخته
|
||||||
if (!categoryId && m.category) {
|
// نمیشوند — قبلاً اینجا برای هر نامِ ناشناخته یک رکورد material_categories
|
||||||
const cat = await postData<RawMaterialCategory>("/material-categories", {
|
// میساخت و باگ «پریز» (دستهی rogue) را به وجود آورد.
|
||||||
slug: norm(m.category) || "cat",
|
|
||||||
name: m.category,
|
|
||||||
is_active: true,
|
|
||||||
});
|
|
||||||
categoryId = cat.id;
|
|
||||||
lookup.categoryNameToId.set(norm(m.category), cat.id);
|
|
||||||
}
|
|
||||||
if (!categoryId) {
|
if (!categoryId) {
|
||||||
throw new Error(`متریال «${m.category}» در بکاند تعریف نشده است`);
|
throw new Error(
|
||||||
|
m.category
|
||||||
|
? `دسته «${m.category}» در سامانه تعریف نشده است — یکی از ۸ دستهی موجود را انتخاب کنید`
|
||||||
|
: "دستهی متریال مشخص نیست — یکی از ۸ دستهی موجود را انتخاب کنید",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// واحد مرجع — اگر تعریف نشده، بساز (بکاند canonical_unit_id الزامی دارد)
|
// واحد مرجع — اگر تعریف نشده، بساز (بکاند canonical_unit_id الزامی دارد)
|
||||||
const unitName = m.base_unit || "عدد";
|
const unitName = m.base_unit || "عدد";
|
||||||
|
|||||||
@@ -6,18 +6,18 @@ import type { Supplier } from "../../lib/types";
|
|||||||
import { nextId } from "../../lib/api";
|
import { nextId } from "../../lib/api";
|
||||||
import { useAddSupplier, useMaterials, useQuotes, useRemoveSupplier, useStoreData, useSuppliers } from "../../hooks";
|
import { useAddSupplier, useMaterials, useQuotes, useRemoveSupplier, useStoreData, useSuppliers } from "../../hooks";
|
||||||
import { useAppStore } from "../../store";
|
import { useAppStore } from "../../store";
|
||||||
import { normalize } from "../../lib/normalize";
|
import { topicByName } from "../../lib/topics";
|
||||||
import { Header, PageLayout } from "../../components/layout/Header";
|
import { Header, PageLayout } from "../../components/layout/Header";
|
||||||
import { EmptyState } from "../../components/EmptyState";
|
import { EmptyState } from "../../components/EmptyState";
|
||||||
import { Combobox } from "../../components/Combobox";
|
import { Combobox } from "../../components/Combobox";
|
||||||
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
|
import { SupplierCard, suppliersOfCategory } from "./supplierUtils";
|
||||||
import { SupplierCard, suppliersOfCategory, topicCategories, topicFromParam } from "./supplierUtils";
|
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
|
|
||||||
// صفحهی تأمینکنندههای یک متریال + فرم افزودن تأمینکننده
|
// صفحهی تأمینکنندههای یک دسته + فرم افزودن تأمینکننده
|
||||||
|
// (سلسلهمراتب دامنه: دسته → تأمینکننده؛ در v2.6 دسته = تاپیک)
|
||||||
export function CategoryPage() {
|
export function CategoryPage() {
|
||||||
useStoreData();
|
useStoreData();
|
||||||
const { topic: topicParam = "", category: catParam = "" } = useParams();
|
const { category: catParam = "" } = useParams();
|
||||||
|
|
||||||
const materials = useMaterials();
|
const materials = useMaterials();
|
||||||
const suppliers = useSuppliers();
|
const suppliers = useSuppliers();
|
||||||
@@ -26,15 +26,10 @@ export function CategoryPage() {
|
|||||||
const removeSupplier = useRemoveSupplier();
|
const removeSupplier = useRemoveSupplier();
|
||||||
const topics = useAppStore((s) => s.topics);
|
const topics = useAppStore((s) => s.topics);
|
||||||
|
|
||||||
const topic = useMemo(() => topicFromParam(topicParam, topics), [topicParam, topics]);
|
const cat = useMemo(() => topicByName(catParam, topics)?.name ?? null, [catParam, topics]);
|
||||||
const matched = useMemo(
|
|
||||||
() => (topic ? topicCategories(topic, materials, suppliers).find((c) => normalize(c) === normalize(catParam)) : undefined),
|
|
||||||
[topic, catParam, materials, suppliers],
|
|
||||||
);
|
|
||||||
|
|
||||||
const [form, setForm] = useState<{ name: string; categories: string[]; location: string; phone: string; contact: string }>({
|
const [form, setForm] = useState<{ name: string; location: string; phone: string; contact: string }>({
|
||||||
name: "",
|
name: "",
|
||||||
categories: [],
|
|
||||||
location: "",
|
location: "",
|
||||||
phone: "",
|
phone: "",
|
||||||
contact: "",
|
contact: "",
|
||||||
@@ -46,13 +41,11 @@ export function CategoryPage() {
|
|||||||
[suppliers],
|
[suppliers],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!topic || !matched) {
|
if (!cat) return <Navigate to="/suppliers" replace />;
|
||||||
return topic ? <Navigate to={`/topics/${encodeURIComponent(topic)}`} replace /> : <Navigate to="/suppliers" replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sups = suppliersOfCategory(topic, matched, suppliers);
|
const sups = suppliersOfCategory(cat, cat, suppliers);
|
||||||
|
|
||||||
// متریالهای خریداریشده از هر تأمینکننده
|
// متریالهای خریداریشده از هر تأمینکننده (برای نمایش روی کارت)
|
||||||
const supMats = useMemo(() => {
|
const supMats = useMemo(() => {
|
||||||
const map = new Map<string, string[]>();
|
const map = new Map<string, string[]>();
|
||||||
quotes.forEach((q) => {
|
quotes.forEach((q) => {
|
||||||
@@ -75,19 +68,18 @@ export function CategoryPage() {
|
|||||||
toast.error("نام تأمینکننده را وارد کنید.");
|
toast.error("نام تأمینکننده را وارد کنید.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// اگر متریالی انتخاب نشده، تأمینکننده به همین متریالِ صفحه اضافه میشود
|
// تأمینکننده دقیقاً زیر همین دسته ثبت میشود (۱ دسته به ازای هر تأمینکننده)
|
||||||
const cats = form.categories.length ? form.categories : [matched];
|
|
||||||
const s: Supplier = {
|
const s: Supplier = {
|
||||||
id: nextId("s", suppliers),
|
id: nextId("s", suppliers),
|
||||||
topic,
|
topic: cat,
|
||||||
categories: cats,
|
categories: [cat],
|
||||||
name,
|
name,
|
||||||
location: form.location.trim(),
|
location: form.location.trim(),
|
||||||
phone: form.phone.trim(),
|
phone: form.phone.trim(),
|
||||||
contact: form.contact.trim(),
|
contact: form.contact.trim(),
|
||||||
};
|
};
|
||||||
void addSupplier.mutateAsync(s);
|
void addSupplier.mutateAsync(s);
|
||||||
setForm({ name: "", categories: [], location: "", phone: "", contact: "" });
|
setForm({ name: "", location: "", phone: "", contact: "" });
|
||||||
toast.success("تأمینکننده افزوده شد.");
|
toast.success("تأمینکننده افزوده شد.");
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -101,14 +93,14 @@ export function CategoryPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<Header title={matched} sub={`تأمینکنندههای این متریال — ${topic}`} backTo="/suppliers" backLabel="موضوعها" />
|
<Header title={cat} sub="تأمینکنندههای این دسته" backTo="/suppliers" backLabel="دستهبندیها" />
|
||||||
<PageLayout className="flex flex-col gap-4">
|
<PageLayout className="flex flex-col gap-4">
|
||||||
{!sups.length ? (
|
{!sups.length ? (
|
||||||
<EmptyState icon={Inbox} text="هنوز تأمینکنندهای برای این متریال ثبت نشده است." />
|
<EmptyState icon={Inbox} text="هنوز تأمینکنندهای برای این دسته ثبت نشده است." />
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||||
{sups.map((s) => (
|
{sups.map((s) => (
|
||||||
<SupplierCard key={s.id} s={s} topic={topic} deletable onDelete={removeSup} materials={supMats.get(s.id) || []} />
|
<SupplierCard key={s.id} s={s} topic={cat} deletable onDelete={removeSup} materials={supMats.get(s.id) || []} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -130,15 +122,6 @@ export function CategoryPage() {
|
|||||||
onInputChange={(v) => setForm((f) => ({ ...f, name: v }))}
|
onInputChange={(v) => setForm((f) => ({ ...f, name: v }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
|
||||||
<label>متریال (ها)</label>
|
|
||||||
<MultiSelectCombobox
|
|
||||||
values={form.categories}
|
|
||||||
onChange={(cats) => setForm((f) => ({ ...f, categories: cats }))}
|
|
||||||
options={topicCategories(topic, materials, suppliers)}
|
|
||||||
placeholder="متریال را انتخاب یا تایپ کنید..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>موقعیت (شهر - منطقه)</label>
|
<label>موقعیت (شهر - منطقه)</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -152,12 +152,12 @@ export function SupplierDetailPage() {
|
|||||||
پیشنهاد جدید
|
پیشنهاد جدید
|
||||||
</Link>
|
</Link>
|
||||||
{firstCat ? (
|
{firstCat ? (
|
||||||
<Link to={`/topics/${encodeURIComponent(topicName)}/${encodeURIComponent(firstCat)}`} className="btn-line-file no-underline">
|
<Link to={`/suppliers/category/${encodeURIComponent(firstCat)}`} className="btn-line-file no-underline">
|
||||||
‹ بازگشت به دسته
|
‹ بازگشت به دسته
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<Link to={`/topics/${encodeURIComponent(topicName)}`} className="btn-line-file no-underline">
|
<Link to="/suppliers" className="btn-line-file no-underline">
|
||||||
‹ بازگشت به موضوع
|
‹ بازگشت به دستهبندیها
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,14 +25,14 @@ export function SuppliersPage() {
|
|||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<Header
|
<Header
|
||||||
title="تأمینکنندهها"
|
title="تأمینکنندهها"
|
||||||
sub="اول موضوع (دستهبندی) را انتخاب کنید"
|
sub="اول دستهبندی را انتخاب کنید"
|
||||||
backTo="/"
|
backTo="/"
|
||||||
backLabel="بازگشت به دفتر"
|
backLabel="بازگشت به دفتر"
|
||||||
/>
|
/>
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<TopicGrid
|
<TopicGrid
|
||||||
topics={topics}
|
topics={topics}
|
||||||
onSelect={(topic) => navigate(`/topics/${encodeURIComponent(topic)}`)}
|
onSelect={(topic) => navigate(`/suppliers/category/${encodeURIComponent(topic)}`)}
|
||||||
showCounts
|
showCounts
|
||||||
counts={counts}
|
counts={counts}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,162 +0,0 @@
|
|||||||
import { useMemo, useState } from "react";
|
|
||||||
import { Navigate, useNavigate, useParams } from "react-router-dom";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { FolderOpen, Plus } from "lucide-react";
|
|
||||||
import type { Material } from "../../lib/types";
|
|
||||||
import { nextId } from "../../lib/api";
|
|
||||||
import { useAddMaterial, useMaterials, useQuotes, useRemoveSupplier, useStoreData, useSuppliers } from "../../hooks";
|
|
||||||
import { useAppStore } from "../../store";
|
|
||||||
import { normalize } from "../../lib/normalize";
|
|
||||||
import { faNum } from "../../lib/formatters";
|
|
||||||
import { Header, PageLayout } from "../../components/layout/Header";
|
|
||||||
import { EmptyState } from "../../components/EmptyState";
|
|
||||||
import { SupplierCard, suppliersOfCategory, topicCategories, topicFromParam } from "./supplierUtils";
|
|
||||||
import { Button } from "../../components/ui/button";
|
|
||||||
|
|
||||||
// صفحهی متریالهای یک موضوع + تأمینکنندههای بدون متریال + افزودن متریال
|
|
||||||
export function TopicPage() {
|
|
||||||
useStoreData();
|
|
||||||
const { topic: topicParam = "" } = useParams();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const materials = useMaterials();
|
|
||||||
const suppliers = useSuppliers();
|
|
||||||
const quotes = useQuotes();
|
|
||||||
const addMaterial = useAddMaterial();
|
|
||||||
const removeSupplier = useRemoveSupplier();
|
|
||||||
const topics = useAppStore((s) => s.topics);
|
|
||||||
|
|
||||||
const topic = useMemo(() => topicFromParam(topicParam, topics), [topicParam, topics]);
|
|
||||||
const [newCatName, setNewCatName] = useState("");
|
|
||||||
|
|
||||||
const cats = useMemo(
|
|
||||||
() => (topic ? topicCategories(topic, materials, suppliers) : []),
|
|
||||||
[topic, materials, suppliers],
|
|
||||||
);
|
|
||||||
|
|
||||||
const uncat = useMemo(
|
|
||||||
() =>
|
|
||||||
topic
|
|
||||||
? suppliers.filter((s) => normalize(s.topic) === normalize(topic) && !(s.categories || []).length)
|
|
||||||
: [],
|
|
||||||
[topic, suppliers],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!topic) return <Navigate to="/suppliers" replace />;
|
|
||||||
|
|
||||||
const addCategory = () => {
|
|
||||||
const name = newCatName.trim();
|
|
||||||
if (!name) return;
|
|
||||||
const dup = materials.some(
|
|
||||||
(m) => normalize(m.topic) === normalize(topic) && normalize(m.category) === normalize(name),
|
|
||||||
);
|
|
||||||
if (dup) {
|
|
||||||
toast.error("این متریال قبلاً ثبت شده است.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const m: Material = {
|
|
||||||
id: nextId("m", materials),
|
|
||||||
name,
|
|
||||||
topic,
|
|
||||||
category: name,
|
|
||||||
brand: "",
|
|
||||||
specs: [],
|
|
||||||
base_unit: "",
|
|
||||||
};
|
|
||||||
void addMaterial.mutateAsync(m);
|
|
||||||
setNewCatName("");
|
|
||||||
toast.success("متریال افزوده شد.");
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeSup = (id: string) => {
|
|
||||||
const s = suppliers.find((x) => x.id === id);
|
|
||||||
if (!s) return;
|
|
||||||
if (confirm(`تأمینکننده «${s.name}» حذف شود؟ پیشنهادهای قبلی او در دفتر باقی میمانند.`)) {
|
|
||||||
void removeSupplier.mutateAsync(id);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// متریالهای خریداریشده از هر تأمینکننده
|
|
||||||
const supMats = useMemo(() => {
|
|
||||||
const map = new Map<string, string[]>();
|
|
||||||
quotes.forEach((q) => {
|
|
||||||
(q.line_items || []).forEach((li) => {
|
|
||||||
if (li.rejected) return;
|
|
||||||
const m = materials.find((x) => x.id === li.material_id);
|
|
||||||
if (!m) return;
|
|
||||||
const label = `${m.category}${m.brand ? " " + m.brand : ""}${m.specs.length ? " (" + m.specs.join("، ") + ")" : ""}`;
|
|
||||||
if (!map.has(q.supplier_id)) map.set(q.supplier_id, []);
|
|
||||||
const list = map.get(q.supplier_id)!;
|
|
||||||
if (!list.includes(label)) list.push(label);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return map;
|
|
||||||
}, [quotes, materials]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen">
|
|
||||||
<Header title={topic} sub="یک متریال را انتخاب کنید" backTo="/suppliers" backLabel="موضوعها" />
|
|
||||||
<PageLayout className="flex flex-col gap-4">
|
|
||||||
{cats.length ? (
|
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
|
||||||
{cats.map((c) => (
|
|
||||||
<button
|
|
||||||
key={c}
|
|
||||||
type="button"
|
|
||||||
className="topic-card"
|
|
||||||
onClick={() => navigate(`/topics/${encodeURIComponent(topic)}/${encodeURIComponent(c)}`)}
|
|
||||||
>
|
|
||||||
<div className="t-name" style={{ fontSize: 13 }}>
|
|
||||||
{c}
|
|
||||||
</div>
|
|
||||||
<div className="t-count">{faNum(suppliersOfCategory(topic, c, suppliers).length)} تأمینکننده</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<EmptyState icon={FolderOpen} text="هنوز متریالی برای این موضوع ثبت نشده — از پایین یک متریال اضافه کنید." />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{uncat.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<h3 className="mb-2 text-[13.5px] font-bold">تأمینکنندههای این موضوع</h3>
|
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
|
||||||
{uncat.map((s) => (
|
|
||||||
<SupplierCard key={s.id} s={s} topic={topic} deletable onDelete={removeSup} materials={supMats.get(s.id) || []} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="add-panel">
|
|
||||||
<h4>
|
|
||||||
<Plus className="me-1 inline h-4 w-4 text-accent" /> افزودن متریال جدید
|
|
||||||
</h4>
|
|
||||||
<div className="grid max-w-md grid-cols-[1fr_auto] items-end gap-2">
|
|
||||||
<div className="field">
|
|
||||||
<label>نام متریال (مثلاً میلگرد آجدار ۱۴)</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
|
||||||
placeholder="مثلاً میلگرد آجدار ۱۴"
|
|
||||||
autoComplete="off"
|
|
||||||
value={newCatName}
|
|
||||||
onChange={(e) => setNewCatName(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
addCategory();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button onClick={addCategory}>افزودن متریال</Button>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 text-[11px] text-muted">
|
|
||||||
بعد از افزودن، داخل همان متریال میتوانید تأمینکننده اضافه یا حذف کنید.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { MapPin, Phone, User, Plus } from "lucide-react";
|
import { MapPin, Phone, User, Plus } from "lucide-react";
|
||||||
import type { Material, Supplier, Topic } from "../../lib/types";
|
import type { Material, Supplier } from "../../lib/types";
|
||||||
import { normalize } from "../../lib/normalize";
|
import { normalize } from "../../lib/normalize";
|
||||||
import { topicByName } from "../../lib/topics";
|
|
||||||
export function topicCategories(topic: string, materials: Material[], suppliers: Supplier[]): string[] {
|
export function topicCategories(topic: string, materials: Material[], suppliers: Supplier[]): string[] {
|
||||||
const out = new Set<string>();
|
const out = new Set<string>();
|
||||||
materials
|
materials
|
||||||
@@ -20,12 +19,6 @@ export function suppliersOfCategory(topic: string, cat: string, suppliers: Suppl
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function topicFromParam(param: string | null, topics: Topic[]): string | null {
|
|
||||||
if (!param) return null;
|
|
||||||
const t = topicByName(param, topics);
|
|
||||||
return t ? t.name : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// کارت تأمینکننده
|
// کارت تأمینکننده
|
||||||
export function SupplierCard({
|
export function SupplierCard({
|
||||||
s,
|
s,
|
||||||
|
|||||||
Reference in New Issue
Block a user