Files
hr-assessment-system/app/admin/questions/page.tsx

614 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

"use client"
import type React from "react"
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 { 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,
Loader2,
ChevronLeft,
ChevronRight,
} from "lucide-react"
import Link from "next/link"
import { parseExcelFile, type ImportResult } from "@/lib/utils/excel-parser"
// 定義題目類型
interface LogicQuestion {
id: number
question: string
option_a: string
option_b: string
option_c: string
option_d: string
option_e?: string
correct_answer: string
explanation: string
}
interface CreativeQuestion {
id: number
statement: string
category: string
is_reverse: boolean
}
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 [logicQuestions, setLogicQuestions] = useState<LogicQuestion[]>([])
const [creativeQuestions, setCreativeQuestions] = useState<CreativeQuestion[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// 分頁狀態
const [currentPage, setCurrentPage] = useState(1)
const [itemsPerPage] = useState(10)
// 分頁計算
const totalPages = Math.ceil(creativeQuestions.length / itemsPerPage)
const startIndex = (currentPage - 1) * itemsPerPage
const endIndex = startIndex + itemsPerPage
const currentCreativeQuestions = creativeQuestions.slice(startIndex, endIndex)
// 分頁處理函數
const handlePageChange = (page: number) => {
setCurrentPage(page)
}
const handlePreviousPage = () => {
if (currentPage > 1) {
setCurrentPage(currentPage - 1)
}
}
const handleNextPage = () => {
if (currentPage < totalPages) {
setCurrentPage(currentPage + 1)
}
}
// 從資料庫獲取題目
useEffect(() => {
const fetchQuestions = async () => {
try {
setIsLoading(true)
setError(null)
// 並行獲取兩種題目
const [logicResponse, creativeResponse] = await Promise.all([
fetch('/api/questions/logic'),
fetch('/api/questions/creative')
])
if (!logicResponse.ok || !creativeResponse.ok) {
throw new Error('獲取題目失敗')
}
const logicData = await logicResponse.json()
const creativeData = await creativeResponse.json()
if (logicData.success) {
setLogicQuestions(logicData.data)
}
if (creativeData.success) {
setCreativeQuestions(creativeData.data)
}
} catch (err) {
console.error('獲取題目失敗:', err)
setError(err instanceof Error ? err.message : '獲取題目失敗')
} finally {
setIsLoading(false)
}
}
fetchQuestions()
}, [])
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" />
<span className="hidden sm:inline"></span>
</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>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin mr-2" />
<span>...</span>
</div>
) : error ? (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{logicQuestions.map((question) => (
<TableRow key={question.id}>
<TableCell className="font-medium">{question.id}</TableCell>
<TableCell className="max-w-md truncate">{question.question}</TableCell>
<TableCell>
{question.option_e ? 5 : 4}
</TableCell>
<TableCell>
<Badge className="bg-green-500 text-white">{question.correct_answer}</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>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin mr-2" />
<span>...</span>
</div>
) : error ? (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{currentCreativeQuestions.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.is_reverse ? (
<Badge className="bg-orange-500 text-white"></Badge>
) : (
<Badge variant="outline"></Badge>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
{/* 創意題目分頁控制 */}
{!isLoading && !error && creativeQuestions.length > itemsPerPage && (
<div className="flex flex-col sm:flex-row items-center justify-between mt-6 gap-4">
<div className="text-sm text-muted-foreground text-center sm:text-left">
{startIndex + 1} - {Math.min(endIndex, creativeQuestions.length)} {creativeQuestions.length}
</div>
{/* Desktop Pagination */}
<div className="hidden sm:flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={handlePreviousPage}
disabled={currentPage === 1}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="flex items-center space-x-1">
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<Button
key={page}
variant={currentPage === page ? "default" : "outline"}
size="sm"
onClick={() => handlePageChange(page)}
className="w-8 h-8 p-0"
>
{page}
</Button>
))}
</div>
<Button
variant="outline"
size="sm"
onClick={handleNextPage}
disabled={currentPage === totalPages}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
{/* Mobile Pagination */}
<div className="flex sm:hidden items-center space-x-2 w-full justify-center">
<Button
variant="outline"
size="sm"
onClick={handlePreviousPage}
disabled={currentPage === 1}
className="flex-1 max-w-[80px]"
>
<ChevronLeft className="h-4 w-4 mr-1" />
</Button>
<div className="flex items-center space-x-1 px-2">
{(() => {
const maxVisiblePages = 3
const startPage = Math.max(1, currentPage - 1)
const endPage = Math.min(totalPages, startPage + maxVisiblePages - 1)
const pages = []
// 如果不在第一頁,顯示第一頁和省略號
if (startPage > 1) {
pages.push(
<Button
key={1}
variant="outline"
size="sm"
onClick={() => handlePageChange(1)}
className="w-8 h-8 p-0"
>
1
</Button>
)
if (startPage > 2) {
pages.push(
<span key="ellipsis1" className="text-muted-foreground px-1">
...
</span>
)
}
}
// 顯示當前頁附近的頁碼
for (let i = startPage; i <= endPage; i++) {
pages.push(
<Button
key={i}
variant={currentPage === i ? "default" : "outline"}
size="sm"
onClick={() => handlePageChange(i)}
className="w-8 h-8 p-0"
>
{i}
</Button>
)
}
// 如果不在最後一頁,顯示省略號和最後一頁
if (endPage < totalPages) {
if (endPage < totalPages - 1) {
pages.push(
<span key="ellipsis2" className="text-muted-foreground px-1">
...
</span>
)
}
pages.push(
<Button
key={totalPages}
variant="outline"
size="sm"
onClick={() => handlePageChange(totalPages)}
className="w-8 h-8 p-0"
>
{totalPages}
</Button>
)
}
return pages
})()}
</div>
<Button
variant="outline"
size="sm"
onClick={handleNextPage}
disabled={currentPage === totalPages}
className="flex-1 max-w-[80px]"
>
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
</div>
</div>
)}
</TabsContent>
</Tabs>
</CardContent>
</Card>
</div>
</div>
</div>
)
}