優化: 門市叫貨模組 UI 調整、權限標籤中文化及調撥單動態導覽

This commit is contained in:
2026-02-13 10:39:10 +08:00
parent 882091ce5f
commit 097708aab7
17 changed files with 2359 additions and 6 deletions

View File

@@ -188,11 +188,13 @@ class RoleController extends Controller
'vendors' => '廠商資料管理', 'vendors' => '廠商資料管理',
'purchase_orders' => '採購單管理', 'purchase_orders' => '採購單管理',
'goods_receipts' => '進貨單管理', 'goods_receipts' => '進貨單管理',
'delivery_notes' => '出貨單管理',
'recipes' => '配方管理', 'recipes' => '配方管理',
'production_orders' => '生產工單管理', 'production_orders' => '生產工單管理',
'utility_fees' => '公共事業費管理', 'utility_fees' => '公共事業費管理',
'accounting' => '會計報表', 'accounting' => '會計報表',
'sales_imports' => '銷售單匯入管理', 'sales_imports' => '銷售單匯入管理',
'store_requisitions' => '門市叫貨申請',
'users' => '使用者管理', 'users' => '使用者管理',
'roles' => '角色與權限', 'roles' => '角色與權限',
'system' => '系統管理', 'system' => '系統管理',

View File

@@ -0,0 +1,352 @@
<?php
namespace App\Modules\Inventory\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Inventory\Models\StoreRequisition;
use App\Modules\Inventory\Models\Warehouse;
use App\Modules\Inventory\Models\Product;
use App\Modules\Inventory\Models\Inventory;
use App\Modules\Inventory\Services\StoreRequisitionService;
use App\Modules\Core\Contracts\CoreServiceInterface;
use Illuminate\Http\Request;
use Inertia\Inertia;
class StoreRequisitionController extends Controller
{
protected StoreRequisitionService $service;
protected CoreServiceInterface $coreService;
public function __construct(
StoreRequisitionService $service,
CoreServiceInterface $coreService
) {
$this->service = $service;
$this->coreService = $coreService;
}
/**
* 叫貨單列表
*/
public function index(Request $request)
{
$query = StoreRequisition::query();
// 搜尋(單號)
if ($request->search) {
$query->where('doc_no', 'like', "%{$request->search}%");
}
// 狀態篩選
if ($request->status && $request->status !== 'all') {
$query->where('status', $request->status);
}
// 倉庫篩選
if ($request->warehouse_id) {
$query->where('store_warehouse_id', $request->warehouse_id);
}
// 日期範圍
if ($request->date_start) {
$query->whereDate('created_at', '>=', $request->date_start);
}
if ($request->date_end) {
$query->whereDate('created_at', '<=', $request->date_end);
}
// 排序
$sortField = $request->input('sort_by', 'id');
$sortOrder = $request->input('sort_order', 'desc');
$allowedSorts = ['id', 'doc_no', 'status', 'created_at', 'submitted_at'];
if (in_array($sortField, $allowedSorts)) {
$query->orderBy($sortField, $sortOrder);
} else {
$query->orderBy('id', 'desc');
}
$perPage = $request->input('per_page', 10);
$requisitions = $query->paginate($perPage)->withQueryString();
// 水和倉庫名稱與使用者名稱
$warehouses = Warehouse::select('id', 'name', 'type')->get();
$warehouseMap = $warehouses->keyBy('id');
$userIds = $requisitions->getCollection()
->pluck('created_by')
->merge($requisitions->getCollection()->pluck('approved_by'))
->filter()
->unique()
->toArray();
$users = $this->coreService->getUsersByIds($userIds)->keyBy('id');
$requisitions->getCollection()->transform(function ($req) use ($warehouseMap, $users) {
$req->store_warehouse_name = $warehouseMap->get($req->store_warehouse_id)?->name ?? '-';
$req->supply_warehouse_name = $warehouseMap->get($req->supply_warehouse_id)?->name ?? '-';
$req->creator_name = $users->get($req->created_by)?->name ?? '-';
$req->approver_name = $users->get($req->approved_by)?->name ?? '-';
return $req;
});
return Inertia::render('StoreRequisition/Index', [
'requisitions' => $requisitions,
'filters' => $request->only(['search', 'status', 'warehouse_id', 'date_start', 'date_end', 'sort_by', 'sort_order', 'per_page']),
'warehouses' => $warehouses->map(fn($w) => ['id' => $w->id, 'name' => $w->name]),
]);
}
/**
* 新增頁面
*/
public function create()
{
$warehouses = Warehouse::select('id', 'name', 'type')->get();
$products = Product::select('id', 'name', 'code', 'base_unit_id')
->with('baseUnit:id,name')
->where('is_active', true)
->get();
return Inertia::render('StoreRequisition/Create', [
'warehouses' => $warehouses->map(fn($w) => [
'id' => $w->id,
'name' => $w->name,
'type' => $w->type?->value,
]),
'products' => $products->map(fn($p) => [
'id' => $p->id,
'name' => $p->name,
'code' => $p->code,
'unit_name' => $p->baseUnit?->name,
]),
]);
}
/**
* 儲存叫貨單
*/
public function store(Request $request)
{
$request->validate([
'store_warehouse_id' => 'required|exists:warehouses,id',
'remark' => 'nullable|string|max:500',
'items' => 'required|array|min:1',
'items.*.product_id' => 'required|exists:products,id',
'items.*.requested_qty' => 'required|numeric|min:0.01',
'items.*.remark' => 'nullable|string|max:200',
], [
'items.required' => '至少需要一項商品',
'items.min' => '至少需要一項商品',
'items.*.requested_qty.min' => '需求數量必須大於 0',
]);
$requisition = $this->service->create(
$request->only(['store_warehouse_id', 'remark']),
$request->items,
auth()->id()
);
// 如果需要直接提交
if ($request->boolean('submit_immediately')) {
$this->service->submit($requisition, auth()->id());
return redirect()->route('store-requisitions.index')
->with('success', '叫貨單已提交審核');
}
return redirect()->route('store-requisitions.show', $requisition->id)
->with('success', '叫貨單已儲存為草稿');
}
/**
* 叫貨單詳情
*/
public function show($id)
{
$requisition = StoreRequisition::with(['items.product.baseUnit'])->findOrFail($id);
// 水和倉庫
$warehouses = Warehouse::select('id', 'name', 'type')->get();
$warehouseMap = $warehouses->keyBy('id');
$requisition->store_warehouse_name = $warehouseMap->get($requisition->store_warehouse_id)?->name ?? '-';
$requisition->supply_warehouse_name = $warehouseMap->get($requisition->supply_warehouse_id)?->name ?? '-';
// 水和使用者
$userIds = collect([$requisition->created_by, $requisition->approved_by])->filter()->unique()->toArray();
$users = $this->coreService->getUsersByIds($userIds)->keyBy('id');
$requisition->creator_name = $users->get($requisition->created_by)?->name ?? '-';
$requisition->approver_name = $users->get($requisition->approved_by)?->name ?? '-';
// 水和明細商品資訊
$requisition->items->transform(function ($item) {
$item->product_name = $item->product?->name ?? '-';
$item->product_code = $item->product?->code ?? '-';
$item->unit_name = $item->product?->baseUnit?->name ?? '-';
return $item;
});
// 取得庫存資訊(顯示該商品在申請倉庫的現有庫存量)
$productIds = $requisition->items->pluck('product_id')->toArray();
$inventories = Inventory::where('warehouse_id', $requisition->store_warehouse_id)
->whereIn('product_id', $productIds)
->select('product_id')
->selectRaw('SUM(quantity) as total_qty')
->groupBy('product_id')
->get()
->keyBy('product_id');
$requisition->items->transform(function ($item) use ($inventories) {
$item->current_stock = $inventories->get($item->product_id)?->total_qty ?? 0;
return $item;
});
// 操作紀錄
$activities = \Spatie\Activitylog\Models\Activity::where('subject_type', StoreRequisition::class)
->where('subject_id', $requisition->id)
->orderBy('created_at', 'desc')
->get();
return Inertia::render('StoreRequisition/Show', [
'requisition' => $requisition,
'warehouses' => $warehouses->map(fn($w) => ['id' => $w->id, 'name' => $w->name]),
'activities' => $activities,
]);
}
/**
* 編輯頁面
*/
public function edit($id)
{
$requisition = StoreRequisition::with(['items.product.baseUnit'])->findOrFail($id);
if (!in_array($requisition->status, ['draft', 'rejected'])) {
return redirect()->route('store-requisitions.show', $id)
->with('error', '僅能編輯草稿或被駁回的叫貨單');
}
$warehouses = Warehouse::select('id', 'name', 'type')->get();
$products = Product::select('id', 'name', 'code', 'base_unit_id')
->with('baseUnit:id,name')
->where('is_active', true)
->get();
return Inertia::render('StoreRequisition/Create', [
'requisition' => $requisition,
'warehouses' => $warehouses->map(fn($w) => [
'id' => $w->id,
'name' => $w->name,
'type' => $w->type?->value,
]),
'products' => $products->map(fn($p) => [
'id' => $p->id,
'name' => $p->name,
'code' => $p->code,
'unit_name' => $p->baseUnit?->name,
]),
]);
}
/**
* 更新叫貨單
*/
public function update(Request $request, $id)
{
$requisition = StoreRequisition::findOrFail($id);
$request->validate([
'store_warehouse_id' => 'required|exists:warehouses,id',
'remark' => 'nullable|string|max:500',
'items' => 'required|array|min:1',
'items.*.product_id' => 'required|exists:products,id',
'items.*.requested_qty' => 'required|numeric|min:0.01',
'items.*.remark' => 'nullable|string|max:200',
]);
$requisition = $this->service->update(
$requisition,
$request->only(['store_warehouse_id', 'remark']),
$request->items
);
// 如果需要直接提交
if ($request->boolean('submit_immediately')) {
$this->service->submit($requisition, auth()->id());
return redirect()->route('store-requisitions.index')
->with('success', '叫貨單已重新提交審核');
}
return redirect()->route('store-requisitions.show', $requisition->id)
->with('success', '叫貨單已更新');
}
/**
* 提交審核
*/
public function submit($id)
{
$requisition = StoreRequisition::findOrFail($id);
$this->service->submit($requisition, auth()->id());
return redirect()->route('store-requisitions.show', $id)
->with('success', '叫貨單已提交審核');
}
/**
* 核准叫貨單
*/
public function approve(Request $request, $id)
{
$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());
return redirect()->route('store-requisitions.show', $id)
->with('success', '叫貨單已核准,調撥單已自動產生');
}
/**
* 駁回叫貨單
*/
public function reject(Request $request, $id)
{
$requisition = StoreRequisition::findOrFail($id);
$request->validate([
'reject_reason' => 'required|string|max:500',
], [
'reject_reason.required' => '請填寫駁回原因',
]);
$this->service->reject($requisition, $request->reject_reason, auth()->id());
return redirect()->route('store-requisitions.show', $id)
->with('success', '叫貨單已駁回');
}
/**
* 刪除叫貨單(僅限草稿)
*/
public function destroy($id)
{
$requisition = StoreRequisition::findOrFail($id);
if ($requisition->status !== 'draft') {
return back()->withErrors(['error' => '僅能刪除草稿狀態的叫貨單']);
}
$requisition->items()->delete();
$requisition->delete();
return redirect()->route('store-requisitions.index')
->with('success', '叫貨單已刪除');
}
}

