Files
portal/app/resources/js/composables/reportsMapper.ts
T
Дмитрий e0ffe7e686 phase2(reports-stage4): frontend integration ReportsView (replace mock)
- 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>
2026-05-09 13:49:55 +03:00

109 lines
4.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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];
}