From 6a9f00ac75f0c5d3dbf6a1f8fb0d493c1d3f4676 Mon Sep 17 00:00:00 2001 From: AmanIbrash-brsh Date: Thu, 23 Jul 2026 17:28:23 +0500 Subject: [PATCH] =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D0=BD=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D1=8C=D1=8E=20=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B8=D0=BB=20?= =?UTF-8?q?=D1=81=D1=82=D1=80=D1=83=D0=BA=D1=82=D1=83=D1=80=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/package-lock.json | 142 +++ backend/package.json | 2 + backend/src/bot.js | 284 +----- backend/src/db.js | 31 +- backend/src/index.js | 505 +++++------ backend/src/scheduler.js | 173 ++-- frontend/package-lock.json | 68 +- frontend/package.json | 5 +- frontend/src/App.jsx | 1256 +-------------------------- frontend/src/components/Layout.jsx | 52 ++ frontend/src/index.css | 816 ++++------------- frontend/src/pages/Auth.jsx | 69 ++ frontend/src/pages/Dashboard.jsx | 59 ++ frontend/src/pages/Debts.jsx | 87 ++ frontend/src/pages/Incomes.jsx | 77 ++ frontend/src/pages/Settings.jsx | 46 + frontend/src/pages/Transactions.jsx | 87 ++ frontend/vite.config.js | 8 + 18 files changed, 1225 insertions(+), 2542 deletions(-) create mode 100644 frontend/src/components/Layout.jsx create mode 100644 frontend/src/pages/Auth.jsx create mode 100644 frontend/src/pages/Dashboard.jsx create mode 100644 frontend/src/pages/Debts.jsx create mode 100644 frontend/src/pages/Incomes.jsx create mode 100644 frontend/src/pages/Settings.jsx create mode 100644 frontend/src/pages/Transactions.jsx diff --git a/backend/package-lock.json b/backend/package-lock.json index cb9ab35..184d4d3 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -8,9 +8,11 @@ "name": "finance-tracker-backend", "version": "1.0.0", "dependencies": { + "bcrypt": "^6.0.0", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", + "jsonwebtoken": "^9.0.3", "sqlite": "^5.1.1", "sqlite3": "^5.1.7", "telegraf": "^4.16.3" @@ -247,6 +249,29 @@ ], "license": "MIT" }, + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/bcrypt/node_modules/node-addon-api": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz", + "integrity": "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -370,6 +395,12 @@ "integrity": "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==", "license": "MIT" }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-fill": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz", @@ -659,6 +690,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -1428,6 +1468,97 @@ "license": "ISC", "optional": true }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -1781,6 +1912,17 @@ "node": ">= 10.12.0" } }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/nodemon": { "version": "3.1.14", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", diff --git a/backend/package.json b/backend/package.json index e4dd182..cc1c63e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -9,9 +9,11 @@ "dev": "nodemon src/index.js" }, "dependencies": { + "bcrypt": "^6.0.0", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", + "jsonwebtoken": "^9.0.3", "sqlite": "^5.1.1", "sqlite3": "^5.1.7", "telegraf": "^4.16.3" diff --git a/backend/src/bot.js b/backend/src/bot.js index 940736c..56cc5d1 100644 --- a/backend/src/bot.js +++ b/backend/src/bot.js @@ -1,40 +1,10 @@ import { Telegraf, Markup } from 'telegraf'; import { getDb } from './db.js'; -import crypto from 'crypto'; let bot; -// Helper to generate a secure random token -function generateAuthToken() { - return crypto.randomBytes(24).toString('hex'); -} - -// Simple category auto-detection based on keywords -function detectCategory(description) { - const desc = description.toLowerCase(); - - const foodKeywords = ['еда', 'обед', 'ужин', 'завтрак', 'кафе', 'ресторан', 'продукт', 'супермаркет', 'хлеб', 'молоко', 'мясо', 'пицца', 'кофе', 'чай', 'мак']; - const transportKeywords = ['такси', 'метро', 'автобус', 'самолет', 'поезд', 'бензин', 'заправка', 'билет', 'шеринг', 'самокат']; - const entertainmentKeywords = ['кино', 'театр', 'игра', 'steam', 'playstation', 'книга', 'музей', 'концерт', 'бар', 'клуб', 'подписка', 'netflix', 'youtube', 'музыка']; - const housingKeywords = ['аренда', 'квартира', 'жкх', 'коммуналка', 'свет', 'вода', 'интернет', 'отопление']; - const clothesKeywords = ['одежда', 'обувь', 'куртка', 'кроссовки', 'футболка', 'штаны', 'носки']; - const healthKeywords = ['аптека', 'лекарство', 'врач', 'больница', 'анализы', 'витамины', 'стоматолог']; - const hygieneKeywords = ['мыло', 'шампунь', 'зубная', 'порошок', 'косметика', 'стрижка', 'парикмахер']; - - if (foodKeywords.some(kw => desc.includes(kw))) return 'Еда'; - if (transportKeywords.some(kw => desc.includes(kw))) return 'Транспорт'; - if (entertainmentKeywords.some(kw => desc.includes(kw))) return 'Развлечения'; - if (housingKeywords.some(kw => desc.includes(kw))) return 'Жилье'; - if (clothesKeywords.some(kw => desc.includes(kw))) return 'Одежда'; - if (healthKeywords.some(kw => desc.includes(kw))) return 'Здоровье'; - if (hygieneKeywords.some(kw => desc.includes(kw))) return 'Гигиена'; - - return 'Прочее'; -} - export async function initBot() { const token = process.env.TELEGRAM_BOT_TOKEN; - const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5173'; if (!token || token === 'your_telegram_bot_token_here') { console.warn('⚠️ TELEGRAM_BOT_TOKEN is not configured. Telegram bot will not start.'); @@ -42,253 +12,40 @@ export async function initBot() { } bot = new Telegraf(token); - const db = getDb(); - // Middleware to register/update user in the database - bot.use(async (ctx, next) => { - if (ctx.from) { - const { id, username, first_name } = ctx.from; - const user = await db.get('SELECT * FROM users WHERE id = ?', id); - - if (!user) { - const token = generateAuthToken(); - await db.run( - 'INSERT INTO users (id, username, first_name, auth_token) VALUES (?, ?, ?, ?)', - id, username || null, first_name || null, token - ); - console.log(`Registered new user: ${first_name} (${id})`); - } else { - // Update profile information if changed - await db.run( - 'UPDATE users SET username = ?, first_name = ? WHERE id = ?', - username || null, first_name || null, id - ); - } - } - return next(); - }); - - // /start command - Welcomes user and offers web app + // /start command bot.start(async (ctx) => { - const webAppLink = `${frontendUrl}`; const name = ctx.from.first_name || 'друг'; await ctx.reply( - `Привет, ${name}! 👋\n\nЯ помогу тебе вести учет финансов.\n\n` + - `✍️ **Как добавлять расходы и доходы:**\n` + - `• Чтобы добавить расход: просто напиши сумму и описание. Например: \`350 кофе и булочка\`\n` + - `• Чтобы добавить доход: напиши плюс (+), затем сумму и описание. Например: \`+ 45000 Аванс\`\n\n` + - `🛒 **Список покупок:**\n` + - `• Добавить: \`/buy [название] [цена]\`. Например: \`/buy Молоко 90\`\n\n` + - `📅 **Подписки:**\n` + - `• Добавить: \`/sub [название] [цена]\`. Например: \`/sub Netflix 800\`\n\n` + - `📊 **Аналитика:**\n` + - `• Используй команду /stats для быстрой сводки расходов.\n` + - `• Или открой полноценный Dashboard с графиками по кнопке ниже! 👇`, - Markup.keyboard([ - ['➕ Доход', '➖ Расход'], - ['📊 Статистика', '🛒 Покупки'], - ['📅 Подписки', Markup.button.webApp('📊 Открыть Dashboard', webAppLink)] - ]).resize() + `Привет, ${name}! 👋\n\n` + + `Я твой финансовый ассистент. Теперь мы используем веб-приложение для основного учета!\n\n` + + `Чтобы получать напоминания о подписках и долгах, привяжи этот аккаунт к профилю на сайте.\n` + + `Зайди в настройки на сайте, сгенерируй код привязки и отправь мне команду:\n\n` + + `/link <твой-код>` ); }); - // /login command - Generates temporary link for desktop/other browser login - bot.command('login', async (ctx) => { - const db = getDb(); - const user = await db.get('SELECT auth_token FROM users WHERE id = ?', ctx.from.id); - let token = user?.auth_token; - - if (!token) { - token = generateAuthToken(); - await db.run('UPDATE users SET auth_token = ? WHERE id = ?', token, ctx.from.id); - } - - const loginUrl = `${frontendUrl}?token=${token}`; - await ctx.reply( - `Вот твоя ссылка для входа в панель управления через обычный браузер (например, на ПК):\n\n` + - `🔗 ${loginUrl}\n\n` + - `⚠️ Не передавай эту ссылку никому, она дает доступ к твоим финансам!`, - { disable_web_page_preview: true } - ); - }); - - const statsHandler = async (ctx) => { - const db = getDb(); - const currentMonth = new Date().toISOString().slice(0, 7); // YYYY-MM - - // Get total expenses - const totalExp = await db.get( - `SELECT SUM(amount) as total FROM transactions - WHERE user_id = ? AND type = 'expense' AND date LIKE ?`, - ctx.from.id, `${currentMonth}%` - ); - - // Get total incomes - const totalInc = await db.get( - `SELECT SUM(amount) as total FROM transactions - WHERE user_id = ? AND type = 'income' AND date LIKE ?`, - ctx.from.id, `${currentMonth}%` - ); - - // Get expenses by category - const categories = await db.all( - `SELECT category, SUM(amount) as total FROM transactions - WHERE user_id = ? AND type = 'expense' AND date LIKE ? - GROUP BY category ORDER BY total DESC`, - ctx.from.id, `${currentMonth}%` - ); - - const expenseSum = totalExp?.total || 0; - const incomeSum = totalInc?.total || 0; - - let message = `📊 **Статистика за текущий месяц (${currentMonth}):**\n\n`; - message += `💰 Доходы: *${incomeSum.toFixed(2)} ₽*\n`; - message += `💸 Расходы: *${expenseSum.toFixed(2)} ₽*\n`; - message += `📈 Баланс: *${(incomeSum - expenseSum).toFixed(2)} ₽*\n\n`; - - if (categories.length > 0) { - message += `🔍 **Расходы по категориям:**\n`; - categories.forEach(cat => { - const percentage = expenseSum > 0 ? ((cat.total / expenseSum) * 100).toFixed(0) : 0; - message += `• ${cat.category}: *${cat.total.toFixed(2)} ₽* (${percentage}%)\n`; - }); - } else { - message += `Нет расходов в этом месяце.`; - } - - await ctx.replyWithMarkdownV2( - message.replace(/\./g, '\\.').replace(/-/g, '\\-').replace(/\+/g, '\\+') - ); - }; - - // /stats command - Show summary of expenses for current month - bot.command('stats', statsHandler); - bot.hears('📊 Статистика', statsHandler); - - // /buy command - Adds item to shopping list - bot.command('buy', async (ctx) => { - const text = ctx.message.text.replace('/buy', '').trim(); - if (!text) { - return ctx.reply('Использование: /buy [название] [примерная цена]\nПример: /buy Молоко 90'); - } - - // Regex to split item name and price - const match = text.match(/^(.+?)\s+(\d+(?:\.\d+)?)$/); - let itemName = text; - let price = 0; - - if (match) { - itemName = match[1].trim(); - price = parseFloat(match[2]); - } - - const db = getDb(); - await db.run( - 'INSERT INTO shopping_list (user_id, item_name, estimated_price, priority) VALUES (?, ?, ?, ?)', - ctx.from.id, itemName, price, 'medium' - ); - - await ctx.reply(`🛒 Добавлено в список покупок: *${itemName}* (~${price} ₽)`, { parse_mode: 'Markdown' }); - }); - - // /sub command - Adds subscription - bot.command('sub', async (ctx) => { - const text = ctx.message.text.replace('/sub', '').trim(); - if (!text) { - return ctx.reply('Использование: /sub [название] [стоимость]\nПример: /sub Netflix 800'); - } - - const match = text.match(/^(.+?)\s+(\d+(?:\.\d+)?)$/); - if (!match) { - return ctx.reply('Пожалуйста, укажите название подписки и стоимость. Пример: /sub Netflix 800'); - } - - const name = match[1].trim(); - const price = parseFloat(match[2]); - - // Set next payment date to 1 month from now - const nextPayment = new Date(); - nextPayment.setMonth(nextPayment.getMonth() + 1); - const nextPaymentStr = nextPayment.toISOString().slice(0, 10); - - const db = getDb(); - await db.run( - 'INSERT INTO subscriptions (user_id, name, amount, period, next_payment_date, active) VALUES (?, ?, ?, ?, ?, 1)', - ctx.from.id, name, price, 'monthly', nextPaymentStr - ); - - await ctx.reply( - `📅 Добавлена подписка: *${name}*\n` + - `💳 Стоимость: *${price} ₽ / мес*\n` + - `📆 След. платеж: *${nextPaymentStr}*`, - { parse_mode: 'Markdown' } - ); - }); - - // Text message handler for quick income/expense input - bot.on('message', async (ctx) => { - if (!ctx.message.text) return; - + // /link command + bot.command('link', async (ctx) => { const text = ctx.message.text.trim(); + const code = text.split(' ')[1]; + + if (!code) { + return ctx.reply('Использование: /link <код>\nНапример: /link A1B2C3D4'); + } + const db = getDb(); - const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD + const user = await db.get('SELECT * FROM users WHERE telegram_link_code = ?', code.toUpperCase()); - // Handle interactive button text replies - if (text === '➕ Доход') { - return ctx.reply('Введите сумму и описание дохода, начиная с "+", например:\n\n`+ 15000 Зарплата`', { parse_mode: 'Markdown' }); - } - if (text === '➖ Расход') { - return ctx.reply('Введите сумму и описание расхода, например:\n\n`450 Кофе и круассан`', { parse_mode: 'Markdown' }); - } - if (text === '🛒 Покупки') { - return ctx.reply('Чтобы добавить покупку, используй команду /buy, например:\n\n`/buy Молоко 90`', { parse_mode: 'Markdown' }); - } - if (text === '📅 Подписки') { - return ctx.reply('Чтобы добавить подписку, используй команду /sub, например:\n\n`/sub Netflix 800`', { parse_mode: 'Markdown' }); + if (!user) { + return ctx.reply('❌ Неверный или устаревший код привязки. Проверьте код на сайте.'); } - // 1. Check for Income (starts with '+' or '+ ') - const incomeMatch = text.match(/^\+\s*(\d+(?:\.\d+)?)\s+(.+)$/); - if (incomeMatch) { - const amount = parseFloat(incomeMatch[1]); - const description = incomeMatch[2].trim(); - const category = 'Доходы'; + // Link telegram account + await db.run('UPDATE users SET telegram_id = ?, telegram_link_code = NULL WHERE id = ?', ctx.from.id, user.id); - await db.run( - 'INSERT INTO transactions (user_id, type, amount, category, description, date) VALUES (?, ?, ?, ?, ?, ?)', - ctx.from.id, 'income', amount, category, description, today - ); - - return ctx.reply(`💰 Добавлен доход: *${amount} ₽* (${description})`, { parse_mode: 'Markdown' }); - } - - // 2. Check for Expense (number followed by description) - const expenseMatch = text.match(/^(\d+(?:\.\d+)?)\s+(.+)$/); - if (expenseMatch) { - const amount = parseFloat(expenseMatch[1]); - const description = expenseMatch[2].trim(); - const category = detectCategory(description); - - await db.run( - 'INSERT INTO transactions (user_id, type, amount, category, description, date) VALUES (?, ?, ?, ?, ?, ?)', - ctx.from.id, 'expense', amount, category, description, today - ); - - return ctx.reply( - `💸 Добавлен расход: *${amount} ₽*\n` + - `📂 Категория: *${category}*\n` + - `📝 Описание: *${description}*`, - { parse_mode: 'Markdown' } - ); - } - - // If text doesn't match, reply with instructions - await ctx.reply( - `Не понял формат сообщения. 🧐\n\n` + - `• Чтобы записать расход, введи: \`[сумма] [описание]\` (например, \`200 такси\`)\n` + - `• Чтобы записать доход, введи: \`+ [сумма] [описание]\` (например, \`+ 1500 продажа книг\`)` - ); + await ctx.reply('✅ Аккаунт успешно привязан! Теперь я буду присылать тебе напоминания о важных финансовых событиях.'); }); bot.catch((err, ctx) => { @@ -301,7 +58,6 @@ export async function initBot() { console.error('Failed to launch bot:', err); }); - // Enable graceful stop process.once('SIGINT', () => bot.stop('SIGINT')); process.once('SIGTERM', () => bot.stop('SIGTERM')); diff --git a/backend/src/db.js b/backend/src/db.js index 3d331ee..cd7e4d9 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -20,10 +20,12 @@ export async function initDb() { // Create tables await db.exec(` CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY, - username TEXT, + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT UNIQUE, + password_hash TEXT, first_name TEXT, - auth_token TEXT, + telegram_id INTEGER, + telegram_link_code TEXT, currency TEXT DEFAULT 'RUB', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); @@ -62,6 +64,29 @@ export async function initDb() { created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); + + CREATE TABLE IF NOT EXISTS debts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + person_name TEXT NOT NULL, + amount REAL NOT NULL, + type TEXT CHECK(type IN ('owe', 'owed')) NOT NULL, + due_date TEXT, -- YYYY-MM-DD + status TEXT CHECK(status IN ('pending', 'paid')) DEFAULT 'pending', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS constant_incomes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + source_name TEXT NOT NULL, + amount REAL NOT NULL, + day_of_month INTEGER NOT NULL, -- 1-31 + last_credited_month TEXT, -- YYYY-MM + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); `); console.log('Database initialized at', dbPath); diff --git a/backend/src/index.js b/backend/src/index.js index a6bb37e..e7792ae 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -1,124 +1,118 @@ import express from 'express'; import cors from 'cors'; import dotenv from 'dotenv'; -import crypto from 'crypto'; +import bcrypt from 'bcrypt'; +import jwt from 'jsonwebtoken'; import path from 'path'; import { fileURLToPath } from 'url'; import { initDb, getDb } from './db.js'; import { initBot } from './bot.js'; import { startScheduler } from './scheduler.js'; +import crypto from 'crypto'; dotenv.config(); const app = express(); const PORT = process.env.PORT || 5000; +const JWT_SECRET = process.env.JWT_SECRET || 'super_secret_key_for_finance_tracker_123!'; app.use(cors()); app.use(express.json()); -// Verification helper for Telegram WebApp initData -function verifyTelegramInitData(initData, botToken) { - if (!botToken || botToken === 'your_telegram_bot_token_here') { - return false; - } - try { - const params = new URLSearchParams(initData); - const hash = params.get('hash'); - params.delete('hash'); - - const pairs = []; - for (const [key, value] of params.entries()) { - pairs.push(`${key}=${value}`); - } - pairs.sort(); - const dataCheckString = pairs.join('\n'); - - const secretKey = crypto.createHmac('sha256', 'WebAppData').update(botToken).digest(); - const calculatedHash = crypto.createHmac('sha256', secretKey).update(dataCheckString).digest('hex'); - - return calculatedHash === hash; - } catch (err) { - console.error('Error verifying Telegram initData:', err); - return false; - } -} - // Authentication Middleware async function authMiddleware(req, res, next) { - const db = getDb(); - const botToken = process.env.TELEGRAM_BOT_TOKEN; - - // 1. Check for Telegram Mini App initData - const initData = req.headers['x-telegram-init-data']; - if (initData) { - const isValid = verifyTelegramInitData(initData, botToken); - - // In development mode, if bot token is placeholder, let it pass or use mock user - const isDevPlaceholder = !botToken || botToken === 'your_telegram_bot_token_here'; - - if (isValid || isDevPlaceholder) { - try { - const params = new URLSearchParams(initData); - const userObjStr = params.get('user'); - if (userObjStr) { - const tgUser = JSON.parse(userObjStr); - - // Ensure user exists in database - let user = await db.get('SELECT * FROM users WHERE id = ?', tgUser.id); - if (!user) { - await db.run( - 'INSERT INTO users (id, username, first_name) VALUES (?, ?, ?)', - tgUser.id, tgUser.username || null, tgUser.first_name || null - ); - user = { id: tgUser.id, username: tgUser.username, first_name: tgUser.first_name }; - } - - req.user = user; - return next(); - } - } catch (err) { - console.error('Failed to parse user in initData:', err); - } - } - } - - // 2. Check for Token (Bearer token for standard browser access) const authHeader = req.headers['authorization']; - if (authHeader && authHeader.startsWith('Bearer ')) { - const token = authHeader.split(' ')[1]; - const user = await db.get('SELECT * FROM users WHERE auth_token = ?', token); + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Unauthorized: No token provided' }); + } + + const token = authHeader.split(' ')[1]; + try { + const decoded = jwt.verify(token, JWT_SECRET); + const db = getDb(); + const user = await db.get('SELECT id, email, first_name, telegram_id, currency FROM users WHERE id = ?', decoded.userId); - if (user) { - req.user = user; - return next(); - } - } - - // 3. Fallback for Local Dev testing if no auth headers provided - if (process.env.NODE_ENV === 'development' || !botToken || botToken === 'your_telegram_bot_token_here') { - // Return a mock user for local testing - let user = await db.get('SELECT * FROM users WHERE id = 1'); if (!user) { - await db.run( - 'INSERT INTO users (id, username, first_name, auth_token) VALUES (1, "test_user", "Test User", "test_token_123")' - ); - user = { id: 1, username: 'test_user', first_name: 'Test User', auth_token: 'test_token_123' }; + return res.status(401).json({ error: 'Unauthorized: User not found' }); } + req.user = user; - return next(); + next(); + } catch (err) { + return res.status(401).json({ error: 'Unauthorized: Invalid token' }); } - - return res.status(401).json({ error: 'Unauthorized' }); } -// --- API ENDPOINTS --- +// --- AUTH ENDPOINTS --- -// GET /api/user/profile +app.post('/api/auth/register', async (req, res) => { + const { email, password, first_name } = req.body; + if (!email || !password || !first_name) { + return res.status(400).json({ error: 'Missing required fields' }); + } + + try { + const db = getDb(); + const existing = await db.get('SELECT id FROM users WHERE email = ?', email); + if (existing) { + return res.status(400).json({ error: 'User with this email already exists' }); + } + + const saltRounds = 10; + const password_hash = await bcrypt.hash(password, saltRounds); + + const result = await db.run( + 'INSERT INTO users (email, password_hash, first_name) VALUES (?, ?, ?)', + email, password_hash, first_name + ); + + const token = jwt.sign({ userId: result.lastID }, JWT_SECRET, { expiresIn: '7d' }); + res.status(201).json({ token, user: { id: result.lastID, email, first_name } }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +app.post('/api/auth/login', async (req, res) => { + const { email, password } = req.body; + if (!email || !password) { + return res.status(400).json({ error: 'Missing email or password' }); + } + + try { + const db = getDb(); + const user = await db.get('SELECT * FROM users WHERE email = ?', email); + if (!user) { + return res.status(400).json({ error: 'Invalid email or password' }); + } + + const match = await bcrypt.compare(password, user.password_hash); + if (!match) { + return res.status(400).json({ error: 'Invalid email or password' }); + } + + const token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' }); + res.json({ token, user: { id: user.id, email: user.email, first_name: user.first_name, telegram_id: user.telegram_id } }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +app.post('/api/auth/generate-link-code', authMiddleware, async (req, res) => { + try { + const db = getDb(); + const code = crypto.randomBytes(4).toString('hex').toUpperCase(); // e.g., A1B2C3D4 + await db.run('UPDATE users SET telegram_link_code = ? WHERE id = ?', code, req.user.id); + res.json({ code }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// --- USER PROFILE --- app.get('/api/user/profile', authMiddleware, async (req, res) => { try { const db = getDb(); - - // Get total income & expense const totals = await db.get( `SELECT SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END) as income, @@ -128,51 +122,32 @@ app.get('/api/user/profile', authMiddleware, async (req, res) => { ); const balance = (totals?.income || 0) - (totals?.expense || 0); - - res.json({ - user: { - id: req.user.id, - username: req.user.username, - first_name: req.user.first_name, - currency: req.user.currency, - auth_token: req.user.auth_token - }, - balance, - totalIncome: totals?.income || 0, - totalExpense: totals?.expense || 0 - }); + res.json({ user: req.user, balance, totalIncome: totals?.income || 0, totalExpense: totals?.expense || 0 }); } catch (err) { res.status(500).json({ error: err.message }); } }); -// GET /api/transactions +// --- TRANSACTIONS --- app.get('/api/transactions', authMiddleware, async (req, res) => { try { const db = getDb(); - const transactions = await db.all( - 'SELECT * FROM transactions WHERE user_id = ? ORDER BY date DESC, id DESC LIMIT 200', - req.user.id - ); + const transactions = await db.all('SELECT * FROM transactions WHERE user_id = ? ORDER BY date DESC, id DESC LIMIT 200', req.user.id); res.json(transactions); } catch (err) { res.status(500).json({ error: err.message }); } }); -// POST /api/transactions app.post('/api/transactions', authMiddleware, async (req, res) => { const { type, amount, category, description, date } = req.body; - if (!type || !amount || !category || !date) { - return res.status(400).json({ error: 'Missing required fields' }); - } + if (!type || !amount || !category || !date) return res.status(400).json({ error: 'Missing required fields' }); try { const db = getDb(); const result = await db.run( 'INSERT INTO transactions (user_id, type, amount, category, description, date) VALUES (?, ?, ?, ?, ?, ?)', req.user.id, type, parseFloat(amount), category, description || null, date ); - const newTx = await db.get('SELECT * FROM transactions WHERE id = ?', result.lastID); res.status(201).json(newTx); } catch (err) { @@ -180,48 +155,141 @@ app.post('/api/transactions', authMiddleware, async (req, res) => { } }); -// DELETE /api/transactions/:id app.delete('/api/transactions/:id', authMiddleware, async (req, res) => { try { const db = getDb(); - const result = await db.run( - 'DELETE FROM transactions WHERE id = ? AND user_id = ?', - req.params.id, req.user.id - ); - if (result.changes === 0) { - return res.status(404).json({ error: 'Transaction not found' }); - } + const result = await db.run('DELETE FROM transactions WHERE id = ? AND user_id = ?', req.params.id, req.user.id); + if (result.changes === 0) return res.status(404).json({ error: 'Not found' }); res.json({ success: true }); } catch (err) { res.status(500).json({ error: err.message }); } }); -// GET /api/subscriptions +// --- CONSTANT INCOMES (SALARY) --- +app.get('/api/incomes', authMiddleware, async (req, res) => { + try { + const db = getDb(); + const incomes = await db.all('SELECT * FROM constant_incomes WHERE user_id = ? ORDER BY day_of_month ASC', req.user.id); + res.json(incomes); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +app.post('/api/incomes', authMiddleware, async (req, res) => { + const { source_name, amount, day_of_month } = req.body; + if (!source_name || !amount || !day_of_month) return res.status(400).json({ error: 'Missing fields' }); + try { + const db = getDb(); + const result = await db.run( + 'INSERT INTO constant_incomes (user_id, source_name, amount, day_of_month) VALUES (?, ?, ?, ?)', + req.user.id, source_name, parseFloat(amount), parseInt(day_of_month) + ); + const item = await db.get('SELECT * FROM constant_incomes WHERE id = ?', result.lastID); + res.status(201).json(item); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +app.delete('/api/incomes/:id', authMiddleware, async (req, res) => { + try { + const db = getDb(); + const result = await db.run('DELETE FROM constant_incomes WHERE id = ? AND user_id = ?', req.params.id, req.user.id); + if (result.changes === 0) return res.status(404).json({ error: 'Not found' }); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// --- DEBTS --- +app.get('/api/debts', authMiddleware, async (req, res) => { + try { + const db = getDb(); + const debts = await db.all('SELECT * FROM debts WHERE user_id = ? ORDER BY status ASC, due_date ASC', req.user.id); + res.json(debts); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +app.post('/api/debts', authMiddleware, async (req, res) => { + const { person_name, amount, type, due_date } = req.body; + if (!person_name || !amount || !type) return res.status(400).json({ error: 'Missing fields' }); + try { + const db = getDb(); + const result = await db.run( + 'INSERT INTO debts (user_id, person_name, amount, type, due_date) VALUES (?, ?, ?, ?, ?)', + req.user.id, person_name, parseFloat(amount), type, due_date || null + ); + const debt = await db.get('SELECT * FROM debts WHERE id = ?', result.lastID); + res.status(201).json(debt); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +app.put('/api/debts/:id/pay', authMiddleware, async (req, res) => { + try { + const db = getDb(); + const debt = await db.get('SELECT * FROM debts WHERE id = ? AND user_id = ?', req.params.id, req.user.id); + if (!debt) return res.status(404).json({ error: 'Not found' }); + + await db.run('BEGIN TRANSACTION'); + await db.run('UPDATE debts SET status = "paid" WHERE id = ?', req.params.id); + + // Add transaction + const today = new Date().toISOString().slice(0, 10); + const txType = debt.type === 'owe' ? 'expense' : 'income'; + const category = debt.type === 'owe' ? 'Возврат долга' : 'Вернули долг'; + + await db.run( + 'INSERT INTO transactions (user_id, type, amount, category, description, date) VALUES (?, ?, ?, ?, ?, ?)', + req.user.id, txType, debt.amount, category, `Долг: ${debt.person_name}`, today + ); + await db.run('COMMIT'); + + const updated = await db.get('SELECT * FROM debts WHERE id = ?', req.params.id); + res.json(updated); + } catch (err) { + const db = getDb(); + try { await db.run('ROLLBACK'); } catch (_) {} + res.status(500).json({ error: err.message }); + } +}); + +app.delete('/api/debts/:id', authMiddleware, async (req, res) => { + try { + const db = getDb(); + const result = await db.run('DELETE FROM debts WHERE id = ? AND user_id = ?', req.params.id, req.user.id); + if (result.changes === 0) return res.status(404).json({ error: 'Not found' }); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// --- SUBSCRIPTIONS --- app.get('/api/subscriptions', authMiddleware, async (req, res) => { try { const db = getDb(); - const subscriptions = await db.all( - 'SELECT * FROM subscriptions WHERE user_id = ? ORDER BY next_payment_date ASC', - req.user.id - ); + const subscriptions = await db.all('SELECT * FROM subscriptions WHERE user_id = ? ORDER BY next_payment_date ASC', req.user.id); res.json(subscriptions); } catch (err) { res.status(500).json({ error: err.message }); } }); -// POST /api/subscriptions app.post('/api/subscriptions', authMiddleware, async (req, res) => { - const { name, amount, period, next_payment_date } = req.body; - if (!name || !amount || !next_payment_date) { - return res.status(400).json({ error: 'Missing required fields' }); - } + const { name, amount, next_payment_date } = req.body; + if (!name || !amount || !next_payment_date) return res.status(400).json({ error: 'Missing fields' }); try { const db = getDb(); const result = await db.run( - 'INSERT INTO subscriptions (user_id, name, amount, period, next_payment_date, active) VALUES (?, ?, ?, ?, ?, 1)', - req.user.id, name, parseFloat(amount), period || 'monthly', next_payment_date + 'INSERT INTO subscriptions (user_id, name, amount, period, next_payment_date, active) VALUES (?, ?, ?, "monthly", ?, 1)', + req.user.id, name, parseFloat(amount), next_payment_date ); const newSub = await db.get('SELECT * FROM subscriptions WHERE id = ?', result.lastID); res.status(201).json(newSub); @@ -230,149 +298,11 @@ app.post('/api/subscriptions', authMiddleware, async (req, res) => { } }); -// PUT /api/subscriptions/:id -app.put('/api/subscriptions/:id', authMiddleware, async (req, res) => { - const { name, amount, period, next_payment_date, active } = req.body; - try { - const db = getDb(); - const currentSub = await db.get('SELECT * FROM subscriptions WHERE id = ? AND user_id = ?', req.params.id, req.user.id); - if (!currentSub) { - return res.status(404).json({ error: 'Subscription not found' }); - } - - await db.run( - `UPDATE subscriptions SET - name = ?, - amount = ?, - period = ?, - next_payment_date = ?, - active = ? - WHERE id = ? AND user_id = ?`, - name !== undefined ? name : currentSub.name, - amount !== undefined ? parseFloat(amount) : currentSub.amount, - period !== undefined ? period : currentSub.period, - next_payment_date !== undefined ? next_payment_date : currentSub.next_payment_date, - active !== undefined ? parseInt(active) : currentSub.active, - req.params.id, req.user.id - ); - - const updatedSub = await db.get('SELECT * FROM subscriptions WHERE id = ?', req.params.id); - res.json(updatedSub); - } catch (err) { - res.status(500).json({ error: err.message }); - } -}); - -// DELETE /api/subscriptions/:id app.delete('/api/subscriptions/:id', authMiddleware, async (req, res) => { try { const db = getDb(); - const result = await db.run( - 'DELETE FROM subscriptions WHERE id = ? AND user_id = ?', - req.params.id, req.user.id - ); - if (result.changes === 0) { - return res.status(404).json({ error: 'Subscription not found' }); - } - res.json({ success: true }); - } catch (err) { - res.status(500).json({ error: err.message }); - } -}); - -// GET /api/shopping -app.get('/api/shopping', authMiddleware, async (req, res) => { - try { - const db = getDb(); - const items = await db.all( - 'SELECT * FROM shopping_list WHERE user_id = ? ORDER BY bought ASC, priority DESC, id DESC', - req.user.id - ); - res.json(items); - } catch (err) { - res.status(500).json({ error: err.message }); - } -}); - -// POST /api/shopping -app.post('/api/shopping', authMiddleware, async (req, res) => { - const { item_name, estimated_price, priority } = req.body; - if (!item_name) { - return res.status(400).json({ error: 'Missing item name' }); - } - try { - const db = getDb(); - const result = await db.run( - 'INSERT INTO shopping_list (user_id, item_name, estimated_price, priority, bought) VALUES (?, ?, ?, ?, 0)', - req.user.id, item_name, parseFloat(estimated_price || 0), priority || 'medium' - ); - const newItem = await db.get('SELECT * FROM shopping_list WHERE id = ?', result.lastID); - res.status(201).json(newItem); - } catch (err) { - res.status(500).json({ error: err.message }); - } -}); - -// POST /api/shopping/:id/buy -app.post('/api/shopping/:id/buy', authMiddleware, async (req, res) => { - try { - const db = getDb(); - const item = await db.get('SELECT * FROM shopping_list WHERE id = ? AND user_id = ?', req.params.id, req.user.id); - if (!item) { - return res.status(404).json({ error: 'Item not found' }); - } - - if (item.bought === 1) { - return res.status(400).json({ error: 'Item already bought' }); - } - - // Start database transaction to ensure atomicity - await db.run('BEGIN TRANSACTION'); - - // 1. Mark item as bought - await db.run('UPDATE shopping_list SET bought = 1 WHERE id = ?', req.params.id); - - // 2. Add to transaction log as expense - const today = new Date().toISOString().slice(0, 10); - - // Helper to detect category based on item name - const detectCategory = (name) => { - const n = name.toLowerCase(); - if (['хлеб', 'молоко', 'еда', 'сыр', 'масло', 'мясо', 'овощи', 'фрукты'].some(k => n.includes(k))) return 'Еда'; - if (['такси', 'бензин', 'билет', 'проезд'].some(k => k.includes(n))) return 'Транспорт'; - if (['куртка', 'джинсы', 'обувь', 'кроссовки', 'одежда'].some(k => n.includes(k))) return 'Одежда'; - if (['фильм', 'игра', 'кино', 'развлечение'].some(k => n.includes(k))) return 'Развлечения'; - return 'Покупки'; - }; - - const category = detectCategory(item.item_name); - - await db.run( - 'INSERT INTO transactions (user_id, type, amount, category, description, date) VALUES (?, ?, ?, ?, ?, ?)', - req.user.id, 'expense', item.estimated_price, category, `Покупка: ${item.item_name}`, today - ); - - await db.run('COMMIT'); - - const updatedItem = await db.get('SELECT * FROM shopping_list WHERE id = ?', req.params.id); - res.json({ success: true, item: updatedItem }); - } catch (err) { - try { await db.run('ROLLBACK'); } catch (_) {} - res.status(500).json({ error: err.message }); - } -}); - -// DELETE /api/shopping/:id -app.delete('/api/shopping/:id', authMiddleware, async (req, res) => { - try { - const db = getDb(); - const result = await db.run( - 'DELETE FROM shopping_list WHERE id = ? AND user_id = ?', - req.params.id, req.user.id - ); - if (result.changes === 0) { - return res.status(404).json({ error: 'Item not found' }); - } + const result = await db.run('DELETE FROM subscriptions WHERE id = ? AND user_id = ?', req.params.id, req.user.id); + if (result.changes === 0) return res.status(404).json({ error: 'Not found' }); res.json({ success: true }); } catch (err) { res.status(500).json({ error: err.message }); @@ -381,41 +311,22 @@ app.delete('/api/shopping/:id', authMiddleware, async (req, res) => { const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); - -// Serve static frontend files const frontendDistPath = path.resolve(__dirname, '../dist'); app.use(express.static(frontendDistPath)); -// Health check / API status -app.get('/api/status', (req, res) => { - res.json({ status: 'ok', time: new Date() }); -}); +app.get('/api/status', (req, res) => res.json({ status: 'ok', time: new Date() })); -// Fallback to React SPA index.html for non-API routes app.get('*', (req, res, next) => { - if (req.path.startsWith('/api')) { - return next(); - } - res.sendFile(path.join(frontendDistPath, 'index.html'), (err) => { - if (err) { - // In development or if frontend dist is missing, send a helper message - res.status(404).send('Frontend is not built or index.html is missing. Run frontend build first.'); - } - }); + if (req.path.startsWith('/api')) return next(); + res.sendFile(path.join(frontendDistPath, 'index.html')); }); -// Start Express server & bot async function startServer() { try { await initDb(); const bot = await initBot(); - if (bot) { - startScheduler(bot); - } - - app.listen(PORT, () => { - console.log(`🚀 Server running on port ${PORT}`); - }); + startScheduler(bot); + app.listen(PORT, () => console.log(`🚀 Server running on port ${PORT}`)); } catch (err) { console.error('Fatal initialization error:', err); process.exit(1); diff --git a/backend/src/scheduler.js b/backend/src/scheduler.js index 82831f2..74ce0d6 100644 --- a/backend/src/scheduler.js +++ b/backend/src/scheduler.js @@ -1,106 +1,115 @@ import { getDb } from './db.js'; -// Helper to add months or years to a date string (YYYY-MM-DD) -function advanceDate(dateStr, period) { - const date = new Date(dateStr); - if (period === 'yearly') { - date.setFullYear(date.getFullYear() + 1); - } else { - date.setMonth(date.getMonth() + 1); - } - return date.toISOString().slice(0, 10); +let botInstance = null; + +export function startScheduler(bot) { + botInstance = bot; + // Run checks every hour + const ONE_HOUR = 60 * 60 * 1000; + setInterval(runChecks, ONE_HOUR); + + // Run immediately on start + runChecks(); + console.log('⏰ Scheduler started (checks every hour).'); } -export async function checkSubscriptions(bot) { - if (!bot) { - console.log('Scheduler: Bot is not initialized. Skipping subscription checks.'); - return; +async function runChecks() { + console.log('🕰️ Running scheduled checks...'); + try { + await checkSalaries(); + await checkSubscriptions(); + await checkDebts(); + } catch (err) { + console.error('Error during scheduled checks:', err); } +} - console.log('🕰️ Running scheduled subscription checks...'); +async function checkSalaries() { const db = getDb(); + const today = new Date(); + const currentDay = today.getDate(); + const currentMonthStr = today.toISOString().slice(0, 7); // YYYY-MM + const fullDateStr = today.toISOString().slice(0, 10); + + const incomes = await db.all('SELECT * FROM constant_incomes'); - const todayStr = new Date().toISOString().slice(0, 10); - - // Calculate tomorrow's date + for (const income of incomes) { + // If today is the payout day or later, and it hasn't been credited this month + if (currentDay >= income.day_of_month && income.last_credited_month !== currentMonthStr) { + // 1. Add transaction + await db.run( + 'INSERT INTO transactions (user_id, type, amount, category, description, date) VALUES (?, ?, ?, ?, ?, ?)', + income.user_id, 'income', income.amount, 'Регулярный доход', income.source_name, fullDateStr + ); + + // 2. Update last credited month + await db.run( + 'UPDATE constant_incomes SET last_credited_month = ? WHERE id = ?', + currentMonthStr, income.id + ); + + // 3. Notify user if telegram linked + const user = await db.get('SELECT telegram_id FROM users WHERE id = ?', income.user_id); + if (user && user.telegram_id && botInstance) { + try { + await botInstance.telegram.sendMessage( + user.telegram_id, + `💰 Начислен регулярный доход!\n*${income.source_name}*: +${income.amount} ₽`, + { parse_mode: 'Markdown' } + ); + } catch (e) { + console.error(`Failed to send salary notification to ${user.telegram_id}`); + } + } + } + } +} + +async function checkSubscriptions() { + const db = getDb(); const tomorrow = new Date(); tomorrow.setDate(tomorrow.getDate() + 1); const tomorrowStr = tomorrow.toISOString().slice(0, 10); - try { - // 1. Pre-notification for tomorrow's payments - const upcomingSubs = await db.all( - 'SELECT * FROM subscriptions WHERE active = 1 AND next_payment_date = ?', - tomorrowStr - ); + const subs = await db.all('SELECT * FROM subscriptions WHERE active = 1 AND next_payment_date = ?', tomorrowStr); - for (const sub of upcomingSubs) { + for (const sub of subs) { + const user = await db.get('SELECT telegram_id FROM users WHERE id = ?', sub.user_id); + if (user && user.telegram_id && botInstance) { try { - await bot.telegram.sendMessage( - sub.user_id, - `🔔 **Напоминание о подписке!**\n\nЗавтра ожидается оплата подписки *${sub.name}* на сумму *${sub.amount} ₽*.`, + await botInstance.telegram.sendMessage( + user.telegram_id, + `⚠️ Напоминание: Завтра (${tomorrowStr}) будет списание за подписку *${sub.name}* на сумму ${sub.amount} ₽.`, { parse_mode: 'Markdown' } ); - console.log(`Sent upcoming subscription warning to user ${sub.user_id} for sub: ${sub.name}`); - } catch (err) { - console.error(`Failed to send Telegram message to user ${sub.user_id}:`, err.message); + } catch (e) { + console.error(`Failed to send subscription notification to ${user.telegram_id}`); } } - - // 2. Process today's payments (Notify + Log Expense + Advance Date) - const todaySubs = await db.all( - 'SELECT * FROM subscriptions WHERE active = 1 AND next_payment_date <= ?', - todayStr - ); - - for (const sub of todaySubs) { - try { - await db.run('BEGIN TRANSACTION'); - - // 1. Log transaction expense - await db.run( - 'INSERT INTO transactions (user_id, type, amount, category, description, date) VALUES (?, ?, ?, ?, ?, ?)', - sub.user_id, 'expense', sub.amount, 'Развлечения', `Подписка: ${sub.name}`, todayStr - ); - - // 2. Advance next payment date - const newPaymentDate = advanceDate(sub.next_payment_date, sub.period); - await db.run( - 'UPDATE subscriptions SET next_payment_date = ? WHERE id = ?', - newPaymentDate, sub.id - ); - - await db.run('COMMIT'); - - // 3. Notify user in Telegram - await bot.telegram.sendMessage( - sub.user_id, - `📅 **Списание по подписке!**\n\nСегодня оплачена подписка *${sub.name}* на сумму *${sub.amount} ₽*.\n` + - `💸 Расход автоматически добавлен в историю.\n` + - `📆 Следующий платеж запланирован на: *${newPaymentDate}*`, - { parse_mode: 'Markdown' } - ); - console.log(`Processed subscription payment for sub: ${sub.name}, advanced to: ${newPaymentDate}`); - } catch (err) { - try { await db.run('ROLLBACK'); } catch (_) {} - console.error(`Failed to process payment for subscription ${sub.name} (${sub.id}):`, err); - } - } - } catch (err) { - console.error('Error checking subscriptions:', err); } } -// Start daily check intervals -export function startScheduler(bot) { - // Run check immediately on start - setTimeout(() => checkSubscriptions(bot), 5000); +async function checkDebts() { + const db = getDb(); + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + const tomorrowStr = tomorrow.toISOString().slice(0, 10); - // Run checks every 12 hours (12 * 60 * 60 * 1000 ms) - const intervalMs = 12 * 60 * 60 * 1000; - setInterval(() => { - checkSubscriptions(bot); - }, intervalMs); + const debts = await db.all('SELECT * FROM debts WHERE status = "pending" AND due_date = ?', tomorrowStr); - console.log('⏰ Subscription scheduler successfully started (checks every 12 hours).'); + for (const debt of debts) { + const user = await db.get('SELECT telegram_id FROM users WHERE id = ?', debt.user_id); + if (user && user.telegram_id && botInstance) { + const typeText = debt.type === 'owe' ? 'Вам нужно вернуть долг' : 'Вам должны вернуть долг'; + try { + await botInstance.telegram.sendMessage( + user.telegram_id, + `📅 Напоминание о долге: Завтра крайний срок!\n${typeText}\n*Человек*: ${debt.person_name}\n*Сумма*: ${debt.amount} ₽`, + { parse_mode: 'Markdown' } + ); + } catch (e) { + console.error(`Failed to send debt notification to ${user.telegram_id}`); + } + } + } } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index dd25710..921a86c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,9 +8,10 @@ "name": "frontend", "version": "0.0.0", "dependencies": { - "lucide-react": "^1.24.0", + "lucide-react": "^1.26.0", "react": "^19.2.7", - "react-dom": "^19.2.7" + "react-dom": "^19.2.7", + "react-router-dom": "^7.18.1" }, "devDependencies": { "@types/react": "^19.2.17", @@ -727,6 +728,19 @@ } } }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1039,9 +1053,9 @@ } }, "node_modules/lucide-react": { - "version": "1.24.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.24.0.tgz", - "integrity": "sha512-YT6mBD8lGKkg4nM39enlm94/sfJIiW0YKUT60fBy4YK8tai31ylg1VhGNWxkpSKHo9UagfnZqwIff3HTDQwXeA==", + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.26.0.tgz", + "integrity": "sha512-raglYVR2+VkMfJL158krjVmE+rV5ST2lzA/KQm1FRSjMHT4MnWaegHxoVEpmc2So3nOEhp9oGejJwAPX8MoAjg==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -1185,6 +1199,44 @@ "react": "^19.2.7" } }, + "node_modules/react-router": { + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz", + "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz", + "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/rolldown": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", @@ -1225,6 +1277,12 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 998d803..9eacc44 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,9 +10,10 @@ "preview": "vite preview" }, "dependencies": { - "lucide-react": "^1.24.0", + "lucide-react": "^1.26.0", "react": "^19.2.7", - "react-dom": "^19.2.7" + "react-dom": "^19.2.7", + "react-router-dom": "^7.18.1" }, "devDependencies": { "@types/react": "^19.2.17", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 29578e7..6884f70 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,1231 +1,29 @@ -import React, { useState, useEffect } from 'react'; -import { - TrendingUp, - TrendingDown, - DollarSign, - Calendar, - ShoppingCart, - Plus, - Trash2, - LogOut, - Check, - X, - Lock, - RefreshCw, - Play, - Pause, - Wallet, - ListFilter -} from 'lucide-react'; -import { - initTelegram, - getTelegramUser, - getAuthHeaders, - saveTokenFromUrl, - logout, - tg -} from './TelegramSDK'; - -const API_URL = window.location.hostname === 'localhost' ? 'http://localhost:5000' : window.location.origin; - -function App() { - // Navigation & Authentication - const [activeTab, setActiveTab] = useState('dashboard'); - const [isAuthenticated, setIsAuthenticated] = useState(false); - const [user, setUser] = useState(null); - - // Data States - const [profile, setProfile] = useState({ balance: 0, totalIncome: 0, totalExpense: 0 }); - const [transactions, setTransactions] = useState([]); - const [subscriptions, setSubscriptions] = useState([]); - const [shoppingList, setShoppingList] = useState([]); - - // UI States - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(''); - const [successMsg, setSuccessMsg] = useState(''); - - // Modal States - const [showTxModal, setShowTxModal] = useState(false); - const [showSubModal, setShowSubModal] = useState(false); - const [showShoppingModal, setShowShoppingModal] = useState(false); - - // Form States - const [txForm, setTxForm] = useState({ type: 'expense', amount: '', category: 'Еда', description: '', date: new Date().toISOString().slice(0, 10) }); - const [subForm, setSubForm] = useState({ name: '', amount: '', period: 'monthly', next_payment_date: new Date().toISOString().slice(0, 10) }); - const [shoppingForm, setShoppingForm] = useState({ item_name: '', estimated_price: '', priority: 'medium' }); - - // Filter States - const [txFilter, setTxFilter] = useState('all'); // all, income, expense - const [txCategoryFilter, setTxCategoryFilter] = useState('all'); - - // Categories list - const categories = ['Еда', 'Транспорт', 'Развлечения', 'Жилье', 'Одежда', 'Здоровье', 'Гигиена', 'Прочее']; - - // Initialize App - useEffect(() => { - initTelegram(); - const token = saveTokenFromUrl(); - - // If inside Telegram, initData is used automatically. Otherwise check token - if ((tg && tg.initData) || token || window.location.hostname === 'localhost') { - setIsAuthenticated(true); - fetchAllData(); - } else { - setIsLoading(false); - } - }, []); - - // Fetch all data from backend - const fetchAllData = async () => { - setIsLoading(true); - setError(''); - try { - const headers = getAuthHeaders(); - - // 1. Profile - const profileRes = await fetch(`${API_URL}/api/user/profile`, { headers }); - if (!profileRes.ok) throw new Error('Ошибка авторизации'); - const profileData = await profileRes.json(); - setProfile(profileData); - setUser(profileData.user); - - // 2. Transactions - const txRes = await fetch(`${API_URL}/api/transactions`, { headers }); - const txData = await txRes.json(); - setTransactions(txData); - - // 3. Subscriptions - const subRes = await fetch(`${API_URL}/api/subscriptions`, { headers }); - const subData = await subRes.json(); - setSubscriptions(subData); - - // 4. Shopping list - const shopRes = await fetch(`${API_URL}/api/shopping`, { headers }); - const shopData = await shopRes.json(); - setShoppingList(shopData); - - setIsAuthenticated(true); - } catch (err) { - console.error('Fetch error:', err); - setError('Не удалось загрузить данные. Проверьте соединение.'); - // If error is auth, clear auth state - if (err.message === 'Ошибка авторизации') { - setIsAuthenticated(false); - } - } finally { - setIsLoading(false); - } - }; - - // Alert message helper - const showMessage = (msg, type = 'success') => { - if (type === 'success') { - setSuccessMsg(msg); - setTimeout(() => setSuccessMsg(''), 4000); - } else { - setError(msg); - setTimeout(() => setError(''), 4000); - } - }; - - // --- API MUTATIONS --- - - // Manually log in as test user (localhost development bypass) - const handleTestLogin = () => { - localStorage.setItem('finance_auth_token', 'test_token_123'); - setIsAuthenticated(true); - fetchAllData(); - }; - - // Transactions CRUD - const handleAddTransaction = async (e) => { - e.preventDefault(); - if (!txForm.amount || isNaN(txForm.amount) || parseFloat(txForm.amount) <= 0) { - return showMessage('Введите корректную сумму', 'error'); - } - - try { - const headers = { ...getAuthHeaders(), 'Content-Type': 'application/json' }; - const res = await fetch(`${API_URL}/api/transactions`, { - method: 'POST', - headers, - body: JSON.stringify(txForm) - }); - - if (!res.ok) throw new Error('Ошибка добавления транзакции'); - - const newTx = await res.json(); - setTransactions([newTx, ...transactions]); - - // Update profile totals - const amountNum = parseFloat(txForm.amount); - setProfile(prev => { - const isInc = txForm.type === 'income'; - return { - ...prev, - balance: prev.balance + (isInc ? amountNum : -amountNum), - totalIncome: prev.totalIncome + (isInc ? amountNum : 0), - totalExpense: prev.totalExpense + (isInc ? 0 : amountNum) - }; - }); - - setShowTxModal(false); - // Reset form - setTxForm({ type: 'expense', amount: '', category: 'Еда', description: '', date: new Date().toISOString().slice(0, 10) }); - showMessage('Транзакция успешно добавлена!'); - } catch (err) { - showMessage(err.message, 'error'); - } - }; - - const handleDeleteTransaction = async (id, amount, type) => { - if (!window.confirm('Вы действительно хотите удалить эту транзакцию?')) return; - try { - const headers = getAuthHeaders(); - const res = await fetch(`${API_URL}/api/transactions/${id}`, { - method: 'DELETE', - headers - }); - if (!res.ok) throw new Error('Ошибка удаления'); - - setTransactions(transactions.filter(t => t.id !== id)); - - // Update profile totals - setProfile(prev => { - const isInc = type === 'income'; - return { - ...prev, - balance: prev.balance - (isInc ? amount : -amount), - totalIncome: prev.totalIncome - (isInc ? amount : 0), - totalExpense: prev.totalExpense - (isInc ? 0 : amount) - }; - }); - showMessage('Транзакция удалена'); - } catch (err) { - showMessage(err.message, 'error'); - } - }; - - // Subscriptions CRUD - const handleAddSubscription = async (e) => { - e.preventDefault(); - if (!subForm.name || !subForm.amount || isNaN(subForm.amount)) { - return showMessage('Заполните обязательные поля корректно', 'error'); - } - - try { - const headers = { ...getAuthHeaders(), 'Content-Type': 'application/json' }; - const res = await fetch(`${API_URL}/api/subscriptions`, { - method: 'POST', - headers, - body: JSON.stringify(subForm) - }); - - if (!res.ok) throw new Error('Ошибка добавления подписки'); - const newSub = await res.json(); - setSubscriptions([...subscriptions, newSub]); - - setShowSubModal(false); - setSubForm({ name: '', amount: '', period: 'monthly', next_payment_date: new Date().toISOString().slice(0, 10) }); - showMessage('Подписка добавлена'); - } catch (err) { - showMessage(err.message, 'error'); - } - }; - - const handleToggleSubActive = async (id, currentStatus) => { - try { - const headers = { ...getAuthHeaders(), 'Content-Type': 'application/json' }; - const newStatus = currentStatus === 1 ? 0 : 1; - const res = await fetch(`${API_URL}/api/subscriptions/${id}`, { - method: 'PUT', - headers, - body: JSON.stringify({ active: newStatus }) - }); - if (!res.ok) throw new Error('Не удалось обновить статус'); - - setSubscriptions(subscriptions.map(s => s.id === id ? { ...s, active: newStatus } : s)); - showMessage(newStatus === 1 ? 'Подписка возобновлена' : 'Подписка приостановлена'); - } catch (err) { - showMessage(err.message, 'error'); - } - }; - - const handleDeleteSubscription = async (id) => { - if (!window.confirm('Удалить эту подписку?')) return; - try { - const headers = getAuthHeaders(); - const res = await fetch(`${API_URL}/api/subscriptions/${id}`, { - method: 'DELETE', - headers - }); - if (!res.ok) throw new Error('Ошибка удаления'); - - setSubscriptions(subscriptions.filter(s => s.id !== id)); - showMessage('Подписка удалена'); - } catch (err) { - showMessage(err.message, 'error'); - } - }; - - // Shopping List CRUD - const handleAddShoppingItem = async (e) => { - e.preventDefault(); - if (!shoppingForm.item_name) return showMessage('Введите название товара', 'error'); - - try { - const headers = { ...getAuthHeaders(), 'Content-Type': 'application/json' }; - const res = await fetch(`${API_URL}/api/shopping`, { - method: 'POST', - headers, - body: JSON.stringify({ - item_name: shoppingForm.item_name, - estimated_price: shoppingForm.estimated_price ? parseFloat(shoppingForm.estimated_price) : 0, - priority: shoppingForm.priority - }) - }); - - if (!res.ok) throw new Error('Ошибка добавления'); - const newItem = await res.json(); - setShoppingList([newItem, ...shoppingList]); - - setShowShoppingModal(false); - setShoppingForm({ item_name: '', estimated_price: '', priority: 'medium' }); - showMessage('Товар добавлен в список покупок'); - } catch (err) { - showMessage(err.message, 'error'); - } - }; - - const handleBuyShoppingItem = async (id, name, price) => { - try { - const headers = getAuthHeaders(); - const res = await fetch(`${API_URL}/api/shopping/${id}/buy`, { - method: 'POST', - headers - }); - if (!res.ok) throw new Error('Ошибка покупки'); - - // Update shopping list state (mark as bought) - setShoppingList(shoppingList.map(item => item.id === id ? { ...item, bought: 1 } : item)); - - // Refetch profile & transactions to get new transaction - const txRes = await fetch(`${API_URL}/api/transactions`, { headers }); - const txData = await txRes.json(); - setTransactions(txData); - - const profileRes = await fetch(`${API_URL}/api/user/profile`, { headers }); - const profileData = await profileRes.json(); - setProfile(profileData); - - showMessage(`Товар "${name}" куплен! Расход записан.`); - } catch (err) { - showMessage(err.message, 'error'); - } - }; - - const handleDeleteShoppingItem = async (id) => { - try { - const headers = getAuthHeaders(); - const res = await fetch(`${API_URL}/api/shopping/${id}`, { - method: 'DELETE', - headers - }); - if (!res.ok) throw new Error('Ошибка удаления'); - - setShoppingList(shoppingList.filter(item => item.id !== id)); - showMessage('Товар удален из списка'); - } catch (err) { - showMessage(err.message, 'error'); - } - }; - - // --- STATS CALCULATIONS --- - - // Category breakdown - const categoryExpenses = transactions - .filter(t => t.type === 'expense') - .reduce((acc, t) => { - acc[t.category] = (acc[t.category] || 0) + t.amount; - return acc; - }, {}); - - // Convert categories object to sorted array - const categoryList = Object.keys(categoryExpenses).map(cat => ({ - name: cat, - value: categoryExpenses[cat], - color: cat === 'Еда' ? '#3b82f6' : - cat === 'Транспорт' ? '#10b981' : - cat === 'Развлечения' ? '#ec4899' : - cat === 'Жилье' ? '#f59e0b' : - cat === 'Одежда' ? '#8b5cf6' : - cat === 'Здоровье' ? '#ef4444' : - cat === 'Гигиена' ? '#06b6d4' : '#6b7280' - })).sort((a, b) => b.value - a.value); - - // Weekly stats for simple SVG chart (last 7 days of expenses) - const getLast7DaysExpenses = () => { - const expenses = new Array(7).fill(0).map((_, i) => { - const d = new Date(); - d.setDate(d.getDate() - i); - return { - dateStr: d.toISOString().slice(0, 10), - dayName: d.toLocaleDateString('ru-RU', { weekday: 'short' }), - amount: 0 - }; - }).reverse(); - - transactions.forEach(t => { - if (t.type === 'expense') { - const item = expenses.find(e => e.dateStr === t.date); - if (item) { - item.amount += t.amount; - } - } - }); - - return expenses; - }; - - const weeklyExpenses = getLast7DaysExpenses(); - const maxWeeklyExpense = Math.max(...weeklyExpenses.map(w => w.amount), 100); - - // Total recurring subscriptions cost - const totalSubCost = subscriptions - .filter(s => s.active === 1) - .reduce((sum, s) => sum + s.amount, 0); - - // Format currency helper - const formatCurrency = (val) => { - return new Intl.NumberFormat('ru-RU', { style: 'currency', currency: 'RUB', maximumFractionDigits: 0 }).format(val); - }; - - // --- RENDER SCREEN --- - - if (isLoading) { - return ( -
-
- -

Загрузка финансов...

-
-
- ); - } - - // Not authenticated screen (Magic Link required or developer mock button) - if (!isAuthenticated) { - return ( -
-
-
- -
-

