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
+93
View File
@@ -0,0 +1,93 @@
import { NextRequest, NextResponse } from 'next/server';
import { ObjectId, WithId, Document } from 'mongodb';
import { getDb, APPOINTMENTS_COLLECTION } from '@/lib/db';
import { Appointment } from '@/types/appointment';
function normalize(doc: WithId<Document>): Appointment {
return {
id: doc._id.toString(),
arztName: doc.arztName,
arztArt: doc.arztArt ?? '',
termin: new Date(doc.termin).toISOString(),
erledigt: Boolean(doc.erledigt),
bemerkungen: doc.bemerkungen ?? '',
};
}
// PUT /api/appointments/:id - Termin aktualisieren (ganz oder teilweise)
export async function PUT(
request: NextRequest,
context: { params: Promise<{ id: string }> }
) {
try {
const { id } = await context.params;
const body = await request.json();
// Nur bekannte Felder übernehmen; termin ggf. in Date umwandeln.
const updateFields: Record<string, unknown> = {};
if (body.arztName !== undefined) updateFields.arztName = body.arztName;
if (body.arztArt !== undefined) updateFields.arztArt = body.arztArt;
if (body.termin !== undefined) updateFields.termin = new Date(body.termin);
if (body.erledigt !== undefined) updateFields.erledigt = Boolean(body.erledigt);
if (body.bemerkungen !== undefined) updateFields.bemerkungen = body.bemerkungen;
const db = await getDb();
const filter = { _id: new ObjectId(id) };
const result = await db
.collection(APPOINTMENTS_COLLECTION)
.updateOne(filter, { $set: updateFields });
if (result.matchedCount === 0) {
return NextResponse.json(
{ success: false, error: 'Termin nicht gefunden.' },
{ status: 404 }
);
}
const updated = await db
.collection(APPOINTMENTS_COLLECTION)
.findOne(filter);
return NextResponse.json({ success: true, data: normalize(updated!) });
} catch (error) {
console.error('Fehler beim Aktualisieren des Termins:', error);
return NextResponse.json(
{ success: false, error: 'Ungültige ID oder Serverfehler' },
{ status: 400 }
);
}
}
// DELETE /api/appointments/:id - Soft-Delete (setzt deleted-Flag)
export async function DELETE(
request: NextRequest,
context: { params: Promise<{ id: string }> }
) {
try {
const { id } = await context.params;
const db = await getDb();
const result = await db
.collection(APPOINTMENTS_COLLECTION)
.updateOne(
{ _id: new ObjectId(id) },
{ $set: { deleted: true, deletedAt: new Date() } }
);
if (result.matchedCount === 0) {
return NextResponse.json(
{ success: false, error: 'Termin nicht gefunden.' },
{ status: 404 }
);
}
return NextResponse.json({ success: true });
} catch (error) {
console.error('Fehler beim Löschen des Termins:', error);
return NextResponse.json(
{ success: false, error: 'Ungültige ID oder Serverfehler' },
{ status: 400 }
);
}
}
+83
View File
@@ -0,0 +1,83 @@
import { NextRequest, NextResponse } from 'next/server';
import { WithId, Document } from 'mongodb';
import { getDb, APPOINTMENTS_COLLECTION } from '@/lib/db';
import { Appointment, AppointmentPayload } from '@/types/appointment';
// Wandelt ein MongoDB-Dokument in das vom Frontend erwartete Format um
// (_id -> id als String, termin als ISO-String).
function normalize(doc: WithId<Document>): Appointment {
return {
id: doc._id.toString(),
arztName: doc.arztName,
arztArt: doc.arztArt ?? '',
termin: new Date(doc.termin).toISOString(),
erledigt: Boolean(doc.erledigt),
bemerkungen: doc.bemerkungen ?? '',
};
}
// GET /api/appointments - Alle nicht gelöschten Termine abrufen
export async function GET() {
try {
const db = await getDb();
const docs = await db
.collection(APPOINTMENTS_COLLECTION)
.find({ deleted: { $ne: true } })
.sort({ termin: 1 })
.toArray();
return NextResponse.json(
{ success: true, data: docs.map(normalize) },
{
headers: {
'Cache-Control': 'no-store, no-cache, must-revalidate',
Pragma: 'no-cache',
Expires: '0',
},
}
);
} catch (error) {
console.error('Fehler beim Abrufen der Termine:', error);
return NextResponse.json(
{ success: false, error: 'Fehler beim Abrufen der Termine' },
{ status: 500 }
);
}
}
// POST /api/appointments - Neuen Termin erstellen
export async function POST(request: NextRequest) {
try {
const body: AppointmentPayload = await request.json();
if (!body.arztName || !body.termin) {
return NextResponse.json(
{ success: false, error: 'Arztname und Termin sind erforderlich.' },
{ status: 400 }
);
}
const db = await getDb();
const doc = {
arztName: body.arztName,
arztArt: body.arztArt ?? '',
termin: new Date(body.termin),
erledigt: Boolean(body.erledigt),
bemerkungen: body.bemerkungen ?? '',
deleted: false,
};
const result = await db.collection(APPOINTMENTS_COLLECTION).insertOne(doc);
return NextResponse.json(
{ success: true, data: normalize({ _id: result.insertedId, ...doc }) },
{ status: 201 }
);
} catch (error) {
console.error('Fehler beim Erstellen des Termins:', error);
return NextResponse.json(
{ success: false, error: 'Fehler beim Speichern des Termins' },
{ status: 500 }
);
}
}
+71
View File
@@ -0,0 +1,71 @@
/* stylelint-disable at-rule-no-unknown */
@import "tailwindcss";
@source "../components/**/*.tsx";
@source "../app/**/*.tsx";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
/* --- Druckansicht --- */
/* Standard: Druckbereich ausblenden */
.print-only {
display: none;
}
@media print {
/* Bildschirm-UI ausblenden */
.screen-only,
.screen-only * {
display: none !important;
}
/* Nur Druckbereich anzeigen */
.print-only {
display: block !important;
}
body {
background: #ffffff;
margin: 10mm;
color: #000;
}
.print-only h1 {
margin-top: 0;
font-size: 20px;
}
.print-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.print-table th,
.print-table td {
border: 1px solid #000;
padding: 6px;
text-align: left;
}
.print-table thead th {
background: #eee;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
+34
View File
@@ -0,0 +1,34 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Arzt-Termine",
description: "Verwaltung von Arzt-Terminen",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="de">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html>
);
}
+212
View File
@@ -0,0 +1,212 @@
'use client';
import { useState, useEffect } from 'react';
import TabLayout from '@/components/TabLayout';
import AppointmentForm from '@/components/AppointmentForm';
import AppointmentList from '@/components/AppointmentList';
import ConfirmationModal from '@/components/ConfirmationModal';
import { Appointment, AppointmentPayload } from '@/types/appointment';
export default function Home() {
const [appointments, setAppointments] = useState<Appointment[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [editingAppointment, setEditingAppointment] =
useState<Appointment | null>(null);
const [deleteId, setDeleteId] = useState<string | null>(null);
const fetchAppointments = async () => {
try {
const res = await fetch('/api/appointments', { cache: 'no-store' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (data.success) {
setAppointments(data.data);
}
} catch (error) {
console.error('Fehler beim Laden der Termine:', error);
} finally {
setIsLoading(false);
}
};
// Termine einmalig beim Mount laden.
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchAppointments();
}, []);
// CREATE
const addAppointment = async (payload: AppointmentPayload) => {
try {
const res = await fetch('/api/appointments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error('Fehler beim Hinzufügen.');
const data = await res.json();
if (data.success) {
setAppointments((prev) => [...prev, data.data]);
}
} catch (error) {
console.error('Fehler beim Speichern des Termins:', error);
alert('Fehler beim Speichern!');
}
};
// UPDATE (vollständig, vom Formular)
const updateAppointment = async (id: string, payload: AppointmentPayload) => {
try {
const res = await fetch(`/api/appointments/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error('Fehler beim Aktualisieren.');
const data = await res.json();
if (data.success) {
setAppointments((prev) =>
prev.map((a) => (a.id === id ? data.data : a))
);
setEditingAppointment(null);
}
} catch (error) {
console.error('Fehler beim Aktualisieren des Termins:', error);
alert('Fehler beim Aktualisieren!');
}
};
// Statuswechsel (erledigt)
const toggleDone = async (id: string) => {
const current = appointments.find((a) => a.id === id);
if (!current) return;
const newStatus = !current.erledigt;
// Optimistisches Update
setAppointments((prev) =>
prev.map((a) => (a.id === id ? { ...a, erledigt: newStatus } : a))
);
try {
const res = await fetch(`/api/appointments/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ erledigt: newStatus }),
});
if (!res.ok) throw new Error('Fehler beim Statuswechsel.');
} catch (error) {
console.error('Fehler beim Statuswechsel:', error);
// Rückgängig machen bei Fehler
setAppointments((prev) =>
prev.map((a) => (a.id === id ? { ...a, erledigt: current.erledigt } : a))
);
}
};
// DELETE (Soft-Delete) mit Bestätigung
const confirmDelete = async () => {
const id = deleteId;
setDeleteId(null);
if (!id) return;
try {
const res = await fetch(`/api/appointments/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Fehler beim Löschen.');
setAppointments((prev) => prev.filter((a) => a.id !== id));
if (editingAppointment && editingAppointment.id === id) {
setEditingAppointment(null);
}
} catch (error) {
console.error('Fehler beim Löschen des Termins:', error);
alert('Fehler beim Löschen!');
}
};
const startEdit = (appointment: Appointment) => {
setEditingAppointment(appointment);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handlePrint = () => window.print();
// Aktuelle (offene) Termine ab heute für die Druckansicht
const startOfToday = new Date();
startOfToday.setHours(0, 0, 0, 0);
const currentAppointments = appointments
.filter((a) => !a.erledigt && new Date(a.termin) >= startOfToday)
.sort(
(a, b) => new Date(a.termin).getTime() - new Date(b.termin).getTime()
);
return (
<>
{/* Bildschirmansicht */}
<div className="screen-only">
<TabLayout onPrint={handlePrint}>
<div className="mb-8">
<h2 className="text-xl font-semibold mb-4">
{editingAppointment
? '✍️ Termin bearbeiten'
: '🗓️ Neuen Termin hinzufügen'}
</h2>
<AppointmentForm
key={editingAppointment?.id ?? 'new'}
onAdd={addAppointment}
onUpdate={updateAppointment}
editingAppointment={editingAppointment}
onCancelEdit={() => setEditingAppointment(null)}
/>
</div>
<div className="bg-white border border-black rounded-lg shadow-md p-6">
<h2 className="text-xl font-semibold mb-4">Alle Termine</h2>
{isLoading ? (
<div className="text-center py-4">Lade Daten...</div>
) : (
<AppointmentList
appointments={appointments}
onToggleDone={toggleDone}
onEdit={startEdit}
onDelete={setDeleteId}
/>
)}
</div>
</TabLayout>
</div>
{/* Druckansicht: nur aktuelle (offene) Termine ab heute */}
<div className="print-only">
<h1>Aktuelle Arzt-Termine</h1>
{currentAppointments.length === 0 ? (
<p>Keine aktuellen Termine vorhanden.</p>
) : (
<table className="print-table">
<thead>
<tr>
<th>Arzt</th>
<th>Fachrichtung</th>
<th>Datum</th>
<th>Bemerkungen</th>
</tr>
</thead>
<tbody>
{currentAppointments.map((app) => (
<tr key={app.id}>
<td>{app.arztName}</td>
<td>{app.arztArt || ''}</td>
<td>{new Date(app.termin).toLocaleString('de-DE')}</td>
<td>{app.bemerkungen || ''}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<ConfirmationModal
isOpen={deleteId !== null}
title="Termin löschen"
message="Möchten Sie diesen Termin wirklich löschen?"
onConfirm={confirmDelete}
onCancel={() => setDeleteId(null)}
/>
</>
);
}