新增單位管理以及一些功能修正

This commit is contained in:
2026-01-08 11:52:25 +08:00
parent eca2f38395
commit 48115082e5
19 changed files with 872 additions and 246 deletions

View File

@@ -59,7 +59,6 @@ export default function CategoryManagerDialog({
post(route("categories.store"), {
onSuccess: () => {
reset();
toast.success("分類已新增");
},
onError: (errors) => {
toast.error("新增失敗: " + (errors.name || "未知錯誤"));
@@ -83,7 +82,6 @@ export default function CategoryManagerDialog({
router.put(route("categories.update", id), { name: editName }, {
onSuccess: () => {
setEditingId(null);
toast.success("分類已更新");
},
onError: (errors) => {
toast.error("更新失敗: " + (errors.name || "未知錯誤"));
@@ -94,7 +92,7 @@ export default function CategoryManagerDialog({
const handleDelete = (id: number) => {
router.delete(route("categories.destroy", id), {
onSuccess: () => {
toast.success("分類已刪除");
// 不在此處理 toast交由全域 flash 處理
},
onError: () => {
toast.error("刪除失敗,請確認該分類下無商品");

View File

@@ -21,19 +21,15 @@ import {
import { useForm } from "@inertiajs/react";
import { toast } from "sonner";
import type { Product, Category } from "@/Pages/Product/Index";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/Components/ui/dropdown-menu";
import { ChevronDown } from "lucide-react";
import type { Unit } from "@/Components/Unit/UnitManagerDialog";
interface ProductDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
product: Product | null;
categories: Category[];
units: Unit[];
onSave?: (product: any) => void; // Legacy prop, can be removed if fully switching to Inertia submit within dialog
}
@@ -42,16 +38,17 @@ export default function ProductDialog({
onOpenChange,
product,
categories,
units,
}: ProductDialogProps) {
const { data, setData, post, put, processing, errors, reset, clearErrors } = useForm({
name: "",
category_id: "",
brand: "",
specification: "",
base_unit: "公斤",
large_unit: "",
base_unit_id: "",
large_unit_id: "",
conversion_rate: "",
purchase_unit: "",
purchase_unit_id: "",
});
useEffect(() => {
@@ -63,10 +60,10 @@ export default function ProductDialog({
category_id: product.category_id.toString(),
brand: product.brand || "",
specification: product.specification || "",
base_unit: product.base_unit,
large_unit: product.large_unit || "",
base_unit_id: product.base_unit_id?.toString() || "",
large_unit_id: product.large_unit_id?.toString() || "",
conversion_rate: product.conversion_rate ? product.conversion_rate.toString() : "",
purchase_unit: product.purchase_unit || "",
purchase_unit_id: product.purchase_unit_id?.toString() || "",
});
} else {
reset();
@@ -188,50 +185,52 @@ export default function ProductDialog({
<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">
<Label htmlFor="base_unit_id">
<span className="text-red-500">*</span>
</Label>
<div className="flex gap-2">
<Input
id="base_unit"
value={data.base_unit}
onChange={(e) => setData("base_unit", e.target.value)}
placeholder="可輸入或選擇..."
className={errors.base_unit ? "border-red-500 flex-1" : "flex-1"}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="shrink-0">
<ChevronDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{["公斤", "公克", "公升", "毫升", "個", "支", "包", "罐", "瓶", "箱", "袋"].map((u) => (
<DropdownMenuItem key={u} onClick={() => setData("base_unit", u)}>
{u}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{errors.base_unit && <p className="text-sm text-red-500">{errors.base_unit}</p>}
<Select
value={data.base_unit_id}
onValueChange={(value) => setData("base_unit_id", value)}
>
<SelectTrigger id="base_unit_id" className={errors.base_unit_id ? "border-red-500" : ""}>
<SelectValue placeholder="選擇單位" />
</SelectTrigger>
<SelectContent>
{units.map((unit) => (
<SelectItem key={unit.id} value={unit.id.toString()}>
{unit.name}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.base_unit_id && <p className="text-sm text-red-500">{errors.base_unit_id}</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>}
<Label htmlFor="large_unit_id"></Label>
<Select
value={data.large_unit_id}
onValueChange={(value) => setData("large_unit_id", value)}
>
<SelectTrigger id="large_unit_id" className={errors.large_unit_id ? "border-red-500" : ""}>
<SelectValue placeholder="無" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
{units.map((unit) => (
<SelectItem key={unit.id} value={unit.id.toString()}>
{unit.name}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.large_unit_id && <p className="text-sm text-red-500">{errors.large_unit_id}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="conversion_rate">
{data.large_unit && <span className="text-red-500">*</span>}
{data.large_unit_id && <span className="text-red-500">*</span>}
</Label>
<Input
id="conversion_rate"
@@ -239,27 +238,37 @@ export default function ProductDialog({
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}
placeholder={data.large_unit_id && data.base_unit_id ? `1 ${units.find(u => u.id.toString() === data.large_unit_id)?.name} = ? ${units.find(u => u.id.toString() === data.base_unit_id)?.name}` : ""}
disabled={!data.large_unit_id}
/>
{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>}
<Label htmlFor="purchase_unit_id"></Label>
<Select
value={data.purchase_unit_id}
onValueChange={(value) => setData("purchase_unit_id", value)}
>
<SelectTrigger id="purchase_unit_id" className={errors.purchase_unit_id ? "border-red-500" : ""}>
<SelectValue placeholder="通常同大單位" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
{units.map((unit) => (
<SelectItem key={unit.id} value={unit.id.toString()}>
{unit.name}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.purchase_unit_id && <p className="text-sm text-red-500">{errors.purchase_unit_id}</p>}
</div>
</div>
{data.large_unit && data.base_unit && data.conversion_rate && (
{data.large_unit_id && data.base_unit_id && 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}
1 {units.find(u => u.id.toString() === data.large_unit_id)?.name} = {data.conversion_rate} {units.find(u => u.id.toString() === data.base_unit_id)?.name}
</div>
)}
</div>

View File

@@ -121,11 +121,11 @@ export default function ProductTable({
{product.category?.name || '-'}
</Badge>
</TableCell>
<TableCell>{product.base_unit}</TableCell>
<TableCell>{product.baseUnit?.name || '-'}</TableCell>
<TableCell>
{product.large_unit ? (
{product.largeUnit ? (
<span className="text-sm text-gray-500">
1 {product.large_unit} = {Number(product.conversion_rate)} {product.base_unit}
1 {product.largeUnit?.name} = {Number(product.conversion_rate)} {product.baseUnit?.name}
</span>
) : (
'-'

View File

@@ -0,0 +1,309 @@
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/Components/ui/alert-dialog";
import { router, useForm } from "@inertiajs/react";
import { toast } from "sonner";
import { Trash2, Edit2, Check, X, Plus, Loader2 } from "lucide-react";
export interface Unit {
id: number;
name: string;
code: string | null;
}
interface UnitManagerDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
units: Unit[];
}
export default function UnitManagerDialog({
open,
onOpenChange,
units,
}: UnitManagerDialogProps) {
const [editingId, setEditingId] = useState<number | null>(null);
const [editName, setEditName] = useState("");
const [editCode, setEditCode] = useState("");
const { data, setData, post, processing, reset, errors, clearErrors } = useForm({
name: "",
code: "",
});
useEffect(() => {
if (!open) {
reset();
clearErrors();
setEditingId(null);
}
}, [open]);
const handleAdd = (e: React.FormEvent) => {
e.preventDefault();
if (!data.name.trim()) return;
post(route("units.store"), {
onSuccess: () => {
reset();
},
onError: (errors) => {
toast.error("新增失敗: " + (errors.name || errors.code || "未知錯誤"));
}
});
};
const startEdit = (unit: Unit) => {
setEditingId(unit.id);
setEditName(unit.name);
setEditCode(unit.code || "");
};
const cancelEdit = () => {
setEditingId(null);
setEditName("");
setEditCode("");
};
const saveEdit = (id: number) => {
if (!editName.trim()) return;
router.put(route("units.update", id), { name: editName, code: editCode }, {
onSuccess: () => {
setEditingId(null);
},
onError: (errors) => {
toast.error("更新失敗: " + (errors.name || errors.code || "未知錯誤"));
}
});
};
const handleDelete = (id: number) => {
router.delete(route("units.destroy", id), {
onSuccess: () => {
// 由全域 flash 處理
},
onError: () => {
toast.error("刪除失敗,請確認該單位無關聯商品");
}
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto py-4 space-y-6">
{/* Add New Section */}
<div className="space-y-4">
<h3 className="text-sm font-medium border-l-4 border-primary pl-2"></h3>
<form onSubmit={handleAdd} className="flex items-end gap-3 p-4 bg-white border rounded-lg shadow-sm">
<div className="flex-1 grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="new-unit-name" className="text-xs text-gray-500"></Label>
<Input
id="new-unit-name"
placeholder="例如: 箱, 包"
value={data.name}
onChange={(e) => setData("name", e.target.value)}
className={errors.name ? "border-red-500" : ""}
/>
{errors.name && <p className="text-xs text-red-500 mt-1">{errors.name}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="new-unit-code" className="text-xs text-gray-500"> ()</Label>
<Input
id="new-unit-code"
placeholder="例如: box, kg"
value={data.code}
onChange={(e) => setData("code", e.target.value)}
className={errors.code ? "border-red-500" : ""}
/>
{errors.code && <p className="text-xs text-red-500 mt-1">{errors.code}</p>}
</div>
</div>
<Button type="submit" disabled={processing} className="button-filled-primary h-10 px-6">
{processing ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<Plus className="w-4 h-4 mr-2" />
)}
</Button>
</form>
</div>
{/* List Section */}
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-sm font-medium border-l-4 border-primary pl-2"></h3>
<span className="text-xs text-gray-400"> {units.length} </span>
</div>
<div className="bg-white border rounded-lg shadow-sm overflow-hidden">
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="w-[50px] font-medium text-gray-700">#</TableHead>
<TableHead className="font-medium text-gray-700"></TableHead>
<TableHead className="font-medium text-gray-700"></TableHead>
<TableHead className="w-[140px] text-right font-medium text-gray-700"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{units.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center py-12 text-gray-400">
</TableCell>
</TableRow>
) : (
units.map((unit, index) => (
<TableRow key={unit.id}>
<TableCell className="py-3 text-center text-gray-500 font-medium">
{index + 1}
</TableCell>
<TableCell className="py-3">
{editingId === unit.id ? (
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
className="h-9 focus-visible:ring-1"
autoFocus
placeholder="單位名稱"
onKeyDown={(e) => {
if (e.key === 'Enter') saveEdit(unit.id);
if (e.key === 'Escape') cancelEdit();
}}
/>
) : (
<span className="font-medium text-gray-700">{unit.name}</span>
)}
</TableCell>
<TableCell className="py-3">
{editingId === unit.id ? (
<Input
value={editCode}
onChange={(e) => setEditCode(e.target.value)}
className="h-9 focus-visible:ring-1"
placeholder="代碼"
onKeyDown={(e) => {
if (e.key === 'Enter') saveEdit(unit.id);
if (e.key === 'Escape') cancelEdit();
}}
/>
) : (
<span className="text-gray-500">{unit.code || '-'}</span>
)}
</TableCell>
<TableCell className="text-right py-3">
{editingId === unit.id ? (
<div className="flex justify-end gap-1">
<Button
size="sm"
variant="ghost"
className="h-8 w-8 text-green-600 hover:text-green-700 hover:bg-green-50"
onClick={() => saveEdit(unit.id)}
>
<Check className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-8 w-8 text-gray-400 hover:text-gray-600 hover:bg-gray-100"
onClick={cancelEdit}
>
<X className="h-4 w-4" />
</Button>
</div>
) : (
<div className="flex justify-end gap-1">
<Button
variant="outline"
size="sm"
className="h-8 w-8 p-0 button-outlined-primary"
onClick={() => startEdit(unit)}
>
<Edit2 className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 w-8 p-0 button-outlined-error"
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{unit.name}<br />
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDelete(unit.id)}
className="bg-red-600 hover:bg-red-700"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
</div>
<div className="flex justify-end gap-2 pt-4 border-t mt-auto">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="button-outlined-primary px-8"
>
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -12,13 +12,20 @@ function AlertDialog({
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
const AlertDialogTrigger = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Trigger>,
React.ComponentProps<typeof AlertDialogPrimitive.Trigger>
>(({ className, ...props }, ref) => {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
<AlertDialogPrimitive.Trigger
ref={ref}
data-slot="alert-dialog-trigger"
className={className}
{...props}
/>
);
}
});
AlertDialogTrigger.displayName = AlertDialogPrimitive.Trigger.displayName;
function AlertDialogPortal({
...props
@@ -28,119 +35,140 @@ function AlertDialogPortal({
);
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
const AlertDialogOverlay = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentProps<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => {
return (
<AlertDialogPrimitive.Overlay
ref={ref}
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[150] bg-black/50",
className,
)}
{...props}
/>
);
}
});
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
const AlertDialogContent = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Content>,
React.ComponentProps<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-[150] grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className,
)}
{...props}
/>
</AlertDialogPortal>
);
}
});
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
const AlertDialogHeader = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
));
AlertDialogHeader.displayName = "AlertDialogHeader";
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
/>
);
}
const AlertDialogFooter = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
/>
));
AlertDialogFooter.displayName = "AlertDialogFooter";
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
const AlertDialogTitle = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Title>,
React.ComponentProps<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => {
return (
<AlertDialogPrimitive.Title
ref={ref}
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
);
}
});
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
const AlertDialogDescription = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Description>,
React.ComponentProps<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => {
return (
<AlertDialogPrimitive.Description
ref={ref}
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
});
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName;
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
const AlertDialogAction = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Action>,
React.ComponentProps<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), "bg-red-600 hover:bg-red-700 text-white border-transparent", className)}
ref={ref}
className={cn(
buttonVariants(),
"bg-red-600 hover:bg-red-700 text-white border-transparent",
className,
)}
{...props}
/>
);
}
});
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
const AlertDialogCancel = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentProps<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), "button-outlined-primary mt-0", className)}
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"button-outlined-primary mt-0",
className,
)}
{...props}
/>
);
}
});
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,

View File

@@ -2,7 +2,7 @@
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
@@ -12,11 +12,20 @@ function Dialog({
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
const DialogTrigger = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Trigger>,
React.ComponentProps<typeof DialogPrimitive.Trigger>
>(({ className, ...props }, ref) => {
return (
<DialogPrimitive.Trigger
ref={ref}
data-slot="dialog-trigger"
className={className}
{...props}
/>
);
});
DialogTrigger.displayName = DialogPrimitive.Trigger.displayName;
function DialogPortal({
...props
@@ -33,96 +42,98 @@ function DialogClose({
const DialogOverlay = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Overlay>,
React.ComponentProps<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => {
return (
<DialogPrimitive.Overlay
ref={ref}
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
});
DialogOverlay.displayName = "DialogOverlay";
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[100] bg-black/50",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Content>,
React.ComponentProps<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
);
});
DialogContent.displayName = "DialogContent";
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
data-slot="dialog-content"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-[100] grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className,
)}
{...props}
/>
);
}
>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
);
}
const DialogHeader = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
));
DialogHeader.displayName = "DialogHeader";
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
const DialogFooter = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
/>
));
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Title>,
React.ComponentProps<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
data-slot="dialog-title"
className={cn("text-lg font-semibold tracking-tight", className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Description>,
React.ComponentProps<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,

View File

@@ -82,9 +82,9 @@ function SelectContent({
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-[150] max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
@@ -95,7 +95,7 @@ function SelectContent({
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)}
>
{children}

View File

@@ -15,7 +15,7 @@ import {
User,
ChevronDown
} from "lucide-react";
import { Toaster } from "sonner";
import { toast, Toaster } from "sonner";
import { useState, useEffect } from "react";
import { Link, usePage } from "@inertiajs/react";
import { cn } from "@/lib/utils";
@@ -139,6 +139,20 @@ export default function AuthenticatedLayout({
localStorage.setItem("sidebar-collapsed", String(isCollapsed));
}, [isCollapsed]);
// 全域監聽 flash 訊息並顯示 Toast
useEffect(() => {
// @ts-ignore
if (props.flash?.success) {
// @ts-ignore
toast.success(props.flash.success);
}
// @ts-ignore
if (props.flash?.error) {
// @ts-ignore
toast.error(props.flash.error);
}
}, [props.flash]);
const toggleExpand = (itemId: string) => {
if (isCollapsed) {
setIsCollapsed(false);

View File

@@ -12,6 +12,7 @@ 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 UnitManagerDialog, { Unit } from "@/Components/Unit/UnitManagerDialog";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, router } from "@inertiajs/react";
import { debounce } from "lodash";
@@ -31,10 +32,13 @@ export interface Product {
category?: Category;
brand?: string;
specification?: string;
base_unit: string;
large_unit?: string;
base_unit_id: number;
baseUnit?: Unit;
large_unit_id?: number;
largeUnit?: Unit;
conversion_rate?: number;
purchase_unit?: string;
purchase_unit_id?: number;
purchaseUnit?: Unit;
created_at: string;
updated_at: string;
}
@@ -46,6 +50,7 @@ interface PageProps {
from: number;
};
categories: Category[];
units: Unit[];
filters: {
search?: string;
category_id?: string;
@@ -55,7 +60,7 @@ interface PageProps {
};
}
export default function ProductManagement({ products, categories, filters }: PageProps) {
export default function ProductManagement({ products, categories, units, 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");
@@ -63,6 +68,7 @@ export default function ProductManagement({ products, categories, filters }: Pag
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 [isUnitDialogOpen, setIsUnitDialogOpen] = useState(false);
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
// Sync state with props when they change (e.g. navigation)
@@ -163,13 +169,11 @@ export default function ProductManagement({ products, categories, filters }: Pag
};
const handleDeleteProduct = (id: number) => {
if (confirm("確定要刪除嗎?")) {
router.delete(route('products.destroy', id), {
onSuccess: () => {
// Toast handled by flash message usually, or add here if needed
}
});
}
router.delete(route('products.destroy', id), {
onSuccess: () => {
// Toast handled by flash message
}
});
};
return (
@@ -226,6 +230,13 @@ export default function ProductManagement({ products, categories, filters }: Pag
>
</Button>
<Button
variant="outline"
onClick={() => setIsUnitDialogOpen(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" />
@@ -270,6 +281,7 @@ export default function ProductManagement({ products, categories, filters }: Pag
onOpenChange={setIsDialogOpen}
product={editingProduct}
categories={categories}
units={units}
/>
<CategoryManagerDialog
@@ -277,6 +289,12 @@ export default function ProductManagement({ products, categories, filters }: Pag
onOpenChange={setIsCategoryDialogOpen}
categories={categories}
/>
<UnitManagerDialog
open={isUnitDialogOpen}
onOpenChange={setIsUnitDialogOpen}
units={units}
/>
</div>
</AuthenticatedLayout>
);