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,289 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import { toast } from "sonner";
import { Building2, Inbox, CheckCircle2, Truck, Phone, FolderKanban } from "lucide-react";
import type { Project, Quote, Store } from "../../lib/types";
import { nextId } from "../../lib/api";
import {
useAddProject,
useMaterials,
useProjects,
useQuotes,
useStoreData,
useSuppliers,
useUnits,
} from "../../hooks";
import { normalize } from "../../lib/normalize";
import {
activeItems,
lineItemTotal,
materialById,
materialSpecs,
refConverted,
supplierById,
} from "../../lib/quoteUtils";
import { faNum, fmtDate } from "../../lib/formatters";
import { Header, PageLayout } from "../../components/layout/Header";
import { EmptyState } from "../../components/EmptyState";
import { Button } from "../../components/ui/button";
function projectQuotes(quotes: Quote[], name: string): Quote[] {
return quotes.filter((q) => (q.projects || []).includes(name) || q.project === name);
}
// اقلام مربوط به این پروژه در یک quote
function itemsForProject(q: Quote, name: string): Quote["line_items"] {
const items = (q.line_items || []).filter((li) => !li.rejected);
if (items.some((li) => li.projects && li.projects.length)) {
return items.filter((li) => (li.projects || []).some((p) => p.name === name));
}
return items;
}
function qTotal(q: Quote): number {
return activeItems(q).reduce((sum, li) => {
const t = lineItemTotal(li);
return sum + (t || 0);
}, 0);
}
function daysSince(iso: string | null): number | null {
if (!iso) return null;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return null;
return Math.floor((Date.now() - d.getTime()) / 86400000);
}
// صفحه‌ی پروژه‌ها — لیست + پنل جزئیات ۳ ستونه
export function ProjectsPage() {
useStoreData();
const projects = useProjects();
const quotes = useQuotes();
const suppliers = useSuppliers();
const materials = useMaterials();
const { categoryUnits, unitConversions, standardConversions } = useUnits();
const addProject = useAddProject();
const store: Store = { quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions };
const [openProject, setOpenProject] = useState<string | null>(null);
const [newName, setNewName] = useState("");
const addProj = () => {
const name = newName.trim();
if (!name) return;
const dup = projects.some((p) => normalize(p.name) === normalize(name));
if (dup) {
toast.error("این پروژه قبلاً ثبت شده است.");
return;
}
const p: Project = { id: nextId("p", projects), name };
void addProject.mutateAsync(p);
setNewName("");
setOpenProject(name);
toast.success("پروژه افزوده شد.");
};
return (
<div className="min-h-screen">
<Header title="پروژه‌ها" sub="روی یک پروژه کلیک کنید تا جزئیات آن باز شود" backTo="/" backLabel="بازگشت به دفتر" />
<PageLayout className="flex flex-col gap-4">
<div className="add-panel">
<h4>+ افزودن پروژه‌ی جدید</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={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addProj();
}
}}
/>
</div>
<Button onClick={addProj}>افزودن پروژه</Button>
</div>
</div>
{!projects.length && !quotes.length ? (
<EmptyState icon={FolderKanban} text="هنوز پروژه‌ای ثبت نشده است." />
) : (
<div className="flex flex-col gap-1.5">
{projects.map((p) => {
const count = projectQuotes(quotes, p.name).length;
const open = openProject === p.name;
return (
<div key={p.id} className="flex flex-col gap-1.5">
<button
type="button"
className={`proj-item ${open ? "open" : ""}`}
onClick={() => setOpenProject(open ? null : p.name)}
>
<span className={open ? "text-accent" : "text-muted"}>{open ? "▾" : "▸"}</span>
<span className="font-medium">{p.name}</span>
<span className="proj-count">{faNum(count)} پیشنهاد</span>
</button>
{/* جزئیات دقیقاً زیر همان پروژه باز می‌شود */}
{open && <div className="ms-2">{renderProjectDetail(store, p.name)}</div>}
</div>
);
})}
</div>
)}
</PageLayout>
</div>
);
}
// ---------- پنل جزئیات یک پروژه ----------
function renderProjectDetail(store: Store, name: string): React.ReactElement {
const quotes = projectQuotes(store.quotes, name);
const sumBy = (st: Quote["status"]) =>
quotes
.filter((q) => q.status === st)
.reduce((s, q) => s + qTotal(q), 0);
const matName = (mid: string) => {
const m = materialById(store.materials, mid);
if (m.category === "؟") return "؟";
const specs = materialSpecs(m);
return `${m.category} ${m.brand}${specs.length ? " · " + specs.join(" · ") : ""}`;
};
const supName = (sid: string) => {
const s = supplierById(store.suppliers, sid);
if (s.name === "؟") return "؟";
return (
<Link to={`/suppliers/${sid}`} className="pj-sup-link">
{s.name}
</Link>
);
};
const itemLine = (li: Quote["line_items"][number]) => {
const total = lineItemTotal(li);
const conv = refConverted(store, li);
return (
<div key={li.id}>
{matName(li.material_id)} — {faNum(li.quantity)} {li.unit} × {faNum(li.unit_price || 0)} ={" "}
<b>{faNum(total || 0)} تومان</b>
{conv && (
<span className="pj-conv">
{" "}
— <span className="pj-conv">≈ {faNum(conv.qty)} {conv.unit}({faNum(conv.perRef ?? 0)} تومان/{conv.unit})</span>
</span>
)}
</div>
);
};
const offered = quotes
.filter((q) => q.status === "offered")
.sort((a, b) => new Date(a.expiry_date || 0).getTime() - new Date(b.expiry_date || 0).getTime());
const accepted = quotes.filter((q) => q.status === "accepted");
const arrived = quotes.filter((q) => q.status === "arrived");
const offeredRow = (q: Quote) => {
const days = q.expiry_date ? Math.ceil((new Date(q.expiry_date).getTime() - Date.now()) / 86400000) : null;
return (
<div key={q.id} className="pj-row">
<div className="pj-main">
<Phone className="me-1 inline h-3 w-3 text-muted" /> {supName(q.supplier_id)}
</div>
<div className="pj-meta">{itemsForProject(q, name).map(itemLine)}</div>
<div className="pj-side">
جمع: <b>{faNum(qTotal(q))} تومان</b>
</div>
<div className="pj-side">
اعتبار تا {fmtDate(q.expiry_date)}<br />
{q.expiry_date == null ? (
<span className="pj-chip">بدون انقضا</span>
) : days != null && days < 0 ? (
<span className="pj-chip red">منقضی شده</span>
) : days === 0 ? (
<span className="pj-chip amber">امروز آخرین مهلت</span>
) : (
<span className="pj-chip amber">{faNum(days)} روز مانده</span>
)}
</div>
</div>
);
};
const acceptedRow = (q: Quote) => {
const d = daysSince(q.accepted_date);
return (
<div key={q.id} className="pj-row">
<div className="pj-main">
<Phone className="me-1 inline h-3 w-3 text-muted" /> {supName(q.supplier_id)}
</div>
<div className="pj-meta">{itemsForProject(q, name).map(itemLine)}</div>
<div className="pj-side">
تایید: {fmtDate(q.accepted_date)}
{d != null && <span className="pj-chip green">{faNum(d)} روز پیش</span>}
</div>
</div>
);
};
const arrivedRow = (q: Quote) => (
<div key={q.id} className="pj-row">
<div className="pj-main">
<Phone className="me-1 inline h-3 w-3 text-muted" /> {supName(q.supplier_id)}
</div>
<div className="pj-meta">{itemsForProject(q, name).map(itemLine)}</div>
<div className="pj-side">
هزینه: <b>{faNum(qTotal(q))} تومان</b>
</div>
<div className="pj-side">رسید: {fmtDate(q.arrived_date)}</div>
</div>
);
return (
<div className="rounded-xl border border-line bg-panel p-4 shadow-sm">
<h3 className="mb-2 text-[15px] font-bold">
<Building2 className="me-1.5 inline h-4 w-4 text-accent" />
{name}
</h3>
<div className="mb-3 flex flex-wrap gap-1.5">
<span className="pj-chip">کل پیشنهادها: {faNum(quotes.length)}</span>
<span className="pj-chip" style={{ background: "#dbeafe", color: "#1d4ed8" }}>
ارزش باز: {faNum(sumBy("offered"))} تومان
</span>
<span className="pj-chip" style={{ background: "#fef3c7", color: "#d97706" }}>
ارزش پذیرفته: {faNum(sumBy("accepted"))} تومان
</span>
<span className="pj-chip" style={{ background: "#dcfce7", color: "#16a34a" }}>
ارزش رسیده: {faNum(sumBy("arrived"))} تومان
</span>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
<div className="pj-col">
<div className="pj-col-h">
<Inbox className="me-1 inline h-3.5 w-3.5 text-[#1d4ed8]" /> پیشنهاد شده ({faNum(offered.length)})
</div>
{offered.length ? offered.map(offeredRow) : <div className="pj-empty">—</div>}
</div>
<div className="pj-col">
<div className="pj-col-h">
<CheckCircle2 className="me-1 inline h-3.5 w-3.5 text-amber" /> پذیرفته شده ({faNum(accepted.length)})
</div>
{accepted.length ? accepted.map(acceptedRow) : <div className="pj-empty">—</div>}
</div>
<div className="pj-col">
<div className="pj-col-h">
<Truck className="me-1 inline h-3.5 w-3.5 text-green" /> رسیده ({faNum(arrived.length)})
</div>
{arrived.length ? arrived.map(arrivedRow) : <div className="pj-empty">—</div>}
</div>
</div>
</div>
);
}