Files
star-erp/resources/js/Pages/StoreRequisition/Index.tsx
sky121113 4fa87925a2
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 1m8s
UI優化: 全系統狀態標籤 (StatusBadge) 統一化重構完成 (Phase 3 & 4)
2026-02-13 13:16:05 +08:00

397 lines
19 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 { useState, useCallback, useEffect } from "react";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, Link, router } from "@inertiajs/react";
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 {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/Components/ui/alert-dialog";
import Pagination from "@/Components/shared/Pagination";
import { toast } from "sonner";
import { Can } from "@/Components/Permission/Can";
import { usePermission } from "@/hooks/usePermission";
import { StatusBadge } from "@/Components/shared/StatusBadge";
import {
Plus,
Search,
Store,
Eye,
Pencil,
Trash2,
X,
} from "lucide-react";
import { formatDate } from "@/lib/date";
function getStatusBadge(status: string) {
switch (status) {
case 'draft':
return <StatusBadge variant="neutral">稿</StatusBadge>;
case 'pending':
return <StatusBadge variant="warning"></StatusBadge>;
case 'approved':
return <StatusBadge variant="success"></StatusBadge>;
case 'rejected':
return <StatusBadge variant="destructive"></StatusBadge>;
case 'completed':
return <StatusBadge variant="success"></StatusBadge>;
case 'cancelled':
return <StatusBadge variant="neutral"></StatusBadge>;
default:
return <StatusBadge variant="neutral">{status}</StatusBadge>;
}
}
export default function Index({
requisitions,
filters,
warehouses,
}: {
requisitions: any;
filters: any;
warehouses: { id: number; name: string }[];
}) {
const { can } = usePermission();
const [searchTerm, setSearchTerm] = useState(filters.search || "");
const [statusFilter, setStatusFilter] = useState(filters.status || "all");
const [warehouseFilter, setWarehouseFilter] = useState(filters.warehouse_id || "all");
const [perPage, setPerPage] = useState(filters.per_page || "10");
const [deleteId, setDeleteId] = useState<string | null>(null);
useEffect(() => {
setSearchTerm(filters.search || "");
setStatusFilter(filters.status || "all");
setWarehouseFilter(filters.warehouse_id || "all");
setPerPage(filters.per_page || "10");
}, [filters]);
const applyFilters = useCallback(
(overrides: Record<string, string> = {}) => {
const params: Record<string, string> = {
search: searchTerm,
status: statusFilter === "all" ? "" : statusFilter,
warehouse_id: warehouseFilter === "all" ? "" : warehouseFilter,
per_page: perPage,
...overrides,
};
// 清理空值
Object.keys(params).forEach((key) => {
if (!params[key]) delete params[key];
});
router.get(route("store-requisitions.index"), params, {
preserveState: true,
replace: true,
preserveScroll: true,
});
},
[searchTerm, statusFilter, warehouseFilter, perPage]
);
const debouncedSearch = useCallback(
debounce((term: string) => {
applyFilters({ search: term });
}, 500),
[applyFilters]
);
const handleSearchChange = (term: string) => {
setSearchTerm(term);
debouncedSearch(term);
};
const handleClearSearch = () => {
setSearchTerm("");
applyFilters({ search: "" });
};
const handleStatusChange = (value: string) => {
setStatusFilter(value);
applyFilters({ status: value === "all" ? "" : value });
};
const handleWarehouseChange = (value: string) => {
setWarehouseFilter(value);
applyFilters({ warehouse_id: value === "all" ? "" : value });
};
const handlePerPageChange = (value: string) => {
setPerPage(value);
applyFilters({ per_page: value });
};
const handleDelete = () => {
if (deleteId) {
router.delete(route("store-requisitions.destroy", [deleteId]), {
onSuccess: () => {
setDeleteId(null);
toast.success("已成功刪除");
},
onError: () => setDeleteId(null),
});
}
};
return (
<AuthenticatedLayout
breadcrumbs={[
{ label: "商品與庫存管理", href: "#" },
{ label: "門市叫貨", href: route("store-requisitions.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">
<Store className="h-6 w-6 text-primary-main" />
</h1>
<p className="text-gray-500 mt-1">
</p>
</div>
</div>
{/* 篩選工具列 */}
<div className="bg-white rounded-lg shadow-sm border p-4 mb-6">
<div className="flex flex-col md:flex-row gap-4">
{/* 搜尋 */}
<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>
{/* 狀態篩選 */}
<SearchableSelect
value={statusFilter}
onValueChange={handleStatusChange}
options={[
{ label: "所有狀態", value: "all" },
{ label: "草稿", value: "draft" },
{ label: "待審核", value: "pending" },
{ label: "已核准", value: "approved" },
{ label: "已駁回", value: "rejected" },
{ label: "已完成", value: "completed" },
{ label: "已取消", value: "cancelled" },
]}
placeholder="選擇狀態"
className="w-full md:w-[160px] h-9"
showSearch={false}
/>
{/* 倉庫篩選 */}
<SearchableSelect
value={warehouseFilter}
onValueChange={handleWarehouseChange}
options={[
{ label: "所有倉庫", value: "all" },
...warehouses.map((w) => ({
label: w.name,
value: w.id.toString(),
})),
]}
placeholder="選擇倉庫"
className="w-full md:w-[200px] h-9"
/>
{/* 操作按鈕 */}
<div className="flex gap-2 w-full md:w-auto">
<Can permission="store_requisitions.create">
<Link href={route("store-requisitions.create")}>
<Button className="flex-1 md:flex-none button-filled-primary">
<Plus className="w-4 h-4 mr-2" />
</Button>
</Link>
</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 font-medium text-gray-600">#</TableHead>
<TableHead className="font-medium text-gray-600"></TableHead>
<TableHead className="font-medium text-gray-600"></TableHead>
<TableHead className="font-medium text-gray-600"></TableHead>
<TableHead className="font-medium text-gray-600"></TableHead>
<TableHead className="font-medium text-gray-600"></TableHead>
<TableHead className="text-center font-medium text-gray-600"></TableHead>
<TableHead className="text-center font-medium text-gray-600"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{requisitions.data.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center h-24 text-gray-500">
</TableCell>
</TableRow>
) : (
requisitions.data.map((req: any, index: number) => (
<TableRow
key={req.id}
className="hover:bg-gray-50/50 transition-colors cursor-pointer group"
onClick={() =>
router.visit(route("store-requisitions.show", [req.id]))
}
>
<TableCell className="text-center text-gray-500 font-medium">
{(requisitions.current_page - 1) * requisitions.per_page + index + 1}
</TableCell>
<TableCell className="font-medium text-primary-main">
{req.doc_no}
</TableCell>
<TableCell className="text-gray-700">
{req.store_warehouse_name}
</TableCell>
<TableCell className="text-gray-700">
{req.supply_warehouse_name}
</TableCell>
<TableCell className="text-sm">{req.creator_name}</TableCell>
<TableCell className="text-gray-500 text-sm">
{formatDate(req.created_at)}
</TableCell>
<TableCell className="text-center">
{getStatusBadge(req.status)}
</TableCell>
<TableCell className="text-center">
<div
className="flex items-center justify-center gap-2"
onClick={(e) => e.stopPropagation()}
>
{(() => {
const isEditable = ["draft", "rejected"].includes(req.status);
const canEdit = can("store_requisitions.edit");
if (isEditable && canEdit) {
return (
<Link href={route("store-requisitions.edit", [req.id])}>
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
title="編輯"
>
<Pencil className="w-4 h-4 ml-0.5" />
</Button>
</Link>
);
}
return (
<Link href={route("store-requisitions.show", [req.id])}>
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
title="查閱"
>
<Eye className="w-4 h-4 ml-0.5" />
</Button>
</Link>
);
})()}
{req.status === "draft" && (
<Can permission="store_requisitions.delete">
<Button
variant="outline"
size="sm"
className="button-outlined-error"
title="刪除"
onClick={() => setDeleteId(req.id)}
>
<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"> {requisitions.total} </span>
</div>
<Pagination links={requisitions.links} />
</div>
{/* 刪除確認對話框 */}
<AlertDialog open={!!deleteId} onOpenChange={(open) => !open && setDeleteId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="button-filled-error">
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</AuthenticatedLayout>
);
}