143cc458c1
Q.DEFER.002 sub-B closure: manual Pa11y audit-pass via Playwright MCP login + axe-core CDN inject on 16 auth-required views. Found ~13 unique violation patterns, 12 fixed, 3 deferred to Q.DEFER.004. ROOT CAUSE found: AdminLayout `<v-navigation-drawer color="secondary" theme="dark">` resolved to Vuetify default-dark `secondary=#54b6b2` (Teal mid) instead of liderraForest `#012019` теало-нуар. Switching to direct hex preserves design intent + restores white-text contrast across all 8 admin views (~50 nodes color-contrast violations cleared). Patterns fixed: 1. AdminLayout sidebar palette (8 admin views): - color="secondary" → color="#012019" (root cause) - .brand-sub red #b94837 → #e06155 (3.41 → 5.08) - .nav-count gray #7a8c87 → #8a9c95 (4.26 → 5.34) - <v-list nav> + role="navigation" + aria-label (aria-required-children fix: <v-list role=list> had [role=link] children — undefined для list) 2. DashboardBalance .runway-bar — role="img" (aria-prohibited-attr fix) 3. DashboardKpiRow .delta-up — #2e8b57 → #1b6e3b (4.27 → 6.25) 4. TransactionsTable .tx-amount-up — #2e8b57 → #1b6e3b (same fix) 5. RemindersList .empty-hint — #9a9690 → #6b6356 (2.98 → 5.74; +liderra-muted alignment) 6. KanbanView .kanban-board — tabindex="0" role="region" aria-label (scrollable-region-focusable fix) 7. ProjectCard: - .v-progress-linear + :aria-label="Прогресс дневной нормы: N%" - icon menu :aria-label="Меню действий проекта «...»" - bulk-select .card-check input :aria-label="Выбрать проект «...»" 8. useStatusPill in_progress #3F7C95 → #2A5A6E (4.07 → 6.11); useStatusPill.spec.ts sync 9. ProjectsView toolbar select-all input aria-label 10. AdminTenants impersonate v-btn aria-label 11. Global app.css: `.v-messages, .v-field-label { --v-medium-emphasis-opacity: 0.7; }` Vuetify default ~0.52 → rendered #7a7a7a/#767471 fails 4.20-4.29:1; 0.7 → rendered ≈#595959 → 7.9:1+ passes WCAG AA. Re-verified post-fix via axe-core on all affected views: all clean except DEV-only `.dev-index-num` chip (tree-shaked в prod, not a real violation). Vitest verified post-fix: 79 files / 614 passed / 3 skipped / 0 failed (baseline preserved). 3 patterns deferred to Q.DEFER.004: - DealsTable VDataTable show-select bulk-checkboxes (6 nodes) — Vuetify slot rewrite needed - AdminSupplierPrices 9 form inputs — v-text-field/v-switch label props - Vuetify v-tooltip eager-mount aria-tooltip-name — library-level cosmetic Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
227 lines
7.3 KiB
Vue
227 lines
7.3 KiB
Vue
<template>
|
|
<div class="projects-view">
|
|
<div class="d-flex justify-space-between align-center mb-4">
|
|
<h1 class="text-h4">Проекты</h1>
|
|
<v-btn color="primary" prepend-icon="mdi-plus" @click="openCreate">+ Создать проект</v-btn>
|
|
</div>
|
|
|
|
<div class="d-flex gap-3 mb-4">
|
|
<v-select
|
|
v-model="store.filters.signal_type"
|
|
:items="typeFilters"
|
|
label="Тип"
|
|
clearable
|
|
density="comfortable"
|
|
style="max-width: 180px"
|
|
hide-details
|
|
@update:model-value="store.fetch()"
|
|
/>
|
|
<v-select
|
|
v-model="store.filters.status"
|
|
:items="statusFilters"
|
|
label="Статус"
|
|
clearable
|
|
density="comfortable"
|
|
style="max-width: 180px"
|
|
hide-details
|
|
@update:model-value="store.fetch()"
|
|
/>
|
|
<v-text-field
|
|
v-model="store.filters.search"
|
|
label="Поиск"
|
|
prepend-inner-icon="mdi-magnify"
|
|
density="comfortable"
|
|
hide-details
|
|
style="max-width: 240px"
|
|
@input="onSearchDebounced"
|
|
/>
|
|
</div>
|
|
|
|
<div v-if="!store.loading && store.items.length > 0" class="projects-toolbar d-flex align-center gap-3 mb-3">
|
|
<label class="toolbar-check" data-testid="select-all-toggle">
|
|
<input
|
|
type="checkbox"
|
|
aria-label="Выбрать все проекты по текущим фильтрам"
|
|
:checked="store.selectAllByFilter"
|
|
@change="(e) => onToggleSelectAll((e.target as HTMLInputElement).checked)"
|
|
/>
|
|
<span
|
|
class="toolbar-check__box"
|
|
:class="{ 'toolbar-check__box--partial': !store.selectAllByFilter && store.selectedIds.size > 0 }"
|
|
/>
|
|
</label>
|
|
<span class="text-body-2"
|
|
>Выбрано: {{ store.selectedIds.size }} из {{ store.total }} (по текущим фильтрам)</span
|
|
>
|
|
</div>
|
|
|
|
<div v-if="store.loading" class="text-center py-8">
|
|
<v-progress-circular indeterminate color="primary" />
|
|
</div>
|
|
<div v-else-if="store.items.length === 0" class="text-center py-12 text-medium-emphasis">
|
|
Нет проектов. Создайте первый — кнопка справа сверху.
|
|
</div>
|
|
<div v-else class="projects-grid">
|
|
<ProjectCard
|
|
v-for="project in store.items"
|
|
:key="project.id"
|
|
:project="project"
|
|
:selected="store.selectedIds.has(project.id)"
|
|
@toggle-select="store.toggleSelect"
|
|
@edit="openEdit"
|
|
@toggle-active="store.toggleActive"
|
|
@sync-now="(p: Project) => store.syncNow(p.id)"
|
|
@archive="(p: Project) => store.archive(p.id)"
|
|
/>
|
|
</div>
|
|
|
|
<BulkActionsBar v-if="store.selectedIds.size > 0" />
|
|
<NewProjectDialog v-model="createOpen" mode="create" @saved="store.fetch()" />
|
|
<EditProjectDialog v-model="editOpen" :project="editing" @saved="store.fetch()" />
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted, onUnmounted, watch } from 'vue';
|
|
import { useProjectsStore, type Project } from '../stores/projectsStore';
|
|
import ProjectCard from '../components/projects/ProjectCard.vue';
|
|
import BulkActionsBar from '../components/projects/BulkActionsBar.vue';
|
|
import NewProjectDialog from './projects/NewProjectDialog.vue';
|
|
import EditProjectDialog from './projects/EditProjectDialog.vue';
|
|
|
|
const store = useProjectsStore();
|
|
const createOpen = ref(false);
|
|
const editOpen = ref(false);
|
|
const editing = ref<Project | null>(null);
|
|
|
|
const typeFilters = [
|
|
{ title: 'Сайт', value: 'site' },
|
|
{ title: 'Звонок', value: 'call' },
|
|
{ title: 'СМС', value: 'sms' },
|
|
];
|
|
|
|
const statusFilters = [
|
|
{ title: 'Активные', value: 'active' },
|
|
{ title: 'На паузе', value: 'paused' },
|
|
{ title: 'Архивные', value: 'archived' },
|
|
];
|
|
|
|
let searchTimer: ReturnType<typeof setTimeout> | null = null;
|
|
function onSearchDebounced() {
|
|
if (searchTimer) clearTimeout(searchTimer);
|
|
searchTimer = setTimeout(() => store.fetch(), 300);
|
|
}
|
|
|
|
function openCreate() {
|
|
createOpen.value = true;
|
|
}
|
|
function openEdit(project: Project) {
|
|
editing.value = project;
|
|
editOpen.value = true;
|
|
}
|
|
|
|
function onToggleSelectAll(value: boolean | null) {
|
|
if (value) {
|
|
store.selectAllByFilter = true;
|
|
store.items.forEach((p: Project) => store.selectedIds.add(p.id));
|
|
} else {
|
|
store.selectAllByFilter = false;
|
|
store.clearSelection();
|
|
}
|
|
}
|
|
|
|
watch(
|
|
() => store.pendingIds.size,
|
|
(size) => {
|
|
if (size > 0) store.startPolling();
|
|
},
|
|
);
|
|
|
|
onMounted(store.fetch);
|
|
onUnmounted(() => store.stopPolling());
|
|
</script>
|
|
|
|
<style scoped>
|
|
.projects-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
|
gap: 16px;
|
|
}
|
|
/* Workaround: MDI-шрифт не подключён в проекте (Диз-4),
|
|
`<i class="mdi-close-circle">` рендерится пустым. Подменяем глиф на Unicode `✕`
|
|
и показываем только когда поле имеет значение (Vuetify ставит `.v-field--dirty`). */
|
|
.projects-view :deep(.v-field__clearable) {
|
|
position: relative;
|
|
}
|
|
.projects-view :deep(.v-field__clearable .v-icon) {
|
|
color: transparent;
|
|
width: 20px;
|
|
height: 20px;
|
|
}
|
|
.projects-view :deep(.v-field--dirty .v-field__clearable)::after {
|
|
content: '✕';
|
|
position: absolute;
|
|
inset: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: rgba(1, 32, 25, 0.55);
|
|
font-size: 14px;
|
|
font-family: 'Inter', system-ui, sans-serif;
|
|
pointer-events: none;
|
|
transition: color 150ms ease;
|
|
}
|
|
.projects-view :deep(.v-field--dirty .v-field__clearable:hover)::after {
|
|
color: var(--liderra-noir, #012019);
|
|
}
|
|
.toolbar-check {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
cursor: pointer;
|
|
padding: 4px;
|
|
}
|
|
.toolbar-check input {
|
|
position: absolute;
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
}
|
|
.toolbar-check__box {
|
|
width: 20px;
|
|
height: 20px;
|
|
border: 2px solid var(--liderra-noir);
|
|
border-radius: 4px;
|
|
background: #fff;
|
|
display: inline-block;
|
|
position: relative;
|
|
transition: background 150ms ease;
|
|
}
|
|
.toolbar-check input:checked + .toolbar-check__box {
|
|
background: var(--liderra-teal, #0f6e56);
|
|
border-color: var(--liderra-teal, #0f6e56);
|
|
}
|
|
.toolbar-check input:checked + .toolbar-check__box::after {
|
|
content: '';
|
|
position: absolute;
|
|
left: 5px;
|
|
top: 1px;
|
|
width: 6px;
|
|
height: 11px;
|
|
border: solid #fff;
|
|
border-width: 0 2px 2px 0;
|
|
transform: rotate(45deg);
|
|
}
|
|
.toolbar-check__box--partial {
|
|
background: var(--liderra-teal, #0f6e56);
|
|
border-color: var(--liderra-teal, #0f6e56);
|
|
}
|
|
.toolbar-check__box--partial::after {
|
|
content: '';
|
|
position: absolute;
|
|
left: 3px;
|
|
top: 7px;
|
|
width: 10px;
|
|
height: 2px;
|
|
background: #fff;
|
|
}
|
|
</style>
|