1398 lines
59 KiB
Plaintext
1398 lines
59 KiB
Plaintext
|
|
"use client"
|
||
|
|
|
||
|
|
import type React from "react"
|
||
|
|
|
||
|
|
import { useState, useEffect } from "react"
|
||
|
|
import { useRouter } from "next/navigation"
|
||
|
|
import { Button } from "@/components/ui/button"
|
||
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||
|
|
import { Badge } from "@/components/ui/badge"
|
||
|
|
import { Input } from "@/components/ui/input"
|
||
|
|
import {
|
||
|
|
Users,
|
||
|
|
UserCheck,
|
||
|
|
Clock,
|
||
|
|
Search,
|
||
|
|
Filter,
|
||
|
|
ArrowLeft,
|
||
|
|
CheckCircle,
|
||
|
|
XCircle,
|
||
|
|
AlertCircle,
|
||
|
|
Plus,
|
||
|
|
Upload,
|
||
|
|
FileText,
|
||
|
|
Download,
|
||
|
|
Trash2,
|
||
|
|
} from "lucide-react"
|
||
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||
|
|
import Link from "next/link"
|
||
|
|
import {
|
||
|
|
Dialog,
|
||
|
|
DialogContent,
|
||
|
|
DialogDescription,
|
||
|
|
DialogHeader,
|
||
|
|
DialogTitle,
|
||
|
|
DialogTrigger,
|
||
|
|
} from "@/components/ui/dialog"
|
||
|
|
import { Label } from "@/components/ui/label"
|
||
|
|
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||
|
|
import { AuthGuard } from "@/components/auth-guard"
|
||
|
|
import { useAuth } from "@/hooks/use-auth"
|
||
|
|
import { Checkbox } from "@/components/ui/checkbox"
|
||
|
|
import Papa from "papaparse"
|
||
|
|
import * as XLSX from "xlsx"
|
||
|
|
|
||
|
|
interface User {
|
||
|
|
id: string
|
||
|
|
username: string
|
||
|
|
name: string
|
||
|
|
memberId?: string
|
||
|
|
memberStatus: "verified" | "pending" | "rejected"
|
||
|
|
department?: string
|
||
|
|
joinDate?: string
|
||
|
|
lastLogin?: string
|
||
|
|
}
|
||
|
|
|
||
|
|
interface MemberStats {
|
||
|
|
totalMembers: number
|
||
|
|
verifiedMembers: number
|
||
|
|
pendingMembers: number
|
||
|
|
rejectedMembers: number
|
||
|
|
votedMembers: number
|
||
|
|
participationRate: number
|
||
|
|
}
|
||
|
|
|
||
|
|
interface BulkUserData {
|
||
|
|
name: string
|
||
|
|
email: string
|
||
|
|
password: string
|
||
|
|
role: string
|
||
|
|
selected?: boolean
|
||
|
|
rowIndex?: number
|
||
|
|
validationError?: string
|
||
|
|
}
|
||
|
|
|
||
|
|
interface ParsedFileData {
|
||
|
|
data: BulkUserData[]
|
||
|
|
errors: string[]
|
||
|
|
fileName: string
|
||
|
|
}
|
||
|
|
|
||
|
|
function MembersPageContent() {
|
||
|
|
const { user } = useAuth("admin")
|
||
|
|
const [members, setMembers] = useState<User[]>([])
|
||
|
|
const [stats, setStats] = useState<MemberStats | null>(null)
|
||
|
|
const [searchTerm, setSearchTerm] = useState("")
|
||
|
|
const [statusFilter, setStatusFilter] = useState<string>("all")
|
||
|
|
const [loading, setLoading] = useState(false)
|
||
|
|
const [debugInfo, setDebugInfo] = useState<string>("")
|
||
|
|
|
||
|
|
const [showCreateUser, setShowCreateUser] = useState(false)
|
||
|
|
const [createUserForm, setCreateUserForm] = useState({
|
||
|
|
username: "",
|
||
|
|
password: "",
|
||
|
|
name: "",
|
||
|
|
email: "",
|
||
|
|
memberId: "",
|
||
|
|
department: "",
|
||
|
|
joinDate: new Date().toISOString().split("T")[0],
|
||
|
|
})
|
||
|
|
|
||
|
|
// Bulk upload states
|
||
|
|
const [showBulkUpload, setShowBulkUpload] = useState(false)
|
||
|
|
const [parsedData, setParsedData] = useState<ParsedFileData | null>(null)
|
||
|
|
const [bulkLoading, setBulkLoading] = useState(false)
|
||
|
|
const [selectedUsers, setSelectedUsers] = useState<Set<number>>(new Set())
|
||
|
|
const [selectAll, setSelectAll] = useState(false)
|
||
|
|
|
||
|
|
const router = useRouter()
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (user) {
|
||
|
|
loadMembers()
|
||
|
|
loadStats()
|
||
|
|
}
|
||
|
|
}, [user])
|
||
|
|
|
||
|
|
const loadMembers = async () => {
|
||
|
|
try {
|
||
|
|
console.log("=== LOADING MEMBERS ===")
|
||
|
|
const response = await fetch("/api/members")
|
||
|
|
const data = await response.json()
|
||
|
|
console.log("Members API response:", data)
|
||
|
|
|
||
|
|
if (data.success) {
|
||
|
|
setMembers(data.members)
|
||
|
|
console.log("Members loaded:", data.members.length)
|
||
|
|
|
||
|
|
// Debug info
|
||
|
|
const statusCounts = data.members.reduce((acc: any, member: User) => {
|
||
|
|
acc[member.memberStatus] = (acc[member.memberStatus] || 0) + 1
|
||
|
|
return acc
|
||
|
|
}, {})
|
||
|
|
|
||
|
|
setDebugInfo(`Total: ${data.members.length}, Status: ${JSON.stringify(statusCounts)}`)
|
||
|
|
|
||
|
|
// Log each member's status
|
||
|
|
data.members.forEach((member: User, index: number) => {
|
||
|
|
console.log(
|
||
|
|
`Member ${index + 1}: ${member.name} - Status: "${member.memberStatus}" (${typeof member.memberStatus})`,
|
||
|
|
)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error("Error loading members:", error)
|
||
|
|
setDebugInfo("Error loading members: " + error.message)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const loadStats = async () => {
|
||
|
|
try {
|
||
|
|
const response = await fetch("/api/members/stats")
|
||
|
|
const data = await response.json()
|
||
|
|
if (data.success) {
|
||
|
|
setStats(data.stats)
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error("Error loading stats:", error)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleStatusChange = async (memberId: string, newStatus: string) => {
|
||
|
|
console.log(`=== CHANGING STATUS ===`)
|
||
|
|
console.log(`Member ID: ${memberId}, New Status: ${newStatus}`)
|
||
|
|
|
||
|
|
setLoading(true)
|
||
|
|
try {
|
||
|
|
const response = await fetch(`/api/members/${memberId}/status`, {
|
||
|
|
method: "PUT",
|
||
|
|
headers: { "Content-Type": "application/json" },
|
||
|
|
body: JSON.stringify({ status: newStatus }),
|
||
|
|
})
|
||
|
|
|
||
|
|
const data = await response.json()
|
||
|
|
console.log("Status change response:", data)
|
||
|
|
|
||
|
|
if (response.ok) {
|
||
|
|
await loadMembers()
|
||
|
|
await loadStats()
|
||
|
|
alert(`Status anggota berhasil diubah menjadi ${newStatus}`)
|
||
|
|
} else {
|
||
|
|
alert("Gagal mengubah status: " + data.message)
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error("Error updating member status:", error)
|
||
|
|
alert("Gagal mengubah status anggota")
|
||
|
|
} finally {
|
||
|
|
setLoading(false)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleCreateUser = async (e: React.FormEvent) => {
|
||
|
|
e.preventDefault()
|
||
|
|
setLoading(true)
|
||
|
|
|
||
|
|
console.log("=== CREATING USER ===")
|
||
|
|
console.log("Form data:", createUserForm)
|
||
|
|
|
||
|
|
try {
|
||
|
|
const response = await fetch("/api/members", {
|
||
|
|
method: "POST",
|
||
|
|
headers: { "Content-Type": "application/json" },
|
||
|
|
body: JSON.stringify({
|
||
|
|
...createUserForm,
|
||
|
|
role: "voter",
|
||
|
|
memberStatus: "pending",
|
||
|
|
}),
|
||
|
|
})
|
||
|
|
|
||
|
|
const data = await response.json()
|
||
|
|
console.log("API response:", data)
|
||
|
|
|
||
|
|
if (data.success) {
|
||
|
|
console.log("User created successfully:", data.user)
|
||
|
|
|
||
|
|
// Reload data
|
||
|
|
await loadMembers()
|
||
|
|
await loadStats()
|
||
|
|
|
||
|
|
// Reset form
|
||
|
|
setCreateUserForm({
|
||
|
|
username: "",
|
||
|
|
password: "",
|
||
|
|
name: "",
|
||
|
|
email: "",
|
||
|
|
memberId: "",
|
||
|
|
department: "",
|
||
|
|
joinDate: new Date().toISOString().split("T")[0],
|
||
|
|
})
|
||
|
|
|
||
|
|
setShowCreateUser(false)
|
||
|
|
alert("Anggota baru berhasil dibuat!")
|
||
|
|
} else {
|
||
|
|
console.error("Failed to create user:", data.message)
|
||
|
|
alert("Gagal membuat anggota: " + data.message)
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error("Error creating user:", error)
|
||
|
|
alert("Terjadi kesalahan sistem")
|
||
|
|
} finally {
|
||
|
|
setLoading(false)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// File parsing functions
|
||
|
|
const validateUserData = (userData: BulkUserData, rowIndex: number): string | null => {
|
||
|
|
if (!userData.name || userData.name.trim().length === 0) {
|
||
|
|
return "Name is required"
|
||
|
|
}
|
||
|
|
if (userData.name.length > 255) {
|
||
|
|
return "Name must be less than 255 characters"
|
||
|
|
}
|
||
|
|
if (!userData.email || !userData.email.includes("@")) {
|
||
|
|
return "Valid email is required"
|
||
|
|
}
|
||
|
|
if (!userData.password || userData.password.length < 6) {
|
||
|
|
return "Password must be at least 6 characters"
|
||
|
|
}
|
||
|
|
if (!userData.role || userData.role.trim().length === 0) {
|
||
|
|
return "Role is required"
|
||
|
|
}
|
||
|
|
return null
|
||
|
|
}
|
||
|
|
|
||
|
|
const parseCSVFile = (file: File): Promise<ParsedFileData> => {
|
||
|
|
return new Promise((resolve) => {
|
||
|
|
Papa.parse(file, {
|
||
|
|
header: true,
|
||
|
|
skipEmptyLines: true,
|
||
|
|
complete: (result) => {
|
||
|
|
const errors: string[] = []
|
||
|
|
const data: BulkUserData[] = []
|
||
|
|
|
||
|
|
result.data.forEach((row: any, index: number) => {
|
||
|
|
const userData: BulkUserData = {
|
||
|
|
name: row.name || row.Name || "",
|
||
|
|
email: row.email || row.Email || "",
|
||
|
|
password: row.password || row.Password || "",
|
||
|
|
role: row.role || row.Role || "voter",
|
||
|
|
selected: true,
|
||
|
|
rowIndex: index
|
||
|
|
}
|
||
|
|
|
||
|
|
const validationError = validateUserData(userData, index)
|
||
|
|
if (validationError) {
|
||
|
|
userData.validationError = validationError
|
||
|
|
errors.push(`Row ${index + 2}: ${validationError}`)
|
||
|
|
}
|
||
|
|
|
||
|
|
data.push(userData)
|
||
|
|
})
|
||
|
|
|
||
|
|
resolve({
|
||
|
|
data,
|
||
|
|
errors,
|
||
|
|
fileName: file.name
|
||
|
|
})
|
||
|
|
},
|
||
|
|
error: (error) => {
|
||
|
|
resolve({
|
||
|
|
data: [],
|
||
|
|
errors: [error.message],
|
||
|
|
fileName: file.name
|
||
|
|
})
|
||
|
|
}
|
||
|
|
})
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
const parseExcelFile = (file: File): Promise<ParsedFileData> => {
|
||
|
|
return new Promise((resolve) => {
|
||
|
|
const reader = new FileReader()
|
||
|
|
reader.onload = (e) => {
|
||
|
|
try {
|
||
|
|
const data = new Uint8Array(e.target?.result as ArrayBuffer)
|
||
|
|
const workbook = XLSX.read(data, { type: 'array' })
|
||
|
|
const sheetName = workbook.SheetNames[0]
|
||
|
|
const worksheet = workbook.Sheets[sheetName]
|
||
|
|
const jsonData = XLSX.utils.sheet_to_json(worksheet)
|
||
|
|
|
||
|
|
const errors: string[] = []
|
||
|
|
const users: BulkUserData[] = []
|
||
|
|
|
||
|
|
jsonData.forEach((row: any, index: number) => {
|
||
|
|
const userData: BulkUserData = {
|
||
|
|
name: row.name || row.Name || "",
|
||
|
|
email: row.email || row.Email || "",
|
||
|
|
password: row.password || row.Password || "",
|
||
|
|
role: row.role || row.Role || "voter",
|
||
|
|
selected: true,
|
||
|
|
rowIndex: index
|
||
|
|
}
|
||
|
|
|
||
|
|
const validationError = validateUserData(userData, index)
|
||
|
|
if (validationError) {
|
||
|
|
userData.validationError = validationError
|
||
|
|
errors.push(`Row ${index + 2}: ${validationError}`)
|
||
|
|
}
|
||
|
|
|
||
|
|
users.push(userData)
|
||
|
|
})
|
||
|
|
|
||
|
|
resolve({
|
||
|
|
data: users,
|
||
|
|
errors,
|
||
|
|
fileName: file.name
|
||
|
|
})
|
||
|
|
} catch (error) {
|
||
|
|
resolve({
|
||
|
|
data: [],
|
||
|
|
errors: [error.message],
|
||
|
|
fileName: file.name
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
reader.readAsArrayBuffer(file)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||
|
|
const file = event.target.files?.[0]
|
||
|
|
if (!file) return
|
||
|
|
|
||
|
|
setBulkLoading(true)
|
||
|
|
try {
|
||
|
|
let parsed: ParsedFileData
|
||
|
|
|
||
|
|
if (file.name.toLowerCase().endsWith('.csv')) {
|
||
|
|
parsed = await parseCSVFile(file)
|
||
|
|
} else if (file.name.toLowerCase().endsWith('.xlsx') || file.name.toLowerCase().endsWith('.xls')) {
|
||
|
|
parsed = await parseExcelFile(file)
|
||
|
|
} else {
|
||
|
|
alert('Please upload a CSV or Excel file')
|
||
|
|
setBulkLoading(false)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
setParsedData(parsed)
|
||
|
|
setSelectedUsers(new Set(parsed.data.map((_, index) => index)))
|
||
|
|
setSelectAll(true)
|
||
|
|
|
||
|
|
} catch (error) {
|
||
|
|
console.error('Error parsing file:', error)
|
||
|
|
alert('Error parsing file: ' + error.message)
|
||
|
|
} finally {
|
||
|
|
setBulkLoading(false)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleSelectAll = (checked: boolean) => {
|
||
|
|
if (checked && parsedData) {
|
||
|
|
setSelectedUsers(new Set(parsedData.data.map((_, index) => index)))
|
||
|
|
} else {
|
||
|
|
setSelectedUsers(new Set())
|
||
|
|
}
|
||
|
|
setSelectAll(checked)
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleSelectUser = (index: number, checked: boolean) => {
|
||
|
|
const newSelected = new Set(selectedUsers)
|
||
|
|
if (checked) {
|
||
|
|
newSelected.add(index)
|
||
|
|
} else {
|
||
|
|
newSelected.delete(index)
|
||
|
|
}
|
||
|
|
setSelectedUsers(newSelected)
|
||
|
|
setSelectAll(parsedData ? newSelected.size === parsedData.data.length : false)
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleBulkCreateUsers = async () => {
|
||
|
|
if (!parsedData || selectedUsers.size === 0) {
|
||
|
|
alert('Please select at least one user to create')
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
setBulkLoading(true)
|
||
|
|
try {
|
||
|
|
const selectedUsersData = parsedData.data
|
||
|
|
.filter((_, index) => selectedUsers.has(index))
|
||
|
|
.filter(user => !user.validationError) // Only include valid users
|
||
|
|
.map(user => ({
|
||
|
|
name: user.name,
|
||
|
|
email: user.email,
|
||
|
|
password: user.password,
|
||
|
|
role: user.role
|
||
|
|
}))
|
||
|
|
|
||
|
|
if (selectedUsersData.length === 0) {
|
||
|
|
alert('No valid users selected for creation')
|
||
|
|
setBulkLoading(false)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
const response = await fetch('/api/v1/users/bulk', {
|
||
|
|
method: 'POST',
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify({
|
||
|
|
users: selectedUsersData
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
const result = await response.json()
|
||
|
|
|
||
|
|
if (response.ok && result.success) {
|
||
|
|
alert(`Successfully created ${selectedUsersData.length} users!`)
|
||
|
|
setParsedData(null)
|
||
|
|
setSelectedUsers(new Set())
|
||
|
|
setSelectAll(false)
|
||
|
|
setShowBulkUpload(false)
|
||
|
|
|
||
|
|
// Reload members list
|
||
|
|
await loadMembers()
|
||
|
|
await loadStats()
|
||
|
|
} else {
|
||
|
|
alert(`Failed to create users: ${result.message || 'Unknown error'}`)
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error('Error creating bulk users:', error)
|
||
|
|
alert('Error creating users: ' + error.message)
|
||
|
|
} finally {
|
||
|
|
setBulkLoading(false)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const downloadTemplate = () => {
|
||
|
|
const csvContent = "name,email,password,role\nJohn Doe,john@example.com,password123,voter\nJane Smith,jane@example.com,password456,admin"
|
||
|
|
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
|
||
|
|
const link = document.createElement('a')
|
||
|
|
const url = URL.createObjectURL(blob)
|
||
|
|
link.setAttribute('href', url)
|
||
|
|
link.setAttribute('download', 'user_template.csv')
|
||
|
|
link.style.visibility = 'hidden'
|
||
|
|
document.body.appendChild(link)
|
||
|
|
link.click()
|
||
|
|
document.body.removeChild(link)
|
||
|
|
}
|
||
|
|
|
||
|
|
const filteredMembers = members.filter((member) => {
|
||
|
|
const matchesSearch =
|
||
|
|
member.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
|
|
member.memberId?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
|
|
member.department?.toLowerCase().includes(searchTerm.toLowerCase())
|
||
|
|
|
||
|
|
const matchesStatus = statusFilter === "all" || member.memberStatus === statusFilter
|
||
|
|
|
||
|
|
return matchesSearch && matchesStatus
|
||
|
|
})
|
||
|
|
|
||
|
|
const getStatusBadge = (status: string) => {
|
||
|
|
switch (status) {
|
||
|
|
case "verified":
|
||
|
|
return (
|
||
|
|
<Badge className="bg-green-100 text-green-800 border-green-200">
|
||
|
|
<CheckCircle className="h-3 w-3 mr-1" />
|
||
|
|
Terverifikasi
|
||
|
|
</Badge>
|
||
|
|
)
|
||
|
|
case "pending":
|
||
|
|
return (
|
||
|
|
<Badge className="bg-yellow-100 text-yellow-800 border-yellow-200">
|
||
|
|
<Clock className="h-3 w-3 mr-1" />
|
||
|
|
Menunggu
|
||
|
|
</Badge>
|
||
|
|
)
|
||
|
|
case "rejected":
|
||
|
|
return (
|
||
|
|
<Badge className="bg-red-100 text-red-800 border-red-200">
|
||
|
|
<XCircle className="h-3 w-3 mr-1" />
|
||
|
|
Ditolak
|
||
|
|
</Badge>
|
||
|
|
)
|
||
|
|
default:
|
||
|
|
return (
|
||
|
|
<Badge variant="secondary" className="bg-gray-100 text-gray-800">
|
||
|
|
<AlertCircle className="h-3 w-3 mr-1" />
|
||
|
|
Status Tidak Valid: {status}
|
||
|
|
</Badge>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!user) return null
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="min-h-screen bg-gray-50">
|
||
|
|
<header className="bg-white shadow-sm border-b">
|
||
|
|
<div className="container mx-auto px-4 py-4">
|
||
|
|
<div className="flex items-center gap-2 lg:gap-4">
|
||
|
|
<Link href="/admin" className="text-blue-600 hover:text-blue-800">
|
||
|
|
<ArrowLeft className="h-5 w-5" />
|
||
|
|
</Link>
|
||
|
|
<img src="/images/meti-logo.png" alt="METI - New & Renewable Energy" className="h-8 lg:h-10 w-auto" />
|
||
|
|
<h1 className="text-lg lg:text-2xl font-bold text-gray-900">Verifikasi Anggota</h1>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</header>
|
||
|
|
|
||
|
|
<div className="container mx-auto px-4 py-8">
|
||
|
|
{/* Debug Info */}
|
||
|
|
{debugInfo && (
|
||
|
|
<Alert className="mb-6">
|
||
|
|
<AlertCircle className="h-4 w-4" />
|
||
|
|
<AlertDescription>
|
||
|
|
<strong>Debug Info:</strong> {debugInfo}
|
||
|
|
</AlertDescription>
|
||
|
|
</Alert>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Statistics Cards */}
|
||
|
|
{stats && (
|
||
|
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 lg:gap-6 mb-8">
|
||
|
|
<Card>
|
||
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||
|
|
<CardTitle className="text-xs lg:text-sm font-medium">Total Anggota</CardTitle>
|
||
|
|
<Users className="h-4 w-4 text-muted-foreground" />
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
<div className="text-xl lg:text-2xl font-bold">{stats.totalMembers}</div>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
<Card>
|
||
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||
|
|
<CardTitle className="text-xs lg:text-sm font-medium">Terverifikasi</CardTitle>
|
||
|
|
<UserCheck className="h-4 w-4 text-green-600" />
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
<div className="text-xl lg:text-2xl font-bold text-green-600">{stats.verifiedMembers}</div>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
<Card>
|
||
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||
|
|
<CardTitle className="text-xs lg:text-sm font-medium">Menunggu</CardTitle>
|
||
|
|
<Clock className="h-4 w-4 text-yellow-600" />
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
<div className="text-xl lg:text-2xl font-bold text-yellow-600">{stats.pendingMembers}</div>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
<Card className="col-span-2 lg:col-span-1">
|
||
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||
|
|
<CardTitle className="text-xs lg:text-sm font-medium">Partisipasi</CardTitle>
|
||
|
|
<AlertCircle className="h-4 w-4 text-blue-600" />
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
<div className="text-xl lg:text-2xl font-bold text-blue-600">{stats.participationRate}%</div>
|
||
|
|
<p className="text-xs text-muted-foreground">
|
||
|
|
{stats.votedMembers} dari {stats.verifiedMembers}
|
||
|
|
</p>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Create User & Filters */}
|
||
|
|
<Card className="mb-6">
|
||
|
|
<CardHeader>
|
||
|
|
<div className="flex justify-between items-center">
|
||
|
|
<CardTitle>Kelola Anggota</CardTitle>
|
||
|
|
<div className="flex gap-2">
|
||
|
|
<Dialog open={showCreateUser} onOpenChange={setShowCreateUser}>
|
||
|
|
<DialogTrigger asChild>
|
||
|
|
<Button>
|
||
|
|
<Plus className="h-4 w-4 mr-2" />
|
||
|
|
Tambah Anggota Baru
|
||
|
|
</Button>
|
||
|
|
</DialogTrigger>
|
||
|
|
<DialogContent>
|
||
|
|
<DialogHeader>
|
||
|
|
<DialogTitle>Tambah Anggota Baru</DialogTitle>
|
||
|
|
<DialogDescription>Buat akun voter baru untuk anggota METI</DialogDescription>
|
||
|
|
</DialogHeader>
|
||
|
|
<form onSubmit={handleCreateUser} className="space-y-4">
|
||
|
|
<div>
|
||
|
|
<Label htmlFor="username">Username</Label>
|
||
|
|
<Input
|
||
|
|
id="username"
|
||
|
|
value={createUserForm.username}
|
||
|
|
onChange={(e) => setCreateUserForm({ ...createUserForm, username: e.target.value })}
|
||
|
|
placeholder="username_anggota"
|
||
|
|
required
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<Label htmlFor="password">Password</Label>
|
||
|
|
<Input
|
||
|
|
id="password"
|
||
|
|
type="password"
|
||
|
|
value={createUserForm.password}
|
||
|
|
onChange={(e) => setCreateUserForm({ ...createUserForm, password: e.target.value })}
|
||
|
|
placeholder="Password untuk login"
|
||
|
|
required
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<Label htmlFor="name">Nama Lengkap</Label>
|
||
|
|
<Input
|
||
|
|
id="name"
|
||
|
|
value={createUserForm.name}
|
||
|
|
onChange={(e) => setCreateUserForm({ ...createUserForm, name: e.target.value })}
|
||
|
|
placeholder="Nama lengkap anggota"
|
||
|
|
required
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<Label htmlFor="email">Email</Label>
|
||
|
|
<Input
|
||
|
|
id="email"
|
||
|
|
type="email"
|
||
|
|
value={createUserForm.email}
|
||
|
|
onChange={(e) => setCreateUserForm({ ...createUserForm, email: e.target.value })}
|
||
|
|
placeholder="email@example.com"
|
||
|
|
required
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<Label htmlFor="memberId">ID Anggota</Label>
|
||
|
|
<Input
|
||
|
|
id="memberId"
|
||
|
|
value={createUserForm.memberId}
|
||
|
|
onChange={(e) => setCreateUserForm({ ...createUserForm, memberId: e.target.value })}
|
||
|
|
placeholder="METI-XXX"
|
||
|
|
required
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<Label htmlFor="department">Departemen</Label>
|
||
|
|
<Input
|
||
|
|
id="department"
|
||
|
|
value={createUserForm.department}
|
||
|
|
onChange={(e) => setCreateUserForm({ ...createUserForm, department: e.target.value })}
|
||
|
|
placeholder="Renewable Energy, Solar Energy, dll"
|
||
|
|
required
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<Label htmlFor="joinDate">Tanggal Bergabung</Label>
|
||
|
|
<Input
|
||
|
|
id="joinDate"
|
||
|
|
type="date"
|
||
|
|
value={createUserForm.joinDate}
|
||
|
|
onChange={(e) => setCreateUserForm({ ...createUserForm, joinDate: e.target.value })}
|
||
|
|
required
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<Button type="submit" disabled={loading} className="w-full">
|
||
|
|
{loading ? "Membuat..." : "Buat Anggota"}
|
||
|
|
</Button>
|
||
|
|
</form>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
|
||
|
|
<Dialog open={showBulkUpload} onOpenChange={setShowBulkUpload}>
|
||
|
|
<DialogTrigger asChild>
|
||
|
|
<Button variant="outline">
|
||
|
|
<Upload className="h-4 w-4 mr-2" />
|
||
|
|
Upload Bulk Users
|
||
|
|
</Button>
|
||
|
|
</DialogTrigger>
|
||
|
|
<DialogContent className="max-w-6xl max-h-[90vh] overflow-hidden flex flex-col">
|
||
|
|
<DialogHeader>
|
||
|
|
<DialogTitle>Upload Bulk Users</DialogTitle>
|
||
|
|
<DialogDescription>
|
||
|
|
Upload CSV or Excel file to create multiple users at once
|
||
|
|
</DialogDescription>
|
||
|
|
</DialogHeader>
|
||
|
|
|
||
|
|
<div className="flex-1 overflow-auto">
|
||
|
|
{!parsedData ? (
|
||
|
|
<div className="space-y-6">
|
||
|
|
{/* File Upload Section */}
|
||
|
|
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8">
|
||
|
|
<div className="text-center">
|
||
|
|
<FileText className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||
|
|
<h3 className="text-lg font-medium mb-2">Upload File</h3>
|
||
|
|
<p className="text-gray-600 mb-4">
|
||
|
|
Choose a CSV or Excel file containing user data
|
||
|
|
</p>
|
||
|
|
<input
|
||
|
|
type="file"
|
||
|
|
accept=".csv,.xlsx,.xls"
|
||
|
|
onChange={handleFileUpload}
|
||
|
|
className="hidden"
|
||
|
|
id="file-upload"
|
||
|
|
/>
|
||
|
|
<label htmlFor="file-upload">
|
||
|
|
<Button className="cursor-pointer" disabled={bulkLoading}>
|
||
|
|
{bulkLoading ? "Processing..." : "Choose File"}
|
||
|
|
</Button>
|
||
|
|
</label>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Template Download */}
|
||
|
|
<div className="bg-blue-50 p-4 rounded-lg">
|
||
|
|
<h4 className="font-medium mb-2">Need a template?</h4>
|
||
|
|
<p className="text-sm text-gray-600 mb-3">
|
||
|
|
Download our CSV template to get started. Required columns: name, email, password, role
|
||
|
|
</p>
|
||
|
|
<Button variant="outline" size="sm" onClick={downloadTemplate}>
|
||
|
|
<Download className="h-4 w-4 mr-2" />
|
||
|
|
Download Template
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Format Info */}
|
||
|
|
<div className="bg-gray-50 p-4 rounded-lg">
|
||
|
|
<h4 className="font-medium mb-2">File Format Requirements:</h4>
|
||
|
|
<ul className="text-sm text-gray-600 space-y-1">
|
||
|
|
<li>• <strong>name</strong>: Full name (required, max 255 chars)</li>
|
||
|
|
<li>• <strong>email</strong>: Valid email address (required)</li>
|
||
|
|
<li>• <strong>password</strong>: Password (required, min 6 chars)</li>
|
||
|
|
<li>• <strong>role</strong>: User role (required, e.g., 'voter', 'admin')</li>
|
||
|
|
</ul>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<div className="space-y-4">
|
||
|
|
{/* File Info & Actions */}
|
||
|
|
<div className="flex justify-between items-center">
|
||
|
|
<div>
|
||
|
|
<h3 className="text-lg font-medium">Preview: {parsedData.fileName}</h3>
|
||
|
|
<p className="text-sm text-gray-600">
|
||
|
|
Found {parsedData.data.length} users, {selectedUsers.size} selected
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
<div className="flex gap-2">
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
onClick={() => setParsedData(null)}
|
||
|
|
>
|
||
|
|
<Trash2 className="h-4 w-4 mr-2" />
|
||
|
|
Clear
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
onClick={handleBulkCreateUsers}
|
||
|
|
disabled={bulkLoading || selectedUsers.size === 0}
|
||
|
|
>
|
||
|
|
{bulkLoading ? "Creating..." : `Create ${selectedUsers.size} Users`}
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Errors Display */}
|
||
|
|
{parsedData.errors.length > 0 && (
|
||
|
|
<Alert className="bg-red-50 border-red-200">
|
||
|
|
<AlertCircle className="h-4 w-4" />
|
||
|
|
<AlertDescription>
|
||
|
|
<strong>Validation Errors:</strong>
|
||
|
|
<ul className="mt-2 space-y-1">
|
||
|
|
{parsedData.errors.slice(0, 5).map((error, index) => (
|
||
|
|
<li key={index} className="text-sm">• {error}</li>
|
||
|
|
))}
|
||
|
|
{parsedData.errors.length > 5 && (
|
||
|
|
<li className="text-sm">• ... and {parsedData.errors.length - 5} more errors</li>
|
||
|
|
)}
|
||
|
|
</ul>
|
||
|
|
</AlertDescription>
|
||
|
|
</Alert>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Data Table */}
|
||
|
|
<div className="border rounded-lg overflow-hidden">
|
||
|
|
<Table>
|
||
|
|
<TableHeader>
|
||
|
|
<TableRow>
|
||
|
|
<TableHead className="w-12">
|
||
|
|
<Checkbox
|
||
|
|
checked={selectAll}
|
||
|
|
onCheckedChange={(checked) => handleSelectAll(checked || false)}
|
||
|
|
/>
|
||
|
|
</TableHead>
|
||
|
|
<TableHead>Name</TableHead>
|
||
|
|
<TableHead>Email</TableHead>
|
||
|
|
<TableHead>Role</TableHead>
|
||
|
|
<TableHead>Status</TableHead>
|
||
|
|
</TableRow>
|
||
|
|
</TableHeader>
|
||
|
|
<TableBody>
|
||
|
|
{parsedData.data.map((user, index) => (
|
||
|
|
<TableRow key={index}>
|
||
|
|
<TableCell>
|
||
|
|
<Checkbox
|
||
|
|
checked={selectedUsers.has(index)}
|
||
|
|
onCheckedChange={(checked) => handleSelectUser(index, checked || false)}
|
||
|
|
disabled={!!user.validationError}
|
||
|
|
/>
|
||
|
|
</TableCell>
|
||
|
|
<TableCell className="font-medium">
|
||
|
|
{user.name || <span className="text-gray-400">Missing</span>}
|
||
|
|
</TableCell>
|
||
|
|
<TableCell>
|
||
|
|
{user.email || <span className="text-gray-400">Missing</span>}
|
||
|
|
</TableCell>
|
||
|
|
<TableCell>
|
||
|
|
{user.role || <span className="text-gray-400">Missing</span>}
|
||
|
|
</TableCell>
|
||
|
|
<TableCell>
|
||
|
|
{user.validationError ? (
|
||
|
|
<Badge variant="destructive" className="text-xs">
|
||
|
|
{user.validationError}
|
||
|
|
</Badge>
|
||
|
|
) : (
|
||
|
|
<Badge className="bg-green-100 text-green-800 text-xs">
|
||
|
|
Valid
|
||
|
|
</Badge>
|
||
|
|
)}
|
||
|
|
</TableCell>
|
||
|
|
</TableRow>
|
||
|
|
))}
|
||
|
|
</TableBody>
|
||
|
|
</Table>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
</div>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
<div className="flex flex-col md:flex-row gap-4">
|
||
|
|
<div className="flex-1">
|
||
|
|
<div className="relative">
|
||
|
|
<Search className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
|
||
|
|
<Input
|
||
|
|
placeholder="Cari nama, ID anggota, atau departemen..."
|
||
|
|
value={searchTerm}
|
||
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||
|
|
className="pl-10"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div className="w-full md:w-48">
|
||
|
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||
|
|
<SelectTrigger>
|
||
|
|
<Filter className="h-4 w-4 mr-2" />
|
||
|
|
<SelectValue placeholder="Filter Status" />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
<SelectItem value="all">Semua Status</SelectItem>
|
||
|
|
<SelectItem value="verified">Terverifikasi</SelectItem>
|
||
|
|
<SelectItem value="pending">Menunggu</SelectItem>
|
||
|
|
<SelectItem value="rejected">Ditolak</SelectItem>
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
{/* Members Table */}
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle>Daftar Anggota ({filteredMembers.length})</CardTitle>
|
||
|
|
<CardDescription>Kelola verifikasi dan status keanggotaan METI</CardDescription>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
{/* Desktop Table View */}
|
||
|
|
<div className="hidden lg:block overflow-x-auto">
|
||
|
|
<Table>
|
||
|
|
<TableHeader>
|
||
|
|
<TableRow>
|
||
|
|
<TableHead className="min-w-[120px]">ID Anggota</TableHead>
|
||
|
|
<TableHead className="min-w-[120px]">Username</TableHead>
|
||
|
|
<TableHead className="min-w-[150px]">Nama</TableHead>
|
||
|
|
<TableHead className="min-w-[120px]">Departemen</TableHead>
|
||
|
|
<TableHead className="min-w-[120px]">Tanggal Bergabung</TableHead>
|
||
|
|
<TableHead className="min-w-[140px]">Status</TableHead>
|
||
|
|
<TableHead className="min-w-[120px]">Login Terakhir</TableHead>
|
||
|
|
<TableHead className="min-w-[200px]">Aksi</TableHead>
|
||
|
|
</TableRow>
|
||
|
|
</TableHeader>
|
||
|
|
<TableBody>
|
||
|
|
{filteredMembers.map((member) => (
|
||
|
|
<TableRow key={member.id}>
|
||
|
|
<TableCell className="font-medium">{member.memberId}</TableCell>
|
||
|
|
<TableCell className="font-mono text-sm bg-gray-50 px-2 py-1 rounded">
|
||
|
|
{member.username}
|
||
|
|
</TableCell>
|
||
|
|
<TableCell className="font-medium">{member.name}</TableCell>
|
||
|
|
<TableCell>{member.department}</TableCell>
|
||
|
|
<TableCell>
|
||
|
|
{member.joinDate ? new Date(member.joinDate).toLocaleDateString("id-ID") : "-"}
|
||
|
|
</TableCell>
|
||
|
|
<TableCell>{getStatusBadge(member.memberStatus)}</TableCell>
|
||
|
|
<TableCell>
|
||
|
|
{member.lastLogin
|
||
|
|
? new Date(member.lastLogin).toLocaleDateString("id-ID")
|
||
|
|
: "Belum pernah login"}
|
||
|
|
</TableCell>
|
||
|
|
<TableCell>
|
||
|
|
<div className="flex gap-2">
|
||
|
|
{/* Existing button logic remains the same */}
|
||
|
|
{member.memberStatus === "pending" && (
|
||
|
|
<>
|
||
|
|
<Dialog>
|
||
|
|
<DialogTrigger asChild>
|
||
|
|
<Button size="sm" className="bg-green-600 hover:bg-green-700 text-white">
|
||
|
|
<CheckCircle className="h-3 w-3 mr-1" />
|
||
|
|
Verifikasi
|
||
|
|
</Button>
|
||
|
|
</DialogTrigger>
|
||
|
|
<DialogContent>
|
||
|
|
<DialogHeader>
|
||
|
|
<DialogTitle>Konfirmasi Verifikasi</DialogTitle>
|
||
|
|
<DialogDescription>
|
||
|
|
Apakah Anda yakin ingin memverifikasi anggota berikut?
|
||
|
|
</DialogDescription>
|
||
|
|
</DialogHeader>
|
||
|
|
<div className="py-4">
|
||
|
|
<div className="space-y-2">
|
||
|
|
<p>
|
||
|
|
<strong>Nama:</strong> {member.name}
|
||
|
|
</p>
|
||
|
|
<p>
|
||
|
|
<strong>ID Anggota:</strong> {member.memberId}
|
||
|
|
</p>
|
||
|
|
<p>
|
||
|
|
<strong>Departemen:</strong> {member.department}
|
||
|
|
</p>
|
||
|
|
<p>
|
||
|
|
<strong>Status Saat Ini:</strong> Menunggu Verifikasi
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div className="flex justify-end gap-3">
|
||
|
|
<DialogTrigger asChild>
|
||
|
|
<Button variant="outline">Batal</Button>
|
||
|
|
</DialogTrigger>
|
||
|
|
<Button
|
||
|
|
onClick={() => handleStatusChange(member.id, "verified")}
|
||
|
|
disabled={loading}
|
||
|
|
className="bg-green-600 hover:bg-green-700"
|
||
|
|
>
|
||
|
|
{loading ? "Memverifikasi..." : "Ya, Verifikasi"}
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
<Dialog>
|
||
|
|
<DialogTrigger asChild>
|
||
|
|
<Button size="sm" variant="destructive">
|
||
|
|
<XCircle className="h-3 w-3 mr-1" />
|
||
|
|
Tolak
|
||
|
|
</Button>
|
||
|
|
</DialogTrigger>
|
||
|
|
<DialogContent>
|
||
|
|
<DialogHeader>
|
||
|
|
<DialogTitle>Konfirmasi Penolakan</DialogTitle>
|
||
|
|
<DialogDescription>
|
||
|
|
Apakah Anda yakin ingin menolak verifikasi anggota berikut?
|
||
|
|
</DialogDescription>
|
||
|
|
</DialogHeader>
|
||
|
|
<div className="py-4">
|
||
|
|
<div className="space-y-2">
|
||
|
|
<p>
|
||
|
|
<strong>Nama:</strong> {member.name}
|
||
|
|
</p>
|
||
|
|
<p>
|
||
|
|
<strong>ID Anggota:</strong> {member.memberId}
|
||
|
|
</p>
|
||
|
|
<p>
|
||
|
|
<strong>Departemen:</strong> {member.department}
|
||
|
|
</p>
|
||
|
|
<p className="text-red-600">
|
||
|
|
<strong>Aksi:</strong> Anggota akan ditolak dan tidak dapat login
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div className="flex justify-end gap-3">
|
||
|
|
<DialogTrigger asChild>
|
||
|
|
<Button variant="outline">Batal</Button>
|
||
|
|
</DialogTrigger>
|
||
|
|
<Button
|
||
|
|
onClick={() => handleStatusChange(member.id, "rejected")}
|
||
|
|
disabled={loading}
|
||
|
|
variant="destructive"
|
||
|
|
>
|
||
|
|
{loading ? "Menolak..." : "Ya, Tolak"}
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
{/* Other status buttons remain the same */}
|
||
|
|
{member.memberStatus === "rejected" && (
|
||
|
|
<Dialog>
|
||
|
|
<DialogTrigger asChild>
|
||
|
|
<Button size="sm" className="bg-green-600 hover:bg-green-700 text-white">
|
||
|
|
<CheckCircle className="h-3 w-3 mr-1" />
|
||
|
|
Verifikasi
|
||
|
|
</Button>
|
||
|
|
</DialogTrigger>
|
||
|
|
<DialogContent>
|
||
|
|
<DialogHeader>
|
||
|
|
<DialogTitle>Konfirmasi Verifikasi</DialogTitle>
|
||
|
|
<DialogDescription>
|
||
|
|
Anggota ini sebelumnya ditolak. Apakah Anda yakin ingin memverifikasinya sekarang?
|
||
|
|
</DialogDescription>
|
||
|
|
</DialogHeader>
|
||
|
|
<div className="py-4">
|
||
|
|
<div className="space-y-2">
|
||
|
|
<p>
|
||
|
|
<strong>Nama:</strong> {member.name}
|
||
|
|
</p>
|
||
|
|
<p>
|
||
|
|
<strong>ID Anggota:</strong> {member.memberId}
|
||
|
|
</p>
|
||
|
|
<p>
|
||
|
|
<strong>Status Saat Ini:</strong> <span className="text-red-600">Ditolak</span>
|
||
|
|
</p>
|
||
|
|
<p className="text-green-600">
|
||
|
|
<strong>Aksi:</strong> Anggota akan dapat login dan voting
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div className="flex justify-end gap-3">
|
||
|
|
<DialogTrigger asChild>
|
||
|
|
<Button variant="outline">Batal</Button>
|
||
|
|
</DialogTrigger>
|
||
|
|
<Button
|
||
|
|
onClick={() => handleStatusChange(member.id, "verified")}
|
||
|
|
disabled={loading}
|
||
|
|
className="bg-green-600 hover:bg-green-700"
|
||
|
|
>
|
||
|
|
{loading ? "Memverifikasi..." : "Ya, Verifikasi"}
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Button untuk status verified */}
|
||
|
|
{member.memberStatus === "verified" && (
|
||
|
|
<Dialog>
|
||
|
|
<DialogTrigger asChild>
|
||
|
|
<Button
|
||
|
|
size="sm"
|
||
|
|
variant="outline"
|
||
|
|
className="border-yellow-300 text-yellow-700 hover:bg-yellow-50 bg-transparent"
|
||
|
|
>
|
||
|
|
<Clock className="h-3 w-3 mr-1" />
|
||
|
|
Tunda
|
||
|
|
</Button>
|
||
|
|
</DialogTrigger>
|
||
|
|
<DialogContent>
|
||
|
|
<DialogHeader>
|
||
|
|
<DialogTitle>Konfirmasi Penangguhan</DialogTitle>
|
||
|
|
<DialogDescription>
|
||
|
|
Apakah Anda yakin ingin menangguhkan verifikasi anggota ini?
|
||
|
|
</DialogDescription>
|
||
|
|
</DialogHeader>
|
||
|
|
<div className="py-4">
|
||
|
|
<div className="space-y-2">
|
||
|
|
<p>
|
||
|
|
<strong>Nama:</strong> {member.name}
|
||
|
|
</p>
|
||
|
|
<p>
|
||
|
|
<strong>ID Anggota:</strong> {member.memberId}
|
||
|
|
</p>
|
||
|
|
<p>
|
||
|
|
<strong>Status Saat Ini:</strong>{" "}
|
||
|
|
<span className="text-green-600">Terverifikasi</span>
|
||
|
|
</p>
|
||
|
|
<p className="text-yellow-600">
|
||
|
|
<strong>Aksi:</strong> Anggota tidak akan dapat login sementara
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div className="flex justify-end gap-3">
|
||
|
|
<DialogTrigger asChild>
|
||
|
|
<Button variant="outline">Batal</Button>
|
||
|
|
</DialogTrigger>
|
||
|
|
<Button
|
||
|
|
onClick={() => handleStatusChange(member.id, "pending")}
|
||
|
|
disabled={loading}
|
||
|
|
className="bg-yellow-600 hover:bg-yellow-700 text-white"
|
||
|
|
>
|
||
|
|
{loading ? "Menangguhkan..." : "Ya, Tunda"}
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Debug info - hapus ini setelah testing */}
|
||
|
|
<span className="text-xs text-gray-500 ml-2">Status: {member.memberStatus}</span>
|
||
|
|
</div>
|
||
|
|
</TableCell>
|
||
|
|
</TableRow>
|
||
|
|
))}
|
||
|
|
</TableBody>
|
||
|
|
</Table>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Mobile Card View */}
|
||
|
|
<div className="lg:hidden space-y-4">
|
||
|
|
{filteredMembers.map((member) => (
|
||
|
|
<Card key={member.id} className="p-4">
|
||
|
|
<div className="space-y-3">
|
||
|
|
<div className="flex justify-between items-start">
|
||
|
|
<div>
|
||
|
|
<h3 className="font-semibold text-lg">{member.name}</h3>
|
||
|
|
<p className="text-sm text-gray-600">{member.memberId}</p>
|
||
|
|
<p className="text-xs text-gray-500 font-mono bg-gray-100 px-2 py-1 rounded inline-block mt-1">
|
||
|
|
@{member.username}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
{getStatusBadge(member.memberStatus)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||
|
|
<div>
|
||
|
|
<span className="text-gray-500">Departemen:</span>
|
||
|
|
<p className="font-medium">{member.department}</p>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<span className="text-gray-500">Bergabung:</span>
|
||
|
|
<p className="font-medium">
|
||
|
|
{member.joinDate ? new Date(member.joinDate).toLocaleDateString("id-ID") : "-"}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="text-sm">
|
||
|
|
<span className="text-gray-500">Login Terakhir:</span>
|
||
|
|
<p className="font-medium">
|
||
|
|
{member.lastLogin
|
||
|
|
? new Date(member.lastLogin).toLocaleDateString("id-ID")
|
||
|
|
: "Belum pernah login"}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Mobile Action Buttons */}
|
||
|
|
<div className="flex gap-2 pt-2">
|
||
|
|
{member.memberStatus === "pending" && (
|
||
|
|
<>
|
||
|
|
<Dialog>
|
||
|
|
<DialogTrigger asChild>
|
||
|
|
<Button size="sm" className="bg-green-600 hover:bg-green-700 text-white flex-1">
|
||
|
|
<CheckCircle className="h-3 w-3 mr-1" />
|
||
|
|
Verifikasi
|
||
|
|
</Button>
|
||
|
|
</DialogTrigger>
|
||
|
|
<DialogContent>
|
||
|
|
<DialogHeader>
|
||
|
|
<DialogTitle>Konfirmasi Verifikasi</DialogTitle>
|
||
|
|
<DialogDescription>
|
||
|
|
Apakah Anda yakin ingin memverifikasi anggota berikut?
|
||
|
|
</DialogDescription>
|
||
|
|
</DialogHeader>
|
||
|
|
<div className="py-4">
|
||
|
|
<div className="space-y-2">
|
||
|
|
<p>
|
||
|
|
<strong>Nama:</strong> {member.name}
|
||
|
|
</p>
|
||
|
|
<p>
|
||
|
|
<strong>ID Anggota:</strong> {member.memberId}
|
||
|
|
</p>
|
||
|
|
<p>
|
||
|
|
<strong>Departemen:</strong> {member.department}
|
||
|
|
</p>
|
||
|
|
<p>
|
||
|
|
<strong>Status Saat Ini:</strong> Menunggu Verifikasi
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div className="flex justify-end gap-3">
|
||
|
|
<DialogTrigger asChild>
|
||
|
|
<Button variant="outline">Batal</Button>
|
||
|
|
</DialogTrigger>
|
||
|
|
<Button
|
||
|
|
onClick={() => handleStatusChange(member.id, "verified")}
|
||
|
|
disabled={loading}
|
||
|
|
className="bg-green-600 hover:bg-green-700"
|
||
|
|
>
|
||
|
|
{loading ? "Memverifikasi..." : "Ya, Verifikasi"}
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
<Dialog>
|
||
|
|
<DialogTrigger asChild>
|
||
|
|
<Button size="sm" variant="destructive" className="flex-1">
|
||
|
|
<XCircle className="h-3 w-3 mr-1" />
|
||
|
|
Tolak
|
||
|
|
</Button>
|
||
|
|
</DialogTrigger>
|
||
|
|
<DialogContent>
|
||
|
|
<DialogHeader>
|
||
|
|
<DialogTitle>Konfirmasi Penolakan</DialogTitle>
|
||
|
|
<DialogDescription>
|
||
|
|
Apakah Anda yakin ingin menolak verifikasi anggota berikut?
|
||
|
|
</DialogDescription>
|
||
|
|
</DialogHeader>
|
||
|
|
<div className="py-4">
|
||
|
|
<div className="space-y-2">
|
||
|
|
<p>
|
||
|
|
<strong>Nama:</strong> {member.name}
|
||
|
|
</p>
|
||
|
|
<p>
|
||
|
|
<strong>ID Anggota:</strong> {member.memberId}
|
||
|
|
</p>
|
||
|
|
<p>
|
||
|
|
<strong>Departemen:</strong> {member.department}
|
||
|
|
</p>
|
||
|
|
<p className="text-red-600">
|
||
|
|
<strong>Aksi:</strong> Anggota akan ditolak dan tidak dapat login
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div className="flex justify-end gap-3">
|
||
|
|
<DialogTrigger asChild>
|
||
|
|
<Button variant="outline">Batal</Button>
|
||
|
|
</DialogTrigger>
|
||
|
|
<Button
|
||
|
|
onClick={() => handleStatusChange(member.id, "rejected")}
|
||
|
|
disabled={loading}
|
||
|
|
variant="destructive"
|
||
|
|
>
|
||
|
|
{loading ? "Menolak..." : "Ya, Tolak"}
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{member.memberStatus === "rejected" && (
|
||
|
|
<Dialog>
|
||
|
|
<DialogTrigger asChild>
|
||
|
|
<Button size="sm" className="bg-green-600 hover:bg-green-700 text-white w-full">
|
||
|
|
<CheckCircle className="h-3 w-3 mr-1" />
|
||
|
|
Verifikasi
|
||
|
|
</Button>
|
||
|
|
</DialogTrigger>
|
||
|
|
<DialogContent>
|
||
|
|
<DialogHeader>
|
||
|
|
<DialogTitle>Konfirmasi Verifikasi</DialogTitle>
|
||
|
|
<DialogDescription>
|
||
|
|
Anggota ini sebelumnya ditolak. Apakah Anda yakin ingin memverifikasinya sekarang?
|
||
|
|
</DialogDescription>
|
||
|
|
</DialogHeader>
|
||
|
|
<div className="py-4">
|
||
|
|
<div className="space-y-2">
|
||
|
|
<p>
|
||
|
|
<strong>Nama:</strong> {member.name}
|
||
|
|
</p>
|
||
|
|
<p>
|
||
|
|
<strong>ID Anggota:</strong> {member.memberId}
|
||
|
|
</p>
|
||
|
|
<p>
|
||
|
|
<strong>Status Saat Ini:</strong> <span className="text-red-600">Ditolak</span>
|
||
|
|
</p>
|
||
|
|
<p className="text-green-600">
|
||
|
|
<strong>Aksi:</strong> Anggota akan dapat login dan voting
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div className="flex justify-end gap-3">
|
||
|
|
<DialogTrigger asChild>
|
||
|
|
<Button variant="outline">Batal</Button>
|
||
|
|
</DialogTrigger>
|
||
|
|
<Button
|
||
|
|
onClick={() => handleStatusChange(member.id, "verified")}
|
||
|
|
disabled={loading}
|
||
|
|
className="bg-green-600 hover:bg-green-700"
|
||
|
|
>
|
||
|
|
{loading ? "Memverifikasi..." : "Ya, Verifikasi"}
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{member.memberStatus === "verified" && (
|
||
|
|
<Dialog>
|
||
|
|
<DialogTrigger asChild>
|
||
|
|
<Button
|
||
|
|
size="sm"
|
||
|
|
variant="outline"
|
||
|
|
className="border-yellow-300 text-yellow-700 hover:bg-yellow-50 bg-transparent w-full"
|
||
|
|
>
|
||
|
|
<Clock className="h-3 w-3 mr-1" />
|
||
|
|
Tunda
|
||
|
|
</Button>
|
||
|
|
</DialogTrigger>
|
||
|
|
<DialogContent>
|
||
|
|
<DialogHeader>
|
||
|
|
<DialogTitle>Konfirmasi Penangguhan</DialogTitle>
|
||
|
|
<DialogDescription>
|
||
|
|
Apakah Anda yakin ingin menangguhkan verifikasi anggota ini?
|
||
|
|
</DialogDescription>
|
||
|
|
</DialogHeader>
|
||
|
|
<div className="py-4">
|
||
|
|
<div className="space-y-2">
|
||
|
|
<p>
|
||
|
|
<strong>Nama:</strong> {member.name}
|
||
|
|
</p>
|
||
|
|
<p>
|
||
|
|
<strong>ID Anggota:</strong> {member.memberId}
|
||
|
|
</p>
|
||
|
|
<p>
|
||
|
|
<strong>Status Saat Ini:</strong>{" "}
|
||
|
|
<span className="text-green-600">Terverifikasi</span>
|
||
|
|
</p>
|
||
|
|
<p className="text-yellow-600">
|
||
|
|
<strong>Aksi:</strong> Anggota tidak akan dapat login sementara
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div className="flex justify-end gap-3">
|
||
|
|
<DialogTrigger asChild>
|
||
|
|
<Button variant="outline">Batal</Button>
|
||
|
|
</DialogTrigger>
|
||
|
|
<Button
|
||
|
|
onClick={() => handleStatusChange(member.id, "pending")}
|
||
|
|
disabled={loading}
|
||
|
|
className="bg-yellow-600 hover:bg-yellow-700 text-white"
|
||
|
|
>
|
||
|
|
{loading ? "Menangguhkan..." : "Ya, Tunda"}
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</Card>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{filteredMembers.length === 0 && (
|
||
|
|
<div className="text-center py-8">
|
||
|
|
<Users className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||
|
|
<h3 className="text-lg font-medium mb-2">Tidak ada anggota ditemukan</h3>
|
||
|
|
<p className="text-gray-600">Coba ubah filter atau kata kunci pencarian</p>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function MembersPage() {
|
||
|
|
return (
|
||
|
|
<AuthGuard requiredRole="admin">
|
||
|
|
<MembersPageContent />
|
||
|
|
</AuthGuard>
|
||
|
|
)
|
||
|
|
}
|