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:
121
apps/native/app/(app)/_layout.tsx
Normal file
121
apps/native/app/(app)/_layout.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useSession, authClient } from "@/src/lib/auth-client";
|
||||
import { useAuthStore } from "@/src/stores/auth.store";
|
||||
import { TAB_COLORS } from "@/src/constants/colors";
|
||||
import { apiRequest } from "@/src/lib/api-client";
|
||||
import { Redirect, Tabs, useRouter } from "expo-router";
|
||||
import React, { useEffect } from "react";
|
||||
import { Alert } from "react-native";
|
||||
import { queryClient } from "@/src/lib/query-client";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function PendingInvitationHandler() {
|
||||
const router = useRouter();
|
||||
const pendingInvitationId = useAuthStore((s) => s.pendingInvitationId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingInvitationId) return;
|
||||
|
||||
authClient.organization.acceptInvitation({ invitationId: pendingInvitationId })
|
||||
.then(async (result) => {
|
||||
useAuthStore.getState().setPendingInvitationId(null);
|
||||
if (result.error) {
|
||||
Alert.alert("Fehler", result.error.message ?? "Einladung konnte nicht angenommen werden.");
|
||||
return;
|
||||
}
|
||||
const householdsResponse = await apiRequest<{ households: { id: string; name: string; role: string }[] }>("/api/households");
|
||||
const newHouseholds = householdsResponse.households;
|
||||
useAuthStore.getState().setHouseholds(newHouseholds);
|
||||
if (newHouseholds[0] && !useAuthStore.getState().activeHouseholdId) {
|
||||
useAuthStore.getState().setActiveHousehold(newHouseholds[0].id);
|
||||
}
|
||||
await queryClient.invalidateQueries();
|
||||
Alert.alert("Einladung angenommen", "Du bist jetzt Mitglied des Haushalts.");
|
||||
router.replace("/(app)/haushalt");
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
useAuthStore.getState().setPendingInvitationId(null);
|
||||
Alert.alert("Fehler", err.message ?? "Einladung konnte nicht angenommen werden.");
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pendingInvitationId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function AppLayout() {
|
||||
const { data: session, isPending } = useSession();
|
||||
const households = useAuthStore((s) => s.households);
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (isPending) return null;
|
||||
if (!session) return <Redirect href="/(auth)/login" />;
|
||||
if (households.length === 0) return <Redirect href="/(auth)/onboarding" />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PendingInvitationHandler />
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
tabBarInactiveTintColor: "#9ca3af",
|
||||
headerShown: false,
|
||||
tabBarStyle: { borderTopColor: "#f3f4f6" },
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="haushalt/index"
|
||||
options={{
|
||||
title: t('tabs.household'),
|
||||
tabBarActiveTintColor: TAB_COLORS.household,
|
||||
tabBarIcon: ({ color, size }) => <Ionicons name="home-outline" size={size} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="ich/index"
|
||||
options={{
|
||||
title: t('tabs.me'),
|
||||
tabBarActiveTintColor: TAB_COLORS.private,
|
||||
tabBarIcon: ({ color, size }) => <Ionicons name="person-outline" size={size} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="kinder/index"
|
||||
options={{
|
||||
title: t('tabs.children'),
|
||||
tabBarActiveTintColor: TAB_COLORS.children,
|
||||
tabBarIcon: ({ color, size }) => <Ionicons name="happy-outline" size={size} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="shopping-list/index"
|
||||
options={{
|
||||
title: t('tabs.shopping'),
|
||||
tabBarActiveTintColor: TAB_COLORS.shopping,
|
||||
tabBarIcon: ({ color, size }) => <Ionicons name="cart-outline" size={size} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="mehr/index"
|
||||
options={{
|
||||
title: t('tabs.more'),
|
||||
tabBarActiveTintColor: TAB_COLORS.more,
|
||||
tabBarIcon: ({ color, size }) => <Ionicons name="grid-outline" size={size} color={color} />,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Hidden — not in tab bar */}
|
||||
<Tabs.Screen name="dashboard/index" options={{ href: null }} />
|
||||
<Tabs.Screen name="transactions/index" options={{ href: null }} />
|
||||
<Tabs.Screen name="urlaub/index" options={{ href: null }} />
|
||||
<Tabs.Screen name="urlaub/[id]" options={{ href: null }} />
|
||||
<Tabs.Screen name="settings/index" options={{ href: null }} />
|
||||
<Tabs.Screen name="settings/categories" options={{ href: null }} />
|
||||
<Tabs.Screen name="settings/fixed-costs" options={{ href: null }} />
|
||||
<Tabs.Screen name="settings/transfer-line-items" options={{ href: null }} />
|
||||
<Tabs.Screen name="settings/household" options={{ href: null }} />
|
||||
<Tabs.Screen name="months/close" options={{ href: null }} />
|
||||
<Tabs.Screen name="scanner" options={{ href: null }} />
|
||||
</Tabs>
|
||||
</>
|
||||
);
|
||||
}
|
||||
10
apps/native/app/(app)/dashboard/index.tsx
Normal file
10
apps/native/app/(app)/dashboard/index.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { PlaceholderScreen } from "@/src/components/features/PlaceholderScreen";
|
||||
|
||||
export default function DashboardScreen() {
|
||||
return (
|
||||
<PlaceholderScreen
|
||||
title="Dashboard"
|
||||
description="Your financial overview will appear here"
|
||||
/>
|
||||
);
|
||||
}
|
||||
647
apps/native/app/(app)/haushalt/index.tsx
Normal file
647
apps/native/app/(app)/haushalt/index.tsx
Normal file
@@ -0,0 +1,647 @@
|
||||
import { QuickAddModal } from "@/src/components/features/transactions/QuickAddModal";
|
||||
import { TransactionItem } from "@/src/components/features/transactions/TransactionItem";
|
||||
import { EditTransactionModal } from "@/src/components/features/transactions/EditTransactionModal";
|
||||
import { CarryOverBanner } from "@/src/components/features/transactions/CarryOverBanner";
|
||||
import { MonthSummaryHeader } from "@/src/components/features/transactions/MonthSummaryHeader";
|
||||
import { AddCategoryModal } from "@/src/components/features/categories/AddCategoryModal";
|
||||
import { ModalHeader } from "@/src/components/ui/ModalHeader";
|
||||
import { EmptyState } from "@/src/components/ui/EmptyState";
|
||||
import { Numpad } from "@/src/components/ui/Numpad";
|
||||
import { useTransactions, useActivateFixed, useMonthBalance, useDeleteTransaction } from "@/src/hooks/useTransactions";
|
||||
import type { TransactionWithCategory } from "@/src/hooks/useTransactions";
|
||||
import { useAuthStore } from "@/src/stores/auth.store";
|
||||
import { useSettlementV2, useCreateMonthlyTransfer, useNettoMonth, type MonthlyTransfer } from "@/src/hooks/useFixedCosts";
|
||||
import { useHouseholdSettings } from "@/src/hooks/useHouseholdSettings";
|
||||
import { useMonthStatus } from "@/src/hooks/useMonthStatus";
|
||||
import { currentMonthStr, addMonths, monthLabel, monthDateRange } from "@/src/utils/date";
|
||||
import { formatEur } from "@/src/utils/format";
|
||||
import { handleNumpadKey, parseAmountStr } from "@/src/utils/numpad";
|
||||
import { useRouter } from "expo-router";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Category } from "@/src/hooks/useCategories";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
FlatList,
|
||||
Modal,
|
||||
Pressable,
|
||||
RefreshControl,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
import { TAB_COLORS } from "@/src/constants/colors";
|
||||
|
||||
const ACCENT = TAB_COLORS.household;
|
||||
|
||||
// ── Record Transfer Modal ─────────────────────────────────────────────────────
|
||||
|
||||
function RecordTransferModal({
|
||||
month,
|
||||
toUserId,
|
||||
onClose,
|
||||
}: {
|
||||
month: string;
|
||||
toUserId: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [amountStr, setAmountStr] = useState("0");
|
||||
const [note, setNote] = useState("");
|
||||
const { t } = useTranslation();
|
||||
const { mutate: createTransfer, isPending } = useCreateMonthlyTransfer();
|
||||
|
||||
function handleNumpad(key: string) {
|
||||
setAmountStr((prev) => handleNumpadKey(prev, key));
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
const amount = parseAmountStr(amountStr);
|
||||
if (!amount || amount <= 0) return;
|
||||
createTransfer(
|
||||
{ month, toUserId, amount, note: note.trim() || undefined },
|
||||
{ onSuccess: onClose },
|
||||
);
|
||||
}
|
||||
|
||||
const canSave = parseAmountStr(amountStr) > 0;
|
||||
|
||||
return (
|
||||
<Modal visible animationType="slide" presentationStyle="pageSheet" onRequestClose={onClose}>
|
||||
<View className="flex-1 bg-white">
|
||||
<ModalHeader
|
||||
title={t('household.settlement.recordTransfer')}
|
||||
onClose={onClose}
|
||||
closeLabel={t('common.cancel')}
|
||||
onSave={handleSave}
|
||||
saveLabel={t('household.settlement.book')}
|
||||
saveDisabled={!canSave}
|
||||
saveLoading={isPending}
|
||||
saveColor={ACCENT}
|
||||
/>
|
||||
<View className="items-center py-6">
|
||||
<Text className="text-5xl font-bold text-gray-900">€ {amountStr}</Text>
|
||||
<Text className="text-sm text-gray-400 mt-1">{t('household.settlement.transferAmount')}</Text>
|
||||
</View>
|
||||
<View className="px-4 mb-4">
|
||||
<TextInput
|
||||
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900"
|
||||
placeholder={t('household.settlement.notePlaceholder')}
|
||||
value={note}
|
||||
onChangeText={setNote}
|
||||
/>
|
||||
</View>
|
||||
<Numpad onKeyPress={handleNumpad} />
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Settlement Banner V2 ──────────────────────────────────────────────────────
|
||||
|
||||
function SettlementBanner({ month, isCurrent }: { month: string; isCurrent: boolean }) {
|
||||
const userId = useAuthStore((s) => s.user?.id);
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const { data: settlement, isLoading } = useSettlementV2(month);
|
||||
const { data: hhSettings } = useHouseholdSettings();
|
||||
const { data: monthStatus } = useMonthStatus(month);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [showTransferModal, setShowTransferModal] = useState(false);
|
||||
const isClosed = monthStatus?.status === "closed";
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View className="mx-4 mt-4 mb-1 rounded-2xl bg-gray-100 p-4 items-center">
|
||||
<ActivityIndicator size="small" color="#9ca3af" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!settlement || settlement.memberCount <= 1) return null;
|
||||
|
||||
// Closed month — show lock banner
|
||||
if (isClosed && monthStatus) {
|
||||
const closedDate = monthStatus.closedAt
|
||||
? new Date(monthStatus.closedAt).toLocaleDateString("de-DE", { day: "numeric", month: "long", year: "numeric" })
|
||||
: "";
|
||||
return (
|
||||
<View
|
||||
className="mx-4 mt-4 mb-1 rounded-2xl px-4 py-3 flex-row items-center gap-3"
|
||||
style={{ backgroundColor: "#f0fdf4", borderWidth: 1, borderColor: "#bbf7d0" }}
|
||||
>
|
||||
<Ionicons name="lock-closed" size={18} color="#16a34a" />
|
||||
<View className="flex-1">
|
||||
<Text className="text-xs text-gray-400">{t('household.settlement.closed')}</Text>
|
||||
<Text className="text-sm font-semibold text-green-700">
|
||||
✓ {closedDate}
|
||||
{monthStatus.finalAmount != null && monthStatus.finalAmount > 0
|
||||
? ` · ${formatEur(monthStatus.finalAmount)}`
|
||||
: ""}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const remaining = settlement.remaining;
|
||||
const isOwing = remaining > 0.005;
|
||||
const isReceiving = remaining < -0.005;
|
||||
const isEven = !isOwing && !isReceiving;
|
||||
|
||||
const bannerBg = isOwing ? "#fff7ed" : isReceiving ? "#f0fdf4" : "#f9fafb";
|
||||
const bannerBorder = isOwing ? "#fed7aa" : isReceiving ? "#bbf7d0" : "#e5e7eb";
|
||||
const amountColor = isOwing ? "#ea580c" : isReceiving ? "#16a34a" : "#6b7280";
|
||||
|
||||
const others = settlement.members.filter((m) => m.userId !== userId);
|
||||
const otherName = hhSettings?.partnerName ?? others[0]?.name ?? "den anderen";
|
||||
const otherUserId = others[0]?.userId ?? "";
|
||||
|
||||
let mainText = t('household.settlement.allSettled');
|
||||
if (isOwing) mainText = t('household.settlement.youOwe', { name: otherName });
|
||||
else if (isReceiving) mainText = t('household.settlement.theyOwe', { name: otherName });
|
||||
|
||||
return (
|
||||
<>
|
||||
<View
|
||||
style={{ backgroundColor: bannerBg, borderColor: bannerBorder, borderWidth: 1 }}
|
||||
className="mx-4 mt-4 mb-1 rounded-2xl overflow-hidden"
|
||||
>
|
||||
{/* Summary row */}
|
||||
<Pressable
|
||||
onPress={() => setExpanded((v) => !v)}
|
||||
className="flex-row items-center px-4 py-3 active:opacity-80"
|
||||
>
|
||||
<View className="flex-1">
|
||||
<Text className="text-xs text-gray-400 mb-0.5">{t('household.settlement.monthlySettlement')}</Text>
|
||||
<Text className="text-sm font-medium" style={{ color: amountColor }}>{mainText}</Text>
|
||||
{!isEven && (
|
||||
<Text className="text-3xl font-bold" style={{ color: amountColor }}>
|
||||
{formatEur(Math.abs(remaining))}
|
||||
</Text>
|
||||
)}
|
||||
{isEven && (
|
||||
<Text className="text-base font-bold text-green-600">{t('household.settlement.allSettled')}</Text>
|
||||
)}
|
||||
</View>
|
||||
<Ionicons name={expanded ? "chevron-up" : "chevron-down"} size={14} color="#9ca3af" />
|
||||
</Pressable>
|
||||
|
||||
{/* Expandable detail */}
|
||||
{expanded && (
|
||||
<View style={{ backgroundColor: "rgba(0,0,0,0.03)" }} className="px-4 pb-4">
|
||||
{/* Haushalt breakdown */}
|
||||
<View className="py-2 gap-1.5">
|
||||
<View className="flex-row justify-between">
|
||||
<Text className="text-xs text-gray-500">{t('household.settlement.householdExpenses')}</Text>
|
||||
<Text className="text-xs font-medium text-gray-700">{formatEur(settlement.householdExpenses)}</Text>
|
||||
</View>
|
||||
{settlement.householdIncome > 0 && (
|
||||
<View className="flex-row justify-between">
|
||||
<Text className="text-xs text-gray-500">{t('household.settlement.householdIncome')}</Text>
|
||||
<Text className="text-xs font-medium text-green-600">−{formatEur(settlement.householdIncome)}</Text>
|
||||
</View>
|
||||
)}
|
||||
<View className="flex-row justify-between">
|
||||
<Text className="text-xs font-semibold text-gray-600">{t('household.settlement.yourShare', { percent: settlement.userSharePercent ?? 50 })}</Text>
|
||||
<Text className="text-xs font-semibold text-gray-800">{formatEur(settlement.perMemberShare)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Who paid what */}
|
||||
<View className="h-px bg-gray-200 my-2" />
|
||||
<View className="gap-1.5">
|
||||
{settlement.members.map((mem) => {
|
||||
const isMe = mem.userId === userId;
|
||||
const name = isMe ? "Du" : otherName;
|
||||
const paidAmount = isMe ? settlement.myOwnExpenses : (settlement.householdExpenses - settlement.myOwnExpenses);
|
||||
if (paidAmount < 0.01) return null;
|
||||
return (
|
||||
<View key={mem.userId} className="flex-row justify-between">
|
||||
<Text className="text-xs text-gray-500">{t('household.settlement.paidBy', { name })}</Text>
|
||||
<Text className="text-xs font-medium text-gray-700">{formatEur(paidAmount)}</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{/* Fixed transfer items — summarised */}
|
||||
{settlement.lineItemsTotal > 0 && (
|
||||
<>
|
||||
<View className="h-px bg-gray-200 my-2" />
|
||||
<View className="flex-row justify-between">
|
||||
<Text className="text-xs text-gray-500">+ {t('household.settlement.fixedTransfers')}</Text>
|
||||
<Text className="text-xs font-medium text-gray-700">{formatEur(settlement.lineItemsTotal)}</Text>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Total owed */}
|
||||
<View className="h-px bg-gray-200 my-2" />
|
||||
<View className="flex-row justify-between mb-3">
|
||||
<Text className="text-xs font-bold text-gray-700">{t('household.settlement.toTransfer')}</Text>
|
||||
<Text className="text-xs font-bold" style={{ color: amountColor }}>{formatEur(settlement.totalOwed)}</Text>
|
||||
</View>
|
||||
|
||||
{/* Already transferred */}
|
||||
<View className="flex-row items-center justify-between">
|
||||
<View>
|
||||
<Text className="text-xs text-gray-400">{t('household.settlement.alreadyTransferred')}</Text>
|
||||
<Text className="text-sm font-semibold text-gray-700">{formatEur(settlement.alreadyTransferred)}</Text>
|
||||
</View>
|
||||
{isOwing && (
|
||||
<Pressable
|
||||
onPress={() => setShowTransferModal(true)}
|
||||
style={{ backgroundColor: ACCENT }}
|
||||
className="px-4 py-2 rounded-xl active:opacity-80"
|
||||
>
|
||||
<Text className="text-xs font-semibold text-white">+ {t('household.settlement.book')}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Close month button */}
|
||||
{isCurrent && (
|
||||
<Pressable
|
||||
onPress={() => router.push({ pathname: "/(app)/months/close", params: { month } })}
|
||||
className="mt-3 flex-row items-center justify-center gap-2 py-2.5 rounded-xl active:opacity-80"
|
||||
style={{ backgroundColor: "#f3f4f6", borderWidth: 1, borderColor: "#e5e7eb" }}
|
||||
>
|
||||
<Ionicons name="lock-closed-outline" size={14} color="#6b7280" />
|
||||
<Text className="text-xs font-semibold text-gray-600">{t('household.settlement.closeMonth')}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
{/* Transfer history */}
|
||||
{settlement.transfers.length > 0 && (
|
||||
<View className="mt-3 gap-1">
|
||||
{settlement.transfers.map((t: MonthlyTransfer) => (
|
||||
<View key={t.id} className="flex-row justify-between">
|
||||
<Text className="text-xs text-gray-400">
|
||||
{new Date(t.createdAt).toLocaleDateString("de-DE", { day: "numeric", month: "short" })}
|
||||
{t.note ? ` · ${t.note}` : ""}
|
||||
</Text>
|
||||
<Text className="text-xs font-medium text-gray-600">{formatEur(t.amount)}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{showTransferModal && (
|
||||
<RecordTransferModal
|
||||
month={month}
|
||||
toUserId={otherUserId}
|
||||
onClose={() => setShowTransferModal(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Netto Card ────────────────────────────────────────────────────────────────
|
||||
|
||||
function NettoCard({ month }: { month: string }) {
|
||||
const { data, isLoading } = useNettoMonth(month);
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View className="mx-4 mt-2 mb-1 rounded-2xl bg-white p-4 items-center" style={{ borderWidth: 1, borderColor: "#f3f4f6" }}>
|
||||
<ActivityIndicator size="small" color="#9ca3af" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const isPositive = data.netto >= 0;
|
||||
const nettoColor = isPositive ? "#16a34a" : "#dc2626";
|
||||
const nettoIcon = isPositive ? "trending-up" : "trending-down";
|
||||
|
||||
return (
|
||||
<View
|
||||
className="mx-4 mt-2 mb-1 rounded-2xl bg-white overflow-hidden"
|
||||
style={{ borderWidth: 1, borderColor: "#f3f4f6" }}
|
||||
>
|
||||
<Pressable
|
||||
onPress={() => setExpanded((v) => !v)}
|
||||
className="flex-row items-center px-4 py-3 active:opacity-80"
|
||||
>
|
||||
<View
|
||||
className="w-9 h-9 rounded-xl items-center justify-center mr-3"
|
||||
style={{ backgroundColor: isPositive ? "#dcfce7" : "#fee2e2" }}
|
||||
>
|
||||
<Ionicons name={nettoIcon as React.ComponentProps<typeof Ionicons>["name"]} size={18} color={nettoColor} />
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="text-xs text-gray-400">{t('household.nettoMonth')}</Text>
|
||||
<Text className="text-xl font-bold" style={{ color: nettoColor }}>
|
||||
{isPositive ? "+" : "−"}{formatEur(Math.abs(data.netto))}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="items-end mr-2">
|
||||
<Text className="text-xs text-gray-400">{t('household.income')}</Text>
|
||||
<Text className="text-sm font-semibold text-green-600">+{formatEur(data.totalIncome)}</Text>
|
||||
</View>
|
||||
<Ionicons name={expanded ? "chevron-up" : "chevron-down"} size={14} color="#9ca3af" />
|
||||
</Pressable>
|
||||
|
||||
{expanded && (
|
||||
<View className="px-4 pb-4" style={{ backgroundColor: "rgba(0,0,0,0.02)" }}>
|
||||
{/* Income breakdown */}
|
||||
{data.incomeByScope.length > 0 ? (
|
||||
<>
|
||||
<Text className="text-xs font-medium text-gray-400 mb-2">Einnahmen nach Bereich</Text>
|
||||
{data.incomeByScope.map((s) => (
|
||||
<View key={s.scope} className="flex-row justify-between mb-1">
|
||||
<Text className="text-xs text-gray-500">{s.label}</Text>
|
||||
<Text className="text-xs font-medium text-green-600">+{formatEur(s.amount)}</Text>
|
||||
</View>
|
||||
))}
|
||||
<View className="h-px bg-gray-100 my-2" />
|
||||
</>
|
||||
) : (
|
||||
<Text className="text-xs text-gray-400 mb-2">Keine Einnahmen gebucht</Text>
|
||||
)}
|
||||
|
||||
{/* Expenses */}
|
||||
<View className="flex-row justify-between mb-1">
|
||||
<Text className="text-xs text-gray-500">Ausgaben (alle Bereiche)</Text>
|
||||
<Text className="text-xs font-medium text-red-500">−{formatEur(data.totalExpenses)}</Text>
|
||||
</View>
|
||||
|
||||
<View className="h-px bg-gray-100 my-2" />
|
||||
<View className="flex-row justify-between">
|
||||
<Text className="text-xs font-bold text-gray-700">Netto</Text>
|
||||
<Text className="text-xs font-bold" style={{ color: nettoColor }}>
|
||||
{isPositive ? "+" : "−"}{formatEur(Math.abs(data.netto))}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Month Switcher ─────────────────────────────────────────────────────────────
|
||||
|
||||
function MonthSwitcher({
|
||||
month,
|
||||
isLocked,
|
||||
onPrev,
|
||||
onNext,
|
||||
}: {
|
||||
month: string;
|
||||
isLocked: boolean;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
}) {
|
||||
const isCurrent = month === currentMonthStr();
|
||||
return (
|
||||
<View className="flex-row items-center justify-center gap-4 py-3">
|
||||
<Pressable onPress={onPrev} className="p-1 active:opacity-50">
|
||||
<Ionicons name="chevron-back" size={18} color="#6b7280" />
|
||||
</Pressable>
|
||||
<View className="flex-row items-center gap-1.5 w-32 justify-center">
|
||||
{isLocked && <Ionicons name="lock-closed" size={12} color="#9ca3af" />}
|
||||
<Text className="text-sm font-semibold text-gray-800 text-center">
|
||||
{monthLabel(month)}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={onNext}
|
||||
disabled={isCurrent}
|
||||
className="p-1 active:opacity-50"
|
||||
style={{ opacity: isCurrent ? 0.3 : 1 }}
|
||||
>
|
||||
<Ionicons name="chevron-forward" size={18} color="#6b7280" />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Screen ───────────────────────────────────────────────────────────────
|
||||
|
||||
type FilterType = "all" | "income" | "expense";
|
||||
|
||||
export default function HaushaltScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [month, setMonth] = useState(currentMonthStr());
|
||||
const [filter, setFilter] = useState<FilterType>("all");
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [showFabMenu, setShowFabMenu] = useState(false);
|
||||
const [showAddCategory, setShowAddCategory] = useState(false);
|
||||
const [addCategoryType, setAddCategoryType] = useState<"expense" | "income">("expense");
|
||||
const [newCategory, setNewCategory] = useState<Category | null>(null);
|
||||
const [editTransaction, setEditTransaction] = useState<TransactionWithCategory | null>(null);
|
||||
const { mutate: deleteTransaction } = useDeleteTransaction();
|
||||
const { data: monthStatus } = useMonthStatus(month);
|
||||
const isLocked = monthStatus?.status === "closed";
|
||||
|
||||
const isCurrent = month === currentMonthStr();
|
||||
const { mutate: activateFixed } = useActivateFixed();
|
||||
useEffect(() => {
|
||||
if (isCurrent) {
|
||||
activateFixed({ month, scope: "household" });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [month]);
|
||||
|
||||
const [fromDate, toDate] = monthDateRange(month);
|
||||
|
||||
const txFilter = {
|
||||
scope: "household" as const,
|
||||
from: fromDate,
|
||||
to: toDate,
|
||||
...(filter !== "all" ? { type: filter as "income" | "expense" } : {}),
|
||||
};
|
||||
|
||||
const { data: transactions = [], isLoading, refetch, isRefetching } = useTransactions(txFilter);
|
||||
const { data: balance, isLoading: balanceLoading } = useMonthBalance("household", month);
|
||||
|
||||
function renderEmpty() {
|
||||
if (isLoading) return null;
|
||||
return (
|
||||
<EmptyState
|
||||
icon="wallet-outline"
|
||||
title={t('household.noTransactions')}
|
||||
subtitle={t('household.noTransactionsHint')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-gray-50">
|
||||
{/* Header */}
|
||||
<View style={{ backgroundColor: "#fff", paddingTop: insets.top, borderBottomWidth: 1, borderBottomColor: "#f3f4f6" }}>
|
||||
<MonthSwitcher
|
||||
month={month}
|
||||
isLocked={isLocked}
|
||||
onPrev={() => setMonth((m) => addMonths(m, -1))}
|
||||
onNext={() => setMonth((m) => addMonths(m, 1))}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<FlatList
|
||||
data={transactions}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item }) => (
|
||||
<View className="bg-white">
|
||||
<TransactionItem
|
||||
transaction={item}
|
||||
onPress={isLocked ? () => {} : setEditTransaction}
|
||||
onDelete={isLocked ? () => {} : (t) => deleteTransaction(t.id)}
|
||||
locked={isLocked}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
ListHeaderComponent={
|
||||
<View className="bg-gray-50">
|
||||
<SettlementBanner month={month} isCurrent={isCurrent} />
|
||||
<NettoCard month={month} />
|
||||
|
||||
<MonthSummaryHeader
|
||||
income={balance?.income}
|
||||
expense={balance?.expense}
|
||||
balance={balance?.balance}
|
||||
isLoading={balanceLoading}
|
||||
accentColor={ACCENT}
|
||||
/>
|
||||
|
||||
<CarryOverBanner month={month} scope="household" />
|
||||
|
||||
{/* Filter Bar */}
|
||||
<View className="flex-row px-4 py-3 gap-2 bg-white border-b border-gray-100 mt-3">
|
||||
{(["all", "expense", "income"] as const).map((f) => (
|
||||
<Pressable
|
||||
key={f}
|
||||
onPress={() => setFilter(f)}
|
||||
style={{ backgroundColor: filter === f ? ACCENT : "#f3f4f6" }}
|
||||
className="px-4 py-1.5 rounded-full"
|
||||
>
|
||||
<Text
|
||||
className="text-sm font-medium"
|
||||
style={{ color: filter === f ? "#fff" : "#4b5563" }}
|
||||
>
|
||||
{f === "all" ? t('household.all') : f === "expense" ? t('household.expenses') : t('household.income')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
}
|
||||
ListEmptyComponent={renderEmpty}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isRefetching}
|
||||
onRefresh={() => void refetch()}
|
||||
tintColor={ACCENT}
|
||||
/>
|
||||
}
|
||||
ItemSeparatorComponent={() => <View className="h-px bg-gray-50 ml-16" />}
|
||||
contentContainerStyle={transactions.length === 0 ? { flex: 1 } : undefined}
|
||||
/>
|
||||
|
||||
{isLoading && (
|
||||
<View className="absolute inset-0 items-center justify-center">
|
||||
<ActivityIndicator size="large" color={ACCENT} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* FAB — hidden for locked months */}
|
||||
{!isLocked && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
{showFabMenu && (
|
||||
<Pressable
|
||||
className="absolute inset-0"
|
||||
style={{ backgroundColor: "rgba(0,0,0,0.25)" }}
|
||||
onPress={() => setShowFabMenu(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* FAB menu — anchored above FAB, zIndex above backdrop */}
|
||||
{showFabMenu && (
|
||||
<View
|
||||
className="absolute right-6 bg-white rounded-2xl overflow-hidden"
|
||||
style={{
|
||||
bottom: insets.bottom + 80,
|
||||
minWidth: 200,
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.18,
|
||||
shadowRadius: 16,
|
||||
shadowOffset: { width: 0, height: 6 },
|
||||
elevation: 12,
|
||||
zIndex: 100,
|
||||
}}
|
||||
>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
setShowFabMenu(false);
|
||||
setShowAddModal(true);
|
||||
}}
|
||||
className="flex-row items-center gap-3 px-5 py-4 active:bg-gray-50"
|
||||
>
|
||||
<Ionicons name="pencil-outline" size={20} color={ACCENT} />
|
||||
<Text className="text-sm font-medium text-gray-800">
|
||||
{t("scanner.manualEntry")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<View className="h-px bg-gray-100" />
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
setShowFabMenu(false);
|
||||
router.push("/(app)/scanner");
|
||||
}}
|
||||
className="flex-row items-center gap-3 px-5 py-4 active:bg-gray-50"
|
||||
>
|
||||
<Ionicons name="camera-outline" size={20} color={ACCENT} />
|
||||
<Text className="text-sm font-medium text-gray-800">
|
||||
{t("scanner.scanReceipt")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* FAB button */}
|
||||
<Pressable
|
||||
onPress={() => setShowFabMenu((v) => !v)}
|
||||
style={{ backgroundColor: ACCENT, bottom: insets.bottom + 20, zIndex: 101 }}
|
||||
className="absolute right-6 w-14 h-14 rounded-full items-center justify-center shadow-lg active:opacity-80"
|
||||
>
|
||||
<Ionicons name={showFabMenu ? "close" : "add"} size={28} color="#fff" />
|
||||
</Pressable>
|
||||
</>
|
||||
)}
|
||||
|
||||
<QuickAddModal
|
||||
visible={showAddModal}
|
||||
onClose={() => { setShowAddModal(false); setNewCategory(null); }}
|
||||
onRequestAddCategory={(t) => { setAddCategoryType(t); setShowAddModal(false); setShowAddCategory(true); }}
|
||||
newCategory={newCategory}
|
||||
defaultScope="household"
|
||||
/>
|
||||
<AddCategoryModal
|
||||
visible={showAddCategory}
|
||||
onClose={() => { setShowAddCategory(false); setShowAddModal(true); }}
|
||||
defaultType={addCategoryType}
|
||||
onCreated={(cat) => { setNewCategory(cat); setShowAddCategory(false); setShowAddModal(true); }}
|
||||
/>
|
||||
{editTransaction && (
|
||||
<EditTransactionModal
|
||||
transaction={editTransaction}
|
||||
onClose={() => setEditTransaction(null)}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
23
apps/native/app/(app)/ich/index.tsx
Normal file
23
apps/native/app/(app)/ich/index.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { TransactionScreen } from "@/src/components/features/transactions/TransactionScreen";
|
||||
import { DebtsSection } from "@/src/components/features/debts/DebtsSection";
|
||||
import { ClaimsSection } from "@/src/components/features/debts/ClaimsSection";
|
||||
import { View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function IchScreen() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<TransactionScreen
|
||||
scope="private"
|
||||
accentColor="#7c3aed"
|
||||
emptyTitle={t('me.noTransactions')}
|
||||
emptySubtitle={t('me.noTransactionsHint')}
|
||||
headerExtra={
|
||||
<View>
|
||||
<DebtsSection />
|
||||
<ClaimsSection />
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
234
apps/native/app/(app)/kinder/index.tsx
Normal file
234
apps/native/app/(app)/kinder/index.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Modal,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { TransactionScreen } from "@/src/components/features/transactions/TransactionScreen";
|
||||
import { useChildren, useCreateChild, type Child } from "@/src/hooks/useChildren";
|
||||
import { ModalHeader } from "@/src/components/ui/ModalHeader";
|
||||
|
||||
const CHILD_COLORS = [
|
||||
"#ec4899",
|
||||
"#f59e0b",
|
||||
"#10b981",
|
||||
"#2563EB",
|
||||
"#7c3aed",
|
||||
"#ef4444",
|
||||
"#0ea5e9",
|
||||
"#378ADD",
|
||||
];
|
||||
|
||||
function AddChildModal({
|
||||
visible,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onCreated: (child: Child) => void;
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
const [color, setColor] = useState(CHILD_COLORS[0]!);
|
||||
const { t } = useTranslation();
|
||||
const { mutate: createChild, isPending } = useCreateChild();
|
||||
|
||||
function handleSave() {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
createChild(
|
||||
{ name: trimmed, color },
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
onCreated(data.child);
|
||||
setName("");
|
||||
setColor(CHILD_COLORS[0]!);
|
||||
onClose();
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
setName("");
|
||||
setColor(CHILD_COLORS[0]!);
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide" presentationStyle="pageSheet" onRequestClose={handleClose}>
|
||||
<View className="flex-1 bg-white">
|
||||
{/* Header */}
|
||||
<ModalHeader
|
||||
title={t('children.addChild')}
|
||||
onClose={handleClose}
|
||||
closeLabel={t('common.cancel')}
|
||||
onSave={handleSave}
|
||||
saveLabel={t('common.save')}
|
||||
saveDisabled={!name.trim()}
|
||||
saveLoading={isPending}
|
||||
saveColor="#ec4899"
|
||||
/>
|
||||
|
||||
<View className="px-4 mt-6">
|
||||
{/* Name Input */}
|
||||
<Text className="text-sm font-medium text-gray-700 mb-2">Name</Text>
|
||||
<TextInput
|
||||
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900 mb-6"
|
||||
placeholder="z.B. Emma"
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
{/* Color Picker */}
|
||||
<Text className="text-sm font-medium text-gray-700 mb-3">Farbe</Text>
|
||||
<View className="flex-row flex-wrap gap-3">
|
||||
{CHILD_COLORS.map((c) => (
|
||||
<Pressable
|
||||
key={c}
|
||||
onPress={() => setColor(c)}
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: c,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderWidth: color === c ? 3 : 0,
|
||||
borderColor: "#fff",
|
||||
shadowColor: color === c ? c : "transparent",
|
||||
shadowOpacity: color === c ? 0.5 : 0,
|
||||
shadowRadius: 4,
|
||||
elevation: color === c ? 4 : 0,
|
||||
}}
|
||||
>
|
||||
{color === c && (
|
||||
<Ionicons name="checkmark" size={20} color="#fff" />
|
||||
)}
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default function KinderScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t } = useTranslation();
|
||||
const { data: children = [], isLoading } = useChildren();
|
||||
const [activeChildId, setActiveChildId] = useState<string | null>(null);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
|
||||
// Determine active child — fall back to first child when list loads
|
||||
const activeChild =
|
||||
children.find((c) => c.id === activeChildId) ?? children[0] ?? null;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View className="flex-1 bg-white items-center justify-center">
|
||||
<ActivityIndicator size="large" color="#ec4899" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-white">
|
||||
{/* Empty State */}
|
||||
{children.length === 0 && (
|
||||
<View className="flex-1 items-center justify-center px-8">
|
||||
<Ionicons name="happy-outline" size={72} color="#d1d5db" style={{ marginBottom: 16 }} />
|
||||
<Text className="text-lg font-semibold text-gray-700 mb-2 text-center">
|
||||
{t('children.noChildren')}
|
||||
</Text>
|
||||
<Text className="text-sm text-gray-400 text-center mb-8">
|
||||
{t('children.noChildrenHint')}
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={() => setShowAddModal(true)}
|
||||
className="px-6 py-3 rounded-full items-center justify-center"
|
||||
style={{ backgroundColor: "#ec4899" }}
|
||||
>
|
||||
<Text className="text-white font-semibold text-base">+ {t('children.addChild')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Children Tab Switcher + Content */}
|
||||
{children.length > 0 && activeChild && (
|
||||
<TransactionScreen
|
||||
scope="child"
|
||||
childId={activeChild.id}
|
||||
accentColor={activeChild.color}
|
||||
emptyTitle={t('children.noTransactions', { name: activeChild.name })}
|
||||
emptySubtitle={t('children.noTransactionsHint')}
|
||||
headerExtra={
|
||||
<View className="bg-white border-b border-gray-100">
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ paddingHorizontal: 12, paddingVertical: 10, gap: 8, flexDirection: "row", alignItems: "center" }}
|
||||
>
|
||||
{children.map((child) => {
|
||||
const isActive = child.id === activeChild.id;
|
||||
return (
|
||||
<Pressable
|
||||
key={child.id}
|
||||
onPress={() => setActiveChildId(child.id)}
|
||||
style={{
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 7,
|
||||
borderRadius: 20,
|
||||
backgroundColor: isActive ? child.color : "#f3f4f6",
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
color: isActive ? "#fff" : "#4b5563",
|
||||
}}
|
||||
>
|
||||
{child.name}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Add Child Button */}
|
||||
<Pressable
|
||||
onPress={() => setShowAddModal(true)}
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: "#fce7f3",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Ionicons name="add" size={20} color="#ec4899" />
|
||||
</Pressable>
|
||||
</ScrollView>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AddChildModal
|
||||
visible={showAddModal}
|
||||
onClose={() => setShowAddModal(false)}
|
||||
onCreated={(child) => setActiveChildId(child.id)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
68
apps/native/app/(app)/mehr/index.tsx
Normal file
68
apps/native/app/(app)/mehr/index.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Pressable, ScrollView, Text, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function MehrScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
|
||||
type MenuItem = {
|
||||
icon: React.ComponentProps<typeof Ionicons>["name"];
|
||||
label: string;
|
||||
subtitle: string;
|
||||
color: string;
|
||||
route: string;
|
||||
};
|
||||
|
||||
const MENU_ITEMS: MenuItem[] = [
|
||||
{
|
||||
icon: "airplane-outline",
|
||||
label: t('mehr.vacation'),
|
||||
subtitle: t('mehr.vacationSubtitle'),
|
||||
color: "#0ea5e9",
|
||||
route: "/(app)/urlaub",
|
||||
},
|
||||
{
|
||||
icon: "settings-outline",
|
||||
label: t('settings.title'),
|
||||
subtitle: t('mehr.settingsSubtitle'),
|
||||
color: "#6b7280",
|
||||
route: "/(app)/settings",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
className="flex-1 bg-gray-50"
|
||||
contentContainerStyle={{ paddingTop: insets.top + 16, paddingBottom: insets.bottom + 24 }}
|
||||
>
|
||||
<Text className="text-2xl font-bold text-gray-900 px-4 mb-6">{t('tabs.more')}</Text>
|
||||
|
||||
<View className="mx-4 bg-white rounded-2xl overflow-hidden" style={{ borderWidth: 1, borderColor: "#f3f4f6" }}>
|
||||
{MENU_ITEMS.map((item, index) => (
|
||||
<Pressable
|
||||
key={item.route}
|
||||
onPress={() => router.push(item.route as Parameters<typeof router.push>[0])}
|
||||
className="flex-row items-center px-4 py-4 active:bg-gray-50"
|
||||
style={index < MENU_ITEMS.length - 1 ? { borderBottomWidth: 1, borderBottomColor: "#f3f4f6" } : undefined}
|
||||
>
|
||||
<View
|
||||
className="w-10 h-10 rounded-xl items-center justify-center mr-4"
|
||||
style={{ backgroundColor: `${item.color}18` }}
|
||||
>
|
||||
<Ionicons name={item.icon} size={22} color={item.color} />
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="text-base font-semibold text-gray-900">{item.label}</Text>
|
||||
<Text className="text-xs text-gray-400 mt-0.5">{item.subtitle}</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={16} color="#d1d5db" />
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
243
apps/native/app/(app)/months/close.tsx
Normal file
243
apps/native/app/(app)/months/close.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useSettlementV2 } from "@/src/hooks/useFixedCosts";
|
||||
import { useHouseholdSettings } from "@/src/hooks/useHouseholdSettings";
|
||||
import { useCloseMonth } from "@/src/hooks/useMonthStatus";
|
||||
import { useAuthStore } from "@/src/stores/auth.store";
|
||||
import { monthLabel } from "@/src/utils/date";
|
||||
import { formatEur } from "@/src/utils/format";
|
||||
|
||||
const ACCENT = "#2563EB";
|
||||
|
||||
export default function CloseMonthScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const { month } = useLocalSearchParams<{ month: string }>();
|
||||
const userId = useAuthStore((s) => s.user?.id);
|
||||
|
||||
const { data: settlement, isLoading: settlementLoading } = useSettlementV2(month);
|
||||
const { data: hhSettings } = useHouseholdSettings();
|
||||
const { mutate: closeMonth, isPending } = useCloseMonth(month);
|
||||
|
||||
const remaining = settlement?.remaining ?? 0;
|
||||
const [amountStr, setAmountStr] = useState<string | null>(null);
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
// Lazy-init amount from settlement once loaded
|
||||
const displayAmount = amountStr ?? (remaining > 0 ? remaining.toFixed(2).replace(".", ",") : "0,00");
|
||||
|
||||
const others = (settlement?.members ?? []).filter((m) => m.userId !== userId);
|
||||
const otherName = hhSettings?.partnerName ?? others[0]?.name ?? "Partner";
|
||||
const otherUserId = others[0]?.userId ?? "";
|
||||
|
||||
function handleAmountChange(text: string) {
|
||||
// Allow only digits and one comma
|
||||
const cleaned = text.replace(/[^0-9,]/g, "");
|
||||
const parts = cleaned.split(",");
|
||||
if (parts.length > 2) return;
|
||||
if (parts[1] !== undefined && parts[1].length > 2) return;
|
||||
setAmountStr(cleaned);
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
const amount = parseFloat(displayAmount.replace(",", ".")) || 0;
|
||||
|
||||
Alert.alert(
|
||||
t('monthClose.closeConfirmTitle', { month: monthLabel(month) }),
|
||||
t('monthClose.closeConfirmMessage'),
|
||||
[
|
||||
{ text: t('common.cancel'), style: "cancel" },
|
||||
{
|
||||
text: t('monthClose.closeConfirmAction'),
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
closeMonth(
|
||||
{ finalAmount: amount, toUserId: otherUserId, notes: notes.trim() || undefined },
|
||||
{
|
||||
onSuccess: () => router.back(),
|
||||
onError: (err) =>
|
||||
Alert.alert(t('common.error'), err.message ?? "Abschluss fehlgeschlagen"),
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
if (settlementLoading) {
|
||||
return (
|
||||
<View className="flex-1 bg-gray-50 items-center justify-center">
|
||||
<ActivityIndicator size="large" color={ACCENT} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const s = settlement;
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-gray-50">
|
||||
{/* Header */}
|
||||
<View
|
||||
className="bg-white border-b border-gray-100"
|
||||
style={{ paddingTop: insets.top }}
|
||||
>
|
||||
<View className="flex-row items-center px-4 py-3">
|
||||
<Pressable onPress={() => router.back()} className="mr-3 p-1">
|
||||
<Ionicons name="chevron-back" size={22} color="#374151" />
|
||||
</Pressable>
|
||||
<Text className="text-base font-semibold text-gray-900 flex-1">
|
||||
{t('monthClose.title', { month: monthLabel(month) })}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{ padding: 16, paddingBottom: insets.bottom + 80 }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* Overview card */}
|
||||
{s && (
|
||||
<View className="bg-white rounded-2xl p-4 mb-4" style={{ borderWidth: 1, borderColor: "#f3f4f6" }}>
|
||||
<Text className="text-xs font-medium uppercase text-gray-400 mb-3">{t('monthClose.overview')}</Text>
|
||||
|
||||
<Row label={t('monthClose.householdTotal')} value={`-${formatEur(s.householdExpenses)}`} />
|
||||
{s.householdIncome > 0 && (
|
||||
<Row label={t('monthClose.householdIncome')} value={`+${formatEur(s.householdIncome)}`} color="#16a34a" />
|
||||
)}
|
||||
<Row
|
||||
label={t('monthClose.yourShare', { percent: s.memberCount > 0 ? Math.round(100 / s.memberCount) : 50 })}
|
||||
value={`-${formatEur(s.perMemberShare)}`}
|
||||
bold
|
||||
/>
|
||||
|
||||
{s.lineItems.length > 0 && (
|
||||
<>
|
||||
<View className="h-px bg-gray-100 my-2" />
|
||||
{s.lineItems.map((li) => (
|
||||
<Row key={li.id} label={li.label} value={`-${formatEur(li.amount)}`} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
<View className="h-px bg-gray-200 my-3" />
|
||||
<Row label={t('monthClose.totalTransfer')} value={`-${formatEur(s.totalOwed)}`} bold />
|
||||
<Row label={t('monthClose.alreadyTransferred')} value={`-${formatEur(s.alreadyTransferred)}`} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Remaining amount hero */}
|
||||
<View
|
||||
className="rounded-2xl p-4 mb-4 items-center"
|
||||
style={{ backgroundColor: remaining > 0.01 ? "#fff7ed" : "#f0fdf4", borderWidth: 1, borderColor: remaining > 0.01 ? "#fed7aa" : "#bbf7d0" }}
|
||||
>
|
||||
<Text className="text-xs text-gray-500 mb-1">
|
||||
{remaining > 0.01 ? t('monthClose.receives', { name: otherName }) : remaining < -0.01 ? t('monthClose.youReceive') : t('monthClose.settled')}
|
||||
</Text>
|
||||
<Text
|
||||
className="text-3xl font-bold"
|
||||
style={{ color: remaining > 0.01 ? "#ea580c" : remaining < -0.01 ? "#16a34a" : "#6b7280" }}
|
||||
>
|
||||
{formatEur(Math.abs(remaining))}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Amount adjustment */}
|
||||
<View className="bg-white rounded-2xl p-4 mb-4" style={{ borderWidth: 1, borderColor: "#f3f4f6" }}>
|
||||
<Text className="text-sm font-medium text-gray-700 mb-2">
|
||||
{t('monthClose.adjustAmount')}
|
||||
</Text>
|
||||
<Text className="text-xs text-gray-400 mb-3">
|
||||
{t('monthClose.adjustHint')}
|
||||
</Text>
|
||||
<View className="flex-row items-center bg-gray-50 border border-gray-200 rounded-xl px-4 py-3">
|
||||
<Text className="text-base text-gray-400 mr-2">€</Text>
|
||||
<TextInput
|
||||
className="flex-1 text-base text-gray-900"
|
||||
value={displayAmount}
|
||||
onChangeText={handleAmountChange}
|
||||
keyboardType="decimal-pad"
|
||||
selectTextOnFocus
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Notes */}
|
||||
<View className="bg-white rounded-2xl p-4 mb-6" style={{ borderWidth: 1, borderColor: "#f3f4f6" }}>
|
||||
<Text className="text-sm font-medium text-gray-700 mb-2">{t('monthClose.note')}</Text>
|
||||
<TextInput
|
||||
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900"
|
||||
placeholder={t('monthClose.notePlaceholder')}
|
||||
value={notes}
|
||||
onChangeText={setNotes}
|
||||
multiline
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* CTA */}
|
||||
<Pressable
|
||||
onPress={handleClose}
|
||||
disabled={isPending}
|
||||
className="rounded-2xl py-4 items-center active:opacity-80 mb-3"
|
||||
style={{ backgroundColor: "#dc2626" }}
|
||||
>
|
||||
{isPending ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<View className="flex-row items-center gap-2">
|
||||
<Ionicons name="lock-closed-outline" size={18} color="#fff" />
|
||||
<Text className="text-base font-semibold text-white">{t('monthClose.closeButton')}</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
onPress={() => router.back()}
|
||||
className="py-3 items-center active:opacity-50"
|
||||
>
|
||||
<Text className="text-sm text-gray-400">{t('common.cancel')}</Text>
|
||||
</Pressable>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({
|
||||
label,
|
||||
value,
|
||||
bold,
|
||||
color,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
bold?: boolean;
|
||||
color?: string;
|
||||
}) {
|
||||
return (
|
||||
<View className="flex-row justify-between items-center py-1.5">
|
||||
<Text className={`text-sm ${bold ? "font-semibold text-gray-800" : "text-gray-500"}`}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text
|
||||
className={`text-sm ${bold ? "font-semibold text-gray-800" : "text-gray-700"}`}
|
||||
style={color ? { color } : undefined}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
459
apps/native/app/(app)/scanner.tsx
Normal file
459
apps/native/app/(app)/scanner.tsx
Normal file
@@ -0,0 +1,459 @@
|
||||
import { useRef, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Linking,
|
||||
Modal,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { CameraView, useCameraPermissions } from "expo-camera";
|
||||
import { readAsStringAsync, EncodingType } from "expo-file-system/legacy";
|
||||
import { useRouter } from "expo-router";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiRequest } from "@/src/lib/api-client";
|
||||
import { useCategories } from "@/src/hooks/useCategories";
|
||||
import { TAB_COLORS } from "@/src/constants/colors";
|
||||
|
||||
const ACCENT = TAB_COLORS.household;
|
||||
|
||||
type ScreenState = "camera" | "scanning" | "confirm" | "booking";
|
||||
|
||||
type OcrResponse = {
|
||||
amount: number | null;
|
||||
label: string | null;
|
||||
date: string | null;
|
||||
confidence: number;
|
||||
};
|
||||
|
||||
type CreateTransactionBody = {
|
||||
amount: number;
|
||||
merchant: string;
|
||||
description: string;
|
||||
date: string;
|
||||
type: "expense";
|
||||
scope: "household" | "private";
|
||||
categoryId?: string;
|
||||
};
|
||||
|
||||
const FALLBACK_CATEGORIES = [
|
||||
"Lebensmittel",
|
||||
"Restaurant",
|
||||
"Transport",
|
||||
"Shopping",
|
||||
"Haushalt",
|
||||
"Sonstiges",
|
||||
];
|
||||
|
||||
function todayIso(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export default function ScannerScreen() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
const cameraRef = useRef<CameraView>(null);
|
||||
|
||||
const [screenState, setScreenState] = useState<ScreenState>("camera");
|
||||
const [cameraKey, setCameraKey] = useState(0);
|
||||
|
||||
// Confirm sheet state
|
||||
const [label, setLabel] = useState("");
|
||||
const [amountStr, setAmountStr] = useState("");
|
||||
const [date, setDate] = useState(todayIso()); // always YYYY-MM-DD internally
|
||||
const [dateDisplay, setDateDisplay] = useState(() => {
|
||||
const d = todayIso();
|
||||
return `${d.slice(8, 10)}.${d.slice(5, 7)}.${d.slice(0, 4)}`;
|
||||
});
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<string | undefined>(undefined);
|
||||
const [scope, setScope] = useState<"household" | "private">("household");
|
||||
|
||||
const { data: categories = [] } = useCategories();
|
||||
const expenseCategories = categories.filter((c) => c.type === "expense");
|
||||
const displayCategories =
|
||||
expenseCategories.length > 0
|
||||
? expenseCategories.map((c) => ({ id: c.id, name: c.name }))
|
||||
: FALLBACK_CATEGORIES.map((name, i) => ({ id: String(i), name }));
|
||||
|
||||
// ── Permission not yet determined ─────────────────────────────────────────
|
||||
if (!permission) {
|
||||
return (
|
||||
<View className="flex-1 bg-black items-center justify-center">
|
||||
<ActivityIndicator color="#fff" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Permission denied ─────────────────────────────────────────────────────
|
||||
if (!permission.granted) {
|
||||
return (
|
||||
<View
|
||||
className="flex-1 bg-black items-center justify-center px-8 gap-4"
|
||||
style={{ paddingTop: insets.top, paddingBottom: insets.bottom }}
|
||||
>
|
||||
{/* Back button */}
|
||||
<Pressable
|
||||
onPress={() => router.back()}
|
||||
className="absolute top-0 left-4 p-3"
|
||||
style={{ top: insets.top }}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={26} color="#fff" />
|
||||
</Pressable>
|
||||
|
||||
<Ionicons name="camera-outline" size={64} color="#6b7280" />
|
||||
<Text className="text-white text-center text-base font-medium">
|
||||
{t("scanner.permissionDenied")}
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={() => void requestPermission()}
|
||||
style={{ backgroundColor: ACCENT }}
|
||||
className="px-6 py-3 rounded-xl"
|
||||
>
|
||||
<Text className="text-white font-semibold">{t("scanner.openSettings")}</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => void Linking.openSettings()}
|
||||
className="px-6 py-3"
|
||||
>
|
||||
<Text className="text-gray-400 text-sm">{t("scanner.openSettings")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Capture & OCR ─────────────────────────────────────────────────────────
|
||||
async function handleCapture() {
|
||||
if (!cameraRef.current) return;
|
||||
setScreenState("scanning");
|
||||
|
||||
try {
|
||||
const photo = await cameraRef.current.takePictureAsync({ base64: false, quality: 0.7 });
|
||||
if (!photo?.uri) throw new Error("No photo URI");
|
||||
|
||||
const base64 = await readAsStringAsync(photo.uri, {
|
||||
encoding: EncodingType.Base64,
|
||||
});
|
||||
|
||||
const result = await apiRequest<OcrResponse>("/api/scanner/receipt", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ imageBase64: base64, mimeType: "image/jpeg" }),
|
||||
});
|
||||
|
||||
setLabel(result.label ?? "");
|
||||
setAmountStr(result.amount != null ? String(result.amount) : "");
|
||||
const isoDate = result.date ?? todayIso();
|
||||
setDate(isoDate);
|
||||
setDateDisplay(`${isoDate.slice(8, 10)}.${isoDate.slice(5, 7)}.${isoDate.slice(0, 4)}`);
|
||||
setScreenState("confirm");
|
||||
} catch {
|
||||
Alert.alert(t("common.error"), t("scanner.error"));
|
||||
setScreenState("camera");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Book transaction ──────────────────────────────────────────────────────
|
||||
async function handleBook() {
|
||||
const amount = parseFloat(amountStr.replace(",", "."));
|
||||
if (!amount || amount <= 0) {
|
||||
Alert.alert(t("common.error"), t("scanner.notRecognized"));
|
||||
return;
|
||||
}
|
||||
|
||||
setScreenState("booking");
|
||||
try {
|
||||
const body: CreateTransactionBody = {
|
||||
amount,
|
||||
merchant: label.trim() || t("scanner.title"),
|
||||
description: label.trim() || t("scanner.title"),
|
||||
date: new Date(date).toISOString(),
|
||||
type: "expense",
|
||||
scope,
|
||||
...(selectedCategoryId ? { categoryId: selectedCategoryId } : {}),
|
||||
};
|
||||
await apiRequest<unknown>("/api/transactions", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: ["transactions"] });
|
||||
handleRetry(); // reset all state
|
||||
router.back();
|
||||
} catch {
|
||||
Alert.alert(t("common.error"), t("scanner.error"));
|
||||
setScreenState("confirm");
|
||||
}
|
||||
}
|
||||
|
||||
function handleRetry() {
|
||||
const today = todayIso();
|
||||
setScreenState("camera");
|
||||
setLabel("");
|
||||
setAmountStr("");
|
||||
setDate(today);
|
||||
setDateDisplay(`${today.slice(8, 10)}.${today.slice(5, 7)}.${today.slice(0, 4)}`);
|
||||
setSelectedCategoryId(undefined);
|
||||
setScope("household");
|
||||
setCameraKey((k) => k + 1);
|
||||
}
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<View className="flex-1 bg-black">
|
||||
{/* Camera */}
|
||||
<CameraView
|
||||
key={cameraKey}
|
||||
ref={cameraRef}
|
||||
style={{ flex: 1 }}
|
||||
facing="back"
|
||||
/>
|
||||
|
||||
{/* Header overlay */}
|
||||
<View
|
||||
className="absolute left-0 right-0 flex-row items-center px-4"
|
||||
style={{ top: insets.top, paddingTop: 8 }}
|
||||
>
|
||||
<Pressable
|
||||
onPress={() => router.back()}
|
||||
className="w-10 h-10 rounded-full bg-black/40 items-center justify-center"
|
||||
>
|
||||
<Ionicons name="chevron-back" size={22} color="#fff" />
|
||||
</Pressable>
|
||||
<Text className="flex-1 text-center text-white font-semibold text-base mr-10">
|
||||
{t("scanner.title")}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Hint text */}
|
||||
{screenState === "camera" && (
|
||||
<View
|
||||
className="absolute left-0 right-0 items-center"
|
||||
style={{ top: insets.top + 64 }}
|
||||
>
|
||||
<View className="bg-black/40 px-4 py-2 rounded-full">
|
||||
<Text className="text-white text-sm">{t("scanner.hint")}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Viewfinder frame */}
|
||||
{screenState === "camera" && (
|
||||
<View className="absolute inset-0 items-center justify-center">
|
||||
<View
|
||||
className="border-2 border-white/60 rounded-2xl"
|
||||
style={{ width: "85%", height: "75%" }}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Capture button */}
|
||||
{screenState === "camera" && (
|
||||
<View
|
||||
className="absolute left-0 right-0 items-center"
|
||||
style={{ bottom: insets.bottom + 40 }}
|
||||
>
|
||||
<Pressable
|
||||
onPress={() => void handleCapture()}
|
||||
className="w-[72px] h-[72px] rounded-full bg-white items-center justify-center active:opacity-80"
|
||||
style={{
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
}}
|
||||
>
|
||||
<View className="w-16 h-16 rounded-full border-4 border-gray-300" />
|
||||
</Pressable>
|
||||
<Text className="text-white/70 text-xs mt-3">{t("scanner.capture")}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Scanning overlay */}
|
||||
{screenState === "scanning" && (
|
||||
<View className="absolute inset-0 bg-black/70 items-center justify-center gap-4">
|
||||
<ActivityIndicator size="large" color="#fff" />
|
||||
<Text className="text-white text-base font-medium">{t("scanner.scanning")}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Booking overlay */}
|
||||
{screenState === "booking" && (
|
||||
<View className="absolute inset-0 bg-black/70 items-center justify-center gap-4">
|
||||
<ActivityIndicator size="large" color="#fff" />
|
||||
<Text className="text-white text-base font-medium">{t("common.loading")}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Confirmation sheet */}
|
||||
<Modal
|
||||
visible={screenState === "confirm"}
|
||||
animationType="slide"
|
||||
presentationStyle="pageSheet"
|
||||
onRequestClose={handleRetry}
|
||||
>
|
||||
<View className="flex-1 bg-white">
|
||||
{/* Sheet header */}
|
||||
<View
|
||||
className="flex-row items-center px-4 py-4 border-b border-gray-100"
|
||||
style={{ paddingTop: insets.top > 0 ? 12 : 12 }}
|
||||
>
|
||||
<Pressable onPress={handleRetry} className="py-1 pr-4">
|
||||
<Text className="text-base" style={{ color: ACCENT }}>
|
||||
{t("scanner.retry")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Text className="flex-1 text-center font-semibold text-base text-gray-900">
|
||||
{t("scanner.detected")}
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={() => void handleBook()}
|
||||
className="py-1 pl-4"
|
||||
style={{ opacity: !amountStr || parseFloat(amountStr) <= 0 ? 0.4 : 1 }}
|
||||
disabled={!amountStr || parseFloat(amountStr) <= 0}
|
||||
>
|
||||
<Text className="text-base font-semibold" style={{ color: ACCENT }}>
|
||||
{t("scanner.book")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<ScrollView className="flex-1 px-4 pt-4" keyboardShouldPersistTaps="handled">
|
||||
{/* Merchant */}
|
||||
<Text className="text-xs text-gray-400 font-medium mb-1 ml-1">
|
||||
{t("scanner.merchant").toUpperCase()}
|
||||
</Text>
|
||||
<TextInput
|
||||
value={label}
|
||||
onChangeText={setLabel}
|
||||
placeholder={t("scanner.merchant")}
|
||||
placeholderTextColor="#9ca3af"
|
||||
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900 mb-4"
|
||||
/>
|
||||
|
||||
{/* Amount */}
|
||||
<Text className="text-xs text-gray-400 font-medium mb-1 ml-1">
|
||||
{t("scanner.amount").toUpperCase()}
|
||||
</Text>
|
||||
<View className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 flex-row items-center mb-4">
|
||||
<TextInput
|
||||
value={amountStr}
|
||||
onChangeText={setAmountStr}
|
||||
placeholder="0.00"
|
||||
placeholderTextColor="#9ca3af"
|
||||
keyboardType="decimal-pad"
|
||||
className="flex-1 text-base text-gray-900"
|
||||
/>
|
||||
<Text className="text-base text-gray-400 ml-2">€</Text>
|
||||
</View>
|
||||
|
||||
{/* Date */}
|
||||
<Text className="text-xs text-gray-400 font-medium mb-1 ml-1">
|
||||
{t("scanner.date").toUpperCase()}
|
||||
</Text>
|
||||
<TextInput
|
||||
value={dateDisplay}
|
||||
onChangeText={(v) => {
|
||||
setDateDisplay(v);
|
||||
// Convert DD.MM.YYYY → YYYY-MM-DD for internal state
|
||||
const parts = v.split(".");
|
||||
if (parts.length === 3 && parts[2]?.length === 4) {
|
||||
setDate(`${parts[2]}-${parts[1]?.padStart(2, "0")}-${parts[0]?.padStart(2, "0")}`);
|
||||
}
|
||||
}}
|
||||
placeholder="TT.MM.JJJJ"
|
||||
placeholderTextColor="#9ca3af"
|
||||
keyboardType="numbers-and-punctuation"
|
||||
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900 mb-4"
|
||||
/>
|
||||
|
||||
{/* Category */}
|
||||
<Text className="text-xs text-gray-400 font-medium mb-2 ml-1">
|
||||
{t("scanner.category").toUpperCase()}
|
||||
</Text>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
className="mb-4"
|
||||
contentContainerStyle={{ gap: 8, paddingRight: 16 }}
|
||||
>
|
||||
{displayCategories.map((cat) => {
|
||||
const isSelected = selectedCategoryId === cat.id;
|
||||
return (
|
||||
<Pressable
|
||||
key={cat.id}
|
||||
onPress={() =>
|
||||
setSelectedCategoryId(isSelected ? undefined : cat.id)
|
||||
}
|
||||
style={{
|
||||
backgroundColor: isSelected ? ACCENT : "#f3f4f6",
|
||||
borderWidth: 1,
|
||||
borderColor: isSelected ? ACCENT : "#e5e7eb",
|
||||
}}
|
||||
className="px-4 py-2 rounded-full active:opacity-70"
|
||||
>
|
||||
<Text
|
||||
className="text-sm font-medium"
|
||||
style={{ color: isSelected ? "#fff" : "#4b5563" }}
|
||||
>
|
||||
{cat.name}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
|
||||
{/* Scope */}
|
||||
<Text className="text-xs text-gray-400 font-medium mb-2 ml-1">
|
||||
{t("scanner.scope").toUpperCase()}
|
||||
</Text>
|
||||
<View className="flex-row gap-3 mb-8">
|
||||
<Pressable
|
||||
onPress={() => setScope("household")}
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: scope === "household" ? ACCENT : "#f3f4f6",
|
||||
borderWidth: 1,
|
||||
borderColor: scope === "household" ? ACCENT : "#e5e7eb",
|
||||
}}
|
||||
className="py-3 rounded-xl items-center active:opacity-70"
|
||||
>
|
||||
<Text
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: scope === "household" ? "#fff" : "#4b5563" }}
|
||||
>
|
||||
{t("scanner.household")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => setScope("private")}
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: scope === "private" ? "#7C3AED" : "#f3f4f6",
|
||||
borderWidth: 1,
|
||||
borderColor: scope === "private" ? "#7C3AED" : "#e5e7eb",
|
||||
}}
|
||||
className="py-3 rounded-xl items-center active:opacity-70"
|
||||
>
|
||||
<Text
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: scope === "private" ? "#fff" : "#4b5563" }}
|
||||
>
|
||||
{t("scanner.private")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
233
apps/native/app/(app)/settings/categories.tsx
Normal file
233
apps/native/app/(app)/settings/categories.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import { useCategories, useDeleteCategory, useUpdateCategory, type Category } from "@/src/hooks/useCategories";
|
||||
import { AddCategoryModal } from "@/src/components/features/categories/AddCategoryModal";
|
||||
import { ModalHeader } from "@/src/components/ui/ModalHeader";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Modal,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
// ── Edit Modal ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function EditCategoryModal({
|
||||
category,
|
||||
onClose,
|
||||
}: {
|
||||
category: Category | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [name, setName] = useState(category?.name ?? "");
|
||||
const { mutate: update, isPending } = useUpdateCategory();
|
||||
const { t } = useTranslation();
|
||||
|
||||
function handleSave() {
|
||||
if (!category || !name.trim()) return;
|
||||
update(
|
||||
{ id: category.id, name: name.trim() },
|
||||
{ onSuccess: onClose },
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={!!category}
|
||||
animationType="slide"
|
||||
presentationStyle="pageSheet"
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<View className="flex-1 bg-white">
|
||||
<ModalHeader
|
||||
title={t('categories.editTitle')}
|
||||
onClose={onClose}
|
||||
closeLabel={t('common.cancel')}
|
||||
onSave={handleSave}
|
||||
saveLabel={t('common.save')}
|
||||
saveDisabled={!name.trim()}
|
||||
saveLoading={isPending}
|
||||
/>
|
||||
|
||||
<View className="px-4 mt-6">
|
||||
<Text className="text-sm font-medium text-gray-700 mb-2">{t('categories.nameLabel')}</Text>
|
||||
<TextInput
|
||||
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900"
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
autoFocus
|
||||
/>
|
||||
{category?.isDefault && (
|
||||
<Text className="text-xs text-gray-400 mt-2">
|
||||
{t('categories.defaultWarning')}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Category Row ───────────────────────────────────────────────────────────────
|
||||
|
||||
function CategoryRow({
|
||||
category,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
category: Category;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View className="flex-row items-center justify-between py-3 border-b border-gray-100">
|
||||
<View className="flex-row items-center gap-3">
|
||||
<View
|
||||
className="w-9 h-9 rounded-full items-center justify-center"
|
||||
style={{ backgroundColor: category.color ?? "#6b7280" }}
|
||||
>
|
||||
<Ionicons name={category.icon} size={18} color="#fff" />
|
||||
</View>
|
||||
<Text className="text-base text-gray-800">{category.name}</Text>
|
||||
{category.isDefault && (
|
||||
<View className="bg-gray-100 rounded px-1.5 py-0.5">
|
||||
<Text className="text-xs text-gray-400">{t('categories.default')}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View className="flex-row gap-3">
|
||||
<Pressable onPress={onEdit} className="p-1 active:opacity-50">
|
||||
<Ionicons name="pencil-outline" size={18} color="#6b7280" />
|
||||
</Pressable>
|
||||
{!category.isDefault && (
|
||||
<Pressable onPress={onDelete} className="p-1 active:opacity-50">
|
||||
<Ionicons name="trash-outline" size={18} color="#dc2626" />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Screen ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function CategoriesScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const { data: categories = [], isLoading } = useCategories();
|
||||
const { mutate: deleteCategory } = useDeleteCategory();
|
||||
|
||||
const [editingCategory, setEditingCategory] = useState<Category | null>(null);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [addType, setAddType] = useState<"income" | "expense">("expense");
|
||||
|
||||
const expenseCategories = categories.filter((c) => c.type === "expense");
|
||||
const incomeCategories = categories.filter((c) => c.type === "income");
|
||||
|
||||
function handleDelete(category: Category) {
|
||||
Alert.alert(
|
||||
t('categories.deleteTitle'),
|
||||
t('categories.deleteMessage', { name: category.name }),
|
||||
[
|
||||
{ text: t('common.cancel'), style: "cancel" },
|
||||
{
|
||||
text: t('common.delete'),
|
||||
style: "destructive",
|
||||
onPress: () =>
|
||||
deleteCategory(category.id, {
|
||||
onError: (err) => {
|
||||
Alert.alert(t('common.error'), err.message);
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View className="flex-1 bg-white items-center justify-center">
|
||||
<ActivityIndicator size="large" color="#2563EB" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-gray-50" style={{ paddingTop: insets.top }}>
|
||||
{/* Header */}
|
||||
<View className="flex-row items-center px-4 py-4 bg-white border-b border-gray-100">
|
||||
<Pressable onPress={() => router.push("/(app)/settings")} className="mr-3 active:opacity-50">
|
||||
<Ionicons name="chevron-back" size={24} color="#374151" />
|
||||
</Pressable>
|
||||
<Text className="text-lg font-semibold text-gray-900">{t('settings.categories')}</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView contentContainerStyle={{ padding: 16 }}>
|
||||
{/* Expense Categories */}
|
||||
<View className="mb-6 rounded-xl bg-white p-4">
|
||||
<View className="flex-row items-center justify-between mb-3">
|
||||
<Text className="text-xs font-medium uppercase text-gray-400">{t('categories.expenseSection')}</Text>
|
||||
</View>
|
||||
{expenseCategories.map((cat) => (
|
||||
<CategoryRow
|
||||
key={cat.id}
|
||||
category={cat}
|
||||
onEdit={() => setEditingCategory(cat)}
|
||||
onDelete={() => handleDelete(cat)}
|
||||
/>
|
||||
))}
|
||||
<Pressable
|
||||
onPress={() => { setAddType("expense"); setShowAddModal(true); }}
|
||||
className="mt-3 flex-row items-center gap-2 py-2 active:opacity-50"
|
||||
>
|
||||
<Ionicons name="add-circle-outline" size={18} color="#2563EB" />
|
||||
<Text className="text-sm font-medium text-blue-600">{t('categories.addExpenseCategory')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Income Categories */}
|
||||
<View className="mb-6 rounded-xl bg-white p-4">
|
||||
<View className="flex-row items-center justify-between mb-3">
|
||||
<Text className="text-xs font-medium uppercase text-gray-400">{t('categories.incomeSection')}</Text>
|
||||
</View>
|
||||
{incomeCategories.map((cat) => (
|
||||
<CategoryRow
|
||||
key={cat.id}
|
||||
category={cat}
|
||||
onEdit={() => setEditingCategory(cat)}
|
||||
onDelete={() => handleDelete(cat)}
|
||||
/>
|
||||
))}
|
||||
<Pressable
|
||||
onPress={() => { setAddType("income"); setShowAddModal(true); }}
|
||||
className="mt-3 flex-row items-center gap-2 py-2 active:opacity-50"
|
||||
>
|
||||
<Ionicons name="add-circle-outline" size={18} color="#2563EB" />
|
||||
<Text className="text-sm font-medium text-blue-600">{t('categories.addIncomeCategory')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<EditCategoryModal
|
||||
key={editingCategory?.id}
|
||||
category={editingCategory}
|
||||
onClose={() => setEditingCategory(null)}
|
||||
/>
|
||||
|
||||
<AddCategoryModal
|
||||
visible={showAddModal}
|
||||
onClose={() => setShowAddModal(false)}
|
||||
defaultType={addType}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
317
apps/native/app/(app)/settings/fixed-costs.tsx
Normal file
317
apps/native/app/(app)/settings/fixed-costs.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Modal,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
SectionList,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
useFixedCosts,
|
||||
useCreateFixedCost,
|
||||
useUpdateFixedCost,
|
||||
useDeleteFixedCost,
|
||||
type FixedCost,
|
||||
type CreateFixedCostInput,
|
||||
} from "@/src/hooks/useFixedCosts";
|
||||
import { useCategories } from "@/src/hooks/useCategories";
|
||||
import { ModalHeader } from "@/src/components/ui/ModalHeader";
|
||||
import { Numpad } from "@/src/components/ui/Numpad";
|
||||
import { formatEur } from "@/src/utils/format";
|
||||
import { handleNumpadKey, parseAmountStr } from "@/src/utils/numpad";
|
||||
|
||||
const SCOPE_LABEL_KEYS: Record<string, string> = {
|
||||
household: "fixedCosts.household",
|
||||
private: "fixedCosts.me",
|
||||
child: "fixedCosts.children",
|
||||
};
|
||||
|
||||
// ── Add / Edit Modal ──────────────────────────────────────────────────────────
|
||||
|
||||
type ModalMode =
|
||||
| { kind: "add"; scope: "household" | "private" | "child" }
|
||||
| { kind: "edit"; item: FixedCost };
|
||||
|
||||
function FixedCostModal({
|
||||
mode,
|
||||
onClose,
|
||||
}: {
|
||||
mode: ModalMode;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const isEdit = mode.kind === "edit";
|
||||
const { data: categories = [] } = useCategories();
|
||||
const { mutate: create, isPending: creating } = useCreateFixedCost();
|
||||
const { mutate: update, isPending: updating } = useUpdateFixedCost();
|
||||
const { t: tFn } = useTranslation();
|
||||
|
||||
const [label, setLabel] = useState(isEdit ? mode.item.label : "");
|
||||
const [amountStr, setAmountStr] = useState(
|
||||
isEdit ? String(mode.item.amount).replace(".", ",") : "0",
|
||||
);
|
||||
const [type, setType] = useState<"expense" | "income">(
|
||||
isEdit ? mode.item.type : "expense",
|
||||
);
|
||||
const [categoryId, setCategoryId] = useState<string | null>(
|
||||
isEdit ? (mode.item.categoryId ?? null) : null,
|
||||
);
|
||||
const [showCategoryPicker, setShowCategoryPicker] = useState(false);
|
||||
|
||||
const filteredCategories = categories.filter((c) => c.type === type);
|
||||
const selectedCategory = categories.find((c) => c.id === categoryId) ?? null;
|
||||
const isPending = creating || updating;
|
||||
|
||||
function handleNumpad(key: string) {
|
||||
setAmountStr((prev) => handleNumpadKey(prev, key));
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
const amount = parseAmountStr(amountStr);
|
||||
if (!label.trim() || !amount || amount <= 0) return;
|
||||
|
||||
if (isEdit) {
|
||||
update(
|
||||
{ id: mode.item.id, input: { label: label.trim(), amount, categoryId } },
|
||||
{ onSuccess: onClose },
|
||||
);
|
||||
} else {
|
||||
const input: CreateFixedCostInput = {
|
||||
scope: mode.scope,
|
||||
label: label.trim(),
|
||||
amount,
|
||||
type,
|
||||
categoryId: categoryId ?? undefined,
|
||||
};
|
||||
create(input, { onSuccess: onClose });
|
||||
}
|
||||
}
|
||||
|
||||
const canSave = label.trim().length > 0 && parseAmountStr(amountStr) > 0;
|
||||
|
||||
return (
|
||||
<Modal visible animationType="slide" presentationStyle="pageSheet" onRequestClose={onClose}>
|
||||
<View className="flex-1 bg-white">
|
||||
<ModalHeader
|
||||
title={isEdit ? tFn('fixedCosts.editTitle') : tFn('fixedCosts.addTitle')}
|
||||
onClose={onClose}
|
||||
closeLabel={tFn('common.cancel')}
|
||||
onSave={handleSave}
|
||||
saveLabel={tFn('common.save')}
|
||||
saveDisabled={!canSave}
|
||||
saveLoading={isPending}
|
||||
/>
|
||||
|
||||
<ScrollView keyboardShouldPersistTaps="handled" contentContainerStyle={{ paddingBottom: 24 }}>
|
||||
{/* Amount */}
|
||||
<View className="items-center py-6">
|
||||
<Text className="text-5xl font-bold text-gray-900">€ {amountStr}</Text>
|
||||
</View>
|
||||
|
||||
<View className="px-4 gap-3 mb-4">
|
||||
{/* Type toggle (only for new) */}
|
||||
{!isEdit && (
|
||||
<View className="flex-row p-1 bg-gray-100 rounded-xl">
|
||||
{(["expense", "income"] as const).map((t) => (
|
||||
<Pressable
|
||||
key={t}
|
||||
onPress={() => { setType(t); setCategoryId(null); }}
|
||||
className={`flex-1 py-2 rounded-lg items-center ${type === t ? "bg-white shadow-sm" : ""}`}
|
||||
>
|
||||
<Text className={`font-medium ${type === t ? (t === "expense" ? "text-red-600" : "text-green-600") : "text-gray-500"}`}>
|
||||
{t === "expense" ? tFn('fixedCosts.expenseType') : tFn('fixedCosts.incomeType')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Label */}
|
||||
<View>
|
||||
<Text className="text-sm font-medium text-gray-700 mb-1">{tFn('fixedCosts.labelRequired')}</Text>
|
||||
<TextInput
|
||||
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900"
|
||||
placeholder={tFn('fixedCosts.labelPlaceholder')}
|
||||
value={label}
|
||||
onChangeText={setLabel}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Category */}
|
||||
<View>
|
||||
<Text className="text-sm font-medium text-gray-700 mb-1">{tFn('fixedCosts.categoryOptional')}</Text>
|
||||
<Pressable
|
||||
onPress={() => setShowCategoryPicker((v) => !v)}
|
||||
className="flex-row items-center bg-gray-50 border border-gray-200 rounded-xl px-4 py-3"
|
||||
>
|
||||
<Text className="flex-1 text-base" style={{ color: selectedCategory ? "#111827" : "#9ca3af" }}>
|
||||
{selectedCategory ? selectedCategory.name : tFn('common.select')}
|
||||
</Text>
|
||||
{selectedCategory ? (
|
||||
<Pressable onPress={(e) => { e.stopPropagation(); setCategoryId(null); }} hitSlop={8}>
|
||||
<Ionicons name="close-circle" size={18} color="#9ca3af" />
|
||||
</Pressable>
|
||||
) : (
|
||||
<Ionicons name={showCategoryPicker ? "chevron-up" : "chevron-down"} size={14} color="#9ca3af" />
|
||||
)}
|
||||
</Pressable>
|
||||
{showCategoryPicker && (
|
||||
<View className="mt-1 bg-white border border-gray-200 rounded-xl overflow-hidden">
|
||||
{filteredCategories.map((cat) => (
|
||||
<Pressable
|
||||
key={cat.id}
|
||||
onPress={() => { setCategoryId(cat.id); setShowCategoryPicker(false); }}
|
||||
className="flex-row items-center px-4 py-3 active:bg-gray-50"
|
||||
style={{ borderBottomWidth: 1, borderBottomColor: "#f3f4f6" }}
|
||||
>
|
||||
<View className="w-6 h-6 rounded-full mr-3 items-center justify-center" style={{ backgroundColor: cat.color ?? "#6b7280" }}>
|
||||
<Ionicons name={(cat.icon ?? "ellipsis-horizontal-circle-outline") as React.ComponentProps<typeof Ionicons>["name"]} size={12} color="#fff" />
|
||||
</View>
|
||||
<Text className="text-sm text-gray-800">{cat.name}</Text>
|
||||
{categoryId === cat.id && <Ionicons name="checkmark" size={16} color="#2563EB" style={{ marginLeft: "auto" }} />}
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Numpad */}
|
||||
<Numpad onKeyPress={handleNumpad} />
|
||||
</ScrollView>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Row ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
function FixedCostRow({
|
||||
item,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
item: FixedCost;
|
||||
onEdit: (item: FixedCost) => void;
|
||||
onDelete: (item: FixedCost) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View className="flex-row items-center px-4 py-3 bg-white border-b border-gray-50">
|
||||
<View className="flex-1">
|
||||
<Text className="text-sm font-medium text-gray-900">{item.label}</Text>
|
||||
<Text className="text-xs text-gray-400 mt-0.5">
|
||||
{item.type === "expense" ? t('fixedCosts.expenseType') : t('fixedCosts.incomeType')} · {t('common.monthly')}
|
||||
</Text>
|
||||
</View>
|
||||
<Text className="text-sm font-semibold text-gray-800 mr-3">{formatEur(item.amount, false)}</Text>
|
||||
<Pressable onPress={() => onEdit(item)} hitSlop={8} className="mr-2 p-1">
|
||||
<Ionicons name="pencil-outline" size={16} color="#6b7280" />
|
||||
</Pressable>
|
||||
<Pressable onPress={() => onDelete(item)} hitSlop={8} className="p-1">
|
||||
<Ionicons name="trash-outline" size={16} color="#d1d5db" />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Screen ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function FixedCostsScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const { data: allFixedCosts = [], isLoading } = useFixedCosts();
|
||||
const { mutate: deleteCost } = useDeleteFixedCost();
|
||||
const [modalMode, setModalMode] = useState<ModalMode | null>(null);
|
||||
|
||||
const active = allFixedCosts.filter((fc) => fc.isActive);
|
||||
|
||||
const sections = (["household", "private", "child"] as const)
|
||||
.map((scope) => ({
|
||||
scope,
|
||||
title: t(SCOPE_LABEL_KEYS[scope] ?? scope),
|
||||
data: active.filter((fc) => fc.scope === scope),
|
||||
}))
|
||||
.filter((s) => s.data.length > 0 || true); // always show all scopes
|
||||
|
||||
function handleDelete(item: FixedCost) {
|
||||
Alert.alert(
|
||||
t('fixedCosts.pauseTitle'),
|
||||
t('fixedCosts.pauseMessage', { label: item.label }),
|
||||
[
|
||||
{ text: t('common.cancel'), style: "cancel" },
|
||||
{ text: t('fixedCosts.pause'), style: "destructive", onPress: () => deleteCost(item.id) },
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-gray-50">
|
||||
{/* Header */}
|
||||
<View
|
||||
className="bg-white border-b border-gray-100"
|
||||
style={{ paddingTop: insets.top }}
|
||||
>
|
||||
<View className="flex-row items-center px-4 py-3">
|
||||
<Pressable onPress={() => router.push("/(app)/settings")} className="mr-3 p-1">
|
||||
<Ionicons name="chevron-back" size={22} color="#374151" />
|
||||
</Pressable>
|
||||
<Text className="text-base font-semibold text-gray-900 flex-1">{t('fixedCosts.title')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{isLoading ? (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<ActivityIndicator size="large" color="#2563EB" />
|
||||
</View>
|
||||
) : (
|
||||
<SectionList
|
||||
sections={sections}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderSectionHeader={({ section }) => (
|
||||
<View className="flex-row items-center justify-between px-4 py-3 bg-gray-50">
|
||||
<Text className="text-xs font-semibold uppercase text-gray-500 tracking-wide">
|
||||
{section.title}
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={() => setModalMode({ kind: "add", scope: section.scope })}
|
||||
className="flex-row items-center gap-1 px-3 py-1.5 rounded-full"
|
||||
style={{ backgroundColor: "#dbeafe" }}
|
||||
>
|
||||
<Ionicons name="add" size={14} color="#2563EB" />
|
||||
<Text className="text-xs font-semibold text-blue-600">{t('common.new')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
renderItem={({ item }) => (
|
||||
<FixedCostRow
|
||||
item={item}
|
||||
onEdit={(i) => setModalMode({ kind: "edit", item: i })}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
)}
|
||||
renderSectionFooter={({ section }) =>
|
||||
section.data.length === 0 ? (
|
||||
<View className="px-4 py-3 bg-white border-b border-gray-50">
|
||||
<Text className="text-sm text-gray-400 italic">{t('fixedCosts.noItems')}</Text>
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
contentContainerStyle={{ paddingBottom: insets.bottom + 24 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{modalMode && (
|
||||
<FixedCostModal mode={modalMode} onClose={() => setModalMode(null)} />
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
269
apps/native/app/(app)/settings/household.tsx
Normal file
269
apps/native/app/(app)/settings/household.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useHouseholdSettings, useUpdateHouseholdSettings } from "@/src/hooks/useHouseholdSettings";
|
||||
import { useHouseholdMembers } from "@/src/hooks/useHouseholdMembers";
|
||||
|
||||
const ACCENT = "#2563EB";
|
||||
const SHARE_PRESETS = [50, 60, 75, 100];
|
||||
|
||||
function SettingsRow({
|
||||
label,
|
||||
value,
|
||||
onPress,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
className="flex-row items-center justify-between py-3 border-b border-gray-100 active:opacity-70"
|
||||
>
|
||||
<Text className="text-base text-gray-900">{label}</Text>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<Text className="text-base text-gray-500">{value}</Text>
|
||||
<Ionicons name="pencil-outline" size={14} color="#9ca3af" />
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function EditModal({
|
||||
title,
|
||||
initialValue,
|
||||
keyboardType,
|
||||
onSave,
|
||||
onClose,
|
||||
}: {
|
||||
title: string;
|
||||
initialValue: string;
|
||||
keyboardType?: "default" | "decimal-pad";
|
||||
onSave: (value: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute", inset: 0, backgroundColor: "rgba(0,0,0,0.4)",
|
||||
alignItems: "center", justifyContent: "center", zIndex: 100,
|
||||
}}
|
||||
>
|
||||
<View className="bg-white rounded-2xl mx-6 p-5 w-full" style={{ maxWidth: 340 }}>
|
||||
<Text className="text-base font-semibold text-gray-900 mb-3">{title}</Text>
|
||||
<TextInput
|
||||
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900 mb-4"
|
||||
value={value}
|
||||
onChangeText={setValue}
|
||||
keyboardType={keyboardType ?? "default"}
|
||||
autoFocus
|
||||
autoCapitalize="words"
|
||||
/>
|
||||
<View className="flex-row gap-3">
|
||||
<Pressable
|
||||
onPress={onClose}
|
||||
className="flex-1 py-3 rounded-xl items-center bg-gray-100 active:opacity-70"
|
||||
>
|
||||
<Text className="text-sm font-semibold text-gray-700">{t('common.cancel')}</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => { onSave(value); onClose(); }}
|
||||
className="flex-1 py-3 rounded-xl items-center active:opacity-70"
|
||||
style={{ backgroundColor: ACCENT }}
|
||||
>
|
||||
<Text className="text-sm font-semibold text-white">{t('common.save')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
type EditingField = "ownerName" | "partnerName" | "monthlyBudget" | "userSharePercent" | null;
|
||||
|
||||
export default function HouseholdSettingsScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const { data: settings, isLoading } = useHouseholdSettings();
|
||||
const { data: membersData } = useHouseholdMembers();
|
||||
const { mutate: update, isPending } = useUpdateHouseholdSettings();
|
||||
const [editing, setEditing] = useState<EditingField>(null);
|
||||
const members = membersData?.members ?? [];
|
||||
|
||||
function save(input: Parameters<typeof update>[0]) {
|
||||
update(input, {
|
||||
onError: () => Alert.alert(t('common.error'), t('settings.saveError')),
|
||||
});
|
||||
}
|
||||
|
||||
if (isLoading || !settings) {
|
||||
return (
|
||||
<View className="flex-1 bg-gray-50 items-center justify-center">
|
||||
<ActivityIndicator size="large" color={ACCENT} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-gray-50">
|
||||
<View
|
||||
className="bg-white border-b border-gray-100"
|
||||
style={{ paddingTop: insets.top }}
|
||||
>
|
||||
<View className="flex-row items-center px-4 py-3">
|
||||
<Pressable onPress={() => router.push("/(app)/settings")} className="mr-3 p-1">
|
||||
<Ionicons name="chevron-back" size={22} color="#374151" />
|
||||
</Pressable>
|
||||
<Text className="text-base font-semibold text-gray-900 flex-1">{t('settings.household.title')}</Text>
|
||||
{isPending && <ActivityIndicator size="small" color={ACCENT} />}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<ScrollView contentContainerStyle={{ padding: 16, paddingBottom: insets.bottom + 32 }}>
|
||||
{/* Namen */}
|
||||
<View className="bg-white rounded-2xl px-4 mb-4">
|
||||
<Text className="text-xs font-medium uppercase text-gray-400 pt-3 mb-1">{t('settings.household.namesSection')}</Text>
|
||||
<SettingsRow
|
||||
label={t('settings.household.yourName')}
|
||||
value={settings.ownerName}
|
||||
onPress={() => setEditing("ownerName")}
|
||||
/>
|
||||
<SettingsRow
|
||||
label={t('settings.household.partnerName')}
|
||||
value={settings.partnerName}
|
||||
onPress={() => setEditing("partnerName")}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Wer zahlt die Ausgaben vor? */}
|
||||
{members.length > 1 && (
|
||||
<View className="bg-white rounded-2xl px-4 mb-4">
|
||||
<Text className="text-xs font-medium uppercase text-gray-400 pt-3 mb-1">{t('settings.household.payerSection')}</Text>
|
||||
<Text className="text-xs text-gray-400 mb-3">{t('settings.household.payerHint')}</Text>
|
||||
<View className="flex-row gap-2 mb-3">
|
||||
{members.map((m) => {
|
||||
const isSelected = settings.payerUserId === m.userId;
|
||||
return (
|
||||
<Pressable
|
||||
key={m.userId}
|
||||
onPress={() => save({ payerUserId: m.userId })}
|
||||
className="flex-1 py-2.5 rounded-xl items-center"
|
||||
style={{ backgroundColor: isSelected ? ACCENT : "#f3f4f6" }}
|
||||
>
|
||||
<Text className="text-sm font-semibold" style={{ color: isSelected ? "#fff" : "#374151" }}>
|
||||
{m.name}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Kostenaufteilung */}
|
||||
<View className="bg-white rounded-2xl px-4 mb-4">
|
||||
<Text className="text-xs font-medium uppercase text-gray-400 pt-3 mb-2">{t('settings.household.costSplitSection')}</Text>
|
||||
|
||||
<Text className="text-xs text-gray-400 mb-3">{t('settings.household.costSplitHint')}</Text>
|
||||
<View className="flex-row gap-2 mb-3">
|
||||
{SHARE_PRESETS.map((p) => (
|
||||
<Pressable
|
||||
key={p}
|
||||
onPress={() => save({ userSharePercent: p })}
|
||||
className="flex-1 py-2.5 rounded-xl items-center"
|
||||
style={{ backgroundColor: settings.userSharePercent === p ? ACCENT : "#f3f4f6" }}
|
||||
>
|
||||
<Text
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: settings.userSharePercent === p ? "#fff" : "#374151" }}
|
||||
>
|
||||
{p}%
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View
|
||||
className="rounded-xl px-3 py-2 mb-3"
|
||||
style={{ backgroundColor: "#eff6ff" }}
|
||||
>
|
||||
<Text className="text-xs text-blue-700">
|
||||
{t('settings.household.sharePreview', { own: settings.userSharePercent, partner: settings.partnerName, rest: 100 - settings.userSharePercent })}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<SettingsRow
|
||||
label={t('settings.household.monthlyBudget')}
|
||||
value={`${settings.monthlyBudget.toFixed(0)} €`}
|
||||
onPress={() => setEditing("monthlyBudget")}
|
||||
/>
|
||||
|
||||
<View className="flex-row items-center justify-between py-3">
|
||||
<Text className="text-base text-gray-900">{t('settings.household.splitChildren')}</Text>
|
||||
<Switch
|
||||
value={settings.splitChildCosts}
|
||||
onValueChange={(v) => save({ splitChildCosts: v })}
|
||||
trackColor={{ false: "#d1d5db", true: ACCENT }}
|
||||
thumbColor="#fff"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Währung */}
|
||||
<View className="bg-white rounded-2xl px-4 mb-4">
|
||||
<Text className="text-xs font-medium uppercase text-gray-400 pt-3 mb-1">{t('settings.household.settingsSection')}</Text>
|
||||
<SettingsRow
|
||||
label={t('settings.household.currency')}
|
||||
value={settings.currency}
|
||||
onPress={() =>
|
||||
Alert.alert(t('settings.household.currency'), t('settings.household.currencyOnlyEur'))
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* Inline Edit Modals */}
|
||||
{editing === "ownerName" && (
|
||||
<EditModal
|
||||
title={t('settings.household.yourName')}
|
||||
initialValue={settings.ownerName}
|
||||
onSave={(v) => save({ ownerName: v.trim() || "Ich" })}
|
||||
onClose={() => setEditing(null)}
|
||||
/>
|
||||
)}
|
||||
{editing === "partnerName" && (
|
||||
<EditModal
|
||||
title={t('settings.household.partnerName')}
|
||||
initialValue={settings.partnerName}
|
||||
onSave={(v) => save({ partnerName: v.trim() || "Partner" })}
|
||||
onClose={() => setEditing(null)}
|
||||
/>
|
||||
)}
|
||||
{editing === "monthlyBudget" && (
|
||||
<EditModal
|
||||
title={t('settings.household.monthlyBudget')}
|
||||
initialValue={String(settings.monthlyBudget)}
|
||||
keyboardType="decimal-pad"
|
||||
onSave={(v) => save({ monthlyBudget: parseFloat(v.replace(",", ".")) || 400 })}
|
||||
onClose={() => setEditing(null)}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
442
apps/native/app/(app)/settings/index.tsx
Normal file
442
apps/native/app/(app)/settings/index.tsx
Normal file
@@ -0,0 +1,442 @@
|
||||
import { useAuthStore } from "@/src/stores/auth.store";
|
||||
import { signOut } from "@/src/lib/auth-client";
|
||||
import {
|
||||
useHouseholdMembers,
|
||||
useRevokeInvitation,
|
||||
type PendingInvitation,
|
||||
type HouseholdMember,
|
||||
} from "@/src/hooks/useHouseholdMembers";
|
||||
import { useHouseholdSettings, useUpdateHouseholdSettings } from "@/src/hooks/useHouseholdSettings";
|
||||
import { ModalHeader } from "@/src/components/ui/ModalHeader";
|
||||
import { useGenerateInviteCode } from "@/src/hooks/useInvite";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
ToastAndroid,
|
||||
Platform,
|
||||
Alert,
|
||||
Modal,
|
||||
Share,
|
||||
ActivityIndicator,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import i18n from "@/src/i18n";
|
||||
import * as Localization from "expo-localization";
|
||||
|
||||
function showToast(message: string) {
|
||||
if (Platform.OS === "android") {
|
||||
ToastAndroid.show(message, ToastAndroid.SHORT);
|
||||
} else {
|
||||
Alert.alert("", message, [{ text: "OK" }], { cancelable: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Invite Code Modal ──────────────────────────────────────────────────────────
|
||||
|
||||
function InviteCodeModal({
|
||||
visible,
|
||||
onClose,
|
||||
}: {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { mutate: generate, data, isPending, reset } = useGenerateInviteCode();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
reset();
|
||||
setCopied(false);
|
||||
generate();
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const code = data?.code ?? "";
|
||||
|
||||
async function handleShare() {
|
||||
if (!code) return;
|
||||
await Share.share({ message: t('invite.shareText', { code }) });
|
||||
}
|
||||
|
||||
async function handleCopy() {
|
||||
if (!code) return;
|
||||
await Share.share({ message: code });
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
reset();
|
||||
setCopied(false);
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
animationType="slide"
|
||||
presentationStyle="pageSheet"
|
||||
onRequestClose={handleClose}
|
||||
>
|
||||
<View className="flex-1 bg-white">
|
||||
<ModalHeader
|
||||
title={t('invite.title')}
|
||||
onClose={handleClose}
|
||||
closeLabel={t('common.cancel')}
|
||||
/>
|
||||
|
||||
<View className="flex-1 items-center justify-center px-6">
|
||||
{isPending ? (
|
||||
<View className="items-center gap-3">
|
||||
<ActivityIndicator size="large" color="#2563EB" />
|
||||
<Text className="text-sm text-gray-400">{t('invite.generating')}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
{/* Code display */}
|
||||
<View className="items-center mb-2 rounded-2xl bg-gray-50 px-8 py-6">
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 40,
|
||||
fontWeight: "700",
|
||||
letterSpacing: 10,
|
||||
color: "#111827",
|
||||
}}
|
||||
>
|
||||
{code || "------"}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text className="text-sm text-gray-400 mb-10">{t('invite.validFor')}</Text>
|
||||
|
||||
{/* Copy button */}
|
||||
<Pressable
|
||||
onPress={handleCopy}
|
||||
className="w-full mb-3 flex-row items-center justify-center gap-2 rounded-xl bg-blue-600 py-4 active:opacity-80"
|
||||
>
|
||||
<Ionicons name="copy-outline" size={18} color="white" />
|
||||
<Text className="text-base font-semibold text-white">
|
||||
{copied ? t('invite.copied') : t('invite.copyCode')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
{/* Share button */}
|
||||
<Pressable
|
||||
onPress={handleShare}
|
||||
className="w-full mb-8 flex-row items-center justify-center gap-2 rounded-xl border border-blue-200 py-4 active:opacity-80"
|
||||
>
|
||||
<Ionicons name="share-outline" size={18} color="#2563EB" />
|
||||
<Text className="text-base font-semibold text-blue-600">{t('invite.share')}</Text>
|
||||
</Pressable>
|
||||
|
||||
{/* Regenerate link */}
|
||||
<Pressable onPress={() => { setCopied(false); generate(); }} className="active:opacity-60">
|
||||
<Text className="text-sm text-gray-400 underline">{t('invite.newCode')}</Text>
|
||||
</Pressable>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Members Section ────────────────────────────────────────────────────────────
|
||||
|
||||
function MembersSection() {
|
||||
const [showInviteModal, setShowInviteModal] = useState(false);
|
||||
const { data, isLoading } = useHouseholdMembers();
|
||||
const { mutate: revoke } = useRevokeInvitation();
|
||||
const currentUserId = useAuthStore((s) => s.user?.id);
|
||||
const { t } = useTranslation();
|
||||
|
||||
function handleRevoke(inv: PendingInvitation) {
|
||||
Alert.alert(
|
||||
t('settings.revokeTitle'),
|
||||
t('settings.revokeMessage', { email: inv.email }),
|
||||
[
|
||||
{ text: t('common.cancel'), style: "cancel" },
|
||||
{
|
||||
text: t('settings.revoke'),
|
||||
style: "destructive",
|
||||
onPress: () => revoke(inv.id, { onSuccess: () => showToast(t('settings.revokeSuccess')) }),
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<View className="mb-6 rounded-xl bg-white p-4">
|
||||
<Text className="text-xs font-medium uppercase text-gray-400 mb-3">{t('settings.members')}</Text>
|
||||
|
||||
{isLoading && (
|
||||
<ActivityIndicator size="small" color="#2563EB" style={{ marginVertical: 8 }} />
|
||||
)}
|
||||
|
||||
{/* Active members */}
|
||||
{data?.members.map((m: HouseholdMember) => (
|
||||
<View
|
||||
key={m.userId}
|
||||
className="flex-row items-center justify-between py-3 border-b border-gray-100"
|
||||
>
|
||||
<View className="flex-row items-center gap-3">
|
||||
<View
|
||||
className="w-8 h-8 rounded-full items-center justify-center"
|
||||
style={{ backgroundColor: m.userId === currentUserId ? "#2563EB" : "#e5e7eb" }}
|
||||
>
|
||||
<Text
|
||||
className="text-xs font-bold"
|
||||
style={{ color: m.userId === currentUserId ? "#fff" : "#6b7280" }}
|
||||
>
|
||||
{m.name.charAt(0).toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
<View>
|
||||
<Text className="text-base text-gray-900">
|
||||
{m.name}{m.userId === currentUserId ? ` ${t('settings.youSuffix')}` : ""}
|
||||
</Text>
|
||||
<Text className="text-xs text-gray-400">{m.email}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text className="text-xs text-gray-400 capitalize">{m.role}</Text>
|
||||
</View>
|
||||
))}
|
||||
|
||||
{/* Pending invitations */}
|
||||
{(data?.pendingInvitations ?? []).length > 0 && (
|
||||
<View className="mt-2">
|
||||
<Text className="text-xs text-gray-400 mb-1">{t('settings.pending')}</Text>
|
||||
{data!.pendingInvitations.map((inv: PendingInvitation) => (
|
||||
<View
|
||||
key={inv.id}
|
||||
className="flex-row items-center justify-between py-3 border-b border-gray-100"
|
||||
>
|
||||
<View className="flex-row items-center gap-3">
|
||||
<View className="w-8 h-8 rounded-full items-center justify-center bg-gray-100">
|
||||
<Ionicons name="mail-outline" size={16} color="#9ca3af" />
|
||||
</View>
|
||||
<Text className="text-base text-gray-500">{inv.email}</Text>
|
||||
</View>
|
||||
<Pressable onPress={() => handleRevoke(inv)} className="p-1 active:opacity-50">
|
||||
<Ionicons name="close-circle-outline" size={20} color="#dc2626" />
|
||||
</Pressable>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Invite button */}
|
||||
<Pressable
|
||||
onPress={() => setShowInviteModal(true)}
|
||||
className="mt-3 flex-row items-center justify-center gap-1.5 rounded-lg border border-blue-200 py-3 active:opacity-70"
|
||||
>
|
||||
<Ionicons name="person-add-outline" size={16} color="#2563EB" />
|
||||
<Text className="text-sm font-medium text-blue-600">{t('settings.invitePerson')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<InviteCodeModal visible={showInviteModal} onClose={() => setShowInviteModal(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Screen ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const { user, households, activeHouseholdId, setActiveHousehold } = useAuthStore();
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t } = useTranslation();
|
||||
const { data: hhSettings } = useHouseholdSettings();
|
||||
const { mutate: updateSettings } = useUpdateHouseholdSettings();
|
||||
|
||||
// Apply saved language preference when settings load
|
||||
useEffect(() => {
|
||||
if (hhSettings?.language && hhSettings.language !== "auto") {
|
||||
void i18n.changeLanguage(hhSettings.language);
|
||||
}
|
||||
}, [hhSettings?.language]);
|
||||
|
||||
function handleLanguageChange() {
|
||||
const deviceLanguage = Localization.getLocales()[0]?.languageCode ?? "de";
|
||||
Alert.alert(t('settings.language'), undefined, [
|
||||
{
|
||||
text: t('settings.languageAuto'),
|
||||
onPress: () => {
|
||||
void i18n.changeLanguage(deviceLanguage);
|
||||
updateSettings({ language: "auto" });
|
||||
},
|
||||
},
|
||||
{
|
||||
text: t('settings.languageDe'),
|
||||
onPress: () => {
|
||||
void i18n.changeLanguage("de");
|
||||
updateSettings({ language: "de" });
|
||||
},
|
||||
},
|
||||
{
|
||||
text: t('settings.languageEn'),
|
||||
onPress: () => {
|
||||
void i18n.changeLanguage("en");
|
||||
updateSettings({ language: "en" });
|
||||
},
|
||||
},
|
||||
{ text: t('common.cancel'), style: "cancel" },
|
||||
]);
|
||||
}
|
||||
|
||||
async function handleSwitch(household: { id: string; name: string }) {
|
||||
if (household.id === activeHouseholdId) return;
|
||||
setActiveHousehold(household.id);
|
||||
await queryClient.invalidateQueries();
|
||||
showToast(t('settings.switchedTo', { name: household.name }));
|
||||
}
|
||||
|
||||
async function handleSignOut() {
|
||||
await signOut();
|
||||
useAuthStore.getState().clearAuth();
|
||||
router.replace("/(auth)/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
className="flex-1 bg-gray-50"
|
||||
contentContainerStyle={{ padding: 16, paddingTop: insets.top + 8 }}
|
||||
>
|
||||
{/* Back + Title */}
|
||||
<View className="flex-row items-center mb-5">
|
||||
<Pressable onPress={() => router.push("/(app)/mehr")} className="mr-3 p-1">
|
||||
<Ionicons name="chevron-back" size={22} color="#374151" />
|
||||
</Pressable>
|
||||
<Text className="text-xl font-bold text-gray-900">{t('settings.title')}</Text>
|
||||
</View>
|
||||
|
||||
{/* User Info */}
|
||||
<View className="mb-6 rounded-xl bg-white p-4">
|
||||
<Text className="text-xs font-medium uppercase text-gray-400 mb-2">{t('settings.account')}</Text>
|
||||
<Text className="text-base font-semibold text-gray-900">{user?.name}</Text>
|
||||
<Text className="text-sm text-gray-500">{user?.email}</Text>
|
||||
</View>
|
||||
|
||||
{/* Household Switcher */}
|
||||
<View className="mb-6 rounded-xl bg-white p-4">
|
||||
<Text className="text-xs font-medium uppercase text-gray-400 mb-3">{t('settings.households')}</Text>
|
||||
{households.map((h) => (
|
||||
<Pressable
|
||||
key={h.id}
|
||||
onPress={() => handleSwitch(h)}
|
||||
className="flex-row items-center justify-between py-3 border-b border-gray-100 active:opacity-70 last:border-b-0"
|
||||
>
|
||||
<View>
|
||||
<Text className="text-base text-gray-900">{h.name}</Text>
|
||||
<Text className="text-xs text-gray-400 capitalize">{h.role}</Text>
|
||||
</View>
|
||||
{activeHouseholdId === h.id && (
|
||||
<Ionicons name="checkmark-circle" size={20} color="#2563EB" />
|
||||
)}
|
||||
</Pressable>
|
||||
))}
|
||||
<Pressable
|
||||
onPress={() => router.push("/(auth)/onboarding")}
|
||||
className="mt-3 flex-row items-center justify-center gap-1.5 rounded-lg border border-blue-200 py-3 active:opacity-70"
|
||||
>
|
||||
<Ionicons name="add-circle-outline" size={16} color="#2563EB" />
|
||||
<Text className="text-sm font-medium text-blue-600">{t('onboarding.createHousehold')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Members + Invite */}
|
||||
<MembersSection />
|
||||
|
||||
{/* Household Settings */}
|
||||
<View className="mb-6 rounded-xl bg-white p-4">
|
||||
<Text className="text-xs font-medium uppercase text-gray-400 mb-3">{t('tabs.household')}</Text>
|
||||
<Pressable
|
||||
onPress={() => router.push("/(app)/settings/household")}
|
||||
className="flex-row items-center justify-between py-3 active:opacity-70"
|
||||
>
|
||||
<View className="flex-row items-center gap-3">
|
||||
<Ionicons name="people-outline" size={20} color="#6b7280" />
|
||||
<Text className="text-base text-gray-900">{t('settings.householdPartner')}</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={16} color="#9ca3af" />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* App Settings */}
|
||||
<View className="mb-6 rounded-xl bg-white p-4">
|
||||
<Text className="text-xs font-medium uppercase text-gray-400 mb-3">{t('settings.appSection')}</Text>
|
||||
<Pressable
|
||||
onPress={() => router.push("/(app)/settings/categories")}
|
||||
className="flex-row items-center justify-between py-3 border-b border-gray-100 active:opacity-70"
|
||||
>
|
||||
<View className="flex-row items-center gap-3">
|
||||
<Ionicons name="pricetags-outline" size={20} color="#6b7280" />
|
||||
<Text className="text-base text-gray-900">{t('settings.categories')}</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={16} color="#9ca3af" />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => router.push("/(app)/settings/fixed-costs")}
|
||||
className="flex-row items-center justify-between py-3 border-b border-gray-100 active:opacity-70"
|
||||
>
|
||||
<View className="flex-row items-center gap-3">
|
||||
<Ionicons name="repeat-outline" size={20} color="#6b7280" />
|
||||
<Text className="text-base text-gray-900">{t('settings.fixedCosts')}</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={16} color="#9ca3af" />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => router.push("/(app)/settings/transfer-line-items")}
|
||||
className="flex-row items-center justify-between py-3 border-b border-gray-100 active:opacity-70"
|
||||
>
|
||||
<View className="flex-row items-center gap-3">
|
||||
<Ionicons name="swap-horizontal-outline" size={20} color="#6b7280" />
|
||||
<Text className="text-base text-gray-900">{t('settings.transferItems')}</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={16} color="#9ca3af" />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={handleLanguageChange}
|
||||
className="flex-row items-center justify-between py-3 active:opacity-70"
|
||||
>
|
||||
<View className="flex-row items-center gap-3">
|
||||
<Ionicons name="language-outline" size={20} color="#6b7280" />
|
||||
<Text className="text-base text-gray-900">{t('settings.language')}</Text>
|
||||
</View>
|
||||
<View className="flex-row items-center gap-1">
|
||||
<Text className="text-sm text-gray-400">
|
||||
{(() => {
|
||||
switch (hhSettings?.language) {
|
||||
case "de": return t('settings.languageDe');
|
||||
case "en": return t('settings.languageEn');
|
||||
default: return t('settings.languageAuto');
|
||||
}
|
||||
})()}
|
||||
</Text>
|
||||
<Ionicons name="chevron-forward" size={16} color="#9ca3af" />
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Sign Out */}
|
||||
<Pressable
|
||||
onPress={handleSignOut}
|
||||
className="rounded-xl bg-red-50 p-4 flex-row items-center justify-center gap-2 active:opacity-70"
|
||||
>
|
||||
<Ionicons name="log-out-outline" size={18} color="#dc2626" />
|
||||
<Text className="text-base font-semibold text-red-600">{t('settings.logout')}</Text>
|
||||
</Pressable>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
165
apps/native/app/(app)/settings/transfer-line-items.tsx
Normal file
165
apps/native/app/(app)/settings/transfer-line-items.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Modal,
|
||||
Pressable,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
FlatList,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
useTransferLineItems,
|
||||
useCreateTransferLineItem,
|
||||
useDeleteTransferLineItem,
|
||||
type TransferLineItem,
|
||||
} from "@/src/hooks/useFixedCosts";
|
||||
import { ModalHeader } from "@/src/components/ui/ModalHeader";
|
||||
import { Numpad } from "@/src/components/ui/Numpad";
|
||||
import { formatEur } from "@/src/utils/format";
|
||||
import { handleNumpadKey, parseAmountStr } from "@/src/utils/numpad";
|
||||
|
||||
function AddModal({ onClose }: { onClose: () => void }) {
|
||||
const [label, setLabel] = useState("");
|
||||
const [amountStr, setAmountStr] = useState("0");
|
||||
const { mutate: create, isPending } = useCreateTransferLineItem();
|
||||
const { t } = useTranslation();
|
||||
|
||||
function handleNumpad(key: string) {
|
||||
setAmountStr((prev) => handleNumpadKey(prev, key));
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
const amount = parseAmountStr(amountStr);
|
||||
if (!label.trim() || !amount || amount <= 0) return;
|
||||
create({ label: label.trim(), amount }, { onSuccess: onClose });
|
||||
}
|
||||
|
||||
const canSave = label.trim().length > 0 && parseAmountStr(amountStr) > 0;
|
||||
|
||||
return (
|
||||
<Modal visible animationType="slide" presentationStyle="pageSheet" onRequestClose={onClose}>
|
||||
<View className="flex-1 bg-white">
|
||||
<ModalHeader
|
||||
title={t('transferItems.addTitle')}
|
||||
onClose={onClose}
|
||||
closeLabel={t('common.cancel')}
|
||||
onSave={handleSave}
|
||||
saveLabel={t('common.save')}
|
||||
saveDisabled={!canSave}
|
||||
saveLoading={isPending}
|
||||
/>
|
||||
|
||||
<View className="items-center py-6">
|
||||
<Text className="text-5xl font-bold text-gray-900">€ {amountStr}</Text>
|
||||
<Text className="text-sm text-gray-400 mt-1">{t('transferItems.monthlyFixedAmount')}</Text>
|
||||
</View>
|
||||
|
||||
<View className="px-4 mb-4">
|
||||
<Text className="text-sm font-medium text-gray-700 mb-1">{t('transferItems.labelRequired')}</Text>
|
||||
<TextInput
|
||||
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900"
|
||||
placeholder={t('transferItems.labelPlaceholder')}
|
||||
value={label}
|
||||
onChangeText={setLabel}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Numpad onKeyPress={handleNumpad} />
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TransferLineItemsScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const { data: items = [], isLoading } = useTransferLineItems();
|
||||
const { mutate: deleteItem } = useDeleteTransferLineItem();
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
|
||||
const total = items.reduce((sum, i) => sum + i.amount, 0);
|
||||
|
||||
function handleDelete(item: TransferLineItem) {
|
||||
Alert.alert(
|
||||
t('transferItems.removeTitle'),
|
||||
t('transferItems.removeMessage', { label: item.label }),
|
||||
[
|
||||
{ text: t('common.cancel'), style: "cancel" },
|
||||
{ text: t('transferItems.remove'), style: "destructive", onPress: () => deleteItem(item.id) },
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-gray-50">
|
||||
<View className="bg-white border-b border-gray-100" style={{ paddingTop: insets.top }}>
|
||||
<View className="flex-row items-center px-4 py-3">
|
||||
<Pressable onPress={() => router.push("/(app)/settings")} className="mr-3 p-1">
|
||||
<Ionicons name="chevron-back" size={22} color="#374151" />
|
||||
</Pressable>
|
||||
<Text className="text-base font-semibold text-gray-900 flex-1">{t('transferItems.title')}</Text>
|
||||
<Pressable
|
||||
onPress={() => setShowAdd(true)}
|
||||
className="flex-row items-center gap-1 px-3 py-1.5 rounded-full"
|
||||
style={{ backgroundColor: "#dbeafe" }}
|
||||
>
|
||||
<Ionicons name="add" size={14} color="#2563EB" />
|
||||
<Text className="text-xs font-semibold text-blue-600">{t('transferItems.new')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text className="text-xs text-gray-400 px-4 py-3">
|
||||
{t('transferItems.hint')}
|
||||
</Text>
|
||||
|
||||
{isLoading ? (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<ActivityIndicator size="large" color="#2563EB" />
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={items}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item }) => (
|
||||
<View className="flex-row items-center px-4 py-3 bg-white border-b border-gray-50">
|
||||
<View className="flex-1">
|
||||
<Text className="text-sm font-medium text-gray-900">{item.label}</Text>
|
||||
<Text className="text-xs text-gray-400 mt-0.5">{t('common.monthly')}</Text>
|
||||
</View>
|
||||
<Text className="text-sm font-semibold text-gray-800 mr-3">{formatEur(item.amount, false)}</Text>
|
||||
<Pressable onPress={() => handleDelete(item)} hitSlop={8} className="p-1">
|
||||
<Ionicons name="trash-outline" size={16} color="#d1d5db" />
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
<View className="px-4 py-8 items-center">
|
||||
<Text className="text-sm text-gray-400 text-center">
|
||||
{t('transferItems.empty')}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
ListFooterComponent={
|
||||
items.length > 0 ? (
|
||||
<View className="flex-row items-center justify-between px-4 py-3 bg-white mt-3">
|
||||
<Text className="text-sm font-semibold text-gray-700">{t('transferItems.totalMonthly')}</Text>
|
||||
<Text className="text-sm font-bold text-blue-600">{formatEur(total, false)}</Text>
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
contentContainerStyle={{ paddingBottom: insets.bottom + 24 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showAdd && <AddModal onClose={() => setShowAdd(false)} />}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
208
apps/native/app/(app)/shopping-list/index.tsx
Normal file
208
apps/native/app/(app)/shopping-list/index.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import { useShoppingList, type ShoppingItem } from "@/src/hooks/useShoppingList";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
FlatList,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Pressable,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
const ACCENT = "#16a34a";
|
||||
|
||||
function StatusDot({ status }: { status: "connecting" | "connected" | "offline" }) {
|
||||
const { t } = useTranslation();
|
||||
if (status === "connected") {
|
||||
return <View className="w-2 h-2 rounded-full bg-green-500" />;
|
||||
}
|
||||
return (
|
||||
<View className="flex-row items-center gap-1">
|
||||
<View className="w-2 h-2 rounded-full bg-gray-400" />
|
||||
{status === "offline" && (
|
||||
<Text className="text-xs text-gray-400">{t("shopping.offline")}</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ShoppingItemRow({
|
||||
item,
|
||||
onToggle,
|
||||
onDelete,
|
||||
}: {
|
||||
item: ShoppingItem;
|
||||
onToggle: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const isChecked = item.checkedBy !== null;
|
||||
return (
|
||||
<View className="flex-row items-center px-4 py-3 bg-white">
|
||||
<TouchableOpacity onPress={onToggle} className="mr-3 active:opacity-60">
|
||||
<Ionicons
|
||||
name={isChecked ? "checkbox" : "square-outline"}
|
||||
size={24}
|
||||
color={isChecked ? "#9ca3af" : ACCENT}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<Text
|
||||
className="flex-1 text-base"
|
||||
style={{
|
||||
color: isChecked ? "#9ca3af" : "#111827",
|
||||
textDecorationLine: isChecked ? "line-through" : "none",
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
{item.quantity ? (
|
||||
<Text style={{ color: "#9ca3af", fontSize: 13 }}> {item.quantity}</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={onDelete} className="p-1 active:opacity-60">
|
||||
<Ionicons name="trash-outline" size={18} color="#d1d5db" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ShoppingListScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t } = useTranslation();
|
||||
const { items, status, addItem, toggleItem, deleteItem, deleteChecked } = useShoppingList();
|
||||
const [text, setText] = useState("");
|
||||
const [quantity, setQuantity] = useState("");
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
|
||||
const unchecked = items.filter((i) => i.checkedBy === null);
|
||||
const checked = items.filter((i) => i.checkedBy !== null);
|
||||
const sorted = [...unchecked, ...checked];
|
||||
const hasChecked = checked.length > 0;
|
||||
|
||||
function handleAdd() {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return;
|
||||
addItem(trimmed, quantity.trim() || undefined);
|
||||
setText("");
|
||||
setQuantity("");
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
className="flex-1 bg-gray-50"
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
keyboardVerticalOffset={0}
|
||||
>
|
||||
{/* Header */}
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: "#fff",
|
||||
paddingTop: insets.top,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#f3f4f6",
|
||||
}}
|
||||
className="px-4 pb-3 flex-row items-center justify-between"
|
||||
>
|
||||
<Text className="text-xl font-bold text-gray-900">{t("shopping.title")}</Text>
|
||||
<View className="flex-row items-center gap-2">
|
||||
{hasChecked && (
|
||||
<Pressable
|
||||
onPress={deleteChecked}
|
||||
className="rounded-full px-3 py-1 active:opacity-70"
|
||||
style={{ backgroundColor: "#f3f4f6" }}
|
||||
>
|
||||
<Text className="text-xs font-medium text-gray-600">
|
||||
{t("shopping.deleteChecked")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
<StatusDot status={status} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* List */}
|
||||
<FlatList
|
||||
data={sorted}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item, index }) => {
|
||||
const isFirstChecked =
|
||||
item.checkedBy !== null &&
|
||||
(index === 0 || sorted[index - 1]?.checkedBy === null);
|
||||
return (
|
||||
<>
|
||||
{isFirstChecked && unchecked.length > 0 && (
|
||||
<View className="h-px bg-gray-200 mx-4 my-1" />
|
||||
)}
|
||||
<ShoppingItemRow
|
||||
item={item}
|
||||
onToggle={() => toggleItem(item)}
|
||||
onDelete={() => deleteItem(item.id)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
ItemSeparatorComponent={() => <View className="h-px bg-gray-100 ml-14" />}
|
||||
ListEmptyComponent={
|
||||
<View className="flex-1 items-center justify-center py-24">
|
||||
<Ionicons
|
||||
name="cart-outline"
|
||||
size={48}
|
||||
color="#d1d5db"
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
<Text className="text-base font-medium text-gray-700 mb-1">
|
||||
{t("shopping.empty")}
|
||||
</Text>
|
||||
<Text className="text-sm text-gray-400 text-center px-8">
|
||||
{t("shopping.emptyHint")}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
contentContainerStyle={sorted.length === 0 ? { flex: 1 } : { paddingBottom: 8 }}
|
||||
/>
|
||||
|
||||
{/* Input bar */}
|
||||
<View
|
||||
style={{ paddingBottom: insets.bottom || 16 }}
|
||||
className="px-4 pt-3 bg-white border-t border-gray-100"
|
||||
>
|
||||
<View className="flex-row items-center gap-3 mb-2">
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
value={text}
|
||||
onChangeText={setText}
|
||||
placeholder={t("shopping.placeholder")}
|
||||
placeholderTextColor="#9ca3af"
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleAdd}
|
||||
blurOnSubmit={false}
|
||||
className="flex-1 text-base text-gray-900 py-2"
|
||||
/>
|
||||
<Pressable
|
||||
onPress={handleAdd}
|
||||
disabled={!text.trim()}
|
||||
style={{ backgroundColor: text.trim() ? ACCENT : "#e5e7eb" }}
|
||||
className="w-9 h-9 rounded-full items-center justify-center active:opacity-70"
|
||||
>
|
||||
<Ionicons name="arrow-up" size={18} color={text.trim() ? "#fff" : "#9ca3af"} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<TextInput
|
||||
value={quantity}
|
||||
onChangeText={setQuantity}
|
||||
placeholder={t("shopping.quantityPlaceholder")}
|
||||
placeholderTextColor="#9ca3af"
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleAdd}
|
||||
blurOnSubmit={false}
|
||||
className="text-sm text-gray-600 py-1"
|
||||
/>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
109
apps/native/app/(app)/transactions/index.tsx
Normal file
109
apps/native/app/(app)/transactions/index.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { QuickAddModal } from "@/src/components/features/transactions/QuickAddModal";
|
||||
import { SummaryHeader } from "@/src/components/features/transactions/SummaryHeader";
|
||||
import { TransactionItem } from "@/src/components/features/transactions/TransactionItem";
|
||||
import { EditTransactionModal } from "@/src/components/features/transactions/EditTransactionModal";
|
||||
import { useTransactions, useTransactionSummary, useDeleteTransaction, type TransactionWithCategory } from "@/src/hooks/useTransactions";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
Pressable,
|
||||
RefreshControl,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
type FilterType = "all" | "income" | "expense";
|
||||
|
||||
export default function TransactionsScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const [filter, setFilter] = useState<FilterType>("all");
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [editTransaction, setEditTransaction] = useState<TransactionWithCategory | null>(null);
|
||||
const { mutate: deleteTransaction } = useDeleteTransaction();
|
||||
|
||||
const transactionFilter = filter === "all" ? undefined : { type: filter as "income" | "expense" };
|
||||
const { data: transactions = [], isLoading, refetch, isRefetching } = useTransactions(transactionFilter);
|
||||
const { data: summary, isLoading: summaryLoading } = useTransactionSummary();
|
||||
|
||||
function renderEmpty() {
|
||||
if (isLoading) return null;
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center py-20">
|
||||
<Ionicons name="wallet-outline" size={48} color="#d1d5db" style={{ marginBottom: 12 }} />
|
||||
<Text className="text-base font-medium text-gray-700 mb-1">Noch keine Buchungen</Text>
|
||||
<Text className="text-sm text-gray-400">Tippe auf + um deine erste Buchung einzutragen</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-white">
|
||||
<View className="bg-blue-600" style={{ paddingTop: insets.top }}>
|
||||
<SummaryHeader summary={summary} isLoading={summaryLoading} />
|
||||
</View>
|
||||
|
||||
{/* Filter Bar */}
|
||||
<View className="flex-row px-4 py-3 gap-2 border-b border-gray-100">
|
||||
{(["all", "expense", "income"] as const).map((f) => (
|
||||
<Pressable
|
||||
key={f}
|
||||
onPress={() => setFilter(f)}
|
||||
className={`px-4 py-1.5 rounded-full ${filter === f ? "bg-blue-600" : "bg-gray-100"}`}
|
||||
>
|
||||
<Text className={`text-sm font-medium ${filter === f ? "text-white" : "text-gray-600"}`}>
|
||||
{f === "all" ? "Alle" : f === "expense" ? "Ausgaben" : "Einnahmen"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Transaction List */}
|
||||
{isLoading ? (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<ActivityIndicator size="large" color="#2563EB" />
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={transactions}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item }) => (
|
||||
<TransactionItem
|
||||
transaction={item}
|
||||
onPress={setEditTransaction}
|
||||
onDelete={(t) => deleteTransaction(t.id)}
|
||||
/>
|
||||
)}
|
||||
ListEmptyComponent={renderEmpty}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={isRefetching} onRefresh={() => void refetch()} />
|
||||
}
|
||||
ItemSeparatorComponent={() => <View className="h-px bg-gray-50 ml-16" />}
|
||||
contentContainerStyle={transactions.length === 0 ? { flex: 1 } : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* FAB */}
|
||||
<Pressable
|
||||
onPress={() => setShowAddModal(true)}
|
||||
className="absolute bottom-6 right-6 w-14 h-14 bg-blue-600 rounded-full items-center justify-center shadow-lg active:opacity-80"
|
||||
>
|
||||
<Ionicons name="add" size={28} color="#fff" />
|
||||
</Pressable>
|
||||
|
||||
<QuickAddModal
|
||||
visible={showAddModal}
|
||||
onClose={() => setShowAddModal(false)}
|
||||
onRequestAddCategory={() => {}}
|
||||
/>
|
||||
{editTransaction && (
|
||||
<EditTransactionModal
|
||||
transaction={editTransaction}
|
||||
onClose={() => setEditTransaction(null)}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
860
apps/native/app/(app)/urlaub/[id].tsx
Normal file
860
apps/native/app/(app)/urlaub/[id].tsx
Normal file
@@ -0,0 +1,860 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
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