Files
portal/app/resources/js/components/projects/ProjectDetailsDrawer.vue
T
Дмитрий f9820460fa feat(pdd): regions multi-select autocomplete + bitmask binding
Реализует Out-of-plan «Region multi-select autocomplete» из parent PDD spec.
Spec: 4f60add. Plan: 159ed3e.

Component (ProjectDetailsDrawer.vue):
- import REGIONS из constants/regions
- selectedRegions: Ref<number[]> + selectableRegions (filter code !== 0
  для исключения «Вся РФ» sentinel — fixes latent NewProjectDialog bug)
- maskToCodes(mask): reverse-decompose bits 1..31
- reseedFromProject: +selectedRegions.value = maskToCodes(form.region_mask)
- watch(selectedRegions): forward-encode mask + mode (include при empty, exclude иначе)
- Template: v-autocomplete multi+chips+clearable между Лимитом и Днями

Tests (ProjectDetailsDrawer.spec.ts): 17 passed (14 prior + 3 new):
- renders region chips when project has non-zero region_mask
- selecting regions encodes mask + sets mode=exclude on save
- clearing all regions resets mask=0 + mode=include on save

NB: config.global.plugins = [createVuetify()] добавлен в spec.ts — v-autocomplete
требует Vuetify defaults provide context. Все 17 PDD tests + 8/1sk ProjectsView
integration green (0 regressions).

Backend без изменений (region_mask + region_mode payload уже в Task 5 onSave).
2026-05-14 17:51:56 +03:00

246 lines
9.7 KiB
Vue

