diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 86c93a7..abf06ea 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -106,38 +106,48 @@ pub async fn create_app(db: DatabaseConnection) -> Result { let backend = auth::AuthBackend { db: db.clone() }; let auth_layer = AuthManagerLayerBuilder::new(backend, session_layer).build(); - let protected_routes = Router::new() + let admin_family_routes = Router::new() .route("/families", post(routes::family::create_family)) - .route_layer(axum_middleware::from_fn(middleware::require_admin)); + .route("/families/:id", delete(routes::family::delete_family)) + .layer(auth_layer.clone()) + .route_layer(axum_middleware::from_fn(middleware::require_admin)) + .with_state(db.clone()); - let api_routes = Router::new() + let auth_routes = Router::new() .route("/login", post(routes::auth::login)) .route("/logout", post(routes::auth::logout)) - .merge(protected_routes) - .route("/families", get(routes::family::get_all_families)) - .route("/families/{id}", get(routes::family::get_family)) - .route("/families/{id}", put(routes::family::update_family)) - .route("/families/{id}", delete(routes::family::delete_family)) - .route("/families/{family_id}/categories", post(routes::category::create_category)) - .route("/families/{family_id}/categories", get(routes::category::get_categories_by_family)) - .route("/families/{family_id}/categories/{category_id}", get(routes::category::get_category)) - .route("/families/{family_id}/categories/{category_id}", put(routes::category::update_category)) - .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/{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)) - .route("/families/{family_id}/categories/{category_id}/remaining", get(routes::expense::get_remaining_limit)) .layer(auth_layer) + .with_state(db.clone()); + + let public_routes = Router::new() + .route("/families", get(routes::family::get_all_families)) + .route("/families/:id", get(routes::family::get_family)) + .route("/families/:id", put(routes::family::update_family)) + .route("/families/:family_id/categories", post(routes::category::create_category)) + .route("/families/:family_id/categories", get(routes::category::get_categories_by_family)) + .route("/families/:family_id/categories/:category_id", get(routes::category::get_category)) + .route("/families/:family_id/categories/:category_id", put(routes::category::update_category)) + .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/: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)) + .route("/families/:family_id/categories/:category_id/remaining", get(routes::expense::get_remaining_limit)) .with_state(db); + let api_routes = Router::new() + .merge(admin_family_routes) + .merge(auth_routes) + .merge(public_routes); + let swagger_ui = SwaggerUi::new("/swagger-ui") .url("/api-docs/openapi.json", ApiDoc::openapi()); let cors = CorsLayer::new() .allow_origin([ "http://localhost:3000".parse::().unwrap(), + "http://localhost:5173".parse::().unwrap(), "http://localhost:8080".parse::().unwrap(), ]) .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS]) diff --git a/frontend/.env b/frontend/.env new file mode 100644 index 0000000..a8cf54a --- /dev/null +++ b/frontend/.env @@ -0,0 +1 @@ +VITE_API_BASE_URL=http://localhost:8080 diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..99cbcd5 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1 @@ +VITE_API_BASE_URL=http://localhost:3000 diff --git a/frontend/FRONTEND_README.md b/frontend/FRONTEND_README.md new file mode 100644 index 0000000..6a6ac3d --- /dev/null +++ b/frontend/FRONTEND_README.md @@ -0,0 +1,94 @@ +# Family Budget - Frontend + +Фронтенд приложение для управления семейным бюджетом. + +## Технологии + +- React 19 +- TypeScript +- Vite +- React Router DOM +- Zustand (state management) +- Axios (HTTP client) +- Tailwind CSS + +## Структура проекта + +``` +frontend/ +├── src/ +│ ├── api/ # API клиент для общения с бэкендом +│ ├── pages/ # Страницы приложения +│ ├── store/ # Zustand store +│ ├── types/ # TypeScript типы +│ ├── App.tsx # Главный компонент с роутингом +│ └── main.tsx # Точка входа +├── .env # Переменные окружения +└── package.json +``` + +## Установка и запуск + +1. Установите зависимости: +```bash +cd frontend +npm install +``` + +2. Настройте переменные окружения в `.env`: +``` +VITE_API_BASE_URL=http://localhost:3000 +``` + +3. Запустите dev сервер: +```bash +npm run dev +``` + +4. Соберите production версию: +```bash +npm run build +``` + +## Страницы + +### Главная страница (`/`) +- Выбор семьи из списка +- Кнопка для перехода в админ панель + +### Страница семьи (`/family/:familyId`) +- Отображение всех категорий семьи +- Показ оставшегося баланса для каждой категории +- Добавление расходов (вычет из остатка) +- Добавление новых категорий +- Удаление категорий +- Сброс лимита категории + +### Админ панель (`/adminpanel`) +- Авторизация администратора +- Создание новых семей +- Удаление семей +- Выход из админки + +## API Endpoints + +Все запросы отправляются на бэкенд через Axios клиент (`src/api/client.ts`): + +- **Auth**: `POST /login`, `POST /logout` +- **Families**: `GET /families`, `POST /families`, `DELETE /families/:id` +- **Categories**: `GET /families/:id/categories`, `POST /families/:id/categories`, etc. +- **Expenses**: `GET /families/:id/categories/:id/expenses`, `POST /families/:id/categories/:id/expenses`, etc. + +## Учетные данные по умолчанию + +Согласно бэкенду, дефолтный админ: +- Username: `admin` +- Password: `2123` + +## State Management + +Используется Zustand для управления глобальным состоянием: +- `isAdmin` - флаг авторизации администратора +- `selectedFamily` - выбранная семья +- `families` - список всех семей +- `categories` - категории текущей семьи diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b09d7f1..cff8d8e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@tailwindcss/postcss": "^4.1.18", "axios": "^1.13.2", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -32,6 +33,18 @@ "vite": "^7.2.4" } }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -969,7 +982,6 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -980,7 +992,6 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -991,7 +1002,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -1001,14 +1011,12 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -1330,6 +1338,262 @@ "win32" ] }, + "node_modules/@tailwindcss/node": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", + "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", + "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-x64": "4.1.18", + "@tailwindcss/oxide-freebsd-x64": "4.1.18", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-x64-musl": "4.1.18", + "@tailwindcss/oxide-wasm32-wasi": "4.1.18", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", + "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", + "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", + "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", + "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", + "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", + "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", + "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", + "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", + "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", + "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.0", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", + "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", + "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.18.tgz", + "integrity": "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==", + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "postcss": "^8.4.41", + "tailwindcss": "4.1.18" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2065,6 +2329,15 @@ "node": ">=0.4.0" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2086,6 +2359,19 @@ "dev": true, "license": "ISC" }, + "node_modules/enhanced-resolve": { + "version": "5.18.4", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", + "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -2629,6 +2915,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2762,6 +3054,15 @@ "dev": true, "license": "ISC" }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2853,6 +3154,255 @@ "node": ">= 0.8.0" } }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -2886,6 +3436,15 @@ "yallist": "^3.0.2" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -2940,7 +3499,6 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, "funding": [ { "type": "github", @@ -3066,7 +3624,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -3086,7 +3643,6 @@ "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, "funding": [ { "type": "opencollective", @@ -3314,7 +3870,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -3347,12 +3902,24 @@ } }, "node_modules/tailwindcss": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", - "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", - "dev": true, + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", "license": "MIT" }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", diff --git a/frontend/package.json b/frontend/package.json index a7bd036..8bc7062 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@tailwindcss/postcss": "^4.1.18", "axios": "^1.13.2", "react": "^19.2.0", "react-dom": "^19.2.0", diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js index 2e7af2b..1c87846 100644 --- a/frontend/postcss.config.js +++ b/frontend/postcss.config.js @@ -1,6 +1,6 @@ export default { plugins: { - tailwindcss: {}, + '@tailwindcss/postcss': {}, autoprefixer: {}, }, } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3d7ded3..476808c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,35 +1,18 @@ -import { useState } from 'react' -import reactLogo from './assets/react.svg' -import viteLogo from '/vite.svg' -import './App.css' +import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import Home from './pages/Home'; +import FamilyView from './pages/FamilyView'; +import AdminPanel from './pages/AdminPanel'; function App() { - const [count, setCount] = useState(0) - return ( - <> -
- - Vite logo - - - React logo - -
-

