first commit

This commit is contained in:
2025-12-30 15:03:19 +08:00
commit c735c36009
902 changed files with 83591 additions and 0 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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
View 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>
);
}

View 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>
);
}

View 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 >
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}