Compare commits
12 Commits
0638885812
...
fix/adapti
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78c4730196 | ||
| f2d0c91488 | |||
|
|
1d7fbea9ef | ||
|
|
627706228b | ||
|
|
a5f2294440 | ||
| 25ddd6b7ed | |||
|
|
3e481d5a55 | ||
| cf24d5dc26 | |||
|
|
723df494ca | ||
| 5fa6b921d8 | |||
|
|
feb756cfe2 | ||
|
|
b598216d24 |
@@ -5,6 +5,11 @@ COPY package.json package-lock.json* ./
|
|||||||
COPY shared ./shared
|
COPY shared ./shared
|
||||||
COPY backend ./backend
|
COPY backend ./backend
|
||||||
|
|
||||||
|
# Увеличенные таймауты для нестабильной сети
|
||||||
|
RUN npm config set fetch-retry-mintimeout 20000 && \
|
||||||
|
npm config set fetch-retry-maxtimeout 120000 && \
|
||||||
|
npm config set fetch-timeout 300000
|
||||||
|
|
||||||
RUN npm install
|
RUN npm install
|
||||||
|
|
||||||
FROM node:20-alpine AS build
|
FROM node:20-alpine AS build
|
||||||
@@ -25,6 +30,7 @@ ENV NODE_ENV=production
|
|||||||
COPY --from=build /app/backend/dist ./dist
|
COPY --from=build /app/backend/dist ./dist
|
||||||
COPY --from=build /app/backend/package.json ./package.json
|
COPY --from=build /app/backend/package.json ./package.json
|
||||||
COPY --from=build /app/node_modules ./node_modules
|
COPY --from=build /app/node_modules ./node_modules
|
||||||
|
COPY --from=build /app/backend/node_modules/ ./node_modules/
|
||||||
COPY backend/.env .env
|
COPY backend/.env .env
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ COPY package.json package-lock.json* tsconfig.json* ./
|
|||||||
COPY shared ./shared
|
COPY shared ./shared
|
||||||
COPY frontend ./frontend
|
COPY frontend ./frontend
|
||||||
|
|
||||||
|
# Увеличиваем таймауты и повторы для нестабильной сети
|
||||||
|
RUN npm config set fetch-retry-mintimeout 20000 && \
|
||||||
|
npm config set fetch-retry-maxtimeout 120000 && \
|
||||||
|
npm config set fetch-timeout 300000
|
||||||
|
|
||||||
# Устанавливаем зависимости из корня
|
# Устанавливаем зависимости из корня
|
||||||
RUN npm install
|
RUN npm install
|
||||||
|
|
||||||
|
|||||||
@@ -18,3 +18,6 @@ LLM_API_KEY=
|
|||||||
# Базовый URL API LLM (опционально). По умолчанию https://api.openai.com
|
# Базовый URL API LLM (опционально). По умолчанию https://api.openai.com
|
||||||
# Примеры: Ollama — http://localhost:11434/v1, Azure — https://YOUR_RESOURCE.openai.azure.com/openai/deployments/YOUR_DEPLOYMENT
|
# Примеры: Ollama — http://localhost:11434/v1, Azure — https://YOUR_RESOURCE.openai.azure.com/openai/deployments/YOUR_DEPLOYMENT
|
||||||
LLM_API_BASE_URL=
|
LLM_API_BASE_URL=
|
||||||
|
|
||||||
|
# Имя модели LLM (опционально). Для OpenAI: gpt-4o-mini. Для Ollama: qwen2.5:7b, qwen3:7b
|
||||||
|
LLM_MODEL=
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"start": "node dist/app.js",
|
"start": "node dist/app.js",
|
||||||
"migrate": "tsx src/db/migrate.ts",
|
"migrate": "tsx src/db/migrate.ts",
|
||||||
"migrate:prod": "node dist/db/migrate.js",
|
"migrate:prod": "node dist/db/migrate.js",
|
||||||
"migrate:prod": "node dist/db/migrate.js"
|
"test:llm": "tsx src/scripts/testLlm.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@family-budget/shared": "*",
|
"@family-budget/shared": "*",
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"multer": "^2.1.1",
|
"multer": "^2.1.1",
|
||||||
"openai": "^6.27.0",
|
"openai": "^6.27.0",
|
||||||
"pdf-parse": "^2.4.5",
|
"pdf-parse": "1.1.1",
|
||||||
"pg": "^8.19.0",
|
"pg": "^8.19.0",
|
||||||
"uuid": "^13.0.0"
|
"uuid": "^13.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -24,4 +24,7 @@ export const config = {
|
|||||||
|
|
||||||
/** Базовый URL API LLM. По умолчанию https://api.openai.com. Для Ollama: http://localhost:11434/v1 */
|
/** Базовый URL API LLM. По умолчанию https://api.openai.com. Для Ollama: http://localhost:11434/v1 */
|
||||||
llmApiBaseUrl: process.env.LLM_API_BASE_URL || undefined,
|
llmApiBaseUrl: process.env.LLM_API_BASE_URL || undefined,
|
||||||
|
|
||||||
|
/** Имя модели LLM. Для OpenAI: gpt-4o-mini. Для Ollama: qwen2.5:7b, qwen3:7b и т.п. */
|
||||||
|
llmModel: process.env.LLM_MODEL || 'gpt-4o-mini',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,4 +7,5 @@ export const pool = new Pool({
|
|||||||
database: config.db.database,
|
database: config.db.database,
|
||||||
user: config.db.user,
|
user: config.db.user,
|
||||||
password: config.db.password,
|
password: config.db.password,
|
||||||
|
connectionTimeoutMillis: 5000,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import multer from 'multer';
|
|||||||
import { asyncHandler } from '../utils';
|
import { asyncHandler } from '../utils';
|
||||||
import { importStatement, isValidationError } from '../services/import';
|
import { importStatement, isValidationError } from '../services/import';
|
||||||
import {
|
import {
|
||||||
convertPdfToStatement,
|
convertPdfToStatementStreaming,
|
||||||
isPdfConversionError,
|
isPdfConversionError,
|
||||||
} from '../services/pdfToStatement';
|
} from '../services/pdfToStatement';
|
||||||
|
|
||||||
@@ -28,6 +28,10 @@ function isJsonFile(file: { mimetype: string; originalname: string }): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sseWrite(res: import('express').Response, data: Record<string, unknown>) {
|
||||||
|
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||||
|
}
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
@@ -51,19 +55,65 @@ router.post(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let body: unknown;
|
|
||||||
|
|
||||||
if (isPdfFile(file)) {
|
if (isPdfFile(file)) {
|
||||||
const converted = await convertPdfToStatement(file.buffer);
|
res.setHeader('Content-Type', 'text/event-stream');
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
res.setHeader('Connection', 'keep-alive');
|
||||||
|
res.setHeader('X-Accel-Buffering', 'no');
|
||||||
|
res.flushHeaders();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const converted = await convertPdfToStatementStreaming(
|
||||||
|
file.buffer,
|
||||||
|
(stage, progress, message) => {
|
||||||
|
sseWrite(res, { stage, progress, message });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (isPdfConversionError(converted)) {
|
if (isPdfConversionError(converted)) {
|
||||||
res.status(converted.status).json({
|
sseWrite(res, {
|
||||||
error: converted.error,
|
stage: 'error',
|
||||||
message: converted.message,
|
message: converted.message,
|
||||||
});
|
});
|
||||||
|
res.end();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
body = converted;
|
|
||||||
} else {
|
sseWrite(res, {
|
||||||
|
stage: 'import',
|
||||||
|
progress: 88,
|
||||||
|
message: 'Импорт в базу данных...',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await importStatement(converted);
|
||||||
|
if (isValidationError(result)) {
|
||||||
|
sseWrite(res, {
|
||||||
|
stage: 'error',
|
||||||
|
message: (result as { message: string }).message,
|
||||||
|
});
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sseWrite(res, {
|
||||||
|
stage: 'done',
|
||||||
|
progress: 100,
|
||||||
|
result,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('SSE import error:', err);
|
||||||
|
sseWrite(res, {
|
||||||
|
stage: 'error',
|
||||||
|
message: 'Внутренняя ошибка сервера',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON files — synchronous response as before
|
||||||
|
let body: unknown;
|
||||||
try {
|
try {
|
||||||
body = JSON.parse(file.buffer.toString('utf-8'));
|
body = JSON.parse(file.buffer.toString('utf-8'));
|
||||||
} catch {
|
} catch {
|
||||||
@@ -73,7 +123,6 @@ router.post(
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const result = await importStatement(body);
|
const result = await importStatement(body);
|
||||||
if (isValidationError(result)) {
|
if (isValidationError(result)) {
|
||||||
|
|||||||
42
backend/src/scripts/testLlm.ts
Normal file
42
backend/src/scripts/testLlm.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Тестовый запрос к LLM серверу.
|
||||||
|
* Запуск: npm run test:llm
|
||||||
|
*/
|
||||||
|
import OpenAI from 'openai';
|
||||||
|
import { config } from '../config';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
if (!config.llmApiKey || config.llmApiKey.trim() === '') {
|
||||||
|
console.error('Ошибка: LLM_API_KEY не задан в .env');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const openai = new OpenAI({
|
||||||
|
apiKey: config.llmApiKey,
|
||||||
|
...(config.llmApiBaseUrl && { baseURL: config.llmApiBaseUrl }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = config.llmApiBaseUrl || 'https://api.openai.com/v1';
|
||||||
|
console.log('LLM сервер:', url);
|
||||||
|
console.log('Модель:', config.llmModel);
|
||||||
|
console.log('---');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const completion = await openai.chat.completions.create({
|
||||||
|
model: config.llmModel,
|
||||||
|
messages: [{ role: 'user', content: 'Ответь одним словом: какой цвет у неба?' }],
|
||||||
|
temperature: 0,
|
||||||
|
max_tokens: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
const content = completion.choices[0]?.message?.content;
|
||||||
|
console.log('Ответ:', content || '(пусто)');
|
||||||
|
console.log('---');
|
||||||
|
console.log('OK');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Ошибка:', err instanceof Error ? err.message : err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { PDFParse } from 'pdf-parse';
|
|
||||||
import OpenAI from 'openai';
|
import OpenAI from 'openai';
|
||||||
import { config } from '../config';
|
import { config } from '../config';
|
||||||
import type { StatementFile } from '@family-budget/shared';
|
import type { StatementFile } from '@family-budget/shared';
|
||||||
@@ -64,6 +63,16 @@ export function isPdfConversionError(r: unknown): r is PdfConversionError {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lazy-loaded so the app starts even if pdf-parse is missing from node_modules.
|
||||||
|
let _pdfParse: ((buf: Buffer) => Promise<{ text: string }>) | undefined;
|
||||||
|
function getPdfParse() {
|
||||||
|
if (!_pdfParse) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
|
_pdfParse = require('pdf-parse') as (buf: Buffer) => Promise<{ text: string }>;
|
||||||
|
}
|
||||||
|
return _pdfParse;
|
||||||
|
}
|
||||||
|
|
||||||
export async function convertPdfToStatement(
|
export async function convertPdfToStatement(
|
||||||
buffer: Buffer,
|
buffer: Buffer,
|
||||||
): Promise<StatementFile | PdfConversionError> {
|
): Promise<StatementFile | PdfConversionError> {
|
||||||
@@ -77,10 +86,8 @@ export async function convertPdfToStatement(
|
|||||||
|
|
||||||
let text: string;
|
let text: string;
|
||||||
try {
|
try {
|
||||||
const parser = new PDFParse({ data: buffer });
|
const result = await getPdfParse()(buffer);
|
||||||
const result = await parser.getText();
|
|
||||||
text = result.text || '';
|
text = result.text || '';
|
||||||
await parser.destroy();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('PDF extraction error:', err);
|
console.error('PDF extraction error:', err);
|
||||||
return {
|
return {
|
||||||
@@ -101,16 +108,18 @@ export async function convertPdfToStatement(
|
|||||||
const openai = new OpenAI({
|
const openai = new OpenAI({
|
||||||
apiKey: config.llmApiKey,
|
apiKey: config.llmApiKey,
|
||||||
...(config.llmApiBaseUrl && { baseURL: config.llmApiBaseUrl }),
|
...(config.llmApiBaseUrl && { baseURL: config.llmApiBaseUrl }),
|
||||||
|
timeout: 5 * 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const completion = await openai.chat.completions.create({
|
const completion = await openai.chat.completions.create({
|
||||||
model: 'gpt-4o-mini',
|
model: config.llmModel,
|
||||||
messages: [
|
messages: [
|
||||||
{ role: 'system', content: PDF2JSON_PROMPT },
|
{ role: 'system', content: PDF2JSON_PROMPT },
|
||||||
{ role: 'user', content: `Текст выписки:\n\n${text}` },
|
{ role: 'user', content: `Текст выписки:\n\n${text}` },
|
||||||
],
|
],
|
||||||
temperature: 0,
|
temperature: 0,
|
||||||
|
max_tokens: 32768,
|
||||||
});
|
});
|
||||||
|
|
||||||
const content = completion.choices[0]?.message?.content?.trim();
|
const content = completion.choices[0]?.message?.content?.trim();
|
||||||
@@ -122,6 +131,129 @@ export async function convertPdfToStatement(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return parseConversionResult(content);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('LLM conversion error:', err);
|
||||||
|
return {
|
||||||
|
status: 502,
|
||||||
|
error: 'BAD_GATEWAY',
|
||||||
|
message: 'Временная ошибка конвертации',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProgressStage = 'pdf' | 'llm' | 'import';
|
||||||
|
export type OnProgress = (stage: ProgressStage, progress: number, message: string) => void;
|
||||||
|
|
||||||
|
const LLM_PROGRESS_MIN = 10;
|
||||||
|
const LLM_PROGRESS_MAX = 85;
|
||||||
|
const LLM_PROGRESS_RANGE = LLM_PROGRESS_MAX - LLM_PROGRESS_MIN;
|
||||||
|
const THROTTLE_MS = 300;
|
||||||
|
|
||||||
|
export async function convertPdfToStatementStreaming(
|
||||||
|
buffer: Buffer,
|
||||||
|
onProgress: OnProgress,
|
||||||
|
): Promise<StatementFile | PdfConversionError> {
|
||||||
|
if (!config.llmApiKey || config.llmApiKey.trim() === '') {
|
||||||
|
return {
|
||||||
|
status: 503,
|
||||||
|
error: 'SERVICE_UNAVAILABLE',
|
||||||
|
message: 'Конвертация PDF недоступна: не задан LLM_API_KEY',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress('pdf', 2, 'Извлечение текста из PDF...');
|
||||||
|
|
||||||
|
let text: string;
|
||||||
|
try {
|
||||||
|
const result = await getPdfParse()(buffer);
|
||||||
|
text = result.text || '';
|
||||||
|
} catch (err) {
|
||||||
|
console.error('PDF extraction error:', err);
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
error: 'BAD_REQUEST',
|
||||||
|
message: 'Не удалось обработать PDF-файл',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!text || text.trim().length === 0) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
error: 'BAD_REQUEST',
|
||||||
|
message: 'Не удалось извлечь текст из PDF',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress('pdf', 8, 'Текст извлечён, отправка в LLM...');
|
||||||
|
|
||||||
|
const openai = new OpenAI({
|
||||||
|
apiKey: config.llmApiKey,
|
||||||
|
...(config.llmApiBaseUrl && { baseURL: config.llmApiBaseUrl }),
|
||||||
|
timeout: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stream = await openai.chat.completions.create({
|
||||||
|
model: config.llmModel,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: PDF2JSON_PROMPT },
|
||||||
|
{ role: 'user', content: `Текст выписки:\n\n${text}` },
|
||||||
|
],
|
||||||
|
temperature: 0,
|
||||||
|
max_tokens: 32768,
|
||||||
|
stream: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Estimate expected output size as ~2x the input PDF text length, clamped
|
||||||
|
const expectedChars = Math.max(2_000, Math.min(text.length * 2, 30_000));
|
||||||
|
|
||||||
|
let accumulated = '';
|
||||||
|
let charsReceived = 0;
|
||||||
|
let lastEmitTime = 0;
|
||||||
|
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
const delta = chunk.choices[0]?.delta?.content;
|
||||||
|
if (delta) {
|
||||||
|
accumulated += delta;
|
||||||
|
charsReceived += delta.length;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastEmitTime >= THROTTLE_MS) {
|
||||||
|
const ratio = Math.min(1, charsReceived / expectedChars);
|
||||||
|
const llmProgress = Math.min(
|
||||||
|
LLM_PROGRESS_MAX,
|
||||||
|
Math.round(ratio * LLM_PROGRESS_RANGE + LLM_PROGRESS_MIN),
|
||||||
|
);
|
||||||
|
onProgress('llm', llmProgress, 'Конвертация через LLM...');
|
||||||
|
lastEmitTime = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress('llm', LLM_PROGRESS_MAX, 'LLM завершил, обработка результата...');
|
||||||
|
|
||||||
|
const content = accumulated.trim();
|
||||||
|
if (!content) {
|
||||||
|
return {
|
||||||
|
status: 422,
|
||||||
|
error: 'VALIDATION_ERROR',
|
||||||
|
message: 'Результат конвертации пуст',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseConversionResult(content);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('LLM streaming error:', err);
|
||||||
|
return {
|
||||||
|
status: 502,
|
||||||
|
error: 'BAD_GATEWAY',
|
||||||
|
message: 'Временная ошибка конвертации',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseConversionResult(content: string): StatementFile | PdfConversionError {
|
||||||
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
||||||
const jsonStr = jsonMatch ? jsonMatch[0] : content;
|
const jsonStr = jsonMatch ? jsonMatch[0] : content;
|
||||||
let parsed: unknown;
|
let parsed: unknown;
|
||||||
@@ -145,12 +277,4 @@ export async function convertPdfToStatement(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return parsed as StatementFile;
|
return parsed as StatementFile;
|
||||||
} catch (err) {
|
|
||||||
console.error('LLM conversion error:', err);
|
|
||||||
return {
|
|
||||||
status: 502,
|
|
||||||
error: 'BAD_GATEWAY',
|
|
||||||
message: 'Временная ошибка конвертации',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,22 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
# Import endpoint — SSE streaming, long timeout, no buffering
|
||||||
|
location /api/import {
|
||||||
|
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 600s;
|
||||||
|
proxy_buffering off;
|
||||||
|
chunked_transfer_encoding off;
|
||||||
|
client_max_body_size 15m;
|
||||||
|
}
|
||||||
|
|
||||||
# API — проксируем на backend (сервис из docker-compose)
|
# API — проксируем на backend (сервис из docker-compose)
|
||||||
location /api {
|
location /api {
|
||||||
proxy_pass http://family-budget-backend:3000;
|
proxy_pass http://family-budget-backend:3000;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import { useAuth } from './context/AuthContext';
|
import { useAuth } from './context/AuthContext';
|
||||||
|
import { ImportProvider } from './context/ImportContext';
|
||||||
import { Layout } from './components/Layout';
|
import { Layout } from './components/Layout';
|
||||||
import { LoginPage } from './pages/LoginPage';
|
import { LoginPage } from './pages/LoginPage';
|
||||||
import { HistoryPage } from './pages/HistoryPage';
|
import { HistoryPage } from './pages/HistoryPage';
|
||||||
@@ -18,6 +19,7 @@ export function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<ImportProvider>
|
||||||
<Layout>
|
<Layout>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Navigate to="/history" replace />} />
|
<Route path="/" element={<Navigate to="/history" replace />} />
|
||||||
@@ -27,5 +29,6 @@ export function App() {
|
|||||||
<Route path="*" element={<Navigate to="/history" replace />} />
|
<Route path="*" element={<Navigate to="/history" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
</ImportProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,3 +11,74 @@ export async function importStatement(
|
|||||||
formData,
|
formData,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SseProgressEvent {
|
||||||
|
stage: 'pdf' | 'llm' | 'import';
|
||||||
|
progress: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SseDoneEvent {
|
||||||
|
stage: 'done';
|
||||||
|
progress: 100;
|
||||||
|
result: ImportStatementResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SseErrorEvent {
|
||||||
|
stage: 'error';
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SseEvent = SseProgressEvent | SseDoneEvent | SseErrorEvent;
|
||||||
|
|
||||||
|
export async function importStatementStream(
|
||||||
|
file: File,
|
||||||
|
onEvent: (event: SseEvent) => void,
|
||||||
|
): Promise<void> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const res = await fetch('/api/import/statement', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
let msg = 'Ошибка импорта';
|
||||||
|
try {
|
||||||
|
const body = await res.json();
|
||||||
|
if (body.message) msg = body.message;
|
||||||
|
} catch { /* use default */ }
|
||||||
|
onEvent({ stage: 'error', message: msg });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = res.body?.getReader();
|
||||||
|
if (!reader) {
|
||||||
|
onEvent({ stage: 'error', message: 'Streaming не поддерживается' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop() ?? '';
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed.startsWith('data: ')) continue;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed.slice(6)) as SseEvent;
|
||||||
|
onEvent(parsed);
|
||||||
|
} catch { /* skip malformed lines */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useRef } from 'react';
|
|||||||
import type { ImportStatementResponse } from '@family-budget/shared';
|
import type { ImportStatementResponse } from '@family-budget/shared';
|
||||||
import { importStatement } from '../api/import';
|
import { importStatement } from '../api/import';
|
||||||
import { updateAccount } from '../api/accounts';
|
import { updateAccount } from '../api/accounts';
|
||||||
|
import { useImport } from '../context/ImportContext';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -9,13 +10,19 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ImportModal({ onClose, onDone }: Props) {
|
export function ImportModal({ onClose, onDone }: Props) {
|
||||||
const [result, setResult] = useState<ImportStatementResponse | null>(null);
|
const { importState, startImport, clearImport } = useImport();
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [jsonResult, setJsonResult] = useState<ImportStatementResponse | null>(null);
|
||||||
|
const [jsonError, setJsonError] = useState('');
|
||||||
|
const [jsonLoading, setJsonLoading] = useState(false);
|
||||||
const [alias, setAlias] = useState('');
|
const [alias, setAlias] = useState('');
|
||||||
const [aliasSaved, setAliasSaved] = useState(false);
|
const [aliasSaved, setAliasSaved] = useState(false);
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const result = importState.result ?? jsonResult;
|
||||||
|
const error = importState.error || jsonError;
|
||||||
|
const loading = importState.active || jsonLoading;
|
||||||
|
|
||||||
const handleFileChange = async (
|
const handleFileChange = async (
|
||||||
e: React.ChangeEvent<HTMLInputElement>,
|
e: React.ChangeEvent<HTMLInputElement>,
|
||||||
) => {
|
) => {
|
||||||
@@ -28,26 +35,47 @@ export function ImportModal({ onClose, onDone }: Props) {
|
|||||||
const isJson = type === 'application/json' || name.endsWith('.json');
|
const isJson = type === 'application/json' || name.endsWith('.json');
|
||||||
|
|
||||||
if (!isPdf && !isJson) {
|
if (!isPdf && !isJson) {
|
||||||
setError('Допустимы только файлы PDF или JSON');
|
setJsonError('Допустимы только файлы PDF или JSON');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setJsonError('');
|
||||||
setError('');
|
setJsonResult(null);
|
||||||
setResult(null);
|
|
||||||
|
|
||||||
|
if (isPdf) {
|
||||||
|
startImport(file);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setJsonLoading(true);
|
||||||
try {
|
try {
|
||||||
const resp = await importStatement(file);
|
const resp = await importStatement(file);
|
||||||
setResult(resp);
|
setJsonResult(resp);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const msg =
|
const msg =
|
||||||
err instanceof Error ? err.message : 'Ошибка импорта';
|
err instanceof Error ? err.message : 'Ошибка импорта';
|
||||||
setError(msg);
|
setJsonError(msg);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setJsonLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (importState.active) {
|
||||||
|
if (window.confirm('Импорт продолжится в фоне. Закрыть окно?')) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (result || importState.error) clearImport();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDone = () => {
|
||||||
|
clearImport();
|
||||||
|
onDone();
|
||||||
|
};
|
||||||
|
|
||||||
const handleSaveAlias = async () => {
|
const handleSaveAlias = async () => {
|
||||||
if (!result || !alias.trim()) return;
|
if (!result || !alias.trim()) return;
|
||||||
try {
|
try {
|
||||||
@@ -58,12 +86,21 @@ export function ImportModal({ onClose, onDone }: Props) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const stageLabel = (stage: string) => {
|
||||||
|
switch (stage) {
|
||||||
|
case 'pdf': return 'Извлечение текста...';
|
||||||
|
case 'llm': return 'Конвертация через LLM...';
|
||||||
|
case 'import': return 'Сохранение в базу...';
|
||||||
|
default: return 'Обработка...';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay" onClick={onClose}>
|
<div className="modal-overlay" onClick={handleClose}>
|
||||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="modal-header">
|
<div className="modal-header">
|
||||||
<h2>Импорт выписки</h2>
|
<h2>Импорт выписки</h2>
|
||||||
<button className="btn-close" onClick={onClose}>
|
<button className="btn-close" onClick={handleClose}>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -71,7 +108,7 @@ export function ImportModal({ onClose, onDone }: Props) {
|
|||||||
<div className="modal-body">
|
<div className="modal-body">
|
||||||
{error && <div className="alert alert-error">{error}</div>}
|
{error && <div className="alert alert-error">{error}</div>}
|
||||||
|
|
||||||
{!result && (
|
{!result && !importState.active && (
|
||||||
<div className="import-upload">
|
<div className="import-upload">
|
||||||
<p>Выберите файл выписки (PDF или JSON, формат 1.0)</p>
|
<p>Выберите файл выписки (PDF или JSON, формат 1.0)</p>
|
||||||
<input
|
<input
|
||||||
@@ -80,16 +117,33 @@ export function ImportModal({ onClose, onDone }: Props) {
|
|||||||
accept=".pdf,.json,application/pdf,application/json"
|
accept=".pdf,.json,application/pdf,application/json"
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
className="file-input"
|
className="file-input"
|
||||||
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
{loading && (
|
{jsonLoading && (
|
||||||
<div className="import-loading">Импорт...</div>
|
<div className="import-loading">Импорт...</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{importState.active && (
|
||||||
|
<div className="import-upload">
|
||||||
|
<div className="import-progress-modal">
|
||||||
|
<div className="import-progress-modal-bar">
|
||||||
|
<div
|
||||||
|
className="import-progress-modal-fill"
|
||||||
|
style={{ width: `${importState.progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="import-progress-modal-label">
|
||||||
|
{stageLabel(importState.stage)} {importState.progress}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{result && (
|
{result && (
|
||||||
<div className="import-result">
|
<div className="import-result">
|
||||||
<div className="import-result-icon">✓</div>
|
<div className="import-result-icon" aria-hidden="true">✓</div>
|
||||||
<h3>Импорт завершён</h3>
|
<h3>Импорт завершён</h3>
|
||||||
<table className="import-stats">
|
<table className="import-stats">
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -149,16 +203,15 @@ export function ImportModal({ onClose, onDone }: Props) {
|
|||||||
|
|
||||||
<div className="modal-footer">
|
<div className="modal-footer">
|
||||||
{result ? (
|
{result ? (
|
||||||
<button className="btn btn-primary" onClick={onDone}>
|
<button className="btn btn-primary" onClick={handleDone}>
|
||||||
Готово
|
Готово
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
onClick={onClose}
|
onClick={handleClose}
|
||||||
disabled={loading}
|
|
||||||
>
|
>
|
||||||
Отмена
|
{importState.active ? 'Свернуть' : 'Отмена'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,70 @@
|
|||||||
import { useState, type ReactNode } from 'react';
|
import { useState, useEffect, useRef, useCallback, type ReactNode } from 'react';
|
||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { useImport } from '../context/ImportContext';
|
||||||
|
|
||||||
|
function ImportProgressBar() {
|
||||||
|
const { importState, clearImport } = useImport();
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
|
|
||||||
|
const isActive = importState.active;
|
||||||
|
const isDone = importState.stage === 'done';
|
||||||
|
const isError = importState.stage === 'error';
|
||||||
|
const showBar = isActive || isDone || isError;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showBar) {
|
||||||
|
setVisible(true);
|
||||||
|
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
|
||||||
|
}
|
||||||
|
if (isDone || isError) {
|
||||||
|
hideTimerRef.current = setTimeout(() => {
|
||||||
|
setVisible(false);
|
||||||
|
clearImport();
|
||||||
|
}, 10_000);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
|
||||||
|
};
|
||||||
|
}, [showBar, isDone, isError, clearImport]);
|
||||||
|
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
|
||||||
|
setVisible(false);
|
||||||
|
clearImport();
|
||||||
|
}, [clearImport]);
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
const barClass = isError
|
||||||
|
? 'import-progress-bar import-progress-bar--error'
|
||||||
|
: isDone
|
||||||
|
? 'import-progress-bar import-progress-bar--done'
|
||||||
|
: 'import-progress-bar';
|
||||||
|
|
||||||
|
const labelText = isError
|
||||||
|
? `Ошибка импорта: ${importState.message}`
|
||||||
|
: isDone && importState.result
|
||||||
|
? `Импорт завершён — ${importState.result.imported} операций`
|
||||||
|
: `${importState.message} ${importState.progress}%`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={barClass}>
|
||||||
|
<div
|
||||||
|
className="import-progress-bar__fill"
|
||||||
|
style={{ width: `${isDone ? 100 : importState.progress}%` }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`import-progress-label ${isDone || isError ? 'import-progress-label--clickable' : ''}`}
|
||||||
|
onClick={isDone || isError ? handleClick : undefined}
|
||||||
|
>
|
||||||
|
{labelText}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function Layout({ children }: { children: ReactNode }) {
|
export function Layout({ children }: { children: ReactNode }) {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
@@ -10,6 +74,8 @@ export function Layout({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="layout">
|
<div className="layout">
|
||||||
|
<ImportProgressBar />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="burger-btn"
|
className="burger-btn"
|
||||||
|
|||||||
105
frontend/src/context/ImportContext.tsx
Normal file
105
frontend/src/context/ImportContext.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useState,
|
||||||
|
useCallback,
|
||||||
|
useRef,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
import type { ImportStatementResponse } from '@family-budget/shared';
|
||||||
|
import { importStatementStream, type SseEvent } from '../api/import';
|
||||||
|
|
||||||
|
export interface ImportProgress {
|
||||||
|
active: boolean;
|
||||||
|
stage: string;
|
||||||
|
progress: number;
|
||||||
|
message: string;
|
||||||
|
result?: ImportStatementResponse;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportContextValue {
|
||||||
|
importState: ImportProgress;
|
||||||
|
startImport: (file: File) => void;
|
||||||
|
clearImport: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL: ImportProgress = {
|
||||||
|
active: false,
|
||||||
|
stage: '',
|
||||||
|
progress: 0,
|
||||||
|
message: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ImportContext = createContext<ImportContextValue | null>(null);
|
||||||
|
|
||||||
|
export function ImportProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [importState, setImportState] = useState<ImportProgress>(INITIAL);
|
||||||
|
const runningRef = useRef(false);
|
||||||
|
|
||||||
|
const startImport = useCallback((file: File) => {
|
||||||
|
if (runningRef.current) return;
|
||||||
|
runningRef.current = true;
|
||||||
|
|
||||||
|
setImportState({
|
||||||
|
active: true,
|
||||||
|
stage: 'pdf',
|
||||||
|
progress: 0,
|
||||||
|
message: 'Загрузка файла...',
|
||||||
|
});
|
||||||
|
|
||||||
|
importStatementStream(file, (event: SseEvent) => {
|
||||||
|
if (event.stage === 'done') {
|
||||||
|
setImportState({
|
||||||
|
active: false,
|
||||||
|
stage: 'done',
|
||||||
|
progress: 100,
|
||||||
|
message: 'Импорт завершён',
|
||||||
|
result: event.result,
|
||||||
|
});
|
||||||
|
runningRef.current = false;
|
||||||
|
} else if (event.stage === 'error') {
|
||||||
|
setImportState({
|
||||||
|
active: false,
|
||||||
|
stage: 'error',
|
||||||
|
progress: 0,
|
||||||
|
message: event.message,
|
||||||
|
error: event.message,
|
||||||
|
});
|
||||||
|
runningRef.current = false;
|
||||||
|
} else {
|
||||||
|
setImportState({
|
||||||
|
active: true,
|
||||||
|
stage: event.stage,
|
||||||
|
progress: event.progress,
|
||||||
|
message: event.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}).catch((err) => {
|
||||||
|
setImportState({
|
||||||
|
active: false,
|
||||||
|
stage: 'error',
|
||||||
|
progress: 0,
|
||||||
|
message: err instanceof Error ? err.message : 'Ошибка импорта',
|
||||||
|
error: err instanceof Error ? err.message : 'Ошибка импорта',
|
||||||
|
});
|
||||||
|
runningRef.current = false;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearImport = useCallback(() => {
|
||||||
|
setImportState(INITIAL);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ImportContext.Provider value={{ importState, startImport, clearImport }}>
|
||||||
|
{children}
|
||||||
|
</ImportContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useImport(): ImportContextValue {
|
||||||
|
const ctx = useContext(ImportContext);
|
||||||
|
if (!ctx) throw new Error('useImport must be used within ImportProvider');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -1058,6 +1058,152 @@ textarea {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Import progress bar in modal */
|
||||||
|
.import-progress-modal {
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-progress-modal-bar {
|
||||||
|
height: 8px;
|
||||||
|
background: var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-progress-modal-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, var(--color-primary), #6366f1);
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-progress-modal-fill::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent 0%,
|
||||||
|
rgba(255, 255, 255, 0.3) 50%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
animation: shimmer 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-progress-modal-label {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================================================
|
||||||
|
Import progress bar (fixed top, Layout)
|
||||||
|
================================================================ */
|
||||||
|
|
||||||
|
.import-progress-bar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 300;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-progress-bar--done {
|
||||||
|
background: var(--color-success-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-progress-bar--error {
|
||||||
|
background: var(--color-danger-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-progress-bar__fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 0 2px 2px 0;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
background: linear-gradient(90deg, var(--color-primary), #6366f1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-progress-bar__fill::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent 0%,
|
||||||
|
rgba(255, 255, 255, 0.4) 50%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
animation: shimmer 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-progress-bar--done .import-progress-bar__fill {
|
||||||
|
background: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-progress-bar--done .import-progress-bar__fill::after {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-progress-bar--error .import-progress-bar__fill {
|
||||||
|
background: var(--color-danger);
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-progress-bar--error .import-progress-bar__fill::after {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-progress-label {
|
||||||
|
position: fixed;
|
||||||
|
top: 6px;
|
||||||
|
right: 16px;
|
||||||
|
z-index: 301;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 4px 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: default;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-progress-label--clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-progress-label--clickable:hover {
|
||||||
|
background: var(--color-bg);
|
||||||
|
border-color: var(--color-border-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-progress-bar--done .import-progress-label {
|
||||||
|
color: var(--color-success);
|
||||||
|
border-color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-progress-bar--error .import-progress-label {
|
||||||
|
color: var(--color-danger);
|
||||||
|
border-color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
/* ================================================================
|
/* ================================================================
|
||||||
Tabs
|
Tabs
|
||||||
================================================================ */
|
================================================================ */
|
||||||
|
|||||||
255
package-lock.json
generated
255
package-lock.json
generated
@@ -24,7 +24,7 @@
|
|||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"multer": "^2.1.1",
|
"multer": "^2.1.1",
|
||||||
"openai": "^6.27.0",
|
"openai": "^6.27.0",
|
||||||
"pdf-parse": "^2.4.5",
|
"pdf-parse": "1.1.1",
|
||||||
"pg": "^8.19.0",
|
"pg": "^8.19.0",
|
||||||
"uuid": "^13.0.0"
|
"uuid": "^13.0.0"
|
||||||
},
|
},
|
||||||
@@ -40,6 +40,28 @@
|
|||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"backend/node_modules/debug": {
|
||||||
|
"version": "3.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
|
||||||
|
"integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"backend/node_modules/pdf-parse": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-v6ZJ/efsBpGrGGknjtq9J/oC8tZWq0KWL5vQrk2GlzLEQPUDB1ex+13Rmidl1neNN358Jn9EHZw5y07FFtaC7A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "^3.1.0",
|
||||||
|
"node-ensure": "^0.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.8.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"frontend": {
|
"frontend": {
|
||||||
"name": "@family-budget/frontend",
|
"name": "@family-budget/frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
@@ -89,7 +111,6 @@
|
|||||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.0",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.0",
|
||||||
@@ -854,190 +875,6 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@napi-rs/canvas": {
|
|
||||||
"version": "0.1.80",
|
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz",
|
|
||||||
"integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==",
|
|
||||||
"license": "MIT",
|
|
||||||
"workspaces": [
|
|
||||||
"e2e/*"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@napi-rs/canvas-android-arm64": "0.1.80",
|
|
||||||
"@napi-rs/canvas-darwin-arm64": "0.1.80",
|
|
||||||
"@napi-rs/canvas-darwin-x64": "0.1.80",
|
|
||||||
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80",
|
|
||||||
"@napi-rs/canvas-linux-arm64-gnu": "0.1.80",
|
|
||||||
"@napi-rs/canvas-linux-arm64-musl": "0.1.80",
|
|
||||||
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.80",
|
|
||||||
"@napi-rs/canvas-linux-x64-gnu": "0.1.80",
|
|
||||||
"@napi-rs/canvas-linux-x64-musl": "0.1.80",
|
|
||||||
"@napi-rs/canvas-win32-x64-msvc": "0.1.80"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@napi-rs/canvas-android-arm64": {
|
|
||||||
"version": "0.1.80",
|
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz",
|
|
||||||
"integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
|
||||||
"version": "0.1.80",
|
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz",
|
|
||||||
"integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@napi-rs/canvas-darwin-x64": {
|
|
||||||
"version": "0.1.80",
|
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz",
|
|
||||||
"integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
|
||||||
"version": "0.1.80",
|
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz",
|
|
||||||
"integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
|
||||||
"version": "0.1.80",
|
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz",
|
|
||||||
"integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
|
||||||
"version": "0.1.80",
|
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz",
|
|
||||||
"integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
|
||||||
"version": "0.1.80",
|
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz",
|
|
||||||
"integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==",
|
|
||||||
"cpu": [
|
|
||||||
"riscv64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
|
||||||
"version": "0.1.80",
|
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz",
|
|
||||||
"integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
|
||||||
"version": "0.1.80",
|
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz",
|
|
||||||
"integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
|
||||||
"version": "0.1.80",
|
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz",
|
|
||||||
"integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@rolldown/pluginutils": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.27",
|
"version": "1.0.0-beta.27",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||||
@@ -1557,7 +1394,6 @@
|
|||||||
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
|
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/body-parser": "*",
|
"@types/body-parser": "*",
|
||||||
"@types/express-serve-static-core": "^5.0.0",
|
"@types/express-serve-static-core": "^5.0.0",
|
||||||
@@ -1636,7 +1472,6 @@
|
|||||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
@@ -1776,7 +1611,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -2800,6 +2634,12 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-ensure": {
|
||||||
|
"version": "0.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-ensure/-/node-ensure-0.0.0.tgz",
|
||||||
|
"integrity": "sha512-DRI60hzo2oKN1ma0ckc6nQWlHU69RH6xN0sjQTjMpChPfTYvKZdcQFfdYK2RWbJcKyUizSIy/l8OTGxMAM1QDw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.27",
|
"version": "2.0.27",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
||||||
@@ -2889,44 +2729,11 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pdf-parse": {
|
|
||||||
"version": "2.4.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz",
|
|
||||||
"integrity": "sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@napi-rs/canvas": "0.1.80",
|
|
||||||
"pdfjs-dist": "5.4.296"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"pdf-parse": "bin/cli.mjs"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.16.0 <21 || >=22.3.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/mehmet-kozan"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/pdfjs-dist": {
|
|
||||||
"version": "5.4.296",
|
|
||||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz",
|
|
||||||
"integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.16.0 || >=22.3.0"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@napi-rs/canvas": "^0.1.80"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/pg": {
|
"node_modules/pg": {
|
||||||
"version": "8.19.0",
|
"version": "8.19.0",
|
||||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz",
|
||||||
"integrity": "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==",
|
"integrity": "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pg-connection-string": "^2.11.0",
|
"pg-connection-string": "^2.11.0",
|
||||||
"pg-pool": "^3.12.0",
|
"pg-pool": "^3.12.0",
|
||||||
@@ -3024,7 +2831,6 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -3174,7 +2980,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -3184,7 +2989,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@@ -4296,7 +4100,6 @@
|
|||||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.4.4",
|
"fdir": "^6.4.4",
|
||||||
|
|||||||
Reference in New Issue
Block a user