mirror of
https://github.com/AmanIbrash-brsh/finance-website.git
synced 2026-11-09 09:35:10 +00:00
полностью изменил структуру
This commit is contained in:
Generated
+142
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
+20
-264
@@ -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'));
|
||||
|
||||
|
||||
+28
-3
@@ -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);
|
||||
|
||||
+207
-296
@@ -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 ')) {
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Unauthorized: No token provided' });
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
const user = await db.get('SELECT * FROM users WHERE auth_token = ?', token);
|
||||
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' };
|
||||
}
|
||||
req.user = user;
|
||||
return next();
|
||||
return res.status(401).json({ error: 'Unauthorized: User not found' });
|
||||
}
|
||||
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
req.user = user;
|
||||
next();
|
||||
} catch (err) {
|
||||
return res.status(401).json({ error: 'Unauthorized: Invalid token' });
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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}`);
|
||||
});
|
||||
app.listen(PORT, () => console.log(`🚀 Server running on port ${PORT}`));
|
||||
} catch (err) {
|
||||
console.error('Fatal initialization error:', err);
|
||||
process.exit(1);
|
||||
|
||||
+90
-81
@@ -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 todayStr = new Date().toISOString().slice(0, 10);
|
||||
const incomes = await db.all('SELECT * FROM constant_incomes');
|
||||
|
||||
// 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+63
-5
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+27
-1229
File diff suppressed because it is too large
Load Diff
@@ -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: <Home size={20} /> },
|
||||
{ path: '/transactions', label: 'Транзакции', icon: <List size={20} /> },
|
||||
{ path: '/incomes', label: 'Регулярные Доходы', icon: <Banknote size={20} /> },
|
||||
{ path: '/debts', label: 'Долги', icon: <CreditCard size={20} /> },
|
||||
{ path: '/settings', label: 'Настройки', icon: <Settings size={20} /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-header">
|
||||
Finance Manager
|
||||
</div>
|
||||
<nav className="sidebar-nav">
|
||||
{navItems.map(item => (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={`nav-item ${location.pathname === item.path ? 'active' : ''}`}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<div className="sidebar-footer">
|
||||
<button className="secondary" onClick={handleLogout} style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem' }}>
|
||||
<LogOut size={18} /> Выйти
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
<main className="main-content">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+156
-660
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="auth-container">
|
||||
<div className="auth-box">
|
||||
<h2>{isLogin ? 'Вход в систему' : 'Регистрация'}</h2>
|
||||
{error && <div style={{ color: 'var(--danger)', marginBottom: '1rem', textAlign: 'center' }}>{error}</div>}
|
||||
<form onSubmit={handleSubmit}>
|
||||
{!isLogin && (
|
||||
<div className="form-group">
|
||||
<label>Имя</label>
|
||||
<input type="text" value={firstName} onChange={e => setFirstName(e.target.value)} required />
|
||||
</div>
|
||||
)}
|
||||
<div className="form-group">
|
||||
<label>Email</label>
|
||||
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Пароль</label>
|
||||
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required />
|
||||
</div>
|
||||
<button type="submit">{isLogin ? 'Войти' : 'Зарегистрироваться'}</button>
|
||||
</form>
|
||||
<div className="toggle-link">
|
||||
<a href="#" onClick={(e) => { e.preventDefault(); setIsLogin(!isLogin); }}>
|
||||
{isLogin ? 'Нет аккаунта? Зарегистрироваться' : 'Уже есть аккаунт? Войти'}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 <div>Загрузка...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1>Сводка</h1>
|
||||
<p style={{ color: 'var(--text-secondary)' }}>Добро пожаловать, {data.user.first_name || data.user.email}</p>
|
||||
</div>
|
||||
|
||||
<div className="dashboard-grid">
|
||||
<div className="card stat-card">
|
||||
<h3>Текущий Баланс</h3>
|
||||
<div className="value">{data.balance.toFixed(2)} ₽</div>
|
||||
</div>
|
||||
<div className="card stat-card">
|
||||
<h3>Всего Доходов</h3>
|
||||
<div className="value income">+{data.totalIncome.toFixed(2)} ₽</div>
|
||||
</div>
|
||||
<div className="card stat-card">
|
||||
<h3>Всего Расходов</h3>
|
||||
<div className="value expense">-{data.totalExpense.toFixed(2)} ₽</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>Быстрые действия</h3>
|
||||
<div style={{ display: 'flex', gap: '1rem', marginTop: '1rem' }}>
|
||||
<Link to="/transactions"><button>Все Транзакции</button></Link>
|
||||
<Link to="/debts"><button className="secondary">Мои Долги</button></Link>
|
||||
<Link to="/incomes"><button className="secondary">Регулярные Доходы</button></Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1>Мои Долги</h1>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>Добавить Долг</h3>
|
||||
<form onSubmit={handleSubmit} style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap', marginTop: '1rem' }}>
|
||||
<select value={type} onChange={e => setType(e.target.value)} style={{ width: '200px' }}>
|
||||
<option value="owe">Я должен кому-то</option>
|
||||
<option value="owed">Мне должны</option>
|
||||
</select>
|
||||
<input type="text" placeholder="Имя человека" value={personName} onChange={e => setPersonName(e.target.value)} required style={{ flex: 1 }} />
|
||||
<input type="number" placeholder="Сумма" value={amount} onChange={e => setAmount(e.target.value)} required style={{ width: '150px' }} />
|
||||
<input type="date" value={dueDate} onChange={e => setDueDate(e.target.value)} style={{ width: '150px' }} title="Дата возврата" />
|
||||
<button type="submit">Сохранить</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Кто/Кому</th>
|
||||
<th>Тип</th>
|
||||
<th>Сумма</th>
|
||||
<th>Дата возврата</th>
|
||||
<th>Статус</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{debts.map(d => (
|
||||
<tr key={d.id}>
|
||||
<td>{d.person_name}</td>
|
||||
<td>{d.type === 'owe' ? <span style={{ color: 'var(--danger)' }}>Я должен</span> : <span style={{ color: 'var(--success)' }}>Мне должны</span>}</td>
|
||||
<td style={{ fontWeight: 'bold' }}>{d.amount} ₽</td>
|
||||
<td>{d.due_date || 'Без срока'}</td>
|
||||
<td>{d.status === 'paid' ? '✅ Оплачено' : '⏳ Ожидается'}</td>
|
||||
<td>
|
||||
{d.status === 'pending' && (
|
||||
<button className="secondary" onClick={() => handlePay(d.id)}>Отметить оплаченным</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1>Регулярные Доходы</h1>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>Добавить Зарплату / Регулярный доход</h3>
|
||||
<p style={{ color: 'var(--text-secondary)', marginBottom: '1rem' }}>
|
||||
Укажите день месяца, и система будет автоматически начислять эту сумму каждый месяц.
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
|
||||
<input type="text" placeholder="Источник (напр. Зарплата)" value={sourceName} onChange={e => setSourceName(e.target.value)} required style={{ flex: 1 }} />
|
||||
<input type="number" placeholder="Сумма" value={amount} onChange={e => setAmount(e.target.value)} required style={{ width: '150px' }} />
|
||||
<input type="number" min="1" max="31" placeholder="День месяца" value={dayOfMonth} onChange={e => setDayOfMonth(e.target.value)} required style={{ width: '150px' }} title="День месяца (1-31)" />
|
||||
<button type="submit">Добавить</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Источник</th>
|
||||
<th>Сумма</th>
|
||||
<th>День начисления</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{incomes.map(inc => (
|
||||
<tr key={inc.id}>
|
||||
<td>{inc.source_name}</td>
|
||||
<td style={{ color: 'var(--success)', fontWeight: 'bold' }}>+{inc.amount} ₽</td>
|
||||
<td>{inc.day_of_month} число</td>
|
||||
<td><button className="danger" onClick={() => handleDelete(inc.id)}>Удалить</button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1>Настройки</h1>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>Привязка Telegram</h3>
|
||||
<p style={{ color: 'var(--text-secondary)', marginBottom: '1rem', maxWidth: '600px' }}>
|
||||
Чтобы получать уведомления о подписках и долгах, привяжите свой аккаунт к Telegram-боту.
|
||||
Нажмите кнопку ниже, чтобы сгенерировать временный код, и отправьте его боту.
|
||||
</p>
|
||||
|
||||
<button onClick={generateCode}>Сгенерировать код</button>
|
||||
|
||||
{code && (
|
||||
<div style={{ marginTop: '1.5rem', padding: '1rem', background: 'rgba(0, 122, 204, 0.1)', border: '1px solid var(--accent-color)', borderRadius: '6px' }}>
|
||||
<p>Ваш код привязки:</p>
|
||||
<h2 style={{ letterSpacing: '2px', color: 'var(--accent-color)', margin: '0.5rem 0' }}>{code}</h2>
|
||||
<p style={{ fontSize: '0.9rem', color: 'var(--text-secondary)' }}>
|
||||
Скопируйте этот код и отправьте боту команду: <br/>
|
||||
<code style={{ background: 'var(--bg-primary)', padding: '0.2rem 0.4rem', borderRadius: '4px', marginTop: '0.5rem', display: 'inline-block' }}>/link {code}</code>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1>Транзакции</h1>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>Добавить Транзакцию</h3>
|
||||
<form onSubmit={handleSubmit} style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap', marginTop: '1rem' }}>
|
||||
<select value={type} onChange={e => setType(e.target.value)} style={{ width: '150px' }}>
|
||||
<option value="expense">Расход</option>
|
||||
<option value="income">Доход</option>
|
||||
</select>
|
||||
<input type="number" placeholder="Сумма" value={amount} onChange={e => setAmount(e.target.value)} required style={{ width: '150px' }} />
|
||||
<input type="text" placeholder="Категория" value={category} onChange={e => setCategory(e.target.value)} required style={{ width: '200px' }} />
|
||||
<input type="text" placeholder="Описание (необязательно)" value={description} onChange={e => setDescription(e.target.value)} style={{ flex: 1 }} />
|
||||
<input type="date" value={date} onChange={e => setDate(e.target.value)} required style={{ width: '150px' }} />
|
||||
<button type="submit">Добавить</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Дата</th>
|
||||
<th>Тип</th>
|
||||
<th>Категория</th>
|
||||
<th>Описание</th>
|
||||
<th>Сумма</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{transactions.map(tx => (
|
||||
<tr key={tx.id}>
|
||||
<td>{tx.date}</td>
|
||||
<td>{tx.type === 'income' ? <span style={{ color: 'var(--success)' }}>Доход</span> : <span style={{ color: 'var(--danger)' }}>Расход</span>}</td>
|
||||
<td>{tx.category}</td>
|
||||
<td>{tx.description}</td>
|
||||
<td style={{ color: tx.type === 'income' ? 'var(--success)' : 'var(--danger)', fontWeight: 'bold' }}>
|
||||
{tx.type === 'income' ? '+' : '-'}{tx.amount} ₽
|
||||
</td>
|
||||
<td><button className="danger" onClick={() => handleDelete(tx.id)}>Удалить</button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user