View File

@@ -99,7 +99,7 @@ class TransferOrderController extends Controller
public function show(InventoryTransferOrder $order) public function show(InventoryTransferOrder $order)
{ {
$order->load(['items.product.baseUnit', 'fromWarehouse', 'toWarehouse', 'createdBy', 'postedBy']); $order->load(['items.product.baseUnit', 'fromWarehouse', 'toWarehouse', 'createdBy', 'postedBy', 'storeRequisition']);
$orderData = [ $orderData = [
'id' => (string) $order->id, 'id' => (string) $order->id,
@@ -113,6 +113,10 @@ class TransferOrderController extends Controller
'remarks' => $order->remarks, 'remarks' => $order->remarks,
'created_at' => $order->created_at->format('Y-m-d H:i'), 'created_at' => $order->created_at->format('Y-m-d H:i'),
'created_by' => $order->createdBy?->name, 'created_by' => $order->createdBy?->name,
'requisition' => $order->storeRequisition ? [
'id' => (string) $order->storeRequisition->id,
'doc_no' => $order->storeRequisition->doc_no,
] : null,
'items' => $order->items->map(function ($item) use ($order) { 'items' => $order->items->map(function ($item) use ($order) {
// 獲取來源倉庫的當前庫存 // 獲取來源倉庫的當前庫存
$stock = Inventory::where('warehouse_id', $order->from_warehouse_id) $stock = Inventory::where('warehouse_id', $order->from_warehouse_id)

View File

@@ -163,6 +163,11 @@ class InventoryTransferOrder extends Model
return $this->belongsTo(User::class, 'created_by'); return $this->belongsTo(User::class, 'created_by');
} }
public function storeRequisition(): \Illuminate\Database\Eloquent\Relations\HasOne
{
return $this->hasOne(StoreRequisition::class, 'transfer_order_id');
}
public function postedBy(): BelongsTo public function postedBy(): BelongsTo
{ {
return $this->belongsTo(User::class, 'posted_by'); return $this->belongsTo(User::class, 'posted_by');

View File

@@ -0,0 +1,147 @@
<?php
namespace App\Modules\Inventory\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
use App\Modules\Core\Models\User;
class StoreRequisition extends Model
{
use HasFactory, LogsActivity;
protected $fillable = [
'doc_no',
'store_warehouse_id',
'supply_warehouse_id',
'status',
'remark',
'reject_reason',
'created_by',
'approved_by',
'submitted_at',
'approved_at',
'transfer_order_id',
];
protected $casts = [
'submitted_at' => 'datetime',
'approved_at' => 'datetime',
];
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logFillable()
->logOnlyDirty()
->dontSubmitEmptyLogs();
}
/**
* 自定義日誌屬性,解析 ID 為名稱
*/
public function tapActivity(\Spatie\Activitylog\Models\Activity $activity, string $eventName)
{
$properties = $activity->properties->toArray();
// 基本單據資訊快照
$properties['snapshot'] = [
'doc_no' => $this->doc_no,
'store_warehouse_name' => $this->storeWarehouse?->name,
'supply_warehouse_name' => $this->supplyWarehouse?->name,
'status' => $this->status,
];
// 移除雜訊欄位
if (isset($properties['attributes'])) {
unset($properties['attributes']['updated_at']);
}
if (isset($properties['old'])) {
unset($properties['old']['updated_at']);
}
$activity->properties = collect($properties);
}
/**
* 自動產生單號 SR-YYYYMMDD-XX
*/
protected static function boot()
{
parent::boot();
static::creating(function ($model) {
if (empty($model->doc_no)) {
$today = date('Ymd');
$prefix = 'SR-' . $today . '-';
$lastDoc = static::where('doc_no', 'like', $prefix . '%')
->orderBy('doc_no', 'desc')
->first();
if ($lastDoc) {
$lastNumber = substr($lastDoc->doc_no, -2);
$nextNumber = str_pad((int)$lastNumber + 1, 2, '0', STR_PAD_LEFT);
} else {
$nextNumber = '01';
}
$model->doc_no = $prefix . $nextNumber;
}
});
}
// ===== 關聯 =====
/**
* 申請倉庫
*/
public function storeWarehouse(): BelongsTo
{
return $this->belongsTo(Warehouse::class, 'store_warehouse_id');
}
/**
* 供貨倉庫(審核時填入)
*/
public function supplyWarehouse(): BelongsTo
{
return $this->belongsTo(Warehouse::class, 'supply_warehouse_id');
}
/**
* 叫貨明細
*/
public function items(): HasMany
{
return $this->hasMany(StoreRequisitionItem::class);
}
/**
* 申請人
*/
public function createdBy(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
/**
* 審核人
*/
public function approvedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'approved_by');
}
/**
* 關聯調撥單
*/
public function transferOrder(): BelongsTo
{
return $this->belongsTo(InventoryTransferOrder::class, 'transfer_order_id');
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Modules\Inventory\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class StoreRequisitionItem extends Model
{
use HasFactory;
protected $fillable = [
'store_requisition_id',
'product_id',
'requested_qty',
'approved_qty',
'remark',
];
protected $casts = [
'requested_qty' => 'decimal:2',
'approved_qty' => 'decimal:2',
];
/**
* 所屬叫貨單
*/
public function requisition(): BelongsTo
{
return $this->belongsTo(StoreRequisition::class, 'store_requisition_id');
}
/**
* 關聯商品(同模組)
*/
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Modules\Inventory\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use App\Modules\Inventory\Models\StoreRequisition;
class StoreRequisitionNotification extends Notification
{
use Queueable;
protected StoreRequisition $requisition;
protected string $action;
protected string $actorName;
/**
* 建立通知實例
*
* @param StoreRequisition $requisition 叫貨單
* @param string $action 操作類型submitted / approved / rejected
* @param string $actorName 操作者名稱
*/
public function __construct(StoreRequisition $requisition, string $action, string $actorName)
{
$this->requisition = $requisition;
$this->action = $action;
$this->actorName = $actorName;
}
public function via(object $notifiable): array
{
return ['database'];
}
public function toArray(object $notifiable): array
{
$messages = [
'submitted' => "{$this->actorName} 提交了叫貨申請:{$this->requisition->doc_no}",
'approved' => "{$this->actorName} 核准了叫貨申請:{$this->requisition->doc_no}",
'rejected' => "{$this->actorName} 駁回了叫貨申請:{$this->requisition->doc_no}",
];
return [
'type' => 'store_requisition',
'action' => $this->action,
'store_requisition_id' => $this->requisition->id,
'doc_no' => $this->requisition->doc_no,
'actor_name' => $this->actorName,
'message' => $messages[$this->action] ?? "{$this->actorName} 操作了叫貨申請:{$this->requisition->doc_no}",
'link' => route('store-requisitions.show', $this->requisition->id),
];
}
}

View File

@@ -141,6 +141,32 @@ Route::middleware('auth')->group(function () {
->middleware('permission:inventory_transfer.view') ->middleware('permission:inventory_transfer.view')
->name('inventory.transfer.template'); ->name('inventory.transfer.template');
// 門市叫貨申請 (Store Requisitions)
Route::middleware('permission:store_requisitions.view')->group(function () {
Route::get('/store-requisitions', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'index'])->name('store-requisitions.index');
Route::middleware('permission:store_requisitions.create')->group(function () {
Route::get('/store-requisitions/create', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'create'])->name('store-requisitions.create');
Route::post('/store-requisitions', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'store'])->name('store-requisitions.store');
});
Route::get('/store-requisitions/{id}', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'show'])->name('store-requisitions.show');
Route::middleware('permission:store_requisitions.edit')->group(function () {
Route::get('/store-requisitions/{id}/edit', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'edit'])->name('store-requisitions.edit');
Route::put('/store-requisitions/{id}', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'update'])->name('store-requisitions.update');
});
Route::post('/store-requisitions/{id}/submit', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'submit'])->name('store-requisitions.submit');
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::delete('/store-requisitions/{id}', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'destroy'])->middleware('permission:store_requisitions.delete')->name('store-requisitions.destroy');
});
// 進貨單 (Goods Receipts) // 進貨單 (Goods Receipts)
Route::middleware('permission:goods_receipts.view')->group(function () { Route::middleware('permission:goods_receipts.view')->group(function () {
Route::get('/goods-receipts', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'index'])->name('goods-receipts.index'); Route::get('/goods-receipts', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'index'])->name('goods-receipts.index');

View File

@@ -0,0 +1,242 @@
<?php
namespace App\Modules\Inventory\Services;
use App\Modules\Inventory\Models\StoreRequisition;
use App\Modules\Inventory\Models\StoreRequisitionItem;
use App\Modules\Inventory\Models\InventoryTransferOrder;
use App\Modules\Inventory\Models\InventoryTransferItem;
use App\Modules\Inventory\Notifications\StoreRequisitionNotification;
use App\Modules\Core\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class StoreRequisitionService
{
protected TransferService $transferService;
public function __construct(TransferService $transferService)
{
$this->transferService = $transferService;
}
/**
* 建立叫貨單(含明細)
*/
public function create(array $data, array $items, int $userId): StoreRequisition
{
return DB::transaction(function () use ($data, $items, $userId) {
$requisition = StoreRequisition::create([
'store_warehouse_id' => $data['store_warehouse_id'],
'status' => 'draft',
'remark' => $data['remark'] ?? null,
'created_by' => $userId,
]);
foreach ($items as $item) {
$requisition->items()->create([
'product_id' => $item['product_id'],
'requested_qty' => $item['requested_qty'],
'remark' => $item['remark'] ?? null,
]);
}
return $requisition->load('items');
});
}
/**
* 更新叫貨單(僅限 draft / rejected 狀態)
*/
public function update(StoreRequisition $requisition, array $data, array $items): StoreRequisition
{
if (!in_array($requisition->status, ['draft', 'rejected'])) {
throw ValidationException::withMessages([
'status' => '僅能編輯草稿或被駁回的叫貨單',
]);
}
return DB::transaction(function () use ($requisition, $data, $items) {
$requisition->update([
'store_warehouse_id' => $data['store_warehouse_id'],
'remark' => $data['remark'] ?? null,
'reject_reason' => null, // 清除駁回原因
]);
// 重建明細
$requisition->items()->delete();
foreach ($items as $item) {
$requisition->items()->create([
'product_id' => $item['product_id'],
'requested_qty' => $item['requested_qty'],
'remark' => $item['remark'] ?? null,
]);
}
return $requisition->load('items');
});
}
/**
* 提交審核draft pending
*/
public function submit(StoreRequisition $requisition, int $userId): StoreRequisition
{
if ($requisition->status !== 'draft' && $requisition->status !== 'rejected') {
throw ValidationException::withMessages([
'status' => '僅能提交草稿或被駁回的叫貨單',
]);
}
if ($requisition->items()->count() === 0) {
throw ValidationException::withMessages([
'items' => '叫貨單必須至少有一項商品',
]);
}
$requisition->update([
'status' => 'pending',
'submitted_at' => now(),
'reject_reason' => null,
]);
// 通知有審核權限的使用者
$this->notifyApprovers($requisition, 'submitted', $userId);
return $requisition;
}
/**
* 核准叫貨單pending approved選擇供貨倉庫並自動產生調撥單
*/
public function approve(StoreRequisition $requisition, array $data, int $userId): StoreRequisition
{
if ($requisition->status !== 'pending') {
throw ValidationException::withMessages([
'status' => '僅能核准待審核的叫貨單',
]);
}
return DB::transaction(function () use ($requisition, $data, $userId) {
// 更新核准數量
if (isset($data['items'])) {
foreach ($data['items'] as $itemData) {
StoreRequisitionItem::where('id', $itemData['id'])
->where('store_requisition_id', $requisition->id)
->update(['approved_qty' => $itemData['approved_qty']]);
}
}
// 產生調撥單(供貨倉庫 → 門市倉庫)
$transferOrder = $this->transferService->createOrder(
fromWarehouseId: $data['supply_warehouse_id'],
toWarehouseId: $requisition->store_warehouse_id,
remarks: "由叫貨單 {$requisition->doc_no} 自動產生",
userId: $userId,
);
// 將核准的明細寫入調撥單
$requisition->load('items');
$transferItems = [];
foreach ($requisition->items as $item) {
$qty = $item->approved_qty ?? $item->requested_qty;
if ($qty > 0) {
$transferItems[] = [
'product_id' => $item->product_id,
'quantity' => $qty,
];
}
}
if (!empty($transferItems)) {
$this->transferService->updateItems($transferOrder, $transferItems);
}
// 更新叫貨單狀態
$requisition->update([
'status' => 'approved',
'supply_warehouse_id' => $data['supply_warehouse_id'],
'approved_by' => $userId,
'approved_at' => now(),
'transfer_order_id' => $transferOrder->id,
]);
// 通知申請人
$this->notifyCreator($requisition, 'approved', $userId);
return $requisition->load(['items', 'transferOrder']);
});
}
/**
* 駁回叫貨單pending rejected
*/
public function reject(StoreRequisition $requisition, string $reason, int $userId): StoreRequisition
{
if ($requisition->status !== 'pending') {
throw ValidationException::withMessages([
'status' => '僅能駁回待審核的叫貨單',
]);
}
$requisition->update([
'status' => 'rejected',
'reject_reason' => $reason,
'approved_by' => $userId,
'approved_at' => now(),
]);
// 通知申請人
$this->notifyCreator($requisition, 'rejected', $userId);
return $requisition;
}
/**
* 取消叫貨單
*/
public function cancel(StoreRequisition $requisition): StoreRequisition
{
if (!in_array($requisition->status, ['draft', 'pending'])) {
throw ValidationException::withMessages([
'status' => '僅能取消草稿或待審核的叫貨單',
]);
}
$requisition->update(['status' => 'cancelled']);
return $requisition;
}
/**
* 通知有審核權限的使用者
*/
protected function notifyApprovers(StoreRequisition $requisition, string $action, int $actorId): void
{
$actor = User::find($actorId);
$actorName = $actor?->name ?? 'System';
// 找出有 store_requisitions.approve 權限的使用者
$approvers = User::permission('store_requisitions.approve')->get();
foreach ($approvers as $approver) {
if ($approver->id !== $actorId) {
$approver->notify(new StoreRequisitionNotification($requisition, $action, $actorName));
}
}
}
/**
* 通知叫貨單申請人
*/
protected function notifyCreator(StoreRequisition $requisition, string $action, int $actorId): void
{
$actor = User::find($actorId);
$actorName = $actor?->name ?? 'System';
$creator = User::find($requisition->created_by);
if ($creator && $creator->id !== $actorId) {
$creator->notify(new StoreRequisitionNotification($requisition, $action, $actorName));
}
}
}

View File

@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 門市叫貨申請主表
*/
public function up(): void
{
Schema::create('store_requisitions', function (Blueprint $table) {
$table->id();
$table->string('doc_no')->unique()->comment('單號 SR-YYYYMMDD-XX');
$table->unsignedBigInteger('store_warehouse_id')->comment('申請倉庫(任意類型)');
$table->unsignedBigInteger('supply_warehouse_id')->nullable()->comment('供貨倉庫(審核時填入)');
$table->enum('status', ['draft', 'pending', 'approved', 'rejected', 'completed', 'cancelled'])
->default('draft');
$table->text('remark')->nullable()->comment('申請備註');
$table->text('reject_reason')->nullable()->comment('駁回原因');
$table->unsignedBigInteger('created_by')->comment('申請人');
$table->unsignedBigInteger('approved_by')->nullable()->comment('審核人');
$table->timestamp('submitted_at')->nullable()->comment('提交時間');
$table->timestamp('approved_at')->nullable()->comment('審核時間');
$table->unsignedBigInteger('transfer_order_id')->nullable()->comment('關聯調撥單');
$table->timestamps();
$table->index('status');
$table->index('store_warehouse_id');
$table->index('created_by');
});
}
public function down(): void
{
Schema::dropIfExists('store_requisitions');
}
};

View File

@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 門市叫貨申請明細表
*/
public function up(): void
{
Schema::create('store_requisition_items', function (Blueprint $table) {
$table->id();
$table->foreignId('store_requisition_id')->constrained()->cascadeOnDelete();
$table->unsignedBigInteger('product_id');
$table->decimal('requested_qty', 12, 2)->comment('需求數量');
$table->decimal('approved_qty', 12, 2)->nullable()->comment('核准數量(審核時填入)');
$table->text('remark')->nullable();
$table->timestamps();
$table->index('product_id');
});
}
public function down(): void
{
Schema::dropIfExists('store_requisition_items');
}
};

View File

@@ -129,6 +129,14 @@ class PermissionSeeder extends Seeder
'sales_imports.create' => '建立', 'sales_imports.create' => '建立',
'sales_imports.confirm' => '確認', 'sales_imports.confirm' => '確認',
'sales_imports.delete' => '刪除', 'sales_imports.delete' => '刪除',
// 門市叫貨申請
'store_requisitions.view' => '檢視',
'store_requisitions.create' => '建立',
'store_requisitions.edit' => '編輯',
'store_requisitions.delete' => '刪除',
'store_requisitions.approve' => '核準',
'store_requisitions.cancel' => '取消',
]; ];
foreach ($permissions as $name => $displayName) { foreach ($permissions as $name => $displayName) {
@@ -172,6 +180,8 @@ class PermissionSeeder extends Seeder
'utility_fees.view', 'utility_fees.create', 'utility_fees.edit', 'utility_fees.delete', 'utility_fees.view', 'utility_fees.create', 'utility_fees.edit', 'utility_fees.delete',
'accounting.view', 'accounting.export', 'accounting.view', 'accounting.export',
'sales_imports.view', 'sales_imports.create', 'sales_imports.confirm', 'sales_imports.delete', 'sales_imports.view', 'sales_imports.create', 'sales_imports.confirm', 'sales_imports.delete',
'store_requisitions.view', 'store_requisitions.create', 'store_requisitions.edit',
'store_requisitions.delete', 'store_requisitions.approve', 'store_requisitions.cancel',
]); ]);
// warehouse-manager 管理庫存與倉庫 // warehouse-manager 管理庫存與倉庫
@@ -186,6 +196,8 @@ class PermissionSeeder extends Seeder
'goods_receipts.view', 'goods_receipts.create', 'goods_receipts.edit', 'goods_receipts.delete', 'goods_receipts.view', 'goods_receipts.create', 'goods_receipts.edit', 'goods_receipts.delete',
'production_orders.view', 'production_orders.create', 'production_orders.edit', 'production_orders.view', 'production_orders.create', 'production_orders.edit',
'warehouses.view', 'warehouses.create', 'warehouses.edit', 'warehouses.view', 'warehouses.create', 'warehouses.edit',
'store_requisitions.view', 'store_requisitions.create', 'store_requisitions.edit',
'store_requisitions.delete', 'store_requisitions.approve', 'store_requisitions.cancel',
]); ]);
// purchaser 管理採購與供應商 // purchaser 管理採購與供應商

