À 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.
« 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.