1337 lines
55 KiB
TypeScript
1337 lines
55 KiB
TypeScript
"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"
|
||
teamName?: string
|
||
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
|
||
}
|
||
|
||
// 生成所有評審和APP的組合
|
||
const generateAllScoringCombinations = () => {
|
||
if (!competitionJudges.length || !competitionParticipants.length) {
|
||
return []
|
||
}
|
||
|
||
const combinations: ScoringRecord[] = []
|
||
|
||
// 為每個評審和每個參賽者創建組合
|
||
competitionJudges.forEach(judge => {
|
||
competitionParticipants.forEach(participant => {
|
||
// 檢查是否已有評分記錄
|
||
const existingRecord = scoringRecords.find(record =>
|
||
record.judgeId === judge.id && record.participantId === participant.id
|
||
)
|
||
|
||
if (existingRecord) {
|
||
// 使用現有記錄
|
||
combinations.push(existingRecord)
|
||
} else {
|
||
// 創建新的待評分記錄
|
||
combinations.push({
|
||
id: `pending_${judge.id}_${participant.id}`,
|
||
judgeId: judge.id,
|
||
judgeName: judge.name,
|
||
participantId: participant.id,
|
||
participantName: participant.displayName || participant.name,
|
||
participantType: participant.type as "individual" | "team",
|
||
teamName: participant.teamName,
|
||
scores: {},
|
||
totalScore: 0,
|
||
comments: "",
|
||
status: "pending"
|
||
})
|
||
}
|
||
})
|
||
})
|
||
|
||
return combinations
|
||
}
|
||
|
||
const getFilteredRecords = () => {
|
||
// 使用生成的組合而不是僅有的評分記錄
|
||
const allCombinations = generateAllScoringCombinations()
|
||
let filtered = [...allCombinations]
|
||
|
||
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 || '',
|
||
})
|
||
|
||
// 如果是待評分項目,顯示手動評分對話框;如果是已完成項目,顯示編輯對話框
|
||
if (record.status === "pending") {
|
||
setShowManualScoring(true)
|
||
} else {
|
||
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 ? "評分更新成功!" : "評分提交成功!")
|
||
|
||
// 更新本地評分記錄
|
||
const newRecord: ScoringRecord = {
|
||
id: data.data?.id || `new_${Date.now()}`,
|
||
judgeId: manualScoring.judgeId,
|
||
judgeName: competitionJudges.find(j => j.id === manualScoring.judgeId)?.name || '未知評審',
|
||
participantId: manualScoring.participantId,
|
||
participantName: competitionParticipants.find(p => p.id === manualScoring.participantId)?.displayName || competitionParticipants.find(p => p.id === manualScoring.participantId)?.name || '未知參賽者',
|
||
participantType: competitionParticipants.find(p => p.id === manualScoring.participantId)?.type as "individual" | "team" || "individual",
|
||
scores: apiScores,
|
||
totalScore: calculateTotalScore(apiScores, rules),
|
||
comments: manualScoring.comments.trim(),
|
||
status: "completed",
|
||
submittedAt: new Date().toISOString()
|
||
}
|
||
|
||
// 更新本地狀態
|
||
setScoringRecords(prev => {
|
||
const existingIndex = prev.findIndex(r => r.judgeId === newRecord.judgeId && r.participantId === newRecord.participantId)
|
||
if (existingIndex >= 0) {
|
||
// 更新現有記錄
|
||
const updated = [...prev]
|
||
updated[existingIndex] = newRecord
|
||
return updated
|
||
} else {
|
||
// 添加新記錄
|
||
return [...prev, newRecord]
|
||
}
|
||
})
|
||
|
||
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: app.name, // 只顯示 app 名稱,團隊名稱通過 teamName 屬性獲取
|
||
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 calculateProgressStats = () => {
|
||
const allCombinations = generateAllScoringCombinations()
|
||
const total = allCombinations.length
|
||
const completed = allCombinations.filter(record => record.status === "completed").length
|
||
const pending = allCombinations.filter(record => record.status === "pending").length
|
||
const percentage = total > 0 ? Math.round((completed / total) * 100) : 0
|
||
|
||
return {
|
||
total,
|
||
completed,
|
||
pending,
|
||
percentage
|
||
}
|
||
}
|
||
|
||
const progress = calculateProgressStats()
|
||
|
||
// 顯示初始載入狀態
|
||
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">
|
||
{competitionJudges.length}
|
||
</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">
|
||
{competitionParticipants.length}
|
||
</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">
|
||
{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-purple-600">
|
||
{progress.percentage}%
|
||
</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>{progress.completed} / {progress.total}</span>
|
||
</div>
|
||
<Progress
|
||
value={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 -= 304 // 滑動一個卡片的寬度 (288px + 16px間距)
|
||
}
|
||
}}
|
||
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 += 304 // 滑動一個卡片的寬度 (288px + 16px間距)
|
||
}
|
||
}}
|
||
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 * 288px + 3 * 16px)' // 4個卡片 + 3個間距
|
||
}}
|
||
>
|
||
{records.map((record) => (
|
||
<div
|
||
key={record.id}
|
||
className="flex-shrink-0 w-72 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-start justify-between space-x-2">
|
||
<div className="flex items-start space-x-2 flex-1 min-w-0">
|
||
{record.participantType === "individual" ? (
|
||
<User className="w-4 h-4 text-blue-600 flex-shrink-0 mt-0.5" />
|
||
) : (
|
||
<Users className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" />
|
||
)}
|
||
<div className="min-w-0 flex-1">
|
||
<span className="font-medium text-sm leading-tight break-words block">
|
||
{record.participantType === "team" && record.teamName
|
||
? `${record.teamName} - ${record.participantName}`
|
||
: record.participantName
|
||
}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<Badge variant="outline" className="text-xs flex-shrink-0">
|
||
{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>
|
||
)
|
||
}
|