Files
portal/app/resources/js/views/ReportsView.vue
T
Дмитрий 6387706be6 fix(a11y): .sep dot separator contrast 2.92:1 → 5.33:1 (Pattern B)
A11y rescan Pattern B — scoped CSS `.sep { color: #92907b; }` повторяется
в 8 компонентах (page-stats / page-meta / hero-meta containers с точкой-
разделителем `·`). На ivory page background #f6f3ec даёт contrast
2.92:1, ниже WCAG 2.1 AA 4.5:1 threshold.

Fix: #92907b → #6b6356 — same warm-grey hue, darker tone, gives
5.33:1 contrast. 8 files:

- views/{DealsView,BillingView,KanbanView,ReportsView}.vue
- components/dashboard/DashboardPageHead.vue
- components/deals/DealDetailHero.vue
- components/admin/tenants/TenantsStatsHeader.vue
- components/admin/tenant-detail/TenantDetailHeader.vue

Closes Pa11y «color-contrast» violations на /dashboard /billing /reports
(8 .sep elements total flagged).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 10:07:11 +03:00

258 lines
8.3 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 { 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');
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: 30_000 });
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> {
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>