修正庫存報表分頁參數衝突導致明細顯示為空的問題

This commit is contained in:
2026-02-10 16:07:31 +08:00
parent 8b950f6529
commit 593ce94734
15 changed files with 1210 additions and 30 deletions

View File

@@ -184,6 +184,7 @@ class RoleController extends Controller
'inventory_count' => '庫存盤點管理', 'inventory_count' => '庫存盤點管理',
'inventory_adjust' => '庫存盤調管理', 'inventory_adjust' => '庫存盤調管理',
'inventory_transfer' => '庫存調撥管理', 'inventory_transfer' => '庫存調撥管理',
'inventory_report' => '庫存報表',
'vendors' => '廠商資料管理', 'vendors' => '廠商資料管理',
'purchase_orders' => '採購單管理', 'purchase_orders' => '採購單管理',
'goods_receipts' => '進貨單管理', 'goods_receipts' => '進貨單管理',

View File

@@ -106,8 +106,8 @@ class InventoryController extends Controller
'batchNumber' => $inv->batch_number ?? 'BATCH-' . $inv->id, 'batchNumber' => $inv->batch_number ?? 'BATCH-' . $inv->id,
'location' => $inv->location, 'location' => $inv->location,
'expiryDate' => $inv->expiry_date ? $inv->expiry_date->format('Y-m-d') : null, '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, '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 ? $inv->lastOutgoingTransaction->actual_time->format('Y-m-d') : $inv->lastOutgoingTransaction->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(), })->values(),
]; ];
@@ -360,7 +360,7 @@ class InventoryController extends Controller
'balanceAfter' => (float) $tx->balance_after, 'balanceAfter' => (float) $tx->balance_after,
'reason' => $tx->reason, 'reason' => $tx->reason,
'userName' => $user ? $user->name : '系統', // 手動對應 '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'),
]; ];
}); });
@@ -554,7 +554,7 @@ class InventoryController extends Controller
'balanceAfter' => (float) $balanceAfter, // 顯示該商品在倉庫的累計結餘 'balanceAfter' => (float) $balanceAfter, // 顯示該商品在倉庫的累計結餘
'reason' => $tx->reason, 'reason' => $tx->reason,
'userName' => $user ? $user->name : '系統', '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 ?? '-', // 補上批號資訊 'batchNumber' => $tx->inventory?->batch_number ?? '-', // 補上批號資訊
'slot' => $tx->inventory?->location, // 加入貨道資訊 'slot' => $tx->inventory?->location, // 加入貨道資訊
]; ];
@@ -597,7 +597,7 @@ class InventoryController extends Controller
'balanceAfter' => (float) $tx->balance_after, 'balanceAfter' => (float) $tx->balance_after,
'reason' => $tx->reason, 'reason' => $tx->reason,
'userName' => $user ? $user->name : '系統', // 手動對應 '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, // 加入貨道資訊 'slot' => $inventory->location, // 加入貨道資訊
]; ];
}); });

View File

@@ -0,0 +1,86 @@
<?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'
]);
$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(),
]);
}
}

View File

@@ -0,0 +1,61 @@
<?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->adjust_qty,
$row->net_change,
];
}
public function styles(Worksheet $sheet)
{
return [
1 => ['font' => ['bold' => true]],
];
}
}

View File

@@ -26,7 +26,9 @@ class InventoryTransaction extends Model
]; ];
protected $casts = [ protected $casts = [
'actual_time' => 'datetime', // actual_time 不做時區轉換,保留原始字串格式(台北時間)
// 原因:資料庫儲存的是台北時間,但 MySQL 時區為 UTC
// 若使用 datetime castLaravel 會誤當作 UTC 再轉回台北時間,造成偏移
'unit_cost' => 'decimal:4', 'unit_cost' => 'decimal:4',
]; ];

View File

