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

428 lines
14 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 { 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>
);
}