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>
233 lines
7.9 KiB
Vue
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>
|