Files
portal/app/resources/js/views/ReportsView.vue
T
Дмитрий eb1ab278fe
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
fix(reports): сбрасывать зелёный «отчёт создан» при отмене задачи (ui-audit минор)
- ReportsView.onCancel снимает submitSuccess — success-баннер больше не висит после отмены отчёта. TDD: тест «отмена задачи сбрасывает зелёный» (RED→GREEN), весь ReportsView.spec 22/22, eslint 0.
- docs: ui-audit-round2 — минор тоста помечен исправленным (открытых пунктов не осталось); stage5-checklist — supplier:rekey-orphans dry-run проверен на проде 22.06 («No orphan»), разовая миграция не нужна.
- На прод не выкачено.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 18:32:10 +03:00

264 lines
8.7 KiB
Vue
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.
<script setup lang="ts">
/**
* Отчёты — асинхронная генерация выгрузок (CSV/XLSX/JSON/PDF) с очередью
* и квотой 3 одновременных (CTO-7) + retry до 3 попыток в окне 7 дней (CTO-6).
*
* Backend (этапы 1-3 эпика): `App\Http\Controllers\Api\ReportJobController`.
* Этап 4: интеграция API в этом view'е.
*
* Polling 30 сек обновляет список (через usePolling).
*/
import { computed, onMounted, ref } from 'vue';
import { cancelReportJob, createReportJob, deleteReportJob, listReportJobs, retryReportJob } from '../api/reports';
import { extractErrorMessage, extractValidationErrors } from '../api/client';
import { usePolling } from '../composables/usePolling';
import { POLLING_INTERVAL_MS } from '../constants/polling';
import { type ReportFormat, type ReportJob, type ReportType } from '../composables/mockReports';
import { mapApiReportJob, uiTypeToApi } from '../composables/reportsMapper';
import ReportRequestForm from '../components/reports/ReportRequestForm.vue';
import ReportJobsList from '../components/reports/ReportJobsList.vue';
const selectedType = ref<ReportType>('deals');
const dateFrom = ref(new Date().toISOString().slice(0, 10));
const dateTo = ref(new Date().toISOString().slice(0, 10));
const project = ref('Все проекты');
const manager = ref('Все');
const selectedFormat = ref<ReportFormat>('csv');
// Фейк-варианты убраны (захардкожены, не из API и даже не отправлялись на бэк).
// Пока только «Все» — реальные проекты/менеджеры будут подведены из API.
const projectOptions = ['Все проекты'];
const managerOptions = ['Все'];
// State
const jobs = ref<ReportJob[]>([]);
const loading = ref(false);
const submitting = ref(false);
const fetchError = ref<string | null>(null);
const submitError = ref<string | null>(null);
const submitSuccess = ref(false);
const quotaActive = ref(0);
const quotaMax = ref(3);
const totalDone = ref(0);
async function loadJobs(): Promise<void> {
loading.value = true;
try {
const data = await listReportJobs({ limit: 50 });
const now = new Date();
jobs.value = data.jobs.map((j) => mapApiReportJob(j, now));
quotaActive.value = data.quota.active;
quotaMax.value = data.quota.max_active;
totalDone.value = data.counts.done;
fetchError.value = null;
} catch (e) {
fetchError.value = extractErrorMessage(e);
} finally {
loading.value = false;
}
}
onMounted(() => {
void loadJobs();
});
usePolling(loadJobs, { intervalMs: POLLING_INTERVAL_MS });
async function submitForm(): Promise<void> {
submitting.value = true;
submitError.value = null;
submitSuccess.value = false;
try {
await createReportJob({
type: uiTypeToApi(selectedType.value),
format: selectedFormat.value,
parameters: {
date_from: dateFrom.value,
date_to: dateTo.value,
},
});
submitSuccess.value = true;
await loadJobs();
} catch (e) {
const validation = extractValidationErrors(e);
if (validation && Object.keys(validation).length > 0) {
submitError.value = Object.values(validation).flat().join(' ');
} else {
submitError.value = extractErrorMessage(e);
}
} finally {
submitting.value = false;
}
}
function resetForm(): void {
selectedType.value = 'deals';
selectedFormat.value = 'csv';
project.value = 'Все проекты';
manager.value = 'Все';
dateFrom.value = new Date().toISOString().slice(0, 10);
dateTo.value = new Date().toISOString().slice(0, 10);
submitError.value = null;
submitSuccess.value = false;
}
async function onRetry(jobId: number): Promise<void> {
try {
await retryReportJob(jobId);
await loadJobs();
} catch (e) {
const validation = extractValidationErrors(e);
if (validation && Object.keys(validation).length > 0) {
submitError.value = Object.values(validation).flat().join(' ');
} else {
submitError.value = extractErrorMessage(e);
}
}
}
async function onCancel(jobId: number): Promise<void> {
// Действие над существующей задачей снимает зелёный «отчёт создан» —
// иначе success-баннер висит после отмены (F-минор ui-audit-round2).
submitSuccess.value = false;
try {
await cancelReportJob(jobId);
await loadJobs();
} catch (e) {
submitError.value = extractErrorMessage(e);
}
}
const deleteDialog = ref(false);
const deleteTargetId = ref<number | null>(null);
function askDelete(jobId: number): void {
deleteTargetId.value = jobId;
deleteDialog.value = true;
}
async function confirmDelete(): Promise<void> {
if (deleteTargetId.value === null) return;
try {
await deleteReportJob(deleteTargetId.value);
await loadJobs();
} catch (e) {
submitError.value = extractErrorMessage(e);
} finally {
deleteDialog.value = false;
deleteTargetId.value = null;
}
}
const canSubmit = computed(() => quotaActive.value < quotaMax.value && !submitting.value);
</script>
<template>
<v-container fluid class="reports pa-6">
<header class="page-head">
<div>
<h1 class="text-h4 mb-2 page-title">Отчёты</h1>
<div class="page-stats text-body-2 text-medium-emphasis">
очередь
<span class="num text-primary">{{ quotaActive }}</span
>/<span class="num">{{ quotaMax }}</span>
<span class="sep">·</span>
готово <span class="num">{{ totalDone }}</span>
</div>
</div>
<v-btn
variant="outlined"
size="small"
prepend-icon="mdi-refresh"
:loading="loading"
data-testid="reload-btn"
@click="loadJobs"
>
Обновить
</v-btn>
</header>
<v-alert
v-if="fetchError"
type="warning"
variant="tonal"
density="compact"
class="mt-3"
closable
data-testid="fetch-error-alert"
>
Backend недоступен: {{ fetchError }}
</v-alert>
<ReportRequestForm
v-model:selected-type="selectedType"
v-model:date-from="dateFrom"
v-model:date-to="dateTo"
v-model:project="project"
v-model:manager="manager"
v-model:selected-format="selectedFormat"
:project-options="projectOptions"
:manager-options="managerOptions"
:submitting="submitting"
:submit-success="submitSuccess"
:submit-error="submitError"
:can-submit="canSubmit"
:quota-active="quotaActive"
:quota-max="quotaMax"
@submit="submitForm"
@reset="resetForm"
/>
<ReportJobsList :jobs="jobs" @retry="onRetry" @cancel="onCancel" @request-delete="askDelete" />
<v-dialog v-model="deleteDialog" max-width="420" persistent>
<v-card>
<v-card-title>Удалить отчёт?</v-card-title>
<v-card-text>Файл будет удалён вместе с записью. Действие необратимо.</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="deleteDialog = false">Отмена</v-btn>
<v-btn color="error" variant="flat" data-testid="delete-confirm-btn" @click="confirmDelete"
>Удалить</v-btn
>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</template>
<style scoped>
.reports {
max-width: 1440px;
}
.page-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: 12px;
}
.page-title {
font-variation-settings: 'opsz' 28;
letter-spacing: -0.018em;
}
.page-stats {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.page-stats .sep {
/* WCAG2AA 4.5:1 fix (was #92907b → 2.92:1 on ivory; #6b6356 → 5.33:1). */
color: #6b6356;
}
.num {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-feature-settings: 'tnum';
font-weight: 500;
}
</style>