e0ffe7e686
- api/reports.ts: типизированные axios-helpers (listReportJobs/createReportJob/
retryReportJob/cancelReportJob/deleteReportJob) + ApiReportJob/Status/Format/
Counts/Quota interfaces. ensureCsrfCookie на mutating-вызовах.
- composables/reportsMapper.ts: mapApiReportJob (API → UI mock format с конверсией
pending→queued / processing→running). title строится на frontend'е (тип + period
с RU-месяцами «апр 2026» или диапазон «мар 2026 — апр 2026»). sizeText форматирует
bytes (B/KB/MB). timeText зависит от status (в очереди / в работе · Nс / N мин назад).
uiTypeToApi (deals → deals_export и т.д.).
- ReportsView.vue полностью переписан под API:
- onMounted → loadJobs (replace MOCK_JOBS на data из listReportJobs).
- usePolling 30 сек (фоновый авто-refresh).
- Submit → createReportJob → reload + success-alert + error-alert (validation+
общие ошибки извлекаются через extractValidationErrors/extractErrorMessage).
- canSubmit computed: disable если квота заполнена (active >= max).
- Reset-btn возвращает форму к defaults.
- Reload-btn (manual fast-path).
- Retry/Cancel/Download/Delete-кнопки → соответствующие API-вызовы;
Delete через v-dialog persistent confirm.
- fetch-error-alert на listReportJobs reject.
- Empty-state «Нет отчётов» когда jobs.length=0.
- canRetry проверяет retry_count<3 (max attempts CTO-6).
- Vitest +24 (всего 393/393, +24 от 369):
- reportsMapper.spec.ts +14: status mapping (pending/processing/done/failed) /
title (один месяц / диапазон) / format / sizeText (B/KB/MB/null) / attempt /
error / timeText (pending / processing / done «10 мин назад» / «только что») /
uiTypeToApi 4 slug'а / progress=50 для running.
- ReportsView.spec.ts переписан с MOCK_JOBS на vi.mock('api/reports') +12:
mount + listReportJobs called on mount / 4 type cards / default Сделки active /
4 формата / quota-banner из API / empty-state / done с Готов+Скачать /
failed с Ошибка+Повторить / failed retry_count=3 НЕ показывает Повторить /
pending с Отменить / Submit вызывает createReportJob+reload / Submit error →
submit-error-alert / Submit-btn disabled при квоте 3/3 / Reset / Reload-btn /
fetch-error-alert / Retry-btn / Cancel-btn / Delete confirm-dialog +
deleteReportJob.
Этап 4/4 эпика Reports backend ЗАКРЫТ. Эпик закрыт целиком.
Backend: 1 type (deals_export) × 4 формата (CSV/XLSX/JSON/PDF-stub).
Этап 2b (3 оставшихся типа: managers_summary/sources_summary/billing_summary)
— расширение через добавление 3 новых Provider-классов без изменений в архитектуре,
вынесено в Post-MVP backlog.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
109 lines
4.0 KiB
TypeScript
109 lines
4.0 KiB
TypeScript
import type { ApiReportJob, ApiReportStatus } from '../api/reports';
|
||
import type { ReportFormat, ReportJob, ReportStatus, ReportType } from './mockReports';
|
||
|
||
/**
|
||
* API → UI converter для ReportsView.
|
||
*
|
||
* Schema-канон → UI mock format:
|
||
* pending → queued
|
||
* processing → running
|
||
* done → done
|
||
* failed → failed
|
||
*
|
||
* Title строится на frontend'е (бэк не хранит) из type + period.
|
||
* timeText зависит от status: «в очереди» / «в работе» / «time1 → time2» / «N дней назад».
|
||
*/
|
||
|
||
const STATUS_MAP: Record<ApiReportStatus, ReportStatus> = {
|
||
pending: 'queued',
|
||
processing: 'running',
|
||
done: 'done',
|
||
failed: 'failed',
|
||
};
|
||
|
||
const TYPE_TITLES: Record<ApiReportJob['type'], string> = {
|
||
deals_export: 'Сделки · детально',
|
||
managers_summary: 'Менеджеры',
|
||
sources_summary: 'Источники',
|
||
billing_summary: 'Биллинг',
|
||
};
|
||
|
||
export function mapApiReportJob(api: ApiReportJob, now: Date = new Date()): ReportJob {
|
||
const baseTitle = TYPE_TITLES[api.type] ?? api.type;
|
||
const title = `${baseTitle} · ${formatPeriod(api.parameters.date_from, api.parameters.date_to)}`;
|
||
|
||
return {
|
||
id: api.id,
|
||
title,
|
||
format: api.parameters.format as ReportFormat,
|
||
status: STATUS_MAP[api.status],
|
||
sizeText: api.file_size !== null ? formatBytes(api.file_size) : null,
|
||
rowsText: null,
|
||
timeText: buildTimeText(api, now),
|
||
progress: api.status === 'processing' ? 50 : null,
|
||
attempt: api.retry_count + 1,
|
||
error: api.error_message,
|
||
};
|
||
}
|
||
|
||
function formatPeriod(dateFrom: string, dateTo: string): string {
|
||
const months = ['янв', 'фев', 'мар', 'апр', 'май', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек'];
|
||
const parse = (s: string): { month: string; year: number } | null => {
|
||
const parts = s.split('-');
|
||
if (parts.length < 3) return null;
|
||
const yr = parseInt(parts[0]!, 10);
|
||
const mo = parseInt(parts[1]!, 10);
|
||
if (Number.isNaN(yr) || Number.isNaN(mo) || mo < 1 || mo > 12) return null;
|
||
return { month: months[mo - 1]!, year: yr };
|
||
};
|
||
const from = parse(dateFrom);
|
||
const to = parse(dateTo);
|
||
if (!from || !to) return `${dateFrom} — ${dateTo}`;
|
||
if (from.year === to.year && from.month === to.month) {
|
||
return `${from.month} ${from.year}`;
|
||
}
|
||
return `${from.month} ${from.year} — ${to.month} ${to.year}`;
|
||
}
|
||
|
||
function buildTimeText(api: ApiReportJob, now: Date): string {
|
||
if (api.status === 'pending') return 'в очереди';
|
||
if (api.status === 'processing') {
|
||
const sec = api.created_at
|
||
? Math.max(1, Math.floor((now.getTime() - new Date(api.created_at).getTime()) / 1000))
|
||
: 0;
|
||
return `в работе · ${sec}с`;
|
||
}
|
||
// done | failed
|
||
if (api.finished_at !== null) {
|
||
const minutesAgo = Math.floor((now.getTime() - new Date(api.finished_at).getTime()) / 60_000);
|
||
if (minutesAgo < 1) return 'только что';
|
||
if (minutesAgo < 60) return `${minutesAgo} мин назад`;
|
||
if (minutesAgo < 60 * 24) return `${Math.floor(minutesAgo / 60)} ч назад`;
|
||
return `${Math.floor(minutesAgo / (60 * 24))} д назад`;
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function formatBytes(bytes: number): string {
|
||
if (bytes < 1024) return `${bytes} B`;
|
||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||
}
|
||
|
||
export function reportTypes(): ReportType[] {
|
||
return ['deals', 'managers', 'sources', 'billing'];
|
||
}
|
||
|
||
/**
|
||
* UI ReportType slug ('deals') → API ApiReportType ('deals_export').
|
||
*/
|
||
export function uiTypeToApi(uiType: ReportType): ApiReportJob['type'] {
|
||
const map: Record<ReportType, ApiReportJob['type']> = {
|
||
deals: 'deals_export',
|
||
managers: 'managers_summary',
|
||
sources: 'sources_summary',
|
||
billing: 'billing_summary',
|
||
};
|
||
return map[uiType];
|
||
}
|