Files
star-erp/resources/js/Pages/Inventory/Adjust/Index.tsx

502 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, useForm, router, Link } from '@inertiajs/react';
import { usePermission } from '@/hooks/usePermission';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/Components/ui/table";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Badge } from "@/Components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/Components/ui/dialog";
import { Label } from "@/Components/ui/label";
import { Plus, Search, X, Eye, Pencil, ClipboardCheck, Trash2 } from "lucide-react";
import { useState, useCallback, useEffect } from 'react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/Components/ui/alert-dialog";
import Pagination from '@/Components/shared/Pagination';
import { SearchableSelect } from '@/Components/ui/searchable-select';
import { Can } from '@/Components/Permission/Can';
import debounce from 'lodash/debounce';
import axios from 'axios';
interface Doc {
id: string;
doc_no: string;
warehouse_name: string;
reason: string;
status: string;
created_by: string;
created_at: string;
posted_at: string;
}
interface Warehouse {
id: string;
name: string;
}
interface Filters {
search?: string;
warehouse_id?: string;
per_page?: string;
}
interface DocsPagination {
data: Doc[];
current_page: number;
per_page: number;
total: number;
links: any[]; // Adjust type as needed for pagination links
}
export default function Index({ docs, warehouses, filters }: { docs: DocsPagination, warehouses: Warehouse[], filters: Filters }) {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState(filters.search || '');
const [warehouseId, setWarehouseId] = useState(filters.warehouse_id || '');
const [perPage, setPerPage] = useState(filters.per_page || '10');
const [deleteId, setDeleteId] = useState<string | null>(null);
// For Count Doc Selection
const [pendingCounts, setPendingCounts] = useState<any[]>([]);
const [loadingPending, setLoadingPending] = useState(false);
const [scanSearch, setScanSearch] = useState('');
const fetchPendingCounts = useCallback(
debounce((search = '') => {
setLoadingPending(true);
axios.get(route('inventory.adjust.pending-counts'), { params: { search } })
.then(res => setPendingCounts(res.data))
.finally(() => setLoadingPending(false));
}, 300),
[]
);
useEffect(() => {
if (isDialogOpen) {
fetchPendingCounts();
}
}, [isDialogOpen, fetchPendingCounts]);
const debouncedFilter = useCallback(
debounce((params: Filters) => {
router.get(route('inventory.adjust.index'), params as any, {
preserveState: true,
replace: true,
});
}, 300),
[]
);
const handleSearchChange = (val: string) => {
setSearchQuery(val);
debouncedFilter({ search: val, warehouse_id: warehouseId, per_page: perPage });
};
const handleWarehouseChange = (val: string) => {
setWarehouseId(val);
debouncedFilter({ search: searchQuery, warehouse_id: val, per_page: perPage });
};
const handlePerPageChange = (value: string) => {
setPerPage(value);
router.get(route('inventory.adjust.index'),
{ ...filters, search: searchQuery, warehouse_id: warehouseId, per_page: value, page: 1 },
{ preserveState: false, replace: true, preserveScroll: true }
);
};
const confirmDelete = (id: string, e: React.MouseEvent) => {
e.stopPropagation();
setDeleteId(id);
};
const handleDelete = () => {
if (deleteId) {
router.delete(route('inventory.adjust.destroy', [deleteId]), {
onSuccess: () => setDeleteId(null),
onError: () => setDeleteId(null),
});
}
};
const { can } = usePermission();
const { data, setData, post, processing, reset } = useForm({
count_doc_id: null as string | null,
warehouse_id: '',
reason: '手動調整庫存',
remarks: '',
});
const handleCreate = (countDocId?: string) => {
if (countDocId) {
setData('count_doc_id', countDocId);
router.post(route('inventory.adjust.store'), { count_doc_id: countDocId }, {
onSuccess: () => setIsDialogOpen(false),
});
return;
}
post(route('inventory.adjust.store'), {
onSuccess: () => {
setIsDialogOpen(false);
reset();
},
});
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'draft':
return <Badge variant="secondary" className="bg-gray-100 text-gray-600 border-none">稿</Badge>;
case 'posted':
return <Badge className="bg-green-100 text-green-700 border-none"></Badge>;
case 'voided':
return <Badge variant="destructive" className="bg-red-100 text-red-700 border-none"></Badge>;
default:
return <Badge variant="outline">{status}</Badge>;
}
};
return (
<AuthenticatedLayout
breadcrumbs={[
{ label: '商品與庫存管理', href: '#' },
{ label: '庫存盤調', href: route('inventory.adjust.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 Context */}
<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 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder="搜尋單號、原因或備註..."
value={searchQuery}
onChange={(e) => handleSearchChange(e.target.value)}
className="pl-10 pr-10 h-9"
/>
{searchQuery && (
<button
onClick={() => handleSearchChange('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{/* Warehouse Filter */}
<SearchableSelect
options={[
{ value: '', label: '所有倉庫' },
...warehouses.map(w => ({ value: w.id, label: w.name }))
]}
value={warehouseId}
onValueChange={handleWarehouseChange}
placeholder="選擇倉庫"
className="w-full md:w-[200px] h-9"
/>
{/* Action Buttons */}
<div className="flex gap-2 w-full md:w-auto">
<Can permission="inventory_adjust.create">
<Button
className="flex-1 md:flex-none button-filled-primary h-9"
onClick={() => setIsDialogOpen(true)}
>
<Plus className="mr-2 h-4 w-4" />
調
</Button>
</Can>
</div>
</div>
</div>
{/* Table Container */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="w-[60px] text-center font-medium text-grey-600">#</TableHead>
<TableHead className="w-[180px] font-medium text-grey-600"></TableHead>
<TableHead className="font-medium text-grey-600"></TableHead>
<TableHead className="font-medium text-grey-600">調</TableHead>
<TableHead className="font-medium text-grey-600 text-center"></TableHead>
<TableHead className="font-medium text-grey-600"></TableHead>
<TableHead className="font-medium text-grey-600"></TableHead>
<TableHead className="font-medium text-grey-600"></TableHead>
<TableHead className="text-center font-medium text-grey-600"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{docs.data.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="h-32 text-center text-grey-400">
調
</TableCell>
</TableRow>
) : (
docs.data.map((doc: Doc, index: number) => (
<TableRow
key={doc.id}
className="hover:bg-gray-50/50 transition-colors cursor-pointer group"
onClick={() => router.visit(route('inventory.adjust.show', [doc.id]))}
>
<TableCell className="text-center text-gray-500 font-medium">
{(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 max-w-[200px] truncate">{doc.reason}</TableCell>
<TableCell className="text-center">{getStatusBadge(doc.status)}</TableCell>
<TableCell className="text-sm">{doc.created_by}</TableCell>
<TableCell className="text-gray-500 text-sm">{doc.created_at}</TableCell>
<TableCell className="text-gray-500 text-sm">{doc.posted_at || '-'}</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-2" onClick={(e) => e.stopPropagation()}>
{(() => {
const isDraft = doc.status === 'draft';
const canEdit = can('inventory_adjust.edit');
const canView = can('inventory_adjust.view');
if (isDraft && canEdit) {
return (
<Link href={route('inventory.adjust.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.adjust.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;
})()}
{doc.status === 'draft' && (
<Can permission="inventory_adjust.delete">
<Button
variant="outline"
size="sm"
className="button-outlined-error"
title="刪除"
onClick={(e) => confirmDelete(doc.id, e)}
>
<Trash2 className="w-4 h-4 ml-0.5" />
</Button>
</Can>
)}
</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" }
]}
className="w-[90px] h-8"
showSearch={false}
/>
<span></span>
</div>
<span className="text-sm text-gray-500"> {docs?.total || 0} </span>
</div>
<Pagination links={docs.links} />
</div>
<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>
</div>
{/* Create Dialog */}
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="text-xl font-bold flex items-center gap-2">
<Plus className="h-5 w-5 text-primary-main" />
調
</DialogTitle>
<DialogDescription>
調
</DialogDescription>
</DialogHeader>
<div className="py-4 space-y-6">
{/* Option 1: Scan/Select from Count Docs */}
<div className="space-y-4 p-4 rounded-xl bg-primary-lightest/50 border border-primary-light/20 shadow-sm">
<Label className="text-sm font-bold text-primary-main flex items-center gap-2">
<ClipboardCheck className="h-4 w-4" />
()
</Label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-grey-3" />
<Input
placeholder="掃描盤點單號或搜尋..."
className="pl-9 h-9"
value={scanSearch}
onChange={(e) => {
setScanSearch(e.target.value);
fetchPendingCounts(e.target.value);
}}
/>
</div>
<div className="max-h-[200px] overflow-y-auto rounded-lg border-2 border-grey-3 bg-white">
{loadingPending ? (
<div className="p-8 text-center text-sm text-grey-3">...</div>
) : pendingCounts.length === 0 ? (
<div className="p-8 text-center text-sm text-grey-3">
調 ()
</div>
) : (
<div className="divide-y divide-grey-4">
{pendingCounts.map((c: any) => (
<div
key={c.id}
className="p-3 hover:bg-primary-lightest flex items-center justify-between cursor-pointer group transition-colors"
onClick={() => handleCreate(c.id)}
>
<div>
<p className="font-bold text-grey-0 group-hover:text-primary-main">{c.doc_no}</p>
<p className="text-xs text-grey-2">{c.warehouse_name} | : {c.completed_at}</p>
</div>
<Button size="sm" variant="outline" className="button-outlined-primary h-7 text-xs">
</Button>
</div>
))}
</div>
)}
</div>
</div>
<div className="relative flex items-center py-2">
<div className="flex-grow border-t border-grey-4"></div>
<span className="flex-shrink mx-4 text-xs font-semibold text-grey-3 uppercase tracking-wider"></span>
<div className="flex-grow border-t border-grey-4"></div>
</div>
{/* Option 2: Manual (Optional, though less common in this flow) */}
<div className="space-y-4 px-1">
<Label className="text-sm font-bold text-grey-0">調</Label>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-xs font-semibold text-grey-1"></Label>
<SearchableSelect
options={warehouses.map(w => ({ value: w.id, label: w.name }))}
value={data.warehouse_id}
onValueChange={(val) => setData('warehouse_id', val)}
placeholder="選擇倉庫"
className="h-9"
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-semibold text-grey-1">調</Label>
<Input
placeholder="例如: 報廢, 破損..."
value={data.reason}
onChange={(e) => setData('reason', e.target.value)}
className="h-9"
/>
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" className="button-outlined-primary" onClick={() => setIsDialogOpen(false)}></Button>
<Button
className="button-filled-primary"
disabled={processing || !data.warehouse_id || !data.reason}
onClick={() => handleCreate()}
>
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
</AuthenticatedLayout>
);
}