<template>
  <q-page padding>
    <div v-if="loading" class="flex flex-center" style="min-height:300px">
      <q-spinner size="40px" color="primary" />
    </div>

    <template v-else-if="customer">
      <div class="row q-col-gutter-md">
        <div class="col-12 col-lg-8">

      <CustomerHeader :customer="customer">
        <template #actions>
          <div class="row justify-end items-center q-gutter-xs q-mt-xs">
            <!-- « Nouveau message » vit dans l'en-tête du panneau Messagerie (un seul point d'entrée) ; ici : appel + récompense. -->
            <q-btn v-if="customerPhoneOptions.length" dense flat size="sm" no-caps color="green-7" icon="call" label="Appeler">
              <q-tooltip>Appeler le client (softphone Twilio intégré)</q-tooltip>
              <q-menu auto-close anchor="bottom right" self="top right">
                <q-list dense style="min-width:200px">
                  <q-item-label header class="q-py-xs">Appeler — softphone</q-item-label>
                  <q-item v-for="ph in customerPhoneOptions" :key="ph.value" clickable @click="ficheCall(ph.value)">
                    <q-item-section avatar><q-icon name="call" color="green-7" size="18px" /></q-item-section>
                    <q-item-section>{{ ph.label }}</q-item-section>
                  </q-item>
                </q-list>
              </q-menu>
            </q-btn>
            <q-btn dense flat size="sm" no-caps color="teal-8" icon="event_available" label="Trouver un créneau" @click="openAvailReason">
              <q-tooltip>Prendre rendez-vous : motif → disponibilité ajustée à la compétence requise (icônes de compétences · « sans-fil » ajouté si le client est sans-fil) → réserver un créneau</q-tooltip>
            </q-btn>
            <OverflowMenu :items="[
              { icon: 'card_giftcard', label: 'Récompense', caption: 'Carte-cadeau : lien · éditer · envoyer', action: () => { rewardOpen = true } },
              { icon: 'tune', label: 'Personnaliser la fiche', caption: 'Réorganiser ou masquer les modules', action: openCustomize },
            ]" />
          </div>
        </template>
        <template #contact><ContactCard :customer="customer" /></template>
        <template #info><CustomerInfoCard :customer="customer" /></template>
      </CustomerHeader>

      <!-- VUE 360 : synthèse client (KPI) + activité récente agrégée (comms + tickets), d'un coup d'œil -->
      <div class="c360">
        <div class="c360-kpis">
          <div v-for="k in kpi360" :key="k.key" class="c360-kpi">
            <q-icon :name="k.icon" :color="k.color" size="22px" />
            <div class="c360-kpi-val">{{ k.value }}</div>
            <div class="c360-kpi-lbl">{{ k.label }}</div>
          </div>
        </div>
      </div>

      <!-- Satisfaction (extrait → CustomerSatisfaction, T7) -->
      <CustomerSatisfaction :latest="latestRating" :count="custRatings.length" @draft="draftRatingInvite" />

      <!-- Communications regroupées : courriels/SMS = panneau « Messagerie » (droite) ; les appels & comms loggées restent visibles dans le résumé « Activité récente ». Le Journal séparé (ex-ChatterPanel) a été retiré pour éviter le doublon. `callLog` est conservé : il alimente l'Activité récente. -->

      <div class="fiche-modules">
      <q-expansion-item v-model="sectionsOpen.locations" header-class="section-header" class="q-mb-sm fiche-mod" :style="moduleStyle('locations')" v-show="moduleVisible('locations')">
        <template #header>
          <div class="section-title" style="font-size:1rem;width:100%;display:flex;align-items:center;gap:6px">
            <q-icon name="location_on" size="20px" />
            <span>Lieux de service ({{ locations.length }})</span>
            <!-- Subtle clarifier: chaque lieu = adresse de livraison
                 = endroit où le tech va = où l'ONT vit. Distinct de
                 l'adresse de facturation portée par Customer. -->
            <q-badge color="green-1" text-color="primary" class="q-ml-sm" style="font-size:0.62rem;font-weight:600">
              <q-icon name="local_shipping" size="11px" class="q-mr-xs" />
              Adresses de livraison
            </q-badge>
          </div>
        </template>

        <div v-if="!locations.length" class="ops-card text-center text-grey-6 q-pa-md q-mb-md">Aucun lieu de service</div>

      <LocationCard v-for="loc in visibleLocations" :key="loc.name"
        :loc="loc"
        :highlight-loc="highlightLoc"
        :deleting-loc="deletingLoc"
        :sub-saving="subSaving"
        :toggling-recurring="togglingRecurring"
        :loc-collapsed="locCollapsed"
        :loc-has-subs="locHasSubs"
        :loc-equip="locEquip"
        :loc-subs="locSubs"
        :loc-subs-monthly="locSubsMonthly"
        :loc-subs-annual="locSubsAnnual"
        :loc-subs-monthly-total="locSubsMonthlyTotal"
        :loc-subs-annual-total="locSubsAnnualTotal"
        :loc-subs-sections="locSubsSections"
        :section-open="sectionOpen"
        :loc-tickets="locTickets"
        :toggle-loc-collapse="toggleLocCollapse"
        :toggle-section="toggleSection"
        :on-sub-drag-change="onSubDragChange"
        :on-sub-price-saved="onSubPriceSaved"
        :toggle-frequency="toggleFrequency"
        :toggle-sub-status="toggleSubStatus"
        :toggle-recurring="toggleRecurring"
        :confirm-delete-sub="confirmDeleteSub"
        :confirm-delete-location="confirmDeleteLocation"
        :open-new-ticket="openNewTicket"
        :open-modal="openModal"
        :open-wizard-for-address="openWizardForAddress"
        :open-add-service="openAddService"
        :open-add-equipment="openAddEquipment"
      />

        <!-- Adresses sans abonnement regroupées : un seul bouton, masquées par défaut, révélées d'un clic -->
        <div v-if="inactiveLocCount" class="text-center q-pb-sm">
          <q-btn flat dense no-caps color="grey-7" size="sm"
            :icon="showInactiveLocs ? 'expand_less' : 'expand_more'"
            :label="showInactiveLocs ? 'Masquer les adresses inactives' : ('Voir adresses inactives (' + inactiveLocCount + ')')"
            @click="showInactiveLocs = !showInactiveLocs" />
        </div>
      </q-expansion-item>

      <q-expansion-item v-model="sectionsOpen.tickets" header-class="section-header" class="q-mb-sm fiche-mod" :style="moduleStyle('tickets')" v-show="moduleVisible('tickets')">
        <template #header>
          <div class="section-title" style="font-size:1rem;width:100%;display:flex;align-items:center">
            <q-icon name="confirmation_number" size="20px" class="q-mr-xs" />
            Tickets ({{ tickets.length }}{{ !ticketsExpanded ? '+' : '' }})
            <span v-if="openTicketCount" class="ops-badge open q-ml-sm">{{ openTicketCount }} ouverts</span>
            <q-space />
            <q-btn flat dense size="sm" icon="add" label="Nouveau" color="primary" no-caps
              @click.stop="openNewTicket()" class="q-mr-sm" />
          </div>
        </template>
        <div v-if="!tickets.length" class="ops-card text-center text-grey-6 q-pa-md q-mb-md">Aucun ticket</div>
        <div v-else class="ops-card q-mb-md">
          <TicketCard v-for="t in visibleTickets" :key="t.name" :ticket="t"
            @open="openModal('Issue', t.name, t.subject)" />
          <div v-if="!ticketsExpanded && tickets.length > 5" class="text-center q-pa-xs">
            <q-btn flat dense no-caps color="primary" :loading="loadingMoreTickets"
              label="Voir tous les tickets" icon="expand_more"
              @click="tickets.length >= 10 ? loadAllTickets() : (ticketsExpanded = true)" />
          </div>
        </div>
      </q-expansion-item>

      <q-expansion-item v-model="sectionsOpen.invoices" header-class="section-header" class="q-mb-sm fiche-mod" :style="moduleStyle('invoices')" v-show="moduleVisible('invoices')">
        <template #header>
          <div class="section-title row items-center" style="font-size:1rem;width:100%">
            <q-icon name="receipt_long" size="20px" class="q-mr-xs" />
            Factures ({{ invoices.length }}{{ !invoicesExpanded ? '+' : '' }})
            <span v-if="totalOutstanding > 0" class="text-caption text-red q-ml-sm">Solde: {{ formatMoney(totalOutstanding) }}</span>
            <q-space />
            <div @click.stop>
              <q-btn flat dense size="sm" icon="add" color="primary" no-caps
                label="Facture" @click="newInvoiceOpen = true">
                <q-tooltip>Creer une facture</q-tooltip>
              </q-btn>
            </div>
          </div>
        </template>
        <div v-if="!invoices.length" class="ops-card text-center text-grey-6 q-pa-md q-mb-md">Aucune facture</div>
        <div v-else class="ops-card q-mb-md">
          <DataTable :rows="invoices" :columns="invoiceCols" row-key="name"
            flat dense class="ops-table clickable-table"
            :pagination="{ rowsPerPage: 10, sortBy: 'posting_date', descending: true }"
            @row-click="(_, row) => openModal('Sales Invoice', row.name, 'Facture ' + row.name)">
            <template #body-cell-name="props">
              <q-td :props="props">
                <div class="text-weight-medium">{{ props.row.name }}</div>
                <div v-if="props.row.is_return" class="text-caption text-warning">
                  <q-icon name="reply" size="12px" /> Renversement de {{ props.row.return_against }}
                </div>
              </q-td>
            </template>
            <template #body-cell-status="props">
              <q-td :props="props"><span class="ops-badge" :class="invStatusClass(props.row.status)">{{ props.row.status }}</span></q-td>
            </template>
            <template #body-cell-grand_total="props">
              <q-td :props="props" class="text-right" :class="{ 'text-red': props.row.grand_total < 0 }">{{ formatMoney(props.row.grand_total) }}</q-td>
            </template>
            <template #body-cell-outstanding_amount="props">
              <q-td :props="props" class="text-right" :class="{ 'text-red': props.row.outstanding_amount > 0 }">
                {{ formatMoney(props.row.outstanding_amount) }}
                <q-btn v-if="props.row.outstanding_amount > 0" flat dense round size="xs" icon="payments" color="primary" class="q-ml-xs" @click.stop="openPayInvoice(props.row)"><q-tooltip>Enregistrer un paiement pour cette facture</q-tooltip></q-btn>
              </q-td>
            </template>
          </DataTable>
          <div v-if="!invoicesExpanded && invoices.length >= 5" class="text-center q-pa-xs">
            <q-btn flat dense no-caps color="primary" :loading="loadingMoreInvoices"
              label="Voir toutes les factures" icon="expand_more" @click="loadAllInvoices" />
          </div>
        </div>
      </q-expansion-item>

      <!-- PAIEMENTS UNIFIÉS (méthode + transactions + ententes) — extrait → PaymentSection, T7 -->
      <PaymentSection
        v-model:open="sectionsOpen.payments"
        :module-style="moduleStyle('payments')"
        :module-visible="moduleVisible('payments')"
        :payments="payments"
        :stripe-charges="stripeCharges"
        :payment-methods="paymentMethods"
        :arrangements="arrangements"
        :refunds-by-pi="refundsByPi"
        :payments-expanded="paymentsExpanded"
        :loading-more-payments="loadingMorePayments"
        :sending-link="sendingLink"
        :charging-card="chargingCard"
        :toggling-ppa="togglingPpa"
        :refunding="refunding"
        :send-payment-link="sendPaymentLink"
        :charge-card="chargeCard"
        :toggle-ppa="togglePpa"
        :open-portal="openPortal"
        :create-checkout="createCheckout"
        :record-payment="recordPayment"
        :confirm-refund="confirmRefund"
        :load-all-payments="loadAllPayments"
        :open-modal="openModal"
      />

      <!-- VoIP Lines -->
      <q-expansion-item v-model="sectionsOpen.voip" header-class="section-header" class="q-mb-sm fiche-mod" :style="moduleStyle('voip')" v-show="moduleVisible('voip')">
        <template #header>
          <div class="section-title" style="font-size:1rem;width:100%">
            <q-icon name="phone" size="20px" class="q-mr-xs" />
            Lignes VoIP ({{ voipLines.length }})
            <span v-if="voipLines.filter(v => !v.e911_synced).length" class="text-caption text-orange q-ml-sm">
              {{ voipLines.filter(v => !v.e911_synced).length }} sans 911
            </span>
          </div>
        </template>
        <div v-if="!voipLines.length" class="ops-card text-center text-grey-6 q-pa-md q-mb-md">Aucune ligne VoIP</div>
        <div v-else class="ops-card q-mb-md">
          <DataTable :rows="voipLines" :columns="voipCols" row-key="name"
            flat dense class="ops-table clickable-table"
            :pagination="{ rowsPerPage: 20 }"
            @row-click="(_, row) => openModal('VoIP Line', row.name, 'VoIP ' + row.did)">
            <template #body-cell-status="props">
              <q-td :props="props">
                <span class="ops-badge" :class="props.row.status === 'Active' ? 'open' : 'inactive'">{{ props.row.status }}</span>
              </q-td>
            </template>
            <template #body-cell-e911_synced="props">
              <q-td :props="props">
                <q-icon :name="props.row.e911_synced ? 'check_circle' : 'warning'" size="16px"
                  :color="props.row.e911_synced ? 'green-6' : 'orange-7'" />
              </q-td>
            </template>
          </DataTable>
        </div>
      </q-expansion-item>

      <!-- Payment Methods + Actions -->
      <!-- (ex-sections « Paiement » [méthodes de paiement] et « Ententes de paiement » : fusionnées dans « Paiements » ci-dessus) -->

      <!-- Contrats de service (offres de service) — wizard-created artifacts
           that summarize récurrent + durée + benefits. This is the primary
           "sommaire" view after a projet est soumis. -->
      <q-expansion-item v-model="sectionsOpen.serviceContracts" header-class="section-header" class="q-mb-sm fiche-mod" :style="moduleStyle('serviceContracts')" v-show="moduleVisible('serviceContracts')">
        <template #header>
          <div class="section-title" style="font-size:1rem;width:100%;display:flex;align-items:center">
            <q-icon name="handshake" size="20px" color="warning" class="q-mr-xs" />
            Contrats de service ({{ serviceContracts.length }})
            <q-badge v-if="serviceContracts.some(c => c.status === 'Brouillon' || c.status === 'Envoyé')"
              color="warning" text-color="white" class="q-ml-sm">
              {{ serviceContracts.filter(c => c.status === 'Brouillon' || c.status === 'Envoyé').length }} en attente
            </q-badge>
            <q-space />
            <FlowQuickButton flat dense size="sm" icon="account_tree" label="Flows contrats"
              tooltip="Éditer les automatisations après signature"
              category="residential" applies-to="Service Contract"
              trigger-event="on_contract_signed" @click.stop />
          </div>
        </template>
        <div v-if="!serviceContracts.length" class="ops-card text-center text-grey-6 q-pa-md q-mb-md">Aucun contrat de service</div>
        <div v-else class="ops-card q-mb-md">
          <DataTable :rows="serviceContracts" :columns="serviceContractCols" row-key="name"
            flat dense class="ops-table clickable-table"
            :pagination="{ rowsPerPage: 10, sortBy: 'start_date', descending: true }"
            @row-click="(_, row) => openModal('Service Contract', row.name, 'Contrat ' + row.name)">
            <template #body-cell-duration_months="props">
              <q-td :props="props" class="text-right">{{ props.row.duration_months }} mois</q-td>
            </template>
            <template #body-cell-monthly_rate="props">
              <q-td :props="props" class="text-right">{{ formatMoney(props.row.monthly_rate) }}/m</q-td>
            </template>
            <template #body-cell-total_benefit_value="props">
              <q-td :props="props" class="text-right">
                <span v-if="props.row.total_benefit_value" class="text-green-7">
                  {{ formatMoney(props.row.total_benefit_value) }}
                </span>
                <span v-else class="text-grey-5">—</span>
              </q-td>
            </template>
            <template #body-cell-status="props">
              <q-td :props="props" class="text-center">
                <q-chip dense :color="contractStatusColor(props.row.status)" text-color="white"
                  size="sm" class="q-px-sm">
                  {{ props.row.status }}
                </q-chip>
              </q-td>
            </template>
          </DataTable>
        </div>
      </q-expansion-item>

      <!-- Soumissions / Quotations -->
      <q-expansion-item v-model="sectionsOpen.quotations" header-class="section-header" class="q-mb-sm fiche-mod" :style="moduleStyle('quotations')" v-show="moduleVisible('quotations')">
        <template #header>
          <div class="section-title" style="font-size:1rem;width:100%;display:flex;align-items:center">
            <q-icon name="request_quote" size="20px" class="q-mr-xs" />
            Soumissions ({{ quotations.length }})
            <q-space />
            <q-btn flat dense size="sm" icon="add" label="Nouvelle soumission" color="primary" no-caps
              @click.stop="openNewQuotation()" class="q-mr-sm" />
          </div>
        </template>
        <div v-if="!quotations.length" class="ops-card text-center text-grey-6 q-pa-md q-mb-md">Aucune soumission</div>
        <div v-else class="ops-card q-mb-md">
          <DataTable :rows="quotations" :columns="quotationCols" row-key="name"
            flat dense class="ops-table clickable-table"
            :pagination="{ rowsPerPage: 10, sortBy: 'transaction_date', descending: true }"
            @row-click="(_, row) => openModal('Quotation', row.name, 'Soumission ' + row.name)">
            <template #body-cell-grand_total="props">
              <q-td :props="props" class="text-right">{{ formatMoney(props.row.grand_total) }}</q-td>
            </template>
          </DataTable>
        </div>
      </q-expansion-item>
      </div><!-- /fiche-modules -->

      </div>

        <div class="col-12 col-lg-4">
          <div class="convos-sticky">
            <!-- Messagerie du client (courriel + SMS unifiés) — panneau principal de la fiche (customer-360), déplacé ici depuis la colonne principale. Le softphone (appels) reste via le bouton « Appeler » de l'en-tête. -->
            <div class="ops-card q-pa-sm q-mb-md" style="border-radius:12px">
              <div class="row items-center q-mb-sm">
                <q-icon name="forum" size="20px" color="primary" class="q-mr-xs" />
                <span class="section-title" style="font-size:1rem">Messagerie</span>
                <q-badge v-if="commsDiscussions.length" color="green-1" text-color="green-8" class="q-ml-xs" :label="commsDiscussions.length" />
                <q-space />
                <q-btn dense flat round size="sm" icon="add_comment" color="primary" @click="ficheCompose"><q-tooltip>Nouveau message (courriel / texto) à ce client</q-tooltip></q-btn>
                <q-btn dense flat round size="sm" icon="refresh" color="grey-6" @click="loadComms"><q-tooltip>Rafraîchir (courriels + SMS de la boîte unifiée)</q-tooltip></q-btn>
              </div>
              <div v-if="!commsDiscussions.length" class="text-center text-grey-6 q-pa-md text-caption">Aucune conversation (courriel / SMS) liée à ce client.</div>
              <q-list v-else separator style="border-radius:8px;overflow:hidden">
                <q-item v-for="d in (msgOpen ? commsDiscussions : commsDiscussions.slice(0, 1))" :key="d.id" clickable @click="openComms(d)">
                  <q-item-section avatar><q-icon :name="d.channel === 'email' ? 'mail' : 'sms'" :color="d.channel === 'email' ? 'red-5' : 'teal-6'" size="20px" /></q-item-section>
                  <q-item-section>
                    <q-item-label lines="1">{{ commsTitle(d) }}</q-item-label>
                    <q-item-label caption lines="2">{{ commsSnippet(d) }}</q-item-label>
                  </q-item-section>
                  <q-item-section side top>
                    <q-item-label caption>{{ formatDate(d.lastActivity) }}</q-item-label>
                    <q-badge v-if="d.status === 'active'" color="green-5" label="actif" class="q-mt-xs" style="font-size:0.6rem" />
                  </q-item-section>
                </q-item>
              </q-list>
              <div v-if="commsDiscussions.length > 1" class="text-center q-pt-xs c360-more" style="cursor:pointer;color:#6366f1;font-size:0.8rem;user-select:none" @click="msgOpen = !msgOpen">
                {{ msgOpen ? '▲ Réduire' : '▼ Voir ' + (commsDiscussions.length - 1) + ' de plus' }}
              </div>
            </div>
            <!-- Journal d'activité : tickets, interventions, appels, système. Les courriels/SMS sont dans « Messagerie » ci-dessus (pas re-dupliqués ici). -->
            <div class="ops-card q-pa-sm q-mb-md" style="border-radius:12px">
              <div class="row items-center q-mb-sm">
                <q-icon name="history" size="20px" color="blue-grey-6" class="q-mr-xs" />
                <span class="section-title" style="font-size:1rem">Activité</span>
                <q-badge v-if="activityLog.length" color="blue-grey-1" text-color="blue-grey-8" class="q-ml-xs" :label="activityLog.length" />
              </div>
              <div v-if="!activityLog.length" class="text-center text-grey-6 q-pa-md text-caption">Aucune activité récente.</div>
              <q-list v-else separator style="border-radius:8px;overflow:hidden">
                <q-item v-for="r in (recentOpen ? activityLog : activityLog.slice(0, 1))" :key="r.key" clickable @click="r.act()">
                  <q-item-section avatar><q-icon :name="r.icon" :color="r.color" size="20px" /></q-item-section>
                  <q-item-section>
                    <q-item-label lines="1">{{ r.label }}</q-item-label>
                    <q-item-label caption lines="1">{{ r.sub }}</q-item-label>
                  </q-item-section>
                  <q-item-section side><q-item-label caption>{{ formatDate(r.date) }}</q-item-label></q-item-section>
                </q-item>
              </q-list>
              <div v-if="activityLog.length > 1" class="text-center q-pt-xs c360-more" style="cursor:pointer;color:#6366f1;font-size:0.8rem;user-select:none" @click="recentOpen = !recentOpen">
                {{ recentOpen ? '▲ Réduire' : '▼ Voir ' + (activityLog.length - 1) + ' de plus' }}
              </div>
            </div>
            <!-- Notes internes (secondaire) — Comment ERPNext. -->
            <div class="ops-card q-pa-sm" style="border-radius:12px">
              <div class="row items-center q-mb-sm">
                <q-icon name="sticky_note_2" size="20px" color="warning" class="q-mr-xs" />
                <span class="section-title" style="font-size:1rem">Notes internes</span>
                <q-badge v-if="comments.length" color="amber-1" text-color="amber-9" class="q-ml-xs" :label="comments.length" />
              </div>
              <div class="row items-start q-gutter-xs q-mb-sm">
                <q-input v-model="newNote" dense outlined autogrow class="col" placeholder="Ajouter une note interne…" @keydown.ctrl.enter="addNote" @keydown.meta.enter="addNote" />
                <q-btn dense unelevated color="warning" icon="add" :disable="!newNote.trim()" :loading="addingNote" @click="addNote"><q-tooltip>Enregistrer la note (Ctrl/⌘+Entrée)</q-tooltip></q-btn>
              </div>
              <div v-if="!sortedComments.length" class="text-grey-6 text-caption q-pa-xs">Aucune note</div>
              <div v-for="c in (notesOpen ? sortedComments : sortedComments.slice(0, 1))" :key="c.name" class="q-pa-xs q-mb-xs" style="border-left:3px solid #fbbf24;background:#fffbeb;border-radius:6px">
                <div class="row items-center no-wrap">
                  <span class="text-caption text-weight-medium text-warning">{{ noteAuthorName(c.comment_by || c.owner) }}</span>
                  <q-space />
                  <span class="text-caption text-grey-5">{{ formatDate(c.creation) }}</span>
                  <q-btn flat dense round size="xs" icon="delete_outline" color="negative" class="q-ml-xs" @click="deleteNote(c)"><q-tooltip>Supprimer la note</q-tooltip></q-btn>
                </div>
                <div style="white-space:pre-wrap;font-size:0.85rem;color:#334155">{{ c.content }}</div>
              </div>
              <div v-if="sortedComments.length > 1" class="text-center q-pt-xs c360-more" style="cursor:pointer;color:#6366f1;font-size:0.8rem;user-select:none" @click="notesOpen = !notesOpen">
                {{ notesOpen ? '▲ Réduire' : '▼ Voir ' + (sortedComments.length - 1) + ' de plus' }}
              </div>
            </div>
          </div>
        </div>
      </div>

    </template>

    <div v-else class="ops-card text-center q-pa-xl">
      <q-icon name="error_outline" size="48px" color="grey-5" />
      <div class="text-h6 text-grey-6 q-mt-sm">Client introuvable</div>
      <q-btn flat color="primary" label="Retour" icon="arrow_back" @click="$router.push('/clients')" class="q-mt-md" />
    </div>

    <!-- Add Service / Subscription dialog -->
    <q-dialog v-model="addServiceOpen" persistent>
      <q-card style="width:560px;max-width:95vw">
        <q-card-section class="row items-center q-pb-sm">
          <q-icon :name="addServiceMode === 'rabais' ? 'sell' : 'wifi'" size="22px"
            :color="addServiceMode === 'rabais' ? 'red-5' : 'primary'" class="q-mr-sm" />
          <div class="text-subtitle1 text-weight-bold">
            {{ addServiceMode === 'rabais' ? 'Ajouter un rabais / crédit' : 'Ajouter un forfait / service' }}
          </div>
          <q-space />
          <q-badge v-if="addServiceLoc" color="green-1" text-color="green-8" class="q-pa-xs">
            {{ addServiceLoc.address_line || addServiceLoc.location_name }}
          </q-badge>
        </q-card-section>

        <q-separator />

        <q-card-section>
          <div class="row q-col-gutter-sm">
            <!-- Plan search (searches Subscription Plans linked to Items) -->
            <div class="col-12">
              <q-select v-model="newService.plan" label="Forfait / Plan (recherche catalogue)"
                outlined dense use-input input-debounce="300" emit-value map-options
                :options="planSearchResults" option-label="label" option-value="value"
                @filter="searchPlans" clearable
                @update:model-value="onPlanSelected">
                <template #no-option>
                  <q-item>
                    <q-item-section class="text-grey">Aucun forfait dans le catalogue</q-item-section>
                  </q-item>
                </template>
                <template #option="scope">
                  <q-item v-bind="scope.itemProps">
                    <q-item-section>
                      <q-item-label>{{ scope.opt.item_name }}</q-item-label>
                      <q-item-label caption>{{ scope.opt.item_code }} · {{ scope.opt.item_group || '' }}</q-item-label>
                    </q-item-section>
                    <q-item-section side>
                      <q-item-label v-if="scope.opt.cost" class="text-weight-bold">{{ formatMoney(scope.opt.cost) }}/m</q-item-label>
                    </q-item-section>
                  </q-item>
                </template>
              </q-select>
            </div>

            <!-- Description override -->
            <div class="col-12">
              <q-input v-model="newService.description" label="Description (override)" outlined dense
                :placeholder="newService.item_name || 'Laisser vide pour utiliser le nom du plan'" />
            </div>

            <!-- Price override + start date -->
            <div class="col-6">
              <q-input v-model.number="newService.price" :label="addServiceMode === 'rabais' ? 'Montant du rabais' : 'Prix mensuel'"
                outlined dense type="number" step="0.01"
                :prefix="addServiceMode === 'rabais' ? '-' : ''" suffix="$">
                <template #hint>
                  <span v-if="addServiceMode === 'rabais'" class="text-red">Le montant sera négatif (crédit)</span>
                </template>
              </q-input>
            </div>
            <div class="col-6">
              <q-input v-model="newService.start_date" label="Date de début" outlined dense type="date" />
            </div>
          </div>
        </q-card-section>

        <q-card-actions align="right">
          <q-btn flat label="Annuler" @click="addServiceOpen = false" />
          <q-btn unelevated :color="addServiceMode === 'rabais' ? 'red-5' : 'primary'"
            :icon="addServiceMode === 'rabais' ? 'sell' : 'add'"
            :label="addServiceMode === 'rabais' ? 'Ajouter le rabais' : 'Ajouter le service'"
            :loading="addingService" :disable="!newService.plan && !newService.description"
            @click="createService" />
        </q-card-actions>
      </q-card>
    </q-dialog>

    <q-dialog v-model="addEquipOpen" persistent>
      <q-card style="width:520px;max-width:95vw">
        <q-card-section class="row items-center q-pb-sm">
          <q-icon name="add_circle" size="22px" color="primary" class="q-mr-sm" />
          <div class="text-subtitle1 text-weight-bold">Ajouter un equipement</div>
          <q-space />
          <q-badge v-if="addEquipLoc" color="green-1" text-color="green-8" class="q-pa-xs">
            {{ addEquipLoc.address_line || addEquipLoc.location_name }}
          </q-badge>
        </q-card-section>

        <q-separator />

        <q-card-section>
          <div class="row items-center q-mb-md q-pa-sm" style="background:#f0fdf4;border-radius:8px;border:1px solid #bbf7d0">
            <q-icon name="qr_code_scanner" size="24px" color="green-7" class="q-mr-sm" />
            <div class="col">
              <div class="text-body2 text-weight-medium">Scanner un code-barres / serial</div>
              <div class="text-caption text-grey-6">Photo de l'etiquette, code-barres ou QR code</div>
            </div>
            <input ref="scanInput" type="file" accept="image/*" capture="environment" style="display:none"
              @change="onScanPhoto" />
            <q-btn unelevated dense color="green-7" icon="photo_camera" label="Scanner"
              :loading="scannerState.scanning.value" @click="$refs.scanInput.click()" />
          </div>

          <div v-if="scannerState.barcodes.value.length" class="q-mb-md">
            <div class="text-caption text-grey-7 q-mb-xs">Codes detectes :</div>
            <div class="row q-gutter-xs">
              <q-chip v-for="(bc, i) in scannerState.barcodes.value" :key="i" dense removable
                color="green-1" text-color="green-9" icon="qr_code"
                @remove="scannerState.barcodes.value.splice(i, 1)"
                clickable @click="applyScannedCode(bc.value)">
                {{ bc.value }}
                <q-tooltip>Cliquer pour utiliser comme N serie</q-tooltip>
              </q-chip>
            </div>
          </div>
          <div v-if="scannerState.error.value" class="text-caption text-warning q-mb-sm">
            {{ scannerState.error.value }}
          </div>

          <div v-if="scannerState.lastPhoto.value" class="q-mb-md text-center">
            <img :src="scannerState.lastPhoto.value" style="max-height:120px;border-radius:8px;border:1px solid #e2e8f0" />
          </div>

          <q-separator class="q-mb-md" />

          <div class="row q-col-gutter-sm">
            <div class="col-6">
              <q-select v-model="newEquip.equipment_type" label="Type" outlined dense emit-value map-options
                :options="equipTypeOptions" />
            </div>
            <div class="col-6">
              <q-select v-model="newEquip.status" label="Statut" outlined dense emit-value map-options
                :options="equipStatusOptions" />
            </div>
            <div class="col-12">
              <q-input v-model="newEquip.serial_number" label="N de serie" outlined dense>
                <template #append>
                  <q-icon v-if="newEquip.serial_number && equipLookupResult" size="18px"
                    :name="equipLookupResult.found ? 'check_circle' : 'help_outline'"
                    :color="equipLookupResult.found ? 'green-6' : 'grey-5'" />
                  <q-spinner v-if="equipLookingUp" size="16px" color="primary" />
                </template>
              </q-input>
              <div v-if="equipLookupResult?.found" class="text-caption text-warning q-mt-xs">
                Cet equipement existe deja : {{ equipLookupResult.equipment.name }}
                ({{ equipLookupResult.equipment.equipment_type }}, {{ equipLookupResult.equipment.service_location || 'non assigne' }})
              </div>
            </div>
            <div class="col-6">
              <q-input v-model="newEquip.brand" label="Marque" outlined dense />
            </div>
            <div class="col-6">
              <q-input v-model="newEquip.model" label="Modele" outlined dense />
            </div>
            <div class="col-6">
              <q-input v-model="newEquip.mac_address" label="Adresse MAC" outlined dense />
            </div>
            <div class="col-6">
              <q-input v-model="newEquip.ip_address" label="Adresse IP" outlined dense />
            </div>
          </div>
        </q-card-section>

        <q-card-actions align="right">
          <q-btn flat label="Annuler" @click="closeAddEquipment" />
          <q-btn v-if="equipLookupResult?.found" unelevated color="warning" icon="link"
            label="Lier a cette adresse" :loading="addingEquip"
            @click="linkExistingEquipment" />
          <q-btn v-else unelevated color="primary" icon="add" label="Creer"
            :loading="addingEquip" :disable="!newEquip.serial_number || !newEquip.equipment_type"
            @click="createEquipment" />
        </q-card-actions>
      </q-card>
    </q-dialog>

    <UnifiedCreateModal v-model="newTicketOpen" mode="ticket"
      :context="ticketContext" :locations="locations"
      @created="onTicketCreated" />

    <!-- « Trouver un créneau » direct (appel / réparation) → crée un Dispatch Job lié au client -->
    <SuggestSlotsDialog v-model="findSlotOpen" :skills="slotSkills"
      :initial-address="findSlotAddr" :initial-skill="findSlotSkill" @select="onFindSlotSelected" />
    <!-- « Dispo par motif » : raison → compétence → disponibilité (dénominateur filtré) → enchaîne sur la recherche de créneau -->
    <AvailabilityByReason v-model="availReasonOpen" :customer-name="customer?.customer_name || ''"
      :address="findSlotAddr" :connection-type="primaryLocation()?.connection_type || ''" @book="onAvailBook" />
    <UnifiedCreateModal v-model="createJobOpen" mode="work-order"
      :context="createJobCtx" :locations="locations" @created="onJobCreated" />

    <CreateInvoiceModal v-model="newInvoiceOpen" :customer="customer" @created="onInvoiceCreated" />

    <ProjectWizard v-model="newQuotationOpen" :customer="quotationCustomerContext"
      :delivery-address-id="wizardDeliveryAddressId" @created="onQuotationPublished" />

    <DetailModal
      v-model:open="modalOpen" :loading="modalLoading" :doctype="modalDoctype"
      :doc-name="modalDocName" :title="modalTitle" :doc="modalDoc"
      :comments="modalComments" :comms="modalComms" :files="modalFiles"
      :doc-fields="modalDocFields" :dispatch-jobs="modalDispatchJobs"
      :hide-customer-link="true"
      @navigate="(dt, name, t) => openModal(dt, name, t)"
      @open-pdf="openPdf" @save-field="saveSubField"
      @toggle-recurring="toggleRecurringModal" @dispatch-created="onDispatchCreated"
      @dispatch-deleted="name => { modalDispatchJobs = modalDispatchJobs.filter(j => j.name !== name) }"
      @contract-terminated="onContractTerminated"
      @deleted="onEntityDeleted" />

    <!-- Carte-cadeau / récompense — composant extrait (voir components/customer/RewardDialog.vue) -->
    <RewardDialog v-model="rewardOpen" :customer="customer" />

    <!-- Personnaliser la disposition des modules de la fiche : glisser pour réordonner, œil pour masquer. Persisté par utilisateur (useUserPrefs 'client360'). -->
    <q-dialog v-model="customizeOpen">
      <q-card style="min-width:360px;max-width:440px">
        <q-card-section class="row items-center q-pb-sm">
          <q-icon name="tune" color="primary" size="22px" class="q-mr-sm" />
          <div class="text-h6">Personnaliser la fiche</div>
          <q-space /><q-btn flat round dense icon="close" v-close-popup />
        </q-card-section>
        <q-card-section class="q-pt-none">
          <div class="text-caption text-grey-6 q-mb-sm">Glissez pour réordonner. L'œil masque un module. Enregistré pour vous, sur tous vos appareils.</div>
          <draggable v-model="draftModules" item-key="key" handle=".mod-handle" :animation="150" class="column q-gutter-xs">
            <template #item="{ element }">
              <div class="row items-center no-wrap mod-row q-pa-xs" :class="{ 'mod-hidden': !element.visible }">
                <q-icon name="drag_indicator" class="mod-handle q-mr-xs text-grey-5" size="20px" />
                <q-icon :name="element.icon" size="18px" class="q-mr-sm text-grey-7" />
                <div class="col text-body2">{{ element.label }}</div>
                <q-btn flat round dense size="sm" :icon="element.visible ? 'visibility' : 'visibility_off'"
                       :color="element.visible ? 'grey-7' : 'grey-4'" @click="element.visible = !element.visible">
                  <q-tooltip>{{ element.visible ? 'Masquer ce module' : 'Afficher ce module' }}</q-tooltip>
                </q-btn>
              </div>
            </template>
          </draggable>
        </q-card-section>
        <q-card-actions align="between" class="q-px-md q-pb-md">
          <q-btn flat no-caps dense color="grey-7" icon="restart_alt" label="Réinitialiser" @click="resetCustomize" />
          <div>
            <q-btn flat label="Annuler" color="grey-7" v-close-popup />
            <q-btn unelevated label="Enregistrer" color="primary" icon="save" @click="saveCustomize" />
          </div>
        </q-card-actions>
      </q-card>
    </q-dialog>

    <!-- Enregistrer un paiement sur une facture (modèle Odoo, sans quitter l'app) : aperçu ERPNext puis confirmation. -->
    <q-dialog v-model="payDlg.open">
      <q-card style="min-width:360px;max-width:480px">
        <q-card-section class="row items-center q-pb-none">
          <q-icon name="payments" color="primary" size="22px" class="q-mr-sm" />
          <div class="text-h6">Enregistrer un paiement</div>
          <q-space /><q-btn flat round dense icon="close" v-close-popup />
        </q-card-section>
        <q-card-section>
          <div class="text-caption text-grey-7 q-mb-sm">Facture <b>{{ payDlg.invoice }}</b> · solde dû <b>{{ formatMoney(payDlg.outstanding) }}</b></div>
          <q-input dense outlined type="number" step="0.01" v-model.number="payDlg.amount" label="Montant reçu" prefix="$" @blur="payInvoicePreview" />
          <q-input dense outlined v-model="payDlg.referenceNo" label="Référence (n° chèque, transaction…)" class="q-mt-sm" />
          <div class="q-mt-sm" style="min-height:20px">
            <q-spinner v-if="payDlg.loading" color="primary" size="18px" />
            <div v-else-if="payDlg.preview" class="text-caption text-grey-7"><q-icon name="account_balance" size="13px" /> Sera comptabilisé via ERPNext — {{ payDlg.preview.mode_of_payment || 'mode par défaut' }}, compte <b>{{ payDlg.preview.paid_to }}</b></div>
          </div>
          <q-banner v-if="payDlg.amount > 0 && payDlg.amount < payDlg.outstanding" dense class="bg-amber-1 text-warning q-mt-sm" style="font-size:0.8rem">Paiement partiel — la facture restera « Partiellement payée ».</q-banner>
          <div v-if="payDlg.error" class="text-negative text-caption q-mt-sm">{{ payDlg.error }}</div>
        </q-card-section>
        <q-card-actions align="right">
          <q-btn flat no-caps label="Annuler" v-close-popup />
          <q-btn unelevated no-caps color="primary" icon="check" label="Enregistrer le paiement" :loading="payDlg.submitting" :disable="!(payDlg.amount > 0) || payDlg.loading || !!payDlg.error || !payDlg.preview" @click="payInvoiceConfirm" />
        </q-card-actions>
      </q-card>
    </q-dialog>

    <!-- Paiement à l'avance / non alloué (SANS facture) — natif, remplace l'ancien lien desk « Payment Entry ». Aperçu puis confirmation. -->
    <q-dialog v-model="onAcctDlg.open">
      <q-card style="min-width:360px;max-width:480px">
        <q-card-section class="row items-center q-pb-none">
          <q-icon name="savings" color="primary" size="22px" class="q-mr-sm" />
          <div class="text-h6">Enregistrer un paiement</div>
          <q-space /><q-btn flat round dense icon="close" v-close-popup />
        </q-card-section>
        <q-card-section>
          <div class="text-caption text-grey-7 q-mb-sm">Paiement général de <b>{{ customer?.customer_name }}</b> (à l'avance / non alloué à une facture précise — crédit sur le compte client).</div>
          <q-input dense outlined type="number" step="0.01" v-model.number="onAcctDlg.amount" label="Montant reçu" prefix="$" @blur="onAcctPreview" />
          <q-select dense outlined v-model="onAcctDlg.mode" :options="['Cheque', 'Cash']" label="Mode de paiement" class="q-mt-sm" @update:model-value="onAcctPreview" />
          <q-input dense outlined v-model="onAcctDlg.referenceNo" label="Référence (n° chèque, transaction…)" class="q-mt-sm" />
          <div class="q-mt-sm" style="min-height:20px">
            <q-spinner v-if="onAcctDlg.loading" color="primary" size="18px" />
            <div v-else-if="onAcctDlg.preview" class="text-caption text-grey-7"><q-icon name="account_balance" size="13px" /> Comptabilisé via ERPNext — dépôt <b>{{ onAcctDlg.preview.paid_to }}</b> ; crédit non alloué sur le compte client.</div>
          </div>
          <div v-if="onAcctDlg.error" class="text-negative text-caption q-mt-sm">{{ onAcctDlg.error }}</div>
        </q-card-section>
        <q-card-actions align="right">
          <q-btn flat no-caps label="Annuler" v-close-popup />
          <q-btn unelevated no-caps color="primary" icon="check" label="Enregistrer" :loading="onAcctDlg.submitting" :disable="!(onAcctDlg.amount > 0) || onAcctDlg.loading || !!onAcctDlg.error || !onAcctDlg.preview" @click="onAcctConfirm" />
        </q-card-actions>
      </q-card>
    </q-dialog>
  </q-page>
</template>

<script setup>
import { ref, computed, onMounted, reactive, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Notify, useQuasar } from 'quasar'
import draggable from 'vuedraggable'
import { deleteDoc, createDoc, listDocs } from 'src/api/erp'
import { authFetch } from 'src/api/auth'
import { BASE_URL } from 'src/config/erpnext'
import { HUB_URL } from 'src/config/hub'
import { formatDate, formatDateTime, formatMoney, noteAuthorName } from 'src/composables/useFormatters'
import { invStatusClass } from 'src/composables/useStatusClasses'
import { useDetailModal } from 'src/composables/useDetailModal'
import { useSubscriptionGroups } from 'src/composables/useSubscriptionGroups'
import { useSubscriptionActions } from 'src/composables/useSubscriptionActions'
import { useCustomerNotes } from 'src/composables/useCustomerNotes'
import { useConversations } from 'src/composables/useConversations'
import { invoiceCols, voipCols, quotationCols, serviceContractCols } from 'src/config/table-columns'
import DetailModal from 'src/components/shared/DetailModal.vue'
import DataTable from 'src/components/shared/DataTable.vue'
import CustomerHeader from 'src/components/customer/CustomerHeader.vue'
import ContactCard from 'src/components/customer/ContactCard.vue'
import CustomerInfoCard from 'src/components/customer/CustomerInfoCard.vue'
import CustomerSatisfaction from 'src/components/customer/CustomerSatisfaction.vue'
import InlineField from 'src/components/shared/InlineField.vue'
import { useSoftphone } from 'src/composables/useSoftphone'
import UnifiedCreateModal from 'src/components/shared/UnifiedCreateModal.vue'
import SuggestSlotsDialog from 'src/modules/dispatch/components/SuggestSlotsDialog.vue' // « Trouver un créneau » direct depuis la fiche (appel/réparation)
import AvailabilityByReason from 'src/components/shared/AvailabilityByReason.vue' // raison → compétence → disponibilité (dénominateur filtré)
import OverflowMenu from 'src/components/shared/OverflowMenu.vue' // ⋮ regroupe Récompense + Personnaliser (déclutter en-tête)
import { DEPARTMENTS } from 'src/config/departments'
import FlowQuickButton from 'src/components/flow-editor/FlowQuickButton.vue'
import CreateInvoiceModal from 'src/components/shared/CreateInvoiceModal.vue'
import ProjectWizard from 'src/components/shared/ProjectWizard.vue'
import { useDeviceStatus } from 'src/composables/useDeviceStatus'
import { usePermissions } from 'src/composables/usePermissions'
import { usePaymentActions } from 'src/composables/usePaymentActions'
import { useClientData } from 'src/composables/useClientData'
import { useEquipmentActions } from 'src/composables/useEquipmentActions'
import { equipTypeOptions, equipStatusOptions, defaultSectionsOpen, phoneLabelMap, CLIENT_360_MODULES } from 'src/data/client-constants'
import { useUserPrefs } from 'src/composables/useUserPrefs'
import { erpPdfUrl } from 'src/utils/erp-pdf'
import RewardDialog from 'src/components/customer/RewardDialog.vue'
import LocationCard from 'src/components/customer/LocationCard.vue'
import PaymentSection from 'src/components/customer/PaymentSection.vue'
import TicketCard from 'src/components/customer/TicketCard.vue'

const $q = useQuasar()
const { can } = usePermissions()
const props = defineProps({ id: String })

const equipment = ref([])
const ticketsExpanded = ref(false)
const invoicesExpanded = ref(false)
const paymentsExpanded = ref(false)

// Device status still needed at the page level to seed the shared map (fetchStatus/fetchOltStatus
// passed to useClientData); DeviceStrip consumes the same singleton composable for display.
const { fetchStatus, fetchOltStatus } = useDeviceStatus()

const {
  modalOpen, modalLoading, modalDoctype, modalDocName, modalTitle,
  modalDoc, modalComments, modalComms, modalFiles, modalDocFields,
  modalDispatchJobs, openModal,
} = useDetailModal()

const {
  loading, customer, locations, subscriptions, tickets,
  invoices, payments, voipLines, paymentMethods, arrangements, quotations,
  serviceContracts, comments, accountBalance, refundsByPi, stripeCharges,
  loadingMoreTickets, loadingMoreInvoices, loadingMorePayments,
  loadCustomer, loadAllTickets, loadAllInvoices, loadAllPayments,
} = useClientData({ equipment, modalOpen, ticketsExpanded, invoicesExpanded, paymentsExpanded, invalidateAll: () => invalidateAll(), fetchStatus, fetchOltStatus })

const {
  locSubs, locSubsMonthly, locSubsAnnual, locSubsMonthlyTotal, locSubsAnnualTotal,
  locSubsSections, sectionOpen, toggleSection, invalidateCache, invalidateAll,
} = useSubscriptionGroups(subscriptions)

const {
  subSaving, togglingRecurring,
  toggleSubStatus, toggleFrequency, toggleRecurring,
  toggleRecurringModal, saveSubField, logSubChange, onSubDragChange,
} = useSubscriptionActions(subscriptions, customer, comments, invalidateCache)

const { newNote, addingNote, sortedComments, addNote, deleteNote } = useCustomerNotes(comments, customer)
const softphone = useSoftphone()
// Appel via le softphone GLOBAL (Twilio WebRTC) + log lié à la fiche.
function ficheCall (num) { softphone.call(num, { customer: customer.value?.name || '', customerName: customer.value?.customer_name || '' }) }
// Nouveau message (courriel/SMS) au client via le composeur unifié (ConversationPanel) — pré-lié à la fiche.
function ficheCompose () {
  const c = customer.value; if (!c) return
  const email = c.email_id || c.email_billing || ''
  const phone = c.cell_phone || c.tel_home || ''
  openComposeDraft({ channel: email ? 'email' : 'sms', to: email, phone, customer: c.name, customerName: c.customer_name || '' })
}

// ── Carte-cadeau / récompense : dialogue extrait dans RewardDialog.vue (ouvert via rewardOpen) ──
const rewardOpen = ref(false)

const {
  sendingLink, chargingCard, togglingPpa, refunding,
  sendPaymentLink, chargeCard, togglePpa, openPortal, createCheckout,
  refundPayment,
} = usePaymentActions(customer)

const {
  scannerState, addEquipOpen, addEquipLoc, addingEquip,
  equipLookupResult, equipLookingUp, newEquip,
  openAddEquipment, closeAddEquipment, onScanPhoto,
  applyScannedCode, createEquipment, linkExistingEquipment,
} = useEquipmentActions(customer, equipment)

// ── Add Service / Subscription (native ERPNext Subscription) ────
const addServiceOpen = ref(false)
const addServiceLoc = ref(null)
const addServiceMode = ref('service') // 'service' | 'rabais'
const addingService = ref(false)
const planSearchResults = ref([])
const newService = reactive({
  plan: null,        // Subscription Plan name
  item_code: '',
  item_name: '',
  item_group: '',
  description: '',
  price: 0,
  start_date: new Date().toISOString().slice(0, 10),
})

function openAddService (loc, mode = 'service') {
  addServiceLoc.value = loc
  addServiceMode.value = mode
  Object.assign(newService, {
    plan: null, item_code: '', item_name: '', item_group: '',
    description: '',
    price: 0,
    start_date: new Date().toISOString().slice(0, 10),
  })
  addServiceOpen.value = true
}

async function searchPlans (val, update) {
  // Empty query → show a reasonable top-of-catalog listing so the dispatcher
  // can browse even without typing ("list catalog + add manually" UX).
  // Short query (1 char) also returns the top slice — Frappe's `like %a%`
  // would match half the catalog, not helpful. 2+ chars → real filter.
  const browseAll = !val || val.length < 2
  try {
    const plans = await listDocs('Subscription Plan', {
      filters: {},
      ...(browseAll ? {} : {
        or_filters: [
          ['name', 'like', `%${val}%`],
          ['item', 'like', `%${val}%`],
          ['plan_name', 'like', `%${val}%`],
        ],
      }),
      fields: ['name', 'item', 'cost', 'plan_name'],
      limit: browseAll ? 50 : 20,
      orderBy: 'plan_name asc',
    })
    // Enrich with Item info
    const itemCodes = [...new Set(plans.map(p => p.item).filter(Boolean))]
    const itemMap = {}
    if (itemCodes.length) {
      const items = await listDocs('Item', {
        filters: { name: ['in', itemCodes] },
        fields: ['name', 'item_name', 'item_group'],
        limit: itemCodes.length,
      })
      for (const it of items) itemMap[it.name] = it
    }
    update(() => {
      planSearchResults.value = plans.map(p => {
        const item = itemMap[p.item] || {}
        return {
          label: `${item.item_name || p.plan_name || p.item} (${p.item})`,
          value: p.name,
          item_code: p.item,
          item_name: item.item_name || p.plan_name || p.item,
          item_group: item.item_group || '',
          cost: parseFloat(p.cost || 0),
        }
      })
    })
  } catch {
    update(() => { planSearchResults.value = [] })
  }
}

function onPlanSelected (val) {
  if (!val) return
  const plan = planSearchResults.value.find(p => p.value === val)
  if (plan) {
    newService.item_code = plan.item_code
    newService.item_name = plan.item_name
    newService.item_group = plan.item_group
    if (!newService.description) newService.description = plan.item_name
    if (!newService.price) {
      newService.price = addServiceMode.value === 'rabais' ? Math.abs(plan.cost) : plan.cost
    }
  }
}

// Map an item_group string (e.g. "Internet", "Téléphonie IP", "Forfait Bundle")
// to one of the Service Subscription service_category enum values. Falls
// back to "Autre" when we can't guess.
function inferServiceCategory (itemGroup, planLabel = '') {
  const g = `${itemGroup || ''} ${planLabel || ''}`.toLowerCase()
  if (g.includes('iptv') || g.includes('tvbevo') || g.includes(' tv') || g.startsWith('tv')) return 'IPTV'
  if (g.includes('voip') || g.includes('tele') || g.includes('phone')) return 'VoIP'
  if (g.includes('ftth') || g.includes('fibre') || g.includes('internet')) return 'Internet'
  if (g.includes('bundle') || g.includes('forfait')) return 'Bundle'
  if (g.includes('heberg') || g.includes('host')) return 'Hébergement'
  return 'Autre'
}

async function createService () {
  if (!addServiceLoc.value || (!newService.plan && !newService.description)) return
  addingService.value = true
  try {
    const price = addServiceMode.value === 'rabais'
      ? -Math.abs(newService.price || 0)
      : (newService.price || 0)

    const custId = customer.value.name
    const locName = addServiceLoc.value.name
    const today = newService.start_date
    const planLabel = newService.description || newService.item_name || 'Service'
    const category = inferServiceCategory(newService.item_group, planLabel)

    // Create a Service Subscription — flat doc, no parent/child plans table.
    // This is also what contracts + chain activation produce, so anything
    // manually added here lives in the same universe.
    const doc = await createDoc('Service Subscription', {
      customer: custId,
      customer_name: customer.value.customer_name || '',
      service_location: locName,
      service_category: category,
      plan_name: planLabel.slice(0, 140),
      monthly_price: price,
      billing_cycle: 'Mensuel',
      start_date: today,
      status: 'Actif',                      // manual add → immediately active
      product_sku: newService.item_code || '',
    })

    // Add to local subscriptions list in the same UI row shape produced by
    // useClientData.loadSubscriptions.
    subscriptions.value.push({
      name: doc.name,
      subscription: doc.name,
      plan_name: planLabel,
      item_code: newService.item_code || '',
      item_name: planLabel,
      item_group: newService.item_group || category,
      custom_description: planLabel,
      actual_price: price,
      service_location: locName,
      billing_frequency: 'M',
      status: 'Active',
      start_date: today,
      end_date: null,
      cancel_at_period_end: 0,
      qty: 1,
    })
    invalidateCache(locName)
    invalidateAll()

    Notify.create({ type: 'positive', message: `${planLabel} ajouté (${formatMoney(price)}/m)`, position: 'top', timeout: 3000 })
    addServiceOpen.value = false
  } catch (e) {
    Notify.create({ type: 'negative', message: 'Erreur: ' + (e.message || 'Création impossible'), position: 'top', timeout: 4000 })
  } finally {
    addingService.value = false
  }
}

// Inline price edit callback — the InlineField has already persisted
// `monthly_price` to ERPNext; we mirror it into the UI row
// (`actual_price`), invalidate the location cache so the monthly total
// recomputes, and drop an audit line.
function onSubPriceSaved (sub, evt) {
  const oldPrice = Number(sub.actual_price || 0)
  const newPrice = Number(evt.value || 0)
  sub.actual_price = newPrice
  if (sub.service_location) invalidateCache(sub.service_location)
  if (oldPrice !== newPrice) {
    logSubChange(sub, `Prix modifié: ${formatMoney(oldPrice)} → ${formatMoney(newPrice)}`)
  }
}

// ─── Subscription delete ─────────────────────────────────────────────
// Best-practice separation of concerns:
//   • Hard delete is reserved for "dispatch typos" — subs with no
//     billing history and no live contract. Anything else keeps the
//     row for audit, chargeback defense, and regulatory retention.
//   • If a live Service Contract references the sub, we redirect the
//     rep to the contract detail modal, where they see the full
//     benefits breakdown (économies accordées, reconnues à date, et
//     résiduelles à rembourser) and can hit "Résilier" with the full
//     fee calculation visible. The contract flow cascades a soft
//     cancel to the sub (status=Annulé + end_date), never a DELETE.
//   • If the linked contract is already terminal (Résilié / Complété
//     / Expiré), the lifecycle is done — we scrub the stale link
//     inline and allow the delete without another round-trip.

const CONTRACT_TERMINAL = new Set(['Résilié', 'Complété', 'Expiré'])

function findLinkedContract (sub) {
  return (serviceContracts.value || []).find(c => c.service_subscription === sub.name) || null
}

async function unlinkContractFromSub (contract) {
  await authFetch(`${BASE_URL}/api/resource/Service%20Contract/${encodeURIComponent(contract.name)}`, {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ service_subscription: '' }),
  })
  contract.service_subscription = ''
}

