Files
star-erp/resources/js/Pages/StoreRequisition/Create.tsx

375 lines
16 KiB
TypeScript
Raw Normal View History

import { useState } from "react";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, Link, router } from "@inertiajs/react";
import { SearchableSelect } from "@/Components/ui/searchable-select";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Textarea } from "@/Components/ui/textarea";
import { Label } from "@/Components/ui/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/Components/ui/table";
import { toast } from "sonner";
import {
Store,
Plus,
Trash2,
Loader2,
Save,
SendHorizontal,
ArrowLeft,
} from "lucide-react";
interface Product {
id: number;
name: string;
code: string;
unit_name: string;
}
interface Warehouse {
id: number;
name: string;
type: string;
}
interface RequisitionItem {
product_id: string;
requested_qty: string;
remark: string;
}
interface Props {
requisition?: {
id: number;
store_warehouse_id: number;
remark: string | null;
status: string;
items: {
id: number;
product_id: number;
requested_qty: number;
remark: string | null;
}[];
};
warehouses: Warehouse[];
products: Product[];
}
export default function Create({ requisition, warehouses, products }: Props) {
const isEditing = !!requisition;
const [storeWarehouseId, setStoreWarehouseId] = useState(
requisition?.store_warehouse_id?.toString() || ""
);
const [remark, setRemark] = useState(requisition?.remark || "");
const [items, setItems] = useState<RequisitionItem[]>(
requisition?.items?.map((item) => ({
product_id: item.product_id.toString(),
requested_qty: item.requested_qty.toString(),
remark: item.remark || "",
})) || [{ product_id: "", requested_qty: "", remark: "" }]
);
const [saving, setSaving] = useState(false);
const [submitting, setSubmitting] = useState(false);
const addItem = () => {
setItems([...items, { product_id: "", requested_qty: "", remark: "" }]);
};
const removeItem = (index: number) => {
if (items.length <= 1) {
toast.error("至少需要一項商品");
return;
}
setItems(items.filter((_, i) => i !== index));
};
const updateItem = (index: number, field: keyof RequisitionItem, value: string) => {
const newItems = [...items];
newItems[index] = { ...newItems[index], [field]: value };
setItems(newItems);
};
const validate = (): boolean => {
if (!storeWarehouseId) {
toast.error("請選擇申請倉庫");
return false;
}
if (items.length === 0) {
toast.error("至少需要一項商品");
return false;
}
for (let i = 0; i < items.length; i++) {
if (!items[i].product_id) {
toast.error(`${i + 1} 行請選擇商品`);
return false;
}
const qty = parseInt(items[i].requested_qty);
if (!qty || qty < 1) {
toast.error(`${i + 1} 行需求數量必須大於等於 1`);
return false;
}
}
// 檢查是否有重複商品
const productIds = items.map((item) => item.product_id);
if (new Set(productIds).size !== productIds.length) {
toast.error("不可重複選擇商品");
return false;
}
return true;
};
const handleSave = (submitImmediately: boolean = false) => {
if (!validate()) return;
const setter = submitImmediately ? setSubmitting : setSaving;
setter(true);
const payload = {
store_warehouse_id: storeWarehouseId,
remark: remark || null,
items: items.map((item) => ({
product_id: parseInt(item.product_id),
requested_qty: parseFloat(item.requested_qty),
remark: item.remark || null,
})),
submit_immediately: submitImmediately,
};
if (isEditing) {
router.put(route("store-requisitions.update", [requisition!.id]), payload, {
onFinish: () => setter(false),
});
} else {
router.post(route("store-requisitions.store"), payload, {
onFinish: () => setter(false),
});
}
};
// 已選商品列表(用於過濾下拉選項)
const selectedProductIds = items.map((item) => item.product_id).filter(Boolean);
return (
<AuthenticatedLayout
breadcrumbs={[
{ label: "商品與庫存管理", href: "#" },
{ label: "門市叫貨", href: route("store-requisitions.index") },
{
label: isEditing ? "編輯叫貨單" : "新增叫貨單",
href: "#",
isPage: true,
},
]}
>
<Head title={isEditing ? "編輯叫貨單" : "新增叫貨單"} />
<div className="container mx-auto p-6 max-w-7xl">
{/* 返回按鈕 */}
<div className="mb-6">
<Link href={route("store-requisitions.index")}>
<Button variant="outline" className="gap-2 button-outlined-primary">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
</div>
{/* 頁面標題 */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<Store className="h-6 w-6 text-primary-main" />
{isEditing ? `編輯叫貨單 ${requisition?.status === "rejected" ? "(重新提交)" : ""}` : "新增叫貨單"}
</h1>
<p className="text-gray-500 mt-1">
</p>
</div>
{/* 基本資訊 */}
<div className="bg-white rounded-lg shadow-sm border p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-800 mb-4"></h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label>
<span className="text-red-500">*</span>
</Label>
<SearchableSelect
value={storeWarehouseId}
onValueChange={setStoreWarehouseId}
options={warehouses.map((w) => ({
label: w.name,
value: w.id.toString(),
}))}
placeholder="請選擇倉庫"
className="h-9"
/>
<p className="text-xs text-gray-400"></p>
</div>
<div className="space-y-2">
<Label></Label>
<Textarea
value={remark}
onChange={(e) => setRemark(e.target.value)}
placeholder="補充說明(選填)"
rows={3}
/>
</div>
</div>
</div>
{/* 商品明細 */}
<div className="bg-white rounded-lg shadow-sm border p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-800"></h2>
<Button
type="button"
variant="outline"
size="sm"
className="button-outlined-primary"
onClick={addItem}
>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="w-[50px] text-center font-medium text-gray-600">
#
</TableHead>
<TableHead className="font-medium text-gray-600 min-w-[250px]">
<span className="text-red-500">*</span>
</TableHead>
<TableHead className="font-medium text-gray-600 w-[150px]">
<span className="text-red-500">*</span>
</TableHead>
<TableHead className="font-medium text-gray-600 w-[100px]"></TableHead>
<TableHead className="font-medium text-gray-600 min-w-[150px]"></TableHead>
<TableHead className="w-[60px] text-center font-medium text-gray-600">
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item, index) => {
const selectedProduct = products.find(
(p) => String(p.id) === String(item.product_id)
);
return (
<TableRow key={index}>
<TableCell className="text-center text-gray-500 font-medium">
{index + 1}
</TableCell>
<TableCell>
<SearchableSelect
value={item.product_id}
onValueChange={(val) =>
updateItem(index, "product_id", val)
}
options={products
.filter(
(p) =>
!selectedProductIds.includes(
p.id.toString()
) ||
p.id.toString() === item.product_id
)
.map((p) => ({
label: `${p.code} - ${p.name}`,
value: p.id.toString(),
}))}
placeholder="選擇商品"
className="h-9"
/>
</TableCell>
<TableCell>
<Input
type="number"
step="1"
min="1"
value={item.requested_qty}
onChange={(e) =>
updateItem(index, "requested_qty", e.target.value)
}
placeholder="0"
className="h-9 text-right"
/>
</TableCell>
<TableCell className="text-gray-500">
{selectedProduct?.unit_name || "-"}
</TableCell>
<TableCell>
<Input
value={item.remark}
onChange={(e) =>
updateItem(index, "remark", e.target.value)
}
placeholder="備註"
className="h-9"
/>
</TableCell>
<TableCell className="text-center">
<Button
variant="outline"
size="sm"
onClick={() => removeItem(index)}
className="button-outlined-error"
>
<Trash2 className="w-4 h-4" />
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</div>
{/* 操作按鈕列 */}
<div className="flex items-center justify-end gap-3">
<Button
type="button"
variant="outline"
className="button-outlined-primary"
onClick={() => router.visit(route("store-requisitions.index"))}
>
</Button>
<Button
type="button"
variant="outline"
className="button-outlined-primary"
disabled={saving || submitting}
onClick={() => handleSave(false)}
>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<Save className="w-4 h-4 mr-1" />
稿
</Button>
<Button
type="button"
className="button-filled-primary"
disabled={saving || submitting}
onClick={() => handleSave(true)}
>
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<SendHorizontal className="w-4 h-4 mr-1" />
</Button>
</div>
</div>
</AuthenticatedLayout>
);
}