Files
awoooi/apps/web/src/components/approval/batch-mode-selector.tsx
OG T 246587a401 fix(web): Sprint F 前端打假行動 — 29處假數據全面清除 (首席架構師 98/100)
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>
2026-04-07 12:53:52 +08:00

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