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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user