UI優化: 全系統狀態標籤 (StatusBadge) 統一化重構完成 (Phase 3 & 4)
This commit is contained in:
@@ -188,11 +188,13 @@ class RoleController extends Controller
|
||||
'vendors' => '廠商資料管理',
|
||||
'purchase_orders' => '採購單管理',
|
||||
'goods_receipts' => '進貨單管理',
|
||||
'delivery_notes' => '出貨單管理',
|
||||
'recipes' => '配方管理',
|
||||
'production_orders' => '生產工單管理',
|
||||
'utility_fees' => '公共事業費管理',
|
||||
'accounting' => '會計報表',
|
||||
'sales_imports' => '銷售單匯入管理',
|
||||
'store_requisitions' => '門市叫貨申請',
|
||||
'users' => '使用者管理',
|
||||
'roles' => '角色與權限',
|
||||
'system' => '系統管理',
|
||||
|
||||
352
app/Modules/Inventory/Controllers/StoreRequisitionController.php
Normal file
352
app/Modules/Inventory/Controllers/StoreRequisitionController.php
Normal file
@@ -0,0 +1,352 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Inventory\Models\StoreRequisition;
|
||||
use App\Modules\Inventory\Models\Warehouse;
|
||||
use App\Modules\Inventory\Models\Product;
|
||||
use App\Modules\Inventory\Models\Inventory;
|
||||
use App\Modules\Inventory\Services\StoreRequisitionService;
|
||||
use App\Modules\Core\Contracts\CoreServiceInterface;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class StoreRequisitionController extends Controller
|
||||
{
|
||||
protected StoreRequisitionService $service;
|
||||
protected CoreServiceInterface $coreService;
|
||||
|
||||
public function __construct(
|
||||
StoreRequisitionService $service,
|
||||
CoreServiceInterface $coreService
|
||||
) {
|
||||
$this->service = $service;
|
||||
$this->coreService = $coreService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 叫貨單列表
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = StoreRequisition::query();
|
||||
|
||||
// 搜尋(單號)
|
||||
if ($request->search) {
|
||||
$query->where('doc_no', 'like', "%{$request->search}%");
|
||||
}
|
||||
|
||||
// 狀態篩選
|
||||
if ($request->status && $request->status !== 'all') {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
|
||||
// 倉庫篩選
|
||||
if ($request->warehouse_id) {
|
||||
$query->where('store_warehouse_id', $request->warehouse_id);
|
||||
}
|
||||
|
||||
// 日期範圍
|
||||
if ($request->date_start) {
|
||||
$query->whereDate('created_at', '>=', $request->date_start);
|
||||
}
|
||||
if ($request->date_end) {
|
||||
$query->whereDate('created_at', '<=', $request->date_end);
|
||||
}
|
||||
|
||||
// 排序
|
||||
$sortField = $request->input('sort_by', 'id');
|
||||
$sortOrder = $request->input('sort_order', 'desc');
|
||||
$allowedSorts = ['id', 'doc_no', 'status', 'created_at', 'submitted_at'];
|
||||
if (in_array($sortField, $allowedSorts)) {
|
||||
$query->orderBy($sortField, $sortOrder);
|
||||
} else {
|
||||
$query->orderBy('id', 'desc');
|
||||
}
|
||||
|
||||
$perPage = $request->input('per_page', 10);
|
||||
$requisitions = $query->paginate($perPage)->withQueryString();
|
||||
|
||||
// 水和倉庫名稱與使用者名稱
|
||||
$warehouses = Warehouse::select('id', 'name', 'type')->get();
|
||||
$warehouseMap = $warehouses->keyBy('id');
|
||||
|
||||
$userIds = $requisitions->getCollection()
|
||||
->pluck('created_by')
|
||||
->merge($requisitions->getCollection()->pluck('approved_by'))
|
||||
->filter()
|
||||
->unique()
|
||||
->toArray();
|
||||
$users = $this->coreService->getUsersByIds($userIds)->keyBy('id');
|
||||
|
||||
$requisitions->getCollection()->transform(function ($req) use ($warehouseMap, $users) {
|
||||
$req->store_warehouse_name = $warehouseMap->get($req->store_warehouse_id)?->name ?? '-';
|
||||
$req->supply_warehouse_name = $warehouseMap->get($req->supply_warehouse_id)?->name ?? '-';
|
||||
$req->creator_name = $users->get($req->created_by)?->name ?? '-';
|
||||
$req->approver_name = $users->get($req->approved_by)?->name ?? '-';
|
||||
return $req;
|
||||
});
|
||||
|
||||
return Inertia::render('StoreRequisition/Index', [
|
||||
'requisitions' => $requisitions,
|
||||
'filters' => $request->only(['search', 'status', 'warehouse_id', 'date_start', 'date_end', 'sort_by', 'sort_order', 'per_page']),
|
||||
'warehouses' => $warehouses->map(fn($w) => ['id' => $w->id, 'name' => $w->name]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增頁面
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
$warehouses = Warehouse::select('id', 'name', 'type')->get();
|
||||
$products = Product::select('id', 'name', 'code', 'base_unit_id')
|
||||
->with('baseUnit:id,name')
|
||||
->where('is_active', true)
|
||||
->get();
|
||||
|
||||
return Inertia::render('StoreRequisition/Create', [
|
||||
'warehouses' => $warehouses->map(fn($w) => [
|
||||
'id' => $w->id,
|
||||
'name' => $w->name,
|
||||
'type' => $w->type?->value,
|
||||
]),
|
||||
'products' => $products->map(fn($p) => [
|
||||
'id' => $p->id,
|
||||
'name' => $p->name,
|
||||
'code' => $p->code,
|
||||
'unit_name' => $p->baseUnit?->name,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 儲存叫貨單
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'store_warehouse_id' => 'required|exists:warehouses,id',
|
||||
'remark' => 'nullable|string|max:500',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.product_id' => 'required|exists:products,id',
|
||||
'items.*.requested_qty' => 'required|numeric|min:0.01',
|
||||
'items.*.remark' => 'nullable|string|max:200',
|
||||
], [
|
||||
'items.required' => '至少需要一項商品',
|
||||
'items.min' => '至少需要一項商品',
|
||||
'items.*.requested_qty.min' => '需求數量必須大於 0',
|
||||
]);
|
||||
|
||||
$requisition = $this->service->create(
|
||||
$request->only(['store_warehouse_id', 'remark']),
|
||||
$request->items,
|
||||
auth()->id()
|
||||
);
|
||||
|
||||
// 如果需要直接提交
|
||||
if ($request->boolean('submit_immediately')) {
|
||||
$this->service->submit($requisition, auth()->id());
|
||||
return redirect()->route('store-requisitions.index')
|
||||
->with('success', '叫貨單已提交審核');
|
||||
}
|
||||
|
||||
return redirect()->route('store-requisitions.show', $requisition->id)
|
||||
->with('success', '叫貨單已儲存為草稿');
|
||||
}
|
||||
|
||||
/**
|
||||
* 叫貨單詳情
|
||||
*/
|
||||
public function show($id)
|
||||
{
|
||||
$requisition = StoreRequisition::with(['items.product.baseUnit'])->findOrFail($id);
|
||||
|
||||
// 水和倉庫
|
||||
$warehouses = Warehouse::select('id', 'name', 'type')->get();
|
||||
$warehouseMap = $warehouses->keyBy('id');
|
||||
|
||||
$requisition->store_warehouse_name = $warehouseMap->get($requisition->store_warehouse_id)?->name ?? '-';
|
||||
$requisition->supply_warehouse_name = $warehouseMap->get($requisition->supply_warehouse_id)?->name ?? '-';
|
||||
|
||||
// 水和使用者
|
||||
$userIds = collect([$requisition->created_by, $requisition->approved_by])->filter()->unique()->toArray();
|
||||
$users = $this->coreService->getUsersByIds($userIds)->keyBy('id');
|
||||
$requisition->creator_name = $users->get($requisition->created_by)?->name ?? '-';
|
||||
$requisition->approver_name = $users->get($requisition->approved_by)?->name ?? '-';
|
||||
|
||||
// 水和明細商品資訊
|
||||
$requisition->items->transform(function ($item) {
|
||||
$item->product_name = $item->product?->name ?? '-';
|
||||
$item->product_code = $item->product?->code ?? '-';
|
||||
$item->unit_name = $item->product?->baseUnit?->name ?? '-';
|
||||
return $item;
|
||||
});
|
||||
|
||||
// 取得庫存資訊(顯示該商品在申請倉庫的現有庫存量)
|
||||
$productIds = $requisition->items->pluck('product_id')->toArray();
|
||||
$inventories = Inventory::where('warehouse_id', $requisition->store_warehouse_id)
|
||||
->whereIn('product_id', $productIds)
|
||||
->select('product_id')
|
||||
->selectRaw('SUM(quantity) as total_qty')
|
||||
->groupBy('product_id')
|
||||
->get()
|
||||
->keyBy('product_id');
|
||||
|
||||
$requisition->items->transform(function ($item) use ($inventories) {
|
||||
$item->current_stock = $inventories->get($item->product_id)?->total_qty ?? 0;
|
||||
return $item;
|
||||
});
|
||||
|
||||
// 操作紀錄
|
||||
$activities = \Spatie\Activitylog\Models\Activity::where('subject_type', StoreRequisition::class)
|
||||
->where('subject_id', $requisition->id)
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
return Inertia::render('StoreRequisition/Show', [
|
||||
'requisition' => $requisition,
|
||||
'warehouses' => $warehouses->map(fn($w) => ['id' => $w->id, 'name' => $w->name]),
|
||||
'activities' => $activities,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 編輯頁面
|
||||
*/
|
||||
public function edit($id)
|
||||
{
|
||||
$requisition = StoreRequisition::with(['items.product.baseUnit'])->findOrFail($id);
|
||||
|
||||
if (!in_array($requisition->status, ['draft', 'rejected'])) {
|
||||
return redirect()->route('store-requisitions.show', $id)
|
||||
->with('error', '僅能編輯草稿或被駁回的叫貨單');
|
||||
}
|
||||
|
||||
$warehouses = Warehouse::select('id', 'name', 'type')->get();
|
||||
$products = Product::select('id', 'name', 'code', 'base_unit_id')
|
||||
->with('baseUnit:id,name')
|
||||
->where('is_active', true)
|
||||
->get();
|
||||
|
||||
return Inertia::render('StoreRequisition/Create', [
|
||||
'requisition' => $requisition,
|
||||
'warehouses' => $warehouses->map(fn($w) => [
|
||||
'id' => $w->id,
|
||||
'name' => $w->name,
|
||||
'type' => $w->type?->value,
|
||||
]),
|
||||
'products' => $products->map(fn($p) => [
|
||||
'id' => $p->id,
|
||||
'name' => $p->name,
|
||||
'code' => $p->code,
|
||||
'unit_name' => $p->baseUnit?->name,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新叫貨單
|
||||
*/
|
||||
public function update(Request $request, $id)
|
||||
{
|
||||
$requisition = StoreRequisition::findOrFail($id);
|
||||
|
||||
$request->validate([
|
||||
'store_warehouse_id' => 'required|exists:warehouses,id',
|
||||
'remark' => 'nullable|string|max:500',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.product_id' => 'required|exists:products,id',
|
||||
'items.*.requested_qty' => 'required|numeric|min:0.01',
|
||||
'items.*.remark' => 'nullable|string|max:200',
|
||||
]);
|
||||
|
||||
$requisition = $this->service->update(
|
||||
$requisition,
|
||||
$request->only(['store_warehouse_id', 'remark']),
|
||||
$request->items
|
||||
);
|
||||
|
||||
// 如果需要直接提交
|
||||
if ($request->boolean('submit_immediately')) {
|
||||
$this->service->submit($requisition, auth()->id());
|
||||
return redirect()->route('store-requisitions.index')
|
||||
->with('success', '叫貨單已重新提交審核');
|
||||
}
|
||||
|
||||
return redirect()->route('store-requisitions.show', $requisition->id)
|
||||
->with('success', '叫貨單已更新');
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交審核
|
||||
*/
|
||||
public function submit($id)
|
||||
{
|
||||
$requisition = StoreRequisition::findOrFail($id);
|
||||
$this->service->submit($requisition, auth()->id());
|
||||
|
||||
return redirect()->route('store-requisitions.show', $id)
|
||||
->with('success', '叫貨單已提交審核');
|
||||
}
|
||||
|
||||
/**
|
||||
* 核准叫貨單
|
||||
*/
|
||||
public function approve(Request $request, $id)
|
||||
{
|
||||
$requisition = StoreRequisition::findOrFail($id);
|
||||
|
||||
$request->validate([
|
||||
'supply_warehouse_id' => 'required|exists:warehouses,id',
|
||||
'items' => 'required|array',
|
||||
'items.*.id' => 'required|exists:store_requisition_items,id',
|
||||
'items.*.approved_qty' => 'required|numeric|min:0',
|
||||
], [
|
||||
'supply_warehouse_id.required' => '請選擇供貨倉庫',
|
||||
]);
|
||||
|
||||
$this->service->approve($requisition, $request->only(['supply_warehouse_id', 'items']), auth()->id());
|
||||
|
||||
return redirect()->route('store-requisitions.show', $id)
|
||||
->with('success', '叫貨單已核准,調撥單已自動產生');
|
||||
}
|
||||
|
||||
/**
|
||||
* 駁回叫貨單
|
||||
*/
|
||||
public function reject(Request $request, $id)
|
||||
{
|
||||
$requisition = StoreRequisition::findOrFail($id);
|
||||
|
||||
$request->validate([
|
||||
'reject_reason' => 'required|string|max:500',
|
||||
], [
|
||||
'reject_reason.required' => '請填寫駁回原因',
|
||||
]);
|
||||
|
||||
$this->service->reject($requisition, $request->reject_reason, auth()->id());
|
||||
|
||||
return redirect()->route('store-requisitions.show', $id)
|
||||
->with('success', '叫貨單已駁回');
|
||||
}
|
||||
|
||||
/**
|
||||
* 刪除叫貨單(僅限草稿)
|
||||
*/
|
||||
public function destroy($id)
|
||||
{
|
||||
$requisition = StoreRequisition::findOrFail($id);
|
||||
|
||||
if ($requisition->status !== 'draft') {
|
||||
return back()->withErrors(['error' => '僅能刪除草稿狀態的叫貨單']);
|
||||
}
|
||||
|
||||
$requisition->items()->delete();
|
||||
$requisition->delete();
|
||||
|
||||
return redirect()->route('store-requisitions.index')
|
||||
->with('success', '叫貨單已刪除');
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,13 @@
|
||||
namespace App\Modules\Inventory\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Enums\WarehouseType;
|
||||
use App\Modules\Inventory\Models\InventoryTransferOrder;
|
||||
use App\Modules\Inventory\Models\Warehouse;
|
||||
use App\Modules\Inventory\Models\Inventory;
|
||||
use App\Modules\Inventory\Services\TransferService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class TransferOrderController extends Controller
|
||||
@@ -65,6 +67,7 @@ class TransferOrderController extends Controller
|
||||
$validated = $request->validate([
|
||||
'from_warehouse_id' => 'required_without:sourceWarehouseId|exists:warehouses,id',
|
||||
'to_warehouse_id' => 'required_without:targetWarehouseId|exists:warehouses,id|different:from_warehouse_id',
|
||||
'transit_warehouse_id' => 'nullable|exists:warehouses,id',
|
||||
'remarks' => 'nullable|string',
|
||||
'notes' => 'nullable|string',
|
||||
'instant_post' => 'boolean',
|
||||
@@ -75,20 +78,22 @@ class TransferOrderController extends Controller
|
||||
]);
|
||||
|
||||
$remarks = $validated['remarks'] ?? $validated['notes'] ?? null;
|
||||
$transitWarehouseId = $validated['transit_warehouse_id'] ?? null;
|
||||
|
||||
$order = $this->transferService->createOrder(
|
||||
$fromId,
|
||||
$toId,
|
||||
$remarks,
|
||||
auth()->id()
|
||||
auth()->id(),
|
||||
$transitWarehouseId
|
||||
);
|
||||
|
||||
if ($request->input('instant_post') === true) {
|
||||
try {
|
||||
$this->transferService->post($order, auth()->id());
|
||||
$this->transferService->dispatch($order, auth()->id());
|
||||
|
||||
return redirect()->back()->with('success', '撥補成功,庫存已更新');
|
||||
} catch (\Exception $e) {
|
||||
// 如果過帳失敗,雖然單據已建立,但應回報錯誤
|
||||
return redirect()->back()->withErrors(['items' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
@@ -99,22 +104,37 @@ class TransferOrderController extends Controller
|
||||
|
||||
public function show(InventoryTransferOrder $order)
|
||||
{
|
||||
$order->load(['items.product.baseUnit', 'fromWarehouse', 'toWarehouse', 'createdBy', 'postedBy']);
|
||||
$order->load(['items.product.baseUnit', 'fromWarehouse', 'toWarehouse', 'transitWarehouse', 'createdBy', 'postedBy', 'dispatchedBy', 'receivedBy', 'storeRequisition']);
|
||||
|
||||
$orderData = [
|
||||
'id' => (string) $order->id,
|
||||
'doc_no' => $order->doc_no,
|
||||
'from_warehouse_id' => (string) $order->from_warehouse_id,
|
||||
'from_warehouse_name' => $order->fromWarehouse->name,
|
||||
'from_warehouse_default_transit' => $order->fromWarehouse->default_transit_warehouse_id ? (string)$order->fromWarehouse->default_transit_warehouse_id : null,
|
||||
'to_warehouse_id' => (string) $order->to_warehouse_id,
|
||||
'to_warehouse_name' => $order->toWarehouse->name,
|
||||
'to_warehouse_type' => $order->toWarehouse->type->value, // 用於判斷是否為販賣機
|
||||
'to_warehouse_type' => $order->toWarehouse->type->value,
|
||||
// 在途倉資訊
|
||||
'transit_warehouse_id' => $order->transit_warehouse_id ? (string) $order->transit_warehouse_id : null,
|
||||
'transit_warehouse_name' => $order->transitWarehouse?->name,
|
||||
'transit_warehouse_plate' => $order->transitWarehouse?->license_plate,
|
||||
'transit_warehouse_driver' => $order->transitWarehouse?->driver_name,
|
||||
'status' => $order->status,
|
||||
'remarks' => $order->remarks,
|
||||
'created_at' => $order->created_at->format('Y-m-d H:i'),
|
||||
'created_by' => $order->createdBy?->name,
|
||||
'posted_at' => $order->posted_at?->format('Y-m-d H:i'),
|
||||
'posted_by' => $order->postedBy?->name,
|
||||
'dispatched_at' => $order->dispatched_at?->format('Y-m-d H:i'),
|
||||
'dispatched_by' => $order->dispatchedBy?->name,
|
||||
'received_at' => $order->received_at?->format('Y-m-d H:i'),
|
||||
'received_by' => $order->receivedBy?->name,
|
||||
'requisition' => $order->storeRequisition ? [
|
||||
'id' => (string) $order->storeRequisition->id,
|
||||
'doc_no' => $order->storeRequisition->doc_no,
|
||||
] : null,
|
||||
'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)
|
||||
@@ -136,18 +156,51 @@ class TransferOrderController extends Controller
|
||||
}),
|
||||
];
|
||||
|
||||
// 取得在途倉庫列表供前端選擇
|
||||
$transitWarehouses = Warehouse::where('type', WarehouseType::TRANSIT)
|
||||
->get()
|
||||
->map(fn($w) => [
|
||||
'id' => (string) $w->id,
|
||||
'name' => $w->name,
|
||||
'license_plate' => $w->license_plate,
|
||||
'driver_name' => $w->driver_name,
|
||||
]);
|
||||
|
||||
return Inertia::render('Inventory/Transfer/Show', [
|
||||
'order' => $orderData,
|
||||
'transitWarehouses' => $transitWarehouses,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, InventoryTransferOrder $order)
|
||||
{
|
||||
// 收貨動作:僅限 dispatched 狀態
|
||||
if ($request->input('action') === 'receive') {
|
||||
if ($order->status !== 'dispatched') {
|
||||
return redirect()->back()->with('error', '僅能對已出貨的調撥單進行收貨確認');
|
||||
}
|
||||
try {
|
||||
$this->transferService->receive($order, auth()->id());
|
||||
return redirect()->route('inventory.transfer.index')
|
||||
->with('success', '調撥單已收貨完成');
|
||||
} catch (ValidationException $e) {
|
||||
return redirect()->back()->withErrors($e->errors());
|
||||
} catch (\Exception $e) {
|
||||
return redirect()->back()->withErrors(['items' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
// 以下操作僅限草稿
|
||||
if ($order->status !== 'draft') {
|
||||
return redirect()->back()->with('error', '只能修改草稿狀態的單據');
|
||||
}
|
||||
|
||||
// 1. 先更新資料 (如果請求中包含 items,則先執行儲存)
|
||||
// 1. 更新在途倉庫(如果前端有傳)
|
||||
if ($request->has('transit_warehouse_id')) {
|
||||
$order->transit_warehouse_id = $request->input('transit_warehouse_id') ?: null;
|
||||
}
|
||||
|
||||
// 2. 先更新資料 (如果請求中包含 items,則先執行儲存)
|
||||
$itemsChanged = false;
|
||||
if ($request->has('items')) {
|
||||
$validated = $request->validate([
|
||||
@@ -167,20 +220,21 @@ class TransferOrderController extends Controller
|
||||
$order->remarks = $request->input('remarks');
|
||||
}
|
||||
|
||||
if ($itemsChanged || $remarksChanged) {
|
||||
// [IMPORTANT] 使用 touch() 確保即便只有品項異動,也會因為 updated_at 變更而觸發自動日誌
|
||||
if ($itemsChanged || $remarksChanged || $order->isDirty()) {
|
||||
$order->touch();
|
||||
$message = '儲存成功';
|
||||
} else {
|
||||
$message = '資料未變更';
|
||||
}
|
||||
|
||||
// 2. 判斷是否需要過帳
|
||||
// 3. 判斷是否需要出貨/過帳
|
||||
if ($request->input('action') === 'post') {
|
||||
try {
|
||||
$this->transferService->post($order, auth()->id());
|
||||
$this->transferService->dispatch($order, auth()->id());
|
||||
$hasTransit = !empty($order->transit_warehouse_id);
|
||||
$successMsg = $hasTransit ? '調撥單已出貨,庫存已轉入在途倉' : '調撥單已過帳完成';
|
||||
return redirect()->route('inventory.transfer.index')
|
||||
->with('success', '調撥單已過帳完成');
|
||||
->with('success', $successMsg);
|
||||
} catch (ValidationException $e) {
|
||||
return redirect()->back()->withErrors($e->errors());
|
||||
} catch (\Exception $e) {
|
||||
|
||||
@@ -113,9 +113,22 @@ class WarehouseController extends Controller
|
||||
'book_amount' => \App\Modules\Inventory\Models\Inventory::sum('total_value'),
|
||||
];
|
||||
|
||||
// 取得在途倉列表供前端選擇「預設在途倉」
|
||||
$transitWarehouses = Warehouse::where('type', \App\Enums\WarehouseType::TRANSIT)
|
||||
->select('id', 'name', 'license_plate', 'driver_name')
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(fn ($w) => [
|
||||
'id' => (string) $w->id,
|
||||
'name' => $w->name,
|
||||
'license_plate' => $w->license_plate,
|
||||
'driver_name' => $w->driver_name,
|
||||
]);
|
||||
|
||||
return Inertia::render('Warehouse/Index', [
|
||||
'warehouses' => $warehouses,
|
||||
'totals' => $totals,
|
||||
'transitWarehouses' => $transitWarehouses,
|
||||
'filters' => $request->only(['search', 'per_page']),
|
||||
]);
|
||||
}
|
||||
@@ -130,6 +143,7 @@ class WarehouseController extends Controller
|
||||
'type' => 'required|string',
|
||||
'license_plate' => 'nullable|string|max:20',
|
||||
'driver_name' => 'nullable|string|max:50',
|
||||
'default_transit_warehouse_id' => 'nullable|exists:warehouses,id',
|
||||
]);
|
||||
|
||||
Warehouse::create($validated);
|
||||
@@ -147,6 +161,7 @@ class WarehouseController extends Controller
|
||||
'type' => 'required|string',
|
||||
'license_plate' => 'nullable|string|max:20',
|
||||
'driver_name' => 'nullable|string|max:50',
|
||||
'default_transit_warehouse_id' => 'nullable|exists:warehouses,id',
|
||||
]);
|
||||
|
||||
$warehouse->update($validated);
|
||||
|
||||
@@ -106,16 +106,23 @@ class InventoryTransferOrder extends Model
|
||||
'doc_no',
|
||||
'from_warehouse_id',
|
||||
'to_warehouse_id',
|
||||
'transit_warehouse_id',
|
||||
'status',
|
||||
'remarks',
|
||||
'posted_at',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'posted_by',
|
||||
'dispatched_at',
|
||||
'dispatched_by',
|
||||
'received_at',
|
||||
'received_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'posted_at' => 'datetime',
|
||||
'dispatched_at' => 'datetime',
|
||||
'received_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected static function boot()
|
||||
@@ -163,8 +170,28 @@ class InventoryTransferOrder extends Model
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function storeRequisition(): \Illuminate\Database\Eloquent\Relations\HasOne
|
||||
{
|
||||
return $this->hasOne(StoreRequisition::class, 'transfer_order_id');
|
||||
}
|
||||
|
||||
public function postedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'posted_by');
|
||||
}
|
||||
|
||||
public function transitWarehouse(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Warehouse::class, 'transit_warehouse_id');
|
||||
}
|
||||
|
||||
public function dispatchedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'dispatched_by');
|
||||
}
|
||||
|
||||
public function receivedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'received_by');
|
||||
}
|
||||
}
|
||||
|
||||
147
app/Modules/Inventory/Models/StoreRequisition.php
Normal file
147
app/Modules/Inventory/Models/StoreRequisition.php
Normal file
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Spatie\Activitylog\Traits\LogsActivity;
|
||||
use Spatie\Activitylog\LogOptions;
|
||||
use App\Modules\Core\Models\User;
|
||||
|
||||
class StoreRequisition extends Model
|
||||
{
|
||||
use HasFactory, LogsActivity;
|
||||
|
||||
protected $fillable = [
|
||||
'doc_no',
|
||||
'store_warehouse_id',
|
||||
'supply_warehouse_id',
|
||||
'status',
|
||||
'remark',
|
||||
'reject_reason',
|
||||
'created_by',
|
||||
'approved_by',
|
||||
'submitted_at',
|
||||
'approved_at',
|
||||
'transfer_order_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'submitted_at' => 'datetime',
|
||||
'approved_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function getActivitylogOptions(): LogOptions
|
||||
{
|
||||
return LogOptions::defaults()
|
||||
->logFillable()
|
||||
->logOnlyDirty()
|
||||
->dontSubmitEmptyLogs();
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定義日誌屬性,解析 ID 為名稱
|
||||
*/
|
||||
public function tapActivity(\Spatie\Activitylog\Models\Activity $activity, string $eventName)
|
||||
{
|
||||
$properties = $activity->properties->toArray();
|
||||
|
||||
// 基本單據資訊快照
|
||||
$properties['snapshot'] = [
|
||||
'doc_no' => $this->doc_no,
|
||||
'store_warehouse_name' => $this->storeWarehouse?->name,
|
||||
'supply_warehouse_name' => $this->supplyWarehouse?->name,
|
||||
'status' => $this->status,
|
||||
];
|
||||
|
||||
// 移除雜訊欄位
|
||||
if (isset($properties['attributes'])) {
|
||||
unset($properties['attributes']['updated_at']);
|
||||
}
|
||||
if (isset($properties['old'])) {
|
||||
unset($properties['old']['updated_at']);
|
||||
}
|
||||
|
||||
$activity->properties = collect($properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* 自動產生單號 SR-YYYYMMDD-XX
|
||||
*/
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function ($model) {
|
||||
if (empty($model->doc_no)) {
|
||||
$today = date('Ymd');
|
||||
$prefix = 'SR-' . $today . '-';
|
||||
|
||||
$lastDoc = static::where('doc_no', 'like', $prefix . '%')
|
||||
->orderBy('doc_no', 'desc')
|
||||
->first();
|
||||
|
||||
if ($lastDoc) {
|
||||
$lastNumber = substr($lastDoc->doc_no, -2);
|
||||
$nextNumber = str_pad((int)$lastNumber + 1, 2, '0', STR_PAD_LEFT);
|
||||
} else {
|
||||
$nextNumber = '01';
|
||||
}
|
||||
|
||||
$model->doc_no = $prefix . $nextNumber;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 關聯 =====
|
||||
|
||||
/**
|
||||
* 申請倉庫
|
||||
*/
|
||||
public function storeWarehouse(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Warehouse::class, 'store_warehouse_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 供貨倉庫(審核時填入)
|
||||
*/
|
||||
public function supplyWarehouse(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Warehouse::class, 'supply_warehouse_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 叫貨明細
|
||||
*/
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(StoreRequisitionItem::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 申請人
|
||||
*/
|
||||
public function createdBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
/**
|
||||
* 審核人
|
||||
*/
|
||||
public function approvedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'approved_by');
|
||||
}
|
||||
|
||||
/**
|
||||
* 關聯調撥單
|
||||
*/
|
||||
public function transferOrder(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InventoryTransferOrder::class, 'transfer_order_id');
|
||||
}
|
||||
}
|
||||
41
app/Modules/Inventory/Models/StoreRequisitionItem.php
Normal file
41
app/Modules/Inventory/Models/StoreRequisitionItem.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class StoreRequisitionItem extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'store_requisition_id',
|
||||
'product_id',
|
||||
'requested_qty',
|
||||
'approved_qty',
|
||||
'remark',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'requested_qty' => 'decimal:2',
|
||||
'approved_qty' => 'decimal:2',
|
||||
];
|
||||
|
||||
/**
|
||||
* 所屬叫貨單
|
||||
*/
|
||||
public function requisition(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(StoreRequisition::class, 'store_requisition_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 關聯商品(同模組)
|
||||
*/
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ class Warehouse extends Model
|
||||
'description',
|
||||
'license_plate',
|
||||
'driver_name',
|
||||
'default_transit_warehouse_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
@@ -50,7 +51,13 @@ class Warehouse extends Model
|
||||
return $this->hasMany(Inventory::class);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 預設在途倉庫
|
||||
*/
|
||||
public function defaultTransitWarehouse(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
{
|
||||
return $this->belongsTo(self::class, 'default_transit_warehouse_id');
|
||||
}
|
||||
|
||||
public function products(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
|
||||
{
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Notifications;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use App\Modules\Inventory\Models\StoreRequisition;
|
||||
|
||||
class StoreRequisitionNotification extends Notification
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
protected StoreRequisition $requisition;
|
||||
protected string $action;
|
||||
protected string $actorName;
|
||||
|
||||
/**
|
||||
* 建立通知實例
|
||||
*
|
||||
* @param StoreRequisition $requisition 叫貨單
|
||||
* @param string $action 操作類型:submitted / approved / rejected
|
||||
* @param string $actorName 操作者名稱
|
||||
*/
|
||||
public function __construct(StoreRequisition $requisition, string $action, string $actorName)
|
||||
{
|
||||
$this->requisition = $requisition;
|
||||
$this->action = $action;
|
||||
$this->actorName = $actorName;
|
||||
}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['database'];
|
||||
}
|
||||
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
$messages = [
|
||||
'submitted' => "{$this->actorName} 提交了叫貨申請:{$this->requisition->doc_no}",
|
||||
'approved' => "{$this->actorName} 核准了叫貨申請:{$this->requisition->doc_no}",
|
||||
'rejected' => "{$this->actorName} 駁回了叫貨申請:{$this->requisition->doc_no}",
|
||||
];
|
||||
|
||||
return [
|
||||
'type' => 'store_requisition',
|
||||
'action' => $this->action,
|
||||
'store_requisition_id' => $this->requisition->id,
|
||||
'doc_no' => $this->requisition->doc_no,
|
||||
'actor_name' => $this->actorName,
|
||||
'message' => $messages[$this->action] ?? "{$this->actorName} 操作了叫貨申請:{$this->requisition->doc_no}",
|
||||
'link' => route('store-requisitions.show', $this->requisition->id),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -141,6 +141,32 @@ Route::middleware('auth')->group(function () {
|
||||
->middleware('permission:inventory_transfer.view')
|
||||
->name('inventory.transfer.template');
|
||||
|
||||
// 門市叫貨申請 (Store Requisitions)
|
||||
Route::middleware('permission:store_requisitions.view')->group(function () {
|
||||
Route::get('/store-requisitions', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'index'])->name('store-requisitions.index');
|
||||
|
||||
Route::middleware('permission:store_requisitions.create')->group(function () {
|
||||
Route::get('/store-requisitions/create', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'create'])->name('store-requisitions.create');
|
||||
Route::post('/store-requisitions', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'store'])->name('store-requisitions.store');
|
||||
});
|
||||
|
||||
Route::get('/store-requisitions/{id}', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'show'])->name('store-requisitions.show');
|
||||
|
||||
Route::middleware('permission:store_requisitions.edit')->group(function () {
|
||||
Route::get('/store-requisitions/{id}/edit', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'edit'])->name('store-requisitions.edit');
|
||||
Route::put('/store-requisitions/{id}', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'update'])->name('store-requisitions.update');
|
||||
});
|
||||
|
||||
Route::post('/store-requisitions/{id}/submit', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'submit'])->name('store-requisitions.submit');
|
||||
|
||||
Route::middleware('permission:store_requisitions.approve')->group(function () {
|
||||
Route::post('/store-requisitions/{id}/approve', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'approve'])->name('store-requisitions.approve');
|
||||
Route::post('/store-requisitions/{id}/reject', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'reject'])->name('store-requisitions.reject');
|
||||
});
|
||||
|
||||
Route::delete('/store-requisitions/{id}', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'destroy'])->middleware('permission:store_requisitions.delete')->name('store-requisitions.destroy');
|
||||
});
|
||||
|
||||
// 進貨單 (Goods Receipts)
|
||||
Route::middleware('permission:goods_receipts.view')->group(function () {
|
||||
Route::get('/goods-receipts', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'index'])->name('goods-receipts.index');
|
||||
|
||||
247
app/Modules/Inventory/Services/StoreRequisitionService.php
Normal file
247
app/Modules/Inventory/Services/StoreRequisitionService.php
Normal file
@@ -0,0 +1,247 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Services;
|
||||
|
||||
use App\Modules\Inventory\Models\StoreRequisition;
|
||||
use App\Modules\Inventory\Models\StoreRequisitionItem;
|
||||
use App\Modules\Inventory\Models\InventoryTransferOrder;
|
||||
use App\Modules\Inventory\Models\InventoryTransferItem;
|
||||
use App\Modules\Inventory\Notifications\StoreRequisitionNotification;
|
||||
use App\Modules\Core\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class StoreRequisitionService
|
||||
{
|
||||
protected TransferService $transferService;
|
||||
|
||||
public function __construct(TransferService $transferService)
|
||||
{
|
||||
$this->transferService = $transferService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立叫貨單(含明細)
|
||||
*/
|
||||
public function create(array $data, array $items, int $userId): StoreRequisition
|
||||
{
|
||||
return DB::transaction(function () use ($data, $items, $userId) {
|
||||
$requisition = StoreRequisition::create([
|
||||
'store_warehouse_id' => $data['store_warehouse_id'],
|
||||
'status' => 'draft',
|
||||
'remark' => $data['remark'] ?? null,
|
||||
'created_by' => $userId,
|
||||
]);
|
||||
|
||||
foreach ($items as $item) {
|
||||
$requisition->items()->create([
|
||||
'product_id' => $item['product_id'],
|
||||
'requested_qty' => $item['requested_qty'],
|
||||
'remark' => $item['remark'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
return $requisition->load('items');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新叫貨單(僅限 draft / rejected 狀態)
|
||||
*/
|
||||
public function update(StoreRequisition $requisition, array $data, array $items): StoreRequisition
|
||||
{
|
||||
if (!in_array($requisition->status, ['draft', 'rejected'])) {
|
||||
throw ValidationException::withMessages([
|
||||
'status' => '僅能編輯草稿或被駁回的叫貨單',
|
||||
]);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($requisition, $data, $items) {
|
||||
$requisition->update([
|
||||
'store_warehouse_id' => $data['store_warehouse_id'],
|
||||
'remark' => $data['remark'] ?? null,
|
||||
'reject_reason' => null, // 清除駁回原因
|
||||
]);
|
||||
|
||||
// 重建明細
|
||||
$requisition->items()->delete();
|
||||
foreach ($items as $item) {
|
||||
$requisition->items()->create([
|
||||
'product_id' => $item['product_id'],
|
||||
'requested_qty' => $item['requested_qty'],
|
||||
'remark' => $item['remark'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
return $requisition->load('items');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交審核(draft → pending)
|
||||
*/
|
||||
public function submit(StoreRequisition $requisition, int $userId): StoreRequisition
|
||||
{
|
||||
if ($requisition->status !== 'draft' && $requisition->status !== 'rejected') {
|
||||
throw ValidationException::withMessages([
|
||||
'status' => '僅能提交草稿或被駁回的叫貨單',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($requisition->items()->count() === 0) {
|
||||
throw ValidationException::withMessages([
|
||||
'items' => '叫貨單必須至少有一項商品',
|
||||
]);
|
||||
}
|
||||
|
||||
$requisition->update([
|
||||
'status' => 'pending',
|
||||
'submitted_at' => now(),
|
||||
'reject_reason' => null,
|
||||
]);
|
||||
|
||||
// 通知有審核權限的使用者
|
||||
$this->notifyApprovers($requisition, 'submitted', $userId);
|
||||
|
||||
return $requisition;
|
||||
}
|
||||
|
||||
/**
|
||||
* 核准叫貨單(pending → approved),選擇供貨倉庫並自動產生調撥單
|
||||
*/
|
||||
public function approve(StoreRequisition $requisition, array $data, int $userId): StoreRequisition
|
||||
{
|
||||
if ($requisition->status !== 'pending') {
|
||||
throw ValidationException::withMessages([
|
||||
'status' => '僅能核准待審核的叫貨單',
|
||||
]);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($requisition, $data, $userId) {
|
||||
// 更新核准數量
|
||||
if (isset($data['items'])) {
|
||||
foreach ($data['items'] as $itemData) {
|
||||
StoreRequisitionItem::where('id', $itemData['id'])
|
||||
->where('store_requisition_id', $requisition->id)
|
||||
->update(['approved_qty' => $itemData['approved_qty']]);
|
||||
}
|
||||
}
|
||||
|
||||
// 查詢供貨倉庫是否有預設在途倉
|
||||
$supplyWarehouse = \App\Modules\Inventory\Models\Warehouse::find($data['supply_warehouse_id']);
|
||||
$defaultTransitId = $supplyWarehouse?->default_transit_warehouse_id;
|
||||
|
||||
// 產生調撥單(供貨倉庫 → 門市倉庫)
|
||||
$transferOrder = $this->transferService->createOrder(
|
||||
fromWarehouseId: $data['supply_warehouse_id'],
|
||||
toWarehouseId: $requisition->store_warehouse_id,
|
||||
remarks: "由叫貨單 {$requisition->doc_no} 自動產生",
|
||||
userId: $userId,
|
||||
transitWarehouseId: $defaultTransitId,
|
||||
);
|
||||
|
||||
// 將核准的明細寫入調撥單
|
||||
$requisition->load('items');
|
||||
$transferItems = [];
|
||||
foreach ($requisition->items as $item) {
|
||||
$qty = $item->approved_qty ?? $item->requested_qty;
|
||||
if ($qty > 0) {
|
||||
$transferItems[] = [
|
||||
'product_id' => $item->product_id,
|
||||
'quantity' => $qty,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($transferItems)) {
|
||||
$this->transferService->updateItems($transferOrder, $transferItems);
|
||||
}
|
||||
|
||||
// 更新叫貨單狀態
|
||||
$requisition->update([
|
||||
'status' => 'approved',
|
||||
'supply_warehouse_id' => $data['supply_warehouse_id'],
|
||||
'approved_by' => $userId,
|
||||
'approved_at' => now(),
|
||||
'transfer_order_id' => $transferOrder->id,
|
||||
]);
|
||||
|
||||
// 通知申請人
|
||||
$this->notifyCreator($requisition, 'approved', $userId);
|
||||
|
||||
return $requisition->load(['items', 'transferOrder']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 駁回叫貨單(pending → rejected)
|
||||
*/
|
||||
public function reject(StoreRequisition $requisition, string $reason, int $userId): StoreRequisition
|
||||
{
|
||||
if ($requisition->status !== 'pending') {
|
||||
throw ValidationException::withMessages([
|
||||
'status' => '僅能駁回待審核的叫貨單',
|
||||
]);
|
||||
}
|
||||
|
||||
$requisition->update([
|
||||
'status' => 'rejected',
|
||||
'reject_reason' => $reason,
|
||||
'approved_by' => $userId,
|
||||
'approved_at' => now(),
|
||||
]);
|
||||
|
||||
// 通知申請人
|
||||
$this->notifyCreator($requisition, 'rejected', $userId);
|
||||
|
||||
return $requisition;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消叫貨單
|
||||
*/
|
||||
public function cancel(StoreRequisition $requisition): StoreRequisition
|
||||
{
|
||||
if (!in_array($requisition->status, ['draft', 'pending'])) {
|
||||
throw ValidationException::withMessages([
|
||||
'status' => '僅能取消草稿或待審核的叫貨單',
|
||||
]);
|
||||
}
|
||||
|
||||
$requisition->update(['status' => 'cancelled']);
|
||||
|
||||
return $requisition;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知有審核權限的使用者
|
||||
*/
|
||||
protected function notifyApprovers(StoreRequisition $requisition, string $action, int $actorId): void
|
||||
{
|
||||
$actor = User::find($actorId);
|
||||
$actorName = $actor?->name ?? 'System';
|
||||
|
||||
// 找出有 store_requisitions.approve 權限的使用者
|
||||
$approvers = User::permission('store_requisitions.approve')->get();
|
||||
|
||||
foreach ($approvers as $approver) {
|
||||
if ($approver->id !== $actorId) {
|
||||
$approver->notify(new StoreRequisitionNotification($requisition, $action, $actorName));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知叫貨單申請人
|
||||
*/
|
||||
protected function notifyCreator(StoreRequisition $requisition, string $action, int $actorId): void
|
||||
{
|
||||
$actor = User::find($actorId);
|
||||
$actorName = $actor?->name ?? 'System';
|
||||
|
||||
$creator = User::find($requisition->created_by);
|
||||
if ($creator && $creator->id !== $actorId) {
|
||||
$creator->notify(new StoreRequisitionNotification($requisition, $action, $actorName));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,27 +14,32 @@ class TransferService
|
||||
/**
|
||||
* 建立調撥單草稿
|
||||
*/
|
||||
public function createOrder(int $fromWarehouseId, int $toWarehouseId, ?string $remarks, int $userId): InventoryTransferOrder
|
||||
public function createOrder(int $fromWarehouseId, int $toWarehouseId, ?string $remarks, int $userId, ?int $transitWarehouseId = null): InventoryTransferOrder
|
||||
{
|
||||
// 若未指定在途倉,嘗試使用來源倉庫的預設在途倉 (一次性設定)
|
||||
if (is_null($transitWarehouseId)) {
|
||||
$fromWarehouse = Warehouse::find($fromWarehouseId);
|
||||
if ($fromWarehouse && $fromWarehouse->default_transit_warehouse_id) {
|
||||
$transitWarehouseId = $fromWarehouse->default_transit_warehouse_id;
|
||||
}
|
||||
}
|
||||
|
||||
return InventoryTransferOrder::create([
|
||||
'from_warehouse_id' => $fromWarehouseId,
|
||||
'to_warehouse_id' => $toWarehouseId,
|
||||
'transit_warehouse_id' => $transitWarehouseId,
|
||||
'status' => 'draft',
|
||||
'remarks' => $remarks,
|
||||
'created_by' => $userId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新調撥單明細
|
||||
*/
|
||||
/**
|
||||
* 更新調撥單明細 (支援精確 Diff 與自動日誌整合)
|
||||
*/
|
||||
public function updateItems(InventoryTransferOrder $order, array $itemsData): bool
|
||||
{
|
||||
return DB::transaction(function () use ($order, $itemsData) {
|
||||
// 1. 準備舊資料索引 (Key: product_id . '_' . batch_number)
|
||||
$oldItemsMap = $order->items->mapWithKeys(function ($item) {
|
||||
$key = $item->product_id . '_' . ($item->batch_number ?? '');
|
||||
return [$key => $item];
|
||||
@@ -46,13 +51,7 @@ class TransferService
|
||||
'updated' => [],
|
||||
];
|
||||
|
||||
// 2. 處理新資料 (Deleted and Re-inserted currently for simplicity, but logic simulates update)
|
||||
// 為了保持 ID 當作外鍵的穩定性,最佳做法是 update 存在的,create 新的,delete 舊的。
|
||||
// 但考量現有邏輯是 delete all -> create all,我們維持原策略但優化 Diff 計算。
|
||||
|
||||
// 由於採用全刪重建,我們必須手動計算 Diff
|
||||
$order->items()->delete();
|
||||
|
||||
$newItemsKeys = [];
|
||||
|
||||
foreach ($itemsData as $data) {
|
||||
@@ -66,13 +65,10 @@ class TransferService
|
||||
'position' => $data['position'] ?? null,
|
||||
'notes' => $data['notes'] ?? null,
|
||||
]);
|
||||
// Eager load product for name
|
||||
$item->load('product');
|
||||
|
||||
// 比對邏輯
|
||||
if ($oldItemsMap->has($key)) {
|
||||
$oldItem = $oldItemsMap->get($key);
|
||||
// 檢查數值是否有變動
|
||||
if ((float)$oldItem->quantity !== (float)$data['quantity'] ||
|
||||
$oldItem->notes !== ($data['notes'] ?? null) ||
|
||||
$oldItem->position !== ($data['position'] ?? null)) {
|
||||
@@ -92,7 +88,6 @@ class TransferService
|
||||
];
|
||||
}
|
||||
} else {
|
||||
// 新增 (使用者需求:顯示為更新,從 0 -> X)
|
||||
$diff['updated'][] = [
|
||||
'product_name' => $item->product->name,
|
||||
'old' => [
|
||||
@@ -107,7 +102,6 @@ class TransferService
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 處理被移除的項目
|
||||
foreach ($oldItemsMap as $key => $oldItem) {
|
||||
if (!in_array($key, $newItemsKeys)) {
|
||||
$diff['removed'][] = [
|
||||
@@ -120,7 +114,6 @@ class TransferService
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 將 Diff 注入到 Model 的暫存屬性中
|
||||
$hasChanged = !empty($diff['added']) || !empty($diff['removed']) || !empty($diff['updated']);
|
||||
if ($hasChanged) {
|
||||
$order->activityProperties['items_diff'] = $diff;
|
||||
@@ -131,16 +124,24 @@ class TransferService
|
||||
}
|
||||
|
||||
/**
|
||||
* 過帳 (Post) - 執行調撥 (直接扣除來源,增加目的)
|
||||
* 出貨 (Dispatch) - 根據是否有在途倉決定流程
|
||||
*
|
||||
* 有在途倉:來源倉扣除 → 在途倉增加,狀態改為 dispatched
|
||||
* 無在途倉:來源倉扣除 → 目的倉增加,狀態改為 completed(維持原有邏輯)
|
||||
*/
|
||||
public function post(InventoryTransferOrder $order, int $userId): void
|
||||
public function dispatch(InventoryTransferOrder $order, int $userId): void
|
||||
{
|
||||
// [IMPORTANT] 強制重新載入品項,因為在 Controller 中可能剛執行過 updateItems,導致記憶體中快取的 items 是舊的或空的
|
||||
$order->load('items.product');
|
||||
|
||||
DB::transaction(function () use ($order, $userId) {
|
||||
$fromWarehouse = $order->fromWarehouse;
|
||||
$toWarehouse = $order->toWarehouse;
|
||||
$hasTransit = !empty($order->transit_warehouse_id);
|
||||
|
||||
$targetWarehouseId = $hasTransit ? $order->transit_warehouse_id : $order->to_warehouse_id;
|
||||
$targetWarehouse = $hasTransit ? $order->transitWarehouse : $order->toWarehouse;
|
||||
|
||||
$outType = '調撥出庫';
|
||||
$inType = $hasTransit ? '在途入庫' : '調撥入庫';
|
||||
|
||||
foreach ($order->items as $item) {
|
||||
if ($item->quantity <= 0) continue;
|
||||
@@ -162,46 +163,41 @@ class TransferService
|
||||
$oldSourceQty = $sourceInventory->quantity;
|
||||
$newSourceQty = $oldSourceQty - $item->quantity;
|
||||
|
||||
// 儲存庫存快照
|
||||
$item->update(['snapshot_quantity' => $oldSourceQty]);
|
||||
|
||||
$sourceInventory->quantity = $newSourceQty;
|
||||
// 更新總值 (假設成本不變)
|
||||
$sourceInventory->total_value = $sourceInventory->quantity * $sourceInventory->unit_cost;
|
||||
$sourceInventory->save();
|
||||
|
||||
// 記錄來源交易
|
||||
$sourceInventory->transactions()->create([
|
||||
'type' => '調撥出庫',
|
||||
'type' => $outType,
|
||||
'quantity' => -$item->quantity,
|
||||
'unit_cost' => $sourceInventory->unit_cost,
|
||||
'balance_before' => $oldSourceQty,
|
||||
'balance_after' => $newSourceQty,
|
||||
'reason' => "調撥單 {$order->doc_no} 至 {$toWarehouse->name}",
|
||||
'reason' => "調撥單 {$order->doc_no} 至 {$targetWarehouse->name}",
|
||||
'actual_time' => now(),
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
|
||||
// 2. 處理目的倉 (增加)
|
||||
// 2. 處理目的倉/在途倉 (增加)
|
||||
$targetInventory = Inventory::firstOrCreate(
|
||||
[
|
||||
'warehouse_id' => $order->to_warehouse_id,
|
||||
'warehouse_id' => $targetWarehouseId,
|
||||
'product_id' => $item->product_id,
|
||||
'batch_number' => $item->batch_number,
|
||||
'location' => $item->position, // 同步貨道至庫存位置
|
||||
'location' => $hasTransit ? null : ($item->position ?? null),
|
||||
],
|
||||
[
|
||||
'quantity' => 0,
|
||||
'unit_cost' => $sourceInventory->unit_cost, // 繼承成本
|
||||
'unit_cost' => $sourceInventory->unit_cost,
|
||||
'total_value' => 0,
|
||||
// 繼承其他屬性
|
||||
'expiry_date' => $sourceInventory->expiry_date,
|
||||
'quality_status' => $sourceInventory->quality_status,
|
||||
'origin_country' => $sourceInventory->origin_country,
|
||||
]
|
||||
);
|
||||
|
||||
// 若是新建立的,且成本為0,確保繼承成本
|
||||
if ($targetInventory->wasRecentlyCreated && $targetInventory->unit_cost == 0) {
|
||||
$targetInventory->unit_cost = $sourceInventory->unit_cost;
|
||||
}
|
||||
@@ -213,9 +209,8 @@ class TransferService
|
||||
$targetInventory->total_value = $targetInventory->quantity * $targetInventory->unit_cost;
|
||||
$targetInventory->save();
|
||||
|
||||
// 記錄目的交易
|
||||
$targetInventory->transactions()->create([
|
||||
'type' => '調撥入庫',
|
||||
'type' => $inType,
|
||||
'quantity' => $item->quantity,
|
||||
'unit_cost' => $targetInventory->unit_cost,
|
||||
'balance_before' => $oldTargetQty,
|
||||
@@ -226,28 +221,126 @@ class TransferService
|
||||
]);
|
||||
}
|
||||
|
||||
// 準備品項快照供日誌使用
|
||||
$itemsSnapshot = $order->items->map(function($item) {
|
||||
return [
|
||||
'product_name' => $item->product->name,
|
||||
'old' => [
|
||||
'quantity' => (float)$item->quantity,
|
||||
'notes' => $item->notes,
|
||||
if ($hasTransit) {
|
||||
$order->status = 'dispatched';
|
||||
$order->dispatched_at = now();
|
||||
$order->dispatched_by = $userId;
|
||||
} else {
|
||||
$order->status = 'completed';
|
||||
$order->posted_at = now();
|
||||
$order->posted_by = $userId;
|
||||
}
|
||||
$order->save();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 收貨確認 (Receive) - 在途倉扣除 → 目的倉增加
|
||||
* 僅適用於有在途倉且狀態為 dispatched 的調撥單
|
||||
*/
|
||||
public function receive(InventoryTransferOrder $order, int $userId): void
|
||||
{
|
||||
if ($order->status !== 'dispatched') {
|
||||
throw new \Exception('僅能對已出貨的調撥單進行收貨確認');
|
||||
}
|
||||
|
||||
if (empty($order->transit_warehouse_id)) {
|
||||
throw new \Exception('此調撥單未設定在途倉庫');
|
||||
}
|
||||
|
||||
$order->load('items.product');
|
||||
|
||||
DB::transaction(function () use ($order, $userId) {
|
||||
$transitWarehouse = $order->transitWarehouse;
|
||||
$toWarehouse = $order->toWarehouse;
|
||||
|
||||
foreach ($order->items as $item) {
|
||||
if ($item->quantity <= 0) continue;
|
||||
|
||||
// 1. 在途倉扣除
|
||||
$transitInventory = Inventory::where('warehouse_id', $order->transit_warehouse_id)
|
||||
->where('product_id', $item->product_id)
|
||||
->where('batch_number', $item->batch_number)
|
||||
->first();
|
||||
|
||||
if (!$transitInventory || $transitInventory->quantity < $item->quantity) {
|
||||
$availableQty = $transitInventory->quantity ?? 0;
|
||||
throw ValidationException::withMessages([
|
||||
'items' => ["商品 {$item->product->name} 在途倉庫存不足。現有:{$availableQty},需要:{$item->quantity}。"],
|
||||
]);
|
||||
}
|
||||
|
||||
$oldTransitQty = $transitInventory->quantity;
|
||||
$newTransitQty = $oldTransitQty - $item->quantity;
|
||||
|
||||
$transitInventory->quantity = $newTransitQty;
|
||||
$transitInventory->total_value = $transitInventory->quantity * $transitInventory->unit_cost;
|
||||
$transitInventory->save();
|
||||
|
||||
$transitInventory->transactions()->create([
|
||||
'type' => '在途出庫',
|
||||
'quantity' => -$item->quantity,
|
||||
'unit_cost' => $transitInventory->unit_cost,
|
||||
'balance_before' => $oldTransitQty,
|
||||
'balance_after' => $newTransitQty,
|
||||
'reason' => "調撥單 {$order->doc_no} 配送至 {$toWarehouse->name}",
|
||||
'actual_time' => now(),
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
|
||||
// 2. 目的倉增加
|
||||
$targetInventory = Inventory::firstOrCreate(
|
||||
[
|
||||
'warehouse_id' => $order->to_warehouse_id,
|
||||
'product_id' => $item->product_id,
|
||||
'batch_number' => $item->batch_number,
|
||||
'location' => $item->position,
|
||||
],
|
||||
'new' => [
|
||||
'quantity' => (float)$item->quantity,
|
||||
'notes' => $item->notes,
|
||||
[
|
||||
'quantity' => 0,
|
||||
'unit_cost' => $transitInventory->unit_cost,
|
||||
'total_value' => 0,
|
||||
'expiry_date' => $transitInventory->expiry_date,
|
||||
'quality_status' => $transitInventory->quality_status,
|
||||
'origin_country' => $transitInventory->origin_country,
|
||||
]
|
||||
];
|
||||
})->toArray();
|
||||
);
|
||||
|
||||
if ($targetInventory->wasRecentlyCreated && $targetInventory->unit_cost == 0) {
|
||||
$targetInventory->unit_cost = $transitInventory->unit_cost;
|
||||
}
|
||||
|
||||
$oldTargetQty = $targetInventory->quantity;
|
||||
$newTargetQty = $oldTargetQty + $item->quantity;
|
||||
|
||||
$targetInventory->quantity = $newTargetQty;
|
||||
$targetInventory->total_value = $targetInventory->quantity * $targetInventory->unit_cost;
|
||||
$targetInventory->save();
|
||||
|
||||
$targetInventory->transactions()->create([
|
||||
'type' => '調撥入庫',
|
||||
'quantity' => $item->quantity,
|
||||
'unit_cost' => $targetInventory->unit_cost,
|
||||
'balance_before' => $oldTargetQty,
|
||||
'balance_after' => $newTargetQty,
|
||||
'reason' => "調撥單 {$order->doc_no} 來自 {$transitWarehouse->name}",
|
||||
'actual_time' => now(),
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
}
|
||||
|
||||
$order->status = 'completed';
|
||||
$order->posted_at = now();
|
||||
$order->posted_by = $userId;
|
||||
$order->save(); // 觸發自動日誌
|
||||
$order->received_at = now();
|
||||
$order->received_by = $userId;
|
||||
$order->save();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 作廢 (Void) - 僅限草稿狀態
|
||||
*/
|
||||
public function void(InventoryTransferOrder $order, int $userId): void
|
||||
{
|
||||
if ($order->status !== 'draft') {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 門市叫貨申請主表
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('store_requisitions', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('doc_no')->unique()->comment('單號 SR-YYYYMMDD-XX');
|
||||
$table->unsignedBigInteger('store_warehouse_id')->comment('申請倉庫(任意類型)');
|
||||
$table->unsignedBigInteger('supply_warehouse_id')->nullable()->comment('供貨倉庫(審核時填入)');
|
||||
$table->enum('status', ['draft', 'pending', 'approved', 'rejected', 'completed', 'cancelled'])
|
||||
->default('draft');
|
||||
$table->text('remark')->nullable()->comment('申請備註');
|
||||
$table->text('reject_reason')->nullable()->comment('駁回原因');
|
||||
$table->unsignedBigInteger('created_by')->comment('申請人');
|
||||
$table->unsignedBigInteger('approved_by')->nullable()->comment('審核人');
|
||||
$table->timestamp('submitted_at')->nullable()->comment('提交時間');
|
||||
$table->timestamp('approved_at')->nullable()->comment('審核時間');
|
||||
$table->unsignedBigInteger('transfer_order_id')->nullable()->comment('關聯調撥單');
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('status');
|
||||
$table->index('store_warehouse_id');
|
||||
$table->index('created_by');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('store_requisitions');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 門市叫貨申請明細表
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('store_requisition_items', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('store_requisition_id')->constrained()->cascadeOnDelete();
|
||||
$table->unsignedBigInteger('product_id');
|
||||
$table->decimal('requested_qty', 12, 2)->comment('需求數量');
|
||||
$table->decimal('approved_qty', 12, 2)->nullable()->comment('核准數量(審核時填入)');
|
||||
$table->text('remark')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('product_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('store_requisition_items');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 在調撥單中新增在途倉庫相關欄位
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('inventory_transfer_orders', function (Blueprint $table) {
|
||||
// 在途倉庫(可選)
|
||||
$table->foreignId('transit_warehouse_id')
|
||||
->nullable()
|
||||
->after('to_warehouse_id')
|
||||
->constrained('warehouses')
|
||||
->nullOnDelete();
|
||||
|
||||
// 出貨資訊
|
||||
$table->timestamp('dispatched_at')->nullable()->after('posted_at');
|
||||
$table->foreignId('dispatched_by')->nullable()->after('dispatched_at')->constrained('users')->nullOnDelete();
|
||||
|
||||
// 收貨確認資訊
|
||||
$table->timestamp('received_at')->nullable()->after('dispatched_by');
|
||||
$table->foreignId('received_by')->nullable()->after('received_at')->constrained('users')->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('inventory_transfer_orders', function (Blueprint $table) {
|
||||
$table->dropForeign(['transit_warehouse_id']);
|
||||
$table->dropForeign(['dispatched_by']);
|
||||
$table->dropForeign(['received_by']);
|
||||
$table->dropColumn([
|
||||
'transit_warehouse_id',
|
||||
'dispatched_at',
|
||||
'dispatched_by',
|
||||
'received_at',
|
||||
'received_by',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 在倉庫表中新增預設在途倉庫欄位
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('warehouses', function (Blueprint $table) {
|
||||
$table->foreignId('default_transit_warehouse_id')
|
||||
->nullable()
|
||||
->after('driver_name')
|
||||
->constrained('warehouses')
|
||||
->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('warehouses', function (Blueprint $table) {
|
||||
$table->dropForeign(['default_transit_warehouse_id']);
|
||||
$table->dropColumn('default_transit_warehouse_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (!Schema::hasColumn('warehouses', 'default_transit_warehouse_id')) {
|
||||
Schema::table('warehouses', function (Blueprint $table) {
|
||||
$table->foreignId('default_transit_warehouse_id')
|
||||
->nullable()
|
||||
->after('driver_name')
|
||||
->comment('預設使用的在途倉(物流車)')
|
||||
->constrained('warehouses')
|
||||
->nullOnDelete();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('warehouses', function (Blueprint $table) {
|
||||
$table->dropForeign(['default_transit_warehouse_id']);
|
||||
$table->dropColumn('default_transit_warehouse_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -55,6 +55,8 @@ class PermissionSeeder extends Seeder
|
||||
'inventory_transfer.create' => '建立',
|
||||
'inventory_transfer.edit' => '編輯',
|
||||
'inventory_transfer.delete' => '刪除',
|
||||
'inventory_transfer.dispatch' => '確認出貨',
|
||||
'inventory_transfer.receive' => '確認收貨',
|
||||
|
||||
// 庫存報表
|
||||
'inventory_report.view' => '檢視',
|
||||
@@ -129,6 +131,14 @@ class PermissionSeeder extends Seeder
|
||||
'sales_imports.create' => '建立',
|
||||
'sales_imports.confirm' => '確認',
|
||||
'sales_imports.delete' => '刪除',
|
||||
|
||||
// 門市叫貨申請
|
||||
'store_requisitions.view' => '檢視',
|
||||
'store_requisitions.create' => '建立',
|
||||
'store_requisitions.edit' => '編輯',
|
||||
'store_requisitions.delete' => '刪除',
|
||||
'store_requisitions.approve' => '核準',
|
||||
'store_requisitions.cancel' => '取消',
|
||||
];
|
||||
|
||||
foreach ($permissions as $name => $displayName) {
|
||||
@@ -158,7 +168,7 @@ class PermissionSeeder extends Seeder
|
||||
'inventory.view', 'inventory.view_cost', 'inventory.delete',
|
||||
'inventory_count.view', 'inventory_count.create', 'inventory_count.edit', 'inventory_count.delete',
|
||||
'inventory_adjust.view', 'inventory_adjust.create', 'inventory_adjust.edit', 'inventory_adjust.delete',
|
||||
'inventory_transfer.view', 'inventory_transfer.create', 'inventory_transfer.edit', 'inventory_transfer.delete',
|
||||
'inventory_transfer.view', 'inventory_transfer.create', 'inventory_transfer.edit', 'inventory_transfer.delete', 'inventory_transfer.dispatch', 'inventory_transfer.receive',
|
||||
'inventory_report.view', 'inventory_report.export',
|
||||
'goods_receipts.view', 'goods_receipts.create', 'goods_receipts.edit', 'goods_receipts.delete',
|
||||
'delivery_notes.view', 'delivery_notes.create', 'delivery_notes.edit', 'delivery_notes.delete',
|
||||
@@ -172,6 +182,8 @@ class PermissionSeeder extends Seeder
|
||||
'utility_fees.view', 'utility_fees.create', 'utility_fees.edit', 'utility_fees.delete',
|
||||
'accounting.view', 'accounting.export',
|
||||
'sales_imports.view', 'sales_imports.create', 'sales_imports.confirm', 'sales_imports.delete',
|
||||
'store_requisitions.view', 'store_requisitions.create', 'store_requisitions.edit',
|
||||
'store_requisitions.delete', 'store_requisitions.approve', 'store_requisitions.cancel',
|
||||
]);
|
||||
|
||||
// warehouse-manager 管理庫存與倉庫
|
||||
@@ -180,12 +192,14 @@ class PermissionSeeder extends Seeder
|
||||
'inventory.view', 'inventory.delete',
|
||||
'inventory_count.view', 'inventory_count.create', 'inventory_count.edit', 'inventory_count.delete',
|
||||
'inventory_adjust.view', 'inventory_adjust.create', 'inventory_adjust.edit', 'inventory_adjust.delete',
|
||||
'inventory_transfer.view', 'inventory_transfer.create', 'inventory_transfer.edit', 'inventory_transfer.delete',
|
||||
'inventory_transfer.view', 'inventory_transfer.create', 'inventory_transfer.edit', 'inventory_transfer.delete', 'inventory_transfer.dispatch', 'inventory_transfer.receive',
|
||||
'inventory_report.view', 'inventory_report.export',
|
||||
'goods_receipts.view', 'goods_receipts.create', 'goods_receipts.edit', 'goods_receipts.delete',
|
||||
'goods_receipts.view', 'goods_receipts.create', 'goods_receipts.edit', 'goods_receipts.delete',
|
||||
'production_orders.view', 'production_orders.create', 'production_orders.edit',
|
||||
'warehouses.view', 'warehouses.create', 'warehouses.edit',
|
||||
'store_requisitions.view', 'store_requisitions.create', 'store_requisitions.edit',
|
||||
'store_requisitions.delete', 'store_requisitions.approve', 'store_requisitions.cancel',
|
||||
]);
|
||||
|
||||
// purchaser 管理採購與供應商
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { StatusBadge, StatusVariant } from "@/Components/shared/StatusBadge";
|
||||
|
||||
export type GoodsReceiptStatus = 'processing' | 'completed' | 'cancelled';
|
||||
|
||||
export const GOODS_RECEIPT_STATUS_CONFIG: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" | "success" | "warning" }> = {
|
||||
processing: { label: "處理中", variant: "warning" },
|
||||
export const GOODS_RECEIPT_STATUS_CONFIG: Record<string, { label: string; variant: StatusVariant }> = {
|
||||
processing: { label: "處理中", variant: "info" },
|
||||
completed: { label: "已完成", variant: "success" },
|
||||
cancelled: { label: "已取消", variant: "destructive" },
|
||||
};
|
||||
@@ -19,28 +19,9 @@ export default function GoodsReceiptStatusBadge({
|
||||
}: GoodsReceiptStatusBadgeProps) {
|
||||
const config = GOODS_RECEIPT_STATUS_CONFIG[status] || { label: "未知", variant: "outline" };
|
||||
|
||||
// Apply custom styling based on variant mapping if not using standard badge variants
|
||||
let badgeClass = "";
|
||||
switch (config.variant) {
|
||||
case "success":
|
||||
badgeClass = "bg-green-100 text-green-800 hover:bg-green-200 border-green-200";
|
||||
break;
|
||||
case "warning":
|
||||
badgeClass = "bg-yellow-100 text-yellow-800 hover:bg-yellow-200 border-yellow-200";
|
||||
break;
|
||||
case "destructive":
|
||||
badgeClass = "bg-red-100 text-red-800 hover:bg-red-200 border-red-200";
|
||||
break;
|
||||
default:
|
||||
badgeClass = "bg-gray-100 text-gray-800 hover:bg-gray-200 border-gray-200";
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`${className} font-medium px-2.5 py-0.5 rounded-full border ${badgeClass}`}
|
||||
>
|
||||
<StatusBadge variant={config.variant} className={className}>
|
||||
{config.label}
|
||||
</Badge>
|
||||
</StatusBadge>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { AlertTriangle, Edit, ChevronDown, ChevronRight, CheckCircle, Package } from "lucide-react";
|
||||
import { Edit, ChevronDown, ChevronRight, Package } from "lucide-react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -14,14 +14,14 @@ import {
|
||||
TableRow,
|
||||
} from "@/Components/ui/table";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { StatusBadge } from "@/Components/shared/StatusBadge";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/Components/ui/collapsible";
|
||||
import { WarehouseInventory, SafetyStockSetting } from "@/types/warehouse";
|
||||
import { calculateProductTotalStock, getSafetyStockStatus } from "@/utils/inventory";
|
||||
import { getSafetyStockStatus } from "@/utils/inventory";
|
||||
import { formatDate } from "@/utils/format";
|
||||
|
||||
export type InventoryItemWithId = WarehouseInventory & { inventoryId: string };
|
||||
@@ -74,31 +74,28 @@ export default function InventoryTable({
|
||||
|
||||
// 獲取狀態徽章
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case "正常":
|
||||
if (status === '正常') {
|
||||
return (
|
||||
<Badge className="bg-green-100 text-green-700 border-green-300">
|
||||
<CheckCircle className="mr-1 h-3 w-3" />
|
||||
正常
|
||||
</Badge>
|
||||
<StatusBadge variant="success">
|
||||
庫存充足
|
||||
</StatusBadge>
|
||||
);
|
||||
case "接近":
|
||||
return (
|
||||
<Badge className="bg-yellow-100 text-yellow-700 border-yellow-300">
|
||||
<AlertTriangle className="mr-1 h-3 w-3" />
|
||||
接近
|
||||
</Badge>
|
||||
);
|
||||
case "低於":
|
||||
return (
|
||||
<Badge className="bg-red-100 text-red-700 border-red-300">
|
||||
<AlertTriangle className="mr-1 h-3 w-3" />
|
||||
低於
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
if (status === '接近') {
|
||||
return (
|
||||
<StatusBadge variant="warning">
|
||||
低於安全存量
|
||||
</StatusBadge>
|
||||
);
|
||||
}
|
||||
if (status === '低於') {
|
||||
return (
|
||||
<StatusBadge variant="destructive">
|
||||
嚴重短缺
|
||||
</StatusBadge>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -128,8 +125,7 @@ export default function InventoryTable({
|
||||
{/* 商品標題 - 可點擊折疊 */}
|
||||
<CollapsibleTrigger asChild>
|
||||
<div
|
||||
className={`px-4 py-3 border-b cursor-pointer hover:bg-gray-100 transition-colors ${
|
||||
isLowStock ? "bg-red-50" : "bg-gray-50"
|
||||
className={`px-4 py-3 border-b cursor-pointer hover:bg-gray-100 transition-colors ${isLowStock ? "bg-red-50" : "bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -164,9 +160,9 @@ export default function InventoryTable({
|
||||
</>
|
||||
)}
|
||||
{!group.safetySetting && (
|
||||
<Badge variant="outline" className="text-gray-500">
|
||||
<StatusBadge variant="neutral">
|
||||
未設定
|
||||
</Badge>
|
||||
</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/Components/ui/table";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { StatusBadge } from "@/Components/shared/StatusBadge";
|
||||
import { Pencil, Trash2, ArrowUpDown, ArrowUp, ArrowDown, Eye } from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -122,15 +122,15 @@ export default function ProductTable({
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-grey-0">{product.name}</span>
|
||||
{product.brand && <Badge variant="secondary" className="text-[10px] h-4 px-1 bg-gray-100 text-gray-500 border-none">{product.brand}</Badge>}
|
||||
{product.brand && <StatusBadge variant="neutral" className="text-[10px] h-4 px-1">{product.brand}</StatusBadge>}
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 font-mono">代號: {product.code}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">
|
||||
<StatusBadge variant="neutral">
|
||||
{product.category?.name || '-'}
|
||||
</Badge>
|
||||
</StatusBadge>
|
||||
</TableCell>
|
||||
<TableCell>{product.baseUnit?.name || '-'}</TableCell>
|
||||
<TableCell>
|
||||
@@ -163,9 +163,9 @@ export default function ProductTable({
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{product.is_active ? (
|
||||
<Badge className="bg-green-100 text-green-700 hover:bg-green-100 border-none">啟用</Badge>
|
||||
<StatusBadge variant="success">啟用</StatusBadge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="bg-gray-100 text-gray-500 hover:bg-gray-100 border-none">停用</Badge>
|
||||
<StatusBadge variant="neutral">停用</StatusBadge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
/**
|
||||
* 生產工單狀態標籤組件
|
||||
*/
|
||||
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { StatusBadge, StatusVariant } from "@/Components/shared/StatusBadge";
|
||||
import { ProductionOrderStatus, STATUS_CONFIG } from "@/constants/production-order";
|
||||
|
||||
interface ProductionOrderStatusBadgeProps {
|
||||
@@ -16,31 +12,31 @@ export default function ProductionOrderStatusBadge({
|
||||
}: ProductionOrderStatusBadgeProps) {
|
||||
const config = STATUS_CONFIG[status] || { label: "未知", variant: "outline" };
|
||||
|
||||
const getStatusStyles = (status: string) => {
|
||||
const getVariant = (status: string): StatusVariant => {
|
||||
switch (status) {
|
||||
case 'draft':
|
||||
return 'bg-gray-100 text-gray-600 border-gray-200';
|
||||
return 'neutral';
|
||||
case 'pending':
|
||||
return 'bg-blue-50 text-blue-600 border-blue-200';
|
||||
return 'warning';
|
||||
case 'approved':
|
||||
return 'bg-primary text-primary-foreground border-transparent';
|
||||
return 'success';
|
||||
case 'in_progress':
|
||||
return 'bg-amber-50 text-amber-600 border-amber-200';
|
||||
return 'info';
|
||||
case 'completed':
|
||||
return 'bg-primary text-primary-foreground border-transparent transition-all shadow-sm';
|
||||
return 'success';
|
||||
case 'cancelled':
|
||||
return 'bg-destructive text-destructive-foreground border-transparent';
|
||||
return 'destructive';
|
||||
default:
|
||||
return 'bg-gray-50 text-gray-500 border-gray-200';
|
||||
return 'neutral';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`${className} ${getStatusStyles(status)} font-bold px-2.5 py-0.5 rounded-full border shadow-none`}
|
||||
<StatusBadge
|
||||
variant={getVariant(status)}
|
||||
className={className}
|
||||
>
|
||||
{config.label}
|
||||
</Badge>
|
||||
</StatusBadge>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* 採購單狀態標籤組件
|
||||
*/
|
||||
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { StatusBadge } from "@/Components/shared/StatusBadge";
|
||||
import { PurchaseOrderStatus } from "@/types/purchase-order";
|
||||
import { STATUS_CONFIG } from "@/constants/purchase-order";
|
||||
|
||||
@@ -15,14 +15,11 @@ export default function PurchaseOrderStatusBadge({
|
||||
status,
|
||||
className,
|
||||
}: PurchaseOrderStatusBadgeProps) {
|
||||
const config = STATUS_CONFIG[status] || { label: "未知", variant: "outline" };
|
||||
const config = STATUS_CONFIG[status] || { label: "未知", variant: "neutral" };
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant={config.variant}
|
||||
className={`${className} font-medium px-2.5 py-0.5 rounded-full`}
|
||||
>
|
||||
<StatusBadge variant={config.variant} className={className}>
|
||||
{config.label}
|
||||
</Badge>
|
||||
</StatusBadge>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { Input } from "@/Components/ui/input";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import { SafetyStockSetting } from "@/types/warehouse";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { StatusBadge } from "@/Components/shared/StatusBadge";
|
||||
|
||||
interface EditSafetyStockDialogProps {
|
||||
open: boolean;
|
||||
@@ -66,7 +66,7 @@ export default function EditSafetyStockDialog({
|
||||
<Label>商品名稱</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{setting.productName}</span>
|
||||
<Badge variant="outline">{setting.productType}</Badge>
|
||||
<StatusBadge variant="neutral">{setting.productType}</StatusBadge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* 安全庫存列表組件
|
||||
*/
|
||||
|
||||
import { Edit, Trash2, AlertCircle, CheckCircle, AlertTriangle } from "lucide-react";
|
||||
import { Trash2, Pencil } from "lucide-react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from "@/Components/ui/table";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { SafetyStockSetting, WarehouseInventory, SafetyStockStatus } from "@/types/warehouse";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { StatusBadge } from "@/Components/shared/StatusBadge";
|
||||
|
||||
interface SafetyStockListProps {
|
||||
settings: SafetyStockSetting[];
|
||||
@@ -35,29 +35,28 @@ function getSafetyStockStatus(
|
||||
|
||||
// 獲取狀態徽章
|
||||
function getStatusBadge(status: SafetyStockStatus) {
|
||||
switch (status) {
|
||||
case "正常":
|
||||
if (status === '正常') {
|
||||
return (
|
||||
<Badge className="bg-green-100 text-green-700 border-green-300">
|
||||
<CheckCircle className="mr-1 h-3 w-3" />
|
||||
<StatusBadge variant="success">
|
||||
正常
|
||||
</Badge>
|
||||
);
|
||||
case "接近":
|
||||
return (
|
||||
<Badge className="bg-yellow-100 text-yellow-700 border-yellow-300">
|
||||
<AlertTriangle className="mr-1 h-3 w-3" />
|
||||
接近
|
||||
</Badge>
|
||||
);
|
||||
case "低於":
|
||||
return (
|
||||
<Badge className="bg-red-100 text-red-700 border-red-300">
|
||||
<AlertCircle className="mr-1 h-3 w-3" />
|
||||
低於
|
||||
</Badge>
|
||||
</StatusBadge>
|
||||
);
|
||||
}
|
||||
if (status === '接近') {
|
||||
return (
|
||||
<StatusBadge variant="warning">
|
||||
接近
|
||||
</StatusBadge>
|
||||
);
|
||||
}
|
||||
if (status === '低於') {
|
||||
return (
|
||||
<StatusBadge variant="destructive">
|
||||
低於
|
||||
</StatusBadge>
|
||||
);
|
||||
}
|
||||
return null; // Should not happen if SafetyStockStatus is exhaustive
|
||||
}
|
||||
|
||||
export default function SafetyStockList({
|
||||
@@ -108,7 +107,7 @@ export default function SafetyStockList({
|
||||
<TableCell className="text-grey-2">{index + 1}</TableCell>
|
||||
<TableCell className="font-medium">{setting.productName}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{setting.productType}</Badge>
|
||||
<StatusBadge variant="neutral">{setting.productType}</StatusBadge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className={isLowStock ? "text-red-600 font-medium" : ""}>
|
||||
@@ -126,7 +125,7 @@ export default function SafetyStockList({
|
||||
onClick={() => onEdit(setting)}
|
||||
className="hover:bg-primary/10 hover:text-primary"
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-1" />
|
||||
<Pencil className="h-4 w-4 mr-1" />
|
||||
編輯
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
import { AlertTriangle, Trash2, Eye, ChevronDown, ChevronRight, CheckCircle, Package } from "lucide-react";
|
||||
import { Trash2, Eye, ChevronDown, ChevronRight, Package, AlertTriangle } from "lucide-react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/Components/ui/table";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { StatusBadge } from "@/Components/shared/StatusBadge";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
@@ -98,25 +98,22 @@ export default function InventoryTable({
|
||||
|
||||
// 獲取狀態徽章
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case "正常":
|
||||
if (status === '正常') {
|
||||
return (
|
||||
<Badge className="bg-green-100 text-green-700 border-green-300">
|
||||
<CheckCircle className="mr-1 h-3 w-3" />
|
||||
<StatusBadge variant="success">
|
||||
正常
|
||||
</Badge>
|
||||
</StatusBadge>
|
||||
);
|
||||
|
||||
case "低於":
|
||||
return (
|
||||
<Badge className="bg-red-100 text-red-700 border-red-300">
|
||||
<AlertTriangle className="mr-1 h-3 w-3" />
|
||||
低於
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
if (status === '低於') {
|
||||
return (
|
||||
<StatusBadge variant="destructive">
|
||||
低於
|
||||
</StatusBadge>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -168,10 +165,9 @@ export default function InventoryTable({
|
||||
{isVending ? '' : (hasInventory ? `${group.batches.length} 個批號` : '無庫存')}
|
||||
</span>
|
||||
{group.batches.some(b => b.expiryDate && new Date(b.expiryDate) < new Date()) && (
|
||||
<Badge className="bg-red-50 text-red-600 border-red-200">
|
||||
<AlertTriangle className="mr-1 h-3 w-3" />
|
||||
<StatusBadge variant="destructive">
|
||||
含過期項目
|
||||
</Badge>
|
||||
</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -199,9 +195,9 @@ export default function InventoryTable({
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-gray-500">
|
||||
<StatusBadge variant="neutral">
|
||||
未設定
|
||||
</Badge>
|
||||
</StatusBadge>
|
||||
)}
|
||||
{onViewProduct && (
|
||||
<Button
|
||||
|
||||
@@ -18,7 +18,7 @@ import { Label } from "@/Components/ui/label";
|
||||
import { Checkbox } from "@/Components/ui/checkbox";
|
||||
import { SafetyStockSetting, Product } from "@/types/warehouse";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { StatusBadge } from "@/Components/shared/StatusBadge";
|
||||
|
||||
interface AddSafetyStockDialogProps {
|
||||
open: boolean;
|
||||
@@ -193,7 +193,7 @@ export default function AddSafetyStockDialog({
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{product.name}</div>
|
||||
</div>
|
||||
<Badge variant="outline">{product.type}</Badge>
|
||||
<StatusBadge variant="neutral">{product.type}</StatusBadge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -223,7 +223,7 @@ export default function AddSafetyStockDialog({
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{product.name}</span>
|
||||
<Badge variant="outline">{product.type}</Badge>
|
||||
<StatusBadge variant="neutral">{product.type}</StatusBadge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* 安全庫存設定列表
|
||||
*/
|
||||
|
||||
import { Trash2, Pencil, CheckCircle, Package, AlertTriangle } from "lucide-react";
|
||||
import { Trash2, Pencil, Package } from "lucide-react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/Components/ui/table";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { StatusBadge } from "@/Components/shared/StatusBadge";
|
||||
import { SafetyStockSetting, WarehouseInventory } from "@/types/warehouse";
|
||||
import { calculateProductTotalStock, getSafetyStockStatus } from "@/utils/inventory";
|
||||
import { Can } from "@/Components/Permission/Can";
|
||||
@@ -57,38 +57,35 @@ export default function SafetyStockList({
|
||||
// 如果是自動帶入的品項且尚未存檔,顯示「未設定」
|
||||
if (isNew) {
|
||||
return (
|
||||
<Badge variant="outline" className="text-gray-400 border-gray-200 font-normal">
|
||||
<StatusBadge variant="neutral" className="border-gray-200 font-normal text-gray-400">
|
||||
未設定
|
||||
</Badge>
|
||||
</StatusBadge>
|
||||
);
|
||||
}
|
||||
|
||||
const status = getSafetyStockStatus(quantity, safetyStock);
|
||||
switch (status) {
|
||||
case "正常":
|
||||
if (status === '正常') {
|
||||
return (
|
||||
<Badge className="bg-green-100 text-green-700 border-green-300 hover:bg-green-100">
|
||||
<CheckCircle className="mr-1 h-3 w-3" />
|
||||
<StatusBadge variant="success">
|
||||
正常
|
||||
</Badge>
|
||||
</StatusBadge>
|
||||
);
|
||||
case "接近": // 數量 <= 安全庫存 * 1.2
|
||||
return (
|
||||
<Badge className="bg-yellow-100 text-yellow-700 border-yellow-300 hover:bg-yellow-100">
|
||||
<AlertTriangle className="mr-1 h-3 w-3" />
|
||||
接近
|
||||
</Badge>
|
||||
);
|
||||
case "低於": // 數量 < 安全庫存
|
||||
return (
|
||||
<Badge className="bg-orange-100 text-orange-700 border-orange-300 hover:bg-orange-100">
|
||||
<AlertTriangle className="mr-1 h-3 w-3" />
|
||||
低於
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
if (status === '接近') { // 數量 <= 安全庫存 * 1.2
|
||||
return (
|
||||
<StatusBadge variant="warning">
|
||||
接近
|
||||
</StatusBadge>
|
||||
);
|
||||
}
|
||||
if (status === '低於') { // 數量 < 安全庫存
|
||||
return (
|
||||
<StatusBadge variant="destructive">
|
||||
低於
|
||||
</StatusBadge>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -118,9 +115,9 @@ export default function SafetyStockList({
|
||||
{setting.productName}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="font-normal">
|
||||
<StatusBadge variant="neutral">
|
||||
{setting.productType}
|
||||
</Badge>
|
||||
</StatusBadge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-semibold">
|
||||
{setting.safetyStock} {setting.unit || '個'}
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { Warehouse, WarehouseStats } from "@/types/warehouse";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { StatusBadge } from "@/Components/shared/StatusBadge";
|
||||
import { Card, CardContent } from "@/Components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -101,13 +101,12 @@ export default function WarehouseCard({
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<Badge
|
||||
variant={warehouse.type === 'quarantine' ? "secondary" : "outline"}
|
||||
className={`text-xs font-normal ${warehouse.type === 'quarantine' ? 'bg-red-100 text-red-700 border-red-200' : ''}`}
|
||||
<StatusBadge
|
||||
variant={warehouse.type === 'quarantine' ? "destructive" : "neutral"}
|
||||
>
|
||||
{WAREHOUSE_TYPE_LABELS[warehouse.type || 'standard'] || '標準倉'}
|
||||
{warehouse.type === 'quarantine' ? ' (不計入可用)' : ' (計入可用)'}
|
||||
</Badge>
|
||||
</StatusBadge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -32,12 +32,20 @@ import { validateWarehouse } from "@/utils/validation";
|
||||
import { toast } from "sonner";
|
||||
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||
|
||||
interface TransitWarehouseOption {
|
||||
id: string;
|
||||
name: string;
|
||||
license_plate?: string;
|
||||
driver_name?: string;
|
||||
}
|
||||
|
||||
interface WarehouseDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
warehouse: Warehouse | null;
|
||||
onSave: (warehouse: Omit<Warehouse, "id" | "createdAt" | "updatedAt">) => void;
|
||||
onDelete?: (warehouseId: string) => void;
|
||||
transitWarehouses?: TransitWarehouseOption[];
|
||||
}
|
||||
|
||||
const WAREHOUSE_TYPE_OPTIONS: { label: string; value: WarehouseType }[] = [
|
||||
@@ -55,6 +63,7 @@ export default function WarehouseDialog({
|
||||
warehouse,
|
||||
onSave,
|
||||
onDelete,
|
||||
transitWarehouses = [],
|
||||
}: WarehouseDialogProps) {
|
||||
const [formData, setFormData] = useState<{
|
||||
code: string;
|
||||
@@ -64,6 +73,7 @@ export default function WarehouseDialog({
|
||||
type: WarehouseType;
|
||||
license_plate: string;
|
||||
driver_name: string;
|
||||
default_transit_warehouse_id: string | null;
|
||||
}>({
|
||||
code: "",
|
||||
name: "",
|
||||
@@ -72,6 +82,7 @@ export default function WarehouseDialog({
|
||||
type: "standard",
|
||||
license_plate: "",
|
||||
driver_name: "",
|
||||
default_transit_warehouse_id: null,
|
||||
});
|
||||
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
@@ -86,6 +97,7 @@ export default function WarehouseDialog({
|
||||
type: warehouse.type || "standard",
|
||||
license_plate: warehouse.license_plate || "",
|
||||
driver_name: warehouse.driver_name || "",
|
||||
default_transit_warehouse_id: warehouse.default_transit_warehouse_id ? String(warehouse.default_transit_warehouse_id) : null,
|
||||
});
|
||||
} else {
|
||||
setFormData({
|
||||
@@ -96,6 +108,7 @@ export default function WarehouseDialog({
|
||||
type: "standard",
|
||||
license_plate: "",
|
||||
driver_name: "",
|
||||
default_transit_warehouse_id: null,
|
||||
});
|
||||
}
|
||||
}, [warehouse, open]);
|
||||
@@ -216,6 +229,32 @@ export default function WarehouseDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 預設在途倉設定(僅非 transit 類型顯示) */}
|
||||
{formData.type !== 'transit' && transitWarehouses.length > 0 && (
|
||||
<div className="space-y-4 bg-blue-50 p-4 rounded-lg border border-blue-100">
|
||||
<div className="border-b border-blue-200 pb-2">
|
||||
<h4 className="text-sm text-blue-800 font-medium">調撥配送設定</h4>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>預設在途倉</Label>
|
||||
<p className="text-xs text-gray-500">從此倉庫建立調撥單時,系統將自動帶入此在途倉作為配送中繼倉</p>
|
||||
<SearchableSelect
|
||||
value={formData.default_transit_warehouse_id || ""}
|
||||
onValueChange={(val) => setFormData({ ...formData, default_transit_warehouse_id: val || null })}
|
||||
options={[
|
||||
{ label: "不指定", value: "" },
|
||||
...transitWarehouses.map((tw) => ({
|
||||
label: `${tw.name}${tw.license_plate ? ` (${tw.license_plate})` : ''}`,
|
||||
value: tw.id,
|
||||
})),
|
||||
]}
|
||||
placeholder="選擇預設在途倉"
|
||||
className="h-9 bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{/* 區塊 B:位置 */}
|
||||
|
||||
34
resources/js/Components/shared/StatusBadge.tsx
Normal file
34
resources/js/Components/shared/StatusBadge.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type StatusVariant =
|
||||
| "neutral"
|
||||
| "info"
|
||||
| "warning"
|
||||
| "success"
|
||||
| "destructive";
|
||||
|
||||
interface StatusBadgeProps {
|
||||
variant: StatusVariant;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const variantStyles: Record<StatusVariant, string> = {
|
||||
neutral: "bg-gray-100 text-gray-800 border-gray-200 hover:bg-gray-100", // Draft, Cancelled(sometimes), Closed
|
||||
info: "bg-blue-100 text-blue-800 border-blue-200 hover:bg-blue-100", // Processing, Active
|
||||
warning: "bg-amber-100 text-amber-800 border-amber-200 hover:bg-amber-100", // Pending, Review
|
||||
success: "bg-green-100 text-green-800 border-green-200 hover:bg-green-100", // Completed, Approved
|
||||
destructive: "bg-red-100 text-red-800 border-red-200 hover:bg-red-100", // Voided, Rejected, High Risk
|
||||
};
|
||||
|
||||
export function StatusBadge({ variant, children, className }: StatusBadgeProps) {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(variantStyles[variant], "font-medium border", className)}
|
||||
>
|
||||
{children}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,8 @@ import {
|
||||
ClipboardCheck,
|
||||
ArrowLeftRight,
|
||||
TrendingUp,
|
||||
FileUp
|
||||
FileUp,
|
||||
Store
|
||||
} from "lucide-react";
|
||||
import { toast, Toaster } from "sonner";
|
||||
import { useState, useEffect, useMemo, useRef } from "react";
|
||||
@@ -131,6 +132,13 @@ export default function AuthenticatedLayout({
|
||||
route: "/inventory/transfer-orders",
|
||||
permission: "inventory_transfer.view",
|
||||
},
|
||||
{
|
||||
id: "store-requisition",
|
||||
label: "門市叫貨",
|
||||
icon: <Store className="h-4 w-4" />,
|
||||
route: "/store-requisitions",
|
||||
permission: "store_requisitions.view",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/Components/ui/table";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { StatusBadge, StatusVariant } from "@/Components/shared/StatusBadge";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
|
||||
interface AbnormalItem {
|
||||
@@ -103,6 +103,21 @@ export default function Dashboard({ stats, abnormalItems }: Props) {
|
||||
},
|
||||
];
|
||||
|
||||
const getStatusVariant = (status: string): StatusVariant => {
|
||||
switch (status) {
|
||||
case 'negative': return 'destructive';
|
||||
case 'low_stock': return 'warning';
|
||||
case 'expiring': return 'warning';
|
||||
case 'expired': return 'destructive';
|
||||
default: return 'neutral';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string): string => {
|
||||
const config = statusConfig[status];
|
||||
return config ? config.label : status;
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
breadcrumbs={[
|
||||
@@ -227,25 +242,14 @@ export default function Dashboard({ stats, abnormalItems }: Props) {
|
||||
<TableCell className="text-center">
|
||||
<div className="flex flex-wrap items-center justify-center gap-1">
|
||||
{item.statuses.map(
|
||||
(status) => {
|
||||
const config =
|
||||
statusConfig[
|
||||
status
|
||||
];
|
||||
if (!config)
|
||||
return null;
|
||||
return (
|
||||
<Badge
|
||||
(status) => (
|
||||
<StatusBadge
|
||||
key={status}
|
||||
variant="outline"
|
||||
className={
|
||||
config.className
|
||||
}
|
||||
variant={getStatusVariant(status)}
|
||||
>
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
{getStatusLabel(status)}
|
||||
</StatusBadge>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||
import { Head, useForm, router, Link } from '@inertiajs/react';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { StatusBadge } from "@/Components/shared/StatusBadge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -11,7 +13,6 @@ import {
|
||||
} from "@/Components/ui/table";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -167,13 +168,13 @@ export default function Index({ docs, warehouses, filters }: { docs: DocsPaginat
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'draft':
|
||||
return <Badge variant="secondary" className="bg-gray-100 text-gray-600 border-none">草稿</Badge>;
|
||||
return <StatusBadge variant="neutral">草稿</StatusBadge>;
|
||||
case 'posted':
|
||||
return <Badge className="bg-green-100 text-green-700 border-none">已過帳</Badge>;
|
||||
return <StatusBadge variant="success">已過帳</StatusBadge>;
|
||||
case 'voided':
|
||||
return <Badge variant="destructive" className="bg-red-100 text-red-700 border-none">已作廢</Badge>;
|
||||
return <StatusBadge variant="destructive">已作廢</StatusBadge>;
|
||||
default:
|
||||
return <Badge variant="outline">{status}</Badge>;
|
||||
return <StatusBadge variant="neutral">{status}</StatusBadge>;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -257,10 +258,10 @@ export default function Index({ docs, warehouses, filters }: { docs: DocsPaginat
|
||||
<TableHead className="w-[180px] font-medium text-grey-600">單號</TableHead>
|
||||
<TableHead className="font-medium text-grey-600">倉庫</TableHead>
|
||||
<TableHead className="font-medium text-grey-600">調整原因</TableHead>
|
||||
<TableHead className="font-medium text-grey-600 text-center">狀態</TableHead>
|
||||
<TableHead className="font-medium text-grey-600">建立者</TableHead>
|
||||
<TableHead className="font-medium text-grey-600">建立時間</TableHead>
|
||||
<TableHead className="font-medium text-grey-600">過帳時間</TableHead>
|
||||
<TableHead className="font-medium text-grey-600 text-center">狀態</TableHead>
|
||||
<TableHead className="text-center font-medium text-grey-600">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -286,10 +287,10 @@ export default function Index({ docs, warehouses, filters }: { docs: DocsPaginat
|
||||
</TableCell>
|
||||
<TableCell>{doc.warehouse_name}</TableCell>
|
||||
<TableCell className="text-gray-500 max-w-[200px] truncate">{doc.reason}</TableCell>
|
||||
<TableCell className="text-center">{getStatusBadge(doc.status)}</TableCell>
|
||||
<TableCell className="text-sm">{doc.created_by}</TableCell>
|
||||
<TableCell className="text-gray-500 text-sm">{doc.created_at}</TableCell>
|
||||
<TableCell className="text-gray-500 text-sm">{doc.posted_at || '-'}</TableCell>
|
||||
<TableCell className="text-center">{getStatusBadge(doc.status)}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<div className="flex items-center justify-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
{(() => {
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from "@/Components/ui/table";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { StatusBadge } from "@/Components/shared/StatusBadge";
|
||||
import { Checkbox } from "@/Components/ui/checkbox";
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -243,9 +243,9 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) {
|
||||
盤調單: {doc.doc_no}
|
||||
</h1>
|
||||
{isDraft ? (
|
||||
<Badge variant="secondary" className="bg-blue-500 text-white border-none py-1 px-3">草稿</Badge>
|
||||
<StatusBadge variant="neutral" className="border-none py-1 px-3">草稿</StatusBadge>
|
||||
) : (
|
||||
<Badge className="bg-green-500 text-white border-none py-1 px-3">已過帳</Badge>
|
||||
<StatusBadge variant="success" className="border-none py-1 px-3">已過帳</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1 font-medium flex items-center gap-2">
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||
import { Head, Link, useForm, router } from '@inertiajs/react';
|
||||
import { Head, Link, router, useForm } from '@inertiajs/react';
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { StatusBadge } from "@/Components/shared/StatusBadge";
|
||||
import { debounce } from "lodash";
|
||||
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||
import {
|
||||
@@ -14,7 +16,6 @@ import {
|
||||
} from '@/Components/ui/table';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Input } from '@/Components/ui/input';
|
||||
import { Badge } from '@/Components/ui/badge';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -138,19 +139,19 @@ export default function Index({ docs, warehouses, filters }: any) {
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'draft':
|
||||
return <Badge variant="secondary">草稿</Badge>;
|
||||
return <StatusBadge variant="neutral">草稿</StatusBadge>;
|
||||
case 'counting':
|
||||
return <Badge className="bg-blue-500 hover:bg-blue-600">盤點中</Badge>;
|
||||
return <StatusBadge variant="info">盤點中</StatusBadge>;
|
||||
case 'completed':
|
||||
return <Badge className="bg-green-500 hover:bg-green-600">盤點完成</Badge>;
|
||||
return <StatusBadge variant="success">盤點完成</StatusBadge>;
|
||||
case 'no_adjust':
|
||||
return <Badge className="bg-green-600 hover:bg-green-700">盤點完成 (無需盤調)</Badge>;
|
||||
return <StatusBadge variant="success">盤點完成 (無需盤調)</StatusBadge>;
|
||||
case 'adjusted':
|
||||
return <Badge className="bg-purple-500 hover:bg-purple-600">已盤調庫存</Badge>;
|
||||
return <StatusBadge variant="info">已盤調庫存</StatusBadge>; // Decided on info/blue for adjusted to match "active/done" but distinctive from pure success if needed, or stick to success? Plan said Info/Blue.
|
||||
case 'cancelled':
|
||||
return <Badge variant="destructive">已取消</Badge>;
|
||||
return <StatusBadge variant="destructive">已取消</StatusBadge>;
|
||||
default:
|
||||
return <Badge variant="outline">{status}</Badge>;
|
||||
return <StatusBadge variant="neutral">{status}</StatusBadge>;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -273,11 +274,11 @@ export default function Index({ docs, warehouses, filters }: any) {
|
||||
<TableHead className="w-[50px] text-center">#</TableHead>
|
||||
<TableHead>單號</TableHead>
|
||||
<TableHead>倉庫</TableHead>
|
||||
<TableHead>狀態</TableHead>
|
||||
<TableHead>快照時間</TableHead>
|
||||
<TableHead>盤點進度</TableHead>
|
||||
<TableHead>完成時間</TableHead>
|
||||
<TableHead>建立人員</TableHead>
|
||||
<TableHead>狀態</TableHead>
|
||||
<TableHead className="text-center">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -296,7 +297,6 @@ export default function Index({ docs, warehouses, filters }: any) {
|
||||
</TableCell>
|
||||
<TableCell className="font-medium text-primary-main">{doc.doc_no}</TableCell>
|
||||
<TableCell>{doc.warehouse_name}</TableCell>
|
||||
<TableCell>{getStatusBadge(doc.status)}</TableCell>
|
||||
<TableCell className="text-gray-500 text-sm">{doc.snapshot_date}</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-medium text-gray-700">{doc.counted_items}</span>
|
||||
@@ -305,6 +305,7 @@ export default function Index({ docs, warehouses, filters }: any) {
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-500 text-sm">{doc.completed_at || '-'}</TableCell>
|
||||
<TableCell className="text-sm">{doc.created_by}</TableCell>
|
||||
<TableCell>{getStatusBadge(doc.status)}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{/* Action Button Logic: Prefer Edit if allowed and status is active, otherwise fallback to View if allowed */}
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from '@/Components/ui/table';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Input } from '@/Components/ui/input';
|
||||
import { Badge } from '@/Components/ui/badge';
|
||||
import { StatusBadge } from "@/Components/shared/StatusBadge";
|
||||
import { Save, Printer, Trash2, ClipboardCheck, ArrowLeft, RotateCcw } from 'lucide-react'; // Added ArrowLeft
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -121,16 +121,16 @@ export default function Show({ doc }: any) {
|
||||
盤點單: {doc.doc_no}
|
||||
</h1>
|
||||
{doc.status === 'completed' && (
|
||||
<Badge className="bg-green-500 hover:bg-green-600">盤點完成</Badge>
|
||||
<StatusBadge variant="success">盤點完成</StatusBadge>
|
||||
)}
|
||||
{doc.status === 'no_adjust' && (
|
||||
<Badge className="bg-green-600 hover:bg-green-700">盤點完成 (無需盤調)</Badge>
|
||||
<StatusBadge variant="success">盤點完成 (無需盤調)</StatusBadge>
|
||||
)}
|
||||
{doc.status === 'adjusted' && (
|
||||
<Badge className="bg-purple-500 hover:bg-purple-600">已盤調庫存</Badge>
|
||||
<StatusBadge variant="warning">已盤調庫存</StatusBadge>
|
||||
)}
|
||||
{doc.status === 'draft' && (
|
||||
<Badge className="bg-blue-500 hover:bg-blue-600">盤點中</Badge>
|
||||
<StatusBadge variant="info">盤點中</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1 font-medium">
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
TableRow,
|
||||
} from '@/Components/ui/table';
|
||||
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { StatusBadge } from "@/Components/shared/StatusBadge";
|
||||
|
||||
|
||||
import {
|
||||
@@ -395,9 +395,9 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
||||
<TableCell className="font-medium text-primary-main">{po.code}</TableCell>
|
||||
<TableCell>{po.vendor_name}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge variant={STATUS_CONFIG[po.status]?.variant || 'outline'}>
|
||||
<StatusBadge variant={STATUS_CONFIG[po.status]?.variant || 'neutral'}>
|
||||
{STATUS_CONFIG[po.status]?.label || po.status}
|
||||
</Badge>
|
||||
</StatusBadge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-gray-600">
|
||||
{po.items.length} 項
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/Components/ui/table";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { StatusBadge } from "@/Components/shared/StatusBadge";
|
||||
import { ArrowLeft, FileText, Package } from "lucide-react";
|
||||
import Pagination from "@/Components/shared/Pagination";
|
||||
import { formatDate } from "@/utils/format";
|
||||
@@ -69,17 +69,18 @@ interface ShowProps extends PageProps {
|
||||
export default function InventoryReportShow({ product, transactions, filters, reportFilters, warehouses }: ShowProps) {
|
||||
|
||||
// 類型 Badge 顏色映射
|
||||
const getTypeBadgeVariant = (type: string) => {
|
||||
// 類型 Badge 顏色映射
|
||||
const getTypeBadgeVariant = (type: string): "success" | "destructive" | "neutral" => {
|
||||
switch (type) {
|
||||
case '入庫':
|
||||
case '手動入庫':
|
||||
case '調撥入庫':
|
||||
return "default";
|
||||
return "success";
|
||||
case '出庫':
|
||||
case '調撥出庫':
|
||||
return "destructive";
|
||||
default:
|
||||
return "secondary";
|
||||
return "neutral";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -128,9 +129,9 @@ export default function InventoryReportShow({ product, transactions, filters, re
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-xl font-bold text-grey-0">{product.name}</h3>
|
||||
<Badge variant="outline" className="text-sm px-2 py-0.5 bg-gray-50">
|
||||
<StatusBadge variant="neutral" className="text-sm px-2 py-0.5">
|
||||
{product.code}
|
||||
</Badge>
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<div className="flex items-center gap-6 text-sm text-gray-500">
|
||||
<span className="flex items-center gap-1.5">
|
||||
@@ -212,9 +213,9 @@ export default function InventoryReportShow({ product, transactions, filters, re
|
||||
{formatDate(tx.actual_time)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={getTypeBadgeVariant(tx.type)}>
|
||||
<StatusBadge variant={getTypeBadgeVariant(tx.type)}>
|
||||
{tx.type}
|
||||
</Badge>
|
||||
</StatusBadge>
|
||||
</TableCell>
|
||||
<TableCell>{tx.warehouse_name}</TableCell>
|
||||
<TableCell className={`text-right font-medium ${tx.quantity > 0 ? 'text-emerald-600' :
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/Components/ui/table";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { StatusBadge, StatusVariant } from "@/Components/shared/StatusBadge";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||
@@ -77,31 +77,31 @@ interface Props {
|
||||
categories: { id: number; name: string }[];
|
||||
}
|
||||
|
||||
// 狀態 Badge
|
||||
const statusConfig: Record<
|
||||
string,
|
||||
{ label: string; className: string }
|
||||
> = {
|
||||
normal: {
|
||||
label: "正常",
|
||||
className: "bg-green-100 text-green-800 border-green-200",
|
||||
},
|
||||
negative: {
|
||||
label: "負庫存",
|
||||
className: "bg-red-100 text-red-800 border-red-200",
|
||||
},
|
||||
low_stock: {
|
||||
label: "低庫存",
|
||||
className: "bg-amber-100 text-amber-800 border-amber-200",
|
||||
},
|
||||
expiring: {
|
||||
label: "即將過期",
|
||||
className: "bg-yellow-100 text-yellow-800 border-yellow-200",
|
||||
},
|
||||
expired: {
|
||||
label: "已過期",
|
||||
className: "bg-red-100 text-red-800 border-red-200",
|
||||
},
|
||||
// 狀態與樣式映射
|
||||
const getStatusVariant = (status: string): StatusVariant => {
|
||||
switch (status) {
|
||||
case 'negative':
|
||||
case 'expired':
|
||||
return 'destructive';
|
||||
case 'low_stock':
|
||||
case 'expiring':
|
||||
return 'warning';
|
||||
case 'normal':
|
||||
return 'success';
|
||||
default:
|
||||
return 'neutral';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string): string => {
|
||||
switch (status) {
|
||||
case 'normal': return "正常";
|
||||
case 'negative': return "負庫存";
|
||||
case 'low_stock': return "低庫存";
|
||||
case 'expiring': return "即將過期";
|
||||
case 'expired': return "已過期";
|
||||
default: return status;
|
||||
}
|
||||
};
|
||||
|
||||
// 狀態篩選選項
|
||||
@@ -512,25 +512,14 @@ export default function StockQueryIndex({
|
||||
<TableCell className="text-center">
|
||||
<div className="flex flex-wrap items-center justify-center gap-1">
|
||||
{item.statuses.map(
|
||||
(status) => {
|
||||
const config =
|
||||
statusConfig[
|
||||
status
|
||||
];
|
||||
if (!config)
|
||||
return null;
|
||||
return (
|
||||
<Badge
|
||||
(status) => (
|
||||
<StatusBadge
|
||||
key={status}
|
||||
variant="outline"
|
||||
className={
|
||||
config.className
|
||||
}
|
||||
variant={getStatusVariant(status)}
|
||||
>
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
{getStatusLabel(status)}
|
||||
</StatusBadge>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
@@ -4,6 +4,7 @@ import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import { Head, Link, router } from "@inertiajs/react";
|
||||
import { debounce } from "lodash";
|
||||
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||
import { StatusBadge } from "@/Components/shared/StatusBadge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -14,7 +15,6 @@ import {
|
||||
} from "@/Components/ui/table";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -160,13 +160,15 @@ export default function Index({ warehouses, orders, filters }: any) {
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'draft':
|
||||
return <Badge variant="secondary">草稿</Badge>;
|
||||
return <StatusBadge variant="neutral">草稿</StatusBadge>;
|
||||
case 'dispatched':
|
||||
return <StatusBadge variant="info">配送中</StatusBadge>;
|
||||
case 'completed':
|
||||
return <Badge className="bg-green-500 hover:bg-green-600">已完成</Badge>;
|
||||
return <StatusBadge variant="success">已完成</StatusBadge>;
|
||||
case 'voided':
|
||||
return <Badge variant="destructive">已作廢</Badge>;
|
||||
return <StatusBadge variant="destructive">已作廢</StatusBadge>;
|
||||
default:
|
||||
return <Badge variant="outline">{status}</Badge>;
|
||||
return <StatusBadge variant="neutral">{status}</StatusBadge>;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -287,12 +289,12 @@ export default function Index({ warehouses, orders, filters }: any) {
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px] text-center font-medium text-gray-600">#</TableHead>
|
||||
<TableHead className="font-medium text-gray-600">單號</TableHead>
|
||||
<TableHead className="text-center font-medium text-gray-600">狀態</TableHead>
|
||||
<TableHead className="font-medium text-gray-600">來源倉庫</TableHead>
|
||||
<TableHead className="font-medium text-gray-600">目的倉庫</TableHead>
|
||||
<TableHead className="font-medium text-gray-600">建立日期</TableHead>
|
||||
<TableHead className="font-medium text-gray-600">過帳日期</TableHead>
|
||||
<TableHead className="font-medium text-gray-600">建立人員</TableHead>
|
||||
<TableHead className="text-center font-medium text-gray-600">狀態</TableHead>
|
||||
<TableHead className="text-center font-medium text-gray-600">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -314,12 +316,12 @@ export default function Index({ warehouses, orders, filters }: any) {
|
||||
{(orders.current_page - 1) * orders.per_page + index + 1}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium text-primary-main">{order.doc_no}</TableCell>
|
||||
<TableCell className="text-center">{getStatusBadge(order.status)}</TableCell>
|
||||
<TableCell className="text-gray-700">{order.from_warehouse_name}</TableCell>
|
||||
<TableCell className="text-gray-700">{order.to_warehouse_name}</TableCell>
|
||||
<TableCell className="text-gray-500 text-sm">{order.created_at}</TableCell>
|
||||
<TableCell className="text-gray-500 text-sm">{order.posted_at || '-'}</TableCell>
|
||||
<TableCell className="text-sm">{order.created_by}</TableCell>
|
||||
<TableCell className="text-center">{getStatusBadge(order.status)}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<div className="flex items-center justify-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
{(() => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import { Head, router, Link } from "@inertiajs/react";
|
||||
import { Head, router, Link, usePage } from "@inertiajs/react";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/Components/ui/table";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { StatusBadge } from "@/Components/shared/StatusBadge";
|
||||
import { Checkbox } from "@/Components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -32,27 +32,86 @@ import {
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/Components/ui/alert-dialog";
|
||||
import { Plus, Save, Trash2, ArrowLeft, CheckCircle, Package, ArrowLeftRight, Printer, Search } from "lucide-react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/Components/ui/select";
|
||||
import { Plus, Save, Trash2, ArrowLeft, CheckCircle, Package, ArrowLeftRight, Printer, Search, Truck, PackageCheck } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import axios from "axios";
|
||||
import { Can } from '@/Components/Permission/Can';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import TransferImportDialog from '@/Components/Transfer/TransferImportDialog';
|
||||
|
||||
export default function Show({ order }: any) {
|
||||
interface TransitWarehouse {
|
||||
id: string;
|
||||
name: string;
|
||||
license_plate: string | null;
|
||||
driver_name: string | null;
|
||||
}
|
||||
|
||||
export default function Show({ order, transitWarehouses = [] }: { order: any; transitWarehouses?: TransitWarehouse[] }) {
|
||||
const { can } = usePermission();
|
||||
const { url } = usePage();
|
||||
|
||||
// 解析 URL query 參數,判斷使用者從哪裡來
|
||||
const backNav = useMemo(() => {
|
||||
const params = new URLSearchParams(url.split('?')[1] || '');
|
||||
const from = params.get('from');
|
||||
if (from === 'requisition') {
|
||||
const fromId = params.get('from_id');
|
||||
const fromDoc = params.get('from_doc') || '';
|
||||
return {
|
||||
href: route('store-requisitions.show', [fromId!]),
|
||||
label: `返回叫貨單: ${decodeURIComponent(fromDoc)}`,
|
||||
breadcrumbs: [
|
||||
{ label: '商品與庫存管理', href: '#' },
|
||||
{ label: '門市叫貨申請', href: route('store-requisitions.index') },
|
||||
{ label: `叫貨單: ${decodeURIComponent(fromDoc)}`, href: route('store-requisitions.show', [fromId!]) },
|
||||
{ label: `調撥單: ${order.doc_no}`, href: route('inventory.transfer.show', [order.id]), isPage: true },
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
href: route('inventory.transfer.index'),
|
||||
label: '返回調撥單列表',
|
||||
breadcrumbs: [
|
||||
{ label: '商品與庫存管理', href: '#' },
|
||||
{ label: '庫存調撥', href: route('inventory.transfer.index') },
|
||||
{ label: `調撥單: ${order.doc_no}`, href: route('inventory.transfer.show', [order.id]), isPage: true },
|
||||
],
|
||||
};
|
||||
}, [url, order]);
|
||||
|
||||
const [items, setItems] = useState(order.items || []);
|
||||
const [remarks, setRemarks] = useState(order.remarks || "");
|
||||
|
||||
// 狀態初始化
|
||||
const [transitWarehouseId, setTransitWarehouseId] = useState<string | null>(order.transit_warehouse_id || null);
|
||||
|
||||
|
||||
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const [isPostDialogOpen, setIsPostDialogOpen] = useState(false);
|
||||
const [isReceiveDialogOpen, setIsReceiveDialogOpen] = useState(false);
|
||||
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
|
||||
|
||||
// 判斷是否有在途倉流程 (包含前端暫選的)
|
||||
const hasTransit = !!transitWarehouseId;
|
||||
|
||||
// 取得選中的在途倉資訊
|
||||
const selectedTransitWarehouse = transitWarehouses.find(w => w.id === transitWarehouseId);
|
||||
|
||||
// 當 order prop 變動時 (例如匯入後 router.reload),同步更新內部狀態
|
||||
useEffect(() => {
|
||||
if (order) {
|
||||
setItems(order.items || []);
|
||||
setRemarks(order.remarks || "");
|
||||
setTransitWarehouseId(order.transit_warehouse_id || null);
|
||||
}
|
||||
}, [order]);
|
||||
|
||||
@@ -74,7 +133,6 @@ export default function Show({ order }: any) {
|
||||
const loadInventory = async () => {
|
||||
setLoadingInventory(true);
|
||||
try {
|
||||
// Fetch inventory from SOURCE warehouse
|
||||
const response = await axios.get(route('api.warehouses.inventories', order.from_warehouse_id));
|
||||
setAvailableInventory(response.data);
|
||||
} catch (error) {
|
||||
@@ -122,8 +180,8 @@ export default function Show({ order }: any) {
|
||||
batch_number: inv.batch_number,
|
||||
expiry_date: inv.expiry_date,
|
||||
unit: inv.unit_name,
|
||||
quantity: 1, // Default 1
|
||||
max_quantity: inv.quantity, // Max available
|
||||
quantity: 1,
|
||||
max_quantity: inv.quantity,
|
||||
notes: "",
|
||||
});
|
||||
addedCount++;
|
||||
@@ -155,6 +213,7 @@ export default function Show({ order }: any) {
|
||||
await router.put(route('inventory.transfer.update', [order.id]), {
|
||||
items: items,
|
||||
remarks: remarks,
|
||||
transit_warehouse_id: transitWarehouseId || '',
|
||||
}, {
|
||||
onSuccess: () => { },
|
||||
onError: () => toast.error("儲存失敗,請檢查輸入"),
|
||||
@@ -164,21 +223,42 @@ export default function Show({ order }: any) {
|
||||
}
|
||||
};
|
||||
|
||||
// 確認出貨 / 確認過帳(無在途倉)
|
||||
// 確認出貨 / 確認過帳(無在途倉)
|
||||
const handlePost = () => {
|
||||
router.put(route('inventory.transfer.update', [order.id]), {
|
||||
action: 'post'
|
||||
action: 'post',
|
||||
transit_warehouse_id: transitWarehouseId || '',
|
||||
items: items,
|
||||
remarks: remarks,
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
setIsPostDialogOpen(false);
|
||||
},
|
||||
onError: (errors) => {
|
||||
const message = Object.values(errors).join('\n') || "過帳失敗,請檢查輸入或庫存狀態";
|
||||
const message = Object.values(errors).join('\n') || "操作失敗,請檢查輸入或庫存狀態";
|
||||
toast.error(message);
|
||||
setIsPostDialogOpen(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 確認收貨
|
||||
const handleReceive = () => {
|
||||
router.put(route('inventory.transfer.update', [order.id]), {
|
||||
action: 'receive'
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
setIsReceiveDialogOpen(false);
|
||||
},
|
||||
onError: (errors) => {
|
||||
const message = Object.values(errors).join('\n') || "收貨失敗";
|
||||
toast.error(message);
|
||||
setIsReceiveDialogOpen(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
router.delete(route('inventory.transfer.destroy', [order.id]), {
|
||||
onSuccess: () => {
|
||||
@@ -188,28 +268,44 @@ export default function Show({ order }: any) {
|
||||
};
|
||||
|
||||
const canEdit = can('inventory_transfer.edit');
|
||||
const isReadOnly = order.status !== 'draft' || !canEdit;
|
||||
const isReadOnly = (order.status !== 'draft' || !canEdit);
|
||||
const isVending = order.to_warehouse_type === 'vending';
|
||||
|
||||
// 狀態 Badge 渲染
|
||||
const renderStatusBadge = () => {
|
||||
const statusConfig: Record<string, { variant: "success" | "warning" | "neutral" | "destructive" | "info", label: string }> = {
|
||||
completed: { variant: 'success', label: '已完成' },
|
||||
dispatched: { variant: 'warning', label: '配送中' },
|
||||
draft: { variant: 'neutral', label: '草稿' },
|
||||
voided: { variant: 'destructive', label: '已作廢' },
|
||||
};
|
||||
|
||||
const config = statusConfig[order.status] || { variant: 'neutral', label: order.status };
|
||||
|
||||
return <StatusBadge variant={config.variant}>{config.label}</StatusBadge>;
|
||||
};
|
||||
|
||||
// 過帳時庫存欄標題
|
||||
const stockColumnTitle = () => {
|
||||
if (order.status === 'completed' || order.status === 'dispatched') return '出貨時庫存';
|
||||
return '可用庫存';
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
breadcrumbs={[
|
||||
{ label: '商品與庫存管理', href: '#' },
|
||||
{ label: '庫存調撥', href: route('inventory.transfer.index') },
|
||||
{ label: `調撥單: ${order.doc_no}`, href: route('inventory.transfer.show', [order.id]), isPage: true },
|
||||
]}
|
||||
breadcrumbs={backNav.breadcrumbs as any}
|
||||
>
|
||||
<Head title={`調撥單 ${order.doc_no}`} />
|
||||
|
||||
<div className="container mx-auto p-6 max-w-7xl animate-in fade-in duration-500 space-y-6">
|
||||
<div>
|
||||
<Link href={route('inventory.transfer.index')}>
|
||||
<Link href={backNav.href}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 button-outlined-primary mb-6"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回調撥單列表
|
||||
{backNav.label}
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
@@ -220,9 +316,7 @@ export default function Show({ order }: any) {
|
||||
<ArrowLeftRight className="h-6 w-6 text-primary-main" />
|
||||
調撥單: {order.doc_no}
|
||||
</h1>
|
||||
{order.status === 'completed' && <Badge className="bg-green-500 hover:bg-green-600">已完成</Badge>}
|
||||
{order.status === 'draft' && <Badge className="bg-blue-500 hover:bg-blue-600">草稿</Badge>}
|
||||
{order.status === 'voided' && <Badge variant="destructive">已作廢</Badge>}
|
||||
{renderStatusBadge()}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1 font-medium">
|
||||
來源: {order.from_warehouse_name} <ArrowLeftRight className="inline-block h-3 w-3 mx-1" /> 目的: {order.to_warehouse_name} <span className="mx-2">|</span> 建立人: {order.created_by}
|
||||
@@ -240,6 +334,7 @@ export default function Show({ order }: any) {
|
||||
列印
|
||||
</Button>
|
||||
|
||||
{/* 草稿狀態:儲存 + 出貨/過帳 + 刪除 */}
|
||||
{!isReadOnly && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Can permission="inventory_transfer.delete">
|
||||
@@ -284,30 +379,168 @@ export default function Show({ order }: any) {
|
||||
className="button-filled-primary"
|
||||
disabled={items.length === 0 || isSaving}
|
||||
>
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
確認過帳
|
||||
{hasTransit ? (
|
||||
<><Truck className="w-4 h-4 mr-2" />確認出貨</>
|
||||
) : (
|
||||
<><CheckCircle className="w-4 h-4 mr-2" />確認過帳</>
|
||||
)}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>確定要過帳嗎?</AlertDialogTitle>
|
||||
<AlertDialogTitle>
|
||||
{hasTransit ? '確定要出貨嗎?' : '確定要過帳嗎?'}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
過帳後庫存將立即從「{order.from_warehouse_name}」轉移至「{order.to_warehouse_name}」,且無法再進行修改。
|
||||
{hasTransit ? (
|
||||
<>庫存將從「{order.from_warehouse_name}」移至在途倉「{selectedTransitWarehouse?.name || order.transit_warehouse_name}」,等待目的倉庫確認收貨後才完成調撥。</>
|
||||
) : (
|
||||
<>過帳後庫存將立即從「{order.from_warehouse_name}」轉移至「{order.to_warehouse_name}」,且無法再進行修改。</>
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handlePost} className="button-filled-primary">確認過帳</AlertDialogAction>
|
||||
<AlertDialogAction onClick={handlePost} className="button-filled-primary">
|
||||
{hasTransit ? '確認出貨' : '確認過帳'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Can>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 已出貨狀態:確認收貨按鈕 */}
|
||||
{order.status === 'dispatched' && (
|
||||
<Can permission="inventory_transfer.edit">
|
||||
<AlertDialog open={isReceiveDialogOpen} onOpenChange={setIsReceiveDialogOpen}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-green-600 hover:bg-green-700 text-white"
|
||||
>
|
||||
<PackageCheck className="w-4 h-4 mr-2" />
|
||||
確認收貨
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>確定要確認收貨嗎?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
庫存將從在途倉「{order.transit_warehouse_name}」移至目的倉庫「{order.to_warehouse_name}」,完成此次調撥流程。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleReceive} className="bg-green-600 hover:bg-green-700 text-white">確認收貨</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Can>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 在途倉資訊卡片 */}
|
||||
{(hasTransit || transitWarehouses.length > 0) && (
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Truck className="h-5 w-5 text-orange-500" />
|
||||
<Label className="text-gray-700 font-semibold text-base">在途倉庫(配送車輛)</Label>
|
||||
</div>
|
||||
|
||||
{order.status === 'draft' && canEdit ? (
|
||||
/* 草稿狀態:可選擇在途倉 */
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs text-gray-500 mb-1 block">選擇在途倉庫</Label>
|
||||
<Select
|
||||
value={transitWarehouseId || ''}
|
||||
onValueChange={(v) => setTransitWarehouseId(v === 'none' ? null : v)}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="不使用在途倉(直接過帳)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">不使用在途倉(直接過帳)</SelectItem>
|
||||
{transitWarehouses.map((w) => (
|
||||
<SelectItem key={w.id} value={w.id}>
|
||||
{w.name} {w.license_plate ? `(${w.license_plate})` : ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{selectedTransitWarehouse && (
|
||||
<>
|
||||
<div>
|
||||
<Label className="text-xs text-gray-500 mb-1 block">車牌</Label>
|
||||
<div className="text-sm font-medium text-gray-700 p-2 bg-gray-50 rounded border">
|
||||
{selectedTransitWarehouse.license_plate || '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-gray-500 mb-1 block">司機</Label>
|
||||
<div className="text-sm font-medium text-gray-700 p-2 bg-gray-50 rounded border">
|
||||
{selectedTransitWarehouse.driver_name || '-'}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : hasTransit ? (
|
||||
/* 非草稿狀態:唯讀顯示在途倉資訊 */
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs text-gray-500 mb-1 block">在途倉庫</Label>
|
||||
<div className="text-sm font-semibold text-gray-700">{order.transit_warehouse_name}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-gray-500 mb-1 block">車牌</Label>
|
||||
<div className="text-sm font-medium text-gray-700">{order.transit_warehouse_plate || '-'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-gray-500 mb-1 block">司機</Label>
|
||||
<div className="text-sm font-medium text-gray-700">{order.transit_warehouse_driver || '-'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-gray-500 mb-1 block">狀態</Label>
|
||||
<div className="text-sm font-medium">
|
||||
{order.status === 'dispatched' && (
|
||||
<span className="text-orange-600">配送中({order.dispatched_at})</span>
|
||||
)}
|
||||
{order.status === 'completed' && (
|
||||
<span className="text-green-600">已收貨({order.received_at})</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* 顯示時間軸(已出貨或已完成時) */}
|
||||
{(order.dispatched_at || order.received_at) && (
|
||||
<div className="border-t pt-3 mt-3 flex flex-wrap gap-6 text-sm text-gray-500">
|
||||
{order.dispatched_at && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Truck className="h-3.5 w-3.5 text-orange-400" />
|
||||
<span>出貨:{order.dispatched_at}</span>
|
||||
<span className="text-gray-400">({order.dispatched_by})</span>
|
||||
</div>
|
||||
)}
|
||||
{order.received_at && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<PackageCheck className="h-3.5 w-3.5 text-green-500" />
|
||||
<span>收貨:{order.received_at}</span>
|
||||
<span className="text-gray-400">({order.received_by})</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-gray-500 font-semibold">備註資訊</Label>
|
||||
@@ -497,7 +730,7 @@ export default function Show({ order }: any) {
|
||||
<TableHead className="font-medium text-grey-600">商品名稱 / 代號</TableHead>
|
||||
<TableHead className="font-medium text-grey-600">批號</TableHead>
|
||||
<TableHead className="text-right w-32 font-medium text-grey-600">
|
||||
{order.status === 'completed' ? '過帳時庫存' : '可用庫存'}
|
||||
{stockColumnTitle()}
|
||||
</TableHead>
|
||||
<TableHead className="text-right w-40 font-medium text-grey-600">調撥數量</TableHead>
|
||||
<TableHead className="font-medium text-grey-600">單位</TableHead>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Head, Link } from "@inertiajs/react";
|
||||
import { ArrowLeft, Package, Tag, Layers, MapPin, DollarSign } from "lucide-react";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { StatusBadge } from "@/Components/shared/StatusBadge";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import { getShowBreadcrumbs } from "@/utils/breadcrumb";
|
||||
import { Can } from "@/Components/Permission/Can";
|
||||
@@ -105,15 +105,15 @@ export default function ProductShow({ product }: Props) {
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs text-secondary-text">所屬分類</Label>
|
||||
<div className="mt-1">
|
||||
<Badge variant="outline">{product.category?.name || "未分類"}</Badge>
|
||||
<StatusBadge variant="neutral">{product.category?.name || "未分類"}</StatusBadge>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs text-secondary-text">啟用狀態</Label>
|
||||
<div className="mt-1">
|
||||
<Badge className={product.is_active ? "bg-green-100 text-green-700 border-green-200" : "bg-gray-100 text-gray-500 border-gray-200"}>
|
||||
<StatusBadge variant={product.is_active ? "success" : "neutral"}>
|
||||
{product.is_active ? "啟用中" : "已停用"}
|
||||
</Badge>
|
||||
</StatusBadge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/Components/ui/table";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { StatusBadge } from "@/Components/shared/StatusBadge";
|
||||
import { Loader2, Package, Calendar, Clock, BookOpen } from "lucide-react";
|
||||
|
||||
interface RecipeDetailModalProps {
|
||||
@@ -34,9 +34,9 @@ export function RecipeDetailModal({ isOpen, onClose, recipe, isLoading }: Recipe
|
||||
配方明細
|
||||
</DialogTitle>
|
||||
{recipe && (
|
||||
<Badge variant={recipe.is_active ? "default" : "secondary"} className="text-xs font-normal">
|
||||
<StatusBadge variant={recipe.is_active ? "success" : "neutral"} className="text-xs font-normal">
|
||||
{recipe.is_active ? "啟用中" : "已停用"}
|
||||
</Badge>
|
||||
</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { StatusBadge } from "@/Components/shared/StatusBadge";
|
||||
import { Can } from "@/Components/Permission/Can";
|
||||
import { RecipeDetailModal } from "./Components/RecipeDetailModal";
|
||||
import axios from 'axios';
|
||||
@@ -231,9 +231,11 @@ export default function RecipeIndex({ recipes, filters }: Props) {
|
||||
{recipe.yield_quantity}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge variant={recipe.is_active ? "default" : "secondary"}>
|
||||
{recipe.is_active ? "啟用" : "停用"}
|
||||
</Badge>
|
||||
{recipe.is_active ? (
|
||||
<StatusBadge variant="success">啟用</StatusBadge>
|
||||
) : (
|
||||
<StatusBadge variant="neutral">停用</StatusBadge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-500 text-sm">
|
||||
{new Date(recipe.updated_at).toLocaleDateString()}
|
||||
|
||||
@@ -10,7 +10,7 @@ import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import { Head, Link, useForm, router } from "@inertiajs/react";
|
||||
import { getBreadcrumbs } from "@/utils/breadcrumb";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { StatusBadge } from "@/Components/shared/StatusBadge";
|
||||
import ProductionOrderStatusBadge from '@/Components/ProductionOrder/ProductionOrderStatusBadge';
|
||||
import { ProductionStatusProgressBar } from '@/Components/ProductionOrder/ProductionStatusProgressBar';
|
||||
import { PRODUCTION_ORDER_STATUS, ProductionOrderStatus } from '@/constants/production-order';
|
||||
@@ -348,9 +348,9 @@ export default function ProductionShow({ productionOrder, warehouses, auth }: Pr
|
||||
<Link2 className="h-5 w-5 text-primary-main" />
|
||||
原料耗用與追溯
|
||||
</h2>
|
||||
<Badge variant="outline" className="text-grey-3 font-medium">
|
||||
<StatusBadge variant="neutral" className="text-grey-3 font-medium">
|
||||
共 {productionOrder.items.length} 項物料
|
||||
</Badge>
|
||||
</StatusBadge>
|
||||
</div>
|
||||
|
||||
{productionOrder.items.length === 0 ? (
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/Components/ui/alert-dialog";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { StatusBadge } from "@/Components/shared/StatusBadge";
|
||||
import { Plus, FileUp, Eye, Trash2, Search, X } from 'lucide-react';
|
||||
import { useState, useEffect } from "react";
|
||||
import { format } from 'date-fns';
|
||||
@@ -201,9 +201,11 @@ export default function SalesImportIndex({ batches, filters = {} }: Props) {
|
||||
NT$ {Number(batch.total_amount || 0).toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 2 })}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge variant={batch.status === 'confirmed' ? 'default' : 'secondary'}>
|
||||
{batch.status === 'confirmed' ? '已確認' : '待確認'}
|
||||
</Badge>
|
||||
{batch.status === 'confirmed' ? (
|
||||
<StatusBadge variant="success">已確認</StatusBadge>
|
||||
) : (
|
||||
<StatusBadge variant="warning">待確認</StatusBadge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex justify-center gap-2">
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/Components/ui/alert-dialog";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { StatusBadge } from "@/Components/shared/StatusBadge";
|
||||
import { ArrowLeft, CheckCircle, Trash2, Printer } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import Pagination from "@/Components/shared/Pagination";
|
||||
@@ -137,9 +137,9 @@ export default function SalesImportShow({ import: batch, items, filters = {} }:
|
||||
<p className="text-gray-500 mt-1">批次編號:#{batch.id} | 匯入時間:{format(new Date(batch.created_at), 'yyyy/MM/dd HH:mm')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant={batch.status === 'confirmed' ? 'default' : 'secondary'}>
|
||||
<StatusBadge variant={batch.status === 'confirmed' ? 'success' : 'neutral'}>
|
||||
{batch.status === 'confirmed' ? '已確認' : '待確認'}
|
||||
</Badge>
|
||||
</StatusBadge>
|
||||
{batch.status === 'pending' && (
|
||||
<div className="flex gap-3">
|
||||
{can('sales_imports.delete') && (
|
||||
@@ -304,9 +304,9 @@ export default function SalesImportShow({ import: batch, items, filters = {} }:
|
||||
{item.slot || '--'}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge variant="outline" className={item.original_status === '已出貨' ? "text-green-600 border-green-200 bg-green-50" : "text-gray-500"}>
|
||||
<StatusBadge variant={item.original_status === '已出貨' ? "success" : "neutral"} className={item.original_status === '已出貨' ? "" : "text-gray-500"}>
|
||||
{item.original_status}
|
||||
</Badge>
|
||||
</StatusBadge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">{Math.floor(item.quantity)}</TableCell>
|
||||
<TableCell className="text-right font-bold text-primary">
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/Components/ui/table";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { StatusBadge } from "@/Components/shared/StatusBadge";
|
||||
|
||||
interface Props {
|
||||
orders: {
|
||||
@@ -54,13 +54,13 @@ export default function ShippingOrderIndex({ orders, filters, warehouses }: Prop
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'draft':
|
||||
return <Badge variant="secondary">草稿</Badge>;
|
||||
return <StatusBadge variant="neutral">草稿</StatusBadge>;
|
||||
case 'completed':
|
||||
return <Badge className="bg-green-100 text-green-800">已過帳</Badge>;
|
||||
return <StatusBadge variant="success">已過帳</StatusBadge>;
|
||||
case 'cancelled':
|
||||
return <Badge variant="destructive">已取消</Badge>;
|
||||
return <StatusBadge variant="destructive">已取消</StatusBadge>;
|
||||
default:
|
||||
return <Badge>{status}</Badge>;
|
||||
return <StatusBadge variant="neutral">{status}</StatusBadge>;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ArrowLeft, Package, Info, CheckCircle2, AlertCircle, Trash2, Edit } fro
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import { Head, Link, router } from "@inertiajs/react";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { StatusBadge } from "@/Components/shared/StatusBadge";
|
||||
import { toast } from "sonner";
|
||||
import ActivityLog from "@/Components/ActivityLog/ActivityLog";
|
||||
|
||||
@@ -31,16 +31,16 @@ export default function ShippingOrderShow({ order, activities = [] }: Props) {
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'draft':
|
||||
return <Badge variant="secondary" className="px-3 py-1">草稿</Badge>;
|
||||
case 'completed':
|
||||
return <Badge className="bg-green-100 text-green-800 px-3 py-1">已完成</Badge>;
|
||||
case 'cancelled':
|
||||
return <Badge variant="destructive" className="px-3 py-1">已取消</Badge>;
|
||||
default:
|
||||
return <Badge>{status}</Badge>;
|
||||
}
|
||||
const statusConfig: Record<string, { variant: "neutral" | "success" | "destructive", label: string }> = {
|
||||
draft: { variant: 'neutral', label: '草稿' },
|
||||
completed: { variant: 'success', label: '已完成' },
|
||||
cancelled: { variant: 'destructive', label: '已取消' },
|
||||
};
|
||||
|
||||
const config = statusConfig[status];
|
||||
if (!config) return <StatusBadge variant="neutral">{status}</StatusBadge>;
|
||||
|
||||
return <StatusBadge variant={config.variant}>{config.label}</StatusBadge>;
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -130,7 +130,7 @@ export default function ShippingOrderShow({ order, activities = [] }: Props) {
|
||||
<h2 className="text-lg font-bold flex items-center gap-2">
|
||||
<Package className="h-5 w-5 text-primary-main" /> 出貨品項清單
|
||||
</h2>
|
||||
<Badge variant="outline">{order.items.length} 個品項</Badge>
|
||||
<StatusBadge variant="neutral">{order.items.length} 個品項</StatusBadge>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
@@ -151,7 +151,7 @@ export default function ShippingOrderShow({ order, activities = [] }: Props) {
|
||||
<div className="text-xs text-gray-500 font-mono">{item.product_code}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<Badge variant="outline" className="font-mono">{item.batch_number || 'N/A'}</Badge>
|
||||
<StatusBadge variant="neutral" className="font-mono">{item.batch_number || 'N/A'}</StatusBadge>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<span className="font-medium text-gray-900">{parseFloat(item.quantity).toLocaleString()}</span>
|
||||
|
||||
374
resources/js/Pages/StoreRequisition/Create.tsx
Normal file
374
resources/js/Pages/StoreRequisition/Create.tsx
Normal file
@@ -0,0 +1,374 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
396
resources/js/Pages/StoreRequisition/Index.tsx
Normal file
396
resources/js/Pages/StoreRequisition/Index.tsx
Normal file
@@ -0,0 +1,396 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import { Head, Link, router } from "@inertiajs/react";
|
||||
import { debounce } from "lodash";
|
||||
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/Components/ui/table";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/Components/ui/alert-dialog";
|
||||
import Pagination from "@/Components/shared/Pagination";
|
||||
import { toast } from "sonner";
|
||||
import { Can } from "@/Components/Permission/Can";
|
||||
import { usePermission } from "@/hooks/usePermission";
|
||||
import { StatusBadge } from "@/Components/shared/StatusBadge";
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Store,
|
||||
Eye,
|
||||
Pencil,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { formatDate } from "@/lib/date";
|
||||
|
||||
function getStatusBadge(status: string) {
|
||||
switch (status) {
|
||||
case 'draft':
|
||||
return <StatusBadge variant="neutral">草稿</StatusBadge>;
|
||||
case 'pending':
|
||||
return <StatusBadge variant="warning">待審核</StatusBadge>;
|
||||
case 'approved':
|
||||
return <StatusBadge variant="success">已核准</StatusBadge>;
|
||||
case 'rejected':
|
||||
return <StatusBadge variant="destructive">已駁回</StatusBadge>;
|
||||
case 'completed':
|
||||
return <StatusBadge variant="success">已完成</StatusBadge>;
|
||||
case 'cancelled':
|
||||
return <StatusBadge variant="neutral">已取消</StatusBadge>;
|
||||
default:
|
||||
return <StatusBadge variant="neutral">{status}</StatusBadge>;
|
||||
}
|
||||
}
|
||||
|
||||
export default function Index({
|
||||
requisitions,
|
||||
filters,
|
||||
warehouses,
|
||||
}: {
|
||||
requisitions: any;
|
||||
filters: any;
|
||||
warehouses: { id: number; name: string }[];
|
||||
}) {
|
||||
const { can } = usePermission();
|
||||
const [searchTerm, setSearchTerm] = useState(filters.search || "");
|
||||
const [statusFilter, setStatusFilter] = useState(filters.status || "all");
|
||||
const [warehouseFilter, setWarehouseFilter] = useState(filters.warehouse_id || "all");
|
||||
const [perPage, setPerPage] = useState(filters.per_page || "10");
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchTerm(filters.search || "");
|
||||
setStatusFilter(filters.status || "all");
|
||||
setWarehouseFilter(filters.warehouse_id || "all");
|
||||
setPerPage(filters.per_page || "10");
|
||||
}, [filters]);
|
||||
|
||||
const applyFilters = useCallback(
|
||||
(overrides: Record<string, string> = {}) => {
|
||||
const params: Record<string, string> = {
|
||||
search: searchTerm,
|
||||
status: statusFilter === "all" ? "" : statusFilter,
|
||||
warehouse_id: warehouseFilter === "all" ? "" : warehouseFilter,
|
||||
per_page: perPage,
|
||||
...overrides,
|
||||
};
|
||||
// 清理空值
|
||||
Object.keys(params).forEach((key) => {
|
||||
if (!params[key]) delete params[key];
|
||||
});
|
||||
router.get(route("store-requisitions.index"), params, {
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
preserveScroll: true,
|
||||
});
|
||||
},
|
||||
[searchTerm, statusFilter, warehouseFilter, perPage]
|
||||
);
|
||||
|
||||
const debouncedSearch = useCallback(
|
||||
debounce((term: string) => {
|
||||
applyFilters({ search: term });
|
||||
}, 500),
|
||||
[applyFilters]
|
||||
);
|
||||
|
||||
const handleSearchChange = (term: string) => {
|
||||
setSearchTerm(term);
|
||||
debouncedSearch(term);
|
||||
};
|
||||
|
||||
const handleClearSearch = () => {
|
||||
setSearchTerm("");
|
||||
applyFilters({ search: "" });
|
||||
};
|
||||
|
||||
const handleStatusChange = (value: string) => {
|
||||
setStatusFilter(value);
|
||||
applyFilters({ status: value === "all" ? "" : value });
|
||||
};
|
||||
|
||||
const handleWarehouseChange = (value: string) => {
|
||||
setWarehouseFilter(value);
|
||||
applyFilters({ warehouse_id: value === "all" ? "" : value });
|
||||
};
|
||||
|
||||
const handlePerPageChange = (value: string) => {
|
||||
setPerPage(value);
|
||||
applyFilters({ per_page: value });
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (deleteId) {
|
||||
router.delete(route("store-requisitions.destroy", [deleteId]), {
|
||||
onSuccess: () => {
|
||||
setDeleteId(null);
|
||||
toast.success("已成功刪除");
|
||||
},
|
||||
onError: () => setDeleteId(null),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
breadcrumbs={[
|
||||
{ label: "商品與庫存管理", href: "#" },
|
||||
{ label: "門市叫貨", href: route("store-requisitions.index"), isPage: true },
|
||||
]}
|
||||
>
|
||||
<Head title="門市叫貨" />
|
||||
|
||||
<div className="container mx-auto p-6 max-w-7xl">
|
||||
{/* 頁面標題 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||
<Store className="h-6 w-6 text-primary-main" />
|
||||
門市叫貨管理
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
門市人員依庫存與銷售需求,向總倉提出商品補貨申請。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 篩選工具列 */}
|
||||
<div className="bg-white rounded-lg shadow-sm border p-4 mb-6">
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
{/* 搜尋 */}
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
||||
<Input
|
||||
placeholder="搜尋單號..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
className="pl-10 pr-10 h-9"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
onClick={handleClearSearch}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 狀態篩選 */}
|
||||
<SearchableSelect
|
||||
value={statusFilter}
|
||||
onValueChange={handleStatusChange}
|
||||
options={[
|
||||
{ label: "所有狀態", value: "all" },
|
||||
{ label: "草稿", value: "draft" },
|
||||
{ label: "待審核", value: "pending" },
|
||||
{ label: "已核准", value: "approved" },
|
||||
{ label: "已駁回", value: "rejected" },
|
||||
{ label: "已完成", value: "completed" },
|
||||
{ label: "已取消", value: "cancelled" },
|
||||
]}
|
||||
placeholder="選擇狀態"
|
||||
className="w-full md:w-[160px] h-9"
|
||||
showSearch={false}
|
||||
/>
|
||||
|
||||
{/* 倉庫篩選 */}
|
||||
<SearchableSelect
|
||||
value={warehouseFilter}
|
||||
onValueChange={handleWarehouseChange}
|
||||
options={[
|
||||
{ label: "所有倉庫", value: "all" },
|
||||
...warehouses.map((w) => ({
|
||||
label: w.name,
|
||||
value: w.id.toString(),
|
||||
})),
|
||||
]}
|
||||
placeholder="選擇倉庫"
|
||||
className="w-full md:w-[200px] h-9"
|
||||
/>
|
||||
|
||||
{/* 操作按鈕 */}
|
||||
<div className="flex gap-2 w-full md:w-auto">
|
||||
<Can permission="store_requisitions.create">
|
||||
<Link href={route("store-requisitions.create")}>
|
||||
<Button className="flex-1 md:flex-none button-filled-primary">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
新增叫貨單
|
||||
</Button>
|
||||
</Link>
|
||||
</Can>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 資料表格 */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm 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">單號</TableHead>
|
||||
<TableHead className="font-medium text-gray-600">申請倉庫</TableHead>
|
||||
<TableHead className="font-medium text-gray-600">供貨倉庫</TableHead>
|
||||
<TableHead className="font-medium text-gray-600">申請人</TableHead>
|
||||
<TableHead className="font-medium text-gray-600">建立日期</TableHead>
|
||||
<TableHead className="text-center font-medium text-gray-600">狀態</TableHead>
|
||||
<TableHead className="text-center font-medium text-gray-600">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{requisitions.data.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center h-24 text-gray-500">
|
||||
尚無叫貨紀錄
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
requisitions.data.map((req: any, index: number) => (
|
||||
<TableRow
|
||||
key={req.id}
|
||||
className="hover:bg-gray-50/50 transition-colors cursor-pointer group"
|
||||
onClick={() =>
|
||||
router.visit(route("store-requisitions.show", [req.id]))
|
||||
}
|
||||
>
|
||||
<TableCell className="text-center text-gray-500 font-medium">
|
||||
{(requisitions.current_page - 1) * requisitions.per_page + index + 1}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium text-primary-main">
|
||||
{req.doc_no}
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-700">
|
||||
{req.store_warehouse_name}
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-700">
|
||||
{req.supply_warehouse_name}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{req.creator_name}</TableCell>
|
||||
<TableCell className="text-gray-500 text-sm">
|
||||
{formatDate(req.created_at)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{getStatusBadge(req.status)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<div
|
||||
className="flex items-center justify-center gap-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{(() => {
|
||||
const isEditable = ["draft", "rejected"].includes(req.status);
|
||||
const canEdit = can("store_requisitions.edit");
|
||||
|
||||
if (isEditable && canEdit) {
|
||||
return (
|
||||
<Link href={route("store-requisitions.edit", [req.id])}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="button-outlined-primary"
|
||||
title="編輯"
|
||||
>
|
||||
<Pencil className="w-4 h-4 ml-0.5" />
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={route("store-requisitions.show", [req.id])}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="button-outlined-primary"
|
||||
title="查閱"
|
||||
>
|
||||
<Eye className="w-4 h-4 ml-0.5" />
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
})()}
|
||||
|
||||
{req.status === "draft" && (
|
||||
<Can permission="store_requisitions.delete">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="button-outlined-error"
|
||||
title="刪除"
|
||||
onClick={() => setDeleteId(req.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 ml-0.5" />
|
||||
</Button>
|
||||
</Can>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 分頁 */}
|
||||
<div className="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<span>每頁顯示</span>
|
||||
<SearchableSelect
|
||||
value={perPage}
|
||||
onValueChange={handlePerPageChange}
|
||||
options={[
|
||||
{ label: "10", value: "10" },
|
||||
{ label: "20", value: "20" },
|
||||
{ label: "50", value: "50" },
|
||||
{ label: "100", value: "100" },
|
||||
]}
|
||||
className="w-[90px] h-8"
|
||||
showSearch={false}
|
||||
/>
|
||||
<span>筆</span>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">共 {requisitions.total} 筆紀錄</span>
|
||||
</div>
|
||||
<Pagination links={requisitions.links} />
|
||||
</div>
|
||||
|
||||
{/* 刪除確認對話框 */}
|
||||
<AlertDialog open={!!deleteId} onOpenChange={(open) => !open && setDeleteId(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>確定要刪除此叫貨單嗎?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
此動作無法復原。如果單據已存在重要資料,請謹慎操作。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} className="button-filled-error">
|
||||
確認刪除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
588
resources/js/Pages/StoreRequisition/Show.tsx
Normal file
588
resources/js/Pages/StoreRequisition/Show.tsx
Normal file
@@ -0,0 +1,588 @@
|
||||
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 { StatusBadge } from "@/Components/shared/StatusBadge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/Components/ui/table";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} from "@/Components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/Components/ui/alert-dialog";
|
||||
import { toast } from "sonner";
|
||||
import { Can } from "@/Components/Permission/Can";
|
||||
import { usePermission } from "@/hooks/usePermission";
|
||||
import {
|
||||
Store,
|
||||
SendHorizontal,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Pencil,
|
||||
Loader2,
|
||||
ArrowLeft,
|
||||
} from "lucide-react";
|
||||
import { formatDate } from "@/lib/date";
|
||||
|
||||
function getStatusBadge(status: string) {
|
||||
const statusMap: Record<string, { label: string; variant: "neutral" | "warning" | "success" | "destructive" | "info" }> = {
|
||||
draft: { label: "草稿", variant: "neutral" },
|
||||
pending: { label: "待審核", variant: "warning" },
|
||||
approved: { label: "已核准", variant: "success" },
|
||||
rejected: { label: "已駁回", variant: "destructive" },
|
||||
completed: { label: "已完成", variant: "success" },
|
||||
cancelled: { label: "已取消", variant: "neutral" },
|
||||
};
|
||||
|
||||
const config = statusMap[status];
|
||||
if (!config) return <StatusBadge variant="neutral">{status}</StatusBadge>;
|
||||
|
||||
return (
|
||||
<StatusBadge variant={config.variant}>
|
||||
{config.label}
|
||||
</StatusBadge>
|
||||
);
|
||||
}
|
||||
|
||||
interface RequisitionItem {
|
||||
id: number;
|
||||
product_id: number;
|
||||
product_name: string;
|
||||
product_code: string;
|
||||
unit_name: string;
|
||||
requested_qty: number;
|
||||
approved_qty: number | null;
|
||||
current_stock: number;
|
||||
remark: string | null;
|
||||
}
|
||||
|
||||
interface Requisition {
|
||||
id: number;
|
||||
doc_no: string;
|
||||
status: string;
|
||||
store_warehouse_id: number;
|
||||
store_warehouse_name: string;
|
||||
supply_warehouse_id: number | null;
|
||||
supply_warehouse_name: string;
|
||||
remark: string | null;
|
||||
reject_reason: string | null;
|
||||
creator_name: string;
|
||||
approver_name: string;
|
||||
submitted_at: string | null;
|
||||
approved_at: string | null;
|
||||
transfer_order_id: number | null;
|
||||
created_at: string;
|
||||
items: RequisitionItem[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
requisition: Requisition;
|
||||
warehouses: { id: number; name: string }[];
|
||||
activities: any[];
|
||||
}
|
||||
|
||||
export default function Show({ requisition, warehouses }: Props) {
|
||||
usePermission();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [approving, setApproving] = useState(false);
|
||||
const [rejecting, setRejecting] = useState(false);
|
||||
|
||||
// 核准狀態
|
||||
const [showApproveDialog, setShowApproveDialog] = useState(false);
|
||||
const [supplyWarehouseId, setSupplyWarehouseId] = useState("");
|
||||
const [approvedItems, setApprovedItems] = useState<{ id: number; approved_qty: string }[]>(
|
||||
requisition.items.map((item) => ({
|
||||
id: item.id,
|
||||
approved_qty: item.requested_qty.toString(),
|
||||
}))
|
||||
);
|
||||
|
||||
// 駁回狀態
|
||||
const [showRejectDialog, setShowRejectDialog] = useState(false);
|
||||
const [rejectReason, setRejectReason] = useState("");
|
||||
|
||||
// 提交確認
|
||||
const [showSubmitDialog, setShowSubmitDialog] = useState(false);
|
||||
|
||||
const handleSubmit = () => {
|
||||
setSubmitting(true);
|
||||
router.post(route("store-requisitions.submit", [requisition.id]), {}, {
|
||||
onFinish: () => {
|
||||
setSubmitting(false);
|
||||
setShowSubmitDialog(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleApprove = () => {
|
||||
if (!supplyWarehouseId) {
|
||||
toast.error("請選擇供貨倉庫");
|
||||
return;
|
||||
}
|
||||
// 確認每個核准數量
|
||||
for (const item of approvedItems) {
|
||||
const qty = parseFloat(item.approved_qty);
|
||||
if (isNaN(qty) || qty < 0) {
|
||||
toast.error("核准數量不能為負數");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setApproving(true);
|
||||
router.post(
|
||||
route("store-requisitions.approve", [requisition.id]),
|
||||
{
|
||||
supply_warehouse_id: supplyWarehouseId,
|
||||
items: approvedItems.map((item) => ({
|
||||
id: item.id,
|
||||
approved_qty: parseFloat(item.approved_qty),
|
||||
})),
|
||||
},
|
||||
{
|
||||
onFinish: () => {
|
||||
setApproving(false);
|
||||
setShowApproveDialog(false);
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleReject = () => {
|
||||
if (!rejectReason.trim()) {
|
||||
toast.error("請填寫駁回原因");
|
||||
return;
|
||||
}
|
||||
setRejecting(true);
|
||||
router.post(
|
||||
route("store-requisitions.reject", [requisition.id]),
|
||||
{ reject_reason: rejectReason },
|
||||
{
|
||||
onFinish: () => {
|
||||
setRejecting(false);
|
||||
setShowRejectDialog(false);
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const updateApprovedQty = (itemId: number, qty: string) => {
|
||||
setApprovedItems(
|
||||
approvedItems.map((item) => (item.id === itemId ? { ...item, approved_qty: qty } : item))
|
||||
);
|
||||
};
|
||||
|
||||
const isEditable = ["draft", "rejected"].includes(requisition.status);
|
||||
const isPending = requisition.status === "pending";
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
breadcrumbs={[
|
||||
{ label: "商品與庫存管理", href: "#" },
|
||||
{ label: "門市叫貨", href: route("store-requisitions.index") },
|
||||
{ label: requisition.doc_no, href: "#", isPage: true },
|
||||
]}
|
||||
>
|
||||
<Head title={`叫貨單 ${requisition.doc_no}`} />
|
||||
|
||||
<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="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||
<Store className="h-6 w-6 text-primary-main" />
|
||||
{requisition.doc_no}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{getStatusBadge(requisition.status)}
|
||||
<span className="text-gray-500 text-sm">
|
||||
{formatDate(requisition.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按鈕 */}
|
||||
<div className="flex gap-2">
|
||||
{isEditable && (
|
||||
<>
|
||||
<Can permission="store_requisitions.edit">
|
||||
<Link href={route("store-requisitions.edit", [requisition.id])}>
|
||||
<Button variant="outline" className="button-outlined-primary">
|
||||
<Pencil className="w-4 h-4 mr-1" />
|
||||
編輯
|
||||
</Button>
|
||||
</Link>
|
||||
</Can>
|
||||
{requisition.status === "draft" && (
|
||||
<Can permission="store_requisitions.view">
|
||||
<Button
|
||||
className="button-filled-primary"
|
||||
onClick={() => setShowSubmitDialog(true)}
|
||||
>
|
||||
<SendHorizontal className="w-4 h-4 mr-1" />
|
||||
提交審核
|
||||
</Button>
|
||||
</Can>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{isPending && (
|
||||
<>
|
||||
<Can permission="store_requisitions.approve">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="button-outlined-error"
|
||||
onClick={() => setShowRejectDialog(true)}
|
||||
>
|
||||
<XCircle className="w-4 h-4 mr-1" />
|
||||
駁回
|
||||
</Button>
|
||||
<Button
|
||||
className="button-filled-success"
|
||||
onClick={() => setShowApproveDialog(true)}
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4 mr-1" />
|
||||
核准
|
||||
</Button>
|
||||
</Can>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</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-3 gap-6">
|
||||
<div>
|
||||
<span className="text-sm text-gray-500">申請倉庫</span>
|
||||
<p className="font-medium text-gray-800 mt-1">
|
||||
{requisition.store_warehouse_name}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-gray-500">供貨倉庫</span>
|
||||
<p className="font-medium text-gray-800 mt-1">
|
||||
{requisition.supply_warehouse_name || "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-gray-500">申請人</span>
|
||||
<p className="font-medium text-gray-800 mt-1">
|
||||
{requisition.creator_name}
|
||||
</p>
|
||||
</div>
|
||||
{requisition.submitted_at && (
|
||||
<div>
|
||||
<span className="text-sm text-gray-500">提交時間</span>
|
||||
<p className="font-medium text-gray-800 mt-1">
|
||||
{formatDate(requisition.submitted_at)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{requisition.approved_at && (
|
||||
<>
|
||||
<div>
|
||||
<span className="text-sm text-gray-500">審核人</span>
|
||||
<p className="font-medium text-gray-800 mt-1">
|
||||
{requisition.approver_name}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-gray-500">審核時間</span>
|
||||
<p className="font-medium text-gray-800 mt-1">
|
||||
{formatDate(requisition.approved_at)}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{requisition.remark && (
|
||||
<div className="md:col-span-3">
|
||||
<span className="text-sm text-gray-500">備註</span>
|
||||
<p className="font-medium text-gray-800 mt-1">
|
||||
{requisition.remark}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{requisition.reject_reason && (
|
||||
<div className="md:col-span-3">
|
||||
<span className="text-sm text-red-500 font-medium">駁回原因</span>
|
||||
<p className="text-red-600 bg-red-50 rounded-md p-3 mt-1">
|
||||
{requisition.reject_reason}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{requisition.transfer_order_id && (
|
||||
<div>
|
||||
<span className="text-sm text-gray-500">關聯調撥單</span>
|
||||
<p className="mt-1">
|
||||
<Link
|
||||
href={`${route("inventory.transfer.show", [requisition.transfer_order_id])}?from=requisition&from_id=${requisition.id}&from_doc=${encodeURIComponent(requisition.doc_no)}`}
|
||||
className="text-primary-main hover:underline font-medium"
|
||||
>
|
||||
查看調撥單 →
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</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="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">商品編號</TableHead>
|
||||
<TableHead className="font-medium text-gray-600">商品名稱</TableHead>
|
||||
<TableHead className="text-right font-medium text-gray-600">
|
||||
現有庫存
|
||||
</TableHead>
|
||||
<TableHead className="text-right font-medium text-gray-600">
|
||||
需求數量
|
||||
</TableHead>
|
||||
<TableHead className="font-medium text-gray-600">單位</TableHead>
|
||||
{["approved", "completed"].includes(requisition.status) && (
|
||||
<TableHead className="text-right font-medium text-gray-600">
|
||||
核准數量
|
||||
</TableHead>
|
||||
)}
|
||||
<TableHead className="font-medium text-gray-600">備註</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{requisition.items.map((item, index) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="text-center text-gray-500 font-medium">
|
||||
{index + 1}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm text-gray-600">
|
||||
{item.product_code}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium text-gray-800">
|
||||
{item.product_name}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-gray-600">
|
||||
{Number(item.current_stock).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium text-gray-800">
|
||||
{Number(item.requested_qty).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-500">{item.unit_name}</TableCell>
|
||||
{["approved", "completed"].includes(requisition.status) && (
|
||||
<TableCell className="text-right font-medium text-green-600">
|
||||
{item.approved_qty !== null
|
||||
? Number(item.approved_qty).toLocaleString()
|
||||
: "-"}
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell className="text-gray-500 text-sm">
|
||||
{item.remark || "-"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 提交確認 */}
|
||||
<AlertDialog open={showSubmitDialog} onOpenChange={setShowSubmitDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>確認提交審核?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
提交後將無法修改叫貨單內容,並會通知相關人員進行審核。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleSubmit}
|
||||
className="button-filled-primary"
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
確認提交
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 核准對話框 */}
|
||||
<Dialog open={showApproveDialog} onOpenChange={setShowApproveDialog}>
|
||||
<DialogContent className="sm:max-w-[700px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>核准叫貨單</DialogTitle>
|
||||
<DialogDescription>選擇供貨倉庫,並確認各商品的核准數量。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
供貨倉庫 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<SearchableSelect
|
||||
value={supplyWarehouseId}
|
||||
onValueChange={setSupplyWarehouseId}
|
||||
options={warehouses
|
||||
.filter((w) => w.id !== requisition.store_warehouse_id)
|
||||
.map((w) => ({
|
||||
label: w.name,
|
||||
value: w.id.toString(),
|
||||
}))}
|
||||
placeholder="請選擇供貨倉庫"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="bg-gray-50">
|
||||
<TableRow>
|
||||
<TableHead className="font-medium text-gray-600">商品</TableHead>
|
||||
<TableHead className="text-right font-medium text-gray-600 w-[120px]">
|
||||
需求數量
|
||||
</TableHead>
|
||||
<TableHead className="font-medium text-gray-600 w-[80px]">單位</TableHead>
|
||||
<TableHead className="font-medium text-gray-600 w-[150px]">
|
||||
核准數量
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{requisition.items.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>
|
||||
<span className="font-mono text-xs text-gray-500">
|
||||
{item.product_code}
|
||||
</span>
|
||||
<span className="ml-2 text-gray-800">{item.product_name}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-gray-700">
|
||||
{Number(item.requested_qty).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-500 text-sm">
|
||||
{item.unit_name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
step="1"
|
||||
min="0"
|
||||
value={
|
||||
approvedItems.find((ai) => ai.id === item.id)
|
||||
?.approved_qty || ""
|
||||
}
|
||||
onChange={(e) =>
|
||||
updateApprovedQty(item.id, e.target.value)
|
||||
}
|
||||
className="h-8 text-right"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="button-outlined-primary"
|
||||
onClick={() => setShowApproveDialog(false)}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-green-600 hover:bg-green-700 text-white"
|
||||
onClick={handleApprove}
|
||||
disabled={approving || !supplyWarehouseId}
|
||||
>
|
||||
{approving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
確認核准
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 駁回對話框 */}
|
||||
<Dialog open={showRejectDialog} onOpenChange={setShowRejectDialog}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>駁回叫貨單</DialogTitle>
|
||||
<DialogDescription>
|
||||
請說明駁回原因,申請人可根據原因修改後重新提交。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-2">
|
||||
<Label>
|
||||
駁回原因 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
placeholder="請填寫駁回原因..."
|
||||
rows={4}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="button-outlined-primary"
|
||||
onClick={() => setShowRejectDialog(false)}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleReject}
|
||||
disabled={rejecting || !rejectReason.trim()}
|
||||
>
|
||||
{rejecting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
確認駁回
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
@@ -39,13 +39,19 @@ interface PageProps {
|
||||
book_amount: number;
|
||||
abnormal_amount: number;
|
||||
};
|
||||
transitWarehouses: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
license_plate?: string;
|
||||
driver_name?: string;
|
||||
}>;
|
||||
filters: {
|
||||
search?: string;
|
||||
per_page?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function WarehouseIndex({ warehouses, totals, filters }: PageProps) {
|
||||
export default function WarehouseIndex({ warehouses, totals, transitWarehouses, filters }: PageProps) {
|
||||
// 篩選狀態
|
||||
const [searchTerm, setSearchTerm] = useState(filters.search || "");
|
||||
const [perPage, setPerPage] = useState(filters.per_page || '10');
|
||||
@@ -306,6 +312,7 @@ export default function WarehouseIndex({ warehouses, totals, filters }: PageProp
|
||||
warehouse={editingWarehouse}
|
||||
onSave={handleSaveWarehouse}
|
||||
onDelete={handleDeleteWarehouse}
|
||||
transitWarehouses={transitWarehouses}
|
||||
/>
|
||||
|
||||
{/* 調撥單建立對話框 */}
|
||||
|
||||
@@ -3,18 +3,19 @@
|
||||
*/
|
||||
|
||||
import type { PurchaseOrderStatus, PaymentMethod, InvoiceType } from "@/types/purchase-order";
|
||||
import { StatusVariant } from "@/Components/shared/StatusBadge";
|
||||
|
||||
// 狀態標籤配置
|
||||
export const STATUS_CONFIG: Record<
|
||||
PurchaseOrderStatus,
|
||||
{ label: string; variant: "default" | "secondary" | "destructive" | "outline" }
|
||||
{ label: string; variant: StatusVariant }
|
||||
> = {
|
||||
draft: { label: "草稿", variant: "outline" },
|
||||
pending: { label: "簽核中", variant: "outline" },
|
||||
approved: { label: "已核准", variant: "default" },
|
||||
partial: { label: "部分收貨", variant: "secondary" },
|
||||
completed: { label: "全數收貨", variant: "outline" },
|
||||
closed: { label: "已結案", variant: "outline" },
|
||||
draft: { label: "草稿", variant: "neutral" },
|
||||
pending: { label: "簽核中", variant: "warning" },
|
||||
approved: { label: "已核准", variant: "success" },
|
||||
partial: { label: "部分收貨", variant: "neutral" },
|
||||
completed: { label: "全數收貨", variant: "success" },
|
||||
closed: { label: "已結案", variant: "neutral" },
|
||||
cancelled: { label: "已作廢", variant: "destructive" },
|
||||
};
|
||||
|
||||
|
||||
@@ -31,6 +31,8 @@ export interface Warehouse {
|
||||
available_stock?: number;
|
||||
book_amount?: number;
|
||||
abnormal_amount?: number;
|
||||
default_transit_warehouse_id?: string | null; // 預設在途倉 ID
|
||||
default_transit_warehouse_name?: string | null; // 預設在途倉名稱
|
||||
}
|
||||
// 倉庫中的庫存項目
|
||||
export interface WarehouseInventory {
|
||||
|
||||
Reference in New Issue
Block a user