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

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

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

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

233 lines
7.9 KiB
Vue

<template>
<q-page padding>
<!-- Header -->
<div class="flex items-center q-mb-md">
<q-btn flat round icon="arrow_back" @click="$router.push('/invoices')" />
<div class="page-title q-mb-none q-ml-sm">{{ invoiceName }}</div>
<q-space />
<q-btn outline color="primary" icon="picture_as_pdf" label="PDF"
:loading="downloadingPDF" @click="downloadPDF" no-caps class="q-mr-sm" size="sm" />
</div>
<!-- Payment CTA for unpaid invoices -->
<q-banner v-if="invoice && canPay" rounded class="bg-indigo-1 q-mb-md">
<template #avatar><q-icon name="payment" color="indigo" /></template>
<div class="row items-center">
<div class="col">
<div class="text-weight-medium">Solde a payer: <strong class="text-negative">{{ formatMoney(invoice.outstanding_amount) }}</strong></div>
<div class="text-caption text-grey-7">Echeance: {{ formatDate(invoice.due_date) }}</div>
</div>
<div class="col-auto q-gutter-sm">
<q-btn color="indigo" unelevated label="Payer maintenant" icon="credit_card"
:loading="payLoading" @click="payInvoice('card')" no-caps />
<q-btn outline color="pink-7" label="Payer avec Klarna" icon="account_balance"
:loading="payKlarnaLoading" @click="payInvoice('klarna')" no-caps size="sm" />
</div>
</div>
<div class="q-mt-xs">
<q-checkbox v-model="saveCard" dense size="sm">
<span class="text-caption">Sauvegarder ma carte pour les prochains paiements automatiques</span>
</q-checkbox>
</div>
</q-banner>
<!-- Summary bar -->
<div v-if="invoice" class="row q-col-gutter-sm q-mb-md">
<div class="col-6 col-sm-3">
<div class="portal-card text-center">
<div class="text-caption text-grey-7">Total</div>
<div class="text-h6">{{ formatMoney(invoice.grand_total) }}</div>
</div>
</div>
<div class="col-6 col-sm-3">
<div class="portal-card text-center">
<div class="text-caption text-grey-7">Solde</div>
<div class="text-h6" :class="invoice.outstanding_amount > 0 ? 'text-negative' : 'text-positive'">
{{ formatMoney(invoice.outstanding_amount) }}
</div>
</div>
</div>
<div class="col-6 col-sm-3">
<div class="portal-card text-center">
<div class="text-caption text-grey-7">Date</div>
<div class="text-body1">{{ formatDate(invoice.posting_date) }}</div>
</div>
</div>
<div class="col-6 col-sm-3">
<div class="portal-card text-center">
<div class="text-caption text-grey-7">Statut</div>
<q-badge :color="statusColor(invoice.status)" :label="statusLabel(invoice.status)" class="q-mt-xs" />
</div>
</div>
</div>
<!-- Line items table -->
<div v-if="invoice && invoice.items" class="q-mb-md">
<div class="text-subtitle1 text-weight-medium q-mb-sm">Details</div>
<q-table
:rows="invoice.items"
:columns="itemColumns"
row-key="name"
flat bordered
class="bg-white"
hide-pagination
:pagination="{ rowsPerPage: 0 }"
>
<template #body-cell-rate="props">
<q-td :props="props" class="text-right">{{ formatMoney(props.value) }}</q-td>
</template>
<template #body-cell-amount="props">
<q-td :props="props" class="text-right text-weight-medium">{{ formatMoney(props.value) }}</q-td>
</template>
</q-table>
<!-- Totals -->
<div class="bg-white q-pa-md rounded-borders" style="border: 1px solid #e0e0e0; border-top: none;">
<div class="row justify-end q-gutter-sm">
<div v-if="invoice.total_taxes_and_charges" class="text-right">
<span class="text-grey-7">Taxes: </span>
<span class="text-weight-medium">{{ formatMoney(invoice.total_taxes_and_charges) }}</span>
</div>
<div class="text-right q-ml-lg">
<span class="text-grey-7">Grand total: </span>
<span class="text-h6 text-weight-bold">{{ formatMoney(invoice.grand_total) }}</span>
</div>
</div>
</div>
</div>
<!-- Jinja rendered preview -->
<div v-if="printHTML" class="q-mb-md">
<div class="text-subtitle1 text-weight-medium q-mb-sm">Apercu de la facture</div>
<div class="bg-white q-pa-md rounded-borders invoice-preview" style="border: 1px solid #e0e0e0;">
<div v-html="printHTML" />
</div>
</div>
<!-- Loading -->
<div v-if="loading" class="flex flex-center q-pa-xl">
<q-spinner-dots size="48px" color="primary" />
</div>
</q-page>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useQuasar } from 'quasar'
import { useCustomerStore } from 'src/stores/customer'
import { fetchInvoice, fetchInvoiceHTML, fetchInvoicePDF } from 'src/api/portal'
import { checkoutInvoice } from 'src/api/payments'
import { useFormatters } from 'src/composables/useFormatters'
const route = useRoute()
const router = useRouter()
const $q = useQuasar()
const store = useCustomerStore()
const { formatDate, formatMoney } = useFormatters()
const invoiceName = route.params.name
const invoice = ref(null)
const printHTML = ref('')
const loading = ref(true)
const downloadingPDF = ref(false)
const payLoading = ref(false)
const payKlarnaLoading = ref(false)
const saveCard = ref(true)
const canPay = computed(() => {
if (!invoice.value) return false
return invoice.value.docstatus === 1 && invoice.value.outstanding_amount > 0
})
const itemColumns = [
{ name: 'item_name', label: 'Description', field: 'item_name', align: 'left' },
{ name: 'qty', label: 'Qte', field: 'qty', align: 'center' },
{ name: 'rate', label: 'Prix unitaire', field: 'rate', align: 'right' },
{ name: 'amount', label: 'Montant', field: 'amount', align: 'right' },
]
function statusColor (s) {
if (s === 'Paid') return 'positive'
if (s === 'Overdue') return 'negative'
if (s === 'Unpaid') return 'warning'
return 'grey'
}
function statusLabel (s) {
const map = { Paid: 'Payee', Unpaid: 'Impayee', Overdue: 'En retard', 'Partly Paid': 'Partielle' }
return map[s] || s
}
async function payInvoice (method) {
const loadRef = method === 'klarna' ? payKlarnaLoading : payLoading
loadRef.value = true
try {
const result = await checkoutInvoice(store.customerId, invoiceName, {
save_card: saveCard.value,
payment_method: method,
})
if (result.url) {
// Redirect to Stripe Checkout
window.location.href = result.url
}
} catch (e) {
$q.notify({ message: 'Erreur: ' + (e.message || 'Paiement impossible'), color: 'negative', icon: 'error' })
} finally {
loadRef.value = false
}
}
async function downloadPDF () {
downloadingPDF.value = true
try {
const blob = await fetchInvoicePDF(invoiceName)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${invoiceName}.pdf`
a.click()
URL.revokeObjectURL(url)
} finally {
downloadingPDF.value = false
}
}
onMounted(async () => {
if (!store.customerId) return
try {
const [inv, html] = await Promise.allSettled([
fetchInvoice(invoiceName),
fetchInvoiceHTML(invoiceName),
])
if (inv.status === 'fulfilled') {
// Security: verify this invoice belongs to the logged-in customer
if (inv.value.customer !== store.customerId) {
router.replace('/invoices')
return
}
invoice.value = inv.value
}
if (html.status === 'fulfilled' && html.value.html) {
printHTML.value = html.value.html
}
} finally {
loading.value = false
}
})
</script>
<style scoped>
.invoice-preview {
overflow-x: auto;
max-height: 80vh;
overflow-y: auto;
}
.invoice-preview :deep(table) {
width: 100%;
}
.invoice-preview :deep(img) {
max-width: 100%;
}
</style>