Files
portal/app/resources/js/components/projects/ProjectCard.vue
T
Дмитрий 143cc458c1 fix(a11y): Q.DEFER.002 sub-B — 12 patterns fixed across 16 auth views
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>
2026-05-12 22:09:48 +03:00

181 lines
6.9 KiB
Vue

<template>
<v-card class="project-card ld-hover-lift" :class="{ paused: !project.is_active }" elevation="1">
<v-card-item>
<template #prepend>
<label class="card-check" data-testid="card-select">
<input
type="checkbox"
:checked="selected"
:aria-label="`Выбрать проект «${project.name}»`"
@change="$emit('toggle-select', project.id)"
/>
<span class="card-check__box" />
</label>
</template>
<v-card-title>
{{ project.name }}
<v-chip size="x-small" :color="typeColor" class="ml-2">{{ typeLabel }}</v-chip>
</v-card-title>
<v-card-subtitle>{{ identifierDisplay }}</v-card-subtitle>
<template #append>
<v-menu>
<template #activator="{ props: menuProps }">
<v-btn
icon="mdi-dots-vertical"
variant="text"
size="small"
:aria-label="`Меню действий проекта «${project.name}»`"
v-bind="menuProps"
/>
</template>
<v-list density="compact">
<v-list-item @click="$emit('edit', project)">
<template #prepend><v-icon>mdi-pencil</v-icon></template>
<v-list-item-title>Редактировать</v-list-item-title>
</v-list-item>
<v-list-item @click="$emit('toggle-active', project)">
<template #prepend
><v-icon>{{ project.is_active ? 'mdi-pause' : 'mdi-play' }}</v-icon></template
>
<v-list-item-title>{{
project.is_active ? 'Приостановить' : 'Возобновить'
}}</v-list-item-title>
</v-list-item>
<v-list-item @click="$emit('sync-now', project)">
<template #prepend><v-icon>mdi-refresh</v-icon></template>
<v-list-item-title>Синхронизировать</v-list-item-title>
</v-list-item>
<v-list-item @click="$emit('archive', project)">
<template #prepend><v-icon>mdi-archive</v-icon></template>
<v-list-item-title>Архивировать</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</template>
</v-card-item>
<v-card-text>
<div v-if="project.is_active" class="mb-2">
<div class="d-flex justify-space-between">
<span class="text-caption"
><span class="ld-mono">{{ project.delivered_today }}</span> /
<span class="ld-mono">{{ project.daily_limit_target }}</span> лидов</span
>
<span class="text-caption text-medium-emphasis"
><span class="ld-mono">{{ progressPercent }}</span
>%</span
>
</div>
<v-progress-linear
:model-value="progressPercent"
:color="progressColor"
height="6"
rounded
:aria-label="`Прогресс дневной нормы: ${progressPercent}%`"
/>
</div>
<div v-else class="text-caption text-medium-emphasis mb-2">На паузе</div>
<v-chip :color="syncStatusColor" size="x-small" variant="tonal">
<v-icon start size="x-small">{{ syncStatusIcon }}</v-icon>
{{ syncStatusLabel }}
</v-chip>
</v-card-text>
</v-card>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import type { Project } from '../../stores/projectsStore';
const props = defineProps<{ project: Project; selected: boolean }>();
defineEmits<{
'toggle-select': [id: number];
edit: [project: Project];
'toggle-active': [project: Project];
'sync-now': [project: Project];
archive: [project: Project];
}>();
const typeLabel = computed(() => ({ site: 'Сайт', call: 'Звонок', sms: 'СМС' })[props.project.signal_type]);
const typeColor = computed(
() => ({ site: 'blue-lighten-4', call: 'orange-lighten-4', sms: 'purple-lighten-4' })[props.project.signal_type],
);
const identifierDisplay = computed(() => {
if (props.project.signal_type === 'sms') {
return [(props.project.sms_senders ?? []).join(', '), props.project.sms_keyword].filter(Boolean).join(' · ');
}
return props.project.signal_identifier ?? '';
});
const progressPercent = computed(() =>
Math.min(100, Math.round((props.project.delivered_today / props.project.daily_limit_target) * 100)),
);
const progressColor = computed(() => (progressPercent.value >= 90 ? 'success' : 'primary'));
const syncStatusLabel = computed(
() => ({ ok: 'Sync OK', pending: 'Sync pending', failed: 'Sync failed' })[props.project.sync_status],
);
const syncStatusIcon = computed(
() =>
({ ok: 'mdi-check-circle', pending: 'mdi-clock-outline', failed: 'mdi-alert-circle' })[
props.project.sync_status
],
);
const syncStatusColor = computed(
() => ({ ok: 'success', pending: 'warning', failed: 'error' })[props.project.sync_status],
);
</script>
<style scoped>
.project-card.paused {
opacity: 0.75;
}
.card-check {
display: inline-flex;
align-items: center;
cursor: pointer;
padding: 4px;
}
.card-check input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.card-check__box {
width: 16px;
height: 16px;
border: 1px solid var(--liderra-line);
border-radius: var(--radius-6);
background: var(--liderra-surface);
display: inline-block;
position: relative;
transition:
border-color 200ms cubic-bezier(0.16, 1, 0.3, 1),
background-color 200ms cubic-bezier(0.16, 1, 0.3, 1);
}
.card-check:hover .card-check__box {
border-color: var(--liderra-line-strong);
}
.card-check input:focus-visible + .card-check__box {
outline: 2px solid var(--liderra-teal);
outline-offset: 2px;
}
.card-check input:checked + .card-check__box {
background: rgba(15, 110, 86, 0.1);
border-color: var(--liderra-teal);
}
.card-check input:checked + .card-check__box::after {
content: '';
position: absolute;
left: 4px;
top: 0;
width: 5px;
height: 9px;
border: solid var(--liderra-teal);
border-width: 0 1.5px 1.5px 0;
transform: rotate(45deg);
}
</style>