Merge main_v2 mobile UI into production, restore mobile bottom tab bar

Merges main_v2 a110741 (mobile UI optimizations) into production and
reinstates the mobile bottom navigation that f0e4243 had removed.

Conflicts in DashboardPage, QuoteCard and InventoryPage resolved in
favour of main_v2's mobile-first markup, keeping production's newer
design system (Button variant="primary" on InventoryPage).

f0e4243's removal was reverted in effect: MobileTabBar is restored and
remounted in App.tsx, and PageLayout regains max-md:pb-24 clearance so
content is not hidden behind the fixed bar.
This commit is contained in:
MMD
2026-09-16 14:14:59 +03:30
9 changed files with 635 additions and 333 deletions
+16 -11
View File
@@ -22,7 +22,6 @@ import { EditModal } from "./EditModal";
const EMPTY_FILTERS: Filters = { category: "", supplier: "", status: "", search: "" };
// صفحه‌ی اصلی دفتر پیشنهادهای خرید
export function DashboardPage() {
useStoreData();
@@ -73,20 +72,16 @@ export function DashboardPage() {
const supplierOptions = useMemo(() => suppliers.map((s) => ({ id: s.id, name: s.name })), [suppliers]);
return (
<div className="min-h-screen">
<div className="min-h-screen pb-20 md:pb-6">
<Header
title="دفتر پیشنهادهای خرید"
brand
menu={<AppSwitcherMenu activeModule="procurement" />}
actions={
<div className="flex items-center gap-2">
{/* دکمه تأمین‌کننده‌ها */}
<Link
to="/suppliers"
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-200 text-xs font-semibold hover:bg-gray-50 dark:hover:bg-zinc-700 shadow-2xs transition-all no-underline"
>
<Users className="h-4 w-4 text-gray-500 dark:text-zinc-400" />
<span>تأمین‌کننده‌ها</span>
<div className="hidden md:flex items-center gap-2">
<Link to="/suppliers" className="btn-line-file no-underline">
<Users className="h-3.5 w-3.5" />
تأمین‌کننده‌ها
</Link>
{/* دکمه پروژه‌ها */}
@@ -118,7 +113,8 @@ export function DashboardPage() {
</div>
}
/>
<PageLayout className="flex flex-col gap-4">
<PageLayout className="flex flex-col gap-3 px-3 md:px-6">
<StatsBar
total={stats.total}
offered={stats.offered}
@@ -143,6 +139,15 @@ export function DashboardPage() {
/>
</PageLayout>
{/* دکمه شناور اکشن (FAB) مخصوص نسخه موبایل */}
<Link
to="/wizard"
aria-label="افزودن پیشنهاد جدید"
className="md:hidden fixed bottom-20 left-4 z-40 flex h-14 w-14 items-center justify-center rounded-full bg-accent text-white shadow-xl shadow-accent/30 active:scale-95 transition-transform"
>
<Plus className="h-7 w-7" />
</Link>
<AcceptModal quoteId={acceptId} onClose={() => setAcceptId(null)} />
<EditModal quoteId={editId} onClose={() => setEditId(null)} />
</div>
+202 -55
View File
@@ -1,3 +1,5 @@
import { useState } from "react";
import { SlidersHorizontal, X, ArrowUpDown, RotateCcw } from "lucide-react";
import { Combobox } from "../../components/Combobox";
import { STATUS_LABEL, STATUS_ORDER } from "../../lib/constants";
@@ -20,67 +22,212 @@ export interface FilterToolbarProps {
onToggleSort: () => void;
}
// نوار فیلتر دفتر: ۳ کامبوباکس + جستجوی آزاد + دکمه‌ی مرتب‌سازی ⇅
export function FilterToolbar({ filters, onChange, categories, suppliers, sortDir, onToggleSort }: FilterToolbarProps) {
export function FilterToolbar({
filters,
onChange,
categories,
suppliers,
sortDir,
onToggleSort,
}: FilterToolbarProps) {
const [isOpen, setIsOpen] = useState(false);
const set = (patch: Partial<Filters>) => onChange({ ...filters, ...patch });
// شمارش تعداد فیلترهای فعال به جز سرچ
const activeFilterCount = [filters.category, filters.supplier, filters.status].filter(Boolean).length;
const clearFilters = () => {
onChange({ ...filters, category: "", supplier: "", status: "" });
};
return (
<div className="flex flex-wrap items-end gap-3">
<div className="field min-w-[150px] flex-1">
<label>دسته</label>
<Combobox
value={filters.category}
onChange={(it: { id: string; label: string }) => set({ category: it.label })}
items={categories}
allowAdd={false}
placeholder="همه"
onInputChange={(v) => set({ category: v })}
/>
</div>
<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={filters.status}
onChange={(it: { id: string; label: string }) => set({ status: it.label })}
items={STATUS_ITEMS}
allowAdd={false}
placeholder="همه"
onInputChange={(v) => set({ status: v })}
/>
</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>
<>
{/* ===== نمای موبایل (صفحه‌نمایش‌های زیر md) ===== */}
<div className="flex md:hidden items-center gap-2">
{/* اینپوت جستجوی متنی */}
<div className="relative flex-1">
<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="جستجو در نام، برند، پروژه..."
value={filters.search}
onChange={(e) => set({ search: e.target.value })}
/>
</div>
{/* دکمه فیلترها با بج تعداد انتخاب‌شده */}
<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" ? "تغییر به قدیمی‌ترین اول" : "تغییر به جدیدترین اول"}
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 ${
activeFilterCount > 0
? "border-accent bg-accent-bg text-accent"
: "border-line bg-panel text-ink hover:bg-slate-bg"
}`}
title="فیلترها"
>
<span aria-hidden className="text-accent">⇅</span>
<span>{sortDir === "desc" ? "جدیدترین اول" : "قدیمی‌ترین اول"}</span>
<SlidersHorizontal className="h-4 w-4" />
<span>فیلتر</span>
{activeFilterCount > 0 && (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-accent text-[11px] 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" ? "جدیدترین اول" : "قدیمی‌ترین اول"}
>
<ArrowUpDown className="h-4 w-4 text-accent" />
</button>
</div>
</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" 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="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>
</div>
<div className="flex items-center gap-3">
{activeFilterCount > 0 && (
<button
type="button"
onClick={clearFilters}
className="inline-flex items-center gap-1 text-xs text-rose-500 hover:underline"
>
<RotateCcw className="h-3 w-3" />
پاک کردن
</button>
)}
<button
type="button"
onClick={() => setIsOpen(false)}
className="rounded-lg p-1 text-muted hover:bg-slate-bg hover:text-ink"
aria-label="بستن"
>
<X className="h-5 w-5" />
</button>
</div>
</div>
{/* دراپ‌داون‌ها داخل کشو */}
<div className="flex flex-col gap-3.5">
<div className="field">
<label className="mb-1 block text-xs font-semibold text-muted">دسته</label>
<Combobox
value={filters.category}
onChange={(it: { id: string; label: string }) => set({ category: it.label })}
items={categories}
allowAdd={false}
placeholder="همه دسته‌ها"
onInputChange={(v) => set({ category: v })}
/>
</div>
<div className="field">
<label className="mb-1 block text-xs font-semibold text-muted">تأمین‌کننده</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">
<label className="mb-1 block text-xs font-semibold text-muted">وضعیت</label>
<Combobox
value={filters.status}
onChange={(it: { id: string; label: string }) => set({ status: it.label })}
items={STATUS_ITEMS}
allowAdd={false}
placeholder="همه وضعیت‌ها"
onInputChange={(v) => set({ status: v })}
/>
</div>
</div>
{/* دکمه تأیید و بستن */}
<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]"
>
مشاهده نتایج
</button>
</div>
</div>
)}
{/* ===== نمای دسکتاپ (md به بالا) ===== */}
<div className="hidden md:flex flex-wrap items-end gap-3">
<div className="field min-w-[150px] flex-1">
<label>دسته</label>
<Combobox
value={filters.category}
onChange={(it: { id: string; label: string }) => set({ category: it.label })}
items={categories}
allowAdd={false}
placeholder="همه"
onInputChange={(v) => set({ category: v })}
/>
</div>
<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={filters.status}
onChange={(it: { id: string; label: string }) => set({ status: it.label })}
items={STATUS_ITEMS}
allowAdd={false}
placeholder="همه"
onInputChange={(v) => set({ status: v })}
/>
</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>
</>
);
}
}
+5 -5
View File
@@ -12,7 +12,7 @@ export interface LedgerTableProps {
onEdit: (id: string) => void;
}
// دفتر پیشنهادها — فهرست کارت‌ها (هر پیشنهاد یک کارت خوانا)
// دفتر پیشنهادها — فهرست کارت‌ها (واکنش‌گرا و بهینه برای موبایل)
export function LedgerTable({
store,
quotes,
@@ -23,14 +23,14 @@ export function LedgerTable({
}: LedgerTableProps) {
if (!quotes.length) {
return (
<div className="rounded-lg border border-line bg-panel">
<div className="rounded-xl border border-line bg-panel p-2 sm:rounded-lg">
<EmptyState icon={Inbox} text="پیشنهادی مطابق فیلترها یافت نشد" />
</div>
);
}
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2.5 sm:gap-3">
{quotes.map((q) => (
<QuoteCard
key={q.id}
@@ -42,9 +42,9 @@ export function LedgerTable({
onEdit={() => onEdit(q.id)}
/>
))}
<div className="py-1 text-center text-[11px] text-muted">
<div className="px-2 py-3 text-center text-[10.5px] leading-5 text-muted sm:py-1 sm:text-[11px]">
هشدار «منقضی» = قیمت پیشنهادی از تاریخ انقضا گذشته؛ برای پیش‌بینی قیمت، شمارش نمی‌شود.
</div>
</div>
);
}
}
+88 -99
View File
@@ -1,5 +1,5 @@
import { Link } from "react-router-dom";
import { AlarmClock, ChevronDown, Pencil, TriangleAlert, Building2, Package, CreditCard } from "lucide-react";
import { AlarmClock, ChevronDown, Pencil, TriangleAlert, Building2, Package, CreditCard, Check } from "lucide-react";
import type { Quote, Store } from "../../lib/types";
import {
isExpired,
@@ -27,16 +27,16 @@ export interface QuoteCardProps {
onEdit: () => void;
}
function InfoCell({ label, children }: { label: string; children: React.ReactNode }) {
function InfoCell({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) {
return (
<div className="min-w-0">
<div className="mb-0.5 text-[10.5px] font-semibold text-muted">{label}</div>
<div className="min-w-0 text-[13px] leading-6">{children}</div>
<div 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 }: QuoteCardProps) {
const sup = supplierById(store.suppliers, q.supplier_id);
const expired = isExpired(q);
@@ -46,24 +46,25 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
const unitWarns = unitWarning(store, q);
const summary = itemsSummary(store, q);
// دکمه یا وضعیت پذیرش
// ستون / دکمه پذیرش
let acceptedCell: React.ReactNode;
if (q.status === "offered") {
acceptedCell = (
<button
type="button"
<button
onClick={onAccept}
className="px-2 py-0.5 rounded text-xs font-semibold bg-emerald-50 text-emerald-700 hover:bg-emerald-100 border border-emerald-200 transition-colors cursor-pointer"
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>
);
} else if (q.accepted_date) {
acceptedCell = <span className="tabular-nums">{fmtDateTime(q.accepted_date)}</span>;
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);
@@ -73,23 +74,24 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
const anyReceived = receivedSum > 0;
const fullyDelivered = activeItems.length > 0 && fulfilledItems === activeItems.length && anyReceived;
// نشان پیشرفت تکمیل
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-[11px] font-bold text-emerald-700">
✓ تکمیل شد
<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-[11px] font-bold text-amber-700">
رسید: {fmtNum(fulfilledItems)} از {fmtNum(activeItems.length)} قلم
<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-[11px] font-bold text-blue-700">
<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>
);
@@ -103,12 +105,6 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
if (Number.isNaN(t)) return acc;
return !acc || t > acc.getTime() ? new Date(li.arrived_date) : acc;
}, null);
const receiptTimeLine =
lastReceiptAt != null ? (
<span className="block text-[10.5px] font-normal text-muted">
آخرین رسید: {fmtDateTime(lastReceiptAt.toISOString())}
</span>
) : null;
if (q.status === "accepted") {
if (q.arrived_date) {
@@ -116,12 +112,11 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
} else if (receivedSum > 0) {
arrivedCell = (
<span className="tabular-nums">
رسیده {fmtNum(receivedSum)} از {fmtNum(orderedSum)} — در راه {fmtNum(Math.max(0, orderedSum - receivedSum))}
{receiptTimeLine}
{fmtNum(receivedSum)} از {fmtNum(orderedSum)}
</span>
);
} else {
arrivedCell = <span className="text-muted">در انتظار PM</span>;
arrivedCell = <span className="text-muted">در انتظار انبار</span>;
}
} else if (q.status === "arrived") {
arrivedCell = (
@@ -136,47 +131,42 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
return (
<div
className={cn(
"overflow-hidden rounded-xl border bg-panel transition-all shadow-xs",
expired ? "border-amber/40" : "border-line hover:border-gray-300",
expanded && "border-blue-500/50 shadow-sm",
"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",
)}
>
{/* هدر یکپارچه و تمیز بدون کادر تیره */}
<div className="flex items-center gap-3 border-b border-line/60 px-4 py-3">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gray-100 text-xs font-bold text-gray-700">
{/* سربرگ کارت */}
<div className="flex 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">
<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">
<div className="min-w-0 flex-1">
<Link
to={`/suppliers/${q.supplier_id}`}
className="block truncate text-[13.5px] font-bold text-ink no-underline hover:text-blue-600"
className="block truncate text-xs font-bold text-ink no-underline hover:text-accent sm:text-[13.5px]"
>
{sup.name}
</Link>
{sup.location && <div className="truncate text-[11px] text-muted">{sup.location}</div>}
{sup.location && <div className="truncate text-[10px] text-muted sm:text-[11px]">{sup.location}</div>}
</div>
{/* دکمه‌های عملیاتی راست به چپ */}
<div className="ms-auto flex shrink-0 items-center gap-2">
<div className="ms-auto flex shrink-0 items-center gap-1 sm:gap-1.5">
{fulfillmentBadge ?? (
<Badge variant={q.status as "offered" | "accepted" | "arrived"}>{STATUS_LABEL[q.status]}</Badge>
<Badge variant={q.status as "offered" | "accepted" | "arrived"} className="text-[10px] sm:text-xs">
{STATUS_LABEL[q.status]}
</Badge>
)}
{/* دکمه ویرایش مینیمال */}
<button
type="button"
className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-gray-500 hover:text-gray-800 hover:bg-gray-100 text-xs transition cursor-pointer"
className="chip h-7 px-2 sm:h-auto sm:px-2.5"
onClick={onEdit}
title="ویرایش پیشنهاد"
aria-label="ویرایش"
>
<Pencil className="h-3 w-3" />
<span>ویرایش</span>
<span className="hidden sm:inline">ویرایش</span>
</button>
{/* فلش آکاردئون */}
<button
type="button"
className="flex h-6 w-6 items-center justify-center rounded-md text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition cursor-pointer"
className="chip chip-expand h-7 w-7 p-0 sm:h-auto sm:w-auto sm:p-1.5"
onClick={onToggleExpand}
title={expanded ? "جمع کردن" : "نمایش جزئیات"}
aria-expanded={expanded}
@@ -186,38 +176,34 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
</div>
</div>
{/* اطلاعات کلیدی کارت */}
<div className="grid grid-cols-2 gap-x-5 gap-y-3 px-4 py-3.5 md:grid-cols-4">
{/* اطلاعات کلیدی (گرید ۲ ستونه موبایل / ۴ ستونه دسکتاپ) */}
<div className="grid grid-cols-2 gap-x-3 gap-y-2.5 p-3 sm:gap-x-5 sm:gap-y-3 sm:p-4 md:grid-cols-4">
<InfoCell label="اقلام">
{summary.count > 1 ? (
<div className="flex flex-col gap-0.5">
<span className="font-semibold">{fmtNum(summary.count)} قلم</span>
<div className="flex flex-col gap-1 text-[11.5px] leading-5 text-muted">
{store.quotes
.find((x) => x.id === q.id)
?.line_items?.filter((li) => !li.rejected)
.map((li) => {
const m = materialById(store.materials, li.material_id);
return (
<span key={li.id} className="flex items-center gap-1">
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-blue-500/60" />
{materialLabel(m)}
<span className="text-[10px] text-muted/70">
{fmtNum(li.quantity)} {li.unit}
</span>
</span>
);
})}
<span className="font-semibold text-xs sm:text-sm">{fmtNum(summary.count)} قلم کالا</span>
<div className="flex flex-col gap-0.5 text-[11px] leading-4 text-muted sm:text-[11.5px] sm:leading-5">
{store.quotes.find((x) => x.id === q.id)?.line_items?.filter((li) => !li.rejected).slice(0, 3).map((li) => {
const m = materialById(store.materials, li.material_id);
return (
<span key={li.id} className="truncate">
• {materialLabel(m)} ({fmtNum(li.quantity)} {li.unit})
</span>
);
})}
{(q.line_items?.length || 0) > 3 && (
<span className="text-[10px] text-muted">و {(q.line_items?.length || 0) - 3} قلم دیگر...</span>
)}
</div>
</div>
) : (
summary.text
<span className="truncate">{summary.text}</span>
)}
{unitWarns.map((w) => (
<Link
key={w.unit}
to={`/units?cat=${encodeURIComponent(w.cat)}&unit=${encodeURIComponent(w.unit)}`}
className="unit-warn mt-1"
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" />
@@ -227,37 +213,34 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
</InfoCell>
<InfoCell label="پروژه">
<span className="inline-flex items-center gap-1.5">
<span className="inline-flex items-center gap-1 text-xs sm:text-[13px]">
<Building2 className="h-3.5 w-3.5 shrink-0 text-muted" />
{q.projects && q.projects.length > 0 ? (
<span className="flex flex-col gap-0.5">
{q.projects.map((p, i) => (
<span key={i}>{p}</span>
))}
</span>
) : (
projectCell(q)
)}
<span className="truncate">
{q.projects && q.projects.length > 0 ? q.projects.join("، ") : projectCell(q)}
</span>
</span>
</InfoCell>
<InfoCell label="مجموع">
<InfoCell label="مجموع مبلغ">
{pc ? (
<span className="font-bold tabular-nums">
{pc.num} <span className="num-words">({pc.words} تومان)</span>
</span>
<div>
<div className="text-sm font-extrabold text-ink tabular-nums 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="معامله">
<InfoCell label="شرایط معامله">
{ts.title !== "—" ? (
<div>
<div className="font-semibold" style={ts.color ? { color: ts.color } : undefined}>
<div className="font-semibold text-xs sm:text-[13px]" style={ts.color ? { color: ts.color } : undefined}>
{ts.title}
</div>
{ts.sub && <div className="text-[11px] text-muted">{ts.sub}</div>}
{ts.sub && <div className="truncate text-[10px] text-muted sm:text-[11px]">{ts.sub}</div>}
</div>
) : (
<span className="text-muted">—</span>
@@ -265,29 +248,35 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
</InfoCell>
</div>
{/* پاورقی تمیز و روشن */}
<div className="flex flex-wrap items-center gap-x-5 gap-y-1.5 border-t border-line/60 px-4 py-2 text-[11.5px] text-muted">
<span className="inline-flex items-center gap-1.5">
<Package className="h-3 w-3" />
پیشنهاد: <b className="text-ink tabular-nums">{fmtDateTime(q.quote_date)}</b>
{/* تاریخ‌ها و اکشن‌ها (۲ ستونه در موبایل، نواری در دسکتاپ) */}
<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="text-ink tabular-nums">{fmtDate(q.quote_date)}</b>
</span>
<span className="inline-flex items-center gap-1.5">
<CreditCard className="h-3 w-3" />
معتبر تا:{" "}
<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 text-amber" aria-label="منقضی" />}
{expired && <AlarmClock className="h-3 w-3 text-amber shrink-0" aria-label="منقضی" />}
</span>
<span className="inline-flex items-center gap-1.5">
پذیرش: {acceptedCell}
<span>پذیرش:</span>
{acceptedCell}
</span>
<span className="inline-flex items-center gap-1.5">
رسید: {arrivedCell}
<span className="inline-flex items-center gap-1.5 truncate">
<span>رسید:</span>
{arrivedCell}
</span>
</div>
{/* جریان (expand) */}
{/* جزئیات تکمیلی بازشونده */}
{expanded && <QuoteDetail store={store} q={q} />}
</div>
);