diff --git a/.gitea/workflows/deploy-demo.yaml b/.gitea/workflows/deploy-demo.yaml index c2e25e2..00857fa 100644 --- a/.gitea/workflows/deploy-demo.yaml +++ b/.gitea/workflows/deploy-demo.yaml @@ -97,4 +97,4 @@ jobs: php artisan optimize && php artisan view:cache " - docker exec star-erp-laravel chmod -R 775 /var/www/html/storage /var/www/html/bootstrap/cache + docker exec star-erp-laravel chmod -R 775 /var/www/html/storage /var/www/html/bootstrap/cache \ No newline at end of file diff --git a/app/Modules/Finance/Controllers/AccountPayableController.php b/app/Modules/Finance/Controllers/AccountPayableController.php new file mode 100644 index 0000000..0e0588c --- /dev/null +++ b/app/Modules/Finance/Controllers/AccountPayableController.php @@ -0,0 +1,124 @@ +filled('search')) { + $search = $request->search; + $query->where(function ($q) use ($search) { + $q->where('document_number', 'like', "%{$search}%") + ->orWhereHas('vendor', function ($q) use ($search) { + $q->where('name', 'like', "%{$search}%"); + }); + }); + } + + // 狀態過濾 + if ($request->filled('status') && $request->status !== 'all') { + $query->where('status', $request->status); + } + + // 供應商過濾 + if ($request->filled('vendor_id') && $request->vendor_id !== 'all') { + $query->where('vendor_id', $request->vendor_id); + } + + // 日期區間過濾 + if ($request->filled('date_start')) { + $query->where('due_date', '>=', $request->date_start); + } + if ($request->filled('date_end')) { + $query->where('due_date', '<=', $request->date_end); + } + + $perPage = $request->input('per_page', 10); + $payables = $query->latest()->paginate($perPage)->withQueryString(); + + $vendors = \App\Modules\Procurement\Models\Vendor::select('id', 'name')->get(); + + return Inertia::render('AccountPayable/Index', [ + 'payables' => $payables, + 'filters' => $request->all(['search', 'status', 'vendor_id', 'date_start', 'date_end', 'per_page']), + 'vendors' => $vendors, + ]); + } + + /** + * Display the specified resource. + */ + public function show(AccountPayable $accountPayable) + { + $accountPayable->load(['vendor', 'creator']); + + // 嘗試加載來源單據資訊 (目前支援 goods_receipt) + $sourceDocumentCode = null; + if ($accountPayable->source_document_type === 'goods_receipt') { + $receipt = \App\Modules\Inventory\Models\GoodsReceipt::find($accountPayable->source_document_id); + if ($receipt) { + $sourceDocumentCode = $receipt->code; + } + } + + return Inertia::render('AccountPayable/Show', [ + // 將 model 轉換成 array 加入額外資訊 + 'payable' => array_merge($accountPayable->toArray(), ['source_document_code' => $sourceDocumentCode]), + ]); + } + + /** + * 更新發票資訊 + */ + public function updateInvoice(Request $request, AccountPayable $accountPayable) + { + $validated = $request->validate([ + 'invoice_number' => 'nullable|string|max:50', + 'invoice_date' => 'nullable|date', + ]); + + $accountPayable->update([ + 'invoice_number' => $validated['invoice_number'], + 'invoice_date' => $validated['invoice_date'], + ]); + + return back()->with('success', '發票資訊已更新'); + } + + /** + * 標記已付款 + */ + public function pay(Request $request, AccountPayable $accountPayable) + { + $validated = $request->validate([ + 'payment_method' => 'required|string|max:50', + 'paid_at' => 'required|date', + 'payment_note' => 'nullable|string|max:255', + ]); + + if ($accountPayable->status === AccountPayable::STATUS_PAID) { + return back()->with('error', '該帳款已經標記為已付款'); + } + + $accountPayable->update([ + 'status' => AccountPayable::STATUS_PAID, + 'payment_method' => $validated['payment_method'], + 'paid_at' => $validated['paid_at'], + 'payment_note' => $validated['payment_note'], + ]); + + return back()->with('success', '帳款已成功標記為已付款'); + } +} diff --git a/app/Modules/Finance/FinanceServiceProvider.php b/app/Modules/Finance/FinanceServiceProvider.php index cff7ffa..75bd504 100644 --- a/app/Modules/Finance/FinanceServiceProvider.php +++ b/app/Modules/Finance/FinanceServiceProvider.php @@ -15,6 +15,9 @@ class FinanceServiceProvider extends ServiceProvider public function boot(): void { - // + \Illuminate\Support\Facades\Event::listen( + \App\Modules\Inventory\Events\GoodsReceiptApprovedEvent::class, + \App\Modules\Finance\Listeners\CreateAccountPayableFromGoodsReceipt::class + ); } } diff --git a/app/Modules/Finance/Listeners/CreateAccountPayableFromGoodsReceipt.php b/app/Modules/Finance/Listeners/CreateAccountPayableFromGoodsReceipt.php new file mode 100644 index 0000000..29ba2ab --- /dev/null +++ b/app/Modules/Finance/Listeners/CreateAccountPayableFromGoodsReceipt.php @@ -0,0 +1,41 @@ +accountPayableService = $accountPayableService; + } + + /** + * Handle the event. + */ + public function handle(GoodsReceiptApprovedEvent $event): void + { + try { + // 目前使用系統預設 User ID 或 0 作為自動生成的建立者,若能從 event 取得更好 + $userId = auth()->id() ?? 1; // 假設 1 為系統管理員或預設使用者 + + $this->accountPayableService->createFromGoodsReceipt($event->goodsReceiptId, $userId); + + Log::info("已成功為進貨單 ID: {$event->goodsReceiptId} 建立應付帳款"); + } catch (\Exception $e) { + Log::error("建立應付帳款失敗 (進貨單 ID: {$event->goodsReceiptId}): " . $e->getMessage()); + // 根據需求決定是否拋出 exception 或只記錄 log + throw $e; + } + } +} diff --git a/app/Modules/Finance/Models/AccountPayable.php b/app/Modules/Finance/Models/AccountPayable.php new file mode 100644 index 0000000..0658845 --- /dev/null +++ b/app/Modules/Finance/Models/AccountPayable.php @@ -0,0 +1,61 @@ + 'decimal:2', + 'tax_amount' => 'decimal:2', + 'due_date' => 'date', + 'invoice_date' => 'date', + 'paid_at' => 'datetime', + ]; + + /** + * 關聯:供應商 + * @return BelongsTo + */ + public function vendor(): BelongsTo + { + return $this->belongsTo(\App\Modules\Procurement\Models\Vendor::class, 'vendor_id'); + } + + /** + * 關聯:建立者 + * @return BelongsTo + */ + public function creator(): BelongsTo + { + return $this->belongsTo(\App\Modules\Core\Models\User::class, 'created_by'); + } +} diff --git a/app/Modules/Finance/Routes/web.php b/app/Modules/Finance/Routes/web.php index 9cb8cfe..dd0a8cd 100644 --- a/app/Modules/Finance/Routes/web.php +++ b/app/Modules/Finance/Routes/web.php @@ -4,7 +4,17 @@ use Illuminate\Support\Facades\Route; use App\Modules\Finance\Controllers\UtilityFeeController; use App\Modules\Finance\Controllers\AccountingReportController; +use App\Modules\Finance\Controllers\AccountPayableController; + Route::middleware('auth')->group(function () { + // 應付帳款 + Route::group(['prefix' => 'finance'], function () { + Route::get('/account-payables', [AccountPayableController::class, 'index'])->name('account-payables.index'); + Route::get('/account-payables/{accountPayable}', [AccountPayableController::class, 'show'])->name('account-payables.show'); + Route::post('/account-payables/{accountPayable}/invoice', [AccountPayableController::class, 'updateInvoice'])->name('account-payables.invoice'); + Route::post('/account-payables/{accountPayable}/pay', [AccountPayableController::class, 'pay'])->name('account-payables.pay'); + }); + // 公共事業費管理 Route::middleware('permission:utility_fees.view')->group(function () { Route::get('/utility-fees', [UtilityFeeController::class, 'index'])->name('utility-fees.index'); diff --git a/app/Modules/Finance/Services/AccountPayableService.php b/app/Modules/Finance/Services/AccountPayableService.php new file mode 100644 index 0000000..1ae3dc8 --- /dev/null +++ b/app/Modules/Finance/Services/AccountPayableService.php @@ -0,0 +1,85 @@ +goodsReceiptService = $goodsReceiptService; + } + + /** + * 根據進貨單建立應付帳款 + * + * @param int $goodsReceiptId + * @param int $userId 執行操作的使用者 ID + * @return AccountPayable + * @throws \Exception + */ + public function createFromGoodsReceipt(int $goodsReceiptId, int $userId): AccountPayable + { + // 透過 Contract 取得 Inventory 模組的資料,避免直接依賴 Model + $receiptData = $this->goodsReceiptService->getGoodsReceiptData($goodsReceiptId); + + if (!$receiptData) { + throw new \Exception("找不到對應的進貨單資料 (ID: {$goodsReceiptId})"); + } + + // 檢查是否已經建立過(密等性) + $existingAp = AccountPayable::where('source_document_type', 'goods_receipt') + ->where('source_document_id', $goodsReceiptId) + ->first(); + + if ($existingAp) { + return $existingAp; + } + + return DB::transaction(function () use ($receiptData, $userId) { + $ap = AccountPayable::create([ + 'vendor_id' => $receiptData['vendor_id'], + 'source_document_type' => 'goods_receipt', + 'source_document_id' => $receiptData['id'], + 'document_number' => $this->generateApNumber(), + 'total_amount' => collect($receiptData['items'] ?? [])->sum('total_amount'), + 'tax_amount' => 0, // 假設後續會實作稅額計算,目前預設為 0 + 'status' => AccountPayable::STATUS_PENDING, + // 設定應付日期,預設為進貨後 30 天 (可依據供應商設定調整) + 'due_date' => now()->addDays(30)->toDateString(), + 'created_by' => $userId, + 'remarks' => "由進貨單 {$receiptData['code']} 自動生成", + ]); + + return $ap; + }); + } + + /** + * 產生應付帳款單號 + */ + protected function generateApNumber(): string + { + $prefix = 'AP-' . date('Ymd') . '-'; + $lastPrefix = "{$prefix}%"; + + $latest = AccountPayable::where('document_number', 'like', $lastPrefix) + ->orderBy('document_number', 'desc') + ->first(); + + if (!$latest) { + return $prefix . '01'; + } + + $parts = explode('-', $latest->document_number); + $lastNumber = intval(end($parts)); + $newNumber = str_pad((string)($lastNumber + 1), 2, '0', STR_PAD_LEFT); + + return $prefix . $newNumber; + } +} diff --git a/app/Modules/Inventory/Contracts/GoodsReceiptServiceInterface.php b/app/Modules/Inventory/Contracts/GoodsReceiptServiceInterface.php new file mode 100644 index 0000000..c44e222 --- /dev/null +++ b/app/Modules/Inventory/Contracts/GoodsReceiptServiceInterface.php @@ -0,0 +1,14 @@ + 'nullable|date', ]); - $this->goodsReceiptService->store($validated); + try { + $this->goodsReceiptService->store($request->all()); + return redirect()->route('goods-receipts.index')->with('success', '進貨草稿已建立'); + } catch (\Exception $e) { + return back()->with('error', $e->getMessage()); + } + } - return redirect()->route('goods-receipts.index')->with('success', '進貨單已建立'); + public function submit(GoodsReceipt $goodsReceipt) + { + if (!auth()->user()->can('goods_receipts.update')) { + return back()->with('error', '您沒有權限確認點收'); + } + + try { + $this->goodsReceiptService->submit($goodsReceipt); + return back()->with('success', '進貨單已點收完成,庫存已增加並拋轉應付帳款'); + } catch (\Exception $e) { + return back()->with('error', $e->getMessage()); + } } // API to search POs diff --git a/app/Modules/Inventory/Controllers/StoreRequisitionController.php b/app/Modules/Inventory/Controllers/StoreRequisitionController.php index 4a61972..8507b47 100644 --- a/app/Modules/Inventory/Controllers/StoreRequisitionController.php +++ b/app/Modules/Inventory/Controllers/StoreRequisitionController.php @@ -299,15 +299,16 @@ class StoreRequisitionController extends Controller $requisition = StoreRequisition::findOrFail($id); $request->validate([ - 'supply_warehouse_id' => 'required|exists:warehouses,id', 'items' => 'required|array', 'items.*.id' => 'required|exists:store_requisition_items,id', 'items.*.approved_qty' => 'required|numeric|min:0', - ], [ - 'supply_warehouse_id.required' => '請選擇供貨倉庫', ]); - $this->service->approve($requisition, $request->only(['supply_warehouse_id', 'items']), auth()->id()); + if (empty($requisition->supply_warehouse_id)) { + return back()->withErrors(['supply_warehouse_id' => '請先選擇供貨倉庫']); + } + + $this->service->approve($requisition, $request->only(['items']), auth()->id()); return redirect()->route('store-requisitions.show', $id) ->with('success', '叫貨單已核准,調撥單已自動產生'); @@ -332,6 +333,28 @@ class StoreRequisitionController extends Controller ->with('success', '叫貨單已駁回'); } + /** + * 更新供貨倉庫 + */ + public function updateSupplyWarehouse(Request $request, $id) + { + $requisition = StoreRequisition::findOrFail($id); + + if ($requisition->status !== 'pending') { + return back()->withErrors(['error' => '僅能在待審核狀態修改供貨倉庫']); + } + + $request->validate([ + 'supply_warehouse_id' => 'required|exists:warehouses,id', + ]); + + $requisition->update([ + 'supply_warehouse_id' => $request->supply_warehouse_id, + ]); + + return redirect()->back()->with('success', '供貨倉庫已更新'); + } + /** * 刪除叫貨單(僅限草稿) */ diff --git a/app/Modules/Inventory/Controllers/TransferOrderController.php b/app/Modules/Inventory/Controllers/TransferOrderController.php index d40edcc..5832401 100644 --- a/app/Modules/Inventory/Controllers/TransferOrderController.php +++ b/app/Modules/Inventory/Controllers/TransferOrderController.php @@ -26,6 +26,14 @@ class TransferOrderController extends Controller $query = InventoryTransferOrder::query() ->with(['fromWarehouse', 'toWarehouse', 'createdBy', 'postedBy']); + // 搜尋:單號或備註 + if ($request->filled('search')) { + $query->where(function ($q) use ($request) { + $q->where('doc_no', 'like', "%{$request->search}%") + ->orWhere('remarks', 'like', "%{$request->search}%"); + }); + } + // 篩選:若有選定倉庫,則顯示該倉庫作為來源或目的地的調撥單 if ($request->filled('warehouse_id')) { $query->where(function ($q) use ($request) { @@ -54,7 +62,7 @@ class TransferOrderController extends Controller return Inertia::render('Inventory/Transfer/Index', [ 'orders' => $orders, 'warehouses' => Warehouse::all()->map(fn($w) => ['id' => (string)$w->id, 'name' => $w->name]), - 'filters' => $request->only(['warehouse_id', 'per_page']), + 'filters' => $request->only(['search', 'warehouse_id', 'per_page']), ]); } diff --git a/app/Modules/Inventory/Events/GoodsReceiptApprovedEvent.php b/app/Modules/Inventory/Events/GoodsReceiptApprovedEvent.php new file mode 100644 index 0000000..2abe4df --- /dev/null +++ b/app/Modules/Inventory/Events/GoodsReceiptApprovedEvent.php @@ -0,0 +1,22 @@ +goodsReceiptId = $goodsReceiptId; + } +} diff --git a/app/Modules/Inventory/InventoryServiceProvider.php b/app/Modules/Inventory/InventoryServiceProvider.php index ddca8f5..3113dff 100644 --- a/app/Modules/Inventory/InventoryServiceProvider.php +++ b/app/Modules/Inventory/InventoryServiceProvider.php @@ -14,6 +14,10 @@ class InventoryServiceProvider extends ServiceProvider { $this->app->bind(InventoryServiceInterface::class, InventoryService::class); $this->app->bind(ProductServiceInterface::class, ProductService::class); + $this->app->bind( + \App\Modules\Inventory\Contracts\GoodsReceiptServiceInterface::class, + \App\Modules\Inventory\Services\GoodsReceiptService::class + ); } public function boot(): void diff --git a/app/Modules/Inventory/Models/GoodsReceipt.php b/app/Modules/Inventory/Models/GoodsReceipt.php index 4f1fbdd..c4ba032 100644 --- a/app/Modules/Inventory/Models/GoodsReceipt.php +++ b/app/Modules/Inventory/Models/GoodsReceipt.php @@ -11,6 +11,11 @@ class GoodsReceipt extends Model use HasFactory, SoftDeletes; use \Spatie\Activitylog\Traits\LogsActivity; + public const STATUS_DRAFT = 'draft'; + public const STATUS_PENDING_AUDIT = 'pending_audit'; + public const STATUS_COMPLETED = 'completed'; + public const STATUS_REJECTED = 'rejected'; + protected $fillable = [ 'code', 'type', diff --git a/app/Modules/Inventory/Routes/web.php b/app/Modules/Inventory/Routes/web.php index 33652b7..063d97f 100644 --- a/app/Modules/Inventory/Routes/web.php +++ b/app/Modules/Inventory/Routes/web.php @@ -168,6 +168,7 @@ Route::middleware('auth')->group(function () { Route::middleware('permission:store_requisitions.approve')->group(function () { Route::post('/store-requisitions/{id}/approve', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'approve'])->name('store-requisitions.approve'); Route::post('/store-requisitions/{id}/reject', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'reject'])->name('store-requisitions.reject'); + Route::patch('/store-requisitions/{id}/supply-warehouse', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'updateSupplyWarehouse'])->name('store-requisitions.update-supply-warehouse'); }); Route::delete('/store-requisitions/{id}', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'destroy'])->middleware('permission:store_requisitions.delete')->name('store-requisitions.destroy'); @@ -179,6 +180,16 @@ Route::middleware('auth')->group(function () { Route::get('/goods-receipts/create', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'create'])->middleware('permission:goods_receipts.create')->name('goods-receipts.create'); Route::get('/goods-receipts/{goods_receipt}', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'show'])->name('goods-receipts.show'); Route::post('/goods-receipts', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'store'])->middleware('permission:goods_receipts.create')->name('goods-receipts.store'); + + // 點收提交路由 + Route::post('/goods-receipts/{goods_receipt}/submit', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'submit']) + ->middleware('permission:goods_receipts.update') + ->name('goods-receipts.submit'); + + Route::delete('/goods-receipts/{goods_receipt}', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'destroy']) + ->middleware('permission:goods_receipts.delete') + ->name('goods-receipts.destroy'); + Route::get('/api/goods-receipts/search-pos', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'searchPOs'])->name('goods-receipts.search-pos'); Route::get('/api/goods-receipts/search-products', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'searchProducts'])->name('goods-receipts.search-products'); Route::get('/api/goods-receipts/search-vendors', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'searchVendors'])->name('goods-receipts.search-vendors'); diff --git a/app/Modules/Inventory/Services/GoodsReceiptService.php b/app/Modules/Inventory/Services/GoodsReceiptService.php index fb2dd9c..6aa12aa 100644 --- a/app/Modules/Inventory/Services/GoodsReceiptService.php +++ b/app/Modules/Inventory/Services/GoodsReceiptService.php @@ -7,8 +7,10 @@ use App\Modules\Inventory\Models\GoodsReceiptItem; use App\Modules\Inventory\Contracts\InventoryServiceInterface; use App\Modules\Procurement\Contracts\ProcurementServiceInterface; use Illuminate\Support\Facades\DB; +use App\Modules\Inventory\Events\GoodsReceiptApprovedEvent; +use Illuminate\Support\Facades\Log; -class GoodsReceiptService +class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsReceiptServiceInterface { protected $inventoryService; protected $procurementService; @@ -22,7 +24,7 @@ class GoodsReceiptService } /** - * Store a new Goods Receipt and process inventory. + * Store a new Goods Receipt (Draft state). * * @param array $data * @return GoodsReceipt @@ -34,7 +36,7 @@ class GoodsReceiptService // 1. Generate Code $data['code'] = $this->generateCode($data['received_date']); $data['user_id'] = auth()->id(); - $data['status'] = 'completed'; // Direct completion for now + $data['status'] = GoodsReceipt::STATUS_DRAFT; // 預設草稿 // 2. Create Header $goodsReceipt = GoodsReceipt::create($data); @@ -52,8 +54,76 @@ class GoodsReceiptService 'expiry_date' => $itemData['expiry_date'] ?? null, ]); $goodsReceipt->items()->save($grItem); + } - // 4. Update Inventory + return $goodsReceipt; + }); + } + + /** + * Update an existing Goods Receipt. + * + * @param GoodsReceipt $goodsReceipt + * @param array $data + * @return GoodsReceipt + * @throws \Exception + */ + public function update(GoodsReceipt $goodsReceipt, array $data) + { + if (!in_array($goodsReceipt->status, [GoodsReceipt::STATUS_DRAFT, GoodsReceipt::STATUS_REJECTED])) { + throw new \Exception('只有草稿或被退回的進貨單可以修改。'); + } + + return DB::transaction(function () use ($goodsReceipt, $data) { + $goodsReceipt->update([ + 'vendor_id' => $data['vendor_id'] ?? $goodsReceipt->vendor_id, + 'received_date' => $data['received_date'] ?? $goodsReceipt->received_date, + 'remarks' => $data['remarks'] ?? $goodsReceipt->remarks, + ]); + + if (isset($data['items'])) { + // Simple strategy: delete existing items and recreate + $goodsReceipt->items()->delete(); + + foreach ($data['items'] as $itemData) { + $grItem = new GoodsReceiptItem([ + 'product_id' => $itemData['product_id'], + 'purchase_order_item_id' => $itemData['purchase_order_item_id'] ?? null, + 'quantity_received' => $itemData['quantity_received'], + 'unit_price' => $itemData['unit_price'], + 'total_amount' => $itemData['quantity_received'] * $itemData['unit_price'], + 'batch_number' => $itemData['batch_number'] ?? null, + 'expiry_date' => $itemData['expiry_date'] ?? null, + ]); + $goodsReceipt->items()->save($grItem); + } + } + + return $goodsReceipt->fresh('items'); + }); + } + + /** + * Submit for audit (Confirm receipt by warehouse staff). + * This will increase inventory and update PO. + * + * @param GoodsReceipt $goodsReceipt + * @return GoodsReceipt + * @throws \Exception + */ + public function submit(GoodsReceipt $goodsReceipt) + { + if (!in_array($goodsReceipt->status, [GoodsReceipt::STATUS_DRAFT, GoodsReceipt::STATUS_REJECTED])) { + throw new \Exception('只有草稿或被退回的進貨單可以確認點收。'); + } + + return DB::transaction(function () use ($goodsReceipt) { + $goodsReceipt->status = GoodsReceipt::STATUS_COMPLETED; + $goodsReceipt->save(); + + // Process Inventory and PO updates + foreach ($goodsReceipt->items as $grItem) { + // 1. Update Inventory $reason = match($goodsReceipt->type) { 'standard' => '採購進貨', 'miscellaneous' => '雜項入庫', @@ -75,7 +145,7 @@ class GoodsReceiptService 'arrival_date' => $goodsReceipt->received_date, ]); - // 5. Update PO if linked and type is standard + // 2. Update PO if linked and type is standard if ($goodsReceipt->type === 'standard' && $goodsReceipt->purchase_order_id && $grItem->purchase_order_item_id) { $this->procurementService->updateReceivedQuantity( $grItem->purchase_order_item_id, @@ -84,10 +154,14 @@ class GoodsReceiptService } } + // Fire event to let Finance module create AP + event(new GoodsReceiptApprovedEvent($goodsReceipt->id)); + return $goodsReceipt; }); } + private function generateCode(string $date) { // Format: GR-YYYYMMDD-NN @@ -106,4 +180,22 @@ class GoodsReceiptService return $prefix . str_pad($seq, 2, '0', STR_PAD_LEFT); } + + /** + * 獲取指定的進貨單資訊 (實作 GoodsReceiptServiceInterface) + * + * @param int $goodsReceiptId + * @return array|null + */ + public function getGoodsReceiptData(int $goodsReceiptId): ?array + { + $receipt = GoodsReceipt::with('items')->find($goodsReceiptId); + + if (!$receipt) { + return null; + } + + // 以陣列形式回傳資料,避免外部模組產生 Model 依賴 + return $receipt->toArray(); + } } diff --git a/app/Modules/Inventory/Services/StoreRequisitionService.php b/app/Modules/Inventory/Services/StoreRequisitionService.php index d290aff..3362773 100644 --- a/app/Modules/Inventory/Services/StoreRequisitionService.php +++ b/app/Modules/Inventory/Services/StoreRequisitionService.php @@ -127,13 +127,22 @@ class StoreRequisitionService } } + // 優先使用傳入的供貨倉庫,若無則從單據中取得 + $supplyWarehouseId = $data['supply_warehouse_id'] ?? $requisition->supply_warehouse_id; + + if (!$supplyWarehouseId) { + throw ValidationException::withMessages([ + 'supply_warehouse_id' => '請指定供貨倉庫', + ]); + } + // 查詢供貨倉庫是否有預設在途倉 - $supplyWarehouse = \App\Modules\Inventory\Models\Warehouse::find($data['supply_warehouse_id']); + $supplyWarehouse = \App\Modules\Inventory\Models\Warehouse::find($supplyWarehouseId); $defaultTransitId = $supplyWarehouse?->default_transit_warehouse_id; // 產生調撥單(供貨倉庫 → 門市倉庫) $transferOrder = $this->transferService->createOrder( - fromWarehouseId: $data['supply_warehouse_id'], + fromWarehouseId: $supplyWarehouseId, toWarehouseId: $requisition->store_warehouse_id, remarks: "由叫貨單 {$requisition->doc_no} 自動產生", userId: $userId, @@ -160,7 +169,7 @@ class StoreRequisitionService // 更新叫貨單狀態 $requisition->update([ 'status' => 'approved', - 'supply_warehouse_id' => $data['supply_warehouse_id'], + 'supply_warehouse_id' => $supplyWarehouseId, 'approved_by' => $userId, 'approved_at' => now(), 'transfer_order_id' => $transferOrder->id, diff --git a/database/migrations/tenant/2026_02_24_134125_create_account_payables_table.php b/database/migrations/tenant/2026_02_24_134125_create_account_payables_table.php new file mode 100644 index 0000000..6ee89c6 --- /dev/null +++ b/database/migrations/tenant/2026_02_24_134125_create_account_payables_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('vendor_id')->constrained('vendors'); + $table->string('source_document_type')->comment('來源單據類型 (e.g. goods_receipt)'); + $table->unsignedBigInteger('source_document_id')->comment('來源單據 ID'); + $table->string('document_number')->unique()->comment('應付帳款單號'); + $table->decimal('total_amount', 15, 2)->comment('總金額 (含稅)'); + $table->decimal('tax_amount', 15, 2)->default(0)->comment('稅金'); + $table->string('status')->default('pending')->comment('狀態: pending, partially_paid, paid, cancelled'); + $table->date('due_date')->nullable()->comment('應付日期'); + $table->text('remarks')->nullable()->comment('備註'); + $table->foreignId('created_by')->nullable()->constrained('users'); + $table->timestamps(); + + $table->index(['source_document_type', 'source_document_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('account_payables'); + } +}; diff --git a/database/migrations/tenant/2026_02_24_151909_modify_status_in_goods_receipts_table_enum.php b/database/migrations/tenant/2026_02_24_151909_modify_status_in_goods_receipts_table_enum.php new file mode 100644 index 0000000..e01f9fe --- /dev/null +++ b/database/migrations/tenant/2026_02_24_151909_modify_status_in_goods_receipts_table_enum.php @@ -0,0 +1,28 @@ +string('status', 20)->default('draft')->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('goods_receipts', function (Blueprint $table) { + $table->enum('status', ['draft', 'completed', 'cancelled'])->default('draft')->change(); + }); + } +}; diff --git a/database/migrations/tenant/2026_02_24_162239_add_payment_and_invoice_to_account_payables_table.php b/database/migrations/tenant/2026_02_24_162239_add_payment_and_invoice_to_account_payables_table.php new file mode 100644 index 0000000..9e1dae8 --- /dev/null +++ b/database/migrations/tenant/2026_02_24_162239_add_payment_and_invoice_to_account_payables_table.php @@ -0,0 +1,39 @@ +string('invoice_number')->nullable()->after('due_date')->comment('發票號碼'); + $table->date('invoice_date')->nullable()->after('invoice_number')->comment('發票日期'); + + $table->timestamp('paid_at')->nullable()->after('status')->comment('付款時間'); + $table->string('payment_method')->nullable()->after('paid_at')->comment('付款方式'); + $table->text('payment_note')->nullable()->after('payment_method')->comment('付款備註'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('account_payables', function (Blueprint $table) { + $table->dropColumn([ + 'invoice_number', + 'invoice_date', + 'paid_at', + 'payment_method', + 'payment_note' + ]); + }); + } +}; diff --git a/fix_ap.php b/fix_ap.php new file mode 100644 index 0000000..ee49bd3 --- /dev/null +++ b/fix_ap.php @@ -0,0 +1,24 @@ +first(); +if ($ap) { + if (!$ap->total_amount || $ap->total_amount == 0) { + $sum = GoodsReceiptItem::where('goods_receipt_id', $ap->source_document_id)->sum('total_amount'); + if ($sum == 0) { + // fallback: check if unit_price * quantity_received works + $items = GoodsReceiptItem::where('goods_receipt_id', $ap->source_document_id)->get(); + foreach($items as $item) { + $sum += ($item->quantity_received * $item->unit_price); + } + } + $ap->total_amount = $sum; + $ap->save(); + echo "Fixed AP {$ap->document_number} total_amount to {$sum}\n"; + } else { + echo "AP total_amount is already set: {$ap->total_amount}\n"; + } +} else { + echo "No AP found\n"; +} diff --git a/resources/css/app.css b/resources/css/app.css index b53a858..0b0a4ac 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -250,6 +250,11 @@ border-color: var(--other-warning); } +.button-outlined-warning:hover { + @apply bg-amber-50 text-[var(--grey-0)]; + border-color: var(--other-warning); +} + .button-filled-error { @apply bg-[var(--button-err-normal)] text-[var(--grey-5)] border-transparent transition-colors shadow-sm; } diff --git a/resources/js/Components/Inventory/GoodsReceiptActions.tsx b/resources/js/Components/Inventory/GoodsReceiptActions.tsx index f336671..6b1ff7d 100644 --- a/resources/js/Components/Inventory/GoodsReceiptActions.tsx +++ b/resources/js/Components/Inventory/GoodsReceiptActions.tsx @@ -33,6 +33,7 @@ export default function GoodsReceiptActions({ receipt, }: { receipt: GoodsReceipt }) { const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const { delete: destroy, processing } = useForm({}); const handleConfirmDelete = () => { @@ -46,6 +47,8 @@ export default function GoodsReceiptActions({ }); }; + + return (
@@ -59,43 +62,46 @@ export default function GoodsReceiptActions({ - {/* Delete typically restricted for Goods Receipts, checking permission */} - - + {/* 只允許刪除草稿或已退回的進貨單 */} + {(receipt.status === 'draft' || receipt.status === 'rejected') && ( + + + + )} + + + + + 確認刪除進貨單 + + 確定要刪除進貨單 「{receipt.code}」 嗎? +
+ + 注意:刪除動作無法復原! + +
+
+ + 取消 + + 確認刪除 + + +
+
- - - - 確認刪除進貨單 - - 確定要刪除進貨單 「{receipt.code}」 嗎? -
- - 注意:刪除進貨單將會扣除已入庫的庫存數量! - -
-
- - 取消 - - 確認刪除 - - -
-
-
); } diff --git a/resources/js/Components/Inventory/GoodsReceiptStatusBadge.tsx b/resources/js/Components/Inventory/GoodsReceiptStatusBadge.tsx index c1af734..2c6c983 100644 --- a/resources/js/Components/Inventory/GoodsReceiptStatusBadge.tsx +++ b/resources/js/Components/Inventory/GoodsReceiptStatusBadge.tsx @@ -3,9 +3,12 @@ import { StatusBadge, StatusVariant } from "@/Components/shared/StatusBadge"; export type GoodsReceiptStatus = 'processing' | 'completed' | 'cancelled'; export const GOODS_RECEIPT_STATUS_CONFIG: Record = { + draft: { label: "草稿", variant: "neutral" }, + pending_audit: { label: "待審核", variant: "warning" }, processing: { label: "處理中", variant: "info" }, completed: { label: "已完成", variant: "success" }, cancelled: { label: "已取消", variant: "destructive" }, + rejected: { label: "已退回", variant: "destructive" }, }; interface GoodsReceiptStatusBadgeProps { diff --git a/resources/js/Layouts/AuthenticatedLayout.tsx b/resources/js/Layouts/AuthenticatedLayout.tsx index 7082da8..d99f2cd 100644 --- a/resources/js/Layouts/AuthenticatedLayout.tsx +++ b/resources/js/Layouts/AuthenticatedLayout.tsx @@ -225,15 +225,22 @@ export default function AuthenticatedLayout({ id: "finance-management", label: "財務管理", icon: , - permission: "utility_fees.view", + permission: ["utility_fees.view", "account_payables.view"], children: [ { id: "utility-fee-list", label: "公共事業費", icon: , - route: "/utility-fees", + route: '/utility-fees', permission: "utility_fees.view", }, + { + id: "account-payable-list", + label: "應付帳款", + icon: , + route: '/finance/account-payables', + permission: "account_payables.view", // 假設這為該功能的權限 + }, ], }, { @@ -246,14 +253,14 @@ export default function AuthenticatedLayout({ id: "accounting-report", label: "會計報表", icon: , - route: "/accounting-report", + route: '/accounting-report', permission: "accounting.view", }, { id: "inventory-report", label: "庫存報表", icon: , - route: "/inventory/report", + route: '/inventory/report', permission: "inventory_report.view", }, { @@ -612,7 +619,7 @@ export default function AuthenticatedLayout({ {isCollapsed ? : } - + {/* Mobile Sidebar Overlay */} { @@ -650,9 +657,10 @@ export default function AuthenticatedLayout({ "flex-1 flex flex-col transition-all duration-300 min-h-screen", "lg:ml-64", isCollapsed && "lg:ml-20", - "pt-16" // 始終為頁首保留空間 + "pt-16", + "w-full min-w-0 overflow-x-hidden" )}> -
+
{breadcrumbs && breadcrumbs.length > 1 && ( @@ -665,6 +673,6 @@ export default function AuthenticatedLayout({ -
+
); } diff --git a/resources/js/Pages/AccountPayable/Index.tsx b/resources/js/Pages/AccountPayable/Index.tsx new file mode 100644 index 0000000..31ca26e --- /dev/null +++ b/resources/js/Pages/AccountPayable/Index.tsx @@ -0,0 +1,288 @@ +import { useState, useCallback } from 'react'; +import { Head, Link, router } from '@inertiajs/react'; +import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout'; +import { + Search, + Wallet, + Eye, + X, +} from "lucide-react"; +import { StatusBadge } from '@/Components/shared/StatusBadge'; +import { formatDate } from '@/lib/date'; +import Pagination from '@/Components/shared/Pagination'; +import { Can } from '@/Components/Permission/Can'; +import { Button } from '@/Components/ui/button'; +import { Input } from '@/Components/ui/input'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/Components/ui/table'; +import { SearchableSelect } from '@/Components/ui/searchable-select'; +import { debounce } from "lodash"; + +const STATUS_OPTIONS = [ + { value: 'all', label: '所有狀態' }, + { value: 'pending', label: '待處理' }, + { value: 'posted', label: '已入帳' }, + { value: 'paid', label: '已支付' }, + { value: 'voided', label: '已作廢' }, +]; + +const getStatusBadgeVariant = (status: string) => { + switch (status) { + case 'pending': return 'warning'; + case 'posted': return 'info'; + case 'paid': return 'success'; + case 'voided': return 'destructive'; + default: return 'neutral'; + } +}; + +const getStatusLabel = (status: string) => { + const found = STATUS_OPTIONS.find(opt => opt.value === status); + return found ? found.label : status; +}; + +export default function AccountPayableIndex({ payables, filters, vendors }: any) { + const [searchTerm, setSearchTerm] = useState(filters.search || ""); + const [statusFilter, setStatusFilter] = useState(filters.status || "all"); + const [vendorFilter, setVendorFilter] = useState(filters.vendor_id || "all"); + const [perPage, setPerPage] = useState(filters.per_page || "10"); + + // 穩定的防抖過濾函式 + const debouncedFilter = useCallback( + debounce((params: any) => { + router.get(route('account-payables.index'), params, { + preserveState: true, + replace: true, + preserveScroll: true, + }); + }, 500), + [] + ); + + const handleSearchChange = (term: string) => { + setSearchTerm(term); + debouncedFilter({ + ...filters, + search: term, + status: statusFilter === "all" ? "" : statusFilter, + vendor_id: vendorFilter === "all" ? "" : vendorFilter, + page: 1 + }); + }; + + const handleClearSearch = () => { + setSearchTerm(""); + debouncedFilter({ + ...filters, + search: "", + status: statusFilter === "all" ? "" : statusFilter, + vendor_id: vendorFilter === "all" ? "" : vendorFilter, + page: 1 + }); + }; + + const handleStatusChange = (value: string) => { + setStatusFilter(value); + debouncedFilter({ + ...filters, + search: searchTerm, + status: value === "all" ? "" : value, + vendor_id: vendorFilter === "all" ? "" : vendorFilter, + page: 1 + }); + }; + + const handleVendorChange = (value: string) => { + setVendorFilter(value); + debouncedFilter({ + ...filters, + search: searchTerm, + status: statusFilter === "all" ? "" : statusFilter, + vendor_id: value === "all" ? "" : value, + page: 1 + }); + }; + + const handlePerPageChange = (value: string) => { + setPerPage(value); + debouncedFilter({ + ...filters, + per_page: value, + page: 1 + }); + }; + + return ( + + + +
+ {/* 頁面標題 */} +
+
+

+ + 應付帳款管理 +

+

+ 追蹤與供應商的應付帳項,管理採購入庫後的結算與付款狀態。 +

+
+
+ + {/* 篩選工具列 */} +
+
+ {/* 搜尋 */} +
+ + handleSearchChange(e.target.value)} + className="pl-10 pr-10 h-9" + /> + {searchTerm && ( + + )} +
+ + {/* 狀態篩選 */} + + + {/* 供應商篩選 */} + ({ label: v.name, value: v.id.toString() })) + ]} + placeholder="選擇供應商" + className="w-full md:w-[200px] h-9" + /> +
+
+ +
+ + + + # + 應付單號 + 供應商 + 金額 + 到期日 + 狀態 + 操作 + + + + {payables.data.length === 0 ? ( + + + 尚無應付帳款資料 + + + ) : ( + payables.data.map((payable: any, index: number) => ( + router.visit(route('account-payables.show', [payable.id]))} + > + + {(payables.current_page - 1) * payables.per_page + index + 1} + + + {payable.document_number} + + + {payable.vendor?.name} + + + {new Intl.NumberFormat().format(payable.total_amount)} + + + {formatDate(payable.due_date)} + + + {/* @ts-ignore */} + + {getStatusLabel(payable.status)} + + + +
e.stopPropagation()} + > + + + + + +
+
+
+ )) + )} +
+
+
+ +
+
+
+ 每頁顯示 + + +
+ 共 {payables.total} 筆紀錄 +
+ +
+
+
+ ); +} diff --git a/resources/js/Pages/AccountPayable/Show.tsx b/resources/js/Pages/AccountPayable/Show.tsx new file mode 100644 index 0000000..45e47f8 --- /dev/null +++ b/resources/js/Pages/AccountPayable/Show.tsx @@ -0,0 +1,400 @@ +import { useState } from 'react'; +import { Head, Link, useForm } from '@inertiajs/react'; +import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout'; +import { Button } from '@/Components/ui/button'; +import { ExternalLink, ArrowLeft, Wallet, FileText, CheckCircle } from 'lucide-react'; +import { StatusBadge } from '@/Components/shared/StatusBadge'; +import { formatDate } from '@/lib/date'; +import { Badge } from '@/Components/ui/badge'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/Components/ui/dialog'; +import { Input } from '@/Components/ui/input'; +import { Label } from '@/Components/ui/label'; +import { toast } from 'sonner'; + +const getStatusBadgeVariant = (status: string) => { + switch (status) { + case 'pending': return 'warning'; + case 'partially_paid': return 'info'; + case 'paid': return 'success'; + case 'cancelled': return 'destructive'; + default: return 'neutral'; + } +}; + +const getStatusLabel = (status: string) => { + switch (status) { + case 'pending': return '待付款'; + case 'partially_paid': return '部分付款'; + case 'paid': return '已結清'; + case 'cancelled': return '已作廢'; + default: return status; + } +}; + +export default function AccountPayableShow({ payable }: any) { + const [invoiceDialogOpen, setInvoiceDialogOpen] = useState(false); + const [paymentDialogOpen, setPaymentDialogOpen] = useState(false); + + const invoiceForm = useForm({ + invoice_number: payable.invoice_number || '', + invoice_date: payable.invoice_date || '', + }); + + const paymentForm = useForm({ + payment_method: payable.payment_method || 'bank_transfer', + paid_at: payable.paid_at ? payable.paid_at.split('T')[0] : new Date().toISOString().split('T')[0], + payment_note: payable.payment_note || '', + }); + + const handleInvoiceSubmit = (e: React.FormEvent) => { + e.preventDefault(); + invoiceForm.post(route('account-payables.invoice', payable.id), { + preserveScroll: true, + onSuccess: () => { + setInvoiceDialogOpen(false); + toast.success('發票資訊已更新'); + }, + onError: (errors) => { + toast.error(Object.values(errors)[0] as string || '更新失敗'); + } + }); + }; + + const handlePaymentSubmit = (e: React.FormEvent) => { + e.preventDefault(); + paymentForm.post(route('account-payables.pay', payable.id), { + preserveScroll: true, + onSuccess: () => { + setPaymentDialogOpen(false); + toast.success('帳款已成功標記為已付款'); + }, + onError: (errors) => { + toast.error(Object.values(errors)[0] as string || '標記失敗'); + } + }); + }; + + return ( + + + +
+ {/* Back Button */} +
+ + + +
+ + {/* 頁面標題與操作 */} +
+
+

+ + {payable.document_number} +

+
+ {/* @ts-ignore */} + + {getStatusLabel(payable.status)} + + + {formatDate(payable.created_at)} + +
+
+ +
+ + + {payable.status !== 'paid' && ( + + )} +
+
+ +
+ {/* 基本資料 */} +
+

基本資料

+
+
+ 供應商 +

+ {payable.vendor?.name || '未知供應商'} +

+
+
+ 總金額 +

+ ${parseFloat(payable.total_amount).toLocaleString()} +

+
+
+ 到期日 +

+ {formatDate(payable.due_date)} +

+
+
+ 建立人 +

+ {payable.creator?.name || '-'} +

+
+
+ 提交時間 +

+ {formatDate(payable.created_at)} +

+
+ {payable.remarks && ( +
+ 備註 +

+ {payable.remarks} +

+
+ )} +
+
+ + {/* 來源關聯 */} +
+

來源關聯

+
+
+ 來源類型 +

+ {payable.source_document_type === 'goods_receipt' ? ( + 進貨單 + ) : ( + payable.source_document_type || '-' + )} +

+
+
+ 來源單號 +
+

+ {payable.source_document_code || payable.source_document_id || '-'} +

+ {payable.source_document_type === 'goods_receipt' && payable.source_document_id && ( + + 查閱 + + )} +
+
+
+
+ +
+ {/* 發票資訊 */} +
+
+

+ + 發票資訊 +

+ +
+
+
+ 發票號碼 +

+ {payable.invoice_number || 尚未登記} +

+
+
+ 發票日期 +

+ {payable.invoice_date ? formatDate(payable.invoice_date) : '-'} +

+
+
+
+ + {/* 付款資訊 */} + {payable.status === 'paid' && ( +
+

+ + 付款資訊 +

+
+
+
+ 付款狀態 +

+ 已結清 +

+
+
+ 付款方式 +

+ {payable.payment_method === 'cash' && '現金'} + {payable.payment_method === 'bank_transfer' && '銀行轉帳'} + {payable.payment_method === 'check' && '支票'} + {payable.payment_method === 'credit_card' && '信用卡'} + {!['cash', 'bank_transfer', 'check', 'credit_card'].includes(payable.payment_method) && (payable.payment_method || '-')} +

+
+
+ 付款日期 +

+ {payable.paid_at ? formatDate(payable.paid_at) : '-'} +

+
+
+ {payable.payment_note && ( +
+ 付款備註 +

+ {payable.payment_note} +

+
+ )} +
+
+ )} +
+
+ + {/* 發票對話框 */} + + +
+ + 登記發票資訊 + + 請填寫供應商開立的發票資料以利後續帳務核對 + + +
+
+ + invoiceForm.setData('invoice_number', e.target.value)} + placeholder="例如: AB12345678" + /> + {invoiceForm.errors.invoice_number &&

{invoiceForm.errors.invoice_number}

} +
+
+ + invoiceForm.setData('invoice_date', e.target.value)} + /> + {invoiceForm.errors.invoice_date &&

{invoiceForm.errors.invoice_date}

} +
+
+ + + + +
+
+
+ + {/* 付款對話框 */} + + +
+ + 標記為已付款 + + 標示此應付帳款已支付給供應商。記錄一旦送出後,付款資訊將無法輕易修改。 + + +
+
+ + + {paymentForm.errors.payment_method &&

{paymentForm.errors.payment_method}

} +
+
+ + paymentForm.setData('paid_at', e.target.value)} + required + /> + {paymentForm.errors.paid_at &&

{paymentForm.errors.paid_at}

} +
+
+ + paymentForm.setData('payment_note', e.target.value)} + placeholder="例如: 匯款帳號後五碼、支票號碼..." + /> + {paymentForm.errors.payment_note &&

{paymentForm.errors.payment_note}

} +
+
+ + + + +
+
+
+ +
+
+ ); +} diff --git a/resources/js/Pages/Inventory/GoodsReceipt/Index.tsx b/resources/js/Pages/Inventory/GoodsReceipt/Index.tsx index 1bfa3f8..eb82cc8 100644 --- a/resources/js/Pages/Inventory/GoodsReceipt/Index.tsx +++ b/resources/js/Pages/Inventory/GoodsReceipt/Index.tsx @@ -106,8 +106,8 @@ export default function GoodsReceiptIndex({ receipts, filters, warehouses }: Pro const statusOptions = [ { label: '全部狀態', value: 'all' }, + { label: '草稿', value: 'draft' }, { label: '已完成', value: 'completed' }, - { label: '處理中', value: 'processing' }, ]; const warehouseOptions = [ diff --git a/resources/js/Pages/Inventory/GoodsReceipt/Show.tsx b/resources/js/Pages/Inventory/GoodsReceipt/Show.tsx index 7860ea8..54c2d2a 100644 --- a/resources/js/Pages/Inventory/GoodsReceipt/Show.tsx +++ b/resources/js/Pages/Inventory/GoodsReceipt/Show.tsx @@ -5,7 +5,8 @@ import { ArrowLeft, Package } from "lucide-react"; import { Button } from "@/Components/ui/button"; import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; -import { Head, Link } from "@inertiajs/react"; +import { Head, Link, usePage, useForm } from "@inertiajs/react"; +import { useState } from "react"; import GoodsReceiptStatusBadge from "@/Components/Inventory/GoodsReceiptStatusBadge"; import CopyButton from "@/Components/shared/CopyButton"; import { @@ -16,8 +17,20 @@ import { TableHeader, TableRow, } from "@/Components/ui/table"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/Components/ui/alert-dialog"; import { formatCurrency, formatDate, formatDateTime } from "@/utils/format"; -import { getShowBreadcrumbs } from "@/utils/breadcrumb"; +import { PageProps } from "@/types/global"; +import { toast } from "sonner"; + +// ... (省略中間介面定義) interface GoodsReceiptItem { id: number; @@ -66,34 +79,43 @@ export default function ViewGoodsReceiptPage({ receipt }: Props) { other: "其他入庫", }; + const breadcrumbs = [ + { label: "庫存管理", href: "#" }, + { label: "進貨單管理", href: route("goods-receipts.index") }, + { label: `單據詳情 (#${receipt.code})` }, + ]; + return ( - +
{/* Header */}
- + +
-
-
-

- - 查看進貨單 -

-

單號:{receipt.code}

-
-
+
+
+

+ + 查看進貨單 +

+
+ 單號:{receipt.code}
+
+ +
@@ -219,3 +241,95 @@ export default function ViewGoodsReceiptPage({ receipt }: Props) { ); } + +function GoodsReceiptActions({ receipt }: { receipt: GoodsReceipt }) { + const { auth } = usePage().props; + const permissions = auth.user?.permissions || []; + const roles = auth.user?.roles || []; + const isSuperAdmin = roles.includes('super-admin'); + + // 權限判斷 + const canView = isSuperAdmin || permissions.includes('goods_receipts.view'); + const canEdit = isSuperAdmin || permissions.includes('goods_receipts.update'); + const canDelete = isSuperAdmin || permissions.includes('goods_receipts.delete'); + + const canSubmit = canEdit || canView; + + // 對話框狀態 + const [dialogType, setDialogType] = useState<"submit" | "delete" | null>(null); + + const { post, delete: destroy, processing } = useForm({}); + + const handleAction = () => { + if (!dialogType) return; + + const options = { + onSuccess: () => { + toast.success('操作成功'); + setDialogType(null); + }, + onError: (errors: any) => toast.error(errors.error || '操作失敗') + }; + + switch (dialogType) { + case "submit": + post(route('goods-receipts.submit', receipt.id), options); + break; + case "delete": + destroy(route('goods-receipts.destroy', receipt.id), options); + break; + } + }; + + return ( +
+ {receipt.status === 'draft' && canDelete && ( + + )} + + {receipt.status === 'draft' && canSubmit && ( + + )} + + {/* 統一確認對話框 */} + !open && setDialogType(null)}> + + + + {dialogType === "submit" && "確認點收"} + {dialogType === "delete" && "刪除進貨單"} + + + {dialogType === "submit" && "確定已點收無誤嗎?送出後將會更新庫存、關聯單據數量,並自動產生應付帳款,且無法再次退回。"} + {dialogType === "delete" && `將刪除進貨單「${receipt.code}」。注意:此操作無法復原。`} + + + + + 取消 + + + + +
+ ); +} diff --git a/resources/js/Pages/Inventory/Transfer/Index.tsx b/resources/js/Pages/Inventory/Transfer/Index.tsx index 98a70ae..22e461d 100644 --- a/resources/js/Pages/Inventory/Transfer/Index.tsx +++ b/resources/js/Pages/Inventory/Transfer/Index.tsx @@ -1,5 +1,4 @@ - -import { useState, useCallback, useEffect } from "react"; +import { useState, useCallback } from "react"; import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; import { Head, Link, router } from "@inertiajs/react"; import { debounce } from "lodash"; @@ -56,12 +55,9 @@ export default function Index({ warehouses, orders, filters }: any) { const [warehouseFilter, setWarehouseFilter] = useState(filters.warehouse_id || "all"); const [perPage, setPerPage] = useState(filters.per_page || "10"); - // Sync state with props - useEffect(() => { - setSearchTerm(filters.search || ""); - setWarehouseFilter(filters.warehouse_id || "all"); - setPerPage(filters.per_page || "10"); - }, [filters]); + // Sync state with props only on initial load or when necessary + // Removed overly aggressive useEffect that overwrites local state on every filters change + // This was causing the search input to reset when props returned before the next debounce cycle // Create Dialog State const [isCreateOpen, setIsCreateOpen] = useState(false); @@ -70,47 +66,52 @@ export default function Index({ warehouses, orders, filters }: any) { const [creating, setCreating] = useState(false); const [deleteId, setDeleteId] = useState(null); - // Debounced Search Handler - const debouncedSearch = useCallback( - debounce((term: string, warehouse: string) => { - router.get( - route('inventory.transfer.index'), - { ...filters, search: term, warehouse_id: warehouse === "all" ? "" : warehouse }, - { preserveState: true, replace: true, preserveScroll: true } - ); + const debouncedFilter = useCallback( + debounce((params: any) => { + router.get(route('inventory.transfer.index'), params, { + preserveState: true, + replace: true, + }); }, 500), - [filters] + [] ); const handleSearchChange = (term: string) => { setSearchTerm(term); - debouncedSearch(term, warehouseFilter); + debouncedFilter({ + ...filters, + search: term, + warehouse_id: warehouseFilter === "all" ? "" : warehouseFilter, + page: 1 + }); }; const handleFilterChange = (value: string) => { setWarehouseFilter(value); - router.get( - route('inventory.transfer.index'), - { ...filters, warehouse_id: value === "all" ? "" : value }, - { preserveState: false, replace: true, preserveScroll: true } - ); + debouncedFilter({ + ...filters, + search: searchTerm, + warehouse_id: value === "all" ? "" : value, + page: 1 + }); }; const handleClearSearch = () => { setSearchTerm(""); - router.get( - route('inventory.transfer.index'), - { ...filters, search: "", warehouse_id: warehouseFilter === "all" ? "" : warehouseFilter }, - { preserveState: true, replace: true, preserveScroll: true } - ); + debouncedFilter({ + ...filters, + search: "", + warehouse_id: warehouseFilter === "all" ? "" : warehouseFilter, + page: 1 + }); }; const handlePerPageChange = (value: string) => { setPerPage(value); router.get( route('inventory.transfer.index'), - { ...filters, per_page: value }, - { preserveState: false, replace: true, preserveScroll: true } + { ...filters, search: searchTerm, warehouse_id: warehouseFilter === "all" ? "" : warehouseFilter, per_page: value, page: 1 }, + { preserveState: true, replace: true, preserveScroll: true } ); }; diff --git a/resources/js/Pages/PurchaseOrder/Show.tsx b/resources/js/Pages/PurchaseOrder/Show.tsx index 1705f6e..073e9f5 100644 --- a/resources/js/Pages/PurchaseOrder/Show.tsx +++ b/resources/js/Pages/PurchaseOrder/Show.tsx @@ -26,29 +26,34 @@ export default function ViewPurchaseOrderPage({ order }: Props) {
{/* Header */} + {/* 返回按鈕 */}
+
-
-
-

- - 查看採購單 -

-

單號:{order.poNumber}

-
-
+ {/* 頁面標題與操作 */} +
+
+

+ + 查看採購單 +

+
+ 單號:{order.poNumber}
+
+ +
{/* 狀態流程條 */}
@@ -169,10 +174,6 @@ export default function ViewPurchaseOrderPage({ order }: Props) {
- {/* 操作按鈕 (底部) */} -
- -
@@ -225,16 +226,15 @@ function PurchaseOrderActions({ order }: { order: PurchaseOrder }) { const canSubmit = canEdit || canView; return ( -
+
{['draft', 'pending', 'approved'].includes(order.status) && canCancel && ( )} @@ -243,23 +243,19 @@ function PurchaseOrderActions({ order }: { order: PurchaseOrder }) { onClick={() => handleUpdateStatus('draft', '退回')} disabled={processing} variant="outline" - size="xl" - className="button-outlined-warning shadow-amber-200/20" + className="button-outlined-warning" > - 退回 + 退回 )} -
- {order.status === 'draft' && canSubmit && ( )} @@ -267,10 +263,9 @@ function PurchaseOrderActions({ order }: { order: PurchaseOrder }) { )}
diff --git a/resources/js/Pages/StoreRequisition/Index.tsx b/resources/js/Pages/StoreRequisition/Index.tsx index 41b1d7d..2885b76 100644 --- a/resources/js/Pages/StoreRequisition/Index.tsx +++ b/resources/js/Pages/StoreRequisition/Index.tsx @@ -81,58 +81,74 @@ export default function Index({ setPerPage(filters.per_page || "10"); }, [filters]); - const applyFilters = useCallback( - (overrides: Record = {}) => { - const params: Record = { - search: searchTerm, - status: statusFilter === "all" ? "" : statusFilter, - warehouse_id: warehouseFilter === "all" ? "" : warehouseFilter, - per_page: perPage, - ...overrides, - }; - // 清理空值 - Object.keys(params).forEach((key) => { - if (!params[key]) delete params[key]; - }); + const debouncedFilter = useCallback( + debounce((params: any) => { router.get(route("store-requisitions.index"), params, { preserveState: true, replace: true, - preserveScroll: true, }); - }, - [searchTerm, statusFilter, warehouseFilter, perPage] - ); - - const debouncedSearch = useCallback( - debounce((term: string) => { - applyFilters({ search: term }); - }, 500), - [applyFilters] + }, 300), + [] ); const handleSearchChange = (term: string) => { setSearchTerm(term); - debouncedSearch(term); + debouncedFilter({ + ...filters, + search: term, + status: statusFilter === "all" ? "" : statusFilter, + warehouse_id: warehouseFilter === "all" ? "" : warehouseFilter, + page: 1, + }); }; const handleClearSearch = () => { setSearchTerm(""); - applyFilters({ search: "" }); + debouncedFilter({ + ...filters, + search: "", + status: statusFilter === "all" ? "" : statusFilter, + warehouse_id: warehouseFilter === "all" ? "" : warehouseFilter, + page: 1, + }); }; const handleStatusChange = (value: string) => { setStatusFilter(value); - applyFilters({ status: value === "all" ? "" : value }); + debouncedFilter({ + ...filters, + search: searchTerm, + status: value === "all" ? "" : value, + warehouse_id: warehouseFilter === "all" ? "" : warehouseFilter, + page: 1, + }); }; const handleWarehouseChange = (value: string) => { setWarehouseFilter(value); - applyFilters({ warehouse_id: value === "all" ? "" : value }); + debouncedFilter({ + ...filters, + search: searchTerm, + status: statusFilter === "all" ? "" : statusFilter, + warehouse_id: value === "all" ? "" : value, + page: 1, + }); }; const handlePerPageChange = (value: string) => { setPerPage(value); - applyFilters({ per_page: value }); + router.get( + route("store-requisitions.index"), + { + ...filters, + search: searchTerm, + status: statusFilter === "all" ? "" : statusFilter, + warehouse_id: warehouseFilter === "all" ? "" : warehouseFilter, + per_page: value, + page: 1, + }, + { preserveState: true, replace: true, preserveScroll: true } + ); }; const handleDelete = () => { diff --git a/resources/js/Pages/StoreRequisition/Show.tsx b/resources/js/Pages/StoreRequisition/Show.tsx index af9783e..5a85178 100644 --- a/resources/js/Pages/StoreRequisition/Show.tsx +++ b/resources/js/Pages/StoreRequisition/Show.tsx @@ -23,6 +23,13 @@ import { DialogFooter, DialogDescription, } from "@/Components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/Components/ui/select"; import { AlertDialog, AlertDialogAction, @@ -138,10 +145,6 @@ export default function Show({ requisition, warehouses }: Props) { }; const handleApprove = () => { - if (!supplyWarehouseId) { - toast.error("請選擇供貨倉庫"); - return; - } // 確認每個核准數量 for (const item of approvedItems) { const qty = parseFloat(item.approved_qty); @@ -155,7 +158,6 @@ export default function Show({ requisition, warehouses }: Props) { router.post( route("store-requisitions.approve", [requisition.id]), { - supply_warehouse_id: supplyWarehouseId, items: approvedItems.map((item) => ({ id: item.id, approved_qty: parseFloat(item.approved_qty), @@ -196,6 +198,20 @@ export default function Show({ requisition, warehouses }: Props) { const isEditable = ["draft", "rejected"].includes(requisition.status); const isPending = requisition.status === "pending"; + const canApprove = usePermission().can("store_requisitions.approve"); + + const handleUpdateSupplyWarehouse = (warehouseId: string) => { + setSubmitting(true); + router.patch( + route("store-requisitions.update-supply-warehouse", [requisition.id]), + { supply_warehouse_id: warehouseId }, + { + onFinish: () => setSubmitting(false), + onSuccess: () => toast.success("供貨倉庫已更新"), + preserveScroll: true, + } + ); + }; return (
供貨倉庫 -

- {requisition.supply_warehouse_name || "-"} -

+
+ {isPending && canApprove ? ( + w.id !== requisition.store_warehouse_id) + .map((w) => ({ + label: w.name, + value: w.id.toString(), + }))} + placeholder="選擇供貨倉庫" + className="h-9 w-full max-w-[200px]" + disabled={submitting} + /> + ) : ( +

+ {requisition.supply_warehouse_name || "-"} +

+ )} +
申請人 @@ -455,22 +489,14 @@ export default function Show({ requisition, warehouses }: Props) { 選擇供貨倉庫,並確認各商品的核准數量。
-
- - w.id !== requisition.store_warehouse_id) - .map((w) => ({ - label: w.name, - value: w.id.toString(), - }))} - placeholder="請選擇供貨倉庫" - className="h-9" - /> +
+
+ 供貨倉庫: + {requisition.supply_warehouse_name || "尚未選擇"} +
+ {!requisition.supply_warehouse_id && ( + * 請先在基本資訊中選擇供貨倉庫 + )}
@@ -534,7 +560,7 @@ export default function Show({ requisition, warehouses }: Props) {