Compare commits

...

15 Commits

Author SHA1 Message Date
77a7d31dc1 fix(dashboard): 修正儀表板待處理數字邏輯與依賴更新
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 54s
2026-02-13 14:29:21 +08:00
e141a45eb9 feat(dashboard): 新增庫存積壓、熱銷數量與即將過期排行,優化熱銷商品顯示與 Tooltip
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 41s
2026-02-13 14:27:43 +08:00
4fa87925a2 UI優化: 全系統狀態標籤 (StatusBadge) 統一化重構完成 (Phase 3 & 4)
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 1m8s
2026-02-13 13:16:05 +08:00
b8cbf0bb6d Merge branch 'dev'
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 51s
2026-02-12 17:13:57 +08:00
245553280a Merge branch 'dev'
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 50s
2026-02-12 16:41:08 +08:00
c9113544ee Merge branch 'dev'
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 51s
2026-02-12 16:31:46 +08:00
b118ea0c39 Merge branch 'dev'
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 52s
2026-02-12 13:41:43 +08:00
57e633c3e9 Merge branch 'dev'
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 1m14s
2026-02-12 13:39:57 +08:00
6c146ac717 Merge branch 'dev'
Some checks failed
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Failing after 0s
2026-02-12 13:37:14 +08:00
e646c6ffd8 Merge branch 'dev'
Some checks failed
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Failing after 0s
2026-02-12 13:31:42 +08:00
19397db2e9 Merge branch 'dev'
Some checks failed
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Failing after 2s
2026-02-12 13:29:07 +08:00
74eeb449f8 Merge branch 'dev'
Some checks failed
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Failing after 4s
2026-02-12 13:27:43 +08:00
bd292b0868 Merge branch 'dev'
Some checks failed
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Failing after 5s
2026-02-12 13:23:10 +08:00
936abc943e trigger: re-run deploy 2 (valid config)
Some checks failed
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Failing after 3s
2026-02-12 13:17:45 +08:00
eabde37d15 trigger: re-run deploy (fix runner permissions)
Some checks failed
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Failing after 1s
2026-02-12 13:14:48 +08:00
51 changed files with 1808 additions and 660 deletions

View File

