diff --git a/apps/ops/src/components/planif/FunctionCatalogDialog.vue b/apps/ops/src/components/planif/FunctionCatalogDialog.vue new file mode 100644 index 0000000..d2d4401 --- /dev/null +++ b/apps/ops/src/components/planif/FunctionCatalogDialog.vue @@ -0,0 +1,107 @@ + + + + + + diff --git a/apps/ops/src/components/planif/PlanifCellMenu.vue b/apps/ops/src/components/planif/PlanifCellMenu.vue index 1a734f1..fe0796a 100644 --- a/apps/ops/src/components/planif/PlanifCellMenu.vue +++ b/apps/ops/src/components/planif/PlanifCellMenu.vue @@ -14,7 +14,7 @@ {{ rowHours(r) }}h - Retirer ce quart + Retirer ce quart
diff --git a/apps/ops/src/components/shared/ConversationPanel.vue b/apps/ops/src/components/shared/ConversationPanel.vue index 5226f7c..e5bb3ba 100644 --- a/apps/ops/src/components/shared/ConversationPanel.vue +++ b/apps/ops/src/components/shared/ConversationPanel.vue @@ -223,7 +223,7 @@ - {{ staffInitials(noteAuthorName(a)) }} + {{ noteAuthorName(a) }}{{ a }} ResponsableAssistant @@ -445,7 +445,7 @@
@@ -558,7 +558,7 @@
- {{ staffInitials(noteAuthorName(msg.agent)) }} + {{ msg.mine ? 'Vous' : shortAgent(msg.agent) }} · brouillon @@ -578,7 +578,7 @@ - + {{ msgWho(msg) }}Ouvrir la fiche client (OPS 360) ↗ {{ msgWho(msg) }} @@ -649,7 +649,7 @@ Taguer un collègue · ↑↓ · Tab/Espace pour insérer - {{ staffInitials(noteAuthorName(a)) }} + {{ noteAuthorName(a) }}@{{ shortAgent(a) }} @@ -867,7 +867,7 @@ - +
Créer un ticket
@@ -876,11 +876,13 @@
À partir de la conversation avec {{ activeDiscussion && (activeDiscussion.customerName || activeDiscussion.phone) }}. La conversation reste dans l'historique ; le ticket suit le travail.
- -
- - + +
+
Quel type de demande ?
+
+ + @@ -1058,6 +1060,8 @@ import { useDetailModal } from 'src/composables/useDetailModal' // Détail ticket NATIF (remplace l'ancien lien ERPNext desk) — ERPNext = base de données. const { modalOpen: tkModalOpen, modalLoading: tkModalLoading, modalDoctype: tkModalDoctype, modalDocName: tkModalDocName, modalTitle: tkModalTitle, modalDoc: tkModalDoc, modalComments: tkModalComments, modalComms: tkModalComms, modalFiles: tkModalFiles, modalDocFields: tkModalDocFields, modalDispatchJobs: tkModalDispatchJobs, openModal: tkOpenModal } = useDetailModal() import AvailabilityByReason from 'src/components/shared/AvailabilityByReason.vue' // raison → compétence → disponibilité (dénominateur filtré) +import IssueTypePicker from 'src/components/shared/IssueTypePicker.vue' // catalogue de cartes (Issue Type) — MÊME UI que la fiche +import { priorityOptions as ticketPriorityOptions } from 'src/config/ticket-config' // priorité ticket — SOURCE UNIQUE import { emailSrcdoc } from 'src/composables/useEmailRender' const $q = useQuasar() @@ -1127,7 +1131,8 @@ function endDrag () { _drag = null; window.removeEventListener('pointermove', on const coordOpen = ref(false) // chrome de coordination replié par défaut (épuration de la vue conversation) // ── Créer un ticket (ERPNext Issue OUVERT) depuis la conversation — la conversation RESTE ── -const ticketCategories = ['Support', 'Facturation', 'Installation', 'Fibre', 'Télévision', 'Téléphonie', 'Commercial', 'Autre'] +// Type = IssueTypePicker (config/issue-types) ; priorité = ticketPriorityOptions (config/ticket-config). Le type choisi +// est envoyé comme `category` : le hub le préfixe au sujet [Type] (write-back legacy conservé), vocabulaire désormais unifié. const creatingTicket = ref(false) const ticketDialog = ref({ open: false, title: '', category: 'Support', priority: 'Medium' }) const linkedTickets = computed(() => (activeConv.value?.linkedTickets || activeDiscussion.value?.linkedTickets || [])) diff --git a/apps/ops/src/components/shared/WeeklyScheduleEditor.vue b/apps/ops/src/components/shared/WeeklyScheduleEditor.vue index becba0d..c813369 100644 --- a/apps/ops/src/components/shared/WeeklyScheduleEditor.vue +++ b/apps/ops/src/components/shared/WeeklyScheduleEditor.vue @@ -8,6 +8,7 @@ */ import { reactive, ref, watch, computed } from 'vue' import { SCHEDULE_PRESETS } from 'src/composables/useHelpers' +import * as roster from 'src/api/roster' const props = defineProps({ modelValue: { type: Boolean, default: false }, @@ -18,15 +19,34 @@ const emit = defineEmits(['update:modelValue', 'apply']) const DAYS = [['mon', 'Lun'], ['tue', 'Mar'], ['wed', 'Mer'], ['thu', 'Jeu'], ['fri', 'Ven'], ['sat', 'Sam'], ['sun', 'Dim']] const sched = reactive({}) +// Affinage par CHIPS (mode 1 tech) : état d'affichage par créneau, keyé `${dow}:${index}`. +// slotUI[key] = { fnSeen, roleId, label, universe:[], sel:[] } — mémorise l'UNIVERS (rôle/base) pour +// pouvoir re-cocher une compétence retirée (l'univers n'est pas récupérable depuis le CSV stocké seul). +const slotUI = reactive({}) +const expanded = reactive({}) const weeks = ref(1) +const functions = ref([]) // catalogue de fonctions (rôles) — pose une fonction par jour (compétences EFFECTIVES du quart) const mode = ref('recurring') // 'recurring' = patron de base (matérialisé, exceptions auto) · 'exception' = override ponctuel (semaines précises) const selected = ref(new Set()) // techIds cochés (mode lot) const bulk = computed(() => (props.techs || []).length > 1) const title = computed(() => bulk.value ? ((props.techs || []).length + ' techniciens') : (props.techName || (props.techs[0] && props.techs[0].name) || '')) -function fill (preset) { for (const [k] of DAYS) { const s = preset[k]; sched[k] = s ? { on: true, start: s.start, end: s.end } : { on: false, start: '08:00', end: '16:00' } } } +// MULTI-SLOT : chaque jour porte un tableau `slots` [{start,end,function}] (ex. 8-16 install + 16-21 support). +function fill (preset) { resetUI(); for (const [k] of DAYS) { const s = preset[k]; const keepFn = (sched[k] && sched[k].slots && sched[k].slots[0] && sched[k].slots[0].function) || ''; sched[k] = s ? { on: true, slots: [{ start: s.start, end: s.end, function: keepFn }] } : { on: false, slots: [{ start: '08:00', end: '16:00', function: keepFn }] } } } +function addSlot (k) { resetUI(); const last = sched[k].slots[sched[k].slots.length - 1]; sched[k].slots.push({ start: (last && last.end) || '16:00', end: '21:00', function: '' }) } +function removeSlot (k, i) { resetUI(); sched[k].slots.splice(i, 1); if (!sched[k].slots.length) { sched[k].slots.push({ start: '08:00', end: '16:00', function: '' }); sched[k].on = false } } +// Normalise une saisie souple → « HH:MM » 24 h (jamais AM/PM). « 8 »→08:00 · « 830 »→08:30 · « 16h »→16:00. +function norm24 (s) { + s = String(s || '').trim().toLowerCase().replace(/h/g, ':').replace(/[^\d:]/g, ''); if (!s) return '' + let h, m + if (s.includes(':')) { const p = s.split(':'); h = parseInt(p[0] || '0', 10); m = parseInt(p[1] || '0', 10) } else if (s.length <= 2) { h = parseInt(s, 10); m = 0 } else { h = parseInt(s.slice(0, s.length - 2), 10); m = parseInt(s.slice(-2), 10) } + if (!Number.isFinite(h)) return '' + h = Math.min(23, Math.max(0, h)); m = Math.min(59, Math.max(0, Number.isFinite(m) ? m : 0)) + return String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0') +} +function slotHours (sl) { if (!sl || !sl.start || !sl.end) return 0; const [h1, m1] = sl.start.split(':').map(Number); const [h2, m2] = sl.end.split(':').map(Number); let mn = (h2 * 60 + m2) - (h1 * 60 + m1); if (mn < 0) mn += 1440; return Math.round(mn / 60 * 10) / 10 } fill(SCHEDULE_PRESETS[0].schedule) -watch(() => props.modelValue, (o) => { if (o) { fill(SCHEDULE_PRESETS[0].schedule); weeks.value = mode.value === 'recurring' ? 6 : 1; selected.value = new Set((props.techs || []).map(t => t.id)) } }) +watch(() => props.modelValue, (o) => { if (o) { fill(SCHEDULE_PRESETS[0].schedule); weeks.value = mode.value === 'recurring' ? 6 : 1; selected.value = new Set((props.techs || []).map(t => t.id)); roster.listFunctions().then(r => { functions.value = (r && r.functions) || [] }).catch(() => {}) } }) watch(mode, (m) => { weeks.value = m === 'recurring' ? 6 : 1 }) function toggleTech (id) { const s = new Set(selected.value); s.has(id) ? s.delete(id) : s.add(id); selected.value = s } // Sélection des techs (mode lot) : recherche par nom · sélection par compétence · tout / aucun @@ -37,13 +57,71 @@ function selectAllShown () { const s = new Set(selected.value); for (const t of function selectNone () { selected.value = new Set() } function selectBySkill (sk) { const s = new Set(selected.value); for (const t of (props.techs || [])) if ((t.skills || []).includes(sk)) s.add(t.id); selected.value = s } -function dayHours (d) { if (!d || !d.on) return 0; const [h1, m1] = d.start.split(':').map(Number); const [h2, m2] = d.end.split(':').map(Number); let mn = (h2 * 60 + m2) - (h1 * 60 + m1); if (mn < 0) mn += 1440; return Math.round(mn / 60 * 10) / 10 } +// ── AFFINAGE DU RÔLE PAR CHIPS (mode 1 tech uniquement) ─────────────────────── +// Le sélecteur de rôle reste le contrôle principal (rapide). « Ajuster » déplie des chips partant des +// compétences du rôle choisi (ou des compétences de BASE si « — rôle — ») : clic = retirer (barré rouge), +// « ajouter » = accorder une compétence rare. Le résultat est écrit en CSV dans sl.function — le résolveur +// (skill-resolver._shiftEffective) traite un CSV comme un ensemble explicite qui REMPLACE la base ce jour-là. +const DAYNAME = { mon: 'les lundis', tue: 'les mardis', wed: 'les mercredis', thu: 'les jeudis', fri: 'les vendredis', sat: 'les samedis', sun: 'les dimanches' } +const _norm = (s) => String(s || '').normalize('NFD').replace(/[̀-ͯ]/g, '').trim().toLowerCase() +const eqSkill = (a, b) => _norm(a) === _norm(b) +const setEq = (a, b) => a.length === b.length && a.every(x => b.some(y => eqSkill(x, y))) +const parseCSV = (v) => String(v || '').split(/[,;]/).map(s => s.trim()).filter(Boolean) +function findFn (val) { if (!val) return null; const v = _norm(val); return (functions.value || []).find(f => _norm(f.id) === v || _norm(f.name) === v) || null } +// Compétences HABITUELLES du tech (mode 1 tech : props.techs[0].skills). Unique univers des chips. +const baseSkills = computed(() => bulk.value ? [] : ((props.techs && props.techs[0] && props.techs[0].skills) || [])) +const isRoleVal = (v) => !!findFn(v) // valeur stockée = rôle du catalogue ? (sinon CSV « personnalisé ») + +const keyOf = (k, si) => k + ':' + si +const ui = (k, si) => slotUI[keyOf(k, si)] +function resetUI () { for (const kk in slotUI) delete slotUI[kk]; for (const kk in expanded) delete expanded[kk] } +// État d'affichage d'un créneau. Univers = compétences habituelles du tech (TOUJOURS). `sel` = celles ACTIVES +// ce créneau ; les autres sont barrées/grisées (désactivées ponctuellement). Rôle connu → base tout actif +// (le rôle reste jusqu'à ce qu'on touche une chip) ; CSV → sous-ensemble actif ; vide → tout actif. +function buildUI (sl) { + const base = baseSkills.value.slice() + const fn = findFn(sl.function) + if (fn) return { fnSeen: sl.function || '', roleId: sl.function, roleLabel: fn.name, universe: base, sel: base.slice() } + if (sl.function) { const csv = parseCSV(sl.function); return { fnSeen: sl.function, roleId: '', roleLabel: '', universe: base, sel: base.filter(b => csv.some(c => eqSkill(c, b))) } } + return { fnSeen: '', roleId: '', roleLabel: '', universe: base, sel: base.slice() } +} +function toggleExpand (k, si, sl) { + const key = keyOf(k, si) + if (expanded[key]) { expanded[key] = false; return } + if (!slotUI[key] || slotUI[key].fnSeen !== (sl.function || '')) slotUI[key] = buildUI(sl) + expanded[key] = true +} +// Le sélecteur de rôle a changé la valeur : si le panneau est ouvert, reconstruire. +function onRoleChange (k, si, sl) { const key = keyOf(k, si); if (expanded[key]) slotUI[key] = buildUI(sl) } +const selHas = (st, sk) => st.sel.some(x => eqSkill(x, sk)) +const disabledCount = (st) => Math.max(0, st.universe.length - st.sel.length) +// Clic sur une chip : active/désactive ce créneau. Garde ≥1 active (0 reviendrait à la base côté résolveur). +// Toucher une chip = on pilote directement ses compétences → on retire le rôle éventuel. +function toggleSkill (k, si, sl, skill) { + const st = ui(k, si); if (!st) return + const i = st.sel.findIndex(x => eqSkill(x, skill)) + if (i >= 0) { if (st.sel.length <= 1) return; st.sel.splice(i, 1) } else st.sel.push(skill) + st.roleId = ''; st.roleLabel = '' + writeBack(sl, st) +} +function writeBack (sl, st) { sl.function = encodeFn(st); st.fnSeen = sl.function } +// Encode l'état dans sl.function : rôle non touché → l'id du rôle ; toutes actives → vide (= base) ; +// sinon CSV du sous-ensemble actif. 1 seule compétence coïncidant avec un nom de rôle → virgule finale. +function encodeFn (st) { + if (st.roleId) return st.roleId + const sel = st.sel.map(s => s.trim()).filter(Boolean) + if (!sel.length || setEq(sel, baseSkills.value)) return '' + if (sel.length === 1 && findFn(sel[0])) return sel[0] + ',' + return sel.join(', ') +} + +function dayHours (d) { if (!d || !d.on || !d.slots) return 0; return Math.round(d.slots.reduce((s, sl) => s + slotHours(sl), 0) * 10) / 10 } const totalDays = () => DAYS.filter(([k]) => sched[k]?.on).length const totalHours = () => DAYS.reduce((s, [k]) => s + dayHours(sched[k]), 0) function submit () { const techIds = bulk.value ? [...selected.value] : (props.techs[0] ? [props.techs[0].id] : []) if (!techIds.length) return - emit('apply', { schedule: DAYS.map(([k]) => ({ dow: k, ...sched[k] })), weeks: Math.max(1, Math.min(12, Number(weeks.value) || 1)), techIds, mode: mode.value }) + emit('apply', { schedule: DAYS.map(([k]) => ({ dow: k, on: !!sched[k].on, slots: sched[k].on ? sched[k].slots.filter(sl => sl.start && sl.end).map(sl => ({ start: sl.start, end: sl.end, function: sl.function || '' })) : [] })), weeks: Math.max(1, Math.min(12, Number(weeks.value) || 1)), techIds, mode: mode.value }) emit('update:modelValue', false) } @@ -90,16 +168,44 @@ function submit () {
{{ p.label }}
- +
En mode lot, l'affinage des compétences par chips est masqué — les techs ont des compétences de base différentes. Le sélecteur de rôle reste disponible par créneau.
+
- +
+
+
+ + + + + {{ slotHours(sl) }}h + + ajuster + Retirer ce quart +
+
+ +
Aucune compétence de base enregistrée — à définir sur la fiche du technicien.
+
+
+ quart +
Repos
@@ -124,10 +230,15 @@ function submit () { .wse-preset { display: inline-flex; align-items: center; padding: 4px 12px; border: 1.5px solid #c7d2fe; border-radius: 14px; font-size: 12.5px; font-weight: 600; color: #4338ca; background: #eef2ff; cursor: pointer; user-select: none; } .wse-preset:hover { background: #e0e7ff; } .wse-grid { display: flex; flex-direction: column; gap: 4px; } -.wse-day { display: flex; align-items: center; gap: 8px; padding: 4px 8px; border-radius: 8px; background: #f8fafc; } -.wse-day.off { opacity: 0.7; } -.wse-day-lbl { min-width: 62px; font-weight: 600; } -.wse-time { border: 1px solid #cbd5e1; border-radius: 6px; padding: 3px 6px; font-size: 13px; font-family: inherit; } +.wse-day { display: flex; align-items: flex-start; gap: 8px; padding: 4px 8px; border-radius: 8px; background: #f8fafc; } +.wse-day.off { opacity: 0.7; align-items: center; } +.wse-day-lbl { min-width: 62px; font-weight: 600; padding-top: 3px; } +.wse-slots { display: flex; flex-direction: column; gap: 4px; flex: 1; } +.wse-slot-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.wse-addslot { display: inline-flex; align-items: center; gap: 2px; align-self: flex-start; font-size: 11px; font-weight: 600; color: #64748b; cursor: pointer; user-select: none; padding: 1px 4px; border-radius: 6px; } +.wse-addslot:hover { color: #4338ca; background: #eef2ff; } +.wse-time { width: 54px; box-sizing: border-box; text-align: center; border: 1px solid #cbd5e1; border-radius: 6px; padding: 3px 6px; font-size: 13px; font-family: inherit; } +.wse-fn { border: 1px solid #cbd5e1; border-radius: 6px; padding: 3px 6px; font-size: 12px; font-family: inherit; color: #475569; background: #fff; max-width: 150px; } .wse-sep { color: #94a3b8; } .wse-h { color: #64748b; font-size: 12px; font-weight: 600; margin-left: auto; } .wse-repos { color: #94a3b8; font-style: italic; font-size: 12.5px; margin-left: 8px; } @@ -135,4 +246,14 @@ function submit () { .wse-tech.on { border-color: #6366f1; background: #eef2ff; color: #4338ca; } .wse-skill { display: inline-flex; align-items: center; gap: 2px; padding: 2px 8px; border: 1px dashed #94a3b8; border-radius: 12px; font-size: 11px; font-weight: 600; color: #475569; background: #f8fafc; cursor: pointer; user-select: none; } .wse-skill:hover { border-color: #6366f1; color: #4338ca; background: #eef2ff; } +.wse-adjust { display: inline-flex; align-items: center; gap: 2px; font-size: 11px; font-weight: 600; color: #4338ca; cursor: pointer; user-select: none; padding: 1px 5px; border-radius: 6px; } +.wse-adjust:hover, .wse-adjust.open { background: #eef2ff; } +.wse-chips { margin: 4px 0 2px 70px; padding: 8px 10px; background: #fff; border: 0.5px solid #e2e8f0; border-radius: 8px; } +.wse-chips-hint { font-size: 11px; color: #64748b; margin-bottom: 6px; } +.wse-chip { display: inline-flex; align-items: center; gap: 3px; padding: 2px 9px; border-radius: 12px; font-size: 12px; font-weight: 500; cursor: pointer; user-select: none; background: #E1F5EE; color: #0F6E56; border: 1px solid #5DCAA5; } +.wse-chip.off { background: #f1f5f9; color: #94a3b8; border-color: #e2e8f0; text-decoration: line-through; } +.wse-role-note { font-size: 11px; color: #4338ca; margin-top: 6px; } +.wse-custom-note { font-size: 11px; color: #64748b; margin-top: 6px; } +.wse-chips-foot { font-size: 10.5px; color: #94a3b8; margin-top: 6px; } +.wse-bulk-note { display: flex; gap: 6px; align-items: flex-start; font-size: 11.5px; color: #854F0B; background: #FAEEDA; border: 0.5px solid #FAC775; border-radius: 8px; padding: 6px 8px; } diff --git a/apps/ops/src/modules/tech/pages/TechScanPage.vue b/apps/ops/src/modules/tech/pages/TechScanPage.vue index 4b75228..fe8d7dd 100644 --- a/apps/ops/src/modules/tech/pages/TechScanPage.vue +++ b/apps/ops/src/modules/tech/pages/TechScanPage.vue @@ -97,7 +97,7 @@
{{ bc.value }}
- +
diff --git a/apps/ops/src/pages/EventsPage.vue b/apps/ops/src/pages/EventsPage.vue index ec1c709..94c79df 100644 --- a/apps/ops/src/pages/EventsPage.vue +++ b/apps/ops/src/pages/EventsPage.vue @@ -166,7 +166,7 @@ - +
@@ -177,7 +177,7 @@
Notes de bas de page
- +
@@ -296,7 +296,7 @@ {{ r.name }} {{ r.email }} · {{ r.source }} - + …et {{ mainList.count - mainList.sample.length }} autres (affichage plafonné à 200) @@ -359,7 +359,7 @@ {{ p.customer_name || p.customer_id }} {{ p.customer_id }} · {{ p.email }} · sans courriel (sera ignoré) - + diff --git a/apps/ops/src/pages/PlanificationPage.vue b/apps/ops/src/pages/PlanificationPage.vue index 43bc81e..91a9f11 100644 --- a/apps/ops/src/pages/PlanificationPage.vue +++ b/apps/ops/src/pages/PlanificationPage.vue @@ -757,7 +757,7 @@ Monter Descendre - Retirer + Retirer
« sem. » = semaines consécutives. Pour N semaines d'écart entre 2 tours d'un tech, mets N+1 étapes (ex. A→B→C = 2 sem. d'écart).
@@ -1220,7 +1220,7 @@ Ticket #{{ s.ticket }} — Ops « {{ s.ops_nom }} » ≠ legacy « {{ s.legacy_staff }} » → ignoré Ticket #{{ s.ticket }} → {{ s.vers_staff }} · {{ s.sujet }} - Désassigner ce job (retour au pool) — ne sera pas poussé + Désassigner ce job (retour au pool) — ne sera pas poussé
Sécurité : seuls les tickets ouverts sont touchés, le nom du staff legacy doit concorder, et c'est idempotent.
@@ -1693,7 +1693,7 @@ Effacer (compétences de base) - Supprimer ce quart (retire le doublon / chevauchement) + Supprimer ce quart (retire le doublon / chevauchement)
@@ -1803,7 +1803,7 @@ {{ j.locked ? 'Heure FIXE (RDV) — verrouillée, non replanifiée' : 'Heure flexible — replanifiée par la tournée' }} Supprimer ce blocage (ex. Formation) — le tech redevient dispatchable. N'est PAS renvoyé au pool. - Retirer du tech (retour au pool) + Retirer du tech (retour au pool) Ouvrir le ticket F (lecture seule)
@@ -1898,7 +1898,7 @@ Effacer (compétences de base) - Supprimer ce quart (retire le doublon / chevauchement) + Supprimer ce quart (retire le doublon / chevauchement)
Aucun quart régulier ce jour — ajoutez-en un depuis le menu de la cellule.