Личный Финансовый Трекер

-

Доступ ограничен. Войдите через Telegram-бота

- -
- Напишите нашему боту в Telegram и нажмите кнопку 📊 Открыть Dashboard.

- Или запросите ссылку на ПК с помощью команды /login в чате с ботом. -
- - {/* Dev Bypass Button */} - -
-
- ); - } - - return ( -
- {/* Sidebar - Desktop */} - - - {/* Bottom Nav - Mobile */} - - - {/* Main Content Area */} -
- {/* Messages */} - {successMsg &&
{successMsg}
} - {error &&
{error}
} - - {/* --- TAB: DASHBOARD --- */} - {activeTab === 'dashboard' && ( -
-
-

Финансовая Панель

- -
- - {/* Metrics */} -
-
-
- Общий Баланс - -
-
{formatCurrency(profile.balance)}
-
- -
-
- Доходы за месяц - -
-
{formatCurrency(profile.totalIncome)}
-
- -
-
- Расходы за месяц - -
-
{formatCurrency(profile.totalExpense)}
-
- -
-
- Регулярные подписки - -
-
{formatCurrency(totalSubCost)} / мес
-
-
- - {/* Charts */} -
- {/* Daily Bar Chart */} -
-
- Расходы за последние 7 дней - -
- - {/* SVG Bar Chart */} -
- {weeklyExpenses.map((w, index) => { - const pctHeight = maxWeeklyExpense > 0 ? (w.amount / maxWeeklyExpense) * 100 : 0; - return ( -
-
- {w.amount > 0 ? Math.round(w.amount) : ''} -
- {/* Bar */} -
0 ? 'linear-gradient(to top, var(--primary), var(--secondary))' : 'rgba(255, 255, 255, 0.05)', - borderRadius: '6px 6px 0 0', - boxShadow: w.amount > 0 ? '0 0 10px rgba(168, 85, 247, 0.3)' : 'none', - transition: 'height 0.5s ease' - }} - title={`${w.dateStr}: ${formatCurrency(w.amount)}`} - /> -
- {w.dayName} -
-
- ); - })} -
-
- - {/* Category Breakdown */} -
-
- Категории расходов -
- -
- {categoryList.length > 0 ? ( - categoryList.map((c, i) => { - const totalExpensesVal = profile.totalExpense || 1; - const percent = ((c.value / totalExpensesVal) * 100).toFixed(0); - return ( -
-
- {c.name} - {formatCurrency(c.value)} ({percent}%) -
-
-
-
-
- ); - }) - ) : ( -
- Нет расходов за этот период -
- )} -
-
-
- - {/* Quick list of upcoming subscriptions */} -
-

Ближайшие платежи по подпискам

-
- {subscriptions.filter(s => s.active === 1).length > 0 ? ( - - - - - - - - - - - {subscriptions - .filter(s => s.active === 1) - .slice(0, 5) - .map(s => ( - - - - - - - ))} - -
НазваниеСтоимостьПериодСлед. платеж
{s.name}{formatCurrency(s.amount)}{s.period === 'monthly' ? 'Ежемесячно' : 'Ежегодно'}{s.next_payment_date}
- ) : ( -

Активных подписок не найдено

- )} -
-
-
- )} - - {/* --- TAB: TRANSACTIONS --- */} - {activeTab === 'transactions' && ( -
-
-

История транзакций

- -
- - {/* Filters */} -
-
- Тип: - -
- -
- Категория: - -
-
- - {/* Transactions Table */} -
-
- {transactions.length > 0 ? ( - - - - - - - - - - - - - {transactions - .filter(t => txFilter === 'all' || t.type === txFilter) - .filter(t => txCategoryFilter === 'all' || t.category === txCategoryFilter) - .map(t => ( - - - - - - - - - ))} - -
ТипКатегорияОписаниеДатаСуммаДействия
- - {t.type === 'income' ? 'Доход' : 'Расход'} - - {t.category}{t.description || '—'}{t.date} - - {t.type === 'income' ? '+' : '-'} {formatCurrency(t.amount)} - - - -
- ) : ( -

Транзакций не найдено

- )} -
-
-
- )} - - {/* --- TAB: SUBSCRIPTIONS --- */} - {activeTab === 'subscriptions' && ( -
-
-

Мои Подписки

- -
- -
- {subscriptions.length > 0 ? ( - subscriptions.map(s => ( -
-
- {s.name} - -
- -
{formatCurrency(s.amount)} / мес
- -
-
Период: {s.period === 'monthly' ? 'Ежемесячно' : 'Ежегодно'}
-
След. оплата: {s.next_payment_date}
-
- -
- -
-
- )) - ) : ( -
- Нет подписок. Создайте новую подписку здесь или в боте с помощью /sub! -
- )} -
-
- )} - - {/* --- TAB: SHOPPING --- */} - {activeTab === 'shopping' && ( -
-
-

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

- -
- -
- {/* HIGH PRIORITY */} -
-

🔥 Высокий приоритет

- {shoppingList.filter(i => i.bought === 0 && i.priority === 'high').map(item => ( -
-
- {item.item_name} - {item.estimated_price > 0 ? formatCurrency(item.estimated_price) : 'Цена не указана'} -
-
- - -
-
- ))} -
- - {/* MEDIUM PRIORITY */} -
-

⚡ Средний приоритет

- {shoppingList.filter(i => i.bought === 0 && i.priority === 'medium').map(item => ( -
-
- {item.item_name} - {item.estimated_price > 0 ? formatCurrency(item.estimated_price) : 'Цена не указана'} -
-
- - -
-
- ))} -
- - {/* LOW PRIORITY */} -
-

🌱 Низкий приоритет

- {shoppingList.filter(i => i.bought === 0 && i.priority === 'low').map(item => ( -
-
- {item.item_name} - {item.estimated_price > 0 ? formatCurrency(item.estimated_price) : 'Цена не указана'} -
-
- - -
-
- ))} -
- - {/* ALREADY BOUGHT */} -
-

✅ Куплено

- {shoppingList.filter(i => i.bought === 1).map(item => ( -
-
- {item.item_name} - {item.estimated_price > 0 ? formatCurrency(item.estimated_price) : ''} -
-
- -
-
- ))} -
-
-
- )} -
- - {/* --- MODAL: ADD TRANSACTION --- */} - {showTxModal && ( -
-
-
-

Новая операция

- -
- -
-
- Тип операции - -
- -
- Сумма (₽) - setTxForm({ ...txForm, amount: e.target.value })} - className="form-input" - placeholder="0.00" - required - id="form-tx-amount" - /> -
- -
- Категория - {txForm.type === 'income' ? ( - - ) : ( - - )} -
- -
- Описание - setTxForm({ ...txForm, description: e.target.value })} - className="form-input" - placeholder="Например, Обед в ресторане" - id="form-tx-description" - /> -
- -
- Дата - setTxForm({ ...txForm, date: e.target.value })} - className="form-input" - required - id="form-tx-date" - /> -
- - -
-
-
- )} - - {/* --- MODAL: ADD SUBSCRIPTION --- */} - {showSubModal && ( -
-
-
-

Новая подписка

- -
- -
-
- Название - setSubForm({ ...subForm, name: e.target.value })} - className="form-input" - placeholder="Например, Netflix" - required - id="form-sub-name" - /> -
- -
- Стоимость (₽) - setSubForm({ ...subForm, amount: e.target.value })} - className="form-input" - placeholder="0.00" - required - id="form-sub-amount" - /> -
- -
- Периодичность - -
- -
- Дата следующего списания - setSubForm({ ...subForm, next_payment_date: e.target.value })} - className="form-input" - required - id="form-sub-date" - /> -
- - -
-
-
- )} - - {/* --- MODAL: ADD SHOPPING ITEM --- */} - {showShoppingModal && ( -
-
-
-

Добавить покупку

- -
- -
-
- Что нужно купить - setShoppingForm({ ...shoppingForm, item_name: e.target.value })} - className="form-input" - placeholder="Например, Хлеб" - required - id="form-shop-name" - /> -
- -
- Ориентировочная стоимость (₽) - setShoppingForm({ ...shoppingForm, estimated_price: e.target.value })} - className="form-input" - placeholder="0.00 (необязательно)" - id="form-shop-price" - /> -
- -
- Приоритет покупки - -
- - -
-
-
- )} -
- ); +import React from 'react'; +import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; +import Layout from './components/Layout'; +import Auth from './pages/Auth'; +import Dashboard from './pages/Dashboard'; +import Transactions from './pages/Transactions'; +import Debts from './pages/Debts'; +import Incomes from './pages/Incomes'; +import Settings from './pages/Settings'; + +function PrivateRoute({ children }) { + const token = localStorage.getItem('token'); + return token ? {children} : ; } -export default App; +export default function App() { + return ( + + + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +} diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx new file mode 100644 index 0000000..95e2bf1 --- /dev/null +++ b/frontend/src/components/Layout.jsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { Link, useLocation, useNavigate } from 'react-router-dom'; +import { Home, List, CreditCard, Banknote, Settings, LogOut } from 'lucide-react'; + +export default function Layout({ children }) { + const location = useLocation(); + const navigate = useNavigate(); + + const handleLogout = () => { + localStorage.removeItem('token'); + localStorage.removeItem('user'); + navigate('/auth'); + }; + + const navItems = [ + { path: '/', label: 'Дашборд', icon: }, + { path: '/transactions', label: 'Транзакции', icon: }, + { path: '/incomes', label: 'Регулярные Доходы', icon: }, + { path: '/debts', label: 'Долги', icon: }, + { path: '/settings', label: 'Настройки', icon: }, + ]; + + return ( +
+ +
+ {children} +
+
+ ); +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 632761e..86da61b 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,759 +1,255 @@ -/* Premium Minimalistic Dark Theme Design System */ - :root { - /* Colors */ - --bg-color: #090a0f; - - --card-bg: #11141e; - --card-border: #1e2330; - --card-border-hover: #2d3446; - - --primary: #8b5cf6; - --secondary: #06b6d4; - - --income: #10b981; - --income-bg: rgba(16, 185, 129, 0.08); - --expense: #f43f5e; - --expense-bg: rgba(244, 63, 94, 0.08); - - --text-primary: #f3f4f6; - --text-secondary: #9ca3af; - --text-muted: #4b5563; - - --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); - - /* Fonts */ - --font-sans: 'Inter', system-ui, -apple-system, sans-serif; - --font-display: 'Outfit', sans-serif; + --bg-primary: #121212; + --bg-secondary: #1e1e1e; + --bg-card: #252526; + --text-primary: #e0e0e0; + --text-secondary: #a0a0a0; + --accent-color: #007acc; + --accent-hover: #0098ff; + --danger: #d32f2f; + --success: #388e3c; + --border-color: #333333; + --font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; } * { box-sizing: border-box; margin: 0; padding: 0; - font-family: var(--font-sans); - -webkit-tap-highlight-color: transparent; } body { - background: var(--bg-color); + background-color: var(--bg-primary); color: var(--text-primary); - min-height: 100vh; - overflow-x: hidden; + font-family: var(--font-family); + -webkit-font-smoothing: antialiased; line-height: 1.5; } -/* Custom Scrollbar */ -::-webkit-scrollbar { - width: 5px; - height: 5px; +h1, h2, h3, h4 { + font-weight: 600; + color: #ffffff; } -::-webkit-scrollbar-track { - background: transparent; + +a { + color: var(--accent-color); + text-decoration: none; } -::-webkit-scrollbar-thumb { - background: #1f2937; + +a:hover { + text-decoration: underline; +} + +button { + cursor: pointer; + font-family: inherit; + border: none; + background: var(--accent-color); + color: #fff; + padding: 0.5rem 1rem; border-radius: 4px; + font-size: 0.9rem; + font-weight: 500; + transition: background 0.2s; } -::-webkit-scrollbar-thumb:hover { - background: var(--text-muted); + +button:hover { + background: var(--accent-hover); +} + +button.secondary { + background: transparent; + border: 1px solid var(--border-color); + color: var(--text-primary); +} + +button.secondary:hover { + background: var(--bg-secondary); +} + +button.danger { + background: var(--danger); +} + +button.danger:hover { + background: #f44336; +} + +input, select { + width: 100%; + padding: 0.6rem; + margin-bottom: 1rem; + background: var(--bg-card); + border: 1px solid var(--border-color); + color: var(--text-primary); + border-radius: 4px; + font-family: inherit; +} + +input:focus, select:focus { + outline: none; + border-color: var(--accent-color); } /* Layout */ -#root { - min-height: 100vh; - display: flex; - flex-direction: column; -} - .app-container { display: flex; min-height: 100vh; - width: 100%; - max-width: 1400px; - margin: 0 auto; } -@media (max-width: 768px) { - .app-container { - flex-direction: column; - padding-bottom: 80px; - } -} - -/* Minimalist Panels */ -.glass-panel { - background: var(--card-bg); - border: 1px solid var(--card-border); - border-radius: 12px; - box-shadow: var(--shadow); - transition: border-color 0.2s ease, transform 0.2s ease; -} - -.glass-panel:hover { - border-color: var(--card-border-hover); -} - -.glass-panel.interactive:hover { - transform: translateY(-2px); -} - -/* Sidebar Navigation */ .sidebar { width: 250px; - padding: 35px 20px; + background-color: var(--bg-secondary); + border-right: 1px solid var(--border-color); display: flex; flex-direction: column; - border-right: 1px solid var(--card-border); - background: #0b0d14; - position: sticky; - top: 0; - height: 100vh; } -.logo-container { - display: flex; - align-items: center; - gap: 10px; - margin-bottom: 40px; - padding-left: 5px; +.sidebar-header { + padding: 1.5rem; + font-size: 1.25rem; + font-weight: 700; + border-bottom: 1px solid var(--border-color); + color: #fff; } -.logo-icon { - color: var(--primary); - display: flex; - align-items: center; - justify-content: center; -} - -.logo-text { - font-family: var(--font-display); - font-size: 19px; - font-weight: 600; - letter-spacing: -0.3px; - color: #ffffff; -} - -.nav-links { +.sidebar-nav { display: flex; flex-direction: column; - gap: 6px; - list-style: none; + padding: 1rem 0; + flex: 1; } -.nav-link-btn { - width: 100%; - display: flex; - align-items: center; - gap: 12px; - padding: 10px 14px; - background: transparent; - border: none; - border-radius: 8px; +.nav-item { + padding: 0.75rem 1.5rem; color: var(--text-secondary); - font-size: 14px; - font-weight: 500; - cursor: pointer; - transition: all 0.15s ease; - text-align: left; -} - -.nav-link-btn:hover { - background: rgba(255, 255, 255, 0.03); - color: var(--text-primary); -} - -.nav-link-btn.active { - background: rgba(255, 255, 255, 0.05); - border: 1px solid var(--card-border-hover); - color: #ffffff; -} - -/* User Info Sidebar */ -.user-profile { - margin-top: auto; - padding: 12px; display: flex; align-items: center; - gap: 10px; - border-radius: 8px; - border: 1px solid var(--card-border); - background: rgba(255, 255, 255, 0.01); + gap: 0.75rem; + text-decoration: none; } -.user-avatar { - width: 34px; - height: 34px; - border-radius: 50%; - background: var(--card-border-hover); - display: flex; - align-items: center; - justify-content: center; - font-family: var(--font-display); - font-weight: 600; - font-size: 14px; +.nav-item:hover, .nav-item.active { + background-color: var(--bg-card); color: var(--text-primary); + text-decoration: none; } -.user-details { - display: flex; - flex-direction: column; - overflow: hidden; +.sidebar-footer { + padding: 1rem; + border-top: 1px solid var(--border-color); } -.user-name { - font-weight: 500; - font-size: 13px; - color: var(--text-primary); - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; -} - -.user-role { - font-size: 11px; - color: var(--text-secondary); -} - -/* Main Content */ .main-content { flex: 1; - padding: 50px 60px; + padding: 2rem; overflow-y: auto; } -@media (max-width: 768px) { - .main-content { - padding: 24px 20px; - } - .sidebar { - display: none; - } -} - -/* Mobile Bottom Navigation */ -.mobile-nav { - display: none; - position: fixed; - bottom: 0; - left: 0; - right: 0; - height: 65px; - background: #0b0d14; - border-top: 1px solid var(--card-border); - z-index: 100; - justify-content: space-around; - align-items: center; -} - -@media (max-width: 768px) { - .mobile-nav { - display: flex; - } -} - -.mobile-nav-btn { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 3px; - background: transparent; - border: none; - color: var(--text-secondary); - font-size: 10px; - font-weight: 500; - cursor: pointer; -} - -.mobile-nav-btn.active { - color: var(--primary); -} - -.mobile-nav-btn svg { - width: 20px; - height: 20px; -} - -/* Metrics Dashboard */ -.metrics-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 16px; - margin-bottom: 24px; -} - -.metric-card { - padding: 20px; -} - -.metric-header { +.page-header { + margin-bottom: 2rem; display: flex; justify-content: space-between; align-items: center; - color: var(--text-secondary); - font-size: 13px; - margin-bottom: 8px; } -.metric-icon { - color: var(--text-muted); +/* Cards & Tables */ +.card { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 1.5rem; + margin-bottom: 1.5rem; } -.metric-value { - font-family: var(--font-display); - font-size: 24px; - font-weight: 600; - letter-spacing: -0.3px; -} - -/* Charts Layout */ -.charts-grid { +.dashboard-grid { display: grid; - grid-template-columns: 1.5fr 1fr; - gap: 20px; - margin-bottom: 24px; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 1.5rem; } -@media (max-width: 1024px) { - .charts-grid { - grid-template-columns: 1fr; - } +.stat-card { + text-align: center; } -.chart-card { - padding: 20px; - min-height: 320px; - display: flex; - flex-direction: column; -} - -.chart-title { - font-family: var(--font-display); - font-size: 15px; - font-weight: 600; - color: var(--text-primary); - margin-bottom: 20px; -} - -/* Minimal Buttons */ -.btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 6px; - padding: 8px 14px; - border-radius: 6px; - font-size: 13px; - font-weight: 500; - cursor: pointer; - transition: all 0.15s ease; - border: 1px solid transparent; - outline: none; -} - -.btn:active { - transform: scale(0.98); -} - -.btn-primary { - background: var(--primary); - color: white; -} - -.btn-primary:hover { - background: #7c3aed; -} - -.btn-secondary { - background: rgba(255, 255, 255, 0.02); - border-color: var(--card-border); - color: var(--text-primary); -} - -.btn-secondary:hover { - background: rgba(255, 255, 255, 0.05); - border-color: var(--card-border-hover); -} - -.btn-danger { - background: var(--expense-bg); - border-color: rgba(244, 63, 94, 0.15); - color: #fb7185; -} - -.btn-danger:hover { - background: rgba(244, 63, 94, 0.12); -} - -.btn-success { - background: var(--income-bg); - border-color: rgba(16, 185, 129, 0.15); - color: #34d399; -} - -.btn-success:hover { - background: rgba(16, 185, 129, 0.12); -} - -/* Forms */ -.form-group { - display: flex; - flex-direction: column; - gap: 4px; - margin-bottom: 12px; -} - -.form-label { - font-size: 12px; - font-weight: 500; +.stat-card h3 { color: var(--text-secondary); + font-size: 0.9rem; + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: 0.5rem; } -.form-input { - width: 100%; - padding: 10px 12px; - background: #090a0f; - border: 1px solid var(--card-border); - border-radius: 6px; - color: white; - font-size: 13px; - transition: border-color 0.15s ease; - outline: none; +.stat-card .value { + font-size: 2rem; + font-weight: 700; } -.form-input:focus { - border-color: var(--primary); -} +.stat-card .value.income { color: var(--success); } +.stat-card .value.expense { color: var(--danger); } -.form-select { - appearance: none; - background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e"); - background-repeat: no-repeat; - background-position: right 10px center; - background-size: 14px; - padding-right: 32px; -} - -/* Modal Popup */ -.modal-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.6); - z-index: 1000; - display: flex; - align-items: center; - justify-content: center; - padding: 16px; -} - -.modal-content { - width: 100%; - max-width: 400px; - padding: 24px; - position: relative; -} - -.modal-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 16px; -} - -.modal-title { - font-family: var(--font-display); - font-size: 17px; - font-weight: 600; -} - -.modal-close { - background: transparent; - border: none; - color: var(--text-secondary); - cursor: pointer; - display: flex; -} - -.modal-close:hover { - color: white; -} - -/* Tables & Lists */ -.table-container { - overflow-x: auto; -} - -.data-table { +table { width: 100%; border-collapse: collapse; +} + +th, td { + padding: 1rem; text-align: left; + border-bottom: 1px solid var(--border-color); } -.data-table th { - padding: 10px 12px; - font-size: 11px; - font-weight: 600; +th { color: var(--text-secondary); - border-bottom: 1px solid var(--card-border); + font-weight: 500; + font-size: 0.85rem; text-transform: uppercase; - letter-spacing: 0.5px; } -.data-table td { - padding: 12px 12px; - font-size: 13px; - border-bottom: 1px solid rgba(255, 255, 255, 0.02); +tr:hover { + background-color: rgba(255, 255, 255, 0.02); } -.data-table tr:last-child td { - border-bottom: none; -} - -.data-table tr:hover td { - background: rgba(255, 255, 255, 0.01); -} - -/* Transaction Styles */ -.tx-type-indicator { - display: inline-flex; - padding: 2px 6px; - border-radius: 4px; - font-size: 10px; - font-weight: 600; -} - -.tx-type-indicator.income { - background: var(--income-bg); - color: var(--income); -} - -.tx-type-indicator.expense { - background: var(--expense-bg); - color: var(--expense); -} - -.tx-amount { - font-family: var(--font-display); - font-weight: 500; -} -.tx-amount.income { color: var(--income); } -.tx-amount.expense { color: var(--expense); } - -/* Subscriptions */ -.sub-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); - gap: 16px; -} - -.sub-card { - padding: 20px; - display: flex; - flex-direction: column; - position: relative; -} - -.sub-card.inactive { - opacity: 0.5; -} - -.sub-header { - display: flex; - justify-content: space-between; - align-items: flex-start; - margin-bottom: 12px; -} - -.sub-name { - font-family: var(--font-display); - font-size: 16px; - font-weight: 500; -} - -.sub-amount { - font-family: var(--font-display); - font-size: 20px; - font-weight: 600; - color: white; - margin-bottom: 8px; -} - -.sub-details { - display: flex; - flex-direction: column; - gap: 4px; - margin-bottom: 16px; - font-size: 12px; - color: var(--text-secondary); -} - -.sub-actions { - display: flex; - gap: 8px; - margin-top: auto; -} - -/* Shopping List */ -.shopping-columns { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); - gap: 20px; - margin-bottom: 24px; -} - -.shopping-column { - display: flex; - flex-direction: column; - gap: 12px; -} - -.column-title { - font-family: var(--font-display); - font-size: 14px; - font-weight: 600; - display: flex; - align-items: center; - gap: 6px; - padding-bottom: 6px; - border-bottom: 1px solid var(--card-border); -} - -.column-title.high { color: var(--expense); } -.column-title.medium { color: #eab308; } -.column-title.low { color: var(--secondary); } -.column-title.bought { color: var(--text-muted); } - -.shopping-card { - padding: 12px 14px; - display: flex; - align-items: center; - justify-content: space-between; -} - -.shopping-item-info { - display: flex; - flex-direction: column; - gap: 2px; - overflow: hidden; -} - -.shopping-item-name { - font-weight: 500; - font-size: 14px; - color: var(--text-primary); -} - -.shopping-card.bought-item .shopping-item-name { - text-decoration: line-through; - color: var(--text-muted); -} - -.shopping-item-price { - font-size: 11px; - color: var(--text-secondary); -} - -.shopping-item-actions { - display: flex; - align-items: center; - gap: 6px; -} - -/* Alerts */ -.alert { - padding: 10px 14px; - border-radius: 6px; - margin-bottom: 16px; - font-size: 13px; - display: flex; - align-items: center; - gap: 8px; -} - -.alert-success { - background: var(--income-bg); - border: 1px solid rgba(16, 185, 129, 0.1); - color: #34d399; -} - -.alert-error { - background: var(--expense-bg); - border: 1px solid rgba(244, 63, 94, 0.1); - color: #fb7185; -} - -/* Headings */ -.page-title { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 24px; -} - -.page-title h1 { - font-family: var(--font-display); - font-size: 24px; - font-weight: 600; - letter-spacing: -0.4px; - color: #ffffff; -} - -/* Login container */ -.login-container { +/* Auth Pages */ +.auth-container { display: flex; align-items: center; justify-content: center; min-height: 100vh; - padding: 16px; + background: var(--bg-primary); } -.login-card { +.auth-box { + background: var(--bg-card); + padding: 2.5rem; + border-radius: 8px; + border: 1px solid var(--border-color); width: 100%; - max-width: 380px; - padding: 30px; + max-width: 400px; +} + +.auth-box h2 { + margin-bottom: 1.5rem; text-align: center; +} + +.auth-box form { display: flex; flex-direction: column; - align-items: center; } -.login-icon-large { - color: var(--primary); - margin-bottom: 20px; +.auth-box .toggle-link { + margin-top: 1rem; + text-align: center; + font-size: 0.9rem; } -.login-title { - font-family: var(--font-display); - font-size: 20px; - font-weight: 600; - margin-bottom: 8px; +.form-group { + margin-bottom: 1rem; } - -.login-subtitle { +.form-group label { + display: block; + margin-bottom: 0.5rem; color: var(--text-secondary); - margin-bottom: 24px; - font-size: 13px; -} - -.login-instruction { - background: rgba(255, 255, 255, 0.01); - border: 1px solid var(--card-border); - padding: 12px; - border-radius: 8px; - font-size: 12px; - color: var(--text-secondary); - margin-top: 20px; - line-height: 1.5; -} - -.login-instruction code { - color: var(--secondary); - font-family: monospace; - background: rgba(0, 0, 0, 0.2); - padding: 1px 4px; - border-radius: 3px; + font-size: 0.85rem; } diff --git a/frontend/src/pages/Auth.jsx b/frontend/src/pages/Auth.jsx new file mode 100644 index 0000000..24a96ee --- /dev/null +++ b/frontend/src/pages/Auth.jsx @@ -0,0 +1,69 @@ +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +export default function Auth() { + const [isLogin, setIsLogin] = useState(true); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [firstName, setFirstName] = useState(''); + const [error, setError] = useState(''); + const navigate = useNavigate(); + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(''); + + const url = isLogin ? '/api/auth/login' : '/api/auth/register'; + const payload = isLogin ? { email, password } : { email, password, first_name: firstName }; + + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + const data = await res.json(); + if (!res.ok) { + throw new Error(data.error || 'Ошибка авторизации'); + } + + localStorage.setItem('token', data.token); + localStorage.setItem('user', JSON.stringify(data.user)); + navigate('/'); + } catch (err) { + setError(err.message); + } + }; + + return ( +
+
+

{isLogin ? 'Вход в систему' : 'Регистрация'}

+ {error &&
{error}
} +
+ {!isLogin && ( +
+ + setFirstName(e.target.value)} required /> +
+ )} +
+ + setEmail(e.target.value)} required /> +
+
+ + setPassword(e.target.value)} required /> +
+ +
+ +
+
+ ); +} diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx new file mode 100644 index 0000000..e6ce18c --- /dev/null +++ b/frontend/src/pages/Dashboard.jsx @@ -0,0 +1,59 @@ +import React, { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; + +export default function Dashboard() { + const [data, setData] = useState(null); + + useEffect(() => { + const fetchProfile = async () => { + const token = localStorage.getItem('token'); + try { + const res = await fetch('/api/user/profile', { + headers: { 'Authorization': `Bearer ${token}` } + }); + if (res.ok) { + const json = await res.json(); + setData(json); + } + } catch (err) { + console.error(err); + } + }; + fetchProfile(); + }, []); + + if (!data) return
Загрузка...
; + + return ( +
+
+

Сводка

+

Добро пожаловать, {data.user.first_name || data.user.email}

+
+ +
+
+

Текущий Баланс

+
{data.balance.toFixed(2)} ₽
+
+
+

Всего Доходов

+
+{data.totalIncome.toFixed(2)} ₽
+
+
+

Всего Расходов

+
-{data.totalExpense.toFixed(2)} ₽
+
+
+ +
+

Быстрые действия

+
+ + + +
+
+
+ ); +} diff --git a/frontend/src/pages/Debts.jsx b/frontend/src/pages/Debts.jsx new file mode 100644 index 0000000..eecdffd --- /dev/null +++ b/frontend/src/pages/Debts.jsx @@ -0,0 +1,87 @@ +import React, { useEffect, useState } from 'react'; + +export default function Debts() { + const [debts, setDebts] = useState([]); + const [personName, setPersonName] = useState(''); + const [amount, setAmount] = useState(''); + const [type, setType] = useState('owe'); + const [dueDate, setDueDate] = useState(''); + + const fetchDebts = async () => { + const token = localStorage.getItem('token'); + const res = await fetch('/api/debts', { headers: { 'Authorization': `Bearer ${token}` } }); + if (res.ok) setDebts(await res.json()); + }; + + useEffect(() => { fetchDebts(); }, []); + + const handleSubmit = async (e) => { + e.preventDefault(); + const token = localStorage.getItem('token'); + await fetch('/api/debts', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, + body: JSON.stringify({ person_name: personName, amount, type, due_date: dueDate }) + }); + setPersonName(''); setAmount(''); fetchDebts(); + }; + + const handlePay = async (id) => { + const token = localStorage.getItem('token'); + await fetch(`/api/debts/${id}/pay`, { method: 'PUT', headers: { 'Authorization': `Bearer ${token}` } }); + fetchDebts(); + }; + + return ( +
+
+

Мои Долги

+
+ +
+

Добавить Долг

+
+ + setPersonName(e.target.value)} required style={{ flex: 1 }} /> + setAmount(e.target.value)} required style={{ width: '150px' }} /> + setDueDate(e.target.value)} style={{ width: '150px' }} title="Дата возврата" /> + +
+
+ +
+ + + + + + + + + + + + + {debts.map(d => ( + + + + + + + + + ))} + +
Кто/КомуТипСуммаДата возвратаСтатусДействия
{d.person_name}{d.type === 'owe' ? Я должен : Мне должны}{d.amount} ₽{d.due_date || 'Без срока'}{d.status === 'paid' ? '✅ Оплачено' : '⏳ Ожидается'} + {d.status === 'pending' && ( + + )} +
+
+
+ ); +} diff --git a/frontend/src/pages/Incomes.jsx b/frontend/src/pages/Incomes.jsx new file mode 100644 index 0000000..31d1b69 --- /dev/null +++ b/frontend/src/pages/Incomes.jsx @@ -0,0 +1,77 @@ +import React, { useEffect, useState } from 'react'; + +export default function Incomes() { + const [incomes, setIncomes] = useState([]); + const [sourceName, setSourceName] = useState(''); + const [amount, setAmount] = useState(''); + const [dayOfMonth, setDayOfMonth] = useState('1'); + + const fetchIncomes = async () => { + const token = localStorage.getItem('token'); + const res = await fetch('/api/incomes', { headers: { 'Authorization': `Bearer ${token}` } }); + if (res.ok) setIncomes(await res.json()); + }; + + useEffect(() => { fetchIncomes(); }, []); + + const handleSubmit = async (e) => { + e.preventDefault(); + const token = localStorage.getItem('token'); + await fetch('/api/incomes', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, + body: JSON.stringify({ source_name: sourceName, amount, day_of_month: dayOfMonth }) + }); + setSourceName(''); setAmount(''); fetchIncomes(); + }; + + const handleDelete = async (id) => { + const token = localStorage.getItem('token'); + await fetch(`/api/incomes/${id}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${token}` } }); + fetchIncomes(); + }; + + return ( +
+
+

Регулярные Доходы

+
+ +
+

Добавить Зарплату / Регулярный доход

+

+ Укажите день месяца, и система будет автоматически начислять эту сумму каждый месяц. +

+
+ setSourceName(e.target.value)} required style={{ flex: 1 }} /> + setAmount(e.target.value)} required style={{ width: '150px' }} /> + setDayOfMonth(e.target.value)} required style={{ width: '150px' }} title="День месяца (1-31)" /> + +
+
+ +
+ + + + + + + + + + + {incomes.map(inc => ( + + + + + + + ))} + +
ИсточникСуммаДень начисленияДействия
{inc.source_name}+{inc.amount} ₽{inc.day_of_month} число
+
+
+ ); +} diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx new file mode 100644 index 0000000..73b3634 --- /dev/null +++ b/frontend/src/pages/Settings.jsx @@ -0,0 +1,46 @@ +import React, { useState } from 'react'; + +export default function Settings() { + const [code, setCode] = useState(''); + + const generateCode = async () => { + const token = localStorage.getItem('token'); + const res = await fetch('/api/auth/generate-link-code', { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}` } + }); + if (res.ok) { + const data = await res.json(); + setCode(data.code); + } + }; + + return ( +
+
+

Настройки

+
+ +
+

Привязка Telegram

+

+ Чтобы получать уведомления о подписках и долгах, привяжите свой аккаунт к Telegram-боту. + Нажмите кнопку ниже, чтобы сгенерировать временный код, и отправьте его боту. +

+ + + + {code && ( +
+

Ваш код привязки:

+

{code}

+

+ Скопируйте этот код и отправьте боту команду:
+ /link {code} +

+
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/Transactions.jsx b/frontend/src/pages/Transactions.jsx new file mode 100644 index 0000000..9fe2d0f --- /dev/null +++ b/frontend/src/pages/Transactions.jsx @@ -0,0 +1,87 @@ +import React, { useEffect, useState } from 'react'; + +export default function Transactions() { + const [transactions, setTransactions] = useState([]); + const [type, setType] = useState('expense'); + const [amount, setAmount] = useState(''); + const [category, setCategory] = useState(''); + const [description, setDescription] = useState(''); + const [date, setDate] = useState(new Date().toISOString().slice(0, 10)); + + const fetchTxs = async () => { + const token = localStorage.getItem('token'); + const res = await fetch('/api/transactions', { headers: { 'Authorization': `Bearer ${token}` } }); + if (res.ok) setTransactions(await res.json()); + }; + + useEffect(() => { fetchTxs(); }, []); + + const handleSubmit = async (e) => { + e.preventDefault(); + const token = localStorage.getItem('token'); + await fetch('/api/transactions', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, + body: JSON.stringify({ type, amount, category, description, date }) + }); + setAmount(''); setCategory(''); setDescription(''); fetchTxs(); + }; + + const handleDelete = async (id) => { + const token = localStorage.getItem('token'); + await fetch(`/api/transactions/${id}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${token}` } }); + fetchTxs(); + }; + + return ( +
+
+

Транзакции

+
+ +
+

Добавить Транзакцию

+
+ + setAmount(e.target.value)} required style={{ width: '150px' }} /> + setCategory(e.target.value)} required style={{ width: '200px' }} /> + setDescription(e.target.value)} style={{ flex: 1 }} /> + setDate(e.target.value)} required style={{ width: '150px' }} /> + +
+
+ +
+ + + + + + + + + + + + + {transactions.map(tx => ( + + + + + + + + + ))} + +
ДатаТипКатегорияОписаниеСуммаДействия
{tx.date}{tx.type === 'income' ? Доход : Расход}{tx.category}{tx.description} + {tx.type === 'income' ? '+' : '-'}{tx.amount} ₽ +
+
+
+ ); +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 8b0f57b..7769dc6 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -4,4 +4,12 @@ import react from '@vitejs/plugin-react' // https://vite.dev/config/ export default defineConfig({ plugins: [react()], + server: { + proxy: { + '/api': { + target: 'http://localhost:5000', + changeOrigin: true + } + } + } })