- 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>
428 lines
14 KiB
TypeScript
428 lines
14 KiB
TypeScript
import { EmptyState } from "@/src/components/ui/EmptyState";
|
||
import { ModalHeader } from "@/src/components/ui/ModalHeader";
|
||
import { TAB_COLORS } from "@/src/constants/colors";
|
||
import {
|
||
useTrips,
|
||
useCreateTrip,
|
||
type Trip,
|
||
type CreateTripInput,
|
||
} from "@/src/hooks/useTrips";
|
||
import { formatEur } from "@/src/utils/format";
|
||
import { Ionicons } from "@expo/vector-icons";
|
||
import { useRouter } from "expo-router";
|
||
import { useState } from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
import {
|
||
ActivityIndicator,
|
||
FlatList,
|
||
Modal,
|
||
Pressable,
|
||
ScrollView,
|
||
Text,
|
||
TextInput,
|
||
View,
|
||
} from "react-native";
|
||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||
|
||
const ACCENT = TAB_COLORS.shopping; // green #16A34A
|
||
|
||
// ── 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"; // red
|
||
if (remaining < budget * 0.1) return "#ea580c"; // orange
|
||
return ACCENT; // green
|
||
}
|
||
|
||
// ── Progress Bar ──────────────────────────────────────────────────────────────
|
||
|
||
function ProgressBar({
|
||
spent,
|
||
budget,
|
||
color,
|
||
}: {
|
||
spent: number;
|
||
budget: number;
|
||
color: string;
|
||
}) {
|
||
const ratio = budget > 0 ? Math.min(spent / budget, 1) : 0;
|
||
return (
|
||
<View className="h-1.5 bg-gray-100 rounded-full overflow-hidden mt-2">
|
||
<View
|
||
style={{ width: `${ratio * 100}%`, backgroundColor: color }}
|
||
className="h-full rounded-full"
|
||
/>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
// ── Active Trip Card ──────────────────────────────────────────────────────────
|
||
|
||
function ActiveTripCard({ trip, onPress }: { trip: Trip; onPress: () => void }) {
|
||
const { t } = useTranslation();
|
||
const remaining = trip.budget - trip.spent;
|
||
const color = getBudgetColor(remaining, trip.budget);
|
||
const isOver = remaining <= 0;
|
||
|
||
return (
|
||
<Pressable
|
||
onPress={onPress}
|
||
className="mx-4 mb-3 bg-white rounded-2xl p-4 active:opacity-80"
|
||
style={{ borderWidth: 1, borderColor: "#f3f4f6" }}
|
||
>
|
||
<View className="flex-row items-start justify-between mb-1">
|
||
<View className="flex-1 mr-2">
|
||
<Text className="text-base font-semibold text-gray-900">{trip.name}</Text>
|
||
{trip.destination && (
|
||
<Text className="text-xs text-gray-400 mt-0.5">{trip.destination}</Text>
|
||
)}
|
||
</View>
|
||
<View
|
||
className="px-2 py-0.5 rounded-full"
|
||
style={{ backgroundColor: `${ACCENT}18` }}
|
||
>
|
||
<Text className="text-xs font-medium" style={{ color: ACCENT }}>
|
||
{t("trips.active")}
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
|
||
<Text className="text-xs text-gray-400 mb-2">
|
||
{formatDateRange(trip.startDate, trip.endDate)}
|
||
</Text>
|
||
|
||
<ProgressBar spent={trip.spent} budget={trip.budget} color={color} />
|
||
|
||
<View className="flex-row justify-between mt-2">
|
||
<Text className="text-xs text-gray-400">
|
||
{t("trips.spent")}: {formatEur(trip.spent)}
|
||
</Text>
|
||
{isOver ? (
|
||
<Text className="text-xs font-semibold" style={{ color: "#dc2626" }}>
|
||
{t("trips.overBudget", { amount: formatEur(Math.abs(remaining)) })}
|
||
</Text>
|
||
) : (
|
||
<Text className="text-xs font-semibold" style={{ color }}>
|
||
{t("trips.remaining")}: {formatEur(remaining)}
|
||
</Text>
|
||
)}
|
||
</View>
|
||
|
||
<View className="flex-row justify-between mt-1">
|
||
<Text className="text-xs text-gray-300">{t("trips.budget")}: {formatEur(trip.budget)}</Text>
|
||
</View>
|
||
</Pressable>
|
||
);
|
||
}
|
||
|
||
// ── Past Trip Row ─────────────────────────────────────────────────────────────
|
||
|
||
function PastTripRow({ trip, onPress }: { trip: Trip; onPress: () => void }) {
|
||
const { t } = useTranslation();
|
||
|
||
const hasSettlement =
|
||
trip.settlementAmount !== null && trip.settlementAmount > 0.01;
|
||
const isBalanced =
|
||
trip.settlementAmount !== null && trip.settlementAmount <= 0.01;
|
||
|
||
return (
|
||
<Pressable
|
||
onPress={onPress}
|
||
className="flex-row items-center px-4 py-3 bg-white active:opacity-80"
|
||
style={{ borderBottomWidth: 1, borderBottomColor: "#f9fafb" }}
|
||
>
|
||
<View className="flex-1">
|
||
<Text className="text-sm font-medium text-gray-700">{trip.name}</Text>
|
||
{trip.destination && (
|
||
<Text className="text-xs text-gray-400">{trip.destination}</Text>
|
||
)}
|
||
{hasSettlement && (
|
||
<Text className="text-xs text-gray-400 mt-0.5">
|
||
{t("trips.settlement.closedBanner")} · {formatEur(trip.settlementAmount ?? 0)}
|
||
</Text>
|
||
)}
|
||
{isBalanced && (
|
||
<Text className="text-xs mt-0.5" style={{ color: "#16a34a" }}>
|
||
{t("trips.settlement.balanced")}
|
||
</Text>
|
||
)}
|
||
</View>
|
||
<View className="items-end mr-3">
|
||
<Text className="text-sm font-semibold text-gray-600">{formatEur(trip.spent)}</Text>
|
||
<Text className="text-xs text-gray-400">{formatDateRange(trip.startDate, trip.endDate)}</Text>
|
||
</View>
|
||
<View
|
||
className="px-2 py-0.5 rounded-full"
|
||
style={{ backgroundColor: "#f3f4f6" }}
|
||
>
|
||
<Text className="text-xs text-gray-500">{t("trips.completed")}</Text>
|
||
</View>
|
||
</Pressable>
|
||
);
|
||
}
|
||
|
||
// ── Create Trip Modal ─────────────────────────────────────────────────────────
|
||
|
||
function CreateTripModal({ onClose }: { onClose: () => void }) {
|
||
const { t } = useTranslation();
|
||
const { mutate: createTrip, isPending } = useCreateTrip();
|
||
|
||
const [name, setName] = useState("");
|
||
const [destination, setDestination] = useState("");
|
||
const [budgetStr, setBudgetStr] = useState("");
|
||
const [startDate, setStartDate] = useState("");
|
||
const [endDate, setEndDate] = useState("");
|
||
|
||
const canSave =
|
||
name.trim().length > 0 &&
|
||
parseFloat(budgetStr.replace(",", ".")) > 0 &&
|
||
startDate.length === 10 &&
|
||
endDate.length === 10;
|
||
|
||
function handleSave() {
|
||
const budget = parseFloat(budgetStr.replace(",", "."));
|
||
if (!canSave || isNaN(budget)) return;
|
||
|
||
const input: CreateTripInput = {
|
||
name: name.trim(),
|
||
budget,
|
||
startDate,
|
||
endDate,
|
||
...(destination.trim() ? { destination: destination.trim() } : {}),
|
||
};
|
||
|
||
createTrip(input, { onSuccess: onClose });
|
||
}
|
||
|
||
return (
|
||
<Modal visible animationType="slide" presentationStyle="pageSheet" onRequestClose={onClose}>
|
||
<View className="flex-1 bg-white">
|
||
<ModalHeader
|
||
title={t("trips.new")}
|
||
onClose={onClose}
|
||
closeLabel={t("common.cancel")}
|
||
onSave={handleSave}
|
||
saveLabel={t("common.create")}
|
||
saveDisabled={!canSave}
|
||
saveLoading={isPending}
|
||
saveColor={ACCENT}
|
||
/>
|
||
|
||
<ScrollView className="flex-1 px-4 pt-4" keyboardShouldPersistTaps="handled">
|
||
{/* Name */}
|
||
<Text className="text-xs font-medium text-gray-500 mb-1 uppercase tracking-wide">
|
||
{t("trips.name")} *
|
||
</Text>
|
||
<TextInput
|
||
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900 mb-4"
|
||
placeholder="z.B. Sommerurlaub Italien"
|
||
placeholderTextColor="#9ca3af"
|
||
value={name}
|
||
onChangeText={setName}
|
||
autoFocus
|
||
/>
|
||
|
||
{/* Destination */}
|
||
<Text className="text-xs font-medium text-gray-500 mb-1 uppercase tracking-wide">
|
||
{t("trips.destination")}
|
||
</Text>
|
||
<TextInput
|
||
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900 mb-4"
|
||
placeholder="z.B. Rom, Italien"
|
||
placeholderTextColor="#9ca3af"
|
||
value={destination}
|
||
onChangeText={setDestination}
|
||
/>
|
||
|
||
{/* Budget */}
|
||
<Text className="text-xs font-medium text-gray-500 mb-1 uppercase tracking-wide">
|
||
{t("trips.budget")} (€) *
|
||
</Text>
|
||
<TextInput
|
||
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900 mb-4"
|
||
placeholder="z.B. 2000"
|
||
placeholderTextColor="#9ca3af"
|
||
value={budgetStr}
|
||
onChangeText={setBudgetStr}
|
||
keyboardType="decimal-pad"
|
||
/>
|
||
|
||
{/* Start Date */}
|
||
<Text className="text-xs font-medium text-gray-500 mb-1 uppercase tracking-wide">
|
||
{t("trips.startDate")} *
|
||
</Text>
|
||
<TextInput
|
||
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900 mb-4"
|
||
placeholder="YYYY-MM-DD"
|
||
placeholderTextColor="#9ca3af"
|
||
value={startDate}
|
||
onChangeText={setStartDate}
|
||
keyboardType="numbers-and-punctuation"
|
||
maxLength={10}
|
||
/>
|
||
|
||
{/* End Date */}
|
||
<Text className="text-xs font-medium text-gray-500 mb-1 uppercase tracking-wide">
|
||
{t("trips.endDate")} *
|
||
</Text>
|
||
<TextInput
|
||
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900 mb-4"
|
||
placeholder="YYYY-MM-DD"
|
||
placeholderTextColor="#9ca3af"
|
||
value={endDate}
|
||
onChangeText={setEndDate}
|
||
keyboardType="numbers-and-punctuation"
|
||
maxLength={10}
|
||
/>
|
||
</ScrollView>
|
||
</View>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
// ── Main Screen ───────────────────────────────────────────────────────────────
|
||
|
||
type SectionItem =
|
||
| { type: "header"; label: string }
|
||
| { type: "active-trip"; trip: Trip }
|
||
| { type: "past-trip"; trip: Trip }
|
||
| { type: "empty" };
|
||
|
||
export default function UrlaubScreen() {
|
||
const insets = useSafeAreaInsets();
|
||
const router = useRouter();
|
||
const { t } = useTranslation();
|
||
const { data: trips = [], isLoading, refetch, isRefetching } = useTrips();
|
||
const [showCreate, setShowCreate] = useState(false);
|
||
|
||
const activeTrips = trips.filter((t) => t.status === "active");
|
||
const pastTrips = trips.filter((t) => t.status === "completed");
|
||
|
||
// Build flat list items for sectioned rendering
|
||
const listItems: SectionItem[] = [];
|
||
|
||
if (activeTrips.length > 0) {
|
||
listItems.push({ type: "header", label: t("trips.active") });
|
||
activeTrips.forEach((trip) => listItems.push({ type: "active-trip", trip }));
|
||
}
|
||
|
||
if (pastTrips.length > 0) {
|
||
listItems.push({ type: "header", label: t("trips.past") });
|
||
pastTrips.forEach((trip) => listItems.push({ type: "past-trip", trip }));
|
||
}
|
||
|
||
if (trips.length === 0 && !isLoading) {
|
||
listItems.push({ type: "empty" });
|
||
}
|
||
|
||
function renderItem({ item }: { item: SectionItem }) {
|
||
if (item.type === "header") {
|
||
return (
|
||
<Text className="text-xs font-semibold text-gray-400 uppercase tracking-wide px-4 pt-4 pb-2">
|
||
{item.label}
|
||
</Text>
|
||
);
|
||
}
|
||
|
||
if (item.type === "active-trip") {
|
||
return (
|
||
<ActiveTripCard
|
||
trip={item.trip}
|
||
onPress={() =>
|
||
router.push({
|
||
pathname: "/(app)/urlaub/[id]",
|
||
params: { id: item.trip.id },
|
||
})
|
||
}
|
||
/>
|
||
);
|
||
}
|
||
|
||
if (item.type === "past-trip") {
|
||
return (
|
||
<PastTripRow
|
||
trip={item.trip}
|
||
onPress={() =>
|
||
router.push({
|
||
pathname: "/(app)/urlaub/[id]",
|
||
params: { id: item.trip.id },
|
||
})
|
||
}
|
||
/>
|
||
);
|
||
}
|
||
|
||
// empty state
|
||
return (
|
||
<EmptyState
|
||
icon="airplane-outline"
|
||
title={t("trips.noTrips")}
|
||
subtitle={t("trips.noTripsHint")}
|
||
/>
|
||
);
|
||
}
|
||
|
||
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 justify-between"
|
||
>
|
||
<View className="flex-row items-center gap-2">
|
||
<Pressable onPress={() => router.push("/(app)/mehr")} className="mr-1 p-1">
|
||
<Ionicons name="chevron-back" size={22} color="#374151" />
|
||
</Pressable>
|
||
<Text className="text-xl font-bold text-gray-900">{t("trips.title")}</Text>
|
||
</View>
|
||
<Pressable
|
||
onPress={() => setShowCreate(true)}
|
||
className="w-9 h-9 rounded-full items-center justify-center active:opacity-70"
|
||
style={{ backgroundColor: ACCENT }}
|
||
>
|
||
<Ionicons name="add" size={22} color="#fff" />
|
||
</Pressable>
|
||
</View>
|
||
|
||
{isLoading ? (
|
||
<View className="flex-1 items-center justify-center">
|
||
<ActivityIndicator size="large" color={ACCENT} />
|
||
</View>
|
||
) : (
|
||
<FlatList
|
||
data={listItems}
|
||
keyExtractor={(item, index) => {
|
||
if (item.type === "header") return `header-${item.label}`;
|
||
if (item.type === "active-trip" || item.type === "past-trip")
|
||
return item.trip.id;
|
||
return `empty-${index}`;
|
||
}}
|
||
renderItem={renderItem}
|
||
contentContainerStyle={
|
||
trips.length === 0
|
||
? { flex: 1, paddingBottom: insets.bottom + 16 }
|
||
: { paddingBottom: insets.bottom + 16, paddingTop: 4 }
|
||
}
|
||
refreshing={isRefetching}
|
||
onRefresh={() => void refetch()}
|
||
/>
|
||
)}
|
||
|
||
{showCreate && <CreateTripModal onClose={() => setShowCreate(false)} />}
|
||
</View>
|
||
);
|
||
}
|