gigafibre-fsm/apps/ops/src/router/index.js
louispaulb d529019106 feat(campaigns): gifts inventory page + expiry presets
Wizard: gift_expiry_days now lives behind a preset toggle
(15/30/60/90/180 + Custom) instead of a naked number input. Operator
clicks a chip; the value flows back into the existing campaign param.

Inventory page (/campaigns/gifts):
- Cross-campaign view of every wrapper token with status taxonomy
  (active / redeemed / expired / revoked / pending). Each card on
  the counters strip is a click-to-filter shortcut.
- "Réassignables" highlighted in amber when > 0 — these are gifts
  whose wrapper expired or was revoked but the Giftbit URL is still
  unredeemed, ready for a fresh recipient.
- Search across name/email/url/token; per-status and per-campaign
  filter dropdowns.
- One-click copy on the Giftbit URL with a tailored toast that walks
  the operator through the reassignment workflow (paste into manual-
  add dialog of a new campaign).
- Revoke action with confirmation; explicit about what survives
  (the Giftbit URL stays valid on their side) vs what changes (our
  wrapper stops redirecting).

Backend:
- GET /campaigns/gifts flattens every recipient with a gift across
  every campaign — single-shot, no pagination yet (we're under 10k
  gifts total).
- POST /campaigns/:id/recipients/:row/revoke sets gift_revoked=true
  and broadcasts the recipient-update SSE event.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 10:21:05 -04:00

60 lines
3.9 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 + gifts inventory routes must be ABOVE /campaigns/:id
// otherwise the ':id' wildcard captures the literal paths and shows
// the detail page instead.
{ path: 'campaigns/templates/:name?', component: () => import('src/modules/campaigns/pages/TemplateEditorPage.vue'), props: true },
{ path: 'campaigns/gifts', component: () => import('src/modules/campaigns/pages/GiftsInventoryPage.vue') },
{ 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,
})
})