Files
star-erp/resources/js/Pages/Production/Recipe/Index.tsx
sky121113 746eeb6f01
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 49s
更新:優化配方詳情彈窗 UI 與一般修正
2026-01-29 16:13:56 +08:00

342 lines
17 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, useEffect } from "react";
import { Plus, Search, RotateCcw, Pencil, Trash2, BookOpen, Eye } from 'lucide-react';
import { Button } from "@/Components/ui/button";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, router, Link } from "@inertiajs/react";
import Pagination from "@/Components/shared/Pagination";
import { getBreadcrumbs } from "@/utils/breadcrumb";
import { SearchableSelect } from "@/Components/ui/searchable-select";
import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table";
import { Badge } from "@/Components/ui/badge";
import { Can } from "@/Components/Permission/Can";
import { RecipeDetailModal } from "./Components/RecipeDetailModal";
import axios from 'axios';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/Components/ui/alert-dialog";
interface Recipe {
id: number;
code: string;
name: string;
product_id: number;
product?: { id: number; name: string; code: string };
yield_quantity: number;
is_active: boolean;
description: string;
updated_at: string;
}
interface Props {
recipes: {
data: Recipe[];
links: any[];
total: number;
from: number;
to: number;
};
filters: {
search?: string;
per_page?: string;
sort_field?: string;
sort_direction?: string;
};
}
export default function RecipeIndex({ recipes, filters }: Props) {
const [search, setSearch] = useState(filters.search || "");
const [perPage, setPerPage] = useState<string>(filters.per_page || "10");
// View Modal State
const [viewRecipe, setViewRecipe] = useState<any | null>(null);
const [isViewModalOpen, setIsViewModalOpen] = useState(false);
const [isViewLoading, setIsViewLoading] = useState(false);
useEffect(() => {
setSearch(filters.search || "");
setPerPage(filters.per_page || "10");
}, [filters]);
const handleFilter = () => {
router.get(
route('recipes.index'),
{
search,
per_page: perPage,
},
{ preserveState: true, replace: true }
);
};
const handleReset = () => {
setSearch("");
router.get(route('recipes.index'));
};
const handlePerPageChange = (value: string) => {
setPerPage(value);
router.get(
route("recipes.index"),
{ ...filters, per_page: value },
{ preserveState: false, replace: true, preserveScroll: true }
);
};
const handleDelete = (id: number) => {
if (confirm("確定要刪除此配方嗎?")) {
router.delete(route('recipes.destroy', id));
}
};
const handleView = async (id: number) => {
setIsViewModalOpen(true);
setIsViewLoading(true);
setViewRecipe(null);
try {
const response = await axios.get(route('recipes.show', id));
setViewRecipe(response.data);
} catch (error) {
console.error("Failed to load recipe details", error);
} finally {
setIsViewLoading(false);
}
};
return (
<AuthenticatedLayout breadcrumbs={getBreadcrumbs("recipes")}>
<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">
<BookOpen className="h-6 w-6 text-primary-main" />
</h1>
<p className="text-gray-500 mt-1">
</p>
</div>
<div className="flex gap-2">
<Can permission="recipes.create">
<Link href={route('recipes.create')}>
<Button className="gap-2 button-filled-primary">
<Plus className="h-4 w-4" />
</Button>
</Link>
</Can>
</div>
</div>
{/* 篩選區塊 */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 mb-6 overflow-hidden">
<div className="p-5">
<div className="grid grid-cols-1 md:grid-cols-12 gap-4">
<div className="md:col-span-12 space-y-1">
<Label className="text-xs font-medium text-grey-2"></Label>
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
<Input
placeholder="搜尋配方代號、名稱、產品名稱..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10 h-9 block"
onKeyDown={(e) => e.key === 'Enter' && handleFilter()}
/>
</div>
</div>
</div>
</div>
<div className="flex items-center justify-end px-5 py-4 bg-gray-50/50 border-t border-gray-100 gap-3">
<Button
variant="outline"
onClick={handleReset}
className="button-outlined-primary h-9 gap-2"
>
<RotateCcw className="h-4 w-4" />
</Button>
<Button
onClick={handleFilter}
className="button-filled-primary h-9 px-6 gap-2"
>
<Search className="h-4 w-4" />
</Button>
</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-[120px]"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-center w-[100px]"></TableHead>
<TableHead className="w-[150px]"></TableHead>
<TableHead className="text-center w-[150px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{recipes.data.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="h-32 text-center text-gray-500">
<div className="flex flex-col items-center justify-center gap-2">
<BookOpen className="h-10 w-10 text-gray-300" />
<p></p>
</div>
</TableCell>
</TableRow>
) : (
recipes.data.map((recipe) => (
<TableRow key={recipe.id}>
<TableCell className="font-medium text-gray-900">
{recipe.code}
</TableCell>
<TableCell>
<div className="flex flex-col">
<span className="font-medium text-gray-900">{recipe.name}</span>
{recipe.description && (
<span className="text-gray-400 text-xs truncate max-w-[200px]">
{recipe.description}
</span>
)}
</div>
</TableCell>
<TableCell>
{recipe.product ? (
<div className="flex flex-col">
<span className="font-medium">{recipe.product.name}</span>
<span className="text-xs text-gray-400">{recipe.product.code}</span>
</div>
) : '-'}
</TableCell>
<TableCell className="text-right font-medium">
{recipe.yield_quantity}
</TableCell>
<TableCell className="text-center">
<Badge variant={recipe.is_active ? "default" : "secondary"}>
{recipe.is_active ? "啟用" : "停用"}
</Badge>
</TableCell>
<TableCell className="text-gray-500 text-sm">
{new Date(recipe.updated_at).toLocaleDateString()}
</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-2">
<Can permission="recipes.view">
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
title="查看明細"
onClick={() => handleView(recipe.id)}
>
<Eye className="h-4 w-4" />
</Button>
</Can>
<Can permission="recipes.edit">
<Link href={route('recipes.edit', recipe.id)}>
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
title="編輯"
>
<Pencil className="h-4 w-4" />
</Button>
</Link>
</Can>
<Can permission="recipes.delete">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="button-outlined-error"
title="刪除"
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{recipe.name}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDelete(recipe.id)}
className="bg-red-600 hover:bg-red-700"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Can>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* 分頁 */}
<div className="mt-4 flex flex-col sm:flex-row items-start sm:items-center justify-between 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-[100px] h-8"
showSearch={false}
/>
<span></span>
</div>
<div className="w-full sm:w-auto flex justify-center sm:justify-end">
<Pagination links={recipes.links} />
</div>
</div>
<RecipeDetailModal
isOpen={isViewModalOpen}
onClose={() => setIsViewModalOpen(false)}
recipe={viewRecipe}
isLoading={isViewLoading}
/>
</div>
</AuthenticatedLayout>
);
}