Backend services: - targo-hub: extract deepGetValue to helpers.js, DRY disconnect reasons lookup map, compact CAPABILITIES, consolidate vision.js prompts/schemas, extract dispatch scoring weights, trim section dividers across 9 files - modem-bridge: extract getSession() helper (6 occurrences), resetIdleTimer(), consolidate DM query factory, fix duplicate username fill bug, trim headers (server.js -36%, tplink-session.js -47%, docker-compose.yml -57%) Frontend: - useWifiDiagnostic: extract THRESHOLDS const, split processDiagnostic into 6 focused helpers (processOnlineStatus, processWanIPs, processRadios, processMeshNodes, processClients, checkRadioIssues) - EquipmentDetail: merge duplicate ROLE_LABELS, remove verbose comments Documentation (17 → 13 files, -1,400 lines): - New consolidated README.md (architecture, services, dependencies, auth) - Merge ECOSYSTEM-OVERVIEW into ARCHITECTURE.md - Merge MIGRATION-PLAN + ARCHITECTURE-COMPARE + FIELD-GAP + CHANGELOG → MIGRATION.md - Merge COMPETITIVE-ANALYSIS into PLATFORM-STRATEGY.md - Update ROADMAP.md with current phase status - Delete CONTEXT.md (absorbed into README) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
306 lines
12 KiB
Vue
306 lines
12 KiB
Vue
<template>
|
|
<q-page padding>
|
|
<div class="page-title">Mon compte</div>
|
|
|
|
<div class="row q-col-gutter-md">
|
|
<!-- Customer info -->
|
|
<div class="col-12 col-md-6">
|
|
<div class="portal-card">
|
|
<div class="text-subtitle1 text-weight-medium q-mb-md">Informations</div>
|
|
<div class="q-gutter-sm">
|
|
<div><strong>Nom:</strong> {{ store.customerName }}</div>
|
|
<div><strong>Courriel:</strong> {{ store.email }}</div>
|
|
<div><strong>No. client:</strong> {{ store.customerId }}</div>
|
|
<div v-if="profile"><strong>Langue:</strong> {{ profile.language || 'fr' }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Services & Locations -->
|
|
<div class="col-12 col-md-6">
|
|
<div class="portal-card">
|
|
<div class="text-subtitle1 text-weight-medium q-mb-md">Adresses de service</div>
|
|
|
|
<!-- Monthly total banner (shown when multiple locations) -->
|
|
<div v-if="serviceLocations.length > 1 && grandMonthlyTotal !== null" class="monthly-total-banner q-mb-md">
|
|
<div class="row items-center justify-between">
|
|
<div>
|
|
<div class="text-caption text-grey-7">Total mensuel estimé</div>
|
|
<div class="text-h5 text-weight-bold" :class="grandMonthlyTotal >= 0 ? 'text-primary' : 'text-positive'">
|
|
{{ formatMoney(grandMonthlyTotal) }}<span class="text-caption text-grey-6"> /mois</span>
|
|
</div>
|
|
</div>
|
|
<div class="text-caption text-grey-6">
|
|
{{ serviceLocations.length }} adresses · {{ subscriptionCount }} services
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="loadingServices" class="q-pa-md text-center">
|
|
<q-spinner-dots size="28px" color="primary" />
|
|
</div>
|
|
<div v-else-if="!serviceLocations.length" class="text-grey-6">Aucune adresse enregistrée</div>
|
|
|
|
<!-- Location cards -->
|
|
<div v-else class="q-gutter-sm">
|
|
<div v-for="loc in serviceLocations" :key="loc.name" class="location-card">
|
|
<!-- Location header -->
|
|
<div class="row items-center q-mb-xs">
|
|
<q-icon name="place" size="18px" color="primary" class="q-mr-xs" />
|
|
<div class="text-weight-medium">{{ loc.location_name || loc.address_line }}</div>
|
|
<q-space />
|
|
<q-badge v-if="loc.connection_type" outline color="grey-7" :label="loc.connection_type" class="q-mr-xs" />
|
|
<q-badge :color="loc.status === 'Active' ? 'positive' : 'grey'" :label="loc.status" />
|
|
</div>
|
|
<div class="text-caption text-grey-6 q-ml-md q-mb-sm">
|
|
{{ loc.address_line }}, {{ loc.city }} {{ loc.postal_code }}
|
|
</div>
|
|
|
|
<!-- Subscriptions for this location -->
|
|
<div v-if="loc.subscriptions.length" class="q-ml-md">
|
|
<div v-for="sub in loc.subscriptions" :key="sub.name" class="subscription-row">
|
|
<div class="row items-center no-wrap">
|
|
<q-icon :name="categoryIcon(sub.service_category)" size="16px" :color="categoryColor(sub.service_category)" class="q-mr-sm" />
|
|
<div class="col">
|
|
<div class="text-body2">{{ sub.plan_name }}</div>
|
|
<div v-if="sub.speed_down" class="text-caption text-grey-6">
|
|
{{ sub.speed_down }} / {{ sub.speed_up }} Mbps
|
|
</div>
|
|
</div>
|
|
<div class="text-right">
|
|
<div class="text-body2 text-weight-medium" :class="sub.monthly_price < 0 ? 'text-positive' : ''">
|
|
{{ formatMoney(sub.monthly_price) }}
|
|
</div>
|
|
<div class="text-caption text-grey-6">/mois</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Location subtotal -->
|
|
<div class="location-subtotal q-mt-xs">
|
|
<div class="row items-center justify-end">
|
|
<span class="text-caption text-grey-7 q-mr-sm">Sous-total:</span>
|
|
<span class="text-body2 text-weight-bold">{{ formatMoney(loc.monthly_total) }}/mois</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-else class="text-caption text-grey-5 q-ml-md">Aucun service actif</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Payment methods -->
|
|
<div class="col-12">
|
|
<div class="portal-card">
|
|
<div class="row items-center q-mb-md">
|
|
<q-icon name="credit_card" size="24px" color="indigo" class="q-mr-sm" />
|
|
<div class="text-subtitle1 text-weight-medium">Paiement</div>
|
|
<q-space />
|
|
<q-btn outline color="indigo" label="Ajouter une carte" icon="add_card" no-caps size="sm"
|
|
:loading="addingCard" @click="addCard" />
|
|
<q-btn v-if="hasStripeCards" flat color="grey-7" label="Gerer les cartes" icon="settings" no-caps size="sm"
|
|
class="q-ml-sm" :loading="openingPortal" @click="openPortal" />
|
|
</div>
|
|
|
|
<!-- Balance -->
|
|
<div v-if="balance !== null" class="q-mb-md">
|
|
<div class="row items-center q-gutter-sm">
|
|
<span class="text-body2">Solde a payer:</span>
|
|
<span class="text-h6 text-weight-bold" :class="balance > 0 ? 'text-negative' : 'text-positive'">
|
|
{{ formatMoney(balance) }}
|
|
</span>
|
|
<q-btn v-if="balance > 0" color="indigo" unelevated label="Payer le solde" icon="payment"
|
|
no-caps size="sm" :loading="payingBalance" @click="payBalance" class="q-ml-md" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Saved cards -->
|
|
<div v-if="loadingPayment" class="q-pa-sm">
|
|
<q-spinner-dots size="24px" color="indigo" />
|
|
</div>
|
|
<div v-else-if="cards.length" class="q-mb-md">
|
|
<div class="text-caption text-weight-bold text-grey-7 q-mb-xs">Cartes enregistrees</div>
|
|
<q-list bordered separator class="rounded-borders">
|
|
<q-item v-for="card in cards" :key="card.id">
|
|
<q-item-section avatar>
|
|
<q-icon :name="cardIcon(card.brand)" size="28px" :color="card.is_default ? 'indigo' : 'grey-6'" />
|
|
</q-item-section>
|
|
<q-item-section>
|
|
<q-item-label>{{ cardBrandLabel(card.brand) }} **** {{ card.last4 }}</q-item-label>
|
|
<q-item-label caption>Exp. {{ card.exp_month }}/{{ card.exp_year }}</q-item-label>
|
|
</q-item-section>
|
|
<q-item-section side v-if="card.is_default">
|
|
<q-badge color="indigo" label="Par defaut" />
|
|
</q-item-section>
|
|
</q-item>
|
|
</q-list>
|
|
</div>
|
|
<div v-else class="text-grey-6 text-body2 q-mb-md">
|
|
Aucune carte enregistree. Ajoutez une carte pour activer le paiement automatique.
|
|
</div>
|
|
|
|
<!-- PPA toggle -->
|
|
<div class="q-pa-sm rounded-borders" style="background: #f8f9fc;">
|
|
<div class="row items-center">
|
|
<div class="col">
|
|
<div class="text-weight-medium">Paiement automatique (PPA)</div>
|
|
<div class="text-caption text-grey-7">
|
|
Vos factures seront payees automatiquement avec votre carte par defaut.
|
|
</div>
|
|
</div>
|
|
<div class="col-auto">
|
|
<q-toggle v-model="ppaEnabled" color="indigo" :disable="!hasStripeCards || togglingPPA"
|
|
@update:model-value="onTogglePPA" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</q-page>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { useQuasar } from 'quasar'
|
|
import { useCustomerStore } from 'src/stores/customer'
|
|
import { fetchProfile, fetchServiceLocations } from 'src/api/portal'
|
|
import { getBalance, getPaymentMethods, checkoutBalance, setupCard, openBillingPortal, togglePPA } from 'src/api/payments'
|
|
import { useFormatters } from 'src/composables/useFormatters'
|
|
|
|
const $q = useQuasar()
|
|
const store = useCustomerStore()
|
|
const { formatMoney } = useFormatters()
|
|
|
|
const profile = ref(null)
|
|
const serviceLocations = ref([])
|
|
const grandMonthlyTotal = ref(null)
|
|
const subscriptionCount = ref(0)
|
|
const loadingServices = ref(true)
|
|
const balance = ref(null)
|
|
const cards = ref([])
|
|
const ppaEnabled = ref(false)
|
|
const loadingPayment = ref(true)
|
|
const addingCard = ref(false)
|
|
const openingPortal = ref(false)
|
|
const payingBalance = ref(false)
|
|
const togglingPPA = ref(false)
|
|
|
|
const hasStripeCards = computed(() => cards.value.length > 0)
|
|
|
|
function cardIcon (brand) {
|
|
const icons = { visa: 'credit_card', mastercard: 'credit_card', amex: 'credit_card' }
|
|
return icons[brand] || 'credit_card'
|
|
}
|
|
|
|
function cardBrandLabel (brand) {
|
|
const labels = { visa: 'Visa', mastercard: 'Mastercard', amex: 'Amex', discover: 'Discover' }
|
|
return labels[brand] || (brand || 'Carte').charAt(0).toUpperCase() + (brand || 'carte').slice(1)
|
|
}
|
|
|
|
async function loadPaymentInfo () {
|
|
if (!store.customerId) return
|
|
loadingPayment.value = true
|
|
try {
|
|
const [balRes, methRes] = await Promise.all([
|
|
getBalance(store.customerId),
|
|
getPaymentMethods(store.customerId),
|
|
])
|
|
balance.value = balRes.balance || 0
|
|
|
|
// Extract cards from Stripe methods
|
|
const methods = methRes.methods || []
|
|
const stripeMethod = methods.find(m => m.provider === 'Stripe')
|
|
cards.value = stripeMethod?.stripe_cards || []
|
|
ppaEnabled.value = !!(stripeMethod?.is_auto_ppa)
|
|
} catch (e) {
|
|
console.error('Payment info load error:', e)
|
|
} finally {
|
|
loadingPayment.value = false
|
|
}
|
|
}
|
|
|
|
async function addCard () {
|
|
addingCard.value = true
|
|
try {
|
|
const result = await setupCard(store.customerId)
|
|
if (result.url) window.location.href = result.url
|
|
} catch (e) {
|
|
$q.notify({ message: 'Erreur: ' + e.message, color: 'negative' })
|
|
} finally {
|
|
addingCard.value = false
|
|
}
|
|
}
|
|
|
|
async function openPortal () {
|
|
openingPortal.value = true
|
|
try {
|
|
const result = await openBillingPortal(store.customerId)
|
|
if (result.url) window.location.href = result.url
|
|
} catch (e) {
|
|
$q.notify({ message: 'Erreur: ' + e.message, color: 'negative' })
|
|
} finally {
|
|
openingPortal.value = false
|
|
}
|
|
}
|
|
|
|
async function payBalance () {
|
|
payingBalance.value = true
|
|
try {
|
|
const result = await checkoutBalance(store.customerId)
|
|
if (result.url) window.location.href = result.url
|
|
} catch (e) {
|
|
$q.notify({ message: 'Erreur: ' + e.message, color: 'negative' })
|
|
} finally {
|
|
payingBalance.value = false
|
|
}
|
|
}
|
|
|
|
async function onTogglePPA (val) {
|
|
togglingPPA.value = true
|
|
try {
|
|
await togglePPA(store.customerId, val)
|
|
$q.notify({
|
|
message: val ? 'Paiement automatique active' : 'Paiement automatique desactive',
|
|
color: 'positive', icon: val ? 'check_circle' : 'info',
|
|
})
|
|
} catch (e) {
|
|
ppaEnabled.value = !val // revert
|
|
$q.notify({ message: 'Erreur: ' + e.message, color: 'negative' })
|
|
} finally {
|
|
togglingPPA.value = false
|
|
}
|
|
}
|
|
|
|
function categoryIcon (cat) {
|
|
const icons = {
|
|
Internet: 'wifi', IPTV: 'tv', VoIP: 'phone', Bundle: 'inventory_2',
|
|
'Hébergement': 'dns', Autre: 'category',
|
|
}
|
|
return icons[cat] || 'category'
|
|
}
|
|
|
|
function categoryColor (cat) {
|
|
const colors = {
|
|
Internet: 'blue', IPTV: 'purple', VoIP: 'green', Bundle: 'orange',
|
|
'Hébergement': 'cyan', Autre: 'grey',
|
|
}
|
|
return colors[cat] || 'grey'
|
|
}
|
|
|
|
onMounted(async () => {
|
|
if (!store.customerId) return
|
|
const [p, svcResult] = await Promise.all([
|
|
fetchProfile(store.customerId),
|
|
fetchServiceLocations(store.customerId).catch(() => ({ locations: [], grandTotal: 0, subscriptionCount: 0 })),
|
|
])
|
|
profile.value = p
|
|
serviceLocations.value = svcResult.locations
|
|
grandMonthlyTotal.value = svcResult.grandTotal
|
|
subscriptionCount.value = svcResult.subscriptionCount
|
|
loadingServices.value = false
|
|
loadPaymentInfo()
|
|
})
|
|
</script>
|