Compare commits

...

7 Commits

29 changed files with 3159 additions and 571 deletions

View File

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

View File

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

View File

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

View File

@@ -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, // 加入貨道資訊
];
});

View File

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

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

View 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]],
];
}
}

View 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]],
];
}
}

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

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

View File

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

View File

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

View File

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