Vite + React

-
- -

- Edit src/App.tsx and save to test HMR -

-
-

- Click on the Vite and React logos to learn more -

- - ) + + + } /> + } /> + } /> + + + ); } -export default App +export default App; diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..6e3961f --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,84 @@ +import axios from 'axios'; +import type { + Family, + Category, + Expense, + RemainingLimit, + LoginRequest, + LoginResponse, + CreateFamilyRequest, + CreateCategoryRequest, + CreateExpenseRequest, +} from '../types'; + +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'; + +const apiClient = axios.create({ + baseURL: API_BASE_URL, + withCredentials: true, +}); + +export const authApi = { + login: (data: LoginRequest) => + apiClient.post('/login', data), + + logout: () => + apiClient.post('/logout'), +}; + +export const familyApi = { + getAll: () => + apiClient.get('/families'), + + getById: (id: number) => + apiClient.get(`/families/${id}`), + + create: (data: CreateFamilyRequest) => + apiClient.post('/families', data), + + update: (id: number, data: { name: string }) => + apiClient.put(`/families/${id}`, data), + + delete: (id: number) => + apiClient.delete(`/families/${id}`), +}; + +export const categoryApi = { + getAllByFamily: (familyId: number) => + apiClient.get(`/families/${familyId}/categories`), + + getById: (familyId: number, categoryId: number) => + apiClient.get(`/families/${familyId}/categories/${categoryId}`), + + create: (familyId: number, data: CreateCategoryRequest) => + apiClient.post(`/families/${familyId}/categories`, data), + + update: (familyId: number, categoryId: number, data: Partial) => + apiClient.put(`/families/${familyId}/categories/${categoryId}`, data), + + delete: (familyId: number, categoryId: number) => + apiClient.delete(`/families/${familyId}/categories/${categoryId}`), + + resetLimit: (familyId: number, categoryId: number, newLimit: number) => + apiClient.put(`/families/${familyId}/categories/${categoryId}`, { limit_amount: newLimit }), +}; + +export const expenseApi = { + getAllByCategory: (familyId: number, categoryId: number) => + apiClient.get(`/families/${familyId}/categories/${categoryId}/expenses`), + + getById: (familyId: number, categoryId: number, expenseId: number) => + apiClient.get(`/families/${familyId}/categories/${categoryId}/expenses/${expenseId}`), + + create: (familyId: number, categoryId: number, data: CreateExpenseRequest) => + apiClient.post(`/families/${familyId}/categories/${categoryId}/expenses`, data), + + update: (familyId: number, categoryId: number, expenseId: number, data: Partial) => + apiClient.put(`/families/${familyId}/categories/${categoryId}/expenses/${expenseId}`, data), + + delete: (familyId: number, categoryId: number, expenseId: number) => + apiClient.delete(`/families/${familyId}/categories/${categoryId}/expenses/${expenseId}`), + + getRemainingLimit: (familyId: number, categoryId: number) => + apiClient.get(`/families/${familyId}/categories/${categoryId}/remaining`), +}; diff --git a/frontend/src/pages/AdminPanel.tsx b/frontend/src/pages/AdminPanel.tsx new file mode 100644 index 0000000..1cca055 --- /dev/null +++ b/frontend/src/pages/AdminPanel.tsx @@ -0,0 +1,228 @@ +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { authApi, familyApi } from '../api/client'; +import { useStore } from '../store/useStore'; + +export default function AdminPanel() { + const navigate = useNavigate(); + const { isAdmin, setIsAdmin, logout: storeLogout } = useStore(); + + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [loginError, setLoginError] = useState(''); + + const [newFamilyName, setNewFamilyName] = useState(''); + const [families, setFamilies] = useState>([]); + + useEffect(() => { + if (isAdmin) { + setIsAuthenticated(true); + loadFamilies(); + } + }, [isAdmin]); + + const handleLogin = async (e: React.FormEvent) => { + e.preventDefault(); + setLoginError(''); + + try { + const response = await authApi.login({ username, password }); + if (response.data.success && response.data.is_admin) { + setIsAdmin(true); + setIsAuthenticated(true); + loadFamilies(); + } else { + setLoginError('Доступ запрещен. Требуются права администратора.'); + } + } catch (err) { + setLoginError('Неверные учетные данные'); + console.error(err); + } + }; + + const handleLogout = async () => { + try { + await authApi.logout(); + storeLogout(); + setIsAuthenticated(false); + setUsername(''); + setPassword(''); + navigate('/'); + } catch (err) { + console.error('Logout error:', err); + } + }; + + const loadFamilies = async () => { + try { + const response = await familyApi.getAll(); + setFamilies(response.data); + } catch (err) { + console.error('Error loading families:', err); + } + }; + + const handleCreateFamily = async () => { + if (!newFamilyName.trim()) return; + + try { + await familyApi.create({ name: newFamilyName }); + setNewFamilyName(''); + loadFamilies(); + } catch (err) { + alert('Ошибка создания семьи'); + console.error(err); + } + }; + + const handleDeleteFamily = async (id: number) => { + if (!confirm('Удалить семью?')) return; + + try { + await familyApi.delete(id); + loadFamilies(); + } catch (err) { + alert('Ошибка удаления семьи'); + console.error(err); + } + }; + + if (!isAuthenticated) { + return ( +
+
+

