From 2f4e8af2a02967e2a518e1d68c2b6c702eae972d Mon Sep 17 00:00:00 2001 From: arrelin Date: Thu, 12 Feb 2026 18:43:36 +0300 Subject: [PATCH] try to do better --- backend/src/lib.rs | 4 + .../m20260212_000001_add_expense_active.rs | 40 ++++ backend/src/migration/mod.rs | 2 + backend/src/models/expense.rs | 1 + backend/src/routes/expense.rs | 79 ++++++- backend/src/services/expense_service.rs | 97 ++++++++- backend/src/services/oauth_service.rs | 6 +- frontend/src/api/client.ts | 6 + frontend/src/i18n/locales/en.json | 21 +- frontend/src/i18n/locales/ru.json | 21 +- frontend/src/pages/FamilyView.tsx | 202 ++++++++++++++---- frontend/src/types/index.ts | 12 ++ 12 files changed, 439 insertions(+), 52 deletions(-) create mode 100644 backend/src/migration/m20260212_000001_add_expense_active.rs diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 0f685f1..970be80 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -50,6 +50,7 @@ pub use middleware::{require_admin, require_family_access}; routes::expense::update_expense, routes::expense::delete_expense, routes::expense::get_remaining_limit, + routes::expense::get_history, routes::shopping_item::create_shopping_item, routes::shopping_item::get_shopping_items_by_family, routes::shopping_item::get_shopping_item, @@ -87,6 +88,8 @@ pub use middleware::{require_admin, require_family_access}; routes::expense::CreateExpenseRequest, routes::expense::UpdateExpenseRequest, routes::expense::RemainingLimitResponse, + routes::expense::ExpenseHistoryResponse, + routes::expense::MonthlyExpenseGroup, routes::shopping_item::CreateShoppingItemRequest, routes::shopping_item::UpdateShoppingItemRequest, routes::shopping_item::MarkAsPurchasedRequest, @@ -187,6 +190,7 @@ pub async fn create_app(db: DatabaseConnection) -> Result { .route("/families/{family_id}/categories/{category_id}", delete(routes::category::delete_category)) .route("/families/{family_id}/categories/{category_id}/expenses", post(routes::expense::create_expense)) .route("/families/{family_id}/categories/{category_id}/expenses", get(routes::expense::get_expenses_by_category)) + .route("/families/{family_id}/categories/{category_id}/expenses/history", get(routes::expense::get_history)) .route("/families/{family_id}/categories/{category_id}/expenses/{expense_id}", get(routes::expense::get_expense)) .route("/families/{family_id}/categories/{category_id}/expenses/{expense_id}", put(routes::expense::update_expense)) .route("/families/{family_id}/categories/{category_id}/expenses/{expense_id}", delete(routes::expense::delete_expense)) diff --git a/backend/src/migration/m20260212_000001_add_expense_active.rs b/backend/src/migration/m20260212_000001_add_expense_active.rs new file mode 100644 index 0000000..3a3bd67 --- /dev/null +++ b/backend/src/migration/m20260212_000001_add_expense_active.rs @@ -0,0 +1,40 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Expense::Table) + .add_column( + ColumnDef::new(Expense::Active) + .boolean() + .not_null() + .default(true) + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Expense::Table) + .drop_column(Expense::Active) + .to_owned(), + ) + .await + } +} + +#[derive(DeriveIden)] +enum Expense { + Table, + Active, +} diff --git a/backend/src/migration/mod.rs b/backend/src/migration/mod.rs index 1bc503f..ea067ae 100644 --- a/backend/src/migration/mod.rs +++ b/backend/src/migration/mod.rs @@ -7,6 +7,7 @@ mod m20241215_000001_add_family_password; mod m20241224_000001_create_shopping_items; mod m20250116_000001_add_oauth_fields; mod m20250117_000001_create_invite_links; +mod m20260212_000001_add_expense_active; pub struct Migrator; @@ -21,6 +22,7 @@ impl MigratorTrait for Migrator { Box::new(m20241224_000001_create_shopping_items::Migration), Box::new(m20250116_000001_add_oauth_fields::Migration), Box::new(m20250117_000001_create_invite_links::Migration), + Box::new(m20260212_000001_add_expense_active::Migration), ] } } diff --git a/backend/src/models/expense.rs b/backend/src/models/expense.rs index cb62f82..2fe357d 100644 --- a/backend/src/models/expense.rs +++ b/backend/src/models/expense.rs @@ -11,6 +11,7 @@ pub struct Model { pub amount: Decimal, pub description: Option, pub created_at: DateTime, + pub active: bool, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/backend/src/routes/expense.rs b/backend/src/routes/expense.rs index 7f6e11f..a990d87 100644 --- a/backend/src/routes/expense.rs +++ b/backend/src/routes/expense.rs @@ -1,12 +1,11 @@ use axum::{ - extract::{Path, State}, + extract::{Path, Query, State}, http::StatusCode, Json, }; use sea_orm::{prelude::Decimal, DatabaseConnection}; use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; - +use utoipa::{IntoParams, ToSchema}; use crate::models::expense::Model as ExpenseModel; use crate::services::{CategoryService, ExpenseService}; @@ -31,6 +30,27 @@ pub struct RemainingLimitResponse { pub remaining_limit: Decimal, } +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct MonthlyExpenseGroup { + pub year: i32, + pub month: u32, + pub total_amount: Decimal, + pub expenses: Vec, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct ExpenseHistoryResponse { + pub months: Vec, +} + +#[derive(Debug, Deserialize, IntoParams)] +pub struct HistoryQueryParams { + #[serde(default)] + pub sort_order: Option, + #[serde(default)] + pub show_archive: Option, +} + #[utoipa::path( post, path = "/families/{family_id}/categories/{category_id}/expenses", @@ -183,6 +203,53 @@ pub async fn update_expense( .map(Json) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) } +#[utoipa::path( + get, + path = "/families/{family_id}/categories/{category_id}/expenses/history", + tag = "expenses", + params( + ("family_id" = i32, Path, description = "Family ID"), + ("category_id" = i32, Path, description = "Category ID"), + HistoryQueryParams + ), + responses( + (status = 200, description = "Expense history grouped by month", body = ExpenseHistoryResponse), + (status = 404, description = "Category not found"), + (status = 500, description = "Internal server error") + ) +)] +pub async fn get_history( + State(db): State, + Path((family_id, category_id)): Path<(i32, i32)>, + Query(params): Query, +) -> Result, StatusCode> { + let category = CategoryService::find_by_id(&db, category_id) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + + if category.family_id != family_id { + return Err(StatusCode::NOT_FOUND); + } + + let groups = ExpenseService::get_expense_history( + &db, + category_id, + params.sort_order, + params.show_archive.unwrap_or(false) + ) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let months = groups.into_iter().map(|g| MonthlyExpenseGroup { + year: g.year, + month: g.month, + total_amount: g.total_amount, + expenses: g.expenses, + }).collect(); + + Ok(Json(ExpenseHistoryResponse { months })) +} #[utoipa::path( delete, @@ -194,7 +261,7 @@ pub async fn update_expense( ("expense_id" = i32, Path, description = "Expense ID") ), responses( - (status = 204, description = "Expense deleted successfully"), + (status = 200, description = "Expense deactivated successfully", body = ExpenseModel), (status = 404, description = "Expense not found"), (status = 500, description = "Internal server error") ) @@ -202,7 +269,7 @@ pub async fn update_expense( pub async fn delete_expense( State(db): State, Path((family_id, category_id, expense_id)): Path<(i32, i32, i32)>, -) -> Result { +) -> Result, StatusCode> { let category = CategoryService::find_by_id(&db, category_id) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? @@ -223,7 +290,7 @@ pub async fn delete_expense( ExpenseService::delete(&db, expense_id) .await - .map(|_| StatusCode::NO_CONTENT) + .map(Json) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) } diff --git a/backend/src/services/expense_service.rs b/backend/src/services/expense_service.rs index 25b006c..360887e 100644 --- a/backend/src/services/expense_service.rs +++ b/backend/src/services/expense_service.rs @@ -1,9 +1,18 @@ use sea_orm::*; use sea_orm::prelude::Decimal; -use chrono::Utc; +use chrono::{Utc, Datelike}; +use std::collections::HashMap; use crate::models::expense::{self, Entity as Expense, Model as ExpenseModel}; use crate::models::category::{Entity as Category}; +#[derive(Debug, Clone)] +pub struct MonthlyExpenseGroup { + pub year: i32, + pub month: u32, + pub total_amount: Decimal, + pub expenses: Vec, +} + pub struct ExpenseService; impl ExpenseService { @@ -18,6 +27,7 @@ impl ExpenseService { amount: Set(amount), description: Set(description), created_at: Set(Utc::now().naive_utc()), + active: Set(true), ..Default::default() }; @@ -38,6 +48,28 @@ impl ExpenseService { ) -> Result, DbErr> { Expense::find() .filter(expense::Column::CategoryId.eq(category_id)) + .filter(expense::Column::Active.eq(true)) + .all(db) + .await + } + + pub async fn find_all_by_category_id( + db: &DatabaseConnection, + category_id: i32, + ) -> Result, DbErr> { + Expense::find() + .filter(expense::Column::CategoryId.eq(category_id)) + .all(db) + .await + } + + pub async fn find_inactive_by_category_id( + db: &DatabaseConnection, + category_id: i32, + ) -> Result, DbErr> { + Expense::find() + .filter(expense::Column::CategoryId.eq(category_id)) + .filter(expense::Column::Active.eq(false)) .all(db) .await } @@ -66,14 +98,15 @@ impl ExpenseService { expense.update(db).await } - pub async fn delete(db: &DatabaseConnection, id: i32) -> Result { + pub async fn delete(db: &DatabaseConnection, id: i32) -> Result { let expense = Expense::find_by_id(id) .one(db) .await? .ok_or(DbErr::RecordNotFound("Expense not found".to_string()))?; - let expense: expense::ActiveModel = expense.into(); - expense.delete(db).await + let mut expense: expense::ActiveModel = expense.into(); + expense.active = Set(false); + expense.update(db).await } pub async fn calculate_remaining_limit( @@ -92,4 +125,60 @@ impl ExpenseService { Ok(remaining) } + + pub async fn get_expense_history( + db: &DatabaseConnection, + category_id: i32, + sort_order: Option, + show_archive: bool, + ) -> Result, DbErr> { + let expenses = if show_archive { + Self::find_inactive_by_category_id(db, category_id).await? + } else { + Self::find_by_category_id(db, category_id).await? + }; + + let mut grouped: HashMap<(i32, u32), Vec> = HashMap::new(); + + for expense in expenses { + let year = expense.created_at.year(); + let month = expense.created_at.month(); + grouped.entry((year, month)) + .or_insert_with(Vec::new) + .push(expense); + } + + let mut result: Vec = grouped + .into_iter() + .map(|((year, month), expenses)| { + let total_amount: Decimal = expenses + .iter() + .filter(|e| e.active) + .map(|e| e.amount) + .sum(); + MonthlyExpenseGroup { + year, + month, + total_amount, + expenses, + } + }) + .collect(); + + let sort_desc = sort_order + .as_deref() + .map(|s| s.to_lowercase() == "desc") + .unwrap_or(true); + + result.sort_by(|a, b| { + let cmp = a.year.cmp(&b.year).then(a.month.cmp(&b.month)); + if sort_desc { + cmp.reverse() + } else { + cmp + } + }); + + Ok(result) + } } diff --git a/backend/src/services/oauth_service.rs b/backend/src/services/oauth_service.rs index 2dc7dba..2d807e5 100644 --- a/backend/src/services/oauth_service.rs +++ b/backend/src/services/oauth_service.rs @@ -32,7 +32,7 @@ impl OAuthService { let redirect_url = std::env::var("GOOGLE_REDIRECT_URL") .unwrap_or_else(|_| "http://localhost:8080/api/auth/google/callback".to_string()); - let client = Self::getClient(client_id, client_secret, redirect_url); + let client = Self::get_client(client_id, client_secret, redirect_url); let (auth_url, csrf_token) = client .authorize_url(CsrfToken::new_random) @@ -52,7 +52,7 @@ impl OAuthService { let redirect_url = std::env::var("GOOGLE_REDIRECT_URL") .unwrap_or_else(|_| "http://localhost:8080/api/auth/google/callback".to_string()); - let client = Self::getClient(client_id, client_secret, redirect_url); + let client = Self::get_client(client_id, client_secret, redirect_url); let http_client = oauth2::reqwest::ClientBuilder::new() .build() @@ -67,7 +67,7 @@ impl OAuthService { Ok(token.access_token().secret().clone()) } - fn getClient(client_id: String, client_secret: String, redirect_url: String) -> Client Client apiClient.get(`/families/${familyId}/categories/${categoryId}/remaining`), + + getHistory: (familyId: number, categoryId: number, showArchive: boolean = false, sortOrder: string = 'desc') => + apiClient.get(`/families/${familyId}/categories/${categoryId}/expenses/history`, { + params: { show_archive: showArchive, sort_order: sortOrder }, + }), }; export const shoppingItemApi = { diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 8284513..f002600 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -55,6 +55,7 @@ "expense": "Expense", "reset": "Reset", "history": "History", + "archive": "Archive", "management": "Category management", "newCategory": "New category", "categoryName": "Category name", @@ -73,9 +74,13 @@ "description": "Description", "descriptionPlaceholder": "Optional", "historyTitle": "Expense history", + "archiveTitle": "Expense archive", "noExpenses": "No expenses", + "noArchive": "Archive is empty", + "archived": "Archived", "addError": "Error adding expense", - "historyError": "Error loading expense history" + "historyError": "Error loading expense history", + "archiveError": "Error loading archive" }, "invite": { "title": "Invite member", @@ -168,5 +173,19 @@ "forest": "Forest", "purple": "Purple" } + }, + "months": { + "1": "January", + "2": "February", + "3": "March", + "4": "April", + "5": "May", + "6": "June", + "7": "July", + "8": "August", + "9": "September", + "10": "October", + "11": "November", + "12": "December" } } diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index bbc31ea..b9ab84a 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -55,6 +55,7 @@ "expense": "Расход", "reset": "Обнулить", "history": "История", + "archive": "Архив", "management": "Управление категориями", "newCategory": "Новая категория", "categoryName": "Название категории", @@ -73,9 +74,13 @@ "description": "Описание", "descriptionPlaceholder": "Опционально", "historyTitle": "История трат", + "archiveTitle": "Архив трат", "noExpenses": "Нет трат", + "noArchive": "Архив пуст", + "archived": "Архив", "addError": "Ошибка добавления расхода", - "historyError": "Ошибка загрузки истории трат" + "historyError": "Ошибка загрузки истории трат", + "archiveError": "Ошибка загрузки архива" }, "invite": { "title": "Пригласить участника", @@ -168,5 +173,19 @@ "forest": "Лес", "purple": "Фиолетовая" } + }, + "months": { + "1": "Январь", + "2": "Февраль", + "3": "Март", + "4": "Апрель", + "5": "Май", + "6": "Июнь", + "7": "Июль", + "8": "Август", + "9": "Сентябрь", + "10": "Октябрь", + "11": "Ноябрь", + "12": "Декабрь" } } diff --git a/frontend/src/pages/FamilyView.tsx b/frontend/src/pages/FamilyView.tsx index 3f366bf..d0ee9fe 100644 --- a/frontend/src/pages/FamilyView.tsx +++ b/frontend/src/pages/FamilyView.tsx @@ -3,13 +3,14 @@ import { useParams, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { categoryApi, expenseApi, inviteLinkApi } from '../api/client'; import { useStore } from '../store/useStore'; -import type { Category, Expense, InviteLinkResponse } from '../types'; +import type { Category, Expense, InviteLinkResponse, ExpenseHistoryResponse } from '../types'; import { Wallet, TrendingDown, Plus, Trash2, RotateCcw, + Archive, Loader2, X, DollarSign, @@ -45,7 +46,9 @@ export default function FamilyView() { const [expenseDescription, setExpenseDescription] = useState(''); const [showHistory, setShowHistory] = useState(null); - const [categoryExpenses, setCategoryExpenses] = useState([]); + const [showArchive, setShowArchive] = useState(null); + const [historyData, setHistoryData] = useState(null); + const [archiveData, setArchiveData] = useState(null); const [showShoppingList, setShowShoppingList] = useState(false); const [showInviteModal, setShowInviteModal] = useState(false); const [inviteLink, setInviteLink] = useState(null); @@ -176,12 +179,15 @@ export default function FamilyView() { return; } + setShowArchive(null); + try { - const response = await expenseApi.getAllByCategory( + const response = await expenseApi.getHistory( parseInt(familyId), - categoryId + categoryId, + false ); - setCategoryExpenses(response.data); + setHistoryData(response.data); setShowHistory(categoryId); } catch (err) { alert(t('expense.historyError')); @@ -189,6 +195,30 @@ export default function FamilyView() { } }; + const handleShowArchive = async (categoryId: number) => { + if (!familyId) return; + + if (showArchive === categoryId) { + setShowArchive(null); + return; + } + + setShowHistory(null); + + try { + const response = await expenseApi.getHistory( + parseInt(familyId), + categoryId, + true + ); + setArchiveData(response.data); + setShowArchive(categoryId); + } catch (err) { + alert(t('expense.archiveError')); + console.error(err); + } + }; + const handleCreateInviteLink = async () => { try { setInviteLoading(true); @@ -264,6 +294,15 @@ export default function FamilyView() { }); }; + const getMonthName = (month: number) => { + const months = [ + t('months.1'), t('months.2'), t('months.3'), t('months.4'), + t('months.5'), t('months.6'), t('months.7'), t('months.8'), + t('months.9'), t('months.10'), t('months.11'), t('months.12') + ]; + return months[month - 1] || month; + }; + return (
@@ -382,32 +421,39 @@ export default function FamilyView() {

-
+
+
- {showHistory === category.id && ( -
+ {showHistory === category.id && historyData && ( +

@@ -421,35 +467,117 @@ export default function FamilyView() {

- {categoryExpenses.length === 0 ? ( + {historyData.months.length === 0 ? (

{t('expense.noExpenses')}

) : ( -
- {categoryExpenses.map((expense) => ( -
-
-
-
- -
- - {parseFloat(expense.amount.toString()).toFixed(2)} ₽ - -
-
- - {formatDate(expense.created_at)} -
+
+ {historyData.months.map((monthGroup) => ( +
+
+

+ {getMonthName(monthGroup.month)} {monthGroup.year} +

+ + {parseFloat(monthGroup.total_amount.toString()).toFixed(2)} ₽ + +
+
+ {monthGroup.expenses.map((expense) => ( +
+
+
+
+ +
+ + {parseFloat(expense.amount.toString()).toFixed(2)} ₽ + +
+
+ + {formatDate(expense.created_at)} +
+
+ {expense.description && ( +
+ + {expense.description} +
+ )} +
+ ))} +
+
+ ))} +
+ )} +
+ )} + + {showArchive === category.id && archiveData && ( +
+
+

+ + {t('expense.archiveTitle')} +

+ +
+ + {archiveData.months.length === 0 ? ( +

{t('expense.noArchive')}

+ ) : ( +
+ {archiveData.months.map((monthGroup) => ( +
+
+

+ {getMonthName(monthGroup.month)} {monthGroup.year} +

+ + {parseFloat(monthGroup.total_amount.toString()).toFixed(2)} ₽ + +
+
+ {monthGroup.expenses.map((expense) => ( +
+
+
+
+ +
+ + {parseFloat(expense.amount.toString()).toFixed(2)} ₽ + + + {t('expense.archived')} + +
+
+ + {formatDate(expense.created_at)} +
+
+ {expense.description && ( +
+ + {expense.description} +
+ )} +
+ ))}
- {expense.description && ( -
- - {expense.description} -
- )}
))}
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 6d0c318..e15f614 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -29,6 +29,18 @@ export interface Expense { amount: number; description?: string; created_at: string; + active: boolean; +} + +export interface MonthlyExpenseGroup { + year: number; + month: number; + total_amount: number | string; + expenses: Expense[]; +} + +export interface ExpenseHistoryResponse { + months: MonthlyExpenseGroup[]; } export interface RemainingLimit {