Ops app (Vue/Quasar PWA) with dispatch V2 integration, tag system, customer 360, tickets, and dashboard. Served via standalone nginx container at erp.gigafibre.ca/ops/ with Traefik StripPrefix + Authentik SSO. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
419 lines
20 KiB
Vue
419 lines
20 KiB
Vue
<template>
|
|
<q-dialog :model-value="open" @update:model-value="$emit('update:open', $event)" position="right" full-height>
|
|
<q-card style="width:600px;max-width:90vw" class="column no-wrap">
|
|
<!-- Header -->
|
|
<q-card-section class="row items-center q-pb-sm" style="border-bottom:1px solid var(--ops-border, #e2e8f0)">
|
|
<div class="col">
|
|
<div class="text-subtitle1 text-weight-bold">
|
|
<slot name="title-prefix" />
|
|
{{ title }}
|
|
</div>
|
|
<div class="text-caption text-grey-6">
|
|
{{ docName }}
|
|
<slot name="title-suffix" />
|
|
</div>
|
|
</div>
|
|
<!-- Action buttons -->
|
|
<slot name="header-actions" />
|
|
<q-btn v-if="doctype === 'Sales Invoice'" flat round dense icon="picture_as_pdf"
|
|
@click="$emit('open-pdf', docName)" class="q-mr-xs" color="red-7">
|
|
<q-tooltip>Voir PDF</q-tooltip>
|
|
</q-btn>
|
|
<q-btn flat round dense icon="open_in_new" @click="openExternal(erpLinkUrl)" class="q-mr-xs" />
|
|
<q-btn flat round dense icon="close" @click="$emit('update:open', false)" />
|
|
</q-card-section>
|
|
|
|
<!-- Loading -->
|
|
<q-card-section v-if="loading" class="flex flex-center" style="min-height:200px">
|
|
<q-spinner size="32px" color="indigo-6" />
|
|
</q-card-section>
|
|
|
|
<!-- Content -->
|
|
<q-card-section v-else-if="doc" class="col q-pt-sm" style="overflow-y:auto">
|
|
|
|
<!-- ═══ Sales Invoice ═══ -->
|
|
<template v-if="doctype === 'Sales Invoice'">
|
|
<div class="modal-field-grid">
|
|
<div class="mf"><span class="mf-label">Date</span>{{ doc.posting_date }}</div>
|
|
<div class="mf"><span class="mf-label">Echeance</span>{{ doc.due_date || '---' }}</div>
|
|
<div class="mf"><span class="mf-label">Statut</span><span class="ops-badge" :class="invStatusClass(doc.status)">{{ doc.status }}</span></div>
|
|
<div class="mf"><span class="mf-label">Total HT</span>{{ formatMoney(doc.net_total) }}</div>
|
|
<div class="mf"><span class="mf-label">Taxes</span>{{ formatMoney(doc.total_taxes_and_charges) }}</div>
|
|
<div class="mf"><span class="mf-label">Total TTC</span><strong>{{ formatMoney(doc.grand_total) }}</strong></div>
|
|
<div class="mf"><span class="mf-label">Solde du</span><span :class="{'text-red': doc.outstanding_amount > 0}">{{ formatMoney(doc.outstanding_amount) }}</span></div>
|
|
<div class="mf"><span class="mf-label">Devise</span>{{ doc.currency || 'CAD' }}</div>
|
|
<div class="mf" v-if="doc.is_return"><span class="mf-label">Type</span><span class="text-red text-weight-medium">Note de credit</span></div>
|
|
<div class="mf" v-if="doc.return_against"><span class="mf-label">Renversement de</span><a class="text-indigo-6 cursor-pointer" @click="$emit('navigate', 'Sales Invoice', doc.return_against, 'Facture ' + doc.return_against)">{{ doc.return_against }}</a></div>
|
|
</div>
|
|
<div v-if="doc.remarks && doc.remarks !== 'No Remarks'" class="q-mt-md">
|
|
<div class="info-block-title">Remarques</div>
|
|
<div class="modal-desc text-grey-8" style="white-space:pre-line">{{ doc.remarks }}</div>
|
|
</div>
|
|
<div v-if="doc.items?.length" class="q-mt-md">
|
|
<div class="info-block-title">Articles ({{ doc.items.length }})</div>
|
|
<q-table
|
|
:rows="doc.items" :columns="invItemCols" row-key="idx"
|
|
flat dense class="ops-table" hide-pagination :pagination="{ rowsPerPage: 0 }"
|
|
>
|
|
<template #body-cell-amount="p">
|
|
<q-td :props="p" class="text-right">{{ formatMoney(p.row.amount) }}</q-td>
|
|
</template>
|
|
<template #body-cell-rate="p">
|
|
<q-td :props="p" class="text-right">{{ formatMoney(p.row.rate) }}</q-td>
|
|
</template>
|
|
</q-table>
|
|
</div>
|
|
<div v-if="doc.taxes?.length" class="q-mt-md">
|
|
<div class="info-block-title">Taxes</div>
|
|
<div v-for="t in doc.taxes" :key="t.idx" class="info-row q-py-xs">
|
|
<span>{{ t.description || t.account_head }}</span>
|
|
<q-space />
|
|
<span>{{ formatMoney(t.tax_amount) }}</span>
|
|
</div>
|
|
</div>
|
|
<div v-if="comments.length" class="q-mt-md">
|
|
<div class="info-block-title">Notes ({{ comments.length }})</div>
|
|
<div v-for="mc in comments" :key="mc.name" class="q-py-xs" style="border-bottom:1px solid var(--ops-border, #e2e8f0)">
|
|
<div class="text-caption text-grey-6">{{ mc.comment_by || 'Systeme' }} · {{ formatDateShort(mc.creation) }}</div>
|
|
<div class="text-body2" style="white-space:pre-line">{{ mc.content }}</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- ═══ Issue / Ticket ═══ -->
|
|
<template v-else-if="doctype === 'Issue'">
|
|
<div class="modal-field-grid">
|
|
<div class="mf"><span class="mf-label">Statut</span><span class="ops-badge" :class="ticketStatusClass(doc.status)">{{ doc.status }}</span></div>
|
|
<div class="mf"><span class="mf-label">Priorite</span><span class="ops-badge" :class="priorityClass(doc.priority)">{{ doc.priority }}</span></div>
|
|
<div class="mf"><span class="mf-label">Ouvert le</span>{{ formatDate(doc.opening_date) }}</div>
|
|
<div class="mf" v-if="doc.resolution_time"><span class="mf-label">Resolu le</span>{{ doc.resolution_time }}</div>
|
|
<div class="mf" v-if="doc.issue_type"><span class="mf-label">Type</span>{{ doc.issue_type }}</div>
|
|
<div class="mf" v-if="doc.service_location"><span class="mf-label">Adresse</span>{{ doc.service_location }}</div>
|
|
<div class="mf" v-if="doc.raised_by"><span class="mf-label">Soumis par</span>{{ doc.raised_by }}</div>
|
|
<div class="mf" v-if="doc.owner"><span class="mf-label">Proprietaire</span>{{ doc.owner }}</div>
|
|
</div>
|
|
<div v-if="doc.issue_split_from" class="q-mt-md">
|
|
<div class="info-block-title">Ticket parent</div>
|
|
<div class="text-body2 erp-link" style="cursor:pointer" @click="$emit('navigate', 'Issue', doc.issue_split_from)">
|
|
{{ doc.issue_split_from }}
|
|
</div>
|
|
</div>
|
|
<div v-if="doc.description" class="q-mt-md">
|
|
<div class="info-block-title">Description</div>
|
|
<div class="modal-desc" v-html="doc.description"></div>
|
|
</div>
|
|
<div v-if="doc.resolution_details" class="q-mt-md">
|
|
<div class="info-block-title">Resolution</div>
|
|
<div class="modal-desc" v-html="doc.resolution_details"></div>
|
|
</div>
|
|
<div v-if="comms.length" class="q-mt-md">
|
|
<div class="info-block-title">Echanges ({{ comms.length }})</div>
|
|
<div v-for="comm in comms" :key="comm.name" class="comm-row">
|
|
<div class="row items-start">
|
|
<div class="col">
|
|
<div class="text-caption text-weight-bold">{{ comm.sender || comm.owner }}</div>
|
|
<div class="modal-desc q-mt-xs" v-html="comm.content"></div>
|
|
</div>
|
|
<div class="col-auto text-caption text-grey-6 text-right" style="min-width:90px">
|
|
{{ formatDate(comm.creation) }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-if="files.length" class="q-mt-md">
|
|
<div class="info-block-title">Pieces jointes ({{ files.length }})</div>
|
|
<div v-for="f in files" :key="f.name" class="q-py-xs">
|
|
<a :href="erpFileUrl(f.file_url)" target="_blank" class="erp-link">
|
|
<q-icon name="attach_file" size="14px" /> {{ f.file_name }}
|
|
</a>
|
|
</div>
|
|
</div>
|
|
<div v-if="comments.length" class="q-mt-md">
|
|
<div class="info-block-title">Fil de discussion ({{ comments.length }})</div>
|
|
<div v-for="c in comments" :key="c.name" class="thread-msg">
|
|
<div class="thread-header">
|
|
<q-icon name="person" size="14px" color="grey-6" class="q-mr-xs" />
|
|
<span class="text-weight-bold">{{ c.comment_by || 'Systeme' }}</span>
|
|
<span class="text-grey-5 q-ml-auto">{{ formatDateTime(c.creation) }}</span>
|
|
</div>
|
|
<div class="thread-body" v-html="c.content"></div>
|
|
</div>
|
|
</div>
|
|
<div v-if="!doc.description && !doc.resolution_details && !comms.length" class="text-center text-grey-5 q-pa-lg">
|
|
Aucun contenu pour ce ticket
|
|
</div>
|
|
</template>
|
|
|
|
<!-- ═══ Payment Entry ═══ -->
|
|
<template v-else-if="doctype === 'Payment Entry'">
|
|
<div class="modal-field-grid">
|
|
<div class="mf"><span class="mf-label">Date</span>{{ doc.posting_date }}</div>
|
|
<div class="mf"><span class="mf-label">Mode</span>{{ doc.mode_of_payment || '---' }}</div>
|
|
<div class="mf"><span class="mf-label">Montant paye</span><strong>{{ formatMoney(doc.paid_amount) }}</strong></div>
|
|
<div class="mf"><span class="mf-label">Reference</span>{{ doc.reference_no || '---' }}</div>
|
|
<div class="mf" v-if="doc.reference_date"><span class="mf-label">Date ref.</span>{{ doc.reference_date }}</div>
|
|
<div class="mf"><span class="mf-label">Compte paye de</span>{{ doc.paid_from || '---' }}</div>
|
|
<div class="mf"><span class="mf-label">Compte paye a</span>{{ doc.paid_to || '---' }}</div>
|
|
</div>
|
|
<div v-if="doc.references?.length" class="q-mt-md">
|
|
<div class="info-block-title">Factures liees</div>
|
|
<div v-for="r in doc.references" :key="r.idx" class="info-row q-py-xs" style="cursor:pointer" @click="$emit('navigate', r.reference_doctype, r.reference_name)">
|
|
<span class="erp-link">{{ r.reference_name }}</span>
|
|
<q-space />
|
|
<span>{{ formatMoney(r.allocated_amount) }}</span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- ═══ Subscription (editable) ═══ -->
|
|
<template v-else-if="doctype === 'Subscription'">
|
|
<div class="modal-field-grid">
|
|
<div class="mf"><span class="mf-label">Statut</span><span class="ops-badge" :class="subStatusClass(doc.status)">{{ doc.status }}</span></div>
|
|
<div class="mf"><span class="mf-label">SKU</span><code>{{ doc.item_code }}</code></div>
|
|
<div class="mf"><span class="mf-label">Article</span>{{ doc.item_name }}</div>
|
|
<div class="mf"><span class="mf-label">Frequence</span>{{ doc.billing_frequency === 'A' ? 'Annuel' : 'Mensuel' }}</div>
|
|
<div class="mf"><span class="mf-label">Debut</span>{{ doc.start_date || '---' }}</div>
|
|
<div class="mf"><span class="mf-label">Fin</span>{{ doc.end_date || '---' }}</div>
|
|
<div class="mf" v-if="doc.service_location"><span class="mf-label">Adresse</span>{{ doc.service_location }}</div>
|
|
<div class="mf" v-if="doc.radius_user"><span class="mf-label">PPPoE</span><code>{{ doc.radius_user }}</code></div>
|
|
<div class="mf" v-if="doc.radius_pwd"><span class="mf-label">Mot de passe</span><code>{{ doc.radius_pwd }}</code></div>
|
|
</div>
|
|
<div class="q-mt-md">
|
|
<div class="info-block-title">Modifier</div>
|
|
<div class="q-gutter-y-sm">
|
|
<q-input v-model="doc.custom_description" dense outlined
|
|
label="Description personnalisee" @change="$emit('save-field', doc, 'custom_description')" />
|
|
<q-input v-model.number="doc.actual_price" dense outlined type="number" step="0.01"
|
|
label="Prix" prefix="$" @change="$emit('save-field', doc, 'actual_price')" />
|
|
<div class="row items-center q-gutter-x-md q-mt-sm">
|
|
<q-toggle
|
|
:model-value="!Number(doc.cancel_at_period_end)"
|
|
@update:model-value="$emit('toggle-recurring', doc)"
|
|
:color="Number(doc.cancel_at_period_end) ? 'grey' : 'green'"
|
|
label="Recurrent"
|
|
/>
|
|
<div v-if="doc.current_invoice_start" class="text-caption text-grey-6">
|
|
Prochaine facture: {{ formatDate(doc.current_invoice_start) }} --- {{ formatDate(doc.current_invoice_end) }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-if="doc.plans?.length" class="q-mt-md">
|
|
<div class="info-block-title">Plans</div>
|
|
<div v-for="p in doc.plans" :key="p.idx" class="info-row q-py-xs">
|
|
<span>{{ p.plan || p.item }}</span>
|
|
<q-space />
|
|
<span>{{ formatMoney(p.cost) }}</span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- ═══ Service Equipment ═══ -->
|
|
<template v-else-if="doctype === 'Service Equipment'">
|
|
<div class="modal-field-grid">
|
|
<div class="mf"><span class="mf-label">Type</span>{{ doc.equipment_type }}</div>
|
|
<div class="mf"><span class="mf-label">Statut</span><span class="ops-badge" :class="eqStatusClass(doc.status)">{{ doc.status }}</span></div>
|
|
<div class="mf"><span class="mf-label">Marque</span>{{ doc.brand || '---' }}</div>
|
|
<div class="mf"><span class="mf-label">Modele</span>{{ doc.model || '---' }}</div>
|
|
<div class="mf"><span class="mf-label">N serie</span><code>{{ doc.serial_number }}</code></div>
|
|
<div class="mf" v-if="doc.mac_address"><span class="mf-label">MAC</span><code>{{ doc.mac_address }}</code></div>
|
|
<div class="mf" v-if="doc.barcode"><span class="mf-label">Code-barres</span><code>{{ doc.barcode }}</code></div>
|
|
<div class="mf" v-if="doc.ip_address"><span class="mf-label">IP</span><code>{{ doc.ip_address }}</code></div>
|
|
<div class="mf" v-if="doc.firmware_version"><span class="mf-label">Firmware</span>{{ doc.firmware_version }}</div>
|
|
<div class="mf"><span class="mf-label">Propriete</span>{{ doc.ownership || '---' }}</div>
|
|
</div>
|
|
<div v-if="doc.olt_name" class="q-mt-md">
|
|
<div class="info-block-title">Information OLT</div>
|
|
<div class="modal-field-grid">
|
|
<div class="mf"><span class="mf-label">OLT</span>{{ doc.olt_name }}</div>
|
|
<div class="mf"><span class="mf-label">IP OLT</span><code>{{ doc.olt_ip }}</code></div>
|
|
<div class="mf"><span class="mf-label">Slot / Port</span>{{ doc.olt_slot }} / {{ doc.olt_port }}</div>
|
|
<div class="mf"><span class="mf-label">ONT ID</span>{{ doc.olt_ontid }}</div>
|
|
</div>
|
|
<div class="mf" v-if="doc.installation_date"><span class="mf-label">Installe le</span>{{ doc.installation_date }}</div>
|
|
<div class="mf" v-if="doc.warranty_end"><span class="mf-label">Fin garantie</span>{{ doc.warranty_end }}</div>
|
|
<div class="mf" v-if="doc.service_location"><span class="mf-label">Adresse</span>{{ doc.service_location }}</div>
|
|
<div class="mf" v-if="doc.subscription"><span class="mf-label">Abonnement</span>{{ doc.subscription }}</div>
|
|
</div>
|
|
<div v-if="doc.login_user" class="q-mt-md">
|
|
<div class="info-block-title">Acces distant</div>
|
|
<div class="info-row q-py-xs"><span class="mf-label">Utilisateur</span><code>{{ doc.login_user }}</code></div>
|
|
</div>
|
|
<div v-if="doc.notes" class="q-mt-md">
|
|
<div class="info-block-title">Notes</div>
|
|
<div class="modal-desc">{{ doc.notes }}</div>
|
|
</div>
|
|
<div v-if="doc.move_log?.length" class="q-mt-md">
|
|
<div class="info-block-title">Historique ({{ doc.move_log.length }})</div>
|
|
<div v-for="m in doc.move_log" :key="m.idx" class="info-row q-py-xs text-caption">
|
|
{{ m.date }} --- {{ m.from_location || '?' }} → {{ m.to_location || '?' }} {{ m.reason ? '(' + m.reason + ')' : '' }}
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- ═══ Generic fallback ═══ -->
|
|
<template v-else>
|
|
<div class="modal-field-grid">
|
|
<div v-for="(val, key) in docFields" :key="key" class="mf">
|
|
<span class="mf-label">{{ key }}</span>
|
|
<span v-if="typeof val === 'number'">{{ val }}</span>
|
|
<span v-else>{{ val || '---' }}</span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</q-card-section>
|
|
</q-card>
|
|
</q-dialog>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { formatDate, formatDateShort, formatMoney, erpFileUrl } from 'src/composables/useFormatters'
|
|
import { invStatusClass, ticketStatusClass, priorityClass, subStatusClass, eqStatusClass } from 'src/composables/useStatusClasses'
|
|
import { erpLink } from 'src/composables/useFormatters'
|
|
import { computed } from 'vue'
|
|
|
|
const props = defineProps({
|
|
open: Boolean,
|
|
loading: Boolean,
|
|
doctype: String,
|
|
docName: String,
|
|
title: String,
|
|
doc: Object,
|
|
comments: { type: Array, default: () => [] },
|
|
comms: { type: Array, default: () => [] },
|
|
files: { type: Array, default: () => [] },
|
|
docFields: { type: Object, default: () => ({}) },
|
|
})
|
|
|
|
defineEmits(['update:open', 'navigate', 'open-pdf', 'save-field', 'toggle-recurring'])
|
|
|
|
const erpLinkUrl = computed(() => erpLink(props.doctype, props.docName))
|
|
|
|
const invItemCols = [
|
|
{ name: 'item_name', label: 'Article', field: r => decodeHtml(r.item_name || r.item_code), align: 'left' },
|
|
{ name: 'qty', label: 'Qte', field: 'qty', align: 'center' },
|
|
{ name: 'rate', label: 'Prix unit.', field: 'rate', align: 'right' },
|
|
{ name: 'amount', label: 'Montant', field: 'amount', align: 'right' },
|
|
]
|
|
|
|
function decodeHtml (str) {
|
|
if (!str) return str
|
|
const el = document.createElement('textarea')
|
|
el.innerHTML = str
|
|
return el.value
|
|
}
|
|
|
|
function formatDateTime (dt) {
|
|
if (!dt) return ''
|
|
const d = new Date(dt)
|
|
if (isNaN(d)) return dt
|
|
return d.toLocaleDateString('fr-CA', { year: 'numeric', month: 'short', day: 'numeric' }) + ' ' +
|
|
d.toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit' })
|
|
}
|
|
|
|
function openExternal (url) {
|
|
window.open(url, '_blank')
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.erp-link {
|
|
color: #6366f1;
|
|
text-decoration: none;
|
|
cursor: pointer;
|
|
}
|
|
.erp-link:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.modal-field-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 2px 16px;
|
|
}
|
|
|
|
.mf {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 8px;
|
|
padding: 6px 0;
|
|
font-size: 0.875rem;
|
|
border-bottom: 1px solid #f1f5f9;
|
|
}
|
|
|
|
.mf-label {
|
|
font-size: 0.75rem;
|
|
font-weight: 600;
|
|
color: #6b7280;
|
|
min-width: 80px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.info-block-title {
|
|
font-size: 0.75rem;
|
|
font-weight: 700;
|
|
color: #6b7280;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
.modal-desc {
|
|
font-size: 0.85rem;
|
|
line-height: 1.5;
|
|
background: #f8fafc;
|
|
border-radius: 8px;
|
|
padding: 10px 12px;
|
|
max-height: 300px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.info-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.comm-row {
|
|
padding: 10px 0;
|
|
border-bottom: 1px solid #e2e8f0;
|
|
}
|
|
.comm-row:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.thread-msg {
|
|
border-left: 3px solid #e2e8f0;
|
|
padding: 8px 0 8px 12px;
|
|
margin-bottom: 2px;
|
|
}
|
|
.thread-msg:hover {
|
|
border-left-color: #6366f1;
|
|
}
|
|
|
|
.thread-header {
|
|
display: flex;
|
|
align-items: center;
|
|
font-size: 0.78rem;
|
|
color: #374151;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.thread-body {
|
|
font-size: 0.84rem;
|
|
line-height: 1.5;
|
|
color: #1f2937;
|
|
}
|
|
.thread-body :deep(p) {
|
|
margin: 0 0 4px;
|
|
}
|
|
.thread-body :deep(hr) {
|
|
border: none;
|
|
border-top: 1px solid #e5e7eb;
|
|
margin: 6px 0;
|
|
}
|
|
.thread-body :deep(br + br) {
|
|
display: none;
|
|
}
|
|
</style>
|