Files
René Schober 9ddc7c6d7a 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>
2026-03-20 11:54:22 +01:00

648 lines
26 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { QuickAddModal } from "@/src/components/features/transactions/QuickAddModal";
import { TransactionItem } from "@/src/components/features/transactions/TransactionItem";
import { EditTransactionModal } from "@/src/components/features/transactions/EditTransactionModal";
import { CarryOverBanner } from "@/src/components/features/transactions/CarryOverBanner";
import { MonthSummaryHeader } from "@/src/components/features/transactions/MonthSummaryHeader";
import { AddCategoryModal } from "@/src/components/features/categories/AddCategoryModal";
import { ModalHeader } from "@/src/components/ui/ModalHeader";
import { EmptyState } from "@/src/components/ui/EmptyState";
import { Numpad } from "@/src/components/ui/Numpad";
import { useTransactions, useActivateFixed, useMonthBalance, useDeleteTransaction } from "@/src/hooks/useTransactions";
import type { TransactionWithCategory } from "@/src/hooks/useTransactions";
import { useAuthStore } from "@/src/stores/auth.store";
import { useSettlementV2, useCreateMonthlyTransfer, useNettoMonth, type MonthlyTransfer } from "@/src/hooks/useFixedCosts";
import { useHouseholdSettings } from "@/src/hooks/useHouseholdSettings";
import { useMonthStatus } from "@/src/hooks/useMonthStatus";
import { currentMonthStr, addMonths, monthLabel, monthDateRange } from "@/src/utils/date";
import { formatEur } from "@/src/utils/format";
import { handleNumpadKey, parseAmountStr } from "@/src/utils/numpad";
import { useRouter } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import type { Category } from "@/src/hooks/useCategories";
import {
ActivityIndicator,
Alert,
FlatList,
Modal,
Pressable,
RefreshControl,
Text,
TextInput,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { TAB_COLORS } from "@/src/constants/colors";
const ACCENT = TAB_COLORS.household;
// ── Record Transfer Modal ─────────────────────────────────────────────────────
function RecordTransferModal({
month,
toUserId,
onClose,
}: {
month: string;
toUserId: string;
onClose: () => void;
}) {
const [amountStr, setAmountStr] = useState("0");
const [note, setNote] = useState("");
const { t } = useTranslation();
const { mutate: createTransfer, isPending } = useCreateMonthlyTransfer();
function handleNumpad(key: string) {
setAmountStr((prev) => handleNumpadKey(prev, key));
}
function handleSave() {
const amount = parseAmountStr(amountStr);
if (!amount || amount <= 0) return;
createTransfer(
{ month, toUserId, amount, note: note.trim() || undefined },
{ onSuccess: onClose },
);
}
const canSave = parseAmountStr(amountStr) > 0;
return (
<Modal visible animationType="slide" presentationStyle="pageSheet" onRequestClose={onClose}>
<View className="flex-1 bg-white">
<ModalHeader
title={t('household.settlement.recordTransfer')}
onClose={onClose}
closeLabel={t('common.cancel')}
onSave={handleSave}
saveLabel={t('household.settlement.book')}
saveDisabled={!canSave}
saveLoading={isPending}
saveColor={ACCENT}
/>
<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('household.settlement.transferAmount')}</Text>
</View>
<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('household.settlement.notePlaceholder')}
value={note}
onChangeText={setNote}
/>
</View>
<Numpad onKeyPress={handleNumpad} />
</View>
</Modal>
);
}
// ── Settlement Banner V2 ──────────────────────────────────────────────────────
function SettlementBanner({ month, isCurrent }: { month: string; isCurrent: boolean }) {
const userId = useAuthStore((s) => s.user?.id);
const router = useRouter();
const { t } = useTranslation();
const { data: settlement, isLoading } = useSettlementV2(month);
const { data: hhSettings } = useHouseholdSettings();
const { data: monthStatus } = useMonthStatus(month);
const [expanded, setExpanded] = useState(false);
const [showTransferModal, setShowTransferModal] = useState(false);
const isClosed = monthStatus?.status === "closed";
if (isLoading) {
return (
<View className="mx-4 mt-4 mb-1 rounded-2xl bg-gray-100 p-4 items-center">
<ActivityIndicator size="small" color="#9ca3af" />
</View>
);
}
if (!settlement || settlement.memberCount <= 1) return null;
// Closed month — show lock banner
if (isClosed && monthStatus) {
const closedDate = monthStatus.closedAt
? new Date(monthStatus.closedAt).toLocaleDateString("de-DE", { day: "numeric", month: "long", year: "numeric" })
: "";
return (
<View
className="mx-4 mt-4 mb-1 rounded-2xl px-4 py-3 flex-row items-center gap-3"
style={{ backgroundColor: "#f0fdf4", borderWidth: 1, borderColor: "#bbf7d0" }}
>
<Ionicons name="lock-closed" size={18} color="#16a34a" />
<View className="flex-1">
<Text className="text-xs text-gray-400">{t('household.settlement.closed')}</Text>
<Text className="text-sm font-semibold text-green-700">
{closedDate}
{monthStatus.finalAmount != null && monthStatus.finalAmount > 0
? ` · ${formatEur(monthStatus.finalAmount)}`
: ""}
</Text>
</View>
</View>
);
}
const remaining = settlement.remaining;
const isOwing = remaining > 0.005;
const isReceiving = remaining < -0.005;
const isEven = !isOwing && !isReceiving;
const bannerBg = isOwing ? "#fff7ed" : isReceiving ? "#f0fdf4" : "#f9fafb";
const bannerBorder = isOwing ? "#fed7aa" : isReceiving ? "#bbf7d0" : "#e5e7eb";
const amountColor = isOwing ? "#ea580c" : isReceiving ? "#16a34a" : "#6b7280";
const others = settlement.members.filter((m) => m.userId !== userId);
const otherName = hhSettings?.partnerName ?? others[0]?.name ?? "den anderen";
const otherUserId = others[0]?.userId ?? "";
let mainText = t('household.settlement.allSettled');
if (isOwing) mainText = t('household.settlement.youOwe', { name: otherName });
else if (isReceiving) mainText = t('household.settlement.theyOwe', { name: otherName });
return (
<>
<View
style={{ backgroundColor: bannerBg, borderColor: bannerBorder, borderWidth: 1 }}
className="mx-4 mt-4 mb-1 rounded-2xl overflow-hidden"
>
{/* Summary row */}
<Pressable
onPress={() => setExpanded((v) => !v)}
className="flex-row items-center px-4 py-3 active:opacity-80"
>
<View className="flex-1">
<Text className="text-xs text-gray-400 mb-0.5">{t('household.settlement.monthlySettlement')}</Text>
<Text className="text-sm font-medium" style={{ color: amountColor }}>{mainText}</Text>
{!isEven && (
<Text className="text-3xl font-bold" style={{ color: amountColor }}>
{formatEur(Math.abs(remaining))}
</Text>
)}
{isEven && (
<Text className="text-base font-bold text-green-600">{t('household.settlement.allSettled')}</Text>
)}
</View>
<Ionicons name={expanded ? "chevron-up" : "chevron-down"} size={14} color="#9ca3af" />
</Pressable>
{/* Expandable detail */}
{expanded && (
<View style={{ backgroundColor: "rgba(0,0,0,0.03)" }} className="px-4 pb-4">
{/* Haushalt breakdown */}
<View className="py-2 gap-1.5">
<View className="flex-row justify-between">
<Text className="text-xs text-gray-500">{t('household.settlement.householdExpenses')}</Text>
<Text className="text-xs font-medium text-gray-700">{formatEur(settlement.householdExpenses)}</Text>
</View>
{settlement.householdIncome > 0 && (
<View className="flex-row justify-between">
<Text className="text-xs text-gray-500">{t('household.settlement.householdIncome')}</Text>
<Text className="text-xs font-medium text-green-600">{formatEur(settlement.householdIncome)}</Text>
</View>
)}
<View className="flex-row justify-between">
<Text className="text-xs font-semibold text-gray-600">{t('household.settlement.yourShare', { percent: settlement.userSharePercent ?? 50 })}</Text>
<Text className="text-xs font-semibold text-gray-800">{formatEur(settlement.perMemberShare)}</Text>
</View>
</View>
{/* Who paid what */}
<View className="h-px bg-gray-200 my-2" />
<View className="gap-1.5">
{settlement.members.map((mem) => {
const isMe = mem.userId === userId;
const name = isMe ? "Du" : otherName;
const paidAmount = isMe ? settlement.myOwnExpenses : (settlement.householdExpenses - settlement.myOwnExpenses);
if (paidAmount < 0.01) return null;
return (
<View key={mem.userId} className="flex-row justify-between">
<Text className="text-xs text-gray-500">{t('household.settlement.paidBy', { name })}</Text>
<Text className="text-xs font-medium text-gray-700">{formatEur(paidAmount)}</Text>
</View>
);
})}
</View>
{/* Fixed transfer items — summarised */}
{settlement.lineItemsTotal > 0 && (
<>
<View className="h-px bg-gray-200 my-2" />
<View className="flex-row justify-between">
<Text className="text-xs text-gray-500">+ {t('household.settlement.fixedTransfers')}</Text>
<Text className="text-xs font-medium text-gray-700">{formatEur(settlement.lineItemsTotal)}</Text>
</View>
</>
)}
{/* Total owed */}
<View className="h-px bg-gray-200 my-2" />
<View className="flex-row justify-between mb-3">
<Text className="text-xs font-bold text-gray-700">{t('household.settlement.toTransfer')}</Text>
<Text className="text-xs font-bold" style={{ color: amountColor }}>{formatEur(settlement.totalOwed)}</Text>
</View>
{/* Already transferred */}
<View className="flex-row items-center justify-between">
<View>
<Text className="text-xs text-gray-400">{t('household.settlement.alreadyTransferred')}</Text>
<Text className="text-sm font-semibold text-gray-700">{formatEur(settlement.alreadyTransferred)}</Text>
</View>
{isOwing && (
<Pressable
onPress={() => setShowTransferModal(true)}
style={{ backgroundColor: ACCENT }}
className="px-4 py-2 rounded-xl active:opacity-80"
>
<Text className="text-xs font-semibold text-white">+ {t('household.settlement.book')}</Text>
</Pressable>
)}
</View>
{/* Close month button */}
{isCurrent && (
<Pressable
onPress={() => router.push({ pathname: "/(app)/months/close", params: { month } })}
className="mt-3 flex-row items-center justify-center gap-2 py-2.5 rounded-xl active:opacity-80"
style={{ backgroundColor: "#f3f4f6", borderWidth: 1, borderColor: "#e5e7eb" }}
>
<Ionicons name="lock-closed-outline" size={14} color="#6b7280" />
<Text className="text-xs font-semibold text-gray-600">{t('household.settlement.closeMonth')}</Text>
</Pressable>
)}
{/* Transfer history */}
{settlement.transfers.length > 0 && (
<View className="mt-3 gap-1">
{settlement.transfers.map((t: MonthlyTransfer) => (
<View key={t.id} className="flex-row justify-between">
<Text className="text-xs text-gray-400">
{new Date(t.createdAt).toLocaleDateString("de-DE", { day: "numeric", month: "short" })}
{t.note ? ` · ${t.note}` : ""}
</Text>
<Text className="text-xs font-medium text-gray-600">{formatEur(t.amount)}</Text>
</View>
))}
</View>
)}
</View>
)}
</View>
{showTransferModal && (
<RecordTransferModal
month={month}
toUserId={otherUserId}
onClose={() => setShowTransferModal(false)}
/>
)}
</>
);
}
// ── Netto Card ────────────────────────────────────────────────────────────────
function NettoCard({ month }: { month: string }) {
const { data, isLoading } = useNettoMonth(month);
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
if (isLoading) {
return (
<View className="mx-4 mt-2 mb-1 rounded-2xl bg-white p-4 items-center" style={{ borderWidth: 1, borderColor: "#f3f4f6" }}>
<ActivityIndicator size="small" color="#9ca3af" />
</View>
);
}
if (!data) return null;
const isPositive = data.netto >= 0;
const nettoColor = isPositive ? "#16a34a" : "#dc2626";
const nettoIcon = isPositive ? "trending-up" : "trending-down";
return (
<View
className="mx-4 mt-2 mb-1 rounded-2xl bg-white overflow-hidden"
style={{ borderWidth: 1, borderColor: "#f3f4f6" }}
>
<Pressable
onPress={() => setExpanded((v) => !v)}
className="flex-row items-center px-4 py-3 active:opacity-80"
>
<View
className="w-9 h-9 rounded-xl items-center justify-center mr-3"
style={{ backgroundColor: isPositive ? "#dcfce7" : "#fee2e2" }}
>
<Ionicons name={nettoIcon as React.ComponentProps<typeof Ionicons>["name"]} size={18} color={nettoColor} />
</View>
<View className="flex-1">
<Text className="text-xs text-gray-400">{t('household.nettoMonth')}</Text>
<Text className="text-xl font-bold" style={{ color: nettoColor }}>
{isPositive ? "+" : ""}{formatEur(Math.abs(data.netto))}
</Text>
</View>
<View className="items-end mr-2">
<Text className="text-xs text-gray-400">{t('household.income')}</Text>
<Text className="text-sm font-semibold text-green-600">+{formatEur(data.totalIncome)}</Text>
</View>
<Ionicons name={expanded ? "chevron-up" : "chevron-down"} size={14} color="#9ca3af" />
</Pressable>
{expanded && (
<View className="px-4 pb-4" style={{ backgroundColor: "rgba(0,0,0,0.02)" }}>
{/* Income breakdown */}
{data.incomeByScope.length > 0 ? (
<>
<Text className="text-xs font-medium text-gray-400 mb-2">Einnahmen nach Bereich</Text>
{data.incomeByScope.map((s) => (
<View key={s.scope} className="flex-row justify-between mb-1">
<Text className="text-xs text-gray-500">{s.label}</Text>
<Text className="text-xs font-medium text-green-600">+{formatEur(s.amount)}</Text>
</View>
))}
<View className="h-px bg-gray-100 my-2" />
</>
) : (
<Text className="text-xs text-gray-400 mb-2">Keine Einnahmen gebucht</Text>
)}
{/* Expenses */}
<View className="flex-row justify-between mb-1">
<Text className="text-xs text-gray-500">Ausgaben (alle Bereiche)</Text>
<Text className="text-xs font-medium text-red-500">{formatEur(data.totalExpenses)}</Text>
</View>
<View className="h-px bg-gray-100 my-2" />
<View className="flex-row justify-between">
<Text className="text-xs font-bold text-gray-700">Netto</Text>
<Text className="text-xs font-bold" style={{ color: nettoColor }}>
{isPositive ? "+" : ""}{formatEur(Math.abs(data.netto))}
</Text>
</View>
</View>
)}
</View>
);
}
// ── Month Switcher ─────────────────────────────────────────────────────────────
function MonthSwitcher({
month,
isLocked,
onPrev,
onNext,
}: {
month: string;
isLocked: boolean;
onPrev: () => void;
onNext: () => void;
}) {
const isCurrent = month === currentMonthStr();
return (
<View className="flex-row items-center justify-center gap-4 py-3">
<Pressable onPress={onPrev} className="p-1 active:opacity-50">
<Ionicons name="chevron-back" size={18} color="#6b7280" />
</Pressable>
<View className="flex-row items-center gap-1.5 w-32 justify-center">
{isLocked && <Ionicons name="lock-closed" size={12} color="#9ca3af" />}
<Text className="text-sm font-semibold text-gray-800 text-center">
{monthLabel(month)}
</Text>
</View>
<Pressable
onPress={onNext}
disabled={isCurrent}
className="p-1 active:opacity-50"
style={{ opacity: isCurrent ? 0.3 : 1 }}
>
<Ionicons name="chevron-forward" size={18} color="#6b7280" />
</Pressable>
</View>
);
}
// ── Main Screen ───────────────────────────────────────────────────────────────
type FilterType = "all" | "income" | "expense";
export default function HaushaltScreen() {
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const router = useRouter();
const [month, setMonth] = useState(currentMonthStr());
const [filter, setFilter] = useState<FilterType>("all");
const [showAddModal, setShowAddModal] = useState(false);
const [showFabMenu, setShowFabMenu] = 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 { data: monthStatus } = useMonthStatus(month);
const isLocked = monthStatus?.status === "closed";
const isCurrent = month === currentMonthStr();
const { mutate: activateFixed } = useActivateFixed();
useEffect(() => {
if (isCurrent) {
activateFixed({ month, scope: "household" });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [month]);
const [fromDate, toDate] = monthDateRange(month);
const txFilter = {
scope: "household" as const,
from: fromDate,
to: toDate,
...(filter !== "all" ? { type: filter as "income" | "expense" } : {}),
};
const { data: transactions = [], isLoading, refetch, isRefetching } = useTransactions(txFilter);
const { data: balance, isLoading: balanceLoading } = useMonthBalance("household", month);
function renderEmpty() {
if (isLoading) return null;
return (
<EmptyState
icon="wallet-outline"
title={t('household.noTransactions')}
subtitle={t('household.noTransactionsHint')}
/>
);
}
return (
<View className="flex-1 bg-gray-50">
{/* Header */}
<View style={{ backgroundColor: "#fff", paddingTop: insets.top, borderBottomWidth: 1, borderBottomColor: "#f3f4f6" }}>
<MonthSwitcher
month={month}
isLocked={isLocked}
onPrev={() => setMonth((m) => addMonths(m, -1))}
onNext={() => setMonth((m) => addMonths(m, 1))}
/>
</View>
<FlatList
data={transactions}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<View className="bg-white">
<TransactionItem
transaction={item}
onPress={isLocked ? () => {} : setEditTransaction}
onDelete={isLocked ? () => {} : (t) => deleteTransaction(t.id)}
locked={isLocked}
/>
</View>
)}
ListHeaderComponent={
<View className="bg-gray-50">
<SettlementBanner month={month} isCurrent={isCurrent} />
<NettoCard month={month} />
<MonthSummaryHeader
income={balance?.income}
expense={balance?.expense}
balance={balance?.balance}
isLoading={balanceLoading}
accentColor={ACCENT}
/>
<CarryOverBanner month={month} scope="household" />
{/* 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 ? ACCENT : "#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>
</View>
}
ListEmptyComponent={renderEmpty}
refreshControl={
<RefreshControl
refreshing={isRefetching}
onRefresh={() => void refetch()}
tintColor={ACCENT}
/>
}
ItemSeparatorComponent={() => <View className="h-px bg-gray-50 ml-16" />}
contentContainerStyle={transactions.length === 0 ? { flex: 1 } : undefined}
/>
{isLoading && (
<View className="absolute inset-0 items-center justify-center">
<ActivityIndicator size="large" color={ACCENT} />
</View>
)}
{/* FAB — hidden for locked months */}
{!isLocked && (
<>
{/* Backdrop */}
{showFabMenu && (
<Pressable
className="absolute inset-0"
style={{ backgroundColor: "rgba(0,0,0,0.25)" }}
onPress={() => setShowFabMenu(false)}
/>
)}
{/* FAB menu — anchored above FAB, zIndex above backdrop */}
{showFabMenu && (
<View
className="absolute right-6 bg-white rounded-2xl overflow-hidden"
style={{
bottom: insets.bottom + 80,
minWidth: 200,
shadowColor: "#000",
shadowOpacity: 0.18,
shadowRadius: 16,
shadowOffset: { width: 0, height: 6 },
elevation: 12,
zIndex: 100,
}}
>
<Pressable
onPress={() => {
setShowFabMenu(false);
setShowAddModal(true);
}}
className="flex-row items-center gap-3 px-5 py-4 active:bg-gray-50"
>
<Ionicons name="pencil-outline" size={20} color={ACCENT} />
<Text className="text-sm font-medium text-gray-800">
{t("scanner.manualEntry")}
</Text>
</Pressable>
<View className="h-px bg-gray-100" />
<Pressable
onPress={() => {
setShowFabMenu(false);
router.push("/(app)/scanner");
}}
className="flex-row items-center gap-3 px-5 py-4 active:bg-gray-50"
>
<Ionicons name="camera-outline" size={20} color={ACCENT} />
<Text className="text-sm font-medium text-gray-800">
{t("scanner.scanReceipt")}
</Text>
</Pressable>
</View>
)}
{/* FAB button */}
<Pressable
onPress={() => setShowFabMenu((v) => !v)}
style={{ backgroundColor: ACCENT, bottom: insets.bottom + 20, zIndex: 101 }}
className="absolute right-6 w-14 h-14 rounded-full items-center justify-center shadow-lg active:opacity-80"
>
<Ionicons name={showFabMenu ? "close" : "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="household"
/>
<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>
);
}