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