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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<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',
|
||||
}
|
||||
|
||||
export 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)
|
||||
|
||||
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) }
|
||||
}
|
||||
|
||||
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">
|
||||
<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={CHANNEL_COLORS[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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user