完成評審評分機制
This commit is contained in:
@@ -699,6 +699,15 @@ export function CompetitionManagement() {
|
||||
fetchTeamStats()
|
||||
}, [])
|
||||
|
||||
// 當競賽列表載入完成後,載入每個競賽的評分進度
|
||||
useEffect(() => {
|
||||
if (competitions.length > 0) {
|
||||
competitions.forEach(competition => {
|
||||
loadScoringProgress(competition.id);
|
||||
});
|
||||
}
|
||||
}, [competitions])
|
||||
|
||||
// 当筛选条件改变时重置分页
|
||||
const resetAwardPagination = () => {
|
||||
setAwardCurrentPage(1)
|
||||
@@ -1608,80 +1617,235 @@ export function CompetitionManagement() {
|
||||
setTimeout(() => setSuccess(""), 3000)
|
||||
}
|
||||
|
||||
const handleManualScoring = (competition: any) => {
|
||||
const handleManualScoring = async (competition: any) => {
|
||||
setSelectedCompetition(competition)
|
||||
|
||||
// 設定初始參賽者類型
|
||||
let participantType = "individual";
|
||||
if (competition.type === "mixed") {
|
||||
setSelectedParticipantType("individual") // 混合賽預設從個人賽開始
|
||||
participantType = "individual";
|
||||
} else {
|
||||
setSelectedParticipantType(competition.type)
|
||||
participantType = competition.type;
|
||||
}
|
||||
|
||||
// 初始化評分項目
|
||||
const initialScores = getInitialScores(competition, competition.type === "mixed" ? "individual" : competition.type)
|
||||
const initialScores = getInitialScores(competition, participantType)
|
||||
|
||||
setManualScoring({
|
||||
judgeId: "",
|
||||
participantId: "",
|
||||
participantType: competition.type || "individual",
|
||||
participantType: participantType,
|
||||
scores: initialScores,
|
||||
comments: "",
|
||||
})
|
||||
|
||||
// 載入競賽相關數據
|
||||
await loadCompetitionDataForScoring(competition)
|
||||
|
||||
setShowManualScoring(true)
|
||||
}
|
||||
|
||||
// 載入競賽相關數據用於評分
|
||||
const loadCompetitionDataForScoring = async (competition: any) => {
|
||||
try {
|
||||
console.log('🔍 開始載入競賽評分數據,競賽ID:', competition.id)
|
||||
|
||||
// 載入競賽評審
|
||||
const judgesResponse = await fetch(`/api/competitions/${competition.id}/judges`)
|
||||
const judgesData = await judgesResponse.json()
|
||||
|
||||
console.log('🔍 競賽評審API回應:', judgesData)
|
||||
console.log('🔍 檢查條件:')
|
||||
console.log(' - judgesData.success:', judgesData.success)
|
||||
console.log(' - judgesData.data:', !!judgesData.data)
|
||||
console.log(' - judgesData.data.judges:', !!judgesData.data?.judges)
|
||||
console.log(' - judgesData.data.judges.length:', judgesData.data?.judges?.length)
|
||||
|
||||
if (judgesData.success && judgesData.data && judgesData.data.judges) {
|
||||
console.log('✅ 競賽評審載入成功:', judgesData.data.judges.length, '個評審')
|
||||
// 更新評審數據到dbJudges(如果需要的話)
|
||||
setDbJudges(prev => {
|
||||
const existingIds = prev.map(j => j.id)
|
||||
const newJudges = judgesData.data.judges.filter((j: any) => !existingIds.includes(j.id))
|
||||
return [...prev, ...newJudges]
|
||||
})
|
||||
} else {
|
||||
console.error('❌ 競賽評審載入失敗:', judgesData.message)
|
||||
console.error('❌ 詳細錯誤信息:', judgesData.error)
|
||||
}
|
||||
|
||||
// 載入競賽參賽者(應用和團隊)
|
||||
const [appsResponse, teamsResponse] = await Promise.all([
|
||||
fetch(`/api/competitions/${competition.id}/apps`),
|
||||
fetch(`/api/competitions/${competition.id}/teams`)
|
||||
])
|
||||
|
||||
const appsData = await appsResponse.json()
|
||||
const teamsData = await teamsResponse.json()
|
||||
|
||||
console.log('應用API回應:', appsData)
|
||||
console.log('團隊API回應:', teamsData)
|
||||
|
||||
// 更新selectedCompetition以包含載入的數據
|
||||
const updatedCompetition = { ...competition }
|
||||
|
||||
// 添加評審數據到selectedCompetition
|
||||
if (judgesData.success && judgesData.data && judgesData.data.judges) {
|
||||
console.log('✅ 競賽評審載入成功,添加到selectedCompetition:', judgesData.data.judges.length, '個評審')
|
||||
updatedCompetition.judges = judgesData.data.judges
|
||||
} else {
|
||||
console.error('❌ 競賽評審載入失敗,設置空數組')
|
||||
updatedCompetition.judges = []
|
||||
}
|
||||
|
||||
if (appsData.success && appsData.data && appsData.data.apps) {
|
||||
console.log('✅ 競賽應用載入成功:', appsData.data.apps.length, '個應用')
|
||||
updatedCompetition.apps = appsData.data.apps
|
||||
} else {
|
||||
console.error('❌ 競賽應用載入失敗:', appsData.message)
|
||||
updatedCompetition.apps = []
|
||||
}
|
||||
|
||||
if (teamsData.success && teamsData.data && teamsData.data.teams) {
|
||||
console.log('✅ 競賽團隊載入成功:', teamsData.data.teams.length, '個團隊')
|
||||
updatedCompetition.teams = teamsData.data.teams
|
||||
// 同時更新dbTeams
|
||||
setDbTeams(prev => {
|
||||
const existingIds = prev.map(t => t.id)
|
||||
const newTeams = teamsData.data.teams.filter((t: any) => !existingIds.includes(t.id))
|
||||
return [...prev, ...newTeams]
|
||||
})
|
||||
} else {
|
||||
console.error('❌ 競賽團隊載入失敗:', teamsData.message)
|
||||
updatedCompetition.teams = []
|
||||
}
|
||||
|
||||
// 更新selectedCompetition
|
||||
setSelectedCompetition(updatedCompetition)
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 載入競賽評分數據失敗:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 獲取初始評分項目的輔助函數
|
||||
const getInitialScores = (competition: any, participantType: "individual" | "team") => {
|
||||
const initialScores: Record<string, number> = {}
|
||||
|
||||
if (competition.type === "mixed") {
|
||||
// 混合賽:根據參賽者類型選擇對應的評分規則
|
||||
const config = participantType === "individual" ? competition.individualConfig : competition.teamConfig
|
||||
if (config && config.rules && config.rules.length > 0) {
|
||||
config.rules.forEach((rule: any) => {
|
||||
initialScores[rule.name] = 0
|
||||
})
|
||||
// 獲取實際的評分項目(與顯示邏輯一致)
|
||||
let currentRules: any[] = [];
|
||||
if (competition?.type === 'mixed') {
|
||||
const config = participantType === 'individual'
|
||||
? competition.individualConfig
|
||||
: competition.teamConfig;
|
||||
currentRules = config?.rules || [];
|
||||
} else {
|
||||
// 預設評分項目
|
||||
getDefaultScoringItems(participantType).forEach(item => {
|
||||
initialScores[item.name] = 0
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// 單一類型競賽
|
||||
if (competition.rules && competition.rules.length > 0) {
|
||||
competition.rules.forEach((rule: any) => {
|
||||
initialScores[rule.name] = 0
|
||||
})
|
||||
} else {
|
||||
// 預設評分項目
|
||||
getDefaultScoringItems(participantType).forEach(item => {
|
||||
initialScores[item.name] = 0
|
||||
})
|
||||
}
|
||||
currentRules = competition?.rules || [];
|
||||
}
|
||||
|
||||
// 如果有自定義規則,使用自定義規則;否則使用預設規則
|
||||
const scoringItems = currentRules.length > 0
|
||||
? currentRules
|
||||
: getDefaultScoringItems(participantType);
|
||||
|
||||
scoringItems.forEach(item => {
|
||||
initialScores[item.name] = 0
|
||||
})
|
||||
|
||||
|
||||
return initialScores
|
||||
}
|
||||
|
||||
// 計算總分(10分制)
|
||||
const calculateTotalScore = () => {
|
||||
const scores = Object.values(manualScoring.scores);
|
||||
return scores.reduce((total, score) => total + (score || 0), 0);
|
||||
};
|
||||
|
||||
// 計算100分制總分
|
||||
const calculateTotalScore100 = () => {
|
||||
const totalScore = calculateTotalScore();
|
||||
|
||||
// 獲取實際的評分項目數量
|
||||
let currentRules: any[] = [];
|
||||
if (selectedCompetition?.type === 'mixed') {
|
||||
const config = selectedParticipantType === 'individual'
|
||||
? selectedCompetition.individualConfig
|
||||
: selectedCompetition.teamConfig;
|
||||
currentRules = config?.rules || [];
|
||||
} else {
|
||||
currentRules = selectedCompetition?.rules || [];
|
||||
}
|
||||
|
||||
// 如果有自定義規則,使用自定義規則的數量;否則使用預設規則
|
||||
const scoringItems = currentRules.length > 0
|
||||
? currentRules
|
||||
: getDefaultScoringItems(
|
||||
selectedCompetition?.type === 'mixed'
|
||||
? selectedParticipantType
|
||||
: selectedCompetition?.type || 'individual'
|
||||
);
|
||||
|
||||
const maxScore = scoringItems.length * 10;
|
||||
return Math.round((totalScore / maxScore) * 100);
|
||||
};
|
||||
|
||||
// 計算加權總分(100分制)
|
||||
const calculateWeightedTotalScore100 = () => {
|
||||
let currentRules: any[] = [];
|
||||
|
||||
if (selectedCompetition?.type === 'mixed') {
|
||||
const config = selectedParticipantType === 'individual'
|
||||
? selectedCompetition.individualConfig
|
||||
: selectedCompetition.teamConfig;
|
||||
currentRules = config?.rules || [];
|
||||
} else {
|
||||
currentRules = selectedCompetition?.rules || [];
|
||||
}
|
||||
|
||||
if (currentRules.length === 0) {
|
||||
// 如果沒有自定義規則,使用預設權重(每個項目權重相等)
|
||||
return calculateTotalScore100();
|
||||
}
|
||||
|
||||
// 使用自定義權重計算
|
||||
let weightedTotal = 0;
|
||||
let totalWeight = 0;
|
||||
|
||||
currentRules.forEach((rule: any) => {
|
||||
const score = manualScoring.scores[rule.name] || 0;
|
||||
const weight = parseFloat(rule.weight) || 0;
|
||||
weightedTotal += (score * weight) / 10; // 10分制轉換
|
||||
totalWeight += weight;
|
||||
});
|
||||
|
||||
// 如果權重總和不是100%,按比例調整
|
||||
if (totalWeight > 0 && totalWeight !== 100) {
|
||||
weightedTotal = (weightedTotal * 100) / totalWeight;
|
||||
}
|
||||
|
||||
return Math.round(weightedTotal); // 100分制,整數
|
||||
};
|
||||
|
||||
// 獲取預設評分項目
|
||||
const getDefaultScoringItems = (participantType: "individual" | "team") => {
|
||||
if (participantType === "team") {
|
||||
return [
|
||||
{ name: '團隊合作', description: '團隊協作和溝通能力' },
|
||||
{ name: '創新性', description: '創新程度和獨特性' },
|
||||
{ name: '技術性', description: '技術實現的複雜度和品質' },
|
||||
{ name: '實用性', description: '實際應用價值和用戶體驗' },
|
||||
{ name: '展示效果', description: '團隊展示的清晰度和吸引力' }
|
||||
{ name: 'innovation', description: '創新程度和獨特性' },
|
||||
{ name: 'technical', description: '技術實現的複雜度和品質' },
|
||||
{ name: 'usability', description: '實際應用價值和用戶體驗' },
|
||||
{ name: 'presentation', description: '團隊展示的清晰度和吸引力' },
|
||||
{ name: 'impact', description: '對行業或社會的潛在影響' }
|
||||
]
|
||||
} else {
|
||||
return [
|
||||
{ name: '創新性', description: '創新程度和獨特性' },
|
||||
{ name: '技術性', description: '技術實現的複雜度和品質' },
|
||||
{ name: '實用性', description: '實際應用價值和用戶體驗' },
|
||||
{ name: '展示效果', description: '展示的清晰度和吸引力' },
|
||||
{ name: '影響力', description: '對行業或社會的潛在影響' }
|
||||
{ name: 'innovation', description: '創新程度和獨特性' },
|
||||
{ name: 'technical', description: '技術實現的複雜度和品質' },
|
||||
{ name: 'usability', description: '實際應用價值和用戶體驗' },
|
||||
{ name: 'presentation', description: '展示的清晰度和吸引力' },
|
||||
{ name: 'impact', description: '對行業或社會的潛在影響' }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1692,6 +1856,7 @@ export function CompetitionManagement() {
|
||||
|
||||
// 重新初始化評分項目
|
||||
const newScores = getInitialScores(selectedCompetition, newType)
|
||||
|
||||
setManualScoring({
|
||||
...manualScoring,
|
||||
participantId: "", // 清空選擇的參賽者
|
||||
@@ -1719,15 +1884,69 @@ export function CompetitionManagement() {
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
|
||||
try {
|
||||
// 直接使用競賽規則的評分項目,不需要轉換
|
||||
// API 會根據 competition_rules 表來驗證和處理評分
|
||||
const apiScores = { ...manualScoring.scores };
|
||||
|
||||
submitJudgeScore({
|
||||
|
||||
// 驗證評分是否有效(1-10分)
|
||||
const invalidScores = Object.entries(apiScores).filter(([key, value]) => value < 1 || value > 10);
|
||||
if (invalidScores.length > 0) {
|
||||
setError(`評分必須在1-10分之間:${invalidScores.map(([key]) => key).join(', ')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 根據參賽者類型確定participantType和實際的APP ID
|
||||
let actualAppId = manualScoring.participantId;
|
||||
let participantType = 'app'; // 默認為APP評分
|
||||
|
||||
// 檢查是否為團隊選擇
|
||||
const selectedTeam = selectedCompetition?.teams?.find((team: any) => team.id === manualScoring.participantId);
|
||||
if (selectedTeam) {
|
||||
// 如果是團隊,使用團隊下的第一個APP進行評分
|
||||
if (selectedTeam.apps && selectedTeam.apps.length > 0) {
|
||||
actualAppId = selectedTeam.apps[0].id; // 使用團隊下的第一個APP
|
||||
participantType = 'app'; // 對APP進行評分
|
||||
} else {
|
||||
setError("該團隊暫無APP,無法進行評分");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// 檢查是否為個人APP
|
||||
const selectedApp = selectedCompetition?.apps?.find((app: any) => app.id === manualScoring.participantId);
|
||||
if (selectedApp) {
|
||||
actualAppId = selectedApp.id;
|
||||
participantType = 'app';
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch('/api/admin/scoring', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
judgeId: manualScoring.judgeId,
|
||||
appId: manualScoring.participantId, // Using appId field for all participant types
|
||||
scores: manualScoring.scores,
|
||||
participantId: actualAppId, // 使用實際的APP ID
|
||||
participantType: participantType,
|
||||
scores: apiScores,
|
||||
comments: manualScoring.comments.trim(),
|
||||
})
|
||||
competitionId: selectedCompetition?.id // 添加競賽ID
|
||||
})
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
const selectedTeam = selectedCompetition?.teams?.find((team: any) => team.id === manualScoring.participantId);
|
||||
const successMessage = selectedTeam
|
||||
? `團隊「${selectedTeam.name}」的APP評分提交成功!`
|
||||
: "APP評分提交成功!";
|
||||
setSuccess(successMessage)
|
||||
|
||||
// 重置表單
|
||||
setManualScoring({
|
||||
judgeId: "",
|
||||
participantId: "",
|
||||
@@ -1742,9 +1961,17 @@ export function CompetitionManagement() {
|
||||
comments: "",
|
||||
})
|
||||
|
||||
setSuccess("評分提交成功!")
|
||||
setShowManualScoring(false)
|
||||
} else {
|
||||
setError(data.message || "評分提交失敗")
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('評分提交失敗:', err)
|
||||
setError("評分提交失敗,請重試")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
setTimeout(() => setSuccess(""), 3000)
|
||||
}
|
||||
}
|
||||
|
||||
const handleViewCompetition = (competition: any) => {
|
||||
@@ -1992,25 +2219,27 @@ export function CompetitionManagement() {
|
||||
}
|
||||
}
|
||||
|
||||
const getScoringProgress = (competitionId: string) => {
|
||||
const competition = competitions.find((c) => c.id === competitionId)
|
||||
if (!competition) return { completed: 0, total: 0, percentage: 0 }
|
||||
const [scoringProgress, setScoringProgress] = useState<Record<string, { completed: number; total: number; percentage: number }>>({});
|
||||
|
||||
const participantCount = getParticipantCount(competition)
|
||||
const judgesCount = competition.judges?.length || 0
|
||||
const totalExpected = judgesCount * participantCount
|
||||
const completed = judgeScores.filter((score) => {
|
||||
const individualParticipants = competition.participatingApps || []
|
||||
const teamParticipants = competition.participatingTeams || []
|
||||
const allParticipants = [...individualParticipants, ...teamParticipants]
|
||||
return allParticipants.includes(score.appId) && (competition.judges || []).includes(score.judgeId)
|
||||
}).length
|
||||
|
||||
return {
|
||||
completed,
|
||||
total: totalExpected,
|
||||
percentage: totalExpected > 0 ? Math.round((completed / totalExpected) * 100) : 0,
|
||||
// 載入評分進度數據
|
||||
const loadScoringProgress = async (competitionId: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/competitions/scoring-progress?competitionId=${competitionId}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setScoringProgress(prev => ({
|
||||
...prev,
|
||||
[competitionId]: data.data
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('載入評分進度失敗:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const getScoringProgress = (competitionId: string) => {
|
||||
return scoringProgress[competitionId] || { completed: 0, total: 0, percentage: 0 };
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
@@ -5581,33 +5810,39 @@ export function CompetitionManagement() {
|
||||
<SelectValue placeholder="選擇評審" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{/* 混合賽時根據參賽者類型過濾評審 */}
|
||||
{selectedCompetition?.type === 'mixed' ? (
|
||||
(dbJudges.length > 0 ? dbJudges : judges).filter(judge => {
|
||||
const config = selectedParticipantType === 'individual'
|
||||
? selectedCompetition.individualConfig
|
||||
: selectedCompetition.teamConfig;
|
||||
return config?.judges?.includes(judge.id) || false;
|
||||
}).map((judge) => (
|
||||
<SelectItem key={judge.id} value={judge.id}>
|
||||
{judge.name} - {judge.expertise}
|
||||
{/* 顯示競賽綁定的評審 */}
|
||||
{(() => {
|
||||
// 使用競賽綁定的評審,而不是所有評審
|
||||
const competitionJudges = selectedCompetition?.judges || [];
|
||||
console.log('🔍 競賽綁定的評審:', competitionJudges);
|
||||
console.log('🔍 selectedCompetition:', selectedCompetition);
|
||||
|
||||
if (competitionJudges.length === 0) {
|
||||
return (
|
||||
<SelectItem value="" disabled>
|
||||
<span className="text-gray-500">此競賽暫無綁定評審</span>
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
(dbJudges.length > 0 ? dbJudges : judges).filter(judge => selectedCompetition?.judges?.includes(judge.id) || false).map((judge) => (
|
||||
);
|
||||
}
|
||||
|
||||
return competitionJudges.map((judge) => (
|
||||
<SelectItem key={judge.id} value={judge.id}>
|
||||
{judge.name} - {judge.expertise}
|
||||
<div className="flex items-center space-x-2">
|
||||
<User className="w-4 h-4 text-blue-600" />
|
||||
<span>{judge.name}</span>
|
||||
<span className="text-xs text-gray-500">({judge.title || judge.expertise})</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
));
|
||||
})()}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{selectedCompetition?.type === 'mixed'
|
||||
? (selectedParticipantType === 'individual' ? '選擇個人' : '選擇團隊')
|
||||
: (selectedCompetition?.type === 'team' ? '選擇團隊' : '選擇個人')
|
||||
? (selectedParticipantType === 'individual' ? '選擇個人APP' : '選擇團隊APP')
|
||||
: (selectedCompetition?.type === 'team' ? '選擇團隊APP' : '選擇個人APP')
|
||||
}
|
||||
</Label>
|
||||
<Select
|
||||
@@ -5617,31 +5852,70 @@ export function CompetitionManagement() {
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={
|
||||
selectedCompetition?.type === 'mixed'
|
||||
? (selectedParticipantType === 'individual' ? '選擇個人' : '選擇團隊')
|
||||
: (selectedCompetition?.type === 'team' ? '選擇團隊' : '選擇個人')
|
||||
? (selectedParticipantType === 'individual' ? '選擇個人APP' : '選擇團隊APP')
|
||||
: (selectedCompetition?.type === 'team' ? '選擇團隊APP' : '選擇個人APP')
|
||||
} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{/* 根據競賽類型和選擇的參賽者類型顯示參賽者 */}
|
||||
{(selectedCompetition?.type === 'individual' ||
|
||||
(selectedCompetition?.type === 'mixed' && selectedParticipantType === 'individual')) &&
|
||||
mockIndividualApps
|
||||
.filter(app => selectedCompetition.participatingApps?.includes(app.id))
|
||||
.map((app) => (
|
||||
(() => {
|
||||
// 從API載入的應用數據
|
||||
const apps = selectedCompetition?.apps || []
|
||||
return apps.length > 0 ? apps.map((app: any) => (
|
||||
<SelectItem key={app.id} value={app.id}>
|
||||
{app.name} - {app.creator}
|
||||
<div className="flex items-center space-x-2">
|
||||
<User className="w-4 h-4 text-blue-600" />
|
||||
<span>{app.name}</span>
|
||||
<span className="text-xs text-gray-500">({app.creator})</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)) : (
|
||||
<SelectItem value="" disabled>
|
||||
<span className="text-gray-500">暫無個人參賽者</span>
|
||||
</SelectItem>
|
||||
)
|
||||
})()
|
||||
}
|
||||
{(selectedCompetition?.type === 'team' ||
|
||||
(selectedCompetition?.type === 'mixed' && selectedParticipantType === 'team')) &&
|
||||
teams
|
||||
.filter(team => selectedCompetition.participatingTeams?.includes(team.id))
|
||||
.map((team) => (
|
||||
(() => {
|
||||
// 使用selectedCompetition.teams數據,顯示為「團隊名 - APP名」格式
|
||||
const teamsData = selectedCompetition?.teams || []
|
||||
return teamsData.length > 0 ? teamsData.map((team: any) => {
|
||||
// 檢查團隊是否有APP
|
||||
const teamApps = team.apps || []
|
||||
if (teamApps.length === 0) {
|
||||
return (
|
||||
<SelectItem key={team.id} value={team.id} disabled>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Users className="w-4 h-4 text-gray-400" />
|
||||
<span className="text-gray-400">{team.name}</span>
|
||||
<span className="text-xs text-gray-400">(暫無APP)</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
)
|
||||
}
|
||||
|
||||
// 顯示團隊和其第一個APP
|
||||
const firstApp = teamApps[0]
|
||||
return (
|
||||
<SelectItem key={team.id} value={team.id}>
|
||||
{team.name} - {team.leader}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Users className="w-4 h-4 text-green-600" />
|
||||
<span>{team.name}</span>
|
||||
<span className="text-gray-400">-</span>
|
||||
<span className="text-blue-600">{firstApp.name}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)
|
||||
}) : (
|
||||
<SelectItem value="" disabled>
|
||||
<span className="text-gray-500">暫無團隊參賽者</span>
|
||||
</SelectItem>
|
||||
)
|
||||
})()
|
||||
}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -5652,11 +5926,74 @@ export function CompetitionManagement() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-lg font-medium">評分項目</Label>
|
||||
<div className="flex items-center space-x-4">
|
||||
{selectedCompetition?.type === 'mixed' && (
|
||||
<Badge variant="outline" className="text-sm">
|
||||
{selectedParticipantType === 'individual' ? '個人賽評分' : '團體賽評分'}
|
||||
</Badge>
|
||||
)}
|
||||
{/* 總分顯示 */}
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg px-4 py-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="text-sm text-gray-600">總分</div>
|
||||
<div className="text-2xl font-bold text-gray-800">
|
||||
{calculateTotalScore()}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">/ {(() => {
|
||||
let currentRules: any[] = [];
|
||||
if (selectedCompetition?.type === 'mixed') {
|
||||
const config = selectedParticipantType === 'individual'
|
||||
? selectedCompetition.individualConfig
|
||||
: selectedCompetition.teamConfig;
|
||||
currentRules = config?.rules || [];
|
||||
} else {
|
||||
currentRules = selectedCompetition?.rules || [];
|
||||
}
|
||||
const scoringItems = currentRules.length > 0
|
||||
? currentRules
|
||||
: getDefaultScoringItems(
|
||||
selectedCompetition?.type === 'mixed'
|
||||
? selectedParticipantType
|
||||
: selectedCompetition?.type || 'individual'
|
||||
);
|
||||
return scoringItems.length * 10;
|
||||
})()}</div>
|
||||
</div>
|
||||
|
||||
{/* 整體進度條 */}
|
||||
<div className="mt-2">
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-gray-600 h-2 rounded-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${calculateTotalScore100()}%`
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
完成度: {calculateTotalScore100()}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 加權總分顯示 */}
|
||||
{(() => {
|
||||
const weightedTotal = calculateWeightedTotalScore100();
|
||||
const simpleTotal = calculateTotalScore100();
|
||||
const hasCustomWeights = selectedCompetition?.rules?.some((rule: any) => rule.weight) ||
|
||||
selectedCompetition?.individualConfig?.rules?.some((rule: any) => rule.weight) ||
|
||||
selectedCompetition?.teamConfig?.rules?.some((rule: any) => rule.weight);
|
||||
|
||||
if (hasCustomWeights && weightedTotal !== simpleTotal) {
|
||||
return (
|
||||
<div className="text-xs text-gray-600 mt-2 font-medium">
|
||||
加權總分: {weightedTotal}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 動態顯示競賽的評分項目 */}
|
||||
@@ -5686,17 +6023,29 @@ export function CompetitionManagement() {
|
||||
return scoringItems.map((item: any, index: number) => (
|
||||
<div key={index} className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<div className="flex-1">
|
||||
<Label className="text-base font-medium">{item.name}</Label>
|
||||
<p className="text-sm text-gray-600 mt-1">{item.description}</p>
|
||||
{item.weight && (
|
||||
<p className="text-xs text-purple-600 mt-1">權重:{item.weight}%</p>
|
||||
)}
|
||||
{/* 進度條 */}
|
||||
<div className="mt-2">
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-gray-500 h-2 rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: `${((manualScoring.scores[item.name] || 0) / 10) * 100}%`
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-lg font-bold">
|
||||
{manualScoring.scores[item.name] || 0} / 10
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right ml-4">
|
||||
<span className="text-lg font-bold text-gray-800">
|
||||
{manualScoring.scores[item.name] || 0}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">/ 10</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5712,8 +6061,8 @@ export function CompetitionManagement() {
|
||||
})}
|
||||
className={`w-10 h-10 rounded-lg border-2 font-medium transition-all ${
|
||||
(manualScoring.scores[item.name] || 0) === score
|
||||
? 'bg-blue-600 text-white border-blue-600'
|
||||
: 'bg-white text-gray-700 border-gray-300 hover:border-blue-400 hover:bg-blue-50'
|
||||
? 'bg-gray-700 text-white border-gray-700'
|
||||
: 'bg-white text-gray-700 border-gray-300 hover:border-gray-500 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{score}
|
||||
|
@@ -13,6 +13,14 @@ interface Judge {
|
||||
id: string
|
||||
name: string
|
||||
specialty: string
|
||||
expertise?: string[]
|
||||
title?: string
|
||||
department?: string
|
||||
email?: string
|
||||
phone?: string
|
||||
organization?: string
|
||||
totalScores?: number
|
||||
completedScores?: number
|
||||
}
|
||||
|
||||
interface JudgeListDialogProps {
|
||||
@@ -54,44 +62,118 @@ export function JudgeListDialog({ open, onOpenChange, judges }: JudgeListDialogP
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{judges.map((judge) => (
|
||||
<Card key={judge.id} className="hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* 左側:頭像和資訊 */}
|
||||
<div className="flex items-center space-x-4">
|
||||
<Avatar className="w-12 h-12">
|
||||
<AvatarFallback className="text-sm font-semibold bg-gray-100">
|
||||
{judge.name.charAt(0)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">{judge.name}</h3>
|
||||
<p className="text-sm text-gray-600">{judge.specialty}</p>
|
||||
</div>
|
||||
</div>
|
||||
{judges.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Users className="w-12 h-12 mx-auto text-gray-400 mb-4" />
|
||||
<p className="text-gray-500">暫無評審數據</p>
|
||||
</div>
|
||||
) : (
|
||||
judges.map((judge) => (
|
||||
<Card key={judge.id} className="hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
{/* 左側:頭像和基本資訊 */}
|
||||
<div className="flex items-start space-x-4">
|
||||
<Avatar className="w-14 h-14">
|
||||
<AvatarFallback className="text-lg font-semibold bg-blue-100 text-blue-700">
|
||||
{judge.name.charAt(0)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-xl mb-1">{judge.name}</h3>
|
||||
<p className="text-sm text-gray-600 mb-2">{judge.specialty}</p>
|
||||
|
||||
{/* 職位和部門 */}
|
||||
{(judge.title && judge.title !== judge.name) || judge.department ? (
|
||||
<div className="flex items-center space-x-2 mb-3">
|
||||
{judge.title && judge.title !== judge.name && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{judge.title}
|
||||
</Badge>
|
||||
)}
|
||||
{judge.department && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{judge.department}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* 評審能力 */}
|
||||
{judge.expertise && judge.expertise.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<p className="text-sm font-medium text-gray-700 mb-2">專業能力</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{judge.expertise.map((skill, index) => (
|
||||
<Badge
|
||||
key={index}
|
||||
variant="outline"
|
||||
className="text-xs bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100"
|
||||
>
|
||||
{skill}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 額外資訊 */}
|
||||
<div className="space-y-1 text-sm text-gray-500">
|
||||
{judge.organization && (
|
||||
<p>🏢 {judge.organization}</p>
|
||||
)}
|
||||
{judge.email && (
|
||||
<p>📧 {judge.email}</p>
|
||||
)}
|
||||
{judge.phone && (
|
||||
<p>📞 {judge.phone}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右側:ID和複製按鈕 */}
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="bg-gray-100 px-3 py-1 rounded-lg">
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
ID: {judge.id}
|
||||
</span>
|
||||
{/* 評分進度 */}
|
||||
{judge.totalScores !== undefined && judge.completedScores !== undefined && (
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center justify-between text-sm mb-1">
|
||||
<span className="text-gray-600">評分進度</span>
|
||||
<span className="font-medium">
|
||||
{judge.completedScores}/{judge.totalScores}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: `${judge.totalScores > 0 ? (judge.completedScores / judge.totalScores) * 100 : 0}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右側:ID和操作按鈕 */}
|
||||
<div className="flex flex-col items-end space-y-3">
|
||||
<div className="bg-gray-100 px-4 py-2 rounded-lg">
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
ID: {judge.id}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => handleCopyJudgeId(judge.id, judge.name)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
<span>複製ID</span>
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => handleCopyJudgeId(judge.id, judge.name)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
<span>複製</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
@@ -40,6 +40,7 @@ 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)
|
||||
@@ -56,10 +57,51 @@ export function ScoringManagement() {
|
||||
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])
|
||||
|
||||
@@ -67,76 +109,80 @@ export function ScoringManagement() {
|
||||
|
||||
|
||||
|
||||
const loadScoringData = () => {
|
||||
const loadScoringData = async () => {
|
||||
if (!selectedCompetition) return
|
||||
|
||||
const participants = [
|
||||
...(selectedCompetition.participatingApps || []).map((appId: string) => {
|
||||
const app = mockIndividualApps.find(a => a.id === appId)
|
||||
return { id: appId, name: app?.name || `應用 ${appId}`, type: "individual" as const }
|
||||
}),
|
||||
...(selectedCompetition.participatingTeams || []).map((teamId: string) => {
|
||||
const team = initialTeams.find(t => t.id === teamId)
|
||||
return { id: teamId, name: team?.name || `團隊 ${teamId}`, type: "team" as const }
|
||||
})
|
||||
]
|
||||
|
||||
const records: ScoringRecord[] = []
|
||||
participants.forEach(participant => {
|
||||
selectedCompetition.judges.forEach((judgeId: string) => {
|
||||
const judge = judges.find(j => j.id === judgeId)
|
||||
if (!judge) return
|
||||
|
||||
const existingScore = judgeScores.find(score =>
|
||||
score.judgeId === judgeId && score.appId === participant.id
|
||||
)
|
||||
|
||||
if (existingScore) {
|
||||
records.push({
|
||||
id: `${judgeId}-${participant.id}`,
|
||||
judgeId, judgeName: judge.name,
|
||||
participantId: participant.id, participantName: participant.name,
|
||||
participantType: participant.type, scores: existingScore.scores,
|
||||
totalScore: calculateTotalScore(existingScore.scores, selectedCompetition.rules || []),
|
||||
comments: existingScore.comments,
|
||||
submittedAt: existingScore.submittedAt || new Date().toISOString(),
|
||||
status: "completed" as const,
|
||||
})
|
||||
} else {
|
||||
// 初始化評分項目
|
||||
const initialScores: Record<string, number> = {}
|
||||
if (selectedCompetition.rules && selectedCompetition.rules.length > 0) {
|
||||
selectedCompetition.rules.forEach((rule: any) => {
|
||||
initialScores[rule.name] = 0
|
||||
})
|
||||
} else {
|
||||
// 預設評分項目
|
||||
initialScores.innovation = 0
|
||||
initialScores.technical = 0
|
||||
initialScores.usability = 0
|
||||
initialScores.presentation = 0
|
||||
initialScores.impact = 0
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
records.push({
|
||||
id: `${judgeId}-${participant.id}`,
|
||||
judgeId, judgeName: judge.name,
|
||||
participantId: participant.id, participantName: participant.name,
|
||||
participantType: participant.type, scores: initialScores,
|
||||
totalScore: 0, comments: "", submittedAt: "",
|
||||
status: "pending" as const,
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
setScoringRecords(records)
|
||||
// 如果沒有動態評分,使用預設字段
|
||||
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 ? Math.round(values.reduce((a, b) => a + b, 0) / values.length) : 0
|
||||
return values.length > 0 ? values.reduce((a, b) => a + b, 0) : 0
|
||||
}
|
||||
|
||||
let totalScore = 0
|
||||
@@ -144,12 +190,12 @@ export function ScoringManagement() {
|
||||
|
||||
rules.forEach((rule: any) => {
|
||||
const score = scores[rule.name] || 0
|
||||
const weight = rule.weight || 1
|
||||
const weight = parseFloat(rule.weight) || 1
|
||||
totalScore += score * weight
|
||||
totalWeight += weight
|
||||
})
|
||||
|
||||
return totalWeight > 0 ? Math.round(totalScore / totalWeight) : 0
|
||||
return totalWeight > 0 ? totalScore / totalWeight : 0
|
||||
}
|
||||
|
||||
const getFilteredRecords = () => {
|
||||
@@ -170,8 +216,8 @@ export function ScoringManagement() {
|
||||
const handleManualScoring = () => {
|
||||
// 根據競賽規則初始化評分項目
|
||||
const initialScores: Record<string, number> = {}
|
||||
if (selectedCompetition?.rules && selectedCompetition.rules.length > 0) {
|
||||
selectedCompetition.rules.forEach((rule: any) => {
|
||||
if (competitionRules && competitionRules.length > 0) {
|
||||
competitionRules.forEach((rule: any) => {
|
||||
initialScores[rule.name] = 0
|
||||
})
|
||||
} else {
|
||||
@@ -192,13 +238,39 @@ export function ScoringManagement() {
|
||||
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: { ...record.scores },
|
||||
comments: record.comments,
|
||||
scores: initialScores,
|
||||
comments: record.comments || '',
|
||||
})
|
||||
setShowEditScoring(true)
|
||||
}
|
||||
@@ -211,12 +283,11 @@ export function ScoringManagement() {
|
||||
}
|
||||
|
||||
// 檢查所有評分項目是否都已評分
|
||||
const scoringRules = selectedCompetition?.rules || []
|
||||
const defaultRules = [
|
||||
{ name: "創新性" }, { name: "技術性" }, { name: "實用性" },
|
||||
{ name: "展示效果" }, { name: "影響力" }
|
||||
]
|
||||
const rules = scoringRules.length > 0 ? scoringRules : defaultRules
|
||||
const rules = competitionRules.length > 0 ? competitionRules : defaultRules
|
||||
|
||||
const hasAllScores = rules.every((rule: any) =>
|
||||
manualScoring.scores[rule.name] && manualScoring.scores[rule.name] > 0
|
||||
@@ -234,18 +305,54 @@ export function ScoringManagement() {
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
await submitJudgeScore({
|
||||
judgeId: manualScoring.judgeId,
|
||||
appId: manualScoring.participantId,
|
||||
scores: manualScoring.scores,
|
||||
comments: manualScoring.comments.trim(),
|
||||
// 轉換評分格式以符合API要求 - 使用動態規則
|
||||
const apiScores: Record<string, number> = {}
|
||||
rules.forEach((rule: any) => {
|
||||
apiScores[rule.name] = manualScoring.scores[rule.name] || 0
|
||||
})
|
||||
setSuccess(showEditScoring ? "評分更新成功!" : "評分提交成功!")
|
||||
loadScoringData()
|
||||
setShowManualScoring(false)
|
||||
setShowEditScoring(false)
|
||||
setSelectedRecord(null)
|
||||
|
||||
// 根據參賽者類型確定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)
|
||||
@@ -261,15 +368,244 @@ export function ScoringManagement() {
|
||||
}
|
||||
}
|
||||
|
||||
const getScoringProgress = () => {
|
||||
const total = scoringRecords.length
|
||||
const completed = scoringRecords.filter(r => r.status === "completed").length
|
||||
const pending = scoringRecords.filter(r => r.status === "pending").length
|
||||
const percentage = total > 0 ? Math.round((completed / total) * 100) : 0
|
||||
return { total, completed, pending, percentage }
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
const progress = getScoringProgress()
|
||||
// 當選擇競賽時載入統計數據
|
||||
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">
|
||||
@@ -297,7 +633,9 @@ export function ScoringManagement() {
|
||||
</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">
|
||||
@@ -338,7 +676,29 @@ export function ScoringManagement() {
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-blue-600">{progress.completed}</p>
|
||||
<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>
|
||||
@@ -346,24 +706,10 @@ export function ScoringManagement() {
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-orange-600">{progress.pending}</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">{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">{progress.total}</p>
|
||||
<p className="text-sm text-gray-600">總評分項目</p>
|
||||
<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>
|
||||
@@ -372,42 +718,54 @@ export function ScoringManagement() {
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>評分進度</span>
|
||||
<span>{progress.completed} / {progress.total}</span>
|
||||
<span>
|
||||
{scoringSummary ?
|
||||
`${scoringSummary.overallStats.completedScores} / ${scoringSummary.overallStats.totalPossibleScores}` :
|
||||
`${progress.completed} / ${progress.total}`
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={progress.percentage} className="h-2" />
|
||||
<Progress
|
||||
value={scoringSummary ? scoringSummary.overallStats.overallCompletionRate : progress.percentage}
|
||||
className="h-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-center">
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<ClipboardList className="w-5 h-5" />
|
||||
<span>評分管理</span>
|
||||
</CardTitle>
|
||||
<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>
|
||||
<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>
|
||||
@@ -443,25 +801,28 @@ export function ScoringManagement() {
|
||||
|
||||
<div className="space-y-6">
|
||||
{(() => {
|
||||
// 按評審分組
|
||||
// 按評審分組 (使用 judgeId 避免重名問題)
|
||||
const groupedByJudge = getFilteredRecords().reduce((groups, record) => {
|
||||
const judgeName = record.judgeName
|
||||
if (!groups[judgeName]) {
|
||||
groups[judgeName] = []
|
||||
const judgeId = record.judgeId
|
||||
if (!groups[judgeId]) {
|
||||
groups[judgeId] = []
|
||||
}
|
||||
groups[judgeName].push(record)
|
||||
groups[judgeId].push(record)
|
||||
return groups
|
||||
}, {} as Record<string, ScoringRecord[]>)
|
||||
|
||||
|
||||
|
||||
return Object.entries(groupedByJudge).map(([judgeName, records]) => {
|
||||
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={judgeName} className="border-l-4 border-l-blue-500">
|
||||
<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">
|
||||
@@ -513,7 +874,7 @@ export function ScoringManagement() {
|
||||
{records.length > 4 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const container = document.getElementById(`scroll-${judgeName}`)
|
||||
const container = document.getElementById(`scroll-${judgeId}`)
|
||||
if (container) {
|
||||
container.scrollLeft -= 280 // 滑動一個卡片的寬度
|
||||
}
|
||||
@@ -528,7 +889,7 @@ export function ScoringManagement() {
|
||||
{records.length > 4 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const container = document.getElementById(`scroll-${judgeName}`)
|
||||
const container = document.getElementById(`scroll-${judgeId}`)
|
||||
if (container) {
|
||||
container.scrollLeft += 280 // 滑動一個卡片的寬度
|
||||
}
|
||||
@@ -540,7 +901,7 @@ export function ScoringManagement() {
|
||||
)}
|
||||
|
||||
<div
|
||||
id={`scroll-${judgeName}`}
|
||||
id={`scroll-${judgeId}`}
|
||||
className="flex space-x-4 overflow-x-auto scrollbar-hide pb-2 scroll-smooth"
|
||||
style={{
|
||||
scrollbarWidth: 'none',
|
||||
@@ -573,8 +934,8 @@ export function ScoringManagement() {
|
||||
<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">{record.totalScore}</span>
|
||||
<span className="text-gray-500 text-sm">/ 10</span>
|
||||
<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">
|
||||
@@ -662,11 +1023,27 @@ export function ScoringManagement() {
|
||||
<SelectValue placeholder="選擇評審" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{judges.map((judge) => (
|
||||
<SelectItem key={judge.id} value={judge.id}>
|
||||
{judge.name}
|
||||
{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>
|
||||
@@ -681,16 +1058,15 @@ export function ScoringManagement() {
|
||||
<SelectValue placeholder="選擇參賽者" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[
|
||||
...(selectedCompetition?.participatingApps || []).map((appId: string) => {
|
||||
const app = mockIndividualApps.find(a => a.id === appId)
|
||||
return { id: appId, name: app?.name || `應用 ${appId}`, type: "individual" }
|
||||
}),
|
||||
...(selectedCompetition?.participatingTeams || []).map((teamId: string) => {
|
||||
const team = initialTeams.find(t => t.id === teamId)
|
||||
return { id: teamId, name: team?.name || `團隊 ${teamId}`, type: "team" }
|
||||
})
|
||||
].map((participant) => (
|
||||
{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" ? (
|
||||
@@ -698,13 +1074,23 @@ export function ScoringManagement() {
|
||||
) : (
|
||||
<Users className="w-4 h-4 text-green-600" />
|
||||
)}
|
||||
<span>{participant.name}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{participant.type === "individual" ? "個人" : "團隊"}
|
||||
</Badge>
|
||||
<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>
|
||||
@@ -714,7 +1100,6 @@ export function ScoringManagement() {
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">評分項目</h3>
|
||||
{(() => {
|
||||
const scoringRules = selectedCompetition?.rules || []
|
||||
const defaultRules = [
|
||||
{ name: "創新性", description: "創新程度和獨特性", weight: 25 },
|
||||
{ name: "技術性", description: "技術實現的複雜度和品質", weight: 30 },
|
||||
@@ -723,7 +1108,7 @@ export function ScoringManagement() {
|
||||
{ name: "影響力", description: "對行業或社會的潛在影響", weight: 10 }
|
||||
]
|
||||
|
||||
const rules = scoringRules.length > 0 ? scoringRules : defaultRules
|
||||
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">
|
||||
@@ -776,9 +1161,9 @@ export function ScoringManagement() {
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-4xl font-bold text-blue-600">
|
||||
{calculateTotalScore(manualScoring.scores, selectedCompetition?.rules || [])}
|
||||
{Math.round(calculateTotalScore(manualScoring.scores, competitionRules) * 10)}
|
||||
</span>
|
||||
<span className="text-xl text-gray-500 font-medium">/ 10</span>
|
||||
<span className="text-xl text-gray-500 font-medium">/ 100</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -842,15 +1227,17 @@ export function ScoringManagement() {
|
||||
<JudgeListDialog
|
||||
open={showJudgeList}
|
||||
onOpenChange={setShowJudgeList}
|
||||
judges={selectedCompetition ?
|
||||
judges
|
||||
.filter(judge => selectedCompetition.judges.includes(judge.id))
|
||||
.map(judge => ({
|
||||
id: judge.id,
|
||||
name: judge.name,
|
||||
specialty: "評審專家"
|
||||
})) : []
|
||||
}
|
||||
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>
|
||||
)
|
||||
|
Reference in New Issue
Block a user