Files
HausApp/apps/native/app/(app)/months/close.tsx
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

244 lines
8.6 KiB
TypeScript

import { useLocalSearchParams, useRouter } from "expo-router";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Alert,
Pressable,
ScrollView,
Text,
TextInput,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Ionicons } from "@expo/vector-icons";
import { useSettlementV2 } from "@/src/hooks/useFixedCosts";
import { useHouseholdSettings } from "@/src/hooks/useHouseholdSettings";
import { useCloseMonth } from "@/src/hooks/useMonthStatus";
import { useAuthStore } from "@/src/stores/auth.store";
import { monthLabel } from "@/src/utils/date";
import { formatEur } from "@/src/utils/format";
const ACCENT = "#2563EB";
export default function CloseMonthScreen() {
const insets = useSafeAreaInsets();
const router = useRouter();
const { t } = useTranslation();
const { month } = useLocalSearchParams<{ month: string }>();
const userId = useAuthStore((s) => s.user?.id);
const { data: settlement, isLoading: settlementLoading } = useSettlementV2(month);
const { data: hhSettings } = useHouseholdSettings();
const { mutate: closeMonth, isPending } = useCloseMonth(month);
const remaining = settlement?.remaining ?? 0;
const [amountStr, setAmountStr] = useState<string | null>(null);
const [notes, setNotes] = useState("");
// Lazy-init amount from settlement once loaded
const displayAmount = amountStr ?? (remaining > 0 ? remaining.toFixed(2).replace(".", ",") : "0,00");
const others = (settlement?.members ?? []).filter((m) => m.userId !== userId);
const otherName = hhSettings?.partnerName ?? others[0]?.name ?? "Partner";
const otherUserId = others[0]?.userId ?? "";
function handleAmountChange(text: string) {
// Allow only digits and one comma
const cleaned = text.replace(/[^0-9,]/g, "");
const parts = cleaned.split(",");
if (parts.length > 2) return;
if (parts[1] !== undefined && parts[1].length > 2) return;
setAmountStr(cleaned);
}
function handleClose() {
const amount = parseFloat(displayAmount.replace(",", ".")) || 0;
Alert.alert(
t('monthClose.closeConfirmTitle', { month: monthLabel(month) }),
t('monthClose.closeConfirmMessage'),
[
{ text: t('common.cancel'), style: "cancel" },
{
text: t('monthClose.closeConfirmAction'),
style: "destructive",
onPress: () => {
closeMonth(
{ finalAmount: amount, toUserId: otherUserId, notes: notes.trim() || undefined },
{
onSuccess: () => router.back(),
onError: (err) =>
Alert.alert(t('common.error'), err.message ?? "Abschluss fehlgeschlagen"),
},
);
},
},
],
);
}
if (settlementLoading) {
return (
<View className="flex-1 bg-gray-50 items-center justify-center">
<ActivityIndicator size="large" color={ACCENT} />
</View>
);
}
const s = settlement;
return (
<View className="flex-1 bg-gray-50">
{/* Header */}
<View
className="bg-white border-b border-gray-100"
style={{ paddingTop: insets.top }}
>
<View className="flex-row items-center px-4 py-3">
<Pressable onPress={() => router.back()} className="mr-3 p-1">
<Ionicons name="chevron-back" size={22} color="#374151" />
</Pressable>
<Text className="text-base font-semibold text-gray-900 flex-1">
{t('monthClose.title', { month: monthLabel(month) })}
</Text>
</View>
</View>
<ScrollView
className="flex-1"
contentContainerStyle={{ padding: 16, paddingBottom: insets.bottom + 80 }}
keyboardShouldPersistTaps="handled"
>
{/* Overview card */}
{s && (
<View className="bg-white rounded-2xl p-4 mb-4" style={{ borderWidth: 1, borderColor: "#f3f4f6" }}>
<Text className="text-xs font-medium uppercase text-gray-400 mb-3">{t('monthClose.overview')}</Text>
<Row label={t('monthClose.householdTotal')} value={`-${formatEur(s.householdExpenses)}`} />
{s.householdIncome > 0 && (
<Row label={t('monthClose.householdIncome')} value={`+${formatEur(s.householdIncome)}`} color="#16a34a" />
)}
<Row
label={t('monthClose.yourShare', { percent: s.memberCount > 0 ? Math.round(100 / s.memberCount) : 50 })}
value={`-${formatEur(s.perMemberShare)}`}
bold
/>
{s.lineItems.length > 0 && (
<>
<View className="h-px bg-gray-100 my-2" />
{s.lineItems.map((li) => (
<Row key={li.id} label={li.label} value={`-${formatEur(li.amount)}`} />
))}
</>
)}
<View className="h-px bg-gray-200 my-3" />
<Row label={t('monthClose.totalTransfer')} value={`-${formatEur(s.totalOwed)}`} bold />
<Row label={t('monthClose.alreadyTransferred')} value={`-${formatEur(s.alreadyTransferred)}`} />
</View>
)}
{/* Remaining amount hero */}
<View
className="rounded-2xl p-4 mb-4 items-center"
style={{ backgroundColor: remaining > 0.01 ? "#fff7ed" : "#f0fdf4", borderWidth: 1, borderColor: remaining > 0.01 ? "#fed7aa" : "#bbf7d0" }}
>
<Text className="text-xs text-gray-500 mb-1">
{remaining > 0.01 ? t('monthClose.receives', { name: otherName }) : remaining < -0.01 ? t('monthClose.youReceive') : t('monthClose.settled')}
</Text>
<Text
className="text-3xl font-bold"
style={{ color: remaining > 0.01 ? "#ea580c" : remaining < -0.01 ? "#16a34a" : "#6b7280" }}
>
{formatEur(Math.abs(remaining))}
</Text>
</View>
{/* Amount adjustment */}
<View className="bg-white rounded-2xl p-4 mb-4" style={{ borderWidth: 1, borderColor: "#f3f4f6" }}>
<Text className="text-sm font-medium text-gray-700 mb-2">
{t('monthClose.adjustAmount')}
</Text>
<Text className="text-xs text-gray-400 mb-3">
{t('monthClose.adjustHint')}
</Text>
<View className="flex-row items-center bg-gray-50 border border-gray-200 rounded-xl px-4 py-3">
<Text className="text-base text-gray-400 mr-2"></Text>
<TextInput
className="flex-1 text-base text-gray-900"
value={displayAmount}
onChangeText={handleAmountChange}
keyboardType="decimal-pad"
selectTextOnFocus
/>
</View>
</View>
{/* Notes */}
<View className="bg-white rounded-2xl p-4 mb-6" style={{ borderWidth: 1, borderColor: "#f3f4f6" }}>
<Text className="text-sm font-medium text-gray-700 mb-2">{t('monthClose.note')}</Text>
<TextInput
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900"
placeholder={t('monthClose.notePlaceholder')}
value={notes}
onChangeText={setNotes}
multiline
/>
</View>
{/* CTA */}
<Pressable
onPress={handleClose}
disabled={isPending}
className="rounded-2xl py-4 items-center active:opacity-80 mb-3"
style={{ backgroundColor: "#dc2626" }}
>
{isPending ? (
<ActivityIndicator color="#fff" />
) : (
<View className="flex-row items-center gap-2">
<Ionicons name="lock-closed-outline" size={18} color="#fff" />
<Text className="text-base font-semibold text-white">{t('monthClose.closeButton')}</Text>
</View>
)}
</Pressable>
<Pressable
onPress={() => router.back()}
className="py-3 items-center active:opacity-50"
>
<Text className="text-sm text-gray-400">{t('common.cancel')}</Text>
</Pressable>
</ScrollView>
</View>
);
}
function Row({
label,
value,
bold,
color,
}: {
label: string;
value: string;
bold?: boolean;
color?: string;
}) {
return (
<View className="flex-row justify-between items-center py-1.5">
<Text className={`text-sm ${bold ? "font-semibold text-gray-800" : "text-gray-500"}`}>
{label}
</Text>
<Text
className={`text-sm ${bold ? "font-semibold text-gray-800" : "text-gray-700"}`}
style={color ? { color } : undefined}
>
{value}
</Text>
</View>
);
}