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

375 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}