f1a3e9f02f
План 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-чисты.
212 lines
7.7 KiB
Vue
212 lines
7.7 KiB
Vue
<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>
|