Production deployment setup + feature complete

- Dockerfile + deploy.sh for Hetzner server
- Email verification via Better Auth + Resend
- Invite code flow (6-digit OTP, generate/join)
- Settlement share percent fix (payer vs debtor)
- OCR scanner fixes (date display, retry, viewfinder)
- app.json icon/splash/adaptive-icon configured
- iOS deployment target 15.5 (ML Kit requirement)
- DB migration 0014: household_invitations table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
René Schober
2026-03-20 11:54:22 +01:00
parent 4e34270786
commit 9ddc7c6d7a
194 changed files with 55961 additions and 305 deletions

View File

@@ -0,0 +1,16 @@
// Mock for expo-secure-store used in bun:test
// The real module requires React Native internals which aren't available in bun:test
const store = new Map<string, string>();
export async function getItemAsync(key: string): Promise<string | null> {
return store.get(key) ?? null;
}
export async function setItemAsync(key: string, value: string): Promise<void> {
store.set(key, value);
}
export async function deleteItemAsync(key: string): Promise<void> {
store.delete(key);
}

View File

@@ -0,0 +1,36 @@
import { describe, expect, it, mock, beforeEach } from "bun:test";
// Mock the api-client — no actual fetch
mock.module("../../lib/api-client", () => ({
apiRequest: async (path: string) => {
if (path.includes("summary")) {
return { income: 1000, expense: 500, balance: 500 };
}
return { transactions: [] };
},
}));
// Mock expo-secure-store (pulled in transitively via auth-store)
mock.module("expo-secure-store", () => ({
getItemAsync: async () => null,
setItemAsync: async () => {},
deleteItemAsync: async () => {},
}));
mock.module("react-native", () => ({}));
mock.module("@haushaltsApp/env/native", () => ({
env: { EXPO_PUBLIC_SERVER_URL: "http://localhost:3000" },
}));
describe("useTransactions query keys", () => {
it("summary query key is correct", () => {
const key = ["transactions", "summary"];
expect(key).toEqual(["transactions", "summary"]);
});
it("list query key with filters", () => {
const filters = { type: "expense" as const };
const key = ["transactions", filters];
expect(key[1]).toEqual(filters);
});
});

View File

@@ -0,0 +1,87 @@
import { beforeEach, describe, expect, it } from "bun:test";
import { create, type StoreApi, type UseBoundStore } from "zustand";
import { authStateCreator, type AuthState } from "../../stores/auth.store";
// Test the pure state logic via authStateCreator — no React Native imports needed.
// The persisted useAuthStore (with expo-secure-store) is tested via Expo Go.
let store: UseBoundStore<StoreApi<AuthState>>;
describe("authStore", () => {
beforeEach(() => {
store = create<AuthState>()(authStateCreator);
});
it("initial state is unauthenticated", () => {
const state = store.getState();
expect(state.isAuthenticated).toBe(false);
expect(state.user).toBeNull();
expect(state.activeHouseholdId).toBeNull();
expect(state.households).toEqual([]);
});
it("setUser authenticates the user", () => {
const user = { id: "1", name: "Test User", email: "test@example.com" };
store.getState().setUser(user);
const state = store.getState();
expect(state.user).toEqual(user);
expect(state.isAuthenticated).toBe(true);
});
it("clearAuth clears user, activeHouseholdId and households", () => {
store.setState({
user: { id: "1", name: "Test", email: "t@t.com" },
activeHouseholdId: "household-abc",
households: [{ id: "household-abc", name: "Test Household", role: "owner" }],
isAuthenticated: true,
});
store.getState().clearAuth();
const state = store.getState();
expect(state.user).toBeNull();
expect(state.activeHouseholdId).toBeNull();
expect(state.households).toEqual([]);
expect(state.isAuthenticated).toBe(false);
});
it("clearSession resets user, activeHouseholdId, households and isAuthenticated", () => {
store.setState({
user: { id: "1", name: "Test", email: "t@t.com" },
activeHouseholdId: "household-abc",
households: [{ id: "household-abc", name: "Test Household", role: "owner" }],
isAuthenticated: true,
});
store.getState().clearSession();
const state = store.getState();
expect(state.user).toBeNull();
expect(state.activeHouseholdId).toBeNull();
expect(state.households).toEqual([]);
expect(state.isAuthenticated).toBe(false);
});
it("setActiveHousehold stores the id", () => {
store.getState().setActiveHousehold("household-123");
expect(store.getState().activeHouseholdId).toBe("household-123");
});
it("setHouseholds stores the list", () => {
const households = [
{ id: "hh-1", name: "Household One", role: "owner" },
{ id: "hh-2", name: "Household Two", role: "member" },
];
store.getState().setHouseholds(households);
expect(store.getState().households).toEqual(households);
});
it("clearAuth resets activeHouseholdId and households", () => {
store.getState().setActiveHousehold("household-123");
store.getState().setHouseholds([{ id: "household-123", name: "My Home", role: "owner" }]);
store.getState().clearAuth();
expect(store.getState().activeHouseholdId).toBeNull();
expect(store.getState().households).toEqual([]);
});
});

View File

@@ -0,0 +1,17 @@
import { Text, View } from "react-native";
type PlaceholderScreenProps = {
title: string;
description?: string;
};
export function PlaceholderScreen({ title, description }: PlaceholderScreenProps) {
return (
<View className="flex-1 items-center justify-center p-6">
<Text className="mb-2 text-2xl font-bold">{title}</Text>
{description && (
<Text className="text-center text-gray-500">{description}</Text>
)}
</View>
);
}

View File

@@ -0,0 +1,260 @@
import { useCreateCategory, type Category } from "@/src/hooks/useCategories";
import { ModalHeader } from "@/src/components/ui/ModalHeader";
import { Ionicons } from "@expo/vector-icons";
import { useState } from "react";
import {
Modal,
Pressable,
ScrollView,
Text,
TextInput,
View,
} from "react-native";
import React from "react";
import { useTranslation } from "react-i18next";
// 30 emoji-like icon names from Ionicons — no external lib needed
const ICON_OPTIONS: Array<{ name: React.ComponentProps<typeof Ionicons>["name"]; label: string }> = [
{ name: "cart-outline", label: "Einkauf" },
{ name: "home-outline", label: "Haus" },
{ name: "car-outline", label: "Auto" },
{ name: "medkit-outline", label: "Gesundheit" },
{ name: "game-controller-outline", label: "Spiel" },
{ name: "happy-outline", label: "Kinder" },
{ name: "airplane-outline", label: "Urlaub" },
{ name: "briefcase-outline", label: "Arbeit" },
{ name: "cash-outline", label: "Geld" },
{ name: "restaurant-outline", label: "Essen" },
{ name: "fitness-outline", label: "Sport" },
{ name: "book-outline", label: "Bildung" },
{ name: "musical-notes-outline", label: "Musik" },
{ name: "phone-portrait-outline", label: "Handy" },
{ name: "wifi-outline", label: "Internet" },
{ name: "shirt-outline", label: "Kleidung" },
{ name: "paw-outline", label: "Tier" },
{ name: "gift-outline", label: "Geschenk" },
{ name: "construct-outline", label: "Reparatur" },
{ name: "cut-outline", label: "Friseur" },
{ name: "bus-outline", label: "Bus" },
{ name: "train-outline", label: "Bahn" },
{ name: "bicycle-outline", label: "Fahrrad" },
{ name: "cafe-outline", label: "Café" },
{ name: "beer-outline", label: "Bar" },
{ name: "tv-outline", label: "TV" },
{ name: "camera-outline", label: "Foto" },
{ name: "flower-outline", label: "Garten" },
{ name: "star-outline", label: "Sonstiges" },
{ name: "ellipsis-horizontal-circle-outline", label: "Allgemein" },
];
const COLORS = [
"#10b981", "#6366f1", "#f59e0b", "#ef4444", "#8b5cf6",
"#ec4899", "#0ea5e9", "#6b7280", "#f97316", "#14b8a6",
"#84cc16", "#a855f7",
];
type Props = {
visible: boolean;
onClose: () => void;
defaultType?: "income" | "expense";
onCreated?: (cat: Category) => void;
};
export function AddCategoryModal({ visible, onClose, defaultType = "expense", onCreated }: Props) {
const [name, setName] = useState("");
const [selectedIcon, setSelectedIcon] = useState<React.ComponentProps<typeof Ionicons>["name"]>("star-outline");
const [selectedColor, setSelectedColor] = useState(COLORS[0]!);
const [type, setType] = useState<"income" | "expense">(defaultType);
const [iconPickerOpen, setIconPickerOpen] = useState(false);
const { mutate: create, isPending } = useCreateCategory();
const { t } = useTranslation();
function handleSave() {
const trimmed = name.trim();
if (!trimmed) return;
create(
{ name: trimmed, icon: selectedIcon, color: selectedColor, type },
{
onSuccess: (cat) => {
onCreated?.(cat);
resetAndClose();
},
},
);
}
function resetAndClose() {
setName("");
setSelectedIcon("star-outline");
setSelectedColor(COLORS[0]!);
setType(defaultType);
onClose();
}
return (
<Modal visible={visible} animationType="slide" presentationStyle="pageSheet" onRequestClose={resetAndClose}>
<View className="flex-1 bg-white">
{/* Header */}
<ModalHeader
title={t('categories.addTitle')}
onClose={resetAndClose}
closeLabel={t('common.cancel')}
onSave={handleSave}
saveLabel={t('common.create')}
saveDisabled={!name.trim()}
saveLoading={isPending}
/>
<ScrollView contentContainerStyle={{ padding: 16 }}>
{/* Type Toggle */}
<View className="flex-row p-1 bg-gray-100 rounded-xl mb-5">
<Pressable
onPress={() => setType("expense")}
className={`flex-1 py-2 rounded-lg items-center ${type === "expense" ? "bg-white shadow-sm" : ""}`}
>
<Text className={`font-medium ${type === "expense" ? "text-red-600" : "text-gray-500"}`}>
{t('categories.expenseType')}
</Text>
</Pressable>
<Pressable
onPress={() => setType("income")}
className={`flex-1 py-2 rounded-lg items-center ${type === "income" ? "bg-white shadow-sm" : ""}`}
>
<Text className={`font-medium ${type === "income" ? "text-green-600" : "text-gray-500"}`}>
{t('categories.incomeType')}
</Text>
</Pressable>
</View>
{/* Name */}
<Text className="text-sm font-medium text-gray-700 mb-2">{t('categories.nameLabel')}</Text>
<TextInput
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900 mb-5"
placeholder={t('categories.namePlaceholder')}
value={name}
onChangeText={setName}
autoFocus
/>
{/* Preview */}
<View className="flex-row items-center gap-3 mb-5 p-3 bg-gray-50 rounded-xl">
<View
className="w-10 h-10 rounded-full items-center justify-center"
style={{ backgroundColor: selectedColor }}
>
<Ionicons name={selectedIcon} size={20} color="#fff" />
</View>
<Text className="text-base font-medium text-gray-800">
{name.trim() || t('common.preview')}
</Text>
</View>
{/* Color Picker */}
<Text className="text-sm font-medium text-gray-700 mb-3">{t('categories.colorLabel')}</Text>
<View className="flex-row flex-wrap gap-2 mb-5">
{COLORS.map((c) => (
<Pressable
key={c}
onPress={() => setSelectedColor(c)}
style={{
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: c,
alignItems: "center",
justifyContent: "center",
borderWidth: selectedColor === c ? 3 : 0,
borderColor: "#fff",
shadowColor: selectedColor === c ? c : "transparent",
shadowOpacity: selectedColor === c ? 0.6 : 0,
shadowRadius: 4,
elevation: selectedColor === c ? 4 : 0,
}}
>
{selectedColor === c && <Ionicons name="checkmark" size={16} color="#fff" />}
</Pressable>
))}
</View>
{/* Icon Picker — select row */}
<Text className="text-sm font-medium text-gray-700 mb-2">{t('categories.iconLabel')}</Text>
<Pressable
onPress={() => setIconPickerOpen((v) => !v)}
style={{
flexDirection: "row",
alignItems: "center",
backgroundColor: "#f3f4f6",
borderRadius: 12,
paddingHorizontal: 14,
paddingVertical: 12,
marginBottom: iconPickerOpen ? 8 : 0,
}}
>
<View
style={{
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: selectedColor,
alignItems: "center",
justifyContent: "center",
marginRight: 10,
}}
>
<Ionicons name={selectedIcon} size={17} color="#fff" />
</View>
<Text style={{ flex: 1, fontSize: 14, color: "#374151" }}>
{ICON_OPTIONS.find((o) => o.name === selectedIcon)?.label ?? t('categories.selectIcon')}
</Text>
<Ionicons
name={iconPickerOpen ? "chevron-up" : "chevron-down"}
size={16}
color="#9ca3af"
/>
</Pressable>
{/* Dropdown grid */}
{iconPickerOpen && (
<View
style={{
backgroundColor: "#fff",
borderWidth: 1,
borderColor: "#e5e7eb",
borderRadius: 12,
padding: 8,
flexDirection: "row",
flexWrap: "wrap",
gap: 4,
marginBottom: 8,
}}
>
{ICON_OPTIONS.map((opt) => {
const active = selectedIcon === opt.name;
return (
<Pressable
key={opt.name}
onPress={() => {
setSelectedIcon(opt.name);
setIconPickerOpen(false);
}}
style={{
width: 44,
height: 44,
borderRadius: 10,
alignItems: "center",
justifyContent: "center",
backgroundColor: active ? selectedColor : "#f3f4f6",
}}
>
<Ionicons name={opt.name} size={20} color={active ? "#fff" : "#6b7280"} />
</Pressable>
);
})}
</View>
)}
</ScrollView>
</View>
</Modal>
);
}

View File

@@ -0,0 +1,187 @@
import { Ionicons } from "@expo/vector-icons";
import { useState } from "react";
import {
Modal,
Pressable,
ScrollView,
Text,
TextInput,
View,
} from "react-native";
import { useCreateDebt } from "@/src/hooks/useDebts";
import { useHouseholdMembers } from "@/src/hooks/useHouseholdMembers";
import { useAuthStore } from "@/src/stores/auth.store";
import { useTranslation } from "react-i18next";
import { ModalHeader } from "@/src/components/ui/ModalHeader";
import { Numpad } from "@/src/components/ui/Numpad";
import { handleNumpadKey, parseAmountStr } from "@/src/utils/numpad";
type Props = {
visible: boolean;
onClose: () => void;
};
export function AddDebtModal({ visible, onClose }: Props) {
const [label, setLabel] = useState("");
const [amountStr, setAmountStr] = useState("0");
const [notes, setNotes] = useState("");
// creditor: internal member OR free text
const [creditorUserId, setCreditorUserId] = useState<string | null>(null);
const [creditorText, setCreditorText] = useState("");
const [showMemberPicker, setShowMemberPicker] = useState(false);
const { mutate: createDebt, isPending } = useCreateDebt();
const { data: membersData } = useHouseholdMembers();
const myUserId = useAuthStore((s) => s.user?.id);
const { t } = useTranslation();
// Only other members (not myself)
const otherMembers = (membersData?.members ?? []).filter((m) => m.userId !== myUserId);
const selectedMember = otherMembers.find((m) => m.userId === creditorUserId) ?? null;
function handleNumpad(key: string) {
setAmountStr((prev) => handleNumpadKey(prev, key));
}
function handleSave() {
const amount = parseAmountStr(amountStr);
if (!label.trim() || !amount || amount <= 0) return;
createDebt(
{
label: label.trim(),
creditorUserId: creditorUserId ?? undefined,
creditor: !creditorUserId && creditorText.trim() ? creditorText.trim() : undefined,
totalAmount: amount,
notes: notes.trim() || undefined,
},
{ onSuccess: resetAndClose },
);
}
function resetAndClose() {
setLabel("");
setAmountStr("0");
setNotes("");
setCreditorUserId(null);
setCreditorText("");
setShowMemberPicker(false);
onClose();
}
const canSave = label.trim().length > 0 && parseAmountStr(amountStr) > 0;
return (
<Modal visible={visible} animationType="slide" presentationStyle="pageSheet" onRequestClose={resetAndClose}>
<View className="flex-1 bg-white">
{/* Header */}
<ModalHeader
title={t('debts.addTitle')}
onClose={resetAndClose}
closeLabel={t('common.cancel')}
onSave={handleSave}
saveLabel={t('common.save')}
saveDisabled={!canSave}
saveLoading={isPending}
saveColor="#7c3aed"
/>
<ScrollView keyboardShouldPersistTaps="handled" contentContainerStyle={{ paddingBottom: 24 }}>
{/* Amount display */}
<View className="items-center py-6">
<Text className="text-5xl font-bold text-gray-900"> {amountStr}</Text>
<Text className="text-sm text-gray-400 mt-1">{t('debts.totalAmount')}</Text>
</View>
{/* Fields */}
<View className="px-4 gap-3 mb-4">
<View>
<Text className="text-sm font-medium text-gray-700 mb-1">{t('debts.labelRequired')}</Text>
<TextInput
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900"
placeholder={t('debts.labelPlaceholder')}
value={label}
onChangeText={setLabel}
/>
</View>
{/* Creditor picker */}
<View>
<Text className="text-sm font-medium text-gray-700 mb-1">{t('debts.iOweMoneyTo')}</Text>
{/* Member select row */}
{otherMembers.length > 0 && (
<Pressable
onPress={() => setShowMemberPicker((v) => !v)}
className="flex-row items-center bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 mb-2"
>
<Ionicons name="people-outline" size={16} color="#7c3aed" style={{ marginRight: 8 }} />
<Text className="flex-1 text-base" style={{ color: selectedMember ? "#111827" : "#9ca3af" }}>
{selectedMember ? selectedMember.name : t('debts.selectMember')}
</Text>
{selectedMember ? (
<Pressable
onPress={(e) => { e.stopPropagation(); setCreditorUserId(null); }}
hitSlop={8}
>
<Ionicons name="close-circle" size={18} color="#9ca3af" />
</Pressable>
) : (
<Ionicons name={showMemberPicker ? "chevron-up" : "chevron-down"} size={14} color="#9ca3af" />
)}
</Pressable>
)}
{/* Member dropdown */}
{showMemberPicker && (
<View className="bg-white border border-gray-200 rounded-xl mb-2 overflow-hidden">
{otherMembers.map((m) => (
<Pressable
key={m.userId}
onPress={() => { setCreditorUserId(m.userId); setCreditorText(""); setShowMemberPicker(false); }}
className="flex-row items-center px-4 py-3 active:bg-gray-50"
style={{ borderBottomWidth: 1, borderBottomColor: "#f3f4f6" }}
>
<View className="w-7 h-7 rounded-full bg-purple-100 items-center justify-center mr-3">
<Text className="text-xs font-bold text-purple-700">
{m.name.charAt(0).toUpperCase()}
</Text>
</View>
<Text className="text-sm text-gray-800">{m.name}</Text>
{creditorUserId === m.userId && (
<Ionicons name="checkmark" size={16} color="#7c3aed" style={{ marginLeft: "auto" }} />
)}
</Pressable>
))}
</View>
)}
{/* Free-text fallback (only when no member selected) */}
{!creditorUserId && (
<TextInput
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900"
placeholder={t('debts.orEnterName')}
value={creditorText}
onChangeText={setCreditorText}
/>
)}
</View>
<View>
<Text className="text-sm font-medium text-gray-700 mb-1">{t('debts.noteOptional')}</Text>
<TextInput
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900"
placeholder={t('debts.notePlaceholder')}
value={notes}
onChangeText={setNotes}
/>
</View>
</View>
{/* Numpad */}
<Numpad onKeyPress={handleNumpad} />
</ScrollView>
</View>
</Modal>
);
}

View File

