Compare commits

..

4 Commits

Author SHA1 Message Date
75c634ffe4 fix(inventory): 修復倉庫低庫存警告計算與全站租戶名稱動態化
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 44s
2026-02-02 11:03:09 +08:00
1748eb007e feat(warehouse): 合併撥補單至調撥單流程並移除舊組件 2026-02-02 10:07:36 +08:00
313b95ceb9 fix(activity-log): 補足庫存對象 unit_cost 與 total_value 欄位翻譯 2026-02-02 09:37:27 +08:00
5e897e4197 fix(inventory): 修復調撥單明細庫存顯示與統一過帳按鈕樣式 2026-02-02 09:34:24 +08:00
13 changed files with 126 additions and 389 deletions

View File

@@ -5,6 +5,7 @@ namespace App\Modules\Inventory\Controllers;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Modules\Inventory\Models\InventoryTransferOrder; use App\Modules\Inventory\Models\InventoryTransferOrder;
use App\Modules\Inventory\Models\Warehouse; use App\Modules\Inventory\Models\Warehouse;
use App\Modules\Inventory\Models\Inventory;
use App\Modules\Inventory\Services\TransferService; use App\Modules\Inventory\Services\TransferService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Inertia; use Inertia\Inertia;
@@ -118,7 +119,13 @@ class TransferOrderController extends Controller
'remarks' => $order->remarks, 'remarks' => $order->remarks,
'created_at' => $order->created_at->format('Y-m-d H:i'), 'created_at' => $order->created_at->format('Y-m-d H:i'),
'created_by' => $order->createdBy?->name, 'created_by' => $order->createdBy?->name,
'items' => $order->items->map(function ($item) { 'items' => $order->items->map(function ($item) use ($order) {
// 獲取來源倉庫的當前庫存
$stock = Inventory::where('warehouse_id', $order->from_warehouse_id)
->where('product_id', $item->product_id)
->where('batch_number', $item->batch_number)
->first();
return [ return [
'id' => (string) $item->id, 'id' => (string) $item->id,
'product_id' => (string) $item->product_id, 'product_id' => (string) $item->product_id,
@@ -127,6 +134,7 @@ class TransferOrderController extends Controller
'batch_number' => $item->batch_number, 'batch_number' => $item->batch_number,
'unit' => $item->product->baseUnit?->name, 'unit' => $item->product->baseUnit?->name,
'quantity' => (float) $item->quantity, 'quantity' => (float) $item->quantity,
'max_quantity' => $stock ? (float) $stock->quantity : 0.0,
'notes' => $item->notes, 'notes' => $item->notes,
]; ];
}), }),

View File

@@ -37,6 +37,12 @@ class WarehouseController extends Controller
->orWhere('expiry_date', '>=', now()); ->orWhere('expiry_date', '>=', now());
}); });
}], 'quantity') }], 'quantity')
->addSelect(['low_stock_count' => function ($query) {
$query->selectRaw('count(*)')
->from('warehouse_product_safety_stocks as ss')
->whereColumn('ss.warehouse_id', 'warehouses.id')
->whereRaw('(SELECT COALESCE(SUM(quantity), 0) FROM inventories WHERE warehouse_id = ss.warehouse_id AND product_id = ss.product_id) < ss.safety_stock');
}])
->orderBy('created_at', 'desc') ->orderBy('created_at', 'desc')
->paginate(10) ->paginate(10)
->withQueryString(); ->withQueryString();

View File

@@ -86,6 +86,8 @@ const fieldLabels: Record<string, string> = {
quantity: '數量', quantity: '數量',
safety_stock: '安全庫存', safety_stock: '安全庫存',
location: '儲位', location: '儲位',
unit_cost: '單位成本',
total_value: '總價值',
// 庫存欄位 // 庫存欄位
batch_number: '批號', batch_number: '批號',
box_number: '箱號', box_number: '箱號',

View File

@@ -21,7 +21,7 @@ export default function ApplicationLogo(props: ImgHTMLAttributes<HTMLImageElemen
<img <img
{...props} {...props}
src="/logo.png" src="/logo.png"
alt="小小冰室 Logo" alt={`${branding?.short_name || 'Star'} Logo`}
/> />
); );
} }

