Major dispatch/task system overhaul: - Project templates with 3-step wizard (choose template → edit steps → publish) - 4 built-in templates: phone service, fiber install, move, repair - Nested task tree with recursive TaskNode component (parent_job hierarchy) - n8n webhook integration (on_open_webhook, on_close_webhook per task) - Inline task editing: status, priority, type, tech assignment, tags, delete - Tech assignment + tags from ticket modal → jobs appear on dispatch timeline - ERPNext custom fields: parent_job, on_open_webhook, on_close_webhook, step_order - Refactored ClientDetailPage, ChatterPanel, DetailModal, dispatch store - CSS consolidation, dead code cleanup, composable extraction - Dashboard KPIs with dispatch integration Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
200 lines
8.1 KiB
Vue
200 lines
8.1 KiB
Vue
<template>
|
|
<div>
|
|
<div class="section-title">
|
|
<q-icon name="contact_phone" size="18px" class="q-mr-xs" /> Contact
|
|
</div>
|
|
<div class="info-grid">
|
|
<div v-for="f in fields" :key="f.field" class="info-row editable-row">
|
|
<q-icon :name="f.icon" size="16px" color="grey-6" />
|
|
<q-input v-model="customer[f.field]" dense borderless :placeholder="f.label"
|
|
:input-class="'editable-input' + (f.small ? ' text-caption' : '')"
|
|
:style="f.small ? 'word-break:break-all' : ''"
|
|
@blur="save(f.field)" @keyup.enter="$event.target.blur()" />
|
|
<q-spinner v-if="saving === f.field" size="12px" color="grey-5" class="q-ml-xs" />
|
|
</div>
|
|
<div v-if="customer.stripe_id" class="info-row">
|
|
<q-icon name="payment" size="16px" color="grey-6" />
|
|
<span class="text-caption text-grey-6">Stripe: {{ customer.stripe_id }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Notification section -->
|
|
<div class="notify-section q-mt-sm">
|
|
<div class="notify-header" @click="notifyExpanded = !notifyExpanded">
|
|
<q-icon :name="notifyExpanded ? 'expand_more' : 'chevron_right'" size="16px" color="grey-5" />
|
|
<q-icon name="notifications" size="16px" color="indigo-5" class="q-mr-xs" />
|
|
<span class="text-caption text-weight-medium text-grey-7">Envoyer notification</span>
|
|
<q-space />
|
|
<q-badge v-if="lastSentLabel" color="green-6" class="text-caption">{{ lastSentLabel }}</q-badge>
|
|
</div>
|
|
<div v-show="notifyExpanded" class="notify-body">
|
|
<q-btn-toggle v-model="channel" no-caps dense unelevated size="sm" class="q-mb-xs full-width"
|
|
toggle-color="indigo-6" color="grey-3" text-color="grey-8"
|
|
:options="[
|
|
{ label: 'SMS', value: 'sms', icon: 'sms' },
|
|
{ label: 'Email', value: 'email', icon: 'email' },
|
|
]"
|
|
/>
|
|
<q-select v-if="channel === 'sms'" v-model="smsTo" dense outlined emit-value map-options
|
|
:options="phoneOptions" label="Envoyer à" style="font-size:0.82rem" class="q-mb-xs" />
|
|
<q-select v-else v-model="emailTo" dense outlined emit-value map-options
|
|
:options="emailOptions" label="Envoyer à" style="font-size:0.82rem" class="q-mb-xs" />
|
|
<q-input v-if="channel === 'email'" v-model="emailSubject" dense outlined
|
|
placeholder="Sujet" class="q-mb-xs" :input-style="{ fontSize: '0.82rem' }" />
|
|
<q-input v-model="notifyMessage" dense outlined type="textarea" autogrow
|
|
placeholder="Message..."
|
|
:input-style="{ fontSize: '0.82rem', minHeight: '45px', maxHeight: '120px' }" />
|
|
<div class="row items-center q-mt-xs">
|
|
<span class="text-caption text-grey-5">{{ notifyMessage.length }} car.</span>
|
|
<q-space />
|
|
<q-btn unelevated dense size="sm" :label="channel === 'sms' ? 'Envoyer SMS' : 'Envoyer Email'"
|
|
color="indigo-6" icon="send"
|
|
:disable="!canSend" :loading="sending" @click="sendNotification" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed } from 'vue'
|
|
import { updateDoc } from 'src/api/erp'
|
|
import { sendTestSms } from 'src/api/sms'
|
|
|
|
const props = defineProps({ customer: { type: Object, required: true } })
|
|
|
|
const fields = [
|
|
{ field: 'contact_name_legacy', label: 'Contact', icon: 'person' },
|
|
{ field: 'mandataire', label: 'Mandataire', icon: 'badge' },
|
|
{ field: 'tel_home', label: 'Tél. maison', icon: 'phone' },
|
|
{ field: 'tel_office', label: 'Tél. bureau', icon: 'business' },
|
|
{ field: 'cell_phone', label: 'Cellulaire', icon: 'smartphone' },
|
|
{ field: 'email_billing', label: 'Email facturation', icon: 'email', small: true },
|
|
]
|
|
|
|
// ── Direct save on blur (no emit chain) ──
|
|
const saving = ref(null)
|
|
const snapshots = {}
|
|
|
|
function snapshot (field) {
|
|
if (!(field in snapshots)) snapshots[field] = props.customer[field] ?? ''
|
|
}
|
|
|
|
async function save (field) {
|
|
const val = props.customer[field] ?? ''
|
|
const prev = snapshots[field] ?? ''
|
|
console.log('[ContactCard] save', field, { val, prev, changed: val !== prev })
|
|
if (val === prev) return // nothing changed
|
|
snapshots[field] = val // update snapshot
|
|
saving.value = field
|
|
try {
|
|
await updateDoc('Customer', props.customer.name, { [field]: val })
|
|
} catch (e) {
|
|
const { Notify } = await import('quasar')
|
|
Notify?.create?.({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 })
|
|
props.customer[field] = prev // revert on error
|
|
snapshots[field] = prev
|
|
} finally {
|
|
saving.value = null
|
|
}
|
|
}
|
|
|
|
// Take initial snapshots once mounted
|
|
import { onMounted, watch } from 'vue'
|
|
onMounted(() => { for (const f of fields) snapshots[f.field] = props.customer[f.field] ?? '' })
|
|
watch(() => props.customer.name, () => { for (const f of fields) snapshots[f.field] = props.customer[f.field] ?? '' })
|
|
|
|
// ── Notification state ──
|
|
const notifyExpanded = ref(false)
|
|
const channel = ref('sms')
|
|
const notifyMessage = ref('Bonjour, ceci est une notification de Gigafibre.')
|
|
const emailSubject = ref('Notification Gigafibre')
|
|
const smsTo = ref('')
|
|
const emailTo = ref('')
|
|
const sending = ref(false)
|
|
const lastSentLabel = ref('')
|
|
|
|
const phoneOptions = computed(() => {
|
|
const opts = []
|
|
if (props.customer.cell_phone) opts.push({ label: `Cell: ${props.customer.cell_phone}`, value: props.customer.cell_phone })
|
|
if (props.customer.tel_home) opts.push({ label: `Maison: ${props.customer.tel_home}`, value: props.customer.tel_home })
|
|
if (props.customer.tel_office) opts.push({ label: `Bureau: ${props.customer.tel_office}`, value: props.customer.tel_office })
|
|
if (!opts.length) opts.push({ label: 'Aucun numéro — ajouter ci-dessus', value: '', disable: true })
|
|
if (opts.length && opts[0].value && !smsTo.value) smsTo.value = opts[0].value
|
|
return opts
|
|
})
|
|
|
|
const emailOptions = computed(() => {
|
|
const opts = []
|
|
if (props.customer.email_billing) opts.push({ label: props.customer.email_billing, value: props.customer.email_billing })
|
|
if (!opts.length) opts.push({ label: 'Aucun email — ajouter ci-dessus', value: '', disable: true })
|
|
if (opts.length && opts[0].value && !emailTo.value) emailTo.value = opts[0].value
|
|
return opts
|
|
})
|
|
|
|
const canSend = computed(() => {
|
|
if (channel.value === 'sms') return !!smsTo.value && !!notifyMessage.value.trim()
|
|
return !!emailTo.value && !!notifyMessage.value.trim() && !!emailSubject.value.trim()
|
|
})
|
|
|
|
async function sendNotification () {
|
|
if (!canSend.value || sending.value) return
|
|
sending.value = true
|
|
lastSentLabel.value = ''
|
|
try {
|
|
if (channel.value === 'sms') {
|
|
const result = await sendTestSms(smsTo.value, notifyMessage.value.trim(), props.customer.name)
|
|
lastSentLabel.value = result?.simulated ? 'Simulé' : 'SMS envoyé'
|
|
} else {
|
|
const { authFetch } = await import('src/api/auth')
|
|
const { BASE_URL } = await import('src/config/erpnext')
|
|
const res = await authFetch(BASE_URL + '/api/method/send_email_notification', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
email: emailTo.value,
|
|
subject: emailSubject.value.trim(),
|
|
message: notifyMessage.value.trim(),
|
|
customer: props.customer.name,
|
|
}),
|
|
})
|
|
if (!res.ok) throw new Error('Email failed: ' + (await res.text()))
|
|
const data = await res.json()
|
|
lastSentLabel.value = data.message?.simulated ? 'Simulé' : 'Email envoyé'
|
|
}
|
|
const { Notify } = await import('quasar')
|
|
Notify?.create?.({ type: 'positive', message: lastSentLabel.value, timeout: 3000 })
|
|
setTimeout(() => { lastSentLabel.value = '' }, 5000)
|
|
} catch (e) {
|
|
console.error('Notification error:', e)
|
|
const { Notify } = await import('quasar')
|
|
Notify?.create?.({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 4000 })
|
|
} finally {
|
|
sending.value = false
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.notify-section {
|
|
border-top: 1px solid #e2e8f0;
|
|
padding-top: 6px;
|
|
}
|
|
.notify-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
cursor: pointer;
|
|
padding: 4px 0;
|
|
border-radius: 4px;
|
|
user-select: none;
|
|
}
|
|
.notify-header:hover {
|
|
background: #f8fafc;
|
|
}
|
|
.notify-body {
|
|
padding: 6px 0 2px 0;
|
|
}
|
|
</style>
|