@@ -0,0 +1,112 @@
import { useState } from "react";
import {
Modal,
Text,
TextInput,
View,
} from "react-native";
import { useCreateDebtPayment, type Debt } from "@/src/hooks/useDebts";
import { useTranslation } from "react-i18next";
import { ModalHeader } from "@/src/components/ui/ModalHeader";
import { Numpad } from "@/src/components/ui/Numpad";
import { todayIso } from "@/src/utils/date";
import { formatEur } from "@/src/utils/format";
import { handleNumpadKey, parseAmountStr } from "@/src/utils/numpad";
type Props = {
visible: boolean;
debt: Debt;
onClose: () => void;
};
export function AddDebtPaymentModal({ visible, debt, onClose }: Props) {
const [amountStr, setAmountStr] = useState("0");
const [note, setNote] = useState("");
const { mutate: createPayment, isPending } = useCreateDebtPayment();
const { t } = useTranslation();
function handleNumpad(key: string) {
setAmountStr((prev) => handleNumpadKey(prev, key));
}
function handleSave() {
const amount = parseAmountStr(amountStr);
if (!amount || amount <= 0) return;
createPayment(
{
debtId: debt.id,
amount,
date: todayIso(),
note: note.trim() || undefined,
},
{
onSuccess: () => {
resetAndClose();
},
},
);
}
function resetAndClose() {
setAmountStr("0");
setNote("");
onClose();
}
const parsedAmount = parseAmountStr(amountStr);
const canSave = parsedAmount > 0;
const isOverpaying = parsedAmount > debt.remainingAmount + 0.005;
return (
<Modal visible={visible} animationType="slide" presentationStyle="pageSheet" onRequestClose={resetAndClose}>
<View className="flex-1 bg-white">
{/* Header */}
<ModalHeader
title={t('debts.payRate')}
onClose={resetAndClose}
closeLabel={t('common.cancel')}
onSave={handleSave}
saveLabel={t('common.book')}
saveDisabled={!canSave}
saveLoading={isPending}
saveColor="#7c3aed"
/>
{/* Debt info */}
<View className="mx-4 mt-4 p-4 bg-purple-50 rounded-2xl mb-2">
<Text className="text-sm font-semibold text-purple-900">{debt.label}</Text>
{debt.creditor && (
<Text className="text-xs text-purple-500 mt-0.5">{debt.creditor}</Text>
)}
<Text className="text-xs text-purple-600 mt-2">
{t('debts.remaining', { amount: formatEur(debt.remainingAmount, false) })}
</Text>
</View>
{/* Amount display */}
<View className="items-center py-6">
<Text className="text-5xl font-bold text-gray-900"> {amountStr}</Text>
{isOverpaying && (
<Text className="text-xs text-orange-500 mt-1">
{t('debts.overpayingWarning')}
</Text>
)}
</View>
{/* Note field */}
<View className="px-4 mb-4">
<TextInput
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900"
placeholder={t('debts.noteOptional')}
value={note}
onChangeText={setNote}
/>
</View>
{/* Numpad */}
<Numpad onKeyPress={handleNumpad} />
</View>
</Modal>
);
}

View File

@@ -0,0 +1,148 @@
import { Ionicons } from "@expo/vector-icons";
import { useState } from "react";
import { ActivityIndicator, Pressable, Text, View } from "react-native";
import { useClaims, type Debt } from "@/src/hooks/useDebts";
import { useTranslation } from "react-i18next";
import { formatEur } from "@/src/utils/format";
function ClaimCard({ debt }: { debt: Debt }) {
const [expanded, setExpanded] = useState(false);
const isClosed = debt.closedAt !== null;
const accentColor = isClosed ? "#10b981" : "#7c3aed";
const pct = Math.round(debt.progressPercent);
const { t } = useTranslation();
// creditorUserName here = the debtor's name (person who owes me money)
const debtorName = debt.creditorUserName ?? t('debts.unknown');
return (
<View
className="bg-white rounded-2xl mx-4 mb-2 overflow-hidden"
style={{ borderWidth: 1, borderColor: isClosed ? "#d1fae5" : "#e0e7ff" }}
>
<Pressable
onPress={() => setExpanded((v) => !v)}
className="flex-row items-center px-4 py-3 active:opacity-80"
>
<View
className="w-9 h-9 rounded-full items-center justify-center mr-3"
style={{ backgroundColor: isClosed ? "#d1fae5" : "#e0e7ff" }}
>
<Ionicons
name={isClosed ? "checkmark-circle" : "cash-outline"}
size={18}
color={accentColor}
/>
</View>
<View className="flex-1 mr-2">
<View className="flex-row items-center justify-between mb-1.5">
<Text className="text-sm font-semibold text-gray-900" numberOfLines={1}>
{debt.label}
</Text>
<Text className="text-xs font-bold ml-2" style={{ color: accentColor }}>
{pct}%
</Text>
</View>
<View className="h-1.5 bg-gray-100 rounded-full overflow-hidden">
<View
className="h-full rounded-full"
style={{ width: `${debt.progressPercent}%`, backgroundColor: accentColor }}
/>
</View>
<Text className="text-xs text-gray-400 mt-1">
{t('debts.fromDebtor', { name: debtorName, amount: formatEur(debt.remainingAmount, false) })}
</Text>
</View>
<Ionicons
name={expanded ? "chevron-up" : "chevron-down"}
size={14}
color="#9ca3af"
/>
</Pressable>
{expanded && (
<View style={{ borderTopWidth: 1, borderTopColor: "#f3f4f6" }}>
<View className="flex-row px-4 py-3 justify-between">
<View>
<Text className="text-xs text-gray-400">{t('debts.received')}</Text>
<Text className="text-sm font-semibold" style={{ color: accentColor }}>
{formatEur(debt.paidAmount, false)}
</Text>
</View>
<View className="items-center">
<Text className="text-xs text-gray-400">{t('debts.total')}</Text>
<Text className="text-sm font-semibold text-gray-700">
{formatEur(debt.totalAmount, false)}
</Text>
</View>
<View className="items-end">
<Text className="text-xs text-gray-400">{t('debts.pendingLabel')}</Text>
<Text className="text-sm font-semibold text-gray-900">
{formatEur(debt.remainingAmount, false)}
</Text>
</View>
</View>
{debt.notes && (
<Text className="text-xs text-gray-400 px-4 pb-3">{debt.notes}</Text>
)}
{isClosed && (
<View className="mx-4 mb-3 px-3 py-2 rounded-xl" style={{ backgroundColor: "#d1fae5" }}>
<Text className="text-xs font-medium text-center" style={{ color: "#059669" }}>
{t('debts.fullyRepaid')}
</Text>
</View>
)}
</View>
)}
</View>
);
}
export function ClaimsSection() {
const { data: claims = [], isLoading } = useClaims();
const { t } = useTranslation();
const [showClosed, setShowClosed] = useState(false);
if (isLoading) {
return (
<View className="py-4 items-center">
<ActivityIndicator size="small" color="#7c3aed" />
</View>
);
}
if (claims.length === 0) return null;
const open = claims.filter((d) => d.closedAt === null);
const closed = claims.filter((d) => d.closedAt !== null);
return (
<View className="mb-2">
<View className="flex-row items-center px-4 py-3">
<Ionicons name="cash-outline" size={18} color="#7c3aed" style={{ marginRight: 8 }} />
<Text className="text-sm font-semibold text-gray-800">{t('debts.claims')}</Text>
</View>
{open.map((debt) => (
<ClaimCard key={debt.id} debt={debt} />
))}
{closed.length > 0 && (
<>
<Pressable
onPress={() => setShowClosed((v) => !v)}
className="flex-row items-center gap-1 mx-4 mb-2"
>
<Text className="text-xs text-gray-400">
{t(showClosed ? 'debts.toggleClosed_hide' : 'debts.toggleClosed_show', { count: closed.length, plural: closed.length === 1 ? '' : 'r' })}
</Text>
<Ionicons name={showClosed ? "chevron-up" : "chevron-down"} size={12} color="#9ca3af" />
</Pressable>
{showClosed && closed.map((debt) => <ClaimCard key={debt.id} debt={debt} />)}
</>
)}
</View>
);
}

View File

@@ -0,0 +1,132 @@
import { Ionicons } from "@expo/vector-icons";
import { useState } from "react";
import { Pressable, Text, View } from "react-native";
import type { Debt } from "@/src/hooks/useDebts";
import { useTranslation } from "react-i18next";
import { formatEur } from "@/src/utils/format";
type Props = {
debt: Debt;
onAddPayment: (debt: Debt) => void;
onDelete: (debt: Debt) => void;
};
export function DebtCard({ debt, onAddPayment, onDelete }: Props) {
const [expanded, setExpanded] = useState(false);
const isClosed = debt.closedAt !== null;
const accentColor = isClosed ? "#10b981" : "#7c3aed";
const pct = Math.round(debt.progressPercent);
const { t } = useTranslation();
return (
<View
className="bg-white rounded-2xl mx-4 mb-2 overflow-hidden"
style={{ borderWidth: 1, borderColor: isClosed ? "#d1fae5" : "#ede9fe" }}
>
{/* ── Collapsed row (always visible) ── */}
<Pressable
onPress={() => setExpanded((v) => !v)}
className="flex-row items-center px-4 py-3 active:opacity-80"
>
{/* Icon */}
<View
className="w-9 h-9 rounded-full items-center justify-center mr-3"
style={{ backgroundColor: isClosed ? "#d1fae5" : "#ede9fe" }}
>
<Ionicons
name={isClosed ? "checkmark-circle" : "card-outline"}
size={18}
color={accentColor}
/>
</View>
{/* Label + progress bar */}
<View className="flex-1 mr-2">
<View className="flex-row items-center justify-between mb-1.5">
<Text className="text-sm font-semibold text-gray-900" numberOfLines={1}>
{debt.label}
</Text>
<Text className="text-xs font-bold ml-2" style={{ color: accentColor }}>
{pct}%
</Text>
</View>
<View className="h-1.5 bg-gray-100 rounded-full overflow-hidden">
<View
className="h-full rounded-full"
style={{ width: `${debt.progressPercent}%`, backgroundColor: accentColor }}
/>
</View>
{debt.creditor ? (
<Text className="text-xs text-gray-400 mt-1">{debt.creditor}</Text>
) : (
<Text className="text-xs text-gray-400 mt-1">
{t('debts.remainingLabel', { amount: formatEur(debt.remainingAmount) })}
</Text>
)}
</View>
{/* Chevron */}
<Ionicons
name={expanded ? "chevron-up" : "chevron-down"}
size={14}
color="#9ca3af"
/>
</Pressable>
{/* ── Expanded content ── */}
{expanded && (
<View style={{ borderTopWidth: 1, borderTopColor: "#f3f4f6" }}>
{/* Amounts row */}
<View className="flex-row px-4 py-3 justify-between">
<View>
<Text className="text-xs text-gray-400">{t('debts.paid')}</Text>
<Text className="text-sm font-semibold" style={{ color: accentColor }}>
{formatEur(debt.paidAmount, false)}
</Text>
</View>
<View className="items-center">
<Text className="text-xs text-gray-400">{t('debts.total')}</Text>
<Text className="text-sm font-semibold text-gray-700">
{formatEur(debt.totalAmount, false)}
</Text>
</View>
<View className="items-end">
<Text className="text-xs text-gray-400">{t('debts.openAmount')}</Text>
<Text className="text-sm font-semibold text-gray-900">
{formatEur(debt.remainingAmount, false)}
</Text>
</View>
</View>
{/* Notes */}
{debt.notes && (
<Text className="text-xs text-gray-400 px-4 pb-2">{debt.notes}</Text>
)}
{/* Action row */}
<View className="flex-row gap-2 px-4 pb-4">
{!isClosed && (
<Pressable
onPress={() => onAddPayment(debt)}
style={{ backgroundColor: accentColor }}
className="flex-1 py-2.5 rounded-xl items-center active:opacity-80"
>
<Text className="text-sm font-semibold text-white">+ {t('debts.payRate')}</Text>
</Pressable>
)}
{!isClosed && (
<Pressable
onPress={() => onDelete(debt)}
className="w-11 h-11 rounded-xl items-center justify-center"
style={{ backgroundColor: "#fef2f2" }}
hitSlop={4}
>
<Ionicons name="trash-outline" size={16} color="#ef4444" />
</Pressable>
)}
</View>
</View>
)}
</View>
);
}

View File

@@ -0,0 +1,113 @@
import { Ionicons } from "@expo/vector-icons";
import { useState } from "react";
import { ActivityIndicator, Pressable, Text, View } from "react-native";
import { useDebts, useDeleteDebt, type Debt } from "@/src/hooks/useDebts";
import { DebtCard } from "./DebtCard";
import { AddDebtModal } from "./AddDebtModal";
import { AddDebtPaymentModal } from "./AddDebtPaymentModal";
import { useTranslation } from "react-i18next";
type ModalState =
| { kind: "idle" }
| { kind: "addDebt" }
| { kind: "addPayment"; debt: Debt };
export function DebtsSection() {
const { data: debts = [], isLoading } = useDebts();
const { mutate: deleteDebt } = useDeleteDebt();
const [modal, setModal] = useState<ModalState>({ kind: "idle" });
const [showClosed, setShowClosed] = useState(false);
const { t } = useTranslation();
const openDebts = debts.filter((d) => d.closedAt === null);
const closedDebts = debts.filter((d) => d.closedAt !== null);
function handleDelete(debt: Debt) {
deleteDebt(debt.id);
}
return (
<>
<View className="mb-2">
{/* Section header */}
<View className="flex-row items-center justify-between px-4 py-3">
<View className="flex-row items-center gap-2">
<Ionicons name="card-outline" size={18} color="#7c3aed" />
<Text className="text-sm font-semibold text-gray-800">{t('debts.title')}</Text>
</View>
<Pressable
onPress={() => setModal({ kind: "addDebt" })}
className="flex-row items-center gap-1 px-3 py-1.5 rounded-full"
style={{ backgroundColor: "#ede9fe" }}
>
<Ionicons name="add" size={14} color="#7c3aed" />
<Text className="text-xs font-semibold" style={{ color: "#7c3aed" }}>{t('common.new')}</Text>
</Pressable>
</View>
{/* Content */}
{isLoading ? (
<View className="py-6 items-center">
<ActivityIndicator size="small" color="#7c3aed" />
</View>
) : openDebts.length === 0 && closedDebts.length === 0 ? (
<View className="mx-4 mb-3 p-4 bg-gray-50 rounded-2xl items-center">
<Text className="text-sm text-gray-400 text-center">
{t('debts.noDebtsEntered')}
</Text>
</View>
) : (
<>
{openDebts.map((debt) => (
<DebtCard
key={debt.id}
debt={debt}
onAddPayment={(d) => setModal({ kind: "addPayment", debt: d })}
onDelete={handleDelete}
/>
))}
{closedDebts.length > 0 && (
<Pressable
onPress={() => setShowClosed((v) => !v)}
className="flex-row items-center gap-1 mx-4 mb-2"
>
<Text className="text-xs text-gray-400">
{t(showClosed ? 'debts.toggleClosed_hide' : 'debts.toggleClosed_show', { count: closedDebts.length, plural: closedDebts.length === 1 ? '' : 'r' })}
</Text>
<Ionicons
name={showClosed ? "chevron-up" : "chevron-down"}
size={12}
color="#9ca3af"
/>
</Pressable>
)}
{showClosed &&
closedDebts.map((debt) => (
<DebtCard
key={debt.id}
debt={debt}
onAddPayment={() => {}}
onDelete={handleDelete}
/>
))}
</>
)}
</View>
{/* Modals — only one open at a time */}
<AddDebtModal
visible={modal.kind === "addDebt"}
onClose={() => setModal({ kind: "idle" })}
/>
{modal.kind === "addPayment" && (
<AddDebtPaymentModal
visible
debt={modal.debt}
onClose={() => setModal({ kind: "idle" })}
/>
)}
</>
);
}

View File

@@ -0,0 +1,105 @@
import { TAB_COLORS } from "@/src/constants/colors";
import { useMonthBalance, useCarryOver } from "@/src/hooks/useTransactions";
import { currentMonthStr, addMonths, monthLabel } from "@/src/utils/date";
import { formatEur } from "@/src/utils/format";
import { Ionicons } from "@expo/vector-icons";
import { Alert, Pressable, Text, View } from "react-native";
import { useTranslation } from "react-i18next";
type Props = {
month: string; // "YYYY-MM" — the displayed (past) month
scope: "household" | "private" | "child";
childId?: string;
accentColor?: string;
};
export function CarryOverBanner({ month, scope, childId, accentColor = TAB_COLORS.household }: Props) {
const isCurrent = month >= currentMonthStr();
// Don't show for current or future months
if (isCurrent) return null;
return (
<CarryOverBannerInner
month={month}
scope={scope}
childId={childId}
accentColor={accentColor}
/>
);
}
function CarryOverBannerInner({
month,
scope,
childId,
accentColor,
}: Required<Pick<Props, "month" | "scope" | "accentColor">> & { childId?: string }) {
const { data: balanceData } = useMonthBalance(scope, month, childId);
const { mutate: carryOver, isPending } = useCarryOver();
const { t } = useTranslation();
const balance = balanceData?.balance ?? 0;
// No banner if balance is ~zero
if (Math.abs(balance) < 0.01) return null;
const toMonth = addMonths(month, 1);
const toMonthLabel = monthLabel(toMonth);
const balanceLabel = balance > 0 ? `+${formatEur(balance)}` : `-${formatEur(balance)}`;
const isPositive = balance > 0;
function handleCarryOver() {
Alert.alert(
t('carryOver.title'),
t('carryOver.confirmMessage', {
balance: balanceLabel,
type: isPositive ? t('carryOver.expense') : t('carryOver.income'),
month: toMonthLabel,
}),
[
{ text: t('common.cancel'), style: "cancel" },
{
text: t('carryOver.transfer'),
onPress: () => {
carryOver(
{ fromMonth: month, toMonth, scope, childId },
{
onError: (err) => {
Alert.alert(t('common.notice'), err.message);
},
},
);
},
},
],
);
}
return (
<View
className="mx-4 my-3 rounded-2xl p-4"
style={{ backgroundColor: "#f5f3ff", borderWidth: 1, borderColor: "#ddd6fe" }}
>
<View className="flex-row items-center gap-3">
<Ionicons name="return-down-forward-outline" size={24} color="#6366f1" />
<View className="flex-1">
<Text className="text-xs text-gray-400 mb-0.5">{t('carryOver.openBalance', { month: monthLabel(month) })}</Text>
<Text className="text-xl font-bold" style={{ color: "#6366f1" }}>
{balanceLabel}
</Text>
</View>
</View>
<Pressable
onPress={handleCarryOver}
disabled={isPending}
className="mt-3 py-2 rounded-xl items-center active:opacity-70"
style={{ backgroundColor: "#6366f1" }}
>
<Text className="text-sm font-semibold text-white">
{isPending ? t('carryOver.transferring') : t('carryOver.transferButton', { month: toMonthLabel })}
</Text>
</Pressable>
</View>
);
}

View File

