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).
290 lines
11 KiB
TypeScript
290 lines
11 KiB
TypeScript
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>
|
||
);
|
||
}
|