feat: 完成進貨單自動拋轉應付帳款流程與AP介面優化
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 1m8s

1. 新增 AccountPayable (應付帳款) 模組,包含 Migration、Model、Service 與 Controller
2. 修改 GoodsReceipt (進貨單) 流程,在確認進貨時自動產生對應的應付帳款單 (AP-YYYYMMDD-XX)
3. 實作應付帳款詳細頁面 (Show.tsx),包含發票登記與標記付款功能
4. 修正應付帳款 Show 頁面的排版,將發票資訊套用標準的綠色背景區塊,並調整按鈕位置
5. 更新相關的 Service Provider 與 Routes
This commit is contained in:
2026-02-24 16:46:55 +08:00
parent aaa93a921e
commit 455f945296
33 changed files with 1708 additions and 186 deletions

View File

@@ -0,0 +1,124 @@
<?php
namespace App\Modules\Finance\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Finance\Models\AccountPayable;
use Illuminate\Http\Request;
use Inertia\Inertia;
class AccountPayableController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index(Request $request)
{
$query = AccountPayable::with(['vendor', 'creator']);
// 關鍵字搜尋 (單號、供應商名稱)
if ($request->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', '帳款已成功標記為已付款');
}
}

View File

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

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Modules\Finance\Listeners;
use App\Modules\Inventory\Events\GoodsReceiptApprovedEvent;
use App\Modules\Finance\Services\AccountPayableService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Log;
class CreateAccountPayableFromGoodsReceipt
{
protected AccountPayableService $accountPayableService;
/**
* Create the event listener.
*/
public function __construct(AccountPayableService $accountPayableService)
{
$this->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;
}
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Modules\Finance\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AccountPayable extends Model
{
use HasFactory;
public const STATUS_PENDING = 'pending';
public const STATUS_PARTIALLY_PAID = 'partially_paid';
public const STATUS_PAID = 'paid';
public const STATUS_CANCELLED = 'cancelled';
protected $fillable = [
'vendor_id',
'source_document_type',
'source_document_id',
'document_number',
'total_amount',
'tax_amount',
'status',
'due_date',
'invoice_number',
'invoice_date',
'paid_at',
'payment_method',
'payment_note',
'remarks',
'created_by',
];
protected $casts = [
'total_amount' => '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');
}
}

View File

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

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Modules\Finance\Services;
use App\Modules\Finance\Models\AccountPayable;
use Illuminate\Support\Facades\DB;
use App\Modules\Inventory\Contracts\GoodsReceiptServiceInterface;
class AccountPayableService
{
protected GoodsReceiptServiceInterface $goodsReceiptService;
public function __construct(GoodsReceiptServiceInterface $goodsReceiptService)
{
$this->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;
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Modules\Inventory\Contracts;
interface GoodsReceiptServiceInterface
{
/**
* 獲取指定的進貨單資訊
*
* @param int $goodsReceiptId
* @return array|null 返回進貨單的純陣列資料,若找不到則回傳 null
*/
public function getGoodsReceiptData(int $goodsReceiptId): ?array;
}

View File

@@ -10,6 +10,7 @@ use Illuminate\Http\Request;
use App\Modules\Procurement\Models\Vendor;
use Inertia\Inertia;
use App\Modules\Inventory\Models\GoodsReceipt;
use Illuminate\Support\Facades\DB;
class GoodsReceiptController extends Controller
{
@@ -174,9 +175,26 @@ class GoodsReceiptController extends Controller
'items.*.expiry_date' => '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

View File

@@ -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', '供貨倉庫已更新');
}
/**
* 刪除叫貨單(僅限草稿)
*/

View File

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

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Modules\Inventory\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class GoodsReceiptApprovedEvent
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public $goodsReceiptId;
/**
* Create a new event instance.
*/
public function __construct(int $goodsReceiptId)
{
$this->goodsReceiptId = $goodsReceiptId;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('account_payables', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('goods_receipts', function (Blueprint $table) {
$table->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();
});
}
};

View File

@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('account_payables', function (Blueprint $table) {
$table->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'
]);
});
}
};

24
fix_ap.php Normal file
View File

