teg/frontend/src/pages/operations/CreateOperationModal.tsx
2026-05-19 18:07:22 +02:00

727 lines
26 KiB
TypeScript

// frontend\src\pages\operations\CreateOperationModal.tsx
import { useEffect, useState, type FormEvent, type MouseEvent } from 'react'
import { DialogTitle } from '@headlessui/react'
import { XMarkIcon } from '@heroicons/react/24/outline'
import {
CheckIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from '@heroicons/react/20/solid'
import Modal from '../../components/Modal'
import Button from '../../components/Button'
import AddressCombobox from '../../components/AddressCombobox'
import { formatOperationNumberInput, isCompleteOperationNumber } from '../../components/formatter'
type CreateOperationModalProps = {
open: boolean
setOpen: (open: boolean) => void
isSaving: boolean
error: string | null
onSubmit: (event: FormEvent<HTMLFormElement>) => void
}
type OperationFormValues = {
operationNumber: string
operationName: string
offense: string
caseWorker: string
targetObject: string
kwAddress: string
operationLeader: string
operationTeam: string
legend: string
camera: string
router: string
technology: string
switchbox: string
hardDrive: string
laptop: string
remark: string
}
type SetupStepId =
| 'masterData'
| 'addresses'
| 'responsibilities'
| 'notes'
| 'equipment'
| 'review'
const inputClassName =
'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500'
const textareaClassName =
'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500'
const initialFormValues: OperationFormValues = {
operationNumber: '',
operationName: '',
offense: '',
caseWorker: '',
targetObject: '',
kwAddress: '',
operationLeader: '',
operationTeam: '',
legend: '',
camera: '',
router: '',
technology: '',
switchbox: '',
hardDrive: '',
laptop: '',
remark: '',
}
const setupSteps: Array<{
id: SetupStepId
title: string
description: string
optional?: boolean
}> = [
{
id: 'masterData',
title: 'Stammdaten',
description: 'Einsatznummer, Name und Delikt.',
},
{
id: 'addresses',
title: 'Adressen',
description: 'KW-Adresse und Zielobjekt mit Kartenvorschau.',
optional: true,
},
{
id: 'responsibilities',
title: 'Zuständigkeiten',
description: 'Sachbearbeitung, Einsatzleitung und Einsatzteam.',
optional: true,
},
{
id: 'notes',
title: 'Zusatzinfos',
description: 'Legende und Bemerkung.',
optional: true,
},
{
id: 'equipment',
title: 'Technik',
description: 'Kamera, Router, Switchbox und Laptop.',
optional: true,
},
{
id: 'review',
title: 'Prüfen',
description: 'Daten prüfen und Einsatz speichern.',
},
]
function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(' ')
}
function getStepIndex(stepId: SetupStepId) {
return setupSteps.findIndex((step) => step.id === stepId)
}
function getDisplayValue(value: string) {
return value.trim() || '—'
}
function ReviewItem({
label,
value,
}: {
label: string
value: string
}) {
return (
<div className="min-w-0">
<dt className="text-xs font-medium text-gray-500 dark:text-gray-400">
{label}
</dt>
<dd className="mt-1 truncate text-sm text-gray-900 dark:text-white">
{getDisplayValue(value)}
</dd>
</div>
)
}
function StepHeader({
title,
description,
}: {
title: string
description: string
}) {
return (
<div>
<h3 className="text-base font-semibold text-gray-900 dark:text-white">
{title}
</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{description}
</p>
</div>
)
}
export default function CreateOperationModal({
open,
setOpen,
isSaving,
error,
onSubmit,
}: CreateOperationModalProps) {
const [activeStep, setActiveStep] = useState<SetupStepId>('masterData')
const [formValues, setFormValues] = useState<OperationFormValues>(initialFormValues)
const [localError, setLocalError] = useState<string | null>(null)
const activeStepIndex = getStepIndex(activeStep)
const isFirstStep = activeStepIndex === 0
const isLastStep = activeStepIndex === setupSteps.length - 1
useEffect(() => {
if (!open) {
return
}
setActiveStep('masterData')
setFormValues(initialFormValues)
setLocalError(null)
}, [open])
function handleOpenChange(nextOpen: boolean) {
if (!nextOpen && isSaving) {
return
}
setOpen(nextOpen)
}
function updateField(field: keyof OperationFormValues, value: string) {
const nextValue =
field === 'operationNumber'
? formatOperationNumberInput(value)
: value
setFormValues((currentValues) => ({
...currentValues,
[field]: nextValue,
}))
if (localError) {
setLocalError(null)
}
}
function goToStep(stepId: SetupStepId) {
setActiveStep(stepId)
}
function goToPreviousStep() {
if (isFirstStep) {
return
}
setActiveStep(setupSteps[activeStepIndex - 1].id)
}
function goToNextStep(event?: MouseEvent<HTMLButtonElement>) {
event?.preventDefault()
event?.stopPropagation()
if (isLastStep) {
return
}
if (activeStep === 'masterData') {
if (formValues.operationNumber.trim() === '') {
setLocalError('Bitte zuerst eine Einsatznummer eintragen.')
return
}
if (!isCompleteOperationNumber(formValues.operationNumber)) {
setLocalError('Die Einsatznummer muss das Format 000-00-0000 haben.')
return
}
}
setActiveStep(setupSteps[activeStepIndex + 1].id)
}
function skipStep() {
if (isLastStep) {
return
}
setActiveStep(setupSteps[activeStepIndex + 1].id)
}
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (activeStep !== 'review') {
goToNextStep()
return
}
if (formValues.operationNumber.trim() === '') {
setLocalError('Einsatznummer ist erforderlich.')
setActiveStep('masterData')
return
}
if (!isCompleteOperationNumber(formValues.operationNumber)) {
setLocalError('Die Einsatznummer muss das Format 000-00-0000 haben.')
setActiveStep('masterData')
return
}
onSubmit(event)
}
const combinedError = localError || error
return (
<Modal
open={open}
setOpen={handleOpenChange}
zIndexClassName="z-[9999]"
panelClassName="max-h-[calc(100dvh-2rem)] max-w-4xl bg-white dark:bg-gray-900"
>
<form
onSubmit={handleSubmit}
className="flex h-[calc(100dvh-2rem)] max-h-[calc(100dvh-2rem)] flex-col"
>
<input type="hidden" name="operationNumber" value={formValues.operationNumber} />
<input type="hidden" name="operationName" value={formValues.operationName} />
<input type="hidden" name="offense" value={formValues.offense} />
<input type="hidden" name="caseWorker" value={formValues.caseWorker} />
<input type="hidden" name="targetObject" value={formValues.targetObject} />
<input type="hidden" name="kwAddress" value={formValues.kwAddress} />
<input type="hidden" name="operationLeader" value={formValues.operationLeader} />
<input type="hidden" name="operationTeam" value={formValues.operationTeam} />
<input type="hidden" name="legend" value={formValues.legend} />
<input type="hidden" name="camera" value={formValues.camera} />
<input type="hidden" name="router" value={formValues.router} />
<input type="hidden" name="technology" value={formValues.technology} />
<input type="hidden" name="switchbox" value={formValues.switchbox} />
<input type="hidden" name="hardDrive" value={formValues.hardDrive} />
<input type="hidden" name="laptop" value={formValues.laptop} />
<input type="hidden" name="remark" value={formValues.remark} />
<div className="flex items-start justify-between border-b border-gray-200 bg-gray-100 px-4 py-4 sm:px-6 dark:border-white/10 dark:bg-gray-800">
<div>
<DialogTitle className="text-base font-semibold text-gray-900 dark:text-white">
Einsatz anlegen
</DialogTitle>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Folge den Schritten oder überspringe optionale Bereiche.
</p>
</div>
<Button
type="button"
variant="secondary"
color="gray"
size="sm"
onClick={() => handleOpenChange(false)}
disabled={isSaving}
>
<span className="sr-only">Schließen</span>
<XMarkIcon aria-hidden="true" className="size-5" />
</Button>
</div>
<div className="border-b border-gray-200 px-4 py-4 sm:px-6 dark:border-white/10">
<nav aria-label="Einrichtungsschritte" className="grid grid-cols-2 gap-2 md:grid-cols-6">
{setupSteps.map((step, index) => {
const isActive = step.id === activeStep
const isDone = index < activeStepIndex
return (
<button
key={step.id}
type="button"
onClick={() => goToStep(step.id)}
className={classNames(
'rounded-lg border px-3 py-2 text-left transition',
isActive
? 'border-indigo-600 bg-indigo-50 text-indigo-700 dark:border-indigo-400 dark:bg-indigo-500/10 dark:text-indigo-300'
: isDone
? 'border-green-200 bg-green-50 text-green-700 dark:border-green-500/20 dark:bg-green-500/10 dark:text-green-300'
: 'border-gray-200 bg-white text-gray-600 hover:bg-gray-50 dark:border-white/10 dark:bg-white/5 dark:text-gray-300 dark:hover:bg-white/10',
)}
>
<span className="block text-[11px] font-medium">
Schritt {index + 1}
</span>
<span className="mt-0.5 block truncate text-sm font-semibold">
{step.title}
</span>
</button>
)
})}
</nav>
</div>
{combinedError && (
<div className="mx-4 mt-4 rounded-md bg-red-50 p-4 text-sm text-red-700 sm:mx-6 dark:bg-red-900/30 dark:text-red-300">
{combinedError}
</div>
)}
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-6 sm:px-6">
{activeStep === 'masterData' && (
<div className="space-y-6">
<StepHeader
title="Stammdaten"
description="Die Einsatznummer ist erforderlich. Alles Weitere kannst du direkt oder später ergänzen."
/>
<div className="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-6">
<div className="sm:col-span-3">
<label htmlFor="createOperationNumber" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Einsatznummer *
</label>
<div className="mt-2">
<input
id="createOperationNumber"
type="text"
inputMode="numeric"
maxLength={11}
placeholder="000-00-0000"
value={formValues.operationNumber}
onChange={(event) => updateField('operationNumber', event.target.value)}
className={inputClassName}
autoFocus
/>
</div>
</div>
<div className="sm:col-span-3">
<label htmlFor="createOperationName" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Einsatzname
</label>
<div className="mt-2">
<input
id="createOperationName"
type="text"
value={formValues.operationName}
onChange={(event) => updateField('operationName', event.target.value)}
className={inputClassName}
/>
</div>
</div>
<div className="sm:col-span-3">
<label htmlFor="createOffense" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Delikt
</label>
<div className="mt-2">
<input
id="createOffense"
type="text"
value={formValues.offense}
onChange={(event) => updateField('offense', event.target.value)}
className={inputClassName}
/>
</div>
</div>
</div>
</div>
)}
{activeStep === 'addresses' && (
<div className="space-y-6">
<StepHeader
title="Adressen"
description="KW und Zielobjekt werden mit AddressCombobox und Karten-Vorschau erfasst."
/>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
<AddressCombobox
id="createKwAddress"
name="createKwAddressDisplay"
label="KW"
value={formValues.kwAddress}
onChange={(value) => updateField('kwAddress', value)}
placeholder="KW-Adresse suchen oder eingeben"
/>
<AddressCombobox
id="createTargetObject"
name="createTargetObjectDisplay"
label="Zielobjekt"
value={formValues.targetObject}
onChange={(value) => updateField('targetObject', value)}
placeholder="Adresse suchen oder Zielobjekt eingeben"
/>
</div>
</div>
)}
{activeStep === 'responsibilities' && (
<div className="space-y-6">
<StepHeader
title="Zuständigkeiten"
description="Sachbearbeitung, Einsatzleitung und eingesetztes Team."
/>
<div className="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-6">
<div className="sm:col-span-3 xl:col-span-2">
<label htmlFor="createCaseWorker" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Sachbearbeitung
</label>
<div className="mt-2">
<input
id="createCaseWorker"
type="text"
value={formValues.caseWorker}
onChange={(event) => updateField('caseWorker', event.target.value)}
className={inputClassName}
/>
</div>
</div>
<div className="sm:col-span-3 xl:col-span-2">
<label htmlFor="createOperationLeader" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Einsatzleiter
</label>
<div className="mt-2">
<input
id="createOperationLeader"
type="text"
value={formValues.operationLeader}
onChange={(event) => updateField('operationLeader', event.target.value)}
className={inputClassName}
/>
</div>
</div>
<div className="sm:col-span-3 xl:col-span-2">
<label htmlFor="createOperationTeam" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Einsatzteam
</label>
<div className="mt-2">
<input
id="createOperationTeam"
type="text"
value={formValues.operationTeam}
onChange={(event) => updateField('operationTeam', event.target.value)}
className={inputClassName}
/>
</div>
</div>
</div>
</div>
)}
{activeStep === 'notes' && (
<div className="space-y-6">
<StepHeader
title="Zusatzinformationen"
description="Legende und Bemerkung können beim Anlegen oder später ergänzt werden."
/>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div>
<label htmlFor="createLegend" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Legende
</label>
<div className="mt-2">
<textarea
id="createLegend"
rows={6}
value={formValues.legend}
onChange={(event) => updateField('legend', event.target.value)}
className={textareaClassName}
/>
</div>
</div>
<div>
<label htmlFor="createRemark" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Bemerkung
</label>
<div className="mt-2">
<textarea
id="createRemark"
rows={6}
value={formValues.remark}
onChange={(event) => updateField('remark', event.target.value)}
className={textareaClassName}
/>
</div>
</div>
</div>
</div>
)}
{activeStep === 'equipment' && (
<div className="space-y-6">
<StepHeader
title="Technik"
description="Erfasse vorgesehene oder eingesetzte technische Ausstattung."
/>
<div className="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-2 xl:grid-cols-3">
<div>
<label htmlFor="createCamera" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Kamera
</label>
<div className="mt-2">
<input
id="createCamera"
type="text"
value={formValues.camera}
onChange={(event) => updateField('camera', event.target.value)}
className={inputClassName}
/>
</div>
</div>
<div>
<label htmlFor="createRouter" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Router
</label>
<div className="mt-2">
<input
id="createRouter"
type="text"
value={formValues.router}
onChange={(event) => updateField('router', event.target.value)}
className={inputClassName}
/>
</div>
</div>
<div>
<label htmlFor="createSwitchbox" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Switchbox
</label>
<div className="mt-2">
<input
id="createSwitchbox"
type="text"
value={formValues.switchbox}
onChange={(event) => updateField('switchbox', event.target.value)}
className={inputClassName}
/>
</div>
</div>
<div>
<label htmlFor="createLaptop" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Laptop
</label>
<div className="mt-2">
<input
id="createLaptop"
type="text"
value={formValues.laptop}
onChange={(event) => updateField('laptop', event.target.value)}
className={inputClassName}
/>
</div>
</div>
</div>
</div>
)}
{activeStep === 'review' && (
<div className="space-y-6">
<StepHeader
title="Einsatz prüfen"
description="Kontrolliere die Angaben. Danach kannst du den Einsatz speichern."
/>
<dl className="grid grid-cols-1 gap-4 rounded-lg border border-gray-200 bg-gray-50 p-4 sm:grid-cols-2 lg:grid-cols-3 dark:border-white/10 dark:bg-white/5">
<ReviewItem label="Einsatznummer" value={formValues.operationNumber} />
<ReviewItem label="Einsatzname" value={formValues.operationName} />
<ReviewItem label="Delikt" value={formValues.offense} />
<ReviewItem label="Sachbearbeitung" value={formValues.caseWorker} />
<ReviewItem label="KW" value={formValues.kwAddress} />
<ReviewItem label="Zielobjekt" value={formValues.targetObject} />
<ReviewItem label="Einsatzleiter" value={formValues.operationLeader} />
<ReviewItem label="Einsatzteam" value={formValues.operationTeam} />
<ReviewItem label="Kamera" value={formValues.camera} />
<ReviewItem label="Router" value={formValues.router} />
<ReviewItem label="Switchbox" value={formValues.switchbox} />
<ReviewItem label="Laptop" value={formValues.laptop} />
<ReviewItem label="Legende" value={formValues.legend} />
<ReviewItem label="Bemerkung" value={formValues.remark} />
</dl>
</div>
)}
</div>
<div className="flex flex-col gap-3 border-t border-gray-200 px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-6 dark:border-white/10">
<div className="text-sm text-gray-500 dark:text-gray-400">
Schritt {activeStepIndex + 1} von {setupSteps.length}
</div>
<div className="flex justify-end gap-x-3">
<Button
type="button"
variant="secondary"
color="gray"
onClick={() => handleOpenChange(false)}
disabled={isSaving}
leadingIcon={<XMarkIcon aria-hidden="true" className="size-4" />}
>
Abbrechen
</Button>
{!isFirstStep && (
<Button
type="button"
variant="secondary"
color="gray"
onClick={goToPreviousStep}
disabled={isSaving}
leadingIcon={<ChevronLeftIcon aria-hidden="true" className="size-4" />}
>
Zurück
</Button>
)}
{!isLastStep && setupSteps[activeStepIndex].optional && (
<Button
type="button"
variant="secondary"
color="gray"
onClick={skipStep}
disabled={isSaving}
>
Überspringen
</Button>
)}
{!isLastStep ? (
<Button
type="button"
color="indigo"
onClick={goToNextStep}
disabled={isSaving}
leadingIcon={<ChevronRightIcon aria-hidden="true" className="size-4" />}
>
Weiter
</Button>
) : (
<Button
type="submit"
color="indigo"
isLoading={isSaving}
leadingIcon={<CheckIcon aria-hidden="true" className="size-4" />}
>
Einsatz speichern
</Button>
)}
</div>
</div>
</form>
</Modal>
)
}