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>
167 lines
6.4 KiB
Vue
167 lines
6.4 KiB
Vue
<template>
|
|
<div class="chatter-compose">
|
|
<div class="compose-channel-row">
|
|
<q-btn-toggle v-model="channel" no-caps dense unelevated size="xs"
|
|
toggle-color="indigo-6" color="grey-2" text-color="grey-7"
|
|
:options="composeOptions" />
|
|
<q-space />
|
|
<q-btn flat dense round size="xs" icon="bolt" color="amber-8" @click="$emit('manage-canned')" title="Réponses rapides">
|
|
<q-tooltip>Gérer les réponses rapides</q-tooltip>
|
|
</q-btn>
|
|
<q-btn v-if="customerPhone" flat dense round size="sm" icon="phone" color="green-7"
|
|
@click="$emit('call')" :loading="calling">
|
|
<q-tooltip>Appeler {{ customerPhone }}</q-tooltip>
|
|
</q-btn>
|
|
</div>
|
|
|
|
<!-- Canned response dropdown -->
|
|
<div v-if="cannedMatches.length" class="canned-dropdown">
|
|
<div v-for="(cr, i) in cannedMatches" :key="i" class="canned-option"
|
|
:class="{ 'canned-highlighted': i === cannedHighlight }"
|
|
@mousedown.prevent="$emit('use-canned', cr)">
|
|
<span class="canned-shortcut-label">::{{ cr.shortcut }}</span>
|
|
<span class="canned-preview">{{ cr.text.slice(0, 60) }}{{ cr.text.length > 60 ? '…' : '' }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- SMS -->
|
|
<div v-if="channel === 'sms'" class="compose-body">
|
|
<q-select v-if="phoneOptions.length > 1" v-model="smsTo" dense outlined emit-value map-options
|
|
:options="phoneOptions" style="font-size:0.8rem" class="q-mb-xs" />
|
|
<q-input v-model="text" dense outlined placeholder="SMS..."
|
|
:input-style="{ fontSize: '0.82rem' }" autogrow
|
|
@update:model-value="onTextChange"
|
|
@keydown.enter.exact.prevent="handleEnter"
|
|
@keydown.down.prevent="cannedDown" @keydown.up.prevent="cannedUp"
|
|
@keydown.escape="cannedMatches.length ? closeCanned() : null">
|
|
<template #append>
|
|
<span class="text-caption text-grey-4 q-mr-xs">{{ text.length }}</span>
|
|
<q-btn flat dense round icon="send" color="indigo-6" size="sm"
|
|
:disable="!text.trim() || !smsTo" :loading="sending" @click="$emit('send')" />
|
|
</template>
|
|
</q-input>
|
|
</div>
|
|
|
|
<!-- Email -->
|
|
<div v-if="channel === 'email'" class="compose-body">
|
|
<q-input v-model="emailSubject" dense outlined placeholder="Sujet"
|
|
:input-style="{ fontSize: '0.8rem' }" class="q-mb-xs" />
|
|
<q-input v-model="text" dense outlined placeholder="Message email..."
|
|
type="textarea" autogrow :input-style="{ fontSize: '0.82rem', minHeight: '50px' }"
|
|
@update:model-value="onTextChange"
|
|
@keydown.down.prevent="cannedDown" @keydown.up.prevent="cannedUp"
|
|
@keydown.escape="cannedMatches.length ? closeCanned() : null">
|
|
<template #append>
|
|
<q-btn flat dense round icon="send" color="indigo-6" size="sm"
|
|
:disable="!text.trim() || !emailSubject.trim()" :loading="sending" @click="$emit('send')" />
|
|
</template>
|
|
</q-input>
|
|
</div>
|
|
|
|
<!-- Note -->
|
|
<div v-if="channel === 'note'" class="compose-body">
|
|
<q-input v-model="text" dense outlined placeholder="Note interne..."
|
|
type="textarea" autogrow :input-style="{ fontSize: '0.82rem', minHeight: '40px' }"
|
|
@update:model-value="onTextChange"
|
|
@keydown.ctrl.enter="$emit('send')" @keydown.meta.enter="$emit('send')"
|
|
@keydown.down.prevent="cannedDown" @keydown.up.prevent="cannedUp"
|
|
@keydown.escape="cannedMatches.length ? closeCanned() : null">
|
|
<template #append>
|
|
<q-btn flat dense round icon="note_add" color="amber-8" size="sm"
|
|
:disable="!text.trim()" :loading="sending" @click="$emit('send')" />
|
|
</template>
|
|
</q-input>
|
|
</div>
|
|
|
|
<!-- Phone -->
|
|
<div v-if="channel === 'phone'" class="compose-body">
|
|
<div class="row items-center q-gutter-sm">
|
|
<q-select v-model="callTo" dense outlined emit-value map-options
|
|
:options="phoneOptions" style="font-size:0.8rem; flex:1" label="Appeler" />
|
|
<q-btn unelevated dense color="green-7" icon="phone" label="Appeler"
|
|
:disable="!callTo" :loading="calling" @click="$emit('call')" />
|
|
</div>
|
|
<div class="text-caption text-grey-5 q-mt-xs">
|
|
L'appel connectera votre poste au client via Twilio.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed } from 'vue'
|
|
|
|
const composeOptions = [
|
|
{ label: 'SMS', value: 'sms', icon: 'sms' },
|
|
{ label: 'Appel', value: 'phone', icon: 'phone' },
|
|
{ label: 'Email', value: 'email', icon: 'email' },
|
|
{ label: 'Note', value: 'note', icon: 'sticky_note_2' },
|
|
]
|
|
|
|
const props = defineProps({
|
|
customerPhone: { type: String, default: '' },
|
|
phoneOptions: { type: Array, default: () => [] },
|
|
sending: Boolean,
|
|
calling: Boolean,
|
|
cannedResponses: { type: Array, default: () => [] },
|
|
})
|
|
|
|
const emit = defineEmits(['send', 'call', 'use-canned', 'manage-canned'])
|
|
|
|
const channel = defineModel('channel', { type: String, default: 'sms' })
|
|
const text = defineModel('text', { type: String, default: '' })
|
|
const emailSubject = defineModel('emailSubject', { type: String, default: '' })
|
|
const smsTo = defineModel('smsTo', { type: String, default: '' })
|
|
const callTo = defineModel('callTo', { type: String, default: '' })
|
|
|
|
// Canned response detection
|
|
const cannedQuery = ref('')
|
|
const cannedHighlight = ref(0)
|
|
|
|
const cannedMatches = computed(() => {
|
|
if (!cannedQuery.value) return []
|
|
const q = cannedQuery.value.toLowerCase()
|
|
return props.cannedResponses.filter(cr =>
|
|
cr.shortcut.toLowerCase().startsWith(q) || cr.text.toLowerCase().includes(q)
|
|
).slice(0, 6)
|
|
})
|
|
|
|
function onTextChange (val) {
|
|
// Detect :: prefix for canned response
|
|
const match = val?.match(/::(\S*)$/)
|
|
if (match) {
|
|
cannedQuery.value = match[1]
|
|
cannedHighlight.value = 0
|
|
} else {
|
|
cannedQuery.value = ''
|
|
}
|
|
}
|
|
|
|
function handleEnter () {
|
|
if (cannedMatches.value.length) {
|
|
selectCanned(cannedMatches.value[cannedHighlight.value])
|
|
} else {
|
|
emit('send')
|
|
}
|
|
}
|
|
|
|
function selectCanned (cr) {
|
|
// Replace the ::query with the canned text
|
|
text.value = text.value.replace(/::(\S*)$/, cr.text)
|
|
cannedQuery.value = ''
|
|
emit('use-canned', cr)
|
|
}
|
|
|
|
function cannedDown () {
|
|
if (cannedMatches.value.length) {
|
|
cannedHighlight.value = (cannedHighlight.value + 1) % cannedMatches.value.length
|
|
}
|
|
}
|
|
function cannedUp () {
|
|
if (cannedMatches.value.length) {
|
|
cannedHighlight.value = cannedHighlight.value <= 0 ? cannedMatches.value.length - 1 : cannedHighlight.value - 1
|
|
}
|
|
}
|
|
function closeCanned () { cannedQuery.value = '' }
|
|
</script>
|