gigafibre-fsm/apps/ops/src/components/customer/SmsThread.vue
louispaulb 43d5ca2e4e 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

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>