P0: Neural Command 三個子組件移除所有 MOCK 常數,接上真實 API props - NeuralLiveCenter: 假歷史/假KPI/假雷達 → 從 stats/history/incidents 即時計算 - NeuralStats: MOCK_HISTORY/SCHEME_STATS/PLAYBOOK_RANKINGS → useMemo 聚合 - NeuralApprovalPanel: MOCK_PENDING → 真實 /api/v1/approvals 簽核操作 P1: 10+處假用戶身份 (demo-user/user-001/War Room User) → CURRENT_USER 常數統一 P2: 刪除 6 個 Demo 匯出 (GlobalPulseChartDemo/MOCK_APPROVAL/DEMO_DECISION_CHAIN) P3: /demo 頁面加 NEXT_PUBLIC_ENABLE_DEMO 環境變數保護 i18n: 新增 22 個翻譯鍵 (zh-TW + en) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
307 lines
9.8 KiB
TypeScript
307 lines
9.8 KiB
TypeScript
'use client'
|
|
|
|
/**
|
|
* BatchModeSelector - 批次處理模式選擇器
|
|
* =======================================
|
|
* Phase 11: #50 批次處理功能
|
|
*
|
|
* Features:
|
|
* - 全部接受: 批次核准所有低/中風險
|
|
* - 逐一審核: 依序審核
|
|
* - 僅顯示 CRITICAL: 過濾高風險
|
|
*
|
|
* 安全限制:
|
|
* - CRITICAL + DESTRUCTIVE 禁止批次核准
|
|
*
|
|
* i18n: 100% next-intl
|
|
*/
|
|
|
|
import { useState, useMemo, useCallback } from 'react'
|
|
import { useTranslations } from 'next-intl'
|
|
import { cn } from '@/lib/utils'
|
|
import { CURRENT_USER } from '@/lib/constants'
|
|
import {
|
|
useApprovalStore,
|
|
usePendingApprovals,
|
|
toFrontendApproval,
|
|
} from '@/stores/approval.store'
|
|
import { StatusOrb } from '@/components/ui/status-orb'
|
|
import {
|
|
CheckCircle2,
|
|
ListFilter,
|
|
AlertTriangle,
|
|
Loader2,
|
|
Shield,
|
|
} from 'lucide-react'
|
|
// ApprovalRequest type used implicitly via toFrontendApproval
|
|
|
|
// =============================================================================
|
|
// Types
|
|
// =============================================================================
|
|
|
|
export type BatchMode = 'sequential' | 'filter_critical' | 'bulk_approve'
|
|
|
|
interface BatchModeSelectorProps {
|
|
className?: string
|
|
/** 批次核准完成後的回調 */
|
|
onBulkComplete?: (succeeded: number, failed: number, skipped: number) => void
|
|
/** 簽核者資訊 */
|
|
signerId?: string
|
|
signerName?: string
|
|
}
|
|
|
|
interface BulkApproveResult {
|
|
approval_id: string
|
|
success: boolean
|
|
message: string
|
|
execution_triggered: boolean
|
|
}
|
|
|
|
interface BulkApproveResponse {
|
|
total: number
|
|
succeeded: number
|
|
failed: number
|
|
skipped: number
|
|
results: BulkApproveResult[]
|
|
}
|
|
|
|
// =============================================================================
|
|
// Component
|
|
// =============================================================================
|
|
|
|
export function BatchModeSelector({
|
|
className,
|
|
onBulkComplete,
|
|
signerId = CURRENT_USER.id,
|
|
signerName = CURRENT_USER.name,
|
|
}: BatchModeSelectorProps) {
|
|
const t = useTranslations('approval')
|
|
const tBatch = useTranslations('approval.batch')
|
|
|
|
const pendingApprovals = usePendingApprovals()
|
|
const { fetchPending } = useApprovalStore()
|
|
|
|
const [mode, setMode] = useState<BatchMode>('sequential')
|
|
const [isProcessing, setIsProcessing] = useState(false)
|
|
|
|
// 轉換為前端格式
|
|
const approvals = useMemo(() => {
|
|
return pendingApprovals.map((a) => toFrontendApproval(a))
|
|
}, [pendingApprovals])
|
|
|
|
// 統計各風險等級數量
|
|
const stats = useMemo(() => {
|
|
const result = {
|
|
total: approvals.length,
|
|
low: 0,
|
|
medium: 0,
|
|
high: 0,
|
|
critical: 0,
|
|
canBulkApprove: 0,
|
|
}
|
|
|
|
approvals.forEach((a) => {
|
|
switch (a.riskLevel) {
|
|
case 'low':
|
|
result.low++
|
|
result.canBulkApprove++
|
|
break
|
|
case 'medium':
|
|
result.medium++
|
|
result.canBulkApprove++
|
|
break
|
|
case 'high':
|
|
result.high++
|
|
// HIGH 風險允許批次,但 DESTRUCTIVE 不允許
|
|
if (a.blastRadius.dataImpact !== 'DESTRUCTIVE') {
|
|
result.canBulkApprove++
|
|
}
|
|
break
|
|
case 'critical':
|
|
result.critical++
|
|
// CRITICAL 禁止批次
|
|
break
|
|
}
|
|
})
|
|
|
|
return result
|
|
}, [approvals])
|
|
|
|
// 批次核准處理
|
|
const handleBulkApprove = useCallback(async () => {
|
|
if (stats.canBulkApprove === 0) return
|
|
|
|
setIsProcessing(true)
|
|
|
|
try {
|
|
// 取得可批次核准的 ID 列表
|
|
const bulkApproveIds = approvals
|
|
.filter((a) => {
|
|
// 排除 CRITICAL
|
|
if (a.riskLevel === 'critical') return false
|
|
// 排除 DESTRUCTIVE
|
|
if (a.blastRadius.dataImpact === 'DESTRUCTIVE') return false
|
|
return true
|
|
})
|
|
.map((a) => a.id)
|
|
|
|
const response = await fetch('/api/v1/approvals/bulk-approve', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
approval_ids: bulkApproveIds,
|
|
signer_id: signerId,
|
|
signer_name: signerName,
|
|
comment: 'Bulk approved via ConversationalView',
|
|
}),
|
|
})
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}`)
|
|
}
|
|
|
|
const result: BulkApproveResponse = await response.json()
|
|
|
|
// 刷新列表
|
|
await fetchPending()
|
|
|
|
// 回調通知
|
|
onBulkComplete?.(result.succeeded, result.failed, result.skipped)
|
|
|
|
} catch (error) {
|
|
console.error('Bulk approve failed:', error)
|
|
} finally {
|
|
setIsProcessing(false)
|
|
}
|
|
}, [approvals, stats.canBulkApprove, signerId, signerName, fetchPending, onBulkComplete])
|
|
|
|
return (
|
|
<div className={cn('p-4 bg-white rounded-xl border border-nothing-gray-200', className)}>
|
|
{/* 統計概覽 */}
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="font-heading text-sm font-bold text-nothing-gray-900">
|
|
{tBatch('title')}
|
|
</h3>
|
|
<div className="flex items-center gap-2 text-xs font-body">
|
|
<span className="text-nothing-gray-400">{stats.total} {t('pendingApprovals')}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 風險等級分佈 */}
|
|
<div className="grid grid-cols-4 gap-2 mb-4">
|
|
<div className="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-nothing-gray-50">
|
|
<StatusOrb status="healthy" size="sm" />
|
|
<span className="text-xs font-body text-nothing-gray-600">
|
|
LOW: {stats.low}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-claw-blue/5">
|
|
<StatusOrb status="healthy" size="sm" />
|
|
<span className="text-xs font-body text-claw-blue">
|
|
MED: {stats.medium}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-status-warning/5">
|
|
<StatusOrb status="warning" size="sm" />
|
|
<span className="text-xs font-body text-status-warning">
|
|
HIGH: {stats.high}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-status-critical/5">
|
|
<StatusOrb status="critical" size="sm" pulse />
|
|
<span className="text-xs font-body text-status-critical">
|
|
CRIT: {stats.critical}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 模式選擇按鈕 */}
|
|
<div className="space-y-2">
|
|
{/* 全部接受 (批次) */}
|
|
<button
|
|
onClick={handleBulkApprove}
|
|
disabled={stats.canBulkApprove === 0 || isProcessing}
|
|
className={cn(
|
|
'w-full flex items-center justify-between p-3 rounded-lg transition-all',
|
|
'border border-nothing-gray-200',
|
|
'hover:bg-status-healthy/5 hover:border-status-healthy/30',
|
|
'focus:outline-none focus:ring-2 focus:ring-status-healthy/50',
|
|
'disabled:opacity-50 disabled:cursor-not-allowed',
|
|
mode === 'bulk_approve' && 'bg-status-healthy/10 border-status-healthy/50'
|
|
)}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
{isProcessing ? (
|
|
<Loader2 className="w-4 h-4 text-status-healthy animate-spin" />
|
|
) : (
|
|
<CheckCircle2 className="w-4 h-4 text-status-healthy" />
|
|
)}
|
|
<span className="text-sm font-medium text-nothing-gray-900">
|
|
{tBatch('bulkApprove')}
|
|
</span>
|
|
</div>
|
|
<span className="text-xs font-body text-nothing-gray-400">
|
|
{stats.canBulkApprove} {tBatch('eligible')}
|
|
</span>
|
|
</button>
|
|
|
|
{/* 逐一審核 */}
|
|
<button
|
|
onClick={() => setMode('sequential')}
|
|
className={cn(
|
|
'w-full flex items-center justify-between p-3 rounded-lg transition-all',
|
|
'border border-nothing-gray-200',
|
|
'hover:bg-nothing-gray-50',
|
|
'focus:outline-none focus:ring-2 focus:ring-claw-blue/50',
|
|
mode === 'sequential' && 'bg-claw-blue/5 border-claw-blue/30'
|
|
)}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<ListFilter className="w-4 h-4 text-claw-blue" />
|
|
<span className="text-sm font-medium text-nothing-gray-900">
|
|
{tBatch('sequential')}
|
|
</span>
|
|
</div>
|
|
<span className="text-xs font-body text-nothing-gray-400">
|
|
{stats.total} {tBatch('items')}
|
|
</span>
|
|
</button>
|
|
|
|
{/* 僅顯示 CRITICAL */}
|
|
<button
|
|
onClick={() => setMode('filter_critical')}
|
|
disabled={stats.critical === 0}
|
|
className={cn(
|
|
'w-full flex items-center justify-between p-3 rounded-lg transition-all',
|
|
'border border-nothing-gray-200',
|
|
'hover:bg-status-critical/5 hover:border-status-critical/30',
|
|
'focus:outline-none focus:ring-2 focus:ring-status-critical/50',
|
|
'disabled:opacity-50 disabled:cursor-not-allowed',
|
|
mode === 'filter_critical' && 'bg-status-critical/10 border-status-critical/50'
|
|
)}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<AlertTriangle className="w-4 h-4 text-status-critical" />
|
|
<span className="text-sm font-medium text-nothing-gray-900">
|
|
{tBatch('criticalOnly')}
|
|
</span>
|
|
</div>
|
|
<span className="text-xs font-body text-status-critical">
|
|
{stats.critical} {tBatch('items')}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* 安全提示 */}
|
|
<div className="mt-4 flex items-start gap-2 p-2 rounded-lg bg-status-warning/5 border border-status-warning/20">
|
|
<Shield className="w-4 h-4 text-status-warning flex-shrink-0 mt-0.5" />
|
|
<p className="text-xs text-status-warning">
|
|
{tBatch('securityNote')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|