@@ -0,0 +1,194 @@
import { useUpdateTransaction } from "@/src/hooks/useTransactions";
import { useCategories, type Category } from "@/src/hooks/useCategories";
import { ModalHeader } from "@/src/components/ui/ModalHeader";
import { Numpad } from "@/src/components/ui/Numpad";
import { formatDateDisplay } from "@/src/utils/format";
import { handleNumpadKey, parseAmountStr } from "@/src/utils/numpad";
import { Ionicons } from "@expo/vector-icons";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import i18n from "@/src/i18n";
import {
Modal,
Pressable,
ScrollView,
Text,
TextInput,
View,
} from "react-native";
import type { TransactionWithCategory } from "@/src/hooks/useTransactions";
type Props = {
transaction: TransactionWithCategory;
onClose: () => void;
};
function amountToDisplay(amount: string): string {
return parseFloat(amount).toFixed(2).replace(".", ",");
}
export function EditTransactionModal({ transaction, onClose }: Props) {
const { t } = useTranslation();
const [amountStr, setAmountStr] = useState(amountToDisplay(transaction.amount));
const [description, setDescription] = useState(transaction.description ?? "");
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(
null, // will be set via category lookup
);
const [showCategoryPicker, setShowCategoryPicker] = useState(false);
const { data: categories = [] } = useCategories();
const { mutate: updateTransaction, isPending } = useUpdateTransaction();
// Resolve initial category from transaction's categoryName
const [resolvedInitial, setResolvedInitial] = useState(false);
React.useEffect(() => {
if (!resolvedInitial && categories.length > 0) {
const match = categories.find((c) => c.name === transaction.categoryName);
setSelectedCategoryId(match?.id ?? null);
setResolvedInitial(true);
}
}, [categories, resolvedInitial, transaction.categoryName]);
const filteredCategories = categories.filter((c) => c.type === transaction.type);
const selectedCategory = categories.find((c) => c.id === selectedCategoryId) ?? null;
function handleNumpad(key: string) {
setAmountStr((prev) => handleNumpadKey(prev, key));
}
function handleSave() {
const amount = parseAmountStr(amountStr);
if (!amount || amount <= 0) return;
updateTransaction(
{
id: transaction.id,
amount,
description: description.trim() || undefined,
categoryId: selectedCategoryId ?? undefined,
},
{ onSuccess: onClose },
);
}
const canSave = parseAmountStr(amountStr) > 0;
return (
<Modal
visible
animationType="slide"
presentationStyle="pageSheet"
onRequestClose={onClose}
>
<View className="flex-1 bg-white">
{/* Header */}
<ModalHeader
title={t('transaction.editTitle')}
onClose={onClose}
closeLabel={t('common.cancel')}
onSave={handleSave}
saveLabel={t('common.save')}
saveDisabled={!canSave}
saveLoading={isPending}
/>
{/* isFixed warning */}
{transaction.isFixed && (
<View className="mx-4 mt-3 px-3 py-2 bg-amber-50 rounded-xl flex-row items-start gap-2">
<Text className="text-sm"></Text>
<Text className="text-xs text-amber-700 flex-1">
Das ist eine Fixkostenbuchung. Änderungen gelten nur für diesen Monat. Um den Betrag dauerhaft zu ändern, gehe zu Einstellungen Fixkosten.
</Text>
</View>
)}
{/* Amount */}
<View className="items-center py-6">
<Text
className="text-5xl font-bold"
style={{ color: transaction.type === "income" ? "#16a34a" : "#111827" }}
>
{amountStr}
</Text>
<Text className="text-sm text-gray-400 mt-1">
{transaction.type === "income" ? t('transaction.income') : t('transaction.expense')}
</Text>
</View>
{/* Category Select */}
<Pressable
onPress={() => setShowCategoryPicker((v) => !v)}
style={{
flexDirection: "row", alignItems: "center",
marginHorizontal: 16, marginBottom: 4,
paddingHorizontal: 14, paddingVertical: 11,
backgroundColor: "#f3f4f6", borderRadius: 12,
borderWidth: selectedCategory ? 1.5 : 0,
borderColor: selectedCategory ? (selectedCategory.color ?? "#6b7280") : "transparent",
}}
>
<View style={{ width: 28, height: 28, borderRadius: 14, backgroundColor: selectedCategory ? (selectedCategory.color ?? "#6b7280") : "#e5e7eb", alignItems: "center", justifyContent: "center", marginRight: 10 }}>
<Ionicons
name={(selectedCategory?.icon ?? "pricetag-outline") as React.ComponentProps<typeof Ionicons>["name"]}
size={14}
color={selectedCategory ? "#fff" : "#9ca3af"}
/>
</View>
<Text style={{ flex: 1, fontSize: 14, color: selectedCategory ? "#111827" : "#9ca3af" }}>
{selectedCategory ? selectedCategory.name : t('transaction.selectCategory')}
</Text>
{selectedCategory ? (
<Pressable onPress={(e) => { e.stopPropagation(); setSelectedCategoryId(null); }} hitSlop={8}>
<Ionicons name="close-circle" size={18} color="#9ca3af" />
</Pressable>
) : (
<Ionicons name={showCategoryPicker ? "chevron-up" : "chevron-down"} size={16} color="#9ca3af" />
)}
</Pressable>
{/* Inline Category Picker */}
{showCategoryPicker && (
<View style={{ marginHorizontal: 16, marginBottom: 4, borderWidth: 1, borderColor: "#e5e7eb", borderRadius: 12, backgroundColor: "#fff", maxHeight: 220 }}>
<ScrollView bounces={false} keyboardShouldPersistTaps="handled">
{filteredCategories.map((cat) => {
const active = cat.id === selectedCategoryId;
const color = cat.color ?? "#6b7280";
return (
<Pressable
key={cat.id}
onPress={() => { setSelectedCategoryId(active ? null : cat.id); setShowCategoryPicker(false); }}
style={{ flexDirection: "row", alignItems: "center", paddingHorizontal: 14, paddingVertical: 10, backgroundColor: active ? `${color}12` : "#fff", borderBottomWidth: 1, borderBottomColor: "#f3f4f6" }}
>
<View style={{ width: 32, height: 32, borderRadius: 16, backgroundColor: color, alignItems: "center", justifyContent: "center", marginRight: 12 }}>
<Ionicons name={(cat.icon ?? "ellipsis-horizontal-circle-outline") as React.ComponentProps<typeof Ionicons>["name"]} size={16} color="#fff" />
</View>
<Text style={{ flex: 1, fontSize: 14, fontWeight: "500", color: active ? color : "#111827" }}>{cat.name}</Text>
{active && <Ionicons name="checkmark-circle" size={18} color={color} />}
</Pressable>
);
})}
</ScrollView>
</View>
)}
{/* Description */}
<View className="px-4 mt-2 mb-3">
<TextInput
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900"
placeholder={t('transaction.descriptionOptional')}
value={description}
onChangeText={setDescription}
/>
</View>
{/* Date (readonly display) */}
<View className="px-4 mb-4 flex-row items-center" style={{ gap: 10 }}>
<Ionicons name="calendar-outline" size={20} color="#6b7280" />
<Text className="text-base text-gray-500">{formatDateDisplay(transaction.date, i18n.language, t('common.today'))}</Text>
</View>
{/* Numpad */}
<Numpad onKeyPress={handleNumpad} />
</View>
</Modal>
);
}

View File

@@ -0,0 +1,56 @@
import { ActivityIndicator, Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { formatEur } from "@/src/utils/format";
type Props = {
income: number | undefined;
expense: number | undefined;
balance: number | undefined;
isLoading: boolean;
accentColor?: string;
};
export function MonthSummaryHeader({ income, expense, balance, isLoading, accentColor }: Props) {
const { t } = useTranslation();
if (isLoading) {
return (
<View className="bg-white mx-4 mt-3 rounded-2xl p-4 items-center" style={{ borderWidth: 1, borderColor: "#f3f4f6" }}>
<ActivityIndicator size="small" color="#9ca3af" />
</View>
);
}
const balancePositive = (balance ?? 0) >= 0;
const balanceColor = accentColor ?? (balancePositive ? "#16a34a" : "#dc2626");
return (
<View
className="bg-white mx-4 mt-3 mb-1 rounded-2xl px-4 py-3 flex-row"
style={{ borderWidth: 1, borderColor: "#f3f4f6" }}
>
<View className="flex-1 items-center">
<Text className="text-xs text-gray-400 mb-1">{t('household.income')}</Text>
<Text className="text-sm font-semibold text-green-600">
{income !== undefined ? formatEur(income) : "—"}
</Text>
</View>
<View className="w-px bg-gray-100 mx-1" />
<View className="flex-1 items-center">
<Text className="text-xs text-gray-400 mb-1">{t('household.expenses')}</Text>
<Text className="text-sm font-semibold text-red-500">
{expense !== undefined ? formatEur(expense) : "—"}
</Text>
</View>
<View className="w-px bg-gray-100 mx-1" />
<View className="flex-1 items-center">
<Text className="text-xs text-gray-400 mb-1">{t('household.balance')}</Text>
<Text
className="text-sm font-semibold"
style={{ color: balanceColor }}
>
{balance !== undefined ? formatEur(balance) : "—"}
</Text>
</View>
</View>
);
}

View File

@@ -0,0 +1,204 @@
import { useCreateTransaction } from "@/src/hooks/useTransactions";
import { useCategories, type Category } from "@/src/hooks/useCategories";
import { ModalHeader } from "@/src/components/ui/ModalHeader";
import { Numpad } from "@/src/components/ui/Numpad";
import { todayIso } from "@/src/utils/date";
import { formatDateDisplay } from "@/src/utils/format";
import { handleNumpadKey, parseAmountStr } from "@/src/utils/numpad";
import { useState } from "react";
import { Ionicons } from "@expo/vector-icons";
import React from "react";
import { useTranslation } from "react-i18next";
import i18n from "@/src/i18n";
import {
Modal,
Pressable,
ScrollView,
Switch,
Text,
TextInput,
View,
} from "react-native";
type Props = {
visible: boolean;
onClose: () => void;
onRequestAddCategory: (type: "expense" | "income") => void;
newCategory?: Category | null;
defaultScope?: "household" | "private" | "child";
defaultChildId?: string;
};
export function QuickAddModal({
visible,
onClose,
onRequestAddCategory,
newCategory,
defaultScope = "household",
defaultChildId,
}: Props) {
const { t: tFn } = useTranslation();
const [type, setType] = useState<"expense" | "income">("expense");
const [amountStr, setAmountStr] = useState("0");
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null);
const [description, setDescription] = useState("");
const [isFixed, setIsFixed] = useState(false);
const [selectedDate, setSelectedDate] = useState<string>(todayIso());
const [showCategoryPicker, setShowCategoryPicker] = useState(false);
const { data: categories = [] } = useCategories();
const filteredCategories = categories.filter((c) => c.type === type);
const selectedCategory = categories.find((c) => c.id === selectedCategoryId) ?? null;
// Auto-select newly created category when parent passes it in
React.useEffect(() => {
if (newCategory) {
setSelectedCategoryId(newCategory.id);
setType(newCategory.type);
}
}, [newCategory]);
const { mutate: createTransaction, isPending } = useCreateTransaction();
function handleNumpad(key: string) {
setAmountStr((prev) => handleNumpadKey(prev, key));
}
function handleSave() {
const amount = parseAmountStr(amountStr);
if (!amount || amount <= 0) return;
createTransaction(
{ amount, type, scope: defaultScope, categoryId: selectedCategoryId ?? undefined, description: description.trim() || undefined, date: new Date(selectedDate).toISOString(), isFixed, childId: defaultChildId ?? undefined },
{ onSuccess: () => { resetState(); onClose(); } },
);
}
function resetState() {
setAmountStr("0");
setDescription("");
setSelectedCategoryId(null);
setType("expense");
setIsFixed(false);
setSelectedDate(todayIso());
setShowCategoryPicker(false);
}
function handleClose() { resetState(); onClose(); }
return (
<Modal visible={visible} animationType="slide" presentationStyle="pageSheet" onRequestClose={handleClose}>
<View className="flex-1 bg-white">
{/* Header */}
<ModalHeader
title={tFn('transaction.newBooking')}
onClose={handleClose}
closeLabel={tFn('common.cancel')}
onSave={handleSave}
saveLabel={tFn('common.save')}
saveLoading={isPending}
/>
{/* Type Toggle */}
<View className="flex-row mx-4 mt-4 p-1 bg-gray-100 rounded-xl">
{(["expense", "income"] as const).map((t) => (
<Pressable key={t} onPress={() => { setType(t); setSelectedCategoryId(null); setShowCategoryPicker(false); }}
className={`flex-1 py-2 rounded-lg items-center ${type === t ? "bg-white shadow-sm" : ""}`}>
<Text className={`font-medium ${type === t ? (t === "expense" ? "text-red-600" : "text-green-600") : "text-gray-500"}`}>
{t === "expense" ? tFn('transaction.expense') : tFn('transaction.income')}
</Text>
</Pressable>
))}
</View>
{/* Amount */}
<View className="items-center py-6">
<Text className="text-5xl font-bold text-gray-900"> {amountStr}</Text>
</View>
{/* Category Select Row */}
<Pressable
onPress={() => setShowCategoryPicker((v) => !v)}
style={{
flexDirection: "row", alignItems: "center",
marginHorizontal: 16, marginBottom: 4,
paddingHorizontal: 14, paddingVertical: 11,
backgroundColor: "#f3f4f6", borderRadius: 12,
borderWidth: selectedCategory ? 1.5 : 0,
borderColor: selectedCategory ? (selectedCategory.color ?? "#6b7280") : "transparent",
}}
>
<View style={{ width: 28, height: 28, borderRadius: 14, backgroundColor: selectedCategory ? (selectedCategory.color ?? "#6b7280") : "#e5e7eb", alignItems: "center", justifyContent: "center", marginRight: 10 }}>
<Ionicons
name={selectedCategory ? (selectedCategory.icon ?? "ellipsis-horizontal-circle-outline") as React.ComponentProps<typeof Ionicons>["name"] : "pricetag-outline"}
size={14} color={selectedCategory ? "#fff" : "#9ca3af"}
/>
</View>
<Text style={{ flex: 1, fontSize: 14, color: selectedCategory ? "#111827" : "#9ca3af" }}>
{selectedCategory ? selectedCategory.name : tFn('transaction.selectCategory')}
</Text>
{selectedCategory ? (
<Pressable onPress={(e) => { e.stopPropagation(); setSelectedCategoryId(null); }} hitSlop={8}>
<Ionicons name="close-circle" size={18} color="#9ca3af" />
</Pressable>
) : (
<Ionicons name={showCategoryPicker ? "chevron-up" : "chevron-down"} size={16} color="#9ca3af" />
)}
</Pressable>
{/* Inline Category Picker */}
{showCategoryPicker && (
<View style={{ marginHorizontal: 16, marginBottom: 4, borderWidth: 1, borderColor: "#e5e7eb", borderRadius: 12, backgroundColor: "#fff", maxHeight: 220 }}>
<ScrollView bounces={false} keyboardShouldPersistTaps="handled">
{filteredCategories.map((cat) => {
const active = cat.id === selectedCategoryId;
const color = cat.color ?? "#6b7280";
return (
<Pressable key={cat.id}
onPress={() => { setSelectedCategoryId(active ? null : cat.id); setShowCategoryPicker(false); }}
style={{ flexDirection: "row", alignItems: "center", paddingHorizontal: 14, paddingVertical: 10, backgroundColor: active ? `${color}12` : "#fff", borderBottomWidth: 1, borderBottomColor: "#f3f4f6" }}
>
<View style={{ width: 32, height: 32, borderRadius: 16, backgroundColor: color, alignItems: "center", justifyContent: "center", marginRight: 12 }}>
<Ionicons name={(cat.icon ?? "ellipsis-horizontal-circle-outline") as React.ComponentProps<typeof Ionicons>["name"]} size={16} color="#fff" />
</View>
<Text style={{ flex: 1, fontSize: 14, fontWeight: "500", color: active ? color : "#111827" }}>{cat.name}</Text>
{active && <Ionicons name="checkmark-circle" size={18} color={color} />}
</Pressable>
);
})}
<Pressable
onPress={() => { setShowCategoryPicker(false); onRequestAddCategory(type); }}
style={{ flexDirection: "row", alignItems: "center", paddingHorizontal: 14, paddingVertical: 10 }}
>
<View style={{ width: 32, height: 32, borderRadius: 16, backgroundColor: "#f3f4f6", alignItems: "center", justifyContent: "center", marginRight: 12 }}>
<Ionicons name="add" size={16} color="#9ca3af" />
</View>
<Text style={{ fontSize: 14, fontWeight: "500", color: "#6b7280" }}>{tFn('transaction.addNewCategory')}</Text>
</Pressable>
</ScrollView>
</View>
)}
{/* Description */}
<View className="px-4 mt-2 mb-4">
<TextInput className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900" placeholder={tFn('transaction.descriptionOptional')} value={description} onChangeText={setDescription} />
</View>
{/* Date Row */}
<View className="px-4 mb-3 flex-row items-center" style={{ gap: 10 }}>
<Ionicons name="calendar-outline" size={20} color="#6b7280" />
<Text className="text-base text-gray-700 flex-1">{formatDateDisplay(selectedDate, i18n.language, tFn('common.today'))}</Text>
</View>
{/* Fixkosten Row */}
<View className="px-4 mb-4 flex-row items-center" style={{ gap: 10 }}>
<Ionicons name="repeat-outline" size={20} color="#6b7280" />
<Text className="text-base text-gray-700 flex-1">{tFn('transaction.repeatMonthly')}</Text>
<Switch value={isFixed} onValueChange={setIsFixed} trackColor={{ false: "#d1d5db", true: "#2563EB" }} thumbColor="#fff" />
</View>
{/* Numpad */}
<Numpad onKeyPress={handleNumpad} />
</View>
</Modal>
);
}

View File

@@ -0,0 +1,54 @@
import { Text, View } from "react-native";
import type { TransactionSummary } from "@/src/hooks/useTransactions";
type Props = {
summary: TransactionSummary | undefined;
isLoading: boolean;
accentColor?: string;
};
function formatEur(amount: number) {
return new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(amount);
}
const monthLabel = new Intl.DateTimeFormat("de-DE", { month: "long", year: "numeric" }).format(new Date());
export function SummaryHeader({ summary, isLoading, accentColor = "#2563EB" }: Props) {
const loading = isLoading || !summary;
const income = loading ? null : formatEur(summary!.income);
const expense = loading ? null : formatEur(summary!.expense);
const balance = loading ? null : formatEur(summary!.balance);
const balancePositive = !loading && summary!.balance >= 0;
return (
<View className="bg-blue-600 px-4 pb-5 pt-3">
<Text className="text-center text-blue-200 text-xs mb-3" style={{ opacity: 0.8 }}>
{monthLabel}
</Text>
<View className="flex-row">
<View className="flex-1 items-center">
<Text className="text-blue-200 text-xs mb-1">Einnahmen</Text>
<Text className="text-white font-semibold text-base">
{loading ? "—" : income}
</Text>
</View>
<View className="w-px bg-blue-500 mx-2" />
<View className="flex-1 items-center">
<Text className="text-blue-200 text-xs mb-1">Ausgaben</Text>
<Text className="text-white font-semibold text-base">
{loading ? "—" : expense}
</Text>
</View>
<View className="w-px bg-blue-500 mx-2" />
<View className="flex-1 items-center">
<Text className="text-blue-200 text-xs mb-1">Bilanz</Text>
<Text
className={`font-semibold text-base ${loading ? "text-blue-300" : balancePositive ? "text-green-300" : "text-red-300"}`}
>
{loading ? "—" : balance}
</Text>
</View>
</View>
</View>
);
}

View File

