Files
star-erp/resources/js/Pages/Production/Recipe/Index.tsx
sky121113 299cf37054 fix: 修正全系統側邊欄捲軸重置問題
在所有報表與管理頁面的 router.get 調用中加入 preserveScroll: true。
受影響模組包括:
- 財務管理 (會計報表、公用事業費)
- 庫存管理 (庫存查詢、倉庫管理、進貨、調整、調撥)
- 生產管理 (工單管理、配方管理)
- 採購管理 (採購單)
- 銷售與發貨管理 (銷售單、發貨單、匯入管理)
- 系統管理 (使用者、角色、操作紀錄)
2026-02-25 14:04:22 +08:00

341 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, 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table";
import { StatusBadge } from "@/Components/shared/StatusBadge";
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, preserveScroll: true }
);
};
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), { preserveScroll: true });
}
};
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>
{/* 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={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10 pr-10 h-9"
onKeyDown={(e) => e.key === 'Enter' && handleFilter()}
/>
{search && (
<button
onClick={() => {
setSearch("");
router.get(route('recipes.index'), { ...filters, search: "" }, { preserveState: true, replace: true, preserveScroll: true });
}}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
<Trash2 className="h-4 w-4" /> {/* Using Trash2/X as clear icon, need to check imports. Inventory used X. */}
</button>
)}
</div>
{/* Action Buttons */}
<div className="flex gap-2 w-full md:w-auto">
<Button
variant="outline"
className="button-outlined-primary"
onClick={handleFilter}
>
<Search className="w-4 h-4 mr-2" />
</Button>
<Can permission="recipes.create">
<Link href={route('recipes.create')}>
<Button className="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-[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">
{recipe.is_active ? (
<StatusBadge variant="success"></StatusBadge>
) : (
<StatusBadge variant="neutral"></StatusBadge>
)}
</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>
);
}