From 6c883fb12e0b5f1fa6b37d3953edb8b4be7f9c3d Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Sat, 11 Apr 2026 18:51:16 +0200 Subject: [PATCH] refactor(admin): split loeschfristen + dsb-portal page.tsx into colocated components Split two oversized page files into _components/ directories following Next.js 15 conventions and the 500-LOC hard cap: - loeschfristen/page.tsx (2322 LOC -> 412 LOC orchestrator + 6 components) - dsb-portal/page.tsx (2068 LOC -> 135 LOC orchestrator + 9 components) All component files stay under 500 lines. Build verified. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../dsb-portal/_components/AufgabenTab.tsx | 189 ++ .../sdk/dsb-portal/_components/DetailView.tsx | 126 + .../_components/EinstellungenTab.tsx | 102 + .../_components/KommunikationTab.tsx | 161 ++ .../dsb-portal/_components/MandantCard.tsx | 69 + .../_components/ZeiterfassungTab.tsx | 230 ++ .../app/sdk/dsb-portal/_components/types.ts | 199 ++ .../dsb-portal/_components/ui-primitives.tsx | 367 +++ admin-compliance/app/sdk/dsb-portal/page.tsx | 1979 +------------- .../_components/EditorSections.tsx | 460 ++++ .../loeschfristen/_components/EditorTab.tsx | 170 ++ .../loeschfristen/_components/ExportTab.tsx | 261 ++ .../_components/GeneratorTab.tsx | 322 +++ .../loeschfristen/_components/TagInput.tsx | 60 + .../_components/UebersichtTab.tsx | 230 ++ .../app/sdk/loeschfristen/page.tsx | 2292 ++--------------- 16 files changed, 3160 insertions(+), 4057 deletions(-) create mode 100644 admin-compliance/app/sdk/dsb-portal/_components/AufgabenTab.tsx create mode 100644 admin-compliance/app/sdk/dsb-portal/_components/DetailView.tsx create mode 100644 admin-compliance/app/sdk/dsb-portal/_components/EinstellungenTab.tsx create mode 100644 admin-compliance/app/sdk/dsb-portal/_components/KommunikationTab.tsx create mode 100644 admin-compliance/app/sdk/dsb-portal/_components/MandantCard.tsx create mode 100644 admin-compliance/app/sdk/dsb-portal/_components/ZeiterfassungTab.tsx create mode 100644 admin-compliance/app/sdk/dsb-portal/_components/types.ts create mode 100644 admin-compliance/app/sdk/dsb-portal/_components/ui-primitives.tsx create mode 100644 admin-compliance/app/sdk/loeschfristen/_components/EditorSections.tsx create mode 100644 admin-compliance/app/sdk/loeschfristen/_components/EditorTab.tsx create mode 100644 admin-compliance/app/sdk/loeschfristen/_components/ExportTab.tsx create mode 100644 admin-compliance/app/sdk/loeschfristen/_components/GeneratorTab.tsx create mode 100644 admin-compliance/app/sdk/loeschfristen/_components/TagInput.tsx create mode 100644 admin-compliance/app/sdk/loeschfristen/_components/UebersichtTab.tsx diff --git a/admin-compliance/app/sdk/dsb-portal/_components/AufgabenTab.tsx b/admin-compliance/app/sdk/dsb-portal/_components/AufgabenTab.tsx new file mode 100644 index 0000000..447f081 --- /dev/null +++ b/admin-compliance/app/sdk/dsb-portal/_components/AufgabenTab.tsx @@ -0,0 +1,189 @@ +'use client' + +import React, { useState, useEffect, useCallback } from 'react' +import { + Task, TASK_CATEGORIES, PRIORITY_LABELS, PRIORITY_COLORS, + TASK_STATUS_LABELS, TASK_STATUS_COLORS, apiFetch, formatDate, +} from './types' +import { + Skeleton, Modal, Badge, FormLabel, FormInput, FormTextarea, + FormSelect, PrimaryButton, SecondaryButton, ErrorState, EmptyState, + IconTask, IconPlus, IconCheck, IconCalendar, +} from './ui-primitives' + +export function AufgabenTab({ + assignmentId, + addToast, +}: { + assignmentId: string + addToast: (msg: string, type?: 'success' | 'error') => void +}) { + const [tasks, setTasks] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [statusFilter, setStatusFilter] = useState('all') + const [showModal, setShowModal] = useState(false) + const [saving, setSaving] = useState(false) + + const [newTitle, setNewTitle] = useState('') + const [newDesc, setNewDesc] = useState('') + const [newCategory, setNewCategory] = useState(TASK_CATEGORIES[0]) + const [newPriority, setNewPriority] = useState('medium') + const [newDueDate, setNewDueDate] = useState('') + + const fetchTasks = useCallback(async () => { + setLoading(true) + setError('') + try { + const params = statusFilter !== 'all' ? `?status=${statusFilter}` : '' + const data = await apiFetch(`/api/sdk/v1/dsb/assignments/${assignmentId}/tasks${params}`) + setTasks(data) + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Fehler beim Laden der Aufgaben') + } finally { setLoading(false) } + }, [assignmentId, statusFilter]) + + useEffect(() => { fetchTasks() }, [fetchTasks]) + + const handleCreateTask = async (e: React.FormEvent) => { + e.preventDefault() + setSaving(true) + try { + await apiFetch(`/api/sdk/v1/dsb/assignments/${assignmentId}/tasks`, { + method: 'POST', + body: JSON.stringify({ + title: newTitle, description: newDesc, category: newCategory, + priority: newPriority, due_date: newDueDate || null, + }), + }) + addToast('Aufgabe erstellt') + setShowModal(false) + setNewTitle(''); setNewDesc(''); setNewCategory(TASK_CATEGORIES[0]) + setNewPriority('medium'); setNewDueDate('') + fetchTasks() + } catch (e: unknown) { + addToast(e instanceof Error ? e.message : 'Fehler', 'error') + } finally { setSaving(false) } + } + + const handleCompleteTask = async (taskId: string) => { + try { + await apiFetch(`/api/sdk/v1/dsb/tasks/${taskId}/complete`, { method: 'POST' }) + addToast('Aufgabe abgeschlossen') + fetchTasks() + } catch (e: unknown) { + addToast(e instanceof Error ? e.message : 'Fehler', 'error') + } + } + + const statusFilters = [ + { value: 'all', label: 'Alle' }, + { value: 'open', label: 'Offen' }, + { value: 'in_progress', label: 'In Bearbeitung' }, + { value: 'completed', label: 'Erledigt' }, + ] + + return ( +
+ {/* Toolbar */} +
+
+ {statusFilters.map((f) => ( + + ))} +
+ setShowModal(true)} className="flex items-center gap-1.5"> + Neue Aufgabe + +
+ + {/* Content */} + {loading ? ( +
+ {Array.from({ length: 4 }).map((_, i) => )} +
+ ) : error ? ( + + ) : tasks.length === 0 ? ( + } title="Keine Aufgaben" + description="Erstellen Sie eine neue Aufgabe um zu beginnen." /> + ) : ( +
+ {tasks.map((task) => ( +
+
+
+
+

+ {task.title} +

+ + + +
+ {task.description &&

{task.description}

} +
+ {task.due_date && ( + Frist: {formatDate(task.due_date)} + )} + Erstellt: {formatDate(task.created_at)} +
+
+ {task.status !== 'completed' && task.status !== 'cancelled' && ( + + )} +
+
+ ))} +
+ )} + + {/* Create task modal */} + setShowModal(false)} title="Neue Aufgabe erstellen"> +
+
+ Titel * + +
+
+ Beschreibung + +
+
+
+ Kategorie + ({ value: c, label: c }))} /> +
+
+ Prioritaet + +
+
+
+ Faelligkeitsdatum + +
+
+ setShowModal(false)}>Abbrechen + + {saving ? 'Erstelle...' : 'Aufgabe erstellen'} + +
+
+
+
+ ) +} diff --git a/admin-compliance/app/sdk/dsb-portal/_components/DetailView.tsx b/admin-compliance/app/sdk/dsb-portal/_components/DetailView.tsx new file mode 100644 index 0000000..7fe1351 --- /dev/null +++ b/admin-compliance/app/sdk/dsb-portal/_components/DetailView.tsx @@ -0,0 +1,126 @@ +'use client' + +import React, { useState } from 'react' +import { AssignmentOverview, ASSIGNMENT_STATUS_LABELS, ASSIGNMENT_STATUS_COLORS, formatDate } from './types' +import { + Badge, ComplianceBar, HoursBar, + IconBack, IconTask, IconClock, IconMail, IconSettings, IconShield, +} from './ui-primitives' +import { AufgabenTab } from './AufgabenTab' +import { ZeiterfassungTab } from './ZeiterfassungTab' +import { KommunikationTab } from './KommunikationTab' +import { EinstellungenTab } from './EinstellungenTab' + +type DetailTab = 'aufgaben' | 'zeit' | 'kommunikation' | 'einstellungen' + +export function DetailView({ + assignment, + onBack, + onUpdate, + addToast, +}: { + assignment: AssignmentOverview + onBack: () => void + onUpdate: () => void + addToast: (msg: string, type?: 'success' | 'error') => void +}) { + const [activeTab, setActiveTab] = useState('aufgaben') + + const tabs: { id: DetailTab; label: string; icon: React.ReactNode }[] = [ + { id: 'aufgaben', label: 'Aufgaben', icon: }, + { id: 'zeit', label: 'Zeiterfassung', icon: }, + { id: 'kommunikation', label: 'Kommunikation', icon: }, + { id: 'einstellungen', label: 'Einstellungen', icon: }, + ] + + return ( +
+ {/* Back + Header */} +
+ + +
+
+
+
+
+ +
+
+

{assignment.tenant_name}

+

{assignment.tenant_slug}

+
+
+
+
+ +
+
+ + {/* Meta info */} +
+
+

Vertragsbeginn

+

{formatDate(assignment.contract_start)}

+
+
+

Vertragsende

+

+ {assignment.contract_end ? formatDate(assignment.contract_end) : 'Unbefristet'} +

+
+
+

Compliance-Score

+
+
+
+

Stunden diesen Monat

+
+
+
+ + {assignment.notes && ( +
+

Anmerkungen

+

{assignment.notes}

+
+ )} +
+
+ + {/* Tabs */} +
+ +
+ + {/* Tab content */} +
+ {activeTab === 'aufgaben' && } + {activeTab === 'zeit' && ( + + )} + {activeTab === 'kommunikation' && } + {activeTab === 'einstellungen' && ( + + )} +
+
+ ) +} diff --git a/admin-compliance/app/sdk/dsb-portal/_components/EinstellungenTab.tsx b/admin-compliance/app/sdk/dsb-portal/_components/EinstellungenTab.tsx new file mode 100644 index 0000000..9245eac --- /dev/null +++ b/admin-compliance/app/sdk/dsb-portal/_components/EinstellungenTab.tsx @@ -0,0 +1,102 @@ +'use client' + +import React, { useState } from 'react' +import { AssignmentOverview, ASSIGNMENT_STATUS_LABELS, apiFetch } from './types' +import { FormLabel, FormInput, FormTextarea, PrimaryButton } from './ui-primitives' + +export function EinstellungenTab({ + assignment, + onUpdate, + addToast, +}: { + assignment: AssignmentOverview + onUpdate: () => void + addToast: (msg: string, type?: 'success' | 'error') => void +}) { + const [status, setStatus] = useState(assignment.status) + const [budget, setBudget] = useState(String(assignment.monthly_hours_budget)) + const [notes, setNotes] = useState(assignment.notes || '') + const [contractStart, setContractStart] = useState(assignment.contract_start?.slice(0, 10) || '') + const [contractEnd, setContractEnd] = useState(assignment.contract_end?.slice(0, 10) || '') + const [saving, setSaving] = useState(false) + + const handleSave = async () => { + setSaving(true) + try { + await apiFetch(`/api/sdk/v1/dsb/assignments/${assignment.id}`, { + method: 'PUT', + body: JSON.stringify({ + status, + monthly_hours_budget: parseFloat(budget) || 0, + notes, + contract_start: contractStart || null, + contract_end: contractEnd || null, + }), + }) + addToast('Einstellungen gespeichert') + onUpdate() + } catch (e: unknown) { + addToast(e instanceof Error ? e.message : 'Fehler beim Speichern', 'error') + } finally { setSaving(false) } + } + + return ( +
+ {/* Status */} +
+

Status

+
+ {(['active', 'paused', 'terminated'] as const).map((s) => ( + + ))} +
+
+ + {/* Contract period */} +
+

Vertragszeitraum

+
+
+ Vertragsbeginn + +
+
+ Vertragsende + +
+
+
+ + {/* Budget */} +
+

Monatliches Stundenbudget

+
+ +

Stunden pro Monat

+
+
+ + {/* Notes */} +
+

Anmerkungen

+ +
+ + {/* Save */} +
+ + {saving ? 'Speichere...' : 'Einstellungen speichern'} + +
+
+ ) +} diff --git a/admin-compliance/app/sdk/dsb-portal/_components/KommunikationTab.tsx b/admin-compliance/app/sdk/dsb-portal/_components/KommunikationTab.tsx new file mode 100644 index 0000000..cc511b8 --- /dev/null +++ b/admin-compliance/app/sdk/dsb-portal/_components/KommunikationTab.tsx @@ -0,0 +1,161 @@ +'use client' + +import React, { useState, useEffect, useCallback } from 'react' +import { Communication, COMM_CHANNELS, apiFetch, formatDateTime } from './types' +import { + Skeleton, Modal, Badge, FormLabel, FormInput, FormTextarea, + FormSelect, PrimaryButton, SecondaryButton, ErrorState, EmptyState, + IconMail, IconPlus, IconInbound, IconOutbound, +} from './ui-primitives' + +const CHANNEL_COLORS: Record = { + 'E-Mail': 'bg-blue-100 text-blue-700 border-blue-200', + 'Telefon': 'bg-green-100 text-green-700 border-green-200', + 'Besprechung': 'bg-purple-100 text-purple-700 border-purple-200', + 'Portal': 'bg-indigo-100 text-indigo-700 border-indigo-200', + 'Brief': 'bg-orange-100 text-orange-700 border-orange-200', +} + +export function KommunikationTab({ + assignmentId, + addToast, +}: { + assignmentId: string + addToast: (msg: string, type?: 'success' | 'error') => void +}) { + const [comms, setComms] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [showModal, setShowModal] = useState(false) + const [saving, setSaving] = useState(false) + + const [formDirection, setFormDirection] = useState('outbound') + const [formChannel, setFormChannel] = useState(COMM_CHANNELS[0]) + const [formSubject, setFormSubject] = useState('') + const [formContent, setFormContent] = useState('') + const [formParticipants, setFormParticipants] = useState('') + + const fetchComms = useCallback(async () => { + setLoading(true); setError('') + try { + const data = await apiFetch( + `/api/sdk/v1/dsb/assignments/${assignmentId}/communications`, + ) + setComms(data) + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Fehler beim Laden der Kommunikation') + } finally { setLoading(false) } + }, [assignmentId]) + + useEffect(() => { fetchComms() }, [fetchComms]) + + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault(); setSaving(true) + try { + await apiFetch(`/api/sdk/v1/dsb/assignments/${assignmentId}/communications`, { + method: 'POST', + body: JSON.stringify({ + direction: formDirection, channel: formChannel, + subject: formSubject, content: formContent, participants: formParticipants, + }), + }) + addToast('Kommunikation erfasst'); setShowModal(false) + setFormDirection('outbound'); setFormChannel(COMM_CHANNELS[0]) + setFormSubject(''); setFormContent(''); setFormParticipants('') + fetchComms() + } catch (e: unknown) { + addToast(e instanceof Error ? e.message : 'Fehler', 'error') + } finally { setSaving(false) } + } + + return ( +
+ {/* Toolbar */} +
+

Kommunikations-Protokoll

+ setShowModal(true)} className="flex items-center gap-1.5"> + Kommunikation erfassen + +
+ + {loading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => )} +
+ ) : error ? ( + + ) : comms.length === 0 ? ( + } title="Keine Kommunikation" + description="Erfassen Sie die erste Kommunikation mit dem Mandanten." /> + ) : ( +
+ {comms.map((comm) => ( +
+
+
+ {comm.direction === 'inbound' ? : } +
+
+
+ {comm.subject} + + + {comm.direction === 'inbound' ? 'Eingehend' : 'Ausgehend'} + +
+ {comm.content &&

{comm.content}

} +
+ {formatDateTime(comm.created_at)} + {comm.participants && Teilnehmer: {comm.participants}} +
+
+
+
+ ))} +
+ )} + + {/* Create communication modal */} + setShowModal(false)} title="Kommunikation erfassen"> +
+
+
+ Richtung + +
+
+ Kanal + ({ value: c, label: c }))} /> +
+
+
+ Betreff * + +
+
+ Inhalt + +
+
+ Teilnehmer + +
+
+ setShowModal(false)}>Abbrechen + + {saving ? 'Speichere...' : 'Kommunikation erfassen'} + +
+
+
+
+ ) +} diff --git a/admin-compliance/app/sdk/dsb-portal/_components/MandantCard.tsx b/admin-compliance/app/sdk/dsb-portal/_components/MandantCard.tsx new file mode 100644 index 0000000..89c3ed0 --- /dev/null +++ b/admin-compliance/app/sdk/dsb-portal/_components/MandantCard.tsx @@ -0,0 +1,69 @@ +'use client' + +import React from 'react' +import { AssignmentOverview, ASSIGNMENT_STATUS_LABELS, ASSIGNMENT_STATUS_COLORS, formatDate } from './types' +import { Badge, ComplianceBar, HoursBar, IconTask, IconCalendar } from './ui-primitives' + +export function MandantCard({ + assignment, + onClick, +}: { + assignment: AssignmentOverview + onClick: () => void +}) { + return ( +
+ {/* Header */} +
+
+

+ {assignment.tenant_name} +

+

{assignment.tenant_slug}

+
+ +
+ + {/* Compliance Score */} +
+
+ Compliance-Score +
+ +
+ + {/* Hours */} +
+
+ Stunden diesen Monat +
+ +
+ + {/* Footer: Tasks */} +
+
+ + {assignment.open_task_count} offene Aufgaben +
+ {assignment.urgent_task_count > 0 && ( + + )} +
+ + {/* Next deadline */} + {assignment.next_deadline && ( +
+ + Naechste Frist: {formatDate(assignment.next_deadline)} +
+ )} +
+ ) +} diff --git a/admin-compliance/app/sdk/dsb-portal/_components/ZeiterfassungTab.tsx b/admin-compliance/app/sdk/dsb-portal/_components/ZeiterfassungTab.tsx new file mode 100644 index 0000000..938a0c2 --- /dev/null +++ b/admin-compliance/app/sdk/dsb-portal/_components/ZeiterfassungTab.tsx @@ -0,0 +1,230 @@ +'use client' + +import React, { useState, useEffect, useCallback } from 'react' +import { + HourEntry, HoursSummary, HOUR_CATEGORIES, + apiFetch, formatDate, currentMonth, monthLabel, prevMonth, nextMonth, +} from './types' +import { + Skeleton, Modal, Badge, HoursBar, FormLabel, FormInput, + FormTextarea, FormSelect, PrimaryButton, SecondaryButton, + ErrorState, EmptyState, IconClock, IconPlus, +} from './ui-primitives' + +export function ZeiterfassungTab({ + assignmentId, + monthlyBudget, + addToast, +}: { + assignmentId: string + monthlyBudget: number + addToast: (msg: string, type?: 'success' | 'error') => void +}) { + const [hours, setHours] = useState([]) + const [summary, setSummary] = useState(null) + const [month, setMonth] = useState(currentMonth()) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [showModal, setShowModal] = useState(false) + const [saving, setSaving] = useState(false) + + const [formDate, setFormDate] = useState(new Date().toISOString().slice(0, 10)) + const [formHours, setFormHours] = useState('1') + const [formCategory, setFormCategory] = useState(HOUR_CATEGORIES[0]) + const [formDesc, setFormDesc] = useState('') + const [formBillable, setFormBillable] = useState(true) + + const fetchData = useCallback(async () => { + setLoading(true); setError('') + try { + const [hoursData, summaryData] = await Promise.all([ + apiFetch(`/api/sdk/v1/dsb/assignments/${assignmentId}/hours?month=${month}`), + apiFetch(`/api/sdk/v1/dsb/assignments/${assignmentId}/hours/summary?month=${month}`), + ]) + setHours(hoursData); setSummary(summaryData) + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Fehler beim Laden der Zeiterfassung') + } finally { setLoading(false) } + }, [assignmentId, month]) + + useEffect(() => { fetchData() }, [fetchData]) + + const handleLogHours = async (e: React.FormEvent) => { + e.preventDefault(); setSaving(true) + try { + await apiFetch(`/api/sdk/v1/dsb/assignments/${assignmentId}/hours`, { + method: 'POST', + body: JSON.stringify({ + date: formDate, hours: parseFloat(formHours), + category: formCategory, description: formDesc, billable: formBillable, + }), + }) + addToast('Stunden erfasst'); setShowModal(false) + setFormDate(new Date().toISOString().slice(0, 10)) + setFormHours('1'); setFormCategory(HOUR_CATEGORIES[0]) + setFormDesc(''); setFormBillable(true); fetchData() + } catch (e: unknown) { + addToast(e instanceof Error ? e.message : 'Fehler', 'error') + } finally { setSaving(false) } + } + + const maxCatHours = summary ? Math.max(...Object.values(summary.by_category), 1) : 1 + + return ( +
+ {/* Toolbar */} +
+
+ + {monthLabel(month)} + +
+ setShowModal(true)} className="flex items-center gap-1.5"> + Stunden erfassen + +
+ + {loading ? ( +
+ + +
+ ) : error ? ( + + ) : ( +
+ {/* Summary cards */} + {summary && ( +
+
+

Gesamt-Stunden

+

{summary.total_hours}h

+
+
+
+

Abrechnungsfaehig

+

{summary.billable_hours}h

+

+ {summary.total_hours > 0 + ? `${Math.round((summary.billable_hours / summary.total_hours) * 100)}% der Gesamtstunden` + : 'Keine Stunden erfasst'} +

+
+
+

Budget verbleibend

+

{Math.max(monthlyBudget - summary.total_hours, 0)}h

+

von {monthlyBudget}h Monatsbudget

+
+
+ )} + + {/* Hours by category */} + {summary && Object.keys(summary.by_category).length > 0 && ( +
+

Stunden nach Kategorie

+
+ {Object.entries(summary.by_category).sort(([, a], [, b]) => b - a).map(([cat, h]) => ( +
+ {cat} +
+
+
+ {h}h +
+ ))} +
+
+ )} + + {/* Hours table */} + {hours.length === 0 ? ( + } title="Keine Stunden erfasst" + description={`Fuer ${monthLabel(month)} wurden noch keine Stunden erfasst.`} /> + ) : ( +
+
+ + + + + + + + + + + + {hours.map((entry) => ( + + + + + + + + ))} + +
DatumStundenKategorieBeschreibungAbrechenbar
{formatDate(entry.date)}{entry.hours}h + + {entry.description || '-'} + {entry.billable + ? Ja + : Nein} +
+
+
+ )} +
+ )} + + {/* Log hours modal */} + setShowModal(false)} title="Stunden erfassen"> +
+
+
+ Datum * + +
+
+ Stunden * + +
+
+
+ Kategorie + ({ value: c, label: c }))} /> +
+
+ Beschreibung + +
+
+ setFormBillable(e.target.checked)} + className="rounded border-gray-300 text-purple-600 focus:ring-purple-500" /> + +
+
+ setShowModal(false)}>Abbrechen + + {saving ? 'Erfasse...' : 'Stunden erfassen'} + +
+
+
+
+ ) +} diff --git a/admin-compliance/app/sdk/dsb-portal/_components/types.ts b/admin-compliance/app/sdk/dsb-portal/_components/types.ts new file mode 100644 index 0000000..10afb83 --- /dev/null +++ b/admin-compliance/app/sdk/dsb-portal/_components/types.ts @@ -0,0 +1,199 @@ +// ============================================================================= +// TYPES +// ============================================================================= + +export 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 +} + +export interface DSBDashboard { + assignments: AssignmentOverview[] + total_assignments: number + active_assignments: number + total_hours_this_month: number + open_tasks: number + urgent_tasks: number + generated_at: string +} + +export interface HourEntry { + id: string + assignment_id: string + date: string + hours: number + category: string + description: string + billable: boolean + created_at: string +} + +export 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 +} + +export interface Communication { + id: string + assignment_id: string + direction: string + channel: string + subject: string + content: string + participants: string + created_at: string +} + +export interface HoursSummary { + total_hours: number + billable_hours: number + by_category: Record + period: string +} + +// ============================================================================= +// CONSTANTS +// ============================================================================= + +export const DSB_USER_ID = '00000000-0000-0000-0000-000000000001' + +export const TASK_CATEGORIES = [ + 'DSFA-Pruefung', 'Betroffenenanfrage', 'Vorfall-Pruefung', + 'Audit-Vorbereitung', 'Richtlinien-Pruefung', 'Schulung', + 'Beratung', 'Sonstiges', +] + +export const HOUR_CATEGORIES = [ + 'DSFA-Pruefung', 'Beratung', 'Audit', 'Schulung', + 'Vorfallreaktion', 'Dokumentation', 'Besprechung', 'Sonstiges', +] + +export const COMM_CHANNELS = ['E-Mail', 'Telefon', 'Besprechung', 'Portal', 'Brief'] + +export const PRIORITY_LABELS: Record = { + urgent: 'Dringend', high: 'Hoch', medium: 'Mittel', low: 'Niedrig', +} + +export 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', +} + +export const TASK_STATUS_LABELS: Record = { + open: 'Offen', in_progress: 'In Bearbeitung', waiting: 'Wartend', + completed: 'Erledigt', cancelled: 'Abgebrochen', +} + +export 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', +} + +export 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', +} + +export const ASSIGNMENT_STATUS_LABELS: Record = { + active: 'Aktiv', paused: 'Pausiert', terminated: 'Beendet', +} + +// ============================================================================= +// API HELPERS +// ============================================================================= + +export 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() +} + +// ============================================================================= +// DATE HELPERS +// ============================================================================= + +export function formatDate(dateStr: string | null): string { + if (!dateStr) return '-' + try { + return new Date(dateStr).toLocaleDateString('de-DE', { + day: '2-digit', month: '2-digit', year: 'numeric', + }) + } catch { return dateStr } +} + +export function formatDateTime(dateStr: string | null): string { + if (!dateStr) return '-' + try { + return new Date(dateStr).toLocaleDateString('de-DE', { + day: '2-digit', month: '2-digit', year: 'numeric', + hour: '2-digit', minute: '2-digit', + }) + } catch { return dateStr } +} + +export function currentMonth(): string { + const d = new Date() + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` +} + +export function monthLabel(ym: string): string { + const [y, m] = ym.split('-') + const months = [ + 'Januar', 'Februar', 'Maerz', 'April', 'Mai', 'Juni', + 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember', + ] + return `${months[parseInt(m, 10) - 1]} ${y}` +} + +export function prevMonth(ym: string): string { + const [y, m] = ym.split('-').map(Number) + if (m === 1) return `${y - 1}-12` + return `${y}-${String(m - 1).padStart(2, '0')}` +} + +export function nextMonth(ym: string): string { + const [y, m] = ym.split('-').map(Number) + if (m === 12) return `${y + 1}-01` + return `${y}-${String(m + 1).padStart(2, '0')}` +} diff --git a/admin-compliance/app/sdk/dsb-portal/_components/ui-primitives.tsx b/admin-compliance/app/sdk/dsb-portal/_components/ui-primitives.tsx new file mode 100644 index 0000000..f59f31b --- /dev/null +++ b/admin-compliance/app/sdk/dsb-portal/_components/ui-primitives.tsx @@ -0,0 +1,367 @@ +'use client' + +import React, { useState, useEffect, useCallback, useRef } from 'react' + +// ============================================================================= +// TOAST +// ============================================================================= + +export interface ToastMessage { + id: number + message: string + type: 'success' | 'error' +} + +let toastIdCounter = 0 + +export 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 } +} + +export function ToastContainer({ toasts }: { toasts: ToastMessage[] }) { + if (toasts.length === 0) return null + return ( +
+ {toasts.map((t) => ( +
+ {t.message} +
+ ))} +
+ ) +} + +// ============================================================================= +// LOADING SKELETON +// ============================================================================= + +export function Skeleton({ className = '' }: { className?: string }) { + return
+} + +export function DashboardSkeleton() { + return ( +
+
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+
+ ) +} + +// ============================================================================= +// MODAL +// ============================================================================= + +export 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, PROGRESS BARS, BADGE +// ============================================================================= + +export function StatCard({ + title, value, icon, accent = false, +}: { + title: string; value: string | number; icon: React.ReactNode; accent?: boolean +}) { + return ( +
+
+
{icon}
+
+

{title}

+

{value}

+
+
+
+ ) +} + +export 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}% +
+ ) +} + +export 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 + +
+ ) +} + +export function Badge({ label, className = '' }: { label: string; className?: string }) { + return ( + + {label} + + ) +} + +// ============================================================================= +// FORM COMPONENTS +// ============================================================================= + +export function FormLabel({ children, htmlFor }: { children: React.ReactNode; htmlFor?: string }) { + return +} + +export 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" /> + ) +} + +export function FormTextarea({ + id, value, onChange, placeholder, rows = 3, +}: { id?: string; value: string; onChange: (val: string) => void; placeholder?: string; rows?: number }) { + return ( +