2026-02-13 13:16:05 +08:00
2026-01-28 18:04:45 +08:00
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout' ;
2026-02-13 13:16:05 +08:00
import { Head , Link , router , useForm } from '@inertiajs/react' ;
2026-01-28 18:04:45 +08:00
import { useState , useCallback , useEffect } from 'react' ;
2026-02-03 17:24:34 +08:00
import { usePermission } from '@/hooks/usePermission' ;
2026-02-13 13:16:05 +08:00
import { StatusBadge } from "@/Components/shared/StatusBadge" ;
2026-01-28 18:04:45 +08:00
import { debounce } from "lodash" ;
import { SearchableSelect } from "@/Components/ui/searchable-select" ;
import {
Table ,
TableBody ,
TableCell ,
TableHead ,
TableHeader ,
TableRow ,
} from '@/Components/ui/table' ;
import { Button } from '@/Components/ui/button' ;
import { Input } from '@/Components/ui/input' ;
import {
Dialog ,
DialogContent ,
DialogDescription ,
DialogFooter ,
DialogHeader ,
DialogTitle ,
DialogTrigger ,
} from "@/Components/ui/dialog" ;
import { Label } from '@/Components/ui/label' ;
import {
Plus ,
Search ,
X ,
ClipboardCheck ,
Eye ,
2026-01-29 13:12:02 +08:00
Pencil ,
Trash2
2026-01-28 18:04:45 +08:00
} from 'lucide-react' ;
2026-01-29 13:12:02 +08:00
import {
AlertDialog ,
AlertDialogAction ,
AlertDialogCancel ,
AlertDialogContent ,
AlertDialogDescription ,
AlertDialogFooter ,
AlertDialogHeader ,
AlertDialogTitle ,
} from "@/Components/ui/alert-dialog" ;
2026-01-28 18:04:45 +08:00
import Pagination from '@/Components/shared/Pagination' ;
import { Can } from '@/Components/Permission/Can' ;
2026-02-04 15:12:10 +08:00
export default function Index ( { docs , warehouses , filters } : any ) {
2026-01-28 18:04:45 +08:00
const [ isCreateDialogOpen , setIsCreateDialogOpen ] = useState ( false ) ;
2026-01-29 13:12:02 +08:00
const [ deleteId , setDeleteId ] = useState < string | null > ( null ) ;
const { data , setData , post , processing , reset , errors , delete : destroy } = useForm ( {
2026-01-28 18:04:45 +08:00
warehouse_id : '' ,
remarks : '' ,
} ) ;
2026-02-03 17:24:34 +08:00
const { can } = usePermission ( ) ;
2026-01-28 18:04:45 +08:00
const [ searchTerm , setSearchTerm ] = useState ( filters . search || "" ) ;
const [ warehouseFilter , setWarehouseFilter ] = useState ( filters . warehouse_id || "all" ) ;
const [ perPage , setPerPage ] = useState ( filters . per_page || "10" ) ;
// Sync state with props
useEffect ( ( ) = > {
setSearchTerm ( filters . search || "" ) ;
setWarehouseFilter ( filters . warehouse_id || "all" ) ;
setPerPage ( filters . per_page || "10" ) ;
} , [ filters ] ) ;
// Debounced Search Handler
const debouncedSearch = useCallback (
debounce ( ( term : string , warehouse : string ) = > {
router . get (
route ( 'inventory.count.index' ) ,
{ . . . filters , search : term , warehouse_id : warehouse === "all" ? "" : warehouse } ,
{ preserveState : true , replace : true , preserveScroll : true }
) ;
} , 500 ) ,
[ filters ]
) ;
const handleSearchChange = ( term : string ) = > {
setSearchTerm ( term ) ;
debouncedSearch ( term , warehouseFilter ) ;
} ;
const handleFilterChange = ( value : string ) = > {
setWarehouseFilter ( value ) ;
router . get (
route ( 'inventory.count.index' ) ,
{ . . . filters , warehouse_id : value === "all" ? "" : value } ,
{ preserveState : false , replace : true , preserveScroll : true }
) ;
} ;
const handleClearSearch = ( ) = > {
setSearchTerm ( "" ) ;
router . get (
route ( 'inventory.count.index' ) ,
{ . . . filters , search : "" , warehouse_id : warehouseFilter === "all" ? "" : warehouseFilter } ,
{ preserveState : true , replace : true , preserveScroll : true }
) ;
} ;
const handlePerPageChange = ( value : string ) = > {
setPerPage ( value ) ;
router . get (
route ( 'inventory.count.index' ) ,
{ . . . filters , per_page : value } ,
{ preserveState : false , replace : true , preserveScroll : true }
) ;
} ;
2026-02-04 15:12:10 +08:00
const handleCreate = ( e : React.FormEvent ) = > {
2026-01-28 18:04:45 +08:00
e . preventDefault ( ) ;
post ( route ( 'inventory.count.store' ) , {
onSuccess : ( ) = > {
setIsCreateDialogOpen ( false ) ;
reset ( ) ;
} ,
} ) ;
} ;
2026-01-29 13:12:02 +08:00
const confirmDelete = ( id : string ) = > {
setDeleteId ( id ) ;
} ;
const handleDelete = ( ) = > {
if ( deleteId ) {
destroy ( route ( 'inventory.count.destroy' , [ deleteId ] ) , {
onSuccess : ( ) = > setDeleteId ( null ) ,
onError : ( ) = > setDeleteId ( null ) ,
} ) ;
}
} ;
2026-02-04 15:12:10 +08:00
const getStatusBadge = ( status : string ) = > {
2026-01-28 18:04:45 +08:00
switch ( status ) {
case 'draft' :
2026-02-13 13:16:05 +08:00
return < StatusBadge variant = "neutral" > 草 稿 < / StatusBadge > ;
2026-01-28 18:04:45 +08:00
case 'counting' :
2026-02-13 13:16:05 +08:00
return < StatusBadge variant = "info" > 盤 點 中 < / StatusBadge > ;
2026-01-28 18:04:45 +08:00
case 'completed' :
2026-02-13 13:16:05 +08:00
return < StatusBadge variant = "success" > 盤 點 完 成 < / StatusBadge > ;
2026-02-04 16:56:08 +08:00
case 'no_adjust' :
2026-02-13 13:16:05 +08:00
return < StatusBadge variant = "success" > 盤 點 完 成 ( 無 需 盤 調 ) < / StatusBadge > ;
2026-01-29 13:41:31 +08:00
case 'adjusted' :
2026-02-13 13:16:05 +08:00
return < StatusBadge variant = "info" > 已 盤 調 庫 存 < / StatusBadge > ; // Decided on info/blue for adjusted to match "active/done" but distinctive from pure success if needed, or stick to success? Plan said Info/Blue.
2026-01-28 18:04:45 +08:00
case 'cancelled' :
2026-02-13 13:16:05 +08:00
return < StatusBadge variant = "destructive" > 已 取 消 < / StatusBadge > ;
2026-01-28 18:04:45 +08:00
default :
2026-02-13 13:16:05 +08:00
return < StatusBadge variant = "neutral" > { status } < / StatusBadge > ;
2026-01-28 18:04:45 +08:00
}
} ;
return (
< AuthenticatedLayout
breadcrumbs = { [
2026-02-05 09:33:36 +08:00
{ label : '商品與庫存管理' , href : '#' } ,
2026-01-28 18:04:45 +08:00
{ label : '庫存盤點' , href : route ( 'inventory.count.index' ) , isPage : true } ,
] }
>
< Head title = "庫存盤點" / >
< div className = "container mx-auto p-6 max-w-7xl" >
< div className = "flex items-center justify-between mb-6" >
< div >
< h1 className = "text-2xl font-bold text-grey-0 flex items-center gap-2" >
< ClipboardCheck className = "h-6 w-6 text-primary-main" / >
庫 存 盤 點 管 理
< / h1 >
< p className = "text-gray-500 mt-1" >
建 立 與 管 理 倉 庫 盤 點 單 , 執 行 定 期 庫 存 核 對 。
< / p >
< / div >
< / div >
{ /* Toolbar */ }
< div className = "bg-white rounded-lg shadow-sm border p-4 mb-6" >
< div className = "flex flex-col md:flex-row gap-4" >
{ /* Search */ }
< div className = "flex-1 relative" >
< Search className = "absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" / >
< Input
placeholder = "搜尋盤點單號、備註..."
value = { searchTerm }
onChange = { ( e ) = > handleSearchChange ( e . target . value ) }
className = "pl-10 pr-10 h-9"
/ >
{ searchTerm && (
< button
onClick = { handleClearSearch }
className = "absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
< X className = "h-4 w-4" / >
< / button >
) }
< / div >
{ /* Warehouse Filter */ }
< SearchableSelect
value = { warehouseFilter }
onValueChange = { handleFilterChange }
options = { [
{ label : "所有倉庫" , value : "all" } ,
. . . warehouses . map ( ( w : any ) = > ( { label : w.name , value : w.id.toString ( ) } ) )
] }
placeholder = "選擇倉庫"
className = "w-full md:w-[200px] h-9"
/ >
{ /* Action Buttons */ }
< div className = "flex gap-2 w-full md:w-auto" >
2026-02-03 17:24:34 +08:00
< Can permission = "inventory_count.create" >
2026-01-28 18:04:45 +08:00
< Dialog open = { isCreateDialogOpen } onOpenChange = { setIsCreateDialogOpen } >
< DialogTrigger asChild >
< Button className = "flex-1 md:flex-none button-filled-primary" >
< Plus className = "w-4 h-4 mr-2" / >
新 增 盤 點 單
< / Button >
< / DialogTrigger >
< DialogContent className = "sm:max-w-[425px]" >
< form onSubmit = { handleCreate } >
< DialogHeader >
< DialogTitle > 建 立 新 盤 點 單 < / DialogTitle >
< DialogDescription >
建 立 後 將 自 動 對 該 倉 庫 庫 存 進 行 快 照 , 請 確 認 倉 庫 作 業 已 暫 停 。
< / DialogDescription >
< / DialogHeader >
< div className = "grid gap-4 py-4" >
< div className = "space-y-2" >
< Label htmlFor = "warehouse" > 選 擇 倉 庫 < / Label >
< SearchableSelect
value = { data . warehouse_id }
onValueChange = { ( val ) = > setData ( 'warehouse_id' , val ) }
options = { warehouses . map ( ( w : any ) = > ( { label : w.name , value : w.id.toString ( ) } ) ) }
placeholder = "請選擇倉庫"
className = "h-9"
/ >
{ errors . warehouse_id && < p className = "text-red-500 text-sm" > { errors . warehouse_id } < / p > }
< / div >
< div className = "space-y-2" >
< Label htmlFor = "remarks" > 備 註 < / Label >
< Input
id = "remarks"
className = "h-9"
value = { data . remarks }
onChange = { ( e ) = > setData ( 'remarks' , e . target . value ) }
/ >
< / div >
< / div >
< DialogFooter >
2026-01-29 13:04:54 +08:00
< Button type = "button" variant = "outline" className = "button-outlined-primary" onClick = { ( ) = > setIsCreateDialogOpen ( false ) } >
2026-01-28 18:04:45 +08:00
取 消
< / Button >
< Button type = "submit" className = "button-filled-primary" disabled = { processing || ! data . warehouse_id } >
2026-01-29 13:04:54 +08:00
新 增
2026-01-28 18:04:45 +08:00
< / Button >
< / DialogFooter >
< / form >
< / DialogContent >
< / Dialog >
< / Can >
< / div >
< / div >
< / div >
< div className = "bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden" >
< Table >
< TableHeader className = "bg-gray-50" >
< TableRow >
< TableHead className = "w-[50px] text-center" > # < / TableHead >
< TableHead > 單 號 < / TableHead >
< TableHead > 倉 庫 < / TableHead >
< TableHead > 快 照 時 間 < / TableHead >
2026-01-29 13:04:54 +08:00
< TableHead > 盤 點 進 度 < / TableHead >
2026-01-28 18:04:45 +08:00
< TableHead > 完 成 時 間 < / TableHead >
< TableHead > 建 立 人 員 < / TableHead >
2026-02-13 13:16:05 +08:00
< TableHead > 狀 態 < / TableHead >
2026-01-28 18:04:45 +08:00
< TableHead className = "text-center" > 操 作 < / TableHead >
< / TableRow >
< / TableHeader >
< TableBody >
{ docs . data . length === 0 ? (
< TableRow >
2026-01-29 13:04:54 +08:00
< TableCell colSpan = { 9 } className = "text-center h-24 text-gray-500" >
2026-01-28 18:04:45 +08:00
尚 無 盤 點 紀 錄
< / TableCell >
< / TableRow >
) : (
2026-02-04 15:12:10 +08:00
docs . data . map ( ( doc : any , index : number ) = > (
2026-01-28 18:04:45 +08:00
< TableRow key = { doc . id } >
< TableCell className = "text-gray-500 font-medium text-center" >
{ ( docs . current_page - 1 ) * docs . per_page + index + 1 }
< / TableCell >
< TableCell className = "font-medium text-primary-main" > { doc . doc_no } < / TableCell >
< TableCell > { doc . warehouse_name } < / TableCell >
< TableCell className = "text-gray-500 text-sm" > { doc . snapshot_date } < / TableCell >
2026-01-29 13:04:54 +08:00
< TableCell >
< span className = "font-medium text-gray-700" > { doc . counted_items } < / span >
< span className = "text-gray-400 mx-1" > / < / span >
< span className = "text-gray-500" > { doc . total_items } < / span >
< / TableCell >
2026-01-28 18:04:45 +08:00
< TableCell className = "text-gray-500 text-sm" > { doc . completed_at || '-' } < / TableCell >
< TableCell className = "text-sm" > { doc . created_by } < / TableCell >
2026-02-13 13:16:05 +08:00
< TableCell > { getStatusBadge ( doc . status ) } < / TableCell >
2026-01-28 18:04:45 +08:00
< TableCell className = "text-center" >
< div className = "flex items-center justify-center gap-2" >
2026-02-03 17:24:34 +08:00
{ /* Action Button Logic: Prefer Edit if allowed and status is active, otherwise fallback to View if allowed */ }
{ ( ( ) = > {
2026-02-04 16:56:08 +08:00
const isEditable = ! [ 'completed' , 'no_adjust' , 'adjusted' ] . includes ( doc . status ) ;
2026-02-03 17:24:34 +08:00
const canEdit = can ( 'inventory_count.edit' ) ;
const canView = can ( 'inventory_count.view' ) ;
if ( isEditable && canEdit ) {
return (
< Link href = { route ( 'inventory.count.show' , [ doc . id ] ) } >
< Button
variant = "outline"
size = "sm"
className = "button-outlined-primary"
title = "盤點"
>
< Pencil className = "w-4 h-4 ml-0.5" / >
< / Button >
< / Link >
) ;
}
if ( canView ) {
return (
< Link href = { route ( 'inventory.count.show' , [ doc . id ] ) } >
< Button
variant = "outline"
size = "sm"
className = "button-outlined-primary"
title = "查閱"
>
< Eye className = "w-4 h-4 ml-0.5" / >
< / Button >
< / Link >
) ;
}
return null ;
} ) ( ) }
2026-02-04 16:56:08 +08:00
{ ! [ 'completed' , 'no_adjust' , 'adjusted' ] . includes ( doc . status ) && (
2026-02-03 17:24:34 +08:00
< Can permission = "inventory_count.delete" >
2026-01-29 13:12:02 +08:00
< Button
variant = "outline"
size = "sm"
className = "button-outlined-error"
title = "作廢"
onClick = { ( ) = > confirmDelete ( doc . id ) }
>
< Trash2 className = "w-4 h-4 ml-0.5" / >
< / Button >
2026-02-03 17:24:34 +08:00
< / Can >
) }
2026-01-28 18:04:45 +08:00
< / div >
< / TableCell >
< / TableRow >
) )
) }
< / TableBody >
< / Table >
< / div >
< div className = "mt-4 flex flex-col sm:flex-row items-center justify-between gap-4" >
< div className = "flex items-center gap-4" >
< div className = "flex items-center gap-2 text-sm text-gray-500" >
< span > 每 頁 顯 示 < / span >
< SearchableSelect
value = { perPage }
onValueChange = { handlePerPageChange }
options = { [
{ label : "10" , value : "10" } ,
{ label : "20" , value : "20" } ,
{ label : "50" , value : "50" } ,
{ label : "100" , value : "100" }
] }
2026-02-03 17:24:34 +08:00
className = "w-[90px] h-8"
2026-01-28 18:04:45 +08:00
showSearch = { false }
/ >
< span > 筆 < / span >
< / div >
< span className = "text-sm text-gray-500" > 共 { docs . total } 筆 紀 錄 < / span >
< / div >
< Pagination links = { docs . links } / >
< / div >
2026-01-29 13:12:02 +08:00
< AlertDialog open = { ! ! deleteId } onOpenChange = { ( open ) = > ! open && setDeleteId ( null ) } >
< AlertDialogContent >
< AlertDialogHeader >
< AlertDialogTitle > 確 定 要 作 廢 此 盤 點 單 嗎 ? < / AlertDialogTitle >
< AlertDialogDescription >
此 動 作 無 法 復 原 。 作 廢 後 請 重 新 建 立 盤 點 單 。
< / AlertDialogDescription >
< / AlertDialogHeader >
< AlertDialogFooter >
< AlertDialogCancel > 取 消 < / AlertDialogCancel >
< AlertDialogAction onClick = { handleDelete } className = "bg-red-600 hover:bg-red-700" > 確 認 作 廢 < / AlertDialogAction >
< / AlertDialogFooter >
< / AlertDialogContent >
< / AlertDialog >
2026-01-28 18:04:45 +08:00
< / div >
< / AuthenticatedLayout >
) ;
}