async function rawDeleteSub (sub, label) {
  await deleteDoc('Service Subscription', sub.name)
  const idx = subscriptions.value.findIndex(s => s.name === sub.name)
  if (idx >= 0) subscriptions.value.splice(idx, 1)
  if (sub.service_location) invalidateCache(sub.service_location)
  invalidateAll()
  Notify.create({ type: 'positive', message: `${label} supprimé`, position: 'top', timeout: 2500 })
}

async function confirmDeleteSub (sub) {
  const label = sub.plan_name || sub.item_name || sub.name
  const contract = findLinkedContract(sub)
  const activeContract = contract && !CONTRACT_TERMINAL.has(contract.status) ? contract : null

  // Active contract → redirect to contract detail. All termination UX
  // (benefits table, refund column, penalty calc, invoice creation,
  // cascade cancel) lives there.
  if (activeContract) {
    $q.dialog({
      title: 'Contrat actif',
      message: `
        <div><code>${sub.name}</code> — ${label}</div>
        <div class="q-mt-sm q-pa-sm" style="background:#fef3c7;border-left:3px solid #d97706;border-radius:4px">
          <div class="text-weight-bold">Ce service est sous contrat <code>${activeContract.name}</code></div>
          <div class="text-caption text-grey-8 q-mt-xs">
            ${activeContract.contract_type || ''} — du ${formatDate(activeContract.start_date) || '—'}
            au ${formatDate(activeContract.end_date) || '—'}
          </div>
        </div>
        <div class="text-caption q-mt-md">
          Pour supprimer ce service il faut d'abord résilier le contrat
          (les avantages accordés, déjà reconnus, et à rembourser
          sont affichés sur la fiche du contrat).
        </div>
      `,
      html: true,
      cancel: { flat: true, label: 'Annuler' },
      ok: { color: 'primary', label: 'Voir le contrat', unelevated: true, icon: 'handshake' },
      persistent: false,
    }).onOk(() => {
      openModal('Service Contract', activeContract.name, 'Contrat ' + activeContract.name)
    })
    return
  }

  // No contract OR terminal-state contract (link is stale) → plain delete.
  $q.dialog({
    title: 'Supprimer ce service ?',
    message: `<div><code>${sub.name}</code> — ${label} (${formatMoney(sub.actual_price)})</div>`
      + (contract
          ? `<div class="text-caption text-grey-7 q-mt-sm">Le contrat <code>${contract.name}</code> (${contract.status}) sera délié.</div>`
          : '')
      + '<div class="text-caption text-grey-6 q-mt-sm">L\'historique de facturation reste intact.</div>',
    html: true,
    cancel: { flat: true, label: 'Annuler' },
    ok: { color: 'red', label: 'Supprimer', unelevated: true, icon: 'delete' },
    persistent: true,
  }).onOk(async () => {
    try {
      if (contract) await unlinkContractFromSub(contract)
      await rawDeleteSub(sub, label)
    } catch (e) {
      Notify.create({ type: 'negative', message: 'Erreur: ' + (e.message || 'Suppression impossible'), position: 'top', timeout: 4000 })
    }
  })
}

