Compare commits

..

1 Commits

Author SHA1 Message Date
Дмитрий f696314b11 feat(frontend): Plan 5 Task 7 — router + nav + regions + ProjectCard + story 2026-05-11 19:30:45 +03:00
11 changed files with 313 additions and 304 deletions
@@ -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>
+42
View File
@@ -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: 'Белгородская область' },
];
+1
View File
@@ -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' },
+6
View File
@@ -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',
+3
View File
@@ -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 — те же причины.
});
});
+53
View File
@@ -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('На паузе');
});
});