diff --git a/app/Modules/Procurement/Controllers/PurchaseOrderController.php b/app/Modules/Procurement/Controllers/PurchaseOrderController.php index e82d538..281291e 100644 --- a/app/Modules/Procurement/Controllers/PurchaseOrderController.php +++ b/app/Modules/Procurement/Controllers/PurchaseOrderController.php @@ -447,11 +447,17 @@ class PurchaseOrderController extends Controller $taxAmount = !is_null($inputTax) ? $inputTax : round($totalAmount * 0.05, 2); $grandTotal = $totalAmount + $taxAmount; + // 狀態轉移權限檢查 + if (isset($validated['status']) && $order->status !== $validated['status']) { + if (!$order->canTransitionTo($validated['status'])) { + return back()->withErrors(['error' => '您沒有權限將狀態從 ' . $order->status . ' 變更為 ' . $validated['status']]); + } + } // 1. 填充屬性但暫不儲存以捕捉變更 $order->fill([ 'vendor_id' => $validated['vendor_id'], 'warehouse_id' => $validated['warehouse_id'], - 'order_date' => $validated['order_date'], // 新增 + 'order_date' => $validated['order_date'], 'expected_delivery_date' => $validated['expected_delivery_date'], 'total_amount' => $totalAmount, 'tax_amount' => $taxAmount, @@ -460,11 +466,22 @@ class PurchaseOrderController extends Controller 'status' => $validated['status'], 'invoice_number' => $validated['invoice_number'] ?? null, 'invoice_date' => $validated['invoice_date'] ?? null, - 'invoice_amount' => $validated['invoice_amount'] ?? null, + 'invoice_amount' => (float) ($validated['invoice_amount'] ?? 0), ]); - // 捕捉變更屬性以進行手動記錄 + // 捕捉變更屬性 $dirty = $order->getDirty(); + + // 嚴格權限檢查:如果修改了 status 以外的任何欄位,必須具備編輯權限 + $otherChanges = array_diff(array_keys($dirty), ['status']); + if (!empty($otherChanges)) { + $canEdit = auth()->user()->hasRole('super-admin') || auth()->user()->can('purchase_orders.edit'); + if (!$canEdit) { + throw new \Exception('您沒有權限修改採購單的基本內容,僅能執行流程異動(如:送審)。'); + } + } + + // 捕捉舊屬性以進行記錄 $oldAttributes = []; $newAttributes = []; @@ -657,7 +674,7 @@ class PurchaseOrderController extends Controller DB::commit(); - return redirect()->route('purchase-orders.index')->with('success', '採購單已刪除'); + return redirect()->route('purchase-orders.index')->with('success', '採購單已作廢'); } catch (\Exception $e) { DB::rollBack(); return back()->withErrors(['error' => '刪除失敗:' . $e->getMessage()]); diff --git a/app/Modules/Procurement/Models/PurchaseOrder.php b/app/Modules/Procurement/Models/PurchaseOrder.php index 154e3d5..1bc7712 100644 --- a/app/Modules/Procurement/Models/PurchaseOrder.php +++ b/app/Modules/Procurement/Models/PurchaseOrder.php @@ -70,4 +70,50 @@ class PurchaseOrder extends Model { return $this->hasMany(PurchaseOrderItem::class); } + + /** + * 檢查是否可以轉移至新狀態,並驗證權限。 + */ + public function canTransitionTo(string $newStatus, $user = null): bool + { + $user = $user ?? auth()->user(); + if (!$user) return false; + if ($user->hasRole('super-admin')) return true; + + $currentStatus = $this->status; + + // 定義合法的狀態轉移路徑與所需權限 + $transitions = [ + 'draft' => [ + 'pending' => 'purchase_orders.view', // 基本檢視者即可送審 + 'cancelled' => 'purchase_orders.cancel', + ], + 'pending' => [ + 'approved' => 'purchase_orders.approve', + 'draft' => 'purchase_orders.approve', // 退回草稿 + 'cancelled' => 'purchase_orders.cancel', + ], + 'approved' => [ + 'cancelled' => 'purchase_orders.cancel', + 'partial' => null, // 系統自動轉移,不需手動權限點 + ], + 'partial' => [ + 'completed' => null, // 系統自動轉移 + 'closed' => 'purchase_orders.approve', // 手動結案通常需要核准權限 + 'cancelled' => 'purchase_orders.cancel', + ], + ]; + + if (!isset($transitions[$currentStatus])) { + return false; + } + + if (!array_key_exists($newStatus, $transitions[$currentStatus])) { + return false; + } + + $requiredPermission = $transitions[$currentStatus][$newStatus]; + + return $requiredPermission ? $user->can($requiredPermission) : true; + } } diff --git a/app/Modules/Procurement/Routes/web.php b/app/Modules/Procurement/Routes/web.php index 7d948b7..1d1fa1b 100644 --- a/app/Modules/Procurement/Routes/web.php +++ b/app/Modules/Procurement/Routes/web.php @@ -32,7 +32,7 @@ Route::middleware('auth')->group(function () { Route::get('/purchase-orders/{id}', [PurchaseOrderController::class, 'show'])->name('purchase-orders.show'); Route::get('/purchase-orders/{id}/edit', [PurchaseOrderController::class, 'edit'])->middleware('permission:purchase_orders.edit')->name('purchase-orders.edit'); - Route::put('/purchase-orders/{id}', [PurchaseOrderController::class, 'update'])->middleware('permission:purchase_orders.edit')->name('purchase-orders.update'); + Route::match(['PUT', 'PATCH'], '/purchase-orders/{id}', [PurchaseOrderController::class, 'update'])->name('purchase-orders.update'); Route::delete('/purchase-orders/{id}', [PurchaseOrderController::class, 'destroy'])->middleware('permission:purchase_orders.delete')->name('purchase-orders.destroy'); }); diff --git a/database/seeders/PermissionSeeder.php b/database/seeders/PermissionSeeder.php index daa946c..b640442 100644 --- a/database/seeders/PermissionSeeder.php +++ b/database/seeders/PermissionSeeder.php @@ -30,6 +30,8 @@ class PermissionSeeder extends Seeder 'purchase_orders.create', 'purchase_orders.edit', 'purchase_orders.delete', + 'purchase_orders.approve', // 核准權限 + 'purchase_orders.cancel', // 作廢權限(原取消) // 庫存管理 @@ -138,7 +140,7 @@ class PermissionSeeder extends Seeder $admin->givePermissionTo([ 'products.view', 'products.create', 'products.edit', 'products.delete', 'purchase_orders.view', 'purchase_orders.create', 'purchase_orders.edit', - 'purchase_orders.delete', + 'purchase_orders.delete', 'purchase_orders.approve', 'purchase_orders.cancel', 'inventory.view', 'inventory.view_cost', 'inventory.delete', 'inventory_count.view', 'inventory_count.create', 'inventory_count.edit', 'inventory_count.delete', 'inventory_adjust.view', 'inventory_adjust.create', 'inventory_adjust.edit', 'inventory_adjust.delete', diff --git a/resources/js/Components/PurchaseOrder/PurchaseOrderActions.tsx b/resources/js/Components/PurchaseOrder/PurchaseOrderActions.tsx index 760ca8a..368c8ec 100644 --- a/resources/js/Components/PurchaseOrder/PurchaseOrderActions.tsx +++ b/resources/js/Components/PurchaseOrder/PurchaseOrderActions.tsx @@ -46,28 +46,32 @@ export function PurchaseOrderActions({ - + {order.status === 'draft' && ( + + + + )} + + + {order.status === 'draft' && ( - - - - + )} diff --git a/resources/js/Components/ui/button.tsx b/resources/js/Components/ui/button.tsx index 7d5175c..9c0b2fb 100644 --- a/resources/js/Components/ui/button.tsx +++ b/resources/js/Components/ui/button.tsx @@ -24,6 +24,7 @@ const buttonVariants = cva( default: "h-9 px-4 py-2 has-[>svg]:px-3", sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + xl: "h-14 px-12 text-lg font-bold rounded-xl shadow-lg transition-all hover:scale-[1.02] active:scale-[0.98] gap-2", icon: "size-9 rounded-md", }, }, diff --git a/resources/js/Pages/Admin/Role/PermissionSelector.tsx b/resources/js/Pages/Admin/Role/PermissionSelector.tsx index b1e4b5e..76488f7 100644 --- a/resources/js/Pages/Admin/Role/PermissionSelector.tsx +++ b/resources/js/Pages/Admin/Role/PermissionSelector.tsx @@ -46,6 +46,8 @@ export default function PermissionSelector({ groupedPermissions, selectedPermiss 'view_cost': '檢視成本', 'view_logs': '檢視日誌', 'activate': '啟用/停用', + 'approve': '核准/退回', + 'cancel': '取消', }; const actionText = map[action] || action; @@ -203,6 +205,11 @@ export default function PermissionSelector({ groupedPermissions, selectedPermiss const totalCount = group.permissions.length; const isAllSelected = selectedCount === totalCount; + // 將權限分為「基本操作」與「狀態/進階操作」 + const statusActions = ['approve', 'cancel', 'complete', 'activate']; + const normalPermissions = group.permissions.filter(p => !statusActions.includes(p.name.split('.').pop() || '')); + const specialPermissions = group.permissions.filter(p => statusActions.includes(p.name.split('.').pop() || '')); + return (
- {/* Group Selection Checkbox - Moved outside trigger to avoid bubbling issues, positioned left */}
{ - // Stop propagation to prevent accordion from toggling - // This is implicitly handled by the checkbox being a sibling, - // but if it were a child of AccordionTrigger, stopPropagation would be needed. - // For clarity, we can add it here if needed, but the current structure makes it unnecessary. - toggleGroup(group.permissions); - }} + onCheckedChange={() => toggleGroup(group.permissions)} className="data-[state=checked]:bg-primary-main" />
@@ -241,30 +241,47 @@ export default function PermissionSelector({ groupedPermissions, selectedPermiss
-
- {/* Permissions Grid */} -
- {group.permissions.map((permission) => ( -
- togglePermission(permission.name)} - /> -
- -

- {permission.name} -

-
+
+ {/* 基本操作 */} + {normalPermissions.length > 0 && ( +
+
+ 基本功能權限
- ))} -
+
+ {normalPermissions.map((permission) => ( + + ))} +
+
+ )} + + {/* 狀態操作/進階權限 */} + {specialPermissions.length > 0 && ( +
+
+ + 單據狀態與進階操作權限 +
+
+ {specialPermissions.map((permission) => ( + + ))} +
+
+ )}
@@ -275,3 +292,26 @@ export default function PermissionSelector({ groupedPermissions, selectedPermiss
); } + +function PermissionItem({ permission, selectedPermissions, onToggle, translate }: any) { + return ( +
+ onToggle(permission.name)} + /> +
+ +

+ {permission.name} +

+
+
+ ); +} diff --git a/resources/js/Pages/PurchaseOrder/Create.tsx b/resources/js/Pages/PurchaseOrder/Create.tsx index de5930a..8b159cc 100644 --- a/resources/js/Pages/PurchaseOrder/Create.tsx +++ b/resources/js/Pages/PurchaseOrder/Create.tsx @@ -10,7 +10,7 @@ import { Textarea } from "@/Components/ui/textarea"; import { Alert, AlertDescription } from "@/Components/ui/alert"; import { SearchableSelect } from "@/Components/ui/searchable-select"; import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; -import { Head, Link, router } from "@inertiajs/react"; +import { Head, Link, router, usePage } from "@inertiajs/react"; import { PurchaseOrderItemsTable } from "@/Components/PurchaseOrder/PurchaseOrderItemsTable"; import type { PurchaseOrder, Supplier } from "@/types/purchase-order"; import type { Warehouse } from "@/types/requester"; @@ -21,8 +21,9 @@ import { getTodayDate, formatCurrency, } from "@/utils/purchase-order"; -import { STATUS_OPTIONS } from "@/constants/purchase-order"; +import { STATUS_CONFIG, MANUAL_STATUS_OPTIONS } from "@/constants/purchase-order"; import { toast } from "sonner"; +import { Can } from "@/Components/Permission/Can"; import { getCreateBreadcrumbs, getEditBreadcrumbs } from "@/utils/breadcrumb"; interface Props { @@ -36,6 +37,17 @@ export default function CreatePurchaseOrder({ suppliers, warehouses, }: Props) { + const { auth } = usePage().props; + const permissions = auth.user?.permissions || []; + const isSuperAdmin = auth.user?.roles?.some((r: any) => r.name === 'super-admin'); + + const canApprove = isSuperAdmin || permissions.includes('purchase_orders.approve'); + const canCreate = isSuperAdmin || permissions.includes('purchase_orders.create'); + const canEdit = isSuperAdmin || permissions.includes('purchase_orders.edit'); + + // 儲存權限判斷 + const canSave = order ? canEdit : canCreate; + const { supplierId, expectedDate, @@ -273,12 +285,26 @@ export default function CreatePurchaseOrder({ {order && (
- setStatus(v as any)} - options={STATUS_OPTIONS.map((opt) => ({ label: opt.label, value: opt.value }))} - placeholder="選擇狀態" - /> + + setStatus(v as any)} + options={MANUAL_STATUS_OPTIONS} + placeholder="選擇狀態" + /> + +
+ {!canApprove && ( + <> +
+ {STATUS_CONFIG[status as keyof typeof STATUS_CONFIG]?.label || status} +
+

+ * 您沒有權限在此修改狀態,請使用詳情頁面的動作按鈕進行操作。 +

+ + )} +
)}
@@ -454,9 +480,11 @@ export default function CreatePurchaseOrder({ diff --git a/resources/js/Pages/PurchaseOrder/Index.tsx b/resources/js/Pages/PurchaseOrder/Index.tsx index 104abcb..1059dca 100644 --- a/resources/js/Pages/PurchaseOrder/Index.tsx +++ b/resources/js/Pages/PurchaseOrder/Index.tsx @@ -23,7 +23,7 @@ import { SelectTrigger, SelectValue, } from "@/Components/ui/select"; -import { STATUS_OPTIONS } from "@/constants/purchase-order"; +import { MANUAL_STATUS_OPTIONS } from "@/constants/purchase-order"; interface Props { orders: { @@ -177,7 +177,7 @@ export default function PurchaseOrderIndex({ orders, filters, warehouses }: Prop 全部狀態 - {STATUS_OPTIONS.map((option) => ( + {MANUAL_STATUS_OPTIONS.map((option) => ( {option.label} diff --git a/resources/js/Pages/PurchaseOrder/Show.tsx b/resources/js/Pages/PurchaseOrder/Show.tsx index 4d1782c..1705f6e 100644 --- a/resources/js/Pages/PurchaseOrder/Show.tsx +++ b/resources/js/Pages/PurchaseOrder/Show.tsx @@ -2,10 +2,10 @@ * 查看採購單詳情頁面 */ -import { ArrowLeft, ShoppingCart } from "lucide-react"; +import { ArrowLeft, ShoppingCart, Send, CheckCircle, XCircle, RotateCcw } from "lucide-react"; import { Button } from "@/Components/ui/button"; import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; -import { Head, Link } from "@inertiajs/react"; +import { Head, Link, useForm, usePage, router } from "@inertiajs/react"; import { StatusProgressBar } from "@/Components/PurchaseOrder/StatusProgressBar"; import PurchaseOrderStatusBadge from "@/Components/PurchaseOrder/PurchaseOrderStatusBadge"; import CopyButton from "@/Components/shared/CopyButton"; @@ -13,6 +13,8 @@ import { PurchaseOrderItemsTable } from "@/Components/PurchaseOrder/PurchaseOrde import type { PurchaseOrder } from "@/types/purchase-order"; import { formatCurrency, formatDateTime } from "@/utils/format"; import { getShowBreadcrumbs } from "@/utils/breadcrumb"; +import { toast } from "sonner"; +import { PageProps } from "@/types/global"; interface Props { order: PurchaseOrder; @@ -44,11 +46,6 @@ export default function ViewPurchaseOrderPage({ order }: Props) {

單號:{order.poNumber}

- - -
@@ -171,9 +168,111 @@ export default function ViewPurchaseOrderPage({ order }: Props) { + + {/* 操作按鈕 (底部) */} +
+ +
); } + +function PurchaseOrderActions({ order }: { order: PurchaseOrder }) { + const { auth } = usePage().props; + const permissions = auth.user?.permissions || []; + + const { processing } = useForm({ + status: order.status, + }); + + const handleUpdateStatus = (newStatus: string, actionName: string) => { + const formData = { + vendor_id: order.supplierId, + warehouse_id: order.warehouse_id, + order_date: order.orderDate, + expected_delivery_date: order.expectedDate ? new Date(order.expectedDate).toISOString().split('T')[0] : null, + items: order.items.map((item: any) => ({ + productId: item.productId, + quantity: item.quantity, + unitId: item.unitId, + subtotal: item.subtotal, + })), + tax_amount: order.taxAmount, + status: newStatus, + remark: order.remark || "", + }; + + router.patch(route('purchase-orders.update', order.id), formData, { + onSuccess: () => toast.success(`採購單已${actionName === '取消' ? '作廢' : actionName}`), + onError: (errors: any) => { + console.error("Status Update Error:", errors); + toast.error(errors.error || "操作失敗"); + } + }); + }; + + // 權限判斷 (包含超級管理員檢查) + const isSuperAdmin = auth.user?.roles?.some((r: any) => r.name === 'super-admin'); + const canApprove = isSuperAdmin || permissions.includes('purchase_orders.approve'); + const canCancel = isSuperAdmin || permissions.includes('purchase_orders.cancel'); + const canEdit = isSuperAdmin || permissions.includes('purchase_orders.edit'); + const canView = isSuperAdmin || permissions.includes('purchase_orders.view'); + + // 送審權限:擁有檢視或編輯權限的人都可以送審 + const canSubmit = canEdit || canView; + + return ( +
+ {['draft', 'pending', 'approved'].includes(order.status) && canCancel && ( + + )} + + {order.status === 'pending' && canApprove && ( + + )} + +
+ + {order.status === 'draft' && canSubmit && ( + + )} + + {order.status === 'pending' && canApprove && ( + + )} +
+ ); +} diff --git a/resources/js/constants/purchase-order.ts b/resources/js/constants/purchase-order.ts index f37c2e1..f52f065 100644 --- a/resources/js/constants/purchase-order.ts +++ b/resources/js/constants/purchase-order.ts @@ -18,6 +18,14 @@ export const STATUS_CONFIG: Record< cancelled: { label: "已作廢", variant: "destructive" }, }; +// 供下單/編輯頁面使用的手動狀態選項 (排除系統自動狀態) +export const MANUAL_STATUS_OPTIONS = [ + { value: 'draft', label: '草稿' }, + { value: 'pending', label: '送審中' }, + { value: 'approved', label: '已核准' }, + { value: 'cancelled', label: '已作廢' }, +]; + export const STATUS_OPTIONS = Object.entries(STATUS_CONFIG).map(([value, config]) => ({ value, label: (config as any).label,