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,47 @@
import { useEffect, useRef } from "react";
import JsBarcode from "jsbarcode";
interface BarcodeDisplayProps {
value: string;
width?: number;
height?: number;
displayValue?: boolean;
className?: string;
}
export default function BarcodeDisplay({
value,
width = 2,
height = 50,
displayValue = true,
className = "",
}: BarcodeDisplayProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
if (canvasRef.current && value) {
try {
JsBarcode(canvasRef.current, value, {
format: "CODE128",
width,
height,
displayValue,
fontSize: 14,
margin: 10,
});
} catch (error) {
console.error("Error generating barcode:", error);
}
}
}, [value, width, height, displayValue]);
if (!value) {
return (
<div className={`flex items-center justify-center bg-gray-100 rounded border-2 border-dashed border-gray-300 p-4 ${className}`}>
<p className="text-gray-400 text-sm"></p>
</div>
);
}
return <canvas ref={canvasRef} className={className} />;
}

View File

@@ -0,0 +1,158 @@
const barcodeSample = "/images/barcode-sample.png";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/Components/ui/dialog";
import { Button } from "@/Components/ui/button";
import { Download, Printer } from "lucide-react";
const barcodePlaceholder = "/images/barcode-placeholder.png";
interface BarcodeViewDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
productName: string;
productCode: string;
barcodeValue: string;
}
export default function BarcodeViewDialog({
open,
onOpenChange,
productName,
productCode,
barcodeValue,
}: BarcodeViewDialogProps) {
const handlePrint = () => {
const printWindow = window.open("", "_blank");
if (printWindow) {
printWindow.document.write(`
<!DOCTYPE html>
<html>
<head>
<title>列印條碼 - ${productName}</title>
<style>
body {
margin: 0;
padding: 20px;
font-family: 'Noto Sans TC', sans-serif;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.barcode-container {
text-align: center;
page-break-inside: avoid;
}
.product-info {
margin-bottom: 16px;
}
.product-name {
font-size: 18px;
font-weight: 600;
margin-bottom: 4px;
}
.product-code {
font-size: 14px;
color: #666;
font-family: monospace;
}
img {
max-width: 400px;
height: auto;
}
@media print {
body {
padding: 0;
}
@page {
margin: 20mm;
}
}
</style>
</head>
<body>
<div class="barcode-container">
<div class="product-info">
<div class="product-name">${productName}</div>
<div class="product-code">商品編號: ${productCode}</div>
</div>
<img src="${barcodePlaceholder}" alt="商品條碼" />
</div>
</body>
</html>
`);
printWindow.document.close();
setTimeout(() => {
printWindow.print();
}, 250);
}
};
const handleDownload = () => {
const link = document.createElement("a");
link.href = barcodePlaceholder;
link.download = `${productCode}_barcode.png`;
link.target = "_blank";
link.click();
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
{productName} ({productCode})
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
{/* 條碼顯示區 */}
<div className="flex items-center justify-center bg-white border-2 border-gray-200 rounded-lg p-6">
<img
src={barcodeSample}
alt="商品條碼"
className="max-w-full h-auto"
/>
</div>
{/* 條碼資訊 */}
<div className="bg-gray-50 rounded-lg p-4 space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-600"></span>
<span className="text-sm font-mono font-semibold">{barcodeValue}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-gray-600"></span>
<span className="text-sm font-semibold">Code128</span>
</div>
</div>
{/* 操作按鈕 */}
<div className="flex gap-3">
<Button
onClick={handlePrint}
className="flex-1 button-outlined-primary"
variant="outline"
>
<Printer className="mr-2 h-4 w-4" />
</Button>
<Button
onClick={handleDownload}
className="flex-1 button-outlined-primary"
variant="outline"
>
<Download className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,275 @@
import { useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/Components/ui/dialog";
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 { useForm } from "@inertiajs/react";
import { toast } from "sonner";
import type { Product, Category } from "@/Pages/Product/Index";
interface ProductDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
product: Product | null;
categories: Category[];
onSave?: (product: any) => void; // Legacy prop, can be removed if fully switching to Inertia submit within dialog
}
export default function ProductDialog({
open,
onOpenChange,
product,
categories,
}: ProductDialogProps) {
const { data, setData, post, put, processing, errors, reset, clearErrors } = useForm({
name: "",
category_id: "",
brand: "",
specification: "",
base_unit: "kg",
large_unit: "",
conversion_rate: "",
purchase_unit: "",
});
useEffect(() => {
if (open) {
clearErrors();
if (product) {
setData({
name: product.name,
category_id: product.category_id.toString(),
brand: product.brand || "",
specification: product.specification || "",
base_unit: product.base_unit,
large_unit: product.large_unit || "",
conversion_rate: product.conversion_rate ? product.conversion_rate.toString() : "",
purchase_unit: product.purchase_unit || "",
});
} else {
reset();
// Set default category if available
if (categories.length > 0) {
setData("category_id", categories[0].id.toString());
}
}
}
}, [open, product, categories]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (product) {
put(route("products.update", product.id), {
onSuccess: () => {
toast.success("商品已更新");
onOpenChange(false);
reset();
},
onError: () => {
toast.error("更新失敗,請檢查輸入資料");
}
});
} else {
post(route("products.store"), {
onSuccess: () => {
toast.success("商品已新增");
onOpenChange(false);
reset();
},
onError: () => {
toast.error("新增失敗,請檢查輸入資料");
}
});
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{product ? "編輯商品" : "新增商品"}</DialogTitle>
<DialogDescription>
{product ? "修改商品資料" : "建立新的商品資料"}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-6 py-4">
{/* 基本資訊區塊 */}
<div className="space-y-4">
<h3 className="text-lg font-medium border-b pb-2"></h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="category_id">
<span className="text-red-500">*</span>
</Label>
<Select
value={data.category_id}
onValueChange={(value) => setData("category_id", value)}
>
<SelectTrigger id="category_id" className={errors.category_id ? "border-red-500" : ""}>
<SelectValue placeholder="選擇分類" />
</SelectTrigger>
<SelectContent>
{categories.map((category) => (
<SelectItem key={category.id} value={category.id.toString()}>
{category.name}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.category_id && <p className="text-sm text-red-500">{errors.category_id}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="name">
<span className="text-red-500">*</span>
</Label>
<Input
id="name"
value={data.name}
onChange={(e) => setData("name", e.target.value)}
placeholder="例:法國麵粉"
className={errors.name ? "border-red-500" : ""}
/>
{errors.name && <p className="text-sm text-red-500">{errors.name}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="brand"></Label>
<Input
id="brand"
value={data.brand}
onChange={(e) => setData("brand", e.target.value)}
placeholder="例:鳥越製粉"
/>
{errors.brand && <p className="text-sm text-red-500">{errors.brand}</p>}
</div>
<div className="space-y-2 col-span-2">
<Label htmlFor="specification"></Label>
<Textarea
id="specification"
value={data.specification}
onChange={(e) => setData("specification", e.target.value)}
placeholder="例25kg/袋灰分0.45%"
className="resize-none"
/>
{errors.specification && <p className="text-sm text-red-500">{errors.specification}</p>}
</div>
</div>
</div>
{/* 單位設定區塊 */}
<div className="space-y-4">
<h3 className="text-lg font-medium border-b pb-2"></h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="base_unit">
<span className="text-red-500">*</span>
</Label>
<Select
value={data.base_unit}
onValueChange={(value) => setData("base_unit", value)}
>
<SelectTrigger id="base_unit" className={errors.base_unit ? "border-red-500" : ""}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="kg"> (kg)</SelectItem>
<SelectItem value="g"> (g)</SelectItem>
<SelectItem value="l"> (l)</SelectItem>
<SelectItem value="ml"> (ml)</SelectItem>
<SelectItem value="個"></SelectItem>
<SelectItem value="支"></SelectItem>
<SelectItem value="包"></SelectItem>
<SelectItem value="罐"></SelectItem>
<SelectItem value="瓶"></SelectItem>
<SelectItem value="箱"></SelectItem>
<SelectItem value="袋"></SelectItem>
</SelectContent>
</Select>
{errors.base_unit && <p className="text-sm text-red-500">{errors.base_unit}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="large_unit"> ()</Label>
<Input
id="large_unit"
value={data.large_unit}
onChange={(e) => setData("large_unit", e.target.value)}
placeholder="例:箱、袋"
/>
{errors.large_unit && <p className="text-sm text-red-500">{errors.large_unit}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="conversion_rate">
{data.large_unit && <span className="text-red-500">*</span>}
</Label>
<Input
id="conversion_rate"
type="number"
step="0.0001"
value={data.conversion_rate}
onChange={(e) => setData("conversion_rate", e.target.value)}
placeholder={data.large_unit ? `1 ${data.large_unit} = ? ${data.base_unit}` : ""}
disabled={!data.large_unit}
/>
{errors.conversion_rate && <p className="text-sm text-red-500">{errors.conversion_rate}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="purchase_unit"></Label>
<Input
id="purchase_unit"
value={data.purchase_unit}
onChange={(e) => setData("purchase_unit", e.target.value)}
placeholder="通常同大單位"
/>
{errors.purchase_unit && <p className="text-sm text-red-500">{errors.purchase_unit}</p>}
</div>
</div>
{data.large_unit && data.base_unit && data.conversion_rate && (
<div className="bg-blue-50 p-3 rounded text-sm text-blue-700">
1 {data.large_unit} = {data.conversion_rate} {data.base_unit}
</div>
)}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
className="button-outlined-primary"
>
</Button>
<Button type="submit" className="button-filled-primary" disabled={processing}>
{processing ? "儲存... " : (product ? "儲存變更" : "新增")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,201 @@
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/Components/ui/table";
import { Button } from "@/Components/ui/button";
import { Badge } from "@/Components/ui/badge";
import { Pencil, Trash2, ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/Components/ui/alert-dialog";
import type { Product } from "@/Pages/Product/Index";
// import BarcodeViewDialog from "@/Components/Product/BarcodeViewDialog";
interface ProductTableProps {
products: Product[];
onEdit: (product: Product) => void;
onDelete: (id: number) => void;
startIndex: number;
sortField: string | null;
sortDirection: "asc" | "desc" | null;
onSort: (field: string) => void;
}
export default function ProductTable({
products,
onEdit,
onDelete,
startIndex,
sortField,
sortDirection,
onSort,
}: ProductTableProps) {
// const [barcodeDialogOpen, setBarcodeDialogOpen] = useState(false);
// const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
const SortIcon = ({ field }: { field: string }) => {
if (sortField !== field) {
return <ArrowUpDown className="h-4 w-4 text-muted-foreground ml-1" />;
}
if (sortDirection === "asc") {
return <ArrowUp className="h-4 w-4 text-primary ml-1" />;
}
if (sortDirection === "desc") {
return <ArrowDown className="h-4 w-4 text-primary ml-1" />;
}
return <ArrowUpDown className="h-4 w-4 text-muted-foreground ml-1" />;
};
// 查看條碼
/*
const handleViewBarcode = (product: Product) => {
setSelectedProduct(product);
setBarcodeDialogOpen(true);
};
*/
return (
<>
<div className="bg-white rounded-lg shadow-sm border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[50px] text-center">#</TableHead>
<TableHead>
<button onClick={() => onSort("code")} className="flex items-center hover:text-gray-900 font-semibold">
<SortIcon field="code" />
</button>
</TableHead>
<TableHead>
<button onClick={() => onSort("name")} className="flex items-center hover:text-gray-900 font-semibold">
<SortIcon field="name" />
</button>
</TableHead>
<TableHead>
<button onClick={() => onSort("category_id")} className="flex items-center hover:text-gray-900 font-semibold">
<SortIcon field="category_id" />
</button>
</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{products.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-gray-500">
</TableCell>
</TableRow>
) : (
products.map((product, index) => (
<TableRow key={product.id}>
<TableCell className="text-gray-500 font-medium text-center">
{startIndex + index}
</TableCell>
<TableCell className="font-mono text-sm text-gray-700">
{product.code}
</TableCell>
<TableCell>
<div className="flex flex-col">
<span className="font-medium">{product.name}</span>
{product.brand && <span className="text-xs text-gray-400">{product.brand}</span>}
</div>
</TableCell>
<TableCell>
<Badge variant="outline">
{product.category?.name || '-'}
</Badge>
</TableCell>
<TableCell>{product.base_unit}</TableCell>
<TableCell>
{product.large_unit ? (
<span className="text-sm text-gray-500">
1 {product.large_unit} = {Number(product.conversion_rate)} {product.base_unit}
</span>
) : (
'-'
)}
</TableCell>
<TableCell className="text-center">
<div className="flex justify-center gap-2">
{/*
<Button
variant="ghost"
size="sm"
onClick={() => handleViewBarcode(product)}
className="h-8 px-2 text-primary hover:text-primary-dark hover:bg-primary-lightest"
>
<Eye className="h-4 w-4" />
</Button>
*/}
<Button
variant="outline"
size="sm"
onClick={() => onEdit(product)}
className="button-outlined-primary"
>
<Pencil className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" className="button-outlined-error">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{product.name}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={() => onDelete(product.id)}
className="bg-red-600 hover:bg-red-700"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* 條碼查看對話框 - Temporarily disabled */}
{/*
{selectedProduct && (
<BarcodeViewDialog
open={barcodeDialogOpen}
onOpenChange={setBarcodeDialogOpen}
productName={selectedProduct.name}
productCode={selectedProduct.code}
barcodeValue={selectedProduct.code}
/>
)}
*/}
</>
);
}