+ Вход в админ панель +

+ + {loginError && ( +
+ {loginError} +
+ )} + +
+
+ + setUsername(e.target.value)} + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" + required + /> +
+ +
+ + setPassword(e.target.value)} + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" + required + /> +
+ + +
+ + +
+
+ ); + } + + return ( +
+
+
+

+ Админ панель +

+ +
+ +
+

+ Создать новую семью +

+ +
+ setNewFamilyName(e.target.value)} + className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> + +
+
+ +
+

+ Список семей +

+ + {families.length === 0 ? ( +

+ Семьи не найдены +

+ ) : ( +
+ {families.map((family) => ( +
+ + {family.name} + + +
+ ))} +
+ )} +
+ + +
+
+ ); +} diff --git a/frontend/src/pages/FamilyView.tsx b/frontend/src/pages/FamilyView.tsx new file mode 100644 index 0000000..a13540d --- /dev/null +++ b/frontend/src/pages/FamilyView.tsx @@ -0,0 +1,299 @@ +import { useEffect, useState } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { categoryApi, expenseApi } from '../api/client'; +import { useStore } from '../store/useStore'; +import type { Category } from '../types'; + +export default function FamilyView() { + const { familyId } = useParams<{ familyId: string }>(); + const navigate = useNavigate(); + const { selectedFamily } = useStore(); + + const [categories, setCategories] = useState([]); + const [remainingLimits, setRemainingLimits] = useState>(new Map()); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + const [showAddCategory, setShowAddCategory] = useState(false); + const [newCategoryName, setNewCategoryName] = useState(''); + const [newCategoryLimit, setNewCategoryLimit] = useState(''); + + const [showAddExpense, setShowAddExpense] = useState(null); + const [expenseAmount, setExpenseAmount] = useState(''); + const [expenseDescription, setExpenseDescription] = useState(''); + + useEffect(() => { + if (!familyId) { + navigate('/'); + return; + } + loadCategories(); + }, [familyId]); + + const loadCategories = async () => { + if (!familyId) return; + + try { + setLoading(true); + setError(''); + console.log('Loading categories for family:', familyId); + const response = await categoryApi.getAllByFamily(parseInt(familyId)); + console.log('Categories loaded:', response.data); + setCategories(response.data); + + const limits = new Map(); + for (const category of response.data) { + const limitResponse = await expenseApi.getRemainingLimit( + parseInt(familyId), + category.id + ); + const limitValue = typeof limitResponse.data.remaining_limit === 'string' + ? parseFloat(limitResponse.data.remaining_limit) + : limitResponse.data.remaining_limit; + limits.set(category.id, limitValue); + } + setRemainingLimits(limits); + console.log('All data loaded successfully'); + } catch (err: any) { + const errorMsg = err.response?.data?.message || err.message || 'Ошибка загрузки категорий'; + setError(errorMsg); + console.error('Error loading categories:', err); + } finally { + setLoading(false); + } + }; + + const handleAddCategory = async () => { + if (!familyId || !newCategoryName || !newCategoryLimit) return; + + try { + await categoryApi.create(parseInt(familyId), { + name: newCategoryName, + limit_amount: parseFloat(newCategoryLimit), + }); + setNewCategoryName(''); + setNewCategoryLimit(''); + setShowAddCategory(false); + loadCategories(); + } catch (err: any) { + const errorMsg = err.response?.data?.message || err.response?.statusText || err.message || 'Ошибка создания категории'; + alert(`Ошибка создания категории: ${errorMsg} (Статус: ${err.response?.status})`); + console.error('Full error:', err); + } + }; + + const handleDeleteCategory = async (categoryId: number) => { + if (!familyId) return; + if (!confirm('Удалить категорию?')) return; + + try { + await categoryApi.delete(parseInt(familyId), categoryId); + loadCategories(); + } catch (err) { + alert('Ошибка удаления категории'); + console.error(err); + } + }; + + const handleResetLimit = async (categoryId: number) => { + if (!familyId) return; + const newLimit = prompt('Введите новый лимит:'); + if (!newLimit) return; + + try { + await categoryApi.resetLimit( + parseInt(familyId), + categoryId, + parseFloat(newLimit) + ); + loadCategories(); + } catch (err) { + alert('Ошибка сброса лимита'); + console.error(err); + } + }; + + const handleAddExpense = async (categoryId: number) => { + if (!familyId || !expenseAmount) return; + + try { + await expenseApi.create(parseInt(familyId), categoryId, { + amount: parseFloat(expenseAmount), + description: expenseDescription || undefined, + }); + setExpenseAmount(''); + setExpenseDescription(''); + setShowAddExpense(null); + loadCategories(); + } catch (err) { + alert('Ошибка добавления расхода'); + console.error(err); + } + }; + + if (loading) { + return ( +
+
Загрузка...
+
+ ); + } + + return ( +
+
+
+
+ +

+ {selectedFamily?.name || 'Семья'} +

+
+
+ + {error && ( +
+ {error} +
+ )} + +
+ {categories.map((category) => ( +
+
+

+ {category.name} +

+

+ Остаток: + {remainingLimits.get(category.id)?.toFixed(2) || '0.00'} ₽ + + {' / '} + {category.limit_amount.toString()} ₽ +

+
+ +
+ {showAddExpense === category.id ? ( +
+ setExpenseAmount(e.target.value)} + className="w-full mb-2 px-3 py-2 border border-gray-300 rounded" + /> + setExpenseDescription(e.target.value)} + className="w-full mb-2 px-3 py-2 border border-gray-300 rounded" + /> +
+ + +
+
+ ) : ( + + )} +
+
+ ))} +
+ +
+

