- 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
145 lines
4.8 KiB
TypeScript
145 lines
4.8 KiB
TypeScript
/** 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`;
|
||
}
|