- 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>
244 lines
8.6 KiB
TypeScript
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>
|
|
);
|
|
}
|