5f209a2fcc
Раунд 2 минор-фиксы (Playwright-аудит): - RuDateField (новый): даты дд.мм.гггг через ru date-picker вместо нативного <input type=date> (показывал мм/дд/гггг на en-локали) — Отчёты + Сделки. - BalanceCapacityIndicator: разделитель тысяч «1 000 ₽», эмодзи→mdi. - dealsApiMapper/DealDetailBody: статус-смена в активности русскими метками (было «viewed → new» сырыми слагами). - ProfileTab: инлайн-валидация Имя/Фамилия (под полем, как в Реквизитах). - RequisitesTab: проверка формата телефона на клиенте. - ApiTab: eye-toggle с aria-label (показать/скрыть ключ и секрет). - DashboardView: «3 / 0» → скрываем «/ N» и «лимит тарифа» при лимите 0. - KanbanView: тост-подтверждение при смене статуса (+ цветной фейл-тост). - NotificationsTab: убран жаргон «users.notification_preferences в БД». - Админка: TenantsTable «ИНН не указан» вместо пустого «ИНН »; PricingTiers epoch-дата «1970»→«начала» + ru-формат цены; Incidents empty-state «Инцидентов нет»; SupplierIntegration/PdSubjectRequests — window.confirm/alert → v-dialog/snackbar. Верификация: type-check, build, Playwright (даты дд.мм.гггг подтверждены). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
497 lines
20 KiB
Vue
497 lines
20 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* Adminка SaaS → Обращения субъектов ПДн (152-ФЗ).
|
||
*
|
||
* Список обращений на удаление/доступ/исправление/возражение.
|
||
* Для request_type='deletion' — кнопка «Анонимизировать» (§ 1.5, дыра #4).
|
||
*
|
||
* API: GET/POST /api/admin/pd-subject-requests, POST /{id}/erase
|
||
*/
|
||
import { onMounted, ref, reactive, computed } from 'vue';
|
||
import * as adminApi from '../../api/admin';
|
||
import type { PdSubjectRequest, CreatePdRequestPayload } from '../../api/admin';
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// State
|
||
// ---------------------------------------------------------------------------
|
||
const rows = ref<PdSubjectRequest[]>([]);
|
||
const total = ref(0);
|
||
const loading = ref(false);
|
||
const fetchError = ref(false);
|
||
|
||
const filterStatus = ref('');
|
||
const filterType = ref('');
|
||
|
||
// Dialog: create
|
||
const createDialog = ref(false);
|
||
const createLoading = ref(false);
|
||
const createError = ref('');
|
||
const createForm = reactive<CreatePdRequestPayload>({
|
||
subject_email: '',
|
||
subject_phone: '',
|
||
subject_full_name: '',
|
||
request_type: 'deletion',
|
||
description: '',
|
||
tenant_id: null,
|
||
});
|
||
|
||
// Dialog: erase confirm
|
||
const eraseDialog = ref(false);
|
||
const eraseLoading = ref(false);
|
||
// UI-аудит: window.alert → v-snackbar.
|
||
const errToastOpen = ref(false);
|
||
const errToastText = ref('');
|
||
const eraseTarget = ref<PdSubjectRequest | null>(null);
|
||
const eraseResult = ref<{ users: number; leads: number; deals: number; webhook_log: number } | null>(null);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Load data
|
||
// ---------------------------------------------------------------------------
|
||
async function loadRows(): Promise<void> {
|
||
loading.value = true;
|
||
fetchError.value = false;
|
||
try {
|
||
const res = await adminApi.listPdSubjectRequests({
|
||
status: filterStatus.value || undefined,
|
||
request_type: filterType.value || undefined,
|
||
limit: 100,
|
||
offset: 0,
|
||
});
|
||
rows.value = res.data;
|
||
total.value = res.total;
|
||
} catch {
|
||
fetchError.value = true;
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
onMounted(loadRows);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Create request
|
||
// ---------------------------------------------------------------------------
|
||
async function submitCreate(): Promise<void> {
|
||
createError.value = '';
|
||
if (!createForm.subject_email && !createForm.subject_phone) {
|
||
createError.value = 'Укажите email или телефон субъекта.';
|
||
return;
|
||
}
|
||
createLoading.value = true;
|
||
try {
|
||
await adminApi.createPdSubjectRequest({
|
||
subject_email: createForm.subject_email || undefined,
|
||
subject_phone: createForm.subject_phone || undefined,
|
||
subject_full_name: createForm.subject_full_name || undefined,
|
||
request_type: createForm.request_type,
|
||
description: createForm.description || undefined,
|
||
tenant_id: createForm.tenant_id ?? undefined,
|
||
});
|
||
createDialog.value = false;
|
||
resetCreateForm();
|
||
await loadRows();
|
||
} catch (e: unknown) {
|
||
const err = e as { response?: { data?: { message?: string } } };
|
||
createError.value = err?.response?.data?.message ?? 'Ошибка при создании обращения.';
|
||
} finally {
|
||
createLoading.value = false;
|
||
}
|
||
}
|
||
|
||
function resetCreateForm(): void {
|
||
createForm.subject_email = '';
|
||
createForm.subject_phone = '';
|
||
createForm.subject_full_name = '';
|
||
createForm.request_type = 'deletion';
|
||
createForm.description = '';
|
||
createForm.tenant_id = null;
|
||
createError.value = '';
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Erase
|
||
// ---------------------------------------------------------------------------
|
||
function openErase(row: PdSubjectRequest): void {
|
||
eraseTarget.value = row;
|
||
eraseResult.value = null;
|
||
eraseDialog.value = true;
|
||
}
|
||
|
||
async function confirmErase(): Promise<void> {
|
||
if (!eraseTarget.value) return;
|
||
eraseLoading.value = true;
|
||
try {
|
||
const res = await adminApi.executePdErasure(eraseTarget.value.id);
|
||
eraseResult.value = res.counts;
|
||
// Update row status in list
|
||
const idx = rows.value.findIndex((r) => r.id === eraseTarget.value?.id);
|
||
if (idx !== -1) {
|
||
rows.value[idx] = { ...rows.value[idx], status: 'completed' };
|
||
}
|
||
} catch (e: unknown) {
|
||
const err = e as { response?: { data?: { message?: string } } };
|
||
errToastText.value = err?.response?.data?.message ?? 'Ошибка анонимизации.';
|
||
errToastOpen.value = true;
|
||
eraseDialog.value = false;
|
||
} finally {
|
||
eraseLoading.value = false;
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Helpers
|
||
// ---------------------------------------------------------------------------
|
||
const statusLabels: Record<string, { label: string; color: string }> = {
|
||
received: { label: 'Получено', color: 'info' },
|
||
in_progress: { label: 'В работе', color: 'warning' },
|
||
completed: { label: 'Выполнено', color: 'success' },
|
||
rejected: { label: 'Отклонено', color: 'error' },
|
||
};
|
||
function statusInfo(s: string) {
|
||
return statusLabels[s] ?? { label: s, color: 'default' };
|
||
}
|
||
|
||
const typeLabels: Record<string, string> = {
|
||
access: 'Доступ',
|
||
rectification: 'Исправление',
|
||
deletion: 'Удаление',
|
||
objection: 'Возражение',
|
||
};
|
||
function typeLabel(t: string): string {
|
||
return typeLabels[t] ?? t;
|
||
}
|
||
|
||
function formatDate(iso: string | null): string {
|
||
if (!iso) return '—';
|
||
return new Date(iso).toLocaleString('ru-RU', {
|
||
day: '2-digit',
|
||
month: '2-digit',
|
||
year: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
});
|
||
}
|
||
|
||
const headers = [
|
||
{ title: 'ID', key: 'id', width: '60px' },
|
||
{ title: 'Получено', key: 'received_at', width: '140px' },
|
||
{ title: 'Email / тел.', key: 'contact', sortable: false },
|
||
{ title: 'Тип', key: 'request_type', width: '110px' },
|
||
{ title: 'Статус', key: 'status', width: '120px' },
|
||
{ title: 'Дедлайн', key: 'deadline_at', width: '140px' },
|
||
{ title: 'Действия', key: 'actions', sortable: false, width: '140px', align: 'end' as const },
|
||
];
|
||
|
||
const filteredRows = computed(() => rows.value);
|
||
|
||
defineExpose({ rows, loading, fetchError, loadRows });
|
||
</script>
|
||
|
||
<template>
|
||
<v-container fluid class="admin-pd pa-6">
|
||
<!-- Page head -->
|
||
<header class="page-head mb-4 d-flex justify-space-between align-start flex-wrap ga-3">
|
||
<div>
|
||
<h1 class="text-h4 page-title">Обращения субъектов ПДн</h1>
|
||
<p class="text-body-2 text-medium-emphasis ma-0">
|
||
Обращения на доступ, исправление, удаление и возражение (152-ФЗ). Срок ответа — 30 дней.
|
||
</p>
|
||
</div>
|
||
<div class="d-flex ga-2">
|
||
<v-btn
|
||
variant="outlined"
|
||
prepend-icon="mdi-refresh"
|
||
:loading="loading"
|
||
data-testid="reload-btn"
|
||
@click="loadRows"
|
||
>
|
||
Обновить
|
||
</v-btn>
|
||
<v-btn color="primary" prepend-icon="mdi-plus" data-testid="create-btn" @click="createDialog = true">
|
||
Новый запрос
|
||
</v-btn>
|
||
</div>
|
||
</header>
|
||
|
||
<v-alert
|
||
v-if="fetchError"
|
||
type="warning"
|
||
variant="tonal"
|
||
density="compact"
|
||
closable
|
||
class="mb-4"
|
||
data-testid="fetch-error-alert"
|
||
>
|
||
Не удалось загрузить обращения. Попробуйте обновить.
|
||
</v-alert>
|
||
|
||
<!-- Filters -->
|
||
<v-card variant="outlined" class="pa-3 mb-4">
|
||
<v-row dense>
|
||
<v-col cols="12" sm="4">
|
||
<v-select
|
||
v-model="filterStatus"
|
||
label="Статус"
|
||
:items="[
|
||
{ title: 'Все статусы', value: '' },
|
||
{ title: 'Получено', value: 'received' },
|
||
{ title: 'В работе', value: 'in_progress' },
|
||
{ title: 'Выполнено', value: 'completed' },
|
||
{ title: 'Отклонено', value: 'rejected' },
|
||
]"
|
||
density="compact"
|
||
variant="outlined"
|
||
hide-details
|
||
@update:model-value="loadRows"
|
||
/>
|
||
</v-col>
|
||
<v-col cols="12" sm="4">
|
||
<v-select
|
||
v-model="filterType"
|
||
label="Тип обращения"
|
||
:items="[
|
||
{ title: 'Все типы', value: '' },
|
||
{ title: 'Доступ', value: 'access' },
|
||
{ title: 'Исправление', value: 'rectification' },
|
||
{ title: 'Удаление', value: 'deletion' },
|
||
{ title: 'Возражение', value: 'objection' },
|
||
]"
|
||
density="compact"
|
||
variant="outlined"
|
||
hide-details
|
||
@update:model-value="loadRows"
|
||
/>
|
||
</v-col>
|
||
<v-col cols="12" sm="4" class="d-flex align-center">
|
||
<span class="text-body-2 text-medium-emphasis">Всего: {{ total }}</span>
|
||
</v-col>
|
||
</v-row>
|
||
</v-card>
|
||
|
||
<!-- Table -->
|
||
<v-card variant="outlined">
|
||
<v-data-table
|
||
:headers="headers"
|
||
:items="filteredRows"
|
||
:loading="loading"
|
||
item-value="id"
|
||
density="compact"
|
||
no-data-text="Обращений нет"
|
||
data-testid="pd-requests-table"
|
||
>
|
||
<template #[`item.received_at`]="{ item }">
|
||
<span class="text-caption font-mono">{{ formatDate(item.received_at) }}</span>
|
||
</template>
|
||
|
||
<template #[`item.contact`]="{ item }">
|
||
<div>
|
||
<span v-if="item.subject_email" class="d-block text-body-2">{{ item.subject_email }}</span>
|
||
<span v-if="item.subject_phone" class="d-block text-caption text-medium-emphasis">
|
||
{{ item.subject_phone }}
|
||
</span>
|
||
<span v-if="item.subject_full_name" class="d-block text-caption text-medium-emphasis">
|
||
{{ item.subject_full_name }}
|
||
</span>
|
||
</div>
|
||
</template>
|
||
|
||
<template #[`item.request_type`]="{ item }">
|
||
<v-chip
|
||
:color="item.request_type === 'deletion' ? 'error' : 'default'"
|
||
size="x-small"
|
||
variant="tonal"
|
||
>
|
||
{{ typeLabel(item.request_type) }}
|
||
</v-chip>
|
||
</template>
|
||
|
||
<template #[`item.status`]="{ item }">
|
||
<v-chip :color="statusInfo(item.status).color" size="x-small" variant="tonal">
|
||
{{ statusInfo(item.status).label }}
|
||
</v-chip>
|
||
</template>
|
||
|
||
<template #[`item.deadline_at`]="{ item }">
|
||
<span
|
||
class="text-caption"
|
||
:class="
|
||
item.status !== 'completed' && new Date(item.deadline_at) < new Date() ? 'text-error' : ''
|
||
"
|
||
>
|
||
{{ formatDate(item.deadline_at) }}
|
||
</span>
|
||
</template>
|
||
|
||
<template #[`item.actions`]="{ item }">
|
||
<v-btn
|
||
v-if="item.request_type === 'deletion' && item.status !== 'completed'"
|
||
color="error"
|
||
size="x-small"
|
||
variant="tonal"
|
||
prepend-icon="mdi-delete-forever"
|
||
:data-testid="`erase-btn-${item.id}`"
|
||
@click="openErase(item)"
|
||
>
|
||
Анонимизировать
|
||
</v-btn>
|
||
<v-chip v-else-if="item.status === 'completed'" color="success" size="x-small" variant="text">
|
||
Выполнено
|
||
</v-chip>
|
||
</template>
|
||
</v-data-table>
|
||
</v-card>
|
||
|
||
<!-- Dialog: create request -->
|
||
<v-dialog v-model="createDialog" max-width="520" data-testid="create-dialog">
|
||
<v-card>
|
||
<v-card-title class="text-h6 pa-4 pb-2">Новое обращение субъекта ПДн</v-card-title>
|
||
<v-card-text class="pa-4 pt-0">
|
||
<v-alert v-if="createError" type="error" variant="tonal" density="compact" class="mb-3">
|
||
{{ createError }}
|
||
</v-alert>
|
||
|
||
<v-select
|
||
v-model="createForm.request_type"
|
||
label="Тип обращения *"
|
||
:items="[
|
||
{ title: 'Доступ к данным', value: 'access' },
|
||
{ title: 'Исправление данных', value: 'rectification' },
|
||
{ title: 'Удаление данных', value: 'deletion' },
|
||
{ title: 'Возражение', value: 'objection' },
|
||
]"
|
||
density="compact"
|
||
variant="outlined"
|
||
class="mb-3"
|
||
data-testid="form-request-type"
|
||
/>
|
||
|
||
<v-text-field
|
||
v-model="createForm.subject_email"
|
||
label="Email субъекта"
|
||
type="email"
|
||
density="compact"
|
||
variant="outlined"
|
||
class="mb-2"
|
||
data-testid="form-email"
|
||
/>
|
||
<v-text-field
|
||
v-model="createForm.subject_phone"
|
||
label="Телефон субъекта"
|
||
density="compact"
|
||
variant="outlined"
|
||
class="mb-2"
|
||
data-testid="form-phone"
|
||
/>
|
||
<v-text-field
|
||
v-model="createForm.subject_full_name"
|
||
label="ФИО субъекта"
|
||
density="compact"
|
||
variant="outlined"
|
||
class="mb-2"
|
||
/>
|
||
<v-text-field
|
||
v-model.number="createForm.tenant_id"
|
||
label="ID тенанта (необязательно)"
|
||
type="number"
|
||
density="compact"
|
||
variant="outlined"
|
||
class="mb-2"
|
||
/>
|
||
<v-textarea
|
||
v-model="createForm.description"
|
||
label="Описание"
|
||
density="compact"
|
||
variant="outlined"
|
||
rows="3"
|
||
/>
|
||
</v-card-text>
|
||
<v-card-actions class="pa-4 pt-0 justify-end">
|
||
<v-btn
|
||
variant="text"
|
||
@click="
|
||
createDialog = false;
|
||
resetCreateForm();
|
||
"
|
||
>Отмена</v-btn
|
||
>
|
||
<v-btn
|
||
color="primary"
|
||
:loading="createLoading"
|
||
data-testid="submit-create-btn"
|
||
@click="submitCreate"
|
||
>
|
||
Создать
|
||
</v-btn>
|
||
</v-card-actions>
|
||
</v-card>
|
||
</v-dialog>
|
||
|
||
<!-- Dialog: erase confirm -->
|
||
<v-dialog v-model="eraseDialog" max-width="480" data-testid="erase-dialog">
|
||
<v-card>
|
||
<v-card-title class="text-h6 pa-4 pb-2 text-error"> Анонимизировать данные субъекта </v-card-title>
|
||
<v-card-text class="pa-4 pt-0">
|
||
<template v-if="!eraseResult">
|
||
<v-alert type="warning" variant="tonal" density="compact" class="mb-3">
|
||
Операция необратима. Данные будут заменены плейсхолдерами.
|
||
</v-alert>
|
||
<p class="text-body-2 mb-1"><strong>Email:</strong> {{ eraseTarget?.subject_email ?? '—' }}</p>
|
||
<p class="text-body-2 mb-1">
|
||
<strong>Телефон:</strong> {{ eraseTarget?.subject_phone ?? '—' }}
|
||
</p>
|
||
<p class="text-body-2"><strong>Тенант:</strong> {{ eraseTarget?.tenant_id ?? 'все' }}</p>
|
||
</template>
|
||
<template v-else>
|
||
<v-alert type="success" variant="tonal" density="compact" class="mb-3">
|
||
Анонимизация выполнена.
|
||
</v-alert>
|
||
<p class="text-body-2 mb-1">
|
||
Пользователей: <strong>{{ eraseResult.users }}</strong>
|
||
</p>
|
||
<p class="text-body-2 mb-1">
|
||
Лидов поставщика: <strong>{{ eraseResult.leads }}</strong>
|
||
</p>
|
||
<p class="text-body-2 mb-1">
|
||
Сделок: <strong>{{ eraseResult.deals }}</strong>
|
||
</p>
|
||
<p class="text-body-2">
|
||
Webhook-логов: <strong>{{ eraseResult.webhook_log }}</strong>
|
||
</p>
|
||
</template>
|
||
</v-card-text>
|
||
<v-card-actions class="pa-4 pt-0 justify-end">
|
||
<v-btn variant="text" @click="eraseDialog = false">
|
||
{{ eraseResult ? 'Закрыть' : 'Отмена' }}
|
||
</v-btn>
|
||
<v-btn
|
||
v-if="!eraseResult"
|
||
color="error"
|
||
:loading="eraseLoading"
|
||
data-testid="confirm-erase-btn"
|
||
@click="confirmErase"
|
||
>
|
||
Подтвердить удаление
|
||
</v-btn>
|
||
</v-card-actions>
|
||
</v-card>
|
||
</v-dialog>
|
||
|
||
<v-snackbar v-model="errToastOpen" :timeout="4000" color="error" location="bottom right">
|
||
{{ errToastText }}
|
||
</v-snackbar>
|
||
</v-container>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.admin-pd {
|
||
max-width: 1200px;
|
||
}
|
||
.page-title {
|
||
font-variation-settings: 'opsz' 28;
|
||
letter-spacing: -0.018em;
|
||
}
|
||
.font-mono {
|
||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||
}
|
||
</style>
|