fix(inventory): optimize mobile layout with card view and chip filters
This commit is contained in:
@@ -1,9 +1,8 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link, useLocation, useSearchParams } from "react-router-dom";
|
||||
import { Plus, Warehouse as WarehouseIcon, SlidersHorizontal, ArrowUpDown, X } from "lucide-react";
|
||||
import { Plus, Warehouse as WarehouseIcon, SlidersHorizontal, ArrowUpDown, X, ChevronDown } from "lucide-react";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { EmptyState } from "../../components/EmptyState";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
|
||||
import { inventorySubheader } from "./panelMenu";
|
||||
@@ -14,7 +13,7 @@ import { faNum } from "../../lib/formatters";
|
||||
import { isoToJalaliStr } from "../../lib/jalali";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
// رنگبندی معنایی: سبز زمردی = ورود/افزایش، رز = خروج/کاهش، آبی = خنثی/جابهجایی
|
||||
// رنگبندی معنایی تراکنشها
|
||||
const TX_META: Record<TransactionType, { label: string; color: string; icon: string }> = {
|
||||
PURCHASE: { label: "خرید", color: "#10b981", icon: "🛒" },
|
||||
WITHDRAWAL: { label: "برداشت", color: "#e11d48", icon: "➖" },
|
||||
@@ -148,24 +147,23 @@ export function InventoryPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen pb-24 md:pb-6">
|
||||
<div className="min-h-screen pb-24 md:pb-6 overflow-x-hidden">
|
||||
<Header
|
||||
title="سامانه مدیریت انبار"
|
||||
sub={inventorySubheader(user)}
|
||||
icon={<WarehouseIcon className="h-4.5 w-4.5" strokeWidth={2} />}
|
||||
menu={<AppSwitcherMenu activeModule="warehouse" canSwitchToProcurement={isAdmin} />}
|
||||
actions={
|
||||
/* تبهای ناوبری فقط در دسکتاپ — دکمه بالایی به طور کامل حذف شد */
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
<InventoryTopNav pathname={location.pathname} variant={isAdmin ? "admin" : "pm"} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<PageLayout className="flex flex-col gap-3 px-3 md:px-6">
|
||||
{/* نوار فیلتر و جستجوی واکنشگرا */}
|
||||
<div className="rounded-xl border border-line bg-panel p-2.5 sm:p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<PageLayout className="flex flex-col gap-3 px-3 md:px-6 w-full max-w-full">
|
||||
{/* جعبه فیلترها و جستجو */}
|
||||
<div className="rounded-xl border border-line bg-panel p-2.5 sm:p-3 w-full shadow-2xs">
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<div className="relative flex-1 min-w-0">
|
||||
<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="جستجوی نام متریال…"
|
||||
@@ -187,8 +185,10 @@ export function InventoryPage() {
|
||||
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"
|
||||
"inline-flex shrink-0 h-9 items-center gap-1.5 rounded-lg border px-2.5 text-xs font-medium transition-colors",
|
||||
showMobileFilters || 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" />
|
||||
@@ -199,7 +199,7 @@ export function InventoryPage() {
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center gap-1 rounded-lg border px-2.5 text-xs transition-colors",
|
||||
"inline-flex shrink-0 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" : ""))}
|
||||
@@ -210,83 +210,186 @@ export function InventoryPage() {
|
||||
{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
|
||||
className="h-8 w-full rounded-lg border border-line bg-panel px-2 text-xs"
|
||||
value={filters.category}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, category: e.target.value, material: "" }))}
|
||||
>
|
||||
<option value="">همه دستهبندیها</option>
|
||||
{lookup.categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<div className="field flex-1 min-w-[140px]">
|
||||
<label className="text-[11px] font-semibold text-muted">پروژه</label>
|
||||
<select
|
||||
className="h-8 w-full rounded-lg border border-line bg-panel px-2 text-xs"
|
||||
value={filters.project}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, project: e.target.value, warehouse: "", material: "" }))}
|
||||
>
|
||||
<option value="">همه پروژهها</option>
|
||||
{lookup.projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="field flex-1 min-w-[140px]">
|
||||
<label className="text-[11px] font-semibold text-muted">انبار</label>
|
||||
<select
|
||||
className="h-8 w-full rounded-lg border border-line bg-panel px-2 text-xs"
|
||||
value={filters.warehouse}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, warehouse: e.target.value, material: "" }))}
|
||||
>
|
||||
<option value="">همه انبارها</option>
|
||||
{visibleWarehouses.map((w) => (
|
||||
<option key={w.id} value={w.id}>
|
||||
{w.name}{isAdmin ? ` — ${lookup.projects.find((p) => p.id === w.project_id)?.name || ""}` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{hasFilter && (
|
||||
{/* فیلتر سریع انبارها (چیپهای افقی لمسی) */}
|
||||
{visibleWarehouses.length > 1 && (
|
||||
<div className="mt-2.5 flex items-center gap-1.5 overflow-x-auto pb-1 pt-0.5 [scrollbar-width:none]">
|
||||
<button
|
||||
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((f) => ({ ...f, warehouse: "" }))}
|
||||
className={cn(
|
||||
"shrink-0 rounded-lg px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
!filters.warehouse
|
||||
? "bg-accent text-white shadow-xs font-bold"
|
||||
: "border border-line bg-bg/40 text-muted hover:bg-panel hover:text-ink"
|
||||
)}
|
||||
>
|
||||
✕ پاک کردن فیلترها
|
||||
همه انبارها
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="text-[11px] text-muted ms-auto sm:hidden">
|
||||
نمایش {faNum(groups.length)} قلم کالا
|
||||
{visibleWarehouses.map((w) => (
|
||||
<button
|
||||
key={w.id}
|
||||
type="button"
|
||||
onClick={() => setFilters((f) => ({ ...f, warehouse: f.warehouse === w.id ? "" : w.id }))}
|
||||
className={cn(
|
||||
"shrink-0 rounded-lg px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
filters.warehouse === w.id
|
||||
? "bg-accent text-white shadow-xs font-bold"
|
||||
: "border border-line bg-bg/40 text-muted hover:bg-panel hover:text-ink"
|
||||
)}
|
||||
>
|
||||
{w.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* فیلترهای پیشرفته — برچسبهای لمسی بدون سلکت و بدون بیرونزدگی */}
|
||||
{showMobileFilters && (
|
||||
<div className="mt-2.5 border-t border-line/60 pt-2.5 flex flex-col gap-2.5">
|
||||
{/* دستهبندیها */}
|
||||
<div>
|
||||
<div className="mb-1.5 text-[11px] font-bold text-muted">دستهبندی متریال:</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilters((f) => ({ ...f, category: "" }))}
|
||||
className={cn(
|
||||
"rounded-lg px-2.5 py-1 text-[11.5px] transition-colors",
|
||||
!filters.category
|
||||
? "bg-accent text-white font-bold"
|
||||
: "border border-line bg-bg/40 text-ink hover:bg-panel"
|
||||
)}
|
||||
>
|
||||
همه دستهبندیها
|
||||
</button>
|
||||
{lookup.categories.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
onClick={() => setFilters((f) => ({ ...f, category: f.category === c.id ? "" : c.id }))}
|
||||
className={cn(
|
||||
"rounded-lg px-2.5 py-1 text-[11.5px] transition-colors",
|
||||
filters.category === c.id
|
||||
? "bg-accent text-white font-bold"
|
||||
: "border border-line bg-bg/40 text-ink hover:bg-panel"
|
||||
)}
|
||||
>
|
||||
{c.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* پروژهها (مخصوص ادمین) */}
|
||||
{isAdmin && lookup.projects.length > 0 && (
|
||||
<div>
|
||||
<div className="mb-1.5 text-[11px] font-bold text-muted">پروژه:</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilters((f) => ({ ...f, project: "" }))}
|
||||
className={cn(
|
||||
"rounded-lg px-2.5 py-1 text-[11.5px] transition-colors",
|
||||
!filters.project
|
||||
? "bg-accent text-white font-bold"
|
||||
: "border border-line bg-bg/40 text-ink hover:bg-panel"
|
||||
)}
|
||||
>
|
||||
همه پروژهها
|
||||
</button>
|
||||
{lookup.projects.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => setFilters((f) => ({ ...f, project: f.project === p.id ? "" : p.id }))}
|
||||
className={cn(
|
||||
"rounded-lg px-2.5 py-1 text-[11.5px] transition-colors",
|
||||
filters.project === p.id
|
||||
? "bg-accent text-white font-bold"
|
||||
: "border border-line bg-bg/40 text-ink hover:bg-panel"
|
||||
)}
|
||||
>
|
||||
{p.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* دکمه پاک کردن فیلترها */}
|
||||
{hasFilter && (
|
||||
<div className="pt-1 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-red font-medium hover:underline inline-flex items-center gap-1"
|
||||
onClick={() => setFilters({ category: "", warehouse: "", material: "", project: "" })}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
پاک کردن فیلترها
|
||||
</button>
|
||||
<span className="text-[11px] text-muted">
|
||||
نمایش {faNum(groups.length)} قلم کالا
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* جدول موجودی انبار با اسکرول افقی محافظتشده */}
|
||||
<div className="overflow-x-auto rounded-xl border border-line bg-panel shadow-2xs [scrollbar-width:thin]">
|
||||
{/* ۱. نمای مخصوص موبایل: کارتهای عمودی کاملاً ریسپانسیو */}
|
||||
<div className="flex flex-col gap-2.5 md:hidden w-full">
|
||||
{!groups.length ? (
|
||||
<div className="rounded-xl border border-line bg-panel p-4">
|
||||
<EmptyState emoji="📋" text="هیچ رکورد موجودیای مطابق فیلترهای انتخابی یافت نشد." />
|
||||
</div>
|
||||
) : (
|
||||
groups.map((g) => {
|
||||
const isExp = expandedKey === g.key;
|
||||
const matCat = lookup.materials.find((m) => m.id === g.matId)?.category;
|
||||
return (
|
||||
<InventoryMobileCard
|
||||
key={g.key}
|
||||
g={g}
|
||||
isExp={isExp}
|
||||
isAdmin={isAdmin}
|
||||
matName={materialName(lookup.materials, g.matId)}
|
||||
matCatName={matCat ? catName(lookup.categories, matCat.id) : "—"}
|
||||
wh={whName(warehouses, g.whId)}
|
||||
projName={lookup.projects.find((p) => p.id === warehouses.find((w) => w.id === g.whId)?.project_id)?.name || ""}
|
||||
unit={unitName(lookup.units, g.unitId)}
|
||||
onToggle={() => {
|
||||
setExpandedKey(isExp ? null : g.key);
|
||||
setSelTx(null);
|
||||
setExpBrand(null);
|
||||
}}
|
||||
timeline={
|
||||
isExp ? (
|
||||
<Timeline
|
||||
g={g}
|
||||
txs={groupTxs(g)}
|
||||
allTxs={transactions}
|
||||
brandTxs={brandTxs}
|
||||
selTx={selTx}
|
||||
setSelTx={setSelTx}
|
||||
expBrand={expBrand}
|
||||
setExpBrand={setExpBrand}
|
||||
matName={materialName(lookup.materials, g.matId)}
|
||||
unit={unitName(lookup.units, g.unitId)}
|
||||
brandNameFn={(id) => brandName(lookup.brands, id)}
|
||||
whNameFn={(id) => whName(warehouses, id)}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ۲. نمای دسکتاپ: جدول سنتی */}
|
||||
<div className="hidden md:block overflow-x-auto rounded-xl border border-line bg-panel shadow-2xs [scrollbar-width:thin]">
|
||||
{!groups.length ? (
|
||||
<EmptyState emoji="📋" text="هیچ رکورد موجودیای مطابق فیلترهای انتخابی یافت نشد." />
|
||||
) : (
|
||||
@@ -349,14 +452,9 @@ export function InventoryPage() {
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* متن راهنما فقط در دسکتاپ نمایش داده میشود */}
|
||||
<div className="hidden md:block text-[11px] text-muted">
|
||||
۱۰ نوع تراکنش استاندارد • سبز زمردی = ورود به انبار · رز = خروج/کاهش · آبی = انتقال و شمارش • کلیک روی هر سطر: خط زمانی با «مانده» لحظهبهلحظه و تفکیک برندها
|
||||
</div>
|
||||
</PageLayout>
|
||||
|
||||
{/* دکمه شناور تغییر موجودی (FAB دایرهای آبی با علامت جمع) */}
|
||||
{/* دکمه شناور تغییر موجودی */}
|
||||
{isPM && (
|
||||
<Link
|
||||
to="/inventory/change"
|
||||
@@ -371,6 +469,111 @@ export function InventoryPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// کامپوننت کارت انبار در موبایل
|
||||
function InventoryMobileCard({
|
||||
g,
|
||||
isExp,
|
||||
isAdmin,
|
||||
matName,
|
||||
matCatName,
|
||||
wh,
|
||||
projName,
|
||||
unit,
|
||||
onToggle,
|
||||
timeline,
|
||||
}: {
|
||||
g: Group;
|
||||
isExp: boolean;
|
||||
isAdmin: boolean;
|
||||
matName: string;
|
||||
matCatName: string;
|
||||
wh: string;
|
||||
projName: string;
|
||||
unit: string;
|
||||
onToggle: () => void;
|
||||
timeline: React.ReactNode;
|
||||
}) {
|
||||
const specsText = Object.entries(g.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-xl border bg-panel transition-all shadow-2xs w-full",
|
||||
isExp ? "border-accent/60 ring-1 ring-accent/20" : "border-line hover:border-line/80"
|
||||
)}
|
||||
>
|
||||
<div onClick={onToggle} className="cursor-pointer p-3 active:bg-bg/40 transition-colors">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-[13.5px] font-bold leading-snug text-ink break-words">
|
||||
{matName}
|
||||
</h3>
|
||||
{matCatName !== "—" && (
|
||||
<span className="mt-0.5 inline-block text-[11px] text-muted">
|
||||
{matCatName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-bg text-muted transition-transform duration-200",
|
||||
isExp && "rotate-180 bg-accent/10 text-accent"
|
||||
)}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* کادر شاخص موجودی و انبار */}
|
||||
<div className="my-2.5 flex items-center justify-between gap-3 rounded-lg border border-line/60 bg-bg/50 p-2.5">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] font-semibold text-muted">موجودی انبار</span>
|
||||
<div className="flex items-baseline gap-1 pt-0.5">
|
||||
<span className="text-lg font-black tabular-nums text-ink">
|
||||
{faNum(g.qty)}
|
||||
</span>
|
||||
<span className="text-[11px] font-medium text-muted">{unit}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end text-end min-w-0">
|
||||
<span className="text-[10px] font-semibold text-muted">محل نگهداری</span>
|
||||
<div className="flex items-center gap-1 text-xs font-bold text-accent truncate pt-0.5">
|
||||
<WarehouseIcon className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{wh}</span>
|
||||
</div>
|
||||
{isAdmin && projName && (
|
||||
<span className="truncate text-[10px] text-muted max-w-[130px]">{projName}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* مشخصات فنی و تاریخ */}
|
||||
<div className="flex items-center justify-between gap-2 text-[10.5px] text-muted pt-0.5">
|
||||
<div className="min-w-0 flex-1 truncate">
|
||||
{specsText ? (
|
||||
<span className="rounded bg-bg px-1.5 py-0.5 text-[10px] font-medium text-ink">
|
||||
{specsText}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted/60">—</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="shrink-0 tabular-nums">
|
||||
{isoToJalaliStr(g.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExp && (
|
||||
<div className="border-t border-line/70 bg-accent-bg/10 p-3">
|
||||
{timeline}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupRow({ g, isExp, isAdmin, matName, matCatName, wh, projName, unit, onToggle, timeline }: {
|
||||
g: Group;
|
||||
isExp: boolean;
|
||||
|
||||
Reference in New Issue
Block a user