Compare commits

..

4 Commits

14 changed files with 405 additions and 188 deletions

View File

@@ -60,7 +60,7 @@ npm start
### Schema erstellen ### Schema erstellen
Falls die Tabelle `Ausgaben_Tag` noch nicht existiert: Falls die Tabelle `Ausgaben` noch nicht existiert:
```bash ```bash
mysql -u root -p RXF < create_table.sql mysql -u root -p RXF < create_table.sql
@@ -72,7 +72,7 @@ Die Anwendung verwendet die gleiche MySQL-Datenbank wie die alte Ausgaben-Anwend
- **Host:** gitea-db (oder localhost für lokale Entwicklung) - **Host:** gitea-db (oder localhost für lokale Entwicklung)
- **Database:** RXF - **Database:** RXF
- **Table:** Ausgaben_Tag - **Table:** Ausgaben
Die Zugangsdaten werden über Umgebungsvariablen in `.env` konfiguriert. Die Zugangsdaten werden über Umgebungsvariablen in `.env` konfiguriert.
@@ -172,7 +172,7 @@ docker-compose -f docker-compose.local.yml up -d --build
Die Datenbank sollte regelmäßig gesichert werden: Die Datenbank sollte regelmäßig gesichert werden:
```bash ```bash
mysqldump -u root -p RXF Ausgaben_Tag > backup_$(date +%Y%m%d).sql mysqldump -u root -p RXF Ausgaben > backup_$(date +%Y%m%d).sql
``` ```
### Application Files ### Application Files

View File