@@ -0,0 +1,24 @@
<?php
use App\Modules\Finance\Models\AccountPayable;
use App\Modules\Inventory\Models\GoodsReceiptItem;
$ap = AccountPayable::latest()->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";
}

View File

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

View File

@@ -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 (
<div className="flex justify-center gap-2">
<Link href={route('goods-receipts.show', receipt.id)}>
@@ -59,43 +62,46 @@ export default function GoodsReceiptActions({
</Button>
</Link>
{/* Delete typically restricted for Goods Receipts, checking permission */}
<Can permission="goods_receipts.delete">
<Button
variant="outline"
size="sm"
className="button-outlined-error"
title="刪除"
onClick={() => setShowDeleteDialog(true)}
disabled={processing}
>
<Trash2 className="h-4 w-4" />
</Button>
{/* 只允許刪除草稿或已退回的進貨單 */}
{(receipt.status === 'draft' || receipt.status === 'rejected') && (
<Can permission="goods_receipts.delete">
<Button
variant="outline"
size="sm"
className="button-outlined-error"
title="刪除"
onClick={() => setShowDeleteDialog(true)}
disabled={processing}
>
<Trash2 className="h-4 w-4" />
</Button>
</Can>
)}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{receipt.code}
<br />
<span className="text-red-500 font-bold mt-2 block">
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="button-outlined-primary"></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
className="button-filled-error"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{receipt.code}
<br />
<span className="text-red-500 font-bold mt-2 block">
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="button-outlined-primary"></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
className="button-filled-error"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Can>
</div>
);
}

View File

@@ -3,9 +3,12 @@ import { StatusBadge, StatusVariant } from "@/Components/shared/StatusBadge";
export type GoodsReceiptStatus = 'processing' | 'completed' | 'cancelled';
export const GOODS_RECEIPT_STATUS_CONFIG: Record<string, { label: string; variant: StatusVariant }> = {
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 {

View File

@@ -225,15 +225,22 @@ export default function AuthenticatedLayout({
id: "finance-management",
label: "財務管理",
icon: <Wallet className="h-5 w-5" />,
permission: "utility_fees.view",
permission: ["utility_fees.view", "account_payables.view"],
children: [
{
id: "utility-fee-list",
label: "公共事業費",
icon: <FileText className="h-4 w-4" />,
route: "/utility-fees",
route: '/utility-fees',
permission: "utility_fees.view",
},
{
id: "account-payable-list",
label: "應付帳款",
icon: <FileText className="h-4 w-4" />,
route: '/finance/account-payables',
permission: "account_payables.view", // 假設這為該功能的權限
},
],
},
{
@@ -246,14 +253,14 @@ export default function AuthenticatedLayout({
id: "accounting-report",
label: "會計報表",
icon: <FileSpreadsheet className="h-4 w-4" />,
route: "/accounting-report",
route: '/accounting-report',
permission: "accounting.view",
},
{
id: "inventory-report",
label: "庫存報表",
icon: <BarChart3 className="h-4 w-4" />,
route: "/inventory/report",
route: '/inventory/report',
permission: "inventory_report.view",
},
{
@@ -612,7 +619,7 @@ export default function AuthenticatedLayout({
{isCollapsed ? <PanelLeftOpen className="h-5 w-5" /> : <PanelLeftClose className="h-5 w-5" />}
</button>
</div>
</aside >
</aside>
{/* 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"
)}>
<div className="relative flex-1 flex flex-col min-h-0">
<div className="relative flex-1 flex flex-col min-w-0 w-full">
<div className="container mx-auto px-6 pt-6 max-w-7xl shrink-0">
{breadcrumbs && breadcrumbs.length > 1 && (
<BreadcrumbNav items={breadcrumbs} className="mb-2" />
@@ -665,6 +673,6 @@ export default function AuthenticatedLayout({
</footer>
<Toaster richColors closeButton position="top-center" />
</main>
</div >
</div>
);
}

View File

@@ -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 (
<AuthenticatedLayout
breadcrumbs={[
{ label: '財務管理', href: '#' },
{ label: '應付帳款管理' }
]}
>
<Head title="應付帳款管理" />
<div className="container mx-auto p-6 max-w-7xl">
{/* 頁面標題 */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<Wallet className="h-6 w-6 text-primary-main" />
</h1>
<p className="text-gray-500 mt-1">
</p>
</div>
</div>
{/* 篩選工具列 */}
<div className="bg-white rounded-lg shadow-sm border p-4 mb-6">
<div className="flex flex-col md:flex-row gap-4">
{/* 搜尋 */}
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
<Input
placeholder="搜尋單號或備註..."
value={searchTerm}
onChange={(e) => handleSearchChange(e.target.value)}
className="pl-10 pr-10 h-9"
/>
{searchTerm && (
<button
onClick={handleClearSearch}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{/* 狀態篩選 */}
<SearchableSelect
value={statusFilter}
onValueChange={handleStatusChange}
options={[
{ label: "所有狀態", value: "all" },
{ label: "待處理", value: "pending" },
{ label: "已入帳", value: "posted" },
{ label: "已支付", value: "paid" },
{ label: "已作廢", value: "voided" }
]}
placeholder="選擇狀態"
className="w-full md:w-[160px] h-9"
showSearch={false}
/>
{/* 供應商篩選 */}
<SearchableSelect
value={vendorFilter}
onValueChange={handleVendorChange}
options={[
{ label: "所有供應商", value: "all" },
...vendors.map((v: any) => ({ label: v.name, value: v.id.toString() }))
]}
placeholder="選擇供應商"
className="w-full md:w-[200px] h-9"
/>
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="w-[50px] text-center font-medium text-gray-600">#</TableHead>
<TableHead className="font-medium text-gray-600"></TableHead>
<TableHead className="font-medium text-gray-600"></TableHead>
<TableHead className="font-medium text-gray-600 text-right"></TableHead>
<TableHead className="font-medium text-gray-600"></TableHead>
<TableHead className="text-center font-medium text-gray-600"></TableHead>
<TableHead className="text-center font-medium text-gray-600"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{payables.data.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-10 text-gray-500">
</TableCell>
</TableRow>
) : (
payables.data.map((payable: any, index: number) => (
<TableRow
key={payable.id}
className="hover:bg-gray-50/50 transition-colors cursor-pointer group"
onClick={() => router.visit(route('account-payables.show', [payable.id]))}
>
<TableCell className="text-center text-gray-500 font-medium">
{(payables.current_page - 1) * payables.per_page + index + 1}
</TableCell>
<TableCell className="font-medium text-primary-main">
{payable.document_number}
</TableCell>
<TableCell className="text-gray-700">
{payable.vendor?.name}
</TableCell>
<TableCell className="text-right font-mono">
{new Intl.NumberFormat().format(payable.total_amount)}
</TableCell>
<TableCell className="text-gray-500 text-sm">
{formatDate(payable.due_date)}
</TableCell>
<TableCell className="text-center">
{/* @ts-ignore */}
<StatusBadge variant={getStatusBadgeVariant(payable.status)}>
{getStatusLabel(payable.status)}
</StatusBadge>
</TableCell>
<TableCell className="text-center">
<div
className="flex items-center justify-center gap-2"
onClick={(e) => e.stopPropagation()}
>
<Can permission="account_payables.view">
<Link href={route('account-payables.show', [payable.id])}>
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
title="查閱"
>
<Eye className="w-4 h-4 ml-0.5" />
</Button>
</Link>
</Can>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<div className="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 text-sm text-gray-500">
<span></span>
<SearchableSelect
value={perPage}
onValueChange={handlePerPageChange}
options={[
{ label: "10", value: "10" },
{ label: "20", value: "20" },
{ label: "50", value: "50" },
{ label: "100", value: "100" }
]}
className="w-[90px] h-8"
showSearch={false}
/>
<span></span>
</div>
<span className="text-sm text-gray-500"> {payables.total} </span>
</div>
<Pagination links={payables.links} />
</div>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -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 (
<AuthenticatedLayout
breadcrumbs={[
{ label: '財務管理', href: '#' },
{ label: '應付帳款', href: route('account-payables.index') },
{ label: `詳情: ${payable.document_number}` }
]}
>
<Head title={`應付帳款 - ${payable.document_number}`} />
<div className="container mx-auto p-6 max-w-7xl">
{/* Back Button */}
<div className="mb-6">
<Link href={route('account-payables.index')}>
<Button
variant="outline"
className="gap-2 button-outlined-primary"
>
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
</div>
{/* 頁面標題與操作 */}
<div className="flex items-start justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<Wallet className="h-6 w-6 text-primary-main" />
{payable.document_number}
</h1>
<div className="flex items-center gap-2 mt-1">
{/* @ts-ignore */}
<StatusBadge variant={getStatusBadgeVariant(payable.status)}>
{getStatusLabel(payable.status)}
</StatusBadge>
<span className="text-gray-500 text-sm">
{formatDate(payable.created_at)}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
className="gap-2 button-outlined-primary"
onClick={() => setInvoiceDialogOpen(true)}
>
<FileText className="h-4 w-4" />
</Button>
{payable.status !== 'paid' && (
<Button
className="gap-2 button-filled-primary"
onClick={() => setPaymentDialogOpen(true)}
>
<CheckCircle className="h-4 w-4" />
</Button>
)}
</div>
</div>
<div className="space-y-6">
{/* 基本資料 */}
<div className="bg-white rounded-lg shadow-sm border p-6">
<h2 className="text-lg font-semibold text-gray-800 mb-4"></h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<span className="text-sm text-gray-500"></span>
<p className="font-medium text-gray-800 mt-1">
{payable.vendor?.name || '未知供應商'}
</p>
</div>
<div>
<span className="text-sm text-gray-500"></span>
<p className="font-medium text-gray-800 mt-1">
${parseFloat(payable.total_amount).toLocaleString()}
</p>
</div>
<div>
<span className="text-sm text-gray-500"></span>
<p className="font-medium text-gray-800 mt-1">
{formatDate(payable.due_date)}
</p>
</div>
<div>
<span className="text-sm text-gray-500"></span>
<p className="font-medium text-gray-800 mt-1">
{payable.creator?.name || '-'}
</p>
</div>
<div>
<span className="text-sm text-gray-500"></span>
<p className="font-medium text-gray-800 mt-1">
{formatDate(payable.created_at)}
</p>
</div>
{payable.remarks && (
<div className="md:col-span-3">
<span className="text-sm text-gray-500"></span>
<p className="font-medium text-gray-800 mt-1">
{payable.remarks}
</p>
</div>
)}
</div>
</div>
{/* 來源關聯 */}
<div className="bg-white rounded-lg shadow-sm border p-6">
<h2 className="text-lg font-semibold text-gray-800 mb-4"></h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<span className="text-sm text-gray-500"></span>
<p className="font-medium text-gray-800 mt-1">
{payable.source_document_type === 'goods_receipt' ? (
<Badge variant="outline" className="font-normal"></Badge>
) : (
payable.source_document_type || '-'
)}
</p>
</div>
<div>
<span className="text-sm text-gray-500"></span>
<div className="flex items-center gap-2 mt-1">
<p className="font-medium text-gray-800">
{payable.source_document_code || payable.source_document_id || '-'}
</p>
{payable.source_document_type === 'goods_receipt' && payable.source_document_id && (
<Link
href={route('goods-receipts.show', [payable.source_document_id])}
className="text-primary-main hover:underline flex items-center gap-1 text-sm font-medium"
>
<ExternalLink className="h-3 w-3" />
</Link>
)}
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 發票資訊 */}
<div className="bg-primary-main/5 rounded-xl border border-primary-main/20 p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold text-primary-main flex items-center gap-2">
<FileText className="h-5 w-5 text-primary-main" />
</h2>
<Button
variant="ghost"
size="sm"
className="text-primary-main hover:text-primary-dark hover:bg-primary-main/10"
onClick={() => setInvoiceDialogOpen(true)}
>
{payable.invoice_number ? '修改' : '填寫'}
</Button>
</div>
<div className="space-y-4">
<div>
<span className="text-sm text-primary-main/70"></span>
<p className="font-medium text-primary-dark mt-1">
{payable.invoice_number || <span className="opacity-70 italic"></span>}
</p>
</div>
<div>
<span className="text-sm text-primary-main/70"></span>
<p className="font-medium text-primary-dark mt-1">
{payable.invoice_date ? formatDate(payable.invoice_date) : '-'}
</p>
</div>
</div>
</div>
{/* 付款資訊 */}
{payable.status === 'paid' && (
<div className="bg-white rounded-lg shadow-sm border p-6">
<h2 className="text-lg font-semibold text-gray-800 mb-4 flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-success-main" />
</h2>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<span className="text-sm text-gray-500"></span>
<p className="font-medium text-success-main mt-1">
</p>
</div>
<div>
<span className="text-sm text-gray-500"></span>
<p className="font-medium text-gray-800 mt-1">
{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 || '-')}
</p>
</div>
<div>
<span className="text-sm text-gray-500"></span>
<p className="font-medium text-gray-800 mt-1">
{payable.paid_at ? formatDate(payable.paid_at) : '-'}
</p>
</div>
</div>
{payable.payment_note && (
<div>
<span className="text-sm text-gray-500"></span>
<p className="font-medium text-gray-800 mt-1">
{payable.payment_note}
</p>
</div>
)}
</div>
</div>
)}
</div>
</div>
{/* 發票對話框 */}
<Dialog open={invoiceDialogOpen} onOpenChange={setInvoiceDialogOpen}>
<DialogContent>
<form onSubmit={handleInvoiceSubmit}>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="invoice_number"></Label>
<Input
id="invoice_number"
value={invoiceForm.data.invoice_number}
onChange={e => invoiceForm.setData('invoice_number', e.target.value)}
placeholder="例如: AB12345678"
/>
{invoiceForm.errors.invoice_number && <p className="text-sm text-destructive">{invoiceForm.errors.invoice_number}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="invoice_date"></Label>
<Input
id="invoice_date"
type="date"
value={invoiceForm.data.invoice_date}
onChange={e => invoiceForm.setData('invoice_date', e.target.value)}
/>
{invoiceForm.errors.invoice_date && <p className="text-sm text-destructive">{invoiceForm.errors.invoice_date}</p>}
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" className="button-outlined-primary" onClick={() => setInvoiceDialogOpen(false)}></Button>
<Button type="submit" disabled={invoiceForm.processing} className="button-filled-primary"></Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{/* 付款對話框 */}
<Dialog open={paymentDialogOpen} onOpenChange={setPaymentDialogOpen}>
<DialogContent>
<form onSubmit={handlePaymentSubmit}>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="payment_method"></Label>
<select
id="payment_method"
className="flex h-10 w-full items-center justify-between rounded-md border border-neutral-200 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-main focus:border-transparent text-gray-700"
value={paymentForm.data.payment_method}
onChange={(e) => paymentForm.setData('payment_method', e.target.value)}
required
>
<option value="bank_transfer">/</option>
<option value="cash"></option>
<option value="check"></option>
<option value="credit_card"></option>
<option value="other"></option>
</select>
{paymentForm.errors.payment_method && <p className="text-sm text-destructive">{paymentForm.errors.payment_method}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="paid_at"></Label>
<Input
id="paid_at"
type="date"
value={paymentForm.data.paid_at}
onChange={e => paymentForm.setData('paid_at', e.target.value)}
required
/>
{paymentForm.errors.paid_at && <p className="text-sm text-destructive">{paymentForm.errors.paid_at}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="payment_note"> ()</Label>
<Input
id="payment_note"
value={paymentForm.data.payment_note}
onChange={e => paymentForm.setData('payment_note', e.target.value)}
placeholder="例如: 匯款帳號後五碼、支票號碼..."
/>
{paymentForm.errors.payment_note && <p className="text-sm text-destructive">{paymentForm.errors.payment_note}</p>}
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" className="button-outlined-primary" onClick={() => setPaymentDialogOpen(false)}></Button>
<Button type="submit" disabled={paymentForm.processing} className="button-filled-primary"></Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
</AuthenticatedLayout>
);
}

View File

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

View File

@@ -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 (
<AuthenticatedLayout breadcrumbs={getShowBreadcrumbs("goodsReceipts", `詳情 (#${receipt.code})`)}>
<AuthenticatedLayout breadcrumbs={breadcrumbs}>
<Head title={`進貨單詳情 - ${receipt.code}`} />
<div className="container mx-auto p-6 max-w-7xl">
{/* Header */}
<div className="mb-6">
<Link href="/goods-receipts">
<Link href={route("goods-receipts.index")}>
<Button
variant="outline"
className="gap-2 button-outlined-primary mb-6"
className="gap-2 button-outlined-primary"
>
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
</div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<Package className="h-6 w-6 text-primary-main" />
</h1>
<p className="text-gray-500 mt-1">{receipt.code}</p>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<Package className="h-6 w-6 text-primary-main" />
</h1>
<div className="flex items-center gap-2 mt-1">
<GoodsReceiptStatusBadge status={receipt.status} />
<span className="text-gray-500 text-sm">{receipt.code}</span>
</div>
</div>
<div className="flex items-center gap-3">
<GoodsReceiptActions receipt={receipt} />
</div>
</div>
<div className="grid grid-cols-1 gap-8">
@@ -219,3 +241,95 @@ export default function ViewGoodsReceiptPage({ receipt }: Props) {
</AuthenticatedLayout>
);
}
function GoodsReceiptActions({ receipt }: { receipt: GoodsReceipt }) {
const { auth } = usePage<PageProps>().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 (
<div className="flex items-center gap-2">
{receipt.status === 'draft' && canDelete && (
<Button
onClick={() => setDialogType("delete")}
variant="outline"
className="button-outlined-error border-red-600 text-red-600 hover:bg-red-50"
disabled={processing}
>
</Button>
)}
{receipt.status === 'draft' && canSubmit && (
<Button
onClick={() => setDialogType("submit")}
className="button-filled-primary shadow-primary/20"
disabled={processing}
>
</Button>
)}
{/* 統一確認對話框 */}
<AlertDialog open={dialogType !== null} onOpenChange={(open) => !open && setDialogType(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{dialogType === "submit" && "確認點收"}
{dialogType === "delete" && "刪除進貨單"}
</AlertDialogTitle>
<AlertDialogDescription>
{dialogType === "submit" && "確定已點收無誤嗎?送出後將會更新庫存、關聯單據數量,並自動產生應付帳款,且無法再次退回。"}
{dialogType === "delete" && `將刪除進貨單「${receipt.code}」。注意:此操作無法復原。`}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={processing}></AlertDialogCancel>
<Button
onClick={handleAction}
disabled={processing}
className={dialogType === "delete" ? "button-filled-error" : "button-filled-primary"}
>
{processing ? "處理中..." : "確定"}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -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<string | null>(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 }
);
};

View File

@@ -26,29 +26,34 @@ export default function ViewPurchaseOrderPage({ order }: Props) {
<Head title={`採購單詳情 - ${order.poNumber}`} />
<div className="container mx-auto p-6 max-w-7xl">
{/* Header */}
{/* 返回按鈕 */}
<div className="mb-6">
<Link href="/purchase-orders">
<Button
variant="outline"
className="gap-2 button-outlined-primary mb-6"
className="gap-2 button-outlined-primary"
>
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
</div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<ShoppingCart className="h-6 w-6 text-primary-main" />
</h1>
<p className="text-gray-500 mt-1">{order.poNumber}</p>
</div>
<div className="flex items-center gap-3">
{/* 頁面標題與操作 */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<ShoppingCart className="h-6 w-6 text-primary-main" />
</h1>
<div className="flex items-center gap-2 mt-1">
<PurchaseOrderStatusBadge status={order.status} />
<span className="text-gray-500 text-sm">{order.poNumber}</span>
</div>
</div>
<div className="flex items-center gap-3">
<PurchaseOrderActions order={order} />
</div>
</div>
{/* 狀態流程條 */}
<div className="mb-8">
@@ -169,10 +174,6 @@ export default function ViewPurchaseOrderPage({ order }: Props) {
</div>
</div>
{/* 操作按鈕 (底部) */}
<div className="flex justify-end pt-4">
<PurchaseOrderActions order={order} />
</div>
</div>
</div>
</div>
@@ -225,16 +226,15 @@ function PurchaseOrderActions({ order }: { order: PurchaseOrder }) {
const canSubmit = canEdit || canView;
return (
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
{['draft', 'pending', 'approved'].includes(order.status) && canCancel && (
<Button
onClick={() => handleUpdateStatus('cancelled', '作廢')}
disabled={processing}
variant="outline"
size="xl"
className="button-outlined-error shadow-red-200/20 border-red-600 text-red-600 hover:bg-red-50"
className="button-outlined-error border-red-600 text-red-600 hover:bg-red-50"
>
<XCircle className="h-5 w-5" />
<XCircle className="h-4 w-4 mr-1" />
</Button>
)}
@@ -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"
>
<RotateCcw className="h-5 w-5" /> 退
<RotateCcw className="h-4 w-4 mr-1" /> 退
</Button>
)}
<div className="flex-1" />
{order.status === 'draft' && canSubmit && (
<Button
onClick={() => handleUpdateStatus('pending', '送出審核')}
disabled={processing}
size="xl"
className="button-filled-primary shadow-primary/20"
>
<Send className="h-5 w-5" />
<Send className="h-4 w-4 mr-1" />
</Button>
)}
@@ -267,10 +263,9 @@ function PurchaseOrderActions({ order }: { order: PurchaseOrder }) {
<Button
onClick={() => handleUpdateStatus('approved', '核准')}
disabled={processing}
size="xl"
className="button-filled-primary shadow-primary/20"
>
<CheckCircle className="h-5 w-5" />
<CheckCircle className="h-4 w-4 mr-1" />
</Button>
)}
</div>

View File

@@ -81,58 +81,74 @@ export default function Index({
setPerPage(filters.per_page || "10");
}, [filters]);
const applyFilters = useCallback(
(overrides: Record<string, string> = {}) => {
const params: Record<string, string> = {
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 = () => {

View File

@@ -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 (
<AuthenticatedLayout
@@ -294,9 +310,27 @@ export default function Show({ requisition, warehouses }: Props) {
</div>
<div>
<span className="text-sm text-gray-500"></span>
<p className="font-medium text-gray-800 mt-1">
{requisition.supply_warehouse_name || "-"}
</p>
<div className="mt-1">
{isPending && canApprove ? (
<SearchableSelect
value={requisition.supply_warehouse_id?.toString() || ""}
onValueChange={handleUpdateSupplyWarehouse}
options={warehouses
.filter((w) => 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}
/>
) : (
<p className="font-medium text-gray-800">
{requisition.supply_warehouse_name || "-"}
</p>
)}
</div>
</div>
<div>
<span className="text-sm text-gray-500"></span>
@@ -455,22 +489,14 @@ export default function Show({ requisition, warehouses }: Props) {
<DialogDescription></DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label>
<span className="text-red-500">*</span>
</Label>
<SearchableSelect
value={supplyWarehouseId}
onValueChange={setSupplyWarehouseId}
options={warehouses
.filter((w) => w.id !== requisition.store_warehouse_id)
.map((w) => ({
label: w.name,
value: w.id.toString(),
}))}
placeholder="請選擇供貨倉庫"
className="h-9"
/>
<div className="flex items-center justify-between p-3 bg-blue-50 border border-blue-100 rounded-lg">
<div className="flex items-center gap-2">
<span className="text-blue-700 font-medium"></span>
<span className="text-blue-900 font-bold">{requisition.supply_warehouse_name || "尚未選擇"}</span>
</div>
{!requisition.supply_warehouse_id && (
<span className="text-xs text-red-500 font-medium">* </span>
)}
</div>
<div className="border rounded-lg overflow-hidden">
@@ -534,7 +560,7 @@ export default function Show({ requisition, warehouses }: Props) {
<Button
className="bg-green-600 hover:bg-green-700 text-white"
onClick={handleApprove}
disabled={approving || !supplyWarehouseId}
disabled={approving || !requisition.supply_warehouse_id}
>
{approving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}