diff --git a/app/resources/js/components/layout/AppSidebar.vue b/app/resources/js/components/layout/AppSidebar.vue index 383e0fc8..5a0bd51a 100644 --- a/app/resources/js/components/layout/AppSidebar.vue +++ b/app/resources/js/components/layout/AppSidebar.vue @@ -32,6 +32,7 @@ const navGroups = computed(() => [ { 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', diff --git a/app/resources/js/components/projects/ProjectCard.story.vue b/app/resources/js/components/projects/ProjectCard.story.vue new file mode 100644 index 00000000..8b757eca --- /dev/null +++ b/app/resources/js/components/projects/ProjectCard.story.vue @@ -0,0 +1,72 @@ + + + + + diff --git a/app/resources/js/components/projects/ProjectCard.vue b/app/resources/js/components/projects/ProjectCard.vue new file mode 100644 index 00000000..eba38e41 --- /dev/null +++ b/app/resources/js/components/projects/ProjectCard.vue @@ -0,0 +1,135 @@ + + + + + diff --git a/app/resources/js/constants/regions.ts b/app/resources/js/constants/regions.ts new file mode 100644 index 00000000..49966e12 --- /dev/null +++ b/app/resources/js/constants/regions.ts @@ -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: 'Белгородская область' }, +]; diff --git a/app/resources/js/layouts/AppLayout.vue b/app/resources/js/layouts/AppLayout.vue index 177f6ce6..0d30b76d 100644 --- a/app/resources/js/layouts/AppLayout.vue +++ b/app/resources/js/layouts/AppLayout.vue @@ -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' }, diff --git a/app/resources/js/router/index.ts b/app/resources/js/router/index.ts index 6f52310a..dfe4902d 100644 --- a/app/resources/js/router/index.ts +++ b/app/resources/js/router/index.ts @@ -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', diff --git a/app/resources/js/views/ProjectsView.vue b/app/resources/js/views/ProjectsView.vue new file mode 100644 index 00000000..cbd76f8c --- /dev/null +++ b/app/resources/js/views/ProjectsView.vue @@ -0,0 +1,3 @@ + diff --git a/app/tests/Frontend/ProjectCard.spec.ts b/app/tests/Frontend/ProjectCard.spec.ts new file mode 100644 index 00000000..010d46f7 --- /dev/null +++ b/app/tests/Frontend/ProjectCard.spec.ts @@ -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('На паузе'); + }); +});