feat(frontend): add React UI — procurement office, wizard, suppliers, inventory panel (dash port), role-based login

This commit is contained in:
ali_mohaghegh
2026-08-25 19:08:15 +03:30
parent 170aed66ea
commit 4e105c5cd5
103 changed files with 16352 additions and 0 deletions
@@ -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"));
}