First commit - es tut schon mal ganz gut

This commit is contained in:
2026-02-27 12:35:29 +00:00
commit 53124c1c78
27 changed files with 8403 additions and 0 deletions

11
.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
node_modules
.next
.git
.gitignore
README.md
.env
.env.example
.env*.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

4
.env.example Normal file
View File

@@ -0,0 +1,4 @@
DB_HOST=localhost
DB_USER=root
DB_PASSWORD=your_password
DB_NAME=RXF

41
.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# env files
.env
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
audit-level=high

193
DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,193 @@
# Deployment Guide - Ausgaben-Next
## Lokale Entwicklung
### Voraussetzungen
- Node.js 22+
- MySQL 8+ (Docker container läuft bereits)
- npm oder yarn
### Setup
1. **Dependencies installieren:**
```bash
npm install
```
2. **Umgebungsvariablen konfigurieren:**
```bash
cp .env.example .env
# Bearbeiten Sie .env mit Ihren Datenbankzugangsdaten
```
3. **Development Server starten:**
```bash
npm run dev
```
Die Anwendung ist dann unter http://localhost:3000 erreichbar.
## Production Deployment
### Mit Docker (Empfohlen)
1. **Lokaler Build:**
```bash
docker-compose -f docker-compose.local.yml build
docker-compose -f docker-compose.local.yml up -d
```
Die Anwendung läuft dann auf Port 3001 (konfigurierbar in docker-compose.local.yml).
2. **Logs prüfen:**
```bash
docker logs ausgaben-next-app
```
### Ohne Docker
1. **Production Build:**
```bash
npm run build
```
2. **Production Server starten:**
```bash
npm start
```
## Datenbank
### Schema erstellen
Falls die Tabelle `Ausgaben_Tag` noch nicht existiert:
```bash
mysql -u root -p RXF < create_table.sql
```
### Datenbank-Verbindung
Die Anwendung verwendet die gleiche MySQL-Datenbank wie die alte Ausgaben-Anwendung:
- **Host:** gitea-db (oder localhost für lokale Entwicklung)
- **Database:** RXF
- **Table:** Ausgaben_Tag
Die Zugangsdaten werden über Umgebungsvariablen in `.env` konfiguriert.
## Umgebungsvariablen
Erforderliche Variablen in `.env`:
```env
DB_HOST=gitea-db # oder localhost für lokale Entwicklung
DB_USER=root
DB_PASS=Ihr_Passwort
DB_NAME=RXF
```
## Port-Konfiguration
- **Development:** Port 3000 (npm run dev)
- **Production (lokal):** Port 3000 (npm start)
- **Docker (lokal):** Port 3001 (gemappt auf internen Port 3000)
Ändern Sie den Port in `docker-compose.local.yml` falls nötig:
```yaml
ports:
- "0.0.0.0:DEIN_PORT:3000"
```
## Troubleshooting
### Datenbankverbindung schlägt fehl
1. Prüfen Sie die Umgebungsvariablen in `.env`
2. Stellen Sie sicher, dass der MySQL-Container läuft:
```bash
docker ps | grep mysql
```
3. Falls Docker verwendet wird, prüfen Sie ob die Container im gleichen Netzwerk sind
### Build-Fehler
1. Löschen Sie `.next` und `node_modules`:
```bash
rm -rf .next node_modules
npm install
npm run build
```
2. Prüfen Sie die Node.js Version:
```bash
node --version # Sollte >= 22.x sein
```
### Port bereits belegt
Ändern Sie den Port in `docker-compose.local.yml` oder verwenden Sie einen anderen Port:
```bash
PORT=3002 npm run dev
```
## Monitoring
### Logs ansehen
**Docker:**
```bash
docker logs -f ausgaben-next-app
```
**Ohne Docker:**
Logs werden in der Konsole angezeigt wo `npm start` läuft.
## Updates
1. **Code aktualisieren:**
```bash
git pull # falls Git verwendet wird
```
2. **Dependencies aktualisieren:**
```bash
npm install
```
3. **Neu bauen und starten:**
```bash
npm run build
# Dann entweder:
npm start
# oder:
docker-compose -f docker-compose.local.yml up -d --build
```
## Backup
### Datenbank
Die Datenbank sollte regelmäßig gesichert werden:
```bash
mysqldump -u root -p RXF Ausgaben_Tag > backup_$(date +%Y%m%d).sql
```
### Application Files
Wichtige Dateien für Backup:
- `.env` (Konfiguration)
- `public/` (falls custom Dateien hinzugefügt wurden)
- Die Datenbank (siehe oben)
## Security
1. **Niemals `.env` ins Git-Repository einchecken!**
2. Verwenden Sie starke Datenbankpasswörter
3. Halten Sie Dependencies aktuell:
```bash
npm audit
npm update
```

51
Dockerfile Normal file
View File

@@ -0,0 +1,51 @@
# Multi-stage build for Next.js application
FROM node:22-alpine AS base
# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Copy package files
COPY package.json package-lock.json* ./
RUN npm ci
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Set build date as build argument
ARG BUILD_DATE
ENV NEXT_PUBLIC_BUILD_DATE=${BUILD_DATE}
# Disable telemetry during build
ENV NEXT_TELEMETRY_DISABLED=1
# Build the application
RUN npm run build
# Production image, copy all the files and run next
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 necessary files
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"]

