Compare commits
No commits in common. "3489576212134af70bc875642f339f68d27dda75" and "893ac8cd4ab01f18e88fa113a4ff2602c05b67d3" have entirely different histories.
3489576212
...
893ac8cd4a
|
|
@ -50,7 +50,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -26,32 +26,4 @@ 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' }) }
|
|
||||||
|
|
|
||||||
|
|
@ -1,252 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
<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 8–16" class="col" @click="$emit('window', { min: 8, max: 16 })" />
|
|
||||||
<q-btn dense unelevated size="sm" color="deep-purple-5" label="Soir 16–20" 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 (=8→17) -->
|
|
||||||
<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 » (=8→17, 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 »=8–16 · « 1016 »=10–16 · « 0816 »=8–16 (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 »=8→17
|
|
||||||
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>
|
|
||||||
|
|
@ -53,15 +53,14 @@
|
||||||
<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 pour marquer · re-clique pour annuler · <b>à publier</b></span>
|
<q-space /><span class="text-caption text-grey-5">glisse sur les jours pour appliquer</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- Grille — clic simple = menu de cellule PARTAGÉ (Jour/Soir/Garde/Absent/heures, comme la semaine/jour) ;
|
<!-- Grille -->
|
||||||
glisser = pinceau rapide (congés multi-jours pour les vacances). -->
|
<div class="ts-cal" @mouseleave="endDrag" @mouseup="endDrag">
|
||||||
<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, $event)"
|
@mousedown.prevent="c.inMonth && startDrag(c)"
|
||||||
@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>
|
||||||
|
|
@ -75,16 +74,6 @@
|
||||||
</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>
|
||||||
|
|
@ -111,16 +100,12 @@ 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', 'stage-abs', 'set-shift', 'remove-shift', 'clear-shifts', 'toggle-garde'])
|
const emit = defineEmits(['update:modelValue', 'edit-schedule', 'changed'])
|
||||||
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) })
|
||||||
|
|
||||||
|
|
@ -160,24 +145,10 @@ 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(), moved: false })
|
const drag = reactive({ on: false, sel: new Set() })
|
||||||
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' }) }
|
||||||
|
|
@ -196,7 +167,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: effAbsType(iso), 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: absMap.value[iso] || null, 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
|
||||||
|
|
@ -224,11 +195,10 @@ 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, asgRes] = await Promise.all([
|
const [absRes, holiRes, occRes] = 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) || {}
|
||||||
|
|
@ -243,41 +213,29 @@ 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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Interaction : CLIC SIMPLE (sans glisser) = menu de cellule partagé (Jour/Soir/Garde/Absent/heures) ; GLISSER = pinceau
|
// Glisser-sélectionner
|
||||||
// rapide de congés (multi-jours, vacances). Le congé reste STAGÉ (publish-required) : on émet le delta au parent, 0 écriture ici.
|
function startDrag (c) { drag.on = true; drag.sel = new Set([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 } }
|
function overDrag (c) { if (drag.on) drag.sel.add(c.iso) }
|
||||||
function overDrag (c) { if (!drag.on) return; if (!drag.sel.has(c.iso)) drag.moved = true; drag.sel.add(c.iso) }
|
async function endDrag () {
|
||||||
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]; drag.sel = new Set()
|
const days = [...drag.sel]
|
||||||
if (!days.length || !props.tech) return
|
drag.sel = new Set()
|
||||||
if (!drag.moved) { openCellMenu(days[0]); return } // clic simple → menu (pas d'application du pinceau)
|
if (!days.length) return
|
||||||
const changes = days.map(iso => {
|
const remove = brush.value === 'clear'
|
||||||
if (brush.value === 'clear') return { iso, type: '' }
|
const type = remove ? '' : brush.value
|
||||||
const cur = effAbsType(iso)
|
calLoading.value = true
|
||||||
return { iso, type: cur === brush.value ? '' : brush.value } // même type → toggle off
|
let ok = 0
|
||||||
})
|
try {
|
||||||
emit('stage-abs', { techId: props.tech.id, changes })
|
for (const iso of days) {
|
||||||
|
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)
|
||||||
|
|
@ -304,11 +262,9 @@ 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 = {}; asgByDate.value = {}; holiSet.value = new Set(); drag.on = false; drag.sel = new Set(); cellMenu.show = false
|
absMap.value = {}; shiftMap.value = {}; holiSet.value = new Set(); drag.on = false; drag.sel = new Set()
|
||||||
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>
|
||||||
|
|
|
||||||
|
|
@ -2,35 +2,24 @@
|
||||||
<q-page class="q-pa-md">
|
<q-page class="q-pa-md">
|
||||||
<PageHeader title="Événements" />
|
<PageHeader title="Événements" />
|
||||||
|
|
||||||
<!-- Bandeau événement + sélecteur -->
|
<!-- Bandeau événement -->
|
||||||
<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">
|
<div class="ev-badge"><b>20</b><span>ANS</span></div>
|
||||||
<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="col" style="min-width:0">
|
||||||
<span v-else style="font-size:1.4rem">🎉</span>
|
<div class="text-h6 text-weight-bold ellipsis">{{ ev.name || 'Targo fête ses 20 ans' }}</div>
|
||||||
</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 || '—' }}
|
<q-icon name="event" size="16px" class="q-mr-xs" />{{ ev.when || '1ᵉʳ août, de 10 h à 15 h' }}
|
||||||
<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 no-caps icon="edit" label="Modifier" @click="openEdit" class="col-auto" :disable="!eventId" />
|
<q-btn flat dense icon="refresh" :loading="loading" @click="load" class="col-auto"><q-tooltip>Rafraîchir</q-tooltip></q-btn>
|
||||||
<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">
|
<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>
|
||||||
<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>
|
||||||
|
|
||||||
|
|
@ -51,9 +40,6 @@
|
||||||
<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>
|
||||||
|
|
@ -82,8 +68,7 @@
|
||||||
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">
|
||||||
<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 class="text-weight-medium ellipsis">{{ props.row.customer_name || props.row.customer_number || '—' }}</div>
|
||||||
<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>
|
||||||
|
|
@ -122,129 +107,6 @@
|
||||||
</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%">
|
||||||
|
|
@ -260,107 +122,31 @@
|
||||||
|
|
||||||
<!-- 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é{{ attachments.length ? ' + pièces jointes de la langue choisie' : '' }}) à ton adresse.</div>
|
<div class="text-caption text-grey-7">Envoi d'un test (courriel avec lien personnalisé) à ton adresse.</div>
|
||||||
<q-btn-toggle v-model="sendChannel" spread no-caps dense :options="[{ label: 'Mailjet (suivi)', value: 'mailjet' }, { label: 'Gmail direct', value: 'gmail' }]" />
|
<q-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">
|
||||||
<div class="text-caption text-grey-7">
|
<q-select v-model="template" :options="templates" dense outlined emit-value map-options label="Modèle de courriel" />
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- LISTE PRINCIPALE (additive, persistée) -->
|
<div class="text-weight-medium text-grey-8 q-mt-sm">Audience</div>
|
||||||
<div class="ops-card q-pa-sm" style="border-radius:10px;border:1px solid var(--ops-border,#e0e0e0)">
|
<q-btn-toggle v-model="audienceSource" spread no-caps dense @update:model-value="audPreview = null"
|
||||||
<div class="row items-center">
|
:options="[{ label: 'Liste clients', value: 'list' }, { label: 'Import CSV', value: 'csv' }]" />
|
||||||
<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 (incl. résiliés)', value: '' }]" dense outlined emit-value map-options label="Statut" />
|
<q-select class="col-12 col-sm-6" v-model="audFilters.statut" :options="[{ label: 'Clients actifs', value: 'active' }, { label: 'Tous', value: '' }]" dense outlined emit-value map-options label="Statut" @update:model-value="audPreview = null" />
|
||||||
<q-select class="col-12 col-sm-6" v-model="audFilters.customer_type" :options="[{ label: 'Tous types', value: '' }, { label: 'Résidentiel', value: 'Individual' }, { label: 'Commercial', value: 'Company' }]" dense outlined emit-value map-options label="Type de client" />
|
<q-select class="col-12 col-sm-6" v-model="audFilters.customer_type" :options="[{ label: 'Tous types', value: '' }, { label: 'Résidentiel', value: 'Individual' }, { label: 'Commercial', value: 'Company' }]" dense outlined emit-value map-options label="Type de client" @update:model-value="audPreview = null" />
|
||||||
</div>
|
|
||||||
<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)">
|
<q-input v-model.number="audFilters.min_monthly" type="number" min="0" dense outlined :disable="!audFilters.active_sub" label="Total mensuel minimum ($/mois)" @update:model-value="audPreview = null">
|
||||||
<template #prepend><q-icon name="attach_money" /></template>
|
<template #prepend><q-icon name="attach_money" /></template>
|
||||||
</q-input>
|
</q-input>
|
||||||
<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>
|
<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>
|
||||||
<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">
|
||||||
|
|
@ -368,9 +154,29 @@
|
||||||
<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.
|
||||||
|
|
@ -396,22 +202,15 @@ 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 {
|
import { listRsvps, deleteRsvp, sendInvite, previewAudience, listTemplates } from 'src/api/events'
|
||||||
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)
|
||||||
|
|
@ -422,30 +221,20 @@ 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: '', city: '', name_like: '', active_sub: false, min_monthly: 0 })
|
const audFilters = ref({ statut: 'active', customer_type: '', active_sub: false, min_monthly: 0 })
|
||||||
const csvFile = ref(null)
|
const csvFile = ref(null)
|
||||||
const audCsvText = ref('')
|
const audCsvText = ref('')
|
||||||
// Pool manuel : recherche temps réel → résultats → sélection avant ajout à la liste
|
const audPreview = ref(null)
|
||||||
const pool = ref([])
|
const previewing = ref(false)
|
||||||
const poolQuery = ref('')
|
const showNoEmail = ref(false)
|
||||||
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' }] }]
|
||||||
|
|
@ -473,27 +262,11 @@ 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(eventId.value)
|
const d = await listRsvps(EVENT_ID)
|
||||||
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
|
||||||
|
|
@ -504,8 +277,6 @@ 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 }))
|
||||||
|
|
@ -517,171 +288,18 @@ 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_${eventId.value}`, headers, data, { totalRow })
|
exportCsv(`inscriptions_${EVENT_ID}`, 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(eventId.value, row.key); $q.notify({ type: 'positive', message: 'Retiré' }); load() }
|
try { await deleteRsvp(EVENT_ID, 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 }) }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Création / édition d'un événement ──────────────────────────────────────
|
function prepSend () { testEmail.value = testEmail.value || userEmail.value || ''; if (templates.value.length <= 1) loadTemplates(); showSend.value = true }
|
||||||
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()
|
||||||
|
|
@ -690,99 +308,26 @@ 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) { if (!v) audFilters.value.min_monthly = 0 } // le montant n'a de sens qu'avec les abonnements actifs
|
function onActiveSubToggle (v) { audPreview.value = null; if (!v) audFilters.value.min_monthly = 0 } // le montant n'a de sens qu'avec les abonnements actifs
|
||||||
|
async function doPreview () {
|
||||||
// ── Pool manuel : recherche floue temps réel → résultats en liste → sélection ──
|
previewing.value = true; audPreview.value = null; showNoEmail.value = false
|
||||||
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 r = await audienceListAdd(eventId.value, { mode: 'manual', filters: { customer_ids: pool.value.map(p => p.customer_id) }, source_label: 'Sélection manuelle' })
|
const body = audienceSource.value === 'csv' ? { mode: 'csv', csv: audCsvText.value } : { mode: 'filter', filters: audFilters.value }
|
||||||
applyListResult(r, 'add'); pool.value = []
|
audPreview.value = await previewAudience(EVENT_ID, body)
|
||||||
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { listBusy.value = false }
|
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { previewing.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(eventId.value, { test: true, test_emails: [em], channel: sendChannel.value, lang: testLang.value })
|
const r = await sendInvite(EVENT_ID, { test: true, test_emails: [em], channel: sendChannel.value })
|
||||||
if (r.ok) $q.notify({ type: 'positive', message: `Courriel test envoyé à ${em}`, icon: 'send' })
|
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 }
|
||||||
|
|
|
||||||
|
|
@ -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: '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-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-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 où 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 où 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="boardView = 'month'">
|
<q-item clickable v-close-popup @click="showDemand = !showDemand">
|
||||||
<q-item-section avatar><q-icon name="calendar_month" color="indigo" /></q-item-section>
|
<q-item-section avatar><q-icon name="tune" :color="showDemand ? 'indigo' : 'grey-7'" /></q-item-section>
|
||||||
<q-item-section>Besoins & couverture (vue Mois)<q-item-label caption>heures requises par compétence · alertes de couverture</q-item-label></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 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,7 +187,36 @@
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- « Demande — effectif requis par créneau » RETIRÉ (remplacé par la vue Mois : besoins par compétence + couverture). -->
|
<!-- Demande -->
|
||||||
|
<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> > 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" />
|
||||||
|
|
@ -198,11 +227,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, 16)" />
|
<q-btn dense unelevated size="sm" color="primary" label="Jour" @click="bulkWindow(8, 17)" />
|
||||||
<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-16" style="width:84px" @keyup.enter="bulkQuick" @mousedown.stop><q-tooltip>Saisie rapide : 8-16 · 816 · 830-16</q-tooltip></q-input>
|
<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-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" />
|
||||||
|
|
@ -492,9 +521,6 @@
|
||||||
<!-- ── 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>
|
||||||
|
|
@ -642,7 +668,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) <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 />
|
<div class="text-subtitle1 text-weight-bold">🛡️ Rotation de garde (par département)</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">
|
||||||
|
|
@ -741,7 +767,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 <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 style="width:88px" class="text-center">Cadence</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">
|
||||||
|
|
@ -1398,6 +1424,7 @@
|
||||||
</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 />
|
||||||
|
|
@ -1671,13 +1698,47 @@
|
||||||
|
|
||||||
<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">
|
||||||
<PlanifCellMenu
|
<q-list dense style="width:262px;user-select:none;-webkit-user-select:none">
|
||||||
:cell-key="(menu.tech && menu.tech.id) + '|' + (menu.day && menu.day.iso)"
|
<q-item-label header class="q-py-xs">{{ menu.tech && menu.tech.name }} — {{ menu.day && menu.day.dnum }}</q-item-label>
|
||||||
:title="(menu.tech && menu.tech.name) + ' — ' + (menu.day && menu.day.dnum)"
|
<!-- 4 actions : Jour · Soir · Garde · Absent -->
|
||||||
:shifts="menuCellShifts" :is-garde="menuIsGarde" :is-absent="menuIsAbsent"
|
<div class="row q-gutter-xs q-px-sm q-pb-xs">
|
||||||
:clipboard-count="cellClipboard.length" :initial-range="menuRange"
|
<q-btn dense unelevated size="sm" color="primary" label="Jour 8–17" class="col" @click="quickShift(8, 17)" />
|
||||||
@window="e => applyWindow(e.min, e.max)" @toggle-garde="toggleGardeMenu" @toggle-absent="openAbsDialog"
|
<q-btn dense unelevated size="sm" color="deep-purple-5" label="Soir 16–20" class="col" @click="quickShift(16, 20)" />
|
||||||
@remove-shift="removeShiftFromMenu" @clear="clearOne" @copy="copyFromMenu" @paste="pasteFromMenu" />
|
</div>
|
||||||
|
<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 (=8→17) -->
|
||||||
|
<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 -->
|
||||||
|
|
@ -1864,9 +1925,7 @@
|
||||||
<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" :pending-abs="pendingAbs" :garde-map="gardeEffective" :refresh-key="techSchedRefresh"
|
<TechScheduleDialog v-model="techSchedOpen" :tech="techSchedTech" @edit-schedule="onTechSchedEdit" @changed="onTechSchedChanged" />
|
||||||
@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>
|
||||||
|
|
||||||
|
|
@ -1902,7 +1961,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 } from 'src/composables/useFormatters' // temps relatif + initiales (source unique, ex-locales dé-dupliquées)
|
import { relTime, initials, erpLink } from 'src/composables/useFormatters' // temps relatif + initiales + lien ERP (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
|
||||||
|
|
@ -1912,8 +1971,6 @@ 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)
|
||||||
|
|
@ -1926,8 +1983,6 @@ 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()
|
||||||
|
|
@ -1969,7 +2024,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 (isAbsent(a.tech, a.date)) continue // congé / pause ce jour (serveur OU en attente) → quart matérialisé mais tech absent : hors capacité
|
if (absByTechDay.value[a.tech + '|' + a.date]) continue // congé / pause ce jour → 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)
|
||||||
|
|
@ -2023,16 +2078,17 @@ 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 loading = ref(false); const generating = ref(false); const publishing = ref(false); const applying = 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 holidays = ref([]); const weekTemplates = ref([])
|
const demand = 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 »)
|
||||||
|
|
@ -2094,7 +2150,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_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_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'
|
||||||
|
|
||||||
// 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') }
|
||||||
|
|
@ -2118,7 +2174,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', 'month'].includes(planifPrefs.value.view) ? planifPrefs.value.view : 'grid') // 'grid' | 'kanban' | 'month' | 'routes'
|
const boardView = ref(['kanban', 'routes'].includes(planifPrefs.value.view) ? planifPrefs.value.view : 'grid') // 'grid' | 'kanban' | '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)
|
||||||
|
|
@ -2329,6 +2385,7 @@ 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'équipement » → « d'équipement ») pour NORMALISER les détails affichés (sujets legacy encodés).
|
// Décode les entités HTML (« d'é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 }
|
||||||
|
|
@ -2498,13 +2555,14 @@ 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
|
||||||
}
|
}
|
||||||
function dropAskAbsence () {
|
async 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 }
|
||||||
pushHistory()
|
for (const dd of days) { try { await roster.setAbsence(t.id, dd, dropAsk.absType || 'Congé', false) } catch (e) { err(e) } } // séquentiel (frappe_pg)
|
||||||
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)
|
await reloadAbsences(); await reloadOccupancy()
|
||||||
const retour = addDaysISO(days[days.length - 1], 1)
|
const retour = addDaysISO(days[days.length - 1], 1)
|
||||||
const noms = dropAsk.names.slice(); dropAsk.open = false; scheduleDraftSave()
|
const noms = dropAsk.names.slice(); dropAsk.open = false
|
||||||
$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() }] })
|
$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() }] })
|
||||||
|
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: '' })
|
||||||
|
|
@ -2515,12 +2573,13 @@ 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 }
|
||||||
function applyAbs (remove) {
|
async function applyAbs (remove) {
|
||||||
const days = absDays(); if (!days.length || !absDialog.techId) return
|
const days = absDays(); if (!days.length || !absDialog.techId) return
|
||||||
pushHistory(); let n = 0
|
for (const d of days) { try { await roster.setAbsence(absDialog.techId, d, 'Congé', remove) } catch (e) { err(e) } } // séquentiel (frappe_pg)
|
||||||
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
|
await reloadAbsences(); await reloadOccupancy()
|
||||||
$q.notify({ type: 'info', message: (remove ? ('Absence retirée (' + n + ' j)') : (n + ' jour(s) marqué(s) absent')) + ' — Publier pour appliquer' })
|
$q.notify({ type: 'info', message: remove ? ('Absence retirée (' + days.length + ' j)') : (days.length + ' jour(s) marqué(s) absent') })
|
||||||
absDialog.open = false; scheduleDraftSave()
|
absDialog.open = false
|
||||||
|
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 ──
|
||||||
|
|
@ -2618,6 +2677,7 @@ 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 ──────────────────────────────────────────────────
|
||||||
|
|
@ -2712,39 +2772,8 @@ 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 }
|
||||||
|
|
@ -2817,6 +2846,13 @@ 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).
|
||||||
|
|
@ -2827,7 +2863,41 @@ 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('')
|
||||||
}
|
}
|
||||||
// (skillIcon / skillSym / markerIcon / BUCKET_TRUCK → composables/useSkillIcons.js — SOURCE UNIQUE réutilisable)
|
// Camion-nacelle (bucket truck) pour « monteur » — Material Symbols n'a pas d'équivalent → SVG custom au format
|
||||||
|
// 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
|
||||||
|
|
@ -4099,10 +4169,9 @@ 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é'))
|
||||||
// « dirty » = quarts NON PUBLIÉS + congés en attente (publish-required) → tout doit passer par « Publier ».
|
const dirty = computed(() => unpublished.value.length > 0)
|
||||||
const dirty = computed(() => unpublished.value.length > 0 || pendingAbsCount.value > 0)
|
const dirtyCount = computed(() => unpublished.value.length)
|
||||||
const dirtyCount = computed(() => unpublished.value.length + pendingAbsCount.value)
|
const dirtyCells = computed(() => new Set(unpublished.value.map(a => a.tech + '|' + a.date)))
|
||||||
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é' })
|
||||||
|
|
@ -4148,50 +4217,18 @@ 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 SERVEUR (En pause / Congé / Maladie…) → hachuré
|
const absByTechDay = ref({}) // tech|date → type d'absence (En pause / Congé / Maladie…) → hachuré
|
||||||
// #congé PUBLISH-REQUIRED : couche LOCALE d'absences en attente (delta vs serveur), écrite au serveur SEULEMENT à « Publier ».
|
function isAbsent (techId, iso) { return !!absByTechDay.value[techId + '|' + iso] }
|
||||||
// clé 'tech|iso' → { op:'set', type } (marquer absent) | { op:'remove' } (retirer une absence serveur). Persistée localStorage (survie au rechargement).
|
function absenceLabel (techId, iso) { return absByTechDay.value[techId + '|' + iso] || 'Absent' }
|
||||||
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) {} }
|
||||||
// Pose le delta d'absence d'une cellule (wantType=null → non absent). N'écrit PAS au serveur (publish-required).
|
// Bascule absence d'1 jour sur des cases (clic + « A » ou menu). Si toutes absentes → retire ; sinon marque.
|
||||||
// Si l'état voulu == état serveur → on efface le delta (pas de changement en attente inutile).
|
async function toggleAbsentCells (targets) {
|
||||||
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) })
|
||||||
const want = !allAbsent
|
for (const k of targets) { const [tid, iso] = k.split('|'); try { await roster.setAbsence(tid, iso, 'Congé', allAbsent) } catch (e) { err(e) } }
|
||||||
pushHistory(); let n = 0
|
await reloadAbsences()
|
||||||
for (const k of targets) {
|
$q.notify({ type: 'info', message: allAbsent ? 'Absence retirée' : (targets.length + ' absence(s) marquée(s)') })
|
||||||
const [tid, iso] = k.split('|')
|
if (!allAbsent) await checkAbsenceImpact(targets) // marquage → vérifier les jobs assignés impactés (IROPS)
|
||||||
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.
|
||||||
|
|
@ -4379,15 +4416,11 @@ 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é.
|
||||||
// #4 — capacité par tech-jour. Le tech ABSENT (congé/vacances) = 0h dispo (bug « 48h malgré des vacances »).
|
function techLoad (o, hasShift) {
|
||||||
// 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 absent = (techId && iso) ? isAbsent(techId, iso) : false
|
const cap = (hasShift && book > 0) ? book : 8
|
||||||
const works = (techId && iso) ? patternWorksDay(techId, iso) : null // null = patron inconnu → on garde l'estimation
|
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 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(() => {
|
||||||
|
|
@ -4398,10 +4431,9 @@ 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 absent = isAbsent(t.id, d.iso); const works = patternWorksDay(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 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
|
assignedH += u; capH += cap; if (u > cap + 0.01) over++
|
||||||
assignedH += u; capH += cap; if (cap > 0 && u > cap + 0.01) over++
|
if (!hasSh && u > 0.01) noShift++ // tech avec du travail mais aucun quart planifié
|
||||||
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
|
||||||
|
|
@ -4413,7 +4445,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), 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)) })).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))
|
||||||
|
|
@ -4435,7 +4467,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), 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)) })).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 ──
|
||||||
|
|
@ -4464,7 +4496,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), t.id, iso)
|
const load = techLoad(techDayOcc(t.id, iso), hasShiftDay(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
|
||||||
|
|
@ -4493,7 +4525,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), t.id, iso)
|
const load = techLoad(techDayOcc(t.id, iso), hasShiftDay(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
|
||||||
|
|
@ -4715,7 +4747,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), 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)); 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.
|
||||||
|
|
@ -5137,7 +5169,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), g.techId, d.iso)
|
const l = techLoad(techDayOcc(g.techId, d.iso), hasShiftDay(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 }
|
||||||
|
|
@ -5674,12 +5706,11 @@ 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 — capture les quarts ET les congés en attente (les 2 sont annulables via Ctrl+Z).
|
// undo / redo
|
||||||
function snap () { return { a: JSON.parse(JSON.stringify(assignments.value)), p: JSON.parse(JSON.stringify(pendingAbs.value)) } }
|
function snap () { return JSON.parse(JSON.stringify(assignments.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()); restoreSnap(history.value.pop()); logChange('↶ Annulé'); scheduleDraftSave() }
|
function undo () { if (!history.value.length) return; future.value.push(snap()); assignments.value = history.value.pop(); logChange('↶ Annulé'); scheduleDraftSave() }
|
||||||
function redo () { if (!future.value.length) return; history.value.push(snap()); restoreSnap(future.value.pop()); logChange('↷ Rétabli'); scheduleDraftSave() }
|
function redo () { if (!future.value.length) return; history.value.push(snap()); assignments.value = 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).
|
||||||
|
|
@ -5761,31 +5792,15 @@ 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)
|
||||||
const abs = await flushPendingAbsences() // congés en attente → serveur
|
$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` : '') })
|
||||||
$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).
|
||||||
|
|
@ -5800,7 +5815,7 @@ async function doWeekStatus (mode) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// demande
|
// demande
|
||||||
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 = {} } }
|
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 = [] } }
|
||||||
|
|
||||||
// ── 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
|
||||||
|
|
@ -5893,8 +5908,30 @@ 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) }
|
||||||
}
|
}
|
||||||
// « Demande » (besoins par template) RETIRÉ → remplacé par la vue Mois (MonthOverview) : besoins EN HEURES par compétence,
|
function saveDemand () { localStorage.setItem(LS_DEMAND, JSON.stringify(demand.value)) }
|
||||||
// couverture + alertes, et génération des Shift Requirements pour le solveur. Voir components/planif/MonthOverview.vue.
|
function onDemandSkills (d, items) { d.skills = normSkillList(items); saveDemand() } // TagEditor émet un tableau ; d.skills reste un tableau (→ CSV à la génération)
|
||||||
|
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 () {
|
||||||
|
|
@ -5961,16 +5998,10 @@ 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()
|
||||||
if (c.revert.absKey) { // congé : restaure le delta d'absence précédent de la cellule
|
const others = assignments.value.filter(a => !(a.tech === tech && a.date === date))
|
||||||
const m = { ...pendingAbs.value }
|
assignments.value = [...others, ...JSON.parse(JSON.stringify(before || []))]
|
||||||
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 })
|
||||||
|
|
@ -6045,15 +6076,17 @@ 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 » (=8→17, dernier chiffre en pm si ≤ début).
|
// Saisie rapide d'heures : « 8-17 » · « 8:30-16 » · « 830-16 » · « 85 » (=8→17, 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 »=8–16 · « 1016 »=10–16
|
|
||||||
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 » = 8→17
|
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 » = 8→17
|
||||||
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) }
|
||||||
|
|
|
||||||
|
|
@ -825,16 +825,6 @@ 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:
|
||||||
|
|
@ -846,7 +836,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-', 'event-']
|
const EDITABLE_TEMPLATE_PREFIXES = ['gift-email-', 'newsletter-', 'transactional-']
|
||||||
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) {
|
||||||
|
|
@ -2797,5 +2787,5 @@ module.exports = {
|
||||||
// Exposed for testing
|
// Exposed for testing
|
||||||
parseCsv, parseMapCsv, parseGiftbitCsv,
|
parseCsv, parseMapCsv, parseGiftbitCsv,
|
||||||
matchCustomer, normalizeCivic, normalizePhone, normalizePostal,
|
matchCustomer, normalizeCivic, normalizePhone, normalizePostal,
|
||||||
renderTemplate, renderNamedTemplate,
|
renderTemplate,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -76,19 +76,6 @@ 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'}`)
|
||||||
|
|
|
||||||
|
|
@ -7,22 +7,8 @@
|
||||||
* ?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, POST /events (créer), PUT/DELETE /events/<id>,
|
* - Endpoints STAFF (gatés) : GET /events, GET /events/<id>/rsvps (+ décompte + confiance),
|
||||||
* GET /events/<id>/rsvps (+ décompte + confiance), DELETE /events/<id>/rsvps/<key>,
|
* DELETE /events/<id>/rsvps/<key>, POST /events/<id>/send (mode test).
|
||||||
* 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,
|
||||||
|
|
@ -38,83 +24,19 @@ 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'
|
||||||
const CONFIG_DIR = path.join(DATA_DIR, 'config') // configs d'événements créés/édités (shadow le SEED)
|
try { fs.mkdirSync(DATA_DIR, { recursive: true }) } catch { /* best-effort */ }
|
||||||
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]))
|
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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] || ''
|
||||||
|
|
||||||
// ── Libellés d'interface COMMUNS (indépendants de l'événement) ─────────────
|
// ── Config événements (semé : Fête Targo 20 ans) ───────────────────────────
|
||||||
// Séparés du contenu éditable : mêmes champs/erreurs/remerciements pour tous les événements.
|
const SEED = {
|
||||||
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 !",
|
||||||
|
|
@ -122,10 +44,10 @@ const SEED_CONTENT = {
|
||||||
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', sub: '' },
|
{ icon: '🎧', label: 'DJ' },
|
||||||
{ icon: '🎪', label: 'Jeux gonflables', sub: '' },
|
{ icon: '🎪', label: 'Jeux gonflables' },
|
||||||
{ icon: '🎨', label: 'Maquillage', sub: '' },
|
{ icon: '🎨', label: 'Maquillage' },
|
||||||
{ icon: '🎁', label: 'Et des surprises', sub: '' },
|
{ icon: '🎁', label: 'Et des surprises' },
|
||||||
],
|
],
|
||||||
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.",
|
||||||
|
|
@ -134,6 +56,26 @@ const SEED_CONTENT = {
|
||||||
'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!',
|
||||||
|
|
@ -142,10 +84,10 @@ const SEED_CONTENT = {
|
||||||
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', sub: '' },
|
{ icon: '🎧', label: 'DJ' },
|
||||||
{ icon: '🎪', label: 'Bouncy castles', sub: '' },
|
{ icon: '🎪', label: 'Bouncy castles' },
|
||||||
{ icon: '🎨', label: 'Face painting', sub: '' },
|
{ icon: '🎨', label: 'Face painting' },
|
||||||
{ icon: '🎁', label: 'And surprises', sub: '' },
|
{ icon: '🎁', label: 'And surprises' },
|
||||||
],
|
],
|
||||||
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.',
|
||||||
|
|
@ -154,107 +96,37 @@ const SEED_CONTENT = {
|
||||||
'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',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Persistance des configs (fichier shadow le SEED ; édition/attachments écrivent ici) ──
|
function getEvent (eventId) { return SEED[eventId] || null }
|
||||||
function configFile (id) { return path.join(CONFIG_DIR, `${id}.json`) }
|
function listEvents () { return Object.values(SEED).map(e => ({ id: e.id, active: e.active, date_iso: e.date_iso, name: e.fr.name })) }
|
||||||
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) }
|
||||||
|
|
@ -307,35 +179,15 @@ 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">${esc(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">${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">`
|
||||||
|
|
@ -344,86 +196,26 @@ function inviteEmailFestive (e, lang, name, rsvpUrl) {
|
||||||
+ `<tr><td style="height:10px;background:#1a86c7;font-size:0;line-height:0"> </td></tr>`
|
+ `<tr><td style="height:10px;background:#1a86c7;font-size:0;line-height:0"> </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>`
|
||||||
+ (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>')
|
+ `<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>`
|
||||||
+ `<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>`
|
||||||
+ (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 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.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 style="padding:14px 18px 4px"><table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr>${prog}</tr></table></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 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.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 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">${(t.notes || []).map(esc).join('<br>')}`
|
+ `<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])}`
|
||||||
+ `<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> · ${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> · ${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)
|
||||||
|
|
@ -431,13 +223,8 @@ 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') {
|
if (channel === 'gmail') { const r = await require('./gmail').sendMessage({ to: em, subject, html, from: from || undefined }); ok = !!r }
|
||||||
const attachments = atts.map(a => ({ filename: a.filename, mime: a.mime, content: a.buffer.toString('base64') }))
|
else ok = await require('./email').sendEmail({ to: em, subject, html, from: from || undefined })
|
||||||
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 }) }
|
||||||
}
|
}
|
||||||
|
|
@ -484,55 +271,6 @@ 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
|
||||||
|
|
@ -540,60 +278,35 @@ 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 = [] // filtres au niveau Customer
|
const f = []
|
||||||
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
|
||||||
|
|
||||||
// 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) {
|
|
||||||
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 }
|
|
||||||
let subIds = Object.keys(byCust)
|
|
||||||
if (minMonthly > 0) subIds = subIds.filter(c => byCust[c] >= minMonthly)
|
|
||||||
intersect(new Set(subIds))
|
|
||||||
}
|
|
||||||
|
|
||||||
let customers = []
|
let customers = []
|
||||||
if (restrict) {
|
if (filters.active_sub || minMonthly > 0) {
|
||||||
const ids = [...restrict]
|
// Agrège les abonnements ACTIFS par client (somme mensuelle) ; filtre par minimum si demandé.
|
||||||
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) }
|
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 }
|
||||||
|
let ids = Object.keys(byCust)
|
||||||
|
if (minMonthly > 0) ids = ids.filter(c => byCust[c] >= minMonthly)
|
||||||
|
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: CFIELDS, limit: 500 })
|
||||||
|
customers.push(...b)
|
||||||
|
}
|
||||||
|
customers.forEach(c => { c._monthly = byCust[c.name] || 0 })
|
||||||
} else {
|
} else {
|
||||||
let s = 0
|
let s = 0
|
||||||
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 }
|
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 }
|
||||||
}
|
}
|
||||||
if (byCust) customers.forEach(c => { c._monthly = byCust[c.name] || 0 })
|
const resil = await resiliatedSet() // GARDE-FOU : jamais inviter un compte F résilié (abos fantômes, cf feedback_ghost_active_subscriptions)
|
||||||
// GARDE-FOU par défaut : exclure les comptes F résiliés (abos fantômes, cf feedback_ghost_active_subscriptions).
|
const before = customers.length
|
||||||
// Sauf si `include_resiliated` (choisi via « Tous ») → on autorise les ex-clients (relance / win-back).
|
customers = customers.filter(c => !resil.has(Number(c.legacy_account_id)))
|
||||||
if (!filters.include_resiliated) {
|
resiliatedExcluded = before - customers.length
|
||||||
const resil = await resiliatedSet()
|
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 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) {
|
||||||
|
|
@ -608,64 +321,21 @@ 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 = {}, opts = {}) {
|
function page (event, lang, prefill = {}) {
|
||||||
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 full = !!opts.full
|
const known = !!prefill.customer_id
|
||||||
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">${esc(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">${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>
|
||||||
|
|
@ -681,7 +351,6 @@ 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}
|
||||||
|
|
@ -715,43 +384,32 @@ 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">
|
||||||
${badgeHtml}
|
<div class="badge"><b>20</b><span>ANS</span></div>
|
||||||
<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">
|
||||||
${t.when ? `<div class="when"><span class="cal">📅</span><span>${esc(t.when)}</span></div>` : ''}
|
<div class="when"><span class="cal">📅</span><span>${esc(t.when)}</span></div>
|
||||||
<p>${esc(t.body)}</p>
|
<p>${esc(t.body)}</p>
|
||||||
${program ? `<div class="prog-wrap">${program}</div>` : ''}
|
<div class="prog-wrap">${program}</div>
|
||||||
${t.program_line ? `<p style="text-align:center;font-style:italic;color:var(--muted);margin-top:12px">${esc(t.program_line)}</p>` : ''}
|
<p style="text-align:center;font-style:italic;color:var(--muted);margin-top:12px">${esc(t.program_line)}</p>
|
||||||
${t.limited ? `<div class="limited"><span class="i">⏳</span><p>${esc(t.limited)}</p></div>` : ''}
|
<div class="limited"><span class="i">⏳</span><p>${esc(t.limited)}</p></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="fullCard" class="card${full ? '' : ' hidden'}">
|
<div id="formCard" class="card">
|
||||||
<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>
|
||||||
|
|
@ -794,7 +452,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(),party_size:ps,allergies:(f.allergies.value||'').trim(),lang:LANG})})
|
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})})
|
||||||
.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){
|
||||||
|
|
@ -802,7 +460,6 @@ 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'});
|
||||||
|
|
@ -840,15 +497,8 @@ 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, { full }))
|
return res.end(page(event, lang, prefill))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Soumission publique : POST /rsvp/<eventId>/submit
|
// Soumission publique : POST /rsvp/<eventId>/submit
|
||||||
|
|
@ -871,11 +521,6 @@ 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,
|
||||||
|
|
@ -902,71 +547,12 @@ 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, capacity: event.capacity, email_template: event.email_template || '', attachments: event.attachments || [], 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, 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\/(.+)$/)
|
||||||
|
|
@ -974,21 +560,6 @@ 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') {
|
||||||
|
|
@ -1005,7 +576,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'|'manual', filters, csv} → APERÇU (ne persiste rien)
|
// Staff : POST /events/<id>/audience {mode:'filter'|'csv', filters, csv} → aperçu (décompte + échantillon)
|
||||||
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' })
|
||||||
|
|
@ -1015,37 +586,8 @@ 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, addAttachment, removeAttachment, loadSendAttachments, headcountOf, audienceListAdd, audienceListRemove, audienceListClear, loadAudienceList, audienceSummary }
|
module.exports = { handle, getEvent, listEvents, listRsvps, deleteRsvp, rsvpLink, inviteEmail, inviteSubject, sendTest, matchAndValidate, resolveAudience, parseAudienceCsv, page, normLang }
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user