feat(procurement): 統一採購單按鈕樣式與術語更名為「作廢」,並加強權限控管
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 1m28s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped

This commit is contained in:
2026-02-06 15:32:12 +08:00
parent 70f1709bd0
commit 6bfdd92347
11 changed files with 318 additions and 73 deletions

View File

@@ -46,28 +46,32 @@ export function PurchaseOrderActions({
</Button>
</Link>
<Can permission="purchase_orders.edit">
<Link href={`/purchase-orders/${order.id}/edit`}>
{order.status === 'draft' && (
<Link href={`/purchase-orders/${order.id}/edit`}>
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
title="編輯"
>
<Pencil className="h-4 w-4" />
</Button>
</Link>
)}
</Can>
<Can permission="purchase_orders.delete">
{order.status === 'draft' && (
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
title="編輯"
className="button-outlined-error"
title="刪除"
onClick={() => setShowDeleteDialog(true)}
disabled={processing}
>
<Pencil className="h-4 w-4" />
<Trash2 className="h-4 w-4" />
</Button>
</Link>
</Can>
<Can permission="purchase_orders.delete">
<Button
variant="outline"
size="sm"
className="button-outlined-error"
title="刪除"
onClick={() => setShowDeleteDialog(true)}
disabled={processing}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>

View File

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

View File

@@ -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 (
<AccordionItem
key={group.key}
@@ -210,18 +217,11 @@ export default function PermissionSelector({ groupedPermissions, selectedPermiss
className="bg-white border rounded-lg px-2 data-[state=open]:bg-gray-50/50 last:border-b"
>
<div className="flex items-center w-full">
{/* Group Selection Checkbox - Moved outside trigger to avoid bubbling issues, positioned left */}
<div className="flex items-center pl-2 pr-1">
<Checkbox
id={`group-select-${group.key}`}
checked={isAllSelected}
onCheckedChange={() => {
// 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"
/>
</div>
@@ -241,30 +241,47 @@ export default function PermissionSelector({ groupedPermissions, selectedPermiss
</AccordionTrigger>
</div>
<AccordionContent className="px-2 pb-4">
<div className="pl-10 space-y-3 pt-1">
{/* Permissions Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-y-3 gap-x-4">
{group.permissions.map((permission) => (
<div key={permission.id} className="flex items-start space-x-3">
<Checkbox
id={permission.name}
checked={selectedPermissions.includes(permission.name)}
onCheckedChange={() => togglePermission(permission.name)}
/>
<div className="grid gap-1.5 leading-none">
<label
htmlFor={permission.name}
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer text-gray-700 hover:text-primary-main transition-colors"
>
{translateAction(permission.name)}
</label>
<p className="text-[10px] text-gray-400 font-mono">
{permission.name}
</p>
</div>
<div className="pl-10 space-y-6 pt-1">
{/* 基本操作 */}
{normalPermissions.length > 0 && (
<div className="space-y-3">
<div className="text-xs font-semibold text-gray-400 uppercase tracking-wider">
</div>
))}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-y-3 gap-x-4">
{normalPermissions.map((permission) => (
<PermissionItem
key={permission.id}
permission={permission}
selectedPermissions={selectedPermissions}
onToggle={togglePermission}
translate={translateAction}
/>
))}
</div>
</div>
)}
{/* 狀態操作/進階權限 */}
{specialPermissions.length > 0 && (
<div className="space-y-3 pt-2 border-t border-gray-100 italic">
<div className="text-xs font-semibold text-amber-600/70 uppercase tracking-wider flex items-center gap-1">
<span className="w-1 h-3 bg-amber-500 rounded-full" />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-y-3 gap-x-4">
{specialPermissions.map((permission) => (
<PermissionItem
key={permission.id}
permission={permission}
selectedPermissions={selectedPermissions}
onToggle={togglePermission}
translate={translateAction}
/>
))}
</div>
</div>
)}
</div>
</AccordionContent>
</AccordionItem>
@@ -275,3 +292,26 @@ export default function PermissionSelector({ groupedPermissions, selectedPermiss
</div>
);
}
function PermissionItem({ permission, selectedPermissions, onToggle, translate }: any) {
return (
<div className="flex items-start space-x-3">
<Checkbox
id={permission.name}
checked={selectedPermissions.includes(permission.name)}
onCheckedChange={() => onToggle(permission.name)}
/>
<div className="grid gap-1.5 leading-none">
<label
htmlFor={permission.name}
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer text-gray-700 hover:text-primary-main transition-colors"
>
{translate(permission.name)}
</label>
<p className="text-[10px] text-gray-400 font-mono">
{permission.name}
</p>
</div>
</div>
);
}

View File

@@ -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<any>().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 && (
<div className="space-y-3">
<label className="text-sm font-bold text-gray-700"></label>
<SearchableSelect
value={status}
onValueChange={(v) => setStatus(v as any)}
options={STATUS_OPTIONS.map((opt) => ({ label: opt.label, value: opt.value }))}
placeholder="選擇狀態"
/>
<Can permission="purchase_orders.approve">
<SearchableSelect
value={status}
onValueChange={(v) => setStatus(v as any)}
options={MANUAL_STATUS_OPTIONS}
placeholder="選擇狀態"
/>
</Can>
<div className="!mt-1">
{!canApprove && (
<>
<div className="px-3 py-2 bg-gray-50 border rounded-md text-sm text-gray-600">
{STATUS_CONFIG[status as keyof typeof STATUS_CONFIG]?.label || status}
</div>
<p className="text-[10px] text-gray-400 mt-1 italic">
* 使
</p>
</>
)}
</div>
</div>
)}
</div>
@@ -454,9 +480,11 @@ export default function CreatePurchaseOrder({
</Button>
</Link>
<Button
size="lg"
className="bg-primary hover:bg-primary/90 text-white px-12 h-14 rounded-xl shadow-lg shadow-primary/20 text-lg font-bold transition-all hover:scale-[1.02] active:scale-[0.98]"
size="xl"
className="bg-primary hover:bg-primary/90 text-white shadow-primary/20"
onClick={handleSave}
disabled={!canSave}
title={!canSave ? "您沒有執行此動作的權限" : ""}
>
{order ? "更新採購單" : "確認發布採購單"}
</Button>

View File

@@ -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
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{STATUS_OPTIONS.map((option) => (
{MANUAL_STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>

View File

@@ -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) {
<p className="text-gray-500 mt-1">{order.poNumber}</p>
</div>
<div className="flex items-center gap-3">
<Link href={`/purchase-orders/${order.id}/edit`}>
<Button variant="outline" className="button-outlined-primary">
</Button>
</Link>
<PurchaseOrderStatusBadge status={order.status} />
</div>
</div>
@@ -171,9 +168,111 @@ export default function ViewPurchaseOrderPage({ order }: Props) {
</div>
</div>
</div>
{/* 操作按鈕 (底部) */}
<div className="flex justify-end pt-4">
<PurchaseOrderActions order={order} />
</div>
</div>
</div>
</div>
</AuthenticatedLayout>
);
}
function PurchaseOrderActions({ order }: { order: PurchaseOrder }) {
const { auth } = usePage<PageProps>().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 (
<div className="flex items-center gap-4">
{['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"
>
<XCircle className="h-5 w-5" />
</Button>
)}
{order.status === 'pending' && canApprove && (
<Button
onClick={() => handleUpdateStatus('draft', '退回')}
disabled={processing}
variant="outline"
size="xl"
className="button-outlined-warning shadow-amber-200/20"
>
<RotateCcw className="h-5 w-5" /> 退
</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" />
</Button>
)}
{order.status === 'pending' && canApprove && (
<Button
onClick={() => handleUpdateStatus('approved', '核准')}
disabled={processing}
size="xl"
className="button-filled-primary shadow-primary/20"
>
<CheckCircle className="h-5 w-5" />
</Button>
)}
</div>
);
}

View File

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