gigafibre-fsm/apps/ops/src/modules/dispatch/components/PublishScheduleModal.vue
louispaulb a8eeb1b77d refactor: major cleanup — remove dead dispatch app, commit all backend code, extract client composables
- Remove apps/dispatch/ (100% replaced by ops dispatch module, unmaintained)
- Commit services/targo-hub/lib/ (24 modules, 6290 lines — was never tracked)
- Commit services/docuseal + services/legacy-db docker-compose configs
- Extract client app composables: useOTP, useAddressSearch, catalog data, format utils
- Refactor CartPage.vue 630→175 lines, CatalogPage.vue 375→95 lines
- Clean hardcoded credentials from config.js fallback values
- Add client portal: catalog, cart, checkout, OTP verification, address search
- Add ops: NetworkPage, AgentFlowsPage, ConversationPanel, UnifiedCreateModal
- Add ops composables: useBestTech, useConversations, usePermissions, useScanner
- Add field app: scanner composable, docker/nginx configs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 17:38:38 -04:00

238 lines
8.4 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>
</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' : 'https://msg.gigafibre.ca'
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 }
}
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) {
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) lines.push(`\nVos tâches: ${magicLink}`)
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 = await getMagicLink(tech.id)
const msg = buildSmsForTech(tech, techJobs, magicLink)
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;
}
</style>