Files
portal/app/resources/js/views/admin/AdminSupplierProjectsView.vue
T
Дмитрий f1a3e9f02f feat(admin): supplier projects cleanup screen (list + bulk delete)
План 4 Task 3 эпика project-migration-redesign.

- AdminSupplierProjectsView.vue — v-data-table (источник/платформа/регион/
  лимит/кто заказывал/последняя поставка) + bulk-delete с v-dialog
  подтверждением + snackbar (deleted/failures).
- Роут /admin/supplier-projects (layout admin, requiresAuth, devIndex 31).
- AdminLayout nav-пункт «Проекты у поставщика».
- Vitest 3/3 (mount GET, bulk-delete confirm POST {ids}, disabled when empty).

NB: type-check имеет 3 pre-existing ошибки в DealDetailHero.spec.ts
(коммит 1412d3f, не Plan 4); файлы T3 type-check-чисты.
2026-05-20 14:34:25 +03:00

212 lines
7.7 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.
<template>
<div class="admin-supplier-projects-view pa-6">
<h1 class="text-h5 mb-4">Проекты у поставщика</h1>
<p class="text-body-2 text-medium-emphasis mb-4">
Все проекты, заведённые у поставщика crm.bp-gr.ru. Удаление снимает проект
на портале и локальные привязки тенантов (каскадом).
</p>
<v-alert
v-if="fetchError"
type="warning"
variant="tonal"
density="compact"
class="mb-4"
data-testid="projects-fetch-error"
closable
@click:close="fetchError = null"
>
{{ fetchError }}
</v-alert>
<div class="d-flex align-center mb-3">
<v-btn
color="error"
variant="flat"
prepend-icon="mdi-delete-outline"
data-testid="bulk-delete-btn"
:disabled="selected.length === 0"
:loading="deleting"
@click="confirmOpen = true"
>
Удалить выбранные ({{ selected.length }})
</v-btn>
<v-spacer />
<v-btn variant="text" prepend-icon="mdi-refresh" :loading="loading" @click="load">
Обновить
</v-btn>
</div>
<v-card elevation="1">
<v-data-table
:headers="headers"
:items="projects"
:loading="loading"
density="comfortable"
item-value="id"
>
<template #[`item.select`]="{ item }">
<v-checkbox
:model-value="selected.includes(item.id)"
:data-testid="`row-checkbox-${item.id}`"
hide-details
density="compact"
@update:model-value="(v: boolean | null) => toggleRow(item.id, v)"
/>
</template>
<template #[`item.orderers`]="{ item }">
<span v-if="item.orderers.length">{{ item.orderers.join(', ') }}</span>
<span v-else class="text-medium-emphasis">—</span>
</template>
<template #[`item.last_delivery_at`]="{ item }">
{{ item.last_delivery_at ? formatDate(item.last_delivery_at) : '—' }}
</template>
</v-data-table>
</v-card>
<v-dialog v-model="confirmOpen" max-width="480">
<v-card>
<v-card-title>Удалить выбранные проекты?</v-card-title>
<v-card-text>
Будет удалено проектов: <strong>{{ selected.length }}</strong>.
Действие снимает проекты у поставщика и локальные привязки.
Отменить нельзя.
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="confirmOpen = false">Отмена</v-btn>
<v-btn
color="error"
variant="flat"
data-testid="confirm-delete-btn"
:loading="deleting"
@click="performDelete"
>
Удалить
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar
v-model="snackbarOpen"
:timeout="4000"
:color="snackbarColor"
location="bottom right"
data-testid="projects-snackbar"
>
{{ snackbarText }}
</v-snackbar>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import axios from 'axios';
/**
* SaaS-admin → «Проекты у поставщика» (Plan 4 Task 3).
*
* Backend: AdminSupplierIntegrationController::projectsIndex / projectsDestroy.
* Список supplier_projects + кто заказывал (orderers) + дата последней поставки;
* bulk-delete выбранных (портал + локально каскадом).
*/
interface SupplierProjectRow {
id: number;
platform: string;
signal_type: string;
unique_key: string;
subject_code: number | null;
subject_name: string | null;
current_limit: number;
supplier_external_id: string | null;
orderers: string[];
last_delivery_at: string | null;
}
const projects = ref<SupplierProjectRow[]>([]);
const selected = ref<number[]>([]);
const loading = ref(false);
const deleting = ref(false);
const fetchError = ref<string | null>(null);
const confirmOpen = ref(false);
const snackbarOpen = ref(false);
const snackbarText = ref('');
const snackbarColor = ref<'success' | 'warning' | 'error'>('success');
const headers = [
{ title: '', key: 'select', sortable: false, width: 56 },
{ title: 'Источник', key: 'unique_key', sortable: true },
{ title: 'Платформа', key: 'platform', sortable: true, width: 110 },
{ title: 'Регион', key: 'subject_name', sortable: true },
{ title: 'Лимит', key: 'current_limit', sortable: true, width: 90 },
{ title: 'Кто заказывал', key: 'orderers', sortable: false },
{ title: 'Последняя поставка', key: 'last_delivery_at', sortable: true, width: 180 },
];
function toggleRow(id: number, value: boolean | null): void {
if (value) {
if (!selected.value.includes(id)) selected.value.push(id);
} else {
selected.value = selected.value.filter((x) => x !== id);
}
}
function formatDate(s: string): string {
return new Date(s).toLocaleString('ru-RU');
}
async function load(): Promise<void> {
loading.value = true;
fetchError.value = null;
try {
const { data } = await axios.get('/api/admin/supplier-integration/projects');
projects.value = Array.isArray(data?.projects) ? data.projects : [];
// Снять выбор с уже удалённых строк.
const ids = new Set(projects.value.map((p) => p.id));
selected.value = selected.value.filter((id) => ids.has(id));
} catch {
fetchError.value = 'Не удалось загрузить список проектов.';
} finally {
loading.value = false;
}
}
async function performDelete(): Promise<void> {
if (selected.value.length === 0) {
confirmOpen.value = false;
return;
}
deleting.value = true;
try {
const { data } = await axios.post('/api/admin/supplier-integration/projects/delete', {
ids: selected.value,
});
const deleted = Number(data?.deleted ?? 0);
const failures = Array.isArray(data?.failures) ? data.failures : [];
if (failures.length > 0) {
snackbarColor.value = 'warning';
snackbarText.value = `Удалено: ${deleted}. Не удалось: ${failures.length}.`;
} else {
snackbarColor.value = 'success';
snackbarText.value = `Удалено проектов: ${deleted}.`;
}
snackbarOpen.value = true;
confirmOpen.value = false;
selected.value = [];
await load();
} catch {
snackbarColor.value = 'error';
snackbarText.value = 'Ошибка при удалении проектов.';
snackbarOpen.value = true;
} finally {
deleting.value = false;
}
}
onMounted(load);
defineExpose({ load, performDelete, toggleRow, projects, selected, confirmOpen });
</script>