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>
254 lines
7.7 KiB
Vue
254 lines
7.7 KiB
Vue
<template>
|
|
<div class="sms-thread">
|
|
<div class="sms-thread-header" @click="expanded = !expanded">
|
|
<q-icon :name="expanded ? 'expand_more' : 'chevron_right'" size="16px" color="grey-5" />
|
|
<q-icon name="forum" size="18px" color="indigo-5" class="q-mr-xs" />
|
|
<span class="text-weight-bold" style="font-size:0.95rem">SMS</span>
|
|
<q-space />
|
|
<q-badge v-if="unreadCount" color="red" class="q-mr-xs">{{ unreadCount }}</q-badge>
|
|
<span class="text-caption text-grey-5">{{ messages.length }}</span>
|
|
</div>
|
|
|
|
<div v-show="expanded" class="sms-thread-body">
|
|
<!-- Loading -->
|
|
<div v-if="loading" class="flex flex-center q-pa-md">
|
|
<q-spinner size="20px" color="indigo-5" />
|
|
</div>
|
|
|
|
<!-- Empty -->
|
|
<div v-else-if="!messages.length" class="text-center text-grey-5 q-pa-md text-caption">
|
|
Aucun SMS pour ce client
|
|
</div>
|
|
|
|
<!-- Messages -->
|
|
<div v-else class="sms-messages" ref="messagesContainer">
|
|
<div v-for="msg in messages" :key="msg.name" class="sms-bubble-wrap"
|
|
:class="msg.sent_or_received === 'Sent' ? 'sms-outbound' : 'sms-inbound'">
|
|
<div class="sms-bubble" :class="msg.sent_or_received === 'Sent' ? 'bubble-sent' : 'bubble-received'">
|
|
<div class="sms-text">{{ stripHtml(msg.content || msg.text_content || '') }}</div>
|
|
<div class="sms-meta">
|
|
<span>{{ formatTime(msg.communication_date || msg.creation) }}</span>
|
|
<q-icon v-if="msg.sent_or_received === 'Sent'" name="done_all" size="12px"
|
|
:color="msg.delivery_status === 'Read' ? 'blue-5' : 'grey-5'" class="q-ml-xs" />
|
|
<q-btn v-if="msg.reference_doctype === 'Issue' || msg.reference_doctype === 'Dispatch Job'"
|
|
flat dense round size="xs" icon="link" color="indigo-5" class="q-ml-xs"
|
|
@click.stop="$emit('navigate', msg.reference_doctype, msg.reference_name)">
|
|
<q-tooltip>{{ msg.reference_doctype }}: {{ msg.reference_name }}</q-tooltip>
|
|
</q-btn>
|
|
</div>
|
|
</div>
|
|
<!-- Link to ticket action -->
|
|
<q-btn v-if="msg.sent_or_received === 'Received' && msg.reference_doctype === 'Customer'"
|
|
flat dense size="xs" icon="link" color="grey-5" class="sms-link-btn"
|
|
@click.stop="linkToTicket(msg)">
|
|
<q-tooltip>Lier a un ticket</q-tooltip>
|
|
</q-btn>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Compose reply -->
|
|
<div class="sms-compose">
|
|
<q-input v-model="reply" dense outlined placeholder="Repondre par SMS..."
|
|
:input-style="{ fontSize: '0.82rem' }" class="col"
|
|
@keydown.enter.exact.prevent="sendReply">
|
|
<template #append>
|
|
<q-btn flat dense round icon="send" color="indigo-6" size="sm"
|
|
:disable="!reply.trim()" :loading="sending" @click="sendReply" />
|
|
</template>
|
|
</q-input>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted, watch, nextTick } from 'vue'
|
|
import { listDocs } from 'src/api/erp'
|
|
import { sendTestSms } from 'src/api/sms'
|
|
import { Notify } from 'quasar'
|
|
|
|
const props = defineProps({
|
|
customerName: { type: String, required: true },
|
|
customerPhone: { type: String, default: '' },
|
|
})
|
|
|
|
const emit = defineEmits(['navigate'])
|
|
|
|
const expanded = ref(true)
|
|
const loading = ref(true)
|
|
const messages = ref([])
|
|
const reply = ref('')
|
|
const sending = ref(false)
|
|
const messagesContainer = ref(null)
|
|
|
|
const unreadCount = ref(0)
|
|
|
|
async function loadMessages () {
|
|
loading.value = true
|
|
try {
|
|
const data = await listDocs('Communication', {
|
|
filters: {
|
|
communication_medium: 'SMS',
|
|
reference_doctype: 'Customer',
|
|
reference_name: props.customerName,
|
|
},
|
|
fields: [
|
|
'name', 'subject', 'content', 'text_content', 'sent_or_received',
|
|
'communication_date', 'creation', 'phone_no', 'sender',
|
|
'status', 'delivery_status', 'reference_doctype', 'reference_name',
|
|
'message_id',
|
|
],
|
|
limit: 50,
|
|
orderBy: 'communication_date asc',
|
|
})
|
|
messages.value = data
|
|
unreadCount.value = data.filter(m => m.sent_or_received === 'Received' && m.status === 'Open').length
|
|
await nextTick()
|
|
scrollToBottom()
|
|
} catch (e) {
|
|
console.error('[SmsThread] load error:', e)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function scrollToBottom () {
|
|
if (messagesContainer.value) {
|
|
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
|
}
|
|
}
|
|
|
|
async function sendReply () {
|
|
if (!reply.value.trim() || sending.value) return
|
|
const text = reply.value.trim()
|
|
const phone = props.customerPhone
|
|
if (!phone) {
|
|
Notify.create({ type: 'warning', message: 'Aucun numero de telephone', timeout: 3000 })
|
|
return
|
|
}
|
|
sending.value = true
|
|
try {
|
|
await sendTestSms(phone, text, props.customerName)
|
|
reply.value = ''
|
|
Notify.create({ type: 'positive', message: 'SMS envoye', timeout: 2000 })
|
|
// Reload after a short delay to let n8n log the Communication
|
|
setTimeout(() => loadMessages(), 1500)
|
|
} catch (e) {
|
|
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 4000 })
|
|
} finally {
|
|
sending.value = false
|
|
}
|
|
}
|
|
|
|
function linkToTicket (msg) {
|
|
// TODO: open a dialog to select a ticket to link this message to
|
|
Notify.create({ type: 'info', message: 'Lier au ticket — a venir', timeout: 2000 })
|
|
}
|
|
|
|
function stripHtml (html) {
|
|
const div = document.createElement('div')
|
|
div.innerHTML = html
|
|
return div.textContent || div.innerText || ''
|
|
}
|
|
|
|
function formatTime (dt) {
|
|
if (!dt) return ''
|
|
const d = new Date(dt)
|
|
const now = new Date()
|
|
const isToday = d.toDateString() === now.toDateString()
|
|
const yesterday = new Date(now)
|
|
yesterday.setDate(yesterday.getDate() - 1)
|
|
const isYesterday = d.toDateString() === yesterday.toDateString()
|
|
|
|
const time = d.toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit' })
|
|
if (isToday) return time
|
|
if (isYesterday) return 'Hier ' + time
|
|
return d.toLocaleDateString('fr-CA', { month: 'short', day: 'numeric' }) + ' ' + time
|
|
}
|
|
|
|
onMounted(loadMessages)
|
|
watch(() => props.customerName, loadMessages)
|
|
|
|
// Auto-refresh every 30s when expanded
|
|
let refreshInterval = null
|
|
watch(expanded, (val) => {
|
|
if (val) {
|
|
loadMessages()
|
|
refreshInterval = setInterval(loadMessages, 30000)
|
|
} else if (refreshInterval) {
|
|
clearInterval(refreshInterval)
|
|
refreshInterval = null
|
|
}
|
|
}, { immediate: true })
|
|
</script>
|
|
|
|
<style scoped>
|
|
.sms-thread {
|
|
border-top: 1px solid #e2e8f0;
|
|
margin-top: 8px;
|
|
padding-top: 6px;
|
|
}
|
|
.sms-thread-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
cursor: pointer;
|
|
padding: 4px 0;
|
|
user-select: none;
|
|
}
|
|
.sms-thread-header:hover { background: #f8fafc; border-radius: 4px; }
|
|
.sms-thread-body { padding: 4px 0; }
|
|
|
|
.sms-messages {
|
|
max-height: 350px;
|
|
overflow-y: auto;
|
|
padding: 8px 4px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
}
|
|
|
|
.sms-bubble-wrap {
|
|
display: flex;
|
|
align-items: flex-end;
|
|
gap: 4px;
|
|
}
|
|
.sms-outbound { justify-content: flex-end; }
|
|
.sms-inbound { justify-content: flex-start; }
|
|
|
|
.sms-bubble {
|
|
max-width: 80%;
|
|
padding: 8px 12px;
|
|
border-radius: 12px;
|
|
font-size: 0.85rem;
|
|
line-height: 1.35;
|
|
}
|
|
.bubble-sent {
|
|
background: #e8eaf6;
|
|
color: #1a237e;
|
|
border-bottom-right-radius: 4px;
|
|
}
|
|
.bubble-received {
|
|
background: #f5f5f5;
|
|
color: #333;
|
|
border-bottom-left-radius: 4px;
|
|
}
|
|
|
|
.sms-text { word-break: break-word; }
|
|
.sms-meta {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: flex-end;
|
|
margin-top: 2px;
|
|
font-size: 0.7rem;
|
|
color: #9e9e9e;
|
|
}
|
|
|
|
.sms-link-btn { opacity: 0; transition: opacity 0.15s; }
|
|
.sms-bubble-wrap:hover .sms-link-btn { opacity: 1; }
|
|
|
|
.sms-compose {
|
|
padding: 6px 0 2px;
|
|
}
|
|
</style>
|