Обновление приложения

This commit is contained in:
2026-04-28 22:37:19 +03:00
parent 7d00cf062e
commit 52594b5dc6
99 changed files with 5736 additions and 1052 deletions

16
android/App.tsx Normal file
View File

@@ -0,0 +1,16 @@
import React from "react";
import { StatusBar } from "expo-status-bar";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { AuthProvider } from "./src/contexts/AuthContext";
import AppNavigator from "./src/navigation/AppNavigator";
export default function App() {
return (
<SafeAreaProvider>
<AuthProvider>
<StatusBar style="light" />
<AppNavigator />
</AuthProvider>
</SafeAreaProvider>
);
}

25
android/app.json Normal file
View File

@@ -0,0 +1,25 @@
{
"expo": {
"name": "FinanceApp",
"slug": "finance-app",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#1e2a3a"
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#1e2a3a"
},
"package": "com.financeapp",
"permissions": ["INTERNET"]
},
"plugins": ["expo-router", "expo-secure-store"],
"scheme": "financeapp"
}
}

31
android/package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "finance-app-android",
"version": "1.0.0",
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios"
},
"dependencies": {
"expo": "~51.0.0",
"expo-router": "~3.5.0",
"expo-secure-store": "~13.0.0",
"expo-status-bar": "~1.12.1",
"react": "18.2.0",
"react-native": "0.74.1",
"react-native-safe-area-context": "4.10.1",
"react-native-screens": "3.31.1",
"@react-navigation/native": "^6.1.17",
"@react-navigation/bottom-tabs": "^6.5.20",
"@react-navigation/stack": "^6.3.29",
"axios": "^1.7.2",
"react-native-vector-icons": "^10.1.0",
"date-fns": "^3.6.0"
},
"devDependencies": {
"@babel/core": "^7.24.0",
"@types/react": "~18.2.79",
"typescript": "~5.3.3"
}
}

83
android/src/api/client.ts Normal file
View File

@@ -0,0 +1,83 @@
import axios from "axios";
import * as SecureStore from "expo-secure-store";
// Change to your server address
const BASE_URL = process.env.EXPO_PUBLIC_API_URL || "https://your-server.com/api/v1";
const api = axios.create({
baseURL: BASE_URL,
headers: { "Content-Type": "application/json" },
timeout: 15000,
});
api.interceptors.request.use(async (config) => {
const token = await SecureStore.getItemAsync("access_token");
if (token) config.headers!.Authorization = `Bearer ${token}`;
return config;
});
api.interceptors.response.use(
(r) => r,
async (error) => {
const original = error.config;
if (error.response?.status === 401 && !original._retry) {
original._retry = true;
const refresh = await SecureStore.getItemAsync("refresh_token");
if (refresh) {
try {
const { data } = await axios.post(`${BASE_URL}/auth/refresh`, null, {
params: { refresh_token: refresh },
});
await SecureStore.setItemAsync("access_token", data.access_token);
await SecureStore.setItemAsync("refresh_token", data.refresh_token);
original.headers.Authorization = `Bearer ${data.access_token}`;
return api(original);
} catch {
await SecureStore.deleteItemAsync("access_token");
await SecureStore.deleteItemAsync("refresh_token");
}
}
}
return Promise.reject(error);
}
);
// ── Auth ────────────────────────────────────────────────
export const login = (username: string, password: string) =>
api.post("/auth/login/json", { username, password });
export const getMe = () => api.get("/users/me");
// ── Accounts ─────────────────────────────────────────────
export const getAccountTypes = () => api.get("/accounts/types");
export const getAccounts = () => api.get("/accounts");
export const createAccount = (d: any) => api.post("/accounts", d);
export const updateAccount = (id: number, d: any) => api.patch(`/accounts/${id}`, d);
export const deleteAccount = (id: number) => api.delete(`/accounts/${id}`);
// ── Operations ────────────────────────────────────────────
export const getOperationTypes = () => api.get("/operations/types");
export const getOperations = (params?: any) => api.get("/operations", { params });
export const createOperation = (d: any) => api.post("/operations", d);
export const createTransfer = (d: any) => api.post("/operations/transfer", d);
export const deleteOperation = (id: number) => api.delete(`/operations/${id}`);
// ── Budgets ───────────────────────────────────────────────
export const getBudgets = () => api.get("/budgets");
export const createBudget = (d: any) => api.post("/budgets", d);
// ── Categories ────────────────────────────────────────────
export const getCategories = () => api.get("/categories");
// ── Currencies ────────────────────────────────────────────
export const getCurrencies = () => api.get("/currencies");
export const getExchangeRates = (params?: any) => api.get("/exchange-rates", { params });
// ── Locations ─────────────────────────────────────────────
export const getLocations = () => api.get("/locations");
// ── Calendar ──────────────────────────────────────────────
export const getCalendarEvents = (params?: any) => api.get("/calendar", { params });
export const createCalendarEvent = (d: any) => api.post("/calendar", d);
export const updateCalendarEvent = (id: number, d: any) => api.patch(`/calendar/${id}`, d);
export default api;

View File