@@ -20,7 +20,7 @@ Dieser Leitfaden hilft Ihnen bei der Migration von der alten PHP/jQuery-basierte
## Was bleibt gleich? ## Was bleibt gleich?
**Datenbank:** Gleiche MySQL-Datenbank (`RXF.Ausgaben_Tag`) **Datenbank:** Gleiche MySQL-Datenbank (`RXF.Ausgaben`)
**Datenstruktur:** Identische Tabellenfelder **Datenstruktur:** Identische Tabellenfelder
**Funktionalität:** Alle Features der alten Version **Funktionalität:** Alle Features der alten Version
**Look & Feel:** Ähnliches Design (angelehnt an werte-next) **Look & Feel:** Ähnliches Design (angelehnt an werte-next)
@@ -37,7 +37,6 @@ Dieser Leitfaden hilft Ihnen bei der Migration von der alten PHP/jQuery-basierte
| Löschen | ✓ | ✓ | ✓ Implementiert | | Löschen | ✓ | ✓ | ✓ Implementiert |
| Datepicker | ✓ jQuery UI | ✓ HTML5 | ✓ Implementiert | | Datepicker | ✓ jQuery UI | ✓ HTML5 | ✓ Implementiert |
| Wochentag auto | ✓ | ✓ | ✓ Implementiert | | Wochentag auto | ✓ | ✓ | ✓ Implementiert |
| OK-Checkbox | ✓ | ✓ | ✓ Implementiert |
| Tab-Navigation | ✓ jQuery UI | ✓ React | ✓ Implementiert | | Tab-Navigation | ✓ jQuery UI | ✓ React | ✓ Implementiert |
## Datenbank ## Datenbank
@@ -45,7 +44,7 @@ Dieser Leitfaden hilft Ihnen bei der Migration von der alten PHP/jQuery-basierte
Die Datenbanktabelle bleibt **unverändert**: Die Datenbanktabelle bleibt **unverändert**:
```sql ```sql
Ausgaben_Tag ( Ausgaben (
ID int(11) AUTO_INCREMENT, ID int(11) AUTO_INCREMENT,
Datum date, Datum date,
WochTag varchar(20), WochTag varchar(20),

View File

@@ -8,26 +8,20 @@ Dies ist die modernisierte Version des alten PHP/jQuery-basierten Ausgaben-Progr
## Features ## Features
- **Eingabe-Tab**: Erfassen von Ausgaben mit: - **Zwei Tabs für verschiedene Ausgabenkategorien:**
- **Haushalt (TYP=0)**: Zahlungsarten ECR, ECB, barR, barB, Ein(nahme), Uber(weisung)
- **Privat (TYP=1)**: Zahlungsarten bar, EC, VISA, MASTER, Einnahme, Uber(weisung)
- **Eingabeformular mit integrierten Features:**
- Datum (mit automatischem Wochentag) - Datum (mit automatischem Wochentag)
- Wo (Geschäft/Ort) - Wo (Geschäft/Ort)
- Was (Beschreibung) - Was (Beschreibung)
- Wieviel (Betrag in Euro) - Wieviel (Betrag in Euro)
- Wie (Zahlungsart: bar, EC, VISA, MASTER, Einnahme, Überweisung) - Wie (Zahlungsart - abhängig vom aktiven Tab)
- OK-Checkbox für Kontrolle - Monatliche Statistiken im Formular (Gesamtsumme, aufgeschlüsselt nach Zahlungsart)
- Monatsstatistiken - Letzte 10 Einträge direkt unter dem Formular mit Bearbeiten/Löschen-Funktion
- Letzte 10 Einträge - Bearbeiten-Funktion: Klick auf Eintrag lädt ihn ins Formular
- Filterung nach aktivem TYP (Haushalt/Privat)
- **Listen-Tab**: Vollständige Auflistung aller Einträge mit:
- Bearbeiten-Funktion
- Löschen-Funktion
- Sortierung nach Datum (absteigend)
- **Statistik-Tab**: Monatliche Auswertungen mit:
- Gesamtausgaben
- Aufschlüsselung nach Zahlungsart
- Einnahmen
- Überweisungen
## Technologie-Stack ## Technologie-Stack
@@ -81,24 +75,26 @@ npm start
## Datenbank-Schema ## Datenbank-Schema
Die Anwendung verwendet die Tabelle `Ausgaben_Tag` mit folgenden Feldern: Die Anwendung verwendet die Tabelle `Ausgaben` mit folgenden Feldern:
- `ID` (auto_increment) - `ID` (auto_increment)
- `Datum` (date) - `Datum` (date)
- `WochTag` (varchar)
- `Wo` (varchar) - Geschäft/Ort - `Wo` (varchar) - Geschäft/Ort
- `Was` (varchar) - Beschreibung - `Was` (varchar) - Beschreibung
- `Wieviel` (decimal) - Betrag - `Wieviel` (decimal) - Betrag
- `Wie` (varchar) - Zahlungsart - `Wie` (varchar) - Zahlungsart
- `TYP` (tinyint) - 0=Haushalt, 1=Privat
- `OK` (tinyint) - Kontrollstatus - `OK` (tinyint) - Kontrollstatus
**Hinweis:** Der Wochentag (`WochTag`) wird nicht in der Datenbank gespeichert, sondern dynamisch aus dem `Datum`-Feld berechnet.
## API Endpoints ## API Endpoints
- `GET /api/ausgaben` - Einträge abrufen (mit limit, startDate, month, year params) - `GET /api/ausgaben` - Einträge abrufen (mit limit, startDate, month, year, typ params)
- `POST /api/ausgaben` - Neuen Eintrag erstellen - `POST /api/ausgaben` - Neuen Eintrag erstellen (mit TYP)
- `PUT /api/ausgaben/[id]` - Eintrag aktualisieren - `PUT /api/ausgaben/[id]` - Eintrag aktualisieren (mit TYP)
- `DELETE /api/ausgaben/[id]` - Eintrag löschen - `DELETE /api/ausgaben/[id]` - Eintrag löschen
- `GET /api/ausgaben/stats` - Monatsstatistiken (mit month, year params) - `GET /api/ausgaben/stats` - Monatsstatistiken (mit month, year, typ params)
## Migration von der alten Version ## Migration von der alten Version

View File

@@ -10,23 +10,23 @@ 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, WochTag, Wo, Was, Wieviel, Wie, OK } = body; const { Datum, Wo, Was, Wieviel, Wie, TYP, OK } = body;
const pool = getDbPool(); const pool = getDbPool();
const query = ` const query = `
UPDATE Ausgaben_Tag UPDATE Ausgaben
SET Datum = ?, WochTag = ?, Wo = ?, Was = ?, Wieviel = ?, Wie = ?, OK = ? SET Datum = ?, Wo = ?, Was = ?, Wieviel = ?, Wie = ?, TYP = ?, OK = ?
WHERE ID = ? WHERE ID = ?
`; `;
const [result] = await pool.query<ResultSetHeader>(query, [ const [result] = await pool.query<ResultSetHeader>(query, [
Datum, Datum,
WochTag,
Wo, Wo,
Was, Was,
parseFloat(Wieviel), parseFloat(Wieviel),
Wie, Wie,
TYP,
OK || 0, OK || 0,
parseInt(id), parseInt(id),
]); ]);
@@ -59,7 +59,7 @@ export async function DELETE(
const { id } = await context.params; const { id } = await context.params;
const pool = getDbPool(); const pool = getDbPool();
const query = 'DELETE FROM Ausgaben_Tag WHERE ID = ?'; const query = 'DELETE FROM Ausgaben WHERE ID = ?';
const [result] = await pool.query<ResultSetHeader>(query, [parseInt(id)]); const [result] = await pool.query<ResultSetHeader>(query, [parseInt(id)]);
if (result.affectedRows === 0) { if (result.affectedRows === 0) {

View File

@@ -10,20 +10,41 @@ export async function GET(request: Request) {
const startDate = searchParams.get('startDate'); const startDate = searchParams.get('startDate');
const month = searchParams.get('month'); const month = searchParams.get('month');
const year = searchParams.get('year'); const year = searchParams.get('year');
const typ = searchParams.get('typ');
const pool = getDbPool(); const pool = getDbPool();
let query = 'SELECT * FROM Ausgaben_Tag'; let query = `SELECT *,
CASE DAYOFWEEK(Datum)
WHEN 1 THEN 'Sonntag'
WHEN 2 THEN 'Montag'
WHEN 3 THEN 'Dienstag'
WHEN 4 THEN 'Mittwoch'
WHEN 5 THEN 'Donnerstag'
WHEN 6 THEN 'Freitag'
WHEN 7 THEN 'Samstag'
END as WochTag
FROM Ausgaben`;
const params: any[] = []; const params: any[] = [];
const conditions: string[] = [];
if (typ !== null && typ !== undefined) {
conditions.push('TYP = ?');
params.push(parseInt(typ));
}
if (month && year) { if (month && year) {
query += ' WHERE YEAR(Datum) = ? AND MONTH(Datum) = ?'; conditions.push('YEAR(Datum) = ? AND MONTH(Datum) = ?');
params.push(year, month); params.push(year, month);
} else if (startDate) { } else if (startDate) {
query += ' WHERE Datum >= ?'; conditions.push('Datum >= ?');
params.push(startDate); params.push(startDate);
} }
if (conditions.length > 0) {
query += ' WHERE ' + conditions.join(' AND ');
}
query += ' ORDER BY Datum DESC, ID DESC LIMIT ?'; query += ' ORDER BY Datum DESC, ID DESC LIMIT ?';
params.push(parseInt(limit)); params.push(parseInt(limit));
@@ -46,9 +67,9 @@ 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, WochTag, Wo, Was, Wieviel, Wie, OK } = body; const { Datum, Wo, Was, Wieviel, Wie, TYP, OK } = body;
if (!Datum || !Wo || !Was || !Wieviel || !Wie) { if (!Datum || !Wo || !Was || !Wieviel || !Wie || TYP === undefined) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: 'Missing required fields' }, { success: false, error: 'Missing required fields' },
{ status: 400 } { status: 400 }
@@ -58,17 +79,17 @@ export async function POST(request: Request) {
const pool = getDbPool(); const pool = getDbPool();
const query = ` const query = `
INSERT INTO Ausgaben_Tag (Datum, WochTag, Wo, Was, Wieviel, Wie, OK) INSERT INTO Ausgaben (Datum, Wo, Was, Wieviel, Wie, TYP, OK)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
`; `;
const [result] = await pool.query<ResultSetHeader>(query, [ const [result] = await pool.query<ResultSetHeader>(query, [
Datum, Datum,
WochTag,
Wo, Wo,
Was, Was,
parseFloat(Wieviel), parseFloat(Wieviel),
Wie, Wie,
TYP,
OK || 0, OK || 0,
]); ]);

View File

@@ -8,53 +8,73 @@ export async function GET(request: Request) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const month = searchParams.get('month'); const month = searchParams.get('month');
const year = searchParams.get('year'); const year = searchParams.get('year');
const typ = searchParams.get('typ');
if (!month || !year) { if (!month || !year || typ === null) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: 'Month and year are required' }, { success: false, error: 'Month, year and typ are required' },
{ status: 400 } { status: 400 }
); );
} }
const pool = getDbPool(); const pool = getDbPool();
// Get total ausgaben and breakdown by payment type // Get total ausgaben and breakdown by payment type based on TYP
const query = ` let query: string;
if (parseInt(typ) === 0) {
// Haushalt - unterstützt beide Varianten: mit/ohne Bindestrich und Ein/Einnahme, Uber/Ueber
query = `
SELECT SELECT
SUM(CASE WHEN Wie IN ('EC-R', 'EC-B', 'bar-R', 'bar-B', 'Ueber') THEN Wieviel ELSE 0 END) as totalAusgaben, SUM(CASE WHEN Wie IN ('EC-R', 'ECR', 'EC-B', 'ECB', 'bar-R', 'barR', 'bar-B', 'barB', 'Ueber', 'Uber') THEN Wieviel ELSE 0 END) as totalAusgaben,
SUM(CASE WHEN Wie = 'EC-R' THEN Wieviel ELSE 0 END) as ECR, SUM(CASE WHEN Wie IN ('EC-R', 'ECR') THEN Wieviel ELSE 0 END) as ECR,
SUM(CASE WHEN Wie = 'EC-B' THEN Wieviel ELSE 0 END) as ECB, SUM(CASE WHEN Wie IN ('EC-B', 'ECB') THEN Wieviel ELSE 0 END) as ECB,
SUM(CASE WHEN Wie = 'bar-R' THEN Wieviel ELSE 0 END) as barR, SUM(CASE WHEN Wie IN ('bar-R', 'barR') THEN Wieviel ELSE 0 END) as barR,
SUM(CASE WHEN Wie = 'bar-B' THEN Wieviel ELSE 0 END) as barB, SUM(CASE WHEN Wie IN ('bar-B', 'barB') THEN Wieviel ELSE 0 END) as barB,
SUM(CASE WHEN Wie = 'Einnahme' THEN Wieviel ELSE 0 END) as Einnahmen, SUM(CASE WHEN Wie IN ('Einnahme', 'Ein') THEN Wieviel ELSE 0 END) as Einnahmen,
SUM(CASE WHEN Wie = 'Ueber' THEN Wieviel ELSE 0 END) as Ueberweisungen SUM(CASE WHEN Wie IN ('Ueber', 'Uber') THEN Wieviel ELSE 0 END) as Ueberweisungen
FROM Ausgaben_Tag FROM Ausgaben
WHERE YEAR(Datum) = ? AND MONTH(Datum) = ? WHERE YEAR(Datum) = ? AND MONTH(Datum) = ? AND TYP = 0
`; `;
} else {
// Privat - unterstützt Uber/Ueber für Überweisung
query = `
SELECT
SUM(CASE WHEN Wie IN ('bar', 'EC', 'VISA', 'MASTER', 'Uber', 'Ueber') THEN Wieviel ELSE 0 END) as totalAusgaben,
SUM(CASE WHEN Wie = 'bar' THEN Wieviel ELSE 0 END) as bar,
SUM(CASE WHEN Wie = 'EC' THEN Wieviel ELSE 0 END) as EC,
SUM(CASE WHEN Wie = 'VISA' THEN Wieviel ELSE 0 END) as VISA,
SUM(CASE WHEN Wie = 'MASTER' THEN Wieviel ELSE 0 END) as MASTER,
SUM(CASE WHEN Wie = 'Einnahme' THEN Wieviel ELSE 0 END) as Einnahmen,
SUM(CASE WHEN Wie IN ('Uber', 'Ueber') THEN Wieviel ELSE 0 END) as Ueberweisungen
FROM Ausgaben
WHERE YEAR(Datum) = ? AND MONTH(Datum) = ? AND TYP = 1
`;
}
const [rows] = await pool.query<RowDataPacket[]>(query, [year, month]); const [rows] = await pool.query<RowDataPacket[]>(query, [year, month]);
const data = rows[0] || { const data = rows[0] || {};
totalAusgaben: 0,
ECR: 0,
ECB: 0,
barR: 0,
barB: 0,
Einnahmen: 0,
Ueberweisungen: 0,
};
// Convert string values from MySQL to numbers // Convert string values from MySQL to numbers
const parsedData = { const parsedData: any = {
totalAusgaben: parseFloat(data.totalAusgaben) || 0, totalAusgaben: parseFloat(data.totalAusgaben) || 0,
ECR: parseFloat(data.ECR) || 0,
ECB: parseFloat(data.ECB) || 0,
barR: parseFloat(data.barR) || 0,
barB: parseFloat(data.barB) || 0,
Einnahmen: parseFloat(data.Einnahmen) || 0, Einnahmen: parseFloat(data.Einnahmen) || 0,
Ueberweisungen: parseFloat(data.Ueberweisungen) || 0, Ueberweisungen: parseFloat(data.Ueberweisungen) || 0,
}; };
if (parseInt(typ) === 0) {
parsedData.ECR = parseFloat(data.ECR) || 0;
parsedData.ECB = parseFloat(data.ECB) || 0;
parsedData.barR = parseFloat(data.barR) || 0;
parsedData.barB = parseFloat(data.barB) || 0;
} else {
parsedData.bar = parseFloat(data.bar) || 0;
parsedData.EC = parseFloat(data.EC) || 0;
parsedData.VISA = parseFloat(data.VISA) || 0;
parsedData.MASTER = parseFloat(data.MASTER) || 0;
}
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
data: parsedData, data: parsedData,

