Déclutter: primitives réutilisables + palette ⌘K + Planif épurée

Ajoute 3 primitives partagées pour standardiser le désencombrement
(philosophie 3 paliers : coup d'œil → survol → détail) :
- OverflowMenu.vue : « ⋮ » standardisé (une action primaire visible,
  le secondaire dans le menu).
- DisclosureSection.vue : section repliée par défaut (résumé + corps
  révélé), état persisté par utilisateur (useUserPrefs).
- CommandPalette.vue + useCommandPalette.js : palette globale ⌘K
  (actions rapides + pages + actions contextuelles de page + recherche
  clients/équipe), registre d'actions par page = escape hatch.

Câblage MainLayout : palette montée + raccourci Cmd/Ctrl+K + indice ⌘K
dans la recherche desktop.

Adoptions :
- RapportsPage : PageHeader + stubs « Opérations » repliés + rapports
  enregistrés dans la palette.
- DashboardPage : bloc admin replié (DisclosureSection) + PPA rétrogradé
  dans un OverflowMenu (anti clic accidentel) + actions dans la palette.
- PlanificationPage : le panneau « Jobs à assigner » n'ouvre plus au
  chargement ; alerte « N à assigner » par jour (clic → panneau filtré) ;
  undo/redo/refresh regroupés dans un OverflowMenu ; actions dans la palette.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-09 06:53:24 -04:00
parent f4a6d07a8b
commit 893ac8cd4a
8 changed files with 530 additions and 33 deletions

View File

@ -0,0 +1,212 @@
<template>
<!-- Palette de commandes globale (Cmd/Ctrl+K). Recherche : actions rapides + pages +
actions contextuelles de la page + clients/équipe (fuzzy via le hub).
C'est l'escape hatch qui permet de déclutterer : rien de caché n'est perdu. -->
<q-dialog v-model="open" @show="onShow" @hide="onHide" position="top" transition-show="fade" transition-hide="fade">
<q-card class="cmdp">
<div class="cmdp-input-row">
<q-icon name="search" size="22px" class="cmdp-search-icon" />
<input
ref="inputEl"
v-model="query"
class="cmdp-input"
type="text"
placeholder="Rechercher une action, une page, un client…"
@keydown.down.prevent="move(1)"
@keydown.up.prevent="move(-1)"
@keydown.enter.prevent="runHighlighted"
@keydown.esc.prevent="open = false"
/>
<span class="cmdp-hint">esc</span>
</div>
<q-scroll-area class="cmdp-results" :style="{ height: resultsHeight }">
<template v-if="visible.length">
<template v-for="(grp, gi) in grouped" :key="grp.label">
<div class="cmdp-group-label">{{ grp.label }}</div>
<div
v-for="it in grp.items" :key="it._key"
class="cmdp-item" :class="{ 'is-active': it._idx === highlight }"
@mousemove="highlight = it._idx"
@click="runItem(it)"
>
<q-icon :name="it.icon || 'chevron_right'" size="20px" class="cmdp-item-icon" :style="it.color ? { color: it.color } : null" />
<div class="cmdp-item-body">
<div class="cmdp-item-label">{{ it.label }}</div>
<div v-if="it.caption" class="cmdp-item-caption">{{ it.caption }}</div>
</div>
<span v-if="it.typeLabel" class="cmdp-item-type">{{ it.typeLabel }}</span>
</div>
<q-separator v-if="gi < grouped.length - 1" spaced />
</template>
</template>
<div v-else class="cmdp-empty">
<q-spinner v-if="searching" size="20px" />
<span v-else>Aucun résultat pour « {{ query }} »</span>
</div>
</q-scroll-area>
<div class="cmdp-footer">
<span><kbd></kbd><kbd></kbd> naviguer</span>
<span><kbd></kbd> ouvrir</span>
<span><kbd></kbd><kbd>K</kbd> palette</span>
</div>
</q-card>
</q-dialog>
</template>
<script setup>
import { ref, computed, nextTick, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { HUB_URL } from 'src/config/hub'
import { navItems as allNavItems } from 'src/config/nav'
import { usePermissions } from 'src/composables/usePermissions'
import { useConversations } from 'src/composables/useConversations'
import { useCreateSignal } from 'src/composables/useCreateSignal'
import { useCommandPalette } from 'src/composables/useCommandPalette'
const route = useRoute()
const router = useRouter()
const { can } = usePermissions()
const { newDialogOpen, newDialogChannel, openComposeDraft, panelOpen } = useConversations()
const { requestCreate } = useCreateSignal()
const { open, contextActions } = useCommandPalette()
const inputEl = ref(null)
const query = ref('')
const highlight = ref(0)
const searching = ref(false)
const entityResults = ref([])
let debounce = null
// Actions rapides intégrées (miroir du FAB « créer rapide »)
const quickActions = computed(() => {
const a = []
if (can('view_clients')) {
a.push({ id: 'create', label: 'Créer…', caption: 'tâche · intervention · soumission', icon: 'add_box', color: '#21ba45', group: 'Actions', keywords: 'nouveau job ticket devis creer', run: () => { requestCreate(); if (!route.path.startsWith('/planification')) router.push('/planification') } })
a.push({ id: 'email', label: 'Nouveau courriel', icon: 'mail', color: '#c10015', group: 'Actions', keywords: 'email mail message ecrire', run: () => { newDialogChannel.value = 'email'; newDialogOpen.value = true } })
a.push({ id: 'sms', label: 'Nouveau texto', icon: 'sms', color: '#21a56a', group: 'Actions', keywords: 'sms texto message', run: () => { newDialogChannel.value = 'sms'; newDialogOpen.value = true } })
a.push({ id: 'comms', label: 'Ouvrir les communications', icon: 'forum', group: 'Actions', keywords: 'boite inbox conversations fils', run: () => { panelOpen.value = false; router.push('/communications') } })
}
return a
})
// Pages (nav filtrée par capacités)
const pageActions = computed(() =>
allNavItems
.filter(n => !n.requires || can(n.requires))
.map(n => ({ id: 'nav-' + n.path, label: n.label, icon: 'north_east', group: 'Aller à', keywords: n.path, run: () => router.push(n.path) })),
)
// Actions contextuelles enregistrées par la page courante
const pageContext = computed(() =>
contextActions().map((a, i) => ({ ...a, id: a.id || ('ctx-' + i), group: a.group || 'Sur cette page', icon: a.icon || 'bolt' })),
)
function matches (item, q) {
if (!q) return true
const hay = (item.label + ' ' + (item.caption || '') + ' ' + (item.keywords || '')).toLowerCase()
return q.toLowerCase().split(/\s+/).every(tok => hay.includes(tok))
}
// Items filtrés, dans l'ordre de collecte : contexte actions pages entités.
const filtered = computed(() => {
const q = query.value.trim()
const out = []
for (const it of pageContext.value) if (matches(it, q)) out.push(it)
for (const it of quickActions.value) if (matches(it, q)) out.push(it)
for (const it of pageActions.value) if (matches(it, q)) out.push(it)
for (const it of entityResults.value) out.push(it) // déjà filtrées côté serveur
return out
})
// Rang des groupes GLOBAUX ; tout groupe inconnu (= action de page) passe en tête (0).
const GLOBAL_RANK = { 'Actions': 1, 'Clients / Équipe': 2, 'Aller à': 3 }
// Groupé POUR L'AFFICHAGE, puis aplati dans CE MÊME ordre pour attribuer `_idx`
// la navigation clavier suit exactement l'ordre visuel (pas de saut).
const grouped = computed(() => {
const byGroup = {}; const order = []
for (const it of filtered.value) { if (!byGroup[it.group]) { byGroup[it.group] = []; order.push(it.group) } byGroup[it.group].push(it) }
order.sort((a, b) => (a in GLOBAL_RANK ? GLOBAL_RANK[a] : 0) - (b in GLOBAL_RANK ? GLOBAL_RANK[b] : 0)) // tri stable
let idx = 0; const groups = []
for (const label of order) {
const items = byGroup[label].map(it => { const _idx = idx++; return { ...it, _idx, _key: (it.id || it.route || it.label) + '#' + _idx } })
groups.push({ label, items })
}
return groups
})
// Liste plate dans l'ordre d'affichage (pour le clavier).
const visible = computed(() => grouped.value.flatMap(g => g.items))
const resultsHeight = computed(() => Math.min(420, Math.max(120, visible.value.length * 52 + 40)) + 'px')
// Recherche d'entités (clients + équipe), fuzzy via le hub (même endpoint que la topbar)
function onQuery () {
clearTimeout(debounce)
const q = query.value.trim()
highlight.value = 0
if (q.length < 2) { entityResults.value = []; searching.value = false; return }
searching.value = true
debounce = setTimeout(() => runEntitySearch(q), 250)
}
async function runEntitySearch (q) {
try {
const r = await fetch(`${HUB_URL}/collab/customer-search?q=${encodeURIComponent(q)}&team=1`)
const matchesArr = r.ok ? ((await r.json()).matches || []) : []
entityResults.value = matchesArr.slice(0, 8).map(m => m.kind === 'équipe'
? { id: 't-' + m.name, group: 'Clients / Équipe', typeLabel: 'Équipe', icon: 'groups', label: m.customer_name || m.name, caption: 'Membre de léquipe · ' + m.email, run: () => openTeam(m.email) }
: { id: 'c-' + m.name, group: 'Clients / Équipe', typeLabel: 'Client', icon: m.matched === 'adresse' ? 'location_on' : m.matched === 'téléphone' ? 'call' : m.matched === 'courriel' ? 'mail' : 'person', label: m.customer_name || m.name, caption: [m.matched, m.address, m.email].filter(Boolean).join(' · ') || m.name, run: () => router.push('/clients/' + m.name) })
} catch { entityResults.value = [] }
searching.value = false
}
async function openTeam (email) {
if (route.path.startsWith('/communications/c/')) { await router.push('/communications'); await nextTick() }
openComposeDraft({ to: email, channel: 'email' })
}
// query relance la recherche entité
watch(query, onQuery)
// Clavier
function move (dir) {
const n = visible.value.length
if (!n) return
highlight.value = (highlight.value + dir + n) % n
}
function runHighlighted () {
const it = visible.value[highlight.value]
if (it) runItem(it)
}
function runItem (it) {
open.value = false
if (typeof it.run === 'function') it.run()
}
function onShow () {
query.value = ''
entityResults.value = []
highlight.value = 0
nextTick(() => inputEl.value && inputEl.value.focus())
}
function onHide () { query.value = ''; entityResults.value = [] }
</script>
<style scoped>
.cmdp { width: 640px; max-width: 92vw; margin-top: 8vh; border-radius: 14px; box-shadow: 0 24px 60px rgba(0,0,0,.28); overflow: hidden; background: var(--ops-surface, #fff); }
.cmdp-input-row { display: flex; align-items: center; gap: 10px; padding: 14px 16px; border-bottom: 1px solid var(--ops-border); }
.cmdp-search-icon { color: var(--ops-text-muted); }
.cmdp-input { flex: 1; border: none; outline: none; background: transparent; font-size: 1.05rem; color: var(--ops-text); }
.cmdp-hint { font-size: 0.68rem; color: var(--ops-text-muted); border: 1px solid var(--ops-border); border-radius: 6px; padding: 1px 6px; text-transform: uppercase; }
.cmdp-results { max-height: 60vh; }
.cmdp-group-label { font-size: 0.7rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--ops-text-muted); padding: 12px 16px 4px; }
.cmdp-item { display: flex; align-items: center; gap: 12px; padding: 9px 16px; cursor: pointer; }
.cmdp-item.is-active { background: var(--ops-bg-light); }
.cmdp-item-icon { color: var(--ops-text-muted); flex: 0 0 auto; }
.cmdp-item-body { flex: 1; min-width: 0; }
.cmdp-item-label { font-size: 0.94rem; color: var(--ops-text); font-weight: 550; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cmdp-item-caption { font-size: 0.78rem; color: var(--ops-text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cmdp-item-type { font-size: 0.7rem; color: var(--ops-text-muted); flex: 0 0 auto; }
.cmdp-empty { display: flex; align-items: center; justify-content: center; gap: 8px; padding: 32px; color: var(--ops-text-muted); font-size: 0.9rem; }
.cmdp-footer { display: flex; gap: 16px; padding: 8px 16px; border-top: 1px solid var(--ops-border); font-size: 0.72rem; color: var(--ops-text-muted); }
.cmdp-footer kbd { font-family: inherit; border: 1px solid var(--ops-border); border-radius: 5px; padding: 0 5px; margin-right: 2px; background: var(--ops-bg-light); }
</style>

View File

@ -0,0 +1,71 @@
<template>
<!-- Section repliable « glance detail » : n'affiche que l'essentiel, révèle le reste au clic.
Standardise le pattern ad-hoc (tickets slice(0,5) + « Voir tout », « Plus d'options »).
- #summary : ligne compacte TOUJOURS visible (aperçu quand replié). Optionnel.
- défaut : contenu révélé quand ouvert.
- #actions : boutons à droite de l'en-tête (n'ouvrent/ferment pas la section).
État d'ouverture persisté PAR UTILISATEUR si `persist-key` fourni (cross-appareil via /prefs).
Usage :
<DisclosureSection title="Tickets" :count="tickets.length" persist-key="client.tickets">
<template #summary>{{ openCount }} ouverts · dernier {{ lastDate }}</template>
<TicketList :rows="tickets" />
</DisclosureSection> -->
<div class="disclosure" :class="{ 'is-open': open }">
<div class="ds-header" role="button" tabindex="0"
@click="toggle" @keydown.enter.prevent="toggle" @keydown.space.prevent="toggle">
<q-icon :name="open ? 'expand_more' : 'chevron_right'" size="22px" class="ds-chevron" />
<q-icon v-if="icon" :name="icon" size="18px" class="ds-icon" />
<span class="ds-title">{{ title }}</span>
<span v-if="count !== null && count !== undefined" class="ds-count">{{ count }}</span>
<div v-if="$slots.summary && !open" class="ds-summary"><slot name="summary" /></div>
<q-space />
<div v-if="$slots.actions" class="ds-actions" @click.stop><slot name="actions" /></div>
</div>
<q-slide-transition>
<div v-show="open" class="ds-body"><slot /></div>
</q-slide-transition>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useUserPrefs } from 'src/composables/useUserPrefs'
const props = defineProps({
title: { type: String, default: '' },
count: { type: [Number, String], default: null },
icon: { type: String, default: '' },
defaultOpen: { type: Boolean, default: false },
// Clé de persistance de l'état ouvert/fermé (namespace 'disclosure'). Absente = pas de persistance.
persistKey: { type: String, default: '' },
})
// Un seul namespace partagé pour toutes les sections repliables de l'app.
const { prefs, save } = props.persistKey ? useUserPrefs('disclosure', {}) : { prefs: null, save: null }
const open = ref(
props.persistKey && prefs && prefs.value[props.persistKey] !== undefined
? !!prefs.value[props.persistKey]
: props.defaultOpen,
)
function toggle () {
open.value = !open.value
if (props.persistKey && save) save({ [props.persistKey]: open.value })
}
</script>
<style scoped>
.disclosure { border: 1px solid var(--ops-border); border-radius: 12px; background: var(--ops-surface, #fff); margin-bottom: 12px; overflow: hidden; }
.ds-header { display: flex; align-items: center; gap: 8px; padding: 12px 14px; cursor: pointer; user-select: none; min-height: 48px; }
.ds-header:hover { background: var(--ops-bg-light); }
.ds-header:focus-visible { outline: 2px solid var(--ops-primary, #6366f1); outline-offset: -2px; }
.ds-chevron { color: var(--ops-text-muted); flex: 0 0 auto; }
.ds-icon { color: var(--ops-text-muted); flex: 0 0 auto; }
.ds-title { font-weight: 650; font-size: 0.98rem; color: var(--ops-text); flex: 0 0 auto; }
.ds-count { font-size: 0.72rem; font-weight: 600; color: var(--ops-text-muted); background: var(--ops-bg-light); border: 1px solid var(--ops-border); border-radius: 20px; padding: 1px 9px; font-variant-numeric: tabular-nums; flex: 0 0 auto; }
.ds-summary { font-size: 0.84rem; color: var(--ops-text-muted); margin-left: 6px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ds-actions { display: flex; gap: 6px; align-items: center; flex: 0 0 auto; }
.ds-body { padding: 4px 14px 14px; border-top: 1px solid var(--ops-border); }
@media (max-width: 599px) { .ds-summary { display: none; } }
</style>

View File

@ -0,0 +1,79 @@
<template>
<!-- Menu de débordement « » standardisé : UNE action primaire visible dans la barre,
TOUT le secondaire ici. Kill le bloat des barres d'outils (Planif, Dispatch « Outils »).
Usage :
<OverflowMenu :groups="[
{ label:'Réglages', items:[{icon:'tune', label:'Compétences', action:openSkills}] },
{ items:[{icon:'refresh', label:'Rafraîchir', action:reload}] },
]" />
ou à plat : <OverflowMenu :items="[{icon:'refresh', label:'Rafraîchir', action:reload}]" />
Chaque item : { icon, label, caption?, color?, disabled?, action?(fn) | to?(route) }. -->
<q-btn v-bind="triggerProps" :label="label" :icon="icon" :aria-label="ariaLabel">
<q-tooltip v-if="tooltip">{{ tooltip }}</q-tooltip>
<q-menu anchor="bottom right" self="top right" auto-close>
<q-list dense style="min-width:220px">
<template v-for="(g, gi) in normalizedGroups" :key="gi">
<q-separator v-if="gi > 0 && g.label == null" spaced />
<q-item-label v-if="g.label" header class="om-header">{{ g.label }}</q-item-label>
<q-item
v-for="(it, ii) in g.items" :key="gi + '-' + ii"
clickable
:to="it.to || undefined"
:disable="!!it.disabled"
@click="run(it)"
>
<q-item-section v-if="it.icon" avatar>
<q-icon :name="it.icon" :color="it.color || undefined" size="20px" />
</q-item-section>
<q-item-section>
<q-item-label>{{ it.label }}</q-item-label>
<q-item-label v-if="it.caption" caption>{{ it.caption }}</q-item-label>
</q-item-section>
</q-item>
</template>
</q-list>
</q-menu>
</q-btn>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
// Soit des groupes (avec en-têtes + séparateurs), soit une liste à plat.
groups: { type: Array, default: null },
items: { type: Array, default: null },
// Apparence du déclencheur : par défaut, une puce ronde « » discrète.
label: { type: String, default: '' },
icon: { type: String, default: 'more_vert' },
tooltip: { type: String, default: 'Plus dactions' },
ariaLabel: { type: String, default: 'Plus dactions' },
flat: { type: Boolean, default: true },
dense: { type: Boolean, default: true },
round: { type: Boolean, default: true },
})
// Déclencheur : rond quand pas de label (), sinon bouton texte discret.
const triggerProps = computed(() => ({
flat: props.flat,
dense: props.dense,
round: props.round && !props.label,
noCaps: true,
}))
const normalizedGroups = computed(() => {
if (props.groups && props.groups.length) {
return props.groups.map(g => ({ label: g.label || null, items: (g.items || []).filter(Boolean) }))
}
return [{ label: null, items: (props.items || []).filter(Boolean) }]
})
function run (it) {
if (it.disabled) return
if (typeof it.action === 'function') it.action()
}
</script>
<style scoped>
.om-header { color: var(--ops-text-muted); font-size: 0.72rem; letter-spacing: 0.03em; text-transform: uppercase; font-weight: 700; padding-top: 8px; }
</style>

View File

@ -0,0 +1,52 @@
import { ref, reactive, readonly } from 'vue'
// ─────────────────────────────────────────────────────────────────────────────
// Palette de commandes globale (Cmd/Ctrl+K) — l'« escape hatch » qui permet de
// DÉCLUTTERER les pages : toute action reléguée dans un ⋮ ou une DisclosureSection
// reste joignable en tapant. Une seule instance, montée dans MainLayout.
//
// const { open, openPalette, registerActions } = useCommandPalette()
//
// Une PAGE enregistre ses actions contextuelles au montage et les retire au démontage :
// onMounted(() => registerActions('planif', [
// { id:'publish', label:'Publier lhoraire', icon:'cloud_upload', group:'Planification', run: publishWeek },
// ]))
// onUnmounted(() => unregisterActions('planif'))
//
// Chaque action : { id, label, icon?, caption?, group?, keywords?, run() }.
// ─────────────────────────────────────────────────────────────────────────────
const open = ref(false)
const _sources = reactive({}) // sourceId → [actions]
function openPalette () { open.value = true }
function closePalette () { open.value = false }
function togglePalette () { open.value = !open.value }
// Enregistre (ou remplace) le lot d'actions d'une source contextuelle.
function registerActions (sourceId, actions) {
_sources[sourceId] = Array.isArray(actions) ? actions.filter(a => a && a.label && typeof a.run === 'function') : []
}
function unregisterActions (sourceId) {
delete _sources[sourceId]
}
// Toutes les actions contextuelles actuellement enregistrées, à plat.
function contextActions () {
const out = []
for (const id in _sources) for (const a of _sources[id]) out.push({ ...a, _source: id })
return out
}
export function useCommandPalette () {
return {
open,
openPalette,
closePalette,
togglePalette,
registerActions,
unregisterActions,
contextActions,
sources: readonly(_sources),
}
}

View File

@ -98,8 +98,11 @@
@keydown.down.prevent="moveHighlight(1)" @keydown.up.prevent="moveHighlight(-1)"
@update:model-value="onSearchInput" @blur="onSearchBlur">
<template #prepend><q-icon name="search" color="grey-6" /></template>
<template #append v-if="searchQuery">
<q-icon name="close" class="cursor-pointer" @click="clearSearch" />
<template #append>
<q-icon v-if="searchQuery" name="close" class="cursor-pointer" @click="clearSearch" />
<div v-else class="ops-kbd-hint cursor-pointer" @click="openPalette">
<q-tooltip>Palette de commandes (K)</q-tooltip>K
</div>
</template>
</q-input>
<div v-if="searchResults.length && searchDropdownOpen" class="ops-search-results ops-search-results-desktop">
@ -161,11 +164,14 @@
<!-- Global Flow Editor dialog (any page can open it via useFlowEditor) -->
<FlowEditorDialog v-if="can('manage_settings')" />
<!-- Palette de commandes globale (Cmd/Ctrl+K) : actions + pages + actions de page + clients/équipe -->
<CommandPalette v-if="can('view_clients')" />
</q-layout>
</template>
<script setup>
import { ref, computed, nextTick, watch, defineAsyncComponent } from 'vue'
import { ref, computed, nextTick, watch, defineAsyncComponent, onMounted, onBeforeUnmount } from 'vue'
import { useQuasar } from 'quasar'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from 'src/stores/auth'
@ -189,6 +195,8 @@ const FlowEditorDialog = defineAsyncComponent(() => import('src/components/flow-
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'))
const CommandPalette = defineAsyncComponent(() => import('src/components/shared/CommandPalette.vue'))
import { useCommandPalette } from 'src/composables/useCommandPalette'
const icons = { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, Mail, CalendarRange, CalendarClock, Sparkles, MapPinned, Workflow, RefreshCw, ReceiptText, MessagesSquare, History, Award, Star, ClipboardCheck, HardHat, PartyPopper }
@ -205,6 +213,14 @@ const orchestratorOpen = ref(false)
const serviceStatusOpen = ref(false)
function openNew (channel) { newDialogChannel.value = channel; newDialogOpen.value = true }
// Palette de commandes globale (Cmd/Ctrl+K) escape hatch pour tout ce qu'on déclutter
const { togglePalette, openPalette } = useCommandPalette()
function onGlobalKey (e) {
if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) { e.preventDefault(); togglePalette() }
}
onMounted(() => window.addEventListener('keydown', onGlobalKey))
onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKey))
const auth = useAuthStore()
const { can, isLoaded, userName } = usePermissions()
@ -357,6 +373,10 @@ function onSearchBlur () {
</script>
<style>
/* Indice ⌘K dans la barre de recherche desktop → ouvre la palette de commandes */
.ops-kbd-hint { font-size: 0.68rem; font-weight: 600; color: var(--ops-text-muted, #94a3b8); border: 1px solid var(--ops-border, #e2e8f0); border-radius: 6px; padding: 1px 6px; line-height: 1.4; white-space: nowrap; }
.ops-kbd-hint:hover { color: var(--ops-text, #334155); border-color: var(--ops-text-muted, #94a3b8); }
/* Menu en sous-menus (sidebar foncée) : entêtes de section + sous-items lisibles sur fond sombre */
.ops-sidebar .ops-sec-header { min-height: 38px; }
.ops-sidebar .ops-sec-header .q-item__label { color: rgba(255,255,255,.55); font-size: .72rem; letter-spacing: .03em; text-transform: uppercase; font-weight: 700; }

View File

@ -47,8 +47,10 @@
</div>
</div>
<!-- Admin controls -->
<div class="row q-col-gutter-md q-mb-lg">
<!-- Administration : contrôles secondaires (planificateur, facturation) repliés par défaut la vue « coup d'œil » (KPI + charge + activité) reste épurée. -->
<DisclosureSection title="Administration" icon="admin_panel_settings" persist-key="dash.admin" class="q-mb-lg">
<template #summary>Planificateur {{ schedulerEnabled ? 'actif' : 'désactivé' }}<template v-if="draftInvoiceCount"> · {{ draftInvoiceCount }} brouillon(s)</template></template>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<div class="ops-card">
<div class="text-subtitle1 text-weight-bold q-mb-sm">Planificateur</div>
@ -110,16 +112,12 @@
>
<q-tooltip>Soumet les {{ draftInvoiceCount }} facture(s) en brouillon</q-tooltip>
</q-btn>
<q-btn
label="PPA"
color="positive"
icon="credit_score"
:loading="runningPPA"
dense no-caps
@click="confirmPPA"
>
<q-tooltip>Prélève les cartes enregistrées (confirmation requise)</q-tooltip>
</q-btn>
<q-space />
<!-- Action sensible (le PPA prélève immédiatement cf. incident double-charge) : reléguée dans un
pour éviter le clic accidentel, sans la retirer. -->
<OverflowMenu tooltip="Actions sensibles" :items="[
{ icon: 'credit_score', label: runningPPA ? 'PPA en cours…' : 'PPA — prélever les cartes', caption: 'Prélèvement immédiat · confirmation requise', color: 'positive', disabled: runningPPA, action: confirmPPA },
]" />
</div>
<div class="q-mt-xs q-gutter-xs" style="min-height:20px">
<span v-if="billingResult" class="text-caption" :class="billingResult.ok ? 'text-positive' : 'text-negative'">
@ -135,6 +133,7 @@
</div>
</div>
</div>
</DisclosureSection>
<!-- Recent activity -->
<div class="row q-col-gutter-md">
@ -199,8 +198,9 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, onMounted, onUnmounted } from 'vue'
import { useQuasar } from 'quasar'
import { useRouter } from 'vue-router'
import { listDocs, countDocs } from 'src/api/erp'
import { authFetch } from 'src/api/auth'
import { getCapacity } from 'src/api/roster'
@ -209,9 +209,13 @@ import { HUB_SSE_URL } from 'src/config/dispatch'
import { startOfWeek, localDateStr } from 'src/composables/useHelpers'
import OutageAlertsPanel from 'src/components/shared/OutageAlertsPanel.vue'
import OccupancyStrip from 'src/components/shared/OccupancyStrip.vue'
import DisclosureSection from 'src/components/shared/DisclosureSection.vue'
import OverflowMenu from 'src/components/shared/OverflowMenu.vue'
import { useCommandPalette } from 'src/composables/useCommandPalette'
import { DEPARTMENTS } from 'src/config/departments'
const $q = useQuasar()
const router = useRouter()
const stats = ref([
{ label: 'Abonnés', value: '...', color: 'var(--ops-accent)', icon: 'people', to: '/clients', sub: '', subClass: '' },
@ -450,6 +454,16 @@ async function runPPA () {
runningPPA.value = false
}
// Actions d'administration joignables via la palette (K), même repliées dans « Administration ».
const { registerActions, unregisterActions } = useCommandPalette()
registerActions('dashboard', [
{ id: 'sched-toggle', label: 'Basculer le planificateur', icon: 'schedule', group: 'Administration', keywords: 'scheduler activer desactiver', run: toggleScheduler },
{ id: 'billing-generate', label: 'Facturation — Générer', icon: 'receipt_long', group: 'Administration', keywords: 'facture recurrente billing generer', run: runBilling },
{ id: 'billing-submit', label: 'Facturation — Soumettre les brouillons', icon: 'publish', group: 'Administration', keywords: 'soumettre draft facture', run: submitDrafts },
{ id: 'billing-ppa', label: 'Facturation — Lancer le PPA', icon: 'credit_score', group: 'Administration', keywords: 'ppa prelevement carte', run: confirmPPA },
])
onUnmounted(() => unregisterActions('dashboard'))
onMounted(async () => {
fetchSchedulerStatus()
refreshDraftCount()

View File

@ -44,8 +44,6 @@
<q-btn dense flat icon="keyboard_double_arrow_right" @click="navWeek(1)"><q-tooltip>Semaine suivante</q-tooltip></q-btn>
</q-btn-group>
<q-input dense outlined type="date" v-model="boardDate" style="width:150px"><q-tooltip>{{ boardView === 'kanban' ? 'Jour affiché (mode Jour)' : 'Début de la fenêtre (mode Semaine)' }}</q-tooltip></q-input>
<q-btn dense flat round icon="undo" :disable="!history.length" @click="undo"><q-tooltip>Annuler (Ctrl+Z)</q-tooltip></q-btn>
<q-btn dense flat round icon="redo" :disable="!future.length" @click="redo"><q-tooltip>Rétablir (Ctrl+Shift+Z)</q-tooltip></q-btn>
<!-- #2 Journal des changements + état auto-save (plus d'alerte « non publié ») -->
<q-btn dense flat round :icon="autosaving ? 'cloud_sync' : 'history'" :color="autosaving ? 'primary' : ''"><q-tooltip>Journal des changements · {{ autosaving ? 'sauvegarde en cours…' : 'auto-sauvegardé' }}</q-tooltip>
<q-menu anchor="bottom middle" self="top middle"><q-list dense style="min-width:290px;max-width:380px">
@ -148,7 +146,12 @@
</q-item>
</q-list>
</q-btn-dropdown>
<q-btn flat dense round icon="refresh" :loading="loading" @click="() => guard(loadWeek)" />
<!-- Utilitaires secondaires regroupés dans un (annuler/rétablir gardent leurs raccourcis Ctrl+Z / Ctrl+Shift+Z) -->
<OverflowMenu :items="[
{ icon: 'undo', label: 'Annuler', caption: 'Ctrl+Z', disabled: !history.length, action: undo },
{ icon: 'redo', label: 'Rétablir', caption: 'Ctrl+Shift+Z', disabled: !future.length, action: redo },
{ icon: 'refresh', label: 'Rafraîchir', action: () => guard(loadWeek) },
]" />
</div>
<!-- Rangée 2 : filtres + légende en popover DESKTOP/tablette uniquement (gt-sm) -->
@ -439,6 +442,11 @@
<th class="tech-col"><div class="row items-center no-wrap">Ressource<q-space /><q-btn v-if="hiddenCount" flat dense round size="xs" :icon="showHidden ? 'visibility' : 'visibility_off'" :color="showHidden ? 'primary' : 'grey-6'" @click="showHidden = !showHidden"><q-badge floating color="grey-7" style="top:-7px;right:6px">{{ hiddenCount }}</q-badge><q-tooltip>{{ showHidden ? 'Cacher les ressources masquées' : (hiddenCount + ' masquée(s) — afficher en grisé') }}</q-tooltip></q-btn></div></th>
<th v-for="(d, di) in dayList" :key="d.iso" class="clk" :class="{ weekend: d.weekend, holiday: isHoliday(d.iso) }" @click="maybeSelectCol(di)">
<div class="dow">{{ d.dow }}</div><div class="dnum">{{ d.dnum }}</div>
<!-- Alerte « jobs non assignés » du jour ouvre la liste filtrée sur ce jour (remplace l'ouverture auto du panneau au chargement). -->
<div v-if="(unassignedByDay[d.iso] || []).length" class="day-unassigned" @click.stop="openDayInPanel(d.iso)">
<q-icon name="warning" size="12px" /> {{ (unassignedByDay[d.iso] || []).length }} à assigner
<q-tooltip class="bg-grey-9">{{ (unassignedByDay[d.iso] || []).length }} job(s) non assigné(s) ce jour cliquer pour ouvrir la liste (recherche · ville · compétence)</q-tooltip>
</div>
<div v-if="visStat(d.iso).hours || estLoad(d.iso)" class="cap-strip" @click.stop>
<span class="hdr-vbar"><span class="hdr-vfill" :style="{ height: hdrFillH(d.iso) + '%', background: heatColor(hdrRatio(d.iso)) }"></span></span>
<span class="cap-seg load-cell" :class="loadClass(d.iso)">{{ estLoad(d.iso) }}/{{ visStat(d.iso).hours }}h
@ -1966,6 +1974,8 @@ import TicketStatusControl from 'src/components/shared/TicketStatusControl.vue'
import TagEditor from 'src/components/shared/TagEditor.vue' // module de tags/compétences PARTAGÉ (chips colorées, création + palette, niveaux) SOURCE UNIQUE : jobs ET techs
import RouteMap from 'src/components/shared/RouteMap.vue' // carte de tournées réutilisable (revue dispatch auto + onglet Tournées) : OSRM réel, arrêts numérotés, fitTo
import OccupancyStrip from 'src/components/shared/OccupancyStrip.vue' // bande d'occupation réutilisable (sélecteur de jour Tournées + tableau de bord)
import OverflowMenu from 'src/components/shared/OverflowMenu.vue' // « » standardisé pour les actions secondaires (désencombrer la barre)
import { useCommandPalette } from 'src/composables/useCommandPalette' // registre d'actions contextuelles joignables via K même si retirées de la barre
import UnifiedCreateModal from 'src/components/shared/UnifiedCreateModal.vue' // création de job NATIVE OPS (work-order) Dispatch déprécié, tout ici
import SuggestSlotsDialog from 'src/modules/dispatch/components/SuggestSlotsDialog.vue' // « Trouver un créneau » pré-remplit la création (tech+date+heure+adresse)
import QuoteWizard from 'src/components/shared/QuoteWizard.vue' // soumission : adresse+fibre+prospect (lead-first)
@ -6153,8 +6163,20 @@ function onLegacyUpdate (data) {
}
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 */ })
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 */ } try { await reloadPool() } catch (e) { /* */ } /* charge le pool (compteurs badge + « N à assigner » par jour) SANS ouvrir le panneau flottant — il s'ouvre à la demande : bouton « À assigner » ou clic sur l'alerte d'un jour */ 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() })
// Actions principales de la Planif joignables via la palette (K), même reléguées dans un menu.
const { registerActions, unregisterActions } = useCommandPalette()
registerActions('planif', [
{ id: 'planif-publish', label: 'Publier lhoraire', icon: 'cloud_upload', group: 'Planification', keywords: 'publier horaire publish', run: doPublish },
{ id: 'planif-suggest', label: 'Suggérer (répartition auto)', icon: 'auto_awesome', group: 'Planification', keywords: 'suggerer repartition auto solveur', run: openSuggest },
{ id: 'planif-generate', label: 'Générer lhoraire (semaine)', icon: 'event_note', group: 'Planification', keywords: 'generer horaire quarts shift', run: doGenerate },
{ id: 'planif-create', label: 'Créer une tâche / intervention', icon: 'add', group: 'Planification', keywords: 'creer job tache intervention soumission', run: () => { createChooser.value = true } },
{ id: 'planif-assign', label: 'Jobs à assigner', icon: 'assignment_ind', group: 'Planification', keywords: 'assigner pool repartir', run: openAssignPanel },
{ id: 'planif-leave', label: 'Congés / absences', icon: 'beach_access', group: 'Planification', keywords: 'conges absences vacances', run: () => { showLeave.value = true } },
])
onUnmounted(() => unregisterActions('planif'))
onBeforeRouteLeave(async () => { await autosaveDraft() }) // #2 flush le brouillon (attendu) en partant ; plus jamais d'« abandonner ? »
</script>
@ -6178,6 +6200,9 @@ onBeforeRouteLeave(async () => { await autosaveDraft() }) // #2 — flush le bro
.loc-results { max-height: 190px; overflow: auto; border-radius: 6px; }
.loc-pick-link { cursor: pointer; text-decoration: underline; font-weight: 600; }
/* Bande de capacité restante par jour (AM/PM/Soir) dans l'en-tête */
/* Alerte cliquable « N à assigner » dans l'en-tête de jour (grille) — ouvre la liste du jour */
.day-unassigned { display: inline-flex; align-items: center; gap: 3px; margin: 2px auto 0; padding: 1px 7px; font-size: 10px; font-weight: 700; color: #b45309; background: #fef3c7; border: 1px solid #fde68a; border-radius: 20px; cursor: pointer; line-height: 1.5; }
.day-unassigned:hover { background: #fde68a; }
.cap-strip { display: flex; gap: 3px; justify-content: center; align-items: center; margin-top: 2px; }
/* Barre verticale d'occupation (miroir de la bande mobile) dans l'en-tête desktop */
.hdr-vbar { width: 7px; height: 22px; background: #eef2f7; border-radius: 4px; overflow: hidden; display: inline-flex; align-items: flex-end; flex: 0 0 auto; }

View File

@ -1,8 +1,8 @@
<template>
<q-page padding>
<div class="text-h6 text-weight-bold q-mb-md">Rapports</div>
<PageHeader title="Rapports" :count="financeReports.length" />
<!-- Comptabilité section -->
<!-- Comptabilité section (rapports opérationnels) -->
<div class="text-subtitle2 text-grey-7 text-weight-bold q-mb-sm">Comptabilité & Finances</div>
<div class="row q-col-gutter-md q-mb-lg">
<div class="col-12 col-md-6 col-lg-4" v-for="report in financeReports" :key="report.title">
@ -16,24 +16,34 @@
</div>
</div>
<!-- Opérations section -->
<div class="text-subtitle2 text-grey-7 text-weight-bold q-mb-sm">Opérations</div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6 col-lg-4" v-for="report in opsReports" :key="report.title">
<div class="ops-card cursor-pointer" style="min-height:120px;opacity:0.6" @click="report.action">
<div class="row items-center q-mb-sm">
<q-icon :name="report.icon" size="28px" :color="report.color" class="q-mr-sm" />
<div class="text-subtitle1 text-weight-bold">{{ report.title }}</div>
<q-badge class="q-ml-sm" color="grey-4" text-color="grey-7" label="Bientôt" />
<!-- Opérations : rapports pas encore prêts repliés par défaut (ne polluent pas la vue). -->
<DisclosureSection title="Opérations" icon="insights" persist-key="rapports.ops">
<template #summary>{{ opsReports.length }} rapports bientôt disponibles</template>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6 col-lg-4" v-for="report in opsReports" :key="report.title">
<div class="ops-card cursor-pointer" style="min-height:120px;opacity:0.6" @click="report.action">
<div class="row items-center q-mb-sm">
<q-icon :name="report.icon" size="28px" :color="report.color" class="q-mr-sm" />
<div class="text-subtitle1 text-weight-bold">{{ report.title }}</div>
<q-badge class="q-ml-sm" color="grey-4" text-color="grey-7" label="Bientôt" />
</div>
<div class="text-caption text-grey-6">{{ report.description }}</div>
</div>
<div class="text-caption text-grey-6">{{ report.description }}</div>
</div>
</div>
</div>
</DisclosureSection>
</q-page>
</template>
<script setup>
import { onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import PageHeader from 'src/components/shared/PageHeader.vue'
import DisclosureSection from 'src/components/shared/DisclosureSection.vue'
import { useCommandPalette } from 'src/composables/useCommandPalette'
const router = useRouter()
const financeReports = [
{
title: 'Explorateur de revenus',
@ -130,4 +140,18 @@ const opsReports = [
action: () => {},
},
]
// Chaque rapport devient joignable par la palette (K) l'index visuel reste épuré,
// mais rien n'est « caché » : taper le nom d'un rapport l'ouvre directement.
const { registerActions, unregisterActions } = useCommandPalette()
onMounted(() => registerActions('rapports', financeReports.map(r => ({
id: 'rapport-' + r.route,
label: r.title,
caption: 'Rapport',
icon: r.icon,
group: 'Rapports',
keywords: r.description,
run: () => router.push(r.route),
}))))
onUnmounted(() => unregisterActions('rapports'))
</script>