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
+226
View File
@@ -0,0 +1,226 @@
import { Link } from "react-router-dom";
import { AlarmClock, ChevronDown, Pencil, TriangleAlert, Building2, Package, CreditCard } from "lucide-react";
import type { Quote, Store } from "../../lib/types";
import {
isExpired,
itemsSummary,
materialById,
materialSpecs,
projectCell,
quoteTotal,
supplierById,
termsheetSummary,
unitWarning,
} from "../../lib/quoteUtils";
import { STATUS_LABEL } from "../../lib/constants";
import { esc, fmtDate, fmtDateTime, fmtNum, priceCell } from "../../lib/formatters";
import { Badge } from "../../components/ui/badge";
import { cn } from "../../lib/utils";
import { QuoteDetail } from "./QuoteDetail";
export interface QuoteCardProps {
store: Store;
q: Quote;
expanded: boolean;
onToggleExpand: () => void;
onAccept: () => void;
onEdit: () => void;
}
function InfoCell({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="min-w-0">
<div className="mb-0.5 text-[10.5px] font-semibold text-muted">{label}</div>
<div className="min-w-0 text-[13px] leading-6">{children}</div>
</div>
);
}
// کارت یک پیشنهاد در دفتر — جایگزین جدول ۱۱ ستونه
export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit }: QuoteCardProps) {
const sup = supplierById(store.suppliers, q.supplier_id);
const expired = isExpired(q);
const total = quoteTotal(q);
const pc = priceCell(total);
const ts = termsheetSummary(store, q);
const unitWarns = unitWarning(store, q);
const summary = itemsSummary(store, q);
// ستون پذیرش (با ساعت)
let acceptedCell: React.ReactNode;
if (q.status === "offered") {
acceptedCell = (
<button className="chip chip-act" onClick={onAccept}>
پذیرش
</button>
);
} else if (q.accepted_date) {
acceptedCell = <span className="tabular-nums">{fmtDateTime(q.accepted_date)}</span>;
} else {
acceptedCell = <span className="text-muted">—</span>;
}
// ستون رسید — v2.6: تأیید رسید فقط در «پنل مدیریت انبار» توسط مدیر پروژه انجام می‌شود؛
// اینجا فقط وضعیت نمایش داده می‌شود.
let arrivedCell: React.ReactNode;
if (q.status === "accepted") {
if (q.arrived_date) {
arrivedCell = <span className="tabular-nums">{fmtDateTime(q.arrived_date)}</span>;
} else {
const items = (q.line_items || []).filter((li) => !li.rejected);
const arrivedCount = items.filter((li) => li.arrived_date).length;
arrivedCell = arrivedCount ? (
<span className="tabular-nums">{fmtNum(arrivedCount)}/{fmtNum(items.length)}</span>
) : (
<span className="text-muted">در انتظار PM</span>
);
}
} else if (q.arrived_date) {
arrivedCell = <span className="tabular-nums">{fmtDateTime(q.arrived_date)}</span>;
} else {
arrivedCell = <span className="text-muted">—</span>;
}
return (
<div
className={cn(
"overflow-hidden rounded-lg border bg-panel transition-colors",
expired ? "border-amber/40" : "border-line hover:border-accent/40",
expanded && "border-accent/60",
)}
>
{/* سربرگ کارت */}
<div className="flex items-center gap-3 border-b border-line bg-bg/40 px-4 py-2.5">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-accent-bg text-sm font-bold text-accent-dark">
{sup.name.slice(0, 1)}
</span>
<div className="min-w-0">
<Link
to={`/suppliers/${q.supplier_id}`}
className="block truncate text-[13.5px] font-bold text-ink no-underline hover:text-accent"
>
{sup.name}
</Link>
{sup.location && <div className="truncate text-[11px] text-muted">{sup.location}</div>}
</div>
<div className="ms-auto flex shrink-0 items-center gap-1.5">
<Badge variant={q.status as "offered" | "accepted" | "arrived"}>{STATUS_LABEL[q.status]}</Badge>
<button className="chip" onClick={onEdit} title="ویرایش پیشنهاد">
<Pencil className="h-3 w-3" />
ویرایش
</button>
<button
className="chip chip-expand"
onClick={onToggleExpand}
title={expanded ? "جمع کردن" : "نمایش جریان"}
aria-expanded={expanded}
>
<ChevronDown className={cn("h-4 w-4 transition-transform duration-200", expanded && "rotate-180")} />
</button>
</div>
</div>
{/* اطلاعات کلیدی */}
<div className="grid grid-cols-2 gap-x-5 gap-y-3 px-4 py-3 md:grid-cols-4">
<InfoCell label="اقلام">
{summary.count > 1 ? (
<div className="flex flex-col gap-0.5">
<span className="font-semibold">{fmtNum(summary.count)} قلم</span>
<div className="flex flex-col gap-1 text-[11.5px] leading-5 text-muted">
{store.quotes.find((x) => x.id === q.id)?.line_items?.filter((li) => !li.rejected).map((li) => {
const m = materialById(store.materials, li.material_id);
const specs = materialSpecs(m);
return (
<span key={li.id} className="flex items-center gap-1">
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-accent/40" />
{m.category} {m.brand}{specs.length ? ` · ${specs.join(" · ")}` : ""}
<span className="text-[10px] text-muted/70">
{fmtNum(li.quantity)} {li.unit}
</span>
</span>
);
})}
</div>
</div>
) : (
summary.text
)}
{unitWarns.map((w) => (
<Link
key={w.unit}
to={`/units?cat=${encodeURIComponent(w.cat)}&unit=${encodeURIComponent(w.unit)}`}
className="unit-warn mt-1"
title={`ضریب تبدیل «${esc(w.unit)}» را در داشبورد واحدها ثبت کنید`}
>
<TriangleAlert className="h-2.5 w-2.5" />
واحد جدید: {w.unit}
</Link>
))}
</InfoCell>
<InfoCell label="پروژه">
<span className="inline-flex items-center gap-1.5">
<Building2 className="h-3.5 w-3.5 shrink-0 text-muted" />
{q.projects && q.projects.length > 0 ? (
<span className="flex flex-col gap-0.5">
{q.projects.map((p, i) => (
<span key={i}>{p}</span>
))}
</span>
) : (
projectCell(q)
)}
</span>
</InfoCell>
<InfoCell label="مجموع">
{pc ? (
<span className="font-bold tabular-nums">
{pc.num} <span className="num-words">تومان</span> <span className="num-words">({pc.words})</span>
</span>
) : (
"—"
)}
</InfoCell>
<InfoCell label="معامله">
{ts.title !== "—" ? (
<div>
<div className="font-semibold" style={ts.color ? { color: ts.color } : undefined}>
{ts.title}
</div>
{ts.sub && <div className="text-[11px] text-muted">{ts.sub}</div>}
</div>
) : (
<span className="text-muted">—</span>
)}
</InfoCell>
</div>
{/* تاریخ‌ها و اقدامات */}
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 border-t border-line bg-bg/40 px-4 py-2 text-[11.5px] text-muted">
<span className="inline-flex items-center gap-1.5">
<Package className="h-3 w-3" />
پیشنهاد: <b className="text-ink tabular-nums">{fmtDateTime(q.quote_date)}</b>
</span>
<span className="inline-flex items-center gap-1.5">
<CreditCard className="h-3 w-3" />
معتبر تا:{" "}
<b className={cn("tabular-nums", expired ? "text-amber" : "text-ink")}>
{fmtDate(q.expiry_date)}
</b>
{expired && <AlarmClock className="h-3 w-3 text-amber" aria-label="منقضی" />}
</span>
<span className="inline-flex items-center gap-1.5">
پذیرش: {acceptedCell}
</span>
<span className="inline-flex items-center gap-1.5">
رسید: {arrivedCell}
</span>
</div>
{/* جریان (expand) */}
{expanded && <QuoteDetail store={store} q={q} />}
</div>
);
}