From 7ca3ceef6699c1d66c35e5be6b1df610038ec834 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Tue, 7 Jul 2026 20:46:57 -0400 Subject: [PATCH] =?UTF-8?q?ops=20:=20priorit=C3=A9s=203=20niveaux=20(sourc?= =?UTF-8?q?e=20unique)=20+=20comp=C3=A9tences=20multi=20+=20cr=C3=A9ation?= =?UTF-8?q?=20unifi=C3=A9e=20+=20planif?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/ops/src/api/roster.js | 7 +- .../src/components/shared/NewTicketDialog.vue | 145 --------- .../src/components/shared/ProjectWizard.vue | 6 + .../ops/src/components/shared/QuoteWizard.vue | 91 +++++- apps/ops/src/components/shared/TaskNode.vue | 7 +- .../components/shared/UnifiedCreateModal.vue | 45 +-- .../shared/detail-sections/IssueDetail.vue | 35 ++- .../src/composables/useConversationDisplay.js | 17 +- apps/ops/src/composables/useHelpers.js | 2 +- apps/ops/src/config/dispatch-priority.js | 13 + apps/ops/src/data/wizard-constants.js | 4 +- apps/ops/src/layouts/MainLayout.vue | 4 - .../dispatch/components/CreateOfferModal.vue | 7 +- .../components/SuggestSlotsDialog.vue | 6 +- .../modules/tech/pages/TechJobDetailPage.vue | 8 +- .../src/modules/tech/pages/TechTasksPage.vue | 5 +- apps/ops/src/pages/PlanificationPage.vue | 274 +++++++++++++++--- 17 files changed, 411 insertions(+), 265 deletions(-) delete mode 100644 apps/ops/src/components/shared/NewTicketDialog.vue create mode 100644 apps/ops/src/config/dispatch-priority.js diff --git a/apps/ops/src/api/roster.js b/apps/ops/src/api/roster.js index acffdba..81a072f 100644 --- a/apps/ops/src/api/roster.js +++ b/apps/ops/src/api/roster.js @@ -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) diff --git a/apps/ops/src/components/shared/NewTicketDialog.vue b/apps/ops/src/components/shared/NewTicketDialog.vue deleted file mode 100644 index 02f38db..0000000 --- a/apps/ops/src/components/shared/NewTicketDialog.vue +++ /dev/null @@ -1,145 +0,0 @@ - - - - - diff --git a/apps/ops/src/components/shared/ProjectWizard.vue b/apps/ops/src/components/shared/ProjectWizard.vue index 6309d30..946d8e8 100644 --- a/apps/ops/src/components/shared/ProjectWizard.vue +++ b/apps/ops/src/components/shared/ProjectWizard.vue @@ -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) {} } + } } }) diff --git a/apps/ops/src/components/shared/QuoteWizard.vue b/apps/ops/src/components/shared/QuoteWizard.vue index fc6d946..1b7261c 100644 --- a/apps/ops/src/components/shared/QuoteWizard.vue +++ b/apps/ops/src/components/shared/QuoteWizard.vue @@ -45,7 +45,33 @@ - + + +
Forfaits Internet disponibles ici
+
Prix promotionnels — rabais 24 mois dĂ©jĂ  appliquĂ©. Choisissez-en un (facultatif) ou continuez.
+
Fibre à valider à cette adresse — prix indicatifs.
+
+ + + +
+
+ {{ t.label }} + {{ t.badge }} +
+
{{ t.speed }} · {{ t.desc }}
+
+
+
{{ money(t.price_effective) }}/mois
+
{{ money(t.price_base) }}
+
+
+
+
+
+ Installation {{ money(99.95) }} (frais unique)
+
+ +
Contact
Associez une fiche existante, ou créez un prospect (courriel ou mobile suffit pour la confirmation).
@@ -81,8 +107,9 @@ - - + + + @@ -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) } @@ -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; } diff --git a/apps/ops/src/components/shared/TaskNode.vue b/apps/ops/src/components/shared/TaskNode.vue index daadc14..94876e9 100644 --- a/apps/ops/src/components/shared/TaskNode.vue +++ b/apps/ops/src/components/shared/TaskNode.vue @@ -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' }, diff --git a/apps/ops/src/components/shared/UnifiedCreateModal.vue b/apps/ops/src/components/shared/UnifiedCreateModal.vue index 08a840d..a9ed3fe 100644 --- a/apps/ops/src/components/shared/UnifiedCreateModal.vue +++ b/apps/ops/src/components/shared/UnifiedCreateModal.vue @@ -15,16 +15,28 @@ -
- + - + + - -
+ + + + +
+
+ + + +
- - +
@@ -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() diff --git a/apps/ops/src/components/shared/detail-sections/IssueDetail.vue b/apps/ops/src/components/shared/detail-sections/IssueDetail.vue index a03c97e..4cc2725 100644 --- a/apps/ops/src/components/shared/detail-sections/IssueDetail.vue +++ b/apps/ops/src/components/shared/detail-sections/IssueDetail.vue @@ -157,16 +157,19 @@ TĂąches ({{ dispatchJobs.length }}) - - - CrĂ©er un projet (modĂšle multi-Ă©tapes) - - - Ajouter une tĂąche simple - - + + + + + + TĂącheTĂąche simple liĂ©e Ă  ce ticket + + + + ProjetModĂšle multi-Ă©tapes (installation
) — les tĂąches en dĂ©coulent + + + @@ -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() diff --git a/apps/ops/src/composables/useConversationDisplay.js b/apps/ops/src/composables/useConversationDisplay.js index 77e40ba..43003c0 100644 --- a/apps/ops/src/composables/useConversationDisplay.js +++ b/apps/ops/src/composables/useConversationDisplay.js @@ -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Ă©' } } diff --git a/apps/ops/src/composables/useHelpers.js b/apps/ops/src/composables/useHelpers.js index 37026ac..04184e5 100644 --- a/apps/ops/src/composables/useHelpers.js +++ b/apps/ops/src/composables/useHelpers.js @@ -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) diff --git a/apps/ops/src/config/dispatch-priority.js b/apps/ops/src/config/dispatch-priority.js new file mode 100644 index 0000000..57b28b6 --- /dev/null +++ b/apps/ops/src/config/dispatch-priority.js @@ -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. diff --git a/apps/ops/src/data/wizard-constants.js b/apps/ops/src/data/wizard-constants.js index 3efeaf5..62cd02a 100644 --- a/apps/ops/src/data/wizard-constants.js +++ b/apps/ops/src/data/wizard-constants.js @@ -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 = [ diff --git a/apps/ops/src/layouts/MainLayout.vue b/apps/ops/src/layouts/MainLayout.vue index c75a7b3..2df2eaa 100644 --- a/apps/ops/src/layouts/MainLayout.vue +++ b/apps/ops/src/layouts/MainLayout.vue @@ -151,8 +151,6 @@ - - @@ -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 } diff --git a/apps/ops/src/modules/dispatch/components/CreateOfferModal.vue b/apps/ops/src/modules/dispatch/components/CreateOfferModal.vue index 21034e6..ebb2155 100644 --- a/apps/ops/src/modules/dispatch/components/CreateOfferModal.vue +++ b/apps/ops/src/modules/dispatch/components/CreateOfferModal.vue @@ -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' }, diff --git a/apps/ops/src/modules/dispatch/components/SuggestSlotsDialog.vue b/apps/ops/src/modules/dispatch/components/SuggestSlotsDialog.vue index 44a44df..6b14391 100644 --- a/apps/ops/src/modules/dispatch/components/SuggestSlotsDialog.vue +++ b/apps/ops/src/modules/dispatch/components/SuggestSlotsDialog.vue @@ -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
+ Ignore les blocs « Réservé » (soft) : un tech réservé pour un projet redevient offert pour une réparation/urgence.
Choisis d'abord une adresse ci-dessus.
diff --git a/apps/ops/src/modules/tech/pages/TechJobDetailPage.vue b/apps/ops/src/modules/tech/pages/TechJobDetailPage.vue index dedc434..8175d58 100644 --- a/apps/ops/src/modules/tech/pages/TechJobDetailPage.vue +++ b/apps/ops/src/modules/tech/pages/TechJobDetailPage.vue @@ -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)) diff --git a/apps/ops/src/modules/tech/pages/TechTasksPage.vue b/apps/ops/src/modules/tech/pages/TechTasksPage.vue index 7e41cfe..8886c02 100644 --- a/apps/ops/src/modules/tech/pages/TechTasksPage.vue +++ b/apps/ops/src/modules/tech/pages/TechTasksPage.vue @@ -102,10 +102,9 @@
{{ job.assigned_group || 'Non assigné' }} - {{ job.priority === 'urgent' ? 'Urgent' : 'Haute' }} + Urgent
{{ job.subject }}
diff --git a/apps/ops/src/pages/PlanificationPage.vue b/apps/ops/src/pages/PlanificationPage.vue index 611afef..a6498fa 100644 --- a/apps/ops/src/pages/PlanificationPage.vue +++ b/apps/ops/src/pages/PlanificationPage.vue @@ -46,6 +46,14 @@ {{ boardView === 'kanban' ? 'Jour affichĂ© (mode Jour)' : 'DĂ©but de la fenĂȘtre (mode Semaine)' }} Annuler (Ctrl+Z) RĂ©tablir (Ctrl+Shift+Z) + + Journal des changements · {{ autosaving ? 'sauvegarde en cours
' : 'auto-sauvegardĂ©' }} + + Journal — {{ autosaving ? 'sauvegarde
' : '✓ auto-sauvegardĂ©' }} + Aucun changement rĂ©cent. + {{ c.text }}{{ new Date(c.at).toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit' }) }} + + @@ -123,6 +131,10 @@ Notifier les techs par SMSĂ  la publication + + Soumettre pour approbationmarque « Soumis » (sans SMS) + Approuvermarque « ApprouvĂ© » (sans SMS) + Publier au legacyrĂ©assigne les tickets dans osTicket (aperçu avant) @@ -285,7 +297,7 @@ {{ j.notes }} - {{ j.priority === 'high' ? 'Prioritaire — toucher pour retirer' : 'Marquer prioritaire' }} + {{ j.priority === 'high' ? 'Urgent — toucher pour retirer' : 'Marquer urgent (prioritĂ©, heure fixĂ©e / SLA)' }} Note importante / dĂ©tails Options (prioritĂ©, statut, compĂ©tence, report) @@ -375,14 +387,14 @@
Priorité
- {{ p.label }} + {{ p.label }}{{ p.desc }}
Statut
{{ s.label }}
-
Compétence requise
- +
Compétences requises
+
Reporter (date prévue)
@@ -839,6 +851,7 @@
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 ». Applique 5×8h Ă  la semaine affichĂ©e (Ă  publier) + 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).
🏠 Domicile (dĂ©part de tournĂ©e si plus proche que le dĂ©pĂŽt)
@@ -1237,8 +1250,8 @@ Priorité - - {{ priorityMeta(lvl).label }} + + {{ priorityMeta(lvl).label }}{{ priorityMeta(lvl).desc }} @@ -1462,7 +1475,6 @@
{{ jobDetail.time }}
-
{{ jobDetail.skill }}
{{ jobDetail.address }}
+ +
+ Compétences requises + +
+ +
+ Donner Ă  : + + +
@@ -1497,7 +1520,7 @@
Équipe / renfort
- {{ a.tech_name || techNameById(a.tech_id) || a.tech_id || '—' }} + {{ jdAssistantName(a) }}
Aucun assistant. Ajoutez un renfort → un bloc hachurĂ© sera rĂ©servĂ© dans son horaire (anti double‑booking).
@@ -1517,10 +1540,69 @@
{{ jobDetail.detail }}
{{ jobDetail.lid ? ((jobDetail.thread && jobDetail.thread.error) ? 'Détail indisponible' : 'Aucun commentaire.') : 'Pas de billet Legacy lié à ce job.' }}
+ +
+ +
+ + + +
+
+ + + + + +
Publier l'horaire
+ +
+ +
{{ pubSummary.added }} ajout(s) · {{ pubSummary.removed }} retrait(s) · {{ notifySms ? pubSummary.added + ' SMS aux techs' : 'sans SMS' }}
+ + + +
+ + + + +
+
+ + + + + + +
Réserver du temps
+ +
+ +
{{ resvDlg.tech && resvDlg.tech.name }} — indisponible au dispatch auto pendant ce bloc
+
+ + + +
+ +
Les rĂ©parations & urgences peuvent quand mĂȘme utiliser ce tech (recherche de crĂ©neau en mode urgence).
+
+ + + + +
+
+ @@ -1700,7 +1782,7 @@
- TriĂ© par prioritĂ© puis heure · 🔮 urgent 🟠 Ă©levĂ©e đŸ”” moyenne âšȘ basse. Heures posĂ©es en premier-trou-libre. + TriĂ© par prioritĂ© puis heure · 🔮 Urgent 🟠 Moyenne âšȘ Basse. Heures posĂ©es en premier-trou-libre. @@ -1930,7 +2012,7 @@ - + @@ -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 ? »