fix(ui): icône corbeille pour les suppressions (au lieu d'un X ambigu avec « fermer »)

Un X sur une action de SUPPRESSION prête à confusion avec le X de fermeture d'un menu/fenêtre.
Toutes les commandes qui SUPPRIMENT/retirent un élément passent de icon="close" à icon="delete"
(corbeille) : WeeklyScheduleEditor (retirer quart), ConversationPanel (supprimer note),
PlanifCellMenu (retirer quart), FunctionCatalogDialog (supprimer rôle), TechScanPage (retirer
code-barres), EventsPage (retirer d'une liste/pool/programme/note), PlanificationPage (étape de
garde, deleteDayAsg ×2). Les 2 actions « retour au pool » (removeFromPush/removeFromDay) passent à
icon="inbox" (renvoi au pool — PAS une destruction). Les clears de filtre/champ libellés « Effacer »
restent inchangés (ce ne sont pas des suppressions d'élément).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-23 21:57:43 -04:00
parent fb764f8468
commit 0d4996e240
7 changed files with 270 additions and 37 deletions

View File

@ -0,0 +1,107 @@
<!--
FunctionCatalogDialog catalogue des FONCTIONS (skillsets nommés réutilisables). Store hub /roster/functions.
Une fonction se pose sur un quart : elle définit les compétences EFFECTIVES du tech ce jour- (réconciliées
avec ses compétences réelles). Visuel volontairement SOBRE : défaut = Nom + compétences ; le mode
(remplace/ajoute) est une option avancée repliée. Réutilise TagEditor + useTagCatalog.
-->
<template>
<q-dialog v-model="open">
<q-card style="min-width:560px;max-width:680px">
<q-card-section class="row items-center q-pb-none">
<div class="text-subtitle1 text-weight-bold">Modèles de rôle</div>
<q-space /><q-btn flat round dense icon="close" v-close-popup />
</q-card-section>
<q-card-section>
<div class="text-caption text-grey-6 q-mb-md">Ensembles de compétences réutilisables : posez-en un sur un quart (le « rôle du quart »), puis ajustez à la main. Ex. Support, Vente porte-à-porte.</div>
<div v-for="f in rows" :key="f.id" class="fn-row row items-center no-wrap q-py-xs">
<div class="fn-name">{{ f.name }}</div>
<div class="fn-skills text-grey-7 ellipsis">{{ f.skills.length ? f.skills.join(' · ') : 'aucune compétence' }}</div>
<span v-if="f.mode === 'add'" class="fn-mode">ajoute</span>
<q-space />
<q-btn flat dense round size="sm" icon="edit" color="grey-6" @click="editRow(f)"><q-tooltip>Modifier</q-tooltip></q-btn>
<q-btn flat dense round size="sm" icon="delete" color="grey-5" @click="removeRow(f)"><q-tooltip>Supprimer</q-tooltip></q-btn>
</div>
<div v-if="!rows.length" class="text-grey-6 q-py-sm">Aucun modèle. Créez-en un ci-dessous (ex. Support = support ; Installation = installation · épissure).</div>
<q-separator class="q-my-md" />
<div class="text-caption text-weight-medium q-mb-sm">{{ draft.id ? 'Modifier « ' + draft.origName + ' »' : 'Nouveau modèle de rôle' }}</div>
<div class="row items-center no-wrap q-gutter-sm">
<q-input dense outlined v-model="draft.name" label="Nom" style="width:190px" />
<TagEditor :model-value="draft.skills" :all-tags="skillTags" :get-color="getColor" :can-edit="false" :can-create="false"
placeholder="Compétences…" style="flex:1;min-width:200px"
@update:model-value="v => draft.skills = normList(v)" />
<q-btn v-if="draft.id" flat dense no-caps label="Annuler" color="grey-7" @click="resetDraft" />
<q-btn flat dense no-caps :label="draft.id ? 'Enregistrer' : 'Créer'" color="primary" @click="saveRow" />
</div>
<q-expansion-item dense dense-toggle icon="tune" label="Option avancée" header-class="text-grey-7 q-px-none q-mt-xs" style="font-size:13px">
<div class="row items-center q-gutter-sm q-pt-sm q-pl-sm">
<span class="text-caption text-grey-7">Mode</span>
<q-btn-toggle v-model="draft.mode" dense flat no-caps toggle-color="primary" color="grey-6"
:options="[{ label: 'Remplace', value: 'replace' }, { label: 'Ajoute', value: 'add' }]" />
<span class="text-caption text-grey-5">{{ draft.mode === 'add' ? 's\'ajoute aux compétences du tech' : 'ce jour-là, le tech ne fait que ces compétences' }}</span>
</div>
</q-expansion-item>
</q-card-section>
</q-card>
</q-dialog>
</template>
<script setup>
import { ref, reactive, computed, watch } from 'vue'
import { useQuasar } from 'quasar'
import * as roster from 'src/api/roster'
import TagEditor from 'src/components/shared/TagEditor.vue'
import { useTagCatalog } from 'src/composables/useTagCatalog'
const props = defineProps({
modelValue: { type: Boolean, default: false },
techs: { type: Array, default: () => [] },
})
const emit = defineEmits(['update:modelValue', 'changed'])
const $q = useQuasar()
const err = (e) => $q.notify({ type: 'negative', message: '' + (e.message || e) })
const open = computed({ get: () => props.modelValue, set: v => emit('update:modelValue', v) })
const { allTags, getColor, load: loadCatalog } = useTagCatalog()
const rows = ref([])
const draft = reactive({ id: '', origName: '', name: '', mode: 'replace', skills: [] })
// TagEditor attend des OBJETS { name, label, color } : catalogue partagé + compétences réelles des techs.
const skillTags = computed(() => {
const cat = allTags.value || []
const have = new Set(cat.map(t => String(t.label || t.name || '').toLowerCase()))
const extra = []
for (const t of (props.techs || [])) for (const sk of (t.skills || [])) {
const k = String(sk || '').toLowerCase()
if (sk && !have.has(k)) { have.add(k); extra.push({ name: sk, label: sk, color: (getColor && getColor(sk)) || '#607d8b' }) }
}
return [...cat, ...extra]
})
const normList = (list) => [...new Set((Array.isArray(list) ? list : String(list || '').split(',')).map(x => (typeof x === 'string' ? x : (x && x.tag) || '')).map(s => String(s).trim()).filter(Boolean))]
async function loadRows () { try { rows.value = (await roster.listFunctions()).functions || [] } catch (e) { err(e) } }
function resetDraft () { Object.assign(draft, { id: '', origName: '', name: '', mode: 'replace', skills: [] }) }
function editRow (f) { Object.assign(draft, { id: f.id, origName: f.name, name: f.name, mode: f.mode || 'replace', skills: (f.skills || []).slice() }) }
async function saveRow () {
const name = String(draft.name || '').trim()
if (!name) { $q.notify({ type: 'warning', message: 'Un nom est requis' }); return }
const payload = { name, mode: draft.mode, skills: draft.skills }
if (draft.id) payload.id = draft.id
try { await roster.saveFunction(payload); emit('changed'); resetDraft(); await loadRows(); $q.notify({ type: 'positive', message: 'Fonction enregistrée' }) } catch (e) { err(e) }
}
async function removeRow (f) {
try { await roster.deleteFunction(f.id); emit('changed'); if (draft.id === f.id) resetDraft(); await loadRows() } catch (e) { err(e) }
}
watch(() => props.modelValue, (v) => { if (v) { loadCatalog && loadCatalog(); resetDraft(); loadRows() } })
</script>
<style scoped>
.fn-row { border-bottom: 1px solid #f0f2f5; gap: 12px; }
.fn-name { min-width: 130px; font-weight: 500; }
.fn-skills { flex: 1; font-size: 13px; }
.fn-mode { font-size: 11px; color: #94a3b8; }
</style>

View File

@ -14,7 +14,7 @@
<span class="pcm-sep"></span>
<input type="text" inputmode="numeric" maxlength="5" placeholder="16:00" v-model="r.end" class="pcm-time" @change="onTimeEdit(r, 'end')" @blur="onTimeEdit(r, 'end')" />
<span class="pcm-h">{{ rowHours(r) }}h</span>
<q-btn flat dense round size="xs" icon="close" color="grey-6" @click="removeRow(i)"><q-tooltip>Retirer ce quart</q-tooltip></q-btn>
<q-btn flat dense round size="xs" icon="delete" color="grey-6" @click="removeRow(i)"><q-tooltip>Retirer ce quart</q-tooltip></q-btn>
</div>
<div v-if="functions.length" class="pcm-role">
<q-icon name="badge" size="13px" color="grey-6" />

View File

@ -223,7 +223,7 @@
<q-input dense outlined v-model="assignSearch" autofocus placeholder="Rechercher un agent…" class="full-width" @keydown.stop><template #prepend><q-icon name="search" size="16px" /></template></q-input>
</q-item>
<q-item v-for="a in filteredAgents" :key="a" clickable @click="pickAgent(a)" :active="a === coordAssignee" active-class="bg-green-1">
<q-item-section avatar><q-avatar size="22px" :style="{ background: staffColor(a), color: '#fff', fontSize: '10px' }">{{ staffInitials(noteAuthorName(a)) }}</q-avatar></q-item-section>
<q-item-section avatar><UserAvatar :email="a" :name="noteAuthorName(a)" :size="22" card /></q-item-section>
<q-item-section><q-item-label>{{ noteAuthorName(a) }}</q-item-label><q-item-label caption>{{ a }}</q-item-label></q-item-section>
<q-item-section side><q-icon v-if="a === coordAssignee" name="star" color="warning" size="16px"><q-tooltip>Responsable</q-tooltip></q-icon><q-icon v-else-if="coordAssistants.includes(a)" name="group" color="blue-grey-5" size="16px"><q-tooltip>Assistant</q-tooltip></q-icon></q-item-section>
</q-item>
@ -445,7 +445,7 @@
<q-space />
<template v-if="editNoteId !== msg.id">
<q-btn flat dense round size="xs" icon="edit" color="grey-5" @click="startEditNote(msg)"><q-tooltip>Modifier</q-tooltip></q-btn>
<q-btn flat dense round size="xs" icon="close" color="grey-5" @click="removeNote(msg)"><q-tooltip>Supprimer</q-tooltip></q-btn>
<q-btn flat dense round size="xs" icon="delete" color="grey-5" @click="removeNote(msg)"><q-tooltip>Supprimer</q-tooltip></q-btn>
</template>
</div>
<q-input v-if="editNoteId === msg.id" dense outlined v-model="editNoteText" autofocus class="q-mt-xs" @keyup.enter="saveEditNote(msg)" @keyup.esc="editNoteId = ''">
@ -558,7 +558,7 @@
<!-- BROUILLON MIROIR (lecture seule) : collègue qui rédige, ou le mien si le composeur est replié -->
<div v-else-if="msg._kind === 'draft'" class="conv-draft-inline" :class="{ 'conv-draft-inline-mine': msg.mine }">
<div class="row items-center no-wrap conv-draft-inline-head">
<q-avatar size="20px" class="q-mr-xs" :class="{ 'presence-pulse': !msg.mine }" :style="{ background: staffColor(msg.agent), color: '#fff', fontSize: '9px' }">{{ staffInitials(noteAuthorName(msg.agent)) }}</q-avatar>
<UserAvatar :email="msg.agent" :name="noteAuthorName(msg.agent)" :size="20" card class="q-mr-xs" :class="{ 'presence-pulse': !msg.mine }" />
<q-icon name="edit_note" size="15px" color="primary" class="q-mr-xs" />
<b class="text-green-8">{{ msg.mine ? 'Vous' : shortAgent(msg.agent) }}</b>
<span class="text-caption text-grey-6 q-ml-xs">· brouillon<template v-if="msg.replyTo && msgById(msg.replyTo)"> · en réponse à {{ msgWho(msgById(msg.replyTo)) }}</template><template v-else-if="!msg.mine"> · rédige en direct</template></span>
@ -578,7 +578,7 @@
<!-- Avatar (photo sinon initiales) + nom par message on voit QUI a écrit.
Affiché dès qu'un fil a >1 message ET TOUJOURS pour un message d'agent (staff),
même seul dans le fil (sinon on ne sait pas quel collègue l'a envoyé ex. courriel transféré). -->
<UserAvatar v-if="showMsgWho(msg)" :email="msg.agent || msg.fromEmail" :name="msgWho(msg)" :size="22" class="q-mr-xs msg-av" />
<UserAvatar v-if="showMsgWho(msg)" :email="msg.agent || msg.fromEmail" :name="msgWho(msg)" :size="22" card class="q-mr-xs msg-av" />
<q-icon :name="msgIcon(msg)" :color="msg.from === 'agent' ? (msg.via === 'ai' ? 'deep-purple-4' : 'green-5') : 'teal-6'" size="13px" class="q-mr-xs" />
<router-link v-if="msg.from === 'customer' && coordCustomer && showMsgWho(msg)" :to="'/clients/' + encodeURIComponent(coordCustomer)" class="msg-who" style="color:#3730a3;text-decoration:underline;text-underline-offset:2px" @click.stop>{{ msgWho(msg) }}<q-tooltip class="bg-grey-9" style="font-size:11px">Ouvrir la fiche client (OPS 360) </q-tooltip></router-link>
<span v-else-if="showMsgWho(msg)" class="msg-who">{{ msgWho(msg) }}</span>
@ -649,7 +649,7 @@
<q-list dense>
<q-item-label header class="q-py-xs">Taguer un collègue · <span class="text-grey-6"> · <b>Tab</b>/<b>Espace</b> pour insérer</span></q-item-label>
<q-item v-for="(a, i) in mentionMatches" :key="a" clickable @click="pickMention(a)" :active="i === (mention.active || 0)" active-class="mention-active" @mouseenter="mention.active = i">
<q-item-section avatar><q-avatar size="24px" :style="{ background: staffColor(a), color: '#fff', fontSize: '10px' }">{{ staffInitials(noteAuthorName(a)) }}</q-avatar></q-item-section>
<q-item-section avatar><UserAvatar :email="a" :name="noteAuthorName(a)" :size="24" card /></q-item-section>
<q-item-section><q-item-label>{{ noteAuthorName(a) }}</q-item-label><q-item-label caption>@{{ shortAgent(a) }}</q-item-label></q-item-section>
<q-item-section side v-if="i === (mention.active || 0)"><q-icon name="keyboard_return" size="15px" color="warning" /></q-item-section>
</q-item>
@ -867,7 +867,7 @@
</q-dialog>
<q-dialog v-model="ticketDialog.open">
<q-card style="min-width:380px;max-width:96vw">
<q-card style="min-width:460px;max-width:96vw">
<q-card-section class="row items-center q-pb-none">
<q-icon name="confirmation_number" color="positive" size="22px" class="q-mr-sm" />
<div class="text-subtitle1 text-weight-bold">Créer un ticket</div>
@ -876,11 +876,13 @@
</q-card-section>
<q-card-section class="q-gutter-sm">
<div class="text-caption text-grey-7">À partir de la conversation avec <b>{{ activeDiscussion && (activeDiscussion.customerName || activeDiscussion.phone) }}</b>. La conversation reste dans l'historique ; le ticket suit le travail.</div>
<q-input dense outlined v-model="ticketDialog.title" label="Titre" autofocus @keyup.enter="ticketDialog.title.trim() && submitTicket()" />
<div class="row q-col-gutter-sm">
<q-select class="col" dense outlined v-model="ticketDialog.category" :options="ticketCategories" label="Catégorie" />
<q-select class="col" dense outlined v-model="ticketDialog.priority" :options="['Low','Medium','High','Urgent']" label="Priorité" />
<!-- Catalogue de cartes (icône par type) EN TÊTE MÊME vocabulaire/UI que la fiche (IssueTypePicker + config/issue-types). -->
<div>
<div class="text-caption text-weight-medium text-grey-7 q-mb-xs">Quel type de demande&nbsp;?</div>
<IssueTypePicker v-model="ticketDialog.category" />
</div>
<q-input dense outlined v-model="ticketDialog.title" label="Titre" autofocus @keyup.enter="ticketDialog.title.trim() && submitTicket()" />
<q-select dense outlined v-model="ticketDialog.priority" :options="ticketPriorityOptions" label="Priorité" emit-value map-options />
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Annuler" v-close-popup />
@ -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 || []))

View File

@ -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)
}
</script>
@ -90,16 +168,44 @@ function submit () {
<div class="row q-gutter-xs">
<span v-for="p in SCHEDULE_PRESETS" :key="p.key" class="wse-preset" @click="fill(p.schedule)">{{ p.label }}</span>
</div>
<!-- Jours -->
<div v-if="bulk" class="wse-bulk-note"><q-icon name="groups" size="15px" /><span>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.</span></div>
<!-- Jours chaque jour peut avoir PLUSIEURS quarts (slots), chacun avec son rôle (ex. jour=install, soir=support) -->
<div class="wse-grid">
<div v-for="[k, lbl] in DAYS" :key="k" class="wse-day" :class="{ off: !sched[k]?.on }">
<q-checkbox dense v-model="sched[k].on" :label="lbl" class="wse-day-lbl" />
<template v-if="sched[k]?.on">
<input type="time" v-model="sched[k].start" class="wse-time" />
<div v-if="sched[k]?.on" class="wse-slots">
<div v-for="(sl, si) in sched[k].slots" :key="si">
<div class="wse-slot-row">
<input type="text" inputmode="numeric" maxlength="5" placeholder="08:00" v-model="sl.start" class="wse-time" @change="sl.start = norm24(sl.start)" @blur="sl.start = norm24(sl.start)" />
<span class="wse-sep"></span>
<input type="time" v-model="sched[k].end" class="wse-time" />
<span class="wse-h">{{ dayHours(sched[k]) }}h</span>
<input type="text" inputmode="numeric" maxlength="5" placeholder="16:00" v-model="sl.end" class="wse-time" @change="sl.end = norm24(sl.end)" @blur="sl.end = norm24(sl.end)" />
<select v-if="functions.length" v-model="sl.function" class="wse-fn" title="Rôle du quart : compétences effectives du tech pour ce quart" @change="onRoleChange(k, si, sl)">
<option value=""> rôle </option>
<option v-for="f in functions" :key="f.id" :value="f.id">{{ f.name }}</option>
<option v-if="sl.function && !isRoleVal(sl.function)" :value="sl.function"> compétences ajustées</option>
</select>
<span class="wse-h">{{ slotHours(sl) }}h</span>
<!-- Affinage par chips : 1 tech seulement (les compétences de base diffèrent d'un tech à l'autre en lot) -->
<span v-if="!bulk" class="wse-adjust" :class="{ open: expanded[keyOf(k, si)] }" @click="toggleExpand(k, si, sl)"><q-icon name="tune" size="13px" />ajuster</span>
<q-btn v-if="sched[k].slots.length > 1" flat dense round size="xs" icon="delete" color="grey-6" @click="removeSlot(k, si)"><q-tooltip>Retirer ce quart</q-tooltip></q-btn>
</div>
<div v-if="!bulk && expanded[keyOf(k, si)] && ui(k, si)" class="wse-chips">
<template v-if="ui(k, si).universe.length">
<div class="wse-chips-hint">Ses compétences habituelles cliquez pour <b>désactiver</b> sur ce créneau</div>
<div class="row items-center" style="flex-wrap:wrap;gap:6px">
<span v-for="sk in ui(k, si).universe" :key="sk" class="wse-chip" :class="{ off: !selHas(ui(k, si), sk) }" @click="toggleSkill(k, si, sl, sk)">
<q-icon :name="selHas(ui(k, si), sk) ? 'check' : 'block'" size="12px" />{{ sk }}
</span>
</div>
<div v-if="ui(k, si).roleId" class="wse-role-note"><q-icon name="info" size="12px" /> Rôle « {{ ui(k, si).roleLabel }} » actif cliquer une compétence bascule sur ses compétences habituelles.</div>
<div v-else-if="disabledCount(ui(k, si))" class="wse-custom-note"><q-icon name="block" size="12px" /> {{ disabledCount(ui(k, si)) }} désactivée(s) {{ DAYNAME[k] }} sur ce créneau.</div>
<div class="wse-chips-foot">Pour ajouter ou retirer <b>définitivement</b> une compétence, modifiez ses compétences de base sur sa fiche.</div>
</template>
<div v-else class="wse-chips-hint">Aucune compétence de base enregistrée à définir sur la fiche du technicien.</div>
</div>
</div>
<span class="wse-addslot" @click="addSlot(k)"><q-icon name="add" size="12px" />quart</span>
</div>
<span v-else class="wse-repos">Repos</span>
</div>
</div>
@ -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; }
</style>

View File

@ -97,7 +97,7 @@
<div class="text-subtitle2 mono">{{ bc.value }}</div>
</div>
<q-btn flat dense icon="search" @click="lookupDevice(bc.value)" :loading="lookingUp === bc.value" />
<q-btn flat dense icon="close" color="negative" @click="scanner.removeBarcode(bc.value)" />
<q-btn flat dense icon="delete" color="negative" @click="scanner.removeBarcode(bc.value)" />
</q-card-section>
<!-- Lookup result -->

View File

@ -166,7 +166,7 @@
<q-input class="col-2" v-model="p.icon" dense outlined label="Icône" maxlength="4" />
<q-input class="col" v-model="p.label" dense outlined label="Libellé" />
<q-input class="col-3" v-model="p.sub" dense outlined label="Détail" />
<q-btn class="col-auto" flat dense round icon="close" color="grey-7" @click="form[lg].program.splice(i, 1)" />
<q-btn class="col-auto" flat dense round icon="delete" color="grey-7" @click="form[lg].program.splice(i, 1)" />
</div>
<q-btn flat dense no-caps size="sm" icon="add" label="Ajouter un élément" color="primary" @click="form[lg].program.push({ icon: '', label: '', sub: '' })" />
@ -177,7 +177,7 @@
<div class="text-weight-medium text-grey-8 q-mt-sm">Notes de bas de page</div>
<div v-for="(n, i) in form[lg].notes" :key="'n' + i" class="row q-col-gutter-xs items-center">
<q-input class="col" v-model="form[lg].notes[i]" dense outlined :label="'Note ' + (i + 1)" />
<q-btn class="col-auto" flat dense round icon="close" color="grey-7" @click="form[lg].notes.splice(i, 1)" />
<q-btn class="col-auto" flat dense round icon="delete" color="grey-7" @click="form[lg].notes.splice(i, 1)" />
</div>
<q-btn flat dense no-caps size="sm" icon="add" label="Ajouter une note" color="primary" @click="form[lg].notes.push('')" />
</q-tab-panel>
@ -296,7 +296,7 @@
<q-item-label class="text-caption">{{ r.name }} <q-badge dense :color="r.language === 'en' ? 'deep-orange-6' : 'blue-6'" :label="(r.language || 'fr').toUpperCase()" class="q-ml-xs" /></q-item-label>
<q-item-label caption>{{ r.email }}<span v-if="r.source"> · {{ r.source }}</span></q-item-label>
</q-item-section>
<q-item-section side><q-btn flat dense round size="sm" icon="close" @click="removeFromList(r)" /></q-item-section>
<q-item-section side><q-btn flat dense round size="sm" icon="delete" @click="removeFromList(r)" /></q-item-section>
</q-item>
<q-item v-if="mainList.count > mainList.sample.length" dense><q-item-section class="text-caption text-grey-6">et {{ mainList.count - mainList.sample.length }} autres (affichage plafonné à 200)</q-item-section></q-item>
</q-list>
@ -359,7 +359,7 @@
<q-item-label>{{ p.customer_name || p.customer_id }}</q-item-label>
<q-item-label caption>{{ p.customer_id }}<span v-if="p.email"> · {{ p.email }}</span><span v-else class="text-orange-8"> · sans courriel (sera ignoré)</span></q-item-label>
</q-item-section>
<q-item-section side><q-btn flat dense round size="sm" icon="close" @click="removeFromPool(p)" /></q-item-section>
<q-item-section side><q-btn flat dense round size="sm" icon="delete" @click="removeFromPool(p)" /></q-item-section>
</q-item>
</q-list>
<q-btn unelevated no-caps color="primary" icon="playlist_add" :label="`Ajouter les ${pool.length} sélectionné(s) à la liste`" :loading="listBusy" @click="addManualToList" />

View File

@ -757,7 +757,7 @@
<q-input dense outlined type="number" min="1" v-model.number="s.weeks" style="width:88px" suffix="sem." />
<q-btn flat dense round size="xs" icon="arrow_upward" :disable="i === 0" @click="moveTech(i, -1)"><q-tooltip>Monter</q-tooltip></q-btn>
<q-btn flat dense round size="xs" icon="arrow_downward" :disable="i === newGardeRule.steps.length - 1" @click="moveTech(i, 1)"><q-tooltip>Descendre</q-tooltip></q-btn>
<q-btn flat dense round size="xs" icon="close" color="grey-6" @click="newGardeRule.steps.splice(i, 1)"><q-tooltip>Retirer</q-tooltip></q-btn>
<q-btn flat dense round size="xs" icon="delete" color="grey-6" @click="newGardeRule.steps.splice(i, 1)"><q-tooltip>Retirer</q-tooltip></q-btn>
</div>
<div class="text-caption text-grey-6">« sem. » = semaines consécutives. Pour <b>N semaines d'écart</b> entre 2 tours d'un tech, mets <b>N+1 étapes</b> (ex. ABC = 2 sem. d'écart).</div>
</div>
@ -1220,7 +1220,7 @@
<q-item-label v-if="s.action" style="font-size:12px" class="text-warning">Ticket #{{ s.ticket }} Ops « {{ s.ops_nom }} » legacy « {{ s.legacy_staff }} » <b>ignoré</b></q-item-label>
<q-item-label v-else style="font-size:12px">Ticket #{{ s.ticket }} <b>{{ s.vers_staff }}</b><span class="text-grey-6"> · {{ s.sujet }}</span></q-item-label>
</q-item-section>
<q-item-section side v-if="s.job"><q-btn flat dense round size="sm" icon="close" color="negative" :disable="legacyPush.applying" @click="removeFromPush(s)"><q-tooltip>Désassigner ce job (retour au pool) ne sera pas poussé</q-tooltip></q-btn></q-item-section>
<q-item-section side v-if="s.job"><q-btn flat dense round size="sm" icon="inbox" color="grey-7" :disable="legacyPush.applying" @click="removeFromPush(s)"><q-tooltip>Désassigner ce job (retour au pool) ne sera pas poussé</q-tooltip></q-btn></q-item-section>
</q-item>
</q-list>
<div class="text-caption text-grey-6 q-mt-xs">Sécurité : seuls les tickets <b>ouverts</b> sont touchés, le nom du staff legacy doit concorder, et c'est idempotent.</div>
@ -1693,7 +1693,7 @@
<q-item v-if="asgSkills(a).length" clickable @click="setAsgSkills(a, [])"><q-item-section class="text-grey-7">Effacer (compétences de base)</q-item-section></q-item>
</q-list></q-menu>
</q-btn>
<q-btn flat dense round size="sm" icon="close" color="grey-6" @click="deleteDayAsg(a)"><q-tooltip>Supprimer ce quart (retire le doublon / chevauchement)</q-tooltip></q-btn>
<q-btn flat dense round size="sm" icon="delete" color="grey-6" @click="deleteDayAsg(a)"><q-tooltip>Supprimer ce quart (retire le doublon / chevauchement)</q-tooltip></q-btn>
</div>
<div v-if="!dayFuncAsgs().length" class="row items-center no-wrap q-mb-xs">
<q-icon name="badge" size="16px" color="grey-6" class="q-mr-xs" />
@ -1803,7 +1803,7 @@
</q-btn>
<q-btn flat dense round size="sm" :icon="j.locked ? 'lock' : 'lock_open'" :color="j.locked ? 'deep-orange' : 'grey-5'" @click="j.locked = !j.locked"><q-tooltip>{{ j.locked ? 'Heure FIXE (RDV) — verrouillée, non replanifiée' : 'Heure flexible — replanifiée par la tournée' }}</q-tooltip></q-btn>
<q-btn v-if="isBlockingJob(j)" flat dense round size="sm" icon="delete" color="deep-orange-6" @click="deleteBlockJob(j)"><q-tooltip>Supprimer ce blocage (ex. Formation) le tech redevient dispatchable. N'est PAS renvoyé au pool.</q-tooltip></q-btn>
<q-btn v-else flat dense round size="sm" icon="close" color="negative" @click="removeFromDay(j)"><q-tooltip>Retirer du tech (retour au pool)</q-tooltip></q-btn>
<q-btn v-else flat dense round size="sm" icon="inbox" color="grey-7" @click="removeFromDay(j)"><q-tooltip>Retirer du tech (retour au pool)</q-tooltip></q-btn>
</template>
<q-btn v-else flat dense round size="sm" icon="open_in_new" color="green-5" @click.stop="openLegacyBlock({ lid: j.lid || j.legacy_id, subject: j.subject, dept: j.dept })"><q-tooltip>Ouvrir le ticket F (lecture seule)</q-tooltip></q-btn>
</div>
@ -1898,7 +1898,7 @@
<q-item v-if="asgSkills(a).length" clickable @click="setAsgSkills(a, [])"><q-item-section class="text-grey-7">Effacer (compétences de base)</q-item-section></q-item>
</q-list></q-menu>
</q-btn>
<q-btn flat dense round size="sm" icon="close" color="grey-6" @click="deleteDayAsg(a)"><q-tooltip>Supprimer ce quart (retire le doublon / chevauchement)</q-tooltip></q-btn>
<q-btn flat dense round size="sm" icon="delete" color="grey-6" @click="deleteDayAsg(a)"><q-tooltip>Supprimer ce quart (retire le doublon / chevauchement)</q-tooltip></q-btn>
</div>
<div v-if="!roleAsgs().length" class="text-grey-6 q-pa-sm text-center">Aucun quart régulier ce jour ajoutez-en un depuis le menu de la cellule.</div>
</q-card-section>