gigafibre-fsm/apps/ops/src/modules/campaigns/pages/GiftsInventoryPage.vue
louispaulb 512c4a5f1b feat(ops): dispatch auto complet + perf Boîte/rapports + fix session
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>
2026-07-02 08:49:35 -04:00

323 lines
13 KiB
Vue

<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">
<q-tooltip>Retour aux campagnes</q-tooltip>
</q-btn>
<div class="text-h5">Inventaire des liens cadeaux</div>
<q-space />
<q-btn flat dense icon="refresh" label="Rafraîchir" @click="load" :loading="loading" />
</div>
<!-- Status counters strip a glance at the inventory health -->
<div class="row q-col-gutter-sm q-mb-md">
<q-card flat bordered class="col-6 col-md-2" @click="filterStatus = null" style="cursor:pointer">
<q-card-section class="text-center q-pa-sm">
<div class="text-h5">{{ gifts.length }}</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" @click="filterStatus = 'active'" style="cursor:pointer">
<q-card-section class="text-center q-pa-sm">
<div class="text-h5 text-positive">{{ counts.active }}</div>
<div class="text-caption text-grey-7">Actifs</div>
</q-card-section>
</q-card>
<q-card flat bordered class="col-6 col-md-2" @click="filterStatus = 'redeemed'" style="cursor:pointer">
<q-card-section class="text-center q-pa-sm">
<div class="text-h5 text-deep-purple-7">{{ counts.redeemed }}</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" @click="filterStatus = 'expired'" style="cursor:pointer">
<q-card-section class="text-center q-pa-sm">
<div class="text-h5 text-warning">{{ counts.expired }}</div>
<div class="text-caption text-grey-7">Expirés</div>
</q-card-section>
</q-card>
<q-card flat bordered class="col-6 col-md-2" @click="filterStatus = 'revoked'" style="cursor:pointer">
<q-card-section class="text-center q-pa-sm">
<div class="text-h5 text-grey-7">{{ counts.revoked }}</div>
<div class="text-caption text-grey-7">Révoqués</div>
</q-card-section>
</q-card>
<q-card flat bordered class="col-6 col-md-2" @click="filterStatus = 'reassignable'" style="cursor:pointer"
:class="{ 'bg-amber-1': counts.reassignable > 0 }">
<q-card-section class="text-center q-pa-sm">
<div class="text-h5 text-warning">{{ counts.reassignable }}</div>
<div class="text-caption text-grey-7">Réassignables</div>
</q-card-section>
</q-card>
</div>
<!-- Filter bar -->
<q-card flat bordered class="q-mb-md">
<q-card-section class="q-py-sm">
<div class="row items-center q-col-gutter-sm">
<q-input v-model="search" placeholder="Rechercher (nom, courriel, lien…)" outlined dense
class="col-12 col-md-4" clearable>
<template v-slot:prepend><q-icon name="search" /></template>
</q-input>
<q-select v-model="filterStatus" :options="statusOptions" emit-value map-options
label="Statut" outlined dense clearable class="col-6 col-md-3" />
<q-select v-model="filterCampaign" :options="campaignOptions" emit-value map-options
label="Campagne" outlined dense clearable class="col-6 col-md-3" />
<q-space />
<div class="text-caption text-grey-7 q-mr-sm">{{ filtered.length }} résultat(s)</div>
</div>
</q-card-section>
</q-card>
<DataTable
:rows="filtered" :columns="columns" row-key="gift_token"
flat bordered dense :pagination="{ rowsPerPage: 50, sortBy: 'expires', descending: false }"
:rows-per-page-options="[25, 50, 100, 0]"
:loading="loading"
>
<template v-slot:body-cell-status="props">
<q-td :props="props">
<q-chip dense size="sm" :color="statusColor(props.row.status)" text-color="white"
:icon="statusIcon(props.row.status)" :label="statusLabel(props.row.status)" />
</q-td>
</template>
<template v-slot:body-cell-recipient="props">
<q-td :props="props">
<div>{{ props.row.firstname }} {{ props.row.lastname }}</div>
<div class="text-caption text-grey-6">{{ props.row.email }}</div>
</q-td>
</template>
<template v-slot:body-cell-campaign="props">
<q-td :props="props">
<a :href="`/ops/#/campaigns/${props.row.campaign_id}`" class="text-primary" style="text-decoration:none">
{{ props.row.campaign_name || props.row.campaign_id }}
<q-icon name="open_in_new" size="14px" />
</a>
</q-td>
</template>
<template v-slot:body-cell-gift_url="props">
<q-td :props="props">
<span style="font-family:monospace; font-size:0.78rem">{{ shorten(props.row.gift_url) }}</span>
<q-btn flat dense round size="xs" icon="content_copy" @click="copy(props.row.gift_url)" class="q-ml-xs">
<q-tooltip>Copier l'URL Giftbit (à coller dans une nouvelle campagne pour réassigner)</q-tooltip>
</q-btn>
<q-btn v-if="giftbitAdminUrl(props.row.gift_url)"
flat dense round size="xs" icon="open_in_new" color="primary"
:href="giftbitAdminUrl(props.row.gift_url)" target="_blank" class="q-ml-xs">
<q-tooltip>Voir l'historique sur Giftbit (statut de redemption)</q-tooltip>
</q-btn>
</q-td>
</template>
<template v-slot:body-cell-expires="props">
<q-td :props="props">
<span v-if="props.row.gift_expires_at" :class="{ 'text-warning': props.row.status === 'expired' }">
{{ formatDate(props.row.gift_expires_at) }}
</span>
<span v-else class="text-grey-5">—</span>
</q-td>
</template>
<template v-slot:body-cell-clicks="props">
<q-td :props="props" class="text-center">
<span v-if="props.row.gift_redirected_count">
{{ props.row.gift_redirected_count }}
<q-tooltip v-if="props.row.gift_first_redirected_at">
1er clic: {{ formatDate(props.row.gift_first_redirected_at) }}
</q-tooltip>
</span>
<span v-else class="text-grey-5">0</span>
</q-td>
</template>
<template v-slot:body-cell-actions="props">
<q-td :props="props" class="text-right">
<q-btn v-if="props.row.status === 'active'" flat dense color="negative" icon="block" size="sm"
@click="confirmRevoke(props.row)">
<q-tooltip>Révoquer ce lien (libère le cadeau Giftbit pour réassignation)</q-tooltip>
</q-btn>
<q-btn v-if="props.row.status === 'expired' || props.row.status === 'revoked'"
flat dense color="warning" icon="autorenew" size="sm"
@click="copyForReassign(props.row.gift_url)">
<q-tooltip>Copier l'URL pour réassigner à un autre destinataire</q-tooltip>
</q-btn>
</q-td>
</template>
</DataTable>
</q-page>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import DataTable from 'src/components/shared/DataTable.vue'
import { useQuasar } from 'quasar'
import { listGifts, revokeGift } from 'src/api/campaigns'
const $q = useQuasar()
const gifts = ref([])
const loading = ref(true)
const search = ref('')
const filterStatus = ref(null)
const filterCampaign = ref(null)
const columns = [
{ name: 'status', label: 'Statut', field: 'status', align: 'left', sortable: true },
{ name: 'recipient', label: 'Destinataire', field: r => `${r.firstname} ${r.lastname}`.trim(), align: 'left', sortable: true },
{ name: 'campaign', label: 'Campagne', field: 'campaign_name', align: 'left', sortable: true },
{ name: 'gift_url', label: 'Lien Giftbit', field: 'gift_url', align: 'left' },
{ name: 'expires', label: 'Expire le', field: 'gift_expires_at', align: 'left', sortable: true },
{ name: 'clicks', label: 'Clics', field: 'gift_redirected_count', align: 'center', sortable: true },
{ name: 'actions', label: '', field: '', align: 'right' },
]
const statusOptions = [
{ label: '🟢 Actif', value: 'active' },
{ label: '🟣 Cliqué', value: 'redeemed' },
{ label: '🟠 Expiré', value: 'expired' },
{ label: ' Révoqué', value: 'revoked' },
{ label: '🟡 Réassignable (expiré + révoqué)', value: 'reassignable' },
{ label: ' Non envoyé', value: 'pending' },
]
const campaignOptions = computed(() => {
const seen = new Map()
for (const g of gifts.value) {
if (!seen.has(g.campaign_id)) seen.set(g.campaign_id, g.campaign_name || g.campaign_id)
}
return [...seen.entries()].map(([value, label]) => ({ value, label }))
})
const counts = computed(() => {
const c = { active: 0, redeemed: 0, expired: 0, revoked: 0, pending: 0, reassignable: 0 }
for (const g of gifts.value) {
c[g.status] = (c[g.status] || 0) + 1
// Reassignable = expired or revoked AND not yet redeemed (i.e. the
// underlying Giftbit gift is presumably still unredeemed).
if ((g.status === 'expired' || g.status === 'revoked') && !g.gift_link_clicked) c.reassignable++
}
return c
})
const filtered = computed(() => {
let rows = gifts.value
if (filterStatus.value === 'reassignable') {
rows = rows.filter(g => (g.status === 'expired' || g.status === 'revoked') && !g.gift_link_clicked)
} else if (filterStatus.value) {
rows = rows.filter(g => g.status === filterStatus.value)
}
if (filterCampaign.value) rows = rows.filter(g => g.campaign_id === filterCampaign.value)
if (search.value) {
const q = search.value.toLowerCase()
rows = rows.filter(g =>
(g.firstname || '').toLowerCase().includes(q) ||
(g.lastname || '').toLowerCase().includes(q) ||
(g.email || '').toLowerCase().includes(q) ||
(g.gift_url || '').toLowerCase().includes(q) ||
(g.gift_token || '').toLowerCase().includes(q),
)
}
return rows
})
function statusColor (s) {
return {
active: 'positive',
redeemed: 'deep-purple-7',
expired: 'orange-9',
revoked: 'grey-7',
pending: 'grey-5',
}[s] || 'grey-5'
}
function statusIcon (s) {
return {
active: 'check_circle',
redeemed: 'redeem',
expired: 'schedule',
revoked: 'block',
pending: 'hourglass_empty',
}[s] || ''
}
function statusLabel (s) {
return {
active: 'Actif',
redeemed: 'Cliqué',
expired: 'Expiré',
revoked: 'Révoqué',
pending: 'En attente',
}[s] || s
}
function shorten (u) {
if (!u) return ''
return u.replace(/^https?:\/\//, '').slice(0, 36) + (u.length > 40 ? '' : '')
}
// Build a deep link into Giftbit's admin panel for a given shortlink so
// operators can confirm redemption status manually until task #25 wires
// the /gifts/{uuid} API polling. The trailing path segment of the
// shortlink URL is what Giftbit's reward search uses as the lookup key.
// "http://gft.link/4kpZMApLK4B"
// → "https://app.giftbit.com/app/rewards?search=4kpZMApLK4B"
function giftbitAdminUrl (giftUrl) {
if (!giftUrl) return null
// Last non-empty path segment (strip trailing slashes if any)
const segments = giftUrl.replace(/\/+$/, '').split('/')
const code = segments[segments.length - 1]
if (!code || code.length < 4) return null
return `https://app.giftbit.com/app/rewards?search=${encodeURIComponent(code)}`
}
function formatDate (iso) {
if (!iso) return ''
return new Date(iso).toLocaleString('fr-CA', { dateStyle: 'medium', timeStyle: 'short' })
}
async function copy (text, customMsg) {
try {
await navigator.clipboard.writeText(text)
$q.notify({ type: 'positive', message: customMsg || 'Copié dans le presse-papier', timeout: 2500 })
} catch {
$q.notify({ type: 'negative', message: 'Copie impossible — sélectionne et copie manuellement' })
}
}
function copyForReassign (url) {
// Separate helper so the toast message can live in JS where quote-escaping
// is trivial (the Vue attribute parser doesn't accept embedded quotes).
copy(url, `Lien Giftbit copié — colle-le dans "Ajouter manuellement" d'une nouvelle campagne pour réassigner ce cadeau`)
}
function confirmRevoke (row) {
$q.dialog({
title: 'Révoquer ce lien ?',
message: `Le destinataire <strong>${row.firstname} ${row.lastname}</strong> ne pourra plus utiliser son lien après cette action. L'URL Giftbit (<code>${shorten(row.gift_url)}</code>) restera valide chez Giftbit et pourra être réassignée à un autre destinataire via une nouvelle campagne.`,
html: true,
cancel: { label: 'Annuler', flat: true },
ok: { label: 'Révoquer', color: 'negative', unelevated: true },
persistent: true,
}).onOk(async () => {
try {
await revokeGift(row.campaign_id, row.row_index)
row.status = 'revoked'
row.gift_revoked = true
$q.notify({ type: 'positive', message: 'Lien révoqué' })
} catch (e) {
$q.notify({ type: 'negative', message: 'Erreur: ' + e.message })
}
})
}
async function load () {
loading.value = true
try {
gifts.value = await listGifts()
} catch (e) {
$q.notify({ type: 'negative', message: 'Chargement échoué: ' + e.message })
} finally {
loading.value = false
}
}
// Ancrage profond depuis la fiche client : ?q=<gift_token|courriel> pré-filtre le tableau sur la bonne récompense.
const route = useRoute()
onMounted(() => {
if (route.query.q) search.value = String(route.query.q)
if (route.query.status) filterStatus.value = String(route.query.status)
load()
})
</script>