diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..07e8966 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,46 @@ +# dependencies +node_modules +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# next.js +.next/ +out/ +build +dist + +# misc +.DS_Store +*.pem + +# debug +*.log + +# local env files +.env +.env.local +.env.*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo + +# git +.git +.gitignore + +# editor +.vscode +.idea + +# docker +Dockerfile +.dockerignore +docker-compose*.yml + +# other +README.md diff --git a/.env.docker b/.env.docker deleted file mode 100644 index 250858b..0000000 --- a/.env.docker +++ /dev/null @@ -1,3 +0,0 @@ -# Environment variables for Docker Compose -MONGO_ROOT_USER=root -MONGO_ROOT_PASSWD=SFluorit diff --git a/.env.example b/.env.example index ab14de9..352713e 100644 --- a/.env.example +++ b/.env.example @@ -1,19 +1,16 @@ -# Vorlage für die Produktionsumgebung. +# Vorlage für die Umgebungsvariablen. # Kopieren nach .env und Werte anpassen: cp .env.example .env -# --- MongoDB Zugangsdaten (werden beim ersten Start der DB angelegt) --- +# --- MongoDB --- +# Verbindungs-URI. Die Datenbank wird aus der URI abgeleitet. +# Lokal: mongodb://localhost:27017/appointmentsdb +# Docker: mongodb://:@mongodb:27017/appointmentsdb?authSource=admin +MONGO_URI=mongodb://localhost:27017/appointmentsdb + +# --- MongoDB Root-Zugangsdaten (nur für docker-compose, DB-Erstanlage) --- MONGO_ROOT_USER=root MONGO_ROOT_PASSWD=bitte-aendern # --- Frontend --- -# Host-Port, unter dem die App erreichbar ist (Standard: 80) -FRONTEND_PORT=80 - -# --- Images (nur für docker-compose.images.yml) --- -# Tag der Images aus docker.citysensor.de (Standard: latest) -# IMAGE_TAG=latest - -# --- Backend / CORS --- -# Nur nötig, falls das Frontend NICHT über denselben Host/nginx-Proxy läuft. -# Standardmäßig nicht erforderlich. Mehrere Origins kommagetrennt, "*" für alle. -# CORS_ORIGIN=https://meine-domain.de +# Host-Port, unter dem die App erreichbar ist (Standard: 3000) +APP_PORT=3000 diff --git a/.gitignore b/.gitignore index ad9e48f..1a15143 100644 --- a/.gitignore +++ b/.gitignore @@ -1,32 +1,38 @@ -# Dependencies -node_modules/ -package-lock.json +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. -# Environment variables +# dependencies +/node_modules +/.pnp +.pnp.* + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files .env .env.local .env.*.local -# Build output -dist/ -build/ +# vercel +.vercel -# Logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# IDE -.vscode/ -.idea/ -*.swp -*.swo -*~ -.DS_Store - -# Testing -coverage/ - -# Misc -.cache/ +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..dc0b3a4 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +audit-level=high diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..61736c4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,47 @@ +# Multi-stage build für die Next.js-Anwendung +FROM node:22-alpine AS base + +# Nur Dependencies installieren, wenn nötig +FROM base AS deps +RUN apk add --no-cache libc6-compat +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm ci + +# Quellcode bauen +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Build-Datum als Build-Argument +ARG BUILD_DATE +ENV NEXT_PUBLIC_BUILD_DATE=${BUILD_DATE} + +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN npm run build + +# Produktions-Image +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs + +EXPOSE 3000 + +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +CMD ["node", "server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..6ff879e --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# Arzt-Termine (Next.js) + +Verwaltung von Arzt-Terminen als Next.js-Anwendung (App Router) mit MongoDB als +Datenspeicher. Design und Layout orientieren sich am Projekt `werte-next`. + +Vormals bestand das Projekt aus einem getrennten React/Vite-Frontend und einem +Express-Backend. Beides wurde in eine einzelne Next.js-App zusammengeführt: + +- **UI** – React-Client-Komponenten in `components/`, Seite in `app/page.tsx` +- **API** – Next.js Route Handler unter `app/api/appointments` +- **DB** – MongoDB-Zugriff in `lib/db.ts` + +## Funktionen + +- Termine anlegen, bearbeiten, als erledigt markieren und (soft) löschen +- Felder: Arztname, Fachrichtung, Datum/Uhrzeit, Bemerkungen +- Druckansicht mit den aktuellen (offenen) Terminen ab heute + +## Entwicklung + +```bash +cp .env.example .env # MONGO_URI setzen +npm install +npm run dev # http://localhost:3000 +``` + +## Produktion (Docker) + +```bash +cp .env.example .env # MONGO_ROOT_USER / MONGO_ROOT_PASSWD setzen +docker compose up -d --build +``` + +## Deploy zur Registry + +```bash +./deploy.sh [tag] # baut & pusht docker.citysensor.de/aerzte-next +``` + +## Umgebungsvariablen + +| Variable | Beschreibung | +| ------------------- | ----------------------------------------------- | +| `MONGO_URI` | MongoDB-Verbindung inkl. Datenbankname | +| `MONGO_ROOT_USER` | Root-User für den MongoDB-Container (Compose) | +| `MONGO_ROOT_PASSWD` | Root-Passwort für den MongoDB-Container | +| `APP_PORT` | Host-Port der App (Standard: 3000) | diff --git a/app/api/appointments/[id]/route.ts b/app/api/appointments/[id]/route.ts new file mode 100644 index 0000000..780c896 --- /dev/null +++ b/app/api/appointments/[id]/route.ts @@ -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): 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 = {}; + 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 } + ); + } +} diff --git a/app/api/appointments/route.ts b/app/api/appointments/route.ts new file mode 100644 index 0000000..3bd2b7a --- /dev/null +++ b/app/api/appointments/route.ts @@ -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): 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 } + ); + } +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..b6488f6 --- /dev/null +++ b/app/globals.css @@ -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; + } +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..fd4d9fa --- /dev/null +++ b/app/layout.tsx @@ -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 ( + + + {children} + + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..d33073c --- /dev/null +++ b/app/page.tsx @@ -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([]); + const [isLoading, setIsLoading] = useState(true); + const [editingAppointment, setEditingAppointment] = + useState(null); + const [deleteId, setDeleteId] = useState(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 */} +
+ +
+

