- 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>
861 lines
28 KiB
TypeScript
861 lines
28 KiB
TypeScript
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>
|
||
);
|
||
}
|