diff --git a/admin-lehrer/app/(sdk)/sdk/dsb-portal/page.tsx b/admin-lehrer/app/(sdk)/sdk/dsb-portal/page.tsx new file mode 100644 index 0000000..4cf7bd4 --- /dev/null +++ b/admin-lehrer/app/(sdk)/sdk/dsb-portal/page.tsx @@ -0,0 +1,2068 @@ +'use client' + +import React, { useState, useEffect, useCallback, useRef } from 'react' + +// ============================================================================= +// TYPES +// ============================================================================= + +interface AssignmentOverview { + id: string + dsb_user_id: string + tenant_id: string + tenant_name: string + tenant_slug: string + status: string + contract_start: string + contract_end: string | null + monthly_hours_budget: number + notes: string + compliance_score: number + hours_this_month: number + hours_budget: number + open_task_count: number + urgent_task_count: number + next_deadline: string | null + created_at: string + updated_at: string +} + +interface DSBDashboard { + assignments: AssignmentOverview[] + total_assignments: number + active_assignments: number + total_hours_this_month: number + open_tasks: number + urgent_tasks: number + generated_at: string +} + +interface HourEntry { + id: string + assignment_id: string + date: string + hours: number + category: string + description: string + billable: boolean + created_at: string +} + +interface Task { + id: string + assignment_id: string + title: string + description: string + category: string + priority: string + status: string + due_date: string | null + completed_at: string | null + created_at: string + updated_at: string +} + +interface Communication { + id: string + assignment_id: string + direction: string + channel: string + subject: string + content: string + participants: string + created_at: string +} + +interface HoursSummary { + total_hours: number + billable_hours: number + by_category: Record + period: string +} + +// ============================================================================= +// CONSTANTS +// ============================================================================= + +const DSB_USER_ID = '00000000-0000-0000-0000-000000000001' + +const TASK_CATEGORIES = [ + 'DSFA-Pruefung', + 'Betroffenenanfrage', + 'Vorfall-Pruefung', + 'Audit-Vorbereitung', + 'Richtlinien-Pruefung', + 'Schulung', + 'Beratung', + 'Sonstiges', +] + +const HOUR_CATEGORIES = [ + 'DSFA-Pruefung', + 'Beratung', + 'Audit', + 'Schulung', + 'Vorfallreaktion', + 'Dokumentation', + 'Besprechung', + 'Sonstiges', +] + +const COMM_CHANNELS = ['E-Mail', 'Telefon', 'Besprechung', 'Portal', 'Brief'] + +const PRIORITY_LABELS: Record = { + urgent: 'Dringend', + high: 'Hoch', + medium: 'Mittel', + low: 'Niedrig', +} + +const PRIORITY_COLORS: Record = { + urgent: 'bg-red-100 text-red-700 border-red-200', + high: 'bg-orange-100 text-orange-700 border-orange-200', + medium: 'bg-blue-100 text-blue-700 border-blue-200', + low: 'bg-gray-100 text-gray-500 border-gray-200', +} + +const TASK_STATUS_LABELS: Record = { + open: 'Offen', + in_progress: 'In Bearbeitung', + waiting: 'Wartend', + completed: 'Erledigt', + cancelled: 'Abgebrochen', +} + +const TASK_STATUS_COLORS: Record = { + open: 'bg-blue-100 text-blue-700', + in_progress: 'bg-yellow-100 text-yellow-700', + waiting: 'bg-orange-100 text-orange-700', + completed: 'bg-green-100 text-green-700', + cancelled: 'bg-gray-100 text-gray-500', +} + +const ASSIGNMENT_STATUS_COLORS: Record = { + active: 'bg-green-100 text-green-700 border-green-300', + paused: 'bg-yellow-100 text-yellow-700 border-yellow-300', + terminated: 'bg-red-100 text-red-700 border-red-300', +} + +const ASSIGNMENT_STATUS_LABELS: Record = { + active: 'Aktiv', + paused: 'Pausiert', + terminated: 'Beendet', +} + +// ============================================================================= +// API HELPERS +// ============================================================================= + +async function apiFetch(url: string, options?: RequestInit): Promise { + const res = await fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + 'X-User-ID': DSB_USER_ID, + ...(options?.headers || {}), + }, + }) + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error(`API Error ${res.status}: ${text || res.statusText}`) + } + return res.json() +} + +// ============================================================================= +// TOAST COMPONENT +// ============================================================================= + +interface ToastMessage { + id: number + message: string + type: 'success' | 'error' +} + +let toastIdCounter = 0 + +function useToast() { + const [toasts, setToasts] = useState([]) + + const addToast = useCallback((message: string, type: 'success' | 'error' = 'success') => { + const id = ++toastIdCounter + setToasts((prev) => [...prev, { id, message, type }]) + setTimeout(() => { + setToasts((prev) => prev.filter((t) => t.id !== id)) + }, 3500) + }, []) + + return { toasts, addToast } +} + +function ToastContainer({ toasts }: { toasts: ToastMessage[] }) { + if (toasts.length === 0) return null + return ( +
+ {toasts.map((t) => ( +
+ {t.message} +
+ ))} +
+ ) +} + +// ============================================================================= +// LOADING SKELETON +// ============================================================================= + +function Skeleton({ className = '' }: { className?: string }) { + return
+} + +function DashboardSkeleton() { + return ( +
+
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+
+ ) +} + +// ============================================================================= +// MODAL COMPONENT +// ============================================================================= + +function Modal({ + open, + onClose, + title, + children, + maxWidth = 'max-w-lg', +}: { + open: boolean + onClose: () => void + title: string + children: React.ReactNode + maxWidth?: string +}) { + const overlayRef = useRef(null) + + useEffect(() => { + if (!open) return + const handleEsc = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + document.addEventListener('keydown', handleEsc) + return () => document.removeEventListener('keydown', handleEsc) + }, [open, onClose]) + + if (!open) return null + + return ( +
{ + if (e.target === overlayRef.current) onClose() + }} + > +
+
+

