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:
243
apps/native/app/(app)/months/close.tsx
Normal file
243
apps/native/app/(app)/months/close.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user