Compare commits
No commits in common. "d4a5644e372c3e5f97fdc2c51d437c7f27bb77c7" and "dc28437d5f850d417c1be24caaf7d96c0fb71653" have entirely different histories.
d4a5644e37
...
dc28437d5f
|
|
@ -1,401 +0,0 @@
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
:id="'loc-' + loc.name"
|
|
||||||
class="ops-card q-mb-md loc-card"
|
|
||||||
:class="{ 'loc-inactive': !locHasSubs(loc.name), 'loc-highlight': highlightLoc === loc.name }">
|
|
||||||
<div class="loc-header" :style="!locHasSubs(loc.name) ? 'cursor:pointer' : ''"
|
|
||||||
@click="!locHasSubs(loc.name) && toggleLocCollapse(loc.name)">
|
|
||||||
<div class="row items-center">
|
|
||||||
<div v-if="!locHasSubs(loc.name)" class="col-auto q-mr-xs">
|
|
||||||
<q-icon :name="locCollapsed[loc.name] === false ? 'expand_more' : 'chevron_right'" size="18px" color="grey-5" />
|
|
||||||
</div>
|
|
||||||
<div class="col">
|
|
||||||
<div class="text-subtitle1 text-weight-bold" :class="{ 'text-grey-6': !locHasSubs(loc.name) }">
|
|
||||||
<InlineField :value="loc.address_line" field="address_line" doctype="Service Location" :docname="loc.name"
|
|
||||||
placeholder="Adresse" @saved="v => loc.address_line = v.value" />
|
|
||||||
</div>
|
|
||||||
<div class="text-caption text-grey-6">
|
|
||||||
<template v-for="f in locInlineFields" :key="f.field">
|
|
||||||
<template v-if="f.prefix">{{ f.prefix }}</template>
|
|
||||||
<InlineField :value="loc[f.field]" :field="f.field" doctype="Service Location" :docname="loc.name"
|
|
||||||
:placeholder="f.placeholder" @saved="v => loc[f.field] = v.value" />
|
|
||||||
</template>
|
|
||||||
<template v-if="!locHasSubs(loc.name)"> · <em>Aucun abonnement</em></template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-auto q-gutter-x-sm">
|
|
||||||
<span v-if="locSubsMonthlyTotal(loc.name)" class="text-weight-bold" style="color:var(--ops-accent)">
|
|
||||||
{{ formatMoney(locSubsMonthlyTotal(loc.name)) }}/m
|
|
||||||
</span>
|
|
||||||
<span class="ops-badge" :class="locHasSubs(loc.name) ? locStatusClass(loc.status) : 'inactive'">
|
|
||||||
{{ locHasSubs(loc.name) ? loc.status : 'Inactif' }}
|
|
||||||
</span>
|
|
||||||
<q-btn v-if="!locHasSubs(loc.name) && can('delete_records')" flat round dense
|
|
||||||
icon="delete_outline" size="sm" color="negative" class="q-ml-xs"
|
|
||||||
@click.stop="confirmDeleteLocation(loc)" :loading="deletingLoc === loc.name">
|
|
||||||
<q-tooltip>Supprimer cette adresse</q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-show="locHasSubs(loc.name) || locCollapsed[loc.name] === false">
|
|
||||||
|
|
||||||
<div v-if="locEquip(loc.name).length || loc.connection_type" class="row items-center q-mt-sm q-mb-xs q-gutter-x-md">
|
|
||||||
<div class="text-caption text-grey-6">
|
|
||||||
<q-icon name="cable" size="14px" class="q-mr-xs" />
|
|
||||||
<InlineField :value="loc.connection_type" field="connection_type" doctype="Service Location" :docname="loc.name"
|
|
||||||
type="select" :options="['Fibre', 'Coax', 'DSL', 'Wireless', 'LTE']"
|
|
||||||
placeholder="Type" @saved="v => loc.connection_type = v.value" />
|
|
||||||
· OLT: <InlineField :value="loc.olt_port" field="olt_port" doctype="Service Location" :docname="loc.name"
|
|
||||||
placeholder="Port OLT" @saved="v => loc.olt_port = v.value" />
|
|
||||||
· <InlineField :value="loc.network_id" field="network_id" doctype="Service Location" :docname="loc.name"
|
|
||||||
placeholder="Network ID" @saved="v => loc.network_id = v.value" />
|
|
||||||
</div>
|
|
||||||
<DeviceStrip
|
|
||||||
:equipment="locEquip(loc.name)"
|
|
||||||
@open-device="eq => openModal('Service Equipment', eq.name, eq.equipment_type + (eq.brand ? ' — ' + eq.brand : ''))"
|
|
||||||
@add-submission="openWizardForAddress(loc)"
|
|
||||||
@add-service="openAddService(loc)"
|
|
||||||
@add-rebate="openAddService(loc, 'rabais')"
|
|
||||||
@add-equipment="openAddEquipment(loc)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="info-block subs-block">
|
|
||||||
<div class="info-block-title" style="display:flex;align-items:center">
|
|
||||||
Abonnements ({{ locSubs(loc.name).length }})
|
|
||||||
<q-btn flat dense round size="xs" icon="add" color="primary" class="q-ml-xs"
|
|
||||||
style="margin-top:-2px">
|
|
||||||
<q-tooltip>Ajouter un service</q-tooltip>
|
|
||||||
<q-menu anchor="bottom left" self="top left" :offset="[0, 4]">
|
|
||||||
<q-list dense style="min-width:220px">
|
|
||||||
<q-item clickable v-close-popup @click="openWizardForAddress(loc)">
|
|
||||||
<q-item-section avatar><q-icon name="description" size="18px" color="primary" /></q-item-section>
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>Nouvelle soumission</q-item-label>
|
|
||||||
<q-item-label caption>Wizard avec étapes</q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
<q-separator />
|
|
||||||
<q-item clickable v-close-popup @click="openAddService(loc)">
|
|
||||||
<q-item-section avatar><q-icon name="wifi" size="18px" color="info" /></q-item-section>
|
|
||||||
<q-item-section>Forfait / Service (direct)</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
<q-item clickable v-close-popup @click="openAddService(loc, 'rabais')">
|
|
||||||
<q-item-section avatar><q-icon name="sell" size="18px" color="negative" /></q-item-section>
|
|
||||||
<q-item-section>Rabais / Crédit</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
</q-list>
|
|
||||||
</q-menu>
|
|
||||||
</q-btn>
|
|
||||||
</div>
|
|
||||||
<div v-if="!locSubs(loc.name).length" class="text-caption text-grey-5">Aucun</div>
|
|
||||||
|
|
||||||
<template v-if="locSubsMonthly(loc.name).length">
|
|
||||||
<template v-for="section in locSubsSections(loc.name, 'M')" :key="'m-'+section.key">
|
|
||||||
<div class="sub-section-header" @click="toggleSection(loc.name, section.key)">
|
|
||||||
<q-icon :name="sectionOpen(loc.name, section.key) ? 'expand_more' : 'chevron_right'" size="18px" />
|
|
||||||
<q-icon :name="section.icon" size="16px" class="text-grey-6 q-mr-xs" />
|
|
||||||
<span class="sub-section-title">{{ section.label }}</span>
|
|
||||||
<span class="sub-section-count">({{ section.items.length }})</span>
|
|
||||||
<span class="sub-section-subtotal">{{ formatMoney(sectionTotal(section.items)) }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-show="sectionOpen(loc.name, section.key)">
|
|
||||||
<draggable :list="section.items" :group="{ name: 'loc-subs-' + loc.name, pull: true, put: true }" item-key="name"
|
|
||||||
handle=".drag-handle" ghost-class="sub-ghost" :animation="150"
|
|
||||||
@change="onSubDragChange($event, loc.name, section.items)">
|
|
||||||
<template #item="{ element: sub }">
|
|
||||||
<div class="sub-row clickable-row" :class="{ 'sub-rebate': isRebate(sub), 'sub-cancelled': sub.status === 'Cancelled' }">
|
|
||||||
<div class="row items-center no-wrap">
|
|
||||||
<q-icon name="drag_indicator" class="drag-handle text-grey-4 q-mr-xs" size="16px" style="cursor:grab" />
|
|
||||||
<div class="col" style="min-width:0" @click="openModal('Service Subscription', sub.name)">
|
|
||||||
<div class="row items-center no-wrap q-gutter-x-sm">
|
|
||||||
<code class="sub-sku">{{ sub.name }}</code>
|
|
||||||
<!-- Lien legacy F : legacy_service_id = service.id de F (0 = ajouté dans ERPNext, absent de F) -->
|
|
||||||
<q-badge v-if="sub.legacy_service_id > 0" color="blue-grey-1" text-color="blue-grey-8" :label="'F #' + sub.legacy_service_id"><q-tooltip>Service F #{{ sub.legacy_service_id }} (corrélé)</q-tooltip></q-badge>
|
|
||||||
<q-badge v-else color="red-1" text-color="red-9" label="OPS"><q-tooltip>Ajouté dans ERPNext — absent de F (pas de legacy_service_id)</q-tooltip></q-badge>
|
|
||||||
<span class="text-weight-medium ellipsis-text" style="flex:1;min-width:0">{{ subMainLabel(sub) }}</span>
|
|
||||||
<!-- Inline price edit: double-click to edit. Negative values kept as-is for rebates. -->
|
|
||||||
<span class="sub-price-wrap" @click.stop @dblclick.stop
|
|
||||||
:title="can('edit_records') ? 'Double-clic pour modifier le prix' : ''">
|
|
||||||
<InlineField :value="sub.actual_price || 0"
|
|
||||||
field="monthly_price" doctype="Service Subscription" :docname="sub.name"
|
|
||||||
type="number" :formatter="formatMoney"
|
|
||||||
:readonly="!can('edit_records')"
|
|
||||||
@saved="onSubPriceSaved(sub, $event)">
|
|
||||||
<template #display="{ displayValue }">
|
|
||||||
<span class="sub-price" :class="{ 'text-red': isRebate(sub), 'text-weight-bold': !isRebate(sub) }">
|
|
||||||
{{ displayValue }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</InlineField>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-auto sub-actions">
|
|
||||||
<q-btn flat dense round size="xs" icon="confirmation_number" color="green-4"
|
|
||||||
@click.stop="openNewTicket(loc, sub)">
|
|
||||||
<q-tooltip>Créer un ticket pour ce service</q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
<q-btn flat dense round size="xs" :icon="sub.billing_frequency === 'A' ? 'event' : 'event_repeat'"
|
|
||||||
@click.stop="toggleFrequency(sub)" :loading="subSaving === sub.name + ':freq'">
|
|
||||||
<q-tooltip>{{ sub.billing_frequency === 'A' ? 'Passer mensuel' : 'Passer annuel' }}</q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
<q-btn flat dense round size="xs"
|
|
||||||
:icon="sub.status === 'Cancelled' ? 'play_circle' : 'cancel'"
|
|
||||||
:color="sub.status === 'Cancelled' ? 'green' : 'red'"
|
|
||||||
@click.stop="toggleSubStatus(sub)" :loading="subSaving === sub.name + ':status'">
|
|
||||||
<q-tooltip>{{ sub.status === 'Cancelled' ? 'Réactiver' : 'Désactiver' }}</q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
<q-btn v-if="can('delete_records')" flat dense round size="xs" icon="delete_outline" color="negative"
|
|
||||||
@click.stop="confirmDeleteSub(sub)">
|
|
||||||
<q-tooltip>Supprimer définitivement</q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</draggable>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div class="sub-total-line">
|
|
||||||
<span class="sub-total-label">Total mensuel</span>
|
|
||||||
<span class="sub-total-amount" :class="{ 'sub-total-neg': locSubsMonthlyTotal(loc.name) < 0 }">{{ formatMoney(locSubsMonthlyTotal(loc.name)) }} /mois</span>
|
|
||||||
</div>
|
|
||||||
<!-- Garde-fou : un abonnement récurrent ne peut pas être négatif (rabais > frais). On NE corrige PAS (facturation = F autoritaire) : on signale pour correction dans F. -->
|
|
||||||
<div v-if="locSubsMonthlyTotal(loc.name) < 0" class="sub-total-warning">
|
|
||||||
<q-icon name="error" size="15px" color="negative" class="q-mr-xs" />
|
|
||||||
<span>Total récurrent négatif — les rabais dépassent les frais. Vérifier/corriger les rabais dans la facturation (F).</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-if="locSubsAnnual(loc.name).length">
|
|
||||||
<div class="sub-annual-divider">
|
|
||||||
<q-icon name="event" size="16px" /> Abonnements annuels ({{ locSubsAnnual(loc.name).length }})
|
|
||||||
</div>
|
|
||||||
<div v-for="sub in locSubsAnnual(loc.name)" :key="sub.name"
|
|
||||||
class="sub-row clickable-row" :class="{ 'sub-rebate': isRebate(sub), 'sub-cancelled': sub.status === 'Cancelled' }">
|
|
||||||
<div class="row items-center no-wrap">
|
|
||||||
<div class="col" style="min-width:0">
|
|
||||||
<div class="row items-center no-wrap q-gutter-x-sm" @click="openModal('Service Subscription', sub.name)">
|
|
||||||
<code class="sub-sku">{{ sub.name }}</code>
|
|
||||||
<span class="sub-freq annual">A</span>
|
|
||||||
<span class="text-weight-medium ellipsis-text" style="flex:1;min-width:0">{{ subMainLabel(sub) }}</span>
|
|
||||||
<span class="sub-price" :class="{ 'text-red': isRebate(sub), 'text-weight-bold': !isRebate(sub) }">{{ formatMoney(annualPrice(sub)) }}</span>
|
|
||||||
<!-- Inline edit on the per-month base price — annual total recomputes from it. -->
|
|
||||||
<span class="sub-price-wrap text-caption text-grey-5" style="white-space:nowrap" @click.stop @dblclick.stop
|
|
||||||
:title="can('edit_records') ? 'Double-clic pour modifier le prix mensuel' : ''">
|
|
||||||
(<InlineField :value="sub.actual_price || 0"
|
|
||||||
field="monthly_price" doctype="Service Subscription" :docname="sub.name"
|
|
||||||
type="number" :formatter="formatMoney"
|
|
||||||
:readonly="!can('edit_records')"
|
|
||||||
@saved="onSubPriceSaved(sub, $event)" />/m×12)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="row items-center q-pl-lg q-mt-xs q-gutter-x-md">
|
|
||||||
<div class="sub-meta">
|
|
||||||
<q-toggle size="xs" dense
|
|
||||||
:model-value="!Number(sub.cancel_at_period_end)"
|
|
||||||
@update:model-value="toggleRecurring(sub)"
|
|
||||||
:color="Number(sub.cancel_at_period_end) ? 'grey' : 'green'"
|
|
||||||
:disable="togglingRecurring === sub.name" />
|
|
||||||
<span class="text-caption" :class="Number(sub.cancel_at_period_end) ? 'text-grey-5' : 'text-green-7'">
|
|
||||||
{{ Number(sub.cancel_at_period_end) ? 'Non récurrent' : 'Récurrent' }}
|
|
||||||
</span>
|
|
||||||
<q-spinner v-if="togglingRecurring === sub.name" size="12px" class="q-ml-xs" />
|
|
||||||
</div>
|
|
||||||
<div v-if="sub.current_invoice_start" class="text-caption text-grey-6">
|
|
||||||
<q-icon name="event" size="12px" class="q-mr-xs" />
|
|
||||||
Prochaine: {{ formatDate(sub.current_invoice_start) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-auto sub-actions">
|
|
||||||
<q-btn flat dense round size="xs" icon="event_repeat"
|
|
||||||
@click.stop="toggleFrequency(sub)" :loading="subSaving === sub.name + ':freq'">
|
|
||||||
<q-tooltip>Passer mensuel</q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
<q-btn flat dense round size="xs"
|
|
||||||
:icon="sub.status === 'Cancelled' ? 'play_circle' : 'cancel'"
|
|
||||||
:color="sub.status === 'Cancelled' ? 'green' : 'red'"
|
|
||||||
@click.stop="toggleSubStatus(sub)" :loading="subSaving === sub.name + ':status'">
|
|
||||||
<q-tooltip>{{ sub.status === 'Cancelled' ? 'Réactiver' : 'Désactiver' }}</q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
<q-btn v-if="can('delete_records')" flat dense round size="xs" icon="delete_outline" color="negative"
|
|
||||||
@click.stop="confirmDeleteSub(sub)">
|
|
||||||
<q-tooltip>Supprimer définitivement</q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sub-total-line annual">
|
|
||||||
<span class="sub-total-label">Total annuel</span>
|
|
||||||
<span class="sub-total-amount">{{ formatMoney(locSubsAnnualTotal(loc.name)) }} /an</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="q-mt-sm">
|
|
||||||
<div class="info-block-title" style="display:flex;align-items:center">
|
|
||||||
Tickets ({{ locTickets(loc.name).length }})
|
|
||||||
<q-btn flat dense round size="xs" icon="add" color="primary" class="q-ml-xs"
|
|
||||||
@click="openNewTicket(loc)" style="margin-top:-2px">
|
|
||||||
<q-tooltip>Créer un ticket pour cette adresse</q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
</div>
|
|
||||||
<div v-for="t in locTickets(loc.name)" :key="t.name" class="ticket-row clickable-row" @click="openModal('Issue', t.name, t.subject)">
|
|
||||||
<div class="row items-center no-wrap">
|
|
||||||
<q-icon v-if="t.is_important" name="star" color="warning" size="14px" class="q-mr-xs" style="flex-shrink:0" />
|
|
||||||
<div class="col" style="min-width:0">
|
|
||||||
<span style="font-size:11px;color:#9e9e9e" class="q-mr-xs">{{ t.legacy_ticket_id || t.name }}</span>
|
|
||||||
<span style="font-size:12.5px">{{ t.subject }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="t.assigned_staff || t.opened_by_staff" class="avatar-stack q-mx-xs" style="flex-shrink:0">
|
|
||||||
<q-avatar v-if="t.opened_by_staff && t.opened_by_staff !== t.assigned_staff"
|
|
||||||
size="20px" class="avatar-chip" :style="{ background: staffColor(t.opened_by_staff), zIndex: 1 }">
|
|
||||||
{{ staffInitials(t.opened_by_staff) }}
|
|
||||||
<q-tooltip>{{ t.opened_by_staff }}</q-tooltip>
|
|
||||||
</q-avatar>
|
|
||||||
<q-avatar v-if="t.assigned_staff"
|
|
||||||
size="20px" class="avatar-chip" :style="{ background: staffColor(t.assigned_staff), zIndex: 2 }">
|
|
||||||
{{ staffInitials(t.assigned_staff) }}
|
|
||||||
<q-tooltip>{{ t.assigned_staff }}</q-tooltip>
|
|
||||||
</q-avatar>
|
|
||||||
</div>
|
|
||||||
<span style="font-size:11px;color:#9e9e9e;white-space:nowrap;flex-shrink:0" class="q-mx-xs">{{ t.opening_date }}</span>
|
|
||||||
<span class="ops-badge" style="font-size:10px;padding:1px 5px;flex-shrink:0" :class="ticketStatusClass(t.status)">{{ t.status }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
// LocationCard — un « lieu de service » (adresse de livraison) : en-tête + bandeau
|
|
||||||
// appareils (DeviceStrip) + abonnements (mensuels/annuels, drag-drop) + tickets.
|
|
||||||
// Les sélecteurs/actions stateful (locSubs*, toggle*, onSub*) restent liés aux
|
|
||||||
// instances de composables du parent (useSubscriptionGroups/useSubscriptionActions)
|
|
||||||
// et sont passés en props-fonctions homonymes → le template les résout tel quel,
|
|
||||||
// comportement identique (mêmes closures, même invalidation de cache).
|
|
||||||
import InlineField from 'src/components/shared/InlineField.vue'
|
|
||||||
import DeviceStrip from 'src/components/customer/DeviceStrip.vue'
|
|
||||||
import draggable from 'vuedraggable'
|
|
||||||
import { formatMoney, formatDate, staffColor, staffInitials } from 'src/composables/useFormatters'
|
|
||||||
import { locStatusClass, ticketStatusClass } from 'src/composables/useStatusClasses'
|
|
||||||
import { isRebate, subMainLabel, sectionTotal, annualPrice } from 'src/composables/useSubscriptionGroups'
|
|
||||||
import { usePermissions } from 'src/composables/usePermissions'
|
|
||||||
import { locInlineFields } from 'src/data/client-constants'
|
|
||||||
|
|
||||||
const { can } = usePermissions()
|
|
||||||
|
|
||||||
defineProps({
|
|
||||||
loc: { type: Object, required: true },
|
|
||||||
highlightLoc: { type: String, default: null },
|
|
||||||
deletingLoc: { type: String, default: null },
|
|
||||||
subSaving: { type: String, default: null },
|
|
||||||
togglingRecurring: { type: String, default: null },
|
|
||||||
locCollapsed: { type: Object, required: true },
|
|
||||||
// ── Sélecteurs (instance useSubscriptionGroups du parent) ──
|
|
||||||
locHasSubs: { type: Function, required: true },
|
|
||||||
locEquip: { type: Function, required: true },
|
|
||||||
locSubs: { type: Function, required: true },
|
|
||||||
locSubsMonthly: { type: Function, required: true },
|
|
||||||
locSubsAnnual: { type: Function, required: true },
|
|
||||||
locSubsMonthlyTotal: { type: Function, required: true },
|
|
||||||
locSubsAnnualTotal: { type: Function, required: true },
|
|
||||||
locSubsSections: { type: Function, required: true },
|
|
||||||
sectionOpen: { type: Function, required: true },
|
|
||||||
locTickets: { type: Function, required: true },
|
|
||||||
// ── Actions (parent : composables + orchestration fiche) ──
|
|
||||||
toggleLocCollapse: { type: Function, required: true },
|
|
||||||
toggleSection: { type: Function, required: true },
|
|
||||||
onSubDragChange: { type: Function, required: true },
|
|
||||||
onSubPriceSaved: { type: Function, required: true },
|
|
||||||
toggleFrequency: { type: Function, required: true },
|
|
||||||
toggleSubStatus: { type: Function, required: true },
|
|
||||||
toggleRecurring: { type: Function, required: true },
|
|
||||||
confirmDeleteSub: { type: Function, required: true },
|
|
||||||
confirmDeleteLocation: { type: Function, required: true },
|
|
||||||
openNewTicket: { type: Function, required: true },
|
|
||||||
openModal: { type: Function, required: true },
|
|
||||||
openWizardForAddress: { type: Function, required: true },
|
|
||||||
openAddService: { type: Function, required: true },
|
|
||||||
openAddEquipment: { type: Function, required: true },
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.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; } }
|
|
||||||
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; } }
|
|
||||||
.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; }
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,153 +0,0 @@
|
||||||
<template>
|
|
||||||
<q-expansion-item :model-value="open" @update:model-value="$emit('update:open', $event)"
|
|
||||||
header-class="section-header" class="q-mb-sm fiche-mod" :style="moduleStyle" v-show="moduleVisible">
|
|
||||||
<template #header>
|
|
||||||
<div class="section-title" style="font-size:1rem;width:100%;display:flex;align-items:center">
|
|
||||||
<q-icon name="payments" size="20px" class="q-mr-xs" />
|
|
||||||
Paiements ({{ payments.length + stripeCharges.length }}{{ (!paymentsExpanded && payments.length >= 5) ? '+' : '' }})
|
|
||||||
<span v-if="paymentMethods.some(p => p.is_auto_ppa)" class="ops-badge open q-ml-sm" style="font-size:10px">PPA</span>
|
|
||||||
<q-space />
|
|
||||||
<div class="q-gutter-x-xs" @click.stop>
|
|
||||||
<q-btn flat dense size="sm" icon="send" color="primary" no-caps :loading="sendingLink" @click="sendPaymentLink('both')"><q-tooltip>Envoyer lien de paiement (SMS + Email)</q-tooltip></q-btn>
|
|
||||||
<q-btn flat dense size="sm" icon="bolt" color="warning" no-caps :loading="chargingCard" @click="chargeCard()"><q-tooltip>Prélever le solde maintenant</q-tooltip></q-btn>
|
|
||||||
<q-btn flat dense size="sm" icon="add" label="Inscrire" color="primary" no-caps @click="recordPayment()"><q-tooltip>Inscrire un paiement reçu (ouvre ERPNext, pré-rempli)</q-tooltip></q-btn>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- ── Méthode de paiement (carte, PPA, liens) — ex-section « Paiement » ── -->
|
|
||||||
<div class="ops-card q-mb-md">
|
|
||||||
<div class="text-caption text-weight-medium text-grey-7 q-mb-xs"><q-icon name="credit_card" size="14px" color="green-5" /> Méthode de paiement</div>
|
|
||||||
<div class="row q-gutter-sm q-mb-sm q-pa-sm" style="background:#f8fafc;border-radius:8px">
|
|
||||||
<q-btn outline dense no-caps color="primary" icon="open_in_new" label="Portail Stripe" size="sm" @click="openPortal"><q-tooltip>Ouvrir le portail Stripe pour gérer la carte</q-tooltip></q-btn>
|
|
||||||
<q-btn outline dense no-caps color="green-7" icon="add_card" label="Ajouter carte" size="sm" @click="createCheckout"><q-tooltip>Créer un lien Stripe Checkout (copié au presse-papier)</q-tooltip></q-btn>
|
|
||||||
<q-btn outline dense no-caps icon="sms" label="Lien SMS" size="sm" color="info" :loading="sendingLink" @click="sendPaymentLink('sms')"><q-tooltip>Envoyer lien de paiement par SMS</q-tooltip></q-btn>
|
|
||||||
<q-btn outline dense no-caps icon="email" label="Lien Email" size="sm" color="purple-6" :loading="sendingLink" @click="sendPaymentLink('email')"><q-tooltip>Envoyer lien de paiement par Email</q-tooltip></q-btn>
|
|
||||||
</div>
|
|
||||||
<div v-if="!paymentMethods.length" class="text-center text-grey-6 q-pa-sm">Aucune méthode de paiement</div>
|
|
||||||
<DataTable v-else :rows="paymentMethods" :columns="paymentMethodCols" row-key="name" flat dense class="ops-table clickable-table" :pagination="{ rowsPerPage: 10 }" @row-click="(_, row) => openModal('Payment Method', row.name, row.provider)">
|
|
||||||
<template #body-cell-is_auto_ppa="props">
|
|
||||||
<q-td :props="props" @click.stop>
|
|
||||||
<q-toggle v-if="props.row.provider === 'Stripe'" dense size="sm" :model-value="!!props.row.is_auto_ppa" :loading="togglingPpa" @update:model-value="v => togglePpa(v).then(ok => { if (ok) props.row.is_auto_ppa = v ? 1 : 0 })" />
|
|
||||||
<q-icon v-else-if="props.row.is_auto_ppa" name="autorenew" size="16px" color="green-6" />
|
|
||||||
</q-td>
|
|
||||||
</template>
|
|
||||||
<template #body-cell-provider="props">
|
|
||||||
<q-td :props="props">
|
|
||||||
<q-icon :name="props.row.provider === 'Stripe' ? 'credit_card' : props.row.provider === 'Bank Draft' ? 'account_balance' : 'payment'" size="14px" class="q-mr-xs" />
|
|
||||||
{{ props.row.provider }}
|
|
||||||
</q-td>
|
|
||||||
</template>
|
|
||||||
</DataTable>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── Transactions (Payment Entry ERPNext + charges Stripe réelles) ── -->
|
|
||||||
<div class="ops-card q-mb-md">
|
|
||||||
<div class="text-caption text-weight-medium text-grey-7 q-mb-xs"><q-icon name="receipt_long" size="14px" color="green-5" /> Transactions</div>
|
|
||||||
<div v-if="!payments.length && !stripeCharges.length" class="text-center text-grey-6 q-pa-sm">Aucune transaction</div>
|
|
||||||
<template v-else>
|
|
||||||
<DataTable v-if="payments.length" :rows="payments" :columns="paymentCols" row-key="name"
|
|
||||||
flat dense class="ops-table clickable-table"
|
|
||||||
:pagination="{ rowsPerPage: 10, sortBy: 'posting_date', descending: true }"
|
|
||||||
@row-click="(_, row) => openModal('Payment Entry', row.name, 'Paiement ' + row.name)">
|
|
||||||
<template #body-cell-paid_amount="props">
|
|
||||||
<q-td :props="props" class="text-right">
|
|
||||||
{{ formatMoney(props.row.paid_amount) }}
|
|
||||||
<q-badge v-if="refundsByPi[props.row.reference_no]" color="amber-1" text-color="amber-9" class="q-ml-xs" style="font-weight:600">
|
|
||||||
<q-icon name="undo" size="12px" class="q-mr-xs" />remboursé {{ formatMoney(refundsByPi[props.row.reference_no].amount_refunded) }}<span v-if="refundsByPi[props.row.reference_no].refunded_at"> · {{ refundsByPi[props.row.reference_no].refunded_at }}</span>
|
|
||||||
<q-tooltip>Remboursement Stripe {{ refundsByPi[props.row.reference_no].fully_refunded ? 'complet' : 'partiel' }} · statut {{ refundsByPi[props.row.reference_no].status }} · le délai bancaire est de 5-10 jours ouvrables</q-tooltip>
|
|
||||||
</q-badge>
|
|
||||||
</q-td>
|
|
||||||
</template>
|
|
||||||
<template #body-cell-actions="props">
|
|
||||||
<q-td :props="props" @click.stop>
|
|
||||||
<q-icon v-if="refundsByPi[props.row.reference_no] && refundsByPi[props.row.reference_no].fully_refunded" name="check_circle" color="green-6" size="18px"><q-tooltip>Déjà remboursé via Stripe{{ refundsByPi[props.row.reference_no].refunded_at ? ' le ' + refundsByPi[props.row.reference_no].refunded_at : '' }}</q-tooltip></q-icon>
|
|
||||||
<q-btn v-else-if="props.row.reference_no && props.row.reference_no.startsWith('pi_')"
|
|
||||||
flat round dense size="xs" icon="replay" color="warning" :loading="refunding"
|
|
||||||
@click="confirmRefund(props.row)">
|
|
||||||
<q-tooltip>{{ refundsByPi[props.row.reference_no] ? 'Rembourser le reste via Stripe' : 'Rembourser via Stripe' }}</q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
</q-td>
|
|
||||||
</template>
|
|
||||||
</DataTable>
|
|
||||||
<div v-if="!paymentsExpanded && payments.length >= 5" class="text-center q-pa-xs">
|
|
||||||
<q-btn flat dense no-caps color="primary" :loading="loadingMorePayments" label="Voir tous les paiements" icon="expand_more" @click="loadAllPayments" />
|
|
||||||
</div>
|
|
||||||
<div v-if="stripeCharges.length" :class="{ 'q-mt-sm': payments.length }" :style="payments.length ? 'border-top:1px solid var(--ops-border,#e5e7eb);padding-top:8px' : ''">
|
|
||||||
<div class="text-caption text-grey-6 q-mb-xs"><q-icon name="credit_card" size="13px" /> Charges Stripe ({{ stripeCharges.length }})<q-tooltip>Transactions Stripe réelles — peuvent ne pas être (encore) enregistrées comme paiement ERPNext</q-tooltip></div>
|
|
||||||
<q-list dense separator>
|
|
||||||
<q-item v-for="c in stripeCharges" :key="c.id" :clickable="!!c.receipt_url" tag="a" :href="c.receipt_url || undefined" target="_blank" rel="noopener">
|
|
||||||
<q-item-section>
|
|
||||||
<q-item-label>{{ formatMoney(c.amount) }}<span v-if="c.brand" class="text-grey-6"> · {{ c.brand }} ••{{ c.last4 }}</span></q-item-label>
|
|
||||||
<q-item-label caption>{{ c.created }}<span v-if="c.description"> · {{ c.description }}</span></q-item-label>
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section side>
|
|
||||||
<span class="ops-badge" :class="c.refunded ? 'inactive' : (c.paid && c.status === 'succeeded' ? 'paid' : 'open')">{{ c.refunded ? 'Remboursé' : (c.paid && c.status === 'succeeded' ? 'Payé' : c.status) }}</span>
|
|
||||||
</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
</q-list>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── Ententes de paiement — ex-section séparée, fusionnée ici ── -->
|
|
||||||
<div v-if="arrangements.length" class="ops-card q-mb-md">
|
|
||||||
<div class="text-caption text-weight-medium text-grey-7 q-mb-xs"><q-icon name="handshake" size="14px" color="green-5" /> Ententes de paiement ({{ arrangements.length }})<span v-if="arrangements.filter(a => a.status === 'Open').length" class="text-caption text-blue q-ml-sm">{{ arrangements.filter(a => a.status === 'Open').length }} actives</span></div>
|
|
||||||
<DataTable :rows="arrangements" :columns="arrangementCols" row-key="name" flat dense class="ops-table clickable-table" :pagination="{ rowsPerPage: 10, sortBy: 'date_agreed', descending: true }" @row-click="(_, row) => openModal('Payment Arrangement', row.name, 'Entente ' + row.name)">
|
|
||||||
<template #body-cell-status="props">
|
|
||||||
<q-td :props="props"><span class="ops-badge" :class="props.row.status === 'Open' ? 'open' : props.row.status === 'Completed' ? 'paid' : 'inactive'">{{ props.row.status }}</span></q-td>
|
|
||||||
</template>
|
|
||||||
<template #body-cell-total_amount="props">
|
|
||||||
<q-td :props="props" class="text-right">{{ formatMoney(props.row.total_amount) }}</q-td>
|
|
||||||
</template>
|
|
||||||
</DataTable>
|
|
||||||
</div>
|
|
||||||
</q-expansion-item>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
// PaymentSection — section « Paiements » unifiée de la fiche : méthode de
|
|
||||||
// paiement (carte/PPA/liens Stripe) + transactions (Payment Entry ERPNext +
|
|
||||||
// charges Stripe réelles) + ententes de paiement. Reste un module de la grille
|
|
||||||
// réordonnable du parent : open (v-model), moduleStyle (ordre CSS) et
|
|
||||||
// moduleVisible sont fournis par la fiche. Données + états de chargement +
|
|
||||||
// actions (usePaymentActions + orchestration fiche) passés en props ; colonnes
|
|
||||||
// et formatMoney importés directement (constantes pures).
|
|
||||||
import DataTable from 'src/components/shared/DataTable.vue'
|
|
||||||
import { formatMoney } from 'src/composables/useFormatters'
|
|
||||||
import { paymentMethodCols, paymentCols, arrangementCols } from 'src/config/table-columns'
|
|
||||||
|
|
||||||
defineProps({
|
|
||||||
open: { type: Boolean, default: false },
|
|
||||||
moduleStyle: { type: [Object, String], default: () => ({}) },
|
|
||||||
moduleVisible: { type: Boolean, default: true },
|
|
||||||
payments: { type: Array, default: () => [] },
|
|
||||||
stripeCharges: { type: Array, default: () => [] },
|
|
||||||
paymentMethods: { type: Array, default: () => [] },
|
|
||||||
arrangements: { type: Array, default: () => [] },
|
|
||||||
refundsByPi: { type: Object, default: () => ({}) },
|
|
||||||
paymentsExpanded: { type: Boolean, default: false },
|
|
||||||
loadingMorePayments: { type: Boolean, default: false },
|
|
||||||
sendingLink: { type: Boolean, default: false },
|
|
||||||
chargingCard: { type: Boolean, default: false },
|
|
||||||
togglingPpa: { type: Boolean, default: false },
|
|
||||||
refunding: { type: Boolean, default: false },
|
|
||||||
// ── Actions (usePaymentActions + fiche) ──
|
|
||||||
sendPaymentLink: { type: Function, required: true },
|
|
||||||
chargeCard: { type: Function, required: true },
|
|
||||||
togglePpa: { type: Function, required: true },
|
|
||||||
openPortal: { type: Function, required: true },
|
|
||||||
createCheckout: { type: Function, required: true },
|
|
||||||
recordPayment: { type: Function, required: true },
|
|
||||||
confirmRefund: { type: Function, required: true },
|
|
||||||
loadAllPayments: { type: Function, required: true },
|
|
||||||
openModal: { type: Function, required: true },
|
|
||||||
})
|
|
||||||
defineEmits(['update:open'])
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.section-title { font-weight: 700; color: var(--ops-text); display: flex; align-items: center; }
|
|
||||||
:deep(.section-header) { padding: 8px 0; min-height: unset; background: transparent; border-bottom: 1px solid var(--ops-border, #e2e8f0); }
|
|
||||||
.clickable-table :deep(thead th) { font-size: 11px; padding: 4px; }
|
|
||||||
</style>
|
|
||||||
|
|
@ -70,38 +70,280 @@
|
||||||
|
|
||||||
<div v-if="!locations.length" class="ops-card text-center text-grey-6 q-pa-md q-mb-md">Aucun lieu de service</div>
|
<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"
|
<div v-for="loc in visibleLocations" :key="loc.name"
|
||||||
:loc="loc"
|
:id="'loc-' + loc.name"
|
||||||
:highlight-loc="highlightLoc"
|
class="ops-card q-mb-md loc-card"
|
||||||
:deleting-loc="deletingLoc"
|
:class="{ 'loc-inactive': !locHasSubs(loc.name), 'loc-highlight': highlightLoc === loc.name }">
|
||||||
:sub-saving="subSaving"
|
<div class="loc-header" :style="!locHasSubs(loc.name) ? 'cursor:pointer' : ''"
|
||||||
:toggling-recurring="togglingRecurring"
|
@click="!locHasSubs(loc.name) && toggleLocCollapse(loc.name)">
|
||||||
:loc-collapsed="locCollapsed"
|
<div class="row items-center">
|
||||||
:loc-has-subs="locHasSubs"
|
<div v-if="!locHasSubs(loc.name)" class="col-auto q-mr-xs">
|
||||||
:loc-equip="locEquip"
|
<q-icon :name="locCollapsed[loc.name] === false ? 'expand_more' : 'chevron_right'" size="18px" color="grey-5" />
|
||||||
:loc-subs="locSubs"
|
</div>
|
||||||
:loc-subs-monthly="locSubsMonthly"
|
<div class="col">
|
||||||
:loc-subs-annual="locSubsAnnual"
|
<div class="text-subtitle1 text-weight-bold" :class="{ 'text-grey-6': !locHasSubs(loc.name) }">
|
||||||
:loc-subs-monthly-total="locSubsMonthlyTotal"
|
<InlineField :value="loc.address_line" field="address_line" doctype="Service Location" :docname="loc.name"
|
||||||
:loc-subs-annual-total="locSubsAnnualTotal"
|
placeholder="Adresse" @saved="v => loc.address_line = v.value" />
|
||||||
:loc-subs-sections="locSubsSections"
|
</div>
|
||||||
:section-open="sectionOpen"
|
<div class="text-caption text-grey-6">
|
||||||
:loc-tickets="locTickets"
|
<template v-for="f in locInlineFields" :key="f.field">
|
||||||
:toggle-loc-collapse="toggleLocCollapse"
|
<template v-if="f.prefix">{{ f.prefix }}</template>
|
||||||
:toggle-section="toggleSection"
|
<InlineField :value="loc[f.field]" :field="f.field" doctype="Service Location" :docname="loc.name"
|
||||||
:on-sub-drag-change="onSubDragChange"
|
:placeholder="f.placeholder" @saved="v => loc[f.field] = v.value" />
|
||||||
:on-sub-price-saved="onSubPriceSaved"
|
</template>
|
||||||
:toggle-frequency="toggleFrequency"
|
<template v-if="!locHasSubs(loc.name)"> · <em>Aucun abonnement</em></template>
|
||||||
:toggle-sub-status="toggleSubStatus"
|
</div>
|
||||||
:toggle-recurring="toggleRecurring"
|
</div>
|
||||||
:confirm-delete-sub="confirmDeleteSub"
|
<div class="col-auto q-gutter-x-sm">
|
||||||
:confirm-delete-location="confirmDeleteLocation"
|
<span v-if="locSubsMonthlyTotal(loc.name)" class="text-weight-bold" style="color:var(--ops-accent)">
|
||||||
:open-new-ticket="openNewTicket"
|
{{ formatMoney(locSubsMonthlyTotal(loc.name)) }}/m
|
||||||
:open-modal="openModal"
|
</span>
|
||||||
:open-wizard-for-address="openWizardForAddress"
|
<span class="ops-badge" :class="locHasSubs(loc.name) ? locStatusClass(loc.status) : 'inactive'">
|
||||||
:open-add-service="openAddService"
|
{{ locHasSubs(loc.name) ? loc.status : 'Inactif' }}
|
||||||
:open-add-equipment="openAddEquipment"
|
</span>
|
||||||
/>
|
<q-btn v-if="!locHasSubs(loc.name) && can('delete_records')" flat round dense
|
||||||
|
icon="delete_outline" size="sm" color="negative" class="q-ml-xs"
|
||||||
|
@click.stop="confirmDeleteLocation(loc)" :loading="deletingLoc === loc.name">
|
||||||
|
<q-tooltip>Supprimer cette adresse</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-show="locHasSubs(loc.name) || locCollapsed[loc.name] === false">
|
||||||
|
|
||||||
|
<div v-if="locEquip(loc.name).length || loc.connection_type" class="row items-center q-mt-sm q-mb-xs q-gutter-x-md">
|
||||||
|
<div class="text-caption text-grey-6">
|
||||||
|
<q-icon name="cable" size="14px" class="q-mr-xs" />
|
||||||
|
<InlineField :value="loc.connection_type" field="connection_type" doctype="Service Location" :docname="loc.name"
|
||||||
|
type="select" :options="['Fibre', 'Coax', 'DSL', 'Wireless', 'LTE']"
|
||||||
|
placeholder="Type" @saved="v => loc.connection_type = v.value" />
|
||||||
|
· OLT: <InlineField :value="loc.olt_port" field="olt_port" doctype="Service Location" :docname="loc.name"
|
||||||
|
placeholder="Port OLT" @saved="v => loc.olt_port = v.value" />
|
||||||
|
· <InlineField :value="loc.network_id" field="network_id" doctype="Service Location" :docname="loc.name"
|
||||||
|
placeholder="Network ID" @saved="v => loc.network_id = v.value" />
|
||||||
|
</div>
|
||||||
|
<DeviceStrip
|
||||||
|
:equipment="locEquip(loc.name)"
|
||||||
|
@open-device="eq => openModal('Service Equipment', eq.name, eq.equipment_type + (eq.brand ? ' — ' + eq.brand : ''))"
|
||||||
|
@add-submission="openWizardForAddress(loc)"
|
||||||
|
@add-service="openAddService(loc)"
|
||||||
|
@add-rebate="openAddService(loc, 'rabais')"
|
||||||
|
@add-equipment="openAddEquipment(loc)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-block subs-block">
|
||||||
|
<div class="info-block-title" style="display:flex;align-items:center">
|
||||||
|
Abonnements ({{ locSubs(loc.name).length }})
|
||||||
|
<q-btn flat dense round size="xs" icon="add" color="primary" class="q-ml-xs"
|
||||||
|
style="margin-top:-2px">
|
||||||
|
<q-tooltip>Ajouter un service</q-tooltip>
|
||||||
|
<q-menu anchor="bottom left" self="top left" :offset="[0, 4]">
|
||||||
|
<q-list dense style="min-width:220px">
|
||||||
|
<q-item clickable v-close-popup @click="openWizardForAddress(loc)">
|
||||||
|
<q-item-section avatar><q-icon name="description" size="18px" color="primary" /></q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>Nouvelle soumission</q-item-label>
|
||||||
|
<q-item-label caption>Wizard avec étapes</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-separator />
|
||||||
|
<q-item clickable v-close-popup @click="openAddService(loc)">
|
||||||
|
<q-item-section avatar><q-icon name="wifi" size="18px" color="info" /></q-item-section>
|
||||||
|
<q-item-section>Forfait / Service (direct)</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-item clickable v-close-popup @click="openAddService(loc, 'rabais')">
|
||||||
|
<q-item-section avatar><q-icon name="sell" size="18px" color="negative" /></q-item-section>
|
||||||
|
<q-item-section>Rabais / Crédit</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</q-menu>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
<div v-if="!locSubs(loc.name).length" class="text-caption text-grey-5">Aucun</div>
|
||||||
|
|
||||||
|
<template v-if="locSubsMonthly(loc.name).length">
|
||||||
|
<template v-for="section in locSubsSections(loc.name, 'M')" :key="'m-'+section.key">
|
||||||
|
<div class="sub-section-header" @click="toggleSection(loc.name, section.key)">
|
||||||
|
<q-icon :name="sectionOpen(loc.name, section.key) ? 'expand_more' : 'chevron_right'" size="18px" />
|
||||||
|
<q-icon :name="section.icon" size="16px" class="text-grey-6 q-mr-xs" />
|
||||||
|
<span class="sub-section-title">{{ section.label }}</span>
|
||||||
|
<span class="sub-section-count">({{ section.items.length }})</span>
|
||||||
|
<span class="sub-section-subtotal">{{ formatMoney(sectionTotal(section.items)) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-show="sectionOpen(loc.name, section.key)">
|
||||||
|
<draggable :list="section.items" :group="{ name: 'loc-subs-' + loc.name, pull: true, put: true }" item-key="name"
|
||||||
|
handle=".drag-handle" ghost-class="sub-ghost" :animation="150"
|
||||||
|
@change="onSubDragChange($event, loc.name, section.items)">
|
||||||
|
<template #item="{ element: sub }">
|
||||||
|
<div class="sub-row clickable-row" :class="{ 'sub-rebate': isRebate(sub), 'sub-cancelled': sub.status === 'Cancelled' }">
|
||||||
|
<div class="row items-center no-wrap">
|
||||||
|
<q-icon name="drag_indicator" class="drag-handle text-grey-4 q-mr-xs" size="16px" style="cursor:grab" />
|
||||||
|
<div class="col" style="min-width:0" @click="openModal('Service Subscription', sub.name)">
|
||||||
|
<div class="row items-center no-wrap q-gutter-x-sm">
|
||||||
|
<code class="sub-sku">{{ sub.name }}</code>
|
||||||
|
<!-- Lien legacy F : legacy_service_id = service.id de F (0 = ajouté dans ERPNext, absent de F) -->
|
||||||
|
<q-badge v-if="sub.legacy_service_id > 0" color="blue-grey-1" text-color="blue-grey-8" :label="'F #' + sub.legacy_service_id"><q-tooltip>Service F #{{ sub.legacy_service_id }} (corrélé)</q-tooltip></q-badge>
|
||||||
|
<q-badge v-else color="red-1" text-color="red-9" label="OPS"><q-tooltip>Ajouté dans ERPNext — absent de F (pas de legacy_service_id)</q-tooltip></q-badge>
|
||||||
|
<span class="text-weight-medium ellipsis-text" style="flex:1;min-width:0">{{ subMainLabel(sub) }}</span>
|
||||||
|
<!-- Inline price edit: double-click to edit. Negative values kept as-is for rebates. -->
|
||||||
|
<span class="sub-price-wrap" @click.stop @dblclick.stop
|
||||||
|
:title="can('edit_records') ? 'Double-clic pour modifier le prix' : ''">
|
||||||
|
<InlineField :value="sub.actual_price || 0"
|
||||||
|
field="monthly_price" doctype="Service Subscription" :docname="sub.name"
|
||||||
|
type="number" :formatter="formatMoney"
|
||||||
|
:readonly="!can('edit_records')"
|
||||||
|
@saved="onSubPriceSaved(sub, $event)">
|
||||||
|
<template #display="{ displayValue }">
|
||||||
|
<span class="sub-price" :class="{ 'text-red': isRebate(sub), 'text-weight-bold': !isRebate(sub) }">
|
||||||
|
{{ displayValue }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</InlineField>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto sub-actions">
|
||||||
|
<q-btn flat dense round size="xs" icon="confirmation_number" color="green-4"
|
||||||
|
@click.stop="openNewTicket(locations.find(l => l.name === loc.name), sub)">
|
||||||
|
<q-tooltip>Créer un ticket pour ce service</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-btn flat dense round size="xs" :icon="sub.billing_frequency === 'A' ? 'event' : 'event_repeat'"
|
||||||
|
@click.stop="toggleFrequency(sub)" :loading="subSaving === sub.name + ':freq'">
|
||||||
|
<q-tooltip>{{ sub.billing_frequency === 'A' ? 'Passer mensuel' : 'Passer annuel' }}</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-btn flat dense round size="xs"
|
||||||
|
:icon="sub.status === 'Cancelled' ? 'play_circle' : 'cancel'"
|
||||||
|
:color="sub.status === 'Cancelled' ? 'green' : 'red'"
|
||||||
|
@click.stop="toggleSubStatus(sub)" :loading="subSaving === sub.name + ':status'">
|
||||||
|
<q-tooltip>{{ sub.status === 'Cancelled' ? 'Réactiver' : 'Désactiver' }}</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-btn v-if="can('delete_records')" flat dense round size="xs" icon="delete_outline" color="negative"
|
||||||
|
@click.stop="confirmDeleteSub(sub)">
|
||||||
|
<q-tooltip>Supprimer définitivement</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</draggable>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="sub-total-line">
|
||||||
|
<span class="sub-total-label">Total mensuel</span>
|
||||||
|
<span class="sub-total-amount" :class="{ 'sub-total-neg': locSubsMonthlyTotal(loc.name) < 0 }">{{ formatMoney(locSubsMonthlyTotal(loc.name)) }} /mois</span>
|
||||||
|
</div>
|
||||||
|
<!-- Garde-fou : un abonnement récurrent ne peut pas être négatif (rabais > frais). On NE corrige PAS (facturation = F autoritaire) : on signale pour correction dans F. -->
|
||||||
|
<div v-if="locSubsMonthlyTotal(loc.name) < 0" class="sub-total-warning">
|
||||||
|
<q-icon name="error" size="15px" color="negative" class="q-mr-xs" />
|
||||||
|
<span>Total récurrent négatif — les rabais dépassent les frais. Vérifier/corriger les rabais dans la facturation (F).</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="locSubsAnnual(loc.name).length">
|
||||||
|
<div class="sub-annual-divider">
|
||||||
|
<q-icon name="event" size="16px" /> Abonnements annuels ({{ locSubsAnnual(loc.name).length }})
|
||||||
|
</div>
|
||||||
|
<div v-for="sub in locSubsAnnual(loc.name)" :key="sub.name"
|
||||||
|
class="sub-row clickable-row" :class="{ 'sub-rebate': isRebate(sub), 'sub-cancelled': sub.status === 'Cancelled' }">
|
||||||
|
<div class="row items-center no-wrap">
|
||||||
|
<div class="col" style="min-width:0">
|
||||||
|
<div class="row items-center no-wrap q-gutter-x-sm" @click="openModal('Service Subscription', sub.name)">
|
||||||
|
<code class="sub-sku">{{ sub.name }}</code>
|
||||||
|
<span class="sub-freq annual">A</span>
|
||||||
|
<span class="text-weight-medium ellipsis-text" style="flex:1;min-width:0">{{ subMainLabel(sub) }}</span>
|
||||||
|
<span class="sub-price" :class="{ 'text-red': isRebate(sub), 'text-weight-bold': !isRebate(sub) }">{{ formatMoney(annualPrice(sub)) }}</span>
|
||||||
|
<!-- Inline edit on the per-month base price — annual total recomputes from it. -->
|
||||||
|
<span class="sub-price-wrap text-caption text-grey-5" style="white-space:nowrap" @click.stop @dblclick.stop
|
||||||
|
:title="can('edit_records') ? 'Double-clic pour modifier le prix mensuel' : ''">
|
||||||
|
(<InlineField :value="sub.actual_price || 0"
|
||||||
|
field="monthly_price" doctype="Service Subscription" :docname="sub.name"
|
||||||
|
type="number" :formatter="formatMoney"
|
||||||
|
:readonly="!can('edit_records')"
|
||||||
|
@saved="onSubPriceSaved(sub, $event)" />/m×12)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="row items-center q-pl-lg q-mt-xs q-gutter-x-md">
|
||||||
|
<div class="sub-meta">
|
||||||
|
<q-toggle size="xs" dense
|
||||||
|
:model-value="!Number(sub.cancel_at_period_end)"
|
||||||
|
@update:model-value="toggleRecurring(sub)"
|
||||||
|
:color="Number(sub.cancel_at_period_end) ? 'grey' : 'green'"
|
||||||
|
:disable="togglingRecurring === sub.name" />
|
||||||
|
<span class="text-caption" :class="Number(sub.cancel_at_period_end) ? 'text-grey-5' : 'text-green-7'">
|
||||||
|
{{ Number(sub.cancel_at_period_end) ? 'Non récurrent' : 'Récurrent' }}
|
||||||
|
</span>
|
||||||
|
<q-spinner v-if="togglingRecurring === sub.name" size="12px" class="q-ml-xs" />
|
||||||
|
</div>
|
||||||
|
<div v-if="sub.current_invoice_start" class="text-caption text-grey-6">
|
||||||
|
<q-icon name="event" size="12px" class="q-mr-xs" />
|
||||||
|
Prochaine: {{ formatDate(sub.current_invoice_start) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto sub-actions">
|
||||||
|
<q-btn flat dense round size="xs" icon="event_repeat"
|
||||||
|
@click.stop="toggleFrequency(sub)" :loading="subSaving === sub.name + ':freq'">
|
||||||
|
<q-tooltip>Passer mensuel</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-btn flat dense round size="xs"
|
||||||
|
:icon="sub.status === 'Cancelled' ? 'play_circle' : 'cancel'"
|
||||||
|
:color="sub.status === 'Cancelled' ? 'green' : 'red'"
|
||||||
|
@click.stop="toggleSubStatus(sub)" :loading="subSaving === sub.name + ':status'">
|
||||||
|
<q-tooltip>{{ sub.status === 'Cancelled' ? 'Réactiver' : 'Désactiver' }}</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-btn v-if="can('delete_records')" flat dense round size="xs" icon="delete_outline" color="negative"
|
||||||
|
@click.stop="confirmDeleteSub(sub)">
|
||||||
|
<q-tooltip>Supprimer définitivement</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sub-total-line annual">
|
||||||
|
<span class="sub-total-label">Total annuel</span>
|
||||||
|
<span class="sub-total-amount">{{ formatMoney(locSubsAnnualTotal(loc.name)) }} /an</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="q-mt-sm">
|
||||||
|
<div class="info-block-title" style="display:flex;align-items:center">
|
||||||
|
Tickets ({{ locTickets(loc.name).length }})
|
||||||
|
<q-btn flat dense round size="xs" icon="add" color="primary" class="q-ml-xs"
|
||||||
|
@click="openNewTicket(loc)" style="margin-top:-2px">
|
||||||
|
<q-tooltip>Créer un ticket pour cette adresse</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
<div v-for="t in locTickets(loc.name)" :key="t.name" class="ticket-row clickable-row" @click="openModal('Issue', t.name, t.subject)">
|
||||||
|
<div class="row items-center no-wrap">
|
||||||
|
<q-icon v-if="t.is_important" name="star" color="warning" size="14px" class="q-mr-xs" style="flex-shrink:0" />
|
||||||
|
<div class="col" style="min-width:0">
|
||||||
|
<span style="font-size:11px;color:#9e9e9e" class="q-mr-xs">{{ t.legacy_ticket_id || t.name }}</span>
|
||||||
|
<span style="font-size:12.5px">{{ t.subject }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="t.assigned_staff || t.opened_by_staff" class="avatar-stack q-mx-xs" style="flex-shrink:0">
|
||||||
|
<q-avatar v-if="t.opened_by_staff && t.opened_by_staff !== t.assigned_staff"
|
||||||
|
size="20px" class="avatar-chip" :style="{ background: staffColor(t.opened_by_staff), zIndex: 1 }">
|
||||||
|
{{ staffInitials(t.opened_by_staff) }}
|
||||||
|
<q-tooltip>{{ t.opened_by_staff }}</q-tooltip>
|
||||||
|
</q-avatar>
|
||||||
|
<q-avatar v-if="t.assigned_staff"
|
||||||
|
size="20px" class="avatar-chip" :style="{ background: staffColor(t.assigned_staff), zIndex: 2 }">
|
||||||
|
{{ staffInitials(t.assigned_staff) }}
|
||||||
|
<q-tooltip>{{ t.assigned_staff }}</q-tooltip>
|
||||||
|
</q-avatar>
|
||||||
|
</div>
|
||||||
|
<span style="font-size:11px;color:#9e9e9e;white-space:nowrap;flex-shrink:0" class="q-mx-xs">{{ t.opening_date }}</span>
|
||||||
|
<span class="ops-badge" style="font-size:10px;padding:1px 5px;flex-shrink:0" :class="ticketStatusClass(t.status)">{{ t.status }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Adresses sans abonnement regroupées : un seul bouton, masquées par défaut, révélées d'un clic -->
|
<!-- 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">
|
<div v-if="inactiveLocCount" class="text-center q-pb-sm">
|
||||||
|
|
@ -245,32 +487,111 @@
|
||||||
</div>
|
</div>
|
||||||
</q-expansion-item>
|
</q-expansion-item>
|
||||||
|
|
||||||
<!-- PAIEMENTS UNIFIÉS (méthode + transactions + ententes) — extrait → PaymentSection, T7 -->
|
<!-- PAIEMENTS UNIFIÉS : méthode (carte/PPA) + transactions (Payment Entry + charges Stripe) + ententes,
|
||||||
<PaymentSection
|
dans une seule section (fusion des ex-sections « Paiement », « Paiements » et « Ententes de paiement »). -->
|
||||||
v-model:open="sectionsOpen.payments"
|
<q-expansion-item v-model="sectionsOpen.payments" header-class="section-header" class="q-mb-sm fiche-mod" :style="moduleStyle('payments')" v-show="moduleVisible('payments')">
|
||||||
:module-style="moduleStyle('payments')"
|
<template #header>
|
||||||
:module-visible="moduleVisible('payments')"
|
<div class="section-title" style="font-size:1rem;width:100%;display:flex;align-items:center">
|
||||||
:payments="payments"
|
<q-icon name="payments" size="20px" class="q-mr-xs" />
|
||||||
:stripe-charges="stripeCharges"
|
Paiements ({{ payments.length + stripeCharges.length }}{{ (!paymentsExpanded && payments.length >= 5) ? '+' : '' }})
|
||||||
:payment-methods="paymentMethods"
|
<span v-if="paymentMethods.some(p => p.is_auto_ppa)" class="ops-badge open q-ml-sm" style="font-size:10px">PPA</span>
|
||||||
:arrangements="arrangements"
|
<q-space />
|
||||||
:refunds-by-pi="refundsByPi"
|
<div class="q-gutter-x-xs" @click.stop>
|
||||||
:payments-expanded="paymentsExpanded"
|
<q-btn flat dense size="sm" icon="send" color="primary" no-caps :loading="sendingLink" @click="sendPaymentLink('both')"><q-tooltip>Envoyer lien de paiement (SMS + Email)</q-tooltip></q-btn>
|
||||||
:loading-more-payments="loadingMorePayments"
|
<q-btn flat dense size="sm" icon="bolt" color="warning" no-caps :loading="chargingCard" @click="chargeCard()"><q-tooltip>Prélever le solde maintenant</q-tooltip></q-btn>
|
||||||
:sending-link="sendingLink"
|
<q-btn flat dense size="sm" icon="add" label="Inscrire" color="primary" no-caps @click="recordPayment()"><q-tooltip>Inscrire un paiement reçu (ouvre ERPNext, pré-rempli)</q-tooltip></q-btn>
|
||||||
:charging-card="chargingCard"
|
</div>
|
||||||
:toggling-ppa="togglingPpa"
|
</div>
|
||||||
:refunding="refunding"
|
</template>
|
||||||
:send-payment-link="sendPaymentLink"
|
|
||||||
:charge-card="chargeCard"
|
<!-- ── Méthode de paiement (carte, PPA, liens) — ex-section « Paiement » ── -->
|
||||||
:toggle-ppa="togglePpa"
|
<div class="ops-card q-mb-md">
|
||||||
:open-portal="openPortal"
|
<div class="text-caption text-weight-medium text-grey-7 q-mb-xs"><q-icon name="credit_card" size="14px" color="green-5" /> Méthode de paiement</div>
|
||||||
:create-checkout="createCheckout"
|
<div class="row q-gutter-sm q-mb-sm q-pa-sm" style="background:#f8fafc;border-radius:8px">
|
||||||
:record-payment="recordPayment"
|
<q-btn outline dense no-caps color="primary" icon="open_in_new" label="Portail Stripe" size="sm" @click="openPortal"><q-tooltip>Ouvrir le portail Stripe pour gérer la carte</q-tooltip></q-btn>
|
||||||
:confirm-refund="confirmRefund"
|
<q-btn outline dense no-caps color="green-7" icon="add_card" label="Ajouter carte" size="sm" @click="createCheckout"><q-tooltip>Créer un lien Stripe Checkout (copié au presse-papier)</q-tooltip></q-btn>
|
||||||
:load-all-payments="loadAllPayments"
|
<q-btn outline dense no-caps icon="sms" label="Lien SMS" size="sm" color="info" :loading="sendingLink" @click="sendPaymentLink('sms')"><q-tooltip>Envoyer lien de paiement par SMS</q-tooltip></q-btn>
|
||||||
:open-modal="openModal"
|
<q-btn outline dense no-caps icon="email" label="Lien Email" size="sm" color="purple-6" :loading="sendingLink" @click="sendPaymentLink('email')"><q-tooltip>Envoyer lien de paiement par Email</q-tooltip></q-btn>
|
||||||
/>
|
</div>
|
||||||
|
<div v-if="!paymentMethods.length" class="text-center text-grey-6 q-pa-sm">Aucune méthode de paiement</div>
|
||||||
|
<DataTable v-else :rows="paymentMethods" :columns="paymentMethodCols" row-key="name" flat dense class="ops-table clickable-table" :pagination="{ rowsPerPage: 10 }" @row-click="(_, row) => openModal('Payment Method', row.name, row.provider)">
|
||||||
|
<template #body-cell-is_auto_ppa="props">
|
||||||
|
<q-td :props="props" @click.stop>
|
||||||
|
<q-toggle v-if="props.row.provider === 'Stripe'" dense size="sm" :model-value="!!props.row.is_auto_ppa" :loading="togglingPpa" @update:model-value="v => togglePpa(v).then(ok => { if (ok) props.row.is_auto_ppa = v ? 1 : 0 })" />
|
||||||
|
<q-icon v-else-if="props.row.is_auto_ppa" name="autorenew" size="16px" color="green-6" />
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
<template #body-cell-provider="props">
|
||||||
|
<q-td :props="props">
|
||||||
|
<q-icon :name="props.row.provider === 'Stripe' ? 'credit_card' : props.row.provider === 'Bank Draft' ? 'account_balance' : 'payment'" size="14px" class="q-mr-xs" />
|
||||||
|
{{ props.row.provider }}
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Transactions (Payment Entry ERPNext + charges Stripe réelles) ── -->
|
||||||
|
<div class="ops-card q-mb-md">
|
||||||
|
<div class="text-caption text-weight-medium text-grey-7 q-mb-xs"><q-icon name="receipt_long" size="14px" color="green-5" /> Transactions</div>
|
||||||
|
<div v-if="!payments.length && !stripeCharges.length" class="text-center text-grey-6 q-pa-sm">Aucune transaction</div>
|
||||||
|
<template v-else>
|
||||||
|
<DataTable v-if="payments.length" :rows="payments" :columns="paymentCols" row-key="name"
|
||||||
|
flat dense class="ops-table clickable-table"
|
||||||
|
:pagination="{ rowsPerPage: 10, sortBy: 'posting_date', descending: true }"
|
||||||
|
@row-click="(_, row) => openModal('Payment Entry', row.name, 'Paiement ' + row.name)">
|
||||||
|
<template #body-cell-paid_amount="props">
|
||||||
|
<q-td :props="props" class="text-right">
|
||||||
|
{{ formatMoney(props.row.paid_amount) }}
|
||||||
|
<q-badge v-if="refundsByPi[props.row.reference_no]" color="amber-1" text-color="amber-9" class="q-ml-xs" style="font-weight:600">
|
||||||
|
<q-icon name="undo" size="12px" class="q-mr-xs" />remboursé {{ formatMoney(refundsByPi[props.row.reference_no].amount_refunded) }}<span v-if="refundsByPi[props.row.reference_no].refunded_at"> · {{ refundsByPi[props.row.reference_no].refunded_at }}</span>
|
||||||
|
<q-tooltip>Remboursement Stripe {{ refundsByPi[props.row.reference_no].fully_refunded ? 'complet' : 'partiel' }} · statut {{ refundsByPi[props.row.reference_no].status }} · le délai bancaire est de 5-10 jours ouvrables</q-tooltip>
|
||||||
|
</q-badge>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
<template #body-cell-actions="props">
|
||||||
|
<q-td :props="props" @click.stop>
|
||||||
|
<q-icon v-if="refundsByPi[props.row.reference_no] && refundsByPi[props.row.reference_no].fully_refunded" name="check_circle" color="green-6" size="18px"><q-tooltip>Déjà remboursé via Stripe{{ refundsByPi[props.row.reference_no].refunded_at ? ' le ' + refundsByPi[props.row.reference_no].refunded_at : '' }}</q-tooltip></q-icon>
|
||||||
|
<q-btn v-else-if="props.row.reference_no && props.row.reference_no.startsWith('pi_')"
|
||||||
|
flat round dense size="xs" icon="replay" color="warning" :loading="refunding"
|
||||||
|
@click="confirmRefund(props.row)">
|
||||||
|
<q-tooltip>{{ refundsByPi[props.row.reference_no] ? 'Rembourser le reste via Stripe' : 'Rembourser via Stripe' }}</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
<div v-if="!paymentsExpanded && payments.length >= 5" class="text-center q-pa-xs">
|
||||||
|
<q-btn flat dense no-caps color="primary" :loading="loadingMorePayments" label="Voir tous les paiements" icon="expand_more" @click="loadAllPayments" />
|
||||||
|
</div>
|
||||||
|
<div v-if="stripeCharges.length" :class="{ 'q-mt-sm': payments.length }" :style="payments.length ? 'border-top:1px solid var(--ops-border,#e5e7eb);padding-top:8px' : ''">
|
||||||
|
<div class="text-caption text-grey-6 q-mb-xs"><q-icon name="credit_card" size="13px" /> Charges Stripe ({{ stripeCharges.length }})<q-tooltip>Transactions Stripe réelles — peuvent ne pas être (encore) enregistrées comme paiement ERPNext</q-tooltip></div>
|
||||||
|
<q-list dense separator>
|
||||||
|
<q-item v-for="c in stripeCharges" :key="c.id" :clickable="!!c.receipt_url" tag="a" :href="c.receipt_url || undefined" target="_blank" rel="noopener">
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>{{ formatMoney(c.amount) }}<span v-if="c.brand" class="text-grey-6"> · {{ c.brand }} ••{{ c.last4 }}</span></q-item-label>
|
||||||
|
<q-item-label caption>{{ c.created }}<span v-if="c.description"> · {{ c.description }}</span></q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section side>
|
||||||
|
<span class="ops-badge" :class="c.refunded ? 'inactive' : (c.paid && c.status === 'succeeded' ? 'paid' : 'open')">{{ c.refunded ? 'Remboursé' : (c.paid && c.status === 'succeeded' ? 'Payé' : c.status) }}</span>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Ententes de paiement — ex-section séparée, fusionnée ici ── -->
|
||||||
|
<div v-if="arrangements.length" class="ops-card q-mb-md">
|
||||||
|
<div class="text-caption text-weight-medium text-grey-7 q-mb-xs"><q-icon name="handshake" size="14px" color="green-5" /> Ententes de paiement ({{ arrangements.length }})<span v-if="arrangements.filter(a => a.status === 'Open').length" class="text-caption text-blue q-ml-sm">{{ arrangements.filter(a => a.status === 'Open').length }} actives</span></div>
|
||||||
|
<DataTable :rows="arrangements" :columns="arrangementCols" row-key="name" flat dense class="ops-table clickable-table" :pagination="{ rowsPerPage: 10, sortBy: 'date_agreed', descending: true }" @row-click="(_, row) => openModal('Payment Arrangement', row.name, 'Entente ' + row.name)">
|
||||||
|
<template #body-cell-status="props">
|
||||||
|
<q-td :props="props"><span class="ops-badge" :class="props.row.status === 'Open' ? 'open' : props.row.status === 'Completed' ? 'paid' : 'inactive'">{{ props.row.status }}</span></q-td>
|
||||||
|
</template>
|
||||||
|
<template #body-cell-total_amount="props">
|
||||||
|
<q-td :props="props" class="text-right">{{ formatMoney(props.row.total_amount) }}</q-td>
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
</q-expansion-item>
|
||||||
|
|
||||||
<!-- VoIP Lines -->
|
<!-- 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')">
|
<q-expansion-item v-model="sectionsOpen.voip" header-class="section-header" class="q-mb-sm fiche-mod" :style="moduleStyle('voip')" v-show="moduleVisible('voip')">
|
||||||
|
|
@ -740,13 +1061,13 @@ import { authFetch } from 'src/api/auth'
|
||||||
import { BASE_URL, ERP_DESK_URL } from 'src/config/erpnext'
|
import { BASE_URL, ERP_DESK_URL } from 'src/config/erpnext'
|
||||||
import { HUB_URL } from 'src/config/hub'
|
import { HUB_URL } from 'src/config/hub'
|
||||||
import { formatDate, formatDateTime, formatMoney, staffColor, staffInitials, noteAuthorName } from 'src/composables/useFormatters'
|
import { formatDate, formatDateTime, formatMoney, staffColor, staffInitials, noteAuthorName } from 'src/composables/useFormatters'
|
||||||
import { ticketStatusClass, invStatusClass, priorityClass } from 'src/composables/useStatusClasses'
|
import { locStatusClass, ticketStatusClass, invStatusClass, priorityClass } from 'src/composables/useStatusClasses'
|
||||||
import { useDetailModal } from 'src/composables/useDetailModal'
|
import { useDetailModal } from 'src/composables/useDetailModal'
|
||||||
import { useSubscriptionGroups } from 'src/composables/useSubscriptionGroups'
|
import { useSubscriptionGroups, isRebate, subMainLabel, sectionTotal, annualPrice } from 'src/composables/useSubscriptionGroups'
|
||||||
import { useSubscriptionActions } from 'src/composables/useSubscriptionActions'
|
import { useSubscriptionActions } from 'src/composables/useSubscriptionActions'
|
||||||
import { useCustomerNotes } from 'src/composables/useCustomerNotes'
|
import { useCustomerNotes } from 'src/composables/useCustomerNotes'
|
||||||
import { useConversations } from 'src/composables/useConversations'
|
import { useConversations } from 'src/composables/useConversations'
|
||||||
import { invoiceCols, ticketCols, voipCols, quotationCols, serviceContractCols } from 'src/config/table-columns'
|
import { invoiceCols, paymentCols, ticketCols, voipCols, paymentMethodCols, arrangementCols, quotationCols, serviceContractCols } from 'src/config/table-columns'
|
||||||
import DetailModal from 'src/components/shared/DetailModal.vue'
|
import DetailModal from 'src/components/shared/DetailModal.vue'
|
||||||
import DataTable from 'src/components/shared/DataTable.vue'
|
import DataTable from 'src/components/shared/DataTable.vue'
|
||||||
import CustomerHeader from 'src/components/customer/CustomerHeader.vue'
|
import CustomerHeader from 'src/components/customer/CustomerHeader.vue'
|
||||||
|
|
@ -764,12 +1085,11 @@ import { usePermissions } from 'src/composables/usePermissions'
|
||||||
import { usePaymentActions } from 'src/composables/usePaymentActions'
|
import { usePaymentActions } from 'src/composables/usePaymentActions'
|
||||||
import { useClientData } from 'src/composables/useClientData'
|
import { useClientData } from 'src/composables/useClientData'
|
||||||
import { useEquipmentActions } from 'src/composables/useEquipmentActions'
|
import { useEquipmentActions } from 'src/composables/useEquipmentActions'
|
||||||
import { equipTypeOptions, equipStatusOptions, defaultSectionsOpen, phoneLabelMap, CLIENT_360_MODULES } from 'src/data/client-constants'
|
import { locInlineFields, equipTypeOptions, equipStatusOptions, defaultSectionsOpen, phoneLabelMap, CLIENT_360_MODULES } from 'src/data/client-constants'
|
||||||
import { useUserPrefs } from 'src/composables/useUserPrefs'
|
import { useUserPrefs } from 'src/composables/useUserPrefs'
|
||||||
import { erpPdfUrl } from 'src/utils/erp-pdf'
|
import { erpPdfUrl } from 'src/utils/erp-pdf'
|
||||||
import RewardDialog from 'src/components/customer/RewardDialog.vue'
|
import RewardDialog from 'src/components/customer/RewardDialog.vue'
|
||||||
import LocationCard from 'src/components/customer/LocationCard.vue'
|
import DeviceStrip from 'src/components/customer/DeviceStrip.vue'
|
||||||
import PaymentSection from 'src/components/customer/PaymentSection.vue'
|
|
||||||
|
|
||||||
const $q = useQuasar()
|
const $q = useQuasar()
|
||||||
const { can } = usePermissions()
|
const { can } = usePermissions()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user