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:
17
apps/native/src/components/features/PlaceholderScreen.tsx
Normal file
17
apps/native/src/components/features/PlaceholderScreen.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Text, View } from "react-native";
|
||||
|
||||
type PlaceholderScreenProps = {
|
||||
title: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export function PlaceholderScreen({ title, description }: PlaceholderScreenProps) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center p-6">
|
||||
<Text className="mb-2 text-2xl font-bold">{title}</Text>
|
||||
{description && (
|
||||
<Text className="text-center text-gray-500">{description}</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import { useCreateCategory, type Category } from "@/src/hooks/useCategories";
|
||||
import { ModalHeader } from "@/src/components/ui/ModalHeader";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Modal,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from "react-native";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// 30 emoji-like icon names from Ionicons — no external lib needed
|
||||
const ICON_OPTIONS: Array<{ name: React.ComponentProps<typeof Ionicons>["name"]; label: string }> = [
|
||||
{ name: "cart-outline", label: "Einkauf" },
|
||||
{ name: "home-outline", label: "Haus" },
|
||||
{ name: "car-outline", label: "Auto" },
|
||||
{ name: "medkit-outline", label: "Gesundheit" },
|
||||
{ name: "game-controller-outline", label: "Spiel" },
|
||||
{ name: "happy-outline", label: "Kinder" },
|
||||
{ name: "airplane-outline", label: "Urlaub" },
|
||||
{ name: "briefcase-outline", label: "Arbeit" },
|
||||
{ name: "cash-outline", label: "Geld" },
|
||||
{ name: "restaurant-outline", label: "Essen" },
|
||||
{ name: "fitness-outline", label: "Sport" },
|
||||
{ name: "book-outline", label: "Bildung" },
|
||||
{ name: "musical-notes-outline", label: "Musik" },
|
||||
{ name: "phone-portrait-outline", label: "Handy" },
|
||||
{ name: "wifi-outline", label: "Internet" },
|
||||
{ name: "shirt-outline", label: "Kleidung" },
|
||||
{ name: "paw-outline", label: "Tier" },
|
||||
{ name: "gift-outline", label: "Geschenk" },
|
||||
{ name: "construct-outline", label: "Reparatur" },
|
||||
{ name: "cut-outline", label: "Friseur" },
|
||||
{ name: "bus-outline", label: "Bus" },
|
||||
{ name: "train-outline", label: "Bahn" },
|
||||
{ name: "bicycle-outline", label: "Fahrrad" },
|
||||
{ name: "cafe-outline", label: "Café" },
|
||||
{ name: "beer-outline", label: "Bar" },
|
||||
{ name: "tv-outline", label: "TV" },
|
||||
{ name: "camera-outline", label: "Foto" },
|
||||
{ name: "flower-outline", label: "Garten" },
|
||||
{ name: "star-outline", label: "Sonstiges" },
|
||||
{ name: "ellipsis-horizontal-circle-outline", label: "Allgemein" },
|
||||
];
|
||||
|
||||
const COLORS = [
|
||||
"#10b981", "#6366f1", "#f59e0b", "#ef4444", "#8b5cf6",
|
||||
"#ec4899", "#0ea5e9", "#6b7280", "#f97316", "#14b8a6",
|
||||
"#84cc16", "#a855f7",
|
||||
];
|
||||
|
||||
type Props = {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
defaultType?: "income" | "expense";
|
||||
onCreated?: (cat: Category) => void;
|
||||
};
|
||||
|
||||
export function AddCategoryModal({ visible, onClose, defaultType = "expense", onCreated }: Props) {
|
||||
const [name, setName] = useState("");
|
||||
const [selectedIcon, setSelectedIcon] = useState<React.ComponentProps<typeof Ionicons>["name"]>("star-outline");
|
||||
const [selectedColor, setSelectedColor] = useState(COLORS[0]!);
|
||||
const [type, setType] = useState<"income" | "expense">(defaultType);
|
||||
const [iconPickerOpen, setIconPickerOpen] = useState(false);
|
||||
|
||||
const { mutate: create, isPending } = useCreateCategory();
|
||||
const { t } = useTranslation();
|
||||
|
||||
function handleSave() {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
create(
|
||||
{ name: trimmed, icon: selectedIcon, color: selectedColor, type },
|
||||
{
|
||||
onSuccess: (cat) => {
|
||||
onCreated?.(cat);
|
||||
resetAndClose();
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function resetAndClose() {
|
||||
setName("");
|
||||
setSelectedIcon("star-outline");
|
||||
setSelectedColor(COLORS[0]!);
|
||||
setType(defaultType);
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide" presentationStyle="pageSheet" onRequestClose={resetAndClose}>
|
||||
<View className="flex-1 bg-white">
|
||||
{/* Header */}
|
||||
<ModalHeader
|
||||
title={t('categories.addTitle')}
|
||||
onClose={resetAndClose}
|
||||
closeLabel={t('common.cancel')}
|
||||
onSave={handleSave}
|
||||
saveLabel={t('common.create')}
|
||||
saveDisabled={!name.trim()}
|
||||
saveLoading={isPending}
|
||||
/>
|
||||
|
||||
<ScrollView contentContainerStyle={{ padding: 16 }}>
|
||||
{/* Type Toggle */}
|
||||
<View className="flex-row p-1 bg-gray-100 rounded-xl mb-5">
|
||||
<Pressable
|
||||
onPress={() => setType("expense")}
|
||||
className={`flex-1 py-2 rounded-lg items-center ${type === "expense" ? "bg-white shadow-sm" : ""}`}
|
||||
>
|
||||
<Text className={`font-medium ${type === "expense" ? "text-red-600" : "text-gray-500"}`}>
|
||||
{t('categories.expenseType')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => setType("income")}
|
||||
className={`flex-1 py-2 rounded-lg items-center ${type === "income" ? "bg-white shadow-sm" : ""}`}
|
||||
>
|
||||
<Text className={`font-medium ${type === "income" ? "text-green-600" : "text-gray-500"}`}>
|
||||
{t('categories.incomeType')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Name */}
|
||||
<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 mb-5"
|
||||
placeholder={t('categories.namePlaceholder')}
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
{/* Preview */}
|
||||
<View className="flex-row items-center gap-3 mb-5 p-3 bg-gray-50 rounded-xl">
|
||||
<View
|
||||
className="w-10 h-10 rounded-full items-center justify-center"
|
||||
style={{ backgroundColor: selectedColor }}
|
||||
>
|
||||
<Ionicons name={selectedIcon} size={20} color="#fff" />
|
||||
</View>
|
||||
<Text className="text-base font-medium text-gray-800">
|
||||
{name.trim() || t('common.preview')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Color Picker */}
|
||||
<Text className="text-sm font-medium text-gray-700 mb-3">{t('categories.colorLabel')}</Text>
|
||||
<View className="flex-row flex-wrap gap-2 mb-5">
|
||||
{COLORS.map((c) => (
|
||||
<Pressable
|
||||
key={c}
|
||||
onPress={() => setSelectedColor(c)}
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: c,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderWidth: selectedColor === c ? 3 : 0,
|
||||
borderColor: "#fff",
|
||||
shadowColor: selectedColor === c ? c : "transparent",
|
||||
shadowOpacity: selectedColor === c ? 0.6 : 0,
|
||||
shadowRadius: 4,
|
||||
elevation: selectedColor === c ? 4 : 0,
|
||||
}}
|
||||
>
|
||||
{selectedColor === c && <Ionicons name="checkmark" size={16} color="#fff" />}
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Icon Picker — select row */}
|
||||
<Text className="text-sm font-medium text-gray-700 mb-2">{t('categories.iconLabel')}</Text>
|
||||
<Pressable
|
||||
onPress={() => setIconPickerOpen((v) => !v)}
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: "#f3f4f6",
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
marginBottom: iconPickerOpen ? 8 : 0,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: selectedColor,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginRight: 10,
|
||||
}}
|
||||
>
|
||||
<Ionicons name={selectedIcon} size={17} color="#fff" />
|
||||
</View>
|
||||
<Text style={{ flex: 1, fontSize: 14, color: "#374151" }}>
|
||||
{ICON_OPTIONS.find((o) => o.name === selectedIcon)?.label ?? t('categories.selectIcon')}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name={iconPickerOpen ? "chevron-up" : "chevron-down"}
|
||||
size={16}
|
||||
color="#9ca3af"
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
{/* Dropdown grid */}
|
||||
{iconPickerOpen && (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: "#fff",
|
||||
borderWidth: 1,
|
||||
borderColor: "#e5e7eb",
|
||||
borderRadius: 12,
|
||||
padding: 8,
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: 4,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{ICON_OPTIONS.map((opt) => {
|
||||
const active = selectedIcon === opt.name;
|
||||
return (
|
||||
<Pressable
|
||||
key={opt.name}
|
||||
onPress={() => {
|
||||
setSelectedIcon(opt.name);
|
||||
setIconPickerOpen(false);
|
||||
}}
|
||||
style={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 10,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: active ? selectedColor : "#f3f4f6",
|
||||
}}
|
||||
>
|
||||
<Ionicons name={opt.name} size={20} color={active ? "#fff" : "#6b7280"} />
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
187
apps/native/src/components/features/debts/AddDebtModal.tsx
Normal file
187
apps/native/src/components/features/debts/AddDebtModal.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Modal,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useCreateDebt } from "@/src/hooks/useDebts";
|
||||
import { useHouseholdMembers } from "@/src/hooks/useHouseholdMembers";
|
||||
import { useAuthStore } from "@/src/stores/auth.store";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ModalHeader } from "@/src/components/ui/ModalHeader";
|
||||
import { Numpad } from "@/src/components/ui/Numpad";
|
||||
import { handleNumpadKey, parseAmountStr } from "@/src/utils/numpad";
|
||||
|
||||
type Props = {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function AddDebtModal({ visible, onClose }: Props) {
|
||||
const [label, setLabel] = useState("");
|
||||
const [amountStr, setAmountStr] = useState("0");
|
||||
const [notes, setNotes] = useState("");
|
||||
// creditor: internal member OR free text
|
||||
const [creditorUserId, setCreditorUserId] = useState<string | null>(null);
|
||||
const [creditorText, setCreditorText] = useState("");
|
||||
const [showMemberPicker, setShowMemberPicker] = useState(false);
|
||||
|
||||
const { mutate: createDebt, isPending } = useCreateDebt();
|
||||
const { data: membersData } = useHouseholdMembers();
|
||||
const myUserId = useAuthStore((s) => s.user?.id);
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Only other members (not myself)
|
||||
const otherMembers = (membersData?.members ?? []).filter((m) => m.userId !== myUserId);
|
||||
|
||||
const selectedMember = otherMembers.find((m) => m.userId === creditorUserId) ?? null;
|
||||
|
||||
function handleNumpad(key: string) {
|
||||
setAmountStr((prev) => handleNumpadKey(prev, key));
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
const amount = parseAmountStr(amountStr);
|
||||
if (!label.trim() || !amount || amount <= 0) return;
|
||||
createDebt(
|
||||
{
|
||||
label: label.trim(),
|
||||
creditorUserId: creditorUserId ?? undefined,
|
||||
creditor: !creditorUserId && creditorText.trim() ? creditorText.trim() : undefined,
|
||||
totalAmount: amount,
|
||||
notes: notes.trim() || undefined,
|
||||
},
|
||||
{ onSuccess: resetAndClose },
|
||||
);
|
||||
}
|
||||
|
||||
function resetAndClose() {
|
||||
setLabel("");
|
||||
setAmountStr("0");
|
||||
setNotes("");
|
||||
setCreditorUserId(null);
|
||||
setCreditorText("");
|
||||
setShowMemberPicker(false);
|
||||
onClose();
|
||||
}
|
||||
|
||||
const canSave = label.trim().length > 0 && parseAmountStr(amountStr) > 0;
|
||||
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide" presentationStyle="pageSheet" onRequestClose={resetAndClose}>
|
||||
<View className="flex-1 bg-white">
|
||||
{/* Header */}
|
||||
<ModalHeader
|
||||
title={t('debts.addTitle')}
|
||||
onClose={resetAndClose}
|
||||
closeLabel={t('common.cancel')}
|
||||
onSave={handleSave}
|
||||
saveLabel={t('common.save')}
|
||||
saveDisabled={!canSave}
|
||||
saveLoading={isPending}
|
||||
saveColor="#7c3aed"
|
||||
/>
|
||||
|
||||
<ScrollView keyboardShouldPersistTaps="handled" contentContainerStyle={{ paddingBottom: 24 }}>
|
||||
{/* Amount display */}
|
||||
<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('debts.totalAmount')}</Text>
|
||||
</View>
|
||||
|
||||
{/* Fields */}
|
||||
<View className="px-4 gap-3 mb-4">
|
||||
<View>
|
||||
<Text className="text-sm font-medium text-gray-700 mb-1">{t('debts.labelRequired')}</Text>
|
||||
<TextInput
|
||||
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900"
|
||||
placeholder={t('debts.labelPlaceholder')}
|
||||
value={label}
|
||||
onChangeText={setLabel}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Creditor picker */}
|
||||
<View>
|
||||
<Text className="text-sm font-medium text-gray-700 mb-1">{t('debts.iOweMoneyTo')}</Text>
|
||||
|
||||
{/* Member select row */}
|
||||
{otherMembers.length > 0 && (
|
||||
<Pressable
|
||||
onPress={() => setShowMemberPicker((v) => !v)}
|
||||
className="flex-row items-center bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 mb-2"
|
||||
>
|
||||
<Ionicons name="people-outline" size={16} color="#7c3aed" style={{ marginRight: 8 }} />
|
||||
<Text className="flex-1 text-base" style={{ color: selectedMember ? "#111827" : "#9ca3af" }}>
|
||||
{selectedMember ? selectedMember.name : t('debts.selectMember')}
|
||||
</Text>
|
||||
{selectedMember ? (
|
||||
<Pressable
|
||||
onPress={(e) => { e.stopPropagation(); setCreditorUserId(null); }}
|
||||
hitSlop={8}
|
||||
>
|
||||
<Ionicons name="close-circle" size={18} color="#9ca3af" />
|
||||
</Pressable>
|
||||
) : (
|
||||
<Ionicons name={showMemberPicker ? "chevron-up" : "chevron-down"} size={14} color="#9ca3af" />
|
||||
)}
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
{/* Member dropdown */}
|
||||
{showMemberPicker && (
|
||||
<View className="bg-white border border-gray-200 rounded-xl mb-2 overflow-hidden">
|
||||
{otherMembers.map((m) => (
|
||||
<Pressable
|
||||
key={m.userId}
|
||||
onPress={() => { setCreditorUserId(m.userId); setCreditorText(""); setShowMemberPicker(false); }}
|
||||
className="flex-row items-center px-4 py-3 active:bg-gray-50"
|
||||
style={{ borderBottomWidth: 1, borderBottomColor: "#f3f4f6" }}
|
||||
>
|
||||
<View className="w-7 h-7 rounded-full bg-purple-100 items-center justify-center mr-3">
|
||||
<Text className="text-xs font-bold text-purple-700">
|
||||
{m.name.charAt(0).toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
<Text className="text-sm text-gray-800">{m.name}</Text>
|
||||
{creditorUserId === m.userId && (
|
||||
<Ionicons name="checkmark" size={16} color="#7c3aed" style={{ marginLeft: "auto" }} />
|
||||
)}
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Free-text fallback (only when no member selected) */}
|
||||
{!creditorUserId && (
|
||||
<TextInput
|
||||
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900"
|
||||
placeholder={t('debts.orEnterName')}
|
||||
value={creditorText}
|
||||
onChangeText={setCreditorText}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View>
|
||||
<Text className="text-sm font-medium text-gray-700 mb-1">{t('debts.noteOptional')}</Text>
|
||||
<TextInput
|
||||
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900"
|
||||
placeholder={t('debts.notePlaceholder')}
|
||||
value={notes}
|
||||
onChangeText={setNotes}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Numpad */}
|
||||
<Numpad onKeyPress={handleNumpad} />
|
||||
</ScrollView>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Modal,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useCreateDebtPayment, type Debt } from "@/src/hooks/useDebts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ModalHeader } from "@/src/components/ui/ModalHeader";
|
||||
import { Numpad } from "@/src/components/ui/Numpad";
|
||||
import { todayIso } from "@/src/utils/date";
|
||||
import { formatEur } from "@/src/utils/format";
|
||||
import { handleNumpadKey, parseAmountStr } from "@/src/utils/numpad";
|
||||
|
||||
type Props = {
|
||||
visible: boolean;
|
||||
debt: Debt;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function AddDebtPaymentModal({ visible, debt, onClose }: Props) {
|
||||
const [amountStr, setAmountStr] = useState("0");
|
||||
const [note, setNote] = useState("");
|
||||
|
||||
const { mutate: createPayment, isPending } = useCreateDebtPayment();
|
||||
const { t } = useTranslation();
|
||||
|
||||
function handleNumpad(key: string) {
|
||||
setAmountStr((prev) => handleNumpadKey(prev, key));
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
const amount = parseAmountStr(amountStr);
|
||||
if (!amount || amount <= 0) return;
|
||||
createPayment(
|
||||
{
|
||||
debtId: debt.id,
|
||||
amount,
|
||||
date: todayIso(),
|
||||
note: note.trim() || undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
resetAndClose();
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function resetAndClose() {
|
||||
setAmountStr("0");
|
||||
setNote("");
|
||||
onClose();
|
||||
}
|
||||
|
||||
const parsedAmount = parseAmountStr(amountStr);
|
||||
const canSave = parsedAmount > 0;
|
||||
const isOverpaying = parsedAmount > debt.remainingAmount + 0.005;
|
||||
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide" presentationStyle="pageSheet" onRequestClose={resetAndClose}>
|
||||
<View className="flex-1 bg-white">
|
||||
{/* Header */}
|
||||
<ModalHeader
|
||||
title={t('debts.payRate')}
|
||||
onClose={resetAndClose}
|
||||
closeLabel={t('common.cancel')}
|
||||
onSave={handleSave}
|
||||
saveLabel={t('common.book')}
|
||||
saveDisabled={!canSave}
|
||||
saveLoading={isPending}
|
||||
saveColor="#7c3aed"
|
||||
/>
|
||||
|
||||
{/* Debt info */}
|
||||
<View className="mx-4 mt-4 p-4 bg-purple-50 rounded-2xl mb-2">
|
||||
<Text className="text-sm font-semibold text-purple-900">{debt.label}</Text>
|
||||
{debt.creditor && (
|
||||
<Text className="text-xs text-purple-500 mt-0.5">{debt.creditor}</Text>
|
||||
)}
|
||||
<Text className="text-xs text-purple-600 mt-2">
|
||||
{t('debts.remaining', { amount: formatEur(debt.remainingAmount, false) })}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Amount display */}
|
||||
<View className="items-center py-6">
|
||||
<Text className="text-5xl font-bold text-gray-900">€ {amountStr}</Text>
|
||||
{isOverpaying && (
|
||||
<Text className="text-xs text-orange-500 mt-1">
|
||||
{t('debts.overpayingWarning')}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Note field */}
|
||||
<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('debts.noteOptional')}
|
||||
value={note}
|
||||
onChangeText={setNote}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Numpad */}
|
||||
<Numpad onKeyPress={handleNumpad} />
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
148
apps/native/src/components/features/debts/ClaimsSection.tsx
Normal file
148
apps/native/src/components/features/debts/ClaimsSection.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useState } from "react";
|
||||
import { ActivityIndicator, Pressable, Text, View } from "react-native";
|
||||
import { useClaims, type Debt } from "@/src/hooks/useDebts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatEur } from "@/src/utils/format";
|
||||
|
||||
function ClaimCard({ debt }: { debt: Debt }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const isClosed = debt.closedAt !== null;
|
||||
const accentColor = isClosed ? "#10b981" : "#7c3aed";
|
||||
const pct = Math.round(debt.progressPercent);
|
||||
const { t } = useTranslation();
|
||||
|
||||
// creditorUserName here = the debtor's name (person who owes me money)
|
||||
const debtorName = debt.creditorUserName ?? t('debts.unknown');
|
||||
|
||||
return (
|
||||
<View
|
||||
className="bg-white rounded-2xl mx-4 mb-2 overflow-hidden"
|
||||
style={{ borderWidth: 1, borderColor: isClosed ? "#d1fae5" : "#e0e7ff" }}
|
||||
>
|
||||
<Pressable
|
||||
onPress={() => setExpanded((v) => !v)}
|
||||
className="flex-row items-center px-4 py-3 active:opacity-80"
|
||||
>
|
||||
<View
|
||||
className="w-9 h-9 rounded-full items-center justify-center mr-3"
|
||||
style={{ backgroundColor: isClosed ? "#d1fae5" : "#e0e7ff" }}
|
||||
>
|
||||
<Ionicons
|
||||
name={isClosed ? "checkmark-circle" : "cash-outline"}
|
||||
size={18}
|
||||
color={accentColor}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="flex-1 mr-2">
|
||||
<View className="flex-row items-center justify-between mb-1.5">
|
||||
<Text className="text-sm font-semibold text-gray-900" numberOfLines={1}>
|
||||
{debt.label}
|
||||
</Text>
|
||||
<Text className="text-xs font-bold ml-2" style={{ color: accentColor }}>
|
||||
{pct}%
|
||||
</Text>
|
||||
</View>
|
||||
<View className="h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<View
|
||||
className="h-full rounded-full"
|
||||
style={{ width: `${debt.progressPercent}%`, backgroundColor: accentColor }}
|
||||
/>
|
||||
</View>
|
||||
<Text className="text-xs text-gray-400 mt-1">
|
||||
{t('debts.fromDebtor', { name: debtorName, amount: formatEur(debt.remainingAmount, false) })}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Ionicons
|
||||
name={expanded ? "chevron-up" : "chevron-down"}
|
||||
size={14}
|
||||
color="#9ca3af"
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
{expanded && (
|
||||
<View style={{ borderTopWidth: 1, borderTopColor: "#f3f4f6" }}>
|
||||
<View className="flex-row px-4 py-3 justify-between">
|
||||
<View>
|
||||
<Text className="text-xs text-gray-400">{t('debts.received')}</Text>
|
||||
<Text className="text-sm font-semibold" style={{ color: accentColor }}>
|
||||
{formatEur(debt.paidAmount, false)}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="items-center">
|
||||
<Text className="text-xs text-gray-400">{t('debts.total')}</Text>
|
||||
<Text className="text-sm font-semibold text-gray-700">
|
||||
{formatEur(debt.totalAmount, false)}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="items-end">
|
||||
<Text className="text-xs text-gray-400">{t('debts.pendingLabel')}</Text>
|
||||
<Text className="text-sm font-semibold text-gray-900">
|
||||
{formatEur(debt.remainingAmount, false)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
{debt.notes && (
|
||||
<Text className="text-xs text-gray-400 px-4 pb-3">{debt.notes}</Text>
|
||||
)}
|
||||
{isClosed && (
|
||||
<View className="mx-4 mb-3 px-3 py-2 rounded-xl" style={{ backgroundColor: "#d1fae5" }}>
|
||||
<Text className="text-xs font-medium text-center" style={{ color: "#059669" }}>
|
||||
{t('debts.fullyRepaid')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClaimsSection() {
|
||||
const { data: claims = [], isLoading } = useClaims();
|
||||
const { t } = useTranslation();
|
||||
const [showClosed, setShowClosed] = useState(false);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View className="py-4 items-center">
|
||||
<ActivityIndicator size="small" color="#7c3aed" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (claims.length === 0) return null;
|
||||
|
||||
const open = claims.filter((d) => d.closedAt === null);
|
||||
const closed = claims.filter((d) => d.closedAt !== null);
|
||||
|
||||
return (
|
||||
<View className="mb-2">
|
||||
<View className="flex-row items-center px-4 py-3">
|
||||
<Ionicons name="cash-outline" size={18} color="#7c3aed" style={{ marginRight: 8 }} />
|
||||
<Text className="text-sm font-semibold text-gray-800">{t('debts.claims')}</Text>
|
||||
</View>
|
||||
|
||||
{open.map((debt) => (
|
||||
<ClaimCard key={debt.id} debt={debt} />
|
||||
))}
|
||||
|
||||
{closed.length > 0 && (
|
||||
<>
|
||||
<Pressable
|
||||
onPress={() => setShowClosed((v) => !v)}
|
||||
className="flex-row items-center gap-1 mx-4 mb-2"
|
||||
>
|
||||
<Text className="text-xs text-gray-400">
|
||||
{t(showClosed ? 'debts.toggleClosed_hide' : 'debts.toggleClosed_show', { count: closed.length, plural: closed.length === 1 ? '' : 'r' })}
|
||||
</Text>
|
||||
<Ionicons name={showClosed ? "chevron-up" : "chevron-down"} size={12} color="#9ca3af" />
|
||||
</Pressable>
|
||||
{showClosed && closed.map((debt) => <ClaimCard key={debt.id} debt={debt} />)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
132
apps/native/src/components/features/debts/DebtCard.tsx
Normal file
132
apps/native/src/components/features/debts/DebtCard.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useState } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import type { Debt } from "@/src/hooks/useDebts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatEur } from "@/src/utils/format";
|
||||
|
||||
type Props = {
|
||||
debt: Debt;
|
||||
onAddPayment: (debt: Debt) => void;
|
||||
onDelete: (debt: Debt) => void;
|
||||
};
|
||||
|
||||
export function DebtCard({ debt, onAddPayment, onDelete }: Props) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const isClosed = debt.closedAt !== null;
|
||||
const accentColor = isClosed ? "#10b981" : "#7c3aed";
|
||||
const pct = Math.round(debt.progressPercent);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<View
|
||||
className="bg-white rounded-2xl mx-4 mb-2 overflow-hidden"
|
||||
style={{ borderWidth: 1, borderColor: isClosed ? "#d1fae5" : "#ede9fe" }}
|
||||
>
|
||||
{/* ── Collapsed row (always visible) ── */}
|
||||
<Pressable
|
||||
onPress={() => setExpanded((v) => !v)}
|
||||
className="flex-row items-center px-4 py-3 active:opacity-80"
|
||||
>
|
||||
{/* Icon */}
|
||||
<View
|
||||
className="w-9 h-9 rounded-full items-center justify-center mr-3"
|
||||
style={{ backgroundColor: isClosed ? "#d1fae5" : "#ede9fe" }}
|
||||
>
|
||||
<Ionicons
|
||||
name={isClosed ? "checkmark-circle" : "card-outline"}
|
||||
size={18}
|
||||
color={accentColor}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Label + progress bar */}
|
||||
<View className="flex-1 mr-2">
|
||||
<View className="flex-row items-center justify-between mb-1.5">
|
||||
<Text className="text-sm font-semibold text-gray-900" numberOfLines={1}>
|
||||
{debt.label}
|
||||
</Text>
|
||||
<Text className="text-xs font-bold ml-2" style={{ color: accentColor }}>
|
||||
{pct}%
|
||||
</Text>
|
||||
</View>
|
||||
<View className="h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<View
|
||||
className="h-full rounded-full"
|
||||
style={{ width: `${debt.progressPercent}%`, backgroundColor: accentColor }}
|
||||
/>
|
||||
</View>
|
||||
{debt.creditor ? (
|
||||
<Text className="text-xs text-gray-400 mt-1">{debt.creditor}</Text>
|
||||
) : (
|
||||
<Text className="text-xs text-gray-400 mt-1">
|
||||
{t('debts.remainingLabel', { amount: formatEur(debt.remainingAmount) })}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Chevron */}
|
||||
<Ionicons
|
||||
name={expanded ? "chevron-up" : "chevron-down"}
|
||||
size={14}
|
||||
color="#9ca3af"
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
{/* ── Expanded content ── */}
|
||||
{expanded && (
|
||||
<View style={{ borderTopWidth: 1, borderTopColor: "#f3f4f6" }}>
|
||||
{/* Amounts row */}
|
||||
<View className="flex-row px-4 py-3 justify-between">
|
||||
<View>
|
||||
<Text className="text-xs text-gray-400">{t('debts.paid')}</Text>
|
||||
<Text className="text-sm font-semibold" style={{ color: accentColor }}>
|
||||
{formatEur(debt.paidAmount, false)}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="items-center">
|
||||
<Text className="text-xs text-gray-400">{t('debts.total')}</Text>
|
||||
<Text className="text-sm font-semibold text-gray-700">
|
||||
{formatEur(debt.totalAmount, false)}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="items-end">
|
||||
<Text className="text-xs text-gray-400">{t('debts.openAmount')}</Text>
|
||||
<Text className="text-sm font-semibold text-gray-900">
|
||||
{formatEur(debt.remainingAmount, false)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Notes */}
|
||||
{debt.notes && (
|
||||
<Text className="text-xs text-gray-400 px-4 pb-2">{debt.notes}</Text>
|
||||
)}
|
||||
|
||||
{/* Action row */}
|
||||
<View className="flex-row gap-2 px-4 pb-4">
|
||||
{!isClosed && (
|
||||
<Pressable
|
||||
onPress={() => onAddPayment(debt)}
|
||||
style={{ backgroundColor: accentColor }}
|
||||
className="flex-1 py-2.5 rounded-xl items-center active:opacity-80"
|
||||
>
|
||||
<Text className="text-sm font-semibold text-white">+ {t('debts.payRate')}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
{!isClosed && (
|
||||
<Pressable
|
||||
onPress={() => onDelete(debt)}
|
||||
className="w-11 h-11 rounded-xl items-center justify-center"
|
||||
style={{ backgroundColor: "#fef2f2" }}
|
||||
hitSlop={4}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={16} color="#ef4444" />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
113
apps/native/src/components/features/debts/DebtsSection.tsx
Normal file
113
apps/native/src/components/features/debts/DebtsSection.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useState } from "react";
|
||||
import { ActivityIndicator, Pressable, Text, View } from "react-native";
|
||||
import { useDebts, useDeleteDebt, type Debt } from "@/src/hooks/useDebts";
|
||||
import { DebtCard } from "./DebtCard";
|
||||
import { AddDebtModal } from "./AddDebtModal";
|
||||
import { AddDebtPaymentModal } from "./AddDebtPaymentModal";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type ModalState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "addDebt" }
|
||||
| { kind: "addPayment"; debt: Debt };
|
||||
|
||||
export function DebtsSection() {
|
||||
const { data: debts = [], isLoading } = useDebts();
|
||||
const { mutate: deleteDebt } = useDeleteDebt();
|
||||
const [modal, setModal] = useState<ModalState>({ kind: "idle" });
|
||||
const [showClosed, setShowClosed] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const openDebts = debts.filter((d) => d.closedAt === null);
|
||||
const closedDebts = debts.filter((d) => d.closedAt !== null);
|
||||
|
||||
function handleDelete(debt: Debt) {
|
||||
deleteDebt(debt.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<View className="mb-2">
|
||||
{/* Section header */}
|
||||
<View className="flex-row items-center justify-between px-4 py-3">
|
||||
<View className="flex-row items-center gap-2">
|
||||
<Ionicons name="card-outline" size={18} color="#7c3aed" />
|
||||
<Text className="text-sm font-semibold text-gray-800">{t('debts.title')}</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={() => setModal({ kind: "addDebt" })}
|
||||
className="flex-row items-center gap-1 px-3 py-1.5 rounded-full"
|
||||
style={{ backgroundColor: "#ede9fe" }}
|
||||
>
|
||||
<Ionicons name="add" size={14} color="#7c3aed" />
|
||||
<Text className="text-xs font-semibold" style={{ color: "#7c3aed" }}>{t('common.new')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
{isLoading ? (
|
||||
<View className="py-6 items-center">
|
||||
<ActivityIndicator size="small" color="#7c3aed" />
|
||||
</View>
|
||||
) : openDebts.length === 0 && closedDebts.length === 0 ? (
|
||||
<View className="mx-4 mb-3 p-4 bg-gray-50 rounded-2xl items-center">
|
||||
<Text className="text-sm text-gray-400 text-center">
|
||||
{t('debts.noDebtsEntered')}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
{openDebts.map((debt) => (
|
||||
<DebtCard
|
||||
key={debt.id}
|
||||
debt={debt}
|
||||
onAddPayment={(d) => setModal({ kind: "addPayment", debt: d })}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
|
||||
{closedDebts.length > 0 && (
|
||||
<Pressable
|
||||
onPress={() => setShowClosed((v) => !v)}
|
||||
className="flex-row items-center gap-1 mx-4 mb-2"
|
||||
>
|
||||
<Text className="text-xs text-gray-400">
|
||||
{t(showClosed ? 'debts.toggleClosed_hide' : 'debts.toggleClosed_show', { count: closedDebts.length, plural: closedDebts.length === 1 ? '' : 'r' })}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name={showClosed ? "chevron-up" : "chevron-down"}
|
||||
size={12}
|
||||
color="#9ca3af"
|
||||
/>
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
{showClosed &&
|
||||
closedDebts.map((debt) => (
|
||||
<DebtCard
|
||||
key={debt.id}
|
||||
debt={debt}
|
||||
onAddPayment={() => {}}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Modals — only one open at a time */}
|
||||
<AddDebtModal
|
||||
visible={modal.kind === "addDebt"}
|
||||
onClose={() => setModal({ kind: "idle" })}
|
||||
/>
|
||||
{modal.kind === "addPayment" && (
|
||||
<AddDebtPaymentModal
|
||||
visible
|
||||
debt={modal.debt}
|
||||
onClose={() => setModal({ kind: "idle" })}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { TAB_COLORS } from "@/src/constants/colors";
|
||||
import { useMonthBalance, useCarryOver } from "@/src/hooks/useTransactions";
|
||||
import { currentMonthStr, addMonths, monthLabel } from "@/src/utils/date";
|
||||
import { formatEur } from "@/src/utils/format";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Alert, Pressable, Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type Props = {
|
||||
month: string; // "YYYY-MM" — the displayed (past) month
|
||||
scope: "household" | "private" | "child";
|
||||
childId?: string;
|
||||
accentColor?: string;
|
||||
};
|
||||
|
||||
export function CarryOverBanner({ month, scope, childId, accentColor = TAB_COLORS.household }: Props) {
|
||||
const isCurrent = month >= currentMonthStr();
|
||||
|
||||
// Don't show for current or future months
|
||||
if (isCurrent) return null;
|
||||
|
||||
return (
|
||||
<CarryOverBannerInner
|
||||
month={month}
|
||||
scope={scope}
|
||||
childId={childId}
|
||||
accentColor={accentColor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CarryOverBannerInner({
|
||||
month,
|
||||
scope,
|
||||
childId,
|
||||
accentColor,
|
||||
}: Required<Pick<Props, "month" | "scope" | "accentColor">> & { childId?: string }) {
|
||||
const { data: balanceData } = useMonthBalance(scope, month, childId);
|
||||
const { mutate: carryOver, isPending } = useCarryOver();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const balance = balanceData?.balance ?? 0;
|
||||
|
||||
// No banner if balance is ~zero
|
||||
if (Math.abs(balance) < 0.01) return null;
|
||||
|
||||
const toMonth = addMonths(month, 1);
|
||||
const toMonthLabel = monthLabel(toMonth);
|
||||
const balanceLabel = balance > 0 ? `+${formatEur(balance)}` : `-${formatEur(balance)}`;
|
||||
const isPositive = balance > 0;
|
||||
|
||||
function handleCarryOver() {
|
||||
Alert.alert(
|
||||
t('carryOver.title'),
|
||||
t('carryOver.confirmMessage', {
|
||||
balance: balanceLabel,
|
||||
type: isPositive ? t('carryOver.expense') : t('carryOver.income'),
|
||||
month: toMonthLabel,
|
||||
}),
|
||||
[
|
||||
{ text: t('common.cancel'), style: "cancel" },
|
||||
{
|
||||
text: t('carryOver.transfer'),
|
||||
onPress: () => {
|
||||
carryOver(
|
||||
{ fromMonth: month, toMonth, scope, childId },
|
||||
{
|
||||
onError: (err) => {
|
||||
Alert.alert(t('common.notice'), err.message);
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
className="mx-4 my-3 rounded-2xl p-4"
|
||||
style={{ backgroundColor: "#f5f3ff", borderWidth: 1, borderColor: "#ddd6fe" }}
|
||||
>
|
||||
<View className="flex-row items-center gap-3">
|
||||
<Ionicons name="return-down-forward-outline" size={24} color="#6366f1" />
|
||||
<View className="flex-1">
|
||||
<Text className="text-xs text-gray-400 mb-0.5">{t('carryOver.openBalance', { month: monthLabel(month) })}</Text>
|
||||
<Text className="text-xl font-bold" style={{ color: "#6366f1" }}>
|
||||
{balanceLabel}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={handleCarryOver}
|
||||
disabled={isPending}
|
||||
className="mt-3 py-2 rounded-xl items-center active:opacity-70"
|
||||
style={{ backgroundColor: "#6366f1" }}
|
||||
>
|
||||
<Text className="text-sm font-semibold text-white">
|
||||
{isPending ? t('carryOver.transferring') : t('carryOver.transferButton', { month: toMonthLabel })}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useUpdateTransaction } from "@/src/hooks/useTransactions";
|
||||
import { useCategories, type Category } from "@/src/hooks/useCategories";
|
||||
import { ModalHeader } from "@/src/components/ui/ModalHeader";
|
||||
import { Numpad } from "@/src/components/ui/Numpad";
|
||||
import { formatDateDisplay } from "@/src/utils/format";
|
||||
import { handleNumpadKey, parseAmountStr } from "@/src/utils/numpad";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import i18n from "@/src/i18n";
|
||||
import {
|
||||
Modal,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from "react-native";
|
||||
import type { TransactionWithCategory } from "@/src/hooks/useTransactions";
|
||||
|
||||
type Props = {
|
||||
transaction: TransactionWithCategory;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function amountToDisplay(amount: string): string {
|
||||
return parseFloat(amount).toFixed(2).replace(".", ",");
|
||||
}
|
||||
|
||||
export function EditTransactionModal({ transaction, onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [amountStr, setAmountStr] = useState(amountToDisplay(transaction.amount));
|
||||
const [description, setDescription] = useState(transaction.description ?? "");
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(
|
||||
null, // will be set via category lookup
|
||||
);
|
||||
const [showCategoryPicker, setShowCategoryPicker] = useState(false);
|
||||
|
||||
const { data: categories = [] } = useCategories();
|
||||
const { mutate: updateTransaction, isPending } = useUpdateTransaction();
|
||||
|
||||
// Resolve initial category from transaction's categoryName
|
||||
const [resolvedInitial, setResolvedInitial] = useState(false);
|
||||
React.useEffect(() => {
|
||||
if (!resolvedInitial && categories.length > 0) {
|
||||
const match = categories.find((c) => c.name === transaction.categoryName);
|
||||
setSelectedCategoryId(match?.id ?? null);
|
||||
setResolvedInitial(true);
|
||||
}
|
||||
}, [categories, resolvedInitial, transaction.categoryName]);
|
||||
|
||||
const filteredCategories = categories.filter((c) => c.type === transaction.type);
|
||||
const selectedCategory = categories.find((c) => c.id === selectedCategoryId) ?? null;
|
||||
|
||||
function handleNumpad(key: string) {
|
||||
setAmountStr((prev) => handleNumpadKey(prev, key));
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
const amount = parseAmountStr(amountStr);
|
||||
if (!amount || amount <= 0) return;
|
||||
updateTransaction(
|
||||
{
|
||||
id: transaction.id,
|
||||
amount,
|
||||
description: description.trim() || undefined,
|
||||
categoryId: selectedCategoryId ?? undefined,
|
||||
},
|
||||
{ onSuccess: onClose },
|
||||
);
|
||||
}
|
||||
|
||||
const canSave = parseAmountStr(amountStr) > 0;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible
|
||||
animationType="slide"
|
||||
presentationStyle="pageSheet"
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<View className="flex-1 bg-white">
|
||||
{/* Header */}
|
||||
<ModalHeader
|
||||
title={t('transaction.editTitle')}
|
||||
onClose={onClose}
|
||||
closeLabel={t('common.cancel')}
|
||||
onSave={handleSave}
|
||||
saveLabel={t('common.save')}
|
||||
saveDisabled={!canSave}
|
||||
saveLoading={isPending}
|
||||
/>
|
||||
|
||||
{/* isFixed warning */}
|
||||
{transaction.isFixed && (
|
||||
<View className="mx-4 mt-3 px-3 py-2 bg-amber-50 rounded-xl flex-row items-start gap-2">
|
||||
<Text className="text-sm">⚠️</Text>
|
||||
<Text className="text-xs text-amber-700 flex-1">
|
||||
Das ist eine Fixkostenbuchung. Änderungen gelten nur für diesen Monat. Um den Betrag dauerhaft zu ändern, gehe zu Einstellungen → Fixkosten.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Amount */}
|
||||
<View className="items-center py-6">
|
||||
<Text
|
||||
className="text-5xl font-bold"
|
||||
style={{ color: transaction.type === "income" ? "#16a34a" : "#111827" }}
|
||||
>
|
||||
€ {amountStr}
|
||||
</Text>
|
||||
<Text className="text-sm text-gray-400 mt-1">
|
||||
{transaction.type === "income" ? t('transaction.income') : t('transaction.expense')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Category Select */}
|
||||
<Pressable
|
||||
onPress={() => setShowCategoryPicker((v) => !v)}
|
||||
style={{
|
||||
flexDirection: "row", alignItems: "center",
|
||||
marginHorizontal: 16, marginBottom: 4,
|
||||
paddingHorizontal: 14, paddingVertical: 11,
|
||||
backgroundColor: "#f3f4f6", borderRadius: 12,
|
||||
borderWidth: selectedCategory ? 1.5 : 0,
|
||||
borderColor: selectedCategory ? (selectedCategory.color ?? "#6b7280") : "transparent",
|
||||
}}
|
||||
>
|
||||
<View style={{ width: 28, height: 28, borderRadius: 14, backgroundColor: selectedCategory ? (selectedCategory.color ?? "#6b7280") : "#e5e7eb", alignItems: "center", justifyContent: "center", marginRight: 10 }}>
|
||||
<Ionicons
|
||||
name={(selectedCategory?.icon ?? "pricetag-outline") as React.ComponentProps<typeof Ionicons>["name"]}
|
||||
size={14}
|
||||
color={selectedCategory ? "#fff" : "#9ca3af"}
|
||||
/>
|
||||
</View>
|
||||
<Text style={{ flex: 1, fontSize: 14, color: selectedCategory ? "#111827" : "#9ca3af" }}>
|
||||
{selectedCategory ? selectedCategory.name : t('transaction.selectCategory')}
|
||||
</Text>
|
||||
{selectedCategory ? (
|
||||
<Pressable onPress={(e) => { e.stopPropagation(); setSelectedCategoryId(null); }} hitSlop={8}>
|
||||
<Ionicons name="close-circle" size={18} color="#9ca3af" />
|
||||
</Pressable>
|
||||
) : (
|
||||
<Ionicons name={showCategoryPicker ? "chevron-up" : "chevron-down"} size={16} color="#9ca3af" />
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
{/* Inline Category Picker */}
|
||||
{showCategoryPicker && (
|
||||
<View style={{ marginHorizontal: 16, marginBottom: 4, borderWidth: 1, borderColor: "#e5e7eb", borderRadius: 12, backgroundColor: "#fff", maxHeight: 220 }}>
|
||||
<ScrollView bounces={false} keyboardShouldPersistTaps="handled">
|
||||
{filteredCategories.map((cat) => {
|
||||
const active = cat.id === selectedCategoryId;
|
||||
const color = cat.color ?? "#6b7280";
|
||||
return (
|
||||
<Pressable
|
||||
key={cat.id}
|
||||
onPress={() => { setSelectedCategoryId(active ? null : cat.id); setShowCategoryPicker(false); }}
|
||||
style={{ flexDirection: "row", alignItems: "center", paddingHorizontal: 14, paddingVertical: 10, backgroundColor: active ? `${color}12` : "#fff", borderBottomWidth: 1, borderBottomColor: "#f3f4f6" }}
|
||||
>
|
||||
<View style={{ width: 32, height: 32, borderRadius: 16, backgroundColor: color, alignItems: "center", justifyContent: "center", marginRight: 12 }}>
|
||||
<Ionicons name={(cat.icon ?? "ellipsis-horizontal-circle-outline") as React.ComponentProps<typeof Ionicons>["name"]} size={16} color="#fff" />
|
||||
</View>
|
||||
<Text style={{ flex: 1, fontSize: 14, fontWeight: "500", color: active ? color : "#111827" }}>{cat.name}</Text>
|
||||
{active && <Ionicons name="checkmark-circle" size={18} color={color} />}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
<View className="px-4 mt-2 mb-3">
|
||||
<TextInput
|
||||
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900"
|
||||
placeholder={t('transaction.descriptionOptional')}
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Date (readonly display) */}
|
||||
<View className="px-4 mb-4 flex-row items-center" style={{ gap: 10 }}>
|
||||
<Ionicons name="calendar-outline" size={20} color="#6b7280" />
|
||||
<Text className="text-base text-gray-500">{formatDateDisplay(transaction.date, i18n.language, t('common.today'))}</Text>
|
||||
</View>
|
||||
|
||||
{/* Numpad */}
|
||||
<Numpad onKeyPress={handleNumpad} />
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { ActivityIndicator, Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatEur } from "@/src/utils/format";
|
||||
|
||||
type Props = {
|
||||
income: number | undefined;
|
||||
expense: number | undefined;
|
||||
balance: number | undefined;
|
||||
isLoading: boolean;
|
||||
accentColor?: string;
|
||||
};
|
||||
|
||||
export function MonthSummaryHeader({ income, expense, balance, isLoading, accentColor }: Props) {
|
||||
const { t } = useTranslation();
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View className="bg-white mx-4 mt-3 rounded-2xl p-4 items-center" style={{ borderWidth: 1, borderColor: "#f3f4f6" }}>
|
||||
<ActivityIndicator size="small" color="#9ca3af" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const balancePositive = (balance ?? 0) >= 0;
|
||||
const balanceColor = accentColor ?? (balancePositive ? "#16a34a" : "#dc2626");
|
||||
|
||||
return (
|
||||
<View
|
||||
className="bg-white mx-4 mt-3 mb-1 rounded-2xl px-4 py-3 flex-row"
|
||||
style={{ borderWidth: 1, borderColor: "#f3f4f6" }}
|
||||
>
|
||||
<View className="flex-1 items-center">
|
||||
<Text className="text-xs text-gray-400 mb-1">{t('household.income')}</Text>
|
||||
<Text className="text-sm font-semibold text-green-600">
|
||||
{income !== undefined ? formatEur(income) : "—"}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="w-px bg-gray-100 mx-1" />
|
||||
<View className="flex-1 items-center">
|
||||
<Text className="text-xs text-gray-400 mb-1">{t('household.expenses')}</Text>
|
||||
<Text className="text-sm font-semibold text-red-500">
|
||||
{expense !== undefined ? formatEur(expense) : "—"}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="w-px bg-gray-100 mx-1" />
|
||||
<View className="flex-1 items-center">
|
||||
<Text className="text-xs text-gray-400 mb-1">{t('household.balance')}</Text>
|
||||
<Text
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: balanceColor }}
|
||||
>
|
||||
{balance !== undefined ? formatEur(balance) : "—"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { useCreateTransaction } from "@/src/hooks/useTransactions";
|
||||
import { useCategories, type Category } from "@/src/hooks/useCategories";
|
||||
import { ModalHeader } from "@/src/components/ui/ModalHeader";
|
||||
import { Numpad } from "@/src/components/ui/Numpad";
|
||||
import { todayIso } from "@/src/utils/date";
|
||||
import { formatDateDisplay } from "@/src/utils/format";
|
||||
import { handleNumpadKey, parseAmountStr } from "@/src/utils/numpad";
|
||||
import { useState } from "react";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import i18n from "@/src/i18n";
|
||||
import {
|
||||
Modal,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from "react-native";
|
||||
|
||||
type Props = {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onRequestAddCategory: (type: "expense" | "income") => void;
|
||||
newCategory?: Category | null;
|
||||
defaultScope?: "household" | "private" | "child";
|
||||
defaultChildId?: string;
|
||||
};
|
||||
|
||||
export function QuickAddModal({
|
||||
visible,
|
||||
onClose,
|
||||
onRequestAddCategory,
|
||||
newCategory,
|
||||
defaultScope = "household",
|
||||
defaultChildId,
|
||||
}: Props) {
|
||||
const { t: tFn } = useTranslation();
|
||||
const [type, setType] = useState<"expense" | "income">("expense");
|
||||
const [amountStr, setAmountStr] = useState("0");
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null);
|
||||
const [description, setDescription] = useState("");
|
||||
const [isFixed, setIsFixed] = useState(false);
|
||||
const [selectedDate, setSelectedDate] = useState<string>(todayIso());
|
||||
const [showCategoryPicker, setShowCategoryPicker] = useState(false);
|
||||
|
||||
const { data: categories = [] } = useCategories();
|
||||
const filteredCategories = categories.filter((c) => c.type === type);
|
||||
const selectedCategory = categories.find((c) => c.id === selectedCategoryId) ?? null;
|
||||
|
||||
// Auto-select newly created category when parent passes it in
|
||||
React.useEffect(() => {
|
||||
if (newCategory) {
|
||||
setSelectedCategoryId(newCategory.id);
|
||||
setType(newCategory.type);
|
||||
}
|
||||
}, [newCategory]);
|
||||
|
||||
const { mutate: createTransaction, isPending } = useCreateTransaction();
|
||||
|
||||
function handleNumpad(key: string) {
|
||||
setAmountStr((prev) => handleNumpadKey(prev, key));
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
const amount = parseAmountStr(amountStr);
|
||||
if (!amount || amount <= 0) return;
|
||||
createTransaction(
|
||||
{ amount, type, scope: defaultScope, categoryId: selectedCategoryId ?? undefined, description: description.trim() || undefined, date: new Date(selectedDate).toISOString(), isFixed, childId: defaultChildId ?? undefined },
|
||||
{ onSuccess: () => { resetState(); onClose(); } },
|
||||
);
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
setAmountStr("0");
|
||||
setDescription("");
|
||||
setSelectedCategoryId(null);
|
||||
setType("expense");
|
||||
setIsFixed(false);
|
||||
setSelectedDate(todayIso());
|
||||
setShowCategoryPicker(false);
|
||||
}
|
||||
|
||||
function handleClose() { resetState(); onClose(); }
|
||||
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide" presentationStyle="pageSheet" onRequestClose={handleClose}>
|
||||
<View className="flex-1 bg-white">
|
||||
{/* Header */}
|
||||
<ModalHeader
|
||||
title={tFn('transaction.newBooking')}
|
||||
onClose={handleClose}
|
||||
closeLabel={tFn('common.cancel')}
|
||||
onSave={handleSave}
|
||||
saveLabel={tFn('common.save')}
|
||||
saveLoading={isPending}
|
||||
/>
|
||||
|
||||
{/* Type Toggle */}
|
||||
<View className="flex-row mx-4 mt-4 p-1 bg-gray-100 rounded-xl">
|
||||
{(["expense", "income"] as const).map((t) => (
|
||||
<Pressable key={t} onPress={() => { setType(t); setSelectedCategoryId(null); setShowCategoryPicker(false); }}
|
||||
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('transaction.expense') : tFn('transaction.income')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Amount */}
|
||||
<View className="items-center py-6">
|
||||
<Text className="text-5xl font-bold text-gray-900">€ {amountStr}</Text>
|
||||
</View>
|
||||
|
||||
{/* Category Select Row */}
|
||||
<Pressable
|
||||
onPress={() => setShowCategoryPicker((v) => !v)}
|
||||
style={{
|
||||
flexDirection: "row", alignItems: "center",
|
||||
marginHorizontal: 16, marginBottom: 4,
|
||||
paddingHorizontal: 14, paddingVertical: 11,
|
||||
backgroundColor: "#f3f4f6", borderRadius: 12,
|
||||
borderWidth: selectedCategory ? 1.5 : 0,
|
||||
borderColor: selectedCategory ? (selectedCategory.color ?? "#6b7280") : "transparent",
|
||||
}}
|
||||
>
|
||||
<View style={{ width: 28, height: 28, borderRadius: 14, backgroundColor: selectedCategory ? (selectedCategory.color ?? "#6b7280") : "#e5e7eb", alignItems: "center", justifyContent: "center", marginRight: 10 }}>
|
||||
<Ionicons
|
||||
name={selectedCategory ? (selectedCategory.icon ?? "ellipsis-horizontal-circle-outline") as React.ComponentProps<typeof Ionicons>["name"] : "pricetag-outline"}
|
||||
size={14} color={selectedCategory ? "#fff" : "#9ca3af"}
|
||||
/>
|
||||
</View>
|
||||
<Text style={{ flex: 1, fontSize: 14, color: selectedCategory ? "#111827" : "#9ca3af" }}>
|
||||
{selectedCategory ? selectedCategory.name : tFn('transaction.selectCategory')}
|
||||
</Text>
|
||||
{selectedCategory ? (
|
||||
<Pressable onPress={(e) => { e.stopPropagation(); setSelectedCategoryId(null); }} hitSlop={8}>
|
||||
<Ionicons name="close-circle" size={18} color="#9ca3af" />
|
||||
</Pressable>
|
||||
) : (
|
||||
<Ionicons name={showCategoryPicker ? "chevron-up" : "chevron-down"} size={16} color="#9ca3af" />
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
{/* Inline Category Picker */}
|
||||
{showCategoryPicker && (
|
||||
<View style={{ marginHorizontal: 16, marginBottom: 4, borderWidth: 1, borderColor: "#e5e7eb", borderRadius: 12, backgroundColor: "#fff", maxHeight: 220 }}>
|
||||
<ScrollView bounces={false} keyboardShouldPersistTaps="handled">
|
||||
{filteredCategories.map((cat) => {
|
||||
const active = cat.id === selectedCategoryId;
|
||||
const color = cat.color ?? "#6b7280";
|
||||
return (
|
||||
<Pressable key={cat.id}
|
||||
onPress={() => { setSelectedCategoryId(active ? null : cat.id); setShowCategoryPicker(false); }}
|
||||
style={{ flexDirection: "row", alignItems: "center", paddingHorizontal: 14, paddingVertical: 10, backgroundColor: active ? `${color}12` : "#fff", borderBottomWidth: 1, borderBottomColor: "#f3f4f6" }}
|
||||
>
|
||||
<View style={{ width: 32, height: 32, borderRadius: 16, backgroundColor: color, alignItems: "center", justifyContent: "center", marginRight: 12 }}>
|
||||
<Ionicons name={(cat.icon ?? "ellipsis-horizontal-circle-outline") as React.ComponentProps<typeof Ionicons>["name"]} size={16} color="#fff" />
|
||||
</View>
|
||||
<Text style={{ flex: 1, fontSize: 14, fontWeight: "500", color: active ? color : "#111827" }}>{cat.name}</Text>
|
||||
{active && <Ionicons name="checkmark-circle" size={18} color={color} />}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
<Pressable
|
||||
onPress={() => { setShowCategoryPicker(false); onRequestAddCategory(type); }}
|
||||
style={{ flexDirection: "row", alignItems: "center", paddingHorizontal: 14, paddingVertical: 10 }}
|
||||
>
|
||||
<View style={{ width: 32, height: 32, borderRadius: 16, backgroundColor: "#f3f4f6", alignItems: "center", justifyContent: "center", marginRight: 12 }}>
|
||||
<Ionicons name="add" size={16} color="#9ca3af" />
|
||||
</View>
|
||||
<Text style={{ fontSize: 14, fontWeight: "500", color: "#6b7280" }}>{tFn('transaction.addNewCategory')}</Text>
|
||||
</Pressable>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
<View className="px-4 mt-2 mb-4">
|
||||
<TextInput className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900" placeholder={tFn('transaction.descriptionOptional')} value={description} onChangeText={setDescription} />
|
||||
</View>
|
||||
|
||||
{/* Date Row */}
|
||||
<View className="px-4 mb-3 flex-row items-center" style={{ gap: 10 }}>
|
||||
<Ionicons name="calendar-outline" size={20} color="#6b7280" />
|
||||
<Text className="text-base text-gray-700 flex-1">{formatDateDisplay(selectedDate, i18n.language, tFn('common.today'))}</Text>
|
||||
</View>
|
||||
|
||||
{/* Fixkosten Row */}
|
||||
<View className="px-4 mb-4 flex-row items-center" style={{ gap: 10 }}>
|
||||
<Ionicons name="repeat-outline" size={20} color="#6b7280" />
|
||||
<Text className="text-base text-gray-700 flex-1">{tFn('transaction.repeatMonthly')}</Text>
|
||||
<Switch value={isFixed} onValueChange={setIsFixed} trackColor={{ false: "#d1d5db", true: "#2563EB" }} thumbColor="#fff" />
|
||||
</View>
|
||||
|
||||
{/* Numpad */}
|
||||
<Numpad onKeyPress={handleNumpad} />
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Text, View } from "react-native";
|
||||
import type { TransactionSummary } from "@/src/hooks/useTransactions";
|
||||
|
||||
type Props = {
|
||||
summary: TransactionSummary | undefined;
|
||||
isLoading: boolean;
|
||||
accentColor?: string;
|
||||
};
|
||||
|
||||
function formatEur(amount: number) {
|
||||
return new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(amount);
|
||||
}
|
||||
|
||||
const monthLabel = new Intl.DateTimeFormat("de-DE", { month: "long", year: "numeric" }).format(new Date());
|
||||
|
||||
export function SummaryHeader({ summary, isLoading, accentColor = "#2563EB" }: Props) {
|
||||
const loading = isLoading || !summary;
|
||||
const income = loading ? null : formatEur(summary!.income);
|
||||
const expense = loading ? null : formatEur(summary!.expense);
|
||||
const balance = loading ? null : formatEur(summary!.balance);
|
||||
const balancePositive = !loading && summary!.balance >= 0;
|
||||
|
||||
return (
|
||||
<View className="bg-blue-600 px-4 pb-5 pt-3">
|
||||
<Text className="text-center text-blue-200 text-xs mb-3" style={{ opacity: 0.8 }}>
|
||||
{monthLabel}
|
||||
</Text>
|
||||
<View className="flex-row">
|
||||
<View className="flex-1 items-center">
|
||||
<Text className="text-blue-200 text-xs mb-1">Einnahmen</Text>
|
||||
<Text className="text-white font-semibold text-base">
|
||||
{loading ? "—" : income}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="w-px bg-blue-500 mx-2" />
|
||||
<View className="flex-1 items-center">
|
||||
<Text className="text-blue-200 text-xs mb-1">Ausgaben</Text>
|
||||
<Text className="text-white font-semibold text-base">
|
||||
{loading ? "—" : expense}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="w-px bg-blue-500 mx-2" />
|
||||
<View className="flex-1 items-center">
|
||||
<Text className="text-blue-200 text-xs mb-1">Bilanz</Text>
|
||||
<Text
|
||||
className={`font-semibold text-base ${loading ? "text-blue-300" : balancePositive ? "text-green-300" : "text-red-300"}`}
|
||||
>
|
||||
{loading ? "—" : balance}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { ComponentProps } from "react";
|
||||
import { Alert, Pressable, Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ReanimatedSwipeable from "react-native-gesture-handler/ReanimatedSwipeable";
|
||||
import Reanimated, { useAnimatedStyle } from "react-native-reanimated";
|
||||
import type { SharedValue } from "react-native-reanimated";
|
||||
import type { TransactionWithCategory } from "@/src/hooks/useTransactions";
|
||||
|
||||
type IoniconName = ComponentProps<typeof Ionicons>["name"];
|
||||
|
||||
const CATEGORY_ICONS: Record<string, IoniconName> = {
|
||||
"Lebensmittel": "cart-outline",
|
||||
"Wohnen": "home-outline",
|
||||
"Transport": "car-outline",
|
||||
"Gesundheit": "medkit-outline",
|
||||
"Freizeit": "game-controller-outline",
|
||||
"Kinder": "happy-outline",
|
||||
"Urlaub": "airplane-outline",
|
||||
"Sonstiges": "ellipsis-horizontal-circle-outline",
|
||||
"Gehalt": "briefcase-outline",
|
||||
"Sonstiges Einkommen": "cash-outline",
|
||||
};
|
||||
|
||||
function resolveIcon(categoryName: string | null, isIncome: boolean): IoniconName {
|
||||
if (categoryName && CATEGORY_ICONS[categoryName]) return CATEGORY_ICONS[categoryName];
|
||||
return isIncome ? "cash-outline" : "ellipsis-horizontal-circle-outline";
|
||||
}
|
||||
|
||||
type Props = {
|
||||
transaction: TransactionWithCategory;
|
||||
onPress: (t: TransactionWithCategory) => void;
|
||||
onDelete: (t: TransactionWithCategory) => void;
|
||||
locked?: boolean;
|
||||
};
|
||||
|
||||
function formatAmount(amount: string, type: "income" | "expense") {
|
||||
const num = parseFloat(amount);
|
||||
const formatted = new Intl.NumberFormat("de-DE", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
}).format(num);
|
||||
return type === "income" ? `+${formatted}` : `-${formatted}`;
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
const date = new Date(dateStr);
|
||||
return new Intl.DateTimeFormat("de-DE", { day: "2-digit", month: "short" }).format(date);
|
||||
}
|
||||
|
||||
function DeleteAction({
|
||||
prog,
|
||||
drag,
|
||||
onDelete,
|
||||
}: {
|
||||
prog: SharedValue<number>;
|
||||
drag: SharedValue<number>;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const animStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateX: drag.value + 80 }],
|
||||
}));
|
||||
|
||||
return (
|
||||
<Reanimated.View
|
||||
style={[{ width: 80, backgroundColor: "#dc2626", justifyContent: "center", alignItems: "center" }, animStyle]}
|
||||
>
|
||||
<Pressable
|
||||
onPress={onDelete}
|
||||
style={{ flex: 1, width: "100%", justifyContent: "center", alignItems: "center" }}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={20} color="#fff" />
|
||||
<Text style={{ color: "#fff", fontSize: 11, marginTop: 3, fontWeight: "600" }}>{t('common.delete')}</Text>
|
||||
</Pressable>
|
||||
</Reanimated.View>
|
||||
);
|
||||
}
|
||||
|
||||
export function TransactionItem({ transaction, onPress, onDelete, locked = false }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const isIncome = transaction.type === "income";
|
||||
const isCarryOver = transaction.isCarryOver;
|
||||
|
||||
const iconName: IoniconName = isCarryOver
|
||||
? "return-down-forward-outline"
|
||||
: resolveIcon(transaction.categoryName, isIncome);
|
||||
const iconColor = isCarryOver ? "#6366f1" : (transaction.categoryColor ?? "#6b7280");
|
||||
const bgColor = isCarryOver ? "#6366f122" : (transaction.categoryColor ?? "#6b7280") + "22";
|
||||
|
||||
function handleDeletePress() {
|
||||
const isFixed = transaction.isFixed;
|
||||
const hasDebt = (transaction as TransactionWithCategory & { linkedDebtPaymentId?: string | null }).linkedDebtPaymentId;
|
||||
|
||||
let message = t('transaction.deleteMessage');
|
||||
if (isFixed) message = t('transaction.deleteFixed');
|
||||
if (hasDebt) message = t('transaction.deleteDebt');
|
||||
|
||||
Alert.alert(t('transaction.deleteTitle'), message, [
|
||||
{ text: t('common.cancel'), style: "cancel" },
|
||||
{ text: t('common.delete'), style: "destructive", onPress: () => onDelete(transaction) },
|
||||
]);
|
||||
}
|
||||
|
||||
// CarryOver: kein Swipe, kein Edit
|
||||
if (isCarryOver) {
|
||||
return (
|
||||
<Pressable className="flex-row items-center px-4 py-3 active:bg-gray-50">
|
||||
<View style={{ width: 40, height: 40, borderRadius: 20, backgroundColor: bgColor, alignItems: "center", justifyContent: "center", marginRight: 12 }}>
|
||||
<Ionicons name={iconName} size={20} color={iconColor} />
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="text-sm font-medium" style={{ color: "#6366f1" }} numberOfLines={1}>
|
||||
{transaction.description ?? t('transaction.carryOver')}
|
||||
</Text>
|
||||
<Text className="text-xs text-gray-400 mt-0.5">{formatDate(transaction.date)}</Text>
|
||||
</View>
|
||||
<Text className="text-sm font-semibold text-indigo-500">
|
||||
{formatAmount(transaction.amount, transaction.type)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
// Locked months: no swipe, no edit — just display
|
||||
if (locked) {
|
||||
return (
|
||||
<View className="flex-row items-center px-4 py-3 bg-white">
|
||||
<View style={{ width: 40, height: 40, borderRadius: 20, backgroundColor: bgColor, alignItems: "center", justifyContent: "center", marginRight: 12 }}>
|
||||
<Ionicons name={iconName} size={20} color={iconColor} />
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="text-sm font-medium text-gray-900" numberOfLines={1}>
|
||||
{transaction.description ?? transaction.categoryName ?? "Buchung"}
|
||||
</Text>
|
||||
<Text className="text-xs text-gray-400 mt-0.5">
|
||||
{transaction.categoryName ? `${transaction.categoryName} · ` : ""}
|
||||
{formatDate(transaction.date)}
|
||||
</Text>
|
||||
</View>
|
||||
<Text className={`text-sm font-semibold ${isIncome ? "text-green-600" : "text-gray-900"}`}>
|
||||
{formatAmount(transaction.amount, transaction.type)}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ReanimatedSwipeable
|
||||
friction={2}
|
||||
rightThreshold={40}
|
||||
renderRightActions={(prog, drag) => (
|
||||
<DeleteAction prog={prog} drag={drag} onDelete={handleDeletePress} />
|
||||
)}
|
||||
>
|
||||
<Pressable
|
||||
onPress={() => onPress(transaction)}
|
||||
className="flex-row items-center px-4 py-3 active:bg-gray-50 bg-white"
|
||||
>
|
||||
<View style={{ width: 40, height: 40, borderRadius: 20, backgroundColor: bgColor, alignItems: "center", justifyContent: "center", marginRight: 12 }}>
|
||||
<Ionicons name={iconName} size={20} color={iconColor} />
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="text-sm font-medium text-gray-900" numberOfLines={1}>
|
||||
{transaction.description ?? transaction.categoryName ?? "Buchung"}
|
||||
</Text>
|
||||
<Text className="text-xs text-gray-400 mt-0.5">
|
||||
{transaction.categoryName ? `${transaction.categoryName} · ` : ""}
|
||||
{formatDate(transaction.date)}
|
||||
</Text>
|
||||
</View>
|
||||
<Text className={`text-sm font-semibold ${isIncome ? "text-green-600" : "text-gray-900"}`}>
|
||||
{formatAmount(transaction.amount, transaction.type)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</ReanimatedSwipeable>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { TAB_COLORS } from "@/src/constants/colors";
|
||||
import { QuickAddModal } from "./QuickAddModal";
|
||||
import { MonthSummaryHeader } from "./MonthSummaryHeader";
|
||||
import { TransactionItem } from "./TransactionItem";
|
||||
import { EditTransactionModal } from "./EditTransactionModal";
|
||||
import { CarryOverBanner } from "./CarryOverBanner";
|
||||
import { AddCategoryModal } from "@/src/components/features/categories/AddCategoryModal";
|
||||
import { EmptyState } from "@/src/components/ui/EmptyState";
|
||||
import { useTransactions, useMonthBalance, useActivateFixed, useDeleteTransaction } from "@/src/hooks/useTransactions";
|
||||
import type { TransactionWithCategory } from "@/src/hooks/useTransactions";
|
||||
import type { Category } from "@/src/hooks/useCategories";
|
||||
import { currentMonthStr, addMonths, monthLabel, monthDateRange } from "@/src/utils/date";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
Pressable,
|
||||
RefreshControl,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
type FilterType = "all" | "income" | "expense";
|
||||
type Scope = "household" | "private" | "child";
|
||||
|
||||
type Props = {
|
||||
scope: Scope;
|
||||
childId?: string;
|
||||
accentColor?: string;
|
||||
emptyTitle?: string;
|
||||
emptySubtitle?: string;
|
||||
disableTopInset?: boolean;
|
||||
headerExtra?: React.ReactNode;
|
||||
};
|
||||
|
||||
const ACCENT_COLORS: Record<Scope, string> = {
|
||||
household: TAB_COLORS.household,
|
||||
private: TAB_COLORS.private,
|
||||
child: TAB_COLORS.children,
|
||||
};
|
||||
|
||||
export function TransactionScreen({
|
||||
scope,
|
||||
childId,
|
||||
accentColor,
|
||||
emptyTitle,
|
||||
emptySubtitle,
|
||||
disableTopInset = false,
|
||||
headerExtra,
|
||||
}: Props) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t } = useTranslation();
|
||||
const resolvedEmptyTitle = emptyTitle ?? t('household.noTransactions');
|
||||
const resolvedEmptySubtitle = emptySubtitle ?? t('household.noTransactionsHint');
|
||||
const [filter, setFilter] = useState<FilterType>("all");
|
||||
const [month, setMonth] = useState(currentMonthStr());
|
||||
const [showAddModal, setShowAddModal] = 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 color = accentColor ?? ACCENT_COLORS[scope];
|
||||
const isCurrent = month === currentMonthStr();
|
||||
|
||||
// 11a: activate fixed transactions silently on mount + when month changes to current
|
||||
const { mutate: activateFixed } = useActivateFixed();
|
||||
useEffect(() => {
|
||||
if (isCurrent) {
|
||||
activateFixed({ month, scope, ...(childId ? { childId } : {}) });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [month, scope, childId]);
|
||||
|
||||
const [fromDate, toDate] = monthDateRange(month);
|
||||
|
||||
const transactionFilter = {
|
||||
scope,
|
||||
from: fromDate,
|
||||
to: toDate,
|
||||
...(childId ? { childId } : {}),
|
||||
...(filter !== "all" ? { type: filter as "income" | "expense" } : {}),
|
||||
};
|
||||
|
||||
const { data: transactions = [], isLoading, refetch, isRefetching } = useTransactions(transactionFilter);
|
||||
const { data: balance, isLoading: balanceLoading } = useMonthBalance(scope, month, childId);
|
||||
|
||||
function renderEmpty() {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center py-20">
|
||||
<ActivityIndicator size="large" color={color} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<EmptyState
|
||||
icon="wallet-outline"
|
||||
title={resolvedEmptyTitle}
|
||||
subtitle={resolvedEmptySubtitle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-gray-50">
|
||||
{/* Neutral header — paddingTop for safe area when used as top-level screen */}
|
||||
<View style={{ backgroundColor: "#fff", borderBottomWidth: 1, borderBottomColor: "#f3f4f6", paddingTop: disableTopInset ? 0 : insets.top }}>
|
||||
{/* Month Switcher */}
|
||||
<View className="flex-row items-center justify-center gap-4 py-3">
|
||||
<Pressable onPress={() => setMonth((m) => addMonths(m, -1))} className="p-1 active:opacity-50">
|
||||
<Ionicons name="chevron-back" size={18} color="#6b7280" />
|
||||
</Pressable>
|
||||
<Text className="text-sm font-semibold w-32 text-center text-gray-800">
|
||||
{monthLabel(month)}
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={() => setMonth((m) => addMonths(m, 1))}
|
||||
disabled={isCurrent}
|
||||
className="p-1 active:opacity-50"
|
||||
style={{ opacity: isCurrent ? 0.3 : 1 }}
|
||||
>
|
||||
<Ionicons name="chevron-forward" size={18} color="#6b7280" />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{headerExtra}
|
||||
|
||||
<MonthSummaryHeader
|
||||
income={balance?.income}
|
||||
expense={balance?.expense}
|
||||
balance={balance?.balance}
|
||||
isLoading={balanceLoading}
|
||||
accentColor={color}
|
||||
/>
|
||||
|
||||
{/* 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 ? color : "#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>
|
||||
|
||||
{/* List */}
|
||||
<FlatList
|
||||
data={transactions}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item }) => (
|
||||
<View className="bg-white">
|
||||
<TransactionItem
|
||||
transaction={item}
|
||||
onPress={setEditTransaction}
|
||||
onDelete={(t) => deleteTransaction(t.id)}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
ListHeaderComponent={
|
||||
!isCurrent ? (
|
||||
<CarryOverBanner month={month} scope={scope} childId={childId} />
|
||||
) : null
|
||||
}
|
||||
ListEmptyComponent={renderEmpty}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={isRefetching} onRefresh={() => void refetch()} tintColor={color} />
|
||||
}
|
||||
ItemSeparatorComponent={() => <View className="h-px bg-gray-50 ml-16" />}
|
||||
contentContainerStyle={transactions.length === 0 ? { flex: 1 } : undefined}
|
||||
/>
|
||||
|
||||
|
||||
{/* FAB */}
|
||||
<Pressable
|
||||
onPress={() => setShowAddModal(true)}
|
||||
style={{ backgroundColor: color }}
|
||||
className="absolute bottom-6 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>
|
||||
|
||||
<QuickAddModal
|
||||
visible={showAddModal}
|
||||
onClose={() => { setShowAddModal(false); setNewCategory(null); }}
|
||||
onRequestAddCategory={(t) => { setAddCategoryType(t); setShowAddModal(false); setShowAddCategory(true); }}
|
||||
newCategory={newCategory}
|
||||
defaultScope={scope}
|
||||
defaultChildId={childId}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user