@@ -0,0 +1,50 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from "react";
import * as SecureStore from "expo-secure-store";
import { login as apiLogin, getMe } from "../api/client";
interface User { id: number; username: string; email: string; role: { name: string }; }
interface AuthCtx { user: User | null; loading: boolean; login(u: string, p: string): Promise<void>; logout(): void; isAdmin: boolean; }
const AuthContext = createContext<AuthCtx | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const loadUser = useCallback(async () => {
const token = await SecureStore.getItemAsync("access_token");
if (!token) { setLoading(false); return; }
try {
const { data } = await getMe();
setUser(data);
} catch {
await SecureStore.deleteItemAsync("access_token");
await SecureStore.deleteItemAsync("refresh_token");
} finally { setLoading(false); }
}, []);
useEffect(() => { loadUser(); }, []);
const login = async (username: string, password: string) => {
const { data } = await apiLogin(username, password);
await SecureStore.setItemAsync("access_token", data.access_token);
await SecureStore.setItemAsync("refresh_token", data.refresh_token);
await loadUser();
};
const logout = async () => {
await SecureStore.deleteItemAsync("access_token");
await SecureStore.deleteItemAsync("refresh_token");
setUser(null);
};
const isAdmin = user?.role?.name === "admin";
return (
<AuthContext.Provider value={{ user, loading, login, logout, isAdmin }}>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => useContext(AuthContext)!;

View File

@@ -0,0 +1,70 @@
import React from "react";
import { NavigationContainer } from "@react-navigation/native";
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import { createStackNavigator } from "@react-navigation/stack";
import { Text, ActivityIndicator, View } from "react-native";
import { useAuth } from "../contexts/AuthContext";
import LoginScreen from "../screens/LoginScreen";
import DashboardScreen from "../screens/DashboardScreen";
import AccountsScreen from "../screens/AccountsScreen";
import OperationsScreen from "../screens/OperationsScreen";
import BudgetsScreen from "../screens/BudgetsScreen";
import CalendarScreen from "../screens/CalendarScreen";
const Tab = createBottomTabNavigator();
const Stack = createStackNavigator();
const THEME = {
primary: "#3b82f6",
surface: "#ffffff",
bg: "#f4f6fa",
text: "#1e293b",
muted: "#64748b",
};
function TabNavigator() {
const tabIcon = (emoji: string) => ({ focused }: any) => (
<Text style={{ fontSize: focused ? 22 : 18 }}>{emoji}</Text>
);
return (
<Tab.Navigator
screenOptions={{
headerShown: false,
tabBarActiveTintColor: THEME.primary,
tabBarInactiveTintColor: THEME.muted,
tabBarStyle: { backgroundColor: THEME.surface, borderTopColor: "#e2e8f0", paddingBottom: 4 },
tabBarLabelStyle: { fontSize: 11, fontWeight: "600" },
}}
>
<Tab.Screen name="Дашборд" component={DashboardScreen} options={{ tabBarIcon: tabIcon("📊") }} />
<Tab.Screen name="Счета" component={AccountsScreen} options={{ tabBarIcon: tabIcon("🏦") }} />
<Tab.Screen name="Операции" component={OperationsScreen} options={{ tabBarIcon: tabIcon("💸") }} />
<Tab.Screen name="Бюджеты" component={BudgetsScreen} options={{ tabBarIcon: tabIcon("🎯") }} />
<Tab.Screen name="Календарь" component={CalendarScreen} options={{ tabBarIcon: tabIcon("📅") }} />
</Tab.Navigator>
);
}
export default function AppNavigator() {
const { user, loading } = useAuth();
if (loading) {
return (
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: THEME.bg }}>
<ActivityIndicator size="large" color={THEME.primary} />
</View>
);
}
return (
<NavigationContainer>
<Stack.Navigator screenOptions={{ headerShown: false }}>
{user
? <Stack.Screen name="Main" component={TabNavigator} />
: <Stack.Screen name="Login" component={LoginScreen} />
}
</Stack.Navigator>
</NavigationContainer>
);
}

View File

