gigafibre-fsm/apps/ops/src/router/index.js
louispaulb 611f4ed5a6 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>
2026-05-21 19:08:04 -04:00

58 lines
3.8 KiB
JavaScript

import { route } from 'quasar/wrappers'
import { createRouter, createWebHashHistory } from 'vue-router'
const routes = [
// Tech mobile view (sent via SMS to field techs)
{
path: '/j',
component: () => import('src/layouts/TechLayout.vue'),
children: [
{ path: '', name: 'tech-tasks', component: () => import('src/modules/tech/pages/TechTasksPage.vue') },
{ path: 'job/:name', name: 'tech-job', component: () => import('src/modules/tech/pages/TechJobDetailPage.vue'), props: true },
{ path: 'scan', name: 'tech-scan', component: () => import('src/modules/tech/pages/TechScanPage.vue') },
{ path: 'device/:serial', name: 'tech-device', component: () => import('src/modules/tech/pages/TechDevicePage.vue'), props: true },
{ path: 'diagnostic', name: 'tech-diag', component: () => import('src/modules/tech/pages/TechDiagnosticPage.vue') },
{ path: 'more', name: 'tech-more', component: () => import('src/modules/tech/pages/TechMorePage.vue') },
// Magic link: /j/{jwt-token} — must be LAST to not capture static paths above
{ path: ':token', name: 'tech-magic', component: () => import('src/modules/tech/pages/TechTasksPage.vue'), props: true },
],
},
// Ops staff desktop view
{
path: '/',
component: () => import('src/layouts/MainLayout.vue'),
children: [
{ path: '', component: () => import('src/pages/DashboardPage.vue') },
{ path: 'clients', component: () => import('src/pages/ClientsPage.vue') },
{ path: 'clients/:id', component: () => import('src/pages/ClientDetailPage.vue'), props: true },
{ path: 'tickets', component: () => import('src/pages/TicketsPage.vue') },
{ path: 'equipe', component: () => import('src/pages/EquipePage.vue') },
{ path: 'rapports', component: () => import('src/pages/RapportsPage.vue') },
{ path: 'rapports/revenus', component: () => import('src/pages/ReportRevenuPage.vue') },
{ path: 'rapports/ventes', component: () => import('src/pages/ReportVentesPage.vue') },
{ path: 'rapports/taxes', component: () => import('src/pages/ReportTaxesPage.vue') },
{ path: 'rapports/ar', component: () => import('src/pages/ReportARPage.vue') },
{ path: 'ocr', component: () => import('src/pages/OcrPage.vue') },
{ path: 'settings', component: () => import('src/pages/SettingsPage.vue') },
{ path: 'telephony', component: () => import('src/pages/TelephonyPage.vue') },
{ 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 },
],
},
]
export default route(function () {
return createRouter({
history: createWebHashHistory(process.env.VUE_ROUTER_BASE),
routes,
})
})