Files
ai-showcase-platform/components/admin/scoring-management.tsx
2025-09-18 18:34:31 +08:00

1244 lines
51 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
import { useState, useEffect } from "react"
import { useCompetition } from "@/contexts/competition-context"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { Progress } from "@/components/ui/progress"
import { ScoringLinkDialog } from "./scoring-link-dialog"
import { JudgeListDialog } from "./judge-list-dialog"
import {
Trophy, Plus, Edit, CheckCircle, AlertTriangle, ClipboardList, User, Users, Search, Loader2, BarChart3, ChevronLeft, ChevronRight, Link
} from "lucide-react"
interface ScoringRecord {
id: string
judgeId: string
judgeName: string
participantId: string
participantName: string
participantType: "individual" | "team"
scores: Record<string, number>
totalScore: number
comments: string
submittedAt: string
status: "completed" | "pending" | "draft"
}
const mockIndividualApps: any[] = []
const initialTeams: any[] = []
export function ScoringManagement() {
const { competitions, judges, judgeScores, submitJudgeScore } = useCompetition()
// 狀態定義必須在使用之前
const [selectedCompetition, setSelectedCompetition] = useState<any>(null)
const [scoringRecords, setScoringRecords] = useState<ScoringRecord[]>([])
const [showManualScoring, setShowManualScoring] = useState(false)
const [showEditScoring, setShowEditScoring] = useState(false)
const [selectedRecord, setSelectedRecord] = useState<ScoringRecord | null>(null)
const [manualScoring, setManualScoring] = useState({
judgeId: "", participantId: "", scores: {} as Record<string, number>, comments: ""
})
const [statusFilter, setStatusFilter] = useState<"all" | "completed" | "pending">("all")
const [searchQuery, setSearchQuery] = useState("")
const [isLoading, setIsLoading] = useState(false)
const [success, setSuccess] = useState("")
const [error, setError] = useState("")
const [showScoringLink, setShowScoringLink] = useState(false)
const [showJudgeList, setShowJudgeList] = useState(false)
// 新增狀態:從後端獲取的評審和參賽者數據
const [competitionJudges, setCompetitionJudges] = useState<any[]>([])
const [competitionParticipants, setCompetitionParticipants] = useState<any[]>([])
const [isLoadingData, setIsLoadingData] = useState(false)
const [isInitialLoading, setIsInitialLoading] = useState(true)
// 評分完成度匯總狀態
const [scoringSummary, setScoringSummary] = useState<any>(null)
const [isLoadingSummary, setIsLoadingSummary] = useState(false)
// APP詳細評分狀態
const [selectedApp, setSelectedApp] = useState<any>(null)
const [appScoringDetails, setAppScoringDetails] = useState<any>(null)
const [isLoadingAppDetails, setIsLoadingAppDetails] = useState(false)
const [showAppDetails, setShowAppDetails] = useState(false)
// 競賽規則狀態
const [competitionRules, setCompetitionRules] = useState<any[]>([])
const [isLoadingRules, setIsLoadingRules] = useState(false)
// 調試:檢查競賽數據
console.log('📋 競賽數據:', competitions)
console.log('👨‍⚖️ 評審數據:', judges)
console.log('📊 競賽數量:', competitions?.length || 0)
// 檢查初始載入狀態
useEffect(() => {
if (competitions && competitions.length > 0) {
console.log('✅ 競賽數據已載入,關閉初始載入狀態')
setIsInitialLoading(false)
// 自動選擇第一個競賽(如果沒有選中的話)
if (!selectedCompetition) {
console.log('🎯 自動選擇第一個競賽:', competitions[0].name)
setSelectedCompetition(competitions[0])
}
}
}, [competitions, selectedCompetition])
useEffect(() => {
if (selectedCompetition) {
loadScoringData()
loadCompetitionData()
}
}, [selectedCompetition])
const loadScoringData = async () => {
if (!selectedCompetition) return
setIsLoading(true)
try {
// 從後端API獲取評分數據
const response = await fetch(`/api/admin/scoring?competitionId=${selectedCompetition.id}`)
const data = await response.json()
if (data.success) {
// 轉換API數據格式為前端組件格式
const records: ScoringRecord[] = data.data.scores.map((score: any) => {
// 解析 score_details 字符串為動態評分對象
let dynamicScores: Record<string, number> = {};
if (score.score_details) {
// 處理兩種格式aa:4,bb:7 或 aa:4:50.00|bb:7:50.00
const details = score.score_details.includes('|')
? score.score_details.split('|')
: score.score_details.split(',');
details.forEach((detail: string) => {
const parts = detail.split(':');
if (parts.length >= 2) {
const ruleName = parts[0];
const scoreValue = parts[1];
if (ruleName && scoreValue) {
dynamicScores[ruleName] = parseInt(scoreValue);
}
}
});
}
// 如果沒有動態評分,使用預設字段
if (Object.keys(dynamicScores).length === 0) {
dynamicScores = {
innovation: score.innovation_score || 0,
technical: score.technical_score || 0,
usability: score.usability_score || 0,
presentation: score.presentation_score || 0,
impact: score.impact_score || 0
};
}
return {
id: score.id,
judgeId: score.judge_id,
judgeName: score.judge_name,
participantId: score.app_id,
participantName: score.app_name,
participantType: score.participant_type === 'app' ? 'individual' : 'team',
scores: dynamicScores,
totalScore: score.total_score,
comments: score.comments || '',
submittedAt: score.submitted_at,
status: score.total_score > 0 ? 'completed' : 'pending'
};
})
setScoringRecords(records)
} else {
setError('載入評分數據失敗')
}
} catch (err) {
console.error('載入評分數據失敗:', err)
setError('載入評分數據失敗')
} finally {
setIsLoading(false)
}
}
const calculateTotalScore = (scores: Record<string, number>, rules: any[]): number => {
if (rules.length === 0) {
const values = Object.values(scores)
return values.length > 0 ? values.reduce((a, b) => a + b, 0) : 0
}
let totalScore = 0
let totalWeight = 0
rules.forEach((rule: any) => {
const score = scores[rule.name] || 0
const weight = parseFloat(rule.weight) || 1
totalScore += score * weight
totalWeight += weight
})
return totalWeight > 0 ? totalScore / totalWeight : 0
}
const getFilteredRecords = () => {
let filtered = [...scoringRecords]
if (statusFilter !== "all") {
filtered = filtered.filter(record => record.status === statusFilter)
}
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase().trim()
filtered = filtered.filter(record =>
record.judgeName.toLowerCase().includes(query) ||
record.participantName.toLowerCase().includes(query)
)
}
return filtered
}
const handleManualScoring = () => {
// 根據競賽規則初始化評分項目
const initialScores: Record<string, number> = {}
if (competitionRules && competitionRules.length > 0) {
competitionRules.forEach((rule: any) => {
initialScores[rule.name] = 0
})
} else {
// 預設評分項目
initialScores.innovation = 0
initialScores.technical = 0
initialScores.usability = 0
initialScores.presentation = 0
initialScores.impact = 0
}
setManualScoring({
judgeId: "",
participantId: "",
scores: initialScores,
comments: ""
})
setShowManualScoring(true)
}
const handleEditScoring = (record: ScoringRecord) => {
setSelectedRecord(record)
// 根據競賽規則初始化評分項目
const initialScores: Record<string, number> = {}
// 直接使用記錄中的評分數據,不依賴競賽規則
Object.keys(record.scores).forEach(key => {
initialScores[key] = record.scores[key] || 0;
});
// 如果記錄中沒有評分數據,則使用競賽規則
if (Object.keys(initialScores).length === 0) {
if (competitionRules && competitionRules.length > 0) {
competitionRules.forEach((rule: any) => {
initialScores[rule.name] = 0;
});
} else {
// 預設評分項目
initialScores.innovation = 0;
initialScores.technical = 0;
initialScores.usability = 0;
initialScores.presentation = 0;
initialScores.impact = 0;
}
}
setManualScoring({
judgeId: record.judgeId,
participantId: record.participantId,
scores: initialScores,
comments: record.comments || '',
})
setShowEditScoring(true)
}
const handleSubmitScore = async () => {
setError("")
if (!manualScoring.judgeId || !manualScoring.participantId) {
setError("請選擇評審和參賽項目")
return
}
// 檢查所有評分項目是否都已評分
const defaultRules = [
{ name: "創新性" }, { name: "技術性" }, { name: "實用性" },
{ name: "展示效果" }, { name: "影響力" }
]
const rules = competitionRules.length > 0 ? competitionRules : defaultRules
const hasAllScores = rules.every((rule: any) =>
manualScoring.scores[rule.name] && manualScoring.scores[rule.name] > 0
)
if (!hasAllScores) {
setError("請為所有評分項目打分")
return
}
if (!manualScoring.comments.trim()) {
setError("請填寫評審意見")
return
}
setIsLoading(true)
try {
// 轉換評分格式以符合API要求 - 使用動態規則
const apiScores: Record<string, number> = {}
rules.forEach((rule: any) => {
apiScores[rule.name] = manualScoring.scores[rule.name] || 0
})
// 根據參賽者類型確定participantType
const selectedParticipant = competitionParticipants.find(p => p.id === manualScoring.participantId)
console.log('🔍 選中的參賽者:', selectedParticipant);
// 由於所有參賽者都是團隊的 app所以 participantType 應該是 'app'
const participantType = 'app'
const requestData = {
judgeId: manualScoring.judgeId,
participantId: manualScoring.participantId,
participantType: participantType,
scores: apiScores,
comments: manualScoring.comments.trim(),
competitionId: selectedCompetition?.id,
isEdit: showEditScoring, // 標識是否為編輯模式
recordId: selectedRecord?.id // 編輯時的記錄ID
}
console.log('🔍 提交評分請求數據:', requestData);
const response = await fetch('/api/admin/scoring', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestData)
})
const data = await response.json()
console.log('🔍 API 回應:', data);
if (data.success) {
setSuccess(showEditScoring ? "評分更新成功!" : "評分提交成功!")
await loadScoringData() // 重新載入數據
setShowManualScoring(false)
setShowEditScoring(false)
setSelectedRecord(null)
} else {
setError(data.message || "評分提交失敗")
}
} catch (err) {
console.error('評分提交失敗:', err)
setError("評分提交失敗,請重試")
} finally {
setIsLoading(false)
setTimeout(() => setSuccess(""), 3000)
}
}
const getStatusBadge = (status: string) => {
switch (status) {
case "completed": return <Badge className="bg-green-100 text-green-800"></Badge>
case "pending": return <Badge className="bg-orange-100 text-orange-800"></Badge>
default: return <Badge variant="outline">{status}</Badge>
}
}
const [scoringStats, setScoringStats] = useState({
totalScores: 0,
completedScores: 0,
pendingScores: 0,
completionRate: 0,
totalParticipants: 0
})
const loadScoringStats = async () => {
if (!selectedCompetition) return
try {
const response = await fetch(`/api/admin/scoring/stats?competitionId=${selectedCompetition.id}`)
const data = await response.json()
if (data.success) {
setScoringStats(data.data)
}
} catch (err) {
console.error('載入評分統計失敗:', err)
}
}
// 當選擇競賽時載入統計數據
useEffect(() => {
if (selectedCompetition) {
loadScoringStats()
loadCompetitionData()
loadScoringSummary()
}
}, [selectedCompetition])
// 載入競賽相關數據(評審和參賽者)
const loadCompetitionData = async () => {
if (!selectedCompetition) return
console.log('🔍 開始載入競賽數據競賽ID:', selectedCompetition.id)
setIsLoadingData(true)
setError("")
try {
// 載入競賽評審
console.log('📋 載入競賽評審...')
const judgesResponse = await fetch(`/api/competitions/${selectedCompetition.id}/judges`)
const judgesData = await judgesResponse.json()
console.log('評審API回應:', judgesData)
if (judgesData.success && judgesData.data && judgesData.data.judges) {
setCompetitionJudges(judgesData.data.judges)
console.log('✅ 評審數據載入成功:', judgesData.data.judges.length, '個評審')
} else {
console.error('❌ 評審數據載入失敗:', judgesData.message || 'API回應格式錯誤')
setCompetitionJudges([])
}
// 載入競賽參賽者(應用和團隊)
console.log('📱 載入競賽參賽者...')
const [appsResponse, teamsResponse] = await Promise.all([
fetch(`/api/competitions/${selectedCompetition.id}/apps`),
fetch(`/api/competitions/${selectedCompetition.id}/teams`)
])
const appsData = await appsResponse.json()
const teamsData = await teamsResponse.json()
console.log('應用API回應:', appsData)
console.log('團隊API回應:', teamsData)
const participants = []
if (appsData.success && appsData.data && appsData.data.apps) {
participants.push(...appsData.data.apps.map((app: any) => ({
id: app.id,
name: app.name,
type: 'individual',
creator: app.creator
})))
console.log('✅ 應用數據載入成功:', appsData.data.apps.length, '個應用')
} else {
console.error('❌ 應用數據載入失敗:', appsData.message || 'API回應格式錯誤')
}
if (teamsData.success && teamsData.data && teamsData.data.teams) {
// 將每個團隊的每個 app 作為獨立的參賽項目
teamsData.data.teams.forEach((team: any) => {
console.log('🔍 處理團隊:', team);
if (team.apps && team.apps.length > 0) {
team.apps.forEach((app: any) => {
console.log('🔍 處理團隊 app:', app);
participants.push({
id: app.id, // 使用 app 的 ID
name: app.name, // app 名稱
type: 'team',
teamName: team.name || '未知團隊', // 團隊名稱
displayName: `${team.name || '未知團隊'} - ${app.name}`, // 顯示名稱:團隊名稱 - app名稱
creator: team.members && team.members.find((m: any) => m.role === '隊長')?.name || '未知隊長',
teamId: team.id // 保存團隊 ID
})
})
} else {
// 如果團隊沒有 app仍然顯示團隊本身
participants.push({
id: team.id,
name: team.name,
type: 'team',
teamName: team.name || '未知團隊',
creator: team.members && team.members.find((m: any) => m.role === '隊長')?.name || '未知隊長',
teamId: team.id
})
}
})
console.log('✅ 團隊數據載入成功:', teamsData.data.teams.length, '個團隊')
} else {
console.error('❌ 團隊數據載入失敗:', teamsData.message || 'API回應格式錯誤')
}
setCompetitionParticipants(participants)
console.log('✅ 參賽者數據載入完成:', participants.length, '個參賽者')
console.log('🔍 參賽者詳細數據:', participants)
// 載入競賽規則
console.log('📋 載入競賽規則...')
const rulesResponse = await fetch(`/api/competitions/${selectedCompetition.id}/rules`)
const rulesData = await rulesResponse.json()
if (rulesData.success && rulesData.data) {
setCompetitionRules(rulesData.data)
console.log('✅ 競賽規則載入成功:', rulesData.data.length, '個規則')
} else {
console.error('❌ 競賽規則載入失敗:', rulesData.message || 'API回應格式錯誤')
setCompetitionRules([])
}
// 如果沒有載入到任何數據,顯示警告
if (participants.length === 0) {
console.warn('⚠️ 沒有載入到任何參賽者數據')
setError('該競賽暫無參賽者數據,請檢查競賽設置')
}
} catch (err) {
console.error('❌ 載入競賽數據失敗:', err)
setError('載入競賽數據失敗: ' + (err instanceof Error ? err.message : '未知錯誤'))
// 設置空數組以避免undefined錯誤
setCompetitionJudges([])
setCompetitionParticipants([])
} finally {
setIsLoadingData(false)
}
}
// 載入評分完成度匯總
const loadScoringSummary = async () => {
if (!selectedCompetition) return
setIsLoadingSummary(true)
try {
const response = await fetch(`/api/admin/scoring/summary?competitionId=${selectedCompetition.id}`)
const data = await response.json()
if (data.success) {
setScoringSummary(data.data)
console.log('✅ 評分完成度匯總載入成功:', data.data)
} else {
console.log('❌ 評分完成度匯總載入失敗:', data)
setScoringSummary(null)
}
} catch (error) {
console.error('載入評分完成度匯總失敗:', error)
setScoringSummary(null)
} finally {
setIsLoadingSummary(false)
}
}
// 載入APP詳細評分信息
const loadAppScoringDetails = async (app: any) => {
if (!selectedCompetition?.id) return
setSelectedApp(app)
setIsLoadingAppDetails(true)
setShowAppDetails(true)
try {
// 獲取該APP的所有評分記錄
const response = await fetch(`/api/admin/scoring?competitionId=${selectedCompetition.id}&appId=${app.id}`)
const data = await response.json()
if (data.success) {
setAppScoringDetails({
app: app,
scores: data.data || [],
judges: competitionJudges,
totalJudges: competitionJudges.length,
scoredJudges: data.data?.length || 0
})
} else {
console.error('載入APP評分詳情失敗:', data.message)
setError(data.message || '載入APP評分詳情失敗')
}
} catch (error) {
console.error('載入APP評分詳情失敗:', error)
setError('載入APP評分詳情失敗')
} finally {
setIsLoadingAppDetails(false)
}
}
// 關閉APP詳細信息
const closeAppDetails = () => {
setShowAppDetails(false)
setSelectedApp(null)
setAppScoringDetails(null)
}
const progress = {
total: scoringStats.totalScores,
completed: scoringStats.completedScores,
pending: scoringStats.pendingScores,
percentage: scoringStats.completionRate
}
// 顯示初始載入狀態
if (isInitialLoading) {
return (
<div className="space-y-6">
<Card>
<CardContent className="flex items-center justify-center py-12">
<div className="text-center space-y-4">
<Loader2 className="w-8 h-8 animate-spin mx-auto" />
<p className="text-lg font-medium">...</p>
<p className="text-sm text-gray-500"></p>
</div>
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-6">
{success && (
<Alert className="border-green-200 bg-green-50">
<CheckCircle className="h-4 w-4 text-green-600" />
<AlertDescription className="text-green-800">{success}</AlertDescription>
</Alert>
)}
{error && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Trophy className="w-5 h-5" />
<span></span>
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<Select value={selectedCompetition?.id || ""} onValueChange={(value) => {
console.log('🎯 選擇競賽:', value)
const competition = competitions.find(c => c.id === value)
console.log('🏆 找到競賽:', competition)
setSelectedCompetition(competition)
}}>
<SelectTrigger className="w-full">
<SelectValue placeholder="選擇競賽" />
</SelectTrigger>
<SelectContent>
{competitions.map((competition) => (
<SelectItem key={competition.id} value={competition.id}>
<div className="flex flex-col">
<span className="font-medium">{competition.name}</span>
<span className="text-xs text-gray-500">
{competition.year}{competition.month} {competition.type === "individual" ? "個人賽" : competition.type === "team" ? "團體賽" : "混合賽"}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</CardContent>
</Card>
{selectedCompetition && (
<>
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<BarChart3 className="w-5 h-5" />
<span>{selectedCompetition.name} - </span>
<Badge variant="outline">
{selectedCompetition.type === "individual" ? "個人賽" : selectedCompetition.type === "team" ? "團體賽" : "混合賽"}
</Badge>
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<CardContent className="p-4">
<div className="text-center">
<p className="text-2xl font-bold text-blue-600">
{scoringSummary ? scoringSummary.overallStats.totalJudges : progress.completed}
</p>
<p className="text-sm text-gray-600"></p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="text-center">
<p className="text-2xl font-bold text-green-600">
{scoringSummary ? scoringSummary.overallStats.totalApps : progress.pending}
</p>
<p className="text-sm text-gray-600">APP數</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="text-center">
<p className="text-2xl font-bold text-orange-600">
{scoringSummary ? scoringSummary.overallStats.completedScores : progress.percentage}
</p>
<p className="text-sm text-gray-600"></p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="text-center">
<p className="text-2xl font-bold text-purple-600">
{scoringSummary ? `${scoringSummary.overallStats.overallCompletionRate}%` : progress.total}
</p>
<p className="text-sm text-gray-600"></p>
</div>
</CardContent>
</Card>
</div>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span></span>
<span>
{scoringSummary ?
`${scoringSummary.overallStats.completedScores} / ${scoringSummary.overallStats.totalPossibleScores}` :
`${progress.completed} / ${progress.total}`
}
</span>
</div>
<Progress
value={scoringSummary ? scoringSummary.overallStats.overallCompletionRate : progress.percentage}
className="h-2"
/>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex justify-between items-center">
<div>
<CardTitle className="flex items-center space-x-2">
<BarChart3 className="w-5 h-5" />
<span></span>
</CardTitle>
<CardDescription>APP評分詳情和完成度狀況</CardDescription>
</div>
<div className="flex space-x-2">
<Button
onClick={() => setShowScoringLink(true)}
variant="outline"
className="border-blue-200 text-blue-600 hover:bg-blue-50"
>
<Link className="w-4 h-4 mr-2" />
</Button>
<Button
onClick={() => setShowJudgeList(true)}
variant="outline"
>
<Users className="w-4 h-4 mr-2" />
</Button>
<Button onClick={handleManualScoring} variant="outline">
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-4 mb-6">
<div className="flex flex-wrap gap-4 items-center">
<div className="flex items-center space-x-2">
<span className="text-sm font-medium"></span>
<Select value={statusFilter} onValueChange={(value: any) => setStatusFilter(value)}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="completed"></SelectItem>
<SelectItem value="pending"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder="搜尋評審或參賽者..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
</div>
</div>
</div>
<div className="space-y-6">
{(() => {
// 按評審分組 (使用 judgeId 避免重名問題)
const groupedByJudge = getFilteredRecords().reduce((groups, record) => {
const judgeId = record.judgeId
if (!groups[judgeId]) {
groups[judgeId] = []
}
groups[judgeId].push(record)
return groups
}, {} as Record<string, ScoringRecord[]>)
return Object.entries(groupedByJudge).map(([judgeId, records]) => {
const completedCount = records.filter(r => r.status === "completed").length
const totalCount = records.length
const progressPercentage = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0
// 從第一條記錄獲取評審名稱 (因為同一個 judgeId 的記錄都有相同的 judgeName)
const judgeName = records[0]?.judgeName || '未知評審'
return (
<Card key={judgeId} className="border-l-4 border-l-blue-500">
<CardHeader className="pb-3">
<div className="flex justify-between items-center">
<div className="flex items-center space-x-3">
<Avatar className="w-10 h-10">
<AvatarFallback className="text-sm font-semibold">
{judgeName.charAt(0)}
</AvatarFallback>
</Avatar>
<div>
<h3 className="text-lg font-semibold">{judgeName}</h3>
<p className="text-sm text-gray-600">
{completedCount} / {totalCount}
</p>
</div>
</div>
<div className="flex items-center space-x-4">
<div className="text-right">
<div className="text-2xl font-bold text-blue-600">{progressPercentage}%</div>
<div className="text-xs text-gray-500"></div>
</div>
<div className="w-16 h-16 relative">
<svg className="w-16 h-16 transform -rotate-90" viewBox="0 0 36 36">
<path
className="text-gray-200"
stroke="currentColor"
strokeWidth="2"
fill="none"
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
/>
<path
className="text-blue-600"
stroke="currentColor"
strokeWidth="2"
fill="none"
strokeDasharray={`${progressPercentage}, 100`}
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-xs font-semibold">{progressPercentage}%</span>
</div>
</div>
</div>
</div>
</CardHeader>
<CardContent>
<div className="relative px-6">
{/* 左滑動箭頭 */}
{records.length > 4 && (
<button
onClick={() => {
const container = document.getElementById(`scroll-${judgeId}`)
if (container) {
container.scrollLeft -= 280 // 滑動一個卡片的寬度
}
}}
className="absolute -left-6 top-1/2 transform -translate-y-1/2 z-10 bg-white border border-gray-300 rounded-full p-3 shadow-lg hover:shadow-xl transition-all duration-200 hover:bg-gray-50"
>
<ChevronLeft className="w-4 h-4 text-gray-600" />
</button>
)}
{/* 右滑動箭頭 */}
{records.length > 4 && (
<button
onClick={() => {
const container = document.getElementById(`scroll-${judgeId}`)
if (container) {
container.scrollLeft += 280 // 滑動一個卡片的寬度
}
}}
className="absolute -right-6 top-1/2 transform -translate-y-1/2 z-10 bg-white border border-gray-300 rounded-full p-3 shadow-lg hover:shadow-xl transition-all duration-200 hover:bg-gray-50"
>
<ChevronRight className="w-4 h-4 text-gray-600" />
</button>
)}
<div
id={`scroll-${judgeId}`}
className="flex space-x-4 overflow-x-auto scrollbar-hide pb-2 scroll-smooth"
style={{
scrollbarWidth: 'none',
msOverflowStyle: 'none',
maxWidth: 'calc(4 * 256px + 3 * 16px)' // 4個卡片 + 3個間距
}}
>
{records.map((record) => (
<div
key={record.id}
className="flex-shrink-0 w-64 bg-white border rounded-lg p-4 shadow-sm hover:shadow-md transition-all duration-200"
>
<div className="space-y-3">
{/* 項目標題和類型 */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
{record.participantType === "individual" ? (
<User className="w-4 h-4 text-blue-600" />
) : (
<Users className="w-4 h-4 text-green-600" />
)}
<span className="font-medium text-sm truncate">{record.participantName}</span>
</div>
<Badge variant="outline" className="text-xs">
{record.participantType === "individual" ? "個人" : "團隊"}
</Badge>
</div>
{/* 評分狀態 */}
<div className="flex items-center justify-between">
<div className="text-center">
<div className="flex items-center space-x-1">
<span className="font-bold text-lg">{Math.round(record.totalScore)}</span>
<span className="text-gray-500 text-sm">/ 100</span>
</div>
</div>
<div className="flex flex-col items-end space-y-1">
{getStatusBadge(record.status)}
{record.submittedAt && (
<span className="text-xs text-gray-500">
{new Date(record.submittedAt).toLocaleDateString()}
</span>
)}
</div>
</div>
{/* 操作按鈕 */}
<div className="flex justify-center pt-2">
<Button
variant="outline"
size="sm"
onClick={() => handleEditScoring(record)}
className="w-full"
>
{record.status === "completed" ? (
<>
<Edit className="w-4 h-4 mr-2" />
</>
) : (
<>
<Plus className="w-4 h-4 mr-2" />
</>
)}
</Button>
</div>
</div>
</div>
))}
</div>
</div>
</CardContent>
</Card>
)
})
})()}
</div>
</CardContent>
</Card>
</>
)}
<Dialog open={showManualScoring || showEditScoring} onOpenChange={(open) => {
if (!open) {
setShowManualScoring(false)
setShowEditScoring(false)
setSelectedRecord(null)
setManualScoring({
judgeId: "",
participantId: "",
scores: {} as Record<string, number>,
comments: ""
})
}
}}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center space-x-2">
<Edit className="w-5 h-5" />
<span>{showEditScoring ? "編輯評分" : "手動輸入評分"}</span>
</DialogTitle>
<DialogDescription>
{showEditScoring ? "修改現有評分記錄" : "為參賽者手動輸入評分"}
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label> *</Label>
<Select
value={manualScoring.judgeId}
onValueChange={(value) => setManualScoring({ ...manualScoring, judgeId: value })}
>
<SelectTrigger>
<SelectValue placeholder="選擇評審" />
</SelectTrigger>
<SelectContent>
{isLoadingData ? (
<SelectItem value="loading-judges" disabled>
<div className="flex items-center space-x-2">
<Loader2 className="w-4 h-4 animate-spin" />
<span>...</span>
</div>
</SelectItem>
) : competitionJudges.length > 0 ? (
competitionJudges.map((judge) => (
<SelectItem key={judge.id} value={judge.id}>
<div className="flex flex-col">
<span className="font-medium">{judge.name}</span>
<span className="text-xs text-gray-500">{judge.title} - {judge.department}</span>
</div>
</SelectItem>
))
) : (
<SelectItem value="no-judges" disabled>
<span className="text-gray-500"></span>
</SelectItem>
)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label> *</Label>
<Select
value={manualScoring.participantId}
onValueChange={(value) => setManualScoring({ ...manualScoring, participantId: value })}
>
<SelectTrigger>
<SelectValue placeholder="選擇參賽者" />
</SelectTrigger>
<SelectContent>
{isLoadingData ? (
<SelectItem value="loading-participants" disabled>
<div className="flex items-center space-x-2">
<Loader2 className="w-4 h-4 animate-spin" />
<span>...</span>
</div>
</SelectItem>
) : competitionParticipants.length > 0 ? (
competitionParticipants.map((participant) => (
<SelectItem key={participant.id} value={participant.id}>
<div className="flex items-center space-x-2">
{participant.type === "individual" ? (
<User className="w-4 h-4 text-blue-600" />
) : (
<Users className="w-4 h-4 text-green-600" />
)}
<div className="flex flex-col">
<span className="font-medium">
{participant.type === "individual"
? `個人 - ${participant.name}`
: participant.displayName || `${participant.teamName} - ${participant.name}`
}
</span>
<span className="text-xs text-gray-500">{participant.creator}</span>
</div>
</div>
</SelectItem>
))
) : (
<SelectItem value="no-participants" disabled>
<span className="text-gray-500"></span>
</SelectItem>
)}
</SelectContent>
</Select>
</div>
</div>
{/* 動態評分項目 */}
<div className="space-y-4">
<h3 className="text-lg font-semibold"></h3>
{(() => {
const defaultRules = [
{ name: "創新性", description: "創新程度和獨特性", weight: 25 },
{ name: "技術性", description: "技術實現的複雜度和品質", weight: 30 },
{ name: "實用性", description: "實際應用價值和用戶體驗", weight: 20 },
{ name: "展示效果", description: "展示的清晰度和吸引力", weight: 15 },
{ name: "影響力", description: "對行業或社會的潛在影響", weight: 10 }
]
const rules = competitionRules.length > 0 ? competitionRules : defaultRules
return rules.map((rule: any, index: number) => (
<div key={index} className="space-y-4 p-6 border rounded-lg bg-white shadow-sm">
<div className="flex justify-between items-start">
<div className="flex-1">
<Label className="text-lg font-semibold text-gray-900">{rule.name}</Label>
<p className="text-sm text-gray-600 mt-2 leading-relaxed">{rule.description}</p>
{rule.weight && (
<p className="text-xs text-purple-600 mt-2 font-medium">{rule.weight}%</p>
)}
</div>
<div className="text-right ml-4">
<span className="text-2xl font-bold text-blue-600">
{manualScoring.scores[rule.name] || 0} / 10
</span>
</div>
</div>
{/* 評分按鈕 */}
<div className="flex flex-wrap gap-3">
{Array.from({ length: 10 }, (_, i) => i + 1).map((score) => (
<button
key={score}
type="button"
onClick={() => setManualScoring({
...manualScoring,
scores: { ...manualScoring.scores, [rule.name]: score }
})}
className={`w-12 h-12 rounded-lg border-2 font-semibold text-lg transition-all duration-200 ${
(manualScoring.scores[rule.name] || 0) === score
? 'bg-blue-600 text-white border-blue-600 shadow-lg scale-105'
: 'bg-white text-gray-700 border-gray-300 hover:border-blue-400 hover:bg-blue-50 hover:scale-105'
}`}
>
{score}
</button>
))}
</div>
</div>
))
})()}
</div>
{/* 總分顯示 */}
<div className="p-6 bg-gradient-to-r from-blue-50 to-purple-50 rounded-lg border-2 border-blue-200">
<div className="flex justify-between items-center">
<div>
<span className="text-xl font-bold text-gray-900"></span>
<p className="text-sm text-gray-600 mt-1"></p>
</div>
<div className="flex items-center space-x-3">
<span className="text-4xl font-bold text-blue-600">
{Math.round(calculateTotalScore(manualScoring.scores, competitionRules) * 10)}
</span>
<span className="text-xl text-gray-500 font-medium">/ 100</span>
</div>
</div>
</div>
<div className="space-y-3">
<Label className="text-lg font-semibold"> *</Label>
<Textarea
placeholder="請詳細填寫評審意見、優點分析、改進建議等..."
value={manualScoring.comments}
onChange={(e) => setManualScoring({ ...manualScoring, comments: e.target.value })}
rows={6}
className="min-h-[120px] resize-none"
/>
<p className="text-xs text-gray-500"></p>
</div>
</div>
<div className="flex justify-end space-x-4 pt-6 border-t border-gray-200">
<Button
variant="outline"
size="lg"
onClick={() => {
setShowManualScoring(false)
setShowEditScoring(false)
setSelectedRecord(null)
}}
className="px-8"
>
</Button>
<Button
onClick={handleSubmitScore}
disabled={isLoading}
size="lg"
className="px-8 bg-blue-600 hover:bg-blue-700"
>
{isLoading ? (
<>
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
...
</>
) : (
<>
<CheckCircle className="w-5 h-5 mr-2" />
{showEditScoring ? "更新評分" : "提交評分"}
</>
)}
</Button>
</div>
</DialogContent>
</Dialog>
{/* 評分連結對話框 */}
<ScoringLinkDialog
open={showScoringLink}
onOpenChange={setShowScoringLink}
currentCompetition={selectedCompetition}
/>
{/* 評審清單對話框 */}
<JudgeListDialog
open={showJudgeList}
onOpenChange={setShowJudgeList}
judges={competitionJudges.map(judge => ({
id: judge.id,
name: judge.name,
specialty: judge.specialty || "評審專家",
expertise: judge.expertise || [],
title: judge.title,
department: judge.department,
email: judge.email,
phone: judge.phone,
organization: judge.organization
}))}
/>
</div>
)
}