Compare commits
20 Commits
auth
...
phpmyadmin
| Author | SHA1 | Date | |
|---|---|---|---|
| 7aa42c9952 | |||
| e5868d904a | |||
| dff78027b6 | |||
| cd96bdfc6a | |||
| 4acb1a5eca | |||
| bb35aa28c9 | |||
| bacd11f722 | |||
| d3718da1f8 | |||
| de28922784 | |||
| 38c18a5ead | |||
| a7863c519f | |||
| 204bf3bf8b | |||
| 46678cb644 | |||
| 74e5f76ec2 | |||
| 90444b8f7d | |||
| 2a9ae7e806 | |||
| ed6bc21248 | |||
| 319ac8699e | |||
| 8c6d1bcf6d | |||
| 1ccd66b307 |
@@ -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
|
||||
|
||||
Vendored
+17
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 "=========================================="
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Generated
+15
-2
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ausgaben_next",
|
||||
"version": "1.0.1",
|
||||
"version": "2.3.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3005",
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user