{title}

+ +
+
{children}
+
+
+ ) +} + +// ============================================================================= +// STAT CARD +// ============================================================================= + +function StatCard({ + title, + value, + icon, + accent = false, +}: { + title: string + value: string | number + icon: React.ReactNode + accent?: boolean +}) { + return ( +
+
+
+ {icon} +
+
+

{title}

+

+ {value} +

+
+
+
+ ) +} + +// ============================================================================= +// COMPLIANCE PROGRESS BAR +// ============================================================================= + +function ComplianceBar({ score }: { score: number }) { + const color = + score < 40 ? 'bg-red-500' : score < 70 ? 'bg-yellow-500' : 'bg-green-500' + const textColor = + score < 40 ? 'text-red-700' : score < 70 ? 'text-yellow-700' : 'text-green-700' + return ( +
+
+
+
+ + {score}% + +
+ ) +} + +// ============================================================================= +// HOURS PROGRESS BAR +// ============================================================================= + +function HoursBar({ used, budget }: { used: number; budget: number }) { + const pct = budget > 0 ? Math.min((used / budget) * 100, 100) : 0 + const over = used > budget + return ( +
+
+
+
+ + {used}h / {budget}h + +
+ ) +} + +// ============================================================================= +// BADGE +// ============================================================================= + +function Badge({ label, className = '' }: { label: string; className?: string }) { + return ( + + {label} + + ) +} + +// ============================================================================= +// FORM COMPONENTS +// ============================================================================= + +function FormLabel({ children, htmlFor }: { children: React.ReactNode; htmlFor?: string }) { + return ( + + ) +} + +function FormInput({ + id, + type = 'text', + value, + onChange, + placeholder, + required, + min, + max, + step, +}: { + id?: string + type?: string + value: string | number + onChange: (val: string) => void + placeholder?: string + required?: boolean + min?: string | number + max?: string | number + step?: string | number +}) { + return ( + onChange(e.target.value)} + placeholder={placeholder} + required={required} + min={min} + max={max} + step={step} + className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500" + /> + ) +} + +function FormTextarea({ + id, + value, + onChange, + placeholder, + rows = 3, +}: { + id?: string + value: string + onChange: (val: string) => void + placeholder?: string + rows?: number +}) { + return ( +