function onDispatchCreated (job) { modalDispatchJobs.value.push(job) }

const locCollapsed = reactive({})
const locHasSubs = (locName) => subscriptions.value.some(s => s.service_location === locName)
const toggleLocCollapse = (locName) => { locCollapsed[locName] = locCollapsed[locName] === false }
const sortedLocations = computed(() => {
  const withSubs = locations.value.filter(l => locHasSubs(l.name))
  const withoutSubs = locations.value.filter(l => !locHasSubs(l.name))
  return [...withSubs, ...withoutSubs]
})
// Adresses sans abonnement : masquées par défaut derrière un seul « Voir adresses inactives (N) » pour réduire le bruit.
const showInactiveLocs = ref(false)
const inactiveLocCount = computed(() => locations.value.filter(l => !locHasSubs(l.name)).length)
const visibleLocations = computed(() => showInactiveLocs.value ? sortedLocations.value : sortedLocations.value.filter(l => locHasSubs(l.name)))

const deletingLoc = ref(null)
function confirmDeleteLocation (loc) {
  if (locHasSubs(loc.name)) return
  $q.dialog({
    title: 'Supprimer cette adresse ?',
    message: `${loc.address_line || loc.name} sera supprimee definitivement.`,
    cancel: { flat: true, label: 'Annuler' },
    ok: { color: 'negative', label: 'Supprimer', flat: true },
    persistent: true,
  }).onOk(async () => {
    deletingLoc.value = loc.name
    try {
      await deleteDoc('Service Location', loc.name)
      locations.value = locations.value.filter(l => l.name !== loc.name)
      Notify.create({ type: 'positive', message: 'Adresse supprimee' })
    } catch (e) {
      Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
    } finally {
      deletingLoc.value = null
    }
  })
}