@@ -0,0 +1,150 @@
import React, { useEffect, useState, useCallback } from "react";
import { View, Text, ScrollView, TouchableOpacity, StyleSheet, TextInput, Modal, Alert, RefreshControl } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { getAccounts, getAccountTypes, getCurrencies, createAccount, deleteAccount } from "../api/client";
const C = { primary: "#3b82f6", success: "#22c55e", danger: "#ef4444", muted: "#64748b", bg: "#f4f6fa", surface: "#fff", border: "#e2e8f0" };
export default function AccountsScreen() {
const [accounts, setAccounts] = useState<any[]>([]);
const [types, setTypes] = useState<any[]>([]);
const [currencies, setCurrencies] = useState<any[]>([]);
const [modal, setModal] = useState(false);
const [form, setForm] = useState({ name: "", account_type_id: "", currency_id: "", initial_balance: "0", color: "#3b82f6" });
const [refreshing, setRefreshing] = useState(false);
const load = useCallback(async () => {
const [a, t, c] = await Promise.all([getAccounts(), getAccountTypes(), getCurrencies()]);
setAccounts(a.data); setTypes(t.data); setCurrencies(c.data.filter((c: any) => c.is_active));
}, []);
useEffect(() => { load(); }, []);
const onRefresh = async () => { setRefreshing(true); await load(); setRefreshing(false); };
const save = async () => {
if (!form.name || !form.account_type_id || !form.currency_id) {
Alert.alert("Ошибка", "Заполните все обязательные поля"); return;
}
try {
await createAccount({ name: form.name, account_type_id: parseInt(form.account_type_id), currency_id: parseInt(form.currency_id), initial_balance: parseFloat(form.initial_balance) || 0, color: form.color });
await load(); setModal(false);
} catch (e: any) { Alert.alert("Ошибка", e.response?.data?.detail || "Не удалось создать счёт"); }
};
const remove = (id: number) => {
Alert.alert("Удалить счёт?", "Все операции по счёту останутся.", [
{ text: "Отмена" },
{ text: "Удалить", style: "destructive", onPress: async () => { await deleteAccount(id); load(); } },
]);
};
const fmt = (v: number) => v.toLocaleString("ru-RU", { minimumFractionDigits: 2 });
return (
<SafeAreaView style={{ flex: 1, backgroundColor: C.bg }}>
<View style={s.header}>
<Text style={s.title}>Счета</Text>
<TouchableOpacity style={s.addBtn} onPress={() => setModal(true)}>
<Text style={s.addBtnText}>+ Добавить</Text>
</TouchableOpacity>
</View>
<ScrollView contentContainerStyle={{ padding: 16 }} refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
{accounts.length === 0
? <Text style={s.empty}>Счетов нет. Нажмите «+ Добавить»</Text>
: accounts.map((a: any) => (
<View key={a.id} style={s.card}>
<View style={{ flexDirection: "row", alignItems: "center" }}>
<View style={[s.dot, { backgroundColor: a.color || "#94a3b8" }]} />
<View style={{ flex: 1 }}>
<Text style={s.cardTitle}>{a.name}</Text>
<Text style={s.cardSub}>{a.account_type?.name} · {a.currency?.code}</Text>
</View>
<View style={{ alignItems: "flex-end" }}>
<Text style={[s.balance, { color: parseFloat(a.balance) >= 0 ? C.success : C.danger }]}>
{fmt(parseFloat(a.balance))} {a.currency?.symbol}
</Text>
<Text style={s.cardSub}>{a.is_active ? "Активен" : "Закрыт"}</Text>
</View>
</View>
<View style={s.cardActions}>
<TouchableOpacity style={s.deleteBtn} onPress={() => remove(a.id)}>
<Text style={s.deleteText}>🗑️ Удалить</Text>
</TouchableOpacity>
</View>
</View>
))
}
</ScrollView>
<Modal visible={modal} animationType="slide" presentationStyle="pageSheet">
<SafeAreaView style={{ flex: 1, backgroundColor: C.bg }}>
<View style={s.modalHeader}>
<Text style={s.modalTitle}>Новый счёт</Text>
<TouchableOpacity onPress={() => setModal(false)}><Text style={{ color: C.danger, fontSize: 16 }}>✕</Text></TouchableOpacity>
</View>
<ScrollView contentContainerStyle={{ padding: 20 }}>
<Text style={s.label}>Название *</Text>
<TextInput style={s.input} value={form.name} onChangeText={v => setForm({ ...form, name: v })} placeholder="Например: Наличные" />
<Text style={s.label}>Тип счёта *</Text>
{types.map((t: any) => (
<TouchableOpacity key={t.id} onPress={() => setForm({ ...form, account_type_id: String(t.id) })}
style={[s.radioItem, form.account_type_id === String(t.id) && s.radioSelected]}>
<Text style={form.account_type_id === String(t.id) ? s.radioTextSelected : s.radioText}>{t.name}</Text>
</TouchableOpacity>
))}
<Text style={[s.label, { marginTop: 12 }]}>Валюта *</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
{currencies.map((c: any) => (
<TouchableOpacity key={c.id} onPress={() => setForm({ ...form, currency_id: String(c.id) })}
style={[s.currencyChip, form.currency_id === String(c.id) && s.currencyChipSelected]}>
<Text style={form.currency_id === String(c.id) ? { color: "#fff", fontWeight: "700" } : { color: C.muted }}>{c.code}</Text>
</TouchableOpacity>
))}
</ScrollView>
<Text style={[s.label, { marginTop: 12 }]}>Начальный баланс</Text>
<TextInput style={s.input} value={form.initial_balance} onChangeText={v => setForm({ ...form, initial_balance: v })} keyboardType="decimal-pad" />
<TouchableOpacity style={s.saveBtn} onPress={save}>
<Text style={s.saveBtnText}>Создать счёт</Text>
</TouchableOpacity>
</ScrollView>
</SafeAreaView>
</Modal>
</SafeAreaView>
);
}
const s = StyleSheet.create({
header: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", padding: 16, backgroundColor: "#1e2a3a" },
title: { color: "#fff", fontSize: 20, fontWeight: "800" },
addBtn: { backgroundColor: C.primary, paddingVertical: 7, paddingHorizontal: 14, borderRadius: 8 },
addBtnText: { color: "#fff", fontWeight: "700", fontSize: 13 },
empty: { textAlign: "center", color: C.muted, padding: 40, fontSize: 15 },
card: { backgroundColor: C.surface, borderRadius: 12, padding: 16, marginBottom: 10, shadowColor: "#000", shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.06, shadowRadius: 4, elevation: 2 },
dot: { width: 12, height: 12, borderRadius: 6, marginRight: 12 },
cardTitle: { fontSize: 15, fontWeight: "700", color: "#1e293b" },
cardSub: { fontSize: 12, color: C.muted, marginTop: 2 },
balance: { fontSize: 16, fontWeight: "800" },
cardActions: { flexDirection: "row", justifyContent: "flex-end", marginTop: 10, paddingTop: 10, borderTopWidth: 1, borderTopColor: C.border },
deleteBtn: { paddingVertical: 4, paddingHorizontal: 12 },
deleteText: { color: C.danger, fontSize: 13 },
modalHeader: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", padding: 20, borderBottomWidth: 1, borderBottomColor: C.border },
modalTitle: { fontSize: 18, fontWeight: "800", color: "#1e293b" },
label: { fontSize: 13, fontWeight: "600", color: "#1e293b", marginBottom: 6 },
input: { borderWidth: 1, borderColor: C.border, borderRadius: 8, padding: 12, fontSize: 15, marginBottom: 14, backgroundColor: C.surface, color: "#1e293b" },
radioItem: { padding: 12, borderRadius: 8, borderWidth: 1, borderColor: C.border, marginBottom: 6, backgroundColor: C.surface },
radioSelected: { borderColor: C.primary, backgroundColor: "#dbeafe" },
radioText: { color: "#1e293b" },
radioTextSelected: { color: C.primary, fontWeight: "700" },
currencyChip: { paddingVertical: 8, paddingHorizontal: 16, borderRadius: 20, borderWidth: 1, borderColor: C.border, marginRight: 8, backgroundColor: C.surface },
currencyChipSelected: { backgroundColor: C.primary, borderColor: C.primary },
saveBtn: { backgroundColor: C.primary, borderRadius: 12, padding: 16, alignItems: "center", marginTop: 24 },
saveBtnText: { color: "#fff", fontWeight: "800", fontSize: 16 },
});
const C_primary = "#3b82f6";

View File

