Compare commits
2 Commits
eb8376c9c3
...
7ca3ceef66
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ca3ceef66 | ||
|
|
4870cec09b |
|
|
@ -43,7 +43,7 @@ export const applyGardeHorizon = (start, weeks, assignments, shifts) => jpost('/
|
||||||
export const setAbsence = (tech, date, type, remove) => jpost('/roster/absence/set', { tech, date, type, remove })
|
export const setAbsence = (tech, date, type, remove) => jpost('/roster/absence/set', { tech, date, type, remove })
|
||||||
export const generate = (start, days = 7, weights) => jpost('/roster/generate', { start, days, weights })
|
export const generate = (start, days = 7, weights) => jpost('/roster/generate', { start, days, weights })
|
||||||
export const publish = (assignments) => jpost('/roster/publish', { assignments })
|
export const publish = (assignments) => jpost('/roster/publish', { assignments })
|
||||||
export const publishWeek = (start, days, assignments, notify) => jpost('/roster/publish-week', { start, days, assignments, notify })
|
export const publishWeek = (start, days, assignments, notify, mode) => jpost('/roster/publish-week', { start, days, assignments, notify, mode })
|
||||||
// Notifier les techs de leur tournée (SMS/Email, lien token + infos) : preview=true → compose sans envoyer.
|
// Notifier les techs de leur tournée (SMS/Email, lien token + infos) : preview=true → compose sans envoyer.
|
||||||
export const notifyDispatch = (payload) => jpost('/roster/notify-dispatch', payload)
|
export const notifyDispatch = (payload) => jpost('/roster/notify-dispatch', payload)
|
||||||
export const updateTemplate = (name, patch) => jput('/roster/template/' + encodeURIComponent(name), patch)
|
export const updateTemplate = (name, patch) => jput('/roster/template/' + encodeURIComponent(name), patch)
|
||||||
|
|
@ -53,8 +53,13 @@ export const askAssistant = (message, history) => jpost('/roster/assistant', { m
|
||||||
export const getPolicy = () => jget('/roster/policy')
|
export const getPolicy = () => jget('/roster/policy')
|
||||||
export const savePolicy = (policy) => jpost('/roster/policy', policy)
|
export const savePolicy = (policy) => jpost('/roster/policy', policy)
|
||||||
export const setJobLevel = (name, level) => jpost('/roster/job-level', { name, level }) // niveau requis persistant par job
|
export const setJobLevel = (name, level) => jpost('/roster/job-level', { name, level }) // niveau requis persistant par job
|
||||||
|
export const setJobSkills = (name, skills) => jpost('/roster/job-skills', { name, skills }) // compétences requises (LISTE) persistantes par job → dispatch auto
|
||||||
export const setJobRemote = (name, remote) => jpost('/roster/job-remote', { name, remote }) // 🖥 sans déplacement (à distance) — exclu des tournées
|
export const setJobRemote = (name, remote) => jpost('/roster/job-remote', { name, remote }) // 🖥 sans déplacement (à distance) — exclu des tournées
|
||||||
export const groupJobs = (parent, children) => jpost('/roster/job-group', { parent, children }) // 📦 grouper en « projet » (parent_job)
|
export const groupJobs = (parent, children) => jpost('/roster/job-group', { parent, children }) // 📦 grouper en « projet » (parent_job)
|
||||||
|
// Répondre au fil du billet depuis le volet job (note interne par défaut, au client si public). agentName = prénom+nom de l'agent (attribution).
|
||||||
|
export const postJobComment = ({ job, lid, text, isPublic, agentName }) => jpost('/roster/job-comment', { job, lid, text, public: !!isPublic, agentName: agentName || '' })
|
||||||
|
// #5 — Réserver le temps d'un tech (bloc « Réservation » priorité moyenne) → dé-priorise au dispatch auto + soustrait des créneaux.
|
||||||
|
export const reserveTech = ({ tech, date, start_time, duration_h, reason }) => jpost('/roster/reserve', { tech, date, start_time, duration_h, reason })
|
||||||
export async function deleteShiftTemplate (name) {
|
export async function deleteShiftTemplate (name) {
|
||||||
const r = await fetch(HUB + '/roster/template/' + encodeURIComponent(name), { method: 'DELETE' })
|
const r = await fetch(HUB + '/roster/template/' + encodeURIComponent(name), { method: 'DELETE' })
|
||||||
if (!r.ok) throw new Error('Suppression modèle: ' + r.status)
|
if (!r.ok) throw new Error('Suppression modèle: ' + r.status)
|
||||||
|
|
|
||||||
|
|
@ -1,145 +0,0 @@
|
||||||
<template>
|
|
||||||
<q-dialog v-model="open">
|
|
||||||
<q-card style="min-width:460px;max-width:96vw">
|
|
||||||
<q-card-section class="row items-center q-pb-none">
|
|
||||||
<q-icon name="confirmation_number" color="positive" size="22px" class="q-mr-sm" />
|
|
||||||
<div class="text-subtitle1 text-weight-bold">Nouveau ticket</div>
|
|
||||||
<q-space />
|
|
||||||
<q-btn flat round dense icon="close" v-close-popup />
|
|
||||||
</q-card-section>
|
|
||||||
<q-card-section class="q-gutter-sm">
|
|
||||||
<!-- Sujet en langage naturel : tape le problème + nom / adresse / téléphone -->
|
|
||||||
<div style="position:relative">
|
|
||||||
<q-input dense outlined v-model="title" type="textarea" autogrow :rows="2" autofocus
|
|
||||||
label="Décris le problème (ex. « Problème wifi 2338 rue Ste-Clotilde » ou un nom / téléphone)"
|
|
||||||
@update:model-value="onSearch">
|
|
||||||
<template #append><q-spinner v-if="searching" size="16px" color="positive" /></template>
|
|
||||||
</q-input>
|
|
||||||
<div v-if="results.length && !customer" class="nt-dd">
|
|
||||||
<div v-for="m in results" :key="m.name" class="nt-item" @click="pick(m)">
|
|
||||||
<q-icon :name="m.matched === 'adresse' ? 'place' : m.matched === 'téléphone' ? 'call' : 'person'" size="16px" color="positive" class="q-mr-sm" />
|
|
||||||
<div class="col ellipsis">
|
|
||||||
<div class="text-body2 ellipsis">{{ m.customer_name || m.name }}<q-badge v-if="m.inactive" color="grey-4" text-color="grey-8" label="inactif" class="q-ml-xs" /></div>
|
|
||||||
<div class="text-caption text-grey-6 ellipsis">{{ m.address || m.territory || m.name }}</div>
|
|
||||||
</div>
|
|
||||||
<q-badge color="green-1" text-color="green-9" :label="m.matched" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Client résolu -->
|
|
||||||
<div v-if="customer">
|
|
||||||
<div class="row items-center">
|
|
||||||
<q-chip dense color="blue-1" text-color="blue-9" icon="person" removable @remove="clearCustomer">{{ customerName }}</q-chip>
|
|
||||||
<span class="text-caption text-grey-6">compte lié</span>
|
|
||||||
</div>
|
|
||||||
<!-- Adresse de service (compte multi-adresses) → on lie la BONNE, pas la facturation -->
|
|
||||||
<q-select v-if="serviceLocs.length > 1" dense outlined v-model="serviceLoc" :options="serviceLocs" option-label="address" option-value="name" emit-value map-options clearable
|
|
||||||
label="Adresse de service" class="q-mt-xs">
|
|
||||||
<template #prepend><q-icon name="place" color="warning" /></template>
|
|
||||||
</q-select>
|
|
||||||
<div v-else-if="serviceLoc && serviceLocs.length === 1" class="text-caption text-grey-7 q-mt-xs"><q-icon name="place" size="13px" color="warning" /> {{ serviceLocs[0].address }}</div>
|
|
||||||
</div>
|
|
||||||
<div v-else class="text-caption text-grey-5"><q-icon name="info" size="14px" /> Tape un nom, une adresse ou un numéro → l'autosuggest lie le bon compte (optionnel).</div>
|
|
||||||
|
|
||||||
<!-- Départements probables par mots-clés (pas besoin de fouiller la liste) -->
|
|
||||||
<div v-if="suggestions.length" class="row items-center q-gutter-xs">
|
|
||||||
<span class="text-caption text-grey-6">Probable :</span>
|
|
||||||
<q-chip v-for="s in suggestions" :key="s.category" dense clickable :icon="s.icon"
|
|
||||||
:color="category === s.category ? s.color : 'grey-3'" :text-color="category === s.category ? 'white' : 'grey-8'"
|
|
||||||
@click="setCategory(s.category)">{{ s.category }}<q-tooltip>détecté : « {{ s.keyword }} »</q-tooltip></q-chip>
|
|
||||||
</div>
|
|
||||||
<div class="row q-col-gutter-sm">
|
|
||||||
<q-select class="col" dense outlined v-model="category" :options="categories" label="Catégorie" @update:model-value="touched = true" />
|
|
||||||
<q-select class="col" dense outlined v-model="priority" :options="['Low','Medium','High','Urgent']" label="Priorité" />
|
|
||||||
</div>
|
|
||||||
<div class="text-caption text-grey-6" v-if="queueFor(category)">→ routé à l'équipe <b>{{ queueFor(category) }}</b></div>
|
|
||||||
<div class="row q-col-gutter-sm items-center">
|
|
||||||
<q-btn-toggle class="col-auto" v-model="status" dense unelevated size="sm" toggle-color="primary" color="grey-3" text-color="grey-8" no-caps :options="[{ label: 'Ouvert', value: 'Open' }, { label: 'En attente', value: 'On Hold' }]" />
|
|
||||||
<q-input class="col" dense outlined v-model="dueDate" type="date" stack-label :label="status === 'On Hold' ? 'Rappel — réactiver à cette date' : 'Échéance (optionnel)'" />
|
|
||||||
</div>
|
|
||||||
<div v-if="status === 'On Hold'" class="text-caption text-warning"><q-icon name="pause_circle" size="13px" /> Suspendu (ex. en attente du paiement) — réapparaît à la date de rappel.</div>
|
|
||||||
</q-card-section>
|
|
||||||
<q-card-actions align="right">
|
|
||||||
<q-btn flat label="Annuler" v-close-popup />
|
|
||||||
<q-btn unelevated color="positive" icon="confirmation_number" label="Créer le ticket" :disable="!title.trim()" :loading="busy" @click="submit" />
|
|
||||||
</q-card-actions>
|
|
||||||
</q-card>
|
|
||||||
</q-dialog>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, watch, computed } from 'vue'
|
|
||||||
import { useQuasar } from 'quasar'
|
|
||||||
import { HUB_URL } from 'src/config/hub'
|
|
||||||
import { useConversations } from 'src/composables/useConversations'
|
|
||||||
import { suggestDepartments } from 'src/config/departments'
|
|
||||||
|
|
||||||
const props = defineProps({ modelValue: Boolean })
|
|
||||||
const emit = defineEmits(['update:modelValue', 'created'])
|
|
||||||
const $q = useQuasar()
|
|
||||||
const { createStandaloneTicket } = useConversations()
|
|
||||||
|
|
||||||
const open = ref(false)
|
|
||||||
const title = ref('')
|
|
||||||
const category = ref('Support')
|
|
||||||
const priority = ref('Medium')
|
|
||||||
const customer = ref('')
|
|
||||||
const customerName = ref('')
|
|
||||||
const results = ref([])
|
|
||||||
const searching = ref(false)
|
|
||||||
const busy = ref(false)
|
|
||||||
const status = ref('Open')
|
|
||||||
const dueDate = ref('')
|
|
||||||
const touched = ref(false) // l'agent a-t-il choisi la catégorie manuellement ?
|
|
||||||
let _t = null
|
|
||||||
const categories = ['Support', 'Facturation', 'Installation', 'Fibre', 'Télévision', 'Téléphonie', 'Commercial', 'Autre']
|
|
||||||
const QUEUE_OF = { Support: 'Supports', Facturation: 'Facturations', Commercial: 'Service à la clientèle', Installation: 'Technicien', Fibre: 'Technicien', Télévision: 'Technicien', Téléphonie: 'Technicien' }
|
|
||||||
function queueFor (c) { return QUEUE_OF[c] || '' }
|
|
||||||
// Départements probables (mots-clés, live) → pré-sélection du plus probable tant que l'agent n'a pas choisi.
|
|
||||||
const suggestions = computed(() => suggestDepartments(title.value).slice(0, 3))
|
|
||||||
watch(suggestions, list => { if (!touched.value && list.length) category.value = list[0].category })
|
|
||||||
function setCategory (c) { category.value = c; touched.value = true }
|
|
||||||
|
|
||||||
watch(() => props.modelValue, v => { open.value = v; if (v) reset() })
|
|
||||||
watch(open, v => emit('update:modelValue', v))
|
|
||||||
const serviceLoc = ref('') // adresse de service liée (Service Location)
|
|
||||||
const serviceLocs = ref([]) // adresses du compte (multi-adresses)
|
|
||||||
function reset () { title.value = ''; category.value = 'Support'; priority.value = 'Medium'; customer.value = ''; customerName.value = ''; results.value = []; touched.value = false; status.value = 'Open'; dueDate.value = ''; serviceLoc.value = ''; serviceLocs.value = [] }
|
|
||||||
function clearCustomer () { customer.value = ''; customerName.value = ''; serviceLoc.value = ''; serviceLocs.value = [] }
|
|
||||||
function pick (m) {
|
|
||||||
customer.value = m.name; customerName.value = m.customer_name || m.name; results.value = []
|
|
||||||
serviceLoc.value = m.service_location || '' // adresse matchée → présélection
|
|
||||||
fetch(`${HUB_URL}/collab/service-locations?customer=${encodeURIComponent(m.name)}`).then(r => r.ok ? r.json() : { locations: [] }).then(d => {
|
|
||||||
serviceLocs.value = d.locations || []
|
|
||||||
if (!serviceLoc.value && serviceLocs.value.length) serviceLoc.value = serviceLocs.value[0].name // pré-sélectionne l'adresse de service active la plus récente (triée en 1re par le hub)
|
|
||||||
}).catch(() => { serviceLocs.value = [] })
|
|
||||||
}
|
|
||||||
|
|
||||||
function onSearch (v) {
|
|
||||||
clearTimeout(_t)
|
|
||||||
if (customer.value) return // déjà lié
|
|
||||||
const q = String(v || '').trim()
|
|
||||||
if (q.length < 3) { results.value = []; return }
|
|
||||||
_t = setTimeout(async () => {
|
|
||||||
searching.value = true
|
|
||||||
try { const r = await fetch(`${HUB_URL}/collab/customer-search?q=${encodeURIComponent(q)}`); if (r.ok) { const d = await r.json(); results.value = d.matches || [] } } catch (e) { results.value = [] } finally { searching.value = false }
|
|
||||||
}, 350)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submit () {
|
|
||||||
if (!title.value.trim()) return
|
|
||||||
busy.value = true
|
|
||||||
try {
|
|
||||||
const d = await createStandaloneTicket({ title: title.value.trim(), category: category.value, priority: priority.value, customer: customer.value || undefined, customer_name: customerName.value || undefined, service_location: serviceLoc.value || undefined, queue: queueFor(category.value) || undefined, status: status.value, due_date: dueDate.value || undefined })
|
|
||||||
if (d.ok) { $q.notify({ type: 'positive', message: `Ticket ${d.name} créé${customerName.value ? ' · ' + customerName.value : ''}${queueFor(category.value) ? ' → ' + queueFor(category.value) : ''}` }); emit('created', d); open.value = false }
|
|
||||||
else $q.notify({ type: 'negative', message: d.error || 'Échec' })
|
|
||||||
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { busy.value = false }
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.nt-dd { position: absolute; z-index: 30; left: 0; right: 0; background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; box-shadow: 0 6px 18px rgba(0,0,0,.12); margin-top: 2px; max-height: 240px; overflow-y: auto; }
|
|
||||||
.nt-item { display: flex; align-items: center; padding: 8px 10px; cursor: pointer; border-bottom: 1px solid #f5f7fa; }
|
|
||||||
.nt-item:hover { background: #f0fdfa; }
|
|
||||||
</style>
|
|
||||||
|
|
@ -1161,6 +1161,7 @@ const props = defineProps({
|
||||||
customer: { type: Object, default: () => null },
|
customer: { type: Object, default: () => null },
|
||||||
existingJobs: { type: Array, default: () => [] },
|
existingJobs: { type: Array, default: () => [] },
|
||||||
deliveryAddressId: { type: String, default: '' },
|
deliveryAddressId: { type: String, default: '' },
|
||||||
|
initialTier: { type: Object, default: null }, // forfait pré-choisi dans QuoteWizard (vitrine avant contact) → pré-remplit le panier
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'created'])
|
const emit = defineEmits(['update:modelValue', 'created'])
|
||||||
|
|
@ -2169,6 +2170,11 @@ watch(() => props.modelValue, (v) => {
|
||||||
// Launched from an existing Issue → start at step 0 so the agent can pick
|
// Launched from an existing Issue → start at step 0 so the agent can pick
|
||||||
// a project template aligned with the ticket.
|
// a project template aligned with the ticket.
|
||||||
currentStep.value = fromCustomer ? 2 : 0
|
currentStep.value = fromCustomer ? 2 : 0
|
||||||
|
// Forfait déjà présenté/choisi dans QuoteWizard (services + promos avant contact) → pré-remplir le panier.
|
||||||
|
if (props.initialTier && props.initialTier.id) {
|
||||||
|
const full = internetHeroTiers.find(t => t.id === props.initialTier.id)
|
||||||
|
if (full) { try { onInternetTierClick(full) } catch (e) {} }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,33 @@
|
||||||
</div>
|
</div>
|
||||||
</q-carousel-slide>
|
</q-carousel-slide>
|
||||||
|
|
||||||
<!-- 2 · CONTACT (fiche existante ou nouveau prospect) -->
|
<!-- 2 · FORFAITS — services + promos VISIBLES avant de demander le contact (lead-first) -->
|
||||||
|
<q-carousel-slide name="forfaits" class="q-pa-md column no-wrap">
|
||||||
|
<div class="text-subtitle1 text-weight-medium">Forfaits Internet<span v-if="chosen.fiber"> disponibles ici</span></div>
|
||||||
|
<div class="text-caption text-grey-7 q-mb-sm">Prix promotionnels — rabais 24 mois déjà appliqué. Choisissez-en un (facultatif) ou continuez.</div>
|
||||||
|
<div v-if="!chosen.fiber" class="text-caption text-orange-8 q-mb-sm"><q-icon name="info" size="14px" /> Fibre à valider à cette adresse — prix indicatifs.</div>
|
||||||
|
<div class="qw-tiers column q-gutter-sm">
|
||||||
|
<q-card v-for="t in tiers" :key="t.id" flat bordered class="qw-tier" :class="{ sel: chosen.tier && chosen.tier.id === t.id }" @click="pickTier(t)">
|
||||||
|
<q-card-section class="row items-center no-wrap q-py-sm">
|
||||||
|
<q-icon :name="chosen.tier && chosen.tier.id === t.id ? 'check_circle' : (t.icon || 'wifi')" size="26px" :color="chosen.tier && chosen.tier.id === t.id ? 'teal-7' : 'grey-6'" class="q-mr-md" />
|
||||||
|
<div class="col">
|
||||||
|
<div class="row items-center no-wrap">
|
||||||
|
<span class="text-weight-bold">{{ t.label }}</span>
|
||||||
|
<q-badge v-if="t.badge" :color="t.default ? 'teal-7' : 'grey-6'" class="q-ml-sm">{{ t.badge }}</q-badge>
|
||||||
|
</div>
|
||||||
|
<div class="text-caption text-grey-7">{{ t.speed }} · {{ t.desc }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-right q-pl-sm">
|
||||||
|
<div class="text-h6 text-teal-8 text-weight-bold" style="line-height:1.1">{{ money(t.price_effective) }}<span class="text-caption text-grey-6">/mois</span></div>
|
||||||
|
<div v-if="t.price_base > t.price_effective" class="text-caption text-grey-5"><s>{{ money(t.price_base) }}</s></div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
<div class="text-caption text-grey-6 q-mt-sm">+ Installation {{ money(99.95) }} (frais unique)</div>
|
||||||
|
</q-carousel-slide>
|
||||||
|
|
||||||
|
<!-- 3 · CONTACT (fiche existante ou nouveau prospect) -->
|
||||||
<q-carousel-slide name="contact" class="q-pa-md column no-wrap">
|
<q-carousel-slide name="contact" class="q-pa-md column no-wrap">
|
||||||
<div class="text-subtitle1 text-weight-medium">Contact</div>
|
<div class="text-subtitle1 text-weight-medium">Contact</div>
|
||||||
<div class="text-caption text-grey-7 q-mb-sm">Associez une fiche existante, ou créez un prospect (courriel <b>ou</b> mobile suffit pour la confirmation).</div>
|
<div class="text-caption text-grey-7 q-mb-sm">Associez une fiche existante, ou créez un prospect (courriel <b>ou</b> mobile suffit pour la confirmation).</div>
|
||||||
|
|
@ -81,8 +107,9 @@
|
||||||
<q-card-actions class="row items-center q-px-md q-py-sm">
|
<q-card-actions class="row items-center q-px-md q-py-sm">
|
||||||
<q-btn flat no-caps icon="chevron_left" label="Retour" :disable="stepIdx === 0" @click="go(stepIdx - 1)" />
|
<q-btn flat no-caps icon="chevron_left" label="Retour" :disable="stepIdx === 0" @click="go(stepIdx - 1)" />
|
||||||
<q-space />
|
<q-space />
|
||||||
<q-btn v-if="step === 'adresse'" unelevated no-caps color="teal-7" icon-right="chevron_right" label="Continuer" :disable="!chosen.address" @click="go(1)" />
|
<q-btn v-if="step === 'adresse'" unelevated no-caps color="teal-7" icon-right="chevron_right" label="Voir les forfaits" :disable="!chosen.address" @click="go(1)" />
|
||||||
<q-btn v-else unelevated no-caps color="teal-8" icon-right="request_quote" label="Continuer vers les services" :loading="submitting" @click="finish" />
|
<q-btn v-else-if="step === 'forfaits'" unelevated no-caps color="teal-7" icon-right="chevron_right" label="Continuer" @click="go(2)" />
|
||||||
|
<q-btn v-else unelevated no-caps color="teal-8" icon-right="request_quote" label="Continuer vers la soumission" :loading="submitting" @click="finish" />
|
||||||
</q-card-actions>
|
</q-card-actions>
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
|
|
@ -94,13 +121,15 @@ import { useQuasar } from 'quasar'
|
||||||
import { useAddressSearch } from 'src/composables/useAddressSearch'
|
import { useAddressSearch } from 'src/composables/useAddressSearch'
|
||||||
import { createPlanMap, addSatelliteLayer, satellitePref, setSatellitePref, setSatelliteVisible } from 'src/config/basemap' // init carte MapLibre/OSM commune + satellite ESRI
|
import { createPlanMap, addSatelliteLayer, satellitePref, setSatellitePref, setSatelliteVisible } from 'src/config/basemap' // init carte MapLibre/OSM commune + satellite ESRI
|
||||||
import { listDocs, createDoc } from 'src/api/erp'
|
import { listDocs, createDoc } from 'src/api/erp'
|
||||||
|
import { INTERNET_HERO_TIERS } from 'src/data/wizard-constants' // forfaits « héros » (prix promo 24 mois) — vitrine AVANT le contact
|
||||||
|
|
||||||
const props = defineProps({ modelValue: Boolean })
|
const props = defineProps({ modelValue: Boolean })
|
||||||
// « qualified » : émis avec le customer (doc ERPNext) prêt pour ProjectWizard, + l'adresse/fibre pour info.
|
// « qualified » : émis avec le customer (doc ERPNext) prêt pour ProjectWizard, + l'adresse/fibre + le forfait d'intérêt.
|
||||||
const emit = defineEmits(['update:modelValue', 'qualified'])
|
const emit = defineEmits(['update:modelValue', 'qualified'])
|
||||||
const $q = useQuasar()
|
const $q = useQuasar()
|
||||||
|
|
||||||
const steps = [{ key: 'adresse', label: 'Adresse' }, { key: 'contact', label: 'Contact' }]
|
// Ordre lead-first : Adresse (dispo fibre) → Forfaits (services + promos VISIBLES avant de demander quoi que ce soit) → Contact.
|
||||||
|
const steps = [{ key: 'adresse', label: 'Adresse' }, { key: 'forfaits', label: 'Forfaits' }, { key: 'contact', label: 'Contact' }]
|
||||||
const step = ref('adresse')
|
const step = ref('adresse')
|
||||||
const stepIdx = computed(() => Math.max(0, steps.findIndex(s => s.key === step.value)))
|
const stepIdx = computed(() => Math.max(0, steps.findIndex(s => s.key === step.value)))
|
||||||
function go (i) { const n = Math.min(steps.length - 1, Math.max(0, i)); step.value = steps[n].key }
|
function go (i) { const n = Math.min(steps.length - 1, Math.max(0, i)); step.value = steps[n].key }
|
||||||
|
|
@ -108,7 +137,12 @@ function go (i) { const n = Math.min(steps.length - 1, Math.max(0, i)); step.val
|
||||||
// ── Étape 1 : adresse + fibre ──
|
// ── Étape 1 : adresse + fibre ──
|
||||||
const { addrResults, addrLoading, searchAddr } = useAddressSearch()
|
const { addrResults, addrLoading, searchAddr } = useAddressSearch()
|
||||||
const addrQuery = ref('')
|
const addrQuery = ref('')
|
||||||
const chosen = reactive({ address: '', ville: '', code_postal: '', lat: null, lon: null, fiber: false, maxSpeed: null, zone: null, customer: null })
|
const chosen = reactive({ address: '', ville: '', code_postal: '', lat: null, lon: null, fiber: false, maxSpeed: null, zone: null, customer: null, tier: null })
|
||||||
|
|
||||||
|
// ── Étape « Forfaits » : vitrine des services + promos à l'adresse, AVANT de demander le contact ──
|
||||||
|
const tiers = INTERNET_HERO_TIERS
|
||||||
|
function money (n) { return (Math.round(Number(n || 0) * 100) / 100).toFixed(2).replace('.', ',') + ' $' }
|
||||||
|
function pickTier (t) { chosen.tier = (chosen.tier && chosen.tier.id === t.id) ? null : t } // clic = sélectionne / re-clic = désélectionne (facultatif)
|
||||||
function pickAddress (a) {
|
function pickAddress (a) {
|
||||||
chosen.address = a.address_full; chosen.ville = a.ville || ''; chosen.code_postal = a.code_postal || ''
|
chosen.address = a.address_full; chosen.ville = a.ville || ''; chosen.code_postal = a.code_postal || ''
|
||||||
chosen.lat = a.latitude != null ? +a.latitude : null; chosen.lon = a.longitude != null ? +a.longitude : null
|
chosen.lat = a.latitude != null ? +a.latitude : null; chosen.lon = a.longitude != null ? +a.longitude : null
|
||||||
|
|
@ -158,24 +192,48 @@ function searchCust (q) {
|
||||||
}
|
}
|
||||||
function pickCustomer (c) { chosen.customer = c; custResults.value = [] }
|
function pickCustomer (c) { chosen.customer = c; custResults.value = [] }
|
||||||
|
|
||||||
|
// Cherche une fiche EXISTANTE par courriel/mobile → évite de créer un doublon (garde-fou).
|
||||||
|
async function findExistingCustomer (email, mobile) {
|
||||||
|
const or = []; const em = (email || '').trim(); const digits = (mobile || '').replace(/\D/g, '')
|
||||||
|
if (em) or.push(['email_id', '=', em])
|
||||||
|
if (digits.length >= 10) { const d = digits.slice(-10); or.push(['cell_phone', 'like', '%' + d + '%'], ['phone', 'like', '%' + d + '%']) }
|
||||||
|
if (!or.length) return null
|
||||||
|
try { const r = await listDocs('Customer', { or_filters: or, fields: ['name', 'customer_name', 'cell_phone', 'phone', 'email_id'], limit: 1 }); const rows = Array.isArray(r) ? r : (r.data || []); return rows[0] || null } catch (e) { return null }
|
||||||
|
}
|
||||||
|
// Message d'erreur LISIBLE (jamais le stack trace Frappe brut).
|
||||||
|
function friendlyError (e) {
|
||||||
|
const m = String((e && e.message) || e || '')
|
||||||
|
if (/duplicate|unique|already exists/i.test(m)) return 'Un client avec ces coordonnées existe déjà — cherchez-le dans l\'étape « Contact ».'
|
||||||
|
if (/mandatory|required|reqd/i.test(m)) return 'Il manque une information obligatoire pour créer le client.'
|
||||||
|
if (/permission|not permitted|forbidden|\b403\b/i.test(m)) return 'Permission insuffisante pour créer un client.'
|
||||||
|
return 'Impossible de créer le prospect. Réessayez, ou cherchez une fiche existante.'
|
||||||
|
}
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
async function finish () {
|
async function finish () {
|
||||||
if (!chosen.customer) {
|
if (!chosen.customer) {
|
||||||
if (!leadContactOk.value) { showLeadErr.value = true; return }
|
if (!leadContactOk.value) { showLeadErr.value = true; return }
|
||||||
const name = [lead.first.trim(), lead.last.trim()].filter(Boolean).join(' ') || (lead.email || lead.mobile)
|
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
const doc = await createDoc('Customer', {
|
// 1) DÉ-DUP : ce courriel/mobile correspond-il déjà à une fiche ? → on la réutilise (pas de doublon).
|
||||||
customer_name: name, customer_type: lead.type, customer_group: lead.type === 'Company' ? 'Commercial' : 'Individual', territory: 'Canada',
|
const existing = await findExistingCustomer(lead.email, lead.mobile)
|
||||||
...(lead.email.trim() ? { email_id: lead.email.trim() } : {}), ...(lead.mobile.replace(/\D/g, '') ? { cell_phone: lead.mobile } : {}),
|
if (existing) {
|
||||||
})
|
chosen.customer = existing
|
||||||
chosen.customer = doc && (doc.data || doc)
|
$q.notify({ type: 'info', icon: 'how_to_reg', message: 'Fiche existante réutilisée — ' + (existing.customer_name || existing.name), timeout: 2600 })
|
||||||
$q.notify({ type: 'positive', icon: 'person_add', message: 'Prospect créé — ' + name, timeout: 2000 })
|
} else {
|
||||||
} catch (e) { $q.notify({ type: 'negative', message: 'Création du prospect impossible : ' + (e.message || e) }); submitting.value = false; return }
|
const name = [lead.first.trim(), lead.last.trim()].filter(Boolean).join(' ') || (lead.email || lead.mobile)
|
||||||
|
const doc = await createDoc('Customer', {
|
||||||
|
customer_name: name, customer_type: lead.type, customer_group: lead.type === 'Company' ? 'Commercial' : 'Individual', territory: 'Canada',
|
||||||
|
...(lead.email.trim() ? { email_id: lead.email.trim() } : {}), ...(lead.mobile.replace(/\D/g, '') ? { cell_phone: lead.mobile } : {}),
|
||||||
|
})
|
||||||
|
chosen.customer = doc && (doc.data || doc)
|
||||||
|
$q.notify({ type: 'positive', icon: 'person_add', message: 'Prospect créé — ' + name, timeout: 2000 })
|
||||||
|
}
|
||||||
|
} catch (e) { $q.notify({ type: 'negative', message: friendlyError(e) }); submitting.value = false; return }
|
||||||
submitting.value = false
|
submitting.value = false
|
||||||
}
|
}
|
||||||
// Handoff : ProjectWizard (panier + rabais rab-x + devis + acceptation) prend le relais, pré-rempli.
|
// Handoff : ProjectWizard (panier + rabais rab-x + devis + acceptation) prend le relais, pré-rempli.
|
||||||
emit('qualified', { customer: chosen.customer, address: { address: chosen.address, ville: chosen.ville, code_postal: chosen.code_postal, lat: chosen.lat, lon: chosen.lon, fiber: chosen.fiber, zone: chosen.zone } })
|
// On transmet le forfait d'intérêt (facultatif) pour pré-remplir le panier — le client l'a déjà vu/choisi.
|
||||||
|
emit('qualified', { customer: chosen.customer, address: { address: chosen.address, ville: chosen.ville, code_postal: chosen.code_postal, lat: chosen.lat, lon: chosen.lon, fiber: chosen.fiber, zone: chosen.zone }, tier: chosen.tier ? { id: chosen.tier.id, code: chosen.tier.code, label: chosen.tier.label } : null })
|
||||||
emit('update:modelValue', false)
|
emit('update:modelValue', false)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -191,6 +249,9 @@ async function finish () {
|
||||||
.qw-body { min-height: 0; } .qw-body :deep(.q-carousel__slide) { overflow-y: auto; }
|
.qw-body { min-height: 0; } .qw-body :deep(.q-carousel__slide) { overflow-y: auto; }
|
||||||
.qw-results { border-radius: 8px; max-height: 260px; overflow-y: auto; }
|
.qw-results { border-radius: 8px; max-height: 260px; overflow-y: auto; }
|
||||||
.qw-chosen { border-radius: 8px; }
|
.qw-chosen { border-radius: 8px; }
|
||||||
|
.qw-tier { cursor: pointer; border-radius: 10px; transition: border-color .15s, background .15s, box-shadow .15s; }
|
||||||
|
.qw-tier:hover { border-color: #14b8a6; }
|
||||||
|
.qw-tier.sel { border-color: #0f766e; background: #f0fdfa; box-shadow: 0 0 0 1px #0f766e inset; }
|
||||||
.qw-map-wrap { position: relative; }
|
.qw-map-wrap { position: relative; }
|
||||||
.qw-map { height: 180px; border-radius: 10px; overflow: hidden; border: 1px solid #e2e8f0; }
|
.qw-map { height: 180px; border-radius: 10px; overflow: hidden; border: 1px solid #e2e8f0; }
|
||||||
.qw-sat-btn { position: absolute; top: 6px; left: 6px; z-index: 3; }
|
.qw-sat-btn { position: absolute; top: 6px; left: 6px; z-index: 3; }
|
||||||
|
|
|
||||||
|
|
@ -177,11 +177,8 @@ const statusOptions = [
|
||||||
{ label: 'Complété', value: 'completed' },
|
{ label: 'Complété', value: 'completed' },
|
||||||
{ label: 'Annulé', value: 'cancelled' },
|
{ label: 'Annulé', value: 'cancelled' },
|
||||||
]
|
]
|
||||||
const priorityOptions = [
|
// Priorité Dispatch Job — SOURCE UNIQUE (config/dispatch-priority).
|
||||||
{ label: 'Basse', value: 'low' },
|
import { DISPATCH_PRIORITIES as priorityOptions } from 'src/config/dispatch-priority'
|
||||||
{ label: 'Moyenne', value: 'medium' },
|
|
||||||
{ label: 'Haute', value: 'high' },
|
|
||||||
]
|
|
||||||
const jobTypeOptions = [
|
const jobTypeOptions = [
|
||||||
{ label: 'Installation', value: 'Installation' },
|
{ label: 'Installation', value: 'Installation' },
|
||||||
{ label: 'Réparation', value: 'Réparation' },
|
{ label: 'Réparation', value: 'Réparation' },
|
||||||
|
|
|
||||||
|
|
@ -15,16 +15,28 @@
|
||||||
<q-input v-model="form.subject" label="Sujet" dense outlined autofocus
|
<q-input v-model="form.subject" label="Sujet" dense outlined autofocus
|
||||||
:rules="[v => !!v?.trim() || 'Requis']" />
|
:rules="[v => !!v?.trim() || 'Requis']" />
|
||||||
|
|
||||||
<div class="row q-gutter-sm">
|
<q-select v-model="form.priority" :options="priorityOptions" label="Priorité"
|
||||||
<q-select v-model="form.priority" :options="priorityOptions" label="Priorité"
|
dense outlined emit-value map-options />
|
||||||
dense outlined emit-value map-options class="col" />
|
|
||||||
|
|
||||||
<q-select v-if="fields.showJobType" v-model="form.job_type" :options="jobTypeOptions"
|
<!-- Description / Notes (essentiel — le contenu du ticket/tâche) -->
|
||||||
label="Type de travail" dense outlined emit-value map-options class="col" />
|
<q-input v-model="form.description" :label="mode === 'work-order' ? 'Notes' : 'Description'"
|
||||||
|
type="textarea" dense outlined autogrow style="min-height:60px" />
|
||||||
|
|
||||||
<q-select v-if="fields.showIssueType" v-model="form.issue_type" :options="issueTypeOptions"
|
<!-- Bascule : n'afficher d'abord que l'essentiel ; révéler le reste à la demande -->
|
||||||
label="Type" dense outlined emit-value map-options clearable class="col" />
|
<q-btn flat dense no-caps size="sm" class="text-grey-7"
|
||||||
</div>
|
:icon="showAdvanced ? 'expand_less' : 'expand_more'"
|
||||||
|
:label="showAdvanced ? 'Moins d\'options' : 'Plus d\'options'"
|
||||||
|
@click="showAdvanced = !showAdvanced" />
|
||||||
|
|
||||||
|
<!-- ══ Champs secondaires — révélés à la demande ══ -->
|
||||||
|
<div v-show="showAdvanced" class="q-gutter-sm">
|
||||||
|
<div class="row q-gutter-sm">
|
||||||
|
<q-select v-if="fields.showJobType" v-model="form.job_type" :options="jobTypeOptions"
|
||||||
|
label="Type de travail" dense outlined emit-value map-options class="col" />
|
||||||
|
|
||||||
|
<q-select v-if="fields.showIssueType" v-model="form.issue_type" :options="issueTypeOptions"
|
||||||
|
label="Type" dense outlined emit-value map-options clearable class="col" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Service location (ticket mode, when locations available) -->
|
<!-- Service location (ticket mode, when locations available) -->
|
||||||
<q-select v-if="fields.showServiceLocation && locationOptions.length"
|
<q-select v-if="fields.showServiceLocation && locationOptions.length"
|
||||||
|
|
@ -130,9 +142,7 @@
|
||||||
<q-input v-if="fields.showScheduledDate" v-model="form.scheduled_date"
|
<q-input v-if="fields.showScheduledDate" v-model="form.scheduled_date"
|
||||||
label="Date prévue (optionnel)" type="date" dense outlined />
|
label="Date prévue (optionnel)" type="date" dense outlined />
|
||||||
|
|
||||||
<!-- Description / Notes -->
|
</div>
|
||||||
<q-input v-model="form.description" :label="mode === 'work-order' ? 'Notes' : 'Description'"
|
|
||||||
type="textarea" dense outlined autogrow style="min-height:60px" />
|
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
|
|
@ -183,6 +193,9 @@ const {
|
||||||
|
|
||||||
const { addrResults, searchAddr, selectAddr } = useAddressSearch()
|
const { addrResults, searchAddr, selectAddr } = useAddressSearch()
|
||||||
|
|
||||||
|
// Divulgation progressive : on n'affiche d'abord que l'essentiel (sujet · priorité · description). « Plus d'options » révèle le reste.
|
||||||
|
const showAdvanced = ref(false)
|
||||||
|
|
||||||
// ── Computed ────────────────────────────────────────────────────────────────
|
// ── Computed ────────────────────────────────────────────────────────────────
|
||||||
const title = computed(() => {
|
const title = computed(() => {
|
||||||
const m = props.mode
|
const m = props.mode
|
||||||
|
|
@ -208,12 +221,8 @@ const contextLabel = computed(() => {
|
||||||
const allTagsComputed = computed(() => props.externalTags || allTagsList.value)
|
const allTagsComputed = computed(() => props.externalTags || allTagsList.value)
|
||||||
const getTagColor = computed(() => props.externalGetColor || internalGetTagColor)
|
const getTagColor = computed(() => props.externalGetColor || internalGetTagColor)
|
||||||
|
|
||||||
const priorityOptions = [
|
// Priorité Dispatch Job — SOURCE UNIQUE (3 niveaux ; « Urgent » = high, le champ Select rejette 'urgent' → 417).
|
||||||
{ label: 'Basse', value: 'low' },
|
import { DISPATCH_PRIORITIES as priorityOptions } from 'src/config/dispatch-priority'
|
||||||
{ label: 'Moyenne', value: 'medium' },
|
|
||||||
{ label: 'Haute', value: 'high' },
|
|
||||||
{ label: 'Urgente', value: 'urgent' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const jobTypeOptions = [
|
const jobTypeOptions = [
|
||||||
{ label: 'Installation', value: 'Installation' },
|
{ label: 'Installation', value: 'Installation' },
|
||||||
|
|
@ -257,6 +266,8 @@ watch(() => props.modelValue, (open) => {
|
||||||
if (props.context.service_location) {
|
if (props.context.service_location) {
|
||||||
form.service_location = props.context.service_location
|
form.service_location = props.context.service_location
|
||||||
}
|
}
|
||||||
|
// Divulgation progressive : ouvrir les options d'emblée SEULEMENT si nécessaires (intervention = adresse+tech) ou déjà pré-remplies par le contexte.
|
||||||
|
showAdvanced.value = props.mode === 'work-order' || !!(props.context.service_location || props.context.issue_type || props.context.job_type || props.context.duration_h || props.context.scheduled_date || props.context.depends_on || (props.context.tags && props.context.tags.length))
|
||||||
// Load tags if in a mode that uses them
|
// Load tags if in a mode that uses them
|
||||||
if (fields.value.showTags && !props.externalTags) {
|
if (fields.value.showTags && !props.externalTags) {
|
||||||
loadTags()
|
loadTags()
|
||||||
|
|
|
||||||
|
|
@ -157,16 +157,19 @@
|
||||||
Tâches ({{ dispatchJobs.length }})
|
Tâches ({{ dispatchJobs.length }})
|
||||||
</div>
|
</div>
|
||||||
<q-space />
|
<q-space />
|
||||||
<q-btn-group unelevated dense>
|
<!-- Un SEUL point d'entrée « Ajouter » (plus deux icônes côte à côte) : tâche simple, ou projet multi-étapes d'où découlent les tâches. -->
|
||||||
<q-btn dense size="sm" icon="playlist_add" label="Projet"
|
<q-btn-dropdown dense size="sm" color="primary" no-caps unelevated icon="add" label="Ajouter">
|
||||||
color="green-7" no-caps @click="showProjectWizard = true">
|
<q-list dense style="min-width:230px">
|
||||||
<q-tooltip>Créer un projet (modèle multi-étapes)</q-tooltip>
|
<q-item clickable v-close-popup @click="showCreateDialog = true">
|
||||||
</q-btn>
|
<q-item-section avatar><q-icon name="add_task" color="primary" /></q-item-section>
|
||||||
<q-btn dense size="sm" icon="add" label="Tâche"
|
<q-item-section><q-item-label>Tâche</q-item-label><q-item-label caption>Tâche simple liée à ce ticket</q-item-label></q-item-section>
|
||||||
color="primary" no-caps @click="showCreateDialog = true">
|
</q-item>
|
||||||
<q-tooltip>Ajouter une tâche simple</q-tooltip>
|
<q-item clickable v-close-popup @click="showProjectWizard = true">
|
||||||
</q-btn>
|
<q-item-section avatar><q-icon name="playlist_add" color="green-7" /></q-item-section>
|
||||||
</q-btn-group>
|
<q-item-section><q-item-label>Projet</q-item-label><q-item-label caption>Modèle multi-étapes (installation…) — les tâches en découlent</q-item-label></q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</q-btn-dropdown>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Nested task tree -->
|
<!-- Nested task tree -->
|
||||||
|
|
@ -309,6 +312,7 @@ import { formatDate, formatDateTime, erpFileUrl } from 'src/composables/useForma
|
||||||
import { createDoc, updateDoc, deleteDoc, listDocs } from 'src/api/erp'
|
import { createDoc, updateDoc, deleteDoc, listDocs } from 'src/api/erp'
|
||||||
import { authFetch } from 'src/api/auth'
|
import { authFetch } from 'src/api/auth'
|
||||||
import { useAuthStore } from 'src/stores/auth'
|
import { useAuthStore } from 'src/stores/auth'
|
||||||
|
import { usePermissions } from 'src/composables/usePermissions'
|
||||||
import { BASE_URL } from 'src/config/erpnext'
|
import { BASE_URL } from 'src/config/erpnext'
|
||||||
import { HUB_URL } from 'src/config/hub'
|
import { HUB_URL } from 'src/config/hub'
|
||||||
import { fetchTags, updateTag, renameTag, deleteTag as deleteTagApi } from 'src/api/dispatch'
|
import { fetchTags, updateTag, renameTag, deleteTag as deleteTagApi } from 'src/api/dispatch'
|
||||||
|
|
@ -331,6 +335,11 @@ const props = defineProps({
|
||||||
const emit = defineEmits(['navigate', 'reply-sent', 'dispatch-created', 'dispatch-deleted', 'dispatch-updated', 'deleted'])
|
const emit = defineEmits(['navigate', 'reply-sent', 'dispatch-created', 'dispatch-deleted', 'dispatch-updated', 'deleted'])
|
||||||
|
|
||||||
const replyContent = ref('')
|
const replyContent = ref('')
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const { userName } = usePermissions()
|
||||||
|
// Expéditeur = PRÉNOM de l'utilisateur connecté (note interne) / prénom+nom (sortie courriel au client) — jamais le courriel/login brut (« louis »).
|
||||||
|
const senderFirst = computed(() => { const n = (userName.value || '').trim(); return (n ? n.split(/\s+/)[0] : (authStore.user || '').split('@')[0]) || 'Agent' })
|
||||||
|
const senderFull = computed(() => ((userName.value || '').trim() || (authStore.user || '').split('@')[0] || 'Agent'))
|
||||||
const sendingReply = ref(false)
|
const sendingReply = ref(false)
|
||||||
const showCreateDialog = ref(false)
|
const showCreateDialog = ref(false)
|
||||||
const showProjectWizard = ref(false)
|
const showProjectWizard = ref(false)
|
||||||
|
|
@ -360,7 +369,7 @@ function initials (name) {
|
||||||
// Fusionne échanges (Communication) + notes (Comment) en UN fil chronologique
|
// Fusionne échanges (Communication) + notes (Comment) en UN fil chronologique
|
||||||
const thread = computed(() => {
|
const thread = computed(() => {
|
||||||
const items = []
|
const items = []
|
||||||
for (const c of props.comms || []) items.push({ id: 'e-' + c.name, who: c.sender || c.sender_full_name || c.owner || 'Système', when: c.creation, content: c.content, kind: 'comm' })
|
for (const c of props.comms || []) items.push({ id: 'e-' + c.name, who: c.sender_full_name || c.sender || c.owner || 'Système', when: c.creation, content: c.content, kind: 'comm' })
|
||||||
for (const c of props.comments || []) items.push({ id: 'c-' + c.name, who: c.comment_by || 'Système', when: c.creation, content: c.content, kind: 'note' })
|
for (const c of props.comments || []) items.push({ id: 'c-' + c.name, who: c.comment_by || 'Système', when: c.creation, content: c.content, kind: 'note' })
|
||||||
return items.sort((a, b) => String(a.when || '').localeCompare(String(b.when || '')))
|
return items.sort((a, b) => String(a.when || '').localeCompare(String(b.when || '')))
|
||||||
})
|
})
|
||||||
|
|
@ -705,7 +714,6 @@ async function sendReply () {
|
||||||
if (!replyContent.value?.trim() || sendingReply.value) return
|
if (!replyContent.value?.trim() || sendingReply.value) return
|
||||||
sendingReply.value = true
|
sendingReply.value = true
|
||||||
try {
|
try {
|
||||||
const authStore = useAuthStore()
|
|
||||||
await createDoc('Communication', {
|
await createDoc('Communication', {
|
||||||
communication_type: 'Communication',
|
communication_type: 'Communication',
|
||||||
communication_medium: 'Other',
|
communication_medium: 'Other',
|
||||||
|
|
@ -713,7 +721,7 @@ async function sendReply () {
|
||||||
subject: props.title || props.docName,
|
subject: props.title || props.docName,
|
||||||
content: replyContent.value.trim(),
|
content: replyContent.value.trim(),
|
||||||
sender: authStore.user || '',
|
sender: authStore.user || '',
|
||||||
sender_full_name: authStore.user || '',
|
sender_full_name: senderFirst.value,
|
||||||
reference_doctype: 'Issue',
|
reference_doctype: 'Issue',
|
||||||
reference_name: props.docName,
|
reference_name: props.docName,
|
||||||
})
|
})
|
||||||
|
|
@ -751,6 +759,7 @@ async function sendTicketEmail () {
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
issue: props.docName, to: emailTo.value.trim(), cc: emailCc.value.trim(),
|
issue: props.docName, to: emailTo.value.trim(), cc: emailCc.value.trim(),
|
||||||
message: emailMessage.value.trim(), link: window.location.href,
|
message: emailMessage.value.trim(), link: window.location.href,
|
||||||
|
agentName: senderFull.value, // sortie courriel → expéditeur = prénom+nom de l'agent (pas « louis »)
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
const d = await res.json()
|
const d = await res.json()
|
||||||
|
|
|
||||||
|
|
@ -70,14 +70,15 @@ export function channelMeta (channel) {
|
||||||
// priorityMeta(p) → { icon, color, label, rank } pour le DRAPEAU de priorité, cohérent liste + détail, PARTOUT (conversations + jobs).
|
// priorityMeta(p) → { icon, color, label, rank } pour le DRAPEAU de priorité, cohérent liste + détail, PARTOUT (conversations + jobs).
|
||||||
// STANDARD 3 NIVEAUX (comme ERPNext) : haute (ROUGE) · moyenne (neutre gris) · basse. '' / medium = non classé → drapeau contour gris (invite à classer).
|
// STANDARD 3 NIVEAUX (comme ERPNext) : haute (ROUGE) · moyenne (neutre gris) · basse. '' / medium = non classé → drapeau contour gris (invite à classer).
|
||||||
const PRIORITIES = {
|
const PRIORITIES = {
|
||||||
high: { icon: 'flag', color: 'red-6', label: 'Haute', rank: 3 },
|
// 3 NIVEAUX (valeurs ERPNext low/medium/high sous le capot) — libellés + sémantique demandés par l'user :
|
||||||
medium: { icon: 'flag', color: 'amber-6', label: 'Moyenne', rank: 2 }, // jaune/orange pâle
|
high: { icon: 'priority_high', color: 'red-6', label: 'Urgent', rank: 3, desc: 'À faire en priorité — heure fixée / SLA à respecter' },
|
||||||
low: { icon: 'flag', color: 'blue-grey-5', label: 'Basse', rank: 1 },
|
medium: { icon: 'flag', color: 'amber-6', label: 'Moyenne', rank: 2, desc: 'Best effort — attente client normale, avec rendez-vous' },
|
||||||
// rétro-compatibilité anciennes valeurs (conversations) : urgent→haute (rouge), normal→moyenne
|
low: { icon: 'flag', color: 'blue-grey-5', label: 'Basse', rank: 1, desc: 'Best effort — sans attente, peut être délestée ou reportée' },
|
||||||
urgent: { icon: 'flag', color: 'red-6', label: 'Haute', rank: 3 },
|
// rétro-compatibilité anciennes valeurs (conversations) : urgent→Urgent (rouge), normal→Moyenne
|
||||||
normal: { icon: 'flag', color: 'amber-6', label: 'Moyenne', rank: 2 },
|
urgent: { icon: 'priority_high', color: 'red-6', label: 'Urgent', rank: 3, desc: 'À faire en priorité — heure fixée / SLA à respecter' },
|
||||||
|
normal: { icon: 'flag', color: 'amber-6', label: 'Moyenne', rank: 2, desc: 'Best effort — attente client normale, avec rendez-vous' },
|
||||||
}
|
}
|
||||||
export const PRIORITY_LEVELS = ['high', 'medium', 'low'] // 3 niveaux (standard ERPNext) — haute en tête
|
export const PRIORITY_LEVELS = ['high', 'medium', 'low'] // 3 niveaux — Urgent (haute) en tête
|
||||||
export function priorityMeta (p) {
|
export function priorityMeta (p) {
|
||||||
return PRIORITIES[p] || { icon: 'outlined_flag', color: 'grey-5', label: 'Aucune', rank: 0 }
|
return PRIORITIES[p] || { icon: 'outlined_flag', color: 'grey-5', label: 'Aucune', rank: 0, desc: 'Non classé' }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -234,7 +234,7 @@ export const STATUS_MAP = {
|
||||||
}
|
}
|
||||||
export function stOf (t) { return STATUS_MAP[(t.status||'').toLowerCase()] || STATUS_MAP['available'] }
|
export function stOf (t) { return STATUS_MAP[(t.status||'').toLowerCase()] || STATUS_MAP['available'] }
|
||||||
|
|
||||||
export function prioLabel (p) { return { high:'Haute', medium:'Moyenne', low:'Basse' }[p] || p || '—' }
|
export function prioLabel (p) { return { high: 'Urgent', urgent: 'Urgent', medium: 'Moyenne', normal: 'Moyenne', low: 'Basse' }[p] || p || '—' }
|
||||||
export function prioClass (p) { return { high:'prio-high', medium:'prio-med', low:'prio-low' }[p] || '' }
|
export function prioClass (p) { return { high:'prio-high', medium:'prio-med', low:'prio-low' }[p] || '' }
|
||||||
|
|
||||||
// Lucide-style inline SVG icons (stroke-based)
|
// Lucide-style inline SVG icons (stroke-based)
|
||||||
|
|
|
||||||
13
apps/ops/src/config/dispatch-priority.js
Normal file
13
apps/ops/src/config/dispatch-priority.js
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
// Priorité d'un Dispatch Job — SOURCE UNIQUE (3 niveaux). Importée partout où l'on
|
||||||
|
// choisit/affiche une priorité de job (modales de création, panneau job, pool Planif…).
|
||||||
|
//
|
||||||
|
// ⚠️ Le champ ERPNext « Dispatch Job.priority » est un Select { low | medium | high }
|
||||||
|
// UNIQUEMENT — la valeur 'urgent' est rejetée (417). « Urgent » = high.
|
||||||
|
// Libellés / couleurs / descriptions alignés sur la légende : 🔴 Urgent · 🟠 Moyenne · ⚪ Basse.
|
||||||
|
// (Les tickets ERPNext « Issue » ont un espace de priorité DISTINCT → voir config/ticket-config.js.)
|
||||||
|
export const DISPATCH_PRIORITIES = [
|
||||||
|
{ value: 'high', label: 'Urgent', color: '#ef4444', desc: 'À faire en priorité — heure fixée / SLA à respecter' },
|
||||||
|
{ value: 'medium', label: 'Moyenne', color: '#f59e0b', desc: 'Best effort — attente client normale, avec rendez-vous' },
|
||||||
|
{ value: 'low', label: 'Basse', color: '#9e9e9e', desc: 'Best effort — sans attente, peut être délestée ou reportée' },
|
||||||
|
]
|
||||||
|
// Pour mapper une valeur → libellé (avec rétro-compat urgent/normal), utiliser `prioLabel` de composables/useHelpers.
|
||||||
|
|
@ -10,9 +10,9 @@ export const JOB_TYPE_OPTIONS = [
|
||||||
]
|
]
|
||||||
|
|
||||||
export const PRIORITY_OPTIONS = [
|
export const PRIORITY_OPTIONS = [
|
||||||
{ label: 'Basse', value: 'low' },
|
{ label: 'Urgent', value: 'high' },
|
||||||
{ label: 'Moyenne', value: 'medium' },
|
{ label: 'Moyenne', value: 'medium' },
|
||||||
{ label: 'Haute', value: 'high' },
|
{ label: 'Basse', value: 'low' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export const ORDER_MODE_OPTIONS = [
|
export const ORDER_MODE_OPTIONS = [
|
||||||
|
|
|
||||||
|
|
@ -151,8 +151,6 @@
|
||||||
</div>
|
</div>
|
||||||
</q-page-sticky>
|
</q-page-sticky>
|
||||||
|
|
||||||
<!-- Création rapide d'un ticket depuis le FAB -->
|
|
||||||
<NewTicketDialog v-if="can('view_clients')" v-model="newTicketOpen" />
|
|
||||||
<!-- Commande dictée / langage naturel → orchestre plusieurs actions -->
|
<!-- Commande dictée / langage naturel → orchestre plusieurs actions -->
|
||||||
<OrchestratorDialog v-if="can('view_clients')" v-model="orchestratorOpen" />
|
<OrchestratorDialog v-if="can('view_clients')" v-model="orchestratorOpen" />
|
||||||
<!-- Statut service+modem par identifiant libre (adresse / nom / téléphone / courriel) — lecture -->
|
<!-- Statut service+modem par identifiant libre (adresse / nom / téléphone / courriel) — lecture -->
|
||||||
|
|
@ -188,7 +186,6 @@ import NotificationBell from 'src/components/shared/NotificationBell.vue'
|
||||||
import { useConversations } from 'src/composables/useConversations'
|
import { useConversations } from 'src/composables/useConversations'
|
||||||
import { useCreateSignal } from 'src/composables/useCreateSignal'
|
import { useCreateSignal } from 'src/composables/useCreateSignal'
|
||||||
const FlowEditorDialog = defineAsyncComponent(() => import('src/components/flow-editor/FlowEditorDialog.vue'))
|
const FlowEditorDialog = defineAsyncComponent(() => import('src/components/flow-editor/FlowEditorDialog.vue'))
|
||||||
const NewTicketDialog = defineAsyncComponent(() => import('src/components/shared/NewTicketDialog.vue'))
|
|
||||||
const OrchestratorDialog = defineAsyncComponent(() => import('src/components/shared/OrchestratorDialog.vue'))
|
const OrchestratorDialog = defineAsyncComponent(() => import('src/components/shared/OrchestratorDialog.vue'))
|
||||||
const ServiceStatusDialog = defineAsyncComponent(() => import('src/components/shared/ServiceStatusDialog.vue'))
|
const ServiceStatusDialog = defineAsyncComponent(() => import('src/components/shared/ServiceStatusDialog.vue'))
|
||||||
const OutboxPanel = defineAsyncComponent(() => import('src/components/shared/OutboxPanel.vue'))
|
const OutboxPanel = defineAsyncComponent(() => import('src/components/shared/OutboxPanel.vue'))
|
||||||
|
|
@ -204,7 +201,6 @@ function openComms () { panelOpen.value = false; if (!route.path.startsWith('/co
|
||||||
// FAB « Créer » → le chooser unifié de la Planification (tâche · intervention · soumission), où vivent les flux de création.
|
// FAB « Créer » → le chooser unifié de la Planification (tâche · intervention · soumission), où vivent les flux de création.
|
||||||
function goCreate () { requestCreate(); if (!route.path.startsWith('/planification')) router.push('/planification') }
|
function goCreate () { requestCreate(); if (!route.path.startsWith('/planification')) router.push('/planification') }
|
||||||
// FAB messagerie : clic « forum » = ouvrir les communications ; bouton « + » (clic, pas survol) = menu créer rapide.
|
// FAB messagerie : clic « forum » = ouvrir les communications ; bouton « + » (clic, pas survol) = menu créer rapide.
|
||||||
const newTicketOpen = ref(false)
|
|
||||||
const orchestratorOpen = ref(false)
|
const orchestratorOpen = ref(false)
|
||||||
const serviceStatusOpen = ref(false)
|
const serviceStatusOpen = ref(false)
|
||||||
function openNew (channel) { newDialogChannel.value = channel; newDialogOpen.value = true }
|
function openNew (channel) { newDialogChannel.value = channel; newDialogOpen.value = true }
|
||||||
|
|
|
||||||
|
|
@ -55,11 +55,8 @@ const pricingOptions = Object.entries(PRICING_PRESETS).map(([k, v]) => ({
|
||||||
value: k, label: v.label, description: v.description,
|
value: k, label: v.label, description: v.description,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const priorityOptions = [
|
// Priorité Dispatch Job — SOURCE UNIQUE (config/dispatch-priority) : « Moyenne » = ambre partout (fini le bleu divergent).
|
||||||
{ value: 'low', label: 'Basse', color: '#6b7280' },
|
import { DISPATCH_PRIORITIES as priorityOptions } from 'src/config/dispatch-priority'
|
||||||
{ value: 'medium', label: 'Moyenne', color: '#3b82f6' },
|
|
||||||
{ value: 'high', label: 'Haute', color: '#ef4444' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const modeOptions = [
|
const modeOptions = [
|
||||||
{ value: 'broadcast', label: '📡 Diffusion', hint: 'Tous les techs disponibles' },
|
{ value: 'broadcast', label: '📡 Diffusion', hint: 'Tous les techs disponibles' },
|
||||||
|
|
|
||||||
|
|
@ -22,13 +22,14 @@ const { addrResults, addrLoading, searchAddr, selectAddr } = useAddressSearch()
|
||||||
const target = reactive({ address: '', latitude: null, longitude: null, ville: '' })
|
const target = reactive({ address: '', latitude: null, longitude: null, ville: '' })
|
||||||
const skill = ref('')
|
const skill = ref('')
|
||||||
const durationH = ref(1)
|
const durationH = ref(1)
|
||||||
|
const urgent = ref(false) // urgence/réparation → ignore les blocs RÉSERVÉS (soft) : le tech redevient disponible pour ce créneau
|
||||||
const afterDate = ref(new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }))
|
const afterDate = ref(new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }))
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const slots = ref([])
|
const slots = ref([])
|
||||||
const searched = ref(false)
|
const searched = ref(false)
|
||||||
|
|
||||||
// Reset à l'ouverture (formulaire propre à chaque fois)
|
// Reset à l'ouverture (formulaire propre à chaque fois)
|
||||||
watch(() => props.modelValue, (o) => { if (o) { target.address = ''; target.latitude = null; target.longitude = null; skill.value = ''; durationH.value = 1; slots.value = []; searched.value = false; addrResults.value = [] } })
|
watch(() => props.modelValue, (o) => { if (o) { target.address = ''; target.latitude = null; target.longitude = null; skill.value = ''; durationH.value = 1; urgent.value = false; slots.value = []; searched.value = false; addrResults.value = [] } })
|
||||||
|
|
||||||
function pickSkill (s) { skill.value = s.skill; durationH.value = s.dur || 1 } // 1 clic → skill + durée par défaut
|
function pickSkill (s) { skill.value = s.skill; durationH.value = s.dur || 1 } // 1 clic → skill + durée par défaut
|
||||||
function onAddrInput (v) { target.latitude = null; target.longitude = null; searchAddr(v) } // retape l'adresse → invalide les coords
|
function onAddrInput (v) { target.latitude = null; target.longitude = null; searchAddr(v) } // retape l'adresse → invalide les coords
|
||||||
|
|
@ -38,7 +39,7 @@ async function search () {
|
||||||
if (target.latitude == null) return
|
if (target.latitude == null) return
|
||||||
loading.value = true; searched.value = true
|
loading.value = true; searched.value = true
|
||||||
try {
|
try {
|
||||||
slots.value = await suggestSlots({ duration_h: durationH.value, latitude: target.latitude, longitude: target.longitude, after_date: afterDate.value, limit: 8, skill: skill.value })
|
slots.value = await suggestSlots({ duration_h: durationH.value, latitude: target.latitude, longitude: target.longitude, after_date: afterDate.value, limit: 8, skill: skill.value, ignoreReserved: urgent.value })
|
||||||
} catch (e) { Notify.create({ type: 'negative', message: 'Recherche impossible : ' + (e.message || e), timeout: 4000 }); slots.value = [] }
|
} catch (e) { Notify.create({ type: 'negative', message: 'Recherche impossible : ' + (e.message || e), timeout: 4000 }); slots.value = [] }
|
||||||
finally { loading.value = false }
|
finally { loading.value = false }
|
||||||
}
|
}
|
||||||
|
|
@ -93,6 +94,7 @@ function dayLabel (iso) { const d = new Date(iso + 'T12:00:00'); return DOW[d.ge
|
||||||
</q-input>
|
</q-input>
|
||||||
<q-input dense outlined type="date" v-model="afterDate" label="Dès le" class="col" />
|
<q-input dense outlined type="date" v-model="afterDate" label="Dès le" class="col" />
|
||||||
</div>
|
</div>
|
||||||
|
<q-toggle v-model="urgent" dense size="sm" color="deep-orange-7" label="Urgence / réparation — utiliser aussi les techs réservés" class="q-mb-xs"><q-tooltip>Ignore les blocs « Réservé » (soft) : un tech réservé pour un projet redevient offert pour une réparation/urgence.</q-tooltip></q-toggle>
|
||||||
<q-btn unelevated color="primary" icon="search" label="Chercher des créneaux" class="full-width" :loading="loading" @click="search" no-caps />
|
<q-btn unelevated color="primary" icon="search" label="Chercher des créneaux" class="full-width" :loading="loading" @click="search" no-caps />
|
||||||
</template>
|
</template>
|
||||||
<div v-else class="text-caption text-grey-6 q-pt-xs">Choisis d'abord une adresse ci-dessus.</div>
|
<div v-else class="text-caption text-grey-6 q-pt-xs">Choisis d'abord une adresse ci-dessus.</div>
|
||||||
|
|
|
||||||
|
|
@ -301,12 +301,8 @@ const jobTypes = [
|
||||||
{ label: 'Livraison', value: 'Delivery' },
|
{ label: 'Livraison', value: 'Delivery' },
|
||||||
{ label: 'Autre', value: 'Other' },
|
{ label: 'Autre', value: 'Other' },
|
||||||
]
|
]
|
||||||
const priorities = [
|
// Priorité Dispatch Job — SOURCE UNIQUE (config/dispatch-priority).
|
||||||
{ label: 'Basse', value: 'low' },
|
import { DISPATCH_PRIORITIES as priorities } from 'src/config/dispatch-priority'
|
||||||
{ label: 'Moyenne', value: 'medium' },
|
|
||||||
{ label: 'Haute', value: 'high' },
|
|
||||||
{ label: 'Urgente', value: 'urgent' },
|
|
||||||
]
|
|
||||||
|
|
||||||
// ── Status helpers ─────────────────────────────────────────────────────────
|
// ── Status helpers ─────────────────────────────────────────────────────────
|
||||||
const isScheduled = computed(() => job.value && ['Scheduled', 'assigned', 'open', 'Open', 'Assigned'].includes(job.value.status))
|
const isScheduled = computed(() => job.value && ['Scheduled', 'assigned', 'open', 'Open', 'Assigned'].includes(job.value.status))
|
||||||
|
|
|
||||||
|
|
@ -102,10 +102,9 @@
|
||||||
<div class="col" style="min-width:0">
|
<div class="col" style="min-width:0">
|
||||||
<div class="t-caption t-ink-600">{{ job.assigned_group || 'Non assigné' }}
|
<div class="t-caption t-ink-600">{{ job.assigned_group || 'Non assigné' }}
|
||||||
<span v-if="job.priority === 'urgent' || job.priority === 'high'"
|
<span v-if="job.priority === 'urgent' || job.priority === 'high'"
|
||||||
class="tg-chip is-compact"
|
class="tg-chip is-compact is-stop"
|
||||||
:class="job.priority === 'urgent' ? 'is-stop' : 'is-work'"
|
|
||||||
style="margin-left:6px">
|
style="margin-left:6px">
|
||||||
{{ job.priority === 'urgent' ? 'Urgent' : 'Haute' }}
|
Urgent
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="t-body t-ink-900" style="font-weight:600;margin-top:2px">{{ job.subject }}</div>
|
<div class="t-body t-ink-900" style="font-weight:600;margin-top:2px">{{ job.subject }}</div>
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,14 @@
|
||||||
<q-input dense outlined type="date" v-model="boardDate" style="width:150px"><q-tooltip>{{ boardView === 'kanban' ? 'Jour affiché (mode Jour)' : 'Début de la fenêtre (mode Semaine)' }}</q-tooltip></q-input>
|
<q-input dense outlined type="date" v-model="boardDate" style="width:150px"><q-tooltip>{{ boardView === 'kanban' ? 'Jour affiché (mode Jour)' : 'Début de la fenêtre (mode Semaine)' }}</q-tooltip></q-input>
|
||||||
<q-btn dense flat round icon="undo" :disable="!history.length" @click="undo"><q-tooltip>Annuler (Ctrl+Z)</q-tooltip></q-btn>
|
<q-btn dense flat round icon="undo" :disable="!history.length" @click="undo"><q-tooltip>Annuler (Ctrl+Z)</q-tooltip></q-btn>
|
||||||
<q-btn dense flat round icon="redo" :disable="!future.length" @click="redo"><q-tooltip>Rétablir (Ctrl+Shift+Z)</q-tooltip></q-btn>
|
<q-btn dense flat round icon="redo" :disable="!future.length" @click="redo"><q-tooltip>Rétablir (Ctrl+Shift+Z)</q-tooltip></q-btn>
|
||||||
|
<!-- #2 — Journal des changements + état auto-save (plus d'alerte « non publié ») -->
|
||||||
|
<q-btn dense flat round :icon="autosaving ? 'cloud_sync' : 'history'" :color="autosaving ? 'primary' : ''"><q-tooltip>Journal des changements · {{ autosaving ? 'sauvegarde en cours…' : 'auto-sauvegardé' }}</q-tooltip>
|
||||||
|
<q-menu anchor="bottom middle" self="top middle"><q-list dense style="min-width:250px;max-width:340px">
|
||||||
|
<q-item-label header class="q-py-xs">Journal — <span :class="autosaving ? 'text-primary' : 'text-positive'">{{ autosaving ? 'sauvegarde…' : '✓ auto-sauvegardé' }}</span></q-item-label>
|
||||||
|
<q-item v-if="!changeLog.length"><q-item-section class="text-grey-6 text-caption">Aucun changement récent.</q-item-section></q-item>
|
||||||
|
<q-item v-for="(c, i) in changeLog.slice(0, 30)" :key="i" dense><q-item-section><q-item-label class="text-caption">{{ c.text }}</q-item-label></q-item-section><q-item-section side><span class="text-grey-5" style="font-size:10px">{{ new Date(c.at).toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit' }) }}</span></q-item-section></q-item>
|
||||||
|
</q-list></q-menu>
|
||||||
|
</q-btn>
|
||||||
<q-separator vertical class="q-mx-xs" />
|
<q-separator vertical class="q-mx-xs" />
|
||||||
<!-- Accès RAPIDE au panneau « Jobs à assigner » (liste · carte · lasso · suggestion auto) — badge = nombre à répartir -->
|
<!-- Accès RAPIDE au panneau « Jobs à assigner » (liste · carte · lasso · suggestion auto) — badge = nombre à répartir -->
|
||||||
<q-btn dense :outline="!assignPanel.open" :unelevated="assignPanel.open" no-caps color="deep-purple" icon="assignment_ind" label="À assigner" @click="assignPanel.open ? (assignPanel.open = false) : openAssignPanel()">
|
<q-btn dense :outline="!assignPanel.open" :unelevated="assignPanel.open" no-caps color="deep-purple" icon="assignment_ind" label="À assigner" @click="assignPanel.open ? (assignPanel.open = false) : openAssignPanel()">
|
||||||
|
|
@ -123,6 +131,10 @@
|
||||||
<q-item-section>Notifier les techs par SMS<q-item-label caption>à la publication</q-item-label></q-item-section>
|
<q-item-section>Notifier les techs par SMS<q-item-label caption>à la publication</q-item-label></q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
<q-separator />
|
<q-separator />
|
||||||
|
<!-- #3 — étape d'approbation FACULTATIVE (maker-checker) : Soumettre → Approuver, sans SMS. « Publier » reste l'étape finale (SMS). -->
|
||||||
|
<q-item clickable v-close-popup @click="doWeekStatus('submit')" :disable="!dirty"><q-item-section avatar><q-icon name="outbox" color="indigo" /></q-item-section><q-item-section>Soumettre pour approbation<q-item-label caption>marque « Soumis » (sans SMS)</q-item-label></q-item-section></q-item>
|
||||||
|
<q-item clickable v-close-popup @click="doWeekStatus('approve')" :disable="!dirty"><q-item-section avatar><q-icon name="verified" color="teal" /></q-item-section><q-item-section>Approuver<q-item-label caption>marque « Approuvé » (sans SMS)</q-item-label></q-item-section></q-item>
|
||||||
|
<q-separator />
|
||||||
<q-item clickable v-close-popup @click="openLegacyPush">
|
<q-item clickable v-close-popup @click="openLegacyPush">
|
||||||
<q-item-section avatar><q-icon name="sync_alt" color="deep-orange" /></q-item-section>
|
<q-item-section avatar><q-icon name="sync_alt" color="deep-orange" /></q-item-section>
|
||||||
<q-item-section>Publier au legacy<q-item-label caption>réassigne les tickets dans osTicket (aperçu avant)</q-item-label></q-item-section>
|
<q-item-section>Publier au legacy<q-item-label caption>réassigne les tickets dans osTicket (aperçu avant)</q-item-label></q-item-section>
|
||||||
|
|
@ -285,7 +297,7 @@
|
||||||
<span v-if="j.notes" class="pm-job-note ellipsis"><q-icon name="sticky_note_2" size="12px" color="warning" /> {{ j.notes }}</span>
|
<span v-if="j.notes" class="pm-job-note ellipsis"><q-icon name="sticky_note_2" size="12px" color="warning" /> {{ j.notes }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="pm-job-icons">
|
<span class="pm-job-icons">
|
||||||
<q-icon :name="j.priority === 'high' ? 'star' : 'star_border'" :color="j.priority === 'high' ? 'amber-7' : 'grey-5'" size="21px" @click.stop="toggleUrgent(j)"><q-tooltip>{{ j.priority === 'high' ? 'Prioritaire — toucher pour retirer' : 'Marquer prioritaire' }}</q-tooltip></q-icon>
|
<q-icon :name="j.priority === 'high' ? 'star' : 'star_border'" :color="j.priority === 'high' ? 'amber-7' : 'grey-5'" size="21px" @click.stop="toggleUrgent(j)"><q-tooltip>{{ j.priority === 'high' ? 'Urgent — toucher pour retirer' : 'Marquer urgent (priorité, heure fixée / SLA)' }}</q-tooltip></q-icon>
|
||||||
<q-icon :name="j.notes ? 'sticky_note_2' : 'note_add'" :color="j.notes ? 'deep-orange-6' : 'grey-4'" size="19px" @click.stop="openJobSheet(j, { note: true })"><q-tooltip>Note importante / détails</q-tooltip></q-icon>
|
<q-icon :name="j.notes ? 'sticky_note_2' : 'note_add'" :color="j.notes ? 'deep-orange-6' : 'grey-4'" size="19px" @click.stop="openJobSheet(j, { note: true })"><q-tooltip>Note importante / détails</q-tooltip></q-icon>
|
||||||
<q-icon name="more_vert" color="grey-6" size="19px" @click.stop="openJobSheet(j)"><q-tooltip>Options (priorité, statut, compétence, report)</q-tooltip></q-icon>
|
<q-icon name="more_vert" color="grey-6" size="19px" @click.stop="openJobSheet(j)"><q-tooltip>Options (priorité, statut, compétence, report)</q-tooltip></q-icon>
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -375,14 +387,14 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="pm-opt-label">Priorité</div>
|
<div class="pm-opt-label">Priorité</div>
|
||||||
<div class="row q-gutter-xs">
|
<div class="row q-gutter-xs">
|
||||||
<q-chip v-for="p in POOL_PRIOS" :key="p.value" clickable dense :selected="jobSheet.job.priority === p.value" :style="{ background: jobSheet.job.priority === p.value ? p.color : '#eef2f7', color: jobSheet.job.priority === p.value ? '#fff' : '#475569' }" @click="setJobPriority(jobSheet.job, p.value)">{{ p.label }}</q-chip>
|
<q-chip v-for="p in POOL_PRIOS" :key="p.value" clickable dense :selected="jobSheet.job.priority === p.value" :style="{ background: jobSheet.job.priority === p.value ? p.color : '#eef2f7', color: jobSheet.job.priority === p.value ? '#fff' : '#475569' }" @click="setJobPriority(jobSheet.job, p.value)">{{ p.label }}<q-tooltip class="bg-grey-9" style="font-size:11px;max-width:230px">{{ p.desc }}</q-tooltip></q-chip>
|
||||||
</div>
|
</div>
|
||||||
<div class="pm-opt-label">Statut</div>
|
<div class="pm-opt-label">Statut</div>
|
||||||
<div class="row q-gutter-xs">
|
<div class="row q-gutter-xs">
|
||||||
<q-chip v-for="s in POOL_STATUSES" :key="s.value" clickable dense :icon="s.icon" :color="jobSheet.job.status === s.value ? s.color : 'grey-3'" :text-color="jobSheet.job.status === s.value ? 'white' : 'grey-8'" @click="setJobStatus(jobSheet.job, s.value)">{{ s.label }}</q-chip>
|
<q-chip v-for="s in POOL_STATUSES" :key="s.value" clickable dense :icon="s.icon" :color="jobSheet.job.status === s.value ? s.color : 'grey-3'" :text-color="jobSheet.job.status === s.value ? 'white' : 'grey-8'" @click="setJobStatus(jobSheet.job, s.value)">{{ s.label }}</q-chip>
|
||||||
</div>
|
</div>
|
||||||
<div class="pm-opt-label">Compétence requise</div>
|
<div class="pm-opt-label">Compétences requises</div>
|
||||||
<q-select dense outlined options-dense clearable v-model="jobSheet.job.required_skill" :options="allSkills" @update:model-value="v => setJobSkill(jobSheet.job, v || '')" />
|
<SkillSelect :model-value="(jobSheet.job.required_skills && jobSheet.job.required_skills.length) ? jobSheet.job.required_skills : (jobSheet.job.required_skill ? [jobSheet.job.required_skill] : [])" @update:model-value="list => setPoolSkills(jobSheet.job, list)" label="" style="width:100%" />
|
||||||
<div class="pm-opt-label">Reporter (date prévue)</div>
|
<div class="pm-opt-label">Reporter (date prévue)</div>
|
||||||
<div class="row q-gutter-sm items-center">
|
<div class="row q-gutter-sm items-center">
|
||||||
<q-btn dense unelevated no-caps color="indigo" icon="snooze" label="+1 jour" @click="snoozeJob(jobSheet.job, 1)" />
|
<q-btn dense unelevated no-caps color="indigo" icon="snooze" label="+1 jour" @click="snoozeJob(jobSheet.job, 1)" />
|
||||||
|
|
@ -839,6 +851,7 @@
|
||||||
<div class="row items-center q-gutter-xs">
|
<div class="row items-center q-gutter-xs">
|
||||||
<q-btn dense outline size="sm" no-caps color="indigo-7" icon="event_note" label="Générer l'horaire…" @click="openSchedGen(skillDialog)"><q-tooltip>Ouvre l'assistant de quarts (modèles 5×8h · 4×10h · 3×12h + heures/jour + N semaines) → publie les quarts. Ce n'est PAS un bouton « enregistrer ».</q-tooltip></q-btn>
|
<q-btn dense outline size="sm" no-caps color="indigo-7" icon="event_note" label="Générer l'horaire…" @click="openSchedGen(skillDialog)"><q-tooltip>Ouvre l'assistant de quarts (modèles 5×8h · 4×10h · 3×12h + heures/jour + N semaines) → publie les quarts. Ce n'est PAS un bouton « enregistrer ».</q-tooltip></q-btn>
|
||||||
<q-btn dense outline size="sm" no-caps color="primary" label="5×8h rapide" @click="applyWeekPreset(skillDialog, [1,2,3,4,5], 8, 16)"><q-tooltip>Applique 5×8h à la semaine affichée (à publier)</q-tooltip></q-btn>
|
<q-btn dense outline size="sm" no-caps color="primary" label="5×8h rapide" @click="applyWeekPreset(skillDialog, [1,2,3,4,5], 8, 16)"><q-tooltip>Applique 5×8h à la semaine affichée (à publier)</q-tooltip></q-btn>
|
||||||
|
<q-btn dense outline size="sm" no-caps color="deep-orange-7" icon="event_busy" label="Réserver…" @click="openReserve(skillDialog)"><q-tooltip>Réserver du temps pour une tâche/projet (bloc priorité moyenne) → dé-priorise ce tech au dispatch auto. Réparations/urgences passent quand même (créneau en mode urgence).</q-tooltip></q-btn>
|
||||||
</div>
|
</div>
|
||||||
<q-separator class="q-my-sm" />
|
<q-separator class="q-my-sm" />
|
||||||
<div class="text-caption text-weight-medium text-grey-7 q-mb-xs">🏠 Domicile <span class="text-grey-5 text-weight-regular">(départ de tournée si plus proche que le dépôt)</span></div>
|
<div class="text-caption text-weight-medium text-grey-7 q-mb-xs">🏠 Domicile <span class="text-grey-5 text-weight-regular">(départ de tournée si plus proche que le dépôt)</span></div>
|
||||||
|
|
@ -1237,8 +1250,8 @@
|
||||||
<q-menu auto-close anchor="bottom right" self="top right"><q-list dense style="min-width:180px">
|
<q-menu auto-close anchor="bottom right" self="top right"><q-list dense style="min-width:180px">
|
||||||
<q-item-label header class="q-py-xs">Priorité</q-item-label>
|
<q-item-label header class="q-py-xs">Priorité</q-item-label>
|
||||||
<q-item v-for="lvl in PRIORITY_LEVELS" :key="lvl" clickable @click="setEntryPriority(e.jobName,lvl)" :active="jobPriorityOf(e.jobName) === lvl" active-class="bg-green-1">
|
<q-item v-for="lvl in PRIORITY_LEVELS" :key="lvl" clickable @click="setEntryPriority(e.jobName,lvl)" :active="jobPriorityOf(e.jobName) === lvl" active-class="bg-green-1">
|
||||||
<q-item-section avatar><q-icon name="flag" :color="priorityMeta(lvl).color" size="18px" /></q-item-section>
|
<q-item-section avatar><q-icon :name="priorityMeta(lvl).icon" :color="priorityMeta(lvl).color" size="18px" /></q-item-section>
|
||||||
<q-item-section>{{ priorityMeta(lvl).label }}</q-item-section>
|
<q-item-section><q-item-label>{{ priorityMeta(lvl).label }}</q-item-label><q-item-label caption>{{ priorityMeta(lvl).desc }}</q-item-label></q-item-section>
|
||||||
<q-item-section side v-if="jobPriorityOf(e.jobName) === lvl"><q-icon name="check" color="primary" size="16px" /></q-item-section>
|
<q-item-section side v-if="jobPriorityOf(e.jobName) === lvl"><q-icon name="check" color="primary" size="16px" /></q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
</q-list></q-menu>
|
</q-list></q-menu>
|
||||||
|
|
@ -1462,7 +1475,6 @@
|
||||||
<!-- Détails SIMPLES d'abord (carte/tracé GPS repliés : non requis pour lire le ticket) -->
|
<!-- Détails SIMPLES d'abord (carte/tracé GPS repliés : non requis pour lire le ticket) -->
|
||||||
<div class="jd-meta">
|
<div class="jd-meta">
|
||||||
<div v-if="jobDetail.time"><q-icon name="schedule" size="16px" /> {{ jobDetail.time }}</div>
|
<div v-if="jobDetail.time"><q-icon name="schedule" size="16px" /> {{ jobDetail.time }}</div>
|
||||||
<div v-if="jobDetail.skill"><q-icon name="construction" size="16px" /> {{ jobDetail.skill }}</div>
|
|
||||||
<div v-if="jobDetail.address"><q-icon name="place" size="16px" /> {{ jobDetail.address }}</div>
|
<div v-if="jobDetail.address"><q-icon name="place" size="16px" /> {{ jobDetail.address }}</div>
|
||||||
<div v-if="jdHasCoords" class="jd-geolinks">
|
<div v-if="jdHasCoords" class="jd-geolinks">
|
||||||
<a :href="jdMapsUrl" target="_blank" rel="noopener" class="jd-geolink"><q-icon name="satellite_alt" size="14px" /> Carte / satellite</a>
|
<a :href="jdMapsUrl" target="_blank" rel="noopener" class="jd-geolink"><q-icon name="satellite_alt" size="14px" /> Carte / satellite</a>
|
||||||
|
|
@ -1475,6 +1487,17 @@
|
||||||
<q-input dense outlined type="number" step="0.25" min="0" v-model.number="jobDetail.durH" style="width:96px" suffix="h" @change="jdSetDuration(jobDetail.durH)" @keyup.enter="jdSetDuration(jobDetail.durH)" />
|
<q-input dense outlined type="number" step="0.25" min="0" v-model.number="jobDetail.durH" style="width:96px" suffix="h" @change="jdSetDuration(jobDetail.durH)" @keyup.enter="jdSetDuration(jobDetail.durH)" />
|
||||||
<span class="text-caption text-grey-5">≈ {{ fmtMin(Math.round((jobDetail.durH || 0) * 60)) }}</span>
|
<span class="text-caption text-grey-5">≈ {{ fmtMin(Math.round((jobDetail.durH || 0) * 60)) }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Compétences REQUISES (dispatch auto) — LISTE : sélecteur/créateur en chips (autosuggest). Le tech doit les avoir TOUTES. 1re = principale. -->
|
||||||
|
<div class="jd-skill row items-center q-gutter-sm q-mb-xs">
|
||||||
|
<q-icon name="construction" size="16px" color="grey-6" /><span class="text-caption text-grey-7">Compétences requises</span>
|
||||||
|
<SkillSelect :model-value="jobDetail.skills" @update:model-value="jdSetJobSkills" style="min-width:220px;flex:1" />
|
||||||
|
</div>
|
||||||
|
<!-- Attribuer cette compétence à un tech précis → il sera matché au dispatch automatique. -->
|
||||||
|
<div v-if="jobDetail.skill" class="row items-center q-gutter-xs q-mb-sm no-wrap">
|
||||||
|
<span class="text-caption text-grey-6 no-wrap">Donner à :</span>
|
||||||
|
<TechSelect v-model="jdSkillTech" :options="jdAllTechOpts" label="un technicien…" style="min-width:180px;flex:1" />
|
||||||
|
<q-btn dense unelevated color="teal-7" icon="add" no-caps :disable="!jdSkillTech" :loading="jdSkillBusy" @click="jdGiveSkillToTech" label="Attribuer" />
|
||||||
|
</div>
|
||||||
<!-- Carte / tracé GPS : repliés par défaut -->
|
<!-- Carte / tracé GPS : repliés par défaut -->
|
||||||
<div class="q-mb-sm">
|
<div class="q-mb-sm">
|
||||||
<q-btn flat dense no-caps size="sm" color="grey-7" :icon="jdShowMap ? 'expand_less' : 'map'" :label="jdShowMap ? 'Masquer la carte' : 'Voir la carte / tracé GPS'" @click="jdShowMap = !jdShowMap" />
|
<q-btn flat dense no-caps size="sm" color="grey-7" :icon="jdShowMap ? 'expand_less' : 'map'" :label="jdShowMap ? 'Masquer la carte' : 'Voir la carte / tracé GPS'" @click="jdShowMap = !jdShowMap" />
|
||||||
|
|
@ -1497,7 +1520,7 @@
|
||||||
<div v-if="jobDetail.canTeam" class="jd-team">
|
<div v-if="jobDetail.canTeam" class="jd-team">
|
||||||
<div class="text-subtitle2 row items-center q-mb-xs"><q-icon name="group" size="18px" class="q-mr-xs" /> Équipe / renfort<q-space /><q-spinner v-if="jobDetail.teamLoading" size="16px" color="deep-purple" /></div>
|
<div class="text-subtitle2 row items-center q-mb-xs"><q-icon name="group" size="18px" class="q-mr-xs" /> Équipe / renfort<q-space /><q-spinner v-if="jobDetail.teamLoading" size="16px" color="deep-purple" /></div>
|
||||||
<div v-if="jobDetail.team && jobDetail.team.length" class="row items-center q-gutter-xs q-mb-xs">
|
<div v-if="jobDetail.team && jobDetail.team.length" class="row items-center q-gutter-xs q-mb-xs">
|
||||||
<q-chip v-for="a in jobDetail.team" :key="a.tech_id" dense removable @remove="jdRemoveAssistant(a.tech_id)" color="deep-purple-1" text-color="deep-purple-9" icon="group">{{ a.tech_name || techNameById(a.tech_id) || a.tech_id || '—' }}</q-chip>
|
<q-chip v-for="a in jobDetail.team" :key="a.tech_id" dense removable @remove="jdRemoveAssistant(a.tech_id)" color="deep-purple-1" text-color="deep-purple-9" icon="group">{{ jdAssistantName(a) }}</q-chip>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="text-caption text-grey-6 q-mb-xs">Aucun assistant. Ajoutez un renfort → un bloc <b>hachuré</b> sera réservé dans son horaire (anti double‑booking).</div>
|
<div v-else class="text-caption text-grey-6 q-mb-xs">Aucun assistant. Ajoutez un renfort → un bloc <b>hachuré</b> sera réservé dans son horaire (anti double‑booking).</div>
|
||||||
<div class="row items-center no-wrap q-gutter-xs">
|
<div class="row items-center no-wrap q-gutter-xs">
|
||||||
|
|
@ -1517,10 +1540,69 @@
|
||||||
<!-- Repli : corps brut du billet UNIQUEMENT s'il n'y a aucun message dans le fil (sinon = doublon du 1er commentaire) -->
|
<!-- Repli : corps brut du billet UNIQUEMENT s'il n'y a aucun message dans le fil (sinon = doublon du 1er commentaire) -->
|
||||||
<div v-else-if="jobDetail.detail" class="jd-detail">{{ jobDetail.detail }}</div>
|
<div v-else-if="jobDetail.detail" class="jd-detail">{{ jobDetail.detail }}</div>
|
||||||
<div v-else class="text-grey-6 q-pa-md text-center">{{ jobDetail.lid ? ((jobDetail.thread && jobDetail.thread.error) ? 'Détail indisponible' : 'Aucun commentaire.') : 'Pas de billet Legacy lié à ce job.' }}</div>
|
<div v-else class="text-grey-6 q-pa-md text-center">{{ jobDetail.lid ? ((jobDetail.thread && jobDetail.thread.error) ? 'Détail indisponible' : 'Aucun commentaire.') : 'Pas de billet Legacy lié à ce job.' }}</div>
|
||||||
|
<!-- Répondre au fil depuis la job — même geste que le panneau ticket + l'app terrain. Note INTERNE par défaut ; au client si coché. -->
|
||||||
|
<div v-if="jobDetail.lid || jobDetail.name" class="q-mt-sm">
|
||||||
|
<q-input v-model="jdReply" dense outlined type="textarea" autogrow placeholder="Écrire une note / réponse…" :input-style="{ minHeight: '48px' }" @keydown.ctrl.enter="sendJdReply" @keydown.meta.enter="sendJdReply" />
|
||||||
|
<div class="row items-center q-mt-xs">
|
||||||
|
<q-checkbox v-if="jobDetail.lid" v-model="jdReplyPublic" dense size="sm" label="Envoyer aussi au client" />
|
||||||
|
<q-space />
|
||||||
|
<q-btn dense unelevated color="primary" no-caps icon="send" :label="jdReplyPublic ? 'Répondre au client' : 'Note interne'" :disable="!jdReply.trim()" :loading="jdReplySending" @click="sendJdReply" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
|
|
||||||
|
<!-- #1 — Sommaire des changements AVANT de publier (ajouts/retraits par jour + nb SMS) -->
|
||||||
|
<q-dialog v-model="pubConfirm">
|
||||||
|
<q-card style="min-width:380px;max-width:96vw">
|
||||||
|
<q-card-section class="row items-center q-pb-none">
|
||||||
|
<q-icon name="cloud_upload" color="positive" size="22px" class="q-mr-sm" />
|
||||||
|
<div class="text-subtitle1 text-weight-bold">Publier l'horaire</div>
|
||||||
|
<q-space /><q-btn flat round dense icon="close" v-close-popup />
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section class="q-pt-sm">
|
||||||
|
<div class="text-body2 q-mb-sm"><b class="text-positive">{{ pubSummary.added }}</b> ajout(s) · <b class="text-negative">{{ pubSummary.removed }}</b> retrait(s) · <b>{{ notifySms ? pubSummary.added + ' SMS aux techs' : 'sans SMS' }}</b></div>
|
||||||
|
<q-list dense bordered style="border-radius:8px;max-height:44vh;overflow:auto">
|
||||||
|
<template v-for="d in pubSummary.days" :key="d.date">
|
||||||
|
<q-item-label header class="q-py-xs text-weight-medium">{{ fmtDueLabel(d.date) }}</q-item-label>
|
||||||
|
<q-item v-for="(a, i) in d.add" :key="'a' + i" dense><q-item-section avatar style="min-width:24px"><q-icon name="add" color="positive" size="16px" /></q-item-section><q-item-section>{{ a.name }}<span class="text-grey-6"> · {{ a.shift }}</span></q-item-section></q-item>
|
||||||
|
<q-item v-for="(r, i) in d.rem" :key="'r' + i" dense><q-item-section avatar style="min-width:24px"><q-icon name="remove" color="negative" size="16px" /></q-item-section><q-item-section>{{ r.name }}<span class="text-grey-6"> · {{ r.shift }}</span></q-item-section></q-item>
|
||||||
|
</template>
|
||||||
|
</q-list>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-actions align="right" class="q-px-md q-pb-md">
|
||||||
|
<q-btn flat no-caps label="Annuler" color="grey-7" v-close-popup />
|
||||||
|
<q-btn unelevated no-caps color="positive" icon="cloud_upload" :label="notifySms ? 'Publier + SMS' : 'Publier'" :loading="publishing" @click="doPublishConfirmed" />
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
|
||||||
|
<!-- #5 — Réserver du temps d'un tech (bloc « Réservation » priorité moyenne → dé-priorise au dispatch auto ; urgences passent) -->
|
||||||
|
<q-dialog v-model="resvDlg.open">
|
||||||
|
<q-card style="min-width:320px;max-width:96vw">
|
||||||
|
<q-card-section class="row items-center q-pb-none">
|
||||||
|
<q-icon name="event_busy" color="deep-orange-7" size="22px" class="q-mr-sm" />
|
||||||
|
<div class="text-subtitle1 text-weight-bold">Réserver du temps</div>
|
||||||
|
<q-space /><q-btn flat round dense icon="close" v-close-popup />
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section class="q-gutter-sm">
|
||||||
|
<div class="text-body2"><b>{{ resvDlg.tech && resvDlg.tech.name }}</b> <span class="text-grey-6">— indisponible au dispatch auto pendant ce bloc</span></div>
|
||||||
|
<div class="row q-col-gutter-sm">
|
||||||
|
<q-input class="col-6" dense outlined type="date" v-model="resvDlg.date" label="Date" />
|
||||||
|
<q-input class="col-3" dense outlined type="time" v-model="resvDlg.start" label="Début" />
|
||||||
|
<q-input class="col-3" dense outlined type="number" v-model.number="resvDlg.dur" label="Durée (h)" min="0.5" step="0.5" />
|
||||||
|
</div>
|
||||||
|
<q-input dense outlined v-model="resvDlg.reason" label="Motif (tâche / projet)" placeholder="Ex. Projet déploiement fibre secteur X" />
|
||||||
|
<div class="text-caption text-grey-6"><q-icon name="info" size="14px" /> Les <b>réparations & urgences</b> peuvent quand même utiliser ce tech (recherche de créneau en mode urgence).</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-actions align="right" class="q-px-md q-pb-md">
|
||||||
|
<q-btn flat no-caps label="Annuler" color="grey-7" v-close-popup />
|
||||||
|
<q-btn unelevated no-caps color="deep-orange-7" icon="event_busy" label="Réserver" :loading="resvDlg.busy" @click="doReserve" />
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
|
||||||
<!-- Drop d'une job sur un tech SANS quart : créer un quart (durée) ou marquer absent (durée + retour) -->
|
<!-- Drop d'une job sur un tech SANS quart : créer un quart (durée) ou marquer absent (durée + retour) -->
|
||||||
<q-dialog v-model="dropAsk.open">
|
<q-dialog v-model="dropAsk.open">
|
||||||
<q-card style="min-width:380px;max-width:440px">
|
<q-card style="min-width:380px;max-width:440px">
|
||||||
|
|
@ -1700,7 +1782,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-card-section v-if="timelineDays.length" class="q-pt-none text-caption text-grey-6">Trié par priorité puis heure · 🔴 urgent 🟠 élevée 🔵 moyenne ⚪ basse. Heures posées en premier-trou-libre.</q-card-section>
|
<q-card-section v-if="timelineDays.length" class="q-pt-none text-caption text-grey-6">Trié par priorité puis heure · 🔴 Urgent 🟠 Moyenne ⚪ Basse. Heures posées en premier-trou-libre.</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
|
|
||||||
|
|
@ -1930,7 +2012,7 @@
|
||||||
<SuggestSlotsDialog v-model="showSuggestSlots" :skills="slotSkills" @select="onSlotSelected" @plan-shifts="onPlanShifts" />
|
<SuggestSlotsDialog v-model="showSuggestSlots" :skills="slotSkills" @select="onSlotSelected" @plan-shifts="onPlanShifts" />
|
||||||
<!-- Soumission : wizard adresse+fibre+prospect → passe le relais à ProjectWizard (panier + rabais + devis + acceptation) -->
|
<!-- Soumission : wizard adresse+fibre+prospect → passe le relais à ProjectWizard (panier + rabais + devis + acceptation) -->
|
||||||
<QuoteWizard v-model="quoteWizardOpen" @qualified="onQuoteQualified" />
|
<QuoteWizard v-model="quoteWizardOpen" @qualified="onQuoteQualified" />
|
||||||
<ProjectWizard v-model="projectWizardOpen" :customer="quoteCustomer" @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" />
|
||||||
</q-page>
|
</q-page>
|
||||||
|
|
@ -1962,6 +2044,7 @@ import { symOutlinedToolsLadder, symOutlinedHeadsetMic, symOutlinedHandyman } fr
|
||||||
import { onBeforeRouteLeave, useRouter } from 'vue-router'
|
import { onBeforeRouteLeave, useRouter } from 'vue-router'
|
||||||
import { useQuasar } from 'quasar'
|
import { useQuasar } from 'quasar'
|
||||||
import * as roster from 'src/api/roster'
|
import * as roster from 'src/api/roster'
|
||||||
|
import { usePermissions } from 'src/composables/usePermissions'
|
||||||
import * as addressApi from 'src/api/address' // recherche d'adresse RQA (coords) pour le sélecteur d'emplacement
|
import * as addressApi from 'src/api/address' // recherche d'adresse RQA (coords) pour le sélecteur d'emplacement
|
||||||
import { useSSE, sendSmsViaHub } from 'src/composables/useSSE'
|
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
|
||||||
|
|
@ -2290,10 +2373,12 @@ function startCreneau () { createMode.value = 'work-order'; createChooser.value
|
||||||
const quoteWizardOpen = ref(false)
|
const quoteWizardOpen = ref(false)
|
||||||
const projectWizardOpen = ref(false)
|
const projectWizardOpen = ref(false)
|
||||||
const quoteCustomer = ref(null)
|
const quoteCustomer = ref(null)
|
||||||
|
const quoteTier = ref(null) // forfait d'intérêt choisi dans la vitrine QuoteWizard → pré-remplit le panier ProjectWizard
|
||||||
function onQuoteQualified (payload) {
|
function onQuoteQualified (payload) {
|
||||||
const cust = (payload && payload.customer) || null
|
const cust = (payload && payload.customer) || null
|
||||||
// Rattache l'adresse qualifiée (+ zone tarifaire) au client passé à ProjectWizard → portée sur le devis (le prix dépend de l'adresse).
|
// Rattache l'adresse qualifiée (+ zone tarifaire) au client passé à ProjectWizard → portée sur le devis (le prix dépend de l'adresse).
|
||||||
quoteCustomer.value = cust ? { ...cust, qualified_address: (payload && payload.address) || null } : null
|
quoteCustomer.value = cust ? { ...cust, qualified_address: (payload && payload.address) || null } : null
|
||||||
|
quoteTier.value = (payload && payload.tier) || null
|
||||||
if (quoteCustomer.value) projectWizardOpen.value = true
|
if (quoteCustomer.value) projectWizardOpen.value = true
|
||||||
}
|
}
|
||||||
function onQuoteCreated (job, meta) {
|
function onQuoteCreated (job, meta) {
|
||||||
|
|
@ -2384,7 +2469,7 @@ function exportGpx (techId) {
|
||||||
window.open(roster.gpxUrl(techId, from, to), '_blank')
|
window.open(roster.gpxUrl(techId, from, to), '_blank')
|
||||||
}
|
}
|
||||||
// ── 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: '', address: '', skill: '', 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: '', 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: '' })
|
||||||
// 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 }
|
||||||
|
|
@ -2396,7 +2481,7 @@ async function openJobDetail (b, t) {
|
||||||
// legacy_detail/legacy_ticket_id) — ouvert depuis une goutte/carrousel. On mappe les DEUX formes pour toujours montrer
|
// legacy_detail/legacy_ticket_id) — ouvert depuis une goutte/carrousel. On mappe les DEUX formes pour toujours montrer
|
||||||
// client, compétence, description et le fil du billet.
|
// client, compétence, description et le fil du billet.
|
||||||
const lid = b.legacy_id || b.legacy_ticket_id || null
|
const lid = b.legacy_id || b.legacy_ticket_id || null
|
||||||
Object.assign(jobDetail, { open: true, name: b.name || '', subject: deEnt(b.subject || b.name || 'Job'), customer: deEnt(b.customer || b.customer_name || ''), address: deEnt(b.address || b.service_location || ''), skill: b.skill || b.required_skill || '', time: (b.start ? (b.start + (b.dur ? ' · ' + Math.round(b.dur * 10) / 10 + 'h' : '')) : (b.scheduled_date ? fmtDueLabel(b.scheduled_date) : '')), durH: Math.round((durH0 || 1) * 100) / 100, detail: deEnt(b.detail || b.legacy_detail || ''), lid, iso: b.scheduled_date || b.iso || (boardView.value === 'routes' ? routesDay.value : '') || '', dept: b.dept || b.legacy_dept || '', techId: (t && t.id) || b.assigned_tech || '', techName: (t && t.name) || '', lat: b.lat != null ? +b.lat : (b.latitude != null ? +b.latitude : null), lon: b.lon != null ? +b.lon : (b.longitude != null ? +b.longitude : null), loading: !!lid, thread: null, canTeam: !!(b.name && !b.legacy), team: [], teamLoading: false, teamAdd: null, assignTech: null, status: b.status || '' })
|
Object.assign(jobDetail, { open: true, name: b.name || '', subject: deEnt(b.subject || b.name || 'Job'), customer: deEnt(b.customer || b.customer_name || ''), address: deEnt(b.address || b.service_location || ''), skill: b.skill || b.required_skill || '', skills: (Array.isArray(b.required_skills) && b.required_skills.length ? b.required_skills.filter(Boolean) : (b.skill || b.required_skill ? [b.skill || b.required_skill] : [])), time: (b.start ? (b.start + (b.dur ? ' · ' + Math.round(b.dur * 10) / 10 + 'h' : '')) : (b.scheduled_date ? fmtDueLabel(b.scheduled_date) : '')), durH: Math.round((durH0 || 1) * 100) / 100, detail: deEnt(b.detail || b.legacy_detail || ''), lid, iso: b.scheduled_date || b.iso || (boardView.value === 'routes' ? routesDay.value : '') || '', dept: b.dept || b.legacy_dept || '', techId: (t && t.id) || b.assigned_tech || '', techName: (t && t.name) || '', lat: b.lat != null ? +b.lat : (b.latitude != null ? +b.latitude : null), lon: b.lon != null ? +b.lon : (b.longitude != null ? +b.longitude : null), loading: !!lid, thread: null, canTeam: !!(b.name && !b.legacy), team: [], teamLoading: false, teamAdd: null, assignTech: null, status: b.status || '' })
|
||||||
// Géofencing (suivi façon colis) : timeline En route → Arrivé → Reparti, non bloquant.
|
// Géofencing (suivi façon colis) : timeline En route → Arrivé → Reparti, non bloquant.
|
||||||
jobDetail.geofence = null
|
jobDetail.geofence = null
|
||||||
if (b.name) roster.jobGeofence(b.name).then(g => { if (jobDetail.name === b.name) jobDetail.geofence = g }).catch(() => {})
|
if (b.name) roster.jobGeofence(b.name).then(g => { if (jobDetail.name === b.name) jobDetail.geofence = g }).catch(() => {})
|
||||||
|
|
@ -2415,6 +2500,27 @@ const geoTimeline = computed(() => {
|
||||||
// Options du sélecteur d'assistant (TechSelect, recherche par nom OU compétence) : tous les techs sauf le lead + l'équipe déjà là.
|
// Options du sélecteur d'assistant (TechSelect, recherche par nom OU compétence) : tous les techs sauf le lead + l'équipe déjà là.
|
||||||
// Label = nom · compétences (searchable) ; CAPABLES d'abord (possèdent la compétence du job) ; value = id (scalaire).
|
// Label = nom · compétences (searchable) ; CAPABLES d'abord (possèdent la compétence du job) ; value = id (scalaire).
|
||||||
function techNameById (id) { const t = (techs.value || []).find(x => x.id === id); return t ? t.name : '' } // repli nom d'un assistant depuis l'id
|
function techNameById (id) { const t = (techs.value || []).find(x => x.id === id); return t ? t.name : '' } // repli nom d'un assistant depuis l'id
|
||||||
|
// Nom d'assistant ROBUSTE : une vieille ligne a pu être persistée avec tech_name = la CHAÎNE « undefined »/« null » → on la traite comme vide et on résout par l'id.
|
||||||
|
function jdAssistantName (a) { const bad = v => !v || v === 'undefined' || v === 'null'; if (a && !bad(a.tech_name)) return a.tech_name; const n = techNameById(a && a.tech_id); return n || (a && !bad(a.tech_id) ? a.tech_id : '—') }
|
||||||
|
// Éditeur de COMPÉTENCES REQUISES du job (SkillSelect multi + création) + attribution à un tech (dispatch auto).
|
||||||
|
const jdSkillTech = ref(null)
|
||||||
|
const jdSkillBusy = ref(false)
|
||||||
|
// LISTE de compétences requises du job → store hub /roster/job-skills (PAS un champ ERPNext). required_skill(principale)=1re. Le tech doit les avoir TOUTES.
|
||||||
|
async function jdSetJobSkills (list) {
|
||||||
|
const arr = [...new Set((Array.isArray(list) ? list : String(list || '').split(',')).map(s => String(s).trim()).filter(Boolean))] // SkillSelect émet une CSV ("a,b"), pas un tableau
|
||||||
|
jobDetail.skills = arr; jobDetail.skill = arr[0] || ''
|
||||||
|
if (jobDetail.name) { try { await roster.setJobSkills(jobDetail.name, arr); if (typeof reloadPool === 'function') reloadPool() } catch (e) { err(e) } }
|
||||||
|
}
|
||||||
|
const jdAllTechOpts = computed(() => (techs.value || []).map(t => ({ label: t.name + ((t.skills || []).length ? ' · ' + t.skills.slice(0, 3).join(', ') : ''), value: t.id })))
|
||||||
|
async function jdGiveSkillToTech () { // attribue au tech TOUTES les compétences requises du job qu'il n'a pas encore
|
||||||
|
const req = (jobDetail.skills && jobDetail.skills.length) ? jobDetail.skills : (jobDetail.skill ? [jobDetail.skill] : [])
|
||||||
|
const tid = jdSkillTech.value; if (!req.length || !tid) return
|
||||||
|
const t = (techs.value || []).find(x => x.id === tid); if (!t) return
|
||||||
|
const missing = req.filter(s => !(t.skills || []).includes(s))
|
||||||
|
if (!missing.length) { $q.notify({ type: 'info', message: t.name + ' a déjà ces compétences' }); jdSkillTech.value = null; return }
|
||||||
|
jdSkillBusy.value = true
|
||||||
|
try { const skills = [...(t.skills || []), ...missing]; await roster.setTechSkills(t.id, skills.join(','), t.skill_levels || {}, t.skill_eff || {}); t.skills = skills; jdSkillTech.value = null; $q.notify({ type: 'positive', icon: 'construction', message: missing.join(', ') + ' → ' + t.name + ' (dispatch)' }) } catch (e) { err(e) } finally { jdSkillBusy.value = false }
|
||||||
|
}
|
||||||
const jdTeamOptions = computed(() => {
|
const jdTeamOptions = computed(() => {
|
||||||
const taken = new Set([jobDetail.techId, ...(jobDetail.team || []).map(a => a.tech_id)])
|
const taken = new Set([jobDetail.techId, ...(jobDetail.team || []).map(a => a.tech_id)])
|
||||||
const req = jobDetail.required_skill || jobDetail.skill || ''
|
const req = jobDetail.required_skill || jobDetail.skill || ''
|
||||||
|
|
@ -2425,6 +2531,24 @@ const jdTeamOptions = computed(() => {
|
||||||
})
|
})
|
||||||
// Fil du billet, PLUS RÉCENT EN HAUT (le dernier commentaire porte souvent l'info la plus importante).
|
// Fil du billet, PLUS RÉCENT EN HAUT (le dernier commentaire porte souvent l'info la plus importante).
|
||||||
const jdMessages = computed(() => { const m = (jobDetail.thread && jobDetail.thread.messages) || []; return m.slice().reverse() })
|
const jdMessages = computed(() => { const m = (jobDetail.thread && jobDetail.thread.messages) || []; return m.slice().reverse() })
|
||||||
|
// Répondre au fil depuis la job (note interne par défaut ; au client si coché). Attribution = agent connecté (prénom+nom).
|
||||||
|
const { userName: _opsUserName } = usePermissions()
|
||||||
|
const jdReply = ref('')
|
||||||
|
const jdReplyPublic = ref(false)
|
||||||
|
const jdReplySending = ref(false)
|
||||||
|
async function sendJdReply () {
|
||||||
|
const text = jdReply.value.trim(); if (!text || jdReplySending.value) return
|
||||||
|
jdReplySending.value = true
|
||||||
|
try {
|
||||||
|
const r = await roster.postJobComment({ job: jobDetail.name, lid: jobDetail.lid, text, isPublic: jdReplyPublic.value, agentName: _opsUserName.value || '' })
|
||||||
|
if (r && r.ok) {
|
||||||
|
const wasPublic = jdReplyPublic.value
|
||||||
|
jdReply.value = ''; jdReplyPublic.value = false
|
||||||
|
$q.notify({ type: 'positive', message: wasPublic ? 'Réponse envoyée au client' : 'Note interne ajoutée', timeout: 2200 })
|
||||||
|
if (jobDetail.lid) { try { jobDetail.thread = await roster.ticketThread(jobDetail.lid) } catch (e) {} } // recharge le fil
|
||||||
|
} else { $q.notify({ type: 'negative', message: (r && r.error) || 'Échec de l\'envoi', timeout: 3000 }) }
|
||||||
|
} catch (e) { $q.notify({ type: 'negative', message: 'Échec de l\'envoi', timeout: 3000 }) } finally { jdReplySending.value = false }
|
||||||
|
}
|
||||||
async function jdAddAssistant () {
|
async function jdAddAssistant () {
|
||||||
const id = jobDetail.teamAdd; if (!id || !jobDetail.name) return
|
const id = jobDetail.teamAdd; if (!id || !jobDetail.name) return
|
||||||
const tech = (techs.value || []).find(t => t.id === id)
|
const tech = (techs.value || []).find(t => t.id === id)
|
||||||
|
|
@ -2657,6 +2781,23 @@ const skillDialog = ref(null) // tech dont on édite les compétences
|
||||||
const skillMenuTarget = ref(null) // élément cliqué = ancre du popover (près de la souris, sur la rangée)
|
const skillMenuTarget = ref(null) // élément cliqué = ancre du popover (près de la souris, sur la rangée)
|
||||||
const skillMenuShown = ref(false)
|
const skillMenuShown = ref(false)
|
||||||
function openSkillEditor (t, ev) { skillDialog.value = t; skillMenuTarget.value = (ev && ev.currentTarget) || null; skillMenuShown.value = true; loadTraccarDevices() } // charge les appareils GPS pour la section « Appareil GPS » du volet
|
function openSkillEditor (t, ev) { skillDialog.value = t; skillMenuTarget.value = (ev && ev.currentTarget) || null; skillMenuShown.value = true; loadTraccarDevices() } // charge les appareils GPS pour la section « Appareil GPS » du volet
|
||||||
|
// #5 — Réserver du temps d'un tech (bloc « Réservation », priorité moyenne). Occupe l'occupation → dé-priorise au dispatch auto + soustrait des créneaux.
|
||||||
|
const resvDlg = reactive({ open: false, tech: null, date: '', start: '08:00', dur: 2, reason: '', busy: false })
|
||||||
|
function openReserve (t) {
|
||||||
|
if (!t) return
|
||||||
|
skillMenuShown.value = false
|
||||||
|
const iso = mobileSelIso.value || kbSelIso.value || start.value || new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' })
|
||||||
|
Object.assign(resvDlg, { open: true, tech: { id: t.id, name: t.name }, date: iso, start: '08:00', dur: 2, reason: '', busy: false })
|
||||||
|
}
|
||||||
|
async function doReserve () {
|
||||||
|
if (!resvDlg.tech || !resvDlg.date || resvDlg.busy) return
|
||||||
|
resvDlg.busy = true
|
||||||
|
try {
|
||||||
|
const r = await roster.reserveTech({ tech: resvDlg.tech.id, date: resvDlg.date, start_time: resvDlg.start, duration_h: resvDlg.dur, reason: resvDlg.reason })
|
||||||
|
if (r && r.ok) { $q.notify({ type: 'positive', icon: 'event_busy', message: 'Temps réservé — ' + resvDlg.tech.name + ' · ' + resvDlg.dur + 'h' }); resvDlg.open = false; try { await reloadOccupancy() } catch (e) {} }
|
||||||
|
else $q.notify({ type: 'negative', message: (r && r.error) || 'Réservation impossible' })
|
||||||
|
} catch (e) { $q.notify({ type: 'negative', message: 'Réservation impossible' }) } finally { resvDlg.busy = false }
|
||||||
|
}
|
||||||
// « Voir sa tournée » depuis le volet réglages du tech : bascule en vue Tournées + isole ce tech sur la carte.
|
// « Voir sa tournée » depuis le volet réglages du tech : bascule en vue Tournées + isole ce tech sur la carte.
|
||||||
function viewTechTournee (tech) {
|
function viewTechTournee (tech) {
|
||||||
if (!tech || !tech.id) return
|
if (!tech || !tech.id) return
|
||||||
|
|
@ -2687,11 +2828,15 @@ async function onScheduleApply ({ schedule, weeks, techIds, mode }) {
|
||||||
// ── RÉCURRENT : le patron devient la SOURCE (weekly_schedule) → matérialisation auto (fériés/vacances sautés, manuels préservés) ──
|
// ── RÉCURRENT : le patron devient la SOURCE (weekly_schedule) → matérialisation auto (fériés/vacances sautés, manuels préservés) ──
|
||||||
if (mode === 'recurring') {
|
if (mode === 'recurring') {
|
||||||
const patt = {}; for (const d of schedule) patt[d.dow] = d.on ? { start: d.start, end: d.end } : null
|
const patt = {}; for (const d of schedule) patt[d.dow] = d.on ? { start: d.start, end: d.end } : null
|
||||||
let okT = 0
|
let okT = 0, failT = 0
|
||||||
for (const t of targets) { try { const r = await roster.setWeeklySchedule(t.id, patt); if (r && r.ok) okT++ } catch (e) {} }
|
for (const t of targets) { try { const r = await roster.setWeeklySchedule(t.id, patt); if (r && r.ok) okT++; else { failT++; err(new Error('weekly-schedule ' + t.name + ': ' + ((r && r.error) || 'échec'))) } } catch (e) { failT++; err(e) } }
|
||||||
let mat = null; try { mat = await roster.materializeShifts({ weeks }) } catch (e) { err(e) }
|
let mat = null, matErr = ''; try { mat = await roster.materializeShifts({ weeks }) } catch (e) { matErr = (e && e.message) || 'erreur'; err(e) }
|
||||||
try { await loadWeek() } catch (e) {}
|
try { await loadWeek() } catch (e) {}
|
||||||
$q.notify({ type: okT ? 'positive' : 'warning', icon: 'event_repeat', message: 'Horaire récurrent défini · ' + okT + ' tech' + (mat ? ' → ' + mat.created + ' quart(s) sur ' + weeks + ' sem.' + (mat.skipped_holiday ? ' (' + mat.skipped_holiday + ' férié(s) sauté(s))' : '') : ''), timeout: 4800 })
|
// #4c : on NE SWALLOW PLUS — les échecs (compte tech introuvable, création de quart refusée) remontent à l'écran.
|
||||||
|
const parts = [okT + ' tech']
|
||||||
|
if (failT) parts.push('⚠ ' + failT + ' non enregistré(s)')
|
||||||
|
if (mat) { parts.push(mat.created + ' quart(s)/' + weeks + ' sem.'); if (mat.create_fail) parts.push('⚠ ' + mat.create_fail + ' échec(s) de création'); if (mat.skipped_holiday) parts.push(mat.skipped_holiday + ' férié(s) sauté(s)') } else if (matErr) parts.push('⚠ matérialisation : ' + matErr)
|
||||||
|
$q.notify({ type: (failT || (mat && mat.create_fail) || matErr) ? 'warning' : 'positive', icon: 'event_repeat', message: 'Horaire récurrent · ' + parts.join(' · '), timeout: 5500 })
|
||||||
schedGenTechs.value = []
|
schedGenTechs.value = []
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -4078,10 +4223,25 @@ function hoursOf (techId) { let h = 0; for (const a of assignments.value) { if (
|
||||||
const serverSet = ref(new Set())
|
const serverSet = ref(new Set())
|
||||||
const currentSet = computed(() => new Set(assignments.value.map(a => a.tech + '|' + a.date + '|' + a.shift)))
|
const currentSet = computed(() => new Set(assignments.value.map(a => a.tech + '|' + a.date + '|' + a.shift)))
|
||||||
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 })
|
||||||
const dirty = computed(() => diffKeys.value.length > 0)
|
// #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 dirtyCount = computed(() => diffKeys.value.length)
|
const unpublished = computed(() => assignments.value.filter(a => a.status && a.status !== 'Publié'))
|
||||||
const dirtyCells = computed(() => new Set(diffKeys.value.map(k => k.slice(0, k.lastIndexOf('|')))))
|
const dirty = computed(() => unpublished.value.length > 0)
|
||||||
|
const dirtyCount = computed(() => unpublished.value.length)
|
||||||
|
const dirtyCells = computed(() => new Set(unpublished.value.map(a => a.tech + '|' + a.date)))
|
||||||
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.
|
||||||
|
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é' })
|
||||||
|
// #1 — Sommaire AVANT publication : liste les quarts NON PUBLIÉS (à publier) par jour + nb SMS.
|
||||||
|
const pubConfirm = ref(false)
|
||||||
|
const pubSummary = computed(() => {
|
||||||
|
const nameOf = id => { const t = (techs.value || []).find(x => x.id === id); return (t && t.name) || id }
|
||||||
|
const byDate = {}
|
||||||
|
for (const a of unpublished.value) {
|
||||||
|
const d = (byDate[a.date] = byDate[a.date] || { date: a.date, add: [], rem: [] })
|
||||||
|
d.add.push({ tech: a.tech, date: a.date, shift: a.shift_name || a.shift, name: nameOf(a.tech), status: a.status })
|
||||||
|
}
|
||||||
|
return { added: unpublished.value.length, removed: 0, days: Object.values(byDate).sort((x, y) => String(x.date).localeCompare(String(y.date))) }
|
||||||
|
})
|
||||||
|
|
||||||
const holSet = computed(() => new Set(holidays.value))
|
const holSet = computed(() => new Set(holidays.value))
|
||||||
const statHolSet = computed(() => new Set(statHolidays.value.map(h => h.date)))
|
const statHolSet = computed(() => new Set(statHolidays.value.map(h => h.date)))
|
||||||
|
|
@ -4388,11 +4548,12 @@ function setJobReqLevel (job, n) { const lv = Math.max(1, Math.min(5, Number(n)
|
||||||
function techsForJob (job) {
|
function techsForJob (job) {
|
||||||
const iso = jobTargetDay(job)
|
const iso = jobTargetDay(job)
|
||||||
const reqSkill = job && job.required_skill
|
const reqSkill = job && job.required_skill
|
||||||
|
const reqSkillList = (job && Array.isArray(job.required_skills) && job.required_skills.length) ? job.required_skills : (reqSkill ? [reqSkill] : [])
|
||||||
const jlat = +(job && job.lat); const jlon = +(job && job.lon)
|
const jlat = +(job && job.lat); const jlon = +(job && job.lon)
|
||||||
const hasJC = isFinite(jlat) && isFinite(jlon) && (jlat || jlon)
|
const hasJC = isFinite(jlat) && isFinite(jlon) && (jlat || jlon)
|
||||||
return (visibleTechs.value || []).map(t => {
|
return (visibleTechs.value || []).map(t => {
|
||||||
const load = techLoad(techDayOcc(t.id, iso), hasShiftDay(t.id, iso))
|
const load = techLoad(techDayOcc(t.id, iso), hasShiftDay(t.id, iso))
|
||||||
const capable = !reqSkill || (t.skills || []).includes(reqSkill)
|
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
|
||||||
return { id: t.id, name: t.name, _t: t, capable, distKm, ...load }
|
return { id: t.id, name: t.name, _t: t, capable, distKm, ...load }
|
||||||
|
|
@ -4641,7 +4802,8 @@ function buildSuggestion () {
|
||||||
const plan = []
|
const plan = []
|
||||||
const holdByDay = {} // jobs SANS tech de quart → regroupés par jour puis découpés en tournées PLACEHOLDER (pass 2)
|
const holdByDay = {} // jobs SANS tech de quart → regroupés par jour puis découpés en tournées PLACEHOLDER (pass 2)
|
||||||
for (const j of sorted) {
|
for (const j of sorted) {
|
||||||
const reqSkill = j.required_skill || skillByType.value[j.service_type] || '' // compétence explicite, sinon déduite du TYPE de job
|
const reqSkill = j.required_skill || skillByType.value[j.service_type] || '' // compétence PRINCIPALE (déduite du TYPE si absente)
|
||||||
|
const reqSkillList = (Array.isArray(j.required_skills) && j.required_skills.length) ? j.required_skills : (reqSkill ? [reqSkill] : []) // LISTE : le tech doit toutes les avoir
|
||||||
const reqLevel = Number(j.required_level) || 1 // niveau imposé PAR LE JOB (persistant) — plus de défaut global par compétence
|
const reqLevel = Number(j.required_level) || 1 // niveau imposé PAR LE JOB (persistant) — plus de défaut global par compétence
|
||||||
const jlat = +(j.lat != null ? j.lat : j.latitude), jlon = +(j.lon != null ? j.lon : j.longitude); const hasLL = isFinite(jlat) && isFinite(jlon) && (jlat || jlon) // pool hub = latitude/longitude (bruts ERP) ; grille = lat/lon
|
const jlat = +(j.lat != null ? j.lat : j.latitude), jlon = +(j.lon != null ? j.lon : j.longitude); const hasLL = isFinite(jlat) && isFinite(jlon) && (jlat || jlon) // pool hub = latitude/longitude (bruts ERP) ; grille = lat/lon
|
||||||
const jcity = jobCity(j) || '' // municipalité — sert de repli de proximité quand le job n'a pas de coordonnées
|
const jcity = jobCity(j) || '' // municipalité — sert de repli de proximité quand le job n'a pas de coordonnées
|
||||||
|
|
@ -4663,7 +4825,7 @@ function buildSuggestion () {
|
||||||
let best = null
|
let best = null
|
||||||
for (const t of techs) {
|
for (const t of techs) {
|
||||||
// SKILL = filtre DUR : un tech sans la compétence requise n'est JAMAIS candidat (ex. Josée-Anne ne fait ni réparation ni installation).
|
// SKILL = filtre DUR : un tech sans la compétence requise n'est JAMAIS candidat (ex. Josée-Anne ne fait ni réparation ni installation).
|
||||||
if (reqSkill && !(t.skills || []).includes(reqSkill)) continue
|
if (reqSkillList.length && !reqSkillList.every(s => (t.skills || []).includes(s))) continue
|
||||||
const capable = true
|
const capable = true
|
||||||
const skillRank = reqSkill ? Math.max(0, (t.skills || []).indexOf(reqSkill)) : 0 // ORDRE de la compétence chez le tech : 0 = principale (spécialiste) ; 1,2… = secondaire (polyvalent)
|
const skillRank = reqSkill ? Math.max(0, (t.skills || []).indexOf(reqSkill)) : 0 // ORDRE de la compétence chez le tech : 0 = principale (spécialiste) ; 1,2… = secondaire (polyvalent)
|
||||||
const lvl = reqSkill ? (skillLevelOf(t, reqSkill) || 0) : 3 // maîtrise 0-5 (3 = neutre sans compétence requise)
|
const lvl = reqSkill ? (skillLevelOf(t, reqSkill) || 0) : 3 // maîtrise 0-5 (3 = neutre sans compétence requise)
|
||||||
|
|
@ -5322,8 +5484,9 @@ function toggleSel (j) {
|
||||||
if (selectedJobs[j.name]) delete selectedJobs[j.name]; else selectedJobs[j.name] = true
|
if (selectedJobs[j.name]) delete selectedJobs[j.name]; else selectedJobs[j.name] = true
|
||||||
}
|
}
|
||||||
// ── Actions rapides du pool (mobile, façon Gmail : glisser + icônes directes ★/📝) ──
|
// ── Actions rapides du pool (mobile, façon Gmail : glisser + icônes directes ★/📝) ──
|
||||||
// ⚠ Le champ Dispatch Job.priority = Select { low | medium | high } UNIQUEMENT (pas de « urgent » → 417). L'étoile = « high » (le plus prioritaire dispo).
|
// ⚠ Le champ Dispatch Job.priority = Select { low | medium | high } UNIQUEMENT (pas de « urgent » → 417). L'étoile = « high ».
|
||||||
const POOL_PRIOS = [{ value: 'high', label: 'Élevée', color: '#ef4444' }, { value: 'medium', label: 'Moyenne', color: '#f59e0b' }, { value: 'low', label: 'Basse', color: '#9e9e9e' }]
|
// POOL_PRIOS = priorités Dispatch Job — SOURCE UNIQUE (config/dispatch-priority).
|
||||||
|
import { DISPATCH_PRIORITIES as POOL_PRIOS } from 'src/config/dispatch-priority'
|
||||||
const POOL_STATUSES = [{ value: 'open', label: 'Ouvert', icon: 'inbox', color: 'grey-7' }, { value: 'On Hold', label: 'En attente', icon: 'pause_circle', color: 'orange-7' }, { value: 'Cancelled', label: 'Annulé', icon: 'cancel', color: 'red-6' }]
|
const POOL_STATUSES = [{ value: 'open', label: 'Ouvert', icon: 'inbox', color: 'grey-7' }, { value: 'On Hold', label: 'En attente', icon: 'pause_circle', color: 'orange-7' }, { value: 'Cancelled', label: 'Annulé', icon: 'cancel', color: 'red-6' }]
|
||||||
const jobSheet = reactive({ open: false, job: null })
|
const jobSheet = reactive({ open: false, job: null })
|
||||||
const noteDialog = reactive({ open: false, job: null, text: '' })
|
const noteDialog = reactive({ open: false, job: null, text: '' })
|
||||||
|
|
@ -5336,10 +5499,18 @@ async function patchJob (j, patch, okMsg) {
|
||||||
try { await roster.updateJob(j.name, patch); if (okMsg) $q.notify({ type: 'positive', message: okMsg, timeout: 1600 }) }
|
try { await roster.updateJob(j.name, patch); if (okMsg) $q.notify({ type: 'positive', message: okMsg, timeout: 1600 }) }
|
||||||
catch (e) { err(e); await reloadPool() }
|
catch (e) { err(e); await reloadPool() }
|
||||||
}
|
}
|
||||||
function toggleUrgent (j) { const on = j.priority === 'high'; patchJob(j, { priority: on ? 'medium' : 'high' }, on ? 'Priorité normale' : '⭐ Prioritaire') }
|
function toggleUrgent (j) { const on = j.priority === 'high'; patchJob(j, { priority: on ? 'medium' : 'high' }, on ? 'Priorité moyenne' : '⚡ Urgent') }
|
||||||
function setJobPriority (j, p) { patchJob(j, { priority: p }, 'Priorité : ' + ((POOL_PRIOS.find(x => x.value === p) || {}).label || p)) }
|
function setJobPriority (j, p) { patchJob(j, { priority: p }, 'Priorité : ' + ((POOL_PRIOS.find(x => x.value === p) || {}).label || p)) }
|
||||||
function setJobStatus (j, s) { patchJob(j, { status: s }, 'Statut : ' + ((POOL_STATUSES.find(x => x.value === s) || {}).label || s)) }
|
function setJobStatus (j, s) { patchJob(j, { status: s }, 'Statut : ' + ((POOL_STATUSES.find(x => x.value === s) || {}).label || s)) }
|
||||||
function setJobSkill (j, sk) { patchJob(j, { required_skill: sk }, 'Compétence : ' + (sk || '—')) }
|
// Compétences requises (LISTE) du job depuis la feuille du pool → store hub /roster/job-skills (PAS un champ ERPNext). SkillSelect émet une CSV. Optimiste + réconcilie sur échec.
|
||||||
|
async function setPoolSkills (j, list) {
|
||||||
|
if (!j) return
|
||||||
|
const arr = [...new Set((Array.isArray(list) ? list : String(list || '').split(',')).map(s => String(s).trim()).filter(Boolean))]
|
||||||
|
j.required_skills = arr; j.required_skill = arr[0] || '' // affichage/couleur/icône immédiats
|
||||||
|
if (!j.name) return
|
||||||
|
try { await roster.setJobSkills(j.name, arr); $q.notify({ type: 'positive', message: 'Compétences : ' + (arr.join(', ') || '—'), timeout: 1600 }) }
|
||||||
|
catch (e) { err(e); await reloadPool() }
|
||||||
|
}
|
||||||
// Reporter : +N jours (base = date actuelle sinon aujourd'hui).
|
// Reporter : +N jours (base = date actuelle sinon aujourd'hui).
|
||||||
function snoozeJob (j, days) {
|
function snoozeJob (j, days) {
|
||||||
if (!j) return
|
if (!j) return
|
||||||
|
|
@ -5580,11 +5751,12 @@ function covStyle (key, iso) { const c = covCell(key, iso); if (!c) return {}; r
|
||||||
// undo / redo
|
// undo / redo
|
||||||
function snap () { return JSON.parse(JSON.stringify(assignments.value)) }
|
function snap () { return JSON.parse(JSON.stringify(assignments.value)) }
|
||||||
function pushHistory () { history.value.push(snap()); if (history.value.length > 40) history.value.shift(); future.value = [] }
|
function pushHistory () { history.value.push(snap()); if (history.value.length > 40) history.value.shift(); future.value = [] }
|
||||||
function undo () { if (!history.value.length) return; future.value.push(snap()); assignments.value = history.value.pop() }
|
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()); assignments.value = future.value.pop() }
|
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
|
||||||
function guard (fn) { if (dirty.value && !window.confirm(DIRTY_MSG)) return; fn() }
|
// #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).
|
||||||
|
async function guard (fn) { await autosaveDraft(); fn() }
|
||||||
function onWeekChange () { if (dirty.value && !window.confirm(DIRTY_MSG)) { start.value = lastWeek.start; return } loadWeek() }
|
function onWeekChange () { if (dirty.value && !window.confirm(DIRTY_MSG)) { start.value = lastWeek.start; return } loadWeek() }
|
||||||
function onDaysChange () { if (dirty.value && !window.confirm(DIRTY_MSG)) { days.value = lastWeek.days; return } loadWeek() }
|
function onDaysChange () { if (dirty.value && !window.confirm(DIRTY_MSG)) { days.value = lastWeek.days; return } loadWeek() }
|
||||||
function navWeek (dir) { guard(() => { start.value = addDaysISO(start.value, dir * days.value); loadWeek() }) }
|
function navWeek (dir) { guard(() => { start.value = addDaysISO(start.value, dir * days.value); loadWeek() }) }
|
||||||
|
|
@ -5668,12 +5840,26 @@ async function doGenerate () {
|
||||||
$q.notify({ type: 'positive', message: 'Horaire généré : ' + solverStats.value.assignments + ' assignations (non publié)' })
|
$q.notify({ type: 'positive', message: 'Horaire généré : ' + solverStats.value.assignments + ' assignations (non publié)' })
|
||||||
} catch (e) { err(e) } finally { generating.value = false }
|
} catch (e) { err(e) } finally { generating.value = false }
|
||||||
}
|
}
|
||||||
async function doPublish () {
|
// #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 }
|
||||||
|
async function doPublishConfirmed () {
|
||||||
publishing.value = true
|
publishing.value = true
|
||||||
try {
|
try {
|
||||||
// Réécriture de semaine : efface la période + recrée la grille (anti-doublons).
|
// 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)
|
const r = await roster.publishWeek(start.value, days.value, assignments.value, notifySms.value, 'publish')
|
||||||
$q.notify({ type: r.errors ? 'warning' : 'positive', message: `Publié : ${r.created} assignations` + (r.deleted ? ` (${r.deleted} remplacées)` : '') + (r.errors ? ` · ${r.errors} erreurs` : '') + (r.notified ? ` · ${r.notified} SMS` : '') })
|
const done = (r.created || 0) + (r.promoted || 0)
|
||||||
|
$q.notify({ type: r.errors ? 'warning' : 'positive', message: `Publié : ${done} quart(s)` + (r.deleted ? ` (${r.deleted} retirés)` : '') + (r.errors ? ` · ${r.errors} erreurs` : '') + (r.notified ? ` · ${r.notified} SMS` : '') })
|
||||||
|
pubConfirm.value = false
|
||||||
|
await loadWeek()
|
||||||
|
} 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).
|
||||||
|
async function doWeekStatus (mode) {
|
||||||
|
publishing.value = true
|
||||||
|
try {
|
||||||
|
const r = await roster.publishWeek(start.value, days.value, assignments.value, false, mode)
|
||||||
|
const n = (r.created || 0) + (r.promoted || 0)
|
||||||
|
$q.notify({ type: r.errors ? 'warning' : 'positive', message: (mode === 'submit' ? 'Soumis pour approbation' : 'Approuvé') + ' · ' + n + ' quart(s)' + (r.errors ? ' · ' + r.errors + ' err.' : '') })
|
||||||
await loadWeek()
|
await loadWeek()
|
||||||
} catch (e) { err(e) } finally { publishing.value = false }
|
} catch (e) { err(e) } finally { publishing.value = false }
|
||||||
}
|
}
|
||||||
|
|
@ -5851,10 +6037,22 @@ function rect (sti, sdi, eti, edi) {
|
||||||
function onDown (ti, di, ev) { if (ev.button !== 0 || ev.shiftKey || ev.ctrlKey || ev.metaKey) return; drag.on = true; drag.ti = ti; drag.di = di; drag.moved = false; drag.base = [] }
|
function onDown (ti, di, ev) { if (ev.button !== 0 || ev.shiftKey || ev.ctrlKey || ev.metaKey) return; drag.on = true; drag.ti = ti; drag.di = di; drag.moved = false; drag.base = [] }
|
||||||
function onEnter (ti, di) { if (!drag.on) return; drag.moved = true; selection.value = [...new Set([...drag.base, ...rect(drag.ti, drag.di, ti, di)])] }
|
function onEnter (ti, di) { if (!drag.on) return; drag.moved = true; selection.value = [...new Set([...drag.base, ...rect(drag.ti, drag.di, ti, di)])] }
|
||||||
function onUp () { if (drag.on) { drag.on = false; if (drag.moved) justDragged.value = true } }
|
function onUp () { if (drag.on) { drag.on = false; if (drag.moved) justDragged.value = true } }
|
||||||
function addShift (techId, techName, iso, tpl) { if (cellsOf(techId, iso).some(a => a.shift === tpl.name)) return; assignments.value = [...assignments.value, { tech: techId, tech_name: techName, date: iso, shift: tpl.name, shift_name: tpl.template_name, zone: tpl.zone || '', hours: tpl.hours || 8, status: 'Proposé', source: 'manuel', color: tpl.color }] }
|
// #2 — AUTO-SAVE : chaque édition de la grille est persistée en BROUILLON (statut 'Proposé') après un court debounce. Plus rien n'est « non sauvegardé ».
|
||||||
function setCellReplace (techId, techName, iso, tpl) { const kept = assignments.value.filter(a => !(a.tech === techId && a.date === iso)); kept.push({ tech: techId, tech_name: techName, date: iso, shift: tpl.name, shift_name: tpl.template_name, zone: tpl.zone || '', hours: tpl.hours || 8, status: 'Proposé', source: 'manuel', color: tpl.color }); assignments.value = kept }
|
const autosaving = ref(false)
|
||||||
|
const changeLog = ref([]) // journal de session : { at, text }
|
||||||
|
let _draftT = null
|
||||||
|
function logChange (text) { changeLog.value.unshift({ at: Date.now(), text }); if (changeLog.value.length > 60) changeLog.value.pop() }
|
||||||
|
function scheduleDraftSave () { if (_draftT) clearTimeout(_draftT); _draftT = setTimeout(autosaveDraft, 800) }
|
||||||
|
async function autosaveDraft () {
|
||||||
|
if (_draftT) { clearTimeout(_draftT); _draftT = null }
|
||||||
|
if (autosaving.value) { scheduleDraftSave(); return } // une sauvegarde en cours → replanifie
|
||||||
|
autosaving.value = true
|
||||||
|
try { await roster.publishWeek(start.value, days.value, assignments.value, false, 'draft') } catch (e) { /* non bloquant : réessaie au prochain edit */ } finally { autosaving.value = false }
|
||||||
|
}
|
||||||
|
function addShift (techId, techName, iso, tpl) { if (cellsOf(techId, iso).some(a => a.shift === tpl.name)) return; assignments.value = [...assignments.value, { tech: techId, tech_name: techName, date: iso, shift: tpl.name, shift_name: tpl.template_name, zone: tpl.zone || '', hours: tpl.hours || 8, status: 'Proposé', source: 'manuel', color: tpl.color }]; logChange('Quart ajouté · ' + techName + ' · ' + iso); scheduleDraftSave() }
|
||||||
|
function setCellReplace (techId, techName, iso, tpl) { const kept = assignments.value.filter(a => !(a.tech === techId && a.date === iso)); kept.push({ tech: techId, tech_name: techName, date: iso, shift: tpl.name, shift_name: tpl.template_name, zone: tpl.zone || '', hours: tpl.hours || 8, status: 'Proposé', source: 'manuel', color: tpl.color }); assignments.value = kept; logChange('Quart défini · ' + techName + ' · ' + iso); scheduleDraftSave() }
|
||||||
function removeShift (techId, iso, shift) { assignments.value = assignments.value.filter(a => !(a.tech === techId && a.date === iso && a.shift === shift)) }
|
function removeShift (techId, iso, shift) { assignments.value = assignments.value.filter(a => !(a.tech === techId && a.date === iso && a.shift === shift)) }
|
||||||
function clearLocal (techId, iso) { assignments.value = assignments.value.filter(x => !(x.tech === techId && x.date === iso)) }
|
function clearLocal (techId, iso) { assignments.value = assignments.value.filter(x => !(x.tech === techId && x.date === iso)); logChange('Quart retiré · ' + iso); scheduleDraftSave() }
|
||||||
// Ouvre le menu d'horaire (Jour/Soir/Garde/Absent + heures) ancré sur la cellule.
|
// Ouvre le menu d'horaire (Jour/Soir/Garde/Absent + heures) ancré sur la cellule.
|
||||||
function openShiftMenu (t, d, ev, ti, di) {
|
function openShiftMenu (t, d, ev, ti, di) {
|
||||||
selection.value = []; anchor.value = { ti, di }; menu.tech = t; menu.day = d
|
selection.value = []; anchor.value = { ti, di }; menu.tech = t; menu.day = d
|
||||||
|
|
@ -5974,7 +6172,7 @@ function onKey (e) {
|
||||||
toggleGardeCells(targets); if (selection.value.length) selection.value = []
|
toggleGardeCells(targets); if (selection.value.length) selection.value = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function onUnload (e) { if (dirty.value) { e.preventDefault(); e.returnValue = '' } }
|
function onUnload () { if (dirty.value && !autosaving.value) autosaveDraft() } // auto-save actif → aucune alerte ; on tente un dernier flush best-effort
|
||||||
// ── Veille Legacy → Ops (SSE topic 'dispatch') : reflète fermeture / réassignation hors-Ops / activité en direct ──
|
// ── Veille Legacy → Ops (SSE topic 'dispatch') : reflète fermeture / réassignation hors-Ops / activité en direct ──
|
||||||
let _legacyRefreshT = null
|
let _legacyRefreshT = null
|
||||||
function scheduleLegacyRefresh () {
|
function scheduleLegacyRefresh () {
|
||||||
|
|
@ -6001,7 +6199,7 @@ const dispatchSSE = useSSE({ listeners: { 'legacy-update': onLegacyUpdate } })
|
||||||
|
|
||||||
onMounted(async () => { loadLS(); document.addEventListener('keydown', onKey); document.addEventListener('mouseup', onUp); window.addEventListener('beforeunload', onUnload); try { await loadBase() } catch (e) { err(e) } if (route.query.day || route.query.skill) focusDay(route.query.day, route.query.skill); await loadWeek(); loadDispatchPolicy(); try { const _h = await roster.holidays(todayISO(), addDaysISO(todayISO(), 400)); statHolidays.value = _h.holidays || [] } catch (e) { /* fériés best-effort */ } try { const _p = await roster.holidayNotice(40); holNoticePreview.value = _p.holidays || [] } catch (e) { /* préavis best-effort */ } /* dépôt + domiciles techs (origine de tournée) */ try { const m = await roster.bookMeta(); jobTypes.value = m.service_types || []; skillByType.value = m.skill_by_type || {} } catch (e) { /* catégories de job pour suggestions */ } if ($q.screen.lt.md) { try { await reloadPool() } catch (e) { /* */ } } else openAssignPanel(); /* mobile : charge le pool pour la liste « À assigner » (assignation au toucher), pas de panneau flottant ; desktop : panneau ouvert */ dispatchSSE.connect(['dispatch']); /* veille Legacy temps-réel */ _nowTimer = setInterval(() => { nowTick.value++ }, 60000) /* ligne « maintenant » du board */ })
|
onMounted(async () => { loadLS(); document.addEventListener('keydown', onKey); document.addEventListener('mouseup', onUp); window.addEventListener('beforeunload', onUnload); try { await loadBase() } catch (e) { err(e) } if (route.query.day || route.query.skill) focusDay(route.query.day, route.query.skill); await loadWeek(); loadDispatchPolicy(); try { const _h = await roster.holidays(todayISO(), addDaysISO(todayISO(), 400)); statHolidays.value = _h.holidays || [] } catch (e) { /* fériés best-effort */ } try { const _p = await roster.holidayNotice(40); holNoticePreview.value = _p.holidays || [] } catch (e) { /* préavis best-effort */ } /* dépôt + domiciles techs (origine de tournée) */ try { const m = await roster.bookMeta(); jobTypes.value = m.service_types || []; skillByType.value = m.skill_by_type || {} } catch (e) { /* catégories de job pour suggestions */ } if ($q.screen.lt.md) { try { await reloadPool() } catch (e) { /* */ } } else openAssignPanel(); /* mobile : charge le pool pour la liste « À assigner » (assignation au toucher), pas de panneau flottant ; desktop : panneau ouvert */ dispatchSSE.connect(['dispatch']); /* veille Legacy temps-réel */ _nowTimer = setInterval(() => { nowTick.value++ }, 60000) /* ligne « maintenant » du board */ })
|
||||||
onUnmounted(() => { document.removeEventListener('keydown', onKey); document.removeEventListener('mouseup', onUp); window.removeEventListener('beforeunload', onUnload); if (_nowTimer) clearInterval(_nowTimer); stopLivePositions(); if (_kbRO) _kbRO.disconnect() })
|
onUnmounted(() => { document.removeEventListener('keydown', onKey); document.removeEventListener('mouseup', onUp); window.removeEventListener('beforeunload', onUnload); if (_nowTimer) clearInterval(_nowTimer); stopLivePositions(); if (_kbRO) _kbRO.disconnect() })
|
||||||
onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return false })
|
onBeforeRouteLeave(async () => { await autosaveDraft() }) // #2 — flush le brouillon (attendu) en partant ; plus jamais d'« abandonner ? »
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
|
||||||
|
|
@ -71,13 +71,19 @@ def solve_route(req: dict) -> dict:
|
||||||
except Exception:
|
except Exception:
|
||||||
return 1.0
|
return 1.0
|
||||||
|
|
||||||
def skill_ok(veh, sk):
|
def skill_ok(veh, job):
|
||||||
return (not sk) or (sk in (veh.get("skills") or []))
|
# job = dict (préféré : liste `skills`, sinon `skill` unique) OU chaîne (rétro-compat). Le tech doit avoir TOUTES les compétences requises.
|
||||||
|
if isinstance(job, str):
|
||||||
|
req = [job] if job else []
|
||||||
|
else:
|
||||||
|
req = (job.get("skills") or ([job["skill"]] if job.get("skill") else []))
|
||||||
|
vs = veh.get("skills") or []
|
||||||
|
return all(s in vs for s in req)
|
||||||
|
|
||||||
# Pré-filtre : un job sans AUCUN tech compétent est non planifiable → hors modèle (évite les "allowed" vides).
|
# Pré-filtre : un job sans AUCUN tech compétent est non planifiable → hors modèle (évite les "allowed" vides).
|
||||||
jobs, unservable = [], []
|
jobs, unservable = [], []
|
||||||
for j in all_jobs:
|
for j in all_jobs:
|
||||||
if any(skill_ok(v, j.get("skill")) for v in vehicles):
|
if any(skill_ok(v, j) for v in vehicles):
|
||||||
jobs.append(j)
|
jobs.append(j)
|
||||||
else:
|
else:
|
||||||
unservable.append(j["id"])
|
unservable.append(j["id"])
|
||||||
|
|
@ -186,9 +192,8 @@ def solve_route(req: dict) -> dict:
|
||||||
# URGENCE → servir TÔT : coût = uw × heure d'arrivée (borne souple à 0) → tire le job en début de tournée
|
# URGENCE → servir TÔT : coût = uw × heure d'arrivée (borne souple à 0) → tire le job en début de tournée
|
||||||
# (conditionne le début de journée du tech). Reste souple : ne casse pas la faisabilité, se combine aux fenêtres AM/PM.
|
# (conditionne le début de journée du tech). Reste souple : ne casse pas la faisabilité, se combine aux fenêtres AM/PM.
|
||||||
time_dim.SetCumulVarSoftUpperBound(idx, 0, uw)
|
time_dim.SetCumulVarSoftUpperBound(idx, 0, uw)
|
||||||
sk = j.get("skill")
|
allowed = [v for v, veh in enumerate(vehicles) if skill_ok(veh, j)]
|
||||||
allowed = [v for v, veh in enumerate(vehicles) if skill_ok(veh, sk)]
|
if len(allowed) < n_veh: # compétence(s) restreignent réellement → filtre DUR
|
||||||
if len(allowed) < n_veh: # compétence restreint réellement → filtre DUR
|
|
||||||
sv.Add(sv.MemberCt(routing.VehicleVar(idx), allowed + [-1]))
|
sv.Add(sv.MemberCt(routing.VehicleVar(idx), allowed + [-1]))
|
||||||
routing.AddDisjunction([idx], base_pen + int(j.get("priority_boost") or 0)) # laisser tomber coûte cher → préfère assigner
|
routing.AddDisjunction([idx], base_pen + int(j.get("priority_boost") or 0)) # laisser tomber coûte cher → préfère assigner
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -775,6 +775,7 @@ async function logToLinkedTicket (conv, msg, subject) {
|
||||||
communication_type: 'Communication', communication_medium: 'Email',
|
communication_type: 'Communication', communication_medium: 'Email',
|
||||||
sent_or_received: sent ? 'Sent' : 'Received',
|
sent_or_received: sent ? 'Sent' : 'Received',
|
||||||
sender: sent ? (cfg.MAIL_FROM || 'cc@targointernet.com') : (conv.email || ''),
|
sender: sent ? (cfg.MAIL_FROM || 'cc@targointernet.com') : (conv.email || ''),
|
||||||
|
sender_full_name: sent ? (msg.agentName || 'Targo Ops') : (conv.customerName || ''), // sortie → prénom+nom de l'agent (jamais le login « louis »)
|
||||||
recipients: sent ? (conv.email || '') : (cfg.MAIL_FROM || 'cc@targointernet.com'),
|
recipients: sent ? (conv.email || '') : (cfg.MAIL_FROM || 'cc@targointernet.com'),
|
||||||
subject: 'Re: ' + (subject || conv.lastSubject || conv.subject || ''),
|
subject: 'Re: ' + (subject || conv.lastSubject || conv.subject || ''),
|
||||||
content: msg.html || (msg.text || '').replace(/\n/g, '<br>'),
|
content: msg.html || (msg.text || '').replace(/\n/g, '<br>'),
|
||||||
|
|
@ -786,7 +787,7 @@ async function logToLinkedTicket (conv, msg, subject) {
|
||||||
// ALLER-RETOUR COURRIEL depuis un ticket : lie l'Issue à une conversation email, envoie dans un fil Gmail
|
// ALLER-RETOUR COURRIEL depuis un ticket : lie l'Issue à une conversation email, envoie dans un fil Gmail
|
||||||
// avec sujet [Ticket #ISS-…] + lien direct. La réponse du client re-thread ici (TICKET_REF_RX) → logToLinkedTicket
|
// avec sujet [Ticket #ISS-…] + lien direct. La réponse du client re-thread ici (TICKET_REF_RX) → logToLinkedTicket
|
||||||
// → Communication (Received) sur l'Issue → visible dans le panneau ticket. Envoi RÉEL : gaté (feu vert humain).
|
// → Communication (Received) sur l'Issue → visible dans le panneau ticket. Envoi RÉEL : gaté (feu vert humain).
|
||||||
async function emailTicket ({ issue, to, cc, message, agent, link } = {}) {
|
async function emailTicket ({ issue, to, cc, message, agent, agentName, link } = {}) {
|
||||||
const iss = String(issue || '').trim()
|
const iss = String(issue || '').trim()
|
||||||
const dest = String(to || '').trim()
|
const dest = String(to || '').trim()
|
||||||
if (!iss || !/.+@.+\..+/.test(dest)) return { ok: false, error: 'issue + destinataire courriel valides requis' }
|
if (!iss || !/.+@.+\..+/.test(dest)) return { ok: false, error: 'issue + destinataire courriel valides requis' }
|
||||||
|
|
@ -802,7 +803,7 @@ async function emailTicket ({ issue, to, cc, message, agent, link } = {}) {
|
||||||
const esc = s => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
const esc = s => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
let html = esc(text).replace(/\n/g, '<br>')
|
let html = esc(text).replace(/\n/g, '<br>')
|
||||||
if (link) html += `<br><br>— <a href="${esc(link)}">Ouvrir le ticket ${esc(iss)} dans OPS</a>`
|
if (link) html += `<br><br>— <a href="${esc(link)}">Ouvrir le ticket ${esc(iss)} dans OPS</a>`
|
||||||
const msg = addMessage(conv, { from: 'agent', text, via: 'email', html, agent: agent || '', toEmail: dest, ccRaw: cc || '' })
|
const msg = addMessage(conv, { from: 'agent', text, via: 'email', html, agent: agent || '', agentName: agentName || '', toEmail: dest, ccRaw: cc || '' })
|
||||||
const chan = await notifyCustomer(conv, { text, html, cc: cc || '', replyToOverride: dest, agent })
|
const chan = await notifyCustomer(conv, { text, html, cc: cc || '', replyToOverride: dest, agent })
|
||||||
await logToLinkedTicket(conv, msg, subject).catch(() => {})
|
await logToLinkedTicket(conv, msg, subject).catch(() => {})
|
||||||
saveToDisk()
|
saveToDisk()
|
||||||
|
|
@ -1311,7 +1312,7 @@ async function handle (req, res, method, p, url) {
|
||||||
|
|
||||||
// ALLER-RETOUR COURRIEL depuis un TICKET (Issue). Gaté (Authentik/proxy). La réponse du client re-thread → Communication sur l'Issue.
|
// ALLER-RETOUR COURRIEL depuis un TICKET (Issue). Gaté (Authentik/proxy). La réponse du client re-thread → Communication sur l'Issue.
|
||||||
if (p === '/conversations/ticket-email' && method === 'POST') {
|
if (p === '/conversations/ticket-email' && method === 'POST') {
|
||||||
try { const b = await parseBody(req); const r = await emailTicket({ ...(b || {}), agent: req.headers['x-authentik-email'] || (b && b.agent) || '' }); return json(res, r.ok ? 200 : 400, r) } catch (e) { return json(res, 500, { error: e.message }) }
|
try { const b = await parseBody(req); const r = await emailTicket({ ...(b || {}), agent: req.headers['x-authentik-email'] || (b && b.agent) || '', agentName: (b && b.agentName) || '' }); return json(res, r.ok ? 200 : 400, r) } catch (e) { return json(res, 500, { error: e.message }) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// INGESTION COURRIEL (Phase 3) — poussé par n8n (Gmail Trigger sur cc@) ou un poller. Protégé par X-Mail-Token (PAS Authentik).
|
// INGESTION COURRIEL (Phase 3) — poussé par n8n (Gmail Trigger sur cc@) ou un poller. Protégé par X-Mail-Token (PAS Authentik).
|
||||||
|
|
|
||||||
|
|
@ -133,7 +133,7 @@ const SLOT_TRAVEL_BUFFER_H = 0.25 // 15 min pre-job slack before proposed star
|
||||||
const SLOT_HORIZON_DAYS = 7
|
const SLOT_HORIZON_DAYS = 7
|
||||||
const SLOT_MAX_PER_TECH = 2
|
const SLOT_MAX_PER_TECH = 2
|
||||||
|
|
||||||
async function suggestSlots ({ duration_h = 1, latitude, longitude, after_date, limit = 5, skill = '' } = {}) {
|
async function suggestSlots ({ duration_h = 1, latitude, longitude, after_date, limit = 5, skill = '', ignoreReserved = false } = {}) {
|
||||||
const baseDate = after_date || todayET()
|
const baseDate = after_date || todayET()
|
||||||
const duration = parseFloat(duration_h) || 1
|
const duration = parseFloat(duration_h) || 1
|
||||||
const dates = Array.from({ length: SLOT_HORIZON_DAYS }, (_, i) => dateAddDays(baseDate, i))
|
const dates = Array.from({ length: SLOT_HORIZON_DAYS }, (_, i) => dateAddDays(baseDate, i))
|
||||||
|
|
@ -152,7 +152,7 @@ async function suggestSlots ({ duration_h = 1, latitude, longitude, after_date,
|
||||||
['scheduled_date', '<=', dates[dates.length - 1]],
|
['scheduled_date', '<=', dates[dates.length - 1]],
|
||||||
]))}&fields=${encodeURIComponent(JSON.stringify([
|
]))}&fields=${encodeURIComponent(JSON.stringify([
|
||||||
'name', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h',
|
'name', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h',
|
||||||
'longitude', 'latitude',
|
'longitude', 'latitude', 'job_type',
|
||||||
]))}&limit_page_length=500`),
|
]))}&limit_page_length=500`),
|
||||||
erpFetch(`/api/resource/Shift Template?fields=${encodeURIComponent(JSON.stringify(['name', 'start_time', 'end_time', 'on_call']))}&limit_page_length=100`),
|
erpFetch(`/api/resource/Shift Template?fields=${encodeURIComponent(JSON.stringify(['name', 'start_time', 'end_time', 'on_call']))}&limit_page_length=100`),
|
||||||
erpFetch(`/api/resource/Shift Assignment?filters=${encodeURIComponent(JSON.stringify([['assignment_date', 'in', dates]]))}&fields=${encodeURIComponent(JSON.stringify(['technician', 'assignment_date', 'shift_template']))}&limit_page_length=2000`),
|
erpFetch(`/api/resource/Shift Assignment?filters=${encodeURIComponent(JSON.stringify([['assignment_date', 'in', dates]]))}&fields=${encodeURIComponent(JSON.stringify(['technician', 'assignment_date', 'shift_template']))}&limit_page_length=2000`),
|
||||||
|
|
@ -195,7 +195,8 @@ async function suggestSlots ({ duration_h = 1, latitude, longitude, after_date,
|
||||||
// without a time are ignored since we don't know when they'll land).
|
// without a time are ignored since we don't know when they'll land).
|
||||||
const dayJobs = allJobs
|
const dayJobs = allJobs
|
||||||
.filter(j => j.assigned_tech === tech.technician_id &&
|
.filter(j => j.assigned_tech === tech.technician_id &&
|
||||||
j.scheduled_date === dateStr && j.start_time)
|
j.scheduled_date === dateStr && j.start_time &&
|
||||||
|
!(ignoreReserved && j.job_type === 'Réservation')) // mode urgence/réparation → les blocs RÉSERVÉS (soft) ne bloquent pas
|
||||||
.map(j => {
|
.map(j => {
|
||||||
const s = timeToHours(j.start_time)
|
const s = timeToHours(j.start_time)
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,24 @@ const muni = require('./municipality') // résolveur de municipalités QC (centr
|
||||||
const sse = require('./sse') // diffusion temps-réel vers Ops (topic 'dispatch')
|
const sse = require('./sse') // diffusion temps-réel vers Ops (topic 'dispatch')
|
||||||
const audit = require('./dispatch-audit') // journal d'audit d'assignation (append-only JSONL)
|
const audit = require('./dispatch-audit') // journal d'audit d'assignation (append-only JSONL)
|
||||||
const fs = require('fs')
|
const fs = require('fs')
|
||||||
|
// Décodeur d'entités HTML UNIQUE (osTicket stocke les sujets encodés : ' é & …). Appliqué à l'ÉCRITURE
|
||||||
|
// du sujet des jobs → la donnée stockée est propre partout (Dispatch, Planification, app terrain, rapports), pas de
|
||||||
|
// rustine d'affichage par surface. 2 passes = gère le double-encodage (&#039;). Remplace 3 closures `decode` qui
|
||||||
|
// divergeaient (l'une gérait ", pas les autres) — source exacte des « codes HTML par endroits ».
|
||||||
|
const NAMED_ENT = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', eacute: 'é', egrave: 'è', ecirc: 'ê', euml: 'ë', agrave: 'à', acirc: 'â', agrave2: 'à', ccedil: 'ç', ugrave: 'ù', ucirc: 'û', uuml: 'ü', icirc: 'î', iuml: 'ï', ocirc: 'ô', ouml: 'ö', auml: 'ä', laquo: '«', raquo: '»', hellip: '…', rsquo: '’', lsquo: '‘', ldquo: '“', rdquo: '”', ndash: '–', mdash: '—', deg: '°', euro: '€', copy: '©', reg: '®', trade: '™' }
|
||||||
|
function decodeEntities (s) {
|
||||||
|
let v = String(s == null ? '' : s)
|
||||||
|
if (v.indexOf('&') < 0) return v
|
||||||
|
for (let i = 0; i < 2 && v.indexOf('&') >= 0; i++) {
|
||||||
|
const nv = v
|
||||||
|
.replace(/&#x([0-9a-f]+);/gi, (_, h) => { try { return String.fromCodePoint(parseInt(h, 16)) } catch (e) { return '' } })
|
||||||
|
.replace(/&#(\d+);/g, (_, n) => { try { return String.fromCodePoint(+n) } catch (e) { return '' } })
|
||||||
|
.replace(/&([a-z]+\d*);/gi, (m, name) => (Object.prototype.hasOwnProperty.call(NAMED_ENT, name.toLowerCase()) ? NAMED_ENT[name.toLowerCase()] : m))
|
||||||
|
if (nv === v) break
|
||||||
|
v = nv
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
// Pont d'ÉCRITURE legacy = endpoint PHP sur facturation.targo.ca (hérite des droits write de l'app ; aucun grant DB).
|
// Pont d'ÉCRITURE legacy = endpoint PHP sur facturation.targo.ca (hérite des droits write de l'app ; aucun grant DB).
|
||||||
// Token lu d'un FICHIER (/app/data/ops_legacy.token) → pas de var d'env → pas besoin de recréer le conteneur.
|
// Token lu d'un FICHIER (/app/data/ops_legacy.token) → pas de var d'env → pas besoin de recréer le conteneur.
|
||||||
const OPS_LEGACY_URL = process.env.OPS_LEGACY_URL || 'https://facturation.targo.ca/ops_reassign.php'
|
const OPS_LEGACY_URL = process.env.OPS_LEGACY_URL || 'https://facturation.targo.ca/ops_reassign.php'
|
||||||
|
|
@ -58,10 +76,14 @@ async function resolveActorStaff (actorEmail) {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
// Poster une note/réponse au fil d'un ticket legacy via ops_reassign.php action=post. public=false (défaut) = note INTERNE (jamais au client) · true = visible.
|
// Poster une note/réponse au fil d'un ticket legacy via ops_reassign.php action=post. public=false (défaut) = note INTERNE (jamais au client) · true = visible.
|
||||||
async function postTicketLegacy (ticketId, msg, isPublic, actorEmail) {
|
async function postTicketLegacy (ticketId, msg, isPublic, actorEmail, actorName) {
|
||||||
if (!ticketId || !String(msg || '').trim()) return { ok: false, error: 'ticket + msg requis' }
|
if (!ticketId || !String(msg || '').trim()) return { ok: false, error: 'ticket + msg requis' }
|
||||||
const actorStaff = await resolveActorStaff(actorEmail)
|
const actorStaff = await resolveActorStaff(actorEmail)
|
||||||
const w = await legacyWrite({ action: 'post', ticket_id: ticketId, msg: String(msg), public: isPublic ? 1 : '', actor_staff_id: actorStaff || '' }).catch(e => ({ data: { ok: false, error: e.message } }))
|
// Attribution : si l'acteur a un compte staff osTicket (courriel reconnu) → la note porte SON nom automatiquement.
|
||||||
|
// Sinon (ex. tech terrain sans compte legacy) on identifie l'auteur dans le corps pour ne PAS afficher « Tech Targo ».
|
||||||
|
let body = String(msg)
|
||||||
|
if (!actorStaff && actorName) body = actorName + ' :\n' + body
|
||||||
|
const w = await legacyWrite({ action: 'post', ticket_id: ticketId, msg: body, public: isPublic ? 1 : '', actor_staff_id: actorStaff || '' }).catch(e => ({ data: { ok: false, error: e.message } }))
|
||||||
return (w && w.data) || { ok: false, error: 'no response' }
|
return (w && w.data) || { ok: false, error: 'no response' }
|
||||||
}
|
}
|
||||||
// RETOUR AU POOL : désassigne le Dispatch Job dans ERPNext PUIS — si le job était poussé à un tech —
|
// RETOUR AU POOL : désassigne le Dispatch Job dans ERPNext PUIS — si le job était poussé à un tech —
|
||||||
|
|
@ -431,7 +453,7 @@ async function buildJob (t) {
|
||||||
const svcAddr = [t.dv_addr, t.dv_city, t.dv_zip].filter(Boolean).join(', ')
|
const svcAddr = [t.dv_addr, t.dv_city, t.dv_zip].filter(Boolean).join(', ')
|
||||||
const billAddr = [t.address1, t.address2, t.city, t.state, t.zip].filter(Boolean).join(', ')
|
const billAddr = [t.address1, t.address2, t.city, t.state, t.zip].filter(Boolean).join(', ')
|
||||||
const addr = svcAddr || billAddr
|
const addr = svcAddr || billAddr
|
||||||
let subject = (t.subject || '').trim() || ([t.dept, cname].filter(Boolean).join(' — '))
|
let subject = decodeEntities(t.subject || '').trim() || ([t.dept, cname].filter(Boolean).join(' — '))
|
||||||
const idTag = ' · #' + t.id // n° de ticket legacy visible dans le TITRE (référence croisée osTicket) — réserve la place pour ne pas le tronquer
|
const idTag = ' · #' + t.id // n° de ticket legacy visible dans le TITRE (référence croisée osTicket) — réserve la place pour ne pas le tronquer
|
||||||
subject = (subject.length + idTag.length > 140 ? subject.slice(0, 140 - idTag.length) : subject) + idTag
|
subject = (subject.length + idTag.length > 140 ? subject.slice(0, 140 - idTag.length) : subject) + idTag
|
||||||
|
|
||||||
|
|
@ -942,7 +964,7 @@ async function ticketThread (legacyId) {
|
||||||
fromClient: !r.staff_id, // staff_id 0/null = message du client (utile pour le fil fusionné P3/P4)
|
fromClient: !r.staff_id, // staff_id 0/null = message du client (utile pour le fil fusionné P3/P4)
|
||||||
text: stripHtml(r.msg, 6000),
|
text: stripHtml(r.msg, 6000),
|
||||||
})).filter(m => m.text)
|
})).filter(m => m.text)
|
||||||
return { ok: true, ticket: id, subject: t.subject || '', status: t.status || '', contact, count: messages.length, messages }
|
return { ok: true, ticket: id, subject: decodeEntities(t.subject || ''), status: t.status || '', contact, count: messages.length, messages }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fil + CONTACT d'un Dispatch Job, source unifiée : osTicket si legacy_ticket_id, SINON fiche client ERPNext (jobs natifs ex. FR-…).
|
// Fil + CONTACT d'un Dispatch Job, source unifiée : osTicket si legacy_ticket_id, SINON fiche client ERPNext (jobs natifs ex. FR-…).
|
||||||
|
|
@ -967,7 +989,7 @@ async function jobThread (jobName) {
|
||||||
text: String(m.text || (m.html ? String(m.html).replace(/<[^>]+>/g, ' ') : '')).replace(/\s+/g, ' ').trim(),
|
text: String(m.text || (m.html ? String(m.html).replace(/<[^>]+>/g, ' ') : '')).replace(/\s+/g, ' ').trim(),
|
||||||
})).filter(m => m.text)
|
})).filter(m => m.text)
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
return { ok: true, ticket: null, subject: job.subject || '', status: '', contact, count: messages.length, messages, source: 'erpnext' }
|
return { ok: true, ticket: null, subject: decodeEntities(job.subject || ''), status: '', contact, count: messages.length, messages, source: 'erpnext' }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── VEILLE Legacy → Ops (poll léger → SSE) ──
|
// ── VEILLE Legacy → Ops (poll léger → SSE) ──
|
||||||
|
|
@ -1172,7 +1194,7 @@ async function legacyTechTickets (staffId) {
|
||||||
FROM ticket t LEFT JOIN ticket_dept dd ON dd.id = t.dept_id LEFT JOIN account a ON a.id = t.account_id
|
FROM ticket t LEFT JOIN ticket_dept dd ON dd.id = t.dept_id LEFT JOIN account a ON a.id = t.account_id
|
||||||
WHERE t.status = 'open' AND t.assign_to = ?
|
WHERE t.status = 'open' AND t.assign_to = ?
|
||||||
ORDER BY (t.due_date IS NULL OR t.due_date = 0), t.due_date ASC LIMIT 400`, [sid])
|
ORDER BY (t.due_date IS NULL OR t.due_date = 0), t.due_date ASC LIMIT 400`, [sid])
|
||||||
const decode = (s) => String(s == null ? '' : s).replace(/�?39;/g, "'").replace(/&/g, '&').replace(/"/g, '"').replace(/&#(\d+);/g, (_, n) => { try { return String.fromCodePoint(+n) } catch (e) { return '' } })
|
const decode = decodeEntities
|
||||||
const FIELD_RE = /install|r[eé]paration|t[eé]l[eé]|monteur|fusion|d[eé]sinstall/i
|
const FIELD_RE = /install|r[eé]paration|t[eé]l[eé]|monteur|fusion|d[eé]sinstall/i
|
||||||
const all = (rows || []).map(r => {
|
const all = (rows || []).map(r => {
|
||||||
const subject = decode(r.subject)
|
const subject = decode(r.subject)
|
||||||
|
|
@ -1233,7 +1255,7 @@ async function legacyWindowLoad (start, days) {
|
||||||
const isoSet = new Set(isos)
|
const isoSet = new Set(isos)
|
||||||
const startUnix = Math.floor(Date.parse(start + 'T00:00:00Z') / 1000)
|
const startUnix = Math.floor(Date.parse(start + 'T00:00:00Z') / 1000)
|
||||||
const lo = startUnix - 2 * 86400, hi = startUnix + (n + 2) * 86400
|
const lo = startUnix - 2 * 86400, hi = startUnix + (n + 2) * 86400
|
||||||
const decode = (s) => String(s == null ? '' : s).replace(/�?39;/g, "'").replace(/&/g, '&').replace(/&#(\d+);/g, (_, k) => { try { return String.fromCodePoint(+k) } catch (e) { return '' } })
|
const decode = decodeEntities
|
||||||
// Tickets lus sur F LIVE via le pont PHP (le miroir n'est PAS rafraîchi pour `ticket` → périmé). Repli miroir si pont indispo.
|
// Tickets lus sur F LIVE via le pont PHP (le miroir n'est PAS rafraîchi pour `ticket` → périmé). Repli miroir si pont indispo.
|
||||||
let rows = []
|
let rows = []
|
||||||
try {
|
try {
|
||||||
|
|
@ -1593,7 +1615,7 @@ async function ticketLookup (id) {
|
||||||
let acc = null; if (t.account_id) { try { const [a] = await p.query('SELECT id, first_name, last_name, company FROM account WHERE id=?', [t.account_id]); acc = a[0] || null } catch (e) {} }
|
let acc = null; if (t.account_id) { try { const [a] = await p.query('SELECT id, first_name, last_name, company FROM account WHERE id=?', [t.account_id]); acc = a[0] || null } catch (e) {} }
|
||||||
let phone = null; if (!t.account_id || !dv) { try { const pm = await accountByPhone(p, t.subject); if (pm) phone = { account_id: pm.account_id, customer: pm.customer_name, address: [pm.dv.address1, pm.dv.city, pm.dv.zip].filter(Boolean).join(', ') } } catch (e) {} }
|
let phone = null; if (!t.account_id || !dv) { try { const pm = await accountByPhone(p, t.subject); if (pm) phone = { account_id: pm.account_id, customer: pm.customer_name, address: [pm.dv.address1, pm.dv.city, pm.dv.zip].filter(Boolean).join(', ') } } catch (e) {} }
|
||||||
let dj = null; try { const x = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '=', String(tid)]], fields: ['name', 'status', 'latitude', 'longitude', 'address', 'assigned_tech', 'customer', 'scheduled_date'], limit: 1 }); dj = x[0] || null } catch (e) {}
|
let dj = null; try { const x = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '=', String(tid)]], fields: ['name', 'status', 'latitude', 'longitude', 'address', 'assigned_tech', 'customer', 'scheduled_date'], limit: 1 }); dj = x[0] || null } catch (e) {}
|
||||||
return { ok: true, found: true, ticket: { id: t.id, subject: t.subject, status: t.status, account_id: t.account_id || 0, delivery_id: t.delivery_id || 0, assign_to: t.assign_to || 0 }, account: acc, service_address: dv, phone_match: phone, dispatch_job: dj }
|
return { ok: true, found: true, ticket: { id: t.id, subject: decodeEntities(t.subject), status: t.status, account_id: t.account_id || 0, delivery_id: t.delivery_id || 0, assign_to: t.assign_to || 0 }, account: acc, service_address: dv, phone_match: phone, dispatch_job: dj }
|
||||||
}
|
}
|
||||||
|
|
||||||
// BACKFILL de la trace tech perdue : les Dispatch Jobs issus du pont SANS assigned_tech (annulés/terminés « Non
|
// BACKFILL de la trace tech perdue : les Dispatch Jobs issus du pont SANS assigned_tech (annulés/terminés « Non
|
||||||
|
|
@ -1787,7 +1809,7 @@ async function handle (req, res, method, path) {
|
||||||
async function reconcileLegacyJobs (opts = {}) {
|
async function reconcileLegacyJobs (opts = {}) {
|
||||||
const apply = opts.confirm === 'RECONCILE'
|
const apply = opts.confirm === 'RECONCILE'
|
||||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
|
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
|
||||||
const decode = (s) => String(s == null ? '' : s).replace(/�?39;/g, "'").replace(/&/g, '&').replace(/&#(\d+);/g, (_, k) => { try { return String.fromCodePoint(+k) } catch (e) { return '' } })
|
const decode = decodeEntities
|
||||||
const techs = await erp.list('Dispatch Technician', { fields: ['name', 'technician_id'], limit: 800 })
|
const techs = await erp.list('Dispatch Technician', { fields: ['name', 'technician_id'], limit: 800 })
|
||||||
const techToStaff = {}; const staffToTech = {}
|
const techToStaff = {}; const staffToTech = {}
|
||||||
for (const t of (techs || [])) { const m = String(t.technician_id || '').match(/(\d+)$/); if (!m) continue; const s = Number(m[1]); techToStaff[t.technician_id] = s; if (s >= 2 && s !== TARGO_TECH_STAFF_ID) staffToTech[s] = t.technician_id }
|
for (const t of (techs || [])) { const m = String(t.technician_id || '').match(/(\d+)$/); if (!m) continue; const s = Number(m[1]); techToStaff[t.technician_id] = s; if (s >= 2 && s !== TARGO_TECH_STAFF_ID) staffToTech[s] = t.technician_id }
|
||||||
|
|
@ -1837,4 +1859,4 @@ async function reconcileLegacyJobs (opts = {}) {
|
||||||
return { ok: true, apply: true, applied: done, cancelled: toCancel.length, reassigned: plan.reassign.length, pool_held: opts.purgePool ? 0 : plan.cancel_pool.length, error_count: errs.length, errors: errs.slice(0, 50), counts }
|
return { ok: true, apply: true, applied: done, cancelled: toCancel.length, reassigned: plan.reassign.length, pool_held: opts.purgePool ? 0 : plan.cancel_pool.length, error_count: errs.length, errors: errs.slice(0, 50), counts }
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { handle, sync, reimportAddresses, fillMissingCoords, fixGeocoding, purgeStaleOrphans, pushAssignments, returnToPool, watchLegacy, techSyncReport, techSyncApply, mineDurations, legacyTechTickets, legacyWindowLoad, ingestAssigned, reconcileLegacyJobs, startSync, stopSync, fetchTargoTickets, coord, prio, startTime, jobType, inTerritory, geocodeRQA, persistGeocodeToSL, reverseNearestFibre } // parseurs purs exposés pour les tests
|
module.exports = { handle, sync, reimportAddresses, fillMissingCoords, fixGeocoding, purgeStaleOrphans, pushAssignments, returnToPool, postTicketLegacy, ticketThread, watchLegacy, techSyncReport, techSyncApply, mineDurations, legacyTechTickets, legacyWindowLoad, ingestAssigned, reconcileLegacyJobs, startSync, stopSync, fetchTargoTickets, coord, prio, startTime, jobType, inTerritory, geocodeRQA, persistGeocodeToSL, reverseNearestFibre } // parseurs purs exposés pour les tests
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,18 @@ function setJobLevel (name, level) {
|
||||||
try { fs.mkdirSync(path.dirname(JOB_LEVELS_FILE), { recursive: true }) } catch (e) {}
|
try { fs.mkdirSync(path.dirname(JOB_LEVELS_FILE), { recursive: true }) } catch (e) {}
|
||||||
fs.writeFileSync(JOB_LEVELS_FILE, JSON.stringify(m)); return m
|
fs.writeFileSync(JOB_LEVELS_FILE, JSON.stringify(m)); return m
|
||||||
}
|
}
|
||||||
|
// Compétences REQUISES par job = LISTE (store hub durable, comme les niveaux). `required_skill` n'est PAS un champ ERPNext (enrichi).
|
||||||
|
// Le solveur exige que le tech ait TOUTES les compétences de la liste ; la 1re = principale (affichage/couleur/icône). Vide → repli dépt.
|
||||||
|
const JOB_SKILLS_FILE = path.join(__dirname, '..', 'data', 'job-skills.json')
|
||||||
|
function getJobSkillsMap () { try { return JSON.parse(fs.readFileSync(JOB_SKILLS_FILE, 'utf8')) || {} } catch { return {} } }
|
||||||
|
function getJobSkills (name) { const v = getJobSkillsMap()[String(name)]; return Array.isArray(v) ? v.filter(Boolean) : [] }
|
||||||
|
function setJobSkills (name, list) {
|
||||||
|
const m = getJobSkillsMap()
|
||||||
|
const arr = [...new Set((Array.isArray(list) ? list : String(list || '').split(',')).map(s => String(s).trim()).filter(Boolean))]
|
||||||
|
if (arr.length) m[String(name)] = arr; else delete m[String(name)]
|
||||||
|
try { fs.mkdirSync(path.dirname(JOB_SKILLS_FILE), { recursive: true }) } catch (e) {}
|
||||||
|
fs.writeFileSync(JOB_SKILLS_FILE, JSON.stringify(m)); return m
|
||||||
|
}
|
||||||
// Priorité de job = champ ERPNext standard (low/medium/high), édité via /roster/job/update (comme le pool). Plus de store parallèle.
|
// Priorité de job = champ ERPNext standard (low/medium/high), édité via /roster/job/update (comme le pool). Plus de store parallèle.
|
||||||
// 🖥 Job « SANS DÉPLACEMENT » (configurable à distance, ex. boîtier tel via ACS) : exclu des tournées (aucun arrêt), à faire du bureau.
|
// 🖥 Job « SANS DÉPLACEMENT » (configurable à distance, ex. boîtier tel via ACS) : exclu des tournées (aucun arrêt), à faire du bureau.
|
||||||
const JOB_REMOTE_FILE = path.join(__dirname, '..', 'data', 'job-remote.json')
|
const JOB_REMOTE_FILE = path.join(__dirname, '..', 'data', 'job-remote.json')
|
||||||
|
|
@ -187,9 +199,12 @@ async function optimizePlan (body) {
|
||||||
const prBoost = p => (p === 'urgent' || p === 'high') ? 400000 : (p === 'low' ? -60000 : 0)
|
const prBoost = p => (p === 'urgent' || p === 'high') ? 400000 : (p === 'low' ? -60000 : 0)
|
||||||
const prEarly = p => (p === 'urgent' || p === 'high') ? 10 : 0
|
const prEarly = p => (p === 'urgent' || p === 'high') ? 10 : 0
|
||||||
const techs = techsAll.filter(t => !wantTech || wantTech.has(t.id))
|
const techs = techsAll.filter(t => !wantTech || wantTech.has(t.id))
|
||||||
|
// Compétences requises d'un job (LISTE) ; `required_skill` = principale (1re). techCovers = le tech les a TOUTES.
|
||||||
|
const skillsOf = j => (Array.isArray(j.required_skills) && j.required_skills.length) ? j.required_skills.filter(Boolean) : (j.required_skill ? [j.required_skill] : [])
|
||||||
|
const techCovers = (t, j) => { const req = skillsOf(j); return !req.length || (t && req.every(s => (t.skills || []).includes(s))) }
|
||||||
const entryOf = (j, iso, dur) => ({
|
const entryOf = (j, iso, dur) => ({
|
||||||
jobName: j.name, subject: j.subject || j.service_type || j.name, customer: j.customer_name || j.location_label || '',
|
jobName: j.name, subject: j.subject || j.service_type || j.name, customer: j.customer_name || j.location_label || '',
|
||||||
iso, dur: Math.round(dur * 100) / 100, skill: j.required_skill || '', reqLevel: Number(j.required_level) || 1,
|
iso, dur: Math.round(dur * 100) / 100, skill: j.required_skill || '', skills: skillsOf(j), reqLevel: Number(j.required_level) || 1,
|
||||||
lat: (j.latitude != null && isFinite(+j.latitude)) ? +j.latitude : null, lon: (j.longitude != null && isFinite(+j.longitude)) ? +j.longitude : null,
|
lat: (j.latitude != null && isFinite(+j.latitude)) ? +j.latitude : null, lon: (j.longitude != null && isFinite(+j.longitude)) ? +j.longitude : null,
|
||||||
priority: String(j.priority || '').toLowerCase(), remote: !!j.remote, parent: j.parent_job || null,
|
priority: String(j.priority || '').toLowerCase(), remote: !!j.remote, parent: j.parent_job || null,
|
||||||
})
|
})
|
||||||
|
|
@ -230,7 +245,7 @@ async function optimizePlan (body) {
|
||||||
if (hit) {
|
if (hit) {
|
||||||
const t = techs.find(x => x.id === hit.techId)
|
const t = techs.find(x => x.id === hit.techId)
|
||||||
const dur = durOf(j)
|
const dur = durOf(j)
|
||||||
plan.push({ ...entryOf(j, iso, dur), techId: hit.techId, techName: hit.techName, pinned: true, coordWith: hit.subject, capable: !j.required_skill || !t || (t.skills || []).includes(j.required_skill) })
|
plan.push({ ...entryOf(j, iso, dur), techId: hit.techId, techName: hit.techName, pinned: true, coordWith: hit.subject, capable: techCovers(t, j) })
|
||||||
usedMin[hit.techId] = (usedMin[hit.techId] || 0) + Math.round(dur * 60)
|
usedMin[hit.techId] = (usedMin[hit.techId] || 0) + Math.round(dur * 60)
|
||||||
} else rest.push(j)
|
} else rest.push(j)
|
||||||
}
|
}
|
||||||
|
|
@ -257,7 +272,8 @@ async function optimizePlan (body) {
|
||||||
lat: (j0.latitude != null && isFinite(+j0.latitude)) ? +j0.latitude : null,
|
lat: (j0.latitude != null && isFinite(+j0.latitude)) ? +j0.latitude : null,
|
||||||
lon: (j0.longitude != null && isFinite(+j0.longitude)) ? +j0.longitude : null,
|
lon: (j0.longitude != null && isFinite(+j0.longitude)) ? +j0.longitude : null,
|
||||||
service_min: Math.max(5, Math.round(g.reduce((s, j) => s + durOf(j), 0) * 60)),
|
service_min: Math.max(5, Math.round(g.reduce((s, j) => s + durOf(j), 0) * 60)),
|
||||||
skill: (g.find(j => j.required_skill) || {}).required_skill || '',
|
skill: (g.find(j => j.required_skill) || {}).required_skill || '', // principale (rang spécialiste)
|
||||||
|
skills: [...new Set(g.flatMap(j => skillsOf(j)))], // UNION des compétences requises du groupe → le tech doit TOUTES les avoir
|
||||||
priority_boost: Math.max(...prios.map(prBoost)),
|
priority_boost: Math.max(...prios.map(prBoost)),
|
||||||
urgent_weight: Math.max(...prios.map(prEarly)),
|
urgent_weight: Math.max(...prios.map(prEarly)),
|
||||||
...(w ? { tw_start_min: w.tw_start_min, tw_end_min: w.tw_end_min } : {}),
|
...(w ? { tw_start_min: w.tw_start_min, tw_end_min: w.tw_end_min } : {}),
|
||||||
|
|
@ -271,7 +287,7 @@ async function optimizePlan (body) {
|
||||||
for (const st of (rt.stops || [])) {
|
for (const st of (rt.stops || [])) {
|
||||||
const ui = Number(String(st.job_id).slice(1)); const g = units[ui]; if (!g) continue
|
const ui = Number(String(st.job_id).slice(1)); const g = units[ui]; if (!g) continue
|
||||||
placed.add(st.job_id)
|
placed.add(st.job_id)
|
||||||
for (const j of g) plan.push({ ...entryOf(j, iso, durOf(j)), techId: t.id, techName: t.name, capable: !j.required_skill || (t.skills || []).includes(j.required_skill), sameAddrN: g.length })
|
for (const j of g) plan.push({ ...entryOf(j, iso, durOf(j)), techId: t.id, techName: t.name, capable: techCovers(t, j), sameAddrN: g.length })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const uid of (res.unassigned || [])) {
|
for (const uid of (res.unassigned || [])) {
|
||||||
|
|
@ -419,6 +435,7 @@ async function ensureShiftTemplate (start, end, cache) { // start/end 'HH:MM'
|
||||||
const r = await retryWrite(() => erp.create('Shift Template', { template_name: start + 'h–' + end + 'h', start_time: st, end_time: et, hours, on_call: 0, default_required: 1, color: '#1976d2' }))
|
const r = await retryWrite(() => erp.create('Shift Template', { template_name: start + 'h–' + end + 'h', start_time: st, end_time: et, hours, on_call: 0, default_required: 1, color: '#1976d2' }))
|
||||||
const name = (r && (r.name || (r.data && r.data.name))) || null
|
const name = (r && (r.name || (r.data && r.data.name))) || null
|
||||||
if (name) cache[key] = name
|
if (name) cache[key] = name
|
||||||
|
else log('[materialize] ensureShiftTemplate FAIL ' + key + ' → ' + JSON.stringify(r).slice(0, 220))
|
||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
async function materializeShifts ({ dryRun = true, weeks = 4, techId = null } = {}) {
|
async function materializeShifts ({ dryRun = true, weeks = 4, techId = null } = {}) {
|
||||||
|
|
@ -430,7 +447,8 @@ async function materializeShifts ({ dryRun = true, weeks = 4, techId = null } =
|
||||||
const techs = techsAll.filter(t => t.status !== PAUSE_STATUS && t.weekly_schedule && (!techId || t.id === techId))
|
const techs = techsAll.filter(t => t.status !== PAUSE_STATUS && t.weekly_schedule && (!techId || t.id === techId))
|
||||||
const rowsByKey = {}; for (const a of existing) { (rowsByKey[a.tech + '|' + a.date] = rowsByKey[a.tech + '|' + a.date] || []).push(a) }
|
const rowsByKey = {}; for (const a of existing) { (rowsByKey[a.tech + '|' + a.date] = rowsByKey[a.tech + '|' + a.date] || []).push(a) }
|
||||||
const tplCache = {}
|
const tplCache = {}
|
||||||
let created = 0, updated = 0, deleted = 0, keptManual = 0, skipHoliday = 0, skipVacation = 0
|
let created = 0, updated = 0, deleted = 0, keptManual = 0, skipHoliday = 0, skipVacation = 0, tplNull = 0, createFail = 0
|
||||||
|
log('[materialize] start=' + start + ' days=' + days + ' techs=' + techs.length + '/' + techsAll.length + ' avec_patron dryRun=' + dryRun)
|
||||||
for (const t of techs) {
|
for (const t of techs) {
|
||||||
const sched = t.weekly_schedule || {}
|
const sched = t.weekly_schedule || {}
|
||||||
for (const iso of dates) {
|
for (const iso of dates) {
|
||||||
|
|
@ -445,17 +463,33 @@ async function materializeShifts ({ dryRun = true, weeks = 4, techId = null } =
|
||||||
for (const r of pat) { if (dryRun) deleted++; else { const rr = await erp.remove('Shift Assignment', r.name); if (rr && rr.ok) deleted++ } }
|
for (const r of pat) { if (dryRun) deleted++; else { const rr = await erp.remove('Shift Assignment', r.name); if (rr && rr.ok) deleted++ } }
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const tplName = await ensureShiftTemplate(day.start, day.end, tplCache); if (!tplName) continue
|
const tplName = await ensureShiftTemplate(day.start, day.end, tplCache); if (!tplName) { tplNull++; continue }
|
||||||
if (pat.some(r => r.shift === tplName)) continue // déjà bon (idempotent)
|
if (pat.some(r => r.shift === tplName)) continue // déjà bon (idempotent)
|
||||||
const hours = Math.round((_timeH(day.end) - _timeH(day.start)) * 100) / 100
|
const hours = Math.round((_timeH(day.end) - _timeH(day.start)) * 100) / 100
|
||||||
if (pat.length) { // patron changé → mettre à jour le quart pattern (+ nettoyer doublons)
|
if (pat.length) { // patron changé → mettre à jour le quart pattern (+ nettoyer doublons)
|
||||||
if (dryRun) updated++; else { const rr = await erp.update('Shift Assignment', pat[0].name, { shift_template: tplName, hours }); if (rr && rr.ok) updated++ }
|
if (dryRun) updated++; else { const rr = await erp.update('Shift Assignment', pat[0].name, { shift_template: tplName, hours }); if (rr && rr.ok) updated++ }
|
||||||
for (const extra of pat.slice(1)) { if (!dryRun) await erp.remove('Shift Assignment', extra.name) }
|
for (const extra of pat.slice(1)) { if (!dryRun) await erp.remove('Shift Assignment', extra.name) }
|
||||||
} else if (dryRun) created++
|
} else if (dryRun) created++
|
||||||
else { const rr = await retryWrite(() => erp.create('Shift Assignment', { technician: t.id, technician_name: t.name, assignment_date: iso, shift_template: tplName, hours, status: 'Publié', source: 'pattern' })); if (rr && rr.ok) created++ }
|
else { const rr = await retryWrite(() => erp.create('Shift Assignment', { technician: t.id, technician_name: t.name, assignment_date: iso, shift_template: tplName, hours, status: 'Publié', source: 'pattern' })); if (rr && rr.ok) created++; else { createFail++; if (createFail <= 3) log('[materialize] SA create FAIL ' + t.name + ' ' + iso + ' → ' + JSON.stringify(rr).slice(0, 220)) } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { ok: true, dryRun, weeks: weeksN, techs: techs.length, created, updated, deleted, kept_manual: keptManual, skipped_holiday: skipHoliday, skipped_vacation: skipVacation }
|
const result = { ok: true, dryRun, weeks: weeksN, techs: techs.length, created, updated, deleted, kept_manual: keptManual, skipped_holiday: skipHoliday, skipped_vacation: skipVacation, tpl_null: tplNull, create_fail: createFail }
|
||||||
|
log('[materialize] done ' + JSON.stringify(result))
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRON : matérialise les quarts récurrents (weekly_schedule → Shift Assignment) sur un HORIZON GLISSANT.
|
||||||
|
// Sans ça, l'horaire récurrent n'existe que pour la fenêtre capturée au clic (= le bug « n'apparaît pas dans le roster »).
|
||||||
|
// Idempotent (saute les lignes pattern déjà bonnes, respecte les overrides manuels). Désactivable : SHIFT_MATERIALIZE_CRON=off.
|
||||||
|
let _matTimer = null
|
||||||
|
function startShiftMaterializer () {
|
||||||
|
if (String(process.env.SHIFT_MATERIALIZE_CRON || 'on').toLowerCase() === 'off') { log('[materialize] cron désactivé (SHIFT_MATERIALIZE_CRON=off)'); return }
|
||||||
|
const run = () => materializeShifts({ dryRun: false, weeks: 6 })
|
||||||
|
.then(r => log('[materialize] cron ' + JSON.stringify({ techs: r.techs, created: r.created, updated: r.updated, deleted: r.deleted, fail: r.create_fail })))
|
||||||
|
.catch(e => log('[materialize] cron error: ' + e.message))
|
||||||
|
setTimeout(run, 90 * 1000) // 1er passage ~90 s après le boot (ERPNext prêt)
|
||||||
|
_matTimer = setInterval(run, 24 * 60 * 60 * 1000) // quotidien → horizon 6 semaines qui avance
|
||||||
|
log('[materialize] cron armé (quotidien, horizon 6 semaines)')
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Construit le payload du solveur + l'appelle ─────────────────────────────
|
// ── Construit le payload du solveur + l'appelle ─────────────────────────────
|
||||||
|
|
@ -683,8 +717,13 @@ async function buildUnassigned () {
|
||||||
const rows = await erp.list('Dispatch Job', { filters: [['status', 'in', ['open', 'On Hold']]], fields: ['name', 'creation', 'subject', 'customer_name', 'service_location', 'service_type', 'job_type', 'assigned_group', 'legacy_dept', 'legacy_detail', 'legacy_ticket_id', 'legacy_activation_url', 'priority', 'duration_h', 'scheduled_date', 'status', 'depends_on', 'parent_job', 'step_order', 'assigned_tech', 'latitude', 'longitude', 'address'], orderBy: 'modified desc', limit: 400 })
|
const rows = await erp.list('Dispatch Job', { filters: [['status', 'in', ['open', 'On Hold']]], fields: ['name', 'creation', 'subject', 'customer_name', 'service_location', 'service_type', 'job_type', 'assigned_group', 'legacy_dept', 'legacy_detail', 'legacy_ticket_id', 'legacy_activation_url', 'priority', 'duration_h', 'scheduled_date', 'status', 'depends_on', 'parent_job', 'step_order', 'assigned_tech', 'latitude', 'longitude', 'address'], orderBy: 'modified desc', limit: 400 })
|
||||||
const jobs = (rows || []).filter(j => !j.assigned_tech)
|
const jobs = (rows || []).filter(j => !j.assigned_tech)
|
||||||
const chars = readJobChar().items
|
const chars = readJobChar().items
|
||||||
const jlv = getJobLevels(); const jrm = getJobRemote()
|
const jlv = getJobLevels(); const jrm = getJobRemote(); const jsk = getJobSkillsMap()
|
||||||
for (const j of jobs) { j.required_skill = skillForJob(j) || deptToSkill(j.legacy_dept || j.job_type || j.subject); j.required_level = jlv[j.name] || 0; j.remote = !!jrm[j.name]; const est = estimateForJob(j, chars); j.est_min = est.minutes; j.est_labels = est.labels }
|
for (const j of jobs) {
|
||||||
|
const ov = jsk[j.name]; const derived = skillForJob(j) || deptToSkill(j.legacy_dept || j.job_type || j.subject)
|
||||||
|
j.required_skills = (Array.isArray(ov) && ov.length) ? ov.filter(Boolean) : (derived ? [derived] : []) // LISTE (override store, sinon dérivé)
|
||||||
|
j.required_skill = j.required_skills[0] || '' // principale = 1re (affichage/couleur/icône + rétro-compat)
|
||||||
|
j.required_level = jlv[j.name] || 0; j.remote = !!jrm[j.name]; const est = estimateForJob(j, chars); j.est_min = est.minutes; j.est_labels = est.labels
|
||||||
|
}
|
||||||
await attachLocations(jobs)
|
await attachLocations(jobs)
|
||||||
log(`pool: construit ${jobs.length} jobs en ${nowMs() - t0}ms`) // mesure réelle (requête + enrichissement)
|
log(`pool: construit ${jobs.length} jobs en ${nowMs() - t0}ms`) // mesure réelle (requête + enrichissement)
|
||||||
return jobs
|
return jobs
|
||||||
|
|
@ -941,21 +980,36 @@ async function handleFieldTech (req, res, method, path, url) {
|
||||||
const isPublic = !!b.public
|
const isPublic = !!b.public
|
||||||
let posted = false, sentToCustomer = false
|
let posted = false, sentToCustomer = false
|
||||||
try {
|
try {
|
||||||
const j = await erp.get('Dispatch Job', name, { fields: ['legacy_ticket_id'] })
|
const j = await erp.get('Dispatch Job', name, { fields: ['legacy_ticket_id', 'assigned_tech'] })
|
||||||
const lid = j && j.legacy_ticket_id
|
const lid = j && j.legacy_ticket_id
|
||||||
|
// Note au nom du TECHNICIEN (pas « Tech Targo »). Courriel → compte staff osTicket si présent (auteur = son nom) ;
|
||||||
|
// nom → repli dans le corps si aucun compte legacy. Le technicien = celui assigné au job.
|
||||||
|
let techEmail = '', techName = ''
|
||||||
|
if (j && j.assigned_tech) { try { const tr = await erp.list('Dispatch Technician', { filters: [['name', '=', j.assigned_tech]], fields: ['full_name', 'email'], limit: 1 }); if (tr && tr[0]) { techEmail = tr[0].email || ''; techName = tr[0].full_name || '' } } catch (e) {} }
|
||||||
if (lid) {
|
if (lid) {
|
||||||
// Fil legacy (osTicket) = ce que le volet job affiche. public=1 → réponse publique (courriel client) ; sinon note interne.
|
// Fil legacy (osTicket) = ce que le volet job affiche. Corps = le texte brut du tech (aucune étiquette « [Note tech] » — bruit inutile) :
|
||||||
const msg = isPublic ? text : ('🔧 [Note tech — interne]\n' + text)
|
// note interne par défaut, publique (courriel client) si coché. L'attribution se fait par l'auteur, pas par un préfixe.
|
||||||
const r = await require('./legacy-dispatch-sync').postTicketLegacy(lid, msg, isPublic, '')
|
const r = await require('./legacy-dispatch-sync').postTicketLegacy(lid, text, isPublic, techEmail, techName)
|
||||||
posted = !!(r && r.ok !== false); sentToCustomer = isPublic && posted
|
posted = !!(r && r.ok !== false); sentToCustomer = isPublic && posted
|
||||||
} else {
|
} else {
|
||||||
// Job natif (pas de ticket legacy) → Comment ERPNext sur le Dispatch Job.
|
// Job natif (pas de ticket legacy) → Comment ERPNext sur le Dispatch Job, préfixé du nom du tech (l'auteur ERPNext = compte service).
|
||||||
await erp.create('Comment', { comment_type: 'Comment', reference_doctype: 'Dispatch Job', reference_name: name, content: (isPublic ? '📣 [Public] ' : '🔧 [Note tech] ') + text })
|
await erp.create('Comment', { comment_type: 'Comment', reference_doctype: 'Dispatch Job', reference_name: name, content: (techName ? techName + ' : ' : '') + text })
|
||||||
posted = true
|
posted = true
|
||||||
}
|
}
|
||||||
} catch (e) { return json(res, 500, { ok: false, error: e.message }) }
|
} catch (e) { return json(res, 500, { ok: false, error: e.message }) }
|
||||||
return json(res, 200, { ok: posted, public: isPublic, sentToCustomer })
|
return json(res, 200, { ok: posted, public: isPublic, sentToCustomer })
|
||||||
}
|
}
|
||||||
|
// Lire le FIL du ticket depuis l'app terrain (near-realtime : le client poll pendant que le panneau est ouvert).
|
||||||
|
if (path === '/field/thread' && method === 'GET') {
|
||||||
|
const name = fieldVerify(token); if (!name) return json(res, 403, { ok: false, error: 'lien invalide' })
|
||||||
|
try {
|
||||||
|
const j = await erp.get('Dispatch Job', name, { fields: ['legacy_ticket_id'] })
|
||||||
|
const lid = j && j.legacy_ticket_id
|
||||||
|
if (!lid) return json(res, 200, { ok: true, messages: [] })
|
||||||
|
const th = await require('./legacy-dispatch-sync').ticketThread(lid)
|
||||||
|
return json(res, 200, (th && th.ok) ? { ok: true, subject: th.subject, status: th.status, messages: th.messages || [] } : { ok: false, error: (th && th.error) || 'fil indisponible' })
|
||||||
|
} catch (e) { return json(res, 500, { ok: false, error: e.message }) }
|
||||||
|
}
|
||||||
if ((path === '/field' || path === '/field/app') && method === 'GET') return serveFieldApp(res) // PWA technicien (carte + tickets + photos + scan)
|
if ((path === '/field' || path === '/field/app') && method === 'GET') return serveFieldApp(res) // PWA technicien (carte + tickets + photos + scan)
|
||||||
if (path === '/field/tech' && method === 'GET') { // jobs du jour (+ demain) du tech, via token PAR TECH
|
if (path === '/field/tech' && method === 'GET') { // jobs du jour (+ demain) du tech, via token PAR TECH
|
||||||
const tech = techFieldVerify(token); if (!tech) return json(res, 403, { ok: false, error: 'lien invalide' })
|
const tech = techFieldVerify(token); if (!tech) return json(res, 403, { ok: false, error: 'lien invalide' })
|
||||||
|
|
@ -1542,6 +1596,52 @@ async function handle (req, res, method, path, url) {
|
||||||
setJobLevel(b.name, b.level); invalidatePool() // le pool porte required_level → rafraîchir
|
setJobLevel(b.name, b.level); invalidatePool() // le pool porte required_level → rafraîchir
|
||||||
return json(res, 200, { ok: true, name: b.name, level: Math.max(0, Math.min(5, Math.round(Number(b.level) || 0))) })
|
return json(res, 200, { ok: true, name: b.name, level: Math.max(0, Math.min(5, Math.round(Number(b.level) || 0))) })
|
||||||
}
|
}
|
||||||
|
if (path === '/roster/job-skills' && method === 'POST') { // compétences requises PERSISTANTES par job (LISTE, store hub) → dispatch auto
|
||||||
|
const b = await parseBody(req); if (!b.name) return json(res, 400, { error: 'name requis' })
|
||||||
|
const m = setJobSkills(b.name, b.skills); invalidatePool() // le pool porte required_skills/required_skill → rafraîchir
|
||||||
|
return json(res, 200, { ok: true, name: b.name, skills: m[String(b.name)] || [] })
|
||||||
|
}
|
||||||
|
// Répondre au fil du billet DEPUIS LE VOLET JOB (dispatcher authentifié) — même mécanique que l'app terrain, mais l'acteur = l'agent connecté.
|
||||||
|
// Note INTERNE par défaut (jamais au client) ; publique (courriel client) SEULEMENT si public=true.
|
||||||
|
// #5 — Bloc RÉSERVÉ : réserve le temps d'un tech pour une tâche/projet = Dispatch Job job_type='Réservation', priorité MOYENNE,
|
||||||
|
// assigné, avec heure+durée → occupe l'occupation ⇒ dé-priorise le tech au dispatch auto ET soustrait des créneaux. « Soft » :
|
||||||
|
// la recherche de créneau en mode urgence/réparation IGNORE ces blocs (dispatch.suggestSlots ignoreReserved) → réparations/urgences passent.
|
||||||
|
if (path === '/roster/reserve' && method === 'POST') {
|
||||||
|
const b = await parseBody(req)
|
||||||
|
if (!b.tech || !b.date) return json(res, 400, { ok: false, error: 'tech + date requis' })
|
||||||
|
try {
|
||||||
|
const ref = await require('./dispatch').nextJobRef()
|
||||||
|
const payload = {
|
||||||
|
ticket_id: ref, subject: b.reason ? ('Réservé — ' + String(b.reason).slice(0, 80)) : 'Réservé (indisponible au dispatch)',
|
||||||
|
job_type: 'Réservation', priority: 'medium', status: 'assigned',
|
||||||
|
assigned_tech: b.tech, scheduled_date: b.date,
|
||||||
|
start_time: b.start_time || '08:00', duration_h: Number(b.duration_h) || 2,
|
||||||
|
}
|
||||||
|
const r = await retryWrite(() => erp.create('Dispatch Job', payload))
|
||||||
|
if (r && r.ok !== false) { invalidatePool(); return json(res, 200, { ok: true, name: r.name || (r.data && r.data.name), ref }) }
|
||||||
|
return json(res, 500, { ok: false, error: (r && r.error) || 'création échouée' })
|
||||||
|
} catch (e) { return json(res, 500, { ok: false, error: e.message }) }
|
||||||
|
}
|
||||||
|
if (path === '/roster/job-comment' && method === 'POST') {
|
||||||
|
const b = await parseBody(req)
|
||||||
|
const text = String(b.text || '').trim(); if (!text) return json(res, 400, { ok: false, error: 'texte requis' })
|
||||||
|
const isPublic = !!b.public
|
||||||
|
const actorEmail = req.headers['x-authentik-email'] || b.agent || '' // attribution : l'agent connecté (→ compte staff osTicket = son nom)
|
||||||
|
const actorName = b.agentName || ''
|
||||||
|
try {
|
||||||
|
let lid = b.lid || null
|
||||||
|
if (!lid && b.job) { const j = await erp.get('Dispatch Job', b.job, { fields: ['legacy_ticket_id'] }); lid = j && j.legacy_ticket_id }
|
||||||
|
if (lid) {
|
||||||
|
const r = await require('./legacy-dispatch-sync').postTicketLegacy(lid, text, isPublic, actorEmail, actorName)
|
||||||
|
return json(res, 200, { ok: !!(r && r.ok !== false), public: isPublic, sentToCustomer: isPublic })
|
||||||
|
}
|
||||||
|
if (b.job) { // job natif sans billet legacy → Comment ERPNext (préfixé du nom de l'agent, l'auteur ERPNext = compte service)
|
||||||
|
await erp.create('Comment', { comment_type: 'Comment', reference_doctype: 'Dispatch Job', reference_name: b.job, content: (actorName ? actorName + ' : ' : '') + text })
|
||||||
|
return json(res, 200, { ok: true, public: isPublic })
|
||||||
|
}
|
||||||
|
return json(res, 400, { ok: false, error: 'job ou lid requis' })
|
||||||
|
} catch (e) { return json(res, 500, { ok: false, error: e.message }) }
|
||||||
|
}
|
||||||
if (path === '/roster/job-remote' && method === 'POST') { // 🖥 « sans déplacement » (configurable à distance, ex. boîtier tel via ACS) — exclu des tournées
|
if (path === '/roster/job-remote' && method === 'POST') { // 🖥 « sans déplacement » (configurable à distance, ex. boîtier tel via ACS) — exclu des tournées
|
||||||
const b = await parseBody(req); if (!b.name) return json(res, 400, { error: 'name requis' })
|
const b = await parseBody(req); if (!b.name) return json(res, 400, { error: 'name requis' })
|
||||||
setJobRemote(b.name, !!b.remote); invalidatePool() // le pool porte j.remote → rafraîchir
|
setJobRemote(b.name, !!b.remote); invalidatePool() // le pool porte j.remote → rafraîchir
|
||||||
|
|
@ -1782,22 +1882,30 @@ async function handle (req, res, method, path, url) {
|
||||||
const keyOf = (a) => a.tech + '|' + a.date + '|' + a.shift
|
const keyOf = (a) => a.tech + '|' + a.date + '|' + a.shift
|
||||||
const existByKey = {}; for (const a of existing) existByKey[keyOf(a)] = a
|
const existByKey = {}; for (const a of existing) existByKey[keyOf(a)] = a
|
||||||
const desiredKeys = new Set(desired.map(keyOf))
|
const desiredKeys = new Set(desired.map(keyOf))
|
||||||
let deleted = 0; let created = 0; let errors = 0; let unchanged = 0
|
// #2/#3 — statut par mode : draft(Proposé, auto-save, 0 SMS · ne touche pas l'existant) · submit(Soumis) · approve(Approuvé) · publish(Publié + SMS).
|
||||||
|
const STATUS_BY_MODE = { draft: 'Proposé', submit: 'Soumis', approve: 'Approuvé', publish: 'Publié' }
|
||||||
|
const mode = STATUS_BY_MODE[b.mode] ? b.mode : 'publish'
|
||||||
|
const targetStatus = STATUS_BY_MODE[mode]
|
||||||
|
let deleted = 0; let created = 0; let errors = 0; let unchanged = 0; let promoted = 0
|
||||||
for (const a of existing) { // supprimer ceux qui ne sont plus voulus
|
for (const a of existing) { // supprimer ceux qui ne sont plus voulus
|
||||||
if (desiredKeys.has(keyOf(a))) continue
|
if (desiredKeys.has(keyOf(a))) continue
|
||||||
const r = await retryWrite(() => erp.remove('Shift Assignment', a.name)); if (r.ok) deleted++
|
const r = await retryWrite(() => erp.remove('Shift Assignment', a.name)); if (r.ok) deleted++
|
||||||
}
|
}
|
||||||
for (const a of desired) { // créer seulement les nouveaux ; ignorer les inchangés
|
for (const a of desired) {
|
||||||
if (existByKey[keyOf(a)]) { unchanged++; continue }
|
const ex = existByKey[keyOf(a)]
|
||||||
|
if (ex) { // draft = laisser tel quel ; submit/approve/publish = faire MONTER le statut (jamais rétrograder un Publié)
|
||||||
|
if (mode !== 'draft' && ex.status !== 'Publié' && ex.status !== targetStatus) { const r = await retryWrite(() => erp.update('Shift Assignment', ex.name, { status: targetStatus })); if (r.ok) promoted++; else errors++ } else unchanged++
|
||||||
|
continue
|
||||||
|
}
|
||||||
const r = await retryWrite(() => erp.create('Shift Assignment', {
|
const r = await retryWrite(() => erp.create('Shift Assignment', {
|
||||||
technician: a.tech, technician_name: a.tech_name || '', assignment_date: a.date,
|
technician: a.tech, technician_name: a.tech_name || '', assignment_date: a.date,
|
||||||
shift_template: a.shift, zone: a.zone || '', hours: Number(a.hours) || 0,
|
shift_template: a.shift, zone: a.zone || '', hours: Number(a.hours) || 0,
|
||||||
status: 'Publié', source: a.source || 'solveur',
|
status: (mode === 'draft' ? 'Proposé' : targetStatus), source: a.source || 'manuel',
|
||||||
}))
|
}))
|
||||||
if (r.ok) created++; else errors++
|
if (r.ok) created++; else errors++
|
||||||
}
|
}
|
||||||
let notified = 0
|
let notified = 0
|
||||||
if (b.notify && created) { // SMS opt-in aux techs (Twilio) — non bloquant
|
if (mode === 'publish' && b.notify && (created || promoted)) { // SMS opt-in aux techs (Twilio) — SEULEMENT à la publication, jamais en auto-save brouillon
|
||||||
try {
|
try {
|
||||||
const techs = await fetchTechnicians()
|
const techs = await fetchTechnicians()
|
||||||
const phoneById = Object.fromEntries(techs.map(t => [t.id, t.phone]))
|
const phoneById = Object.fromEntries(techs.map(t => [t.id, t.phone]))
|
||||||
|
|
@ -1812,7 +1920,7 @@ async function handle (req, res, method, path, url) {
|
||||||
}
|
}
|
||||||
} catch (e) { /* notif non bloquante */ }
|
} catch (e) { /* notif non bloquante */ }
|
||||||
}
|
}
|
||||||
return json(res, 200, { ok: errors === 0, created, deleted, errors, notified, unchanged })
|
return json(res, 200, { ok: errors === 0, mode, created, deleted, promoted, errors, notified, unchanged })
|
||||||
}
|
}
|
||||||
// Garde : matérialiser la rotation sur un HORIZON (plusieurs semaines) — comme un évènement récurrent.
|
// Garde : matérialiser la rotation sur un HORIZON (plusieurs semaines) — comme un évènement récurrent.
|
||||||
// Wipe ROBUSTE : on supprime TOUTE garde (tout template on_call) dans la plage, pas seulement les shifts
|
// Wipe ROBUSTE : on supprime TOUTE garde (tout template on_call) dans la plage, pas seulement les shifts
|
||||||
|
|
@ -1982,4 +2090,4 @@ async function handle (req, res, method, path, url) {
|
||||||
return json(res, 404, { error: 'roster: route inconnue ' + path })
|
return json(res, 404, { error: 'roster: route inconnue ' + path })
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { handle, handlePublicBooking, handleFieldTech, fieldLink, techFieldLink, generate, publish, coverage, fetchTechnicians, fetchTemplates, bookingSlots }
|
module.exports = { handle, handlePublicBooking, handleFieldTech, fieldLink, techFieldLink, generate, publish, coverage, fetchTechnicians, fetchTemplates, bookingSlots, startShiftMaterializer }
|
||||||
|
|
|
||||||
|
|
@ -53,11 +53,13 @@
|
||||||
<button onclick=openScan()>📦 Appareil</button>
|
<button onclick=openScan()>📦 Appareil</button>
|
||||||
</div>
|
</div>
|
||||||
<div id=cmtbox style="display:none;margin-top:8px;background:#0f172a;border:1px solid #334155;border-radius:10px;padding:10px;text-align:left">
|
<div id=cmtbox style="display:none;margin-top:8px;background:#0f172a;border:1px solid #334155;border-radius:10px;padding:10px;text-align:left">
|
||||||
<textarea id=cmtxt placeholder="Note / commentaire pour ce ticket…" style="width:100%;box-sizing:border-box;min-height:70px;border-radius:8px;border:1px solid #475569;background:#1e293b;color:#e2e8f0;padding:8px;font-size:15px"></textarea>
|
<div style="font-size:12px;font-weight:700;color:#94a3b8;margin-bottom:4px">💬 Fil du ticket</div>
|
||||||
|
<div id=cmthread style="max-height:210px;overflow:auto;margin-bottom:8px;font-size:13px;line-height:1.4;color:#e2e8f0">Chargement…</div>
|
||||||
|
<textarea id=cmtxt placeholder="Écrire une note / un commentaire…" style="width:100%;box-sizing:border-box;min-height:64px;border-radius:8px;border:1px solid #475569;background:#1e293b;color:#e2e8f0;padding:8px;font-size:15px"></textarea>
|
||||||
<label style="display:flex;align-items:center;gap:6px;margin:8px 0;font-size:13px;color:#cbd5e1"><input type=checkbox id=cmpub> 📣 Envoyer aussi au client (réponse publique)</label>
|
<label style="display:flex;align-items:center;gap:6px;margin:8px 0;font-size:13px;color:#cbd5e1"><input type=checkbox id=cmpub> 📣 Envoyer aussi au client (réponse publique)</label>
|
||||||
<div style="display:flex;gap:8px">
|
<div style="display:flex;gap:8px">
|
||||||
<button onclick=sendComment() style="flex:1;background:#6366f1">Ajouter au ticket</button>
|
<button onclick=sendComment() style="flex:1;background:#6366f1;color:#fff;font-weight:700;font-size:15px;padding:13px;border:0;border-radius:10px">Ajouter au ticket</button>
|
||||||
<button onclick="document.getElementById('cmtbox').style.display='none'" style="flex:0 0 90px;background:#475569">Annuler</button>
|
<button onclick=closeComment() style="flex:0 0 96px;background:#475569;color:#fff;font-weight:600;font-size:15px;padding:13px;border:0;border-radius:10px">Fermer</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<input type=file id=photo accept=image/* capture=environment style=display:none onchange=sendPhoto(this)>
|
<input type=file id=photo accept=image/* capture=environment style=display:none onchange=sendPhoto(this)>
|
||||||
|
|
@ -83,8 +85,11 @@ const $=id=>document.getElementById(id); let JOBS=[],CUR=null,map=null,jobMk=nul
|
||||||
function toast(m){const t=$('toast');t.textContent=m;t.style.display='block';setTimeout(()=>t.style.display='none',2600)}
|
function toast(m){const t=$('toast');t.textContent=m;t.style.display='block';setTimeout(()=>t.style.display='none',2600)}
|
||||||
function badge(j){return j.ended?'<span class="badge b-done">terminé'+(j.minutes!=null?' · '+j.minutes+'m':'')+'</span>':j.started?'<span class="badge b-run">en cours</span>':'<span class="badge b-todo">à faire</span>'}
|
function badge(j){return j.ended?'<span class="badge b-done">terminé'+(j.minutes!=null?' · '+j.minutes+'m':'')+'</span>':j.started?'<span class="badge b-run">en cours</span>':'<span class="badge b-todo">à faire</span>'}
|
||||||
async function api(p,o){const r=await fetch(HUB+p,o);return r.json()}
|
async function api(p,o){const r=await fetch(HUB+p,o);return r.json()}
|
||||||
function openComment(){var b=$('cmtbox');b.style.display=(b.style.display==='none'||!b.style.display)?'block':'none';if(b.style.display==='block')$('cmtxt').focus()}
|
var _cmPoll=null;
|
||||||
async function sendComment(){var t=($('cmtxt').value||'').trim();if(!t){toast('Écris un commentaire');return}var pub=$('cmpub').checked;var r=await api('/field/comment',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:CUR.token,text:t,public:pub})}).catch(function(){return{ok:false}});if(r&&r.ok){$('cmtxt').value='';$('cmpub').checked=false;$('cmtbox').style.display='none';toast(pub?(r.sentToCustomer?'✓ Ajouté au ticket + envoyé au client':'✓ Ajouté (client non joignable)'):'✓ Note interne ajoutée au ticket')}else{toast('Échec — réessaie')}}
|
function openComment(){var b=$('cmtbox');var show=(b.style.display==='none'||!b.style.display);if(show){b.style.display='block';$('cmtxt').focus();loadThread();if(_cmPoll)clearInterval(_cmPoll);_cmPoll=setInterval(loadThread,15000)}else{closeComment()}}
|
||||||
|
function closeComment(){$('cmtbox').style.display='none';if(_cmPoll){clearInterval(_cmPoll);_cmPoll=null}}
|
||||||
|
async function loadThread(){if(!CUR||!CUR.token)return;var r=await api('/field/thread?token='+encodeURIComponent(CUR.token)).catch(function(){return{ok:false}});var el=$('cmthread');if(!el)return;if(!r||!r.ok){el.innerHTML='<div style="color:#94a3b8">Fil indisponible</div>';return}var ms=r.messages||[];if(!ms.length){el.innerHTML='<div style="color:#94a3b8">Aucun message pour l\'instant.</div>';return}el.innerHTML=ms.slice(-40).map(function(m){var d=m.at?new Date(m.at).toLocaleString('fr-CA',{dateStyle:'short',timeStyle:'short'}):'';var txt=esc(String(m.text||m.msg||'').replace(/<[^>]+>/g,' ').replace(/ /g,' ').trim()).slice(0,700);var who=m.fromClient?'<span style="color:#fbbf24;font-size:10px;font-weight:700">CLIENT</span> ':'';return '<div style="border-bottom:1px solid #1e293b;padding:5px 0">'+who+'<b style="color:#a5b4fc">'+esc(m.author||'—')+'</b> <span style="color:#64748b;font-size:11px">'+d+'</span><br><span style="white-space:pre-wrap">'+txt+'</span></div>'}).join('');el.scrollTop=el.scrollHeight}
|
||||||
|
async function sendComment(){var t=($('cmtxt').value||'').trim();if(!t){toast('Écris un commentaire');return}var pub=$('cmpub').checked;var r=await api('/field/comment',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:CUR.token,text:t,public:pub})}).catch(function(){return{ok:false}});if(r&&r.ok){$('cmtxt').value='';$('cmpub').checked=false;toast(pub?(r.sentToCustomer?'✓ Ajouté + envoyé au client':'✓ Ajouté (client non joignable)'):'✓ Note interne ajoutée');loadThread()}else{toast('Échec — réessaie')}}
|
||||||
function dist(a1,o1,a2,o2){const R=6371000,t=Math.PI/180,dA=(a2-a1)*t,dO=(o2-o1)*t;const x=Math.sin(dA/2)**2+Math.cos(a1*t)*Math.cos(a2*t)*Math.sin(dO/2)**2;return Math.round(R*2*Math.atan2(Math.sqrt(x),Math.sqrt(1-x)))}
|
function dist(a1,o1,a2,o2){const R=6371000,t=Math.PI/180,dA=(a2-a1)*t,dO=(o2-o1)*t;const x=Math.sin(dA/2)**2+Math.cos(a1*t)*Math.cos(a2*t)*Math.sin(dO/2)**2;return Math.round(R*2*Math.atan2(Math.sqrt(x),Math.sqrt(1-x)))}
|
||||||
async function load(){
|
async function load(){
|
||||||
const r=await api('/field/tech?t='+encodeURIComponent(T)).catch(()=>({}));
|
const r=await api('/field/tech?t='+encodeURIComponent(T)).catch(()=>({}));
|
||||||
|
|
@ -98,15 +103,16 @@ function renderList(){
|
||||||
if(!JOBS.length){$('jobs').innerHTML='Aucune intervention.';return}
|
if(!JOBS.length){$('jobs').innerHTML='Aucune intervention.';return}
|
||||||
$('jobs').innerHTML='';
|
$('jobs').innerHTML='';
|
||||||
JOBS.forEach((j,i)=>{const d=document.createElement('div');d.className='card job';d.onclick=()=>openDetail(i);
|
JOBS.forEach((j,i)=>{const d=document.createElement('div');d.className='card job';d.onclick=()=>openDetail(i);
|
||||||
d.innerHTML='<div style=flex:1><div style=font-weight:600>'+esc(j.subject||'Job')+'</div><div class=sub>'+(j.start?j.start+' · ':'')+esc(j.address||'')+'</div></div>'+badge(j);
|
d.innerHTML='<div style=flex:1><div style=font-weight:600>'+esc(dec(j.subject)||'Job')+'</div><div class=sub>'+(j.start?j.start+' · ':'')+esc(dec(j.address))+'</div></div>'+badge(j);
|
||||||
$('jobs').appendChild(d)});
|
$('jobs').appendChild(d)});
|
||||||
}
|
}
|
||||||
function esc(s){return String(s||'').replace(/[<>&]/g,c=>({'<':'<','>':'>','&':'&'}[c]))}
|
function esc(s){return String(s||'').replace(/[<>&]/g,c=>({'<':'<','>':'>','&':'&'}[c]))}
|
||||||
|
function dec(s){var v=String(s||'');if(v.indexOf('&')<0)return v;for(var i=0;i<2;i++){var t=document.createElement('textarea');t.innerHTML=v;if(t.value===v)break;v=t.value}return v} // décode les entités HTML (' é …) venues d'osTicket AVANT affichage
|
||||||
function showList(){if(watch){navigator.geolocation.clearWatch(watch);watch=null}if(map){map.remove();map=null}$('detail').style.display='none';$('list').style.display='block';load()}
|
function showList(){if(watch){navigator.geolocation.clearWatch(watch);watch=null}if(map){map.remove();map=null}$('detail').style.display='none';$('list').style.display='block';load()}
|
||||||
function openDetail(i){
|
function openDetail(i){
|
||||||
CUR=JOBS[i];enteredZone=false;$('list').style.display='none';$('detail').style.display='block';
|
CUR=JOBS[i];enteredZone=false;$('list').style.display='none';$('detail').style.display='block';
|
||||||
$('dsub').textContent=CUR.subject||'Job';$('dcust').textContent=CUR.customer||'';$('daddr').textContent='📍 '+(CUR.address||'(adresse manquante)');
|
$('dsub').textContent=dec(CUR.subject)||'Job';$('dcust').textContent=dec(CUR.customer);$('daddr').textContent='📍 '+(dec(CUR.address)||'(adresse manquante)');
|
||||||
var dn=$('dnote'),nt=(CUR.notes||'').trim(),sk=CUR.skill||'';dn.innerHTML=(nt?('<b>📌 Note importante</b><br>'+esc(nt)):'')+(sk?('<div class=skl>🔧 '+esc(sk)+'</div>'):'');dn.style.display=(nt||sk)?'block':'none';
|
var dn=$('dnote'),nt=dec(CUR.notes).trim(),sk=dec(CUR.skill);dn.innerHTML=(nt?('<b>📌 Note importante</b><br>'+esc(nt)):'')+(sk?('<div class=skl>🔧 '+esc(sk)+'</div>'):'');dn.style.display=(nt||sk)?'block':'none';
|
||||||
$('dbadge').innerHTML=badge(CUR);
|
$('dbadge').innerHTML=badge(CUR);
|
||||||
const hasLL=CUR.lat&&Math.abs(+CUR.lat)>0.01;
|
const hasLL=CUR.lat&&Math.abs(+CUR.lat)>0.01;
|
||||||
$('ditin').href=hasLL?('https://www.google.com/maps/dir/?api=1&destination='+CUR.lat+','+CUR.lon):'#';
|
$('ditin').href=hasLL?('https://www.google.com/maps/dir/?api=1&destination='+CUR.lat+','+CUR.lon):'#';
|
||||||
|
|
|
||||||
|
|
@ -382,6 +382,9 @@ server.listen(cfg.PORT, '0.0.0.0', () => {
|
||||||
// Pont legacy (osTicket) → Dispatch Job : tire les tickets « Tech Targo » à dispatcher
|
// Pont legacy (osTicket) → Dispatch Job : tire les tickets « Tech Targo » à dispatcher
|
||||||
try { require('./lib/legacy-dispatch-sync').startSync() }
|
try { require('./lib/legacy-dispatch-sync').startSync() }
|
||||||
catch (e) { log('legacy-dispatch-sync failed to start:', e.message) }
|
catch (e) { log('legacy-dispatch-sync failed to start:', e.message) }
|
||||||
|
// Matérialisation des quarts récurrents (weekly_schedule → Shift Assignment) sur horizon glissant 6 sem. Désactivable SHIFT_MATERIALIZE_CRON=off.
|
||||||
|
try { require('./lib/roster').startShiftMaterializer() }
|
||||||
|
catch (e) { log('shift materializer failed to start:', e.message) }
|
||||||
// Ingestion Gmail (cc@) → conversations + triage IA ; opt-in GMAIL_INGEST=on + clé /app/data/gmail_sa.json
|
// Ingestion Gmail (cc@) → conversations + triage IA ; opt-in GMAIL_INGEST=on + clé /app/data/gmail_sa.json
|
||||||
try { require('./lib/gmail').start() }
|
try { require('./lib/gmail').start() }
|
||||||
catch (e) { log('gmail ingest failed to start:', e.message) }
|
catch (e) { log('gmail ingest failed to start:', e.message) }
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user