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>
This commit is contained in:
427
apps/native/app/(app)/urlaub/index.tsx
Normal file
427
apps/native/app/(app)/urlaub/index.tsx
Normal file
@@ -0,0 +1,427 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user