6387706be6
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>
258 lines
8.3 KiB
Vue
258 lines
8.3 KiB
Vue
<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>
|