View File

@@ -25,7 +25,8 @@ import {
ClipboardCheck, ClipboardCheck,
ArrowLeftRight, ArrowLeftRight,
TrendingUp, TrendingUp,
FileUp FileUp,
Store
} from "lucide-react"; } from "lucide-react";
import { toast, Toaster } from "sonner"; import { toast, Toaster } from "sonner";
import { useState, useEffect, useMemo, useRef } from "react"; import { useState, useEffect, useMemo, useRef } from "react";
@@ -131,6 +132,13 @@ export default function AuthenticatedLayout({
route: "/inventory/transfer-orders", route: "/inventory/transfer-orders",
permission: "inventory_transfer.view", permission: "inventory_transfer.view",
}, },
{
id: "store-requisition",
label: "門市叫貨",
icon: <Store className="h-4 w-4" />,
route: "/store-requisitions",
permission: "store_requisitions.view",
},
], ],
}, },
{ {

View File

@@ -195,21 +195,28 @@ export default function Show({ order }: any) {
<AuthenticatedLayout <AuthenticatedLayout
breadcrumbs={[ breadcrumbs={[
{ label: '商品與庫存管理', href: '#' }, { label: '商品與庫存管理', href: '#' },
{ label: '庫存調撥', href: route('inventory.transfer.index') }, {
label: order.requisition ? '門市叫貨申請' : '庫存調撥',
href: order.requisition ? route('store-requisitions.index') : route('inventory.transfer.index')
},
order.requisition && {
label: `叫貨單: ${order.requisition.doc_no}`,
href: route('store-requisitions.show', [order.requisition.id])
},
{ label: `調撥單: ${order.doc_no}`, href: route('inventory.transfer.show', [order.id]), isPage: true }, { label: `調撥單: ${order.doc_no}`, href: route('inventory.transfer.show', [order.id]), isPage: true },
]} ].filter(Boolean) as any}
> >
<Head title={`調撥單 ${order.doc_no}`} /> <Head title={`調撥單 ${order.doc_no}`} />
<div className="container mx-auto p-6 max-w-7xl animate-in fade-in duration-500 space-y-6"> <div className="container mx-auto p-6 max-w-7xl animate-in fade-in duration-500 space-y-6">
<div> <div>
<Link href={route('inventory.transfer.index')}> <Link href={order.requisition ? route('store-requisitions.show', [order.requisition.id]) : route('inventory.transfer.index')}>
<Button <Button
variant="outline" variant="outline"
className="gap-2 button-outlined-primary mb-6" className="gap-2 button-outlined-primary mb-6"
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
調 {order.requisition ? `返回叫貨單: ${order.requisition.doc_no}` : '返回調撥單列表'}
</Button> </Button>
</Link> </Link>

View File

@@ -0,0 +1,374 @@
import { useState } from "react";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, Link, router } from "@inertiajs/react";
import { SearchableSelect } from "@/Components/ui/searchable-select";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Textarea } from "@/Components/ui/textarea";
import { Label } from "@/Components/ui/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/Components/ui/table";
import { toast } from "sonner";
import {
Store,
Plus,
Trash2,
Loader2,
Save,
SendHorizontal,
ArrowLeft,
} from "lucide-react";
interface Product {
id: number;
name: string;
code: string;
unit_name: string;
}
interface Warehouse {
id: number;
name: string;
type: string;
}
interface RequisitionItem {
product_id: string;
requested_qty: string;
remark: string;
}
interface Props {
requisition?: {
id: number;
store_warehouse_id: number;
remark: string | null;
status: string;
items: {
id: number;
product_id: number;
requested_qty: number;
remark: string | null;
}[];
};
warehouses: Warehouse[];
products: Product[];
}
export default function Create({ requisition, warehouses, products }: Props) {
const isEditing = !!requisition;
const [storeWarehouseId, setStoreWarehouseId] = useState(
requisition?.store_warehouse_id?.toString() || ""
);
const [remark, setRemark] = useState(requisition?.remark || "");
const [items, setItems] = useState<RequisitionItem[]>(
requisition?.items?.map((item) => ({
product_id: item.product_id.toString(),
requested_qty: item.requested_qty.toString(),
remark: item.remark || "",
})) || [{ product_id: "", requested_qty: "", remark: "" }]
);
const [saving, setSaving] = useState(false);
const [submitting, setSubmitting] = useState(false);
const addItem = () => {
setItems([...items, { product_id: "", requested_qty: "", remark: "" }]);
};
const removeItem = (index: number) => {
if (items.length <= 1) {
toast.error("至少需要一項商品");
return;
}
setItems(items.filter((_, i) => i !== index));
};
const updateItem = (index: number, field: keyof RequisitionItem, value: string) => {
const newItems = [...items];
newItems[index] = { ...newItems[index], [field]: value };
setItems(newItems);
};
const validate = (): boolean => {
if (!storeWarehouseId) {
toast.error("請選擇申請倉庫");
return false;
}
if (items.length === 0) {
toast.error("至少需要一項商品");
return false;
}
for (let i = 0; i < items.length; i++) {
if (!items[i].product_id) {
toast.error(`${i + 1} 行請選擇商品`);
return false;
}
const qty = parseInt(items[i].requested_qty);
if (!qty || qty < 1) {
toast.error(`${i + 1} 行需求數量必須大於等於 1`);
return false;
}
}
// 檢查是否有重複商品
const productIds = items.map((item) => item.product_id);
if (new Set(productIds).size !== productIds.length) {
toast.error("不可重複選擇商品");
return false;
}
return true;
};
const handleSave = (submitImmediately: boolean = false) => {
if (!validate()) return;
const setter = submitImmediately ? setSubmitting : setSaving;
setter(true);
const payload = {
store_warehouse_id: storeWarehouseId,
remark: remark || null,
items: items.map((item) => ({
product_id: parseInt(item.product_id),
requested_qty: parseFloat(item.requested_qty),
remark: item.remark || null,
})),
submit_immediately: submitImmediately,
};
if (isEditing) {
router.put(route("store-requisitions.update", [requisition!.id]), payload, {
onFinish: () => setter(false),
});
} else {
router.post(route("store-requisitions.store"), payload, {
onFinish: () => setter(false),
});
}
};
// 已選商品列表(用於過濾下拉選項)
const selectedProductIds = items.map((item) => item.product_id).filter(Boolean);
return (
<AuthenticatedLayout
breadcrumbs={[
{ label: "商品與庫存管理", href: "#" },
{ label: "門市叫貨", href: route("store-requisitions.index") },
{
label: isEditing ? "編輯叫貨單" : "新增叫貨單",
href: "#",
isPage: true,
},
]}
>
<Head title={isEditing ? "編輯叫貨單" : "新增叫貨單"} />
<div className="container mx-auto p-6 max-w-7xl">
{/* 返回按鈕 */}
<div className="mb-6">
<Link href={route("store-requisitions.index")}>
<Button variant="outline" className="gap-2 button-outlined-primary">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
</div>
{/* 頁面標題 */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<Store className="h-6 w-6 text-primary-main" />
{isEditing ? `編輯叫貨單 ${requisition?.status === "rejected" ? "(重新提交)" : ""}` : "新增叫貨單"}
</h1>
<p className="text-gray-500 mt-1">
</p>
</div>
{/* 基本資訊 */}
<div className="bg-white rounded-lg shadow-sm border p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-800 mb-4"></h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label>
<span className="text-red-500">*</span>
</Label>
<SearchableSelect
value={storeWarehouseId}
onValueChange={setStoreWarehouseId}
options={warehouses.map((w) => ({
label: w.name,
value: w.id.toString(),
}))}
placeholder="請選擇倉庫"
className="h-9"
/>
<p className="text-xs text-gray-400"></p>
</div>
<div className="space-y-2">
<Label></Label>
<Textarea
value={remark}
onChange={(e) => setRemark(e.target.value)}
placeholder="補充說明(選填)"
rows={3}
/>
</div>
</div>
</div>
{/* 商品明細 */}
<div className="bg-white rounded-lg shadow-sm border p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-800"></h2>
<Button
type="button"
variant="outline"
size="sm"
className="button-outlined-primary"
onClick={addItem}
>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
<div className="bg-white rounded-xl border border-gray-200 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 min-w-[250px]">
<span className="text-red-500">*</span>
</TableHead>
<TableHead className="font-medium text-gray-600 w-[150px]">
<span className="text-red-500">*</span>
</TableHead>
<TableHead className="font-medium text-gray-600 w-[100px]"></TableHead>
<TableHead className="font-medium text-gray-600 min-w-[150px]"></TableHead>
<TableHead className="w-[60px] text-center font-medium text-gray-600">
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item, index) => {
const selectedProduct = products.find(
(p) => String(p.id) === String(item.product_id)
);
return (
<TableRow key={index}>
<TableCell className="text-center text-gray-500 font-medium">
{index + 1}
</TableCell>
<TableCell>
<SearchableSelect
value={item.product_id}
onValueChange={(val) =>
updateItem(index, "product_id", val)
}
options={products
.filter(
(p) =>
!selectedProductIds.includes(
p.id.toString()
) ||
p.id.toString() === item.product_id
)
.map((p) => ({
label: `${p.code} - ${p.name}`,
value: p.id.toString(),
}))}
placeholder="選擇商品"
className="h-9"
/>
</TableCell>
<TableCell>
<Input
type="number"
step="1"
min="1"
value={item.requested_qty}
onChange={(e) =>
updateItem(index, "requested_qty", e.target.value)
}
placeholder="0"
className="h-9 text-right"
/>
</TableCell>
<TableCell className="text-gray-500">
{selectedProduct?.unit_name || "-"}
</TableCell>
<TableCell>
<Input
value={item.remark}
onChange={(e) =>
updateItem(index, "remark", e.target.value)
}
placeholder="備註"
className="h-9"
/>
</TableCell>
<TableCell className="text-center">
<Button
variant="outline"
size="sm"
onClick={() => removeItem(index)}
className="button-outlined-error"
>
<Trash2 className="w-4 h-4" />
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</div>
{/* 操作按鈕列 */}
<div className="flex items-center justify-end gap-3">
<Button
type="button"
variant="outline"
className="button-outlined-primary"
onClick={() => router.visit(route("store-requisitions.index"))}
>
</Button>
<Button
type="button"
variant="outline"
className="button-outlined-primary"
disabled={saving || submitting}
onClick={() => handleSave(false)}
>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<Save className="w-4 h-4 mr-1" />
稿
</Button>
<Button
type="button"
className="button-filled-primary"
disabled={saving || submitting}
onClick={() => handleSave(true)}
>
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<SendHorizontal className="w-4 h-4 mr-1" />
</Button>
</div>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -0,0 +1,407 @@
import { useState, useCallback, useEffect } from "react";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, Link, router } from "@inertiajs/react";
import { debounce } from "lodash";
import { SearchableSelect } from "@/Components/ui/searchable-select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/Components/ui/table";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Badge } from "@/Components/ui/badge";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/Components/ui/alert-dialog";
import Pagination from "@/Components/shared/Pagination";
import { toast } from "sonner";
import { Can } from "@/Components/Permission/Can";
import { usePermission } from "@/hooks/usePermission";
import {
Plus,
Search,
Store,
Eye,
Pencil,
Trash2,
X,
} from "lucide-react";
import { formatDate } from "@/lib/date";
const statusMap: Record<string, { label: string; variant: string }> = {
draft: { label: "草稿", variant: "secondary" },
pending: { label: "待審核", variant: "warning" },
approved: { label: "已核准", variant: "success" },
rejected: { label: "已駁回", variant: "destructive" },
completed: { label: "已完成", variant: "default" },
cancelled: { label: "已取消", variant: "outline" },
};
function getStatusBadge(status: string) {
const config = statusMap[status];
if (!config) return <Badge variant="outline">{status}</Badge>;
const variantClass: Record<string, string> = {
secondary: "",
warning: "bg-amber-500 hover:bg-amber-600 text-white",
success: "bg-green-500 hover:bg-green-600 text-white",
destructive: "",
default: "bg-blue-500 hover:bg-blue-600 text-white",
outline: "",
};
const variant = ["secondary", "destructive", "outline"].includes(config.variant)
? (config.variant as "secondary" | "destructive" | "outline")
: "default";
return (
<Badge variant={variant} className={variantClass[config.variant] || ""}>
{config.label}
</Badge>
);
}
export default function Index({
requisitions,
filters,
warehouses,
}: {
requisitions: any;
filters: any;
warehouses: { id: number; name: string }[];
}) {
const { can } = usePermission();
const [searchTerm, setSearchTerm] = useState(filters.search || "");
const [statusFilter, setStatusFilter] = useState(filters.status || "all");
const [warehouseFilter, setWarehouseFilter] = useState(filters.warehouse_id || "all");
const [perPage, setPerPage] = useState(filters.per_page || "10");
const [deleteId, setDeleteId] = useState<string | null>(null);
useEffect(() => {
setSearchTerm(filters.search || "");
setStatusFilter(filters.status || "all");
setWarehouseFilter(filters.warehouse_id || "all");
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];
});
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]
);
const handleSearchChange = (term: string) => {
setSearchTerm(term);
debouncedSearch(term);
};
const handleClearSearch = () => {
setSearchTerm("");
applyFilters({ search: "" });
};
const handleStatusChange = (value: string) => {
setStatusFilter(value);
applyFilters({ status: value === "all" ? "" : value });
};
const handleWarehouseChange = (value: string) => {
setWarehouseFilter(value);
applyFilters({ warehouse_id: value === "all" ? "" : value });
};
const handlePerPageChange = (value: string) => {
setPerPage(value);
applyFilters({ per_page: value });
};
const handleDelete = () => {
if (deleteId) {
router.delete(route("store-requisitions.destroy", [deleteId]), {
onSuccess: () => {
setDeleteId(null);
toast.success("已成功刪除");
},
onError: () => setDeleteId(null),
});
}
};
return (
<AuthenticatedLayout
breadcrumbs={[
{ label: "商品與庫存管理", href: "#" },
{ label: "門市叫貨", href: route("store-requisitions.index"), isPage: true },
]}
>
<Head title="門市叫貨" />
<div className="container mx-auto p-6 max-w-7xl">
{/* 頁面標題 */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<Store 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: "draft" },
{ label: "待審核", value: "pending" },
{ label: "已核准", value: "approved" },
{ label: "已駁回", value: "rejected" },
{ label: "已完成", value: "completed" },
{ label: "已取消", value: "cancelled" },
]}
placeholder="選擇狀態"
className="w-full md:w-[160px] h-9"
showSearch={false}
/>
{/* 倉庫篩選 */}
<SearchableSelect
value={warehouseFilter}
onValueChange={handleWarehouseChange}
options={[
{ label: "所有倉庫", value: "all" },
...warehouses.map((w) => ({
label: w.name,
value: w.id.toString(),
})),
]}
placeholder="選擇倉庫"
className="w-full md:w-[200px] h-9"
/>
{/* 操作按鈕 */}
<div className="flex gap-2 w-full md:w-auto">
<Can permission="store_requisitions.create">
<Link href={route("store-requisitions.create")}>
<Button className="flex-1 md:flex-none button-filled-primary">
<Plus className="w-4 h-4 mr-2" />
</Button>
</Link>
</Can>
</div>
</div>
</div>
{/* 資料表格 */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="w-[50px] text-center 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"></TableHead>
<TableHead className="font-medium text-gray-600"></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>
{requisitions.data.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center h-24 text-gray-500">
</TableCell>
</TableRow>
) : (
requisitions.data.map((req: any, index: number) => (
<TableRow
key={req.id}
className="hover:bg-gray-50/50 transition-colors cursor-pointer group"
onClick={() =>
router.visit(route("store-requisitions.show", [req.id]))
}
>
<TableCell className="text-center text-gray-500 font-medium">
{(requisitions.current_page - 1) * requisitions.per_page + index + 1}
</TableCell>
<TableCell className="font-medium text-primary-main">
{req.doc_no}
</TableCell>
<TableCell className="text-gray-700">
{req.store_warehouse_name}
</TableCell>
<TableCell className="text-gray-700">
{req.supply_warehouse_name}
</TableCell>
<TableCell className="text-sm">{req.creator_name}</TableCell>
<TableCell className="text-gray-500 text-sm">
{formatDate(req.created_at)}
</TableCell>
<TableCell className="text-center">
{getStatusBadge(req.status)}
</TableCell>
<TableCell className="text-center">
<div
className="flex items-center justify-center gap-2"
onClick={(e) => e.stopPropagation()}
>
{(() => {
const isEditable = ["draft", "rejected"].includes(req.status);
const canEdit = can("store_requisitions.edit");
if (isEditable && canEdit) {
return (
<Link href={route("store-requisitions.edit", [req.id])}>
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
title="編輯"
>
<Pencil className="w-4 h-4 ml-0.5" />
</Button>
</Link>
);
}
return (
<Link href={route("store-requisitions.show", [req.id])}>
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
title="查閱"
>
<Eye className="w-4 h-4 ml-0.5" />
</Button>
</Link>
);
})()}
{req.status === "draft" && (
<Can permission="store_requisitions.delete">
<Button
variant="outline"
size="sm"
className="button-outlined-error"
title="刪除"
onClick={() => setDeleteId(req.id)}
>
<Trash2 className="w-4 h-4 ml-0.5" />
</Button>
</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"> {requisitions.total} </span>
</div>
<Pagination links={requisitions.links} />
</div>
{/* 刪除確認對話框 */}
<AlertDialog open={!!deleteId} onOpenChange={(open) => !open && setDeleteId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="button-filled-error">
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -0,0 +1,601 @@
import { useState } from "react";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, Link, router } from "@inertiajs/react";
import { SearchableSelect } from "@/Components/ui/searchable-select";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Textarea } from "@/Components/ui/textarea";
import { Label } from "@/Components/ui/label";
import { Badge } from "@/Components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/Components/ui/table";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from "@/Components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/Components/ui/alert-dialog";
import { toast } from "sonner";
import { Can } from "@/Components/Permission/Can";
import { usePermission } from "@/hooks/usePermission";
import {
Store,
SendHorizontal,
CheckCircle2,
XCircle,
Pencil,
Loader2,
ArrowLeft,
} from "lucide-react";
import { formatDate } from "@/lib/date";
const statusMap: Record<string, { label: string; variant: string }> = {
draft: { label: "草稿", variant: "secondary" },
pending: { label: "待審核", variant: "warning" },
approved: { label: "已核准", variant: "success" },
rejected: { label: "已駁回", variant: "destructive" },
completed: { label: "已完成", variant: "default" },
cancelled: { label: "已取消", variant: "outline" },
};
function getStatusBadge(status: string) {
const config = statusMap[status];
if (!config) return <Badge variant="outline">{status}</Badge>;
const variantClass: Record<string, string> = {
secondary: "",
warning: "bg-amber-500 hover:bg-amber-600 text-white",
success: "bg-green-500 hover:bg-green-600 text-white",
destructive: "",
default: "bg-blue-500 hover:bg-blue-600 text-white",
outline: "",
};
const variant = ["secondary", "destructive", "outline"].includes(config.variant)
? (config.variant as "secondary" | "destructive" | "outline")
: "default";
return (
<Badge variant={variant} className={variantClass[config.variant] || ""}>
{config.label}
</Badge>
);
}
interface RequisitionItem {
id: number;
product_id: number;
product_name: string;
product_code: string;
unit_name: string;
requested_qty: number;
approved_qty: number | null;
current_stock: number;
remark: string | null;
}
interface Requisition {
id: number;
doc_no: string;
status: string;
store_warehouse_id: number;
store_warehouse_name: string;
supply_warehouse_id: number | null;
supply_warehouse_name: string;
remark: string | null;
reject_reason: string | null;
creator_name: string;
approver_name: string;
submitted_at: string | null;
approved_at: string | null;
transfer_order_id: number | null;
created_at: string;
items: RequisitionItem[];
}
interface Props {
requisition: Requisition;
warehouses: { id: number; name: string }[];
activities: any[];
}
export default function Show({ requisition, warehouses }: Props) {
usePermission();
const [submitting, setSubmitting] = useState(false);
const [approving, setApproving] = useState(false);
const [rejecting, setRejecting] = useState(false);
// 核准狀態
const [showApproveDialog, setShowApproveDialog] = useState(false);
const [supplyWarehouseId, setSupplyWarehouseId] = useState("");
const [approvedItems, setApprovedItems] = useState<{ id: number; approved_qty: string }[]>(
requisition.items.map((item) => ({
id: item.id,
approved_qty: item.requested_qty.toString(),
}))
);
// 駁回狀態
const [showRejectDialog, setShowRejectDialog] = useState(false);
const [rejectReason, setRejectReason] = useState("");
// 提交確認
const [showSubmitDialog, setShowSubmitDialog] = useState(false);
const handleSubmit = () => {
setSubmitting(true);
router.post(route("store-requisitions.submit", [requisition.id]), {}, {
onFinish: () => {
setSubmitting(false);
setShowSubmitDialog(false);
},
});
};
const handleApprove = () => {
if (!supplyWarehouseId) {
toast.error("請選擇供貨倉庫");
return;
}
// 確認每個核准數量
for (const item of approvedItems) {
const qty = parseFloat(item.approved_qty);
if (isNaN(qty) || qty < 0) {
toast.error("核准數量不能為負數");
return;
}
}
setApproving(true);
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),
})),
},
{
onFinish: () => {
setApproving(false);
setShowApproveDialog(false);
},
}
);
};
const handleReject = () => {
if (!rejectReason.trim()) {
toast.error("請填寫駁回原因");
return;
}
setRejecting(true);
router.post(
route("store-requisitions.reject", [requisition.id]),
{ reject_reason: rejectReason },
{
onFinish: () => {
setRejecting(false);
setShowRejectDialog(false);
},
}
);
};
const updateApprovedQty = (itemId: number, qty: string) => {
setApprovedItems(
approvedItems.map((item) => (item.id === itemId ? { ...item, approved_qty: qty } : item))
);
};
const isEditable = ["draft", "rejected"].includes(requisition.status);
const isPending = requisition.status === "pending";
return (
<AuthenticatedLayout
breadcrumbs={[
{ label: "商品與庫存管理", href: "#" },
{ label: "門市叫貨", href: route("store-requisitions.index") },
{ label: requisition.doc_no, href: "#", isPage: true },
]}
>
<Head title={`叫貨單 ${requisition.doc_no}`} />
<div className="container mx-auto p-6 max-w-7xl">
{/* 返回按鈕 */}
<div className="mb-6">
<Link href={route("store-requisitions.index")}>
<Button variant="outline" 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">
<Store className="h-6 w-6 text-primary-main" />
{requisition.doc_no}
</h1>
<div className="flex items-center gap-2 mt-1">
{getStatusBadge(requisition.status)}
<span className="text-gray-500 text-sm">
{formatDate(requisition.created_at)}
</span>
</div>
</div>
{/* 操作按鈕 */}
<div className="flex gap-2">
{isEditable && (
<>
<Can permission="store_requisitions.edit">
<Link href={route("store-requisitions.edit", [requisition.id])}>
<Button variant="outline" className="button-outlined-primary">
<Pencil className="w-4 h-4 mr-1" />
</Button>
</Link>
</Can>
{requisition.status === "draft" && (
<Can permission="store_requisitions.view">
<Button
className="button-filled-primary"
onClick={() => setShowSubmitDialog(true)}
>
<SendHorizontal className="w-4 h-4 mr-1" />
</Button>
</Can>
)}
</>
)}
{isPending && (
<>
<Can permission="store_requisitions.approve">
<Button
variant="outline"
className="button-outlined-error"
onClick={() => setShowRejectDialog(true)}
>
<XCircle className="w-4 h-4 mr-1" />
</Button>
<Button
className="button-filled-success"
onClick={() => setShowApproveDialog(true)}
>
<CheckCircle2 className="w-4 h-4 mr-1" />
</Button>
</Can>
</>
)}
</div>
</div>
{/* 基本資訊 */}
<div className="bg-white rounded-lg shadow-sm border p-6 mb-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">
{requisition.store_warehouse_name}
</p>
</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>
<div>
<span className="text-sm text-gray-500"></span>
<p className="font-medium text-gray-800 mt-1">
{requisition.creator_name}
</p>
</div>
{requisition.submitted_at && (
<div>
<span className="text-sm text-gray-500"></span>
<p className="font-medium text-gray-800 mt-1">
{formatDate(requisition.submitted_at)}
</p>
</div>
)}
{requisition.approved_at && (
<>
<div>
<span className="text-sm text-gray-500"></span>
<p className="font-medium text-gray-800 mt-1">
{requisition.approver_name}
</p>
</div>
<div>
<span className="text-sm text-gray-500"></span>
<p className="font-medium text-gray-800 mt-1">
{formatDate(requisition.approved_at)}
</p>
</div>
</>
)}
{requisition.remark && (
<div className="md:col-span-3">
<span className="text-sm text-gray-500"></span>
<p className="font-medium text-gray-800 mt-1">
{requisition.remark}
</p>
</div>
)}
{requisition.reject_reason && (
<div className="md:col-span-3">
<span className="text-sm text-red-500 font-medium"></span>
<p className="text-red-600 bg-red-50 rounded-md p-3 mt-1">
{requisition.reject_reason}
</p>
</div>
)}
{requisition.transfer_order_id && (
<div>
<span className="text-sm text-gray-500">調</span>
<p className="mt-1">
<Link
href={route("inventory.transfer.show", [requisition.transfer_order_id])}
className="text-primary-main hover:underline font-medium"
>
調
</Link>
</p>
</div>
)}
</div>
</div>
{/* 商品明細 */}
<div className="bg-white rounded-lg shadow-sm border p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-800 mb-4"></h2>
<div className="bg-white rounded-xl border border-gray-200 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="text-right font-medium text-gray-600">
</TableHead>
<TableHead className="text-right font-medium text-gray-600">
</TableHead>
<TableHead className="font-medium text-gray-600"></TableHead>
{["approved", "completed"].includes(requisition.status) && (
<TableHead className="text-right font-medium text-gray-600">
</TableHead>
)}
<TableHead className="font-medium text-gray-600"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{requisition.items.map((item, index) => (
<TableRow key={item.id}>
<TableCell className="text-center text-gray-500 font-medium">
{index + 1}
</TableCell>
<TableCell className="font-mono text-sm text-gray-600">
{item.product_code}
</TableCell>
<TableCell className="font-medium text-gray-800">
{item.product_name}
</TableCell>
<TableCell className="text-right text-gray-600">
{Number(item.current_stock).toLocaleString()}
</TableCell>
<TableCell className="text-right font-medium text-gray-800">
{Number(item.requested_qty).toLocaleString()}
</TableCell>
<TableCell className="text-gray-500">{item.unit_name}</TableCell>
{["approved", "completed"].includes(requisition.status) && (
<TableCell className="text-right font-medium text-green-600">
{item.approved_qty !== null
? Number(item.approved_qty).toLocaleString()
: "-"}
</TableCell>
)}
<TableCell className="text-gray-500 text-sm">
{item.remark || "-"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</div>
{/* 提交確認 */}
<AlertDialog open={showSubmitDialog} onOpenChange={setShowSubmitDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleSubmit}
className="button-filled-primary"
disabled={submitting}
>
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 核准對話框 */}
<Dialog open={showApproveDialog} onOpenChange={setShowApproveDialog}>
<DialogContent className="sm:max-w-[700px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<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>
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="font-medium text-gray-600"></TableHead>
<TableHead className="text-right font-medium text-gray-600 w-[120px]">
</TableHead>
<TableHead className="font-medium text-gray-600 w-[80px]"></TableHead>
<TableHead className="font-medium text-gray-600 w-[150px]">
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{requisition.items.map((item) => (
<TableRow key={item.id}>
<TableCell>
<span className="font-mono text-xs text-gray-500">
{item.product_code}
</span>
<span className="ml-2 text-gray-800">{item.product_name}</span>
</TableCell>
<TableCell className="text-right text-gray-700">
{Number(item.requested_qty).toLocaleString()}
</TableCell>
<TableCell className="text-gray-500 text-sm">
{item.unit_name}
</TableCell>
<TableCell>
<Input
type="number"
step="1"
min="0"
value={
approvedItems.find((ai) => ai.id === item.id)
?.approved_qty || ""
}
onChange={(e) =>
updateApprovedQty(item.id, e.target.value)
}
className="h-8 text-right"
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
className="button-outlined-primary"
onClick={() => setShowApproveDialog(false)}
>
</Button>
<Button
className="bg-green-600 hover:bg-green-700 text-white"
onClick={handleApprove}
disabled={approving || !supplyWarehouseId}
>
{approving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 駁回對話框 */}
<Dialog open={showRejectDialog} onOpenChange={setShowRejectDialog}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="py-2">
<Label>
<span className="text-red-500">*</span>
</Label>
<Textarea
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
placeholder="請填寫駁回原因..."
rows={4}
className="mt-2"
/>
</div>
<DialogFooter>
<Button
variant="outline"
className="button-outlined-primary"
onClick={() => setShowRejectDialog(false)}
>
</Button>
<Button
variant="destructive"
onClick={handleReject}
disabled={rejecting || !rejectReason.trim()}
>
{rejecting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</AuthenticatedLayout>
);
}