diff --git a/.agent/skills/ui-consistency/SKILL.md b/.agent/skills/ui-consistency/SKILL.md
index db63ef8..0f6069b 100644
--- a/.agent/skills/ui-consistency/SKILL.md
+++ b/.agent/skills/ui-consistency/SKILL.md
@@ -1160,3 +1160,58 @@ import { Pencil } from 'lucide-react';
- **取消按鈕**:`variant="outline"`,且為 `button-outlined-primary`。
- **提交按鈕**:`button-filled-primary`,且在處理中時顯示 `Loader2`。
+
+---
+
+## 16. 詳情頁面項目清單規範 (Detail Page Item List Standards)
+
+為了確保詳情頁面(如:採購單詳情、進貨單詳情、銷售匯入詳情)的資訊層級清晰且視覺統一,所有項目清單必須遵循以下規範。
+
+### 16.1 容器結構 (Container Structure)
+
+項目清單應封裝在一個帶有內距的卡片容器中,而不是讓表格直接緊貼外層卡片邊緣。
+
+1. **外層卡片**:`bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden`
+2. **標題區塊**:`p-6 border-b border-gray-100 bg-gray-50/30`
+3. **內容內距**:標題下方的內容區塊應加上 `p-6`。
+4. **表格包裹層**:表格應再包裹一層 `border rounded-lg overflow-hidden`,以確保表格內部的邊角與隔線視覺完整。
+
+```tsx
+
+ {/* 標題 */}
+
+
項目清單標題
+
+
+ {/* 內容區塊 */}
+
+
+
+
+
+ {/* 標頭欄位 */}
+
+
+
+ {/* 表格內容 */}
+
+
+
+
+ {/* 若有分頁,直接放在 p-6 容器內,並加 mt-6 分隔 */}
+
+
+
+```
+
+### 16.2 表格樣式細節 (Table Styling)
+
+1. **標頭背景**:`TableHeader` 的第一個 `TableRow` 應使用 `bg-gray-50 hover:bg-gray-50` 強化視覺區隔。
+2. **文字顏色**:主體文字使用 `text-gray-900`(標題/重要數據)或 `text-gray-500`(輔助/序號)。
+3. **數據對齊**:
+ * **數量/序號**:文字置中 (`text-center`) 或依據數據類型對齊。
+ * **金額**:金額欄位必須使用 `text-right` 並視情況加粗 (`font-bold`) 或加上 `text-primary-main` 顏色。
+4. **表格隔線**:確保表格具有清晰但不過於突出的水平隔線,提升長列表的可讀性。
+
diff --git a/app/Modules/Inventory/Contracts/InventoryServiceInterface.php b/app/Modules/Inventory/Contracts/InventoryServiceInterface.php
index 2bf631a..30ae0de 100644
--- a/app/Modules/Inventory/Contracts/InventoryServiceInterface.php
+++ b/app/Modules/Inventory/Contracts/InventoryServiceInterface.php
@@ -15,16 +15,15 @@ interface InventoryServiceInterface
public function checkStock(int $productId, int $warehouseId, float $quantity): bool;
/**
- * Decrease stock for a product (e.g., when an order is placed).
- *
* @param int $productId
* @param int $warehouseId
* @param float $quantity
* @param string|null $reason
* @param bool $force
+ * @param string|null $slot
* @return void
*/
- public function decreaseStock(int $productId, int $warehouseId, float $quantity, ?string $reason = null, bool $force = false): void;
+ public function decreaseStock(int $productId, int $warehouseId, float $quantity, ?string $reason = null, bool $force = false, ?string $slot = null): void;
/**
* Get all active warehouses.
diff --git a/app/Modules/Inventory/Controllers/InventoryController.php b/app/Modules/Inventory/Controllers/InventoryController.php
index e318b6e..aefc5ce 100644
--- a/app/Modules/Inventory/Controllers/InventoryController.php
+++ b/app/Modules/Inventory/Controllers/InventoryController.php
@@ -555,6 +555,7 @@ class InventoryController extends Controller
'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'),
'batchNumber' => $tx->inventory?->batch_number ?? '-', // 補上批號資訊
+ 'slot' => $tx->inventory?->location, // 加入貨道資訊
];
});
@@ -585,7 +586,7 @@ class InventoryController extends Controller
$userIds = $inventory->transactions->pluck('user_id')->filter()->unique()->toArray();
$users = $this->coreService->getUsersByIds($userIds)->keyBy('id');
- $transactions = $inventory->transactions->map(function ($tx) use ($users) {
+ $transactions = $inventory->transactions->map(function ($tx) use ($users, $inventory) {
$user = $tx->user_id ? ($users[$tx->user_id] ?? null) : null;
return [
'id' => (string) $tx->id,
@@ -596,6 +597,7 @@ class InventoryController extends Controller
'reason' => $tx->reason,
'userName' => $user ? $user->name : '系統', // 手動對應
'actualTime' => $tx->actual_time ? $tx->actual_time->format('Y-m-d H:i') : $tx->created_at->format('Y-m-d H:i'),
+ 'slot' => $inventory->location, // 加入貨道資訊
];
});
diff --git a/app/Modules/Inventory/Services/InventoryService.php b/app/Modules/Inventory/Services/InventoryService.php
index ece5d17..3b57abb 100644
--- a/app/Modules/Inventory/Services/InventoryService.php
+++ b/app/Modules/Inventory/Services/InventoryService.php
@@ -59,13 +59,18 @@ class InventoryService implements InventoryServiceInterface
return $stock >= $quantity;
}
- public function decreaseStock(int $productId, int $warehouseId, float $quantity, ?string $reason = null, bool $force = false): void
+ public function decreaseStock(int $productId, int $warehouseId, float $quantity, ?string $reason = null, bool $force = false, ?string $slot = null): void
{
- DB::transaction(function () use ($productId, $warehouseId, $quantity, $reason, $force) {
- $inventories = Inventory::where('product_id', $productId)
+ DB::transaction(function () use ($productId, $warehouseId, $quantity, $reason, $force, $slot) {
+ $query = Inventory::where('product_id', $productId)
->where('warehouse_id', $warehouseId)
- ->where('quantity', '>', 0)
- ->orderBy('arrival_date', 'asc')
+ ->where('quantity', '>', 0);
+
+ if ($slot) {
+ $query->where('location', $slot);
+ }
+
+ $inventories = $query->orderBy('arrival_date', 'asc')
->get();
$remainingToDecrease = $quantity;
@@ -80,19 +85,25 @@ class InventoryService implements InventoryServiceInterface
if ($remainingToDecrease > 0) {
if ($force) {
- // Find any existing inventory record in this warehouse to subtract from, or create one
- $inventory = Inventory::where('product_id', $productId)
- ->where('warehouse_id', $warehouseId)
- ->first();
+ // Find any existing inventory record in this warehouse/slot to subtract from, or create one
+ $query = Inventory::where('product_id', $productId)
+ ->where('warehouse_id', $warehouseId);
+
+ if ($slot) {
+ $query->where('location', $slot);
+ }
+
+ $inventory = $query->first();
if (!$inventory) {
$inventory = Inventory::create([
'warehouse_id' => $warehouseId,
'product_id' => $productId,
+ 'location' => $slot,
'quantity' => 0,
'unit_cost' => 0,
'total_value' => 0,
- 'batch_number' => 'POS-AUTO-' . time(),
+ 'batch_number' => 'POS-AUTO-' . ($slot ? $slot . '-' : '') . time(),
'arrival_date' => now(),
'origin_country' => 'TW',
'quality_status' => 'normal',
diff --git a/app/Modules/Sales/Controllers/SalesImportController.php b/app/Modules/Sales/Controllers/SalesImportController.php
new file mode 100644
index 0000000..7c61aab
--- /dev/null
+++ b/app/Modules/Sales/Controllers/SalesImportController.php
@@ -0,0 +1,152 @@
+input('per_page', 10);
+
+ $batches = SalesImportBatch::with('importer')
+ ->orderByDesc('created_at')
+ ->paginate($perPage)
+ ->withQueryString();
+
+ return Inertia::render('Sales/Import/Index', [
+ 'batches' => $batches,
+ 'filters' => [
+ 'per_page' => $perPage,
+ ],
+ ]);
+ }
+
+ public function create()
+ {
+ return Inertia::render('Sales/Import/Create');
+ }
+
+ public function store(Request $request)
+ {
+ $request->validate([
+ 'file' => 'required|file|mimes:xlsx,xls,csv,zip',
+ ]);
+
+ DB::transaction(function () use ($request) {
+ $batch = SalesImportBatch::create([
+ 'import_date' => now(),
+ 'imported_by' => auth()->id(),
+ 'status' => 'pending',
+ 'tenant_id' => tenant('id'), // If tenant context requires it, but usually automatic
+ ]);
+
+ Excel::import(new SalesImport($batch), $request->file('file'));
+ });
+
+ return redirect()->route('sales-imports.index')->with('success', '匯入成功,請確認內容。');
+ }
+
+ public function show(Request $request, SalesImportBatch $import)
+ {
+ $import->load(['items', 'importer']);
+
+ $perPage = $request->input('per_page', 10);
+
+ return Inertia::render('Sales/Import/Show', [
+ 'import' => $import,
+ 'items' => $import->items()->with(['product', 'warehouse'])->paginate($perPage)->withQueryString(),
+ 'filters' => [
+ 'per_page' => $perPage,
+ ],
+ ]);
+ }
+
+ public function confirm(SalesImportBatch $import, InventoryService $inventoryService)
+ {
+ if ($import->status !== 'pending') {
+ return back()->with('error', '此批次無法確認。');
+ }
+
+ DB::transaction(function () use ($import, $inventoryService) {
+ // 1. Prepare Aggregation
+ $aggregatedDeductions = []; // Key: "warehouse_id:product_id:slot"
+
+ // Pre-load necessary warehouses for matching
+ $machineIds = $import->items->pluck('machine_id')->filter()->unique();
+ $warehouses = \App\Modules\Inventory\Models\Warehouse::whereIn('code', $machineIds)->get()->keyBy('code');
+
+ foreach ($import->items as $item) {
+ // Only process shipped items with a valid product
+ if ($item->product_id && $item->original_status === '已出貨') {
+ // Resolve Warehouse from Machine ID
+ $warehouse = $warehouses->get($item->machine_id);
+
+ // Skip if machine_id is empty or warehouse not found
+ if (!$warehouse) {
+ continue;
+ }
+
+ // Aggregation Key includes Slot (貨道)
+ $slot = $item->slot ?: '';
+ $key = "{$warehouse->id}:{$item->product_id}:{$slot}";
+
+ if (!isset($aggregatedDeductions[$key])) {
+ $aggregatedDeductions[$key] = [
+ 'warehouse_id' => $warehouse->id,
+ 'product_id' => $item->product_id,
+ 'slot' => $slot,
+ 'quantity' => 0,
+ 'details' => []
+ ];
+ }
+
+ $aggregatedDeductions[$key]['quantity'] += $item->quantity;
+ $aggregatedDeductions[$key]['details'][] = $item->transaction_serial;
+ }
+ }
+
+ // 2. Execute Aggregated Deductions
+ foreach ($aggregatedDeductions as $deduction) {
+ // Construct a descriptive reason
+ $serialCount = count($deduction['details']);
+ $reason = "銷售出貨彙總 (批號: {$import->id}, 貨道: {$deduction['slot']}, 共 {$serialCount} 筆交易)";
+
+ $inventoryService->decreaseStock(
+ $deduction['product_id'],
+ $deduction['warehouse_id'],
+ $deduction['quantity'],
+ $reason,
+ true, // Force deduction
+ $deduction['slot'] // Location/Slot
+ );
+ }
+
+ // 3. Update Batch Status
+ $import->update([
+ 'status' => 'confirmed',
+ 'confirmed_at' => now(),
+ ]);
+ });
+
+ return redirect()->route('sales-imports.index')->with('success', '已彙總(含貨道)並扣除庫存。');
+ }
+
+ public function destroy(SalesImportBatch $import)
+ {
+ if ($import->status !== 'pending') {
+ return back()->with('error', '只能刪除待確認的批次。');
+ }
+
+ $import->delete();
+ return redirect()->route('sales-imports.index')->with('success', '已刪除匯入批次。');
+ }
+}
diff --git a/app/Modules/Sales/Imports/SalesImport.php b/app/Modules/Sales/Imports/SalesImport.php
new file mode 100644
index 0000000..67fd5aa
--- /dev/null
+++ b/app/Modules/Sales/Imports/SalesImport.php
@@ -0,0 +1,24 @@
+batch = $batch;
+ }
+
+ public function sheets(): array
+ {
+ // Only import the first sheet (index 0)
+ return [
+ 0 => new SalesImportSheet($this->batch),
+ ];
+ }
+}
diff --git a/app/Modules/Sales/Imports/SalesImportSheet.php b/app/Modules/Sales/Imports/SalesImportSheet.php
new file mode 100644
index 0000000..2102117
--- /dev/null
+++ b/app/Modules/Sales/Imports/SalesImportSheet.php
@@ -0,0 +1,106 @@
+batch = $batch;
+ // Pre-load all products to minimize queries (keyed by code)
+ $this->products = Product::pluck('id', 'code'); // assumes code is unique
+ }
+
+ public function startRow(): int
+ {
+ return 3;
+ }
+
+ public function collection(Collection $rows)
+ {
+ $totalQuantity = 0;
+ $totalAmount = 0;
+ $items = [];
+
+ foreach ($rows as $row) {
+ // Index mapping based on analysis:
+ // 0: 銷貨單號 (Serial)
+ // 1: 機台編號 (Machine ID)
+ // 4: 訂單狀態 (Original Status)
+ // 7: 產品代號 (Product Code)
+ // 9: 銷貨日期 (Transaction At)
+ // 11: 金額 (Amount)
+ // 19: 貨道 (Slot)
+ // Quantity default to 1
+
+ $serial = $row[0];
+ $machineId = $row[1];
+ $originalStatus = $row[4];
+ $productCode = $row[7];
+ $transactionAt = $row[9];
+ $amount = $row[11];
+ $slot = $row[19] ?? null;
+
+ // Skip empty rows
+ if (empty($serial) && empty($productCode)) {
+ continue;
+ }
+
+ // Parse Date
+ try {
+ // Formatting might be needed depending on Excel date format
+ $transactionAt = Carbon::parse($transactionAt);
+ } catch (\Exception $e) {
+ $transactionAt = now();
+ }
+
+ $quantity = 1; // Default
+
+ // Clean amount (remove comma etc if needed)
+ $amount = is_numeric($amount) ? $amount : 0;
+
+ $items[] = [
+ 'batch_id' => $this->batch->id,
+ 'machine_id' => $machineId,
+ 'slot' => $slot,
+ 'product_code' => $productCode,
+ 'product_id' => $this->products[$productCode] ?? null,
+ 'transaction_at' => $transactionAt,
+ 'transaction_serial' => $serial,
+ 'quantity' => (int)$quantity,
+ 'amount' => $amount,
+ 'original_status' => $originalStatus,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ];
+
+ $totalQuantity += $quantity;
+ $totalAmount += $amount;
+ }
+
+ // Bulk insert items (chunk if necessary, but assuming reasonable size)
+ foreach (array_chunk($items, 1000) as $chunk) {
+ SalesImportItem::insert($chunk);
+ }
+
+ // Update Batch Totals
+ // Increment totals instead of overwriting, in case we decide to process multiple sheets later?
+ // But for now, since we only process sheet 0, overwriting or incrementing is fine.
+ // Given we strictly return [0 => ...], only one sheet runs.
+ $this->batch->update([
+ 'total_quantity' => $totalQuantity,
+ 'total_amount' => $totalAmount,
+ ]);
+ }
+}
diff --git a/app/Modules/Sales/Models/SalesImportBatch.php b/app/Modules/Sales/Models/SalesImportBatch.php
new file mode 100644
index 0000000..25370a4
--- /dev/null
+++ b/app/Modules/Sales/Models/SalesImportBatch.php
@@ -0,0 +1,43 @@
+ 'date',
+ 'confirmed_at' => 'datetime',
+ 'total_quantity' => 'decimal:4',
+ 'total_amount' => 'decimal:4',
+ ];
+
+ public function items(): HasMany
+ {
+ return $this->hasMany(SalesImportItem::class, 'batch_id');
+ }
+
+ public function importer(): BelongsTo
+ {
+ return $this->belongsTo(User::class, 'imported_by');
+ }
+}
diff --git a/app/Modules/Sales/Models/SalesImportItem.php b/app/Modules/Sales/Models/SalesImportItem.php
new file mode 100644
index 0000000..da08811
--- /dev/null
+++ b/app/Modules/Sales/Models/SalesImportItem.php
@@ -0,0 +1,51 @@
+ 'datetime',
+ 'quantity' => 'integer',
+ 'amount' => 'decimal:4',
+ 'original_status' => 'string',
+ ];
+
+ public function batch(): BelongsTo
+ {
+ return $this->belongsTo(SalesImportBatch::class, 'batch_id');
+ }
+
+ public function product(): BelongsTo
+ {
+ return $this->belongsTo(Product::class, 'product_id');
+ }
+
+ public function warehouse(): BelongsTo
+ {
+ return $this->belongsTo(Warehouse::class, 'machine_id', 'code');
+ }
+}
diff --git a/app/Modules/Sales/Routes/web.php b/app/Modules/Sales/Routes/web.php
new file mode 100644
index 0000000..dae5c65
--- /dev/null
+++ b/app/Modules/Sales/Routes/web.php
@@ -0,0 +1,13 @@
+prefix('sales')->name('sales-imports.')->group(function () {
+ Route::get('/imports', [SalesImportController::class, 'index'])->name('index');
+ Route::get('/imports/create', [SalesImportController::class, 'create'])->name('create');
+ Route::post('/imports', [SalesImportController::class, 'store'])->name('store');
+ Route::get('/imports/{import}', [SalesImportController::class, 'show'])->name('show');
+ Route::post('/imports/{import}/confirm', [SalesImportController::class, 'confirm'])->name('confirm');
+ Route::delete('/imports/{import}', [SalesImportController::class, 'destroy'])->name('destroy');
+});
diff --git a/database/migrations/tenant/2026_02_06_101512_create_personal_access_tokens_table.php b/database/migrations/tenant/2026_02_06_101512_create_personal_access_tokens_table.php
index 40ff706..3a29a6c 100644
--- a/database/migrations/tenant/2026_02_06_101512_create_personal_access_tokens_table.php
+++ b/database/migrations/tenant/2026_02_06_101512_create_personal_access_tokens_table.php
@@ -11,16 +11,18 @@ return new class extends Migration
*/
public function up(): void
{
- Schema::create('personal_access_tokens', function (Blueprint $table) {
- $table->id();
- $table->morphs('tokenable');
- $table->text('name');
- $table->string('token', 64)->unique();
- $table->text('abilities')->nullable();
- $table->timestamp('last_used_at')->nullable();
- $table->timestamp('expires_at')->nullable()->index();
- $table->timestamps();
- });
+ if (!Schema::hasTable('personal_access_tokens')) {
+ Schema::create('personal_access_tokens', function (Blueprint $table) {
+ $table->id();
+ $table->morphs('tokenable');
+ $table->text('name');
+ $table->string('token', 64)->unique();
+ $table->text('abilities')->nullable();
+ $table->timestamp('last_used_at')->nullable();
+ $table->timestamp('expires_at')->nullable()->index();
+ $table->timestamps();
+ });
+ }
}
/**
diff --git a/database/migrations/tenant/2026_02_09_114137_create_sales_import_tables.php b/database/migrations/tenant/2026_02_09_114137_create_sales_import_tables.php
new file mode 100644
index 0000000..82cf52e
--- /dev/null
+++ b/database/migrations/tenant/2026_02_09_114137_create_sales_import_tables.php
@@ -0,0 +1,52 @@
+id();
+ $table->date('import_date')->default(now());
+ $table->decimal('total_quantity', 12, 4)->default(0);
+ $table->decimal('total_amount', 12, 4)->default(0);
+ $table->string('status')->default('pending')->comment('pending, confirmed, cancelled');
+ $table->foreignId('imported_by')->nullable()->constrained('users')->nullOnDelete();
+ $table->timestamp('confirmed_at')->nullable();
+ $table->text('note')->nullable();
+ $table->timestamps();
+ });
+
+ Schema::create('sales_import_items', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('batch_id')->constrained('sales_import_batches')->cascadeOnDelete();
+ $table->string('machine_id')->nullable()->comment('機台編號');
+ $table->string('product_code')->comment('商品代碼');
+ // product_id could be null if product not found at import time, but we should try to link it.
+ // Constraint might fail if product doesn't exist, so maybe just foreignId without constrained first,
+ // or nullable constrained. Since we might import data for products not yet in system?
+ // Better to allow null product_id.
+ $table->foreignId('product_id')->nullable()->constrained('products')->nullOnDelete();
+ $table->timestamp('transaction_at')->nullable()->comment('交易時間');
+ $table->string('transaction_serial')->nullable()->comment('交易序號');
+ $table->decimal('quantity', 12, 4)->default(0);
+ $table->decimal('amount', 12, 4)->default(0);
+ $table->timestamps();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('sales_import_items');
+ Schema::dropIfExists('sales_import_batches');
+ }
+};
diff --git a/database/migrations/tenant/2026_02_09_132944_add_original_status_to_sales_import_items.php b/database/migrations/tenant/2026_02_09_132944_add_original_status_to_sales_import_items.php
new file mode 100644
index 0000000..7975cdb
--- /dev/null
+++ b/database/migrations/tenant/2026_02_09_132944_add_original_status_to_sales_import_items.php
@@ -0,0 +1,28 @@
+string('original_status')->nullable()->after('amount')->comment('原始檔案狀態');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('sales_import_items', function (Blueprint $table) {
+ $table->dropColumn('original_status');
+ });
+ }
+};
diff --git a/database/migrations/tenant/2026_02_09_140749_add_slot_to_sales_import_items.php b/database/migrations/tenant/2026_02_09_140749_add_slot_to_sales_import_items.php
new file mode 100644
index 0000000..5e8a719
--- /dev/null
+++ b/database/migrations/tenant/2026_02_09_140749_add_slot_to_sales_import_items.php
@@ -0,0 +1,28 @@
+string('slot')->nullable()->after('machine_id')->comment('貨道');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('sales_import_items', function (Blueprint $table) {
+ $table->dropColumn('slot');
+ });
+ }
+};
diff --git a/docs/~$f6_1770350984272.xlsx b/docs/~$f6_1770350984272.xlsx
new file mode 100644
index 0000000..975f9aa
Binary files /dev/null and b/docs/~$f6_1770350984272.xlsx differ
diff --git a/resources/js/Components/Warehouse/Inventory/TransactionTable.tsx b/resources/js/Components/Warehouse/Inventory/TransactionTable.tsx
index 206b6c5..4ba7c28 100644
--- a/resources/js/Components/Warehouse/Inventory/TransactionTable.tsx
+++ b/resources/js/Components/Warehouse/Inventory/TransactionTable.tsx
@@ -9,6 +9,7 @@ export interface Transaction {
userName: string;
actualTime: string;
batchNumber?: string; // 商品層級查詢時顯示批號
+ slot?: string; // 貨道資訊
}
interface TransactionTableProps {
@@ -27,6 +28,8 @@ export default function TransactionTable({ transactions, showBatchNumber = false
// 自動偵測是否需要顯示批號(如果任一筆記錄有 batchNumber)
const shouldShowBatchNumber = showBatchNumber || transactions.some(tx => tx.batchNumber);
+ // 自動偵測是否需要顯示貨道(如果任一筆記錄有 slot)
+ const shouldShowSlot = transactions.some(tx => tx.slot);
return (
@@ -39,6 +42,7 @@ export default function TransactionTable({ transactions, showBatchNumber = false
類型 |
變動數量 |
結餘 |
+ {shouldShowSlot && 貨道 | }
經手人 |
原因/備註 |
@@ -66,6 +70,9 @@ export default function TransactionTable({ transactions, showBatchNumber = false
{tx.quantity > 0 ? '+' : ''}{tx.quantity}
{tx.balanceAfter} |
+ {shouldShowSlot && (
+ {tx.slot || '-'} |
+ )}
{tx.userName} |
{tx.reason || '-'} |
diff --git a/resources/js/Layouts/AuthenticatedLayout.tsx b/resources/js/Layouts/AuthenticatedLayout.tsx
index 50f3117..e855417 100644
--- a/resources/js/Layouts/AuthenticatedLayout.tsx
+++ b/resources/js/Layouts/AuthenticatedLayout.tsx
@@ -23,7 +23,9 @@ import {
FileSpreadsheet,
BookOpen,
ClipboardCheck,
- ArrowLeftRight
+ ArrowLeftRight,
+ TrendingUp,
+ FileUp
} from "lucide-react";
import { toast, Toaster } from "sonner";
import { useState, useEffect, useMemo, useRef } from "react";
@@ -159,6 +161,21 @@ export default function AuthenticatedLayout({
},
],
},
+ {
+ id: "sales-management",
+ label: "銷售管理",
+ icon: ,
+ // permission: ["sales.view_imports"], // Temporarily disabled for immediate visibility
+ children: [
+ {
+ id: "sales-import-list",
+ label: "銷售單匯入",
+ icon: ,
+ route: "/sales/imports",
+ // permission: "sales.view_imports",
+ },
+ ],
+ },
{
id: "production-management",
label: "生產管理",
diff --git a/resources/js/Pages/Inventory/GoodsReceipt/Show.tsx b/resources/js/Pages/Inventory/GoodsReceipt/Show.tsx
index dabe172..7860ea8 100644
--- a/resources/js/Pages/Inventory/GoodsReceipt/Show.tsx
+++ b/resources/js/Pages/Inventory/GoodsReceipt/Show.tsx
@@ -140,15 +140,15 @@ export default function ViewGoodsReceiptPage({ receipt }: Props) {
{/* 品項清單卡片 */}
-
-
+
+
進貨品項清單
-
-
+
+
-
+
#
商品名稱
進貨數量
diff --git a/resources/js/Pages/Sales/Import/Create.tsx b/resources/js/Pages/Sales/Import/Create.tsx
new file mode 100644
index 0000000..4127f81
--- /dev/null
+++ b/resources/js/Pages/Sales/Import/Create.tsx
@@ -0,0 +1,90 @@
+import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
+import { Head, useForm, Link } from '@inertiajs/react';
+import { Button } from '@/Components/ui/button';
+import { Input } from '@/Components/ui/input';
+import { Label } from '@/Components/ui/label';
+import { Upload, ArrowLeft, FileSpreadsheet } from 'lucide-react';
+import React from 'react';
+
+export default function SalesImportCreate() {
+ const { data, setData, post, processing, errors } = useForm({
+ file: null as File | null,
+ });
+
+ const submit = (e: React.FormEvent) => {
+ e.preventDefault();
+ post(route('sales-imports.store'));
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ 新增銷售匯入
+
+
+
+
+
+
+
匯入說明
+
+ - 請使用統一的 Excel 格式(商品銷貨單)。
+ - 系統將自動解析機台編號、商品代號與交易時間。
+ - 匯入後請至「待確認」清單檢查內容,確認無誤後再執行扣庫。
+
+
+
+
+ );
+}
diff --git a/resources/js/Pages/Sales/Import/Index.tsx b/resources/js/Pages/Sales/Import/Index.tsx
new file mode 100644
index 0000000..f413e14
--- /dev/null
+++ b/resources/js/Pages/Sales/Import/Index.tsx
@@ -0,0 +1,203 @@
+import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
+import { Head, Link } from '@inertiajs/react';
+import { Button } from '@/Components/ui/button';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/Components/ui/table';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "@/Components/ui/alert-dialog";
+import { Badge } from "@/Components/ui/badge";
+import { Plus, FileUp, Eye, Trash2 } from 'lucide-react';
+import { useState, useEffect } from "react";
+import { format } from 'date-fns';
+import Pagination from "@/Components/shared/Pagination";
+import { SearchableSelect } from "@/Components/ui/searchable-select";
+import { router } from "@inertiajs/react";
+
+interface ImportBatch {
+ id: number;
+ import_date: string;
+ status: 'pending' | 'confirmed';
+ total_quantity: number;
+ total_amount: number;
+ importer?: {
+ name: string;
+ };
+ created_at: string;
+}
+
+interface Props {
+ batches: {
+ data: ImportBatch[];
+ links: any[]; // Pagination links
+ };
+ filters?: { // Add filters prop if not present, though we main need per_page state
+ per_page?: string;
+ }
+}
+
+export default function SalesImportIndex({ batches, filters = {} }: Props) {
+ const [perPage, setPerPage] = useState(filters?.per_page?.toString() || "10");
+
+ useEffect(() => {
+ if (filters?.per_page) {
+ setPerPage(filters.per_page.toString());
+ }
+ }, [filters?.per_page]);
+
+ const handlePerPageChange = (value: string) => {
+ setPerPage(value);
+ router.get(
+ route("sales-imports.index"),
+ { per_page: value },
+ { preserveState: true, preserveScroll: true, replace: true }
+ );
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ 銷售單匯入管理
+
+
+ 匯入並管理銷售出貨紀錄
+
+
+
+
+
+
+
+
+
+
+
+ ID
+ 匯入日期
+ 匯入人員
+ 總數量
+ 總金額
+ 狀態
+ 操作
+
+
+
+ {batches.data.length === 0 ? (
+
+
+ 尚無匯入紀錄
+
+
+ ) : (
+ batches.data.map((batch) => (
+
+ #{batch.id}
+
+ {format(new Date(batch.created_at), 'yyyy/MM/dd HH:mm')}
+
+ {batch.importer?.name || '--'}
+
+ {Math.floor(batch.total_quantity || 0).toLocaleString()}
+
+
+ NT$ {Number(batch.total_amount || 0).toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 2 })}
+
+
+
+ {batch.status === 'confirmed' ? '已確認' : '待確認'}
+
+
+
+
+
+
+
+ {batch.status === 'pending' && (
+
+
+
+
+
+
+ 確認刪除匯入紀錄
+
+ 確定要刪除此筆匯入紀錄(#{batch.id})嗎?此操作將會移除所有相關的明細資料且無法復原。
+
+
+
+ 取消
+ router.delete(route('sales-imports.destroy', batch.id), { preserveScroll: true })}
+ >
+ 確認刪除
+
+
+
+
+ )}
+
+
+
+ ))
+ )}
+
+
+
+
+ {/* Pagination */}
+
+
+
+ );
+}
diff --git a/resources/js/Pages/Sales/Import/Show.tsx b/resources/js/Pages/Sales/Import/Show.tsx
new file mode 100644
index 0000000..484bb40
--- /dev/null
+++ b/resources/js/Pages/Sales/Import/Show.tsx
@@ -0,0 +1,338 @@
+import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
+import { Head, Link, useForm, router } from '@inertiajs/react'; // Add router import
+import { useState, useEffect } from "react";
+import { SearchableSelect } from "@/Components/ui/searchable-select";
+import { Button } from '@/Components/ui/button';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/Components/ui/table';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "@/Components/ui/alert-dialog";
+import { Badge } from "@/Components/ui/badge";
+import { ArrowLeft, CheckCircle, Trash2, Printer } from 'lucide-react';
+import { format } from 'date-fns';
+import Pagination from "@/Components/shared/Pagination";
+
+interface ImportItem {
+ id: number;
+ transaction_serial: string;
+ machine_id: string;
+ slot: string | null;
+ product_code: string;
+ product_id: number | null;
+ product?: {
+ name: string;
+ };
+ quantity: number;
+ amount: number;
+ transaction_at: string;
+ original_status: string;
+ warehouse?: {
+ name: string;
+ };
+}
+
+interface ImportBatch {
+ id: number;
+ import_date: string;
+ status: 'pending' | 'confirmed';
+ total_quantity: number;
+ total_amount: number;
+ items: ImportItem[]; // Note: items might be paginated in props, handled below
+ created_at: string;
+ confirmed_at?: string;
+}
+
+interface Props {
+ import: ImportBatch;
+ items: {
+ data: ImportItem[];
+ links: any[];
+ current_page: number;
+ per_page: number;
+ total: number;
+ };
+ filters?: {
+ per_page?: string;
+ };
+ flash?: {
+ success?: string;
+ error?: string;
+ };
+}
+
+export default function SalesImportShow({ import: batch, items, filters = {} }: Props) {
+ const { post, processing } = useForm({});
+ const [perPage, setPerPage] = useState(filters?.per_page?.toString() || "10");
+
+ // Sync state with prop if it changes via navigation
+ useEffect(() => {
+ if (filters?.per_page) {
+ setPerPage(filters.per_page.toString());
+ }
+ }, [filters?.per_page]);
+
+ const handlePerPageChange = (value: string) => {
+ setPerPage(value);
+ router.get(
+ route("sales-imports.show", batch.id),
+ { per_page: value },
+ { preserveState: true, preserveScroll: true, replace: true }
+ );
+ };
+
+ const handleConfirm = () => {
+ post(route('sales-imports.confirm', batch.id));
+ };
+
+ const handleDelete = () => {
+ router.delete(route('sales-imports.destroy', batch.id));
+ };
+
+ return (
+
+
+
+
+ {/* Header */}
+
+
+
+
+
+
+
+
+
+ 銷售匯入詳情
+
+
批次編號:#{batch.id} | 匯入時間:{format(new Date(batch.created_at), 'yyyy/MM/dd HH:mm')}
+
+
+
+ {batch.status === 'confirmed' ? '已確認' : '待確認'}
+
+ {batch.status === 'pending' && (
+ <>
+
+
+
+
+
+
+ 確認刪除匯入紀錄
+
+ 確定要刪除此筆匯入紀錄(#{batch.id})嗎?此操作將會移除所有相關的明細資料且無法復原。
+
+
+
+ 取消
+
+ 確認刪除
+
+
+
+
+
+
+
+
+
+
+
+ 確認執行庫存扣取
+
+ 確認要執行扣庫嗎?系統將會根據此匯入內容減少對應倉庫的商品庫存。此操作無法復原。
+
+
+
+ 取消
+
+ 確認執行
+
+
+
+
+ >
+ )}
+ {batch.status === 'confirmed' && (
+
+ )}
+
+
+
+
+
+ {/* 統計資訊卡片 */}
+
+
統計資訊
+
+
+ 總筆數
+ {Math.floor(batch.total_quantity || 0).toLocaleString()}
+
+
+ 總金額
+
+ NT$ {Number(batch.total_amount || 0).toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 2 })}
+
+
+
+ 確認時間
+
+ {batch.confirmed_at ? format(new Date(batch.confirmed_at), 'yyyy/MM/dd HH:mm') : '--'}
+
+
+
+
+
+ {/* 匯入明細清單 */}
+
+
+
匯入明細清單
+
+
+
+
+
+
+ #
+ 交易序號 / 時間
+ 倉庫 (機台編號)
+ 商品代碼
+ 商品名稱
+ 機台 / 貨道
+ 原始狀態
+ 數量
+ 金額
+
+
+
+ {items.data.length === 0 ? (
+
+
+ 無匯入明細資料
+
+
+ ) : (
+ items.data.map((item, index) => (
+
+
+ {(items.current_page - 1) * items.per_page + index + 1}
+
+
+
+ {item.transaction_serial}
+
+ {format(new Date(item.transaction_at), 'yyyy/MM/dd HH:mm:ss')}
+
+
+
+
+
+ {item.warehouse?.name || '--'}
+ {item.machine_id}
+
+
+
+ {item.product_code}
+
+
+
+ {item.product?.name || '--'}
+
+
+
+ {item.slot || '--'}
+
+
+
+ {item.original_status}
+
+
+ {Math.floor(item.quantity)}
+
+ NT$ {Number(item.amount).toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 2 })}
+
+
+ ))
+ )}
+
+
+
+
+ {/* Pagination */}
+
+
+
+
+
+
+
+ );
+}
diff --git a/resources/js/Pages/Warehouse/Inventory.tsx b/resources/js/Pages/Warehouse/Inventory.tsx
index 3808884..35b16ad 100644
--- a/resources/js/Pages/Warehouse/Inventory.tsx
+++ b/resources/js/Pages/Warehouse/Inventory.tsx
@@ -1,4 +1,4 @@
-import { useState, useMemo } from "react";
+import { useState, useMemo, useEffect } from "react";
import { ArrowLeft, PackagePlus, AlertTriangle, Shield, Boxes, FileUp } from "lucide-react";
import { Button } from "@/Components/ui/button";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
@@ -36,11 +36,31 @@ export default function WarehouseInventoryPage({
safetyStockSettings,
availableProducts,
}: Props) {
- const [searchTerm, setSearchTerm] = useState("");
- const [typeFilter, setTypeFilter] = useState("all");
+ // 從 URL 讀取初始狀態
+ const queryParams = new URLSearchParams(window.location.search);
+ const [searchTerm, setSearchTerm] = useState(queryParams.get("search") || "");
+ const [typeFilter, setTypeFilter] = useState(queryParams.get("type") || "all");
const [deleteId, setDeleteId] = useState(null);
const [importDialogOpen, setImportDialogOpen] = useState(false);
+ // 當搜尋或篩選變更時,同步到 URL (使用 replace: true 避免產生過多歷史紀錄)
+ useEffect(() => {
+ const params: any = {};
+ if (searchTerm) params.search = searchTerm;
+ if (typeFilter !== "all") params.type = typeFilter;
+
+ router.get(
+ route("warehouses.inventory.index", warehouse.id),
+ params,
+ {
+ preserveState: true,
+ preserveScroll: true,
+ replace: true,
+ only: ["inventories"], // 僅重新拉取數據,避免全頁重新渲染 (如有後端過濾)
+ }
+ );
+ }, [searchTerm, typeFilter]);
+
// 篩選庫存列表
const filteredInventories = useMemo(() => {
return inventories.filter((group) => {