Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f696314b11 |
@@ -32,6 +32,7 @@ const navGroups = computed<NavGroup[]>(() => [
|
||||
{ title: 'Дашборд', icon: 'mdi-view-dashboard-outline', to: '/dashboard' },
|
||||
{ title: 'Сделки', icon: 'mdi-format-list-bulleted', to: '/deals', count: 247 },
|
||||
{ title: 'Канбан', icon: 'mdi-view-column-outline', to: '/kanban' },
|
||||
{ title: 'Проекты', icon: 'mdi-folder-multiple-outline', to: '/projects' },
|
||||
{
|
||||
title: 'Напоминания',
|
||||
icon: 'mdi-clock-outline',
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import ProjectCard from './ProjectCard.vue';
|
||||
|
||||
const base = {
|
||||
id: 1,
|
||||
name: 'Окна СПб',
|
||||
signal_type: 'site' as const,
|
||||
signal_identifier: 'okna.ru',
|
||||
daily_limit_target: 50,
|
||||
delivered_today: 32,
|
||||
is_active: true,
|
||||
archived_at: null,
|
||||
sync_status: 'ok' as const,
|
||||
};
|
||||
|
||||
const okProject = base;
|
||||
const pendingProject = { ...base, sync_status: 'pending' as const };
|
||||
const failedProject = { ...base, sync_status: 'failed' as const };
|
||||
const pausedProject = { ...base, is_active: false };
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Projects / ProjectCard" :layout="{ type: 'single', iframe: true }">
|
||||
<Variant title="Sync OK (active)">
|
||||
<v-app>
|
||||
<v-main class="story-pane">
|
||||
<div class="card-wrap">
|
||||
<ProjectCard :project="okProject" :selected="false" />
|
||||
</div>
|
||||
</v-main>
|
||||
</v-app>
|
||||
</Variant>
|
||||
<Variant title="Sync pending">
|
||||
<v-app>
|
||||
<v-main class="story-pane">
|
||||
<div class="card-wrap">
|
||||
<ProjectCard :project="pendingProject" :selected="false" />
|
||||
</div>
|
||||
</v-main>
|
||||
</v-app>
|
||||
</Variant>
|
||||
<Variant title="Sync failed">
|
||||
<v-app>
|
||||
<v-main class="story-pane">
|
||||
<div class="card-wrap">
|
||||
<ProjectCard :project="failedProject" :selected="false" />
|
||||
</div>
|
||||
</v-main>
|
||||
</v-app>
|
||||
</Variant>
|
||||
<Variant title="Paused">
|
||||
<v-app>
|
||||
<v-main class="story-pane">
|
||||
<div class="card-wrap">
|
||||
<ProjectCard :project="pausedProject" :selected="false" />
|
||||
</div>
|
||||
</v-main>
|
||||
</v-app>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.story-pane {
|
||||
background: #f6f3ec;
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
}
|
||||
.card-wrap {
|
||||
width: 360px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<v-card class="project-card" :class="{ paused: !project.is_active }" elevation="1">
|
||||
<v-card-item>
|
||||
<template #prepend>
|
||||
<v-checkbox
|
||||
:model-value="selected"
|
||||
data-testid="card-select"
|
||||
hide-details
|
||||
density="compact"
|
||||
@change="$emit('toggle-select', project.id)"
|
||||
/>
|
||||
</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" 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">{{ project.delivered_today }} / {{ project.daily_limit_target }} лидов</span>
|
||||
<span class="text-caption text-medium-emphasis">{{ progressPercent }}%</span>
|
||||
</div>
|
||||
<v-progress-linear :model-value="progressPercent" :color="progressColor" height="6" rounded />
|
||||
</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';
|
||||
|
||||
interface Project {
|
||||
id: number;
|
||||
name: string;
|
||||
signal_type: 'site' | 'call' | 'sms';
|
||||
signal_identifier?: string | null;
|
||||
sms_senders?: string[] | null;
|
||||
sms_keyword?: string | null;
|
||||
daily_limit_target: number;
|
||||
delivered_today: number;
|
||||
is_active: boolean;
|
||||
archived_at: string | null;
|
||||
sync_status: 'ok' | 'pending' | 'failed';
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,42 @@
|
||||
export interface Region {
|
||||
code: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// MVP: 31 региона (коды 1..31) ограничены 32-bit region_mask из Plan 5 Task 9.
|
||||
// Sentinel code:0 = «Вся РФ» (включает все регионы, эквивалент пустой маски).
|
||||
// Имена — официальные субъекты РФ по конституционному порядку нумерации.
|
||||
export const REGIONS: Region[] = [
|
||||
{ code: 0, name: 'Вся РФ' },
|
||||
{ code: 1, name: 'Республика Адыгея' },
|
||||
{ code: 2, name: 'Республика Башкортостан' },
|
||||
{ code: 3, name: 'Республика Бурятия' },
|
||||
{ code: 4, name: 'Республика Алтай' },
|
||||
{ code: 5, name: 'Республика Дагестан' },
|
||||
{ code: 6, name: 'Республика Ингушетия' },
|
||||
{ code: 7, name: 'Кабардино-Балкарская Республика' },
|
||||
{ code: 8, name: 'Республика Калмыкия' },
|
||||
{ code: 9, name: 'Карачаево-Черкесская Республика' },
|
||||
{ code: 10, name: 'Республика Карелия' },
|
||||
{ code: 11, name: 'Республика Коми' },
|
||||
{ code: 12, name: 'Республика Марий Эл' },
|
||||
{ code: 13, name: 'Республика Мордовия' },
|
||||
{ code: 14, name: 'Республика Саха (Якутия)' },
|
||||
{ code: 15, name: 'Республика Северная Осетия — Алания' },
|
||||
{ code: 16, name: 'Республика Татарстан' },
|
||||
{ code: 17, name: 'Республика Тыва' },
|
||||
{ code: 18, name: 'Удмуртская Республика' },
|
||||
{ code: 19, name: 'Республика Хакасия' },
|
||||
{ code: 20, name: 'Чеченская Республика' },
|
||||
{ code: 21, name: 'Чувашская Республика' },
|
||||
{ code: 22, name: 'Алтайский край' },
|
||||
{ code: 23, name: 'Краснодарский край' },
|
||||
{ code: 24, name: 'Красноярский край' },
|
||||
{ code: 25, name: 'Приморский край' },
|
||||
{ code: 26, name: 'Ставропольский край' },
|
||||
{ code: 27, name: 'Хабаровский край' },
|
||||
{ code: 28, name: 'Амурская область' },
|
||||
{ code: 29, name: 'Архангельская область' },
|
||||
{ code: 30, name: 'Астраханская область' },
|
||||
{ code: 31, name: 'Белгородская область' },
|
||||
];
|
||||
@@ -31,6 +31,7 @@ const navItems = computed(() => [
|
||||
{ title: 'Дашборд', to: '/dashboard' },
|
||||
{ title: 'Сделки', to: '/deals' },
|
||||
{ title: 'Канбан', to: '/kanban' },
|
||||
{ title: 'Проекты', to: '/projects' },
|
||||
{ title: 'Напоминания', to: '/reminders' },
|
||||
{ title: 'Биллинг', to: '/billing' },
|
||||
{ title: 'Отчёты', to: '/reports' },
|
||||
|
||||
@@ -77,6 +77,12 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('../views/KanbanView.vue'),
|
||||
meta: { layout: 'app', title: 'Канбан', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/projects',
|
||||
name: 'projects',
|
||||
component: () => import('../views/ProjectsView.vue'),
|
||||
meta: { layout: 'app', title: 'Проекты', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/billing',
|
||||
name: 'billing',
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<div>Projects (stub)</div>
|
||||
</template>
|
||||
@@ -1,30 +0,0 @@
|
||||
<template>
|
||||
<Story title="NewProjectDialog">
|
||||
<Variant title="Site tab (create mode)">
|
||||
<NewProjectDialog v-model="open" mode="create" />
|
||||
</Variant>
|
||||
<Variant title="SMS tab (create mode)">
|
||||
<NewProjectDialog v-model="open" mode="create" />
|
||||
</Variant>
|
||||
<Variant title="Edit mode (readonly signal_type)">
|
||||
<NewProjectDialog v-model="open" mode="edit" :project="sampleProject" />
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import NewProjectDialog from './NewProjectDialog.vue';
|
||||
|
||||
const open = ref(true);
|
||||
const sampleProject = {
|
||||
id: 1,
|
||||
name: 'Окна СПб',
|
||||
signal_type: 'site' as const,
|
||||
signal_identifier: 'okna.ru',
|
||||
daily_limit_target: 50,
|
||||
region_mask: 0,
|
||||
region_mode: 'include' as const,
|
||||
delivery_days_mask: 127,
|
||||
};
|
||||
</script>
|
||||
@@ -1,201 +0,0 @@
|
||||
<template>
|
||||
<v-dialog :model-value="modelValue" max-width="720" @update:model-value="$emit('update:modelValue', $event)">
|
||||
<v-card>
|
||||
<v-card-title>{{ mode === 'edit' ? 'Редактирование проекта' : 'Новый проект' }}</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<v-tabs v-model="form.signal_type" :disabled="mode === 'edit'" color="primary">
|
||||
<v-tab value="site"><v-icon start>mdi-web</v-icon>Сайт</v-tab>
|
||||
<v-tab value="call"><v-icon start>mdi-phone</v-icon>Звонок</v-tab>
|
||||
<v-tab value="sms"><v-icon start>mdi-message-text</v-icon>СМС</v-tab>
|
||||
</v-tabs>
|
||||
|
||||
<v-tabs-window v-model="form.signal_type" class="mt-4">
|
||||
<v-tabs-window-item value="site">
|
||||
<v-text-field
|
||||
v-model="form.signal_identifier"
|
||||
label="Домен конкурента"
|
||||
placeholder="okna-konkurent.ru"
|
||||
:readonly="mode === 'edit'"
|
||||
:error-messages="errors.signal_identifier"
|
||||
/>
|
||||
</v-tabs-window-item>
|
||||
<v-tabs-window-item value="call">
|
||||
<v-text-field
|
||||
v-model="form.signal_identifier"
|
||||
label="Номер конкурента"
|
||||
placeholder="79161234567"
|
||||
hint="Формат: 11 цифр, начинаются с 7"
|
||||
:readonly="mode === 'edit'"
|
||||
:error-messages="errors.signal_identifier"
|
||||
/>
|
||||
</v-tabs-window-item>
|
||||
<v-tabs-window-item value="sms">
|
||||
<v-combobox
|
||||
v-model="form.sms_senders"
|
||||
label="Отправители (до 11 символов каждый)"
|
||||
multiple
|
||||
chips
|
||||
clearable
|
||||
:error-messages="errors.sms_senders"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="form.sms_keyword"
|
||||
label="Ключевое слово (опционально)"
|
||||
hint="Если пусто — проект подключится только к B3"
|
||||
:error-messages="errors.sms_keyword"
|
||||
/>
|
||||
</v-tabs-window-item>
|
||||
</v-tabs-window>
|
||||
|
||||
<v-divider class="my-4" />
|
||||
|
||||
<v-text-field v-model="form.name" label="Название проекта" :error-messages="errors.name" />
|
||||
|
||||
<v-text-field
|
||||
v-model.number="form.daily_limit_target"
|
||||
label="Лимит лидов в день"
|
||||
type="number"
|
||||
min="1"
|
||||
max="10000"
|
||||
:error-messages="errors.daily_limit_target"
|
||||
/>
|
||||
|
||||
<v-autocomplete
|
||||
v-model="selectedRegions"
|
||||
:items="REGIONS"
|
||||
item-title="name"
|
||||
item-value="code"
|
||||
label="Регионы (пусто = вся РФ)"
|
||||
multiple
|
||||
chips
|
||||
clearable
|
||||
/>
|
||||
|
||||
<div class="mt-3">
|
||||
<span class="text-caption">Дни недели приёма</span>
|
||||
<v-btn-toggle v-model="selectedDays" multiple density="comfortable" class="mt-1">
|
||||
<v-btn v-for="(day, i) in dayLabels" :key="i" :value="i">{{ day }}</v-btn>
|
||||
</v-btn-toggle>
|
||||
<div class="mt-1">
|
||||
<v-btn size="small" variant="text" @click="setWorkdays('weekdays')">Будни</v-btn>
|
||||
<v-btn size="small" variant="text" @click="setWorkdays('all')">Все дни</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="close">Отмена</v-btn>
|
||||
<v-btn color="primary" :loading="saving" data-testid="submit-btn" @click="submit">
|
||||
{{ mode === 'edit' ? 'Сохранить' : 'Создать' }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { REGIONS } from '../../constants/regions';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
mode?: 'create' | 'edit';
|
||||
project?: Record<string, unknown> | null;
|
||||
}>();
|
||||
const emit = defineEmits(['update:modelValue', 'saved']);
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
signal_type: 'site' as 'site' | 'call' | 'sms',
|
||||
signal_identifier: '',
|
||||
sms_senders: [] as string[],
|
||||
sms_keyword: '',
|
||||
daily_limit_target: 50,
|
||||
region_mask: 0,
|
||||
region_mode: 'include' as 'include' | 'exclude',
|
||||
delivery_days_mask: 127,
|
||||
});
|
||||
const errors = reactive<Record<string, string[]>>({});
|
||||
const saving = ref(false);
|
||||
|
||||
const selectedRegions = ref<number[]>([]);
|
||||
watch(selectedRegions, (codes) => {
|
||||
if (codes.length === 0) {
|
||||
form.region_mask = 0;
|
||||
form.region_mode = 'include';
|
||||
} else {
|
||||
// 32-bit JS bitwise limit — region codes >31 не помещаются в Int32 mask.
|
||||
// На MVP покрываем 1-31 (см. constants/regions.ts); для >31 нужен bigint
|
||||
// или array-колонка (Plan 6 — schema delta).
|
||||
form.region_mask = codes.reduce((acc, c) => (c <= 31 ? acc | (1 << c) : acc), 0);
|
||||
form.region_mode = 'exclude';
|
||||
}
|
||||
});
|
||||
|
||||
const dayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
|
||||
const selectedDays = ref<number[]>([0, 1, 2, 3, 4, 5, 6]);
|
||||
watch(selectedDays, (days) => {
|
||||
form.delivery_days_mask = days.reduce((acc, d) => acc | (1 << d), 0);
|
||||
});
|
||||
|
||||
function setWorkdays(preset: 'weekdays' | 'all') {
|
||||
if (preset === 'weekdays') selectedDays.value = [0, 1, 2, 3, 4];
|
||||
else selectedDays.value = [0, 1, 2, 3, 4, 5, 6];
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
if (open && props.mode === 'edit' && props.project) {
|
||||
Object.assign(form, props.project);
|
||||
// TODO: разобрать region_mask обратно в codes (Plan 6 ↑).
|
||||
selectedRegions.value = [];
|
||||
const days: number[] = [];
|
||||
for (let i = 0; i < 7; i++) if (form.delivery_days_mask & (1 << i)) days.push(i);
|
||||
selectedDays.value = days;
|
||||
} else if (open) {
|
||||
Object.assign(form, {
|
||||
name: '',
|
||||
signal_type: 'site',
|
||||
signal_identifier: '',
|
||||
sms_senders: [],
|
||||
sms_keyword: '',
|
||||
daily_limit_target: 50,
|
||||
region_mask: 0,
|
||||
region_mode: 'include',
|
||||
delivery_days_mask: 127,
|
||||
});
|
||||
selectedRegions.value = [];
|
||||
selectedDays.value = [0, 1, 2, 3, 4, 5, 6];
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
async function submit() {
|
||||
saving.value = true;
|
||||
Object.keys(errors).forEach((k) => delete errors[k]);
|
||||
try {
|
||||
if (props.mode === 'edit' && props.project) {
|
||||
await axios.patch(`/api/projects/${(props.project as { id: number }).id}`, { ...form });
|
||||
} else {
|
||||
await axios.post('/api/projects', { ...form });
|
||||
}
|
||||
emit('saved');
|
||||
close();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { status?: number; data?: { errors?: Record<string, string[]> } } };
|
||||
if (err.response?.status === 422 && err.response.data?.errors) {
|
||||
Object.assign(errors, err.response.data.errors);
|
||||
}
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('update:modelValue', false);
|
||||
}
|
||||
</script>
|
||||
@@ -1,73 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import axios from 'axios';
|
||||
|
||||
vi.mock('axios');
|
||||
|
||||
import NewProjectDialog from '../../resources/js/views/projects/NewProjectDialog.vue';
|
||||
|
||||
// VDialog в JSDOM не рендерит в teleport-цели; стаб делает <slot/> доступным
|
||||
// внутри корня для wrapper.text() / find().
|
||||
const factory = (props: { modelValue: boolean; mode?: 'create' | 'edit'; project?: unknown } = { modelValue: true, mode: 'create' }) =>
|
||||
mount(NewProjectDialog, {
|
||||
props,
|
||||
global: {
|
||||
plugins: [createVuetify()],
|
||||
stubs: {
|
||||
VDialog: {
|
||||
template: '<div class="dialog-stub" v-if="modelValue"><slot /></div>',
|
||||
props: ['modelValue'],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('NewProjectDialog', () => {
|
||||
it('renders 3 tabs: Сайт / Звонок / СМС', async () => {
|
||||
const wrapper = factory();
|
||||
await flushPromises();
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('Сайт');
|
||||
expect(text).toContain('Звонок');
|
||||
expect(text).toContain('СМС');
|
||||
});
|
||||
|
||||
it('switching to SMS tab shows sms_senders field', async () => {
|
||||
const wrapper = factory();
|
||||
await flushPromises();
|
||||
const tabs = wrapper.findComponent({ name: 'VTabs' });
|
||||
if (tabs.exists()) {
|
||||
tabs.vm.$emit('update:modelValue', 'sms');
|
||||
}
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toMatch(/Отправители|sms_senders/i);
|
||||
});
|
||||
|
||||
it('validation: empty site domain does not POST (button stays available, axios.post not called by default)', async () => {
|
||||
const wrapper = factory();
|
||||
await flushPromises();
|
||||
const btn = wrapper.find('[data-testid="submit-btn"]');
|
||||
expect(btn.exists()).toBe(true);
|
||||
// Не нажимаем — проверяем, что данные формы по умолчанию пустые и POST ещё не вызван.
|
||||
expect((axios.post as ReturnType<typeof vi.fn>).mock?.calls?.length ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line vitest/no-disabled-tests
|
||||
it.skip('submits valid site project to POST /api/projects', async () => {
|
||||
// TODO: полная проверка submit требует rendering Vuetify-формы и заполнения
|
||||
// v-text-field/v-combobox/v-btn-toggle — нестабильно в JSDOM. Покрытие
|
||||
// делается через Histoire story + e2e (Playwright) после Plan 5 closure.
|
||||
});
|
||||
|
||||
// eslint-disable-next-line vitest/no-disabled-tests
|
||||
it.skip('emits saved event after successful POST', async () => {
|
||||
// TODO: см. предыдущий skip — те же причины.
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import ProjectCard from '../../resources/js/components/projects/ProjectCard.vue';
|
||||
|
||||
const vuetify = createVuetify();
|
||||
const baseProject = {
|
||||
id: 1,
|
||||
name: 'Окна СПб',
|
||||
signal_type: 'site' as const,
|
||||
signal_identifier: 'okna.ru',
|
||||
daily_limit_target: 50,
|
||||
delivered_today: 32,
|
||||
is_active: true,
|
||||
archived_at: null,
|
||||
sync_status: 'ok' as const,
|
||||
};
|
||||
|
||||
describe('ProjectCard', () => {
|
||||
it('renders project name + signal_identifier', () => {
|
||||
const wrapper = mount(ProjectCard, {
|
||||
global: { plugins: [vuetify] },
|
||||
props: { project: baseProject, selected: false },
|
||||
});
|
||||
expect(wrapper.text()).toContain('Окна СПб');
|
||||
expect(wrapper.text()).toContain('okna.ru');
|
||||
});
|
||||
|
||||
it('shows progress percentage 32/50', () => {
|
||||
const wrapper = mount(ProjectCard, {
|
||||
global: { plugins: [vuetify] },
|
||||
props: { project: baseProject, selected: false },
|
||||
});
|
||||
expect(wrapper.text()).toMatch(/32.*50/);
|
||||
});
|
||||
|
||||
it('emits select event on checkbox change', async () => {
|
||||
const wrapper = mount(ProjectCard, {
|
||||
global: { plugins: [vuetify] },
|
||||
props: { project: baseProject, selected: false },
|
||||
});
|
||||
await wrapper.find('[data-testid="card-select"]').trigger('change');
|
||||
expect(wrapper.emitted('toggle-select')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows "На паузе" when is_active=false', () => {
|
||||
const wrapper = mount(ProjectCard, {
|
||||
global: { plugins: [vuetify] },
|
||||
props: { project: { ...baseProject, is_active: false }, selected: false },
|
||||
});
|
||||
expect(wrapper.text()).toContain('На паузе');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user