20 Commits

Author SHA1 Message Date
admin 7aa42c9952 Add phpMyAdmin service to local docker-compose
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 16:45:13 +02:00
admin e5868d904a Merge branch 'responsive-mobile-fixes'
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 07:53:59 +02:00
admin dff78027b6 V 2.3.1 Formular und Liste responsiv gestaltet
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 07:53:51 +02:00
admin cd96bdfc6a Formular und Liste responsiv gestaltet
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 18:41:32 +02:00
admin 4acb1a5eca Version auf 2.3.0 erhöht
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 13:59:30 +02:00
admin bb35aa28c9 Merge branch 'entferne-login-logik'
Login-/Session-/Passkey-Logik entfernt, App läuft künftig nur noch lokal.
2026-08-21 13:59:15 +02:00
admin bacd11f722 Login-/Session-/Passkey-Logik vollständig entfernen
Die App läuft künftig nur noch lokal, daher ist Anmeldung nicht mehr
nötig: Proxy-Middleware, Login-Seite, Session-Handling, Passkey/WebAuthn
(inkl. DB-Tabelle ausgaben_passkeys), Logout-Button und der Passwort-Hash-
Generator (scripts/generate-password.js) entfallen. Der Einstellungen-Tab
enthielt ausschließlich Passkey-Verwaltung und wurde daher ebenfalls
entfernt. Nicht mehr benötigte Abhängigkeiten (@simplewebauthn/*,
bcryptjs, jose) sowie die zugehörigen Env-Variablen (AUTH_USERS,
AUTH_SECRET, AUSGABEN_RP_*) wurden aus package.json, Docker-Compose-
Dateien und Dokumentation gestrichen. AUTH_README.md komplett entfernt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 13:58:46 +02:00
admin d3718da1f8 V 2.2.1 Anmeldung per Passkey (WebAuthn)
Passkey-Login analog zu werte-next: auf der Login-Seite "Mit Passkey
anmelden", Verwaltung der eigenen Passkeys im neuen Tab Einstellungen.
Das Passwort bleibt als Alternative bestehen.

- lib/webauthn.ts, lib/passkeys.ts sowie API-Routen unter /api/passkey
- proxy.ts: /api/passkey/authenticate ohne Session erreichbar
- TabLayout: dritter Tab Einstellungen (Index-Tabs statt Route wie werte)
- RP-Konfiguration ueber AUSGABEN_RP_*, da sich die .env auf dem Server
  mit anderen Apps teilt, die RP_ID/RP_ORIGIN bereits belegen
- Eigene Tabelle ausgaben_passkeys aus demselben Grund: werte-next nutzt
  in der gemeinsamen Datenbank RXF bereits die Tabelle passkeys
- eslint.config.mjs auf die Flat-Config von eslint-config-next umgestellt

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 16:35:35 +02:00
admin 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
admin 38c18a5ead V 2.1.0 Verbesserungen von Claud Code eingefügt 2026-04-27 10:39:09 +02:00
admin a7863c519f V 2.0.2 Nun mit Tabs
Anzahl in der Liste als const
2026-03-06 13:36:21 +01:00
admin 204bf3bf8b V 1.2.2. Environment f. DB_PASS angepasst 2026-03-06 13:09:29 +01:00
admin 46678cb644 V 1.2.1 Multiplatfoprm Build
Version auch auf der Login-Seite
2026-03-05 09:05:27 +01:00
admin 74e5f76ec2 V 1.2.0 migration.tsx in proxy.tsx umbenannt
Loginpage eingebettet
2026-03-04 13:20:50 +00:00
admin 90444b8f7d V 1.1.0 nur Version geändert 2026-03-01 11:49:36 +00:00
admin 2a9ae7e806 Summen-Statistik der Kategorien
Eigenes 'Löschen' PopUp
2026-03-01 11:48:24 +00:00
admin ed6bc21248 Kategorien dazu 2026-03-01 11:26:44 +00:00
admin 319ac8699e V 1.0.2. Keine vorausgefüllte Login-Seite mehr 2026-03-01 08:46:59 +00:00
admin 8c6d1bcf6d V 1.0.1: Auth erfolgreich 2026-03-01 08:35:46 +00:00
admin 1ccd66b307 weiter die Sache mit Auth 2026-03-01 07:52:31 +00:00
22 changed files with 629 additions and 317 deletions
-10
View File
@@ -2,13 +2,3 @@ DB_HOST=localhost
DB_USER=root
DB_PASSWORD=your_password
DB_NAME=RXF
# Authentication Configuration
# Format: username:passwordHash,username2:passwordHash2 (max 5 users)
# Use 'node scripts/generate-password.js [password]' to generate hashes
# Leave empty to disable authentication
# Example hashes below (passwords: admin123, pass1):
AUTH_USERS=admin:$2b$10$DKLO7uQPmdAw9Z64NChro.8mOsnqZQaRZjctWDojIkK926ROBVyJW,user1:$2b$10$K613Z70Hodr6xyEh10Mw2uoRZMV3U4LIB09929JUWw2n/pXKoUqaW
# Secret key for JWT session encryption (change in production!)
AUTH_SECRET=your-super-secret-key-change-this-in-production
+17
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"
}
]
}
+1 -1
View File
@@ -37,7 +37,7 @@ RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# 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/static ./.next/static
+5
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;
+3 -2
View File
@@ -10,7 +10,7 @@ export async function PUT(
try {
const { id } = await context.params;
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) {
return NextResponse.json(
@@ -23,7 +23,7 @@ export async function PUT(
const query = `
UPDATE Ausgaben
SET Datum = ?, Wo = ?, Was = ?, Wieviel = ?, Wie = ?, TYP = ?
SET Datum = ?, Wo = ?, Was = ?, Kat = ?, Wieviel = ?, Wie = ?, TYP = ?
WHERE ID = ?
`;
@@ -31,6 +31,7 @@ export async function PUT(
Datum,
Wo,
Was,
Kat || 'L',
parseFloat(Wieviel),
Wie,
TYP,
+5 -4
View File
@@ -15,7 +15,7 @@ export async function GET(request: Request) {
const pool = getDbPool();
let query = `SELECT
ID, Datum, Wo, Was, Wieviel, Wie, TYP,
ID, Datum, Wo, Was, Kat, Wieviel, Wie, TYP,
CASE DAYOFWEEK(Datum)
WHEN 1 THEN 'Sonntag'
WHEN 2 THEN 'Montag'
@@ -68,7 +68,7 @@ export async function GET(request: Request) {
export async function POST(request: Request) {
try {
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) {
return NextResponse.json(
@@ -80,14 +80,15 @@ export async function POST(request: Request) {
const pool = getDbPool();
const query = `
INSERT INTO Ausgaben (Datum, Wo, Was, Wieviel, Wie, TYP)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO Ausgaben (Datum, Wo, Was, Kat, Wieviel, Wie, TYP)
VALUES (?, ?, ?, ?, ?, ?, ?)
`;
const [result] = await pool.query<ResultSetHeader>(query, [
Datum,
Wo,
Was,
Kat || 'L',
parseFloat(Wieviel),
Wie,
TYP,
+16 -1
View File
@@ -56,6 +56,21 @@ export async function GET(request: Request) {
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
const parsedData: any = {
totalAusgaben: parseFloat(data.totalAusgaben) || 0,
@@ -77,7 +92,7 @@ export async function GET(request: Request) {
return NextResponse.json({
success: true,
data: parsedData,
data: { ...parsedData, katStats },
});
} catch (error) {
console.error('Database error:', error);
+33
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 -59
View File
@@ -3,32 +3,35 @@
import { useState, useEffect } from 'react';
import AusgabenForm from '@/components/AusgabenForm';
import AusgabenList from '@/components/AusgabenList';
import MonatsStatistik from '@/components/MonatsStatistik';
import TabLayout from '@/components/TabLayout';
import { AusgabenEntry } from '@/types/ausgaben';
import packageJson from '@/package.json';
const MAX_ENTRIES = 15;
const MOBILE_ENTRIES = 5;
export default function Home() {
const [activeTab, setActiveTab] = useState(0); // 0 = Haushalt, 1 = Privat
const [entries, setEntries] = useState<AusgabenEntry[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [selectedEntry, setSelectedEntry] = useState<AusgabenEntry | null>(null);
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' });
const [statsRefreshKey, setStatsRefreshKey] = useState(0);
useEffect(() => {
fetchRecentEntries();
setSelectedEntry(null); // Clear selected entry when switching tabs
fetchRecentEntries();
}, [activeTab]);
const fetchRecentEntries = async () => {
setIsLoading(true);
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',
headers: {
'Cache-Control': 'no-cache',
},
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
if (data.success) {
setEntries(data.data);
@@ -42,6 +45,7 @@ export default function Home() {
const handleSuccess = () => {
setSelectedEntry(null);
setStatsRefreshKey((k) => k + 1);
setTimeout(() => {
fetchRecentEntries();
}, 100);
@@ -49,6 +53,7 @@ export default function Home() {
const handleDelete = (id: number) => {
setEntries(entries.filter(entry => entry.ID !== id));
setStatsRefreshKey((k) => k + 1);
};
const handleEdit = (entry: AusgabenEntry) => {
@@ -57,60 +62,24 @@ export default function Home() {
};
return (
<div className="min-h-screen bg-white py-4 px-4">
<main className="max-w-7xl mx-auto border-2 border-black rounded-lg p-6 bg-[#FFFFDD]">
<h1 className="text-3xl font-bold text-center mb-6">Ausgaben - Log</h1>
{/* 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>
<TabLayout activeTab={activeTab} onTabChange={setActiveTab}>
<div>
<h2 className="text-xl font-semibold mb-4">Eingabe</h2>
<AusgabenForm onSuccess={handleSuccess} selectedEntry={selectedEntry} typ={activeTab} />
<div>
<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>
<MonatsStatistik typ={activeTab} refreshKey={statsRefreshKey} />
{/* 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>
<div className="mt-6 bg-white border border-black rounded-lg shadow-md p-6">
<h3 className="text-xl font-semibold mb-4">
Letzte <span className="md:hidden">{MOBILE_ENTRIES}</span><span className="hidden md:inline">{MAX_ENTRIES}</span> Einträge
</h3>
{isLoading ? (
<div className="text-center py-4">Lade Daten...</div>
) : (
<AusgabenList entries={entries} onDelete={handleDelete} onEdit={handleEdit} mobileLimit={MOBILE_ENTRIES} />
)}
</div>
</div>
</TabLayout>
);
}
+15
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
+149 -189
View File
@@ -1,7 +1,8 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { CreateAusgabenEntry, AusgabenEntry, ZAHLUNGSARTEN_HAUSHALT, ZAHLUNGSARTEN_PRIVAT, MonthlyStats } from '@/types/ausgaben';
import { useState, useEffect, useCallback, useRef } from 'react';
import { CreateAusgabenEntry, AusgabenEntry, ZAHLUNGSARTEN_HAUSHALT, ZAHLUNGSARTEN_PRIVAT } from '@/types/ausgaben';
import { Category } from '@/app/api/categories/route';
interface AusgabenFormProps {
onSuccess: () => void;
@@ -18,6 +19,7 @@ export default function AusgabenForm({ onSuccess, selectedEntry, typ }: Ausgaben
WochTag: '',
Wo: '',
Was: '',
Kat: 'L',
Wieviel: '',
Wie: defaultZahlungsart,
TYP: typ,
@@ -26,32 +28,12 @@ export default function AusgabenForm({ onSuccess, selectedEntry, typ }: Ausgaben
const [isSubmitting, setIsSubmitting] = useState(false);
const [editId, setEditId] = useState<number | null>(null);
// Monthly stats
const [stats, setStats] = useState<MonthlyStats | null>(null);
const [month, setMonth] = useState('');
const [year, setYear] = useState('');
const [isLoadingStats, setIsLoadingStats] = useState(false);
// Autocomplete data
const [autoCompleteWo, setAutoCompleteWo] = useState<string[]>([]);
const [autoCompleteWas, setAutoCompleteWas] = useState<string[]>([]);
const fetchStats = useCallback(async (y: string, m: string) => {
if (!y || !m) return;
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 [categories, setCategories] = useState<Category[]>([]);
const [katDropdownOpen, setKatDropdownOpen] = useState(false);
const katDropdownRef = useRef<HTMLDivElement>(null);
const fetchAutoComplete = useCallback(async () => {
try {
@@ -66,42 +48,30 @@ export default function AusgabenForm({ onSuccess, selectedEntry, typ }: Ausgaben
}
}, [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
useEffect(() => {
fetchAutoComplete();
}, [typ, fetchAutoComplete]);
const handleMonthChange = (newMonth: string) => {
setMonth(newMonth);
};
// Close Kat dropdown when clicking outside
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) => {
setYear(newYear);
};
// Fetch categories once on mount
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(() => {
if (selectedEntry) {
@@ -113,6 +83,7 @@ export default function AusgabenForm({ onSuccess, selectedEntry, typ }: Ausgaben
WochTag: selectedEntry.WochTag,
Wo: selectedEntry.Wo,
Was: selectedEntry.Was,
Kat: selectedEntry.Kat || 'L',
Wieviel: selectedEntry.Wieviel.toString(),
Wie: selectedEntry.Wie,
TYP: selectedEntry.TYP,
@@ -132,6 +103,7 @@ export default function AusgabenForm({ onSuccess, selectedEntry, typ }: Ausgaben
WochTag: weekday,
Wo: '',
Was: '',
Kat: 'L',
Wieviel: '',
Wie: defaultZahlungsart,
TYP: typ,
@@ -184,7 +156,8 @@ export default function AusgabenForm({ onSuccess, selectedEntry, typ }: Ausgaben
Datum: formData.Datum,
Wo: formData.Wo,
Was: formData.Was,
Wieviel: formData.Wieviel,
Kat: formData.Kat,
Wieviel: String(formData.Wieviel).replace(',', '.'),
Wie: formData.Wie,
TYP: formData.TYP,
};
@@ -200,8 +173,6 @@ export default function AusgabenForm({ onSuccess, selectedEntry, typ }: Ausgaben
if (response.ok) {
handleReset();
onSuccess();
// Refresh stats after successful save
fetchStats(year, month);
} else {
alert('Fehler beim Speichern!');
}
@@ -223,6 +194,7 @@ export default function AusgabenForm({ onSuccess, selectedEntry, typ }: Ausgaben
WochTag: weekday,
Wo: '',
Was: '',
Kat: 'L',
Wieviel: '',
Wie: defaultZahlungsart,
TYP: typ,
@@ -232,98 +204,133 @@ export default function AusgabenForm({ onSuccess, selectedEntry, typ }: Ausgaben
setEditId(null);
};
const fieldInputClass = "w-full min-w-0 px-2 py-1 text-sm rounded border-2 border-gray-400 bg-white focus:border-blue-500 focus:outline-none";
return (
<div className="bg-[#CCCCFF] border border-black p-6 rounded-lg mb-6">
<div className="bg-[#CCCCFF] border border-black p-3 sm:p-6 rounded-lg mb-6">
{editId && (
<div className="mb-4 p-3 bg-blue-100 border border-blue-400 rounded text-sm text-blue-800">
<strong>Bearbeitungsmodus:</strong> Sie bearbeiten einen bestehenden Eintrag.
</div>
)}
<form onSubmit={handleSubmit}>
<table className="w-full text-center">
<thead>
<tr >
<th className="p-2 w-32">Datum</th>
<th className="p-2">Wo</th>
<th className="p-2">Was</th>
<th className="p-2 w-24">Wieviel</th>
<th className="p-2 w-4"></th>
<th className="p-2 w-38 text-left">Wie</th>
</tr>
</thead>
<tbody>
<tr>
<td className="p-2 w-32">
<input
type="date"
value={formData.Datum}
onChange={(e) => handleDateChange(e.target.value)}
className="w-full px-2 py-1 text-base rounded border-2 border-gray-400 bg-white focus:border-blue-500 focus:outline-none"
required
/>
</td>
<td className="p-2">
<input
type="text"
value={formData.Wo}
onChange={(e) => setFormData({ ...formData, Wo: e.target.value })}
className="w-full px-2 py-1 text-base rounded border-2 border-gray-400 bg-white focus:border-blue-500 focus:outline-none"
placeholder="Geschäft/Ort"
list="wo-suggestions"
required
/>
<datalist id="wo-suggestions">
{autoCompleteWo.map((wo, index) => (
<option key={index} value={wo} />
{/* Wo/Was sind auf Mobil/Tablet volle Breite (mehr Platz zum Tippen), am Desktop feste Spaltenbreiten wie zuvor im Tabellen-Layout. */}
<div className="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-[110px_1fr_1fr_70px_100px_140px] gap-3 sm:gap-2">
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-center">Datum</label>
<input
type="date"
value={formData.Datum}
onChange={(e) => handleDateChange(e.target.value)}
className={fieldInputClass}
required
/>
</div>
<div className="flex flex-col gap-1 col-span-2 sm:col-span-2 md:col-span-1">
<label className="text-xs font-medium text-center">Wo</label>
<input
type="text"
value={formData.Wo}
onChange={(e) => setFormData({ ...formData, Wo: e.target.value })}
className={fieldInputClass}
placeholder="Geschäft/Ort"
list="wo-suggestions"
required
/>
</div>
<div className="flex flex-col gap-1 col-span-2 sm:col-span-2 md:col-span-1">
<label className="text-xs font-medium text-center">Was</label>
<input
type="text"
value={formData.Was}
onChange={(e) => setFormData({ ...formData, Was: e.target.value })}
className={fieldInputClass}
placeholder="Beschreibung"
list="was-suggestions"
required
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-center">Kat.</label>
<div ref={katDropdownRef} className="relative w-full">
<button
type="button"
onClick={() => setKatDropdownOpen((o) => !o)}
className={`${fieldInputClass} text-left`}
>
{formData.Kat}
</button>
{katDropdownOpen && (
<ul className="absolute z-50 left-0 mt-1 w-48 max-w-[calc(100vw-2rem)] 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>
))}
</datalist>
</td>
<td className="p-2">
<input
type="text"
value={formData.Was}
onChange={(e) => setFormData({ ...formData, Was: e.target.value })}
className="w-full px-2 py-1 text-base rounded border-2 border-gray-400 bg-white focus:border-blue-500 focus:outline-none"
placeholder="Beschreibung"
list="was-suggestions"
required
/>
<datalist id="was-suggestions">
{autoCompleteWas.map((was, index) => (
<option key={index} value={was} />
))}
</datalist>
</td>
<td className="p-2 w-24">
<input
type="number"
step="0.01"
value={formData.Wieviel}
onChange={(e) => setFormData({ ...formData, Wieviel: e.target.value })}
className="w-full px-2 py-1 text-base rounded border-2 border-gray-400 bg-white focus:border-blue-500 focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
placeholder="0.00"
required
/>
</td>
<td className="p-2 w-4 text-left"></td>
<td className="p-2 w-38">
<select
value={formData.Wie}
onChange={(e) => setFormData({ ...formData, Wie: e.target.value })}
className="w-full px-2 py-1 text-base rounded border-2 border-gray-400 bg-white focus:border-blue-500 focus:outline-none"
required
>
{zahlungsarten.map((za) => (
<option key={za.value} value={za.value}>
{za.label}
</option>
))}
</select>
</td>
</tr>
</tbody>
</table>
</ul>
)}
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-center">
Wieviel
<span className="text-[10px] font-normal"> ()</span>
</label>
<input
type="text"
inputMode="decimal"
value={formData.Wieviel}
onChange={(e) => {
const val = e.target.value.replace(/[^0-9.,]/g, '').replace(',', '.');
setFormData({ ...formData, Wieviel: val });
}}
className={fieldInputClass}
placeholder="0.00"
required
/>
</div>
<div className="flex flex-col gap-1 col-span-2 sm:col-span-2 md:col-span-1">
<label className="text-xs font-medium text-center">Wie</label>
<select
value={formData.Wie}
onChange={(e) => setFormData({ ...formData, Wie: e.target.value })}
className={fieldInputClass}
required
>
{zahlungsarten.map((za) => (
<option key={za.value} value={za.value}>
{za.label}
</option>
))}
</select>
</div>
</div>
<datalist id="wo-suggestions">
{autoCompleteWo.map((wo, index) => (
<option key={index} value={wo} />
))}
</datalist>
<datalist id="was-suggestions">
{autoCompleteWas.map((was, index) => (
<option key={index} value={was} />
))}
</datalist>
{/* Wochentag */}
<div className="mt-3 text-left pl-3">
@@ -331,7 +338,7 @@ export default function AusgabenForm({ onSuccess, selectedEntry, typ }: Ausgaben
</div>
{/* Buttons */}
<div className="mb-3 flex gap-10 justify-center">
<div className="mb-3 flex gap-4 sm:gap-10 justify-center">
<button
type="submit"
disabled={isSubmitting}
@@ -347,53 +354,6 @@ export default function AusgabenForm({ onSuccess, selectedEntry, typ }: Ausgaben
Löschen
</button>
</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>
</div>
);
+77 -18
View File
@@ -1,22 +1,22 @@
'use client';
import { useState } from 'react';
import { AusgabenEntry } from '@/types/ausgaben';
interface AusgabenListProps {
entries: AusgabenEntry[];
onDelete: (id: number) => void;
onEdit: (entry: AusgabenEntry) => void;
mobileLimit?: number;
}
export default function AusgabenList({ entries, onDelete, onEdit }: AusgabenListProps) {
const handleDelete = async (id: number) => {
if (!confirm('Wirklich löschen?')) return;
export default function AusgabenList({ entries, onDelete, onEdit, mobileLimit }: AusgabenListProps) {
const [confirmId, setConfirmId] = useState<number | null>(null);
const handleDeleteConfirmed = async (id: number) => {
setConfirmId(null);
try {
const response = await fetch(`/api/ausgaben/${id}`, {
method: 'DELETE',
});
const response = await fetch(`/api/ausgaben/${id}`, { method: 'DELETE' });
if (response.ok) {
onDelete(id);
} else {
@@ -29,12 +29,7 @@ export default function AusgabenList({ entries, onDelete, onEdit }: AusgabenList
};
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
return dateStr.toString().split('T')[0];
};
const formatAmount = (amount: number) => {
@@ -45,7 +40,8 @@ export default function AusgabenList({ entries, onDelete, onEdit }: AusgabenList
};
return (
<div className="overflow-x-auto">
<div>
<div className="hidden md:block overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-[#CCCCFF]">
@@ -53,15 +49,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-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-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-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>
</thead>
<tbody>
{entries.length === 0 ? (
<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
</td>
</tr>
@@ -74,6 +71,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">{entry.Wo}</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">
{formatAmount(entry.Wieviel)}
</td>
@@ -83,10 +81,10 @@ export default function AusgabenList({ entries, onDelete, onEdit }: AusgabenList
onClick={() => onEdit(entry)}
className="text-blue-600 hover:text-blue-800 px-3 py-1 rounded text-sm mr-2"
>
Bearbeiten
Editieren
</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"
>
Löschen
@@ -97,6 +95,67 @@ export default function AusgabenList({ entries, onDelete, onEdit }: AusgabenList
)}
</tbody>
</table>
</div>
<div className="md:hidden flex flex-col gap-2">
{entries.length === 0 ? (
<div className="text-center py-4 text-gray-500">Keine Einträge vorhanden</div>
) : (
(mobileLimit ? entries.slice(0, mobileLimit) : entries).map((entry, index) => (
<div
key={entry.ID || `entry-idx-${index}`}
className={`border border-gray-300 rounded-lg p-2 ${index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}`}
>
<div className="flex justify-between items-center text-sm font-medium">
<span>{formatDate(entry.Datum)} ({entry.WochTag.slice(0, 2)}) · {formatAmount(entry.Wieviel)}</span>
<div className="flex gap-2">
<button
onClick={() => onEdit(entry)}
className="text-blue-600 hover:text-blue-800 text-xs"
>
Editieren
</button>
<button
onClick={() => setConfirmId(entry.ID)}
className="text-red-600 hover:text-red-800 text-xs"
>
Löschen
</button>
</div>
</div>
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs text-gray-700 mt-1">
<span>Wo: {entry.Wo}</span>
<span>Was: {entry.Was}</span>
<span>Kat: {entry.Kat}</span>
<span>Wie: {entry.Wie}</span>
</div>
</div>
))
)}
</div>
{/* 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 max-w-[calc(100vw-2rem)] mx-4">
<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>
);
}
+121
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-3 sm:p-4">
{/* Zeile 1: Monat/Jahr + Gesamtsumme */}
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex flex-wrap gap-x-4 gap-y-2 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-20"
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>
);
}
+76
View File
@@ -0,0 +1,76 @@
'use client';
import { ReactNode } from 'react';
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-2 sm:px-4">
{/* Outer wrapper with border */}
<div className="max-w-316 mx-auto border-2 border-black rounded-xl bg-gray-200 p-3 sm: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 items-end">
{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>
{/* Content panel */}
<main className="border-2 border-black rounded-b-lg rounded-tr-lg p-3 sm: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>
);
}
+21 -16
View File
@@ -15,34 +15,39 @@ FULL_IMAGE="${REGISTRY}/${IMAGE_NAME}:${TAG}"
BUILD_DATE=$(date +%d.%m.%Y)
echo "=========================================="
echo "Ausgaben-Next Deploy Script"
echo "ausgaben-next Deploy Script"
echo "=========================================="
echo "Registry: ${REGISTRY}"
echo "Image: ${IMAGE_NAME}"
echo "Tag: ${TAG}"
echo "Build-Datum: ${BUILD_DATE}"
echo "=========================================="
echo ""
# 1. Docker Image bauen
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)
# 1. Login zur Registry (falls noch nicht eingeloggt)
echo ">>> Login zu ${REGISTRY}..."
docker login "${REGISTRY}"
echo ""
# 3. Image pushen
echo ">>> Pushe Image zu ${REGISTRY}..."
docker push "${FULL_IMAGE}"
# 2. Multiplatform Builder einrichten (docker-container driver erforderlich)
echo ">>> Richte Multiplatform Builder ein..."
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 "=========================================="
+13
View File
@@ -15,3 +15,16 @@ services:
- DB_USER=${DB_USER}
- DB_PASS=${DB_PASS}
- DB_NAME=${DB_NAME}
phpmyadmin:
image: phpmyadmin:latest
container_name: ausgaben-next-phpmyadmin
restart: unless-stopped
ports:
- "0.0.0.0:3006:80"
environment:
- PMA_HOST=${DB_HOST}
- PMA_PORT=3306
- PMA_USER=${DB_USER}
- PMA_PASSWORD=${DB_PASS}
- UPLOAD_LIMIT=256M
+15 -13
View File
@@ -1,16 +1,18 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
];
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+1 -1
View File
@@ -7,7 +7,7 @@ export function getDbPool() {
pool = mysql.createPool({
host: process.env.DB_HOST || 'localhost',
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',
waitForConnections: true,
connectionLimit: 10,
+14
View File
@@ -2,6 +2,20 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
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;
+15 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ausgaben_next",
"version": "1.0.0",
"version": "2.3.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ausgaben_next",
"version": "1.0.0",
"version": "2.3.1",
"dependencies": {
"mysql2": "^3.17.4",
"next": "16.1.6",
@@ -68,6 +68,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -1551,6 +1552,7 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.34.tgz",
"integrity": "sha512-by3/Z0Qp+L9cAySEsSNNwZ6WWw8ywgGLPQGgbQDhNRSitqYgkgp4pErd23ZSCavbtUA2CN4jQtoB3T8nk4j3Rg==",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -1561,6 +1563,7 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -1620,6 +1623,7 @@
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/types": "8.56.1",
@@ -2145,6 +2149,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2497,6 +2502,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -3073,6 +3079,7 @@
"integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -3258,6 +3265,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -5541,6 +5549,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -5550,6 +5559,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -6259,6 +6269,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -6421,6 +6432,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -6695,6 +6707,7 @@
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ausgaben_next",
"version": "1.0.1",
"version": "2.3.1",
"private": true,
"scripts": {
"dev": "next dev -p 3005",
+3
View File
@@ -6,6 +6,7 @@ export interface AusgabenEntry {
WochTag: string;
Wo: string;
Was: string;
Kat: string;
Wieviel: number;
Wie: string;
TYP: number;
@@ -16,6 +17,7 @@ export interface CreateAusgabenEntry {
WochTag: string;
Wo: string;
Was: string;
Kat: string;
Wieviel: string | number;
Wie: string;
TYP: number;
@@ -33,6 +35,7 @@ export interface MonthlyStats {
MASTER?: number;
Einnahmen: number;
Ueberweisungen: number;
katStats?: Record<string, number>;
}
// Haushalt Zahlungsarten (TYP = 0)