gigafibre-fsm/apps/ops/src/modules/dispatch/components/PublishScheduleModal.vue
louispaulb 0f65c02d83 feat(fsm): platform build — comms UI, F→ERPNext sync/billing, roster, campaigns, network, reports
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>
2026-06-15 06:12:12 -04:00

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">&rarr;</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&nbsp;:
<b>📱&nbsp;Mes tâches</b> (application mobile) et
<b>📅&nbsp;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>