gigafibre-fsm/apps/ops/src/components/customer/ComposeBar.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

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>