- 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>
188 lines
7.2 KiB
TypeScript
188 lines
7.2 KiB
TypeScript
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>
|
|
);
|
|
}
|