@@ -0,0 +1,178 @@
import { Ionicons } from "@expo/vector-icons";
import type { ComponentProps } from "react";
import { Alert, Pressable, Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import ReanimatedSwipeable from "react-native-gesture-handler/ReanimatedSwipeable";
import Reanimated, { useAnimatedStyle } from "react-native-reanimated";
import type { SharedValue } from "react-native-reanimated";
import type { TransactionWithCategory } from "@/src/hooks/useTransactions";
type IoniconName = ComponentProps<typeof Ionicons>["name"];
const CATEGORY_ICONS: Record<string, IoniconName> = {
"Lebensmittel": "cart-outline",
"Wohnen": "home-outline",
"Transport": "car-outline",
"Gesundheit": "medkit-outline",
"Freizeit": "game-controller-outline",
"Kinder": "happy-outline",
"Urlaub": "airplane-outline",
"Sonstiges": "ellipsis-horizontal-circle-outline",
"Gehalt": "briefcase-outline",
"Sonstiges Einkommen": "cash-outline",
};
function resolveIcon(categoryName: string | null, isIncome: boolean): IoniconName {
if (categoryName && CATEGORY_ICONS[categoryName]) return CATEGORY_ICONS[categoryName];
return isIncome ? "cash-outline" : "ellipsis-horizontal-circle-outline";
}
type Props = {
transaction: TransactionWithCategory;
onPress: (t: TransactionWithCategory) => void;
onDelete: (t: TransactionWithCategory) => void;
locked?: boolean;
};
function formatAmount(amount: string, type: "income" | "expense") {
const num = parseFloat(amount);
const formatted = new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
}).format(num);
return type === "income" ? `+${formatted}` : `-${formatted}`;
}
function formatDate(dateStr: string) {
const date = new Date(dateStr);
return new Intl.DateTimeFormat("de-DE", { day: "2-digit", month: "short" }).format(date);
}
function DeleteAction({
prog,
drag,
onDelete,
}: {
prog: SharedValue<number>;
drag: SharedValue<number>;
onDelete: () => void;
}) {
const { t } = useTranslation();
const animStyle = useAnimatedStyle(() => ({
transform: [{ translateX: drag.value + 80 }],
}));
return (
<Reanimated.View
style={[{ width: 80, backgroundColor: "#dc2626", justifyContent: "center", alignItems: "center" }, animStyle]}
>
<Pressable
onPress={onDelete}
style={{ flex: 1, width: "100%", justifyContent: "center", alignItems: "center" }}
>
<Ionicons name="trash-outline" size={20} color="#fff" />
<Text style={{ color: "#fff", fontSize: 11, marginTop: 3, fontWeight: "600" }}>{t('common.delete')}</Text>
</Pressable>
</Reanimated.View>
);
}
export function TransactionItem({ transaction, onPress, onDelete, locked = false }: Props) {
const { t } = useTranslation();
const isIncome = transaction.type === "income";
const isCarryOver = transaction.isCarryOver;
const iconName: IoniconName = isCarryOver
? "return-down-forward-outline"
: resolveIcon(transaction.categoryName, isIncome);
const iconColor = isCarryOver ? "#6366f1" : (transaction.categoryColor ?? "#6b7280");
const bgColor = isCarryOver ? "#6366f122" : (transaction.categoryColor ?? "#6b7280") + "22";
function handleDeletePress() {
const isFixed = transaction.isFixed;
const hasDebt = (transaction as TransactionWithCategory & { linkedDebtPaymentId?: string | null }).linkedDebtPaymentId;
let message = t('transaction.deleteMessage');
if (isFixed) message = t('transaction.deleteFixed');
if (hasDebt) message = t('transaction.deleteDebt');
Alert.alert(t('transaction.deleteTitle'), message, [
{ text: t('common.cancel'), style: "cancel" },
{ text: t('common.delete'), style: "destructive", onPress: () => onDelete(transaction) },
]);
}
// CarryOver: kein Swipe, kein Edit
if (isCarryOver) {
return (
<Pressable className="flex-row items-center px-4 py-3 active:bg-gray-50">
<View style={{ width: 40, height: 40, borderRadius: 20, backgroundColor: bgColor, alignItems: "center", justifyContent: "center", marginRight: 12 }}>
<Ionicons name={iconName} size={20} color={iconColor} />
</View>
<View className="flex-1">
<Text className="text-sm font-medium" style={{ color: "#6366f1" }} numberOfLines={1}>
{transaction.description ?? t('transaction.carryOver')}
</Text>
<Text className="text-xs text-gray-400 mt-0.5">{formatDate(transaction.date)}</Text>
</View>
<Text className="text-sm font-semibold text-indigo-500">
{formatAmount(transaction.amount, transaction.type)}
</Text>
</Pressable>
);
}
// Locked months: no swipe, no edit — just display
if (locked) {
return (
<View className="flex-row items-center px-4 py-3 bg-white">
<View style={{ width: 40, height: 40, borderRadius: 20, backgroundColor: bgColor, alignItems: "center", justifyContent: "center", marginRight: 12 }}>
<Ionicons name={iconName} size={20} color={iconColor} />
</View>
<View className="flex-1">
<Text className="text-sm font-medium text-gray-900" numberOfLines={1}>
{transaction.description ?? transaction.categoryName ?? "Buchung"}
</Text>
<Text className="text-xs text-gray-400 mt-0.5">
{transaction.categoryName ? `${transaction.categoryName} · ` : ""}
{formatDate(transaction.date)}
</Text>
</View>
<Text className={`text-sm font-semibold ${isIncome ? "text-green-600" : "text-gray-900"}`}>
{formatAmount(transaction.amount, transaction.type)}
</Text>
</View>
);
}
return (
<ReanimatedSwipeable
friction={2}
rightThreshold={40}
renderRightActions={(prog, drag) => (
<DeleteAction prog={prog} drag={drag} onDelete={handleDeletePress} />
)}
>
<Pressable
onPress={() => onPress(transaction)}
className="flex-row items-center px-4 py-3 active:bg-gray-50 bg-white"
>
<View style={{ width: 40, height: 40, borderRadius: 20, backgroundColor: bgColor, alignItems: "center", justifyContent: "center", marginRight: 12 }}>
<Ionicons name={iconName} size={20} color={iconColor} />
</View>
<View className="flex-1">
<Text className="text-sm font-medium text-gray-900" numberOfLines={1}>
{transaction.description ?? transaction.categoryName ?? "Buchung"}
</Text>
<Text className="text-xs text-gray-400 mt-0.5">
{transaction.categoryName ? `${transaction.categoryName} · ` : ""}
{formatDate(transaction.date)}
</Text>
</View>
<Text className={`text-sm font-semibold ${isIncome ? "text-green-600" : "text-gray-900"}`}>
{formatAmount(transaction.amount, transaction.type)}
</Text>
</Pressable>
</ReanimatedSwipeable>
);
}

View File

@@ -0,0 +1,219 @@
import { TAB_COLORS } from "@/src/constants/colors";
import { QuickAddModal } from "./QuickAddModal";
import { MonthSummaryHeader } from "./MonthSummaryHeader";
import { TransactionItem } from "./TransactionItem";
import { EditTransactionModal } from "./EditTransactionModal";
import { CarryOverBanner } from "./CarryOverBanner";
import { AddCategoryModal } from "@/src/components/features/categories/AddCategoryModal";
import { EmptyState } from "@/src/components/ui/EmptyState";
import { useTransactions, useMonthBalance, useActivateFixed, useDeleteTransaction } from "@/src/hooks/useTransactions";
import type { TransactionWithCategory } from "@/src/hooks/useTransactions";
import type { Category } from "@/src/hooks/useCategories";
import { currentMonthStr, addMonths, monthLabel, monthDateRange } from "@/src/utils/date";
import { Ionicons } from "@expo/vector-icons";
import React, { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
FlatList,
Pressable,
RefreshControl,
Text,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
type FilterType = "all" | "income" | "expense";
type Scope = "household" | "private" | "child";
type Props = {
scope: Scope;
childId?: string;
accentColor?: string;
emptyTitle?: string;
emptySubtitle?: string;
disableTopInset?: boolean;
headerExtra?: React.ReactNode;
};
const ACCENT_COLORS: Record<Scope, string> = {
household: TAB_COLORS.household,
private: TAB_COLORS.private,
child: TAB_COLORS.children,
};
export function TransactionScreen({
scope,
childId,
accentColor,
emptyTitle,
emptySubtitle,
disableTopInset = false,
headerExtra,
}: Props) {
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const resolvedEmptyTitle = emptyTitle ?? t('household.noTransactions');
const resolvedEmptySubtitle = emptySubtitle ?? t('household.noTransactionsHint');
const [filter, setFilter] = useState<FilterType>("all");
const [month, setMonth] = useState(currentMonthStr());
const [showAddModal, setShowAddModal] = useState(false);
const [showAddCategory, setShowAddCategory] = useState(false);
const [addCategoryType, setAddCategoryType] = useState<"expense" | "income">("expense");
const [newCategory, setNewCategory] = useState<Category | null>(null);
const [editTransaction, setEditTransaction] = useState<TransactionWithCategory | null>(null);
const { mutate: deleteTransaction } = useDeleteTransaction();
const color = accentColor ?? ACCENT_COLORS[scope];
const isCurrent = month === currentMonthStr();
// 11a: activate fixed transactions silently on mount + when month changes to current
const { mutate: activateFixed } = useActivateFixed();
useEffect(() => {
if (isCurrent) {
activateFixed({ month, scope, ...(childId ? { childId } : {}) });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [month, scope, childId]);
const [fromDate, toDate] = monthDateRange(month);
const transactionFilter = {
scope,
from: fromDate,
to: toDate,
...(childId ? { childId } : {}),
...(filter !== "all" ? { type: filter as "income" | "expense" } : {}),
};
const { data: transactions = [], isLoading, refetch, isRefetching } = useTransactions(transactionFilter);
const { data: balance, isLoading: balanceLoading } = useMonthBalance(scope, month, childId);
function renderEmpty() {
if (isLoading) {
return (
<View className="flex-1 items-center justify-center py-20">
<ActivityIndicator size="large" color={color} />
</View>
);
}
return (
<EmptyState
icon="wallet-outline"
title={resolvedEmptyTitle}
subtitle={resolvedEmptySubtitle}
/>
);
}
return (
<View className="flex-1 bg-gray-50">
{/* Neutral header — paddingTop for safe area when used as top-level screen */}
<View style={{ backgroundColor: "#fff", borderBottomWidth: 1, borderBottomColor: "#f3f4f6", paddingTop: disableTopInset ? 0 : insets.top }}>
{/* Month Switcher */}
<View className="flex-row items-center justify-center gap-4 py-3">
<Pressable onPress={() => setMonth((m) => addMonths(m, -1))} className="p-1 active:opacity-50">
<Ionicons name="chevron-back" size={18} color="#6b7280" />
</Pressable>
<Text className="text-sm font-semibold w-32 text-center text-gray-800">
{monthLabel(month)}
</Text>
<Pressable
onPress={() => setMonth((m) => addMonths(m, 1))}
disabled={isCurrent}
className="p-1 active:opacity-50"
style={{ opacity: isCurrent ? 0.3 : 1 }}
>
<Ionicons name="chevron-forward" size={18} color="#6b7280" />
</Pressable>
</View>
</View>
{headerExtra}
<MonthSummaryHeader
income={balance?.income}
expense={balance?.expense}
balance={balance?.balance}
isLoading={balanceLoading}
accentColor={color}
/>
{/* Filter Bar */}
<View className="flex-row px-4 py-3 gap-2 bg-white border-b border-gray-100 mt-3">
{(["all", "expense", "income"] as const).map((f) => (
<Pressable
key={f}
onPress={() => setFilter(f)}
style={{ backgroundColor: filter === f ? color : "#f3f4f6" }}
className="px-4 py-1.5 rounded-full"
>
<Text
className="text-sm font-medium"
style={{ color: filter === f ? "#fff" : "#4b5563" }}
>
{f === "all" ? t('household.all') : f === "expense" ? t('household.expenses') : t('household.income')}
</Text>
</Pressable>
))}
</View>
{/* List */}
<FlatList
data={transactions}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<View className="bg-white">
<TransactionItem
transaction={item}
onPress={setEditTransaction}
onDelete={(t) => deleteTransaction(t.id)}
/>
</View>
)}
ListHeaderComponent={
!isCurrent ? (
<CarryOverBanner month={month} scope={scope} childId={childId} />
) : null
}
ListEmptyComponent={renderEmpty}
refreshControl={
<RefreshControl refreshing={isRefetching} onRefresh={() => void refetch()} tintColor={color} />
}
ItemSeparatorComponent={() => <View className="h-px bg-gray-50 ml-16" />}
contentContainerStyle={transactions.length === 0 ? { flex: 1 } : undefined}
/>
{/* FAB */}
<Pressable
onPress={() => setShowAddModal(true)}
style={{ backgroundColor: color }}
className="absolute bottom-6 right-6 w-14 h-14 rounded-full items-center justify-center shadow-lg active:opacity-80"
>
<Ionicons name="add" size={28} color="#fff" />
</Pressable>
<QuickAddModal
visible={showAddModal}
onClose={() => { setShowAddModal(false); setNewCategory(null); }}
onRequestAddCategory={(t) => { setAddCategoryType(t); setShowAddModal(false); setShowAddCategory(true); }}
newCategory={newCategory}
defaultScope={scope}
defaultChildId={childId}
/>
<AddCategoryModal
visible={showAddCategory}
onClose={() => { setShowAddCategory(false); setShowAddModal(true); }}
defaultType={addCategoryType}
onCreated={(cat) => { setNewCategory(cat); setShowAddCategory(false); setShowAddModal(true); }}
/>
{editTransaction && (
<EditTransactionModal
transaction={editTransaction}
onClose={() => setEditTransaction(null)}
/>
)}
</View>
);
}

View File

@@ -0,0 +1,20 @@
import { Ionicons } from "@expo/vector-icons";
import type { ComponentProps } from "react";
import { Text, View } from "react-native";
type Props = {
icon: ComponentProps<typeof Ionicons>["name"];
title: string;
subtitle: string;
iconSize?: number;
};
export function EmptyState({ icon, title, subtitle, iconSize = 48 }: Props) {
return (
<View className="flex-1 items-center justify-center py-20">
<Ionicons name={icon} size={iconSize} color="#d1d5db" style={{ marginBottom: 12 }} />
<Text className="text-base font-medium text-gray-700 mb-1">{title}</Text>
<Text className="text-sm text-gray-400 text-center px-8">{subtitle}</Text>
</View>
);
}

View File

@@ -0,0 +1,13 @@
import { Text, View } from "react-native";
type ErrorMessageProps = {
message: string;
};
export function ErrorMessage({ message }: ErrorMessageProps) {
return (
<View className="rounded-lg bg-red-50 p-4">
<Text className="text-red-600">{message}</Text>
</View>
);
}

View File

@@ -0,0 +1,14 @@
import { ActivityIndicator, View } from "react-native";
type LoadingSpinnerProps = {
size?: "small" | "large";
color?: string;
};
export function LoadingSpinner({ size = "large", color = "#9ca3af" }: LoadingSpinnerProps) {
return (
<View className="flex-1 items-center justify-center">
<ActivityIndicator size={size} color={color} />
</View>
);
}

View File

@@ -0,0 +1,49 @@
import { ActivityIndicator, Pressable, Text, View } from "react-native";
import { TAB_COLORS } from "@/src/constants/colors";
type Props = {
title: string;
onClose: () => void;
closeLabel: string;
onSave?: () => void;
saveLabel?: string;
saveDisabled?: boolean;
saveLoading?: boolean;
saveColor?: string;
};
export function ModalHeader({
title,
onClose,
closeLabel,
onSave,
saveLabel,
saveDisabled = false,
saveLoading = false,
saveColor = TAB_COLORS.household,
}: Props) {
return (
<View className="flex-row items-center justify-between px-4 py-4 border-b border-gray-100">
<Pressable onPress={onClose}>
<Text className="text-base text-gray-500">{closeLabel}</Text>
</Pressable>
<Text className="text-base font-semibold text-gray-900">{title}</Text>
{onSave ? (
<Pressable onPress={onSave} disabled={saveLoading || saveDisabled}>
{saveLoading ? (
<ActivityIndicator size="small" color={saveColor} />
) : (
<Text
className="text-base font-semibold"
style={{ color: saveDisabled ? "#9ca3af" : saveColor }}
>
{saveLabel}
</Text>
)}
</Pressable>
) : (
<View style={{ width: 40 }} />
)}
</View>
);
}

View File

@@ -0,0 +1,31 @@
import { Ionicons } from "@expo/vector-icons";
import { Pressable, Text, View } from "react-native";
import { NUMPAD_KEYS } from "@/src/utils/numpad";
type Props = {
onKeyPress: (key: string) => void;
};
export function Numpad({ onKeyPress }: Props) {
return (
<View className="px-4">
{NUMPAD_KEYS.map((row, i) => (
<View key={i} className="flex-row gap-2 mb-2">
{row.map((key) => (
<Pressable
key={key}
onPress={() => onKeyPress(key)}
className="flex-1 h-14 bg-gray-100 rounded-xl items-center justify-center active:bg-gray-200"
>
{key === "\u232B" ? (
<Ionicons name="backspace-outline" size={20} color="#374151" />
) : (
<Text className="text-xl font-medium text-gray-800">{key}</Text>
)}
</Pressable>
))}
</View>
))}
</View>
);
}

View File

@@ -0,0 +1,7 @@
export const TAB_COLORS = {
household: "#2563EB",
private: "#7C3AED",
children: "#16A34A",
shopping: "#16A34A",
more: "#6B7280",
} as const;

View File

@@ -0,0 +1,31 @@
import { useCallback, useState } from "react";
import { apiRequest } from "../lib/api-client";
type ApiState<T> = {
data: T | null;
error: string | null;
isLoading: boolean;
};
export function useApi<T>() {
const [state, setState] = useState<ApiState<T>>({
data: null,
error: null,
isLoading: false,
});
const execute = useCallback(async (path: string, options?: RequestInit) => {
setState((prev) => ({ ...prev, isLoading: true, error: null }));
try {
const data = await apiRequest<T>(path, options);
setState({ data, error: null, isLoading: false });
return data;
} catch (err) {
const error = err instanceof Error ? err.message : "Unknown error";
setState({ data: null, error, isLoading: false });
throw err;
}
}, []);
return { ...state, execute };
}

View File

@@ -0,0 +1,104 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "../lib/api-client";
import { useAuthStore } from "../stores/auth.store";
import type { ComponentProps } from "react";
import type { Ionicons } from "@expo/vector-icons";
type IoniconName = ComponentProps<typeof Ionicons>["name"];
// Icon mapping by category name — DB icon field is not used for rendering
export const CATEGORY_ICONS: Record<string, IoniconName> = {
"Lebensmittel": "cart-outline",
"Wohnen": "home-outline",
"Transport": "car-outline",
"Gesundheit": "medkit-outline",
"Freizeit": "game-controller-outline",
"Kinder": "happy-outline",
"Urlaub": "airplane-outline",
"Sonstiges": "ellipsis-horizontal-circle-outline",
"Gehalt": "briefcase-outline",
"Sonstiges Einkommen": "cash-outline",
};
const DEFAULT_EXPENSE_ICON: IoniconName = "ellipsis-horizontal-circle-outline";
const DEFAULT_INCOME_ICON: IoniconName = "cash-outline";
export type Category = {
id: string;
name: string;
icon: IoniconName;
color: string | null;
type: "income" | "expense";
isDefault: boolean;
};
type ApiCategory = {
id: string;
name: string;
icon: string | null;
color: string | null;
type: "income" | "expense";
isDefault: boolean;
};
function mapCategory(cat: ApiCategory): Category {
return {
...cat,
icon:
CATEGORY_ICONS[cat.name] ??
(cat.type === "income" ? DEFAULT_INCOME_ICON : DEFAULT_EXPENSE_ICON),
};
}
export function useCategories() {
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
return useQuery({
queryKey: ["categories", activeHouseholdId],
queryFn: () =>
apiRequest<{ categories: ApiCategory[] }>("/api/households/categories"),
select: (data) => data.categories.map(mapCategory),
enabled: !!activeHouseholdId,
});
}
export function useCreateCategory() {
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: { name: string; icon?: string | null; color?: string | null; type: "income" | "expense" }) =>
apiRequest<{ category: ApiCategory }>("/api/categories", {
method: "POST",
body: JSON.stringify(data),
}).then((r) => mapCategory(r.category)),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["categories", activeHouseholdId] });
},
});
}
export function useUpdateCategory() {
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, ...data }: { id: string; name?: string; icon?: string | null; color?: string | null }) =>
apiRequest<{ category: ApiCategory }>(`/api/categories/${id}`, {
method: "PATCH",
body: JSON.stringify(data),
}).then((r) => mapCategory(r.category)),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["categories", activeHouseholdId] });
},
});
}
export function useDeleteCategory() {
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
apiRequest<{ deleted: boolean }>(`/api/categories/${id}`, { method: "DELETE" }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["categories", activeHouseholdId] });
},
});
}

