feat(frontend): add React UI — procurement office, wizard, suppliers, inventory panel (dash port), role-based login
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user