v2.7 dropped materials.category_id and the API started returning category: null for
every material, but the frontend still read material.category as the material's label.
Effect: the edit page's material field rendered blank for every row, the dashboard
category filter listed nothing (and matched nothing), the wizard's category suggestions
were empty, and the conversion lookups keyed by category (refConverted →
categoryUnits/unitConversions) silently returned null, dropping the per-reference-unit
price. Saving an edited quote matched materials on an always-true category term, so two
materials sharing brand+specs were interchangeable.
Auditing the dropped column also turned up four places still dereferencing it, one
request away from a 500 and hidden only because the inventory is empty:
GET /inventory (serializer), GET /inventory?category_id=…, GET /inventory/materials/options
and DELETE /material-categories/{id}.
Backend:
- expand_service: category_by_material() resolves a material's display category from its
FIRST linked supplier (oldest supplier_materials row — the v2.5 trigger's rule), used by
the material and line-item payloads; category_id is populated again when not expanded.
- inventory: list filter, row serializer and material-options filter match through
supplier_materials ⋈ suppliers, mirroring GET /materials?category_id=…
- material_categories: the delete guard blocks on suppliers (the RESTRICT FK) and says so.
Frontend:
- Material.category → Material.supplier_category (supplier-derived, documented); the
rename pushed every read site through tsc.
- Labels use the material's own name; the edit modal field is «متریال» and prefills by
name; formatMaterialTitle no longer falls back to a category.
- Identity matching (edit modal, wizard, material memory) is name + brand + specs via a
shared matchMaterial(), so same-brand same-spec materials are not conflated; legacy
composed names are still found through materialBaseName().
- Filters read the supplier's category (dashboard options + quote filter, consistent with
the backend's quote filtering); supplier categories now come from the supplier FK
instead of the removed JSONB column. Conversion keys use supplier_category.
- Removed dead code carrying the old logic: two uncalled topicCategories copies and
materialOptions' unused "cat" branch.
Verified: both images build clean (tsc -b && vite build); the API was exercised in-process
against a restore of the current production dump with an inventory row added — material
and line-item payloads carry the supplier category, the inventory endpoints that used to
raise return 200, and the category delete guard returns 409 instead of 500.
182 lines
7.2 KiB
TypeScript
182 lines
7.2 KiB
TypeScript
import { useMemo, useState } from "react";
|
||
import type { Supplier } from "../../lib/types";
|
||
import { normalize } from "../../lib/normalize";
|
||
import { useSuppliers } from "../../hooks";
|
||
import { useAppStore } from "../../store";
|
||
import { TopicGrid } from "../../components/TopicGrid";
|
||
import { Combobox } from "../../components/Combobox";
|
||
import { cn } from "../../lib/utils";
|
||
|
||
export interface Step1Props {
|
||
selectedTopic: string;
|
||
onTopicSelect: (topic: string) => void;
|
||
supplierName: string;
|
||
onSupplierName: (name: string) => void;
|
||
newSup: { location: string; phone: string; contact: string };
|
||
onNewSup: (patch: Partial<{ location: string; phone: string; contact: string }>) => void;
|
||
isOfficial: boolean;
|
||
onOfficial: (v: boolean) => void;
|
||
invalid: boolean;
|
||
}
|
||
|
||
// مرحله ۱ — تاپیک + تأمینکننده (چیپهای سریع + فرم تأمینکنندهی جدید)
|
||
export function Step1TopicSupplier({
|
||
selectedTopic,
|
||
onTopicSelect,
|
||
supplierName,
|
||
onSupplierName,
|
||
newSup,
|
||
onNewSup,
|
||
isOfficial,
|
||
onOfficial,
|
||
invalid,
|
||
}: Step1Props) {
|
||
const suppliers = useSuppliers();
|
||
const topics = useAppStore((s) => s.topics);
|
||
const [selectedSupId, setSelectedSupId] = useState<string | null>(null);
|
||
|
||
const topicSups = useMemo(
|
||
() => (selectedTopic ? suppliers.filter((s) => normalize(s.topic) === normalize(selectedTopic)) : []),
|
||
[suppliers, selectedTopic],
|
||
);
|
||
|
||
const counts = useMemo(() => {
|
||
const out: Record<string, number> = {};
|
||
topics.forEach((t) => {
|
||
out[t.name] = suppliers.filter((s) => normalize(s.topic) === normalize(t.name)).length;
|
||
});
|
||
return out;
|
||
}, [topics, suppliers]);
|
||
|
||
// آیا تأمینکنندهی فعلی در لیست هست؟
|
||
const matched = useMemo(() => {
|
||
const n = normalize(supplierName);
|
||
if (!n) return null;
|
||
return topicSups.find((s) => normalize(s.name) === n) || null;
|
||
}, [topicSups, supplierName]);
|
||
|
||
const showNewSupForm = supplierName.trim().length > 0 && !matched;
|
||
|
||
const pickSupplier = (s: Supplier) => {
|
||
onSupplierName(s.name);
|
||
setSelectedSupId(s.id);
|
||
};
|
||
|
||
return (
|
||
<div>
|
||
<h3 className="mb-1 text-[15px] font-bold">
|
||
<span className="me-1 inline-flex h-6 w-6 items-center justify-center rounded-full bg-accent text-[12px] font-bold text-white">۱</span>
|
||
موضوع (دستهبندی کالا)
|
||
</h3>
|
||
<div className="mb-3 text-[12px] text-muted">
|
||
اول موضوع کالای موردنیاز را از بین تصاویر انتخاب کنید. تأمینکنندهها بر اساس موضوع فیلتر میشوند.
|
||
</div>
|
||
<TopicGrid
|
||
topics={topics}
|
||
selectedTopic={selectedTopic}
|
||
onSelect={onTopicSelect}
|
||
showCounts
|
||
counts={counts}
|
||
/>
|
||
|
||
{selectedTopic && (
|
||
<div className="mt-5">
|
||
<h3 className="mb-1 text-[15px] font-bold">
|
||
<span className="me-1 inline-flex h-6 w-6 items-center justify-center rounded-full bg-accent text-[12px] font-bold text-white">۱</span>
|
||
تأمینکننده
|
||
</h3>
|
||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||
{!topicSups.length ? (
|
||
<div className="chip-divider">
|
||
هنوز تأمینکنندهای در این موضوع ثبت نشده — پایین را تایپ کنید تا بهعنوان تأمینکنندهی جدید ذخیره شود.
|
||
</div>
|
||
) : (
|
||
topicSups.map((s) => (
|
||
<button
|
||
key={s.id}
|
||
type="button"
|
||
className={cn("quick-chip", selectedSupId === s.id && "selected")}
|
||
onClick={() => pickSupplier(s)}
|
||
>
|
||
{s.name} <span className="qc-sub">{s.location || ""}</span>
|
||
</button>
|
||
))
|
||
)}
|
||
</div>
|
||
<div data-field="supplier_id" className={cn("field max-w-md", invalid && "invalid")}>
|
||
<label>یا نام تأمینکننده را تایپ و انتخاب کنید</label>
|
||
<Combobox
|
||
value={supplierName}
|
||
onChange={(it: { id: string; label: string }) => {
|
||
onSupplierName(it.label);
|
||
setSelectedSupId(it.id === it.label ? null : it.id);
|
||
}}
|
||
items={topicSups.map((s) => ({ id: s.id, name: s.name }))}
|
||
allowAdd
|
||
addSuffix="به عنوان تأمینکنندهٔ جدید"
|
||
placeholder="تایپ کنید... مثلاً زاگرس"
|
||
onInputChange={(v) => {
|
||
onSupplierName(v);
|
||
setSelectedSupId(null);
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{showNewSupForm && (
|
||
<div className="mt-3 rounded-xl border border-dashed border-accent/40 bg-blue-bg/20 p-3">
|
||
<div className="mb-2 text-[12px] text-muted">
|
||
این تأمینکننده در لیست نیست. جزئیات را تکمیل کنید تا ذخیره شود:
|
||
</div>
|
||
<div className="grid max-w-2xl grid-cols-2 gap-3 max-md:grid-cols-1">
|
||
<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="مثلاً اصفهان - ذوب آهن"
|
||
value={newSup.location}
|
||
onChange={(e) => onNewSup({ location: e.target.value })}
|
||
/>
|
||
</div>
|
||
<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="۰۲۱-..."
|
||
value={newSup.phone}
|
||
onChange={(e) => onNewSup({ phone: e.target.value })}
|
||
/>
|
||
</div>
|
||
<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="نام فرد"
|
||
value={newSup.contact}
|
||
onChange={(e) => onNewSup({ contact: e.target.value })}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{supplierName.trim() && (
|
||
<label className="mt-3 flex cursor-pointer items-center gap-2 text-[13px]">
|
||
<input
|
||
type="checkbox"
|
||
className="accent-accent"
|
||
checked={isOfficial}
|
||
onChange={(e) => onOfficial(e.target.checked)}
|
||
/>
|
||
فاکتور رسمی
|
||
</label>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|