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