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