15 Commits

Author SHA1 Message Date
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
21 changed files with 488 additions and 234 deletions
-10
View File
@@ -2,13 +2,3 @@ DB_HOST=localhost
DB_USER=root DB_USER=root
DB_PASSWORD=your_password DB_PASSWORD=your_password
DB_NAME=RXF 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 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
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 { 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,
+5 -4
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,
+16 -1
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);
+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 }
);
}
}
+15 -49
View File
@@ -3,32 +3,34 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import AusgabenForm from '@/components/AusgabenForm'; import AusgabenForm from '@/components/AusgabenForm';
import AusgabenList from '@/components/AusgabenList'; import AusgabenList from '@/components/AusgabenList';
import MonatsStatistik from '@/components/MonatsStatistik';
import TabLayout from '@/components/TabLayout';
import { AusgabenEntry } from '@/types/ausgaben'; import { AusgabenEntry } from '@/types/ausgaben';
import packageJson from '@/package.json';
const MAX_ENTRIES = 15;
export default function Home() { export default function Home() {
const [activeTab, setActiveTab] = useState(0); // 0 = Haushalt, 1 = Privat const [activeTab, setActiveTab] = useState(0); // 0 = Haushalt, 1 = Privat
const [entries, setEntries] = useState<AusgabenEntry[]>([]); const [entries, setEntries] = useState<AusgabenEntry[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [selectedEntry, setSelectedEntry] = useState<AusgabenEntry | null>(null); const [selectedEntry, setSelectedEntry] = useState<AusgabenEntry | null>(null);
const [statsRefreshKey, setStatsRefreshKey] = useState(0);
const version = packageJson.version;
const buildDate = process.env.NEXT_PUBLIC_BUILD_DATE || new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
useEffect(() => { useEffect(() => {
fetchRecentEntries();
setSelectedEntry(null); // Clear selected entry when switching tabs setSelectedEntry(null); // Clear selected entry when switching tabs
fetchRecentEntries();
}, [activeTab]); }, [activeTab]);
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);
@@ -42,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);
@@ -49,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) => {
@@ -57,40 +61,15 @@ 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]">
<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>
<div> <div>
<h2 className="text-xl font-semibold mb-4">Eingabe</h2> <h2 className="text-xl font-semibold mb-4">Eingabe</h2>
<AusgabenForm onSuccess={handleSuccess} selectedEntry={selectedEntry} typ={activeTab} /> <AusgabenForm onSuccess={handleSuccess} selectedEntry={selectedEntry} typ={activeTab} />
<MonatsStatistik typ={activeTab} refreshKey={statsRefreshKey} />
<div className="mt-6 bg-white border border-black rounded-lg shadow-md p-6"> <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> <h3 className="text-xl font-semibold mb-4">Letzte {MAX_ENTRIES} Einträge</h3>
{isLoading ? ( {isLoading ? (
<div className="text-center py-4">Lade Daten...</div> <div className="text-center py-4">Lade Daten...</div>
) : ( ) : (
@@ -98,19 +77,6 @@ export default function Home() {
)} )}
</div> </div>
</div> </div>
</TabLayout>
{/* 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>
); );
} }
+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
+65 -107
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>
); );
+35 -16
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>
); );
} }
+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-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>
);
}
+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-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 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-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) 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 "=========================================="
+15 -13
View File
@@ -1,16 +1,18 @@
import { dirname } from "path"; import { defineConfig, globalIgnores } from "eslint/config";
import { fileURLToPath } from "url"; import nextVitals from "eslint-config-next/core-web-vitals";
import { FlatCompat } from "@eslint/eslintrc"; import nextTs from "eslint-config-next/typescript";
const __filename = fileURLToPath(import.meta.url); const eslintConfig = defineConfig([
const __dirname = dirname(__filename); ...nextVitals,
...nextTs,
const compat = new FlatCompat({ // Override default ignores of eslint-config-next.
baseDirectory: __dirname, globalIgnores([
}); // Default ignores of eslint-config-next:
".next/**",
const eslintConfig = [ "out/**",
...compat.extends("next/core-web-vitals", "next/typescript"), "build/**",
]; "next-env.d.ts",
]),
]);
export default eslintConfig; export default eslintConfig;
+1 -1
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,
+14
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;
+15 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "ausgaben_next", "name": "ausgaben_next",
"version": "1.0.0", "version": "2.2.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ausgaben_next", "name": "ausgaben_next",
"version": "1.0.0", "version": "2.2.1",
"dependencies": { "dependencies": {
"mysql2": "^3.17.4", "mysql2": "^3.17.4",
"next": "16.1.6", "next": "16.1.6",
@@ -68,6 +68,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",
@@ -1551,6 +1552,7 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.34.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.34.tgz",
"integrity": "sha512-by3/Z0Qp+L9cAySEsSNNwZ6WWw8ywgGLPQGgbQDhNRSitqYgkgp4pErd23ZSCavbtUA2CN4jQtoB3T8nk4j3Rg==", "integrity": "sha512-by3/Z0Qp+L9cAySEsSNNwZ6WWw8ywgGLPQGgbQDhNRSitqYgkgp4pErd23ZSCavbtUA2CN4jQtoB3T8nk4j3Rg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@@ -1561,6 +1563,7 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@@ -1620,6 +1623,7 @@
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/types": "8.56.1", "@typescript-eslint/types": "8.56.1",
@@ -2145,6 +2149,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"
}, },
@@ -2497,6 +2502,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@@ -3073,6 +3079,7 @@
"integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -3258,6 +3265,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",
@@ -5541,6 +5549,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -5550,6 +5559,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@@ -6259,6 +6269,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -6421,6 +6432,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -6695,6 +6707,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"
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ausgaben_next", "name": "ausgaben_next",
"version": "1.0.1", "version": "2.2.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 3005", "dev": "next dev -p 3005",
+3
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)