gigafibre-fsm/apps/ops/src/pages/DashboardPage.vue
louispaulb 5491aa8abe feat(dashboard/planif): vue 2 semaines + deep-link jour + slot-finder « Planifier des quarts »
- Tableau de bord : 2 bandes d'occupation (« Cette semaine » / « Semaine prochaine », 14 j depuis lundi), jour courant marqué (liseré indigo dans OccupancyStrip). Clic jour → /planification?day=ISO
- Planif : deep-link `?day=` (cadre la semaine + sélectionne le jour grille/mobile/tournées) & `?skill=` (filtre compétence, match casse-insensible vs allSkills) via focusDay() lu au montage
- Slot-finder (SuggestSlotsDialog) : état « Aucun créneau » → bouton « Planifier des quarts pour « X » » → émet plan-shifts → Planif ouvre la timeline (vue grille) au jour cherché, filtrée sur la compétence, + toast « crée un quart pour ouvrir des créneaux »

Vérifié préview bout-en-bout : 2×7 jours + today, clic → ?day=2026-07-09 cadre 06→12/07, recherche vide → bouton → dialog fermé + grille + toast compétence. Build vert + leak-check 0. Aucun changement hub.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 08:34:43 -04:00

520 lines
22 KiB
Vue

<template>
<q-page padding>
<!-- Real-time outage alerts -->
<OutageAlertsPanel class="q-mb-md" @open-ticket="id => $router.push('/tickets')" />
<!-- KPI cards (cliquables → page liée · sous-ligne = tendance/contexte) -->
<div class="row q-col-gutter-md q-mb-lg">
<div class="col-6 col-md" v-for="stat in stats" :key="stat.label">
<div class="ops-card ops-stat" :class="{ 'ops-stat-clk': stat.to }" @click="stat.to && $router.push(stat.to)">
<div class="row items-center no-wrap q-gutter-x-sm">
<q-icon :name="stat.icon" :style="{ color: stat.color }" size="22px" />
<div class="col">
<div class="ops-stat-value" :style="{ color: stat.color }">{{ stat.value }}</div>
<div class="ops-stat-label">{{ stat.label }}</div>
<div v-if="stat.sub" class="ops-stat-sub" :class="stat.subClass">{{ stat.sub }}</div>
</div>
<q-icon v-if="stat.to" name="chevron_right" size="18px" class="ops-stat-go" />
</div>
</div>
</div>
</div>
<!-- Charge 2 semaines : 2 bandes d'occupation (courante + prochaine), jour courant marqué (liseré indigo). Clic jour → Planification (jour ciblé). -->
<div class="ops-card q-mb-lg">
<div class="row items-center q-mb-sm">
<div class="text-subtitle1 text-weight-bold">Charge <span class="text-caption text-grey-6 q-ml-xs">2 semaines</span></div>
<q-space />
<q-btn flat dense no-caps size="sm" color="primary" icon-right="chevron_right" label="Planification" @click="$router.push('/planification')" />
</div>
<template v-if="weekLoad.length">
<div class="dash-wk-lbl">Cette semaine</div>
<OccupancyStrip :days="weekLoad.slice(0, 7)" :selected="todayIso" @select="iso => $router.push({ path: '/planification', query: { day: iso } })" />
<div class="dash-wk-lbl q-mt-sm">Semaine prochaine</div>
<OccupancyStrip :days="weekLoad.slice(7, 14)" :selected="todayIso" @select="iso => $router.push({ path: '/planification', query: { day: iso } })" />
</template>
<div v-else class="text-caption text-grey-6 q-py-sm">Chargement de la charge…</div>
<div class="text-caption text-grey-6 q-mt-sm row items-center q-gutter-x-sm">
<span><span class="os-leg-dot" style="background:#22c55e"></span>libre</span>
<span><span class="os-leg-dot" style="background:#f59e0b"></span>chargé</span>
<span><span class="os-leg-dot" style="background:#ef4444"></span>saturé</span>
<span v-if="weekUnplaced" class="text-warning">· <b>{{ weekUnplaced }}h</b> à répartir</span>
</div>
</div>
<!-- Admin controls -->
<div class="row q-col-gutter-md q-mb-lg">
<div class="col-12 col-md-6">
<div class="ops-card">
<div class="text-subtitle1 text-weight-bold q-mb-sm">Planificateur</div>
<div class="row items-center q-gutter-md">
<q-chip :color="schedulerEnabled ? 'positive' : 'negative'" text-color="white" icon="schedule">
{{ schedulerEnabled ? 'Actif' : 'Désactivé' }}
</q-chip>
<q-btn
:label="schedulerEnabled ? 'Désactiver' : 'Activer'"
:color="schedulerEnabled ? 'negative' : 'positive'"
:icon="schedulerEnabled ? 'pause' : 'play_arrow'"
:loading="togglingScheduler"
dense no-caps
@click="toggleScheduler"
/>
</div>
</div>
</div>
<div class="col-12 col-md-6">
<div class="ops-card">
<div class="text-subtitle1 text-weight-bold q-mb-sm">Facturation récurrente</div>
<!-- Step indicators -->
<div class="row items-center q-gutter-x-sm q-mb-sm">
<q-chip :color="billingStep >= 1 ? 'primary' : 'grey-4'" text-color="white" dense size="sm" icon="receipt_long">1. Générer</q-chip>
<q-icon name="chevron_right" size="16px" color="grey-5" />
<q-chip :color="billingStep >= 2 ? 'amber-8' : 'grey-4'" text-color="white" dense size="sm" icon="publish">2. Soumettre</q-chip>
<q-icon name="chevron_right" size="16px" color="grey-5" />
<q-chip :color="billingStep >= 3 ? 'teal-7' : 'grey-4'" text-color="white" dense size="sm" icon="credit_score">3. PPA</q-chip>
</div>
<!-- Draft count indicator -->
<div v-if="draftInvoiceCount > 0" class="q-mb-sm">
<q-banner dense rounded class="bg-amber-1 text-warning" style="font-size:0.8rem">
<template #avatar><q-icon name="pending_actions" color="warning" /></template>
{{ draftInvoiceCount }} facture(s) en brouillon à soumettre
</q-banner>
</div>
<div class="row items-center q-gutter-sm">
<q-btn
label="Générer"
color="primary"
icon="receipt_long"
:loading="runningBilling"
dense no-caps
@click="runBilling"
>
<q-tooltip>Génère les factures récurrentes pour les abonnements actifs</q-tooltip>
</q-btn>
<q-btn
label="Soumettre"
color="warning"
icon="publish"
:loading="submittingDrafts"
:disable="draftInvoiceCount === 0"
dense no-caps
@click="submitDrafts"
>
<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>
</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'">
{{ billingResult.message }}
</span>
<span v-if="submitResult" class="text-caption" :class="submitResult.ok ? 'text-positive' : 'text-negative'">
{{ submitResult.message }}
</span>
<span v-if="ppaResult" class="text-caption" :class="ppaResult.ok ? 'text-positive' : 'text-negative'">
{{ ppaResult.message }}
</span>
</div>
</div>
</div>
</div>
<!-- Recent activity -->
<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">Tickets ouverts</div>
<q-list separator>
<q-item v-for="t in openTickets" :key="t.name" clickable @click="$router.push('/tickets')">
<q-item-section avatar style="min-width:32px">
<q-icon name="confirmation_number" size="20px"
:color="t.priority === 'Urgent' ? 'red' : t.priority === 'High' ? 'orange-8' : 'grey-5'" />
</q-item-section>
<q-item-section>
<q-item-label>{{ t.subject }}</q-item-label>
<q-item-label caption>{{ t.customer_name || t.customer }}</q-item-label>
</q-item-section>
<q-item-section side>
<span class="ops-badge open">{{ t.priority }}</span>
</q-item-section>
</q-item>
<q-item v-if="!openTickets.length">
<q-item-section>
<q-item-label caption>Aucun ticket ouvert</q-item-label>
</q-item-section>
</q-item>
</q-list>
</div>
</div>
<div class="col-12 col-md-6">
<div class="ops-card">
<div class="text-subtitle1 text-weight-bold q-mb-sm">
Dispatch aujourd'hui
<q-badge v-if="todayJobs.length" :label="todayJobs.length" color="primary" class="q-ml-sm" />
</div>
<q-list separator>
<q-item v-for="j in todayJobs" :key="j.name" clickable @click="$router.push('/planification')">
<q-item-section avatar style="min-width:32px">
<q-icon :name="j.status === 'completed' ? 'check_circle' : j.assigned_tech ? 'person' : 'radio_button_unchecked'"
:color="j.status === 'completed' ? 'green-6' : j.assigned_tech ? 'blue-6' : 'grey-5'" size="20px" />
</q-item-section>
<q-item-section>
<q-item-label>{{ j.subject || j.name }}</q-item-label>
<q-item-label caption>
<span v-if="j.assigned_tech">{{ j.assigned_tech }}</span>
<span v-if="j.customer"> &middot; {{ j.customer }}</span>
</q-item-label>
</q-item-section>
<q-item-section side>
<span class="ops-badge" :class="j.status === 'completed' ? 'active' : j.status === 'assigned' ? 'draft' : 'closed'">{{ j.status }}</span>
</q-item-section>
</q-item>
<q-item v-if="!todayJobs.length">
<q-item-section>
<q-item-label caption>Aucune tâche planifiée aujourd'hui</q-item-label>
</q-item-section>
</q-item>
</q-list>
</div>
</div>
</div>
</q-page>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useQuasar } from 'quasar'
import { listDocs, countDocs } from 'src/api/erp'
import { authFetch } from 'src/api/auth'
import { getCapacity } from 'src/api/roster'
import { BASE_URL } from 'src/config/erpnext'
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'
const $q = useQuasar()
const stats = ref([
{ label: 'Abonnés', value: '...', color: 'var(--ops-accent)', icon: 'people', to: '/clients', sub: '', subClass: '' },
{ label: 'Clients', value: '...', color: 'var(--ops-primary)', icon: 'business', to: '/clients', sub: '', subClass: '' },
{ label: 'Rev. mensuel', value: '...', color: 'var(--ops-success)', icon: 'attach_money', to: '/rapports/revenus', sub: 'récurrent', subClass: 'text-grey-6' },
{ label: 'Tickets ouverts', value: '...', color: 'var(--ops-warning)', icon: 'confirmation_number', to: '/tickets', sub: '', subClass: '' },
{ label: 'Dispatch aujourd\'hui', value: '...', color: 'var(--ops-accent)', icon: 'local_shipping', to: '/planification', sub: '', subClass: '' },
])
const openTickets = ref([])
const todayJobs = ref([])
// ── Charge de la semaine (bande d'occupation, réutilise /roster/capacity) ──
const weekLoad = ref([])
const weekUnplaced = ref(0)
const todayIso = localDateStr(new Date())
const FR_DOW = ['dim', 'lun', 'mar', 'mer', 'jeu', 'ven', 'sam']
async function loadWeek () {
try {
const mon = startOfWeek(new Date()) // lundi de la semaine courante → 2 semaines (courante + prochaine)
const { capacity } = await getCapacity(localDateStr(mon), 14)
const days = []; let unplaced = 0
for (let i = 0; i < 14; i++) {
const dt = new Date(mon); dt.setDate(mon.getDate() + i)
const iso = localDateStr(dt)
const c = capacity[iso] || {}
const h = +c.due_h || 0; const cap = +c.cap_h || 0
unplaced += +c.unplaced_h || 0
days.push({
iso, dow: FR_DOW[dt.getDay()], dnum: dt.getDate() + '/' + String(dt.getMonth() + 1).padStart(2, '0'),
h, cap, ratio: cap > 0 ? h / cap : (h > 0 ? 1 : 0), over: !!c.overbooked,
weekend: dt.getDay() === 0 || dt.getDay() === 6, noShift: cap === 0 && h > 0, today: iso === todayIso,
})
}
weekLoad.value = days
weekUnplaced.value = Math.round(unplaced)
} catch { /* strip masqué si le hub ne répond pas */ }
}
// Scheduler controls
const schedulerEnabled = ref(false)
const togglingScheduler = ref(false)
async function fetchSchedulerStatus () {
try {
const res = await authFetch(BASE_URL + '/api/method/scheduler_status')
if (res.ok) {
const data = await res.json()
schedulerEnabled.value = data.message?.status === 'enabled'
}
} catch {}
}
async function toggleScheduler () {
togglingScheduler.value = true
try {
const res = await authFetch(BASE_URL + '/api/method/toggle_scheduler', { method: 'POST' })
if (res.ok) {
const data = await res.json()
schedulerEnabled.value = data.message?.status === 'enabled'
}
} catch {}
togglingScheduler.value = false
}
// ── Billing pipeline: Generate → Submit → PPA ──────────────────
const billingStep = ref(0) // 0=idle, 1=generated, 2=submitted, 3=ppa done
const runningBilling = ref(false)
const billingResult = ref(null)
const submittingDrafts = ref(false)
const submitResult = ref(null)
const runningPPA = ref(false)
const ppaResult = ref(null)
const draftInvoiceCount = ref(0)
async function refreshDraftCount () {
try {
const res = await authFetch(BASE_URL + '/api/method/frappe.client.get_count?doctype=Sales+Invoice&filters=' +
encodeURIComponent(JSON.stringify({ docstatus: 0, is_return: 0 })))
if (res.ok) {
const data = await res.json()
draftInvoiceCount.value = data.message || 0
}
} catch {}
}
async function runBilling () {
runningBilling.value = true
billingResult.value = null
submitResult.value = null
ppaResult.value = null
billingStep.value = 0
try {
// Create a Process Subscription doc (submitting it enqueues the background job)
const today = new Date().toISOString().slice(0, 10)
const res = await authFetch(BASE_URL + '/api/resource/Process Subscription', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ posting_date: today }),
})
if (!res.ok) throw new Error('Erreur création: ' + res.status)
const doc = (await res.json()).data
// Submit it to trigger the background job (must pass full doc with modified timestamp)
const submitRes = await authFetch(BASE_URL + '/api/method/frappe.client.submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ doc: doc }),
})
if (!submitRes.ok) throw new Error('Erreur soumission: ' + submitRes.status)
// Poll background jobs until complete (max 60s)
billingResult.value = { ok: true, message: 'Génération en cours...' }
const startDrafts = draftInvoiceCount.value
let attempts = 0
while (attempts < 30) {
await new Promise(r => setTimeout(r, 2000))
await refreshDraftCount()
attempts++
// Check if background job finished by looking at job queue
const jobRes = await authFetch(BASE_URL + '/api/method/frappe.client.get_list?' + new URLSearchParams({
doctype: 'RQ Job',
filters: JSON.stringify({ job_name: ['like', '%process_all%'], status: ['in', ['queued', 'started']] }),
limit_page_length: 1,
}))
if (jobRes.ok) {
const jobData = await jobRes.json()
const jobs = jobData.data || jobData.message || []
if (!jobs.length) break // no more running jobs
} else {
break // can't check, assume done
}
}
await refreshDraftCount()
const newDrafts = draftInvoiceCount.value - startDrafts
billingResult.value = { ok: true, message: `Génération terminée. ${newDrafts > 0 ? newDrafts + ' nouvelle(s) facture(s)' : 'Aucune nouvelle facture'}.` }
billingStep.value = 1
} catch (e) {
billingResult.value = { ok: false, message: e.message }
}
runningBilling.value = false
}
async function submitDrafts () {
submittingDrafts.value = true
submitResult.value = null
try {
// Get all draft invoices (docstatus=0, not credit notes)
const draftsRes = await authFetch(BASE_URL + '/api/method/frappe.client.get_list?' + new URLSearchParams({
doctype: 'Sales Invoice',
filters: JSON.stringify({ docstatus: 0, is_return: 0 }),
fields: JSON.stringify(['name']),
limit_page_length: 500,
}))
if (!draftsRes.ok) throw new Error('Erreur lecture drafts: ' + draftsRes.status)
const drafts = (await draftsRes.json()).message || []
if (!drafts.length) {
submitResult.value = { ok: true, message: 'Aucune facture en brouillon.' }
submittingDrafts.value = false
return
}
// Submit each draft (must fetch full doc for modified timestamp)
let submitted = 0
let errors = 0
for (const d of drafts) {
try {
const docRes = await authFetch(BASE_URL + '/api/resource/Sales%20Invoice/' + encodeURIComponent(d.name))
if (!docRes.ok) { errors++; continue }
const fullDoc = (await docRes.json()).data
const r = await authFetch(BASE_URL + '/api/method/frappe.client.submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ doc: fullDoc }),
})
if (r.ok) submitted++
else errors++
} catch {
errors++
}
}
await refreshDraftCount()
submitResult.value = {
ok: errors === 0,
message: `${submitted} facture(s) soumise(s)${errors ? ', ' + errors + ' erreur(s)' : ''}.`,
}
billingStep.value = 2
} catch (e) {
submitResult.value = { ok: false, message: e.message }
}
submittingDrafts.value = false
}
// Garde-fou : le PPA prélève IMMÉDIATEMENT les cartes enregistrées. Un double lancement peut
// charger deux fois (cf. incident 2026-06) → confirmation explicite obligatoire avant d'exécuter.
function confirmPPA () {
$q.dialog({
title: 'Prélèvement automatique (PPA)',
message: 'Cette action prélève <b>immédiatement</b> les cartes enregistrées des clients éligibles. À n\'exécuter <b>qu\'une seule fois</b> par cycle — un double lancement peut charger deux fois. Continuer ?',
html: true,
ok: { label: 'Prélever maintenant', color: 'positive', noCaps: true, unelevated: true },
cancel: { label: 'Annuler', flat: true, noCaps: true },
persistent: true,
}).onOk(() => { runPPA() })
}
async function runPPA () {
runningPPA.value = true
ppaResult.value = null
try {
const res = await fetch(HUB_SSE_URL + '/payments/ppa-run', { method: 'POST' })
if (res.ok) {
const data = await res.json()
const charged = data.charged?.length || 0
const failed = data.failed?.length || 0
const skipped = data.skipped?.length || 0
ppaResult.value = {
ok: true,
message: `PPA: ${charged} prélevé(s), ${failed} échoué(s), ${skipped} ignoré(s)`,
}
billingStep.value = 3
} else {
const err = await res.json().catch(() => ({}))
ppaResult.value = { ok: false, message: err.error || 'Erreur ' + res.status }
}
} catch (e) {
ppaResult.value = { ok: false, message: e.message }
}
runningPPA.value = false
}
onMounted(async () => {
fetchSchedulerStatus()
refreshDraftCount()
loadWeek()
const today = new Date().toISOString().slice(0, 10)
const d7 = localDateStr(new Date(Date.now() - 7 * 86400000)) // il y a 7 jours (tendance tickets)
const [clients, tickets, todayDispatch, openTix, newTickets7d] = await Promise.all([
countDocs('Customer', { disabled: 0 }),
countDocs('Issue', { status: 'Open' }),
listDocs('Dispatch Job', {
filters: { scheduled_date: today },
fields: ['name', 'subject', 'status', 'assigned_tech', 'customer', 'priority', 'source_issue'],
limit: 50, orderBy: 'start_time asc',
}).catch(() => []),
listDocs('Issue', {
filters: { status: 'Open' },
fields: ['name', 'subject', 'customer', 'customer_name', 'priority', 'opening_date'],
limit: 10, orderBy: 'opening_date desc',
}),
countDocs('Issue', { opening_date: ['>=', d7] }).catch(() => 0),
])
// Abonnés = unique customers with active subscriptions (via server script)
let abonnes = 0
let monthlyRev = 0
try {
const [subRes, revRes] = await Promise.all([
authFetch(BASE_URL + '/api/method/subscriber_count').then(r => r.ok ? r.json() : null).catch(() => null),
listDocs('Service Subscription', {
filters: { status: 'Actif' },
fields: ['actual_price'],
limit: 0,
}).catch(() => []),
])
abonnes = subRes?.message?.count || clients
monthlyRev = revRes.reduce((sum, s) => sum + parseFloat(s.actual_price || 0), 0)
} catch {
abonnes = clients
}
stats.value[0].value = abonnes.toLocaleString()
stats.value[1].value = clients.toLocaleString()
stats.value[2].value = monthlyRev ? (Math.round(monthlyRev).toLocaleString() + ' $') : '...'
stats.value[3].value = tickets.toLocaleString()
stats.value[4].value = todayDispatch.length.toLocaleString()
// Tendance / contexte des KPI (à partir des données déjà chargées → 0 requête de plus, sauf le compte 7 j)
const urgents = openTix.filter(t => t.priority === 'Urgent' || t.priority === 'High').length
const tkParts = []
if (urgents) tkParts.push(urgents + ' urgents')
if (newTickets7d) tkParts.push('+' + newTickets7d + ' (7j)')
stats.value[3].sub = tkParts.join(' · ')
stats.value[3].subClass = urgents ? 'text-negative' : 'text-grey-6'
const unassigned = todayDispatch.filter(j => !j.assigned_tech).length
stats.value[4].sub = todayDispatch.length ? (unassigned ? unassigned + ' non assignés' : 'tous assignés ✓') : ''
stats.value[4].subClass = unassigned ? 'text-warning' : 'text-positive'
openTickets.value = openTix
todayJobs.value = todayDispatch
})
</script>
<style scoped>
/* KPI cliquable : affordance au survol + sous-ligne tendance/contexte */
.ops-stat-clk { cursor: pointer; transition: transform .12s, box-shadow .12s; }
.ops-stat-clk:hover { transform: translateY(-1px); box-shadow: 0 4px 14px rgba(2, 6, 23, .08); }
.ops-stat-clk .ops-stat-go { color: var(--ops-text-secondary, #94a3b8); opacity: 0; transition: opacity .12s; }
.ops-stat-clk:hover .ops-stat-go { opacity: 1; }
.ops-stat-sub { font-size: 11px; line-height: 1.2; margin-top: 1px; }
/* Légende de la bande d'occupation */
.os-leg-dot { display: inline-block; width: 9px; height: 9px; border-radius: 3px; margin-right: 3px; vertical-align: middle; }
/* Libellé de semaine au-dessus de chaque bande (« Cette semaine » / « Semaine prochaine ») */
.dash-wk-lbl { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .03em; color: var(--ops-text-secondary, #94a3b8); margin-bottom: 4px; }
</style>