+ {editingAppointment + ? '✍️ Termin bearbeiten' + : '🗓️ Neuen Termin hinzufügen'} +

+ setEditingAppointment(null)} + /> +
+ +
+

Alle Termine

+ {isLoading ? ( +
Lade Daten...
+ ) : ( + + )} +
+
+
+ + {/* Druckansicht: nur aktuelle (offene) Termine ab heute */} +
+

Aktuelle Arzt-Termine

+ {currentAppointments.length === 0 ? ( +

Keine aktuellen Termine vorhanden.

+ ) : ( + + + + + + + + + + + {currentAppointments.map((app) => ( + + + + + + + ))} + +
ArztFachrichtungDatumBemerkungen
{app.arztName}{app.arztArt || ''}{new Date(app.termin).toLocaleString('de-DE')}{app.bemerkungen || ''}
+ )} +
+ + setDeleteId(null)} + /> + + ); +} diff --git a/backend/.dockerignore b/backend/.dockerignore deleted file mode 100644 index c6cdbfa..0000000 --- a/backend/.dockerignore +++ /dev/null @@ -1,5 +0,0 @@ -node_modules -npm-debug.log -.env -.git -.gitignore diff --git a/backend/Dockerfile b/backend/Dockerfile deleted file mode 100644 index 0cdc382..0000000 --- a/backend/Dockerfile +++ /dev/null @@ -1,18 +0,0 @@ -FROM node:20-alpine - -WORKDIR /app - -# Copy package files -COPY package*.json ./ - -# Install dependencies -RUN npm ci - -# Copy source code -COPY . . - -# Expose the application port -EXPOSE 3001 - -# Start the application -CMD ["npm", "run", "dev"] diff --git a/backend/Dockerfile.prod b/backend/Dockerfile.prod deleted file mode 100644 index 6025ab7..0000000 --- a/backend/Dockerfile.prod +++ /dev/null @@ -1,17 +0,0 @@ -FROM node:20-alpine - -WORKDIR /app - -# Nur Produktions-Abhängigkeiten installieren -COPY package*.json ./ -RUN npm ci --omit=dev - -# Quellcode kopieren -COPY . . - -ENV NODE_ENV=production - -EXPOSE 3001 - -# Produktionsstart (ohne nodemon) -CMD ["npm", "start"] diff --git a/backend/package.json b/backend/package.json deleted file mode 100644 index 3068f49..0000000 --- a/backend/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "backend", - "type": "module", - "version": "1.0.0", - "vdate": "2025-11-23 11:00 UTC", - "description": "", - "main": "index.js", - "scripts": { - "start": "node src/server.js", - "dev": "nodemon src/server.js" - }, - "keywords": [], - "author": "", - "license": "ISC", - "dependencies": { - "cors": "^2.8.5", - "dotenv": "^17.2.3", - "express": "^5.1.0", - "mongodb": "^7.0.0" - }, - "devDependencies": { - "nodemon": "^3.1.11" - } -} diff --git a/backend/src/db/db.js b/backend/src/db/db.js deleted file mode 100644 index 10bd509..0000000 --- a/backend/src/db/db.js +++ /dev/null @@ -1,40 +0,0 @@ -// src/db/db.js -import 'dotenv/config'; // Importiert und lädt Umgebungsvariablen -import { MongoClient } from 'mongodb'; - -// MongoDB Verbindungs-URI aus der .env-Datei -const uri = process.env.MONGO_URI; - -let db; - -/** - * Stellt die Verbindung zur MongoDB her. - * @returns {db} Die verbundene Datenbankinstanz. - */ -export async function connectToDb() { - try { - const client = new MongoClient(uri, { - }); - - await client.connect(); - - // Die Datenbankinstanz speichern - db = client.db(); - - console.log(`✅ Erfolgreich mit MongoDB verbunden.`); - - return db; - - } catch (error) { - console.error("❌ Fehler bei der Verbindung zur MongoDB:", error); - process.exit(1); - } -} - -/** - * Gibt die gespeicherte Datenbankinstanz zurück. - * @returns {db} Die Datenbankinstanz. - */ -export function getDb() { - return db; -} \ No newline at end of file diff --git a/backend/src/server.js b/backend/src/server.js deleted file mode 100644 index 2ca629b..0000000 --- a/backend/src/server.js +++ /dev/null @@ -1,175 +0,0 @@ -// src/server.js -import 'dotenv/config'; -import express from 'express'; -import cors from 'cors'; -import { connectToDb, getDb } from './db/db.js'; -import { ObjectId } from 'mongodb'; // Wichtig für die Arbeit mit MongoDB IDs - -// Initialisiert Express -const app = express(); -const PORT = process.env.PORT || 3001; - -// Erlaubte CORS-Origins aus der Umgebung (kommagetrennt). -// In Produktion wird das Frontend i.d.R. über denselben Host (nginx-Proxy) -// ausgeliefert, dann sind keine Cross-Origin-Anfragen nötig. -// Standard: localhost:5173 für die lokale Entwicklung. -// CORS_ORIGIN="*" erlaubt alle Origins. -const corsOrigins = (process.env.CORS_ORIGIN || 'http://localhost:5173') - .split(',') - .map((o) => o.trim()); - -app.use(cors({ - origin: corsOrigins.includes('*') ? true : corsOrigins, - methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], - allowedHeaders: ['Origin', 'X-Requested-With', 'Content-Type', 'Accept'], - credentials: true, -})); - -app.use((req, res, next) => { - console.log(`📥 ${req.method} ${req.path} from ${req.get('origin') || 'unknown origin'}`); - next(); -}); -app.use(express.json()); - -// ----------------------------------------------------- -// API ROUTEN -// ----------------------------------------------------- - -// Root-Route zur Status-Überprüfung -app.get('/', (req, res) => { - res.json({ - message: 'Appointment API Server läuft', - status: 'ok', - endpoints: { - appointments: '/api/appointments' - } - }); -}); - -// 1. READ: Alle Termine abrufen (GET /api/appointments) -app.get('/api/appointments', async (req, res) => { - try { - const db = getDb(); - // Finde alle Dokumente in der Collection 'appointments', die nicht gelöscht sind - const appointments = await db.collection('appointments') - .find({ deleted: { $ne: true } }) - // Optional: Nach Termindatum sortieren (aufsteigend) - .sort({ termin: 1 }) - .toArray(); - - // Sende die Daten zurück an das Frontend - res.json(appointments); - - } catch (error) { - console.error("Fehler beim Abrufen der Termine:", error); - res.status(500).json({ message: "Interner Serverfehler beim Abrufen der Daten." }); - } -}); - -// 2. CREATE: Neuen Termin erstellen (POST /api/appointments) -app.post('/api/appointments', async (req, res) => { - try { - const db = getDb(); - const appointmentData = req.body; // Die Daten kommen vom React-Formular - - // Validiere, dass der Termin ein gültiges Datum hat (Optional, aber gut) - if (!appointmentData.arztName || !appointmentData.termin) { - return res.status(400).json({ message: "Arztname und Termin sind erforderlich." }); - } - - // Mongo generiert die _id automatisch. - const result = await db.collection('appointments').insertOne(appointmentData); - - // Der eingefügte Termin, inklusive der von Mongo erstellten _id - const newAppointment = { _id: result.insertedId, ...appointmentData }; - - // Sende den neu erstellten Termin zurück an das Frontend (Status 201 Created) - res.status(201).json(newAppointment); - - } catch (error) { - console.error("Fehler beim Erstellen des Termins:", error); - res.status(500).json({ message: "Interner Serverfehler beim Speichern der Daten." }); - } -}); - -// 3. UPDATE: Termin aktualisieren (PUT /api/appointments/:id) -app.put('/api/appointments/:id', async (req, res) => { - try { - const db = getDb(); - const { id } = req.params; - const updatedFields = req.body; // Enthält alle Felder (oder nur die geänderten) - - // Die _id des Dokuments muss ein gültiges ObjectId-Objekt sein - const filter = { _id: new ObjectId(id) }; - - // Entferne das _id Feld aus den aktualisierten Daten, um Fehler zu vermeiden - delete updatedFields._id; - - // Führe die Aktualisierung durch - const result = await db.collection('appointments').updateOne( - filter, - { $set: updatedFields } - ); - - if (result.matchedCount === 0) { - return res.status(404).json({ message: "Termin nicht gefunden." }); - } - - // Hole das aktualisierte Dokument, um es zurückzusenden (optional, aber nützlich) - const updatedAppointment = await db.collection('appointments').findOne(filter); - - res.json(updatedAppointment); - - } catch (error) { - console.error(`Fehler beim Aktualisieren des Termins ${req.params.id}:`, error); - // Gib einen 400 Bad Request zurück, falls die ID ungültig ist (z.B. falsches Format) - res.status(400).json({ message: "Ungültige ID oder Serverfehler." }); - } -}); - -// 4. DELETE: Termin löschen (DELETE /api/appointments/:id) -// Soft Delete: Setzt das deleted-Flag statt den Eintrag zu löschen -app.delete('/api/appointments/:id', async (req, res) => { - try { - const db = getDb(); - const { id } = req.params; - - const filter = { _id: new ObjectId(id) }; - - // Setze das deleted-Flag auf true statt den Eintrag zu löschen - const result = await db.collection('appointments').updateOne( - filter, - { $set: { deleted: true, deletedAt: new Date() } } - ); - - if (result.matchedCount === 0) { - return res.status(404).json({ message: "Termin nicht gefunden." }); - } - - // Sende Status 204 (No Content), um den erfolgreichen Löschvorgang zu signalisieren - res.status(204).send(); - - } catch (error) { - console.error(`Fehler beim Löschen des Termins ${req.params.id}:`, error); - res.status(400).json({ message: "Ungültige ID oder Serverfehler." }); - } -}); - - -// ----------------------------------------------------- -// Server Start Logik -// ----------------------------------------------------- -async function startServer() { - try { - await connectToDb(); - - app.listen(PORT, () => { - console.log(`🚀 Server läuft auf Port ${PORT}`); - }); - - } catch (error) { - console.error("Fehler beim Starten des Servers:", error); - } -} - -startServer(); \ No newline at end of file diff --git a/components/AppointmentForm.tsx b/components/AppointmentForm.tsx new file mode 100644 index 0000000..5c84577 --- /dev/null +++ b/components/AppointmentForm.tsx @@ -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(() => + buildInitialForm(editingAppointment) + ); + + const handleChange = ( + e: React.ChangeEvent + ) => { + 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 ( +
+ {editingAppointment && ( +
+ ℹ️ Bearbeitungsmodus: Sie bearbeiten einen bestehenden + Termin. Klicken Sie auf "Aktualisieren", um die Änderungen zu + speichern, oder "Abbrechen", um zur Neuerfassung + zurückzukehren. +
+ )} +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +