822 lines
36 KiB
TypeScript
822 lines
36 KiB
TypeScript
import { useMemo, useState } from "react";
|
||
import { Link, useLocation, useSearchParams } from "react-router-dom";
|
||
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 { useAuthStore } from "../../store/auth";
|
||
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
|
||
import { inventorySubheader } from "./panelMenu";
|
||
import { InventoryTopNav } from "./InventoryTopNav";
|
||
import { useInventoryData, brandName, materialName, unitName, whName } from "./useInventoryData";
|
||
import type { InventoryRow, InventoryTransaction, TransactionType } from "../../lib/types";
|
||
import { faNum } from "../../lib/formatters";
|
||
import { isoToJalaliStr } from "../../lib/jalali";
|
||
import { cn } from "../../lib/utils";
|
||
import { JalaliDatePicker } from "../../components/JalaliDatePicker";
|
||
|
||
const TX_TYPE_OPTIONS: { type: string; label: string }[] = [
|
||
{ type: "", label: "همه عملیاتها" },
|
||
{ type: "PURCHASE", label: "موجودی اولیه / خرید" },
|
||
{ type: "WITHDRAWAL", label: "خروج / مصرف" },
|
||
{ type: "PROJECT_TRANSFER", label: "انتقال بین کارگاهی" },
|
||
{ type: "RETURN", label: "برگشت به انبار" },
|
||
{ type: "TRANSFER_OUT", label: "انتقال بین انبارها (خروجی)" },
|
||
{ type: "TRANSFER_IN", label: "انتقال بین انبارها (ورودی)" },
|
||
{ type: "DAMAGE", label: "خرابی" },
|
||
{ type: "WASTE", label: "ضایعات" },
|
||
{ type: "LOSS", label: "مفقودی" },
|
||
{ type: "FOUND", label: "پیدا شده" },
|
||
{ type: "STOCK_COUNT", label: "انبارگردانی / شمارش" },
|
||
];
|
||
|
||
// رنگبندی معنایی تراکنشها
|
||
const TX_META: Record<TransactionType, { label: string; color: string; icon: string }> = {
|
||
PURCHASE: { label: "خرید", color: "#10b981", icon: "🛒" },
|
||
WITHDRAWAL: { label: "برداشت", color: "#e11d48", icon: "➖" },
|
||
RETURN: { label: "برگشت", color: "#10b981", icon: "↩️" },
|
||
TRANSFER_IN: { label: "انتقال (ورودی)", color: "#2563eb", icon: "⬅️" },
|
||
TRANSFER_OUT: { label: "انتقال (خروجی)", color: "#2563eb", icon: "➡️" },
|
||
DAMAGE: { label: "ضایعات", color: "#e11d48", icon: "⚠️" },
|
||
WASTE: { label: "اتلاف", color: "#e11d48", icon: "🗑️" },
|
||
LOSS: { label: "مفقودی", color: "#e11d48", icon: "📦" },
|
||
FOUND: { label: "پیدا شده", color: "#10b981", icon: "🔍" },
|
||
STOCK_COUNT: { label: "شمارش موجودی", color: "#2563eb", icon: "✅" },
|
||
};
|
||
|
||
interface Group {
|
||
key: string;
|
||
whId: string;
|
||
matId: string;
|
||
specs: Record<string, string>;
|
||
unitId: number | null;
|
||
qty: number;
|
||
updatedAt: string;
|
||
recs: InventoryRow[];
|
||
brands: Record<string, number>;
|
||
brandOrder: string[];
|
||
}
|
||
|
||
function specsHash(specs: Record<string, string> | null | undefined): string {
|
||
const s = specs || {};
|
||
const keys = Object.keys(s).filter((k) => s[k] !== "" && s[k] != null).sort();
|
||
if (!keys.length) return "default";
|
||
return keys.map((k) => `${k}=${s[k]}`).join("|");
|
||
}
|
||
|
||
export function InventoryPage() {
|
||
const location = useLocation();
|
||
const user = useAuthStore((s) => s.user);
|
||
const { warehouses, inventory, transactions, lookup, loading } = useInventoryData();
|
||
const isManager = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm";
|
||
const isSupervisor = user?.role === "supervisor";
|
||
const isAdmin = user?.role === "admin";
|
||
// پنل انبار limited: تغییر موجودی مجاز است؛ full: طبق طراحی ممنوع
|
||
const canModify = isSupervisor || isAdmin || user?.panels?.inventory === "limited";
|
||
const [params] = useSearchParams();
|
||
|
||
const [filters, setFilters] = useState<{
|
||
warehouse: string;
|
||
material: string;
|
||
project: string;
|
||
transactionType: string;
|
||
dateFrom: string | null;
|
||
dateTo: string | null;
|
||
}>({
|
||
warehouse: params.get("warehouse") || "",
|
||
material: "",
|
||
project: "",
|
||
transactionType: "",
|
||
dateFrom: null,
|
||
dateTo: null,
|
||
});
|
||
const [sort, setSort] = useState<"asc" | "desc" | "">("");
|
||
const [showMobileFilters, setShowMobileFilters] = useState(false);
|
||
const [expandedKey, setExpandedKey] = useState<string | null>(null);
|
||
const [selTx, setSelTx] = useState<string | null>(null);
|
||
const [expBrand, setExpBrand] = useState<string | null>(null);
|
||
|
||
const visibleWarehouses = useMemo(() => {
|
||
if (isManager && filters.project) return warehouses.filter((w) => w.project_id === filters.project);
|
||
if (isManager) return warehouses;
|
||
return warehouses.filter((w) => String(w.project_id) === String(user?.project_id || ""));
|
||
}, [warehouses, isManager, filters.project, user?.project_id]);
|
||
|
||
const visibleIds = useMemo(() => new Set(visibleWarehouses.map((w) => w.id)), [visibleWarehouses]);
|
||
|
||
// فیلتر تراکنشها بر اساس نوع و بازه زمانی روزانه (۰۰:۰۰:۰۰ تا ۲۳:۵۹:۵۹)
|
||
const filteredTransactions = useMemo(() => {
|
||
let list = transactions;
|
||
if (filters.transactionType) {
|
||
list = list.filter((t) => t.transaction_type === filters.transactionType);
|
||
}
|
||
if (filters.dateFrom) {
|
||
const dFrom = new Date(filters.dateFrom);
|
||
const startOfDay = new Date(dFrom.getFullYear(), dFrom.getMonth(), dFrom.getDate(), 0, 0, 0, 0);
|
||
list = list.filter((t) => new Date(t.transaction_date).getTime() >= startOfDay.getTime());
|
||
}
|
||
if (filters.dateTo) {
|
||
const dTo = new Date(filters.dateTo);
|
||
const endOfDay = new Date(dTo.getFullYear(), dTo.getMonth(), dTo.getDate(), 23, 59, 59, 999);
|
||
list = list.filter((t) => new Date(t.transaction_date).getTime() <= endOfDay.getTime());
|
||
}
|
||
return list;
|
||
}, [transactions, filters.transactionType, filters.dateFrom, filters.dateTo]);
|
||
|
||
const hasTxFilter = !!(filters.transactionType || filters.dateFrom || filters.dateTo);
|
||
|
||
const groups = useMemo<Group[]>(() => {
|
||
const gmap = new Map<string, Group>();
|
||
const order: string[] = [];
|
||
|
||
// اگر فیلتر تراکنش فعال است، فقط اقلام دارای تراکنش منطبق در خروجی ظاهر میشوند
|
||
const activeInvIds = hasTxFilter ? new Set(filteredTransactions.map((t) => t.inventory_id)) : null;
|
||
|
||
inventory.forEach((r) => {
|
||
const whId = r.warehouse?.id;
|
||
if (!whId || !visibleIds.has(whId)) return;
|
||
if (filters.warehouse && whId !== filters.warehouse) return;
|
||
if (filters.material && !r.material.name.includes(filters.material.trim())) return;
|
||
if (activeInvIds && !activeInvIds.has(r.id)) return;
|
||
const gk = `${whId}|${r.material.id}|${specsHash(r.specs)}`;
|
||
let g = gmap.get(gk);
|
||
if (!g) {
|
||
g = {
|
||
key: gk,
|
||
whId,
|
||
matId: r.material.id,
|
||
specs: r.specs || {},
|
||
unitId: r.unit?.id ?? null,
|
||
qty: 0,
|
||
updatedAt: r.updated_at,
|
||
recs: [],
|
||
brands: {},
|
||
brandOrder: [],
|
||
};
|
||
gmap.set(gk, g);
|
||
order.push(gk);
|
||
}
|
||
g.qty += r.quantity_number || 0;
|
||
g.recs.push(r);
|
||
if (r.brand) {
|
||
if (!(r.brand.id in g.brands)) {
|
||
g.brands[r.brand.id] = 0;
|
||
g.brandOrder.push(r.brand.id);
|
||
}
|
||
g.brands[r.brand.id] += r.quantity_number || 0;
|
||
}
|
||
});
|
||
|
||
// استخراج آخرین تاریخ تراکنش واقعی کاربر برای هر گروه و انتساب به updatedAt
|
||
order.forEach((k) => {
|
||
const g = gmap.get(k);
|
||
if (!g) return;
|
||
const groupAllTxs = transactions.filter((t) => g.recs.some((r) => r.id === t.inventory_id));
|
||
if (groupAllTxs.length > 0) {
|
||
const latestTx = groupAllTxs.reduce((latest, t) => {
|
||
return new Date(t.transaction_date).getTime() > new Date(latest.transaction_date).getTime() ? t : latest;
|
||
}, groupAllTxs[0]);
|
||
g.updatedAt = latestTx.transaction_date;
|
||
}
|
||
});
|
||
|
||
const out = order.map((k) => gmap.get(k)!);
|
||
if (sort === "asc") out.sort((a, b) => a.qty - b.qty);
|
||
else if (sort === "desc") out.sort((a, b) => b.qty - a.qty);
|
||
else out.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
||
return out;
|
||
}, [inventory, visibleIds, filters, sort, hasTxFilter, filteredTransactions, transactions]);
|
||
|
||
const groupTxs = (g: Group) =>
|
||
filteredTransactions
|
||
.filter((t) => g.recs.some((r) => r.id === t.inventory_id))
|
||
.slice()
|
||
.sort((a, b) => {
|
||
const da = new Date(a.transaction_date).getTime();
|
||
const db = new Date(b.transaction_date).getTime();
|
||
if (da !== db) return da - db;
|
||
const ca = a.created_at ? new Date(a.created_at).getTime() : 0;
|
||
const cb = b.created_at ? new Date(b.created_at).getTime() : 0;
|
||
return ca - cb;
|
||
});
|
||
|
||
const brandTxs = (g: Group, bid: string) =>
|
||
filteredTransactions
|
||
.filter((t) => t.brand?.id === bid && g.recs.some((r) => r.id === t.inventory_id))
|
||
.slice()
|
||
.sort((a, b) => {
|
||
const da = new Date(a.transaction_date).getTime();
|
||
const db = new Date(b.transaction_date).getTime();
|
||
if (da !== db) return da - db;
|
||
const ca = a.created_at ? new Date(a.created_at).getTime() : 0;
|
||
const cb = b.created_at ? new Date(b.created_at).getTime() : 0;
|
||
return ca - cb;
|
||
});
|
||
|
||
const hasFilter = !!(
|
||
filters.warehouse ||
|
||
(isAdmin && filters.project) ||
|
||
filters.material ||
|
||
filters.transactionType ||
|
||
filters.dateFrom ||
|
||
filters.dateTo
|
||
);
|
||
|
||
if (loading) return <div className="min-h-screen" />;
|
||
if (!user) {
|
||
return (
|
||
<div className="min-h-screen">
|
||
<Header title="داشبورد انبار" backTo="/" backLabel="بازگشت" />
|
||
<PageLayout>
|
||
<EmptyState emoji="🔒" text="لطفاً برای دسترسی به سامانه انبار وارد شوید." />
|
||
</PageLayout>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<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" />}
|
||
actions={
|
||
<div className="hidden md:flex items-center gap-2">
|
||
<InventoryTopNav pathname={location.pathname} />
|
||
</div>
|
||
}
|
||
/>
|
||
<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="جستجوی نام متریال…"
|
||
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 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" />
|
||
<span>فیلترها</span>
|
||
{hasFilter && <span className="h-1.5 w-1.5 rounded-full bg-accent" />}
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
className={cn(
|
||
"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" : ""))}
|
||
title="مرتبسازی تعداد"
|
||
>
|
||
<ArrowUpDown className="h-3.5 w-3.5" />
|
||
<span className="hidden sm:inline">
|
||
{sort === "asc" ? "تعداد: صعودی" : sort === "desc" ? "تعداد: نزولی" : "مرتبسازی"}
|
||
</span>
|
||
</button>
|
||
</div>
|
||
|
||
{/* فیلتر سریع انبارها (چیپهای افقی لمسی) */}
|
||
{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"
|
||
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>
|
||
{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>
|
||
)}
|
||
|
||
{/* فیلترهای پیشرفته — برچسبهای لمسی بدون سلکت و بدون بیرونزدگی */}
|
||
{showMobileFilters && (
|
||
<div className="mt-2.5 border-t border-line/60 pt-2.5 flex flex-col gap-2.5">
|
||
{/* پروژهها (مدیران و ادمین) */}
|
||
{isManager && 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>
|
||
)}
|
||
|
||
{/* نوع عملیات / بخش */}
|
||
<div>
|
||
<div className="mb-1.5 text-[11px] font-bold text-muted">نوع عملیات / بخش:</div>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{TX_TYPE_OPTIONS.map((opt) => (
|
||
<button
|
||
key={opt.type}
|
||
type="button"
|
||
onClick={() => setFilters((f) => ({ ...f, transactionType: f.transactionType === opt.type ? "" : opt.type }))}
|
||
className={cn(
|
||
"rounded-lg px-2.5 py-1 text-[11.5px] transition-colors",
|
||
filters.transactionType === opt.type
|
||
? "bg-accent text-white font-bold"
|
||
: "border border-line bg-bg/40 text-ink hover:bg-panel"
|
||
)}
|
||
>
|
||
{opt.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* فیلتر بازه تاریخ یا روز مشخص */}
|
||
<div>
|
||
<div className="mb-1.5 flex items-center justify-between">
|
||
<span className="text-[11px] font-bold text-muted">فیلتر بازه روزانه (بازه کامل ۰۰:۰۰ تا ۲۳:۵۹):</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const todayIso = new Date().toISOString().slice(0, 10);
|
||
setFilters((f) => ({ ...f, dateFrom: todayIso, dateTo: todayIso }));
|
||
}}
|
||
className="text-[11px] text-accent font-semibold hover:underline cursor-pointer"
|
||
>
|
||
امروز
|
||
</button>
|
||
</div>
|
||
<div className="grid gap-2 sm:grid-cols-2 max-w-md">
|
||
<div>
|
||
<label className="mb-0.5 block text-[10.5px] text-muted">از تاریخ (یا روز مشخص):</label>
|
||
<JalaliDatePicker
|
||
value={filters.dateFrom}
|
||
onChange={(val) => setFilters((f) => ({ ...f, dateFrom: val, dateTo: f.dateTo || val }))}
|
||
placeholder="از تاریخ…"
|
||
maxDate={new Date()}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="mb-0.5 block text-[10.5px] text-muted">تا تاریخ:</label>
|
||
<JalaliDatePicker
|
||
value={filters.dateTo}
|
||
onChange={(val) => setFilters((f) => ({ ...f, dateTo: val }))}
|
||
placeholder="تا تاریخ…"
|
||
maxDate={new Date()}
|
||
/>
|
||
</div>
|
||
</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 cursor-pointer"
|
||
onClick={() => setFilters({ warehouse: "", material: "", project: "", transactionType: "", dateFrom: null, dateTo: null })}
|
||
>
|
||
<X className="h-3 w-3" />
|
||
پاک کردن فیلترها
|
||
</button>
|
||
<span className="text-[11px] text-muted">
|
||
نمایش {faNum(groups.length)} قلم کالا
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* ۱. نمای مخصوص موبایل: کارتهای عمودی کاملاً ریسپانسیو */}
|
||
<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;
|
||
return (
|
||
<InventoryMobileCard
|
||
key={g.key}
|
||
g={g}
|
||
isExp={isExp}
|
||
isAdmin={isAdmin}
|
||
matName={materialName(lookup.materials, g.matId)}
|
||
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="هیچ رکورد موجودیای مطابق فیلترهای انتخابی یافت نشد." />
|
||
) : (
|
||
<table className="w-full min-w-[640px] text-[12.5px]">
|
||
<thead>
|
||
<tr className="border-b border-line bg-bg/40 text-right text-muted">
|
||
<th className="w-8 py-2.5" />
|
||
<th className="py-2.5 pe-3 font-semibold">متریال</th>
|
||
<th className="py-2.5 pe-3 font-semibold">دستهبندی</th>
|
||
<th className="py-2.5 pe-3 font-semibold">انبار</th>
|
||
<th className="py-2.5 pe-3 font-semibold">مشخصه فنی</th>
|
||
<th className="py-2.5 pe-3 font-semibold">تعداد</th>
|
||
<th className="py-2.5 pe-3 font-semibold">واحد</th>
|
||
<th className="py-2.5 pe-3 font-semibold">آخرین بهروزرسانی</th>
|
||
<th className="w-8 py-2.5" />
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{groups.map((g) => {
|
||
const isExp = expandedKey === g.key;
|
||
return (
|
||
<GroupRow
|
||
key={g.key}
|
||
g={g}
|
||
isExp={isExp}
|
||
isAdmin={isAdmin}
|
||
matName={materialName(lookup.materials, g.matId)}
|
||
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
|
||
}
|
||
/>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</div>
|
||
</PageLayout>
|
||
|
||
{/* دکمه شناور تغییر موجودی */}
|
||
{canModify && (
|
||
<Link
|
||
to="/inventory/change"
|
||
className="fixed bottom-20 left-5 z-40 flex h-14 w-14 items-center justify-center rounded-full bg-blue-600 text-white shadow-xl shadow-blue-600/40 transition-all duration-200 hover:bg-blue-700 hover:scale-105 active:scale-90 md:bottom-8 md:left-8"
|
||
title="تغییر موجودی"
|
||
aria-label="تغییر موجودی"
|
||
>
|
||
<Plus className="h-7 w-7" strokeWidth={2.5} />
|
||
</Link>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// کامپوننت کارت انبار در موبایل
|
||
function InventoryMobileCard({
|
||
g,
|
||
isExp,
|
||
isAdmin,
|
||
matName,
|
||
wh,
|
||
projName,
|
||
unit,
|
||
onToggle,
|
||
timeline,
|
||
}: {
|
||
g: Group;
|
||
isExp: boolean;
|
||
isAdmin: boolean;
|
||
matName: 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>
|
||
</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, wh, projName, unit, onToggle, timeline }: {
|
||
g: Group;
|
||
isExp: boolean;
|
||
isAdmin: boolean;
|
||
matName: string;
|
||
wh: string;
|
||
projName: string;
|
||
unit: string;
|
||
onToggle: () => void;
|
||
timeline: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<>
|
||
<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 pe-3 font-bold text-ink">{matName}</td>
|
||
<td className="py-2.5 pe-3">
|
||
<div>{wh}</div>
|
||
{isAdmin && <span className="block text-[10.5px] text-muted">{projName}</span>}
|
||
</td>
|
||
<td className="py-2.5 pe-3 text-muted">
|
||
{Object.entries(g.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}
|
||
</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 text-muted tabular-nums">{isoToJalaliStr(g.updatedAt)}</td>
|
||
<td className="py-2.5 text-muted">🕘</td>
|
||
</tr>
|
||
{isExp && (
|
||
<tr className="border-b border-line/40 bg-accent-bg/10">
|
||
<td colSpan={9} className="p-3.5">{timeline}</td>
|
||
</tr>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function Timeline({ g, txs, allTxs, brandTxs, selTx, setSelTx, expBrand, setExpBrand, matName, unit, brandNameFn, whNameFn }: {
|
||
g: Group;
|
||
txs: InventoryTransaction[];
|
||
allTxs: InventoryTransaction[];
|
||
brandTxs: (g: Group, bid: string) => InventoryTransaction[];
|
||
selTx: string | null;
|
||
setSelTx: (id: string | null) => void;
|
||
expBrand: string | null;
|
||
setExpBrand: (id: string | null) => void;
|
||
matName: string;
|
||
unit: string;
|
||
brandNameFn: (id: string | null | undefined) => string;
|
||
whNameFn: (id: string | null | undefined) => string;
|
||
}) {
|
||
const track = (list: InventoryTransaction[]) => {
|
||
let bal = 0;
|
||
return list.map((t) => {
|
||
const meta = TX_META[t.transaction_type] || { label: t.transaction_type, color: "#64748b", icon: "•" };
|
||
bal += t.quantity_number || 0;
|
||
const sel = selTx === t.id;
|
||
return (
|
||
<button
|
||
key={t.id}
|
||
type="button"
|
||
onClick={() => setSelTx(sel ? null : t.id)}
|
||
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}` }}
|
||
>
|
||
<span className="text-[11px] font-bold" style={{ color: meta.color }}>{meta.icon} {meta.label}</span>
|
||
<span className="text-[12px] font-bold tabular-nums" style={{ color: meta.color }}>
|
||
{faNum(Math.abs(t.quantity_number))} {unit}
|
||
</span>
|
||
<span className="text-[10.5px] text-muted">مانده: <b className="tabular-nums">{faNum(bal)}</b> {unit}</span>
|
||
</button>
|
||
);
|
||
});
|
||
};
|
||
|
||
if (!txs.length) return <div className="text-[12px] text-muted">تراکنشی ثبت نشده است.</div>;
|
||
|
||
const brandSec = expBrand ? (
|
||
<div className="mt-2">
|
||
<div className="mb-1 text-[12px] font-semibold">جریان برند: {brandNameFn(expBrand)} ({faNum(brandTxs(g, expBrand).length)} تراکنش)</div>
|
||
<div className="flex gap-1.5 overflow-x-auto pb-1 [scrollbar-width:thin]">{track(brandTxs(g, expBrand))}</div>
|
||
</div>
|
||
) : null;
|
||
|
||
const selDetail = selTx ? (() => {
|
||
const t = allTxs.find((x) => x.id === selTx);
|
||
if (!t) return null;
|
||
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 (
|
||
<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>
|
||
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-muted">
|
||
<span>تاریخ و ساعت: <b className="text-ink">{isoToJalaliStr(t.transaction_date)}</b></span>
|
||
{t.transaction_type === "TRANSFER_OUT" || t.transaction_type === "TRANSFER_IN" ? (
|
||
<>
|
||
<span>از انبار: <b className="text-ink">{t.transaction_type === "TRANSFER_OUT" ? 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 className="text-ink">{whNameFn(t.warehouse_id)}</b></span>
|
||
)}
|
||
{t.brand?.name && <span>برند: <b className="text-ink">{t.brand.name}</b></span>}
|
||
{t.performed_by && <span>انجامدهنده: <b className="text-ink">{t.performed_by}</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 && (
|
||
<span>
|
||
ثبتشده: {faNum(t.recorded_qty)} • شمارششده: {faNum(t.physical_qty)} • اختلاف: {faNum(t.physical_qty - t.recorded_qty)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})() : null;
|
||
|
||
return (
|
||
<div>
|
||
<div className="mb-1.5 flex flex-wrap items-center gap-2 text-[12px]">
|
||
<b>خط زمانی — {matName}</b>
|
||
{Object.entries(g.specs || {}).length > 0 && (
|
||
<span className="rounded bg-panel px-2 py-0.5 text-[10.5px] text-muted border border-line">
|
||
{Object.entries(g.specs).map(([k, v]) => `${k}: ${v}`).join(" · ")}
|
||
</span>
|
||
)}
|
||
<span className="rounded bg-panel px-2 py-0.5 text-[10.5px] text-muted border border-line">{faNum(g.brandOrder.length)} برند</span>
|
||
</div>
|
||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||
{g.brandOrder.map((bid) => (
|
||
<button
|
||
key={bid}
|
||
type="button"
|
||
onClick={() => setExpBrand(expBrand === bid ? null : bid)}
|
||
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)}: <b className="tabular-nums">{faNum(g.brands[bid])}</b> {unit}{expBrand === bid ? " ✕" : ""}
|
||
</button>
|
||
))}
|
||
</div>
|
||
{brandSec}
|
||
<div className="mb-1 mt-2 text-[12px] font-semibold">جریان کلی رکورد — ترکیب همه برندها ({faNum(txs.length)} تراکنش)</div>
|
||
<div className="flex gap-1.5 overflow-x-auto pb-1 [scrollbar-width:thin]">{track(txs)}</div>
|
||
{selDetail}
|
||
</div>
|
||
);
|
||
} |