Compare commits
7 Commits
6980eac1a4
...
220478641d
| Author | SHA1 | Date | |
|---|---|---|---|
| 220478641d | |||
| 593ce94734 | |||
| 8b950f6529 | |||
| e098e40fb8 | |||
| 83d26de6f9 | |||
| 38642cc58b | |||
| a6393e03d8 |
@@ -32,20 +32,16 @@ class DashboardController extends Controller
|
||||
}
|
||||
|
||||
$invStats = $this->inventoryService->getDashboardStats();
|
||||
$procStats = $this->procurementService->getDashboardStats();
|
||||
|
||||
$stats = [
|
||||
'productsCount' => $invStats['productsCount'],
|
||||
'vendorsCount' => $procStats['vendorsCount'],
|
||||
'purchaseOrdersCount' => $procStats['purchaseOrdersCount'],
|
||||
'warehousesCount' => $invStats['warehousesCount'],
|
||||
'totalInventoryValue' => $invStats['totalInventoryQuantity'], // 原本前端命名是 totalInventoryValue 但實作是 Quantity,暫且保留欄位名以不破壞前端
|
||||
'pendingOrdersCount' => $procStats['pendingOrdersCount'],
|
||||
'lowStockCount' => $invStats['lowStockCount'],
|
||||
];
|
||||
|
||||
return Inertia::render('Dashboard', [
|
||||
'stats' => $stats,
|
||||
'stats' => [
|
||||
'totalItems' => $invStats['productsCount'],
|
||||
'lowStockCount' => $invStats['lowStockCount'],
|
||||
'negativeCount' => $invStats['negativeCount'] ?? 0,
|
||||
'expiringCount' => $invStats['expiringCount'] ?? 0,
|
||||
],
|
||||
'abnormalItems' => $invStats['abnormalItems'] ?? [],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -184,6 +184,7 @@ class RoleController extends Controller
|
||||
'inventory_count' => '庫存盤點管理',
|
||||
'inventory_adjust' => '庫存盤調管理',
|
||||
'inventory_transfer' => '庫存調撥管理',
|
||||
'inventory_report' => '庫存報表',
|
||||
'vendors' => '廠商資料管理',
|
||||
'purchase_orders' => '採購單管理',
|
||||
'goods_receipts' => '進貨單管理',
|
||||
|
||||
@@ -116,6 +116,15 @@ interface InventoryServiceInterface
|
||||
*/
|
||||
public function findInventoryByBatch(int $warehouseId, int $productId, ?string $batchNumber);
|
||||
|
||||
/**
|
||||
* 取得即時庫存查詢資料(含統計卡片 + 分頁明細)。
|
||||
*
|
||||
* @param array $filters 篩選條件
|
||||
* @param int $perPage 每頁筆數
|
||||
* @return array
|
||||
*/
|
||||
public function getStockQueryData(array $filters = [], int $perPage = 10): array;
|
||||
|
||||
/**
|
||||
* Get statistics for the dashboard.
|
||||
*
|
||||
|
||||
@@ -106,8 +106,8 @@ class InventoryController extends Controller
|
||||
'batchNumber' => $inv->batch_number ?? 'BATCH-' . $inv->id,
|
||||
'location' => $inv->location,
|
||||
'expiryDate' => $inv->expiry_date ? $inv->expiry_date->format('Y-m-d') : null,
|
||||
'lastInboundDate' => $inv->lastIncomingTransaction ? ($inv->lastIncomingTransaction->actual_time ? $inv->lastIncomingTransaction->actual_time->format('Y-m-d') : $inv->lastIncomingTransaction->created_at->format('Y-m-d')) : null,
|
||||
'lastOutboundDate' => $inv->lastOutgoingTransaction ? ($inv->lastOutgoingTransaction->actual_time ? $inv->lastOutgoingTransaction->actual_time->format('Y-m-d') : $inv->lastOutgoingTransaction->created_at->format('Y-m-d')) : null,
|
||||
'lastInboundDate' => $inv->lastIncomingTransaction ? ($inv->lastIncomingTransaction->actual_time ? substr($inv->lastIncomingTransaction->actual_time, 0, 10) : $inv->lastIncomingTransaction->created_at->format('Y-m-d')) : null,
|
||||
'lastOutboundDate' => $inv->lastOutgoingTransaction ? ($inv->lastOutgoingTransaction->actual_time ? substr($inv->lastOutgoingTransaction->actual_time, 0, 10) : $inv->lastOutgoingTransaction->created_at->format('Y-m-d')) : null,
|
||||
];
|
||||
})->values(),
|
||||
];
|
||||
@@ -295,7 +295,8 @@ class InventoryController extends Controller
|
||||
'originCountry' => $inventory->origin_country,
|
||||
'expiryDate' => $inventory->expiry_date ? $inventory->expiry_date->format('Y-m-d') : null,
|
||||
'quantity' => (float) $inventory->quantity,
|
||||
'unitCost' => (float) $inventory->unit_cost, // 新增
|
||||
'unitCost' => (float) $inventory->unit_cost,
|
||||
'location' => $inventory->location,
|
||||
];
|
||||
});
|
||||
|
||||
@@ -359,7 +360,7 @@ class InventoryController extends Controller
|
||||
'balanceAfter' => (float) $tx->balance_after,
|
||||
'reason' => $tx->reason,
|
||||
'userName' => $user ? $user->name : '系統', // 手動對應
|
||||
'actualTime' => $tx->actual_time ? $tx->actual_time->format('Y-m-d H:i') : $tx->created_at->format('Y-m-d H:i'),
|
||||
'actualTime' => $tx->actual_time ? substr($tx->actual_time, 0, 16) : $tx->created_at->format('Y-m-d H:i'),
|
||||
];
|
||||
});
|
||||
|
||||
@@ -553,7 +554,7 @@ class InventoryController extends Controller
|
||||
'balanceAfter' => (float) $balanceAfter, // 顯示該商品在倉庫的累計結餘
|
||||
'reason' => $tx->reason,
|
||||
'userName' => $user ? $user->name : '系統',
|
||||
'actualTime' => $tx->actual_time ? $tx->actual_time->format('Y-m-d H:i') : $tx->created_at->format('Y-m-d H:i'),
|
||||
'actualTime' => $tx->actual_time ? substr($tx->actual_time, 0, 16) : $tx->created_at->format('Y-m-d H:i'),
|
||||
'batchNumber' => $tx->inventory?->batch_number ?? '-', // 補上批號資訊
|
||||
'slot' => $tx->inventory?->location, // 加入貨道資訊
|
||||
];
|
||||
@@ -596,7 +597,7 @@ class InventoryController extends Controller
|
||||
'balanceAfter' => (float) $tx->balance_after,
|
||||
'reason' => $tx->reason,
|
||||
'userName' => $user ? $user->name : '系統', // 手動對應
|
||||
'actualTime' => $tx->actual_time ? $tx->actual_time->format('Y-m-d H:i') : $tx->created_at->format('Y-m-d H:i'),
|
||||
'actualTime' => $tx->actual_time ? substr($tx->actual_time, 0, 16) : $tx->created_at->format('Y-m-d H:i'),
|
||||
'slot' => $inventory->location, // 加入貨道資訊
|
||||
];
|
||||
});
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Inventory\Models\Category;
|
||||
use App\Modules\Inventory\Models\Warehouse;
|
||||
use App\Modules\Inventory\Services\InventoryReportService;
|
||||
use App\Modules\Inventory\Exports\InventoryReportExport;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
|
||||
|
||||
class InventoryReportController extends Controller
|
||||
{
|
||||
protected $reportService;
|
||||
|
||||
public function __construct(InventoryReportService $reportService)
|
||||
{
|
||||
$this->reportService = $reportService;
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$filters = $request->only([
|
||||
'date_from', 'date_to', 'warehouse_id', 'category_id', 'search', 'per_page',
|
||||
'sort_by', 'sort_order'
|
||||
]);
|
||||
|
||||
if (!isset($filters['date_from'])) {
|
||||
$filters['date_from'] = date('Y-m-d');
|
||||
}
|
||||
if (!isset($filters['date_to'])) {
|
||||
$filters['date_to'] = date('Y-m-d');
|
||||
}
|
||||
|
||||
$reportData = $this->reportService->getReportData($filters, $request->input('per_page', 10));
|
||||
$summary = $this->reportService->getSummary($filters);
|
||||
|
||||
return Inertia::render('Inventory/Report/Index', [
|
||||
'reportData' => $reportData,
|
||||
'summary' => $summary,
|
||||
'warehouses' => Warehouse::select('id', 'name')->get(),
|
||||
'categories' => Category::select('id', 'name')->get(),
|
||||
'filters' => $filters,
|
||||
]);
|
||||
}
|
||||
|
||||
public function export(Request $request)
|
||||
{
|
||||
$filters = $request->only([
|
||||
'period', 'date_from', 'date_to', 'warehouse_id', 'category_id', 'search'
|
||||
]);
|
||||
|
||||
return Excel::download(new InventoryReportExport($this->reportService, $filters), 'inventory_report_' . date('YmdHis') . '.xlsx');
|
||||
}
|
||||
|
||||
public function show(Request $request, $productId)
|
||||
{
|
||||
// 明細頁面自身使用的篩選條件
|
||||
$filters = $request->only([
|
||||
'date_from', 'date_to', 'warehouse_id'
|
||||
]);
|
||||
|
||||
// 報表頁面的完整篩選狀態(用於返回時恢復)
|
||||
$reportFilters = $request->only([
|
||||
'date_from', 'date_to', 'warehouse_id',
|
||||
'category_id', 'search', 'per_page'
|
||||
]);
|
||||
// 將傳入的 report_page 轉回 page 以便 Link 元件正確生成回報表頁的連結
|
||||
if ($request->has('report_page')) {
|
||||
$reportFilters['page'] = $request->input('report_page');
|
||||
}
|
||||
|
||||
// 取得商品資訊 (用於顯示標題,含基本單位)
|
||||
$product = \App\Modules\Inventory\Models\Product::with('baseUnit')->findOrFail($productId);
|
||||
|
||||
$transactions = $this->reportService->getProductDetails($productId, $filters, 20);
|
||||
|
||||
return Inertia::render('Inventory/Report/Show', [
|
||||
'product' => [
|
||||
'id' => $product->id,
|
||||
'code' => $product->code,
|
||||
'name' => $product->name,
|
||||
'unit_name' => $product->baseUnit?->name ?? '-',
|
||||
],
|
||||
'transactions' => $transactions,
|
||||
'filters' => $filters,
|
||||
'reportFilters' => $reportFilters,
|
||||
'warehouses' => Warehouse::select('id', 'name')->get(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
59
app/Modules/Inventory/Controllers/StockQueryController.php
Normal file
59
app/Modules/Inventory/Controllers/StockQueryController.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||
use App\Modules\Inventory\Models\Category;
|
||||
use App\Modules\Inventory\Models\Warehouse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class StockQueryController extends Controller
|
||||
{
|
||||
protected InventoryServiceInterface $inventoryService;
|
||||
|
||||
public function __construct(InventoryServiceInterface $inventoryService)
|
||||
{
|
||||
$this->inventoryService = $inventoryService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 即時庫存查詢頁面
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$filters = $request->only(['warehouse_id', 'category_id', 'search', 'status', 'sort_by', 'sort_order', 'per_page']);
|
||||
$perPage = (int) ($filters['per_page'] ?? 10);
|
||||
|
||||
$result = $this->inventoryService->getStockQueryData($filters, $perPage);
|
||||
|
||||
return Inertia::render('Inventory/StockQuery/Index', [
|
||||
'filters' => $filters,
|
||||
'summary' => $result['summary'],
|
||||
'inventories' => [
|
||||
'data' => $result['data'],
|
||||
'total' => $result['pagination']['total'],
|
||||
'per_page' => $result['pagination']['per_page'],
|
||||
'current_page' => $result['pagination']['current_page'],
|
||||
'last_page' => $result['pagination']['last_page'],
|
||||
'links' => $result['pagination']['links'],
|
||||
],
|
||||
'warehouses' => Warehouse::select('id', 'name')->orderBy('name')->get(),
|
||||
'categories' => Category::select('id', 'name')->orderBy('name')->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Excel 匯出
|
||||
*/
|
||||
public function export(Request $request)
|
||||
{
|
||||
$filters = $request->only(['warehouse_id', 'category_id', 'search', 'status']);
|
||||
|
||||
return \Maatwebsite\Excel\Facades\Excel::download(
|
||||
new \App\Modules\Inventory\Exports\StockQueryExport($filters),
|
||||
'即時庫存查詢_' . now()->format('Ymd_His') . '.xlsx'
|
||||
);
|
||||
}
|
||||
}
|
||||
65
app/Modules/Inventory/Exports/InventoryReportExport.php
Normal file
65
app/Modules/Inventory/Exports/InventoryReportExport.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Exports;
|
||||
|
||||
use App\Modules\Inventory\Services\InventoryReportService;
|
||||
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
use Maatwebsite\Excel\Concerns\WithMapping;
|
||||
use Maatwebsite\Excel\Concerns\ShouldAutoSize;
|
||||
use Maatwebsite\Excel\Concerns\WithStyles;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
|
||||
class InventoryReportExport implements FromCollection, WithHeadings, WithMapping, ShouldAutoSize, WithStyles
|
||||
{
|
||||
protected $service;
|
||||
protected $filters;
|
||||
|
||||
public function __construct(InventoryReportService $service, array $filters)
|
||||
{
|
||||
$this->service = $service;
|
||||
$this->filters = $filters;
|
||||
}
|
||||
|
||||
public function collection()
|
||||
{
|
||||
return $this->service->getReportData($this->filters, null); // perPage = null to get all
|
||||
}
|
||||
|
||||
public function headings(): array
|
||||
{
|
||||
return [
|
||||
'商品代碼',
|
||||
'商品名稱',
|
||||
'分類',
|
||||
'進貨量',
|
||||
'出貨量',
|
||||
'調撥入',
|
||||
'調撥出',
|
||||
'調整量',
|
||||
'淨變動',
|
||||
];
|
||||
}
|
||||
|
||||
public function map($row): array
|
||||
{
|
||||
return [
|
||||
$row->product_code,
|
||||
$row->product_name,
|
||||
$row->category_name ?? '-',
|
||||
$row->inbound_qty,
|
||||
$row->outbound_qty,
|
||||
$row->transfer_in_qty,
|
||||
$row->transfer_out_qty,
|
||||
$row->adjust_qty,
|
||||
$row->net_change,
|
||||
];
|
||||
}
|
||||
|
||||
public function styles(Worksheet $sheet)
|
||||
{
|
||||
return [
|
||||
1 => ['font' => ['bold' => true]],
|
||||
];
|
||||
}
|
||||
}
|
||||
167
app/Modules/Inventory/Exports/StockQueryExport.php
Normal file
167
app/Modules/Inventory/Exports/StockQueryExport.php
Normal file
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Exports;
|
||||
|
||||
use App\Modules\Inventory\Models\Inventory;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
use Maatwebsite\Excel\Concerns\WithMapping;
|
||||
use Maatwebsite\Excel\Concerns\ShouldAutoSize;
|
||||
use Maatwebsite\Excel\Concerns\WithStyles;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
|
||||
class StockQueryExport implements FromCollection, WithHeadings, WithMapping, ShouldAutoSize, WithStyles
|
||||
{
|
||||
protected array $filters;
|
||||
|
||||
public function __construct(array $filters = [])
|
||||
{
|
||||
$this->filters = $filters;
|
||||
}
|
||||
|
||||
public function collection()
|
||||
{
|
||||
$today = now()->toDateString();
|
||||
$expiryThreshold = now()->addDays(30)->toDateString();
|
||||
|
||||
$query = Inventory::query()
|
||||
->join('products', 'inventories.product_id', '=', 'products.id')
|
||||
->join('warehouses', 'inventories.warehouse_id', '=', 'warehouses.id')
|
||||
->leftJoin('categories', 'products.category_id', '=', 'categories.id')
|
||||
->leftJoin('warehouse_product_safety_stocks as ss', function ($join) {
|
||||
$join->on('inventories.warehouse_id', '=', 'ss.warehouse_id')
|
||||
->on('inventories.product_id', '=', 'ss.product_id');
|
||||
})
|
||||
->whereNull('inventories.deleted_at')
|
||||
->select([
|
||||
'inventories.id',
|
||||
'inventories.quantity',
|
||||
'inventories.batch_number',
|
||||
'inventories.expiry_date',
|
||||
'inventories.quality_status',
|
||||
'products.code as product_code',
|
||||
'products.name as product_name',
|
||||
'categories.name as category_name',
|
||||
'warehouses.name as warehouse_name',
|
||||
'ss.safety_stock',
|
||||
]);
|
||||
|
||||
// 篩選
|
||||
if (!empty($this->filters['warehouse_id'])) {
|
||||
$query->where('inventories.warehouse_id', $this->filters['warehouse_id']);
|
||||
}
|
||||
if (!empty($this->filters['category_id'])) {
|
||||
$query->where('products.category_id', $this->filters['category_id']);
|
||||
}
|
||||
if (!empty($this->filters['search'])) {
|
||||
$search = $this->filters['search'];
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('products.code', 'like', "%{$search}%")
|
||||
->orWhere('products.name', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
if (!empty($this->filters['status'])) {
|
||||
switch ($this->filters['status']) {
|
||||
case 'low_stock':
|
||||
$query->whereNotNull('ss.safety_stock')
|
||||
->whereRaw('inventories.quantity <= ss.safety_stock')
|
||||
->where('inventories.quantity', '>=', 0);
|
||||
break;
|
||||
case 'negative':
|
||||
$query->where('inventories.quantity', '<', 0);
|
||||
break;
|
||||
case 'expiring':
|
||||
$query->whereNotNull('inventories.expiry_date')
|
||||
->where('inventories.expiry_date', '>', $today)
|
||||
->where('inventories.expiry_date', '<=', $expiryThreshold);
|
||||
break;
|
||||
case 'expired':
|
||||
$query->whereNotNull('inventories.expiry_date')
|
||||
->where('inventories.expiry_date', '<=', $today);
|
||||
break;
|
||||
case 'abnormal':
|
||||
$query->where(function ($q) use ($today, $expiryThreshold) {
|
||||
$q->where('inventories.quantity', '<', 0)
|
||||
->orWhere(function ($q2) {
|
||||
$q2->whereNotNull('ss.safety_stock')
|
||||
->whereRaw('inventories.quantity <= ss.safety_stock');
|
||||
})
|
||||
->orWhere(function ($q2) use ($expiryThreshold) {
|
||||
$q2->whereNotNull('inventories.expiry_date')
|
||||
->where('inventories.expiry_date', '<=', $expiryThreshold);
|
||||
});
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $query->orderBy('products.code', 'asc')->get();
|
||||
}
|
||||
|
||||
public function headings(): array
|
||||
{
|
||||
return [
|
||||
'商品代碼',
|
||||
'商品名稱',
|
||||
'分類',
|
||||
'倉庫',
|
||||
'批號',
|
||||
'數量',
|
||||
'安全庫存',
|
||||
'到期日',
|
||||
'品質狀態',
|
||||
'狀態',
|
||||
];
|
||||
}
|
||||
|
||||
public function map($row): array
|
||||
{
|
||||
$today = now()->toDateString();
|
||||
$expiryThreshold = now()->addDays(30)->toDateString();
|
||||
|
||||
$statuses = [];
|
||||
if ($row->quantity < 0) {
|
||||
$statuses[] = '負庫存';
|
||||
}
|
||||
if ($row->safety_stock !== null && $row->quantity <= $row->safety_stock && $row->quantity >= 0) {
|
||||
$statuses[] = '低庫存';
|
||||
}
|
||||
if ($row->expiry_date) {
|
||||
if ($row->expiry_date <= $today) {
|
||||
$statuses[] = '已過期';
|
||||
} elseif ($row->expiry_date <= $expiryThreshold) {
|
||||
$statuses[] = '即將過期';
|
||||
}
|
||||
}
|
||||
if (empty($statuses)) {
|
||||
$statuses[] = '正常';
|
||||
}
|
||||
|
||||
$qualityLabels = [
|
||||
'normal' => '正常',
|
||||
'inspecting' => '檢驗中',
|
||||
'rejected' => '不合格',
|
||||
];
|
||||
|
||||
return [
|
||||
$row->product_code,
|
||||
$row->product_name,
|
||||
$row->category_name ?? '-',
|
||||
$row->warehouse_name,
|
||||
$row->batch_number ?? '-',
|
||||
$row->quantity,
|
||||
$row->safety_stock ?? '-',
|
||||
$row->expiry_date ?? '-',
|
||||
$qualityLabels[$row->quality_status] ?? $row->quality_status ?? '-',
|
||||
implode('、', $statuses),
|
||||
];
|
||||
}
|
||||
|
||||
public function styles(Worksheet $sheet): array
|
||||
{
|
||||
return [
|
||||
1 => ['font' => ['bold' => true]],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,9 @@ class InventoryTransaction extends Model
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'actual_time' => 'datetime',
|
||||
// actual_time 不做時區轉換,保留原始字串格式(台北時間)
|
||||
// 原因:資料庫儲存的是台北時間,但 MySQL 時區為 UTC
|
||||
// 若使用 datetime cast,Laravel 會誤當作 UTC 再轉回台北時間,造成偏移
|
||||
'unit_cost' => 'decimal:4',
|
||||
];
|
||||
|
||||
|
||||
@@ -11,8 +11,27 @@ use App\Modules\Inventory\Controllers\TransferOrderController;
|
||||
use App\Modules\Inventory\Controllers\CountDocController;
|
||||
use App\Modules\Inventory\Controllers\AdjustDocController;
|
||||
|
||||
use App\Modules\Inventory\Controllers\InventoryReportController;
|
||||
|
||||
use App\Modules\Inventory\Controllers\StockQueryController;
|
||||
|
||||
Route::middleware('auth')->group(function () {
|
||||
|
||||
// 即時庫存查詢
|
||||
Route::middleware('permission:inventory.view')->group(function () {
|
||||
Route::get('/inventory/stock-query', [StockQueryController::class, 'index'])->name('inventory.stock-query.index');
|
||||
Route::get('/inventory/stock-query/export', [StockQueryController::class, 'export'])->name('inventory.stock-query.export');
|
||||
});
|
||||
|
||||
// 庫存報表
|
||||
Route::middleware('permission:inventory_report.view')->group(function () {
|
||||
Route::get('/inventory/report', [InventoryReportController::class, 'index'])->name('inventory.report.index');
|
||||
Route::get('/inventory/report/export', [InventoryReportController::class, 'export'])
|
||||
->middleware('permission:inventory_report.export')
|
||||
->name('inventory.report.export');
|
||||
Route::get('/inventory/report/{product}', [InventoryReportController::class, 'show'])->name('inventory.report.show');
|
||||
});
|
||||
|
||||
// 類別管理 (用於商品對話框) - 需要商品權限
|
||||
Route::middleware('permission:products.view')->group(function () {
|
||||
Route::get('/categories', [CategoryController::class, 'index'])->name('categories.index');
|
||||
|
||||
248
app/Modules/Inventory/Services/InventoryReportService.php
Normal file
248
app/Modules/Inventory/Services/InventoryReportService.php
Normal file
@@ -0,0 +1,248 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Services;
|
||||
|
||||
use App\Modules\Inventory\Models\InventoryTransaction;
|
||||
use App\Modules\Inventory\Models\Product; // Use Inventory module's Product if available, or Core's? Usually Product is in Inventory/Models? No, let's check.
|
||||
// Checking Product model location... likely App\Modules\Product\Models\Product or App\Modules\Inventory\Models\Product.
|
||||
// From previous context: "products.create" permission suggests a Products module.
|
||||
// But stock query uses `products` table join.
|
||||
// Let's assume standard Laravel query builder or check existing models.
|
||||
// StockQueryController uses `InventoryService`.
|
||||
// I will use DB facade or InventoryTransaction model for aggregation.
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class InventoryReportService
|
||||
{
|
||||
/**
|
||||
* 取得庫存報表資料
|
||||
*
|
||||
* @param array $filters 篩選條件
|
||||
* @param int|null $perPage 每頁筆數
|
||||
* @return \Illuminate\Pagination\LengthAwarePaginator|\Illuminate\Support\Collection
|
||||
*/
|
||||
public function getReportData(array $filters, ?int $perPage = 10)
|
||||
{
|
||||
$dateFrom = $filters['date_from'] ?? null;
|
||||
$dateTo = $filters['date_to'] ?? null;
|
||||
$warehouseId = $filters['warehouse_id'] ?? null;
|
||||
$categoryId = $filters['category_id'] ?? null;
|
||||
$search = $filters['search'] ?? null;
|
||||
$sortBy = $filters['sort_by'] ?? 'product_code';
|
||||
$sortOrder = $filters['sort_order'] ?? 'asc';
|
||||
|
||||
// 若無任何篩選條件,直接回傳空資料
|
||||
if (!$dateFrom && !$dateTo && !$warehouseId && !$categoryId && !$search) {
|
||||
return new \Illuminate\Pagination\LengthAwarePaginator([], 0, $perPage ?: 10);
|
||||
}
|
||||
|
||||
// 定義時間欄位轉換 (UTC -> Asia/Taipei)
|
||||
// 日期欄位:Laravel 時區已設為 Asia/Taipei,直接使用 actual_time
|
||||
$timeColumn = "inventory_transactions.actual_time";
|
||||
|
||||
// 建立查詢
|
||||
// 我們需要針對每個 品項 在選定區間內 進行彙總
|
||||
// 來源:inventory_transactions -> inventory -> product
|
||||
|
||||
$query = InventoryTransaction::query()
|
||||
->select([
|
||||
'products.code as product_code',
|
||||
'products.name as product_name',
|
||||
'categories.name as category_name',
|
||||
'products.id as product_id',
|
||||
// 進貨量:type 為 入庫, 手動入庫 (排除 調撥入庫)
|
||||
DB::raw("SUM(CASE WHEN inventory_transactions.type IN ('入庫', '手動入庫') AND inventory_transactions.quantity > 0 THEN inventory_transactions.quantity ELSE 0 END) as inbound_qty"),
|
||||
// 出貨量:type 為 出庫 (排除 調撥出庫) (取絕對值)
|
||||
DB::raw("ABS(SUM(CASE WHEN inventory_transactions.type IN ('出庫') AND inventory_transactions.quantity < 0 THEN inventory_transactions.quantity ELSE 0 END)) as outbound_qty"),
|
||||
// 調撥入:type 為 調撥入庫
|
||||
DB::raw("SUM(CASE WHEN inventory_transactions.type = '調撥入庫' AND inventory_transactions.quantity > 0 THEN inventory_transactions.quantity ELSE 0 END) as transfer_in_qty"),
|
||||
// 調撥出:type 為 調撥出庫 (取絕對值)
|
||||
DB::raw("ABS(SUM(CASE WHEN inventory_transactions.type = '調撥出庫' AND inventory_transactions.quantity < 0 THEN inventory_transactions.quantity ELSE 0 END)) as transfer_out_qty"),
|
||||
// 調整量:type 為 庫存調整, 手動編輯
|
||||
DB::raw("SUM(CASE WHEN inventory_transactions.type IN ('庫存調整', '手動編輯') THEN inventory_transactions.quantity ELSE 0 END) as adjust_qty"),
|
||||
// 淨變動:總和 (包含所有類型:進貨、出貨、調整、調撥)
|
||||
DB::raw("SUM(inventory_transactions.quantity) as net_change"),
|
||||
])
|
||||
->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id')
|
||||
->join('products', 'inventories.product_id', '=', 'products.id')
|
||||
->leftJoin('categories', 'products.category_id', '=', 'categories.id');
|
||||
|
||||
// 日期篩選:資料庫儲存的是台北時間,直接用字串比對
|
||||
if ($dateFrom && $dateTo) {
|
||||
$query->whereRaw("$timeColumn >= ? AND $timeColumn <= ?", [
|
||||
$dateFrom . ' 00:00:00',
|
||||
$dateTo . ' 23:59:59'
|
||||
]);
|
||||
} elseif ($dateFrom) {
|
||||
$query->whereRaw("$timeColumn >= ?", [$dateFrom . ' 00:00:00']);
|
||||
} elseif ($dateTo) {
|
||||
$query->whereRaw("$timeColumn <= ?", [$dateTo . ' 23:59:59']);
|
||||
}
|
||||
|
||||
// 應用篩選
|
||||
if ($warehouseId) {
|
||||
$query->where('inventories.warehouse_id', $warehouseId);
|
||||
}
|
||||
|
||||
if ($categoryId) {
|
||||
$query->where('products.category_id', $categoryId);
|
||||
}
|
||||
|
||||
if ($search) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('products.name', 'like', "%{$search}%")
|
||||
->orWhere('products.code', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// 分組
|
||||
$query->groupBy([
|
||||
'products.id',
|
||||
'products.code',
|
||||
'products.name',
|
||||
'categories.name'
|
||||
]);
|
||||
|
||||
// 動態排序
|
||||
$allowedSortFields = [
|
||||
'product_code' => 'products.code',
|
||||
'product_name' => 'products.name',
|
||||
'inbound_qty' => 'inbound_qty',
|
||||
'outbound_qty' => 'outbound_qty',
|
||||
'transfer_in_qty' => 'transfer_in_qty',
|
||||
'transfer_out_qty' => 'transfer_out_qty',
|
||||
'adjust_qty' => 'adjust_qty',
|
||||
'net_change' => 'net_change',
|
||||
];
|
||||
|
||||
$sortColumn = $allowedSortFields[$sortBy] ?? 'products.code';
|
||||
$query->orderBy($sortColumn, $sortOrder === 'desc' ? 'desc' : 'asc');
|
||||
|
||||
|
||||
if ($perPage) {
|
||||
return $query->paginate($perPage)->withQueryString();
|
||||
}
|
||||
|
||||
return $query->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得報表統計數據 (不分頁,針對篩選條件的全量統計)
|
||||
*/
|
||||
public function getSummary(array $filters)
|
||||
{
|
||||
$dateFrom = $filters['date_from'] ?? null;
|
||||
$dateTo = $filters['date_to'] ?? null;
|
||||
$warehouseId = $filters['warehouse_id'] ?? null;
|
||||
$categoryId = $filters['category_id'] ?? null;
|
||||
$search = $filters['search'] ?? null;
|
||||
|
||||
// 若無任何篩選條件,直接回傳零值
|
||||
if (!$dateFrom && !$dateTo && !$warehouseId && !$categoryId && !$search) {
|
||||
return (object)[
|
||||
'total_inbound' => 0,
|
||||
'total_outbound' => 0,
|
||||
'total_adjust' => 0,
|
||||
'total_net_change' => 0,
|
||||
];
|
||||
}
|
||||
// 日期欄位:Laravel 時區已設為 Asia/Taipei,直接使用 actual_time
|
||||
$timeColumn = "inventory_transactions.actual_time";
|
||||
|
||||
$query = InventoryTransaction::query()
|
||||
->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id')
|
||||
->join('products', 'inventories.product_id', '=', 'products.id')
|
||||
->leftJoin('categories', 'products.category_id', '=', 'categories.id');
|
||||
|
||||
// 日期篩選:資料庫儲存的是台北時間,直接用字串比對
|
||||
if ($dateFrom && $dateTo) {
|
||||
$query->whereRaw("$timeColumn >= ? AND $timeColumn <= ?", [
|
||||
$dateFrom . ' 00:00:00',
|
||||
$dateTo . ' 23:59:59'
|
||||
]);
|
||||
} elseif ($dateFrom) {
|
||||
$query->whereRaw("$timeColumn >= ?", [$dateFrom . ' 00:00:00']);
|
||||
} elseif ($dateTo) {
|
||||
$query->whereRaw("$timeColumn <= ?", [$dateTo . ' 23:59:59']);
|
||||
}
|
||||
|
||||
if ($warehouseId) {
|
||||
$query->where('inventories.warehouse_id', $warehouseId);
|
||||
}
|
||||
|
||||
if ($categoryId) {
|
||||
$query->where('products.category_id', $categoryId);
|
||||
}
|
||||
|
||||
if ($search) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('products.name', 'like', "%{$search}%")
|
||||
->orWhere('products.code', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// 直接聚合所有符合條件的交易
|
||||
return $query->select([
|
||||
DB::raw("SUM(CASE WHEN inventory_transactions.type IN ('入庫', '手動入庫') AND inventory_transactions.quantity > 0 THEN inventory_transactions.quantity ELSE 0 END) as total_inbound"),
|
||||
DB::raw("ABS(SUM(CASE WHEN inventory_transactions.type IN ('出庫') AND inventory_transactions.quantity < 0 THEN inventory_transactions.quantity ELSE 0 END)) as total_outbound"),
|
||||
DB::raw("SUM(CASE WHEN inventory_transactions.type = '調撥入庫' AND inventory_transactions.quantity > 0 THEN inventory_transactions.quantity ELSE 0 END) as total_transfer_in"),
|
||||
DB::raw("ABS(SUM(CASE WHEN inventory_transactions.type = '調撥出庫' AND inventory_transactions.quantity < 0 THEN inventory_transactions.quantity ELSE 0 END)) as total_transfer_out"),
|
||||
DB::raw("SUM(CASE WHEN inventory_transactions.type IN ('庫存調整', '手動編輯') THEN inventory_transactions.quantity ELSE 0 END) as total_adjust"),
|
||||
DB::raw("SUM(inventory_transactions.quantity) as total_net_change"),
|
||||
])->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得特定商品的庫存異動明細
|
||||
*/
|
||||
public function getProductDetails($productId, array $filters, ?int $perPage = 20)
|
||||
{
|
||||
$dateFrom = $filters['date_from'] ?? null;
|
||||
$dateTo = $filters['date_to'] ?? null;
|
||||
$warehouseId = $filters['warehouse_id'] ?? null;
|
||||
// 日期欄位:Laravel 時區已設為 Asia/Taipei,直接使用 actual_time
|
||||
$timeColumn = "inventory_transactions.actual_time";
|
||||
|
||||
$query = InventoryTransaction::query()
|
||||
->select([
|
||||
'inventory_transactions.*',
|
||||
'inventories.warehouse_id',
|
||||
'inventories.batch_number as batch_no',
|
||||
'warehouses.name as warehouse_name',
|
||||
'users.name as user_name',
|
||||
'products.code as product_code',
|
||||
'products.name as product_name',
|
||||
'units.name as unit_name'
|
||||
])
|
||||
->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id')
|
||||
->join('products', 'inventories.product_id', '=', 'products.id')
|
||||
->leftJoin('units', 'products.base_unit_id', '=', 'units.id')
|
||||
->leftJoin('warehouses', 'inventories.warehouse_id', '=', 'warehouses.id')
|
||||
->leftJoin('users', 'inventory_transactions.user_id', '=', 'users.id')
|
||||
->where('products.id', $productId);
|
||||
|
||||
// 日期篩選:資料庫儲存的是台北時間,直接用字串比對
|
||||
if ($dateFrom && $dateTo) {
|
||||
$query->whereRaw("$timeColumn >= ? AND $timeColumn <= ?", [
|
||||
$dateFrom . ' 00:00:00',
|
||||
$dateTo . ' 23:59:59'
|
||||
]);
|
||||
} elseif ($dateFrom) {
|
||||
$query->whereRaw("$timeColumn >= ?", [$dateFrom . ' 00:00:00']);
|
||||
} elseif ($dateTo) {
|
||||
$query->whereRaw("$timeColumn <= ?", [$dateTo . ' 23:59:59']);
|
||||
}
|
||||
|
||||
if ($warehouseId) {
|
||||
$query->where('inventories.warehouse_id', $warehouseId);
|
||||
}
|
||||
|
||||
// 排序:最新的在最上面
|
||||
$query->orderBy('inventory_transactions.actual_time', 'desc')
|
||||
->orderBy('inventory_transactions.id', 'desc');
|
||||
|
||||
return $query->paginate($perPage)->withQueryString();
|
||||
}
|
||||
}
|
||||
@@ -229,23 +229,363 @@ class InventoryService implements InventoryServiceInterface
|
||||
->first();
|
||||
}
|
||||
|
||||
public function getDashboardStats(): array
|
||||
/**
|
||||
* 即時庫存查詢:統計卡片 + 分頁明細
|
||||
*/
|
||||
public function getStockQueryData(array $filters = [], int $perPage = 10): array
|
||||
{
|
||||
// 庫存總表 join 安全庫存表,計算低庫存
|
||||
$lowStockCount = DB::table('warehouse_product_safety_stocks as ss')
|
||||
->join(DB::raw('(SELECT warehouse_id, product_id, SUM(quantity) as total_qty FROM inventories WHERE deleted_at IS NULL GROUP BY warehouse_id, product_id) as inv'),
|
||||
function ($join) {
|
||||
$join->on('ss.warehouse_id', '=', 'inv.warehouse_id')
|
||||
->on('ss.product_id', '=', 'inv.product_id');
|
||||
})
|
||||
->whereRaw('inv.total_qty <= ss.safety_stock')
|
||||
$today = now()->toDateString();
|
||||
$expiryThreshold = now()->addDays(30)->toDateString();
|
||||
|
||||
// 基礎查詢
|
||||
$query = Inventory::query()
|
||||
->join('products', 'inventories.product_id', '=', 'products.id')
|
||||
->join('warehouses', 'inventories.warehouse_id', '=', 'warehouses.id')
|
||||
->leftJoin('categories', 'products.category_id', '=', 'categories.id')
|
||||
->leftJoin('warehouse_product_safety_stocks as ss', function ($join) {
|
||||
$join->on('inventories.warehouse_id', '=', 'ss.warehouse_id')
|
||||
->on('inventories.product_id', '=', 'ss.product_id');
|
||||
})
|
||||
->whereNull('inventories.deleted_at')
|
||||
->select([
|
||||
'inventories.id',
|
||||
'inventories.warehouse_id',
|
||||
'inventories.product_id',
|
||||
'inventories.quantity',
|
||||
'inventories.batch_number',
|
||||
'inventories.expiry_date',
|
||||
'inventories.location',
|
||||
'inventories.quality_status',
|
||||
'products.code as product_code',
|
||||
'products.name as product_name',
|
||||
'categories.name as category_name',
|
||||
'warehouses.name as warehouse_name',
|
||||
'ss.safety_stock',
|
||||
]);
|
||||
|
||||
// 篩選:倉庫
|
||||
if (!empty($filters['warehouse_id'])) {
|
||||
$query->where('inventories.warehouse_id', $filters['warehouse_id']);
|
||||
}
|
||||
|
||||
// 篩選:分類
|
||||
if (!empty($filters['category_id'])) {
|
||||
$query->where('products.category_id', $filters['category_id']);
|
||||
}
|
||||
|
||||
// 篩選:關鍵字(商品代碼或名稱)
|
||||
if (!empty($filters['search'])) {
|
||||
$search = $filters['search'];
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('products.code', 'like', "%{$search}%")
|
||||
->orWhere('products.name', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// 篩選:狀態 (改為對齊聚合統計的判斷標準)
|
||||
if (!empty($filters['status'])) {
|
||||
switch ($filters['status']) {
|
||||
case 'low_stock':
|
||||
$query->whereIn(DB::raw('(inventories.warehouse_id, inventories.product_id)'), function ($sub) {
|
||||
$sub->select('i2.warehouse_id', 'i2.product_id')
|
||||
->from('inventories as i2')
|
||||
->join('warehouse_product_safety_stocks as ss2', function ($join) {
|
||||
$join->on('i2.warehouse_id', '=', 'ss2.warehouse_id')
|
||||
->on('i2.product_id', '=', 'ss2.product_id');
|
||||
})
|
||||
->whereNull('i2.deleted_at')
|
||||
->groupBy('i2.warehouse_id', 'i2.product_id', 'ss2.safety_stock')
|
||||
->havingRaw('SUM(i2.quantity) <= ss2.safety_stock');
|
||||
});
|
||||
break;
|
||||
case 'negative':
|
||||
$query->whereIn(DB::raw('(inventories.warehouse_id, inventories.product_id)'), function ($sub) {
|
||||
$sub->select('i2.warehouse_id', 'i2.product_id')
|
||||
->from('inventories as i2')
|
||||
->whereNull('i2.deleted_at')
|
||||
->groupBy('i2.warehouse_id', 'i2.product_id')
|
||||
->havingRaw('SUM(i2.quantity) < 0');
|
||||
});
|
||||
break;
|
||||
case 'expiring':
|
||||
$query->whereNotNull('inventories.expiry_date')
|
||||
->where('inventories.expiry_date', '>', $today)
|
||||
->where('inventories.expiry_date', '<=', $expiryThreshold);
|
||||
break;
|
||||
case 'expired':
|
||||
$query->whereNotNull('inventories.expiry_date')
|
||||
->where('inventories.expiry_date', '<=', $today);
|
||||
break;
|
||||
case 'abnormal':
|
||||
// 只要該「倉庫-品項」對應的總庫存有低庫存、負庫存,或該批次已過期/即將過期
|
||||
$query->where(function ($q) use ($today, $expiryThreshold) {
|
||||
// 1. 低庫存或負庫存 (依聚合判斷)
|
||||
$q->whereIn(DB::raw('(inventories.warehouse_id, inventories.product_id)'), function ($sub) {
|
||||
$sub->select('i3.warehouse_id', 'i3.product_id')
|
||||
->from('inventories as i3')
|
||||
->leftJoin('warehouse_product_safety_stocks as ss3', function ($join) {
|
||||
$join->on('i3.warehouse_id', '=', 'ss3.warehouse_id')
|
||||
->on('i3.product_id', '=', 'ss3.product_id');
|
||||
})
|
||||
->whereNull('i3.deleted_at')
|
||||
->groupBy('i3.warehouse_id', 'i3.product_id', 'ss3.safety_stock')
|
||||
->havingRaw('SUM(i3.quantity) < 0 OR (ss3.safety_stock IS NOT NULL AND SUM(i3.quantity) <= ss3.safety_stock)');
|
||||
})
|
||||
// 2. 或該批次效期異常
|
||||
->orWhere(function ($q_batch) use ($expiryThreshold) {
|
||||
$q_batch->whereNotNull('inventories.expiry_date')
|
||||
->where('inventories.expiry_date', '<=', $expiryThreshold);
|
||||
});
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 排序
|
||||
$sortBy = $filters['sort_by'] ?? 'products.code';
|
||||
$sortOrder = $filters['sort_order'] ?? 'asc';
|
||||
$allowedSorts = ['products.code', 'products.name', 'warehouses.name', 'inventories.quantity', 'inventories.expiry_date'];
|
||||
if (in_array($sortBy, $allowedSorts)) {
|
||||
$query->orderBy($sortBy, $sortOrder);
|
||||
} else {
|
||||
$query->orderBy('products.code', 'asc');
|
||||
}
|
||||
|
||||
// 統計卡片(預設無篩選條件下的全域統計,改為明細筆數計數以對齊顯示)
|
||||
// 1. 庫存明細總數
|
||||
$totalItems = DB::table('inventories')
|
||||
->whereNull('deleted_at')
|
||||
->count();
|
||||
|
||||
// 2. 低庫存明細數:只要該明細所屬的「倉庫+商品」總量低於安全庫存,則所有相關明細都計入
|
||||
$lowStockCount = DB::table('inventories as i')
|
||||
->join('warehouse_product_safety_stocks as ss', function ($join) {
|
||||
$join->on('i.warehouse_id', '=', 'ss.warehouse_id')
|
||||
->on('i.product_id', '=', 'ss.product_id');
|
||||
})
|
||||
->whereNull('i.deleted_at')
|
||||
->whereIn(DB::raw('(i.warehouse_id, i.product_id)'), function ($sub) {
|
||||
$sub->select('i2.warehouse_id', 'i2.product_id')
|
||||
->from('inventories as i2')
|
||||
->whereNull('i2.deleted_at')
|
||||
->groupBy('i2.warehouse_id', 'i2.product_id')
|
||||
->havingRaw('SUM(i2.quantity) <= (SELECT safety_stock FROM warehouse_product_safety_stocks WHERE warehouse_id = i2.warehouse_id AND product_id = i2.product_id LIMIT 1)');
|
||||
})
|
||||
->count();
|
||||
|
||||
// 3. 負庫存明細數
|
||||
$negativeCount = DB::table('inventories as i')
|
||||
->whereNull('i.deleted_at')
|
||||
->whereIn(DB::raw('(i.warehouse_id, i.product_id)'), function ($sub) {
|
||||
$sub->select('i2.warehouse_id', 'i2.product_id')
|
||||
->from('inventories as i2')
|
||||
->whereNull('i2.deleted_at')
|
||||
->groupBy('i2.warehouse_id', 'i2.product_id')
|
||||
->havingRaw('SUM(i2.quantity) < 0');
|
||||
})
|
||||
->count();
|
||||
|
||||
// 4. 即將過期明細數 (必須排除已過期)
|
||||
$expiringCount = DB::table('inventories')
|
||||
->whereNull('deleted_at')
|
||||
->whereNotNull('expiry_date')
|
||||
->where('expiry_date', '>', $today)
|
||||
->where('expiry_date', '<=', $expiryThreshold)
|
||||
->count();
|
||||
|
||||
// 分頁
|
||||
$paginated = $query->paginate($perPage)->withQueryString();
|
||||
|
||||
// 為每筆紀錄附加最後入庫/出庫時間 + 狀態
|
||||
$items = collect($paginated->items())->map(function ($item) use ($today, $expiryThreshold) {
|
||||
$lastIn = \App\Modules\Inventory\Models\InventoryTransaction::where('inventory_id', $item->id)
|
||||
->where('type', '入庫')
|
||||
->orderByDesc('actual_time')
|
||||
->value('actual_time');
|
||||
|
||||
$lastOut = \App\Modules\Inventory\Models\InventoryTransaction::where('inventory_id', $item->id)
|
||||
->where('type', '出庫')
|
||||
->orderByDesc('actual_time')
|
||||
->value('actual_time');
|
||||
|
||||
// 計算狀態
|
||||
$statuses = [];
|
||||
if ($item->quantity < 0) {
|
||||
$statuses[] = 'negative';
|
||||
}
|
||||
if ($item->safety_stock !== null && $item->quantity <= $item->safety_stock && $item->quantity >= 0) {
|
||||
$statuses[] = 'low_stock';
|
||||
}
|
||||
if ($item->expiry_date) {
|
||||
if ($item->expiry_date <= $today) {
|
||||
$statuses[] = 'expired';
|
||||
} elseif ($item->expiry_date <= $expiryThreshold) {
|
||||
$statuses[] = 'expiring';
|
||||
}
|
||||
}
|
||||
if (empty($statuses)) {
|
||||
$statuses[] = 'normal';
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $item->id,
|
||||
'product_code' => $item->product_code,
|
||||
'product_name' => $item->product_name,
|
||||
'category_name' => $item->category_name,
|
||||
'warehouse_name' => $item->warehouse_name,
|
||||
'batch_number' => $item->batch_number,
|
||||
'quantity' => $item->quantity,
|
||||
'safety_stock' => $item->safety_stock,
|
||||
'expiry_date' => $item->expiry_date ? \Carbon\Carbon::parse($item->expiry_date)->toDateString() : null,
|
||||
'location' => $item->location,
|
||||
'quality_status' => $item->quality_status ?? null,
|
||||
'last_inbound' => $lastIn ? \Carbon\Carbon::parse($lastIn)->toDateString() : null,
|
||||
'last_outbound' => $lastOut ? \Carbon\Carbon::parse($lastOut)->toDateString() : null,
|
||||
'statuses' => $statuses,
|
||||
];
|
||||
});
|
||||
|
||||
return [
|
||||
'productsCount' => Product::count(),
|
||||
'summary' => [
|
||||
'totalItems' => $totalItems,
|
||||
'lowStockCount' => $lowStockCount,
|
||||
'negativeCount' => $negativeCount,
|
||||
'expiringCount' => $expiringCount,
|
||||
],
|
||||
'data' => $items->toArray(),
|
||||
'pagination' => [
|
||||
'total' => $paginated->total(),
|
||||
'per_page' => $paginated->perPage(),
|
||||
'current_page' => $paginated->currentPage(),
|
||||
'last_page' => $paginated->lastPage(),
|
||||
'links' => $paginated->linkCollection()->toArray(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function getDashboardStats(): array
|
||||
{
|
||||
$today = now()->toDateString();
|
||||
$expiryThreshold = now()->addDays(30)->toDateString();
|
||||
|
||||
// 1. 庫存品項數 (明細總數)
|
||||
$totalItems = DB::table('inventories')
|
||||
->whereNull('deleted_at')
|
||||
->count();
|
||||
|
||||
// 2. 低庫存 (明細計數:只要該明細所屬的「倉庫+商品」總量低於安全庫存,則所有相關明細都計入)
|
||||
$lowStockCount = DB::table('inventories as i')
|
||||
->join('warehouse_product_safety_stocks as ss', function ($join) {
|
||||
$join->on('i.warehouse_id', '=', 'ss.warehouse_id')
|
||||
->on('i.product_id', '=', 'ss.product_id');
|
||||
})
|
||||
->whereNull('i.deleted_at')
|
||||
->whereIn(DB::raw('(i.warehouse_id, i.product_id)'), function ($sub) {
|
||||
$sub->select('i2.warehouse_id', 'i2.product_id')
|
||||
->from('inventories as i2')
|
||||
->whereNull('i2.deleted_at')
|
||||
->groupBy('i2.warehouse_id', 'i2.product_id')
|
||||
->havingRaw('SUM(i2.quantity) <= (SELECT safety_stock FROM warehouse_product_safety_stocks WHERE warehouse_id = i2.warehouse_id AND product_id = i2.product_id LIMIT 1)');
|
||||
})
|
||||
->count();
|
||||
|
||||
// 3. 負庫存 (明細計數)
|
||||
$negativeCount = DB::table('inventories as i')
|
||||
->whereNull('i.deleted_at')
|
||||
->whereIn(DB::raw('(i.warehouse_id, i.product_id)'), function ($sub) {
|
||||
$sub->select('i2.warehouse_id', 'i2.product_id')
|
||||
->from('inventories as i2')
|
||||
->whereNull('i2.deleted_at')
|
||||
->groupBy('i2.warehouse_id', 'i2.product_id')
|
||||
->havingRaw('SUM(i2.quantity) < 0');
|
||||
})
|
||||
->count();
|
||||
|
||||
// 4. 即將過期 (明細計數)
|
||||
$expiringCount = DB::table('inventories')
|
||||
->whereNull('deleted_at')
|
||||
->whereNotNull('expiry_date')
|
||||
->where('expiry_date', '>', $today) // 確保不過期 (getStockQueryData 沒加這個但這裡加上以防與 expired 混淆? 不,stock query 是 > today && <= threshold)
|
||||
->where('expiry_date', '<=', $expiryThreshold)
|
||||
->count();
|
||||
|
||||
// 異常庫存前 10 筆 (明細面依然以個別批次為主,供快速跳轉)
|
||||
$abnormalItems = Inventory::query()
|
||||
->join('products', 'inventories.product_id', '=', 'products.id')
|
||||
->join('warehouses', 'inventories.warehouse_id', '=', 'warehouses.id')
|
||||
->leftJoin('warehouse_product_safety_stocks as ss', function ($join) {
|
||||
$join->on('inventories.warehouse_id', '=', 'ss.warehouse_id')
|
||||
->on('inventories.product_id', '=', 'ss.product_id');
|
||||
})
|
||||
->whereNull('inventories.deleted_at')
|
||||
->where(function ($q) use ($today, $expiryThreshold) {
|
||||
// 1. 屬於低庫存或負庫存品項的批次
|
||||
$q->whereIn(DB::raw('(inventories.warehouse_id, inventories.product_id)'), function ($sub) {
|
||||
$sub->select('i3.warehouse_id', 'i3.product_id')
|
||||
->from('inventories as i3')
|
||||
->leftJoin('warehouse_product_safety_stocks as ss3', function ($join) {
|
||||
$join->on('i3.warehouse_id', '=', 'ss3.warehouse_id')
|
||||
->on('i3.product_id', '=', 'ss3.product_id');
|
||||
})
|
||||
->whereNull('i3.deleted_at')
|
||||
->groupBy('i3.warehouse_id', 'i3.product_id', 'ss3.safety_stock')
|
||||
->havingRaw('SUM(i3.quantity) < 0 OR (ss3.safety_stock IS NOT NULL AND SUM(i3.quantity) <= ss3.safety_stock)');
|
||||
})
|
||||
// 2. 或單一批次效期異常
|
||||
->orWhere(function ($q2) use ($expiryThreshold) {
|
||||
$q2->whereNotNull('inventories.expiry_date')
|
||||
->where('inventories.expiry_date', '<=', $expiryThreshold);
|
||||
});
|
||||
})
|
||||
->select([
|
||||
'inventories.id',
|
||||
'inventories.quantity',
|
||||
'inventories.expiry_date',
|
||||
'products.code as product_code',
|
||||
'products.name as product_name',
|
||||
'warehouses.name as warehouse_name',
|
||||
'ss.safety_stock',
|
||||
])
|
||||
->orderBy('inventories.id', 'desc')
|
||||
->limit(10)
|
||||
->get()
|
||||
->map(function ($item) use ($today, $expiryThreshold) {
|
||||
$statuses = [];
|
||||
if ($item->quantity < 0) {
|
||||
$statuses[] = 'negative';
|
||||
}
|
||||
if ($item->safety_stock !== null && $item->quantity <= $item->safety_stock && $item->quantity >= 0) {
|
||||
$statuses[] = 'low_stock';
|
||||
}
|
||||
if ($item->expiry_date) {
|
||||
if ($item->expiry_date <= $today) {
|
||||
$statuses[] = 'expired';
|
||||
} elseif ($item->expiry_date <= $expiryThreshold) {
|
||||
$statuses[] = 'expiring';
|
||||
}
|
||||
}
|
||||
return [
|
||||
'id' => $item->id,
|
||||
'product_code' => $item->product_code,
|
||||
'product_name' => $item->product_name,
|
||||
'warehouse_name' => $item->warehouse_name,
|
||||
'quantity' => $item->quantity,
|
||||
'safety_stock' => $item->safety_stock,
|
||||
'expiry_date' => $item->expiry_date,
|
||||
'statuses' => $statuses,
|
||||
];
|
||||
})
|
||||
->toArray();
|
||||
|
||||
return [
|
||||
'productsCount' => $totalItems,
|
||||
'warehousesCount' => Warehouse::count(),
|
||||
'lowStockCount' => $lowStockCount,
|
||||
'negativeCount' => $negativeCount,
|
||||
'expiringCount' => $expiringCount,
|
||||
'totalInventoryQuantity' => Inventory::sum('quantity'),
|
||||
'abnormalItems' => $abnormalItems,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,8 +16,17 @@ class SalesImportController extends Controller
|
||||
public function index(Request $request)
|
||||
{
|
||||
$perPage = $request->input('per_page', 10);
|
||||
$search = $request->input('search');
|
||||
|
||||
$batches = SalesImportBatch::with('importer')
|
||||
->when($search, function ($query, $search) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('id', 'like', "%{$search}%")
|
||||
->orWhereHas('importer', function ($u) use ($search) {
|
||||
$u->where('name', 'like', "%{$search}%");
|
||||
});
|
||||
});
|
||||
})
|
||||
->orderByDesc('created_at')
|
||||
->paginate($perPage)
|
||||
->withQueryString();
|
||||
@@ -25,7 +34,8 @@ class SalesImportController extends Controller
|
||||
return Inertia::render('Sales/Import/Index', [
|
||||
'batches' => $batches,
|
||||
'filters' => [
|
||||
'per_page' => $perPage,
|
||||
'per_page' => (string) $perPage,
|
||||
'search' => $search,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -56,6 +56,10 @@ class PermissionSeeder extends Seeder
|
||||
'inventory_transfer.edit' => '編輯',
|
||||
'inventory_transfer.delete' => '刪除',
|
||||
|
||||
// 庫存報表
|
||||
'inventory_report.view' => '檢視',
|
||||
'inventory_report.export' => '匯出',
|
||||
|
||||
// 進貨單管理
|
||||
'goods_receipts.view' => '檢視',
|
||||
'goods_receipts.create' => '建立',
|
||||
@@ -153,6 +157,7 @@ class PermissionSeeder extends Seeder
|
||||
'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_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',
|
||||
'production_orders.view', 'production_orders.create', 'production_orders.edit', 'production_orders.delete',
|
||||
@@ -174,6 +179,8 @@ class PermissionSeeder extends Seeder
|
||||
'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_report.view', 'inventory_report.export',
|
||||
'goods_receipts.view', 'goods_receipts.create', 'goods_receipts.edit', 'goods_receipts.delete',
|
||||
'goods_receipts.view', 'goods_receipts.create', 'goods_receipts.edit', 'goods_receipts.delete',
|
||||
'production_orders.view', 'production_orders.create', 'production_orders.edit',
|
||||
'warehouses.view', 'warehouses.create', 'warehouses.edit',
|
||||
@@ -197,6 +204,7 @@ class PermissionSeeder extends Seeder
|
||||
'vendors.view',
|
||||
'warehouses.view',
|
||||
'utility_fees.view',
|
||||
'inventory_report.view',
|
||||
'accounting.view',
|
||||
]);
|
||||
|
||||
|
||||
43
package-lock.json
generated
43
package-lock.json
generated
@@ -21,6 +21,7 @@
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@types/lodash": "^4.17.21",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
@@ -78,6 +79,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
|
||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
@@ -1798,6 +1800,36 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tabs": {
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
|
||||
"integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-direction": "1.1.1",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-roving-focus": "1.1.11",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tooltip": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
|
||||
@@ -2647,6 +2679,7 @@
|
||||
"integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
@@ -2657,6 +2690,7 @@
|
||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -2667,6 +2701,7 @@
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
@@ -2774,6 +2809,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -2986,7 +3022,8 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "4.1.0",
|
||||
@@ -3865,6 +3902,7 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -3926,6 +3964,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
@@ -3938,6 +3977,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
@@ -4430,6 +4470,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
|
||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@types/lodash": "^4.17.21",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
* 顯示庫存項目列表(依商品分組並支援折疊)
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
import { AlertTriangle, Trash2, Eye, ChevronDown, ChevronRight, CheckCircle, Package } from "lucide-react";
|
||||
import {
|
||||
Table,
|
||||
@@ -47,10 +48,29 @@ export default function InventoryTable({
|
||||
// 判斷是否為販賣機倉庫
|
||||
const isVending = warehouse?.type === "vending";
|
||||
|
||||
// 每個商品的展開/折疊狀態
|
||||
const [expandedProducts, setExpandedProducts] = useState<Set<string>>(new Set());
|
||||
// 每個商品的展開/折疊狀態 - 使用 sessionStorage 保留狀態 (改用 Array 以利序列化)
|
||||
// 解決使用 Link 返回時 State 被重置的問題
|
||||
const storageKey = `inventory_expanded_${warehouse.id}`;
|
||||
const [expandedProducts, setExpandedProducts] = useState<string[]>(() => {
|
||||
if (typeof window === 'undefined') return [];
|
||||
try {
|
||||
const saved = sessionStorage.getItem(storageKey);
|
||||
return saved ? JSON.parse(saved) : [];
|
||||
} catch (e) {
|
||||
console.error("Failed to parse expanded state", e);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
sessionStorage.setItem(storageKey, JSON.stringify(expandedProducts));
|
||||
} catch (e) {
|
||||
console.error("Failed to save expanded state", e);
|
||||
}
|
||||
}, [expandedProducts, storageKey]);
|
||||
|
||||
// console.log('InventoryTable Rendered', { warehouseId: warehouse.id, expandedProducts });
|
||||
|
||||
if (inventories.length === 0) {
|
||||
return (
|
||||
@@ -68,13 +88,11 @@ export default function InventoryTable({
|
||||
|
||||
const toggleProduct = (productId: string) => {
|
||||
setExpandedProducts((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(productId)) {
|
||||
newSet.delete(productId);
|
||||
if (prev.includes(productId)) {
|
||||
return prev.filter(id => id !== productId);
|
||||
} else {
|
||||
newSet.add(productId);
|
||||
return [...prev, productId];
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -111,7 +129,7 @@ export default function InventoryTable({
|
||||
const status = group.status;
|
||||
|
||||
const isLowStock = status === "低於";
|
||||
const isExpanded = expandedProducts.has(group.productId);
|
||||
const isExpanded = expandedProducts.includes(group.productId);
|
||||
const hasInventory = group.batches.length > 0;
|
||||
|
||||
return (
|
||||
|
||||
@@ -104,7 +104,7 @@ export function SearchableSelect({
|
||||
{options.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.label}
|
||||
value={`${option.label} ${option.value}`}
|
||||
onSelect={() => {
|
||||
onValueChange(option.value);
|
||||
setOpen(false);
|
||||
|
||||
@@ -88,6 +88,13 @@ export default function AuthenticatedLayout({
|
||||
icon: <Boxes className="h-5 w-5" />,
|
||||
permission: ["products.view", "warehouses.view", "inventory.view"], // 滿足任一即可看到此群組
|
||||
children: [
|
||||
{
|
||||
id: "stock-query",
|
||||
label: "即時庫存查詢",
|
||||
icon: <BarChart3 className="h-4 w-4" />,
|
||||
route: "/inventory/stock-query",
|
||||
permission: "inventory.view",
|
||||
},
|
||||
{
|
||||
id: "product-management",
|
||||
label: "商品資料管理",
|
||||
@@ -217,7 +224,7 @@ export default function AuthenticatedLayout({
|
||||
id: "report-management",
|
||||
label: "報表管理",
|
||||
icon: <BarChart3 className="h-5 w-5" />,
|
||||
permission: "accounting.view",
|
||||
permission: ["accounting.view", "inventory_report.view"],
|
||||
children: [
|
||||
{
|
||||
id: "accounting-report",
|
||||
@@ -226,6 +233,13 @@ export default function AuthenticatedLayout({
|
||||
route: "/accounting-report",
|
||||
permission: "accounting.view",
|
||||
},
|
||||
{
|
||||
id: "inventory-report",
|
||||
label: "庫存報表",
|
||||
icon: <BarChart3 className="h-4 w-4" />,
|
||||
route: "/inventory/report",
|
||||
permission: "inventory_report.view",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -411,6 +425,7 @@ export default function AuthenticatedLayout({
|
||||
<Link
|
||||
href={item.route || "#"}
|
||||
onClick={() => setIsMobileOpen(false)}
|
||||
preserveScroll={true}
|
||||
className={cn(
|
||||
"w-full flex items-center transition-all rounded-lg group",
|
||||
level === 0 ? "px-3 py-2.5" : "px-3 py-2",
|
||||
@@ -469,7 +484,7 @@ export default function AuthenticatedLayout({
|
||||
>
|
||||
{isMobileOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
|
||||
</button>
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<Link href="/" preserveScroll={true} className="flex items-center gap-2">
|
||||
<ApplicationLogo className="w-8 h-8 rounded-lg object-contain" />
|
||||
<span className="font-bold text-slate-900">{branding?.short_name || 'Star'} ERP</span>
|
||||
</Link>
|
||||
@@ -496,6 +511,7 @@ export default function AuthenticatedLayout({
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href={route('profile.edit')}
|
||||
preserveScroll={true}
|
||||
className="w-full flex items-center cursor-pointer text-slate-600 focus:bg-slate-100 focus:text-slate-900 group"
|
||||
>
|
||||
<Settings className="mr-2 h-4 w-4 text-slate-500 group-focus:text-slate-900" />
|
||||
@@ -537,7 +553,7 @@ export default function AuthenticatedLayout({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-4 space-y-6">
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-4 space-y-6" scroll-region="true">
|
||||
<nav className="space-y-1">
|
||||
{menuItems.map((item) => renderMenuItem(item))}
|
||||
</nav>
|
||||
@@ -582,7 +598,7 @@ export default function AuthenticatedLayout({
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="flex-1 overflow-y-auto p-4" scroll-region="true">
|
||||
<nav className="space-y-1">
|
||||
{menuItems.map((item) => renderMenuItem(item))}
|
||||
</nav>
|
||||
|
||||
@@ -174,8 +174,9 @@ export default function AccountingReport({ records, summary, filters }: PageProp
|
||||
{/* Filters with Quick Date Range */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-grey-4 p-5 mb-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 items-end animate-in fade-in slide-in-from-top-2 duration-200">
|
||||
<div className="md:col-span-6 space-y-2">
|
||||
{/* Top Config: Date Range & Quick Buttons */}
|
||||
<div className="flex flex-col lg:flex-row gap-4 lg:items-end">
|
||||
<div className="flex-none space-y-2">
|
||||
<Label className="text-xs font-medium text-grey-2">快速時間區間</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
@@ -201,8 +202,9 @@ export default function AccountingReport({ records, summary, filters }: PageProp
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-6">
|
||||
<div className="grid grid-cols-2 gap-4 items-end">
|
||||
{/* Date Inputs */}
|
||||
<div className="w-full lg:flex-1">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-grey-2 font-medium">開始日期</Label>
|
||||
<div className="relative">
|
||||
@@ -237,22 +239,25 @@ export default function AccountingReport({ records, summary, filters }: PageProp
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center justify-end border-t border-grey-4 pt-5 gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClearFilters}
|
||||
className="flex items-center gap-2 button-outlined-primary h-9 ml-auto"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
重置
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleFilter}
|
||||
className="button-filled-primary h-9 px-6 gap-2"
|
||||
>
|
||||
<Filter className="h-4 w-4" /> 查詢
|
||||
</Button>
|
||||
{/* Row 2: Actions */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 items-end">
|
||||
<div className="md:col-span-9"></div>
|
||||
<div className="md:col-span-3 flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClearFilters}
|
||||
className="flex-1 items-center gap-2 button-outlined-primary h-9"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
重置
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleFilter}
|
||||
className="flex-1 button-filled-primary h-9 gap-2"
|
||||
>
|
||||
<Filter className="h-4 w-4" /> 查詢
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,211 +1,259 @@
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||
import { Link, Head, usePage } from '@inertiajs/react';
|
||||
import { PageProps } from '@/types/global';
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Head, Link } from "@inertiajs/react";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import {
|
||||
Package,
|
||||
Users,
|
||||
ShoppingCart,
|
||||
Warehouse as WarehouseIcon,
|
||||
AlertTriangle,
|
||||
MinusCircle,
|
||||
Clock,
|
||||
TrendingUp,
|
||||
ChevronRight
|
||||
} from 'lucide-react';
|
||||
ArrowRight,
|
||||
LayoutDashboard,
|
||||
} 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 Stats {
|
||||
productsCount: number;
|
||||
vendorsCount: number;
|
||||
purchaseOrdersCount: number;
|
||||
warehousesCount: number;
|
||||
totalInventoryValue: number;
|
||||
pendingOrdersCount: number;
|
||||
lowStockCount: number;
|
||||
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[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
stats: Stats;
|
||||
stats: {
|
||||
totalItems: number;
|
||||
lowStockCount: number;
|
||||
negativeCount: number;
|
||||
expiringCount: number;
|
||||
};
|
||||
abnormalItems: AbnormalItem[];
|
||||
}
|
||||
|
||||
export default function Dashboard({ stats }: Props) {
|
||||
const { branding } = usePage<PageProps>().props;
|
||||
const cardData = [
|
||||
{
|
||||
label: '商品總數',
|
||||
value: stats.productsCount,
|
||||
icon: <Package className="h-6 w-6 text-primary-main" />,
|
||||
description: '目前系統中的商品種類',
|
||||
color: 'bg-primary-main/10',
|
||||
},
|
||||
{
|
||||
label: '合作廠商',
|
||||
value: stats.vendorsCount,
|
||||
icon: <Users className="h-6 w-6 text-blue-600" />,
|
||||
description: '已建立資料的供應商',
|
||||
color: 'bg-blue-50',
|
||||
},
|
||||
{
|
||||
label: '採購單據',
|
||||
value: stats.purchaseOrdersCount,
|
||||
icon: <ShoppingCart className="h-6 w-6 text-purple-600" />,
|
||||
description: '歷年累計採購單數量',
|
||||
color: 'bg-purple-50',
|
||||
},
|
||||
{
|
||||
label: '倉庫站點',
|
||||
value: stats.warehousesCount,
|
||||
icon: <WarehouseIcon className="h-6 w-6 text-orange-600" />,
|
||||
description: '目前營運中的倉庫環境',
|
||||
color: 'bg-orange-50',
|
||||
},
|
||||
];
|
||||
// 狀態 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",
|
||||
},
|
||||
};
|
||||
|
||||
const alertData = [
|
||||
export default function Dashboard({ stats, abnormalItems }: Props) {
|
||||
const cards = [
|
||||
{
|
||||
label: '待處理採購單',
|
||||
value: stats.pendingOrdersCount,
|
||||
icon: <Clock className="h-5 w-5" />,
|
||||
status: stats.pendingOrdersCount > 0 ? 'warning' : 'normal',
|
||||
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: '低庫存警示',
|
||||
label: "低庫存",
|
||||
value: stats.lowStockCount,
|
||||
icon: <AlertTriangle className="h-5 w-5" />,
|
||||
status: stats.lowStockCount > 0 ? 'error' : 'normal',
|
||||
icon: <AlertTriangle className="h-6 w-6" />,
|
||||
color: "text-amber-600",
|
||||
bgColor: "bg-amber-50",
|
||||
borderColor: "border-amber-200",
|
||||
href: "/inventory/stock-query?status=low_stock",
|
||||
alert: stats.lowStockCount > 0,
|
||||
},
|
||||
{
|
||||
label: "負庫存",
|
||||
value: stats.negativeCount,
|
||||
icon: <MinusCircle className="h-6 w-6" />,
|
||||
color: "text-red-600",
|
||||
bgColor: "bg-red-50",
|
||||
borderColor: "border-red-200",
|
||||
href: "/inventory/stock-query?status=negative",
|
||||
alert: stats.negativeCount > 0,
|
||||
},
|
||||
{
|
||||
label: "即將過期",
|
||||
value: stats.expiringCount,
|
||||
icon: <Clock className="h-6 w-6" />,
|
||||
color: "text-yellow-600",
|
||||
bgColor: "bg-yellow-50",
|
||||
borderColor: "border-yellow-200",
|
||||
href: "/inventory/stock-query?status=expiring",
|
||||
alert: stats.expiringCount > 0,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout>
|
||||
<Head title={`控制台 - ${branding?.short_name || 'Star'} ERP`} />
|
||||
<AuthenticatedLayout
|
||||
breadcrumbs={[
|
||||
{
|
||||
label: "儀表板",
|
||||
href: "/",
|
||||
isPage: true,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Head title="儀表板" />
|
||||
|
||||
<div className="p-8 max-w-7xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<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">
|
||||
<TrendingUp className="h-6 w-6 text-primary-main" />
|
||||
系統總覽
|
||||
<LayoutDashboard className="h-6 w-6 text-primary-main" />
|
||||
庫存總覽
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">歡迎回來,這是您的 {branding?.short_name || 'Star'} ERP 營運數據概況。</p>
|
||||
<p className="text-gray-500 mt-1">
|
||||
即時掌握庫存狀態,異常情況一目了然
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 主要數據卡片 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-10">
|
||||
{cardData.map((card, index) => (
|
||||
<div key={index} className="bg-white p-6 rounded-2xl border border-grey-4 shadow-sm hover:shadow-md transition-shadow">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className={`p-3 rounded-xl ${card.color}`}>
|
||||
{card.icon}
|
||||
{/* 統計卡片 */}
|
||||
<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`}
|
||||
>
|
||||
{card.alert && (
|
||||
<span className="absolute top-3 right-3 h-2.5 w-2.5 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>
|
||||
<span className="text-xs font-medium text-grey-3 bg-grey-5 px-2 py-1 rounded-full border border-grey-4">
|
||||
統計中
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-grey-2 text-sm font-medium mb-1">{card.label}</h3>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-2xl font-bold text-grey-0">{card.value}</span>
|
||||
<div
|
||||
className={`text-3xl font-bold ${card.color}`}
|
||||
>
|
||||
{card.value.toLocaleString()}
|
||||
</div>
|
||||
<p className="text-xs text-grey-3 mt-2">{card.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* 警示與通知 */}
|
||||
<div className="lg:col-span-1 space-y-6">
|
||||
<h2 className="text-xl font-bold text-grey-0 flex items-center gap-2">
|
||||
<TrendingUp className="h-5 w-5 text-primary-main" />
|
||||
即時動態
|
||||
{/* 異常庫存清單 */}
|
||||
<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>
|
||||
<div className="bg-white rounded-2xl border border-grey-4 shadow-sm divide-y divide-grey-4">
|
||||
{alertData.map((alert, index) => (
|
||||
<div key={index} className="p-6 flex items-center justify-between group cursor-pointer hover:bg-background-light transition-colors">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={cn(
|
||||
"p-2 rounded-lg",
|
||||
alert.status === 'error' ? "bg-red-50 text-red-600" :
|
||||
alert.status === 'warning' ? "bg-amber-50 text-amber-600" : "bg-grey-5 text-grey-2"
|
||||
)}>
|
||||
{alert.icon}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-grey-1">{alert.label}</p>
|
||||
<p className="text-xs text-grey-3">需立即查看</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
"text-lg font-bold",
|
||||
alert.status === 'error' ? "text-red-600" :
|
||||
alert.status === 'warning' ? "text-amber-600" : "text-grey-1"
|
||||
)}>
|
||||
{alert.value}
|
||||
</span>
|
||||
<ChevronRight className="h-4 w-4 text-grey-4 group-hover:translate-x-1 transition-transform" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bg-primary/5 rounded-2xl p-6 border border-primary/10">
|
||||
<h4 className="text-sm font-bold text-primary mb-2">系統提示</h4>
|
||||
<p className="text-xs text-grey-1 leading-relaxed">
|
||||
目前系統運行正常。如有任何問題,請聯絡開發團隊獲取支援。
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{/* 快速捷徑 */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<h2 className="text-xl font-bold text-grey-0">快速操作</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Link href="/products" className="group h-full">
|
||||
<div className="bg-white p-6 rounded-2xl border border-grey-4 shadow-sm hover:border-primary-main transition-all h-full flex flex-col justify-between">
|
||||
<div>
|
||||
<h3 className="font-bold text-grey-0 mb-1 group-hover:text-primary-main">商品管理</h3>
|
||||
<p className="text-sm text-grey-2">查看並編輯所有商品資料與單位換算。</p>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center text-xs font-bold text-primary-main group-hover:gap-2 transition-all">
|
||||
即刻前往 <ChevronRight className="h-3 w-3" />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Link href="/purchase-orders" className="group h-full">
|
||||
<div className="bg-white p-6 rounded-2xl border border-grey-4 shadow-sm hover:border-purple-500 transition-all h-full flex flex-col justify-between">
|
||||
<div>
|
||||
<h3 className="font-bold text-grey-0 mb-1 group-hover:text-purple-600">採購單管理</h3>
|
||||
<p className="text-sm text-grey-2">處理進貨、追蹤採購進度與管理單據。</p>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center text-xs font-bold text-purple-600 group-hover:gap-2 transition-all">
|
||||
即刻前往 <ChevronRight className="h-3 w-3" />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Link href="/vendors" className="group h-full">
|
||||
<div className="bg-white p-6 rounded-2xl border border-grey-4 shadow-sm hover:border-blue-500 transition-all h-full flex flex-col justify-between">
|
||||
<div>
|
||||
<h3 className="font-bold text-grey-0 mb-1 group-hover:text-blue-600">廠商管理</h3>
|
||||
<p className="text-sm text-grey-2">管理供應商聯絡資訊與供貨清單。</p>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center text-xs font-bold text-blue-600 group-hover:gap-2 transition-all">
|
||||
即刻前往 <ChevronRight className="h-3 w-3" />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Link href="/warehouses" className="group h-full">
|
||||
<div className="bg-white p-6 rounded-2xl border border-grey-4 shadow-sm hover:border-orange-500 transition-all h-full flex flex-col justify-between">
|
||||
<div>
|
||||
<h3 className="font-bold text-grey-0 mb-1 group-hover:text-orange-600">倉庫與庫存</h3>
|
||||
<p className="text-sm text-grey-2">管理入庫、庫存水位與基礎設施。</p>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center text-xs font-bold text-orange-600 group-hover:gap-2 transition-all">
|
||||
即刻前往 <ChevronRight className="h-3 w-3" />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||
import { Head, Link, router } from '@inertiajs/react';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Plus, Search, FileText, RotateCcw, Calendar, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Plus, Search, FileText, RotateCcw, Calendar } from 'lucide-react';
|
||||
import { Input } from '@/Components/ui/input';
|
||||
import { Label } from '@/Components/ui/label';
|
||||
import { SearchableSelect } from '@/Components/ui/searchable-select';
|
||||
@@ -49,9 +49,7 @@ export default function GoodsReceiptIndex({ receipts, filters, warehouses }: Pro
|
||||
const [dateRangeType, setDateRangeType] = useState('custom');
|
||||
|
||||
// Advanced Filter Toggle
|
||||
const [showAdvanced, setShowAdvanced] = useState(
|
||||
!!(filters.date_start || filters.date_end)
|
||||
);
|
||||
|
||||
|
||||
// Sync filters from props
|
||||
useEffect(() => {
|
||||
@@ -149,55 +147,12 @@ export default function GoodsReceiptIndex({ receipts, filters, warehouses }: Pro
|
||||
</div>
|
||||
|
||||
{/* Filter Bar */}
|
||||
<div className="bg-white p-5 rounded-lg shadow-sm border border-gray-200 mb-6">
|
||||
{/* Row 1: Search, Status, Warehouse */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 mb-4">
|
||||
<div className="md:col-span-4 space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-1">關鍵字搜尋</Label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
||||
<Input
|
||||
placeholder="搜尋單號..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-10 h-9 block"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleFilter()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-4 space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-1">狀態</Label>
|
||||
<Select value={status} onValueChange={setStatus}>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="選擇狀態" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{statusOptions.map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-4 space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-1">倉庫</Label>
|
||||
<SearchableSelect
|
||||
value={warehouseId}
|
||||
onValueChange={setWarehouseId}
|
||||
options={warehouseOptions}
|
||||
placeholder="選擇倉庫"
|
||||
className="w-full h-9"
|
||||
showSearch={warehouses.length > 10}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Date Filters (Collapsible) */}
|
||||
{showAdvanced && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 items-end animate-in fade-in slide-in-from-top-2 duration-200">
|
||||
<div className="md:col-span-6 space-y-2">
|
||||
<Label className="text-xs font-medium text-grey-1">快速時間區間</Label>
|
||||
<div className="bg-white rounded-xl shadow-sm border border-grey-4 p-5 mb-6">
|
||||
<div className="space-y-4">
|
||||
{/* Row 1: Date Range & Quick Buttons */}
|
||||
<div className="flex flex-col lg:flex-row gap-4 lg:items-end">
|
||||
<div className="flex-none space-y-2">
|
||||
<Label className="text-xs font-medium text-grey-2">快速時間區間</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{ label: "今日", value: "today" },
|
||||
@@ -222,8 +177,9 @@ export default function GoodsReceiptIndex({ receipts, filters, warehouses }: Pro
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-6">
|
||||
<div className="grid grid-cols-2 gap-4 items-end">
|
||||
{/* Date Inputs */}
|
||||
<div className="w-full lg:flex-1">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-grey-2 font-medium">開始日期</Label>
|
||||
<div className="relative">
|
||||
@@ -257,45 +213,71 @@ export default function GoodsReceiptIndex({ receipts, filters, warehouses }: Pro
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end border-t border-gray-100 pt-5 gap-3 mt-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="mr-auto text-gray-500 hover:text-gray-900 h-9"
|
||||
>
|
||||
{showAdvanced ? (
|
||||
<>
|
||||
<ChevronUp className="h-4 w-4 mr-1" />
|
||||
收合篩選
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-4 w-4 mr-1" />
|
||||
進階篩選
|
||||
{(dateStart || dateEnd) && (
|
||||
<span className="ml-2 w-2 h-2 rounded-full bg-primary-main" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
className="flex items-center gap-2 button-outlined-primary h-9"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
重置
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleFilter}
|
||||
className="flex items-center gap-2 button-filled-primary h-9 px-6"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
查詢
|
||||
</Button>
|
||||
{/* Row 2: Filters & Actions */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 items-end">
|
||||
{/* Search */}
|
||||
<div className="md:col-span-4 space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-1">關鍵字搜尋</Label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
||||
<Input
|
||||
placeholder="搜尋單號..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-10 h-9 block"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleFilter()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="md:col-span-2 space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-1">狀態</Label>
|
||||
<Select value={status} onValueChange={setStatus}>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="選擇狀態" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{statusOptions.map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Warehouse */}
|
||||
<div className="md:col-span-3 space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-1">倉庫</Label>
|
||||
<SearchableSelect
|
||||
value={warehouseId}
|
||||
onValueChange={setWarehouseId}
|
||||
options={warehouseOptions}
|
||||
placeholder="選擇倉庫"
|
||||
className="w-full h-9"
|
||||
showSearch={warehouses.length > 10}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="md:col-span-3 flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
className="flex-1 flex items-center justify-center gap-2 button-outlined-primary h-9"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
重置
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleFilter}
|
||||
className="flex-1 flex items-center justify-center gap-2 button-filled-primary h-9"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
查詢
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
608
resources/js/Pages/Inventory/Report/Index.tsx
Normal file
608
resources/js/Pages/Inventory/Report/Index.tsx
Normal file
@@ -0,0 +1,608 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import {
|
||||
Download,
|
||||
Calendar,
|
||||
Filter,
|
||||
Package,
|
||||
RotateCcw,
|
||||
FileSpreadsheet,
|
||||
ArrowUpFromLine,
|
||||
ArrowDownToLine,
|
||||
ArrowRightLeft,
|
||||
TrendingUp,
|
||||
ArrowUpDown,
|
||||
ArrowUp,
|
||||
ArrowDown
|
||||
} from 'lucide-react';
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import { Head, Link, router } from "@inertiajs/react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/Components/ui/table";
|
||||
import { getDateRange } from "@/utils/format";
|
||||
import Pagination from "@/Components/shared/Pagination";
|
||||
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||
import { Can } from "@/Components/Permission/Can";
|
||||
import { PageProps } from "@/types/global";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/Components/ui/tooltip";
|
||||
|
||||
interface ReportData {
|
||||
product_code: string;
|
||||
product_name: string;
|
||||
category_name: string;
|
||||
product_id: number;
|
||||
inbound_qty: number;
|
||||
outbound_qty: number;
|
||||
transfer_in_qty: number;
|
||||
transfer_out_qty: number;
|
||||
adjust_qty: number;
|
||||
net_change: number;
|
||||
}
|
||||
|
||||
interface SummaryData {
|
||||
total_inbound: number;
|
||||
total_outbound: number;
|
||||
total_transfer_in: number;
|
||||
total_transfer_out: number;
|
||||
total_adjust: number;
|
||||
total_net_change: number;
|
||||
}
|
||||
|
||||
interface InventoryReportProps extends PageProps {
|
||||
reportData: {
|
||||
data: ReportData[];
|
||||
links: any[];
|
||||
total: number;
|
||||
from: number;
|
||||
to: number;
|
||||
current_page: number;
|
||||
};
|
||||
summary: SummaryData;
|
||||
warehouses: { id: number; name: string }[];
|
||||
categories: { id: number; name: string }[];
|
||||
filters: {
|
||||
date_from: string;
|
||||
date_to: string;
|
||||
warehouse_id: string;
|
||||
category_id: string;
|
||||
search: string;
|
||||
per_page?: number;
|
||||
sort_by?: string;
|
||||
sort_order?: 'asc' | 'desc';
|
||||
};
|
||||
}
|
||||
|
||||
export default function InventoryReportIndex({ reportData, summary, warehouses, categories, filters }: InventoryReportProps) {
|
||||
const [dateStart, setDateStart] = useState(filters.date_from || "");
|
||||
const [dateEnd, setDateEnd] = useState(filters.date_to || "");
|
||||
const [warehouseId, setWarehouseId] = useState(filters.warehouse_id || "all");
|
||||
const [categoryId, setCategoryId] = useState(filters.category_id || "all");
|
||||
const [search, setSearch] = useState(filters.search || "");
|
||||
const [perPage, setPerPage] = useState(filters.per_page?.toString() || "10");
|
||||
|
||||
// Determine initial range type based on date pairs
|
||||
const getInitialRangeType = () => {
|
||||
const { start: todayS, end: todayE } = getDateRange('today');
|
||||
const { start: yestS, end: yestE } = getDateRange('yesterday');
|
||||
const { start: weekS, end: weekE } = getDateRange('this_week');
|
||||
const { start: monthS, end: monthE } = getDateRange('this_month');
|
||||
const { start: lastMS, end: lastME } = getDateRange('last_month');
|
||||
|
||||
const fS = filters.date_from || "";
|
||||
const fE = filters.date_to || "";
|
||||
|
||||
if (fS === todayS && fE === todayE) return "today";
|
||||
if (fS === yestS && fE === yestE) return "yesterday";
|
||||
if (fS === weekS && fE === weekE) return "this_week";
|
||||
if (fS === monthS && fE === monthE) return "this_month";
|
||||
if (fS === lastMS && fE === lastME) return "last_month";
|
||||
|
||||
return "custom";
|
||||
};
|
||||
|
||||
const [dateRangeType, setDateRangeType] = useState(getInitialRangeType());
|
||||
|
||||
const handleDateRangeChange = (type: string) => {
|
||||
setDateRangeType(type);
|
||||
if (type === "custom") return;
|
||||
|
||||
const { start, end } = getDateRange(type);
|
||||
setDateStart(start);
|
||||
setDateEnd(end);
|
||||
};
|
||||
|
||||
const handleFilter = useCallback(() => {
|
||||
router.get(
|
||||
route("inventory.report.index"),
|
||||
{
|
||||
date_from: dateStart,
|
||||
date_to: dateEnd,
|
||||
warehouse_id: warehouseId === "all" ? "" : warehouseId,
|
||||
category_id: categoryId === "all" ? "" : categoryId,
|
||||
search: search,
|
||||
per_page: perPage,
|
||||
},
|
||||
{ preserveState: true, preserveScroll: true }
|
||||
);
|
||||
}, [dateStart, dateEnd, warehouseId, categoryId, search, perPage]);
|
||||
|
||||
const handlePerPageChange = (value: string) => {
|
||||
setPerPage(value);
|
||||
router.get(
|
||||
route("inventory.report.index"),
|
||||
{
|
||||
date_from: dateStart,
|
||||
date_to: dateEnd,
|
||||
warehouse_id: warehouseId === "all" ? "" : warehouseId,
|
||||
category_id: categoryId === "all" ? "" : categoryId,
|
||||
search: search,
|
||||
per_page: value,
|
||||
},
|
||||
{ preserveState: true, preserveScroll: true }
|
||||
);
|
||||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
// Service defaults: -7 days to today.
|
||||
// Let's just clear params and let backend decide or set explicitly.
|
||||
// Or simply reset to "daily" and "all"
|
||||
setWarehouseId("all");
|
||||
setCategoryId("all");
|
||||
setSearch("");
|
||||
setDateStart(""); // Will trigger service default
|
||||
setDateEnd("");
|
||||
setDateRangeType("custom");
|
||||
setPerPage("10");
|
||||
router.get(route("inventory.report.index"));
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
const query: any = {
|
||||
date_from: dateStart,
|
||||
date_to: dateEnd,
|
||||
warehouse_id: warehouseId === "all" ? "" : warehouseId,
|
||||
category_id: categoryId === "all" ? "" : categoryId,
|
||||
search: search,
|
||||
sort_by: filters.sort_by,
|
||||
sort_order: filters.sort_order,
|
||||
};
|
||||
window.location.href = route("inventory.report.export", query);
|
||||
};
|
||||
|
||||
const handleSort = (field: string) => {
|
||||
let newSortBy: string | undefined = field;
|
||||
let newSortOrder: 'asc' | 'desc' | undefined = 'asc';
|
||||
|
||||
if (filters.sort_by === field) {
|
||||
if (filters.sort_order === 'asc') {
|
||||
newSortOrder = 'desc';
|
||||
} else {
|
||||
newSortBy = undefined;
|
||||
newSortOrder = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
router.get(
|
||||
route("inventory.report.index"),
|
||||
{
|
||||
date_from: dateStart,
|
||||
date_to: dateEnd,
|
||||
warehouse_id: warehouseId === "all" ? "" : warehouseId,
|
||||
category_id: categoryId === "all" ? "" : categoryId,
|
||||
search: search,
|
||||
per_page: perPage,
|
||||
sort_by: newSortBy,
|
||||
sort_order: newSortOrder,
|
||||
},
|
||||
{ preserveState: true, preserveScroll: true }
|
||||
);
|
||||
};
|
||||
|
||||
const SortIcon = ({ field }: { field: string }) => {
|
||||
if (filters.sort_by !== field) {
|
||||
return <ArrowUpDown className="h-4 w-4 text-gray-300 ml-1" />;
|
||||
}
|
||||
if (filters.sort_order === "asc") {
|
||||
return <ArrowUp className="h-4 w-4 text-primary-main ml-1" />;
|
||||
}
|
||||
return <ArrowDown className="h-4 w-4 text-primary-main ml-1" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout breadcrumbs={[{ label: "報表管理", href: "#" }, { label: "庫存報表", href: route("inventory.report.index"), isPage: true }]}>
|
||||
<Head title="庫存報表" />
|
||||
|
||||
<div className="container mx-auto p-6 max-w-7xl">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||
<FileSpreadsheet className="h-6 w-6 text-primary-main" />
|
||||
庫存報表
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
統計區間:
|
||||
{filters.date_from && filters.date_to ? (
|
||||
<><span className="font-medium text-gray-700">{filters.date_from}</span> 至 <span className="font-medium text-gray-700">{filters.date_to}</span></>
|
||||
) : (
|
||||
<span className="font-medium text-gray-700">全部期間</span>
|
||||
)}
|
||||
內的進貨、出貨與庫存變動匯總
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Can permission="inventory_report.export">
|
||||
<Button
|
||||
onClick={handleExport}
|
||||
variant="outline"
|
||||
className="button-outlined-primary gap-2"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
匯出 Excel 報表
|
||||
</Button>
|
||||
</Can>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-grey-4 p-5 mb-6">
|
||||
<div className="space-y-4">
|
||||
{/* Top Config: Date Range & Quick Buttons */}
|
||||
<div className="flex flex-col lg:flex-row gap-4 lg:items-end">
|
||||
<div className="flex-none space-y-2">
|
||||
<Label className="text-xs font-medium text-grey-2">快速時間區間</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{ label: "今日", value: "today" },
|
||||
{ label: "昨日", value: "yesterday" },
|
||||
{ label: "本週", value: "this_week" },
|
||||
{ label: "本月", value: "this_month" },
|
||||
{ label: "上月", value: "last_month" },
|
||||
].map((opt) => (
|
||||
<Button
|
||||
key={opt.value}
|
||||
size="sm"
|
||||
onClick={() => handleDateRangeChange(opt.value)}
|
||||
className={
|
||||
dateRangeType === opt.value
|
||||
? 'button-filled-primary h-9 px-4 shadow-sm'
|
||||
: 'button-outlined-primary h-9 px-4 bg-white'
|
||||
}
|
||||
>
|
||||
{opt.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date Inputs */}
|
||||
<div className="w-full lg:flex-1">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-grey-2 font-medium">開始日期</Label>
|
||||
<div className="relative">
|
||||
<Calendar className="absolute left-2.5 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
|
||||
<Input
|
||||
type="date"
|
||||
value={dateStart}
|
||||
onChange={(e) => {
|
||||
setDateStart(e.target.value);
|
||||
setDateRangeType('custom');
|
||||
}}
|
||||
className="pl-9 block w-full h-9 bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-grey-2 font-medium">結束日期</Label>
|
||||
<div className="relative">
|
||||
<Calendar className="absolute left-2.5 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
|
||||
<Input
|
||||
type="date"
|
||||
value={dateEnd}
|
||||
onChange={(e) => {
|
||||
setDateEnd(e.target.value);
|
||||
setDateRangeType('custom');
|
||||
}}
|
||||
className="pl-9 block w-full h-9 bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detailed Filters row */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 items-end">
|
||||
{/* Warehouse & Category */}
|
||||
<div className="md:col-span-3 space-y-1">
|
||||
<Label className="text-xs text-grey-2 font-medium">倉庫</Label>
|
||||
<SearchableSelect
|
||||
value={warehouseId}
|
||||
onValueChange={setWarehouseId}
|
||||
options={[{ label: "全部倉庫", value: "all" }, ...warehouses.map(w => ({ label: w.name, value: w.id.toString() }))]}
|
||||
className="w-full h-9"
|
||||
placeholder="選擇倉庫..."
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-3 space-y-1">
|
||||
<Label className="text-xs text-grey-2 font-medium">分類</Label>
|
||||
<SearchableSelect
|
||||
value={categoryId}
|
||||
onValueChange={setCategoryId}
|
||||
options={[{ label: "全部分類", value: "all" }, ...categories.map(c => ({ label: c.name, value: c.id.toString() }))]}
|
||||
className="w-full h-9"
|
||||
placeholder="選擇分類..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="md:col-span-3 space-y-1">
|
||||
<Label className="text-xs text-grey-2 font-medium">關鍵字</Label>
|
||||
<Input
|
||||
placeholder="搜尋商品..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="h-9 bg-white"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleFilter()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons Integrated */}
|
||||
<div className="md:col-span-3 flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClearFilters}
|
||||
className="flex-1 items-center gap-2 button-outlined-primary h-9"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
重置
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleFilter}
|
||||
className="flex-1 button-filled-primary h-9 gap-2"
|
||||
>
|
||||
<Filter className="h-4 w-4" /> 查詢
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<TooltipProvider>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-3 mb-6">
|
||||
<div className="flex items-center gap-3 px-4 py-3 bg-white rounded-xl border-l-4 border-l-emerald-500 shadow-sm border border-gray-100 transition-all hover:bg-gray-50">
|
||||
<ArrowDownToLine className="h-4 w-4 text-emerald-500 shrink-0" />
|
||||
<div className="flex flex-1 items-baseline justify-between gap-2 min-w-0">
|
||||
<span className="text-xs text-gray-500 font-medium shrink-0">進貨量</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-lg font-bold text-gray-900 truncate cursor-help">{Number(summary?.total_inbound || 0).toLocaleString()}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{Number(summary?.total_inbound || 0).toLocaleString()}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 px-4 py-3 bg-white rounded-xl border-l-4 border-l-red-500 shadow-sm border border-gray-100 transition-all hover:bg-gray-50">
|
||||
<ArrowUpFromLine className="h-4 w-4 text-red-500 shrink-0" />
|
||||
<div className="flex flex-1 items-baseline justify-between gap-2 min-w-0">
|
||||
<span className="text-xs text-gray-500 font-medium shrink-0">出貨量</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-lg font-bold text-gray-900 truncate cursor-help">{Number(summary?.total_outbound || 0).toLocaleString()}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{Number(summary?.total_outbound || 0).toLocaleString()}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 px-4 py-3 bg-white rounded-xl border-l-4 border-l-cyan-500 shadow-sm border border-gray-100 transition-all hover:bg-gray-50">
|
||||
<ArrowDownToLine className="h-4 w-4 text-cyan-500 shrink-0 rotate-180" />
|
||||
<div className="flex flex-1 items-baseline justify-between gap-2 min-w-0">
|
||||
<span className="text-xs text-gray-500 font-medium shrink-0">調撥入</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-lg font-bold text-gray-900 truncate cursor-help">{Number(summary?.total_transfer_in || 0).toLocaleString()}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{Number(summary?.total_transfer_in || 0).toLocaleString()}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 px-4 py-3 bg-white rounded-xl border-l-4 border-l-orange-500 shadow-sm border border-gray-100 transition-all hover:bg-gray-50">
|
||||
<ArrowUpFromLine className="h-4 w-4 text-orange-500 shrink-0 rotate-180" />
|
||||
<div className="flex flex-1 items-baseline justify-between gap-2 min-w-0">
|
||||
<span className="text-xs text-gray-500 font-medium shrink-0">調撥出</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-lg font-bold text-gray-900 truncate cursor-help">{Number(summary?.total_transfer_out || 0).toLocaleString()}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{Number(summary?.total_transfer_out || 0).toLocaleString()}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 px-4 py-3 bg-white rounded-xl border-l-4 border-l-blue-500 shadow-sm border border-gray-100 transition-all hover:bg-gray-50">
|
||||
<ArrowRightLeft className="h-4 w-4 text-blue-500 shrink-0" />
|
||||
<div className="flex flex-1 items-baseline justify-between gap-2 min-w-0">
|
||||
<span className="text-xs text-gray-500 font-medium shrink-0">調整量</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-lg font-bold text-gray-900 truncate cursor-help">{Number(summary?.total_adjust || 0).toLocaleString()}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{Number(summary?.total_adjust || 0).toLocaleString()}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 px-4 py-3 bg-white rounded-xl border-l-4 border-l-gray-700 shadow-sm border border-gray-100 transition-all hover:bg-gray-50">
|
||||
<TrendingUp className="h-4 w-4 text-gray-700 shrink-0" />
|
||||
<div className="flex flex-1 items-baseline justify-between gap-2 min-w-0">
|
||||
<span className="text-xs text-gray-500 font-medium shrink-0">淨變動</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className={`text-lg font-bold truncate cursor-help ${summary?.total_net_change >= 0 ? "text-emerald-600" : "text-red-600"}`}>
|
||||
{summary?.total_net_change > 0 ? "+" : ""}{Number(summary?.total_net_change || 0).toLocaleString()}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{summary?.total_net_change > 0 ? "+" : ""}{Number(summary?.total_net_change || 0).toLocaleString()}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
{/* Results Table */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="bg-gray-50">
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px]">商品代碼</TableHead>
|
||||
<TableHead>商品名稱</TableHead>
|
||||
<TableHead className="w-[120px]">分類</TableHead>
|
||||
<TableHead className="text-right w-[100px] text-emerald-600 cursor-pointer hover:bg-gray-100 transition-colors" onClick={() => handleSort('inbound_qty')}>
|
||||
<div className="flex items-center justify-end">進貨量 <SortIcon field="inbound_qty" /></div>
|
||||
</TableHead>
|
||||
<TableHead className="text-right w-[100px] text-red-600 cursor-pointer hover:bg-gray-100 transition-colors" onClick={() => handleSort('outbound_qty')}>
|
||||
<div className="flex items-center justify-end">出貨量 <SortIcon field="outbound_qty" /></div>
|
||||
</TableHead>
|
||||
<TableHead className="text-right w-[100px] text-cyan-600 cursor-pointer hover:bg-gray-100 transition-colors" onClick={() => handleSort('transfer_in_qty')}>
|
||||
<div className="flex items-center justify-end">調撥入 <SortIcon field="transfer_in_qty" /></div>
|
||||
</TableHead>
|
||||
<TableHead className="text-right w-[100px] text-orange-600 cursor-pointer hover:bg-gray-100 transition-colors" onClick={() => handleSort('transfer_out_qty')}>
|
||||
<div className="flex items-center justify-end">調撥出 <SortIcon field="transfer_out_qty" /></div>
|
||||
</TableHead>
|
||||
<TableHead className="text-right w-[100px] text-blue-600 cursor-pointer hover:bg-gray-100 transition-colors" onClick={() => handleSort('adjust_qty')}>
|
||||
<div className="flex items-center justify-end">調整量 <SortIcon field="adjust_qty" /></div>
|
||||
</TableHead>
|
||||
<TableHead className="text-right w-[100px] cursor-pointer hover:bg-gray-100 transition-colors" onClick={() => handleSort('net_change')}>
|
||||
<div className="flex items-center justify-end">淨變動 <SortIcon field="net_change" /></div>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{reportData.data.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9}>
|
||||
<div className="flex flex-col items-center justify-center space-y-2 py-8 text-gray-400">
|
||||
<Package className="h-10 w-10 opacity-20" />
|
||||
<p>無符合條件的報表資料</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
reportData.data.map((row) => (
|
||||
<TableRow key={row.product_id} className="hover:bg-gray-50/50 transition-colors">
|
||||
<TableCell className="font-medium">
|
||||
<Link
|
||||
href={route('inventory.report.show', {
|
||||
product: row.product_id,
|
||||
date_from: filters.date_from,
|
||||
date_to: filters.date_to,
|
||||
warehouse_id: filters.warehouse_id,
|
||||
// 以下為返回時恢復報表狀態用
|
||||
category_id: filters.category_id,
|
||||
search: filters.search,
|
||||
per_page: filters.per_page,
|
||||
report_page: reportData.current_page,
|
||||
})}
|
||||
className="text-primary hover:underline hover:text-primary/80 transition-colors"
|
||||
>
|
||||
{row.product_code}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Link
|
||||
href={route('inventory.report.show', {
|
||||
product: row.product_id,
|
||||
date_from: filters.date_from,
|
||||
date_to: filters.date_to,
|
||||
warehouse_id: filters.warehouse_id,
|
||||
category_id: filters.category_id,
|
||||
search: filters.search,
|
||||
per_page: filters.per_page,
|
||||
report_page: reportData.current_page,
|
||||
})}
|
||||
className="text-gray-900 hover:text-primary transition-colors font-medium"
|
||||
>
|
||||
{row.product_name}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-500">{row.category_name || '-'}</TableCell>
|
||||
<TableCell className="text-right text-emerald-600 font-medium">
|
||||
{row.inbound_qty > 0 ? `+${row.inbound_qty}` : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-red-600 font-medium">
|
||||
{row.outbound_qty > 0 ? `-${row.outbound_qty}` : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-cyan-600 font-medium">
|
||||
{row.transfer_in_qty > 0 ? `+${row.transfer_in_qty}` : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-orange-600 font-medium">
|
||||
{row.transfer_out_qty > 0 ? `-${row.transfer_out_qty}` : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-blue-600 font-medium">
|
||||
{row.adjust_qty !== 0 ? (row.adjust_qty > 0 ? `+${row.adjust_qty}` : row.adjust_qty) : "-"}
|
||||
</TableCell>
|
||||
<TableCell className={`text-right font-bold ${Number(row.net_change) >= 0 ? 'text-gray-900' : 'text-red-500'}`}>
|
||||
{Number(row.net_change) > 0 ? `+${row.net_change}` : row.net_change}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination Footer */}
|
||||
<div className="mt-6 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<span>每頁顯示</span>
|
||||
<SearchableSelect
|
||||
value={perPage}
|
||||
onValueChange={handlePerPageChange}
|
||||
options={[
|
||||
{ label: "10", value: "10" },
|
||||
{ label: "20", value: "20" },
|
||||
{ label: "50", value: "50" },
|
||||
{ label: "100", value: "100" }
|
||||
]}
|
||||
className="w-[100px] h-8"
|
||||
showSearch={false}
|
||||
/>
|
||||
<span>筆</span>
|
||||
</div>
|
||||
<div className="w-full sm:w-auto flex justify-center sm:justify-end">
|
||||
<Pagination links={reportData.links} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
248
resources/js/Pages/Inventory/Report/Show.tsx
Normal file
248
resources/js/Pages/Inventory/Report/Show.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import { Head, Link } from "@inertiajs/react";
|
||||
import { PageProps } from "@/types/global";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/Components/ui/table";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { ArrowLeft, FileText, Package } from "lucide-react";
|
||||
import Pagination from "@/Components/shared/Pagination";
|
||||
import { formatDate } from "@/utils/format";
|
||||
|
||||
interface Transaction {
|
||||
id: number;
|
||||
inventory_id: number;
|
||||
type: string;
|
||||
quantity: number;
|
||||
unit_cost: number;
|
||||
total_cost: number;
|
||||
actual_time: string;
|
||||
note: string | null;
|
||||
batch_no: string | null;
|
||||
user_id: number;
|
||||
created_at: string;
|
||||
warehouse_name: string;
|
||||
user_name: string;
|
||||
}
|
||||
|
||||
interface ShowProps extends PageProps {
|
||||
product: {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
unit_name: string;
|
||||
};
|
||||
transactions: {
|
||||
data: Transaction[];
|
||||
links: any[];
|
||||
total: number;
|
||||
from: number;
|
||||
to: number;
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
per_page: number;
|
||||
};
|
||||
filters: {
|
||||
date_from: string;
|
||||
date_to: string;
|
||||
warehouse_id: string;
|
||||
};
|
||||
/** 報表頁面的完整篩選狀態(用於返回時恢復) */
|
||||
reportFilters: {
|
||||
date_from: string;
|
||||
date_to: string;
|
||||
warehouse_id: string;
|
||||
category_id: string;
|
||||
search: string;
|
||||
per_page: string;
|
||||
report_page: string;
|
||||
};
|
||||
warehouses: { id: number; name: string }[];
|
||||
}
|
||||
|
||||
export default function InventoryReportShow({ product, transactions, filters, reportFilters, warehouses }: ShowProps) {
|
||||
|
||||
// 類型 Badge 顏色映射
|
||||
const getTypeBadgeVariant = (type: string) => {
|
||||
switch (type) {
|
||||
case '入庫':
|
||||
case '手動入庫':
|
||||
case '調撥入庫':
|
||||
return "default";
|
||||
case '出庫':
|
||||
case '調撥出庫':
|
||||
return "destructive";
|
||||
default:
|
||||
return "secondary";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
breadcrumbs={[
|
||||
{ label: "報表管理", href: "#" },
|
||||
{ label: "庫存報表", href: route("inventory.report.index", reportFilters) },
|
||||
{ label: `${product.name} - 庫存異動明細`, href: "#", isPage: true }
|
||||
]}
|
||||
>
|
||||
<Head title={`${product.name} - 庫存異動明細`} />
|
||||
|
||||
<div className="container mx-auto p-6 max-w-7xl">
|
||||
{/* 返回按鈕 */}
|
||||
<div className="mb-6">
|
||||
<Link href={route('inventory.report.index', reportFilters)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 button-outlined-primary"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回庫存報表
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 頁面標題 */}
|
||||
<div className="mb-6">
|
||||
<div className="mb-4">
|
||||
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||
<FileText className="h-6 w-6 text-primary-main" />
|
||||
庫存異動明細
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
查看商品「{product.name}」的所有庫存異動紀錄
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 商品資訊 & 篩選條件卡片 */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-6 mb-6">
|
||||
<div className="flex flex-col md:flex-row justify-between gap-6">
|
||||
|
||||
{/* 商品資訊 */}
|
||||
<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">
|
||||
{product.code}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-6 text-sm text-gray-500">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Package className="h-4 w-4" />
|
||||
單位: {product.unit_name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 目前篩選條件 (唯讀) */}
|
||||
<div className="bg-gray-50 rounded-lg p-4 space-y-2 min-w-[280px]">
|
||||
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">
|
||||
目前篩選條件
|
||||
</h4>
|
||||
<div className="space-y-1.5 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">日期範圍:</span>
|
||||
<span className="font-medium text-grey-0">
|
||||
{filters.date_from && filters.date_to
|
||||
? `${filters.date_from} ~ ${filters.date_to}`
|
||||
: filters.date_from ? `${filters.date_from} 起`
|
||||
: filters.date_to ? `${filters.date_to} 止`
|
||||
: '全部期間'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">倉庫:</span>
|
||||
<span className="font-medium text-grey-0">
|
||||
{filters.warehouse_id
|
||||
? warehouses.find(w => w.id.toString() === filters.warehouse_id)?.name || '未指定'
|
||||
: '全部倉庫'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 異動紀錄表格 */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-medium text-grey-0 flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 text-gray-400" />
|
||||
異動紀錄
|
||||
</h3>
|
||||
<span className="text-sm text-gray-500">
|
||||
共 {transactions.total} 筆紀錄
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader className="bg-gray-50">
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px] text-center">#</TableHead>
|
||||
<TableHead className="w-[160px]">異動時間</TableHead>
|
||||
<TableHead>類型</TableHead>
|
||||
<TableHead>倉庫</TableHead>
|
||||
<TableHead className="text-right">異動數量</TableHead>
|
||||
<TableHead>批號</TableHead>
|
||||
<TableHead>經手人</TableHead>
|
||||
<TableHead className="w-[200px]">備註</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{transactions.data.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center py-8 text-gray-500">
|
||||
無符合條件的資料
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
transactions.data.map((tx, index) => (
|
||||
<TableRow key={tx.id}>
|
||||
<TableCell className="text-gray-500 font-medium text-center">
|
||||
{(transactions.from || 0) + index}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium text-gray-700">
|
||||
{formatDate(tx.actual_time)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={getTypeBadgeVariant(tx.type)}>
|
||||
{tx.type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{tx.warehouse_name}</TableCell>
|
||||
<TableCell className={`text-right font-medium ${tx.quantity > 0 ? 'text-emerald-600' :
|
||||
tx.quantity < 0 ? 'text-red-600' : 'text-gray-500'
|
||||
}`}>
|
||||
{tx.quantity > 0 ? '+' : ''}{tx.quantity}
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-500">{tx.batch_no || '-'}</TableCell>
|
||||
<TableCell>{tx.user_name || '-'}</TableCell>
|
||||
<TableCell className="text-gray-500 truncate max-w-[200px]" title={tx.note || ''}>
|
||||
{tx.note || '-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 底部分頁列 */}
|
||||
<div className="px-6 pb-6">
|
||||
<div className="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<span className="text-sm text-gray-500">共 {transactions.total} 筆紀錄</span>
|
||||
<Pagination links={transactions.links} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
578
resources/js/Pages/Inventory/StockQuery/Index.tsx
Normal file
578
resources/js/Pages/Inventory/StockQuery/Index.tsx
Normal file
@@ -0,0 +1,578 @@
|
||||
import { useState } from "react";
|
||||
import { Head, router } from "@inertiajs/react";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import {
|
||||
Search,
|
||||
Package,
|
||||
AlertTriangle,
|
||||
MinusCircle,
|
||||
Clock,
|
||||
Download,
|
||||
ArrowUpDown,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
} 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";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||
import Pagination from "@/Components/shared/Pagination";
|
||||
|
||||
interface InventoryItem {
|
||||
id: number;
|
||||
product_code: string;
|
||||
product_name: string;
|
||||
category_name: string | null;
|
||||
warehouse_name: string;
|
||||
batch_number: string | null;
|
||||
quantity: number;
|
||||
safety_stock: number | null;
|
||||
expiry_date: string | null;
|
||||
quality_status: string | null;
|
||||
last_inbound: string | null;
|
||||
last_outbound: string | null;
|
||||
statuses: string[];
|
||||
location: string | null;
|
||||
}
|
||||
|
||||
interface PaginationLink {
|
||||
url: string | null;
|
||||
label: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
filters: {
|
||||
warehouse_id?: string;
|
||||
category_id?: string;
|
||||
search?: string;
|
||||
status?: string;
|
||||
sort_by?: string;
|
||||
sort_order?: string;
|
||||
per_page?: string;
|
||||
};
|
||||
summary: {
|
||||
totalItems: number;
|
||||
lowStockCount: number;
|
||||
negativeCount: number;
|
||||
expiringCount: number;
|
||||
};
|
||||
inventories: {
|
||||
data: InventoryItem[];
|
||||
total: number;
|
||||
per_page: number;
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
links: PaginationLink[];
|
||||
};
|
||||
warehouses: { id: number; name: string }[];
|
||||
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 statusOptions = [
|
||||
{ label: "全部狀態", value: "" },
|
||||
{ label: "低庫存", value: "low_stock" },
|
||||
{ label: "負庫存", value: "negative" },
|
||||
{ label: "即將過期", value: "expiring" },
|
||||
{ label: "已過期", value: "expired" },
|
||||
{ label: "所有異常", value: "abnormal" },
|
||||
];
|
||||
|
||||
export default function StockQueryIndex({
|
||||
filters,
|
||||
summary,
|
||||
inventories,
|
||||
warehouses,
|
||||
categories,
|
||||
}: Props) {
|
||||
const [search, setSearch] = useState(filters.search || "");
|
||||
const [perPage, setPerPage] = useState<string>(
|
||||
filters.per_page || "10"
|
||||
);
|
||||
|
||||
// 執行篩選
|
||||
const applyFilters = (newFilters: Record<string, string | undefined>) => {
|
||||
const merged = { ...filters, ...newFilters, page: undefined };
|
||||
// 移除空值
|
||||
const cleaned: Record<string, string> = {};
|
||||
Object.entries(merged).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== "" && value !== null) {
|
||||
cleaned[key] = String(value);
|
||||
}
|
||||
});
|
||||
router.get(route("inventory.stock-query.index"), cleaned, {
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
});
|
||||
};
|
||||
|
||||
// 搜尋
|
||||
const handleSearch = () => {
|
||||
applyFilters({ search: search || undefined });
|
||||
};
|
||||
|
||||
const handleSearchKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
handleSearch();
|
||||
}
|
||||
};
|
||||
|
||||
// 排序
|
||||
const handleSort = (field: string) => {
|
||||
let newSortBy: string | undefined = field;
|
||||
let newSortOrder: string | undefined = "asc";
|
||||
|
||||
if (filters.sort_by === field) {
|
||||
if (filters.sort_order === "asc") {
|
||||
newSortOrder = "desc";
|
||||
} else {
|
||||
newSortBy = undefined;
|
||||
newSortOrder = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
applyFilters({ sort_by: newSortBy, sort_order: newSortOrder });
|
||||
};
|
||||
|
||||
// 排序圖標
|
||||
const SortIcon = ({ field }: { field: string }) => {
|
||||
if (filters.sort_by !== field) {
|
||||
return (
|
||||
<ArrowUpDown className="h-4 w-4 text-muted-foreground ml-1" />
|
||||
);
|
||||
}
|
||||
if (filters.sort_order === "asc") {
|
||||
return <ArrowUp className="h-4 w-4 text-primary ml-1" />;
|
||||
}
|
||||
return <ArrowDown className="h-4 w-4 text-primary ml-1" />;
|
||||
};
|
||||
|
||||
// 每頁筆數變更
|
||||
const handlePerPageChange = (value: string) => {
|
||||
setPerPage(value);
|
||||
router.get(
|
||||
route("inventory.stock-query.index"),
|
||||
{ ...filters, per_page: value, page: undefined },
|
||||
{ preserveState: false, replace: true, preserveScroll: true }
|
||||
);
|
||||
};
|
||||
|
||||
// 匯出
|
||||
const handleExport = () => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.warehouse_id)
|
||||
params.append("warehouse_id", filters.warehouse_id);
|
||||
if (filters.category_id)
|
||||
params.append("category_id", filters.category_id);
|
||||
if (filters.search) params.append("search", filters.search);
|
||||
if (filters.status) params.append("status", filters.status);
|
||||
window.location.href =
|
||||
route("inventory.stock-query.export") + "?" + params.toString();
|
||||
};
|
||||
|
||||
// 計算序號起始值
|
||||
const startIndex =
|
||||
(inventories.current_page - 1) * inventories.per_page + 1;
|
||||
|
||||
// 統計卡片
|
||||
const cards = [
|
||||
{
|
||||
label: "庫存品項",
|
||||
value: summary.totalItems,
|
||||
icon: <Package className="h-5 w-5" />,
|
||||
color: "text-primary-main",
|
||||
bgColor: "bg-primary-lightest",
|
||||
borderColor: "border-primary-light",
|
||||
status: "",
|
||||
},
|
||||
{
|
||||
label: "低庫存",
|
||||
value: summary.lowStockCount,
|
||||
icon: <AlertTriangle className="h-5 w-5" />,
|
||||
color: "text-amber-600",
|
||||
bgColor: "bg-amber-50",
|
||||
borderColor: "border-amber-200",
|
||||
status: "low_stock",
|
||||
alert: summary.lowStockCount > 0,
|
||||
},
|
||||
{
|
||||
label: "負庫存",
|
||||
value: summary.negativeCount,
|
||||
icon: <MinusCircle className="h-5 w-5" />,
|
||||
color: "text-red-600",
|
||||
bgColor: "bg-red-50",
|
||||
borderColor: "border-red-200",
|
||||
status: "negative",
|
||||
alert: summary.negativeCount > 0,
|
||||
},
|
||||
{
|
||||
label: "即將過期",
|
||||
value: summary.expiringCount,
|
||||
icon: <Clock className="h-5 w-5" />,
|
||||
color: "text-yellow-600",
|
||||
bgColor: "bg-yellow-50",
|
||||
borderColor: "border-yellow-200",
|
||||
status: "expiring",
|
||||
alert: summary.expiringCount > 0,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
breadcrumbs={[
|
||||
{ label: "商品與庫存管理", href: "#" },
|
||||
{
|
||||
label: "即時庫存查詢",
|
||||
href: route("inventory.stock-query.index"),
|
||||
isPage: true,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Head title="即時庫存查詢" />
|
||||
|
||||
<div className="container mx-auto p-6 max-w-7xl">
|
||||
{/* 頁面標題 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||
<Search className="h-6 w-6 text-primary-main" />
|
||||
即時庫存查詢
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
跨倉庫即時庫存查詢,含批號追蹤與到期管理
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className="button-filled-primary gap-2"
|
||||
onClick={handleExport}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
匯出 Excel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 統計卡片 */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
|
||||
{cards.map((card) => (
|
||||
<div
|
||||
key={card.label}
|
||||
onClick={() =>
|
||||
applyFilters({
|
||||
status: card.status || undefined,
|
||||
})
|
||||
}
|
||||
className={`relative rounded-xl border ${card.borderColor} ${card.bgColor} p-4 transition-all hover:shadow-md hover:-translate-y-0.5 cursor-pointer ${filters.status === card.status
|
||||
? "ring-2 ring-primary-main ring-offset-1"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{card.alert && (
|
||||
<span className="absolute top-2.5 right-2.5 h-2 w-2 rounded-full bg-red-500 animate-pulse" />
|
||||
)}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className={card.color}>{card.icon}</div>
|
||||
<span className="text-xs font-medium text-grey-2">
|
||||
{card.label}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={`text-2xl font-bold ${card.color}`}
|
||||
>
|
||||
{card.value.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 篩選列 */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4 mb-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<SearchableSelect
|
||||
value={filters.warehouse_id || ""}
|
||||
onValueChange={(v) =>
|
||||
applyFilters({
|
||||
warehouse_id: v || undefined,
|
||||
})
|
||||
}
|
||||
options={[
|
||||
{ label: "全部倉庫", value: "" },
|
||||
...warehouses.map((w) => ({
|
||||
label: w.name,
|
||||
value: String(w.id),
|
||||
})),
|
||||
]}
|
||||
className="w-[160px] h-9"
|
||||
placeholder="選擇倉庫"
|
||||
/>
|
||||
<SearchableSelect
|
||||
value={filters.category_id || ""}
|
||||
onValueChange={(v) =>
|
||||
applyFilters({
|
||||
category_id: v || undefined,
|
||||
})
|
||||
}
|
||||
options={[
|
||||
{ label: "全部分類", value: "" },
|
||||
...categories.map((c) => ({
|
||||
label: c.name,
|
||||
value: String(c.id),
|
||||
})),
|
||||
]}
|
||||
className="w-[160px] h-9"
|
||||
placeholder="選擇分類"
|
||||
/>
|
||||
<SearchableSelect
|
||||
value={filters.status || ""}
|
||||
onValueChange={(v) =>
|
||||
applyFilters({ status: v || undefined })
|
||||
}
|
||||
options={statusOptions}
|
||||
className="w-[140px] h-9"
|
||||
showSearch={false}
|
||||
placeholder="篩選狀態"
|
||||
/>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-[200px]">
|
||||
<Input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
placeholder="搜尋商品代碼或名稱..."
|
||||
className="h-9"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="button-outlined-primary h-9"
|
||||
onClick={handleSearch}
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 庫存明細表格 */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="bg-gray-50">
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px] text-center">
|
||||
#
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<button
|
||||
onClick={() =>
|
||||
handleSort("products.code")
|
||||
}
|
||||
className="flex items-center hover:text-gray-900"
|
||||
>
|
||||
商品代碼
|
||||
<SortIcon field="products.code" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<button
|
||||
onClick={() =>
|
||||
handleSort("products.name")
|
||||
}
|
||||
className="flex items-center hover:text-gray-900"
|
||||
>
|
||||
商品名稱
|
||||
<SortIcon field="products.name" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead>分類</TableHead>
|
||||
<TableHead>
|
||||
<button
|
||||
onClick={() =>
|
||||
handleSort("warehouses.name")
|
||||
}
|
||||
className="flex items-center hover:text-gray-900"
|
||||
>
|
||||
倉庫
|
||||
<SortIcon field="warehouses.name" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead>批號</TableHead>
|
||||
<TableHead>儲位/貨道</TableHead>
|
||||
<TableHead>
|
||||
<button
|
||||
onClick={() =>
|
||||
handleSort("inventories.quantity")
|
||||
}
|
||||
className="flex items-center hover:text-gray-900"
|
||||
>
|
||||
數量
|
||||
<SortIcon field="inventories.quantity" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead>安全庫存</TableHead>
|
||||
<TableHead>
|
||||
<button
|
||||
onClick={() =>
|
||||
handleSort(
|
||||
"inventories.expiry_date"
|
||||
)
|
||||
}
|
||||
className="flex items-center hover:text-gray-900"
|
||||
>
|
||||
保存期限
|
||||
<SortIcon field="inventories.expiry_date" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead className="text-center">
|
||||
狀態
|
||||
</TableHead>
|
||||
<TableHead>最後入庫</TableHead>
|
||||
<TableHead>最後出庫</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{inventories.data.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={13}
|
||||
className="text-center py-8 text-gray-500"
|
||||
>
|
||||
無符合條件的資料
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
inventories.data.map((item: InventoryItem, index) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="text-gray-500 font-medium text-center">
|
||||
{startIndex + index}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">
|
||||
{item.product_code}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{item.product_name}
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-500">
|
||||
{item.category_name || "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{item.warehouse_name}
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-500 text-sm">
|
||||
{item.batch_number || "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-gray-500">
|
||||
{item.location || "—"}
|
||||
</TableCell>
|
||||
<TableCell className={`text-right font-medium ${item.quantity < 0 ? "text-red-600" : ""}`}>
|
||||
{item.quantity}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-gray-500">
|
||||
{item.safety_stock !== null
|
||||
? item.safety_stock
|
||||
: "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{item.expiry_date || "—"}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-gray-500">
|
||||
{item.last_inbound || "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-gray-500">
|
||||
{item.last_outbound || "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 分頁 */}
|
||||
<div className="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<span>每頁顯示</span>
|
||||
<SearchableSelect
|
||||
value={perPage}
|
||||
onValueChange={handlePerPageChange}
|
||||
options={[
|
||||
{ label: "10", value: "10" },
|
||||
{ label: "20", value: "20" },
|
||||
{ label: "50", value: "50" },
|
||||
{ label: "100", value: "100" },
|
||||
]}
|
||||
className="w-[90px] h-8"
|
||||
showSearch={false}
|
||||
/>
|
||||
<span>筆</span>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">
|
||||
共 {inventories.total} 筆紀錄
|
||||
</span>
|
||||
</div>
|
||||
<Pagination links={inventories.links} />
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Plus, ShoppingCart, Search, RotateCcw, Calendar, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Plus, ShoppingCart, Search, RotateCcw, Calendar } from 'lucide-react';
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import { Head, router } from "@inertiajs/react";
|
||||
@@ -57,9 +57,7 @@ export default function PurchaseOrderIndex({ orders, filters, warehouses }: Prop
|
||||
const [dateRangeType, setDateRangeType] = useState('custom');
|
||||
|
||||
// Advanced Filter Toggle
|
||||
const [showAdvancedFilter, setShowAdvancedFilter] = useState(
|
||||
!!(filters.date_start || filters.date_end)
|
||||
);
|
||||
|
||||
|
||||
// 同步 URL 參數到 State (雖有初始值,但若由外部連結進入可確保同步)
|
||||
useEffect(() => {
|
||||
@@ -152,60 +150,13 @@ export default function PurchaseOrderIndex({ orders, filters, warehouses }: Prop
|
||||
</div>
|
||||
|
||||
{/* 篩選區塊 */}
|
||||
<div className="bg-white p-5 rounded-lg shadow-sm border border-gray-200 mb-6">
|
||||
{/* Row 1: Search, Status, Warehouse */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 mb-4">
|
||||
<div className="md:col-span-4 space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-1">關鍵字搜尋</Label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
||||
<Input
|
||||
placeholder="搜尋採購單號、廠商..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-10 h-9 block"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleFilter()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-4 space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-1">訂單狀態</Label>
|
||||
<Select value={status} onValueChange={setStatus}>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="選擇狀態" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部狀態</SelectItem>
|
||||
{MANUAL_STATUS_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-4 space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-1">入庫倉庫</Label>
|
||||
<SearchableSelect
|
||||
value={warehouseId}
|
||||
onValueChange={setWarehouseId}
|
||||
options={[
|
||||
{ label: "全部倉庫", value: "all" },
|
||||
...warehouses.map(w => ({ label: w.name, value: String(w.id) }))
|
||||
]}
|
||||
placeholder="選擇倉庫"
|
||||
className="w-full h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Date Filters (Collapsible) */}
|
||||
{showAdvancedFilter && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 items-end animate-in fade-in slide-in-from-top-2 duration-200">
|
||||
<div className="md:col-span-6 space-y-2">
|
||||
<Label className="text-xs font-medium text-grey-1">快速時間區間</Label>
|
||||
{/* 篩選區塊 */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-grey-4 p-5 mb-6">
|
||||
<div className="space-y-4">
|
||||
{/* Row 1: Date Range & Quick Buttons */}
|
||||
<div className="flex flex-col lg:flex-row gap-4 lg:items-end">
|
||||
<div className="flex-none space-y-2">
|
||||
<Label className="text-xs font-medium text-grey-2">快速時間區間</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{ label: "今日", value: "today" },
|
||||
@@ -230,8 +181,9 @@ export default function PurchaseOrderIndex({ orders, filters, warehouses }: Prop
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-6">
|
||||
<div className="grid grid-cols-2 gap-4 items-end">
|
||||
{/* Date Inputs */}
|
||||
<div className="w-full lg:flex-1">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-grey-2 font-medium">開始日期</Label>
|
||||
<div className="relative">
|
||||
@@ -265,45 +217,76 @@ export default function PurchaseOrderIndex({ orders, filters, warehouses }: Prop
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end border-t border-grey-4 pt-5 gap-3 mt-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowAdvancedFilter(!showAdvancedFilter)}
|
||||
className="mr-auto text-gray-500 hover:text-gray-900 h-9"
|
||||
>
|
||||
{showAdvancedFilter ? (
|
||||
<>
|
||||
<ChevronUp className="h-4 w-4 mr-1" />
|
||||
收合篩選
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-4 w-4 mr-1" />
|
||||
進階篩選
|
||||
{(dateStart || dateEnd) && (
|
||||
<span className="ml-2 w-2 h-2 rounded-full bg-primary-main" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
className="flex items-center gap-2 button-outlined-primary h-9"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
重置
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleFilter}
|
||||
className="flex items-center gap-2 button-filled-primary h-9 px-6"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
查詢
|
||||
</Button>
|
||||
{/* Row 2: Filters & Actions */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 items-end">
|
||||
{/* Search */}
|
||||
<div className="md:col-span-4 space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-1">關鍵字搜尋</Label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
||||
<Input
|
||||
placeholder="搜尋採購單號、廠商..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-10 h-9 block"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleFilter()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="md:col-span-2 space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-1">訂單狀態</Label>
|
||||
<Select value={status} onValueChange={setStatus}>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="選擇狀態" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部狀態</SelectItem>
|
||||
{MANUAL_STATUS_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Warehouse */}
|
||||
<div className="md:col-span-3 space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-1">入庫倉庫</Label>
|
||||
<SearchableSelect
|
||||
value={warehouseId}
|
||||
onValueChange={setWarehouseId}
|
||||
options={[
|
||||
{ label: "全部倉庫", value: "all" },
|
||||
...warehouses.map(w => ({ label: w.name, value: String(w.id) }))
|
||||
]}
|
||||
placeholder="選擇倉庫"
|
||||
className="w-full h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="md:col-span-3 flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
className="flex-1 flex items-center justify-center gap-2 button-outlined-primary h-9"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
重置
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleFilter}
|
||||
className="flex-1 flex items-center justify-center gap-2 button-filled-primary h-9"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
查詢
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -21,11 +21,12 @@ import {
|
||||
AlertDialogTrigger,
|
||||
} from "@/Components/ui/alert-dialog";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { Plus, FileUp, Eye, Trash2 } from 'lucide-react';
|
||||
import { Plus, FileUp, Eye, Trash2, Search, X } from 'lucide-react';
|
||||
import { useState, useEffect } from "react";
|
||||
import { format } from 'date-fns';
|
||||
import Pagination from "@/Components/shared/Pagination";
|
||||
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { router } from "@inertiajs/react";
|
||||
import { usePermission } from "@/hooks/usePermission";
|
||||
import SalesImportDialog from "@/Components/Sales/SalesImportDialog";
|
||||
@@ -47,27 +48,41 @@ interface Props {
|
||||
data: ImportBatch[];
|
||||
links: any[]; // Pagination links
|
||||
};
|
||||
filters?: { // Add filters prop if not present, though we main need per_page state
|
||||
filters?: {
|
||||
per_page?: string;
|
||||
search?: string;
|
||||
}
|
||||
}
|
||||
|
||||
export default function SalesImportIndex({ batches, filters = {} }: Props) {
|
||||
const { can } = usePermission();
|
||||
const [perPage, setPerPage] = useState(filters?.per_page?.toString() || "10");
|
||||
const [search, setSearch] = useState(filters?.search || "");
|
||||
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (filters?.per_page) {
|
||||
setPerPage(filters.per_page.toString());
|
||||
}
|
||||
}, [filters?.per_page]);
|
||||
setSearch(filters?.search || "");
|
||||
}, [filters]);
|
||||
|
||||
const handleFilter = () => {
|
||||
router.get(
|
||||
route("sales-imports.index"),
|
||||
{
|
||||
per_page: perPage,
|
||||
search: search
|
||||
},
|
||||
{ preserveState: true, replace: true }
|
||||
);
|
||||
};
|
||||
|
||||
const handlePerPageChange = (value: string) => {
|
||||
setPerPage(value);
|
||||
router.get(
|
||||
route("sales-imports.index"),
|
||||
{ per_page: value },
|
||||
{ ...filters, per_page: value },
|
||||
{ preserveState: true, preserveScroll: true, replace: true }
|
||||
);
|
||||
};
|
||||
@@ -92,15 +107,56 @@ export default function SalesImportIndex({ batches, filters = {} }: Props) {
|
||||
匯入並管理銷售出貨紀錄
|
||||
</p>
|
||||
</div>
|
||||
{can('sales_imports.create') && (
|
||||
<Button
|
||||
className="button-filled-primary gap-2"
|
||||
onClick={() => setIsImportDialogOpen(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
新增匯入
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Toolbar (Aligned with Recipe Management) */}
|
||||
<div className="bg-white rounded-lg shadow-sm border p-4 mb-6">
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
{/* Search */}
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
||||
<Input
|
||||
placeholder="搜尋批次 ID、匯入人員..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-10 pr-10 h-9"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleFilter()}
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearch("");
|
||||
router.get(route('sales-imports.index'), { ...filters, search: "" }, { preserveState: true, replace: true });
|
||||
}}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2 w-full md:w-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="button-outlined-primary"
|
||||
onClick={handleFilter}
|
||||
>
|
||||
<Search className="w-4 h-4 mr-2" />
|
||||
搜尋
|
||||
</Button>
|
||||
|
||||
{can('sales_imports.create') && (
|
||||
<Button
|
||||
className="button-filled-primary gap-2"
|
||||
onClick={() => setIsImportDialogOpen(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
新增匯入
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SalesImportDialog
|
||||
@@ -112,7 +168,7 @@ export default function SalesImportIndex({ batches, filters = {} }: Props) {
|
||||
<Table>
|
||||
<TableHeader className="bg-gray-50">
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px]">ID</TableHead>
|
||||
<TableHead className="w-[80px] text-center">#</TableHead>
|
||||
<TableHead>匯入日期</TableHead>
|
||||
<TableHead>匯入人員</TableHead>
|
||||
<TableHead className="text-center w-[120px]">總數量</TableHead>
|
||||
@@ -129,9 +185,11 @@ export default function SalesImportIndex({ batches, filters = {} }: Props) {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
batches.data.map((batch) => (
|
||||
batches.data.map((batch, index) => (
|
||||
<TableRow key={batch.id} className="hover:bg-gray-50/50">
|
||||
<TableCell className="font-medium">#{batch.id}</TableCell>
|
||||
<TableCell className="text-center text-gray-500">
|
||||
{(batches as any).from + index}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{format(new Date(batch.created_at), 'yyyy/MM/dd HH:mm')}
|
||||
</TableCell>
|
||||
|
||||
@@ -13,9 +13,7 @@ import {
|
||||
RotateCcw,
|
||||
ArrowUpDown,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
ChevronDown,
|
||||
ChevronUp
|
||||
ArrowDown
|
||||
} from 'lucide-react';
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
@@ -81,10 +79,7 @@ export default function UtilityFeeIndex({ fees, availableCategories, filters }:
|
||||
const [editingFee, setEditingFee] = useState<UtilityFee | null>(null);
|
||||
const [deletingFeeId, setDeletingFeeId] = useState<number | null>(null);
|
||||
|
||||
// Advanced Filter Toggle
|
||||
const [showAdvancedFilter, setShowAdvancedFilter] = useState(
|
||||
!!(filters.date_start || filters.date_end)
|
||||
);
|
||||
|
||||
|
||||
// Sorting
|
||||
const [sortField, setSortField] = useState<string | null>(filters.sort_field || null);
|
||||
@@ -236,11 +231,76 @@ export default function UtilityFeeIndex({ fees, availableCategories, filters }:
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-grey-4 p-5 mb-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Row 1: Search and Category */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-4">
|
||||
<div className="md:col-span-8 space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-1">關鍵字搜尋</Label>
|
||||
<div className="space-y-4">
|
||||
{/* Row 1: Date Range & Quick Buttons (Aligned with Inventory Report) */}
|
||||
<div className="flex flex-col lg:flex-row gap-4 lg:items-end">
|
||||
<div className="flex-none space-y-2">
|
||||
<Label className="text-xs font-medium text-grey-2">快速時間區間</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{ label: "今日", value: "today" },
|
||||
{ label: "昨日", value: "yesterday" },
|
||||
{ label: "本週", value: "this_week" },
|
||||
{ label: "本月", value: "this_month" },
|
||||
{ label: "上月", value: "last_month" },
|
||||
].map((opt) => (
|
||||
<Button
|
||||
key={opt.value}
|
||||
size="sm"
|
||||
onClick={() => handleDateRangeChange(opt.value)}
|
||||
className={
|
||||
dateRangeType === opt.value
|
||||
? 'button-filled-primary h-9 px-4 shadow-sm'
|
||||
: 'button-outlined-primary h-9 px-4 bg-white'
|
||||
}
|
||||
>
|
||||
{opt.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date Inputs */}
|
||||
<div className="w-full lg:flex-1">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-grey-2 font-medium">開始日期</Label>
|
||||
<div className="relative">
|
||||
<Calendar className="absolute left-2.5 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
|
||||
<Input
|
||||
type="date"
|
||||
value={dateStart}
|
||||
onChange={(e) => {
|
||||
setDateStart(e.target.value);
|
||||
setDateRangeType('custom');
|
||||
}}
|
||||
className="pl-9 block w-full h-9 bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-grey-2 font-medium">結束日期</Label>
|
||||
<div className="relative">
|
||||
<Calendar className="absolute left-2.5 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
|
||||
<Input
|
||||
type="date"
|
||||
value={dateEnd}
|
||||
onChange={(e) => {
|
||||
setDateEnd(e.target.value);
|
||||
setDateRangeType('custom');
|
||||
}}
|
||||
className="pl-9 block w-full h-9 bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Search, Category & Actions */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 items-end">
|
||||
<div className="md:col-span-5 space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-2">關鍵字搜尋</Label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
||||
<Input
|
||||
@@ -248,7 +308,7 @@ export default function UtilityFeeIndex({ fees, availableCategories, filters }:
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
className="pl-10 h-9 block"
|
||||
className="pl-10 h-9 block bg-white"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
@@ -261,7 +321,7 @@ export default function UtilityFeeIndex({ fees, availableCategories, filters }:
|
||||
</div>
|
||||
</div>
|
||||
<div className="md:col-span-4 space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-1">費用類別</Label>
|
||||
<Label className="text-xs font-medium text-grey-2">費用類別</Label>
|
||||
<SearchableSelect
|
||||
value={categoryFilter}
|
||||
onValueChange={setCategoryFilter}
|
||||
@@ -270,114 +330,27 @@ export default function UtilityFeeIndex({ fees, availableCategories, filters }:
|
||||
...availableCategories.map(c => ({ label: c, value: c }))
|
||||
]}
|
||||
placeholder="篩選類別"
|
||||
className="h-9"
|
||||
className="h-9 w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Date Filters (Collapsible) */}
|
||||
{showAdvancedFilter && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 items-end animate-in fade-in slide-in-from-top-2 duration-200">
|
||||
<div className="md:col-span-6 space-y-2">
|
||||
<Label className="text-xs font-medium text-grey-1">快速時間區間</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{ label: "今日", value: "today" },
|
||||
{ label: "昨日", value: "yesterday" },
|
||||
{ label: "本週", value: "this_week" },
|
||||
{ label: "本月", value: "this_month" },
|
||||
{ label: "上月", value: "last_month" },
|
||||
].map((opt) => (
|
||||
<Button
|
||||
key={opt.value}
|
||||
size="sm"
|
||||
onClick={() => handleDateRangeChange(opt.value)}
|
||||
className={
|
||||
dateRangeType === opt.value
|
||||
? 'button-filled-primary h-9 px-4 shadow-sm'
|
||||
: 'button-outlined-primary h-9 px-4 bg-white'
|
||||
}
|
||||
>
|
||||
{opt.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-6">
|
||||
<div className="grid grid-cols-2 gap-4 items-end">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-grey-2 font-medium">開始日期</Label>
|
||||
<div className="relative">
|
||||
<Calendar className="absolute left-2.5 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
|
||||
<Input
|
||||
type="date"
|
||||
value={dateStart}
|
||||
onChange={(e) => {
|
||||
setDateStart(e.target.value);
|
||||
setDateRangeType('custom');
|
||||
}}
|
||||
className="pl-9 block w-full h-9 bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-grey-2 font-medium">結束日期</Label>
|
||||
<div className="relative">
|
||||
<Calendar className="absolute left-2.5 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
|
||||
<Input
|
||||
type="date"
|
||||
value={dateEnd}
|
||||
onChange={(e) => {
|
||||
setDateEnd(e.target.value);
|
||||
setDateRangeType('custom');
|
||||
}}
|
||||
className="pl-9 block w-full h-9 bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Actions Buttons Group */}
|
||||
<div className="md:col-span-3 flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClearFilters}
|
||||
className="flex-1 items-center gap-2 button-outlined-primary h-9"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
重置
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSearch}
|
||||
className="flex-1 button-filled-primary h-9 gap-2"
|
||||
>
|
||||
<Search className="h-4 w-4" /> 查詢
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center justify-end border-t border-grey-4 pt-5 gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowAdvancedFilter(!showAdvancedFilter)}
|
||||
className="mr-auto text-gray-500 hover:text-gray-900 h-9"
|
||||
>
|
||||
{showAdvancedFilter ? (
|
||||
<>
|
||||
<ChevronUp className="h-4 w-4 mr-1" />
|
||||
收合篩選
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-4 w-4 mr-1" />
|
||||
進階篩選
|
||||
{(dateStart || dateEnd) && (
|
||||
<span className="ml-2 w-2 h-2 rounded-full bg-primary-main" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClearFilters}
|
||||
className="flex items-center gap-2 button-outlined-primary h-9"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
重置
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSearch}
|
||||
className="button-filled-primary h-9 px-6 gap-2"
|
||||
>
|
||||
<Search className="h-4 w-4" /> 查詢
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -43,6 +43,7 @@ interface Batch {
|
||||
expiryDate: string | null;
|
||||
quantity: number;
|
||||
isDeleted?: boolean;
|
||||
location?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -322,7 +323,8 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
|
||||
inventoryId: item.inventoryId,
|
||||
originCountry: item.originCountry,
|
||||
expiryDate: item.expiryDate,
|
||||
unit_cost: item.unit_cost
|
||||
unit_cost: item.unit_cost,
|
||||
location: item.location,
|
||||
};
|
||||
})
|
||||
}, {
|
||||
@@ -575,17 +577,25 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
|
||||
batchMode: 'existing',
|
||||
inventoryId: value,
|
||||
originCountry: selectedBatch?.originCountry,
|
||||
expiryDate: selectedBatch?.expiryDate || undefined
|
||||
expiryDate: selectedBatch?.expiryDate || undefined,
|
||||
location: selectedBatch?.location || item.location,
|
||||
});
|
||||
}
|
||||
}}
|
||||
options={[
|
||||
{ label: "📦 不使用批號 (自動累加)", value: "no_batch" },
|
||||
{ label: "+ 建立新批號", value: "new_batch" },
|
||||
...(batchesCache[item.productId]?.batches || []).map(b => ({
|
||||
label: `${b.batchNumber === 'NO-BATCH' ? '(無批號紀錄)' : b.batchNumber} - 庫存: ${b.quantity}`,
|
||||
value: b.inventoryId
|
||||
}))
|
||||
...(batchesCache[item.productId]?.batches || []).map(b => {
|
||||
const isNoBatch = b.batchNumber === 'NO-BATCH';
|
||||
const showLocation = isNoBatch || warehouse.type === 'vending';
|
||||
const locationInfo = (showLocation && b.location) ? ` [${b.location}]` : '';
|
||||
const batchLabel = isNoBatch ? '(無批號紀錄)' : b.batchNumber;
|
||||
|
||||
return {
|
||||
label: `${batchLabel}${locationInfo} - 庫存: ${b.quantity}`,
|
||||
value: b.inventoryId
|
||||
};
|
||||
})
|
||||
]}
|
||||
placeholder="選擇或新增批號"
|
||||
className="border-gray-300"
|
||||
|
||||
Reference in New Issue
Block a user