@@ -11,6 +11,8 @@ use App\Modules\Inventory\Controllers\TransferOrderController;
use App\Modules\Inventory\Controllers\CountDocController; use App\Modules\Inventory\Controllers\CountDocController;
use App\Modules\Inventory\Controllers\AdjustDocController; use App\Modules\Inventory\Controllers\AdjustDocController;
use App\Modules\Inventory\Controllers\InventoryReportController;
use App\Modules\Inventory\Controllers\StockQueryController; use App\Modules\Inventory\Controllers\StockQueryController;
Route::middleware('auth')->group(function () { Route::middleware('auth')->group(function () {
@@ -20,6 +22,16 @@ Route::middleware('auth')->group(function () {
Route::get('/inventory/stock-query', [StockQueryController::class, 'index'])->name('inventory.stock-query.index'); 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::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::middleware('permission:products.view')->group(function () {
Route::get('/categories', [CategoryController::class, 'index'])->name('categories.index'); Route::get('/categories', [CategoryController::class, 'index'])->name('categories.index');

View File

@@ -0,0 +1,228 @@
<?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;
// 若無任何篩選條件,直接回傳空資料
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 IN ('庫存調整', '手動編輯') THEN inventory_transactions.quantity ELSE 0 END) as adjust_qty"),
// 調撥淨額 (隱藏欄位,但包含在 net_change)
// 淨變動:總和 (包含所有類型:進貨、出貨、調整、調撥)
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'
]);
$query->orderBy('products.code', '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 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();
}
}

View File

@@ -385,10 +385,11 @@ class InventoryService implements InventoryServiceInterface
}) })
->count(); ->count();
// 4. 即將過期明細數 // 4. 即將過期明細數 (必須排除已過期)
$expiringCount = DB::table('inventories') $expiringCount = DB::table('inventories')
->whereNull('deleted_at') ->whereNull('deleted_at')
->whereNotNull('expiry_date') ->whereNotNull('expiry_date')
->where('expiry_date', '>', $today)
->where('expiry_date', '<=', $expiryThreshold) ->where('expiry_date', '<=', $expiryThreshold)
->count(); ->count();
@@ -467,34 +468,46 @@ class InventoryService implements InventoryServiceInterface
$today = now()->toDateString(); $today = now()->toDateString();
$expiryThreshold = now()->addDays(30)->toDateString(); $expiryThreshold = now()->addDays(30)->toDateString();
// 1. 庫存品項數 (Unique Product-Warehouse) // 1. 庫存品項數 (明細總數)
$totalItems = DB::table('inventories') $totalItems = DB::table('inventories')
->whereNull('deleted_at') ->whereNull('deleted_at')
->distinct()
->count(DB::raw('CONCAT(warehouse_id, "-", product_id)'));
// 2. 低庫存 (品項計數)
$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')
->count(); ->count();
// 3. 庫存 (品項計數) // 2. 庫存 (明細計數:只要該明細所屬的「倉庫+商品」總量低於安全庫存,則所有相關明細都計入)
$negativeCount = DB::table(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')) $lowStockCount = DB::table('inventories as i')
->where('total_qty', '<', 0) ->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(); ->count();
// 4. 即將過期 (品項計數) // 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') $expiringCount = DB::table('inventories')
->whereNull('deleted_at') ->whereNull('deleted_at')
->whereNotNull('expiry_date') ->whereNotNull('expiry_date')
->where('expiry_date', '>', $today) // 確保不過期 (getStockQueryData 沒加這個但這裡加上以防與 expired 混淆? 不stock query 是 > today && <= threshold)
->where('expiry_date', '<=', $expiryThreshold) ->where('expiry_date', '<=', $expiryThreshold)
->distinct() ->count();
->count(DB::raw('CONCAT(warehouse_id, "-", product_id)'));
// 異常庫存前 10 筆 (明細面依然以個別批次為主,供快速跳轉) // 異常庫存前 10 筆 (明細面依然以個別批次為主,供快速跳轉)
$abnormalItems = Inventory::query() $abnormalItems = Inventory::query()

View File

@@ -56,6 +56,10 @@ class PermissionSeeder extends Seeder
'inventory_transfer.edit' => '編輯', 'inventory_transfer.edit' => '編輯',
'inventory_transfer.delete' => '刪除', 'inventory_transfer.delete' => '刪除',
// 庫存報表
'inventory_report.view' => '檢視',
'inventory_report.export' => '匯出',
// 進貨單管理 // 進貨單管理
'goods_receipts.view' => '檢視', 'goods_receipts.view' => '檢視',
'goods_receipts.create' => '建立', 'goods_receipts.create' => '建立',
@@ -153,6 +157,7 @@ class PermissionSeeder extends Seeder
'inventory_count.view', 'inventory_count.create', 'inventory_count.edit', 'inventory_count.delete', 'inventory_count.view', 'inventory_count.create', 'inventory_count.edit', 'inventory_count.delete',
'inventory_adjust.view', 'inventory_adjust.create', 'inventory_adjust.edit', 'inventory_adjust.delete', 'inventory_adjust.view', 'inventory_adjust.create', 'inventory_adjust.edit', 'inventory_adjust.delete',
'inventory_transfer.view', 'inventory_transfer.create', 'inventory_transfer.edit', 'inventory_transfer.delete', 'inventory_transfer.view', 'inventory_transfer.create', 'inventory_transfer.edit', 'inventory_transfer.delete',
'inventory_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',
'delivery_notes.view', 'delivery_notes.create', 'delivery_notes.edit', 'delivery_notes.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', '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_count.view', 'inventory_count.create', 'inventory_count.edit', 'inventory_count.delete',
'inventory_adjust.view', 'inventory_adjust.create', 'inventory_adjust.edit', 'inventory_adjust.delete', 'inventory_adjust.view', 'inventory_adjust.create', 'inventory_adjust.edit', 'inventory_adjust.delete',
'inventory_transfer.view', 'inventory_transfer.create', 'inventory_transfer.edit', 'inventory_transfer.delete', 'inventory_transfer.view', 'inventory_transfer.create', 'inventory_transfer.edit', 'inventory_transfer.delete',
'inventory_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', 'goods_receipts.view', 'goods_receipts.create', 'goods_receipts.edit', 'goods_receipts.delete',
'production_orders.view', 'production_orders.create', 'production_orders.edit', 'production_orders.view', 'production_orders.create', 'production_orders.edit',
'warehouses.view', 'warehouses.create', 'warehouses.edit', 'warehouses.view', 'warehouses.create', 'warehouses.edit',
@@ -197,6 +204,7 @@ class PermissionSeeder extends Seeder
'vendors.view', 'vendors.view',
'warehouses.view', 'warehouses.view',
'utility_fees.view', 'utility_fees.view',
'inventory_report.view',
'accounting.view', 'accounting.view',
]); ]);

