Compare commits

...

11 Commits

Author SHA1 Message Date
de28922784 V 2.1.1 Passwort-Sichtbarkeit im Login per Auge-Icon umschaltbar
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 11:40:09 +02:00
38c18a5ead V 2.1.0 Verbesserungen von Claud Code eingefügt 2026-04-27 10:39:09 +02:00
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
24 changed files with 652 additions and 341 deletions

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 }
);
}
}

View File

@@ -8,16 +8,11 @@ export async function login(prevState: any, formData: FormData) {
const username = formData.get('username') as string; const username = formData.get('username') as string;
const password = formData.get('password') as string; const password = formData.get('password') as string;
console.log('Login attempt:', { username, passwordLength: password?.length });
console.log('AUTH_USERS env:', process.env.AUTH_USERS);
if (!username || !password) { if (!username || !password) {
return { error: 'Bitte Benutzername und Passwort eingeben' }; return { error: 'Bitte Benutzername und Passwort eingeben' };
} }
const isValid = verifyCredentials(username, password); const isValid = await verifyCredentials(username, password);
console.log('Credentials valid:', isValid);
if (!isValid) { if (!isValid) {
return { error: 'Ungültige Anmeldedaten' }; return { error: 'Ungültige Anmeldedaten' };

View File

@@ -1,79 +1,116 @@
'use client'; 'use client';
import { useActionState } from 'react'; import { useActionState, useState } from 'react';
import { login } from './actions'; import { login } from './actions';
import packageJson from '@/package.json';
export default function LoginPage() { export default function LoginPage() {
const [state, loginAction, isPending] = useActionState(login, undefined); const [state, loginAction, isPending] = useActionState(login, undefined);
const [showPassword, setShowPassword] = useState(false);
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 ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-gray-900 dark:to-gray-800 px-4"> <div className="min-h-screen bg-white py-4 px-4">
<div className="max-w-md w-full space-y-8 bg-white dark:bg-gray-800 p-8 rounded-2xl shadow-xl"> <main className="max-w-6xl mx-auto border-2 border-black rounded-lg p-6 bg-[#FFFFDD]">
<div className="text-center"> <div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2"> <h1 className="text-3xl font-bold">Ausgaben - Log</h1>
Anmeldung
</h1>
<p className="text-gray-600 dark:text-gray-400">
Bitte melden Sie sich an, um fortzufahren
</p>
</div> </div>
<form action={loginAction} className="mt-8 space-y-6"> <div className="flex justify-center py-10">
<div className="space-y-4"> <div className="w-full max-w-sm bg-white border border-gray-300 rounded-xl shadow-md p-8">
<div> <h2 className="text-xl font-semibold text-gray-900 mb-6 text-center">Anmeldung</h2>
<label
htmlFor="username"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Benutzername
</label>
<input
id="username"
name="username"
type="text"
required
autoComplete="username"
className="appearance-none relative block w-full px-4 py-3 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white bg-white dark:bg-gray-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors"
placeholder="Benutzername"
disabled={isPending}
/>
</div>
<div> <form action={loginAction} className="space-y-5">
<label <div>
htmlFor="password" <label
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" htmlFor="username"
> className="block text-sm font-medium text-gray-700 mb-1"
Passwort >
</label> Benutzername
<input </label>
id="password" <input
name="password" id="username"
type="password" name="username"
required type="text"
autoComplete="current-password" required
className="appearance-none relative block w-full px-4 py-3 border border-gray-300 dark:border-gray-600 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white bg-white dark:bg-gray-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors" autoComplete="off"
placeholder="Passwort" 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>
<div className="relative">
<input
id="password"
name="password"
type={showPassword ? 'text' : 'password'}
required
autoComplete="new-password"
className="w-full px-3 py-2 pr-10 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}
/>
<button
type="button"
onClick={() => setShowPassword(v => !v)}
tabIndex={-1}
aria-label={showPassword ? 'Passwort verbergen' : 'Passwort anzeigen'}
className="absolute inset-y-0 right-0 px-3 flex items-center text-gray-500 hover:text-gray-800"
>
{showPassword ? (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 4.411m0 0L21 21" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
)}
</button>
</div>
</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} 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"
</div> >
{isPending ? 'Anmeldung läuft...' : 'Anmelden'}
</button>
</form>
</div> </div>
</div>
{state?.error && ( {/* Footer */}
<div className="bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg text-sm"> <footer className="mt-8 flex justify-between items-center text-sm text-gray-600 px-4 ">
{state.error} <div>
</div> <a href="mailto:rxf@gmx.de" className="text-blue-600 hover:underline">
)} mailto:rxf@gmx.de
</a>
<button </div>
type="submit" <div className="text-right">
disabled={isPending} Version {version} - {buildDate}
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" </div>
> </footer>
{isPending ? 'Anmeldung läuft...' : 'Anmelden'} </main>
</button>
</form>
</div>
</div> </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,18 +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 LogoutButton from '@/components/LogoutButton'; 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();
@@ -24,12 +24,13 @@ 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',
}, },
}); });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json(); const data = await response.json();
if (data.success) { if (data.success) {
setEntries(data.data); setEntries(data.data);
@@ -43,6 +44,7 @@ export default function Home() {
const handleSuccess = () => { const handleSuccess = () => {
setSelectedEntry(null); setSelectedEntry(null);
setStatsRefreshKey((k) => k + 1);
setTimeout(() => { setTimeout(() => {
fetchRecentEntries(); fetchRecentEntries();
}, 100); }, 100);
@@ -50,6 +52,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) => {
@@ -58,63 +61,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>
<div className="flex justify-between items-center mb-6"> <h2 className="text-xl font-semibold mb-4">Eingabe</h2>
<h1 className="text-3xl font-bold">Ausgaben - Log</h1> <AusgabenForm onSuccess={handleSuccess} selectedEntry={selectedEntry} typ={activeTab} />
<LogoutButton className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm rounded-lg transition-colors shadow-md" />
</div>
{/* 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,121 @@
'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}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
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

@@ -1,35 +0,0 @@
const bcrypt = require('bcryptjs');
// Direkt aus .env kopiert
const AUTH_USERS = 'rxf:$2b$10$VdshbfnSFZIn59QJqDRiROi.ekU83ObiQBM.R3MVaSIcGQb5eYbEq';
console.log('=== AUTH DEBUG ===\n');
console.log('AUTH_USERS:', AUTH_USERS);
console.log('');
const usersString = AUTH_USERS || '';
const users = usersString
.split(',')
.map((userPair) => {
const [username, passwordHash] = userPair.trim().split(':');
return { username: username?.trim(), passwordHash: passwordHash?.trim() };
})
.filter((user) => user.username && user.passwordHash);
console.log('Parsed users:', JSON.stringify(users, null, 2));
console.log('');
// Test credentials
const testUser = 'rxf';
const testPassword = 'Fluorit';
const user = users.find(u => u.username === testUser);
console.log('Found user:', user);
console.log('');
if (user) {
console.log('Testing password:', testPassword);
console.log('Against hash:', user.passwordHash);
const result = bcrypt.compareSync(testPassword, user.passwordHash);
console.log('Result:', result);
}

View File

@@ -15,34 +15,39 @@ 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}"
echo "Tag: ${TAG}" echo "Tag: ${TAG}"
echo "Build-Datum: ${BUILD_DATE}" 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

@@ -1,20 +1,10 @@
/** import bcrypt from 'bcryptjs';
* 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 { export interface User {
username: string; username: string;
password: string; password: string;
} }
/**
* Parse users from environment variable
* Format: username:password,username2:password2
*/
export function getUsers(): User[] { export function getUsers(): User[] {
const usersString = process.env.AUTH_USERS || ''; const usersString = process.env.AUTH_USERS || '';
if (!usersString) { if (!usersString) {
@@ -30,21 +20,15 @@ export function getUsers(): User[] {
.filter((user) => user.username && user.password); .filter((user) => user.username && user.password);
} }
/** export async function verifyCredentials(username: string, password: string): Promise<boolean> {
* Verify user credentials
*/
export function verifyCredentials(username: string, password: string): boolean {
const users = getUsers(); const users = getUsers();
const user = users.find(u => u.username === username); const user = users.find(u => u.username === username);
if (!user) { if (!user) {
return false; return false;
} }
return user.password === password; return bcrypt.compare(password, user.password);
} }
/**
* Check if authentication is enabled
*/
export function isAuthEnabled(): boolean { export function isAuthEnabled(): boolean {
return !!process.env.AUTH_USERS; 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,

View File

@@ -2,6 +2,20 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: 'standalone', output: 'standalone',
async headers() {
return [
{
source: '/(.*)',
headers: [
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
],
},
];
},
}; };
export default nextConfig; export default nextConfig;

17
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "ausgaben_next", "name": "ausgaben_next",
"version": "1.0.1", "version": "1.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ausgaben_next", "name": "ausgaben_next",
"version": "1.0.1", "version": "1.2.0",
"dependencies": { "dependencies": {
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"jose": "^6.1.3", "jose": "^6.1.3",
@@ -71,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",
@@ -1561,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"
} }
@@ -1571,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"
} }
@@ -1630,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",
@@ -2155,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"
}, },
@@ -2516,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",
@@ -3092,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",
@@ -3277,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",
@@ -5569,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"
} }
@@ -5578,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"
}, },
@@ -6287,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"
}, },
@@ -6449,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"
@@ -6723,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,6 +1,6 @@
{ {
"name": "ausgaben_next", "name": "ausgaben_next",
"version": "1.0.1", "version": "2.1.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 3005", "dev": "next dev -p 3005",

View File

@@ -1,14 +1,13 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { jwtVerify } from 'jose'; import { jwtVerify } from 'jose';
const SESSION_COOKIE_NAME = 'auth_session'; const SESSION_COOKIE_NAME = 'auth_session';
/** /**
* Middleware to protect routes with authentication * Proxy to protect routes with authentication
* Reusable for other projects - just copy this file * Reusable for other projects - just copy this file
*/ */
export async function middleware(request: NextRequest) { export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl; const { pathname } = request.nextUrl;
// Check if authentication is enabled // Check if authentication is enabled
@@ -28,8 +27,7 @@ export async function middleware(request: NextRequest) {
} }
// Check for session cookie // Check for session cookie
const cookieStore = await cookies(); const sessionCookie = request.cookies.get(SESSION_COOKIE_NAME);
const sessionCookie = cookieStore.get(SESSION_COOKIE_NAME);
if (!sessionCookie) { if (!sessionCookie) {
return NextResponse.redirect(new URL('/login', request.url)); return NextResponse.redirect(new URL('/login', request.url));
@@ -60,6 +58,8 @@ export async function middleware(request: NextRequest) {
} }
} }
export default proxy;
export const config = { export const config = {
matcher: [ matcher: [
/* /*

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)