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,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>
);
}