Fachlogik in lib/cases/service.ts, Zugriffsprüfung in lib/access/. Jede Funktion prüft selbst und schreibt ihren Protokolleintrag in derselben Transaktion wie die Änderung - die Server Actions daneben sind dünn. Schreibgrenzen nach docs/03: niemand legt eine Akte über der eigenen Stufe an, niemand stuft über die eigene Stufe hoch, archiviert und versiegelt sind schreibgeschützt auch für Admins. Eine Herabstufung verlangt eine Begründung und landet als sichtbarer Systemeintrag im Verlauf, nicht nur im Protokoll. Aktenzeichen über CaseCounter mit INSERT ... ON CONFLICT DO UPDATE, geprüft mit acht gleichzeitigen Anlagen ohne Doppelung. Wer eine Akte anlegt, wird ihr Ermittler, sonst verliert er sie aus dem Blick, sobald jemand hochstuft. Fallliste cursorbasiert auf (updatedAt, id), Sichtbarkeit und Trefferzahl in derselben Abfrage. Aktenansicht mit Kopfdaten sofort und Verlauf in eigener Suspense-Grenze. Vermerke optimistisch mit clientseitiger UUIDv7, die zugleich Idempotenzschlüssel ist - zweimal gesendet bleibt ein Eintrag. Lesezugriffe ab Stufe 3 werden protokolliert, gepuffert und alle zwei Sekunden gebündelt geschrieben, damit das Protokoll die Antwort nicht ausbremst. Die vier Startvorlagen stehen jetzt im Seed statt in Phase 3: ohne Vorlage lässt sich keine Akte anlegen.
104 lines
3.4 KiB
TypeScript
104 lines
3.4 KiB
TypeScript
'use client'
|
|
|
|
import { useOptimistic, useRef, useState, useTransition } from 'react'
|
|
import { uuidv7 } from 'uuidv7'
|
|
import { de } from '@/lib/i18n/de'
|
|
import { addEntryAction } from '../actions'
|
|
|
|
type Pending = { id: string; body: string; failed: boolean }
|
|
|
|
/**
|
|
* Vermerk anlegen, optimistisch.
|
|
*
|
|
* Die clientseitig erzeugte UUIDv7 ist zugleich Idempotenzschlüssel: ein
|
|
* Wiederholungsversuch nach einem Netzfehler erzeugt keinen zweiten Eintrag.
|
|
* Siehe docs/09-performance-und-ux.md.
|
|
*
|
|
* Nach dem Absenden springt der Fokus sofort zurück ins leere Feld, Enter
|
|
* sendet, Shift+Enter macht einen Zeilenumbruch.
|
|
*/
|
|
export function EntryComposer({ caseNumber }: { caseNumber: string }) {
|
|
const [draft, setDraft] = useState('')
|
|
const [failures, setFailures] = useState<Pending[]>([])
|
|
const [optimistic, addOptimistic] = useOptimistic<Pending[], Pending>(
|
|
[],
|
|
(current, entry) => [entry, ...current],
|
|
)
|
|
const [pending, startTransition] = useTransition()
|
|
const field = useRef<HTMLTextAreaElement>(null)
|
|
|
|
function submit(text: string) {
|
|
const body = text.trim()
|
|
if (!body) return
|
|
|
|
const id = uuidv7()
|
|
setDraft('')
|
|
field.current?.focus()
|
|
|
|
startTransition(async () => {
|
|
addOptimistic({ id, body, failed: false })
|
|
const result = await addEntryAction({ id, caseNumber, body, minClearance: 0 })
|
|
if (!result.ok) {
|
|
// Der Eintrag bleibt stehen und ist als nicht gespeichert markiert.
|
|
setFailures((current) => [{ id, body, failed: true }, ...current])
|
|
}
|
|
})
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<form
|
|
action={() => submit(draft)}
|
|
className="max-w-(--container-measure)"
|
|
>
|
|
<label htmlFor="vermerk" className="font-condensed text-xs text-text-dim">
|
|
{de.case.newEntry}
|
|
</label>
|
|
<textarea
|
|
id="vermerk"
|
|
ref={field}
|
|
value={draft}
|
|
onChange={(event) => setDraft(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter' && !event.shiftKey) {
|
|
event.preventDefault()
|
|
submit(draft)
|
|
}
|
|
}}
|
|
rows={3}
|
|
placeholder={de.case.entryPlaceholder}
|
|
className="mt-1.5 block w-full border border-line bg-raised px-3 py-2 text-sm"
|
|
/>
|
|
<button
|
|
type="submit"
|
|
disabled={pending || draft.trim().length === 0}
|
|
className="mt-2 border border-line bg-raised px-3 py-1.5 font-condensed text-xs text-accent disabled:text-text-dim"
|
|
>
|
|
{pending ? de.case.saving : de.entry.create}
|
|
</button>
|
|
</form>
|
|
|
|
{optimistic.length > 0 || failures.length > 0 ? (
|
|
<ol className="mt-6 timeline">
|
|
{[...failures, ...optimistic].map((entry) => (
|
|
<li key={entry.id} className={entry.failed ? 'pb-4' : 'pb-4 opacity-60'}>
|
|
<p
|
|
className={
|
|
entry.failed
|
|
? 'max-w-(--container-measure) border-l-(length:--spacing-edge) border-stamp-4 pl-3 text-sm whitespace-pre-wrap'
|
|
: 'max-w-(--container-measure) text-sm whitespace-pre-wrap'
|
|
}
|
|
>
|
|
{entry.body}
|
|
</p>
|
|
{entry.failed ? (
|
|
<p className="mt-1 font-condensed text-2xs text-stamp-4">{de.case.unsaved}</p>
|
|
) : null}
|
|
</li>
|
|
))}
|
|
</ol>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|