@@ -0,0 +1,94 @@
import React, { useEffect, useState, useCallback } from "react";
import { View, Text, ScrollView, StyleSheet, RefreshControl } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { getBudgets } from "../api/client";
const C = { primary: "#3b82f6", success: "#22c55e", danger: "#ef4444", muted: "#64748b", bg: "#f4f6fa", surface: "#fff", border: "#e2e8f0" };
const PERIOD: Record<string, string> = { monthly: "Ежемесячно", weekly: "Еженедельно", yearly: "Ежегодно", custom: "Произвольный" };
export default function BudgetsScreen() {
const [budgets, setBudgets] = useState<any[]>([]);
const [refreshing, setRefreshing] = useState(false);
const load = useCallback(async () => {
const { data } = await getBudgets();
setBudgets(data);
}, []);
useEffect(() => { load(); }, []);
const onRefresh = async () => { setRefreshing(true); await load(); setRefreshing(false); };
const fmt = (v: number) => v.toLocaleString("ru-RU", { minimumFractionDigits: 2 });
const active = budgets.filter((b: any) => b.is_active);
const inactive = budgets.filter((b: any) => !b.is_active);
const BudgetCard = ({ b }: { b: any }) => {
const spent = parseFloat(b.spent || 0);
const limit = parseFloat(b.amount);
const pct = limit > 0 ? Math.min(spent / limit, 1) : 0;
const over = spent > limit;
return (
<View style={s.card}>
<View style={{ flexDirection: "row", justifyContent: "space-between", marginBottom: 4 }}>
<Text style={s.cardTitle}>{b.name}</Text>
<View style={[s.badge, { backgroundColor: b.is_active ? "#dcfce7" : "#f1f5f9" }]}>
<Text style={{ color: b.is_active ? "#166534" : C.muted, fontSize: 11, fontWeight: "700" }}>{b.is_active ? "Активен" : "Неактивен"}</Text>
</View>
</View>
<Text style={s.cardSub}>{b.category?.name} · {PERIOD[b.period_type] || b.period_type}</Text>
<View style={{ flexDirection: "row", justifyContent: "space-between", marginTop: 10, marginBottom: 6 }}>
<Text style={{ fontSize: 13, color: C.muted }}>Потрачено: <Text style={{ fontWeight: "700", color: over ? C.danger : "#1e293b" }}>{fmt(spent)}</Text></Text>
<Text style={{ fontSize: 13, color: C.muted }}>Лимит: <Text style={{ fontWeight: "700", color: "#1e293b" }}>{fmt(limit)} {b.currency?.symbol}</Text></Text>
</View>
<View style={s.progressBg}>
<View style={[s.progressFill, { width: `${pct * 100}%`, backgroundColor: over ? C.danger : C.primary }]} />
</View>
{over
? <Text style={{ color: C.danger, fontSize: 12, marginTop: 6, fontWeight: "600" }}>Перерасход: {fmt(spent - limit)}</Text>
: <Text style={{ color: C.success, fontSize: 12, marginTop: 6 }}>Остаток: {fmt(limit - spent)}</Text>
}
</View>
);
};
return (
<SafeAreaView style={{ flex: 1, backgroundColor: C.bg }}>
<View style={s.header}>
<Text style={s.title}>Бюджеты</Text>
</View>
<ScrollView contentContainerStyle={{ padding: 16 }} refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
{budgets.length === 0
? <Text style={s.empty}>Бюджеты не настроены. Добавьте их в веб-версии.</Text>
: <>
{active.length > 0 && (
<>
<Text style={s.sectionTitle}>Активные</Text>
{active.map((b: any) => <BudgetCard key={b.id} b={b} />)}
</>
)}
{inactive.length > 0 && (
<>
<Text style={s.sectionTitle}>Неактивные</Text>
{inactive.map((b: any) => <BudgetCard key={b.id} b={b} />)}
</>
)}
</>
}
</ScrollView>
</SafeAreaView>
);
}
const s = StyleSheet.create({
header: { padding: 16, backgroundColor: "#1e2a3a" },
title: { color: "#fff", fontSize: 20, fontWeight: "800" },
empty: { textAlign: "center", color: "#64748b", padding: 40, fontSize: 15 },
sectionTitle: { fontSize: 15, fontWeight: "700", color: "#1e293b", marginTop: 12, marginBottom: 8 },
card: { backgroundColor: "#fff", borderRadius: 12, padding: 16, marginBottom: 10, shadowColor: "#000", shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.06, shadowRadius: 4, elevation: 2 },
cardTitle: { fontSize: 15, fontWeight: "700", color: "#1e293b" },
cardSub: { fontSize: 12, color: "#64748b", marginTop: 2 },
badge: { paddingHorizontal: 8, paddingVertical: 3, borderRadius: 12 },
progressBg: { height: 10, backgroundColor: "#e2e8f0", borderRadius: 5, overflow: "hidden" },
progressFill: { height: 10, borderRadius: 5 },
});

View File