function onEntityDeleted (docName) {
  equipment.value = equipment.value.filter(e => e.name !== docName)
  invoices.value = invoices.value.filter(i => i.name !== docName)
}

// Fired from the Service Contract detail modal after a successful
// /contract/terminate. We reflect the cascade locally so the user
// doesn't need to refresh: the sub row flips to Annulé, the contract
// row flips to Résilié, and the fresh termination invoice pops into
// the invoice list if it's within the 5-latest window.
function onContractTerminated (payload) {
  const today = new Date().toISOString().slice(0, 10)
  const contractRow = serviceContracts.value.find(c => c.name === payload.contract)
  if (contractRow) {
    contractRow.status = 'Résilié'
    contractRow.service_subscription = ''
  }
  if (payload.service_subscription) {
    const subRow = subscriptions.value.find(s => s.name === payload.service_subscription)
    if (subRow) {
      subRow.status = 'Cancelled'
      subRow.cancelation_date = today
      if (!subRow.end_date) subRow.end_date = today
      if (subRow.service_location) invalidateCache(subRow.service_location)
    }
  }
  if (payload.termination_invoice) {
    // Drop an audit line on the customer so the termination shows
    // up in the notes feed alongside the fresh invoice.
    authFetch(BASE_URL + '/api/resource/Comment', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        doctype: 'Comment', comment_type: 'Comment',
        reference_doctype: 'Customer', reference_name: customer.value?.name,
        content: `Contrat ${payload.contract} résilié — facture ${payload.termination_invoice} (${formatMoney(payload.termination_fee_total)})`,
      }),
    }).then(r => r.ok && r.json()).then(d => d?.data && comments.value.unshift(d.data)).catch(() => {})
  }
}

