feat: add new critical API documentation for notifications, dashboard widgets, signage, and exam sessions
Co-authored-by: cetinmustafa83 <30570791+cetinmustafa83@users.noreply.github.com>
This commit is contained in:
9 files changed
+1788
No files matched your search
@@ -0,0 +1,112 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { WidgetContainer } from './widget-container';
|
||||
import { WIDGET_TYPES, WIDGET_SIZES, getDefaultConfig } from '@/lib/widget-utils';
|
||||
import { Plus, Edit2, Save } from 'lucide-react';
|
||||
|
||||
interface DashboardCustomizerProps {
|
||||
schoolId: string;
|
||||
userId: string;
|
||||
widgets: any[];
|
||||
onWidgetAdd: (widgetType: string) => void;
|
||||
onWidgetRemove: (widgetId: string) => void;
|
||||
onLayoutSave: () => void;
|
||||
}
|
||||
|
||||
export function DashboardCustomizer({
|
||||
schoolId,
|
||||
userId,
|
||||
widgets,
|
||||
onWidgetAdd,
|
||||
onWidgetRemove,
|
||||
onLayoutSave,
|
||||
}: DashboardCustomizerProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [showWidgetGallery, setShowWidgetGallery] = useState(false);
|
||||
|
||||
const availableWidgets = Object.values(WIDGET_TYPES).filter(
|
||||
(type) => !widgets.some((w) => w.widgetType === type)
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex gap-2 mb-4">
|
||||
<Button
|
||||
onClick={() => setIsEditing(!isEditing)}
|
||||
variant={isEditing ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
{isEditing ? 'Done Editing' : 'Customize'}
|
||||
</Button>
|
||||
|
||||
{isEditing && (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => setShowWidgetGallery(true)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Widget
|
||||
</Button>
|
||||
<Button onClick={onLayoutSave} size="sm" className="flex items-center gap-2">
|
||||
<Save className="h-4 w-4" />
|
||||
Save Layout
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Widget Gallery Dialog */}
|
||||
<Dialog open={showWidgetGallery} onOpenChange={setShowWidgetGallery}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Widget</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{availableWidgets.map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => {
|
||||
onWidgetAdd(type);
|
||||
setShowWidgetGallery(false);
|
||||
}}
|
||||
className="p-4 border rounded-lg hover:bg-gray-50 text-left"
|
||||
>
|
||||
<div className="font-semibold text-sm capitalize">
|
||||
{type.replace(/_/g, ' ')}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
Add this widget to your dashboard
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Dashboard Grid */}
|
||||
<div className="grid grid-cols-4 gap-4 auto-rows-max">
|
||||
{widgets.map((widget) => (
|
||||
<WidgetContainer
|
||||
key={widget.id}
|
||||
id={widget.id}
|
||||
title={widget.title || widget.widgetType.replace(/_/g, ' ')}
|
||||
widgetType={widget.widgetType}
|
||||
size={widget.size as any}
|
||||
isEditing={isEditing}
|
||||
onRemove={() => onWidgetRemove(widget.id)}
|
||||
>
|
||||
<div className="text-sm text-gray-500 text-center py-8">
|
||||
{widget.widgetType} widget content
|
||||
</div>
|
||||
</WidgetContainer>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { MoreVertical, X, RotateCw } from 'lucide-react';
|
||||
import { needsRefresh, updateWidgetCache } from '@/lib/widget-utils';
|
||||
|
||||
interface WidgetContainerProps {
|
||||
id: string;
|
||||
title: string;
|
||||
widgetType: string;
|
||||
size: 'small' | 'medium' | 'large' | 'full_width';
|
||||
isEditing: boolean;
|
||||
onRemove: () => void;
|
||||
onRefresh?: () => Promise<void>;
|
||||
refreshInterval?: number;
|
||||
lastRefresh?: Date | null;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function WidgetContainer({
|
||||
id,
|
||||
title,
|
||||
size,
|
||||
isEditing,
|
||||
onRemove,
|
||||
onRefresh,
|
||||
refreshInterval = 300,
|
||||
lastRefresh,
|
||||
children,
|
||||
}: WidgetContainerProps) {
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
|
||||
const sizeClasses = {
|
||||
small: 'col-span-1',
|
||||
medium: 'col-span-2',
|
||||
large: 'col-span-3',
|
||||
full_width: 'col-span-full',
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
if (!onRefresh) return;
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await onRefresh();
|
||||
} catch (error) {
|
||||
console.error('[v0] Widget refresh failed:', error);
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={`${sizeClasses[size]} h-full shadow-sm hover:shadow-md transition-shadow`}>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-3">
|
||||
<CardTitle className="text-base font-semibold">{title}</CardTitle>
|
||||
{isEditing && (
|
||||
<div className="flex gap-1">
|
||||
{onRefresh && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<RotateCw className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onRemove}
|
||||
className="h-6 w-6 p-0 hover:bg-red-50 hover:text-red-600"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="pb-4">{children}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { db } from '@/lib/db';
|
||||
import type { DashboardWidget } from '@prisma/client';
|
||||
|
||||
export const WIDGET_TYPES = {
|
||||
GRADE_SUMMARY: 'grade_summary',
|
||||
ATTENDANCE_TREND: 'attendance_trend',
|
||||
UPCOMING_ASSESSMENTS: 'upcoming_assessments',
|
||||
CLASS_SCHEDULE: 'class_schedule',
|
||||
WELLNESS_STATUS: 'wellness_status',
|
||||
RECENT_MESSAGES: 'recent_messages',
|
||||
ANNOUNCEMENTS: 'announcements',
|
||||
} as const;
|
||||
|
||||
export const WIDGET_SIZES = {
|
||||
SMALL: 'small',
|
||||
MEDIUM: 'medium',
|
||||
LARGE: 'large',
|
||||
FULL_WIDTH: 'full_width',
|
||||
} as const;
|
||||
|
||||
export const WIDGET_CONFIGS = {
|
||||
[WIDGET_TYPES.GRADE_SUMMARY]: {
|
||||
dateRange: 'semester',
|
||||
showTrend: true,
|
||||
colorScheme: 'default',
|
||||
},
|
||||
[WIDGET_TYPES.ATTENDANCE_TREND]: {
|
||||
days: 30,
|
||||
showTarget: true,
|
||||
unit: 'percentage',
|
||||
},
|
||||
[WIDGET_TYPES.UPCOMING_ASSESSMENTS]: {
|
||||
daysAhead: 14,
|
||||
showDueIn: true,
|
||||
sortBy: 'dueDate',
|
||||
},
|
||||
[WIDGET_TYPES.CLASS_SCHEDULE]: {
|
||||
view: 'week',
|
||||
showLocation: true,
|
||||
showTeacher: true,
|
||||
},
|
||||
[WIDGET_TYPES.WELLNESS_STATUS]: {
|
||||
showHistory: true,
|
||||
days: 7,
|
||||
},
|
||||
[WIDGET_TYPES.RECENT_MESSAGES]: {
|
||||
limit: 5,
|
||||
showUnread: true,
|
||||
},
|
||||
[WIDGET_TYPES.ANNOUNCEMENTS]: {
|
||||
limit: 3,
|
||||
priority: 'all',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get default widget configuration
|
||||
*/
|
||||
export function getDefaultConfig(widgetType: string) {
|
||||
return WIDGET_CONFIGS[widgetType as keyof typeof WIDGET_CONFIGS] || {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate widget position is unique in dashboard
|
||||
*/
|
||||
export async function isPositionAvailable(
|
||||
userId: string,
|
||||
position: number,
|
||||
excludeWidgetId?: string
|
||||
): Promise<boolean> {
|
||||
const existing = await db.dashboardWidget.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
position,
|
||||
...(excludeWidgetId && { id: { not: excludeWidgetId } }),
|
||||
},
|
||||
});
|
||||
return !existing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get next available position in dashboard grid
|
||||
*/
|
||||
export async function getNextPosition(userId: string, gridSize: number = 12): Promise<number> {
|
||||
const widgets = await db.dashboardWidget.findMany({
|
||||
where: { userId },
|
||||
select: { position: true },
|
||||
orderBy: { position: 'desc' },
|
||||
take: 1,
|
||||
});
|
||||
|
||||
const nextPos = (widgets[0]?.position ?? -1) + 1;
|
||||
return nextPos < gridSize ? nextPos : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move widget to new position
|
||||
*/
|
||||
export async function moveWidget(
|
||||
widgetId: string,
|
||||
newPosition: number
|
||||
): Promise<DashboardWidget> {
|
||||
return db.dashboardWidget.update({
|
||||
where: { id: widgetId },
|
||||
data: { position: newPosition, updatedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get widgets for user dashboard
|
||||
*/
|
||||
export async function getUserDashboardWidgets(userId: string, schoolId: string) {
|
||||
const widgets = await db.dashboardWidget.findMany({
|
||||
where: {
|
||||
userId,
|
||||
school: { id: schoolId },
|
||||
isVisible: true,
|
||||
},
|
||||
orderBy: { position: 'asc' },
|
||||
});
|
||||
|
||||
return widgets.map((w) => ({
|
||||
...w,
|
||||
config: w.config ? JSON.parse(w.config) : getDefaultConfig(w.widgetType),
|
||||
cacheData: w.cacheData ? JSON.parse(w.cacheData) : null,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update widget cache (called after data refresh)
|
||||
*/
|
||||
export async function updateWidgetCache(widgetId: string, data: unknown) {
|
||||
return db.dashboardWidget.update({
|
||||
where: { id: widgetId },
|
||||
data: {
|
||||
cacheData: JSON.stringify(data),
|
||||
lastRefresh: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if widget needs refresh based on interval
|
||||
*/
|
||||
export function needsRefresh(lastRefresh: Date | null, interval: number): boolean {
|
||||
if (!lastRefresh || interval === 0) return false; // 0 = manual refresh
|
||||
const secondsElapsed = (Date.now() - lastRefresh.getTime()) / 1000;
|
||||
return secondsElapsed >= interval;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save dashboard layout
|
||||
*/
|
||||
export async function saveDashboardLayout(
|
||||
userId: string,
|
||||
schoolId: string,
|
||||
name: string,
|
||||
widgetPositions: number[],
|
||||
isDefault: boolean = false
|
||||
) {
|
||||
return db.dashboardLayout.create({
|
||||
data: {
|
||||
userId,
|
||||
schoolId,
|
||||
name,
|
||||
widgets: JSON.stringify(widgetPositions),
|
||||
isDefault,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's default or first layout
|
||||
*/
|
||||
export async function getUserLayout(userId: string, schoolId: string) {
|
||||
const layout = await db.dashboardLayout.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
schoolId,
|
||||
isDefault: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (layout) {
|
||||
return {
|
||||
...layout,
|
||||
widgets: JSON.parse(layout.widgets),
|
||||
};
|
||||
}
|
||||
|
||||
// Fall back to first layout
|
||||
const first = await db.dashboardLayout.findFirst({
|
||||
where: { userId, schoolId },
|
||||
});
|
||||
|
||||
return first
|
||||
? {
|
||||
...first,
|
||||
widgets: JSON.parse(first.widgets),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
Reference in new issue
Block a user