A (garde-fou immédiat, sans écriture) : resolveAudience exclut les clients dont le compte F est résilié (fResiliatedAccountIds, caché 30 min) — évite d'inviter d'ex-clients tant que la donnée n'est pas nettoyée (cf feedback_ghost_active_subscriptions). L'aperçu affiche « N ex-client(s) résilié(s) exclus ». Vérifié prod : « clients actifs » 7943→5943 (2663 exclus) ; « abonnement actif ≥40$ » 3102→2213 (1125 ex-clients exclus). 33/33 tests. Déployé hub. Ne corrige PAS la donnée sous-jacente (abos 'Actif' fantômes + MRR gonflé) ni la logique de sync — voir tâche #25 (dry-run prêt : 3873 abos / 2663 clients / ~150K$ MRR fantôme). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
344 lines
20 KiB
Vue
344 lines
20 KiB
Vue
<template>
|
|
<q-page class="q-pa-md">
|
|
<PageHeader title="Événements" />
|
|
|
|
<!-- Bandeau événement -->
|
|
<div class="ops-card q-pa-md q-mb-md" style="border-radius:12px">
|
|
<div class="row items-center ops-row-wrap q-gutter-sm">
|
|
<div class="ev-badge"><b>20</b><span>ANS</span></div>
|
|
<div class="col" style="min-width:0">
|
|
<div class="text-h6 text-weight-bold ellipsis">{{ ev.name || 'Targo fête ses 20 ans' }}</div>
|
|
<div class="text-caption text-grey-7">
|
|
<q-icon name="event" size="16px" class="q-mr-xs" />{{ ev.when || '1ᵉʳ août, de 10 h à 15 h' }}
|
|
</div>
|
|
</div>
|
|
<q-btn flat dense icon="refresh" :loading="loading" @click="load" class="col-auto"><q-tooltip>Rafraîchir</q-tooltip></q-btn>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- KPIs -->
|
|
<div class="row q-col-gutter-sm q-mb-md">
|
|
<div class="col-6 col-sm-4"><StatCard label="Inscriptions" :value="count" icon="how_to_reg" color="primary" hint="Réponses reçues" /></div>
|
|
<div class="col-6 col-sm-4"><StatCard label="Personnes attendues" :value="headcount" icon="groups" color="green-7" hint="Total pour le food truck" /></div>
|
|
<div class="col-6 col-sm-4"><StatCard label="À vérifier" :value="unmatched" icon="help_outline" :color="unmatched ? 'orange-8' : 'grey-5'" :value-class="unmatched ? 'text-orange-8' : ''" hint="N° client non reconnu" /></div>
|
|
</div>
|
|
|
|
<div class="row q-col-gutter-md">
|
|
<!-- Colonne gauche : inviter -->
|
|
<div class="col-12 col-md-4">
|
|
<div class="ops-card q-pa-md q-mb-md" style="border-radius:12px">
|
|
<div class="text-subtitle2 text-weight-bold q-mb-sm"><q-icon name="link" class="q-mr-xs" />Lien public d'inscription</div>
|
|
<div class="text-caption text-grey-7 q-mb-sm">À mettre sur l'affiche, les réseaux sociaux ou un code QR. Le client saisit son numéro.</div>
|
|
<q-input dense outlined readonly :model-value="publicUrl" class="q-mb-sm">
|
|
<template #append><q-btn flat dense round icon="content_copy" @click="copy(publicUrl)"><q-tooltip>Copier</q-tooltip></q-btn></template>
|
|
</q-input>
|
|
<q-btn flat dense no-caps color="primary" icon="open_in_new" label="Ouvrir la page" :href="publicUrl" target="_blank" :disable="!publicUrl" />
|
|
</div>
|
|
|
|
<div class="ops-card q-pa-md" style="border-radius:12px">
|
|
<div class="text-subtitle2 text-weight-bold q-mb-sm"><q-icon name="mail" class="q-mr-xs" />Invitation par courriel</div>
|
|
<div class="text-caption text-grey-7 q-mb-sm">
|
|
Chaque client reçoit un lien <b>personnalisé</b> : son numéro est pré-rempli et son inscription est rattachée automatiquement.
|
|
</div>
|
|
<q-btn unelevated no-caps color="primary" icon="send" label="Préparer l'envoi" @click="prepSend" />
|
|
<div class="text-caption text-grey-5 q-mt-sm">Aucun courriel n'est envoyé sans confirmation.</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Colonne droite : liste des inscrits -->
|
|
<div class="col-12 col-md-8">
|
|
<div class="ops-card q-pa-md" style="border-radius:12px">
|
|
<div class="row items-center ops-row-wrap q-mb-sm">
|
|
<div class="text-subtitle2 text-weight-bold">Inscriptions</div>
|
|
<q-space />
|
|
<q-btn flat dense no-caps icon="download" label="CSV" :disable="!filtered.length" @click="exportRows" />
|
|
</div>
|
|
<DynamicFilter v-model="filterState" dense search-placeholder="Nom, n° client, courriel…"
|
|
:chip-groups="chipGroups" :filtered-count="filtered.length" class="q-mb-sm" />
|
|
|
|
<q-banner v-if="error" dense rounded class="bg-orange-1 text-orange-9 q-mb-sm">
|
|
<template #avatar><q-icon name="cloud_off" /></template>
|
|
{{ error }}
|
|
</q-banner>
|
|
|
|
<DataTable
|
|
:rows="filtered" :columns="columns" row-key="key"
|
|
flat bordered dense :loading="loading"
|
|
:pagination="{ rowsPerPage: 50, sortBy: 'updated', descending: true }"
|
|
no-data-label="Aucune inscription pour l'instant.">
|
|
<template #body-cell-client="props">
|
|
<q-td :props="props">
|
|
<div class="text-weight-medium ellipsis">{{ props.row.customer_name || props.row.customer_number || '—' }}</div>
|
|
<div class="text-caption text-grey-6">{{ props.row.customer_number }}</div>
|
|
</q-td>
|
|
</template>
|
|
<template #body-cell-party="props">
|
|
<q-td :props="props" class="text-center"><q-chip dense square color="green-1" text-color="green-9" class="text-weight-bold">{{ props.value }}</q-chip></q-td>
|
|
</template>
|
|
<template #body-cell-email="props">
|
|
<q-td :props="props">
|
|
<div>{{ props.value || '—' }}</div>
|
|
<div v-if="props.row.phone" class="text-caption text-grey-7"><q-icon name="call" size="12px" class="q-mr-xs" />{{ props.row.phone }}</div>
|
|
</q-td>
|
|
</template>
|
|
<template #body-cell-allergies="props">
|
|
<q-td :props="props">
|
|
<span v-if="props.value" class="text-orange-9">⚠ {{ props.value }}</span>
|
|
<span v-else class="text-grey-5">—</span>
|
|
</q-td>
|
|
</template>
|
|
<template #body-cell-confidence="props">
|
|
<q-td :props="props" class="text-center">
|
|
<q-badge v-if="props.value === 'confirmed'" color="green-7" label="Confirmé"><q-tooltip>≥2 infos concordent : {{ (props.row.signals || []).join(', ') }}</q-tooltip></q-badge>
|
|
<q-badge v-else-if="props.value === 'probable'" color="blue-6" label="Probable"><q-tooltip>1 info concorde : {{ (props.row.signals || []).join(', ') }} — à confirmer</q-tooltip></q-badge>
|
|
<q-badge v-else color="orange-7" label="À vérifier"><q-tooltip>Aucune concordance — valider manuellement</q-tooltip></q-badge>
|
|
<div v-if="props.row.account_name && props.row.account_name !== props.row.customer_name" class="text-caption text-grey-6 q-mt-xs">dossier : {{ props.row.account_name }}</div>
|
|
</q-td>
|
|
</template>
|
|
<template #body-cell-updated="props">
|
|
<q-td :props="props" class="text-caption">{{ fmtDate(props.value) }}</q-td>
|
|
</template>
|
|
<template #body-cell-actions="props">
|
|
<q-td :props="props" class="text-center">
|
|
<q-btn flat dense round size="sm" icon="delete" color="negative" @click="confirmDelete(props.row)"><q-tooltip>Retirer</q-tooltip></q-btn>
|
|
</q-td>
|
|
</template>
|
|
</DataTable>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<!-- Dialogue d'envoi : test OU audience de masse (aperçu ; le blast reste à activer) -->
|
|
<q-dialog v-model="showSend">
|
|
<q-card style="min-width:340px;max-width:560px;width:100%">
|
|
<q-card-section class="row items-center q-pb-none">
|
|
<div class="text-h6">Envoyer une invitation</div><q-space />
|
|
<q-btn flat round dense icon="close" v-close-popup />
|
|
</q-card-section>
|
|
|
|
<q-card-section class="q-pb-none">
|
|
<q-btn-toggle v-model="sendMode" spread no-caps unelevated toggle-color="primary" color="grey-3" text-color="grey-8"
|
|
:options="[{ label: 'Test à moi', value: 'test' }, { label: 'Envoi de masse', value: 'mass' }]" />
|
|
</q-card-section>
|
|
|
|
<!-- TEST -->
|
|
<q-card-section v-if="sendMode === 'test'" class="q-gutter-sm">
|
|
<div class="text-caption text-grey-7">Envoi d'un test (courriel avec lien personnalisé) à ton adresse.</div>
|
|
<q-btn-toggle v-model="sendChannel" spread no-caps dense :options="[{ label: 'Mailjet (suivi)', value: 'mailjet' }, { label: 'Gmail direct', value: 'gmail' }]" />
|
|
<q-input v-model="testEmail" dense outlined type="email" label="Courriel de test" autofocus />
|
|
</q-card-section>
|
|
|
|
<!-- MASSE -->
|
|
<q-card-section v-else class="q-gutter-sm">
|
|
<q-select v-model="template" :options="templates" dense outlined emit-value map-options label="Modèle de courriel" />
|
|
<q-btn-toggle v-model="sendChannel" spread no-caps dense :options="[{ label: 'Mailjet (suivi)', value: 'mailjet' }, { label: 'Gmail direct', value: 'gmail' }]" />
|
|
<div class="text-caption text-grey-7"><q-icon name="info" size="14px" /> Mailjet = suivi ouvertures/clics. Gmail direct = aucun suivi.</div>
|
|
|
|
<div class="text-weight-medium text-grey-8 q-mt-sm">Audience</div>
|
|
<q-btn-toggle v-model="audienceSource" spread no-caps dense @update:model-value="audPreview = null"
|
|
:options="[{ label: 'Liste clients', value: 'list' }, { label: 'Import CSV', value: 'csv' }]" />
|
|
|
|
<div v-if="audienceSource === 'list'" class="q-gutter-sm">
|
|
<div class="row q-col-gutter-sm">
|
|
<q-select class="col-12 col-sm-6" v-model="audFilters.statut" :options="[{ label: 'Clients actifs', value: 'active' }, { label: 'Tous', value: '' }]" dense outlined emit-value map-options label="Statut" @update:model-value="audPreview = null" />
|
|
<q-select class="col-12 col-sm-6" v-model="audFilters.customer_type" :options="[{ label: 'Tous types', value: '' }, { label: 'Résidentiel', value: 'Individual' }, { label: 'Commercial', value: 'Company' }]" dense outlined emit-value map-options label="Type de client" @update:model-value="audPreview = null" />
|
|
</div>
|
|
<q-toggle v-model="audFilters.active_sub" label="Abonnement actif seulement" @update:model-value="onActiveSubToggle" />
|
|
<q-input v-model.number="audFilters.min_monthly" type="number" min="0" dense outlined :disable="!audFilters.active_sub" label="Total mensuel minimum ($/mois)" @update:model-value="audPreview = null">
|
|
<template #prepend><q-icon name="attach_money" /></template>
|
|
</q-input>
|
|
<div class="text-caption text-grey-6">Le « total mensuel » (somme des abonnements actifs) requiert d'activer « Abonnement actif ». « Résidentiel/Commercial » = type de compte.</div>
|
|
</div>
|
|
|
|
<div v-else class="q-gutter-sm">
|
|
<q-file v-model="csvFile" dense outlined label="Fichier CSV" accept=".csv,text/csv" @update:model-value="onCsvFile">
|
|
<template #prepend><q-icon name="upload_file" /></template>
|
|
</q-file>
|
|
<div class="text-caption text-grey-6">Colonne obligatoire : <b>email</b>. Optionnelles : firstname, lastname, language (fr/en), customer_id, phone.</div>
|
|
</div>
|
|
|
|
<q-btn outline no-caps color="primary" icon="groups" label="Aperçu de l'audience" :loading="previewing" @click="doPreview" />
|
|
|
|
<q-banner v-if="audPreview" dense rounded class="bg-blue-1 text-blue-10">
|
|
<b>{{ audPreview.count }}</b> destinataire(s)<span v-if="audPreview.dropped_no_email"> · {{ audPreview.dropped_no_email }} sans courriel écarté(s)</span><span v-if="audPreview.resiliated_excluded" class="text-orange-9"> · {{ audPreview.resiliated_excluded }} ex-client(s) résilié(s) exclus</span>
|
|
<div v-if="audPreview.sample && audPreview.sample.length" class="text-caption q-mt-xs" style="opacity:.85">
|
|
ex. {{ audPreview.sample.slice(0, 4).map(s => s.firstname || s.email).join(', ') }}…
|
|
</div>
|
|
<div v-if="audPreview.no_email && audPreview.no_email.length" class="q-mt-xs">
|
|
<a class="cursor-pointer text-weight-medium text-blue-10" @click="showNoEmail = !showNoEmail">
|
|
{{ showNoEmail ? '▾ Masquer' : '▸ Voir' }} les {{ audPreview.dropped_no_email }} comptes sans courriel (à valider)
|
|
</a>
|
|
<div v-if="showNoEmail" class="q-mt-xs q-pa-xs bg-white rounded-borders" style="max-height:170px;overflow:auto">
|
|
<div v-for="ne in audPreview.no_email" :key="ne.customer_id" class="text-caption">
|
|
<a :href="'#/clients/' + ne.customer_id" target="_blank" class="text-primary">{{ ne.name }}</a>
|
|
<span class="text-grey-6"> · {{ ne.customer_id }}</span>
|
|
</div>
|
|
<div v-if="audPreview.dropped_no_email > audPreview.no_email.length" class="text-caption text-grey-6 q-mt-xs">…et {{ audPreview.dropped_no_email - audPreview.no_email.length }} autres (liste plafonnée à 300)</div>
|
|
</div>
|
|
</div>
|
|
</q-banner>
|
|
|
|
<q-banner dense rounded class="bg-grey-2 text-grey-8">
|
|
<template #avatar><q-icon name="lock" color="grey-7" /></template>
|
|
L'envoi réel (création de la campagne + envoi suivi) est la dernière étape à activer — rien n'est envoyé aux clients ici.
|
|
</q-banner>
|
|
</q-card-section>
|
|
|
|
<q-card-actions align="right">
|
|
<q-btn flat no-caps label="Fermer" v-close-popup />
|
|
<q-btn v-if="sendMode === 'test'" unelevated no-caps color="primary" icon="send" label="Envoyer un test" :loading="sending" @click="sendTestNow" />
|
|
</q-card-actions>
|
|
</q-card>
|
|
</q-dialog>
|
|
</q-page>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { useQuasar } from 'quasar'
|
|
import PageHeader from 'src/components/shared/PageHeader.vue'
|
|
import StatCard from 'src/components/shared/StatCard.vue'
|
|
import DataTable from 'src/components/shared/DataTable.vue'
|
|
import DynamicFilter from 'src/components/shared/DynamicFilter.vue'
|
|
import { formatDateTimeShort as fmtDate } from 'src/composables/useFormatters'
|
|
import { useCsvExport } from 'src/composables/useCsvExport'
|
|
import { usePermissions } from 'src/composables/usePermissions'
|
|
import { listRsvps, deleteRsvp, sendInvite, previewAudience, listTemplates } from 'src/api/events'
|
|
|
|
const $q = useQuasar()
|
|
const { exportCsv } = useCsvExport()
|
|
const EVENT_ID = 'fete-20-ans'
|
|
|
|
const loading = ref(false)
|
|
const error = ref('')
|
|
const ev = ref({})
|
|
const rows = ref([])
|
|
const count = ref(0)
|
|
const headcount = ref(0)
|
|
const matched = ref(0)
|
|
const unmatched = ref(0)
|
|
|
|
const { userEmail } = usePermissions()
|
|
const showSend = ref(false)
|
|
const sendChannel = ref('mailjet')
|
|
const testEmail = ref('')
|
|
const sending = ref(false)
|
|
// Envoi de masse (audience + modèle) — aperçu seulement ; le blast reste à activer.
|
|
const sendMode = ref('test')
|
|
const template = ref('')
|
|
const templates = ref([{ value: '', label: 'Invitation Fête 20 ans (TARGO)' }])
|
|
const audienceSource = ref('list')
|
|
const audFilters = ref({ statut: 'active', customer_type: '', active_sub: false, min_monthly: 0 })
|
|
const csvFile = ref(null)
|
|
const audCsvText = ref('')
|
|
const audPreview = ref(null)
|
|
const previewing = ref(false)
|
|
const showNoEmail = ref(false)
|
|
|
|
const publicUrl = computed(() => ev.value.public_url || '')
|
|
|
|
const filterState = ref({ search: '', chips: {} })
|
|
const chipGroups = [{ key: 'statut', label: 'Compte', icon: 'label', options: [{ value: 'confirmed', label: 'Confirmés', color: 'green-7' }, { value: 'probable', label: 'Probables', color: 'blue-6' }, { value: 'unverified', label: 'À vérifier', color: 'orange-7' }] }]
|
|
|
|
const columns = [
|
|
{ name: 'client', label: 'Client', field: 'customer_name', align: 'left', sortable: true },
|
|
{ name: 'party', label: 'Personnes', field: 'party_size', align: 'center', sortable: true },
|
|
{ name: 'email', label: 'Courriel', field: 'email', align: 'left' },
|
|
{ name: 'allergies', label: 'Allergies', field: 'allergies', align: 'left' },
|
|
{ name: 'confidence', label: 'Compte', field: 'confidence', align: 'center', sortable: true },
|
|
{ name: 'updated', label: 'Répondu le', field: 'updated', align: 'left', sortable: true },
|
|
{ name: 'actions', label: '', field: 'key', align: 'center' },
|
|
]
|
|
|
|
const filtered = computed(() => {
|
|
const st = filterState.value.chips.statut
|
|
const q = (filterState.value.search || '').toLowerCase().trim()
|
|
return rows.value.filter(r => {
|
|
const conf = r.confidence || 'none'
|
|
if (st === 'confirmed' && conf !== 'confirmed') return false
|
|
if (st === 'probable' && conf !== 'probable') return false
|
|
if (st === 'unverified' && conf !== 'none') return false
|
|
if (q && !((r.customer_name || '') + ' ' + (r.customer_number || '') + ' ' + (r.email || '') + ' ' + (r.phone || '')).toLowerCase().includes(q)) return false
|
|
return true
|
|
})
|
|
})
|
|
|
|
async function load () {
|
|
loading.value = true; error.value = ''
|
|
try {
|
|
const d = await listRsvps(EVENT_ID)
|
|
ev.value = d.event || {}
|
|
rows.value = d.responses || []
|
|
count.value = d.count || 0
|
|
headcount.value = d.headcount || 0
|
|
matched.value = d.matched || 0
|
|
unmatched.value = d.unmatched || 0
|
|
} catch (e) {
|
|
error.value = 'Impossible de charger les inscriptions (' + e.message + '). La fonctionnalité doit être déployée sur le hub.'
|
|
} finally { loading.value = false }
|
|
}
|
|
|
|
function copy (txt) {
|
|
if (!txt) return
|
|
navigator.clipboard?.writeText(txt).then(() => $q.notify({ type: 'positive', message: 'Lien copié', timeout: 1200 }))
|
|
.catch(() => $q.notify({ type: 'warning', message: txt }))
|
|
}
|
|
|
|
function exportRows () {
|
|
const conf = { confirmed: 'Confirmé', probable: 'Probable' }
|
|
const headers = ['Client', 'N° client', 'ID compte', 'Nom au dossier', 'Téléphone', 'Personnes', 'Courriel', 'Allergies', 'Confiance', 'Signaux', 'Langue', 'IP', 'Créé le', 'Répondu le']
|
|
const data = filtered.value.map(r => [r.customer_name || '', r.customer_number || '', r.customer_id || '', r.account_name || '', r.phone || '', r.party_size || 0, r.email || '', r.allergies || '', conf[r.confidence] || 'À vérifier', (r.signals || []).join(' + '), r.lang || '', r.ip || '', fmtDate(r.created), fmtDate(r.updated)])
|
|
const totalRow = ['TOTAL', '', '', '', '', filtered.value.reduce((s, r) => s + (r.party_size || 0), 0), '', '', '', '', '', '', '', '']
|
|
exportCsv(`inscriptions_${EVENT_ID}`, headers, data, { totalRow })
|
|
}
|
|
|
|
function confirmDelete (row) {
|
|
$q.dialog({ title: 'Retirer l\'inscription', message: `Retirer ${row.customer_name || row.customer_number || 'cette inscription'} (${row.party_size} pers.) ?`, cancel: true, persistent: true })
|
|
.onOk(async () => {
|
|
try { await deleteRsvp(EVENT_ID, row.key); $q.notify({ type: 'positive', message: 'Retiré' }); load() }
|
|
catch (e) { $q.notify({ type: 'negative', message: e.message }) }
|
|
})
|
|
}
|
|
|
|
function prepSend () { testEmail.value = testEmail.value || userEmail.value || ''; if (templates.value.length <= 1) loadTemplates(); showSend.value = true }
|
|
async function loadTemplates () {
|
|
try {
|
|
const d = await listTemplates()
|
|
const files = (d.templates || []).map(t => (typeof t === 'string' ? { value: t, label: t } : { value: t.name, label: t.label || t.name }))
|
|
templates.value = [{ value: '', label: 'Invitation Fête 20 ans (TARGO)' }, ...files]
|
|
} catch (e) { /* garde le défaut */ }
|
|
}
|
|
function onCsvFile (file) {
|
|
audPreview.value = null
|
|
if (!file) { audCsvText.value = ''; return }
|
|
const rd = new FileReader()
|
|
rd.onload = () => { audCsvText.value = String(rd.result || '') }
|
|
rd.readAsText(file)
|
|
}
|
|
function onActiveSubToggle (v) { audPreview.value = null; if (!v) audFilters.value.min_monthly = 0 } // le montant n'a de sens qu'avec les abonnements actifs
|
|
async function doPreview () {
|
|
previewing.value = true; audPreview.value = null; showNoEmail.value = false
|
|
try {
|
|
const body = audienceSource.value === 'csv' ? { mode: 'csv', csv: audCsvText.value } : { mode: 'filter', filters: audFilters.value }
|
|
audPreview.value = await previewAudience(EVENT_ID, body)
|
|
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { previewing.value = false }
|
|
}
|
|
async function sendTestNow () {
|
|
const em = (testEmail.value || '').trim()
|
|
if (!em) { $q.notify({ type: 'warning', message: 'Entrez un courriel de test' }); return }
|
|
sending.value = true
|
|
try {
|
|
const r = await sendInvite(EVENT_ID, { test: true, test_emails: [em], channel: sendChannel.value })
|
|
if (r.ok) $q.notify({ type: 'positive', message: `Courriel test envoyé à ${em}`, icon: 'send' })
|
|
else $q.notify({ type: 'negative', message: 'Échec : ' + ((r.results && r.results[0] && r.results[0].error) || 'inconnu') })
|
|
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { sending.value = false }
|
|
}
|
|
|
|
onMounted(load)
|
|
</script>
|
|
|
|
<style scoped>
|
|
.ev-badge { display:flex; flex-direction:column; align-items:center; justify-content:center; width:52px; height:52px; border-radius:50%; background:#21a34a; color:#fff; font-weight:800; line-height:1; flex:0 0 auto; }
|
|
.ev-badge b { font-size:1.15rem; }
|
|
.ev-badge span { font-size:.52rem; letter-spacing:1px; }
|
|
</style>
|