Compare commits

..

4 Commits

Author SHA1 Message Date
louispaulb
3489576212 Planif: congés publish-required, menu de cellule partagé, vue Mois
- Absences (congé/pause) passent par le modèle local -> Publier (journal,
  undo, dirty badge) au lieu d'écrire directement au serveur. Applique à la
  grille ET au calendrier par tech (TechScheduleDialog).
- Fix capacité "48h" : l'estimation ignorait l'absence et le patron hebdo.
- Menu de cellule (Jour/Soir/Garde/Absent/heures) extrait en composant
  partagé PlanifCellMenu, réutilisé par la grille et le calendrier par tech.
- Raccourci "Jour" = 8-16 (pas 8-17) ; saisie rapide accepte "816".
- Nouvelle vue Mois (MonthOverview) : qui est en quart/absent par jour +
  couverture par compétence (heures requises vs heures en quart). Remplace
  l'ancien module "Demande - effectif requis par créneau".
2026-07-09 11:32:54 -04:00
louispaulb
0853947c43 Events: remove RSVP customer-number field; fuzzy multi-field name audience filter
- RSVP form: drop the "Votre numéro client (optionnel)" field (and its submit
  wiring). Magic-link tokens still resolve the customer; matching falls back to
  name/email/phone.
- Audience name filter: was a case-sensitive ERPNext `like` on customer_name only,
  so "lpb" matched nothing (the account is C-LPB4 / "Louis-Paul Bourdon", matched via
  account ID / mandataire). Now resolved via nameLikeCustomerIds — accent/case-
  insensitive across customer_name + account name + mandataire + contact_name_legacy
  (PG pool, REST fallback), intersected with the other filters. Confirms "Tous"
  includes résiliés (include_resiliated) works end to end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 11:26:59 -04:00
louispaulb
d26722f048 Events: editable (Unlayer) email template, "Tous" includes résiliés, realtime picker
- Email template: an event can use an editable campaign-style template (Unlayer) for
  its invitation instead of the built-in festive builder. Config field email_template
  ('' = festive default/fallback). inviteEmail() renders the named template via
  campaigns.renderNamedTemplate with {{firstname}}/{{rsvp_url}} (+ event vars), else
  festive. campaigns.js: add 'event-' template prefix + export renderNamedTemplate.
  New GET /events/:id/invite-preview (festive | configured | override) for live preview.
  Editor gains a template selector, "design in editor" link, and an inbox preview dialog.
- Audience "Tous (incl. résiliés)": filters.include_resiliated skips the résilié guard
  for win-back; "Clients actifs" still excludes them.
- Manual picker: realtime fuzzy search into a results list (add per row), separate pool,
  then add-batch — replaces the combobox.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 11:05:31 -04:00
louispaulb
3aa59adec3 Events: creation, capacity cap, language-tagged attachments, additive audience list
Ops event page overhaul (hub lib/events.js + email.js, ops EventsPage.vue + api):

- Create/edit/delete events (persisted config shadowing the seeded SEED_CONTENT);
  labels factored into shared LABELS, content editable bilingual (EN falls back to FR).
- Capacity cap by headcount: public RSVP form closes with a "booking is full" message
  when reached (server-enforced); existing registrants can still update.
- Email attachments (image/PDF, <=4MB, <=6), tagged per language (fr/en/both) so each
  customer gets files in their account language; optional Gemini auto-detect on images.
  email.sendEmail gains a generic attachments[] param.
- Additive main audience list (persisted per event): build one deduped list by appending
  batches from filters, manual picks, or CSV; per-source breakdown, remove/clear.
- Filters: name-contains (customer_name like) and city (Service Location.city, resolved
  accent/case-insensitively via PG with REST fallback), intersected with other criteria.
- Manual picker + fuzzy search reuse app-wide /collab/customer-search (pg_trgm), so
  "repa" finds "Réparation …".
- nginx /hub/ proxy: client_max_body_size 8m for base64 attachment uploads.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 10:10:43 -04:00
10 changed files with 1697 additions and 390 deletions

View File

@ -50,6 +50,7 @@ server {
proxy_set_header Connection ""; proxy_set_header Connection "";
proxy_buffering off; proxy_buffering off;
proxy_read_timeout 3600s; proxy_read_timeout 3600s;
client_max_body_size 8m; # pièces jointes événement (image/PDF base64, ~5.5m pour 4m de binaire)
} }
# SPA fallback all routes serve index.html # SPA fallback all routes serve index.html

View File