219
MIGRATION.md Normal file
View File

@@ -0,0 +1,219 @@
# Migration Guide: Von Ausgaben (PHP/jQuery) zu Ausgaben-Next
Dieser Leitfaden hilft Ihnen bei der Migration von der alten PHP/jQuery-basierten Ausgaben-Anwendung zur neuen Next.js-Version.
## Übersicht
### Alte Version (`../Ausgaben`)
- **Frontend:** HTML + jQuery + jqGrid
- **Backend:** PHP
- **Styling:** Custom CSS
- **Datums-Handling:** jQuery UI Datepicker
- **Tab-System:** jQuery UI Tabs
### Neue Version (`ausgaben-next`)
- **Frontend:** React 19 + TypeScript
- **Backend:** Next.js API Routes
- **Styling:** Tailwind CSS v4
- **Datums-Handling:** Native HTML5 Date Input
- **Tab-System:** React State Management
## Was bleibt gleich?
**Datenbank:** Gleiche MySQL-Datenbank (`RXF.Ausgaben_Tag`)
**Datenstruktur:** Identische Tabellenfelder
**Funktionalität:** Alle Features der alten Version
**Look & Feel:** Ähnliches Design (angelehnt an werte-next)
## Feature-Vergleich
| Feature | Alte Version | Neue Version | Status |
|---------|-------------|--------------|---------|
| Eingabe-Formular | ✓ jQuery | ✓ React | ✓ Implementiert |
| Letzte 10 Einträge | ✓ jqGrid | ✓ React Table | ✓ Implementiert |
| Liste aller Einträge | ✓ jqGrid | ✓ React Table | ✓ Implementiert |
| Monats-Statistiken | ✓ PHP + AJAX | ✓ API Routes | ✓ Implementiert |
| Bearbeiten | ✓ | ✓ | ✓ Implementiert |
| Löschen | ✓ | ✓ | ✓ Implementiert |
| Datepicker | ✓ jQuery UI | ✓ HTML5 | ✓ Implementiert |
| Wochentag auto | ✓ | ✓ | ✓ Implementiert |
| OK-Checkbox | ✓ | ✓ | ✓ Implementiert |
| Tab-Navigation | ✓ jQuery UI | ✓ React | ✓ Implementiert |
## Datenbank
Die Datenbanktabelle bleibt **unverändert**:
```sql
Ausgaben_Tag (
ID int(11) AUTO_INCREMENT,
Datum date,
WochTag varchar(20),
Wo varchar(255),
Was varchar(500),
Wieviel decimal(10,2),
Wie varchar(50),
OK tinyint(1)
)
```
### Wichtig:
- Beide Anwendungen können parallel auf **derselben Datenbank** laufen!
- Keine Datenmigration notwendig
- Bestehende Daten werden automatisch übernommen
## Parallelbetrieb
Sie können beide Versionen gleichzeitig laufen lassen:
1. **Alte Version:** Bleibt unter ihrem bisherigen Pfad aktiv
2. **Neue Version:** Läuft auf eigenem Port (z.B. 3001)
3. **Datenbank:** Wird von beiden genutzt
### Vorteile:
- Sanfte Migration ohne Downtime
- Training mit neuer Version möglich
- Fallback zur alten Version jederzeit möglich
- Schrittweise Umstellung der Nutzer
## Unterschiede im Verhalten
### 1. Datepicker
**Alt:** jQuery UI Datepicker mit Pop-up
**Neu:** Native HTML5 Date Input (Browser-native Erscheinung)
### 2. Auto-Complete
**Alt:** jQuery Autocomplete für "Wo" und "Was" Felder
**Neu:** Noch nicht implementiert (kann später hinzugefügt werden)
### 3. Tab-Umschaltung
**Alt:** Seite bleibt identisch, nur Tabs wechseln
**Neu:** React State Management, kein Page Reload
### 4. Datums-Formatierung
**Alt:** Server-seitig (PHP) formatiert
**Neu:** Client-seitig (JavaScript Intl API)
## Migrationsschritte
### Schritt 1: Installation
```bash
cd /home/rxf/Projekte/ausgaben-next
npm install
```
### Schritt 2: Umgebungsvariablen
```bash
cp .env.example .env
# Bearbeiten Sie .env und verwenden Sie die gleichen
# Datenbankzugangsdaten wie die alte Version
```
### Schritt 3: Test
```bash
npm run dev
```
Öffnen Sie http://localhost:3000 und testen Sie die Funktionalität.
### Schritt 4: Production Build
```bash
npm run build
```
### Schritt 5: Docker Deployment (Optional)
```bash
docker-compose -f docker-compose.local.yml up -d
```
### Schritt 6: Parallelbetrieb testen
- Alte Version: Weiter unter bisheriger URL
- Neue Version: Unter Port 3001 (oder konfiguriert)
- Beide greifen auf gleiche Datenbank zu
### Schritt 7: Umstellung
Wenn Sie mit der neuen Version zufrieden sind:
1. Ändern Sie die Ports/URLs in Ihrer Infrastruktur
2. Leiten Sie Traffic zur neuen Version um
3. Behalten Sie alte Version als Backup
## Was wurde verbessert?
### Performance
- ✓ Schnelleres Loading durch Server-Side Rendering
- ✓ Optimierte Bundles durch Webpack/Turbopack
- ✓ Lazy Loading von Komponenten
### Entwicklung
- ✓ TypeScript für bessere Code-Qualität
- ✓ Moderne React Patterns (Hooks)
- ✓ Wiederverwendbare Komponenten
### Wartbarkeit
- ✓ Klare Trennung von Logik und Präsentation
- ✓ Testbare Komponenten
- ✓ Moderne Toolchain
### Security
- ✓ Aktuellere Dependencies
- ✓ Keine direct SQL-Queries im Frontend
- ✓ Input Validation
## Bekannte Einschränkungen
1. **Auto-Complete:** Noch nicht implementiert für "Wo" und "Was" Felder
2. **Drucken:** Print-Funktionalität noch nicht implementiert
3. **Anleitung-Seite:** Noch nicht erstellt (kann kopiert werden)
Diese Features können bei Bedarf hinzugefügt werden.
## Rollback-Plan
Falls Sie zur alten Version zurück müssen:
1. Stoppen Sie den Next.js Server/Container
2. Die alte PHP-Version funktioniert weiterhin
3. Keine Datenbank-Änderungen notwendig
## Support und Wartung
### Alte Version
- PHP 7.4+ erforderlich
- jQuery und jQuery UI müssen geladen bleiben
- Keine aktive Weiterentwicklung geplant
### Neue Version
- Node.js 22+
- Regelmäßige Dependency-Updates empfohlen
- Moderne Stack ermöglicht einfache Erweiterungen
## Nächste Schritte
Nach erfolgreicher Migration:
1. **Auto-Complete implementieren** (optional)
- Suche nach häufigen "Wo"-Einträgen
- Vorschläge für "Was"-Einträge
2. **Print-Funktion** (optional)
- CSS für Print-Layout
- Export als PDF
3. **Anleitung-Seite**
- Kopieren Sie die alte Anleitung
- Passen Sie sie an die neue UI an
4. **Mobile Optimierung**
- Responsive Design ist bereits vorhanden
- Kann weiter optimiert werden
5. **Erweiterte Statistiken**
- Jahresübersichten
- Grafische Darstellungen
- Kategorien-Auswertungen
## Fragen?
Bei Problemen oder Fragen:
- Prüfen Sie [DEPLOYMENT.md](DEPLOYMENT.md)
- Lesen Sie [README.md](README.md)
- Kontakt: rxf@gmx.de

