Split 1257-LOC client page into _types.ts plus nine components under _components/ (TabNavigation/StatCard/FilterBar in shared, CourseCard, EnrollmentCard, CertificatesTab, EnrollmentEditModal, CourseEditModal, SettingsTab, and PageSections for header actions and empty states). Behavior preserved exactly; page.tsx is now a thin wiring shell. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
225 lines
9.2 KiB
TypeScript
225 lines
9.2 KiB
TypeScript
'use client'
|
|
|
|
import React from 'react'
|
|
import Link from 'next/link'
|
|
|
|
// =============================================================================
|
|
// HEADER ACTIONS
|
|
// =============================================================================
|
|
|
|
export function HeaderActions({
|
|
isGenerating,
|
|
onGenerateAll
|
|
}: {
|
|
isGenerating: boolean
|
|
onGenerateAll: () => void
|
|
}) {
|
|
return (
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={onGenerateAll}
|
|
disabled={isGenerating}
|
|
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50"
|
|
>
|
|
{isGenerating ? (
|
|
<svg className="animate-spin w-5 h-5" fill="none" viewBox="0 0 24 24">
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
|
</svg>
|
|
) : (
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
|
</svg>
|
|
)}
|
|
{isGenerating ? 'Generiere...' : 'Alle Kurse generieren'}
|
|
</button>
|
|
<Link
|
|
href="/sdk/academy/new"
|
|
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
|
</svg>
|
|
Kurs erstellen
|
|
</Link>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// GENERATION RESULT BAR
|
|
// =============================================================================
|
|
|
|
export function GenerationResultBar({
|
|
result
|
|
}: {
|
|
result: { generated: number; skipped: number; errors: string[] }
|
|
}) {
|
|
return (
|
|
<div className={`p-4 rounded-lg border ${result.errors.length > 0 ? 'bg-yellow-50 border-yellow-200' : 'bg-green-50 border-green-200'}`}>
|
|
<div className="flex items-center gap-4 text-sm">
|
|
<span className="text-green-700 font-medium">{result.generated} Kurse generiert</span>
|
|
<span className="text-gray-500">{result.skipped} uebersprungen</span>
|
|
{result.errors.length > 0 && (
|
|
<span className="text-red-600">{result.errors.length} Fehler</span>
|
|
)}
|
|
</div>
|
|
{result.errors.length > 0 && (
|
|
<div className="mt-2 text-xs text-red-600">
|
|
{result.errors.map((err, i) => <div key={i}>{err}</div>)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// LOADING SPINNER
|
|
// =============================================================================
|
|
|
|
export function LoadingSpinner() {
|
|
return (
|
|
<div className="flex items-center justify-center py-12">
|
|
<svg className="animate-spin w-8 h-8 text-purple-600" fill="none" viewBox="0 0 24 24">
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
|
</svg>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// OVERDUE ALERT
|
|
// =============================================================================
|
|
|
|
export function OverdueAlert({ count, onShow }: { count: number; onShow: () => void }) {
|
|
return (
|
|
<div className="bg-red-50 border border-red-200 rounded-xl p-4 flex items-center gap-4">
|
|
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center flex-shrink-0">
|
|
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
|
</svg>
|
|
</div>
|
|
<div className="flex-1">
|
|
<h4 className="font-medium text-red-800">
|
|
Achtung: {count} ueberfaellige Schulung(en)
|
|
</h4>
|
|
<p className="text-sm text-red-600">
|
|
Mitarbeiter haben Pflichtschulungen nicht fristgerecht abgeschlossen. Handeln Sie umgehend.
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={onShow}
|
|
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium"
|
|
>
|
|
Anzeigen
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// INFO BOX
|
|
// =============================================================================
|
|
|
|
export function InfoBox() {
|
|
return (
|
|
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
|
<div className="flex items-start gap-3">
|
|
<svg className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
<div>
|
|
<h4 className="font-medium text-blue-800">Schulungspflicht nach Art. 39 DSGVO</h4>
|
|
<p className="text-sm text-blue-600 mt-1">
|
|
Gemaess Art. 39 Abs. 1 lit. b DSGVO gehoert die Sensibilisierung und Schulung
|
|
der an den Verarbeitungsvorgaengen beteiligten Mitarbeiter zu den Aufgaben des
|
|
Datenschutzbeauftragten. Nachweisbare Compliance-Schulungen sind Pflicht und
|
|
sollten mindestens jaehrlich aufgefrischt werden.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// EMPTY STATES
|
|
// =============================================================================
|
|
|
|
export function EmptyCourses({
|
|
selectedCategory,
|
|
onClearFilters
|
|
}: {
|
|
selectedCategory: string
|
|
onClearFilters: () => void
|
|
}) {
|
|
return (
|
|
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
|
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
|
|
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
|
</svg>
|
|
</div>
|
|
<h3 className="text-lg font-semibold text-gray-900">Keine Kurse gefunden</h3>
|
|
<p className="mt-2 text-gray-500">
|
|
{selectedCategory !== 'all'
|
|
? 'Passen Sie die Filter an oder'
|
|
: 'Es sind noch keine Kurse vorhanden.'
|
|
}
|
|
</p>
|
|
{selectedCategory !== 'all' ? (
|
|
<button
|
|
onClick={onClearFilters}
|
|
className="mt-4 px-4 py-2 text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
|
|
>
|
|
Filter zuruecksetzen
|
|
</button>
|
|
) : (
|
|
<Link
|
|
href="/sdk/academy/new"
|
|
className="mt-4 inline-flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
|
</svg>
|
|
Ersten Kurs erstellen
|
|
</Link>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function EmptyEnrollments({
|
|
selectedStatus,
|
|
onClearFilters
|
|
}: {
|
|
selectedStatus: string
|
|
onClearFilters: () => void
|
|
}) {
|
|
return (
|
|
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
|
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
|
|
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
</svg>
|
|
</div>
|
|
<h3 className="text-lg font-semibold text-gray-900">Keine Einschreibungen gefunden</h3>
|
|
<p className="mt-2 text-gray-500">
|
|
{selectedStatus !== 'all'
|
|
? 'Passen Sie die Filter an.'
|
|
: 'Es sind noch keine Mitarbeiter in Kurse eingeschrieben.'
|
|
}
|
|
</p>
|
|
{selectedStatus !== 'all' && (
|
|
<button
|
|
onClick={onClearFilters}
|
|
className="mt-4 px-4 py-2 text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
|
|
>
|
|
Filter zuruecksetzen
|
|
</button>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|