feat(frontend): add React UI — procurement office, wizard, suppliers, inventory panel (dash port), role-based login
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import type { Material, Supplier } from "../../lib/types";
|
||||
import { TOPICS } from "../../lib/constants";
|
||||
import { normalize } from "../../lib/normalize";
|
||||
import { useSuppliers } from "../../hooks";
|
||||
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 [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;
|
||||
}, [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
|
||||
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 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">
|
||||
<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)}
|
||||
/>
|
||||
فاکتور رسمی
|
||||
<span className="text-[11px] text-muted">
|
||||
(تأمینکننده برای این معامله فاکتور رسمی صادر میکند — نه فقط توافق تلفنی)
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// دستههای موجود داخل یک تاپیک (از materials + تأمینکنندهها)
|
||||
export function topicCategories(topic: string, suppliers: Supplier[], materials: Material[]): string[] {
|
||||
const out = new Set<string>();
|
||||
materials
|
||||
.filter((m) => topic && normalize(m.topic) === normalize(topic) && m.category)
|
||||
.forEach((m) => out.add(m.category));
|
||||
suppliers
|
||||
.filter((s) => topic && normalize(s.topic) === normalize(topic))
|
||||
.forEach((s) => (s.categories || []).forEach((c) => c && out.add(c)));
|
||||
return [...out].sort((a, b) => a.localeCompare(b, "fa"));
|
||||
}
|
||||
Reference in New Issue
Block a user