const sectionsOpen = ref({ ...defaultSectionsOpen })

// ── Disposition des modules de la fiche 360, PAR UTILISATEUR (ordre + masquage) ──────────────
// Réordonnable/masquable via le dialogue « Personnaliser » ; persistée (localStorage + hub /prefs).
const { prefs: c360Prefs, save: saveC360Prefs } = useUserPrefs('client360', {
  order: CLIENT_360_MODULES.map(m => m.key), hidden: [],
})
// Ordre effectif : préf validée contre les modules connus (clés inconnues retirées, nouvelles ajoutées en fin).
const orderedModuleKeys = computed(() => {
  const known = CLIENT_360_MODULES.map(m => m.key)
  const saved = (c360Prefs.value.order || []).filter(k => known.includes(k))
  return [...saved, ...known.filter(k => !saved.includes(k))]
})
function moduleStyle (key) { return { order: orderedModuleKeys.value.indexOf(key) } }
function moduleVisible (key) { return !(c360Prefs.value.hidden || []).includes(key) }

const customizeOpen = ref(false)
const draftModules = ref([])   // [{ key, label, icon, visible }] — brouillon édité dans le dialogue
function openCustomize () {
  draftModules.value = orderedModuleKeys.value.map(k => {
    const m = CLIENT_360_MODULES.find(x => x.key === k) || { key: k, label: k, icon: 'widgets' }
    return { ...m, visible: moduleVisible(k) }
  })
  customizeOpen.value = true
}
function saveCustomize () {
  saveC360Prefs({
    order: draftModules.value.map(m => m.key),
    hidden: draftModules.value.filter(m => !m.visible).map(m => m.key),
  })
  customizeOpen.value = false
  Notify.create({ type: 'positive', message: 'Disposition enregistrée pour vous', position: 'top' })
}
function resetCustomize () {
  draftModules.value = CLIENT_360_MODULES.map(m => ({ ...m, visible: true }))
}
// Communications unifiées (courriel + SMS) du client — réutilise le panneau global flottant (singleton)
const { openDiscussion, panelOpen, fetchForCustomer, openComposeDraft } = useConversations()
const commsDiscussions = ref([])
async function loadComms () {
  const c = customer.value; if (!c) return
  commsDiscussions.value = await fetchForCustomer({ customer: c.name, email: c.email_id || c.email_billing || '', phone: c.cell_phone || c.tel_home || '' })
}
function openComms (d) { openDiscussion(d); panelOpen.value = true }
// Interventions (Dispatch Jobs) du client → agrégées dans la vue 360
const jobs = ref([])
async function loadJobs () {
  const c = customer.value; if (!c || !c.name) { jobs.value = []; return }
  try { jobs.value = await listDocs('Dispatch Job', { filters: { customer: c.name }, fields: ['name', 'subject', 'status', 'scheduled_date', 'job_type', 'assigned_tech'], limit: 50, orderBy: 'modified desc' }) || [] } catch (e) { jobs.value = [] }
}

