gigafibre-fsm/apps/ops/src/pages/LegacySyncPage.vue
louispaulb 14530787bb consolidate uncommitted multi-session work (NL staff-agent, user avatars/profil, HelpHint, ticket detail modules, skill icons, hub agent/payments/sync)
Lot de travaux accumulés non commités (plusieurs sessions), vérifiés : hub `node --check`
tout OK + build SPA propre.
- ops : AssignmentField, CommandPalette, IssueDetail/DetailModal + detail-sections/modules,
  UserAvatar/useAvatar, UserProfileDialog/EmployeeEditDialog, HelpHint, useSkillIcons,
  pages (Dashboard/Equipe/Clients/Tickets/Reports/Settings/Evaluations/LegacySync…),
  usePermissions/useUserGroups/useDetailModal, MainLayout, TicketStatusControl
- hub : staff-agent.js (couche commandes NL), agent.js/agent-tools.json/voice-agent.js,
  avatars.js, conversation.js, payments.js, sync-orchestrator.js, auth.js, campaigns.js,
  legacy-dispatch-sync.js, server.js

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

375 lines
26 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<q-page class="q-pa-md" style="background:#f7f8fa">
<div class="row items-center q-mb-md">
<div>
<div class="text-h6 text-weight-bold">Sync F ERPNext <span class="text-grey-6 text-subtitle2"> tableau de bord</span></div>
<div class="text-caption text-grey-7">Moteurs modulaires (un par domaine), orchestration + vue unifiées. F autoritaire ; ERPNext reflète F.</div>
</div>
<q-space />
<q-btn outline color="grey-7" icon="confirmation_number" label="Rafraîchir tickets" :disable="syncing" no-caps class="q-mr-sm" @click="startSync('tickets')"><q-tooltip>Tirer les tickets legacy Dispatch (lourd ~1 min ; sinon auto aux 15 min + sync d'assignation live en Planification)</q-tooltip></q-btn>
<q-btn unelevated color="positive" icon="sync" label="Synchroniser maintenant" :loading="syncing" no-caps class="q-mr-sm" @click="startSync('core')"><q-tooltip>Comptes + paiements + soldes, tout de suite (~30 s). Factures/services : cron horaire.</q-tooltip></q-btn>
<q-btn flat color="primary" icon="refresh" label="Recalculer" :loading="loading || sLoading" @click="reload" no-caps />
</div>
<!-- Progression de la synchronisation manuelle -->
<q-card v-if="syncRun" flat bordered class="q-mb-md" :class="syncRun.running ? 'bg-blue-1' : 'bg-grey-2'">
<q-card-section class="q-py-sm">
<div class="row items-center q-mb-xs">
<q-icon :name="syncRun.running ? 'sync' : 'check_circle'" :color="syncRun.running ? 'blue-7' : 'green-7'" :class="{ spin: syncRun.running }" class="q-mr-sm" />
<span class="text-weight-medium">{{ syncRun.running ? 'Synchronisation en cours' : 'Synchronisation terminée' }}</span>
<span class="text-caption text-grey-7 q-ml-sm">{{ syncRun.elapsed_s }} s · {{ syncRun.done }}/{{ syncRun.total }}</span>
<q-space />
<q-btn v-if="!syncRun.running" flat dense round size="sm" icon="close" @click="syncRun = null" />
</div>
<q-linear-progress :value="syncRun.total ? syncRun.done / syncRun.total : 0" :indeterminate="syncRun.running && !syncRun.done" color="teal" rounded size="7px" class="q-mb-sm" />
<div class="row q-col-gutter-md">
<div v-for="s in syncRun.steps" :key="s.key" class="col-auto row items-center">
<q-icon :name="stepIcon(s.status)" :color="stepColor(s.status)" size="16px" :class="{ spin: s.status === 'running' }" class="q-mr-xs" />
<span class="text-caption">{{ s.label }}<span v-if="s.detail" class="text-grey-7"> — {{ s.detail }}</span></span>
</div>
</div>
<div v-if="syncRun.note" class="text-caption text-grey-6 q-mt-xs"><q-icon name="info" size="13px" /> {{ syncRun.note }}</div>
</q-card-section>
</q-card>
<!-- ── TABLEAU DE BORD DE RÉCONCILIATION : comptes bruts F vs ERPNext, par module ── -->
<q-card flat bordered class="q-mb-md">
<q-card-section class="row items-center q-py-sm">
<q-icon name="balance" size="20px" color="grey-7" class="q-mr-sm" />
<div>
<div class="text-weight-bold">Réconciliation par module — F vs ERPNext</div>
<div class="text-caption text-grey-6">Comptes bruts de chaque côté. Écart = F ERPNext (positif = manque dans ERPNext).</div>
</div>
<q-space />
<q-btn flat dense round size="sm" icon="refresh" :loading="sbLoading" @click="loadScoreboard"><q-tooltip>Recalculer les écarts</q-tooltip></q-btn>
</q-card-section>
<q-markup-table v-if="scoreboard" flat dense wrap-cells separator="horizontal">
<thead>
<tr class="text-grey-7">
<th class="text-left">Module</th>
<th class="text-right">F (legacy)</th>
<th class="text-right">ERPNext</th>
<th class="text-right">Écart</th>
<th class="text-left" style="min-width:180px">Note</th>
</tr>
</thead>
<tbody>
<tr v-for="e in scoreboard.entities" :key="e.key">
<td class="text-left text-weight-medium">{{ e.label }}</td>
<td class="text-right">{{ fmtN(e.f) }}</td>
<td class="text-right">{{ fmtN(e.erp) }}</td>
<td class="text-right text-weight-bold" :class="deltaClass(e.delta)">
<q-icon v-if="e.delta === 0" name="check_circle" size="14px" class="q-mr-xs" />{{ e.delta == null ? 'n/a' : (e.delta > 0 ? '+' : '') + fmtN(e.delta) }}
</td>
<td class="text-left text-caption text-grey-7">{{ e.note }}</td>
</tr>
</tbody>
</q-markup-table>
<q-card-section v-if="scoreboard" class="q-pt-sm text-caption text-grey-6 row items-center q-gutter-x-md">
<span>{{ fmtN(scoreboard.resiliated_f_accounts) }} comptes F résiliés</span>
<span>·</span>
<span :class="scoreboard.ghost_active_subscriptions ? 'text-orange-9 text-weight-bold' : 'text-green-8'">
<q-icon :name="scoreboard.ghost_active_subscriptions ? 'rate_review' : 'check_circle'" size="14px" /> {{ fmtN(scoreboard.ghost_active_subscriptions) }} actifs sur compte résilié — à revoir
</span>
<q-space />
<span v-if="scoreboard.generated_at">{{ new Date(scoreboard.generated_at).toLocaleString('fr-CA') }}</span>
</q-card-section>
<q-card-section v-else class="text-caption text-grey-6"><q-spinner size="16px" class="q-mr-xs" /> Calcul des écarts par module…</q-card-section>
</q-card>
<!-- ── BANDE UNIFIÉE : tous les domaines en ordre de dépendance (DAG) ── -->
<q-card flat bordered class="q-mb-md">
<q-card-section class="row items-center q-py-sm">
<div class="text-weight-bold">État par domaine</div>
<span class="text-caption text-grey-6 q-ml-sm">— ordre d'exécution = dépendances (un module ne tourne jamais avant ses prérequis)</span>
<q-space />
<template v-if="health.healthy != null">
<q-badge v-if="health.healthy" color="green-2" text-color="green-9"><q-icon name="check_circle" size="13px" class="q-mr-xs" />facturation à jour</q-badge>
<q-badge v-else color="amber-1" text-color="amber-9"><q-icon name="warning" size="13px" class="q-mr-xs" />facturation à vérifier</q-badge>
<span v-if="health.last_cycle_min_ago != null" class="text-caption text-grey-6 q-ml-sm">dernier cycle il y a {{ health.last_cycle_min_ago }} min</span>
</template>
</q-card-section>
<q-separator />
<div class="row q-pa-sm items-stretch">
<div class="col-6 col-md-2 q-pa-xs" v-for="d in domains" :key="d.key">
<q-card flat bordered class="dcard fit">
<div class="row items-center no-wrap q-mb-xs">
<q-badge rounded color="green-1" text-color="green-9" class="q-mr-xs">{{ d.order }}</q-badge>
<span class="text-weight-medium">{{ d.label }}</span>
<q-space />
<q-icon :name="d.icon" size="15px" :color="d.iconColor" />
</div>
<div class="num" :class="d.colorClass">{{ d.value }}</div>
<div class="lbl">{{ d.metric }}</div>
<div class="text-caption text-grey-6 ellipsis" :title="d.sub">{{ d.sub }}</div>
</q-card>
</div>
</div>
<q-card-section class="text-caption text-grey-7 q-pt-none">
<q-icon name="account_tree" size="14px" /> clients → adresses · services · tickets → factures → paiements.
<code>disabled</code>/<code>is_suspended</code> = dérivé des services (jamais synchronisé) — hors « à réviser ».
</q-card-section>
</q-card>
<q-banner dense rounded class="bg-green-1 text-green-9 q-mb-md">
<template #avatar><q-icon name="lock" color="green-7" /></template>
<b>Lecture seule.</b> Politique : <b>ADD</b> = remplir un champ vide (sûr) · <b>CHANGE</b> = valeur divergente (révision) ·
<b>NEVER-CLOBBER</b> = F vide → jamais écrasé · statut client (<code>disabled</code>) = dérivé, jamais synchronisé.
</q-banner>
<div class="text-subtitle2 text-weight-bold text-grey-8 q-mb-sm"><q-icon name="group" size="18px" /> Détail clients <span class="text-caption text-grey-6">— aperçu legacy-sync</span></div>
<div v-if="loading" class="column items-center q-pa-xl text-grey-6">
<q-spinner-dots size="40px" color="primary" /><div class="q-mt-sm">Calcul de l'aperçu clients… (~10 s)</div>
</div>
<div v-else-if="err" class="text-negative q-pa-md">{{ err }}</div>
<template v-else-if="rep">
<div class="row q-col-gutter-md q-mb-md">
<div class="col-6 col-md-3"><q-card flat bordered class="stat"><div class="num text-info">{{ cs.would_create }}</div><div class="lbl">Comptes à créer <HelpHint title="Créer tout (F → OPS)">Crée dans ERPNext <b>tous</b> les comptes F qui n'ont pas encore de fiche client — pas seulement ceux facturés récemment. Politique <b>F autoritaire</b> : le nom n'est jamais écrasé, le statut est dérivé des services (jamais imposé), et l'opération est <b>idempotente</b> (relançable sans doublon).</HelpHint></div><q-btn v-if="cs.would_create" dense unelevated color="info" size="sm" no-caps icon="group_add" label="Créer tout" class="q-mt-xs" :disable="syncing" @click="confirmCreateAll"><q-tooltip>Créer dans ERPNext tous les comptes F qui n'existent pas encore (F→OPS), pas seulement ceux facturés récemment.</q-tooltip></q-btn></q-card></div>
<div class="col-6 col-md-3"><q-card flat bordered class="stat"><div class="num text-green-8">{{ cs.safe_add }}</div><div class="lbl">À enrichir (sûr)</div></q-card></div>
<div class="col-6 col-md-3"><q-card flat bordered class="stat"><div class="num text-warning">{{ cs.needs_review }}</div><div class="lbl">À réviser (actionnable)</div></q-card></div>
<div class="col-6 col-md-3"><q-card flat bordered class="stat"><div class="num text-grey-7">{{ cs.unchanged }}</div><div class="lbl">Inchangés</div></q-card></div>
</div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-4">
<q-card flat bordered>
<q-card-section class="text-weight-bold bg-green-1 text-green-9">Enrichissements sûrs (ADD) — {{ cs.safe_add }} clients</q-card-section>
<q-list dense separator>
<q-item v-for="(n,f) in rep.customers.add_by_field" :key="f"><q-item-section>{{ f }}</q-item-section><q-item-section side class="text-weight-bold">{{ n }}</q-item-section></q-item>
<q-item v-if="!Object.keys(rep.customers.add_by_field||{}).length"><q-item-section class="text-grey-6">Aucun</q-item-section></q-item>
</q-list>
<q-card-section class="text-caption text-grey-7">Remplit des champs ERPNext vides (email/tél…). Réversible. <code>applySafeAdds(confirm:'SAFE-ADD')</code> — non exécuté.</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-4">
<q-card flat bordered>
<q-card-section class="text-weight-bold bg-orange-1 text-warning row items-center q-py-sm">
<span>À réviser (CHANGE) — {{ cs.needs_review }}</span>
<q-space />
<q-btn dense unelevated color="warning" size="sm" no-caps icon="done_all" label="Tout appliquer" :loading="batchApplying === 'tous'" :disable="!!batchApplying || !cs.needs_review" @click="confirmApplyAll" />
</q-card-section>
<q-list dense separator>
<q-item v-for="(n,f) in rep.customers.review_by_field" :key="f">
<q-item-section>{{ f }}</q-item-section>
<q-item-section side>
<div class="row items-center no-wrap q-gutter-xs">
<q-badge color="amber-1" text-color="amber-9">{{ n }}</q-badge>
<q-btn dense flat size="sm" icon="visibility" color="grey-7" @click="showDetail(f)"><q-tooltip>Voir le détail</q-tooltip></q-btn>
<q-btn dense flat color="positive" size="sm" no-caps label="Appliquer" :loading="batchApplying === f" :disable="!!batchApplying || f === 'customer_name'" @click="applyBatch(f)"><q-tooltip v-if="f === 'customer_name'">Nom protégé — jamais écrasé</q-tooltip></q-btn>
</div>
</q-item-section>
</q-item>
<q-item v-if="!Object.keys(rep.customers.review_by_field||{}).length"><q-item-section class="text-grey-6">Aucun</q-item-section></q-item>
</q-list>
<q-card-section v-if="reviewDetail" class="bg-grey-1 q-py-sm">
<div class="row items-center q-mb-xs"><span class="text-caption text-weight-medium">{{ reviewDetail.field }} — {{ reviewDetail.total }} changement(s)</span><q-space /><q-btn flat dense round size="xs" icon="close" @click="reviewDetail = null" /></div>
<div v-for="(it,i) in reviewDetail.items" :key="i" class="text-caption ellipsis">{{ it.customer_name }} <span class="text-grey-6">#{{ it.legacy_account_id }}</span> : <span v-for="d in it.change" :key="d.field"><s>{{ d.from }}</s> → <b>{{ d.to }}</b></span></div>
<div v-if="reviewDetail.total > reviewDetail.items.length" class="text-caption text-grey-6">… +{{ reviewDetail.total - reviewDetail.items.length }} autres</div>
</q-card-section>
<q-card-section class="text-caption text-grey-7">F gagne, sur décision. Le nom n'est jamais écrasé. Applique par champ ou tout d'un coup.</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-4">
<q-card flat bordered class="q-mb-md">
<q-card-section class="text-weight-bold bg-blue-grey-1 text-blue-grey-9">Statut — dérivé (non-actionnable)</q-card-section>
<q-list dense separator>
<q-item><q-item-section>F actif / ERPNext désactivé</q-item-section><q-item-section side class="text-weight-bold">{{ sd.f_active_erp_disabled }}</q-item-section></q-item>
<q-item><q-item-section>F inactif / ERPNext actif</q-item-section><q-item-section side class="text-weight-bold">{{ sd.f_inactive_erp_enabled }}</q-item-section></q-item>
</q-list>
<q-card-section class="text-caption text-grey-7">Statut dérivé des abonnements — <b>jamais synchronisé</b>. Un client hors zone reste vendable (cross-sell), pas « churn ».</q-card-section>
</q-card>
<q-card flat bordered>
<q-card-section class="text-weight-bold">Never-clobber (protégés)</q-card-section>
<q-list dense separator>
<q-item v-for="(n,f) in rep.customers.never_clobber_by_field" :key="f"><q-item-section>{{ f }}</q-item-section><q-item-section side class="text-weight-bold">{{ n }}</q-item-section></q-item>
<q-item v-if="!Object.keys(rep.customers.never_clobber_by_field||{}).length"><q-item-section class="text-grey-6">Aucun</q-item-section></q-item>
</q-list>
</q-card>
</div>
</div>
<div class="row q-col-gutter-md q-mt-xs">
<div class="col-12 col-md-6">
<q-card flat bordered>
<q-card-section class="text-weight-bold">Nouveaux comptes (échantillon)</q-card-section>
<q-markup-table flat dense>
<thead><tr><th class="text-left">legacy_account_id</th><th class="text-left">Nom</th><th class="text-left">Statut F</th></tr></thead>
<tbody><tr v-for="c in rep.customers.creates_sample" :key="c.legacy_account_id"><td>{{ c.legacy_account_id }}</td><td>{{ c.customer_name }}</td><td>{{ c.status }}</td></tr></tbody>
</q-markup-table>
</q-card>
</div>
<div class="col-12 col-md-6">
<q-card flat bordered>
<q-card-section class="text-weight-bold">À réviser (échantillon)</q-card-section>
<q-list dense separator>
<q-item v-for="r in rep.customers.needs_review_sample" :key="r.legacy_account_id">
<q-item-section>
<q-item-label>{{ r.customer_name }} <span class="text-grey-6">#{{ r.legacy_account_id }}</span></q-item-label>
<q-item-label caption><span v-for="d in r.change" :key="d.field" class="chg">{{ d.field }}: <s>{{ d.from }}</s> → <b>{{ d.to }}</b></span></q-item-label>
</q-item-section>
<q-item-section side>
<q-btn dense flat color="positive" icon="check" label="Appliquer" size="sm" no-caps :loading="applyingId === r.legacy_account_id" @click="applyChange(r)">
<q-tooltip>Accepter la valeur de F (écrit dans ERPNext). Réversible. Le nom n'est jamais écrasé.</q-tooltip>
</q-btn>
</q-item-section>
</q-item>
<q-item v-if="!(rep.customers.needs_review_sample||[]).length"><q-item-section class="text-grey-6">Aucun</q-item-section></q-item>
</q-list>
</q-card>
</div>
</div>
<div class="text-caption text-grey-6 q-mt-md">Aperçu clients calculé {{ rep.started_at }} · F {{ cs.f_total }} comptes / ERPNext {{ cs.erp_total }}.</div>
</template>
</q-page>
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useQuasar } from 'quasar'
import { HUB_URL } from 'src/config/hub'
import HelpHint from 'src/components/shared/HelpHint.vue'
const $q = useQuasar()
const loading = ref(false)
const err = ref('')
const rep = ref(null)
const cs = computed(() => (rep.value && rep.value.customers.summary) || {})
const sd = computed(() => (rep.value && rep.value.customers.status_divergence) || {})
// ── Bande unifiée (rapide, /sync/status) ──
const sLoading = ref(false)
const status = ref(null)
// ── Tableau de bord de réconciliation (comptes bruts F vs ERPNext, /legacy-sync/scoreboard) ──
const scoreboard = ref(null)
const sbLoading = ref(false)
const health = computed(() => (status.value && status.value.billing_health) || {})
const fmtN = (v) => v == null ? '—' : Number(v).toLocaleString('fr-CA', { maximumFractionDigits: 0 })
const fmtCur = (v) => v == null ? '—' : '$' + Number(v).toLocaleString('fr-CA', { maximumFractionDigits: 0 })
const lagCls = (l) => (l == null ? 'text-grey-6' : l > 0 ? 'text-warning' : 'text-green-8')
const createCls = (n) => (n == null ? 'text-grey-6' : n > 0 ? 'text-info' : 'text-green-8')
const domains = computed(() => {
const m = (status.value && status.value.modules) || {}
const okIcon = (cond) => cond ? 'check_circle' : 'pending'
return [
{ key: 'clients', label: 'Clients', order: 1, value: fmtN(m.clients?.to_create), metric: 'à créer', sub: `ERPNext ${fmtN(m.clients?.erp)}`, colorClass: createCls(m.clients?.to_create), icon: okIcon(!m.clients?.to_create), iconColor: m.clients?.to_create ? 'blue-5' : 'green-5' },
{ key: 'adresses', label: 'Adresses', order: 2, value: fmtN(m.adresses?.to_create), metric: 'à créer', sub: `ERPNext ${fmtN(m.adresses?.erp)}`, colorClass: createCls(m.adresses?.to_create), icon: okIcon(!m.adresses?.to_create), iconColor: m.adresses?.to_create ? 'blue-5' : 'green-5' },
{ key: 'services', label: 'Services', order: 2, value: fmtN(m.services?.to_create), metric: 'à créer', sub: `ERPNext ${fmtN(m.services?.erp)}`, colorClass: createCls(m.services?.to_create), icon: okIcon(!m.services?.to_create), iconColor: m.services?.to_create ? 'blue-5' : 'green-5' },
{ key: 'factures', label: 'Factures', order: 3, value: fmtN(m.factures?.lag), metric: 'en retard', sub: `${fmtN(m.factures?.synced)} vivantes · ${fmtCur(m.factures?.ar_open)} dû`, colorClass: lagCls(m.factures?.lag), icon: okIcon(!m.factures?.lag), iconColor: m.factures?.lag ? 'orange-5' : 'green-5' },
{ key: 'paiements', label: 'Paiements', order: 4, value: fmtN(m.paiements?.lag), metric: 'en retard', sub: `${fmtN(m.paiements?.total)} au total`, colorClass: lagCls(m.paiements?.lag), icon: okIcon(!m.paiements?.lag), iconColor: m.paiements?.lag ? 'orange-5' : 'green-5' },
{ key: 'tickets', label: 'Tickets', order: 2, value: '·', metric: 'moteur séparé', sub: 'osTicket → Dispatch', colorClass: 'text-grey-6', icon: 'open_in_new', iconColor: 'grey-5' },
]
})
async function loadStatus () {
sLoading.value = true
try { const r = await fetch(`${HUB_URL}/sync/status`); if (r.ok) status.value = await r.json() } catch {}
sLoading.value = false
}
async function load () {
loading.value = true; err.value = ''
try {
const res = await fetch(`${HUB_URL}/legacy-sync/preview`)
if (!res.ok) throw new Error('Échec du calcul (' + res.status + ')')
rep.value = await res.json()
} catch (e) { err.value = e.message }
loading.value = false
}
async function loadScoreboard () {
sbLoading.value = true
try { const r = await fetch(`${HUB_URL}/legacy-sync/scoreboard`); if (r.ok) scoreboard.value = await r.json() } catch {} finally { sbLoading.value = false }
}
function deltaClass (d) { if (d == null) return 'text-grey-5'; if (d === 0) return 'text-green-7'; if (Math.abs(d) < 100) return 'text-orange-8'; return 'text-red-7' }
function reload () { loadStatus(); load(); loadScoreboard() }
// ── Synchronisation manuelle + progression ──
const syncing = ref(false)
const syncRun = ref(null)
let _pollTimer = null
const stepIcon = (s) => ({ pending: 'schedule', running: 'sync', done: 'check_circle', error: 'error' }[s] || 'schedule')
const stepColor = (s) => ({ pending: 'grey-5', running: 'blue-6', done: 'green-6', error: 'red-6' }[s] || 'grey-5')
async function startSync (scope) {
if (syncing.value) return
syncing.value = true; syncRun.value = null
try {
const r = await fetch(`${HUB_URL}/sync/run`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ confirm: 'F-WINS', scope }) })
const d = await r.json()
if (d.already_running) { $q.notify({ type: 'warning', message: 'Une synchronisation est déjà en cours' }); poll(); return }
if (!d.started) { $q.notify({ type: 'negative', message: d.error || 'Échec' }); syncing.value = false; return }
poll()
} catch (e) { $q.notify({ type: 'negative', message: e.message }); syncing.value = false }
}
function poll () {
clearTimeout(_pollTimer)
fetch(`${HUB_URL}/sync/run-status`).then(r => r.json()).then(d => {
syncRun.value = d
if (d.running) { _pollTimer = setTimeout(poll, 1500) }
else { syncing.value = false; const ok = (d.steps || []).filter(s => s.status === 'error').length === 0; $q.notify({ type: ok ? 'positive' : 'warning', message: ok ? 'Synchronisation terminée' : 'Terminée avec des erreurs' }); reload() }
}).catch(() => { syncing.value = false })
}
// Appliquer un changement révisé (accepter F) — par compte
const applyingId = ref(null)
async function applyChange (r) {
applyingId.value = r.legacy_account_id
try {
const res = await fetch(`${HUB_URL}/legacy-sync/apply-change`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ legacy_account_id: r.legacy_account_id, confirm: 'F-WINS' }) })
const d = await res.json()
if (d.applied) {
$q.notify({ type: 'positive', message: `Appliqué à ${r.customer_name} : ${Object.keys(d.patch).join(', ')}` })
rep.value.customers.needs_review_sample = rep.value.customers.needs_review_sample.filter(x => x.legacy_account_id !== r.legacy_account_id)
loadStatus()
} else $q.notify({ type: 'warning', message: d.error || d.note || 'Rien à appliquer' })
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { applyingId.value = null }
}
// Appliquer en LOT (par champ ou tout) + détail
const batchApplying = ref(null)
const reviewDetail = ref(null)
async function applyBatch (field) {
const label = field || 'tous'
if (batchApplying.value) return
batchApplying.value = label
try {
const r = await fetch(`${HUB_URL}/legacy-sync/apply-review-batch`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ field, confirm: 'F-WINS' }) })
const d = await r.json()
if (d.applied != null) { $q.notify({ type: 'positive', message: `${d.applied} changement(s) appliqué(s)${field ? ' · ' + field : ''}` }); reviewDetail.value = null; load() }
else $q.notify({ type: 'negative', message: d.error || 'Échec' })
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { batchApplying.value = null }
}
async function showDetail (field) {
if (reviewDetail.value && reviewDetail.value.field === field) { reviewDetail.value = null; return }
try {
const r = await fetch(`${HUB_URL}/legacy-sync/apply-review-batch`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ field }) }) // dry-run
const d = await r.json()
reviewDetail.value = { field, items: d.sample || [], total: d.would_apply || 0 }
} catch (e) { $q.notify({ type: 'negative', message: e.message }) }
}
// Créer TOUS les comptes F manquants (F→OPS complet) — passe par le run ASYNC de l'orchestrateur (scope 'customers') → progression + polling, pas de requête bloquante.
function confirmCreateAll () {
$q.dialog({ title: 'Créer tous les comptes manquants', message: `Créer dans ERPNext les <b>${cs.value.would_create}</b> compte(s) F sans Customer (incl. résiliés) ?<br><span class="text-caption">Politique : créé visible, statut dérivé des services. Idempotent — relançable sans doublon.</span>`, html: true, cancel: true, ok: { label: 'Créer tout', color: 'info' } }).onOk(() => startSync('customers'))
}
function confirmApplyAll () {
const bf = rep.value.customers.review_by_field || {}
const detail = Object.entries(bf).filter(([f]) => f !== 'customer_name').map(([f, n]) => `${f}: ${n}`).join(' · ')
$q.dialog({ title: 'Tout appliquer (F gagne)', message: `Accepter F sur ${cs.value.needs_review} client(s) ?<br><span class="text-caption">${detail}</span><br><br>Le nom est protégé. Réversible.`, html: true, cancel: true, ok: { label: 'Appliquer', color: 'orange-8' } }).onOk(() => applyBatch(null))
}
onBeforeUnmount(() => clearTimeout(_pollTimer))
onMounted(reload)
</script>
<style scoped>
.stat { padding: 12px; text-align: center; }
.stat .num { font-size: 1.7rem; font-weight: 700; line-height: 1; }
.stat .lbl { font-size: 0.72rem; color: #64748b; margin-top: 4px; }
.dcard { padding: 10px; }
.dcard .num { font-size: 1.5rem; font-weight: 700; line-height: 1.1; }
.dcard .lbl { font-size: 0.7rem; color: #64748b; text-transform: uppercase; letter-spacing: .02em; }
.chg { display: inline-block; margin-right: 10px; }
code { background: #eef2ff; padding: 1px 4px; border-radius: 3px; }
.spin { animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
</style>