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,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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
|
||||
* });
|
||||
*/
|
||||
@@ -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;
|
||||
};
|
||||
Reference in new issue
Block a user