Planificateur « Suggérer » : 4 stratégies (smart / meilleurs d'abord / équilibré / juste ce qu'il faut), compétences+niveaux par tech (édition inline), niveau requis par compétence + par job, carte des tournées (1 couleur/tech, domicile→arrêts, sélecteur de jour), fenêtre de dispatch auj.+demain (dates sélectionnées), règle week-end + placeholder « en attente du quart », clustering + lasso + filtre-date sur la carte, accès rapide « À assigner » (badge). Boîte : liste /conversations allégée (45 Mo → ~1 Mo, 17×) + messages chargés à l'ouverture. Rapports : cache SWR sur revenue-explorer (22×). Session : keep-alive + timeout fetch global + authFetch durci → fin des rechargements manuels. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
133 lines
5.6 KiB
Vue
133 lines
5.6 KiB
Vue
<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="redeem" label="Liens cadeaux" :to="'/campaigns/gifts'" class="q-mr-sm">
|
|
<q-tooltip>Inventaire de tous les liens cadeaux (actifs, expirés, réassignables)</q-tooltip>
|
|
</q-btn>
|
|
<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>
|
|
|
|
<DataTable
|
|
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">{{ sentCount(props.row) }} / {{ props.row.total || 0 }}</span>
|
|
<q-linear-progress
|
|
:value="sentCount(props.row) / 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-tooltip>Voir la campagne</q-tooltip>
|
|
</q-btn>
|
|
<q-btn flat dense color="negative" icon="delete"
|
|
:disable="props.row.status === 'sending'"
|
|
@click="confirmDelete(props.row)">
|
|
<q-tooltip>{{ props.row.status === 'sending' ? "Envoi en cours — impossible de supprimer" : "Supprimer la campagne" }}</q-tooltip>
|
|
</q-btn>
|
|
</q-td>
|
|
</template>
|
|
</DataTable>
|
|
</q-page>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted } from 'vue'
|
|
import DataTable from 'src/components/shared/DataTable.vue'
|
|
import { useQuasar } from 'quasar'
|
|
import { listCampaigns, deleteCampaign } from 'src/api/campaigns'
|
|
|
|
const $q = useQuasar()
|
|
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' },
|
|
]
|
|
|
|
// "Envoyés" = anything past the SMTP handoff. Webhooks (Mailjet Event API)
|
|
// promote 'sent' rows to 'opened' / 'clicked' over time, so counting only
|
|
// counters.sent would (incorrectly) drop the displayed count back to 0 once
|
|
// recipients start opening their emails. Matches the detail-page convention.
|
|
function sentCount (row) {
|
|
const c = row?.counters || {}
|
|
return (c.sent || 0) + (c.opened || 0) + (c.clicked || 0)
|
|
}
|
|
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
|
|
}
|
|
|
|
// Hard-delete: removes the JSON on the hub. Giftbit shortlinks already
|
|
// issued for this campaign are unaffected (they live on Giftbit's side).
|
|
// Used mostly to clean up test runs from the list.
|
|
function confirmDelete (row) {
|
|
const total = row.total ?? row.counters?.total ?? 0
|
|
$q.dialog({
|
|
title: 'Supprimer la campagne ?',
|
|
message: `<div><strong>${row.name || row.id}</strong> (${total} destinataire${total > 1 ? 's' : ''})<br><br>Cette opération est irréversible — le suivi, les statistiques et le rapport CSV seront perdus. Les liens Giftbit déjà émis restent valides côté Giftbit.</div>`,
|
|
html: true,
|
|
cancel: { label: 'Annuler', flat: true },
|
|
ok: { label: 'Supprimer', color: 'negative', unelevated: true },
|
|
persistent: true,
|
|
}).onOk(async () => {
|
|
try {
|
|
await deleteCampaign(row.id)
|
|
campaigns.value = campaigns.value.filter(c => c.id !== row.id)
|
|
$q.notify({ type: 'positive', message: 'Campagne supprimée' })
|
|
} catch (e) {
|
|
$q.notify({ type: 'negative', message: 'Erreur : ' + e.message, timeout: 4000 })
|
|
}
|
|
})
|
|
}
|
|
|
|
async function load () {
|
|
loading.value = true
|
|
try { campaigns.value = await listCampaigns() }
|
|
finally { loading.value = false }
|
|
}
|
|
|
|
onMounted(load)
|
|
</script>
|