@@ -0,0 +1,118 @@
import React, { useEffect, useState, useCallback } from "react";
import { View, Text, ScrollView, TouchableOpacity, StyleSheet, RefreshControl } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { getCalendarEvents, updateCalendarEvent } from "../api/client";
import { format, startOfMonth, endOfMonth, addMonths, subMonths } from "date-fns";
import { ru } from "date-fns/locale";
const C = { primary: "#3b82f6", success: "#22c55e", warning: "#f59e0b", muted: "#64748b", bg: "#f4f6fa", surface: "#fff", border: "#e2e8f0", danger: "#ef4444" };
export default function CalendarScreen() {
const [events, setEvents] = useState<any[]>([]);
const [currentMonth, setCurrentMonth] = useState(new Date());
const [refreshing, setRefreshing] = useState(false);
const load = useCallback(async () => {
const from = format(startOfMonth(currentMonth), "yyyy-MM-dd");
const to = format(endOfMonth(currentMonth), "yyyy-MM-dd");
const { data } = await getCalendarEvents({ date_from: from, date_to: to, limit: 500 });
setEvents(data);
}, [currentMonth]);
useEffect(() => { load(); }, [currentMonth]);
const onRefresh = async () => { setRefreshing(true); await load(); setRefreshing(false); };
const toggle = async (ev: any) => {
await updateCalendarEvent(ev.id, { is_completed: !ev.is_completed });
load();
};
// Group events by date
const grouped: Record<string, any[]> = {};
events.forEach((ev: any) => {
if (!grouped[ev.event_date]) grouped[ev.event_date] = [];
grouped[ev.event_date].push(ev);
});
const sortedDates = Object.keys(grouped).sort();
const pending = events.filter((e: any) => !e.is_completed).length;
const completed = events.filter((e: any) => e.is_completed).length;
return (
<SafeAreaView style={{ flex: 1, backgroundColor: C.bg }}>
<View style={s.header}>
<TouchableOpacity onPress={() => setCurrentMonth(subMonths(currentMonth, 1))}>
<Text style={s.navArrow}>‹</Text>
</TouchableOpacity>
<View>
<Text style={s.title}>Календарь</Text>
<Text style={s.subtitle}>{format(currentMonth, "LLLL yyyy", { locale: ru })}</Text>
</View>
<TouchableOpacity onPress={() => setCurrentMonth(addMonths(currentMonth, 1))}>
<Text style={s.navArrow}>›</Text>
</TouchableOpacity>
</View>
{/* Stats */}
<View style={s.statsRow}>
<View style={[s.statCard, { flex: 1, marginRight: 8 }]}>
<Text style={s.statNum}>{pending}</Text>
<Text style={s.statLabel}>Ожидается</Text>
</View>
<View style={[s.statCard, { flex: 1 }]}>
<Text style={[s.statNum, { color: C.success }]}>{completed}</Text>
<Text style={s.statLabel}>Выполнено</Text>
</View>
</View>
<ScrollView contentContainerStyle={{ padding: 16 }} refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
{sortedDates.length === 0
? <Text style={s.empty}>Событий в этом месяце нет</Text>
: sortedDates.map(date => (
<View key={date} style={{ marginBottom: 16 }}>
<Text style={s.dateHeader}>{format(new Date(date), "d MMMM, EEEE", { locale: ru })}</Text>
{grouped[date].map((ev: any) => (
<View key={ev.id} style={[s.eventCard, ev.is_completed && s.eventCompleted]}>
<TouchableOpacity onPress={() => toggle(ev)} style={s.checkBtn}>
<Text style={{ fontSize: 22 }}>{ev.is_completed ? "✅" : "⏳"}</Text>
</TouchableOpacity>
<View style={{ flex: 1 }}>
<Text style={[s.eventTitle, ev.is_completed && s.strikethrough]}>{ev.title}</Text>
<View style={{ flexDirection: "row", gap: 8, flexWrap: "wrap", marginTop: 4 }}>
{ev.event_time && <Text style={s.tag}>🕐 {ev.event_time.slice(0, 5)}</Text>}
{ev.amount && <Text style={s.tag}>💰 {parseFloat(ev.amount).toLocaleString("ru-RU")}</Text>}
{ev.category?.name && <Text style={s.tag}>{ev.category.name}</Text>}
{ev.is_recurring && <Text style={[s.tag, { color: C.primary }]}>🔁 {ev.recurrence_pattern}</Text>}
{ev.reminder_days && <Text style={s.tag}>🔔 за {ev.reminder_days} дн.</Text>}
</View>
{ev.description && <Text style={s.eventDesc}>{ev.description}</Text>}
</View>
</View>
))}
</View>
))
}
</ScrollView>
</SafeAreaView>
);
}
const s = StyleSheet.create({
header: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", padding: 16, backgroundColor: "#1e2a3a" },
navArrow: { color: "#cbd5e1", fontSize: 28, fontWeight: "300", paddingHorizontal: 8 },
title: { color: "#fff", fontSize: 20, fontWeight: "800", textAlign: "center" },
subtitle: { color: "#94a3b8", fontSize: 13, textAlign: "center", marginTop: 2 },
statsRow: { flexDirection: "row", padding: 12, gap: 0 },
statCard: { backgroundColor: "#fff", borderRadius: 10, padding: 12, alignItems: "center", shadowColor: "#000", shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.05, shadowRadius: 3, elevation: 1 },
statNum: { fontSize: 24, fontWeight: "800", color: "#1e293b" },
statLabel: { fontSize: 12, color: "#64748b", marginTop: 2 },
empty: { textAlign: "center", color: "#64748b", padding: 40, fontSize: 15 },
dateHeader: { fontSize: 14, fontWeight: "700", color: "#64748b", textTransform: "capitalize", marginBottom: 8 },
eventCard: { backgroundColor: "#fff", borderRadius: 12, padding: 14, marginBottom: 8, flexDirection: "row", alignItems: "flex-start", shadowColor: "#000", shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.06, shadowRadius: 4, elevation: 2 },
eventCompleted: { opacity: 0.6, backgroundColor: "#f8fafc" },
checkBtn: { marginRight: 12, paddingTop: 2 },
eventTitle: { fontSize: 15, fontWeight: "700", color: "#1e293b" },
strikethrough: { textDecorationLine: "line-through", color: "#94a3b8" },
tag: { fontSize: 11, color: "#64748b", backgroundColor: "#f1f5f9", paddingHorizontal: 8, paddingVertical: 3, borderRadius: 10 },
eventDesc: { fontSize: 12, color: "#94a3b8", marginTop: 6, fontStyle: "italic" },
});

View File

