diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2b0ed8b..4c1b872 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,6 +3,7 @@ import { Navigate, Route, Routes } from "react-router-dom"; import { useAuthStore } from "./store/auth"; import { ErrorBoundary } from "./components/ErrorBoundary"; import { RequirePmProjects } from "./components/RequirePmProjects"; +import { MobileTabBar } from "./components/layout/MobileTabBar"; // هر صفحه با React.lazy لود می‌شود (code splitting) const DashboardPage = lazy(() => @@ -209,6 +210,7 @@ export default function App() { } /> + ); } diff --git a/frontend/src/components/StatsBar.tsx b/frontend/src/components/StatsBar.tsx index 56cf40a..a7a8cb4 100644 --- a/frontend/src/components/StatsBar.tsx +++ b/frontend/src/components/StatsBar.tsx @@ -8,31 +8,72 @@ export interface StatsBarProps { avgLeadTime: number | null; // روز } -// نوار ۴ ستونه‌ی آمار export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProps) { - const stats: { label: string; value: string; color: string; icon: LucideIcon }[] = [ - { label: "کل پیشنهادها", value: fmtNum(total), color: "text-ink", icon: ClipboardList }, - // پیشنهاد باز = نارنجی، پذیرفته‌شده = آبی، رسیده = سبز - { label: "پیشنهاد باز", value: fmtNum(offered), color: "text-amber", icon: Inbox }, - { label: "پذیرفته‌شده", value: fmtNum(accepted), color: "text-[#1d4ed8]", icon: CheckCircle2 }, - { label: "میانگین lead time (روز)", value: avgLeadTime == null ? "—" : fmtNum(avgLeadTime), color: "text-green", icon: Timer }, + const stats: { + label: string; + shortLabel: string; + value: string; + color: string; + icon: LucideIcon; + }[] = [ + { + label: "کل پیشنهادها", + shortLabel: "کل", + value: fmtNum(total), + color: "text-ink", + icon: ClipboardList, + }, + { + label: "پیشنهاد باز", + shortLabel: "باز", + value: fmtNum(offered), + color: "text-amber", + icon: Inbox, + }, + { + label: "پذیرفته‌شده", + shortLabel: "پذیرفته", + value: fmtNum(accepted), + color: "text-[#1d4ed8]", + icon: CheckCircle2, + }, + { + label: "میانگین lead time (روز)", + shortLabel: "زمان تحویل", + value: avgLeadTime == null ? "—" : fmtNum(avgLeadTime), + color: "text-green", + icon: Timer, + }, ]; + return ( -
+
{stats.map((s) => (
-
-
{s.label}
- + {/* عنوان کارت */} +
+ + {s.shortLabel} + {s.label} + + + {/* آیکون در موبایل حذف شده تا فضا برای متن باز بماند */} +
-
{s.value}
+ + {/* مقدار عددی */} +
+ {s.value} +
))}
); -} +} \ No newline at end of file diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx index c91b016..7b6350d 100644 --- a/frontend/src/components/layout/Header.tsx +++ b/frontend/src/components/layout/Header.tsx @@ -38,7 +38,7 @@ export function Header({ title, sub, backTo, backLabel = "بازگشت", actions + + {/* دکمه تغییر جهت سورت */} + - + + {/* باتم‌شیت کشویی فیلترها برای موبایل */} + {isOpen && ( +
+
setIsOpen(false)} aria-hidden="true" /> +
+ {/* هدر کشو */} +
+
+ + فیلترهای پیشرفته +
+
+ {activeFilterCount > 0 && ( + + )} + +
+
+ + {/* دراپ‌داون‌ها داخل کشو */} +
+
+ + set({ category: it.label })} + items={categories} + allowAdd={false} + placeholder="همه دسته‌ها" + onInputChange={(v) => set({ category: v })} + /> +
+ +
+ + set({ supplier: it.label })} + items={suppliers} + allowAdd={false} + placeholder="همه تأمین‌کنندگان" + onInputChange={(v) => set({ supplier: v })} + /> +
+ +
+ + set({ status: it.label })} + items={STATUS_ITEMS} + allowAdd={false} + placeholder="همه وضعیت‌ها" + onInputChange={(v) => set({ status: v })} + /> +
+
+ + {/* دکمه تأیید و بستن */} + +
+
+ )} + + {/* ===== نمای دسکتاپ (md به بالا) ===== */} +
+
+ + set({ category: it.label })} + items={categories} + allowAdd={false} + placeholder="همه" + onInputChange={(v) => set({ category: v })} + /> +
+
+ + set({ supplier: it.label })} + items={suppliers} + allowAdd={false} + placeholder="همه" + onInputChange={(v) => set({ supplier: v })} + /> +
+
+ + set({ status: it.label })} + items={STATUS_ITEMS} + allowAdd={false} + placeholder="همه" + onInputChange={(v) => set({ status: v })} + /> +
+
+ + set({ search: e.target.value })} + /> +
+
+ + +
+
+ ); -} +} \ No newline at end of file diff --git a/frontend/src/pages/Dashboard/LedgerTable.tsx b/frontend/src/pages/Dashboard/LedgerTable.tsx index 29b7ce4..f4a55f5 100644 --- a/frontend/src/pages/Dashboard/LedgerTable.tsx +++ b/frontend/src/pages/Dashboard/LedgerTable.tsx @@ -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 ( -
+
); } return ( -
+
{quotes.map((q) => ( onEdit(q.id)} /> ))} -
+
هشدار «منقضی» = قیمت پیشنهادی از تاریخ انقضا گذشته؛ برای پیش‌بینی قیمت، شمارش نمی‌شود.
); -} +} \ No newline at end of file diff --git a/frontend/src/pages/Dashboard/QuoteCard.tsx b/frontend/src/pages/Dashboard/QuoteCard.tsx index 39b5ebb..d7f0ab3 100644 --- a/frontend/src/pages/Dashboard/QuoteCard.tsx +++ b/frontend/src/pages/Dashboard/QuoteCard.tsx @@ -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 ( -
-
{label}
-
{children}
+
+
{label}
+
{children}
); } -// کارت ساده و یکدست یک پیشنهاد در دفتر +// کارت پیشنهاد خرید — بهینه‌شده برای کاربری سرپا با موبایل 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 = ( - ); } else if (q.accepted_date) { - acceptedCell = {fmtDateTime(q.accepted_date)}; + acceptedCell = {fmtDateTime(q.accepted_date)}; } else { acceptedCell = —; } + // وضعیت رسید کالا 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 = ( - - ✓ تکمیل شد + + ✓ تکمیل ); } else if (anyReceived) { fulfillmentBadge = ( - - رسید: {fmtNum(fulfilledItems)} از {fmtNum(activeItems.length)} قلم + + رسید: {fmtNum(fulfilledItems)}/{fmtNum(activeItems.length)} ); } else { fulfillmentBadge = ( - + ⏳ در راه ); @@ -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 ? ( - - آخرین رسید: {fmtDateTime(lastReceiptAt.toISOString())} - - ) : 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 = ( - رسیده {fmtNum(receivedSum)} از {fmtNum(orderedSum)} — در راه {fmtNum(Math.max(0, orderedSum - receivedSum))} - {receiptTimeLine} + {fmtNum(receivedSum)} از {fmtNum(orderedSum)} ); } else { - arrivedCell = در انتظار PM; + arrivedCell = در انتظار انبار; } } else if (q.status === "arrived") { arrivedCell = ( @@ -136,47 +131,42 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit return (
- {/* هدر یکپارچه و تمیز بدون کادر تیره */} -
- + {/* سربرگ کارت */} +
+ {sup.name.slice(0, 1)} -
+
{sup.name} - {sup.location &&
{sup.location}
} + {sup.location &&
{sup.location}
}
- - {/* دکمه‌های عملیاتی راست به چپ */} -
+
{fulfillmentBadge ?? ( - {STATUS_LABEL[q.status]} + + {STATUS_LABEL[q.status]} + )} - - {/* دکمه ویرایش مینیمال */} - - {/* فلش آکاردئون */}
- {/* اطلاعات کلیدی کارت */} -
+ {/* اطلاعات کلیدی (گرید ۲ ستونه موبایل / ۴ ستونه دسکتاپ) */} +
{summary.count > 1 ? (
- {fmtNum(summary.count)} قلم -
- {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 ( - - - {materialLabel(m)} - - {fmtNum(li.quantity)} {li.unit} - - - ); - })} + {fmtNum(summary.count)} قلم کالا +
+ {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 ( + + • {materialLabel(m)} ({fmtNum(li.quantity)} {li.unit}) + + ); + })} + {(q.line_items?.length || 0) > 3 && ( + و {(q.line_items?.length || 0) - 3} قلم دیگر... + )}
) : ( - summary.text + {summary.text} )} {unitWarns.map((w) => ( @@ -227,37 +213,34 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit - + - {q.projects && q.projects.length > 0 ? ( - - {q.projects.map((p, i) => ( - {p} - ))} - - ) : ( - projectCell(q) - )} + + {q.projects && q.projects.length > 0 ? q.projects.join("، ") : projectCell(q)} + - + {pc ? ( - - {pc.num} ({pc.words} تومان) - +
+
+ {pc.num} تومان +
+
({pc.words})
+
) : ( "—" )}
- + {ts.title !== "—" ? (
-
+
{ts.title}
- {ts.sub &&
{ts.sub}
} + {ts.sub &&
{ts.sub}
}
) : ( — @@ -265,29 +248,35 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
- {/* پاورقی تمیز و روشن */} -
- - - پیشنهاد: {fmtDateTime(q.quote_date)} + {/* تاریخ‌ها و اکشن‌ها (۲ ستونه در موبایل، نواری در دسکتاپ) */} +
+ + + تاریخ: + {fmtDate(q.quote_date)} - - - معتبر تا:{" "} + + + + اعتبار: {fmtDate(q.expiry_date)} - {expired && } + {expired && } + - پذیرش: {acceptedCell} + پذیرش: + {acceptedCell} - - رسید: {arrivedCell} + + + رسید: + {arrivedCell}
- {/* جریان (expand) */} + {/* جزئیات تکمیلی بازشونده */} {expanded && }
); diff --git a/frontend/src/pages/Inventory/InventoryPage.tsx b/frontend/src/pages/Inventory/InventoryPage.tsx index a89413c..c65ad3b 100644 --- a/frontend/src/pages/Inventory/InventoryPage.tsx +++ b/frontend/src/pages/Inventory/InventoryPage.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from "react"; import { Link, useLocation, useSearchParams } from "react-router-dom"; -import { Plus, Warehouse as WarehouseIcon, ArrowUpDown, ArrowUp, ArrowDown, X } from "lucide-react"; +import { Plus, Warehouse as WarehouseIcon, SlidersHorizontal, ArrowUpDown, X } from "lucide-react"; import { Header, PageLayout } from "../../components/layout/Header"; import { EmptyState } from "../../components/EmptyState"; import { Badge } from "../../components/ui/badge"; @@ -13,6 +13,7 @@ import { useInventoryData, brandName, catName, materialName, unitName, whName } import type { InventoryRow, InventoryTransaction, TransactionType } from "../../lib/types"; import { faNum } from "../../lib/formatters"; import { isoToJalaliStr } from "../../lib/jalali"; +import { cn } from "../../lib/utils"; // رنگ‌بندی معنایی: سبز زمردی = ورود/افزایش، رز = خروج/کاهش، آبی = خنثی/جابه‌جایی const TX_META: Record = { @@ -48,7 +49,6 @@ function specsHash(specs: Record | null | undefined): string { return keys.map((k) => `${k}=${s[k]}`).join("|"); } -// داشبورد انبار — پورت کامل نسخه‌ی ارسالی (گروه‌بندی + خط زمانی + فیلترها) export function InventoryPage() { const location = useLocation(); const user = useAuthStore((s) => s.user); @@ -64,6 +64,7 @@ export function InventoryPage() { project: "", }); const [sort, setSort] = useState<"asc" | "desc" | "">(""); + const [showMobileFilters, setShowMobileFilters] = useState(false); const [expandedKey, setExpandedKey] = useState(null); const [selTx, setSelTx] = useState(null); const [expBrand, setExpBrand] = useState(null); @@ -133,7 +134,7 @@ export function InventoryPage() { .slice() .sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime()); - const hasFilter = !!(filters.category || filters.warehouse || filters.material || (isAdmin && filters.project)); + const hasFilter = !!(filters.category || filters.warehouse || (isAdmin && filters.project)); if (loading) return
; if (!user || (!isAdmin && !isPM)) { @@ -148,149 +149,169 @@ export function InventoryPage() { } return ( -
+
} menu={} actions={ - <> - +
+ {/* تب‌های تکراری در موبایل مخفی و فقط در دسکتاپ نمایش داده می‌شوند */} +
+ +
{isPM && ( - )} - +
} /> - - {/* نوار فیلترها و ابزارها */} -
-
- - setFilters((f) => ({ ...f, material: e.target.value }))} + /> + {filters.material && ( + + )} +
+ + + + + + + {faNum(groups.length)} قلم +
- {isAdmin && ( -
- + {/* گزینه‌های فیلتر پیشرفته: در موبایل با دکمه باز می‌شود و در دسکتاپ همیشه باز است */} +
+
+
- )} -
- - -
- -
- - setFilters((f) => ({ ...f, material: e.target.value }))} - /> -
- - - {hasFilter && ( - - )} +
+ + +
-
- {filters.warehouse && ( - - 🏬 {whName(warehouses, filters.warehouse)} - + {hasFilter && ( + )} - - {faNum(groups.length)} قلم کالا - + +
+ نمایش {faNum(groups.length)} قلم کالا +
- {/* بخش جدول یا وضعیت خالی */} - {!groups.length ? ( -
+ {/* جدول موجودی انبار با اسکرول افقی محافظت‌شده */} +
+ {!groups.length ? ( -
- ) : ( -
- + ) : ( +
- + - - - - - - + + + + + + + @@ -337,10 +358,11 @@ export function InventoryPage() { })}
- متریالدسته‌بندیانبارمشخصه فنیتعدادواحدآخرین به‌روزرسانیمتریالدسته‌بندیانبارمشخصه فنیتعدادواحدآخرین به‌روزرسانی
-
- )} + )} +
-
+ {/* متن راهنما فقط در دسکتاپ نمایش داده می‌شود */} +
۱۰ نوع تراکنش استاندارد • سبز زمردی = ورود به انبار · رز = خروج/کاهش · آبی = انتقال و شمارش • کلیک روی هر سطر: خط زمانی با «مانده» لحظه‌به‌لحظه و تفکیک برندها
@@ -362,20 +384,20 @@ function GroupRow({ g, isExp, isAdmin, matName, matCatName, wh, projName, unit, }) { return ( <> - + {isExp ? "▾" : "▸"} - {matName} - {matCatName} + {matName} + {matCatName} - {wh} - {isAdmin && {projName}} +
{wh}
+ {isAdmin && {projName}} {Object.entries(g.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"} - {faNum(g.qty)} + {faNum(g.qty)} {unit} - {isoToJalaliStr(g.updatedAt)} + {isoToJalaliStr(g.updatedAt)} 🕘 {isExp && ( @@ -412,16 +434,14 @@ function Timeline({ g, txs, allTxs, brandTxs, selTx, setSelTx, expBrand, setExpB key={t.id} type="button" onClick={() => setSelTx(sel ? null : t.id)} - className={`flex min-w-[125px] flex-col gap-1 rounded-md border p-2.5 text-start transition-all duration-150 active:scale-[0.98] ${ - sel ? "border-accent bg-accent/10 shadow-xs" : "border-line bg-panel hover:border-slate-300" - }`} + className={`flex min-w-[120px] flex-col gap-0.5 rounded-lg border p-2 text-start transition-all ${sel ? "border-accent bg-accent/10 shadow-xs" : "border-line bg-panel hover:border-accent/40"}`} style={{ borderTop: `3px solid ${meta.color}` }} > {meta.icon} {meta.label} - + {faNum(Math.abs(t.quantity_number))} {unit} - مانده: {faNum(bal)} {unit} + مانده: {faNum(bal)} {unit} ); }); @@ -430,9 +450,9 @@ function Timeline({ g, txs, allTxs, brandTxs, selTx, setSelTx, expBrand, setExpB if (!txs.length) return
تراکنشی ثبت نشده است.
; const brandSec = expBrand ? ( -
-
جریان برند: {brandNameFn(expBrand)} ({faNum(brandTxs(g, expBrand).length)} تراکنش)
-
{track(brandTxs(g, expBrand))}
+
+
جریان برند: {brandNameFn(expBrand)} ({faNum(brandTxs(g, expBrand).length)} تراکنش)
+
{track(brandTxs(g, expBrand))}
) : null; @@ -442,21 +462,21 @@ function Timeline({ g, txs, allTxs, brandTxs, selTx, setSelTx, expBrand, setExpB const meta = TX_META[t.transaction_type] || { label: t.transaction_type, color: "#64748b", icon: "•" }; const pairTx = t.transfer_pair_id ? allTxs.find((x) => x.transfer_pair_id === t.transfer_pair_id && x.id !== t.id) : null; return ( -
+
{meta.label}
- تاریخ و ساعت: {isoToJalaliStr(t.transaction_date)} + تاریخ و ساعت: {isoToJalaliStr(t.transaction_date)} {t.transaction_type === "TRANSFER_OUT" || t.transaction_type === "TRANSFER_IN" ? ( <> - از انبار: {t.transaction_type === "TRANSFER_OUT" ? whNameFn(t.warehouse_id) : whNameFn(pairTx?.warehouse_id)} - به انبار: {t.transaction_type === "TRANSFER_IN" ? whNameFn(t.warehouse_id) : whNameFn(pairTx?.warehouse_id)} + از انبار: {t.transaction_type === "TRANSFER_OUT" ? whNameFn(t.warehouse_id) : whNameFn(pairTx?.warehouse_id)} + به انبار: {t.transaction_type === "TRANSFER_IN" ? whNameFn(t.warehouse_id) : whNameFn(pairTx?.warehouse_id)} ) : ( - انبار: {whNameFn(t.warehouse_id)} + انبار: {whNameFn(t.warehouse_id)} )} - {t.brand?.name && برند: {t.brand.name}} - {t.performed_by && انجام‌دهنده: {t.performed_by}} - {t.note && یادداشت: {t.note}} + {t.brand?.name && برند: {t.brand.name}} + {t.performed_by && انجام‌دهنده: {t.performed_by}} + {t.note && یادداشت: {t.note}} {t.transaction_type === "STOCK_COUNT" && t.recorded_qty != null && t.physical_qty != null && ( ثبت‌شده: {faNum(t.recorded_qty)} • شمارش‌شده: {faNum(t.physical_qty)} • اختلاف: {faNum(t.physical_qty - t.recorded_qty)} @@ -484,17 +504,15 @@ function Timeline({ g, txs, allTxs, brandTxs, selTx, setSelTx, expBrand, setExpB key={bid} type="button" onClick={() => setExpBrand(expBrand === bid ? null : bid)} - className={`rounded-md border px-2.5 py-1 text-[11.5px] transition-all duration-150 active:scale-[0.98] ${ - expBrand === bid ? "border-accent bg-accent/10 font-semibold text-accent" : "border-line bg-panel hover:bg-slate-bg" - }`} + className={`rounded-lg border px-2 py-1 text-[11.5px] transition-colors ${expBrand === bid ? "border-accent bg-accent/10 text-accent font-bold" : "border-line bg-panel hover:bg-bg"}`} > - {brandNameFn(bid)}: {faNum(g.brands[bid])} {unit}{expBrand === bid ? " ✕" : ""} + {brandNameFn(bid)}: {faNum(g.brands[bid])} {unit}{expBrand === bid ? " ✕" : ""} ))}
{brandSec} -
جریان کلی رکورد — ترکیب همه برندها ({faNum(txs.length)} تراکنش)
-
{track(txs)}
+
جریان کلی رکورد — ترکیب همه برندها ({faNum(txs.length)} تراکنش)
+
{track(txs)}
{selDetail}
);