- 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>
520 lines
22 KiB
Vue
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"> · {{ 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>
|