fix: 404 при обновлении, стрелки периода, фильтры в URL, авто-категории и очистка истории
- Nginx: проксирование /api на backend (единая точка входа) - История: стрелки ← → для переключения недель/месяцев/годов - История: сохранение фильтров и пагинации в URL при F5 - Импорт: миграция 003 — дефолтные правила категорий (PYATEROCHK, AUCHAN и др.) - Настройки: вкладка «Данные» с кнопкой «Очистить историю» - Backend: DELETE /api/transactions для удаления всех транзакций - ClearHistoryModal: подтверждение чекбоксами и вводом «УДАЛИТЬ»
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -15,3 +15,5 @@ npm-debug.log*
|
|||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
coverage/
|
coverage/
|
||||||
|
|
||||||
|
jan_feb.json
|
||||||
|
|||||||
31
Dockerfile.backend
Normal file
31
Dockerfile.backend
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
FROM node:20-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
COPY shared ./shared
|
||||||
|
COPY backend ./backend
|
||||||
|
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
FROM node:20-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY --from=deps /app/package.json ./
|
||||||
|
COPY --from=deps /app/shared ./shared
|
||||||
|
COPY --from=deps /app/backend ./backend
|
||||||
|
|
||||||
|
RUN npm run build -w @family-budget/shared
|
||||||
|
RUN npm run build -w @family-budget/backend
|
||||||
|
|
||||||
|
FROM node:20-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
COPY --from=build /app/backend/dist ./dist
|
||||||
|
COPY --from=build /app/backend/package.json ./package.json
|
||||||
|
COPY --from=build /app/node_modules ./node_modules
|
||||||
|
COPY backend/.env .env
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node","dist/app.js"]
|
||||||
25
Dockerfile.frontend
Normal file
25
Dockerfile.frontend
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Копируем корень и конфигурации
|
||||||
|
COPY package.json package-lock.json* tsconfig.json* ./
|
||||||
|
|
||||||
|
# Копируем исходники воркспэйсов
|
||||||
|
COPY shared ./shared
|
||||||
|
COPY frontend ./frontend
|
||||||
|
|
||||||
|
# Устанавливаем зависимости из корня
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# Сначала собираем shared (если там есть скрипт build)
|
||||||
|
RUN npm run build -w @family-budget/shared || echo "No build script in shared, skipping..."
|
||||||
|
|
||||||
|
# Теперь собираем frontend
|
||||||
|
RUN npm run build -w @family-budget/frontend
|
||||||
|
|
||||||
|
# Финальный образ — nginx для раздачи статики
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=builder /app/frontend/dist /usr/share/nginx/html
|
||||||
|
COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
FROM node:20-alpine AS deps
|
|
||||||
WORKDIR /app
|
|
||||||
COPY package.json package-lock.json* ./
|
|
||||||
RUN npm ci
|
|
||||||
|
|
||||||
FROM node:20-alpine AS build
|
|
||||||
WORKDIR /app
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
|
||||||
COPY tsconfig*.json ./
|
|
||||||
COPY src ./src
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
FROM node:20-alpine AS runner
|
|
||||||
WORKDIR /app
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
|
||||||
COPY --from=build /app/dist ./dist
|
|
||||||
COPY package.json ./
|
|
||||||
EXPOSE 3000
|
|
||||||
CMD ["npm","run","start"]
|
|
||||||
@@ -14,6 +14,7 @@ import categoryRulesRouter from './routes/categoryRules';
|
|||||||
import analyticsRouter from './routes/analytics';
|
import analyticsRouter from './routes/analytics';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
app.set('trust proxy', 1);
|
||||||
|
|
||||||
app.use(express.json({ limit: '10mb' }));
|
app.use(express.json({ limit: '10mb' }));
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
|
|||||||
@@ -96,6 +96,43 @@ const migrations: { name: string; sql: string }[] = [
|
|||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: '003_seed_category_rules',
|
||||||
|
sql: `
|
||||||
|
INSERT INTO category_rules (pattern, match_type, category_id, priority, requires_confirmation)
|
||||||
|
SELECT pattern, match_type, category_id, priority, requires_confirmation
|
||||||
|
FROM (VALUES
|
||||||
|
('PYATEROCHK', 'contains', (SELECT id FROM categories WHERE name = 'Продукты' AND type = 'expense' LIMIT 1), 20, false),
|
||||||
|
('ПЯТЕРОЧКА', 'contains', (SELECT id FROM categories WHERE name = 'Продукты' AND type = 'expense' LIMIT 1), 20, false),
|
||||||
|
('AUCHAN', 'contains', (SELECT id FROM categories WHERE name = 'Продукты' AND type = 'expense' LIMIT 1), 20, false),
|
||||||
|
('DOSTAVKA IZ PYATEROCHK', 'contains', (SELECT id FROM categories WHERE name = 'Продукты' AND type = 'expense' LIMIT 1), 25, false),
|
||||||
|
('МЕТРО', 'contains', (SELECT id FROM categories WHERE name = 'Продукты' AND type = 'expense' LIMIT 1), 20, false),
|
||||||
|
('PEREKRESTOK', 'contains', (SELECT id FROM categories WHERE name = 'Продукты' AND type = 'expense' LIMIT 1), 20, false),
|
||||||
|
('MAGNIT', 'contains', (SELECT id FROM categories WHERE name = 'Продукты' AND type = 'expense' LIMIT 1), 20, false),
|
||||||
|
('LENTA', 'contains', (SELECT id FROM categories WHERE name = 'Продукты' AND type = 'expense' LIMIT 1), 20, false),
|
||||||
|
('SPORTMASTER', 'contains', (SELECT id FROM categories WHERE name = 'Спорт' AND type = 'expense' LIMIT 1), 20, false),
|
||||||
|
('DECATHLON', 'contains', (SELECT id FROM categories WHERE name = 'Спорт' AND type = 'expense' LIMIT 1), 20, false),
|
||||||
|
('PAYPARKING', 'contains', (SELECT id FROM categories WHERE name = 'Авто' AND type = 'expense' LIMIT 1), 20, false),
|
||||||
|
('PARKING', 'contains', (SELECT id FROM categories WHERE name = 'Авто' AND type = 'expense' LIMIT 1), 15, false),
|
||||||
|
('AZS', 'contains', (SELECT id FROM categories WHERE name = 'Авто' AND type = 'expense' LIMIT 1), 20, false),
|
||||||
|
('ЯНДЕКС.ЕДА', 'contains', (SELECT id FROM categories WHERE name = 'Общепит' AND type = 'expense' LIMIT 1), 25, false),
|
||||||
|
('MCDONALD', 'contains', (SELECT id FROM categories WHERE name = 'Общепит' AND type = 'expense' LIMIT 1), 20, false),
|
||||||
|
('KFC', 'contains', (SELECT id FROM categories WHERE name = 'Общепит' AND type = 'expense' LIMIT 1), 20, false),
|
||||||
|
('STARBUCKS', 'contains', (SELECT id FROM categories WHERE name = 'Общепит' AND type = 'expense' LIMIT 1), 20, false),
|
||||||
|
('tapper.ru', 'contains', (SELECT id FROM categories WHERE name = 'Развлечения' AND type = 'expense' LIMIT 1), 20, false),
|
||||||
|
('АПТЕКА', 'contains', (SELECT id FROM categories WHERE name = 'Здоровье' AND type = 'expense' LIMIT 1), 20, false),
|
||||||
|
('РИГЛА', 'contains', (SELECT id FROM categories WHERE name = 'Здоровье' AND type = 'expense' LIMIT 1), 20, false),
|
||||||
|
('OZON', 'contains', (SELECT id FROM categories WHERE name = 'Дом' AND type = 'expense' LIMIT 1), 15, false),
|
||||||
|
('WILDBERRIES', 'contains', (SELECT id FROM categories WHERE name = 'Дом' AND type = 'expense' LIMIT 1), 15, false),
|
||||||
|
('ЯНДЕКС ПЛЮС', 'contains', (SELECT id FROM categories WHERE name = 'Подписки' AND type = 'expense' LIMIT 1), 20, false),
|
||||||
|
('SPOTIFY', 'contains', (SELECT id FROM categories WHERE name = 'Подписки' AND type = 'expense' LIMIT 1), 20, false),
|
||||||
|
('НЕТФЛИКС', 'contains', (SELECT id FROM categories WHERE name = 'Подписки' AND type = 'expense' LIMIT 1), 20, false)
|
||||||
|
) AS v(pattern, match_type, category_id, priority, requires_confirmation)
|
||||||
|
WHERE category_id IS NOT NULL
|
||||||
|
AND EXISTS (SELECT 1 FROM categories LIMIT 1)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM category_rules LIMIT 1);
|
||||||
|
`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export async function runMigrations(): Promise<void> {
|
export async function runMigrations(): Promise<void> {
|
||||||
|
|||||||
@@ -16,12 +16,13 @@ router.post(
|
|||||||
|
|
||||||
const result = await authService.login({ login, password });
|
const result = await authService.login({ login, password });
|
||||||
if (!result) {
|
if (!result) {
|
||||||
res.status(401).json({ error: 'UNAUTHORIZED', message: 'Invalid credentials' });
|
res.status(401).json({ error: 'UNAUTHORIZED', message: 'Неверный логин или пароль' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.cookie('sid', result.sessionId, {
|
res.cookie('sid', result.sessionId, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
path: '/',
|
path: '/',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ import * as transactionService from '../services/transactions';
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
'/',
|
||||||
|
asyncHandler(async (_req, res) => {
|
||||||
|
const result = await transactionService.clearAllTransactions();
|
||||||
|
res.json(result);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/',
|
'/',
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
|
|||||||
@@ -168,3 +168,8 @@ export async function updateTransaction(
|
|||||||
comment: r.comment ?? null,
|
comment: r.comment ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function clearAllTransactions(): Promise<{ deleted: number }> {
|
||||||
|
const result = await pool.query('DELETE FROM transactions RETURNING id');
|
||||||
|
return { deleted: result.rowCount ?? 0 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,17 +1,36 @@
|
|||||||
|
# NPM: указывайте Forward на порт 3002 (frontend). Frontend проксирует /api на backend.
|
||||||
|
# Backend, frontend и PostgreSQL — в одной сети (postgres_default).
|
||||||
|
|
||||||
services:
|
services:
|
||||||
backend:
|
backend:
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile.backend
|
||||||
container_name: family-budget-backend
|
container_name: family-budget-backend
|
||||||
env_file:
|
environment:
|
||||||
- ./backend/.env
|
# Имя контейнера/сервиса PostgreSQL — postgres или postgres_budget
|
||||||
|
- DB_HOST=postgres_budget
|
||||||
|
- DB_PORT=5432
|
||||||
|
- DB_NAME=family_budget
|
||||||
|
- DB_USER=budget_user
|
||||||
|
- DB_PASSWORD=difficult_Paaaaaasword
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- postgre_buget_default
|
- postgres_default
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.frontend
|
||||||
|
container_name: family-budget-frontend
|
||||||
|
ports:
|
||||||
|
- "3002:80"
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- postgres_default
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
postgre_buget_default:
|
postgres_default:
|
||||||
external: true
|
external: true
|
||||||
|
|||||||
2
frontend/dist-node/vite.config.d.ts
vendored
Normal file
2
frontend/dist-node/vite.config.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
declare const _default: import("vite").UserConfig;
|
||||||
|
export default _default;
|
||||||
14
frontend/dist-node/vite.config.js
Normal file
14
frontend/dist-node/vite.config.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
23
frontend/nginx.conf
Normal file
23
frontend/nginx.conf
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# API — проксируем на backend (сервис из docker-compose)
|
||||||
|
location /api {
|
||||||
|
proxy_pass http://family-budget-backend:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cookie_path / /;
|
||||||
|
proxy_connect_timeout 5s;
|
||||||
|
proxy_read_timeout 30s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA — все остальные пути отдаём index.html
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,8 +26,16 @@ async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
|
let body: ApiError;
|
||||||
|
try {
|
||||||
|
body = await res.json();
|
||||||
|
} catch {
|
||||||
|
body = { error: 'UNAUTHORIZED', message: 'Сессия истекла' };
|
||||||
|
}
|
||||||
|
if (!url.includes('/api/auth/login')) {
|
||||||
onUnauthorized?.();
|
onUnauthorized?.();
|
||||||
throw new ApiException(401, { error: 'UNAUTHORIZED', message: 'Сессия истекла' });
|
}
|
||||||
|
throw new ApiException(401, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -59,4 +67,7 @@ export const api = {
|
|||||||
|
|
||||||
patch: <T>(url: string, body: unknown) =>
|
patch: <T>(url: string, body: unknown) =>
|
||||||
request<T>(url, { method: 'PATCH', body: JSON.stringify(body) }),
|
request<T>(url, { method: 'PATCH', body: JSON.stringify(body) }),
|
||||||
|
|
||||||
|
delete: <T>(url: string) =>
|
||||||
|
request<T>(url, { method: 'DELETE' }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,3 +25,7 @@ export async function updateTransaction(
|
|||||||
): Promise<Transaction> {
|
): Promise<Transaction> {
|
||||||
return api.put<Transaction>(`/api/transactions/${id}`, data);
|
return api.put<Transaction>(`/api/transactions/${id}`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function clearAllTransactions(): Promise<{ deleted: number }> {
|
||||||
|
return api.delete<{ deleted: number }>('/api/transactions');
|
||||||
|
}
|
||||||
|
|||||||
110
frontend/src/components/ClearHistoryModal.tsx
Normal file
110
frontend/src/components/ClearHistoryModal.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { clearAllTransactions } from '../api/transactions';
|
||||||
|
|
||||||
|
const CONFIRM_WORD = 'УДАЛИТЬ';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void;
|
||||||
|
onDone: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClearHistoryModal({ onClose, onDone }: Props) {
|
||||||
|
const [check1, setCheck1] = useState(false);
|
||||||
|
const [confirmInput, setConfirmInput] = useState('');
|
||||||
|
const [check2, setCheck2] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const canConfirm =
|
||||||
|
check1 &&
|
||||||
|
confirmInput.trim().toUpperCase() === CONFIRM_WORD &&
|
||||||
|
check2;
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
if (!canConfirm || loading) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await clearAllTransactions();
|
||||||
|
onDone();
|
||||||
|
} catch (e) {
|
||||||
|
setError(
|
||||||
|
e instanceof Error ? e.message : 'Ошибка при очистке истории',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={onClose}>
|
||||||
|
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2>Очистить историю операций</h2>
|
||||||
|
<button className="btn-close" onClick={onClose}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-body">
|
||||||
|
<p className="clear-history-warn">
|
||||||
|
Все транзакции будут безвозвратно удалены. Счета и категории
|
||||||
|
сохранятся.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && <div className="alert alert-error">{error}</div>}
|
||||||
|
|
||||||
|
<div className="form-group form-group-checkbox clear-history-check">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={check1}
|
||||||
|
onChange={(e) => setCheck1(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Я хочу очистить историю операций
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>
|
||||||
|
Введите <strong>{CONFIRM_WORD}</strong> для подтверждения
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={confirmInput}
|
||||||
|
onChange={(e) => setConfirmInput(e.target.value)}
|
||||||
|
placeholder={CONFIRM_WORD}
|
||||||
|
className={confirmInput && confirmInput.trim().toUpperCase() !== CONFIRM_WORD ? 'input-error' : ''}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group form-group-checkbox clear-history-check">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={check2}
|
||||||
|
onChange={(e) => setCheck2(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Я понимаю, что действие необратимо и все данные об операциях
|
||||||
|
будут потеряны навсегда
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button
|
||||||
|
className="btn btn-danger"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={!canConfirm || loading}
|
||||||
|
>
|
||||||
|
{loading ? 'Удаление…' : 'Удалить всё'}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary" onClick={onClose}>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
frontend/src/components/DataSection.tsx
Normal file
37
frontend/src/components/DataSection.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { ClearHistoryModal } from './ClearHistoryModal';
|
||||||
|
|
||||||
|
export function DataSection() {
|
||||||
|
const [showClearModal, setShowClearModal] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="data-section">
|
||||||
|
<div className="section-block">
|
||||||
|
<h3>Очистка данных</h3>
|
||||||
|
<p className="section-desc">
|
||||||
|
Очистить историю операций (все транзакции). Счета, категории и
|
||||||
|
правила сохранятся.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-danger"
|
||||||
|
onClick={() => setShowClearModal(true)}
|
||||||
|
>
|
||||||
|
Очистить историю
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showClearModal && (
|
||||||
|
<ClearHistoryModal
|
||||||
|
onClose={() => setShowClearModal(false)}
|
||||||
|
onDone={() => {
|
||||||
|
setShowClearModal(false);
|
||||||
|
navigate('/history');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,7 +6,10 @@ import type {
|
|||||||
} from '@family-budget/shared';
|
} from '@family-budget/shared';
|
||||||
import { toISODate } from '../utils/format';
|
import { toISODate } from '../utils/format';
|
||||||
|
|
||||||
|
export type PeriodMode = 'week' | 'month' | 'year' | 'custom';
|
||||||
|
|
||||||
export interface FiltersState {
|
export interface FiltersState {
|
||||||
|
periodMode: PeriodMode;
|
||||||
from: string;
|
from: string;
|
||||||
to: string;
|
to: string;
|
||||||
accountId: string;
|
accountId: string;
|
||||||
@@ -58,7 +61,57 @@ export function TransactionFilters({
|
|||||||
from = new Date(now.getFullYear(), 0, 1);
|
from = new Date(now.getFullYear(), 0, 1);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
onChange({ ...filters, from: toISODate(from), to: toISODate(now) });
|
onChange({
|
||||||
|
...filters,
|
||||||
|
periodMode: preset,
|
||||||
|
from: toISODate(from),
|
||||||
|
to: toISODate(now),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigate = (direction: -1 | 1) => {
|
||||||
|
const fromDate = new Date(filters.from);
|
||||||
|
let newFrom: Date;
|
||||||
|
let newTo: Date;
|
||||||
|
switch (filters.periodMode) {
|
||||||
|
case 'week':
|
||||||
|
newFrom = new Date(fromDate);
|
||||||
|
newFrom.setDate(fromDate.getDate() + 7 * direction);
|
||||||
|
newTo = new Date(newFrom);
|
||||||
|
newTo.setDate(newFrom.getDate() + 6);
|
||||||
|
break;
|
||||||
|
case 'month':
|
||||||
|
newFrom = new Date(
|
||||||
|
fromDate.getFullYear(),
|
||||||
|
fromDate.getMonth() + direction,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
newTo = new Date(
|
||||||
|
newFrom.getFullYear(),
|
||||||
|
newFrom.getMonth() + 1,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'year':
|
||||||
|
newFrom = new Date(fromDate.getFullYear() + direction, 0, 1);
|
||||||
|
newTo = new Date(newFrom.getFullYear(), 11, 31);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChange({
|
||||||
|
...filters,
|
||||||
|
from: toISODate(newFrom),
|
||||||
|
to: toISODate(newTo),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDateChange = (field: 'from' | 'to', value: string) => {
|
||||||
|
onChange({
|
||||||
|
...filters,
|
||||||
|
periodMode: 'custom',
|
||||||
|
[field]: value,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -66,34 +119,56 @@ export function TransactionFilters({
|
|||||||
<div className="filters-row">
|
<div className="filters-row">
|
||||||
<div className="filter-group">
|
<div className="filter-group">
|
||||||
<label>Период</label>
|
<label>Период</label>
|
||||||
|
<div className="filter-dates-wrap">
|
||||||
|
{filters.periodMode !== 'custom' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-page"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
title="Предыдущий период"
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<div className="filter-dates">
|
<div className="filter-dates">
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={filters.from}
|
value={filters.from}
|
||||||
onChange={(e) => set('from', e.target.value)}
|
onChange={(e) => handleDateChange('from', e.target.value)}
|
||||||
/>
|
/>
|
||||||
<span className="filter-separator">—</span>
|
<span className="filter-separator">—</span>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={filters.to}
|
value={filters.to}
|
||||||
onChange={(e) => set('to', e.target.value)}
|
onChange={(e) => handleDateChange('to', e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{filters.periodMode !== 'custom' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-page"
|
||||||
|
onClick={() => navigate(1)}
|
||||||
|
title="Следующий период"
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="filter-presets">
|
<div className="filter-presets">
|
||||||
<button
|
<button
|
||||||
className="btn-preset"
|
className={`btn-preset ${filters.periodMode === 'week' ? 'active' : ''}`}
|
||||||
onClick={() => applyPreset('week')}
|
onClick={() => applyPreset('week')}
|
||||||
>
|
>
|
||||||
Неделя
|
Неделя
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn-preset"
|
className={`btn-preset ${filters.periodMode === 'month' ? 'active' : ''}`}
|
||||||
onClick={() => applyPreset('month')}
|
onClick={() => applyPreset('month')}
|
||||||
>
|
>
|
||||||
Месяц
|
Месяц
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn-preset"
|
className={`btn-preset ${filters.periodMode === 'year' ? 'active' : ''}`}
|
||||||
onClick={() => applyPreset('year')}
|
onClick={() => applyPreset('year')}
|
||||||
>
|
>
|
||||||
Год
|
Год
|
||||||
|
|||||||
@@ -43,8 +43,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const me = await getMe();
|
const me = await getMe();
|
||||||
setUser({ login: me.login });
|
setUser({ login: me.login });
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const msg =
|
const msg = e instanceof Error && e.message === 'Failed to fetch'
|
||||||
e instanceof Error ? e.message : 'Ошибка входа';
|
? 'Сервер недоступен. Проверьте, что backend запущен и NPM проксирует /api на порт 3000.'
|
||||||
|
: e instanceof Error
|
||||||
|
? e.message
|
||||||
|
: 'Ошибка входа';
|
||||||
setError(msg);
|
setError(msg);
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import type {
|
import type {
|
||||||
Transaction,
|
Transaction,
|
||||||
Account,
|
Account,
|
||||||
@@ -19,10 +20,26 @@ import { EditTransactionModal } from '../components/EditTransactionModal';
|
|||||||
import { ImportModal } from '../components/ImportModal';
|
import { ImportModal } from '../components/ImportModal';
|
||||||
import { toISODate } from '../utils/format';
|
import { toISODate } from '../utils/format';
|
||||||
|
|
||||||
|
const PARAM_KEYS = [
|
||||||
|
'from',
|
||||||
|
'to',
|
||||||
|
'accountId',
|
||||||
|
'direction',
|
||||||
|
'categoryId',
|
||||||
|
'search',
|
||||||
|
'amountMin',
|
||||||
|
'amountMax',
|
||||||
|
'onlyUnconfirmed',
|
||||||
|
'sortBy',
|
||||||
|
'sortOrder',
|
||||||
|
'periodMode',
|
||||||
|
] as const;
|
||||||
|
|
||||||
function getDefaultFilters(): FiltersState {
|
function getDefaultFilters(): FiltersState {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const from = new Date(now.getFullYear(), now.getMonth(), 1);
|
const from = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
return {
|
return {
|
||||||
|
periodMode: 'month',
|
||||||
from: toISODate(from),
|
from: toISODate(from),
|
||||||
to: toISODate(now),
|
to: toISODate(now),
|
||||||
accountId: '',
|
accountId: '',
|
||||||
@@ -37,10 +54,70 @@ function getDefaultFilters(): FiltersState {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function filtersFromParams(params: URLSearchParams): Partial<FiltersState> {
|
||||||
|
const out: Partial<FiltersState> = {};
|
||||||
|
for (const key of PARAM_KEYS) {
|
||||||
|
const v = params.get(key);
|
||||||
|
if (v !== null) {
|
||||||
|
if (key === 'onlyUnconfirmed') {
|
||||||
|
out[key] = v === '1' || v === 'true';
|
||||||
|
} else {
|
||||||
|
(out as Record<string, unknown>)[key] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function paramsFromUrl(params: URLSearchParams): {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
} {
|
||||||
|
const p = params.get('page');
|
||||||
|
const ps = params.get('pageSize');
|
||||||
|
return {
|
||||||
|
page: p ? Math.max(1, parseInt(p, 10) || 1) : 1,
|
||||||
|
pageSize: ps ? parseInt(ps, 10) || 50 : 50,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function filtersToParams(
|
||||||
|
f: FiltersState,
|
||||||
|
pageNum: number,
|
||||||
|
pageSizeNum: number,
|
||||||
|
): URLSearchParams {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
if (f.from) p.set('from', f.from);
|
||||||
|
if (f.to) p.set('to', f.to);
|
||||||
|
if (f.accountId) p.set('accountId', f.accountId);
|
||||||
|
if (f.direction) p.set('direction', f.direction);
|
||||||
|
if (f.categoryId) p.set('categoryId', f.categoryId);
|
||||||
|
if (f.search) p.set('search', f.search);
|
||||||
|
if (f.amountMin) p.set('amountMin', f.amountMin);
|
||||||
|
if (f.amountMax) p.set('amountMax', f.amountMax);
|
||||||
|
if (f.onlyUnconfirmed) p.set('onlyUnconfirmed', '1');
|
||||||
|
if (f.sortBy) p.set('sortBy', f.sortBy);
|
||||||
|
if (f.sortOrder) p.set('sortOrder', f.sortOrder);
|
||||||
|
if (f.periodMode) p.set('periodMode', f.periodMode);
|
||||||
|
if (pageNum > 1) p.set('page', String(pageNum));
|
||||||
|
if (pageSizeNum !== 50) p.set('pageSize', String(pageSizeNum));
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
export function HistoryPage() {
|
export function HistoryPage() {
|
||||||
const [filters, setFilters] = useState<FiltersState>(getDefaultFilters);
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [page, setPage] = useState(1);
|
const urlFilters = filtersFromParams(searchParams);
|
||||||
const [pageSize, setPageSize] = useState(50);
|
const { page: urlPage, pageSize: urlPageSize } = paramsFromUrl(searchParams);
|
||||||
|
const defaultFilters = getDefaultFilters();
|
||||||
|
const [filters, setFilters] = useState<FiltersState>(() => ({
|
||||||
|
...defaultFilters,
|
||||||
|
...urlFilters,
|
||||||
|
periodMode:
|
||||||
|
(urlFilters.periodMode as FiltersState['periodMode']) ??
|
||||||
|
defaultFilters.periodMode,
|
||||||
|
}));
|
||||||
|
const [page, setPage] = useState(urlPage);
|
||||||
|
const [pageSize, setPageSize] = useState(urlPageSize);
|
||||||
const [data, setData] = useState<PaginatedResponse<Transaction> | null>(
|
const [data, setData] = useState<PaginatedResponse<Transaction> | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@@ -55,6 +132,22 @@ export function HistoryPage() {
|
|||||||
getCategories().then(setCategories).catch(() => {});
|
getCategories().then(setCategories).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const urlFilters = filtersFromParams(searchParams);
|
||||||
|
const { page: p, pageSize: ps } = paramsFromUrl(searchParams);
|
||||||
|
if (Object.keys(urlFilters).length > 0) {
|
||||||
|
setFilters((prev) => ({
|
||||||
|
...getDefaultFilters(),
|
||||||
|
...urlFilters,
|
||||||
|
periodMode:
|
||||||
|
(urlFilters.periodMode as FiltersState['periodMode']) ??
|
||||||
|
prev.periodMode,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
setPage(p);
|
||||||
|
setPageSize(ps);
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -92,8 +185,12 @@ export function HistoryPage() {
|
|||||||
const handleFiltersChange = (newFilters: FiltersState) => {
|
const handleFiltersChange = (newFilters: FiltersState) => {
|
||||||
setFilters(newFilters);
|
setFilters(newFilters);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
|
setSearchParams(filtersToParams(newFilters, 1, pageSize), {
|
||||||
|
replace: true,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const handleEditSave = () => {
|
const handleEditSave = () => {
|
||||||
setEditingTx(null);
|
setEditingTx(null);
|
||||||
fetchData();
|
fetchData();
|
||||||
@@ -136,10 +233,20 @@ export function HistoryPage() {
|
|||||||
pageSize={data.pageSize}
|
pageSize={data.pageSize}
|
||||||
totalItems={data.totalItems}
|
totalItems={data.totalItems}
|
||||||
totalPages={data.totalPages}
|
totalPages={data.totalPages}
|
||||||
onPageChange={setPage}
|
onPageChange={(p) => {
|
||||||
|
setPage(p);
|
||||||
|
setSearchParams(
|
||||||
|
filtersToParams(filters, p, pageSize),
|
||||||
|
{ replace: true },
|
||||||
|
);
|
||||||
|
}}
|
||||||
onPageSizeChange={(size) => {
|
onPageSizeChange={(size) => {
|
||||||
setPageSize(size);
|
setPageSize(size);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
|
setSearchParams(
|
||||||
|
filtersToParams(filters, 1, size),
|
||||||
|
{ replace: true },
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import { useState } from 'react';
|
|||||||
import { AccountsList } from '../components/AccountsList';
|
import { AccountsList } from '../components/AccountsList';
|
||||||
import { CategoriesList } from '../components/CategoriesList';
|
import { CategoriesList } from '../components/CategoriesList';
|
||||||
import { RulesList } from '../components/RulesList';
|
import { RulesList } from '../components/RulesList';
|
||||||
|
import { DataSection } from '../components/DataSection';
|
||||||
|
|
||||||
type Tab = 'accounts' | 'categories' | 'rules';
|
type Tab = 'accounts' | 'categories' | 'rules' | 'data';
|
||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
const [tab, setTab] = useState<Tab>('accounts');
|
const [tab, setTab] = useState<Tab>('accounts');
|
||||||
@@ -33,12 +34,19 @@ export function SettingsPage() {
|
|||||||
>
|
>
|
||||||
Правила
|
Правила
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={`tab ${tab === 'data' ? 'active' : ''}`}
|
||||||
|
onClick={() => setTab('data')}
|
||||||
|
>
|
||||||
|
Данные
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="tab-content">
|
<div className="tab-content">
|
||||||
{tab === 'accounts' && <AccountsList />}
|
{tab === 'accounts' && <AccountsList />}
|
||||||
{tab === 'categories' && <CategoriesList />}
|
{tab === 'categories' && <CategoriesList />}
|
||||||
{tab === 'rules' && <RulesList />}
|
{tab === 'rules' && <RulesList />}
|
||||||
|
{tab === 'data' && <DataSection />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -390,6 +390,15 @@ textarea {
|
|||||||
border-color: var(--color-border-hover);
|
border-color: var(--color-border-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: var(--color-danger);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-block {
|
.btn-block {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@@ -497,6 +506,16 @@ textarea {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filter-dates-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-dates-wrap .btn-page {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.filter-dates {
|
.filter-dates {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1003,6 +1022,46 @@ textarea {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.data-section {
|
||||||
|
padding: 20px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-block {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-block:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-block h3 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-desc {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
max-width: 480px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-history-warn {
|
||||||
|
color: var(--color-danger);
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-history-check {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-error {
|
||||||
|
border-color: var(--color-danger) !important;
|
||||||
|
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
/* ================================================================
|
/* ================================================================
|
||||||
Toggle button
|
Toggle button
|
||||||
================================================================ */
|
================================================================ */
|
||||||
|
|||||||
77
pdf2json.md
Normal file
77
pdf2json.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# Промпт для конвертации PDF банковской выписки в JSON
|
||||||
|
|
||||||
|
Ты — конвертер банковских выписок. Твоя задача: извлечь данные из прикреплённого PDF банковской выписки и вернуть строго один валидный JSON-объект в формате ниже. Никакого текста до или после JSON, только сам объект.
|
||||||
|
|
||||||
|
## Структура выходного JSON
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schemaVersion": "1.0",
|
||||||
|
"bank": "<название банка из выписки>",
|
||||||
|
"statement": {
|
||||||
|
"accountNumber": "<номер счёта, только цифры, без пробелов>",
|
||||||
|
"currency": "RUB",
|
||||||
|
"openingBalance": <число в копейках, целое>,
|
||||||
|
"closingBalance": <число в копейках, целое>,
|
||||||
|
"exportedAt": "<дата экспорта в формате ISO 8601 с offset, например 2026-02-27T13:23:00+03:00>"
|
||||||
|
},
|
||||||
|
"transactions": [
|
||||||
|
{
|
||||||
|
"operationAt": "<дата и время операции в формате ISO 8601 с offset>",
|
||||||
|
"amountSigned": <число: положительное для прихода, отрицательное для расхода; в копейках>,
|
||||||
|
"commission": <число, целое, >= 0, в копейках>,
|
||||||
|
"description": "<полное описание операции из выписки>"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Правила конвертации
|
||||||
|
|
||||||
|
1. **Суммы** — всегда в копейках (рубли × 100). Пример: 500,00 ₽ → 50000, -1234,56 ₽ → -123456.
|
||||||
|
|
||||||
|
2. **amountSigned**:
|
||||||
|
- Приход (зачисление, пополнение) — положительное число.
|
||||||
|
- Расход (списание, оплата) — отрицательное число.
|
||||||
|
- Переводы — знак в зависимости от направления движения на счёт.
|
||||||
|
|
||||||
|
3. **operationAt** — дата и время операции. Если время не указано, используй 00:00:00. Обязательно указывай offset (+03:00 для МСК).
|
||||||
|
|
||||||
|
4. **commission** — комиссия по операции. Если не указана — 0.
|
||||||
|
|
||||||
|
5. **description** — полный текст операции как в выписке (назначение платежа, магазин, получатель и т.п.). Не сокращай и не меняй формулировки.
|
||||||
|
|
||||||
|
6. **accountNumber** — только цифры, без пробелов и дефисов (например: 40817810825104025611).
|
||||||
|
|
||||||
|
7. **openingBalance / closingBalance** — начальный и конечный остаток по счёту в копейках.
|
||||||
|
|
||||||
|
8. **bank** — краткое название банка (VTB, Sberbank, Тинькофф и т.п.).
|
||||||
|
|
||||||
|
9. **exportedAt** — дата формирования выписки. Если неизвестна — возьми дату последней операции в выписке.
|
||||||
|
|
||||||
|
10. **Порядок транзакций** — сохраняй хронологический порядок из выписки (обычно от старых к новым).
|
||||||
|
|
||||||
|
## Требования
|
||||||
|
|
||||||
|
- Массив `transactions` не должен быть пустым.
|
||||||
|
- Все числа — целые.
|
||||||
|
- Даты — строго в формате ISO 8601 с offset.
|
||||||
|
- currency всегда "RUB".
|
||||||
|
- schemaVersion всегда "1.0".
|
||||||
|
|
||||||
|
## Пример одной транзакции
|
||||||
|
|
||||||
|
Выписка: «26.02.2026 14:06 | -500,00 ₽ | 0,00 | Оплата товаров. PAVELETSKAYA по карте *8214»
|
||||||
|
|
||||||
|
→
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"operationAt": "2026-02-26T14:06:00+03:00",
|
||||||
|
"amountSigned": -50000,
|
||||||
|
"commission": 0,
|
||||||
|
"description": "Оплата товаров. PAVELETSKAYA по карте *8214"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Обработай прикреплённый PDF и верни один JSON-объект.
|
||||||
Reference in New Issue
Block a user