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:
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user