Add Next.js pages for Academy, Whistleblower, Incidents, Document Crawler, DSB Portal, Industry Templates, Multi-Tenant and SSO. Add API proxy routes and TypeScript SDK client libraries. Add server binary to .gitignore. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2069 lines
69 KiB
TypeScript
2069 lines
69 KiB
TypeScript
'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<string, number>
|
|
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<string, string> = {
|
|
urgent: 'Dringend',
|
|
high: 'Hoch',
|
|
medium: 'Mittel',
|
|
low: 'Niedrig',
|
|
}
|
|
|
|
const PRIORITY_COLORS: Record<string, string> = {
|
|
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<string, string> = {
|
|
open: 'Offen',
|
|
in_progress: 'In Bearbeitung',
|
|
waiting: 'Wartend',
|
|
completed: 'Erledigt',
|
|
cancelled: 'Abgebrochen',
|
|
}
|
|
|
|
const TASK_STATUS_COLORS: Record<string, string> = {
|
|
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<string, string> = {
|
|
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<string, string> = {
|
|
active: 'Aktiv',
|
|
paused: 'Pausiert',
|
|
terminated: 'Beendet',
|
|
}
|
|
|
|
// =============================================================================
|
|
// API HELPERS
|
|
// =============================================================================
|
|
|
|
async function apiFetch<T>(url: string, options?: RequestInit): Promise<T> {
|
|
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<ToastMessage[]>([])
|
|
|
|
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 (
|
|
<div className="fixed top-4 right-4 z-[100] flex flex-col gap-2">
|
|
{toasts.map((t) => (
|
|
<div
|
|
key={t.id}
|
|
className={`px-5 py-3 rounded-lg shadow-lg text-sm font-medium text-white transition-all animate-slide-in ${
|
|
t.type === 'success' ? 'bg-green-600' : 'bg-red-600'
|
|
}`}
|
|
>
|
|
{t.message}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// LOADING SKELETON
|
|
// =============================================================================
|
|
|
|
function Skeleton({ className = '' }: { className?: string }) {
|
|
return <div className={`animate-pulse bg-gray-200 rounded ${className}`} />
|
|
}
|
|
|
|
function DashboardSkeleton() {
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
{Array.from({ length: 4 }).map((_, i) => (
|
|
<Skeleton key={i} className="h-28 rounded-xl" />
|
|
))}
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
|
{Array.from({ length: 3 }).map((_, i) => (
|
|
<Skeleton key={i} className="h-64 rounded-xl" />
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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<HTMLDivElement>(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 (
|
|
<div
|
|
ref={overlayRef}
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
|
onClick={(e) => {
|
|
if (e.target === overlayRef.current) onClose()
|
|
}}
|
|
>
|
|
<div className={`bg-white rounded-2xl shadow-2xl w-full ${maxWidth} mx-4 overflow-hidden`}>
|
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 bg-purple-50">
|
|
<h3 className="text-lg font-semibold text-purple-900">{title}</h3>
|
|
<button
|
|
onClick={onClose}
|
|
className="p-1 rounded-lg hover:bg-purple-100 transition-colors text-purple-600"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div className="px-6 py-4 max-h-[70vh] overflow-y-auto">{children}</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// STAT CARD
|
|
// =============================================================================
|
|
|
|
function StatCard({
|
|
title,
|
|
value,
|
|
icon,
|
|
accent = false,
|
|
}: {
|
|
title: string
|
|
value: string | number
|
|
icon: React.ReactNode
|
|
accent?: boolean
|
|
}) {
|
|
return (
|
|
<div
|
|
className={`rounded-xl border p-5 transition-all ${
|
|
accent
|
|
? 'bg-red-50 border-red-200'
|
|
: 'bg-white border-gray-200 hover:border-purple-300'
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<div
|
|
className={`w-10 h-10 rounded-lg flex items-center justify-center ${
|
|
accent ? 'bg-red-100 text-red-600' : 'bg-purple-100 text-purple-600'
|
|
}`}
|
|
>
|
|
{icon}
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-gray-500">{title}</p>
|
|
<p className={`text-2xl font-bold ${accent ? 'text-red-700' : 'text-gray-900'}`}>
|
|
{value}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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 (
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex-1 h-2.5 bg-gray-100 rounded-full overflow-hidden">
|
|
<div
|
|
className={`h-full rounded-full transition-all ${color}`}
|
|
style={{ width: `${Math.min(score, 100)}%` }}
|
|
/>
|
|
</div>
|
|
<span className={`text-xs font-semibold min-w-[36px] text-right ${textColor}`}>
|
|
{score}%
|
|
</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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 (
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex-1 h-2.5 bg-gray-100 rounded-full overflow-hidden">
|
|
<div
|
|
className={`h-full rounded-full transition-all ${over ? 'bg-red-500' : 'bg-purple-500'}`}
|
|
style={{ width: `${pct}%` }}
|
|
/>
|
|
</div>
|
|
<span className={`text-xs font-medium min-w-[60px] text-right ${over ? 'text-red-600' : 'text-gray-600'}`}>
|
|
{used}h / {budget}h
|
|
</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// BADGE
|
|
// =============================================================================
|
|
|
|
function Badge({ label, className = '' }: { label: string; className?: string }) {
|
|
return (
|
|
<span className={`inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full border ${className}`}>
|
|
{label}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// FORM COMPONENTS
|
|
// =============================================================================
|
|
|
|
function FormLabel({ children, htmlFor }: { children: React.ReactNode; htmlFor?: string }) {
|
|
return (
|
|
<label htmlFor={htmlFor} className="block text-sm font-medium text-gray-700 mb-1">
|
|
{children}
|
|
</label>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<input
|
|
id={id}
|
|
type={type}
|
|
value={value}
|
|
onChange={(e) => 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 (
|
|
<textarea
|
|
id={id}
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
placeholder={placeholder}
|
|
rows={rows}
|
|
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 resize-none"
|
|
/>
|
|
)
|
|
}
|
|
|
|
function FormSelect({
|
|
id,
|
|
value,
|
|
onChange,
|
|
options,
|
|
placeholder,
|
|
}: {
|
|
id?: string
|
|
value: string
|
|
onChange: (val: string) => void
|
|
options: { value: string; label: string }[]
|
|
placeholder?: string
|
|
}) {
|
|
return (
|
|
<select
|
|
id={id}
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
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 bg-white"
|
|
>
|
|
{placeholder && (
|
|
<option value="" disabled>
|
|
{placeholder}
|
|
</option>
|
|
)}
|
|
{options.map((o) => (
|
|
<option key={o.value} value={o.value}>
|
|
{o.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
)
|
|
}
|
|
|
|
function PrimaryButton({
|
|
onClick,
|
|
disabled,
|
|
children,
|
|
type = 'button',
|
|
className = '',
|
|
}: {
|
|
onClick?: () => void
|
|
disabled?: boolean
|
|
children: React.ReactNode
|
|
type?: 'button' | 'submit'
|
|
className?: string
|
|
}) {
|
|
return (
|
|
<button
|
|
type={type}
|
|
onClick={onClick}
|
|
disabled={disabled}
|
|
className={`px-4 py-2 rounded-lg bg-purple-600 text-white text-sm font-medium hover:bg-purple-700 focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors ${className}`}
|
|
>
|
|
{children}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
function SecondaryButton({
|
|
onClick,
|
|
disabled,
|
|
children,
|
|
type = 'button',
|
|
className = '',
|
|
}: {
|
|
onClick?: () => void
|
|
disabled?: boolean
|
|
children: React.ReactNode
|
|
type?: 'button' | 'submit'
|
|
className?: string
|
|
}) {
|
|
return (
|
|
<button
|
|
type={type}
|
|
onClick={onClick}
|
|
disabled={disabled}
|
|
className={`px-4 py-2 rounded-lg border border-gray-300 text-gray-700 text-sm font-medium hover:bg-gray-50 focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors ${className}`}
|
|
>
|
|
{children}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// ERROR STATE
|
|
// =============================================================================
|
|
|
|
function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
|
<div className="w-16 h-16 rounded-full bg-red-100 flex items-center justify-center mb-4">
|
|
<svg className="w-8 h-8 text-red-500" 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-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<p className="text-gray-700 font-medium mb-1">Fehler beim Laden</p>
|
|
<p className="text-sm text-gray-500 mb-4 max-w-md">{message}</p>
|
|
<PrimaryButton onClick={onRetry}>Erneut versuchen</PrimaryButton>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// EMPTY STATE
|
|
// =============================================================================
|
|
|
|
function EmptyState({ icon, title, description }: { icon: React.ReactNode; title: string; description: string }) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
|
<div className="w-14 h-14 rounded-full bg-purple-50 flex items-center justify-center mb-3 text-purple-400">
|
|
{icon}
|
|
</div>
|
|
<p className="text-gray-700 font-medium">{title}</p>
|
|
<p className="text-sm text-gray-400 mt-1">{description}</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// ICONS (inline SVGs)
|
|
// =============================================================================
|
|
|
|
function IconUsers({ className = 'w-5 h-5' }: { className?: string }) {
|
|
return (
|
|
<svg className={className} 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 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
function IconClock({ className = 'w-5 h-5' }: { className?: string }) {
|
|
return (
|
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
function IconTask({ className = 'w-5 h-5' }: { className?: string }) {
|
|
return (
|
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
function IconAlert({ className = 'w-5 h-5' }: { className?: string }) {
|
|
return (
|
|
<svg className={className} 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-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
function IconBack({ className = 'w-5 h-5' }: { className?: string }) {
|
|
return (
|
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
function IconPlus({ className = 'w-4 h-4' }: { className?: string }) {
|
|
return (
|
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
function IconCheck({ className = 'w-4 h-4' }: { className?: string }) {
|
|
return (
|
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
function IconMail({ className = 'w-5 h-5' }: { className?: string }) {
|
|
return (
|
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
function IconSettings({ className = 'w-5 h-5' }: { className?: string }) {
|
|
return (
|
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
function IconRefresh({ className = 'w-4 h-4' }: { className?: string }) {
|
|
return (
|
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
function IconInbound({ className = 'w-4 h-4' }: { className?: string }) {
|
|
return (
|
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
function IconOutbound({ className = 'w-4 h-4' }: { className?: string }) {
|
|
return (
|
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
function IconShield({ className = 'w-6 h-6' }: { className?: string }) {
|
|
return (
|
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
function IconCalendar({ className = 'w-4 h-4' }: { className?: string }) {
|
|
return (
|
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// DATE HELPERS
|
|
// =============================================================================
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
function currentMonth(): string {
|
|
const d = new Date()
|
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
|
}
|
|
|
|
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}`
|
|
}
|
|
|
|
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')}`
|
|
}
|
|
|
|
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')}`
|
|
}
|
|
|
|
// =============================================================================
|
|
// MANDANT CARD (Dashboard)
|
|
// =============================================================================
|
|
|
|
function MandantCard({
|
|
assignment,
|
|
onClick,
|
|
}: {
|
|
assignment: AssignmentOverview
|
|
onClick: () => void
|
|
}) {
|
|
return (
|
|
<div
|
|
onClick={onClick}
|
|
className="bg-white rounded-xl border border-gray-200 p-5 hover:border-purple-400 hover:shadow-lg cursor-pointer transition-all group"
|
|
>
|
|
{/* Header */}
|
|
<div className="flex items-start justify-between mb-3">
|
|
<div className="min-w-0">
|
|
<h3 className="font-semibold text-gray-900 truncate group-hover:text-purple-700 transition-colors">
|
|
{assignment.tenant_name}
|
|
</h3>
|
|
<p className="text-xs text-gray-400 font-mono">{assignment.tenant_slug}</p>
|
|
</div>
|
|
<Badge
|
|
label={ASSIGNMENT_STATUS_LABELS[assignment.status] || assignment.status}
|
|
className={ASSIGNMENT_STATUS_COLORS[assignment.status] || 'bg-gray-100 text-gray-600'}
|
|
/>
|
|
</div>
|
|
|
|
{/* Compliance Score */}
|
|
<div className="mb-3">
|
|
<div className="flex items-center justify-between text-xs text-gray-500 mb-1">
|
|
<span>Compliance-Score</span>
|
|
</div>
|
|
<ComplianceBar score={assignment.compliance_score} />
|
|
</div>
|
|
|
|
{/* Hours */}
|
|
<div className="mb-3">
|
|
<div className="flex items-center justify-between text-xs text-gray-500 mb-1">
|
|
<span>Stunden diesen Monat</span>
|
|
</div>
|
|
<HoursBar used={assignment.hours_this_month} budget={assignment.hours_budget} />
|
|
</div>
|
|
|
|
{/* Footer: Tasks */}
|
|
<div className="flex items-center justify-between pt-3 border-t border-gray-100">
|
|
<div className="flex items-center gap-1 text-sm text-gray-600">
|
|
<IconTask className="w-4 h-4" />
|
|
<span>{assignment.open_task_count} offene Aufgaben</span>
|
|
</div>
|
|
{assignment.urgent_task_count > 0 && (
|
|
<Badge
|
|
label={`${assignment.urgent_task_count} dringend`}
|
|
className="bg-red-100 text-red-700 border-red-200"
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* Next deadline */}
|
|
{assignment.next_deadline && (
|
|
<div className="flex items-center gap-1 mt-2 text-xs text-gray-400">
|
|
<IconCalendar className="w-3 h-3" />
|
|
<span>Naechste Frist: {formatDate(assignment.next_deadline)}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// AUFGABEN TAB
|
|
// =============================================================================
|
|
|
|
function AufgabenTab({
|
|
assignmentId,
|
|
addToast,
|
|
}: {
|
|
assignmentId: string
|
|
addToast: (msg: string, type?: 'success' | 'error') => void
|
|
}) {
|
|
const [tasks, setTasks] = useState<Task[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState('')
|
|
const [statusFilter, setStatusFilter] = useState<string>('all')
|
|
const [showModal, setShowModal] = useState(false)
|
|
const [saving, setSaving] = useState(false)
|
|
|
|
// New task form
|
|
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<Task[]>(
|
|
`/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<Task>(`/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 (
|
|
<div>
|
|
{/* Toolbar */}
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
|
|
<div className="flex flex-wrap gap-1">
|
|
{statusFilters.map((f) => (
|
|
<button
|
|
key={f.value}
|
|
onClick={() => setStatusFilter(f.value)}
|
|
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
|
statusFilter === f.value
|
|
? 'bg-purple-600 text-white'
|
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
|
}`}
|
|
>
|
|
{f.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<PrimaryButton onClick={() => setShowModal(true)} className="flex items-center gap-1.5">
|
|
<IconPlus />
|
|
Neue Aufgabe
|
|
</PrimaryButton>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
{loading ? (
|
|
<div className="space-y-3">
|
|
{Array.from({ length: 4 }).map((_, i) => (
|
|
<Skeleton key={i} className="h-16 rounded-lg" />
|
|
))}
|
|
</div>
|
|
) : error ? (
|
|
<ErrorState message={error} onRetry={fetchTasks} />
|
|
) : tasks.length === 0 ? (
|
|
<EmptyState
|
|
icon={<IconTask className="w-7 h-7" />}
|
|
title="Keine Aufgaben"
|
|
description="Erstellen Sie eine neue Aufgabe um zu beginnen."
|
|
/>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{tasks.map((task) => (
|
|
<div
|
|
key={task.id}
|
|
className="bg-white border border-gray-200 rounded-lg p-4 hover:border-purple-300 transition-colors"
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<h4
|
|
className={`font-medium ${
|
|
task.status === 'completed' ? 'line-through text-gray-400' : 'text-gray-900'
|
|
}`}
|
|
>
|
|
{task.title}
|
|
</h4>
|
|
<Badge
|
|
label={task.category}
|
|
className="bg-purple-50 text-purple-600 border-purple-200"
|
|
/>
|
|
<Badge
|
|
label={PRIORITY_LABELS[task.priority] || task.priority}
|
|
className={PRIORITY_COLORS[task.priority] || 'bg-gray-100 text-gray-500'}
|
|
/>
|
|
<Badge
|
|
label={TASK_STATUS_LABELS[task.status] || task.status}
|
|
className={TASK_STATUS_COLORS[task.status] || 'bg-gray-100 text-gray-500'}
|
|
/>
|
|
</div>
|
|
{task.description && (
|
|
<p className="text-sm text-gray-500 mt-1 line-clamp-2">{task.description}</p>
|
|
)}
|
|
<div className="flex items-center gap-3 mt-2 text-xs text-gray-400">
|
|
{task.due_date && (
|
|
<span className="flex items-center gap-1">
|
|
<IconCalendar className="w-3 h-3" />
|
|
Frist: {formatDate(task.due_date)}
|
|
</span>
|
|
)}
|
|
<span>Erstellt: {formatDate(task.created_at)}</span>
|
|
</div>
|
|
</div>
|
|
{task.status !== 'completed' && task.status !== 'cancelled' && (
|
|
<button
|
|
onClick={() => handleCompleteTask(task.id)}
|
|
title="Aufgabe abschliessen"
|
|
className="p-2 rounded-lg text-green-600 hover:bg-green-50 transition-colors flex-shrink-0"
|
|
>
|
|
<IconCheck className="w-5 h-5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Create task modal */}
|
|
<Modal open={showModal} onClose={() => setShowModal(false)} title="Neue Aufgabe erstellen">
|
|
<form onSubmit={handleCreateTask} className="space-y-4">
|
|
<div>
|
|
<FormLabel htmlFor="task-title">Titel *</FormLabel>
|
|
<FormInput
|
|
id="task-title"
|
|
value={newTitle}
|
|
onChange={setNewTitle}
|
|
placeholder="Aufgabentitel"
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<FormLabel htmlFor="task-desc">Beschreibung</FormLabel>
|
|
<FormTextarea
|
|
id="task-desc"
|
|
value={newDesc}
|
|
onChange={setNewDesc}
|
|
placeholder="Beschreibung der Aufgabe..."
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<FormLabel htmlFor="task-cat">Kategorie</FormLabel>
|
|
<FormSelect
|
|
id="task-cat"
|
|
value={newCategory}
|
|
onChange={setNewCategory}
|
|
options={TASK_CATEGORIES.map((c) => ({ value: c, label: c }))}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<FormLabel htmlFor="task-prio">Prioritaet</FormLabel>
|
|
<FormSelect
|
|
id="task-prio"
|
|
value={newPriority}
|
|
onChange={setNewPriority}
|
|
options={[
|
|
{ value: 'urgent', label: 'Dringend' },
|
|
{ value: 'high', label: 'Hoch' },
|
|
{ value: 'medium', label: 'Mittel' },
|
|
{ value: 'low', label: 'Niedrig' },
|
|
]}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<FormLabel htmlFor="task-due">Faelligkeitsdatum</FormLabel>
|
|
<FormInput id="task-due" type="date" value={newDueDate} onChange={setNewDueDate} />
|
|
</div>
|
|
<div className="flex justify-end gap-3 pt-2">
|
|
<SecondaryButton onClick={() => setShowModal(false)}>Abbrechen</SecondaryButton>
|
|
<PrimaryButton type="submit" disabled={saving || !newTitle.trim()}>
|
|
{saving ? 'Erstelle...' : 'Aufgabe erstellen'}
|
|
</PrimaryButton>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// ZEITERFASSUNG TAB
|
|
// =============================================================================
|
|
|
|
function ZeiterfassungTab({
|
|
assignmentId,
|
|
monthlyBudget,
|
|
addToast,
|
|
}: {
|
|
assignmentId: string
|
|
monthlyBudget: number
|
|
addToast: (msg: string, type?: 'success' | 'error') => void
|
|
}) {
|
|
const [hours, setHours] = useState<HourEntry[]>([])
|
|
const [summary, setSummary] = useState<HoursSummary | null>(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)
|
|
|
|
// Form
|
|
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<HourEntry[]>(
|
|
`/api/sdk/v1/dsb/assignments/${assignmentId}/hours?month=${month}`
|
|
),
|
|
apiFetch<HoursSummary>(
|
|
`/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)
|
|
}
|
|
}
|
|
|
|
// Calculate max category hours for bar sizing
|
|
const maxCatHours = summary
|
|
? Math.max(...Object.values(summary.by_category), 1)
|
|
: 1
|
|
|
|
return (
|
|
<div>
|
|
{/* Toolbar */}
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => setMonth(prevMonth(month))}
|
|
className="p-2 rounded-lg hover:bg-gray-100 text-gray-500 transition-colors"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
|
</svg>
|
|
</button>
|
|
<span className="text-sm font-medium text-gray-700 min-w-[140px] text-center">
|
|
{monthLabel(month)}
|
|
</span>
|
|
<button
|
|
onClick={() => setMonth(nextMonth(month))}
|
|
className="p-2 rounded-lg hover:bg-gray-100 text-gray-500 transition-colors"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<PrimaryButton onClick={() => setShowModal(true)} className="flex items-center gap-1.5">
|
|
<IconPlus />
|
|
Stunden erfassen
|
|
</PrimaryButton>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="space-y-3">
|
|
<Skeleton className="h-24 rounded-lg" />
|
|
<Skeleton className="h-40 rounded-lg" />
|
|
</div>
|
|
) : error ? (
|
|
<ErrorState message={error} onRetry={fetchData} />
|
|
) : (
|
|
<div className="space-y-6">
|
|
{/* Summary cards */}
|
|
{summary && (
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
|
<div className="bg-purple-50 rounded-xl p-4 border border-purple-200">
|
|
<p className="text-xs text-purple-600 font-medium">Gesamt-Stunden</p>
|
|
<p className="text-2xl font-bold text-purple-900 mt-1">{summary.total_hours}h</p>
|
|
<div className="mt-2">
|
|
<HoursBar used={summary.total_hours} budget={monthlyBudget} />
|
|
</div>
|
|
</div>
|
|
<div className="bg-green-50 rounded-xl p-4 border border-green-200">
|
|
<p className="text-xs text-green-600 font-medium">Abrechnungsfaehig</p>
|
|
<p className="text-2xl font-bold text-green-900 mt-1">{summary.billable_hours}h</p>
|
|
<p className="text-xs text-green-500 mt-1">
|
|
{summary.total_hours > 0
|
|
? `${Math.round((summary.billable_hours / summary.total_hours) * 100)}% der Gesamtstunden`
|
|
: 'Keine Stunden erfasst'}
|
|
</p>
|
|
</div>
|
|
<div className="bg-gray-50 rounded-xl p-4 border border-gray-200">
|
|
<p className="text-xs text-gray-500 font-medium">Budget verbleibend</p>
|
|
<p className="text-2xl font-bold text-gray-900 mt-1">
|
|
{Math.max(monthlyBudget - summary.total_hours, 0)}h
|
|
</p>
|
|
<p className="text-xs text-gray-400 mt-1">
|
|
von {monthlyBudget}h Monatsbudget
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Hours by category */}
|
|
{summary && Object.keys(summary.by_category).length > 0 && (
|
|
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
|
<h4 className="text-sm font-semibold text-gray-700 mb-3">
|
|
Stunden nach Kategorie
|
|
</h4>
|
|
<div className="space-y-2.5">
|
|
{Object.entries(summary.by_category)
|
|
.sort(([, a], [, b]) => b - a)
|
|
.map(([cat, h]) => (
|
|
<div key={cat} className="flex items-center gap-3">
|
|
<span className="text-xs text-gray-500 min-w-[120px] truncate">{cat}</span>
|
|
<div className="flex-1 h-5 bg-gray-100 rounded-full overflow-hidden">
|
|
<div
|
|
className="h-full bg-purple-400 rounded-full transition-all"
|
|
style={{ width: `${(h / maxCatHours) * 100}%` }}
|
|
/>
|
|
</div>
|
|
<span className="text-xs font-medium text-gray-700 min-w-[40px] text-right">
|
|
{h}h
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Hours table */}
|
|
{hours.length === 0 ? (
|
|
<EmptyState
|
|
icon={<IconClock className="w-7 h-7" />}
|
|
title="Keine Stunden erfasst"
|
|
description={`Fuer ${monthLabel(month)} wurden noch keine Stunden erfasst.`}
|
|
/>
|
|
) : (
|
|
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="bg-gray-50 border-b border-gray-200">
|
|
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">
|
|
Datum
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">
|
|
Stunden
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">
|
|
Kategorie
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">
|
|
Beschreibung
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">
|
|
Abrechenbar
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-100">
|
|
{hours.map((entry) => (
|
|
<tr key={entry.id} className="hover:bg-gray-50 transition-colors">
|
|
<td className="px-4 py-3 text-gray-700 whitespace-nowrap">
|
|
{formatDate(entry.date)}
|
|
</td>
|
|
<td className="px-4 py-3 font-medium text-gray-900">{entry.hours}h</td>
|
|
<td className="px-4 py-3">
|
|
<Badge
|
|
label={entry.category}
|
|
className="bg-purple-50 text-purple-600 border-purple-200"
|
|
/>
|
|
</td>
|
|
<td className="px-4 py-3 text-gray-600 max-w-xs truncate">
|
|
{entry.description || '-'}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
{entry.billable ? (
|
|
<span className="text-green-600 font-medium">Ja</span>
|
|
) : (
|
|
<span className="text-gray-400">Nein</span>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Log hours modal */}
|
|
<Modal open={showModal} onClose={() => setShowModal(false)} title="Stunden erfassen">
|
|
<form onSubmit={handleLogHours} className="space-y-4">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<FormLabel htmlFor="h-date">Datum *</FormLabel>
|
|
<FormInput id="h-date" type="date" value={formDate} onChange={setFormDate} required />
|
|
</div>
|
|
<div>
|
|
<FormLabel htmlFor="h-hours">Stunden *</FormLabel>
|
|
<FormInput
|
|
id="h-hours"
|
|
type="number"
|
|
value={formHours}
|
|
onChange={setFormHours}
|
|
min={0.25}
|
|
max={24}
|
|
step={0.25}
|
|
required
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<FormLabel htmlFor="h-cat">Kategorie</FormLabel>
|
|
<FormSelect
|
|
id="h-cat"
|
|
value={formCategory}
|
|
onChange={setFormCategory}
|
|
options={HOUR_CATEGORIES.map((c) => ({ value: c, label: c }))}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<FormLabel htmlFor="h-desc">Beschreibung</FormLabel>
|
|
<FormTextarea
|
|
id="h-desc"
|
|
value={formDesc}
|
|
onChange={setFormDesc}
|
|
placeholder="Was wurde gemacht..."
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
id="h-billable"
|
|
type="checkbox"
|
|
checked={formBillable}
|
|
onChange={(e) => setFormBillable(e.target.checked)}
|
|
className="rounded border-gray-300 text-purple-600 focus:ring-purple-500"
|
|
/>
|
|
<label htmlFor="h-billable" className="text-sm text-gray-700">
|
|
Abrechnungsfaehig
|
|
</label>
|
|
</div>
|
|
<div className="flex justify-end gap-3 pt-2">
|
|
<SecondaryButton onClick={() => setShowModal(false)}>Abbrechen</SecondaryButton>
|
|
<PrimaryButton type="submit" disabled={saving}>
|
|
{saving ? 'Erfasse...' : 'Stunden erfassen'}
|
|
</PrimaryButton>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// KOMMUNIKATION TAB
|
|
// =============================================================================
|
|
|
|
function KommunikationTab({
|
|
assignmentId,
|
|
addToast,
|
|
}: {
|
|
assignmentId: string
|
|
addToast: (msg: string, type?: 'success' | 'error') => void
|
|
}) {
|
|
const [comms, setComms] = useState<Communication[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState('')
|
|
const [showModal, setShowModal] = useState(false)
|
|
const [saving, setSaving] = useState(false)
|
|
|
|
// Form
|
|
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<Communication[]>(
|
|
`/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)
|
|
}
|
|
}
|
|
|
|
const channelColors: Record<string, string> = {
|
|
'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',
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
{/* Toolbar */}
|
|
<div className="flex items-center justify-between mb-4">
|
|
<p className="text-sm text-gray-500">Kommunikations-Protokoll</p>
|
|
<PrimaryButton onClick={() => setShowModal(true)} className="flex items-center gap-1.5">
|
|
<IconPlus />
|
|
Kommunikation erfassen
|
|
</PrimaryButton>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="space-y-3">
|
|
{Array.from({ length: 3 }).map((_, i) => (
|
|
<Skeleton key={i} className="h-20 rounded-lg" />
|
|
))}
|
|
</div>
|
|
) : error ? (
|
|
<ErrorState message={error} onRetry={fetchComms} />
|
|
) : comms.length === 0 ? (
|
|
<EmptyState
|
|
icon={<IconMail className="w-7 h-7" />}
|
|
title="Keine Kommunikation"
|
|
description="Erfassen Sie die erste Kommunikation mit dem Mandanten."
|
|
/>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{comms.map((comm) => (
|
|
<div
|
|
key={comm.id}
|
|
className="bg-white border border-gray-200 rounded-xl p-4 hover:border-purple-200 transition-colors"
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
{/* Direction icon */}
|
|
<div
|
|
className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
|
|
comm.direction === 'inbound'
|
|
? 'bg-blue-100 text-blue-600'
|
|
: 'bg-green-100 text-green-600'
|
|
}`}
|
|
>
|
|
{comm.direction === 'inbound' ? (
|
|
<IconInbound className="w-4 h-4" />
|
|
) : (
|
|
<IconOutbound className="w-4 h-4" />
|
|
)}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className="font-medium text-gray-900 text-sm">{comm.subject}</span>
|
|
<Badge
|
|
label={comm.channel}
|
|
className={channelColors[comm.channel] || 'bg-gray-100 text-gray-600 border-gray-200'}
|
|
/>
|
|
<span className="text-xs text-gray-400">
|
|
{comm.direction === 'inbound' ? 'Eingehend' : 'Ausgehend'}
|
|
</span>
|
|
</div>
|
|
{comm.content && (
|
|
<p className="text-sm text-gray-500 mt-1 line-clamp-2">{comm.content}</p>
|
|
)}
|
|
<div className="flex items-center gap-3 mt-2 text-xs text-gray-400">
|
|
<span>{formatDateTime(comm.created_at)}</span>
|
|
{comm.participants && <span>Teilnehmer: {comm.participants}</span>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Create communication modal */}
|
|
<Modal
|
|
open={showModal}
|
|
onClose={() => setShowModal(false)}
|
|
title="Kommunikation erfassen"
|
|
>
|
|
<form onSubmit={handleCreate} className="space-y-4">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<FormLabel htmlFor="comm-dir">Richtung</FormLabel>
|
|
<FormSelect
|
|
id="comm-dir"
|
|
value={formDirection}
|
|
onChange={setFormDirection}
|
|
options={[
|
|
{ value: 'outbound', label: 'Ausgehend' },
|
|
{ value: 'inbound', label: 'Eingehend' },
|
|
]}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<FormLabel htmlFor="comm-ch">Kanal</FormLabel>
|
|
<FormSelect
|
|
id="comm-ch"
|
|
value={formChannel}
|
|
onChange={setFormChannel}
|
|
options={COMM_CHANNELS.map((c) => ({ value: c, label: c }))}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<FormLabel htmlFor="comm-subj">Betreff *</FormLabel>
|
|
<FormInput
|
|
id="comm-subj"
|
|
value={formSubject}
|
|
onChange={setFormSubject}
|
|
placeholder="Betreff der Kommunikation"
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<FormLabel htmlFor="comm-content">Inhalt</FormLabel>
|
|
<FormTextarea
|
|
id="comm-content"
|
|
value={formContent}
|
|
onChange={setFormContent}
|
|
placeholder="Inhalt / Zusammenfassung..."
|
|
rows={4}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<FormLabel htmlFor="comm-parts">Teilnehmer</FormLabel>
|
|
<FormInput
|
|
id="comm-parts"
|
|
value={formParticipants}
|
|
onChange={setFormParticipants}
|
|
placeholder="z.B. Herr Mueller, Frau Schmidt"
|
|
/>
|
|
</div>
|
|
<div className="flex justify-end gap-3 pt-2">
|
|
<SecondaryButton onClick={() => setShowModal(false)}>Abbrechen</SecondaryButton>
|
|
<PrimaryButton type="submit" disabled={saving || !formSubject.trim()}>
|
|
{saving ? 'Speichere...' : 'Kommunikation erfassen'}
|
|
</PrimaryButton>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// EINSTELLUNGEN TAB
|
|
// =============================================================================
|
|
|
|
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 (
|
|
<div className="max-w-2xl space-y-6">
|
|
{/* Status */}
|
|
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
|
<h4 className="text-sm font-semibold text-gray-700 mb-3">Status</h4>
|
|
<div className="flex gap-2">
|
|
{(['active', 'paused', 'terminated'] as const).map((s) => (
|
|
<button
|
|
key={s}
|
|
onClick={() => setStatus(s)}
|
|
className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
|
status === s
|
|
? s === 'active'
|
|
? 'bg-green-100 text-green-700 border-green-300'
|
|
: s === 'paused'
|
|
? 'bg-yellow-100 text-yellow-700 border-yellow-300'
|
|
: 'bg-red-100 text-red-700 border-red-300'
|
|
: 'bg-white text-gray-500 border-gray-200 hover:bg-gray-50'
|
|
}`}
|
|
>
|
|
{ASSIGNMENT_STATUS_LABELS[s]}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Contract period */}
|
|
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
|
<h4 className="text-sm font-semibold text-gray-700 mb-3">Vertragszeitraum</h4>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<FormLabel htmlFor="s-start">Vertragsbeginn</FormLabel>
|
|
<FormInput
|
|
id="s-start"
|
|
type="date"
|
|
value={contractStart}
|
|
onChange={setContractStart}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<FormLabel htmlFor="s-end">Vertragsende</FormLabel>
|
|
<FormInput
|
|
id="s-end"
|
|
type="date"
|
|
value={contractEnd}
|
|
onChange={setContractEnd}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Budget */}
|
|
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
|
<h4 className="text-sm font-semibold text-gray-700 mb-3">Monatliches Stundenbudget</h4>
|
|
<div className="max-w-xs">
|
|
<FormInput
|
|
type="number"
|
|
value={budget}
|
|
onChange={setBudget}
|
|
min={0}
|
|
max={999}
|
|
step={1}
|
|
/>
|
|
<p className="text-xs text-gray-400 mt-1">Stunden pro Monat</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Notes */}
|
|
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
|
<h4 className="text-sm font-semibold text-gray-700 mb-3">Anmerkungen</h4>
|
|
<FormTextarea
|
|
value={notes}
|
|
onChange={setNotes}
|
|
placeholder="Interne Anmerkungen zum Mandat..."
|
|
rows={4}
|
|
/>
|
|
</div>
|
|
|
|
{/* Save */}
|
|
<div className="flex justify-end">
|
|
<PrimaryButton onClick={handleSave} disabled={saving}>
|
|
{saving ? 'Speichere...' : 'Einstellungen speichern'}
|
|
</PrimaryButton>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// DETAIL VIEW
|
|
// =============================================================================
|
|
|
|
type DetailTab = 'aufgaben' | 'zeit' | 'kommunikation' | 'einstellungen'
|
|
|
|
function DetailView({
|
|
assignment,
|
|
onBack,
|
|
onUpdate,
|
|
addToast,
|
|
}: {
|
|
assignment: AssignmentOverview
|
|
onBack: () => void
|
|
onUpdate: () => void
|
|
addToast: (msg: string, type?: 'success' | 'error') => void
|
|
}) {
|
|
const [activeTab, setActiveTab] = useState<DetailTab>('aufgaben')
|
|
|
|
const tabs: { id: DetailTab; label: string; icon: React.ReactNode }[] = [
|
|
{ id: 'aufgaben', label: 'Aufgaben', icon: <IconTask className="w-4 h-4" /> },
|
|
{ id: 'zeit', label: 'Zeiterfassung', icon: <IconClock className="w-4 h-4" /> },
|
|
{ id: 'kommunikation', label: 'Kommunikation', icon: <IconMail className="w-4 h-4" /> },
|
|
{ id: 'einstellungen', label: 'Einstellungen', icon: <IconSettings className="w-4 h-4" /> },
|
|
]
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Back + Header */}
|
|
<div>
|
|
<button
|
|
onClick={onBack}
|
|
className="flex items-center gap-2 text-sm text-purple-600 hover:text-purple-800 font-medium mb-4 transition-colors"
|
|
>
|
|
<IconBack className="w-4 h-4" />
|
|
Zurueck zur Uebersicht
|
|
</button>
|
|
|
|
<div className="bg-white border border-gray-200 rounded-xl p-6">
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
|
<div>
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-lg bg-purple-100 flex items-center justify-center text-purple-600">
|
|
<IconShield className="w-5 h-5" />
|
|
</div>
|
|
<div>
|
|
<h2 className="text-xl font-bold text-gray-900">{assignment.tenant_name}</h2>
|
|
<p className="text-sm text-gray-400 font-mono">{assignment.tenant_slug}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Badge
|
|
label={ASSIGNMENT_STATUS_LABELS[assignment.status] || assignment.status}
|
|
className={ASSIGNMENT_STATUS_COLORS[assignment.status] || 'bg-gray-100 text-gray-600'}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Meta info */}
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-5 pt-5 border-t border-gray-100">
|
|
<div>
|
|
<p className="text-xs text-gray-400">Vertragsbeginn</p>
|
|
<p className="text-sm font-medium text-gray-700">{formatDate(assignment.contract_start)}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-gray-400">Vertragsende</p>
|
|
<p className="text-sm font-medium text-gray-700">
|
|
{assignment.contract_end ? formatDate(assignment.contract_end) : 'Unbefristet'}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-gray-400">Compliance-Score</p>
|
|
<div className="mt-1">
|
|
<ComplianceBar score={assignment.compliance_score} />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-gray-400">Stunden diesen Monat</p>
|
|
<div className="mt-1">
|
|
<HoursBar used={assignment.hours_this_month} budget={assignment.hours_budget} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{assignment.notes && (
|
|
<div className="mt-4 pt-4 border-t border-gray-100">
|
|
<p className="text-xs text-gray-400 mb-1">Anmerkungen</p>
|
|
<p className="text-sm text-gray-600">{assignment.notes}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="border-b border-gray-200">
|
|
<nav className="flex gap-0 -mb-px overflow-x-auto">
|
|
{tabs.map((tab) => (
|
|
<button
|
|
key={tab.id}
|
|
onClick={() => setActiveTab(tab.id)}
|
|
className={`flex items-center gap-2 px-5 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
|
|
activeTab === tab.id
|
|
? 'border-purple-600 text-purple-700'
|
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
|
}`}
|
|
>
|
|
{tab.icon}
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</nav>
|
|
</div>
|
|
|
|
{/* Tab content */}
|
|
<div>
|
|
{activeTab === 'aufgaben' && (
|
|
<AufgabenTab assignmentId={assignment.id} addToast={addToast} />
|
|
)}
|
|
{activeTab === 'zeit' && (
|
|
<ZeiterfassungTab
|
|
assignmentId={assignment.id}
|
|
monthlyBudget={assignment.monthly_hours_budget}
|
|
addToast={addToast}
|
|
/>
|
|
)}
|
|
{activeTab === 'kommunikation' && (
|
|
<KommunikationTab assignmentId={assignment.id} addToast={addToast} />
|
|
)}
|
|
{activeTab === 'einstellungen' && (
|
|
<EinstellungenTab
|
|
assignment={assignment}
|
|
onUpdate={onUpdate}
|
|
addToast={addToast}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// MAIN PAGE
|
|
// =============================================================================
|
|
|
|
export default function DSBPortalPage() {
|
|
const [dashboard, setDashboard] = useState<DSBDashboard | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState('')
|
|
const [selectedAssignment, setSelectedAssignment] = useState<AssignmentOverview | null>(null)
|
|
const { toasts, addToast } = useToast()
|
|
|
|
const fetchDashboard = useCallback(async () => {
|
|
setLoading(true)
|
|
setError('')
|
|
try {
|
|
const data = await apiFetch<DSBDashboard>('/api/sdk/v1/dsb/dashboard')
|
|
setDashboard(data)
|
|
// If we had a selected assignment, update it from fresh data
|
|
if (selectedAssignment) {
|
|
const updated = data.assignments.find((a) => a.id === selectedAssignment.id)
|
|
if (updated) setSelectedAssignment(updated)
|
|
}
|
|
} catch (e: unknown) {
|
|
setError(e instanceof Error ? e.message : 'Fehler beim Laden des Dashboards')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
useEffect(() => {
|
|
fetchDashboard()
|
|
}, [fetchDashboard])
|
|
|
|
const handleSelectAssignment = (assignment: AssignmentOverview) => {
|
|
setSelectedAssignment(assignment)
|
|
}
|
|
|
|
const handleBackToDashboard = () => {
|
|
setSelectedAssignment(null)
|
|
fetchDashboard()
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-50">
|
|
<ToastContainer toasts={toasts} />
|
|
|
|
{/* Global Header */}
|
|
<div className="bg-gradient-to-r from-purple-700 via-violet-600 to-purple-800 text-white">
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<div className="w-12 h-12 rounded-xl bg-white/20 backdrop-blur-sm flex items-center justify-center">
|
|
<IconShield className="w-7 h-7 text-white" />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-2xl font-bold">DSB-Portal</h1>
|
|
<p className="text-purple-200 text-sm">
|
|
Datenschutzbeauftragter Mandanten-Verwaltung
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={fetchDashboard}
|
|
className="p-2 rounded-lg bg-white/10 hover:bg-white/20 transition-colors"
|
|
title="Aktualisieren"
|
|
>
|
|
<IconRefresh className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
|
{loading && !dashboard ? (
|
|
<DashboardSkeleton />
|
|
) : error && !dashboard ? (
|
|
<ErrorState message={error} onRetry={fetchDashboard} />
|
|
) : selectedAssignment ? (
|
|
<DetailView
|
|
assignment={selectedAssignment}
|
|
onBack={handleBackToDashboard}
|
|
onUpdate={fetchDashboard}
|
|
addToast={addToast}
|
|
/>
|
|
) : dashboard ? (
|
|
<div className="space-y-6">
|
|
{/* Stats Row */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
<StatCard
|
|
title="Aktive Mandanten"
|
|
value={dashboard.active_assignments}
|
|
icon={<IconUsers />}
|
|
/>
|
|
<StatCard
|
|
title="Stunden diesen Monat"
|
|
value={`${dashboard.total_hours_this_month}h`}
|
|
icon={<IconClock />}
|
|
/>
|
|
<StatCard
|
|
title="Offene Aufgaben"
|
|
value={dashboard.open_tasks}
|
|
icon={<IconTask />}
|
|
/>
|
|
<StatCard
|
|
title="Dringende Aufgaben"
|
|
value={dashboard.urgent_tasks}
|
|
icon={<IconAlert />}
|
|
accent={dashboard.urgent_tasks > 0}
|
|
/>
|
|
</div>
|
|
|
|
{/* Mandanten Grid */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h2 className="text-lg font-semibold text-gray-900">
|
|
Mandanten ({dashboard.total_assignments})
|
|
</h2>
|
|
</div>
|
|
|
|
{dashboard.assignments.length === 0 ? (
|
|
<EmptyState
|
|
icon={<IconUsers className="w-7 h-7" />}
|
|
title="Keine Mandanten"
|
|
description="Es sind noch keine Mandanten-Zuweisungen vorhanden."
|
|
/>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
|
{dashboard.assignments.map((a) => (
|
|
<MandantCard
|
|
key={a.id}
|
|
assignment={a}
|
|
onClick={() => handleSelectAssignment(a)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
{/* Inline animation styles */}
|
|
<style jsx global>{`
|
|
@keyframes slideIn {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateX(100px);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: translateX(0);
|
|
}
|
|
}
|
|
.animate-slide-in {
|
|
animation: slideIn 0.3s ease-out;
|
|
}
|
|
.line-clamp-2 {
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
`}</style>
|
|
</div>
|
|
)
|
|
}
|