@@ -1,9 +1,10 @@
import { useMemo , useState } from "react" ;
import { Link , useLocation , useSearchParams } from "react-router-dom" ;
import { Plus , Warehouse as WarehouseIcon } from "lucide-react" ;
import { Plus , Warehouse as WarehouseIcon , ArrowUpDown , ArrowUp , ArrowDown , X } from "lucide-react" ;
import { Header , PageLayout } from "../../components/layout/Header" ;
import { EmptyState } from "../../components/EmptyState" ;
import { Badge } from "../../components/ui/badge" ;
import { Button } from "../../components/ui/button" ;
import { useAuthStore } from "../../store/auth" ;
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu" ;
import { inventorySubheader } from "./panelMenu" ;
@@ -52,20 +53,21 @@ export function InventoryPage() {
const location = useLocation ( ) ;
const user = useAuthStore ( ( s ) = > s . user ) ;
const { warehouses , inventory , transactions , lookup , loading } = useInventoryData ( ) ;
const isAdmin = user ? . role === "admin" || user ? . role === "procurement_manager" ; // مدیر تدارکات = ادمین دفتر
const isAdmin = user ? . role === "admin" || user ? . role === "procurement_manager" ;
const isPM = user ? . role === "pm" ;
// پرش عمیق از «انبارها»: /inventory?warehouse=<id> → داشبورد همان انبار از پیش فیلتر شده
const [ params ] = useSearchParams ( ) ;
const [ filters , setFilters ] = useState < { category : string ; warehouse : string ; material : string ; project : string } > ( {
category : "" , warehouse : params.get ( "warehouse" ) || "" , material : "" , project : "" ,
category : "" ,
warehouse : params.get ( "warehouse" ) || "" ,
material : "" ,
project : "" ,
} ) ;
const [ sort , setSort ] = useState < "asc" | "desc" | "" > ( "" ) ;
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 ( isAdmin && filters . project ) return warehouses . filter ( ( w ) = > w . project_id === filters . project ) ;
if ( isAdmin ) return warehouses ;
@@ -86,7 +88,18 @@ export function InventoryPage() {
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 : [ ] } ;
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 ) ;
}
@@ -145,24 +158,23 @@ export function InventoryPage() {
< >
< InventoryTopNav pathname = { location . pathname } variant = { isAdmin ? "admin" : "pm" } / >
{ isPM && (
< Link
to = "/inventory/change"
className = "btn-line-file no-underline bg-accent text-white hover:bg-accent-dark"
>
< Plus className = "h-3.5 w-3.5" / >
ت غ ی ی ر م و ج و د ی
< / Link >
< Button asChild variant = "primary" >
< Link to = "/inventory/change" className = "no-underline" >
< Plus className = "h-4 w-4" / >
ت غ ی ی ر م و ج و د ی
< / Link >
< / Button >
) }
< / >
}
/ >
< PageLayout className = "flex flex-col gap-3 " >
{ /* فیلت رها */ }
< div className = "flex flex-wrap items-end gap-2 rounded-xl border border-line bg-panel p-3" >
< div className = "field " >
< label > د س ت ه ب ن د ی < / label >
< PageLayout className = "flex flex-col gap-4 " >
{ /* نوار فیلترها و ابزا رها */ }
< div className = "flex flex-wrap items-end gap-3 rounded-xl border border-line bg-panel p-3.5 shadow-sm " >
< div className = "flex flex-col gap-1.5 " >
< label className = "text-xs font-medium text-muted" > د س ت ه ب ن د ی < / label >
< select
className = "h-8 rounded-lg border border-line bg-panel px-2 text-[12.5px] "
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 "
value = { filters . category }
onChange = { ( e ) = > setFilters ( ( f ) = > ( { . . . f , category : e.target.value , material : "" } ) ) }
>
@@ -172,11 +184,12 @@ export function InventoryPage() {
) ) }
< / select >
< / div >
{ isAdmin && (
< div className = "field " >
< label > پ ر و ژ ه < / label >
< div className = "flex flex-col gap-1.5 " >
< label className = "text-xs font-medium text-muted" > پ ر و ژ ه < / label >
< select
className = "h-8 rounded-lg border border-line bg-panel px-2 text-[12.5px] "
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 "
value = { filters . project }
onChange = { ( e ) = > setFilters ( ( f ) = > ( { . . . f , project : e.target.value , warehouse : "" , material : "" } ) ) }
>
@@ -187,10 +200,11 @@ export function InventoryPage() {
< / select >
< / div >
) }
< div className = "field" >
< label > ا ن ب ا ر < / label >
< div className = "flex flex-col gap-1.5" >
< label className = "text-xs font-medium text-muted" > ا ن ب ا ر < / label >
< select
className = "h-8 rounded-lg border border-line bg-panel px-2 text-[12.5px] "
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 "
value = { filters . warehouse }
onChange = { ( e ) = > setFilters ( ( f ) = > ( { . . . f , warehouse : e.target.value , material : "" } ) ) }
>
@@ -202,52 +216,82 @@ export function InventoryPage() {
) ) }
< / select >
< / div >
< div className = "field" >
< label > م ت ر ی ا ل < / label >
< div className = "flex flex-col gap-1.5" >
< label className = "text-xs font-medium text-muted" > م ت ر ی ا ل < / label >
< input
className = "h-8 w-44 rounded-lg border border-line bg-panel px-2 text-[12.5px] "
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
className = "h-8 rounded-lg border border-line bg-panel px-3 text-[12px]"
< Button
variant = "line"
onClick = { ( ) = > setSort ( ( s ) = > ( s === "" ? "asc" : s === "asc" ? "desc" : "" ) ) }
className = "gap-1.5"
>
{ sort === "asc" ? "تعداد: صعودی ▲" : sort === "desc" ? "تعداد: نزولی ▼" : "مرتبسازی تعداد" }
< / button >
{ 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 && (
< button
className = "h-8 rounded-lg border border-red/30 bg-red/5 px-3 text-[12px] text-red "
< Button
variant = "danger "
onClick = { ( ) = > setFilters ( { category : "" , warehouse : "" , material : "" , project : "" } ) }
className = "gap-1"
>
✕ پ ا ک ک ر د ن ف ی ل ت ر ه ا
< / button >
< X className = "h-3.5 w-3.5" / >
پ ا ک ک ر د ن ف ی ل ت ر ه ا
< / Button >
) }
{ filters . warehouse && (
< Badge variant = "offered" > 🏬 { whName ( warehouses , filters . warehouse ) } < / Badge >
) }
< Badge variant = "outline" > { faNum ( groups . length ) } ق ل م ک ا ل ا < / Badge >
< div className = "ms-auto flex items-center gap-2 self-end" >
{ filters . warehouse && (
< Badge variant = "offered" className = "rounded-md px-2.5 py-1 text-xs" >
🏬 { 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 className = "overflow-hidden rounded-xl border border-line bg-panel" >
{ ! 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 = "هیچ رکورد موجودیای مطابق فیلترهای انتخابی یافت نشد." / >
) : (
< / div >
) : (
< div className = "overflow-hidden rounded-xl border border-line bg-panel shadow-sm" >
< table className = "w-full text-[12.5px]" >
< thead >
< tr className = "border-b border-line bg-panel text-right text-muted" >
< th className = "w-8 py-2" / >
< th className = "py-2 pe-3" > م ت ر ی ا ل < / th >
< th className = "py-2 pe-3" > د س ت ه ب ن د ی < / th >
< th className = "py-2 pe-3" > ا ن ب ا ر < / th >
< th className = "py-2 pe-3" > م ش خ ص ه ف ن ی < / th >
< th className = "py-2 pe-3" > ت ع د ا د < / th >
< th className = "py-2 pe-3" > و ا ح د < / th >
< th className = "py-2 pe-3" > آ خ ر ی ن ب ه ر و ز ر س ا ن ی < / th >
< th className = "w-8 py-2" / >
< tr className = "border-b border-line bg-slate-bg/50 text-right text-muted font-medium " >
< th className = "w-8 py-2.5 " / >
< th className = "py-2.5 pe-3" > م ت ر ی ا ل < / th >
< th className = "py-2.5 pe-3" > د س ت ه ب ن د ی < / th >
< th className = "py-2.5 pe-3" > ا ن ب ا ر < / th >
< th className = "py-2.5 pe-3" > م ش خ ص ه ف ن ی < / th >
< th className = "py-2.5 pe-3" > ت ع د ا د < / th >
< th className = "py-2.5 pe-3" > و ا ح د < / th >
< th className = "py-2.5 pe-3" > آ خ ر ی ن ب ه ر و ز ر س ا ن ی < / th >
< th className = "w-8 py-2.5 " / >
< / tr >
< / thead >
< tbody >
@@ -293,8 +337,9 @@ export function InventoryPage() {
} ) }
< / tbody >
< / table >
) }
< / div >
< / div >
) }
< div className = "text-[11px] text-muted" >
۱ ۰ ن و ع ت ر ا ک ن ش ا س ت ا ن د ا ر د • س ب ز ز م ر د ی = و ر و د ب ه ا ن ب ا ر · ر ز = خ ر و ج / ک ا ه ش · آ ب ی = ا ن ت ق ا ل و ش م ا ر ش • ک ل ی ک ر و ی ه ر سطر : خط ز م ا ن ی ب ا « م ا ن د ه » ل ح ظ ه ب ه ل ح ظ ه و ت ف ک ی ک ب ر ن د ه ا
< / div >
@@ -317,25 +362,25 @@ function GroupRow({ g, isExp, isAdmin, matName, matCatName, wh, projName, unit,
} ) {
return (
< >
< tr className = { ` border-b border-line/60 cursor-pointer ${ isExp ? "bg-accent/5" : "hover:bg-accent/5 " } ` } onClick = { onToggle } >
< td className = "py-2 text-center text-muted" > { isExp ? "▾" : "▸" } < / td >
< td className = "py-2 pe-3 font-semibold" > { matName } < / td >
< td className = "py-2 pe-3" > { matCatName } < / td >
< td className = "py-2 pe-3" >
< tr className = { ` border-b border-line/60 cursor-pointer transition-colors duration-150 ${ isExp ? "bg-accent/5" : "hover:bg-slate-bg/60 " } ` } onClick = { onToggle } >
< 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" > { matCatName } < / td >
< td className = "py-2.5 pe-3" >
{ wh }
{ isAdmin && < small className = "block text-muted" > { projName } < / small > }
< / td >
< td className = "py-2 pe-3 text-muted" >
< td className = "py-2.5 pe-3 text-muted" >
{ Object . entries ( g . specs || { } ) . map ( ( [ k , v ] ) = > ` ${ k } : ${ v } ` ) . join ( " · " ) || "—" }
< / td >
< td className = "py-2 pe-3 font-bold" > { faNum ( g . qty ) } < / td >
< td className = "py-2 pe-3" > { unit } < / td >
< td className = "py-2 pe-3 text-muted" > { isoToJalaliStr ( g . updatedAt ) } < / td >
< td className = "py-2 text-muted" > 🕘 < / td >
< td className = "py-2.5 pe-3 font-bold text-ink " > { faNum ( g . qty ) } < / 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 text-muted" > 🕘 < / td >
< / tr >
{ isExp && (
< tr className = "border-b border-line/40 bg-accent-bg/2 0" >
< td colSpan = { 9 } className = "p-3" > { timeline } < / td >
< tr className = "border-b border-line/40 bg-accent-bg/1 0" >
< td colSpan = { 9 } className = "p-3.5 " > { timeline } < / td >
< / tr >
) }
< / >
@@ -367,7 +412,9 @@ function Timeline({ g, txs, allTxs, brandTxs, selTx, setSelTx, expBrand, setExpB
key = { t . id }
type = "button"
onClick = { ( ) = > setSelTx ( sel ? null : t . id ) }
className = { ` flex min-w-[120 px] flex-col gap-0.5 rounded-lg border p-2 text-start ${ sel ? "border-accent bg-accent/10" : "border-line bg-panel" } ` }
className = { ` flex min-w-[125 px] flex-col gap-1 rounded-md border p-2.5 text-start transition-all duration-150 active:scale-[0.98] ${
sel ? "border-accent bg-accent/10 shadow-xs" : "border-line bg-panel hover:border-slate-300"
} ` }
style = { { borderTop : ` 3px solid ${ meta . color } ` } }
>
< span className = "text-[11px] font-bold" style = { { color : meta.color } } > { meta . icon } { meta . label } < / span >
@@ -383,9 +430,9 @@ function Timeline({ g, txs, allTxs, brandTxs, selTx, setSelTx, expBrand, setExpB
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" > { track ( brandTxs ( g , expBrand ) ) } < / div >
< div className = "mt-2.5 " >
< div className = "mb-1.5 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 >
) : null ;
@@ -395,7 +442,7 @@ function Timeline({ g, txs, allTxs, brandTxs, selTx, setSelTx, expBrand, setExpB
const meta = TX_META [ t . transaction_type ] || { label : t.transaction_type , color : "#64748b" , icon : "•" } ;
const pairTx = t . transfer_pair_id ? allTxs . find ( ( x ) = > x . transfer_pair_id === t . transfer_pair_id && x . id !== t . id ) : null ;
return (
< div className = "mt-2 rounded-lg border p-2 text-[12px]" style = { { borderTop : ` 3px solid ${ meta . color } ` } } >
< 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 } ` } } >
< 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 > { isoToJalaliStr ( t . transaction_date ) } < / b > < / span >
@@ -422,14 +469,14 @@ function Timeline({ g, txs, allTxs, brandTxs, selTx, setSelTx, expBrand, setExpB
return (
< div >
< div className = "mb-1 flex flex-wrap items-center gap-2 text-[12px]" >
< 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-1.5 py-0.5 text-[10.5px] text-muted" >
< 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-1.5 py-0.5 text-[10.5px] text-muted" > { faNum ( g . brandOrder . length ) } ب ر ن د < / 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 ) = > (
@@ -437,16 +484,18 @@ function Timeline({ g, txs, allTxs, brandTxs, selTx, setSelTx, expBrand, setExpB
key = { bid }
type = "button"
onClick = { ( ) = > setExpBrand ( expBrand === bid ? null : bid ) }
className = { ` rounded-lg border px-2 py-1 text-[11.5px] ${ expBrand === bid ? "border-accent bg-accent/10 text-accent" : "border-line bg-panel" } ` }
className = { ` rounded-md border px-2.5 py-1 text-[11.5px] transition-all duration-150 active:scale-[0.98] ${
expBrand === bid ? "border-accent bg-accent/10 font-semibold text-accent" : "border-line bg-panel hover:bg-slate-bg"
} ` }
>
{ brandNameFn ( bid ) } : < b > { 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" > { track ( txs ) } < / div >
< div className = "mb-1.5 mt-2.5 text-[12px] font-semibold" > ج ر ی ا ن ک ل ی ر ک و ر د — ت ر ک ی ب ه م ه ب ر ن د ه ا ( { faNum ( txs . length ) } ت ر ا ک ن ش ) < / div >
< div className = "flex gap-2 overflow-x-auto pb-1" > { track ( txs ) } < / div >
{ selDetail }
< / div >
) ;
}
}