Initial commit

This commit is contained in:
2025-09-25 12:30:25 +08:00
commit 2765d9df54
100 changed files with 16023 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
export default function Loading() {
return null
}

View File

@@ -0,0 +1,430 @@
"use client"
import { useState, useEffect } from "react"
import { ProtectedRoute } from "@/components/protected-route"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Progress } from "@/components/ui/progress"
import { Badge } from "@/components/ui/badge"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Brain, Lightbulb, BarChart3, ArrowLeft, TrendingUp, Users, Award, Target } from "lucide-react"
import Link from "next/link"
import { useAuth, type User } from "@/lib/hooks/use-auth"
interface DepartmentStats {
department: string
totalUsers: number
participatedUsers: number
participationRate: number
averageLogicScore: number
averageCreativeScore: number
averageCombinedScore: number
overallAverage: number
topPerformer: string | null
testCounts: {
logic: number
creative: number
combined: number
}
}
interface TestResult {
userId: string
userName: string
userDepartment: string
type: "logic" | "creative" | "combined"
score: number
completedAt: string
}
export default function AnalyticsPage() {
return (
<ProtectedRoute adminOnly>
<AnalyticsContent />
</ProtectedRoute>
)
}
function AnalyticsContent() {
const { user } = useAuth()
const [departmentStats, setDepartmentStats] = useState<DepartmentStats[]>([])
const [selectedDepartment, setSelectedDepartment] = useState("all")
const [overallStats, setOverallStats] = useState({
totalUsers: 0,
totalParticipants: 0,
overallParticipationRate: 0,
averageScore: 0,
totalTests: 0,
})
const departments = ["人力資源部", "資訊技術部", "財務部", "行銷部", "業務部", "研發部", "客服部", "其他"]
useEffect(() => {
loadAnalyticsData()
}, [])
const loadAnalyticsData = () => {
// Load users
const users: User[] = JSON.parse(localStorage.getItem("hr_users") || "[]")
// Load all test results
const allResults: TestResult[] = []
users.forEach((user: User) => {
// Check for logic test results
const logicKey = `logicTestResults_${user.id}`
const logicResults = localStorage.getItem(logicKey)
if (logicResults) {
const data = JSON.parse(logicResults)
allResults.push({
userId: user.id,
userName: user.name,
userDepartment: user.department,
type: "logic",
score: data.score,
completedAt: data.completedAt,
})
}
// Check for creative test results
const creativeKey = `creativeTestResults_${user.id}`
const creativeResults = localStorage.getItem(creativeKey)
if (creativeResults) {
const data = JSON.parse(creativeResults)
allResults.push({
userId: user.id,
userName: user.name,
userDepartment: user.department,
type: "creative",
score: data.score,
completedAt: data.completedAt,
})
}
// Check for combined test results
const combinedKey = `combinedTestResults_${user.id}`
const combinedResults = localStorage.getItem(combinedKey)
if (combinedResults) {
const data = JSON.parse(combinedResults)
allResults.push({
userId: user.id,
userName: user.name,
userDepartment: user.department,
type: "combined",
score: data.overallScore,
completedAt: data.completedAt,
})
}
})
// Calculate department statistics
const deptStats: DepartmentStats[] = departments
.map((dept) => {
const deptUsers = users.filter((u) => u.department === dept)
const deptResults = allResults.filter((r) => r.userDepartment === dept)
const participatedUsers = new Set(deptResults.map((r) => r.userId)).size
// Calculate average scores by test type
const logicResults = deptResults.filter((r) => r.type === "logic")
const creativeResults = deptResults.filter((r) => r.type === "creative")
const combinedResults = deptResults.filter((r) => r.type === "combined")
const averageLogicScore =
logicResults.length > 0
? Math.round(logicResults.reduce((sum, r) => sum + r.score, 0) / logicResults.length)
: 0
const averageCreativeScore =
creativeResults.length > 0
? Math.round(creativeResults.reduce((sum, r) => sum + r.score, 0) / creativeResults.length)
: 0
const averageCombinedScore =
combinedResults.length > 0
? Math.round(combinedResults.reduce((sum, r) => sum + r.score, 0) / combinedResults.length)
: 0
// Calculate overall average
const allScores = [averageLogicScore, averageCreativeScore, averageCombinedScore].filter((s) => s > 0)
const overallAverage =
allScores.length > 0 ? Math.round(allScores.reduce((sum, s) => sum + s, 0) / allScores.length) : 0
// Find top performer
const userScores = new Map<string, number[]>()
deptResults.forEach((result) => {
if (!userScores.has(result.userId)) {
userScores.set(result.userId, [])
}
userScores.get(result.userId)!.push(result.score)
})
let topPerformer: string | null = null
let topScore = 0
userScores.forEach((scores, userId) => {
const avgScore = scores.reduce((sum, s) => sum + s, 0) / scores.length
if (avgScore > topScore) {
topScore = avgScore
const user = users.find((u) => u.id === userId)
topPerformer = user ? user.name : null
}
})
return {
department: dept,
totalUsers: deptUsers.length,
participatedUsers,
participationRate: deptUsers.length > 0 ? Math.round((participatedUsers / deptUsers.length) * 100) : 0,
averageLogicScore,
averageCreativeScore,
averageCombinedScore,
overallAverage,
topPerformer,
testCounts: {
logic: logicResults.length,
creative: creativeResults.length,
combined: combinedResults.length,
},
}
})
.filter((stat) => stat.totalUsers > 0) // Only show departments with users
setDepartmentStats(deptStats)
// Calculate overall statistics
const totalUsers = users.length
const totalParticipants = new Set(allResults.map((r) => r.userId)).size
const overallParticipationRate = totalUsers > 0 ? Math.round((totalParticipants / totalUsers) * 100) : 0
const averageScore =
allResults.length > 0 ? Math.round(allResults.reduce((sum, r) => sum + r.score, 0) / allResults.length) : 0
setOverallStats({
totalUsers,
totalParticipants,
overallParticipationRate,
averageScore,
totalTests: allResults.length,
})
}
const getScoreColor = (score: number) => {
if (score >= 90) return "text-green-600"
if (score >= 80) return "text-blue-600"
if (score >= 70) return "text-yellow-600"
if (score >= 60) return "text-orange-600"
return "text-red-600"
}
const getParticipationColor = (rate: number) => {
if (rate >= 80) return "text-green-600"
if (rate >= 60) return "text-blue-600"
if (rate >= 40) return "text-yellow-600"
return "text-red-600"
}
const filteredStats =
selectedDepartment === "all"
? departmentStats
: departmentStats.filter((stat) => stat.department === selectedDepartment)
return (
<div className="min-h-screen bg-background">
{/* Header */}
<header className="border-b bg-card/50 backdrop-blur-sm">
<div className="container mx-auto px-4 py-4">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" asChild>
<Link href="/dashboard">
<ArrowLeft className="w-4 h-4 mr-2" />
</Link>
</Button>
<div>
<h1 className="text-xl font-bold text-foreground"></h1>
<p className="text-sm text-muted-foreground"></p>
</div>
</div>
</div>
</header>
<div className="container mx-auto px-4 py-8">
<div className="max-w-7xl mx-auto space-y-8">
{/* Overall Statistics */}
<div className="grid grid-cols-1 md:grid-cols-5 gap-6">
<Card>
<CardContent className="p-6 text-center">
<div className="w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center mx-auto mb-3">
<Users className="w-6 h-6 text-primary" />
</div>
<div className="text-2xl font-bold text-foreground mb-1">{overallStats.totalUsers}</div>
<div className="text-sm text-muted-foreground"></div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6 text-center">
<div className="w-12 h-12 bg-blue-500/10 rounded-full flex items-center justify-center mx-auto mb-3">
<Target className="w-6 h-6 text-blue-500" />
</div>
<div className="text-2xl font-bold text-foreground mb-1">{overallStats.totalParticipants}</div>
<div className="text-sm text-muted-foreground"></div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6 text-center">
<div className="w-12 h-12 bg-green-500/10 rounded-full flex items-center justify-center mx-auto mb-3">
<TrendingUp className="w-6 h-6 text-green-500" />
</div>
<div className="text-2xl font-bold text-foreground mb-1">{overallStats.overallParticipationRate}%</div>
<div className="text-sm text-muted-foreground"></div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6 text-center">
<div className="w-12 h-12 bg-accent/10 rounded-full flex items-center justify-center mx-auto mb-3">
<Award className="w-6 h-6 text-accent" />
</div>
<div className="text-2xl font-bold text-foreground mb-1">{overallStats.averageScore}</div>
<div className="text-sm text-muted-foreground"></div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6 text-center">
<div className="w-12 h-12 bg-purple-500/10 rounded-full flex items-center justify-center mx-auto mb-3">
<BarChart3 className="w-6 h-6 text-purple-500" />
</div>
<div className="text-2xl font-bold text-foreground mb-1">{overallStats.totalTests}</div>
<div className="text-sm text-muted-foreground"></div>
</CardContent>
</Card>
</div>
{/* Department Filter */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="max-w-xs">
<Select value={selectedDepartment} onValueChange={setSelectedDepartment}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{departments.map((dept) => (
<SelectItem key={dept} value={dept}>
{dept}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* Department Statistics */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{filteredStats.map((stat) => (
<Card key={stat.department} className="border-2">
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>{stat.department}</span>
<Badge variant="outline">
{stat.participatedUsers}/{stat.totalUsers}
</Badge>
</CardTitle>
<CardDescription>
<span className={getParticipationColor(stat.participationRate)}>{stat.participationRate}%</span>
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Participation Rate */}
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium"></span>
<span className="text-sm font-medium">{stat.participationRate}%</span>
</div>
<Progress value={stat.participationRate} className="h-2" />
</div>
{/* Test Scores */}
<div className="grid grid-cols-3 gap-4">
<div className="text-center">
<div className="w-10 h-10 bg-primary/10 rounded-full flex items-center justify-center mx-auto mb-2">
<Brain className="w-5 h-5 text-primary" />
</div>
<div className={`text-lg font-bold ${getScoreColor(stat.averageLogicScore)}`}>
{stat.averageLogicScore || "-"}
</div>
<div className="text-xs text-muted-foreground"></div>
<div className="text-xs text-muted-foreground">({stat.testCounts.logic} )</div>
</div>
<div className="text-center">
<div className="w-10 h-10 bg-accent/10 rounded-full flex items-center justify-center mx-auto mb-2">
<Lightbulb className="w-5 h-5 text-accent" />
</div>
<div className={`text-lg font-bold ${getScoreColor(stat.averageCreativeScore)}`}>
{stat.averageCreativeScore || "-"}
</div>
<div className="text-xs text-muted-foreground"></div>
<div className="text-xs text-muted-foreground">({stat.testCounts.creative} )</div>
</div>
<div className="text-center">
<div className="w-10 h-10 bg-gradient-to-r from-primary to-accent rounded-full flex items-center justify-center mx-auto mb-2">
<BarChart3 className="w-5 h-5 text-white" />
</div>
<div className={`text-lg font-bold ${getScoreColor(stat.averageCombinedScore)}`}>
{stat.averageCombinedScore || "-"}
</div>
<div className="text-xs text-muted-foreground"></div>
<div className="text-xs text-muted-foreground">({stat.testCounts.combined} )</div>
</div>
</div>
{/* Overall Average */}
<div className="border-t pt-4">
<div className="flex justify-between items-center">
<span className="font-medium"></span>
<span className={`text-xl font-bold ${getScoreColor(stat.overallAverage)}`}>
{stat.overallAverage || "-"}
</span>
</div>
</div>
{/* Top Performer */}
{stat.topPerformer && (
<div className="border-t pt-4">
<div className="flex justify-between items-center">
<span className="font-medium"></span>
<Badge className="bg-yellow-500 text-white">
<Award className="w-3 h-3 mr-1" />
{stat.topPerformer}
</Badge>
</div>
</div>
)}
</CardContent>
</Card>
))}
</div>
{filteredStats.length === 0 && (
<Card>
<CardContent className="p-8 text-center">
<div className="text-muted-foreground">
{selectedDepartment === "all" ? "暫無部門數據" : "該部門暫無數據"}
</div>
</CardContent>
</Card>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,354 @@
"use client"
import type React from "react"
import { useState } from "react"
import { ProtectedRoute } from "@/components/protected-route"
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Badge } from "@/components/ui/badge"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import {
ArrowLeft,
Upload,
Download,
Brain,
Lightbulb,
FileSpreadsheet,
CheckCircle,
AlertCircle,
Info,
} from "lucide-react"
import Link from "next/link"
import { logicQuestions } from "@/lib/questions/logic-questions"
import { creativeQuestions } from "@/lib/questions/creative-questions"
import { parseExcelFile, type ImportResult } from "@/lib/utils/excel-parser"
export default function QuestionsManagementPage() {
return (
<ProtectedRoute adminOnly>
<QuestionsManagementContent />
</ProtectedRoute>
)
}
function QuestionsManagementContent() {
const [activeTab, setActiveTab] = useState("logic")
const [isImporting, setIsImporting] = useState(false)
const [importResult, setImportResult] = useState<ImportResult | null>(null)
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [importType, setImportType] = useState<"logic" | "creative">("logic")
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (file) {
if (
file.type === "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" ||
file.type === "application/vnd.ms-excel" ||
file.name.endsWith(".xlsx") ||
file.name.endsWith(".xls")
) {
setSelectedFile(file)
setImportResult(null)
} else {
setImportResult({
success: false,
message: "請選擇 Excel 檔案 (.xlsx 或 .xls)",
})
}
}
}
const handleImport = async () => {
if (!selectedFile) {
setImportResult({
success: false,
message: "請先選擇要匯入的 Excel 檔案",
})
return
}
setIsImporting(true)
try {
const result = await parseExcelFile(selectedFile, importType)
setImportResult(result)
if (result.success) {
setSelectedFile(null)
// 重置檔案輸入
const fileInput = document.getElementById("file-input") as HTMLInputElement
if (fileInput) fileInput.value = ""
}
} catch (error) {
setImportResult({
success: false,
message: "匯入失敗,請檢查檔案格式是否正確",
errors: [error instanceof Error ? error.message : "未知錯誤"],
})
} finally {
setIsImporting(false)
}
}
const downloadTemplate = (type: "logic" | "creative") => {
let csvContent = ""
if (type === "logic") {
csvContent = [
["題目ID", "題目內容", "選項A", "選項B", "選項C", "選項D", "正確答案", "解釋"],
[
"1",
"範例題目如果所有A都是B所有B都是C那麼",
"所有A都是C",
"所有C都是A",
"有些A不是C",
"無法確定",
"A",
"根據邏輯推理...",
],
["2", "在序列 2, 4, 8, 16, ? 中,下一個數字是?", "24", "32", "30", "28", "B", "每個數字都是前一個數字的2倍"],
]
.map((row) => row.join(","))
.join("\n")
} else {
csvContent = [
["題目ID", "陳述內容", "類別", "是否反向計分"],
["1", "我經常能想出創新的解決方案", "innovation", "否"],
["2", "我更喜歡按照既定規則工作", "flexibility", "是"],
["3", "我喜歡嘗試新的做事方法", "innovation", "否"],
]
.map((row) => row.join(","))
.join("\n")
}
const blob = new Blob(["\uFEFF" + csvContent], { type: "text/csv;charset=utf-8;" })
const link = document.createElement("a")
const url = URL.createObjectURL(blob)
link.setAttribute("href", url)
link.setAttribute("download", `${type === "logic" ? "邏輯思維" : "創意能力"}題目範本.csv`)
link.style.visibility = "hidden"
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
return (
<div className="min-h-screen bg-background">
{/* Header */}
<header className="border-b bg-card/50 backdrop-blur-sm">
<div className="container mx-auto px-4 py-4">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" asChild>
<Link href="/dashboard">
<ArrowLeft className="w-4 h-4 mr-2" />
</Link>
</Button>
<div>
<h1 className="text-xl font-bold text-foreground"></h1>
<p className="text-sm text-muted-foreground"></p>
</div>
</div>
</div>
</header>
<div className="container mx-auto px-4 py-8">
<div className="max-w-6xl mx-auto space-y-8">
{/* Import Section */}
<Card className="border-2 border-primary/20">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Upload className="w-5 h-5 text-primary" />
Excel
</CardTitle>
<CardDescription> Excel </CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* File Upload */}
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="file-input"> Excel </Label>
<Input
id="file-input"
type="file"
accept=".xlsx,.xls"
onChange={handleFileSelect}
className="cursor-pointer"
/>
</div>
<div className="space-y-2">
<Label></Label>
<Select value={importType} onValueChange={(value: "logic" | "creative") => setImportType(value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="logic"></SelectItem>
<SelectItem value="creative"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
{selectedFile && (
<div className="flex items-center gap-2 p-3 bg-blue-50 rounded-lg border border-blue-200">
<FileSpreadsheet className="w-4 h-4 text-blue-600" />
<span className="text-sm text-blue-800">{selectedFile.name}</span>
</div>
)}
<div className="flex gap-3">
<Button onClick={handleImport} disabled={!selectedFile || isImporting} className="flex-1">
{isImporting ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2" />
...
</>
) : (
<>
<Upload className="w-4 h-4 mr-2" />
</>
)}
</Button>
</div>
</div>
{/* Import Result */}
{importResult && (
<Alert variant={importResult.success ? "default" : "destructive"}>
{importResult.success ? <CheckCircle className="h-4 w-4" /> : <AlertCircle className="h-4 w-4" />}
<AlertDescription>
{importResult.message}
{importResult.errors && (
<ul className="mt-2 list-disc list-inside">
{importResult.errors.map((error, index) => (
<li key={index} className="text-sm">
{error}
</li>
))}
</ul>
)}
</AlertDescription>
</Alert>
)}
{/* Template Download */}
<div className="border-t pt-4">
<div className="flex items-center gap-2 mb-3">
<Info className="w-4 h-4 text-blue-500" />
<span className="text-sm font-medium"></span>
</div>
<div className="flex gap-3">
<Button variant="outline" onClick={() => downloadTemplate("logic")} className="flex-1">
<Download className="w-4 h-4 mr-2" />
</Button>
<Button variant="outline" onClick={() => downloadTemplate("creative")} className="flex-1">
<Download className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
</CardContent>
</Card>
{/* Current Questions */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="logic" className="flex items-center gap-2">
<Brain className="w-4 h-4" />
({logicQuestions.length})
</TabsTrigger>
<TabsTrigger value="creative" className="flex items-center gap-2">
<Lightbulb className="w-4 h-4" />
({creativeQuestions.length})
</TabsTrigger>
</TabsList>
<TabsContent value="logic" className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-semibold"></h3>
<Badge variant="outline">{logicQuestions.length} </Badge>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{logicQuestions.slice(0, 10).map((question) => (
<TableRow key={question.id}>
<TableCell className="font-medium">{question.id}</TableCell>
<TableCell className="max-w-md truncate">{question.question}</TableCell>
<TableCell>{question.options.length}</TableCell>
<TableCell>
<Badge className="bg-green-500 text-white">{question.correctAnswer}</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TabsContent>
<TabsContent value="creative" className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-semibold"></h3>
<Badge variant="outline">{creativeQuestions.length} </Badge>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{creativeQuestions.slice(0, 10).map((question) => (
<TableRow key={question.id}>
<TableCell className="font-medium">{question.id}</TableCell>
<TableCell className="max-w-md truncate">{question.statement}</TableCell>
<TableCell>
<Badge variant="secondary">{question.category}</Badge>
</TableCell>
<TableCell>
{question.isReverse ? (
<Badge className="bg-orange-500 text-white"></Badge>
) : (
<Badge variant="outline"></Badge>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TabsContent>
</Tabs>
</CardContent>
</Card>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,3 @@
export default function Loading() {
return null
}

421
app/admin/results/page.tsx Normal file
View File

@@ -0,0 +1,421 @@
"use client"
import { useState, useEffect } from "react"
import { ProtectedRoute } from "@/components/protected-route"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Badge } from "@/components/ui/badge"
import { Brain, Lightbulb, BarChart3, ArrowLeft, Search, Download, Filter } from "lucide-react"
import Link from "next/link"
import { useAuth, type User } from "@/lib/hooks/use-auth"
interface TestResult {
userId: string
userName: string
userDepartment: string
type: "logic" | "creative" | "combined"
score: number
completedAt: string
details?: any
}
export default function AdminResultsPage() {
return (
<ProtectedRoute adminOnly>
<AdminResultsContent />
</ProtectedRoute>
)
}
function AdminResultsContent() {
const { user } = useAuth()
const [results, setResults] = useState<TestResult[]>([])
const [filteredResults, setFilteredResults] = useState<TestResult[]>([])
const [users, setUsers] = useState<User[]>([])
const [searchTerm, setSearchTerm] = useState("")
const [departmentFilter, setDepartmentFilter] = useState("all")
const [testTypeFilter, setTestTypeFilter] = useState("all")
const [stats, setStats] = useState({
totalResults: 0,
averageScore: 0,
totalUsers: 0,
completionRate: 0,
})
const departments = ["人力資源部", "資訊技術部", "財務部", "行銷部", "業務部", "研發部", "客服部", "其他"]
useEffect(() => {
loadData()
}, [])
useEffect(() => {
filterResults()
}, [results, searchTerm, departmentFilter, testTypeFilter])
const loadData = () => {
// Load users
const usersData = JSON.parse(localStorage.getItem("hr_users") || "[]")
setUsers(usersData)
// Load all test results
const allResults: TestResult[] = []
usersData.forEach((user: User) => {
// Check for logic test results
const logicKey = `logicTestResults_${user.id}`
const logicResults = localStorage.getItem(logicKey)
if (logicResults) {
const data = JSON.parse(logicResults)
allResults.push({
userId: user.id,
userName: user.name,
userDepartment: user.department,
type: "logic",
score: data.score,
completedAt: data.completedAt,
details: data,
})
}
// Check for creative test results
const creativeKey = `creativeTestResults_${user.id}`
const creativeResults = localStorage.getItem(creativeKey)
if (creativeResults) {
const data = JSON.parse(creativeResults)
allResults.push({
userId: user.id,
userName: user.name,
userDepartment: user.department,
type: "creative",
score: data.score,
completedAt: data.completedAt,
details: data,
})
}
// Check for combined test results
const combinedKey = `combinedTestResults_${user.id}`
const combinedResults = localStorage.getItem(combinedKey)
if (combinedResults) {
const data = JSON.parse(combinedResults)
allResults.push({
userId: user.id,
userName: user.name,
userDepartment: user.department,
type: "combined",
score: data.overallScore,
completedAt: data.completedAt,
details: data,
})
}
})
// Sort by completion date (newest first)
allResults.sort((a, b) => new Date(b.completedAt).getTime() - new Date(a.completedAt).getTime())
setResults(allResults)
// Calculate statistics
const totalResults = allResults.length
const averageScore =
totalResults > 0 ? Math.round(allResults.reduce((sum, r) => sum + r.score, 0) / totalResults) : 0
const totalUsers = usersData.length
const usersWithResults = new Set(allResults.map((r) => r.userId)).size
const completionRate = totalUsers > 0 ? Math.round((usersWithResults / totalUsers) * 100) : 0
setStats({
totalResults,
averageScore,
totalUsers,
completionRate,
})
}
const filterResults = () => {
let filtered = results
// Filter by search term (user name)
if (searchTerm) {
filtered = filtered.filter((result) => result.userName.toLowerCase().includes(searchTerm.toLowerCase()))
}
// Filter by department
if (departmentFilter !== "all") {
filtered = filtered.filter((result) => result.userDepartment === departmentFilter)
}
// Filter by test type
if (testTypeFilter !== "all") {
filtered = filtered.filter((result) => result.type === testTypeFilter)
}
setFilteredResults(filtered)
}
const getTestTypeInfo = (type: string) => {
switch (type) {
case "logic":
return {
name: "邏輯思維",
icon: Brain,
color: "bg-primary",
textColor: "text-primary",
}
case "creative":
return {
name: "創意能力",
icon: Lightbulb,
color: "bg-accent",
textColor: "text-accent",
}
case "combined":
return {
name: "綜合能力",
icon: BarChart3,
color: "bg-gradient-to-r from-primary to-accent",
textColor: "text-primary",
}
default:
return {
name: "未知",
icon: BarChart3,
color: "bg-muted",
textColor: "text-muted-foreground",
}
}
}
const getScoreLevel = (score: number) => {
if (score >= 90) return { level: "優秀", color: "bg-green-500" }
if (score >= 80) return { level: "良好", color: "bg-blue-500" }
if (score >= 70) return { level: "中等", color: "bg-yellow-500" }
if (score >= 60) return { level: "及格", color: "bg-orange-500" }
return { level: "不及格", color: "bg-red-500" }
}
const exportResults = () => {
const csvContent = [
["姓名", "部門", "測試類型", "分數", "等級", "完成時間"],
...filteredResults.map((result) => [
result.userName,
result.userDepartment,
getTestTypeInfo(result.type).name,
result.score.toString(),
getScoreLevel(result.score).level,
new Date(result.completedAt).toLocaleString("zh-TW"),
]),
]
.map((row) => row.join(","))
.join("\n")
const blob = new Blob(["\uFEFF" + csvContent], { type: "text/csv;charset=utf-8;" })
const link = document.createElement("a")
const url = URL.createObjectURL(blob)
link.setAttribute("href", url)
link.setAttribute("download", `測試結果_${new Date().toISOString().split("T")[0]}.csv`)
link.style.visibility = "hidden"
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
return (
<div className="min-h-screen bg-background">
{/* Header */}
<header className="border-b bg-card/50 backdrop-blur-sm">
<div className="container mx-auto px-4 py-4">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" asChild>
<Link href="/dashboard">
<ArrowLeft className="w-4 h-4 mr-2" />
</Link>
</Button>
<div>
<h1 className="text-xl font-bold text-foreground"></h1>
<p className="text-sm text-muted-foreground"></p>
</div>
</div>
</div>
</header>
<div className="container mx-auto px-4 py-8">
<div className="max-w-7xl mx-auto space-y-8">
{/* Statistics Overview */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<Card>
<CardContent className="p-6 text-center">
<div className="w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center mx-auto mb-3">
<BarChart3 className="w-6 h-6 text-primary" />
</div>
<div className="text-2xl font-bold text-foreground mb-1">{stats.totalResults}</div>
<div className="text-sm text-muted-foreground"></div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6 text-center">
<div className="w-12 h-12 bg-blue-500/10 rounded-full flex items-center justify-center mx-auto mb-3">
<Brain className="w-6 h-6 text-blue-500" />
</div>
<div className="text-2xl font-bold text-foreground mb-1">{stats.averageScore}</div>
<div className="text-sm text-muted-foreground"></div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6 text-center">
<div className="w-12 h-12 bg-green-500/10 rounded-full flex items-center justify-center mx-auto mb-3">
<Lightbulb className="w-6 h-6 text-green-500" />
</div>
<div className="text-2xl font-bold text-foreground mb-1">{stats.totalUsers}</div>
<div className="text-sm text-muted-foreground"></div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6 text-center">
<div className="w-12 h-12 bg-accent/10 rounded-full flex items-center justify-center mx-auto mb-3">
<BarChart3 className="w-6 h-6 text-accent" />
</div>
<div className="text-2xl font-bold text-foreground mb-1">{stats.completionRate}%</div>
<div className="text-sm text-muted-foreground"></div>
</CardContent>
</Card>
</div>
{/* Filters */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Filter className="w-5 h-5" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="輸入用戶姓名"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<Select value={departmentFilter} onValueChange={setDepartmentFilter}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{departments.map((dept) => (
<SelectItem key={dept} value={dept}>
{dept}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<Select value={testTypeFilter} onValueChange={setTestTypeFilter}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="logic"></SelectItem>
<SelectItem value="creative"></SelectItem>
<SelectItem value="combined"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<Button onClick={exportResults} className="w-full">
<Download className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
</CardContent>
</Card>
{/* Results Table */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
{filteredResults.length} {results.length}
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredResults.map((result, index) => {
const testInfo = getTestTypeInfo(result.type)
const scoreLevel = getScoreLevel(result.score)
const Icon = testInfo.icon
return (
<TableRow key={index}>
<TableCell>
<div className="font-medium">{result.userName}</div>
</TableCell>
<TableCell>{result.userDepartment}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<div className={`w-8 h-8 ${testInfo.color} rounded-lg flex items-center justify-center`}>
<Icon className="w-4 h-4 text-white" />
</div>
<span className={testInfo.textColor}>{testInfo.name}</span>
</div>
</TableCell>
<TableCell>
<div className="text-lg font-bold">{result.score}</div>
</TableCell>
<TableCell>
<Badge className={`${scoreLevel.color} text-white`}>{scoreLevel.level}</Badge>
</TableCell>
<TableCell>
<div className="text-sm">{new Date(result.completedAt).toLocaleString("zh-TW")}</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
{filteredResults.length === 0 && (
<div className="text-center py-8">
<div className="text-muted-foreground"></div>
</div>
)}
</CardContent>
</Card>
</div>
</div>
</div>
)
}

452
app/admin/users/page.tsx Normal file
View File

@@ -0,0 +1,452 @@
"use client"
import { useState, useEffect } from "react"
import { ProtectedRoute } from "@/components/protected-route"
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Badge } from "@/components/ui/badge"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { Plus, Edit, Trash2, ArrowLeft } from "lucide-react"
import Link from "next/link"
import { useAuth, type User } from "@/lib/hooks/use-auth"
export default function UsersManagementPage() {
return (
<ProtectedRoute adminOnly>
<UsersManagementContent />
</ProtectedRoute>
)
}
function UsersManagementContent() {
const { user: currentUser } = useAuth()
const [users, setUsers] = useState<(User & { password?: string })[]>([])
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
const [editingUser, setEditingUser] = useState<User | null>(null)
const [newUser, setNewUser] = useState({
name: "",
email: "",
password: "",
department: "",
role: "user" as "user" | "admin",
})
const [error, setError] = useState("")
const departments = ["人力資源部", "資訊技術部", "財務部", "行銷部", "業務部", "研發部", "客服部", "其他"]
useEffect(() => {
loadUsers()
}, [])
const loadUsers = () => {
const usersData = JSON.parse(localStorage.getItem("hr_users") || "[]")
setUsers(usersData)
}
const handleAddUser = () => {
setError("")
if (!newUser.name || !newUser.email || !newUser.password || !newUser.department) {
setError("請填寫所有必填欄位")
return
}
if (users.some((u) => u.email === newUser.email)) {
setError("該電子郵件已被使用")
return
}
const user = {
...newUser,
id: `user-${Date.now()}`,
createdAt: new Date().toISOString(),
}
const updatedUsers = [...users, user]
setUsers(updatedUsers)
localStorage.setItem("hr_users", JSON.stringify(updatedUsers))
setNewUser({
name: "",
email: "",
password: "",
department: "",
role: "user",
})
setIsAddDialogOpen(false)
}
const handleEditUser = (user: User) => {
setEditingUser(user)
setNewUser({
name: user.name,
email: user.email,
password: "",
department: user.department,
role: user.role,
})
}
const handleUpdateUser = () => {
if (!editingUser) return
setError("")
if (!newUser.name || !newUser.email || !newUser.department) {
setError("請填寫所有必填欄位")
return
}
if (users.some((u) => u.email === newUser.email && u.id !== editingUser.id)) {
setError("該電子郵件已被使用")
return
}
const updatedUsers = users.map((u) =>
u.id === editingUser.id
? {
...u,
name: newUser.name,
email: newUser.email,
department: newUser.department,
role: newUser.role,
...(newUser.password && { password: newUser.password }),
}
: u,
)
setUsers(updatedUsers)
localStorage.setItem("hr_users", JSON.stringify(updatedUsers))
setEditingUser(null)
setNewUser({
name: "",
email: "",
password: "",
department: "",
role: "user",
})
}
const handleDeleteUser = (userId: string) => {
if (userId === currentUser?.id) {
setError("無法刪除自己的帳戶")
return
}
const updatedUsers = users.filter((u) => u.id !== userId)
setUsers(updatedUsers)
localStorage.setItem("hr_users", JSON.stringify(updatedUsers))
}
return (
<div className="min-h-screen bg-background">
{/* Header */}
<header className="border-b bg-card/50 backdrop-blur-sm">
<div className="container mx-auto px-4 py-4">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" asChild>
<Link href="/dashboard">
<ArrowLeft className="w-4 h-4 mr-2" />
</Link>
</Button>
<div>
<h1 className="text-xl font-bold text-foreground"></h1>
<p className="text-sm text-muted-foreground"></p>
</div>
</div>
</div>
</header>
<div className="container mx-auto px-4 py-8">
<div className="max-w-6xl mx-auto">
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground"></CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{users.length}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground"></CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{users.filter((u) => u.role === "admin").length}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground"></CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{users.filter((u) => u.role === "user").length}</div>
</CardContent>
</Card>
</div>
{/* Users Table */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</div>
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="w-4 h-4 mr-2" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name"></Label>
<Input
id="name"
value={newUser.name}
onChange={(e) => setNewUser({ ...newUser, name: e.target.value })}
placeholder="請輸入姓名"
/>
</div>
<div className="space-y-2">
<Label htmlFor="email"></Label>
<Input
id="email"
type="email"
value={newUser.email}
onChange={(e) => setNewUser({ ...newUser, email: e.target.value })}
placeholder="請輸入電子郵件"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password"></Label>
<Input
id="password"
type="password"
value={newUser.password}
onChange={(e) => setNewUser({ ...newUser, password: e.target.value })}
placeholder="請輸入密碼"
/>
</div>
<div className="space-y-2">
<Label htmlFor="department"></Label>
<Select
value={newUser.department}
onValueChange={(value) => setNewUser({ ...newUser, department: value })}
>
<SelectTrigger>
<SelectValue placeholder="請選擇部門" />
</SelectTrigger>
<SelectContent>
{departments.map((dept) => (
<SelectItem key={dept} value={dept}>
{dept}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="role"></Label>
<Select
value={newUser.role}
onValueChange={(value: "user" | "admin") => setNewUser({ ...newUser, role: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user"></SelectItem>
<SelectItem value="admin"></SelectItem>
</SelectContent>
</Select>
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="flex gap-2">
<Button onClick={handleAddUser} className="flex-1">
</Button>
<Button variant="outline" onClick={() => setIsAddDialogOpen(false)} className="flex-1">
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">{user.name}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>{user.department}</TableCell>
<TableCell>
<Badge variant={user.role === "admin" ? "default" : "secondary"}>
{user.role === "admin" ? "管理員" : "一般用戶"}
</Badge>
</TableCell>
<TableCell>{new Date(user.createdAt).toLocaleDateString()}</TableCell>
<TableCell>
<div className="flex gap-2">
<Button variant="ghost" size="sm" onClick={() => handleEditUser(user)}>
<Edit className="w-4 h-4" />
</Button>
{user.id !== currentUser?.id && (
<Button variant="ghost" size="sm" onClick={() => handleDeleteUser(user.id)}>
<Trash2 className="w-4 h-4" />
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{/* Edit User Dialog */}
<Dialog open={!!editingUser} onOpenChange={() => setEditingUser(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="edit-name"></Label>
<Input
id="edit-name"
value={newUser.name}
onChange={(e) => setNewUser({ ...newUser, name: e.target.value })}
placeholder="請輸入姓名"
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-email"></Label>
<Input
id="edit-email"
type="email"
value={newUser.email}
onChange={(e) => setNewUser({ ...newUser, email: e.target.value })}
placeholder="請輸入電子郵件"
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-password"></Label>
<Input
id="edit-password"
type="password"
value={newUser.password}
onChange={(e) => setNewUser({ ...newUser, password: e.target.value })}
placeholder="請輸入新密碼"
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-department"></Label>
<Select
value={newUser.department}
onValueChange={(value) => setNewUser({ ...newUser, department: value })}
>
<SelectTrigger>
<SelectValue placeholder="請選擇部門" />
</SelectTrigger>
<SelectContent>
{departments.map((dept) => (
<SelectItem key={dept} value={dept}>
{dept}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="edit-role"></Label>
<Select
value={newUser.role}
onValueChange={(value: "user" | "admin") => setNewUser({ ...newUser, role: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user"></SelectItem>
<SelectItem value="admin"></SelectItem>
</SelectContent>
</Select>
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="flex gap-2">
<Button onClick={handleUpdateUser} className="flex-1">
</Button>
<Button variant="outline" onClick={() => setEditingUser(null)} className="flex-1">
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
</div>
</div>
)
}