// ── « Trouver un créneau » DIRECT depuis la fiche client (appel entrant / réparation) ──
// Pré-remplit l'adresse du lieu de service principal + « réparation », puis le créneau choisi crée un Dispatch Job lié au client.
const findSlotOpen = ref(false)
const findSlotAddr = ref('')
const findSlotSkill = ref('')
const availReasonOpen = ref(false)
const createJobOpen = ref(false)
const createJobCtx = ref({})
// Chips de compétence dérivés de la SOURCE UNIQUE (config/departments.js) — plus de liste codée en dur.
const SKILL_COLORS = { installation: '#f59e0b', 'réparation': '#ef4444', tv: '#8b5cf6', telephone: '#0ea5e9', 'épissure': '#10b981', monteur: '#6366f1' }
const slotSkills = DEPARTMENTS.filter(d => (d.skills || []).length).map(d => ({ skill: d.skills[0], dur: d.dur || 1, color: SKILL_COLORS[d.skills[0]] || '#64748b' }))
function primaryLocation () {
  const list = (sortedLocations.value && sortedLocations.value.length) ? sortedLocations.value : locations.value
  return (list || []).find(l => locHasSubs(l.name)) || (list || [])[0] || null
}
function openFindSlot () {
  const loc = primaryLocation()
  findSlotAddr.value = (loc && (loc.address_line || loc.address_full)) || ''
  findSlotSkill.value = 'réparation' // appel client = réparation le plus souvent ; l'agent peut changer la compétence
  findSlotOpen.value = true
}
// « Dispo par motif » : pré-remplit l'adresse du lieu de service puis ouvre le widget raison→compétence→dispo.
function openAvailReason () {
  const loc = primaryLocation()
  findSlotAddr.value = (loc && (loc.address_line || loc.address_full)) || ''
  availReasonOpen.value = true
}
// Le widget a résolu une compétence → enchaîne sur la recherche de créneau, pré-remplie (compétence + adresse).
function onAvailBook (b) {
  findSlotSkill.value = b.primary || ''
  findSlotAddr.value = b.address || findSlotAddr.value
  findSlotOpen.value = true
}
function onFindSlotSelected (slot) {
  const loc = primaryLocation()
  createJobCtx.value = {
    customer: customer.value?.name,
    service_location: loc ? loc.name : '',
    assigned_tech: slot.tech_id, scheduled_date: slot.date, start_time: slot.start_time,
    address: slot._address || findSlotAddr.value || '', duration_h: slot._duration || 1,
    latitude: slot._latitude, longitude: slot._longitude,
    tags: slot._skill ? [slot._skill] : [],
  }
  createJobOpen.value = true
}
async function onJobCreated () { createJobOpen.value = false; await loadJobs(); $q.notify({ type: 'positive', icon: 'add_task', message: 'Intervention planifiée pour le client', timeout: 2600 }) }
// Journal ERPNext : appels (Communication medium=Phone) + messages loggés → réintégrés à la fiche (ex-ChatterPanel).
const callLog = ref([])
async function loadCallLog () {
  const c = customer.value; if (!c || !c.name) { callLog.value = []; return }
  try { callLog.value = await listDocs('Communication', { filters: { reference_doctype: 'Customer', reference_name: c.name }, fields: ['name', 'subject', 'content', 'communication_medium', 'sent_or_received', 'phone_no', 'creation'], limit: 40, orderBy: 'creation desc' }) || [] } catch (e) { callLog.value = [] }
}
function logIcon (m) { return m === 'Phone' ? 'call' : m === 'SMS' ? 'sms' : m === 'Email' ? 'mail' : 'forum' }
function logSnippet (m) { return String(m.content || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 120) }
// Click-to-call : lien tel: (le softphone 3CX du poste prend l'appel) — NA → +1 si 10 chiffres.
// Évaluations laissées par CE client (étoiles + commentaire) → en tête de fiche (lien fiche garanti côté hub).
const custRatings = ref([])
const latestRating = computed(() => custRatings.value[0] || null)
async function loadRatings () {
  const c = customer.value; if (!c || !c.name) return
  try { const r = await fetch(`${HUB_URL}/collab/ratings?customer=${encodeURIComponent(c.name)}`); const d = r.ok ? await r.json() : {}; custRatings.value = d.ratings || [] } catch (e) { custRatings.value = [] }
}
// Pas d'évaluation → ouvrir un BROUILLON d'invitation (courriel/texto) pré-rempli dans la fenêtre Compose, langue du client.
async function draftRatingInvite (channel) {
  const id = customer.value && customer.value.name; if (!id) return
  try {
    const r = await fetch(`${HUB_URL}/collab/rating-invite-draft?customer=${encodeURIComponent(id)}&channel=${channel}`)
    const d = r.ok ? await r.json() : {}
    if (!d.ok || !d.prefill) { $q.notify({ type: 'negative', message: d.error || 'Impossible de préparer le brouillon' }); return }
    openComposeDraft(d.prefill)
  } catch (e) { $q.notify({ type: 'negative', message: e.message }) }
}
function commsTitle (d) {
  if (d.channel === 'email') return (d.conversations && d.conversations[0] && d.conversations[0].subject) || d.subject || '(courriel sans objet)'
  return 'SMS' + (d.phone ? ' · ' + d.phone : '')
}
function commsSnippet (d) {
  const msgs = d.messages || []
  const last = msgs.length ? msgs[msgs.length - 1] : (d.lastMessage || null)
  const t = last ? String(last.text || '').replace(/^✉️[^\n]*\n+/, '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim() : ''
  return t.slice(0, 120) || '—'
}

const openTicketCount = computed(() => tickets.value.filter(t => t.status === 'Open').length)
// Affiche 5 tickets par défaut ; « Voir tous » révèle le reste (déjà chargés) ou les recharge tous.
const visibleTickets = computed(() => ticketsExpanded.value ? tickets.value : tickets.value.slice(0, 5))
// Inscrire un paiement à l'avance / non alloué (SANS facture) — dialogue NATIF (hub /payments/record-on-account),
// remplace l'ancien lien vers le formulaire Payment Entry d'ERPNext desk.
const onAcctDlg = reactive({ open: false, amount: 0, mode: 'Cheque', referenceNo: '', loading: false, submitting: false, preview: null, error: '' })
function recordPayment () {
  if (!customer.value) return
  onAcctDlg.amount = 0; onAcctDlg.mode = 'Cheque'; onAcctDlg.referenceNo = ''; onAcctDlg.preview = null; onAcctDlg.error = ''
  onAcctDlg.open = true
}
async function onAcctReq (preview) {
  return fetch(`${HUB_URL}/payments/record-on-account`, {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ customer: customer.value.name, amount: Number(onAcctDlg.amount) || undefined, mode_of_payment: onAcctDlg.mode, reference_no: onAcctDlg.referenceNo || undefined, preview: !!preview }),
  }).then(r => r.json().then(d => ({ ok: r.ok, d })))
}
async function onAcctPreview () {
  if (!(onAcctDlg.amount > 0)) { onAcctDlg.preview = null; return }
  onAcctDlg.loading = true; onAcctDlg.error = ''
  try { const { ok, d } = await onAcctReq(true); if (!ok || d.error) { onAcctDlg.error = d.error || 'Erreur'; onAcctDlg.preview = null } else onAcctDlg.preview = d }
  catch (e) { onAcctDlg.error = e.message } finally { onAcctDlg.loading = false }
}
async function onAcctConfirm () {
  onAcctDlg.submitting = true; onAcctDlg.error = ''
  try {
    const { ok, d } = await onAcctReq(false)
    if (!ok || d.error) { onAcctDlg.error = d.error || 'Échec de l\'enregistrement' }
    else { $q.notify({ type: 'positive', message: `Paiement de ${formatMoney(d.paid_amount)} enregistré (${d.name})` }); onAcctDlg.open = false; loadCustomer(props.id) }
  } catch (e) { onAcctDlg.error = e.message } finally { onAcctDlg.submitting = false }
}

