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:
parent
f4a6d07a8b
commit
893ac8cd4a
212
apps/ops/src/components/shared/CommandPalette.vue
Normal file
212
apps/ops/src/components/shared/CommandPalette.vue
Normal 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>
|
||||||
71
apps/ops/src/components/shared/DisclosureSection.vue
Normal file
71
apps/ops/src/components/shared/DisclosureSection.vue
Normal 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>
|
||||||
79
apps/ops/src/components/shared/OverflowMenu.vue
Normal file
79
apps/ops/src/components/shared/OverflowMenu.vue
Normal 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 d’actions' },
|
||||||
|
ariaLabel: { type: String, default: 'Plus d’actions' },
|
||||||
|
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>
|
||||||
52
apps/ops/src/composables/useCommandPalette.js
Normal file
52
apps/ops/src/composables/useCommandPalette.js
Normal 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 l’horaire', 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -98,8 +98,11 @@
|
||||||
@keydown.down.prevent="moveHighlight(1)" @keydown.up.prevent="moveHighlight(-1)"
|
@keydown.down.prevent="moveHighlight(1)" @keydown.up.prevent="moveHighlight(-1)"
|
||||||
@update:model-value="onSearchInput" @blur="onSearchBlur">
|
@update:model-value="onSearchInput" @blur="onSearchBlur">
|
||||||
<template #prepend><q-icon name="search" color="grey-6" /></template>
|
<template #prepend><q-icon name="search" color="grey-6" /></template>
|
||||||
<template #append v-if="searchQuery">
|
<template #append>
|
||||||
<q-icon name="close" class="cursor-pointer" @click="clearSearch" />
|
<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>
|
</template>
|
||||||
</q-input>
|
</q-input>
|
||||||
<div v-if="searchResults.length && searchDropdownOpen" class="ops-search-results ops-search-results-desktop">
|
<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) -->
|
<!-- Global Flow Editor dialog (any page can open it via useFlowEditor) -->
|
||||||
<FlowEditorDialog v-if="can('manage_settings')" />
|
<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>
|
</q-layout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<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 { useQuasar } from 'quasar'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from 'src/stores/auth'
|
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 OrchestratorDialog = defineAsyncComponent(() => import('src/components/shared/OrchestratorDialog.vue'))
|
||||||
const ServiceStatusDialog = defineAsyncComponent(() => import('src/components/shared/ServiceStatusDialog.vue'))
|
const ServiceStatusDialog = defineAsyncComponent(() => import('src/components/shared/ServiceStatusDialog.vue'))
|
||||||
const OutboxPanel = defineAsyncComponent(() => import('src/components/shared/OutboxPanel.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 }
|
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)
|
const serviceStatusOpen = ref(false)
|
||||||
function openNew (channel) { newDialogChannel.value = channel; newDialogOpen.value = true }
|
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 auth = useAuthStore()
|
||||||
const { can, isLoaded, userName } = usePermissions()
|
const { can, isLoaded, userName } = usePermissions()
|
||||||
|
|
||||||
|
|
@ -357,6 +373,10 @@ function onSearchBlur () {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<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 */
|
/* 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 { 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; }
|
.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; }
|
||||||
|
|
|
||||||
|
|
@ -47,8 +47,10 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Admin controls -->
|
<!-- Administration : contrôles secondaires (planificateur, facturation) repliés par défaut → la vue « coup d'œil » (KPI + charge + activité) reste épurée. -->
|
||||||
<div class="row q-col-gutter-md q-mb-lg">
|
<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="col-12 col-md-6">
|
||||||
<div class="ops-card">
|
<div class="ops-card">
|
||||||
<div class="text-subtitle1 text-weight-bold q-mb-sm">Planificateur</div>
|
<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-tooltip>Soumet les {{ draftInvoiceCount }} facture(s) en brouillon</q-tooltip>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
<q-btn
|
<q-space />
|
||||||
label="PPA"
|
<!-- Action sensible (le PPA prélève immédiatement — cf. incident double-charge) : reléguée dans un ⋮
|
||||||
color="positive"
|
pour éviter le clic accidentel, sans la retirer. -->
|
||||||
icon="credit_score"
|
<OverflowMenu tooltip="Actions sensibles" :items="[
|
||||||
:loading="runningPPA"
|
{ 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 },
|
||||||
dense no-caps
|
]" />
|
||||||
@click="confirmPPA"
|
|
||||||
>
|
|
||||||
<q-tooltip>Prélève les cartes enregistrées (confirmation requise)</q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="q-mt-xs q-gutter-xs" style="min-height:20px">
|
<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'">
|
<span v-if="billingResult" class="text-caption" :class="billingResult.ok ? 'text-positive' : 'text-negative'">
|
||||||
|
|
@ -135,6 +133,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</DisclosureSection>
|
||||||
|
|
||||||
<!-- Recent activity -->
|
<!-- Recent activity -->
|
||||||
<div class="row q-col-gutter-md">
|
<div class="row q-col-gutter-md">
|
||||||
|
|
@ -199,8 +198,9 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
import { useQuasar } from 'quasar'
|
import { useQuasar } from 'quasar'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import { listDocs, countDocs } from 'src/api/erp'
|
import { listDocs, countDocs } from 'src/api/erp'
|
||||||
import { authFetch } from 'src/api/auth'
|
import { authFetch } from 'src/api/auth'
|
||||||
import { getCapacity } from 'src/api/roster'
|
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 { startOfWeek, localDateStr } from 'src/composables/useHelpers'
|
||||||
import OutageAlertsPanel from 'src/components/shared/OutageAlertsPanel.vue'
|
import OutageAlertsPanel from 'src/components/shared/OutageAlertsPanel.vue'
|
||||||
import OccupancyStrip from 'src/components/shared/OccupancyStrip.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'
|
import { DEPARTMENTS } from 'src/config/departments'
|
||||||
|
|
||||||
const $q = useQuasar()
|
const $q = useQuasar()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const stats = ref([
|
const stats = ref([
|
||||||
{ label: 'Abonnés', value: '...', color: 'var(--ops-accent)', icon: 'people', to: '/clients', sub: '', subClass: '' },
|
{ label: 'Abonnés', value: '...', color: 'var(--ops-accent)', icon: 'people', to: '/clients', sub: '', subClass: '' },
|
||||||
|
|
@ -450,6 +454,16 @@ async function runPPA () {
|
||||||
runningPPA.value = false
|
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 () => {
|
onMounted(async () => {
|
||||||
fetchSchedulerStatus()
|
fetchSchedulerStatus()
|
||||||
refreshDraftCount()
|
refreshDraftCount()
|
||||||
|
|
|
||||||
|
|
@ -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 dense flat icon="keyboard_double_arrow_right" @click="navWeek(1)"><q-tooltip>Semaine suivante</q-tooltip></q-btn>
|
||||||
</q-btn-group>
|
</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-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é ») -->
|
<!-- #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-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">
|
<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-item>
|
||||||
</q-list>
|
</q-list>
|
||||||
</q-btn-dropdown>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Rangée 2 : filtres + légende en popover — DESKTOP/tablette uniquement (gt-sm) -->
|
<!-- 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 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)">
|
<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>
|
<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>
|
<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="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
|
<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 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 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 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 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 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)
|
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 } })
|
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() })
|
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 l’horaire', 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 l’horaire (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 ? »
|
onBeforeRouteLeave(async () => { await autosaveDraft() }) // #2 — flush le brouillon (attendu) en partant ; plus jamais d'« abandonner ? »
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -6178,6 +6200,9 @@ onBeforeRouteLeave(async () => { await autosaveDraft() }) // #2 — flush le bro
|
||||||
.loc-results { max-height: 190px; overflow: auto; border-radius: 6px; }
|
.loc-results { max-height: 190px; overflow: auto; border-radius: 6px; }
|
||||||
.loc-pick-link { cursor: pointer; text-decoration: underline; font-weight: 600; }
|
.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 */
|
/* 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; }
|
.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 */
|
/* 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; }
|
.hdr-vbar { width: 7px; height: 22px; background: #eef2f7; border-radius: 4px; overflow: hidden; display: inline-flex; align-items: flex-end; flex: 0 0 auto; }
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
<template>
|
<template>
|
||||||
<q-page padding>
|
<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="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="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">
|
<div class="col-12 col-md-6 col-lg-4" v-for="report in financeReports" :key="report.title">
|
||||||
|
|
@ -16,24 +16,34 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Opérations section -->
|
<!-- Opérations : rapports pas encore prêts → repliés par défaut (ne polluent pas la vue). -->
|
||||||
<div class="text-subtitle2 text-grey-7 text-weight-bold q-mb-sm">Opérations</div>
|
<DisclosureSection title="Opérations" icon="insights" persist-key="rapports.ops">
|
||||||
<div class="row q-col-gutter-md">
|
<template #summary>{{ opsReports.length }} rapports — bientôt disponibles</template>
|
||||||
<div class="col-12 col-md-6 col-lg-4" v-for="report in opsReports" :key="report.title">
|
<div class="row q-col-gutter-md">
|
||||||
<div class="ops-card cursor-pointer" style="min-height:120px;opacity:0.6" @click="report.action">
|
<div class="col-12 col-md-6 col-lg-4" v-for="report in opsReports" :key="report.title">
|
||||||
<div class="row items-center q-mb-sm">
|
<div class="ops-card cursor-pointer" style="min-height:120px;opacity:0.6" @click="report.action">
|
||||||
<q-icon :name="report.icon" size="28px" :color="report.color" class="q-mr-sm" />
|
<div class="row items-center q-mb-sm">
|
||||||
<div class="text-subtitle1 text-weight-bold">{{ report.title }}</div>
|
<q-icon :name="report.icon" size="28px" :color="report.color" class="q-mr-sm" />
|
||||||
<q-badge class="q-ml-sm" color="grey-4" text-color="grey-7" label="Bientôt" />
|
<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>
|
||||||
<div class="text-caption text-grey-6">{{ report.description }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DisclosureSection>
|
||||||
</q-page>
|
</q-page>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<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 = [
|
const financeReports = [
|
||||||
{
|
{
|
||||||
title: 'Explorateur de revenus',
|
title: 'Explorateur de revenus',
|
||||||
|
|
@ -130,4 +140,18 @@ const opsReports = [
|
||||||
action: () => {},
|
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>
|
</script>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user