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["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 ( ); } // ── 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 ( {t("trips.budget")} {formatEur(budget)} {t("trips.spent")} {formatEur(totalSpent)} {t("trips.remaining")} {isOver ? `−${formatEur(Math.abs(remaining))}` : formatEur(remaining)} ); } // ── Category Section ────────────────────────────────────────────────────────── function CategorySection({ byCategory, budget, }: { byCategory: Record; budget: number; }) { const { t } = useTranslation(); const categories = CATEGORY_ORDER.filter((cat) => (byCategory[cat] ?? 0) > 0); if (categories.length === 0) return null; return ( {t("trips.budget")} nach Kategorie {categories.map((cat, index) => { const amount = byCategory[cat] ?? 0; const icon = CATEGORY_ICONS[cat]; return ( {t(`trips.categories.${cat}`)} {formatEur(amount)} ); })} ); } // ── Expense Row ─────────────────────────────────────────────────────────────── function ExpenseRow({ expense, memberName, onDelete, }: { expense: TripExpense; memberName: string; onDelete: () => void; }) { const { t } = useTranslation(); const icon = CATEGORY_ICONS[expense.category]; return ( {expense.label} {t("trips.paidBy", { name: memberName })} · {expense.date} {expense.note && ( {expense.note} )} {formatEur(expense.amount)} ); } // ── 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 ( {t("trips.settlement.closedBanner")} {hasSettlement && settlementFromUserId !== null && settlementToUserId !== null ? ( {t("trips.settlement.settledInfo", { from: getMemberName(settlementFromUserId), to: getMemberName(settlementToUserId), amount: formatEur(settlementAmount ?? 0), })} ) : ( {t("trips.settlement.balanced")} )} ); } // ── 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 ( {/* Header */} {t("trips.settlement.title")} {/* Total row */} {t("trips.settlement.total")} {formatEur(settlement.total)} {/* Fair share row */} {t("trips.settlement.fairShare")} {formatEur(settlement.fairShare)} {/* Divider */} {/* Per-person rows */} {settlement.balances.map((balance) => ( {balance.name} {formatEur(balance.paid)} {t("trips.settlement.paid")} 0 ? settlement.total : 1} color={balance.balance >= 0 ? ACCENT : "#f97316"} height={4} /> ))} {/* Divider */} {/* Settlement result box */} {hasSettlement && settlement.settlement !== null ? ( <> {t("trips.settlement.owes", { from: settlement.settlement.fromName, to: settlement.settlement.toName, })} {formatEur(settlement.settlement.amount)} ) : ( {t("trips.settlement.balanced")} )} {/* Confirm button */} {isPending ? ( ) : ( {t("trips.settlement.closeTrip")} )} {/* Cancel text button */} {t("common.cancel")} ); } // ── 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("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 ( {/* Amount display */} € {amountStr} {t("trips.spent")} {/* Numpad */} {/* Fields */} {/* Label */} {/* Category chips */} {CATEGORY_ORDER.map((cat) => { const selected = category === cat; return ( setCategory(cat)} className="flex-row items-center gap-1.5 px-3 py-2 rounded-full" style={{ backgroundColor: selected ? ACCENT : "#f3f4f6", }} > {t(`trips.categories.${cat}`)} ); })} {/* Paid by */} {members.length > 1 && ( {members.map((m) => { const selected = paidBy === m.userId; return ( setPaidBy(m.userId)} className="flex-1 py-2.5 rounded-xl items-center" style={{ backgroundColor: selected ? ACCENT : "#f3f4f6", }} > {m.name} ); })} )} {/* Date */} {/* Note */} ); } // ── 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(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 ( ); } if (!summary) { return ( Nicht gefunden ); } 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 ( ); } if (item.key === "budget") { return ( ); } if (item.key === "category") { return ; } if (item.key === "expense-header") { return ( Ausgaben {isActive && ( setShowAddExpense(true)} className="flex-row items-center gap-1 px-3 py-1.5 rounded-full active:opacity-70" style={{ backgroundColor: ACCENT }} > {t("trips.newExpense")} )} ); } if (item.key.startsWith("expense-") && "expense" in item) { return ( handleDeleteExpense(item.expense.id)} /> ); } return null; } const completeDisabled = completing || isLoadingSettlement || expenses.length === 0; return ( {/* Header */} router.back()} className="mr-3 p-1"> {trip.name} {trip.destination ? `${trip.destination} · ` : ""} {formatDateRange(trip.startDate, trip.endDate)} {isActive && ( 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 ? ( ) : ( <> {t("trips.complete")} )} )} {expensesLoading ? ( ) : ( item.key} renderItem={renderSection} contentContainerStyle={{ paddingBottom: insets.bottom + 80 }} ListEmptyComponent={null} /> )} {/* FAB */} {isActive && ( 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" > )} {showAddExpense && ( setShowAddExpense(false)} /> )} {showSettlementModal && settlementPreview !== null && ( setShowSettlementModal(false)} isPending={completing} /> )} ); }