@ -26,4 +26,32 @@ export async function listRsvps (eventId) { return hubFetch(`/events/${encodeURI
export async function deleteRsvp (eventId, key) { return hubFetch(`/events/${encodeURIComponent(eventId)}/rsvps/${encodeURIComponent(key)}`, { method: 'DELETE' }) } export async function deleteRsvp (eventId, key) { return hubFetch(`/events/${encodeURIComponent(eventId)}/rsvps/${encodeURIComponent(key)}`, { method: 'DELETE' }) }
export async function sendInvite (eventId, body) { return hubFetch(`/events/${encodeURIComponent(eventId)}/send`, { method: 'POST', body }) } export async function sendInvite (eventId, body) { return hubFetch(`/events/${encodeURIComponent(eventId)}/send`, { method: 'POST', body }) }
export async function previewAudience (eventId, body) { return hubFetch(`/events/${encodeURIComponent(eventId)}/audience`, { method: 'POST', body }) } export async function previewAudience (eventId, body) { return hubFetch(`/events/${encodeURIComponent(eventId)}/audience`, { method: 'POST', body }) }
// Aperçu HTML de l'invitation (gabarit festif OU template éditable). template: undefined=modèle configuré, ''=festif, '<nom>'=ce template.
export async function getInvitePreview (eventId, { lang = 'fr', template } = {}) {
const qs = new URLSearchParams({ lang })
if (template !== undefined) qs.set('template', template)
return hubFetch(`/events/${encodeURIComponent(eventId)}/invite-preview?${qs.toString()}`)
}
// Recherche FLOUE de clients (pg_trgm, typo/accent-tolérante) — réutilise l'endpoint app-wide.
export async function searchCustomersFuzzy (q) { return hubFetch(`/collab/customer-search?q=${encodeURIComponent(q)}`) }
// Liste principale d'audience (additive, persistée par événement).
export async function getAudienceList (eventId) { return hubFetch(`/events/${encodeURIComponent(eventId)}/audience-list`) }
export async function audienceListAdd (eventId, body) { return hubFetch(`/events/${encodeURIComponent(eventId)}/audience-list/add`, { method: 'POST', body }) }
export async function audienceListRemove (eventId, email) { return hubFetch(`/events/${encodeURIComponent(eventId)}/audience-list/remove`, { method: 'POST', body: { email } }) }
export async function audienceListClear (eventId) { return hubFetch(`/events/${encodeURIComponent(eventId)}/audience-list`, { method: 'DELETE' }) }
export async function listTemplates () { return hubFetch('/campaigns/templates') } export async function listTemplates () { return hubFetch('/campaigns/templates') }
// Config brute éditable d'un événement (pour le formulaire d'édition).
export async function getEventConfig (eventId) { return hubFetch(`/events/${encodeURIComponent(eventId)}`) }
// Création / édition / suppression d'un événement (config persistée côté hub).
export async function createEvent (body) { return hubFetch('/events', { method: 'POST', body }) }
export async function updateEvent (eventId, body) { return hubFetch(`/events/${encodeURIComponent(eventId)}`, { method: 'PUT', body }) }
export async function deleteEvent (eventId) { return hubFetch(`/events/${encodeURIComponent(eventId)}`, { method: 'DELETE' }) }
// Pièces jointes du courriel d'invitation (image/PDF, base64).
export async function uploadAttachment (eventId, { filename, mime, data, lang }) { return hubFetch(`/events/${encodeURIComponent(eventId)}/attachments`, { method: 'POST', body: { filename, mime, data, lang } }) }
export async function deleteAttachment (eventId, stored) { return hubFetch(`/events/${encodeURIComponent(eventId)}/attachments/${encodeURIComponent(stored)}`, { method: 'DELETE' }) }

View File

@ -0,0 +1,252 @@
<template>
<div class="mov-wrap q-pa-sm">
<!-- Barre : navigation de mois + éditeur des besoins (heures requises par compétence) + génération des besoins solveur -->
<div class="row items-center q-gutter-sm q-mb-sm">
<q-btn flat round dense icon="chevron_left" @click="shiftMonth(-1)" />
<div class="text-subtitle1 text-weight-bold" style="min-width:150px;text-align:center;text-transform:capitalize">{{ monthLabel }}</div>
<q-btn flat round dense icon="chevron_right" @click="shiftMonth(1)" />
<q-btn flat dense no-caps size="sm" icon="today" label="Ce mois" @click="goThisMonth" />
<q-spinner v-if="loading" size="18px" color="primary" />
<q-space />
<q-btn dense flat no-caps size="sm" :color="showReq ? 'indigo' : 'grey-7'" icon="tune" :label="showReq ? 'Masquer les besoins' : 'Besoins par compétence'" @click="showReq = !showReq" />
</div>
<!-- Éditeur : heures REQUISES par compétence × type de jour (semaine / fin de sem. / férié) remplace l'ancienne « Demande ». -->
<q-card v-if="showReq" flat bordered class="q-mb-sm">
<q-card-section class="q-py-sm">
<div class="row items-center q-mb-xs">
<div class="text-weight-medium">Besoins heures requises par compétence</div>
<q-space />
<q-btn dense unelevated color="indigo" size="sm" no-caps icon="playlist_add_check" label="Générer les besoins du mois" :loading="generating" @click="generateRequirements">
<q-tooltip class="bg-grey-9" style="max-width:280px">Traduit ces heures requises en <b>besoins de couverture</b> (Shift Requirements) pour le solveur « Générer », sur tout le mois affiché. Remplace les besoins existants du mois.</q-tooltip>
</q-btn>
</div>
<table class="mov-req">
<thead><tr><th style="text-align:left">Compétence</th><th>Semaine</th><th>Fin de sem.</th><th>Férié</th></tr></thead>
<tbody>
<tr v-for="sk in skills" :key="sk">
<td style="text-align:left"><span class="mov-dot" :style="{ background: tagColor(sk) }"></span>{{ sk }}</td>
<td><input type="number" min="0" step="1" class="mov-in" :value="reqOf(sk, 'weekday')" @change="setReq(sk, 'weekday', $event.target.value)" /></td>
<td><input type="number" min="0" step="1" class="mov-in" :value="reqOf(sk, 'weekend')" @change="setReq(sk, 'weekend', $event.target.value)" /></td>
<td><input type="number" min="0" step="1" class="mov-in" :value="reqOf(sk, 'holiday')" @change="setReq(sk, 'holiday', $event.target.value)" /></td>
</tr>
<tr v-if="!skills.length"><td colspan="4" class="text-grey-6 q-pa-sm">Aucune compétence taggez des techniciens.</td></tr>
</tbody>
</table>
<div class="text-caption text-grey-6 q-mt-xs">Heures/jour requises par compétence. La couverture = heures des techs <b>en quart</b> (hors garde, hors absents) qui ont la compétence. Case rouge = déficit.</div>
</q-card-section>
</q-card>
<!-- Calendrier du mois : 1 case/jour, résumé en quart / absents + alertes de couverture -->
<div class="mov-cal">
<div v-for="d in DOW_LABELS" :key="'h' + d" class="mov-dowh">{{ d }}</div>
<div v-for="(c, i) in monthCells" :key="i" class="mov-cell" :class="{ 'mov-empty': !c.inMonth, weekend: c.weekend, today: c.isToday, holiday: c.holiday, short: c.inMonth && c.deficits.length }">
<template v-if="c.inMonth">
<div class="mov-top"><span class="mov-daynum">{{ c.day }}</span><span v-if="c.holiday" class="mov-holi" title="Férié"></span><q-space /><q-icon v-if="c.deficits.length" name="warning" color="negative" size="15px" /></div>
<!-- Initiales des techs en quart (max 5) -->
<div class="mov-inits">
<span v-for="t in c.onShift.slice(0, 5)" :key="t.id" class="mov-init" :style="{ background: t.color }">{{ t.initials }}</span>
<span v-if="c.onShift.length > 5" class="mov-more">+{{ c.onShift.length - 5 }}</span>
</div>
<!-- Compteurs + déficits -->
<div class="mov-foot">
<span class="mov-on" :class="{ zero: !c.onShift.length }">{{ c.onShift.length }} en quart</span>
<span v-if="c.absent.length" class="mov-off">· {{ c.absent.length }} abs.</span>
</div>
<div v-if="c.deficits.length" class="mov-defs">
<span v-for="df in c.deficits" :key="df.skill" class="mov-def">{{ df.skill }} {{ df.deficit }}h</span>
</div>
<q-tooltip v-if="c.onShift.length || c.absent.length || c.coverage.length" anchor="center middle" self="center middle" class="bg-grey-9" style="font-size:11.5px;max-width:320px">
<div class="text-weight-bold q-mb-xs">{{ c.iso }}<span v-if="c.holiday"> · férié</span></div>
<div v-if="c.onShift.length"><b>{{ c.onShift.length }} en quart :</b> {{ c.onShift.map(t => t.name + ' (' + t.hours + 'h)').join(', ') }}</div>
<div v-if="c.absent.length" class="q-mt-xs"><b>{{ c.absent.length }} absent(s) :</b> {{ c.absent.map(t => t.name + ' — ' + t.type).join(', ') }}</div>
<div v-if="c.coverage.length" class="q-mt-xs"><b>Couverture :</b><br><span v-for="cov in c.coverage" :key="cov.skill" :class="{ 'text-negative': cov.deficit > 0 }">• {{ cov.skill }} : {{ cov.avail }}/{{ cov.req }}h<span v-if="cov.deficit > 0"> (manque {{ cov.deficit }}h)</span><br></span></div>
</q-tooltip>
</template>
</div>
</div>
<div class="row items-center q-gutter-md q-mt-xs mov-legend">
<span><i class="mov-lg mov-lg-on"></i>en quart</span>
<span><i class="mov-lg mov-lg-short"></i>couverture insuffisante</span>
<span><i class="mov-lg mov-lg-holi"></i>férié</span>
<q-space />
<span class="text-grey-6">{{ monthTotals.onDays }} jour(s) couvert(s) · {{ monthTotals.shortDays }} en déficit</span>
</div>
</div>
</template>
<script setup>
// Vue MOIS (tous techs) : résumé par jour = qui est EN QUART / ABSENT + alertes de COUVERTURE par compétence
// (heures requises vs heures en quart des techs qualifiés). Remplace l'ancien module « Demande » (besoins par template).
// Auto-contenu : charge ses propres données du mois (assignations, absences, fériés) ; besoins par compétence en localStorage.
import { ref, reactive, computed, watch, onMounted } from 'vue'
import { useQuasar } from 'quasar'
import * as roster from 'src/api/roster'
const props = defineProps({
techs: { type: Array, default: () => [] }, // { id, name, skills:[], color_hex? }
templates: { type: Array, default: () => [] }, // Shift Template (nom, hours, on_call) pour l'unité de couverture + génération
anchor: { type: String, default: '' }, // date ISO dans le mois à afficher (sinon aujourd'hui)
refreshKey: { type: Number, default: 0 },
})
const emit = defineEmits(['generated'])
const $q = useQuasar()
const err = (e) => $q.notify({ type: 'negative', message: '' + (e.message || e) })
const DOW_LABELS = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim']
const MO = ['janvier', 'février', 'mars', 'avril', 'mai', 'juin', 'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre']
const LS_SKILL_DEMAND = 'roster-skill-demand-v1'
const month = ref('') // 'YYYY-MM'
const loading = ref(false)
const generating = ref(false)
const showReq = ref(false)
const asgByDate = ref({}) // iso [{ tech, hours }] (quarts réguliers, hors garde)
const absByKey = ref({}) // 'tech|iso' type
const holiSet = ref(new Set())
const skillDemand = reactive(load())
function load () { try { return JSON.parse(localStorage.getItem(LS_SKILL_DEMAND) || '{}') } catch { return {} } }
function saveReq () { try { localStorage.setItem(LS_SKILL_DEMAND, JSON.stringify(skillDemand)) } catch (e) {} }
function reqOf (sk, slot) { return (skillDemand[sk] && skillDemand[sk][slot]) || 0 }
function setReq (sk, slot, v) { const n = Math.max(0, Number(v) || 0); if (!skillDemand[sk]) skillDemand[sk] = { weekday: 0, weekend: 0, holiday: 0 }; skillDemand[sk][slot] = n; saveReq() }
const skills = computed(() => [...new Set((props.techs || []).flatMap(t => t.skills || []))].sort())
const techById = computed(() => { const m = {}; for (const t of (props.techs || [])) m[t.id] = t; return m })
const tplByName = computed(() => Object.fromEntries((props.templates || []).map(t => [t.name, t])))
function tagColor (sk) { let h = 0; for (let i = 0; i < sk.length; i++) h = (h * 31 + sk.charCodeAt(i)) & 0xffffff; return 'hsl(' + (h % 360) + ',55%,55%)' }
function initials (name) { const p = String(name || '').trim().split(/\s+/); return ((p[0] || '')[0] || '').toUpperCase() + ((p[1] || '')[0] || '').toUpperCase() }
function techColor (t) { return (t && t.color_hex) || tagColor(t ? (t.name || t.id) : '?') }
function pad (n) { return String(n).padStart(2, '0') }
const monthLabel = computed(() => { if (!month.value) return ''; const [y, m] = month.value.split('-').map(Number); return MO[m - 1] + ' ' + y })
const monthDays = computed(() => { if (!month.value) return 0; const [y, m] = month.value.split('-').map(Number); return new Date(Date.UTC(y, m, 0)).getUTCDate() })
const monthStart = computed(() => month.value ? month.value + '-01' : '')
// dayType d'un jour clé de besoin (semaine / fin de sem. / férié)
function dayType (iso, dow) { if (holiSet.value.has(iso)) return 'holiday'; return (dow === 0 || dow === 6) ? 'weekend' : 'weekday' }
const monthCells = computed(() => {
if (!month.value) return []
const [y, m] = month.value.split('-').map(Number)
const first = new Date(Date.UTC(y, m - 1, 1))
const lead = (first.getUTCDay() + 6) % 7 // lundi = 0
const cells = []
for (let i = 0; i < lead; i++) cells.push({ inMonth: false })
const today = new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' })
for (let day = 1; day <= monthDays.value; day++) {
const iso = y + '-' + pad(m) + '-' + pad(day)
const dow = new Date(iso + 'T12:00:00').getUTCDay()
const type = dayType(iso, dow)
// Techs en quart ce jour (hors garde), non absents.
const onShift = []
for (const a of (asgByDate.value[iso] || [])) {
if (absByKey.value[a.tech + '|' + iso]) continue
const t = techById.value[a.tech]; if (!t) continue
onShift.push({ id: t.id, name: t.name, initials: initials(t.name), color: techColor(t), hours: a.hours, skills: t.skills || [] })
}
// Absents ce jour.
const absent = []
for (const t of (props.techs || [])) { const type2 = absByKey.value[t.id + '|' + iso]; if (type2) absent.push({ id: t.id, name: t.name, type: type2 }) }
// Couverture par compétence : heures dispo (techs en quart ayant la compétence) vs requis.
const coverage = []; const deficits = []
for (const sk of skills.value) {
const req = reqOf(sk, type); if (!req) continue
let avail = 0; for (const t of onShift) if ((t.skills || []).includes(sk)) avail += Number(t.hours) || 0
avail = Math.round(avail * 10) / 10
const deficit = Math.max(0, Math.round((req - avail) * 10) / 10)
coverage.push({ skill: sk, avail, req, deficit })
if (deficit > 0) deficits.push({ skill: sk, deficit })
}
cells.push({ inMonth: true, day, iso, dow, weekend: dow === 0 || dow === 6, holiday: holiSet.value.has(iso), isToday: iso === today, onShift, absent, coverage, deficits })
}
while (cells.length % 7) cells.push({ inMonth: false })
return cells
})
const monthTotals = computed(() => { let onDays = 0, shortDays = 0; for (const c of monthCells.value) { if (!c.inMonth) continue; if (c.onShift && c.onShift.length) onDays++; if (c.deficits && c.deficits.length) shortDays++ } return { onDays, shortDays } })
function initMonth () { const a = props.anchor || new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }); month.value = a.slice(0, 7) }
function shiftMonth (n) { const [y, m] = month.value.split('-').map(Number); const d = new Date(Date.UTC(y, m - 1 + n, 1)); month.value = d.getUTCFullYear() + '-' + pad(d.getUTCMonth() + 1); loadMonth() }
function goThisMonth () { month.value = new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }).slice(0, 7); loadMonth() }
async function loadMonth () {
if (!month.value) return
loading.value = true
try {
const start = monthStart.value; const days = monthDays.value
const [asgRes, absRes, holiRes] = await Promise.all([
roster.listAssignments(start, days).catch(() => null),
roster.getAbsences(start, days).catch(() => null),
roster.holidays(start, month.value + '-' + pad(days)).catch(() => null),
])
const abd = {}
for (const a of ((asgRes && asgRes.assignments) || [])) {
const tp = tplByName.value[a.shift]; if (tp && tp.on_call) continue // garde exclue de la couverture
;(abd[a.date] || (abd[a.date] = [])).push({ tech: a.tech, hours: Number(a.hours) || (tp ? tp.hours : 0) || 0 })
}
asgByDate.value = abd
absByKey.value = (absRes && absRes.absences) || {}
holiSet.value = new Set(((holiRes && (holiRes.holidays || [])) || []).map(h => h.date || h))
} catch (e) { err(e) } finally { loading.value = false }
}
// Génère les Shift Requirements du MOIS à partir des heures requises par compétence (remplace les besoins existants du mois).
// Traduction : effectif = heures requises ÷ heures du modèle de jour, compétence requise = la compétence.
async function generateRequirements () {
const dayTpl = (props.templates || []).find(t => !t.on_call && Number(t.hours) >= 7 && Number(t.hours) <= 9) || (props.templates || []).find(t => !t.on_call)
if (!dayTpl) { $q.notify({ type: 'warning', message: 'Aucun modèle de shift de jour — créez-en un (Types de shift).' }); return }
const tplH = Number(dayTpl.hours) || 8
generating.value = true
try {
await roster.clearRequirements(monthStart.value, monthDays.value)
const reqs = []
for (const c of monthCells.value) {
if (!c.inMonth) continue
for (const sk of skills.value) {
const req = reqOf(sk, dayType(c.iso, c.dow)); if (!req) continue
reqs.push({ requirement_date: c.iso, shift_template: dayTpl.name, zone: '', required_count: Math.max(1, Math.ceil(req / tplH)), required_skills: sk })
}
}
if (reqs.length) await roster.bulkRequirements(reqs)
$q.notify({ type: 'positive', message: reqs.length + ' besoin(s) généré(s) pour ' + monthLabel.value + ' — le solveur « Générer » les utilise' })
emit('generated')
} catch (e) { err(e) } finally { generating.value = false }
}
watch(() => props.anchor, () => { const wasMonth = month.value; initMonth(); if (month.value !== wasMonth) loadMonth() })
watch(() => props.refreshKey, () => loadMonth())
onMounted(() => { initMonth(); loadMonth() })
</script>
<style scoped>
.mov-wrap { max-width: 1100px; }
.mov-req { border-collapse: collapse; }
.mov-req th { font-size: 11px; color: #64748b; font-weight: 600; padding: 2px 10px; }
.mov-req td { padding: 2px 10px; font-size: 12.5px; }
.mov-in { width: 64px; padding: 3px 6px; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 12px; text-align: center; }
.mov-dot { display: inline-block; width: 9px; height: 9px; border-radius: 2px; margin-right: 6px; vertical-align: middle; }
.mov-cal { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; }
.mov-dowh { text-align: center; font-size: 10.5px; color: #64748b; font-weight: 700; padding-bottom: 2px; }
.mov-cell { position: relative; min-height: 92px; border: 1px solid #e2e8f0; border-radius: 8px; padding: 5px 6px; background: #fff; display: flex; flex-direction: column; gap: 3px; }
.mov-empty { border: none; background: transparent; }
.mov-cell.weekend { background: #f8fafc; }
.mov-cell.today { border-color: #2563eb; box-shadow: inset 0 0 0 1px #2563eb; }
.mov-cell.holiday { background: #fffbeb; }
.mov-cell.short { border-color: #ef4444; }
.mov-top { display: flex; align-items: center; gap: 4px; }
.mov-daynum { font-size: 12px; color: #334155; font-weight: 700; }
.mov-holi { color: #b45309; font-size: 11px; }
.mov-inits { display: flex; flex-wrap: wrap; gap: 2px; }
.mov-init { width: 19px; height: 19px; border-radius: 50%; color: #fff; font-size: 9.5px; font-weight: 700; display: inline-flex; align-items: center; justify-content: center; }
.mov-more { font-size: 10px; color: #64748b; align-self: center; }
.mov-foot { font-size: 10.5px; color: #475569; margin-top: auto; }
.mov-on.zero { color: #94a3b8; }
.mov-off { color: #64748b; }
.mov-defs { display: flex; flex-wrap: wrap; gap: 2px; }
.mov-def { font-size: 9.5px; font-weight: 700; color: #b91c1c; background: #fee2e2; border-radius: 4px; padding: 0 4px; }
.mov-legend { font-size: 10.5px; color: #475569; }
.mov-lg { display: inline-block; width: 10px; height: 10px; border-radius: 2px; vertical-align: middle; margin-right: 4px; }
.mov-lg-on { background: #2563eb; }
.mov-lg-short { background: #ef4444; }
.mov-lg-holi { background: #fbbf24; }
</style>

View File

@ -0,0 +1,79 @@
<template>
<!-- Contenu du menu de cellule PARTAGÉ (grille semaine/jour ET calendrier par tech).
Le PARENT l'enveloppe dans son propre <q-menu> (ancrage propre à chaque contexte) et câble les intentions émises
à ses propres fonctions (applyWindow / toggleGarde / absence / ). Aucun état de page ici : 100 % réutilisable. -->
<q-list dense style="width:262px;user-select:none;-webkit-user-select:none">
<q-item-label header class="q-py-xs">{{ title }}</q-item-label>
<!-- 4 actions : Jour · Soir · Garde · Absent -->
<div class="row q-gutter-xs q-px-sm q-pb-xs">
<q-btn dense unelevated size="sm" color="primary" label="Jour 816" class="col" @click="$emit('window', { min: 8, max: 16 })" />
<q-btn dense unelevated size="sm" color="deep-purple-5" label="Soir 1620" class="col" @click="$emit('window', { min: 16, max: 20 })" />
</div>
<div class="row q-gutter-xs q-px-sm q-pb-xs">
<q-btn dense :unelevated="isGarde" :outline="!isGarde" size="sm" color="brown" icon="shield" :label="isGarde ? 'Garde ✓' : 'Garde'" class="col" @click="$emit('toggle-garde')"><q-tooltip>Mettre / retirer de garde (G) en parallèle d'un shift</q-tooltip></q-btn>
<q-btn dense :unelevated="isAbsent" :outline="!isAbsent" size="sm" color="negative" icon="event_busy" :label="isAbsent ? 'Absent ✓' : 'Absent'" class="col" @click="$emit('toggle-absent')"><q-tooltip>Congé / absence à publier</q-tooltip></q-btn>
</div>
<!-- Saisie rapide d'heures : 8-17 · 830-16 · 85 (=817) -->
<div class="q-px-sm q-pb-xs" @click.stop @mousedown.stop>
<q-input dense outlined v-model="quickEntry" placeholder="Heures : 8-16 · 816 · 830-16" @keyup.enter="applyQuick">
<template #append><q-btn flat dense round size="sm" icon="keyboard_return" color="primary" @click="applyQuick"><q-tooltip>Appliquer</q-tooltip></q-btn></template>
</q-input>
</div>
<!-- Plage personnalisée (slider replié) -->
<q-expansion-item dense dense-toggle icon="tune" label="Personnaliser la plage" header-class="text-caption text-grey-7">
<div class="q-px-md q-pb-sm" @click.stop @mousedown.stop>
<q-range v-model="range" :min="0" :max="24" :step="0.5" snap color="primary" class="q-mt-sm" />
<div class="row items-center no-wrap q-gutter-sm">
<span class="text-caption text-weight-bold">{{ fmtH(range.min) }}h{{ fmtH(range.max) }}h</span>
<q-space />
<q-btn dense unelevated size="sm" color="primary" label="Appliquer" @click="applyRange" />
</div>
</div>
</q-expansion-item>
<q-separator />
<!-- Shifts en place + actions compactes -->
<q-item v-for="a in shifts" :key="'c' + (a.shift || a.name)" dense>
<q-item-section>{{ a.shift_name || a.shift }} <span class="text-grey-6">{{ a.hours }}h</span></q-item-section>
<q-item-section side><q-btn flat dense round size="sm" icon="close" color="grey-7" @click="$emit('remove-shift', a)"><q-tooltip>Retirer</q-tooltip></q-btn></q-item-section>
</q-item>
<div class="row items-center q-px-sm q-py-xs q-gutter-sm">
<q-btn v-if="showCopy" flat dense size="sm" icon="content_copy" color="grey-8" @click="$emit('copy')"><q-tooltip>Copier la case</q-tooltip></q-btn>
<q-btn v-if="showCopy" flat dense size="sm" icon="content_paste" color="grey-8" :disable="!clipboardCount" @click="$emit('paste')"><q-tooltip>Coller{{ clipboardCount ? ' (' + clipboardCount + ')' : '' }}</q-tooltip></q-btn>
<q-space />
<q-btn v-if="shifts.length" flat dense size="sm" icon="layers_clear" color="grey-8" label="Vider" @click="$emit('clear')" />
</div>
</q-list>
</template>
<script setup>
// Menu de cellule réutilisable voir en-tête du template. Émet des INTENTIONS ; le parent exécute (single source of logic).
import { ref, watch } from 'vue'
const props = defineProps({
title: { type: String, default: '' },
shifts: { type: Array, default: () => [] }, // [{ shift, shift_name, hours, name? }]
isGarde: { type: Boolean, default: false },
isAbsent: { type: Boolean, default: false },
clipboardCount: { type: Number, default: 0 },
initialRange: { type: Object, default: () => ({ min: 8, max: 16 }) },
cellKey: { type: String, default: '' }, // change à chaque (ré)ouverture réinitialise saisie/slider
showCopy: { type: Boolean, default: true },
})
const emit = defineEmits(['window', 'toggle-garde', 'toggle-absent', 'remove-shift', 'clear', 'copy', 'paste'])
const quickEntry = ref('')
const range = ref({ min: props.initialRange.min, max: props.initialRange.max })
// Réinitialise à chaque ouverture sur une nouvelle cellule (le composant reste monté, seul le q-menu parent s'affiche/masque).
watch(() => props.cellKey, () => { quickEntry.value = ''; range.value = { min: props.initialRange.min, max: props.initialRange.max } })
function fmtH (h) { const hh = Math.floor(h); const mm = Math.round((h - hh) * 60); return mm ? (hh + ':' + String(mm).padStart(2, '0')) : ('' + hh) }
// Parse « 8-17 » · « 8:30-16 » · « 830-16 » · « 85 » (=817, dernier chiffre en pm si début).
function parseHM (tok) { tok = String(tok).trim().toLowerCase().replace(/h/g, ':').replace(/[^\d:]/g, ''); if (!tok) return null; if (tok.includes(':')) { const [h, m] = tok.split(':'); return Number(h) + (Number(m || 0)) / 60 } if (tok.length >= 3) return Number(tok.slice(0, -2)) + Number(tok.slice(-2)) / 60; return Number(tok) }
function parseQuickShift (str) {
const s = (str || '').trim().toLowerCase(); if (!s) return null
if (/[-–—]|to|→|\s/.test(s)) { const p = s.split(/[-–—]|to|→|\s+/).filter(Boolean); if (p.length < 2) return null; const a = parseHM(p[0]); const b = parseHM(p[1]); return (a == null || b == null || b <= a || b > 24) ? null : { min: a, max: b } }
// Compact SANS séparateur : « 816 »=816 · « 1016 »=1016 · « 0816 »=816 (2 derniers chiffres = fin, le reste = début).
if (/^\d{3,4}$/.test(s)) { const end = Number(s.slice(-2)); const start = Number(s.slice(0, -2)); if (end <= 24 && start >= 0 && start < end) return { min: start, max: end } }
if (/^\d{2}$/.test(s)) { const a = Number(s[0]); let b = Number(s[1]); if (b <= a) b += 12; return (b <= a || b > 24) ? null : { min: a, max: b } } // « 85 »=817
return null
}
function applyQuick () { const r = parseQuickShift(quickEntry.value); if (!r) return; quickEntry.value = ''; emit('window', r) }
function applyRange () { if (range.value.max > range.value.min) emit('window', { min: range.value.min, max: range.value.max }) }
</script>

View File

@ -53,14 +53,15 @@
<span v-for="b in BRUSHES" :key="b.type" class="ts-brush" :class="{ on: brush === b.type }" <span v-for="b in BRUSHES" :key="b.type" class="ts-brush" :class="{ on: brush === b.type }"
:style="brush === b.type ? { background: b.color, borderColor: b.color, color: '#fff' } : { borderColor: b.color, color: b.color }" :style="brush === b.type ? { background: b.color, borderColor: b.color, color: '#fff' } : { borderColor: b.color, color: b.color }"
@click="brush = b.type">{{ b.label }}</span> @click="brush = b.type">{{ b.label }}</span>
<q-space /><span class="text-caption text-grey-5">glisse sur les jours pour appliquer</span> <q-space /><span class="text-caption text-grey-5">glisse pour marquer · re-clique pour annuler · <b>à publier</b></span>
</div> </div>
<!-- Grille --> <!-- Grille clic simple = menu de cellule PARTAGÉ (Jour/Soir/Garde/Absent/heures, comme la semaine/jour) ;
<div class="ts-cal" @mouseleave="endDrag" @mouseup="endDrag"> glisser = pinceau rapide (congés multi-jours pour les vacances). -->
<div class="ts-cal" @mouseleave="cancelDrag" @mouseup="endDrag">
<div v-for="d in DOW_LABELS" :key="'h' + d" class="ts-dowh">{{ d }}</div> <div v-for="d in DOW_LABELS" :key="'h' + d" class="ts-dowh">{{ d }}</div>
<div v-for="(c, i) in monthCells" :key="i" <div v-for="(c, i) in monthCells" :key="i"
class="ts-cell" :class="cellClass(c)" class="ts-cell" :class="cellClass(c)"
@mousedown.prevent="c.inMonth && startDrag(c)" @mousedown.prevent="c.inMonth && startDrag(c, $event)"
@mouseenter="c.inMonth && overDrag(c)"> @mouseenter="c.inMonth && overDrag(c)">
<template v-if="c.inMonth"> <template v-if="c.inMonth">
<span class="ts-daynum">{{ c.day }}</span> <span class="ts-daynum">{{ c.day }}</span>
@ -74,6 +75,16 @@
</template> </template>
</div> </div>
</div> </div>
<!-- Menu de cellule PARTAGÉ (même composant que la grille) ouvert au clic simple sur un jour. -->
<div ref="cellAnchor" style="position:fixed;width:1px;height:1px;pointer-events:none;z-index:0" :style="{ left: cellMenu.x + 'px', top: cellMenu.y + 'px' }"></div>
<q-menu v-model="cellMenu.show" :target="cellAnchor" anchor="bottom right" self="top left" max-height="85vh">
<PlanifCellMenu
:cell-key="cellMenu.iso"
:title="(tech && tech.name) + ' — ' + fmtDayLabel(cellMenu.iso)"
:shifts="menuShifts" :is-garde="menuIsGarde" :is-absent="menuIsAbsent" :show-copy="false"
@window="onMenuWindow" @toggle-garde="onMenuGarde" @toggle-absent="onMenuAbsent"
@remove-shift="onMenuRemoveShift" @clear="onMenuClear" />
</q-menu>
<div class="row items-center q-gutter-md q-mt-xs ts-legend"> <div class="row items-center q-gutter-md q-mt-xs ts-legend">
<span v-for="b in BRUSHES.filter(x => x.type !== 'clear')" :key="'l' + b.type"><i class="ts-dot" :style="{ background: b.color }"></i>{{ b.label }}</span> <span v-for="b in BRUSHES.filter(x => x.type !== 'clear')" :key="'l' + b.type"><i class="ts-dot" :style="{ background: b.color }"></i>{{ b.label }}</span>
<span><i class="ts-dot ts-dot-today"></i>aujourd'hui</span> <span><i class="ts-dot ts-dot-today"></i>aujourd'hui</span>
@ -100,12 +111,16 @@ import { ref, reactive, computed, watch } from 'vue'
import { useQuasar } from 'quasar' import { useQuasar } from 'quasar'
import * as roster from 'src/api/roster' import * as roster from 'src/api/roster'
import { techOccupancy } from 'src/api/dispatch' // occupation/quart par jour du tech grise les jours sans quart, barre d'occupation les jours avec quart import { techOccupancy } from 'src/api/dispatch' // occupation/quart par jour du tech grise les jours sans quart, barre d'occupation les jours avec quart
import PlanifCellMenu from 'src/components/planif/PlanifCellMenu.vue' // menu de cellule PARTAGÉ (identique à la grille semaine/jour)
const props = defineProps({ const props = defineProps({
modelValue: { type: Boolean, default: false }, modelValue: { type: Boolean, default: false },
tech: { type: Object, default: null }, // { id, name, status, ... } tech: { type: Object, default: null }, // { id, name, status, ... }
pendingAbs: { type: Object, default: () => ({}) }, // congés EN ATTENTE (delta local du parent, publish-required) superposés à l'état serveur
gardeMap: { type: Object, default: () => ({}) }, // garde EFFECTIVE du parent (gardeEffective) : clé tech|iso shift
refreshKey: { type: Number, default: 0 }, // par le parent après une écriture de quart recharge le mois
}) })
const emit = defineEmits(['update:modelValue', 'edit-schedule', 'changed']) const emit = defineEmits(['update:modelValue', 'edit-schedule', 'changed', 'stage-abs', 'set-shift', 'remove-shift', 'clear-shifts', 'toggle-garde'])
const $q = useQuasar() const $q = useQuasar()
const err = (e) => $q.notify({ type: 'negative', message: '' + (e.message || e) }) const err = (e) => $q.notify({ type: 'negative', message: '' + (e.message || e) })
@ -145,10 +160,24 @@ const month = ref('') // 'YYYY-MM'
const calLoading = ref(false) const calLoading = ref(false)
const absMap = ref({}) // 'YYYY-MM-DD' type d'absence const absMap = ref({}) // 'YYYY-MM-DD' type d'absence
const shiftMap = ref({}) // 'YYYY-MM-DD' { shift:bool, occupancy:0..1 } (quart réel du tech ce jour) gris = pas de quart const shiftMap = ref({}) // 'YYYY-MM-DD' { shift:bool, occupancy:0..1 } (quart réel du tech ce jour) gris = pas de quart
const asgByDate = ref({}) // 'YYYY-MM-DD' [{ name, shift, shift_name, hours }] (quarts réguliers du tech ce jour) menu « shifts en place »
const holiSet = ref(new Set()) const holiSet = ref(new Set())
const drag = reactive({ on: false, sel: new Set() }) const drag = reactive({ on: false, sel: new Set(), moved: false })
const selCount = computed(() => drag.sel.size) const selCount = computed(() => drag.sel.size)
// Menu de cellule (clic simple) ancré au curseur (comme la grille).
const cellAnchor = ref(null)
const cellMenu = reactive({ show: false, iso: '', x: 0, y: 0 })
const menuShifts = computed(() => asgByDate.value[cellMenu.iso] || [])
const menuIsGarde = computed(() => !!(props.tech && props.gardeMap && props.gardeMap[props.tech.id + '|' + cellMenu.iso]))
const menuIsAbsent = computed(() => !!effAbsType(cellMenu.iso))
function fmtDayLabel (iso) { if (!iso) return ''; return iso.slice(8) + '/' + iso.slice(5, 7) }
// Type d'absence EFFECTIF pour un jour = serveur (absMap) écrasé par le delta EN ATTENTE du parent. null = pas absent.
function effAbsType (iso) {
const p = props.pendingAbs && props.tech && props.pendingAbs[props.tech.id + '|' + iso]
if (p) return p.op === 'set' ? (p.type || 'Congé') : null
return absMap.value[iso] || null
}
const MO = ['janvier', 'février', 'mars', 'avril', 'mai', 'juin', 'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre'] const MO = ['janvier', 'février', 'mars', 'avril', 'mai', 'juin', 'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre']
const monthLabel = computed(() => { const [y, m] = month.value.split('-').map(Number); return MO[m - 1] + ' ' + y }) const monthLabel = computed(() => { const [y, m] = month.value.split('-').map(Number); return MO[m - 1] + ' ' + y })
function todayISO () { return new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }) } function todayISO () { return new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }) }
@ -167,7 +196,7 @@ const monthCells = computed(() => {
const iso = y + '-' + pad(m) + '-' + pad(day) const iso = y + '-' + pad(m) + '-' + pad(day)
const dow = new Date(iso + 'T12:00:00').getUTCDay() const dow = new Date(iso + 'T12:00:00').getUTCDay()
const sh = shiftMap.value[iso] const sh = shiftMap.value[iso]
cells.push({ inMonth: true, day, iso, dow, weekend: dow === 0 || dow === 6, isToday: iso === t, absType: absMap.value[iso] || null, holiday: holiSet.value.has(iso), sel: drag.sel.has(iso), hasShift: !!(sh && sh.shift), occupancy: sh ? sh.occupancy : null }) cells.push({ inMonth: true, day, iso, dow, weekend: dow === 0 || dow === 6, isToday: iso === t, absType: effAbsType(iso), holiday: holiSet.value.has(iso), sel: drag.sel.has(iso), hasShift: !!(sh && sh.shift), occupancy: sh ? sh.occupancy : null })
} }
while (cells.length % 7) cells.push({ inMonth: false }) while (cells.length % 7) cells.push({ inMonth: false })
return cells return cells
@ -195,10 +224,11 @@ async function loadCal () {
const [y, m] = month.value.split('-').map(Number) const [y, m] = month.value.split('-').map(Number)
const start = y + '-' + pad(m) + '-01' const start = y + '-' + pad(m) + '-01'
const days = new Date(Date.UTC(y, m, 0)).getUTCDate() const days = new Date(Date.UTC(y, m, 0)).getUTCDate()
const [absRes, holiRes, occRes] = await Promise.all([ const [absRes, holiRes, occRes, asgRes] = await Promise.all([
roster.getAbsences(start, days), roster.getAbsences(start, days),
roster.holidays(start, y + '-' + pad(m) + '-' + pad(days)), roster.holidays(start, y + '-' + pad(m) + '-' + pad(days)),
techOccupancy({ after_date: start, days, skill: '' }).catch(() => null), // quart + occupation par jour (tous techs) on garde le nôtre techOccupancy({ after_date: start, days, skill: '' }).catch(() => null), // quart + occupation par jour (tous techs) on garde le nôtre
roster.listAssignments(start, days).catch(() => null), // quarts réguliers du mois menu « shifts en place » / retrait
]) ])
const out = {} const out = {}
const abs = (absRes && absRes.absences) || {} const abs = (absRes && absRes.absences) || {}
@ -213,29 +243,41 @@ async function loadCal () {
const mine = occRes && (occRes.techs || []).find(x => x.tech_id === props.tech.id || x.tech_name === props.tech.name) const mine = occRes && (occRes.techs || []).find(x => x.tech_id === props.tech.id || x.tech_name === props.tech.name)
if (mine) for (const d of (mine.days || [])) sh[d.date] = { shift: !d.off, occupancy: d.occupancy } if (mine) for (const d of (mine.days || [])) sh[d.date] = { shift: !d.off, occupancy: d.occupancy }
shiftMap.value = sh shiftMap.value = sh
// Quarts réguliers du mois pour CE tech menu « shifts en place » + retrait par docname.
const abd = {}
for (const a of ((asgRes && asgRes.assignments) || [])) {
if (a.tech !== props.tech.id && a.tech !== props.tech.name) continue
;(abd[a.date] || (abd[a.date] = [])).push({ name: a.name, shift: a.shift, shift_name: a.shift_name || a.shift, hours: a.hours })
}
asgByDate.value = abd
} catch (e) { err(e) } finally { calLoading.value = false } } catch (e) { err(e) } finally { calLoading.value = false }
} }
// Glisser-sélectionner // Interaction : CLIC SIMPLE (sans glisser) = menu de cellule partagé (Jour/Soir/Garde/Absent/heures) ; GLISSER = pinceau
function startDrag (c) { drag.on = true; drag.sel = new Set([c.iso]) } // rapide de congés (multi-jours, vacances). Le congé reste STAGÉ (publish-required) : on émet le delta au parent, 0 écriture ici.
function overDrag (c) { if (drag.on) drag.sel.add(c.iso) } function startDrag (c, ev) { drag.on = true; drag.moved = false; drag.sel = new Set([c.iso]); if (ev) { cellMenu.x = ev.clientX; cellMenu.y = ev.clientY } }
async function endDrag () { function overDrag (c) { if (!drag.on) return; if (!drag.sel.has(c.iso)) drag.moved = true; drag.sel.add(c.iso) }
function cancelDrag () { drag.on = false; drag.sel = new Set(); drag.moved = false }
function endDrag () {
if (!drag.on) return if (!drag.on) return
drag.on = false drag.on = false
const days = [...drag.sel] const days = [...drag.sel]; drag.sel = new Set()
drag.sel = new Set() if (!days.length || !props.tech) return
if (!days.length) return if (!drag.moved) { openCellMenu(days[0]); return } // clic simple menu (pas d'application du pinceau)
const remove = brush.value === 'clear' const changes = days.map(iso => {
const type = remove ? '' : brush.value if (brush.value === 'clear') return { iso, type: '' }
calLoading.value = true const cur = effAbsType(iso)
let ok = 0 return { iso, type: cur === brush.value ? '' : brush.value } // même type toggle off
try { })
for (const iso of days) { emit('stage-abs', { techId: props.tech.id, changes })
try { await roster.setAbsence(props.tech.id, iso, type, remove); ok++; if (remove) delete absMap.value[iso]; else absMap.value[iso] = type } catch (e) { err(e) }
}
if (ok) { $q.notify({ type: 'positive', message: (remove ? 'Effacé' : 'Marqué « ' + type + ' »') + ' — ' + ok + ' jour(s)', timeout: 1800 }); emit('changed', { id: props.tech.id }) }
} finally { calLoading.value = false; absMap.value = { ...absMap.value } }
} }
// Menu de cellule (clic simple) émet des intentions au parent (single source of logic).
function openCellMenu (iso) { cellMenu.iso = iso; cellMenu.show = true }
function onMenuWindow (r) { if (props.tech && r) emit('set-shift', { techId: props.tech.id, iso: cellMenu.iso, min: r.min, max: r.max }); cellMenu.show = false }
function onMenuGarde () { if (props.tech) emit('toggle-garde', { techId: props.tech.id, iso: cellMenu.iso }); cellMenu.show = false }
function onMenuAbsent () { if (props.tech) emit('stage-abs', { techId: props.tech.id, changes: [{ iso: cellMenu.iso, type: menuIsAbsent.value ? '' : 'Congé' }] }); cellMenu.show = false }
function onMenuRemoveShift (a) { if (a && a.name) emit('remove-shift', { name: a.name }); cellMenu.show = false }
function onMenuClear () { if (props.tech) emit('clear-shifts', { techId: props.tech.id, iso: cellMenu.iso, names: (asgByDate.value[cellMenu.iso] || []).map(a => a.name) }); cellMenu.show = false }
// Archivage (réversible) // Archivage (réversible)
const archiveBusy = ref(false) const archiveBusy = ref(false)
@ -262,9 +304,11 @@ watch(() => props.modelValue, (o) => {
pauseNote.value = '' pauseNote.value = ''
const t = todayISO() const t = todayISO()
month.value = t.slice(0, 7) month.value = t.slice(0, 7)
absMap.value = {}; shiftMap.value = {}; holiSet.value = new Set(); drag.on = false; drag.sel = new Set() absMap.value = {}; shiftMap.value = {}; asgByDate.value = {}; holiSet.value = new Set(); drag.on = false; drag.sel = new Set(); cellMenu.show = false
loadCal() loadCal()
}) })
// Le parent a persisté un quart (createShift/deleteAssignment) recharger le mois pour refléter les shifts.
watch(() => props.refreshKey, () => { if (props.modelValue) loadCal() })
</script> </script>
<style scoped> <style scoped>

View File

@ -2,24 +2,35 @@
<q-page class="q-pa-md"> <q-page class="q-pa-md">
<PageHeader title="Événements" /> <PageHeader title="Événements" />
<!-- Bandeau événement --> <!-- Bandeau événement + sélecteur -->
<div class="ops-card q-pa-md q-mb-md" style="border-radius:12px"> <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="row items-center ops-row-wrap q-gutter-sm">
<div class="ev-badge"><b>20</b><span>ANS</span></div> <div class="ev-badge">
<div class="col" style="min-width:0"> <template v-if="ev.badge_top || ev.badge_bottom"><b>{{ ev.badge_top || '🎉' }}</b><span v-if="ev.badge_bottom">{{ ev.badge_bottom }}</span></template>
<div class="text-h6 text-weight-bold ellipsis">{{ ev.name || 'Targo fête ses 20 ans' }}</div> <span v-else style="font-size:1.4rem">🎉</span>
</div>
<div class="col" style="min-width:180px">
<q-select v-if="events.length > 1" dense borderless v-model="eventId" :options="eventOptions" emit-value map-options
class="text-h6 text-weight-bold" @update:model-value="onSelectEvent" popup-content-class="text-body1" />
<div v-else class="text-h6 text-weight-bold ellipsis">{{ ev.name || '—' }}</div>
<div class="text-caption text-grey-7"> <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' }} <q-icon name="event" size="16px" class="q-mr-xs" />{{ ev.when || '—' }}
<span v-if="ev.capacity" class="q-ml-sm"><q-icon name="groups" size="16px" class="q-mr-xs" />plafond {{ ev.capacity }} pers.</span>
</div> </div>
</div> </div>
<q-btn flat dense icon="refresh" :loading="loading" @click="load" class="col-auto"><q-tooltip>Rafraîchir</q-tooltip></q-btn> <q-btn flat dense no-caps icon="edit" label="Modifier" @click="openEdit" class="col-auto" :disable="!eventId" />
<q-btn unelevated dense no-caps color="primary" icon="add" label="Nouvel événement" @click="openCreate" class="col-auto" />
<q-btn flat dense round icon="refresh" :loading="loading" @click="load" class="col-auto"><q-tooltip>Rafraîchir</q-tooltip></q-btn>
</div> </div>
</div> </div>
<!-- KPIs --> <!-- KPIs -->
<div class="row q-col-gutter-sm q-mb-md"> <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="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="Personnes attendues" :value="capacityLabel" icon="groups" :color="isFull ? 'red-7' : 'green-7'"
:value-class="isFull ? 'text-red-7' : ''" :hint="ev.capacity ? (isFull ? 'COMPLET — formulaire fermé' : 'Total / plafond') : '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 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>
@ -40,6 +51,9 @@
<div class="text-caption text-grey-7 q-mb-sm"> <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. Chaque client reçoit un lien <b>personnalisé</b> : son numéro est pré-rempli et son inscription est rattachée automatiquement.
</div> </div>
<div v-if="attachments.length" class="text-caption text-grey-7 q-mb-sm">
<q-icon name="attach_file" size="14px" /> {{ attachments.length }} pièce(s) jointe(s) au courriel
</div>
<q-btn unelevated no-caps color="primary" icon="send" label="Préparer l'envoi" @click="prepSend" /> <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 class="text-caption text-grey-5 q-mt-sm">Aucun courriel n'est envoyé sans confirmation.</div>
</div> </div>
@ -68,7 +82,8 @@
no-data-label="Aucune inscription pour l'instant."> no-data-label="Aucune inscription pour l'instant.">
<template #body-cell-client="props"> <template #body-cell-client="props">
<q-td :props="props"> <q-td :props="props">
<div class="text-weight-medium ellipsis">{{ props.row.customer_name || props.row.customer_number || '—' }}</div> <router-link v-if="props.row.customer_id" :to="'/clients/' + encodeURIComponent(props.row.customer_id)" class="text-weight-medium ellipsis erp-link" style="display:block">{{ props.row.customer_name || props.row.customer_number || '' }}</router-link>
<div v-else 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> <div class="text-caption text-grey-6">{{ props.row.customer_number }}</div>
</q-td> </q-td>
</template> </template>
@ -107,6 +122,129 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Dialogue de création / édition d'un événement -->
<q-dialog v-model="showForm" persistent>
<q-card style="min-width:340px;max-width:720px;width:100%">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6">{{ formMode === 'create' ? 'Nouvel événement' : 'Modifier l\'événement' }}</div><q-space />
<q-btn flat round dense icon="close" v-close-popup />
</q-card-section>
<q-card-section style="max-height:70vh;overflow:auto">
<div class="row q-col-gutter-sm">
<q-input class="col-12 col-sm-8" v-model="form.fr.name" dense outlined label="Nom de l'événement *" hint="Requis" />
<q-input class="col-6 col-sm-4" v-model="form.date_iso" dense outlined type="date" label="Date" stack-label />
</div>
<div class="row q-col-gutter-sm q-mt-none">
<q-input class="col-4 col-sm-2" v-model="form.badge_top" dense outlined label="Badge (haut)" maxlength="6" hint="ex. 20" />
<q-input class="col-8 col-sm-3" v-model="form.badge_bottom" dense outlined label="Badge (bas)" maxlength="8" hint="ex. ANS" />
<q-input class="col-6 col-sm-4" v-model.number="form.capacity" dense outlined type="number" min="0" label="Plafond (personnes)" hint="0 = illimité">
<template #prepend><q-icon name="groups" /></template>
</q-input>
<div class="col-6 col-sm-3 flex items-center"><q-toggle v-model="form.active" label="Actif" /></div>
</div>
<q-tabs v-model="formLang" dense class="text-grey-8 q-mt-md" active-color="primary" indicator-color="primary" align="left" narrow-indicator>
<q-tab name="fr" label="Français" />
<q-tab name="en" label="English (optionnel)" />
</q-tabs>
<q-separator />
<q-tab-panels v-model="formLang" animated class="bg-transparent">
<q-tab-panel v-for="lg in ['fr', 'en']" :key="lg" :name="lg" class="q-px-none q-gutter-sm">
<div v-if="lg === 'en'" class="text-caption text-grey-6">Laissez vide pour reprendre le texte français automatiquement.</div>
<q-input v-if="lg === 'en'" v-model="form.en.name" dense outlined label="Name" />
<q-input v-model="form[lg].tagline" dense outlined autogrow label="Accroche" />
<q-input v-model="form[lg].when" dense outlined :label="lg === 'fr' ? 'Quand (texte)' : 'When (text)'" hint="ex. 1ᵉʳ août, de 10 h à 15 h" />
<q-input v-model="form[lg].body" dense outlined type="textarea" autogrow :label="lg === 'fr' ? 'Texte d\'invitation' : 'Invitation text'" />
<div class="text-weight-medium text-grey-8 q-mt-sm">Programme</div>
<div v-for="(p, i) in form[lg].program" :key="i" class="row q-col-gutter-xs items-center">
<q-input class="col-2" v-model="p.icon" dense outlined label="Icône" maxlength="4" />
<q-input class="col" v-model="p.label" dense outlined label="Libellé" />
<q-input class="col-3" v-model="p.sub" dense outlined label="Détail" />
<q-btn class="col-auto" flat dense round icon="close" color="grey-7" @click="form[lg].program.splice(i, 1)" />
</div>
<q-btn flat dense no-caps size="sm" icon="add" label="Ajouter un élément" color="primary" @click="form[lg].program.push({ icon: '', label: '', sub: '' })" />
<q-input v-model="form[lg].program_line" dense outlined class="q-mt-sm" :label="lg === 'fr' ? 'Ligne sous le programme' : 'Line under program'" />
<q-input v-model="form[lg].limited" dense outlined type="textarea" autogrow :label="lg === 'fr' ? 'Encadré « places limitées »' : 'Limited-space note'" />
<q-input v-model="form[lg].closing" dense outlined :label="lg === 'fr' ? 'Phrase de clôture' : 'Closing line'" />
<div class="text-weight-medium text-grey-8 q-mt-sm">Notes de bas de page</div>
<div v-for="(n, i) in form[lg].notes" :key="'n' + i" class="row q-col-gutter-xs items-center">
<q-input class="col" v-model="form[lg].notes[i]" dense outlined :label="'Note ' + (i + 1)" />
<q-btn class="col-auto" flat dense round icon="close" color="grey-7" @click="form[lg].notes.splice(i, 1)" />
</div>
<q-btn flat dense no-caps size="sm" icon="add" label="Ajouter une note" color="primary" @click="form[lg].notes.push('')" />
</q-tab-panel>
</q-tab-panels>
<!-- Modèle de courriel d'invitation festif intégré OU template éditable (Unlayer, comme les campagnes) -->
<q-separator class="q-my-md" />
<div class="text-weight-medium text-grey-8 q-mb-xs"><q-icon name="mail" size="18px" /> Modèle du courriel d'invitation</div>
<q-select v-model="form.email_template" :options="emailTemplateOptions" emit-value map-options dense outlined
label="Modèle" hint="« Festif intégré » = charte TARGO verrouillée. Un template éditable se conçoit dans l'éditeur (glisser-déposer)." />
<div class="row q-gutter-sm q-mt-xs">
<template v-if="formMode === 'edit'">
<q-btn outline no-caps size="sm" color="primary" icon="visibility" label="Aperçu" :loading="previewLoading" @click="openInvitePreview" />
<q-btn v-if="form.email_template" flat no-caps size="sm" color="primary" icon="brush" label="Concevoir dans l'éditeur"
type="a" :href="'/ops/#/campaigns/templates/' + form.email_template" target="_blank">
<q-tooltip>Ouvre l'éditeur glisser-déposer (nouvel onglet). Variables : firstname, rsvp_url.</q-tooltip>
</q-btn>
</template>
<div v-else class="text-caption text-grey-6">Aperçu et conception disponibles après la création.</div>
</div>
<!-- Pièces jointes (courriel) seulement en édition (l'événement doit exister) -->
<template v-if="formMode === 'edit'">
<q-separator class="q-my-md" />
<div class="text-weight-medium text-grey-8 q-mb-xs"><q-icon name="attach_file" size="18px" /> Pièces jointes au courriel d'invitation</div>
<div class="text-caption text-grey-6 q-mb-sm">Images (PNG/JPG/GIF/WebP) ou PDF, max 4 Mo · jusqu'à 6 fichiers. Chaque client reçoit les fichiers de <b>sa langue</b> (+ les fichiers « Les deux »).</div>
<q-list bordered separator class="rounded-borders q-mb-sm" v-if="attachments.length">
<q-item v-for="a in attachments" :key="a.stored" dense>
<q-item-section avatar><q-icon :name="/pdf/.test(a.mime) ? 'picture_as_pdf' : 'image'" color="grey-7" /></q-item-section>
<q-item-section><q-item-label>{{ a.filename }}</q-item-label><q-item-label caption>{{ (a.size / 1024).toFixed(0) }} Ko</q-item-label></q-item-section>
<q-item-section side><q-badge :color="attLangColor(a.lang)" :label="attLangLabel(a.lang)" /></q-item-section>
<q-item-section side><q-btn flat dense round icon="delete" color="negative" @click="removeAtt(a)" /></q-item-section>
</q-item>
</q-list>
<div class="row q-col-gutter-sm items-center">
<q-select class="col-12 col-sm-5" v-model="attLang" dense outlined label="Langue du fichier"
:options="[{ label: 'Français', value: 'fr' }, { label: 'English', value: 'en' }, { label: 'Les deux', value: 'both' }, { label: 'Détecter (Gemini)', value: 'auto' }]"
emit-value map-options />
<q-file class="col" v-model="attFile" dense outlined label="Ajouter une pièce jointe" accept="image/*,application/pdf" :loading="uploadingAtt" @update:model-value="onAttFile">
<template #prepend><q-icon name="upload_file" /></template>
</q-file>
</div>
</template>
</q-card-section>
<q-card-actions align="right">
<q-btn v-if="formMode === 'edit'" flat no-caps color="negative" icon="delete" label="Supprimer" @click="confirmDeleteEvent" />
<q-space />
<q-btn flat no-caps label="Annuler" v-close-popup />
<q-btn unelevated no-caps color="primary" :label="formMode === 'create' ? 'Créer' : 'Enregistrer'" :loading="savingForm" @click="saveForm" />
</q-card-actions>
</q-card>
</q-dialog>
<!-- Aperçu de l'invitation (rendu HTML, échantillon de variables) -->
<q-dialog v-model="invitePreviewOpen" maximized>
<q-card class="bg-grey-2">
<q-toolbar class="bg-white" style="border-bottom:1px solid #e5e7eb">
<q-icon name="visibility" class="q-mr-sm" />
<q-toolbar-title>Aperçu de l'invitation</q-toolbar-title>
<q-btn-toggle v-model="invitePreviewLang" dense no-caps unelevated toggle-color="primary" color="grey-3" text-color="grey-8"
:options="[{ label: 'FR', value: 'fr' }, { label: 'EN', value: 'en' }]" @update:model-value="loadInvitePreview" class="q-mr-sm" />
<q-btn flat dense round icon="close" v-close-popup />
</q-toolbar>
<q-card-section style="height:calc(100vh - 60px);overflow:hidden">
<iframe :srcdoc="invitePreviewHtml" style="width:100%;height:100%;border:1px solid #e5e7eb;background:#fff" />
</q-card-section>
</q-card>
</q-dialog>
<!-- Dialogue d'envoi : test OU audience de masse (aperçu ; le blast reste à activer) --> <!-- Dialogue d'envoi : test OU audience de masse (aperçu ; le blast reste à activer) -->
<q-dialog v-model="showSend"> <q-dialog v-model="showSend">
<q-card style="min-width:340px;max-width:560px;width:100%"> <q-card style="min-width:340px;max-width:560px;width:100%">
@ -122,31 +260,107 @@
<!-- TEST --> <!-- TEST -->
<q-card-section v-if="sendMode === 'test'" class="q-gutter-sm"> <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> <div class="text-caption text-grey-7">Envoi d'un test (courriel avec lien personnalisé{{ attachments.length ? ' + pièces jointes de la langue choisie' : '' }}) à 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-btn-toggle v-model="sendChannel" spread no-caps dense :options="[{ label: 'Mailjet (suivi)', value: 'mailjet' }, { label: 'Gmail direct', value: 'gmail' }]" />
<q-btn-toggle v-model="testLang" spread no-caps dense :options="[{ label: 'Français', value: 'fr' }, { label: 'English', value: 'en' }]" />
<q-input v-model="testEmail" dense outlined type="email" label="Courriel de test" autofocus /> <q-input v-model="testEmail" dense outlined type="email" label="Courriel de test" autofocus />
</q-card-section> </q-card-section>
<!-- MASSE --> <!-- MASSE -->
<q-card-section v-else class="q-gutter-sm"> <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" /> <div class="text-caption text-grey-7">
<q-icon name="mail" size="14px" /> Modèle : <b>{{ ev.email_template || 'Festif intégré (TARGO)' }}</b> se change/conçoit dans « Modifier ».
</div>
<q-btn-toggle v-model="sendChannel" spread no-caps dense :options="[{ label: 'Mailjet (suivi)', value: 'mailjet' }, { label: 'Gmail direct', value: 'gmail' }]" /> <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-caption text-grey-7"><q-icon name="info" size="14px" /> Mailjet = suivi ouvertures/clics. Gmail direct = aucun suivi.</div>
<div v-if="attachments.length" class="text-caption text-grey-7"><q-icon name="attach_file" size="14px" /> {{ attachments.length }} pièce(s) jointe(s) gérées dans « Modifier ».</div>
<div class="text-weight-medium text-grey-8 q-mt-sm">Audience</div> <!-- LISTE PRINCIPALE (additive, persistée) -->
<q-btn-toggle v-model="audienceSource" spread no-caps dense @update:model-value="audPreview = null" <div class="ops-card q-pa-sm" style="border-radius:10px;border:1px solid var(--ops-border,#e0e0e0)">
:options="[{ label: 'Liste clients', value: 'list' }, { label: 'Import CSV', value: 'csv' }]" /> <div class="row items-center">
<div class="text-subtitle2 text-weight-bold"><q-icon name="list_alt" class="q-mr-xs" />Liste d'envoi : {{ mainList.count }} destinataire(s)</div>
<q-space />
<q-btn v-if="mainList.count" flat dense no-caps size="sm" color="negative" icon="delete_sweep" label="Vider" :loading="listBusy" @click="clearList" />
</div>
<div v-if="mainListSources.length" class="q-mt-xs">
<q-chip v-for="s in mainListSources" :key="s.label" dense square size="sm" color="blue-1" text-color="blue-10">{{ s.label }} · {{ s.n }}</q-chip>
</div>
<div v-if="mainList.count" class="q-mt-xs">
<a class="cursor-pointer text-caption text-primary" @click="showListSample = !showListSample">{{ showListSample ? '▾ Masquer' : '▸ Voir' }} les destinataires</a>
<q-list v-if="showListSample" dense separator bordered class="rounded-borders q-mt-xs" style="max-height:200px;overflow:auto">
<q-item v-for="r in mainList.sample" :key="r.email" dense>
<q-item-section>
<q-item-label class="text-caption">{{ r.name }} <q-badge dense :color="r.language === 'en' ? 'deep-orange-6' : 'blue-6'" :label="(r.language || 'fr').toUpperCase()" class="q-ml-xs" /></q-item-label>
<q-item-label caption>{{ r.email }}<span v-if="r.source"> · {{ r.source }}</span></q-item-label>
</q-item-section>
<q-item-section side><q-btn flat dense round size="sm" icon="close" @click="removeFromList(r)" /></q-item-section>
</q-item>
<q-item v-if="mainList.count > mainList.sample.length" dense><q-item-section class="text-caption text-grey-6">et {{ mainList.count - mainList.sample.length }} autres (affichage plafonné à 200)</q-item-section></q-item>
</q-list>
</div>
<div v-else class="text-caption text-grey-6 q-mt-xs">Ajoutez des lots ci-dessous (par filtre, sélection ou CSV). Ils s'additionnent ici, sans doublon.</div>
</div>
<div class="text-weight-medium text-grey-8 q-mt-sm">Ajouter des destinataires</div>
<q-btn-toggle v-model="audienceSource" spread no-caps dense
:options="[{ label: 'Par filtre', value: 'list' }, { label: 'Sélection', value: 'manual' }, { label: 'CSV', value: 'csv' }]" />
<div v-if="audienceSource === 'list'" class="q-gutter-sm"> <div v-if="audienceSource === 'list'" class="q-gutter-sm">
<div class="row q-col-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.statut" :options="[{ label: 'Clients actifs', value: 'active' }, { label: 'Tous (incl. résiliés)', value: '' }]" dense outlined emit-value map-options label="Statut" />
<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" /> <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" />
</div>
<div class="row q-col-gutter-sm">
<q-input class="col-12 col-sm-6" v-model="audFilters.city" dense outlined clearable label="Ville" placeholder="ex. Gatineau">
<template #prepend><q-icon name="location_city" /></template>
</q-input>
<q-input class="col-12 col-sm-6" v-model="audFilters.name_like" dense outlined clearable label="Nom du compte contient" placeholder="ex. Proprio">
<template #prepend><q-icon name="search" /></template>
</q-input>
</div> </div>
<q-toggle v-model="audFilters.active_sub" label="Abonnement actif seulement" @update:model-value="onActiveSubToggle" /> <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"> <q-input v-model.number="audFilters.min_monthly" type="number" min="0" dense outlined :disable="!audFilters.active_sub" label="Total mensuel minimum ($/mois)">
<template #prepend><q-icon name="attach_money" /></template> <template #prepend><q-icon name="attach_money" /></template>
</q-input> </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 class="text-caption text-grey-6">« Ville » filtre par lieu de service ; « Nom contient » par sous-chaîne. Les critères se combinent (ET). Le « total mensuel » requiert « Abonnement actif ».</div>
<q-btn unelevated no-caps color="primary" icon="playlist_add" label="Ajouter ces clients à la liste" :loading="listBusy" @click="addFilterToList" />
</div>
<div v-else-if="audienceSource === 'manual'" class="q-gutter-sm">
<q-input v-model="poolQuery" dense outlined clearable debounce="300" :loading="poolSearching"
label="Rechercher un client (nom, n°, courriel)…" @update:model-value="onPoolSearch">
<template #prepend><q-icon name="person_search" /></template>
</q-input>
<!-- Résultats en temps réel : cliquer « + » ajoute au lot, la liste reste affichée -->
<q-list v-if="poolResults.length" bordered separator class="rounded-borders" style="max-height:200px;overflow:auto">
<q-item v-for="m in poolResults" :key="m.customer_id" dense>
<q-item-section>
<q-item-label>{{ m.customer_name }}<q-badge v-if="m.matched" dense color="grey-4" text-color="grey-8" :label="m.matched" class="q-ml-xs" /></q-item-label>
<q-item-label caption>{{ m.customer_id }}<span v-if="m.email"> · {{ m.email }}</span><span v-else class="text-orange-8"> · sans courriel</span></q-item-label>
</q-item-section>
<q-item-section side>
<q-btn flat dense round size="sm" :icon="inPool(m) ? 'check' : 'add'" :color="inPool(m) ? 'green-7' : 'primary'" :disable="inPool(m)" @click="addToPool(m)">
<q-tooltip>{{ inPool(m) ? 'Déjà dans le lot' : 'Ajouter au lot' }}</q-tooltip>
</q-btn>
</q-item-section>
</q-item>
</q-list>
<div v-else-if="(poolQuery || '').trim().length >= 2 && !poolSearching" class="text-caption text-grey-6">Aucun résultat pour « {{ poolQuery }} ».</div>
<div v-else-if="!pool.length" class="text-caption text-grey-6">Tapez au moins 2 caractères. Recherche floue (tolère les fautes et accents).</div>
<div v-if="pool.length" class="q-gutter-xs q-pt-xs">
<div class="text-caption text-weight-medium text-grey-8">Lot sélectionné : {{ pool.length }}</div>
<q-list bordered separator class="rounded-borders" style="max-height:150px;overflow:auto">
<q-item v-for="p in pool" :key="p.customer_id" dense>
<q-item-section>
<q-item-label>{{ p.customer_name || p.customer_id }}</q-item-label>
<q-item-label caption>{{ p.customer_id }}<span v-if="p.email"> · {{ p.email }}</span><span v-else class="text-orange-8"> · sans courriel (sera ignoré)</span></q-item-label>
</q-item-section>
<q-item-section side><q-btn flat dense round size="sm" icon="close" @click="removeFromPool(p)" /></q-item-section>
</q-item>
</q-list>
<q-btn unelevated no-caps color="primary" icon="playlist_add" :label="`Ajouter les ${pool.length} sélectionné(s) à la liste`" :loading="listBusy" @click="addManualToList" />
</div>
</div> </div>
<div v-else class="q-gutter-sm"> <div v-else class="q-gutter-sm">
@ -154,29 +368,9 @@
<template #prepend><q-icon name="upload_file" /></template> <template #prepend><q-icon name="upload_file" /></template>
</q-file> </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 class="text-caption text-grey-6">Colonne obligatoire : <b>email</b>. Optionnelles : firstname, lastname, language (fr/en), customer_id, phone.</div>
<q-btn unelevated no-caps color="primary" icon="playlist_add" label="Ajouter le CSV à la liste" :disable="!audCsvText" :loading="listBusy" @click="addCsvToList" />
</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"> <q-banner dense rounded class="bg-grey-2 text-grey-8">
<template #avatar><q-icon name="lock" color="grey-7" /></template> <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. L'envoi réel (création de la campagne + envoi suivi) est la dernière étape à activer — rien n'est envoyé aux clients ici.
@ -202,15 +396,22 @@ import DynamicFilter from 'src/components/shared/DynamicFilter.vue'
import { formatDateTimeShort as fmtDate } from 'src/composables/useFormatters' import { formatDateTimeShort as fmtDate } from 'src/composables/useFormatters'
import { useCsvExport } from 'src/composables/useCsvExport' import { useCsvExport } from 'src/composables/useCsvExport'
import { usePermissions } from 'src/composables/usePermissions' import { usePermissions } from 'src/composables/usePermissions'
import { listRsvps, deleteRsvp, sendInvite, previewAudience, listTemplates } from 'src/api/events' import {
listEvents, listRsvps, deleteRsvp, sendInvite, listTemplates,
getEventConfig, createEvent, updateEvent, deleteEvent, uploadAttachment, deleteAttachment,
getAudienceList, audienceListAdd, audienceListRemove, audienceListClear, searchCustomersFuzzy,
getInvitePreview,
} from 'src/api/events'
const $q = useQuasar() const $q = useQuasar()
const { exportCsv } = useCsvExport() const { exportCsv } = useCsvExport()
const EVENT_ID = 'fete-20-ans'
const loading = ref(false) const loading = ref(false)
const error = ref('') const error = ref('')
const events = ref([])
const eventId = ref('')
const ev = ref({}) const ev = ref({})
const attachments = ref([])
const rows = ref([]) const rows = ref([])
const count = ref(0) const count = ref(0)
const headcount = ref(0) const headcount = ref(0)
@ -221,20 +422,30 @@ const { userEmail } = usePermissions()
const showSend = ref(false) const showSend = ref(false)
const sendChannel = ref('mailjet') const sendChannel = ref('mailjet')
const testEmail = ref('') const testEmail = ref('')
const testLang = ref('fr')
const sending = ref(false) const sending = ref(false)
// Envoi de masse (audience + modèle) aperçu seulement ; le blast reste à activer. // Envoi de masse (audience + modèle) aperçu seulement ; le blast reste à activer.
const sendMode = ref('test') const sendMode = ref('test')
const template = ref('')
const templates = ref([{ value: '', label: 'Invitation Fête 20 ans (TARGO)' }]) const templates = ref([{ value: '', label: 'Invitation Fête 20 ans (TARGO)' }])
const audienceSource = ref('list') const audienceSource = ref('list')
const audFilters = ref({ statut: 'active', customer_type: '', active_sub: false, min_monthly: 0 }) const audFilters = ref({ statut: 'active', customer_type: '', city: '', name_like: '', active_sub: false, min_monthly: 0 })
const csvFile = ref(null) const csvFile = ref(null)
const audCsvText = ref('') const audCsvText = ref('')
const audPreview = ref(null) // Pool manuel : recherche temps réel résultats sélection avant ajout à la liste
const previewing = ref(false) const pool = ref([])
const showNoEmail = ref(false) const poolQuery = ref('')
const poolResults = ref([])
const poolSearching = ref(false)
// Liste principale (additive, persistée côté hub)
const mainList = ref({ count: 0, by_source: {}, sample: [], updated: '' })
const listBusy = ref(false)
const showListSample = ref(false)
const mainListSources = computed(() => Object.entries(mainList.value.by_source || {}).map(([label, n]) => ({ label, n })).sort((a, b) => b.n - a.n))
const eventOptions = computed(() => events.value.map(e => ({ value: e.id, label: e.name || e.id })))
const publicUrl = computed(() => ev.value.public_url || '') const publicUrl = computed(() => ev.value.public_url || '')
const isFull = computed(() => ev.value.capacity > 0 && headcount.value >= ev.value.capacity)
const capacityLabel = computed(() => ev.value.capacity ? `${headcount.value} / ${ev.value.capacity}` : headcount.value)
const filterState = ref({ search: '', chips: {} }) 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 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' }] }]
@ -262,11 +473,27 @@ const filtered = computed(() => {
}) })
}) })
async function loadEventList (preferId) {
try {
const d = await listEvents()
events.value = d.events || []
if (!events.value.length) { eventId.value = ''; return }
if (preferId && events.value.some(e => e.id === preferId)) eventId.value = preferId
else if (!eventId.value || !events.value.some(e => e.id === eventId.value)) {
const active = events.value.find(e => e.active) || events.value[0]
eventId.value = active.id
}
} catch (e) { /* laisse load() rapporter l'erreur */ }
}
async function load () { async function load () {
if (!events.value.length) await loadEventList()
if (!eventId.value) { loading.value = false; return }
loading.value = true; error.value = '' loading.value = true; error.value = ''
try { try {
const d = await listRsvps(EVENT_ID) const d = await listRsvps(eventId.value)
ev.value = d.event || {} ev.value = d.event || {}
attachments.value = (d.event && d.event.attachments) || []
rows.value = d.responses || [] rows.value = d.responses || []
count.value = d.count || 0 count.value = d.count || 0
headcount.value = d.headcount || 0 headcount.value = d.headcount || 0
@ -277,6 +504,8 @@ async function load () {
} finally { loading.value = false } } finally { loading.value = false }
} }
function onSelectEvent () { load() }
function copy (txt) { function copy (txt) {
if (!txt) return if (!txt) return
navigator.clipboard?.writeText(txt).then(() => $q.notify({ type: 'positive', message: 'Lien copié', timeout: 1200 })) navigator.clipboard?.writeText(txt).then(() => $q.notify({ type: 'positive', message: 'Lien copié', timeout: 1200 }))
@ -288,18 +517,171 @@ function exportRows () {
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 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 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), '', '', '', '', '', '', '', ''] const totalRow = ['TOTAL', '', '', '', '', filtered.value.reduce((s, r) => s + (r.party_size || 0), 0), '', '', '', '', '', '', '', '']
exportCsv(`inscriptions_${EVENT_ID}`, headers, data, { totalRow }) exportCsv(`inscriptions_${eventId.value}`, headers, data, { totalRow })
} }
function confirmDelete (row) { 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 }) $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 () => { .onOk(async () => {
try { await deleteRsvp(EVENT_ID, row.key); $q.notify({ type: 'positive', message: 'Retiré' }); load() } try { await deleteRsvp(eventId.value, row.key); $q.notify({ type: 'positive', message: 'Retiré' }); load() }
catch (e) { $q.notify({ type: 'negative', message: e.message }) } 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 } // Création / édition d'un événement
const showForm = ref(false)
const formMode = ref('create')
const formLang = ref('fr')
const savingForm = ref(false)
const attFile = ref(null)
const attLang = ref('both')
const uploadingAtt = ref(false)
function attLangLabel (l) { return l === 'fr' ? 'FR' : l === 'en' ? 'EN' : 'Les deux' }
function attLangColor (l) { return l === 'fr' ? 'blue-7' : l === 'en' ? 'deep-orange-7' : 'grey-6' }
const emptyLang = () => ({ name: '', tagline: '', when: '', body: '', program: [], program_line: '', limited: '', closing: '', notes: [] })
const emptyForm = () => ({ id: '', active: true, date_iso: '', badge_top: '', badge_bottom: '', capacity: 0, email_template: '', fr: emptyLang(), en: emptyLang() })
const form = ref(emptyForm())
// Options du sélecteur de modèle : festif intégré + templates éditables (/campaigns/templates)
const emailTemplateOptions = computed(() => {
const opts = templates.value.map(t => ({ value: t.value, label: t.value === '' ? 'Modèle festif intégré (TARGO)' : t.label }))
if (!opts.some(o => o.value === '')) opts.unshift({ value: '', label: 'Modèle festif intégré (TARGO)' })
return opts
})
// Aperçu de l'invitation
const invitePreviewOpen = ref(false)
const invitePreviewLang = ref('fr')
const invitePreviewHtml = ref('')
const previewLoading = ref(false)
function openCreate () {
form.value = emptyForm()
formMode.value = 'create'
formLang.value = 'fr'
if (templates.value.length <= 1) loadTemplates()
showForm.value = true
}
async function openEdit () {
if (!eventId.value) return
try {
const d = await getEventConfig(eventId.value)
const c = d.event || {}
const f = emptyForm()
f.id = c.id || eventId.value
f.active = c.active !== false
f.date_iso = c.date_iso || ''
f.badge_top = c.badge_top || ''
f.badge_bottom = c.badge_bottom || ''
f.capacity = c.capacity || 0
f.email_template = c.email_template || ''
for (const lg of ['fr', 'en']) {
const src = c[lg] || {}
f[lg] = {
name: src.name || '', tagline: src.tagline || '', when: src.when || '', body: src.body || '',
program: Array.isArray(src.program) ? src.program.map(p => ({ icon: p.icon || '', label: p.label || '', sub: p.sub || '' })) : [],
program_line: src.program_line || '', limited: src.limited || '', closing: src.closing || '',
notes: Array.isArray(src.notes) ? [...src.notes] : [],
}
}
form.value = f
formMode.value = 'edit'
formLang.value = 'fr'
if (templates.value.length <= 1) loadTemplates()
showForm.value = true
} catch (e) { $q.notify({ type: 'negative', message: e.message }) }
}
async function loadInvitePreview () {
previewLoading.value = true
try {
const r = await getInvitePreview(form.value.id, { lang: invitePreviewLang.value, template: form.value.email_template })
invitePreviewHtml.value = r.html || ''
} catch (e) { $q.notify({ type: 'negative', message: 'Aperçu impossible : ' + e.message }); invitePreviewHtml.value = '' } finally { previewLoading.value = false }
}
async function openInvitePreview () { invitePreviewOpen.value = true; await loadInvitePreview() }
function cleanLang (c) {
return {
name: (c.name || '').trim(), tagline: (c.tagline || '').trim(), when: (c.when || '').trim(), body: (c.body || '').trim(),
program: (c.program || []).filter(p => (p.label || '').trim() || (p.icon || '').trim()),
program_line: (c.program_line || '').trim(), limited: (c.limited || '').trim(), closing: (c.closing || '').trim(),
notes: (c.notes || []).map(n => (n || '').trim()).filter(Boolean),
}
}
async function saveForm () {
if (!form.value.fr.name.trim()) { $q.notify({ type: 'warning', message: 'Le nom (français) est requis' }); formLang.value = 'fr'; return }
savingForm.value = true
try {
const enHasContent = Object.values(form.value.en).some(v => Array.isArray(v) ? v.length : String(v || '').trim())
const body = {
active: form.value.active,
date_iso: form.value.date_iso,
badge_top: form.value.badge_top,
badge_bottom: form.value.badge_bottom,
capacity: form.value.capacity,
email_template: form.value.email_template || '',
fr: cleanLang(form.value.fr),
...(enHasContent ? { en: cleanLang(form.value.en) } : {}),
}
if (formMode.value === 'create') {
const r = await createEvent(body)
$q.notify({ type: 'positive', message: 'Événement créé' })
showForm.value = false
await loadEventList(r.event && r.event.id)
await load()
} else {
await updateEvent(form.value.id, body)
$q.notify({ type: 'positive', message: 'Événement enregistré' })
showForm.value = false
await loadEventList(form.value.id)
await load()
}
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { savingForm.value = false }
}
function confirmDeleteEvent () {
const seeded = form.value.id === 'fete-20-ans'
$q.dialog({
title: 'Supprimer l\'événement',
message: seeded ? 'Réinitialiser cet événement à sa configuration par défaut ? Les inscriptions sont conservées.' : `Supprimer « ${form.value.fr.name} » ? Les inscriptions déjà reçues sont conservées.`,
cancel: true, persistent: true, ok: { color: 'negative', label: seeded ? 'Réinitialiser' : 'Supprimer' },
}).onOk(async () => {
try {
await deleteEvent(form.value.id)
$q.notify({ type: 'positive', message: seeded ? 'Réinitialisé' : 'Supprimé' })
showForm.value = false
eventId.value = ''
events.value = []
await loadEventList()
await load()
} catch (e) { $q.notify({ type: 'negative', message: e.message }) }
})
}
// Pièces jointes (édition)
function onAttFile (file) {
if (!file) return
if (file.size > 4 * 1024 * 1024) { $q.notify({ type: 'warning', message: 'Fichier trop volumineux (max 4 Mo)' }); attFile.value = null; return }
uploadingAtt.value = true
const rd = new FileReader()
rd.onload = async () => {
try {
const data = String(rd.result || '').replace(/^data:[^,]*,/, '')
const r = await uploadAttachment(form.value.id, { filename: file.name, mime: file.type || 'application/octet-stream', data, lang: attLang.value })
attachments.value = r.attachments || []
const added = r.attachment
$q.notify({ type: 'positive', message: `Pièce jointe ajoutée (${attLangLabel(added ? added.lang : attLang.value)})` })
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { uploadingAtt.value = false; attFile.value = null }
}
rd.onerror = () => { uploadingAtt.value = false; attFile.value = null; $q.notify({ type: 'negative', message: 'Lecture du fichier impossible' }) }
rd.readAsDataURL(file)
}
async function removeAtt (a) {
try { const r = await deleteAttachment(form.value.id, a.stored); attachments.value = r.attachments || []; $q.notify({ type: 'positive', message: 'Retirée' }) }
catch (e) { $q.notify({ type: 'negative', message: e.message }) }
}
// Envoi (test + aperçu audience)
function prepSend () { testEmail.value = testEmail.value || userEmail.value || ''; if (templates.value.length <= 1) loadTemplates(); loadMainList(); showSend.value = true }
async function loadTemplates () { async function loadTemplates () {
try { try {
const d = await listTemplates() const d = await listTemplates()
@ -308,26 +690,99 @@ async function loadTemplates () {
} catch (e) { /* garde le défaut */ } } catch (e) { /* garde le défaut */ }
} }
function onCsvFile (file) { function onCsvFile (file) {
audPreview.value = null
if (!file) { audCsvText.value = ''; return } if (!file) { audCsvText.value = ''; return }
const rd = new FileReader() const rd = new FileReader()
rd.onload = () => { audCsvText.value = String(rd.result || '') } rd.onload = () => { audCsvText.value = String(rd.result || '') }
rd.readAsText(file) 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 function onActiveSubToggle (v) { 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 // Pool manuel : recherche floue temps réel résultats en liste sélection
function onPoolSearch (val) {
const q = (val || '').trim()
if (q.length < 2) { poolResults.value = []; return }
poolSearching.value = true
// Recherche FLOUE app-wide (pg_trgm, tolère typos + accents : « repa » trouve « Réparation »).
searchCustomersFuzzy(q)
.then(d => {
poolResults.value = (d.matches || [])
.filter(m => m.name && m.kind !== 'équipe') // clients seulement
.map(m => ({ customer_id: m.name, customer_name: m.customer_name || m.name, email: (m.email || '').split(/[;,]/)[0].trim(), language: m.language || '', matched: m.matched || '' }))
})
.catch(() => { poolResults.value = [] })
.finally(() => { poolSearching.value = false })
}
function inPool (m) { return pool.value.some(p => p.customer_id === m.customer_id) }
function addToPool (sel) {
if (sel && sel.customer_id && !pool.value.some(p => p.customer_id === sel.customer_id)) pool.value.push(sel)
}
function removeFromPool (p) { pool.value = pool.value.filter(x => x.customer_id !== p.customer_id) }
// Liste principale : chargement + ajout de lots + retrait/vidage
async function loadMainList () {
try { mainList.value = await getAudienceList(eventId.value) } catch (e) { /* liste vide si indispo */ }
}
function applyListResult (r, verb) {
mainList.value = { count: r.count, by_source: r.by_source, sample: r.sample, updated: r.updated }
const bits = []
if (typeof r.added === 'number') bits.push(`${r.added} ajouté(s)`)
if (r.duplicates) bits.push(`${r.duplicates} doublon(s) ignoré(s)`)
if (r.dropped_no_email) bits.push(`${r.dropped_no_email} sans courriel`)
if (r.resiliated_excluded) bits.push(`${r.resiliated_excluded} résilié(s) exclus`)
$q.notify({ type: r.added === 0 && verb === 'add' ? 'warning' : 'positive', message: (bits.join(' · ') || verb) + `${r.count} au total`, icon: 'playlist_add_check' })
}
function filterLabel () {
const f = audFilters.value; const p = []
if (f.city) p.push('Ville: ' + f.city)
if (f.customer_type === 'Individual') p.push('Résidentiel'); else if (f.customer_type === 'Company') p.push('Commercial')
if (f.name_like) p.push('Nom~' + f.name_like)
if (f.active_sub) p.push(f.min_monthly > 0 ? `Abo ≥${f.min_monthly}$` : 'Abo actif')
if (f.statut === 'active' && !p.length) p.push('Clients actifs')
else if (f.statut !== 'active') p.push('incl. résiliés')
return p.join(' · ') || 'Filtre'
}
async function addFilterToList () {
listBusy.value = true
// « Tous » (statut vide) autorise les ex-clients résiliés ; « Clients actifs » les exclut.
const filters = { ...audFilters.value, include_resiliated: audFilters.value.statut !== 'active' }
try { applyListResult(await audienceListAdd(eventId.value, { mode: 'filter', filters, source_label: filterLabel() }), 'add') }
catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { listBusy.value = false }
}
async function addManualToList () {
if (!pool.value.length) return
listBusy.value = true
try { try {
const body = audienceSource.value === 'csv' ? { mode: 'csv', csv: audCsvText.value } : { mode: 'filter', filters: audFilters.value } const r = await audienceListAdd(eventId.value, { mode: 'manual', filters: { customer_ids: pool.value.map(p => p.customer_id) }, source_label: 'Sélection manuelle' })
audPreview.value = await previewAudience(EVENT_ID, body) applyListResult(r, 'add'); pool.value = []
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { previewing.value = false } } catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { listBusy.value = false }
}
async function addCsvToList () {
if (!audCsvText.value) return
listBusy.value = true
try {
const r = await audienceListAdd(eventId.value, { mode: 'csv', csv: audCsvText.value, source_label: 'CSV' + (csvFile.value ? ': ' + csvFile.value.name : '') })
applyListResult(r, 'add'); csvFile.value = null; audCsvText.value = ''
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { listBusy.value = false }
}
async function removeFromList (r) {
listBusy.value = true
try { const res = await audienceListRemove(eventId.value, r.email); mainList.value = { count: res.count, by_source: res.by_source, sample: res.sample, updated: res.updated } }
catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { listBusy.value = false }
}
function clearList () {
$q.dialog({ title: 'Vider la liste', message: 'Retirer tous les destinataires de la liste d\'envoi ?', cancel: true, persistent: true, ok: { color: 'negative', label: 'Vider' } })
.onOk(async () => {
listBusy.value = true
try { const r = await audienceListClear(eventId.value); mainList.value = { count: 0, by_source: {}, sample: [], updated: '' }; void r; $q.notify({ type: 'positive', message: 'Liste vidée' }) }
catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { listBusy.value = false }
})
} }
async function sendTestNow () { async function sendTestNow () {
const em = (testEmail.value || '').trim() const em = (testEmail.value || '').trim()
if (!em) { $q.notify({ type: 'warning', message: 'Entrez un courriel de test' }); return } if (!em) { $q.notify({ type: 'warning', message: 'Entrez un courriel de test' }); return }
sending.value = true sending.value = true
try { try {
const r = await sendInvite(EVENT_ID, { test: true, test_emails: [em], channel: sendChannel.value }) const r = await sendInvite(eventId.value, { test: true, test_emails: [em], channel: sendChannel.value, lang: testLang.value })
if (r.ok) $q.notify({ type: 'positive', message: `Courriel test envoyé à ${em}`, icon: 'send' }) 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') }) 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 } } catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { sending.value = false }

View File

@ -32,7 +32,7 @@
<!-- Rangée 1 : actions principales (nav · Outils · Générer · Publier) DESKTOP/tablette uniquement (gt-sm) --> <!-- Rangée 1 : actions principales (nav · Outils · Générer · Publier) DESKTOP/tablette uniquement (gt-sm) -->
<div class="row items-center q-mb-sm q-gutter-xs gt-sm"> <div class="row items-center q-mb-sm q-gutter-xs gt-sm">
<!-- Sélecteur de VUE à GAUCHE, le plus évident (le titre « Planification » est déjà au-dessus, inutile de le répéter) --> <!-- Sélecteur de VUE à GAUCHE, le plus évident (le titre « Planification » est déjà au-dessus, inutile de le répéter) -->
<q-btn-toggle v-model="boardView" dense unelevated no-caps class="view-switch" :options="[{ value: 'grid', icon: 'calendar_view_week', label: 'Semaine' }, { value: 'kanban', icon: 'today', label: 'Jour' }, { value: 'routes', icon: 'route', label: 'Tournées' }]" toggle-color="primary" color="grey-2" text-color="grey-8"><q-tooltip>Semaine = grille (vue d'ensemble) · Jour = disposition d'une journée · Tournées = trajets réels de la journée, 1 couleur/tech (OSRM)</q-tooltip></q-btn-toggle> <q-btn-toggle v-model="boardView" dense unelevated no-caps class="view-switch" :options="[{ value: 'grid', icon: 'calendar_view_week', label: 'Semaine' }, { value: 'kanban', icon: 'today', label: 'Jour' }, { value: 'month', icon: 'calendar_month', label: 'Mois' }, { value: 'routes', icon: 'route', label: 'Tournées' }]" toggle-color="primary" color="grey-2" text-color="grey-8"><q-tooltip>Semaine = grille (vue d'ensemble) · Jour = disposition d'une journée · Mois = qui est en quart/absent + couverture par compétence · Tournées = trajets réels (OSRM)</q-tooltip></q-btn-toggle>
<q-chip v-if="dirty" dense size="sm" color="orange" text-color="white" icon="circle">{{ dirtyCount }} non publié(s)</q-chip> <q-chip v-if="dirty" dense size="sm" color="orange" text-color="white" icon="circle">{{ dirtyCount }} non publié(s)</q-chip>
<q-chip v-if="offShiftWeekCount" dense size="sm" color="warning" text-color="white" icon="warning">{{ offShiftWeekCount }} hors quart<q-tooltip class="bg-grey-9">{{ offShiftWeekCount }} job(s) assigné(s) cette période un jour la ressource n'a AUCUN quart publié. Repère le dans la grille publier un quart ou réassigner.</q-tooltip></q-chip> <q-chip v-if="offShiftWeekCount" dense size="sm" color="warning" text-color="white" icon="warning">{{ offShiftWeekCount }} hors quart<q-tooltip class="bg-grey-9">{{ offShiftWeekCount }} job(s) assigné(s) cette période un jour la ressource n'a AUCUN quart publié. Repère le dans la grille publier un quart ou réassigner.</q-tooltip></q-chip>
<q-space /> <q-space />
@ -118,16 +118,16 @@
<q-item-section>Jobs Legacy sur les timelines<q-item-label caption>durées estimées des jobs osTicket datés (fenêtre affichée)</q-item-label></q-item-section> <q-item-section>Jobs Legacy sur les timelines<q-item-label caption>durées estimées des jobs osTicket datés (fenêtre affichée)</q-item-label></q-item-section>
<q-item-section side><q-icon v-if="showLegacyLoad" name="check" color="brown" /></q-item-section> <q-item-section side><q-icon v-if="showLegacyLoad" name="check" color="brown" /></q-item-section>
</q-item> </q-item>
<q-item clickable v-close-popup @click="showDemand = !showDemand"> <q-item clickable v-close-popup @click="boardView = 'month'">
<q-item-section avatar><q-icon name="tune" :color="showDemand ? 'indigo' : 'grey-7'" /></q-item-section> <q-item-section avatar><q-icon name="calendar_month" color="indigo" /></q-item-section>
<q-item-section>Demande de personnel<q-item-label caption>panneau besoins vs capacité</q-item-label></q-item-section> <q-item-section>Besoins &amp; couverture (vue Mois)<q-item-label caption>heures requises par compétence · alertes de couverture</q-item-label></q-item-section>
<q-item-section side><q-icon v-if="showDemand" name="check" color="indigo" /></q-item-section>
</q-item> </q-item>
</q-list> </q-list>
</q-btn-dropdown> </q-btn-dropdown>
<q-btn v-if="defaultTemplate" dense flat color="warning" icon="star" :label="defaultTemplate.name" @click="applyDefault"><q-tooltip>Appliquer le modèle par défaut (consciente des absences)</q-tooltip></q-btn> <q-btn v-if="defaultTemplate" dense flat color="warning" icon="star" :label="defaultTemplate.name" @click="applyDefault"><q-tooltip>Appliquer le modèle par défaut (consciente des absences)</q-tooltip></q-btn>
<q-separator vertical class="q-mx-xs" /> <q-separator vertical class="q-mx-xs" />
<q-btn unelevated color="primary" icon="auto_awesome" label="Suggérer" @click="openSuggest"><q-tooltip class="bg-grey-9">Répartition automatique des jobs du <b>jour sélectionné</b> (distance · compétence · priorité · taux d'occupation) — revue avant d'appliquer</q-tooltip></q-btn> <q-btn unelevated color="primary" icon="auto_awesome" label="Suggérer" @click="openSuggest"><q-tooltip class="bg-grey-9">Répartition automatique des jobs du <b>jour sélectionné</b> (distance · compétence · priorité · taux d'occupation) — revue avant d'appliquer</q-tooltip></q-btn>
<HelpHint class="q-ml-xs" title="Suggérer — répartition automatique">Propose une assignation optimisée des jobs <b>du jour sélectionné</b> aux techniciens, en combinant compétence requise, disponibilité, distance (routes réelles) et taux d'occupation. Les cas simples vont d'abord aux techs les <b>moins polyvalents</b> (réserve les experts). Rien n'est appliqué avant votre validation.</HelpHint>
<!-- PUBLIER unifié : action principale + sous-options (SMS · publier au legacy) dans le même bouton --> <!-- PUBLIER unifié : action principale + sous-options (SMS · publier au legacy) dans le même bouton -->
<q-btn-dropdown split :outline="!dirty" :unelevated="dirty" color="positive" icon="cloud_upload" :label="dirty ? ('Publier (' + dirtyCount + ')') : 'Publier'" :loading="publishing" :disable="!dirty" no-caps @click="doPublish"> <q-btn-dropdown split :outline="!dirty" :unelevated="dirty" color="positive" icon="cloud_upload" :label="dirty ? ('Publier (' + dirtyCount + ')') : 'Publier'" :loading="publishing" :disable="!dirty" no-caps @click="doPublish">
<q-list dense style="min-width:250px"> <q-list dense style="min-width:250px">
@ -187,36 +187,7 @@
</template> </template>
</div> </div>
<!-- Demande --> <!-- « Demande effectif requis par créneau » RETIRÉ (remplacé par la vue Mois : besoins par compétence + couverture). -->
<q-card v-if="showDemand" flat bordered class="q-mb-md">
<q-card-section class="q-pb-none">
<div class="row items-center">
<div class="text-subtitle2 text-weight-bold">Demande effectif requis par créneau</div><q-space />
<q-btn dense flat icon="schedule" label="Types de shift" @click="showShiftEditor = true" />
<q-btn dense flat icon="add" label="Ajouter" @click="addDemand" />
<q-btn dense unelevated color="indigo" icon="playlist_add_check" label="Appliquer à la semaine" :loading="applying" class="q-ml-sm" @click="applyDemand" />
</div>
<div class="text-caption text-grey-7 q-mt-xs">Coche les jours <b>fériés</b> (F) dans l'en-tête · fin de semaine = sam/dim (auto). Si <b>Durée/job</b> &gt; 0, les nombres = <b>nb de jobs</b> effectif = jobs × durée ÷ heures du shift (compétences requises = colonne Compétences).</div>
</q-card-section>
<q-card-section>
<table class="demand-tbl">
<thead><tr><th>Modèle</th><th>Zone</th><th>Compétences</th><th>Durée/job (h)</th><th>Semaine</th><th>Fin de sem.</th><th>Férié</th><th></th></tr></thead>
<tbody>
<tr v-for="(d, i) in demand" :key="i">
<td><q-select dense options-dense outlined v-model="d.shift" :options="tplOptions" emit-value map-options style="min-width:150px" @update:model-value="saveDemand" /></td>
<td><q-input dense outlined v-model="d.zone" style="width:120px" @update:model-value="saveDemand" /></td>
<td><TagEditor :model-value="Array.isArray(d.skills) ? d.skills : []" :all-tags="tagCatalog" :get-color="getTagColor" :can-edit="false" compact placeholder="+ compétence" style="min-width:150px" @update:model-value="items => onDemandSkills(d, items)" @create="onCreateRosterTag" /></td>
<td><q-input dense outlined type="number" step="0.5" v-model.number="d.job_h" placeholder="0" style="width:80px" @update:model-value="saveDemand" /></td>
<td><q-input dense outlined type="number" v-model.number="d.weekday" style="width:70px" @update:model-value="saveDemand" /></td>
<td><q-input dense outlined type="number" v-model.number="d.weekend" style="width:70px" @update:model-value="saveDemand" /></td>
<td><q-input dense outlined type="number" v-model.number="d.holiday" style="width:70px" @update:model-value="saveDemand" /></td>
<td><q-btn flat dense round size="sm" icon="delete" color="grey-7" @click="removeDemand(i)" /></td>
</tr>
<tr v-if="!demand.length"><td colspan="8" class="text-grey-6 q-pa-sm">Aucune ligne clique « Ajouter ».</td></tr>
</tbody>
</table>
</q-card-section>
</q-card>
<q-banner v-if="solverStats" dense rounded class="q-mb-md" :class="solverStats.shortfall ? 'bg-orange-1 text-warning' : 'bg-green-1 text-green-9'"> <q-banner v-if="solverStats" dense rounded class="q-mb-md" :class="solverStats.shortfall ? 'bg-orange-1 text-warning' : 'bg-green-1 text-green-9'">
<q-icon :name="solverStats.shortfall ? 'warning' : 'check_circle'" class="q-mr-xs" /> <q-icon :name="solverStats.shortfall ? 'warning' : 'check_circle'" class="q-mr-xs" />
@ -227,11 +198,11 @@
du tableau quand la sélection démarre pendant un glisser (le curseur reste sur la rangée visée). --> du tableau quand la sélection démarre pendant un glisser (le curseur reste sur la rangée visée). -->
<div v-if="selection.length" class="sel-actions" @mousedown.stop> <div v-if="selection.length" class="sel-actions" @mousedown.stop>
<span class="text-weight-medium q-mr-xs">{{ selection.length }} cellule(s) :</span> <span class="text-weight-medium q-mr-xs">{{ selection.length }} cellule(s) :</span>
<q-btn dense unelevated size="sm" color="primary" label="Jour" @click="bulkWindow(8, 17)" /> <q-btn dense unelevated size="sm" color="primary" label="Jour" @click="bulkWindow(8, 16)" />
<q-btn dense unelevated size="sm" color="deep-purple-5" label="Soir" @click="bulkWindow(16, 20)" /> <q-btn dense unelevated size="sm" color="deep-purple-5" label="Soir" @click="bulkWindow(16, 20)" />
<q-btn dense unelevated size="sm" color="brown" icon="shield" label="Garde" @click="bulkGarde" /> <q-btn dense unelevated size="sm" color="brown" icon="shield" label="Garde" @click="bulkGarde" />
<q-btn dense unelevated size="sm" color="negative" icon="event_busy" label="Absent" @click="bulkAbsent" /> <q-btn dense unelevated size="sm" color="negative" icon="event_busy" label="Absent" @click="bulkAbsent" />
<q-input dense outlined v-model="quickEntry" placeholder="8-17" style="width:84px" @keyup.enter="bulkQuick" @mousedown.stop><q-tooltip>Saisie rapide : 8-17 · 830-16 · 85</q-tooltip></q-input> <q-input dense outlined v-model="quickEntry" placeholder="8-16" style="width:84px" @keyup.enter="bulkQuick" @mousedown.stop><q-tooltip>Saisie rapide : 8-16 · 816 · 830-16</q-tooltip></q-input>
<q-separator vertical class="q-mx-xs" /> <q-separator vertical class="q-mx-xs" />
<q-btn dense flat size="sm" icon="content_copy" label="Copier" @click="copyCell" /> <q-btn dense flat size="sm" icon="content_copy" label="Copier" @click="copyCell" />
<q-btn dense flat size="sm" icon="content_paste" :label="cellClipboard.length ? ('Coller (' + cellClipboard.length + ')') : 'Coller'" :disable="!cellClipboard.length" @click="pasteCells" /> <q-btn dense flat size="sm" icon="content_paste" :label="cellClipboard.length ? ('Coller (' + cellClipboard.length + ')') : 'Coller'" :disable="!cellClipboard.length" @click="pasteCells" />
@ -521,6 +492,9 @@
<!-- VUE BOARD (Kanban horizontal, façon Dispatch/Gaiia) : pool « À assigner » VERTICAL (recherche + tri) à gauche ; <!-- VUE BOARD (Kanban horizontal, façon Dispatch/Gaiia) : pool « À assigner » VERTICAL (recherche + tri) à gauche ;
techs en LANES horizontales à ÉCHELLE D'HEURES (réutilise cellBands + pos + axisTicks). Glisser = assigner (hub). --> techs en LANES horizontales à ÉCHELLE D'HEURES (réutilise cellBands + pos + axisTicks). Glisser = assigner (hub). -->
<!-- P3 Onglet « Tournées » : trajets RÉELS (jobs assignés) de la journée sélectionnée, 1 couleur/tech (composant RouteMap partagé, OSRM) --> <!-- P3 Onglet « Tournées » : trajets RÉELS (jobs assignés) de la journée sélectionnée, 1 couleur/tech (composant RouteMap partagé, OSRM) -->
<!-- Vue MOIS : résumé mensuel (en quart / absents) + couverture par compétence (remplace « Demande ») -->
<MonthOverview v-if="boardView === 'month'" :techs="techs" :templates="templates" :anchor="start" @generated="() => guard(loadWeek)" />
<div v-if="boardView === 'routes'" class="routes-wrap q-pa-sm"> <div v-if="boardView === 'routes'" class="routes-wrap q-pa-sm">
<div class="row items-center q-mb-sm" style="gap:6px;flex-wrap:wrap"> <div class="row items-center q-mb-sm" style="gap:6px;flex-wrap:wrap">
<span class="text-caption text-grey-7">Journée :</span> <span class="text-caption text-grey-7">Journée :</span>
@ -668,7 +642,7 @@
<q-dialog v-model="showGarde"> <q-dialog v-model="showGarde">
<q-card style="min-width:680px;max-width:760px"> <q-card style="min-width:680px;max-width:760px">
<q-card-section class="row items-center q-pb-none"> <q-card-section class="row items-center q-pb-none">
<div class="text-subtitle1 text-weight-bold">🛡 Rotation de garde (par département)</div><q-space /> <div class="text-subtitle1 text-weight-bold">🛡 Rotation de garde (par département) <HelpHint title="Rotation de garde (sur appel)">La garde, c'est le technicien <b>sur appel</b> hors des heures normales (soir, fin de semaine). Il n'est pas planifié à l'horaire mais reste disponible en cas d'urgence. Cette rotation définit, par département, quel tech est de garde et selon quelle alternance.</HelpHint></div><q-space />
<q-btn flat round dense icon="close" v-close-popup /> <q-btn flat round dense icon="close" v-close-popup />
</q-card-section> </q-card-section>
<q-card-section class="q-gutter-y-md"> <q-card-section class="q-gutter-y-md">
@ -767,7 +741,7 @@
<div class="row items-center text-caption text-grey-6 q-pb-xs"> <div class="row items-center text-caption text-grey-6 q-pb-xs">
<div class="col">Compétence</div> <div class="col">Compétence</div>
<div style="width:90px" class="text-center">Score</div> <div style="width:90px" class="text-center">Score</div>
<div style="width:88px" class="text-center">Cadence</div> <div style="width:88px" class="text-center">Cadence <HelpHint title="Cadence (vitesse)" text="Vitesse du technicien pour cette compétence. 100 % = cadence normale ; plus haut = plus rapide (fait plus de jobs, ex. 200 % = deux fois plus) ; sous 100 % = plus lent. Vide = hérite de la cadence globale du tech. Le dispatch auto en tient compte pour estimer les durées." /></div>
</div> </div>
<div v-for="(sk, si) in skillDialog.skills" :key="sk" class="row items-center no-wrap q-py-xs" style="border-top:1px solid #eee"> <div v-for="(sk, si) in skillDialog.skills" :key="sk" class="row items-center no-wrap q-py-xs" style="border-top:1px solid #eee">
<div class="col row items-center no-wrap"> <div class="col row items-center no-wrap">
@ -1424,7 +1398,6 @@
</AssignmentField> </AssignmentField>
</div> </div>
<q-space /> <q-space />
<q-btn v-if="jobDetail.name" flat round dense icon="open_in_new" @click="openExternal(erpLink('Dispatch Job', jobDetail.name))"><q-tooltip>Ouvrir dans ERPNext</q-tooltip></q-btn>
<q-btn flat round dense icon="close" v-close-popup /> <q-btn flat round dense icon="close" v-close-popup />
</q-card-section> </q-card-section>
<q-separator /> <q-separator />
@ -1698,47 +1671,13 @@
<div ref="menuAnchorEl" :style="{ position: 'fixed', width: '1px', height: '1px', left: menu.x + 'px', top: menu.y + 'px', pointerEvents: 'none', zIndex: 0 }"></div> <div ref="menuAnchorEl" :style="{ position: 'fixed', width: '1px', height: '1px', left: menu.x + 'px', top: menu.y + 'px', pointerEvents: 'none', zIndex: 0 }"></div>
<q-menu v-model="menu.show" :target="menu.target" anchor="bottom right" self="top left" max-height="85vh"> <q-menu v-model="menu.show" :target="menu.target" anchor="bottom right" self="top left" max-height="85vh">
<q-list dense style="width:262px;user-select:none;-webkit-user-select:none"> <PlanifCellMenu
<q-item-label header class="q-py-xs">{{ menu.tech && menu.tech.name }} {{ menu.day && menu.day.dnum }}</q-item-label> :cell-key="(menu.tech && menu.tech.id) + '|' + (menu.day && menu.day.iso)"
<!-- 4 actions : Jour · Soir · Garde · Absent --> :title="(menu.tech && menu.tech.name) + ' — ' + (menu.day && menu.day.dnum)"
<div class="row q-gutter-xs q-px-sm q-pb-xs"> :shifts="menuCellShifts" :is-garde="menuIsGarde" :is-absent="menuIsAbsent"
<q-btn dense unelevated size="sm" color="primary" label="Jour 817" class="col" @click="quickShift(8, 17)" /> :clipboard-count="cellClipboard.length" :initial-range="menuRange"
<q-btn dense unelevated size="sm" color="deep-purple-5" label="Soir 1620" class="col" @click="quickShift(16, 20)" /> @window="e => applyWindow(e.min, e.max)" @toggle-garde="toggleGardeMenu" @toggle-absent="openAbsDialog"
</div> @remove-shift="removeShiftFromMenu" @clear="clearOne" @copy="copyFromMenu" @paste="pasteFromMenu" />
<div class="row q-gutter-xs q-px-sm q-pb-xs">
<q-btn dense :unelevated="menuIsGarde" :outline="!menuIsGarde" size="sm" color="brown" icon="shield" :label="menuIsGarde ? 'Garde ✓' : 'Garde'" class="col" @click="toggleGardeMenu"><q-tooltip>Mettre / retirer de garde (G) en parallèle d'un shift</q-tooltip></q-btn>
<q-btn dense :unelevated="menuIsAbsent" :outline="!menuIsAbsent" size="sm" color="negative" icon="event_busy" :label="menuIsAbsent ? 'Absent ✓' : 'Absent'" class="col" @click="openAbsDialog"><q-tooltip>Absence : ce jour (défaut), la semaine, ou une plage de dates</q-tooltip></q-btn>
</div>
<!-- Saisie rapide d'heures : 8-17 · 830-16 · 85 (=817) -->
<div class="q-px-sm q-pb-xs" @click.stop @mousedown.stop>
<q-input dense outlined v-model="quickEntry" placeholder="Heures : 8-17 · 830-16 · 85" @keyup.enter="applyQuick()">
<template #append><q-btn flat dense round size="sm" icon="keyboard_return" color="primary" @click="applyQuick()"><q-tooltip>Appliquer</q-tooltip></q-btn></template>
</q-input>
</div>
<!-- Plage personnalisée (slider replié) -->
<q-expansion-item dense dense-toggle icon="tune" label="Personnaliser la plage" header-class="text-caption text-grey-7">
<div class="q-px-md q-pb-sm" @click.stop @mousedown.stop>
<q-range v-model="menuRange" :min="0" :max="24" :step="0.5" snap color="primary" class="q-mt-sm" />
<div class="row items-center no-wrap q-gutter-sm">
<span class="text-caption text-weight-bold">{{ fmtH(menuRange.min) }}h{{ fmtH(menuRange.max) }}h</span>
<q-space />
<q-btn dense unelevated size="sm" color="primary" label="Appliquer" @click="applyMenuRange" />
</div>
</div>
</q-expansion-item>
<q-separator />
<!-- Shifts en place + actions compactes -->
<q-item v-for="a in menuCellShifts" :key="'c' + a.shift" dense>
<q-item-section>{{ a.shift_name || a.shift }} <span class="text-grey-6">{{ a.hours }}h</span></q-item-section>
<q-item-section side><q-btn flat dense round size="sm" icon="close" color="grey-7" @click="removeShiftFromMenu(a)"><q-tooltip>Retirer</q-tooltip></q-btn></q-item-section>
</q-item>
<div class="row items-center q-px-sm q-py-xs q-gutter-sm">
<q-btn flat dense size="sm" icon="content_copy" color="grey-8" @click="copyFromMenu"><q-tooltip>Copier la case</q-tooltip></q-btn>
<q-btn flat dense size="sm" icon="content_paste" color="grey-8" :disable="!cellClipboard.length" @click="pasteFromMenu"><q-tooltip>Coller{{ cellClipboard.length ? ' (' + cellClipboard.length + ')' : '' }}</q-tooltip></q-btn>
<q-space />
<q-btn v-if="menuCellShifts.length" flat dense size="sm" icon="layers_clear" color="grey-8" label="Vider" @click="clearOne" />
</div>
</q-list>
</q-menu> </q-menu>
<!-- Éditeur de JOURNÉE (clic sur le progressbar) : timeline + réordonner par drag-drop + retirer un job --> <!-- Éditeur de JOURNÉE (clic sur le progressbar) : timeline + réordonner par drag-drop + retirer un job -->
@ -1925,7 +1864,9 @@
<ProjectWizard v-model="projectWizardOpen" :customer="quoteCustomer" :initial-tier="quoteTier" @created="onQuoteCreated" /> <ProjectWizard v-model="projectWizardOpen" :customer="quoteCustomer" :initial-tier="quoteTier" @created="onQuoteCreated" />
<!-- Génération de quarts hebdo (modèles + N semaines + LOT multi-techs) écrit les Shift Assignment --> <!-- Génération de quarts hebdo (modèles + N semaines + LOT multi-techs) écrit les Shift Assignment -->
<WeeklyScheduleEditor v-model="schedGenOpen" :techs="schedGenTechs" :tech-name="schedGenTechs[0] && schedGenTechs[0].name" @apply="onScheduleApply" /> <WeeklyScheduleEditor v-model="schedGenOpen" :techs="schedGenTechs" :tech-name="schedGenTechs[0] && schedGenTechs[0].name" @apply="onScheduleApply" />
<TechScheduleDialog v-model="techSchedOpen" :tech="techSchedTech" @edit-schedule="onTechSchedEdit" @changed="onTechSchedChanged" /> <TechScheduleDialog v-model="techSchedOpen" :tech="techSchedTech" :pending-abs="pendingAbs" :garde-map="gardeEffective" :refresh-key="techSchedRefresh"
@edit-schedule="onTechSchedEdit" @changed="onTechSchedChanged" @stage-abs="onStageAbs"
@set-shift="onTechSchedSetShift" @remove-shift="onTechSchedRemoveShift" @clear-shifts="onTechSchedClearShifts" @toggle-garde="onTechSchedToggleGarde" />
</q-page> </q-page>
</template> </template>
@ -1961,7 +1902,7 @@ import { useSSE, sendSmsViaHub } from 'src/composables/useSSE'
import { installMaplibre, basemapStyle, createPlanMap } from 'src/config/basemap' // fond de carte MapLibre/OSM auto-hébergé (plus de Mapbox, plus de logo) + init commune des cartes import { installMaplibre, basemapStyle, createPlanMap } from 'src/config/basemap' // fond de carte MapLibre/OSM auto-hébergé (plus de Mapbox, plus de logo) + init commune des cartes
import { legacyDeptColor, heatColor } from 'src/composables/useHelpers' // coloriage par type « comme legacy » (partagé) + heatColor (source unique, aussi <OccupancyStrip>) import { legacyDeptColor, heatColor } from 'src/composables/useHelpers' // coloriage par type « comme legacy » (partagé) + heatColor (source unique, aussi <OccupancyStrip>)
import { useUserPrefs } from 'src/composables/useUserPrefs' // préférences d'affichage par utilisateur (serveur) import { useUserPrefs } from 'src/composables/useUserPrefs' // préférences d'affichage par utilisateur (serveur)
import { relTime, initials, erpLink } from 'src/composables/useFormatters' // temps relatif + initiales + lien ERP (source unique, ex-locales dé-dupliquées) import { relTime, initials } from 'src/composables/useFormatters' // temps relatif + initiales (source unique, ex-locales dé-dupliquées)
import { messageIdentity, priorityMeta, PRIORITY_LEVELS } from 'src/composables/useConversationDisplay' // resolvers partagés + priorité (drapeau) réutilisée des conversations import { messageIdentity, priorityMeta, PRIORITY_LEVELS } from 'src/composables/useConversationDisplay' // resolvers partagés + priorité (drapeau) réutilisée des conversations
import { useSla, SLA_BADGE } from 'src/composables/useSla' // SLA existant (politiques éditables) réutilisé sur les jobs (échéance = création + résolution) import { useSla, SLA_BADGE } from 'src/composables/useSla' // SLA existant (politiques éditables) réutilisé sur les jobs (échéance = création + résolution)
import { useAuthStore } from 'src/stores/auth' // email de l'agent (X-Authentik-Email) pour l'envoi de réponses client import { useAuthStore } from 'src/stores/auth' // email de l'agent (X-Authentik-Email) pour l'envoi de réponses client
@ -1971,6 +1912,8 @@ import LeaveDialog from 'src/components/planif/LeaveDialog.vue' // Congés & dis
import TechSyncDialog from 'src/components/planif/TechSyncDialog.vue' // Synchroniser les techniciens (extrait décomposition #4) import TechSyncDialog from 'src/components/planif/TechSyncDialog.vue' // Synchroniser les techniciens (extrait décomposition #4)
import ShiftTypesDialog from 'src/components/planif/ShiftTypesDialog.vue' // Types de shift (extrait décomposition #4) import ShiftTypesDialog from 'src/components/planif/ShiftTypesDialog.vue' // Types de shift (extrait décomposition #4)
import TicketStatusControl from 'src/components/shared/TicketStatusControl.vue' import TicketStatusControl from 'src/components/shared/TicketStatusControl.vue'
import HelpHint from 'src/components/shared/HelpHint.vue'
import { skillIcon, skillSym, markerIcon } from 'src/composables/useSkillIcons' // SOURCE UNIQUE des icônes de compétences (réutilisée fiche client)
import TagEditor from 'src/components/shared/TagEditor.vue' // module de tags/compétences PARTAGÉ (chips colorées, création + palette, niveaux) SOURCE UNIQUE : jobs ET techs import TagEditor from 'src/components/shared/TagEditor.vue' // module de tags/compétences PARTAGÉ (chips colorées, création + palette, niveaux) SOURCE UNIQUE : jobs ET techs
import RouteMap from 'src/components/shared/RouteMap.vue' // carte de tournées réutilisable (revue dispatch auto + onglet Tournées) : OSRM réel, arrêts numérotés, fitTo import RouteMap from 'src/components/shared/RouteMap.vue' // carte de tournées réutilisable (revue dispatch auto + onglet Tournées) : OSRM réel, arrêts numérotés, fitTo
import OccupancyStrip from 'src/components/shared/OccupancyStrip.vue' // bande d'occupation réutilisable (sélecteur de jour Tournées + tableau de bord) import OccupancyStrip from 'src/components/shared/OccupancyStrip.vue' // bande d'occupation réutilisable (sélecteur de jour Tournées + tableau de bord)
@ -1983,6 +1926,8 @@ import { useCreateSignal } from 'src/composables/useCreateSignal' // FAB global
import ProjectWizard from 'src/components/shared/ProjectWizard.vue' // moteur soumission existant (panier + rabais + devis + acceptation) réutilisé import ProjectWizard from 'src/components/shared/ProjectWizard.vue' // moteur soumission existant (panier + rabais + devis + acceptation) réutilisé
import WeeklyScheduleEditor from 'src/components/shared/WeeklyScheduleEditor.vue' // génération de quarts hebdo par tech (modèles) repris/amélioré depuis Dispatch import WeeklyScheduleEditor from 'src/components/shared/WeeklyScheduleEditor.vue' // génération de quarts hebdo par tech (modèles) repris/amélioré depuis Dispatch
import TechScheduleDialog from 'src/components/planif/TechScheduleDialog.vue' // écran unique horaire/pause/congés par tech (icône « event » de la rangée) import TechScheduleDialog from 'src/components/planif/TechScheduleDialog.vue' // écran unique horaire/pause/congés par tech (icône « event » de la rangée)
import PlanifCellMenu from 'src/components/planif/PlanifCellMenu.vue' // menu de cellule PARTAGÉ (grille + calendrier par tech)
import MonthOverview from 'src/components/planif/MonthOverview.vue' // vue MOIS : qui est en quart / absent + couverture par compétence (remplace « Demande »)
const $q = useQuasar() const $q = useQuasar()
const router = useRouter() const router = useRouter()
@ -2024,7 +1969,7 @@ const visStatByDay = computed(() => {
const vis = new Set(visibleTechs.value.map(t => t.id)); const tpl = tplByName.value; const agg = {} const vis = new Set(visibleTechs.value.map(t => t.id)); const tpl = tplByName.value; const agg = {}
for (const a of assignments.value) { for (const a of assignments.value) {
if (!vis.has(a.tech)) continue if (!vis.has(a.tech)) continue
if (absByTechDay.value[a.tech + '|' + a.date]) continue // congé / pause ce jour quart matérialisé mais tech absent : hors capacité if (isAbsent(a.tech, a.date)) continue // congé / pause ce jour (serveur OU en attente) quart matérialisé mais tech absent : hors capacité
const t = tpl[a.shift]; if (t && t.on_call) continue // garde = mise en dispo, pas travaillée const t = tpl[a.shift]; if (t && t.on_call) continue // garde = mise en dispo, pas travaillée
const o = agg[a.date] || (agg[a.date] = { hours: 0, staff: new Set() }) const o = agg[a.date] || (agg[a.date] = { hours: 0, staff: new Set() })
o.hours += Number(a.hours) || 0; o.staff.add(a.tech) o.hours += Number(a.hours) || 0; o.staff.add(a.tech)
@ -2078,17 +2023,16 @@ const unassignedByDay = computed(() => {
}) })
const dailyStats = ref([]) const dailyStats = ref([])
const solverStats = ref(null) const solverStats = ref(null)
const loading = ref(false); const generating = ref(false); const publishing = ref(false); const applying = ref(false) const loading = ref(false); const generating = ref(false); const publishing = ref(false)
const days = ref(14) // 2 semaines par défaut (sélecteur retiré pour éviter les changements d'étendue accidentels) const days = ref(14) // 2 semaines par défaut (sélecteur retiré pour éviter les changements d'étendue accidentels)
const start = ref(thisMonday()) // défaut = semaine COURANTE (cohérent avec « Auj. ») pas la semaine suivante const start = ref(thisMonday()) // défaut = semaine COURANTE (cohérent avec « Auj. ») pas la semaine suivante
const lastWeek = reactive({ start: start.value, days: days.value }) const lastWeek = reactive({ start: start.value, days: days.value })
const showDemand = ref(false)
const drag = reactive({ on: false, ti: 0, di: 0, moved: false, base: [] }) const drag = reactive({ on: false, ti: 0, di: 0, moved: false, base: [] })
const justDragged = ref(false) const justDragged = ref(false)
const selection = ref([]) const selection = ref([])
const activeCell = ref(null) // dernière case cliquée {id, name, iso} pour copier/coller au clavier sans multi-sélection const activeCell = ref(null) // dernière case cliquée {id, name, iso} pour copier/coller au clavier sans multi-sélection
const anchor = ref(null) const anchor = ref(null)
const demand = ref([]); const holidays = ref([]); const weekTemplates = ref([]) const holidays = ref([]); const weekTemplates = ref([])
const statHolidays = ref([]) // fériés QC DÉTERMINISTES (calendrier hub /roster/holidays) fusionnés dans isHoliday, tous navigateurs const statHolidays = ref([]) // fériés QC DÉTERMINISTES (calendrier hub /roster/holidays) fusionnés dans isHoliday, tous navigateurs
const gardeRules = ref([]); const showGarde = ref(false) const gardeRules = ref([]); const showGarde = ref(false)
const manualGarde = ref({}) // overrides manuels de garde : 'techId|iso' 'on' | 'off' (touche « G ») const manualGarde = ref({}) // overrides manuels de garde : 'techId|iso' 'on' | 'off' (touche « G »)
@ -2150,7 +2094,7 @@ const showLeave = ref(false) // ouverture de <LeaveDialog> (état leaveRows/leav
// numToTime : heure décimale HH:MM (partagé aussi utilisé par le glisser-créer de quart). newTpl/newTplRange déplacés dans ShiftTypesDialog. // numToTime : heure décimale HH:MM (partagé aussi utilisé par le glisser-créer de quart). newTpl/newTplRange déplacés dans ShiftTypesDialog.
function numToTime (h) { const hh = Math.floor(h); const mm = Math.round((h - hh) * 60); return String(hh).padStart(2, '0') + ':' + String(mm).padStart(2, '0') } function numToTime (h) { const hh = Math.floor(h); const mm = Math.round((h - hh) * 60); return String(hh).padStart(2, '0') + ':' + String(mm).padStart(2, '0') }
const LS_DEMAND = 'roster-demand-v1'; const LS_HOL = 'roster-holidays-v1'; const LS_TPL = 'roster-week-templates-v1'; const LS_GARDE = 'roster-garde-rules-v1'; const LS_GARDE_MANUAL = 'roster-garde-manual-v1' const LS_HOL = 'roster-holidays-v1'; const LS_TPL = 'roster-week-templates-v1'; const LS_GARDE = 'roster-garde-rules-v1'; const LS_GARDE_MANUAL = 'roster-garde-manual-v1'
// Date LOCALE du jour (PAS toISOString, qui bascule au lendemain le soir en UTC cause des « jours décalés »). // Date LOCALE du jour (PAS toISOString, qui bascule au lendemain le soir en UTC cause des « jours décalés »).
function todayISO () { const d = new Date(); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0') } function todayISO () { const d = new Date(); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0') }
@ -2174,7 +2118,7 @@ watch(dayMode, (on) => { if (on) showDayJobs.value = true }) // en mode Jour, af
// VUE KANBAN (mode de vue, sur le socle hub) : colonne « À assigner » + 1 colonne/tech, cartes glissables entre lanes. // VUE KANBAN (mode de vue, sur le socle hub) : colonne « À assigner » + 1 colonne/tech, cartes glissables entre lanes.
// Réutilise tout : occupation (avec équipes via job.assist), pool unassignedJobs, onCellDrop (= roster.assignJob hub). // Réutilise tout : occupation (avec équipes via job.assist), pool unassignedJobs, onCellDrop (= roster.assignJob hub).
const { prefs: planifPrefs, save: savePlanif } = useUserPrefs('planif', { view: 'grid' }) // préférence d'affichage SERVEUR (suit l'usager entre appareils) const { prefs: planifPrefs, save: savePlanif } = useUserPrefs('planif', { view: 'grid' }) // préférence d'affichage SERVEUR (suit l'usager entre appareils)
const boardView = ref(['kanban', 'routes'].includes(planifPrefs.value.view) ? planifPrefs.value.view : 'grid') // 'grid' | 'kanban' | 'routes' const boardView = ref(['kanban', 'routes', 'month'].includes(planifPrefs.value.view) ? planifPrefs.value.view : 'grid') // 'grid' | 'kanban' | 'month' | 'routes'
watch(boardView, (v) => { savePlanif({ view: v }); if (v === 'kanban') { kbSelIso.value = nowET.value.iso; if (!dayList.value.find(d => d.iso === kbSelIso.value)) { start.value = thisMonday(); loadWeek() } _kbDidScroll = false; nextTick(() => setTimeout(kbScrollToDefault, 160)) } else if (v === 'grid' && days.value < 7) { days.value = 7; loadWeek() } }) watch(boardView, (v) => { savePlanif({ view: v }); if (v === 'kanban') { kbSelIso.value = nowET.value.iso; if (!dayList.value.find(d => d.iso === kbSelIso.value)) { start.value = thisMonday(); loadWeek() } _kbDidScroll = false; nextTick(() => setTimeout(kbScrollToDefault, 160)) } else if (v === 'grid' && days.value < 7) { days.value = 7; loadWeek() } })
watch(() => planifPrefs.value.view, (v) => { if (v && v !== boardView.value) boardView.value = v }) // sync depuis le serveur (autre appareil) une fois chargé watch(() => planifPrefs.value.view, (v) => { if (v && v !== boardView.value) boardView.value = v }) // sync depuis le serveur (autre appareil) une fois chargé
const kbSelIso = ref('') // mode Jour : jour explicitement choisi ('' = auto aujourd'hui) const kbSelIso = ref('') // mode Jour : jour explicitement choisi ('' = auto aujourd'hui)
@ -2385,7 +2329,6 @@ function exportGpx (techId) {
} }
// Détails d'un job : double-clic sur un bloc grand volet DROIT (billet + commentaires) ; simple clic = éditeur de jour. // Détails d'un job : double-clic sur un bloc grand volet DROIT (billet + commentaires) ; simple clic = éditeur de jour.
const jobDetail = reactive({ open: false, name: '', subject: '', customer: '', customerId: '', address: '', skill: '', skills: [], time: '', durH: 1, detail: '', lid: null, iso: '', dept: '', techId: '', techName: '', lat: null, lon: null, loading: false, thread: null, canTeam: false, team: [], teamLoading: false, teamAdd: null, assignTech: null, geofence: null, status: '' }) const jobDetail = reactive({ open: false, name: '', subject: '', customer: '', customerId: '', address: '', skill: '', skills: [], time: '', durH: 1, detail: '', lid: null, iso: '', dept: '', techId: '', techName: '', lat: null, lon: null, loading: false, thread: null, canTeam: false, team: [], teamLoading: false, teamAdd: null, assignTech: null, geofence: null, status: '' })
function openExternal (url) { if (url) window.open(url, '_blank', 'noopener') } // ouvre le doctype dans ERPNext (chrome harmonisé avec DetailModal)
// Décode les entités HTML (« d&#039;équipement » « d'équipement ») pour NORMALISER les détails affichés (sujets legacy encodés). // Décode les entités HTML (« d&#039;équipement » « d'équipement ») pour NORMALISER les détails affichés (sujets legacy encodés).
const _deEntEl = typeof document !== 'undefined' ? document.createElement('textarea') : null const _deEntEl = typeof document !== 'undefined' ? document.createElement('textarea') : null
function deEnt (s) { if (!s || String(s).indexOf('&') < 0 || !_deEntEl) return s || ''; _deEntEl.innerHTML = String(s); return _deEntEl.value } function deEnt (s) { if (!s || String(s).indexOf('&') < 0 || !_deEntEl) return s || ''; _deEntEl.innerHTML = String(s); return _deEntEl.value }
@ -2555,14 +2498,13 @@ function dropAskDays () {
if (dropAsk.scope === 'week') { const m = mondayISO(dropAsk.day.iso); return Array.from({ length: 7 }, (_, i) => addDaysISO(m, i)) } if (dropAsk.scope === 'week') { const m = mondayISO(dropAsk.day.iso); return Array.from({ length: 7 }, (_, i) => addDaysISO(m, i)) }
const out = []; let dd = dropAsk.from; let g = 0; while (dd && dropAsk.to && dd <= dropAsk.to && g++ < 400) { out.push(dd); dd = addDaysISO(dd, 1) }; return out const out = []; let dd = dropAsk.from; let g = 0; while (dd && dropAsk.to && dd <= dropAsk.to && g++ < 400) { out.push(dd); dd = addDaysISO(dd, 1) }; return out
} }
async function dropAskAbsence () { function dropAskAbsence () {
const t = dropAsk.tech; const days = dropAskDays(); if (!days.length) { $q.notify({ type: 'warning', message: 'Plage de dates invalide.' }); return } const t = dropAsk.tech; const days = dropAskDays(); if (!days.length) { $q.notify({ type: 'warning', message: 'Plage de dates invalide.' }); return }
for (const dd of days) { try { await roster.setAbsence(t.id, dd, dropAsk.absType || 'Congé', false) } catch (e) { err(e) } } // séquentiel (frappe_pg) pushHistory()
await reloadAbsences(); await reloadOccupancy() for (const dd of days) { const k = t.id + '|' + dd; const beforeVal = pendingAbs.value[k] ? { ...pendingAbs.value[k] } : null; setAbsPending(k, dropAsk.absType || 'Congé'); logAbsChange('Congé ajouté · ' + dd, k, beforeVal) } // LOCAL à Publier (IROPS au moment de Publier)
const retour = addDaysISO(days[days.length - 1], 1) const retour = addDaysISO(days[days.length - 1], 1)
const noms = dropAsk.names.slice(); dropAsk.open = false const noms = dropAsk.names.slice(); dropAsk.open = false; scheduleDraftSave()
$q.notify({ type: 'warning', icon: 'event_busy', message: `${t.name} absent ${days.length} j (${dropAsk.absType}) · retour le ${retour}. ${noms.length} job(s) laissé(s) au pool.`, timeout: 8000, actions: [{ label: 'Assigner à un autre tech', color: 'white', handler: () => openAssignPanel() }] }) $q.notify({ type: 'warning', icon: 'event_busy', message: `${t.name} absent ${days.length} j (${dropAsk.absType}) en attente · retour le ${retour}. Publier pour appliquer. ${noms.length} job(s) laissé(s) au pool.`, timeout: 8000, actions: [{ label: 'Assigner à un autre tech', color: 'white', handler: () => openAssignPanel() }] })
await checkAbsenceImpact(days.map(dd => t.id + '|' + dd)) // redistribue les jobs DÉJÀ assignés sur ces jours (dialogue existant)
} }
// Absence sur PLAGE (comme l'ancien dispatch) : défaut = jour affiché ; options « ce jour / cette semaine / plage du..au ». // Absence sur PLAGE (comme l'ancien dispatch) : défaut = jour affiché ; options « ce jour / cette semaine / plage du..au ».
const absDialog = reactive({ open: false, techId: '', techName: '', from: '', to: '' }) const absDialog = reactive({ open: false, techId: '', techName: '', from: '', to: '' })
@ -2573,13 +2515,12 @@ function openAbsDialog () {
} }
function absWeek () { const m = mondayISO(absDialog.from || todayISO()); absDialog.from = m; absDialog.to = addDaysISO(m, 6) } function absWeek () { const m = mondayISO(absDialog.from || todayISO()); absDialog.from = m; absDialog.to = addDaysISO(m, 6) }
function absDays () { const out = []; let d = absDialog.from; if (!d || !absDialog.to || absDialog.to < d) return d ? [d] : out; let g = 0; while (d <= absDialog.to && g++ < 400) { out.push(d); d = addDaysISO(d, 1) }; return out } function absDays () { const out = []; let d = absDialog.from; if (!d || !absDialog.to || absDialog.to < d) return d ? [d] : out; let g = 0; while (d <= absDialog.to && g++ < 400) { out.push(d); d = addDaysISO(d, 1) }; return out }
async function applyAbs (remove) { function applyAbs (remove) {
const days = absDays(); if (!days.length || !absDialog.techId) return const days = absDays(); if (!days.length || !absDialog.techId) return
for (const d of days) { try { await roster.setAbsence(absDialog.techId, d, 'Congé', remove) } catch (e) { err(e) } } // séquentiel (frappe_pg) pushHistory(); let n = 0
await reloadAbsences(); await reloadOccupancy() for (const d of days) { const k = absDialog.techId + '|' + d; const beforeVal = pendingAbs.value[k] ? { ...pendingAbs.value[k] } : null; setAbsPending(k, remove ? null : 'Congé'); logAbsChange((remove ? 'Congé retiré · ' : 'Congé ajouté · ') + d, k, beforeVal); n++ } // LOCAL à Publier
$q.notify({ type: 'info', message: remove ? ('Absence retirée (' + days.length + ' j)') : (days.length + ' jour(s) marqué(s) absent') }) $q.notify({ type: 'info', message: (remove ? ('Absence retirée (' + n + ' j)') : (n + ' jour(s) marqué(s) absent')) + ' — Publier pour appliquer' })
absDialog.open = false absDialog.open = false; scheduleDraftSave()
if (!remove) await checkAbsenceImpact(days.map(d => absDialog.techId + '|' + d))
} }
// Mini-carte du volet détails : zoome sur le job + layer « tracé GPS réel » (Traccar) en option // Mini-carte du volet détails : zoome sur le job + layer « tracé GPS réel » (Traccar) en option
@ -2677,7 +2618,6 @@ watch(jdShowTrack, (on) => { if (!_jdMap) return; if (on) loadJdTrack(); else {
watch(() => jobDetail.open, (open) => { if (!open) { jdShowMap.value = false; if (_jdMap) { try { _jdMap.remove() } catch (e) {} _jdMap = null } } }) watch(() => jobDetail.open, (open) => { if (!open) { jdShowMap.value = false; if (_jdMap) { try { _jdMap.remove() } catch (e) {} _jdMap = null } } })
watch(jdShowMap, (on) => { if (on && jobDetail.open) nextTick(() => setTimeout(initJdMap, 200)); else if (!on && _jdMap) { try { _jdMap.remove() } catch (e) {} _jdMap = null } }) watch(jdShowMap, (on) => { if (on && jobDetail.open) nextTick(() => setTimeout(initJdMap, 200)); else if (!on && _jdMap) { try { _jdMap.remove() } catch (e) {} _jdMap = null } })
const tplOptions = computed(() => templates.value.map(t => ({ label: t.template_name, value: t.name })))
const techOptions = computed(() => techs.value.map(t => ({ label: t.name, value: t.id }))) const techOptions = computed(() => techs.value.map(t => ({ label: t.name, value: t.id })))
const tplByName = computed(() => Object.fromEntries(templates.value.map(t => [t.name, t]))) const tplByName = computed(() => Object.fromEntries(templates.value.map(t => [t.name, t])))
// CALQUE de garde LIVE // CALQUE de garde LIVE
@ -2772,8 +2712,39 @@ async function applyWeekPreset (t, dows, min, max) {
} }
// Écran unique « Horaire » par tech (icône event de la rangée) : pause indéfinie + horaire récurrent + calendrier congés + archivage. // Écran unique « Horaire » par tech (icône event de la rangée) : pause indéfinie + horaire récurrent + calendrier congés + archivage.
const techSchedOpen = ref(false); const techSchedTech = ref(null) const techSchedOpen = ref(false); const techSchedTech = ref(null)
const techSchedRefresh = ref(0) // après une écriture de quart le calendrier par tech recharge son mois
function openTechSchedule (t) { techSchedTech.value = t; techSchedOpen.value = true } function openTechSchedule (t) { techSchedTech.value = t; techSchedOpen.value = true }
// Calendrier par tech : le menu de cellule PARTAGÉ y émet des intentions ; on les exécute avec les mêmes fonctions que la grille.
// Quarts = persistés PAR DATE (createShift 'Publié' / deleteAssignment) car le calendrier est mensuel (hors fenêtre de la grille).
async function onTechSchedSetShift ({ techId, iso, min, max } = {}) {
if (!techId || !iso || !(max > min)) return
const t = (techs.value || []).find(x => x.id === techId); const tpl = await ensureWindowTpl(min, max); if (!tpl) return
try {
// Remplace : retire les quarts réguliers existants de ce jour, puis pose le nouveau.
const existing = (await roster.listAssignments(iso, 1)).assignments || []
for (const a of existing) { const tp = tplByName.value[a.shift]; if (a.tech === techId && a.date === iso && tp && !tp.on_call) { try { await roster.deleteAssignment(a.name) } catch (e) {} } }
await roster.createShift({ tech: techId, tech_name: t ? t.name : '', date: iso, shift: tpl.name, zone: tpl.zone || '', hours: tpl.hours || 8 })
$q.notify({ type: 'positive', message: `Quart ${fmtH(min)}h${fmtH(max)}h · ${iso}`, timeout: 1800 })
} catch (e) { err(e) }
techSchedRefresh.value++; await loadWeek()
}
async function onTechSchedRemoveShift ({ name } = {}) { if (!name) return; try { await roster.deleteAssignment(name); $q.notify({ type: 'info', message: 'Quart retiré', timeout: 1500 }) } catch (e) { err(e) } techSchedRefresh.value++; await loadWeek() }
async function onTechSchedClearShifts ({ names } = {}) { if (!names || !names.length) return; for (const n of names) { try { await roster.deleteAssignment(n) } catch (e) {} } $q.notify({ type: 'info', message: 'Quarts retirés', timeout: 1500 }); techSchedRefresh.value++; await loadWeek() }
function onTechSchedToggleGarde ({ techId, iso } = {}) { if (techId && iso) toggleGardeCells([techId + '|' + iso]) } // garde = manualGarde (localStorage), clé tech|iso cross-fenêtre OK
function onTechSchedEdit (t) { techSchedOpen.value = false; openSchedGen(t) } // « Modifier l'horaire récurrent » réutilise WeeklyScheduleEditor existant function onTechSchedEdit (t) { techSchedOpen.value = false; openSchedGen(t) } // « Modifier l'horaire récurrent » réutilise WeeklyScheduleEditor existant
// Calendrier par tech : les congés y sont STAGÉS (publish-required), comme dans la grille. { techId, changes:[{iso, type}] } (type '' = retrait).
function onStageAbs ({ techId, changes } = {}) {
if (!techId || !Array.isArray(changes) || !changes.length) return
pushHistory(); let n = 0
for (const c of changes) {
const k = techId + '|' + c.iso; const wantType = c.type || null
if ((effAbsType(techId, c.iso) || null) === wantType) continue // déjà dans l'état voulu
const beforeVal = pendingAbs.value[k] ? { ...pendingAbs.value[k] } : null
setAbsPending(k, wantType)
logAbsChange((wantType ? (wantType + ' · ') : 'Congé retiré · ') + c.iso, k, beforeVal); n++
}
if (n) { $q.notify({ type: 'info', message: n + ' changement(s) de congé en attente — Publier pour appliquer', timeout: 2200 }); scheduleDraftSave() }
}
async function onTechSchedChanged (ev) { async function onTechSchedChanged (ev) {
// pause : reflète le statut localement ; archivage : recharge la base (le tech disparaît). Congés : recharge la semaine (hachures). // pause : reflète le statut localement ; archivage : recharge la base (le tech disparaît). Congés : recharge la semaine (hachures).
if (ev && ev.status) { const tt = techs.value.find(x => x.id === ev.id); if (tt) tt.status = ev.status } if (ev && ev.status) { const tt = techs.value.find(x => x.id === ev.id); if (tt) tt.status = ev.status }
@ -2846,13 +2817,6 @@ function skillEffColor (t, sk) { const e = t.skill_eff && t.skill_eff[sk]; retur
// Icône de compétence pour q-icon (accepte les SYMBOLES SVG importés) : échelle=installation, casque=support, // Icône de compétence pour q-icon (accepte les SYMBOLES SVG importés) : échelle=installation, casque=support,
// outils=réparation ; sinon ligature classique unique (skillIcon). NB: les pins carte (HTML brut) gardent skillIcon. // outils=réparation ; sinon ligature classique unique (skillIcon). NB: les pins carte (HTML brut) gardent skillIcon.
function tSkills (t) { return [...(t.skills || [])].sort((a, b) => a.localeCompare(b)) } // compétences triées du tech (pour l'affichage 1 + « +N ») function tSkills (t) { return [...(t.skills || [])].sort((a, b) => a.localeCompare(b)) } // compétences triées du tech (pour l'affichage 1 + « +N »)
function skillSym (sk) {
const s = String(sk || '').toLowerCase()
if (/t[ée]l[ée]vis|\btv\b|iptv|t[ée]l[ée]\b/.test(s)) return 'live_tv' // télé/TV TV AVANT « install » (« Install/Reparation Télé » contient « install »)
if (/install/.test(s)) return 'router' // installation fibre = pose du routeur/ONT (icône unique carte + listes)
if (/support|service.?client|t[ée]l[ée]assist|\baide\b/.test(s)) return symOutlinedHeadsetMic
return skillIcon(sk) // réparation → 'build' (clé simple / wrench) via skillIcon ; autres ligature unique
}
// Icône d'un bloc de tournée (vue JOUR et SEMAINE) : renfort group ; sinon icône de COMPÉTENCE // Icône d'un bloc de tournée (vue JOUR et SEMAINE) : renfort group ; sinon icône de COMPÉTENCE
// (skill/required_skill, ou dept pour les tickets legacy ex. « Install/Reparation Télé » = TV). // (skill/required_skill, ou dept pour les tickets legacy ex. « Install/Reparation Télé » = TV).
// Le ticket générique n'est utilisé qu'en dernier recours (legacy sans skill ni dept). // Le ticket générique n'est utilisé qu'en dernier recours (legacy sans skill ni dept).
@ -2863,41 +2827,7 @@ function blkIcon (b) {
if (sk) return skillSym(sk) if (sk) return skillSym(sk)
return (b && b.legacy) ? 'confirmation_number' : skillSym('') return (b && b.legacy) ? 'confirmation_number' : skillSym('')
} }
// Camion-nacelle (bucket truck) pour « monteur » Material Symbols n'a pas d'équivalent SVG custom au format // (skillIcon / skillSym / markerIcon / BUCKET_TRUCK composables/useSkillIcons.js SOURCE UNIQUE réutilisable)
// q-icon Quasar (chemins séparés par && ; chaque chemin = d@@style). Aplats (carrosserie/roues/nacelle, roues
// évidées en evenodd) + bras articulé en trait épais. Hérite couleur (currentColor) et taille. viewBox 512×416.
const BUCKET_TRUCK = 'M120,300 H440 V322 H120 Z M120,276 H310 V300 H120 Z M206,226 H280 V300 H206 Z M56,64 H160 V144 H56 Z@@fill:currentColor'
+ '&&M300,300 V250 L322,222 H408 L438,260 V300 Z M330,252 H396 V284 H330 Z@@fill:currentColor;fill-rule:evenodd'
+ '&&M144,356 a40,40 0 1,0 80,0 a40,40 0 1,0 -80,0 Z M170,356 a14,14 0 1,1 28,0 a14,14 0 1,1 -28,0 Z M352,356 a40,40 0 1,0 80,0 a40,40 0 1,0 -80,0 Z M378,356 a14,14 0 1,1 28,0 a14,14 0 1,1 -28,0 Z@@fill:currentColor;fill-rule:evenodd'
+ '&&M243,250 L384,150 L120,104@@fill:none;stroke:currentColor;stroke-width:30;stroke-linecap:round;stroke-linejoin:round'
+ '|0 0 512 416'
function skillIcon (sk) {
const s = String(sk || '').toLowerCase()
if (/install/.test(s)) return 'construction'
if (/r[ée]par|d[ée]pann|bris/.test(s)) return 'build'
if (/fibre|fusion|soud|épissure|epissure/.test(s)) return 'cable'
if (/t[ée]l[ée]vis|\btv\b|iptv|t[ée]l[ée]\b/.test(s)) return 'live_tv'
if (/t[ée]l[ée]phon|voip|\b3cx\b/.test(s)) return 'call'
if (/r[ée]seau|net\s?admin|router|bgp|olt|acs/.test(s)) return 'dns'
if (/\bwifi\b|wi-?fi|mesh/.test(s)) return 'wifi' // wifi MAISON (couverture interne) symbole wifi simple
if (/sans.?fil|wireless|\bfwa\b|\blte\b|radio|antenne|\btour\b|micro.?onde|point.?[àa].?point|\bptp\b|liaison/.test(s)) return 'cell_tower' // sans fil = PtP inter-bâtiments/tours tour
if (/monteur|poteau|hauteur|nacelle|a[ée]rien|grimp/.test(s)) return BUCKET_TRUCK // monteur / aérien camion-nacelle
if (/d[ée]sinstall|retrait|ramass|d[ée]mant/.test(s)) return 'delete_sweep'
if (/info|ordinateur|\bpc\b|cam[ée]ra/.test(s)) return 'computer'
if (/vente|sales|soumission/.test(s)) return 'sell'
if (/factur|paiement|compta/.test(s)) return 'receipt_long'
return 'handyman' // visite générique (type non reconnu) : technicien avec outils, plutôt que l'éclair peu parlant
}
// Icône de compétence pour un MARQUEUR carte (DOM, police material-icons) : UNIQUEMENT des ligatures material (pas les SVG
// custom de skillSym) sinon le glyphe ne s'affiche pas dans un <span class="material-icons"> hors q-icon.
function markerIcon (skill) {
const s = String(skill || '').toLowerCase()
if (/t[ée]l[ée]vis|\btv\b|iptv/.test(s)) return 'live_tv'
if (/install/.test(s)) return 'router' // installation fibre = pose du routeur/ONT au domicile
if (/monteur|poteau|hauteur|nacelle|a[ée]rien|grimp/.test(s)) return 'local_shipping'
const ic = skillIcon(skill)
return (typeof ic === 'string' && /^[a-z0-9_]+$/.test(ic)) ? ic : 'build'
}
function onTagsChange (t, items) { function onTagsChange (t, items) {
const newLabels = (items || []).map(x => typeof x === 'string' ? x : x.tag).filter(Boolean) const newLabels = (items || []).map(x => typeof x === 'string' ? x : x.tag).filter(Boolean)
const removed = (t.skills || []).filter(s => !newLabels.includes(s)) // compétences retirées vérifier l'impact sur les jobs assignés const removed = (t.skills || []).filter(s => !newLabels.includes(s)) // compétences retirées vérifier l'impact sur les jobs assignés
@ -4169,9 +4099,10 @@ const currentSet = computed(() => new Set(assignments.value.map(a => a.tech + '|
const diffKeys = computed(() => { const cur = currentSet.value; const srv = serverSet.value; const d = []; for (const k of cur) if (!srv.has(k)) d.push(k); for (const k of srv) if (!cur.has(k)) d.push(k); return d }) const diffKeys = computed(() => { const cur = currentSet.value; const srv = serverSet.value; const d = []; for (const k of cur) if (!srv.has(k)) d.push(k); for (const k of srv) if (!cur.has(k)) d.push(k); return d })
// #2/#3 « dirty » = quarts NON PUBLIÉS (statut Publié). Tout est AUTO-SAUVÉ en brouillon plus d'alerte « modifications non publiées » à la navigation. // #2/#3 « dirty » = quarts NON PUBLIÉS (statut Publié). Tout est AUTO-SAUVÉ en brouillon plus d'alerte « modifications non publiées » à la navigation.
const unpublished = computed(() => assignments.value.filter(a => a.status && a.status !== 'Publié')) const unpublished = computed(() => assignments.value.filter(a => a.status && a.status !== 'Publié'))
const dirty = computed(() => unpublished.value.length > 0) // « dirty » = quarts NON PUBLIÉS + congés en attente (publish-required) tout doit passer par « Publier ».
const dirtyCount = computed(() => unpublished.value.length) const dirty = computed(() => unpublished.value.length > 0 || pendingAbsCount.value > 0)
const dirtyCells = computed(() => new Set(unpublished.value.map(a => a.tech + '|' + a.date))) const dirtyCount = computed(() => unpublished.value.length + pendingAbsCount.value)
const dirtyCells = computed(() => { const s = new Set(unpublished.value.map(a => a.tech + '|' + a.date)); for (const k of Object.keys(pendingAbs.value)) s.add(k); return s })
function isCellDirty (techId, iso) { return dirtyCells.value.has(techId + '|' + iso) } function isCellDirty (techId, iso) { return dirtyCells.value.has(techId + '|' + iso) }
// Statut agrégé de la semaine (pour l'affichage #3) : le « plus bas » statut non publié présent. // Statut agrégé de la semaine (pour l'affichage #3) : le « plus bas » statut non publié présent.
const weekStatus = computed(() => { const st = new Set(unpublished.value.map(a => a.status)); if (st.has('Proposé')) return 'Proposé'; if (st.has('Soumis')) return 'Soumis'; if (st.has('Approuvé')) return 'Approuvé'; return 'Publié' }) const weekStatus = computed(() => { const st = new Set(unpublished.value.map(a => a.status)); if (st.has('Proposé')) return 'Proposé'; if (st.has('Soumis')) return 'Soumis'; if (st.has('Approuvé')) return 'Approuvé'; return 'Publié' })
@ -4217,18 +4148,50 @@ watch([boardView, kanbanDay, occByTechDay], () => {
if (boardView.value !== 'kanban') return if (boardView.value !== 'kanban') return
clearTimeout(_kbMatT); _kbMatT = setTimeout(() => { for (const t of visibleTechs.value) fetchKbMatrix(t.id) }, 350) // 1 call/tournée visible, débouncé + caché clearTimeout(_kbMatT); _kbMatT = setTimeout(() => { for (const t of visibleTechs.value) fetchKbMatrix(t.id) }, 350) // 1 call/tournée visible, débouncé + caché
}) })
const absByTechDay = ref({}) // tech|date → type d'absence (En pause / Congé / Maladie) hachuré const absByTechDay = ref({}) // tech|date → type d'absence SERVEUR (En pause / Congé / Maladie) hachuré
function isAbsent (techId, iso) { return !!absByTechDay.value[techId + '|' + iso] } // #congé PUBLISH-REQUIRED : couche LOCALE d'absences en attente (delta vs serveur), écrite au serveur SEULEMENT à « Publier ».
function absenceLabel (techId, iso) { return absByTechDay.value[techId + '|' + iso] || 'Absent' } // clé 'tech|iso' { op:'set', type } (marquer absent) | { op:'remove' } (retirer une absence serveur). Persistée localStorage (survie au rechargement).
const LS_PENDING_ABS = 'roster-pending-abs-v1'
const pendingAbs = ref({})
function savePendingAbs () { try { localStorage.setItem(LS_PENDING_ABS, JSON.stringify(pendingAbs.value)) } catch (e) {} }
const pendingAbsCount = computed(() => Object.keys(pendingAbs.value).length)
// Type d'absence EFFECTIF = état serveur écrasé par le delta local en attente. null = pas absent.
function effAbsType (techId, iso) { const k = techId + '|' + iso; const p = pendingAbs.value[k]; if (p) return p.op === 'set' ? (p.type || 'Congé') : null; return absByTechDay.value[k] || null }
function isAbsent (techId, iso) { return !!effAbsType(techId, iso) }
function absenceLabel (techId, iso) { return effAbsType(techId, iso) || 'Absent' }
async function reloadAbsences () { try { const r = await roster.getAbsences(start.value, days.value); absByTechDay.value = r.absences || {} } catch (e) {} } async function reloadAbsences () { try { const r = await roster.getAbsences(start.value, days.value); absByTechDay.value = r.absences || {} } catch (e) {} }
// Bascule absence d'1 jour sur des cases (clic + « A » ou menu). Si toutes absentes retire ; sinon marque. // Pose le delta d'absence d'une cellule (wantType=null non absent). N'écrit PAS au serveur (publish-required).
async function toggleAbsentCells (targets) { // Si l'état voulu == état serveur on efface le delta (pas de changement en attente inutile).
function setAbsPending (key, wantType) {
const serverType = absByTechDay.value[key] || null
const m = { ...pendingAbs.value }
if ((wantType || null) === serverType) delete m[key]
else m[key] = wantType ? { op: 'set', type: wantType } : { op: 'remove' }
pendingAbs.value = m; savePendingAbs()
}
// Écrit une entrée d'absence dans le journal (avec revert du delta précédent) annulable comme un quart.
function logAbsChange (text, key, beforeVal) { logChange(text, { absKey: key, beforeVal }) }
// Le tech travaille-t-il ce jour selon son patron hebdo (weekly_schedule) ? null = pas de patron inconnu (on garde l'estimation 8h).
function patternWorksDay (techId, iso) {
const t = (techs.value || []).find(x => x.id === techId); const sched = t && t.weekly_schedule
if (!sched || typeof sched !== 'object') return null
const day = sched[['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'][new Date(iso + 'T12:00:00Z').getUTCDay()]]
return !!(day && day.start && day.end)
}
// Bascule absence d'1 jour sur des cases (clic + « A » ou menu). Toutes absentes retire ; sinon marque. LOCAL à Publier.
function toggleAbsentCells (targets) {
if (!targets || !targets.length) return if (!targets || !targets.length) return
const allAbsent = targets.every(k => { const [tid, iso] = k.split('|'); return isAbsent(tid, iso) }) const allAbsent = targets.every(k => { const [tid, iso] = k.split('|'); return isAbsent(tid, iso) })
for (const k of targets) { const [tid, iso] = k.split('|'); try { await roster.setAbsence(tid, iso, 'Congé', allAbsent) } catch (e) { err(e) } } const want = !allAbsent
await reloadAbsences() pushHistory(); let n = 0
$q.notify({ type: 'info', message: allAbsent ? 'Absence retirée' : (targets.length + ' absence(s) marquée(s)') }) for (const k of targets) {
if (!allAbsent) await checkAbsenceImpact(targets) // marquage vérifier les jobs assignés impactés (IROPS) const [tid, iso] = k.split('|')
if (!!effAbsType(tid, iso) === want) continue // déjà dans l'état voulu (toggle idempotent)
const beforeVal = pendingAbs.value[k] ? { ...pendingAbs.value[k] } : null
setAbsPending(k, want ? 'Congé' : null)
logAbsChange((want ? 'Congé ajouté · ' : 'Congé retiré · ') + iso, k, beforeVal); n++
}
if (n) { $q.notify({ type: 'info', message: (want ? (n + ' congé(s) en attente') : (n + ' congé(s) retiré(s)')) + ' — Publier pour appliquer' }); scheduleDraftSave() }
} }
function saveManualGarde () { localStorage.setItem(LS_GARDE_MANUAL, JSON.stringify(manualGarde.value)) } function saveManualGarde () { localStorage.setItem(LS_GARDE_MANUAL, JSON.stringify(manualGarde.value)) }
// Override manuel : ne stocke QUE les écarts vs règles (want===défaut-règle on retire l'override) carte minimale. // Override manuel : ne stocke QUE les écarts vs règles (want===défaut-règle on retire l'override) carte minimale.
@ -4416,11 +4379,15 @@ const selDayLabel = computed(() => { const d = mobileSelDayObj.value; return d &
// Charge d'un tech : heures occupées / capacité. Dénominateur = heures du QUART RÉEL s'il est planifié ; // Charge d'un tech : heures occupées / capacité. Dénominateur = heures du QUART RÉEL s'il est planifié ;
// SINON journée nominale ESTIMÉE à 8h (le tech a du travail mais aucun quart on le signale avec noShift=). // SINON journée nominale ESTIMÉE à 8h (le tech a du travail mais aucun quart on le signale avec noShift=).
// 3h sans quart 3/8 37% (vert) ; rouge seulement si réellement au-dessus de la capacité. // 3h sans quart 3/8 37% (vert) ; rouge seulement si réellement au-dessus de la capacité.
function techLoad (o, hasShift) { // #4 capacité par tech-jour. Le tech ABSENT (congé/vacances) = 0h dispo (bug « 48h malgré des vacances »).
// Sans quart : estimation 8h SEULEMENT un jour où il travaille selon son patron ; jour off du patron = 0h (bug « 48h alors qu'il fait 5×8h »).
function techLoad (o, hasShift, techId, iso) {
const h = +(o && o.usedH) || 0 const h = +(o && o.usedH) || 0
const book = +(o && o.bookableH) || 0 const book = +(o && o.bookableH) || 0
const cap = (hasShift && book > 0) ? book : 8 const absent = (techId && iso) ? isAbsent(techId, iso) : false
return { h: Math.round(h * 10) / 10, cap: Math.round(cap * 10) / 10, ratio: cap > 0 ? h / cap : 0, over: h > cap + 0.01, noShift: !hasShift } const works = (techId && iso) ? patternWorksDay(techId, iso) : null // null = patron inconnu on garde l'estimation
const cap = absent ? 0 : ((hasShift && book > 0) ? book : (works === false ? 0 : 8))
return { h: Math.round(h * 10) / 10, cap: Math.round(cap * 10) / 10, ratio: cap > 0 ? h / cap : 0, over: cap > 0 && h > cap + 0.01, noShift: !hasShift && !absent && works !== false, absent }
} }
// Barre globale du jour = Σ durées jobs estimées (numérateur) / Σ durées des quarts (dénominateur) = couverture du jour. // Barre globale du jour = Σ durées jobs estimées (numérateur) / Σ durées des quarts (dénominateur) = couverture du jour.
const mobileDayLoads = computed(() => { const mobileDayLoads = computed(() => {
@ -4431,9 +4398,10 @@ const mobileDayLoads = computed(() => {
for (const t of ts) { for (const t of ts) {
const o = techDayOcc(t.id, d.iso); if (!o) continue const o = techDayOcc(t.id, d.iso); if (!o) continue
const u = +o.usedH || 0; const bk = +o.bookableH || 0; const hasSh = hasShiftDay(t.id, d.iso) const u = +o.usedH || 0; const bk = +o.bookableH || 0; const hasSh = hasShiftDay(t.id, d.iso)
const cap = (hasSh && bk > 0) ? bk : 8 // quart réel sinon estimation 8h (cohérent avec les barres par tech) const absent = isAbsent(t.id, d.iso); const works = patternWorksDay(t.id, d.iso)
assignedH += u; capH += cap; if (u > cap + 0.01) over++ const cap = absent ? 0 : ((hasSh && bk > 0) ? bk : (works === false ? 0 : 8)) // absent = 0h ; jour off du patron = 0h ; sinon quart réel ou estimation 8h
if (!hasSh && u > 0.01) noShift++ // tech avec du travail mais aucun quart planifié assignedH += u; capH += cap; if (cap > 0 && u > cap + 0.01) over++
if (!absent && !hasSh && works !== false && u > 0.01) noShift++ // tech avec du travail mais aucun quart planifié (et pas en congé / jour off)
} }
// Numérateur = TOUT le travail DÛ ce jour à répartir = jobs déjà assignés ce jour (assignedH) + jobs du POOL (non assignés) dont la date prévue est ce jour. // Numérateur = TOUT le travail DÛ ce jour à répartir = jobs déjà assignés ce jour (assignedH) + jobs du POOL (non assignés) dont la date prévue est ce jour.
let pendingH = 0 let pendingH = 0
@ -4445,7 +4413,7 @@ const mobileDayLoads = computed(() => {
}) })
const mobileTechLoads = computed(() => { const mobileTechLoads = computed(() => {
const iso = mobileSelDay.value const iso = mobileSelDay.value
return (visibleTechs.value || []).map(t => ({ id: t.id, name: t.name, _t: t, ...techLoad(techDayOcc(t.id, iso), hasShiftDay(t.id, iso)) })).sort((a, b) => b.h - a.h) return (visibleTechs.value || []).map(t => ({ id: t.id, name: t.name, _t: t, ...techLoad(techDayOcc(t.id, iso), hasShiftDay(t.id, iso), t.id, iso) })).sort((a, b) => b.h - a.h)
}) })
const pmShowIdle = ref(false) // mobile : replier les techniciens à 0 h (sinon on scrolle 50+ barres vides) const pmShowIdle = ref(false) // mobile : replier les techniciens à 0 h (sinon on scrolle 50+ barres vides)
const mobileTechBusy = computed(() => mobileTechLoads.value.filter(t => t.h > 0)) const mobileTechBusy = computed(() => mobileTechLoads.value.filter(t => t.h > 0))
@ -4467,7 +4435,7 @@ const mobileTechAssign = computed(() => {
const capable = (t) => !act.size || (t.skills || []).some(s => act.has(s)) const capable = (t) => !act.size || (t.skills || []).some(s => act.has(s))
// Candidats CAPABLES : ceux qui ONT déjà un quart d'abord (prêts, pas de création requise), puis les moins chargés. // Candidats CAPABLES : ceux qui ONT déjà un quart d'abord (prêts, pas de création requise), puis les moins chargés.
// Les « sans quart » () restent listés en dessous le dispatcher peut quand même les toucher et accepter la création d'un quart. // Les « sans quart » () restent listés en dessous le dispatcher peut quand même les toucher et accepter la création d'un quart.
return (visibleTechs.value || []).filter(capable).map(t => ({ id: t.id, name: t.name, _t: t, ...techLoad(techDayOcc(t.id, iso), hasShiftDay(t.id, iso)) })).sort((a, b) => (a.noShift ? 1 : 0) - (b.noShift ? 1 : 0) || a.h - b.h) return (visibleTechs.value || []).filter(capable).map(t => ({ id: t.id, name: t.name, _t: t, ...techLoad(techDayOcc(t.id, iso), hasShiftDay(t.id, iso), t.id, iso) })).sort((a, b) => (a.noShift ? 1 : 0) - (b.noShift ? 1 : 0) || a.h - b.h)
}) })
// TAP-TO-ASSIGNER (desktop) : clic sur une job du pool techs CLASSÉS par pertinence pour CETTE job, 1 clic assigne // TAP-TO-ASSIGNER (desktop) : clic sur une job du pool techs CLASSÉS par pertinence pour CETTE job, 1 clic assigne
@ -4496,7 +4464,7 @@ function techsForJob (job) {
const jlat = +(job && job.lat); const jlon = +(job && job.lon) const jlat = +(job && job.lat); const jlon = +(job && job.lon)
const hasJC = isFinite(jlat) && isFinite(jlon) && (jlat || jlon) const hasJC = isFinite(jlat) && isFinite(jlon) && (jlat || jlon)
return (visibleTechs.value || []).map(t => { return (visibleTechs.value || []).map(t => {
const load = techLoad(techDayOcc(t.id, iso), hasShiftDay(t.id, iso)) const load = techLoad(techDayOcc(t.id, iso), hasShiftDay(t.id, iso), t.id, iso)
const capable = !reqSkillList.length || reqSkillList.every(s => (t.skills || []).includes(s)) const capable = !reqSkillList.length || reqSkillList.every(s => (t.skills || []).includes(s))
const home = techOrigin(t.id) // domicile, sinon bureau TARGO (défaut) const home = techOrigin(t.id) // domicile, sinon bureau TARGO (défaut)
const distKm = (hasJC && home) ? haversineKm(home.lat, home.lon, jlat, jlon) : null const distKm = (hasJC && home) ? haversineKm(home.lat, home.lon, jlat, jlon) : null
@ -4525,7 +4493,7 @@ function techsForSelection () {
const cLon = ll.length ? ll.reduce((s, j) => s + (+j.lon), 0) / ll.length : null const cLon = ll.length ? ll.reduce((s, j) => s + (+j.lon), 0) / ll.length : null
const iso = jobTargetDay(jobs[0]) // jour de référence pour la charge (approx.) const iso = jobTargetDay(jobs[0]) // jour de référence pour la charge (approx.)
return (visibleTechs.value || []).map(t => { return (visibleTechs.value || []).map(t => {
const load = techLoad(techDayOcc(t.id, iso), hasShiftDay(t.id, iso)) const load = techLoad(techDayOcc(t.id, iso), hasShiftDay(t.id, iso), t.id, iso)
const capable = !reqSkills.some(s => !(t.skills || []).includes(s)) // capable de TOUTES les compétences de la sélection const capable = !reqSkills.some(s => !(t.skills || []).includes(s)) // capable de TOUTES les compétences de la sélection
const home = techOrigin(t.id) // domicile, sinon bureau TARGO (défaut) const home = techOrigin(t.id) // domicile, sinon bureau TARGO (défaut)
const distKm = (cLat != null && home) ? haversineKm(home.lat, home.lon, cLat, cLon) : null const distKm = (cLat != null && home) ? haversineKm(home.lat, home.lon, cLat, cLon) : null
@ -4747,7 +4715,7 @@ function buildSuggestion () {
const isOver = j => (j.scheduled_date && j.scheduled_date !== 'Sans date' && j.scheduled_date < today) ? 0 : 1 const isOver = j => (j.scheduled_date && j.scheduled_date !== 'Sans date' && j.scheduled_date < today) ? 0 : 1
const sorted = [...jobs].sort((a, b) => pr(a) - pr(b) || isOver(a) - isOver(b) || String(a.scheduled_date || '9').localeCompare(String(b.scheduled_date || '9')) || jobDur(b) - jobDur(a)) const sorted = [...jobs].sort((a, b) => pr(a) - pr(b) || isOver(a) - isOver(b) || String(a.scheduled_date || '9').localeCompare(String(b.scheduled_date || '9')) || jobDur(b) - jobDur(a))
const proj = {} // techId|iso { h, cap, pts:[[lat,lon]] } charge PROJETÉE, initialisée depuis l'occupation actuelle const proj = {} // techId|iso { h, cap, pts:[[lat,lon]] } charge PROJETÉE, initialisée depuis l'occupation actuelle
const cell = (tid, iso) => { const k = tid + '|' + iso; if (!proj[k]) { const load = techLoad(techDayOcc(tid, iso), hasShiftDay(tid, iso)); proj[k] = { h: load.h || 0, cap: load.cap || 8, pts: [], cities: new Set() } } return proj[k] } const cell = (tid, iso) => { const k = tid + '|' + iso; if (!proj[k]) { const load = techLoad(techDayOcc(tid, iso), hasShiftDay(tid, iso), tid, iso); proj[k] = { h: load.h || 0, cap: load.cap || 8, pts: [], cities: new Set() } } return proj[k] }
// Poids selon la STRATÉGIE : smart · best (meilleurs d'abord, cascade) · balance (round-robin) · enough (juste ce qu'il faut réserve les experts) // Poids selon la STRATÉGIE : smart · best (meilleurs d'abord, cascade) · balance (round-robin) · enough (juste ce qu'il faut réserve les experts)
const strat = suggestDlg.strategy || 'smart' const strat = suggestDlg.strategy || 'smart'
// rank = ORDRE de la compétence chez le tech (0 = compétence PRINCIPALE) un job va d'abord aux spécialistes de CETTE compétence, épargnant les polyvalents. // rank = ORDRE de la compétence chez le tech (0 = compétence PRINCIPALE) un job va d'abord aux spécialistes de CETTE compétence, épargnant les polyvalents.
@ -5169,7 +5137,7 @@ const suggestGroups = computed(() => {
const placeholder = isPh const placeholder = isPh
let worstPct = 0, worstProj = 0, worstCap = 8, existing = 0, over = false let worstPct = 0, worstProj = 0, worstCap = 8, existing = 0, over = false
if (!placeholder) for (const d of days) { if (!placeholder) for (const d of days) {
const l = techLoad(techDayOcc(g.techId, d.iso), hasShiftDay(g.techId, d.iso)) const l = techLoad(techDayOcc(g.techId, d.iso), hasShiftDay(g.techId, d.iso), g.techId, d.iso)
const dcap = l.cap || 8; const dproj = (l.h || 0) + (d.hours || 0); existing += (l.h || 0) const dcap = l.cap || 8; const dproj = (l.h || 0) + (d.hours || 0); existing += (l.h || 0)
const pct = dcap ? dproj / dcap * 100 : 0 const pct = dcap ? dproj / dcap * 100 : 0
if (pct > worstPct) { worstPct = pct; worstProj = dproj; worstCap = dcap } if (pct > worstPct) { worstPct = pct; worstProj = dproj; worstCap = dcap }
@ -5706,11 +5674,12 @@ function covCell (key, iso) { return covByKeyDay.value[key + '|' + iso] }
function covText (key, iso) { const c = covCell(key, iso); return c ? (c.assigned + '/' + c.required) : '' } function covText (key, iso) { const c = covCell(key, iso); return c ? (c.assigned + '/' + c.required) : '' }
function covStyle (key, iso) { const c = covCell(key, iso); if (!c) return {}; return c.shortfall > 0 ? { background: '#ffcdd2', color: '#b71c1c', fontWeight: 700 } : { background: '#c8e6c9', color: '#1b5e20' } } function covStyle (key, iso) { const c = covCell(key, iso); if (!c) return {}; return c.shortfall > 0 ? { background: '#ffcdd2', color: '#b71c1c', fontWeight: 700 } : { background: '#c8e6c9', color: '#1b5e20' } }
// undo / redo // undo / redo capture les quarts ET les congés en attente (les 2 sont annulables via Ctrl+Z).
function snap () { return JSON.parse(JSON.stringify(assignments.value)) } function snap () { return { a: JSON.parse(JSON.stringify(assignments.value)), p: JSON.parse(JSON.stringify(pendingAbs.value)) } }
function restoreSnap (s) { if (Array.isArray(s)) { assignments.value = s; return } assignments.value = s.a || []; pendingAbs.value = s.p || {}; savePendingAbs() }
function pushHistory () { history.value.push(snap()); if (history.value.length > 40) history.value.shift(); future.value = [] } function pushHistory () { history.value.push(snap()); if (history.value.length > 40) history.value.shift(); future.value = [] }
function undo () { if (!history.value.length) return; future.value.push(snap()); assignments.value = history.value.pop(); logChange('↶ Annulé'); scheduleDraftSave() } function undo () { if (!history.value.length) return; future.value.push(snap()); restoreSnap(history.value.pop()); logChange('↶ Annulé'); scheduleDraftSave() }
function redo () { if (!future.value.length) return; history.value.push(snap()); assignments.value = future.value.pop(); logChange('↷ Rétabli'); scheduleDraftSave() } function redo () { if (!future.value.length) return; history.value.push(snap()); restoreSnap(future.value.pop()); logChange('↷ Rétabli'); scheduleDraftSave() }
// garde anti-perte // garde anti-perte
// #2 plus de blocage : tout est auto-sauvé en brouillon. On ATTEND le flush (semaine courante) AVANT de naviguer (évite d'écrire sur la nouvelle semaine). // #2 plus de blocage : tout est auto-sauvé en brouillon. On ATTEND le flush (semaine courante) AVANT de naviguer (évite d'écrire sur la nouvelle semaine).
@ -5792,15 +5761,31 @@ async function doGenerate () {
} }
// #1 « Publier » ouvre d'abord le sommaire des changements ; la publication réelle se fait sur confirmation. // #1 « Publier » ouvre d'abord le sommaire des changements ; la publication réelle se fait sur confirmation.
function doPublish () { if (!dirty.value) return; pubConfirm.value = true } function doPublish () { if (!dirty.value) return; pubConfirm.value = true }
// #congé publish-required : commit les deltas d'absence en attente (create/remove Tech Availability), SÉQUENTIEL (frappe_pg).
// Retourne le nb écrit + les clés nouvellement mises en congé (pour l'impact IROPS, une fois l'absence réellement en vigueur).
async function flushPendingAbsences () {
const entries = Object.entries(pendingAbs.value); const newlyAbsent = []; let n = 0
for (const [k, v] of entries) {
const [tid, iso] = k.split('|')
try {
if (v.op === 'set') { await roster.setAbsence(tid, iso, v.type || 'Congé', false); newlyAbsent.push(k); n++ }
else { await roster.setAbsence(tid, iso, '', true); n++ }
} catch (e) { err(e) }
}
pendingAbs.value = {}; savePendingAbs()
return { n, newlyAbsent }
}
async function doPublishConfirmed () { async function doPublishConfirmed () {
publishing.value = true publishing.value = true
try { try {
// Mode 'publish' : promeut les brouillons (Proposé/Soumis/Approuvé) Publié + SMS. Les éditions étaient déjà auto-sauvées. // Mode 'publish' : promeut les brouillons (Proposé/Soumis/Approuvé) Publié + SMS. Les éditions étaient déjà auto-sauvées.
const r = await roster.publishWeek(start.value, days.value, assignments.value, notifySms.value, 'publish') const r = await roster.publishWeek(start.value, days.value, assignments.value, notifySms.value, 'publish')
const done = (r.created || 0) + (r.promoted || 0) const done = (r.created || 0) + (r.promoted || 0)
$q.notify({ type: r.errors ? 'warning' : 'positive', message: `Publié : ${done} quart(s)` + (r.deleted ? ` (${r.deleted} retirés)` : '') + (r.errors ? ` · ${r.errors} erreurs` : '') + (r.notified ? ` · ${r.notified} SMS` : '') }) const abs = await flushPendingAbsences() // congés en attente serveur
$q.notify({ type: r.errors ? 'warning' : 'positive', message: `Publié : ${done} quart(s)` + (abs.n ? ` · ${abs.n} congé(s)` : '') + (r.deleted ? ` (${r.deleted} retirés)` : '') + (r.errors ? ` · ${r.errors} erreurs` : '') + (r.notified ? ` · ${r.notified} SMS` : '') })
pubConfirm.value = false pubConfirm.value = false
await loadWeek() await loadWeek()
if (abs.newlyAbsent.length) await checkAbsenceImpact(abs.newlyAbsent) // IROPS : redistribue les jobs des jours désormais en congé
} catch (e) { err(e) } finally { publishing.value = false } } catch (e) { err(e) } finally { publishing.value = false }
} }
// #3 étape d'approbation FACULTATIVE : Soumettre ( Soumis) / Approuver ( Approuvé), sans SMS. « Publier » reste l'étape finale (SMS). // #3 étape d'approbation FACULTATIVE : Soumettre ( Soumis) / Approuver ( Approuvé), sans SMS. « Publier » reste l'étape finale (SMS).
@ -5815,7 +5800,7 @@ async function doWeekStatus (mode) {
} }
// demande // demande
function loadLS () { try { demand.value = JSON.parse(localStorage.getItem(LS_DEMAND) || '[]') } catch { demand.value = [] } demand.value.forEach(d => { if (!Array.isArray(d.skills)) d.skills = String(d.skills || '').split(',').map(s => s.trim()).filter(Boolean) }); /* migration CSV→tableau (ancien SkillSelect) */ try { holidays.value = JSON.parse(localStorage.getItem(LS_HOL) || '[]') } catch { holidays.value = [] } try { weekTemplates.value = JSON.parse(localStorage.getItem(LS_TPL) || '[]') } catch { weekTemplates.value = [] } try { gardeRules.value = JSON.parse(localStorage.getItem(LS_GARDE) || '[]') } catch { gardeRules.value = [] } try { manualGarde.value = JSON.parse(localStorage.getItem(LS_GARDE_MANUAL) || '{}') } catch { manualGarde.value = {} } try { customTags.value = JSON.parse(localStorage.getItem('roster-skill-tags-v1') || '[]') } catch { customTags.value = [] } try { hiddenTechs.value = JSON.parse(localStorage.getItem('roster-hidden-techs-v1') || '[]') } catch { hiddenTechs.value = [] } } function loadLS () { try { holidays.value = JSON.parse(localStorage.getItem(LS_HOL) || '[]') } catch { holidays.value = [] } try { weekTemplates.value = JSON.parse(localStorage.getItem(LS_TPL) || '[]') } catch { weekTemplates.value = [] } try { gardeRules.value = JSON.parse(localStorage.getItem(LS_GARDE) || '[]') } catch { gardeRules.value = [] } try { manualGarde.value = JSON.parse(localStorage.getItem(LS_GARDE_MANUAL) || '{}') } catch { manualGarde.value = {} } try { customTags.value = JSON.parse(localStorage.getItem('roster-skill-tags-v1') || '[]') } catch { customTags.value = [] } try { hiddenTechs.value = JSON.parse(localStorage.getItem('roster-hidden-techs-v1') || '[]') } catch { hiddenTechs.value = [] } try { pendingAbs.value = JSON.parse(localStorage.getItem(LS_PENDING_ABS) || '{}') } catch { pendingAbs.value = {} } }
// Rotation de garde par département (récurrence + rotation) // Rotation de garde par département (récurrence + rotation)
const GARDE_EPOCH = '2026-01-05' // lundi de référence pour l'index de semaine const GARDE_EPOCH = '2026-01-05' // lundi de référence pour l'index de semaine
@ -5908,30 +5893,8 @@ async function applyGardeRules () {
$q.notify({ type: 'positive', message: `Garde publiée sur ${weeks} sem. : ${r.created} assignations` + (r.deleted ? ` (${r.deleted} remplacées)` : '') + '. La grille la montrait déjà en direct ; c\'est maintenant visible par dispatch et les techs.', timeout: 6000 }) $q.notify({ type: 'positive', message: `Garde publiée sur ${weeks} sem. : ${r.created} assignations` + (r.deleted ? ` (${r.deleted} remplacées)` : '') + '. La grille la montrait déjà en direct ; c\'est maintenant visible par dispatch et les techs.', timeout: 6000 })
} catch (e) { err(e) } } catch (e) { err(e) }
} }
function saveDemand () { localStorage.setItem(LS_DEMAND, JSON.stringify(demand.value)) } // « Demande » (besoins par template) RETIRÉ remplacé par la vue Mois (MonthOverview) : besoins EN HEURES par compétence,
function onDemandSkills (d, items) { d.skills = normSkillList(items); saveDemand() } // TagEditor émet un tableau ; d.skills reste un tableau ( CSV à la génération) // couverture + alertes, et génération des Shift Requirements pour le solveur. Voir components/planif/MonthOverview.vue.
function addDemand () { demand.value = [...demand.value, { shift: templates.value[0] && templates.value[0].name, zone: 'Montréal', skills: [], job_h: 0, weekday: 1, weekend: 0, holiday: 0 }]; saveDemand() }
function removeDemand (i) { demand.value = demand.value.filter((_, j) => j !== i); saveDemand() }
async function applyDemand () {
if (!demand.value.length) { $q.notify({ type: 'warning', message: 'Aucune ligne de demande' }); return }
applying.value = true
try {
await roster.clearRequirements(start.value, days.value)
const reqs = []
for (const d of dayList.value) {
const slot = isHoliday(d.iso) ? 'holiday' : (d.weekend ? 'weekend' : 'weekday')
for (const row of demand.value) {
const n = Number(row[slot]) || 0; if (n <= 0 || !row.shift) continue
const jobH = Number(row.job_h) || 0
const sh = (tplByName.value[row.shift] && tplByName.value[row.shift].hours) || 8
const count = jobH > 0 ? Math.max(1, Math.ceil(n * jobH / sh)) : n // mode jobs effectif
reqs.push({ requirement_date: d.iso, shift_template: row.shift, zone: row.zone || '', required_count: count, required_skills: normSkillList(row.skills).join(',') })
}
}
if (reqs.length) await roster.bulkRequirements(reqs)
await loadWeek(); $q.notify({ type: 'positive', message: 'Demande appliquée : ' + reqs.length + ' besoins' })
} catch (e) { err(e) } finally { applying.value = false }
}
// modèles de semaine // modèles de semaine
function saveTemplate () { function saveTemplate () {
@ -5998,10 +5961,16 @@ function logChange (text, revert) { changeLog.value.unshift({ id: ++_chgSeq, at:
// Annule UN changement du journal : restaure la cellule (tech, date) à son état d'avant. L'annulation est elle-même dans l'historique global (undo). // Annule UN changement du journal : restaure la cellule (tech, date) à son état d'avant. L'annulation est elle-même dans l'historique global (undo).
function revertChange (c) { function revertChange (c) {
if (!c || !c.revert || c.reverted) return if (!c || !c.revert || c.reverted) return
const { tech, date, before } = c.revert
pushHistory() pushHistory()
const others = assignments.value.filter(a => !(a.tech === tech && a.date === date)) if (c.revert.absKey) { // congé : restaure le delta d'absence précédent de la cellule
assignments.value = [...others, ...JSON.parse(JSON.stringify(before || []))] const m = { ...pendingAbs.value }
if (c.revert.beforeVal) m[c.revert.absKey] = c.revert.beforeVal; else delete m[c.revert.absKey]
pendingAbs.value = m; savePendingAbs()
} else {
const { tech, date, before } = c.revert
const others = assignments.value.filter(a => !(a.tech === tech && a.date === date))
assignments.value = [...others, ...JSON.parse(JSON.stringify(before || []))]
}
c.reverted = true c.reverted = true
logChange('↶ Annulé · ' + c.text) logChange('↶ Annulé · ' + c.text)
$q.notify({ type: 'info', message: 'Changement annulé', timeout: 1600 }) $q.notify({ type: 'info', message: 'Changement annulé', timeout: 1600 })
@ -6076,17 +6045,15 @@ async function applyWindow (min, max) {
const tpl = await ensureWindowTpl(min, max) const tpl = await ensureWindowTpl(min, max)
if (tpl) { pushHistory(); setCellReplace(menu.tech.id, menu.tech.name, menu.day.iso, tpl); menu.show = false } if (tpl) { pushHistory(); setCellReplace(menu.tech.id, menu.tech.name, menu.day.iso, tpl); menu.show = false }
} }
function quickShift (min, max) { return applyWindow(min, max) }
async function applyMenuRange () { return applyWindow(menuRange.value.min, menuRange.value.max) }
// Saisie rapide d'heures : « 8-17 » · « 8:30-16 » · « 830-16 » · « 85 » (=817, dernier chiffre en pm si début). // Saisie rapide d'heures : « 8-17 » · « 8:30-16 » · « 830-16 » · « 85 » (=817, dernier chiffre en pm si début).
function parseHM (tok) { tok = String(tok).trim().toLowerCase().replace(/h/g, ':').replace(/[^\d:]/g, ''); if (!tok) return null; if (tok.includes(':')) { const [h, m] = tok.split(':'); return Number(h) + (Number(m || 0)) / 60 } if (tok.length >= 3) return Number(tok.slice(0, -2)) + Number(tok.slice(-2)) / 60; return Number(tok) } function parseHM (tok) { tok = String(tok).trim().toLowerCase().replace(/h/g, ':').replace(/[^\d:]/g, ''); if (!tok) return null; if (tok.includes(':')) { const [h, m] = tok.split(':'); return Number(h) + (Number(m || 0)) / 60 } if (tok.length >= 3) return Number(tok.slice(0, -2)) + Number(tok.slice(-2)) / 60; return Number(tok) }
function parseQuickShift (str) { function parseQuickShift (str) {
const s = (str || '').trim().toLowerCase(); if (!s) return null const s = (str || '').trim().toLowerCase(); if (!s) return null
if (/[-–—]|to|→|\s/.test(s)) { const p = s.split(/[-–—]|to|→|\s+/).filter(Boolean); if (p.length < 2) return null; const a = parseHM(p[0]); const b = parseHM(p[1]); return (a == null || b == null || b <= a || b > 24) ? null : { min: a, max: b } } if (/[-–—]|to|→|\s/.test(s)) { const p = s.split(/[-–—]|to|→|\s+/).filter(Boolean); if (p.length < 2) return null; const a = parseHM(p[0]); const b = parseHM(p[1]); return (a == null || b == null || b <= a || b > 24) ? null : { min: a, max: b } }
if (/^\d{3,4}$/.test(s)) { const end = Number(s.slice(-2)); const start = Number(s.slice(0, -2)); if (end <= 24 && start >= 0 && start < end) return { min: start, max: end } } // « 816 »=816 · « 1016 »=1016
if (/^\d{2}$/.test(s)) { const a = Number(s[0]); let b = Number(s[1]); if (b <= a) b += 12; return (b <= a || b > 24) ? null : { min: a, max: b } } // « 85 » = 817 if (/^\d{2}$/.test(s)) { const a = Number(s[0]); let b = Number(s[1]); if (b <= a) b += 12; return (b <= a || b > 24) ? null : { min: a, max: b } } // « 85 » = 817
return null return null
} }
function applyQuick () { const r = parseQuickShift(quickEntry.value); if (!r) { $q.notify({ type: 'warning', message: 'Format : 8-17 · 8:30-16 · 85' }); return } quickEntry.value = ''; applyWindow(r.min, r.max) }
function assignBulk (tpl) { pushHistory(); for (const k of selection.value) { const [tid, iso] = k.split('|'); const t = techs.value.find(x => x.id === tid); addShift(tid, t ? t.name : tid, iso, tpl) } selection.value = [] } function assignBulk (tpl) { pushHistory(); for (const k of selection.value) { const [tid, iso] = k.split('|'); const t = techs.value.find(x => x.id === tid); addShift(tid, t ? t.name : tid, iso, tpl) } selection.value = [] }
// Barre de sélection : mêmes 4 actions que le menu de cellule // Barre de sélection : mêmes 4 actions que le menu de cellule
async function bulkWindow (min, max) { if (!selection.value.length) return; const tpl = await ensureWindowTpl(min, max); if (tpl) assignBulk(tpl) } async function bulkWindow (min, max) { if (!selection.value.length) return; const tpl = await ensureWindowTpl(min, max); if (tpl) assignBulk(tpl) }

View File

@ -825,6 +825,16 @@ function renderTemplate (tpl, vars) {
// Templates are bundled inside the hub at services/targo-hub/templates/. // Templates are bundled inside the hub at services/targo-hub/templates/.
// Keep them in sync with scripts/campaigns/templates/ for the CLI use case. // Keep them in sync with scripts/campaigns/templates/ for the CLI use case.
const TEMPLATES_DIR = path.resolve(__dirname, '..', 'templates') const TEMPLATES_DIR = path.resolve(__dirname, '..', 'templates')
// Rend un template éditable (Unlayer/HTML) par NOM avec des variables Mustache.
// Réutilisé par lib/events.js (invitation d'événement). Renvoie null si nom invalide/absent
// (l'appelant retombe sur son gabarit intégré). Anti-LFI : nom validé par l'allow-list.
function renderNamedTemplate (name, vars = {}) {
if (!isValidTemplateName(name)) return null
const file = path.join(TEMPLATES_DIR, name + '.html')
if (!fs.existsSync(file)) return null
try { return renderTemplate(fs.readFileSync(file, 'utf8'), vars) } catch { return null }
}
const DEFAULT_TEMPLATE = path.join(TEMPLATES_DIR, 'gift-email-fr.html') const DEFAULT_TEMPLATE = path.join(TEMPLATES_DIR, 'gift-email-fr.html')
// Allow-list templates that can be edited via the API. Naming convention: // Allow-list templates that can be edited via the API. Naming convention:
@ -836,7 +846,7 @@ const DEFAULT_TEMPLATE = path.join(TEMPLATES_DIR, 'gift-email-fr.html')
// + disk-scan approach so the user can create new templates from the editor // + disk-scan approach so the user can create new templates from the editor
// UI without us re-deploying the hub. The prefix list bounds what names are // UI without us re-deploying the hub. The prefix list bounds what names are
// allowed (prevents arbitrary file writes outside the campaign domain). // allowed (prevents arbitrary file writes outside the campaign domain).
const EDITABLE_TEMPLATE_PREFIXES = ['gift-email-', 'newsletter-', 'transactional-'] const EDITABLE_TEMPLATE_PREFIXES = ['gift-email-', 'newsletter-', 'transactional-', 'event-']
const TEMPLATE_NAME_RE = /^[a-z0-9-]+$/ // lowercase, digits, dashes only const TEMPLATE_NAME_RE = /^[a-z0-9-]+$/ // lowercase, digits, dashes only
function isValidTemplateName (name) { function isValidTemplateName (name) {
@ -2787,5 +2797,5 @@ module.exports = {
// Exposed for testing // Exposed for testing
parseCsv, parseMapCsv, parseGiftbitCsv, parseCsv, parseMapCsv, parseGiftbitCsv,
matchCustomer, normalizeCivic, normalizePhone, normalizePostal, matchCustomer, normalizeCivic, normalizePhone, normalizePostal,
renderTemplate, renderTemplate, renderNamedTemplate,
} }

View File

@ -76,6 +76,19 @@ async function sendEmail (opts) {
}) })
} }
// Generic attachments (e.g. event invitation images) : [{ filename, content(Buffer), contentType|mime }]
if (Array.isArray(opts.attachments)) {
for (const a of opts.attachments) {
if (a && a.filename && a.content) {
mailOpts.attachments.push({
filename: a.filename,
content: a.content,
contentType: a.contentType || a.mime || 'application/octet-stream',
})
}
}
}
try { try {
const info = await transport.sendMail(mailOpts) const info = await transport.sendMail(mailOpts)
log(`Email sent to ${opts.to}: ${info.messageId || 'OK'}`) log(`Email sent to ${opts.to}: ${info.messageId || 'OK'}`)

View File

@ -7,8 +7,22 @@
* ?t=<jwt magic-link client> pré-remplit nom/courriel + rattache d'office. * ?t=<jwt magic-link client> pré-remplit nom/courriel + rattache d'office.
* - POST /rsvp/<eventId>/submit rattachement + validation croisée AVEUGLE (voir matchAndValidate), * - POST /rsvp/<eventId>/submit rattachement + validation croisée AVEUGLE (voir matchAndValidate),
* clé par client re-soumission = mise à jour (pas de doublon). Jamais bloquant. * clé par client re-soumission = mise à jour (pas de doublon). Jamais bloquant.
* - Endpoints STAFF (gatés) : GET /events, GET /events/<id>/rsvps (+ décompte + confiance), * - Endpoints STAFF (gatés) : GET /events, POST /events (créer), PUT/DELETE /events/<id>,
* DELETE /events/<id>/rsvps/<key>, POST /events/<id>/send (mode test). * GET /events/<id>/rsvps (+ décompte + confiance), DELETE /events/<id>/rsvps/<key>,
* POST /events/<id>/send (mode test), POST /events/<id>/audience (aperçu),
* POST /events/<id>/attachments (téléverser une pièce jointe image), DELETE /events/<id>/attachments/<nom>.
*
* MODÈLE : chaque événement réutilise la mise en page festive (barre confetti, pastille badge,
* pastilles de programme). Le CONTENU (nom, date, textes, programme, badge, capacité, pièces jointes)
* est ÉDITABLE et persisté par fichier ; les LIBELLÉS d'interface (champs, erreurs, remerciements)
* sont communs à tous les événements (const LABELS). L'événement « Fête 20 ans » est un cas semé.
*
* CAPACITÉ : `capacity` (0 = illimité) = plafond de PERSONNES (somme des party_size). À l'atteinte,
* le formulaire public est remplacé par un message « inscriptions complètes » (enforcement serveur
* ET client) ; un inscrit existant (jeton) peut toujours modifier sa réponse.
*
* PIÈCES JOINTES : images/fichiers joints au COURRIEL d'invitation (masse + test) uniquement pas
* affichées sur la page RSVP. Stockées sur disque (DATA_DIR/attachments/<id>/), métadonnées dans la config.
* *
* IMPORTANT (demande user) : AUCUN autosuggest/auto-remplissage à partir des données client * IMPORTANT (demande user) : AUCUN autosuggest/auto-remplissage à partir des données client
* (fuite de PII). Le client saisit ce qu'il connaît ; on valide côté serveur, à la soumission, * (fuite de PII). Le client saisit ce qu'il connaît ; on valide côté serveur, à la soumission,
@ -24,19 +38,83 @@ const { log, json, parseBody, readJsonFile, writeJsonFile, lookupCustomersByEmai
const { generateCustomerToken, verifyJwt } = require('./magic-link') const { generateCustomerToken, verifyJwt } = require('./magic-link')
const DATA_DIR = process.env.EVENTS_DATA_DIR || '/app/data/events' const DATA_DIR = process.env.EVENTS_DATA_DIR || '/app/data/events'
try { fs.mkdirSync(DATA_DIR, { recursive: true }) } catch { /* best-effort */ } const CONFIG_DIR = path.join(DATA_DIR, 'config') // configs d'événements créés/édités (shadow le SEED)
const ATT_DIR = path.join(DATA_DIR, 'attachments') // pièces jointes des courriels d'invitation
const AUDIENCE_DIR = path.join(DATA_DIR, 'audience') // liste principale d'audience par événement (additive)
try { fs.mkdirSync(CONFIG_DIR, { recursive: true }) } catch { /* best-effort */ }
try { fs.mkdirSync(ATT_DIR, { recursive: true }) } catch { /* best-effort */ }
try { fs.mkdirSync(AUDIENCE_DIR, { recursive: true }) } catch { /* best-effort */ }
const pub = () => cfg.HUB_PUBLIC_URL || 'https://msg.gigafibre.ca' const pub = () => cfg.HUB_PUBLIC_URL || 'https://msg.gigafibre.ca'
const LOGO = 'https://msg.gigafibre.ca/campaigns/assets/6a2bcb6057d9881c08304a5d2fea8723e2a15b05061fbbe3590bd4a0ee714477.png' const LOGO = 'https://msg.gigafibre.ca/campaigns/assets/6a2bcb6057d9881c08304a5d2fea8723e2a15b05061fbbe3590bd4a0ee714477.png'
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])) const esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]))
const normLang = (l) => /^en/i.test(String(l || '')) ? 'en' : 'fr' const normLang = (l) => /^en/i.test(String(l || '')) ? 'en' : 'fr'
const firstName = (n) => String(n || '').trim().split(/\s+/)[0] || '' const firstName = (n) => String(n || '').trim().split(/\s+/)[0] || ''
// ── Config événements (semé : Fête Targo 20 ans) ─────────────────────────── // ── Libellés d'interface COMMUNS (indépendants de l'événement) ─────────────
const SEED = { // Séparés du contenu éditable : mêmes champs/erreurs/remerciements pour tous les événements.
const LABELS = {
fr: {
f_name: 'Votre nom',
f_email: 'Votre adresse courriel',
f_phone: 'Votre téléphone',
f_number: 'Votre numéro client',
f_party: 'Combien serez-vous ?',
f_allerg: 'Allergies ou restrictions alimentaires',
opt: '(optionnel)',
f_party_ph: 'Nombre de personnes',
submit: 'Confirmer ma présence',
greet: (n) => n ? `Bonjour ${n} !` : '',
thanks_title: 'Merci, votre présence est confirmée ! 🎉',
thanks_body: "On a hâte de vous voir. Vous recevrez votre coupon repas à l'accueil.",
thanks_recognized: (n) => `✓ Nous vous avons reconnu${n} — inscription reliée à votre compte.`,
thanks_tocheck: 'Nous validerons votre inscription sous peu.',
thanks_update: 'Vous pouvez modifier votre réponse en tout temps depuis ce lien.',
err_name: 'Veuillez entrer votre nom.',
err_party: 'Veuillez indiquer combien de personnes seront présentes.',
err_email: 'Veuillez entrer une adresse courriel valide.',
err_generic: "Une erreur s'est produite. Réessayez ou écrivez-nous.",
foot: 'TARGO — votre fournisseur Internet local',
full_title: 'Inscriptions complètes',
full_msg: "Malheureusement, les inscriptions à l'événement sont complètes.",
},
en: {
f_name: 'Your name',
f_email: 'Your email address',
f_phone: 'Your phone',
f_number: 'Your customer number',
f_party: 'How many will attend?',
f_allerg: 'Allergies or dietary restrictions',
opt: '(optional)',
f_party_ph: 'Number of people',
submit: 'Confirm my attendance',
greet: (n) => n ? `Hi ${n}!` : '',
thanks_title: "Thank you, you're confirmed! 🎉",
thanks_body: 'We look forward to seeing you. Pick up your meal coupon at the welcome desk.',
thanks_recognized: (n) => `✓ We found your account${n} — your RSVP is linked.`,
thanks_tocheck: "We'll confirm your registration shortly.",
thanks_update: 'You can change your answer anytime from this link.',
err_name: 'Please enter your name.',
err_party: 'Please tell us how many people will attend.',
err_email: 'Please enter a valid email address.',
err_generic: 'Something went wrong. Please try again or contact us.',
foot: 'TARGO — your local Internet provider',
full_title: 'Registration full',
full_msg: 'Unfortunately, the event booking is full.',
},
}
// ── Contenu éditable des événements (semé : Fête Targo 20 ans) ─────────────
// Forme brute persistée : { id, active, date_iso, badge_top, badge_bottom, capacity, attachments[],
// fr:{ name, tagline, when, body, program:[{icon,label,sub}], program_line, limited, closing, notes[] }, en:{...} }
const SEED_CONTENT = {
'fete-20-ans': { 'fete-20-ans': {
id: 'fete-20-ans', id: 'fete-20-ans',
active: true, active: true,
date_iso: '2026-08-01', date_iso: '2026-08-01',
badge_top: '20',
badge_bottom: 'ANS',
capacity: 0,
attachments: [],
fr: { fr: {
name: 'Targo fête ses 20 ans', name: 'Targo fête ses 20 ans',
tagline: "Toute l'équipe de Targo est heureuse de vous inviter à notre grande fête d'anniversaire !", tagline: "Toute l'équipe de Targo est heureuse de vous inviter à notre grande fête d'anniversaire !",
@ -44,10 +122,10 @@ const SEED = {
body: "Joignez-vous à nous pour une journée de plaisir, de rencontres et de célébration en compagnie des employés de Targo et de leurs familles.", body: "Joignez-vous à nous pour une journée de plaisir, de rencontres et de célébration en compagnie des employés de Targo et de leurs familles.",
program: [ program: [
{ icon: '🚚', label: 'Food truck', sub: 'repas inclus' }, { icon: '🚚', label: 'Food truck', sub: 'repas inclus' },
{ icon: '🎧', label: 'DJ' }, { icon: '🎧', label: 'DJ', sub: '' },
{ icon: '🎪', label: 'Jeux gonflables' }, { icon: '🎪', label: 'Jeux gonflables', sub: '' },
{ icon: '🎨', label: 'Maquillage' }, { icon: '🎨', label: 'Maquillage', sub: '' },
{ icon: '🎁', label: 'Et des surprises' }, { icon: '🎁', label: 'Et des surprises', sub: '' },
], ],
program_line: "Le tout dans une ambiance familiale et conviviale.", program_line: "Le tout dans une ambiance familiale et conviviale.",
limited: "Les places étant limitées, nous vous invitons à confirmer votre présence dès que possible en remplissant le formulaire ci-dessous.", limited: "Les places étant limitées, nous vous invitons à confirmer votre présence dès que possible en remplissant le formulaire ci-dessous.",
@ -56,26 +134,6 @@ const SEED = {
'Si vous avez des allergies, le menu pourrait ne pas vous convenir.', 'Si vous avez des allergies, le menu pourrait ne pas vous convenir.',
"Vous recevrez un coupon repas sur place (à l'accueil).", "Vous recevrez un coupon repas sur place (à l'accueil).",
], ],
f_name: 'Votre nom',
f_email: 'Votre adresse courriel',
f_phone: 'Votre téléphone',
f_number: 'Votre numéro client',
f_party: 'Combien serez-vous ?',
f_allerg: 'Allergies ou restrictions alimentaires',
opt: '(optionnel)',
f_party_ph: 'Nombre de personnes',
submit: 'Confirmer ma présence',
greet: (n) => n ? `Bonjour ${n} !` : '',
thanks_title: 'Merci, votre présence est confirmée ! 🎉',
thanks_body: "On a hâte de vous voir le 1ᵉʳ août. Vous recevrez votre coupon repas à l'accueil.",
thanks_recognized: (n) => `✓ Nous vous avons reconnu${n} — inscription reliée à votre compte.`,
thanks_tocheck: 'Nous validerons votre inscription sous peu.',
thanks_update: 'Vous pouvez modifier votre réponse en tout temps depuis ce lien.',
err_name: 'Veuillez entrer votre nom.',
err_party: 'Veuillez indiquer combien de personnes seront présentes.',
err_email: 'Veuillez entrer une adresse courriel valide.',
err_generic: "Une erreur s'est produite. Réessayez ou écrivez-nous.",
foot: 'TARGO — votre fournisseur Internet local',
}, },
en: { en: {
name: 'Targo turns 20!', name: 'Targo turns 20!',
@ -84,10 +142,10 @@ const SEED = {
body: 'Join us for a day of fun, meeting people and celebrating alongside the Targo team and their families.', body: 'Join us for a day of fun, meeting people and celebrating alongside the Targo team and their families.',
program: [ program: [
{ icon: '🚚', label: 'Food truck', sub: 'meal included' }, { icon: '🚚', label: 'Food truck', sub: 'meal included' },
{ icon: '🎧', label: 'DJ' }, { icon: '🎧', label: 'DJ', sub: '' },
{ icon: '🎪', label: 'Bouncy castles' }, { icon: '🎪', label: 'Bouncy castles', sub: '' },
{ icon: '🎨', label: 'Face painting' }, { icon: '🎨', label: 'Face painting', sub: '' },
{ icon: '🎁', label: 'And surprises' }, { icon: '🎁', label: 'And surprises', sub: '' },
], ],
program_line: 'All in a warm, family-friendly atmosphere.', program_line: 'All in a warm, family-friendly atmosphere.',
limited: 'Space is limited, so please confirm your attendance as soon as possible using the form below.', limited: 'Space is limited, so please confirm your attendance as soon as possible using the form below.',
@ -96,37 +154,107 @@ const SEED = {
'If you have allergies, the menu may not suit you.', 'If you have allergies, the menu may not suit you.',
'You will receive a meal coupon on site (at the welcome desk).', 'You will receive a meal coupon on site (at the welcome desk).',
], ],
f_name: 'Your name',
f_email: 'Your email address',
f_phone: 'Your phone',
f_number: 'Your customer number',
f_party: 'How many will attend?',
f_allerg: 'Allergies or dietary restrictions',
opt: '(optional)',
f_party_ph: 'Number of people',
submit: 'Confirm my attendance',
greet: (n) => n ? `Hi ${n}!` : '',
thanks_title: "Thank you, you're confirmed! 🎉",
thanks_body: 'We look forward to seeing you on August 1st. Pick up your meal coupon at the welcome desk.',
thanks_recognized: (n) => `✓ We found your account${n} — your RSVP is linked.`,
thanks_tocheck: "We'll confirm your registration shortly.",
thanks_update: 'You can change your answer anytime from this link.',
err_name: 'Please enter your name.',
err_party: 'Please tell us how many people will attend.',
err_email: 'Please enter a valid email address.',
err_generic: 'Something went wrong. Please try again or contact us.',
foot: 'TARGO — your local Internet provider',
}, },
}, },
} }
function getEvent (eventId) { return SEED[eventId] || null } // ── Persistance des configs (fichier shadow le SEED ; édition/attachments écrivent ici) ──
function listEvents () { return Object.values(SEED).map(e => ({ id: e.id, active: e.active, date_iso: e.date_iso, name: e.fr.name })) } function configFile (id) { return path.join(CONFIG_DIR, `${id}.json`) }
function rawConfig (id) {
const stored = readJsonFile(configFile(id), null)
if (stored && stored.id) return stored
if (SEED_CONTENT[id]) return JSON.parse(JSON.stringify(SEED_CONTENT[id]))
return null
}
function saveConfig (id, obj) { obj.id = id; writeJsonFile(configFile(id), obj) }
// Construit l'objet événement d'exécution : contenu + LIBELLÉS communs (EN retombe sur FR si vide).
function buildEvent (base) {
if (!base) return null
const content = (lang) => {
const c = base[lang] && Object.keys(base[lang]).length ? base[lang] : (base.fr || {})
return { ...LABELS[lang], ...c, program: Array.isArray(c.program) ? c.program : [], notes: Array.isArray(c.notes) ? c.notes : [] }
}
return {
id: base.id,
active: base.active !== false,
date_iso: base.date_iso || '',
badge_top: String(base.badge_top || ''),
badge_bottom: String(base.badge_bottom || ''),
capacity: Math.max(0, parseInt(base.capacity, 10) || 0),
email_template: String(base.email_template || ''), // '' = gabarit festif intégré ; sinon nom d'un template éditable (Unlayer)
attachments: Array.isArray(base.attachments) ? base.attachments : [],
fr: content('fr'),
en: content('en'),
}
}
function getEvent (eventId) { return buildEvent(rawConfig(eventId)) }
function listEventIds () {
const ids = new Set(Object.keys(SEED_CONTENT))
try { for (const f of fs.readdirSync(CONFIG_DIR)) if (f.endsWith('.json')) ids.add(f.slice(0, -5)) } catch { /* best-effort */ }
return [...ids]
}
function listEvents () {
return listEventIds().map(getEvent).filter(Boolean)
.map(e => ({ id: e.id, active: e.active, date_iso: e.date_iso, name: e.fr.name, capacity: e.capacity, attachments: (e.attachments || []).length }))
.sort((a, b) => (b.date_iso || '').localeCompare(a.date_iso || ''))
}
// ── Slug / validation à la création ────────────────────────────────────────
function slugify (s) {
return String(s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60)
}
function uniqueId (base) {
let id = base || 'evenement'; let n = 2
const taken = new Set(listEventIds())
while (taken.has(id)) { id = `${base}-${n++}` }
return id
}
function sanitizeProgram (arr) {
return (Array.isArray(arr) ? arr : []).slice(0, 8).map(p => ({
icon: String(p.icon || '').slice(0, 8),
label: String(p.label || '').slice(0, 60),
sub: String(p.sub || '').slice(0, 60),
})).filter(p => p.label || p.icon)
}
function sanitizeLangContent (c) {
c = c || {}
return {
name: String(c.name || '').slice(0, 160),
tagline: String(c.tagline || '').slice(0, 300),
when: String(c.when || '').slice(0, 120),
body: String(c.body || '').slice(0, 1200),
program: sanitizeProgram(c.program),
program_line: String(c.program_line || '').slice(0, 200),
limited: String(c.limited || '').slice(0, 400),
closing: String(c.closing || '').slice(0, 300),
notes: (Array.isArray(c.notes) ? c.notes : []).slice(0, 6).map(n => String(n || '').slice(0, 300)).filter(Boolean),
}
}
// Normalise le corps de création/édition en une config brute persistable.
function normalizeConfig (b, existing = null) {
const fr = sanitizeLangContent(b.fr)
const enRaw = b.en && Object.values(b.en).some(v => (Array.isArray(v) ? v.length : String(v || '').trim())) ? sanitizeLangContent(b.en) : null
return {
id: existing ? existing.id : undefined,
active: b.active !== false,
date_iso: /^\d{4}-\d{2}-\d{2}$/.test(String(b.date_iso || '')) ? b.date_iso : (existing ? existing.date_iso : ''),
badge_top: String(b.badge_top || '').slice(0, 6),
badge_bottom: String(b.badge_bottom || '').slice(0, 8),
capacity: Math.max(0, parseInt(b.capacity, 10) || 0),
email_template: /^[a-z0-9-]+$/.test(String(b.email_template || '')) ? String(b.email_template) : '',
attachments: existing && Array.isArray(existing.attachments) ? existing.attachments : [],
fr,
...(enRaw ? { en: enRaw } : {}),
}
}
// ── Stockage des réponses (une map par événement, clé = client) ──────────── // ── Stockage des réponses (une map par événement, clé = client) ────────────
function respFile (eventId) { return path.join(DATA_DIR, `${eventId}.json`) } function respFile (eventId) { return path.join(DATA_DIR, `${eventId}.json`) }
function loadResp (eventId) { const d = readJsonFile(respFile(eventId), {}); return (d && typeof d === 'object' && !Array.isArray(d)) ? d : {} } function loadResp (eventId) { const d = readJsonFile(respFile(eventId), {}); return (d && typeof d === 'object' && !Array.isArray(d)) ? d : {} }
function saveResp (eventId, m) { writeJsonFile(respFile(eventId), m) } function saveResp (eventId, m) { writeJsonFile(respFile(eventId), m) }
function headcountOf (m) { return Object.values(m).reduce((s, r) => s + (parseInt(r.party_size, 10) || 0), 0) }
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/ const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/
function clampParty (v) { const n = parseInt(v, 10); if (!Number.isFinite(n) || n < 1) return null; return Math.min(30, n) } function clampParty (v) { const n = parseInt(v, 10); if (!Number.isFinite(n) || n < 1) return null; return Math.min(30, n) }
@ -179,15 +307,35 @@ function inviteSubject (eventId, lang) {
const e = getEvent(eventId); if (!e) return '' const e = getEvent(eventId); if (!e) return ''
return normLang(lang) === 'en' ? `You're invited — ${e.en.name} 🎉` : `Vous êtes invité — ${e.fr.name} 🎉` return normLang(lang) === 'en' ? `You're invited — ${e.en.name} 🎉` : `Vous êtes invité — ${e.fr.name} 🎉`
} }
// Variables Mustache offertes aux templates éditables d'invitation d'événement.
function inviteTemplateVars (e, lang, name, rsvpUrl) {
const t = e[lang]
return {
firstname: firstName(name), name: name || '', rsvp_url: rsvpUrl, gift_url: rsvpUrl, // gift_url = alias (templates cadeau réutilisables)
event_name: t.name, tagline: t.tagline, when: t.when, body: t.body, closing: t.closing,
year: String(new Date().getFullYear()), lang,
}
}
// Invitation : modèle éditable (Unlayer) si configuré, sinon gabarit festif intégré.
function inviteEmail (eventId, lang, name, rsvpUrl) { function inviteEmail (eventId, lang, name, rsvpUrl) {
const e = getEvent(eventId); if (!e) return '' const e = getEvent(eventId); if (!e) return ''
lang = normLang(lang)
if (e.email_template) {
const html = require('./campaigns').renderNamedTemplate(e.email_template, inviteTemplateVars(e, lang, name, rsvpUrl))
if (html) return html // template introuvable → repli festif
}
return inviteEmailFestive(e, lang, name, rsvpUrl)
}
// Gabarit festif intégré (charte TARGO garantie) — utilisé par défaut et en repli.
function inviteEmailFestive (e, lang, name, rsvpUrl) {
lang = normLang(lang); const t = e[lang] lang = normLang(lang); const t = e[lang]
const merci = lang === 'en' ? 'Thank you for your trust over the years! 💚' : 'Merci de votre confiance au fil des années ! 💚' const merci = lang === 'en' ? 'Thank you for your trust over the years! 💚' : 'Merci de votre confiance au fil des années ! 💚'
const badge = [e.badge_top, e.badge_bottom].filter(Boolean).join(' ')
const CIRC = ['#e8f5ec', '#e6f2f9', '#f1f5f9', '#e8f5ec', '#e6f2f9'] // vert / bleu / gris (palette TARGO) const CIRC = ['#e8f5ec', '#e6f2f9', '#f1f5f9', '#e8f5ec', '#e6f2f9'] // vert / bleu / gris (palette TARGO)
const LBL = ['#1e8e3e', '#1a6f9e', '#4b5563', '#1e8e3e', '#1a6f9e'] const LBL = ['#1e8e3e', '#1a6f9e', '#4b5563', '#1e8e3e', '#1a6f9e']
const prog = t.program.map((p, i) => const prog = (t.program || []).map((p, i) =>
`<td align="center" valign="top" style="padding:6px 3px">` `<td align="center" valign="top" style="padding:6px 3px">`
+ `<div style="width:50px;height:50px;line-height:50px;border-radius:25px;background:${CIRC[i % 5]};font-size:25px;margin:0 auto">${p.icon}</div>` + `<div style="width:50px;height:50px;line-height:50px;border-radius:25px;background:${CIRC[i % 5]};font-size:25px;margin:0 auto">${esc(p.icon)}</div>`
+ `<div style="margin-top:6px;font-size:10px;font-weight:700;color:${LBL[i % 5]};line-height:1.2">${esc(p.label)}</div></td>`).join('') + `<div style="margin-top:6px;font-size:10px;font-weight:700;color:${LBL[i % 5]};line-height:1.2">${esc(p.label)}</div></td>`).join('')
return `<!doctype html><html lang="${lang}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>` return `<!doctype html><html lang="${lang}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>`
+ `<body style="margin:0;background:#eef4f8;font-family:-apple-system,Segoe UI,Roboto,Arial,sans-serif;color:#1e293b">` + `<body style="margin:0;background:#eef4f8;font-family:-apple-system,Segoe UI,Roboto,Arial,sans-serif;color:#1e293b">`
@ -196,26 +344,86 @@ function inviteEmail (eventId, lang, name, rsvpUrl) {
+ `<tr><td style="height:10px;background:#1a86c7;font-size:0;line-height:0">&nbsp;</td></tr>` + `<tr><td style="height:10px;background:#1a86c7;font-size:0;line-height:0">&nbsp;</td></tr>`
+ `<tr><td align="center" style="padding:22px 30px 0">` + `<tr><td align="center" style="padding:22px 30px 0">`
+ `<div style="font-size:24px;letter-spacing:6px;line-height:1">🎈🎉🎈</div>` + `<div style="font-size:24px;letter-spacing:6px;line-height:1">🎈🎉🎈</div>`
+ `<div style="display:inline-block;background:#21a34a;color:#fff;font-weight:800;font-size:12px;letter-spacing:1.5px;padding:6px 16px;border-radius:999px;margin:10px 0 8px">🎉 20 ANS 🎉</div><br>` + (badge ? `<div style="display:inline-block;background:#21a34a;color:#fff;font-weight:800;font-size:12px;letter-spacing:1.5px;padding:6px 16px;border-radius:999px;margin:10px 0 8px">🎉 ${esc(badge)} 🎉</div><br>` : '<div style="height:10px"></div>')
+ `<img src="${LOGO}" alt="TARGO" width="150" style="width:150px;max-width:150px;height:auto;display:inline-block;border:0;margin:2px 0"></td></tr>` + `<img src="${LOGO}" alt="TARGO" width="150" style="width:150px;max-width:150px;height:auto;display:inline-block;border:0;margin:2px 0"></td></tr>`
+ `<tr><td align="center" style="padding:8px 26px 0"><h1 style="margin:6px 0 4px;font-size:27px;font-weight:800;color:#1f2937;line-height:1.15">${esc(t.name)} 🎉</h1>` + `<tr><td align="center" style="padding:8px 26px 0"><h1 style="margin:6px 0 4px;font-size:27px;font-weight:800;color:#1f2937;line-height:1.15">${esc(t.name)} 🎉</h1>`
+ `<p style="margin:0;font-size:15px;font-weight:700;color:#21a34a;line-height:1.4">${esc(t.tagline)}</p></td></tr>` + `<p style="margin:0;font-size:15px;font-weight:700;color:#21a34a;line-height:1.4">${esc(t.tagline)}</p></td></tr>`
+ `<tr><td style="padding:16px 34px 4px"><p style="margin:0 0 10px;font-size:15px;line-height:1.6;color:#475569">${esc(t.greet(firstName(name)) || '')}</p>` + `<tr><td style="padding:16px 34px 4px"><p style="margin:0 0 10px;font-size:15px;line-height:1.6;color:#475569">${esc(t.greet(firstName(name)) || '')}</p>`
+ `<p style="margin:0;font-size:15px;line-height:1.6;color:#475569">${esc(t.body)}</p></td></tr>` + `<p style="margin:0;font-size:15px;line-height:1.6;color:#475569">${esc(t.body)}</p></td></tr>`
+ `<tr><td align="center" style="padding:16px 30px 6px"><table role="presentation" cellpadding="0" cellspacing="0"><tr><td style="background:#1f2937;border-radius:12px;padding:13px 24px;color:#fff;font-size:18px;font-weight:800">📅 ${esc(t.when)}</td></tr></table></td></tr>` + (t.when ? `<tr><td align="center" style="padding:16px 30px 6px"><table role="presentation" cellpadding="0" cellspacing="0"><tr><td style="background:#1f2937;border-radius:12px;padding:13px 24px;color:#fff;font-size:18px;font-weight:800">📅 ${esc(t.when)}</td></tr></table></td></tr>` : '')
+ `<tr><td style="padding:14px 18px 4px"><table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr>${prog}</tr></table></td></tr>` + ((t.program || []).length ? `<tr><td style="padding:14px 18px 4px"><table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr>${prog}</tr></table></td></tr>` : '')
+ `<tr><td align="center" style="padding:2px 30px 8px"><p style="margin:0;font-size:13px;font-style:italic;color:#94a3b8">${esc(t.program_line)}</p></td></tr>` + (t.program_line ? `<tr><td align="center" style="padding:2px 30px 8px"><p style="margin:0;font-size:13px;font-style:italic;color:#94a3b8">${esc(t.program_line)}</p></td></tr>` : '')
+ `<tr><td style="padding:6px 30px 4px"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f3f4f6;border:1px solid #e5e7eb;border-radius:12px"><tr><td style="padding:12px 16px;font-size:14px;line-height:1.6;color:#374151">⏳ ${esc(t.limited)}</td></tr></table></td></tr>` + (t.limited ? `<tr><td style="padding:6px 30px 4px"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f3f4f6;border:1px solid #e5e7eb;border-radius:12px"><tr><td style="padding:12px 16px;font-size:14px;line-height:1.6;color:#374151">⏳ ${esc(t.limited)}</td></tr></table></td></tr>` : '')
+ `<tr><td align="center" style="padding:20px 30px 6px"><table role="presentation" cellpadding="0" cellspacing="0"><tr><td style="background:#21a34a;border-radius:14px;box-shadow:0 6px 16px rgba(33,163,74,.3)"><a href="${esc(rsvpUrl)}" target="_blank" style="display:inline-block;padding:16px 40px;color:#fff;font-size:17px;font-weight:800;text-decoration:none">${esc(t.submit)} →</a></td></tr></table></td></tr>` + `<tr><td align="center" style="padding:20px 30px 6px"><table role="presentation" cellpadding="0" cellspacing="0"><tr><td style="background:#21a34a;border-radius:14px;box-shadow:0 6px 16px rgba(33,163,74,.3)"><a href="${esc(rsvpUrl)}" target="_blank" style="display:inline-block;padding:16px 40px;color:#fff;font-size:17px;font-weight:800;text-decoration:none">${esc(t.submit)} →</a></td></tr></table></td></tr>`
+ `<tr><td align="center" style="padding:8px 30px 20px"><p style="margin:0;font-size:15px;font-weight:700;color:#21a34a">${esc(merci)}</p>` + `<tr><td align="center" style="padding:8px 30px 20px"><p style="margin:0;font-size:15px;font-weight:700;color:#21a34a">${esc(merci)}</p>`
+ `<p style="margin:6px 0 0;font-size:13px;color:#94a3b8">${esc(t.closing)}</p></td></tr>` + `<p style="margin:6px 0 0;font-size:13px;color:#94a3b8">${esc(t.closing)}</p></td></tr>`
+ `<tr><td style="background:#f0f7f2;padding:16px 30px;text-align:center;font-size:12px;line-height:1.6;color:#94a3b8;border-top:1px solid #e2e8f0">${esc(t.notes[0])}<br>${esc(t.notes[1])}` + `<tr><td style="background:#f0f7f2;padding:16px 30px;text-align:center;font-size:12px;line-height:1.6;color:#94a3b8;border-top:1px solid #e2e8f0">${(t.notes || []).map(esc).join('<br>')}`
+ `<div style="margin-top:12px;padding-top:12px;border-top:1px solid #dce8e0"><a href="https://targo.ca" style="color:#21a34a;font-weight:700;text-decoration:none">🌐 targo.ca</a> &nbsp;·&nbsp; ${esc(t.foot)}</div></td></tr>` + `<div style="margin-top:12px;padding-top:12px;border-top:1px solid #dce8e0"><a href="https://targo.ca" style="color:#21a34a;font-weight:700;text-decoration:none">🌐 targo.ca</a> &nbsp;·&nbsp; ${esc(t.foot)}</div></td></tr>`
+ `</table></td></tr></table></body></html>` + `</table></td></tr></table></body></html>`
} }
// ── Pièces jointes du courriel d'invitation ────────────────────────────────
const ATT_MAX_BYTES = 4 * 1024 * 1024 // 4 Mo par fichier
const ATT_MAX_COUNT = 6
const ATT_MIME_OK = /^(image\/(png|jpe?g|gif|webp)|application\/pdf)$/i
function eventAttDir (eventId) { const d = path.join(ATT_DIR, eventId); try { fs.mkdirSync(d, { recursive: true }) } catch { /* */ } return d }
function safeName (name) { return String(name || 'fichier').replace(/[^A-Za-z0-9._-]+/g, '_').slice(0, 80) || 'fichier' }
// Langue d'une pièce jointe : 'fr' | 'en' | 'both' (both = envoyée quelle que soit la langue du client).
function normAttLang (l) { l = String(l || '').toLowerCase(); return (l === 'fr' || l === 'en') ? l : 'both' }
// Détection best-effort de la langue d'une image (affiche/flyer) via Gemini Vision. 'both' si échec/ambigu.
const LANG_SCHEMA = { type: 'object', properties: { lang: { type: 'string', enum: ['fr', 'en', 'both'] } }, required: ['lang'] }
async function detectAttachmentLang (base64, mime) {
try {
if (!/^image\//i.test(String(mime || ''))) return 'both' // PDF & co : pas d'auto-détection
const r = await require('./vision').geminiVision(base64,
'This is a marketing/event flyer or poster image. Detect the PRIMARY written language of its text. Answer "fr" (French), "en" (English), or "both" if bilingual or if there is no meaningful text.',
LANG_SCHEMA, String(mime))
return normAttLang(r && r.lang)
} catch (e) { log('detectAttachmentLang: ' + e.message); return 'both' }
}
// Ajoute une pièce jointe (data base64) à un événement → persiste le fichier + métadonnée (dont langue).
// lang: 'fr'|'en'|'both' ; lang='auto' → détection Gemini best-effort (images seulement).
async function addAttachment (eventId, { filename, mime, data, lang }) {
const base = rawConfig(eventId); if (!base) return { error: 'event_not_found' }
if (!ATT_MIME_OK.test(String(mime || ''))) return { error: 'bad_type' }
const raw = String(data || '').replace(/^data:[^,]*,/, '')
const buf = Buffer.from(raw, 'base64')
if (!buf.length) return { error: 'empty' }
if (buf.length > ATT_MAX_BYTES) return { error: 'too_large' }
base.attachments = Array.isArray(base.attachments) ? base.attachments : []
if (base.attachments.length >= ATT_MAX_COUNT) return { error: 'too_many' }
const alang = String(lang || '').toLowerCase() === 'auto' ? await detectAttachmentLang(raw, mime) : normAttLang(lang)
const stored = Date.now().toString(36) + '-' + safeName(filename)
fs.writeFileSync(path.join(eventAttDir(eventId), stored), buf)
const meta = { filename: safeName(filename), stored, mime: String(mime), size: buf.length, lang: alang }
base.attachments.push(meta)
saveConfig(eventId, base)
return { attachment: meta, attachments: base.attachments }
}
function removeAttachment (eventId, stored) {
const base = rawConfig(eventId); if (!base) return { error: 'event_not_found' }
base.attachments = (base.attachments || []).filter(a => a.stored !== stored)
try { fs.unlinkSync(path.join(eventAttDir(eventId), stored)) } catch { /* déjà absent */ }
saveConfig(eventId, base)
return { attachments: base.attachments }
}
// Lit les pièces jointes en mémoire pour l'envoi (buffers), FILTRÉES par langue du destinataire.
// On joint les fichiers de SA langue + les fichiers 'both' (neutres). Ignore un fichier manquant.
function loadSendAttachments (event, lang) {
const want = normLang(lang) // 'fr' ou 'en'
const out = []
for (const a of (event.attachments || [])) {
const al = a.lang || 'both'
if (al !== 'both' && al !== want) continue
try { out.push({ filename: a.filename, mime: a.mime, buffer: fs.readFileSync(path.join(eventAttDir(event.id), a.stored)) }) } catch { /* fichier absent */ }
}
return out
}
// ── Envoi TEST (déclenché par le staff depuis l'admin ; jamais automatique) ── // ── Envoi TEST (déclenché par le staff depuis l'admin ; jamais automatique) ──
async function sendTest ({ eventId, emails, channel = 'mailjet', from = '', lang = 'fr', name = '' }) { async function sendTest ({ eventId, emails, channel = 'mailjet', from = '', lang = 'fr', name = '' }) {
const event = getEvent(eventId)
const atts = loadSendAttachments(event, lang) // pièces jointes de la langue testée (+ 'both')
const results = [] const results = []
for (const em of emails) { for (const em of emails) {
const link = rsvpLink(eventId, 'TEST-' + em, name || 'Test', em, 24) const link = rsvpLink(eventId, 'TEST-' + em, name || 'Test', em, 24)
@ -223,8 +431,13 @@ async function sendTest ({ eventId, emails, channel = 'mailjet', from = '', lang
const subject = '[TEST] ' + inviteSubject(eventId, lang) const subject = '[TEST] ' + inviteSubject(eventId, lang)
try { try {
let ok let ok
if (channel === 'gmail') { const r = await require('./gmail').sendMessage({ to: em, subject, html, from: from || undefined }); ok = !!r } if (channel === 'gmail') {
else ok = await require('./email').sendEmail({ to: em, subject, html, from: from || undefined }) const attachments = atts.map(a => ({ filename: a.filename, mime: a.mime, content: a.buffer.toString('base64') }))
const r = await require('./gmail').sendMessage({ to: em, subject, html, from: from || undefined, attachments }); ok = !!r
} else {
const attachments = atts.map(a => ({ filename: a.filename, contentType: a.mime, content: a.buffer }))
ok = await require('./email').sendEmail({ to: em, subject, html, from: from || undefined, attachments })
}
results.push({ email: em, ok: !!ok }) results.push({ email: em, ok: !!ok })
} catch (e) { results.push({ email: em, ok: false, error: e.message }) } } catch (e) { results.push({ email: em, ok: false, error: e.message }) }
} }
@ -271,6 +484,55 @@ function parseAudienceCsv (text) {
} }
// Résout l'audience → destinataires [{email,firstname,lastname,language,customer_id}] (dédupliqués, courriel requis). // Résout l'audience → destinataires [{email,firstname,lastname,language,customer_id}] (dédupliqués, courriel requis).
// mode : 'filter' (liste filtrée), 'csv' (import), 'manual' (pool de clients ajoutés un à un — filters.customer_ids[]).
const AUD_CFIELDS = ['name', 'customer_name', 'email_id', 'language', 'customer_type', 'legacy_account_id']
function mapCustomerRow (c) { const n = splitName(c.customer_name); return { email: firstEmail(c.email_id), firstname: n.firstname, lastname: n.lastname, language: normLang(c.language), customer_id: c.name, customer_type: c.customer_type || '', monthly: Math.round(c._monthly || 0) } }
// Clients ayant une Service Location dans une ville. FUZZY : accent/casse-insensible (unaccent+lower)
// via le pool Postgres ERPNext (comme la recherche floue app-wide) ; repli REST `like` si pool indispo.
async function cityCustomerIds (city) {
const term = String(city || '').trim()
const ids = new Set()
if (!term) return ids
let pool = null
try { pool = require('./address-db').pool() } catch { pool = null }
if (pool) {
try {
const r = await pool.query(`SELECT DISTINCT customer FROM "tabService Location" WHERE customer IS NOT NULL AND unaccent(lower(coalesce(city,''))) LIKE unaccent(lower($1))`, ['%' + term + '%'])
r.rows.forEach(x => { if (x.customer) ids.add(x.customer) })
return ids // PG autoritatif (même si 0)
} catch (e) { log('cityCustomerIds pg: ' + e.message) /* → repli REST */ }
}
const erp = require('./erp'); let s = 0
for (let p = 0; p < 80; p++) { const b = await erp.list('Service Location', { filters: [['city', 'like', '%' + term + '%']], fields: ['customer'], limit: 500, start: s }); b.forEach(x => { if (x.customer) ids.add(x.customer) }); if (b.length < 500) break; s += 500 }
return ids
}
// Filtre « nom » de l'audience — FUZZY comme la recherche app-wide : accent/casse-insensible ET multi-champs
// (customer_name + ID de compte `name` + mandataire + contact_name_legacy). Ex. « lpb » → C-LPB4 (Louis-Paul
// Bourdon, matché par ID/mandataire, pas par customer_name). Via PG ; repli REST (customer_name, sensible à la casse).
async function nameLikeCustomerIds (term) {
const t = String(term || '').trim(); const ids = new Set()
if (!t) return ids
let pool = null
try { pool = require('./address-db').pool() } catch { pool = null }
if (pool) {
const like = '%' + t + '%'
try {
const r = await pool.query("SELECT name FROM \"tabCustomer\" WHERE unaccent(lower(coalesce(customer_name,''))) LIKE unaccent(lower($1)) OR lower(coalesce(name,'')) LIKE lower($1)", [like])
r.rows.forEach(x => { if (x.name) ids.add(x.name) })
} catch (e) { log('nameLikeCustomerIds pg: ' + e.message) }
// Champs legacy (colonnes custom possibles) — try/catch séparé pour ne pas casser si absentes.
try {
const r = await pool.query("SELECT name FROM \"tabCustomer\" WHERE unaccent(lower(coalesce(mandataire,''))) LIKE unaccent(lower($1)) OR unaccent(lower(coalesce(contact_name_legacy,''))) LIKE unaccent(lower($1))", [like])
r.rows.forEach(x => { if (x.name) ids.add(x.name) })
} catch { /* colonnes absentes → ignore */ }
return ids
}
const erp = require('./erp'); let s = 0
for (let p = 0; p < 80; p++) { const b = await erp.list('Customer', { filters: [['customer_name', 'like', '%' + t + '%']], fields: ['name'], limit: 500, start: s }); b.forEach(x => { if (x.name) ids.add(x.name) }); if (b.length < 500) break; s += 500 }
return ids
}
async function resolveAudience ({ mode = 'filter', filters = {}, csv = '' } = {}) { async function resolveAudience ({ mode = 'filter', filters = {}, csv = '' } = {}) {
let rows = [] let rows = []
let resiliatedExcluded = 0 let resiliatedExcluded = 0
@ -278,35 +540,60 @@ async function resolveAudience ({ mode = 'filter', filters = {}, csv = '' } = {}
const p = parseAudienceCsv(csv) const p = parseAudienceCsv(csv)
if (p.error) return { error: p.error } if (p.error) return { error: p.error }
rows = p.rows rows = p.rows
} else if (mode === 'manual') {
// Pool manuel : le staff a ajouté des clients un à un ; on re-résout les IDs → destinataires autoritatifs
// (courriel/langue frais depuis ERPNext). Choix EXPLICITE → pas d'exclusion des résiliés.
const erp = require('./erp')
const ids = [...new Set((filters.customer_ids || []).map(v => String(v).trim()).filter(Boolean))].slice(0, 5000)
if (!ids.length) return { error: 'empty_pool' }
const customers = []
for (let i = 0; i < ids.length; i += 100) { const b = await erp.list('Customer', { filters: [['name', 'in', ids.slice(i, i + 100)]], fields: AUD_CFIELDS, limit: 500 }); customers.push(...b) }
rows = customers.map(mapCustomerRow)
} else { } else {
const erp = require('./erp') const erp = require('./erp')
const f = [] const f = [] // filtres au niveau Customer
if (filters.statut === 'active') f.push(['disabled', '=', 0]) if (filters.statut === 'active') f.push(['disabled', '=', 0])
else if (filters.statut === 'disabled') f.push(['disabled', '=', 1]) else if (filters.statut === 'disabled') f.push(['disabled', '=', 1])
if (filters.customer_type) f.push(['customer_type', '=', filters.customer_type]) // Individual = résidentiel · Company = commercial if (filters.customer_type) f.push(['customer_type', '=', filters.customer_type]) // Individual = résidentiel · Company = commercial
const CFIELDS = ['name', 'customer_name', 'email_id', 'language', 'customer_type', 'legacy_account_id']
const minMonthly = Number(filters.min_monthly) || 0 const minMonthly = Number(filters.min_monthly) || 0
let customers = []
// Ensembles d'IDs à INTERSECTER (nom fuzzy, ville via Service Location, abonnements via Service Subscription).
let restrict = null // null = pas de restriction ; sinon Set d'IDs Customer
const intersect = (set) => { restrict = restrict ? new Set([...restrict].filter(x => set.has(x))) : set }
// NOM (fuzzy, multi-champs, insensible casse/accents) — ex. « lpb » → C-LPB4.
if (filters.name_like && String(filters.name_like).trim()) intersect(await nameLikeCustomerIds(String(filters.name_like)))
// VILLE → Service Location.customer (champ `city` peuplé, contrairement à `territory`).
if (filters.city && String(filters.city).trim()) intersect(await cityCustomerIds(String(filters.city)))
// ABONNEMENTS ACTIFS (somme mensuelle) → restriction + montant par client.
let byCust = null
if (filters.active_sub || minMonthly > 0) { if (filters.active_sub || minMonthly > 0) {
// Agrège les abonnements ACTIFS par client (somme mensuelle) ; filtre par minimum si demandé. byCust = {}; let s = 0
const byCust = {}; let s = 0
for (let p = 0; p < 40; p++) { const b = await erp.list('Service Subscription', { filters: [['status', '=', 'Actif']], fields: ['customer', 'monthly_price'], limit: 500, start: s }); b.forEach(x => { if (x.customer) byCust[x.customer] = (byCust[x.customer] || 0) + (Number(x.monthly_price) || 0) }); if (b.length < 500) break; s += 500 } for (let p = 0; p < 40; p++) { const b = await erp.list('Service Subscription', { filters: [['status', '=', 'Actif']], fields: ['customer', 'monthly_price'], limit: 500, start: s }); b.forEach(x => { if (x.customer) byCust[x.customer] = (byCust[x.customer] || 0) + (Number(x.monthly_price) || 0) }); if (b.length < 500) break; s += 500 }
let ids = Object.keys(byCust) let subIds = Object.keys(byCust)
if (minMonthly > 0) ids = ids.filter(c => byCust[c] >= minMonthly) if (minMonthly > 0) subIds = subIds.filter(c => byCust[c] >= minMonthly)
for (let i = 0; i < ids.length; i += 100) { intersect(new Set(subIds))
const b = await erp.list('Customer', { filters: [...f, ['name', 'in', ids.slice(i, i + 100)]], fields: CFIELDS, limit: 500 }) }
customers.push(...b)
} let customers = []
customers.forEach(c => { c._monthly = byCust[c.name] || 0 }) if (restrict) {
const ids = [...restrict]
for (let i = 0; i < ids.length; i += 100) { const b = await erp.list('Customer', { filters: [...f, ['name', 'in', ids.slice(i, i + 100)]], fields: AUD_CFIELDS, limit: 500 }); customers.push(...b) }
} else { } else {
let s = 0 let s = 0
for (let p = 0; p < 60; p++) { const b = await erp.list('Customer', { filters: f, fields: CFIELDS, limit: 500, start: s }); customers.push(...b); if (b.length < 500) break; s += 500 } for (let p = 0; p < 60; p++) { const b = await erp.list('Customer', { filters: f, fields: AUD_CFIELDS, limit: 500, start: s }); customers.push(...b); if (b.length < 500) break; s += 500 }
} }
const resil = await resiliatedSet() // GARDE-FOU : jamais inviter un compte F résilié (abos fantômes, cf feedback_ghost_active_subscriptions) if (byCust) customers.forEach(c => { c._monthly = byCust[c.name] || 0 })
const before = customers.length // GARDE-FOU par défaut : exclure les comptes F résiliés (abos fantômes, cf feedback_ghost_active_subscriptions).
customers = customers.filter(c => !resil.has(Number(c.legacy_account_id))) // Sauf si `include_resiliated` (choisi via « Tous ») → on autorise les ex-clients (relance / win-back).
resiliatedExcluded = before - customers.length if (!filters.include_resiliated) {
rows = customers.map(c => { const n = splitName(c.customer_name); return { email: firstEmail(c.email_id), firstname: n.firstname, lastname: n.lastname, language: normLang(c.language), customer_id: c.name, customer_type: c.customer_type || '', monthly: Math.round(c._monthly || 0) } }) const resil = await resiliatedSet()
const before = customers.length
customers = customers.filter(c => !resil.has(Number(c.legacy_account_id)))
resiliatedExcluded = before - customers.length
}
rows = customers.map(mapCustomerRow)
} }
const seen = new Set(); const recipients = []; const noEmail = []; let dropped = 0 const seen = new Set(); const recipients = []; const noEmail = []; let dropped = 0
for (const r of rows) { for (const r of rows) {
@ -321,21 +608,64 @@ async function resolveAudience ({ mode = 'filter', filters = {}, csv = '' } = {}
return { recipients, count: recipients.length, dropped_no_email: dropped, no_email: noEmail, resiliated_excluded: resiliatedExcluded } return { recipients, count: recipients.length, dropped_no_email: dropped, no_email: noEmail, resiliated_excluded: resiliatedExcluded }
} }
// ── Liste principale d'audience (ADDITIVE, persistée par événement) ─────────
// Le staff construit UNE liste en additionnant plusieurs lots (filtre ville A, puis filtre ville B,
// puis sélections manuelles, puis CSV…). Dédup par courriel (insensible à la casse). Chaque
// destinataire porte son `source` (libellé du lot) pour la ventilation. C'est CETTE liste qu'un
// futur envoi de masse consommera.
function audienceFile (id) { return path.join(AUDIENCE_DIR, `${id}.json`) }
function loadAudienceList (id) { const d = readJsonFile(audienceFile(id), null); return (d && Array.isArray(d.recipients)) ? d : { recipients: [], updated: '' } }
function saveAudienceList (id, a) { writeJsonFile(audienceFile(id), a) }
function bySource (a) { const m = {}; for (const r of a.recipients) { const s = r.source || '—'; m[s] = (m[s] || 0) + 1 } return m }
function audienceSummary (a) { return { count: a.recipients.length, by_source: bySource(a), updated: a.updated || '', sample: a.recipients.slice(0, 200).map(r => ({ email: r.email, name: ((r.firstname || '') + ' ' + (r.lastname || '')).trim() || r.email, customer_id: r.customer_id || '', language: r.language || 'fr', source: r.source || '' })) } }
// Résout un lot (filtre/manuel/CSV) et l'AJOUTE à la liste principale (dédup courriel).
async function audienceListAdd (id, { mode, filters, csv, source_label }) {
const r = await resolveAudience({ mode, filters, csv })
if (r.error) return { error: r.error }
const a = loadAudienceList(id)
const seen = new Set(a.recipients.map(x => String(x.email || '').toLowerCase()))
const label = String(source_label || '').slice(0, 80) || (mode === 'csv' ? 'CSV' : mode === 'manual' ? 'Sélection manuelle' : 'Filtre')
let added = 0
for (const rec of r.recipients) {
const k = String(rec.email || '').toLowerCase()
if (!k || seen.has(k)) continue
seen.add(k)
a.recipients.push({ email: rec.email, firstname: rec.firstname || '', lastname: rec.lastname || '', language: rec.language || 'fr', customer_id: rec.customer_id || '', customer_type: rec.customer_type || '', monthly: rec.monthly || 0, source: label })
added++
}
a.updated = new Date().toISOString()
saveAudienceList(id, a)
return { added, duplicates: r.count - added, dropped_no_email: r.dropped_no_email || 0, resiliated_excluded: r.resiliated_excluded || 0, ...audienceSummary(a) }
}
function audienceListRemove (id, email) {
const a = loadAudienceList(id)
const before = a.recipients.length
a.recipients = a.recipients.filter(r => String(r.email || '').toLowerCase() !== String(email || '').toLowerCase())
a.updated = new Date().toISOString()
saveAudienceList(id, a)
return { removed: before - a.recipients.length, ...audienceSummary(a) }
}
function audienceListClear (id) { saveAudienceList(id, { recipients: [], updated: new Date().toISOString() }); return { count: 0, by_source: {}, sample: [] } }
// ── Page publique (festive, mobile-first, bilingue) ──────────────────────── // ── Page publique (festive, mobile-first, bilingue) ────────────────────────
function field (id, nameAttr, labelHtml, inputHtml, errId, errMsg) { function field (id, nameAttr, labelHtml, inputHtml, errId, errMsg) {
return `<label for="${id}">${labelHtml}</label>${inputHtml}${errId ? `<div class="err" id="${errId}">${esc(errMsg)}</div>` : ''}` return `<label for="${id}">${labelHtml}</label>${inputHtml}${errId ? `<div class="err" id="${errId}">${esc(errMsg)}</div>` : ''}`
} }
function page (event, lang, prefill = {}) { function page (event, lang, prefill = {}, opts = {}) {
lang = normLang(lang) lang = normLang(lang)
const t = event[lang] const t = event[lang]
const other = lang === 'fr' ? 'en' : 'fr' const other = lang === 'fr' ? 'en' : 'fr'
const known = !!prefill.customer_id const full = !!opts.full
const greet = t.greet(firstName(prefill.name)) const greet = t.greet(firstName(prefill.name))
const program = t.program.map(p => const program = (t.program || []).map(p =>
`<div class="prog"><div class="prog-ic">${p.icon}</div><div class="prog-lb">${esc(p.label)}${p.sub ? `<span>${esc(p.sub)}</span>` : ''}</div></div>` `<div class="prog"><div class="prog-ic">${esc(p.icon)}</div><div class="prog-lb">${esc(p.label)}${p.sub ? `<span>${esc(p.sub)}</span>` : ''}</div></div>`
).join('') ).join('')
const notes = t.notes.map((n, i) => `<div class="note">${'*'.repeat(i + 1)} ${esc(n)}</div>`).join('') const notes = (t.notes || []).map((n, i) => `<div class="note">${'*'.repeat(i + 1)} ${esc(n)}</div>`).join('')
const optlbl = (s) => `${esc(s)} <span class="opt">${esc(t.opt)}</span>` const optlbl = (s) => `${esc(s)} <span class="opt">${esc(t.opt)}</span>`
const badgeHtml = event.badge_top || event.badge_bottom
? `<div class="badge"><b>${esc(event.badge_top || '🎉')}</b>${event.badge_bottom ? `<span>${esc(event.badge_bottom)}</span>` : ''}</div>`
: `<div class="badge emoji">🎉</div>`
return `<!doctype html><html lang="${lang}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> return `<!doctype html><html lang="${lang}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>${esc(t.name)}</title> <title>${esc(t.name)}</title>
<style> <style>
@ -351,6 +681,7 @@ body{margin:0;font-family:-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-s
.badge{display:inline-flex;flex-direction:column;align-items:center;justify-content:center;width:64px;height:64px;border-radius:50%; .badge{display:inline-flex;flex-direction:column;align-items:center;justify-content:center;width:64px;height:64px;border-radius:50%;
background:var(--g);color:#fff;font-weight:800;line-height:1;box-shadow:0 8px 20px rgba(33,163,74,.3);margin-bottom:12px} background:var(--g);color:#fff;font-weight:800;line-height:1;box-shadow:0 8px 20px rgba(33,163,74,.3);margin-bottom:12px}
.badge b{font-size:1.35rem}.badge span{font-size:.6rem;letter-spacing:1px} .badge b{font-size:1.35rem}.badge span{font-size:.6rem;letter-spacing:1px}
.badge.emoji{font-size:2rem}
.logo{height:34px;width:auto;display:block;margin:0 auto 14px} .logo{height:34px;width:auto;display:block;margin:0 auto 14px}
h1{font-size:1.7rem;font-weight:800;letter-spacing:-.5px;margin:0 0 6px;color:var(--ink)} h1{font-size:1.7rem;font-weight:800;letter-spacing:-.5px;margin:0 0 6px;color:var(--ink)}
.tag{color:var(--g);font-weight:700;font-size:1.02rem;margin:0 0 4px} .tag{color:var(--g);font-weight:700;font-size:1.02rem;margin:0 0 4px}
@ -384,32 +715,43 @@ button:hover{filter:brightness(1.05)}button:active{transform:translateY(1px)}but
.thanks .big{font-size:3rem;line-height:1;margin-bottom:10px} .thanks .big{font-size:3rem;line-height:1;margin-bottom:10px}
.thanks h2{color:var(--g);font-size:1.4rem;margin:0 0 10px} .thanks h2{color:var(--g);font-size:1.4rem;margin:0 0 10px}
.recog{font-weight:600;color:var(--g);margin:0 0 10px} .recog{font-weight:600;color:var(--g);margin:0 0 10px}
.full{text-align:center;padding:16px 6px}
.full .big{font-size:2.6rem;line-height:1;margin-bottom:12px}
.full h2{color:var(--ink);font-size:1.35rem;margin:0 0 10px}
.full p{color:var(--muted)}
.hidden{display:none} .hidden{display:none}
</style></head> </style></head>
<body><div class="confetti"></div><div class="wrap"> <body><div class="confetti"></div><div class="wrap">
<div class="lang"><a href="?lang=${other}">${other === 'en' ? 'English' : 'Français'}</a></div> <div class="lang"><a href="?lang=${other}">${other === 'en' ? 'English' : 'Français'}</a></div>
<div class="head"> <div class="head">
<div class="badge"><b>20</b><span>ANS</span></div> ${badgeHtml}
<img class="logo" src="${LOGO}" alt="TARGO"> <img class="logo" src="${LOGO}" alt="TARGO">
<h1>${esc(t.name)}</h1> <h1>${esc(t.name)}</h1>
<div class="tag">${esc(t.tagline)}</div> <div class="tag">${esc(t.tagline)}</div>
</div> </div>
<div id="invite" class="card"> <div id="invite" class="card">
<div class="when"><span class="cal">📅</span><span>${esc(t.when)}</span></div> ${t.when ? `<div class="when"><span class="cal">📅</span><span>${esc(t.when)}</span></div>` : ''}
<p>${esc(t.body)}</p> <p>${esc(t.body)}</p>
<div class="prog-wrap">${program}</div> ${program ? `<div class="prog-wrap">${program}</div>` : ''}
<p style="text-align:center;font-style:italic;color:var(--muted);margin-top:12px">${esc(t.program_line)}</p> ${t.program_line ? `<p style="text-align:center;font-style:italic;color:var(--muted);margin-top:12px">${esc(t.program_line)}</p>` : ''}
<div class="limited"><span class="i"></span><p>${esc(t.limited)}</p></div> ${t.limited ? `<div class="limited"><span class="i">⏳</span><p>${esc(t.limited)}</p></div>` : ''}
</div> </div>
<div id="formCard" class="card"> <div id="fullCard" class="card${full ? '' : ' hidden'}">
<div class="full">
<div class="big">🎪</div>
<h2>${esc(t.full_title)}</h2>
<p>${esc(t.full_msg)}</p>
</div>
</div>
<div id="formCard" class="card${full ? ' hidden' : ''}">
${greet ? `<div class="greet">${esc(greet)}</div>` : ''} ${greet ? `<div class="greet">${esc(greet)}</div>` : ''}
<form id="rsvp" onsubmit="return submitRsvp(event)" autocomplete="on"> <form id="rsvp" onsubmit="return submitRsvp(event)" autocomplete="on">
${field('nm', 'name', esc(t.f_name), `<input id="nm" name="name" value="${esc(prefill.name || '')}" autocomplete="name" placeholder="Jean Tremblay">`, 'e_nm', t.err_name)} ${field('nm', 'name', esc(t.f_name), `<input id="nm" name="name" value="${esc(prefill.name || '')}" autocomplete="name" placeholder="Jean Tremblay">`, 'e_nm', t.err_name)}
${field('em', 'email', esc(t.f_email), `<input id="em" name="email" type="email" value="${esc(prefill.email || '')}" autocomplete="email" placeholder="vous@exemple.com">`, 'e_em', t.err_email)} ${field('em', 'email', esc(t.f_email), `<input id="em" name="email" type="email" value="${esc(prefill.email || '')}" autocomplete="email" placeholder="vous@exemple.com">`, 'e_em', t.err_email)}
${field('ph', 'phone', optlbl(t.f_phone), `<input id="ph" name="phone" type="tel" autocomplete="tel" placeholder="819 555-1234">`)} ${field('ph', 'phone', optlbl(t.f_phone), `<input id="ph" name="phone" type="tel" autocomplete="tel" placeholder="819 555-1234">`)}
${field('cn', 'customer_number', optlbl(t.f_number), `<input id="cn" name="customer_number" value="${esc(prefill.customer_number || '')}" ${known ? 'readonly' : ''} autocomplete="off" placeholder="C-12345">`)}
${field('ps', 'party_size', esc(t.f_party), `<input id="ps" name="party_size" type="number" min="1" max="30" inputmode="numeric" placeholder="${esc(t.f_party_ph)}">`, 'e_ps', t.err_party)} ${field('ps', 'party_size', esc(t.f_party), `<input id="ps" name="party_size" type="number" min="1" max="30" inputmode="numeric" placeholder="${esc(t.f_party_ph)}">`, 'e_ps', t.err_party)}
${field('al', 'allergies', optlbl(t.f_allerg), `<textarea id="al" name="allergies" maxlength="500"></textarea>`)} ${field('al', 'allergies', optlbl(t.f_allerg), `<textarea id="al" name="allergies" maxlength="500"></textarea>`)}
<button type="submit" id="sub">${esc(t.submit)}</button> <button type="submit" id="sub">${esc(t.submit)}</button>
@ -452,7 +794,7 @@ function submitRsvp(e){
if(!ok)return false; if(!ok)return false;
var b=document.getElementById('sub');b.disabled=true; var b=document.getElementById('sub');b.disabled=true;
fetch('/rsvp/'+encodeURIComponent(EVENT)+'/submit',{method:'POST',headers:{'Content-Type':'application/json'}, fetch('/rsvp/'+encodeURIComponent(EVENT)+'/submit',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({token:TOKEN,name:nm,email:em,phone:(f.phone.value||'').trim(),customer_number:(f.customer_number.value||'').trim(),party_size:ps,allergies:(f.allergies.value||'').trim(),lang:LANG})}) body:JSON.stringify({token:TOKEN,name:nm,email:em,phone:(f.phone.value||'').trim(),party_size:ps,allergies:(f.allergies.value||'').trim(),lang:LANG})})
.then(function(r){return r.json()}) .then(function(r){return r.json()})
.then(function(d){ .then(function(d){
if(d&&d.ok){ if(d&&d.ok){
@ -460,6 +802,7 @@ function submitRsvp(e){
if(d.confidence==='confirmed'){rec.textContent=RECOG_TPL.replace('###',d.name?(', '+d.name):'')} if(d.confidence==='confirmed'){rec.textContent=RECOG_TPL.replace('###',d.name?(', '+d.name):'')}
else{rec.textContent=MSG_CHECK;rec.style.color='#64748b'} else{rec.textContent=MSG_CHECK;rec.style.color='#64748b'}
hide('formCard');show('thanks');window.scrollTo({top:0,behavior:'smooth'}) hide('formCard');show('thanks');window.scrollTo({top:0,behavior:'smooth'})
}else if(d&&d.full){hide('formCard');show('fullCard');window.scrollTo({top:0,behavior:'smooth'})
}else{b.disabled=false;document.getElementById('e_gen').style.display='block'} }else{b.disabled=false;document.getElementById('e_gen').style.display='block'}
}) })
.catch(function(){b.disabled=false;document.getElementById('e_gen').style.display='block'}); .catch(function(){b.disabled=false;document.getElementById('e_gen').style.display='block'});
@ -497,8 +840,15 @@ async function handle (req, res, method, path, url) {
const p = verifyJwt(token) const p = verifyJwt(token)
if (p && p.scope === 'customer') { prefill.customer_id = p.sub; prefill.customer_number = p.sub; prefill.name = p.name || ''; prefill.email = p.email || ''; if (!url.searchParams.get('lang') && p.lang) lang = p.lang } if (p && p.scope === 'customer') { prefill.customer_id = p.sub; prefill.customer_number = p.sub; prefill.name = p.name || ''; prefill.email = p.email || ''; if (!url.searchParams.get('lang') && p.lang) lang = p.lang }
} }
// Capacité (personnes) : plein → message « complet », SAUF si cet inscrit existe déjà (peut modifier).
let full = false
if (event.capacity > 0) {
const m = loadResp(event.id)
const existing = prefill.customer_id ? m['c:' + prefill.customer_id] : null
full = headcountOf(m) >= event.capacity && !existing
}
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }) res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
return res.end(page(event, lang, prefill)) return res.end(page(event, lang, prefill, { full }))
} }
// Soumission publique : POST /rsvp/<eventId>/submit // Soumission publique : POST /rsvp/<eventId>/submit
@ -521,6 +871,11 @@ async function handle (req, res, method, path, url) {
const ip = String(req.headers['x-forwarded-for'] || '').split(',')[0].trim() || (req.socket && req.socket.remoteAddress) || '' const ip = String(req.headers['x-forwarded-for'] || '').split(',')[0].trim() || (req.socket && req.socket.remoteAddress) || ''
const m = loadResp(eventId) const m = loadResp(eventId)
const prev = m[key] || {} const prev = m[key] || {}
// Enforcement CAPACITÉ (personnes) : bloque une NOUVELLE inscription quand le plafond est atteint.
// Un inscrit existant peut toujours mettre à jour sa réponse (déjà compté).
if (event.capacity > 0 && !m[key] && headcountOf(m) >= event.capacity) {
return json(res, 200, { ok: false, full: true, error: 'event_full' })
}
m[key] = { m[key] = {
event_id: eventId, event_id: eventId,
ip, ip,
@ -547,12 +902,71 @@ async function handle (req, res, method, path, url) {
// Staff : GET /events // Staff : GET /events
if (path === '/events' && method === 'GET') return json(res, 200, { events: listEvents() }) if (path === '/events' && method === 'GET') return json(res, 200, { events: listEvents() })
// Staff : POST /events (créer un événement)
if (path === '/events' && method === 'POST') {
const b = await parseBody(req)
const fr = (b.fr || {})
if (!String(fr.name || '').trim()) return json(res, 400, { error: 'name_required' })
const id = uniqueId(slugify(b.id || fr.name))
if (!id) return json(res, 400, { error: 'bad_id' })
const conf = normalizeConfig(b)
conf.id = id
saveConfig(id, conf)
log(`Event created — ${id}`)
const e = getEvent(id)
return json(res, 200, { ok: true, event: { id: e.id, name: e.fr.name, date_iso: e.date_iso, capacity: e.capacity, public_url: `${pub()}/rsvp/${e.id}` } })
}
// Staff : GET /events/<id> (config brute éditable — pour le formulaire d'édition)
mm = path.match(/^\/events\/([A-Za-z0-9_-]+)$/)
if (mm && method === 'GET') {
const base = rawConfig(mm[1]); if (!base) return json(res, 404, { error: 'event_not_found' })
return json(res, 200, { event: { ...base, public_url: `${pub()}/rsvp/${mm[1]}` } })
}
// Staff : PUT /events/<id> (éditer) · DELETE /events/<id>
if (mm && method === 'PUT') {
const existing = rawConfig(mm[1]); if (!existing) return json(res, 404, { error: 'event_not_found' })
const b = await parseBody(req)
const fr = (b.fr || {})
if (!String(fr.name || '').trim()) return json(res, 400, { error: 'name_required' })
const conf = normalizeConfig(b, existing)
conf.id = mm[1]
saveConfig(mm[1], conf)
log(`Event updated — ${mm[1]}`)
return json(res, 200, { ok: true })
}
if (mm && method === 'DELETE') {
// Ne supprime que le fichier config (les événements semés reviennent à leur défaut).
try { fs.unlinkSync(configFile(mm[1])) } catch { /* absent */ }
log(`Event config deleted — ${mm[1]}`)
return json(res, 200, { ok: true, reverted_to_seed: !!SEED_CONTENT[mm[1]] })
}
// Staff : GET /events/<id>/invite-preview?lang=fr|en[&template=<name>] → HTML rendu (aperçu inbox)
mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/invite-preview$/)
if (mm && method === 'GET') {
const event = getEvent(mm[1]); if (!event) return json(res, 404, { error: 'event_not_found' })
const lang = normLang(url.searchParams.get('lang') || 'fr')
const sampleName = lang === 'en' ? 'Jean Smith' : 'Jean Tremblay'
const sampleUrl = `${pub()}/rsvp/${event.id}`
// ?template absent → modèle configuré de l'événement · ='' → forcer le festif · =<nom> → ce template (aperçu avant enregistrement)
const override = url.searchParams.get('template')
let html
if (override === null) html = inviteEmail(event.id, lang, sampleName, sampleUrl)
else if (override === '') html = inviteEmailFestive(event, lang, sampleName, sampleUrl)
else {
html = require('./campaigns').renderNamedTemplate(override, inviteTemplateVars(event, lang, sampleName, sampleUrl))
if (!html) return json(res, 400, { error: 'template_not_found' })
}
return json(res, 200, { html })
}
// Staff : GET /events/<id>/rsvps // Staff : GET /events/<id>/rsvps
mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/rsvps$/) mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/rsvps$/)
if (mm && method === 'GET') { if (mm && method === 'GET') {
const event = getEvent(mm[1]); if (!event) return json(res, 404, { error: 'event_not_found' }) const event = getEvent(mm[1]); if (!event) return json(res, 404, { error: 'event_not_found' })
const data = listRsvps(mm[1]) const data = listRsvps(mm[1])
return json(res, 200, { event: { id: event.id, name: event.fr.name, date_iso: event.date_iso, when: event.fr.when, public_url: `${pub()}/rsvp/${event.id}` }, ...data }) return json(res, 200, { event: { id: event.id, name: event.fr.name, date_iso: event.date_iso, when: event.fr.when, capacity: event.capacity, email_template: event.email_template || '', attachments: event.attachments || [], public_url: `${pub()}/rsvp/${event.id}` }, ...data })
} }
// Staff : DELETE /events/<id>/rsvps/<key> // Staff : DELETE /events/<id>/rsvps/<key>
mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/rsvps\/(.+)$/) mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/rsvps\/(.+)$/)
@ -560,6 +974,21 @@ async function handle (req, res, method, path, url) {
const ok = deleteRsvp(mm[1], decodeURIComponent(mm[2])) const ok = deleteRsvp(mm[1], decodeURIComponent(mm[2]))
return json(res, ok ? 200 : 404, { ok }) return json(res, ok ? 200 : 404, { ok })
} }
// Staff : POST /events/<id>/attachments {filename, mime, data(base64)} · DELETE /events/<id>/attachments/<stored>
mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/attachments$/)
if (mm && method === 'POST') {
if (!getEvent(mm[1])) return json(res, 404, { error: 'event_not_found' })
const b = await parseBody(req)
const r = await addAttachment(mm[1], { filename: b.filename, mime: b.mime, data: b.data, lang: b.lang })
if (r.error) return json(res, 400, { error: r.error })
return json(res, 200, { ok: true, attachment: r.attachment, attachments: r.attachments })
}
mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/attachments\/(.+)$/)
if (mm && method === 'DELETE') {
if (!getEvent(mm[1])) return json(res, 404, { error: 'event_not_found' })
const r = removeAttachment(mm[1], decodeURIComponent(mm[2]))
return json(res, 200, { ok: true, attachments: r.attachments || [] })
}
// Staff : POST /events/<id>/send (mode TEST uniquement ; masse = pas encore activé) // Staff : POST /events/<id>/send (mode TEST uniquement ; masse = pas encore activé)
mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/send$/) mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/send$/)
if (mm && method === 'POST') { if (mm && method === 'POST') {
@ -576,7 +1005,7 @@ async function handle (req, res, method, path, url) {
} }
return json(res, 400, { error: 'mass_send_not_enabled', message: "Envoi de masse pas encore activé — choisir l'audience d'abord." }) return json(res, 400, { error: 'mass_send_not_enabled', message: "Envoi de masse pas encore activé — choisir l'audience d'abord." })
} }
// Staff : POST /events/<id>/audience {mode:'filter'|'csv', filters, csv} → aperçu (décompte + échantillon) // Staff : POST /events/<id>/audience {mode:'filter'|'csv'|'manual', filters, csv} → APERÇU (ne persiste rien)
mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/audience$/) mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/audience$/)
if (mm && method === 'POST') { if (mm && method === 'POST') {
if (!getEvent(mm[1])) return json(res, 404, { error: 'event_not_found' }) if (!getEvent(mm[1])) return json(res, 404, { error: 'event_not_found' })
@ -586,8 +1015,37 @@ async function handle (req, res, method, path, url) {
return json(res, 200, { count: r.count, dropped_no_email: r.dropped_no_email, resiliated_excluded: r.resiliated_excluded || 0, sample: r.recipients.slice(0, 50), no_email: (r.no_email || []).slice(0, 300) }) return json(res, 200, { count: r.count, dropped_no_email: r.dropped_no_email, resiliated_excluded: r.resiliated_excluded || 0, sample: r.recipients.slice(0, 50), no_email: (r.no_email || []).slice(0, 300) })
} }
// Staff : LISTE PRINCIPALE d'audience (additive, persistée)
// GET /events/<id>/audience-list → { count, by_source, sample, updated }
// POST /events/<id>/audience-list/add {mode,filters,csv,source_label} → résout un lot + l'ajoute (dédup)
// POST /events/<id>/audience-list/remove {email} → retire un destinataire
// DELETE /events/<id>/audience-list → vide la liste
mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/audience-list$/)
if (mm && method === 'GET') {
if (!getEvent(mm[1])) return json(res, 404, { error: 'event_not_found' })
return json(res, 200, audienceSummary(loadAudienceList(mm[1])))
}
if (mm && method === 'DELETE') {
if (!getEvent(mm[1])) return json(res, 404, { error: 'event_not_found' })
return json(res, 200, { ok: true, ...audienceListClear(mm[1]) })
}
mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/audience-list\/add$/)
if (mm && method === 'POST') {
if (!getEvent(mm[1])) return json(res, 404, { error: 'event_not_found' })
const b = await parseBody(req)
const r = await audienceListAdd(mm[1], { mode: b.mode, filters: b.filters || {}, csv: b.csv || '', source_label: b.source_label })
if (r.error) return json(res, 400, { error: r.error })
return json(res, 200, { ok: true, ...r })
}
mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/audience-list\/remove$/)
if (mm && method === 'POST') {
if (!getEvent(mm[1])) return json(res, 404, { error: 'event_not_found' })
const b = await parseBody(req)
return json(res, 200, { ok: true, ...audienceListRemove(mm[1], b.email) })
}
return json(res, 404, { error: 'not found' }) return json(res, 404, { error: 'not found' })
} catch (e) { log('events handle: ' + e.message); return json(res, 500, { error: e.message }) } } catch (e) { log('events handle: ' + e.message); return json(res, 500, { error: e.message }) }
} }
module.exports = { handle, getEvent, listEvents, listRsvps, deleteRsvp, rsvpLink, inviteEmail, inviteSubject, sendTest, matchAndValidate, resolveAudience, parseAudienceCsv, page, normLang } module.exports = { handle, getEvent, listEvents, listRsvps, deleteRsvp, rsvpLink, inviteEmail, inviteSubject, sendTest, matchAndValidate, resolveAudience, parseAudienceCsv, page, normLang, addAttachment, removeAttachment, loadSendAttachments, headcountOf, audienceListAdd, audienceListRemove, audienceListClear, loadAudienceList, audienceSummary }