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:
165
apps/native/app/(app)/settings/transfer-line-items.tsx
Normal file
165
apps/native/app/(app)/settings/transfer-line-items.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Modal,
|
||||
Pressable,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
FlatList,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
useTransferLineItems,
|
||||
useCreateTransferLineItem,
|
||||
useDeleteTransferLineItem,
|
||||
type TransferLineItem,
|
||||
} from "@/src/hooks/useFixedCosts";
|
||||
import { ModalHeader } from "@/src/components/ui/ModalHeader";
|
||||
import { Numpad } from "@/src/components/ui/Numpad";
|
||||
import { formatEur } from "@/src/utils/format";
|
||||
import { handleNumpadKey, parseAmountStr } from "@/src/utils/numpad";
|
||||
|
||||
function AddModal({ onClose }: { onClose: () => void }) {
|
||||
const [label, setLabel] = useState("");
|
||||
const [amountStr, setAmountStr] = useState("0");
|
||||
const { mutate: create, isPending } = useCreateTransferLineItem();
|
||||
const { t } = useTranslation();
|
||||
|
||||
function handleNumpad(key: string) {
|
||||
setAmountStr((prev) => handleNumpadKey(prev, key));
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
const amount = parseAmountStr(amountStr);
|
||||
if (!label.trim() || !amount || amount <= 0) return;
|
||||
create({ label: label.trim(), amount }, { onSuccess: onClose });
|
||||
}
|
||||
|
||||
const canSave = label.trim().length > 0 && parseAmountStr(amountStr) > 0;
|
||||
|
||||
return (
|
||||
<Modal visible animationType="slide" presentationStyle="pageSheet" onRequestClose={onClose}>
|
||||
<View className="flex-1 bg-white">
|
||||
<ModalHeader
|
||||
title={t('transferItems.addTitle')}
|
||||
onClose={onClose}
|
||||
closeLabel={t('common.cancel')}
|
||||
onSave={handleSave}
|
||||
saveLabel={t('common.save')}
|
||||
saveDisabled={!canSave}
|
||||
saveLoading={isPending}
|
||||
/>
|
||||
|
||||
<View className="items-center py-6">
|
||||
<Text className="text-5xl font-bold text-gray-900">€ {amountStr}</Text>
|
||||
<Text className="text-sm text-gray-400 mt-1">{t('transferItems.monthlyFixedAmount')}</Text>
|
||||
</View>
|
||||
|
||||
<View className="px-4 mb-4">
|
||||
<Text className="text-sm font-medium text-gray-700 mb-1">{t('transferItems.labelRequired')}</Text>
|
||||
<TextInput
|
||||
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900"
|
||||
placeholder={t('transferItems.labelPlaceholder')}
|
||||
value={label}
|
||||
onChangeText={setLabel}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Numpad onKeyPress={handleNumpad} />
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TransferLineItemsScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const { data: items = [], isLoading } = useTransferLineItems();
|
||||
const { mutate: deleteItem } = useDeleteTransferLineItem();
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
|
||||
const total = items.reduce((sum, i) => sum + i.amount, 0);
|
||||
|
||||
function handleDelete(item: TransferLineItem) {
|
||||
Alert.alert(
|
||||
t('transferItems.removeTitle'),
|
||||
t('transferItems.removeMessage', { label: item.label }),
|
||||
[
|
||||
{ text: t('common.cancel'), style: "cancel" },
|
||||
{ text: t('transferItems.remove'), style: "destructive", onPress: () => deleteItem(item.id) },
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-gray-50">
|
||||
<View className="bg-white border-b border-gray-100" style={{ paddingTop: insets.top }}>
|
||||
<View className="flex-row items-center px-4 py-3">
|
||||
<Pressable onPress={() => router.push("/(app)/settings")} className="mr-3 p-1">
|
||||
<Ionicons name="chevron-back" size={22} color="#374151" />
|
||||
</Pressable>
|
||||
<Text className="text-base font-semibold text-gray-900 flex-1">{t('transferItems.title')}</Text>
|
||||
<Pressable
|
||||
onPress={() => setShowAdd(true)}
|
||||
className="flex-row items-center gap-1 px-3 py-1.5 rounded-full"
|
||||
style={{ backgroundColor: "#dbeafe" }}
|
||||
>
|
||||
<Ionicons name="add" size={14} color="#2563EB" />
|
||||
<Text className="text-xs font-semibold text-blue-600">{t('transferItems.new')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text className="text-xs text-gray-400 px-4 py-3">
|
||||
{t('transferItems.hint')}
|
||||
</Text>
|
||||
|
||||
{isLoading ? (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<ActivityIndicator size="large" color="#2563EB" />
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={items}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item }) => (
|
||||
<View className="flex-row items-center px-4 py-3 bg-white border-b border-gray-50">
|
||||
<View className="flex-1">
|
||||
<Text className="text-sm font-medium text-gray-900">{item.label}</Text>
|
||||
<Text className="text-xs text-gray-400 mt-0.5">{t('common.monthly')}</Text>
|
||||
</View>
|
||||
<Text className="text-sm font-semibold text-gray-800 mr-3">{formatEur(item.amount, false)}</Text>
|
||||
<Pressable onPress={() => handleDelete(item)} hitSlop={8} className="p-1">
|
||||
<Ionicons name="trash-outline" size={16} color="#d1d5db" />
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
<View className="px-4 py-8 items-center">
|
||||
<Text className="text-sm text-gray-400 text-center">
|
||||
{t('transferItems.empty')}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
ListFooterComponent={
|
||||
items.length > 0 ? (
|
||||
<View className="flex-row items-center justify-between px-4 py-3 bg-white mt-3">
|
||||
<Text className="text-sm font-semibold text-gray-700">{t('transferItems.totalMonthly')}</Text>
|
||||
<Text className="text-sm font-bold text-blue-600">{formatEur(total, false)}</Text>
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
contentContainerStyle={{ paddingBottom: insets.bottom + 24 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showAdd && <AddModal onClose={() => setShowAdd(false)} />}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user