Files
portal/app/resources/js/components/projects/ProjectDetailsDrawer.vue
T

579 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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, FEDERAL_DISTRICT_NAMES } from '../../constants/regions';
import { formatLeadDate, firstLeadDate } from '../../utils/leadDate';
const props = defineProps<{ project: Project | null }>();
const emit = defineEmits<{ close: []; saved: [appliesFrom: string | null] }>();
interface FormState {
name: string;
daily_limit_target: number;
regions: number[];
delivery_days_mask: number;
sms_senders: string[];
sms_keyword: string;
signal_identifier: string;
}
const form = reactive<FormState>({
name: '',
daily_limit_target: 50,
regions: [],
delivery_days_mask: 127,
sms_senders: [],
sms_keyword: '',
signal_identifier: '',
});
const selectableRegions = REGIONS.filter((r) => r.code !== 0);
function reseedFromProject(p: Project | null): void {
if (!p) return;
form.name = p.name;
form.daily_limit_target = p.daily_limit_target;
form.regions = Array.isArray(p.regions) ? [...p.regions] : [];
form.delivery_days_mask = p.delivery_days_mask ?? 127;
form.sms_senders = p.sms_senders ?? [];
form.sms_keyword = p.sms_keyword ?? '';
form.signal_identifier = p.signal_identifier ?? '';
}
reseedFromProject(props.project);
watch(
() => props.project?.id,
() => {
reseedFromProject(props.project);
},
);
const saving = ref(false);
const errors = reactive<Record<string, string[]>>({});
const store = useProjectsStore();
// Эпик 3 (редизайн 2026-06-25): вместо жёсткого замка — редактируемые поля
// + объявление о вступлении + подтверждение смены источника. Раздача доводит
// хвост по старому источнику через слепок, поэтому смена безопасна.
const sourceLocked = computed(() => props.project?.source_locked === true);
// Информационный баннер: пока летит сбор на завтра, правки количества/региона/дней
// вступят в силу не сразу. Показываем, когда проект залочен И эти поля изменены.
const slepokFieldsDirty = computed(() => {
const p = props.project;
if (!p) return false;
const regionsChanged = JSON.stringify([...form.regions].sort()) !== JSON.stringify([...(p.regions ?? [])].sort());
return (
form.daily_limit_target !== p.daily_limit_target ||
form.delivery_days_mask !== (p.delivery_days_mask ?? 127) ||
regionsChanged
);
});
const showAppliesFromBanner = computed(() => sourceLocked.value && slepokFieldsDirty.value);
// Time-aware дата вступления правок по правилу слепка 18:00 МСК (до 18:00 → завтра,
// после → послезавтра). Объявление всегда показывает АКТУАЛЬНУЮ дату.
const appliesFromDate = computed(() => firstLeadDate());
// Изменён ли источник относительно исходного состояния проекта.
const sourceDirty = computed(() => {
const p = props.project;
if (!p) return false;
if (p.signal_type === 'sms') {
const sendersChanged = JSON.stringify(form.sms_senders) !== JSON.stringify(p.sms_senders ?? []);
return sendersChanged || form.sms_keyword !== (p.sms_keyword ?? '');
}
return form.signal_identifier !== (p.signal_identifier ?? '');
});
// Диалог подтверждения смены источника на залоченном проекте.
const sourceConfirmOpen = ref(false);
const sourceConfirmText = computed(() => {
// Эпик 6.3: единый текст правила из API (ProjectRuleMessages). Fallback — локальный.
const fromApi = props.project?.source_change_message;
if (fromApi) {
return `Мы уже ведём сбор на завтра. ${fromApi} Подтвердите смену источника.`;
}
const d = formatLeadDate(props.project?.source_unlock_at);
return (
'Мы уже ведём сбор на завтра. Лиды по старому источнику придут завтра в любом случае. ' +
(d ? `Послезавтра (после ${d}) — уже по новому. ` : '') +
'Подтвердите смену источника.'
);
});
async function onPause(): Promise<void> {
if (!props.project) return;
await store.toggleActive(props.project);
// #4: после паузы/возобновления панель и галочка должны исчезнуть (как у Save и Delete).
emit('close');
}
async function onDelete(): Promise<void> {
if (!props.project) return;
const ok = window.confirm(
'Удалить проект? Действие необратимо. Если по проекту есть сделки или поставщик уже заказал лиды — удаление будет заблокировано.',
);
if (!ok) return;
Object.keys(errors).forEach((k) => delete errors[k]);
try {
await store.del(props.project.id);
emit('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);
}
// НЕ закрываем drawer — клиент видит ошибку и может поставить проект на паузу.
}
}
async function onSave(): Promise<void> {
if (!props.project) return;
// Эпик 3.2: смена источника на залоченном проекте — через подтверждение.
if (sourceLocked.value && sourceDirty.value) {
sourceConfirmOpen.value = true;
return;
}
await doSave();
}
function cancelSourceChange(): void {
sourceConfirmOpen.value = false;
}
async function confirmSourceChange(): Promise<void> {
sourceConfirmOpen.value = false;
await doSave();
}
async function doSave(): 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,
regions: form.regions,
delivery_days_mask: form.delivery_days_mask,
};
// 18.05.2026 ux: редактирование источника проекта.
if (props.project.signal_type === 'site' || props.project.signal_type === 'call') {
payload.signal_identifier = form.signal_identifier;
}
if (props.project.signal_type === 'sms') {
payload.sms_senders = form.sms_senders;
payload.sms_keyword = form.sms_keyword;
}
const { data } = await axios.patch(`/api/projects/${props.project.id}`, payload);
// Backend кладёт applies_from когда правка задела slepok-чувствительные поля
// (см. ProjectService::updateAndExposeAppliesFrom / Task 2.8).
const appliesFrom: string | null = data?.data?.applies_from ?? null;
emit('saved', appliesFrom);
} catch (e: unknown) {
const err = e as {
response?: {
status?: number;
data?: {
errors?: Record<string, string[]>;
error?: string;
current_capacity_leads?: number;
would_be_required_leads?: number;
};
};
};
if (err.response?.status === 422 && err.response.data?.errors) {
Object.assign(errors, err.response.data.errors);
} else if (err.response?.status === 409 && err.response.data?.error === 'balance_insufficient') {
// O (балансовый блок): раньше панель молчала при 409 — клиент не понимал,
// почему правка лимита не сохранилась. Показываем причину под полем лимита.
const d = err.response.data;
errors.daily_limit_target = [
`Лимит превышает баланс: хватает на ${d.current_capacity_leads ?? 0} лид(ов), запрошено ${d.would_be_required_leads ?? form.daily_limit_target}. Пополните баланс, чтобы увеличить лимит.`,
];
}
} 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" aria-label="Закрыть" @click="$emit('close')">
<v-icon size="20" icon="mdi-close" />
</button>
</header>
<div class="pdd-body">
<!-- Общая ошибка уровня проекта (например, supplier-snapshot guard или has-deals на delete). -->
<div v-if="errors.project" class="pdd-error pdd-error-banner" data-testid="pdd-error-project">
{{ errors.project[0] }}
</div>
<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>
<!-- 18.05.2026 ux: редактирование источника проекта (site/call/sms) -->
<label v-if="project?.signal_type === 'site'" class="pdd-field">
<span class="pdd-label">Сайт, с которого нужны заявки</span>
<input
v-model="form.signal_identifier"
data-testid="pdd-signal-identifier"
class="pdd-input"
placeholder="okna-moskva.ru"
/>
<div v-if="errors.signal_identifier" class="pdd-error" data-testid="pdd-error-signal">
{{ errors.signal_identifier[0] }}
</div>
</label>
<label v-else-if="project?.signal_type === 'call'" class="pdd-field">
<span class="pdd-label">Телефон, по которому идут клиенты</span>
<input
v-model="form.signal_identifier"
data-testid="pdd-signal-identifier"
class="pdd-input"
placeholder="79161234567"
/>
<div class="pdd-hint">Можно с +7, 8, скобками и пробелами приведём к виду 79161234567</div>
<div v-if="errors.signal_identifier" class="pdd-error" data-testid="pdd-error-signal">
{{ errors.signal_identifier[0] }}
</div>
</label>
<div v-else-if="project?.signal_type === 'sms'" class="pdd-field">
<span class="pdd-label">Источник отправители SMS</span>
<v-combobox
v-model="form.sms_senders"
data-testid="pdd-sms-senders"
multiple
chips
clearable
density="comfortable"
hide-details
placeholder="MTS, BEELINE …"
/>
<div v-if="errors.sms_senders" class="pdd-error">{{ errors.sms_senders[0] }}</div>
<span class="pdd-label mt-2">Ключевое слово (опционально)</span>
<input
v-model="form.sms_keyword"
data-testid="pdd-sms-keyword"
class="pdd-input"
placeholder="КРЕДИТ"
/>
<div v-if="errors.sms_keyword" class="pdd-error">{{ errors.sms_keyword[0] }}</div>
</div>
<!-- Эпик 3.1: объявление о вступлении правок количества/региона/дней (без блокировки). -->
<div
v-if="showAppliesFromBanner"
class="pdd-lock-hint"
data-testid="pdd-applies-from-banner"
>
Ближайший сбор уже идёт по текущим настройкам. Новые количество, регионы
и дни приёма вступят в силу с {{ appliesFromDate }}.
</div>
<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="form.regions"
:items="selectableRegions"
item-title="name"
item-value="code"
multiple
chips
closable-chips
clearable
density="comfortable"
hide-details
data-testid="pdd-regions"
>
<template #item="{ props: itemProps, item }">
<v-list-item v-bind="itemProps">
<template #subtitle>
{{ FEDERAL_DISTRICT_NAMES[item.raw.federalDistrict] || '' }}
</template>
</v-list-item>
</template>
</v-autocomplete>
</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">
<v-icon size="18" :icon="project.is_active ? 'mdi-pause' : 'mdi-play'" />
{{ project.is_active ? 'Приостановить' : 'Возобновить' }}
</button>
<button class="pdd-btn pdd-btn-error" data-testid="pdd-delete" @click="onDelete">
<v-icon size="18" icon="mdi-delete" /> Удалить
</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>
<!-- Эпик 3.2: подтверждение смены источника на залоченном проекте. -->
<div
v-if="sourceConfirmOpen"
class="pdd-confirm-overlay"
data-testid="pdd-source-change-confirm"
>
<div class="pdd-confirm-box">
<div class="pdd-confirm-title">Сменить источник?</div>
<div class="pdd-confirm-text">{{ sourceConfirmText }}</div>
<div class="pdd-confirm-actions">
<button
class="pdd-btn pdd-btn-text"
data-testid="pdd-source-confirm-no"
@click="cancelSourceChange"
>
Отмена
</button>
<button
class="pdd-btn pdd-btn-primary"
data-testid="pdd-source-confirm-yes"
@click="confirmSourceChange"
>
Сменить источник
</button>
</div>
</div>
</div>
</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;
}
.pdd-hint {
color: #6b6f72;
font-size: 12px;
margin-top: 4px;
}
.pdd-lock-hint {
margin-top: 6px;
font-size: 12.5px;
line-height: 1.45;
color: #23433a;
background: #f0f6f3;
border: 1px solid #cfe4db;
border-left: 3px solid #0f6e56;
border-radius: 6px;
padding: 9px 11px;
}
.pdd-confirm-overlay {
position: absolute;
inset: 0;
background: rgba(1, 32, 25, 0.45);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
z-index: 10;
}
.pdd-confirm-box {
background: #ffffff;
border-radius: 10px;
padding: 20px;
max-width: 380px;
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.18);
}
.pdd-confirm-title {
font-weight: 600;
font-size: 15px;
margin-bottom: 8px;
}
.pdd-confirm-text {
font-size: 13px;
line-height: 1.5;
color: #23433a;
margin-bottom: 16px;
}
.pdd-confirm-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>