Обновление приложения
This commit is contained in:
16
android/App.tsx
Normal file
16
android/App.tsx
Normal 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
25
android/app.json
Normal 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
31
android/package.json
Normal 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
83
android/src/api/client.ts
Normal 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;
|
||||
50
android/src/contexts/AuthContext.tsx
Normal file
50
android/src/contexts/AuthContext.tsx
Normal 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)!;
|
||||
70
android/src/navigation/AppNavigator.tsx
Normal file
70
android/src/navigation/AppNavigator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
150
android/src/screens/AccountsScreen.tsx
Normal file
150
android/src/screens/AccountsScreen.tsx
Normal 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";
|
||||
94
android/src/screens/BudgetsScreen.tsx
Normal file
94
android/src/screens/BudgetsScreen.tsx
Normal 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 },
|
||||
});
|
||||
118
android/src/screens/CalendarScreen.tsx
Normal file
118
android/src/screens/CalendarScreen.tsx
Normal 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" },
|
||||
});
|
||||
155
android/src/screens/DashboardScreen.tsx
Normal file
155
android/src/screens/DashboardScreen.tsx
Normal 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 },
|
||||
});
|
||||
54
android/src/screens/LoginScreen.tsx
Normal file
54
android/src/screens/LoginScreen.tsx
Normal 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 },
|
||||
});
|
||||
171
android/src/screens/OperationsScreen.tsx
Normal file
171
android/src/screens/OperationsScreen.tsx
Normal 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 },
|
||||
});
|
||||
Reference in New Issue
Block a user