43
package-lock.json generated
View File

@@ -21,6 +21,7 @@
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@types/lodash": "^4.17.21", "@types/lodash": "^4.17.21",
"@vitejs/plugin-react": "^5.1.2", "@vitejs/plugin-react": "^5.1.2",
@@ -78,6 +79,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5", "@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": { "node_modules/@radix-ui/react-tooltip": {
"version": "1.2.8", "version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
@@ -2647,6 +2679,7 @@
"integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~7.16.0" "undici-types": "~7.16.0"
} }
@@ -2657,6 +2690,7 @@
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@@ -2667,6 +2701,7 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
@@ -2774,6 +2809,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@@ -2986,7 +3022,8 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/date-fns": { "node_modules/date-fns": {
"version": "4.1.0", "version": "4.1.0",
@@ -3865,6 +3902,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -3926,6 +3964,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
}, },
@@ -3938,6 +3977,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0", "loose-envify": "^1.1.0",
"scheduler": "^0.23.2" "scheduler": "^0.23.2"
@@ -4430,6 +4470,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.27.0", "esbuild": "^0.27.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",

View File

@@ -35,6 +35,7 @@
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@types/lodash": "^4.17.21", "@types/lodash": "^4.17.21",
"@vitejs/plugin-react": "^5.1.2", "@vitejs/plugin-react": "^5.1.2",

View File

