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>
|
||||
@@ -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