11dcd04173
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
579 lines
22 KiB
Vue
579 lines
22 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, 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>
|