first commit
This commit is contained in:
282
resources/js/Pages/Product/Index.tsx
Normal file
282
resources/js/Pages/Product/Index.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/Components/ui/select";
|
||||
import { Plus, Search, X } from "lucide-react";
|
||||
import ProductTable from "@/Components/Product/ProductTable";
|
||||
import ProductDialog from "@/Components/Product/ProductDialog";
|
||||
import CategoryManagerDialog from "@/Components/Category/CategoryManagerDialog";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import { Head, router } from "@inertiajs/react";
|
||||
import { debounce } from "lodash";
|
||||
import Pagination from "@/Components/shared/Pagination";
|
||||
|
||||
export interface Category {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
category_id: number;
|
||||
category?: Category;
|
||||
brand?: string;
|
||||
specification?: string;
|
||||
base_unit: string;
|
||||
large_unit?: string;
|
||||
conversion_rate?: number;
|
||||
purchase_unit?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface PageProps {
|
||||
products: {
|
||||
data: Product[];
|
||||
links: any[]; // Todo: pagination types
|
||||
from: number;
|
||||
};
|
||||
categories: Category[];
|
||||
filters: {
|
||||
search?: string;
|
||||
category_id?: string;
|
||||
per_page?: string;
|
||||
sort_field?: string;
|
||||
sort_direction?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function ProductManagement({ products, categories, filters }: PageProps) {
|
||||
const [searchTerm, setSearchTerm] = useState(filters.search || "");
|
||||
const [typeFilter, setTypeFilter] = useState<string>(filters.category_id || "all");
|
||||
const [perPage, setPerPage] = useState<string>(filters.per_page || "10");
|
||||
const [sortField, setSortField] = useState<string | null>(filters.sort_field || null);
|
||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc" | null>(filters.sort_direction as "asc" | "desc" || null);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [isCategoryDialogOpen, setIsCategoryDialogOpen] = useState(false);
|
||||
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
|
||||
|
||||
// Sync state with props when they change (e.g. navigation)
|
||||
useEffect(() => {
|
||||
setSearchTerm(filters.search || "");
|
||||
setTypeFilter(filters.category_id || "all");
|
||||
setSearchTerm(filters.search || "");
|
||||
setTypeFilter(filters.category_id || "all");
|
||||
setPerPage(filters.per_page || "10");
|
||||
setSortField(filters.sort_field || null);
|
||||
setSortDirection(filters.sort_direction as "asc" | "desc" || null);
|
||||
}, [filters]);
|
||||
|
||||
const handleSort = (field: string) => {
|
||||
let newField: string | null = field;
|
||||
let newDirection: "asc" | "desc" | null = "asc";
|
||||
|
||||
if (sortField === field) {
|
||||
if (sortDirection === "asc") {
|
||||
newDirection = "desc";
|
||||
} else {
|
||||
// desc -> reset
|
||||
newDirection = null;
|
||||
newField = null;
|
||||
}
|
||||
}
|
||||
|
||||
setSortField(newField);
|
||||
setSortDirection(newDirection);
|
||||
|
||||
router.get(
|
||||
route("products.index"),
|
||||
{
|
||||
search: searchTerm,
|
||||
category_id: typeFilter,
|
||||
per_page: perPage,
|
||||
sort_field: newField,
|
||||
sort_direction: newDirection
|
||||
},
|
||||
{ preserveState: true, replace: true, preserveScroll: true }
|
||||
);
|
||||
};
|
||||
|
||||
// Debounced Search Handler
|
||||
const debouncedSearch = useCallback(
|
||||
debounce((term: string, category: string) => {
|
||||
router.get(
|
||||
route("products.index"),
|
||||
{ search: term, category_id: category },
|
||||
{ preserveState: true, replace: true, preserveScroll: true }
|
||||
);
|
||||
}, 500),
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSearchChange = (term: string) => {
|
||||
setSearchTerm(term);
|
||||
debouncedSearch(term, typeFilter);
|
||||
};
|
||||
|
||||
const handleCategoryChange = (value: string) => {
|
||||
setTypeFilter(value);
|
||||
// Immediate update for category
|
||||
router.get(
|
||||
route("products.index"),
|
||||
{ search: searchTerm, category_id: value },
|
||||
{ preserveState: false, replace: true, preserveScroll: true }
|
||||
);
|
||||
};
|
||||
|
||||
const handleClearSearch = () => {
|
||||
setSearchTerm("");
|
||||
// Immediate update for clear
|
||||
router.get(
|
||||
route("products.index"),
|
||||
{ search: "", category_id: typeFilter, per_page: perPage },
|
||||
{ preserveState: true, replace: true, preserveScroll: true }
|
||||
);
|
||||
};
|
||||
|
||||
const handlePerPageChange = (value: string) => {
|
||||
setPerPage(value);
|
||||
router.get(
|
||||
route("products.index"),
|
||||
{ search: searchTerm, category_id: typeFilter, per_page: value },
|
||||
{ preserveState: false, replace: true, preserveScroll: true }
|
||||
);
|
||||
};
|
||||
|
||||
const handleAddProduct = () => {
|
||||
setEditingProduct(null);
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleEditProduct = (product: Product) => {
|
||||
setEditingProduct(product);
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteProduct = (id: number) => {
|
||||
if (confirm("確定要刪除嗎?")) {
|
||||
router.delete(route('products.destroy', id), {
|
||||
onSuccess: () => {
|
||||
// Toast handled by flash message usually, or add here if needed
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout>
|
||||
<Head title="商品資料管理" />
|
||||
<div className="container mx-auto p-6 max-w-7xl">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="mb-2">商品資料管理</h1>
|
||||
<p className="text-gray-600">管理甜點店原物料與成品資料</p>
|
||||
</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"
|
||||
/>
|
||||
{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>
|
||||
|
||||
{/* Type Filter */}
|
||||
<Select value={typeFilter} onValueChange={handleCategoryChange}>
|
||||
<SelectTrigger className="w-full md:w-[180px]">
|
||||
<SelectValue placeholder="商品分類" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部分類</SelectItem>
|
||||
{categories.map(cat => (
|
||||
<SelectItem key={cat.id} value={cat.id.toString()}>{cat.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Add Button */}
|
||||
<div className="flex gap-2 w-full md:w-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsCategoryDialogOpen(true)}
|
||||
className="flex-1 md:flex-none button-outlined-primary"
|
||||
>
|
||||
管理分類
|
||||
</Button>
|
||||
<Button onClick={handleAddProduct} className="flex-1 md:flex-none button-filled-primary">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
新增商品
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product Table */}
|
||||
<ProductTable
|
||||
products={products.data}
|
||||
onEdit={handleEditProduct}
|
||||
onDelete={handleDeleteProduct}
|
||||
startIndex={products.from}
|
||||
sortField={sortField}
|
||||
sortDirection={sortDirection}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
|
||||
{/* 分頁元件 */}
|
||||
<div className="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<span>每頁顯示</span>
|
||||
<Select value={perPage} onValueChange={handlePerPageChange}>
|
||||
<SelectTrigger className="w-[80px] h-8">
|
||||
<SelectValue placeholder="10" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="10">10</SelectItem>
|
||||
<SelectItem value="20">20</SelectItem>
|
||||
<SelectItem value="50">50</SelectItem>
|
||||
<SelectItem value="100">100</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span>筆</span>
|
||||
</div>
|
||||
<Pagination links={products.links} />
|
||||
</div>
|
||||
|
||||
<ProductDialog
|
||||
open={isDialogOpen}
|
||||
onOpenChange={setIsDialogOpen}
|
||||
product={editingProduct}
|
||||
categories={categories}
|
||||
/>
|
||||
|
||||
<CategoryManagerDialog
|
||||
open={isCategoryDialogOpen}
|
||||
onOpenChange={setIsCategoryDialogOpen}
|
||||
categories={categories}
|
||||
/>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
295
resources/js/Pages/PurchaseOrder/Create.tsx
Normal file
295
resources/js/Pages/PurchaseOrder/Create.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* 建立/編輯採購單頁面
|
||||
*/
|
||||
|
||||
import { ArrowLeft, Plus, Info } from "lucide-react";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Textarea } from "@/Components/ui/textarea";
|
||||
import { Alert, AlertDescription } from "@/Components/ui/alert";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/Components/ui/select";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import { Head, Link, router } from "@inertiajs/react";
|
||||
import { PurchaseOrderItemsTable } from "@/Components/PurchaseOrder/PurchaseOrderItemsTable";
|
||||
import type { PurchaseOrder, Supplier } from "@/types/purchase-order";
|
||||
import type { Warehouse } from "@/types/requester";
|
||||
import { usePurchaseOrderForm } from "@/hooks/usePurchaseOrderForm";
|
||||
import {
|
||||
validatePurchaseOrder,
|
||||
filterValidItems,
|
||||
calculateTotalAmount,
|
||||
getTodayDate,
|
||||
formatCurrency,
|
||||
} from "@/utils/purchase-order";
|
||||
import { STATUS_OPTIONS } from "@/constants/purchase-order";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
order?: PurchaseOrder;
|
||||
suppliers: Supplier[];
|
||||
warehouses: Warehouse[];
|
||||
}
|
||||
|
||||
export default function CreatePurchaseOrder({
|
||||
order,
|
||||
suppliers,
|
||||
warehouses,
|
||||
}: Props) {
|
||||
const {
|
||||
supplierId,
|
||||
expectedDate,
|
||||
items,
|
||||
notes,
|
||||
selectedSupplier,
|
||||
isOrderSent,
|
||||
warehouseId,
|
||||
setSupplierId,
|
||||
setExpectedDate,
|
||||
setNotes,
|
||||
setWarehouseId,
|
||||
addItem,
|
||||
removeItem,
|
||||
updateItem,
|
||||
status,
|
||||
setStatus,
|
||||
} = usePurchaseOrderForm({ order, suppliers });
|
||||
|
||||
const totalAmount = calculateTotalAmount(items);
|
||||
const isValid = validatePurchaseOrder(String(supplierId), expectedDate, items);
|
||||
|
||||
const handleSave = () => {
|
||||
if (!isValid || !warehouseId) {
|
||||
toast.error("請填寫完整的表單資訊");
|
||||
return;
|
||||
}
|
||||
|
||||
const validItems = filterValidItems(items);
|
||||
if (validItems.length === 0) {
|
||||
toast.error("請至少新增一項採購商品");
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
vendor_id: supplierId,
|
||||
warehouse_id: warehouseId,
|
||||
expected_delivery_date: expectedDate,
|
||||
remark: notes,
|
||||
status: status,
|
||||
items: validItems.map(item => ({
|
||||
productId: item.productId,
|
||||
quantity: item.quantity,
|
||||
unitPrice: item.unitPrice,
|
||||
})),
|
||||
};
|
||||
|
||||
if (order) {
|
||||
// Edit not implemented yet but structure is ready
|
||||
router.put(`/purchase-orders/${order.id}`, data, {
|
||||
onSuccess: () => toast.success("採購單已更新")
|
||||
});
|
||||
} else {
|
||||
router.post("/purchase-orders", data, {
|
||||
onSuccess: () => toast.success("採購單已成功建立")
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const hasSupplier = !!supplierId;
|
||||
const canSave = isValid && !!warehouseId && items.length > 0;
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout>
|
||||
<Head title={order ? "編輯採購單" : "建立採購單"} />
|
||||
<div className="container mx-auto p-6 max-w-5xl">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<Link href="/purchase-orders">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 button-outlined-primary mb-6"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回列表
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<div className="mb-6">
|
||||
<h1 className="mb-2">{order ? "編輯採購單" : "建立採購單"}</h1>
|
||||
<p className="text-gray-600">
|
||||
{order ? `修改採購單 ${order.poNumber} 的詳細資訊` : "填寫新採購單的資訊以開始流程"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-8">
|
||||
{/* 步驟一:基本資訊 */}
|
||||
<div className="bg-white rounded-lg border shadow-sm overflow-hidden">
|
||||
<div className="p-6 bg-gray-50/50 border-b flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-primary text-white flex items-center justify-center font-bold">1</div>
|
||||
<h2 className="text-lg font-bold">基本資訊</h2>
|
||||
</div>
|
||||
|
||||
<div className="p-8 space-y-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div className="space-y-3">
|
||||
<label className="text-sm font-bold text-gray-700">
|
||||
預計入庫倉庫
|
||||
</label>
|
||||
<Select
|
||||
value={String(warehouseId)}
|
||||
onValueChange={setWarehouseId}
|
||||
disabled={isOrderSent}
|
||||
>
|
||||
<SelectTrigger className="h-12 border-gray-200 focus:ring-primary/20">
|
||||
<SelectValue placeholder="請選擇倉庫" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{warehouses.map((w) => (
|
||||
<SelectItem key={w.id} value={String(w.id)}>
|
||||
{w.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="text-sm font-bold text-gray-700">供應商</label>
|
||||
<Select
|
||||
value={String(supplierId)}
|
||||
onValueChange={setSupplierId}
|
||||
disabled={isOrderSent}
|
||||
>
|
||||
<SelectTrigger className="h-12 border-gray-200">
|
||||
<SelectValue placeholder="選擇供應商" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{suppliers.map((s) => (
|
||||
<SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div className="space-y-3">
|
||||
<label className="text-sm font-bold text-gray-700">
|
||||
預計到貨日期
|
||||
</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={expectedDate || ""}
|
||||
onChange={(e) => setExpectedDate(e.target.value)}
|
||||
min={getTodayDate()}
|
||||
className="h-12 border-gray-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{order && (
|
||||
<div className="space-y-3">
|
||||
<label className="text-sm font-bold text-gray-700">狀態</label>
|
||||
<Select
|
||||
value={status}
|
||||
onValueChange={(v) => setStatus(v as any)}
|
||||
>
|
||||
<SelectTrigger className="h-12 border-gray-200">
|
||||
<SelectValue placeholder="選擇狀態" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="text-sm font-bold text-gray-700">備註事項</label>
|
||||
<Textarea
|
||||
value={notes || ""}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="備註這筆採購單的特殊需求..."
|
||||
className="min-h-[100px] border-gray-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 步驟二:品項明細 */}
|
||||
<div className={`bg-white rounded-lg border shadow-sm overflow-hidden transition-all duration-300 ${!hasSupplier ? 'opacity-60 saturate-50' : ''}`}>
|
||||
<div className="p-6 bg-gray-50/50 border-b flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-primary text-white flex items-center justify-center font-bold">2</div>
|
||||
<h2 className="text-lg font-bold">採購商品明細</h2>
|
||||
</div>
|
||||
<Button
|
||||
onClick={addItem}
|
||||
disabled={!hasSupplier || isOrderSent}
|
||||
className="button-filled-primary h-10 gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" /> 新增一個品項
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
{!hasSupplier && (
|
||||
<Alert className="mb-6 bg-amber-50 border-amber-200 text-amber-800">
|
||||
<Info className="h-4 w-4 text-amber-600" />
|
||||
<AlertDescription>
|
||||
請先在步驟一選擇「供應商」,才能從該供應商的常用項目中選取商品。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<PurchaseOrderItemsTable
|
||||
items={items}
|
||||
supplier={selectedSupplier}
|
||||
isReadOnly={isOrderSent}
|
||||
isDisabled={!hasSupplier}
|
||||
onRemoveItem={removeItem}
|
||||
onItemChange={updateItem}
|
||||
/>
|
||||
|
||||
{hasSupplier && items.length > 0 && (
|
||||
<div className="mt-8 flex justify-end">
|
||||
<div className="bg-primary/5 px-8 py-5 rounded-xl border border-primary/10 inline-flex flex-col items-end min-w-[240px]">
|
||||
<span className="text-sm text-gray-500 font-medium mb-1">採購預估總額</span>
|
||||
<span className="text-3xl font-black text-primary">{formatCurrency(totalAmount)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部按鈕 */}
|
||||
<div className="flex items-center justify-end gap-4 py-4">
|
||||
<Link href="/purchase-orders">
|
||||
<Button variant="ghost" className="h-12 px-8 text-gray-500 hover:text-gray-700">
|
||||
取消
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
size="lg"
|
||||
className="bg-primary hover:bg-primary/90 text-white px-12 h-14 rounded-xl shadow-lg shadow-primary/20 text-lg font-bold transition-all hover:scale-[1.02] active:scale-[0.98]"
|
||||
onClick={handleSave}
|
||||
disabled={!canSave}
|
||||
>
|
||||
{order ? "更新採購單" : "確認發布採購單"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
132
resources/js/Pages/PurchaseOrder/Index.tsx
Normal file
132
resources/js/Pages/PurchaseOrder/Index.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* 採購單管理主頁面
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import { Head, router } from "@inertiajs/react";
|
||||
import PurchaseOrderTable from "@/Components/PurchaseOrder/PurchaseOrderTable";
|
||||
import { PurchaseOrderFilters } from "@/Components/PurchaseOrder/PurchaseOrderFilters";
|
||||
import { type DateRange } from "@/Components/PurchaseOrder/DateFilter";
|
||||
import type { PurchaseOrder } from "@/types/purchase-order";
|
||||
import { debounce } from "lodash";
|
||||
import Pagination from "@/Components/shared/Pagination";
|
||||
|
||||
interface Props {
|
||||
orders: {
|
||||
data: PurchaseOrder[];
|
||||
links: any[];
|
||||
total: number;
|
||||
from: number;
|
||||
to: number;
|
||||
};
|
||||
filters: {
|
||||
search?: string;
|
||||
status?: string;
|
||||
warehouse_id?: string;
|
||||
sort_field?: string;
|
||||
sort_direction?: string;
|
||||
};
|
||||
warehouses: { id: number; name: string }[];
|
||||
}
|
||||
|
||||
export default function PurchaseOrderIndex({ orders, filters, warehouses }: Props) {
|
||||
const [searchQuery, setSearchQuery] = useState(filters.search || "");
|
||||
const [statusFilter, setStatusFilter] = useState<string>(filters.status || "all");
|
||||
const [requesterFilter, setRequesterFilter] = useState<string>(filters.warehouse_id || "all");
|
||||
const [dateRange, setDateRange] = useState<DateRange | null>(null);
|
||||
|
||||
const handleFilterChange = (newFilters: any) => {
|
||||
router.get("/purchase-orders", {
|
||||
...filters,
|
||||
...newFilters,
|
||||
page: 1,
|
||||
}, {
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSearch = useCallback(
|
||||
debounce((value: string) => {
|
||||
handleFilterChange({ search: value });
|
||||
}, 500),
|
||||
[filters]
|
||||
);
|
||||
|
||||
const onSearchChange = (value: string) => {
|
||||
setSearchQuery(value);
|
||||
handleSearch(value);
|
||||
};
|
||||
|
||||
const onStatusChange = (value: string) => {
|
||||
setStatusFilter(value);
|
||||
handleFilterChange({ status: value });
|
||||
};
|
||||
|
||||
const onWarehouseChange = (value: string) => {
|
||||
setRequesterFilter(value);
|
||||
handleFilterChange({ warehouse_id: value });
|
||||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setSearchQuery("");
|
||||
setStatusFilter("all");
|
||||
setRequesterFilter("all");
|
||||
setDateRange(null);
|
||||
router.get("/purchase-orders");
|
||||
};
|
||||
|
||||
const hasActiveFilters = searchQuery !== "" || statusFilter !== "all" || requesterFilter !== "all" || dateRange !== null;
|
||||
|
||||
const handleNavigateToCreateOrder = () => {
|
||||
router.get("/purchase-orders/create");
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout>
|
||||
<Head title="採購管理 - 管理採購單" />
|
||||
<div className="container mx-auto p-6 max-w-7xl">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="mb-2">管理採購單</h1>
|
||||
<p className="text-gray-600">追蹤並管理所有倉庫的採購申請與進度</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleNavigateToCreateOrder}
|
||||
className="gap-2 button-filled-primary"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
建立採購單
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<PurchaseOrderFilters
|
||||
searchQuery={searchQuery}
|
||||
statusFilter={statusFilter}
|
||||
requesterFilter={requesterFilter}
|
||||
warehouses={warehouses}
|
||||
onSearchChange={onSearchChange}
|
||||
onStatusChange={onStatusChange}
|
||||
onRequesterChange={onWarehouseChange}
|
||||
onClearFilters={handleClearFilters}
|
||||
hasActiveFilters={hasActiveFilters}
|
||||
dateRange={dateRange}
|
||||
onDateRangeChange={setDateRange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PurchaseOrderTable
|
||||
orders={orders.data}
|
||||
/>
|
||||
|
||||
<div className="mt-6 flex justify-center">
|
||||
<Pagination links={orders.links} />
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
173
resources/js/Pages/PurchaseOrder/Show.tsx
Normal file
173
resources/js/Pages/PurchaseOrder/Show.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* 查看採購單詳情頁面
|
||||
*/
|
||||
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import { Head, Link } from "@inertiajs/react";
|
||||
import { StatusProgressBar } from "@/Components/PurchaseOrder/StatusProgressBar";
|
||||
import PurchaseOrderStatusBadge from "@/Components/PurchaseOrder/PurchaseOrderStatusBadge";
|
||||
import CopyButton from "@/Components/shared/CopyButton";
|
||||
import type { PurchaseOrder } from "@/types/purchase-order";
|
||||
import { formatCurrency, formatDateTime } from "@/utils/format";
|
||||
|
||||
interface Props {
|
||||
order: PurchaseOrder;
|
||||
}
|
||||
|
||||
export default function ViewPurchaseOrderPage({ order }: Props) {
|
||||
return (
|
||||
<AuthenticatedLayout>
|
||||
<Head title={`採購單詳情 - ${order.poNumber}`} />
|
||||
<div className="container mx-auto p-6 max-w-7xl">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<Link href="/purchase-orders">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 button-outlined-primary mb-6"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回採購單列表
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="mb-2">查看採購單</h1>
|
||||
<p className="text-gray-600">單號:{order.poNumber}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href={`/purchase-orders/${order.id}/edit`}>
|
||||
<Button variant="outline" className="button-outlined-primary">
|
||||
編輯採購單
|
||||
</Button>
|
||||
</Link>
|
||||
<PurchaseOrderStatusBadge status={order.status} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 狀態流程條 */}
|
||||
<div className="mb-8">
|
||||
<StatusProgressBar currentStatus={order.status} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-8">
|
||||
{/* 基本資訊與品項 */}
|
||||
<div className="space-y-8">
|
||||
|
||||
|
||||
{/* 基本資訊卡片 */}
|
||||
<div className="bg-white rounded-lg border shadow-sm p-6">
|
||||
<h2 className="text-lg font-bold text-gray-900 mb-6">基本資訊</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-6">
|
||||
<div>
|
||||
<span className="text-sm text-gray-500 block mb-1">採購單編號</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-mono font-medium text-gray-900">{order.poNumber}</span>
|
||||
<CopyButton text={order.poNumber} label="複製單號" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-gray-500 block mb-1">供應商</span>
|
||||
<span className="font-medium text-gray-900">{order.supplierName}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-gray-500 block mb-1">申請單位 (申請人)</span>
|
||||
<span className="font-medium text-gray-900">
|
||||
{order.warehouse_name} ({order.createdBy})
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-gray-500 block mb-1">建立日期</span>
|
||||
<span className="font-medium text-gray-900">{formatDateTime(order.createdAt)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-gray-500 block mb-1">預計到貨日期</span>
|
||||
<span className="font-medium text-gray-900">{order.expectedDate || "-"}</span>
|
||||
</div>
|
||||
</div>
|
||||
{order.remark && (
|
||||
<div className="mt-8 pt-6 border-t border-gray-100">
|
||||
<span className="text-sm text-gray-500 block mb-2">備註</span>
|
||||
<p className="text-sm text-gray-700 bg-gray-50 p-4 rounded-lg leading-relaxed">
|
||||
{order.remark}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 採購項目卡片 */}
|
||||
<div className="bg-white rounded-lg border shadow-sm overflow-hidden">
|
||||
<div className="p-6 border-b border-gray-100">
|
||||
<h2 className="text-lg font-bold text-gray-900">採購項目清單</h2>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-50/50">
|
||||
<th className="text-left py-4 px-6 text-xs font-semibold text-gray-500 uppercase tracking-wider w-[50px]">
|
||||
#
|
||||
</th>
|
||||
<th className="text-left py-4 px-6 text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
||||
商品名稱
|
||||
</th>
|
||||
<th className="text-right py-4 px-6 text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
||||
單價
|
||||
</th>
|
||||
<th className="text-right py-4 px-6 text-xs font-semibold text-gray-500 uppercase tracking-wider w-32">
|
||||
數量
|
||||
</th>
|
||||
<th className="text-right py-4 px-6 text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
||||
小計
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{order.items.map((item, index) => (
|
||||
<tr key={index} className="hover:bg-gray-50/30 transition-colors">
|
||||
<td className="py-4 px-6 text-gray-500 font-medium text-center">
|
||||
{index + 1}
|
||||
</td>
|
||||
<td className="py-4 px-6">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-gray-900">{item.productName}</span>
|
||||
<span className="text-xs text-gray-400">ID: {item.productId}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 px-6 text-right">
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="text-gray-900">{formatCurrency(item.unitPrice)}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 px-6 text-right">
|
||||
<span className="text-gray-900 font-medium">
|
||||
{item.quantity} {item.unit}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-4 px-6 text-right font-bold text-gray-900">
|
||||
{formatCurrency(item.subtotal)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot className="bg-gray-50/50 border-t border-gray-100">
|
||||
<tr>
|
||||
<td colSpan={4} className="py-5 px-6 text-right font-medium text-gray-600">
|
||||
總金額
|
||||
</td>
|
||||
<td className="py-5 px-6 text-right font-bold text-xl text-primary">
|
||||
{formatCurrency(order.totalAmount)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
185
resources/js/Pages/Vendor/Index.tsx
vendored
Normal file
185
resources/js/Pages/Vendor/Index.tsx
vendored
Normal file
@@ -0,0 +1,185 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Plus, Search, X } from "lucide-react";
|
||||
import VendorTable from "@/Components/Vendor/VendorTable";
|
||||
import VendorDialog from "@/Components/Vendor/VendorDialog";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import { Head, router } from "@inertiajs/react";
|
||||
import { debounce } from "lodash";
|
||||
|
||||
export interface Vendor {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
short_name?: string;
|
||||
tax_id?: string;
|
||||
owner?: string;
|
||||
contact_name?: string;
|
||||
tel?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
address?: string;
|
||||
remark?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface PageProps {
|
||||
vendors: {
|
||||
data: Vendor[];
|
||||
links: any[];
|
||||
meta: any;
|
||||
};
|
||||
filters: {
|
||||
search?: string;
|
||||
sort_field?: string;
|
||||
sort_direction?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function VendorManagement({ vendors, filters }: PageProps) {
|
||||
const [searchTerm, setSearchTerm] = useState(filters.search || "");
|
||||
const [sortField, setSortField] = useState<string | null>(filters.sort_field || null);
|
||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc" | null>(filters.sort_direction as "asc" | "desc" || null);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [editingVendor, setEditingVendor] = useState<Vendor | null>(null);
|
||||
|
||||
// Sync state with props
|
||||
useEffect(() => {
|
||||
setSearchTerm(filters.search || "");
|
||||
setSortField(filters.sort_field || null);
|
||||
setSortDirection(filters.sort_direction as "asc" | "desc" || null);
|
||||
}, [filters]);
|
||||
|
||||
// Debounced Search
|
||||
const debouncedSearch = useCallback(
|
||||
debounce((term: string) => {
|
||||
router.get(
|
||||
route("vendors.index"),
|
||||
{ search: term },
|
||||
{ preserveState: true, replace: true, preserveScroll: true }
|
||||
);
|
||||
}, 500),
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSearchChange = (term: string) => {
|
||||
setSearchTerm(term);
|
||||
debouncedSearch(term);
|
||||
};
|
||||
|
||||
const handleClearSearch = () => {
|
||||
setSearchTerm("");
|
||||
router.get(
|
||||
route("vendors.index"),
|
||||
{ search: "" },
|
||||
{ preserveState: true, replace: true, preserveScroll: true }
|
||||
);
|
||||
};
|
||||
|
||||
const handleSort = (field: string) => {
|
||||
let newField: string | null = field;
|
||||
let newDirection: "asc" | "desc" | null = "asc";
|
||||
|
||||
if (sortField === field) {
|
||||
if (sortDirection === "asc") {
|
||||
newDirection = "desc";
|
||||
} else {
|
||||
newDirection = null;
|
||||
newField = null;
|
||||
}
|
||||
}
|
||||
|
||||
setSortField(newField);
|
||||
setSortDirection(newDirection);
|
||||
|
||||
router.get(
|
||||
route("vendors.index"),
|
||||
{
|
||||
search: searchTerm,
|
||||
sort_field: newField,
|
||||
sort_direction: newDirection
|
||||
},
|
||||
{ preserveState: true, replace: true, preserveScroll: true }
|
||||
);
|
||||
};
|
||||
|
||||
const handleAddVendor = () => {
|
||||
setEditingVendor(null);
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleEditVendor = (vendor: Vendor) => {
|
||||
setEditingVendor(vendor);
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleViewVendor = (vendor: Vendor) => {
|
||||
router.get(route("vendors.show", vendor.id));
|
||||
};
|
||||
|
||||
const handleDeleteVendor = (id: number) => {
|
||||
router.delete(route('vendors.destroy', id));
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout>
|
||||
<Head title="廠商資料管理" />
|
||||
<div className="container mx-auto p-6 max-w-7xl">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="mb-2">廠商資料管理</h1>
|
||||
<p className="text-gray-600">管理 ERP 系統供應商與聯絡資訊</p>
|
||||
</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"
|
||||
/>
|
||||
{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>
|
||||
|
||||
{/* Add Button */}
|
||||
<Button onClick={handleAddVendor} className="flex-1 md:flex-none button-filled-primary">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
新增廠商
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vendor Table */}
|
||||
<VendorTable
|
||||
vendors={vendors.data}
|
||||
onView={handleViewVendor}
|
||||
onEdit={handleEditVendor}
|
||||
onDelete={handleDeleteVendor}
|
||||
sortField={sortField}
|
||||
sortDirection={sortDirection}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
|
||||
<VendorDialog
|
||||
open={isDialogOpen}
|
||||
onOpenChange={setIsDialogOpen}
|
||||
vendor={editingVendor}
|
||||
/>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
251
resources/js/Pages/Vendor/Show.tsx
vendored
Normal file
251
resources/js/Pages/Vendor/Show.tsx
vendored
Normal file
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* 廠商詳細資訊頁面
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Head, Link, router } from "@inertiajs/react";
|
||||
import { Phone, Mail, Plus, ArrowLeft } from "lucide-react";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/Components/ui/alert-dialog";
|
||||
import SupplyProductList from "@/Components/Vendor/SupplyProductList";
|
||||
import AddSupplyProductDialog from "@/Components/Vendor/AddSupplyProductDialog";
|
||||
import EditSupplyProductDialog from "@/Components/Vendor/EditSupplyProductDialog";
|
||||
import type { Vendor } from "@/Pages/Vendor/Index";
|
||||
import type { SupplyProduct } from "@/types/vendor";
|
||||
|
||||
interface Pivot {
|
||||
last_price: number | null;
|
||||
}
|
||||
|
||||
interface VendorProduct {
|
||||
id: number;
|
||||
name: string;
|
||||
unit?: string;
|
||||
base_unit?: string;
|
||||
purchase_unit?: string;
|
||||
pivot: Pivot;
|
||||
}
|
||||
|
||||
interface ExtendedVendor extends Vendor {
|
||||
products: VendorProduct[];
|
||||
}
|
||||
|
||||
interface ShowProps {
|
||||
vendor: ExtendedVendor;
|
||||
products: any[];
|
||||
}
|
||||
|
||||
export default function VendorShow({ vendor, products }: ShowProps) {
|
||||
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||
const [showEditDialog, setShowEditDialog] = useState(false);
|
||||
const [showRemoveDialog, setShowRemoveDialog] = useState(false);
|
||||
const [selectedProduct, setSelectedProduct] = useState<SupplyProduct | null>(null);
|
||||
|
||||
// 轉換後端資料格式為前端組件需要的格式
|
||||
const supplyProducts: SupplyProduct[] = vendor.products.map(p => ({
|
||||
id: String(p.id),
|
||||
productId: String(p.id),
|
||||
productName: p.name,
|
||||
unit: p.purchase_unit || p.base_unit || "個",
|
||||
lastPrice: p.pivot.last_price || undefined,
|
||||
}));
|
||||
|
||||
const handleAddProduct = (productId: string, lastPrice?: number) => {
|
||||
router.post(route('vendors.products.store', vendor.id), {
|
||||
product_id: productId,
|
||||
last_price: lastPrice,
|
||||
}, {
|
||||
onSuccess: () => setShowAddDialog(false),
|
||||
});
|
||||
};
|
||||
|
||||
const handleEditProduct = (product: SupplyProduct) => {
|
||||
setSelectedProduct(product);
|
||||
setShowEditDialog(true);
|
||||
};
|
||||
|
||||
const handleUpdateProduct = (productId: string, lastPrice?: number) => {
|
||||
router.put(route('vendors.products.update', [vendor.id, productId]), {
|
||||
last_price: lastPrice,
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
setShowEditDialog(false);
|
||||
setSelectedProduct(null);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveProduct = (product: SupplyProduct) => {
|
||||
setSelectedProduct(product);
|
||||
setShowRemoveDialog(true);
|
||||
};
|
||||
|
||||
const handleConfirmRemove = () => {
|
||||
if (selectedProduct) {
|
||||
router.delete(route('vendors.products.destroy', [vendor.id, selectedProduct.productId]), {
|
||||
onSuccess: () => {
|
||||
setShowRemoveDialog(false);
|
||||
setSelectedProduct(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout>
|
||||
<Head title={`廠商詳情 - ${vendor.name}`} />
|
||||
<div className="container mx-auto p-6 max-w-7xl">
|
||||
{/* 返回按鈕 */}
|
||||
<div className="mb-6">
|
||||
<Link href="/vendors">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 button-outlined-primary mb-6"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回廠商資料管理
|
||||
</Button>
|
||||
</Link>
|
||||
<h1 className="mb-2">廠商詳細資訊</h1>
|
||||
<p className="text-gray-600">
|
||||
查看並管理供應商的詳細資料與供貨商品
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 基本資料 */}
|
||||
<div className="bg-white rounded-lg border border-border p-6 mb-6 shadow-sm">
|
||||
<h3 className="mb-4 text-primary font-bold">基本資料</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs">廠商名稱</Label>
|
||||
<p className="mt-1 font-medium flex items-baseline gap-2">
|
||||
{vendor.name}
|
||||
<span className="text-sm text-muted-foreground">({vendor.code})</span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs">廠商簡稱</Label>
|
||||
<p className="mt-1 font-medium">{vendor.short_name || "-"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs">統一編號</Label>
|
||||
<p className="mt-1">{vendor.tax_id || "-"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs">負責人</Label>
|
||||
<p className="mt-1">{vendor.owner || "-"}</p>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<Label className="text-muted-foreground text-xs">備註</Label>
|
||||
<p className="mt-1 whitespace-pre-wrap">{vendor.remark || "-"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 聯絡資料 */}
|
||||
<div className="bg-white rounded-lg border border-border p-6 mb-6 shadow-sm">
|
||||
<h3 className="mb-4 text-primary font-bold">聯絡資料</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs">聯絡人</Label>
|
||||
<p className="mt-1">{vendor.contact_name || "-"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs">聯絡電話</Label>
|
||||
<p className="mt-1 flex items-center gap-2">
|
||||
<Phone className="h-4 w-4 text-muted-foreground" />
|
||||
{vendor.phone || vendor.tel || "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs">Email</Label>
|
||||
<p className="mt-1 flex items-center gap-2">
|
||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||
{vendor.email || "-"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs">地址</Label>
|
||||
<p className="mt-1">{vendor.address || "-"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 供貨商品列表 */}
|
||||
<div className="bg-white rounded-lg border border-border p-6 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3>供貨商品</h3>
|
||||
<Button
|
||||
onClick={() => setShowAddDialog(true)}
|
||||
className="gap-2 button-filled-primary"
|
||||
size="sm"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
新增供貨商品
|
||||
</Button>
|
||||
</div>
|
||||
<SupplyProductList
|
||||
products={supplyProducts}
|
||||
onEdit={handleEditProduct}
|
||||
onRemove={handleRemoveProduct}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 新增供貨商品對話框 */}
|
||||
<AddSupplyProductDialog
|
||||
open={showAddDialog}
|
||||
products={products}
|
||||
existingSupplyProducts={supplyProducts}
|
||||
onClose={() => setShowAddDialog(false)}
|
||||
onAdd={handleAddProduct}
|
||||
/>
|
||||
|
||||
{/* 編輯供貨商品對話框 */}
|
||||
<EditSupplyProductDialog
|
||||
open={showEditDialog}
|
||||
product={selectedProduct}
|
||||
onClose={() => {
|
||||
setShowEditDialog(false);
|
||||
setSelectedProduct(null);
|
||||
}}
|
||||
onSave={handleUpdateProduct}
|
||||
/>
|
||||
|
||||
{/* 取消供貨確認對話框 */}
|
||||
<AlertDialog open={showRemoveDialog} onOpenChange={setShowRemoveDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>確認取消供貨</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
確定要將「{selectedProduct?.productName}」從供貨列表中移除嗎?此操作無法撤銷。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel
|
||||
className="gap-2 button-outlined-primary"
|
||||
>
|
||||
取消
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="gap-2 button-filled-error"
|
||||
onClick={handleConfirmRemove}
|
||||
>
|
||||
確認移除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
422
resources/js/Pages/Warehouse/AddInventory.tsx
Normal file
422
resources/js/Pages/Warehouse/AddInventory.tsx
Normal file
@@ -0,0 +1,422 @@
|
||||
/**
|
||||
* 新增庫存頁面(手動入庫)
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Plus, Trash2, Calendar, ArrowLeft, Save } from "lucide-react";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import { Textarea } from "@/Components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/Components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/Components/ui/table";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import { Head, Link, router } from "@inertiajs/react";
|
||||
import { Warehouse, InboundItem, InboundReason } from "@/types/warehouse";
|
||||
import { getCurrentDateTime } from "@/utils/format";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Product {
|
||||
id: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
warehouse: Warehouse;
|
||||
products: Product[];
|
||||
}
|
||||
|
||||
const INBOUND_REASONS: InboundReason[] = [
|
||||
"期初建檔",
|
||||
"盤點調整",
|
||||
"實際入庫未走採購流程",
|
||||
"生產加工成品入庫",
|
||||
"其他",
|
||||
];
|
||||
|
||||
export default function AddInventoryPage({ warehouse, products }: Props) {
|
||||
const [inboundDate, setInboundDate] = useState(getCurrentDateTime());
|
||||
const [reason, setReason] = useState<InboundReason>("期初建檔");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [items, setItems] = useState<InboundItem[]>([]);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// 新增明細行
|
||||
const handleAddItem = () => {
|
||||
const defaultProduct = products.length > 0 ? products[0] : { id: "", name: "", unit: "kg" };
|
||||
const newItem: InboundItem = {
|
||||
tempId: `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
productId: defaultProduct.id,
|
||||
productName: defaultProduct.name,
|
||||
quantity: 0,
|
||||
unit: defaultProduct.unit,
|
||||
};
|
||||
setItems([...items, newItem]);
|
||||
};
|
||||
|
||||
// 刪除明細行
|
||||
const handleRemoveItem = (tempId: string) => {
|
||||
setItems(items.filter((item) => item.tempId !== tempId));
|
||||
};
|
||||
|
||||
// 更新明細行
|
||||
const handleUpdateItem = (tempId: string, updates: Partial<InboundItem>) => {
|
||||
setItems(
|
||||
items.map((item) =>
|
||||
item.tempId === tempId ? { ...item, ...updates } : item
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// 處理商品變更
|
||||
const handleProductChange = (tempId: string, productId: string) => {
|
||||
const product = products.find((p) => p.id === productId);
|
||||
if (product) {
|
||||
handleUpdateItem(tempId, {
|
||||
productId,
|
||||
productName: product.name,
|
||||
unit: product.unit,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 驗證表單
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!reason) {
|
||||
newErrors.reason = "請選擇入庫原因";
|
||||
}
|
||||
|
||||
if (reason === "其他" && !notes.trim()) {
|
||||
newErrors.notes = "原因為「其他」時,備註為必填";
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
newErrors.items = "請至少新增一筆庫存明細";
|
||||
}
|
||||
|
||||
items.forEach((item, index) => {
|
||||
if (!item.productId) {
|
||||
newErrors[`item-${index}-product`] = "請選擇商品";
|
||||
}
|
||||
if (item.quantity <= 0) {
|
||||
newErrors[`item-${index}-quantity`] = "數量必須大於 0";
|
||||
}
|
||||
});
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
// 處理儲存
|
||||
const handleSave = () => {
|
||||
if (!validateForm()) {
|
||||
toast.error("請檢查表單內容");
|
||||
return;
|
||||
}
|
||||
|
||||
router.post(`/warehouses/${warehouse.id}/inventory`, {
|
||||
inboundDate,
|
||||
reason,
|
||||
notes,
|
||||
items: items.map(item => ({
|
||||
productId: item.productId,
|
||||
quantity: item.quantity
|
||||
}))
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success("庫存記錄已儲存");
|
||||
router.get(`/warehouses/${warehouse.id}/inventory`);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error("儲存失敗,請檢查輸入內容");
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout>
|
||||
<Head title={`新增庫存 - ${warehouse.name}`} />
|
||||
<div className="container mx-auto p-6 max-w-7xl">
|
||||
{/* 頁面標題與導航 - 已於先前任務優化 */}
|
||||
<div className="mb-6">
|
||||
<div className="mb-6">
|
||||
<Link href={`/warehouses/${warehouse.id}/inventory`}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 button-outlined-primary"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回庫存管理
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="mb-2">新增庫存(手動入庫)</h1>
|
||||
<p className="text-gray-600 font-medium">
|
||||
為 <span className="font-semibold text-gray-900">{warehouse.name}</span> 新增庫存記錄
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
className="button-filled-primary"
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
儲存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 表單內容 */}
|
||||
<div className="space-y-6">
|
||||
{/* 基本資訊區塊 */}
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6 space-y-4">
|
||||
<h3 className="font-semibold text-lg border-b pb-2">基本資訊</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* 倉庫 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-700">倉庫</Label>
|
||||
<Input
|
||||
value={warehouse.name}
|
||||
disabled
|
||||
className="bg-gray-50 border-gray-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 入庫日期 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="inbound-date" className="text-gray-700">
|
||||
入庫日期 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="inbound-date"
|
||||
type="datetime-local"
|
||||
value={inboundDate}
|
||||
onChange={(e) => setInboundDate(e.target.value)}
|
||||
className="border-gray-300 pr-10"
|
||||
/>
|
||||
<Calendar className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 入庫原因 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reason" className="text-gray-700">
|
||||
入庫原因 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select value={reason} onValueChange={(value) => setReason(value as InboundReason)}>
|
||||
<SelectTrigger id="reason" className="border-gray-300">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{INBOUND_REASONS.map((r) => (
|
||||
<SelectItem key={r} value={r}>
|
||||
{r}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.reason && (
|
||||
<p className="text-sm text-red-500">{errors.reason}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 備註 */}
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label htmlFor="notes" className="text-gray-700">
|
||||
備註 {reason === "其他" && <span className="text-red-500">*</span>}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="請輸入備註說明..."
|
||||
className="border-gray-300 resize-none min-h-[100px]"
|
||||
/>
|
||||
{errors.notes && (
|
||||
<p className="text-sm text-red-500">{errors.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 庫存明細區塊 */}
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">庫存明細</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
請新增要入庫的商品明細
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleAddItem}
|
||||
variant="outline"
|
||||
className="button-outlined-primary"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
新增明細
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{errors.items && (
|
||||
<p className="text-sm text-red-500">{errors.items}</p>
|
||||
)}
|
||||
|
||||
{items.length > 0 ? (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50/50">
|
||||
<TableHead className="w-[280px]">
|
||||
商品 <span className="text-red-500">*</span>
|
||||
</TableHead>
|
||||
<TableHead className="w-[120px]">
|
||||
數量 <span className="text-red-500">*</span>
|
||||
</TableHead>
|
||||
<TableHead className="w-[100px]">單位</TableHead>
|
||||
{/* <TableHead className="w-[180px]">效期</TableHead>
|
||||
<TableHead className="w-[220px]">進貨編號</TableHead> */}
|
||||
<TableHead className="w-[60px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item, index) => (
|
||||
<TableRow key={item.tempId}>
|
||||
{/* 商品 */}
|
||||
<TableCell>
|
||||
<Select
|
||||
value={item.productId}
|
||||
onValueChange={(value) =>
|
||||
handleProductChange(item.tempId, value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="border-gray-300">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{products.map((product) => (
|
||||
<SelectItem key={product.id} value={product.id}>
|
||||
{product.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors[`item-${index}-product`] && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors[`item-${index}-product`]}
|
||||
</p>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
{/* 數量 */}
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
value={item.quantity || ""}
|
||||
onChange={(e) =>
|
||||
handleUpdateItem(item.tempId, {
|
||||
quantity: parseInt(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
className="border-gray-300"
|
||||
/>
|
||||
{errors[`item-${index}-quantity`] && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors[`item-${index}-quantity`]}
|
||||
</p>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
{/* 單位 */}
|
||||
<TableCell>
|
||||
<Input
|
||||
value={item.unit}
|
||||
disabled
|
||||
className="bg-gray-50 border-gray-200"
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
{/* 效期 */}
|
||||
{/* <TableCell>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="date"
|
||||
value={item.expiryDate}
|
||||
onChange={(e) =>
|
||||
handleUpdateItem(item.tempId, {
|
||||
expiryDate: e.target.value,
|
||||
})
|
||||
}
|
||||
className="border-gray-300"
|
||||
/>
|
||||
</div>
|
||||
</TableCell> */}
|
||||
|
||||
{/* 批號 */}
|
||||
{/* <TableCell>
|
||||
<Input
|
||||
value={item.batchNumber}
|
||||
onChange={(e) =>
|
||||
handleBatchNumberChange(item.tempId, e.target.value)
|
||||
}
|
||||
className="border-gray-300"
|
||||
placeholder="系統自動生成"
|
||||
/>
|
||||
{errors[`item-${index}-batch`] && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors[`item-${index}-batch`]}
|
||||
</p>
|
||||
)}
|
||||
</TableCell> */}
|
||||
|
||||
{/* 刪除按鈕 */}
|
||||
<TableCell>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveItem(item.tempId)}
|
||||
className="hover:bg-red-50 hover:text-red-600 h-8 w-8"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border border-dashed rounded-lg p-12 text-center text-gray-500 bg-gray-50/30">
|
||||
<p className="text-base font-medium">尚無明細</p>
|
||||
<p className="text-sm mt-1">請點擊右上方「新增明細」按鈕加入商品</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
269
resources/js/Pages/Warehouse/EditInventory.tsx
Normal file
269
resources/js/Pages/Warehouse/EditInventory.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { Head, Link, useForm } from "@inertiajs/react";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import { ArrowLeft, Save, Trash2 } from "lucide-react";
|
||||
import { Warehouse, WarehouseInventory } from "@/types/warehouse";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/Components/ui/alert-dialog";
|
||||
import TransactionTable, { Transaction } from "@/Components/Warehouse/Inventory/TransactionTable";
|
||||
|
||||
|
||||
interface Props {
|
||||
warehouse: Warehouse;
|
||||
inventory: WarehouseInventory;
|
||||
transactions: Transaction[];
|
||||
}
|
||||
|
||||
export default function EditInventory({ warehouse, inventory, transactions = [] }: Props) {
|
||||
const { data, setData, put, delete: destroy, processing, errors } = useForm({
|
||||
quantity: inventory.quantity,
|
||||
batchNumber: inventory.batchNumber || "",
|
||||
expiryDate: inventory.expiryDate || "",
|
||||
lastInboundDate: inventory.lastInboundDate || "",
|
||||
lastOutboundDate: inventory.lastOutboundDate || "",
|
||||
// 為了記錄異動原因,還是需要傳這兩個欄位,雖然 UI 上原本的 EditPage 沒有原因輸入框
|
||||
// 但為了符合我們後端的交易紀錄邏輯,我們可能需要預設一個,或者偷加一個欄位?
|
||||
// 原 source code 沒有原因欄位。
|
||||
// 我們可以預設 reason 為 "手動編輯更新"
|
||||
reason: "編輯頁面手動更新",
|
||||
});
|
||||
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
|
||||
const handleSave = () => {
|
||||
if (data.quantity < 0) {
|
||||
toast.error("庫存數量不可為負數");
|
||||
return;
|
||||
}
|
||||
|
||||
put(route("warehouses.inventory.update", { warehouse: warehouse.id, inventory: inventory.id }), {
|
||||
onSuccess: () => {
|
||||
toast.success("庫存資料已更新");
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("更新失敗,請檢查欄位");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
destroy(route("warehouses.inventory.destroy", { warehouse: warehouse.id, inventory: inventory.id }), {
|
||||
onSuccess: () => {
|
||||
toast.success("庫存品項已刪除");
|
||||
setShowDeleteDialog(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("刪除失敗");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout>
|
||||
<Head title={`編輯庫存 - ${inventory.productName} `} />
|
||||
<div className="container mx-auto p-6 max-w-4xl">
|
||||
{/* 頁面標題與麵包屑 */}
|
||||
<div className="mb-6">
|
||||
<Link href={`/warehouses/${warehouse.id}/inventory`}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 button-outlined-primary mb-6"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回庫存管理
|
||||
</Button>
|
||||
</Link >
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 mb-2">
|
||||
<span>商品與庫存管理</span>
|
||||
<span>/</span>
|
||||
<span>倉庫管理</span>
|
||||
<span>/</span>
|
||||
<span>庫存管理</span>
|
||||
<span>/</span>
|
||||
<span className="text-gray-900">編輯庫存品項</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="mb-2">編輯庫存品項</h1>
|
||||
<p className="text-gray-600">
|
||||
倉庫:<span className="font-medium text-gray-900">{warehouse.name}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
variant="outline"
|
||||
className="group border-red-200 text-red-600 hover:bg-red-50 hover:text-red-700 hover:border-red-300"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
刪除品項
|
||||
</Button>
|
||||
<Button onClick={handleSave} className="button-filled-primary" disabled={processing}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
儲存變更
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div >
|
||||
|
||||
{/* 表單內容 */}
|
||||
< div className="bg-white rounded-lg shadow-sm border p-6 mb-6" >
|
||||
<div className="space-y-6">
|
||||
{/* 商品基本資訊 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-medium border-b pb-2 text-lg">商品基本資訊</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="productName">
|
||||
商品名稱 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="productName"
|
||||
value={inventory.productName}
|
||||
disabled
|
||||
className="bg-gray-100"
|
||||
/>
|
||||
<p className="text-sm text-gray-500">
|
||||
商品名稱無法修改
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="batchNumber">批號</Label>
|
||||
<Input
|
||||
id="batchNumber"
|
||||
type="text"
|
||||
value={data.batchNumber}
|
||||
onChange={(e) => setData("batchNumber", e.target.value)}
|
||||
placeholder="例:FL20251101"
|
||||
className="button-outlined-primary"
|
||||
// 目前後端可能尚未支援儲存,但依需求顯示
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 庫存數量 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-medium border-b pb-2 text-lg">庫存數量</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="quantity">
|
||||
庫存數量 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="quantity"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={data.quantity}
|
||||
onChange={(e) =>
|
||||
setData("quantity", parseFloat(e.target.value) || 0)
|
||||
}
|
||||
placeholder="0"
|
||||
className={`button-outlined-primary ${errors.quantity ? "border-red-500" : ""}`}
|
||||
/>
|
||||
{errors.quantity && <p className="text-xs text-red-500">{errors.quantity}</p>}
|
||||
<p className="text-sm text-gray-500">
|
||||
批號層級的庫存數量,安全庫存請至「安全庫存設定」頁面進行商品層級設定
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 日期資訊 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-medium border-b pb-2 text-lg">日期資訊</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="expiryDate">保存期限</Label>
|
||||
<Input
|
||||
id="expiryDate"
|
||||
type="date"
|
||||
value={data.expiryDate}
|
||||
onChange={(e) => setData("expiryDate", e.target.value)}
|
||||
className="button-outlined-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lastInboundDate">最新入庫時間</Label>
|
||||
<Input
|
||||
id="lastInboundDate"
|
||||
type="date"
|
||||
value={data.lastInboundDate}
|
||||
onChange={(e) =>
|
||||
setData("lastInboundDate", e.target.value)
|
||||
}
|
||||
className="button-outlined-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lastOutboundDate">最新出庫時間</Label>
|
||||
<Input
|
||||
id="lastOutboundDate"
|
||||
type="date"
|
||||
value={data.lastOutboundDate}
|
||||
onChange={(e) =>
|
||||
setData("lastOutboundDate", e.target.value)
|
||||
}
|
||||
className="button-outlined-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div >
|
||||
|
||||
{/* 庫存異動紀錄 */}
|
||||
< div className="bg-white rounded-lg shadow-sm border p-6" >
|
||||
<h3 className="font-medium text-lg border-b pb-4 mb-4">庫存異動紀錄</h3>
|
||||
|
||||
<TransactionTable transactions={transactions} />
|
||||
</div >
|
||||
|
||||
{/* 刪除確認對話框 */}
|
||||
< AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog} >
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>確認刪除庫存品項</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
您確定要刪除「{inventory.productName}」的此筆庫存嗎?此操作無法復原。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel className="button-outlined-primary">
|
||||
取消
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
className="bg-red-600 text-white hover:bg-red-700"
|
||||
>
|
||||
確認刪除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog >
|
||||
</div >
|
||||
</AuthenticatedLayout >
|
||||
);
|
||||
}
|
||||
192
resources/js/Pages/Warehouse/Index.tsx
Normal file
192
resources/js/Pages/Warehouse/Index.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { useState } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import { Head, router } from "@inertiajs/react";
|
||||
import WarehouseDialog from "@/Components/Warehouse/WarehouseDialog";
|
||||
import TransferOrderDialog from "@/Components/Warehouse/TransferOrderDialog";
|
||||
import SearchToolbar from "@/Components/shared/SearchToolbar";
|
||||
import WarehouseCard from "@/Components/Warehouse/WarehouseCard";
|
||||
import WarehouseEmptyState from "@/Components/Warehouse/WarehouseEmptyState";
|
||||
import { Warehouse } from "@/types/warehouse";
|
||||
import Pagination from "@/Components/shared/Pagination";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface PageProps {
|
||||
warehouses: {
|
||||
data: Warehouse[];
|
||||
links: any[];
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
total: number;
|
||||
};
|
||||
filters: {
|
||||
search?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function WarehouseIndex({ warehouses, filters }: PageProps) {
|
||||
// 篩選狀態
|
||||
const [searchTerm, setSearchTerm] = useState(filters.search || "");
|
||||
|
||||
// 對話框狀態
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [editingWarehouse, setEditingWarehouse] = useState<Warehouse | null>(null);
|
||||
const [transferOrderDialogOpen, setTransferOrderDialogOpen] = useState(false);
|
||||
|
||||
// 暫時的 Mock Inventories,直到後端 API 實作
|
||||
|
||||
|
||||
// 搜尋處理
|
||||
const handleSearch = (term: string) => {
|
||||
setSearchTerm(term);
|
||||
router.get(route('warehouses.index'), { search: term }, {
|
||||
preserveState: true,
|
||||
preserveScroll: true,
|
||||
replace: true,
|
||||
});
|
||||
};
|
||||
|
||||
// 導航處理
|
||||
const handleViewInventory = (warehouseId: string) => {
|
||||
router.get(`/warehouses/${warehouseId}/inventory`);
|
||||
};
|
||||
|
||||
// 倉庫操作處理函式
|
||||
const handleAddWarehouse = () => {
|
||||
setEditingWarehouse(null);
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleEditWarehouse = (warehouse: Warehouse) => {
|
||||
setEditingWarehouse(warehouse);
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
// 接收 Dialog 回傳的資料並呼叫後端
|
||||
const handleSaveWarehouse = (data: Partial<Warehouse>) => {
|
||||
if (editingWarehouse) {
|
||||
router.put(route('warehouses.update', editingWarehouse.id), data, {
|
||||
onSuccess: () => setIsDialogOpen(false),
|
||||
});
|
||||
} else {
|
||||
router.post(route('warehouses.store'), data, {
|
||||
onSuccess: () => setIsDialogOpen(false),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteWarehouse = (id: string) => {
|
||||
if (confirm("確定要停用此倉庫嗎?\n注意:刪除倉庫將連帶刪除所有庫存與紀錄!")) {
|
||||
router.delete(route('warehouses.destroy', id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddTransferOrder = () => {
|
||||
setTransferOrderDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveTransferOrder = (data: any) => {
|
||||
router.post(route('transfer-orders.store'), data, {
|
||||
onSuccess: () => {
|
||||
toast.success('撥補單已建立且庫存已轉移');
|
||||
setTransferOrderDialogOpen(false);
|
||||
},
|
||||
onError: (errors) => {
|
||||
toast.error('建立撥補單失敗');
|
||||
console.error(errors);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout>
|
||||
<Head title="倉庫管理" />
|
||||
<div className="container mx-auto p-6 max-w-7xl">
|
||||
{/* 頁面標題 */}
|
||||
<div className="mb-6">
|
||||
<h1 className="mb-2">倉庫管理</h1>
|
||||
<p className="text-gray-600 font-medium mb-4">管理倉庫資訊與庫存配置</p>
|
||||
</div>
|
||||
|
||||
{/* 工具列 */}
|
||||
<div className="bg-white rounded-lg shadow-sm border p-4 mb-6">
|
||||
<div className="flex flex-col md:flex-row gap-4 items-start md:items-center justify-between">
|
||||
<div className="flex flex-col sm:flex-row gap-4 flex-1 w-full">
|
||||
{/* 搜尋框 */}
|
||||
<SearchToolbar
|
||||
value={searchTerm}
|
||||
onChange={handleSearch}
|
||||
placeholder="搜尋倉庫名稱..."
|
||||
className="flex-1 w-full md:max-w-md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 操作按鈕 */}
|
||||
<div className="flex gap-2 w-full md:w-auto">
|
||||
<Button
|
||||
onClick={handleAddTransferOrder}
|
||||
className="flex-1 md:flex-initial button-outlined-primary"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
新增撥補單
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAddWarehouse}
|
||||
className="flex-1 md:flex-initial button-filled-primary"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
新增倉庫
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 倉庫卡片列表 */}
|
||||
{warehouses.data.length === 0 ? (
|
||||
<WarehouseEmptyState />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{warehouses.data.map((warehouse) => (
|
||||
<WarehouseCard
|
||||
key={warehouse.id}
|
||||
warehouse={warehouse}
|
||||
stats={{
|
||||
totalQuantity: warehouse.total_quantity || 0,
|
||||
lowStockCount: warehouse.low_stock_count || 0,
|
||||
replenishmentNeeded: warehouse.low_stock_count || 0
|
||||
}}
|
||||
hasWarning={(warehouse.low_stock_count || 0) > 0}
|
||||
onViewInventory={() => handleViewInventory(warehouse.id)}
|
||||
onEdit={handleEditWarehouse}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分頁 */}
|
||||
<div className="mt-6">
|
||||
<Pagination links={warehouses.links} />
|
||||
</div>
|
||||
|
||||
{/* 倉庫對話框 */}
|
||||
<WarehouseDialog
|
||||
open={isDialogOpen}
|
||||
onOpenChange={setIsDialogOpen}
|
||||
warehouse={editingWarehouse}
|
||||
onSave={handleSaveWarehouse}
|
||||
onDelete={handleDeleteWarehouse}
|
||||
/>
|
||||
|
||||
{/* 撥補單對話框 */}
|
||||
<TransferOrderDialog
|
||||
open={transferOrderDialogOpen}
|
||||
onOpenChange={setTransferOrderDialogOpen}
|
||||
order={null}
|
||||
onSave={handleSaveTransferOrder}
|
||||
warehouses={warehouses.data}
|
||||
/>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
187
resources/js/Pages/Warehouse/Inventory.tsx
Normal file
187
resources/js/Pages/Warehouse/Inventory.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { ArrowLeft, PackagePlus, AlertTriangle, Shield } from "lucide-react";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import { Head, Link, router } from "@inertiajs/react";
|
||||
import { Warehouse, WarehouseInventory, SafetyStockSetting, Product } from "@/types/warehouse";
|
||||
import InventoryToolbar from "@/Components/Warehouse/Inventory/InventoryToolbar";
|
||||
import InventoryTable from "@/Components/Warehouse/Inventory/InventoryTable";
|
||||
import { calculateLowStockCount } from "@/utils/inventory";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/Components/ui/alert-dialog";
|
||||
|
||||
// 庫存頁面 Props
|
||||
interface Props {
|
||||
warehouse: Warehouse;
|
||||
inventories: WarehouseInventory[];
|
||||
safetyStockSettings: SafetyStockSetting[];
|
||||
availableProducts: Product[];
|
||||
}
|
||||
|
||||
export default function WarehouseInventoryPage({
|
||||
warehouse,
|
||||
inventories,
|
||||
safetyStockSettings,
|
||||
availableProducts,
|
||||
}: Props) {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState<string>("all");
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
|
||||
// 篩選庫存列表
|
||||
const filteredInventories = useMemo(() => {
|
||||
return inventories.filter((item) => {
|
||||
// 搜尋條件:匹配商品名稱、編號或批號
|
||||
const matchesSearch = !searchTerm ||
|
||||
item.productName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(item.productCode && item.productCode.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
||||
item.batchNumber.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
// 類型篩選 (需要比對 availableProducts 找到類型)
|
||||
let matchesType = true;
|
||||
if (typeFilter !== "all") {
|
||||
const product = availableProducts.find((p) => p.id === item.productId);
|
||||
matchesType = product?.type === typeFilter;
|
||||
}
|
||||
|
||||
return matchesSearch && matchesType;
|
||||
});
|
||||
}, [inventories, searchTerm, typeFilter, availableProducts]);
|
||||
|
||||
// 計算統計資訊
|
||||
const lowStockItems = calculateLowStockCount(inventories, warehouse.id, safetyStockSettings);
|
||||
|
||||
// 導航至流動紀錄頁
|
||||
const handleView = (inventoryId: string) => {
|
||||
router.visit(route('warehouses.inventory.history', { warehouse: warehouse.id, inventory: inventoryId }));
|
||||
};
|
||||
|
||||
|
||||
const confirmDelete = (inventoryId: string) => {
|
||||
setDeleteId(inventoryId);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!deleteId) return;
|
||||
|
||||
router.delete(route("warehouses.inventory.destroy", { warehouse: warehouse.id, inventory: deleteId }), {
|
||||
onSuccess: () => {
|
||||
toast.success("庫存記錄已刪除");
|
||||
setDeleteId(null);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("刪除失敗");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout>
|
||||
<Head title={`庫存管理 - ${warehouse.name}`} />
|
||||
<div className="container mx-auto p-6 max-w-7xl">
|
||||
{/* 頁面標題與導航 */}
|
||||
<div className="mb-6">
|
||||
<Link href="/warehouses">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 button-outlined-primary mb-6"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回倉庫管理
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="mb-2">庫存管理 - {warehouse.name}</h1>
|
||||
<p className="text-gray-600 font-medium">查看並管理此倉庫內的商品庫存數量與批號資訊</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按鈕 (位於標題下方) */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
{/* 安全庫存設定按鈕 */}
|
||||
<Link href={`/warehouses/${warehouse.id}/safety-stock-settings`}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="button-outlined-primary"
|
||||
>
|
||||
<Shield className="mr-2 h-4 w-4" />
|
||||
安全庫存設定
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{/* 庫存警告顯示 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className={`button-outlined-primary cursor-default hover:bg-transparent ${lowStockItems > 0
|
||||
? "border-orange-500 text-orange-600"
|
||||
: "border-green-500 text-green-600"
|
||||
}`}
|
||||
>
|
||||
<AlertTriangle className="mr-2 h-4 w-4" />
|
||||
庫存警告:{lowStockItems} 項
|
||||
</Button>
|
||||
|
||||
{/* 新增庫存按鈕 */}
|
||||
<Link href={`/warehouses/${warehouse.id}/add-inventory`}>
|
||||
<Button
|
||||
className="button-filled-primary"
|
||||
>
|
||||
<PackagePlus className="mr-2 h-4 w-4" />
|
||||
新增庫存
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 篩選工具列 */}
|
||||
<div className="mb-6 bg-white rounded-lg shadow-sm border p-4">
|
||||
<InventoryToolbar
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
typeFilter={typeFilter}
|
||||
onTypeFilterChange={setTypeFilter}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 庫存表格 */}
|
||||
<div className="bg-white rounded-lg shadow-sm border overflow-hidden">
|
||||
<InventoryTable
|
||||
inventories={filteredInventories}
|
||||
onView={handleView}
|
||||
onDelete={confirmDelete}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
{/* 刪除確認對話框 */}
|
||||
<AlertDialog open={!!deleteId} onOpenChange={(open) => !open && setDeleteId(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>確認刪除庫存項目</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
您確定要刪除此筆庫存項目嗎?此操作將會清空該項目的數量並保留刪除紀錄。此動作無法復原。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel className="button-outlined-primary">取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} className="bg-red-600 hover:bg-red-700 text-white">
|
||||
確認刪除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
69
resources/js/Pages/Warehouse/InventoryHistory.tsx
Normal file
69
resources/js/Pages/Warehouse/InventoryHistory.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Head, Link } from "@inertiajs/react";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Warehouse } from "@/types/warehouse";
|
||||
import TransactionTable, { Transaction } from "@/Components/Warehouse/Inventory/TransactionTable";
|
||||
|
||||
interface Props {
|
||||
warehouse: Warehouse;
|
||||
inventory: {
|
||||
id: string;
|
||||
productName: string;
|
||||
productCode: string;
|
||||
quantity: number;
|
||||
};
|
||||
transactions: Transaction[];
|
||||
}
|
||||
|
||||
export default function InventoryHistory({ warehouse, inventory, transactions }: Props) {
|
||||
return (
|
||||
<AuthenticatedLayout>
|
||||
<Head title={`庫存異動紀錄 - ${inventory.productName}`} />
|
||||
<div className="container mx-auto p-6 max-w-4xl">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<Link href={`/warehouses/${warehouse.id}/inventory`}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 button-outlined-primary mb-6"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回庫存管理
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 mb-2">
|
||||
<span>倉庫管理</span>
|
||||
<span>/</span>
|
||||
<span>庫存管理</span>
|
||||
<span>/</span>
|
||||
<span className="text-gray-900">庫存異動紀錄</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="mb-2">庫存異動紀錄</h1>
|
||||
<p className="text-gray-600">
|
||||
商品:<span className="font-medium text-gray-900">{inventory.productName}</span>
|
||||
{inventory.productCode && <span className="text-gray-500 ml-2">({inventory.productCode})</span>}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6">
|
||||
<div className="flex justify-between items-center mb-4 border-b pb-4">
|
||||
<h3 className="font-medium text-lg">異動流水帳</h3>
|
||||
<div className="text-sm text-gray-500">
|
||||
目前庫存:<span className="font-medium text-gray-900">{inventory.quantity}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TransactionTable transactions={transactions} />
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
148
resources/js/Pages/Warehouse/SafetyStockSettings.tsx
Normal file
148
resources/js/Pages/Warehouse/SafetyStockSettings.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* 安全庫存設定頁面
|
||||
* Last Updated: 2025-12-29
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { ArrowLeft, Plus } from "lucide-react";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import { Head, Link, router } from "@inertiajs/react";
|
||||
import { SafetyStockSetting, WarehouseInventory, Warehouse, Product } from "@/types/warehouse";
|
||||
import SafetyStockList from "@/Components/Warehouse/SafetyStock/SafetyStockList";
|
||||
import AddSafetyStockDialog from "@/Components/Warehouse/SafetyStock/AddSafetyStockDialog";
|
||||
import EditSafetyStockDialog from "@/Components/Warehouse/SafetyStock/EditSafetyStockDialog";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
warehouse: Warehouse;
|
||||
safetyStockSettings: SafetyStockSetting[];
|
||||
inventories: WarehouseInventory[];
|
||||
availableProducts: Product[];
|
||||
}
|
||||
|
||||
export default function SafetyStockPage({
|
||||
warehouse,
|
||||
safetyStockSettings: initialSettings = [],
|
||||
inventories = [],
|
||||
availableProducts = [],
|
||||
}: Props) {
|
||||
const [settings, setSettings] = useState<SafetyStockSetting[]>(initialSettings);
|
||||
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||
const [editingSetting, setEditingSetting] = useState<SafetyStockSetting | null>(null);
|
||||
|
||||
|
||||
// 當 Props 更新時同步本地 State
|
||||
useEffect(() => {
|
||||
setSettings(initialSettings);
|
||||
}, [initialSettings]);
|
||||
|
||||
const handleAdd = (newSettings: SafetyStockSetting[]) => {
|
||||
router.post(route('warehouses.safety-stock.store', warehouse.id), {
|
||||
settings: newSettings.map(s => ({
|
||||
productId: s.productId,
|
||||
quantity: s.safetyStock
|
||||
})),
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
setShowAddDialog(false);
|
||||
toast.success(`成功新增 ${newSettings.length} 項安全庫存設定`);
|
||||
},
|
||||
onError: (errors) => {
|
||||
const firstError = Object.values(errors)[0];
|
||||
toast.error(typeof firstError === 'string' ? firstError : "新增失敗");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleEdit = (updatedSetting: SafetyStockSetting) => {
|
||||
router.put(route('warehouses.safety-stock.update', [warehouse.id, updatedSetting.id]), {
|
||||
safetyStock: updatedSetting.safetyStock,
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
setEditingSetting(null);
|
||||
toast.success(`成功更新 ${updatedSetting.productName} 的安全庫存`);
|
||||
},
|
||||
onError: (errors) => {
|
||||
const firstError = Object.values(errors)[0];
|
||||
toast.error(typeof firstError === 'string' ? firstError : "更新失敗");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
router.delete(route('warehouses.safety-stock.destroy', [warehouse.id, id]), {
|
||||
onSuccess: () => {
|
||||
toast.success("已刪除安全庫存設定");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (!warehouse) {
|
||||
return <div className="p-8 text-center text-muted-foreground">正在載入倉庫資料...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout>
|
||||
<Head title={`安全庫存設定 - ${warehouse.name}`} />
|
||||
<div className="container mx-auto p-6 max-w-7xl">
|
||||
{/* 頁面標題與導航 */}
|
||||
<div className="mb-6">
|
||||
<Link href={route('warehouses.inventory.index', warehouse.id)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 button-outlined-primary mb-6"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回庫存管理
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="mb-2">安全庫存設定 - {warehouse.name}</h1>
|
||||
<p className="text-gray-600 font-medium">
|
||||
設定商品的安全庫存量,當庫存低於安全值時將發出警告
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setShowAddDialog(true)}
|
||||
className="button-filled-primary"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
新增安全庫存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 安全庫存列表 */}
|
||||
<SafetyStockList
|
||||
settings={settings}
|
||||
inventories={inventories}
|
||||
onEdit={setEditingSetting}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
|
||||
{/* 新增對話框 */}
|
||||
<AddSafetyStockDialog
|
||||
open={showAddDialog}
|
||||
onOpenChange={setShowAddDialog}
|
||||
warehouseId={warehouse.id}
|
||||
existingSettings={settings}
|
||||
availableProducts={availableProducts}
|
||||
onAdd={handleAdd}
|
||||
/>
|
||||
|
||||
{/* 編輯對話框 */}
|
||||
{editingSetting && (
|
||||
<EditSafetyStockDialog
|
||||
open={!!editingSetting}
|
||||
onOpenChange={(open) => !open && setEditingSetting(null)}
|
||||
setting={editingSetting}
|
||||
onSave={handleEdit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
18
resources/js/Pages/Welcome.tsx
Normal file
18
resources/js/Pages/Welcome.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
import { Head } from '@inertiajs/react';
|
||||
|
||||
export default function Welcome() {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-100">
|
||||
<Head title="Welcome" />
|
||||
<div className="p-8 bg-white rounded-lg shadow-lg">
|
||||
<h1 className="text-4xl font-bold text-blue-600">
|
||||
Koori ERP
|
||||
</h1>
|
||||
<p className="mt-4 text-gray-600">
|
||||
React + Inertia + Laravel Integration Successful!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user