feat(frontend): Oberfläche mit RRULE-Editor und Logo-Auswahl

- Vite, React 18, TypeScript und Tailwind mit dunklem Standard-Theme über
  CSS-Variablen, heller Modus umschaltbar und in localStorage gemerkt
- Anmeldung, erzwungener Passwortwechsel, Layout mit Seitenleiste
- API-Client mit stiller Token-Erneuerung, TanStack Query mit Fehler-Toasts
- Seiten Recurrences (inkl. Detail-Drawer), Transactions, Merchants, Settings
- Geführter RRULE-Editor mit Vorlagen, Expertenmodus, deutschem Klartext und
  Live-Vorschau der nächsten Termine vom preview-Endpunkt
- Firmen-Kachelgrid mit Logo, Markenfarbe und Logo-Auswahldialog samt Upload
- Beträge durchgängig in de-DE, Eingabe in beiden Schreibweisen
- 49 Tests, tsc --noEmit und eslint sauber, Produktionsbundle baut

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
This commit is contained in:
moneyfy
2026-09-09 16:23:04 +02:00
co-authored by Claude Opus 5
parent 8edb69fe6a
commit 8d6fcfeb58
57 changed files with 13064 additions and 2 deletions
+144
View File
@@ -0,0 +1,144 @@
/** Formatierung und Rechnen mit Geldbeträgen, durchgängig in de-DE. */
const WAEHRUNG = new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
const WAEHRUNG_MIT_VORZEICHEN = new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
signDisplay: "exceptZero",
});
const ZAHL = new Intl.NumberFormat("de-DE", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
const DATUM = new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
const DATUM_LANG = new Intl.DateTimeFormat("de-DE", {
day: "numeric",
month: "long",
year: "numeric",
});
const MONAT = new Intl.DateTimeFormat("de-DE", { month: "long", year: "numeric" });
const WOCHENTAG = new Intl.DateTimeFormat("de-DE", { weekday: "short" });
/** Wandelt einen Betrag der API (String) in eine Zahl. */
export function toNumber(amount: string | number | null | undefined): number {
if (amount === null || amount === undefined || amount === "") return 0;
const zahl = typeof amount === "number" ? amount : Number.parseFloat(amount);
return Number.isFinite(zahl) ? zahl : 0;
}
export function formatMoney(amount: string | number | null | undefined): string {
return WAEHRUNG.format(toNumber(amount));
}
/** Wie `formatMoney`, stellt aber auch bei positiven Werten ein Vorzeichen voran. */
export function formatSignedMoney(amount: string | number | null | undefined): string {
return WAEHRUNG_MIT_VORZEICHEN.format(toNumber(amount));
}
export function formatNumber(value: number): string {
return ZAHL.format(value);
}
/**
* Bringt eine Nutzereingabe auf das Format der API.
* Akzeptiert "12,50", "12.50" und "1.234,56".
*/
export function parseAmountInput(input: string): string | null {
const bereinigt = input.trim().replace(/\s|€/g, "");
if (!bereinigt) return null;
// Deutsche Schreibweise: Punkt trennt Tausender, Komma die Nachkommastellen.
const normalisiert = bereinigt.includes(",")
? bereinigt.replace(/\./g, "").replace(",", ".")
: bereinigt;
const zahl = Number.parseFloat(normalisiert);
if (!Number.isFinite(zahl)) return null;
return zahl.toFixed(2);
}
/** Zeigt einen API-Betrag in einem Eingabefeld an ("13.99" -> "13,99"). */
export function toAmountInput(amount: string | number | null | undefined): string {
if (amount === null || amount === undefined || amount === "") return "";
return toNumber(amount).toFixed(2).replace(".", ",");
}
function toDate(value: string | Date): Date {
if (value instanceof Date) return value;
// Reine Datumsangaben ohne Zeitzone werden als lokaler Tag gelesen.
const [jahr, monat, tag] = value.split("-").map(Number);
if (jahr && monat && tag) return new Date(jahr, monat - 1, tag);
return new Date(value);
}
export function formatDate(value: string | Date | null | undefined): string {
if (!value) return "";
return DATUM.format(toDate(value));
}
export function formatDateLong(value: string | Date | null | undefined): string {
if (!value) return "";
return DATUM_LANG.format(toDate(value));
}
export function formatMonth(value: string | Date | null | undefined): string {
if (!value) return "";
return MONAT.format(toDate(value));
}
export function formatWeekday(value: string | Date): string {
return WOCHENTAG.format(toDate(value));
}
/** ISO-Datum (YYYY-MM-DD) eines Datums in lokaler Zeit. */
export function toIsoDate(date: Date): string {
const monat = String(date.getMonth() + 1).padStart(2, "0");
const tag = String(date.getDate()).padStart(2, "0");
return `${date.getFullYear()}-${monat}-${tag}`;
}
export function todayIso(): string {
return toIsoDate(new Date());
}
export function firstOfMonth(value: string | Date = new Date()): string {
const datum = toDate(value);
return toIsoDate(new Date(datum.getFullYear(), datum.getMonth(), 1));
}
export function addMonthsIso(value: string, months: number): string {
const datum = toDate(value);
const ziel = new Date(datum.getFullYear(), datum.getMonth() + months, 1);
const letzterTag = new Date(ziel.getFullYear(), ziel.getMonth() + 1, 0).getDate();
ziel.setDate(Math.min(datum.getDate(), letzterTag));
return toIsoDate(ziel);
}
/** "in 5 Tagen", "heute", "vor 2 Tagen" für Fälligkeitshinweise. */
export function relativeDays(value: string, reference: Date = new Date()): string {
const ziel = toDate(value);
const heute = new Date(reference.getFullYear(), reference.getMonth(), reference.getDate());
const tage = Math.round((ziel.getTime() - heute.getTime()) / 86_400_000);
if (tage === 0) return "heute";
if (tage === 1) return "morgen";
if (tage === -1) return "gestern";
if (tage > 0) return `in ${tage} Tagen`;
return `vor ${Math.abs(tage)} Tagen`;
}