- 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>
648 lines
26 KiB
TypeScript
648 lines
26 KiB
TypeScript
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>
|
||
);
|
||
}
|