feat(ops/campaigns): UI module for gift campaigns + GrapesJS template editor
New /campaigns section in the ops SPA, gated by manage_users (proxy until a
dedicated manage_campaigns capability is added).
Pages (apps/ops/src/modules/campaigns/pages/):
- CampaignsListPage: table of all campaigns with status chip + progress
bar (sent/total, with fail count), "Nouvelle campagne" + "Éditer le
template" buttons. Empty state with onboarding copy.
- CampaignNewPage: 3-step Quasar Stepper wizard.
Step 1 — upload Map CSV + Giftbit CSV, configure params (name, amount,
commitment_months, sender, throttle, multi-email handling).
Step 2 — preview the matched send list from POST /campaigns/parse, with
counters (matched/unmatched/excluded), per-row match-method
chip, and exclude/include toggle. Banner warns when CSVs are
mis-aligned (leftover gifts or contacts).
Step 3 — confirmation recap with estimated send duration, then fire
POST /campaigns + POST /campaigns/:id/send and redirect to the
live detail page.
- CampaignDetailPage: per-recipient table with status chips updated live
via EventSource on the campaign:<id> SSE topic. Counters bar
(envoyés / cliqués / queued / échecs / non envoyés), progress bar,
per-row customer-link badge with deep-link into /clients/<id>.
Auto-subscribes to SSE when status is draft|sending; "Lancer l'envoi"
button for draft campaigns.
- TemplateEditorPage: GrapesJS-based visual editor for the campaign
templates. Three view modes (Visuel / HTML / Aperçu) — the HTML mode
is the fallback for our table-heavy hand-crafted template that
GrapesJS-preset-newsletter may parse imperfectly. Aperçu mode calls
POST /campaigns/templates/:name/preview on the hub for live variable
substitution. Custom GrapesJS blocks under "Variables" category for
drag-drop insertion of {{firstname}}, {{amount}}, {{gift_url}},
{{description}}, {{expiry}}, {{commitment_months}}. Saves via PUT
with hub-side backup of the previous version.
Wiring:
- api/campaigns.js: hubFetch wrapper, exports parseCsvs / createCampaign
/ listCampaigns / getCampaign / updateCampaign / sendCampaign +
campaignSseUrl(id) for EventSource subscription, + listTemplates /
getTemplate / saveTemplate / previewTemplate for the editor.
- router/index.js: three new routes under /campaigns. The
/campaigns/templates/:name? route is positioned ABOVE /campaigns/:id
to prevent the wildcard from catching template paths.
- config/nav.js + layouts/MainLayout.vue: "Campagnes" sidebar entry with
Lucide Gift icon.
- package.json: grapesjs + grapesjs-preset-newsletter dependencies.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
5d763f12ff
commit
611f4ed5a6
96
apps/ops/package-lock.json
generated
96
apps/ops/package-lock.json
generated
|
|
@ -12,6 +12,8 @@
|
|||
"@twilio/voice-sdk": "^2.18.1",
|
||||
"chart.js": "^4.5.1",
|
||||
"cytoscape": "^3.33.2",
|
||||
"grapesjs": "^0.22.16",
|
||||
"grapesjs-preset-newsletter": "^1.0.2",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"lucide-vue-next": "^1.0.0",
|
||||
"pinia": "^2.1.7",
|
||||
|
|
@ -2661,6 +2663,16 @@
|
|||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/backbone": {
|
||||
"version": "1.4.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/backbone/-/backbone-1.4.15.tgz",
|
||||
"integrity": "sha512-WWeKtYlsIMtDyLbbhkb96taJMEbfQBnuz7yw1u0pkphCOtksemoWhIXhK74VRCY9hbjnsH3rsJu2uUiFtnsEYg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/jquery": "*",
|
||||
"@types/underscore": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/body-parser": {
|
||||
"version": "1.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||
|
|
@ -2781,6 +2793,12 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/jquery": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-4.0.0.tgz",
|
||||
"integrity": "sha512-Z+to+A2VkaHq1DfI2oSwsoCdhCHMpTSgjWzNcbNlRGYzksDBpPUgEcAL+RQjOBJRaLoEAOHXxqDGBVP+BblBwg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/mime": {
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
||||
|
|
@ -2862,6 +2880,12 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/underscore": {
|
||||
"version": "1.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/underscore/-/underscore-1.13.0.tgz",
|
||||
"integrity": "sha512-L6LBgy1f0EFQZ+7uSA57+n2g/s4Qs5r06Vwrwn0/nuK1de+adz00NWaztRQ30aEqw5qOaWbPI8u2cGQ52lj6VA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@ungap/structured-clone": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
|
||||
|
|
@ -3373,6 +3397,26 @@
|
|||
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/backbone": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/backbone/-/backbone-1.4.1.tgz",
|
||||
"integrity": "sha512-ADy1ztN074YkWbHi8ojJVFe3vAanO/lrzMGZWUClIP7oDD/Pjy2vrASraUP+2EVCfIiTtCW4FChVow01XneivA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"underscore": ">=1.8.3"
|
||||
}
|
||||
},
|
||||
"node_modules/backbone-undo": {
|
||||
"version": "0.2.6",
|
||||
"resolved": "https://registry.npmjs.org/backbone-undo/-/backbone-undo-0.2.6.tgz",
|
||||
"integrity": "sha512-AsfpNiljLXlk7TcffDUu3EAUq7CxWbyTNwARWrql5XTzN4vh6WzEEBZYaKK4kTTz+iW1tSzqUooaGRIwO83kWA==",
|
||||
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"backbone": ">=1.0.0",
|
||||
"underscore": ">=1.4.4"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
|
|
@ -3870,6 +3914,18 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/codemirror": {
|
||||
"version": "5.63.0",
|
||||
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.63.0.tgz",
|
||||
"integrity": "sha512-KlLWRPggDg2rBD1Mx7/EqEhaBdy+ybBCVh/efgjBDsPpMeEu6MbTAJzIT4TuCzvmbTEgvKOGzVT6wdBTNusqrg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/codemirror-formatting": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/codemirror-formatting/-/codemirror-formatting-1.0.0.tgz",
|
||||
"integrity": "sha512-br9yM6eJI3pJHekEnoyHaBEb1B7XxxDjju+vRyBe8QGLp5saTIXXkZ+eFCTqXSAtI8QEZDFVEX2/SOjH2sVWRQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
|
|
@ -5770,6 +5826,28 @@
|
|||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/grapesjs": {
|
||||
"version": "0.22.16",
|
||||
"resolved": "https://registry.npmjs.org/grapesjs/-/grapesjs-0.22.16.tgz",
|
||||
"integrity": "sha512-kCfphgpC7pqJPuMYmIhMR6ueyB3+V67isdpMZOvmuGeWDMomkgzqRWOMH3matfdqIJW7LUivHZo9GeyVQAGmLw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@types/backbone": "1.4.15",
|
||||
"backbone": "1.4.1",
|
||||
"backbone-undo": "0.2.6",
|
||||
"codemirror": "5.63.0",
|
||||
"codemirror-formatting": "1.0.0",
|
||||
"html-entities": "~1.4.0",
|
||||
"promise-polyfill": "8.3.0",
|
||||
"underscore": "1.13.8"
|
||||
}
|
||||
},
|
||||
"node_modules/grapesjs-preset-newsletter": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/grapesjs-preset-newsletter/-/grapesjs-preset-newsletter-1.0.2.tgz",
|
||||
"integrity": "sha512-z8KJ1ZrTXfASSJZ/tHOcnpcWu4AMr2F/ZfQit+QjimNi3UGowwl7+Yjefuh3R7lbDTrXMMaxhCannCaJo/kPJw==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/graphemer": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
|
||||
|
|
@ -5871,6 +5949,12 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/html-entities": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-1.4.0.tgz",
|
||||
"integrity": "sha512-8nxjcBcd8wovbeKx7h3wTji4e6+rhaVuPNpMqwWgnHh+N9ToqsCs6XztWRBPQ+UtzsoMAdKZtUENoVzU/EMtZA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/html-minifier-terser": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-7.2.0.tgz",
|
||||
|
|
@ -7687,6 +7771,12 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/promise-polyfill": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.3.0.tgz",
|
||||
"integrity": "sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
|
|
@ -9220,6 +9310,12 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/underscore": {
|
||||
"version": "1.13.8",
|
||||
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz",
|
||||
"integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@
|
|||
"@twilio/voice-sdk": "^2.18.1",
|
||||
"chart.js": "^4.5.1",
|
||||
"cytoscape": "^3.33.2",
|
||||
"grapesjs": "^0.22.16",
|
||||
"grapesjs-preset-newsletter": "^1.0.2",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"lucide-vue-next": "^1.0.0",
|
||||
"pinia": "^2.1.7",
|
||||
|
|
|
|||
105
apps/ops/src/api/campaigns.js
Normal file
105
apps/ops/src/api/campaigns.js
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
/**
|
||||
* api/campaigns.js — Client for Hub /campaigns endpoints.
|
||||
*
|
||||
* Mirrors services/targo-hub/lib/campaigns.js. All gift-campaign requests
|
||||
* go through the Hub which handles ERPNext auth + Mailjet send + SSE
|
||||
* progress broadcast.
|
||||
*
|
||||
* Functions:
|
||||
* parseCsvs({ map_csv, giftbit_csv, multi }) → preview matched send list
|
||||
* createCampaign({ name, params, recipients }) → save + return id
|
||||
* listCampaigns() → summaries
|
||||
* getCampaign(id) → full detail
|
||||
* updateCampaign(id, patch) → edit recipients/params
|
||||
* sendCampaign(id) → fire background worker
|
||||
* campaignSseUrl(id) → SSE URL for live updates
|
||||
*/
|
||||
|
||||
import { HUB_URL } from 'src/config/hub'
|
||||
|
||||
async function hubFetch (path, { method = 'GET', body } = {}) {
|
||||
const opts = { method, headers: { 'Content-Type': 'application/json' } }
|
||||
if (body) opts.body = JSON.stringify(body)
|
||||
const res = await fetch(`${HUB_URL}${path}`, opts)
|
||||
const text = await res.text()
|
||||
let data
|
||||
try { data = text ? JSON.parse(text) : {} }
|
||||
catch { throw new Error(`Invalid JSON from ${path}: ${text.slice(0, 200)}`) }
|
||||
if (!res.ok) {
|
||||
const msg = data.error || `HTTP ${res.status}`
|
||||
const err = new Error(msg)
|
||||
err.status = res.status
|
||||
throw err
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
export function parseCsvs ({ map_csv, giftbit_csv, multi = 'first' }) {
|
||||
return hubFetch('/campaigns/parse', {
|
||||
method: 'POST',
|
||||
body: { map_csv, giftbit_csv, multi },
|
||||
})
|
||||
}
|
||||
|
||||
export function createCampaign ({ name, params, recipients }) {
|
||||
return hubFetch('/campaigns', {
|
||||
method: 'POST',
|
||||
body: { name, params, recipients },
|
||||
})
|
||||
}
|
||||
|
||||
export function listCampaigns () {
|
||||
return hubFetch('/campaigns').then(r => r.campaigns || [])
|
||||
}
|
||||
|
||||
export function getCampaign (id) {
|
||||
return hubFetch(`/campaigns/${encodeURIComponent(id)}`)
|
||||
}
|
||||
|
||||
export function updateCampaign (id, patch) {
|
||||
return hubFetch(`/campaigns/${encodeURIComponent(id)}`, {
|
||||
method: 'PATCH',
|
||||
body: patch,
|
||||
})
|
||||
}
|
||||
|
||||
export function sendCampaign (id) {
|
||||
return hubFetch(`/campaigns/${encodeURIComponent(id)}/send`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
// ── Template editing (used by the GrapesJS editor page) ─────────────────────
|
||||
|
||||
export function listTemplates () {
|
||||
return hubFetch('/campaigns/templates').then(r => r.templates || [])
|
||||
}
|
||||
|
||||
export function getTemplate (name) {
|
||||
return hubFetch(`/campaigns/templates/${encodeURIComponent(name)}`)
|
||||
}
|
||||
|
||||
export function saveTemplate (name, html) {
|
||||
return hubFetch(`/campaigns/templates/${encodeURIComponent(name)}`, {
|
||||
method: 'PUT',
|
||||
body: { html },
|
||||
})
|
||||
}
|
||||
|
||||
export function previewTemplate (name, { html, vars } = {}) {
|
||||
return hubFetch(`/campaigns/templates/${encodeURIComponent(name)}/preview`, {
|
||||
method: 'POST',
|
||||
body: { html, vars },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the URL for the SSE channel of one campaign. The Hub broadcasts on
|
||||
* topic `campaign:<id>` so we subscribe to that single topic. Use with:
|
||||
* const es = new EventSource(campaignSseUrl(id))
|
||||
* es.addEventListener('recipient-update', ev => { ... })
|
||||
* es.addEventListener('campaign-done', ev => { ... })
|
||||
*/
|
||||
export function campaignSseUrl (id) {
|
||||
return `${HUB_URL}/sse?topics=campaign:${encodeURIComponent(id)}`
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ export const navItems = [
|
|||
{ path: '/tickets', icon: 'Ticket', label: 'Tickets', requires: 'view_all_tickets' },
|
||||
{ path: '/equipe', icon: 'UsersRound', label: 'Équipe', requires: 'manage_users' },
|
||||
{ path: '/rapports', icon: 'BarChart3', label: 'Rapports', requires: 'view_dashboard_kpi' },
|
||||
{ path: '/campaigns', icon: 'Gift', label: 'Campagnes', requires: 'manage_users' },
|
||||
{ path: '/settings', icon: 'Settings', label: 'Paramètres', requires: 'view_settings' },
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -123,13 +123,13 @@ import { listDocs } from 'src/api/erp'
|
|||
import { navItems as allNavItems } from 'src/config/nav'
|
||||
import {
|
||||
LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3,
|
||||
Settings, LogOut, PanelLeftOpen, PanelLeftClose,
|
||||
Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose,
|
||||
} from 'lucide-vue-next'
|
||||
import ConversationPanel from 'src/components/shared/ConversationPanel.vue'
|
||||
import { useConversations } from 'src/composables/useConversations'
|
||||
import FlowEditorDialog from 'src/components/flow-editor/FlowEditorDialog.vue'
|
||||
|
||||
const icons = { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, Settings, LogOut, PanelLeftOpen, PanelLeftClose }
|
||||
const icons = { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose }
|
||||
|
||||
const { panelOpen, activeCount: convCount } = useConversations()
|
||||
function toggleConvPanel () { panelOpen.value = !panelOpen.value }
|
||||
|
|
|
|||
194
apps/ops/src/modules/campaigns/pages/CampaignDetailPage.vue
Normal file
194
apps/ops/src/modules/campaigns/pages/CampaignDetailPage.vue
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
<template>
|
||||
<q-page padding>
|
||||
<div class="row items-center q-mb-md">
|
||||
<q-btn flat dense round icon="arrow_back" :to="'/campaigns'" class="q-mr-sm" />
|
||||
<div class="text-h5">{{ campaign?.name || id }}</div>
|
||||
<q-chip dense class="q-ml-md" :color="statusColor(campaign?.status)" text-color="white" :label="statusLabel(campaign?.status)" />
|
||||
<q-space />
|
||||
<q-btn v-if="campaign?.status === 'draft'" unelevated color="primary" icon="send" label="Lancer l'envoi"
|
||||
:loading="resending" @click="relaunch" />
|
||||
</div>
|
||||
|
||||
<!-- Counters bar -->
|
||||
<div class="row q-col-gutter-sm q-mb-md" v-if="campaign">
|
||||
<q-card flat bordered class="col-6 col-md-2"><q-card-section class="text-center">
|
||||
<div class="text-h5">{{ campaign.counters?.total || campaign.recipients?.length || 0 }}</div>
|
||||
<div class="text-caption text-grey-7">Total</div>
|
||||
</q-card-section></q-card>
|
||||
<q-card flat bordered class="col-6 col-md-2"><q-card-section class="text-center">
|
||||
<div class="text-h5 text-positive">{{ counterFor('sent') + counterFor('opened') + counterFor('clicked') }}</div>
|
||||
<div class="text-caption text-grey-7">Envoyés</div>
|
||||
</q-card-section></q-card>
|
||||
<q-card flat bordered class="col-6 col-md-2"><q-card-section class="text-center">
|
||||
<div class="text-h5 text-blue">{{ counterFor('clicked') }}</div>
|
||||
<div class="text-caption text-grey-7">Cliqués</div>
|
||||
</q-card-section></q-card>
|
||||
<q-card flat bordered class="col-6 col-md-2"><q-card-section class="text-center">
|
||||
<div class="text-h5 text-orange">{{ counterFor('queued') }}</div>
|
||||
<div class="text-caption text-grey-7">En attente</div>
|
||||
</q-card-section></q-card>
|
||||
<q-card flat bordered class="col-6 col-md-2"><q-card-section class="text-center">
|
||||
<div class="text-h5 text-negative">{{ counterFor('failed') + counterFor('bounced') }}</div>
|
||||
<div class="text-caption text-grey-7">Échecs</div>
|
||||
</q-card-section></q-card>
|
||||
<q-card flat bordered class="col-6 col-md-2"><q-card-section class="text-center">
|
||||
<div class="text-h5 text-grey-7">{{ counterFor('pending') }}</div>
|
||||
<div class="text-caption text-grey-7">Non envoyés</div>
|
||||
</q-card-section></q-card>
|
||||
</div>
|
||||
|
||||
<q-linear-progress
|
||||
v-if="campaign && (campaign.status === 'sending' || campaign.status === 'completed')"
|
||||
:value="sentRatio" :color="campaign.counters?.failed ? 'orange' : 'positive'" size="8px" class="q-mb-md"
|
||||
/>
|
||||
|
||||
<q-table
|
||||
v-if="campaign"
|
||||
:rows="campaign.recipients || []"
|
||||
:columns="columns" row-key="email"
|
||||
flat bordered dense
|
||||
:pagination="{ rowsPerPage: 50 }"
|
||||
:rows-per-page-options="[25, 50, 100, 0]"
|
||||
>
|
||||
<template v-slot:body-cell-status="props">
|
||||
<q-td :props="props">
|
||||
<q-chip dense size="sm" :color="statusColor(props.row.status)" text-color="white" :label="statusLabel(props.row.status)" />
|
||||
</q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-customer="props">
|
||||
<q-td :props="props">
|
||||
<span v-if="props.row.customer_id">
|
||||
<q-icon name="person" size="14px" class="q-mr-xs" />
|
||||
<a :href="`/#/clients/${props.row.customer_id}`" target="_blank" style="color:var(--q-primary)">
|
||||
{{ props.row.customer_name || props.row.customer_id }}
|
||||
</a>
|
||||
<q-chip dense size="xs" outline class="q-ml-xs">{{ props.row.match_method }}</q-chip>
|
||||
</span>
|
||||
<span v-else class="text-grey-6">—</span>
|
||||
</q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-gift_url="props">
|
||||
<q-td :props="props">
|
||||
<a :href="props.row.gift_url" target="_blank" class="text-grey-7" style="font-family:monospace; font-size:0.78rem">
|
||||
{{ shortLink(props.row.gift_url) }}
|
||||
</a>
|
||||
</q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-error="props">
|
||||
<q-td :props="props">
|
||||
<span v-if="props.row.error" class="text-negative text-caption">{{ props.row.error }}</span>
|
||||
</q-td>
|
||||
</template>
|
||||
</q-table>
|
||||
|
||||
<div v-if="!campaign && !loading" class="text-center q-pa-xl text-grey-7">
|
||||
<q-icon name="error_outline" size="48px" />
|
||||
<div class="text-h6 q-mt-md">Campagne introuvable</div>
|
||||
</div>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useQuasar } from 'quasar'
|
||||
import { getCampaign, sendCampaign, campaignSseUrl } from 'src/api/campaigns'
|
||||
|
||||
const route = useRoute()
|
||||
const $q = useQuasar()
|
||||
const id = route.params.id
|
||||
const campaign = ref(null)
|
||||
const loading = ref(true)
|
||||
const resending = ref(false)
|
||||
|
||||
let es = null
|
||||
|
||||
const columns = [
|
||||
{ name: 'firstname', label: 'Prénom', field: 'firstname', align: 'left' },
|
||||
{ name: 'lastname', label: 'Nom', field: 'lastname', align: 'left' },
|
||||
{ name: 'email', label: 'Email', field: 'email', align: 'left' },
|
||||
{ name: 'civic_address', label: 'Adresse', field: 'civic_address', align: 'left' },
|
||||
{ name: 'customer', label: 'Client lié', field: 'customer_name', align: 'left' },
|
||||
{ name: 'gift_url', label: 'Shortlink', field: 'gift_url', align: 'left' },
|
||||
{ name: 'status', label: 'Statut', field: 'status', align: 'left' },
|
||||
{ name: 'error', label: 'Erreur', field: 'error', align: 'left' },
|
||||
]
|
||||
|
||||
function counterFor (s) { return campaign.value?.counters?.[s] || 0 }
|
||||
function statusColor (s) {
|
||||
return {
|
||||
pending: 'grey-5', queued: 'orange', sent: 'positive', opened: 'positive',
|
||||
clicked: 'blue', failed: 'negative', bounced: 'negative',
|
||||
draft: 'grey', sending: 'orange', completed: 'positive',
|
||||
}[s] || 'grey-5'
|
||||
}
|
||||
function statusLabel (s) {
|
||||
return {
|
||||
pending: 'En attente', queued: 'En file', sent: 'Envoyé', opened: 'Ouvert',
|
||||
clicked: 'Cliqué', failed: 'Échec', bounced: 'Rejeté',
|
||||
draft: 'Brouillon', sending: 'En cours', completed: 'Terminée',
|
||||
}[s] || s
|
||||
}
|
||||
function shortLink (u) { return (u || '').replace(/^https?:\/\//, '').slice(0, 28) + ((u || '').length > 35 ? '…' : '') }
|
||||
|
||||
const sentRatio = computed(() => {
|
||||
const total = campaign.value?.counters?.total || 1
|
||||
const done = counterFor('sent') + counterFor('opened') + counterFor('clicked')
|
||||
+ counterFor('failed') + counterFor('bounced')
|
||||
return Math.min(1, done / total)
|
||||
})
|
||||
|
||||
async function load () {
|
||||
loading.value = true
|
||||
try { campaign.value = await getCampaign(id) }
|
||||
catch (e) { $q.notify({ type: 'negative', message: e.message }) }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
function subscribeSse () {
|
||||
if (es) es.close()
|
||||
es = new EventSource(campaignSseUrl(id))
|
||||
es.addEventListener('recipient-update', (ev) => {
|
||||
const data = JSON.parse(ev.data)
|
||||
if (!campaign.value?.recipients) return
|
||||
// Apply patch by index; counters will be re-rendered from recipients next refresh
|
||||
if (campaign.value.recipients[data.i]) {
|
||||
Object.assign(campaign.value.recipients[data.i], data.recipient)
|
||||
// Recompute counters in-place for live update
|
||||
const counters = { total: campaign.value.recipients.length }
|
||||
for (const r of campaign.value.recipients) counters[r.status] = (counters[r.status] || 0) + 1
|
||||
campaign.value.counters = counters
|
||||
}
|
||||
})
|
||||
es.addEventListener('campaign-done', () => {
|
||||
$q.notify({ type: 'positive', message: 'Campagne terminée' })
|
||||
load()
|
||||
})
|
||||
es.addEventListener('campaign-status', (ev) => {
|
||||
const data = JSON.parse(ev.data)
|
||||
if (campaign.value) campaign.value.status = data.status
|
||||
})
|
||||
}
|
||||
|
||||
async function relaunch () {
|
||||
resending.value = true
|
||||
try {
|
||||
await sendCampaign(id)
|
||||
await load()
|
||||
subscribeSse()
|
||||
} catch (e) {
|
||||
$q.notify({ type: 'negative', message: e.message })
|
||||
} finally {
|
||||
resending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await load()
|
||||
// Auto-subscribe to SSE if still running (or about to run)
|
||||
if (campaign.value && ['draft','sending'].includes(campaign.value.status)) {
|
||||
subscribeSse()
|
||||
}
|
||||
})
|
||||
onBeforeUnmount(() => { if (es) es.close() })
|
||||
</script>
|
||||
268
apps/ops/src/modules/campaigns/pages/CampaignNewPage.vue
Normal file
268
apps/ops/src/modules/campaigns/pages/CampaignNewPage.vue
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
<template>
|
||||
<q-page padding>
|
||||
<div class="row items-center q-mb-md">
|
||||
<q-btn flat dense round icon="arrow_back" :to="'/campaigns'" class="q-mr-sm" />
|
||||
<div class="text-h5">Nouvelle campagne</div>
|
||||
</div>
|
||||
|
||||
<q-stepper v-model="step" header-nav ref="stepper" color="primary" animated flat bordered class="bg-white">
|
||||
|
||||
<!-- Step 1 — Upload + parameters ─────────────────────────────────── -->
|
||||
<q-step :name="1" title="Fichiers + paramètres" icon="upload_file" :done="step > 1" :header-nav="step > 1">
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<q-card flat bordered>
|
||||
<q-card-section>
|
||||
<div class="text-subtitle1 q-mb-sm">1. Export Map CSV (brut)</div>
|
||||
<div class="text-caption text-grey-7 q-mb-md">
|
||||
Le fichier <code>selectionAdressesMap*.csv</code> tel qu'exporté de la sélection
|
||||
d'adresses (pipe-delimited, préambule de 1 ligne accepté).
|
||||
</div>
|
||||
<q-file v-model="mapFile" label="Sélectionner le fichier" accept=".csv" outlined dense @update:model-value="readMapFile">
|
||||
<template v-slot:prepend><q-icon name="attach_file" /></template>
|
||||
</q-file>
|
||||
<div v-if="mapPreview" class="text-caption q-mt-sm text-grey-7">
|
||||
✓ {{ Math.max(0, (mapPreview.match(/\n/g)||[]).length - 1) }} lignes lues
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<q-card flat bordered>
|
||||
<q-card-section>
|
||||
<div class="text-subtitle1 q-mb-sm">2. Shortlinks Giftbit CSV</div>
|
||||
<div class="text-caption text-grey-7 q-mb-md">
|
||||
Le fichier <code>giftbit-gifts-<id>.csv</code> retourné par
|
||||
<code>create_giftbit_campaign.js</code> (colonnes: firstname, lastname, email,
|
||||
gift_url, giftbit_uuid, gift_value_cents).
|
||||
</div>
|
||||
<q-file v-model="giftFile" label="Sélectionner le fichier" accept=".csv" outlined dense @update:model-value="readGiftFile">
|
||||
<template v-slot:prepend><q-icon name="attach_file" /></template>
|
||||
</q-file>
|
||||
<div v-if="giftPreview" class="text-caption q-mt-sm text-grey-7">
|
||||
✓ {{ Math.max(0, (giftPreview.match(/\n/g)||[]).length - 1) }} cartes-cadeaux
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-card flat bordered class="q-mt-md">
|
||||
<q-card-section>
|
||||
<div class="text-subtitle1 q-mb-sm">Paramètres de la campagne</div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<q-input v-model="params.name" label="Nom interne" outlined dense class="col-12 col-md-6" />
|
||||
<q-input v-model="params.amount" label="Montant (affiché)" outlined dense class="col-6 col-md-3" placeholder="60 $" />
|
||||
<q-input v-model.number="params.commitment_months" type="number" label="Engagement (mois)" outlined dense class="col-6 col-md-3" />
|
||||
<q-input v-model="params.subject" label="Sujet du courriel" outlined dense class="col-12 col-md-6" />
|
||||
<q-input v-model="params.from" label="Expéditeur (From)" outlined dense class="col-12 col-md-6" placeholder="TARGO <support@targointernet.com>" />
|
||||
<q-input v-model="params.expiry" label="Expiration (texte affiché)" outlined dense class="col-12 col-md-6" placeholder="31 décembre 2026" />
|
||||
<q-input v-model.number="params.throttle_ms" type="number" label="Throttle (ms entre envois)" outlined dense class="col-12 col-md-6" />
|
||||
<q-select v-model="params.multi" :options="multiOptions" emit-value map-options label="Emails multiples (couples)" outlined dense class="col-12 col-md-6" />
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-stepper-navigation>
|
||||
<q-btn unelevated color="primary" label="Suivant — Aperçu" icon-right="arrow_forward"
|
||||
:disable="!mapPreview || !giftPreview || parsing"
|
||||
:loading="parsing" @click="goPreview" />
|
||||
</q-stepper-navigation>
|
||||
</q-step>
|
||||
|
||||
<!-- Step 2 — Preview matched send list ─────────────────────────── -->
|
||||
<q-step :name="2" title="Aperçu et matching" icon="preview" :done="step > 2" :header-nav="step > 2">
|
||||
<div class="row q-col-gutter-md q-mb-md">
|
||||
<q-card flat bordered class="col-12 col-md-3">
|
||||
<q-card-section class="text-center">
|
||||
<div class="text-h4">{{ recipients.length }}</div>
|
||||
<div class="text-caption text-grey-7">Total destinataires</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<q-card flat bordered class="col-12 col-md-3">
|
||||
<q-card-section class="text-center">
|
||||
<div class="text-h4 text-positive">{{ matchedCount }}</div>
|
||||
<div class="text-caption text-grey-7">Client ERPNext lié</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<q-card flat bordered class="col-12 col-md-3">
|
||||
<q-card-section class="text-center">
|
||||
<div class="text-h4 text-warning">{{ unmatchedCount }}</div>
|
||||
<div class="text-caption text-grey-7">Non liés (review)</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<q-card flat bordered class="col-12 col-md-3">
|
||||
<q-card-section class="text-center">
|
||||
<div class="text-h4 text-grey-7">{{ excludedCount }}</div>
|
||||
<div class="text-caption text-grey-7">Exclus</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<q-banner v-if="parseInfo.leftover_gifts || parseInfo.leftover_contacts" class="bg-orange-1 text-orange-9 q-mb-md" rounded>
|
||||
<template v-slot:avatar><q-icon name="warning" /></template>
|
||||
Désalignement détecté: {{ parseInfo.leftover_gifts }} carte(s) sans contact,
|
||||
{{ parseInfo.leftover_contacts }} contact(s) sans carte. Vérifier l'ordre des CSV.
|
||||
</q-banner>
|
||||
|
||||
<q-table
|
||||
:rows="recipients" :columns="recipientColumns" row-key="email"
|
||||
flat bordered dense :pagination="{ rowsPerPage: 25 }"
|
||||
:rows-per-page-options="[10, 25, 50, 100, 0]"
|
||||
>
|
||||
<template v-slot:body-cell-match="props">
|
||||
<q-td :props="props">
|
||||
<q-chip v-if="props.row.customer_id" dense color="positive" text-color="white" size="sm"
|
||||
:label="props.row.match_method" />
|
||||
<q-chip v-else dense color="warning" text-color="white" size="sm" label="non lié" />
|
||||
</q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-actions="props">
|
||||
<q-td :props="props" class="text-right">
|
||||
<q-btn flat dense size="sm" :icon="props.row.excluded ? 'add_circle' : 'block'"
|
||||
:color="props.row.excluded ? 'positive' : 'negative'"
|
||||
@click="props.row.excluded = !props.row.excluded">
|
||||
<q-tooltip>{{ props.row.excluded ? 'Inclure' : 'Exclure' }}</q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
</template>
|
||||
</q-table>
|
||||
|
||||
<q-stepper-navigation>
|
||||
<q-btn flat label="Retour" @click="step = 1" />
|
||||
<q-btn unelevated color="primary" label="Confirmer et envoyer" icon-right="send"
|
||||
class="q-ml-sm" @click="step = 3" :disable="recipients.length === 0" />
|
||||
</q-stepper-navigation>
|
||||
</q-step>
|
||||
|
||||
<!-- Step 3 — Confirm + send ──────────────────────────────────────── -->
|
||||
<q-step :name="3" title="Confirmation" icon="send">
|
||||
<q-card flat bordered>
|
||||
<q-card-section>
|
||||
<div class="text-subtitle1 q-mb-sm">Récapitulatif</div>
|
||||
<q-list dense>
|
||||
<q-item><q-item-section side>Nom</q-item-section><q-item-section>{{ params.name }}</q-item-section></q-item>
|
||||
<q-item><q-item-section side>Destinataires actifs</q-item-section><q-item-section>{{ recipients.length - excludedCount }} (sur {{ recipients.length }} totaux)</q-item-section></q-item>
|
||||
<q-item><q-item-section side>Montant affiché</q-item-section><q-item-section>{{ params.amount }}</q-item-section></q-item>
|
||||
<q-item><q-item-section side>Engagement</q-item-section><q-item-section>{{ params.commitment_months }} mois</q-item-section></q-item>
|
||||
<q-item><q-item-section side>Sujet</q-item-section><q-item-section>{{ params.subject }}</q-item-section></q-item>
|
||||
<q-item><q-item-section side>Expéditeur</q-item-section><q-item-section>{{ params.from }}</q-item-section></q-item>
|
||||
<q-item><q-item-section side>Throttle</q-item-section><q-item-section>{{ params.throttle_ms }} ms entre envois (≈ {{ Math.round((60 / (params.throttle_ms / 1000)) || 0) }} emails/min)</q-item-section></q-item>
|
||||
<q-item><q-item-section side>Durée estimée</q-item-section><q-item-section>≈ {{ estimatedMinutes }} min</q-item-section></q-item>
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
<q-card-section class="bg-orange-1 text-orange-9">
|
||||
<q-icon name="info" /> L'envoi démarre dès que vous cliquez ci-dessous.
|
||||
Vous serez redirigé vers la page de progression en temps réel.
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<q-stepper-navigation>
|
||||
<q-btn flat label="Retour" @click="step = 2" />
|
||||
<q-btn unelevated color="primary" label="Lancer l'envoi" icon-right="send"
|
||||
class="q-ml-sm" :loading="sending" @click="launchSend" />
|
||||
</q-stepper-navigation>
|
||||
</q-step>
|
||||
|
||||
</q-stepper>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useQuasar } from 'quasar'
|
||||
import { parseCsvs, createCampaign, sendCampaign } from 'src/api/campaigns'
|
||||
|
||||
const $q = useQuasar()
|
||||
const router = useRouter()
|
||||
const step = ref(1)
|
||||
|
||||
const mapFile = ref(null)
|
||||
const giftFile = ref(null)
|
||||
const mapPreview = ref('')
|
||||
const giftPreview = ref('')
|
||||
|
||||
const params = ref({
|
||||
name: `Campagne ${new Date().toISOString().slice(0,10)}`,
|
||||
amount: '60 $',
|
||||
commitment_months: 3,
|
||||
subject: '🎁 Un cadeau pour vous, de la part de TARGO',
|
||||
from: 'TARGO <support@targointernet.com>',
|
||||
expiry: '',
|
||||
throttle_ms: 600,
|
||||
multi: 'first',
|
||||
})
|
||||
const multiOptions = [
|
||||
{ label: '1er email seulement (1 cadeau/foyer)', value: 'first' },
|
||||
{ label: 'Séparer en 2 rangées (1 cadeau/personne)', value: 'split' },
|
||||
{ label: 'Ignorer les couples', value: 'skip' },
|
||||
]
|
||||
|
||||
const parsing = ref(false)
|
||||
const sending = ref(false)
|
||||
const recipients = ref([])
|
||||
const parseInfo = ref({ leftover_gifts: 0, leftover_contacts: 0 })
|
||||
|
||||
const recipientColumns = [
|
||||
{ name: 'firstname', label: 'Prénom', field: 'firstname', align: 'left' },
|
||||
{ name: 'lastname', label: 'Nom', field: 'lastname', align: 'left' },
|
||||
{ name: 'email', label: 'Email', field: 'email', align: 'left' },
|
||||
{ name: 'civic_address', label: 'Adresse', field: 'civic_address', align: 'left' },
|
||||
{ name: 'postal_code', label: 'Code postal', field: 'postal_code', align: 'left' },
|
||||
{ name: 'match', label: 'Match client', field: 'match_method', align: 'left' },
|
||||
{ name: 'customer_name', label: 'Client ERPNext', field: 'customer_name', align: 'left' },
|
||||
{ name: 'actions', label: '', field: '', align: 'right' },
|
||||
]
|
||||
|
||||
const matchedCount = computed(() => recipients.value.filter(r => r.customer_id).length)
|
||||
const unmatchedCount = computed(() => recipients.value.filter(r => !r.customer_id).length)
|
||||
const excludedCount = computed(() => recipients.value.filter(r => r.excluded).length)
|
||||
const estimatedMinutes = computed(() => {
|
||||
const n = recipients.value.length - excludedCount.value
|
||||
const per = (params.value.throttle_ms || 600) / 1000
|
||||
return Math.max(1, Math.round((n * per) / 60))
|
||||
})
|
||||
|
||||
function readFile (file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const r = new FileReader()
|
||||
r.onload = () => resolve(r.result)
|
||||
r.onerror = reject
|
||||
r.readAsText(file, 'utf-8')
|
||||
})
|
||||
}
|
||||
async function readMapFile (file) { if (file) mapPreview.value = await readFile(file) }
|
||||
async function readGiftFile (file) { if (file) giftPreview.value = await readFile(file) }
|
||||
|
||||
async function goPreview () {
|
||||
if (!mapPreview.value || !giftPreview.value) return
|
||||
parsing.value = true
|
||||
try {
|
||||
const r = await parseCsvs({ map_csv: mapPreview.value, giftbit_csv: giftPreview.value, multi: params.value.multi })
|
||||
recipients.value = r.recipients || []
|
||||
parseInfo.value = { leftover_gifts: r.leftover_gifts || 0, leftover_contacts: r.leftover_contacts || 0 }
|
||||
step.value = 2
|
||||
} catch (e) {
|
||||
$q.notify({ type: 'negative', message: 'Erreur de parsing: ' + e.message })
|
||||
} finally {
|
||||
parsing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function launchSend () {
|
||||
sending.value = true
|
||||
try {
|
||||
const saved = await createCampaign({
|
||||
name: params.value.name,
|
||||
params: { ...params.value },
|
||||
recipients: recipients.value,
|
||||
})
|
||||
await sendCampaign(saved.id)
|
||||
router.push(`/campaigns/${saved.id}`)
|
||||
} catch (e) {
|
||||
$q.notify({ type: 'negative', message: 'Erreur: ' + e.message })
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
88
apps/ops/src/modules/campaigns/pages/CampaignsListPage.vue
Normal file
88
apps/ops/src/modules/campaigns/pages/CampaignsListPage.vue
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
<template>
|
||||
<q-page padding>
|
||||
<div class="row items-center q-mb-md">
|
||||
<div class="text-h5">Campagnes</div>
|
||||
<q-space />
|
||||
<q-btn flat color="primary" icon="palette" label="Éditer le template" :to="'/campaigns/templates/gift-email-fr'" class="q-mr-sm" />
|
||||
<q-btn unelevated color="primary" icon="add" label="Nouvelle campagne" :to="'/campaigns/new'" />
|
||||
</div>
|
||||
|
||||
<q-card flat bordered v-if="!loading && campaigns.length === 0" class="q-pa-xl text-center text-grey-7">
|
||||
<q-icon name="card_giftcard" size="48px" class="q-mb-md" />
|
||||
<div class="text-h6">Aucune campagne pour le moment</div>
|
||||
<div class="q-mt-sm">
|
||||
Une campagne envoie des cartes-cadeaux Giftbit par courriel personnalisé.
|
||||
Importer 2 CSV (export Map + shortlinks Giftbit) et lancer l'envoi via Mailjet.
|
||||
</div>
|
||||
<q-btn class="q-mt-lg" color="primary" icon="add" label="Créer la première" :to="'/campaigns/new'" />
|
||||
</q-card>
|
||||
|
||||
<q-table
|
||||
v-else
|
||||
:rows="campaigns"
|
||||
:columns="columns"
|
||||
row-key="id"
|
||||
:loading="loading"
|
||||
flat bordered
|
||||
:pagination="{ rowsPerPage: 25, sortBy: 'created_at', descending: true }"
|
||||
>
|
||||
<template v-slot:body-cell-status="props">
|
||||
<q-td :props="props">
|
||||
<q-chip dense :color="statusColor(props.row.status)" text-color="white" :label="statusLabel(props.row.status)" />
|
||||
</q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-progress="props">
|
||||
<q-td :props="props">
|
||||
<div class="row items-center q-gutter-xs">
|
||||
<span class="text-grey-7">{{ (props.row.counters?.sent || 0) }} / {{ props.row.total || 0 }}</span>
|
||||
<q-linear-progress
|
||||
:value="(props.row.counters?.sent || 0) / Math.max(1, props.row.total || 1)"
|
||||
size="6px"
|
||||
:color="props.row.counters?.failed ? 'negative' : 'positive'"
|
||||
style="min-width:80px"
|
||||
/>
|
||||
<span v-if="props.row.counters?.failed" class="text-negative text-caption">
|
||||
{{ props.row.counters.failed }} échec(s)
|
||||
</span>
|
||||
</div>
|
||||
</q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-actions="props">
|
||||
<q-td :props="props" class="text-right">
|
||||
<q-btn flat dense color="primary" icon="visibility" :to="`/campaigns/${props.row.id}`" />
|
||||
</q-td>
|
||||
</template>
|
||||
</q-table>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { listCampaigns } from 'src/api/campaigns'
|
||||
|
||||
const campaigns = ref([])
|
||||
const loading = ref(true)
|
||||
|
||||
const columns = [
|
||||
{ name: 'name', label: 'Nom', field: 'name', align: 'left', sortable: true },
|
||||
{ name: 'created_at', label: 'Créée', field: r => new Date(r.created_at).toLocaleString('fr-CA', { dateStyle: 'medium', timeStyle: 'short' }), align: 'left', sortable: true },
|
||||
{ name: 'status', label: 'Statut', field: 'status', align: 'left' },
|
||||
{ name: 'progress', label: 'Envois', field: 'counters', align: 'left' },
|
||||
{ name: 'actions', label: '', field: '', align: 'right' },
|
||||
]
|
||||
|
||||
function statusColor (s) {
|
||||
return { draft: 'grey', sending: 'orange', completed: 'positive', failed: 'negative' }[s] || 'grey'
|
||||
}
|
||||
function statusLabel (s) {
|
||||
return { draft: 'Brouillon', sending: 'En cours', completed: 'Terminée', failed: 'Échec' }[s] || s
|
||||
}
|
||||
|
||||
async function load () {
|
||||
loading.value = true
|
||||
try { campaigns.value = await listCampaigns() }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
232
apps/ops/src/modules/campaigns/pages/TemplateEditorPage.vue
Normal file
232
apps/ops/src/modules/campaigns/pages/TemplateEditorPage.vue
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
<template>
|
||||
<q-page>
|
||||
<!-- Top bar -->
|
||||
<div class="row items-center q-px-md q-py-sm bg-white" style="border-bottom:1px solid #e5e7eb">
|
||||
<q-btn flat dense round icon="arrow_back" :to="'/campaigns'" class="q-mr-sm" />
|
||||
<div class="text-h6 q-mr-md">Éditeur de template</div>
|
||||
<q-select v-model="currentName" :options="templateOptions" emit-value map-options dense outlined style="min-width:240px" @update:model-value="loadTemplate" />
|
||||
<q-space />
|
||||
<q-btn-toggle v-model="viewMode" :options="[
|
||||
{ label: 'Visuel', value: 'visual' },
|
||||
{ label: 'HTML', value: 'html' },
|
||||
{ label: 'Aperçu', value: 'preview' },
|
||||
]" dense unelevated toggle-color="primary" />
|
||||
<q-btn flat icon="undo" label="Annuler" class="q-ml-sm" :disable="!dirty" @click="discardChanges" />
|
||||
<q-btn unelevated color="primary" icon="save" label="Enregistrer" class="q-ml-sm"
|
||||
:loading="saving" :disable="!dirty" @click="save" />
|
||||
</div>
|
||||
|
||||
<!-- Editor surface (one of three modes) -->
|
||||
<div v-show="viewMode === 'visual'" ref="grapesContainer" style="height:calc(100vh - 110px)"></div>
|
||||
|
||||
<div v-show="viewMode === 'html'" class="q-pa-md" style="height:calc(100vh - 110px)">
|
||||
<q-banner class="bg-blue-1 text-blue-9 q-mb-sm" rounded>
|
||||
<template v-slot:avatar><q-icon name="info" /></template>
|
||||
Édition HTML brute. Les changements sauvegardés ici écrasent ceux de l'éditeur visuel.
|
||||
Variables disponibles : <code>{{ '{{firstname}}' }}</code>, <code>{{ '{{amount}}' }}</code>,
|
||||
<code>{{ '{{gift_url}}' }}</code>, <code>{{ '{{description}}' }}</code>,
|
||||
<code>{{ '{{expiry}}' }}</code>, <code>{{ '{{commitment_months}}' }}</code>.
|
||||
Blocs conditionnels : <code>{{ '{{#expiry}}...{{/expiry}}' }}</code>.
|
||||
</q-banner>
|
||||
<q-input v-model="html" type="textarea" outlined dense filled
|
||||
input-style="font-family:monospace; font-size:0.85rem; line-height:1.4; min-height:calc(100vh - 220px)"
|
||||
@update:model-value="dirty = true" />
|
||||
</div>
|
||||
|
||||
<div v-show="viewMode === 'preview'" style="height:calc(100vh - 110px); overflow:auto; background:#f7f8f7;">
|
||||
<iframe :srcdoc="previewHtml" frameborder="0" style="width:100%; height:100%; display:block;"></iframe>
|
||||
</div>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useQuasar } from 'quasar'
|
||||
import { listTemplates, getTemplate, saveTemplate, previewTemplate } from 'src/api/campaigns'
|
||||
import grapesjs from 'grapesjs'
|
||||
import 'grapesjs/dist/css/grapes.min.css'
|
||||
import presetNewsletter from 'grapesjs-preset-newsletter'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const $q = useQuasar()
|
||||
|
||||
const grapesContainer = ref(null)
|
||||
const templates = ref([])
|
||||
const currentName = ref(route.params.name || 'gift-email-fr')
|
||||
const html = ref('')
|
||||
const previewHtml = ref('')
|
||||
const dirty = ref(false)
|
||||
const saving = ref(false)
|
||||
const viewMode = ref('visual') // 'visual' | 'html' | 'preview'
|
||||
|
||||
let editor = null
|
||||
let originalHtml = ''
|
||||
|
||||
const templateOptions = ref([])
|
||||
|
||||
async function loadAvailableTemplates () {
|
||||
templates.value = await listTemplates()
|
||||
templateOptions.value = templates.value.map(t => ({
|
||||
label: `${t.name}.html (${Math.round(t.size/1024)} KB)`,
|
||||
value: t.name,
|
||||
}))
|
||||
}
|
||||
|
||||
async function loadTemplate (name) {
|
||||
const data = await getTemplate(name || currentName.value)
|
||||
html.value = data.html
|
||||
originalHtml = data.html
|
||||
dirty.value = false
|
||||
// Push the loaded HTML into the GrapesJS canvas
|
||||
if (editor) {
|
||||
editor.setComponents(data.html)
|
||||
// Once components are loaded we'll need the editor to NOT mark as dirty
|
||||
// (setComponents triggers a `component:update` event by design).
|
||||
setTimeout(() => { dirty.value = false }, 100)
|
||||
}
|
||||
router.replace({ path: `/campaigns/templates/${name || currentName.value}` })
|
||||
}
|
||||
|
||||
function initGrapes () {
|
||||
editor = grapesjs.init({
|
||||
container: grapesContainer.value,
|
||||
height: '100%',
|
||||
width: 'auto',
|
||||
storageManager: false, // we manage save ourselves via REST
|
||||
fromElement: false,
|
||||
plugins: [presetNewsletter],
|
||||
pluginsOpts: {
|
||||
[presetNewsletter]: {
|
||||
modalLabelImport: 'Coller votre HTML ici',
|
||||
modalLabelExport: 'Copier le HTML',
|
||||
codeViewerTheme: 'hopscotch',
|
||||
importPlaceholder: '<table>...</table>',
|
||||
cellStyle: { 'font-size': '12px', 'font-weight': 300, 'vertical-align': 'top' },
|
||||
},
|
||||
},
|
||||
// Custom blocks for merge-variable insertion
|
||||
blockManager: {
|
||||
blocks: [
|
||||
{
|
||||
id: 'var-firstname',
|
||||
label: '{{firstname}}',
|
||||
category: 'Variables',
|
||||
content: '<span>{{firstname}}</span>',
|
||||
attributes: { class: 'fa fa-user' },
|
||||
},
|
||||
{
|
||||
id: 'var-amount',
|
||||
label: '{{amount}}',
|
||||
category: 'Variables',
|
||||
content: '<strong>{{amount}}</strong>',
|
||||
},
|
||||
{
|
||||
id: 'var-gift-url',
|
||||
label: '{{gift_url}}',
|
||||
category: 'Variables',
|
||||
content: '<a href="{{gift_url}}" style="color:#019547; font-weight:700;">Lien cadeau</a>',
|
||||
},
|
||||
{
|
||||
id: 'var-description',
|
||||
label: '{{description}}',
|
||||
category: 'Variables',
|
||||
content: '<span>{{description}}</span>',
|
||||
},
|
||||
{
|
||||
id: 'var-expiry',
|
||||
label: '{{expiry}}',
|
||||
category: 'Variables',
|
||||
content: '<span>{{expiry}}</span>',
|
||||
},
|
||||
{
|
||||
id: 'var-commitment',
|
||||
label: '{{commitment_months}}',
|
||||
category: 'Variables',
|
||||
content: '<span>{{commitment_months}}</span>',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
// Track changes
|
||||
editor.on('component:add component:remove component:update', () => {
|
||||
if (!editor.getHtml) return
|
||||
const next = editor.getHtml({ component: editor.getWrapper() }) + '<style>' + editor.getCss() + '</style>'
|
||||
if (next !== originalHtml) dirty.value = true
|
||||
})
|
||||
|
||||
// Sync HTML on every change so the HTML tab stays accurate
|
||||
editor.on('update', () => {
|
||||
try {
|
||||
html.value = editor.getHtml() + (editor.getCss() ? '<style>' + editor.getCss() + '</style>' : '')
|
||||
} catch (e) { /* during init the editor may not have a canvas ready */ }
|
||||
})
|
||||
}
|
||||
|
||||
// When viewMode changes to 'preview', call the hub's preview endpoint with
|
||||
// sample data so {{vars}} get substituted live.
|
||||
watch(viewMode, async (mode) => {
|
||||
if (mode === 'preview') {
|
||||
// If user has been editing in HTML mode, use their current html.value;
|
||||
// otherwise pull from the grapes canvas.
|
||||
const sourceHtml = viewMode.value === 'html'
|
||||
? html.value
|
||||
: (editor ? editor.getHtml() + (editor.getCss() ? '<style>' + editor.getCss() + '</style>' : '') : html.value)
|
||||
try {
|
||||
const r = await previewTemplate(currentName.value, { html: sourceHtml })
|
||||
previewHtml.value = r.rendered
|
||||
} catch (e) {
|
||||
$q.notify({ type: 'negative', message: 'Erreur prévisualisation: ' + e.message })
|
||||
}
|
||||
}
|
||||
// When switching from HTML mode back to Visual, sync grapes canvas from textarea
|
||||
if (mode === 'visual' && editor && html.value && html.value !== editor.getHtml()) {
|
||||
editor.setComponents(html.value)
|
||||
}
|
||||
})
|
||||
|
||||
async function save () {
|
||||
saving.value = true
|
||||
try {
|
||||
// Determine final HTML to save based on current mode
|
||||
const finalHtml = viewMode.value === 'html'
|
||||
? html.value
|
||||
: (editor.getHtml() + (editor.getCss() ? '<style>' + editor.getCss() + '</style>' : ''))
|
||||
await saveTemplate(currentName.value, finalHtml)
|
||||
originalHtml = finalHtml
|
||||
html.value = finalHtml
|
||||
dirty.value = false
|
||||
$q.notify({ type: 'positive', message: 'Template enregistré ✓' })
|
||||
} catch (e) {
|
||||
$q.notify({ type: 'negative', message: 'Erreur: ' + e.message })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function discardChanges () {
|
||||
html.value = originalHtml
|
||||
if (editor) editor.setComponents(originalHtml)
|
||||
dirty.value = false
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadAvailableTemplates()
|
||||
await nextTick()
|
||||
initGrapes()
|
||||
await loadTemplate(currentName.value)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (editor) try { editor.destroy() } catch {}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* GrapesJS UI is darkish by default — tone down to match Quasar light theme */
|
||||
.gjs-one-bg { background: #f3f4f3 !important; }
|
||||
.gjs-three-bg { background: #019547 !important; }
|
||||
.gjs-four-color, .gjs-four-color-h:hover { color: #019547 !important; }
|
||||
</style>
|
||||
|
|
@ -38,6 +38,13 @@ const routes = [
|
|||
{ path: 'dispatch', component: () => import('src/pages/DispatchPage.vue') },
|
||||
{ path: 'agent-flows', component: () => import('src/pages/AgentFlowsPage.vue') },
|
||||
{ path: 'network', component: () => import('src/pages/NetworkPage.vue') },
|
||||
// Gift campaigns — list, new wizard (CSV upload + matching), per-campaign detail with live SSE updates
|
||||
{ path: 'campaigns', component: () => import('src/modules/campaigns/pages/CampaignsListPage.vue') },
|
||||
{ path: 'campaigns/new', component: () => import('src/modules/campaigns/pages/CampaignNewPage.vue') },
|
||||
// Template editor route must be ABOVE /campaigns/:id otherwise the
|
||||
// ':id' wildcard captures 'templates/...' and shows the detail page.
|
||||
{ path: 'campaigns/templates/:name?', component: () => import('src/modules/campaigns/pages/TemplateEditorPage.vue'), props: true },
|
||||
{ path: 'campaigns/:id', component: () => import('src/modules/campaigns/pages/CampaignDetailPage.vue'), props: true },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user