@@ -0,0 +1,155 @@
import React, { useEffect, useState, useCallback } from "react";
import { View, Text, ScrollView, TouchableOpacity, StyleSheet, RefreshControl } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { getAccounts, getOperations, getBudgets } from "../api/client";
import { useAuth } from "../contexts/AuthContext";
import { startOfMonth, endOfMonth, format } from "date-fns";
const C = { primary: "#3b82f6", success: "#22c55e", danger: "#ef4444", muted: "#64748b", bg: "#f4f6fa", surface: "#fff", border: "#e2e8f0" };
const fmt = (v: number) => v.toLocaleString("ru-RU", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
export default function DashboardScreen() {
const { user, logout } = useAuth();
const [accounts, setAccounts] = useState<any[]>([]);
const [operations, setOperations] = useState<any[]>([]);
const [budgets, setBudgets] = useState<any[]>([]);
const [refreshing, setRefreshing] = useState(false);
const load = useCallback(async () => {
const from = startOfMonth(new Date()).toISOString();
const to = endOfMonth(new Date()).toISOString();
const [a, o, b] = await Promise.all([
getAccounts(), getOperations({ date_from: from, date_to: to, limit: 500 }), getBudgets(),
]);
setAccounts(a.data); setOperations(o.data); setBudgets(b.data);
}, []);
useEffect(() => { load(); }, []);
const onRefresh = async () => { setRefreshing(true); await load(); setRefreshing(false); };
const totalBalance = accounts.reduce((s: number, a: any) => s + parseFloat(a.balance), 0);
const income = operations.filter((o: any) => o.operation_type?.name === "income").reduce((s: number, o: any) => s + parseFloat(o.amount), 0);
const expense = operations.filter((o: any) => o.operation_type?.name === "expense").reduce((s: number, o: any) => s + parseFloat(o.amount), 0);
return (
<SafeAreaView style={{ flex: 1, backgroundColor: C.bg }}>
<View style={s.header}>
<View>
<Text style={s.headerTitle}>Дашборд</Text>
<Text style={s.headerSub}>{user?.username}</Text>
</View>
<TouchableOpacity onPress={logout} style={s.logoutBtn}>
<Text style={s.logoutText}>Выйти</Text>
</TouchableOpacity>
</View>
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 16 }} refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
{/* KPI Cards */}
<View style={s.kpiRow}>
<View style={[s.kpiCard, { flex: 1 }]}>
<Text style={s.kpiLabel}>Общий баланс</Text>
<Text style={[s.kpiValue, { fontSize: 18 }]}>{fmt(totalBalance)} ₽</Text>
<Text style={s.kpiSub}>{accounts.length} счётов</Text>
</View>
</View>
<View style={s.kpiRow}>
<View style={[s.kpiCard, { flex: 1, marginRight: 8 }]}>
<Text style={s.kpiLabel}>Доходы (мес.)</Text>
<Text style={[s.kpiValue, { color: C.success }]}>{fmt(income)}</Text>
</View>
<View style={[s.kpiCard, { flex: 1 }]}>
<Text style={s.kpiLabel}>Расходы (мес.)</Text>
<Text style={[s.kpiValue, { color: C.danger }]}>{fmt(expense)}</Text>
</View>
</View>
{/* Accounts */}
<Text style={s.sectionTitle}>Мои счета</Text>
{accounts.length === 0
? <Text style={s.empty}>Счетов нет</Text>
: accounts.map((a: any) => (
<View key={a.id} style={s.listItem}>
<View style={[s.colorDot, { backgroundColor: a.color || "#94a3b8" }]} />
<View style={{ flex: 1 }}>
<Text style={s.itemTitle}>{a.name}</Text>
<Text style={s.itemSub}>{a.account_type?.name} · {a.currency?.code}</Text>
</View>
<Text style={[s.itemAmount, { color: parseFloat(a.balance) >= 0 ? C.success : C.danger }]}>
{fmt(parseFloat(a.balance))} {a.currency?.symbol}
</Text>
</View>
))
}
{/* Active budgets */}
{budgets.filter((b: any) => b.is_active).length > 0 && (
<>
<Text style={s.sectionTitle}>Бюджеты</Text>
{budgets.filter((b: any) => b.is_active).map((b: any) => {
const spent = parseFloat(b.spent || 0);
const limit = parseFloat(b.amount);
const pct = limit > 0 ? Math.min(spent / limit, 1) : 0;
const over = spent > limit;
return (
<View key={b.id} style={s.card}>
<View style={{ flexDirection: "row", justifyContent: "space-between", marginBottom: 6 }}>
<Text style={{ fontWeight: "600", color: "#1e293b" }}>{b.name}</Text>
<Text style={{ color: over ? C.danger : C.muted, fontSize: 13 }}>{fmt(spent)} / {fmt(limit)}</Text>
</View>
<View style={s.progressBg}>
<View style={[s.progressFill, { width: `${pct * 100}%`, backgroundColor: over ? C.danger : C.primary }]} />
</View>
{over && <Text style={{ color: C.danger, fontSize: 12, marginTop: 4 }}>Перерасход: {fmt(spent - limit)}</Text>}
</View>
);
})}
</>
)}
{/* Recent operations */}
<Text style={s.sectionTitle}>Последние операции</Text>
{operations.slice(0, 10).map((o: any) => {
const isIncome = o.operation_type?.name === "income";
const isExpense = o.operation_type?.name === "expense";
return (
<View key={o.id} style={s.listItem}>
<Text style={{ fontSize: 20, marginRight: 12 }}>{isIncome ? "📈" : isExpense ? "📉" : "🔄"}</Text>
<View style={{ flex: 1 }}>
<Text style={s.itemTitle}>{o.category?.name || o.description || "—"}</Text>
<Text style={s.itemSub}>{o.date ? format(new Date(o.date), "dd.MM.yy HH:mm") : ""}</Text>
</View>
<Text style={[s.itemAmount, { color: isIncome ? C.success : isExpense ? C.danger : C.muted }]}>
{isIncome ? "+" : isExpense ? "−" : ""}{fmt(parseFloat(o.amount))} {o.currency?.symbol}
</Text>
</View>
);
})}
</ScrollView>
</SafeAreaView>
);
}
const s = StyleSheet.create({
header: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", padding: 16, backgroundColor: "#1e2a3a" },
headerTitle: { color: "#fff", fontSize: 20, fontWeight: "800" },
headerSub: { color: "#94a3b8", fontSize: 13, marginTop: 2 },
logoutBtn: { paddingVertical: 6, paddingHorizontal: 14, borderRadius: 8, borderWidth: 1, borderColor: "rgba(255,255,255,0.3)" },
logoutText: { color: "#cbd5e1", fontSize: 13, fontWeight: "600" },
kpiRow: { flexDirection: "row", marginBottom: 10 },
kpiCard: { backgroundColor: "#fff", borderRadius: 12, padding: 16, shadowColor: "#000", shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.06, shadowRadius: 4, elevation: 2 },
kpiLabel: { fontSize: 11, fontWeight: "600", color: "#64748b", textTransform: "uppercase", letterSpacing: 0.5, marginBottom: 4 },
kpiValue: { fontSize: 22, fontWeight: "800", color: "#1e293b" },
kpiSub: { fontSize: 12, color: "#64748b", marginTop: 2 },
sectionTitle: { fontSize: 15, fontWeight: "700", color: "#1e293b", marginTop: 20, marginBottom: 10 },
empty: { color: "#64748b", textAlign: "center", padding: 20 },
listItem: { flexDirection: "row", alignItems: "center", backgroundColor: "#fff", borderRadius: 10, padding: 14, marginBottom: 8, shadowColor: "#000", shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.04, shadowRadius: 3, elevation: 1 },
colorDot: { width: 10, height: 10, borderRadius: 5, marginRight: 12 },
itemTitle: { fontSize: 14, fontWeight: "600", color: "#1e293b" },
itemSub: { fontSize: 12, color: "#64748b", marginTop: 2 },
itemAmount: { fontWeight: "700", fontSize: 14 },
card: { backgroundColor: "#fff", borderRadius: 12, padding: 14, marginBottom: 10, shadowColor: "#000", shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.06, shadowRadius: 4, elevation: 2 },
progressBg: { height: 8, backgroundColor: "#e2e8f0", borderRadius: 4 },
progressFill: { height: 8, borderRadius: 4 },
});

View File

