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
+2
View File
@@ -3,6 +3,7 @@ import { Navigate, Route, Routes } from "react-router-dom";
import { useAuthStore } from "./store/auth"; import { useAuthStore } from "./store/auth";
import { ErrorBoundary } from "./components/ErrorBoundary"; import { ErrorBoundary } from "./components/ErrorBoundary";
import { RequirePmProjects } from "./components/RequirePmProjects"; import { RequirePmProjects } from "./components/RequirePmProjects";
import { MobileTabBar } from "./components/layout/MobileTabBar";
// هر صفحه با React.lazy لود می‌شود (code splitting) // هر صفحه با React.lazy لود می‌شود (code splitting)
const DashboardPage = lazy(() => const DashboardPage = lazy(() =>
@@ -209,6 +210,7 @@ export default function App() {
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</Suspense> </Suspense>
<MobileTabBar />
</ErrorBoundary> </ErrorBoundary>
); );
} }
+54 -13
View File
@@ -8,29 +8,70 @@ export interface StatsBarProps {
avgLeadTime: number | null; // روز avgLeadTime: number | null; // روز
} }
// نوار ۴ ستونه‌ی آمار
export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProps) { export function StatsBar({ total, offered, accepted, avgLeadTime }: StatsBarProps) {
const stats: { label: string; value: string; color: string; icon: LucideIcon }[] = [ const stats: {
{ label: "کل پیشنهادها", value: fmtNum(total), color: "text-ink", icon: ClipboardList }, label: string;
// پیشنهاد باز = نارنجی، پذیرفته‌شده = آبی، رسیده = سبز shortLabel: string;
{ label: "پیشنهاد باز", value: fmtNum(offered), color: "text-amber", icon: Inbox }, value: string;
{ label: "پذیرفته‌شده", value: fmtNum(accepted), color: "text-[#1d4ed8]", icon: CheckCircle2 }, color: string;
{ label: "میانگین lead time (روز)", value: avgLeadTime == null ? "—" : fmtNum(avgLeadTime), color: "text-green", icon: Timer }, 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 ( return (
<section className="grid grid-cols-2 gap-3 sm:grid-cols-4"> <section className="grid grid-cols-4 gap-1.5 sm:gap-3">
{stats.map((s) => ( {stats.map((s) => (
<div <div
key={s.label} key={s.label}
className="rounded-lg border border-line bg-panel p-3.5 transition-colors hover:border-accent/40" className="rounded-xl border border-line bg-panel p-2 text-center transition-colors hover:border-accent/40 sm:rounded-lg sm:p-3.5 sm:text-right"
> >
<div className="flex items-center justify-between"> {/* عنوان کارت */}
<div className="text-[11px] font-medium text-muted">{s.label}</div> <div className="flex items-center justify-center sm:justify-between">
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-slate-bg text-muted"> <span className="truncate text-[10px] font-medium text-muted sm:text-[11px]">
<span className="sm:hidden">{s.shortLabel}</span>
<span className="hidden sm:inline">{s.label}</span>
</span>
{/* آیکون در موبایل حذف شده تا فضا برای متن باز بماند */}
<span className="hidden h-6 w-6 shrink-0 items-center justify-center rounded-md bg-slate-bg text-muted sm:flex">
<s.icon className="h-3.5 w-3.5" strokeWidth={2} /> <s.icon className="h-3.5 w-3.5" strokeWidth={2} />
</span> </span>
</div> </div>
<div className={`mt-1 text-[22px] font-extrabold leading-8 tabular-nums ${s.color}`}>{s.value}</div>
{/* مقدار عددی */}
<div
className={`mt-0.5 text-base font-extrabold leading-5 tabular-nums sm:mt-1 sm:text-[22px] sm:leading-8 ${s.color}`}
>
{s.value}
</div>
</div> </div>
))} ))}
</section> </section>
+2 -2
View File
@@ -38,7 +38,7 @@ export function Header({ title, sub, backTo, backLabel = "بازگشت", actions
<button <button
type="button" type="button"
onClick={goBack} onClick={goBack}
className="inline-flex cursor-pointer items-center gap-0.5 rounded-md px-2 py-1 text-[13px] text-muted transition-colors hover:bg-slate-bg hover:text-ink max-md:px-2.5 max-md:py-2" className="hidden md:inline-flex cursor-pointer items-center gap-0.5 rounded-md px-2 py-1 text-[13px] text-muted transition-colors hover:bg-slate-bg hover:text-ink"
> >
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
{backLabel} {backLabel}
@@ -64,6 +64,6 @@ export function PageLayout({
className?: string; className?: string;
}) { }) {
return ( return (
<div className={cn("mx-auto w-full max-w-[1200px] px-4 py-4", className)}>{children}</div> <div className={cn("mx-auto w-full max-w-[1200px] px-4 py-4 max-md:pb-24", className)}>{children}</div>
); );
} }
@@ -0,0 +1,100 @@
import { NavLink, useLocation } from "react-router-dom";
import {
ArrowLeftRight,
Boxes,
Building2,
CalendarDays,
LayoutDashboard,
PackageSearch,
Scale,
Truck,
UsersRound,
type LucideIcon,
} from "lucide-react";
import { cn } from "../../lib/utils";
import { useAuthStore } from "../../store/auth";
interface TabDef {
to: string;
label: string;
icon: LucideIcon;
end?: boolean;
}
// تب‌های دفتر تدارکات (ادمین / مدیر تدارکات)
const PROC_TABS: TabDef[] = [
{ to: "/", label: "داشبورد", icon: LayoutDashboard, end: true },
{ to: "/suppliers", label: "تأمین‌کننده‌ها", icon: Truck },
{ to: "/projects", label: "پروژه‌ها", icon: Building2 },
{ to: "/units", label: "ضرایب", icon: Scale },
];
// تب‌های پنل انبار برای مدیر پروژه
const PM_INV_TABS: TabDef[] = [
{ to: "/inventory", label: "انبار", icon: Boxes, end: true },
{ to: "/inventory/change", label: "تغییر موجودی", icon: ArrowLeftRight },
{ to: "/inventory/daily", label: "کنترل روزانه", icon: CalendarDays },
{ to: "/inventory/arrivals", label: "کالاهای در راه", icon: PackageSearch },
];
// تب‌های پنل انبار برای ادمین / مدیر تدارکات (آینه‌ی ناوبری بالای دسکتاپ)
const ADMIN_INV_TABS: TabDef[] = [
{ to: "/inventory", label: "انبار", icon: Boxes, end: true },
{ to: "/inventory/daily", label: "کنترل روزانه", icon: CalendarDays },
{ to: "/inventory/arrivals", label: "کالاهای در راه", icon: PackageSearch },
{ to: "/inventory/org", label: "سازمان‌دهی", icon: UsersRound },
];
function TabBar({ tabs }: { tabs: TabDef[] }) {
return (
<nav
className="fixed inset-x-0 bottom-0 z-40 border-t border-line bg-panel md:hidden"
style={{ paddingBottom: "env(safe-area-inset-bottom)" }}
>
<div className="grid grid-cols-4">
{tabs.map((t) => (
<NavLink
key={t.to}
to={t.to}
end={t.end}
className={({ isActive }) =>
cn(
"flex min-h-[56px] flex-col items-center justify-center gap-0.5 whitespace-nowrap px-1 text-[10.5px] font-medium transition-colors",
isActive ? "text-accent" : "text-muted",
)
}
>
<t.icon className="h-5 w-5" strokeWidth={1.8} />
<span>{t.label}</span>
</NavLink>
))}
</div>
</nav>
);
}
// نوار تب پایینِ موبایل — فقط زیر 768px (md:hidden) و فقط در صفحاتِ نقش کاربر:
// مدیر پروژه → تب‌های انبار؛ ادمین/مدیر تدارکات → تب‌های دفتر یا انبار (بر اساس مسیر).
// در ویزارد نمایش داده نمی‌شود تا با جابه‌جایی تصادفی، کارِ کاربر از بین نرود.
export function MobileTabBar() {
const role = useAuthStore((s) => s.user?.role);
const { pathname } = useLocation();
if (pathname === "/login" || pathname === "/admin") return null;
const isPM = role === "pm";
const isOffice = role === "admin" || role === "procurement_manager";
if (pathname.startsWith("/inventory")) {
if (isPM) return <TabBar tabs={PM_INV_TABS} />;
if (isOffice) return <TabBar tabs={ADMIN_INV_TABS} />;
return null;
}
if (pathname === "/wizard") return null;
const onOfficePage =
pathname === "/" ||
pathname === "/projects" ||
pathname === "/units" ||
pathname.startsWith("/suppliers");
if (onOfficePage && isOffice) return <TabBar tabs={PROC_TABS} />;
return null;
}
+16 -11
View File
@@ -22,7 +22,6 @@ import { EditModal } from "./EditModal";
const EMPTY_FILTERS: Filters = { category: "", supplier: "", status: "", search: "" }; const EMPTY_FILTERS: Filters = { category: "", supplier: "", status: "", search: "" };
// صفحه‌ی اصلی دفتر پیشنهادهای خرید
export function DashboardPage() { export function DashboardPage() {
useStoreData(); useStoreData();
@@ -73,20 +72,16 @@ export function DashboardPage() {
const supplierOptions = useMemo(() => suppliers.map((s) => ({ id: s.id, name: s.name })), [suppliers]); const supplierOptions = useMemo(() => suppliers.map((s) => ({ id: s.id, name: s.name })), [suppliers]);
return ( return (
<div className="min-h-screen"> <div className="min-h-screen pb-20 md:pb-6">
<Header <Header
title="دفتر پیشنهادهای خرید" title="دفتر پیشنهادهای خرید"
brand brand
menu={<AppSwitcherMenu activeModule="procurement" />} menu={<AppSwitcherMenu activeModule="procurement" />}
actions={ actions={
<div className="flex items-center gap-2"> <div className="hidden md:flex items-center gap-2">
{/* دکمه تأمین‌کننده‌ها */} <Link to="/suppliers" className="btn-line-file no-underline">
<Link <Users className="h-3.5 w-3.5" />
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>
</Link> </Link>
{/* دکمه پروژه‌ها */} {/* دکمه پروژه‌ها */}
@@ -118,7 +113,8 @@ export function DashboardPage() {
</div> </div>
} }
/> />
<PageLayout className="flex flex-col gap-4">
<PageLayout className="flex flex-col gap-3 px-3 md:px-6">
<StatsBar <StatsBar
total={stats.total} total={stats.total}
offered={stats.offered} offered={stats.offered}
@@ -143,6 +139,15 @@ export function DashboardPage() {
/> />
</PageLayout> </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)} /> <AcceptModal quoteId={acceptId} onClose={() => setAcceptId(null)} />
<EditModal quoteId={editId} onClose={() => setEditId(null)} /> <EditModal quoteId={editId} onClose={() => setEditId(null)} />
</div> </div>
+150 -3
View File
@@ -1,3 +1,5 @@
import { useState } from "react";
import { SlidersHorizontal, X, ArrowUpDown, RotateCcw } from "lucide-react";
import { Combobox } from "../../components/Combobox"; import { Combobox } from "../../components/Combobox";
import { STATUS_LABEL, STATUS_ORDER } from "../../lib/constants"; import { STATUS_LABEL, STATUS_ORDER } from "../../lib/constants";
@@ -20,12 +22,156 @@ export interface FilterToolbarProps {
onToggleSort: () => void; onToggleSort: () => void;
} }
// نوار فیلتر دفتر: ۳ کامبوباکس + جستجوی آزاد + دکمه‌ی مرتب‌سازی ⇅ export function FilterToolbar({
export function FilterToolbar({ filters, onChange, categories, suppliers, sortDir, onToggleSort }: FilterToolbarProps) { filters,
onChange,
categories,
suppliers,
sortDir,
onToggleSort,
}: FilterToolbarProps) {
const [isOpen, setIsOpen] = useState(false);
const set = (patch: Partial<Filters>) => onChange({ ...filters, ...patch }); 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 ( return (
<div className="flex flex-wrap items-end gap-3"> <>
{/* ===== نمای موبایل (صفحه‌نمایش‌های زیر 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"
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="فیلترها"
>
<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>
{/* باتم‌شیت کشویی فیلترها برای موبایل */}
{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"> <div className="field min-w-[150px] flex-1">
<label>دسته</label> <label>دسته</label>
<Combobox <Combobox
@@ -82,5 +228,6 @@ export function FilterToolbar({ filters, onChange, categories, suppliers, sortDi
</button> </button>
</div> </div>
</div> </div>
</>
); );
} }
+4 -4
View File
@@ -12,7 +12,7 @@ export interface LedgerTableProps {
onEdit: (id: string) => void; onEdit: (id: string) => void;
} }
// دفتر پیشنهادها — فهرست کارت‌ها (هر پیشنهاد یک کارت خوانا) // دفتر پیشنهادها — فهرست کارت‌ها (واکنش‌گرا و بهینه برای موبایل)
export function LedgerTable({ export function LedgerTable({
store, store,
quotes, quotes,
@@ -23,14 +23,14 @@ export function LedgerTable({
}: LedgerTableProps) { }: LedgerTableProps) {
if (!quotes.length) { if (!quotes.length) {
return ( 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="پیشنهادی مطابق فیلترها یافت نشد" /> <EmptyState icon={Inbox} text="پیشنهادی مطابق فیلترها یافت نشد" />
</div> </div>
); );
} }
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-2.5 sm:gap-3">
{quotes.map((q) => ( {quotes.map((q) => (
<QuoteCard <QuoteCard
key={q.id} key={q.id}
@@ -42,7 +42,7 @@ export function LedgerTable({
onEdit={() => onEdit(q.id)} 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>
</div> </div>
+81 -92
View File
@@ -1,5 +1,5 @@
import { Link } from "react-router-dom"; 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 type { Quote, Store } from "../../lib/types";
import { import {
isExpired, isExpired,
@@ -27,16 +27,16 @@ export interface QuoteCardProps {
onEdit: () => void; onEdit: () => void;
} }
function InfoCell({ label, children }: { label: string; children: React.ReactNode }) { function InfoCell({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) {
return ( return (
<div className="min-w-0"> <div className={cn("min-w-0", className)}>
<div className="mb-0.5 text-[10.5px] font-semibold text-muted">{label}</div> <div className="mb-0.5 text-[10px] font-semibold text-muted sm:text-[10.5px]">{label}</div>
<div className="min-w-0 text-[13px] leading-6">{children}</div> <div className="min-w-0 text-xs leading-5 text-ink sm:text-[13px] sm:leading-6">{children}</div>
</div> </div>
); );
} }
// کارت ساده و یکدست یک پیشنهاد در دفتر // کارت پیشنهاد خرید — بهینه‌شده برای کاربری سرپا با موبایل
export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit }: QuoteCardProps) { export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit }: 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);
@@ -46,24 +46,25 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
const unitWarns = unitWarning(store, q); const unitWarns = unitWarning(store, q);
const summary = itemsSummary(store, q); const summary = itemsSummary(store, q);
// دکمه یا وضعیت پذیرش // ستون / دکمه پذیرش
let acceptedCell: React.ReactNode; let acceptedCell: React.ReactNode;
if (q.status === "offered") { if (q.status === "offered") {
acceptedCell = ( acceptedCell = (
<button <button
type="button"
onClick={onAccept} 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> </button>
); );
} else if (q.accepted_date) { } 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 { } else {
acceptedCell = <span className="text-muted">—</span>; 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 receivedSum = activeItems.reduce((s, li) => s + (li.received_qty || 0), 0);
const orderedSum = activeItems.reduce((s, li) => s + (li.quantity || 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 anyReceived = receivedSum > 0;
const fullyDelivered = activeItems.length > 0 && fulfilledItems === activeItems.length && anyReceived; const fullyDelivered = activeItems.length > 0 && fulfilledItems === activeItems.length && anyReceived;
// نشان پیشرفت تکمیل
let fulfillmentBadge: React.ReactNode = null; let fulfillmentBadge: React.ReactNode = null;
if (q.status === "accepted" || q.status === "arrived") { if (q.status === "accepted" || q.status === "arrived") {
if (fullyDelivered || q.status === "arrived") { if (fullyDelivered || q.status === "arrived") {
fulfillmentBadge = ( 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> </span>
); );
} else if (anyReceived) { } else if (anyReceived) {
fulfillmentBadge = ( 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"> <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)} قلم رسید: {fmtNum(fulfilledItems)}/{fmtNum(activeItems.length)}
</span> </span>
); );
} else { } else {
fulfillmentBadge = ( 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> </span>
); );
@@ -103,12 +105,6 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
if (Number.isNaN(t)) return acc; if (Number.isNaN(t)) return acc;
return !acc || t > acc.getTime() ? new Date(li.arrived_date) : acc; return !acc || t > acc.getTime() ? new Date(li.arrived_date) : acc;
}, null); }, 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.status === "accepted") {
if (q.arrived_date) { if (q.arrived_date) {
@@ -116,12 +112,11 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
} else if (receivedSum > 0) { } else if (receivedSum > 0) {
arrivedCell = ( arrivedCell = (
<span className="tabular-nums"> <span className="tabular-nums">
رسیده {fmtNum(receivedSum)} از {fmtNum(orderedSum)} — در راه {fmtNum(Math.max(0, orderedSum - receivedSum))} {fmtNum(receivedSum)} از {fmtNum(orderedSum)}
{receiptTimeLine}
</span> </span>
); );
} else { } else {
arrivedCell = <span className="text-muted">در انتظار PM</span>; arrivedCell = <span className="text-muted">در انتظار انبار</span>;
} }
} else if (q.status === "arrived") { } else if (q.status === "arrived") {
arrivedCell = ( arrivedCell = (
@@ -136,47 +131,42 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
return ( return (
<div <div
className={cn( className={cn(
"overflow-hidden rounded-xl border bg-panel transition-all shadow-xs", "overflow-hidden rounded-xl border bg-panel transition-colors shadow-2xs sm:rounded-lg",
expired ? "border-amber/40" : "border-line hover:border-gray-300", expired ? "border-amber/40" : "border-line hover:border-accent/40",
expanded && "border-blue-500/50 shadow-sm", 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"> <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-gray-100 text-xs font-bold text-gray-700"> <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)} {sup.name.slice(0, 1)}
</span> </span>
<div className="min-w-0"> <div className="min-w-0 flex-1">
<Link <Link
to={`/suppliers/${q.supplier_id}`} 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} {sup.name}
</Link> </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>
<div className="ms-auto flex shrink-0 items-center gap-1 sm:gap-1.5">
{/* دکمه‌های عملیاتی راست به چپ */}
<div className="ms-auto flex shrink-0 items-center gap-2">
{fulfillmentBadge ?? ( {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 <button
type="button" className="chip h-7 px-2 sm:h-auto sm:px-2.5"
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"
onClick={onEdit} onClick={onEdit}
title="ویرایش پیشنهاد" title="ویرایش پیشنهاد"
aria-label="ویرایش"
> >
<Pencil className="h-3 w-3" /> <Pencil className="h-3 w-3" />
<span>ویرایش</span> <span className="hidden sm:inline">ویرایش</span>
</button> </button>
{/* فلش آکاردئون */}
<button <button
type="button" className="chip chip-expand h-7 w-7 p-0 sm:h-auto sm:w-auto sm:p-1.5"
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"
onClick={onToggleExpand} onClick={onToggleExpand}
title={expanded ? "جمع کردن" : "نمایش جزئیات"} title={expanded ? "جمع کردن" : "نمایش جزئیات"}
aria-expanded={expanded} aria-expanded={expanded}
@@ -186,38 +176,34 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
</div> </div>
</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="اقلام"> <InfoCell label="اقلام">
{summary.count > 1 ? ( {summary.count > 1 ? (
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<span className="font-semibold">{fmtNum(summary.count)} قلم</span> <span className="font-semibold text-xs sm:text-sm">{fmtNum(summary.count)} قلم کالا</span>
<div className="flex flex-col gap-1 text-[11.5px] leading-5 text-muted"> <div className="flex flex-col gap-0.5 text-[11px] leading-4 text-muted sm:text-[11.5px] sm:leading-5">
{store.quotes {store.quotes.find((x) => x.id === q.id)?.line_items?.filter((li) => !li.rejected).slice(0, 3).map((li) => {
.find((x) => x.id === q.id)
?.line_items?.filter((li) => !li.rejected)
.map((li) => {
const m = materialById(store.materials, li.material_id); const m = materialById(store.materials, li.material_id);
return ( return (
<span key={li.id} className="flex items-center gap-1"> <span key={li.id} className="truncate">
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-blue-500/60" /> • {materialLabel(m)} ({fmtNum(li.quantity)} {li.unit})
{materialLabel(m)}
<span className="text-[10px] text-muted/70">
{fmtNum(li.quantity)} {li.unit}
</span>
</span> </span>
); );
})} })}
{(q.line_items?.length || 0) > 3 && (
<span className="text-[10px] text-muted">و {(q.line_items?.length || 0) - 3} قلم دیگر...</span>
)}
</div> </div>
</div> </div>
) : ( ) : (
summary.text <span className="truncate">{summary.text}</span>
)} )}
{unitWarns.map((w) => ( {unitWarns.map((w) => (
<Link <Link
key={w.unit} key={w.unit}
to={`/units?cat=${encodeURIComponent(w.cat)}&unit=${encodeURIComponent(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)}» را در داشبورد واحدها ثبت کنید`} title={`ضریب تبدیل «${esc(w.unit)}» را در داشبورد واحدها ثبت کنید`}
> >
<TriangleAlert className="h-2.5 w-2.5" /> <TriangleAlert className="h-2.5 w-2.5" />
@@ -227,37 +213,34 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
</InfoCell> </InfoCell>
<InfoCell label="پروژه"> <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" /> <Building2 className="h-3.5 w-3.5 shrink-0 text-muted" />
{q.projects && q.projects.length > 0 ? ( <span className="truncate">
<span className="flex flex-col gap-0.5"> {q.projects && q.projects.length > 0 ? q.projects.join("، ") : projectCell(q)}
{q.projects.map((p, i) => (
<span key={i}>{p}</span>
))}
</span> </span>
) : (
projectCell(q)
)}
</span> </span>
</InfoCell> </InfoCell>
<InfoCell label="مجموع"> <InfoCell label="مجموع مبلغ">
{pc ? ( {pc ? (
<span className="font-bold tabular-nums"> <div>
{pc.num} <span className="num-words">({pc.words} تومان)</span> <div className="text-sm font-extrabold text-ink tabular-nums sm:text-base">
</span> {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>
<InfoCell label="معامله"> <InfoCell label="شرایط معامله">
{ts.title !== "—" ? ( {ts.title !== "—" ? (
<div> <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} {ts.title}
</div> </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> </div>
) : ( ) : (
<span className="text-muted">—</span> <span className="text-muted">—</span>
@@ -265,29 +248,35 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
</InfoCell> </InfoCell>
</div> </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"> <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"> <span className="inline-flex items-center gap-1.5 truncate">
<Package className="h-3 w-3" /> <Package className="h-3 w-3 shrink-0" />
پیشنهاد: <b className="text-ink tabular-nums">{fmtDateTime(q.quote_date)}</b> <span>تاریخ:</span>
<b className="text-ink tabular-nums">{fmtDate(q.quote_date)}</b>
</span> </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")}> <b className={cn("tabular-nums", expired ? "text-amber" : "text-ink")}>
{fmtDate(q.expiry_date)} {fmtDate(q.expiry_date)}
</b> </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>
<span className="inline-flex items-center gap-1.5"> <span className="inline-flex items-center gap-1.5">
پذیرش: {acceptedCell} <span>پذیرش:</span>
{acceptedCell}
</span> </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> </span>
</div> </div>
{/* جریان (expand) */} {/* جزئیات تکمیلی بازشونده */}
{expanded && <QuoteDetail store={store} q={q} />} {expanded && <QuoteDetail store={store} q={q} />}
</div> </div>
); );
+131 -113
View File
@@ -1,6 +1,6 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Link, useLocation, useSearchParams } from "react-router-dom"; 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 { Header, PageLayout } from "../../components/layout/Header";
import { EmptyState } from "../../components/EmptyState"; import { EmptyState } from "../../components/EmptyState";
import { Badge } from "../../components/ui/badge"; 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 type { InventoryRow, InventoryTransaction, TransactionType } from "../../lib/types";
import { faNum } from "../../lib/formatters"; import { faNum } from "../../lib/formatters";
import { isoToJalaliStr } from "../../lib/jalali"; import { isoToJalaliStr } from "../../lib/jalali";
import { cn } from "../../lib/utils";
// رنگ‌بندی معنایی: سبز زمردی = ورود/افزایش، رز = خروج/کاهش، آبی = خنثی/جابه‌جایی // رنگ‌بندی معنایی: سبز زمردی = ورود/افزایش، رز = خروج/کاهش، آبی = خنثی/جابه‌جایی
const TX_META: Record<TransactionType, { label: string; color: string; icon: string }> = { const TX_META: Record<TransactionType, { label: string; color: string; icon: string }> = {
@@ -48,7 +49,6 @@ function specsHash(specs: Record<string, string> | null | undefined): string {
return keys.map((k) => `${k}=${s[k]}`).join("|"); return keys.map((k) => `${k}=${s[k]}`).join("|");
} }
// داشبورد انبار — پورت کامل نسخه‌ی ارسالی (گروه‌بندی + خط زمانی + فیلترها)
export function InventoryPage() { export function InventoryPage() {
const location = useLocation(); const location = useLocation();
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
@@ -64,6 +64,7 @@ export function InventoryPage() {
project: "", project: "",
}); });
const [sort, setSort] = useState<"asc" | "desc" | "">(""); const [sort, setSort] = useState<"asc" | "desc" | "">("");
const [showMobileFilters, setShowMobileFilters] = useState(false);
const [expandedKey, setExpandedKey] = useState<string | null>(null); const [expandedKey, setExpandedKey] = useState<string | null>(null);
const [selTx, setSelTx] = useState<string | null>(null); const [selTx, setSelTx] = useState<string | null>(null);
const [expBrand, setExpBrand] = useState<string | null>(null); const [expBrand, setExpBrand] = useState<string | null>(null);
@@ -133,7 +134,7 @@ export function InventoryPage() {
.slice() .slice()
.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime()); .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 <div className="min-h-screen" />; if (loading) return <div className="min-h-screen" />;
if (!user || (!isAdmin && !isPM)) { if (!user || (!isAdmin && !isPM)) {
@@ -148,33 +149,95 @@ export function InventoryPage() {
} }
return ( return (
<div className="min-h-screen"> <div className="min-h-screen pb-20 md:pb-6">
<Header <Header
title="سامانه مدیریت انبار" title="سامانه مدیریت انبار"
sub={inventorySubheader(user)} sub={inventorySubheader(user)}
icon={<WarehouseIcon className="h-4.5 w-4.5" strokeWidth={2} />} icon={<WarehouseIcon className="h-4.5 w-4.5" strokeWidth={2} />}
menu={<AppSwitcherMenu activeModule="warehouse" canSwitchToProcurement={isAdmin} />} menu={<AppSwitcherMenu activeModule="warehouse" canSwitchToProcurement={isAdmin} />}
actions={ actions={
<> <div className="flex items-center gap-2">
{/* تب‌های تکراری در موبایل مخفی و فقط در دسکتاپ نمایش داده می‌شوند */}
<div className="hidden md:flex items-center gap-2">
<InventoryTopNav pathname={location.pathname} variant={isAdmin ? "admin" : "pm"} /> <InventoryTopNav pathname={location.pathname} variant={isAdmin ? "admin" : "pm"} />
</div>
{isPM && ( {isPM && (
<Button asChild variant="primary"> <Button asChild variant="primary" className="h-8 px-2.5 text-xs">
<Link to="/inventory/change" className="no-underline"> <Link to="/inventory/change" className="no-underline">
<Plus className="h-4 w-4" /> <Plus className="h-3.5 w-3.5" />
تغییر موجودی <span>تغییر موجودی</span>
</Link> </Link>
</Button> </Button>
)} )}
</> </div>
} }
/> />
<PageLayout className="flex flex-col gap-4"> <PageLayout className="flex flex-col gap-3 px-3 md:px-6">
{/* نوار فیلترها و ابزارها */} {/* نوار فیلتر و جستجوی واکنش‌گرا */}
<div className="flex flex-wrap items-end gap-3 rounded-xl border border-line bg-panel p-3.5 shadow-sm"> <div className="rounded-xl border border-line bg-panel p-2.5 sm:p-3">
<div className="flex flex-col gap-1.5"> <div className="flex items-center gap-2">
<label className="text-xs font-medium text-muted">دسته‌بندی</label> <div className="relative flex-1">
<input
className="h-9 w-full rounded-lg border border-line bg-bg/50 px-3 text-xs focus:border-accent focus:bg-panel focus:outline-none"
placeholder="جستجوی نام متریال…"
value={filters.material}
onChange={(e) => setFilters((f) => ({ ...f, material: e.target.value }))}
/>
{filters.material && (
<button
type="button"
onClick={() => setFilters((f) => ({ ...f, material: "" }))}
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted hover:text-ink"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
<button
type="button"
onClick={() => setShowMobileFilters((s) => !s)}
className={cn(
"inline-flex h-9 items-center gap-1.5 rounded-lg border px-2.5 text-xs font-medium transition-colors md:hidden",
hasFilter ? "border-accent bg-accent/10 text-accent font-bold" : "border-line bg-panel text-ink hover:bg-bg"
)}
>
<SlidersHorizontal className="h-3.5 w-3.5" />
<span>فیلترها</span>
{hasFilter && <span className="h-1.5 w-1.5 rounded-full bg-accent" />}
</button>
<button
type="button"
className={cn(
"inline-flex h-9 items-center gap-1 rounded-lg border px-2.5 text-xs transition-colors",
sort ? "border-accent bg-accent/5 text-accent font-semibold" : "border-line bg-panel text-ink hover:bg-bg"
)}
onClick={() => setSort((s) => (s === "" ? "asc" : s === "asc" ? "desc" : ""))}
title="مرتب‌سازی تعداد"
>
<ArrowUpDown className="h-3.5 w-3.5" />
<span className="hidden sm:inline">
{sort === "asc" ? "تعداد: صعودی" : sort === "desc" ? "تعداد: نزولی" : "مرتب‌سازی"}
</span>
</button>
<Badge variant="outline" className="hidden sm:inline-flex h-9 items-center px-2.5 text-xs">
{faNum(groups.length)} قلم
</Badge>
</div>
{/* گزینه‌های فیلتر پیشرفته: در موبایل با دکمه باز می‌شود و در دسکتاپ همیشه باز است */}
<div
className={cn(
"mt-3 grid grid-cols-1 gap-2.5 border-t border-line/60 pt-3 sm:grid-cols-2 md:flex md:flex-wrap md:items-end",
!showMobileFilters && "hidden md:flex"
)}
>
<div className="field flex-1 min-w-[140px]">
<label className="text-[11px] font-semibold text-muted">دسته‌بندی</label>
<select <select
className="h-9 min-w-[150px] rounded-md border border-line bg-panel px-2.5 text-xs text-ink transition-all focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30" className="h-8 w-full rounded-lg border border-line bg-panel px-2 text-xs"
value={filters.category} value={filters.category}
onChange={(e) => setFilters((f) => ({ ...f, category: e.target.value, material: "" }))} onChange={(e) => setFilters((f) => ({ ...f, category: e.target.value, material: "" }))}
> >
@@ -186,10 +249,10 @@ export function InventoryPage() {
</div> </div>
{isAdmin && ( {isAdmin && (
<div className="flex flex-col gap-1.5"> <div className="field flex-1 min-w-[140px]">
<label className="text-xs font-medium text-muted">پروژه</label> <label className="text-[11px] font-semibold text-muted">پروژه</label>
<select <select
className="h-9 min-w-[140px] rounded-md border border-line bg-panel px-2.5 text-xs text-ink transition-all focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30" className="h-8 w-full rounded-lg border border-line bg-panel px-2 text-xs"
value={filters.project} value={filters.project}
onChange={(e) => setFilters((f) => ({ ...f, project: e.target.value, warehouse: "", material: "" }))} onChange={(e) => setFilters((f) => ({ ...f, project: e.target.value, warehouse: "", material: "" }))}
> >
@@ -201,10 +264,10 @@ export function InventoryPage() {
</div> </div>
)} )}
<div className="flex flex-col gap-1.5"> <div className="field flex-1 min-w-[140px]">
<label className="text-xs font-medium text-muted">انبار</label> <label className="text-[11px] font-semibold text-muted">انبار</label>
<select <select
className="h-9 min-w-[140px] rounded-md border border-line bg-panel px-2.5 text-xs text-ink transition-all focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30" className="h-8 w-full rounded-lg border border-line bg-panel px-2 text-xs"
value={filters.warehouse} value={filters.warehouse}
onChange={(e) => setFilters((f) => ({ ...f, warehouse: e.target.value, material: "" }))} onChange={(e) => setFilters((f) => ({ ...f, warehouse: e.target.value, material: "" }))}
> >
@@ -217,80 +280,38 @@ export function InventoryPage() {
</select> </select>
</div> </div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-muted">متریال</label>
<input
className="h-9 w-48 rounded-md border border-line bg-panel px-3 text-xs text-ink transition-all placeholder:text-muted/70 focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
placeholder="تایپ نام متریال…"
value={filters.material}
onChange={(e) => setFilters((f) => ({ ...f, material: e.target.value }))}
/>
</div>
<Button
variant="line"
onClick={() => setSort((s) => (s === "" ? "asc" : s === "asc" ? "desc" : ""))}
className="gap-1.5"
>
{sort === "asc" ? (
<>
<ArrowUp className="h-3.5 w-3.5 text-accent" />
تعداد: صعودی
</>
) : sort === "desc" ? (
<>
<ArrowDown className="h-3.5 w-3.5 text-accent" />
تعداد: نزولی
</>
) : (
<>
<ArrowUpDown className="h-3.5 w-3.5 text-muted" />
مرتب‌سازی تعداد
</>
)}
</Button>
{hasFilter && ( {hasFilter && (
<Button <button
variant="danger" type="button"
className="h-8 rounded-lg border border-red/30 bg-red/5 px-3 text-xs text-red hover:bg-red/10 transition-colors"
onClick={() => setFilters({ category: "", warehouse: "", material: "", project: "" })} onClick={() => setFilters({ category: "", warehouse: "", material: "", project: "" })}
className="gap-1"
> >
<X className="h-3.5 w-3.5" /> ✕ پاک کردن فیلترها
پاک کردن فیلترها </button>
</Button>
)} )}
<div className="ms-auto flex items-center gap-2 self-end"> <div className="text-[11px] text-muted ms-auto sm:hidden">
{filters.warehouse && ( نمایش {faNum(groups.length)} قلم کالا
<Badge variant="offered" className="rounded-md px-2.5 py-1 text-xs"> </div>
🏬 {whName(warehouses, filters.warehouse)}
</Badge>
)}
<Badge variant="outline" className="rounded-md border-line bg-slate-bg px-3 py-1.5 text-xs font-normal text-muted">
{faNum(groups.length)} قلم کالا
</Badge>
</div> </div>
</div> </div>
{/* بخش جدول یا وضعیت خالی */} {/* جدول موجودی انبار با اسکرول افقی محافظت‌شده */}
<div className="overflow-x-auto rounded-xl border border-line bg-panel shadow-2xs [scrollbar-width:thin]">
{!groups.length ? ( {!groups.length ? (
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-line bg-panel/40 py-16 px-4 shadow-xs">
<EmptyState emoji="📋" text="هیچ رکورد موجودی‌ای مطابق فیلترهای انتخابی یافت نشد." /> <EmptyState emoji="📋" text="هیچ رکورد موجودی‌ای مطابق فیلترهای انتخابی یافت نشد." />
</div>
) : ( ) : (
<div className="overflow-hidden rounded-xl border border-line bg-panel shadow-sm"> <table className="w-full min-w-[640px] text-[12.5px]">
<table className="w-full text-[12.5px]">
<thead> <thead>
<tr className="border-b border-line bg-slate-bg/50 text-right text-muted font-medium"> <tr className="border-b border-line bg-bg/40 text-right text-muted">
<th className="w-8 py-2.5" /> <th className="w-8 py-2.5" />
<th className="py-2.5 pe-3">متریال</th> <th className="py-2.5 pe-3 font-semibold">متریال</th>
<th className="py-2.5 pe-3">دسته‌بندی</th> <th className="py-2.5 pe-3 font-semibold">دسته‌بندی</th>
<th className="py-2.5 pe-3">انبار</th> <th className="py-2.5 pe-3 font-semibold">انبار</th>
<th className="py-2.5 pe-3">مشخصه فنی</th> <th className="py-2.5 pe-3 font-semibold">مشخصه فنی</th>
<th className="py-2.5 pe-3">تعداد</th> <th className="py-2.5 pe-3 font-semibold">تعداد</th>
<th className="py-2.5 pe-3">واحد</th> <th className="py-2.5 pe-3 font-semibold">واحد</th>
<th className="py-2.5 pe-3">آخرین به‌روزرسانی</th> <th className="py-2.5 pe-3 font-semibold">آخرین به‌روزرسانی</th>
<th className="w-8 py-2.5" /> <th className="w-8 py-2.5" />
</tr> </tr>
</thead> </thead>
@@ -337,10 +358,11 @@ export function InventoryPage() {
})} })}
</tbody> </tbody>
</table> </table>
</div>
)} )}
</div>
<div className="text-[11px] text-muted"> {/* متن راهنما فقط در دسکتاپ نمایش داده می‌شود */}
<div className="hidden md:block text-[11px] text-muted">
۱۰ نوع تراکنش استاندارد • سبز زمردی = ورود به انبار · رز = خروج/کاهش · آبی = انتقال و شمارش • کلیک روی هر سطر: خط زمانی با «مانده» لحظه‌به‌لحظه و تفکیک برندها ۱۰ نوع تراکنش استاندارد • سبز زمردی = ورود به انبار · رز = خروج/کاهش · آبی = انتقال و شمارش • کلیک روی هر سطر: خط زمانی با «مانده» لحظه‌به‌لحظه و تفکیک برندها
</div> </div>
</PageLayout> </PageLayout>
@@ -362,20 +384,20 @@ function GroupRow({ g, isExp, isAdmin, matName, matCatName, wh, projName, unit,
}) { }) {
return ( return (
<> <>
<tr className={`border-b border-line/60 cursor-pointer transition-colors duration-150 ${isExp ? "bg-accent/5" : "hover:bg-slate-bg/60"}`} onClick={onToggle}> <tr className={`border-b border-line/60 cursor-pointer transition-colors ${isExp ? "bg-accent/5" : "hover:bg-accent/5"}`} onClick={onToggle}>
<td className="py-2.5 text-center text-muted">{isExp ? "▾" : "▸"}</td> <td className="py-2.5 text-center text-muted">{isExp ? "▾" : "▸"}</td>
<td className="py-2.5 pe-3 font-semibold text-ink">{matName}</td> <td className="py-2.5 pe-3 font-bold text-ink">{matName}</td>
<td className="py-2.5 pe-3">{matCatName}</td> <td className="py-2.5 pe-3 text-muted">{matCatName}</td>
<td className="py-2.5 pe-3"> <td className="py-2.5 pe-3">
{wh} <div>{wh}</div>
{isAdmin && <small className="block text-muted">{projName}</small>} {isAdmin && <span className="block text-[10.5px] text-muted">{projName}</span>}
</td> </td>
<td className="py-2.5 pe-3 text-muted"> <td className="py-2.5 pe-3 text-muted">
{Object.entries(g.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"} {Object.entries(g.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}
</td> </td>
<td className="py-2.5 pe-3 font-bold text-ink">{faNum(g.qty)}</td> <td className="py-2.5 pe-3 font-extrabold text-ink tabular-nums">{faNum(g.qty)}</td>
<td className="py-2.5 pe-3">{unit}</td> <td className="py-2.5 pe-3">{unit}</td>
<td className="py-2.5 pe-3 text-muted">{isoToJalaliStr(g.updatedAt)}</td> <td className="py-2.5 pe-3 text-muted tabular-nums">{isoToJalaliStr(g.updatedAt)}</td>
<td className="py-2.5 text-muted">🕘</td> <td className="py-2.5 text-muted">🕘</td>
</tr> </tr>
{isExp && ( {isExp && (
@@ -412,16 +434,14 @@ function Timeline({ g, txs, allTxs, brandTxs, selTx, setSelTx, expBrand, setExpB
key={t.id} key={t.id}
type="button" type="button"
onClick={() => setSelTx(sel ? null : t.id)} 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] ${ 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"}`}
sel ? "border-accent bg-accent/10 shadow-xs" : "border-line bg-panel hover:border-slate-300"
}`}
style={{ borderTop: `3px solid ${meta.color}` }} style={{ borderTop: `3px solid ${meta.color}` }}
> >
<span className="text-[11px] font-bold" style={{ color: meta.color }}>{meta.icon} {meta.label}</span> <span className="text-[11px] font-bold" style={{ color: meta.color }}>{meta.icon} {meta.label}</span>
<span className="text-[12px] font-bold" style={{ color: meta.color }}> <span className="text-[12px] font-bold tabular-nums" style={{ color: meta.color }}>
{faNum(Math.abs(t.quantity_number))} {unit} {faNum(Math.abs(t.quantity_number))} {unit}
</span> </span>
<span className="text-[10.5px] text-muted">مانده: <b>{faNum(bal)}</b> {unit}</span> <span className="text-[10.5px] text-muted">مانده: <b className="tabular-nums">{faNum(bal)}</b> {unit}</span>
</button> </button>
); );
}); });
@@ -430,9 +450,9 @@ function Timeline({ g, txs, allTxs, brandTxs, selTx, setSelTx, expBrand, setExpB
if (!txs.length) return <div className="text-[12px] text-muted">تراکنشی ثبت نشده است.</div>; if (!txs.length) return <div className="text-[12px] text-muted">تراکنشی ثبت نشده است.</div>;
const brandSec = expBrand ? ( const brandSec = expBrand ? (
<div className="mt-2.5"> <div className="mt-2">
<div className="mb-1.5 text-[12px] font-semibold">جریان برند: {brandNameFn(expBrand)} ({faNum(brandTxs(g, expBrand).length)} تراکنش)</div> <div className="mb-1 text-[12px] font-semibold">جریان برند: {brandNameFn(expBrand)} ({faNum(brandTxs(g, expBrand).length)} تراکنش)</div>
<div className="flex gap-2 overflow-x-auto pb-1">{track(brandTxs(g, expBrand))}</div> <div className="flex gap-1.5 overflow-x-auto pb-1 [scrollbar-width:thin]">{track(brandTxs(g, expBrand))}</div>
</div> </div>
) : null; ) : 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 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; const pairTx = t.transfer_pair_id ? allTxs.find((x) => x.transfer_pair_id === t.transfer_pair_id && x.id !== t.id) : null;
return ( return (
<div className="mt-2.5 rounded-md border border-line bg-panel p-2.5 text-[12px] shadow-xs" style={{ borderTop: `3px solid ${meta.color}` }}> <div className="mt-2 rounded-lg border p-2.5 text-[12px] bg-panel shadow-2xs" style={{ borderTop: `3px solid ${meta.color}` }}>
<b style={{ color: meta.color }}>{meta.label}</b> <b style={{ color: meta.color }}>{meta.label}</b>
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-muted"> <div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-muted">
<span>تاریخ و ساعت: <b>{isoToJalaliStr(t.transaction_date)}</b></span> <span>تاریخ و ساعت: <b className="text-ink">{isoToJalaliStr(t.transaction_date)}</b></span>
{t.transaction_type === "TRANSFER_OUT" || t.transaction_type === "TRANSFER_IN" ? ( {t.transaction_type === "TRANSFER_OUT" || t.transaction_type === "TRANSFER_IN" ? (
<> <>
<span>از انبار: <b>{t.transaction_type === "TRANSFER_OUT" ? whNameFn(t.warehouse_id) : whNameFn(pairTx?.warehouse_id)}</b></span> <span>از انبار: <b className="text-ink">{t.transaction_type === "TRANSFER_OUT" ? whNameFn(t.warehouse_id) : whNameFn(pairTx?.warehouse_id)}</b></span>
<span>به انبار: <b>{t.transaction_type === "TRANSFER_IN" ? whNameFn(t.warehouse_id) : whNameFn(pairTx?.warehouse_id)}</b></span> <span>به انبار: <b className="text-ink">{t.transaction_type === "TRANSFER_IN" ? whNameFn(t.warehouse_id) : whNameFn(pairTx?.warehouse_id)}</b></span>
</> </>
) : ( ) : (
<span>انبار: <b>{whNameFn(t.warehouse_id)}</b></span> <span>انبار: <b className="text-ink">{whNameFn(t.warehouse_id)}</b></span>
)} )}
{t.brand?.name && <span>برند: <b>{t.brand.name}</b></span>} {t.brand?.name && <span>برند: <b className="text-ink">{t.brand.name}</b></span>}
{t.performed_by && <span>انجام‌دهنده: <b>{t.performed_by}</b></span>} {t.performed_by && <span>انجام‌دهنده: <b className="text-ink">{t.performed_by}</b></span>}
{t.note && <span>یادداشت: <b>{t.note}</b></span>} {t.note && <span>یادداشت: <b className="text-ink">{t.note}</b></span>}
{t.transaction_type === "STOCK_COUNT" && t.recorded_qty != null && t.physical_qty != null && ( {t.transaction_type === "STOCK_COUNT" && t.recorded_qty != null && t.physical_qty != null && (
<span> <span>
ثبت‌شده: {faNum(t.recorded_qty)} • شمارش‌شده: {faNum(t.physical_qty)} • اختلاف: {faNum(t.physical_qty - t.recorded_qty)} ثبت‌شده: {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} key={bid}
type="button" type="button"
onClick={() => setExpBrand(expBrand === bid ? null : bid)} 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] ${ 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"}`}
expBrand === bid ? "border-accent bg-accent/10 font-semibold text-accent" : "border-line bg-panel hover:bg-slate-bg"
}`}
> >
{brandNameFn(bid)}: <b>{faNum(g.brands[bid])}</b> {unit}{expBrand === bid ? " ✕" : ""} {brandNameFn(bid)}: <b className="tabular-nums">{faNum(g.brands[bid])}</b> {unit}{expBrand === bid ? " ✕" : ""}
</button> </button>
))} ))}
</div> </div>
{brandSec} {brandSec}
<div className="mb-1.5 mt-2.5 text-[12px] font-semibold">جریان کلی رکورد — ترکیب همه برندها ({faNum(txs.length)} تراکنش)</div> <div className="mb-1 mt-2 text-[12px] font-semibold">جریان کلی رکورد — ترکیب همه برندها ({faNum(txs.length)} تراکنش)</div>
<div className="flex gap-2 overflow-x-auto pb-1">{track(txs)}</div> <div className="flex gap-1.5 overflow-x-auto pb-1 [scrollbar-width:thin]">{track(txs)}</div>
{selDetail} {selDetail}
</div> </div>
); );