Compare commits
No commits in common. "cd25fa250481c4148dc71951271165a22f701895" and "9c1b578d5d189475987f8f0dcc744e8d1b918894" have entirely different histories.
cd25fa2504
...
9c1b578d5d
|
|
@ -12,12 +12,7 @@ async function _fetch (path, init = {}, ms = 45000) {
|
||||||
const timer = setTimeout(() => ctl.abort(), ms)
|
const timer = setTimeout(() => ctl.abort(), ms)
|
||||||
try {
|
try {
|
||||||
const r = await fetch(HUB + path, { ...init, signal: ctl.signal })
|
const r = await fetch(HUB + path, { ...init, signal: ctl.signal })
|
||||||
if (!r.ok) {
|
if (!r.ok) throw new Error('Roster API ' + r.status)
|
||||||
// Le corps d'erreur porte parfois un contexte actionnable (ex. 409 conflict de /roster/assign-job) → l'attacher.
|
|
||||||
let body = null; try { body = await r.json() } catch (e2) { /* corps non-JSON */ }
|
|
||||||
const err = new Error((body && body.error) || ('Roster API ' + r.status)); err.status = r.status; if (body) err.body = body
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
return await r.json()
|
return await r.json()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e && e.name === 'AbortError') throw new Error('Roster API timeout (' + Math.round(ms / 1000) + 's) — serveur trop lent ou injoignable')
|
if (e && e.name === 'AbortError') throw new Error('Roster API timeout (' + Math.round(ms / 1000) + 's) — serveur trop lent ou injoignable')
|
||||||
|
|
@ -151,37 +146,13 @@ export const closeLegacyTicket = (ticket) => jpost('/dispatch/legacy-sync/close-
|
||||||
// Fermeture EN LOT (1 appel pour N tickets) — efficace
|
// Fermeture EN LOT (1 appel pour N tickets) — efficace
|
||||||
export const batchCloseLegacy = (ids) => jpost('/dispatch/legacy-sync/batch-close?tickets=' + encodeURIComponent(ids.join(',')), {})
|
export const batchCloseLegacy = (ids) => jpost('/dispatch/legacy-sync/batch-close?tickets=' + encodeURIComponent(ids.join(',')), {})
|
||||||
// Assigner un job à un tech (date = case déposée)
|
// Assigner un job à un tech (date = case déposée)
|
||||||
// Assignation avec GARDE-FOU F : si le ticket legacy est déjà assigné dans F à un autre vrai tech (fenêtre aveugle
|
export const assignJob = (job, tech, date) => jpost('/roster/assign-job', { job, tech, date })
|
||||||
// du miroir ~3 min), le hub renvoie 409 conflict → on demande confirmation AVANT d'écraser (jamais silencieux).
|
|
||||||
export const assignJob = async (job, tech, date, { force = false } = {}) => {
|
|
||||||
try {
|
|
||||||
return await jpost('/roster/assign-job', { job, tech, date, force })
|
|
||||||
} catch (e) {
|
|
||||||
const b = e && e.body
|
|
||||||
if (b && b.conflict) {
|
|
||||||
const { Dialog } = await import('quasar')
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
Dialog.create({
|
|
||||||
title: 'Déjà assigné dans F',
|
|
||||||
message: `Le ticket ${b.ticket || ''} est déjà assigné dans F à ${b.f_staff_name || ('staff #' + b.f_staff_id)}${b.f_status ? ' (ticket ' + b.f_status + ')' : ''}. Réassigner quand même ?`,
|
|
||||||
cancel: { label: 'Annuler', flat: true, color: 'grey-8' },
|
|
||||||
ok: { label: 'Réassigner quand même', color: 'negative', unelevated: true },
|
|
||||||
persistent: true,
|
|
||||||
}).onOk(() => { assignJob(job, tech, date, { force: true }).then(resolve, reject) })
|
|
||||||
.onCancel(() => reject(new Error('Assignation annulée — déjà assigné dans F à ' + (b.f_staff_name || ('staff #' + b.f_staff_id)))))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Fil complet d'un ticket legacy (description + commentaires/réponses des collaborateurs) — read-only
|
// Fil complet d'un ticket legacy (description + commentaires/réponses des collaborateurs) — read-only
|
||||||
export const legacyTicketThread = (id) => jget('/dispatch/legacy-sync/ticket-thread?id=' + encodeURIComponent(id))
|
export const legacyTicketThread = (id) => jget('/dispatch/legacy-sync/ticket-thread?id=' + encodeURIComponent(id))
|
||||||
// Réordonner / re-prioriser les jobs d'un tech×jour : updates = [{ job, route_order, priority? }]
|
// Réordonner / re-prioriser les jobs d'un tech×jour : updates = [{ job, route_order, priority? }]
|
||||||
export const reorderJobs = (updates) => jpost('/roster/reorder-jobs', { updates })
|
export const reorderJobs = (updates) => jpost('/roster/reorder-jobs', { updates })
|
||||||
// Retirer un job d'un tech (retour au pool non assigné) — ERPNext SEULEMENT (redistributions auto)
|
// Retirer un job d'un tech (retour au pool non assigné) — ERPNext SEULEMENT (redistributions auto)
|
||||||
export const unassignJobRoster = (job) => jpost('/roster/unassign-job', { job })
|
export const unassignJobRoster = (job) => jpost('/roster/unassign-job', { job })
|
||||||
// Médias terrain d'un job : photos du tech (géo-vérifiées) + journal completion_notes (arrivées, scans, notes).
|
|
||||||
export const jobMedia = (job) => jget('/roster/job-media?job=' + encodeURIComponent(job))
|
|
||||||
// Situer manuellement un job « hors carte » : pose latitude/longitude (+ adresse) choisis sur la carte
|
// Situer manuellement un job « hors carte » : pose latitude/longitude (+ adresse) choisis sur la carte
|
||||||
export const setJobLocation = (job, lat, lon, address) => jpost('/roster/job/set-location', { job, lat, lon, address })
|
export const setJobLocation = (job, lat, lon, address) => jpost('/roster/job/set-location', { job, lat, lon, address })
|
||||||
// Actions rapides du pool (mobile) : patch d'un Dispatch Job (priority/status/required_skill/scheduled_date/notes — liste blanche côté hub).
|
// Actions rapides du pool (mobile) : patch d'un Dispatch Job (priority/status/required_skill/scheduled_date/notes — liste blanche côté hub).
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@
|
||||||
<!-- Job « bouche-trou » : occuper la journée du tech (formation, réunion, admin…) → le retire du dispatch -->
|
<!-- Job « bouche-trou » : occuper la journée du tech (formation, réunion, admin…) → le retire du dispatch -->
|
||||||
<q-item clickable dense @click="$emit('filler')">
|
<q-item clickable dense @click="$emit('filler')">
|
||||||
<q-item-section avatar style="min-width:30px"><q-icon name="block" color="deep-orange-6" /></q-item-section>
|
<q-item-section avatar style="min-width:30px"><q-icon name="block" color="deep-orange-6" /></q-item-section>
|
||||||
<q-item-section><q-item-label>Bloquer du temps…</q-item-label><q-item-label caption>Job/ticket qui occupe la journée → hors dispatch (priorité = curseur dur/souple)</q-item-label></q-item-section>
|
<q-item-section><q-item-label>Bloquer / job générique…</q-item-label><q-item-label caption>Occupe la journée (durée éditable) · retire du dispatch · ticket</q-item-label></q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
<q-separator />
|
<q-separator />
|
||||||
<!-- Shifts en place + actions compactes -->
|
<!-- Shifts en place + actions compactes -->
|
||||||
|
|
|
||||||
|
|
@ -1,117 +0,0 @@
|
||||||
<!--
|
|
||||||
JobMediaModule — photos prises par le TECH sur place (app terrain /field/photo, géo-vérifiées vs l'adresse du job)
|
|
||||||
+ journal d'activité terrain (completion_notes : arrivées géofence, scans d'appareil, notes). Module RÉUTILISABLE
|
|
||||||
(détail ticket « Intervention sur site » + volet job Planification). Autonome : charge via roster.jobMedia(name),
|
|
||||||
se rafraîchit seul pendant que le panneau est ouvert (les photos arrivent PENDANT la visite).
|
|
||||||
-->
|
|
||||||
<template>
|
|
||||||
<div v-if="props.name">
|
|
||||||
<div class="text-subtitle2 q-mt-md q-mb-xs row items-center">
|
|
||||||
Photos & activité terrain
|
|
||||||
<span v-if="photos.length" class="text-grey-5 q-ml-sm" style="font-size:10px">· {{ photos.length }} photo(s)</span>
|
|
||||||
<q-space />
|
|
||||||
<q-btn flat dense round size="sm" icon="refresh" :loading="loading" @click="reload(false)"><q-tooltip>Rafraîchir</q-tooltip></q-btn>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Photos : vignettes cliquables ; badge « sur place » (distance GPS ↔ adresse du job) = preuve d'adresse ;
|
|
||||||
la légende (note du tech) sert à identifier l'appareil photographié. -->
|
|
||||||
<div v-if="photos.length" class="jm-grid">
|
|
||||||
<div v-for="p in photos" :key="p.file" class="jm-cell" @click="openPhoto(p)">
|
|
||||||
<img :src="p.url" loading="lazy" :alt="p.note || 'photo terrain'" />
|
|
||||||
<div class="jm-meta">
|
|
||||||
<span v-if="p.onsite" class="jm-badge jm-ok">✓ sur place</span>
|
|
||||||
<span v-else-if="p.offsite" class="jm-badge jm-warn">⚠ hors adresse</span>
|
|
||||||
<span v-if="p.at" class="jm-ts">{{ fmtDT(p.at) }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="p.note" class="jm-note">{{ p.note }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Journal terrain (plus récent en haut) : arrivé/reparti, scans SN/MAC, photos, hors-zone… -->
|
|
||||||
<div v-if="journal.length" class="q-mt-xs">
|
|
||||||
<a class="jm-toggle" @click.prevent="showJournal = !showJournal">
|
|
||||||
{{ showJournal ? 'Masquer' : 'Voir' }} le journal terrain ({{ journal.length }})
|
|
||||||
</a>
|
|
||||||
<div v-if="showJournal" class="jm-journal">
|
|
||||||
<div v-for="(l, i) in journal" :key="i" class="jm-line">{{ l }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="!loading && !photos.length && !journal.length" class="text-grey-5" style="font-size:11px">
|
|
||||||
Aucune photo ni activité terrain pour l'instant.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Plein écran -->
|
|
||||||
<q-dialog v-model="viewer">
|
|
||||||
<q-card style="max-width:94vw">
|
|
||||||
<q-card-section class="q-pa-xs row items-center">
|
|
||||||
<div class="text-caption q-ml-sm">
|
|
||||||
<b v-if="current && current.onsite" class="text-positive">✓ sur place</b>
|
|
||||||
<b v-else-if="current && current.offsite" class="text-warning">⚠ hors adresse</b>
|
|
||||||
<span v-if="current && current.geo" class="text-grey-7 q-ml-xs">{{ current.geo }}</span>
|
|
||||||
<span v-if="current && current.at" class="text-grey-6 q-ml-xs">· {{ fmtDT(current.at) }}</span>
|
|
||||||
<span v-if="current && current.note" class="q-ml-xs">— {{ current.note }}</span>
|
|
||||||
</div>
|
|
||||||
<q-space /><q-btn flat round dense icon="close" v-close-popup />
|
|
||||||
</q-card-section>
|
|
||||||
<img v-if="current" :src="current.url" style="max-width:94vw;max-height:82vh;display:block" :alt="current.note || 'photo terrain'" />
|
|
||||||
</q-card>
|
|
||||||
</q-dialog>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
|
||||||
import * as roster from 'src/api/roster'
|
|
||||||
import { formatDateTime as fmtDT } from 'src/composables/useFormatters'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
name: { type: String, default: '' }, // docname du Dispatch Job
|
|
||||||
})
|
|
||||||
|
|
||||||
const loading = ref(false)
|
|
||||||
const photos = ref([])
|
|
||||||
const journal = ref([])
|
|
||||||
const showJournal = ref(false)
|
|
||||||
const viewer = ref(false)
|
|
||||||
const current = ref(null)
|
|
||||||
|
|
||||||
function openPhoto (p) { current.value = p; viewer.value = true }
|
|
||||||
|
|
||||||
async function reload (silent = true) {
|
|
||||||
if (!props.name) return
|
|
||||||
if (!silent) loading.value = true
|
|
||||||
try {
|
|
||||||
const r = await roster.jobMedia(props.name)
|
|
||||||
photos.value = (r && r.photos) || []
|
|
||||||
// Journal = lignes non-vides de completion_notes, PLUS RÉCENT EN HAUT (comme le fil du billet).
|
|
||||||
journal.value = String((r && r.notes) || '').split('\n').map(s => s.replace(/^•\s*/, '').trim()).filter(Boolean).reverse().slice(0, 60)
|
|
||||||
} catch (e) { /* best-effort : le module ne casse jamais le panneau */ } finally { loading.value = false }
|
|
||||||
}
|
|
||||||
|
|
||||||
let pollTimer = null
|
|
||||||
onMounted(() => {
|
|
||||||
reload(false)
|
|
||||||
// Les photos/checkpoints arrivent pendant la visite → petit poll tant que le panneau est ouvert (silencieux).
|
|
||||||
pollTimer = setInterval(() => { if (document.visibilityState === 'visible') reload(true) }, 60000)
|
|
||||||
})
|
|
||||||
onBeforeUnmount(() => { if (pollTimer) clearInterval(pollTimer) })
|
|
||||||
watch(() => props.name, () => { photos.value = []; journal.value = []; reload(false) })
|
|
||||||
defineExpose({ reload })
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.jm-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); gap: 8px; }
|
|
||||||
.jm-cell { cursor: pointer; border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden; background: #fafafa; }
|
|
||||||
.jm-cell img { width: 100%; height: 84px; object-fit: cover; display: block; }
|
|
||||||
.jm-meta { display: flex; align-items: center; gap: 4px; padding: 2px 5px 0; flex-wrap: wrap; }
|
|
||||||
.jm-badge { font-size: 9px; font-weight: 700; border-radius: 4px; padding: 0 4px; }
|
|
||||||
.jm-ok { color: #1b5e20; background: #e6f4ec; }
|
|
||||||
.jm-warn { color: #8a5a00; background: #fbf0dc; }
|
|
||||||
.jm-ts { font-size: 9px; color: #90a4ae; }
|
|
||||||
.jm-note { font-size: 10px; color: #455a64; padding: 0 5px 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.jm-toggle { font-size: 11px; color: #5e7a76; cursor: pointer; text-decoration: underline dotted; }
|
|
||||||
.jm-journal { margin-top: 4px; max-height: 180px; overflow-y: auto; background: #f6f8fb; border-radius: 6px; padding: 6px 8px; }
|
|
||||||
.jm-line { font-size: 11px; color: #37474f; white-space: pre-wrap; border-top: 1px dashed #e0e6ef; padding: 2px 0; }
|
|
||||||
.jm-line:first-child { border-top: none; }
|
|
||||||
</style>
|
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
import { ref, computed, watch, onMounted } from 'vue'
|
||||||
import * as roster from 'src/api/roster'
|
import * as roster from 'src/api/roster'
|
||||||
import { formatDateTime as fmtDT } from 'src/composables/useFormatters'
|
import { formatDateTime as fmtDT } from 'src/composables/useFormatters'
|
||||||
|
|
||||||
|
|
@ -41,20 +41,13 @@ const threadStatus = computed(() => (activeThread.value && activeThread.value.st
|
||||||
// Fil PLUS RÉCENT EN HAUT (le dernier commentaire porte souvent l'info la plus importante).
|
// Fil PLUS RÉCENT EN HAUT (le dernier commentaire porte souvent l'info la plus importante).
|
||||||
const messages = computed(() => { const m = (activeThread.value && activeThread.value.messages) || []; return m.slice().reverse() })
|
const messages = computed(() => { const m = (activeThread.value && activeThread.value.messages) || []; return m.slice().reverse() })
|
||||||
|
|
||||||
async function reload (silent = false) {
|
async function reload () {
|
||||||
if (props.thread || !props.lid) return
|
if (props.thread || !props.lid) return
|
||||||
if (!silent || !loaded.value) { loading.value = true } // silencieux = pas de spinner qui clignote sur le poll
|
loading.value = true; loadErr.value = false
|
||||||
loadErr.value = false
|
try { loaded.value = await roster.ticketThread(props.lid) } catch (e) { loaded.value = { error: true, messages: [] }; loadErr.value = true } finally { loading.value = false }
|
||||||
try { loaded.value = await roster.ticketThread(props.lid) } catch (e) { if (!silent) { loaded.value = { error: true, messages: [] }; loadErr.value = true } } finally { loading.value = false }
|
|
||||||
}
|
}
|
||||||
watch(() => props.lid, () => { loaded.value = null; reload() })
|
watch(() => props.lid, () => { loaded.value = null; reload() })
|
||||||
// Les réponses des techs (app terrain) arrivent PENDANT que le panneau est ouvert → le fil se met à jour tout seul.
|
onMounted(reload)
|
||||||
let pollTimer = null
|
|
||||||
onMounted(() => {
|
|
||||||
reload()
|
|
||||||
pollTimer = setInterval(() => { if (document.visibilityState === 'visible' && !loading.value) reload(true) }, 45000)
|
|
||||||
})
|
|
||||||
onBeforeUnmount(() => { if (pollTimer) clearInterval(pollTimer) })
|
|
||||||
defineExpose({ reload })
|
defineExpose({ reload })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<q-page padding>
|
<q-page padding>
|
||||||
<!-- Real-time outage alerts -->
|
<!-- Real-time outage alerts -->
|
||||||
<!-- Ouvre le TICKET précis (détail natif via deep link ?open=), pas seulement la liste. -->
|
<OutageAlertsPanel class="q-mb-md" @open-ticket="id => $router.push('/tickets')" />
|
||||||
<OutageAlertsPanel class="q-mb-md" @open-ticket="id => $router.push(id ? { path: '/tickets', query: { open: id } } : '/tickets')" />
|
|
||||||
|
|
||||||
<!-- KPI cards (cliquables → page liée · sous-ligne = tendance/contexte) -->
|
<!-- KPI cards (cliquables → page liée · sous-ligne = tendance/contexte) -->
|
||||||
<div class="row q-col-gutter-md q-mb-lg">
|
<div class="row q-col-gutter-md q-mb-lg">
|
||||||
|
|
@ -149,7 +148,7 @@
|
||||||
<div class="ops-card">
|
<div class="ops-card">
|
||||||
<div class="text-subtitle1 text-weight-bold q-mb-sm">Tickets ouverts</div>
|
<div class="text-subtitle1 text-weight-bold q-mb-sm">Tickets ouverts</div>
|
||||||
<q-list separator>
|
<q-list separator>
|
||||||
<q-item v-for="t in openTickets" :key="t.name" clickable @click="$router.push({ path: '/tickets', query: { open: t.name } })">
|
<q-item v-for="t in openTickets" :key="t.name" clickable @click="$router.push('/tickets')">
|
||||||
<q-item-section avatar style="min-width:32px">
|
<q-item-section avatar style="min-width:32px">
|
||||||
<q-icon name="confirmation_number" size="20px"
|
<q-icon name="confirmation_number" size="20px"
|
||||||
:color="t.priority === 'Urgent' ? 'red' : t.priority === 'High' ? 'orange-8' : 'grey-5'" />
|
:color="t.priority === 'Urgent' ? 'red' : t.priority === 'High' ? 'orange-8' : 'grey-5'" />
|
||||||
|
|
|
||||||
|
|
@ -743,7 +743,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="Bloquer du temps…" @click="openReserve(skillDialog)"><q-tooltip>Bloquer du temps (formation · réunion · projet · entretien…) : un job/ticket qui occupe la journée → dé-priorise au dispatch. Priorité Urgent = bloc dur ; sinon les urgences peuvent quand même passer.</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>
|
||||||
|
|
@ -1359,20 +1359,42 @@
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
|
|
||||||
<!-- Bloquer du temps d'un tech = UN simple job/ticket qui OCCUPE la timeline (formation, réunion, projet, entretien…).
|
<!-- #5 — Réserver du temps d'un tech (bloc « Réservation » priorité moyenne → dé-priorise au dispatch auto ; urgences passent) -->
|
||||||
Occupe la journée ⇒ dé-priorise au dispatch. La PRIORITÉ est le curseur : Moyenne/Basse = les urgences/réparations
|
<q-dialog v-model="resvDlg.open">
|
||||||
peuvent quand même le prendre ; Urgent (high) = bloc dur, même les urgences ne le prennent pas. (Fusionne les
|
<q-card style="min-width:320px;max-width:96vw">
|
||||||
ex- « Réserver du temps » + « Bloquer / job générique » — un seul concept.) -->
|
|
||||||
<q-dialog v-model="fillerDlg.open">
|
|
||||||
<q-card style="min-width:360px;max-width:96vw">
|
|
||||||
<q-card-section class="row items-center q-pb-none">
|
<q-card-section class="row items-center q-pb-none">
|
||||||
<q-icon name="event_busy" color="deep-orange-6" size="22px" class="q-mr-sm" />
|
<q-icon name="event_busy" color="deep-orange-7" size="22px" class="q-mr-sm" />
|
||||||
<div class="text-subtitle1 text-weight-bold">Bloquer du temps</div>
|
<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-space /><q-btn flat round dense icon="close" v-close-popup />
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-card-section class="q-gutter-sm">
|
<q-card-section class="q-gutter-sm">
|
||||||
<div class="text-body2"><b>{{ fillerDlg.tech && fillerDlg.tech.name }}</b></div>
|
<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>
|
||||||
<q-input dense outlined v-model="fillerDlg.subject" label="Intitulé" placeholder="Ex. Formation · Réunion · Projet · Entretien véhicule" autofocus />
|
<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>
|
||||||
|
|
||||||
|
<!-- Job « bouche-trou » (filler) : job générique de priorité STANDARD assigné → occupe la journée ⇒ retire le tech du dispatch. Optionnel : génère un ticket. -->
|
||||||
|
<q-dialog v-model="fillerDlg.open">
|
||||||
|
<q-card style="min-width:340px;max-width:96vw">
|
||||||
|
<q-card-section class="row items-center q-pb-none">
|
||||||
|
<q-icon name="block" color="deep-orange-6" size="22px" class="q-mr-sm" />
|
||||||
|
<div class="text-subtitle1 text-weight-bold">Bloquer / job générique</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>{{ fillerDlg.tech && fillerDlg.tech.name }}</b> <span class="text-grey-6">— occupé toute la période ⇒ retiré du dispatch régulier</span></div>
|
||||||
|
<q-input dense outlined v-model="fillerDlg.subject" label="Intitulé" placeholder="Ex. Formation · Réunion · Entretien véhicule" autofocus />
|
||||||
<div class="row items-center q-gutter-sm">
|
<div class="row items-center q-gutter-sm">
|
||||||
<q-toggle v-model="fillerDlg.fullDay" dense size="sm" color="deep-orange-6" label="Journée complète" @update:model-value="onFillerFullDay" />
|
<q-toggle v-model="fillerDlg.fullDay" dense size="sm" color="deep-orange-6" label="Journée complète" @update:model-value="onFillerFullDay" />
|
||||||
<q-input class="col" dense outlined type="date" v-model="fillerDlg.date" label="Date" />
|
<q-input class="col" dense outlined type="date" v-model="fillerDlg.date" label="Date" />
|
||||||
|
|
@ -1382,16 +1404,12 @@
|
||||||
<q-input class="col-4" dense outlined type="number" v-model.number="fillerDlg.dur" label="Durée (h)" min="0.5" step="0.5" :disable="fillerDlg.fullDay" />
|
<q-input class="col-4" dense outlined type="number" v-model.number="fillerDlg.dur" label="Durée (h)" min="0.5" step="0.5" :disable="fillerDlg.fullDay" />
|
||||||
<q-select class="col-4" dense outlined emit-value map-options v-model="fillerDlg.priority" :options="POOL_PRIOS" option-value="value" option-label="label" label="Priorité" />
|
<q-select class="col-4" dense outlined emit-value map-options v-model="fillerDlg.priority" :options="POOL_PRIOS" option-value="value" option-label="label" label="Priorité" />
|
||||||
</div>
|
</div>
|
||||||
<div class="text-caption text-grey-6">
|
<q-input dense outlined v-model="fillerDlg.job_type" label="Type" placeholder="Interne" />
|
||||||
<q-icon name="info" size="14px" />
|
|
||||||
<template v-if="fillerDlg.priority === 'high'"> Bloc <b>prioritaire</b> — même les réparations/urgences ne prendront pas ce tech.</template>
|
|
||||||
<template v-else> Occupe la période (dé-priorisé au dispatch). Les <b>réparations & urgences</b> peuvent quand même le prendre — passez la priorité à <b>Urgent</b> pour un bloc dur.</template>
|
|
||||||
</div>
|
|
||||||
<q-toggle v-model="fillerDlg.createTicket" dense size="sm" color="primary" label="Générer un ticket lié" />
|
<q-toggle v-model="fillerDlg.createTicket" dense size="sm" color="primary" label="Générer un ticket lié" />
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-card-actions align="right" class="q-px-md q-pb-md">
|
<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 flat no-caps label="Annuler" color="grey-7" v-close-popup />
|
||||||
<q-btn unelevated no-caps color="deep-orange-6" icon="event_busy" label="Bloquer" :loading="fillerDlg.busy" :disable="!fillerDlg.subject" @click="doBlockTime" />
|
<q-btn unelevated no-caps color="deep-orange-6" icon="add" label="Créer" :loading="fillerDlg.busy" :disable="!fillerDlg.subject" @click="doFiller" />
|
||||||
</q-card-actions>
|
</q-card-actions>
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
|
|
@ -2553,24 +2571,22 @@ 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
|
||||||
// BLOQUER DU TEMPS = un simple job/ticket qui OCCUPE la timeline (formation, réunion, projet, entretien…). Occupe la
|
// #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.
|
||||||
// journée ⇒ dé-priorise au dispatch. La PRIORITÉ est le curseur : Moyenne/Basse = urgences/réparations peuvent quand
|
const resvDlg = reactive({ open: false, tech: null, date: '', start: '08:00', dur: 2, reason: '', busy: false })
|
||||||
// même le prendre (suggestSlots pierce les blocs priorité ≤ moyenne en mode urgent) ; Urgent (high) = bloc dur.
|
function openReserve (t) {
|
||||||
// UN seul concept (fusion des ex- « Réserver du temps » + « Bloquer / job générique » — plus de mode soft/hard).
|
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 })
|
||||||
|
}
|
||||||
|
// Job « bouche-trou » (filler) : depuis le menu de cellule (clic case vide / clic droit). Pré-remplit tech+date+durée.
|
||||||
|
// La durée « journée complète » = longueur du quart de la case s'il existe, sinon 8 h. Occupe l'occupation → hors dispatch.
|
||||||
const fillerDlg = reactive({ open: false, tech: null, date: '', start: '08:00', dur: 8, fullDay: true, shiftH: 8, priority: 'medium', job_type: 'Interne', subject: '', createTicket: true, busy: false })
|
const fillerDlg = reactive({ open: false, tech: null, date: '', start: '08:00', dur: 8, fullDay: true, shiftH: 8, priority: 'medium', job_type: 'Interne', subject: '', createTicket: true, busy: false })
|
||||||
function shiftHoursOf (techId, iso) { // longueur cumulée des quarts de la case (0 si aucun)
|
function shiftHoursOf (techId, iso) { // longueur cumulée des quarts de la case (0 si aucun)
|
||||||
const occ = cellOcc(techId, iso)
|
const occ = cellOcc(techId, iso)
|
||||||
if (occ && occ.hasReg && occ.shiftE > occ.shiftS) return Math.round((occ.shiftE - occ.shiftS) * 10) / 10
|
if (occ && occ.hasReg && occ.shiftE > occ.shiftS) return Math.round((occ.shiftE - occ.shiftS) * 10) / 10
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
// Ouverture depuis le volet réglages du tech (bouton « Bloquer du temps… ») : jour = sélection courante, 2 h par défaut.
|
|
||||||
function openReserve (t) {
|
|
||||||
if (!t) return
|
|
||||||
skillMenuShown.value = false
|
|
||||||
const iso = selDay.value || mobileSelIso.value || kbSelIso.value || start.value || todayISO()
|
|
||||||
Object.assign(fillerDlg, { open: true, tech: { id: t.id, name: t.name }, date: iso, start: '08:00', dur: 2, fullDay: false, shiftH: 0, priority: 'medium', job_type: 'Interne', subject: '', createTicket: false, busy: false })
|
|
||||||
}
|
|
||||||
// Ouverture depuis le menu de cellule (clic case) : journée complète par défaut, pré-rempli tech+date+durée (= longueur du quart, sinon 8 h).
|
|
||||||
function openFillerFromMenu () {
|
function openFillerFromMenu () {
|
||||||
if (!menu.tech || !menu.day) return
|
if (!menu.tech || !menu.day) return
|
||||||
const sh = shiftHoursOf(menu.tech.id, menu.day.iso)
|
const sh = shiftHoursOf(menu.tech.id, menu.day.iso)
|
||||||
|
|
@ -2584,22 +2600,31 @@ function openFillerFromMenu () {
|
||||||
menu.show = false
|
menu.show = false
|
||||||
}
|
}
|
||||||
function onFillerFullDay (v) { if (v) { fillerDlg.dur = fillerDlg.shiftH || 8 } } // journée complète → durée = quart
|
function onFillerFullDay (v) { if (v) { fillerDlg.dur = fillerDlg.shiftH || 8 } } // journée complète → durée = quart
|
||||||
// Soumission UNIQUE : crée le job/ticket interne qui occupe la timeline (priorité = curseur dur/souple).
|
async function doFiller () {
|
||||||
async function doBlockTime () {
|
|
||||||
if (!fillerDlg.tech || !fillerDlg.date || !fillerDlg.subject || fillerDlg.busy) return
|
if (!fillerDlg.tech || !fillerDlg.date || !fillerDlg.subject || fillerDlg.busy) return
|
||||||
fillerDlg.busy = true
|
fillerDlg.busy = true
|
||||||
const dur = fillerDlg.fullDay ? (fillerDlg.shiftH || 8) : (Number(fillerDlg.dur) || 8)
|
|
||||||
try {
|
try {
|
||||||
|
const dur = fillerDlg.fullDay ? (fillerDlg.shiftH || 8) : (Number(fillerDlg.dur) || 8)
|
||||||
const r = await roster.createFillerJob({
|
const r = await roster.createFillerJob({
|
||||||
tech: fillerDlg.tech.id, date: fillerDlg.date, start_time: fillerDlg.start, duration_h: dur,
|
tech: fillerDlg.tech.id, date: fillerDlg.date, start_time: fillerDlg.start, duration_h: dur,
|
||||||
priority: fillerDlg.priority, job_type: fillerDlg.job_type || 'Interne', subject: fillerDlg.subject, create_ticket: fillerDlg.createTicket,
|
priority: fillerDlg.priority, job_type: fillerDlg.job_type || 'Interne', subject: fillerDlg.subject,
|
||||||
|
create_ticket: fillerDlg.createTicket,
|
||||||
})
|
})
|
||||||
if (r && r.ok) {
|
if (r && r.ok) {
|
||||||
const soft = fillerDlg.priority !== 'high'
|
$q.notify({ type: 'positive', icon: 'block', message: fillerDlg.tech.name + ' — ' + dur + 'h bloquées' + (r.issue ? ' · ticket ' + r.issue : '') })
|
||||||
$q.notify({ type: 'positive', icon: 'event_busy', message: fillerDlg.tech.name + ' — ' + dur + 'h bloquées' + (soft ? ' (urgences OK)' : ' (bloc dur)') + (r.issue ? ' · ticket ' + r.issue : '') })
|
fillerDlg.open = false
|
||||||
fillerDlg.open = false; try { await reloadOccupancy() } catch (e) {}
|
try { await reloadOccupancy() } catch (e) {}
|
||||||
} else $q.notify({ type: 'negative', message: (r && r.error) || 'Blocage impossible' })
|
} else $q.notify({ type: 'negative', message: (r && r.error) || 'Création impossible' })
|
||||||
} catch (e) { $q.notify({ type: 'negative', message: 'Blocage impossible : ' + (e.message || e) }) } finally { fillerDlg.busy = false }
|
} catch (e) { $q.notify({ type: 'negative', message: 'Création impossible : ' + (e.message || e) }) } finally { fillerDlg.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) {
|
||||||
|
|
|
||||||
|
|
@ -68,8 +68,7 @@
|
||||||
<!-- Lien PI déjà créé -->
|
<!-- Lien PI déjà créé -->
|
||||||
<q-banner v-if="selected.purchase_invoice" class="bg-green-1 text-green-9 q-mb-sm" dense rounded>
|
<q-banner v-if="selected.purchase_invoice" class="bg-green-1 text-green-9 q-mb-sm" dense rounded>
|
||||||
<q-icon name="check_circle" /> Brouillon créé : <b>{{ selected.purchase_invoice }}</b>
|
<q-icon name="check_circle" /> Brouillon créé : <b>{{ selected.purchase_invoice }}</b>
|
||||||
<!-- Desk ERPNext = escape hatch ADMIN (le brouillon PI n'a pas de vue native ; la finance le revoit au grand livre). -->
|
<template #action><q-btn flat dense color="green-9" icon="open_in_new" label="Ouvrir dans ERPNext" no-caps :href="erpBase + '/app/purchase-invoice/' + encodeURIComponent(selected.purchase_invoice)" target="_blank" /></template>
|
||||||
<template #action><q-btn v-if="can('manage_settings')" flat dense color="green-9" icon="open_in_new" label="Ouvrir dans ERPNext" no-caps :href="erpBase + '/app/purchase-invoice/' + encodeURIComponent(selected.purchase_invoice)" target="_blank" /></template>
|
|
||||||
</q-banner>
|
</q-banner>
|
||||||
|
|
||||||
<div class="row items-center q-gutter-sm">
|
<div class="row items-center q-gutter-sm">
|
||||||
|
|
@ -88,7 +87,6 @@
|
||||||
import { ref, computed, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { useQuasar } from 'quasar'
|
import { useQuasar } from 'quasar'
|
||||||
import { HUB_URL } from 'src/config/hub'
|
import { HUB_URL } from 'src/config/hub'
|
||||||
import { usePermissions } from 'src/composables/usePermissions'
|
|
||||||
import DynamicFilter from 'src/components/shared/DynamicFilter.vue'
|
import DynamicFilter from 'src/components/shared/DynamicFilter.vue'
|
||||||
|
|
||||||
const $q = useQuasar()
|
const $q = useQuasar()
|
||||||
|
|
@ -102,7 +100,6 @@ const filterState = ref({ search: '', chips: { statut: 'open' } })
|
||||||
const chipGroups = [{ key: 'statut', label: 'Statut', icon: 'label', options: [{ value: 'open', label: 'À traiter' }, { value: 'Created', label: 'Créées' }, { value: 'Ignored', label: 'Ignorées' }] }]
|
const chipGroups = [{ key: 'statut', label: 'Statut', icon: 'label', options: [{ value: 'open', label: 'À traiter' }, { value: 'Created', label: 'Créées' }, { value: 'Ignored', label: 'Ignorées' }] }]
|
||||||
const invoiceTo = ref('factures@targointernet.com')
|
const invoiceTo = ref('factures@targointernet.com')
|
||||||
const erpBase = ref('https://erp.gigafibre.ca')
|
const erpBase = ref('https://erp.gigafibre.ca')
|
||||||
const { can } = usePermissions() // lien desk PI = admin seulement
|
|
||||||
const imgError = ref(false)
|
const imgError = ref(false)
|
||||||
|
|
||||||
watch(selected, () => { imgError.value = false })
|
watch(selected, () => { imgError.value = false })
|
||||||
|
|
|
||||||
|
|
@ -170,8 +170,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
|
||||||
import { listDocs, countDocs } from 'src/api/erp'
|
import { listDocs, countDocs } from 'src/api/erp'
|
||||||
import DataTable from 'src/components/shared/DataTable.vue'
|
import DataTable from 'src/components/shared/DataTable.vue'
|
||||||
import { formatDate, staffColor, staffInitials } from 'src/composables/useFormatters'
|
import { formatDate, staffColor, staffInitials } from 'src/composables/useFormatters'
|
||||||
|
|
@ -367,21 +366,11 @@ function onIntervenedFollow ({ name, doctype, following }) {
|
||||||
myFollows.value = following ? [...new Set([...myFollows.value, name])] : myFollows.value.filter(n => n !== name)
|
myFollows.value = following ? [...new Set([...myFollows.value, name])] : myFollows.value.filter(n => n !== name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DEEP LINK « ouvrir ce ticket » : /#/tickets?open=ISS-… (utilisé par les courriels de notification du hub —
|
|
||||||
// fini le desk ERPNext). Ouvre le détail NATIF (DetailModal → IssueDetail) par-dessus la liste.
|
|
||||||
const route = useRoute()
|
|
||||||
function openFromQuery () {
|
|
||||||
const name = route.query && route.query.open
|
|
||||||
if (name) loadModalTicket(String(name))
|
|
||||||
}
|
|
||||||
watch(() => route.query && route.query.open, () => openFromQuery())
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([loadIssueTypes(), loadMyDepartments(), loadMyFollows()])
|
await Promise.all([loadIssueTypes(), loadMyDepartments(), loadMyFollows()])
|
||||||
ownerFilter.value = myMode.value === 'personnel' ? 'mine' : 'depts' // scope par défaut selon le mode de la personne (department/tags → « Mes départements »)
|
ownerFilter.value = myMode.value === 'personnel' ? 'mine' : 'depts' // scope par défaut selon le mode de la personne (department/tags → « Mes départements »)
|
||||||
pagination.value.rowsPerPage = ownerFilter.value === 'all' ? 25 : 100
|
pagination.value.rowsPerPage = ownerFilter.value === 'all' ? 25 : 100
|
||||||
await loadTickets()
|
await loadTickets()
|
||||||
openFromQuery()
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -157,7 +157,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', 'job_type', 'priority',
|
'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`),
|
||||||
|
|
@ -209,12 +209,8 @@ async function suggestSlots ({ duration_h = 1, latitude, longitude, after_date,
|
||||||
|
|
||||||
// Day's pinned jobs (only those with a real start_time) — servent à découper les trous.
|
// Day's pinned jobs (only those with a real start_time) — servent à découper les trous.
|
||||||
// Match par technician_id OU docname (les jobs legacy portent parfois l'un ou l'autre).
|
// Match par technician_id OU docname (les jobs legacy portent parfois l'un ou l'autre).
|
||||||
// BLOC DE TEMPS interne (formation/réunion/projet…) = job_type Interne/Réservation. En mode URGENT (ignoreReserved),
|
|
||||||
// la PRIORITÉ est le curseur : un bloc de priorité ≤ moyenne est TRAVERSÉ (l'urgence peut prendre le tech) ; un bloc
|
|
||||||
// « Urgent » (high) RÉSISTE = bloc dur. Les vrais jobs client ne sont JAMAIS traversés (jamais de double-booking).
|
|
||||||
const isSoftBlock = (j) => (j.job_type === 'Réservation' || j.job_type === 'Interne') && String(j.priority || 'medium').toLowerCase() !== 'high'
|
|
||||||
const belongs = (j) => (j.assigned_tech === tech.technician_id || j.assigned_tech === tech.name) &&
|
const belongs = (j) => (j.assigned_tech === tech.technician_id || j.assigned_tech === tech.name) &&
|
||||||
j.scheduled_date === dateStr && !(ignoreReserved && isSoftBlock(j))
|
j.scheduled_date === dateStr && !(ignoreReserved && j.job_type === 'Réservation')
|
||||||
const dayJobs = allJobs
|
const dayJobs = allJobs
|
||||||
.filter(j => belongs(j) && j.start_time)
|
.filter(j => belongs(j) && j.start_time)
|
||||||
.map(j => {
|
.map(j => {
|
||||||
|
|
|
||||||
|
|
@ -1017,7 +1017,7 @@ function pushAssignments (opts = {}) {
|
||||||
_syncLock = run.then(() => {}, () => {})
|
_syncLock = run.then(() => {}, () => {})
|
||||||
return run
|
return run
|
||||||
}
|
}
|
||||||
async function pushAssignmentsImpl ({ dryRun = false, actorEmail = '', notify = true, force = false } = {}) {
|
async function pushAssignmentsImpl ({ dryRun = false, actorEmail = '', notify = true } = {}) {
|
||||||
const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub')
|
const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub')
|
||||||
let actorStaff = 0 // staff legacy du répartiteur Ops (email Authentik → staff) = auteur du log/closed_by côté legacy
|
let actorStaff = 0 // staff legacy du répartiteur Ops (email Authentik → staff) = auteur du log/closed_by côté legacy
|
||||||
if (actorEmail) { try { const [ar] = await p.query('SELECT id FROM staff WHERE status=1 AND lower(email)=? LIMIT 1', [String(actorEmail).toLowerCase()]); if (ar && ar[0]) actorStaff = ar[0].id } catch (e) {} }
|
if (actorEmail) { try { const [ar] = await p.query('SELECT id FROM staff WHERE status=1 AND lower(email)=? LIMIT 1', [String(actorEmail).toLowerCase()]); if (ar && ar[0]) actorStaff = ar[0].id } catch (e) {} }
|
||||||
|
|
@ -1049,7 +1049,7 @@ async function pushAssignmentsImpl ({ dryRun = false, actorEmail = '', notify =
|
||||||
}
|
}
|
||||||
return { staff: null, via: null }
|
return { staff: null, via: null }
|
||||||
}
|
}
|
||||||
let pushed = 0, skipped = 0, mismatch = 0, locked = 0, errors = 0, notified = 0, conflicts = 0; const errSamples = []; const notifyErrors = []
|
let pushed = 0, skipped = 0, mismatch = 0, locked = 0, errors = 0, notified = 0; const errSamples = []; const notifyErrors = []
|
||||||
const samples = []
|
const samples = []
|
||||||
for (const j of jobs) {
|
for (const j of jobs) {
|
||||||
const techRow = techByName[j.assigned_tech]
|
const techRow = techByName[j.assigned_tech]
|
||||||
|
|
@ -1059,15 +1059,6 @@ async function pushAssignmentsImpl ({ dryRun = false, actorEmail = '', notify =
|
||||||
const staffId = String(st.id)
|
const staffId = String(st.id)
|
||||||
if (!tkrow || tkrow.status === 'closed') { skipped++; continue } // ticket fermé/introuvable → on ne réassigne pas
|
if (!tkrow || tkrow.status === 'closed') { skipped++; continue } // ticket fermé/introuvable → on ne réassigne pas
|
||||||
if (String(tkrow.assign_to) === staffId) { skipped++; continue } // déjà assigné au bon tech → idempotent
|
if (String(tkrow.assign_to) === staffId) { skipped++; continue } // déjà assigné au bon tech → idempotent
|
||||||
// GARDE ANTI-CLOBBER : F tient DÉJÀ un AUTRE vrai tech (≠ pool 3301). Le push en lot n'a AUCUN arbitrage de
|
|
||||||
// récence → écraser risquerait d'effacer une assignation F plus récente (et d'aviser le mauvais tech).
|
|
||||||
// → ignoré par défaut, visible dans l'aperçu ; force=1 pour écraser en connaissance de cause.
|
|
||||||
const fAt = parseInt(tkrow.assign_to, 10) || 0
|
|
||||||
if (!force && fAt > 0 && fAt !== TARGO_TECH_STAFF_ID) {
|
|
||||||
conflicts++
|
|
||||||
if (samples.length < 25) samples.push({ job: j.name, ticket: j.legacy_ticket_id, de_staff: tkrow.assign_to, vers_staff: staffId + ' (' + (st.first_name || '') + ' ' + (st.last_name || '') + ')', action: '⚠ CONFLIT : F a déjà un vrai tech — ignoré (force=1 pour écraser)' })
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (samples.length < 25) samples.push({ job: j.name, ticket: j.legacy_ticket_id, de_staff: tkrow.assign_to, vers_staff: staffId + ' (' + (st.first_name || '') + ' ' + (st.last_name || '') + ')', via, sujet: (j.subject || '').slice(0, 34) })
|
if (samples.length < 25) samples.push({ job: j.name, ticket: j.legacy_ticket_id, de_staff: tkrow.assign_to, vers_staff: staffId + ' (' + (st.first_name || '') + ' ' + (st.last_name || '') + ')', via, sujet: (j.subject || '').slice(0, 34) })
|
||||||
if (!dryRun) {
|
if (!dryRun) {
|
||||||
const w = await legacyWrite({ action: 'reassign', ticket_id: j.legacy_ticket_id, staff_id: staffId, actor_staff_id: actorStaff || '', notify: notify ? 1 : '', address: j.address || '' }).catch(e => ({ data: { ok: false, error: e.message } }))
|
const w = await legacyWrite({ action: 'reassign', ticket_id: j.legacy_ticket_id, staff_id: staffId, actor_staff_id: actorStaff || '', notify: notify ? 1 : '', address: j.address || '' }).catch(e => ({ data: { ok: false, error: e.message } }))
|
||||||
|
|
@ -1077,7 +1068,7 @@ async function pushAssignmentsImpl ({ dryRun = false, actorEmail = '', notify =
|
||||||
else { errors++; if (errSamples.length < 6) errSamples.push({ ticket: j.legacy_ticket_id, error: d.error || ('http ' + (w && w.status)) }) }
|
else { errors++; if (errSamples.length < 6) errSamples.push({ ticket: j.legacy_ticket_id, error: d.error || ('http ' + (w && w.status)) }) }
|
||||||
} else pushed++
|
} else pushed++
|
||||||
}
|
}
|
||||||
return { ok: true, dryRun, candidates: jobs.length, pushed, skipped, mismatch, conflicts, locked, errors, notified, notify_errors: notifyErrors, error_samples: errSamples, samples }
|
return { ok: true, dryRun, candidates: jobs.length, pushed, skipped, mismatch, locked, errors, notified, notify_errors: notifyErrors, error_samples: errSamples, samples }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-fermeture : un Dispatch Job issu du pont dont le ticket legacy est passé `closed` → on le marque « Completed »
|
// Auto-fermeture : un Dispatch Job issu du pont dont le ticket legacy est passé `closed` → on le marque « Completed »
|
||||||
|
|
@ -1098,21 +1089,6 @@ async function closeResolved () {
|
||||||
return { checked: djs.length, closed, details }
|
return { checked: djs.length, closed, details }
|
||||||
}
|
}
|
||||||
|
|
||||||
// État d'assignation LIVE d'un ticket F — garde-fou « assign-time » du roster (fenêtre aveugle du miroir ~3 min).
|
|
||||||
// Lecture seule, 1 requête. assigned = pris par un VRAI tech (≠ pool 3301) et pas fermé.
|
|
||||||
async function ticketAssignState (legacyId) {
|
|
||||||
const p = pool(); if (!p) return null
|
|
||||||
const id = parseInt(String(legacyId || '').replace(/[^0-9]/g, ''), 10); if (!id) return null
|
|
||||||
const [rows] = await p.query('SELECT t.id, t.status, t.assign_to, s.first_name, s.last_name FROM ticket t LEFT JOIN staff s ON s.id = t.assign_to WHERE t.id = ? LIMIT 1', [id])
|
|
||||||
const r = rows && rows[0]; if (!r) return null
|
|
||||||
const at = parseInt(r.assign_to, 10) || 0
|
|
||||||
return {
|
|
||||||
ticket: id, status: r.status, staff_id: at,
|
|
||||||
staff_name: [r.first_name, r.last_name].filter(Boolean).join(' '),
|
|
||||||
assigned: r.status !== 'closed' && at > 0 && at !== TARGO_TECH_STAFF_ID,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fil COMPLET d'un ticket legacy (description + commentaires/réponses des collaborateurs) — read-only.
|
// Fil COMPLET d'un ticket legacy (description + commentaires/réponses des collaborateurs) — read-only.
|
||||||
async function ticketThread (legacyId) {
|
async function ticketThread (legacyId) {
|
||||||
const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub')
|
const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub')
|
||||||
|
|
@ -1193,29 +1169,24 @@ async function loadStaffToTech () {
|
||||||
// MIROIR d'assignation : quand F assigne/réassigne un ticket à un vrai tech, on REFLÈTE le tech sur le Dispatch Job
|
// MIROIR d'assignation : quand F assigne/réassigne un ticket à un vrai tech, on REFLÈTE le tech sur le Dispatch Job
|
||||||
// (assigned_tech + status assigned + scheduled_date d'après le due_date) au lieu de l'annuler → la TRACE est conservée
|
// (assigned_tech + status assigned + scheduled_date d'après le due_date) au lieu de l'annuler → la TRACE est conservée
|
||||||
// (le job reste visible SOUS LE BON TECH dans le board et l'historique). Renvoie false si le tech n'est pas mappé.
|
// (le job reste visible SOUS LE BON TECH dans le board et l'historique). Renvoie false si le tech n'est pas mappé.
|
||||||
async function mirrorAssign (job, staffId, dueTs, staffToTech, dueTime) {
|
async function mirrorAssign (job, staffId, dueTs, staffToTech) {
|
||||||
const tech = staffToTech[staffId]; if (!tech) return false
|
const tech = staffToTech[staffId]; if (!tech) return false
|
||||||
const patch = { assigned_tech: tech, status: 'assigned' }
|
const patch = { assigned_tech: tech, status: 'assigned' }
|
||||||
const ts = Number(dueTs) || 0
|
const ts = Number(dueTs) || 0
|
||||||
if (ts > 0) { try { patch.scheduled_date = new Date(ts * 1000).toISOString().slice(0, 10) } catch (e) {} }
|
if (ts > 0) { try { patch.scheduled_date = new Date(ts * 1000).toISOString().slice(0, 10) } catch (e) {} }
|
||||||
// Heure MIROIR depuis F due_time (am→08:00, pm→13:00, HH:MM tel quel) : sans start_time le job n'occupe NI la
|
|
||||||
// capacité NI les créneaux → « on peut encore choisir le même tech » (double-booking). 'day'/inconnu → pas d'heure.
|
|
||||||
const stt = startTime(dueTime)
|
|
||||||
if (stt) patch.start_time = stt
|
|
||||||
try { await erp.update('Dispatch Job', job.name, patch); return true } catch (e) { return false }
|
try { await erp.update('Dispatch Job', job.name, patch); return true } catch (e) { return false }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function watchLegacy () {
|
async function watchLegacy () {
|
||||||
const p = pool(); if (!p) return { ok: false, error: 'mysql2 indisponible' }
|
const p = pool(); if (!p) return { ok: false, error: 'mysql2 indisponible' }
|
||||||
const jobs = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '!=', ''], ['status', 'in', ['open', 'assigned', 'On Hold', 'In Progress']]], fields: ['name', 'legacy_ticket_id', 'status', 'assigned_tech', 'start_time', 'subject'], limit: 5000 })
|
const jobs = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '!=', ''], ['status', 'in', ['open', 'assigned', 'On Hold', 'In Progress']]], fields: ['name', 'legacy_ticket_id', 'status', 'assigned_tech', 'subject'], limit: 5000 })
|
||||||
const jobByTk = new Map(); const ids = []
|
const jobByTk = new Map(); const ids = []
|
||||||
for (const j of (jobs || [])) { const id = String(j.legacy_ticket_id); jobByTk.set(id, j); const n = parseInt(id, 10); if (n) ids.push(n) }
|
for (const j of (jobs || [])) { const id = String(j.legacy_ticket_id); jobByTk.set(id, j); const n = parseInt(id, 10); if (n) ids.push(n) }
|
||||||
if (!ids.length) { _lastWatch = { at: new Date().toISOString(), tracked: 0, changes: 0 }; return { ok: true, tracked: 0, changes: [] } }
|
if (!ids.length) { _lastWatch = { at: new Date().toISOString(), tracked: 0, changes: 0 }; return { ok: true, tracked: 0, changes: [] } }
|
||||||
const [rows] = await p.query('SELECT id, status, assign_to, last_update, due_date, due_time FROM ticket WHERE id IN (?)', [ids])
|
const [rows] = await p.query('SELECT id, status, assign_to, last_update, due_date FROM ticket WHERE id IN (?)', [ids])
|
||||||
const staffToTech = await loadStaffToTech() // pour REFLÉTER l'assignation F sur le job (conserver la trace)
|
const staffToTech = await loadStaffToTech() // pour REFLÉTER l'assignation F sur le job (conserver la trace)
|
||||||
const seeded = _watchSnap.size > 0 // 1er passage (snapshot vide) = amorçage silencieux des DELTAS
|
const seeded = _watchSnap.size > 0 // 1er passage (snapshot vide) = amorçage silencieux des DELTAS
|
||||||
const changes = []; let closedNow = 0; let pulled = 0; let mirrored = 0
|
const changes = []; let closedNow = 0; let pulled = 0; let mirrored = 0
|
||||||
let timed = 0; let fillBudget = 80 // remplissage start_time par passage, borné (écritures séquentielles frappe_pg)
|
|
||||||
for (const r of (rows || [])) {
|
for (const r of (rows || [])) {
|
||||||
const id = String(r.id)
|
const id = String(r.id)
|
||||||
const prev = _watchSnap.get(id)
|
const prev = _watchSnap.get(id)
|
||||||
|
|
@ -1229,7 +1200,7 @@ async function watchLegacy () {
|
||||||
if (!j.assigned_tech && (j.status === 'open' || j.status === 'On Hold') && cur.status !== 'closed' && at > 0 && at !== TARGO_TECH_STAFF_ID) {
|
if (!j.assigned_tech && (j.status === 'open' || j.status === 'On Hold') && cur.status !== 'closed' && at > 0 && at !== TARGO_TECH_STAFF_ID) {
|
||||||
// F a assigné ce job (du pool) à un vrai tech. On REFLÈTE le tech sur le job (trace conservée, visible sous le bon
|
// F a assigné ce job (du pool) à un vrai tech. On REFLÈTE le tech sur le job (trace conservée, visible sous le bon
|
||||||
// tech) plutôt que de l'annuler. Repli sur Cancelled seulement si le tech F n'a pas de fiche Dispatch Technician.
|
// tech) plutôt que de l'annuler. Repli sur Cancelled seulement si le tech F n'a pas de fiche Dispatch Technician.
|
||||||
if (await mirrorAssign(j, at, r.due_date, staffToTech, r.due_time)) {
|
if (await mirrorAssign(j, at, cur.due_date, staffToTech)) {
|
||||||
mirrored++; audit.record({ job: j.name, ticket: id, event: 'mirror-assigned', to: staffToTech[at], actor: 'F', source: 'F-watch' })
|
mirrored++; audit.record({ job: j.name, ticket: id, event: 'mirror-assigned', to: staffToTech[at], actor: 'F', source: 'F-watch' })
|
||||||
changes.push({ ticket: id, job: j.name, kind: 'mirror-assigned', to_staff: cur.assign_to, tech: staffToTech[at], subject: j.subject || '' })
|
changes.push({ ticket: id, job: j.name, kind: 'mirror-assigned', to_staff: cur.assign_to, tech: staffToTech[at], subject: j.subject || '' })
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1237,13 +1208,6 @@ async function watchLegacy () {
|
||||||
}
|
}
|
||||||
continue // reflété (ou sorti du pool) → pas d'autre delta à émettre
|
continue // reflété (ou sorti du pool) → pas d'autre delta à émettre
|
||||||
}
|
}
|
||||||
// (1b) REMPLISSAGE start_time (fill-only, idempotent) : job assigné SANS heure alors que F a un due_time
|
|
||||||
// exploitable (am/pm/HH:MM). Rattrape le stock miroré AVANT ce fix — sans heure, le job n'occupe ni la
|
|
||||||
// capacité ni les créneaux (double-booking). Ne touche JAMAIS une heure déjà posée par le répartiteur.
|
|
||||||
if (fillBudget > 0 && j.assigned_tech && !j.start_time && cur.status !== 'closed') {
|
|
||||||
const stt = startTime(r.due_time)
|
|
||||||
if (stt) { try { await erp.update('Dispatch Job', j.name, { start_time: stt }); j.start_time = stt; timed++; fillBudget-- } catch (e) {} }
|
|
||||||
}
|
|
||||||
// (2) DELTAS diffusés — seulement une fois le snapshot amorcé
|
// (2) DELTAS diffusés — seulement une fois le snapshot amorcé
|
||||||
if (!seeded || !prev) continue
|
if (!seeded || !prev) continue
|
||||||
if (prev.status !== cur.status) {
|
if (prev.status !== cur.status) {
|
||||||
|
|
@ -1255,7 +1219,7 @@ async function watchLegacy () {
|
||||||
} else if (prev.assign_to !== cur.assign_to) {
|
} else if (prev.assign_to !== cur.assign_to) {
|
||||||
const nat = parseInt(cur.assign_to, 10) || 0
|
const nat = parseInt(cur.assign_to, 10) || 0
|
||||||
// Réassignation F (tech → autre tech) → refléter le nouveau tech sur le job pour que la trace suive la réalité F.
|
// Réassignation F (tech → autre tech) → refléter le nouveau tech sur le job pour que la trace suive la réalité F.
|
||||||
if (nat > 0 && nat !== TARGO_TECH_STAFF_ID && await mirrorAssign(j, nat, r.due_date, staffToTech, r.due_time)) mirrored++
|
if (nat > 0 && nat !== TARGO_TECH_STAFF_ID && await mirrorAssign(j, nat, cur.due_date, staffToTech)) mirrored++
|
||||||
audit.record({ job: j.name, ticket: id, event: 'reassigned', from: j.assigned_tech || '', to: staffToTech[nat] || (nat === TARGO_TECH_STAFF_ID ? '(pool)' : ''), actor: 'F', source: 'F-watch' })
|
audit.record({ job: j.name, ticket: id, event: 'reassigned', from: j.assigned_tech || '', to: staffToTech[nat] || (nat === TARGO_TECH_STAFF_ID ? '(pool)' : ''), actor: 'F', source: 'F-watch' })
|
||||||
changes.push({ ticket: id, job: j.name, kind: 'reassigned', to_staff: cur.assign_to, pool: cur.assign_to === String(TARGO_TECH_STAFF_ID), tech: staffToTech[nat] || j.assigned_tech || '', subject: j.subject || '' })
|
changes.push({ ticket: id, job: j.name, kind: 'reassigned', to_staff: cur.assign_to, pool: cur.assign_to === String(TARGO_TECH_STAFF_ID), tech: staffToTech[nat] || j.assigned_tech || '', subject: j.subject || '' })
|
||||||
} else if (prev.last_update !== cur.last_update) {
|
} else if (prev.last_update !== cur.last_update) {
|
||||||
|
|
@ -1263,8 +1227,8 @@ async function watchLegacy () {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (changes.length) sse.broadcast('dispatch', 'legacy-update', { at: new Date().toISOString(), changes })
|
if (changes.length) sse.broadcast('dispatch', 'legacy-update', { at: new Date().toISOString(), changes })
|
||||||
_lastWatch = { at: new Date().toISOString(), tracked: ids.length, seeded, closed: closedNow, pulled, mirrored, timed, changes: changes.length }
|
_lastWatch = { at: new Date().toISOString(), tracked: ids.length, seeded, closed: closedNow, pulled, mirrored, changes: changes.length }
|
||||||
return { ok: true, tracked: ids.length, seeded, closed: closedNow, pulled, mirrored, timed, changes }
|
return { ok: true, tracked: ids.length, seeded, closed: closedNow, pulled, mirrored, changes }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── RÉCONCILIATION DES TECHNICIENS (union des 3 systèmes) — RAPPORT + apply manuel ──────────────────────────
|
// ── RÉCONCILIATION DES TECHNICIENS (union des 3 systèmes) — RAPPORT + apply manuel ──────────────────────────
|
||||||
|
|
@ -1983,13 +1947,10 @@ async function handle (req, res, method, path) {
|
||||||
if (path === '/dispatch/legacy-sync/fill-coords' && method === 'POST') return json(res, 200, await fillMissingCoords({ dryRun: false })) // applique
|
if (path === '/dispatch/legacy-sync/fill-coords' && method === 'POST') return json(res, 200, await fillMissingCoords({ dryRun: false })) // applique
|
||||||
if (path === '/dispatch/legacy-sync/fix-geocoding' && method === 'GET') return json(res, 200, await fixGeocoding({ dryRun: true })) // aperçu correction coords manquantes/hors-zone
|
if (path === '/dispatch/legacy-sync/fix-geocoding' && method === 'GET') return json(res, 200, await fixGeocoding({ dryRun: true })) // aperçu correction coords manquantes/hors-zone
|
||||||
if (path === '/dispatch/legacy-sync/fix-geocoding' && method === 'POST') return json(res, 200, await fixGeocoding({ dryRun: false })) // applique
|
if (path === '/dispatch/legacy-sync/fix-geocoding' && method === 'POST') return json(res, 200, await fixGeocoding({ dryRun: false })) // applique
|
||||||
if (path === '/dispatch/legacy-sync/push-assignments' && method === 'GET') { // APERÇU write-back tech → legacy (0 écriture) ; force=1 simule l'écrasement des conflits
|
if (path === '/dispatch/legacy-sync/push-assignments' && method === 'GET') return json(res, 200, await pushAssignments({ dryRun: true })) // APERÇU write-back tech → legacy (0 écriture)
|
||||||
const force = new URL(req.url, 'http://localhost').searchParams.get('force') === '1'
|
if (path === '/dispatch/legacy-sync/push-assignments' && method === 'POST') { // ÉCRIT ticket.assign_to + log attribué à l'acteur Authentik ; notify=0 coupe l'avis courriel
|
||||||
return json(res, 200, await pushAssignments({ dryRun: true, force }))
|
const notify = new URL(req.url, 'http://localhost').searchParams.get('notify') !== '0'
|
||||||
}
|
return json(res, 200, await pushAssignments({ dryRun: false, actorEmail: req.headers['x-authentik-email'] || '', notify }))
|
||||||
if (path === '/dispatch/legacy-sync/push-assignments' && method === 'POST') { // ÉCRIT ticket.assign_to + log attribué à l'acteur Authentik ; notify=0 coupe l'avis courriel ; force=1 écrase les conflits F
|
|
||||||
const sp = new URL(req.url, 'http://localhost').searchParams
|
|
||||||
return json(res, 200, await pushAssignments({ dryRun: false, actorEmail: req.headers['x-authentik-email'] || '', notify: sp.get('notify') !== '0', force: sp.get('force') === '1' }))
|
|
||||||
}
|
}
|
||||||
if (path === '/dispatch/legacy-sync/close-ticket' && method === 'POST') { // ferme un ticket legacy + marque le Dispatch Job Completed
|
if (path === '/dispatch/legacy-sync/close-ticket' && method === 'POST') { // ferme un ticket legacy + marque le Dispatch Job Completed
|
||||||
const id = new URL(req.url, 'http://localhost').searchParams.get('ticket'); if (!id) return json(res, 400, { ok: false, error: 'ticket requis' })
|
const id = new URL(req.url, 'http://localhost').searchParams.get('ticket'); if (!id) return json(res, 400, { ok: false, error: 'ticket requis' })
|
||||||
|
|
@ -2722,4 +2683,4 @@ async function provisionIdentities ({ dryRun = true, scope = 'ledger' } = {}) {
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { handle, sync, reimportAddresses, fillMissingCoords, fixGeocoding, purgeStaleOrphans, pushAssignments, returnToPool, postTicketLegacy, ticketThread, ticketAssignState, watchLegacy, techSyncReport, techSyncApply, mineDurations, legacyTechTickets, legacyWindowLoad, ingestAssigned, reconcileLegacyJobs, backfillServiceLocations, backfillIssueCustomers, backfillSourceIssue, backfillDependsOn, startSync, stopSync, fetchTargoTickets, staleTickets, staleNudge, publishPreview, publishJob, techHistoryBackfill, techHistory, provisionIdentities, 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, backfillServiceLocations, backfillIssueCustomers, backfillSourceIssue, backfillDependsOn, startSync, stopSync, fetchTargoTickets, staleTickets, staleNudge, publishPreview, publishJob, techHistoryBackfill, techHistory, provisionIdentities, coord, prio, startTime, jobType, inTerritory, geocodeRQA, persistGeocodeToSL, reverseNearestFibre } // parseurs purs exposés pour les tests
|
||||||
|
|
|
||||||
|
|
@ -138,10 +138,7 @@ function digits (v) { return String(v == null ? '' : v).replace(/\D/g, '') }
|
||||||
// séparés par ; ou ,. ERPNext.email_id n'accepte qu'UN email → on garde le 1er valide comme principal
|
// séparés par ; ou ,. ERPNext.email_id n'accepte qu'UN email → on garde le 1er valide comme principal
|
||||||
// et la LISTE COMPLÈTE va dans email_billing (CC facturation, emails secondaires importés).
|
// et la LISTE COMPLÈTE va dans email_billing (CC facturation, emails secondaires importés).
|
||||||
function emailList (v) {
|
function emailList (v) {
|
||||||
// F contient parfois des caractères INVISIBLES dans les courriels (soft hyphen U+00AD, zero-width — collages
|
return clean(v).toLowerCase().split(/[;,]/).map(s => s.trim()).filter(s => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(s))
|
||||||
// Outlook/Word). Ils passent notre regex (pas des \s) mais ERPNext les rejette (417 InvalidEmailAddress en
|
|
||||||
// boucle à chaque sync, ex. reynet69[U+00AD]@yahoo.com). On les strippe AVANT de valider.
|
|
||||||
return clean(v).toLowerCase().replace(/[\u00ad\u200b-\u200d\u2060\ufeff]/g, '').split(/[;,]/).map(s => s.trim()).filter(s => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(s))
|
|
||||||
}
|
}
|
||||||
function firstEmail (v) { return emailList(v)[0] || '' }
|
function firstEmail (v) { return emailList(v)[0] || '' }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1043,17 +1043,6 @@ async function handleFieldTech (req, res, method, path, url) {
|
||||||
await retryWrite(() => erp.update('Dispatch Job', name, { completion_notes: (((j && j.completion_notes) || '') + note).slice(-4000) }))
|
await retryWrite(() => erp.update('Dispatch Job', name, { completion_notes: (((j && j.completion_notes) || '') + note).slice(-4000) }))
|
||||||
return json(res, 200, { ok: true, file: fn, distM, onsite, distLabel: distM != null ? fmtDistM(distM) : null })
|
return json(res, 200, { ok: true, file: fn, distM, onsite, distLabel: distM != null ? fmtDistM(distM) : null })
|
||||||
}
|
}
|
||||||
if (path === '/field/photo-file' && method === 'GET') { // sert UNE photo terrain — URL signée (fieldSign du job), anti-traversal strict
|
|
||||||
const name = fieldVerify(token); if (!name) return json(res, 403, { ok: false, error: 'lien invalide' })
|
|
||||||
const f = String(url.searchParams.get('f') || '')
|
|
||||||
const prefix = 'f_' + String(name).replace(/[^a-zA-Z0-9_-]/g, '') + '_'
|
|
||||||
if (!/^f_[a-zA-Z0-9_-]+_\d+\.(jpg|png)$/.test(f) || !f.startsWith(prefix)) return json(res, 400, { ok: false, error: 'fichier invalide' })
|
|
||||||
try {
|
|
||||||
const buf = require('fs').readFileSync('/app/uploads/field/' + f)
|
|
||||||
res.writeHead(200, { 'Content-Type': f.endsWith('.png') ? 'image/png' : 'image/jpeg', 'Cache-Control': 'private, max-age=86400', 'Content-Length': buf.length })
|
|
||||||
return res.end(buf)
|
|
||||||
} catch (e) { return json(res, 404, { ok: false, error: 'photo introuvable' }) }
|
|
||||||
}
|
|
||||||
if (path === '/field/device' && method === 'POST') { // appareil scané (série/MAC) → checkpoint présence + note (GenieACS à brancher)
|
if (path === '/field/device' && method === 'POST') { // appareil scané (série/MAC) → checkpoint présence + note (GenieACS à brancher)
|
||||||
const b = await parseBody(req); const name = fieldVerify(b.token || token); if (!name) return json(res, 403, { ok: false, error: 'lien invalide' })
|
const b = await parseBody(req); const name = fieldVerify(b.token || token); if (!name) return json(res, 403, { ok: false, error: 'lien invalide' })
|
||||||
const serial = String(b.serial || '').slice(0, 60), mac = String(b.mac || '').slice(0, 40), kind = String(b.kind || 'appareil').slice(0, 30)
|
const serial = String(b.serial || '').slice(0, 60), mac = String(b.mac || '').slice(0, 40), kind = String(b.kind || 'appareil').slice(0, 30)
|
||||||
|
|
@ -1282,17 +1271,9 @@ async function capacityByDay (start, days, skills = null) {
|
||||||
const sh = j.start_time ? timeToH(j.start_time) : null
|
const sh = j.start_time ? timeToH(j.start_time) : null
|
||||||
if (sh != null && dur > 0) DAY_SEGMENTS.forEach((seg, i) => { o.segments[i].used += segOverlap(sh, sh + dur, seg.from, seg.to) })
|
if (sh != null && dur > 0) DAY_SEGMENTS.forEach((seg, i) => { o.segments[i].used += segOverlap(sh, sh + dur, seg.from, seg.to) })
|
||||||
else if (!j.assigned_tech) o.unplaced_h += dur // dû mais pas encore placé sur l'horaire
|
else if (!j.assigned_tech) o.unplaced_h += dur // dû mais pas encore placé sur l'horaire
|
||||||
// Job ASSIGNÉ sans heure (miroir F due_time 'day', legacy) : il occupe QUAND MÊME le tech ce jour-là.
|
|
||||||
// Avant : ni used ni unplaced → la journée paraissait libre et on suggérait le même tech (double-booking).
|
|
||||||
else if (dur > 0) o.untimed_used_h = (o.untimed_used_h || 0) + dur
|
|
||||||
}
|
}
|
||||||
for (const d of dates) {
|
for (const d of dates) {
|
||||||
const o = out[d]
|
const o = out[d]
|
||||||
// Répartir la charge assignée SANS heure au prorata de la capacité de chaque segment (on ne sait pas QUAND,
|
|
||||||
// mais on sait que ces heures ne sont plus libres).
|
|
||||||
const unt = o.untimed_used_h || 0
|
|
||||||
if (unt > 0) { const capTot = o.segments.reduce((x, s) => x + s.cap, 0); if (capTot > 0) o.segments.forEach(s => { s.used += unt * (s.cap / capTot) }) }
|
|
||||||
o.untimed_used_h = r1(unt)
|
|
||||||
o.segments.forEach(s => { s.cap = r1(s.cap); s.used = r1(s.used); s.free = r1(s.cap - s.used) })
|
o.segments.forEach(s => { s.cap = r1(s.cap); s.used = r1(s.used); s.free = r1(s.cap - s.used) })
|
||||||
o.cap_h = r1(o.segments.reduce((x, s) => x + s.cap, 0))
|
o.cap_h = r1(o.segments.reduce((x, s) => x + s.cap, 0))
|
||||||
o.used_h = r1(o.segments.reduce((x, s) => x + s.used, 0))
|
o.used_h = r1(o.segments.reduce((x, s) => x + s.used, 0))
|
||||||
|
|
@ -1633,20 +1614,8 @@ async function handle (req, res, method, path, url) {
|
||||||
// les heures occupées mais n'affiche AUCUN bloc sur la timeline → la barre d'occupation semble figée.
|
// les heures occupées mais n'affiche AUCUN bloc sur la timeline → la barre d'occupation semble figée.
|
||||||
if (path === '/roster/assign-job' && method === 'POST') {
|
if (path === '/roster/assign-job' && method === 'POST') {
|
||||||
const b = await parseBody(req); if (!b.job || !b.tech) return json(res, 400, { error: 'job + tech requis' })
|
const b = await parseBody(req); if (!b.job || !b.tech) return json(res, 400, { error: 'job + tech requis' })
|
||||||
let dur = 1; let legacyId = ''
|
let dur = 1
|
||||||
try { const jb = await erp.get('Dispatch Job', b.job, { fields: ['duration_h', 'legacy_ticket_id'] }); dur = Number(jb && jb.duration_h) || 1; legacyId = (jb && jb.legacy_ticket_id) || '' } catch (e) {}
|
try { const jb = await erp.get('Dispatch Job', b.job, { fields: ['duration_h'] }); dur = Number(jb && jb.duration_h) || 1 } catch (e) {}
|
||||||
// GARDE-FOU F (fenêtre aveugle du miroir ~3 min) : si le ticket legacy est DÉJÀ assigné dans F à un AUTRE vrai
|
|
||||||
// tech, on refuse (409 conflict) sauf force=true — le SPA affiche « Déjà assigné dans F à X — réassigner ? ».
|
|
||||||
// Best-effort : F injoignable ⇒ on n'empêche PAS l'assignation.
|
|
||||||
if (legacyId && !b.force) {
|
|
||||||
try {
|
|
||||||
const st = await require('./legacy-dispatch-sync').ticketAssignState(legacyId)
|
|
||||||
const sidTarget = (String(b.tech).match(/(\d{2,})$/) || [])[1] || ''
|
|
||||||
if (st && st.assigned && String(st.staff_id) !== sidTarget) {
|
|
||||||
return json(res, 409, { ok: false, conflict: true, ticket: legacyId, f_staff_id: st.staff_id, f_staff_name: st.staff_name || '', f_status: st.status || '', error: 'Déjà assigné dans F à ' + (st.staff_name || ('staff #' + st.staff_id)) })
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
const patch = { assigned_tech: b.tech, status: 'assigned', duration_h: dur } // duration_h garanti → occupation comptée
|
const patch = { assigned_tech: b.tech, status: 'assigned', duration_h: dur } // duration_h garanti → occupation comptée
|
||||||
if (b.date) patch.scheduled_date = b.date
|
if (b.date) patch.scheduled_date = b.date
|
||||||
let placed = null
|
let placed = null
|
||||||
|
|
@ -1668,32 +1637,6 @@ async function handle (req, res, method, path, url) {
|
||||||
const m = setJobSkills(b.name, b.skills); invalidatePool() // le pool porte required_skills/required_skill → rafraîchir
|
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)] || [] })
|
return json(res, 200, { ok: true, name: b.name, skills: m[String(b.name)] || [] })
|
||||||
}
|
}
|
||||||
// MÉDIAS TERRAIN d'un job : photos prises par le tech (app terrain, /field/photo) + journal completion_notes
|
|
||||||
// (arrivées géofence, scans d'appareil, notes). Sert le module « Photos & activité terrain » du détail ticket/job.
|
|
||||||
// Les <img> passent par /field/photo-file (URL signée fieldSign — même modèle de jeton que l'app terrain).
|
|
||||||
if (path === '/roster/job-media' && method === 'GET') {
|
|
||||||
const name = qs.get('job') || ''
|
|
||||||
if (!name) return json(res, 400, { ok: false, error: 'job requis' })
|
|
||||||
let notes = ''
|
|
||||||
try { const r = await erp.list('Dispatch Job', { filters: [['name', '=', name]], fields: ['completion_notes'], limit: 1 }); notes = (r && r[0] && r[0].completion_notes) || '' } catch (e) {}
|
|
||||||
const fs = require('fs'); const dir = '/app/uploads/field'
|
|
||||||
const prefix = 'f_' + String(name).replace(/[^a-zA-Z0-9_-]/g, '') + '_'
|
|
||||||
let photos = []
|
|
||||||
try {
|
|
||||||
photos = fs.readdirSync(dir).filter(f => f.startsWith(prefix) && /\.(jpg|png)$/.test(f))
|
|
||||||
.map(f => { const ts = Number((f.match(/_(\d{10,})\./) || [])[1]) || 0; return { file: f, at: ts ? new Date(ts).toISOString() : null, ts } })
|
|
||||||
.sort((a, b) => b.ts - a.ts).slice(0, 40)
|
|
||||||
} catch (e) {}
|
|
||||||
const base = (cfg.PUBLIC_BASE || 'https://msg.gigafibre.ca').replace(/\/$/, '')
|
|
||||||
const tok = fieldSign(name)
|
|
||||||
for (const ph of photos) {
|
|
||||||
ph.url = base + '/field/photo-file?t=' + encodeURIComponent(tok) + '&f=' + encodeURIComponent(ph.file)
|
|
||||||
// Contexte de la photo depuis le journal (sur place / distance / note) — la ligne contient le nom de fichier.
|
|
||||||
const line = (notes.split('\n') || []).find(l => l.includes(ph.file))
|
|
||||||
if (line) { ph.onsite = line.includes('✓ sur place'); ph.offsite = line.includes('⚠ à '); const m = line.match(/📍 (✓ sur place \(≈[^)]+\)|⚠ à [^·—]+)/); ph.geo = m ? m[1].trim() : ''; const n = line.match(/—\s*(.+)$/); ph.note = n ? n[1].trim() : '' }
|
|
||||||
}
|
|
||||||
return json(res, 200, { ok: true, job: name, photos, notes })
|
|
||||||
}
|
|
||||||
// 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é.
|
// 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.
|
// 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,
|
// #5 — Bloc RÉSERVÉ : réserve le temps d'un tech pour une tâche/projet = Dispatch Job job_type='Réservation', priorité MOYENNE,
|
||||||
|
|
|
||||||
|
|
@ -316,11 +316,7 @@ async function addComment (doctype, name, { text, mentions = [], agent } = {}) {
|
||||||
if (ment.length) {
|
if (ment.length) {
|
||||||
const gmail = require('./gmail')
|
const gmail = require('./gmail')
|
||||||
const subj = (doctype === 'Issue' ? 'Ticket ' : doctype + ' ') + name
|
const subj = (doctype === 'Issue' ? 'Ticket ' : doctype + ' ') + name
|
||||||
// Deep link OPS (détail natif) — plus JAMAIS le desk ERPNext pour les tickets. Autres doctypes : desk en repli
|
const link = ERP_URL() + '/app/' + doctype.toLowerCase().replace(/ /g, '-') + '/' + encodeURIComponent(name)
|
||||||
// (rare ; à migrer quand un deep link générique existera).
|
|
||||||
const link = doctype === 'Issue'
|
|
||||||
? OPS_URL() + '/#/tickets?open=' + encodeURIComponent(name)
|
|
||||||
: ERP_URL() + '/app/' + doctype.toLowerCase().replace(/ /g, '-') + '/' + encodeURIComponent(name)
|
|
||||||
const body = `${shortName(agent) || 'Un collègue'} t'a mentionné sur ${subj} :\n\n${content}\n\nOuvrir : ${link}\n(OPS — collaboration, ceci ne change pas l'assignation du ticket.)`
|
const body = `${shortName(agent) || 'Un collègue'} t'a mentionné sur ${subj} :\n\n${content}\n\nOuvrir : ${link}\n(OPS — collaboration, ceci ne change pas l'assignation du ticket.)`
|
||||||
for (const to of ment) { try { await gmail.sendMessage({ to, subject: `[OPS] mention — ${subj}`, body }) } catch (e) { log('collab notify ' + to + ': ' + e.message) } }
|
for (const to of ment) { try { await gmail.sendMessage({ to, subject: `[OPS] mention — ${subj}`, body }) } catch (e) { log('collab notify ' + to + ': ' + e.message) } }
|
||||||
}
|
}
|
||||||
|
|
@ -348,7 +344,7 @@ async function createTicket ({ title, category, priority, description, customer,
|
||||||
if (queue) {
|
if (queue) {
|
||||||
const members = (readJsonFile('/app/data/queue_members.json', {})[queue] || []).filter(e => /.+@.+\..+/.test(e))
|
const members = (readJsonFile('/app/data/queue_members.json', {})[queue] || []).filter(e => /.+@.+\..+/.test(e))
|
||||||
if (members.length) {
|
if (members.length) {
|
||||||
const link = OPS_URL() + '/#/tickets?open=' + encodeURIComponent(r.name) // détail natif OPS (fini le desk ERPNext)
|
const link = ERP_URL() + '/app/issue/' + encodeURIComponent(r.name)
|
||||||
const body = `Nouveau ticket pour l'équipe « ${queue} » :\n\n${data.subject}\n${customer_name ? 'Client : ' + customer_name + '\n' : ''}${description ? '\n' + description + '\n' : ''}\nOuvrir : ${link}\n— TARGO OPS`
|
const body = `Nouveau ticket pour l'équipe « ${queue} » :\n\n${data.subject}\n${customer_name ? 'Client : ' + customer_name + '\n' : ''}${description ? '\n' + description + '\n' : ''}\nOuvrir : ${link}\n— TARGO OPS`
|
||||||
try { await require('./outbox').enqueue({ to: members.join(','), subject: `[Ticket · ${queue}] ${data.subject}`.slice(0, 90), body }, { kind: 'ticket-notif', label: queue }) } catch (e) { log('createTicket notify ' + queue + ': ' + e.message) }
|
try { await require('./outbox').enqueue({ to: members.join(','), subject: `[Ticket · ${queue}] ${data.subject}`.slice(0, 90), body }, { kind: 'ticket-notif', label: queue }) } catch (e) { log('createTicket notify ' + queue + ': ' + e.message) }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user