gigafibre-fsm/apps/ops/src/components/customer/ContactCard.vue
louispaulb 7d7b4fdb06 feat: nested tasks, project wizard, n8n webhooks, inline task editing
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>
2026-04-01 13:01:20 -04:00

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>