@@ -0,0 +1,54 @@
import React, { useState } from "react";
import { View, Text, TextInput, TouchableOpacity, StyleSheet, Alert, KeyboardAvoidingView, Platform, ActivityIndicator } from "react-native";
import { useAuth } from "../contexts/AuthContext";
export default function LoginScreen() {
const { login } = useAuth();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const handle = async () => {
if (!username || !password) return;
setLoading(true);
try {
await login(username, password);
} catch (err: any) {
Alert.alert("Ошибка", err.response?.data?.detail || "Неверный логин или пароль");
} finally {
setLoading(false);
}
};
return (
<KeyboardAvoidingView style={s.container} behavior={Platform.OS === "ios" ? "padding" : undefined}>
<View style={s.card}>
<Text style={s.logo}>Finance<Text style={s.accent}>App</Text></Text>
<Text style={s.subtitle}>Управление личными финансами</Text>
<Text style={s.label}>Логин или Email</Text>
<TextInput style={s.input} value={username} onChangeText={setUsername} autoCapitalize="none" autoCorrect={false} returnKeyType="next" />
<Text style={s.label}>Пароль</Text>
<TextInput style={s.input} value={password} onChangeText={setPassword} secureTextEntry returnKeyType="done" onSubmitEditing={handle} />
<TouchableOpacity style={[s.btn, loading && s.btnDisabled]} onPress={handle} disabled={loading}>
{loading ? <ActivityIndicator color="#fff" /> : <Text style={s.btnText}>Войти</Text>}
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
);
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: "#f4f6fa", alignItems: "center", justifyContent: "center", padding: 20 },
card: { width: "100%", maxWidth: 400, backgroundColor: "#fff", borderRadius: 16, padding: 32, shadowColor: "#000", shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.08, shadowRadius: 8, elevation: 4 },
logo: { fontSize: 28, fontWeight: "800", textAlign: "center", color: "#1e293b", marginBottom: 4 },
accent: { color: "#3b82f6" },
subtitle: { textAlign: "center", color: "#64748b", marginBottom: 28, fontSize: 14 },
label: { fontSize: 14, fontWeight: "600", color: "#1e293b", marginBottom: 6 },
input: { borderWidth: 1, borderColor: "#e2e8f0", borderRadius: 8, padding: 12, fontSize: 15, marginBottom: 16, color: "#1e293b" },
btn: { backgroundColor: "#3b82f6", borderRadius: 10, padding: 14, alignItems: "center", marginTop: 4 },
btnDisabled: { opacity: 0.6 },
btnText: { color: "#fff", fontWeight: "700", fontSize: 16 },
});

View File