@@ -32,6 +32,102 @@ class DashboardController extends Controller
}
$invStats = $this->inventoryService->getDashboardStats();
$procStats = $this->procurementService->getDashboardStats();
// 銷售統計 (本月營收)
$thisMonthRevenue = \App\Modules\Sales\Models\SalesImportItem::whereMonth('transaction_at', now()->month)
->whereYear('transaction_at', now()->year)
->sum('amount');
// 生產統計 (待核准工單)
$pendingProductionCount = \App\Modules\Production\Models\ProductionOrder::where('status', 'pending')->count();
// 生產狀態分佈
// 近30日銷售趨勢 (Area Chart)
$startDate = now()->subDays(29)->startOfDay();
$salesData = \App\Modules\Sales\Models\SalesImportItem::where('transaction_at', '>=', $startDate)
->selectRaw('DATE(transaction_at) as date, SUM(amount) as total')
->groupBy('date')
->orderBy('date')
->get()
->mapWithKeys(function ($item) {
return [$item->date => (int)$item->total];
});
$salesTrend = [];
for ($i = 0; $i < 30; $i++) {
$date = $startDate->copy()->addDays($i)->format('Y-m-d');
$salesTrend[] = [
'date' => $startDate->copy()->addDays($i)->format('m/d'),
'amount' => $salesData[$date] ?? 0,
];
}
// 本月熱銷商品 Top 5 (Bar Chart)
$topSellingProducts = \App\Modules\Sales\Models\SalesImportItem::with('product')
->whereMonth('transaction_at', now()->month)
->whereYear('transaction_at', now()->year)
->select('product_code', 'product_id', \Illuminate\Support\Facades\DB::raw('SUM(amount) as total_amount'))
->groupBy('product_code', 'product_id')
->orderByDesc('total_amount')
->limit(5)
->get()
->map(function ($item) {
return [
'name' => $item->product ? $item->product->name : $item->product_code,
'amount' => (int)$item->total_amount,
];
});
// 庫存積壓排行 (Top Inventory Value)
$topInventoryValue = \App\Modules\Inventory\Models\Inventory::with('product')
->select('product_id', \Illuminate\Support\Facades\DB::raw('SUM(quantity * unit_cost) as total_value'))
->where('quantity', '>', 0)
->groupBy('product_id')
->orderByDesc('total_value')
->limit(5)
->get()
->map(function ($item) {
return [
'name' => $item->product ? $item->product->name : 'Unknown Product',
'code' => $item->product ? $item->product->code : '',
'value' => (int)$item->total_value,
];
});
// 熱銷數量排行 (Top Selling by Quantity)
$topSellingByQuantity = \App\Modules\Sales\Models\SalesImportItem::with('product')
->whereMonth('transaction_at', now()->month)
->whereYear('transaction_at', now()->year)
->select('product_code', 'product_id', \Illuminate\Support\Facades\DB::raw('SUM(quantity) as total_quantity'))
->groupBy('product_code', 'product_id')
->orderByDesc('total_quantity')
->limit(5)
->get()
->map(function ($item) {
return [
'name' => $item->product ? $item->product->name : $item->product_code,
'code' => $item->product_code,
'value' => (int)$item->total_quantity,
];
});
// 即將過期商品 (Expiring Soon)
$expiringSoon = \App\Modules\Inventory\Models\Inventory::with('product')
->where('quantity', '>', 0)
->whereNotNull('expiry_date')
->where('expiry_date', '>=', now()) // 只顯示未過期但即將過期的
->orderBy('expiry_date', 'asc')
->limit(5)
->get()
->map(function ($item) {
return [
'name' => $item->product ? $item->product->name : 'Unknown Product',
'batch_number' => $item->batch_number,
'expiry_date' => $item->expiry_date->format('Y-m-d'),
'quantity' => (int)$item->quantity,
];
});
return Inertia::render('Dashboard', [
'stats' => [
@@ -39,8 +135,18 @@ class DashboardController extends Controller
'lowStockCount' => $invStats['lowStockCount'],
'negativeCount' => $invStats['negativeCount'] ?? 0,
'expiringCount' => $invStats['expiringCount'] ?? 0,
'totalInventoryValue' => $invStats['totalInventoryValue'] ?? 0,
'thisMonthRevenue' => $thisMonthRevenue,
'pendingOrdersCount' => $procStats['pendingOrdersCount'] ?? 0,
'pendingTransferCount' => $invStats['pendingTransferCount'] ?? 0,
'pendingProductionCount' => $pendingProductionCount,
'todoCount' => ($procStats['pendingOrdersCount'] ?? 0) + ($invStats['pendingTransferCount'] ?? 0) + $pendingProductionCount,
'salesTrend' => $salesTrend,
'topSellingProducts' => $topSellingProducts,
'topInventoryValue' => $topInventoryValue,
'topSellingByQuantity' => $topSellingByQuantity,
'expiringSoon' => $expiringSoon,
],
'abnormalItems' => $invStats['abnormalItems'] ?? [],
]);
}
}

View File

@@ -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,26 +104,37 @@ class TransferOrderController extends Controller
public function show(InventoryTransferOrder $order)
{
$order->load(['items.product.baseUnit', 'fromWarehouse', 'toWarehouse', 'createdBy', 'postedBy', 'storeRequisition']);
$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)
@@ -140,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([
@@ -171,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) {

View File

@@ -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);

View File

@@ -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()
@@ -172,4 +179,19 @@ class InventoryTransferOrder extends Model
{
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');
}
}

View File

@@ -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
{

View File

@@ -6,6 +6,7 @@ use App\Modules\Inventory\Contracts\InventoryServiceInterface;
use App\Modules\Inventory\Models\Inventory;
use App\Modules\Inventory\Models\Warehouse;
use App\Modules\Inventory\Models\Product;
use App\Modules\Inventory\Models\InventoryTransferOrder;
use Illuminate\Support\Facades\DB;
class InventoryService implements InventoryServiceInterface
@@ -584,6 +585,8 @@ class InventoryService implements InventoryServiceInterface
'negativeCount' => $negativeCount,
'expiringCount' => $expiringCount,
'totalInventoryQuantity' => Inventory::sum('quantity'),
'totalInventoryValue' => Inventory::sum('total_value'),
'pendingTransferCount' => InventoryTransferOrder::whereIn('status', ['draft', 'dispatched'])->count(), // 新增:待處理調撥單
'abnormalItems' => $abnormalItems,
];
}

