Files
ERP/frontend/src/pages/Projects/ProjectsPage.tsx
T
whqreza fd1831b3fc fix(catalog): show real material names, not categories, across quote views
New shared materialLabel() renders the global materials.name with brand/specs
appended only when not already part of the name (deduped; «؟» fallback kept).
Replaces the category-first composer in five places: supplier card badges
(CategoryPage), dashboard item lines (QuoteCard), ledger items summary,
project rows and purchase history. Also fixes the stale Units subtitle
«ضریب تبدیل همان دسته» → «ضریب تبدیل متریال» (category model kept).
2026-09-06 19:03:35 +03:30

290 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { useAppStore } from "../../store";
import {
activeItems,
lineItemTotal,
materialById,
materialLabel,
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 topics = useAppStore((s) => s.topics);
const store: Store = { quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions, topics };
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);
return materialLabel(m);
};
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>
);
}