mirror of
https://github.com/complexcaresolutions/dak.c2s.git
synced 2026-03-17 19:33:41 +00:00
feat: make all case detail fields editable with edit-mode toggle
Replace individual field editors (KVNR, ICD) with a unified edit-mode approach using data-driven field configuration. A single "Bearbeiten" button toggles all fields into edit mode with dirty-tracking and split-save (KVNR via dedicated endpoint for all users, remaining fields via admin-only general update endpoint). - Extend Case TypeScript interface with 17 missing backend fields - Add declarative field config (7 sections, 30 fields) in fieldConfig.ts - Add useInlineEdit hook with dirty-tracking and split-save logic - Add EditableField dual-mode component (text/date/boolean/select/textarea) - Refactor CaseDetail to render sections from config - ICD section retains its own endpoint with separate save button Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
97731552c5
commit
19db4c5def
5 changed files with 499 additions and 108 deletions
|
|
@ -1,9 +1,10 @@
|
|||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import {
|
||||
Search, ChevronLeft, ChevronRight, Save, CheckCircle, XCircle,
|
||||
Search, ChevronLeft, ChevronRight, Save, CheckCircle, XCircle, Pencil, X,
|
||||
} from 'lucide-react'
|
||||
import api from '@/services/api'
|
||||
import type { Case, CaseListResponse } from '@/types'
|
||||
import { useAuth } from '@/context/AuthContext'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
|
@ -18,6 +19,9 @@ import {
|
|||
} from '@/components/ui/sheet'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { CASE_SECTIONS } from './cases/fieldConfig'
|
||||
import { useInlineEdit } from './cases/useInlineEdit'
|
||||
import { EditableField } from './cases/EditableField'
|
||||
|
||||
const FALLGRUPPEN_LABELS: Record<string, string> = {
|
||||
onko: 'Onkologie',
|
||||
|
|
@ -111,7 +115,7 @@ export function CasesPage({ pendingIcdOnly = false }: CasesPageProps) {
|
|||
setSheetOpen(true)
|
||||
}
|
||||
|
||||
const handleIcdSaved = (updated: Case) => {
|
||||
const handleCaseSaved = (updated: Case) => {
|
||||
setSelectedCase(updated)
|
||||
// Refresh list
|
||||
setData((prev) =>
|
||||
|
|
@ -282,7 +286,7 @@ export function CasesPage({ pendingIcdOnly = false }: CasesPageProps) {
|
|||
{selectedCase && (
|
||||
<CaseDetail
|
||||
caseData={selectedCase}
|
||||
onIcdSaved={handleIcdSaved}
|
||||
onCaseSaved={handleCaseSaved}
|
||||
/>
|
||||
)}
|
||||
</SheetContent>
|
||||
|
|
@ -305,56 +309,42 @@ function StatusBadges({ c }: { c: Case }) {
|
|||
|
||||
function CaseDetail({
|
||||
caseData,
|
||||
onIcdSaved,
|
||||
onCaseSaved,
|
||||
}: {
|
||||
caseData: Case
|
||||
onIcdSaved: (updated: Case) => void
|
||||
onCaseSaved: (updated: Case) => void
|
||||
}) {
|
||||
const [icdValue, setIcdValue] = useState(caseData.icd || '')
|
||||
const [kvnrValue, setKvnrValue] = useState(caseData.kvnr || '')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [savingKvnr, setSavingKvnr] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState(false)
|
||||
const [kvnrSuccess, setKvnrSuccess] = useState(false)
|
||||
const { isAdmin } = useAuth()
|
||||
const {
|
||||
editing, formValues, saving, error, success,
|
||||
startEditing, cancelEditing, saveAll, setFieldValue, canEditField, isDirty,
|
||||
} = useInlineEdit(caseData, onCaseSaved)
|
||||
|
||||
// ICD has its own endpoint and state
|
||||
const [icdValue, setIcdValue] = useState(caseData.icd || '')
|
||||
const [icdSaving, setIcdSaving] = useState(false)
|
||||
const [icdError, setIcdError] = useState('')
|
||||
const [icdSuccess, setIcdSuccess] = useState(false)
|
||||
|
||||
// Reset on case change
|
||||
useEffect(() => {
|
||||
setIcdValue(caseData.icd || '')
|
||||
setKvnrValue(caseData.kvnr || '')
|
||||
setError('')
|
||||
setSuccess(false)
|
||||
setKvnrSuccess(false)
|
||||
}, [caseData.id, caseData.icd, caseData.kvnr])
|
||||
|
||||
const saveKvnr = async () => {
|
||||
setSavingKvnr(true)
|
||||
setError('')
|
||||
setKvnrSuccess(false)
|
||||
try {
|
||||
const res = await api.put<Case>(`/cases/${caseData.id}/kvnr`, { kvnr: kvnrValue.trim() || null })
|
||||
onIcdSaved(res.data)
|
||||
setKvnrSuccess(true)
|
||||
} catch {
|
||||
setError('Fehler beim Speichern der KVNR.')
|
||||
} finally {
|
||||
setSavingKvnr(false)
|
||||
}
|
||||
}
|
||||
setIcdError('')
|
||||
setIcdSuccess(false)
|
||||
}, [caseData.id, caseData.icd])
|
||||
|
||||
const saveIcd = async () => {
|
||||
if (!icdValue.trim()) return
|
||||
setSaving(true)
|
||||
setError('')
|
||||
setSuccess(false)
|
||||
setIcdSaving(true)
|
||||
setIcdError('')
|
||||
setIcdSuccess(false)
|
||||
try {
|
||||
const res = await api.put<Case>(`/cases/${caseData.id}/icd`, { icd: icdValue.trim() })
|
||||
onIcdSaved(res.data)
|
||||
setSuccess(true)
|
||||
onCaseSaved(res.data)
|
||||
setIcdSuccess(true)
|
||||
} catch {
|
||||
setError('Fehler beim Speichern des ICD-Codes.')
|
||||
setIcdError('Fehler beim Speichern des ICD-Codes.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
setIcdSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -370,102 +360,109 @@ function CaseDetail({
|
|||
</SheetHeader>
|
||||
|
||||
<div className="px-4 space-y-4">
|
||||
{/* Edit toolbar */}
|
||||
<div className="flex items-center gap-2">
|
||||
{!editing ? (
|
||||
<Button size="sm" variant="outline" onClick={startEditing}>
|
||||
<Pencil className="size-4 mr-1" />
|
||||
Bearbeiten
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button size="sm" onClick={saveAll} disabled={saving || !isDirty}>
|
||||
<Save className="size-4 mr-1" />
|
||||
{saving ? 'Speichern...' : 'Speichern'}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={cancelEditing} disabled={saving}>
|
||||
<X className="size-4 mr-1" />
|
||||
Abbrechen
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{success && (
|
||||
<p className="text-sm text-green-600">Änderungen gespeichert.</p>
|
||||
)}
|
||||
|
||||
{/* Status badges */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<StatusBadges c={caseData} />
|
||||
</div>
|
||||
|
||||
{/* Patient info */}
|
||||
{/* Static metadata (always read-only) */}
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<DetailField label="Anrede" value={caseData.anrede} />
|
||||
<DetailField label="Vorname" value={caseData.vorname} />
|
||||
<DetailField label="Nachname" value={caseData.nachname} />
|
||||
<DetailField label="Geburtsdatum" value={caseData.geburtsdatum ? formatDate(caseData.geburtsdatum) : null} />
|
||||
<div className="col-span-2">
|
||||
<label className="text-sm text-muted-foreground">KVNR (Versichertennummer)</label>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<Input
|
||||
value={kvnrValue}
|
||||
onChange={(e) => { setKvnrValue(e.target.value); setKvnrSuccess(false) }}
|
||||
placeholder="z.B. A123456789"
|
||||
className="flex-1 font-mono"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={saveKvnr}
|
||||
disabled={savingKvnr || kvnrValue === (caseData.kvnr || '')}
|
||||
>
|
||||
<Save className="size-4 mr-1" />
|
||||
{savingKvnr ? '...' : 'Speichern'}
|
||||
</Button>
|
||||
<ReadOnlyField label="Fall-ID" value={caseData.fall_id} />
|
||||
<ReadOnlyField label="CRM-Ticket" value={caseData.crm_ticket_id} />
|
||||
<ReadOnlyField label="Datum" value={formatDate(caseData.datum)} />
|
||||
<ReadOnlyField label="KW / Jahr" value={`${caseData.kw} / ${caseData.jahr}`} />
|
||||
</div>
|
||||
|
||||
{/* Editable sections from config */}
|
||||
{CASE_SECTIONS.map((section) => (
|
||||
<div key={section.title} className="border-t pt-3 space-y-3">
|
||||
<h3 className="text-sm font-semibold">{section.title}</h3>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
{section.fields.map((field) => (
|
||||
<div key={field.key} className={field.colSpan === 2 ? 'col-span-2' : ''}>
|
||||
<EditableField
|
||||
field={field}
|
||||
value={editing ? formValues[field.key] : caseData[field.key]}
|
||||
editable={canEditField(field, isAdmin)}
|
||||
onChange={setFieldValue}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{kvnrSuccess && (
|
||||
<p className="text-sm text-green-600 mt-1">KVNR gespeichert.</p>
|
||||
)}
|
||||
</div>
|
||||
<DetailField label="Versicherung" value={caseData.versicherung} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Case details */}
|
||||
<div className="border-t pt-3 grid grid-cols-2 gap-3 text-sm">
|
||||
<DetailField label="Datum" value={formatDate(caseData.datum)} />
|
||||
<DetailField label="KW / Jahr" value={`${caseData.kw} / ${caseData.jahr}`} />
|
||||
<DetailField label="CRM-Ticket" value={caseData.crm_ticket_id} />
|
||||
<DetailField label="Gutachter" value={caseData.gutachter} />
|
||||
<DetailField label="Gutachten-Typ" value={caseData.gutachten_typ} />
|
||||
<DetailField label="Therapieänderung" value={caseData.therapieaenderung} />
|
||||
</div>
|
||||
|
||||
{/* ICD edit */}
|
||||
{/* ICD section — own endpoint with validation */}
|
||||
<div className="border-t pt-3 space-y-2">
|
||||
<label className="text-sm font-medium">ICD-Code</label>
|
||||
<label className="text-sm font-semibold">ICD-Code</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={icdValue}
|
||||
onChange={(e) => { setIcdValue(e.target.value); setSuccess(false) }}
|
||||
onChange={(e) => { setIcdValue(e.target.value); setIcdSuccess(false) }}
|
||||
placeholder="z.B. C50.9"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={saveIcd}
|
||||
disabled={saving || !icdValue.trim()}
|
||||
disabled={icdSaving || !icdValue.trim()}
|
||||
>
|
||||
<Save className="size-4 mr-1" />
|
||||
{saving ? 'Speichern...' : 'Speichern'}
|
||||
{icdSaving ? 'Speichern...' : 'Speichern'}
|
||||
</Button>
|
||||
</div>
|
||||
{error && (
|
||||
{icdError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
<AlertDescription>{icdError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{success && (
|
||||
{icdSuccess && (
|
||||
<p className="text-sm text-green-600">ICD-Code gespeichert.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description fields */}
|
||||
{caseData.kurzbeschreibung && (
|
||||
<div className="border-t pt-3 space-y-1">
|
||||
<label className="text-sm font-medium">Kurzbeschreibung</label>
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{caseData.kurzbeschreibung}</p>
|
||||
</div>
|
||||
)}
|
||||
{caseData.fragestellung && (
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">Fragestellung</label>
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{caseData.fragestellung}</p>
|
||||
</div>
|
||||
)}
|
||||
{caseData.kommentar && (
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">Kommentar</label>
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{caseData.kommentar}</p>
|
||||
{/* Coding info (read-only, managed via coding endpoint) */}
|
||||
{(caseData.gutachten_typ || caseData.therapieaenderung) && (
|
||||
<div className="border-t pt-3 space-y-2">
|
||||
<h3 className="text-sm font-semibold">Coding</h3>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<ReadOnlyField label="Gutachten-Typ" value={caseData.gutachten_typ} />
|
||||
<ReadOnlyField label="Therapieänderung" value={caseData.therapieaenderung} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metadata footer */}
|
||||
<div className="border-t pt-3 text-xs text-muted-foreground space-y-1">
|
||||
<p>Importiert: {formatDateTime(caseData.imported_at)}</p>
|
||||
<p>Aktualisiert: {formatDateTime(caseData.updated_at)}</p>
|
||||
|
|
@ -476,11 +473,11 @@ function CaseDetail({
|
|||
)
|
||||
}
|
||||
|
||||
function DetailField({ label, value }: { label: string; value: string | null | undefined }) {
|
||||
function ReadOnlyField({ label, value }: { label: string; value: string | null | undefined }) {
|
||||
return (
|
||||
<div>
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<p className="font-medium">{value || '-'}</p>
|
||||
<span className="text-muted-foreground text-sm">{label}</span>
|
||||
<p className="font-medium text-sm">{value || '–'}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
114
frontend/src/pages/cases/EditableField.tsx
Normal file
114
frontend/src/pages/cases/EditableField.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import type { FieldConfig } from './fieldConfig'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
|
||||
interface EditableFieldProps {
|
||||
field: FieldConfig
|
||||
value: unknown
|
||||
editable: boolean
|
||||
onChange: (key: string, value: unknown) => void
|
||||
}
|
||||
|
||||
function formatDisplayDate(val: string | null | undefined): string {
|
||||
if (!val) return '–'
|
||||
try {
|
||||
return new Date(val).toLocaleDateString('de-DE', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
})
|
||||
} catch {
|
||||
return val
|
||||
}
|
||||
}
|
||||
|
||||
function formatDisplayBoolean(val: unknown): string {
|
||||
if (val === true) return 'Ja'
|
||||
if (val === false) return 'Nein'
|
||||
return '–'
|
||||
}
|
||||
|
||||
function formatDisplayValue(field: FieldConfig, value: unknown): string {
|
||||
if (field.type === 'date') return formatDisplayDate(value as string | null)
|
||||
if (field.type === 'boolean') return formatDisplayBoolean(value)
|
||||
if (field.type === 'select' && field.options) {
|
||||
const opt = field.options.find((o) => o.value === (value ?? ''))
|
||||
return opt?.label || (value as string) || '–'
|
||||
}
|
||||
return (value as string) || '–'
|
||||
}
|
||||
|
||||
export function EditableField({ field, value, editable, onChange }: EditableFieldProps) {
|
||||
if (!editable) {
|
||||
return (
|
||||
<div>
|
||||
<span className="text-muted-foreground text-sm">{field.label}</span>
|
||||
<p className="font-medium text-sm whitespace-pre-wrap">
|
||||
{formatDisplayValue(field, value)}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const handleChange = (newVal: unknown) => onChange(field.key, newVal)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="text-sm text-muted-foreground">{field.label}</label>
|
||||
<div className="mt-1">
|
||||
{field.type === 'text' && (
|
||||
<Input
|
||||
value={(value as string) ?? ''}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
)}
|
||||
{field.type === 'textarea' && (
|
||||
<textarea
|
||||
value={(value as string) ?? ''}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
rows={3}
|
||||
className="flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
)}
|
||||
{field.type === 'date' && (
|
||||
<Input
|
||||
type="date"
|
||||
value={(value as string) ?? ''}
|
||||
onChange={(e) => handleChange(e.target.value || null)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
)}
|
||||
{field.type === 'boolean' && (
|
||||
<div className="flex items-center gap-2 h-8">
|
||||
<Switch
|
||||
checked={value === true}
|
||||
onCheckedChange={(checked) => handleChange(checked)}
|
||||
/>
|
||||
<span className="text-sm">{value ? 'Ja' : 'Nein'}</span>
|
||||
</div>
|
||||
)}
|
||||
{field.type === 'select' && field.options && (
|
||||
<Select
|
||||
value={(value as string) ?? ''}
|
||||
onValueChange={(v) => handleChange(v || null)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue placeholder="Auswählen..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{field.options.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
95
frontend/src/pages/cases/fieldConfig.ts
Normal file
95
frontend/src/pages/cases/fieldConfig.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import type { Case } from '@/types'
|
||||
|
||||
export type FieldType = 'text' | 'date' | 'boolean' | 'select' | 'textarea'
|
||||
|
||||
export interface FieldConfig {
|
||||
key: keyof Case
|
||||
label: string
|
||||
type: FieldType
|
||||
colSpan?: 1 | 2
|
||||
editableBy: 'admin' | 'all'
|
||||
placeholder?: string
|
||||
options?: { value: string; label: string }[]
|
||||
}
|
||||
|
||||
export interface SectionConfig {
|
||||
title: string
|
||||
fields: FieldConfig[]
|
||||
}
|
||||
|
||||
const ANREDE_OPTIONS = [
|
||||
{ value: '', label: '–' },
|
||||
{ value: 'Herr', label: 'Herr' },
|
||||
{ value: 'Frau', label: 'Frau' },
|
||||
{ value: 'Divers', label: 'Divers' },
|
||||
]
|
||||
|
||||
export const CASE_SECTIONS: SectionConfig[] = [
|
||||
{
|
||||
title: 'Persönliche Daten',
|
||||
fields: [
|
||||
{ key: 'anrede', label: 'Anrede', type: 'select', editableBy: 'admin', options: ANREDE_OPTIONS },
|
||||
{ key: 'vorname', label: 'Vorname', type: 'text', editableBy: 'admin', placeholder: 'Vorname' },
|
||||
{ key: 'nachname', label: 'Nachname', type: 'text', editableBy: 'admin', placeholder: 'Nachname' },
|
||||
{ key: 'geburtsdatum', label: 'Geburtsdatum', type: 'date', editableBy: 'admin' },
|
||||
{ key: 'kvnr', label: 'KVNR', type: 'text', editableBy: 'all', placeholder: 'z.B. A123456789' },
|
||||
{ key: 'versicherung', label: 'Versicherung', type: 'text', editableBy: 'admin', placeholder: 'z.B. DAK' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Kontakt',
|
||||
fields: [
|
||||
{ key: 'strasse', label: 'Straße', type: 'text', editableBy: 'admin', placeholder: 'Straße + Nr.' },
|
||||
{ key: 'plz', label: 'PLZ', type: 'text', editableBy: 'admin', placeholder: 'PLZ' },
|
||||
{ key: 'ort', label: 'Ort', type: 'text', editableBy: 'admin', placeholder: 'Ort' },
|
||||
{ key: 'email', label: 'E-Mail', type: 'text', editableBy: 'admin', placeholder: 'E-Mail' },
|
||||
{ key: 'telefonnummer', label: 'Telefon', type: 'text', editableBy: 'admin', placeholder: 'Telefonnummer' },
|
||||
{ key: 'mobiltelefon', label: 'Mobil', type: 'text', editableBy: 'admin', placeholder: 'Mobilnummer' },
|
||||
{ key: 'ansprechpartner', label: 'Ansprechpartner', type: 'text', editableBy: 'admin', colSpan: 2, placeholder: 'Ansprechpartner' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Falldetails',
|
||||
fields: [
|
||||
{ key: 'kurzbeschreibung', label: 'Kurzbeschreibung', type: 'textarea', editableBy: 'admin', colSpan: 2 },
|
||||
{ key: 'fragestellung', label: 'Fragestellung', type: 'textarea', editableBy: 'admin', colSpan: 2 },
|
||||
{ key: 'kommentar', label: 'Kommentar', type: 'textarea', editableBy: 'admin', colSpan: 2 },
|
||||
{ key: 'sonstiges', label: 'Sonstiges', type: 'textarea', editableBy: 'admin', colSpan: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Unterlagen',
|
||||
fields: [
|
||||
{ key: 'unterlagen', label: 'Unterlagen vorhanden', type: 'boolean', editableBy: 'admin' },
|
||||
{ key: 'erhalten', label: 'Erhalten', type: 'boolean', editableBy: 'admin' },
|
||||
{ key: 'unterlagen_verschickt', label: 'Verschickt am', type: 'date', editableBy: 'admin' },
|
||||
{ key: 'unterlagen_erhalten', label: 'Erhalten am', type: 'date', editableBy: 'admin' },
|
||||
{ key: 'unterlagen_an_gutachter', label: 'An Gutachter am', type: 'date', editableBy: 'admin' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Gutachten',
|
||||
fields: [
|
||||
{ key: 'gutachten', label: 'Gutachten vorhanden', type: 'boolean', editableBy: 'admin' },
|
||||
{ key: 'gutachter', label: 'Gutachter', type: 'text', editableBy: 'admin', placeholder: 'Name des Gutachters' },
|
||||
{ key: 'gutachten_erstellt', label: 'Erstellt am', type: 'date', editableBy: 'admin' },
|
||||
{ key: 'gutachten_versendet', label: 'Versendet am', type: 'date', editableBy: 'admin' },
|
||||
{ key: 'schweigepflicht', label: 'Schweigepflichtentbindung', type: 'boolean', editableBy: 'admin' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
fields: [
|
||||
{ key: 'ablehnung', label: 'Ablehnung', type: 'boolean', editableBy: 'admin' },
|
||||
{ key: 'abbruch', label: 'Abbruch', type: 'boolean', editableBy: 'admin' },
|
||||
{ key: 'abbruch_datum', label: 'Abbruch-Datum', type: 'date', editableBy: 'admin' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Abrechnung',
|
||||
fields: [
|
||||
{ key: 'abgerechnet', label: 'Abgerechnet', type: 'boolean', editableBy: 'admin' },
|
||||
{ key: 'abrechnung_datum', label: 'Abrechnungsdatum', type: 'date', editableBy: 'admin' },
|
||||
],
|
||||
},
|
||||
]
|
||||
165
frontend/src/pages/cases/useInlineEdit.ts
Normal file
165
frontend/src/pages/cases/useInlineEdit.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import { useState, useCallback, useEffect } from 'react'
|
||||
import api from '@/services/api'
|
||||
import type { Case } from '@/types'
|
||||
import { CASE_SECTIONS, type FieldConfig } from './fieldConfig'
|
||||
|
||||
type FormValues = Record<string, unknown>
|
||||
|
||||
interface UseInlineEditReturn {
|
||||
editing: boolean
|
||||
formValues: FormValues
|
||||
saving: boolean
|
||||
error: string
|
||||
success: boolean
|
||||
startEditing: () => void
|
||||
cancelEditing: () => void
|
||||
saveAll: () => Promise<Case | null>
|
||||
setFieldValue: (key: string, value: unknown) => void
|
||||
canEditField: (field: FieldConfig, isAdmin: boolean) => boolean
|
||||
isDirty: boolean
|
||||
}
|
||||
|
||||
function extractFormValues(caseData: Case): FormValues {
|
||||
const values: FormValues = {}
|
||||
for (const section of CASE_SECTIONS) {
|
||||
for (const field of section.fields) {
|
||||
values[field.key] = caseData[field.key]
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
function getDirtyFields(original: FormValues, current: FormValues): FormValues {
|
||||
const dirty: FormValues = {}
|
||||
for (const key of Object.keys(current)) {
|
||||
const orig = original[key] ?? null
|
||||
const curr = current[key] ?? null
|
||||
if (orig !== curr) {
|
||||
dirty[key] = curr
|
||||
}
|
||||
}
|
||||
return dirty
|
||||
}
|
||||
|
||||
export function useInlineEdit(
|
||||
caseData: Case,
|
||||
onSaved: (updated: Case) => void,
|
||||
): UseInlineEditReturn {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [formValues, setFormValues] = useState<FormValues>(() => extractFormValues(caseData))
|
||||
const [originalValues, setOriginalValues] = useState<FormValues>(() => extractFormValues(caseData))
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
// Reset when case changes
|
||||
useEffect(() => {
|
||||
const vals = extractFormValues(caseData)
|
||||
setFormValues(vals)
|
||||
setOriginalValues(vals)
|
||||
setEditing(false)
|
||||
setError('')
|
||||
setSuccess(false)
|
||||
}, [caseData.id, caseData.updated_at])
|
||||
|
||||
const startEditing = useCallback(() => {
|
||||
setFormValues(extractFormValues(caseData))
|
||||
setOriginalValues(extractFormValues(caseData))
|
||||
setEditing(true)
|
||||
setError('')
|
||||
setSuccess(false)
|
||||
}, [caseData])
|
||||
|
||||
const cancelEditing = useCallback(() => {
|
||||
setFormValues(extractFormValues(caseData))
|
||||
setEditing(false)
|
||||
setError('')
|
||||
setSuccess(false)
|
||||
}, [caseData])
|
||||
|
||||
const setFieldValue = useCallback((key: string, value: unknown) => {
|
||||
setFormValues((prev) => ({ ...prev, [key]: value }))
|
||||
setSuccess(false)
|
||||
}, [])
|
||||
|
||||
const isDirty = Object.keys(getDirtyFields(originalValues, formValues)).length > 0
|
||||
|
||||
const saveAll = useCallback(async (): Promise<Case | null> => {
|
||||
const dirty = getDirtyFields(originalValues, formValues)
|
||||
if (Object.keys(dirty).length === 0) {
|
||||
setEditing(false)
|
||||
return null
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
setError('')
|
||||
setSuccess(false)
|
||||
|
||||
try {
|
||||
let lastResult: Case | null = null
|
||||
|
||||
// Split: KVNR goes to its own endpoint (accessible by all users)
|
||||
if ('kvnr' in dirty) {
|
||||
const kvnrVal = dirty.kvnr as string | null
|
||||
const res = await api.put<Case>(`/cases/${caseData.id}/kvnr`, {
|
||||
kvnr: kvnrVal?.trim() || null,
|
||||
})
|
||||
lastResult = res.data
|
||||
delete dirty.kvnr
|
||||
}
|
||||
|
||||
// Remaining fields go to the admin-only general update endpoint
|
||||
if (Object.keys(dirty).length > 0) {
|
||||
// Convert empty strings to null for optional text fields
|
||||
const payload: FormValues = {}
|
||||
for (const [key, value] of Object.entries(dirty)) {
|
||||
if (typeof value === 'string' && value.trim() === '') {
|
||||
payload[key] = null
|
||||
} else {
|
||||
payload[key] = value
|
||||
}
|
||||
}
|
||||
const res = await api.put<Case>(`/cases/${caseData.id}`, payload)
|
||||
lastResult = res.data
|
||||
}
|
||||
|
||||
if (lastResult) {
|
||||
onSaved(lastResult)
|
||||
setSuccess(true)
|
||||
setEditing(false)
|
||||
}
|
||||
|
||||
return lastResult
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
err instanceof Error ? err.message : 'Fehler beim Speichern.'
|
||||
setError(msg)
|
||||
return null
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [caseData.id, formValues, originalValues, onSaved])
|
||||
|
||||
const canEditField = useCallback(
|
||||
(field: FieldConfig, isAdmin: boolean): boolean => {
|
||||
if (!editing) return false
|
||||
if (field.editableBy === 'all') return true
|
||||
return isAdmin
|
||||
},
|
||||
[editing],
|
||||
)
|
||||
|
||||
return {
|
||||
editing,
|
||||
formValues,
|
||||
saving,
|
||||
error,
|
||||
success,
|
||||
startEditing,
|
||||
cancelEditing,
|
||||
saveAll,
|
||||
setFieldValue,
|
||||
canEditField,
|
||||
isDirty,
|
||||
}
|
||||
}
|
||||
|
|
@ -44,17 +44,37 @@ export interface Case {
|
|||
versicherung: string
|
||||
icd: string | null
|
||||
fallgruppe: string
|
||||
strasse: string | null
|
||||
plz: string | null
|
||||
ort: string | null
|
||||
email: string | null
|
||||
ansprechpartner: string | null
|
||||
telefonnummer: string | null
|
||||
mobiltelefon: string | null
|
||||
unterlagen: boolean
|
||||
unterlagen_verschickt: string | null
|
||||
erhalten: boolean | null
|
||||
unterlagen_erhalten: string | null
|
||||
unterlagen_an_gutachter: string | null
|
||||
gutachten: boolean
|
||||
gutachter: string | null
|
||||
gutachten_typ: string | null
|
||||
therapieaenderung: string | null
|
||||
gutachten_erstellt: string | null
|
||||
gutachten_versendet: string | null
|
||||
schweigepflicht: boolean
|
||||
ablehnung: boolean
|
||||
abbruch: boolean
|
||||
abbruch_datum: string | null
|
||||
gutachten_typ: string | null
|
||||
therapieaenderung: string | null
|
||||
ta_diagnosekorrektur: boolean
|
||||
ta_unterversorgung: boolean
|
||||
ta_uebertherapie: boolean
|
||||
kurzbeschreibung: string | null
|
||||
fragestellung: string | null
|
||||
kommentar: string | null
|
||||
sonstiges: string | null
|
||||
abgerechnet: boolean
|
||||
abrechnung_datum: string | null
|
||||
import_source: string | null
|
||||
imported_at: string
|
||||
updated_at: string
|
||||
|
|
|
|||
Loading…
Reference in a new issue