Compare commits

10 Commits
auth ... main

Author SHA1 Message Date
rxf
a7863c519f V 2.0.2 Nun mit Tabs
Anzahl in der Liste als const
2026-03-06 13:36:21 +01:00
rxf
204bf3bf8b V 1.2.2. Environment f. DB_PASS angepasst 2026-03-06 13:09:29 +01:00
rxf
46678cb644 V 1.2.1 Multiplatfoprm Build
Version auch auf der Login-Seite
2026-03-05 09:05:27 +01:00
74e5f76ec2 V 1.2.0 migration.tsx in proxy.tsx umbenannt
Loginpage eingebettet
2026-03-04 13:20:50 +00:00
90444b8f7d V 1.1.0 nur Version geändert 2026-03-01 11:49:36 +00:00
2a9ae7e806 Summen-Statistik der Kategorien
Eigenes 'Löschen' PopUp
2026-03-01 11:48:24 +00:00
ed6bc21248 Kategorien dazu 2026-03-01 11:26:44 +00:00
319ac8699e V 1.0.2. Keine vorausgefüllte Login-Seite mehr 2026-03-01 08:46:59 +00:00
8c6d1bcf6d V 1.0.1: Auth erfolgreich 2026-03-01 08:35:46 +00:00
1ccd66b307 weiter die Sache mit Auth 2026-03-01 07:52:31 +00:00
29 changed files with 1188 additions and 211 deletions

17
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,17 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Next.js Debug",
"runtimeExecutable": "node",
"args": [
"--inspect-brk",
"${workspaceFolder}/node_modules/.bin/next",
"dev"
],
"console": "integratedTerminal"
}
]
}

174
AUTH_README.md Normal file
View File

@@ -0,0 +1,174 @@
# Wiederverwendbare Authentifizierung
Diese Authentifizierungslösung kann einfach in andere Next.js Projekte übernommen werden.
## Komponenten
### 1. Core Libraries (wiederverwendbar)
- `/lib/auth.ts` - Authentifizierungslogik (Benutzerverwaltung über .env)
- `/lib/session.ts` - JWT-basiertes Session-Management
- `/middleware.ts` - Route-Schutz Middleware
### 2. UI Komponenten (wiederverwendbar)
- `/app/login/page.tsx` - Login-Seite
- `/app/login/actions.ts` - Server Actions für Login/Logout
- `/components/LogoutButton.tsx` - Logout-Button Komponente
## Installation in neuen Projekten
### 1. Dependencies installieren
```bash
npm install jose bcryptjs
npm install --save-dev @types/bcryptjs
```
### 2. Dateien kopieren
Kopiere folgende Dateien in dein neues Projekt:
- `lib/auth.ts`
- `lib/session.ts`
- `middleware.ts`
- `app/login/` (gesamter Ordner)
- `scripts/generate-password.js` (Passwort-Hash Generator)
- `components/LogoutButton.tsx` (optional)
### 3. Passwort-Hashes generieren
Verwende das mitgelieferte Script, um sichere Passwort-Hashes zu erstellen:
```bash
# Interactive Mode
node scripts/generate-password.js
# Mit Passwort als Argument
node scripts/generate-password.js meinPasswort123
```
Das Script gibt einen bcrypt-Hash aus, den du in der `.env` verwenden kannst.
### 4. Umgebungsvariablen einrichten
Füge zu deiner `.env` hinzu:
```env
# Authentifizierung
# Format: username:passwordHash,username2:passwordHash2
# Verwende 'node scripts/generate-password.js' um Hashes zu generieren
AUTH_USERS=admin:$2b$10$DKLO7uQPmdAw9Z64NChro...,user1:$2b$10$K613Z70Hodr6xyEh10Mw2u...
# Secret Key für JWT (unbedingt ändern in Production!)
AUTH_SECRET=your-super-secret-key-change-this
```
### 5. Logout-Button einbinden (optional)
```tsx
import LogoutButton from '@/components/LogoutButton';
// In deiner Komponente:
<LogoutButton />
```
## Konfiguration
### Benutzer hinzufügen/entfernen
1. Generiere einen Passwort-Hash:
```bash
node scripts/generate-password.js neuesPasswort
```
2. Editiere die `AUTH_USERS` Variable in der `.env`:
```env
AUTH_USERS=user1:$2b$10$hash1...,user2:$2b$10$hash2...,user3:$2b$10$hash3...
```
### Authentifizierung deaktivieren
Entferne die `AUTH_USERS` Variable oder setze sie auf einen leeren String:
```env
AUTH_USERS=
```
### Session-Dauer anpassen
Editiere in `lib/session.ts`:
```ts
const SESSION_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 Tage
```
### Geschützte Routen anpassen
Editiere in `middleware.ts` die `publicPaths`:
```ts
const publicPaths = ['/login', '/public-page'];
```
## Sicherheitshinweise
1. **AUTH_SECRET ändern**: Verwende in Production einen starken, zufälligen Schlüssel
2. **HTTPS verwenden**: In Production immer HTTPS aktivieren
3. **Passwort-Hashing**: Passwörter werden mit bcrypt gehashed (10 Salt Rounds)
4. **Keine Klartext-Passwörter**: Verwende immer das Script zur Hash-Generierung
## Passwort-Hash Generator
Das Script `scripts/generate-password.js` verwendet bcrypt mit 10 Salt Rounds, um sichere Passwort-Hashes zu erstellen.
### Verwendung
Interactive Mode (empfohlen für sensible Passwörter):
```bash
npm run generate-password
# oder
node scripts/generate-password.js
# Passwort wird interaktiv abgefragt
```
Mit Argument:
```bash
npm run generate-password -- meinPasswort
# oder
node scripts/generate-password.js meinPasswort
```
### Ausgabe
```
🔐 Generiere Passwort-Hash...
✅ Hash generiert:
────────────────────────────────────────────────────────────────────────────────
$2b$10$DKLO7uQPmdAw9Z64NChro.8mOsnqZQaRZjctWDojIkK926ROBVyJW
────────────────────────────────────────────────────────────────────────────────
📝 Verwende diesen Hash in der .env Datei:
AUTH_USERS=username:$2b$10$DKLO7uQPmdAw9Z64NChro.8mOsnqZQaRZjctWDojIkK926ROBVyJW
```
## Erweiterte Verwendung
### Session-Informationen abrufen
```ts
import { getSession } from '@/lib/session';
const session = await getSession();
if (session) {
console.log('Eingeloggt als:', session.username);
}
```
### Programmatisch prüfen, ob authentifiziert
```ts
import { isAuthenticated } from '@/lib/session';
const authenticated = await isAuthenticated();
```
### In Server Components
```tsx
import { getSession } from '@/lib/session';
import { redirect } from 'next/navigation';
export default async function ProtectedPage() {
const session = await getSession();
if (!session) {
redirect('/login');
}
return <div>Hallo {session.username}!</div>;
}
```

View File

@@ -37,7 +37,7 @@ RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs RUN adduser --system --uid 1001 nextjs
# Copy necessary files # Copy necessary files
COPY --from=builder /app/public ./public # COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

