Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1953e26356 |
@@ -62,7 +62,7 @@ export function formatMaterialTitle(
|
|||||||
const normName = normalize(rawName);
|
const normName = normalize(rawName);
|
||||||
if (normName.startsWith(normBrand)) {
|
if (normName.startsWith(normBrand)) {
|
||||||
const rest = rawName.slice(rawBrand.length).trim();
|
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> = {
|
const STATUS_BY_LABEL: Record<string, string> = {
|
||||||
"پیشنهاد شده": "offered",
|
"پیشنهاد شده": "offered",
|
||||||
|
"در انتظار تایید": "offered",
|
||||||
|
"در انتظار تأیید": "offered",
|
||||||
"پذیرفته شده": "accepted",
|
"پذیرفته شده": "accepted",
|
||||||
|
"در حال خرید": "accepted",
|
||||||
"رسیده": "arrived",
|
"رسیده": "arrived",
|
||||||
|
"تکمیل شده": "arrived",
|
||||||
"باز": "offered",
|
"باز": "offered",
|
||||||
"پذیرفته": "accepted",
|
"پذیرفته": "accepted",
|
||||||
|
"تکمیل": "arrived",
|
||||||
"offered": "offered",
|
"offered": "offered",
|
||||||
"accepted": "accepted",
|
"accepted": "accepted",
|
||||||
"arrived": "arrived",
|
"arrived": "arrived",
|
||||||
|
"rejected": "rejected",
|
||||||
|
"رد شده": "rejected",
|
||||||
|
"expired": "expired",
|
||||||
|
"منقضی شده": "expired",
|
||||||
};
|
};
|
||||||
const stKey = STATUS_BY_LABEL[stVal] || stVal;
|
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));
|
const matInfo = activeItems(quote).map((li) => materialById(store.materials, li.material_id));
|
||||||
|
|
||||||
if (q) {
|
if (q) {
|
||||||
|
const projectsText = `${(quote.projects || []).join(" ")} ${quote.project || ""}`;
|
||||||
const hay =
|
const hay =
|
||||||
`${supObj.name} ${quote.project || ""} ` +
|
`${quote.id} PR-${quote.id} ${supObj.name} ${projectsText} ` +
|
||||||
matInfo.map((m) => `${m.brand} ${materialSpecs(m).join(" ")}`).join(" ");
|
matInfo.map((m) => `${m.name} ${m.brand} ${materialSpecs(m).join(" ")}`).join(" ");
|
||||||
if (!hay.toLowerCase().includes(q)) return false;
|
if (!hay.toLowerCase().includes(q)) return false;
|
||||||
}
|
}
|
||||||
return true;
|
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">
|
<div className="px-6 py-4 bg-white border-b border-line shrink-0 z-10 text-right">
|
||||||
<DialogHeader className="p-0 text-right">
|
<DialogHeader className="p-0 text-right">
|
||||||
<DialogTitle className="text-right text-lg font-bold text-slate-800">
|
<div className="flex items-center justify-between gap-3">
|
||||||
پذیرش پیشنهاد — {sup.name}
|
<DialogTitle className="text-right text-lg font-bold text-slate-800">
|
||||||
</DialogTitle>
|
پذیرش پیشنهاد — {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>
|
</DialogHeader>
|
||||||
<p className="text-[13px] text-muted mt-1 text-right">
|
<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">
|
<PageLayout className="flex flex-col gap-3 px-3 md:px-6">
|
||||||
<StatsBar
|
<div className="hidden md:block">
|
||||||
total={stats.total}
|
<StatsBar
|
||||||
offered={stats.offered}
|
total={stats.total}
|
||||||
accepted={stats.accepted}
|
offered={stats.offered}
|
||||||
arrived={stats.arrived}
|
accepted={stats.accepted}
|
||||||
activeFilter={activeStatusFilter}
|
arrived={stats.arrived}
|
||||||
onFilterChange={handleStatusFilterChange}
|
activeFilter={activeStatusFilter}
|
||||||
/>
|
onFilterChange={handleStatusFilterChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<FilterToolbar
|
<FilterToolbar
|
||||||
filters={filters}
|
filters={filters}
|
||||||
onChange={setFilters}
|
onChange={setFilters}
|
||||||
suppliers={supplierOptions}
|
suppliers={supplierOptions}
|
||||||
sortDir={sortDir}
|
sortDir={sortDir}
|
||||||
onToggleSort={() => setSortDir((d) => (d === "desc" ? "asc" : "desc"))}
|
onToggleSort={() => setSortDir((d) => (d === "desc" ? "asc" : "desc"))}
|
||||||
|
stats={stats}
|
||||||
/>
|
/>
|
||||||
<LedgerTable
|
<LedgerTable
|
||||||
store={store}
|
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">
|
<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">
|
<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">
|
<div className="text-[12px] text-gray-500 truncate mt-0.5">
|
||||||
{supplierName ? `تأمینکننده: ${supplierName}` : "تنظیم اقلام، مبالغ و شرایط پرداخت"}
|
{supplierName ? `تأمینکننده: ${supplierName}` : "تنظیم اقلام، مبالغ و شرایط پرداخت"}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useState } from "react";
|
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 { Combobox } from "../../components/Combobox";
|
||||||
|
import { fmtNum } from "../../lib/formatters";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
export interface Filters {
|
export interface Filters {
|
||||||
supplier: string;
|
supplier: string;
|
||||||
@@ -8,29 +10,33 @@ export interface Filters {
|
|||||||
search: string;
|
search: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// گزینههای استاندارد و یکپارچه وضعیت هماهنگ با کارتهای بالای صفحه
|
// گزینههای استاندارد وضعیت برای دراپداون فیلترهای پیشرفته
|
||||||
const STATUS_OPTIONS = [
|
const STATUS_OPTIONS = [
|
||||||
{ id: "offered", label: "پیشنهاد باز" },
|
{ id: "offered", label: "در انتظار تایید" },
|
||||||
{ id: "accepted", label: "پذیرفتهشده" },
|
{ id: "accepted", label: "در حال خرید" },
|
||||||
{ id: "arrived", label: "تحویلشده" },
|
{ id: "arrived", label: "تکمیل شده" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const STATUS_MAP: Record<string, string> = {
|
const STATUS_MAP: Record<string, string> = {
|
||||||
offered: "پیشنهاد باز",
|
offered: "در انتظار تایید",
|
||||||
accepted: "پذیرفتهشده",
|
accepted: "در حال خرید",
|
||||||
arrived: "تحویلشده",
|
arrived: "تکمیل شده",
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_TO_KEY: Record<string, string> = {
|
const STATUS_TO_KEY: Record<string, string> = {
|
||||||
|
"در انتظار تایید": "offered",
|
||||||
|
"در انتظار تأیید": "offered",
|
||||||
"پیشنهاد باز": "offered",
|
"پیشنهاد باز": "offered",
|
||||||
"پذیرفتهشده": "accepted",
|
|
||||||
"تحویلشده": "arrived",
|
|
||||||
"پیشنهاد شده": "offered",
|
"پیشنهاد شده": "offered",
|
||||||
|
"در حال خرید": "accepted",
|
||||||
|
"پذیرفتهشده": "accepted",
|
||||||
"پذیرفته شده": "accepted",
|
"پذیرفته شده": "accepted",
|
||||||
|
"تکمیل شده": "arrived",
|
||||||
|
"تحویلشده": "arrived",
|
||||||
"رسیده": "arrived",
|
"رسیده": "arrived",
|
||||||
"باز": "offered",
|
"باز": "offered",
|
||||||
"پذیرفته": "accepted",
|
"پذیرفته": "accepted",
|
||||||
"تحویل": "arrived",
|
"تکمیل": "arrived",
|
||||||
offered: "offered",
|
offered: "offered",
|
||||||
accepted: "accepted",
|
accepted: "accepted",
|
||||||
arrived: "arrived",
|
arrived: "arrived",
|
||||||
@@ -42,6 +48,7 @@ export interface FilterToolbarProps {
|
|||||||
suppliers: { id: string; name: string }[];
|
suppliers: { id: string; name: string }[];
|
||||||
sortDir: "desc" | "asc";
|
sortDir: "desc" | "asc";
|
||||||
onToggleSort: () => void;
|
onToggleSort: () => void;
|
||||||
|
stats?: { total: number; offered: number; accepted: number; arrived: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FilterToolbar({
|
export function FilterToolbar({
|
||||||
@@ -50,97 +57,179 @@ export function FilterToolbar({
|
|||||||
suppliers,
|
suppliers,
|
||||||
sortDir,
|
sortDir,
|
||||||
onToggleSort,
|
onToggleSort,
|
||||||
|
stats,
|
||||||
}: FilterToolbarProps) {
|
}: FilterToolbarProps) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const set = (patch: Partial<Filters>) => onChange({ ...filters, ...patch });
|
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 = () => {
|
const clearSupplierFilter = () => {
|
||||||
onChange({ ...filters, supplier: "", status: "" });
|
set({ supplier: "" });
|
||||||
};
|
};
|
||||||
|
|
||||||
// تبدیل کلید انگلیسی به لیبل فارسی برای نمایش داخل کامبوباکس
|
const clearAllFilters = () => {
|
||||||
const statusDisplayValue = STATUS_MAP[filters.status] || filters.status;
|
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 handleStatusChange = (val: string) => {
|
||||||
const key = STATUS_TO_KEY[val] || val;
|
const key = STATUS_TO_KEY[val] || val;
|
||||||
set({ status: key });
|
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 (
|
return (
|
||||||
<>
|
<div className="flex flex-col gap-2.5">
|
||||||
{/* ===== نمای موبایل (صفحهنمایشهای زیر md) ===== */}
|
{/* ===== ردیف جستجو و کنترلهای فیلتر (تکردیف افقی فشرده) ===== */}
|
||||||
<div className="flex md:hidden items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* اینپوت جستجوی متنی */}
|
{/* فیلد جستجوی متنی پروژه / کالا / تأمینکننده */}
|
||||||
<div className="relative flex-1">
|
<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
|
<input
|
||||||
type="search"
|
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"
|
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="جستجو در نام، برند، پروژه..."
|
placeholder="جستجو در پروژه، کالا، تأمینکننده..."
|
||||||
value={filters.search}
|
value={filters.search}
|
||||||
onChange={(e) => set({ search: e.target.value })}
|
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>
|
</div>
|
||||||
|
|
||||||
{/* دکمه فیلترها با بج تعداد انتخابشده */}
|
{/* دکمه باز کردن فیلترهای پیشرفته (تأمینکننده و ...) */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsOpen(true)}
|
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
|
activeFilterCount > 0
|
||||||
? "border-accent bg-accent-bg text-accent"
|
? "border-accent bg-accent-bg text-accent-dark ring-1 ring-accent/30"
|
||||||
: "border-line bg-panel text-ink hover:bg-slate-bg"
|
: "border-line bg-panel text-ink hover:bg-slate-50 dark:hover:bg-zinc-800",
|
||||||
}`}
|
)}
|
||||||
title="فیلترها"
|
title="فیلترهای پیشرفته"
|
||||||
>
|
>
|
||||||
<SlidersHorizontal className="h-4 w-4" />
|
<SlidersHorizontal className="h-4 w-4" />
|
||||||
<span>فیلتر</span>
|
<span className="hidden xs:inline">فیلترها</span>
|
||||||
{activeFilterCount > 0 && (
|
{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}
|
{activeFilterCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* دکمه تغییر جهت سورت */}
|
{/* دکمه تغییر جهت مرتبسازی */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onToggleSort}
|
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"
|
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" ? "جدیدترین اول" : "قدیمیترین اول"}
|
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>
|
</button>
|
||||||
</div>
|
</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 && (
|
{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="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="mb-4 flex items-center justify-between border-b border-line pb-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<SlidersHorizontal className="h-4 w-4 text-accent" />
|
<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>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{activeFilterCount > 0 && (
|
{(filters.supplier || filters.status || filters.search) && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={clearFilters}
|
onClick={clearAllFilters}
|
||||||
className="inline-flex items-center gap-1 text-xs text-rose-500 hover:underline"
|
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>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsOpen(false)}
|
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="بستن"
|
aria-label="بستن"
|
||||||
>
|
>
|
||||||
<X className="h-5 w-5" />
|
<X className="h-5 w-5" />
|
||||||
@@ -149,9 +238,9 @@ export function FilterToolbar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* دراپداونها داخل کشو */}
|
{/* دراپداونها داخل کشو */}
|
||||||
<div className="flex flex-col gap-3.5">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="field">
|
<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
|
<Combobox
|
||||||
value={filters.supplier}
|
value={filters.supplier}
|
||||||
onChange={(it: { id: string; label: string }) => set({ supplier: it.label })}
|
onChange={(it: { id: string; label: string }) => set({ supplier: it.label })}
|
||||||
@@ -163,7 +252,7 @@ export function FilterToolbar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="field">
|
<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
|
<Combobox
|
||||||
value={statusDisplayValue}
|
value={statusDisplayValue}
|
||||||
onChange={(it: { id: string; label: string }) => handleStatusChange(it.label)}
|
onChange={(it: { id: string; label: string }) => handleStatusChange(it.label)}
|
||||||
@@ -179,61 +268,13 @@ export function FilterToolbar({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsOpen(false)}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,19 +1,29 @@
|
|||||||
import { Link } from "react-router-dom";
|
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 type { Quote, Store } from "../../lib/types";
|
||||||
import {
|
import {
|
||||||
isExpired,
|
isExpired,
|
||||||
materialById,
|
materialById,
|
||||||
materialLabel,
|
formatMaterialTitle,
|
||||||
projectCell,
|
projectCell,
|
||||||
quoteTotal,
|
quoteTotal,
|
||||||
supplierById,
|
supplierById,
|
||||||
termsheetSummary,
|
termsheetSummary,
|
||||||
unitWarning,
|
|
||||||
} from "../../lib/quoteUtils";
|
} from "../../lib/quoteUtils";
|
||||||
import { STATUS_LABEL } from "../../lib/constants";
|
import { topicByName } from "../../lib/topics";
|
||||||
import { esc, fmtDate, fmtDateTime, fmtNum, priceCell } from "../../lib/formatters";
|
import { fmtDate, fmtNum } from "../../lib/formatters";
|
||||||
import { Badge } from "../../components/ui/badge";
|
import { groupCommas } from "../../lib/numutil";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
import { QuoteDetail } from "./QuoteDetail";
|
import { QuoteDetail } from "./QuoteDetail";
|
||||||
|
|
||||||
@@ -27,318 +37,270 @@ export interface QuoteCardProps {
|
|||||||
canWrite?: boolean;
|
canWrite?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function InfoCell({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) {
|
export function QuoteCard({
|
||||||
return (
|
store,
|
||||||
<div className={cn("min-w-0", className)}>
|
q,
|
||||||
<div className="mb-0.5 text-[10px] font-semibold text-muted sm:text-[10.5px]">{label}</div>
|
expanded,
|
||||||
<div className="min-w-0 text-xs leading-5 text-ink sm:text-[13px] sm:leading-6">{children}</div>
|
onToggleExpand,
|
||||||
</div>
|
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 sup = supplierById(store.suppliers, q.supplier_id);
|
||||||
const expired = isExpired(q);
|
const expired = isExpired(q);
|
||||||
const total = quoteTotal(q);
|
const total = quoteTotal(q);
|
||||||
const pc = priceCell(total);
|
|
||||||
const ts = termsheetSummary(store, q);
|
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 activeItems = (q.line_items || []).filter((li) => !li.rejected);
|
||||||
const receivedSum = activeItems.reduce((s, li) => s + (li.received_qty || 0), 0);
|
const primaryLineItem = activeItems[0];
|
||||||
const orderedSum = activeItems.reduce((s, li) => s + (li.quantity || 0), 0);
|
const primaryMaterial = primaryLineItem
|
||||||
const fulfilledItems = activeItems.filter(
|
? materialById(store.materials, primaryLineItem.material_id)
|
||||||
(li) => li.quantity != null && li.quantity > 0 && (li.received_qty || 0) >= li.quantity,
|
: null;
|
||||||
).length;
|
|
||||||
const anyReceived = receivedSum > 0;
|
|
||||||
const fullyDelivered = activeItems.length > 0 && fulfilledItems === activeItems.length && anyReceived;
|
|
||||||
|
|
||||||
// نشان پیشرفت تکمیل
|
// عنوان اصلی کالا / متریال
|
||||||
let fulfillmentBadge: React.ReactNode = null;
|
let itemTitle = "پیشنهاد بدون قلم";
|
||||||
if (q.status === "accepted" || q.status === "arrived") {
|
if (activeItems.length === 1 && primaryMaterial) {
|
||||||
if (fullyDelivered || q.status === "arrived") {
|
itemTitle = formatMaterialTitle(primaryMaterial);
|
||||||
fulfillmentBadge = (
|
} else if (activeItems.length > 1 && primaryMaterial) {
|
||||||
<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]">
|
itemTitle = `${formatMaterialTitle(primaryMaterial)} (+${fmtNum(activeItems.length - 1)} قلم دیگر)`;
|
||||||
✓ تکمیل
|
|
||||||
</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 arrivedCell: React.ReactNode;
|
// آیکون دستهبندی
|
||||||
const lastReceiptAt = activeItems.reduce<Date | null>((acc, li) => {
|
const topicName = primaryMaterial?.topic || sup.topic;
|
||||||
if (!li.arrived_date) return acc;
|
const topic = topicByName(topicName, store.topics);
|
||||||
const t = new Date(li.arrived_date).getTime();
|
const categoryIcon = topic?.icon;
|
||||||
if (Number.isNaN(t)) return acc;
|
|
||||||
return !acc || t > acc.getTime() ? new Date(li.arrived_date) : acc;
|
|
||||||
}, null);
|
|
||||||
|
|
||||||
if (q.status === "accepted") {
|
// محاسبه مجموع مقدار و واحدها
|
||||||
if (q.arrived_date) {
|
let quantityDisplay = "—";
|
||||||
arrivedCell = <span className="tabular-nums">{fmtDateTime(q.arrived_date)}</span>;
|
if (activeItems.length === 1 && primaryLineItem) {
|
||||||
} else if (receivedSum > 0) {
|
quantityDisplay =
|
||||||
arrivedCell = (
|
primaryLineItem.quantity != null
|
||||||
<span className="tabular-nums">
|
? `${fmtNum(primaryLineItem.quantity)} ${primaryLineItem.unit || ""}`
|
||||||
{fmtNum(receivedSum)} از {fmtNum(orderedSum)}
|
: "—";
|
||||||
</span>
|
} else if (activeItems.length > 1) {
|
||||||
);
|
const totalQty = activeItems.reduce((acc, li) => acc + (li.quantity || 0), 0);
|
||||||
} else {
|
const uniqueUnits = [...new Set(activeItems.map((li) => li.unit).filter(Boolean))].join("، ");
|
||||||
arrivedCell = <span className="text-muted">در انتظار انبار</span>;
|
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") {
|
} else if (q.status === "arrived") {
|
||||||
arrivedCell = (
|
statusBadge = (
|
||||||
<span className="tabular-nums">
|
<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">
|
||||||
{fmtDateTime(q.arrived_date || (lastReceiptAt ? lastReceiptAt.toISOString() : ""))}
|
✓ تکمیل شده
|
||||||
|
</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>
|
</span>
|
||||||
);
|
);
|
||||||
} else {
|
} 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"overflow-hidden rounded-xl border bg-panel transition-colors shadow-2xs sm:rounded-lg",
|
"overflow-hidden rounded-2xl border bg-panel transition-all shadow-2xs",
|
||||||
expired ? "border-amber/40" : "border-line hover:border-accent/40",
|
expired ? "border-amber-300/80 bg-amber-50/10" : "border-line hover:border-accent/40",
|
||||||
expanded && "border-accent/60 ring-1 ring-accent/20",
|
expanded && "border-accent/60 ring-2 ring-accent/15 shadow-sm",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* سربرگ کارت — با کلیک روی فضای خالی اکسپند میشود */}
|
{/* ===== سربرگ کارت (Header: راست عنوان و آیکون / چپ وضعیت) ===== */}
|
||||||
<div
|
<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">
|
||||||
onClick={onToggleExpand}
|
{/* بخش راست (RTL): آیکون دستهبندی + نام کالا + تأمینکننده */}
|
||||||
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"
|
<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">
|
||||||
<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">
|
{categoryIcon ? (
|
||||||
{sup.name.slice(0, 1)}
|
<img src={categoryIcon} alt="" className="h-6 w-6 object-contain" loading="lazy" />
|
||||||
</span>
|
) : (
|
||||||
|
<Package className="h-5 w-5 text-accent" />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* خط ۲: موقعیت مکانی و برچسب هشدار فاکتور رسمی */}
|
<div className="min-w-0 flex-1">
|
||||||
<div className="mt-0.5 flex min-w-0 items-center gap-1.5">
|
<h3
|
||||||
{sup.location && (
|
className="truncate text-sm sm:text-[15px] font-bold text-ink leading-tight"
|
||||||
<span className="truncate text-[10px] text-muted sm:text-[11px]">
|
title={itemTitle}
|
||||||
{sup.location}
|
>
|
||||||
</span>
|
{itemTitle}
|
||||||
)}
|
</h3>
|
||||||
|
|
||||||
{q.is_official && (
|
{/* سابتایتل: نام تأمینکننده، موقعیت و فاکتور رسمی */}
|
||||||
<span
|
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-muted">
|
||||||
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"
|
<Link
|
||||||
title="فاکتور رسمی (مشمول ارزش افزوده و مالیات)"
|
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" />
|
🏢 {sup.name}
|
||||||
<span>فاکتور رسمی</span>
|
</Link>
|
||||||
</span>
|
|
||||||
)}
|
{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>
|
</div>
|
||||||
|
|
||||||
<div className="ms-auto flex shrink-0 items-center gap-1.5">
|
{/* بخش چپ: بج وضعیت */}
|
||||||
{fulfillmentBadge ?? (
|
<div className="shrink-0 pt-0.5">{statusBadge}</div>
|
||||||
<Badge variant={q.status as "offered" | "accepted" | "arrived"} className="text-[10px] sm:text-xs">
|
</div>
|
||||||
{STATUS_LABEL[q.status]}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* دکمه ویرایش */}
|
{/* ===== بدنه کارت (Body: گرید دو ستونه فشرده متادیتا) ===== */}
|
||||||
{canWrite && (
|
<div className="p-3.5 sm:p-4 flex flex-col gap-2.5">
|
||||||
<button
|
<div className="grid grid-cols-2 gap-2 sm:gap-2.5">
|
||||||
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"
|
<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">
|
||||||
onClick={(e) => {
|
<Building2 className="h-4 w-4 shrink-0 text-muted" />
|
||||||
e.stopPropagation();
|
<div className="min-w-0 flex-1">
|
||||||
onEdit();
|
<span className="block text-[10px] font-medium text-muted">پروژه</span>
|
||||||
}}
|
<span className="block truncate text-xs font-bold text-ink" title={projectName}>
|
||||||
title="ویرایش پیشنهاد"
|
{projectName}
|
||||||
aria-label="ویرایش"
|
</span>
|
||||||
>
|
</div>
|
||||||
<Pencil className="h-3 w-3 text-muted" />
|
</div>
|
||||||
<span className="hidden sm:inline">ویرایش</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* دکمه فلش اکسپند در هدر */}
|
{/* ۲. مقدار */}
|
||||||
|
<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
|
<button
|
||||||
type="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) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onToggleExpand();
|
onAccept();
|
||||||
}}
|
}}
|
||||||
title={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"
|
||||||
aria-expanded={expanded}
|
|
||||||
>
|
>
|
||||||
<ChevronDown className={cn("h-4 w-4 transition-transform duration-200", expanded && "rotate-180")} />
|
<span>پذیرش پیشنهاد</span>
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
{/* اطلاعات کلیدی — لمس این بخش هم کارت را اکسپند میکند */}
|
{/* ===== بخش جزئیات بازشونده (Expanded Detail) ===== */}
|
||||||
<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 && (
|
{expanded && (
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
<div className="border-t border-line" onClick={(e) => e.stopPropagation()}>
|
||||||
<QuoteDetail store={store} q={q} />
|
<QuoteDetail store={store} q={q} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
import { useEffect, useState } from "react";
|
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 type { Attachment, Quote, Store } from "../../lib/types";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import {
|
import {
|
||||||
@@ -13,6 +24,7 @@ import { groupCommas } from "../../lib/numutil";
|
|||||||
import { FlowDiagram, type FlowRefs } from "./FlowDiagram";
|
import { FlowDiagram, type FlowRefs } from "./FlowDiagram";
|
||||||
import { itemProjects } from "../../lib/quoteUtils";
|
import { itemProjects } from "../../lib/quoteUtils";
|
||||||
import { FilePreviewModal, useFilePreview } from "../../components/FilePreviewModal";
|
import { FilePreviewModal, useFilePreview } from "../../components/FilePreviewModal";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
export interface QuoteDetailProps {
|
export interface QuoteDetailProps {
|
||||||
store: Store;
|
store: Store;
|
||||||
@@ -24,6 +36,31 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
|
|||||||
const [flowEl, setFlowEl] = useState<HTMLDivElement | null>(null);
|
const [flowEl, setFlowEl] = useState<HTMLDivElement | null>(null);
|
||||||
const [flowRefs] = useState<FlowRefs>(() => ({ items: new Map(), termsheets: new Map() }));
|
const [flowRefs] = useState<FlowRefs>(() => ({ items: new Map(), termsheets: new Map() }));
|
||||||
const [bump, setBump] = useState(0);
|
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> نمیتوانند هدر بفرستند.
|
// بایتها با توکن گرفته میشوند چون <img>/<iframe> نمیتوانند هدر بفرستند.
|
||||||
@@ -180,6 +217,39 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={setFlowEl} className="flow border-t border-line" data-status={q.status}>
|
<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-cols">
|
||||||
<div className="flow-col flow-items-col">
|
<div className="flow-col flow-items-col">
|
||||||
<div className="flow-col-h">
|
<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 {
|
.num-words {
|
||||||
@apply text-muted;
|
@apply text-muted;
|
||||||
|
|||||||
Reference in New Issue
Block a user