feat: complete refactoring phase 1 with API, component, and business logic consolidation
Co-authored-by: cetinmustafa83 <30570791+cetinmustafa83@users.noreply.github.com>
This commit is contained in:
17 files changed
+3061
-1
No files matched your search
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
export interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
description?: string;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
onCancel: () => void;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
isLoading?: boolean;
|
||||
variant?: 'default' | 'destructive';
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
confirmText = 'Confirm',
|
||||
cancelText = 'Cancel',
|
||||
isLoading = false,
|
||||
variant = 'default',
|
||||
}: ConfirmDialogProps) {
|
||||
const [isExecuting, setIsExecuting] = React.useState(false);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setIsExecuting(true);
|
||||
try {
|
||||
await onConfirm();
|
||||
} finally {
|
||||
setIsExecuting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={(o) => !o && onCancel()}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
{description && <AlertDialogDescription>{description}</AlertDialogDescription>}
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={onCancel} disabled={isExecuting}>
|
||||
{cancelText}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirm}
|
||||
disabled={isExecuting}
|
||||
className={variant === 'destructive' ? 'bg-red-600 hover:bg-red-700' : ''}
|
||||
>
|
||||
{isExecuting ? 'Loading...' : confirmText}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ChevronDown, MoreHorizontal, Search } from 'lucide-react';
|
||||
|
||||
export interface DataTableColumn<T> {
|
||||
key: keyof T;
|
||||
label: string;
|
||||
sortable?: boolean;
|
||||
filterable?: boolean;
|
||||
render?: (value: any, row: T) => React.ReactNode;
|
||||
width?: string;
|
||||
}
|
||||
|
||||
export interface DataTableAction<T> {
|
||||
label: string;
|
||||
onClick: (row: T) => void;
|
||||
variant?: 'default' | 'destructive';
|
||||
}
|
||||
|
||||
export interface DataTableProps<T> {
|
||||
columns: DataTableColumn<T>[];
|
||||
data: T[];
|
||||
actions?: DataTableAction<T>[];
|
||||
onRowClick?: (row: T) => void;
|
||||
searchableFields?: (keyof T)[];
|
||||
isLoading?: boolean;
|
||||
rowKey?: keyof T;
|
||||
}
|
||||
|
||||
export function DataTable<T extends Record<string, any>>({
|
||||
columns,
|
||||
data,
|
||||
actions,
|
||||
onRowClick,
|
||||
searchableFields = [],
|
||||
isLoading = false,
|
||||
rowKey = 'id' as keyof T,
|
||||
}: DataTableProps<T>) {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [sortConfig, setSortConfig] = useState<{
|
||||
key: keyof T;
|
||||
direction: 'asc' | 'desc';
|
||||
} | null>(null);
|
||||
|
||||
// Filter data based on search
|
||||
const filteredData = useMemo(() => {
|
||||
if (!searchTerm || searchableFields.length === 0) return data;
|
||||
|
||||
return data.filter((row) =>
|
||||
searchableFields.some((field) => {
|
||||
const value = row[field];
|
||||
return value?.toString().toLowerCase().includes(searchTerm.toLowerCase());
|
||||
})
|
||||
);
|
||||
}, [data, searchTerm, searchableFields]);
|
||||
|
||||
// Sort data
|
||||
const sortedData = useMemo(() => {
|
||||
if (!sortConfig) return filteredData;
|
||||
|
||||
return [...filteredData].sort((a, b) => {
|
||||
const aVal = a[sortConfig.key];
|
||||
const bVal = b[sortConfig.key];
|
||||
|
||||
if (aVal < bVal) return sortConfig.direction === 'asc' ? -1 : 1;
|
||||
if (aVal > bVal) return sortConfig.direction === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
}, [filteredData, sortConfig]);
|
||||
|
||||
const handleSort = (key: keyof T) => {
|
||||
setSortConfig((prev) =>
|
||||
prev?.key === key && prev.direction === 'asc'
|
||||
? { key, direction: 'desc' }
|
||||
: { key, direction: 'asc' }
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-center py-8 text-gray-500">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{searchableFields.length > 0 && (
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-2.5 h-5 w-5 text-gray-400" />
|
||||
<Input
|
||||
placeholder="Search..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto rounded-lg border border-gray-200">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50">
|
||||
{columns.map((col) => (
|
||||
<TableHead
|
||||
key={String(col.key)}
|
||||
style={{ width: col.width }}
|
||||
className="font-semibold text-gray-900"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{col.label}</span>
|
||||
{col.sortable && (
|
||||
<button
|
||||
onClick={() => handleSort(col.key)}
|
||||
className="hover:bg-gray-100 p-1 rounded"
|
||||
>
|
||||
<ChevronDown
|
||||
className="h-4 w-4 text-gray-400"
|
||||
style={{
|
||||
transform:
|
||||
sortConfig?.key === col.key && sortConfig.direction === 'desc'
|
||||
? 'rotate(180deg)'
|
||||
: 'rotate(0deg)',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</TableHead>
|
||||
))}
|
||||
{actions && <TableHead className="w-12"></TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedData.map((row, idx) => (
|
||||
<TableRow
|
||||
key={String(row[rowKey]) || idx}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
className="cursor-pointer hover:bg-gray-50"
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<TableCell key={String(col.key)} className="text-gray-700">
|
||||
{col.render ? col.render(row[col.key], row) : row[col.key]}
|
||||
</TableCell>
|
||||
))}
|
||||
{actions && (
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{actions.map((action) => (
|
||||
<DropdownMenuItem
|
||||
key={action.label}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
action.onClick(row);
|
||||
}}
|
||||
className={
|
||||
action.variant === 'destructive' ? 'text-red-600' : ''
|
||||
}
|
||||
>
|
||||
{action.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{sortedData.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">No data found</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
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 { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
export type FieldType = 'text' | 'email' | 'number' | 'select' | 'textarea' | 'checkbox';
|
||||
|
||||
export interface FormField {
|
||||
name: string;
|
||||
label: string;
|
||||
type: FieldType;
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
options?: Array<{ value: string; label: string }>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface FormBuilderProps {
|
||||
fields: FormField[];
|
||||
onSubmit: (data: any) => void | Promise<void>;
|
||||
submitLabel?: string;
|
||||
isLoading?: boolean;
|
||||
defaultValues?: Record<string, any>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FormBuilder({
|
||||
fields,
|
||||
onSubmit,
|
||||
submitLabel = 'Submit',
|
||||
isLoading = false,
|
||||
defaultValues = {},
|
||||
className = '',
|
||||
}: FormBuilderProps) {
|
||||
const { register, handleSubmit, formState: { errors }, watch } = useForm({
|
||||
defaultValues,
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
||||
|
||||
const onSubmitHandler = async (data: any) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onSubmit(data);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmitHandler)} className={`space-y-6 ${className}`}>
|
||||
{fields.map((field) => (
|
||||
<div key={field.name} className={field.className || ''}>
|
||||
<Label htmlFor={field.name} className="font-medium text-gray-900">
|
||||
{field.label}
|
||||
{field.required && <span className="text-red-600 ml-1">*</span>}
|
||||
</Label>
|
||||
|
||||
{field.type === 'select' && (
|
||||
<Select
|
||||
defaultValue={defaultValues[field.name]}
|
||||
onValueChange={(value) => {
|
||||
const event = {
|
||||
target: { name: field.name, value },
|
||||
} as any;
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id={field.name} className="mt-2">
|
||||
<SelectValue placeholder={field.placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{field.options?.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{field.type === 'textarea' && (
|
||||
<Textarea
|
||||
id={field.name}
|
||||
placeholder={field.placeholder}
|
||||
{...register(field.name, {
|
||||
required: field.required ? `${field.label} is required` : false,
|
||||
})}
|
||||
className="mt-2"
|
||||
/>
|
||||
)}
|
||||
|
||||
{['text', 'email', 'number'].includes(field.type) && (
|
||||
<Input
|
||||
id={field.name}
|
||||
type={field.type}
|
||||
placeholder={field.placeholder}
|
||||
{...register(field.name, {
|
||||
required: field.required ? `${field.label} is required` : false,
|
||||
pattern:
|
||||
field.type === 'email'
|
||||
? { value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: 'Invalid email' }
|
||||
: undefined,
|
||||
})}
|
||||
className="mt-2"
|
||||
/>
|
||||
)}
|
||||
|
||||
{field.type === 'checkbox' && (
|
||||
<input
|
||||
id={field.name}
|
||||
type="checkbox"
|
||||
{...register(field.name)}
|
||||
className="mt-2 rounded"
|
||||
/>
|
||||
)}
|
||||
|
||||
{errors[field.name] && (
|
||||
<span className="text-red-600 text-sm mt-1 block">
|
||||
{errors[field.name]?.message as string}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading || isSubmitting}
|
||||
className="w-full"
|
||||
>
|
||||
{isLoading || isSubmitting ? 'Loading...' : submitLabel}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import {
|
||||
processGrade,
|
||||
calculateWeightedGrade,
|
||||
getStatistics,
|
||||
type GradingConfig,
|
||||
type GradeOutput,
|
||||
} from '@/lib/grading-engine';
|
||||
import { useApiGet, useApiMutation } from '@/lib/hooks/useApi';
|
||||
|
||||
export interface GradingPanelProps {
|
||||
assessmentId: string;
|
||||
studentId?: string; // if provided, show single student; otherwise show class
|
||||
config: GradingConfig;
|
||||
onGradeSaved?: (grade: any) => void;
|
||||
}
|
||||
|
||||
export function GradingPanel({
|
||||
assessmentId,
|
||||
studentId,
|
||||
config,
|
||||
onGradeSaved,
|
||||
}: GradingPanelProps) {
|
||||
const [selectedStudent, setSelectedStudent] = useState<string | null>(studentId || null);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
const [pendingGrade, setPendingGrade] = useState<any>(null);
|
||||
|
||||
// Fetch assessment and results
|
||||
const { data: assessment } = useApiGet(
|
||||
`/api/v1/assessments/${assessmentId}`
|
||||
);
|
||||
|
||||
const { data: results, mutate: refetchResults } = useApiGet(
|
||||
studentId
|
||||
? `/api/v1/assessments/${assessmentId}/results?studentId=${studentId}`
|
||||
: `/api/v1/assessments/${assessmentId}/results`
|
||||
);
|
||||
|
||||
const { mutate: saveGrade, isLoading: isSaving } = useApiMutation(
|
||||
`/api/v1/assessments/${assessmentId}/grades`,
|
||||
{
|
||||
method: 'POST',
|
||||
onSuccess: () => {
|
||||
setShowConfirm(false);
|
||||
setPendingGrade(null);
|
||||
refetchResults();
|
||||
onGradeSaved?.(pendingGrade);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const handleGradeChange = (grade: number | string, studentId: string) => {
|
||||
setPendingGrade({ studentId, score: grade });
|
||||
setShowConfirm(true);
|
||||
};
|
||||
|
||||
const handleConfirmGrade = async () => {
|
||||
await saveGrade(pendingGrade);
|
||||
};
|
||||
|
||||
// Statistics
|
||||
const stats = results?.length
|
||||
? getStatistics(
|
||||
results.map((r: any) => r.score),
|
||||
config
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Assessment Header */}
|
||||
{assessment && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{assessment.title}</CardTitle>
|
||||
<CardDescription>
|
||||
{assessment.type} · Max Score: {assessment.maxScore}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Tabs defaultValue="list" className="w-full">
|
||||
<TabsList>
|
||||
<TabsTrigger value="list">Students</TabsTrigger>
|
||||
<TabsTrigger value="analytics">Analytics</TabsTrigger>
|
||||
{studentId && <TabsTrigger value="detail">Details</TabsTrigger>}
|
||||
</TabsList>
|
||||
|
||||
{/* Student List */}
|
||||
<TabsContent value="list" className="space-y-4">
|
||||
{results && (
|
||||
<DataTable
|
||||
columns={[
|
||||
{
|
||||
key: 'student' as any,
|
||||
label: 'Student',
|
||||
render: (_, row) => `${row.student?.firstName} ${row.student?.lastName}`,
|
||||
},
|
||||
{
|
||||
key: 'score' as any,
|
||||
label: 'Score',
|
||||
sortable: true,
|
||||
render: (_, row) => (
|
||||
<Input
|
||||
type="number"
|
||||
value={row.score || ''}
|
||||
onChange={(e) => handleGradeChange(e.target.value, row.studentId)}
|
||||
className="w-20"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'feedback' as any,
|
||||
label: 'Feedback',
|
||||
render: (_, row) => {
|
||||
const processed = processGrade(
|
||||
{
|
||||
score: row.score,
|
||||
studentId: row.studentId,
|
||||
assessmentId: row.assessmentId,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
config
|
||||
);
|
||||
return (
|
||||
<span className={processed.isPassing ? 'text-green-600' : 'text-red-600'}>
|
||||
{processed.isPassing ? 'Pass' : 'Fail'}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
data={results}
|
||||
isLoading={!results}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Analytics */}
|
||||
<TabsContent value="analytics" className="space-y-4">
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Average</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.average.toFixed(1)}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Median</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.median.toFixed(1)}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Passing</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{stats.passingPercentage.toFixed(0)}%
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Std Dev</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.stdDev.toFixed(1)}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Single Student Details */}
|
||||
{studentId && results && (
|
||||
<TabsContent value="detail" className="space-y-4">
|
||||
{results[0] && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{results[0].student?.firstName} {results[0].student?.lastName}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label>Current Score</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={results[0].score || ''}
|
||||
onChange={(e) => handleGradeChange(e.target.value, results[0].studentId)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Notes</Label>
|
||||
<Input
|
||||
value={results[0].notes || ''}
|
||||
placeholder="Add notes..."
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
|
||||
{/* Confirm Dialog */}
|
||||
<ConfirmDialog
|
||||
open={showConfirm}
|
||||
title="Confirm Grade"
|
||||
description={`Are you sure you want to save this grade for student ${selectedStudent}?`}
|
||||
onConfirm={handleConfirmGrade}
|
||||
onCancel={() => setShowConfirm(false)}
|
||||
isLoading={isSaving}
|
||||
confirmText="Save Grade"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user