Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1953e26356 |
@@ -62,7 +62,7 @@ export function formatMaterialTitle(
|
||||
const normName = normalize(rawName);
|
||||
if (normName.startsWith(normBrand)) {
|
||||
const rest = rawName.slice(rawBrand.length).trim();
|
||||
rawName = rest.replace(/^[.\s·\-]+/, "").trim();
|
||||
rawName = rest.replace(/^[.\s·-]+/, "").trim();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,13 +291,22 @@ export function filterQuotes(
|
||||
|
||||
const STATUS_BY_LABEL: Record<string, string> = {
|
||||
"پیشنهاد شده": "offered",
|
||||
"در انتظار تایید": "offered",
|
||||
"در انتظار تأیید": "offered",
|
||||
"پذیرفته شده": "accepted",
|
||||
"در حال خرید": "accepted",
|
||||
"رسیده": "arrived",
|
||||
"تکمیل شده": "arrived",
|
||||
"باز": "offered",
|
||||
"پذیرفته": "accepted",
|
||||
"تکمیل": "arrived",
|
||||
"offered": "offered",
|
||||
"accepted": "accepted",
|
||||
"arrived": "arrived",
|
||||
"rejected": "rejected",
|
||||
"رد شده": "rejected",
|
||||
"expired": "expired",
|
||||
"منقضی شده": "expired",
|
||||
};
|
||||
const stKey = STATUS_BY_LABEL[stVal] || stVal;
|
||||
|
||||
@@ -309,9 +318,10 @@ export function filterQuotes(
|
||||
const matInfo = activeItems(quote).map((li) => materialById(store.materials, li.material_id));
|
||||
|
||||
if (q) {
|
||||
const projectsText = `${(quote.projects || []).join(" ")} ${quote.project || ""}`;
|
||||
const hay =
|
||||
`${supObj.name} ${quote.project || ""} ` +
|
||||
matInfo.map((m) => `${m.brand} ${materialSpecs(m).join(" ")}`).join(" ");
|
||||
`${quote.id} PR-${quote.id} ${supObj.name} ${projectsText} ` +
|
||||
matInfo.map((m) => `${m.name} ${m.brand} ${materialSpecs(m).join(" ")}`).join(" ");
|
||||
if (!hay.toLowerCase().includes(q)) return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -235,9 +235,14 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
{/* هدر ثابت و همتراز */}
|
||||
<div className="px-6 py-4 bg-white border-b border-line shrink-0 z-10 text-right">
|
||||
<DialogHeader className="p-0 text-right">
|
||||
<DialogTitle className="text-right text-lg font-bold text-slate-800">
|
||||
پذیرش پیشنهاد — {sup.name}
|
||||
</DialogTitle>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<DialogTitle className="text-right text-lg font-bold text-slate-800">
|
||||
پذیرش پیشنهاد — {sup.name}
|
||||
</DialogTitle>
|
||||
<span className="font-mono text-xs font-bold text-slate-700 bg-slate-100 px-2.5 py-1 rounded-lg border border-line shrink-0 select-all">
|
||||
کد: {q.id.startsWith("PR-") ? q.id : `PR-${q.id}`}
|
||||
</span>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<p className="text-[13px] text-muted mt-1 text-right">
|
||||
شرایط پرداخت را ویرایش کنید. سپس هر قلم را به بخش و پروژه دلخواه تخصیص دهید.
|
||||
|
||||
@@ -132,20 +132,23 @@ export function DashboardPage() {
|
||||
/>
|
||||
|
||||
<PageLayout className="flex flex-col gap-3 px-3 md:px-6">
|
||||
<StatsBar
|
||||
total={stats.total}
|
||||
offered={stats.offered}
|
||||
accepted={stats.accepted}
|
||||
arrived={stats.arrived}
|
||||
activeFilter={activeStatusFilter}
|
||||
onFilterChange={handleStatusFilterChange}
|
||||
/>
|
||||
<div className="hidden md:block">
|
||||
<StatsBar
|
||||
total={stats.total}
|
||||
offered={stats.offered}
|
||||
accepted={stats.accepted}
|
||||
arrived={stats.arrived}
|
||||
activeFilter={activeStatusFilter}
|
||||
onFilterChange={handleStatusFilterChange}
|
||||
/>
|
||||
</div>
|
||||
<FilterToolbar
|
||||
filters={filters}
|
||||
onChange={setFilters}
|
||||
suppliers={supplierOptions}
|
||||
sortDir={sortDir}
|
||||
onToggleSort={() => setSortDir((d) => (d === "desc" ? "asc" : "desc"))}
|
||||
stats={stats}
|
||||
/>
|
||||
<LedgerTable
|
||||
store={store}
|
||||
|
||||
@@ -462,7 +462,14 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
<DialogContent className="flex h-[88vh] max-h-[850px] w-[95vw] max-w-3xl flex-col p-0 overflow-hidden rounded-2xl border border-gray-200 bg-white text-gray-900 shadow-2xl">
|
||||
{/* هدر */}
|
||||
<DialogHeader className="border-b border-gray-100 bg-white px-4 py-3 sm:px-6 sm:py-4 shrink-0 text-right">
|
||||
<DialogTitle className="text-base font-bold text-gray-900 sm:text-lg">ویرایش پیشنهاد خرید</DialogTitle>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<DialogTitle className="text-base font-bold text-gray-900 sm:text-lg">ویرایش پیشنهاد خرید</DialogTitle>
|
||||
{q && (
|
||||
<span className="font-mono text-xs font-bold text-gray-700 bg-gray-100 px-2.5 py-1 rounded-lg border border-gray-200 shrink-0 select-all">
|
||||
کد: {q.id.startsWith("PR-") ? q.id : `PR-${q.id}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[12px] text-gray-500 truncate mt-0.5">
|
||||
{supplierName ? `تأمینکننده: ${supplierName}` : "تنظیم اقلام، مبالغ و شرایط پرداخت"}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { SlidersHorizontal, X, ArrowUpDown, RotateCcw } from "lucide-react";
|
||||
import { Search, SlidersHorizontal, X, ArrowUpDown, RotateCcw } from "lucide-react";
|
||||
import { Combobox } from "../../components/Combobox";
|
||||
import { fmtNum } from "../../lib/formatters";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export interface Filters {
|
||||
supplier: string;
|
||||
@@ -8,29 +10,33 @@ export interface Filters {
|
||||
search: string;
|
||||
}
|
||||
|
||||
// گزینههای استاندارد و یکپارچه وضعیت هماهنگ با کارتهای بالای صفحه
|
||||
// گزینههای استاندارد وضعیت برای دراپداون فیلترهای پیشرفته
|
||||
const STATUS_OPTIONS = [
|
||||
{ id: "offered", label: "پیشنهاد باز" },
|
||||
{ id: "accepted", label: "پذیرفتهشده" },
|
||||
{ id: "arrived", label: "تحویلشده" },
|
||||
{ id: "offered", label: "در انتظار تایید" },
|
||||
{ id: "accepted", label: "در حال خرید" },
|
||||
{ id: "arrived", label: "تکمیل شده" },
|
||||
];
|
||||
|
||||
const STATUS_MAP: Record<string, string> = {
|
||||
offered: "پیشنهاد باز",
|
||||
accepted: "پذیرفتهشده",
|
||||
arrived: "تحویلشده",
|
||||
offered: "در انتظار تایید",
|
||||
accepted: "در حال خرید",
|
||||
arrived: "تکمیل شده",
|
||||
};
|
||||
|
||||
const STATUS_TO_KEY: Record<string, string> = {
|
||||
"در انتظار تایید": "offered",
|
||||
"در انتظار تأیید": "offered",
|
||||
"پیشنهاد باز": "offered",
|
||||
"پذیرفتهشده": "accepted",
|
||||
"تحویلشده": "arrived",
|
||||
"پیشنهاد شده": "offered",
|
||||
"در حال خرید": "accepted",
|
||||
"پذیرفتهشده": "accepted",
|
||||
"پذیرفته شده": "accepted",
|
||||
"تکمیل شده": "arrived",
|
||||
"تحویلشده": "arrived",
|
||||
"رسیده": "arrived",
|
||||
"باز": "offered",
|
||||
"پذیرفته": "accepted",
|
||||
"تحویل": "arrived",
|
||||
"تکمیل": "arrived",
|
||||
offered: "offered",
|
||||
accepted: "accepted",
|
||||
arrived: "arrived",
|
||||
@@ -42,6 +48,7 @@ export interface FilterToolbarProps {
|
||||
suppliers: { id: string; name: string }[];
|
||||
sortDir: "desc" | "asc";
|
||||
onToggleSort: () => void;
|
||||
stats?: { total: number; offered: number; accepted: number; arrived: number };
|
||||
}
|
||||
|
||||
export function FilterToolbar({
|
||||
@@ -50,97 +57,179 @@ export function FilterToolbar({
|
||||
suppliers,
|
||||
sortDir,
|
||||
onToggleSort,
|
||||
stats,
|
||||
}: FilterToolbarProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const set = (patch: Partial<Filters>) => onChange({ ...filters, ...patch });
|
||||
|
||||
// شمارش تعداد فیلترهای فعال به جز سرچ
|
||||
const activeFilterCount = [filters.supplier, filters.status].filter(Boolean).length;
|
||||
// شمارش تعداد فیلترهای فعال به جز سرچ و تب وضعیت
|
||||
const activeFilterCount = [filters.supplier].filter(Boolean).length;
|
||||
|
||||
const clearFilters = () => {
|
||||
onChange({ ...filters, supplier: "", status: "" });
|
||||
const clearSupplierFilter = () => {
|
||||
set({ supplier: "" });
|
||||
};
|
||||
|
||||
// تبدیل کلید انگلیسی به لیبل فارسی برای نمایش داخل کامبوباکس
|
||||
const statusDisplayValue = STATUS_MAP[filters.status] || filters.status;
|
||||
const clearAllFilters = () => {
|
||||
onChange({ supplier: "", status: "", search: "" });
|
||||
};
|
||||
|
||||
const normalizedStatus = STATUS_TO_KEY[filters.status] ?? filters.status ?? "";
|
||||
const statusDisplayValue = STATUS_MAP[normalizedStatus] || filters.status;
|
||||
|
||||
const handleStatusChange = (val: string) => {
|
||||
const key = STATUS_TO_KEY[val] || val;
|
||||
set({ status: key });
|
||||
};
|
||||
|
||||
const STATUS_TABS: {
|
||||
key: string;
|
||||
label: string;
|
||||
countKey?: keyof NonNullable<FilterToolbarProps["stats"]>;
|
||||
}[] = [
|
||||
{ key: "", label: "همه", countKey: "total" },
|
||||
{ key: "offered", label: "در انتظار تایید", countKey: "offered" },
|
||||
{ key: "accepted", label: "در حال خرید", countKey: "accepted" },
|
||||
{ key: "arrived", label: "تکمیل شده", countKey: "arrived" },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ===== نمای موبایل (صفحهنمایشهای زیر md) ===== */}
|
||||
<div className="flex md:hidden items-center gap-2">
|
||||
{/* اینپوت جستجوی متنی */}
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{/* ===== ردیف جستجو و کنترلهای فیلتر (تکردیف افقی فشرده) ===== */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* فیلد جستجوی متنی پروژه / کالا / تأمینکننده */}
|
||||
<div className="relative flex-1">
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-muted">
|
||||
<Search className="h-4 w-4" />
|
||||
</div>
|
||||
<input
|
||||
type="search"
|
||||
className="h-10 w-full rounded-xl border border-line bg-panel px-3 text-sm shadow-sm placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
placeholder="جستجو در نام، برند، پروژه..."
|
||||
className="h-10 w-full rounded-xl border border-line bg-panel pr-9 pl-8 text-xs sm:text-sm text-ink shadow-2xs placeholder:text-muted focus-visible:border-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/25 transition-all"
|
||||
placeholder="جستجو در پروژه، کالا، تأمینکننده..."
|
||||
value={filters.search}
|
||||
onChange={(e) => set({ search: e.target.value })}
|
||||
/>
|
||||
{filters.search && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => set({ search: "" })}
|
||||
className="absolute inset-y-0 left-0 flex items-center pl-2.5 text-muted hover:text-ink cursor-pointer"
|
||||
aria-label="پاک کردن جستجو"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* دکمه فیلترها با بج تعداد انتخابشده */}
|
||||
{/* دکمه باز کردن فیلترهای پیشرفته (تأمینکننده و ...) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(true)}
|
||||
className={`relative inline-flex h-10 items-center justify-center gap-1.5 rounded-xl border px-3 text-xs font-semibold shadow-sm transition-colors ${
|
||||
className={cn(
|
||||
"relative inline-flex h-10 items-center justify-center gap-1.5 rounded-xl border px-3 text-xs font-semibold shadow-2xs transition-all shrink-0 cursor-pointer",
|
||||
activeFilterCount > 0
|
||||
? "border-accent bg-accent-bg text-accent"
|
||||
: "border-line bg-panel text-ink hover:bg-slate-bg"
|
||||
}`}
|
||||
title="فیلترها"
|
||||
? "border-accent bg-accent-bg text-accent-dark ring-1 ring-accent/30"
|
||||
: "border-line bg-panel text-ink hover:bg-slate-50 dark:hover:bg-zinc-800",
|
||||
)}
|
||||
title="فیلترهای پیشرفته"
|
||||
>
|
||||
<SlidersHorizontal className="h-4 w-4" />
|
||||
<span>فیلتر</span>
|
||||
<span className="hidden xs:inline">فیلترها</span>
|
||||
{activeFilterCount > 0 && (
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-accent text-[11px] font-bold text-white">
|
||||
<span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-accent px-1 text-[10.5px] font-bold text-white">
|
||||
{activeFilterCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* دکمه تغییر جهت سورت */}
|
||||
{/* دکمه تغییر جهت مرتبسازی */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleSort}
|
||||
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-line bg-panel text-ink shadow-sm hover:bg-slate-bg"
|
||||
title={sortDir === "desc" ? "جدیدترین اول" : "قدیمیترین اول"}
|
||||
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-line bg-panel text-ink shadow-2xs hover:bg-slate-50 dark:hover:bg-zinc-800 transition-colors cursor-pointer"
|
||||
title={sortDir === "desc" ? "مرتبسازی: جدیدترین اول" : "مرتبسازی: قدیمیترین اول"}
|
||||
aria-label={sortDir === "desc" ? "مرتبسازی جدیدترین" : "مرتبسازی قدیمیترین"}
|
||||
>
|
||||
<ArrowUpDown className="h-4 w-4 text-accent" />
|
||||
<ArrowUpDown className={cn("h-4 w-4 text-accent transition-transform", sortDir === "asc" && "rotate-180")} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* باتمشیت کشویی فیلترها برای موبایل */}
|
||||
{/* ===== تبهای اسکرولافقی چیپ وضعیت (Filter Chips) ===== */}
|
||||
<div className="flex items-center gap-1.5 overflow-x-auto py-0.5 no-scrollbar -mx-1 px-1 select-none">
|
||||
{STATUS_TABS.map((tab) => {
|
||||
const isActive = (!normalizedStatus && !tab.key) || normalizedStatus === tab.key;
|
||||
const count = stats && tab.countKey ? stats[tab.countKey] : null;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.label}
|
||||
type="button"
|
||||
onClick={() => handleStatusChange(tab.key)}
|
||||
className={cn(
|
||||
"group inline-flex h-9 shrink-0 items-center justify-center gap-1.5 rounded-xl px-3 text-xs font-semibold transition-all cursor-pointer shadow-2xs active:scale-[0.98]",
|
||||
isActive
|
||||
? "bg-accent text-white shadow-sm ring-2 ring-accent/25"
|
||||
: "border border-line bg-panel text-ink/80 hover:bg-slate-50 dark:hover:bg-zinc-800 hover:text-ink",
|
||||
)}
|
||||
>
|
||||
<span>{tab.label}</span>
|
||||
{count != null && (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-md px-1.5 py-0.5 text-[10.5px] tabular-nums font-bold leading-none transition-colors",
|
||||
isActive
|
||||
? "bg-white/20 text-white"
|
||||
: "bg-slate-100 dark:bg-zinc-800 text-muted group-hover:text-ink",
|
||||
)}
|
||||
>
|
||||
{fmtNum(count)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* برچسب چیپ تأمینکننده در صورت فعال بودن با امکان حذف سریع */}
|
||||
{filters.supplier && (
|
||||
<span className="inline-flex h-9 shrink-0 items-center gap-1 rounded-xl border border-accent/40 bg-accent-bg px-2.5 text-xs font-semibold text-accent-dark">
|
||||
<span className="max-w-[120px] truncate">تأمینکننده: {filters.supplier}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearSupplierFilter}
|
||||
className="rounded-full p-0.5 hover:bg-accent/20 cursor-pointer"
|
||||
title="حذف فیلتر تأمینکننده"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ===== باتمشیت کشویی فیلترهای تکمیلی (موبایل و تبلت) ===== */}
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 z-50 flex flex-col justify-end bg-black/40 backdrop-blur-xs md:hidden">
|
||||
<div className="fixed inset-0 z-50 flex flex-col justify-end bg-black/40 backdrop-blur-xs">
|
||||
<div className="fixed inset-0" onClick={() => setIsOpen(false)} aria-hidden="true" />
|
||||
<div className="relative z-10 max-h-[85vh] w-full overflow-y-auto rounded-t-2xl border-t border-line bg-panel p-4 shadow-2xl">
|
||||
<div className="relative z-10 max-h-[85vh] w-full max-w-lg mx-auto overflow-y-auto rounded-t-2xl border-t border-line bg-panel p-4 sm:p-5 shadow-2xl">
|
||||
{/* هدر کشو */}
|
||||
<div className="mb-4 flex items-center justify-between border-b border-line pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<SlidersHorizontal className="h-4 w-4 text-accent" />
|
||||
<span className="text-sm font-bold text-ink">فیلترهای پیشرفته</span>
|
||||
<span className="text-sm font-bold text-ink">فیلترهای تکمیلی</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{activeFilterCount > 0 && (
|
||||
{(filters.supplier || filters.status || filters.search) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearFilters}
|
||||
className="inline-flex items-center gap-1 text-xs text-rose-500 hover:underline"
|
||||
onClick={clearAllFilters}
|
||||
className="inline-flex items-center gap-1 text-xs text-rose-600 dark:text-rose-400 font-semibold hover:underline cursor-pointer"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
پاک کردن
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
پاک کردن همه
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="rounded-lg p-1 text-muted hover:bg-slate-bg hover:text-ink"
|
||||
className="rounded-lg p-1 text-muted hover:bg-slate-50 dark:hover:bg-zinc-800 hover:text-ink cursor-pointer"
|
||||
aria-label="بستن"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
@@ -149,9 +238,9 @@ export function FilterToolbar({
|
||||
</div>
|
||||
|
||||
{/* دراپداونها داخل کشو */}
|
||||
<div className="flex flex-col gap-3.5">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="field">
|
||||
<label className="mb-1 block text-xs font-semibold text-muted">تأمینکننده</label>
|
||||
<label className="mb-1.5 block text-xs font-bold text-ink">تأمینکننده</label>
|
||||
<Combobox
|
||||
value={filters.supplier}
|
||||
onChange={(it: { id: string; label: string }) => set({ supplier: it.label })}
|
||||
@@ -163,7 +252,7 @@ export function FilterToolbar({
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="mb-1 block text-xs font-semibold text-muted">وضعیت</label>
|
||||
<label className="mb-1.5 block text-xs font-bold text-ink">وضعیت</label>
|
||||
<Combobox
|
||||
value={statusDisplayValue}
|
||||
onChange={(it: { id: string; label: string }) => handleStatusChange(it.label)}
|
||||
@@ -179,61 +268,13 @@ export function FilterToolbar({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="mt-5 flex h-11 w-full items-center justify-center rounded-xl bg-accent text-sm font-bold text-white shadow-md shadow-accent/20 active:scale-[0.99]"
|
||||
className="mt-6 flex h-11 w-full items-center justify-center rounded-xl bg-accent text-sm font-bold text-white shadow-md shadow-accent/20 active:scale-[0.99] transition-transform cursor-pointer"
|
||||
>
|
||||
مشاهده نتایج
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===== نمای دسکتاپ (md به بالا) ===== */}
|
||||
<div className="hidden md:flex flex-wrap items-end gap-3">
|
||||
<div className="field min-w-[170px] flex-1">
|
||||
<label>تأمینکننده</label>
|
||||
<Combobox
|
||||
value={filters.supplier}
|
||||
onChange={(it: { id: string; label: string }) => set({ supplier: it.label })}
|
||||
items={suppliers}
|
||||
allowAdd={false}
|
||||
placeholder="همه"
|
||||
onInputChange={(v) => set({ supplier: v })}
|
||||
/>
|
||||
</div>
|
||||
<div className="field min-w-[150px] flex-1">
|
||||
<label>وضعیت</label>
|
||||
<Combobox
|
||||
value={statusDisplayValue}
|
||||
onChange={(it: { id: string; label: string }) => handleStatusChange(it.label)}
|
||||
items={STATUS_OPTIONS}
|
||||
allowAdd={false}
|
||||
placeholder="همه"
|
||||
onInputChange={handleStatusChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="field min-w-[200px] flex-1">
|
||||
<label>جستجو</label>
|
||||
<input
|
||||
type="search"
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm shadow-sm placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
placeholder="جستجو در نام/برند/پروژه..."
|
||||
value={filters.search}
|
||||
onChange={(e) => set({ search: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="field min-w-[140px]">
|
||||
<label>ترتیب</label>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-9 w-full items-center justify-center gap-1.5 rounded-lg border border-line bg-panel px-3 text-sm shadow-sm hover:bg-slate-bg"
|
||||
onClick={onToggleSort}
|
||||
title={sortDir === "desc" ? "تغییر به قدیمیترین اول" : "تغییر به جدیدترین اول"}
|
||||
>
|
||||
<span aria-hidden className="text-accent">⇅</span>
|
||||
<span>{sortDir === "desc" ? "جدیدترین اول" : "قدیمیترین اول"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,29 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { AlarmClock, ChevronDown, Pencil, TriangleAlert, Building2, Package, CreditCard, Check } from "lucide-react";
|
||||
import {
|
||||
Building2,
|
||||
Boxes,
|
||||
Calendar,
|
||||
Hash,
|
||||
CreditCard,
|
||||
ChevronDown,
|
||||
Pencil,
|
||||
Package,
|
||||
TriangleAlert,
|
||||
AlarmClock,
|
||||
} from "lucide-react";
|
||||
import type { Quote, Store } from "../../lib/types";
|
||||
import {
|
||||
isExpired,
|
||||
materialById,
|
||||
materialLabel,
|
||||
formatMaterialTitle,
|
||||
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 { topicByName } from "../../lib/topics";
|
||||
import { fmtDate, fmtNum } from "../../lib/formatters";
|
||||
import { groupCommas } from "../../lib/numutil";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { QuoteDetail } from "./QuoteDetail";
|
||||
|
||||
@@ -27,318 +37,270 @@ export interface QuoteCardProps {
|
||||
canWrite?: boolean;
|
||||
}
|
||||
|
||||
function InfoCell({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={cn("min-w-0", className)}>
|
||||
<div className="mb-0.5 text-[10px] font-semibold text-muted sm:text-[10.5px]">{label}</div>
|
||||
<div className="min-w-0 text-xs leading-5 text-ink sm:text-[13px] sm:leading-6">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit, canWrite = true }: QuoteCardProps) {
|
||||
export function QuoteCard({
|
||||
store,
|
||||
q,
|
||||
expanded,
|
||||
onToggleExpand,
|
||||
onAccept,
|
||||
onEdit,
|
||||
canWrite = true,
|
||||
}: 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);
|
||||
|
||||
// ستون / دکمه پذیرش
|
||||
let acceptedCell: React.ReactNode;
|
||||
if (q.status === "offered") {
|
||||
acceptedCell = canWrite ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAccept();
|
||||
}}
|
||||
className="inline-flex h-7 items-center justify-center gap-1 rounded-md bg-emerald-600 px-2.5 text-xs font-bold text-white shadow-xs active:scale-95 transition-transform"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
<span>پذیرش</span>
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-muted text-xs font-medium">در انتظار پذیرش</span>
|
||||
);
|
||||
} else if (q.accepted_date) {
|
||||
acceptedCell = <span className="tabular-nums font-medium">{fmtDateTime(q.accepted_date)}</span>;
|
||||
} else {
|
||||
acceptedCell = <span className="text-muted">—</span>;
|
||||
}
|
||||
|
||||
// وضعیت رسید کالا
|
||||
const activeItems = (q.line_items || []).filter((li) => !li.rejected);
|
||||
const receivedSum = activeItems.reduce((s, li) => s + (li.received_qty || 0), 0);
|
||||
const orderedSum = activeItems.reduce((s, li) => s + (li.quantity || 0), 0);
|
||||
const fulfilledItems = activeItems.filter(
|
||||
(li) => li.quantity != null && li.quantity > 0 && (li.received_qty || 0) >= li.quantity,
|
||||
).length;
|
||||
const anyReceived = receivedSum > 0;
|
||||
const fullyDelivered = activeItems.length > 0 && fulfilledItems === activeItems.length && anyReceived;
|
||||
const primaryLineItem = activeItems[0];
|
||||
const primaryMaterial = primaryLineItem
|
||||
? materialById(store.materials, primaryLineItem.material_id)
|
||||
: null;
|
||||
|
||||
// نشان پیشرفت تکمیل
|
||||
let fulfillmentBadge: React.ReactNode = null;
|
||||
if (q.status === "accepted" || q.status === "arrived") {
|
||||
if (fullyDelivered || q.status === "arrived") {
|
||||
fulfillmentBadge = (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-emerald-200 bg-emerald-50 px-2 py-0.5 text-[10.5px] font-bold text-emerald-700 sm:text-[11px]">
|
||||
✓ تکمیل
|
||||
</span>
|
||||
);
|
||||
} else if (anyReceived) {
|
||||
fulfillmentBadge = (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-[10.5px] font-bold text-amber-700 sm:text-[11px]">
|
||||
رسید: {fmtNum(fulfilledItems)}/{fmtNum(activeItems.length)}
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
fulfillmentBadge = (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-blue-200 bg-blue-50 px-2 py-0.5 text-[10.5px] font-bold text-blue-700 sm:text-[11px]">
|
||||
⏳ در راه
|
||||
</span>
|
||||
);
|
||||
}
|
||||
// عنوان اصلی کالا / متریال
|
||||
let itemTitle = "پیشنهاد بدون قلم";
|
||||
if (activeItems.length === 1 && primaryMaterial) {
|
||||
itemTitle = formatMaterialTitle(primaryMaterial);
|
||||
} else if (activeItems.length > 1 && primaryMaterial) {
|
||||
itemTitle = `${formatMaterialTitle(primaryMaterial)} (+${fmtNum(activeItems.length - 1)} قلم دیگر)`;
|
||||
}
|
||||
|
||||
let arrivedCell: React.ReactNode;
|
||||
const lastReceiptAt = activeItems.reduce<Date | null>((acc, li) => {
|
||||
if (!li.arrived_date) return acc;
|
||||
const t = new Date(li.arrived_date).getTime();
|
||||
if (Number.isNaN(t)) return acc;
|
||||
return !acc || t > acc.getTime() ? new Date(li.arrived_date) : acc;
|
||||
}, null);
|
||||
// آیکون دستهبندی
|
||||
const topicName = primaryMaterial?.topic || sup.topic;
|
||||
const topic = topicByName(topicName, store.topics);
|
||||
const categoryIcon = topic?.icon;
|
||||
|
||||
if (q.status === "accepted") {
|
||||
if (q.arrived_date) {
|
||||
arrivedCell = <span className="tabular-nums">{fmtDateTime(q.arrived_date)}</span>;
|
||||
} else if (receivedSum > 0) {
|
||||
arrivedCell = (
|
||||
<span className="tabular-nums">
|
||||
{fmtNum(receivedSum)} از {fmtNum(orderedSum)}
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
arrivedCell = <span className="text-muted">در انتظار انبار</span>;
|
||||
}
|
||||
// محاسبه مجموع مقدار و واحدها
|
||||
let quantityDisplay = "—";
|
||||
if (activeItems.length === 1 && primaryLineItem) {
|
||||
quantityDisplay =
|
||||
primaryLineItem.quantity != null
|
||||
? `${fmtNum(primaryLineItem.quantity)} ${primaryLineItem.unit || ""}`
|
||||
: "—";
|
||||
} else if (activeItems.length > 1) {
|
||||
const totalQty = activeItems.reduce((acc, li) => acc + (li.quantity || 0), 0);
|
||||
const uniqueUnits = [...new Set(activeItems.map((li) => li.unit).filter(Boolean))].join("، ");
|
||||
quantityDisplay = `${fmtNum(totalQty)} ${uniqueUnits || "واحد"} (${fmtNum(activeItems.length)} قلم)`;
|
||||
}
|
||||
|
||||
// نام پروژه
|
||||
const projectName =
|
||||
q.projects && q.projects.length > 0 ? q.projects.join("، ") : projectCell(q);
|
||||
|
||||
// بج وضعیت با کدگذاری رنگی معنایی (Semantic Color Coding)
|
||||
let statusBadge: React.ReactNode;
|
||||
if (expired) {
|
||||
statusBadge = (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-amber-300 bg-amber-50 dark:bg-amber-950/40 px-2 py-0.5 text-[11px] font-bold text-amber-700 dark:text-amber-400">
|
||||
<AlarmClock className="h-3 w-3 shrink-0" />
|
||||
منقضی شده
|
||||
</span>
|
||||
);
|
||||
} else if (q.status === "arrived") {
|
||||
arrivedCell = (
|
||||
<span className="tabular-nums">
|
||||
{fmtDateTime(q.arrived_date || (lastReceiptAt ? lastReceiptAt.toISOString() : ""))}
|
||||
statusBadge = (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-emerald-200 dark:border-emerald-800 bg-emerald-50 dark:bg-emerald-950/40 px-2.5 py-0.5 text-[11px] font-bold text-emerald-700 dark:text-emerald-300">
|
||||
✓ تکمیل شده
|
||||
</span>
|
||||
);
|
||||
} else if (q.status === "accepted") {
|
||||
const fulfilledItems = activeItems.filter(
|
||||
(li) => li.quantity != null && li.quantity > 0 && (li.received_qty || 0) >= li.quantity,
|
||||
).length;
|
||||
const anyReceived = activeItems.some((li) => (li.received_qty || 0) > 0);
|
||||
|
||||
statusBadge = (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-950/40 px-2.5 py-0.5 text-[11px] font-bold text-blue-700 dark:text-blue-300">
|
||||
{anyReceived ? `در حال خرید (${fmtNum(fulfilledItems)}/${fmtNum(activeItems.length)})` : "در حال خرید"}
|
||||
</span>
|
||||
);
|
||||
} else if (q.status === "rejected") {
|
||||
statusBadge = (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-rose-200 dark:border-rose-800 bg-rose-50 dark:bg-rose-950/40 px-2.5 py-0.5 text-[11px] font-bold text-rose-700 dark:text-rose-300">
|
||||
رد شده
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
arrivedCell = <span className="text-muted">—</span>;
|
||||
// offered / پیشفرض در انتظار تایید
|
||||
statusBadge = (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-950/40 px-2.5 py-0.5 text-[11px] font-bold text-amber-700 dark:text-amber-300">
|
||||
در انتظار تایید
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-xl border bg-panel transition-colors shadow-2xs sm:rounded-lg",
|
||||
expired ? "border-amber/40" : "border-line hover:border-accent/40",
|
||||
expanded && "border-accent/60 ring-1 ring-accent/20",
|
||||
"overflow-hidden rounded-2xl border bg-panel transition-all shadow-2xs",
|
||||
expired ? "border-amber-300/80 bg-amber-50/10" : "border-line hover:border-accent/40",
|
||||
expanded && "border-accent/60 ring-2 ring-accent/15 shadow-sm",
|
||||
)}
|
||||
>
|
||||
{/* سربرگ کارت — با کلیک روی فضای خالی اکسپند میشود */}
|
||||
<div
|
||||
onClick={onToggleExpand}
|
||||
className="flex cursor-pointer items-center gap-2.5 border-b border-line bg-bg/40 px-3 py-2 sm:gap-3 sm:px-4 sm:py-2.5 select-none"
|
||||
>
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-accent-bg text-xs font-bold text-accent-dark sm:h-9 sm:w-9 sm:text-sm">
|
||||
{sup.name.slice(0, 1)}
|
||||
</span>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* خط ۱: فقط خود اسم تأمینکننده لینک است */}
|
||||
<div className="flex items-center">
|
||||
<Link
|
||||
to={`/suppliers/${q.supplier_id}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="inline-block max-w-full truncate text-xs font-bold text-ink no-underline hover:text-accent hover:underline sm:text-[13.5px]"
|
||||
>
|
||||
{sup.name}
|
||||
</Link>
|
||||
{/* ===== سربرگ کارت (Header: راست عنوان و آیکون / چپ وضعیت) ===== */}
|
||||
<div className="flex items-start justify-between gap-3 border-b border-line/70 bg-slate-50/50 dark:bg-zinc-800/30 p-3.5 sm:p-4">
|
||||
{/* بخش راست (RTL): آیکون دستهبندی + نام کالا + تأمینکننده */}
|
||||
<div className="flex items-start gap-2.5 min-w-0 flex-1">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-accent-bg text-accent-dark overflow-hidden shadow-2xs border border-accent/20">
|
||||
{categoryIcon ? (
|
||||
<img src={categoryIcon} alt="" className="h-6 w-6 object-contain" loading="lazy" />
|
||||
) : (
|
||||
<Package className="h-5 w-5 text-accent" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* خط ۲: موقعیت مکانی و برچسب هشدار فاکتور رسمی */}
|
||||
<div className="mt-0.5 flex min-w-0 items-center gap-1.5">
|
||||
{sup.location && (
|
||||
<span className="truncate text-[10px] text-muted sm:text-[11px]">
|
||||
{sup.location}
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3
|
||||
className="truncate text-sm sm:text-[15px] font-bold text-ink leading-tight"
|
||||
title={itemTitle}
|
||||
>
|
||||
{itemTitle}
|
||||
</h3>
|
||||
|
||||
{q.is_official && (
|
||||
<span
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded bg-rose-500/10 px-1.5 py-0.5 text-[9.5px] font-bold text-rose-600 dark:text-rose-400 border border-rose-500/25 select-none"
|
||||
title="فاکتور رسمی (مشمول ارزش افزوده و مالیات)"
|
||||
{/* سابتایتل: نام تأمینکننده، موقعیت و فاکتور رسمی */}
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-muted">
|
||||
<Link
|
||||
to={`/suppliers/${q.supplier_id}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="font-semibold text-muted hover:text-accent truncate no-underline hover:underline transition-colors"
|
||||
>
|
||||
<TriangleAlert className="h-2.5 w-2.5 shrink-0 stroke-[2.2] text-rose-500 dark:text-rose-400" />
|
||||
<span>فاکتور رسمی</span>
|
||||
</span>
|
||||
)}
|
||||
🏢 {sup.name}
|
||||
</Link>
|
||||
|
||||
{sup.location && <span className="text-muted/60">· {sup.location}</span>}
|
||||
|
||||
{q.is_official && (
|
||||
<span
|
||||
className="inline-flex items-center gap-0.5 rounded px-1.5 py-0.2 text-[9.5px] font-bold bg-rose-500/10 text-rose-600 dark:text-rose-400 border border-rose-500/25 select-none"
|
||||
title="فاکتور رسمی (مشمول ارزش افزوده و مالیات)"
|
||||
>
|
||||
<TriangleAlert className="h-2.5 w-2.5 shrink-0" />
|
||||
فاکتور رسمی
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ms-auto flex shrink-0 items-center gap-1.5">
|
||||
{fulfillmentBadge ?? (
|
||||
<Badge variant={q.status as "offered" | "accepted" | "arrived"} className="text-[10px] sm:text-xs">
|
||||
{STATUS_LABEL[q.status]}
|
||||
</Badge>
|
||||
)}
|
||||
{/* بخش چپ: بج وضعیت */}
|
||||
<div className="shrink-0 pt-0.5">{statusBadge}</div>
|
||||
</div>
|
||||
|
||||
{/* دکمه ویرایش */}
|
||||
{canWrite && (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-7 items-center justify-center gap-1 rounded-md border border-line bg-panel px-2 text-xs font-medium text-ink hover:bg-slate-50 dark:hover:bg-zinc-800 transition-colors sm:h-8 sm:px-2.5"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
title="ویرایش پیشنهاد"
|
||||
aria-label="ویرایش"
|
||||
>
|
||||
<Pencil className="h-3 w-3 text-muted" />
|
||||
<span className="hidden sm:inline">ویرایش</span>
|
||||
</button>
|
||||
)}
|
||||
{/* ===== بدنه کارت (Body: گرید دو ستونه فشرده متادیتا) ===== */}
|
||||
<div className="p-3.5 sm:p-4 flex flex-col gap-2.5">
|
||||
<div className="grid grid-cols-2 gap-2 sm:gap-2.5">
|
||||
{/* ۱. پروژه */}
|
||||
<div className="flex items-center gap-2 min-w-0 rounded-xl bg-slate-50/70 dark:bg-zinc-800/40 border border-line/50 p-2 sm:p-2.5">
|
||||
<Building2 className="h-4 w-4 shrink-0 text-muted" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block text-[10px] font-medium text-muted">پروژه</span>
|
||||
<span className="block truncate text-xs font-bold text-ink" title={projectName}>
|
||||
{projectName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* دکمه فلش اکسپند در هدر */}
|
||||
{/* ۲. مقدار */}
|
||||
<div className="flex items-center gap-2 min-w-0 rounded-xl bg-slate-50/70 dark:bg-zinc-800/40 border border-line/50 p-2 sm:p-2.5">
|
||||
<Boxes className="h-4 w-4 shrink-0 text-muted" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block text-[10px] font-medium text-muted">مقدار</span>
|
||||
<span className="block truncate text-xs font-bold text-ink tabular-nums" title={quantityDisplay}>
|
||||
{quantityDisplay}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ۳. تاریخ */}
|
||||
<div className="flex items-center gap-2 min-w-0 rounded-xl bg-slate-50/70 dark:bg-zinc-800/40 border border-line/50 p-2 sm:p-2.5">
|
||||
<Calendar className="h-4 w-4 shrink-0 text-muted" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block text-[10px] font-medium text-muted">تاریخ درخواست</span>
|
||||
<span className="block truncate text-xs font-bold text-ink tabular-nums">
|
||||
{fmtDate(q.quote_date)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ۴. کد پیگیری */}
|
||||
<div className="flex items-start gap-2 min-w-0 rounded-xl bg-slate-50/70 dark:bg-zinc-800/40 border border-line/50 p-2 sm:p-2.5">
|
||||
<Hash className="h-4 w-4 shrink-0 text-muted mt-0.5" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block text-[10px] font-medium text-muted">کد پیگیری</span>
|
||||
<span className="block font-mono font-bold text-[11px] sm:text-xs text-ink break-all select-all leading-tight">
|
||||
{q.id.startsWith("PR-") ? q.id : `PR-${q.id}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* سطر قیمت و شرایط پرداخت (در صورت وجود) */}
|
||||
{total > 0 && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-xl bg-accent-bg/40 dark:bg-accent-bg/10 border border-accent/15 px-3 py-2 text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CreditCard className="h-3.5 w-3.5 text-accent" />
|
||||
<span className="text-[11px] text-muted">مبلغ کل:</span>
|
||||
<span className="text-xs sm:text-sm font-extrabold text-ink tabular-nums">
|
||||
{groupCommas(total, true)} تومان
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{ts.title !== "—" && (
|
||||
<span className="text-[11px] font-medium text-muted truncate">
|
||||
شرایط: {ts.title} {ts.sub ? `(${ts.sub})` : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ===== فوتر کارت (Footer: اکشنهای استاندارد با تاچ تارگت حداقل ۴۰px) ===== */}
|
||||
<div className="flex items-center justify-between gap-2 border-t border-line/70 bg-slate-50/40 dark:bg-zinc-800/20 px-3.5 py-2.5 sm:px-4">
|
||||
{/* دکمه اصلی مشاهده / بستن جزئیات */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleExpand}
|
||||
className="min-h-[40px] h-10 flex-1 inline-flex items-center justify-center gap-1.5 rounded-xl border border-line bg-panel hover:bg-slate-50 dark:hover:bg-zinc-800 text-xs font-bold text-ink px-4 transition-all shadow-2xs cursor-pointer select-none active:scale-[0.99]"
|
||||
>
|
||||
<span>{expanded ? "بستن جزئیات" : "مشاهده جزئیات"}</span>
|
||||
<ChevronDown
|
||||
className={cn("h-4 w-4 transition-transform text-muted duration-200", expanded && "rotate-180 text-accent")}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* دکمههای عملیاتی برای کاربران دارای دسترسی نوشتن */}
|
||||
{canWrite && q.status === "offered" && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex h-7 w-7 items-center justify-center rounded-md border border-line bg-panel text-ink transition-colors hover:bg-slate-50 dark:hover:bg-zinc-800 sm:h-8 sm:w-8",
|
||||
expanded && "bg-accent-bg text-accent-dark border-accent/40",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleExpand();
|
||||
onAccept();
|
||||
}}
|
||||
title={expanded ? "جمع کردن توضیحات" : "نمایش توضیحات و جزئیات"}
|
||||
aria-expanded={expanded}
|
||||
className="min-h-[40px] h-10 inline-flex items-center justify-center gap-1.5 rounded-xl bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white text-xs font-bold px-3.5 shadow-xs transition-all cursor-pointer active:scale-95 shrink-0"
|
||||
>
|
||||
<ChevronDown className={cn("h-4 w-4 transition-transform duration-200", expanded && "rotate-180")} />
|
||||
<span>پذیرش پیشنهاد</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canWrite && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
className="min-h-[40px] h-10 inline-flex items-center justify-center gap-1 rounded-xl border border-line bg-panel hover:bg-slate-50 dark:hover:bg-zinc-800 text-xs font-semibold text-muted hover:text-ink px-3 transition-colors cursor-pointer shrink-0"
|
||||
title="ویرایش پیشنهاد"
|
||||
aria-label="ویرایش"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">ویرایش</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* اطلاعات کلیدی — لمس این بخش هم کارت را اکسپند میکند */}
|
||||
<div
|
||||
onClick={onToggleExpand}
|
||||
className="cursor-pointer grid grid-cols-2 gap-x-3 gap-y-3 p-3 select-none sm:gap-x-5 sm:gap-y-3.5 sm:p-4 md:grid-cols-4"
|
||||
>
|
||||
{/* ۱. پروژه */}
|
||||
<InfoCell label="پروژه" className="col-span-2 md:col-span-1">
|
||||
<div className="flex items-center gap-1.5 text-xs font-semibold text-ink sm:text-[13px]">
|
||||
<Building2 className="h-3.5 w-3.5 shrink-0 text-muted" />
|
||||
<span className="truncate">
|
||||
{q.projects && q.projects.length > 0 ? q.projects.join("، ") : projectCell(q)}
|
||||
</span>
|
||||
</div>
|
||||
</InfoCell>
|
||||
|
||||
{/* ۲. اقلام */}
|
||||
<InfoCell label="اقلام" className="col-span-2 md:col-span-1">
|
||||
<div className="flex flex-col gap-1 text-xs font-medium leading-5 text-ink sm:text-[13px]">
|
||||
{activeItems.length > 0 ? (
|
||||
activeItems.map((li) => {
|
||||
const m = materialById(store.materials, li.material_id);
|
||||
return (
|
||||
<span key={li.id} className="break-words">
|
||||
{materialLabel(m)}
|
||||
</span>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<span>—</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{unitWarns.map((w) => (
|
||||
<Link
|
||||
key={w.unit}
|
||||
to={`/units?cat=${encodeURIComponent(w.cat)}&unit=${encodeURIComponent(w.unit)}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="unit-warn mt-1 inline-flex items-center gap-1 text-[10px]"
|
||||
title={`ضریب تبدیل «${esc(w.unit)}» را در داشبورد واحدها ثبت کنید`}
|
||||
>
|
||||
<TriangleAlert className="h-2.5 w-2.5 shrink-0" />
|
||||
واحد جدید: {w.unit}
|
||||
</Link>
|
||||
))}
|
||||
</InfoCell>
|
||||
|
||||
{/* ۳. مجموع مبلغ */}
|
||||
<InfoCell label="مجموع مبلغ" className="col-span-1">
|
||||
{pc ? (
|
||||
<div>
|
||||
<div className="text-sm font-extrabold tabular-nums text-ink sm:text-base">
|
||||
{pc.num} <span className="text-[10px] font-normal text-muted sm:text-xs">تومان</span>
|
||||
</div>
|
||||
<div className="hidden text-[11px] text-muted sm:block">({pc.words})</div>
|
||||
</div>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</InfoCell>
|
||||
|
||||
{/* ۴. شرایط معامله */}
|
||||
<InfoCell label="شرایط معامله" className="col-span-1">
|
||||
{ts.title !== "—" ? (
|
||||
<div>
|
||||
<div className="text-xs font-semibold sm:text-[13px]" style={ts.color ? { color: ts.color } : undefined}>
|
||||
{ts.title}
|
||||
</div>
|
||||
{ts.sub && <div className="truncate text-[10px] text-muted sm:text-[11px]">{ts.sub}</div>}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted">—</span>
|
||||
)}
|
||||
</InfoCell>
|
||||
</div>
|
||||
|
||||
{/* تاریخها و اکشنها */}
|
||||
<div className="grid grid-cols-2 gap-2 border-t border-line bg-bg/40 p-2.5 text-[11px] text-muted sm:flex sm:flex-wrap sm:items-center sm:gap-x-5 sm:gap-y-2 sm:px-4 sm:py-2 sm:text-[11.5px]">
|
||||
<span className="inline-flex items-center gap-1.5 truncate">
|
||||
<Package className="h-3 w-3 shrink-0" />
|
||||
<span>تاریخ:</span>
|
||||
<b className="tabular-nums text-ink">{fmtDate(q.quote_date)}</b>
|
||||
</span>
|
||||
|
||||
<span className="inline-flex items-center gap-1.5 truncate">
|
||||
<CreditCard className="h-3 w-3 shrink-0" />
|
||||
<span>اعتبار:</span>
|
||||
<b className={cn("tabular-nums", expired ? "text-amber" : "text-ink")}>
|
||||
{fmtDate(q.expiry_date)}
|
||||
</b>
|
||||
{expired && <AlarmClock className="h-3 w-3 shrink-0 text-amber" aria-label="منقضی" />}
|
||||
</span>
|
||||
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span>پذیرش:</span>
|
||||
{acceptedCell}
|
||||
</span>
|
||||
|
||||
<span className="inline-flex items-center gap-1.5 truncate">
|
||||
<span>رسید:</span>
|
||||
{arrivedCell}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* دکمه تمامعرض توضیحات بیشتر */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleExpand}
|
||||
className="flex w-full items-center justify-center gap-1.5 border-t border-line bg-bg/20 py-2 text-xs font-semibold text-muted hover:text-accent hover:bg-bg/50 transition-colors select-none"
|
||||
>
|
||||
<span>{expanded ? "بستن توضیحات و جزئیات" : "توضیحات بیشتر و جزئیات پیشنهاد"}</span>
|
||||
<ChevronDown className={cn("h-3.5 w-3.5 transition-transform duration-200", expanded && "rotate-180 text-accent")} />
|
||||
</button>
|
||||
|
||||
{/* جزئیات تکمیلی بازشونده — کلیک داخل آن مانع بسته شدن ناخواسته میشود */}
|
||||
{/* ===== بخش جزئیات بازشونده (Expanded Detail) ===== */}
|
||||
{expanded && (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<div className="border-t border-line" onClick={(e) => e.stopPropagation()}>
|
||||
<QuoteDetail store={store} q={q} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Package, CreditCard, CalendarDays, Paperclip, Building2, Ban } from "lucide-react";
|
||||
import {
|
||||
Package,
|
||||
CreditCard,
|
||||
CalendarDays,
|
||||
Paperclip,
|
||||
Building2,
|
||||
Ban,
|
||||
Hash,
|
||||
Copy,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import type { Attachment, Quote, Store } from "../../lib/types";
|
||||
import { api } from "../../lib/api";
|
||||
import {
|
||||
@@ -13,6 +24,7 @@ import { groupCommas } from "../../lib/numutil";
|
||||
import { FlowDiagram, type FlowRefs } from "./FlowDiagram";
|
||||
import { itemProjects } from "../../lib/quoteUtils";
|
||||
import { FilePreviewModal, useFilePreview } from "../../components/FilePreviewModal";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export interface QuoteDetailProps {
|
||||
store: Store;
|
||||
@@ -24,6 +36,31 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
|
||||
const [flowEl, setFlowEl] = useState<HTMLDivElement | null>(null);
|
||||
const [flowRefs] = useState<FlowRefs>(() => ({ items: new Map(), termsheets: new Map() }));
|
||||
const [bump, setBump] = useState(0);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const trackingCode = q.id.startsWith("PR-") ? q.id : `PR-${q.id}`;
|
||||
|
||||
const handleCopyCode = async () => {
|
||||
try {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(trackingCode);
|
||||
} else {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = trackingCode;
|
||||
ta.style.position = "fixed";
|
||||
ta.style.opacity = "0";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
setCopied(true);
|
||||
toast.success("کد پیگیری با موفقیت کپی شد");
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
toast.error("کپی کد پیگیری با خطا مواجه شد");
|
||||
}
|
||||
};
|
||||
|
||||
// پیشنمایش پیوستها: کلیک روی نام فایل، فایل را نشان میدهد (نه دانلود).
|
||||
// بایتها با توکن گرفته میشوند چون <img>/<iframe> نمیتوانند هدر بفرستند.
|
||||
@@ -180,6 +217,39 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
|
||||
|
||||
return (
|
||||
<div ref={setFlowEl} className="flow border-t border-line" data-status={q.status}>
|
||||
{/* بخش برجسته و اختصاصی کد پیگیری */}
|
||||
<div className="mb-3.5 flex flex-wrap items-center justify-between gap-2.5 rounded-xl border border-line bg-panel p-3 sm:px-4 sm:py-3 shadow-2xs">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-accent-bg text-accent shadow-2xs border border-accent/20">
|
||||
<Hash className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<span className="block text-[10.5px] font-semibold text-muted">کد پیگیری پیشنهاد</span>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="font-mono text-xs sm:text-sm font-bold text-ink select-all break-all leading-tight">
|
||||
{trackingCode}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* دکمه کپی کد پیگیری */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyCode}
|
||||
className={cn(
|
||||
"inline-flex h-8 items-center gap-1.5 rounded-xl border px-3 text-xs font-bold transition-all cursor-pointer shadow-2xs active:scale-95 shrink-0",
|
||||
copied
|
||||
? "border-emerald-300 bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300 dark:border-emerald-800"
|
||||
: "border-line bg-slate-50 dark:bg-zinc-800 text-ink hover:bg-slate-100 dark:hover:bg-zinc-700 hover:border-accent/40",
|
||||
)}
|
||||
title="کپی کد پیگیری در کلیپبورد"
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5 text-emerald-600" /> : <Copy className="h-3.5 w-3.5 text-muted" />}
|
||||
<span>{copied ? "کپی شد" : "کپی کد"}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flow-cols">
|
||||
<div className="flow-col flow-items-col">
|
||||
<div className="flow-col-h">
|
||||
|
||||
@@ -62,6 +62,15 @@ button:disabled {
|
||||
}
|
||||
}
|
||||
|
||||
/* پنهانسازی اسکرولبار برای چیپهای افقی */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
/* ---------- تایپوگرافی ---------- */
|
||||
.num-words {
|
||||
@apply text-muted;
|
||||
|
||||
Reference in New Issue
Block a user