Compare commits
4 Commits
71458dd976
...
75c634ffe4
| Author | SHA1 | Date | |
|---|---|---|---|
| 75c634ffe4 | |||
| 1748eb007e | |||
| 313b95ceb9 | |||
| 5e897e4197 |
@@ -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,
|
||||||
];
|
];
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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: '箱號',
|
||||||
|
|||||||
@@ -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`}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 © {new Date().getFullYear()} {branding?.name || '小小冰室'}. All rights reserved. Design by 星科技
|
Copyright © {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>
|
||||||
|
|||||||
@@ -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">
|
||||||
© {new Date().getFullYear()} {props.branding?.name || '小小冰室'}. All rights reserved.
|
© {new Date().getFullYear()} {props.branding?.name || props.branding?.short_name || 'Star ERP'}. All rights reserved.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
{/* 主要數據卡片 */}
|
{/* 主要數據卡片 */}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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' }}">
|
||||||
|
|||||||
Reference in New Issue
Block a user