basic AI front epta

This commit is contained in:
arrelin
2025-12-15 12:16:37 +03:00
parent 74d55c43fd
commit 1e393c79b5
14 changed files with 1513 additions and 63 deletions

1
frontend/.env Normal file
View File

@@ -0,0 +1 @@
VITE_API_BASE_URL=http://localhost:8080

1
frontend/.env.example Normal file
View File

@@ -0,0 +1 @@
VITE_API_BASE_URL=http://localhost:3000

View File

@@ -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` - категории текущей семьи

View File

@@ -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",

View File

@@ -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",

View File

@@ -1,6 +1,6 @@
export default {
plugins: {
tailwindcss: {},
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}

View File

@@ -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 (
<>
<div>
<a href="https://vite.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<h1>Vite + React</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</>
)
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/family/:familyId" element={<FamilyView />} />
<Route path="/adminpanel" element={<AdminPanel />} />
</Routes>
</BrowserRouter>
);
}
export default App
export default App;

View File

@@ -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<LoginResponse>('/login', data),
logout: () =>
apiClient.post('/logout'),
};
export const familyApi = {
getAll: () =>
apiClient.get<Family[]>('/families'),
getById: (id: number) =>
apiClient.get<Family>(`/families/${id}`),
create: (data: CreateFamilyRequest) =>
apiClient.post<Family>('/families', data),
update: (id: number, data: { name: string }) =>
apiClient.put<Family>(`/families/${id}`, data),
delete: (id: number) =>
apiClient.delete(`/families/${id}`),
};
export const categoryApi = {
getAllByFamily: (familyId: number) =>
apiClient.get<Category[]>(`/families/${familyId}/categories`),
getById: (familyId: number, categoryId: number) =>
apiClient.get<Category>(`/families/${familyId}/categories/${categoryId}`),
create: (familyId: number, data: CreateCategoryRequest) =>
apiClient.post<Category>(`/families/${familyId}/categories`, data),
update: (familyId: number, categoryId: number, data: Partial<CreateCategoryRequest>) =>
apiClient.put<Category>(`/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<Category>(`/families/${familyId}/categories/${categoryId}`, { limit_amount: newLimit }),
};
export const expenseApi = {
getAllByCategory: (familyId: number, categoryId: number) =>
apiClient.get<Expense[]>(`/families/${familyId}/categories/${categoryId}/expenses`),
getById: (familyId: number, categoryId: number, expenseId: number) =>
apiClient.get<Expense>(`/families/${familyId}/categories/${categoryId}/expenses/${expenseId}`),
create: (familyId: number, categoryId: number, data: CreateExpenseRequest) =>
apiClient.post<Expense>(`/families/${familyId}/categories/${categoryId}/expenses`, data),
update: (familyId: number, categoryId: number, expenseId: number, data: Partial<CreateExpenseRequest>) =>
apiClient.put<Expense>(`/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<RemainingLimit>(`/families/${familyId}/categories/${categoryId}/remaining`),
};

View File

@@ -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<Array<{ id: number; name: string }>>([]);
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 (
<div className="min-h-screen bg-gray-50 flex items-center justify-center py-12 px-4">
<div className="max-w-md w-full bg-white rounded-lg shadow-md p-8">
<h1 className="text-3xl font-bold text-gray-900 mb-6 text-center">
Вход в админ панель
</h1>
{loginError && (
<div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded">
{loginError}
</div>
)}
<form onSubmit={handleLogin} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Логин
</label>
<input
type="text"
value={username}
onChange={(e) => 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
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Пароль
</label>
<input
type="password"
value={password}
onChange={(e) => 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
/>
</div>
<button
type="submit"
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
>
Войти
</button>
</form>
<button
onClick={() => navigate('/')}
className="w-full mt-4 px-4 py-2 bg-gray-300 text-gray-700 rounded-lg hover:bg-gray-400 transition"
>
Назад
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 py-12 px-4">
<div className="max-w-4xl mx-auto">
<div className="flex justify-between items-center mb-8">
<h1 className="text-4xl font-bold text-gray-900">
Админ панель
</h1>
<button
onClick={handleLogout}
className="px-6 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition"
>
Выход
</button>
</div>
<div className="bg-white rounded-lg shadow-md p-8 mb-6">
<h2 className="text-2xl font-semibold text-gray-900 mb-4">
Создать новую семью
</h2>
<div className="flex gap-2">
<input
type="text"
placeholder="Название семьи"
value={newFamilyName}
onChange={(e) => 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"
/>
<button
onClick={handleCreateFamily}
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition"
>
Создать
</button>
</div>
</div>
<div className="bg-white rounded-lg shadow-md p-8">
<h2 className="text-2xl font-semibold text-gray-900 mb-4">
Список семей
</h2>
{families.length === 0 ? (
<p className="text-gray-500 text-center py-4">
Семьи не найдены
</p>
) : (
<div className="space-y-2">
{families.map((family) => (
<div
key={family.id}
className="flex justify-between items-center p-4 bg-gray-50 rounded-lg"
>
<span className="text-lg font-medium text-gray-900">
{family.name}
</span>
<button
onClick={() => handleDeleteFamily(family.id)}
className="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition"
>
Удалить
</button>
</div>
))}
</div>
)}
</div>
<button
onClick={() => navigate('/')}
className="mt-6 px-6 py-2 bg-gray-300 text-gray-700 rounded-lg hover:bg-gray-400 transition"
>
На главную
</button>
</div>
</div>
);
}

View File

@@ -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<Category[]>([]);
const [remainingLimits, setRemainingLimits] = useState<Map<number, number>>(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<number | null>(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<number, number>();
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 (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-xl text-gray-600">Загрузка...</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 py-12 px-4">
<div className="max-w-6xl mx-auto">
<div className="flex justify-between items-center mb-8">
<div>
<button
onClick={() => navigate('/')}
className="text-blue-600 hover:text-blue-700 mb-2"
>
Назад к списку семей
</button>
<h1 className="text-4xl font-bold text-gray-900">
{selectedFamily?.name || 'Семья'}
</h1>
</div>
</div>
{error && (
<div className="mb-6 p-4 bg-red-100 border border-red-400 text-red-700 rounded-lg">
{error}
</div>
)}
<div className="space-y-4">
{categories.map((category) => (
<div
key={category.id}
className="bg-white rounded-lg shadow-md p-6 flex items-center gap-6"
>
<div className="flex-1">
<h2 className="text-2xl font-semibold text-gray-900 mb-2">
{category.name}
</h2>
<p className="text-lg text-gray-700">
Остаток: <span className="font-bold text-green-600">
{remainingLimits.get(category.id)?.toFixed(2) || '0.00'}
</span>
{' / '}
{category.limit_amount.toString()}
</p>
</div>
<div className="flex flex-col gap-2">
{showAddExpense === category.id ? (
<div className="bg-gray-50 p-4 rounded-lg border border-gray-200">
<input
type="number"
placeholder="Сумма"
value={expenseAmount}
onChange={(e) => setExpenseAmount(e.target.value)}
className="w-full mb-2 px-3 py-2 border border-gray-300 rounded"
/>
<input
type="text"
placeholder="Описание (опционально)"
value={expenseDescription}
onChange={(e) => setExpenseDescription(e.target.value)}
className="w-full mb-2 px-3 py-2 border border-gray-300 rounded"
/>
<div className="flex gap-2">
<button
onClick={() => handleAddExpense(category.id)}
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"
>
Добавить
</button>
<button
onClick={() => setShowAddExpense(null)}
className="px-4 py-2 bg-gray-300 text-gray-700 rounded hover:bg-gray-400"
>
Отмена
</button>
</div>
</div>
) : (
<button
onClick={() => setShowAddExpense(category.id)}
className="px-6 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition"
>
Вычесть из остатка
</button>
)}
</div>
</div>
))}
</div>
<div className="mt-8 bg-white rounded-lg shadow-md p-6">
<h2 className="text-2xl font-semibold text-gray-900 mb-4">
Управление категориями
</h2>
{showAddCategory ? (
<div className="mb-4">
<input
type="text"
placeholder="Название категории"
value={newCategoryName}
onChange={(e) => setNewCategoryName(e.target.value)}
className="w-full mb-2 px-4 py-2 border border-gray-300 rounded-lg"
/>
<input
type="number"
placeholder="Лимит"
value={newCategoryLimit}
onChange={(e) => setNewCategoryLimit(e.target.value)}
className="w-full mb-2 px-4 py-2 border border-gray-300 rounded-lg"
/>
<div className="flex gap-2">
<button
onClick={handleAddCategory}
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
>
Создать
</button>
<button
onClick={() => setShowAddCategory(false)}
className="px-6 py-2 bg-gray-300 text-gray-700 rounded-lg hover:bg-gray-400"
>
Отмена
</button>
</div>
</div>
) : (
<button
onClick={() => setShowAddCategory(true)}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 mb-4"
>
Добавить категорию
</button>
)}
<div className="space-y-2">
{categories.map((category) => (
<div key={category.id} className="flex justify-between items-center p-3 bg-gray-50 rounded-lg">
<span className="font-medium">{category.name}</span>
<div className="flex gap-2">
<button
onClick={() => handleResetLimit(category.id)}
className="px-4 py-1 bg-yellow-500 text-white rounded hover:bg-yellow-600"
>
Сбросить лимит
</button>
<button
onClick={() => handleDeleteCategory(category.id)}
className="px-4 py-1 bg-red-500 text-white rounded hover:bg-red-600"
>
Удалить
</button>
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
}

View File

@@ -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 (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-xl text-gray-600">Загрузка...</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 py-12 px-4">
<div className="max-w-4xl mx-auto">
<div className="flex justify-between items-center mb-8">
<h1 className="text-4xl font-bold text-gray-900">
Семейный бюджет
</h1>
<button
onClick={handleGoToAdmin}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
>
Админка
</button>
</div>
{error && (
<div className="mb-6 p-4 bg-red-100 border border-red-400 text-red-700 rounded-lg">
{error}
</div>
)}
<div className="bg-white rounded-lg shadow-md p-8">
<h2 className="text-2xl font-semibold text-gray-800 mb-6">
Выберите семью
</h2>
{families.length === 0 ? (
<p className="text-gray-500 text-center py-8">
Семьи не найдены. Создайте семью в админ панели.
</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{families.map((family) => (
<button
key={family.id}
onClick={() => handleSelectFamily(family)}
className="p-6 border-2 border-gray-200 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition text-left"
>
<h3 className="text-xl font-semibold text-gray-900">
{family.name}
</h3>
</button>
))}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -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<AppState>((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: []
}),
}));

View File

@@ -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;
}