@@ -0,0 +1,171 @@
import React, { useEffect, useState, useCallback } from "react";
import { View, Text, ScrollView, TouchableOpacity, StyleSheet, TextInput, Modal, Alert, RefreshControl } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { getOperations, getOperationTypes, getAccounts, getCategories, getCurrencies, createOperation, deleteOperation } from "../api/client";
import { format } from "date-fns";
const C = { primary: "#3b82f6", success: "#22c55e", danger: "#ef4444", warning: "#f59e0b", muted: "#64748b", bg: "#f4f6fa", surface: "#fff", border: "#e2e8f0" };
const TYPE_LABELS: Record<string, string> = { income: "Доход", expense: "Расход", transfer: "Перевод" };
export default function OperationsScreen() {
const [ops, setOps] = useState<any[]>([]);
const [opTypes, setOpTypes] = useState<any[]>([]);
const [accounts, setAccounts] = useState<any[]>([]);
const [categories, setCategories] = useState<any[]>([]);
const [currencies, setCurrencies] = useState<any[]>([]);
const [modal, setModal] = useState(false);
const [form, setForm] = useState({ account_id: "", operation_type_id: "", amount: "", currency_id: "", category_id: "", description: "", date: format(new Date(), "yyyy-MM-dd'T'HH:mm") });
const [refreshing, setRefreshing] = useState(false);
const load = useCallback(async () => {
const [o, ot, a, c, cu] = await Promise.all([
getOperations({ limit: 100 }), getOperationTypes(), getAccounts(), getCategories(), getCurrencies()
]);
setOps(o.data); setOpTypes(ot.data); setAccounts(a.data); setCategories(c.data); setCurrencies(cu.data.filter((c: any) => c.is_active));
}, []);
useEffect(() => { load(); }, []);
const onRefresh = async () => { setRefreshing(true); await load(); setRefreshing(false); };
const save = async () => {
if (!form.account_id || !form.operation_type_id || !form.amount || !form.currency_id) {
Alert.alert("Ошибка", "Заполните счёт, тип, сумму и валюту"); return;
}
try {
await createOperation({ account_id: parseInt(form.account_id), operation_type_id: parseInt(form.operation_type_id), amount: parseFloat(form.amount), currency_id: parseInt(form.currency_id), category_id: form.category_id ? parseInt(form.category_id) : null, description: form.description || null, date: form.date });
await load(); setModal(false);
} catch (e: any) { Alert.alert("Ошибка", e.response?.data?.detail || "Не удалось создать операцию"); }
};
const remove = (id: number) => {
Alert.alert("Удалить операцию?", "", [
{ text: "Отмена" },
{ text: "Удалить", style: "destructive", onPress: async () => { await deleteOperation(id); load(); } },
]);
};
const typeName = (id: number) => opTypes.find((t: any) => t.id === id)?.name || "";
const fmt = (v: number) => v.toLocaleString("ru-RU", { minimumFractionDigits: 2 });
return (
<SafeAreaView style={{ flex: 1, backgroundColor: C.bg }}>
<View style={s.header}>
<Text style={s.title}>Операции</Text>
<TouchableOpacity style={s.addBtn} onPress={() => setModal(true)}>
<Text style={s.addBtnText}>+ Добавить</Text>
</TouchableOpacity>
</View>
<ScrollView contentContainerStyle={{ padding: 16 }} refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
{ops.length === 0
? <Text style={s.empty}>Операций нет</Text>
: ops.map((o: any) => {
const tn = typeName(o.operation_type_id);
const color = tn === "income" ? C.success : tn === "expense" ? C.danger : C.warning;
const sign = tn === "income" ? "+" : tn === "expense" ? "−" : "↔";
return (
<View key={o.id} style={s.card}>
<View style={{ flexDirection: "row", alignItems: "center" }}>
<Text style={{ fontSize: 24, marginRight: 12 }}>{tn === "income" ? "📈" : tn === "expense" ? "📉" : "🔄"}</Text>
<View style={{ flex: 1 }}>
<Text style={s.cardTitle}>{o.category?.name || o.description || TYPE_LABELS[tn] || "—"}</Text>
<Text style={s.cardSub}>{o.date ? format(new Date(o.date), "dd.MM.yy HH:mm") : "—"} · {accounts.find((a: any) => a.id === o.account_id)?.name}</Text>
{o.description && o.category?.name && <Text style={[s.cardSub, { fontStyle: "italic" }]}>{o.description}</Text>}
</View>
<View style={{ alignItems: "flex-end" }}>
<Text style={[s.amount, { color }]}>{sign}{fmt(parseFloat(o.amount))} {o.currency?.symbol}</Text>
<Text style={[s.cardSub, { color: o.is_planned ? C.warning : C.success }]}>{o.is_planned ? "Запланировано" : "Выполнено"}</Text>
</View>
</View>
<TouchableOpacity onPress={() => remove(o.id)} style={s.deleteRow}>
<Text style={{ color: C.danger, fontSize: 12 }}>🗑️ Удалить</Text>
</TouchableOpacity>
</View>
);
})
}
</ScrollView>
<Modal visible={modal} animationType="slide" presentationStyle="pageSheet">
<SafeAreaView style={{ flex: 1, backgroundColor: C.bg }}>
<View style={s.modalHeader}>
<Text style={s.modalTitle}>Новая операция</Text>
<TouchableOpacity onPress={() => setModal(false)}><Text style={{ color: C.danger, fontSize: 16 }}>✕</Text></TouchableOpacity>
</View>
<ScrollView contentContainerStyle={{ padding: 20 }}>
<Text style={s.label}>Тип операции *</Text>
{opTypes.filter((t: any) => t.name !== "transfer").map((t: any) => (
<TouchableOpacity key={t.id} onPress={() => setForm({ ...form, operation_type_id: String(t.id) })}
style={[s.radioItem, form.operation_type_id === String(t.id) && s.radioSelected]}>
<Text style={form.operation_type_id === String(t.id) ? s.radioTextSel : s.radioText}>{TYPE_LABELS[t.name] || t.name}</Text>
</TouchableOpacity>
))}
<Text style={[s.label, { marginTop: 12 }]}>Счёт *</Text>
{accounts.map((a: any) => (
<TouchableOpacity key={a.id} onPress={() => setForm({ ...form, account_id: String(a.id) })}
style={[s.radioItem, form.account_id === String(a.id) && s.radioSelected]}>
<Text style={form.account_id === String(a.id) ? s.radioTextSel : s.radioText}>{a.name} ({a.currency?.code})</Text>
</TouchableOpacity>
))}
<Text style={[s.label, { marginTop: 12 }]}>Сумма *</Text>
<TextInput style={s.input} value={form.amount} onChangeText={v => setForm({ ...form, amount: v })} keyboardType="decimal-pad" placeholder="0.00" />
<Text style={s.label}>Валюта *</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={{ marginBottom: 14 }}>
{currencies.map((c: any) => (
<TouchableOpacity key={c.id} onPress={() => setForm({ ...form, currency_id: String(c.id) })}
style={[s.chip, form.currency_id === String(c.id) && s.chipSel]}>
<Text style={form.currency_id === String(c.id) ? { color: "#fff", fontWeight: "700" } : { color: C.muted }}>{c.code}</Text>
</TouchableOpacity>
))}
</ScrollView>
<Text style={s.label}>Категория</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={{ marginBottom: 14 }}>
{[{ id: "", name: "Без категории" }, ...categories].map((c: any) => (
<TouchableOpacity key={c.id} onPress={() => setForm({ ...form, category_id: String(c.id) })}
style={[s.chip, form.category_id === String(c.id) && s.chipSel]}>
<Text style={form.category_id === String(c.id) ? { color: "#fff", fontSize: 12 } : { color: C.muted, fontSize: 12 }}>{c.name}</Text>
</TouchableOpacity>
))}
</ScrollView>
<Text style={s.label}>Описание</Text>
<TextInput style={s.input} value={form.description} onChangeText={v => setForm({ ...form, description: v })} placeholder="Необязательно" />
<TouchableOpacity style={s.saveBtn} onPress={save}>
<Text style={s.saveBtnText}>Сохранить</Text>
</TouchableOpacity>
</ScrollView>
</SafeAreaView>
</Modal>
</SafeAreaView>
);
}
const s = StyleSheet.create({
header: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", padding: 16, backgroundColor: "#1e2a3a" },
title: { color: "#fff", fontSize: 20, fontWeight: "800" },
addBtn: { backgroundColor: "#3b82f6", paddingVertical: 7, paddingHorizontal: 14, borderRadius: 8 },
addBtnText: { color: "#fff", fontWeight: "700", fontSize: 13 },
empty: { textAlign: "center", color: "#64748b", padding: 40, fontSize: 15 },
card: { backgroundColor: "#fff", borderRadius: 12, padding: 14, marginBottom: 10, shadowColor: "#000", shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.06, shadowRadius: 4, elevation: 2 },
cardTitle: { fontSize: 14, fontWeight: "700", color: "#1e293b" },
cardSub: { fontSize: 12, color: "#64748b", marginTop: 2 },
amount: { fontSize: 15, fontWeight: "800" },
deleteRow: { flexDirection: "row", justifyContent: "flex-end", paddingTop: 8, marginTop: 8, borderTopWidth: 1, borderTopColor: "#e2e8f0" },
modalHeader: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", padding: 20, borderBottomWidth: 1, borderBottomColor: "#e2e8f0" },
modalTitle: { fontSize: 18, fontWeight: "800", color: "#1e293b" },
label: { fontSize: 13, fontWeight: "600", color: "#1e293b", marginBottom: 6 },
input: { borderWidth: 1, borderColor: "#e2e8f0", borderRadius: 8, padding: 12, fontSize: 15, marginBottom: 14, backgroundColor: "#fff", color: "#1e293b" },
radioItem: { padding: 12, borderRadius: 8, borderWidth: 1, borderColor: "#e2e8f0", marginBottom: 6, backgroundColor: "#fff" },
radioSelected: { borderColor: "#3b82f6", backgroundColor: "#dbeafe" },
radioText: { color: "#1e293b" },
radioTextSel: { color: "#3b82f6", fontWeight: "700" },
chip: { paddingVertical: 7, paddingHorizontal: 14, borderRadius: 20, borderWidth: 1, borderColor: "#e2e8f0", marginRight: 8, backgroundColor: "#fff" },
chipSel: { backgroundColor: "#3b82f6", borderColor: "#3b82f6" },
saveBtn: { backgroundColor: "#3b82f6", borderRadius: 12, padding: 16, alignItems: "center", marginTop: 24 },
saveBtnText: { color: "#fff", fontWeight: "800", fontSize: 16 },
});