feat: optimized participants list with sorting and search
This commit is contained in:
@@ -5,6 +5,7 @@ import { participant } from '@/lib/model/participant';
|
|||||||
import { annotation } from '@/lib/model/annotation';
|
import { annotation } from '@/lib/model/annotation';
|
||||||
import { experiment } from '@/lib/model/experiment';
|
import { experiment } from '@/lib/model/experiment';
|
||||||
import { dataset_entry } from '@/lib/model/dataset_entry';
|
import { dataset_entry } from '@/lib/model/dataset_entry';
|
||||||
|
import { user } from '@/lib/model/auth-schema';
|
||||||
import { and, eq, count } from 'drizzle-orm';
|
import { and, eq, count } from 'drizzle-orm';
|
||||||
import { requireAdmin } from '@/lib/auth';
|
import { requireAdmin } from '@/lib/auth';
|
||||||
import { isParticipantCalibrationDone, getDialectScoresFromCalibration } from './calibration-scoring';
|
import { isParticipantCalibrationDone, getDialectScoresFromCalibration } from './calibration-scoring';
|
||||||
@@ -13,6 +14,7 @@ export interface ParticipantListItem {
|
|||||||
id: number;
|
id: number;
|
||||||
experimentId: number;
|
experimentId: number;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
email: string;
|
||||||
completedOnboarding: boolean;
|
completedOnboarding: boolean;
|
||||||
completedCalibration: boolean;
|
completedCalibration: boolean;
|
||||||
annotationCount: number;
|
annotationCount: number;
|
||||||
@@ -53,8 +55,18 @@ export async function getParticipantsList(experimentId: number): Promise<Partici
|
|||||||
|
|
||||||
// Get all participants for this experiment
|
// Get all participants for this experiment
|
||||||
const participants = await db
|
const participants = await db
|
||||||
.select()
|
.select({
|
||||||
|
id: participant.id,
|
||||||
|
experimentId: participant.experimentId,
|
||||||
|
userId: participant.userId,
|
||||||
|
email: user.email,
|
||||||
|
calibrationAnswers: participant.calibrationAnswers,
|
||||||
|
onboardingAnswers: participant.onboardingAnswers,
|
||||||
|
createdAt: participant.createdAt,
|
||||||
|
updatedAt: participant.updatedAt,
|
||||||
|
})
|
||||||
.from(participant)
|
.from(participant)
|
||||||
|
.leftJoin(user, eq(participant.userId, user.id))
|
||||||
.where(eq(participant.experimentId, experimentId));
|
.where(eq(participant.experimentId, experimentId));
|
||||||
|
|
||||||
// Build list with annotation counts
|
// Build list with annotation counts
|
||||||
@@ -81,6 +93,7 @@ export async function getParticipantsList(experimentId: number): Promise<Partici
|
|||||||
id: p.id,
|
id: p.id,
|
||||||
experimentId: p.experimentId!,
|
experimentId: p.experimentId!,
|
||||||
userId: p.userId,
|
userId: p.userId,
|
||||||
|
email: p.email || 'Unknown',
|
||||||
completedOnboarding: p.onboardingAnswers !== null && p.onboardingAnswers !== undefined,
|
completedOnboarding: p.onboardingAnswers !== null && p.onboardingAnswers !== undefined,
|
||||||
completedCalibration,
|
completedCalibration,
|
||||||
annotationCount,
|
annotationCount,
|
||||||
|
|||||||
@@ -2,13 +2,60 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { ParticipantListItem } from '@/app/actions/participants';
|
import { ParticipantListItem } from '@/app/actions/participants';
|
||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
|
||||||
interface ParticipantsListProps {
|
interface ParticipantsListProps {
|
||||||
experimentId: number;
|
experimentId: number;
|
||||||
participants: ParticipantListItem[];
|
participants: ParticipantListItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SortField = 'userId' | 'email' | 'createdAt' | 'annotationCount';
|
||||||
|
type SortOrder = 'asc' | 'desc';
|
||||||
|
|
||||||
export default function ParticipantsList({ experimentId, participants }: ParticipantsListProps) {
|
export default function ParticipantsList({ experimentId, participants }: ParticipantsListProps) {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [sortField, setSortField] = useState<SortField>('createdAt');
|
||||||
|
const [sortOrder, setSortOrder] = useState<SortOrder>('desc');
|
||||||
|
|
||||||
|
const handleSort = (field: SortField) => {
|
||||||
|
if (sortField === field) {
|
||||||
|
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
||||||
|
} else {
|
||||||
|
setSortField(field);
|
||||||
|
setSortOrder('asc');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSortIndicator = (field: SortField) => {
|
||||||
|
if (sortField !== field) return ' ↑↓';
|
||||||
|
return sortOrder === 'asc' ? ' ↑' : ' ↓';
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredAndSortedParticipants = useMemo(() => {
|
||||||
|
let filtered = participants.filter((p) => {
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
return (
|
||||||
|
p.userId.toLowerCase().includes(query) ||
|
||||||
|
p.email.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
let aValue: any = a[sortField];
|
||||||
|
let bValue: any = b[sortField];
|
||||||
|
|
||||||
|
if (sortField === 'createdAt') {
|
||||||
|
aValue = new Date(aValue).getTime();
|
||||||
|
bValue = new Date(bValue).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
const comparison = aValue < bValue ? -1 : aValue > bValue ? 1 : 0;
|
||||||
|
return sortOrder === 'asc' ? comparison : -comparison;
|
||||||
|
});
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}, [participants, searchQuery, sortField, sortOrder]);
|
||||||
|
|
||||||
if (participants.length === 0) {
|
if (participants.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
@@ -18,23 +65,50 @@ export default function ParticipantsList({ experimentId, participants }: Partici
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Search Bar */}
|
||||||
|
<div className="bg-white rounded-lg shadow p-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by User ID or Email..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-gray-50 border-b border-gray-200">
|
<tr className="bg-gray-50 border-b border-gray-200">
|
||||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-900">User ID</th>
|
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-900 cursor-pointer hover:bg-gray-100"
|
||||||
|
onClick={() => handleSort('userId')}>
|
||||||
|
User ID{getSortIndicator('userId')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-900 cursor-pointer hover:bg-gray-100"
|
||||||
|
onClick={() => handleSort('email')}>
|
||||||
|
Email{getSortIndicator('email')}
|
||||||
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-900">Onboarding</th>
|
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-900">Onboarding</th>
|
||||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-900">Calibration</th>
|
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-900">Calibration</th>
|
||||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-900">Annotations</th>
|
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-900 cursor-pointer hover:bg-gray-100"
|
||||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-900">Joined</th>
|
onClick={() => handleSort('annotationCount')}>
|
||||||
|
Annotations{getSortIndicator('annotationCount')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-900 cursor-pointer hover:bg-gray-100"
|
||||||
|
onClick={() => handleSort('createdAt')}>
|
||||||
|
Joined{getSortIndicator('createdAt')}
|
||||||
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-900">Action</th>
|
<th className="px-6 py-3 text-left text-sm font-semibold text-gray-900">Action</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-200">
|
<tbody className="divide-y divide-gray-200">
|
||||||
{participants.map((participant) => (
|
{filteredAndSortedParticipants.map((participant) => (
|
||||||
<tr key={participant.id} className="hover:bg-gray-50 transition-colors">
|
<tr key={participant.id} className="hover:bg-gray-50 transition-colors">
|
||||||
<td className="px-6 py-4 text-sm text-gray-900 font-medium">{participant.userId}</td>
|
<td className="px-6 py-4 text-sm text-gray-900 font-medium">{participant.userId}</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-600">{participant.email}</td>
|
||||||
<td className="px-6 py-4 text-sm">
|
<td className="px-6 py-4 text-sm">
|
||||||
<span
|
<span
|
||||||
className={`inline-block px-3 py-1 rounded-full text-xs font-medium ${
|
className={`inline-block px-3 py-1 rounded-full text-xs font-medium ${
|
||||||
@@ -78,6 +152,12 @@ export default function ParticipantsList({ experimentId, participants }: Partici
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
{filteredAndSortedParticipants.length === 0 && (
|
||||||
|
<div className="px-6 py-8 text-center text-gray-600">
|
||||||
|
No participants match your search
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user