// « Enregistrer un paiement » par facture (modèle Odoo, sans quitter l'app) : aperçu ERPNext → confirmation → écriture.
const payDlg = reactive({ open: false, invoice: '', outstanding: 0, amount: 0, referenceNo: '', loading: false, submitting: false, preview: null, error: '' })
function openPayInvoice (inv) {
  payDlg.invoice = inv.name; payDlg.outstanding = inv.outstanding_amount || 0; payDlg.amount = inv.outstanding_amount || 0
  payDlg.referenceNo = ''; payDlg.preview = null; payDlg.error = ''; payDlg.open = true
  payInvoicePreview()
}
async function payInvoiceReq (preview) {
  return fetch(`${HUB_URL}/payments/record-invoice`, {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ invoice: payDlg.invoice, amount: Number(payDlg.amount) || undefined, reference_no: payDlg.referenceNo || undefined, preview: !!preview }),
  }).then(r => r.json().then(d => ({ ok: r.ok, d })))
}
async function payInvoicePreview () {
  if (!(payDlg.amount > 0)) { payDlg.preview = null; return }
  payDlg.loading = true; payDlg.error = ''
  try { const { ok, d } = await payInvoiceReq(true); if (!ok || d.error) { payDlg.error = d.error || 'Erreur'; payDlg.preview = null } else payDlg.preview = d }
  catch (e) { payDlg.error = e.message } finally { payDlg.loading = false }
}
async function payInvoiceConfirm () {
  payDlg.submitting = true; payDlg.error = ''
  try {
    const { ok, d } = await payInvoiceReq(false)
    if (!ok || d.error) { payDlg.error = d.error || 'Échec de l\'enregistrement' }
    else { $q.notify({ type: 'positive', message: `Paiement de ${formatMoney(d.paid_amount)} enregistré (${d.name})` }); payDlg.open = false; loadCustomer(props.id) }
  } catch (e) { payDlg.error = e.message } finally { payDlg.submitting = false }
}
const totalOutstanding = computed(() => accountBalance.value?.balance ?? 0)

// ── VUE 360 : synthèse (KPI) + activité récente agrégée (comms + tickets), depuis les données déjà chargées ──
const activeSubsCount = computed(() => (subscriptions.value || []).filter(s => (s.status || 'Active') === 'Active').length)
const kpi360 = computed(() => [
  { key: 'comms', icon: 'forum', color: 'primary', label: 'Communications', value: commsDiscussions.value.length },
  { key: 'tickets', icon: 'confirmation_number', color: 'teal-7', label: 'Tickets ouverts', value: openTicketCount.value },
  { key: 'jobs', icon: 'local_shipping', color: 'deep-purple-5', label: 'Interventions', value: jobs.value.length },
  { key: 'subs', icon: 'router', color: 'blue-6', label: 'Abonnements actifs', value: activeSubsCount.value },
  { key: 'balance', icon: 'account_balance_wallet', color: totalOutstanding.value > 0 ? 'red-6' : 'green-7', label: 'Solde', value: formatMoney(totalOutstanding.value) },
])
const recentOpen = ref(false)   // Journal d'activité (droite) : dernier seulement, « Voir N de plus / Réduire »
const msgOpen = ref(false)       // Messagerie (droite) : dernière conversation seulement
const notesOpen = ref(false)     // Notes internes (droite) : dernière note seulement
// Journal = tickets + interventions + appels/comms loggées (Communication ERPNext). Les courriels/SMS vivent dans le panneau « Messagerie » → PAS re-dupliqués ici.
const activityLog = computed(() => {
  const items = []
  for (const t of (tickets.value || [])) items.push({ key: 't:' + t.name, icon: 'confirmation_number', color: t.status === 'Open' ? 'orange-7' : 'teal-7', label: t.subject || t.name, sub: 'Ticket · ' + (t.status || ''), date: t.modified || t.creation || '', act: () => openModal('Issue', t.name, t.subject) })
  for (const j of (jobs.value || [])) items.push({ key: 'j:' + j.name, icon: 'local_shipping', color: 'deep-purple-5', label: j.subject || j.job_type || j.name, sub: 'Intervention · ' + (j.status || ''), date: j.scheduled_date || j.modified || j.creation || '', act: () => openModal('Dispatch Job', j.name, j.subject) })
  for (const m of (callLog.value || [])) items.push({ key: 'm:' + m.name, icon: logIcon(m.communication_medium), color: m.communication_medium === 'Phone' ? 'green-7' : 'blue-grey-6', label: m.subject || m.communication_medium || 'Communication', sub: (m.sent_or_received === 'Received' ? '↙ reçu' : '↗ envoyé') + ' · ' + logSnippet(m), date: m.creation || '', act: () => openModal('Communication', m.name, m.subject) })
  return items.filter(x => x.date).sort((a, b) => String(b.date).localeCompare(String(a.date))).slice(0, 20)
})

const customerPhoneOptions = computed(() => {
  if (!customer.value) return []
  return Object.entries(phoneLabelMap)
    .filter(([k]) => customer.value[k])
    .map(([k, label]) => ({ label: `${label}: ${customer.value[k]}`, value: customer.value[k] }))
})

const locEquip = (locName) => equipment.value.filter(e => e.service_location === locName)
const locTickets = (locName) => tickets.value.filter(t => t.service_location === locName)

async function openPdf (name) {
  try {
    const res = await authFetch(erpPdfUrl(name))
    if (!res.ok) throw new Error('PDF error ' + res.status)
    const blob = await res.blob()
    const url = URL.createObjectURL(blob)
    window.open(url, '_blank')
    setTimeout(() => URL.revokeObjectURL(url), 60000)
  } catch (e) {
    Notify.create({ type: 'negative', message: 'Erreur PDF : ' + e.message })
  }
}

const newTicketOpen = ref(false)
const ticketContext = ref({})

const newQuotationOpen = ref(false)
const wizardDeliveryAddressId = ref('')
const quotationCustomerContext = computed(() => {
  if (!customer.value) return null
  const primaryLoc = sortedLocations.value?.find(l => locHasSubs(l.name)) || sortedLocations.value?.[0]
  return {
    name: customer.value.name,
    customer_name: customer.value.customer_name,
    cell_phone: customer.value.cell_phone,
    tel_home: customer.value.tel_home,
    email_billing: customer.value.email_billing,
    email_id: customer.value.email_id,
    service_location: wizardDeliveryAddressId.value || primaryLoc?.name || '',
  }
})

function openNewQuotation () {
  wizardDeliveryAddressId.value = ''
  newQuotationOpen.value = true
}

function openWizardForAddress (loc) {
  wizardDeliveryAddressId.value = loc?.name || ''
  newQuotationOpen.value = true
}

function onQuotationPublished () {
  loadCustomer?.()
}

function openNewTicket (loc = null, sub = null) {
  const subLabel = sub ? (sub.custom_description || sub.item_name || sub.name) : ''
  const locLabel = loc ? (loc.address_line || loc.location_name || '') : ''
  ticketContext.value = {
    customer: customer.value?.name || '',
    subject: sub ? `${subLabel} — ${locLabel}`.trim().replace(/ — $/, '') : '',
    service_location: loc?.name || null,
    locationLabel: loc ? (loc.address_line || loc.location_name || '') : '',
    subscription: sub?.name || '',
  }
  newTicketOpen.value = true
}

function onTicketCreated (doc) {
  tickets.value.unshift({
    name: doc.name, subject: doc.subject, status: 'Open',
    priority: doc.priority, opening_date: new Date().toISOString().slice(0, 10),
    service_location: doc.service_location || '', issue_type: doc.issue_type || '',
  })
  openModal('Issue', doc.name, doc.subject)
}

// Service Contract status → chip color. Matches ERPNext status values.
function contractStatusColor (status) {
  switch (status) {
    case 'Actif': return 'green-6'
    case 'Envoyé': return 'orange-6'
    case 'Brouillon': return 'blue-grey-5'
    case 'Résilié': return 'red-6'
    case 'Complété': return 'primary'
    case 'Expiré': return 'grey-5'
    default: return 'grey-5'
  }
}

const newInvoiceOpen = ref(false)

function onInvoiceCreated (doc) {
  invoices.value.unshift({
    name: doc.name,
    posting_date: doc.posting_date,
    due_date: doc.due_date,
    grand_total: doc.grand_total || 0,
    outstanding_amount: doc.outstanding_amount || doc.grand_total || 0,
    status: doc.status || 'Draft',
    is_return: doc.is_return || 0,
    return_against: doc.return_against || '',
  })
  openModal('Sales Invoice', doc.name, 'Facture ' + doc.name)
}

function confirmRefund (pe) {
  $q.dialog({
    title: 'Rembourser ce paiement ?',
    message: `${pe.name} — ${formatMoney(pe.paid_amount)} (${pe.reference_no})\nCe remboursement sera envoyé via Stripe et enregistré dans ERPNext.`,
    cancel: { flat: true, label: 'Annuler' },
    ok: { color: 'orange-8', label: 'Rembourser', flat: true },
    persistent: true,
  }).onOk(async () => {
    const res = await refundPayment(pe.name)
    if (res?.ok) {
      // Refresh payments list
      loadCustomer(props.id)
    }
  })
}

// Navigation vers une AUTRE fiche (même composant réutilisé, seul :id change) : recharger TOUT ce que onMounted charge,
// sinon les panneaux chargés séparément (Messagerie, évaluations, interventions, journal d'appels) gardent les données
// du client PRÉCÉDENT (ex. LPB4 affichait les messages de Mme Merckel). On vide d'abord la Messagerie pour éviter le flash.
watch(() => props.id, async (newId) => {
  if (!newId) return
  commsDiscussions.value = []
  await loadCustomer(newId)
  focusLocationFromQuery(); loadComms(); loadRatings(); loadJobs(); loadCallLog()
})

// ?location=LOC-XXX query param: when arriving from a dispatch link
// we scroll to that Service Location and pulse-highlight it for 2 s
// so the rep doesn't have to find it among potentially many SLs.
const highlightLoc = ref(null)
const route = useRoute()
const router = useRouter()
async function focusLocationFromQuery () {
  const target = route.query?.location
  if (!target) return
  highlightLoc.value = target
  // Wait for the customer load to finish + DOM to render the cards.
  let tries = 0
  while (tries < 30 && !document.getElementById('loc-' + target)) {
    await new Promise(r => setTimeout(r, 100))
    tries++
  }
  const el = document.getElementById('loc-' + target)
  if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' })
  setTimeout(() => { highlightLoc.value = null }, 2200)
}

