gigafibre-fsm/apps/ops/src/pages/DashboardPage.vue
louispaulb 607ea54b5c refactor: reduce token count, DRY code, consolidate docs
Backend services:
- targo-hub: extract deepGetValue to helpers.js, DRY disconnect reasons
  lookup map, compact CAPABILITIES, consolidate vision.js prompts/schemas,
  extract dispatch scoring weights, trim section dividers across 9 files
- modem-bridge: extract getSession() helper (6 occurrences), resetIdleTimer(),
  consolidate DM query factory, fix duplicate username fill bug, trim headers
  (server.js -36%, tplink-session.js -47%, docker-compose.yml -57%)

Frontend:
- useWifiDiagnostic: extract THRESHOLDS const, split processDiagnostic into
  6 focused helpers (processOnlineStatus, processWanIPs, processRadios,
  processMeshNodes, processClients, checkRadioIssues)
- EquipmentDetail: merge duplicate ROLE_LABELS, remove verbose comments

Documentation (17 → 13 files, -1,400 lines):
- New consolidated README.md (architecture, services, dependencies, auth)
- Merge ECOSYSTEM-OVERVIEW into ARCHITECTURE.md
- Merge MIGRATION-PLAN + ARCHITECTURE-COMPARE + FIELD-GAP + CHANGELOG → MIGRATION.md
- Merge COMPETITIVE-ANALYSIS into PLATFORM-STRATEGY.md
- Update ROADMAP.md with current phase status
- Delete CONTEXT.md (absorbed into README)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 08:39:58 -04:00

422 lines
16 KiB
Vue

<template>
<q-page padding>
<!-- Real-time outage alerts -->
<OutageAlertsPanel class="q-mb-md" @open-ticket="id => $router.push('/tickets')" />
<!-- KPI cards -->
<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">
<div class="row items-center no-wrap q-gutter-x-sm">
<q-icon :name="stat.icon" :style="{ color: stat.color }" size="22px" />
<div>
<div class="ops-stat-value" :style="{ color: stat.color }">{{ stat.value }}</div>
<div class="ops-stat-label">{{ stat.label }}</div>
</div>
</div>
</div>
</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-amber-9" style="font-size:0.8rem">
<template #avatar><q-icon name="pending_actions" color="amber-8" /></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="amber-8"
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="teal-7"
icon="credit_score"
:loading="runningPPA"
dense no-caps
@click="runPPA"
>
<q-tooltip>Prélève automatiquement les cartes enregistrées</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="indigo-6" class="q-ml-sm" />
</div>
<q-list separator>
<q-item v-for="j in todayJobs" :key="j.name" clickable @click="$router.push('/dispatch')">
<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 { listDocs, countDocs } from 'src/api/erp'
import { authFetch } from 'src/api/auth'
import { BASE_URL } from 'src/config/erpnext'
import { HUB_SSE_URL } from 'src/config/dispatch'
import OutageAlertsPanel from 'src/components/shared/OutageAlertsPanel.vue'
const stats = ref([
{ label: 'Abonnés', value: '...', color: 'var(--ops-accent)', icon: 'people' },
{ label: 'Clients', value: '...', color: 'var(--ops-primary)', icon: 'business' },
{ label: 'Rev. mensuel', value: '...', color: 'var(--ops-success)', icon: 'attach_money' },
{ label: 'Tickets ouverts', value: '...', color: 'var(--ops-warning)', icon: 'confirmation_number' },
{ label: 'Dispatch aujourd\'hui', value: '...', color: 'var(--ops-accent)', icon: 'local_shipping' },
])
const openTickets = ref([])
const todayJobs = ref([])
// 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
}
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()
const today = new Date().toISOString().slice(0, 10)
const [clients, tickets, todayDispatch, openTix] = 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',
}),
])
// 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()
openTickets.value = openTix
todayJobs.value = todayDispatch
})
</script>