From fcd4199cbd42a67ce210c4d06c7d59ccc1bf6c2b Mon Sep 17 00:00:00 2001 From: arrelin Date: Wed, 24 Dec 2025 15:38:36 +0300 Subject: [PATCH] init feature --- backend/src/lib.rs | 24 +- .../m20241224_000001_create_shopping_items.rs | 69 ++++ backend/src/migration/mod.rs | 2 + backend/src/models/family.rs | 8 + backend/src/models/mod.rs | 2 + backend/src/models/shopping_item.rs | 34 ++ backend/src/routes/mod.rs | 1 + backend/src/routes/shopping_item.rs | 225 +++++++++++ backend/src/services/mod.rs | 2 + backend/src/services/shopping_item_service.rs | 100 +++++ frontend/src/api/client.ts | 31 ++ frontend/src/components/ConfirmModal.tsx | 82 ++++ frontend/src/components/ShoppingListModal.tsx | 373 ++++++++++++++++++ frontend/src/pages/FamilyView.tsx | 19 +- frontend/src/types/index.ts | 24 ++ 15 files changed, 994 insertions(+), 2 deletions(-) create mode 100644 backend/src/migration/m20241224_000001_create_shopping_items.rs create mode 100644 backend/src/models/shopping_item.rs create mode 100644 backend/src/routes/shopping_item.rs create mode 100644 backend/src/services/shopping_item_service.rs create mode 100644 frontend/src/components/ConfirmModal.tsx create mode 100644 frontend/src/components/ShoppingListModal.tsx diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 5770be0..fe22314 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -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 { .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()); diff --git a/backend/src/migration/m20241224_000001_create_shopping_items.rs b/backend/src/migration/m20241224_000001_create_shopping_items.rs new file mode 100644 index 0000000..63dd000 --- /dev/null +++ b/backend/src/migration/m20241224_000001_create_shopping_items.rs @@ -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, +} diff --git a/backend/src/migration/mod.rs b/backend/src/migration/mod.rs index 694f2bb..73fc434 100644 --- a/backend/src/migration/mod.rs +++ b/backend/src/migration/mod.rs @@ -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), ] } } diff --git a/backend/src/models/family.rs b/backend/src/models/family.rs index aec6206..2eecb25 100644 --- a/backend/src/models/family.rs +++ b/backend/src/models/family.rs @@ -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 for Entity { @@ -24,4 +26,10 @@ impl Related for Entity { } } +impl Related for Entity { + fn to() -> RelationDef { + Relation::ShoppingItem.def() + } +} + impl ActiveModelBehavior for ActiveModel {} diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs index 9767ee1..1763061 100644 --- a/backend/src/models/mod.rs +++ b/backend/src/models/mod.rs @@ -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; diff --git a/backend/src/models/shopping_item.rs b/backend/src/models/shopping_item.rs new file mode 100644 index 0000000..402cf6b --- /dev/null +++ b/backend/src/models/shopping_item.rs @@ -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 for Entity { + fn to() -> RelationDef { + Relation::Family.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 892ea92..b299d8f 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -2,3 +2,4 @@ pub mod family; pub mod category; pub mod expense; pub mod auth; +pub mod shopping_item; diff --git a/backend/src/routes/shopping_item.rs b/backend/src/routes/shopping_item.rs new file mode 100644 index 0000000..40f96cc --- /dev/null +++ b/backend/src/routes/shopping_item.rs @@ -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, + Path(family_id): Path, + Json(payload): Json, +) -> Result, 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), + (status = 500, description = "Internal server error") + ) +)] +pub async fn get_shopping_items_by_family( + State(db): State, + Path(family_id): Path, +) -> Result>, 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, + Path((_family_id, id)): Path<(i32, i32)>, +) -> Result, 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, + Path((_family_id, id)): Path<(i32, i32)>, + Json(payload): Json, +) -> Result, 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, + Path((_family_id, id)): Path<(i32, i32)>, +) -> Result { + 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, + Path((_family_id, id)): Path<(i32, i32)>, + Json(payload): Json, +) -> Result, 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, + Path(family_id): Path, +) -> Result, 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, + Path(family_id): Path, +) -> Result, StatusCode> { + ShoppingItemService::clear_all(&db, family_id) + .await + .map(|result| Json(BulkOperationResponse { affected_rows: result.rows_affected })) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) +} diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs index 1f69f2d..da56834 100644 --- a/backend/src/services/mod.rs +++ b/backend/src/services/mod.rs @@ -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; diff --git a/backend/src/services/shopping_item_service.rs b/backend/src/services/shopping_item_service.rs new file mode 100644 index 0000000..c4ee686 --- /dev/null +++ b/backend/src/services/shopping_item_service.rs @@ -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 { + 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, DbErr> { + ShoppingItem::find_by_id(id).one(db).await + } + + pub async fn find_by_family( + db: &DatabaseConnection, + family_id: i32, + ) -> Result, 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 { + 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 { + 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 { + 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 { + 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 { + ShoppingItem::delete_many() + .filter(shopping_item::Column::FamilyId.eq(family_id)) + .exec(db) + .await + } +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 8a60fb5..11d93ed 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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(`/families/${familyId}/categories/${categoryId}/remaining`), }; + +export const shoppingItemApi = { + getAllByFamily: (familyId: number) => + apiClient.get(`/families/${familyId}/shopping-items`), + + getById: (familyId: number, itemId: number) => + apiClient.get(`/families/${familyId}/shopping-items/${itemId}`), + + create: (familyId: number, data: CreateShoppingItemRequest) => + apiClient.post(`/families/${familyId}/shopping-items`, data), + + update: (familyId: number, itemId: number, data: UpdateShoppingItemRequest) => + apiClient.put(`/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(`/families/${familyId}/shopping-items/${itemId}/purchased`, data), + + markAllAsPurchased: (familyId: number) => + apiClient.post(`/families/${familyId}/shopping-items/mark-all-purchased`), + + clearAll: (familyId: number) => + apiClient.delete(`/families/${familyId}/shopping-items/clear-all`), +}; diff --git a/frontend/src/components/ConfirmModal.tsx b/frontend/src/components/ConfirmModal.tsx new file mode 100644 index 0000000..eebded0 --- /dev/null +++ b/frontend/src/components/ConfirmModal.tsx @@ -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 ( +
+
+
+
+
+ +
+
+

{title}

+

{message}

+
+ +
+
+ +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/ShoppingListModal.tsx b/frontend/src/components/ShoppingListModal.tsx new file mode 100644 index 0000000..fe97873 --- /dev/null +++ b/frontend/src/components/ShoppingListModal.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [newItemName, setNewItemName] = useState(''); + const [editingId, setEditingId] = useState(null); + const [editingName, setEditingName] = useState(''); + const [confirmAction, setConfirmAction] = useState(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 ( + <> +
+
+
+
+
+ +
+

Список покупок

+
+ +
+ +
+
+
+ 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" + /> + +
+
+ + {loading ? ( +
+ +
+ ) : ( +
+ {unpurchasedItems.length > 0 && ( +
+

К покупке

+
+ {unpurchasedItems.map((item) => ( +
+ {editingId === item.id ? ( +
+ 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 + /> + + +
+ ) : ( +
+
+
+
+ + +
+
+ )} +
+ ))} +
+
+ )} + + {purchasedItems.length > 0 && ( +
+

Куплено

+
+ {purchasedItems.map((item) => ( +
+
+
+ + {item.name} +
+ +
+
+ ))} +
+
+ )} + + {items.length === 0 && ( +
+ +

Список покупок пуст

+
+ )} +
+ )} +
+ + {items.length > 0 && ( +
+
+ + +
+
+ )} +
+
+ + {confirmContent && ( + setConfirmAction(null)} + variant={confirmContent.variant || 'danger'} + /> + )} + + ); +} diff --git a/frontend/src/pages/FamilyView.tsx b/frontend/src/pages/FamilyView.tsx index 5da5830..c743385 100644 --- a/frontend/src/pages/FamilyView.tsx +++ b/frontend/src/pages/FamilyView.tsx @@ -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(null); const [categoryExpenses, setCategoryExpenses] = useState([]); + const [showShoppingList, setShowShoppingList] = useState(false); useEffect(() => { if (!familyId) { @@ -241,7 +244,7 @@ export default function FamilyView() { {selectedFamily?.name || 'Семья'}
-
+

Общий лимит

@@ -255,6 +258,13 @@ export default function FamilyView() {

+
@@ -510,6 +520,13 @@ export default function FamilyView() { )} + + {showShoppingList && familyId && ( + setShowShoppingList(false)} + /> + )} ); } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index f6c17e0..cd63c1b 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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; +}