onMounted(async () => {
  await loadCustomer(props.id)
  focusLocationFromQuery()
  loadComms()
  loadRatings()
  loadJobs()
  loadCallLog()
})
watch(() => route.query?.location, () => focusLocationFromQuery())
</script>

<style lang="scss" scoped>
/* Zone des modules réordonnables : flex-column pilotée par la préf d'ordre (CSS `order` par module).
   Les modules restent en place dans le DOM ; seul `order` change leur position visuelle. */
.fiche-modules { display: flex; flex-direction: column; }
/* Ligne de module dans le dialogue « Personnaliser » */
.mod-row { border: 1px solid var(--ops-border, #e2e8f0); border-radius: 8px; background: var(--ops-surface, #fff); }
.mod-row .mod-handle { cursor: grab; }
.mod-row.mod-hidden { opacity: 0.5; }
.sortable-ghost { opacity: 0.4; }

/* Vue 360 : synthèse KPI + activité récente */
.c360 { display: flex; gap: 14px; flex-wrap: wrap; margin: 12px 0 6px; }
.c360-kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: 10px; flex: 1 1 400px; }
.c360-kpi { background: #fff; border: 1px solid #e9edf3; border-radius: 12px; padding: 12px 14px; display: flex; flex-direction: column; gap: 2px; box-shadow: 0 1px 3px rgba(15,23,42,.04); }
.c360-kpi-val { font-size: 1.4rem; font-weight: 700; color: #1e293b; line-height: 1.15; }
.c360-kpi-lbl { font-size: .72rem; color: #64748b; }
@media (max-width: 600px) { .c360-kpis { grid-template-columns: repeat(2, 1fr); } }

.section-title { font-weight: 700; color: var(--ops-text); display: flex; align-items: center; }
.kpi-label { font-size: 0.7rem; color: var(--ops-text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
.info-grid { display: flex; flex-direction: column; gap: 6px; }
.info-row { display: flex; align-items: center; gap: 8px; font-size: 0.875rem; }
.info-label { font-size: 0.75rem; font-weight: 600; color: var(--ops-text-muted); min-width: 80px; }
.editable-row .q-field { flex: 1; min-width: 0; }
.editable-row .q-field :deep(.q-field__control) { height: 24px; min-height: 24px; }
.editable-row .q-field :deep(.q-field__native),
.editable-row .q-field :deep(.q-field__input) { padding: 0; min-height: 24px; font-size: 0.875rem; }
.editable-input { font-size: 0.875rem !important; }
.editable-row .q-field :deep(.q-field__native):hover,
.editable-row .q-field :deep(.q-field__input):hover { background: #f1f5f9; border-radius: 4px; }
.editable-row .q-field :deep(.q-field__native):focus,
.editable-row .q-field :deep(.q-field__input):focus { background: #e0f2fe; border-radius: 4px; }

.loc-card { border-left: 3px solid var(--ops-accent); transition: box-shadow 0.3s, border-left-color 0.3s; }
.loc-card.loc-inactive { border-left-color: #cbd5e1; opacity: 0.7; }
.loc-card.loc-inactive:hover { opacity: 0.9; }
/* loc-highlight: 2-second pulse when the page is opened from a
   dispatch link with ?location=LOC-XXX, so the rep visually finds
   the right Service Location among several. */
.loc-card.loc-highlight {
  box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.45), 0 4px 18px rgba(99, 102, 241, 0.2);
  border-left-color: #6366f1;
  animation: loc-flash 1.4s ease-out;
}
@keyframes loc-flash {
  0%   { box-shadow: 0 0 0 6px rgba(99, 102, 241, 0.6), 0 6px 26px rgba(99, 102, 241, 0.4); }
  100% { box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.45), 0 4px 18px rgba(99, 102, 241, 0.2); }
}
.loc-header { padding-bottom: 8px; border-bottom: 1px solid var(--ops-border); }
.info-block { background: #f8fafc; border-radius: 8px; padding: 10px 12px; }
.info-block-title { font-size: 0.75rem; font-weight: 700; color: var(--ops-text-muted); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 6px; }

.sub-row, .equip-row { padding: 6px 0; &:not(:last-child) { border-bottom: 1px solid #e2e8f0; } }
.sub-total-line { display: flex; align-items: center; justify-content: flex-end; gap: 12px; padding: 8px 0 4px; margin-top: 4px; border-top: 2px solid #334155; }
.sub-total-label { font-size: 0.85rem; font-weight: 700; text-transform: uppercase; color: #334155; }
.sub-total-amount { font-size: 1rem; font-weight: 800; color: var(--ops-accent); min-width: 100px; text-align: right; }
.sub-total-line.annual { border-top: 2px solid #92400e; .sub-total-label { color: #92400e; } .sub-total-amount { color: #92400e; } }
.sub-total-neg { color: #dc2626 !important; }
.sub-total-warning { display: flex; align-items: center; justify-content: flex-end; gap: 4px; font-size: 0.78rem; color: #b91c1c; padding: 2px 0 6px; text-align: right; }
.sub-annual-divider { display: flex; align-items: center; gap: 6px; margin-top: 16px; padding: 8px 0 4px; border-top: 1px dashed #d97706; font-size: 0.8rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; color: #92400e; }
.sub-section-header { display: flex; align-items: center; gap: 4px; padding: 6px 0 4px; cursor: pointer; user-select: none; &:hover { background: #f8fafc; } }
.sub-section-title { font-size: 0.85rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; color: #334155; }
.sub-section-count { font-size: 0.75rem; color: #94a3b8; }
.sub-section-subtotal { margin-left: auto; font-size: 0.8rem; font-weight: 700; color: var(--ops-accent); }
.sub-ghost { opacity: 0.4; background: #e0f2fe; border-radius: 6px; }
.drag-handle:hover { color: #64748b !important; }
/* Rebate rows: nested visually under the service above via stronger indent +
   smaller type. The left border + arrow-glyph treatment mirrors what shows up
   on the invoice PDF so dispatchers can preview the print order. */
.sub-rebate {
  padding-left: 32px; border-left: 2px solid #fca5a5; margin-left: 12px;
  background: #fef2f2; border-radius: 0 6px 6px 0;
  font-size: 0.85rem;
  position: relative;
}
.sub-rebate::before {
  content: '↳'; position: absolute; left: 12px; top: 6px;
  font-size: 0.75rem; color: #ef4444; opacity: 0.7;
}
.sub-rebate .sub-sku { font-size: 0.55rem; width: 64px; min-width: 64px; max-width: 64px; }
.sub-rebate .sub-price { font-size: 0.8rem; }
.sub-cancelled { opacity: 0.5; text-decoration: line-through; }
.sub-actions { display: flex; gap: 2px; opacity: 0; transition: opacity 0.15s; }
.sub-row:hover .sub-actions { opacity: 1; }
.sub-sku { font-size: 0.6rem; font-weight: 400; color: #94a3b8; background: none; padding: 0; white-space: nowrap; display: inline-block; width: 72px; min-width: 72px; max-width: 72px; text-align: left; overflow: hidden; text-overflow: ellipsis; }
.sub-freq { font-size: 0.65rem; font-weight: 700; color: #9ca3af; &.annual { color: #92400e; background: #fef3c7; } background: #f1f5f9; border-radius: 3px; padding: 1px 4px; }
.sub-price { font-size: 0.85rem; white-space: nowrap; }
/* InlineField wraps the price in a <span>/<input>; force children to inherit
   the row-level color so `.text-red` on the display span works for rebates
   and the edit input picks up the same accent. */
.sub-price-wrap { display: inline-flex; align-items: center; }
.sub-price-wrap :deep(input) { color: inherit; text-align: right; max-width: 80px; }
.sub-rebate .sub-price-wrap, .sub-rebate .sub-price-wrap :deep(*) { color: #dc2626; }
.sub-meta { display: flex; align-items: center; gap: 2px; }
.ellipsis-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ticket-row { padding: 4px 0; &:not(:last-child) { border-bottom: 1px solid #e2e8f0; } }
.memo-row { padding: 10px 0; &:not(:last-child) { border-bottom: 1px solid var(--ops-border); } }

.notes-panel { position: sticky; top: 16px; background: #fff; border: 1px solid var(--ops-border, #e2e8f0); border-radius: 12px; display: flex; flex-direction: column; max-height: calc(100vh - 100px); }
.notes-panel-header { padding: 16px 16px 12px; border-bottom: 1px solid var(--ops-border, #e2e8f0); display: flex; align-items: center; }
.note-input-wrap { padding: 12px 14px; border-bottom: 1px solid var(--ops-border, #e2e8f0); }
.note-input-placeholder { padding: 10px 12px; background: #f8fafc; border-radius: 8px; color: #9ca3af; font-size: 0.875rem; cursor: text; }
.notes-list { overflow-y: auto; flex: 1; }
.note-item { padding: 14px 14px 12px; border-bottom: 1px solid #f1f5f9; }
.note-item:last-child { border-bottom: none; }
.note-item:hover .note-menu-btn { opacity: 1; }
.note-sticky { background: #fffbeb; border-left: 3px solid #f59e0b; }
.note-header { display: flex; align-items: flex-start; gap: 10px; margin-bottom: 8px; }
.note-avatar { width: 32px; height: 32px; border-radius: 50%; background: #f1f5f9; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.note-menu-btn { opacity: 0; transition: opacity 0.15s; }
.note-content { font-size: 0.875rem; line-height: 1.55; color: #374151; word-break: break-word; padding-left: 42px; }

:deep(.section-header) { padding: 8px 0; min-height: unset; background: transparent; border-bottom: 1px solid var(--ops-border, #e2e8f0); }
code { background: #e2e8f0; padding: 1px 4px; border-radius: 3px; font-size: 0.8rem; }
.clickable-row { cursor: pointer; transition: background 0.1s; border-radius: 6px; padding-left: 4px; padding-right: 4px; margin-left: -4px; margin-right: -4px; &:hover { background: #eef2ff; } }
.ticket-subject-cell { white-space: normal !important; word-break: break-word; max-width: 0; width: 100%; }
.clickable-table :deep(thead th) { font-size: 11px; padding: 4px; }
.avatar-stack { display: flex; align-items: center; justify-content: center; }
.avatar-stack .avatar-chip { color: #fff; font-size: 9px; font-weight: 600; border: 1.5px solid #fff; margin-left: -8px; }
.avatar-stack .avatar-chip:first-child { margin-left: 0; }
.device-strip { display: flex; flex-wrap: wrap; gap: 6px; }
.device-icon-chip {
  display: flex; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 8px; cursor: pointer; transition: transform 0.15s; position: relative;
  &:hover { transform: scale(1.1); }
  &.small { width: 28px; height: 28px; border-radius: 6px; flex-shrink: 0; }
  &.dev-green { background: #dcfce7; color: #16a34a; }
  &.dev-red { background: #fee2e2; color: #dc2626; }
  &.dev-grey { background: #f1f5f9; color: #94a3b8; }
}
.acs-dot { position: absolute; top: 2px; right: 2px; width: 8px; height: 8px; border-radius: 50%; border: 1.5px solid #fff; }
.acs-online { background: #22c55e; box-shadow: 0 0 4px #22c55e88; }
.acs-offline { background: #ef4444; box-shadow: 0 0 4px #ef444488; }
.acs-probing { background: #f59e0b; animation: acs-pulse 1.2s ease-in-out infinite; }
@keyframes acs-pulse {
  0%, 100% { opacity: 1; box-shadow: 0 0 4px #f59e0b88; }
  50% { opacity: 0.3; box-shadow: 0 0 8px #f59e0bcc; }
}
.device-add-chip {
  background: #f1f5f9; color: #94a3b8; border: 2px dashed #cbd5e1;
  &:hover { border-color: #6366f1; color: #6366f1; background: #eef2ff; }
}
</style>