View File

@@ -1,349 +0,0 @@
/**
* 撥補單對話框元件
* 重構後:加入驗證邏輯模組化
*/
import { useState, useEffect } from "react";
import { getCurrentDateTime } from "@/utils/format";
import axios from "axios";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/Components/ui/dialog";
import { Label } from "@/Components/ui/label";
import { Input } from "@/Components/ui/input";
import { Button } from "@/Components/ui/button";
import { SearchableSelect } from "@/Components/ui/searchable-select";
import { Textarea } from "@/Components/ui/textarea";
import { toast } from "sonner";
import { Warehouse, TransferOrder, TransferOrderStatus } from "@/types/warehouse";
import { validateTransferOrder, validateTransferQuantity } from "@/utils/validation";
import { usePermission } from "@/hooks/usePermission";
export type { TransferOrder };
interface TransferOrderDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
order: TransferOrder | null;
warehouses: Warehouse[];
// inventories: WarehouseInventory[]; // 因從 API 獲取而移除
onSave: (order: Omit<TransferOrder, "id" | "createdAt" | "orderNumber">) => void;
}
interface AvailableProduct {
productId: string;
productName: string;
batchNumber: string;
availableQty: number;
unit: string;
expiryDate: string | null;
unitCost: number; // 新增
totalValue: number; // 新增
}
export default function TransferOrderDialog({
open,
onOpenChange,
order,
warehouses,
// inventories,
onSave,
}: TransferOrderDialogProps) {
const { can } = usePermission();
const canViewCost = can('inventory.view_cost');
const [formData, setFormData] = useState({
sourceWarehouseId: "",
targetWarehouseId: "",
productId: "",
productName: "",
batchNumber: "",
quantity: 0,
transferDate: getCurrentDateTime(),
status: "待處理" as TransferOrderStatus,
notes: "",
});
const [availableProducts, setAvailableProducts] = useState<AvailableProduct[]>([]);
// 當對話框開啟或訂單變更時,重置表單
useEffect(() => {
if (order) {
setFormData({
sourceWarehouseId: order.sourceWarehouseId,
targetWarehouseId: order.targetWarehouseId,
productId: order.productId,
productName: order.productName,
batchNumber: order.batchNumber,
quantity: order.quantity,
transferDate: order.transferDate,
status: order.status,
notes: order.notes || "",
});
} else {
setFormData({
sourceWarehouseId: "",
targetWarehouseId: "",
productId: "",
productName: "",
batchNumber: "",
quantity: 0,
transferDate: getCurrentDateTime(),
status: "待處理",
notes: "",
});
}
}, [order, open]);
// 當來源倉庫變更時,從 API 更新可用商品列表
useEffect(() => {
if (formData.sourceWarehouseId) {
axios.get(route('api.warehouses.inventories', formData.sourceWarehouseId))
.then(response => {
const mappedData = response.data.map((item: any) => ({
productId: item.product_id,
productName: item.product_name,
batchNumber: item.batch_number,
availableQty: item.quantity,
unit: item.unit_name,
expiryDate: item.expiry_date,
unitCost: item.unit_cost, // 映射
totalValue: item.total_value, // 映射
}));
setAvailableProducts(mappedData);
})
.catch(error => {
console.error("Failed to fetch inventories:", error);
toast.error("無法取得倉庫庫存資訊");
setAvailableProducts([]);
});
} else {
setAvailableProducts([]);
}
}, [formData.sourceWarehouseId]);
const handleSubmit = () => {
// 基本驗證
const validation = validateTransferOrder(formData);
if (!validation.isValid) {
toast.error(validation.error);
return;
}
// 檢查可用數量
const selectedProduct = availableProducts.find(
(p) => p.productId === formData.productId && p.batchNumber === formData.batchNumber
);
if (selectedProduct) {
const quantityValidation = validateTransferQuantity(
formData.quantity,
selectedProduct.availableQty
);
if (!quantityValidation.isValid) {
toast.error(quantityValidation.error);
return;
}
}
onSave({
from_warehouse_id: formData.sourceWarehouseId,
to_warehouse_id: formData.targetWarehouseId,
product_id: formData.productId,
quantity: formData.quantity,
batch_number: formData.batchNumber,
notes: formData.notes,
instant_post: true,
} as any);
};
const handleProductChange = (productKey: string) => {
const [productId, batchNumber] = productKey.split("|||");
const product = availableProducts.find(
(p) => p.productId === productId && p.batchNumber === batchNumber
);
if (product) {
setFormData({
...formData,
productId: product.productId,
productName: product.productName,
batchNumber: product.batchNumber,
quantity: 0,
});
}
};
const selectedProduct = availableProducts.find(
(p) => p.productId === formData.productId && p.batchNumber === formData.batchNumber
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{order ? "編輯撥補單" : "新增撥補單"}</DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* 來源倉庫和目標倉庫 */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="sourceWarehouse">
<span className="text-red-500">*</span>
</Label>
<SearchableSelect
value={formData.sourceWarehouseId}
onValueChange={(value) =>
setFormData({
...formData,
sourceWarehouseId: value,
productId: "",
productName: "",
batchNumber: "",
quantity: 0,
})
}
disabled={!!order}
options={warehouses.map((warehouse) => ({ label: warehouse.name, value: warehouse.id }))}
placeholder="選擇來源倉庫"
searchPlaceholder="搜尋倉庫..."
/>
</div>
<div className="space-y-2">
<Label htmlFor="targetWarehouse">
<span className="text-red-500">*</span>
</Label>
<SearchableSelect
value={formData.targetWarehouseId}
onValueChange={(value) =>
setFormData({ ...formData, targetWarehouseId: value })
}
disabled={!!order}
options={warehouses
.filter((w) => w.id !== formData.sourceWarehouseId)
.map((warehouse) => ({ label: warehouse.name, value: warehouse.id }))}
placeholder="選擇目標倉庫"
searchPlaceholder="搜尋倉庫..."
/>
</div>
</div>
{/* 商品選擇 */}
<div className="space-y-2">
<Label htmlFor="product">
<span className="text-red-500">*</span>
</Label>
<SearchableSelect
value={
formData.productId && formData.batchNumber
? `${formData.productId}|||${formData.batchNumber}`
: ""
}
onValueChange={handleProductChange}
disabled={!formData.sourceWarehouseId || !!order}
options={availableProducts.map((product) => ({
label: `${product.productName} | 批號: ${product.batchNumber || '-'} | 效期: ${product.expiryDate || '-'} (庫存: ${product.availableQty} ${product.unit})${canViewCost ? ` | 成本: $${product.unitCost?.toLocaleString()}` : ''}`,
value: `${product.productId}|||${product.batchNumber}`,
}))}
placeholder="選擇商品與批號"
searchPlaceholder="搜尋商品..."
emptyText={formData.sourceWarehouseId ? "該倉庫無可用庫存" : "請先選擇來源倉庫"}
/>
</div>
{/* 數量和日期 */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="quantity">
<span className="text-red-500">*</span>
</Label>
<Input
id="quantity"
type="number"
min="0"
max={selectedProduct?.availableQty || 0}
value={formData.quantity}
onChange={(e) =>
setFormData({ ...formData, quantity: Number(e.target.value) })
}
/>
<div className="h-5">
{selectedProduct && (
<p className="text-sm text-gray-500">
: {selectedProduct.availableQty} {selectedProduct.unit}
</p>
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="transferDate">
<span className="text-red-500">*</span>
</Label>
<Input
id="transferDate"
type="datetime-local"
value={formData.transferDate}
onChange={(e) =>
setFormData({ ...formData, transferDate: e.target.value })
}
/>
</div>
</div>
{/* 狀態(僅編輯時顯示) */}
{order && (
<div className="space-y-2">
<Label htmlFor="status"></Label>
<SearchableSelect
value={formData.status}
onValueChange={(value) =>
setFormData({ ...formData, status: value as TransferOrderStatus })
}
options={[
{ label: "待處理", value: "待處理" },
{ label: "處理中", value: "處理中" },
{ label: "已完成", value: "已完成" },
{ label: "已取消", value: "已取消" },
]}
/>
</div>
)}
{/* 備註 */}
<div className="space-y-2">
<Label htmlFor="notes"></Label>
<Textarea
id="notes"
placeholder="請輸入備註(選填)"
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="button-outlined-primary"
>
</Button>
<Button onClick={handleSubmit} className="button-filled-primary">
{order ? "更新" : "新增"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -454,7 +454,7 @@ export default function AuthenticatedLayout({
</button> </button>
<Link href="/" className="flex items-center gap-2"> <Link href="/" className="flex items-center gap-2">
<ApplicationLogo className="w-8 h-8 rounded-lg object-contain" /> <ApplicationLogo className="w-8 h-8 rounded-lg object-contain" />
<span className="font-bold text-slate-900">{branding?.short_name || '小小冰室'} ERP</span> <span className="font-bold text-slate-900">{branding?.short_name || 'Star'} ERP</span>
</Link> </Link>
</div> </div>
@@ -510,7 +510,7 @@ export default function AuthenticatedLayout({
{!isCollapsed && ( {!isCollapsed && (
<Link href="/" className="flex items-center gap-2 group"> <Link href="/" className="flex items-center gap-2 group">
<ApplicationLogo className="w-8 h-8 rounded-lg object-contain group-hover:scale-110 transition-transform" /> <ApplicationLogo className="w-8 h-8 rounded-lg object-contain group-hover:scale-110 transition-transform" />
<span className="font-extrabold text-primary-main text-lg tracking-tight">{branding?.short_name || '小小冰室'} ERP</span> <span className="font-extrabold text-primary-main text-lg tracking-tight">{branding?.short_name || 'Star'} ERP</span>
</Link> </Link>
)} )}
{isCollapsed && ( {isCollapsed && (
@@ -559,7 +559,7 @@ export default function AuthenticatedLayout({
<div className="h-16 flex items-center justify-between px-6 border-b border-slate-100"> <div className="h-16 flex items-center justify-between px-6 border-b border-slate-100">
<Link href="/" className="flex items-center gap-2"> <Link href="/" className="flex items-center gap-2">
<ApplicationLogo className="w-8 h-8 rounded-lg object-contain" /> <ApplicationLogo className="w-8 h-8 rounded-lg object-contain" />
<span className="font-extrabold text-primary-main text-lg">{branding?.short_name || '小小冰室'} ERP</span> <span className="font-extrabold text-primary-main text-lg">{branding?.short_name || 'Star'} ERP</span>
</Link> </Link>
<button onClick={() => setIsMobileOpen(false)} className="p-2 text-slate-400"> <button onClick={() => setIsMobileOpen(false)} className="p-2 text-slate-400">
<X className="h-5 w-5" /> <X className="h-5 w-5" />
@@ -588,7 +588,7 @@ export default function AuthenticatedLayout({
{children} {children}
</div> </div>
<footer className="mt-auto py-6 text-center text-sm text-slate-400"> <footer className="mt-auto py-6 text-center text-sm text-slate-400">
Copyright &copy; {new Date().getFullYear()} {branding?.name || '小小冰室'}. All rights reserved. Design by Copyright &copy; {new Date().getFullYear()} {branding?.name || branding?.short_name || 'Star ERP'}. All rights reserved. Design by
</footer> </footer>
<Toaster richColors closeButton position="top-center" /> <Toaster richColors closeButton position="top-center" />
</main> </main>

View File

@@ -42,7 +42,7 @@ export default function Login() {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 relative overflow-hidden"> <div className="min-h-screen flex items-center justify-center bg-gray-50 relative overflow-hidden">
<Head title="登入"> <Head title={`登入 - ${props.branding?.short_name || 'Star ERP'}`}>
<link rel="icon" type="image/png" href="/favicon.png" /> <link rel="icon" type="image/png" href="/favicon.png" />
</Head> </Head>
@@ -136,7 +136,7 @@ export default function Login() {
</div> </div>
<p className="text-center text-gray-400 text-sm mt-8"> <p className="text-center text-gray-400 text-sm mt-8">
&copy; {new Date().getFullYear()} {props.branding?.name || '小小冰室'}. All rights reserved. &copy; {new Date().getFullYear()} {props.branding?.name || props.branding?.short_name || 'Star ERP'}. All rights reserved.
</p> </p>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,6 @@
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout'; import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Link, Head } from '@inertiajs/react'; import { Link, Head, usePage } from '@inertiajs/react';
import { PageProps } from '@/types/global';
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
Package, Package,
@@ -27,6 +28,7 @@ interface Props {
} }
export default function Dashboard({ stats }: Props) { export default function Dashboard({ stats }: Props) {
const { branding } = usePage<PageProps>().props;
const cardData = [ const cardData = [
{ {
label: '商品總數', label: '商品總數',
@@ -75,7 +77,7 @@ export default function Dashboard({ stats }: Props) {
return ( return (
<AuthenticatedLayout> <AuthenticatedLayout>
<Head title="控制台 - 小小冰室 ERP" /> <Head title={`控制台 - ${branding?.short_name || 'Star'} ERP`} />
<div className="p-8 max-w-7xl mx-auto"> <div className="p-8 max-w-7xl mx-auto">
<div className="mb-8"> <div className="mb-8">
@@ -83,7 +85,7 @@ export default function Dashboard({ stats }: Props) {
<TrendingUp className="h-6 w-6 text-primary-main" /> <TrendingUp className="h-6 w-6 text-primary-main" />
</h1> </h1>
<p className="text-gray-500 mt-1"> ERP </p> <p className="text-gray-500 mt-1"> {branding?.short_name || 'Star'} ERP </p>
</div> </div>
{/* 主要數據卡片 */} {/* 主要數據卡片 */}

View File

@@ -389,7 +389,7 @@ export default function Index({ warehouses, orders, filters }: any) {
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel> <AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-red-600 hover:bg-red-700"></AlertDialogAction> <AlertDialogAction onClick={handleDelete} className="button-filled-error"></AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>

View File

@@ -251,7 +251,7 @@ export default function Show({ order }: any) {
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel> <AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-red-600 hover:bg-red-700"></AlertDialogAction> <AlertDialogAction onClick={handleDelete} className="button-filled-error"></AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
@@ -287,7 +287,7 @@ export default function Show({ order }: any) {
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel> <AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handlePost} className="bg-primary-600 hover:bg-primary-700"></AlertDialogAction> <AlertDialogAction onClick={handlePost} className="button-filled-primary"></AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>

View File

@@ -8,7 +8,8 @@ import ProductDialog from "@/Components/Product/ProductDialog";
import CategoryManagerDialog from "@/Components/Category/CategoryManagerDialog"; import CategoryManagerDialog from "@/Components/Category/CategoryManagerDialog";
import UnitManagerDialog, { Unit } from "@/Components/Unit/UnitManagerDialog"; import UnitManagerDialog, { Unit } from "@/Components/Unit/UnitManagerDialog";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, router } from "@inertiajs/react"; import { Head, router, usePage } from "@inertiajs/react";
import { PageProps as GlobalPageProps } from "@/types/global";
import { debounce } from "lodash"; import { debounce } from "lodash";
import Pagination from "@/Components/shared/Pagination"; import Pagination from "@/Components/shared/Pagination";
import { getBreadcrumbs } from "@/utils/breadcrumb"; import { getBreadcrumbs } from "@/utils/breadcrumb";
@@ -57,6 +58,7 @@ interface PageProps {
} }
export default function ProductManagement({ products, categories, units, filters }: PageProps) { export default function ProductManagement({ products, categories, units, filters }: PageProps) {
const { branding } = usePage<GlobalPageProps>().props;
const [searchTerm, setSearchTerm] = useState(filters.search || ""); const [searchTerm, setSearchTerm] = useState(filters.search || "");
const [typeFilter, setTypeFilter] = useState<string>(filters.category_id || "all"); const [typeFilter, setTypeFilter] = useState<string>(filters.category_id || "all");
const [perPage, setPerPage] = useState<string>(filters.per_page || "10"); const [perPage, setPerPage] = useState<string>(filters.per_page || "10");
@@ -182,7 +184,7 @@ export default function ProductManagement({ products, categories, units, filters
<Package className="h-6 w-6 text-primary-main" /> <Package className="h-6 w-6 text-primary-main" />
</h1> </h1>
<p className="text-gray-500 mt-1"></p> <p className="text-gray-500 mt-1"> {branding?.short_name || 'Star'} </p>
</div> </div>
{/* Toolbar */} {/* Toolbar */}

View File

@@ -1,10 +1,20 @@
import { useState } from "react"; import { useState } from "react";
import { Plus, Warehouse as WarehouseIcon } from 'lucide-react'; import { SearchableSelect } from "@/Components/ui/searchable-select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from "@/Components/ui/dialog";
import { Label } from "@/Components/ui/label";
import { Loader2, Plus, Warehouse as WarehouseIcon } from 'lucide-react';
import { Card, CardContent } from "@/Components/ui/card";
import { Button } from "@/Components/ui/button"; import { Button } from "@/Components/ui/button";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, router } from "@inertiajs/react"; import { Head, router } from "@inertiajs/react";
import WarehouseDialog from "@/Components/Warehouse/WarehouseDialog"; import WarehouseDialog from "@/Components/Warehouse/WarehouseDialog";
import TransferOrderDialog from "@/Components/Warehouse/TransferOrderDialog";
import SearchToolbar from "@/Components/shared/SearchToolbar"; import SearchToolbar from "@/Components/shared/SearchToolbar";
import WarehouseCard from "@/Components/Warehouse/WarehouseCard"; import WarehouseCard from "@/Components/Warehouse/WarehouseCard";
import WarehouseEmptyState from "@/Components/Warehouse/WarehouseEmptyState"; import WarehouseEmptyState from "@/Components/Warehouse/WarehouseEmptyState";
@@ -13,7 +23,6 @@ import Pagination from "@/Components/shared/Pagination";
import { toast } from "sonner"; import { toast } from "sonner";
import { getBreadcrumbs } from "@/utils/breadcrumb"; import { getBreadcrumbs } from "@/utils/breadcrumb";
import { Can } from "@/Components/Permission/Can"; import { Can } from "@/Components/Permission/Can";
import { Card, CardContent } from "@/Components/ui/card";
interface PageProps { interface PageProps {
warehouses: { warehouses: {
@@ -39,7 +48,12 @@ export default function WarehouseIndex({ warehouses, totals, filters }: PageProp
// 對話框狀態 // 對話框狀態
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingWarehouse, setEditingWarehouse] = useState<Warehouse | null>(null); const [editingWarehouse, setEditingWarehouse] = useState<Warehouse | null>(null);
const [transferOrderDialogOpen, setTransferOrderDialogOpen] = useState(false);
// 調撥單單建立狀態
const [isTransferCreateOpen, setIsTransferCreateOpen] = useState(false);
const [sourceWarehouseId, setSourceWarehouseId] = useState("");
const [targetWarehouseId, setTargetWarehouseId] = useState("");
const [creating, setCreating] = useState(false);
// 搜尋處理 // 搜尋處理
const handleSearch = (term: string) => { const handleSearch = (term: string) => {
@@ -93,18 +107,33 @@ export default function WarehouseIndex({ warehouses, totals, filters }: PageProp
}; };
const handleAddTransferOrder = () => { const handleAddTransferOrder = () => {
setTransferOrderDialogOpen(true); setIsTransferCreateOpen(true);
}; };
const handleSaveTransferOrder = (data: any) => { const handleCreateTransferOrder = () => {
router.post(route('inventory.transfer.store'), data, { if (!sourceWarehouseId) {
toast.error("請選擇來源倉庫");
return;
}
if (!targetWarehouseId) {
toast.error("請選擇目的倉庫");
return;
}
if (sourceWarehouseId === targetWarehouseId) {
toast.error("來源與目的倉庫不能相同");
return;
}
setCreating(true);
router.post(route('inventory.transfer.store'), {
from_warehouse_id: sourceWarehouseId,
to_warehouse_id: targetWarehouseId
}, {
onFinish: () => setCreating(false),
onSuccess: () => { onSuccess: () => {
toast.success('撥補單已建立且庫存已轉移'); setIsTransferCreateOpen(false);
setTransferOrderDialogOpen(false); setSourceWarehouseId("");
}, setTargetWarehouseId("");
onError: (errors) => { toast.success('調撥單已建立');
toast.error('建立撥補單失敗');
console.error(errors);
} }
}); });
}; };
@@ -170,7 +199,7 @@ export default function WarehouseIndex({ warehouses, totals, filters }: PageProp
className="flex-1 md:flex-initial button-outlined-primary" className="flex-1 md:flex-initial button-outlined-primary"
> >
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
調
</Button> </Button>
</Can> </Can>
<Can permission="warehouses.create"> <Can permission="warehouses.create">
@@ -196,7 +225,7 @@ export default function WarehouseIndex({ warehouses, totals, filters }: PageProp
key={warehouse.id} key={warehouse.id}
warehouse={warehouse} warehouse={warehouse}
stats={{ stats={{
totalQuantity: warehouse.total_quantity || 0, totalQuantity: warehouse.book_stock || 0,
lowStockCount: warehouse.low_stock_count || 0, lowStockCount: warehouse.low_stock_count || 0,
replenishmentNeeded: warehouse.low_stock_count || 0 replenishmentNeeded: warehouse.low_stock_count || 0
}} }}
@@ -222,14 +251,51 @@ export default function WarehouseIndex({ warehouses, totals, filters }: PageProp
onDelete={handleDeleteWarehouse} onDelete={handleDeleteWarehouse}
/> />
{/* 撥補單對話框 */} {/* 調撥單建立對話框 */}
<TransferOrderDialog <Dialog open={isTransferCreateOpen} onOpenChange={setIsTransferCreateOpen}>
open={transferOrderDialogOpen} <DialogContent className="sm:max-w-[425px]">
onOpenChange={setTransferOrderDialogOpen} <DialogHeader>
order={null} <DialogTitle>調</DialogTitle>
onSave={handleSaveTransferOrder} <DialogDescription>
warehouses={warehouses.data}
/> </DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="space-y-2">
<Label></Label>
<SearchableSelect
value={sourceWarehouseId}
onValueChange={setSourceWarehouseId}
options={warehouses.data.map((w: any) => ({ label: w.name, value: w.id.toString() }))}
placeholder="請選擇來源倉庫"
className="h-9"
/>
</div>
<div className="space-y-2">
<Label></Label>
<SearchableSelect
value={targetWarehouseId}
onValueChange={setTargetWarehouseId}
options={warehouses.data
.filter((w: any) => w.id.toString() !== sourceWarehouseId)
.map((w: any) => ({ label: w.name, value: w.id.toString() }))
}
placeholder="請選擇目的倉庫"
className="h-9"
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" className="button-outlined-primary" onClick={() => setIsTransferCreateOpen(false)}>
</Button>
<Button onClick={handleCreateTransferOrder} className="button-filled-primary" disabled={creating || !sourceWarehouseId || !targetWarehouseId}>
{creating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
</AuthenticatedLayout> </AuthenticatedLayout>
); );

View File

@@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title inertia>{{ config('app.name', 'Laravel') }}</title> <title inertia>{{ $appName ?? config('app.name', 'Star ERP') }}</title>
<!-- Fonts --> <!-- Fonts -->
<link rel="icon" type="image/png" href="{{ $branding['logo_url'] ?? '/favicon.png' }}"> <link rel="icon" type="image/png" href="{{ $branding['logo_url'] ?? '/favicon.png' }}">