diff --git a/app/resources/js/api/advertising.ts b/app/resources/js/api/advertising.ts index 9bb6d45f..22b2f6c4 100644 --- a/app/resources/js/api/advertising.ts +++ b/app/resources/js/api/advertising.ts @@ -156,6 +156,34 @@ export async function uploadCreativeImage(id: number, adId: number, file: File): return data; } +/** DELETE /api/advertising/campaigns/{id} — удалить ЧЕРНОВИК кампании своего тенанта (409 если не draft, 404 чужой). */ +export async function deleteCampaign(id: number): Promise { + await ensureCsrfCookie(); + await apiClient.delete(`/api/advertising/campaigns/${id}`); +} + +/** Ответ POST /api/advertising/campaigns/{id}/phones — сколько номеров распознано / отброшено. */ +export interface UploadPhonesResult { + recognized: number; + skipped: number; +} + +/** + * POST /api/advertising/campaigns/{id}/phones — загрузить «мой список номеров» кампании + * (файл csv/txt и/или текст, оба необязательны по отдельности — но хотя бы один нужен серверу). + */ +export async function uploadCampaignPhones( + id: number, + payload: { file?: File | null; text?: string }, +): Promise { + await ensureCsrfCookie(); + const form = new FormData(); + if (payload.file) form.append('file', payload.file); + if (payload.text && payload.text.trim() !== '') form.append('text', payload.text); + const { data } = await apiClient.post(`/api/advertising/campaigns/${id}/phones`, form); + return data; +} + /** * Счёт на пополнение РЕКЛАМНОГО кошелька (POST /api/billing/invoices, credit_target=advertising). * Тот же эндпоинт, что и обычный счёт за лиды (api/billing.ts::createInvoice) — diff --git a/app/resources/js/components/advertising/CampaignList.vue b/app/resources/js/components/advertising/CampaignList.vue index 2ccb3262..d1834828 100644 --- a/app/resources/js/components/advertising/CampaignList.vue +++ b/app/resources/js/components/advertising/CampaignList.vue @@ -8,12 +8,21 @@ * зависят от текущего статуса (pauseCampaign/resumeCampaign), «Отчёт» — * открывает CampaignReportDialog. «Изменить» пробрасывает id наверх — * понадобится мастеру редактирования (B2-9). + * + * T16 — черновики называются одинаково («Реклама от ДД.ММ.ГГГГ»), поэтому в + * заголовке карточки к имени добавляется `#id`, чтобы различать несколько + * черновиков в списке. + * + * T15 — у черновика (status === 'draft') есть кнопка «Удалить»: подтверждение + * через `v-dialog`, затем `deleteCampaign` + перезагрузка списка. Для не- + * черновиков кнопки нет (сервер и не даст удалить — только draft, 409 иначе). */ import { ref, onMounted } from 'vue'; import { fetchCampaigns, pauseCampaign, resumeCampaign, + deleteCampaign, type Campaign, type CampaignStatus, } from '../../api/advertising'; @@ -118,6 +127,37 @@ function openReport(campaign: Campaign): void { reportDialogOpen.value = true; } +// T15 — подтверждение удаления черновика. +const deleteConfirmOpen = ref(false); +const campaignToDelete = ref(null); + +function askDelete(campaign: Campaign): void { + campaignToDelete.value = campaign; + deleteConfirmOpen.value = true; +} + +function cancelDelete(): void { + deleteConfirmOpen.value = false; + campaignToDelete.value = null; +} + +async function confirmDelete(): Promise { + const campaign = campaignToDelete.value; + if (!campaign) return; + deleteConfirmOpen.value = false; + busyId.value = campaign.id; + actionError.value = null; + try { + await deleteCampaign(campaign.id); + await loadCampaigns(); + } catch (e) { + actionError.value = extractErrorMessage(e, 'Не удалось удалить кампанию.'); + } finally { + busyId.value = null; + campaignToDelete.value = null; + } +} + defineExpose({ loadCampaigns }); @@ -175,7 +215,9 @@ defineExpose({ loadCampaigns });
- {{ campaign.name }} + {{ campaign.name }} #{{ campaign.id }}
Отчёт + + + Удалить +
@@ -256,6 +311,28 @@ defineExpose({ loadCampaigns }); + + + + Удалить кампанию? + + Черновик «{{ campaignToDelete?.name }} #{{ campaignToDelete?.id }}» будет удалён без возможности + восстановления. + + + + Отмена + + Удалить + + + + diff --git a/app/resources/js/components/advertising/CampaignWizard.vue b/app/resources/js/components/advertising/CampaignWizard.vue index a4230407..e34dd427 100644 --- a/app/resources/js/components/advertising/CampaignWizard.vue +++ b/app/resources/js/components/advertising/CampaignWizard.vue @@ -16,10 +16,14 @@ * без id не работает живой счётчик аудитории (GET .../audience-size требует * campaign id). Имя черновика — авто, «Реклама от ДД.ММ.ГГГГ» (дата создания). * - * ⚠️ Брошенные черновики (клиент закрыл мастер, не дойдя до запуска) пока - * НЕ чистятся и НЕ переиспользуются — они остаются в статусе draft. Чистка/ - * переиспользование черновиков — следующий инкремент (эндпоинта удаления - * в API B1 нет, выдумывать его здесь нельзя). + * ПЕРЕИСПОЛЬЗОВАНИЕ ЧЕРНОВИКА (T14): без `campaignId` мастер сперва спрашивает + * список кампаний (`fetchCampaigns`) и ищет ПОСЛЕДНИЙ незапущенный черновик + * (`status === 'draft'`, список приходит отсортированным `id` desc — первый + * найденный и есть последний). Нашёлся — продолжаем его через `loadForEdit` + * (без `createDraft`, новый черновик НЕ плодим); это НЕ режим правки — + * `isEditMode` завязан строго на `props.campaignId`, подсказка Р30 не + * показывается. Черновиков нет или список не удалось получить — как раньше, + * `createDraft()`. * * РЕЖИМ ПРАВКИ (B2-9, Р30): опциональный prop `campaignId`. Если задан — при * монтировании грузим существующую кампанию (`fetchCampaign`) и префиллим @@ -36,8 +40,10 @@ import { createCampaign, patchCampaign, fetchCampaign, + fetchCampaigns, fetchAudienceSize, launchCampaign, + uploadCampaignPhones, type CampaignCreate, type AudienceSize, type AdCreative, @@ -263,15 +269,72 @@ function stepValue(item: unknown): number { return Number((item as { value: number }).value); } +/** + * T14 — без `campaignId` не плодим черновик бездумно: сперва смотрим список + * кампаний тенанта и, если там уже есть незапущенный черновик, продолжаем + * его вместо создания нового. Сбой запроса списка — не роняем мастер молча, + * фолбэк на прежнее поведение (`createDraft`). + */ +async function initDraftOrResume(): Promise { + try { + const campaigns = await fetchCampaigns(); + const existingDraft = campaigns.find((c) => c.status === 'draft'); + if (existingDraft) { + await loadForEdit(existingDraft.id); + return; + } + } catch { + // Список кампаний не удалось получить — не блокируем создание рекламы этим сбоем. + } + await createDraft(); +} + onMounted(() => { if (props.campaignId != null) { void loadForEdit(props.campaignId); } else { - void createDraft(); + void initDraftOrResume(); } }); -defineExpose({ form, draftId, audienceSize, canLaunch, refreshAudienceSize, budgetError, launchError }); +const phonesFile = ref(null); +const phonesText = ref(''); +const phonesUploading = ref(false); +const phonesUploadError = ref(null); +const phonesUploadResult = ref<{ recognized: number; skipped: number } | null>(null); + +/** T18 — загрузка «моего списка номеров» с шага 1 (файл и/или текст, хотя бы один нужен серверу). */ +async function uploadPhones(): Promise { + if (draftId.value === null) return; + phonesUploadError.value = null; + phonesUploadResult.value = null; + phonesUploading.value = true; + try { + phonesUploadResult.value = await uploadCampaignPhones(draftId.value, { + file: phonesFile.value, + text: phonesText.value, + }); + } catch (e) { + phonesUploadError.value = extractErrorMessage(e, 'Не удалось загрузить список номеров.'); + } finally { + phonesUploading.value = false; + } +} + +defineExpose({ + form, + draftId, + audienceSize, + canLaunch, + refreshAudienceSize, + budgetError, + launchError, + phonesFile, + phonesText, + uploadPhones, + phonesUploadResult, + phonesUploadError, +});