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

+ + 新增銷售匯入 +

+
+ +
+
+
+ +
+ setData('file', e.target.files ? e.target.files[0] : null)} + className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" + /> + +
+

+ {data.file ? data.file.name : '點擊或拖曳檔案至此'} +

+

支援 .xlsx, .xls, .csv

+
+
+ {errors.file &&

{errors.file}

} +
+ +
+ +
+ +
+ +
+

匯入說明

+
    +
  • 請使用統一的 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) => {