init feature
This commit is contained in:
@@ -45,12 +45,21 @@ pub use middleware::{require_admin, require_family_access};
|
||||
routes::expense::update_expense,
|
||||
routes::expense::delete_expense,
|
||||
routes::expense::get_remaining_limit,
|
||||
routes::shopping_item::create_shopping_item,
|
||||
routes::shopping_item::get_shopping_items_by_family,
|
||||
routes::shopping_item::get_shopping_item,
|
||||
routes::shopping_item::update_shopping_item,
|
||||
routes::shopping_item::delete_shopping_item,
|
||||
routes::shopping_item::mark_as_purchased,
|
||||
routes::shopping_item::mark_all_as_purchased,
|
||||
routes::shopping_item::clear_all,
|
||||
),
|
||||
components(
|
||||
schemas(
|
||||
models::family::Model,
|
||||
models::category::Model,
|
||||
models::expense::Model,
|
||||
models::shopping_item::Model,
|
||||
routes::auth::LoginRequest,
|
||||
routes::auth::LoginResponse,
|
||||
routes::family::CreateFamilyRequest,
|
||||
@@ -60,13 +69,18 @@ pub use middleware::{require_admin, require_family_access};
|
||||
routes::expense::CreateExpenseRequest,
|
||||
routes::expense::UpdateExpenseRequest,
|
||||
routes::expense::RemainingLimitResponse,
|
||||
routes::shopping_item::CreateShoppingItemRequest,
|
||||
routes::shopping_item::UpdateShoppingItemRequest,
|
||||
routes::shopping_item::MarkAsPurchasedRequest,
|
||||
routes::shopping_item::BulkOperationResponse,
|
||||
)
|
||||
),
|
||||
tags(
|
||||
(name = "auth", description = "Authentication endpoints"),
|
||||
(name = "families", description = "Family management endpoints"),
|
||||
(name = "categories", description = "Category management endpoints"),
|
||||
(name = "expenses", description = "Expense management endpoints")
|
||||
(name = "expenses", description = "Expense management endpoints"),
|
||||
(name = "shopping-items", description = "Shopping list management endpoints")
|
||||
),
|
||||
info(
|
||||
title = "Family Budget API",
|
||||
@@ -131,6 +145,14 @@ pub async fn create_app(db: DatabaseConnection) -> Result<Router, DbErr> {
|
||||
.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))
|
||||
.route("/families/:family_id/categories/:category_id/remaining", get(routes::expense::get_remaining_limit))
|
||||
.route("/families/:family_id/shopping-items", post(routes::shopping_item::create_shopping_item))
|
||||
.route("/families/:family_id/shopping-items", get(routes::shopping_item::get_shopping_items_by_family))
|
||||
.route("/families/:family_id/shopping-items/:id", get(routes::shopping_item::get_shopping_item))
|
||||
.route("/families/:family_id/shopping-items/:id", put(routes::shopping_item::update_shopping_item))
|
||||
.route("/families/:family_id/shopping-items/:id", delete(routes::shopping_item::delete_shopping_item))
|
||||
.route("/families/:family_id/shopping-items/:id/purchased", axum::routing::patch(routes::shopping_item::mark_as_purchased))
|
||||
.route("/families/:family_id/shopping-items/mark-all-purchased", post(routes::shopping_item::mark_all_as_purchased))
|
||||
.route("/families/:family_id/shopping-items/clear-all", delete(routes::shopping_item::clear_all))
|
||||
.route_layer(axum_middleware::from_fn(middleware::require_family_access))
|
||||
.layer(session_layer.clone())
|
||||
.with_state(db.clone());
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
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
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(ShoppingItem::Table)
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(ShoppingItem::Id)
|
||||
.integer()
|
||||
.not_null()
|
||||
.auto_increment()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(ShoppingItem::FamilyId).integer().not_null())
|
||||
.col(ColumnDef::new(ShoppingItem::Name).string().not_null())
|
||||
.col(
|
||||
ColumnDef::new(ShoppingItem::IsPurchased)
|
||||
.boolean()
|
||||
.not_null()
|
||||
.default(false),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(ShoppingItem::CreatedAt)
|
||||
.timestamp()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_shopping_item_family")
|
||||
.from(ShoppingItem::Table, ShoppingItem::FamilyId)
|
||||
.to(Family::Table, Family::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade)
|
||||
.on_update(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_table(Table::drop().table(ShoppingItem::Table).to_owned())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum ShoppingItem {
|
||||
Table,
|
||||
Id,
|
||||
FamilyId,
|
||||
Name,
|
||||
IsPurchased,
|
||||
CreatedAt,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Family {
|
||||
Table,
|
||||
Id,
|
||||
}
|
||||
@@ -4,6 +4,7 @@ mod m20241209_000001_create_tables;
|
||||
mod m20241209_000002_create_users;
|
||||
mod m20241209_000003_seed_admin;
|
||||
mod m20241215_000001_add_family_password;
|
||||
mod m20241224_000001_create_shopping_items;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
@@ -15,6 +16,7 @@ impl MigratorTrait for Migrator {
|
||||
Box::new(m20241209_000002_create_users::Migration),
|
||||
Box::new(m20241209_000003_seed_admin::Migration),
|
||||
Box::new(m20241215_000001_add_family_password::Migration),
|
||||
Box::new(m20241224_000001_create_shopping_items::Migration),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ pub struct Model {
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_many = "super::category::Entity")]
|
||||
Category,
|
||||
#[sea_orm(has_many = "super::shopping_item::Entity")]
|
||||
ShoppingItem,
|
||||
}
|
||||
|
||||
impl Related<super::category::Entity> for Entity {
|
||||
@@ -24,4 +26,10 @@ impl Related<super::category::Entity> for Entity {
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::shopping_item::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::ShoppingItem.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
|
||||
@@ -2,8 +2,10 @@ pub mod family;
|
||||
pub mod category;
|
||||
pub mod expense;
|
||||
pub mod user;
|
||||
pub mod shopping_item;
|
||||
|
||||
pub use family::Entity as Family;
|
||||
pub use category::Entity as Category;
|
||||
pub use expense::Entity as Expense;
|
||||
pub use user::Entity as User;
|
||||
pub use shopping_item::Entity as ShoppingItem;
|
||||
|
||||
34
backend/src/models/shopping_item.rs
Normal file
34
backend/src/models/shopping_item.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize, ToSchema)]
|
||||
#[sea_orm(table_name = "shopping_item")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub family_id: i32,
|
||||
pub name: String,
|
||||
pub is_purchased: bool,
|
||||
pub created_at: DateTime,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::family::Entity",
|
||||
from = "Column::FamilyId",
|
||||
to = "super::family::Column::Id",
|
||||
on_update = "Cascade",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
Family,
|
||||
}
|
||||
|
||||
impl Related<super::family::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Family.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -2,3 +2,4 @@ pub mod family;
|
||||
pub mod category;
|
||||
pub mod expense;
|
||||
pub mod auth;
|
||||
pub mod shopping_item;
|
||||
|
||||
225
backend/src/routes/shopping_item.rs
Normal file
225
backend/src/routes/shopping_item.rs
Normal file
@@ -0,0 +1,225 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use sea_orm::DatabaseConnection;
|
||||
use serde::{Deserialize, Serialize};
|
||||
#[allow(unused_imports)] // че за хуйня раст?
|
||||
use serde_json::json;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::models::shopping_item::Model as ShoppingItemModel;
|
||||
use crate::services::ShoppingItemService;
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
#[schema(example = json!({"name": "Milk"}))]
|
||||
pub struct CreateShoppingItemRequest {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
#[schema(example = json!({"name": "Updated item name"}))]
|
||||
pub struct UpdateShoppingItemRequest {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
#[schema(example = json!({"is_purchased": true}))]
|
||||
pub struct MarkAsPurchasedRequest {
|
||||
pub is_purchased: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
#[schema(example = json!({"affected_rows": 5}))]
|
||||
pub struct BulkOperationResponse {
|
||||
pub affected_rows: u64,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/families/{family_id}/shopping-items",
|
||||
tag = "shopping-items",
|
||||
params(
|
||||
("family_id" = i32, Path, description = "Family ID")
|
||||
),
|
||||
request_body = CreateShoppingItemRequest,
|
||||
responses(
|
||||
(status = 200, description = "Shopping item created successfully", body = ShoppingItemModel),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn create_shopping_item(
|
||||
State(db): State<DatabaseConnection>,
|
||||
Path(family_id): Path<i32>,
|
||||
Json(payload): Json<CreateShoppingItemRequest>,
|
||||
) -> Result<Json<ShoppingItemModel>, StatusCode> {
|
||||
ShoppingItemService::create(&db, family_id, payload.name)
|
||||
.await
|
||||
.map(Json)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/families/{family_id}/shopping-items",
|
||||
tag = "shopping-items",
|
||||
params(
|
||||
("family_id" = i32, Path, description = "Family ID")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "List of shopping items", body = Vec<ShoppingItemModel>),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn get_shopping_items_by_family(
|
||||
State(db): State<DatabaseConnection>,
|
||||
Path(family_id): Path<i32>,
|
||||
) -> Result<Json<Vec<ShoppingItemModel>>, StatusCode> {
|
||||
ShoppingItemService::find_by_family(&db, family_id)
|
||||
.await
|
||||
.map(Json)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/families/{family_id}/shopping-items/{id}",
|
||||
tag = "shopping-items",
|
||||
params(
|
||||
("family_id" = i32, Path, description = "Family ID"),
|
||||
("id" = i32, Path, description = "Shopping item ID")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Shopping item found", body = ShoppingItemModel),
|
||||
(status = 404, description = "Shopping item not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn get_shopping_item(
|
||||
State(db): State<DatabaseConnection>,
|
||||
Path((_family_id, id)): Path<(i32, i32)>,
|
||||
) -> Result<Json<ShoppingItemModel>, StatusCode> {
|
||||
ShoppingItemService::find_by_id(&db, id)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
.map(Json)
|
||||
.ok_or(StatusCode::NOT_FOUND)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/families/{family_id}/shopping-items/{id}",
|
||||
tag = "shopping-items",
|
||||
params(
|
||||
("family_id" = i32, Path, description = "Family ID"),
|
||||
("id" = i32, Path, description = "Shopping item ID")
|
||||
),
|
||||
request_body = UpdateShoppingItemRequest,
|
||||
responses(
|
||||
(status = 200, description = "Shopping item updated successfully", body = ShoppingItemModel),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn update_shopping_item(
|
||||
State(db): State<DatabaseConnection>,
|
||||
Path((_family_id, id)): Path<(i32, i32)>,
|
||||
Json(payload): Json<UpdateShoppingItemRequest>,
|
||||
) -> Result<Json<ShoppingItemModel>, StatusCode> {
|
||||
ShoppingItemService::update(&db, id, payload.name)
|
||||
.await
|
||||
.map(Json)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/families/{family_id}/shopping-items/{id}",
|
||||
tag = "shopping-items",
|
||||
params(
|
||||
("family_id" = i32, Path, description = "Family ID"),
|
||||
("id" = i32, Path, description = "Shopping item ID")
|
||||
),
|
||||
responses(
|
||||
(status = 204, description = "Shopping item deleted successfully"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn delete_shopping_item(
|
||||
State(db): State<DatabaseConnection>,
|
||||
Path((_family_id, id)): Path<(i32, i32)>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
ShoppingItemService::delete(&db, id)
|
||||
.await
|
||||
.map(|_| StatusCode::NO_CONTENT)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/families/{family_id}/shopping-items/{id}/purchased",
|
||||
tag = "shopping-items",
|
||||
params(
|
||||
("family_id" = i32, Path, description = "Family ID"),
|
||||
("id" = i32, Path, description = "Shopping item ID")
|
||||
),
|
||||
request_body = MarkAsPurchasedRequest,
|
||||
responses(
|
||||
(status = 200, description = "Shopping item marked as purchased", body = ShoppingItemModel),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn mark_as_purchased(
|
||||
State(db): State<DatabaseConnection>,
|
||||
Path((_family_id, id)): Path<(i32, i32)>,
|
||||
Json(payload): Json<MarkAsPurchasedRequest>,
|
||||
) -> Result<Json<ShoppingItemModel>, StatusCode> {
|
||||
ShoppingItemService::mark_as_purchased(&db, id, payload.is_purchased)
|
||||
.await
|
||||
.map(Json)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/families/{family_id}/shopping-items/mark-all-purchased",
|
||||
tag = "shopping-items",
|
||||
params(
|
||||
("family_id" = i32, Path, description = "Family ID")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "All shopping items marked as purchased", body = BulkOperationResponse),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn mark_all_as_purchased(
|
||||
State(db): State<DatabaseConnection>,
|
||||
Path(family_id): Path<i32>,
|
||||
) -> Result<Json<BulkOperationResponse>, StatusCode> {
|
||||
ShoppingItemService::mark_all_as_purchased(&db, family_id)
|
||||
.await
|
||||
.map(|affected_rows| Json(BulkOperationResponse { affected_rows }))
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/families/{family_id}/shopping-items/clear-all",
|
||||
tag = "shopping-items",
|
||||
params(
|
||||
("family_id" = i32, Path, description = "Family ID")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "All shopping items cleared", body = BulkOperationResponse),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
)]
|
||||
pub async fn clear_all(
|
||||
State(db): State<DatabaseConnection>,
|
||||
Path(family_id): Path<i32>,
|
||||
) -> Result<Json<BulkOperationResponse>, StatusCode> {
|
||||
ShoppingItemService::clear_all(&db, family_id)
|
||||
.await
|
||||
.map(|result| Json(BulkOperationResponse { affected_rows: result.rows_affected }))
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
pub mod family_service;
|
||||
pub mod category_service;
|
||||
pub mod expense_service;
|
||||
pub mod shopping_item_service;
|
||||
|
||||
pub use family_service::FamilyService;
|
||||
pub use category_service::CategoryService;
|
||||
pub use expense_service::ExpenseService;
|
||||
pub use shopping_item_service::ShoppingItemService;
|
||||
|
||||
100
backend/src/services/shopping_item_service.rs
Normal file
100
backend/src/services/shopping_item_service.rs
Normal file
@@ -0,0 +1,100 @@
|
||||
use sea_orm::*;
|
||||
use sea_orm::prelude::Expr;
|
||||
use crate::models::shopping_item::{self, Entity as ShoppingItem, Model as ShoppingItemModel};
|
||||
|
||||
pub struct ShoppingItemService;
|
||||
|
||||
impl ShoppingItemService {
|
||||
pub async fn create(
|
||||
db: &DatabaseConnection,
|
||||
family_id: i32,
|
||||
name: String,
|
||||
) -> Result<ShoppingItemModel, DbErr> {
|
||||
let shopping_item = shopping_item::ActiveModel {
|
||||
family_id: Set(family_id),
|
||||
name: Set(name),
|
||||
is_purchased: Set(false),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
shopping_item.insert(db).await
|
||||
}
|
||||
|
||||
pub async fn find_by_id(
|
||||
db: &DatabaseConnection,
|
||||
id: i32,
|
||||
) -> Result<Option<ShoppingItemModel>, DbErr> {
|
||||
ShoppingItem::find_by_id(id).one(db).await
|
||||
}
|
||||
|
||||
pub async fn find_by_family(
|
||||
db: &DatabaseConnection,
|
||||
family_id: i32,
|
||||
) -> Result<Vec<ShoppingItemModel>, DbErr> {
|
||||
ShoppingItem::find()
|
||||
.filter(shopping_item::Column::FamilyId.eq(family_id))
|
||||
.all(db)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
db: &DatabaseConnection,
|
||||
id: i32,
|
||||
name: String,
|
||||
) -> Result<ShoppingItemModel, DbErr> {
|
||||
let shopping_item = ShoppingItem::find_by_id(id)
|
||||
.one(db)
|
||||
.await?
|
||||
.ok_or(DbErr::RecordNotFound("Shopping item not found".to_string()))?;
|
||||
|
||||
let mut shopping_item: shopping_item::ActiveModel = shopping_item.into();
|
||||
shopping_item.name = Set(name);
|
||||
|
||||
shopping_item.update(db).await
|
||||
}
|
||||
|
||||
pub async fn delete(db: &DatabaseConnection, id: i32) -> Result<DeleteResult, DbErr> {
|
||||
let shopping_item = ShoppingItem::find_by_id(id)
|
||||
.one(db)
|
||||
.await?
|
||||
.ok_or(DbErr::RecordNotFound("Shopping item not found".to_string()))?;
|
||||
|
||||
let shopping_item: shopping_item::ActiveModel = shopping_item.into();
|
||||
shopping_item.delete(db).await
|
||||
}
|
||||
|
||||
pub async fn mark_as_purchased(
|
||||
db: &DatabaseConnection,
|
||||
id: i32,
|
||||
is_purchased: bool,
|
||||
) -> Result<ShoppingItemModel, DbErr> {
|
||||
let shopping_item = ShoppingItem::find_by_id(id)
|
||||
.one(db)
|
||||
.await?
|
||||
.ok_or(DbErr::RecordNotFound("Shopping item not found".to_string()))?;
|
||||
|
||||
let mut shopping_item: shopping_item::ActiveModel = shopping_item.into();
|
||||
shopping_item.is_purchased = Set(is_purchased);
|
||||
|
||||
shopping_item.update(db).await
|
||||
}
|
||||
|
||||
pub async fn mark_all_as_purchased(
|
||||
db: &DatabaseConnection,
|
||||
family_id: i32,
|
||||
) -> Result<u64, DbErr> {
|
||||
ShoppingItem::update_many()
|
||||
.filter(shopping_item::Column::FamilyId.eq(family_id))
|
||||
.col_expr(shopping_item::Column::IsPurchased, Expr::value(true))
|
||||
.exec(db)
|
||||
.await
|
||||
.map(|result| result.rows_affected)
|
||||
}
|
||||
|
||||
pub async fn clear_all(db: &DatabaseConnection, family_id: i32) -> Result<DeleteResult, DbErr> {
|
||||
ShoppingItem::delete_many()
|
||||
.filter(shopping_item::Column::FamilyId.eq(family_id))
|
||||
.exec(db)
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,11 @@ import type {
|
||||
CreateExpenseRequest,
|
||||
VerifyFamilyPasswordRequest,
|
||||
VerifyFamilyPasswordResponse,
|
||||
ShoppingItem,
|
||||
CreateShoppingItemRequest,
|
||||
UpdateShoppingItemRequest,
|
||||
MarkAsPurchasedRequest,
|
||||
BulkOperationResponse,
|
||||
} from '../types';
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '';
|
||||
@@ -87,3 +92,29 @@ export const expenseApi = {
|
||||
getRemainingLimit: (familyId: number, categoryId: number) =>
|
||||
apiClient.get<RemainingLimit>(`/families/${familyId}/categories/${categoryId}/remaining`),
|
||||
};
|
||||
|
||||
export const shoppingItemApi = {
|
||||
getAllByFamily: (familyId: number) =>
|
||||
apiClient.get<ShoppingItem[]>(`/families/${familyId}/shopping-items`),
|
||||
|
||||
getById: (familyId: number, itemId: number) =>
|
||||
apiClient.get<ShoppingItem>(`/families/${familyId}/shopping-items/${itemId}`),
|
||||
|
||||
create: (familyId: number, data: CreateShoppingItemRequest) =>
|
||||
apiClient.post<ShoppingItem>(`/families/${familyId}/shopping-items`, data),
|
||||
|
||||
update: (familyId: number, itemId: number, data: UpdateShoppingItemRequest) =>
|
||||
apiClient.put<ShoppingItem>(`/families/${familyId}/shopping-items/${itemId}`, data),
|
||||
|
||||
delete: (familyId: number, itemId: number) =>
|
||||
apiClient.delete(`/families/${familyId}/shopping-items/${itemId}`),
|
||||
|
||||
markAsPurchased: (familyId: number, itemId: number, data: MarkAsPurchasedRequest) =>
|
||||
apiClient.patch<ShoppingItem>(`/families/${familyId}/shopping-items/${itemId}/purchased`, data),
|
||||
|
||||
markAllAsPurchased: (familyId: number) =>
|
||||
apiClient.post<BulkOperationResponse>(`/families/${familyId}/shopping-items/mark-all-purchased`),
|
||||
|
||||
clearAll: (familyId: number) =>
|
||||
apiClient.delete<BulkOperationResponse>(`/families/${familyId}/shopping-items/clear-all`),
|
||||
};
|
||||
|
||||
82
frontend/src/components/ConfirmModal.tsx
Normal file
82
frontend/src/components/ConfirmModal.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { X, AlertTriangle } from 'lucide-react';
|
||||
|
||||
interface ConfirmModalProps {
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
variant?: 'danger' | 'warning' | 'info';
|
||||
}
|
||||
|
||||
export default function ConfirmModal({
|
||||
title,
|
||||
message,
|
||||
confirmText = 'Подтвердить',
|
||||
cancelText = 'Отмена',
|
||||
onConfirm,
|
||||
onCancel,
|
||||
variant = 'danger',
|
||||
}: ConfirmModalProps) {
|
||||
const getVariantStyles = () => {
|
||||
switch (variant) {
|
||||
case 'danger':
|
||||
return {
|
||||
icon: 'bg-red-100 text-red-600',
|
||||
confirmButton: 'bg-gradient-to-r from-red-500 to-red-600 hover:shadow-lg',
|
||||
};
|
||||
case 'warning':
|
||||
return {
|
||||
icon: 'bg-yellow-100 text-yellow-600',
|
||||
confirmButton: 'bg-gradient-to-r from-yellow-500 to-yellow-600 hover:shadow-lg',
|
||||
};
|
||||
case 'info':
|
||||
return {
|
||||
icon: 'bg-blue-100 text-blue-600',
|
||||
confirmButton: 'bg-gradient-to-r from-blue-500 to-blue-600 hover:shadow-lg',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const styles = getVariantStyles();
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-[60] p-4">
|
||||
<div className="bg-white rounded-3xl shadow-2xl max-w-md w-full overflow-hidden animate-scale-in">
|
||||
<div className="p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`p-3 rounded-2xl ${styles.icon}`}>
|
||||
<AlertTriangle className="w-6 h-6" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-bold text-gray-900 mb-2">{title}</h3>
|
||||
<p className="text-gray-600">{message}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="p-2 hover:bg-gray-100 rounded-xl transition-all"
|
||||
>
|
||||
<X className="w-5 h-5 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 pb-6 flex gap-3">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="flex-1 px-6 py-3 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-2xl transition-all font-semibold"
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className={`flex-1 px-6 py-3 text-white rounded-2xl transition-all font-semibold ${styles.confirmButton}`}
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
373
frontend/src/components/ShoppingListModal.tsx
Normal file
373
frontend/src/components/ShoppingListModal.tsx
Normal file
@@ -0,0 +1,373 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { shoppingItemApi } from '../api/client';
|
||||
import type { ShoppingItem } from '../types';
|
||||
import {
|
||||
X,
|
||||
Plus,
|
||||
Trash2,
|
||||
ShoppingCart,
|
||||
Check,
|
||||
Loader2,
|
||||
Pencil,
|
||||
} from 'lucide-react';
|
||||
import ConfirmModal from './ConfirmModal';
|
||||
|
||||
interface ShoppingListModalProps {
|
||||
familyId: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type ConfirmAction =
|
||||
| { type: 'delete-item'; itemId: number }
|
||||
| { type: 'mark-all' }
|
||||
| { type: 'clear-all' };
|
||||
|
||||
export default function ShoppingListModal({ familyId, onClose }: ShoppingListModalProps) {
|
||||
const [items, setItems] = useState<ShoppingItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [newItemName, setNewItemName] = useState('');
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [editingName, setEditingName] = useState('');
|
||||
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadItems();
|
||||
}, [familyId]);
|
||||
|
||||
const loadItems = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await shoppingItemApi.getAllByFamily(familyId);
|
||||
setItems(response.data);
|
||||
} catch (err) {
|
||||
console.error('Error loading shopping items:', err);
|
||||
alert('Ошибка загрузки списка покупок');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddItem = async () => {
|
||||
if (!newItemName.trim()) return;
|
||||
|
||||
try {
|
||||
await shoppingItemApi.create(familyId, { name: newItemName });
|
||||
setNewItemName('');
|
||||
loadItems();
|
||||
} catch (err) {
|
||||
console.error('Error adding item:', err);
|
||||
alert('Ошибка добавления покупки');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTogglePurchased = async (itemId: number, currentStatus: boolean) => {
|
||||
try {
|
||||
await shoppingItemApi.markAsPurchased(familyId, itemId, { is_purchased: !currentStatus });
|
||||
loadItems();
|
||||
} catch (err) {
|
||||
console.error('Error toggling purchased status:', err);
|
||||
alert('Ошибка изменения статуса');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteItem = (itemId: number) => {
|
||||
setConfirmAction({ type: 'delete-item', itemId });
|
||||
};
|
||||
|
||||
const executeDeleteItem = async (itemId: number) => {
|
||||
try {
|
||||
await shoppingItemApi.delete(familyId, itemId);
|
||||
loadItems();
|
||||
} catch (err) {
|
||||
console.error('Error deleting item:', err);
|
||||
alert('Ошибка удаления покупки');
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartEdit = (item: ShoppingItem) => {
|
||||
setEditingId(item.id);
|
||||
setEditingName(item.name);
|
||||
};
|
||||
|
||||
const handleSaveEdit = async (itemId: number) => {
|
||||
if (!editingName.trim()) return;
|
||||
|
||||
try {
|
||||
await shoppingItemApi.update(familyId, itemId, { name: editingName });
|
||||
setEditingId(null);
|
||||
setEditingName('');
|
||||
loadItems();
|
||||
} catch (err) {
|
||||
console.error('Error updating item:', err);
|
||||
alert('Ошибка обновления покупки');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingId(null);
|
||||
setEditingName('');
|
||||
};
|
||||
|
||||
const handleMarkAllPurchased = () => {
|
||||
setConfirmAction({ type: 'mark-all' });
|
||||
};
|
||||
|
||||
const executeMarkAllPurchased = async () => {
|
||||
try {
|
||||
await shoppingItemApi.markAllAsPurchased(familyId);
|
||||
loadItems();
|
||||
} catch (err) {
|
||||
console.error('Error marking all as purchased:', err);
|
||||
alert('Ошибка обновления списка');
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearAll = () => {
|
||||
setConfirmAction({ type: 'clear-all' });
|
||||
};
|
||||
|
||||
const executeClearAll = async () => {
|
||||
try {
|
||||
await shoppingItemApi.clearAll(familyId);
|
||||
loadItems();
|
||||
} catch (err) {
|
||||
console.error('Error clearing all items:', err);
|
||||
alert('Ошибка очистки списка');
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!confirmAction) return;
|
||||
|
||||
switch (confirmAction.type) {
|
||||
case 'delete-item':
|
||||
executeDeleteItem(confirmAction.itemId);
|
||||
break;
|
||||
case 'mark-all':
|
||||
executeMarkAllPurchased();
|
||||
break;
|
||||
case 'clear-all':
|
||||
executeClearAll();
|
||||
break;
|
||||
}
|
||||
|
||||
setConfirmAction(null);
|
||||
};
|
||||
|
||||
const getConfirmModalContent = () => {
|
||||
if (!confirmAction) return null;
|
||||
|
||||
switch (confirmAction.type) {
|
||||
case 'delete-item':
|
||||
return {
|
||||
title: 'Удалить покупку?',
|
||||
message: 'Покупка будет удалена из списка безвозвратно.',
|
||||
confirmText: 'Удалить',
|
||||
};
|
||||
case 'mark-all':
|
||||
return {
|
||||
title: 'Пометить все как купленные?',
|
||||
message: 'Все покупки в списке будут отмечены как купленные.',
|
||||
confirmText: 'Пометить',
|
||||
variant: 'info' as const,
|
||||
};
|
||||
case 'clear-all':
|
||||
return {
|
||||
title: 'Очистить список?',
|
||||
message: 'Все покупки будут удалены из списка безвозвратно.',
|
||||
confirmText: 'Очистить',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const unpurchasedItems = items.filter(item => !item.is_purchased);
|
||||
const purchasedItems = items.filter(item => item.is_purchased);
|
||||
const confirmContent = getConfirmModalContent();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-3xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div className="bg-gradient-to-r from-green-500 to-emerald-600 p-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-3 bg-white/20 backdrop-blur-md rounded-2xl">
|
||||
<ShoppingCart className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-white">Список покупок</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-white/20 rounded-xl transition-all"
|
||||
>
|
||||
<X className="w-6 h-6 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 flex-1 overflow-y-auto">
|
||||
<div className="mb-6">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Добавить покупку..."
|
||||
value={newItemName}
|
||||
onChange={(e) => setNewItemName(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleAddItem()}
|
||||
className="flex-1 px-4 py-3 border-2 border-gray-300 rounded-2xl focus:border-green-500 focus:ring-2 focus:ring-green-200 transition-all"
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddItem}
|
||||
className="px-6 py-3 bg-gradient-to-r from-green-500 to-green-600 text-white rounded-2xl hover:shadow-lg transition-all font-semibold flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
<span className="hidden sm:inline">Добавить</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-green-500" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{unpurchasedItems.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-700 mb-3">К покупке</h3>
|
||||
<div className="space-y-2">
|
||||
{unpurchasedItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="bg-gray-50 p-4 rounded-2xl border-2 border-gray-200 hover:border-green-300 transition-all"
|
||||
>
|
||||
{editingId === item.id ? (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editingName}
|
||||
onChange={(e) => setEditingName(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleSaveEdit(item.id)}
|
||||
className="flex-1 px-3 py-2 border-2 border-green-300 rounded-xl focus:border-green-500 focus:ring-2 focus:ring-green-200"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleSaveEdit(item.id)}
|
||||
className="px-4 py-2 bg-green-500 text-white rounded-xl hover:bg-green-600 transition-all"
|
||||
>
|
||||
<Check className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancelEdit}
|
||||
className="px-4 py-2 bg-gray-300 text-gray-700 rounded-xl hover:bg-gray-400 transition-all"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => handleTogglePurchased(item.id, item.is_purchased)}
|
||||
className="w-6 h-6 border-2 border-gray-400 rounded-lg hover:border-green-500 transition-all"
|
||||
/>
|
||||
<span className="text-gray-800 font-medium">{item.name}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleStartEdit(item)}
|
||||
className="p-2 hover:bg-gray-200 rounded-xl transition-all"
|
||||
>
|
||||
<Pencil className="w-4 h-4 text-gray-600" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteItem(item.id)}
|
||||
className="p-2 hover:bg-red-100 rounded-xl transition-all"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-500" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{purchasedItems.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-700 mb-3">Куплено</h3>
|
||||
<div className="space-y-2">
|
||||
{purchasedItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="bg-green-50 p-4 rounded-2xl border-2 border-green-200"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => handleTogglePurchased(item.id, item.is_purchased)}
|
||||
className="w-6 h-6 bg-green-500 border-2 border-green-500 rounded-lg flex items-center justify-center hover:bg-green-600 transition-all"
|
||||
>
|
||||
<Check className="w-4 h-4 text-white" />
|
||||
</button>
|
||||
<span className="text-gray-500 line-through">{item.name}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDeleteItem(item.id)}
|
||||
className="p-2 hover:bg-red-100 rounded-xl transition-all"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-500" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<ShoppingCart className="w-16 h-16 mx-auto mb-4 opacity-30" />
|
||||
<p className="text-lg">Список покупок пуст</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{items.length > 0 && (
|
||||
<div className="p-6 border-t-2 border-gray-200 bg-gray-50">
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleMarkAllPurchased}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-gradient-to-r from-green-500 to-green-600 text-white rounded-2xl hover:shadow-lg transition-all font-semibold"
|
||||
>
|
||||
<Check className="w-5 h-5" />
|
||||
Все куплено
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClearAll}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-gradient-to-r from-red-500 to-red-600 text-white rounded-2xl hover:shadow-lg transition-all font-semibold"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
Очистить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{confirmContent && (
|
||||
<ConfirmModal
|
||||
title={confirmContent.title}
|
||||
message={confirmContent.message}
|
||||
confirmText={confirmContent.confirmText}
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={() => setConfirmAction(null)}
|
||||
variant={confirmContent.variant || 'danger'}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,9 @@ import {
|
||||
History,
|
||||
Calendar,
|
||||
MessageSquare,
|
||||
ShoppingCart,
|
||||
} from 'lucide-react';
|
||||
import ShoppingListModal from '../components/ShoppingListModal';
|
||||
|
||||
export default function FamilyView() {
|
||||
const { familyId } = useParams<{ familyId: string }>();
|
||||
@@ -39,6 +41,7 @@ export default function FamilyView() {
|
||||
|
||||
const [showHistory, setShowHistory] = useState<number | null>(null);
|
||||
const [categoryExpenses, setCategoryExpenses] = useState<Expense[]>([]);
|
||||
const [showShoppingList, setShowShoppingList] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!familyId) {
|
||||
@@ -241,7 +244,7 @@ export default function FamilyView() {
|
||||
{selectedFamily?.name || 'Семья'}
|
||||
</h1>
|
||||
<div className="max-w-2xl mx-auto glass-effect rounded-2xl shadow-lg p-5">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div className="text-center">
|
||||
<p className="text-gray-600 font-medium text-sm mb-2">Общий лимит</p>
|
||||
<p className="text-2xl sm:text-3xl font-bold text-gray-900">
|
||||
@@ -255,6 +258,13 @@ export default function FamilyView() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowShoppingList(true)}
|
||||
className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-gradient-to-r from-green-500 to-emerald-600 text-white rounded-2xl hover:shadow-xl transition-all duration-300 font-semibold"
|
||||
>
|
||||
<ShoppingCart className="w-5 h-5" />
|
||||
Список покупок
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -510,6 +520,13 @@ export default function FamilyView() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showShoppingList && familyId && (
|
||||
<ShoppingListModal
|
||||
familyId={parseInt(familyId)}
|
||||
onClose={() => setShowShoppingList(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,3 +56,27 @@ export interface CreateExpenseRequest {
|
||||
amount: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ShoppingItem {
|
||||
id: number;
|
||||
family_id: number;
|
||||
name: string;
|
||||
is_purchased: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CreateShoppingItemRequest {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface UpdateShoppingItemRequest {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface MarkAsPurchasedRequest {
|
||||
is_purchased: boolean;
|
||||
}
|
||||
|
||||
export interface BulkOperationResponse {
|
||||
affected_rows: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user