5
add_kategorie.sql Normal file
View File

@@ -0,0 +1,5 @@
-- Migration: Kategorie-Spalte zur Ausgaben-Tabelle hinzufügen
-- Ausführen: mysql -u <user> -p <database> < add_kategorie.sql
ALTER TABLE Ausgaben
ADD COLUMN Kat VARCHAR(4) NOT NULL DEFAULT 'L' AFTER Was;

View File

@@ -10,7 +10,7 @@ export async function PUT(
try { try {
const { id } = await context.params; const { id } = await context.params;
const body = await request.json(); const body = await request.json();
const { Datum, Wo, Was, Wieviel, Wie, TYP } = body; const { Datum, Wo, Was, Kat, Wieviel, Wie, TYP } = body;
if (!Datum || !Wo || !Was || !Wieviel || !Wie || TYP === undefined) { if (!Datum || !Wo || !Was || !Wieviel || !Wie || TYP === undefined) {
return NextResponse.json( return NextResponse.json(
@@ -23,7 +23,7 @@ export async function PUT(
const query = ` const query = `
UPDATE Ausgaben UPDATE Ausgaben
SET Datum = ?, Wo = ?, Was = ?, Wieviel = ?, Wie = ?, TYP = ? SET Datum = ?, Wo = ?, Was = ?, Kat = ?, Wieviel = ?, Wie = ?, TYP = ?
WHERE ID = ? WHERE ID = ?
`; `;
@@ -31,6 +31,7 @@ export async function PUT(
Datum, Datum,
Wo, Wo,
Was, Was,
Kat || 'L',
parseFloat(Wieviel), parseFloat(Wieviel),
Wie, Wie,
TYP, TYP,

View File

@@ -15,7 +15,7 @@ export async function GET(request: Request) {
const pool = getDbPool(); const pool = getDbPool();
let query = `SELECT let query = `SELECT
ID, Datum, Wo, Was, Wieviel, Wie, TYP, ID, Datum, Wo, Was, Kat, Wieviel, Wie, TYP,
CASE DAYOFWEEK(Datum) CASE DAYOFWEEK(Datum)
WHEN 1 THEN 'Sonntag' WHEN 1 THEN 'Sonntag'
WHEN 2 THEN 'Montag' WHEN 2 THEN 'Montag'
@@ -68,7 +68,7 @@ export async function GET(request: Request) {
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const body = await request.json(); const body = await request.json();
const { Datum, Wo, Was, Wieviel, Wie, TYP } = body; const { Datum, Wo, Was, Kat, Wieviel, Wie, TYP } = body;
if (!Datum || !Wo || !Was || !Wieviel || !Wie || TYP === undefined) { if (!Datum || !Wo || !Was || !Wieviel || !Wie || TYP === undefined) {
return NextResponse.json( return NextResponse.json(
@@ -80,14 +80,15 @@ export async function POST(request: Request) {
const pool = getDbPool(); const pool = getDbPool();
const query = ` const query = `
INSERT INTO Ausgaben (Datum, Wo, Was, Wieviel, Wie, TYP) INSERT INTO Ausgaben (Datum, Wo, Was, Kat, Wieviel, Wie, TYP)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
`; `;
const [result] = await pool.query<ResultSetHeader>(query, [ const [result] = await pool.query<ResultSetHeader>(query, [
Datum, Datum,
Wo, Wo,
Was, Was,
Kat || 'L',
parseFloat(Wieviel), parseFloat(Wieviel),
Wie, Wie,
TYP, TYP,

View File

@@ -56,6 +56,21 @@ export async function GET(request: Request) {
const data = rows[0] || {}; const data = rows[0] || {};
// Per-category breakdown
const [katRows] = await pool.query<RowDataPacket[]>(
`SELECT Kat, SUM(Wieviel) as total
FROM Ausgaben
WHERE YEAR(Datum) = ? AND MONTH(Datum) = ? AND TYP = ?
GROUP BY Kat
HAVING total > 0
ORDER BY total DESC`,
[year, month, parseInt(typ)]
);
const katStats: Record<string, number> = {};
for (const row of katRows) {
katStats[row.Kat] = parseFloat(row.total) || 0;
}
// Convert string values from MySQL to numbers // Convert string values from MySQL to numbers
const parsedData: any = { const parsedData: any = {
totalAusgaben: parseFloat(data.totalAusgaben) || 0, totalAusgaben: parseFloat(data.totalAusgaben) || 0,
@@ -77,7 +92,7 @@ export async function GET(request: Request) {
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
data: parsedData, data: { ...parsedData, katStats },
}); });
} catch (error) { } catch (error) {
console.error('Database error:', error); console.error('Database error:', error);

View File

@@ -0,0 +1,33 @@
import { NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
export interface Category {
value: string;
label: string;
}
// GET /api/categories - Fetch categories from categories.txt
export async function GET() {
try {
const filePath = path.join(process.cwd(), 'categories.txt');
const content = fs.readFileSync(filePath, 'utf-8');
const categories: Category[] = content
.split('\n')
.map((line) => line.trim())
.filter((line) => line.includes('='))
.map((line) => {
const [value, label] = line.split('=');
return { value: value.trim(), label: label.trim() };
});
return NextResponse.json({ success: true, data: categories });
} catch (error) {
console.error('Error reading categories:', error);
return NextResponse.json(
{ success: false, error: 'Could not load categories' },
{ status: 500 }
);
}
}

28
app/login/actions.ts Normal file
View File

@@ -0,0 +1,28 @@
'use server';
import { verifyCredentials } from '@/lib/auth';
import { createSession, deleteSession } from '@/lib/session';
import { redirect } from 'next/navigation';
export async function login(prevState: any, formData: FormData) {
const username = formData.get('username') as string;
const password = formData.get('password') as string;
if (!username || !password) {
return { error: 'Bitte Benutzername und Passwort eingeben' };
}
const isValid = verifyCredentials(username, password);
if (!isValid) {
return { error: 'Ungültige Anmeldedaten' };
}
await createSession(username);
redirect('/');
}
export async function logout() {
await deleteSession();
redirect('/login');
}

95
app/login/page.tsx Normal file
View File

@@ -0,0 +1,95 @@
'use client';
import { useActionState } from 'react';
import { login } from './actions';
import packageJson from '@/package.json';
export default function LoginPage() {
const [state, loginAction, isPending] = useActionState(login, undefined);
const version = packageJson.version;
const buildDate = process.env.NEXT_PUBLIC_BUILD_DATE || new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
return (
<div className="min-h-screen bg-white py-4 px-4">
<main className="max-w-6xl mx-auto border-2 border-black rounded-lg p-6 bg-[#FFFFDD]">
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">Ausgaben - Log</h1>
</div>
<div className="flex justify-center py-10">
<div className="w-full max-w-sm bg-white border border-gray-300 rounded-xl shadow-md p-8">
<h2 className="text-xl font-semibold text-gray-900 mb-6 text-center">Anmeldung</h2>
<form action={loginAction} className="space-y-5">
<div>
<label
htmlFor="username"
className="block text-sm font-medium text-gray-700 mb-1"
>
Benutzername
</label>
<input
id="username"
name="username"
type="text"
required
autoComplete="off"
className="w-full px-3 py-2 border-2 border-gray-400 rounded-lg bg-white text-gray-900 focus:border-blue-500 focus:outline-none text-sm"
placeholder="Benutzername"
disabled={isPending}
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700 mb-1"
>
Passwort
</label>
<input
id="password"
name="password"
type="password"
required
autoComplete="new-password"
className="w-full px-3 py-2 border-2 border-gray-400 rounded-lg bg-white text-gray-900 focus:border-blue-500 focus:outline-none text-sm"
placeholder="Passwort"
disabled={isPending}
/>
</div>
{state?.error && (
<div className="bg-red-50 border border-red-300 text-red-700 px-3 py-2 rounded-lg text-sm">
{state.error}
</div>
)}
<button
type="submit"
disabled={isPending}
className="w-full py-2 px-4 bg-[#85B7D7] hover:bg-[#6a9fc5] text-black font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm"
>
{isPending ? 'Anmeldung läuft...' : 'Anmelden'}
</button>
</form>
</div>
</div>
{/* Footer */}
<footer className="mt-8 flex justify-between items-center text-sm text-gray-600 px-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} - {buildDate}
</div>
</footer>
</main>
</div>
);
}

83
app/login/page.tsx_xx Normal file
View File

@@ -0,0 +1,83 @@
'use client';
import { useActionState } from 'react';
import { login } from './actions';
export default function LoginPage() {
const [state, loginAction, isPending] = useActionState(login, undefined);
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 mb-6">Ausgaben - Log</h1>
<div className="flex items-center justify-center py-8">
<div className="max-w-md w-full space-y-8 bg-white p-8 rounded-2xl shadow-xl">
<div className="text-center">
<h2 className="text-2xl font-bold mb-2">Anmeldung</h2>
<p className="text-gray-600">
Bitte melden Sie sich an, um fortzufahren
</p>
</div>
<form action={loginAction} className="mt-8 space-y-6">
<div className="space-y-4">
<div>
<label
htmlFor="username"
className="block text-sm font-medium text-gray-700 mb-1"
>
Benutzername
</label>
<input
id="username"
name="username"
type="text"
required
autoComplete="off"
className="appearance-none relative block w-full px-4 py-3 border border-gray-300 placeholder-gray-500 text-gray-900 bg-white rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors"
placeholder="Benutzername"
disabled={isPending}
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700 mb-1"
>
Passwort
</label>
<input
id="password"
name="password"
type="password"
required
autoComplete="new-password"
className="appearance-none relative block w-full px-4 py-3 border border-gray-300 placeholder-gray-500 text-gray-900 bg-white rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors"
placeholder="Passwort"
disabled={isPending}
/>
</div>
</div>
{state?.error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
{state.error}
</div>
)}
<button
type="submit"
disabled={isPending}
className="w-full flex justify-center py-3 px-4 border border-transparent text-sm font-semibold rounded-lg text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 shadow-lg hover:shadow-xl"
>
{isPending ? 'Anmeldung läuft...' : 'Anmelden'}
</button>
</form>
</div>
</div>
</main>
</div>
);
}

View File

@@ -3,17 +3,18 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import AusgabenForm from '@/components/AusgabenForm'; import AusgabenForm from '@/components/AusgabenForm';
import AusgabenList from '@/components/AusgabenList'; import AusgabenList from '@/components/AusgabenList';
import MonatsStatistik from '@/components/MonatsStatistik';
import TabLayout from '@/components/TabLayout';
import { AusgabenEntry } from '@/types/ausgaben'; import { AusgabenEntry } from '@/types/ausgaben';
import packageJson from '@/package.json';
const MAX_ENTRIES = 15;
export default function Home() { export default function Home() {
const [activeTab, setActiveTab] = useState(0); // 0 = Haushalt, 1 = Privat const [activeTab, setActiveTab] = useState(0); // 0 = Haushalt, 1 = Privat
const [entries, setEntries] = useState<AusgabenEntry[]>([]); const [entries, setEntries] = useState<AusgabenEntry[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [selectedEntry, setSelectedEntry] = useState<AusgabenEntry | null>(null); const [selectedEntry, setSelectedEntry] = useState<AusgabenEntry | null>(null);
const [statsRefreshKey, setStatsRefreshKey] = useState(0);
const version = packageJson.version;
const buildDate = process.env.NEXT_PUBLIC_BUILD_DATE || new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
useEffect(() => { useEffect(() => {
fetchRecentEntries(); fetchRecentEntries();
@@ -23,7 +24,7 @@ export default function Home() {
const fetchRecentEntries = async () => { const fetchRecentEntries = async () => {
setIsLoading(true); setIsLoading(true);
try { try {
const response = await fetch(`/api/ausgaben?limit=20&typ=${activeTab}`, { const response = await fetch(`/api/ausgaben?limit=${MAX_ENTRIES}&typ=${activeTab}`, {
cache: 'no-store', cache: 'no-store',
headers: { headers: {
'Cache-Control': 'no-cache', 'Cache-Control': 'no-cache',
@@ -42,6 +43,7 @@ export default function Home() {
const handleSuccess = () => { const handleSuccess = () => {
setSelectedEntry(null); setSelectedEntry(null);
setStatsRefreshKey((k) => k + 1);
setTimeout(() => { setTimeout(() => {
fetchRecentEntries(); fetchRecentEntries();
}, 100); }, 100);
@@ -49,6 +51,7 @@ export default function Home() {
const handleDelete = (id: number) => { const handleDelete = (id: number) => {
setEntries(entries.filter(entry => entry.ID !== id)); setEntries(entries.filter(entry => entry.ID !== id));
setStatsRefreshKey((k) => k + 1);
}; };
const handleEdit = (entry: AusgabenEntry) => { const handleEdit = (entry: AusgabenEntry) => {
@@ -57,60 +60,22 @@ export default function Home() {
}; };
return ( return (
<div className="min-h-screen bg-white py-4 px-4"> <TabLayout activeTab={activeTab} onTabChange={setActiveTab}>
<main className="max-w-7xl mx-auto border-2 border-black rounded-lg p-6 bg-[#FFFFDD]"> <div>
<h1 className="text-3xl font-bold text-center mb-6">Ausgaben - Log</h1> <h2 className="text-xl font-semibold mb-4">Eingabe</h2>
<AusgabenForm onSuccess={handleSuccess} selectedEntry={selectedEntry} typ={activeTab} />
{/* Tab Navigation */}
<div className="flex gap-2 mb-6">
<button
onClick={() => setActiveTab(0)}
className={`flex-1 py-3 px-6 rounded-lg font-semibold transition-colors ${
activeTab === 0
? 'bg-[#85B7D7] text-black'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
Haushalt
</button>
<button
onClick={() => setActiveTab(1)}
className={`flex-1 py-3 px-6 rounded-lg font-semibold transition-colors ${
activeTab === 1
? 'bg-[#85B7D7] text-black'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
Privat
</button>
</div>
<div> <MonatsStatistik typ={activeTab} refreshKey={statsRefreshKey} />
<h2 className="text-xl font-semibold mb-4">Eingabe</h2>
<AusgabenForm onSuccess={handleSuccess} selectedEntry={selectedEntry} typ={activeTab} />
<div className="mt-6 bg-white border border-black rounded-lg shadow-md p-6">
<h3 className="text-xl font-semibold mb-4">Letzte 20 Einträge</h3>
{isLoading ? (
<div className="text-center py-4">Lade Daten...</div>
) : (
<AusgabenList entries={entries} onDelete={handleDelete} onEdit={handleEdit} />
)}
</div>
</div>
{/* Footer */} <div className="mt-6 bg-white border border-black rounded-lg shadow-md p-6">
<footer className="mt-8 flex justify-between items-center text-sm text-gray-600 px-4 "> <h3 className="text-xl font-semibold mb-4">Letzte {MAX_ENTRIES} Einträge</h3>
<div> {isLoading ? (
<a href="mailto:rxf@gmx.de" className="text-blue-600 hover:underline"> <div className="text-center py-4">Lade Daten...</div>
mailto:rxf@gmx.de ) : (
</a> <AusgabenList entries={entries} onDelete={handleDelete} onEdit={handleEdit} />
</div> )}
<div className="text-right"> </div>
Version {version} - {buildDate} </div>
</div> </TabLayout>
</footer>
</main>
</div>
); );
} }

15
categories.txt Normal file
View File

@@ -0,0 +1,15 @@
R=Restaurant
L=Lebensmittel
H=Haushalt
Ku=Kultur
Kl=Kleidung
Dr=Drogerie
Ap=Apotheke
Ar=Arzt
Re=Reise
Au=Auto
El=Elektronik
Fr=Freizeit
Ge=Getränke
Ba=Bäckerei
So=Sonstiges

View File

@@ -1,7 +1,8 @@
'use client'; 'use client';
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { CreateAusgabenEntry, AusgabenEntry, ZAHLUNGSARTEN_HAUSHALT, ZAHLUNGSARTEN_PRIVAT, MonthlyStats } from '@/types/ausgaben'; import { CreateAusgabenEntry, AusgabenEntry, ZAHLUNGSARTEN_HAUSHALT, ZAHLUNGSARTEN_PRIVAT } from '@/types/ausgaben';
import { Category } from '@/app/api/categories/route';
interface AusgabenFormProps { interface AusgabenFormProps {
onSuccess: () => void; onSuccess: () => void;
@@ -18,6 +19,7 @@ export default function AusgabenForm({ onSuccess, selectedEntry, typ }: Ausgaben
WochTag: '', WochTag: '',
Wo: '', Wo: '',
Was: '', Was: '',
Kat: 'L',
Wieviel: '', Wieviel: '',
Wie: defaultZahlungsart, Wie: defaultZahlungsart,
TYP: typ, TYP: typ,
@@ -26,32 +28,12 @@ export default function AusgabenForm({ onSuccess, selectedEntry, typ }: Ausgaben
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [editId, setEditId] = useState<number | null>(null); 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);
// Autocomplete data // Autocomplete data
const [autoCompleteWo, setAutoCompleteWo] = useState<string[]>([]); const [autoCompleteWo, setAutoCompleteWo] = useState<string[]>([]);
const [autoCompleteWas, setAutoCompleteWas] = useState<string[]>([]); const [autoCompleteWas, setAutoCompleteWas] = useState<string[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const fetchStats = useCallback(async (y: string, m: string) => { const [katDropdownOpen, setKatDropdownOpen] = useState(false);
if (!y || !m) return; const katDropdownRef = useRef<HTMLDivElement>(null);
setIsLoadingStats(true);
try {
const response = await fetch(`/api/ausgaben/stats?year=${y}&month=${m}&typ=${typ}`);
const data = await response.json();
if (data.success) {
setStats(data.data);
}
} catch (error) {
console.error('Error fetching stats:', error);
} finally {
setIsLoadingStats(false);
}
}, [typ]);
const fetchAutoComplete = useCallback(async () => { const fetchAutoComplete = useCallback(async () => {
try { try {
@@ -66,42 +48,30 @@ export default function AusgabenForm({ onSuccess, selectedEntry, typ }: Ausgaben
} }
}, [typ]); }, [typ]);
// Initialize month/year on first load
useEffect(() => {
const now = new Date();
const currentMonth = String(now.getMonth() + 1).padStart(2, '0');
const currentYear = String(now.getFullYear());
setMonth(currentMonth);
setYear(currentYear);
}, []);
// Fetch stats when month, year, or typ changes
useEffect(() => {
if (month && year) {
fetchStats(year, month);
}
}, [month, year, typ, fetchStats]);
// Fetch autocomplete data when typ changes // Fetch autocomplete data when typ changes
useEffect(() => { useEffect(() => {
fetchAutoComplete(); fetchAutoComplete();
}, [typ, fetchAutoComplete]); }, [typ, fetchAutoComplete]);
const handleMonthChange = (newMonth: string) => { // Close Kat dropdown when clicking outside
setMonth(newMonth); useEffect(() => {
}; const handleClickOutside = (e: MouseEvent) => {
if (katDropdownRef.current && !katDropdownRef.current.contains(e.target as Node)) {
setKatDropdownOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleYearChange = (newYear: string) => { // Fetch categories once on mount
setYear(newYear); useEffect(() => {
}; fetch('/api/categories')
.then((r) => r.json())
.then((data) => { if (data.success) setCategories(data.data); })
.catch(() => {});
}, []);
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(() => { useEffect(() => {
if (selectedEntry) { if (selectedEntry) {
@@ -113,6 +83,7 @@ export default function AusgabenForm({ onSuccess, selectedEntry, typ }: Ausgaben
WochTag: selectedEntry.WochTag, WochTag: selectedEntry.WochTag,
Wo: selectedEntry.Wo, Wo: selectedEntry.Wo,
Was: selectedEntry.Was, Was: selectedEntry.Was,
Kat: selectedEntry.Kat || 'L',
Wieviel: selectedEntry.Wieviel.toString(), Wieviel: selectedEntry.Wieviel.toString(),
Wie: selectedEntry.Wie, Wie: selectedEntry.Wie,
TYP: selectedEntry.TYP, TYP: selectedEntry.TYP,
@@ -132,6 +103,7 @@ export default function AusgabenForm({ onSuccess, selectedEntry, typ }: Ausgaben
WochTag: weekday, WochTag: weekday,
Wo: '', Wo: '',
Was: '', Was: '',
Kat: 'L',
Wieviel: '', Wieviel: '',
Wie: defaultZahlungsart, Wie: defaultZahlungsart,
TYP: typ, TYP: typ,
@@ -184,7 +156,8 @@ export default function AusgabenForm({ onSuccess, selectedEntry, typ }: Ausgaben
Datum: formData.Datum, Datum: formData.Datum,
Wo: formData.Wo, Wo: formData.Wo,
Was: formData.Was, Was: formData.Was,
Wieviel: formData.Wieviel, Kat: formData.Kat,
Wieviel: String(formData.Wieviel).replace(',', '.'),
Wie: formData.Wie, Wie: formData.Wie,
TYP: formData.TYP, TYP: formData.TYP,
}; };
@@ -200,8 +173,6 @@ export default function AusgabenForm({ onSuccess, selectedEntry, typ }: Ausgaben
if (response.ok) { if (response.ok) {
handleReset(); handleReset();
onSuccess(); onSuccess();
// Refresh stats after successful save
fetchStats(year, month);
} else { } else {
alert('Fehler beim Speichern!'); alert('Fehler beim Speichern!');
} }
@@ -223,6 +194,7 @@ export default function AusgabenForm({ onSuccess, selectedEntry, typ }: Ausgaben
WochTag: weekday, WochTag: weekday,
Wo: '', Wo: '',
Was: '', Was: '',
Kat: 'L',
Wieviel: '', Wieviel: '',
Wie: defaultZahlungsart, Wie: defaultZahlungsart,
TYP: typ, TYP: typ,
@@ -247,6 +219,7 @@ export default function AusgabenForm({ onSuccess, selectedEntry, typ }: Ausgaben
<th className="p-2 w-32">Datum</th> <th className="p-2 w-32">Datum</th>
<th className="p-2">Wo</th> <th className="p-2">Wo</th>
<th className="p-2">Was</th> <th className="p-2">Was</th>
<th className="p-2 w-12">Kat.</th>
<th className="p-2 w-24">Wieviel</th> <th className="p-2 w-24">Wieviel</th>
<th className="p-2 w-4"></th> <th className="p-2 w-4"></th>
<th className="p-2 w-38 text-left">Wie</th> <th className="p-2 w-38 text-left">Wie</th>
@@ -295,13 +268,45 @@ export default function AusgabenForm({ onSuccess, selectedEntry, typ }: Ausgaben
))} ))}
</datalist> </datalist>
</td> </td>
<td className="p-2 w-12">
<div ref={katDropdownRef} className="relative w-full">
<button
type="button"
onClick={() => setKatDropdownOpen((o) => !o)}
className="w-full px-2 py-1 text-base text-left rounded border-2 border-gray-400 bg-white focus:border-blue-500 focus:outline-none"
>
{formData.Kat}
</button>
{katDropdownOpen && (
<ul className="absolute z-50 left-0 mt-1 w-48 bg-white border-2 border-gray-400 rounded shadow-lg max-h-60 overflow-y-auto text-left">
{categories.map((cat) => (
<li
key={cat.value}
className={`px-3 py-1 cursor-pointer hover:bg-blue-100 text-sm ${
formData.Kat === cat.value ? 'bg-blue-50 font-semibold' : ''
}`}
onMouseDown={() => {
setFormData({ ...formData, Kat: cat.value });
setKatDropdownOpen(false);
}}
>
{cat.value} - {cat.label}
</li>
))}
</ul>
)}
</div>
</td>
<td className="p-2 w-24"> <td className="p-2 w-24">
<input <input
type="number" type="text"
step="0.01" inputMode="decimal"
value={formData.Wieviel} value={formData.Wieviel}
onChange={(e) => setFormData({ ...formData, Wieviel: e.target.value })} onChange={(e) => {
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" const val = e.target.value.replace(/[^0-9.,]/g, '').replace(',', '.');
setFormData({ ...formData, Wieviel: val });
}}
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="0.00" placeholder="0.00"
required required
/> />
@@ -347,53 +352,6 @@ export default function AusgabenForm({ onSuccess, selectedEntry, typ }: Ausgaben
Löschen Löschen
</button> </button>
</div> </div>
{/* Monatsstatistiken */}
<div className="mt-6 pt-4 pb-6 -mb-6 border-t border-black -mx-6 px-6 bg-[#E0E0FF]">
<div className="flex items-center justify-between pt-1">
<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>
</div>
</form> </form>
</div> </div>
); );

View File

@@ -1,5 +1,6 @@
'use client'; 'use client';
import { useState } from 'react';
import { AusgabenEntry } from '@/types/ausgaben'; import { AusgabenEntry } from '@/types/ausgaben';
interface AusgabenListProps { interface AusgabenListProps {
@@ -9,14 +10,12 @@ interface AusgabenListProps {
} }
export default function AusgabenList({ entries, onDelete, onEdit }: AusgabenListProps) { export default function AusgabenList({ entries, onDelete, onEdit }: AusgabenListProps) {
const handleDelete = async (id: number) => { const [confirmId, setConfirmId] = useState<number | null>(null);
if (!confirm('Wirklich löschen?')) return;
const handleDeleteConfirmed = async (id: number) => {
setConfirmId(null);
try { try {
const response = await fetch(`/api/ausgaben/${id}`, { const response = await fetch(`/api/ausgaben/${id}`, { method: 'DELETE' });
method: 'DELETE',
});
if (response.ok) { if (response.ok) {
onDelete(id); onDelete(id);
} else { } else {
@@ -29,12 +28,7 @@ export default function AusgabenList({ entries, onDelete, onEdit }: AusgabenList
}; };
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
const date = new Date(dateStr); return dateStr.toString().split('T')[0];
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}; };
const formatAmount = (amount: number) => { const formatAmount = (amount: number) => {
@@ -53,15 +47,16 @@ export default function AusgabenList({ entries, onDelete, onEdit }: AusgabenList
<th className="border-b-2 border-black p-2 w-12">Tag</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-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-48">Was</th>
<th className="border-b-2 border-black p-2 w-12">Kat.</th>
<th className="border-b-2 border-black p-2 w-8">Betrag</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-16">Wie</th>
<th className="border-b-2 border-black p-2 w-38">Aktion</th> <th className="border-b-2 border-black p-2 w-48">Aktion</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{entries.length === 0 ? ( {entries.length === 0 ? (
<tr> <tr>
<td colSpan={7} className="text-center p-4 text-gray-500"> <td colSpan={8} className="text-center p-4 text-gray-500">
Keine Einträge vorhanden Keine Einträge vorhanden
</td> </td>
</tr> </tr>
@@ -74,6 +69,7 @@ export default function AusgabenList({ entries, onDelete, onEdit }: AusgabenList
<td className="border-y border-black p-2 text-center">{entry.WochTag.slice(0, 2)}</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.Wo}</td>
<td className="border-y border-black p-2">{entry.Was}</td> <td className="border-y border-black p-2">{entry.Was}</td>
<td className="border-y border-black p-2 text-center">{entry.Kat}</td>
<td className="border-y border-black p-2 text-right"> <td className="border-y border-black p-2 text-right">
{formatAmount(entry.Wieviel)} {formatAmount(entry.Wieviel)}
</td> </td>
@@ -83,10 +79,10 @@ export default function AusgabenList({ entries, onDelete, onEdit }: AusgabenList
onClick={() => onEdit(entry)} onClick={() => onEdit(entry)}
className="text-blue-600 hover:text-blue-800 px-3 py-1 rounded text-sm mr-2" className="text-blue-600 hover:text-blue-800 px-3 py-1 rounded text-sm mr-2"
> >
Bearbeiten Editieren
</button> </button>
<button <button
onClick={() => handleDelete(entry.ID)} onClick={() => setConfirmId(entry.ID)}
className="text-red-600 hover:text-red-800 px-3 py-1 rounded text-sm" className="text-red-600 hover:text-red-800 px-3 py-1 rounded text-sm"
> >
Löschen Löschen
@@ -97,6 +93,29 @@ export default function AusgabenList({ entries, onDelete, onEdit }: AusgabenList
)} )}
</tbody> </tbody>
</table> </table>
{/* Bestätigungs-Modal */}
{confirmId !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white border-2 border-black rounded-lg shadow-xl p-6 w-80">
<p className="text-lg font-semibold mb-6 text-center">Eintrag wirklich löschen?</p>
<div className="flex justify-center gap-4">
<button
onClick={() => handleDeleteConfirmed(confirmId)}
className="bg-red-600 hover:bg-red-700 text-white font-medium py-2 px-6 rounded-lg transition-colors"
>
Löschen
</button>
<button
onClick={() => setConfirmId(null)}
className="bg-gray-200 hover:bg-gray-300 text-black font-medium py-2 px-6 rounded-lg transition-colors"
>
Abbrechen
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,23 @@
'use client';
import { logout } from '@/app/login/actions';
interface LogoutButtonProps {
className?: string;
children?: React.ReactNode;
}
export default function LogoutButton({ className, children }: LogoutButtonProps) {
const handleLogout = async () => {
await logout();
};
return (
<button
onClick={handleLogout}
className={className || "px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors"}
>
{children || 'Abmelden'}
</button>
);
}

View File

@@ -0,0 +1,120 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { MonthlyStats } from '@/types/ausgaben';
import { Category } from '@/app/api/categories/route';
interface MonatsStatistikProps {
typ: number;
refreshKey?: number;
}
export default function MonatsStatistik({ typ, refreshKey }: MonatsStatistikProps) {
const [stats, setStats] = useState<MonthlyStats | null>(null);
const [month, setMonth] = useState('');
const [year, setYear] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [categories, setCategories] = useState<Category[]>([]);
// Initialize month/year
useEffect(() => {
const now = new Date();
setMonth(String(now.getMonth() + 1).padStart(2, '0'));
setYear(String(now.getFullYear()));
}, []);
// Fetch categories once
useEffect(() => {
fetch('/api/categories')
.then((r) => r.json())
.then((data) => { if (data.success) setCategories(data.data); })
.catch(() => {});
}, []);
const fetchStats = useCallback(async (y: string, m: string) => {
if (!y || !m) return;
setIsLoading(true);
try {
const response = await fetch(`/api/ausgaben/stats?year=${y}&month=${m}&typ=${typ}`);
const data = await response.json();
if (data.success) setStats(data.data);
} catch (error) {
console.error('Error fetching stats:', error);
} finally {
setIsLoading(false);
}
}, [typ]);
useEffect(() => {
if (month && year) fetchStats(year, month);
}, [month, year, typ, refreshKey, fetchStats]);
const formatAmount = (amount: number) =>
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(amount);
const getCatLabel = (code: string) => {
const cat = categories.find((c) => c.value === code);
return cat ? `${cat.label}` : code;
};
return (
<div className="mt-4 bg-[#E0E0FF] border border-black rounded-lg shadow-md p-4">
{/* Zeile 1: Monat/Jahr + Gesamtsumme */}
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex gap-4 items-center">
<label className="font-semibold">Monat:</label>
<select
value={month}
onChange={(e) => setMonth(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) => setYear(e.target.value)}
className="border border-gray-400 rounded px-3 py-1 w-24"
min="2013"
max="2099"
/>
</div>
<div>
{isLoading ? (
<span>Lade...</span>
) : stats ? (
<span className="font-bold text-lg">
Summe: {formatAmount(stats.totalAusgaben)}
</span>
) : null}
</div>
</div>
{/* Zeile 2+: Kategorien */}
{!isLoading && stats?.katStats && Object.keys(stats.katStats).length > 0 && (
<div className="mt-3 pt-3 border-t border-gray-400 flex flex-wrap gap-x-6 gap-y-1">
{Object.entries(stats.katStats).map(([code, total]) => (
<div key={code} className="flex gap-2 text-sm">
<span className="font-medium">{getCatLabel(code)}:</span>
<span>{formatAmount(total)}</span>
</div>
))}
</div>
)}
</div>
);
}

82
components/TabLayout.tsx Normal file
View File

@@ -0,0 +1,82 @@
'use client';
import { ReactNode } from 'react';
import LogoutButton from '@/components/LogoutButton';
import packageJson from '@/package.json';
interface Tab {
label: string;
index: number;
}
interface TabLayoutProps {
children: ReactNode;
activeTab: number;
onTabChange: (index: number) => void;
}
const TABS: Tab[] = [
{ label: 'Haushalt', index: 0 },
{ label: 'Privat', index: 1 },
];
export default function TabLayout({ children, activeTab, onTabChange }: TabLayoutProps) {
const version = packageJson.version;
const buildDate =
process.env.NEXT_PUBLIC_BUILD_DATE ||
new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
return (
<div className="min-h-screen py-8 px-4">
{/* Outer wrapper with border */}
<div className="max-w-316 mx-auto border-2 border-black rounded-xl bg-gray-200 p-6">
{/* Page title */}
<h1 className="text-4xl font-bold text-center mb-6 tracking-tight">Ausgaben - Log</h1>
{/* Inner content */}
<div className="max-w-6xl mx-auto">
{/* Tab bar */}
<div className="flex justify-between items-end">
<div className="flex">
{TABS.map(tab => {
const isActive = activeTab === tab.index;
return (
<button
key={tab.index}
onClick={() => onTabChange(tab.index)}
className="px-6 py-2 text-sm font-semibold border-t-2 border-l-2 border-r-2 rounded-tl-lg rounded-tr-lg mr-1 transition-colors"
style={
isActive
? { backgroundColor: '#FFFFDD', color: '#000000', borderColor: '#000000', borderBottom: '2px solid #FFFFDD', marginBottom: '-2px', position: 'relative', zIndex: 10 }
: { backgroundColor: '#85B7D7', color: '#374151', borderColor: '#000000' }
}
>
{tab.label}
</button>
);
})}
</div>
<div className="pb-1">
<LogoutButton className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm rounded-lg shadow-md" />
</div>
</div>
{/* Content panel */}
<main className="border-2 border-black rounded-b-lg rounded-tr-lg p-6 bg-[#FFFFDD]">
{children}
</main>
<footer className="mt-8 flex justify-between items-center text-sm text-gray-600 px-4">
<a href="mailto:rxf@gmx.de" className="hover:underline">
mailto:rxf@gmx.de
</a>
<div>Version {version} - {buildDate}</div>
</footer>
</div>
</div>
</div>
);
}

View File

@@ -15,7 +15,7 @@ FULL_IMAGE="${REGISTRY}/${IMAGE_NAME}:${TAG}"
BUILD_DATE=$(date +%d.%m.%Y) BUILD_DATE=$(date +%d.%m.%Y)
echo "==========================================" echo "=========================================="
echo "Ausgaben-Next Deploy Script" echo "ausgaben-next Deploy Script"
echo "==========================================" echo "=========================================="
echo "Registry: ${REGISTRY}" echo "Registry: ${REGISTRY}"
echo "Image: ${IMAGE_NAME}" echo "Image: ${IMAGE_NAME}"
@@ -24,25 +24,29 @@ echo "Build-Datum: ${BUILD_DATE}"
echo "==========================================" echo "=========================================="
echo "" echo ""
# 1. Docker Image bauen # 1. Login zur Registry (falls noch nicht eingeloggt)
echo ">>> Baue Docker Image..."
docker build \
--build-arg BUILD_DATE="${BUILD_DATE}" \
-t "${IMAGE_NAME}:${TAG}" \
-t "${FULL_IMAGE}" \
.
echo ">>> Build erfolgreich!"
echo ""
# 2. Login zur Registry (falls noch nicht eingeloggt)
echo ">>> Login zu ${REGISTRY}..." echo ">>> Login zu ${REGISTRY}..."
docker login "${REGISTRY}" docker login "${REGISTRY}"
echo "" echo ""
# 3. Image pushen # 2. Multiplatform Builder einrichten (docker-container driver erforderlich)
echo ">>> Pushe Image zu ${REGISTRY}..." echo ">>> Richte Multiplatform Builder ein..."
docker push "${FULL_IMAGE}" if ! docker buildx inspect multiplatform-builder &>/dev/null; then
docker buildx create --name multiplatform-builder --driver docker-container --bootstrap
fi
docker buildx use multiplatform-builder
echo ""
# 3. Docker Image bauen und pushen (Multiplatform)
echo ">>> Baue Multiplatform Docker Image und pushe zu Registry..."
docker buildx build \
--platform linux/amd64,linux/arm64 \
--build-arg BUILD_DATE="${BUILD_DATE}" \
-t "${FULL_IMAGE}" \
--push \
.
echo ">>> Build und Push erfolgreich!"
echo "" echo ""
echo "==========================================" echo "=========================================="

View File

@@ -15,3 +15,5 @@ services:
- DB_USER=${DB_USER} - DB_USER=${DB_USER}
- DB_PASS=${DB_PASS} - DB_PASS=${DB_PASS}
- DB_NAME=${DB_NAME} - DB_NAME=${DB_NAME}
- AUTH_USERS=${AUTH_USERS}
- AUTH_SECRET=${AUTH_SECRET}

View File

@@ -12,6 +12,8 @@ services:
- DB_USER=${DB_USER} - DB_USER=${DB_USER}
- DB_PASS=${DB_PASS} - DB_PASS=${DB_PASS}
- DB_NAME=${DB_NAME} - DB_NAME=${DB_NAME}
- AUTH_USERS=${AUTH_USERS}
- AUTH_SECRET=${AUTH_SECRET}
labels: labels:
- traefik.enable=true - traefik.enable=true
- traefik.http.routers.ausgaben.entrypoints=http - traefik.http.routers.ausgaben.entrypoints=http

50
lib/auth.ts Normal file
View File

@@ -0,0 +1,50 @@
/**
* Reusable authentication library
* Configure users via environment variables in .env:
* AUTH_USERS=user1:$2a$10$hash1,user2:$2a$10$hash2
*
* Use scripts/generate-password.js to generate password hashes
*/
export interface User {
username: string;
password: string;
}
/**
* Parse users from environment variable
* Format: username:password,username2:password2
*/
export function getUsers(): User[] {
const usersString = process.env.AUTH_USERS || '';
if (!usersString) {
console.warn('AUTH_USERS not configured in .env');
return [];
}
return usersString
.split(',')
.map((userPair) => {
const [username, password] = userPair.trim().split(':');
return { username: username?.trim(), password: password?.trim() };
})
.filter((user) => user.username && user.password);
}
/**
* Verify user credentials
*/
export function verifyCredentials(username: string, password: string): boolean {
const users = getUsers();
const user = users.find(u => u.username === username);
if (!user) {
return false;
}
return user.password === password;
}
/**
* Check if authentication is enabled
*/
export function isAuthEnabled(): boolean {
return !!process.env.AUTH_USERS;
}

View File

@@ -7,7 +7,7 @@ export function getDbPool() {
pool = mysql.createPool({ pool = mysql.createPool({
host: process.env.DB_HOST || 'localhost', host: process.env.DB_HOST || 'localhost',
user: process.env.DB_USER || 'root', user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '', password: process.env.DB_PASS || process.env.DB_PASSWORD || '',
database: process.env.DB_NAME || 'RXF', database: process.env.DB_NAME || 'RXF',
waitForConnections: true, waitForConnections: true,
connectionLimit: 10, connectionLimit: 10,

102
lib/session.ts Normal file
View File

@@ -0,0 +1,102 @@
import { cookies } from 'next/headers';
import { SignJWT, jwtVerify } from 'jose';
const SESSION_COOKIE_NAME = 'auth_session';
const SESSION_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 days
const secretKey = process.env.AUTH_SECRET || 'default-secret-change-in-production';
const key = new TextEncoder().encode(secretKey);
export interface SessionData {
username: string;
isAuthenticated: boolean;
expiresAt: number;
}
/**
* Encrypt session data to JWT
*/
async function encrypt(payload: SessionData): Promise<string> {
return await new SignJWT(payload as any)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(new Date(payload.expiresAt))
.sign(key);
}
/**
* Decrypt JWT to session data
*/
async function decrypt(token: string): Promise<SessionData | null> {
try {
const { payload } = await jwtVerify(token, key, {
algorithms: ['HS256'],
});
return {
username: payload.username as string,
isAuthenticated: payload.isAuthenticated as boolean,
expiresAt: payload.expiresAt as number,
};
} catch (error) {
return null;
}
}
/**
* Create a new session
*/
export async function createSession(username: string): Promise<void> {
const expiresAt = Date.now() + SESSION_DURATION;
const session: SessionData = {
username,
isAuthenticated: true,
expiresAt,
};
const encryptedSession = await encrypt(session);
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE_NAME, encryptedSession, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
expires: expiresAt,
sameSite: 'lax',
path: '/',
});
}
/**
* Get current session
*/
export async function getSession(): Promise<SessionData | null> {
const cookieStore = await cookies();
const cookie = cookieStore.get(SESSION_COOKIE_NAME);
if (!cookie?.value) {
return null;
}
const session = await decrypt(cookie.value);
if (!session || session.expiresAt < Date.now()) {
return null;
}
return session;
}
/**
* Delete session (logout)
*/
export async function deleteSession(): Promise<void> {
const cookieStore = await cookies();
cookieStore.delete(SESSION_COOKIE_NAME);
}
/**
* Verify if user is authenticated
*/
export async function isAuthenticated(): Promise<boolean> {
const session = await getSession();
return session?.isAuthenticated ?? false;
}

45
package-lock.json generated
View File

@@ -1,13 +1,15 @@
{ {
"name": "ausgaben_next", "name": "ausgaben_next",
"version": "1.0.0", "version": "1.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ausgaben_next", "name": "ausgaben_next",
"version": "1.0.0", "version": "1.2.0",
"dependencies": { "dependencies": {
"bcryptjs": "^3.0.3",
"jose": "^6.1.3",
"mysql2": "^3.17.4", "mysql2": "^3.17.4",
"next": "16.1.6", "next": "16.1.6",
"react": "19.2.3", "react": "19.2.3",
@@ -15,6 +17,7 @@
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
@@ -68,6 +71,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.0", "@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0", "@babel/generator": "^7.29.0",
@@ -1525,6 +1529,13 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@types/bcryptjs": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1551,6 +1562,7 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.34.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.34.tgz",
"integrity": "sha512-by3/Z0Qp+L9cAySEsSNNwZ6WWw8ywgGLPQGgbQDhNRSitqYgkgp4pErd23ZSCavbtUA2CN4jQtoB3T8nk4j3Rg==", "integrity": "sha512-by3/Z0Qp+L9cAySEsSNNwZ6WWw8ywgGLPQGgbQDhNRSitqYgkgp4pErd23ZSCavbtUA2CN4jQtoB3T8nk4j3Rg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@@ -1561,6 +1573,7 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@@ -1620,6 +1633,7 @@
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/types": "8.56.1", "@typescript-eslint/types": "8.56.1",
@@ -2145,6 +2159,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -2453,6 +2468,15 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.12", "version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@@ -2497,6 +2521,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@@ -3073,6 +3098,7 @@
"integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -3258,6 +3284,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@rtsao/scc": "^1.1.0", "@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9", "array-includes": "^3.1.9",
@@ -4483,6 +4510,15 @@
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
} }
}, },
"node_modules/jose": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz",
"integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -5541,6 +5577,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -5550,6 +5587,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@@ -6259,6 +6297,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -6421,6 +6460,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -6695,6 +6735,7 @@
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }

View File

@@ -1,14 +1,17 @@
{ {
"name": "ausgaben_next", "name": "ausgaben_next",
"version": "1.0.1", "version": "2.0.2",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 3005", "dev": "next dev -p 3005",
"build": "next build", "build": "next build",
"start": "next start -p 3005", "start": "next start -p 3005",
"lint": "eslint" "lint": "eslint",
"generate-password": "node scripts/generate-password.js"
}, },
"dependencies": { "dependencies": {
"bcryptjs": "^3.0.3",
"jose": "^6.1.3",
"mysql2": "^3.17.4", "mysql2": "^3.17.4",
"next": "16.1.6", "next": "16.1.6",
"react": "19.2.3", "react": "19.2.3",
@@ -16,6 +19,7 @@
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",

74
proxy.ts Normal file
View File

@@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from 'next/server';
import { jwtVerify } from 'jose';
const SESSION_COOKIE_NAME = 'auth_session';
/**
* Proxy to protect routes with authentication
* Reusable for other projects - just copy this file
*/
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
// Check if authentication is enabled
const authEnabled = !!process.env.AUTH_USERS;
// If auth is not enabled, allow all requests
if (!authEnabled) {
return NextResponse.next();
}
// Public paths that don't require authentication
const publicPaths = ['/login'];
const isPublicPath = publicPaths.some(path => pathname.startsWith(path));
if (isPublicPath) {
return NextResponse.next();
}
// Check for session cookie
const sessionCookie = request.cookies.get(SESSION_COOKIE_NAME);
if (!sessionCookie) {
return NextResponse.redirect(new URL('/login', request.url));
}
// Verify session token
try {
const secretKey = process.env.AUTH_SECRET || 'default-secret-change-in-production';
const key = new TextEncoder().encode(secretKey);
const { payload } = await jwtVerify(sessionCookie.value, key, {
algorithms: ['HS256'],
});
// Check if session is expired
if (payload.expiresAt && (payload.expiresAt as number) < Date.now()) {
const response = NextResponse.redirect(new URL('/login', request.url));
response.cookies.delete(SESSION_COOKIE_NAME);
return response;
}
return NextResponse.next();
} catch (error) {
// Invalid token - redirect to login
const response = NextResponse.redirect(new URL('/login', request.url));
response.cookies.delete(SESSION_COOKIE_NAME);
return response;
}
}
export default proxy;
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
* - public folder
*/
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
};

61
scripts/generate-password.js Executable file
View File

@@ -0,0 +1,61 @@
#!/usr/bin/env node
/**
* Password Hash Generator
*
* Usage:
* node scripts/generate-password.js [password]
*
* If no password is provided, you'll be prompted to enter one.
*/
const bcrypt = require('bcryptjs');
const readline = require('readline');
function generateHash(password) {
const saltRounds = 10;
const hash = bcrypt.hashSync(password, saltRounds);
return hash;
}
function promptPassword() {
return new Promise((resolve) => {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
rl.question('Passwort eingeben: ', (password) => {
rl.close();
resolve(password);
});
});
}
async function main() {
let password = process.argv[2];
if (!password) {
password = await promptPassword();
}
if (!password) {
console.error('❌ Kein Passwort angegeben!');
process.exit(1);
}
console.log('\n🔐 Generiere Passwort-Hash...\n');
const hash = generateHash(password);
console.log('✅ Hash generiert:');
console.log('─'.repeat(80));
console.log(hash);
console.log('─'.repeat(80));
console.log('\n📝 Verwende diesen Hash in der .env Datei:');
console.log(`AUTH_USERS=username:${hash}`);
console.log('\n💡 Beispiel für mehrere Benutzer:');
console.log(`AUTH_USERS=admin:${hash},user2:$2a$10$...\n`);
}
main().catch(console.error);

View File

@@ -6,6 +6,7 @@ export interface AusgabenEntry {
WochTag: string; WochTag: string;
Wo: string; Wo: string;
Was: string; Was: string;
Kat: string;
Wieviel: number; Wieviel: number;
Wie: string; Wie: string;
TYP: number; TYP: number;
@@ -16,6 +17,7 @@ export interface CreateAusgabenEntry {
WochTag: string; WochTag: string;
Wo: string; Wo: string;
Was: string; Was: string;
Kat: string;
Wieviel: string | number; Wieviel: string | number;
Wie: string; Wie: string;
TYP: number; TYP: number;
@@ -33,6 +35,7 @@ export interface MonthlyStats {
MASTER?: number; MASTER?: number;
Einnahmen: number; Einnahmen: number;
Ueberweisungen: number; Ueberweisungen: number;
katStats?: Record<string, number>;
} }
// Haushalt Zahlungsarten (TYP = 0) // Haushalt Zahlungsarten (TYP = 0)