View File

@@ -0,0 +1,54 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "../lib/api-client";
import { useAuthStore } from "../stores/auth.store";
export type Child = {
id: string;
name: string;
color: string;
householdId: string;
createdAt: string;
};
export type CreateChildInput = {
name: string;
color?: string;
};
export function useChildren() {
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
return useQuery({
queryKey: ["children", activeHouseholdId],
queryFn: () => apiRequest<{ children: Child[] }>("/api/children"),
select: (data) => data.children,
enabled: !!activeHouseholdId,
});
}
export function useCreateChild() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CreateChildInput) =>
apiRequest<{ child: Child }>("/api/children", {
method: "POST",
body: JSON.stringify(input),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["children"] });
},
});
}
export function useDeleteChild() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
apiRequest<{ child: Child }>(`/api/children/${id}`, {
method: "DELETE",
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["children"] });
},
});
}

View File

@@ -0,0 +1,109 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "../lib/api-client";
import { useAuthStore } from "../stores/auth.store";
export type Debt = {
id: string;
householdId: string;
userId: string;
creditorUserId: string | null;
creditorUserName: string | null;
label: string;
creditor: string | null;
totalAmount: number;
paidAmount: number;
remainingAmount: number;
progressPercent: number;
notes: string | null;
createdAt: string;
closedAt: string | null;
};
export type DebtPayment = {
id: string;
debtId: string;
amount: number;
date: string;
note: string | null;
linkedTransactionId: string | null;
createdAt: string;
};
export type CreateDebtInput = {
label: string;
creditorUserId?: string;
creditor?: string;
totalAmount: number;
notes?: string;
};
export type CreateDebtPaymentInput = {
debtId: string;
amount: number;
date: string;
note?: string;
};
export function useDebts() {
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
return useQuery({
queryKey: ["debts", activeHouseholdId],
queryFn: () => apiRequest<{ debts: Debt[] }>("/api/debts"),
select: (data) => data.debts,
enabled: !!activeHouseholdId,
});
}
export function useClaims() {
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
return useQuery({
queryKey: ["debts-claims", activeHouseholdId],
queryFn: () => apiRequest<{ debts: Debt[] }>("/api/debts/claims"),
select: (data) => data.debts,
enabled: !!activeHouseholdId,
});
}
export function useCreateDebt() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CreateDebtInput) =>
apiRequest<{ debt: Debt }>("/api/debts", {
method: "POST",
body: JSON.stringify(input),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["debts"] });
},
});
}
export function useDeleteDebt() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
apiRequest<{ success: boolean }>(`/api/debts/${id}`, {
method: "DELETE",
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["debts"] });
void queryClient.invalidateQueries({ queryKey: ["transactions"] });
},
});
}
export function useCreateDebtPayment() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CreateDebtPaymentInput) =>
apiRequest<{ payment: DebtPayment; debt: Debt }>("/api/debts/payments", {
method: "POST",
body: JSON.stringify(input),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["debts"] });
void queryClient.invalidateQueries({ queryKey: ["debts-claims"] });
void queryClient.invalidateQueries({ queryKey: ["transactions"] });
},
});
}

View File

@@ -0,0 +1,210 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "../lib/api-client";
import { useAuthStore } from "../stores/auth.store";
export type FixedCost = {
id: string;
householdId: string;
scope: "household" | "private" | "child";
childId: string | null;
categoryId: string | null;
label: string;
amount: number;
type: "income" | "expense";
isActive: boolean;
createdAt: string;
};
export type TransferLineItem = {
id: string;
householdId: string;
label: string;
amount: number;
isActive: boolean;
createdAt: string;
};
export type MonthlyTransfer = {
id: string;
householdId: string;
month: string;
fromUserId: string;
toUserId: string;
amount: number;
note: string | null;
createdAt: string;
};
export type NettoMonth = {
month: string;
totalIncome: number;
incomeByScope: Array<{ scope: string; label: string; amount: number }>;
totalExpenses: number;
netto: number;
};
export type SettlementV2 = {
month: string;
householdExpenses: number;
householdIncome: number;
householdNet: number;
memberCount: number;
perMemberShare: number;
userSharePercent: number;
lineItems: Array<{ id: string; label: string; amount: number }>;
lineItemsTotal: number;
myOwnExpenses: number;
transfers: MonthlyTransfer[];
alreadyTransferred: number;
totalOwed: number;
remaining: number;
members: Array<{ userId: string; name: string; paid: number; owes: number }>;
};
export type CreateFixedCostInput = {
scope: "household" | "private" | "child";
childId?: string;
categoryId?: string;
label: string;
amount: number;
type?: "income" | "expense";
};
export type UpdateFixedCostInput = {
label?: string;
amount?: number;
categoryId?: string | null;
isActive?: boolean;
};
// ── Fixed Costs ───────────────────────────────────────────────────────────────
export function useFixedCosts() {
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
return useQuery({
queryKey: ["fixed-costs", activeHouseholdId],
queryFn: () => apiRequest<{ fixedCosts: FixedCost[] }>("/api/fixed-costs"),
select: (data) => data.fixedCosts,
enabled: !!activeHouseholdId,
});
}
export function useCreateFixedCost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CreateFixedCostInput) =>
apiRequest<{ fixedCost: FixedCost }>("/api/fixed-costs", {
method: "POST",
body: JSON.stringify(input),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["fixed-costs"] });
},
});
}
export function useUpdateFixedCost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, input }: { id: string; input: UpdateFixedCostInput }) =>
apiRequest<{ fixedCost: FixedCost }>(`/api/fixed-costs/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["fixed-costs"] });
},
});
}
export function useDeleteFixedCost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
apiRequest<{ success: boolean }>(`/api/fixed-costs/${id}`, { method: "DELETE" }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["fixed-costs"] });
},
});
}
// ── Transfer Line Items ───────────────────────────────────────────────────────
export function useTransferLineItems() {
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
return useQuery({
queryKey: ["transfer-line-items", activeHouseholdId],
queryFn: () => apiRequest<{ lineItems: TransferLineItem[] }>("/api/fixed-costs/line-items"),
select: (data) => data.lineItems,
enabled: !!activeHouseholdId,
});
}
export function useCreateTransferLineItem() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: { label: string; amount: number }) =>
apiRequest<{ lineItem: TransferLineItem }>("/api/fixed-costs/line-items", {
method: "POST",
body: JSON.stringify(input),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["transfer-line-items"] });
void queryClient.invalidateQueries({ queryKey: ["settlement-v2"] });
},
});
}
export function useDeleteTransferLineItem() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
apiRequest<{ success: boolean }>(`/api/fixed-costs/line-items/${id}`, { method: "DELETE" }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["transfer-line-items"] });
void queryClient.invalidateQueries({ queryKey: ["settlement-v2"] });
},
});
}
// ── Monthly Transfers ─────────────────────────────────────────────────────────
export function useCreateMonthlyTransfer() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: { month: string; toUserId: string; amount: number; note?: string }) =>
apiRequest<{ transfer: MonthlyTransfer }>("/api/fixed-costs/monthly-transfers", {
method: "POST",
body: JSON.stringify(input),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["settlement-v2"] });
},
});
}
// ── Netto Month ───────────────────────────────────────────────────────────────
export function useNettoMonth(month: string) {
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
return useQuery({
queryKey: ["netto-month", activeHouseholdId, month],
queryFn: () =>
apiRequest<{ netto: NettoMonth | null }>(`/api/fixed-costs/netto/${month}`),
select: (data) => data.netto,
enabled: !!activeHouseholdId,
});
}
// ── Settlement V2 ─────────────────────────────────────────────────────────────
export function useSettlementV2(month: string) {
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
return useQuery({
queryKey: ["settlement-v2", activeHouseholdId, month],
queryFn: () =>
apiRequest<{ settlement: SettlementV2 }>(`/api/fixed-costs/settlement/${month}`),
select: (data) => data.settlement,
enabled: !!activeHouseholdId,
});
}

View File

@@ -0,0 +1,79 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "../lib/api-client";
import { useAuthStore } from "../stores/auth.store";
import { authClient } from "../lib/auth-client";
export type HouseholdMember = {
userId: string;
name: string;
email: string;
role: string;
};
export type PendingInvitation = {
id: string;
email: string;
role: string | null;
status: string;
expiresAt: string;
createdAt: string;
};
type MembersResponse = {
members: HouseholdMember[];
pendingInvitations: PendingInvitation[];
};
export function useHouseholdMembers() {
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
return useQuery({
queryKey: ["household-members", activeHouseholdId],
queryFn: () => apiRequest<MembersResponse>("/api/households/members"),
enabled: !!activeHouseholdId,
});
}
export function useInviteMember() {
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (email: string) => {
const result = await authClient.organization.inviteMember({
email,
role: "member",
organizationId: activeHouseholdId!,
});
if (result.error) throw new Error(result.error.message ?? "Einladung fehlgeschlagen");
return result.data;
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["household-members", activeHouseholdId] });
},
});
}
export function useRevokeInvitation() {
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
const queryClient = useQueryClient();
return useMutation({
mutationFn: (invitationId: string) =>
apiRequest(`/api/households/invitations/${invitationId}`, { method: "DELETE" }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["household-members", activeHouseholdId] });
},
});
}
export function useAcceptInvitation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (invitationId: string) => {
const result = await authClient.organization.acceptInvitation({ invitationId });
if (result.error) throw new Error(result.error.message ?? "Annahme fehlgeschlagen");
return result.data;
},
onSuccess: () => {
void queryClient.invalidateQueries();
},
});
}

View File

@@ -0,0 +1,58 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "../lib/api-client";
import { useAuthStore } from "../stores/auth.store";
export type HouseholdSettings = {
id: string;
householdId: string;
ownerName: string;
partnerName: string;
userSharePercent: number;
monthlyBudget: number;
currency: string;
splitChildCosts: boolean;
payerUserId: string | null;
onboardingComplete: boolean;
language: string;
createdAt: string;
updatedAt: string;
};
export type UpdateHouseholdSettingsInput = {
ownerName?: string;
partnerName?: string;
userSharePercent?: number;
monthlyBudget?: number;
currency?: string;
splitChildCosts?: boolean;
payerUserId?: string | null;
onboardingComplete?: boolean;
language?: string;
};
export function useHouseholdSettings() {
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
return useQuery({
queryKey: ["household-settings", activeHouseholdId],
queryFn: () => apiRequest<{ settings: HouseholdSettings }>("/api/household-settings"),
select: (data) => data.settings,
enabled: !!activeHouseholdId,
});
}
export function useUpdateHouseholdSettings() {
const queryClient = useQueryClient();
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
return useMutation({
mutationFn: (input: UpdateHouseholdSettingsInput) =>
apiRequest<{ settings: HouseholdSettings }>("/api/household-settings", {
method: "PATCH",
body: JSON.stringify(input),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["household-settings", activeHouseholdId] });
void queryClient.invalidateQueries({ queryKey: ["netto-month"] });
void queryClient.invalidateQueries({ queryKey: ["settlement-v2"] });
},
});
}

View File

@@ -0,0 +1,31 @@
import { useMutation } from "@tanstack/react-query";
import { apiRequest } from "../lib/api-client";
type GenerateInviteCodeResponse = {
code: string;
expiresAt: string;
};
type JoinWithCodeResponse = {
householdId: string;
householdName: string;
};
export function useGenerateInviteCode() {
return useMutation({
mutationFn: () =>
apiRequest<GenerateInviteCodeResponse>("/api/households/invite/generate", {
method: "POST",
}),
});
}
export function useJoinWithCode() {
return useMutation({
mutationFn: (code: string) =>
apiRequest<JoinWithCodeResponse>("/api/households/invite/join", {
method: "POST",
body: JSON.stringify({ code }),
}),
});
}

View File

@@ -0,0 +1,50 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "../lib/api-client";
import { useAuthStore } from "../stores/auth.store";
export type MonthStatus = {
id: string;
householdId: string;
month: string;
status: "open" | "closed";
closedAt: string | null;
closedBy: string | null;
finalAmount: number | null;
notes: string | null;
finalTransferId: string | null;
createdAt: string;
};
export function useMonthStatus(month: string) {
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
return useQuery({
queryKey: ["month-status", activeHouseholdId, month],
queryFn: () =>
apiRequest<{ status: MonthStatus }>(`/api/months/${month}/status`),
select: (data) => data.status,
enabled: !!activeHouseholdId,
});
}
export type CloseMonthInput = {
finalAmount: number;
toUserId: string;
notes?: string;
};
export function useCloseMonth(month: string) {
const queryClient = useQueryClient();
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
return useMutation({
mutationFn: (input: CloseMonthInput) =>
apiRequest<{ status: MonthStatus }>(`/api/months/${month}/close`, {
method: "POST",
body: JSON.stringify(input),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["month-status", activeHouseholdId, month] });
void queryClient.invalidateQueries({ queryKey: ["settlement-v2", activeHouseholdId, month] });
void queryClient.invalidateQueries({ queryKey: ["transactions", activeHouseholdId] });
},
});
}

View File

@@ -0,0 +1,28 @@
import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "../lib/api-client";
import { useAuthStore } from "../stores/auth.store";
export type SettlementMember = {
userId: string;
name: string;
paid: number;
owes: number;
};
export type Settlement = {
month: string;
totalExpenses: number;
memberCount: number;
perMember: number;
members: SettlementMember[];
};
export function useSettlement(month: string) {
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
return useQuery({
queryKey: ["settlement", activeHouseholdId, month],
queryFn: () =>
apiRequest<Settlement>(`/api/households/settlement?month=${month}`),
enabled: !!activeHouseholdId,
});
}

View File

@@ -0,0 +1,224 @@
import { useCallback, useEffect, useRef, useState } from "react";
import * as SecureStore from "expo-secure-store";
import { env } from "@haushaltsApp/env/native";
import { useAuthStore } from "../stores/auth.store";
// Mirroring @haushaltsApp/shared/schemas/shopping.schema types
// (workspace package exports are not resolved by Expo's TS config)
export type ShoppingItem = {
id: string;
householdId: string;
label: string;
quantity: string | null;
addedBy: string;
checkedBy: string | null;
checkedAt: string | null;
sortOrder: number;
createdAt: string;
};
type ShoppingServerEvent =
| { type: "item:added"; item: ShoppingItem }
| { type: "item:checked"; itemId: string; checkedBy: string; checkedAt: string }
| { type: "item:unchecked"; itemId: string }
| { type: "item:deleted"; itemId: string }
| { type: "item:cleared" }
| { type: "sync"; items: ShoppingItem[] };
type ShoppingClientCommand =
| { type: "item:add"; label: string; quantity?: string }
| { type: "item:check"; itemId: string }
| { type: "item:uncheck"; itemId: string }
| { type: "item:delete"; itemId: string }
| { type: "item:clear" };
// expoClient plugin stores the session token under "<storagePrefix>.session_token"
const TOKEN_KEY = "haushaltsapp.session_token";
const MAX_BACKOFF_MS = 16_000;
function getWsUrl(serverUrl: string, householdId: string, token: string): string {
// Convert http(s) to ws(s) for WebSocket connection
const base = serverUrl.replace(/^http/, "ws");
return `${base}/api/shopping-lists/ws?householdId=${encodeURIComponent(householdId)}&token=${encodeURIComponent(token)}`;
}
export type ConnectionStatus = "connecting" | "connected" | "offline";
export function useShoppingList() {
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
const [items, setItems] = useState<ShoppingItem[]>([]);
const [status, setStatus] = useState<ConnectionStatus>("connecting");
const wsRef = useRef<WebSocket | null>(null);
const backoffRef = useRef(1_000);
const retryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const mountedRef = useRef(true);
const offlineQueueRef = useRef<ShoppingClientCommand[]>([]);
const send = useCallback((cmd: ShoppingClientCommand) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(cmd));
} else {
offlineQueueRef.current.push(cmd);
}
}, []);
function handleServerEvent(event: ShoppingServerEvent) {
switch (event.type) {
case "sync":
setItems(event.items);
break;
case "item:added":
setItems((prev) => {
// Replace optimistic placeholder (keyed by label) or append
const withoutTemp = prev.filter((i) => i.id !== `temp-${event.item.label}`);
return [...withoutTemp, event.item];
});
break;
case "item:checked":
setItems((prev) =>
prev.map((i) =>
i.id === event.itemId
? { ...i, checkedBy: event.checkedBy, checkedAt: event.checkedAt }
: i,
),
);
break;
case "item:unchecked":
setItems((prev) =>
prev.map((i) =>
i.id === event.itemId ? { ...i, checkedBy: null, checkedAt: null } : i,
),
);
break;
case "item:deleted":
setItems((prev) => prev.filter((i) => i.id !== event.itemId));
break;
case "item:cleared":
setItems((prev) => prev.filter((i) => i.checkedBy === null));
break;
}
}
const connect = useCallback(async () => {
if (!activeHouseholdId || !mountedRef.current) return;
const token = (await SecureStore.getItemAsync(TOKEN_KEY)) ?? "";
if (!token) return;
const wsUrl = getWsUrl(env.EXPO_PUBLIC_SERVER_URL, activeHouseholdId, token);
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
setStatus("connecting");
ws.onopen = () => {
if (!mountedRef.current) {
ws.close();
return;
}
setStatus("connected");
backoffRef.current = 1_000;
// Flush offline queue
const queue = offlineQueueRef.current.splice(0);
for (const cmd of queue) ws.send(JSON.stringify(cmd));
};
ws.onmessage = (event) => {
if (!mountedRef.current) return;
try {
const serverEvent = JSON.parse(event.data as string) as ShoppingServerEvent;
handleServerEvent(serverEvent);
} catch {
// Ignore malformed messages
}
};
ws.onerror = () => {
ws.close();
};
ws.onclose = () => {
if (!mountedRef.current) return;
setStatus("offline");
wsRef.current = null;
retryTimeoutRef.current = setTimeout(() => {
backoffRef.current = Math.min(backoffRef.current * 2, MAX_BACKOFF_MS);
void connect();
}, backoffRef.current);
};
}, [activeHouseholdId]);
useEffect(() => {
mountedRef.current = true;
void connect();
return () => {
mountedRef.current = false;
if (retryTimeoutRef.current) clearTimeout(retryTimeoutRef.current);
wsRef.current?.close();
};
}, [connect]);
// ── Mutations (optimistic + WS) ────────────────────────────────────────────
const addItem = useCallback(
(label: string, quantity?: string) => {
const trimmed = label.trim();
if (!trimmed) return;
// Optimistic placeholder
const tempItem: ShoppingItem = {
id: `temp-${trimmed}`,
householdId: activeHouseholdId ?? "",
label: trimmed,
quantity: quantity ?? null,
addedBy: "",
checkedBy: null,
checkedAt: null,
sortOrder: 0,
createdAt: new Date().toISOString(),
};
setItems((prev) => [...prev, tempItem]);
send({ type: "item:add", label: trimmed, quantity });
},
[activeHouseholdId, send],
);
const toggleItem = useCallback(
(item: ShoppingItem) => {
const isChecked = item.checkedBy !== null;
// Optimistic update
setItems((prev) =>
prev.map((i) =>
i.id === item.id
? isChecked
? { ...i, checkedBy: null, checkedAt: null }
: { ...i, checkedBy: "optimistic", checkedAt: new Date().toISOString() }
: i,
),
);
send(
isChecked
? { type: "item:uncheck", itemId: item.id }
: { type: "item:check", itemId: item.id },
);
},
[send],
);
const deleteItem = useCallback(
(itemId: string) => {
// Optimistic update
setItems((prev) => prev.filter((i) => i.id !== itemId));
send({ type: "item:delete", itemId });
},
[send],
);
const deleteChecked = useCallback(() => {
// Optimistic update
setItems((prev) => prev.filter((i) => i.checkedBy === null));
send({ type: "item:clear" });
}, [send]);
return { items, status, addItem, toggleItem, deleteItem, deleteChecked };
}

View File

@@ -0,0 +1,174 @@
import {
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { apiRequest } from "../lib/api-client";
import { useAuthStore } from "../stores/auth.store";
import { monthDateRange } from "../utils/date";
import type {
CreateTransactionInput,
TransactionFilters,
} from "@haushaltsApp/shared/schemas/transaction";
export type TransactionWithCategory = {
id: string;
amount: string;
type: "income" | "expense";
scope: "household" | "private" | "child";
childId: string | null;
isFixed: boolean;
isCarryOver: boolean;
description: string | null;
merchant: string | null;
date: string;
categoryName: string | null;
categoryIcon: string | null;
categoryColor: string | null;
createdAt: string;
};
export type TransactionSummary = {
income: number;
expense: number;
balance: number;
};
export function useTransactions(filters?: Partial<TransactionFilters>) {
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
const params = new URLSearchParams();
if (filters?.type) params.set("type", filters.type);
if (filters?.categoryId) params.set("categoryId", filters.categoryId);
if (filters?.scope) params.set("scope", filters.scope);
if (filters?.childId) params.set("childId", filters.childId);
if (filters?.from) params.set("from", filters.from);
if (filters?.to) params.set("to", filters.to);
const query = params.toString();
return useQuery({
queryKey: ["transactions", activeHouseholdId, filters],
queryFn: () =>
apiRequest<{ transactions: TransactionWithCategory[] }>(
`/api/transactions${query ? `?${query}` : ""}`,
),
select: (data) => data.transactions,
enabled: !!activeHouseholdId,
});
}
export function useTransactionSummary(scope?: "household" | "private" | "child") {
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
const query = scope ? `?scope=${scope}` : "";
return useQuery({
queryKey: ["transactions", "summary", activeHouseholdId, scope],
queryFn: () => apiRequest<TransactionSummary>(`/api/transactions/summary${query}`),
enabled: !!activeHouseholdId,
});
}
export function useCreateTransaction() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CreateTransactionInput) =>
apiRequest<{ transaction: TransactionWithCategory }>("/api/transactions", {
method: "POST",
body: JSON.stringify(input),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["transactions"] });
},
});
}
export function useDeleteTransaction() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
apiRequest(`/api/transactions/${id}`, { method: "DELETE" }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["transactions"] });
},
});
}
export function useUpdateTransaction() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, ...input }: { id: string } & Partial<CreateTransactionInput>) =>
apiRequest<{ transaction: TransactionWithCategory }>(`/api/transactions/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["transactions"] });
},
});
}
export function useActivateFixed() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (params: {
month: string;
scope: "household" | "private" | "child";
childId?: string;
}) =>
apiRequest<{ created: number }>("/api/transactions/activate-fixed", {
method: "POST",
body: JSON.stringify(params),
}),
onSuccess: (data) => {
if (data.created > 0) {
void queryClient.invalidateQueries({ queryKey: ["transactions"] });
}
},
});
}
export function useCarryOver() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (params: {
fromMonth: string;
toMonth: string;
scope: "household" | "private" | "child";
childId?: string;
}) =>
apiRequest<{ transaction: TransactionWithCategory }>("/api/transactions/carry-over", {
method: "POST",
body: JSON.stringify(params),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["transactions"] });
},
});
}
export function useMonthBalance(
scope: "household" | "private" | "child",
month: string,
childId?: string,
) {
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
const [from, to] = monthDateRange(month);
const params = new URLSearchParams({ scope, from, to });
if (childId) params.set("childId", childId);
return useQuery({
queryKey: ["transactions", "balance", activeHouseholdId, scope, month, childId],
queryFn: () =>
apiRequest<{ transactions: TransactionWithCategory[] }>(
`/api/transactions?${params.toString()}`,
).then((data) => {
let income = 0;
let expense = 0;
for (const tx of data.transactions) {
if (tx.type === "income") income += parseFloat(tx.amount);
else expense += parseFloat(tx.amount);
}
return { income, expense, balance: income - expense };
}),
enabled: !!activeHouseholdId,
});
}