<script setup lang="ts">
import { ref, reactive, computed, onMounted, onBeforeUnmount, watch } from 'vue';
import axios from 'axios';
import type { Project } from '../../stores/projectsStore';
import { useProjectsStore } from '../../stores/projectsStore';
import { REGIONS } from '../../constants/regions';
const props = defineProps<{ project: Project | null }>();
const emit = defineEmits<{ close: []; saved: [] }>();
interface FormState {
name: string;
daily_limit_target: number;
region_mask: number;
region_mode: 'include' | 'exclude';
delivery_days_mask: number;
sms_senders: string[];
sms_keyword: string;
}
const form = reactive<FormState>({
name: '',
daily_limit_target: 50,
region_mask: 0,
region_mode: 'include',
delivery_days_mask: 127,
sms_senders: [],
sms_keyword: '',
});
const selectedRegions = ref<number[]>([]);
const selectableRegions = REGIONS.filter((r) => r.code !== 0);
function maskToCodes(mask: number): number[] {
const codes: number[] = [];
for (let i = 1; i <= 31; i++) if (mask & (1 << i)) codes.push(i);
return codes;
}
function reseedFromProject(p: Project | null): void {
if (!p) return;
form.name = p.name;
form.daily_limit_target = p.daily_limit_target;
form.region_mask = p.region_mask ?? 0;
form.region_mode = (p.region_mode ?? 'include') as 'include' | 'exclude';
form.delivery_days_mask = p.delivery_days_mask ?? 127;
form.sms_senders = p.sms_senders ?? [];
form.sms_keyword = p.sms_keyword ?? '';
selectedRegions.value = maskToCodes(form.region_mask);
}
reseedFromProject(props.project);
watch(() => props.project?.id, () => {
reseedFromProject(props.project);
});
watch(selectedRegions, (codes) => {
if (codes.length === 0) {
form.region_mask = 0;
form.region_mode = 'include';
} else {
form.region_mask = codes.reduce((acc, c) => (c >= 1 && c <= 31 ? acc | (1 << c) : acc), 0);
form.region_mode = 'exclude';
}
});
const saving = ref(false);
const errors = reactive<Record<string, string[]>>({});
const store = useProjectsStore();
async function onPause(): Promise<void> {
if (!props.project) return;
await store.toggleActive(props.project);
}
async function onDelete(): Promise<void> {
if (!props.project) return;
const ok = window.confirm('Архивировать проект? Действие необратимо в Plan 5 (восстановление потребует ручного запроса).');
if (!ok) return;
await store.archive(props.project.id);
emit('close');
}
async function onSave(): Promise<void> {
if (!props.project) return;
saving.value = true;
Object.keys(errors).forEach((k) => delete errors[k]);
try {
const payload: Record<string, unknown> = {
name: form.name,
daily_limit_target: form.daily_limit_target,
region_mask: form.region_mask,
region_mode: form.region_mode,
delivery_days_mask: form.delivery_days_mask,
};
if (props.project.signal_type === 'sms') {
payload.sms_senders = form.sms_senders;
payload.sms_keyword = form.sms_keyword;
}
await axios.patch(`/api/projects/${props.project.id}`, payload);
emit('saved');
} 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 onKey(e: KeyboardEvent): void {
if (e.key === 'Escape' && props.project) emit('close');
}
onMounted(() => document.addEventListener('keydown', onKey));
onBeforeUnmount(() => document.removeEventListener('keydown', onKey));
const activeDays = computed<boolean[]>(() => {
const mask = form.delivery_days_mask;
return Array.from({ length: 7 }, (_, i) => (mask & (1 << i)) !== 0);
});
function toggleDay(i: number): void {
form.delivery_days_mask ^= (1 << i);
}
const dayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
</script>
<template>
<aside class="project-details-drawer" :class="{ open: project !== null }">
<div v-if="project" class="pdd-content">
<header class="pdd-head">
<div class="pdd-title">{{ project.name }}</div>
<button class="pdd-close" data-testid="pdd-close" @click="$emit('close')"></button>
</header>
<div class="pdd-body">
<label class="pdd-field">
<span class="pdd-label">Название</span>
<input v-model="form.name" data-testid="pdd-name" class="pdd-input" />
<div v-if="errors.name" class="pdd-error" data-testid="pdd-error-name">{{ errors.name[0] }}</div>
</label>
<label class="pdd-field">
<span class="pdd-label">Лимит лидов в день</span>
<input
v-model.number="form.daily_limit_target"
type="number"
min="1"
max="10000"
data-testid="pdd-limit"
class="pdd-input"
/>
<div v-if="errors.daily_limit_target" class="pdd-error">{{ errors.daily_limit_target[0] }}</div>
</label>
<div class="pdd-field">
<span class="pdd-label">Регионы (пусто = вся РФ)</span>
<v-autocomplete
v-model="selectedRegions"
:items="selectableRegions"
item-title="name"
item-value="code"
multiple
chips
clearable
density="comfortable"
hide-details
data-testid="pdd-regions"
/>
</div>
<div class="pdd-field">
<span class="pdd-label">Дни недели приёма</span>
<div class="pdd-days">
<button
v-for="(label, i) in dayLabels"
:key="i"
type="button"
:data-testid="`pdd-day-${i}`"
:class="['pdd-day', { active: activeDays[i] }]"
@click="toggleDay(i)"
>
{{ label }}
</button>
</div>
</div>
</div>
<footer class="pdd-foot">
<div class="pdd-foot-left">
<button class="pdd-btn pdd-btn-warning" data-testid="pdd-pause" @click="onPause">
{{ project.is_active ? ' Приостановить' : ' Возобновить' }}
</button>
<button class="pdd-btn pdd-btn-error" data-testid="pdd-delete" @click="onDelete">🗄 Удалить</button>
</div>
<div class="pdd-foot-right">
<button class="pdd-btn pdd-btn-text" data-testid="pdd-cancel" @click="$emit('close')">Отмена</button>
<button
class="pdd-btn pdd-btn-primary"
data-testid="pdd-save"
:disabled="saving"
@click="onSave"
>Сохранить</button>
</div>
</footer>
</div>
</aside>
</template>
<style scoped>
.project-details-drawer {
position: fixed; top: 0; right: 0; bottom: 0;
width: 480px;
background: var(--liderra-surface, #ffffff);
border-left: 1px solid var(--liderra-line, #e6e2d6);
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.06);
transform: translateX(100%);
transition: transform 240ms cubic-bezier(0.16, 1, 0.3, 1);
display: flex; flex-direction: column;
z-index: 5;
}
.project-details-drawer.open { transform: translateX(0); }
.pdd-content { display: flex; flex-direction: column; height: 100%; }
.pdd-head { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; border-bottom: 1px solid var(--liderra-line, #e6e2d6); }
.pdd-title { font-weight: 600; font-size: 16px; }
.pdd-close { background: none; border: 0; cursor: pointer; font-size: 18px; padding: 4px; }
.pdd-body { padding: 16px 20px; display: flex; flex-direction: column; gap: 14px; flex: 1; overflow-y: auto; }
.pdd-field { display: flex; flex-direction: column; gap: 4px; }
.pdd-label { font-size: 12px; color: #6b6f72; }
.pdd-input { padding: 8px 10px; border: 1px solid var(--liderra-line, #e6e2d6); border-radius: 6px; font: inherit; }
.pdd-days { display: flex; gap: 4px; }
.pdd-day { padding: 6px 10px; border: 1px solid var(--liderra-line, #e6e2d6); background: #ffffff; border-radius: 4px; cursor: pointer; font: inherit; }
.pdd-day.active { background: #0f6e56; color: #ffffff; border-color: #0f6e56; }
.pdd-foot { display: flex; justify-content: space-between; padding: 12px 20px; border-top: 1px solid var(--liderra-line, #e6e2d6); }
.pdd-foot-left, .pdd-foot-right { display: flex; gap: 8px; }
.pdd-btn { padding: 6px 14px; border: 0; border-radius: 6px; cursor: pointer; font: inherit; }
.pdd-btn-text { background: transparent; color: #081319; }
.pdd-btn-primary { background: #0f6e56; color: #ffffff; }
.pdd-btn-warning { background: #f59e0b; color: #ffffff; }
.pdd-btn-error { background: #dc2626; color: #ffffff; }
.pdd-error { color: #dc2626; font-size: 12px; margin-top: 4px; }
</style>