View File

@@ -7,20 +7,23 @@ import { AusgabenEntry } from '@/types/ausgaben';
import packageJson from '@/package.json'; import packageJson from '@/package.json';
export default function Home() { export default function Home() {
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 version = packageJson.version; const version = packageJson.version;
const buildDate = process.env.NEXT_PUBLIC_BUILD_DATE || new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
useEffect(() => { useEffect(() => {
fetchRecentEntries(); fetchRecentEntries();
}, []); setSelectedEntry(null); // Clear selected entry when switching tabs
}, [activeTab]);
const fetchRecentEntries = async () => { const fetchRecentEntries = async () => {
setIsLoading(true); setIsLoading(true);
try { try {
const response = await fetch('/api/ausgaben?limit=10', { const response = await fetch(`/api/ausgaben?limit=20&typ=${activeTab}`, {
cache: 'no-store', cache: 'no-store',
headers: { headers: {
'Cache-Control': 'no-cache', 'Cache-Control': 'no-cache',
@@ -58,12 +61,36 @@ export default function Home() {
<main className="max-w-7xl mx-auto border-2 border-black rounded-lg p-6 bg-[#FFFFDD]"> <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> <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} /> <AusgabenForm onSuccess={handleSuccess} selectedEntry={selectedEntry} typ={activeTab} />
<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 10 Einträge</h3> <h3 className="text-xl font-semibold mb-4">Letzte 20 Einträge</h3>
{isLoading ? ( {isLoading ? (
<div className="text-center py-4">Lade Daten...</div> <div className="text-center py-4">Lade Daten...</div>
) : ( ) : (
@@ -80,7 +107,7 @@ export default function Home() {
</a> </a>
</div> </div>
<div className="text-right"> <div className="text-right">
Version {version} Version {version} - {buildDate}
</div> </div>
</footer> </footer>
</main> </main>

View File

@@ -1,21 +1,26 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { CreateAusgabenEntry, AusgabenEntry, ZAHLUNGSARTEN, Zahlungsart, MonthlyStats } from '@/types/ausgaben'; import { CreateAusgabenEntry, AusgabenEntry, ZAHLUNGSARTEN_HAUSHALT, ZAHLUNGSARTEN_PRIVAT, MonthlyStats } from '@/types/ausgaben';
interface AusgabenFormProps { interface AusgabenFormProps {
onSuccess: () => void; onSuccess: () => void;
selectedEntry?: AusgabenEntry | null; selectedEntry?: AusgabenEntry | null;
typ: number; // 0 = Haushalt, 1 = Privat
} }
export default function AusgabenForm({ onSuccess, selectedEntry }: AusgabenFormProps) { export default function AusgabenForm({ onSuccess, selectedEntry, typ }: AusgabenFormProps) {
const zahlungsarten = typ === 0 ? ZAHLUNGSARTEN_HAUSHALT : ZAHLUNGSARTEN_PRIVAT;
const defaultZahlungsart = typ === 0 ? 'ECR' : 'bar';
const [formData, setFormData] = useState<CreateAusgabenEntry>({ const [formData, setFormData] = useState<CreateAusgabenEntry>({
Datum: '', Datum: '',
WochTag: '', WochTag: '',
Wo: '', Wo: '',
Was: '', Was: '',
Wieviel: '', Wieviel: '',
Wie: 'EC-R', Wie: defaultZahlungsart,
TYP: typ,
OK: 0, OK: 0,
}); });
@@ -28,22 +33,12 @@ export default function AusgabenForm({ onSuccess, selectedEntry }: AusgabenFormP
const [year, setYear] = useState(''); const [year, setYear] = useState('');
const [isLoadingStats, setIsLoadingStats] = useState(false); const [isLoadingStats, setIsLoadingStats] = useState(false);
// Initialize stats with current month/year const fetchStats = useCallback(async (y: string, m: string) => {
useEffect(() => {
const now = new Date();
const currentMonth = String(now.getMonth() + 1).padStart(2, '0');
const currentYear = String(now.getFullYear());
setMonth(currentMonth);
setYear(currentYear);
fetchStats(currentYear, currentMonth);
}, []);
const fetchStats = async (y: string, m: string) => {
if (!y || !m) return; if (!y || !m) return;
setIsLoadingStats(true); setIsLoadingStats(true);
try { try {
const response = await fetch(`/api/ausgaben/stats?year=${y}&month=${m}`); const response = await fetch(`/api/ausgaben/stats?year=${y}&month=${m}&typ=${typ}`);
const data = await response.json(); const data = await response.json();
if (data.success) { if (data.success) {
setStats(data.data); setStats(data.data);
@@ -53,16 +48,30 @@ export default function AusgabenForm({ onSuccess, selectedEntry }: AusgabenFormP
} finally { } finally {
setIsLoadingStats(false); setIsLoadingStats(false);
} }
}; }, [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]);
const handleMonthChange = (newMonth: string) => { const handleMonthChange = (newMonth: string) => {
setMonth(newMonth); setMonth(newMonth);
fetchStats(year, newMonth);
}; };
const handleYearChange = (newYear: string) => { const handleYearChange = (newYear: string) => {
setYear(newYear); setYear(newYear);
fetchStats(newYear, month);
}; };
const formatAmount = (amount: number | null) => { const formatAmount = (amount: number | null) => {
@@ -85,6 +94,7 @@ export default function AusgabenForm({ onSuccess, selectedEntry }: AusgabenFormP
Was: selectedEntry.Was, Was: selectedEntry.Was,
Wieviel: selectedEntry.Wieviel.toString(), Wieviel: selectedEntry.Wieviel.toString(),
Wie: selectedEntry.Wie, Wie: selectedEntry.Wie,
TYP: selectedEntry.TYP,
OK: selectedEntry.OK || 0, OK: selectedEntry.OK || 0,
}); });
@@ -99,11 +109,12 @@ export default function AusgabenForm({ onSuccess, selectedEntry }: AusgabenFormP
...prev, ...prev,
Datum: dateStr, Datum: dateStr,
WochTag: weekday, WochTag: weekday,
TYP: typ,
})); }));
setEditId(null); setEditId(null);
} }
}, [selectedEntry]); }, [selectedEntry, typ]);
const getWeekday = (date: Date): string => { const getWeekday = (date: Date): string => {
const weekdays = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag']; const weekdays = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'];
@@ -167,7 +178,8 @@ export default function AusgabenForm({ onSuccess, selectedEntry }: AusgabenFormP
Wo: '', Wo: '',
Was: '', Was: '',
Wieviel: '', Wieviel: '',
Wie: 'EC-R', Wie: defaultZahlungsart,
TYP: typ,
OK: 0, OK: 0,
}); });
@@ -240,11 +252,11 @@ export default function AusgabenForm({ onSuccess, selectedEntry }: AusgabenFormP
<td className="p-2 w-38"> <td className="p-2 w-38">
<select <select
value={formData.Wie} value={formData.Wie}
onChange={(e) => setFormData({ ...formData, Wie: e.target.value as Zahlungsart })} 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" 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 required
> >
{ZAHLUNGSARTEN.map((za) => ( {zahlungsarten.map((za) => (
<option key={za.value} value={za.value}> <option key={za.value} value={za.value}>
{za.label} {za.label}
</option> </option>
@@ -252,17 +264,16 @@ export default function AusgabenForm({ onSuccess, selectedEntry }: AusgabenFormP
</select> </select>
</td> </td>
</tr> </tr>
<tr> </tbody>
<th className="p-2 w-32">{formData.WochTag}</th> </table>
<th className="p-2"></th>
<th className="p-2"></th> {/* Wochentag */}
<th className="p-2"></th> <div className="mt-3 text-left pl-3">
<th className="p-2"></th> <span className="font-semibold">{formData.WochTag}</span>
<th className="p-2"></th> </div>
</tr>
<tr> {/* Buttons */}
<td colSpan={6} className="p-3"> <div className="mb-3 flex gap-10 justify-center">
<div className="flex gap-3 justify-center">
<button <button
type="submit" type="submit"
disabled={isSubmitting} disabled={isSubmitting}
@@ -278,11 +289,10 @@ export default function AusgabenForm({ onSuccess, selectedEntry }: AusgabenFormP
Löschen Löschen
</button> </button>
</div> </div>
</td>
</tr> {/* Monatsstatistiken */}
<tr> <div className="mt-6 pt-4 pb-6 -mb-6 border-t border-black -mx-6 px-6 bg-[#E0E0FF]">
<td colSpan={6} className="p-3 pt-6 border-t border-gray-300"> <div className="flex items-center justify-between pt-1">
<div className="flex items-center justify-between">
<div className="flex gap-4 items-center"> <div className="flex gap-4 items-center">
<label className="font-semibold">Monat:</label> <label className="font-semibold">Monat:</label>
<select <select
@@ -325,10 +335,7 @@ export default function AusgabenForm({ onSuccess, selectedEntry }: AusgabenFormP
) : null} ) : null}
</div> </div>
</div> </div>
</td> </div>
</tr>
</tbody>
</table>
</form> </form>
</div> </div>
); );

View File

@@ -67,7 +67,7 @@ export default function AusgabenList({ entries, onDelete, onEdit }: AusgabenList
</tr> </tr>
) : ( ) : (
entries.map((entry, index) => ( entries.map((entry, index) => (
<tr key={entry.ID} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-100'}> <tr key={entry.ID || `entry-idx-${index}`} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-100'}>
<td className="border-y border-black p-2 text-center"> <td className="border-y border-black p-2 text-center">
{formatDate(entry.Datum)} {formatDate(entry.Datum)}
</td> </td>

View File

@@ -2,15 +2,23 @@
-- Diese Tabelle sollte bereits in der Docker MySQL-Datenbank existieren -- Diese Tabelle sollte bereits in der Docker MySQL-Datenbank existieren
-- Falls nicht, hier ist das CREATE Statement: -- Falls nicht, hier ist das CREATE Statement:
CREATE TABLE IF NOT EXISTS `Ausgaben_Tag` ( CREATE TABLE IF NOT EXISTS `Ausgaben` (
`ID` int(11) NOT NULL AUTO_INCREMENT, `ID` int(11) NOT NULL AUTO_INCREMENT,
`Datum` date NOT NULL, `Datum` date NOT NULL,
`WochTag` varchar(20) DEFAULT NULL,
`Wo` varchar(255) DEFAULT NULL, `Wo` varchar(255) DEFAULT NULL,
`Was` varchar(500) DEFAULT NULL, `Was` varchar(500) DEFAULT NULL,
`Wieviel` decimal(10,2) NOT NULL, `Wieviel` decimal(10,2) NOT NULL,
`Wie` varchar(50) DEFAULT NULL, `Wie` varchar(50) DEFAULT NULL,
`TYP` tinyint(1) NOT NULL DEFAULT 0 COMMENT '0=Haushalt, 1=Privat',
`OK` tinyint(1) DEFAULT 0, `OK` tinyint(1) DEFAULT 0,
PRIMARY KEY (`ID`), PRIMARY KEY (`ID`),
KEY `idx_datum` (`Datum`) KEY `idx_datum` (`Datum`),
KEY `idx_typ` (`TYP`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Upgrade existing table: Add TYP column if it doesn't exist
ALTER TABLE `Ausgaben` ADD COLUMN IF NOT EXISTS `TYP` tinyint(1) NOT NULL DEFAULT 0 COMMENT '0=Haushalt, 1=Privat' AFTER `Wie`;
ALTER TABLE `Ausgaben` ADD INDEX IF NOT EXISTS `idx_typ` (`TYP`);
-- Remove WochTag column if it exists (no longer stored in DB, calculated from Datum)
-- ALTER TABLE `Ausgaben` DROP COLUMN IF EXISTS `WochTag`;

55
deploy.sh Executable file
View File

@@ -0,0 +1,55 @@
#!/bin/bash
# Deploy Script für ausgaben-next
# Baut das Docker Image und lädt es zu docker.citysensor.de hoch
set -e
# Konfiguration
REGISTRY="docker.citysensor.de"
IMAGE_NAME="ausgaben-next"
TAG="${1:-latest}" # Erster Parameter oder "latest"
FULL_IMAGE="${REGISTRY}/${IMAGE_NAME}:${TAG}"
# Build-Datum
BUILD_DATE=$(date +%d.%m.%Y)
echo "=========================================="
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)
echo ">>> Login zu ${REGISTRY}..."
docker login "${REGISTRY}"
echo ""
# 3. Image pushen
echo ">>> Pushe Image zu ${REGISTRY}..."
docker push "${FULL_IMAGE}"
echo ""
echo "=========================================="
echo "✓ Deploy erfolgreich abgeschlossen!"
echo "=========================================="
echo ""
echo "Auf dem Server ausführen:"
echo " docker pull ${FULL_IMAGE}"
echo " docker-compose -f docker-compose.prod.yml up -d"
echo ""

37
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,37 @@
# Docker Compose für Production Server mit Traefik
services:
ausgaben-app:
image: docker.citysensor.de/ausgaben-next:latest
container_name: ausgaben-next-app
restart: unless-stopped
expose:
- 3000
environment:
- NODE_ENV=production
- DB_HOST=${DB_HOST}
- DB_USER=${DB_USER}
- DB_PASS=${DB_PASS}
- DB_NAME=${DB_NAME}
labels:
- traefik.enable=true
- traefik.http.routers.ausgaben.entrypoints=http
- traefik.http.routers.ausgaben.rule=Host(`ausgaben.fuerst-stuttgart.de`)
- traefik.http.middlewares.ausgaben-https-redirect.redirectscheme.scheme=https
- traefik.http.routers.ausgaben.middlewares=ausgaben-https-redirect
- traefik.http.routers.ausgaben-secure.entrypoints=https
- traefik.http.routers.ausgaben-secure.rule=Host(`ausgaben.fuerst-stuttgart.de`)
- traefik.http.routers.ausgaben-secure.tls=true
- traefik.http.routers.ausgaben-secure.tls.certresolver=letsencrypt
- traefik.http.routers.ausgaben-secure.service=ausgaben
- traefik.http.services.ausgaben.loadbalancer.server.port=3000
networks:
- proxy
- gitea-internal
networks:
proxy:
name: dockge_default
external: true
gitea-internal:
name: gitea_gitea-internal
external: true

View File

@@ -8,6 +8,7 @@ export interface AusgabenEntry {
Was: string; Was: string;
Wieviel: number; Wieviel: number;
Wie: string; Wie: string;
TYP: number;
OK?: number; OK?: number;
} }
@@ -18,26 +19,49 @@ export interface CreateAusgabenEntry {
Was: string; Was: string;
Wieviel: string | number; Wieviel: string | number;
Wie: string; Wie: string;
TYP: number;
OK?: number; OK?: number;
} }
export interface MonthlyStats { export interface MonthlyStats {
totalAusgaben: number; totalAusgaben: number;
ECR: number; ECR?: number;
ECB: number; ECB?: number;
barR: number; barR?: number;
barB: number; barB?: number;
bar?: number;
EC?: number;
VISA?: number;
MASTER?: number;
Einnahmen: number; Einnahmen: number;
Ueberweisungen: number; Ueberweisungen: number;
} }
export type Zahlungsart = 'EC-R' | 'EC-B' | 'bar-R' | 'bar-B' | 'Einnahme' | 'Ueber'; // Haushalt Zahlungsarten (TYP = 0)
// Verwende alte Formate ohne Bindestriche für Kompatibilität mit bestehenden Daten
export type ZahlungsartHaushalt = 'ECR' | 'ECB' | 'barR' | 'barB' | 'Ein' | 'Uber';
export const ZAHLUNGSARTEN: { value: Zahlungsart; label: string }[] = [ export const ZAHLUNGSARTEN_HAUSHALT: { value: ZahlungsartHaushalt; label: string }[] = [
{ value: 'EC-R', label: 'EC-R' }, { value: 'ECR', label: 'EC-R' },
{ value: 'EC-B', label: 'EC-B' }, { value: 'ECB', label: 'EC-B' },
{ value: 'bar-R', label: 'bar-R' }, { value: 'barR', label: 'bar-R' },
{ value: 'bar-B', label: 'bar-B' }, { value: 'barB', label: 'bar-B' },
{ value: 'Einnahme', label: 'Einnahme' }, { value: 'Ein', label: 'Einnahme' },
{ value: 'Ueber', label: 'Überweisung' }, { value: 'Uber', label: 'Überweisung' },
]; ];
// Privat Zahlungsarten (TYP = 1)
export type ZahlungsartPrivat = 'bar' | 'EC' | 'VISA' | 'MASTER' | 'Einnahme' | 'Uber';
export const ZAHLUNGSARTEN_PRIVAT: { value: ZahlungsartPrivat; label: string }[] = [
{ value: 'bar', label: 'bar' },
{ value: 'EC', label: 'EC' },
{ value: 'VISA', label: 'VISA' },
{ value: 'MASTER', label: 'Master' },
{ value: 'Einnahme', label: 'Einnahme' },
{ value: 'Uber', label: 'Überweisung' },
];
// Legacy exports for backward compatibility
export type Zahlungsart = ZahlungsartHaushalt | ZahlungsartPrivat;
export const ZAHLUNGSARTEN = ZAHLUNGSARTEN_HAUSHALT;

23
upgrade_typ.sql Normal file
View File

@@ -0,0 +1,23 @@
-- Upgrade-Skript: TYP-Spalte zur Ausgaben-Tabelle hinzufügen
-- Dieses Skript fügt die TYP-Spalte zur bestehenden Tabelle hinzu
-- TYP-Spalte hinzufügen (falls nicht vorhanden)
ALTER TABLE `Ausgaben`
ADD COLUMN IF NOT EXISTS `TYP` tinyint(1) NOT NULL DEFAULT 0 COMMENT '0=Haushalt, 1=Privat' AFTER `Wie`;
-- Index für TYP hinzufügen (falls nicht vorhanden)
ALTER TABLE `Ausgaben`
ADD INDEX IF NOT EXISTS `idx_typ` (`TYP`);
-- Bestehende Einträge: Setze TYP=0 (Haushalt) für alle Einträge ohne TYP
UPDATE `Ausgaben`
SET `TYP` = 0
WHERE `TYP` IS NULL OR `TYP` = 0;
-- Optional: WochTag-Spalte entfernen (falls vorhanden)
-- Der Wochentag wird jetzt dynamisch aus dem Datum berechnet
-- Kommentieren Sie die folgende Zeile aus, wenn Sie die Spalte entfernen möchten:
-- ALTER TABLE `Ausgaben` DROP COLUMN IF EXISTS `WochTag`;
-- Zeige Struktur der Tabelle
DESCRIBE `Ausgaben`;