Files
HausApp/apps/native/app/(app)/urlaub/[id].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

861 lines
28 KiB
TypeScript
Raw 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 { ModalHeader } from "@/src/components/ui/ModalHeader";
import { Numpad } from "@/src/components/ui/Numpad";
import { TAB_COLORS } from "@/src/constants/colors";
import {
useTrip,
useTripExpenses,
useCreateTripExpense,
useDeleteTripExpense,
useCompleteTrip,
type TripExpense,
type TripSettlement,
type CreateTripExpenseInput,
} from "@/src/hooks/useTrips";
import { useHouseholdMembers } from "@/src/hooks/useHouseholdMembers";
import { useAuthStore } from "@/src/stores/auth.store";
import { formatEur } from "@/src/utils/format";
import { todayIso } from "@/src/utils/date";
import { handleNumpadKey, parseAmountStr } from "@/src/utils/numpad";
import { Ionicons } from "@expo/vector-icons";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Alert,
FlatList,
Modal,
Pressable,
ScrollView,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const ACCENT = TAB_COLORS.shopping; // green #16A34A
// ── Category config ───────────────────────────────────────────────────────────
type ExpenseCategory = TripExpense["category"];
const CATEGORY_ICONS: Record<ExpenseCategory, React.ComponentProps<typeof Ionicons>["name"]> = {
unterkunft: "bed-outline",
essen: "restaurant-outline",
transport: "car-outline",
aktivitaeten: "ticket-outline",
sonstiges: "cube-outline",
};
const CATEGORY_ORDER: ExpenseCategory[] = [
"unterkunft",
"essen",
"transport",
"aktivitaeten",
"sonstiges",
];
// ── Helpers ───────────────────────────────────────────────────────────────────
function formatDateRange(startDate: string, endDate: string): string {
const fmt = (d: string) => {
const parts = d.split("-");
return `${parts[2]}.${parts[1]}.${parts[0]?.slice(2)}`;
};
return `${fmt(startDate)} ${fmt(endDate)}`;
}
function getBudgetColor(remaining: number, budget: number): string {
if (remaining <= 0) return "#dc2626";
if (remaining < budget * 0.1) return "#ea580c";
return ACCENT;
}
// ── Progress Bar ──────────────────────────────────────────────────────────────
function ProgressBar({
spent,
budget,
color,
height = 6,
}: {
spent: number;
budget: number;
color: string;
height?: number;
}) {
const ratio = budget > 0 ? Math.min(spent / budget, 1) : 0;
return (
<View
style={{ height, borderRadius: height / 2, backgroundColor: "#f3f4f6", overflow: "hidden" }}
>
<View
style={{
width: `${ratio * 100}%`,
height,
backgroundColor: color,
borderRadius: height / 2,
}}
/>
</View>
);
}
// ── Budget Summary Card ───────────────────────────────────────────────────────
function BudgetSummaryCard({
budget,
totalSpent,
remaining,
}: {
budget: number;
totalSpent: number;
remaining: number;
}) {
const { t } = useTranslation();
const color = getBudgetColor(remaining, budget);
const isOver = remaining <= 0;
return (
<View
className="mx-4 mt-4 bg-white rounded-2xl p-4"
style={{ borderWidth: 1, borderColor: "#f3f4f6" }}
>
<View className="flex-row justify-between mb-3">
<View className="flex-1 items-center">
<Text className="text-xs text-gray-400 mb-1">{t("trips.budget")}</Text>
<Text className="text-lg font-bold text-gray-900">{formatEur(budget)}</Text>
</View>
<View className="w-px bg-gray-100" />
<View className="flex-1 items-center">
<Text className="text-xs text-gray-400 mb-1">{t("trips.spent")}</Text>
<Text className="text-lg font-bold text-gray-700">{formatEur(totalSpent)}</Text>
</View>
<View className="w-px bg-gray-100" />
<View className="flex-1 items-center">
<Text className="text-xs text-gray-400 mb-1">{t("trips.remaining")}</Text>
<Text className="text-lg font-bold" style={{ color }}>
{isOver ? `${formatEur(Math.abs(remaining))}` : formatEur(remaining)}
</Text>
</View>
</View>
<ProgressBar spent={totalSpent} budget={budget} color={color} height={8} />
</View>
);
}
// ── Category Section ──────────────────────────────────────────────────────────
function CategorySection({
byCategory,
budget,
}: {
byCategory: Record<string, number>;
budget: number;
}) {
const { t } = useTranslation();
const categories = CATEGORY_ORDER.filter((cat) => (byCategory[cat] ?? 0) > 0);
if (categories.length === 0) return null;
return (
<View className="mx-4 mt-4 bg-white rounded-2xl overflow-hidden" style={{ borderWidth: 1, borderColor: "#f3f4f6" }}>
<Text className="text-xs font-semibold text-gray-400 uppercase tracking-wide px-4 pt-3 pb-2">
{t("trips.budget")} nach Kategorie
</Text>
{categories.map((cat, index) => {
const amount = byCategory[cat] ?? 0;
const icon = CATEGORY_ICONS[cat];
return (
<View
key={cat}
className="px-4 py-3"
style={index < categories.length - 1 ? { borderBottomWidth: 1, borderBottomColor: "#f9fafb" } : undefined}
>
<View className="flex-row items-center mb-1.5">
<View
className="w-7 h-7 rounded-lg items-center justify-center mr-3"
style={{ backgroundColor: `${ACCENT}18` }}
>
<Ionicons name={icon} size={14} color={ACCENT} />
</View>
<Text className="text-sm text-gray-700 flex-1">
{t(`trips.categories.${cat}`)}
</Text>
<Text className="text-sm font-semibold text-gray-800">{formatEur(amount)}</Text>
</View>
<View className="ml-10">
<ProgressBar spent={amount} budget={budget} color={ACCENT} height={3} />
</View>
</View>
);
})}
</View>
);
}
// ── Expense Row ───────────────────────────────────────────────────────────────
function ExpenseRow({
expense,
memberName,
onDelete,
}: {
expense: TripExpense;
memberName: string;
onDelete: () => void;
}) {
const { t } = useTranslation();
const icon = CATEGORY_ICONS[expense.category];
return (
<View className="flex-row items-center px-4 py-3 bg-white">
<View
className="w-9 h-9 rounded-xl items-center justify-center mr-3"
style={{ backgroundColor: `${ACCENT}18` }}
>
<Ionicons name={icon} size={18} color={ACCENT} />
</View>
<View className="flex-1 mr-2">
<Text className="text-sm font-medium text-gray-800">{expense.label}</Text>
<Text className="text-xs text-gray-400">
{t("trips.paidBy", { name: memberName })} · {expense.date}
</Text>
{expense.note && (
<Text className="text-xs text-gray-400 italic">{expense.note}</Text>
)}
</View>
<Text className="text-sm font-semibold text-gray-700 mr-3">{formatEur(expense.amount)}</Text>
<TouchableOpacity onPress={onDelete} className="p-1 active:opacity-60">
<Ionicons name="trash-outline" size={18} color="#d1d5db" />
</TouchableOpacity>
</View>
);
}
// ── Closed Banner ─────────────────────────────────────────────────────────────
function ClosedBanner({
settlementAmount,
settlementFromUserId,
settlementToUserId,
getMemberName,
}: {
settlementAmount: number | null;
settlementFromUserId: string | null;
settlementToUserId: string | null;
getMemberName: (userId: string) => string;
}) {
const { t } = useTranslation();
const hasSettlement =
settlementAmount !== null &&
settlementAmount > 0.01 &&
settlementFromUserId !== null &&
settlementToUserId !== null;
return (
<View
className="mx-4 mt-4 rounded-2xl px-4 py-3"
style={{ backgroundColor: "#dcfce7" }}
>
<View className="flex-row items-center gap-2 mb-1">
<Ionicons name="lock-closed" size={14} color="#15803d" />
<Text className="text-sm font-semibold" style={{ color: "#15803d" }}>
{t("trips.settlement.closedBanner")}
</Text>
</View>
{hasSettlement && settlementFromUserId !== null && settlementToUserId !== null ? (
<Text className="text-xs" style={{ color: "#166534" }}>
{t("trips.settlement.settledInfo", {
from: getMemberName(settlementFromUserId),
to: getMemberName(settlementToUserId),
amount: formatEur(settlementAmount ?? 0),
})}
</Text>
) : (
<Text className="text-xs" style={{ color: "#166534" }}>
{t("trips.settlement.balanced")}
</Text>
)}
</View>
);
}
// ── Settlement Modal ──────────────────────────────────────────────────────────
function SettlementModal({
settlement,
onConfirm,
onClose,
isPending,
}: {
settlement: TripSettlement;
onConfirm: () => void;
onClose: () => void;
isPending: boolean;
}) {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const hasSettlement = settlement.settlement !== null;
return (
<Modal visible animationType="slide" presentationStyle="pageSheet" onRequestClose={onClose}>
<View className="flex-1 bg-white" style={{ paddingBottom: insets.bottom + 16 }}>
{/* Header */}
<View
className="flex-row items-center justify-between px-4 py-4"
style={{ borderBottomWidth: 1, borderBottomColor: "#f3f4f6" }}
>
<Text className="text-lg font-bold text-gray-900">
{t("trips.settlement.title")}
</Text>
<Pressable onPress={onClose} className="p-1 active:opacity-70">
<Ionicons name="close" size={22} color="#6b7280" />
</Pressable>
</View>
<ScrollView className="flex-1" contentContainerStyle={{ padding: 16, gap: 4 }}>
{/* Total row */}
<View className="flex-row justify-between py-3">
<Text className="text-sm text-gray-600">{t("trips.settlement.total")}</Text>
<Text className="text-sm font-semibold text-gray-900">
{formatEur(settlement.total)}
</Text>
</View>
{/* Fair share row */}
<View className="flex-row justify-between py-3">
<Text className="text-sm text-gray-600">{t("trips.settlement.fairShare")}</Text>
<Text className="text-sm font-semibold text-gray-900">
{formatEur(settlement.fairShare)}
</Text>
</View>
{/* Divider */}
<View style={{ height: 1, backgroundColor: "#f3f4f6", marginVertical: 4 }} />
{/* Per-person rows */}
{settlement.balances.map((balance) => (
<View key={balance.userId} className="py-3">
<View className="flex-row justify-between mb-1.5">
<Text className="text-sm font-medium text-gray-700">{balance.name}</Text>
<Text className="text-sm text-gray-500">
{formatEur(balance.paid)} {t("trips.settlement.paid")}
</Text>
</View>
<ProgressBar
spent={balance.paid}
budget={settlement.total > 0 ? settlement.total : 1}
color={balance.balance >= 0 ? ACCENT : "#f97316"}
height={4}
/>
</View>
))}
{/* Divider */}
<View style={{ height: 1, backgroundColor: "#f3f4f6", marginVertical: 4 }} />
{/* Settlement result box */}
<View
className="rounded-2xl p-4 mt-2"
style={{ backgroundColor: hasSettlement ? "#dbeafe" : "#dcfce7" }}
>
{hasSettlement && settlement.settlement !== null ? (
<>
<Text
className="text-xs font-medium mb-1"
style={{ color: hasSettlement ? "#1d4ed8" : "#15803d" }}
>
{t("trips.settlement.owes", {
from: settlement.settlement.fromName,
to: settlement.settlement.toName,
})}
</Text>
<Text
className="text-2xl font-bold"
style={{ color: hasSettlement ? "#1e40af" : "#166534" }}
>
{formatEur(settlement.settlement.amount)}
</Text>
</>
) : (
<View className="flex-row items-center gap-2">
<Ionicons name="checkmark-circle" size={18} color="#15803d" />
<Text className="text-sm font-medium" style={{ color: "#15803d" }}>
{t("trips.settlement.balanced")}
</Text>
</View>
)}
</View>
<View className="h-4" />
{/* Confirm button */}
<Pressable
onPress={onConfirm}
disabled={isPending}
className="rounded-2xl py-4 items-center active:opacity-80"
style={{ backgroundColor: ACCENT }}
>
{isPending ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text className="text-base font-semibold text-white">
{t("trips.settlement.closeTrip")}
</Text>
)}
</Pressable>
{/* Cancel text button */}
<Pressable onPress={onClose} className="py-3 items-center active:opacity-70">
<Text className="text-sm text-gray-500">{t("common.cancel")}</Text>
</Pressable>
</ScrollView>
</View>
</Modal>
);
}
// ── Add Expense Modal ─────────────────────────────────────────────────────────
function AddExpenseModal({
tripId,
onClose,
}: {
tripId: string;
onClose: () => void;
}) {
const { t } = useTranslation();
const userId = useAuthStore((s) => s.user?.id ?? "");
const { data: membersData } = useHouseholdMembers();
const members = membersData?.members ?? [];
const { mutate: createExpense, isPending } = useCreateTripExpense(tripId);
const [label, setLabel] = useState("");
const [amountStr, setAmountStr] = useState("0");
const [category, setCategory] = useState<ExpenseCategory>("sonstiges");
const [paidBy, setPaidBy] = useState(userId);
const [date, setDate] = useState(todayIso());
const [note, setNote] = useState("");
function handleNumpadKey_(key: string) {
setAmountStr((prev) => handleNumpadKey(prev, key));
}
const amount = parseAmountStr(amountStr);
const canSave = label.trim().length > 0 && amount > 0 && date.length === 10;
function handleSave() {
if (!canSave) return;
const input: CreateTripExpenseInput = {
label: label.trim(),
amount,
category,
paidBy,
date,
...(note.trim() ? { note: note.trim() } : {}),
};
createExpense(input, { onSuccess: onClose });
}
return (
<Modal visible animationType="slide" presentationStyle="pageSheet" onRequestClose={onClose}>
<View className="flex-1 bg-white">
<ModalHeader
title={t("trips.newExpense")}
onClose={onClose}
closeLabel={t("common.cancel")}
onSave={handleSave}
saveLabel={t("common.save")}
saveDisabled={!canSave}
saveLoading={isPending}
saveColor={ACCENT}
/>
<ScrollView className="flex-1" keyboardShouldPersistTaps="handled">
{/* Amount display */}
<View className="items-center py-5">
<Text className="text-5xl font-bold text-gray-900"> {amountStr}</Text>
<Text className="text-xs text-gray-400 mt-1">{t("trips.spent")}</Text>
</View>
{/* Numpad */}
<Numpad onKeyPress={handleNumpadKey_} />
{/* Fields */}
<View className="px-4 mt-4 gap-3">
{/* Label */}
<TextInput
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900"
placeholder={t("trips.label")}
placeholderTextColor="#9ca3af"
value={label}
onChangeText={setLabel}
/>
{/* Category chips */}
<ScrollView horizontal showsHorizontalScrollIndicator={false} className="-mx-4 px-4">
<View className="flex-row gap-2">
{CATEGORY_ORDER.map((cat) => {
const selected = category === cat;
return (
<Pressable
key={cat}
onPress={() => setCategory(cat)}
className="flex-row items-center gap-1.5 px-3 py-2 rounded-full"
style={{
backgroundColor: selected ? ACCENT : "#f3f4f6",
}}
>
<Ionicons
name={CATEGORY_ICONS[cat]}
size={14}
color={selected ? "#fff" : "#6b7280"}
/>
<Text
className="text-xs font-medium"
style={{ color: selected ? "#fff" : "#4b5563" }}
>
{t(`trips.categories.${cat}`)}
</Text>
</Pressable>
);
})}
</View>
</ScrollView>
{/* Paid by */}
{members.length > 1 && (
<View className="flex-row gap-2">
{members.map((m) => {
const selected = paidBy === m.userId;
return (
<Pressable
key={m.userId}
onPress={() => setPaidBy(m.userId)}
className="flex-1 py-2.5 rounded-xl items-center"
style={{
backgroundColor: selected ? ACCENT : "#f3f4f6",
}}
>
<Text
className="text-sm font-medium"
style={{ color: selected ? "#fff" : "#4b5563" }}
>
{m.name}
</Text>
</Pressable>
);
})}
</View>
)}
{/* Date */}
<TextInput
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900"
placeholder="YYYY-MM-DD"
placeholderTextColor="#9ca3af"
value={date}
onChangeText={setDate}
keyboardType="numbers-and-punctuation"
maxLength={10}
/>
{/* Note */}
<TextInput
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900"
placeholder={t("trips.note")}
placeholderTextColor="#9ca3af"
value={note}
onChangeText={setNote}
/>
</View>
<View className="h-8" />
</ScrollView>
</View>
</Modal>
);
}
// ── Main Detail Screen ────────────────────────────────────────────────────────
export default function TripDetailScreen() {
const insets = useSafeAreaInsets();
const router = useRouter();
const { t } = useTranslation();
const { id } = useLocalSearchParams<{ id: string }>();
const { data: summary, isLoading: summaryLoading } = useTrip(id);
const { data: expenses = [], isLoading: expensesLoading } = useTripExpenses(id);
const { data: membersData } = useHouseholdMembers();
const { mutate: deleteExpense } = useDeleteTripExpense(id);
const { mutate: completeTrip, isPending: completing } = useCompleteTrip();
const [showAddExpense, setShowAddExpense] = useState(false);
const [showSettlementModal, setShowSettlementModal] = useState(false);
const [settlementPreview, setSettlementPreview] = useState<TripSettlement | null>(null);
const [isLoadingSettlement, setIsLoadingSettlement] = useState(false);
const members = membersData?.members ?? [];
function getMemberName(userId: string): string {
return members.find((m) => m.userId === userId)?.name ?? userId;
}
function handleDeleteExpense(expenseId: string) {
Alert.alert(
t("common.delete"),
t("common.confirm") + "?",
[
{ text: t("common.cancel"), style: "cancel" },
{
text: t("common.delete"),
style: "destructive",
onPress: () => deleteExpense(expenseId),
},
],
);
}
async function handleComplete() {
if (!summary) return;
if (expenses.length === 0) {
Alert.alert(
t("trips.settlement.title"),
t("trips.settlement.noExpenses"),
);
return;
}
setIsLoadingSettlement(true);
try {
const { apiRequest } = await import("@/src/lib/api-client");
const result = await apiRequest<{ settlement: TripSettlement }>(
`/api/trips/${id}/settlement`,
);
setSettlementPreview(result.settlement);
setShowSettlementModal(true);
} catch {
Alert.alert(t("common.error"), t("common.error"));
} finally {
setIsLoadingSettlement(false);
}
}
function handleConfirmComplete() {
completeTrip(id, {
onSuccess: () => {
setShowSettlementModal(false);
router.back();
},
});
}
if (summaryLoading) {
return (
<View className="flex-1 bg-gray-50 items-center justify-center">
<ActivityIndicator size="large" color={ACCENT} />
</View>
);
}
if (!summary) {
return (
<View className="flex-1 bg-gray-50 items-center justify-center">
<Text className="text-gray-400">Nicht gefunden</Text>
</View>
);
}
const { trip, totalSpent, remaining, byCategory } = summary;
const isActive = trip.status === "active";
type ListSection =
| { key: "closed-banner" }
| { key: "budget" }
| { key: "category" }
| { key: "expense-header" }
| { key: `expense-${string}`; expense: TripExpense };
const sections: ListSection[] = [];
if (!isActive) {
sections.push({ key: "closed-banner" });
}
sections.push(
{ key: "budget" },
{ key: "category" },
{ key: "expense-header" },
...expenses.map(
(e): ListSection => ({ key: `expense-${e.id}`, expense: e }),
),
);
function renderSection({ item }: { item: ListSection }) {
if (item.key === "closed-banner") {
return (
<ClosedBanner
settlementAmount={trip.settlementAmount}
settlementFromUserId={trip.settlementFromUserId}
settlementToUserId={trip.settlementToUserId}
getMemberName={getMemberName}
/>
);
}
if (item.key === "budget") {
return (
<BudgetSummaryCard
budget={trip.budget}
totalSpent={totalSpent}
remaining={remaining}
/>
);
}
if (item.key === "category") {
return <CategorySection byCategory={byCategory} budget={trip.budget} />;
}
if (item.key === "expense-header") {
return (
<View className="flex-row items-center justify-between px-4 pt-5 pb-2">
<Text className="text-xs font-semibold text-gray-400 uppercase tracking-wide">
Ausgaben
</Text>
{isActive && (
<Pressable
onPress={() => setShowAddExpense(true)}
className="flex-row items-center gap-1 px-3 py-1.5 rounded-full active:opacity-70"
style={{ backgroundColor: ACCENT }}
>
<Ionicons name="add" size={14} color="#fff" />
<Text className="text-xs font-semibold text-white">{t("trips.newExpense")}</Text>
</Pressable>
)}
</View>
);
}
if (item.key.startsWith("expense-") && "expense" in item) {
return (
<View
className="mx-4 bg-white rounded-xl mb-2"
style={{ borderWidth: 1, borderColor: "#f3f4f6" }}
>
<ExpenseRow
expense={item.expense}
memberName={getMemberName(item.expense.paidBy)}
onDelete={() => handleDeleteExpense(item.expense.id)}
/>
</View>
);
}
return null;
}
const completeDisabled = completing || isLoadingSettlement || expenses.length === 0;
return (
<View className="flex-1 bg-gray-50">
{/* Header */}
<View
style={{
backgroundColor: "#fff",
paddingTop: insets.top + 12,
borderBottomWidth: 1,
borderBottomColor: "#f3f4f6",
}}
className="px-4 pb-3 flex-row items-center"
>
<Pressable onPress={() => router.back()} className="mr-3 p-1">
<Ionicons name="chevron-back" size={22} color="#374151" />
</Pressable>
<View className="flex-1">
<Text className="text-base font-bold text-gray-900">{trip.name}</Text>
<Text className="text-xs text-gray-400">
{trip.destination ? `${trip.destination} · ` : ""}
{formatDateRange(trip.startDate, trip.endDate)}
</Text>
</View>
{isActive && (
<Pressable
onPress={() => void handleComplete()}
disabled={completeDisabled}
className="flex-row items-center gap-1.5 px-3 py-2 rounded-xl active:opacity-70"
style={{
backgroundColor: completeDisabled ? "#e5e7eb" : "#f3f4f6",
}}
>
{completing || isLoadingSettlement ? (
<ActivityIndicator size="small" color="#6b7280" />
) : (
<>
<Ionicons
name="checkmark-circle-outline"
size={16}
color={expenses.length === 0 ? "#d1d5db" : "#6b7280"}
/>
<Text
className="text-xs font-semibold"
style={{ color: expenses.length === 0 ? "#d1d5db" : "#6b7280" }}
>
{t("trips.complete")}
</Text>
</>
)}
</Pressable>
)}
</View>
{expensesLoading ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" color={ACCENT} />
</View>
) : (
<FlatList
data={sections}
keyExtractor={(item) => item.key}
renderItem={renderSection}
contentContainerStyle={{ paddingBottom: insets.bottom + 80 }}
ListEmptyComponent={null}
/>
)}
{/* FAB */}
{isActive && (
<Pressable
onPress={() => setShowAddExpense(true)}
style={{ backgroundColor: ACCENT, bottom: insets.bottom + 20 }}
className="absolute right-6 w-14 h-14 rounded-full items-center justify-center shadow-lg active:opacity-80"
>
<Ionicons name="add" size={28} color="#fff" />
</Pressable>
)}
{showAddExpense && (
<AddExpenseModal tripId={id} onClose={() => setShowAddExpense(false)} />
)}
{showSettlementModal && settlementPreview !== null && (
<SettlementModal
settlement={settlementPreview}
onConfirm={handleConfirmComplete}
onClose={() => setShowSettlementModal(false)}
isPending={completing}
/>
)}
</View>
);
}