Umstellung auf Next.js (App Router), Design analog werte-next

Führt das getrennte React/Vite-Frontend und Express/MongoDB-Backend in
eine einzelne Next.js-Anwendung zusammen:

- UI: React-Client-Komponenten (TabLayout, AppointmentForm, -List,
  ConfirmationModal) im Look von werte-next (Farben #CCCCFF/#85B7D7/
  #FFFFDD, Rahmenbox, Tabellen-Liste, Footer mit Version/Datum)
- API: Next.js Route Handler unter /api/appointments (GET/POST/PUT/DELETE,
  Soft-Delete beibehalten)
- DB: MongoDB via lib/db.ts (gecachter Client, Standard appointmentsdb)
- Tailwind 4 + TypeScript, Standalone-Docker-Build wie werte-next
- Druckansicht der aktuellen offenen Termine beibehalten
- Deployment: Dockerfile (multi-stage), docker-compose(.prod), deploy.sh

Entfernt die alten frontend/- und backend/-Verzeichnisse.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 14:24:34 +02:00
parent 91a31f04b1
commit 1a0fe18e89
55 changed files with 8216 additions and 1756 deletions
+226
View File
@@ -0,0 +1,226 @@
'use client';
import { useState } from 'react';
import { Appointment, AppointmentFormData, AppointmentPayload } from '@/types/appointment';
interface AppointmentFormProps {
onAdd: (payload: AppointmentPayload) => void;
onUpdate: (id: string, payload: AppointmentPayload) => void;
editingAppointment: Appointment | null;
onCancelEdit: () => void;
}
const emptyForm: AppointmentFormData = {
arztName: '',
arztArt: '',
terminDatum: '',
terminUhrzeit: '',
bemerkungen: '',
};
// Hilfsfunktionen: ISO-String -> Formularfelder
const toDateInput = (iso: string) => {
const d = new Date(iso);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
};
const toTimeInput = (iso: string) => {
const d = new Date(iso);
const h = String(d.getHours()).padStart(2, '0');
const min = String(d.getMinutes()).padStart(2, '0');
return `${h}:${min}`;
};
// Leitet die Formularfelder aus dem zu bearbeitenden Termin ab (oder leer).
const buildInitialForm = (
editing: Appointment | null
): AppointmentFormData =>
editing
? {
arztName: editing.arztName,
arztArt: editing.arztArt ?? '',
terminDatum: toDateInput(editing.termin),
terminUhrzeit: toTimeInput(editing.termin),
bemerkungen: editing.bemerkungen ?? '',
}
: emptyForm;
export default function AppointmentForm({
onAdd,
onUpdate,
editingAppointment,
onCancelEdit,
}: AppointmentFormProps) {
// Der Anfangszustand wird aus den Props abgeleitet. Wechselt der zu
// bearbeitende Termin, wird die Komponente über einen `key` im Parent
// neu gemountet dadurch entfällt ein setState im Effect.
const [formData, setFormData] = useState<AppointmentFormData>(() =>
buildInitialForm(editingAppointment)
);
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.arztName || !formData.terminDatum || !formData.terminUhrzeit) {
alert('Bitte füllen Sie Name, Datum und Uhrzeit aus.');
return;
}
const termin = new Date(
`${formData.terminDatum}T${formData.terminUhrzeit}`
).toISOString();
const payload: AppointmentPayload = {
arztName: formData.arztName,
arztArt: formData.arztArt,
termin,
erledigt: editingAppointment ? editingAppointment.erledigt : false,
bemerkungen: formData.bemerkungen,
};
if (editingAppointment) {
onUpdate(editingAppointment.id, payload);
} else {
onAdd(payload);
}
};
const inputClass =
'w-full px-2 py-1 text-sm rounded border-2 border-gray-400 bg-white focus:border-blue-500 focus:outline-none';
return (
<div className="bg-[#CCCCFF] border border-black p-6 rounded-lg mb-6">
{editingAppointment && (
<div className="mb-4 p-3 bg-blue-100 border border-blue-400 rounded text-sm text-blue-800">
<strong>Bearbeitungsmodus:</strong> Sie bearbeiten einen bestehenden
Termin. Klicken Sie auf &quot;Aktualisieren&quot;, um die Änderungen zu
speichern, oder &quot;Abbrechen&quot;, um zur Neuerfassung
zurückzukehren.
</div>
)}
<form onSubmit={handleSubmit}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label
htmlFor="arztName"
className="block text-sm font-medium text-gray-800 mb-1"
>
Name des Arztes
</label>
<input
type="text"
id="arztName"
name="arztName"
className={inputClass}
value={formData.arztName}
onChange={handleChange}
required
/>
</div>
<div>
<label
htmlFor="arztArt"
className="block text-sm font-medium text-gray-800 mb-1"
>
Art des Arztes
</label>
<input
type="text"
id="arztArt"
name="arztArt"
className={inputClass}
placeholder="z.B. Zahnarzt, Kardiologe"
value={formData.arztArt}
onChange={handleChange}
/>
</div>
<div>
<label
htmlFor="terminDatum"
className="block text-sm font-medium text-gray-800 mb-1"
>
Datum
</label>
<input
type="date"
id="terminDatum"
name="terminDatum"
className={inputClass}
value={formData.terminDatum}
onChange={handleChange}
required
/>
</div>
<div>
<label
htmlFor="terminUhrzeit"
className="block text-sm font-medium text-gray-800 mb-1"
>
Uhrzeit
</label>
<input
type="time"
id="terminUhrzeit"
name="terminUhrzeit"
className={inputClass}
value={formData.terminUhrzeit}
onChange={handleChange}
required
/>
</div>
<div className="md:col-span-2">
<label
htmlFor="bemerkungen"
className="block text-sm font-medium text-gray-800 mb-1"
>
Bemerkungen
</label>
<textarea
id="bemerkungen"
name="bemerkungen"
rows={3}
className={`${inputClass} resize-y`}
value={formData.bemerkungen}
onChange={handleChange}
/>
</div>
</div>
<div className="flex justify-center gap-4 mt-6">
<button
type="submit"
className="bg-[#85B7D7] hover:bg-[#6a9fc5] text-black font-medium py-2 px-8 rounded-lg transition-colors"
>
{editingAppointment ? 'Aktualisieren' : 'Speichern'}
</button>
<button
type="button"
onClick={() => {
if (editingAppointment) {
onCancelEdit();
} else {
setFormData(emptyForm);
}
}}
className="bg-[#85B7D7] hover:bg-[#6a9fc5] text-black font-medium py-2 px-8 rounded-lg transition-colors"
>
{editingAppointment ? 'Abbrechen' : 'Löschen'}
</button>
</div>
</form>
</div>
);
}
+118
View File
@@ -0,0 +1,118 @@
'use client';
import { Appointment } from '@/types/appointment';
interface AppointmentListProps {
appointments: Appointment[];
onToggleDone: (id: string) => void;
onEdit: (appointment: Appointment) => void;
onDelete: (id: string) => void;
}
const weekdays = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
const formatDate = (iso: string) => {
const d = new Date(iso);
const day = String(d.getDate()).padStart(2, '0');
const m = String(d.getMonth() + 1).padStart(2, '0');
return `${day}.${m}.${d.getFullYear()}`;
};
const formatTime = (iso: string) => {
const d = new Date(iso);
const h = String(d.getHours()).padStart(2, '0');
const min = String(d.getMinutes()).padStart(2, '0');
return `${h}:${min}`;
};
const getWeekday = (iso: string) => weekdays[new Date(iso).getDay()];
export default function AppointmentList({
appointments,
onToggleDone,
onEdit,
onDelete,
}: AppointmentListProps) {
if (appointments.length === 0) {
return (
<div className="text-center py-8 text-gray-500">
Keine Termine vorhanden. Fügen Sie oben einen neuen Termin hinzu!
</div>
);
}
const sorted = [...appointments].sort(
(a, b) => new Date(a.termin).getTime() - new Date(b.termin).getTime()
);
return (
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="bg-[#CCCCFF] border-b-2 border-gray-400">
<th className="p-2 text-center">Erledigt</th>
<th className="p-2 text-center">Arzt</th>
<th className="p-2 text-center">Fachrichtung</th>
<th className="p-2 text-center">Datum</th>
<th className="p-2 text-center">Tag</th>
<th className="p-2 text-center">Zeit</th>
<th className="p-2 text-center">Bemerkungen</th>
<th className="p-2 text-center">Aktion</th>
</tr>
</thead>
<tbody>
{sorted.map((app, index) => (
<tr
key={app.id}
onClick={() => onEdit(app)}
className={`border-b border-gray-300 hover:bg-gray-100 cursor-pointer ${
app.erledigt
? 'bg-green-50 text-gray-400 line-through'
: index % 2 === 0
? 'bg-white'
: 'bg-gray-50'
}`}
>
<td
className="p-2 text-center"
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
className="w-4 h-4 cursor-pointer"
checked={app.erledigt}
onChange={() => onToggleDone(app.id)}
/>
</td>
<td className="p-2 text-center font-medium">{app.arztName}</td>
<td className="p-2 text-center">{app.arztArt || '-'}</td>
<td className="p-2 text-center">{formatDate(app.termin)}</td>
<td className="p-2 text-center">{getWeekday(app.termin)}</td>
<td className="p-2 text-center">{formatTime(app.termin)}</td>
<td className="p-2 text-center">{app.bemerkungen || '-'}</td>
<td
className="p-2 text-center"
onClick={(e) => e.stopPropagation()}
>
<div className="flex gap-2 justify-center no-underline">
<button
onClick={() => onEdit(app)}
className="text-blue-600 hover:text-blue-800 text-sm no-underline"
>
Editieren
</button>
<button
onClick={() => onDelete(app.id)}
className="text-red-600 hover:text-red-800 text-sm no-underline"
>
Löschen
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
'use client';
interface ConfirmationModalProps {
isOpen: boolean;
title?: string;
message: string;
onConfirm: () => void;
onCancel: () => void;
}
export default function ConfirmationModal({
isOpen,
title,
message,
onConfirm,
onCancel,
}: ConfirmationModalProps) {
if (!isOpen) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
onClick={onCancel}
>
<div
className="bg-white border border-gray-300 rounded-lg shadow-lg p-6 w-80 text-center"
onClick={(e) => e.stopPropagation()}
>
{title && (
<h3 className="text-lg font-semibold text-gray-900 mb-3">{title}</h3>
)}
<p className="mb-6 text-gray-800 font-medium">{message}</p>
<div className="flex justify-center gap-4">
<button
onClick={onConfirm}
className="bg-red-500 hover:bg-red-600 text-white font-medium py-1.5 px-6 rounded-lg transition-colors"
>
Löschen
</button>
<button
onClick={onCancel}
className="bg-[#85B7D7] hover:bg-[#6a9fc5] text-black font-medium py-1.5 px-6 rounded-lg transition-colors"
>
Abbrechen
</button>
</div>
</div>
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
'use client';
import packageJson from '@/package.json';
interface TabLayoutProps {
children: React.ReactNode;
onPrint?: () => void;
}
export default function TabLayout({ children, onPrint }: TabLayoutProps) {
const version = packageJson.version;
const buildDate =
process.env.NEXT_PUBLIC_BUILD_DATE ||
new Date().toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
return (
<div className="min-h-screen py-8 px-4">
{/* Äußerer Rahmen */}
<div className="max-w-316 mx-auto border-2 border-black rounded-xl bg-gray-200 p-6">
{/* Seitentitel */}
<h1 className="text-4xl font-bold text-center mb-6 tracking-tight">
Arzt-Termine
</h1>
{/* Innerer Inhalt */}
<div className="max-w-6xl mx-auto">
{/* Tab-Leiste */}
<div className="flex justify-between items-end">
<div className="flex">
<span
className="px-6 py-2 text-sm font-semibold border-t-2 border-l-2 border-r-2 rounded-tl-lg rounded-tr-lg mr-1"
style={{
backgroundColor: '#FFFFDD',
color: '#000000',
borderColor: '#000000',
borderBottom: '2px solid #FFFFDD',
marginBottom: '-2px',
position: 'relative',
zIndex: 10,
}}
>
Termine
</span>
</div>
<div className="pb-1">
<button
onClick={onPrint}
className="px-4 py-2 bg-[#85B7D7] hover:bg-[#6a9fc5] text-black text-sm rounded-lg shadow-md transition-colors"
>
🖨 Aktuelle Termine drucken
</button>
</div>
</div>
{/* Inhaltsbereich */}
<main className="border-2 border-black rounded-b-lg rounded-tr-lg p-6 bg-[#FFFFDD]">
{children}
</main>
<footer className="mt-8 flex justify-between items-center text-sm text-gray-600 px-4">
<a href="mailto:rxf@gmx.de" className="hover:underline">
mailto:rxf@gmx.de
</a>
<div>
Version {version} - {buildDate}
</div>
</footer>
</div>
</div>
</div>
);
}