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:
317
apps/native/app/(app)/settings/fixed-costs.tsx
Normal file
317
apps/native/app/(app)/settings/fixed-costs.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Modal,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
SectionList,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
useFixedCosts,
|
||||
useCreateFixedCost,
|
||||
useUpdateFixedCost,
|
||||
useDeleteFixedCost,
|
||||
type FixedCost,
|
||||
type CreateFixedCostInput,
|
||||
} from "@/src/hooks/useFixedCosts";
|
||||
import { useCategories } from "@/src/hooks/useCategories";
|
||||
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";
|
||||
|
||||
const SCOPE_LABEL_KEYS: Record<string, string> = {
|
||||
household: "fixedCosts.household",
|
||||
private: "fixedCosts.me",
|
||||
child: "fixedCosts.children",
|
||||
};
|
||||
|
||||
// ── Add / Edit Modal ──────────────────────────────────────────────────────────
|
||||
|
||||
type ModalMode =
|
||||
| { kind: "add"; scope: "household" | "private" | "child" }
|
||||
| { kind: "edit"; item: FixedCost };
|
||||
|
||||
function FixedCostModal({
|
||||
mode,
|
||||
onClose,
|
||||
}: {
|
||||
mode: ModalMode;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const isEdit = mode.kind === "edit";
|
||||
const { data: categories = [] } = useCategories();
|
||||
const { mutate: create, isPending: creating } = useCreateFixedCost();
|
||||
const { mutate: update, isPending: updating } = useUpdateFixedCost();
|
||||
const { t: tFn } = useTranslation();
|
||||
|
||||
const [label, setLabel] = useState(isEdit ? mode.item.label : "");
|
||||
const [amountStr, setAmountStr] = useState(
|
||||
isEdit ? String(mode.item.amount).replace(".", ",") : "0",
|
||||
);
|
||||
const [type, setType] = useState<"expense" | "income">(
|
||||
isEdit ? mode.item.type : "expense",
|
||||
);
|
||||
const [categoryId, setCategoryId] = useState<string | null>(
|
||||
isEdit ? (mode.item.categoryId ?? null) : null,
|
||||
);
|
||||
const [showCategoryPicker, setShowCategoryPicker] = useState(false);
|
||||
|
||||
const filteredCategories = categories.filter((c) => c.type === type);
|
||||
const selectedCategory = categories.find((c) => c.id === categoryId) ?? null;
|
||||
const isPending = creating || updating;
|
||||
|
||||
function handleNumpad(key: string) {
|
||||
setAmountStr((prev) => handleNumpadKey(prev, key));
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
const amount = parseAmountStr(amountStr);
|
||||
if (!label.trim() || !amount || amount <= 0) return;
|
||||
|
||||
if (isEdit) {
|
||||
update(
|
||||
{ id: mode.item.id, input: { label: label.trim(), amount, categoryId } },
|
||||
{ onSuccess: onClose },
|
||||
);
|
||||
} else {
|
||||
const input: CreateFixedCostInput = {
|
||||
scope: mode.scope,
|
||||
label: label.trim(),
|
||||
amount,
|
||||
type,
|
||||
categoryId: categoryId ?? undefined,
|
||||
};
|
||||
create(input, { 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={isEdit ? tFn('fixedCosts.editTitle') : tFn('fixedCosts.addTitle')}
|
||||
onClose={onClose}
|
||||
closeLabel={tFn('common.cancel')}
|
||||
onSave={handleSave}
|
||||
saveLabel={tFn('common.save')}
|
||||
saveDisabled={!canSave}
|
||||
saveLoading={isPending}
|
||||
/>
|
||||
|
||||
<ScrollView keyboardShouldPersistTaps="handled" contentContainerStyle={{ paddingBottom: 24 }}>
|
||||
{/* Amount */}
|
||||
<View className="items-center py-6">
|
||||
<Text className="text-5xl font-bold text-gray-900">€ {amountStr}</Text>
|
||||
</View>
|
||||
|
||||
<View className="px-4 gap-3 mb-4">
|
||||
{/* Type toggle (only for new) */}
|
||||
{!isEdit && (
|
||||
<View className="flex-row p-1 bg-gray-100 rounded-xl">
|
||||
{(["expense", "income"] as const).map((t) => (
|
||||
<Pressable
|
||||
key={t}
|
||||
onPress={() => { setType(t); setCategoryId(null); }}
|
||||
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('fixedCosts.expenseType') : tFn('fixedCosts.incomeType')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Label */}
|
||||
<View>
|
||||
<Text className="text-sm font-medium text-gray-700 mb-1">{tFn('fixedCosts.labelRequired')}</Text>
|
||||
<TextInput
|
||||
className="bg-gray-50 border border-gray-200 rounded-xl px-4 py-3 text-base text-gray-900"
|
||||
placeholder={tFn('fixedCosts.labelPlaceholder')}
|
||||
value={label}
|
||||
onChangeText={setLabel}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Category */}
|
||||
<View>
|
||||
<Text className="text-sm font-medium text-gray-700 mb-1">{tFn('fixedCosts.categoryOptional')}</Text>
|
||||
<Pressable
|
||||
onPress={() => setShowCategoryPicker((v) => !v)}
|
||||
className="flex-row items-center bg-gray-50 border border-gray-200 rounded-xl px-4 py-3"
|
||||
>
|
||||
<Text className="flex-1 text-base" style={{ color: selectedCategory ? "#111827" : "#9ca3af" }}>
|
||||
{selectedCategory ? selectedCategory.name : tFn('common.select')}
|
||||
</Text>
|
||||
{selectedCategory ? (
|
||||
<Pressable onPress={(e) => { e.stopPropagation(); setCategoryId(null); }} hitSlop={8}>
|
||||
<Ionicons name="close-circle" size={18} color="#9ca3af" />
|
||||
</Pressable>
|
||||
) : (
|
||||
<Ionicons name={showCategoryPicker ? "chevron-up" : "chevron-down"} size={14} color="#9ca3af" />
|
||||
)}
|
||||
</Pressable>
|
||||
{showCategoryPicker && (
|
||||
<View className="mt-1 bg-white border border-gray-200 rounded-xl overflow-hidden">
|
||||
{filteredCategories.map((cat) => (
|
||||
<Pressable
|
||||
key={cat.id}
|
||||
onPress={() => { setCategoryId(cat.id); setShowCategoryPicker(false); }}
|
||||
className="flex-row items-center px-4 py-3 active:bg-gray-50"
|
||||
style={{ borderBottomWidth: 1, borderBottomColor: "#f3f4f6" }}
|
||||
>
|
||||
<View className="w-6 h-6 rounded-full mr-3 items-center justify-center" style={{ backgroundColor: cat.color ?? "#6b7280" }}>
|
||||
<Ionicons name={(cat.icon ?? "ellipsis-horizontal-circle-outline") as React.ComponentProps<typeof Ionicons>["name"]} size={12} color="#fff" />
|
||||
</View>
|
||||
<Text className="text-sm text-gray-800">{cat.name}</Text>
|
||||
{categoryId === cat.id && <Ionicons name="checkmark" size={16} color="#2563EB" style={{ marginLeft: "auto" }} />}
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Numpad */}
|
||||
<Numpad onKeyPress={handleNumpad} />
|
||||
</ScrollView>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Row ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
function FixedCostRow({
|
||||
item,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
item: FixedCost;
|
||||
onEdit: (item: FixedCost) => void;
|
||||
onDelete: (item: FixedCost) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<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">
|
||||
{item.type === "expense" ? t('fixedCosts.expenseType') : t('fixedCosts.incomeType')} · {t('common.monthly')}
|
||||
</Text>
|
||||
</View>
|
||||
<Text className="text-sm font-semibold text-gray-800 mr-3">{formatEur(item.amount, false)}</Text>
|
||||
<Pressable onPress={() => onEdit(item)} hitSlop={8} className="mr-2 p-1">
|
||||
<Ionicons name="pencil-outline" size={16} color="#6b7280" />
|
||||
</Pressable>
|
||||
<Pressable onPress={() => onDelete(item)} hitSlop={8} className="p-1">
|
||||
<Ionicons name="trash-outline" size={16} color="#d1d5db" />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Screen ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function FixedCostsScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const { data: allFixedCosts = [], isLoading } = useFixedCosts();
|
||||
const { mutate: deleteCost } = useDeleteFixedCost();
|
||||
const [modalMode, setModalMode] = useState<ModalMode | null>(null);
|
||||
|
||||
const active = allFixedCosts.filter((fc) => fc.isActive);
|
||||
|
||||
const sections = (["household", "private", "child"] as const)
|
||||
.map((scope) => ({
|
||||
scope,
|
||||
title: t(SCOPE_LABEL_KEYS[scope] ?? scope),
|
||||
data: active.filter((fc) => fc.scope === scope),
|
||||
}))
|
||||
.filter((s) => s.data.length > 0 || true); // always show all scopes
|
||||
|
||||
function handleDelete(item: FixedCost) {
|
||||
Alert.alert(
|
||||
t('fixedCosts.pauseTitle'),
|
||||
t('fixedCosts.pauseMessage', { label: item.label }),
|
||||
[
|
||||
{ text: t('common.cancel'), style: "cancel" },
|
||||
{ text: t('fixedCosts.pause'), style: "destructive", onPress: () => deleteCost(item.id) },
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-gray-50">
|
||||
{/* Header */}
|
||||
<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('fixedCosts.title')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{isLoading ? (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<ActivityIndicator size="large" color="#2563EB" />
|
||||
</View>
|
||||
) : (
|
||||
<SectionList
|
||||
sections={sections}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderSectionHeader={({ section }) => (
|
||||
<View className="flex-row items-center justify-between px-4 py-3 bg-gray-50">
|
||||
<Text className="text-xs font-semibold uppercase text-gray-500 tracking-wide">
|
||||
{section.title}
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={() => setModalMode({ kind: "add", scope: section.scope })}
|
||||
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('common.new')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
renderItem={({ item }) => (
|
||||
<FixedCostRow
|
||||
item={item}
|
||||
onEdit={(i) => setModalMode({ kind: "edit", item: i })}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
)}
|
||||
renderSectionFooter={({ section }) =>
|
||||
section.data.length === 0 ? (
|
||||
<View className="px-4 py-3 bg-white border-b border-gray-50">
|
||||
<Text className="text-sm text-gray-400 italic">{t('fixedCosts.noItems')}</Text>
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
contentContainerStyle={{ paddingBottom: insets.bottom + 24 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{modalMode && (
|
||||
<FixedCostModal mode={modalMode} onClose={() => setModalMode(null)} />
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user