ops : priorités 3 niveaux (source unique) + compétences multi + création unifiée + planif
- config/dispatch-priority.js (NOUVEAU) : DISPATCH_PRIORITIES = SOURCE UNIQUE des 3 niveaux (Urgent/Moyenne/Basse, couleurs 🔴🟠⚪) — élimine 5 copies locales (UnifiedCreateModal, TaskNode, CreateOfferModal, TechJobDetailPage, PlanificationPage POOL_PRIOS) et corrige la dérive « Moyenne » bleue de CreateOfferModal - PlanificationPage/TechJobDetailPage : éditeur de compétences SkillSelect → /roster/job-skills ; FIX : SkillSelect émet une CSV (pas un tableau) → les handlers n'effaçaient plus les compétences ; panneau job (statut/reporter/réserver un tech) ; sommaire avant publication + auto-save + journal + annuler - UnifiedCreateModal/ProjectWizard/QuoteWizard/TaskNode : formulaire de création unifié, divulgation progressive, dédup client (courriel/téléphone) + message d'erreur convivial ; suppression de NewTicketDialog (fusionné) - IssueDetail : fil de ticket (nom d'expéditeur) ; app terrain lecture/commentaire - useConversationDisplay/useHelpers/wizard-constants : priorités alignées sur 3 niveaux Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
4870cec09b
commit
7ca3ceef66
|
|
@ -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 generate = (start, days = 7, weights) => jpost('/roster/generate', { start, days, weights })
|
||||
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.
|
||||
export const notifyDispatch = (payload) => jpost('/roster/notify-dispatch', payload)
|
||||
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 savePolicy = (policy) => jpost('/roster/policy', policy)
|
||||
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 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) {
|
||||
const r = await fetch(HUB + '/roster/template/' + encodeURIComponent(name), { method: 'DELETE' })
|
||||
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 },
|
||||
existingJobs: { type: Array, 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'])
|
||||
|
|
@ -2169,6 +2170,11 @@ watch(() => props.modelValue, (v) => {
|
|||
// Launched from an existing Issue → start at step 0 so the agent can pick
|
||||
// a project template aligned with the ticket.
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -45,7 +45,33 @@
|
|||
</div>
|
||||
</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">
|
||||
<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>
|
||||
|
|
@ -81,8 +107,9 @@
|
|||
<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-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-else unelevated no-caps color="teal-8" icon-right="request_quote" label="Continuer vers les services" :loading="submitting" @click="finish" />
|
||||
<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-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>
|
||||
</q-dialog>
|
||||
|
|
@ -94,13 +121,15 @@ import { useQuasar } from 'quasar'
|
|||
import { useAddressSearch } from 'src/composables/useAddressSearch'
|
||||
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 { INTERNET_HERO_TIERS } from 'src/data/wizard-constants' // forfaits « héros » (prix promo 24 mois) — vitrine AVANT le contact
|
||||
|
||||
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 $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 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 }
|
||||
|
|
@ -108,7 +137,12 @@ function go (i) { const n = Math.min(steps.length - 1, Math.max(0, i)); step.val
|
|||
// ── Étape 1 : adresse + fibre ──
|
||||
const { addrResults, addrLoading, searchAddr } = useAddressSearch()
|
||||
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) {
|
||||
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
|
||||
|
|
@ -158,24 +192,48 @@ function searchCust (q) {
|
|||
}
|
||||
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)
|
||||
async function finish () {
|
||||
if (!chosen.customer) {
|
||||
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
|
||||
try {
|
||||
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: 'Création du prospect impossible : ' + (e.message || e) }); submitting.value = false; return }
|
||||
// 1) DÉ-DUP : ce courriel/mobile correspond-il déjà à une fiche ? → on la réutilise (pas de doublon).
|
||||
const existing = await findExistingCustomer(lead.email, lead.mobile)
|
||||
if (existing) {
|
||||
chosen.customer = existing
|
||||
$q.notify({ type: 'info', icon: 'how_to_reg', message: 'Fiche existante réutilisée — ' + (existing.customer_name || existing.name), timeout: 2600 })
|
||||
} else {
|
||||
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
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
</script>
|
||||
|
|
@ -191,6 +249,9 @@ async function finish () {
|
|||
.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-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 { height: 180px; border-radius: 10px; overflow: hidden; border: 1px solid #e2e8f0; }
|
||||
.qw-sat-btn { position: absolute; top: 6px; left: 6px; z-index: 3; }
|
||||
|
|
|
|||
|
|
@ -177,11 +177,8 @@ const statusOptions = [
|
|||
{ label: 'Complété', value: 'completed' },
|
||||
{ label: 'Annulé', value: 'cancelled' },
|
||||
]
|
||||
const priorityOptions = [
|
||||
{ label: 'Basse', value: 'low' },
|
||||
{ label: 'Moyenne', value: 'medium' },
|
||||
{ label: 'Haute', value: 'high' },
|
||||
]
|
||||
// Priorité Dispatch Job — SOURCE UNIQUE (config/dispatch-priority).
|
||||
import { DISPATCH_PRIORITIES as priorityOptions } from 'src/config/dispatch-priority'
|
||||
const jobTypeOptions = [
|
||||
{ label: 'Installation', value: 'Installation' },
|
||||
{ label: 'Réparation', value: 'Réparation' },
|
||||
|
|
|
|||
|
|
@ -15,16 +15,28 @@
|
|||
<q-input v-model="form.subject" label="Sujet" dense outlined autofocus
|
||||
:rules="[v => !!v?.trim() || 'Requis']" />
|
||||
|
||||
<div class="row q-gutter-sm">
|
||||
<q-select v-model="form.priority" :options="priorityOptions" label="Priorité"
|
||||
dense outlined emit-value map-options class="col" />
|
||||
<q-select v-model="form.priority" :options="priorityOptions" label="Priorité"
|
||||
dense outlined emit-value map-options />
|
||||
|
||||
<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" />
|
||||
<!-- Description / Notes (essentiel — le contenu du ticket/tâche) -->
|
||||
<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"
|
||||
label="Type" dense outlined emit-value map-options clearable class="col" />
|
||||
</div>
|
||||
<!-- Bascule : n'afficher d'abord que l'essentiel ; révéler le reste à la demande -->
|
||||
<q-btn flat dense no-caps size="sm" class="text-grey-7"
|
||||
: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) -->
|
||||
<q-select v-if="fields.showServiceLocation && locationOptions.length"
|
||||
|
|
@ -130,9 +142,7 @@
|
|||
<q-input v-if="fields.showScheduledDate" v-model="form.scheduled_date"
|
||||
label="Date prévue (optionnel)" type="date" dense outlined />
|
||||
|
||||
<!-- Description / Notes -->
|
||||
<q-input v-model="form.description" :label="mode === 'work-order' ? 'Notes' : 'Description'"
|
||||
type="textarea" dense outlined autogrow style="min-height:60px" />
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<!-- Actions -->
|
||||
|
|
@ -183,6 +193,9 @@ const {
|
|||
|
||||
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 ────────────────────────────────────────────────────────────────
|
||||
const title = computed(() => {
|
||||
const m = props.mode
|
||||
|
|
@ -208,12 +221,8 @@ const contextLabel = computed(() => {
|
|||
const allTagsComputed = computed(() => props.externalTags || allTagsList.value)
|
||||
const getTagColor = computed(() => props.externalGetColor || internalGetTagColor)
|
||||
|
||||
const priorityOptions = [
|
||||
{ label: 'Basse', value: 'low' },
|
||||
{ label: 'Moyenne', value: 'medium' },
|
||||
{ label: 'Haute', value: 'high' },
|
||||
{ label: 'Urgente', value: 'urgent' },
|
||||
]
|
||||
// Priorité Dispatch Job — SOURCE UNIQUE (3 niveaux ; « Urgent » = high, le champ Select rejette 'urgent' → 417).
|
||||
import { DISPATCH_PRIORITIES as priorityOptions } from 'src/config/dispatch-priority'
|
||||
|
||||
const jobTypeOptions = [
|
||||
{ label: 'Installation', value: 'Installation' },
|
||||
|
|
@ -257,6 +266,8 @@ watch(() => props.modelValue, (open) => {
|
|||
if (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
|
||||
if (fields.value.showTags && !props.externalTags) {
|
||||
loadTags()
|
||||
|
|
|
|||
|
|
@ -157,16 +157,19 @@
|
|||
Tâches ({{ dispatchJobs.length }})
|
||||
</div>
|
||||
<q-space />
|
||||
<q-btn-group unelevated dense>
|
||||
<q-btn dense size="sm" icon="playlist_add" label="Projet"
|
||||
color="green-7" no-caps @click="showProjectWizard = true">
|
||||
<q-tooltip>Créer un projet (modèle multi-étapes)</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn dense size="sm" icon="add" label="Tâche"
|
||||
color="primary" no-caps @click="showCreateDialog = true">
|
||||
<q-tooltip>Ajouter une tâche simple</q-tooltip>
|
||||
</q-btn>
|
||||
</q-btn-group>
|
||||
<!-- 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-dropdown dense size="sm" color="primary" no-caps unelevated icon="add" label="Ajouter">
|
||||
<q-list dense style="min-width:230px">
|
||||
<q-item clickable v-close-popup @click="showCreateDialog = true">
|
||||
<q-item-section avatar><q-icon name="add_task" color="primary" /></q-item-section>
|
||||
<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>
|
||||
</q-item>
|
||||
<q-item clickable v-close-popup @click="showProjectWizard = true">
|
||||
<q-item-section avatar><q-icon name="playlist_add" color="green-7" /></q-item-section>
|
||||
<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>
|
||||
|
||||
<!-- 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 { authFetch } from 'src/api/auth'
|
||||
import { useAuthStore } from 'src/stores/auth'
|
||||
import { usePermissions } from 'src/composables/usePermissions'
|
||||
import { BASE_URL } from 'src/config/erpnext'
|
||||
import { HUB_URL } from 'src/config/hub'
|
||||
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 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 showCreateDialog = ref(false)
|
||||
const showProjectWizard = ref(false)
|
||||
|
|
@ -360,7 +369,7 @@ function initials (name) {
|
|||
// Fusionne échanges (Communication) + notes (Comment) en UN fil chronologique
|
||||
const thread = computed(() => {
|
||||
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' })
|
||||
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
|
||||
sendingReply.value = true
|
||||
try {
|
||||
const authStore = useAuthStore()
|
||||
await createDoc('Communication', {
|
||||
communication_type: 'Communication',
|
||||
communication_medium: 'Other',
|
||||
|
|
@ -713,7 +721,7 @@ async function sendReply () {
|
|||
subject: props.title || props.docName,
|
||||
content: replyContent.value.trim(),
|
||||
sender: authStore.user || '',
|
||||
sender_full_name: authStore.user || '',
|
||||
sender_full_name: senderFirst.value,
|
||||
reference_doctype: 'Issue',
|
||||
reference_name: props.docName,
|
||||
})
|
||||
|
|
@ -751,6 +759,7 @@ async function sendTicketEmail () {
|
|||
body: JSON.stringify({
|
||||
issue: props.docName, to: emailTo.value.trim(), cc: emailCc.value.trim(),
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
// STANDARD 3 NIVEAUX (comme ERPNext) : haute (ROUGE) · moyenne (neutre gris) · basse. '' / medium = non classé → drapeau contour gris (invite à classer).
|
||||
const PRIORITIES = {
|
||||
high: { icon: 'flag', color: 'red-6', label: 'Haute', rank: 3 },
|
||||
medium: { icon: 'flag', color: 'amber-6', label: 'Moyenne', rank: 2 }, // jaune/orange pâle
|
||||
low: { icon: 'flag', color: 'blue-grey-5', label: 'Basse', rank: 1 },
|
||||
// rétro-compatibilité anciennes valeurs (conversations) : urgent→haute (rouge), normal→moyenne
|
||||
urgent: { icon: 'flag', color: 'red-6', label: 'Haute', rank: 3 },
|
||||
normal: { icon: 'flag', color: 'amber-6', label: 'Moyenne', rank: 2 },
|
||||
// 3 NIVEAUX (valeurs ERPNext low/medium/high sous le capot) — libellés + sémantique demandés par l'user :
|
||||
high: { icon: 'priority_high', color: 'red-6', label: 'Urgent', rank: 3, desc: 'À faire en priorité — heure fixée / SLA à respecter' },
|
||||
medium: { icon: 'flag', color: 'amber-6', label: 'Moyenne', rank: 2, desc: 'Best effort — attente client normale, avec rendez-vous' },
|
||||
low: { icon: 'flag', color: 'blue-grey-5', label: 'Basse', rank: 1, desc: 'Best effort — sans attente, peut être délestée ou reportée' },
|
||||
// rétro-compatibilité anciennes valeurs (conversations) : urgent→Urgent (rouge), normal→Moyenne
|
||||
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) {
|
||||
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 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] || '' }
|
||||
|
||||
// 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 = [
|
||||
{ label: 'Basse', value: 'low' },
|
||||
{ label: 'Urgent', value: 'high' },
|
||||
{ label: 'Moyenne', value: 'medium' },
|
||||
{ label: 'Haute', value: 'high' },
|
||||
{ label: 'Basse', value: 'low' },
|
||||
]
|
||||
|
||||
export const ORDER_MODE_OPTIONS = [
|
||||
|
|
|
|||
|
|
@ -151,8 +151,6 @@
|
|||
</div>
|
||||
</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 -->
|
||||
<OrchestratorDialog v-if="can('view_clients')" v-model="orchestratorOpen" />
|
||||
<!-- 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 { useCreateSignal } from 'src/composables/useCreateSignal'
|
||||
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 ServiceStatusDialog = defineAsyncComponent(() => import('src/components/shared/ServiceStatusDialog.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.
|
||||
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.
|
||||
const newTicketOpen = ref(false)
|
||||
const orchestratorOpen = ref(false)
|
||||
const serviceStatusOpen = ref(false)
|
||||
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,
|
||||
}))
|
||||
|
||||
const priorityOptions = [
|
||||
{ value: 'low', label: 'Basse', color: '#6b7280' },
|
||||
{ value: 'medium', label: 'Moyenne', color: '#3b82f6' },
|
||||
{ value: 'high', label: 'Haute', color: '#ef4444' },
|
||||
]
|
||||
// Priorité Dispatch Job — SOURCE UNIQUE (config/dispatch-priority) : « Moyenne » = ambre partout (fini le bleu divergent).
|
||||
import { DISPATCH_PRIORITIES as priorityOptions } from 'src/config/dispatch-priority'
|
||||
|
||||
const modeOptions = [
|
||||
{ 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 skill = ref('')
|
||||
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 loading = ref(false)
|
||||
const slots = ref([])
|
||||
const searched = ref(false)
|
||||
|
||||
// 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 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
|
||||
loading.value = true; searched.value = true
|
||||
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 = [] }
|
||||
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 dense outlined type="date" v-model="afterDate" label="Dès le" class="col" />
|
||||
</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 />
|
||||
</template>
|
||||
<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: 'Autre', value: 'Other' },
|
||||
]
|
||||
const priorities = [
|
||||
{ label: 'Basse', value: 'low' },
|
||||
{ label: 'Moyenne', value: 'medium' },
|
||||
{ label: 'Haute', value: 'high' },
|
||||
{ label: 'Urgente', value: 'urgent' },
|
||||
]
|
||||
// Priorité Dispatch Job — SOURCE UNIQUE (config/dispatch-priority).
|
||||
import { DISPATCH_PRIORITIES as priorities } from 'src/config/dispatch-priority'
|
||||
|
||||
// ── Status helpers ─────────────────────────────────────────────────────────
|
||||
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="t-caption t-ink-600">{{ job.assigned_group || 'Non assigné' }}
|
||||
<span v-if="job.priority === 'urgent' || job.priority === 'high'"
|
||||
class="tg-chip is-compact"
|
||||
:class="job.priority === 'urgent' ? 'is-stop' : 'is-work'"
|
||||
class="tg-chip is-compact is-stop"
|
||||
style="margin-left:6px">
|
||||
{{ job.priority === 'urgent' ? 'Urgent' : 'Haute' }}
|
||||
Urgent
|
||||
</span>
|
||||
</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-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>
|
||||
<!-- #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" />
|
||||
<!-- 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()">
|
||||
|
|
@ -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>
|
||||
<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-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>
|
||||
|
|
@ -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>
|
||||
<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="more_vert" color="grey-6" size="19px" @click.stop="openJobSheet(j)"><q-tooltip>Options (priorité, statut, compétence, report)</q-tooltip></q-icon>
|
||||
</span>
|
||||
|
|
@ -375,14 +387,14 @@
|
|||
</div>
|
||||
<div class="pm-opt-label">Priorité</div>
|
||||
<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 class="pm-opt-label">Statut</div>
|
||||
<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>
|
||||
</div>
|
||||
<div class="pm-opt-label">Compétence requise</div>
|
||||
<q-select dense outlined options-dense clearable v-model="jobSheet.job.required_skill" :options="allSkills" @update:model-value="v => setJobSkill(jobSheet.job, v || '')" />
|
||||
<div class="pm-opt-label">Compétences requises</div>
|
||||
<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="row q-gutter-sm items-center">
|
||||
<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">
|
||||
<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="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>
|
||||
<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>
|
||||
|
|
@ -1237,8 +1250,8 @@
|
|||
<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 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>{{ priorityMeta(lvl).label }}</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><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>
|
||||
</q-list></q-menu>
|
||||
|
|
@ -1462,7 +1475,6 @@
|
|||
<!-- Détails SIMPLES d'abord (carte/tracé GPS repliés : non requis pour lire le ticket) -->
|
||||
<div class="jd-meta">
|
||||
<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="jdHasCoords" class="jd-geolinks">
|
||||
<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)" />
|
||||
<span class="text-caption text-grey-5">≈ {{ fmtMin(Math.round((jobDetail.durH || 0) * 60)) }}</span>
|
||||
</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 -->
|
||||
<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" />
|
||||
|
|
@ -1497,7 +1520,7 @@
|
|||
<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 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 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">
|
||||
|
|
@ -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) -->
|
||||
<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>
|
||||
<!-- 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>
|
||||
</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) -->
|
||||
<q-dialog v-model="dropAsk.open">
|
||||
<q-card style="min-width:380px;max-width:440px">
|
||||
|
|
@ -1700,7 +1782,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</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-dialog>
|
||||
|
||||
|
|
@ -1930,7 +2012,7 @@
|
|||
<SuggestSlotsDialog v-model="showSuggestSlots" :skills="slotSkills" @select="onSlotSelected" @plan-shifts="onPlanShifts" />
|
||||
<!-- Soumission : wizard adresse+fibre+prospect → passe le relais à ProjectWizard (panier + rabais + devis + acceptation) -->
|
||||
<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 -->
|
||||
<WeeklyScheduleEditor v-model="schedGenOpen" :techs="schedGenTechs" :tech-name="schedGenTechs[0] && schedGenTechs[0].name" @apply="onScheduleApply" />
|
||||
</q-page>
|
||||
|
|
@ -1962,6 +2044,7 @@ import { symOutlinedToolsLadder, symOutlinedHeadsetMic, symOutlinedHandyman } fr
|
|||
import { onBeforeRouteLeave, useRouter } from 'vue-router'
|
||||
import { useQuasar } from 'quasar'
|
||||
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 { 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
|
||||
|
|
@ -2290,10 +2373,12 @@ function startCreneau () { createMode.value = 'work-order'; createChooser.value
|
|||
const quoteWizardOpen = ref(false)
|
||||
const projectWizardOpen = ref(false)
|
||||
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) {
|
||||
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).
|
||||
quoteCustomer.value = cust ? { ...cust, qualified_address: (payload && payload.address) || null } : null
|
||||
quoteTier.value = (payload && payload.tier) || null
|
||||
if (quoteCustomer.value) projectWizardOpen.value = true
|
||||
}
|
||||
function onQuoteCreated (job, meta) {
|
||||
|
|
@ -2384,7 +2469,7 @@ function exportGpx (techId) {
|
|||
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. ──
|
||||
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).
|
||||
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 }
|
||||
|
|
@ -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
|
||||
// client, compétence, description et le fil du billet.
|
||||
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.
|
||||
jobDetail.geofence = null
|
||||
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à.
|
||||
// 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
|
||||
// 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 taken = new Set([jobDetail.techId, ...(jobDetail.team || []).map(a => a.tech_id)])
|
||||
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).
|
||||
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 () {
|
||||
const id = jobDetail.teamAdd; if (!id || !jobDetail.name) return
|
||||
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 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
|
||||
// #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.
|
||||
function viewTechTournee (tech) {
|
||||
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) ──
|
||||
if (mode === 'recurring') {
|
||||
const patt = {}; for (const d of schedule) patt[d.dow] = d.on ? { start: d.start, end: d.end } : null
|
||||
let okT = 0
|
||||
for (const t of targets) { try { const r = await roster.setWeeklySchedule(t.id, patt); if (r && r.ok) okT++ } catch (e) {} }
|
||||
let mat = null; try { mat = await roster.materializeShifts({ weeks }) } catch (e) { err(e) }
|
||||
let okT = 0, failT = 0
|
||||
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, matErr = ''; try { mat = await roster.materializeShifts({ weeks }) } catch (e) { matErr = (e && e.message) || 'erreur'; err(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 = []
|
||||
return
|
||||
}
|
||||
|
|
@ -4078,10 +4223,25 @@ function hoursOf (techId) { let h = 0; for (const a of assignments.value) { if (
|
|||
const serverSet = ref(new Set())
|
||||
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 dirty = computed(() => diffKeys.value.length > 0)
|
||||
const dirtyCount = computed(() => diffKeys.value.length)
|
||||
const dirtyCells = computed(() => new Set(diffKeys.value.map(k => k.slice(0, k.lastIndexOf('|')))))
|
||||
// #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 unpublished = computed(() => assignments.value.filter(a => a.status && a.status !== 'Publié'))
|
||||
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) }
|
||||
// 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 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) {
|
||||
const iso = jobTargetDay(job)
|
||||
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 hasJC = isFinite(jlat) && isFinite(jlon) && (jlat || jlon)
|
||||
return (visibleTechs.value || []).map(t => {
|
||||
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 distKm = (hasJC && home) ? haversineKm(home.lat, home.lon, jlat, jlon) : null
|
||||
return { id: t.id, name: t.name, _t: t, capable, distKm, ...load }
|
||||
|
|
@ -4641,7 +4802,8 @@ function buildSuggestion () {
|
|||
const plan = []
|
||||
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) {
|
||||
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 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
|
||||
|
|
@ -4663,7 +4825,7 @@ function buildSuggestion () {
|
|||
let best = null
|
||||
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).
|
||||
if (reqSkill && !(t.skills || []).includes(reqSkill)) continue
|
||||
if (reqSkillList.length && !reqSkillList.every(s => (t.skills || []).includes(s))) continue
|
||||
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 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
|
||||
}
|
||||
// ── 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).
|
||||
const POOL_PRIOS = [{ value: 'high', label: 'Élevée', color: '#ef4444' }, { value: 'medium', label: 'Moyenne', color: '#f59e0b' }, { value: 'low', label: 'Basse', color: '#9e9e9e' }]
|
||||
// ⚠ Le champ Dispatch Job.priority = Select { low | medium | high } UNIQUEMENT (pas de « urgent » → 417). L'étoile = « high ».
|
||||
// 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 jobSheet = reactive({ open: false, job: null })
|
||||
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 }) }
|
||||
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 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).
|
||||
function snoozeJob (j, days) {
|
||||
if (!j) return
|
||||
|
|
@ -5580,11 +5751,12 @@ function covStyle (key, iso) { const c = covCell(key, iso); if (!c) return {}; r
|
|||
// undo / redo
|
||||
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 undo () { if (!history.value.length) return; future.value.push(snap()); assignments.value = history.value.pop() }
|
||||
function redo () { if (!future.value.length) return; history.value.push(snap()); assignments.value = future.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(); logChange('↷ Rétabli'); scheduleDraftSave() }
|
||||
|
||||
// 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 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() }) }
|
||||
|
|
@ -5668,12 +5840,26 @@ async function doGenerate () {
|
|||
$q.notify({ type: 'positive', message: 'Horaire généré : ' + solverStats.value.assignments + ' assignations (non publié)' })
|
||||
} 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
|
||||
try {
|
||||
// Réécriture de semaine : efface la période + recrée la grille (anti-doublons).
|
||||
const r = await roster.publishWeek(start.value, days.value, assignments.value, notifySms.value)
|
||||
$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` : '') })
|
||||
// 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, 'publish')
|
||||
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()
|
||||
} 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 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 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 }] }
|
||||
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 }
|
||||
// #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é ».
|
||||
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 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.
|
||||
function openShiftMenu (t, d, ev, ti, di) {
|
||||
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 = []
|
||||
}
|
||||
}
|
||||
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 ──
|
||||
let _legacyRefreshT = null
|
||||
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 */ })
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user