Accumulated work on the dispatch/legacy-writeback branch: - Communications UI: CommunicationsPage, ConversationFullPage, DepartmentBoard, PipelineBoard, ReaderStack, Orchestrator/NewTicket/ServiceStatus/Outbox dialogs; hub gmail.js, ticket-collab.js, outbox.js, coupon-triage.js, client-diag.js. - Billing/sync mirror (F→ERPNext): legacy-payments.js, legacy-sync.js, sync-orchestrator.js, supplier-invoices.js, municipality.js + incremental migration scripts; LegacySyncPage, SupplierInvoices + negative-billing / terminated-active reports. - Roster/campaigns/network/voice: roster + roster-assistant, campaigns, giftbit, olt-snmp, traccar, twilio, vision, tech-absence-sms, ai/agent/config/helpers, legacy-dispatch-sync; ops PlanificationPage, RapportsPage, Settings, Tickets, ClientDetail updates. - docs/ PLATFORM_GUIDE + UI_AND_OPTIMIZATION; .gitignore __pycache__. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
280 lines
9.9 KiB
Vue
280 lines
9.9 KiB
Vue
<template>
|
|
<q-dialog :model-value="modelValue" @update:model-value="$emit('update:modelValue', $event)">
|
|
<q-card style="width:560px;max-width:95vw" class="publish-modal">
|
|
<q-card-section class="row items-center q-pb-sm" style="border-bottom:1px solid rgba(255,255,255,0.06)">
|
|
<div class="col">
|
|
<div class="text-subtitle1 text-weight-bold">Publier & envoyer l'horaire par SMS</div>
|
|
</div>
|
|
<q-btn flat round dense icon="close" color="grey-5" @click="$emit('update:modelValue', false)" />
|
|
</q-card-section>
|
|
|
|
<q-card-section class="q-gutter-md">
|
|
<!-- Period -->
|
|
<div>
|
|
<div class="text-caption text-grey-5 q-mb-xs">Période</div>
|
|
<div class="row items-center q-gutter-sm">
|
|
<q-input v-model="startDate" type="date" dense outlined dark class="col" />
|
|
<span class="text-grey-5">→</span>
|
|
<q-input v-model="endDate" type="date" dense outlined dark class="col" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Employees -->
|
|
<div>
|
|
<div class="text-caption text-grey-5 q-mb-xs">Employés</div>
|
|
<q-select v-model="selectedTechs" :options="techOptions" multiple use-chips
|
|
dense outlined dark emit-value map-options
|
|
option-value="value" option-label="label">
|
|
<template v-slot:selected-item="{ opt, removeAtIndex, index }">
|
|
<q-chip removable dense size="sm" color="indigo-8" text-color="white"
|
|
@remove="removeAtIndex(index)">
|
|
<q-avatar color="indigo-6" text-color="white" size="18px" class="q-mr-xs">T</q-avatar>
|
|
{{ opt.label || opt }}
|
|
</q-chip>
|
|
</template>
|
|
</q-select>
|
|
</div>
|
|
|
|
<!-- Draft count info -->
|
|
<div v-if="draftCount > 0" class="text-caption" style="color:#a5b4fc">
|
|
{{ draftCount }} travaux brouillon à publier pour cette période
|
|
</div>
|
|
<div v-else class="text-caption text-grey-6">
|
|
Aucun brouillon à publier pour cette sélection
|
|
</div>
|
|
|
|
<!-- Include open jobs -->
|
|
<q-checkbox v-model="includeOpen" label="Inclure les postes ouverts (non assignés)"
|
|
dark dense color="indigo-6" />
|
|
|
|
<!-- Additional message -->
|
|
<div>
|
|
<div class="text-caption text-grey-5 q-mb-xs">Message additionnel inclus dans le SMS envoyé aux employés</div>
|
|
<q-input v-model="extraMessage" type="textarea" dense outlined dark
|
|
placeholder="Message optionnel..." :input-style="{ minHeight: '48px' }" />
|
|
</div>
|
|
|
|
<!-- What the tech will receive -->
|
|
<div class="sms-preview-hint">
|
|
<q-icon name="info" size="xs" color="indigo-3" class="q-mr-xs" />
|
|
<span>Chaque SMS contient l'horaire + deux liens :
|
|
<b>📱 Mes tâches</b> (application mobile) et
|
|
<b>📅 Ajouter à mon calendrier</b>
|
|
(ajout automatique dans Google Agenda ou Apple Calendar — se met à jour tout seul).
|
|
</span>
|
|
</div>
|
|
</q-card-section>
|
|
|
|
<q-card-actions class="q-px-md q-pb-md">
|
|
<q-btn unelevated label="Publier & Envoyer" color="red-7" icon="send"
|
|
:loading="publishing" :disable="draftCount === 0 && !includeOpen"
|
|
@click="onPublish" />
|
|
<q-btn flat label="Ignorer" color="grey-5" @click="$emit('update:modelValue', false)" />
|
|
</q-card-actions>
|
|
</q-card>
|
|
</q-dialog>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, watch } from 'vue'
|
|
import { Notify } from 'quasar'
|
|
import { publishJobs } from 'src/api/dispatch'
|
|
import { sendTestSms } from 'src/api/sms'
|
|
|
|
const HUB_URL = window.location.hostname === 'localhost' ? 'http://localhost:3300' : (import.meta.env.BASE_URL||'/').replace(/\/$/,'')+'/hub'
|
|
|
|
async function getMagicLink (techId) {
|
|
try {
|
|
const res = await fetch(`${HUB_URL}/magic-link/generate`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ tech_id: techId, ttl_hours: 72 }),
|
|
})
|
|
const data = await res.json()
|
|
return data.ok ? data.link : null
|
|
} catch { return null }
|
|
}
|
|
|
|
// Calendar subscription URL. webcal:// triggers native Calendar.app / Google
|
|
// Calendar "add subscription" flow on tap, so the tech's agenda auto-refreshes
|
|
// whenever dispatch updates jobs — no re-send required.
|
|
async function getIcalUrl (techId) {
|
|
try {
|
|
const res = await fetch(`${HUB_URL}/dispatch/ical-token/${encodeURIComponent(techId)}`)
|
|
const data = await res.json()
|
|
if (!data?.url) return null
|
|
return HUB_URL.replace(/^https?:/, 'webcal:') + data.url
|
|
} catch { return null }
|
|
}
|
|
|
|
const props = defineProps({
|
|
modelValue: Boolean,
|
|
jobs: { type: Array, default: () => [] },
|
|
technicians: { type: Array, default: () => [] },
|
|
periodStart: { type: String, default: '' },
|
|
periodEnd: { type: String, default: '' },
|
|
})
|
|
|
|
const emit = defineEmits(['update:modelValue', 'published'])
|
|
|
|
const startDate = ref('')
|
|
const endDate = ref('')
|
|
const selectedTechs = ref([])
|
|
const includeOpen = ref(true)
|
|
const extraMessage = ref('')
|
|
const publishing = ref(false)
|
|
|
|
const techOptions = computed(() =>
|
|
props.technicians
|
|
.filter(t => t.active !== false)
|
|
.map(t => ({ label: t.fullName || t.name, value: t.id }))
|
|
)
|
|
|
|
// Draft jobs matching period + selected techs
|
|
const draftJobs = computed(() => {
|
|
const start = startDate.value
|
|
const end = endDate.value
|
|
const techIds = new Set(selectedTechs.value)
|
|
return props.jobs.filter(j => {
|
|
if (j.published) return false
|
|
if (j.status === 'completed' || j.status === 'cancelled') return false
|
|
if (!j.scheduledDate) return includeOpen.value
|
|
if (start && j.scheduledDate < start) return false
|
|
if (end && j.scheduledDate > end) return false
|
|
if (techIds.size && j.assignedTech && !techIds.has(j.assignedTech)) return false
|
|
if (!includeOpen.value && !j.assignedTech) return false
|
|
return true
|
|
})
|
|
})
|
|
|
|
const draftCount = computed(() => draftJobs.value.length)
|
|
|
|
// Reset on open
|
|
watch(() => props.modelValue, (open) => {
|
|
if (open) {
|
|
startDate.value = props.periodStart
|
|
endDate.value = props.periodEnd
|
|
selectedTechs.value = techOptions.value.map(t => t.value)
|
|
includeOpen.value = true
|
|
extraMessage.value = ''
|
|
}
|
|
})
|
|
|
|
// ── Build SMS message per tech ──
|
|
function buildSmsForTech (tech, techJobs, magicLink = null, icalUrl = null) {
|
|
const byDate = {}
|
|
for (const j of techJobs) {
|
|
const d = j.scheduledDate || 'Non planifié'
|
|
if (!byDate[d]) byDate[d] = []
|
|
byDate[d].push(j)
|
|
}
|
|
const dayNames = { 0: 'Dim', 1: 'Lun', 2: 'Mar', 3: 'Mer', 4: 'Jeu', 5: 'Ven', 6: 'Sam' }
|
|
const lines = [`Horaire du ${fmtDate(startDate.value)} au ${fmtDate(endDate.value)}:`]
|
|
const sortedDates = Object.keys(byDate).sort()
|
|
for (const d of sortedDates) {
|
|
const dt = new Date(d + 'T12:00:00')
|
|
const dayLabel = isNaN(dt) ? d : `${dayNames[dt.getDay()]} ${dt.getDate()}`
|
|
const jobDescs = byDate[d].map(j => {
|
|
const time = j.startTime ? j.startTime.slice(0, 5) : ''
|
|
const dur = j.duration ? `${j.duration}h` : ''
|
|
const addr = j.address ? shortAddr(j.address) : ''
|
|
return [time, j.subject, dur, addr].filter(Boolean).join(' - ')
|
|
})
|
|
lines.push(`${dayLabel}: ${jobDescs.join('; ')}`)
|
|
}
|
|
if (extraMessage.value.trim()) lines.push(extraMessage.value.trim())
|
|
if (magicLink || icalUrl) lines.push('')
|
|
if (magicLink) lines.push(`📱 Mes tâches: ${magicLink}`)
|
|
if (icalUrl) lines.push(`📅 Ajouter à mon calendrier: ${icalUrl}`)
|
|
return lines.join('\n')
|
|
}
|
|
|
|
function fmtDate (d) {
|
|
if (!d) return '?'
|
|
const dt = new Date(d + 'T12:00:00')
|
|
return isNaN(dt) ? d : `${dt.getDate()}/${dt.getMonth() + 1}`
|
|
}
|
|
|
|
function shortAddr (addr) {
|
|
if (!addr) return ''
|
|
return addr.split(',')[0].trim().slice(0, 30)
|
|
}
|
|
|
|
async function onPublish () {
|
|
publishing.value = true
|
|
try {
|
|
// 1. Collect job names to publish
|
|
const jobNames = draftJobs.value.map(j => j.name || j.id)
|
|
if (jobNames.length === 0) {
|
|
Notify.create({ type: 'warning', message: 'Aucun brouillon à publier' })
|
|
publishing.value = false
|
|
return
|
|
}
|
|
|
|
// 2. Bulk publish via API
|
|
const result = await publishJobs(jobNames)
|
|
|
|
// 3. Send SMS to each selected tech (with magic link)
|
|
const techIds = new Set(selectedTechs.value)
|
|
let smsSent = 0
|
|
let smsFailed = 0
|
|
for (const tech of props.technicians) {
|
|
if (!techIds.has(tech.id)) continue
|
|
if (!tech.phone) continue
|
|
const techJobs = draftJobs.value.filter(j => j.assignedTech === tech.id)
|
|
if (!techJobs.length) continue
|
|
const [magicLink, icalUrl] = await Promise.all([
|
|
getMagicLink(tech.id),
|
|
getIcalUrl(tech.id),
|
|
])
|
|
const msg = buildSmsForTech(tech, techJobs, magicLink, icalUrl)
|
|
try {
|
|
await sendTestSms(tech.phone, msg, '', {
|
|
reference_doctype: 'Dispatch Technician',
|
|
reference_name: tech.name || tech.id,
|
|
})
|
|
smsSent++
|
|
} catch (e) {
|
|
console.warn(`SMS failed for ${tech.fullName}:`, e)
|
|
smsFailed++
|
|
}
|
|
}
|
|
|
|
// 4. Emit published event so parent updates store
|
|
emit('published', jobNames)
|
|
|
|
const parts = [`${result.published} travaux publiés`]
|
|
if (smsSent) parts.push(`${smsSent} SMS envoyés`)
|
|
if (smsFailed) parts.push(`${smsFailed} SMS échoués`)
|
|
Notify.create({ type: 'positive', message: parts.join(', '), timeout: 4000 })
|
|
emit('update:modelValue', false)
|
|
} catch (err) {
|
|
Notify.create({ type: 'negative', message: `Erreur: ${err.message}` })
|
|
} finally {
|
|
publishing.value = false
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.publish-modal {
|
|
background: #1a1e2e;
|
|
color: #e2e4ef;
|
|
}
|
|
.sms-preview-hint {
|
|
background: rgba(99, 102, 241, 0.08);
|
|
border: 1px solid rgba(99, 102, 241, 0.25);
|
|
border-radius: 6px;
|
|
padding: 8px 10px;
|
|
font-size: 11.5px;
|
|
line-height: 1.45;
|
|
color: #c7d2fe;
|
|
display: flex;
|
|
align-items: flex-start;
|
|
}
|
|
.sms-preview-hint b {
|
|
color: #e0e7ff;
|
|
font-weight: 600;
|
|
}
|
|
</style>
|