View File

@@ -0,0 +1,242 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "../lib/api-client";
import { useAuthStore } from "../stores/auth.store";
// ── Types ─────────────────────────────────────────────────────────────────────
export type Trip = {
id: string;
householdId: string;
name: string;
destination: string | null;
budget: number;
startDate: string; // YYYY-MM-DD
endDate: string; // YYYY-MM-DD
status: "active" | "completed";
spent: number; // computed by server
createdAt: string;
settlementFromUserId: string | null;
settlementToUserId: string | null;
settlementAmount: number | null;
settledAt: string | null;
};
export type TripSettlement = {
total: number;
fairShare: number;
balances: Array<{
userId: string;
name: string;
paid: number;
fairShare: number;
balance: number;
}>;
settlement: {
from: string;
fromName: string;
to: string;
toName: string;
amount: number;
} | null;
};
export type TripExpense = {
id: string;
tripId: string;
householdId: string;
label: string;
amount: number;
category: "unterkunft" | "essen" | "transport" | "aktivitaeten" | "sonstiges";
paidBy: string; // userId
date: string;
note: string | null;
createdAt: string;
};
export type TripSummary = {
trip: Trip;
totalSpent: number;
remaining: number;
byCategory: Record<string, number>;
};
export type CreateTripInput = {
name: string;
destination?: string;
budget: number;
startDate: string;
endDate: string;
};
export type UpdateTripInput = {
name?: string;
destination?: string | null;
budget?: number;
startDate?: string;
endDate?: string;
};
export type CreateTripExpenseInput = {
label: string;
amount: number;
category: TripExpense["category"];
paidBy: string;
date: string;
note?: string;
};
export type UpdateTripExpenseInput = {
label?: string;
amount?: number;
category?: TripExpense["category"];
paidBy?: string;
date?: string;
note?: string | null;
};
// ── Trip Queries ──────────────────────────────────────────────────────────────
export function useTrips() {
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
return useQuery({
queryKey: ["trips", activeHouseholdId],
queryFn: () => apiRequest<{ trips: Trip[] }>("/api/trips"),
select: (data) => data.trips,
enabled: !!activeHouseholdId,
});
}
export function useTrip(id: string) {
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
return useQuery({
queryKey: ["trip-summary", id, activeHouseholdId],
queryFn: () => apiRequest<{ summary: TripSummary }>(`/api/trips/${id}/summary`),
select: (data) => data.summary,
enabled: !!activeHouseholdId && !!id,
});
}
export function useTripExpenses(tripId: string) {
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
return useQuery({
queryKey: ["trip-expenses", tripId, activeHouseholdId],
queryFn: () => apiRequest<{ expenses: TripExpense[] }>(`/api/trips/${tripId}/expenses`),
select: (data) => data.expenses,
enabled: !!activeHouseholdId && !!tripId,
});
}
export function useTripSettlement(tripId: string) {
const activeHouseholdId = useAuthStore((s) => s.activeHouseholdId);
return useQuery({
queryKey: ["trip-settlement", tripId, activeHouseholdId],
queryFn: () =>
apiRequest<{ settlement: TripSettlement }>(`/api/trips/${tripId}/settlement`),
select: (data) => data.settlement,
enabled: !!tripId && !!activeHouseholdId,
});
}
// ── Trip Mutations ────────────────────────────────────────────────────────────
export function useCreateTrip() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CreateTripInput) =>
apiRequest<{ trip: Trip }>("/api/trips", {
method: "POST",
body: JSON.stringify(input),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["trips"] });
},
});
}
export function useUpdateTrip(id: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: UpdateTripInput) =>
apiRequest<{ trip: Trip }>(`/api/trips/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["trips"] });
void queryClient.invalidateQueries({ queryKey: ["trip-summary", id] });
},
});
}
export function useDeleteTrip() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
apiRequest<{ success: boolean }>(`/api/trips/${id}`, { method: "DELETE" }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["trips"] });
},
});
}
export function useCompleteTrip() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
apiRequest<{ trip: Trip }>(`/api/trips/${id}/complete`, { method: "POST" }),
onSuccess: (_data, id) => {
void queryClient.invalidateQueries({ queryKey: ["trips"] });
void queryClient.invalidateQueries({ queryKey: ["trip-summary", id] });
void queryClient.invalidateQueries({ queryKey: ["trip-settlement", id] });
},
});
}
// ── Trip Expense Mutations ────────────────────────────────────────────────────
export function useCreateTripExpense(tripId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CreateTripExpenseInput) =>
apiRequest<{ expense: TripExpense }>(`/api/trips/${tripId}/expenses`, {
method: "POST",
body: JSON.stringify(input),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["trip-expenses", tripId] });
void queryClient.invalidateQueries({ queryKey: ["trip-summary", tripId] });
void queryClient.invalidateQueries({ queryKey: ["trips"] });
},
});
}
export function useDeleteTripExpense(tripId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (expenseId: string) =>
apiRequest<{ success: boolean }>(`/api/trips/${tripId}/expenses/${expenseId}`, {
method: "DELETE",
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["trip-expenses", tripId] });
void queryClient.invalidateQueries({ queryKey: ["trip-summary", tripId] });
void queryClient.invalidateQueries({ queryKey: ["trips"] });
},
});
}
export function useUpdateTripExpense(tripId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ expenseId, input }: { expenseId: string; input: UpdateTripExpenseInput }) =>
apiRequest<{ expense: TripExpense }>(`/api/trips/${tripId}/expenses/${expenseId}`, {
method: "PATCH",
body: JSON.stringify(input),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["trip-expenses", tripId] });
void queryClient.invalidateQueries({ queryKey: ["trip-summary", tripId] });
void queryClient.invalidateQueries({ queryKey: ["trips"] });
},
});
}

View File

@@ -0,0 +1,18 @@
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import * as Localization from 'expo-localization'
import de from './locales/de.json'
import en from './locales/en.json'
const deviceLanguage = Localization.getLocales()[0]?.languageCode ?? 'de'
i18n
.use(initReactI18next)
.init({
resources: { de: { translation: de }, en: { translation: en } },
lng: deviceLanguage,
fallbackLng: 'de',
interpolation: { escapeValue: false },
})
export default i18n

View File

@@ -0,0 +1,443 @@
{
"common": {
"save": "Speichern",
"cancel": "Abbrechen",
"delete": "Löschen",
"edit": "Bearbeiten",
"add": "Hinzufügen",
"close": "Schließen",
"back": "Zurück",
"loading": "Lädt...",
"error": "Fehler",
"confirm": "Bestätigen",
"currency": "€",
"next": "Weiter",
"create": "Erstellen",
"book": "Buchen",
"yes": "Ja",
"no": "Nein",
"or": "oder",
"new": "Neu",
"today": "Heute",
"preview": "Vorschau",
"notice": "Hinweis",
"monthly": "monatlich",
"select": "Wählen…"
},
"tabs": {
"household": "Haushalt",
"me": "Ich",
"children": "Kinder",
"shopping": "Einkauf",
"more": "Mehr"
},
"mehr": {
"vacation": "Urlaub",
"vacationSubtitle": "Reisebudget & Ausgaben",
"settingsSubtitle": "Fixkosten, Kategorien, Haushalt"
},
"household": {
"title": "Haushalt",
"income": "Einnahmen",
"expenses": "Ausgaben",
"balance": "Bilanz",
"all": "Alle",
"noTransactions": "Noch keine Buchungen",
"noTransactionsHint": "Tippe auf + um eine gemeinsame Ausgabe einzutragen",
"nettoMonth": "Netto Monat",
"settlement": {
"youOwe": "Du schuldest {{name}}",
"theyOwe": "{{name}} schuldet dir",
"allSettled": "✓ Alles beglichen",
"book": "Buchen",
"alreadyTransferred": "Bereits überwiesen",
"monthlySettlement": "Monatsabrechnung",
"householdExpenses": "Haushalt Ausgaben",
"householdIncome": "Haushalt Einnahmen",
"yourShare": "Dein Anteil ({{percent}}%)",
"paidBy": "{{name}} gezahlt",
"fixedTransfers": "Feste Überweisungen",
"toTransfer": "Zu überweisen",
"closeMonth": "Monat abschließen",
"closed": "Abgeschlossen",
"recordTransfer": "Überweisung buchen",
"transferAmount": "Überwiesener Betrag",
"notePlaceholder": "Notiz (optional)"
}
},
"me": {
"title": "Ich",
"noTransactions": "Noch keine privaten Buchungen",
"noTransactionsHint": "Nur du siehst diese Einträge — niemand sonst im Haushalt"
},
"children": {
"title": "Kinder",
"addChild": "Kind hinzufügen",
"noChildren": "Noch keine Kinder angelegt",
"noChildrenHint": "Lege ein Kind an, um Ausgaben separat zu verfolgen.",
"noTransactions": "Noch keine Buchungen für {{name}}",
"noTransactionsHint": "Tippe auf + um die erste Buchung einzutragen"
},
"shopping": {
"title": "Einkaufsliste",
"empty": "Noch nichts auf der Liste",
"emptyHint": "Füge dein erstes Produkt unten hinzu",
"placeholder": "Produkt hinzufügen…",
"deleteChecked": "Erledigt löschen",
"offline": "offline",
"checkedBy": "von {{name}}",
"deleteCheckedConfirm": "Alle erledigten Items löschen?",
"reconnecting": "Verbindung wird hergestellt…",
"quantityPlaceholder": "Menge (optional)"
},
"debts": {
"title": "Schulden & Kredite",
"new": "+ Neu",
"open": "{{amount}} offen",
"payRate": "Rate buchen",
"noDebts": "Keine offenen Schulden",
"rateAutoBooked": "Diese Rate wird automatisch als private Ausgabe gebucht.",
"addTitle": "Schuld erfassen",
"totalAmount": "Gesamtbetrag",
"labelRequired": "Bezeichnung *",
"labelPlaceholder": "z.B. Autokredit",
"iOweMoneyTo": "Ich schulde das Geld…",
"selectMember": "Haushaltsmitglied wählen",
"orEnterName": "…oder Name eingeben (z.B. Sparkasse)",
"noteOptional": "Notiz (optional)",
"notePlaceholder": "z.B. Laufzeit bis 2026",
"remaining": "Noch offen: {{amount}}",
"overpayingWarning": "Betrag übersteigt den offenen Restbetrag",
"paid": "Bezahlt",
"total": "Gesamt",
"openAmount": "Noch offen",
"remainingLabel": "{{amount}} offen",
"toggleClosed_show": "{{count}} abgeschlossene{{plural}} anzeigen",
"toggleClosed_hide": "{{count}} abgeschlossene{{plural}} ausblenden",
"claims": "Offene Forderungen",
"received": "Erhalten",
"pendingLabel": "Ausstehend",
"fullyRepaid": "Vollständig zurückgezahlt ✓",
"noDebtsEntered": "Keine Schulden eingetragen.",
"fromDebtor": "von {{name}} · {{amount}} offen",
"unknown": "Unbekannt"
},
"fixedCosts": {
"title": "Fixkosten",
"household": "Haushalt",
"me": "Ich",
"children": "Kinder",
"expense": "Ausgabe · monatlich",
"income": "Einnahme · monatlich",
"noItems": "Keine Fixkosten eingetragen",
"editTitle": "Fixkosten bearbeiten",
"addTitle": "Neue Fixkosten",
"labelRequired": "Bezeichnung *",
"labelPlaceholder": "z.B. Miete",
"categoryOptional": "Kategorie (optional)",
"pauseTitle": "Fixkosten pausieren",
"pauseMessage": "\"{{label}}\" wird pausiert und nicht mehr monatlich gebucht.",
"pause": "Pausieren",
"expenseType": "Ausgabe",
"incomeType": "Einnahme"
},
"settings": {
"title": "Einstellungen",
"householdPartner": "Haushalt & Partner",
"fixedCosts": "Fixkosten verwalten",
"transferItems": "Feste Überweisungsposten",
"categories": "Kategorien",
"language": "Sprache",
"languageAuto": "Automatisch (Gerätesprache)",
"languageDe": "Deutsch",
"languageEn": "English",
"logout": "Abmelden",
"members": "Mitglieder",
"pending": "Ausstehend",
"account": "Konto",
"households": "Haushalte",
"youSuffix": "(du)",
"invitePerson": "Person einladen",
"invite": "Einladen",
"emailLabel": "E-Mail-Adresse",
"emailPlaceholder": "person@beispiel.de",
"inviteHint": "Die Person erhält eine E-Mail mit einem Einladungslink.",
"inviteSent": "Einladung gesendet",
"inviteError": "Fehler beim Einladen",
"revokeTitle": "Einladung widerrufen",
"revokeMessage": "Einladung an {{email}} widerrufen?",
"revoke": "Widerrufen",
"revokeSuccess": "Einladung widerrufen",
"saveError": "Einstellungen konnten nicht gespeichert werden.",
"switchedTo": "Zu {{name}} gewechselt",
"appSection": "App",
"household": {
"title": "Haushalt & Partner",
"yourName": "Dein Name",
"partnerName": "Partner / Partnerin",
"sharePercent": "Dein Anteil",
"sharePreview": "Du zahlst {{own}}% · {{partner}} zahlt {{rest}}%",
"monthlyBudget": "Monatsbudget",
"splitChildren": "Kinderkosten teilen",
"currency": "Währung",
"namesSection": "Namen",
"payerSection": "Wer zahlt die Ausgaben vor?",
"payerHint": "Diese Person schießt alle Haushaltsausgaben vor. Der Partner überweist am Monatsende seinen Anteil.",
"costSplitSection": "Kostenaufteilung",
"costSplitHint": "Dein Anteil an gemeinsamen Haushaltskosten",
"settingsSection": "Einstellungen",
"currencyOnlyEur": "Derzeit wird nur EUR unterstützt."
}
},
"monthClose": {
"title": "Monatsabschluss {{month}}",
"overview": "Übersicht",
"householdTotal": "Haushalt gesamt",
"householdIncome": "Haushalt Einnahmen",
"yourShare": "Dein Anteil ({{percent}}%)",
"totalTransfer": "Gesamte Überweisung",
"alreadyTransferred": "Bereits überwiesen",
"receives": "{{name}} bekommt noch",
"youReceive": "Du bekommst noch",
"settled": "Alles ausgeglichen",
"adjustAmount": "Betrag anpassen (optional)",
"adjustHint": "Falls ihr euch auf einen gerundeten Betrag geeinigt habt.",
"note": "Notiz (optional)",
"notePlaceholder": "z.B. Abschluss März — per Dauerauftrag",
"closeButton": "Monat abschließen & sperren",
"closeConfirmTitle": "{{month}} abschließen?",
"closeConfirmMessage": "Dieser Monat wird gesperrt. Keine weiteren Buchungen oder Änderungen möglich.",
"closeConfirmAction": "Jetzt abschließen",
"closedBanner": "🔒 Abgeschlossen am {{date}}"
},
"onboarding": {
"welcome": "Willkommen bei HausApp",
"subtitle": "Deine persönliche Haushalts-App für gemeinsame Finanzen",
"start": "Los geht's",
"step": "Schritt {{current}} von {{total}}",
"yourName": "Wie heißt du?",
"yourNamePlaceholder": "Dein Name",
"partnerName": "Wie heißt dein Partner / deine Partnerin?",
"partnerNamePlaceholder": "Name des Partners",
"costSplit": "Wie viel zahlst du von den gemeinsamen Kosten?",
"preview": "Du zahlst {{own}}% · {{partner}} zahlt {{rest}}%",
"done": "✓ Alles eingerichtet!",
"doneHint": "Du kannst diese Einstellungen jederzeit unter Einstellungen → Haushalt ändern.",
"startApp": "App starten",
"skip": "Überspringen",
"createHousehold": "Haushalt erstellen",
"joinHousehold": "Einladungslink eingeben",
"setupTitle": "Haushalt einrichten",
"setupSubtitle": "Erstelle deinen Haushalt oder tritt einem bestehenden bei",
"householdNameLabel": "Haushaltsname",
"householdNamePlaceholder": "z.B. Familie Müller",
"enterHouseholdName": "Bitte einen Haushaltsnamen eingeben",
"createError": "Haushalt konnte nicht erstellt werden",
"enterInviteCode": "Bitte einen Einladungscode eingeben",
"invitesComingSoon": "Einladungen werden in Kürze unterstützt",
"inviteCodeLabel": "Einladungscode",
"inviteCodePlaceholder": "Einladungscode eingeben",
"joinHouseholdAction": "Einladung annehmen"
},
"setup": {
"namesTitle": "Wie heißt ihr?",
"namesHint": "Diese Namen erscheinen in der Abrechnung und bei Schulden.",
"costSplitTitle": "Kostenaufteilung",
"costSplitHint": "Wie viel der gemeinsamen Haushaltskosten zahlst du?",
"monthlyBudgetLabel": "Gemeinsames Monatsbudget (variabel)",
"splitChildCostsLabel": "Kinderkosten gleich aufteilen?"
},
"vacation": {
"title": "Urlaub",
"comingSoon": "Bald verfügbar",
"comingSoonHint": "Urlaubsbudgets und Reiseausgaben — kommt in einer späteren Version."
},
"trips": {
"title": "Urlaub",
"new": "Neuer Urlaub",
"active": "Aktiv",
"past": "Vergangen",
"budget": "Budget",
"spent": "Ausgegeben",
"remaining": "Verbleibend",
"noTrips": "Noch kein Urlaub geplant",
"noTripsHint": "Tippe auf + um einen Urlaub anzulegen",
"overBudget": "Budget überschritten um {{amount}}",
"paidBy": "Gezahlt von {{name}}",
"complete": "Abschließen",
"completed": "Abgeschlossen",
"destination": "Reiseziel",
"startDate": "Von",
"endDate": "Bis",
"name": "Name",
"newExpense": "Neue Ausgabe",
"label": "Bezeichnung",
"note": "Notiz (optional)",
"categories": {
"unterkunft": "Unterkunft",
"essen": "Essen",
"transport": "Transport",
"aktivitaeten": "Aktivitäten",
"sonstiges": "Sonstiges"
},
"settlement": {
"title": "Abrechnung",
"total": "Gesamtausgaben",
"fairShare": "Fairer Anteil (50%)",
"paid": "gezahlt",
"owes": "{{from}} schuldet {{to}}",
"balanced": "Ausgeglichen — niemand schuldet was",
"closeTrip": "Urlaub abschließen",
"closedBanner": "Abgeschlossen",
"settledInfo": "{{from}} hat {{to}} {{amount}} überwiesen",
"noExpenses": "Füge zuerst Ausgaben hinzu"
}
},
"login": {
"welcome": "Willkommen zurück",
"subtitle": "Melde dich in deinem Konto an",
"fillAllFields": "Bitte alle Felder ausfüllen",
"signInError": "Anmeldung fehlgeschlagen",
"emailLabel": "E-Mail",
"emailPlaceholder": "deine@email.de",
"passwordLabel": "Passwort",
"passwordPlaceholder": "••••••••",
"signIn": "Anmelden",
"signInWithApple": "Mit Apple anmelden",
"noAccount": "Noch kein Konto?",
"register": "Registrieren",
"forgotPassword": "Passwort vergessen?",
"appleSignInError": "Apple-Anmeldung fehlgeschlagen"
},
"forgotPassword": {
"title": "Passwort vergessen?",
"subtitle": "Wir schicken dir einen Link zum Zurücksetzen deines Passworts.",
"sendButton": "Link senden",
"sentTitle": "E-Mail verschickt!",
"sentHint": "Check deine E-Mails — wir haben dir einen Link geschickt."
},
"resetPassword": {
"title": "Neues Passwort",
"subtitle": "Wähle ein sicheres Passwort mit mindestens 8 Zeichen.",
"newPassword": "Neues Passwort",
"confirmPassword": "Passwort bestätigen",
"mismatch": "Passwörter stimmen nicht überein",
"saveButton": "Passwort speichern",
"successMessage": "✓ Passwort geändert — du wirst weitergeleitet."
},
"categories": {
"editTitle": "Kategorie bearbeiten",
"addTitle": "Neue Kategorie",
"nameLabel": "Name",
"namePlaceholder": "z.B. Fitnessstudio",
"colorLabel": "Farbe",
"iconLabel": "Icon",
"selectIcon": "Icon wählen",
"defaultWarning": "Standardkategorien können umbenannt, aber nicht gelöscht werden.",
"default": "Standard",
"deleteTitle": "Kategorie löschen",
"deleteMessage": "\"{{name}}\" wirklich löschen?",
"addExpenseCategory": "+ Ausgaben-Kategorie hinzufügen",
"addIncomeCategory": "+ Einnahmen-Kategorie hinzufügen",
"expenseSection": "Ausgaben",
"incomeSection": "Einnahmen",
"expenseType": "Ausgabe",
"incomeType": "Einnahme"
},
"transferItems": {
"title": "Feste Überweisungsposten",
"addTitle": "Neuer Posten",
"monthlyFixedAmount": "Monatlicher Fixbetrag",
"labelRequired": "Bezeichnung *",
"labelPlaceholder": "z.B. Bausparer Noah",
"hint": "Diese Posten werden monatlich zur Haushaltsabrechnung addiert (z.B. Bausparer, Handy).",
"removeTitle": "Posten entfernen",
"removeMessage": "\"{{label}}\" wird aus der monatlichen Abrechnung entfernt.",
"remove": "Entfernen",
"empty": "Noch keine festen Posten eingetragen.",
"totalMonthly": "Gesamt monatlich",
"new": "Neu"
},
"carryOver": {
"title": "Saldo übertragen",
"confirmMessage": "Saldo von {{balance}} als {{type}} in {{month}} übertragen?",
"transfer": "Übertragen",
"openBalance": "{{month}} — offener Saldo",
"transferring": "Wird übertragen…",
"transferButton": "Saldo in {{month}} übertragen",
"expense": "Ausgabe",
"income": "Einnahme"
},
"scanner": {
"title": "Bon scannen",
"scanReceipt": "Bon scannen",
"manualEntry": "Manuelle Eingabe",
"hint": "Kassenbon in den Rahmen halten",
"capture": "Foto aufnehmen",
"scanning": "Wird erkannt...",
"detected": "Erkannt ✓",
"retry": "Nochmal scannen",
"book": "Buchen",
"permissionDenied": "Kamera-Zugriff verweigert. Bitte in den Einstellungen aktivieren.",
"openSettings": "Einstellungen öffnen",
"notRecognized": "Betrag konnte nicht erkannt werden.",
"merchant": "Händler",
"amount": "Betrag",
"date": "Datum",
"category": "Kategorie",
"scope": "Bereich",
"household": "Haushalt",
"private": "Privat",
"error": "Fehler beim Scannen. Bitte erneut versuchen."
},
"invite": {
"title": "Person einladen",
"shareText": "Ich lade dich zu HausApp ein! Gib diesen Code in der App ein: {{code}} (gültig 24h)",
"validFor": "Gültig für 24 Stunden",
"copyCode": "Code kopieren",
"copied": "Kopiert!",
"share": "Teilen",
"newCode": "Neuen Code generieren",
"joinTitle": "Einladungscode",
"joinHint": "Gib den 6-stelligen Code ein den du erhalten hast:",
"joinButton": "Haushalt beitreten",
"invalidCode": "Ungültiger oder abgelaufener Code",
"alreadyMember": "Du bist bereits Mitglied dieses Haushalts",
"success": "Willkommen im Haushalt!",
"setupTitle": "Haushalt einrichten",
"createNew": "Neuen Haushalt erstellen",
"createNewSub": "Du richtest alles ein",
"enterCode": "Einladungscode eingeben",
"enterCodeSub": "Du wurdest eingeladen",
"generating": "Wird generiert..."
},
"transaction": {
"booking": "Buchung",
"bookingType": "Buchungstyp",
"expense": "Ausgabe",
"income": "Einnahme",
"category": "Kategorie",
"description": "Beschreibung",
"date": "Datum",
"deleteTitle": "Buchung löschen?",
"deleteMessage": "Diese Buchung wird unwiderruflich gelöscht.",
"deleteFixed": "Diese Fixkostenbuchung wird nur für diesen Monat gelöscht. Im nächsten Monat wird sie wieder automatisch erstellt.",
"deleteDebt": "Diese Rate wird auch aus Schulden & Kredite entfernt.",
"fixedWarning": "Das ist eine Fixkostenbuchung. Änderungen gelten nur für diesen Monat.",
"carryOver": "Übertrag",
"newBooking": "Neue Buchung",
"editTitle": "Buchung bearbeiten",
"selectCategory": "Kategorie wählen (optional)",
"descriptionOptional": "Beschreibung (optional)",
"repeatMonthly": "Jeden Monat wiederholen",
"addNewCategory": "Neue Kategorie anlegen"
},
"verifyEmail": {
"title": "E-Mail bestätigen",
"hint": "Wir haben dir eine Bestätigungs-E-Mail geschickt an:",
"resend": "E-Mail erneut senden",
"resentConfirm": "✓ E-Mail wurde erneut gesendet",
"resendError": "Fehler beim Senden der E-Mail",
"backToLogin": "Zurück zur Anmeldung"
}
}

View File

@@ -0,0 +1,443 @@
{
"common": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"add": "Add",
"close": "Close",
"back": "Back",
"loading": "Loading...",
"error": "Error",
"confirm": "Confirm",
"currency": "€",
"next": "Next",
"create": "Create",
"book": "Book",
"yes": "Yes",
"no": "No",
"or": "or",
"new": "New",
"today": "Today",
"preview": "Preview",
"notice": "Notice",
"monthly": "monthly",
"select": "Select…"
},
"tabs": {
"household": "Household",
"me": "Me",
"children": "Kids",
"shopping": "Shopping",
"more": "More"
},
"mehr": {
"vacation": "Vacation",
"vacationSubtitle": "Travel budget & expenses",
"settingsSubtitle": "Fixed costs, categories, household"
},
"household": {
"title": "Household",
"income": "Income",
"expenses": "Expenses",
"balance": "Balance",
"all": "All",
"noTransactions": "No transactions yet",
"noTransactionsHint": "Tap + to add a shared expense",
"nettoMonth": "Net Month",
"settlement": {
"youOwe": "You owe {{name}}",
"theyOwe": "{{name}} owes you",
"allSettled": "✓ All settled",
"book": "Book",
"alreadyTransferred": "Already transferred",
"monthlySettlement": "Monthly settlement",
"householdExpenses": "Household expenses",
"householdIncome": "Household income",
"yourShare": "Your share ({{percent}}%)",
"paidBy": "{{name}} paid",
"fixedTransfers": "Fixed transfers",
"toTransfer": "To transfer",
"closeMonth": "Close month",
"closed": "Closed",
"recordTransfer": "Record transfer",
"transferAmount": "Transfer amount",
"notePlaceholder": "Note (optional)"
}
},
"me": {
"title": "Me",
"noTransactions": "No private transactions yet",
"noTransactionsHint": "Only you can see these — nobody else in the household"
},
"children": {
"title": "Kids",
"addChild": "Add child",
"noChildren": "No children added yet",
"noChildrenHint": "Add a child to track expenses separately.",
"noTransactions": "No transactions for {{name}} yet",
"noTransactionsHint": "Tap + to add the first transaction"
},
"shopping": {
"title": "Shopping List",
"empty": "Nothing on the list yet",
"emptyHint": "Add your first item below",
"placeholder": "Add item…",
"deleteChecked": "Delete checked",
"offline": "offline",
"checkedBy": "by {{name}}",
"deleteCheckedConfirm": "Delete all checked items?",
"reconnecting": "Reconnecting…",
"quantityPlaceholder": "Quantity (optional)"
},
"debts": {
"title": "Debts & Loans",
"new": "+ New",
"open": "{{amount}} remaining",
"payRate": "Book payment",
"noDebts": "No open debts",
"rateAutoBooked": "This payment will automatically be booked as a private expense.",
"addTitle": "Record debt",
"totalAmount": "Total amount",
"labelRequired": "Label *",
"labelPlaceholder": "e.g. Car loan",
"iOweMoneyTo": "I owe the money to…",
"selectMember": "Select household member",
"orEnterName": "…or enter name (e.g. Bank)",
"noteOptional": "Note (optional)",
"notePlaceholder": "e.g. Term until 2026",
"remaining": "Remaining: {{amount}}",
"overpayingWarning": "Amount exceeds the open remaining balance",
"paid": "Paid",
"total": "Total",
"openAmount": "Remaining",
"remainingLabel": "{{amount}} remaining",
"toggleClosed_show": "Show {{count}} closed",
"toggleClosed_hide": "Hide {{count}} closed",
"claims": "Open claims",
"received": "Received",
"pendingLabel": "Pending",
"fullyRepaid": "Fully repaid ✓",
"noDebtsEntered": "No debts recorded.",
"fromDebtor": "from {{name}} · {{amount}} remaining",
"unknown": "Unknown"
},
"fixedCosts": {
"title": "Fixed Costs",
"household": "Household",
"me": "Me",
"children": "Kids",
"expense": "Expense · monthly",
"income": "Income · monthly",
"noItems": "No fixed costs added",
"editTitle": "Edit fixed cost",
"addTitle": "New fixed cost",
"labelRequired": "Label *",
"labelPlaceholder": "e.g. Rent",
"categoryOptional": "Category (optional)",
"pauseTitle": "Pause fixed cost",
"pauseMessage": "\"{{label}}\" will be paused and no longer booked monthly.",
"pause": "Pause",
"expenseType": "Expense",
"incomeType": "Income"
},
"settings": {
"title": "Settings",
"householdPartner": "Household & Partner",
"fixedCosts": "Manage fixed costs",
"transferItems": "Fixed transfer items",
"categories": "Categories",
"language": "Language",
"languageAuto": "Automatic (device language)",
"languageDe": "Deutsch",
"languageEn": "English",
"logout": "Sign out",
"members": "Members",
"pending": "Pending",
"account": "Account",
"households": "Households",
"youSuffix": "(you)",
"invitePerson": "Invite person",
"invite": "Invite",
"emailLabel": "Email address",
"emailPlaceholder": "person@example.com",
"inviteHint": "The person will receive an email with an invitation link.",
"inviteSent": "Invitation sent",
"inviteError": "Error sending invitation",
"revokeTitle": "Revoke invitation",
"revokeMessage": "Revoke invitation for {{email}}?",
"revoke": "Revoke",
"revokeSuccess": "Invitation revoked",
"saveError": "Could not save settings.",
"switchedTo": "Switched to {{name}}",
"appSection": "App",
"household": {
"title": "Household & Partner",
"yourName": "Your name",
"partnerName": "Partner",
"sharePercent": "Your share",
"sharePreview": "You pay {{own}}% · {{partner}} pays {{rest}}%",
"monthlyBudget": "Monthly budget",
"splitChildren": "Split child costs",
"currency": "Currency",
"namesSection": "Names",
"payerSection": "Who pays expenses upfront?",
"payerHint": "This person pays all household expenses. The partner transfers their share at month end.",
"costSplitSection": "Cost split",
"costSplitHint": "Your share of shared household costs",
"settingsSection": "Settings",
"currencyOnlyEur": "Currently only EUR is supported."
}
},
"monthClose": {
"title": "Month close {{month}}",
"overview": "Overview",
"householdTotal": "Household total",
"householdIncome": "Household income",
"yourShare": "Your share ({{percent}}%)",
"totalTransfer": "Total transfer",
"alreadyTransferred": "Already transferred",
"receives": "{{name}} receives",
"youReceive": "You receive",
"settled": "All settled",
"adjustAmount": "Adjust amount (optional)",
"adjustHint": "In case you agreed on a rounded amount.",
"note": "Note (optional)",
"notePlaceholder": "e.g. March close — standing order",
"closeButton": "Close & lock month",
"closeConfirmTitle": "Close {{month}}?",
"closeConfirmMessage": "This month will be locked. No further bookings or changes possible.",
"closeConfirmAction": "Close now",
"closedBanner": "🔒 Closed on {{date}}"
},
"onboarding": {
"welcome": "Welcome to HausApp",
"subtitle": "Your personal household app for shared finances",
"start": "Get started",
"step": "Step {{current}} of {{total}}",
"yourName": "What's your name?",
"yourNamePlaceholder": "Your name",
"partnerName": "What's your partner's name?",
"partnerNamePlaceholder": "Partner's name",
"costSplit": "How much of the shared costs do you pay?",
"preview": "You pay {{own}}% · {{partner}} pays {{rest}}%",
"done": "✓ All set up!",
"doneHint": "You can change these settings anytime under Settings → Household.",
"startApp": "Start app",
"skip": "Skip",
"createHousehold": "Create household",
"joinHousehold": "Enter invitation link",
"setupTitle": "Set up household",
"setupSubtitle": "Create your household or join an existing one",
"householdNameLabel": "Household name",
"householdNamePlaceholder": "e.g. Smith Family",
"enterHouseholdName": "Please enter a household name",
"createError": "Could not create household",
"enterInviteCode": "Please enter an invitation code",
"invitesComingSoon": "Invitations will be supported soon",
"inviteCodeLabel": "Invitation code",
"inviteCodePlaceholder": "Enter invitation code",
"joinHouseholdAction": "Accept invitation"
},
"setup": {
"namesTitle": "What are your names?",
"namesHint": "These names appear in statements and debts.",
"costSplitTitle": "Cost split",
"costSplitHint": "How much of the shared household costs do you pay?",
"monthlyBudgetLabel": "Shared monthly budget (variable)",
"splitChildCostsLabel": "Split child costs equally?"
},
"vacation": {
"title": "Vacation",
"comingSoon": "Coming soon",
"comingSoonHint": "Vacation budgets and travel expenses — coming in a later version."
},
"trips": {
"title": "Vacation",
"new": "New trip",
"active": "Active",
"past": "Past",
"budget": "Budget",
"spent": "Spent",
"remaining": "Remaining",
"noTrips": "No trips planned yet",
"noTripsHint": "Tap + to add a trip",
"overBudget": "Over budget by {{amount}}",
"paidBy": "Paid by {{name}}",
"complete": "Complete",
"completed": "Completed",
"destination": "Destination",
"startDate": "From",
"endDate": "To",
"name": "Name",
"newExpense": "New expense",
"label": "Label",
"note": "Note (optional)",
"categories": {
"unterkunft": "Accommodation",
"essen": "Food",
"transport": "Transport",
"aktivitaeten": "Activities",
"sonstiges": "Other"
},
"settlement": {
"title": "Settlement",
"total": "Total expenses",
"fairShare": "Fair share (50%)",
"paid": "paid",
"owes": "{{from}} owes {{to}}",
"balanced": "Balanced — nobody owes anything",
"closeTrip": "Complete trip",
"closedBanner": "Completed",
"settledInfo": "{{from}} transferred {{amount}} to {{to}}",
"noExpenses": "Add expenses first"
}
},
"login": {
"welcome": "Welcome back",
"subtitle": "Sign in to your account",
"fillAllFields": "Please fill in all fields",
"signInError": "Sign in failed",
"emailLabel": "Email",
"emailPlaceholder": "your@email.com",
"passwordLabel": "Password",
"passwordPlaceholder": "••••••••",
"signIn": "Sign in",
"signInWithApple": "Sign in with Apple",
"noAccount": "Don't have an account?",
"register": "Register",
"forgotPassword": "Forgot password?",
"appleSignInError": "Apple sign-in failed"
},
"forgotPassword": {
"title": "Forgot password?",
"subtitle": "We'll send you a link to reset your password.",
"sendButton": "Send link",
"sentTitle": "Email sent!",
"sentHint": "Check your emails — we sent you a reset link."
},
"resetPassword": {
"title": "New password",
"subtitle": "Choose a secure password with at least 8 characters.",
"newPassword": "New password",
"confirmPassword": "Confirm password",
"mismatch": "Passwords don't match",
"saveButton": "Save password",
"successMessage": "✓ Password changed — redirecting you now."
},
"categories": {
"editTitle": "Edit category",
"addTitle": "New category",
"nameLabel": "Name",
"namePlaceholder": "e.g. Gym",
"colorLabel": "Color",
"iconLabel": "Icon",
"selectIcon": "Select icon",
"defaultWarning": "Default categories can be renamed but not deleted.",
"default": "Default",
"deleteTitle": "Delete category",
"deleteMessage": "Really delete \"{{name}}\"?",
"addExpenseCategory": "+ Add expense category",
"addIncomeCategory": "+ Add income category",
"expenseSection": "Expenses",
"incomeSection": "Income",
"expenseType": "Expense",
"incomeType": "Income"
},
"transferItems": {
"title": "Fixed transfer items",
"addTitle": "New item",
"monthlyFixedAmount": "Monthly fixed amount",
"labelRequired": "Label *",
"labelPlaceholder": "e.g. Savings plan Noah",
"hint": "These items are added monthly to the household statement (e.g. savings plans, phone).",
"removeTitle": "Remove item",
"removeMessage": "\"{{label}}\" will be removed from the monthly statement.",
"remove": "Remove",
"empty": "No fixed items added yet.",
"totalMonthly": "Total monthly",
"new": "New"
},
"carryOver": {
"title": "Transfer balance",
"confirmMessage": "Transfer balance of {{balance}} as {{type}} to {{month}}?",
"transfer": "Transfer",
"openBalance": "{{month}} — open balance",
"transferring": "Transferring…",
"transferButton": "Transfer balance to {{month}}",
"expense": "expense",
"income": "income"
},
"scanner": {
"title": "Scan Receipt",
"scanReceipt": "Scan Receipt",
"manualEntry": "Manual Entry",
"hint": "Hold receipt in frame",
"capture": "Take Photo",
"scanning": "Recognizing...",
"detected": "Detected ✓",
"retry": "Scan Again",
"book": "Book",
"permissionDenied": "Camera access denied. Please enable in settings.",
"openSettings": "Open Settings",
"notRecognized": "Could not recognize amount.",
"merchant": "Merchant",
"amount": "Amount",
"date": "Date",
"category": "Category",
"scope": "Scope",
"household": "Household",
"private": "Private",
"error": "Scan failed. Please try again."
},
"invite": {
"title": "Invite Person",
"shareText": "I'm inviting you to HausApp! Enter this code in the app: {{code}} (valid 24h)",
"validFor": "Valid for 24 hours",
"copyCode": "Copy code",
"copied": "Copied!",
"share": "Share",
"newCode": "Generate new code",
"joinTitle": "Invitation Code",
"joinHint": "Enter the 6-digit code you received:",
"joinButton": "Join Household",
"invalidCode": "Invalid or expired code",
"alreadyMember": "You are already a member of this household",
"success": "Welcome to the household!",
"setupTitle": "Set up household",
"createNew": "Create new household",
"createNewSub": "You set everything up",
"enterCode": "Enter invitation code",
"enterCodeSub": "You were invited",
"generating": "Generating..."
},
"transaction": {
"booking": "Booking",
"bookingType": "Type",
"expense": "Expense",
"income": "Income",
"category": "Category",
"description": "Description",
"date": "Date",
"deleteTitle": "Delete booking?",
"deleteMessage": "This booking will be permanently deleted.",
"deleteFixed": "This fixed cost booking will only be deleted for this month. It will be recreated automatically next month.",
"deleteDebt": "This payment will also be removed from Debts & Loans.",
"fixedWarning": "This is a fixed cost booking. Changes only apply to this month.",
"carryOver": "Carry over",
"newBooking": "New booking",
"editTitle": "Edit booking",
"selectCategory": "Select category (optional)",
"descriptionOptional": "Description (optional)",
"repeatMonthly": "Repeat every month",
"addNewCategory": "Add new category"
},
"verifyEmail": {
"title": "Verify your email",
"hint": "We sent a verification email to:",
"resend": "Resend email",
"resentConfirm": "✓ Email sent again",
"resendError": "Failed to send email",
"backToLogin": "Back to sign in"
}
}

View File

@@ -0,0 +1,41 @@
import * as SecureStore from "expo-secure-store";
import { router } from "expo-router";
import { env } from "@haushaltsApp/env/native";
import { useAuthStore } from "../stores/auth.store";
const BASE_URL = env.EXPO_PUBLIC_SERVER_URL;
// expoClient plugin stores session token under "<storagePrefix>.session_token"
const TOKEN_KEY = "haushaltsapp.session_token";
export async function apiRequest<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
const householdId = useAuthStore.getState().activeHouseholdId;
const token = await SecureStore.getItemAsync(TOKEN_KEY);
const response = await fetch(`${BASE_URL}${path}`, {
...options,
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(householdId ? { "x-household-id": householdId } : {}),
...options.headers,
},
credentials: "include",
});
if (response.status === 401) {
await SecureStore.deleteItemAsync(TOKEN_KEY);
useAuthStore.getState().clearSession();
router.replace("/(auth)/login");
throw new Error("Unauthorized");
}
if (!response.ok) {
const error = await response.json().catch(() => ({ error: "Unknown error" }));
throw new Error((error as { error: string }).error ?? `HTTP ${response.status}`);
}
return response.json() as Promise<T>;
}

View File

@@ -0,0 +1,34 @@
import { createAuthClient } from "better-auth/react";
import { organizationClient } from "better-auth/client/plugins";
import { expoClient } from "@better-auth/expo/client";
import * as SecureStore from "expo-secure-store";
import { env } from "@haushaltsApp/env/native";
// expoClient plugin stores session token under "<storagePrefix>.session_token"
const TOKEN_KEY = "haushaltsapp.session_token";
export const authClient = createAuthClient({
baseURL: env.EXPO_PUBLIC_SERVER_URL,
fetchOptions: {
onSuccess: (ctx) => {
const token = ctx.response.headers.get("set-auth-token");
if (token) {
SecureStore.setItemAsync(TOKEN_KEY, token);
}
},
auth: {
type: "Bearer",
token: () => SecureStore.getItem(TOKEN_KEY) ?? "",
},
},
plugins: [
expoClient({
scheme: "haushaltsApp",
storagePrefix: "haushaltsapp",
storage: SecureStore,
}),
organizationClient(),
],
});
export const { signIn, signUp, signOut, useSession } = authClient;

View File

@@ -0,0 +1,10 @@
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60, // 1 minute
retry: 1,
},
},
});

View File

@@ -0,0 +1,47 @@
import { env } from "@haushaltsApp/env/native";
const WS_BASE_URL = env.EXPO_PUBLIC_SERVER_URL.replace(/^http/, "ws");
export type WSEventHandler<T> = (data: T) => void;
export class WebSocketClient<T = unknown> {
private ws: WebSocket | null = null;
private handlers: Map<string, WSEventHandler<T>[]> = new Map();
connect(path: string): void {
this.ws = new WebSocket(`${WS_BASE_URL}${path}`);
this.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data as string) as { type: string } & T;
const typeHandlers = this.handlers.get(data.type) ?? [];
for (const handler of typeHandlers) {
handler(data);
}
} catch {
console.error("Failed to parse WebSocket message");
}
};
this.ws.onerror = (error) => {
console.error("WebSocket error:", error);
};
}
on(event: string, handler: WSEventHandler<T>): void {
const existing = this.handlers.get(event) ?? [];
this.handlers.set(event, [...existing, handler]);
}
send(data: unknown): void {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
}
}
disconnect(): void {
this.ws?.close();
this.ws = null;
this.handlers.clear();
}
}

View File

@@ -0,0 +1,105 @@
import { create, type StoreApi, type UseBoundStore } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
export type User = {
id: string;
name: string;
email: string;
};
export type Household = {
id: string;
name: string;
role: string;
};
export type AuthState = {
user: User | null;
activeHouseholdId: string | null;
households: Household[];
isAuthenticated: boolean;
pendingInvitationId: string | null;
setUser: (user: User | null) => void;
setActiveHousehold: (id: string) => void;
setHouseholds: (households: Household[]) => void;
setPendingInvitationId: (id: string | null) => void;
clearAuth: () => void;
clearSession: () => void;
};
export const authStateCreator = (
set: (partial: Partial<AuthState>) => void,
): AuthState => ({
user: null,
activeHouseholdId: null,
households: [],
isAuthenticated: false,
pendingInvitationId: null,
setUser: (user) => set({ user, isAuthenticated: !!user }),
setActiveHousehold: (activeHouseholdId) => set({ activeHouseholdId }),
setHouseholds: (households) => set({ households }),
setPendingInvitationId: (pendingInvitationId) => set({ pendingInvitationId }),
clearAuth: () =>
set({
user: null,
activeHouseholdId: null,
households: [],
isAuthenticated: false,
pendingInvitationId: null,
}),
clearSession: () =>
set({
user: null,
activeHouseholdId: null,
households: [],
isAuthenticated: false,
}),
});
// Lazily import SecureStore so it's only resolved at runtime, not at module load.
// This keeps bun:test able to test authStateCreator without React Native internals.
async function getSecureStorage() {
const SecureStore = await import("expo-secure-store");
return {
getItem: (key: string) => SecureStore.getItemAsync(key),
setItem: (key: string, value: string) => SecureStore.setItemAsync(key, value),
removeItem: (key: string) => SecureStore.deleteItemAsync(key),
};
}
let _store: UseBoundStore<StoreApi<AuthState>> | null = null;
export async function getAuthStore() {
if (_store) return _store;
const storage = await getSecureStorage();
_store = create<AuthState>()(
persist(authStateCreator, {
name: "auth-store",
storage: createJSONStorage(() => storage),
}),
);
return _store;
}
// Synchronous store for use in non-async contexts (components).
// Storage hydration happens async — initial state is always the default.
export const useAuthStore = create<AuthState>()(
persist(authStateCreator, {
name: "auth-store",
storage: createJSONStorage(() => ({
// Lazy proxy — defers the actual SecureStore call to runtime
getItem: async (key: string) => {
const { getItemAsync } = await import("expo-secure-store");
return getItemAsync(key);
},
setItem: async (key: string, value: string) => {
const { setItemAsync } = await import("expo-secure-store");
return setItemAsync(key, value);
},
removeItem: async (key: string) => {
const { deleteItemAsync } = await import("expo-secure-store");
return deleteItemAsync(key);
},
})),
}),
);

View File

@@ -0,0 +1,20 @@
import { create } from "zustand";
type Household = {
id: string;
name: string;
};
type HouseholdState = {
currentHousehold: Household | null;
households: Household[];
setCurrentHousehold: (household: Household | null) => void;
setHouseholds: (households: Household[]) => void;
};
export const useHouseholdStore = create<HouseholdState>((set) => ({
currentHousehold: null,
households: [],
setCurrentHousehold: (currentHousehold) => set({ currentHousehold }),
setHouseholds: (households) => set({ households }),
}));

View File

@@ -0,0 +1,43 @@
/**
* Shared date/month utilities used across the native app.
*/
/** Returns the current month as "YYYY-MM". */
export function currentMonthStr(): string {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
}
/** Adds `delta` months to a "YYYY-MM" string and returns the resulting "YYYY-MM". */
export function addMonths(monthStr: string, delta: number): string {
const [y, m] = monthStr.split("-").map(Number);
const d = new Date(y!, m! - 1 + delta);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
}
/** Formats a "YYYY-MM" string as a localized German month+year label (e.g. "März 2026"). */
export function monthLabel(month: string): string {
const [year, m] = month.split("-");
return new Intl.DateTimeFormat("de-DE", { month: "long", year: "numeric" }).format(
new Date(Number(year), Number(m) - 1),
);
}
/**
* Returns the first-of-month and last-of-month ISO timestamps for a "YYYY-MM" string.
* Useful for building date-range query filters.
*/
export function monthDateRange(month: string): [from: string, to: string] {
const [y, m] = month.split("-").map(Number);
const lastDay = new Date(y!, m!, 0).getDate();
return [
`${month}-01T00:00:00.000Z`,
`${month}-${String(lastDay).padStart(2, "0")}T23:59:59.999Z`,
];
}
/** Returns today's date as "YYYY-MM-DD". */
export function todayIso(): string {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
}

View File

@@ -0,0 +1,31 @@
/**
* Shared formatting utilities used across the native app.
*/
/** EUR formatter instance shared by both formatEur variants. */
const eurFormatter = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" });
/**
* Formats a number as EUR currency using German locale.
* Uses Math.abs by default — callers add +/- signs where needed.
* Pass `absolute: false` to preserve the original sign.
*/
export function formatEur(amount: number, absolute = true): string {
return eurFormatter.format(absolute ? Math.abs(amount) : amount);
}
/**
* Formats an ISO date string for display.
* Returns "Heute" / "Today" for today, otherwise "D. MonthName".
*/
export function formatDateDisplay(isoDate: string, language: string, todayLabel: string): string {
const today = new Date();
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
if (isoDate.startsWith(todayStr)) return todayLabel;
const d = new Date(isoDate);
const locale = language === "en" ? "en-US" : "de-DE";
const monthName = new Intl.DateTimeFormat(locale, { month: "long" }).format(
new Date(2024, d.getMonth()),
);
return `${d.getDate()}. ${monthName}`;
}

View File

@@ -0,0 +1,35 @@
/**
* Shared numpad input handler for EUR amount entry.
* Used by every modal with a custom numpad (transactions, debts, fixed costs, etc.).
*/
/** Processes a numpad key press and returns the updated amount string. */
export function handleNumpadKey(current: string, key: string): string {
if (key === "\u232B") {
return current.length > 1 ? current.slice(0, -1) : "0";
}
if (key === ",") {
return current.includes(",") ? current : current + ",";
}
if (current === "0") {
return key;
}
const parts = current.split(",");
if (parts[1] !== undefined && parts[1].length >= 2) {
return current;
}
return current + key;
}
/** Parses a German-format amount string ("1.234,56") to a number. */
export function parseAmountStr(amountStr: string): number {
return parseFloat(amountStr.replace(",", ".")) || 0;
}
/** The numpad key layout used across all modals. */
export const NUMPAD_KEYS: string[][] = [
["1", "2", "3"],
["4", "5", "6"],
["7", "8", "9"],
[",", "0", "\u232B"],
];

View File

@@ -0,0 +1,134 @@
export type ScanResult = {
amount: number | null;
label: string | null;
date: string | null;
confidence: number; // 0-100
rawText: string;
};
const KNOWN_MERCHANTS = [
"BILLA",
"SPAR",
"HOFER",
"LIDL",
"PENNY",
"MERKUR",
"REWE",
"EDEKA",
"ALDI",
"DM",
"MÜLLER",
"ROSSMANN",
"MCDONALD",
"MCDONALDS",
"BURGER KING",
"STARBUCKS",
];
// Patterns tried in order to extract the total amount
const AMOUNT_PATTERNS: RegExp[] = [
/(?:summe|gesamt|total|zu zahlen|zu pay|betrag|sum)[\s:]*(\d+[,.]?\d*[,.]\d{2})/gi,
];
function normaliseAmount(raw: string): number {
// Replace comma decimal separator with dot, strip thousands separators
// e.g. "1.234,56" → 1234.56, "12,34" → 12.34, "12.34" → 12.34
const cleaned = raw.replace(/\./g, "").replace(",", ".");
return parseFloat(cleaned);
}
function extractAmount(text: string): number | null {
// Pattern 1: keyword-based
for (const pattern of AMOUNT_PATTERNS) {
pattern.lastIndex = 0;
const match = pattern.exec(text);
if (match) {
const val = normaliseAmount(match[1]);
if (!isNaN(val)) return val;
}
}
// Pattern 2: highest "digits€" match
const withSuffix = /(\d+[,.]\d{2})\s*€/g;
let highest: number | null = null;
let m: RegExpExecArray | null;
while ((m = withSuffix.exec(text)) !== null) {
const val = normaliseAmount(m[1]);
if (!isNaN(val) && (highest === null || val > highest)) {
highest = val;
}
}
if (highest !== null) return highest;
// Pattern 3: "€ digits" match
const withPrefix = /€\s*(\d+[,.]\d{2})/g;
while ((m = withPrefix.exec(text)) !== null) {
const val = normaliseAmount(m[1]);
if (!isNaN(val) && (highest === null || val > highest)) {
highest = val;
}
}
return highest;
}
function extractLabel(text: string): { label: string | null; isKnown: boolean } {
const upper = text.toUpperCase();
for (const merchant of KNOWN_MERCHANTS) {
if (upper.includes(merchant)) {
return { label: merchant.charAt(0) + merchant.slice(1).toLowerCase(), isKnown: true };
}
}
// Take first non-empty line that is not digits-only and not a date/time/address line
const lines = text.split("\n");
for (const raw of lines) {
const line = raw.trim();
if (!line) continue;
if (/^\d+$/.test(line)) continue; // digits-only
if (/\d{2}[.:/]\d{2}/.test(line)) continue; // date/time pattern
return { label: line, isKnown: false };
}
return { label: null, isKnown: false };
}
function extractDate(text: string): string | null {
// DD.MM.YYYY
const dmyFull = /\b(\d{2})\.(\d{2})\.(\d{4})\b/;
let m = dmyFull.exec(text);
if (m) {
return `${m[3]}-${m[2]}-${m[1]}`;
}
// DD.MM.YY
const dmyShort = /\b(\d{2})\.(\d{2})\.(\d{2})\b/;
m = dmyShort.exec(text);
if (m) {
const year = parseInt(m[3], 10) >= 50 ? `19${m[3]}` : `20${m[3]}`;
return `${year}-${m[2]}-${m[1]}`;
}
// YYYY-MM-DD
const iso = /\b(\d{4})-(\d{2})-(\d{2})\b/;
m = iso.exec(text);
if (m) {
return `${m[1]}-${m[2]}-${m[3]}`;
}
return null;
}
export function parseReceiptText(text: string): ScanResult {
const amount = extractAmount(text);
const { label, isKnown } = extractLabel(text);
const date = extractDate(text);
let confidence = 0;
if (amount !== null) confidence += 50;
if (label !== null) confidence += isKnown ? 30 : 10;
if (date !== null) confidence += 20;
confidence = Math.min(confidence, 100);
return { amount, label, date, confidence, rawText: text };
}