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:
v0andcetinmustafa83 committed 2026-07-31 13:46:13 +00:00
1 parent 36a6206c3b
commit 8b33280c20
17 files changed
+3061 -1

No files matched your search

+347
View File
@@ -0,0 +1,347 @@
'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 { useApiGet, useApiMutation } from '@/lib/hooks/useApi';
import { getPriorityColor, getMessageTypeIcon, formatDisplayTime } from '@/lib/signage-utils';
import { useSession } from '@/lib/auth';
import { AlertCircle, Tv, Plus } from 'lucide-react';
export default function SignageAdminPage() {
const session = useSession() as any;
const schoolId = session?.user?.schoolId;
const [showNewMessage, setShowNewMessage] = useState(false);
const [selectedSignage, setSelectedSignage] = useState<string | null>(null);
const [showConfirm, setShowConfirm] = useState(false);
// Fetch all signages
const { data: signages, mutate: refetchSignages } = useApiGet(
schoolId ? `/api/v1/signage?schoolId=${schoolId}` : null
);
// Fetch messages for selected signage
const { data: messages, mutate: refetchMessages } = useApiGet(
selectedSignage && schoolId
? `/api/v1/signage/${selectedSignage}/messages`
: null
);
const { mutate: createMessage, isLoading: isCreating } = useApiMutation(
`/api/v1/signage/${selectedSignage}/messages`,
{
method: 'POST',
onSuccess: () => {
setShowNewMessage(false);
refetchMessages();
},
}
);
const { mutate: deleteMessage, isLoading: isDeleting } = useApiMutation(
`/api/v1/signage/messages/delete`,
{
method: 'DELETE',
onSuccess: () => {
setShowConfirm(false);
refetchMessages();
},
}
);
const handleCreateMessage = async (e: React.FormEvent) => {
e.preventDefault();
const formData = new FormData(e.target as HTMLFormElement);
const message = {
title: formData.get('title'),
content: formData.get('content'),
messageType: formData.get('messageType'),
priority: formData.get('priority'),
displayDuration: parseInt(formData.get('displayDuration') as string) || 10,
soundAlert: formData.get('soundAlert') === 'on',
};
await createMessage(message);
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">Emergency Signage</h1>
<p className="text-gray-600 mt-1">Manage displays, messages, and emergency alerts</p>
</div>
<Button onClick={() => setShowNewMessage(true)} size="lg" className="gap-2">
<Plus className="h-5 w-5" />
New Message
</Button>
</div>
<Tabs defaultValue="displays" className="w-full">
<TabsList>
<TabsTrigger value="displays">Displays</TabsTrigger>
<TabsTrigger value="messages">Messages</TabsTrigger>
<TabsTrigger value="activity">Activity Log</TabsTrigger>
</TabsList>
{/* Displays Tab */}
<TabsContent value="displays" className="space-y-4">
{signages && (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{signages.map((signage: any) => (
<Card
key={signage.id}
className="cursor-pointer hover:shadow-lg transition-shadow"
onClick={() => setSelectedSignage(signage.id)}
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex-1">
<CardTitle className="text-lg">{signage.displayName}</CardTitle>
<CardDescription>{signage.location}</CardDescription>
</div>
<Tv className="h-5 w-5 text-gray-400" />
</div>
</CardHeader>
<CardContent className="space-y-2">
<div className="grid grid-cols-2 gap-2 text-sm">
<div>
<p className="text-gray-600">Status</p>
<p className={signage.isActive ? 'text-green-600 font-medium' : 'text-red-600 font-medium'}>
{signage.isActive ? 'Online' : 'Offline'}
</p>
</div>
<div>
<p className="text-gray-600">Type</p>
<p className="text-gray-900 font-medium">{signage.signageType}</p>
</div>
</div>
<div className="pt-2 border-t">
<Button
size="sm"
variant="outline"
className="w-full"
onClick={() => setSelectedSignage(signage.id)}
>
Manage
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</TabsContent>
{/* Messages Tab */}
<TabsContent value="messages" className="space-y-4">
{selectedSignage ? (
<>
{messages && (
<DataTable
columns={[
{
key: 'title' as any,
label: 'Title',
render: (_, row) => (
<div className="flex items-center gap-2">
<span>{getMessageTypeIcon(row.messageType)}</span>
<span>{row.title}</span>
</div>
),
},
{
key: 'priority' as any,
label: 'Priority',
render: (_, row) => (
<span
className="px-3 py-1 rounded-full text-sm font-medium text-white"
style={{ backgroundColor: getPriorityColor(row.priority) }}
>
{row.priority.toUpperCase()}
</span>
),
},
{
key: 'displayDuration' as any,
label: 'Duration',
render: (_, row) => formatDisplayTime(row.displayDuration || 10),
},
{
key: 'soundAlert' as any,
label: 'Sound',
render: (_, row) => (
<span className={row.soundAlert ? 'text-amber-600' : 'text-gray-400'}>
{row.soundAlert ? '🔊' : '🔇'}
</span>
),
},
]}
data={messages}
actions={[
{
label: 'Send Now',
onClick: (row) => console.log('Send', row.id),
},
{
label: 'Edit',
onClick: (row) => console.log('Edit', row.id),
},
{
label: 'Delete',
onClick: (row) => {
setShowConfirm(true);
},
variant: 'destructive',
},
]}
/>
)}
</>
) : (
<Card>
<CardContent className="pt-6 text-center text-gray-500">
<AlertCircle className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>Select a display to manage messages</p>
</CardContent>
</Card>
)}
</TabsContent>
{/* Activity Log Tab */}
<TabsContent value="activity">
<Card>
<CardHeader>
<CardTitle>Activity Log</CardTitle>
<CardDescription>Recent signage events and alerts</CardDescription>
</CardHeader>
<CardContent>
<div className="text-center text-gray-500 py-8">
Activity log feature coming soon
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* New Message Dialog */}
{showNewMessage && selectedSignage && (
<Card className="fixed inset-4 z-50 md:inset-auto md:top-1/2 md:left-1/2 md:-translate-x-1/2 md:-translate-y-1/2 md:max-w-md max-h-[90vh] overflow-y-auto">
<CardHeader>
<CardTitle>New Message</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleCreateMessage} className="space-y-4">
<div>
<Label htmlFor="title">Title</Label>
<Input
id="title"
name="title"
placeholder="Message title"
required
/>
</div>
<div>
<Label htmlFor="content">Content</Label>
<textarea
id="content"
name="content"
placeholder="Message content"
className="w-full h-24 px-3 py-2 border border-gray-300 rounded-md"
required
/>
</div>
<div>
<Label htmlFor="messageType">Type</Label>
<select
id="messageType"
name="messageType"
className="w-full px-3 py-2 border border-gray-300 rounded-md"
required
>
<option value="announcement">Announcement</option>
<option value="alert">Alert</option>
<option value="emergency">Emergency</option>
<option value="notification">Notification</option>
</select>
</div>
<div>
<Label htmlFor="priority">Priority</Label>
<select
id="priority"
name="priority"
className="w-full px-3 py-2 border border-gray-300 rounded-md"
required
>
<option value="low">Low</option>
<option value="normal">Normal</option>
<option value="high">High</option>
<option value="critical">Critical</option>
</select>
</div>
<div>
<Label htmlFor="displayDuration">Display Duration (seconds)</Label>
<Input
id="displayDuration"
name="displayDuration"
type="number"
defaultValue="10"
min="1"
max="300"
/>
</div>
<div className="flex items-center gap-2">
<input
id="soundAlert"
name="soundAlert"
type="checkbox"
className="rounded"
/>
<Label htmlFor="soundAlert" className="mb-0">Play sound alert</Label>
</div>
<div className="flex gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={() => setShowNewMessage(false)}
className="flex-1"
>
Cancel
</Button>
<Button type="submit" disabled={isCreating} className="flex-1">
{isCreating ? 'Creating...' : 'Create Message'}
</Button>
</div>
</form>
</CardContent>
</Card>
)}
{/* Confirm Dialog */}
<ConfirmDialog
open={showConfirm}
title="Delete Message"
description="This action cannot be undone."
onConfirm={async () => {
// Delete logic
setShowConfirm(false);
}}
onCancel={() => setShowConfirm(false)}
variant="destructive"
/>
</div>
);
}
+71
View File
@@ -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>
);
}
+198
View File
@@ -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>
);
}
+144
View File
@@ -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>
);
}
+236
View File
@@ -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>
);
}
+119
View File
@@ -0,0 +1,119 @@
import { NextResponse } from 'next/server';
/**
* Standardized API Response Format
* All routes should use these helpers for consistency
*/
export interface StandardResponse<T = any> {
success: boolean;
data?: T;
error?: string;
timestamp: string;
path?: string;
}
/**
* Success response with data
*/
export const successResponse = <T = any>(
data: T,
status = 200,
path?: string
): NextResponse<StandardResponse<T>> => {
return NextResponse.json(
{
success: true,
data,
timestamp: new Date().toISOString(),
path,
},
{ status }
);
};
/**
* Error response
*/
export const errorResponse = (
error: string | Error,
status = 400,
path?: string
): NextResponse<StandardResponse> => {
const message = error instanceof Error ? error.message : error;
return NextResponse.json(
{
success: false,
error: message,
timestamp: new Date().toISOString(),
path,
},
{ status }
);
};
/**
* Created (201) response for POST
*/
export const createdResponse = <T = any>(
data: T,
path?: string
): NextResponse<StandardResponse<T>> => {
return successResponse(data, 201, path);
};
/**
* No content (204) response for DELETE
*/
export const noContentResponse = () => {
return new NextResponse(null, { status: 204 });
};
/**
* Unauthorized error
*/
export const unauthorizedError = (path?: string) => {
return errorResponse('Unauthorized', 401, path);
};
/**
* Forbidden error
*/
export const forbiddenError = (path?: string) => {
return errorResponse('Forbidden', 403, path);
};
/**
* Not found error
*/
export const notFoundError = (message = 'Not found', path?: string) => {
return errorResponse(message, 404, path);
};
/**
* Validation error
*/
export const validationError = (message: string, path?: string) => {
return errorResponse(message, 422, path);
};
/**
* Server error
*/
export const serverError = (error: Error | string, path?: string) => {
return errorResponse(error, 500, path);
};
/**
* Wrap handler with automatic error handling
*/
export const withErrorHandling = (handler: Function) => {
return async (...args: any[]) => {
try {
return await handler(...args);
} catch (error) {
console.error('[v0] API Error:', error);
return serverError(error instanceof Error ? error : 'Unknown error');
}
};
};
+235
View File
@@ -0,0 +1,235 @@
/**
* Unified Grading Engine
* Consolidates all grading logic from:
* - grading-view.tsx
* - tablet-grading-view.tsx
* - grade-analytics-view.tsx
* - ai-grading-audit logic
*/
export type GradeScale = 1 | 2 | 3 | 4 | 5 | 6; // German grading scale
export type MasteryLevel = 'emerging' | 'developing' | 'proficient' | 'advanced';
export interface GradingConfig {
scaleType: 'numeric' | 'competency' | 'points'; // numeric=1-6, competency=E/D/P/A, points=0-100
maxScore?: number; // for point-based grading
masteryLevels?: MasteryLevel[];
weights?: Record<string, number>; // for weighted grading
passingScore?: number; // default 4 for numeric, configurable otherwise
}
export interface GradeInput {
score: number | GradeScale | MasteryLevel;
maxScore?: number;
studentId: string;
assessmentId: string;
timestamp: Date;
notes?: string;
}
export interface GradeOutput {
originalScore: number | GradeScale | MasteryLevel;
normalizedScore: number; // 0-100 for comparison
grade: GradeScale | MasteryLevel; // final grade
isPassing: boolean;
feedback: string;
competencyMap?: Record<string, MasteryLevel>;
}
/**
* Normalize different grading scales to 0-100
*/
export function normalizeScore(
score: number | GradeScale | MasteryLevel,
config: GradingConfig
): number {
if (typeof score === 'string') {
// Competency level to percentage
const masteryMap: Record<MasteryLevel, number> = {
emerging: 25,
developing: 50,
proficient: 75,
advanced: 100,
};
return masteryMap[score as MasteryLevel] || 0;
}
if (config.scaleType === 'numeric') {
// German scale (1-6) to percentage: 1=100%, 6=0%
return ((7 - (score as number)) / 6) * 100;
}
if (config.scaleType === 'points') {
// Point-based to percentage
const max = config.maxScore || 100;
return ((score as number) / max) * 100;
}
return score as number;
}
/**
* Convert normalized score back to target scale
*/
export function denormalizeScore(
normalized: number,
config: GradingConfig
): GradeScale | MasteryLevel | number {
if (config.scaleType === 'numeric') {
// Percentage back to German scale
const scale = Math.round((100 - normalized) / (100 / 6)) + 1;
return Math.max(1, Math.min(6, scale)) as GradeScale;
}
if (config.scaleType === 'competency') {
if (normalized >= 75) return 'advanced';
if (normalized >= 50) return 'proficient';
if (normalized >= 25) return 'developing';
return 'emerging';
}
if (config.scaleType === 'points') {
const max = config.maxScore || 100;
return Math.round((normalized / 100) * max);
}
return normalized;
}
/**
* Determine if grade is passing
*/
export function isPassing(
score: number | GradeScale | MasteryLevel,
config: GradingConfig
): boolean {
const normalized = normalizeScore(score, config);
const passingPercentage =
config.passingScore !== undefined ? config.passingScore : 50;
return normalized >= passingPercentage;
}
/**
* Generate feedback message based on performance
*/
export function generateFeedback(
normalized: number,
config: GradingConfig
): string {
const feedbackMap: Record<string, string> = {
excellent: 'Ausgezeichnete Leistung! Weiterhin so gut!',
good: 'Gute Leistung. Du machst Fortschritte!',
satisfactory: 'Befriedigende Leistung. Einige Bereiche können verbessert werden.',
adequate: 'Ausreichende Leistung. Mehr Anstrengung ist erforderlich.',
poor: 'Unzureichende Leistung. Zusätzliche Unterstützung wird empfohlen.',
};
if (normalized >= 90) return feedbackMap.excellent;
if (normalized >= 75) return feedbackMap.good;
if (normalized >= 60) return feedbackMap.satisfactory;
if (normalized >= 50) return feedbackMap.adequate;
return feedbackMap.poor;
}
/**
* Process a grade with full analysis
*/
export function processGrade(
input: GradeInput,
config: GradingConfig
): GradeOutput {
const normalized = normalizeScore(input.score, config);
const grade = denormalizeScore(normalized, config);
const passing = isPassing(input.score, config);
const feedback = generateFeedback(normalized, config);
return {
originalScore: input.score,
normalizedScore: normalized,
grade: grade as GradeScale | MasteryLevel,
isPassing: passing,
feedback,
};
}
/**
* Calculate weighted grade from multiple components
*/
export function calculateWeightedGrade(
components: Array<{
name: string;
score: number | GradeScale | MasteryLevel;
weight: number;
}>,
config: GradingConfig
): GradeOutput {
let totalWeight = 0;
let weightedSum = 0;
for (const comp of components) {
const normalized = normalizeScore(comp.score, config);
const weight = config.weights?.[comp.name] || comp.weight || 1;
weightedSum += normalized * weight;
totalWeight += weight;
}
const finalNormalized = totalWeight > 0 ? weightedSum / totalWeight : 0;
const grade = denormalizeScore(finalNormalized, config);
const passing = isPassing(grade, config);
const feedback = generateFeedback(finalNormalized, config);
return {
originalScore: components.map((c) => c.score),
normalizedScore: finalNormalized,
grade: grade as GradeScale | MasteryLevel,
isPassing: passing,
feedback,
};
}
/**
* Compare grades (for analytics)
*/
export function compareGrades(
grade1: number | GradeScale | MasteryLevel,
grade2: number | GradeScale | MasteryLevel,
config: GradingConfig
): number {
const norm1 = normalizeScore(grade1, config);
const norm2 = normalizeScore(grade2, config);
return norm2 - norm1; // positive = improvement, negative = decline
}
/**
* Get grading statistics
*/
export function getStatistics(
grades: Array<number | GradeScale | MasteryLevel>,
config: GradingConfig
) {
const normalized = grades.map((g) => normalizeScore(g, config));
const sum = normalized.reduce((a, b) => a + b, 0);
const avg = sum / normalized.length;
const sorted = [...normalized].sort((a, b) => a - b);
const median = sorted[Math.floor(sorted.length / 2)];
const min = Math.min(...normalized);
const max = Math.max(...normalized);
const stdDev = Math.sqrt(
normalized.reduce((sq, n) => sq + Math.pow(n - avg, 2), 0) / normalized.length
);
return {
average: avg,
median,
min,
max,
stdDev,
count: grades.length,
passingCount: grades.filter((g) => isPassing(g, config)).length,
passingPercentage:
(grades.filter((g) => isPassing(g, config)).length / grades.length) * 100,
};
}
+204
View File
@@ -0,0 +1,204 @@
'use client';
import { useCallback, useState, useEffect } from 'react';
import useSWR, { SWRConfiguration } from 'swr';
export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
status: number;
}
export interface UseApiOptions extends SWRConfiguration {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
body?: any;
headers?: HeadersInit;
onSuccess?: (data: any) => void;
onError?: (error: any) => void;
skip?: boolean;
}
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
/**
* Standardized API fetcher with error handling
*/
export const apiFetcher = async (
url: string,
options?: {
method?: string;
body?: any;
headers?: HeadersInit;
}
): Promise<any> => {
const method = options?.method || 'GET';
const headers = {
'Content-Type': 'application/json',
...options?.headers,
};
const response = await fetch(`${apiUrl}${url}`, {
method,
headers,
body: options?.body ? JSON.stringify(options.body) : undefined,
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.error || `HTTP ${response.status}`);
}
return response.json();
};
/**
* Hook for fetching data with built-in caching (GET requests)
* @param url - API endpoint
* @param options - SWR options
* @returns { data, error, isLoading, mutate }
*/
export function useApiGet<T = any>(
url: string | null,
options?: SWRConfiguration & { skip?: boolean }
) {
const { skip = false, ...swrOptions } = options || {};
const { data, error, isLoading, mutate } = useSWR<T>(
skip || !url ? null : url,
(url) => apiFetcher(url),
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
dedupingInterval: 60000,
...swrOptions,
}
);
return {
data,
error,
isLoading: isLoading && !error,
mutate,
};
}
/**
* Hook for mutations (POST, PUT, DELETE, PATCH)
* @param url - API endpoint
* @param options - Custom options including onSuccess, onError callbacks
* @returns { mutate, isLoading, error, data }
*/
export function useApiMutation<T = any>(
url: string,
options?: UseApiOptions
) {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<T | null>(null);
const mutate = useCallback(
async (payload?: any) => {
setIsLoading(true);
setError(null);
try {
const response = await fetch(`${apiUrl}${url}`, {
method: options?.method || 'POST',
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
body: JSON.stringify(payload || options?.body),
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || `HTTP ${response.status}`);
}
setData(result.data || result);
options?.onSuccess?.(result.data || result);
return result.data || result;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
setError(message);
options?.onError?.(err);
throw err;
} finally {
setIsLoading(false);
}
},
[url, options]
);
return { mutate, isLoading, error, data };
}
/**
* Hook for complex queries with multiple dependencies
* Useful for parallel requests
*/
export function useApiQueries<T = any>(
queries: Array<{ url: string | null; key?: string }>,
options?: SWRConfiguration
) {
const results = queries.map((query) => {
const { data, error, isLoading, mutate } = useApiGet(query.url, options);
return {
key: query.key || query.url,
data,
error,
isLoading,
mutate,
};
});
const isLoading = results.some((r) => r.isLoading);
const error = results.find((r) => r.error);
const data = Object.fromEntries(results.map((r) => [r.key, r.data])) as Record<
string,
T | undefined
>;
return { data, error, isLoading };
}
/**
* Imperative API call (for one-off requests)
*/
export const apiCall = async <T = any>(
url: string,
options?: {
method?: string;
body?: any;
headers?: HeadersInit;
}
): Promise<T> => {
return apiFetcher(`${apiUrl}${url}`, options);
};
/**
* Convenience methods
*/
export const apiGet = async <T = any>(url: string): Promise<T> => {
return apiCall(url, { method: 'GET' });
};
export const apiPost = async <T = any>(url: string, body?: any): Promise<T> => {
return apiCall(url, { method: 'POST', body });
};
export const apiPut = async <T = any>(url: string, body?: any): Promise<T> => {
return apiCall(url, { method: 'PUT', body });
};
export const apiDelete = async <T = any>(url: string, body?: any): Promise<T> => {
return apiCall(url, { method: 'DELETE', body });
};
export const apiPatch = async <T = any>(url: string, body?: any): Promise<T> => {
return apiCall(url, { method: 'PATCH', body });
};
+151
View File
@@ -0,0 +1,151 @@
import { NextRequest, NextResponse } from 'next/server';
import { getSession } from '@/lib/auth';
import { logComplianceAudit } from '@/lib/audit';
import { successResponse, unauthorizedError, forbiddenError } from '@/lib/api-response';
export type RouteHandler = (
request: NextRequest,
context: any
) => Promise<NextResponse>;
export interface RouteConfig {
auth?: boolean; // Require authentication
roles?: string[]; // Required roles
audit?: boolean; // Log to audit trail
}
/**
* Middleware for authentication check
*/
export const withAuth = (handler: RouteHandler) => {
return async (request: NextRequest, context: any) => {
try {
const session = await getSession();
if (!session) {
return unauthorizedError(request.url);
}
// Attach user to request for use in handler
(request as any).user = session.user;
return handler(request, context);
} catch (error) {
console.error('[v0] Auth error:', error);
return unauthorizedError(request.url);
}
};
};
/**
* Middleware for role-based access control
*/
export const withRoles = (allowedRoles: string[]) => {
return (handler: RouteHandler) => {
return async (request: NextRequest, context: any) => {
const session = await getSession();
if (!session) {
return unauthorizedError(request.url);
}
const userRole = session.user?.role;
if (!allowedRoles.includes(userRole)) {
return forbiddenError(request.url);
}
(request as any).user = session.user;
return handler(request, context);
};
};
};
/**
* Middleware for audit logging
*/
export const withAudit = (action: string) => {
return (handler: RouteHandler) => {
return async (request: NextRequest, context: any) => {
const session = await getSession();
const user = session?.user;
const response = await handler(request, context);
// Log after handler completes
if (user) {
try {
await logComplianceAudit({
userId: user.id,
schoolId: user.schoolId,
action,
entityType: request.url.split('/').pop() || 'unknown',
metadata: {
method: request.method,
path: request.url,
timestamp: new Date().toISOString(),
},
});
} catch (error) {
console.error('[v0] Audit log error:', error);
}
}
return response;
};
};
};
/**
* Middleware for request validation
*/
export const withValidation = (schema: any) => {
return (handler: RouteHandler) => {
return async (request: NextRequest, context: any) => {
try {
if (request.method === 'GET' || request.method === 'DELETE') {
return handler(request, context);
}
const body = await request.json();
const validation = schema.safeParse(body);
if (!validation.success) {
return NextResponse.json(
{
success: false,
error: 'Validation error',
details: validation.error.errors,
},
{ status: 422 }
);
}
(request as any).validatedBody = validation.data;
return handler(request, context);
} catch (error) {
return NextResponse.json(
{ success: false, error: 'Invalid request body' },
{ status: 400 }
);
}
};
};
};
/**
* Combine multiple middleware
*/
export const withMiddleware =
(...middleware: Array<(h: RouteHandler) => RouteHandler>) =>
(handler: RouteHandler) => {
return middleware.reduceRight((fn, mw) => mw(fn), handler);
};
/**
* Example usage in a route:
*
* export const GET = withMiddleware(
* withAuth,
* withRoles(['TEACHER', 'ADMIN']),
* withAudit('GET_STUDENTS')
* )(async (request) => {
* const user = (request as any).user;
* // handler logic
* });
*/
+149
View File
@@ -0,0 +1,149 @@
/**
* Signage utilities for emergency alerting system
*/
export type MessageType = 'emergency' | 'notification' | 'schedule' | 'announcement' | 'alert';
export type Priority = 'low' | 'normal' | 'high' | 'critical';
export interface SignageMessageData {
title: string;
content: string;
messageType: MessageType;
priority: Priority;
displayDuration?: number;
soundAlert?: boolean;
backgroundColor?: string;
textColor?: string;
soundFile?: string;
}
/**
* Get priority color for UI
*/
export const getPriorityColor = (priority: Priority): string => {
const colors: Record<Priority, string> = {
low: '#10b981', // green
normal: '#3b82f6', // blue
high: '#f59e0b', // amber
critical: '#ef4444', // red
};
return colors[priority] || colors.normal;
};
/**
* Get message type icon
*/
export const getMessageTypeIcon = (type: MessageType): string => {
const icons: Record<MessageType, string> = {
emergency: '🚨',
notification: '📢',
schedule: '📅',
announcement: '📣',
alert: '⚠️',
};
return icons[type] || '📢';
};
/**
* Format time display
*/
export const formatDisplayTime = (seconds: number): string => {
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const secs = seconds % 60;
return secs > 0 ? `${minutes}m ${secs}s` : `${minutes}m`;
};
/**
* Check if message is active
*/
export const isMessageActive = (message: {
startTime?: Date;
endTime?: Date;
}): boolean => {
const now = new Date();
if (message.startTime && now < message.startTime) return false;
if (message.endTime && now > message.endTime) return false;
return true;
};
/**
* Get contrast color for text (black or white based on background)
*/
export const getContrastColor = (hexColor?: string): string => {
if (!hexColor) return '#000000';
const r = parseInt(hexColor.slice(1, 3), 16);
const g = parseInt(hexColor.slice(3, 5), 16);
const b = parseInt(hexColor.slice(5, 7), 16);
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > 0.5 ? '#000000' : '#ffffff';
};
/**
* Validate signage message
*/
export const validateSignageMessage = (message: Partial<SignageMessageData>): string[] => {
const errors: string[] = [];
if (!message.title || message.title.trim().length === 0) {
errors.push('Title is required');
}
if (!message.content || message.content.trim().length === 0) {
errors.push('Content is required');
}
if (!message.messageType) {
errors.push('Message type is required');
}
if (!message.priority) {
errors.push('Priority is required');
}
if (message.displayDuration && message.displayDuration < 1) {
errors.push('Display duration must be at least 1 second');
}
return errors;
};
/**
* Generate emergency notification for school
*/
export const createEmergencyMessage = (
incident: string,
location: string,
additionalInfo?: string
): SignageMessageData => {
return {
title: 'NOTFALL MELDUNG',
content: `${incident}\nOrt: ${location}${additionalInfo ? '\n' + additionalInfo : ''}`,
messageType: 'emergency',
priority: 'critical',
displayDuration: 30,
soundAlert: true,
backgroundColor: '#ef4444', // red
textColor: '#ffffff', // white
};
};
/**
* Get next active message based on schedule
*/
export const getNextScheduledMessage = (
messages: Array<{
id: string;
displayOrder: number;
startTime?: Date;
endTime?: Date;
}>
): string | null => {
const activeMessages = messages
.filter((m) => isMessageActive(m))
.sort((a, b) => a.displayOrder - b.displayOrder);
return activeMessages.length > 0 ? activeMessages[0].id : null;
};