@@ -224,7 +224,7 @@ export default function AuthenticatedLayout({
id: "report-management", id: "report-management",
label: "報表管理", label: "報表管理",
icon: <BarChart3 className="h-5 w-5" />, icon: <BarChart3 className="h-5 w-5" />,
permission: "accounting.view", permission: ["accounting.view", "inventory_report.view"],
children: [ children: [
{ {
id: "accounting-report", id: "accounting-report",
@@ -233,6 +233,13 @@ export default function AuthenticatedLayout({
route: "/accounting-report", route: "/accounting-report",
permission: "accounting.view", permission: "accounting.view",
}, },
{
id: "inventory-report",
label: "庫存報表",
icon: <BarChart3 className="h-4 w-4" />,
route: "/inventory/report",
permission: "inventory_report.view",
},
], ],
}, },
{ {

View File

@@ -63,7 +63,7 @@ const statusConfig: Record<string, { label: string; className: string }> = {
export default function Dashboard({ stats, abnormalItems }: Props) { export default function Dashboard({ stats, abnormalItems }: Props) {
const cards = [ const cards = [
{ {
label: "庫存品項數", label: "庫存明細數",
value: stats.totalItems, value: stats.totalItems,
icon: <Package className="h-6 w-6" />, icon: <Package className="h-6 w-6" />,
color: "text-primary-main", color: "text-primary-main",
@@ -218,8 +218,8 @@ export default function Dashboard({ stats, abnormalItems }: Props) {
</TableCell> </TableCell>
<TableCell <TableCell
className={`text-right font-medium ${item.quantity < 0 className={`text-right font-medium ${item.quantity < 0
? "text-red-600" ? "text-red-600"
: "" : ""
}`} }`}
> >
{item.quantity} {item.quantity}

View File

@@ -0,0 +1,472 @@
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
} 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";
interface ReportData {
product_code: string;
product_name: string;
category_name: string;
product_id: number;
inbound_qty: number;
outbound_qty: number;
adjust_qty: number;
net_change: number;
}
interface SummaryData {
total_inbound: number;
total_outbound: 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;
};
}
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 }
);
}, [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 }
);
};
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,
};
window.location.href = route("inventory.report.export", query);
};
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-4 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-4 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-4 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>
</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>
</div>
</div>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="flex items-center gap-3 px-4 py-4 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-6 w-6 text-emerald-500 shrink-0" />
<div className="flex flex-1 items-baseline justify-between gap-2 min-w-0">
<span className="text-sm text-gray-500 font-medium shrink-0"></span>
<span className="text-xl font-bold text-gray-900 truncate">{Number(summary?.total_inbound || 0).toLocaleString()}</span>
</div>
</div>
<div className="flex items-center gap-3 px-4 py-4 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-6 w-6 text-red-500 shrink-0" />
<div className="flex flex-1 items-baseline justify-between gap-2 min-w-0">
<span className="text-sm text-gray-500 font-medium shrink-0"></span>
<span className="text-xl font-bold text-gray-900 truncate">{Number(summary?.total_outbound || 0).toLocaleString()}</span>
</div>
</div>
<div className="flex items-center gap-3 px-4 py-4 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-6 w-6 text-blue-500 shrink-0" />
<div className="flex flex-1 items-baseline justify-between gap-2 min-w-0">
<span className="text-sm text-gray-500 font-medium shrink-0">調</span>
<span className="text-xl font-bold text-gray-900 truncate">{Number(summary?.total_adjust || 0).toLocaleString()}</span>
</div>
</div>
<div className="flex items-center gap-3 px-4 py-4 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-6 w-6 text-gray-700 shrink-0" />
<div className="flex flex-1 items-baseline justify-between gap-2 min-w-0">
<span className="text-sm text-gray-500 font-medium shrink-0"></span>
<span className={`text-xl font-bold truncate ${summary?.total_net_change >= 0 ? "text-emerald-600" : "text-red-600"}`}>
{summary?.total_net_change > 0 ? "+" : ""}{Number(summary?.total_net_change || 0).toLocaleString()}
</span>
</div>
</div>
</div>
{/* 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 className=""></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="text-right w-[100px] text-emerald-600"></TableHead>
<TableHead className="text-right w-[100px] text-red-600"></TableHead>
<TableHead className="text-right w-[100px] text-blue-600">調</TableHead>
<TableHead className="text-right w-[100px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{reportData.data.length === 0 ? (
<TableRow>
<TableCell colSpan={7}>
<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-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>
);
}

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