130
README.md Normal file
View File

@@ -0,0 +1,130 @@
# Ausgaben - Log (Next.js)
Moderne Ausgaben-Tracking-Anwendung mit Next.js, React, TypeScript und MySQL.
## Übersicht
Dies ist die modernisierte Version des alten PHP/jQuery-basierten Ausgaben-Programms. Die Anwendung wurde neu in Next.js/React/TypeScript entwickelt und verwendet das gleiche Look & Feel wie die werte-next Anwendung.
## Features
- **Eingabe-Tab**: Erfassen von Ausgaben mit:
- Datum (mit automatischem Wochentag)
- Wo (Geschäft/Ort)
- Was (Beschreibung)
- Wieviel (Betrag in Euro)
- Wie (Zahlungsart: bar, EC, VISA, MASTER, Einnahme, Überweisung)
- OK-Checkbox für Kontrolle
- Monatsstatistiken
- Letzte 10 Einträge
- **Listen-Tab**: Vollständige Auflistung aller Einträge mit:
- Bearbeiten-Funktion
- Löschen-Funktion
- Sortierung nach Datum (absteigend)
- **Statistik-Tab**: Monatliche Auswertungen mit:
- Gesamtausgaben
- Aufschlüsselung nach Zahlungsart
- Einnahmen
- Überweisungen
## Technologie-Stack
- **Frontend**: Next.js 16, React 19, TypeScript
- **Styling**: Tailwind CSS v4
- **Datenbank**: MySQL 8 (via Docker)
- **ORM**: mysql2
## Installation
1. Repository klonen / Dateien kopieren
2. Dependencies installieren:
```bash
npm install
```
3. Umgebungsvariablen konfigurieren:
```bash
cp .env.example .env
```
Bearbeiten Sie `.env` und passen Sie die Datenbankverbindung an:
```
DB_HOST=localhost
DB_USER=root
DB_PASSWORD=ihr_passwort
DB_NAME=RXF
```
4. Datenbank-Tabelle erstellen (falls noch nicht vorhanden):
```bash
mysql -u root -p RXF < create_table.sql
```
## Entwicklung
Development-Server starten:
```bash
npm run dev
```
Die Anwendung ist dann unter [http://localhost:3000](http://localhost:3000) erreichbar.
## Production Build
```bash
npm run build
npm start
```
## Datenbank-Schema
Die Anwendung verwendet die Tabelle `Ausgaben_Tag` mit folgenden Feldern:
- `ID` (auto_increment)
- `Datum` (date)
- `WochTag` (varchar)
- `Wo` (varchar) - Geschäft/Ort
- `Was` (varchar) - Beschreibung
- `Wieviel` (decimal) - Betrag
- `Wie` (varchar) - Zahlungsart
- `OK` (tinyint) - Kontrollstatus
## API Endpoints
- `GET /api/ausgaben` - Einträge abrufen (mit limit, startDate, month, year params)
- `POST /api/ausgaben` - Neuen Eintrag erstellen
- `PUT /api/ausgaben/[id]` - Eintrag aktualisieren
- `DELETE /api/ausgaben/[id]` - Eintrag löschen
- `GET /api/ausgaben/stats` - Monatsstatistiken (mit month, year params)
## Migration von der alten Version
Die alte PHP/jQuery-Version in `../Ausgaben` kann parallel weiter betrieben werden. Die Datenbank wird von beiden Anwendungen verwendet.
### Unterschiede zur alten Version:
- Moderne React-basierte UI statt jQuery
- TypeScript statt JavaScript
- REST API statt direkte PHP-Aufrufe
- Tailwind CSS statt custom CSS
- Client-side Rendering statt Server-side (PHP)
## Look & Feel
Das Design orientiert sich an der `werte-next` Anwendung:
- Gelber Hintergrund (#FFFFDD)
- Schwarze Rahmen um Hauptcontainer
- Blaue Akzente für Eingabebereich (#CCCCFF)
- Klare Tabellenstruktur
- Responsive Design
## Autor
rxf@gmx.de
## Version
1.0.0

View File

@@ -0,0 +1,82 @@
import { NextResponse } from 'next/server';
import { getDbPool } from '@/lib/db';
import { ResultSetHeader } from 'mysql2';
// PUT /api/ausgaben/[id] - Update entry
export async function PUT(
request: Request,
context: { params: Promise<{ id: string }> }
) {
try {
const { id } = await context.params;
const body = await request.json();
const { Datum, WochTag, Wo, Was, Wieviel, Wie, OK } = body;
const pool = getDbPool();
const query = `
UPDATE Ausgaben_Tag
SET Datum = ?, WochTag = ?, Wo = ?, Was = ?, Wieviel = ?, Wie = ?, OK = ?
WHERE ID = ?
`;
const [result] = await pool.query<ResultSetHeader>(query, [
Datum,
WochTag,
Wo,
Was,
parseFloat(Wieviel),
Wie,
OK || 0,
parseInt(id),
]);
if (result.affectedRows === 0) {
return NextResponse.json(
{ success: false, error: 'Entry not found' },
{ status: 404 }
);
}
return NextResponse.json({
success: true,
});
} catch (error) {
console.error('Database error:', error);
return NextResponse.json(
{ success: false, error: 'Database error' },
{ status: 500 }
);
}
}
// DELETE /api/ausgaben/[id] - Delete entry
export async function DELETE(
request: Request,
context: { params: Promise<{ id: string }> }
) {
try {
const { id } = await context.params;
const pool = getDbPool();
const query = 'DELETE FROM Ausgaben_Tag WHERE ID = ?';
const [result] = await pool.query<ResultSetHeader>(query, [parseInt(id)]);
if (result.affectedRows === 0) {
return NextResponse.json(
{ success: false, error: 'Entry not found' },
{ status: 404 }
);
}
return NextResponse.json({
success: true,
});
} catch (error) {
console.error('Database error:', error);
return NextResponse.json(
{ success: false, error: 'Database error' },
{ status: 500 }
);
}
}

86
app/api/ausgaben/route.ts Normal file
View File

@@ -0,0 +1,86 @@
import { NextResponse } from 'next/server';
import { getDbPool } from '@/lib/db';
import { RowDataPacket, ResultSetHeader } from 'mysql2';
// GET /api/ausgaben - Fetch entries
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const limit = searchParams.get('limit') || '10';
const startDate = searchParams.get('startDate');
const month = searchParams.get('month');
const year = searchParams.get('year');
const pool = getDbPool();
let query = 'SELECT * FROM Ausgaben_Tag';
const params: any[] = [];
if (month && year) {
query += ' WHERE YEAR(Datum) = ? AND MONTH(Datum) = ?';
params.push(year, month);
} else if (startDate) {
query += ' WHERE Datum >= ?';
params.push(startDate);
}
query += ' ORDER BY Datum DESC, ID DESC LIMIT ?';
params.push(parseInt(limit));
const [rows] = await pool.query<RowDataPacket[]>(query, params);
return NextResponse.json({
success: true,
data: rows,
});
} catch (error) {
console.error('Database error:', error);
return NextResponse.json(
{ success: false, error: 'Database error' },
{ status: 500 }
);
}
}
// POST /api/ausgaben - Create new entry
export async function POST(request: Request) {
try {
const body = await request.json();
const { Datum, WochTag, Wo, Was, Wieviel, Wie, OK } = body;
if (!Datum || !Wo || !Was || !Wieviel || !Wie) {
return NextResponse.json(
{ success: false, error: 'Missing required fields' },
{ status: 400 }
);
}
const pool = getDbPool();
const query = `
INSERT INTO Ausgaben_Tag (Datum, WochTag, Wo, Was, Wieviel, Wie, OK)
VALUES (?, ?, ?, ?, ?, ?, ?)
`;
const [result] = await pool.query<ResultSetHeader>(query, [
Datum,
WochTag,
Wo,
Was,
parseFloat(Wieviel),
Wie,
OK || 0,
]);
return NextResponse.json({
success: true,
id: result.insertId,
});
} catch (error) {
console.error('Database error:', error);
return NextResponse.json(
{ success: false, error: 'Database error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,69 @@
import { NextResponse } from 'next/server';
import { getDbPool } from '@/lib/db';
import { RowDataPacket } from 'mysql2';
// GET /api/ausgaben/stats - Get monthly statistics
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const month = searchParams.get('month');
const year = searchParams.get('year');
if (!month || !year) {
return NextResponse.json(
{ success: false, error: 'Month and year are required' },
{ status: 400 }
);
}
const pool = getDbPool();
// Get total ausgaben and breakdown by payment type
const query = `
SELECT
SUM(CASE WHEN Wie IN ('EC-R', 'EC-B', 'bar-R', 'bar-B', 'Ueber') THEN Wieviel ELSE 0 END) as totalAusgaben,
SUM(CASE WHEN Wie = 'EC-R' THEN Wieviel ELSE 0 END) as ECR,
SUM(CASE WHEN Wie = 'EC-B' THEN Wieviel ELSE 0 END) as ECB,
SUM(CASE WHEN Wie = 'bar-R' THEN Wieviel ELSE 0 END) as barR,
SUM(CASE WHEN Wie = 'bar-B' THEN Wieviel ELSE 0 END) as barB,
SUM(CASE WHEN Wie = 'Einnahme' THEN Wieviel ELSE 0 END) as Einnahmen,
SUM(CASE WHEN Wie = 'Ueber' THEN Wieviel ELSE 0 END) as Ueberweisungen
FROM Ausgaben_Tag
WHERE YEAR(Datum) = ? AND MONTH(Datum) = ?
`;
const [rows] = await pool.query<RowDataPacket[]>(query, [year, month]);
const data = rows[0] || {
totalAusgaben: 0,
ECR: 0,
ECB: 0,
barR: 0,
barB: 0,
Einnahmen: 0,
Ueberweisungen: 0,
};
// Convert string values from MySQL to numbers
const parsedData = {
totalAusgaben: parseFloat(data.totalAusgaben) || 0,
ECR: parseFloat(data.ECR) || 0,
ECB: parseFloat(data.ECB) || 0,
barR: parseFloat(data.barR) || 0,
barB: parseFloat(data.barB) || 0,
Einnahmen: parseFloat(data.Einnahmen) || 0,
Ueberweisungen: parseFloat(data.Ueberweisungen) || 0,
};
return NextResponse.json({
success: true,
data: parsedData,
});
} catch (error) {
console.error('Database error:', error);
return NextResponse.json(
{ success: false, error: 'Database error' },
{ status: 500 }
);
}
}

18
app/globals.css Normal file
View File

@@ -0,0 +1,18 @@
/* stylelint-disable at-rule-no-unknown */
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}

21
app/layout.tsx Normal file
View File

@@ -0,0 +1,21 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Ausgaben - Log",
description: "Ausgaben-Tracking Anwendung",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="de">
<body className="antialiased">
{children}
</body>
</html>
);
}