View File

@@ -127,12 +127,17 @@ class StoreRequisitionService
}
}
// 查詢供貨倉庫是否有預設在途倉
$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,
);
// 將核准的明細寫入調撥單

View File

@@ -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') {

View File

@@ -26,7 +26,7 @@ class ProcurementService implements ProcurementServiceInterface
return [
'vendorsCount' => \App\Modules\Procurement\Models\Vendor::count(),
'purchaseOrdersCount' => PurchaseOrder::count(),
'pendingOrdersCount' => PurchaseOrder::where('status', 'pending')->count(),
'pendingOrdersCount' => PurchaseOrder::whereIn('status', ['approved', 'partial'])->count(), // 改為真正待進貨的狀態
];
}

View File

@@ -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',
]);
});
}
};

View File

@@ -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');
});
}
};

View File

@@ -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');
});
}
};

View File

@@ -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' => '檢視',
@@ -166,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',
@@ -190,7 +192,7 @@ 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',

400
package-lock.json generated
View File

@@ -35,6 +35,7 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hot-toast": "^2.6.0",
"recharts": "^3.7.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0"
},
@@ -2047,6 +2048,42 @@
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT"
},
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.1.4",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.53",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz",
@@ -2339,6 +2376,18 @@
"win32"
]
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@tailwindcss/node": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz",
@@ -2652,6 +2701,69 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -2706,6 +2818,12 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz",
@@ -3025,6 +3143,127 @@
"license": "MIT",
"peer": true
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
@@ -3052,6 +3291,12 @@
}
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -3163,6 +3408,16 @@
"node": ">= 0.4"
}
},
"node_modules/es-toolkit": {
"version": "1.44.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz",
"integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/esbuild": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
@@ -3213,6 +3468,12 @@
"node": ">=6"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -3431,6 +3692,25 @@
"node": ">= 0.4"
}
},
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
@@ -4003,6 +4283,37 @@
"react-dom": ">=16"
}
},
"node_modules/react-is": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz",
"integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==",
"license": "MIT",
"peer": true
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-refresh": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
@@ -4081,6 +4392,52 @@
}
}
},
"node_modules/recharts": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz",
"integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "1.x.x || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT",
"peer": true
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -4091,6 +4448,12 @@
"node": ">=0.10.0"
}
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/rollup": {
"version": "4.54.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz",
@@ -4339,6 +4702,12 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -4465,6 +4834,37 @@
}
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",

View File

@@ -49,6 +49,7 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hot-toast": "^2.6.0",
"recharts": "^3.7.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0"
}

View File

@@ -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>
);
}

View File

@@ -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 "正常":
return (
<Badge className="bg-green-100 text-green-700 border-green-300">
<CheckCircle className="mr-1 h-3 w-3" />
</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">
<AlertTriangle className="mr-1 h-3 w-3" />
</Badge>
);
default:
return null;
if (status === '正常') {
return (
<StatusBadge variant="success">
</StatusBadge>
);
}
if (status === '接近') {
return (
<StatusBadge variant="warning">
</StatusBadge>
);
}
if (status === '低於') {
return (
<StatusBadge variant="destructive">
</StatusBadge>
);
}
return null;
};
return (
@@ -108,12 +105,12 @@ export default function InventoryTable({
(sum, item) => sum + item.quantity,
0
);
// 計算安全庫存狀態
const status = group.safetySetting
? getSafetyStockStatus(totalQuantity, group.safetySetting.safetyStock)
: null;
const isLowStock = status === "低於";
const isExpanded = expandedProducts.has(group.productId);
const hasInventory = group.items.length > 0;
@@ -127,10 +124,9 @@ export default function InventoryTable({
<div className="border rounded-lg overflow-hidden">
{/* 商品標題 - 可點擊折疊 */}
<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"
}`}
<div
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">
<div className="flex items-center gap-3">
@@ -164,9 +160,9 @@ export default function InventoryTable({
</>
)}
{!group.safetySetting && (
<Badge variant="outline" className="text-gray-500">
<StatusBadge variant="neutral">
</Badge>
</StatusBadge>
)}
</div>
</div>

View File

@@ -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">

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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 "正常":
return (
<Badge className="bg-green-100 text-green-700 border-green-300">
<CheckCircle className="mr-1 h-3 w-3" />
</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>
);
if (status === '正常') {
return (
<StatusBadge variant="success">
</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

View File

@@ -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 "正常":
return (
<Badge className="bg-green-100 text-green-700 border-green-300">
<CheckCircle 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="success">
</StatusBadge>
);
}
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

View File

@@ -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">

View File

@@ -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 "正常":
return (
<Badge className="bg-green-100 text-green-700 border-green-300 hover:bg-green-100">
<CheckCircle className="mr-1 h-3 w-3" />
</Badge>
);
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 === '正常') {
return (
<StatusBadge variant="success">
</StatusBadge>
);
}
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 || '個'}

View File

@@ -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>

View File

@@ -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位置 */}

View 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>
);
}

View File

@@ -1,34 +1,30 @@
import { Head, Link } from "@inertiajs/react";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import {
Package,
AlertTriangle,
MinusCircle,
Clock,
ArrowRight,
LayoutDashboard,
TrendingUp,
DollarSign,
ClipboardCheck,
Trophy,
Package,
} from "lucide-react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/Components/ui/table";
import { Badge } from "@/Components/ui/badge";
import { Button } from "@/Components/ui/button";
interface AbnormalItem {
id: number;
product_code: string;
product_name: string;
warehouse_name: string;
quantity: number;
safety_stock: number | null;
expiry_date: string | null;
statuses: string[];
}
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip as RechartsTooltip,
ResponsiveContainer,
} from "recharts";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/Components/ui/tooltip";
interface Props {
stats: {
@@ -36,45 +32,71 @@ interface Props {
lowStockCount: number;
negativeCount: number;
expiringCount: number;
totalInventoryValue: number;
thisMonthRevenue: number;
pendingOrdersCount: number;
pendingTransferCount: number;
pendingProductionCount: number;
todoCount: number;
salesTrend: { date: string; amount: number }[];
topSellingProducts: { name: string; amount: number }[];
topInventoryValue: { name: string; code: string; value: number }[];
topSellingByQuantity: { name: string; code: string; value: number }[];
expiringSoon: { name: string; batch_number: string; expiry_date: string; quantity: number }[];
};
abnormalItems: AbnormalItem[];
}
// 狀態 Badge 映射
const statusConfig: Record<string, { label: string; className: string }> = {
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",
},
};
export default function Dashboard({ stats, abnormalItems }: Props) {
const cards = [
export default function Dashboard({ stats }: Props) {
const mainCards = [
{
label: "庫存明細數",
value: stats.totalItems,
icon: <Package className="h-6 w-6" />,
color: "text-primary-main",
bgColor: "bg-primary-lightest",
borderColor: "border-primary-light",
href: "/inventory/stock-query",
label: "庫存總值",
value: `NT$ ${Math.round(stats.totalInventoryValue).toLocaleString()}`,
description: `品項總數: ${stats.totalItems}`,
icon: <TrendingUp className="h-5 w-5" />,
color: "text-blue-600",
bgColor: "bg-blue-50",
borderColor: "border-blue-100",
},
{
label: "本月銷售營收",
value: `NT$ ${Math.round(stats.thisMonthRevenue).toLocaleString()}`,
description: "基於銷售導入數據",
icon: <DollarSign className="h-5 w-5" />,
color: "text-emerald-600",
bgColor: "bg-emerald-50",
borderColor: "border-emerald-100",
},
{
label: "待辦任務",
value: stats.todoCount,
description: (
<div className="flex items-center gap-1 font-medium">
<Link href={route('purchase-orders.index')} className="text-purple-600 hover:text-purple-800 hover:underline transition-colors">
: {stats.pendingOrdersCount}
</Link>
<span className="mx-1 text-gray-400">|</span>
<Link href={route('production-orders.index')} className="text-purple-600 hover:text-purple-800 hover:underline transition-colors">
: {stats.pendingProductionCount}
</Link>
<span className="mx-1 text-gray-400">|</span>
<Link href={route('inventory.transfer.index')} className="text-purple-600 hover:text-purple-800 hover:underline transition-colors">
調: {stats.pendingTransferCount}
</Link>
</div>
),
icon: <ClipboardCheck className="h-5 w-5" />,
color: "text-purple-600",
bgColor: "bg-purple-50",
borderColor: "border-purple-100",
alert: stats.todoCount > 0,
},
];
const alertCards = [
{
label: "低庫存",
value: stats.lowStockCount,
icon: <AlertTriangle className="h-6 w-6" />,
icon: <AlertTriangle className="h-4 w-4" />,
color: "text-amber-600",
bgColor: "bg-amber-50",
borderColor: "border-amber-200",
@@ -84,7 +106,7 @@ export default function Dashboard({ stats, abnormalItems }: Props) {
{
label: "負庫存",
value: stats.negativeCount,
icon: <MinusCircle className="h-6 w-6" />,
icon: <MinusCircle className="h-4 w-4" />,
color: "text-red-600",
bgColor: "bg-red-50",
borderColor: "border-red-200",
@@ -94,7 +116,7 @@ export default function Dashboard({ stats, abnormalItems }: Props) {
{
label: "即將過期",
value: stats.expiringCount,
icon: <Clock className="h-6 w-6" />,
icon: <Clock className="h-4 w-4" />,
color: "text-yellow-600",
bgColor: "bg-yellow-50",
borderColor: "border-yellow-200",
@@ -105,155 +127,214 @@ export default function Dashboard({ stats, abnormalItems }: Props) {
return (
<AuthenticatedLayout
breadcrumbs={[
{
label: "儀表板",
href: "/",
isPage: true,
},
]}
breadcrumbs={[{ label: "儀表板", href: "/", isPage: true }]}
>
<Head title="儀表板" />
<div className="container mx-auto p-6 max-w-7xl">
{/* 頁面標題 */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<LayoutDashboard className="h-6 w-6 text-primary-main" />
</h1>
<p className="text-gray-500 mt-1">
</p>
<div className="container mx-auto p-6 max-w-7xl space-y-8">
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<LayoutDashboard className="h-6 w-6 text-primary-main" />
</h1>
<p className="text-gray-500 mt-1"></p>
</div>
<div className="flex gap-2">
{alertCards.map((card) => (
<Link key={card.label} href={card.href} className="flex-1 md:flex-none">
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-lg border ${card.borderColor} ${card.bgColor} transition-colors hover:shadow-sm`}>
<div className={card.color}>{card.icon}</div>
<span className="text-xs font-medium text-gray-700">{card.label}</span>
<span className={`text-sm font-bold ${card.color}`}>{card.value}</span>
</div>
</Link>
))}
</div>
</div>
{/* 統計卡片 */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{cards.map((card) => (
<Link key={card.label} href={card.href}>
<div
className={`relative rounded-xl border ${card.borderColor} ${card.bgColor} p-5 transition-all hover:shadow-md hover:-translate-y-0.5 cursor-pointer`}
>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{mainCards.map((card) => (
<div key={card.label} className={`relative rounded-xl border ${card.borderColor} bg-white p-6 shadow-sm`}>
<div className="flex items-center justify-between mb-4">
<div className={`p-2 rounded-lg ${card.bgColor} ${card.color}`}>
{card.icon}
</div>
{card.alert && (
<span className="absolute top-3 right-3 h-2.5 w-2.5 rounded-full bg-red-500 animate-pulse" />
<span className="flex h-2 w-2 rounded-full bg-red-500 animate-pulse" />
)}
<div className="flex items-center gap-3 mb-3">
<div className={card.color}>
{card.icon}
</div>
<span className="text-sm font-medium text-grey-1">
{card.label}
</span>
</div>
<div
className={`text-3xl font-bold ${card.color}`}
>
{card.value.toLocaleString()}
</div>
</div>
</Link>
<div className="text-sm font-medium text-gray-500 mb-1">{card.label}</div>
<div className="text-2xl font-bold text-gray-900 mb-1">{card.value}</div>
<div className="text-xs text-gray-400">{card.description}</div>
</div>
))}
</div>
{/* 異常庫存清單 */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100">
<h2 className="text-lg font-semibold text-grey-0 flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-amber-500" />
</h2>
<Link href="/inventory/stock-query?status=abnormal">
<Button
variant="outline"
size="sm"
className="button-outlined-primary gap-1"
>
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
{/* 銷售趨勢 & 熱銷排行 */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* 銷售趨勢 - Area Chart */}
<div className="lg:col-span-2 bg-white rounded-xl border border-gray-200 shadow-sm p-6">
<div className="flex items-center gap-2 mb-6">
<TrendingUp className="h-5 w-5 text-emerald-500" />
<h2 className="text-lg font-semibold text-gray-800"> 30 </h2>
</div>
<div className="h-[300px] w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={stats.salesTrend} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="colorAmount" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.8} />
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
</linearGradient>
</defs>
<XAxis dataKey="date" />
<YAxis tickFormatter={(value) => `$${value / 1000}k`} />
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<RechartsTooltip formatter={(value) => `NT$ ${Number(value).toLocaleString()}`} />
<Area type="monotone" dataKey="amount" stroke="#10b981" fillOpacity={1} fill="url(#colorAmount)" />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="w-[50px] text-center">
#
</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right">
</TableHead>
<TableHead className="text-center">
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{abnormalItems.length === 0 ? (
<TableRow>
<TableCell
colSpan={6}
className="text-center py-8 text-gray-500"
>
🎉
</TableCell>
</TableRow>
) : (
abnormalItems.map((item, index) => (
<TableRow key={item.id}>
<TableCell className="text-gray-500 font-medium text-center">
{index + 1}
</TableCell>
<TableCell className="font-mono text-sm">
{item.product_code}
</TableCell>
<TableCell className="font-medium">
{item.product_name}
</TableCell>
<TableCell>
{item.warehouse_name}
</TableCell>
<TableCell
className={`text-right font-medium ${item.quantity < 0
? "text-red-600"
: ""
}`}
>
{item.quantity}
</TableCell>
<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
key={status}
variant="outline"
className={
config.className
}
>
{config.label}
</Badge>
);
}
)}
{/* 熱銷商品排行 (金額) - Bar Chart */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-6">
<div className="flex items-center gap-2 mb-6">
<Trophy className="h-5 w-5 text-indigo-500" />
<h2 className="text-lg font-semibold text-gray-800"> Top 5</h2>
</div>
<div className="h-[300px] w-full flex flex-col justify-center space-y-6">
{stats.topSellingProducts.length > 0 ? (
(() => {
const maxAmount = Math.max(...stats.topSellingProducts.map(p => p.amount));
return stats.topSellingProducts.map((product, index) => (
<div key={index} className="space-y-1">
<div className="flex justify-between items-end">
<div className="min-w-0 flex-1 pr-4">
<Tooltip>
<TooltipTrigger asChild>
<span className="block text-sm font-medium text-gray-700 truncate cursor-help">
{product.name}
</span>
</TooltipTrigger>
<TooltipContent>
<p>{product.name}</p>
</TooltipContent>
</Tooltip>
</div>
<span className="text-sm font-bold text-indigo-600 shrink-0">
NT$ {product.amount.toLocaleString()}
</span>
</div>
</TableCell>
</TableRow>
))
<div className="w-full bg-gray-100 rounded-full h-2 overflow-hidden">
<div
className="bg-indigo-500 h-2 rounded-full transition-all duration-500"
style={{ width: `${(product.amount / maxAmount) * 100}%` }}
/>
</div>
</div>
));
})()
) : (
<div className="h-full flex items-center justify-center text-gray-400 text-sm"></div>
)}
</TableBody>
</Table>
</div>
</div>
</div>
{/* 其他排行資訊 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* 庫存積壓排行 */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<div className="p-4 border-b border-gray-100 bg-gray-50 flex items-center gap-2">
<DollarSign className="h-4 w-4 text-blue-500" />
<h3 className="font-semibold text-gray-700"> Top 5</h3>
</div>
<div className="divide-y divide-gray-100">
{stats.topInventoryValue.length > 0 ? stats.topInventoryValue.map((item, idx) => (
<div key={idx} className="p-3 flex items-center justify-between hover:bg-gray-50 transition-colors">
<div className="min-w-0 flex-1 pr-2">
<Tooltip>
<TooltipTrigger asChild>
<div className="text-sm font-medium text-gray-900 truncate cursor-help">{item.name}</div>
</TooltipTrigger>
<TooltipContent>
<p>{item.name}</p>
</TooltipContent>
</Tooltip>
<div className="text-xs text-gray-500 truncate">{item.code}</div>
</div>
<div className="text-right">
<div className="text-sm font-bold text-gray-700">NT$ {item.value.toLocaleString()}</div>
</div>
</div>
)) : (
<div className="p-8 text-center text-gray-400 text-sm"></div>
)}
</div>
</div>
{/* 熱銷數量排行 */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<div className="p-4 border-b border-gray-100 bg-gray-50 flex items-center gap-2">
<Package className="h-4 w-4 text-emerald-500" />
<h3 className="font-semibold text-gray-700"> Top 5</h3>
</div>
<div className="divide-y divide-gray-100">
{stats.topSellingByQuantity.length > 0 ? stats.topSellingByQuantity.map((item, idx) => (
<div key={idx} className="p-3 flex items-center justify-between hover:bg-gray-50 transition-colors">
<div className="min-w-0 flex-1 pr-2">
<Tooltip>
<TooltipTrigger asChild>
<div className="text-sm font-medium text-gray-900 truncate cursor-help">{item.name}</div>
</TooltipTrigger>
<TooltipContent>
<p>{item.name}</p>
</TooltipContent>
</Tooltip>
<div className="text-xs text-gray-500 truncate">{item.code}</div>
</div>
<div className="text-right">
<div className="text-sm font-bold text-gray-700">{item.value.toLocaleString()} <span className="text-xs font-normal text-gray-500"></span></div>
</div>
</div>
)) : (
<div className="p-8 text-center text-gray-400 text-sm"></div>
)}
</div>
</div>
{/* 即將過期商品 */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<div className="p-4 border-b border-gray-100 bg-gray-50 flex items-center gap-2">
<Clock className="h-4 w-4 text-red-500" />
<h3 className="font-semibold text-gray-700"> Top 5</h3>
</div>
<div className="divide-y divide-gray-100">
{stats.expiringSoon.length > 0 ? stats.expiringSoon.map((item, idx) => (
<div key={idx} className="p-3 flex items-center justify-between hover:bg-gray-50 transition-colors">
<div className="min-w-0 flex-1 pr-2">
<Tooltip>
<TooltipTrigger asChild>
<div className="text-sm font-medium text-gray-900 truncate cursor-help">{item.name}</div>
</TooltipTrigger>
<TooltipContent>
<p>{item.name}</p>
</TooltipContent>
</Tooltip>
<div className="text-xs text-gray-500 truncate">: {item.batch_number}</div>
</div>
<div className="text-right">
<div className="text-sm font-bold text-red-600">{item.expiry_date}</div>
<div className="text-xs text-gray-500">: {item.quantity}</div>
</div>
</div>
)) : (
<div className="p-8 text-center text-green-500 text-sm"></div>
)}
</div>
</div>
</div>
</div>
</AuthenticatedLayout>

View File

@@ -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()}>
{(() => {

View File

@@ -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">
@@ -604,6 +604,6 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) {
</div>
</div>
</AuthenticatedLayout>
</AuthenticatedLayout >
);
}

View File

@@ -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 */}

View File

@@ -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">

View File

@@ -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}

View File

@@ -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' :

View File

@@ -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
key={status}
variant="outline"
className={
config.className
}
>
{config.label}
</Badge>
);
}
(status) => (
<StatusBadge
key={status}
variant={getStatusVariant(status)}
>
{getStatusLabel(status)}
</StatusBadge>
)
)}
</div>
</TableCell>

View File

@@ -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()}>
{(() => {

View File

@@ -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,35 +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: order.requisition ? '門市叫貨申請' : '庫存調撥',
href: order.requisition ? route('store-requisitions.index') : route('inventory.transfer.index')
},
order.requisition && {
label: `叫貨單: ${order.requisition.doc_no}`,
href: route('store-requisitions.show', [order.requisition.id])
},
{ label: `調撥單: ${order.doc_no}`, href: route('inventory.transfer.show', [order.id]), isPage: true },
].filter(Boolean) as any}
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={order.requisition ? route('store-requisitions.show', [order.requisition.id]) : 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" />
{order.requisition ? `返回叫貨單: ${order.requisition.doc_no}` : '返回調撥單列表'}
{backNav.label}
</Button>
</Link>
@@ -227,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}
@@ -247,6 +334,7 @@ export default function Show({ order }: any) {
</Button>
{/* 草稿狀態:儲存 + 出貨/過帳 + 刪除 */}
{!isReadOnly && (
<div className="flex items-center gap-2">
<Can permission="inventory_transfer.delete">
@@ -291,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>
@@ -504,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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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()}

View File

@@ -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 ? (

View File

@@ -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">

View File

@@ -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">

View File

@@ -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>;
}
};

View File

@@ -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>

View File

@@ -13,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 {
AlertDialog,
AlertDialogAction,
@@ -28,6 +27,7 @@ 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,
@@ -39,34 +39,23 @@ import {
} from "lucide-react";
import { formatDate } from "@/lib/date";
const statusMap: Record<string, { label: string; variant: string }> = {
draft: { label: "草稿", variant: "secondary" },
pending: { label: "待審核", variant: "warning" },
approved: { label: "已核准", variant: "success" },
rejected: { label: "已駁回", variant: "destructive" },
completed: { label: "已完成", variant: "default" },
cancelled: { label: "已取消", variant: "outline" },
};
function getStatusBadge(status: string) {
const config = statusMap[status];
if (!config) return <Badge variant="outline">{status}</Badge>;
const variantClass: Record<string, string> = {
secondary: "",
warning: "bg-amber-500 hover:bg-amber-600 text-white",
success: "bg-green-500 hover:bg-green-600 text-white",
destructive: "",
default: "bg-blue-500 hover:bg-blue-600 text-white",
outline: "",
};
const variant = ["secondary", "destructive", "outline"].includes(config.variant)
? (config.variant as "secondary" | "destructive" | "outline")
: "default";
return (
<Badge variant={variant} className={variantClass[config.variant] || ""}>
{config.label}
</Badge>
);
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({

View File

@@ -6,7 +6,7 @@ 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 { Badge } from "@/Components/ui/badge";
import { StatusBadge } from "@/Components/shared/StatusBadge";
import {
Table,
TableBody,
@@ -47,36 +47,23 @@ import {
} from "lucide-react";
import { formatDate } from "@/lib/date";
const statusMap: Record<string, { label: string; variant: string }> = {
draft: { label: "草稿", variant: "secondary" },
pending: { label: "待審核", variant: "warning" },
approved: { label: "已核准", variant: "success" },
rejected: { label: "已駁回", variant: "destructive" },
completed: { label: "已完成", variant: "default" },
cancelled: { label: "已取消", variant: "outline" },
};
function getStatusBadge(status: string) {
const config = statusMap[status];
if (!config) return <Badge variant="outline">{status}</Badge>;
const variantClass: Record<string, string> = {
secondary: "",
warning: "bg-amber-500 hover:bg-amber-600 text-white",
success: "bg-green-500 hover:bg-green-600 text-white",
destructive: "",
default: "bg-blue-500 hover:bg-blue-600 text-white",
outline: "",
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 variant = ["secondary", "destructive", "outline"].includes(config.variant)
? (config.variant as "secondary" | "destructive" | "outline")
: "default";
const config = statusMap[status];
if (!config) return <StatusBadge variant="neutral">{status}</StatusBadge>;
return (
<Badge variant={variant} className={variantClass[config.variant] || ""}>
<StatusBadge variant={config.variant}>
{config.label}
</Badge>
</StatusBadge>
);
}
@@ -362,7 +349,7 @@ export default function Show({ requisition, warehouses }: Props) {
<span className="text-sm text-gray-500">調</span>
<p className="mt-1">
<Link
href={route("inventory.transfer.show", [requisition.transfer_order_id])}
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"
>
調

View File

@@ -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}
/>
{/* 調撥單建立對話框 */}

View File

@@ -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" },
};

View File

@@ -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 {