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:
@@ -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`;
|
||||
}
|
||||
Reference in New Issue
Block a user