+ Управление категориями +

+ + {showAddCategory ? ( +
+ setNewCategoryName(e.target.value)} + className="w-full mb-2 px-4 py-2 border border-gray-300 rounded-lg" + /> + setNewCategoryLimit(e.target.value)} + className="w-full mb-2 px-4 py-2 border border-gray-300 rounded-lg" + /> +
+ + +
+
+ ) : ( + + )} + +
+ {categories.map((category) => ( +
+ {category.name} +
+ + +
+
+ ))} +
+
+
+
+ ); +} diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx new file mode 100644 index 0000000..c002a36 --- /dev/null +++ b/frontend/src/pages/Home.tsx @@ -0,0 +1,96 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { familyApi } from '../api/client'; +import { useStore } from '../store/useStore'; +import type { Family } from '../types'; + +export default function Home() { + const navigate = useNavigate(); + const { families, setFamilies, setSelectedFamily } = useStore(); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + useEffect(() => { + loadFamilies(); + }, []); + + const loadFamilies = async () => { + try { + setLoading(true); + const response = await familyApi.getAll(); + setFamilies(response.data); + } catch (err) { + setError('Ошибка загрузки списка семей'); + console.error(err); + } finally { + setLoading(false); + } + }; + + const handleSelectFamily = (family: Family) => { + setSelectedFamily(family); + navigate(`/family/${family.id}`); + }; + + const handleGoToAdmin = () => { + navigate('/adminpanel'); + }; + + if (loading) { + return ( +
+
Загрузка...
+
+ ); + } + + return ( +
+
+
+

+ Семейный бюджет +

+ +
+ + {error && ( +
+ {error} +
+ )} + +
+

+ Выберите семью +

+ + {families.length === 0 ? ( +

+ Семьи не найдены. Создайте семью в админ панели. +

+ ) : ( +
+ {families.map((family) => ( + + ))} +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/store/useStore.ts b/frontend/src/store/useStore.ts new file mode 100644 index 0000000..1563c4a --- /dev/null +++ b/frontend/src/store/useStore.ts @@ -0,0 +1,37 @@ +import { create } from 'zustand'; +import type { Family, Category } from '../types'; + +interface AppState { + isAdmin: boolean; + selectedFamily: Family | null; + families: Family[]; + categories: Category[]; + + setIsAdmin: (isAdmin: boolean) => void; + setSelectedFamily: (family: Family | null) => void; + setFamilies: (families: Family[]) => void; + setCategories: (categories: Category[]) => void; + logout: () => void; +} + +export const useStore = create((set) => ({ + isAdmin: false, + selectedFamily: null, + families: [], + categories: [], + + setIsAdmin: (isAdmin) => set({ isAdmin }), + + setSelectedFamily: (family) => set({ selectedFamily: family }), + + setFamilies: (families) => set({ families }), + + setCategories: (categories) => set({ categories }), + + logout: () => set({ + isAdmin: false, + selectedFamily: null, + families: [], + categories: [] + }), +})); diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts new file mode 100644 index 0000000..bd3e85d --- /dev/null +++ b/frontend/src/types/index.ts @@ -0,0 +1,49 @@ +export interface Family { + id: number; + name: string; +} + +export interface Category { + id: number; + family_id: number; + name: string; + limit_amount: number | string; + created_at: string; +} + +export interface Expense { + id: number; + category_id: number; + amount: number; + description?: string; + created_at: string; +} + +export interface RemainingLimit { + category_id: number; + remaining_limit: number | string; +} + +export interface LoginRequest { + username: string; + password: string; +} + +export interface LoginResponse { + success: boolean; + is_admin: boolean; +} + +export interface CreateFamilyRequest { + name: string; +} + +export interface CreateCategoryRequest { + name: string; + limit_amount: number; +} + +export interface CreateExpenseRequest { + amount: number; + description?: string; +}