89
app/page.tsx Normal file
View File

@@ -0,0 +1,89 @@
'use client';
import { useState, useEffect } from 'react';
import AusgabenForm from '@/components/AusgabenForm';
import AusgabenList from '@/components/AusgabenList';
import { AusgabenEntry } from '@/types/ausgaben';
import packageJson from '@/package.json';
export default function Home() {
const [entries, setEntries] = useState<AusgabenEntry[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [selectedEntry, setSelectedEntry] = useState<AusgabenEntry | null>(null);
const version = packageJson.version;
useEffect(() => {
fetchRecentEntries();
}, []);
const fetchRecentEntries = async () => {
setIsLoading(true);
try {
const response = await fetch('/api/ausgaben?limit=10', {
cache: 'no-store',
headers: {
'Cache-Control': 'no-cache',
},
});
const data = await response.json();
if (data.success) {
setEntries(data.data);
}
} catch (error) {
console.error('Error fetching entries:', error);
} finally {
setIsLoading(false);
}
};
const handleSuccess = () => {
setSelectedEntry(null);
setTimeout(() => {
fetchRecentEntries();
}, 100);
};
const handleDelete = (id: number) => {
setEntries(entries.filter(entry => entry.ID !== id));
};
const handleEdit = (entry: AusgabenEntry) => {
setSelectedEntry(entry);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
return (
<div className="min-h-screen bg-white py-4 px-4">
<main className="max-w-7xl mx-auto border-2 border-black rounded-lg p-6 bg-[#FFFFDD]">
<h1 className="text-3xl font-bold text-center mb-6">Ausgaben - Log</h1>
<div>
<h2 className="text-xl font-semibold mb-4">Eingabe</h2>
<AusgabenForm onSuccess={handleSuccess} selectedEntry={selectedEntry} />
<div className="mt-6 bg-white border border-black rounded-lg shadow-md p-6">
<h3 className="text-xl font-semibold mb-4">Letzte 10 Einträge</h3>
{isLoading ? (
<div className="text-center py-4">Lade Daten...</div>
) : (
<AusgabenList entries={entries} onDelete={handleDelete} onEdit={handleEdit} />
)}
</div>
</div>
{/* Footer */}
<footer className="mt-8 flex justify-between items-center text-sm text-gray-600 px-4 border-t-2 border-black pt-4">
<div>
<a href="mailto:rxf@gmx.de" className="text-blue-600 hover:underline">
mailto:rxf@gmx.de
</a>
</div>
<div className="text-right">
Version {version}
</div>
</footer>
</main>
</div>
);
}

335
components/AusgabenForm.tsx Normal file
View File

@@ -0,0 +1,335 @@
'use client';
import { useState, useEffect } from 'react';
import { CreateAusgabenEntry, AusgabenEntry, ZAHLUNGSARTEN, Zahlungsart, MonthlyStats } from '@/types/ausgaben';
interface AusgabenFormProps {
onSuccess: () => void;
selectedEntry?: AusgabenEntry | null;
}
export default function AusgabenForm({ onSuccess, selectedEntry }: AusgabenFormProps) {
const [formData, setFormData] = useState<CreateAusgabenEntry>({
Datum: '',
WochTag: '',
Wo: '',
Was: '',
Wieviel: '',
Wie: 'EC-R',
OK: 0,
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [editId, setEditId] = useState<number | null>(null);
// Monthly stats
const [stats, setStats] = useState<MonthlyStats | null>(null);
const [month, setMonth] = useState('');
const [year, setYear] = useState('');
const [isLoadingStats, setIsLoadingStats] = useState(false);
// Initialize stats with current month/year
useEffect(() => {
const now = new Date();
const currentMonth = String(now.getMonth() + 1).padStart(2, '0');
const currentYear = String(now.getFullYear());
setMonth(currentMonth);
setYear(currentYear);
fetchStats(currentYear, currentMonth);
}, []);
const fetchStats = async (y: string, m: string) => {
if (!y || !m) return;
setIsLoadingStats(true);
try {
const response = await fetch(`/api/ausgaben/stats?year=${y}&month=${m}`);
const data = await response.json();
if (data.success) {
setStats(data.data);
}
} catch (error) {
console.error('Error fetching stats:', error);
} finally {
setIsLoadingStats(false);
}
};
const handleMonthChange = (newMonth: string) => {
setMonth(newMonth);
fetchStats(year, newMonth);
};
const handleYearChange = (newYear: string) => {
setYear(newYear);
fetchStats(newYear, month);
};
const formatAmount = (amount: number | null) => {
if (amount === null || amount === undefined) return '0,00 €';
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(amount);
};
useEffect(() => {
if (selectedEntry) {
// Load selected entry for editing
const dateStr = selectedEntry.Datum.toString().split('T')[0];
setFormData({
Datum: dateStr,
WochTag: selectedEntry.WochTag,
Wo: selectedEntry.Wo,
Was: selectedEntry.Was,
Wieviel: selectedEntry.Wieviel.toString(),
Wie: selectedEntry.Wie,
OK: selectedEntry.OK || 0,
});
setEditId(selectedEntry.ID);
} else {
// Initialize with current date for new entry
const now = new Date();
const dateStr = now.toISOString().split('T')[0];
const weekday = getWeekday(now);
setFormData(prev => ({
...prev,
Datum: dateStr,
WochTag: weekday,
}));
setEditId(null);
}
}, [selectedEntry]);
const getWeekday = (date: Date): string => {
const weekdays = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'];
return weekdays[date.getDay()];
};
const handleDateChange = (dateStr: string) => {
const [year, month, day] = dateStr.split('-');
const date = new Date(Number(year), Number(month) - 1, Number(day));
const weekday = getWeekday(date);
setFormData(prev => ({ ...prev, Datum: dateStr, WochTag: weekday }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.Wo || !formData.Was || !formData.Wieviel) {
alert('Bitte alle Pflichtfelder ausfüllen!');
return;
}
setIsSubmitting(true);
try {
const url = editId ? `/api/ausgaben/${editId}` : '/api/ausgaben';
const method = editId ? 'PUT' : 'POST';
const response = await fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(formData),
});
if (response.ok) {
handleReset();
onSuccess();
// Refresh stats after successful save
fetchStats(year, month);
} else {
alert('Fehler beim Speichern!');
}
} catch (error) {
console.error('Error:', error);
alert('Fehler beim Speichern!');
} finally {
setIsSubmitting(false);
}
};
const handleReset = () => {
const now = new Date();
const dateStr = now.toISOString().split('T')[0];
const weekday = getWeekday(now);
setFormData({
Datum: dateStr,
WochTag: weekday,
Wo: '',
Was: '',
Wieviel: '',
Wie: 'EC-R',
OK: 0,
});
setEditId(null);
};
return (
<div className="bg-[#CCCCFF] border border-black p-6 rounded-lg mb-6">
{editId && (
<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 Eintrag.
</div>
)}
<form onSubmit={handleSubmit}>
<table className="w-full text-center">
<thead>
<tr >
<th className="p-2 w-32">Datum</th>
<th className="p-2">Wo</th>
<th className="p-2">Was</th>
<th className="p-2 w-24">Wieviel</th>
<th className="p-2 w-4"></th>
<th className="p-2 w-38 text-left">Wie</th>
</tr>
</thead>
<tbody>
<tr>
<td className="p-2 w-32">
<input
type="date"
value={formData.Datum}
onChange={(e) => handleDateChange(e.target.value)}
className="w-full px-2 py-1 text-base rounded border-2 border-gray-400 bg-white focus:border-blue-500 focus:outline-none"
required
/>
</td>
<td className="p-2">
<input
type="text"
value={formData.Wo}
onChange={(e) => setFormData({ ...formData, Wo: e.target.value })}
className="w-full px-2 py-1 text-base rounded border-2 border-gray-400 bg-white focus:border-blue-500 focus:outline-none"
placeholder="Geschäft/Ort"
required
/>
</td>
<td className="p-2">
<input
type="text"
value={formData.Was}
onChange={(e) => setFormData({ ...formData, Was: e.target.value })}
className="w-full px-2 py-1 text-base rounded border-2 border-gray-400 bg-white focus:border-blue-500 focus:outline-none"
placeholder="Beschreibung"
required
/>
</td>
<td className="p-2 w-24">
<input
type="number"
step="0.01"
value={formData.Wieviel}
onChange={(e) => setFormData({ ...formData, Wieviel: e.target.value })}
className="w-full px-2 py-1 text-base rounded border-2 border-gray-400 bg-white focus:border-blue-500 focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
placeholder="0.00"
required
/>
</td>
<td className="p-2 w-4 text-left"></td>
<td className="p-2 w-38">
<select
value={formData.Wie}
onChange={(e) => setFormData({ ...formData, Wie: e.target.value as Zahlungsart })}
className="w-full px-2 py-1 text-base rounded border-2 border-gray-400 bg-white focus:border-blue-500 focus:outline-none"
required
>
{ZAHLUNGSARTEN.map((za) => (
<option key={za.value} value={za.value}>
{za.label}
</option>
))}
</select>
</td>
</tr>
<tr>
<th className="p-2 w-32">{formData.WochTag}</th>
<th className="p-2"></th>
<th className="p-2"></th>
<th className="p-2"></th>
<th className="p-2"></th>
<th className="p-2"></th>
</tr>
<tr>
<td colSpan={6} className="p-3">
<div className="flex gap-3 justify-center">
<button
type="submit"
disabled={isSubmitting}
className="bg-[#85B7D7] hover:bg-[#6a9fc5] text-black font-medium py-2 px-8 rounded-lg transition-colors disabled:opacity-50"
>
{isSubmitting ? 'Speichere...' : editId ? 'Aktualisieren' : 'Speichern'}
</button>
<button
type="button"
onClick={handleReset}
className="bg-[#85B7D7] hover:bg-[#6a9fc5] text-black font-medium py-2 px-8 rounded-lg transition-colors"
>
Löschen
</button>
</div>
</td>
</tr>
<tr>
<td colSpan={6} className="p-3 pt-6 border-t border-gray-300">
<div className="flex items-center justify-between">
<div className="flex gap-4 items-center">
<label className="font-semibold">Monat:</label>
<select
value={month}
onChange={(e) => handleMonthChange(e.target.value)}
className="border border-gray-400 rounded px-3 py-1"
>
<option value="01">Januar</option>
<option value="02">Februar</option>
<option value="03">März</option>
<option value="04">April</option>
<option value="05">Mai</option>
<option value="06">Juni</option>
<option value="07">Juli</option>
<option value="08">August</option>
<option value="09">September</option>
<option value="10">Oktober</option>
<option value="11">November</option>
<option value="12">Dezember</option>
</select>
<label className="font-semibold">Jahr:</label>
<input
type="number"
value={year}
onChange={(e) => handleYearChange(e.target.value)}
className="border border-gray-400 rounded px-3 py-1 w-24"
min="2013"
max="2099"
/>
</div>
<div>
{isLoadingStats ? (
<span>Lade...</span>
) : stats ? (
<span className="font-bold text-lg">
Summe: {formatAmount(stats.totalAusgaben)}
</span>
) : null}
</div>
</div>
</td>
</tr>
</tbody>
</table>
</form>
</div>
);
}

102
components/AusgabenList.tsx Normal file
View File

@@ -0,0 +1,102 @@
'use client';
import { AusgabenEntry } from '@/types/ausgaben';
interface AusgabenListProps {
entries: AusgabenEntry[];
onDelete: (id: number) => void;
onEdit: (entry: AusgabenEntry) => void;
}
export default function AusgabenList({ entries, onDelete, onEdit }: AusgabenListProps) {
const handleDelete = async (id: number) => {
if (!confirm('Wirklich löschen?')) return;
try {
const response = await fetch(`/api/ausgaben/${id}`, {
method: 'DELETE',
});
if (response.ok) {
onDelete(id);
} else {
alert('Fehler beim Löschen!');
}
} catch (error) {
console.error('Error:', error);
alert('Fehler beim Löschen!');
}
};
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
};
const formatAmount = (amount: number) => {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(amount);
};
return (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-[#CCCCFF]">
<th className="border-b-2 border-black p-2 w-32">Datum</th>
<th className="border-b-2 border-black p-2 w-12">Tag</th>
<th className="border-b-2 border-black p-2 w-36">Wo</th>
<th className="border-b-2 border-black p-2 w-48">Was</th>
<th className="border-b-2 border-black p-2 w-8">Betrag</th>
<th className="border-b-2 border-black p-2 w-16">Wie</th>
<th className="border-b-2 border-black p-2 w-38">Aktion</th>
</tr>
</thead>
<tbody>
{entries.length === 0 ? (
<tr>
<td colSpan={7} className="text-center p-4 text-gray-500">
Keine Einträge vorhanden
</td>
</tr>
) : (
entries.map((entry, index) => (
<tr key={entry.ID} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-100'}>
<td className="border-y border-black p-2 text-center">
{formatDate(entry.Datum)}
</td>
<td className="border-y border-black p-2 text-center">{entry.WochTag.slice(0, 2)}</td>
<td className="border-y border-black p-2">{entry.Wo}</td>
<td className="border-y border-black p-2">{entry.Was}</td>
<td className="border-y border-black p-2 text-right">
{formatAmount(entry.Wieviel)}
</td>
<td className="border-y border-black p-2 text-center">{entry.Wie}</td>
<td className="border-y border-black p-2 text-center">
<button
onClick={() => onEdit(entry)}
className="text-blue-600 hover:text-blue-800 px-3 py-1 rounded text-sm mr-2"
>
Bearbeiten
</button>
<button
onClick={() => handleDelete(entry.ID)}
className="text-red-600 hover:text-red-800 px-3 py-1 rounded text-sm"
>
Löschen
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
);
}

16
create_table.sql Normal file
View File

@@ -0,0 +1,16 @@
-- Tabelle für Ausgaben erstellen
-- Diese Tabelle sollte bereits in der Docker MySQL-Datenbank existieren
-- Falls nicht, hier ist das CREATE Statement:
CREATE TABLE IF NOT EXISTS `Ausgaben_Tag` (
`ID` int(11) NOT NULL AUTO_INCREMENT,
`Datum` date NOT NULL,
`WochTag` varchar(20) DEFAULT NULL,
`Wo` varchar(255) DEFAULT NULL,
`Was` varchar(500) DEFAULT NULL,
`Wieviel` decimal(10,2) NOT NULL,
`Wie` varchar(50) DEFAULT NULL,
`OK` tinyint(1) DEFAULT 0,
PRIMARY KEY (`ID`),
KEY `idx_datum` (`Datum`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

17
docker-compose.local.yml Normal file
View File

@@ -0,0 +1,17 @@
# Docker Compose für lokale Entwicklung auf Esprimo (ohne Traefik)
services:
ausgaben-app:
build:
context: .
args:
BUILD_DATE: ${BUILD_DATE:-$(date +%d.%m.%Y)}
container_name: ausgaben-next-app
restart: unless-stopped
ports:
- "0.0.0.0:3005:3000"
environment:
- NODE_ENV=production
- DB_HOST=${DB_HOST}
- DB_USER=${DB_USER}
- DB_PASS=${DB_PASS}
- DB_NAME=${DB_NAME}

16
eslint.config.mjs Normal file
View File

@@ -0,0 +1,16 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
];
export default eslintConfig;

18
lib/db.ts Normal file
View File

@@ -0,0 +1,18 @@
import mysql from 'mysql2/promise';
let pool: mysql.Pool | null = null;
export function getDbPool() {
if (!pool) {
pool = mysql.createPool({
host: process.env.DB_HOST || 'localhost',
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '',
database: process.env.DB_NAME || 'RXF',
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
});
}
return pool;
}

7
next.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: 'standalone',
};
export default nextConfig;

6716
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "ausgaben_next",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev -p 3005",
"build": "next build",
"start": "next start -p 3005",
"lint": "eslint"
},
"dependencies": {
"mysql2": "^3.17.4",
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"tailwindcss": "^4",
"typescript": "^5"
}
}

8
postcss.config.mjs Normal file
View File

@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
};
export default config;

42
setup.sh Executable file
View File

@@ -0,0 +1,42 @@
#!/bin/bash
# Ausgaben-Next Setup Script
echo "==================================="
echo "Ausgaben-Next Setup"
echo "==================================="
echo ""
# Check if .env exists
if [ ! -f ".env" ]; then
echo "Creating .env file from template..."
cp .env.example .env
echo "✓ .env created. Please edit it with your database credentials!"
echo ""
fi
# Install dependencies
echo "Installing dependencies..."
npm install
echo "✓ Dependencies installed"
echo ""
# Build the project
echo "Building project..."
npm run build
echo "✓ Build complete"
echo ""
echo "==================================="
echo "Setup complete!"
echo "==================================="
echo ""
echo "To start the development server:"
echo " npm run dev"
echo ""
echo "To start the production server:"
echo " npm start"
echo ""
echo "To build for Docker:"
echo " docker-compose -f docker-compose.local.yml up -d"
echo ""

41
tsconfig.json Normal file
View File

@@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}

43
types/ausgaben.ts Normal file
View File

@@ -0,0 +1,43 @@
// TypeScript interfaces for Ausgaben application
export interface AusgabenEntry {
ID: number;
Datum: string;
WochTag: string;
Wo: string;
Was: string;
Wieviel: number;
Wie: string;
OK?: number;
}
export interface CreateAusgabenEntry {
Datum: string;
WochTag: string;
Wo: string;
Was: string;
Wieviel: string | number;
Wie: string;
OK?: number;
}
export interface MonthlyStats {
totalAusgaben: number;
ECR: number;
ECB: number;
barR: number;
barB: number;
Einnahmen: number;
Ueberweisungen: number;
}
export type Zahlungsart = 'EC-R' | 'EC-B' | 'bar-R' | 'bar-B' | 'Einnahme' | 'Ueber';
export const ZAHLUNGSARTEN: { value: Zahlungsart; label: string }[] = [
{ value: 'EC-R', label: 'EC-R' },
{ value: 'EC-B', label: 'EC-B' },
{ value: 'bar-R', label: 'bar-R' },
{ value: 'bar-B', label: 'bar-B' },
{ value: 'Einnahme', label: 'Einnahme' },
{ value: 'Ueber', label: 'Überweisung' },
];