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>
This commit is contained in:
louispaulb 2026-04-01 13:01:20 -04:00
parent 101faa21f1
commit 7d7b4fdb06
54 changed files with 5626 additions and 2808 deletions

View File

@ -9,8 +9,9 @@
# Static files go to /opt/ops-app/ on the host, mounted into the container. # Static files go to /opt/ops-app/ on the host, mounted into the container.
# #
# Usage: # Usage:
# ./deploy.sh # deploy to remote server (production) # ./deploy.sh # deploy to remote server (SPA mode, no service worker cache)
# ./deploy.sh local # deploy to local Docker (development) # ./deploy.sh local # deploy to local Docker (development)
# ./deploy.sh pwa # deploy to remote server (PWA mode, for production)
# #
# Prerequisites (remote): # Prerequisites (remote):
# - SSH key ~/.ssh/proxmox_vm for root@96.125.196.67 # - SSH key ~/.ssh/proxmox_vm for root@96.125.196.67
@ -25,33 +26,42 @@ SERVER="root@96.125.196.67"
SSH_KEY="$HOME/.ssh/proxmox_vm" SSH_KEY="$HOME/.ssh/proxmox_vm"
DEST="/opt/ops-app" DEST="/opt/ops-app"
# Default to SPA mode (no service worker = no cache headaches during dev)
BUILD_MODE="spa"
DIST_DIR="dist/spa"
if [ "$1" = "pwa" ]; then
BUILD_MODE="pwa"
DIST_DIR="dist/pwa"
shift
fi
echo "==> Installing dependencies..." echo "==> Installing dependencies..."
npm ci --silent npm ci --silent
echo "==> Building PWA (base=/ops/)..." echo "==> Building $BUILD_MODE (base=/ops/)..."
DEPLOY_BASE=/ops/ npx quasar build -m pwa DEPLOY_BASE=/ops/ npx quasar build -m "$BUILD_MODE"
if [ "$1" = "local" ]; then if [ "$1" = "local" ]; then
# ── Local deploy ── # ── Local deploy ──
echo "==> Deploying to local $DEST..." echo "==> Deploying to local $DEST..."
rm -rf "$DEST"/* rm -rf "$DEST"/*
cp -r dist/pwa/* "$DEST/" cp -r "$DIST_DIR"/* "$DEST/"
echo "" echo ""
echo "Done! Targo Ops: http://localhost/ops/" echo "Done! Targo Ops: http://localhost/ops/"
else else
# ── Remote deploy ── # ── Remote deploy ──
echo "==> Packaging..." echo "==> Packaging..."
tar czf /tmp/ops-pwa.tar.gz -C dist/pwa . tar czf /tmp/ops-build.tar.gz -C "$DIST_DIR" .
echo "==> Deploying to $SERVER..." echo "==> Deploying to $SERVER..."
cat /tmp/ops-pwa.tar.gz | ssh -i "$SSH_KEY" "$SERVER" \ cat /tmp/ops-build.tar.gz | ssh -i "$SSH_KEY" "$SERVER" \
"cat > /tmp/ops.tar.gz && \ "cat > /tmp/ops.tar.gz && \
rm -rf $DEST/*.js $DEST/*.html $DEST/*.json $DEST/assets $DEST/icons && \ rm -rf $DEST/*.js $DEST/*.html $DEST/*.json $DEST/assets $DEST/icons $DEST/sw.js $DEST/workbox-*.js && \
cd $DEST && tar xzf /tmp/ops.tar.gz && \ cd $DEST && tar xzf /tmp/ops.tar.gz && \
rm -f /tmp/ops.tar.gz" rm -f /tmp/ops.tar.gz"
rm -f /tmp/ops-pwa.tar.gz rm -f /tmp/ops-build.tar.gz
echo "" echo ""
echo "Done! Targo Ops: https://erp.gigafibre.ca/ops/" echo "Done! Targo Ops ($BUILD_MODE): https://erp.gigafibre.ca/ops/"
fi fi

View File

@ -9,6 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@quasar/extras": "^1.16.12", "@quasar/extras": "^1.16.12",
"lucide-vue-next": "^1.0.0",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"quasar": "^2.16.10", "quasar": "^2.16.10",
"vue": "^3.4.21", "vue": "^3.4.21",
@ -6916,6 +6917,15 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/lucide-vue-next": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-1.0.0.tgz",
"integrity": "sha512-V6SPvx1IHTj/UY+FrIYWV5faISsPSb8BnWSFDxAtezWKvWc9ZZ40PDrdu1/Qb5vg4lHWr1hs1BAMGVGm6V1Xdg==",
"license": "ISC",
"peerDependencies": {
"vue": ">=3.0.1"
}
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",

View File

@ -11,6 +11,7 @@
}, },
"dependencies": { "dependencies": {
"@quasar/extras": "^1.16.12", "@quasar/extras": "^1.16.12",
"lucide-vue-next": "^1.0.0",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"quasar": "^2.16.10", "quasar": "^2.16.10",
"vue": "^3.4.21", "vue": "^3.4.21",

View File

@ -25,6 +25,11 @@ module.exports = configure(function () {
host: '0.0.0.0', host: '0.0.0.0',
port: 9001, port: 9001,
proxy: { proxy: {
'/ops/api': {
target: 'https://erp.gigafibre.ca',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/ops/, ''),
},
'/api': { '/api': {
target: 'https://erp.gigafibre.ca', target: 'https://erp.gigafibre.ca',
changeOrigin: true, changeOrigin: true,

View File

@ -9,11 +9,10 @@ register(process.env.SERVICE_WORKER_FILE, {
cached () {}, cached () {},
updatefound () {}, updatefound () {},
updated (reg) { updated (reg) {
// New service worker available — activate it and reload // New service worker available — activate it silently (no reload)
if (reg && reg.waiting) { if (reg && reg.waiting) {
reg.waiting.postMessage({ type: 'SKIP_WAITING' }) reg.waiting.postMessage({ type: 'SKIP_WAITING' })
} }
window.location.reload()
}, },
offline () {}, offline () {},
error () {} error () {}

View File

@ -13,14 +13,12 @@ export function authFetch (url, opts = {}) {
} else { } else {
opts.headers = { ...opts.headers } opts.headers = { ...opts.headers }
} }
opts.redirect = 'manual'
if (opts.method && opts.method !== 'GET') {
opts.credentials = 'omit'
}
return fetch(url, opts).then(res => { return fetch(url, opts).then(res => {
if (res.type === 'opaqueredirect' || res.status === 302 || res.status === 401) { console.log('[authFetch]', opts.method || 'GET', url, '→', res.status, res.type)
if (res.status === 401 || res.status === 403) {
console.warn('authFetch: session expired, reloading')
window.location.reload() window.location.reload()
return new Response('{}', { status: 401 }) return new Response('{}', { status: res.status })
} }
return res return res
}) })

View File

@ -54,7 +54,8 @@ export async function createDoc (doctype, data) {
// Update a document (partial update) // Update a document (partial update)
export async function updateDoc (doctype, name, data) { export async function updateDoc (doctype, name, data) {
const res = await authFetch(BASE_URL + '/api/resource/' + doctype + '/' + encodeURIComponent(name), { const url = BASE_URL + '/api/resource/' + doctype + '/' + encodeURIComponent(name)
const res = await authFetch(url, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data), body: JSON.stringify(data),
@ -64,6 +65,14 @@ export async function updateDoc (doctype, name, data) {
return json.data return json.data
} }
// Delete a document
export async function deleteDoc (doctype, name) {
const url = BASE_URL + '/api/resource/' + doctype + '/' + encodeURIComponent(name)
const res = await authFetch(url, { method: 'DELETE' })
if (!res.ok) throw new Error('Delete failed: ' + res.status)
return true
}
// Count documents // Count documents
export async function countDocs (doctype, filters = {}, or_filters) { export async function countDocs (doctype, filters = {}, or_filters) {
const params = new URLSearchParams({ const params = new URLSearchParams({

View File

@ -1,25 +1,32 @@
import { BASE_URL } from 'src/config/erpnext'
import { authFetch } from './auth'
/** /**
* Send a test SMS notification via ERPNext server script. * Send SMS via n8n webhook Twilio.
* Falls back to logging if Twilio is not configured. * n8n handles Twilio auth + logs to ERPNext automatically.
* *
* @param {string} phone - Phone number (e.g. +15145551234) * @param {string} phone - Phone number (e.g. +15145551234)
* @param {string} message - SMS body * @param {string} message - SMS body
* @param {string} customer - Customer ID (e.g. CUST-4) * @param {string} customer - Customer ID (e.g. CUST-00001) logged as Communication in ERPNext
* @returns {Promise<{ok: boolean, message: string}>} * @param {object} [opts] - Extra options
* @param {string} [opts.reference_doctype] - Link to specific doctype (default: Customer)
* @param {string} [opts.reference_name] - Link to specific record
* @returns {Promise<{ok: boolean, message: string, sid?: string}>}
*/ */
export async function sendTestSms (phone, message, customer) {
const res = await authFetch(BASE_URL + '/api/method/send_sms_notification', { const N8N_WEBHOOK_URL = 'https://n8n.gigafibre.ca/webhook/sms-send'
export async function sendTestSms (phone, message, customer, opts = {}) {
const payload = { phone, message, customer }
if (opts.reference_doctype) payload.reference_doctype = opts.reference_doctype
if (opts.reference_name) payload.reference_name = opts.reference_name
const res = await fetch(N8N_WEBHOOK_URL, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone, message, customer }), body: JSON.stringify(payload),
}) })
if (!res.ok) { if (!res.ok) {
const err = await res.text().catch(() => 'Unknown error') const err = await res.text().catch(() => 'Unknown error')
throw new Error('SMS failed: ' + err) throw new Error('SMS failed (' + res.status + '): ' + err)
} }
const data = await res.json() const data = await res.json()
return data.message || { ok: true, message: 'Sent' } if (data.ok === false) throw new Error(data.message || 'SMS send error')
return data
} }

View File

@ -0,0 +1,536 @@
<template>
<div class="chatter-panel">
<div class="chatter-header">
<span class="text-weight-bold" style="font-size:1.1rem">Convos</span>
<q-space />
<q-badge v-if="unreadCount" color="red" text-color="white" class="q-mr-xs">{{ unreadCount }}</q-badge>
<q-btn flat dense round size="sm" icon="refresh" color="grey-6" @click="loadAll" :loading="loading" />
</div>
<ComposeBar
v-model:channel="composeChannel"
v-model:text="composeText"
v-model:email-subject="emailSubject"
v-model:sms-to="smsTo"
v-model:call-to="callTo"
:customer-phone="customerPhone"
:phone-options="phoneOptions"
:sending="sending"
:calling="calling"
:canned-responses="cannedResponses"
@send="send"
@call="initiateCall"
@use-canned="useCanned"
@manage-canned="cannedModal = true"
/>
<div class="chatter-tabs">
<q-btn-toggle v-model="activeTab" no-caps dense unelevated size="xs" spread
toggle-color="indigo-6" color="grey-2" text-color="grey-7"
:options="tabOptions" />
</div>
<div class="chatter-timeline" ref="timelineRef">
<div v-if="loading && !entries.length" class="flex flex-center q-pa-lg">
<q-spinner size="24px" color="indigo-5" />
</div>
<div v-else-if="!filteredEntries.length" class="text-center text-grey-5 q-pa-lg text-caption">
{{ activeTab === 'all' ? 'Aucune communication' : 'Aucun ' + tabLabel }}
</div>
<template v-else>
<template v-for="(group, idx) in groupedEntries" :key="idx">
<div class="chatter-date-sep">
<span>{{ group.label }}</span>
</div>
<div v-for="entry in group.items" :key="entry.id" class="chatter-entry"
:class="entryClass(entry)" @mouseenter="hoveredEntry = entry.id" @mouseleave="hoveredEntry = null">
<div class="entry-icon" :class="'icon-' + entry.channel">
<q-icon :name="channelIcon(entry)" size="16px" />
</div>
<div class="entry-body">
<div class="entry-header">
<span class="entry-direction">
<q-icon :name="entry.direction === 'out' ? 'call_made' : entry.direction === 'in' ? 'call_received' : 'edit_note'"
size="12px" :color="entry.direction === 'out' ? 'indigo-4' : entry.direction === 'in' ? 'green-6' : 'grey-5'" />
</span>
<span class="entry-author text-weight-medium">{{ entry.author }}</span>
<q-space />
<!-- Action buttons (visible on hover) -->
<div v-if="hoveredEntry === entry.id" class="entry-actions">
<q-btn flat dense round size="xs" icon="edit" color="grey-5" @click="startEdit(entry)" title="Modifier" />
<q-btn flat dense round size="xs" icon="delete_outline" color="red-4" @click="confirmDelete(entry)" title="Supprimer" />
</div>
<span class="entry-time text-caption text-grey-5">{{ formatTime(entry.date) }}</span>
</div>
<!-- Editing mode -->
<div v-if="editingEntry === entry.id" class="entry-edit">
<q-input v-model="editText" dense outlined autogrow :input-style="{ fontSize: '0.82rem' }"
@keydown.escape="editingEntry = null"
@keydown.ctrl.enter="saveEdit(entry)" @keydown.meta.enter="saveEdit(entry)" />
<div class="row q-gutter-xs q-mt-xs">
<q-btn dense unelevated size="xs" color="indigo-6" label="Sauver" @click="saveEdit(entry)" :loading="savingEdit" />
<q-btn dense flat size="xs" color="grey-6" label="Annuler" @click="editingEntry = null" />
</div>
</div>
<!-- Normal display -->
<div v-else class="entry-content" :class="{ 'entry-note': entry.channel === 'note' }">
{{ entry.text }}
</div>
<div v-if="entry.channel === 'phone' && entry.meta" class="entry-meta text-caption text-grey-5">
<q-icon name="timer" size="12px" class="q-mr-xs" />
{{ entry.meta.duration || '0s' }}
<template v-if="entry.meta.status"> &middot; {{ entry.meta.status }}</template>
</div>
<div v-if="entry.linkedTo && entry.linkedTo !== customerName" class="entry-link">
<q-chip dense size="sm" icon="link" clickable color="grey-2" text-color="grey-8"
@click="$emit('navigate', entry.linkedDoctype, entry.linkedTo)">
{{ entry.linkedDoctype }}: {{ entry.linkedTo }}
</q-chip>
</div>
</div>
</div>
</template>
</template>
</div>
<!-- Delete confirmation -->
<q-dialog v-model="deleteDialog">
<q-card style="min-width:300px">
<q-card-section>
<div class="text-weight-bold">Supprimer ce message ?</div>
<div class="text-caption text-grey-6 q-mt-xs">Cette action est irréversible.</div>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Annuler" color="grey-7" v-close-popup />
<q-btn unelevated label="Supprimer" color="red" :loading="deleting" @click="doDelete" />
</q-card-actions>
</q-card>
</q-dialog>
<!-- Canned Responses Manager -->
<q-dialog v-model="cannedModal">
<q-card style="min-width:460px;max-width:600px">
<q-card-section>
<div class="text-weight-bold" style="font-size:1rem">Réponses rapides</div>
<div class="text-caption text-grey-6">Tapez <code>::raccourci</code> dans la zone de texte pour insérer une réponse rapide.</div>
</q-card-section>
<q-separator />
<q-card-section style="max-height:350px;overflow-y:auto;padding:8px 16px">
<div v-for="(cr, i) in cannedResponses" :key="i" class="canned-row">
<div class="canned-shortcut">
<q-input v-model="cr.shortcut" dense borderless prefix="::" :input-style="{ fontSize:'0.82rem', fontWeight:600 }" style="width:100px" />
</div>
<div class="canned-text">
<q-input v-model="cr.text" dense borderless autogrow :input-style="{ fontSize:'0.82rem' }" placeholder="Texte de la réponse..." style="flex:1" />
</div>
<q-btn flat dense round size="xs" icon="delete_outline" color="red-4" @click="removeCanned(i)" />
</div>
<div v-if="!cannedResponses.length" class="text-grey-5 text-caption text-center q-py-md">
Aucune réponse rapide. Cliquez + pour en ajouter.
</div>
</q-card-section>
<q-separator />
<q-card-actions>
<q-btn flat dense icon="add" label="Ajouter" color="indigo-6" @click="addCanned" />
<q-space />
<q-btn flat label="Fermer" color="grey-7" v-close-popup />
<q-btn unelevated label="Sauver" color="indigo-6" @click="saveCanned" v-close-popup />
</q-card-actions>
</q-card>
</q-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch, nextTick, onUnmounted } from 'vue'
import { Notify } from 'quasar'
import { listDocs, createDoc, updateDoc, deleteDoc } from 'src/api/erp'
import { useSSE, sendSmsViaHub } from 'src/composables/useSSE'
import ComposeBar from './ComposeBar.vue'
const TAB_OPTIONS = [
{ label: 'Tout', value: 'all', icon: 'forum' },
{ label: 'SMS', value: 'sms', icon: 'sms' },
{ label: 'Appels', value: 'phone', icon: 'phone' },
{ label: 'Email', value: 'email', icon: 'email' },
{ label: 'Notes', value: 'note', icon: 'sticky_note_2' },
]
const TAB_LABEL_MAP = { sms: 'SMS', phone: 'appel', email: 'email', note: 'note' }
const props = defineProps({
customerName: { type: String, required: true },
customerPhone: { type: String, default: '' },
customerPhones: { type: Array, default: () => [] },
customerEmail: { type: String, default: '' },
comments: { type: Array, default: () => [] },
})
const emit = defineEmits(['navigate', 'note-added', 'note-updated'])
const loading = ref(false)
const activeTab = ref('all')
const composeChannel = ref('sms')
const composeText = ref('')
const emailSubject = ref('')
const sending = ref(false)
const calling = ref(false)
const smsTo = ref('')
const callTo = ref('')
const timelineRef = ref(null)
const communications = ref([])
const unreadCount = ref(0)
// Edit/delete state
const hoveredEntry = ref(null)
const editingEntry = ref(null)
const editText = ref('')
const savingEdit = ref(false)
const deleteDialog = ref(false)
const deleteTarget = ref(null)
const deleting = ref(false)
// Canned responses
const cannedModal = ref(false)
const cannedResponses = ref(loadCannedResponses())
function loadCannedResponses () {
try { return JSON.parse(localStorage.getItem('ops-canned-responses') || '[]') } catch { return [] }
}
function addCanned () { cannedResponses.value.push({ shortcut: '', text: '' }) }
function removeCanned (i) { cannedResponses.value.splice(i, 1) }
function saveCanned () {
const valid = cannedResponses.value.filter(c => c.shortcut.trim() && c.text.trim())
cannedResponses.value = valid
localStorage.setItem('ops-canned-responses', JSON.stringify(valid))
Notify.create({ type: 'positive', message: 'Réponses rapides sauvegardées', timeout: 2000 })
}
function useCanned (cr) {
composeText.value = cr.text
}
const tabOptions = computed(() => TAB_OPTIONS)
const tabLabel = computed(() => TAB_LABEL_MAP[activeTab.value] || 'message')
const phoneOptions = computed(() => {
if (props.customerPhones.length) return props.customerPhones
const opts = []
if (props.customerPhone) opts.push({ label: props.customerPhone, value: props.customerPhone })
return opts.length ? opts : [{ label: 'Aucun numero', value: '', disable: true }]
})
watch(() => props.customerPhone, (v) => {
if (v && !smsTo.value) smsTo.value = v
if (v && !callTo.value) callTo.value = v
}, { immediate: true })
const { connect: sseConnect, disconnect: sseDisconnect, connected: sseConnected } = useSSE({
onMessage: async (data) => {
if (data.customer === props.customerName) {
await loadCommunications()
if (data.direction === 'in') {
playNotificationSound()
Notify.create({
type: 'info', icon: 'sms',
message: `${data.type === 'sms' ? 'SMS' : 'Message'} de ${data.customer_name || data.phone || 'Client'}`,
timeout: 3000, position: 'top-right',
})
}
}
},
})
let fallbackTimer = null
function startFallbackPoll () {
stopFallbackPoll()
fallbackTimer = setInterval(() => loadCommunications(), sseConnected.value ? 30000 : 5000)
}
function stopFallbackPoll () {
if (fallbackTimer) { clearInterval(fallbackTimer); fallbackTimer = null }
}
async function loadCommunications () {
try {
const data = await listDocs('Communication', {
filters: { reference_doctype: 'Customer', reference_name: props.customerName },
fields: [
'name', 'subject', 'content', 'text_content', 'communication_medium',
'sent_or_received', 'communication_date', 'creation', 'phone_no',
'sender', 'sender_full_name', 'status', 'delivery_status',
'reference_doctype', 'reference_name', 'message_id', 'communication_type',
],
limit: 100,
orderBy: 'communication_date asc',
})
communications.value = data
unreadCount.value = data.filter(m => m.sent_or_received === 'Received' && m.status === 'Open').length
} catch { /* load error */ }
}
function playNotificationSound () {
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)()
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.connect(gain); gain.connect(ctx.destination)
osc.type = 'sine'
osc.frequency.setValueAtTime(880, ctx.currentTime)
osc.frequency.setValueAtTime(1100, ctx.currentTime + 0.1)
gain.gain.setValueAtTime(0.15, ctx.currentTime)
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.3)
osc.start(ctx.currentTime); osc.stop(ctx.currentTime + 0.3)
} catch { /* silent fallback */ }
}
async function loadAll () {
loading.value = true
await loadCommunications()
loading.value = false
sseConnect(['customer:' + props.customerName])
startFallbackPoll()
}
const entries = computed(() => {
const items = []
for (const c of communications.value) {
const medium = (c.communication_medium || '').toLowerCase()
const channel = medium === 'sms' ? 'sms' : medium === 'phone' ? 'phone' : medium === 'email' ? 'email' : 'other'
items.push({
id: c.name, channel,
direction: c.sent_or_received === 'Sent' ? 'out' : 'in',
author: c.sent_or_received === 'Sent' ? (c.sender_full_name || 'Targo Ops') : (c.sender_full_name || c.phone_no || c.sender || 'Client'),
text: stripHtml(c.content || c.text_content || c.subject || ''),
date: c.communication_date || c.creation,
status: c.status, deliveryStatus: c.delivery_status,
linkedDoctype: c.reference_doctype, linkedTo: c.reference_name,
meta: channel === 'phone' ? { duration: null, status: c.delivery_status || null } : null,
raw: c, doctype: 'Communication',
})
}
for (const n of (props.comments || [])) {
items.push({
id: 'note-' + n.name, channel: 'note', direction: 'internal',
author: n.comment_by?.split('@')[0] || 'Admin',
text: stripHtml(n.content || ''),
date: n.creation, status: null, linkedDoctype: null, linkedTo: null, meta: null,
raw: n, doctype: 'Comment', docName: n.name,
})
}
items.sort((a, b) => new Date(b.date) - new Date(a.date))
return items
})
const filteredEntries = computed(() => {
if (activeTab.value === 'all') return entries.value
return entries.value.filter(e => e.channel === activeTab.value)
})
const groupedEntries = computed(() => {
const groups = []
let currentLabel = ''
for (const entry of filteredEntries.value) {
const label = dateLabel(entry.date)
if (label !== currentLabel) {
currentLabel = label
groups.push({ label, items: [] })
}
groups[groups.length - 1].items.push(entry)
}
return groups
})
// --- Edit / Delete ---
function startEdit (entry) {
editingEntry.value = entry.id
editText.value = entry.text
}
async function saveEdit (entry) {
if (!editText.value.trim()) return
savingEdit.value = true
try {
const doctype = entry.doctype || (entry.channel === 'note' ? 'Comment' : 'Communication')
const name = entry.docName || entry.raw?.name
if (!name) return
await updateDoc(doctype, name, { content: editText.value.trim() })
entry.raw.content = editText.value.trim()
editingEntry.value = null
if (doctype === 'Comment') emit('note-updated')
else await loadCommunications()
Notify.create({ type: 'positive', message: 'Message modifié', timeout: 2000 })
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 })
} finally {
savingEdit.value = false
}
}
function confirmDelete (entry) {
deleteTarget.value = entry
deleteDialog.value = true
}
async function doDelete () {
if (!deleteTarget.value) return
const entry = deleteTarget.value
const doctype = entry.doctype || (entry.channel === 'note' ? 'Comment' : 'Communication')
const name = entry.docName || entry.raw?.name
if (!name) return
// Optimistic: remove from UI immediately
if (doctype === 'Comment') {
const idx = props.comments.findIndex(n => n.name === entry.raw?.name)
if (idx !== -1) props.comments.splice(idx, 1)
} else {
const idx = communications.value.findIndex(c => c.name === name)
if (idx !== -1) communications.value.splice(idx, 1)
}
deleteDialog.value = false
deleteTarget.value = null
Notify.create({ type: 'positive', message: 'Message supprimé', timeout: 2000 })
// Background: actually delete from backend
try {
await deleteDoc(doctype, name)
if (doctype === 'Comment') emit('note-updated')
} catch (e) {
// Reload to restore state on error
Notify.create({ type: 'negative', message: 'Erreur suppression: ' + e.message, timeout: 3000 })
await loadCommunications()
if (doctype === 'Comment') emit('note-updated')
}
}
// --- Send ---
async function send () {
if (!composeText.value.trim() || sending.value) return
sending.value = true
try {
if (composeChannel.value === 'sms') await sendSms()
else if (composeChannel.value === 'note') await addNote()
else if (composeChannel.value === 'email') await sendEmail()
composeText.value = ''
emailSubject.value = ''
setTimeout(loadCommunications, 1000)
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 4000 })
} finally {
sending.value = false
}
}
async function sendSms () {
if (!smsTo.value) throw new Error('Aucun numero')
await sendSmsViaHub(smsTo.value, composeText.value.trim(), props.customerName)
Notify.create({ type: 'positive', message: 'SMS envoyé', timeout: 2000 })
}
async function addNote () {
const doc = await createDoc('Comment', {
comment_type: 'Comment',
reference_doctype: 'Customer',
reference_name: props.customerName,
content: composeText.value.trim(),
})
emit('note-added', doc)
Notify.create({ type: 'positive', message: 'Note ajoutée', timeout: 2000 })
}
async function sendEmail () {
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: props.customerEmail,
subject: emailSubject.value.trim(),
message: composeText.value.trim(),
customer: props.customerName,
}),
})
if (!res.ok) throw new Error('Email failed')
Notify.create({ type: 'positive', message: 'Email envoyé', timeout: 2000 })
}
async function initiateCall () {
const phone = callTo.value || smsTo.value || props.customerPhone
if (!phone) { Notify.create({ type: 'warning', message: 'Aucun numéro', timeout: 3000 }); return }
calling.value = true
try {
window.open('tel:' + phone, '_self')
await createDoc('Communication', {
subject: 'Appel \u2192 ' + phone,
communication_type: 'Communication',
communication_medium: 'Phone',
sent_or_received: 'Sent',
status: 'Open',
phone_no: phone,
sender: 'sms@gigafibre.ca',
sender_full_name: 'Targo Ops',
content: 'Appel initié vers ' + phone + ' via 3CX',
reference_doctype: 'Customer',
reference_name: props.customerName,
})
Notify.create({ type: 'positive', message: 'Appel lancé via 3CX', timeout: 3000 })
setTimeout(loadCommunications, 1000)
} catch {
Notify.create({ type: 'info', message: 'Appel lancé', timeout: 3000 })
} finally {
calling.value = false
}
}
// --- Helpers ---
const CHANNEL_ICONS = { sms: 'sms', phone: 'phone', email: 'email', note: 'sticky_note_2', other: 'chat' }
function channelIcon (entry) { return CHANNEL_ICONS[entry.channel] || 'chat' }
function entryClass (entry) {
return [
'entry-' + entry.channel,
entry.direction === 'in' ? 'entry-inbound' : entry.direction === 'out' ? 'entry-outbound' : 'entry-internal',
entry.status === 'Open' ? 'entry-unread' : '',
]
}
function stripHtml (html) {
if (!html) return ''
const div = document.createElement('div')
div.innerHTML = html
return div.textContent || div.innerText || ''
}
function formatTime (dt) {
if (!dt) return ''
return new Date(dt).toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit' })
}
function dateLabel (dt) {
if (!dt) return ''
const d = new Date(dt)
const now = new Date()
if (d.toDateString() === now.toDateString()) return "Aujourd'hui"
const yesterday = new Date(now)
yesterday.setDate(yesterday.getDate() - 1)
if (d.toDateString() === yesterday.toDateString()) return 'Hier'
return d.toLocaleDateString('fr-CA', { weekday: 'short', month: 'short', day: 'numeric' })
}
function scrollToBottom () {
if (timelineRef.value) timelineRef.value.scrollTop = timelineRef.value.scrollHeight
}
onMounted(loadAll)
watch(() => props.customerName, loadAll)
onUnmounted(() => { sseDisconnect(); stopFallbackPoll() })
</script>
<style src="./chatter-panel.scss" scoped></style>

View File

@ -0,0 +1,166 @@
<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>

View File

@ -1,39 +1,16 @@
<template> <template>
<div class="ops-card" style="height:100%"> <div>
<div class="section-title"> <div class="section-title">
<q-icon name="contact_phone" size="18px" class="q-mr-xs" /> Contact <q-icon name="contact_phone" size="18px" class="q-mr-xs" /> Contact
</div> </div>
<div class="info-grid"> <div class="info-grid">
<div class="info-row editable-row"> <div v-for="f in fields" :key="f.field" class="info-row editable-row">
<q-icon name="person" size="16px" color="grey-6" /> <q-icon :name="f.icon" size="16px" color="grey-6" />
<q-input v-model="customer.contact_name_legacy" dense borderless placeholder="Contact" <q-input v-model="customer[f.field]" dense borderless :placeholder="f.label"
input-class="editable-input" @change="$emit('save', 'contact_name_legacy')" /> :input-class="'editable-input' + (f.small ? ' text-caption' : '')"
</div> :style="f.small ? 'word-break:break-all' : ''"
<div class="info-row editable-row"> @blur="save(f.field)" @keyup.enter="$event.target.blur()" />
<q-icon name="badge" size="16px" color="grey-6" /> <q-spinner v-if="saving === f.field" size="12px" color="grey-5" class="q-ml-xs" />
<q-input v-model="customer.mandataire" dense borderless placeholder="Mandataire"
input-class="editable-input" @change="$emit('save', 'mandataire')" />
</div>
<div class="info-row editable-row">
<q-icon name="phone" size="16px" color="grey-6" />
<q-input v-model="customer.tel_home" dense borderless placeholder="Tél. maison"
input-class="editable-input" @change="$emit('save', 'tel_home')" />
</div>
<div class="info-row editable-row">
<q-icon name="business" size="16px" color="grey-6" />
<q-input v-model="customer.tel_office" dense borderless placeholder="Tél. bureau"
input-class="editable-input" @change="$emit('save', 'tel_office')" />
</div>
<div class="info-row editable-row">
<q-icon name="smartphone" size="16px" color="grey-6" />
<q-input v-model="customer.cell_phone" dense borderless placeholder="Cellulaire"
input-class="editable-input" @change="$emit('save', 'cell_phone')" />
</div>
<div class="info-row editable-row">
<q-icon name="email" size="16px" color="grey-6" />
<q-input v-model="customer.email_billing" dense borderless placeholder="Email facturation"
input-class="editable-input text-caption" style="word-break:break-all"
@change="$emit('save', 'email_billing')" />
</div> </div>
<div v-if="customer.stripe_id" class="info-row"> <div v-if="customer.stripe_id" class="info-row">
<q-icon name="payment" size="16px" color="grey-6" /> <q-icon name="payment" size="16px" color="grey-6" />
@ -51,7 +28,6 @@
<q-badge v-if="lastSentLabel" color="green-6" class="text-caption">{{ lastSentLabel }}</q-badge> <q-badge v-if="lastSentLabel" color="green-6" class="text-caption">{{ lastSentLabel }}</q-badge>
</div> </div>
<div v-show="notifyExpanded" class="notify-body"> <div v-show="notifyExpanded" class="notify-body">
<!-- Channel toggle -->
<q-btn-toggle v-model="channel" no-caps dense unelevated size="sm" class="q-mb-xs full-width" <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" toggle-color="indigo-6" color="grey-3" text-color="grey-8"
:options="[ :options="[
@ -59,22 +35,15 @@
{ label: 'Email', value: 'email', icon: 'email' }, { label: 'Email', value: 'email', icon: 'email' },
]" ]"
/> />
<!-- Recipient -->
<q-select v-if="channel === 'sms'" v-model="smsTo" dense outlined emit-value map-options <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" /> :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 <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" /> :options="emailOptions" label="Envoyer à" style="font-size:0.82rem" class="q-mb-xs" />
<!-- Subject (email only) -->
<q-input v-if="channel === 'email'" v-model="emailSubject" dense outlined <q-input v-if="channel === 'email'" v-model="emailSubject" dense outlined
placeholder="Sujet" class="q-mb-xs" :input-style="{ fontSize: '0.82rem' }" /> placeholder="Sujet" class="q-mb-xs" :input-style="{ fontSize: '0.82rem' }" />
<!-- Message body -->
<q-input v-model="notifyMessage" dense outlined type="textarea" autogrow <q-input v-model="notifyMessage" dense outlined type="textarea" autogrow
placeholder="Message..." placeholder="Message..."
:input-style="{ fontSize: '0.82rem', minHeight: '45px', maxHeight: '120px' }" /> :input-style="{ fontSize: '0.82rem', minHeight: '45px', maxHeight: '120px' }" />
<div class="row items-center q-mt-xs"> <div class="row items-center q-mt-xs">
<span class="text-caption text-grey-5">{{ notifyMessage.length }} car.</span> <span class="text-caption text-grey-5">{{ notifyMessage.length }} car.</span>
<q-space /> <q-space />
@ -84,17 +53,59 @@
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { updateDoc } from 'src/api/erp'
import { sendTestSms } from 'src/api/sms' import { sendTestSms } from 'src/api/sms'
const props = defineProps({ customer: { type: Object, required: true } }) const props = defineProps({ customer: { type: Object, required: true } })
defineEmits(['save'])
// Notification state 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 notifyExpanded = ref(false)
const channel = ref('sms') const channel = ref('sms')
const notifyMessage = ref('Bonjour, ceci est une notification de Gigafibre.') const notifyMessage = ref('Bonjour, ceci est une notification de Gigafibre.')
@ -136,7 +147,6 @@ async function sendNotification () {
const result = await sendTestSms(smsTo.value, notifyMessage.value.trim(), props.customer.name) const result = await sendTestSms(smsTo.value, notifyMessage.value.trim(), props.customer.name)
lastSentLabel.value = result?.simulated ? 'Simulé' : 'SMS envoyé' lastSentLabel.value = result?.simulated ? 'Simulé' : 'SMS envoyé'
} else { } else {
// Email via same endpoint pattern n8n webhook
const { authFetch } = await import('src/api/auth') const { authFetch } = await import('src/api/auth')
const { BASE_URL } = await import('src/config/erpnext') const { BASE_URL } = await import('src/config/erpnext')
const res = await authFetch(BASE_URL + '/api/method/send_email_notification', { const res = await authFetch(BASE_URL + '/api/method/send_email_notification', {
@ -171,7 +181,6 @@ async function sendNotification () {
border-top: 1px solid #e2e8f0; border-top: 1px solid #e2e8f0;
padding-top: 6px; padding-top: 6px;
} }
.notify-header { .notify-header {
display: flex; display: flex;
align-items: center; align-items: center;
@ -181,11 +190,9 @@ async function sendNotification () {
border-radius: 4px; border-radius: 4px;
user-select: none; user-select: none;
} }
.notify-header:hover { .notify-header:hover {
background: #f8fafc; background: #f8fafc;
} }
.notify-body { .notify-body {
padding: 6px 0 2px 0; padding: 6px 0 2px 0;
} }

View File

@ -1,11 +1,12 @@
<template> <template>
<div class="ops-card q-mb-md"> <div class="customer-header-block">
<div class="row items-center q-col-gutter-md"> <!-- Main header line -->
<div class="row items-center q-col-gutter-sm">
<div class="col-auto"> <div class="col-auto">
<q-btn flat dense round icon="arrow_back" @click="$router.back()" /> <q-btn flat dense round icon="arrow_back" @click="$router.back()" />
</div> </div>
<div class="col"> <div class="col">
<div class="text-h5 text-weight-bold"> <div class="text-h5 text-weight-bold" style="line-height:1.2">
<InlineField :value="customer.customer_name" field="customer_name" doctype="Customer" :docname="customer.name" <InlineField :value="customer.customer_name" field="customer_name" doctype="Customer" :docname="customer.name"
placeholder="Nom du client" @saved="v => customer.customer_name = v.value" /> placeholder="Nom du client" @saved="v => customer.customer_name = v.value" />
</div> </div>
@ -16,18 +17,18 @@
<q-select v-model="customer.customer_type" dense borderless <q-select v-model="customer.customer_type" dense borderless
:options="['Individual', 'Company']" emit-value map-options :options="['Individual', 'Company']" emit-value map-options
input-class="editable-input text-caption" style="min-width:80px;max-width:110px" input-class="editable-input text-caption" style="min-width:80px;max-width:110px"
@update:model-value="$emit('save', 'customer_type')" /> @update:model-value="save('customer_type')" />
<span>&middot;</span> <span>&middot;</span>
<q-select v-model="customer.customer_group" dense borderless <q-select v-model="customer.customer_group" dense borderless
:options="customerGroups" emit-value map-options :options="customerGroups" emit-value map-options
input-class="editable-input text-caption" style="min-width:90px;max-width:130px" input-class="editable-input text-caption" style="min-width:90px;max-width:130px"
@update:model-value="$emit('save', 'customer_group')" /> @update:model-value="save('customer_group')" />
<template v-if="customer.language"> <template v-if="customer.language">
<span>&middot;</span> <span>&middot;</span>
<q-select v-model="customer.language" dense borderless <q-select v-model="customer.language" dense borderless
:options="[{label:'Français',value:'fr'},{label:'English',value:'en'}]" emit-value map-options :options="[{label:'Français',value:'fr'},{label:'English',value:'en'}]" emit-value map-options
input-class="editable-input text-caption" style="min-width:70px;max-width:100px" input-class="editable-input text-caption" style="min-width:70px;max-width:100px"
@update:model-value="$emit('save', 'language')" /> @update:model-value="save('language')" />
</template> </template>
</div> </div>
</div> </div>
@ -35,17 +36,70 @@
<span class="ops-badge q-mb-xs" :class="customer.disabled ? 'inactive' : 'active'" style="font-size:0.85rem"> <span class="ops-badge q-mb-xs" :class="customer.disabled ? 'inactive' : 'active'" style="font-size:0.85rem">
{{ customer.disabled ? 'Inactif' : 'Actif' }} {{ customer.disabled ? 'Inactif' : 'Actif' }}
</span> </span>
<div class="q-mt-xs q-gutter-x-xs">
<q-btn flat dense size="xs" icon="open_in_new" label="ERPNext"
:href="erpDeskUrl + '/app/customer/' + customer.name" target="_blank"
class="text-grey-6" no-caps />
<q-btn flat dense size="xs" icon="manage_accounts" label="Users"
:href="erpDeskUrl + '/app/user?customer=' + customer.name" target="_blank"
class="text-grey-6" no-caps />
</div> </div>
</div> </div>
</div> </div>
<!-- Contact & Info (collapsible) -->
<q-expansion-item v-model="contactOpen" dense header-class="contact-toggle-header" class="q-mt-xs">
<template #header>
<div class="row items-center" style="width:100%;gap:4px">
<q-icon name="contact_phone" size="16px" color="grey-5" />
<span class="text-caption text-weight-medium text-grey-6">Contact & Info</span>
<q-space />
<span class="text-caption text-grey-5">{{ customer.contact_name_legacy || '' }}</span>
</div>
</template>
<div class="q-pt-xs">
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<slot name="contact" />
</div>
<div class="col-12 col-md-6">
<slot name="info" />
</div>
</div>
</div>
</q-expansion-item>
</div>
</template> </template>
<script setup> <script setup>
import { ref } from 'vue'
import InlineField from 'src/components/shared/InlineField.vue' import InlineField from 'src/components/shared/InlineField.vue'
import { ERP_DESK_URL as erpDeskUrl } from 'src/config/erpnext'
import { updateDoc } from 'src/api/erp'
defineProps({ const contactOpen = ref(false)
const props = defineProps({
customer: { type: Object, required: true }, customer: { type: Object, required: true },
customerGroups: { type: Array, default: () => ['Commercial', 'Individual', 'Government', 'Non Profit'] }, customerGroups: { type: Array, default: () => ['Commercial', 'Individual', 'Government', 'Non Profit'] },
}) })
defineEmits(['save'])
async function save (field) {
try {
await updateDoc('Customer', props.customer.name, { [field]: props.customer[field] ?? '' })
} catch (e) {
const { Notify } = await import('quasar')
Notify?.create?.({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 })
}
}
</script> </script>
<style scoped>
.customer-header-block {
padding: 8px 0 4px 0;
}
.contact-toggle-header {
padding: 4px 8px !important;
min-height: 28px !important;
}
</style>

View File

@ -1,5 +1,5 @@
<template> <template>
<div class="ops-card" style="height:100%"> <div>
<div class="section-title"> <div class="section-title">
<q-icon name="info" size="18px" class="q-mr-xs" /> Informations <q-icon name="info" size="18px" class="q-mr-xs" /> Informations
</div> </div>
@ -10,7 +10,7 @@
<q-select v-model="customer.invoice_delivery_method" dense borderless <q-select v-model="customer.invoice_delivery_method" dense borderless
:options="['Email', 'Poste', 'Email + Poste']" emit-value map-options :options="['Email', 'Poste', 'Email + Poste']" emit-value map-options
style="min-width:120px" input-class="editable-input text-weight-bold" style="min-width:120px" input-class="editable-input text-weight-bold"
@update:model-value="$emit('save', 'invoice_delivery_method')" /> @update:model-value="save('invoice_delivery_method')" />
</div> </div>
<div class="info-row editable-row"> <div class="info-row editable-row">
<q-icon name="receipt_long" size="16px" color="grey-6" /> <q-icon name="receipt_long" size="16px" color="grey-6" />
@ -18,29 +18,29 @@
<q-select v-model="customer.tax_category_legacy" dense borderless <q-select v-model="customer.tax_category_legacy" dense borderless
:options="['Federal + Provincial (9.5%)', 'Federal seulement', 'Exempté']" :options="['Federal + Provincial (9.5%)', 'Federal seulement', 'Exempté']"
emit-value map-options style="min-width:140px" input-class="editable-input" emit-value map-options style="min-width:140px" input-class="editable-input"
@update:model-value="$emit('save', 'tax_category_legacy')" /> @update:model-value="save('tax_category_legacy')" />
</div> </div>
<div class="info-row"> <div class="info-row">
<q-toggle v-model="customer.is_commercial" dense size="xs" color="blue" <q-toggle v-model="customer.is_commercial" dense size="xs" color="blue"
@update:model-value="$emit('save', 'is_commercial')" /> @update:model-value="save('is_commercial')" />
<q-icon name="storefront" size="16px" :color="customer.is_commercial ? 'blue-6' : 'grey-4'" /> <q-icon name="storefront" size="16px" :color="customer.is_commercial ? 'blue-6' : 'grey-4'" />
<span :class="customer.is_commercial ? 'text-blue-8 text-weight-medium' : 'text-grey-5'">Commercial</span> <span :class="customer.is_commercial ? 'text-blue-8 text-weight-medium' : 'text-grey-5'">Commercial</span>
</div> </div>
<div class="info-row"> <div class="info-row">
<q-toggle v-model="customer.is_bad_payer" dense size="xs" color="red" <q-toggle v-model="customer.is_bad_payer" dense size="xs" color="red"
@update:model-value="$emit('save', 'is_bad_payer')" /> @update:model-value="save('is_bad_payer')" />
<q-icon name="warning" size="16px" :color="customer.is_bad_payer ? 'red-6' : 'grey-4'" /> <q-icon name="warning" size="16px" :color="customer.is_bad_payer ? 'red-6' : 'grey-4'" />
<span :class="customer.is_bad_payer ? 'text-red-8 text-weight-medium' : 'text-grey-5'">Mauvais payeur</span> <span :class="customer.is_bad_payer ? 'text-red-8 text-weight-medium' : 'text-grey-5'">Mauvais payeur</span>
</div> </div>
<div class="info-row"> <div class="info-row">
<q-toggle v-model="customer.exclude_fees" dense size="xs" color="orange" <q-toggle v-model="customer.exclude_fees" dense size="xs" color="orange"
@update:model-value="$emit('save', 'exclude_fees')" /> @update:model-value="save('exclude_fees')" />
<q-icon name="money_off" size="16px" :color="customer.exclude_fees ? 'orange-6' : 'grey-4'" /> <q-icon name="money_off" size="16px" :color="customer.exclude_fees ? 'orange-6' : 'grey-4'" />
<span :class="customer.exclude_fees ? 'text-orange-8' : 'text-grey-5'">Exclure frais</span> <span :class="customer.exclude_fees ? 'text-orange-8' : 'text-grey-5'">Exclure frais</span>
</div> </div>
<div class="info-row"> <div class="info-row">
<q-toggle v-model="customer.ppa_enabled" dense size="xs" color="green" <q-toggle v-model="customer.ppa_enabled" dense size="xs" color="green"
@update:model-value="$emit('save', 'ppa_enabled')" /> @update:model-value="save('ppa_enabled')" />
<q-icon :name="customer.ppa_enabled ? 'credit_card' : 'credit_card_off'" size="16px" <q-icon :name="customer.ppa_enabled ? 'credit_card' : 'credit_card_off'" size="16px"
:color="customer.ppa_enabled ? 'green-6' : 'grey-4'" /> :color="customer.ppa_enabled ? 'green-6' : 'grey-4'" />
<span :class="customer.ppa_enabled ? 'text-green-7' : 'text-grey-5'">PPA</span> <span :class="customer.ppa_enabled ? 'text-green-7' : 'text-grey-5'">PPA</span>
@ -53,12 +53,26 @@
<div class="q-mt-sm" style="border-top:1px solid #e2e8f0;padding-top:6px"> <div class="q-mt-sm" style="border-top:1px solid #e2e8f0;padding-top:6px">
<q-input v-model="customer.notes_internal" dense borderless type="textarea" autogrow <q-input v-model="customer.notes_internal" dense borderless type="textarea" autogrow
placeholder="Notes internes..." input-class="editable-input text-caption" placeholder="Notes internes..." input-class="editable-input text-caption"
@change="$emit('save', 'notes_internal')" /> @blur="save('notes_internal')" @keyup.ctrl.enter="$event.target.blur()" />
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
defineProps({ customer: { type: Object, required: true } }) import { updateDoc } from 'src/api/erp'
defineEmits(['save'])
const props = defineProps({ customer: { type: Object, required: true } })
const CHECK_FIELDS = ['is_commercial', 'is_bad_payer', 'exclude_fees', 'ppa_enabled']
async function save (field) {
try {
let val = props.customer[field]
if (CHECK_FIELDS.includes(field)) val = val ? 1 : 0
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 })
}
}
</script> </script>

View File

@ -0,0 +1,253 @@
<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>

View File

@ -0,0 +1,177 @@
.chatter-panel {
background: white;
border: 1px solid #e2e8f0;
border-radius: 10px;
display: flex;
flex-direction: column;
height: calc(100vh - 220px);
min-height: 500px;
position: sticky;
top: 16px;
}
.chatter-header {
display: flex;
align-items: center;
padding: 12px 14px 6px;
}
.chatter-tabs {
padding: 0 10px 8px;
}
.chatter-timeline {
flex: 1;
overflow-y: auto;
padding: 0 10px;
scroll-behavior: smooth;
}
.chatter-date-sep {
text-align: center;
margin: 12px 0 6px;
}
.chatter-date-sep span {
background: #f1f5f9;
color: #94a3b8;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 2px 10px;
border-radius: 10px;
}
.chatter-entry {
display: flex;
gap: 8px;
padding: 6px 4px;
border-radius: 6px;
transition: background 0.1s;
}
.chatter-entry:hover { background: #f8fafc; }
.chatter-entry.entry-unread { background: #eff6ff; }
.entry-icon {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 2px;
}
.icon-sms { background: #e8eaf6; color: #3f51b5; }
.icon-phone { background: #e8f5e9; color: #2e7d32; }
.icon-email { background: #fff3e0; color: #e65100; }
.icon-note { background: #fff8e1; color: #f9a825; }
.icon-other { background: #f5f5f5; color: #757575; }
.entry-body { flex: 1; min-width: 0; }
.entry-header {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 1px;
}
.entry-author { font-size: 0.8rem; color: #334155; }
.entry-time { font-size: 0.7rem; }
.entry-actions {
display: flex;
gap: 0;
margin-right: 4px;
}
.entry-content {
font-size: 0.84rem;
line-height: 1.4;
color: #475569;
word-break: break-word;
}
.entry-note {
font-style: italic;
color: #92400e;
background: #fffbeb;
padding: 4px 8px;
border-radius: 6px;
border-left: 3px solid #f59e0b;
}
.entry-edit {
margin-top: 2px;
}
.entry-meta { margin-top: 2px; }
.entry-link { margin-top: 3px; }
.chatter-compose {
border-top: 1px solid #e2e8f0;
padding: 8px 10px;
background: #fafbfc;
border-radius: 0 0 10px 10px;
position: relative;
}
.compose-channel-row {
display: flex;
align-items: center;
margin-bottom: 6px;
}
// Canned responses dropdown
.canned-dropdown {
position: absolute;
bottom: 100%;
left: 8px;
right: 8px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
box-shadow: 0 -4px 16px rgba(0,0,0,0.1);
max-height: 200px;
overflow-y: auto;
z-index: 10;
}
.canned-option {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
transition: background 0.1s;
&:hover, &.canned-highlighted { background: #f1f5f9; }
&:not(:last-child) { border-bottom: 1px solid #f1f5f9; }
}
.canned-shortcut-label {
font-size: 0.75rem;
font-weight: 700;
color: #6366f1;
background: #eef2ff;
padding: 1px 6px;
border-radius: 4px;
white-space: nowrap;
}
.canned-preview {
font-size: 0.8rem;
color: #64748b;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
// Canned manager modal
.canned-row {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 0;
border-bottom: 1px solid #f1f5f9;
}
.canned-shortcut { flex-shrink: 0; }
.canned-text { flex: 1; min-width: 0; }
.chatter-timeline::-webkit-scrollbar { width: 4px; }
.chatter-timeline::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
.chatter-timeline::-webkit-scrollbar-track { background: transparent; }

View File

@ -0,0 +1,226 @@
<template>
<q-dialog :model-value="modelValue" @update:model-value="$emit('update:modelValue', $event)">
<q-card style="width:520px;max-width:90vw">
<!-- Header -->
<q-card-section class="row items-center q-pb-sm" style="border-bottom:1px solid var(--ops-border, #e2e8f0)">
<div class="col">
<div class="text-subtitle1 text-weight-bold">Créer un travail de dispatch</div>
<div class="text-caption text-grey-6">{{ issue?.name }}</div>
</div>
<q-btn flat round dense icon="close" @click="$emit('update:modelValue', false)" />
</q-card-section>
<!-- Form -->
<q-card-section class="q-pt-md q-gutter-sm">
<q-input
v-model="form.subject"
label="Sujet"
dense outlined
:rules="[v => !!v || 'Requis']"
/>
<q-select
v-model="form.job_type"
:options="jobTypeOptions"
label="Type de travail"
dense outlined emit-value map-options
/>
<q-select
v-model="form.priority"
:options="priorityOptions"
label="Priorité"
dense outlined emit-value map-options
/>
<q-input
v-model.number="form.duration_h"
label="Durée (heures)"
type="number"
dense outlined
:rules="[v => v > 0 || 'Doit être > 0']"
/>
<q-input
v-model="form.address"
label="Adresse"
dense outlined
/>
<q-select
v-if="existingJobs.length"
v-model="form.depends_on"
:options="dependencyOptions"
label="Dépend de (optionnel)"
dense outlined clearable emit-value map-options
/>
<q-input
v-model="form.scheduled_date"
label="Date prévue (optionnel)"
type="date"
dense outlined
/>
<q-input
v-model="form.notes"
label="Notes (optionnel)"
type="textarea"
dense outlined
autogrow
/>
</q-card-section>
<!-- Actions -->
<q-card-actions align="right" class="q-px-md q-pb-md">
<q-btn flat label="Annuler" color="grey-7" @click="$emit('update:modelValue', false)" />
<q-btn
unelevated label="Créer" color="indigo-6"
:loading="submitting"
@click="submit"
/>
</q-card-actions>
</q-card>
</q-dialog>
</template>
<script setup>
import { reactive, ref, computed, watch } from 'vue'
import { Notify } from 'quasar'
import { createJob } from 'src/api/dispatch'
import { getDoc } from 'src/api/erp'
const props = defineProps({
modelValue: Boolean,
issue: { type: Object, default: () => ({}) },
existingJobs: { type: Array, default: () => [] },
})
const emit = defineEmits(['update:modelValue', 'created'])
// Options
const jobTypeOptions = [
{ label: 'Installation', value: 'Installation' },
{ label: 'Réparation', value: 'Réparation' },
{ label: 'Maintenance', value: 'Maintenance' },
{ label: 'Retrait', value: 'Retrait' },
{ label: 'Dépannage', value: 'Dépannage' },
{ label: 'Autre', value: 'Autre' },
]
const priorityOptions = [
{ label: 'Basse', value: 'low' },
{ label: 'Moyenne', value: 'medium' },
{ label: 'Haute', value: 'high' },
]
const dependencyOptions = computed(() => props.existingJobs.map(j => ({
label: `${j.name}: ${j.subject || ''}`,
value: j.name,
})))
// Mappings
function mapIssueType (issueType) {
if (!issueType) return 'Autre'
const t = issueType.trim()
if (t === 'Installation') return 'Installation'
if (t === 'Support' || t === 'Réseau') return 'Réparation'
if (t === 'Déménagement') return 'Maintenance'
return 'Autre'
}
function mapPriority (issuePriority) {
if (!issuePriority) return 'medium'
const p = issuePriority.trim()
if (p === 'Urgent' || p === 'High') return 'high'
if (p === 'Medium') return 'medium'
if (p === 'Low') return 'low'
return 'medium'
}
// Form state
const form = reactive({
subject: '',
job_type: 'Autre',
priority: 'medium',
duration_h: 1,
address: '',
depends_on: null,
scheduled_date: '',
notes: '',
})
const submitting = ref(false)
// Reset form when dialog opens or issue changes
watch(() => [props.modelValue, props.issue], async () => {
if (props.modelValue && props.issue) {
form.subject = props.issue.subject || ''
form.job_type = mapIssueType(props.issue.issue_type)
form.priority = mapPriority(props.issue.priority)
form.duration_h = 1
form.depends_on = null
form.scheduled_date = ''
form.notes = ''
// Resolve address from service_location or customer's first location
form.address = ''
try {
if (props.issue.service_location) {
const loc = await getDoc('Service Location', props.issue.service_location)
form.address = buildAddress(loc)
} else if (props.issue.customer) {
// Fallback: get first active service location for this customer
const { listDocs } = await import('src/api/erp')
const locs = await listDocs('Service Location', {
filters: { customer: props.issue.customer },
fields: ['name', 'address_line', 'city', 'postal_code'],
limit: 1,
orderBy: 'status asc',
})
if (locs.length) form.address = buildAddress(locs[0])
}
} catch { /* address pre-fill is best effort */ }
}
}, { immediate: true })
function buildAddress (loc) {
if (!loc) return ''
const parts = [loc.address_line, loc.city, loc.postal_code].filter(Boolean)
return parts.join(', ')
}
// Submit
async function submit () {
if (!form.subject) return
submitting.value = true
try {
// ticket_id is required by Dispatch Job doctype generate a unique one
const ticketId = 'DJ-' + Date.now().toString(36).toUpperCase()
const payload = {
ticket_id: ticketId,
subject: form.subject,
address: form.address,
duration_h: form.duration_h,
priority: form.priority,
status: 'open',
job_type: form.job_type,
source_issue: props.issue.name,
customer: props.issue.customer || '',
service_location: props.issue.service_location || '',
depends_on: form.depends_on || '',
scheduled_date: form.scheduled_date || '',
notes: form.notes || '',
}
const newJob = await createJob(payload)
Notify.create({ type: 'positive', message: 'Travail de dispatch créé avec succès' })
emit('created', newJob)
emit('update:modelValue', false)
} catch (err) {
console.error('[CreateDispatchJobDialog] submit error:', err)
Notify.create({ type: 'negative', message: `Erreur: ${err.message || err}` })
} finally {
submitting.value = false
}
}
</script>

View File

@ -13,7 +13,6 @@
<slot name="title-suffix" /> <slot name="title-suffix" />
</div> </div>
</div> </div>
<!-- Action buttons -->
<slot name="header-actions" /> <slot name="header-actions" />
<q-btn v-if="doctype === 'Sales Invoice'" flat round dense icon="picture_as_pdf" <q-btn v-if="doctype === 'Sales Invoice'" flat round dense icon="picture_as_pdf"
@click="$emit('open-pdf', docName)" class="q-mr-xs" color="red-7"> @click="$emit('open-pdf', docName)" class="q-mr-xs" color="red-7">
@ -30,294 +29,20 @@
<!-- Content --> <!-- Content -->
<q-card-section v-else-if="doc" class="col q-pt-sm" style="overflow-y:auto"> <q-card-section v-else-if="doc" class="col q-pt-sm" style="overflow-y:auto">
<component :is="sectionComponent" v-if="sectionComponent"
<!-- Sales Invoice --> :doc="doc" :doc-name="docName" :title="title"
<template v-if="doctype === 'Sales Invoice'"> :comments="comments" :comms="comms" :files="files"
<div class="modal-field-grid"> :dispatch-jobs="dispatchJobs"
<div class="mf"><span class="mf-label">Date</span>{{ doc.posting_date }}</div> @navigate="(...a) => $emit('navigate', ...a)"
<div class="mf"><span class="mf-label">Echeance</span>{{ doc.due_date || '---' }}</div> @reply-sent="(...a) => $emit('reply-sent', ...a)"
<div class="mf"><span class="mf-label">Statut</span><span class="ops-badge" :class="invStatusClass(doc.status)">{{ doc.status }}</span></div> @save-field="(...a) => $emit('save-field', ...a)"
<div class="mf"><span class="mf-label">Total HT</span>{{ formatMoney(doc.net_total) }}</div> @toggle-recurring="(...a) => $emit('toggle-recurring', ...a)"
<div class="mf"><span class="mf-label">Taxes</span>{{ formatMoney(doc.total_taxes_and_charges) }}</div> @dispatch-created="(...a) => $emit('dispatch-created', ...a)"
<div class="mf"><span class="mf-label">Total TTC</span><strong>{{ formatMoney(doc.grand_total) }}</strong></div> @dispatch-deleted="(...a) => $emit('dispatch-deleted', ...a)"
<div class="mf"><span class="mf-label">Solde du</span><span :class="{'text-red': doc.outstanding_amount > 0}">{{ formatMoney(doc.outstanding_amount) }}</span></div> @dispatch-updated="(...a) => $emit('dispatch-updated', ...a)"
<div class="mf"><span class="mf-label">Devise</span>{{ doc.currency || 'CAD' }}</div>
<div class="mf" v-if="doc.is_return"><span class="mf-label">Type</span><span class="text-red text-weight-medium">Note de credit</span></div>
<div class="mf" v-if="doc.return_against"><span class="mf-label">Renversement de</span><a class="text-indigo-6 cursor-pointer" @click="$emit('navigate', 'Sales Invoice', doc.return_against, 'Facture ' + doc.return_against)">{{ doc.return_against }}</a></div>
</div>
<div class="q-mt-md">
<div class="info-block-title">Remarques</div>
<InlineField :value="doc.remarks === 'No Remarks' ? '' : doc.remarks" field="remarks" doctype="Sales Invoice" :docname="docName"
type="textarea" placeholder="Ajouter des remarques..." @saved="v => doc.remarks = v.value" />
</div>
<div v-if="doc.items?.length" class="q-mt-md">
<div class="info-block-title">Articles ({{ doc.items.length }})</div>
<q-table
:rows="doc.items" :columns="invItemCols" row-key="idx"
flat dense class="ops-table" hide-pagination :pagination="{ rowsPerPage: 0 }"
>
<template #body-cell-amount="p">
<q-td :props="p" class="text-right">{{ formatMoney(p.row.amount) }}</q-td>
</template>
<template #body-cell-rate="p">
<q-td :props="p" class="text-right">{{ formatMoney(p.row.rate) }}</q-td>
</template>
</q-table>
</div>
<div v-if="doc.taxes?.length" class="q-mt-md">
<div class="info-block-title">Taxes</div>
<div v-for="t in doc.taxes" :key="t.idx" class="info-row q-py-xs">
<span>{{ t.description || t.account_head }}</span>
<q-space />
<span>{{ formatMoney(t.tax_amount) }}</span>
</div>
</div>
<div v-if="comments.length" class="q-mt-md">
<div class="info-block-title">Notes ({{ comments.length }})</div>
<div v-for="mc in comments" :key="mc.name" class="q-py-xs" style="border-bottom:1px solid var(--ops-border, #e2e8f0)">
<div class="text-caption text-grey-6">{{ mc.comment_by || 'Systeme' }} &middot; {{ formatDateShort(mc.creation) }}</div>
<div class="text-body2" style="white-space:pre-line">{{ mc.content }}</div>
</div>
</div>
</template>
<!-- Issue / Ticket -->
<template v-else-if="doctype === 'Issue'">
<div class="modal-field-grid">
<div class="mf"><span class="mf-label">Statut</span>
<InlineField :value="doc.status" field="status" doctype="Issue" :docname="docName"
type="select" :options="['Open', 'Replied', 'On Hold', 'Resolved', 'Closed']"
@saved="v => doc.status = v.value" />
</div>
<div class="mf"><span class="mf-label">Priorite</span>
<InlineField :value="doc.priority" field="priority" doctype="Issue" :docname="docName"
type="select" :options="['Low', 'Medium', 'High', 'Urgent']"
@saved="v => doc.priority = v.value" />
</div>
<div class="mf"><span class="mf-label">Ouvert le</span>{{ formatDate(doc.opening_date) }}</div>
<div class="mf" v-if="doc.resolution_time"><span class="mf-label">Resolu le</span>{{ doc.resolution_time }}</div>
<div class="mf"><span class="mf-label">Type</span>
<InlineField :value="doc.issue_type" field="issue_type" doctype="Issue" :docname="docName"
type="select" :options="['Support', 'Installation', 'Déménagement', 'Facturation', 'Réseau', 'Autre']"
placeholder="Type" @saved="v => doc.issue_type = v.value" />
</div>
<div class="mf" v-if="doc.service_location"><span class="mf-label">Adresse</span>{{ doc.service_location }}</div>
<div class="mf" v-if="doc.raised_by"><span class="mf-label">Soumis par</span>{{ doc.raised_by }}</div>
<div class="mf" v-if="doc.owner"><span class="mf-label">Proprietaire</span>{{ doc.owner }}</div>
</div>
<!-- Editable subject -->
<div class="q-mt-sm">
<div class="info-block-title">Sujet</div>
<InlineField :value="doc.subject" field="subject" doctype="Issue" :docname="docName"
placeholder="Sujet du ticket" @saved="v => doc.subject = v.value" />
</div>
<div v-if="doc.issue_split_from" class="q-mt-md">
<div class="info-block-title">Ticket parent</div>
<div class="text-body2 erp-link" style="cursor:pointer" @click="$emit('navigate', 'Issue', doc.issue_split_from)">
{{ doc.issue_split_from }}
</div>
</div>
<div v-if="doc.description" class="q-mt-md">
<div class="info-block-title">Description</div>
<div class="modal-desc" v-html="doc.description"></div>
</div>
<div v-if="doc.resolution_details" class="q-mt-md">
<div class="info-block-title">Resolution</div>
<div class="modal-desc" v-html="doc.resolution_details"></div>
</div>
<div v-if="comms.length" class="q-mt-md">
<div class="info-block-title">Echanges ({{ comms.length }})</div>
<div v-for="comm in comms" :key="comm.name" class="comm-row">
<div class="row items-start">
<div class="col">
<div class="text-caption text-weight-bold">{{ comm.sender || comm.owner }}</div>
<div class="modal-desc q-mt-xs" v-html="comm.content"></div>
</div>
<div class="col-auto text-caption text-grey-6 text-right" style="min-width:90px">
{{ formatDate(comm.creation) }}
</div>
</div>
</div>
</div>
<div v-if="files.length" class="q-mt-md">
<div class="info-block-title">Pieces jointes ({{ files.length }})</div>
<div v-for="f in files" :key="f.name" class="q-py-xs">
<a :href="erpFileUrl(f.file_url)" target="_blank" class="erp-link">
<q-icon name="attach_file" size="14px" /> {{ f.file_name }}
</a>
</div>
</div>
<div v-if="comments.length" class="q-mt-md">
<div class="info-block-title">Fil de discussion ({{ comments.length }})</div>
<div v-for="c in comments" :key="c.name" class="thread-msg">
<div class="thread-header">
<q-icon name="person" size="14px" color="grey-6" class="q-mr-xs" />
<span class="text-weight-bold">{{ c.comment_by || 'Systeme' }}</span>
<span class="text-grey-5 q-ml-auto">{{ formatDateTime(c.creation) }}</span>
</div>
<div class="thread-body" v-html="c.content"></div>
</div>
</div>
<div v-if="!doc.description && !doc.resolution_details && !comms.length && !comments.length" class="text-center text-grey-5 q-pa-lg">
Aucun contenu pour ce ticket
</div>
<!-- Reply input -->
<div class="q-mt-md reply-box">
<div class="info-block-title">Repondre</div>
<q-input v-model="replyContent" dense outlined type="textarea" autogrow
placeholder="Ecrire une reponse..."
:input-style="{ fontSize: '0.85rem', minHeight: '50px' }"
@keydown.ctrl.enter="sendReply" @keydown.meta.enter="sendReply" />
<div class="row justify-end q-mt-xs">
<q-btn unelevated dense size="sm" label="Envoyer" color="indigo-6" icon="send"
:disable="!replyContent?.trim()" :loading="sendingReply" @click="sendReply" />
</div>
</div>
</template>
<!-- Payment Entry -->
<template v-else-if="doctype === 'Payment Entry'">
<div class="modal-field-grid">
<div class="mf"><span class="mf-label">Date</span>{{ doc.posting_date }}</div>
<div class="mf"><span class="mf-label">Mode</span>{{ doc.mode_of_payment || '---' }}</div>
<div class="mf"><span class="mf-label">Montant paye</span><strong>{{ formatMoney(doc.paid_amount) }}</strong></div>
<div class="mf"><span class="mf-label">Reference</span>{{ doc.reference_no || '---' }}</div>
<div class="mf" v-if="doc.reference_date"><span class="mf-label">Date ref.</span>{{ doc.reference_date }}</div>
<div class="mf"><span class="mf-label">Compte paye de</span>{{ doc.paid_from || '---' }}</div>
<div class="mf"><span class="mf-label">Compte paye a</span>{{ doc.paid_to || '---' }}</div>
</div>
<div v-if="doc.references?.length" class="q-mt-md">
<div class="info-block-title">Factures liees</div>
<div v-for="r in doc.references" :key="r.idx" class="info-row q-py-xs" style="cursor:pointer" @click="$emit('navigate', r.reference_doctype, r.reference_name)">
<span class="erp-link">{{ r.reference_name }}</span>
<q-space />
<span>{{ formatMoney(r.allocated_amount) }}</span>
</div>
</div>
</template>
<!-- Subscription (editable) -->
<template v-else-if="doctype === 'Subscription'">
<div class="modal-field-grid">
<div class="mf"><span class="mf-label">Statut</span><span class="ops-badge" :class="subStatusClass(doc.status)">{{ doc.status }}</span></div>
<div class="mf"><span class="mf-label">SKU</span><code>{{ doc.item_code }}</code></div>
<div class="mf"><span class="mf-label">Article</span>{{ doc.item_name }}</div>
<div class="mf"><span class="mf-label">Frequence</span>{{ doc.billing_frequency === 'A' ? 'Annuel' : 'Mensuel' }}</div>
<div class="mf"><span class="mf-label">Debut</span>{{ doc.start_date || '---' }}</div>
<div class="mf"><span class="mf-label">Fin</span>{{ doc.end_date || '---' }}</div>
<div class="mf" v-if="doc.service_location"><span class="mf-label">Adresse</span>{{ doc.service_location }}</div>
<div class="mf" v-if="doc.radius_user"><span class="mf-label">PPPoE</span><code>{{ doc.radius_user }}</code></div>
<div class="mf" v-if="doc.radius_pwd"><span class="mf-label">Mot de passe</span><code>{{ doc.radius_pwd }}</code></div>
</div>
<div class="q-mt-md">
<div class="info-block-title">Modifier</div>
<div class="q-gutter-y-sm">
<q-input v-model="doc.custom_description" dense outlined
label="Description personnalisee" @change="$emit('save-field', doc, 'custom_description')" />
<q-input v-model.number="doc.actual_price" dense outlined type="number" step="0.01"
label="Prix" prefix="$" @change="$emit('save-field', doc, 'actual_price')" />
<div class="row items-center q-gutter-x-md q-mt-sm">
<q-toggle
:model-value="!Number(doc.cancel_at_period_end)"
@update:model-value="$emit('toggle-recurring', doc)"
:color="Number(doc.cancel_at_period_end) ? 'grey' : 'green'"
label="Recurrent"
/> />
<div v-if="doc.current_invoice_start" class="text-caption text-grey-6">
Prochaine facture: {{ formatDate(doc.current_invoice_start) }} --- {{ formatDate(doc.current_invoice_end) }}
</div>
</div>
</div>
</div>
<div v-if="doc.plans?.length" class="q-mt-md">
<div class="info-block-title">Plans</div>
<div v-for="p in doc.plans" :key="p.idx" class="info-row q-py-xs">
<span>{{ p.plan || p.item }}</span>
<q-space />
<span>{{ formatMoney(p.cost) }}</span>
</div>
</div>
</template>
<!-- Service Equipment --> <!-- Generic fallback -->
<template v-else-if="doctype === 'Service Equipment'">
<div class="modal-field-grid">
<div class="mf"><span class="mf-label">Type</span>
<InlineField :value="doc.equipment_type" field="equipment_type" doctype="Service Equipment" :docname="docName"
type="select" :options="['ONT', 'Router', 'Switch', 'AP', 'OLT', 'Décodeur', 'Modem', 'Autre']"
@saved="v => doc.equipment_type = v.value" />
</div>
<div class="mf"><span class="mf-label">Statut</span>
<InlineField :value="doc.status" field="status" doctype="Service Equipment" :docname="docName"
type="select" :options="['Active', 'Inactive', 'En stock', 'Défectueux', 'Retourné']"
@saved="v => doc.status = v.value" />
</div>
<div class="mf"><span class="mf-label">Marque</span>
<InlineField :value="doc.brand" field="brand" doctype="Service Equipment" :docname="docName"
placeholder="—" @saved="v => doc.brand = v.value" />
</div>
<div class="mf"><span class="mf-label">Modele</span>
<InlineField :value="doc.model" field="model" doctype="Service Equipment" :docname="docName"
placeholder="—" @saved="v => doc.model = v.value" />
</div>
<div class="mf"><span class="mf-label">N serie</span>
<InlineField :value="doc.serial_number" field="serial_number" doctype="Service Equipment" :docname="docName"
placeholder="—" @saved="v => doc.serial_number = v.value" />
</div>
<div class="mf"><span class="mf-label">MAC</span>
<InlineField :value="doc.mac_address" field="mac_address" doctype="Service Equipment" :docname="docName"
placeholder="—" @saved="v => doc.mac_address = v.value" />
</div>
<div class="mf" v-if="doc.barcode"><span class="mf-label">Code-barres</span><code>{{ doc.barcode }}</code></div>
<div class="mf"><span class="mf-label">IP</span>
<InlineField :value="doc.ip_address" field="ip_address" doctype="Service Equipment" :docname="docName"
placeholder="—" @saved="v => doc.ip_address = v.value" />
</div>
<div class="mf"><span class="mf-label">Firmware</span>
<InlineField :value="doc.firmware_version" field="firmware_version" doctype="Service Equipment" :docname="docName"
placeholder="—" @saved="v => doc.firmware_version = v.value" />
</div>
<div class="mf"><span class="mf-label">Propriete</span>
<InlineField :value="doc.ownership" field="ownership" doctype="Service Equipment" :docname="docName"
type="select" :options="['Client', 'Compagnie', 'Location']"
placeholder="—" @saved="v => doc.ownership = v.value" />
</div>
</div>
<div v-if="doc.olt_name" class="q-mt-md">
<div class="info-block-title">Information OLT</div>
<div class="modal-field-grid">
<div class="mf"><span class="mf-label">OLT</span>{{ doc.olt_name }}</div>
<div class="mf"><span class="mf-label">IP OLT</span><code>{{ doc.olt_ip }}</code></div>
<div class="mf"><span class="mf-label">Slot / Port</span>{{ doc.olt_slot }} / {{ doc.olt_port }}</div>
<div class="mf"><span class="mf-label">ONT ID</span>{{ doc.olt_ontid }}</div>
</div>
<div class="mf" v-if="doc.installation_date"><span class="mf-label">Installe le</span>{{ doc.installation_date }}</div>
<div class="mf" v-if="doc.warranty_end"><span class="mf-label">Fin garantie</span>{{ doc.warranty_end }}</div>
<div class="mf" v-if="doc.service_location"><span class="mf-label">Adresse</span>{{ doc.service_location }}</div>
<div class="mf" v-if="doc.subscription"><span class="mf-label">Abonnement</span>{{ doc.subscription }}</div>
</div>
<div class="q-mt-md">
<div class="info-block-title">Acces distant</div>
<div class="info-row q-py-xs"><span class="mf-label">Utilisateur</span>
<InlineField :value="doc.login_user" field="login_user" doctype="Service Equipment" :docname="docName"
placeholder="—" @saved="v => doc.login_user = v.value" />
</div>
</div>
<div class="q-mt-md">
<div class="info-block-title">Notes</div>
<InlineField :value="doc.notes" field="notes" doctype="Service Equipment" :docname="docName"
type="textarea" placeholder="Ajouter des notes..." @saved="v => doc.notes = v.value" />
</div>
<div v-if="doc.move_log?.length" class="q-mt-md">
<div class="info-block-title">Historique ({{ doc.move_log.length }})</div>
<div v-for="m in doc.move_log" :key="m.idx" class="info-row q-py-xs text-caption">
{{ m.date }} --- {{ m.from_location || '?' }} &rarr; {{ m.to_location || '?' }} {{ m.reason ? '(' + m.reason + ')' : '' }}
</div>
</div>
</template>
<!-- Generic fallback -->
<template v-else> <template v-else>
<div class="modal-field-grid"> <div class="modal-field-grid">
<div v-for="(val, key) in docFields" :key="key" class="mf"> <div v-for="(val, key) in docFields" :key="key" class="mf">
@ -333,11 +58,22 @@
</template> </template>
<script setup> <script setup>
import { formatDate, formatDateShort, formatMoney, erpFileUrl } from 'src/composables/useFormatters' import { computed } from 'vue'
import { invStatusClass, ticketStatusClass, priorityClass, subStatusClass, eqStatusClass } from 'src/composables/useStatusClasses'
import { erpLink } from 'src/composables/useFormatters' import { erpLink } from 'src/composables/useFormatters'
import { computed, ref } from 'vue'
import InlineField from 'src/components/shared/InlineField.vue' import InvoiceDetail from './detail-sections/InvoiceDetail.vue'
import IssueDetail from './detail-sections/IssueDetail.vue'
import PaymentDetail from './detail-sections/PaymentDetail.vue'
import SubscriptionDetail from './detail-sections/SubscriptionDetail.vue'
import EquipmentDetail from './detail-sections/EquipmentDetail.vue'
const SECTION_MAP = {
'Sales Invoice': InvoiceDetail,
'Issue': IssueDetail,
'Payment Entry': PaymentDetail,
'Subscription': SubscriptionDetail,
'Service Equipment': EquipmentDetail,
}
const props = defineProps({ const props = defineProps({
open: Boolean, open: Boolean,
@ -350,173 +86,32 @@ const props = defineProps({
comms: { type: Array, default: () => [] }, comms: { type: Array, default: () => [] },
files: { type: Array, default: () => [] }, files: { type: Array, default: () => [] },
docFields: { type: Object, default: () => ({}) }, docFields: { type: Object, default: () => ({}) },
dispatchJobs: { type: Array, default: () => [] },
}) })
const emit = defineEmits(['update:open', 'navigate', 'open-pdf', 'save-field', 'toggle-recurring', 'reply-sent']) defineEmits(['update:open', 'navigate', 'open-pdf', 'save-field', 'toggle-recurring', 'reply-sent', 'dispatch-created', 'dispatch-deleted', 'dispatch-updated'])
const erpLinkUrl = computed(() => erpLink(props.doctype, props.docName)) const erpLinkUrl = computed(() => erpLink(props.doctype, props.docName))
const sectionComponent = computed(() => SECTION_MAP[props.doctype] || null)
const invItemCols = [ function openExternal (url) { window.open(url, '_blank') }
{ name: 'item_name', label: 'Article', field: r => decodeHtml(r.item_name || r.item_code), align: 'left' },
{ name: 'qty', label: 'Qte', field: 'qty', align: 'center' },
{ name: 'rate', label: 'Prix unit.', field: 'rate', align: 'right' },
{ name: 'amount', label: 'Montant', field: 'amount', align: 'right' },
]
function decodeHtml (str) {
if (!str) return str
const el = document.createElement('textarea')
el.innerHTML = str
return el.value
}
function formatDateTime (dt) {
if (!dt) return ''
const d = new Date(dt)
if (isNaN(d)) return dt
return d.toLocaleDateString('fr-CA', { year: 'numeric', month: 'short', day: 'numeric' }) + ' ' +
d.toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit' })
}
function openExternal (url) {
window.open(url, '_blank')
}
// Reply to ticket
import { createDoc } from 'src/api/erp'
const replyContent = ref('')
const sendingReply = ref(false)
async function sendReply () {
if (!replyContent.value?.trim() || sendingReply.value) return
sendingReply.value = true
try {
await createDoc('Communication', {
communication_type: 'Communication',
communication_medium: 'Other',
sent_or_received: 'Sent',
subject: props.title || props.docName,
content: replyContent.value.trim(),
reference_doctype: 'Issue',
reference_name: props.docName,
})
replyContent.value = ''
emit('reply-sent', props.docName)
const { Notify } = await import('quasar')
Notify?.create?.({ type: 'positive', message: 'Reponse envoyee', timeout: 2000 })
} catch (e) {
console.error('Failed to send reply:', e)
const { Notify } = await import('quasar')
Notify?.create?.({ type: 'negative', message: 'Erreur: reponse non envoyee', timeout: 3000 })
} finally {
sendingReply.value = false
}
}
</script> </script>
<style scoped> <style scoped>
.erp-link { .modal-field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 2px 16px; }
color: #6366f1; .mf { display: flex; align-items: baseline; gap: 8px; padding: 6px 0; font-size: 0.875rem; border-bottom: 1px solid #f1f5f9; }
text-decoration: none; .mf-label { font-size: 0.75rem; font-weight: 600; color: #6b7280; min-width: 80px; flex-shrink: 0; }
cursor: pointer; .info-block-title { font-size: 0.75rem; font-weight: 700; color: #6b7280; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 6px; }
} .modal-desc { font-size: 0.85rem; line-height: 1.5; background: #f8fafc; border-radius: 8px; padding: 10px 12px; max-height: 300px; overflow-y: auto; }
.erp-link:hover { .info-row { display: flex; align-items: center; gap: 8px; }
text-decoration: underline; .comm-row { padding: 10px 0; border-bottom: 1px solid #e2e8f0; }
} .comm-row:last-child { border-bottom: none; }
.thread-msg { border-left: 3px solid #e2e8f0; padding: 8px 0 8px 12px; margin-bottom: 2px; }
.modal-field-grid { .thread-msg:hover { border-left-color: #6366f1; }
display: grid; .thread-header { display: flex; align-items: center; font-size: 0.78rem; color: #374151; margin-bottom: 4px; }
grid-template-columns: 1fr 1fr; .thread-body { font-size: 0.84rem; line-height: 1.5; color: #1f2937; }
gap: 2px 16px; .thread-body :deep(p) { margin: 0 0 4px; }
} .thread-body :deep(hr) { border: none; border-top: 1px solid #e5e7eb; margin: 6px 0; }
.thread-body :deep(br + br) { display: none; }
.mf { .reply-box { border-top: 1px solid #e2e8f0; padding-top: 12px; }
display: flex;
align-items: baseline;
gap: 8px;
padding: 6px 0;
font-size: 0.875rem;
border-bottom: 1px solid #f1f5f9;
}
.mf-label {
font-size: 0.75rem;
font-weight: 600;
color: #6b7280;
min-width: 80px;
flex-shrink: 0;
}
.info-block-title {
font-size: 0.75rem;
font-weight: 700;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 6px;
}
.modal-desc {
font-size: 0.85rem;
line-height: 1.5;
background: #f8fafc;
border-radius: 8px;
padding: 10px 12px;
max-height: 300px;
overflow-y: auto;
}
.info-row {
display: flex;
align-items: center;
gap: 8px;
}
.comm-row {
padding: 10px 0;
border-bottom: 1px solid #e2e8f0;
}
.comm-row:last-child {
border-bottom: none;
}
.thread-msg {
border-left: 3px solid #e2e8f0;
padding: 8px 0 8px 12px;
margin-bottom: 2px;
}
.thread-msg:hover {
border-left-color: #6366f1;
}
.thread-header {
display: flex;
align-items: center;
font-size: 0.78rem;
color: #374151;
margin-bottom: 4px;
}
.thread-body {
font-size: 0.84rem;
line-height: 1.5;
color: #1f2937;
}
.thread-body :deep(p) {
margin: 0 0 4px;
}
.thread-body :deep(hr) {
border: none;
border-top: 1px solid #e5e7eb;
margin: 6px 0;
}
.thread-body :deep(br + br) {
display: none;
}
.reply-box {
border-top: 1px solid #e2e8f0;
padding-top: 12px;
}
</style> </style>

View File

@ -0,0 +1,424 @@
<template>
<q-dialog :model-value="modelValue" @update:model-value="$emit('update:modelValue', $event)" persistent>
<q-card style="width:650px;max-width:95vw" class="column no-wrap">
<!-- Header -->
<q-card-section class="row items-center q-pb-sm" style="border-bottom:1px solid var(--ops-border, #e2e8f0)">
<div class="col">
<div class="text-subtitle1 text-weight-bold">
<q-icon name="account_tree" class="q-mr-xs" />
Projet &mdash; {{ stepLabels[currentStep] }}
</div>
<div class="text-caption text-grey-6">{{ issue?.name }} &middot; {{ issue?.subject }}</div>
</div>
<q-btn flat round dense icon="close" @click="cancel" />
</q-card-section>
<!-- Stepper indicator -->
<div class="row q-px-md q-pt-sm" style="gap:4px">
<div v-for="(label, i) in stepLabels" :key="i"
class="wizard-step-dot" :class="{ active: i === currentStep, done: i < currentStep }"
@click="i < currentStep && (currentStep = i)">
<div class="wizard-step-num">{{ i < currentStep ? '✓' : i + 1 }}</div>
<div class="wizard-step-label">{{ label }}</div>
</div>
</div>
<!-- Step 1: Choose template -->
<q-card-section v-if="currentStep === 0" class="col q-pt-md" style="overflow-y:auto;min-height:300px">
<div class="text-caption text-grey-6 q-mb-sm">Choisissez un modèle de projet ou créez un projet vide</div>
<div class="template-grid">
<div v-for="tpl in templates" :key="tpl.id" class="template-card"
:class="{ selected: selectedTemplate?.id === tpl.id }" @click="selectTemplate(tpl)">
<q-icon :name="tpl.icon" size="28px" :color="selectedTemplate?.id === tpl.id ? 'indigo-6' : 'grey-6'" />
<div class="template-card-name">{{ tpl.name }}</div>
<div class="template-card-desc">{{ tpl.description }}</div>
<q-badge :label="tpl.steps.length + ' étapes'" color="grey-3" text-color="grey-8" class="q-mt-xs" />
</div>
<div class="template-card" :class="{ selected: selectedTemplate === null && customSteps.length }"
@click="selectCustom">
<q-icon name="add_circle_outline" size="28px" color="grey-6" />
<div class="template-card-name">Projet vide</div>
<div class="template-card-desc">Créer les étapes manuellement</div>
</div>
</div>
</q-card-section>
<!-- Step 2: Edit steps -->
<q-card-section v-if="currentStep === 1" class="col q-pt-md" style="overflow-y:auto;min-height:300px;max-height:60vh">
<div class="text-caption text-grey-6 q-mb-sm">Modifiez les étapes, dates et assignations</div>
<div class="steps-list">
<div v-for="(step, i) in wizardSteps" :key="i" class="step-card">
<div class="row items-center q-mb-xs">
<div class="step-num">{{ i + 1 }}</div>
<q-input v-model="step.subject" dense outlined class="col q-ml-sm" placeholder="Sujet de l'étape"
:input-style="{ fontSize: '0.85rem', fontWeight: '600' }" />
<q-btn flat round dense icon="delete" size="sm" color="red-5" class="q-ml-xs"
@click="removeStep(i)" v-if="wizardSteps.length > 1" />
</div>
<div class="row q-col-gutter-sm q-mb-xs">
<div class="col-4">
<q-select v-model="step.job_type" dense outlined emit-value map-options label="Type"
:options="jobTypeOptions" :input-style="{ fontSize: '0.8rem' }" />
</div>
<div class="col-4">
<q-select v-model="step.priority" dense outlined emit-value map-options label="Priorité"
:options="priorityOptions" :input-style="{ fontSize: '0.8rem' }" />
</div>
<div class="col-4">
<q-input v-model.number="step.duration_h" dense outlined type="number" label="Durée (h)"
:input-style="{ fontSize: '0.8rem' }" />
</div>
</div>
<div class="row q-col-gutter-sm q-mb-xs">
<div class="col-4">
<q-select v-model="step.assigned_group" dense outlined emit-value map-options label="Groupe"
:options="groupOptions" clearable :input-style="{ fontSize: '0.8rem' }" />
</div>
<div class="col-4">
<q-input v-model="step.scheduled_date" dense outlined type="date" label="Date"
:input-style="{ fontSize: '0.8rem' }" />
</div>
<div class="col-4">
<q-select v-model="step.depends_on_step" dense outlined emit-value map-options label="Après"
:options="dependencyOptionsFor(i)" clearable :input-style="{ fontSize: '0.8rem' }" />
</div>
</div>
<!-- n8n webhooks (collapsed) -->
<q-expansion-item dense dense-toggle label="Webhooks n8n" icon="webhook"
header-class="text-caption text-grey-6" style="margin:-4px -4px 0">
<div class="row q-col-gutter-sm q-pa-sm">
<div class="col-6">
<q-input v-model="step.on_open_webhook" dense outlined label="À l'ouverture"
placeholder="https://n8n.../webhook/..." :input-style="{ fontSize: '0.75rem' }" />
</div>
<div class="col-6">
<q-input v-model="step.on_close_webhook" dense outlined label="À la fermeture"
placeholder="https://n8n.../webhook/..." :input-style="{ fontSize: '0.75rem' }" />
</div>
</div>
</q-expansion-item>
<!-- Dependency arrow -->
<div v-if="step.depends_on_step !== null && step.depends_on_step !== undefined" class="step-dep-indicator">
<q-icon name="subdirectory_arrow_right" size="14px" color="orange-7" />
<span class="text-caption text-orange-8">Après: étape {{ step.depends_on_step + 1 }}</span>
</div>
</div>
</div>
<q-btn flat dense icon="add" label="Ajouter une étape" color="indigo-6" no-caps class="q-mt-sm"
@click="addStep" />
</q-card-section>
<!-- Step 3: Review & publish -->
<q-card-section v-if="currentStep === 2" class="col q-pt-md" style="overflow-y:auto;min-height:300px;max-height:60vh">
<div class="text-caption text-grey-6 q-mb-md">Vérifiez le projet avant publication</div>
<div class="review-tree">
<div v-for="(step, i) in wizardSteps" :key="i" class="review-step">
<div class="review-step-connector" v-if="i > 0">
<q-icon name="arrow_downward" size="16px" color="grey-4" />
</div>
<div class="review-step-card" :class="{ 'has-dep': step.depends_on_step !== null && step.depends_on_step !== undefined }">
<div class="row items-center no-wrap q-gutter-x-sm">
<div class="step-num small">{{ i + 1 }}</div>
<div class="col" style="min-width:0">
<div class="text-weight-bold" style="font-size:0.85rem">{{ step.subject }}</div>
<div class="text-caption text-grey-6 row items-center q-gutter-x-sm">
<span v-if="step.job_type">{{ step.job_type }}</span>
<span v-if="step.assigned_group"><q-icon name="group" size="12px" /> {{ step.assigned_group }}</span>
<span v-if="step.scheduled_date"><q-icon name="event" size="12px" /> {{ step.scheduled_date }}</span>
<span>{{ step.duration_h }}h</span>
<span v-if="step.on_open_webhook || step.on_close_webhook" class="text-blue-6">
<q-icon name="webhook" size="12px" /> n8n
</span>
</div>
</div>
<span class="ops-badge" :class="step.priority === 'high' ? 'open' : step.priority === 'low' ? 'closed' : 'draft'"
style="font-size:10px;padding:1px 6px">{{ step.priority }}</span>
</div>
</div>
</div>
</div>
<div class="q-mt-md ops-card" style="padding:10px 12px;background:#f0fdf4;border-color:#bbf7d0">
<div class="row items-center q-gutter-x-sm">
<q-icon name="check_circle" color="green-6" size="20px" />
<div>
<div class="text-weight-bold text-green-8" style="font-size:0.85rem">Prêt à publier</div>
<div class="text-caption text-green-7">
{{ wizardSteps.length }} tâches seront créées et liées au ticket {{ issue?.name }}
</div>
</div>
</div>
</div>
</q-card-section>
<!-- Actions -->
<q-card-actions align="right" class="q-px-md q-pb-md" style="border-top:1px solid var(--ops-border, #e2e8f0)">
<q-btn flat label="Annuler" color="grey-7" @click="cancel" />
<q-btn v-if="currentStep > 0" flat label="Précédent" color="grey-7" icon="arrow_back"
@click="currentStep--" />
<q-btn v-if="currentStep < 2" unelevated label="Suivant" color="indigo-6" icon-right="arrow_forward"
:disable="!canProceed" @click="currentStep++" />
<q-btn v-if="currentStep === 2" unelevated label="Publier le projet" color="green-7" icon="rocket_launch"
:loading="publishing" @click="publish" />
</q-card-actions>
</q-card>
</q-dialog>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { Notify } from 'quasar'
import { createJob } from 'src/api/dispatch'
import { getDoc } from 'src/api/erp'
import { PROJECT_TEMPLATES, ASSIGNED_GROUPS } from 'src/config/project-templates'
const props = defineProps({
modelValue: Boolean,
issue: { type: Object, default: () => ({}) },
existingJobs: { type: Array, default: () => [] },
})
const emit = defineEmits(['update:modelValue', 'created'])
const templates = PROJECT_TEMPLATES
const stepLabels = ['Modèle', 'Étapes', 'Publier']
const currentStep = ref(0)
const selectedTemplate = ref(null)
const wizardSteps = ref([])
const customSteps = ref([])
const publishing = ref(false)
const jobTypeOptions = [
{ label: 'Installation', value: 'Installation' },
{ label: 'Réparation', value: 'Réparation' },
{ label: 'Maintenance', value: 'Maintenance' },
{ label: 'Retrait', value: 'Retrait' },
{ label: 'Dépannage', value: 'Dépannage' },
{ label: 'Autre', value: 'Autre' },
]
const priorityOptions = [
{ label: 'Basse', value: 'low' },
{ label: 'Moyenne', value: 'medium' },
{ label: 'Haute', value: 'high' },
]
const groupOptions = ASSIGNED_GROUPS.map(g => ({ label: g, value: g }))
function dependencyOptionsFor (stepIndex) {
return wizardSteps.value
.map((s, i) => ({ label: `${i + 1}. ${s.subject?.substring(0, 35) || 'Étape'}`, value: i }))
.filter((_, i) => i !== stepIndex)
}
const canProceed = computed(() => {
if (currentStep.value === 0) return wizardSteps.value.length > 0
if (currentStep.value === 1) return wizardSteps.value.every(s => s.subject?.trim())
return true
})
function selectTemplate (tpl) {
selectedTemplate.value = tpl
wizardSteps.value = tpl.steps.map(s => ({
subject: s.subject,
job_type: s.job_type,
priority: s.priority,
duration_h: s.duration_h,
assigned_group: s.assigned_group || '',
depends_on_step: s.depends_on_step,
scheduled_date: '',
on_open_webhook: s.on_open_webhook || '',
on_close_webhook: s.on_close_webhook || '',
}))
}
function selectCustom () {
selectedTemplate.value = null
if (!customSteps.value.length) {
customSteps.value = [makeEmptyStep()]
}
wizardSteps.value = customSteps.value
}
function makeEmptyStep () {
return {
subject: '',
job_type: 'Autre',
priority: 'medium',
duration_h: 1,
assigned_group: '',
depends_on_step: null,
scheduled_date: '',
on_open_webhook: '',
on_close_webhook: '',
}
}
function addStep () {
wizardSteps.value.push(makeEmptyStep())
}
function removeStep (i) {
// Fix dependencies pointing to this step or after it
wizardSteps.value.forEach(s => {
if (s.depends_on_step === i) s.depends_on_step = null
else if (s.depends_on_step !== null && s.depends_on_step > i) s.depends_on_step--
})
wizardSteps.value.splice(i, 1)
}
function cancel () {
currentStep.value = 0
selectedTemplate.value = null
wizardSteps.value = []
customSteps.value = []
emit('update:modelValue', false)
}
// Reset when dialog opens
watch(() => props.modelValue, (v) => {
if (v) {
currentStep.value = 0
selectedTemplate.value = null
wizardSteps.value = []
customSteps.value = []
}
})
// Resolve address from issue
async function resolveAddress () {
try {
if (props.issue?.service_location) {
const loc = await getDoc('Service Location', props.issue.service_location)
return [loc.address_line, loc.city, loc.postal_code].filter(Boolean).join(', ')
}
} catch {}
return ''
}
async function publish () {
if (publishing.value) return
publishing.value = true
try {
const address = await resolveAddress()
const createdJobs = []
// Create jobs sequentially to resolve dependency names
for (let i = 0; i < wizardSteps.value.length; i++) {
const step = wizardSteps.value[i]
const ticketId = 'DJ-' + Date.now().toString(36).toUpperCase() + '-' + i
// Resolve depends_on to actual job name
let dependsOn = ''
if (step.depends_on_step !== null && step.depends_on_step !== undefined) {
const depJob = createdJobs[step.depends_on_step]
if (depJob) dependsOn = depJob.name
}
// Resolve parent_job: first job is root, rest are children of root
const parentJob = i > 0 && createdJobs[0] ? createdJobs[0].name : ''
const payload = {
ticket_id: ticketId,
subject: step.subject,
address,
duration_h: step.duration_h || 1,
priority: step.priority || 'medium',
status: 'open',
job_type: step.job_type || 'Autre',
source_issue: props.issue?.name || '',
customer: props.issue?.customer || '',
service_location: props.issue?.service_location || '',
depends_on: dependsOn,
parent_job: parentJob,
step_order: i + 1,
on_open_webhook: step.on_open_webhook || '',
on_close_webhook: step.on_close_webhook || '',
notes: step.assigned_group ? `Groupe: ${step.assigned_group}` : '',
scheduled_date: step.scheduled_date || '',
}
const newJob = await createJob(payload)
createdJobs.push(newJob)
}
Notify.create({
type: 'positive',
message: `Projet créé: ${createdJobs.length} tâches liées au ticket`,
timeout: 4000,
})
// Emit all created jobs
for (const job of createdJobs) {
emit('created', job)
}
cancel()
} catch (err) {
console.error('[ProjectWizard] publish error:', err)
Notify.create({ type: 'negative', message: `Erreur: ${err.message || err}` })
} finally {
publishing.value = false
}
}
</script>
<style scoped>
.wizard-step-dot {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 8px 4px;
cursor: default;
opacity: 0.4;
transition: opacity 0.2s;
}
.wizard-step-dot.active, .wizard-step-dot.done { opacity: 1; }
.wizard-step-dot.done { cursor: pointer; }
.wizard-step-num {
width: 24px; height: 24px; border-radius: 50%;
background: #e2e8f0; color: #475569;
display: flex; align-items: center; justify-content: center;
font-size: 0.75rem; font-weight: 700;
}
.wizard-step-dot.active .wizard-step-num { background: #6366f1; color: #fff; }
.wizard-step-dot.done .wizard-step-num { background: #10b981; color: #fff; }
.wizard-step-label { font-size: 0.7rem; color: #6b7280; margin-top: 2px; }
.template-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 10px; }
.template-card {
border: 2px solid #e2e8f0; border-radius: 10px; padding: 14px;
cursor: pointer; transition: all 0.15s; text-align: center;
}
.template-card:hover { border-color: #6366f1; background: #f5f3ff; }
.template-card.selected { border-color: #6366f1; background: #eef2ff; }
.template-card-name { font-weight: 700; font-size: 0.85rem; margin-top: 6px; color: #1e293b; }
.template-card-desc { font-size: 0.75rem; color: #6b7280; margin-top: 2px; }
.steps-list { display: flex; flex-direction: column; gap: 10px; }
.step-card {
border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px 12px;
background: #fff; transition: border-color 0.15s;
}
.step-card:hover { border-color: #6366f1; }
.step-num {
width: 26px; height: 26px; border-radius: 50%;
background: #6366f1; color: #fff; font-weight: 700; font-size: 0.8rem;
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.step-num.small { width: 22px; height: 22px; font-size: 0.7rem; }
.step-dep-indicator {
display: flex; align-items: center; gap: 4px;
margin-top: 4px; padding: 2px 6px;
background: #fff7ed; border-radius: 4px;
}
.review-tree { display: flex; flex-direction: column; }
.review-step-connector { text-align: center; padding: 0; line-height: 1; }
.review-step-card {
border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px 12px; background: #fff;
}
.review-step-card.has-dep { border-left: 3px solid #f59e0b; }
</style>

View File

@ -0,0 +1,443 @@
<template>
<div class="task-node" :style="{ marginLeft: depth * 20 + 'px' }">
<div class="task-row" :class="['task-' + (job.status || 'open'), { 'task-expanded': isExpanded }]"
@click="isExpanded = !isExpanded">
<div class="row items-center no-wrap q-gutter-x-sm">
<!-- Expand/collapse for nodes with children -->
<q-btn v-if="children.length" flat round dense size="xs"
:icon="treeExpanded ? 'expand_more' : 'chevron_right'" color="grey-6"
@click.stop="treeExpanded = !treeExpanded" style="margin:-4px" />
<div v-else style="width:24px" />
<q-icon :name="statusIcon" :color="statusColor" size="18px" />
<div class="col" style="min-width:0">
<div class="row items-center no-wrap q-gutter-x-xs">
<code class="task-id">{{ job.name }}</code>
<span v-if="job.step_order" class="task-step-badge">{{ job.step_order }}</span>
<span class="text-weight-medium ellipsis-text" style="font-size:0.85rem">{{ job.subject }}</span>
</div>
<div class="text-caption text-grey-6 row items-center q-gutter-x-sm">
<span v-if="job.job_type">{{ job.job_type }}</span>
<span v-if="job.assigned_tech" class="text-indigo-6"><q-icon name="person" size="12px" /> {{ job.assigned_tech }}</span>
<span v-if="job.scheduled_date"><q-icon name="event" size="12px" /> {{ job.scheduled_date }}</span>
<span v-if="job.depends_on" class="text-orange-8">
<q-icon name="link" size="12px" /> après {{ job.depends_on }}
</span>
<span v-if="job.on_open_webhook || job.on_close_webhook" class="text-blue-6">
<q-icon name="webhook" size="12px" /> n8n
</span>
<span v-if="children.length" class="text-grey-5">
<q-icon name="account_tree" size="12px" /> {{ children.length }}
</span>
<!-- Tag chips inline -->
<span v-for="tag in jobTags" :key="tag" class="task-tag-chip">{{ tag }}</span>
</div>
</div>
<span class="ops-badge" :class="badgeClass" style="font-size:10px;padding:1px 6px;flex-shrink:0">
{{ job.status || 'open' }}
</span>
<q-icon name="expand_more" size="16px" color="grey-4"
:style="{ transform: isExpanded ? 'rotate(180deg)' : 'rotate(0)', transition: 'transform 0.2s' }" />
</div>
</div>
<!-- Expanded edit panel -->
<div v-if="isExpanded" class="task-edit-panel" @click.stop>
<div class="row q-col-gutter-sm q-mb-sm">
<div class="col-12">
<q-input v-model="editSubject" dense outlined label="Sujet"
:input-style="{ fontSize: '0.8rem' }" @blur="saveField('subject', editSubject)"
@keydown.enter="saveField('subject', editSubject)" />
</div>
</div>
<div class="row q-col-gutter-sm q-mb-sm">
<div class="col-4">
<q-select v-model="editStatus" dense outlined emit-value map-options label="Statut"
:options="statusOptions" @update:model-value="saveField('status', $event)" />
</div>
<div class="col-4">
<q-select v-model="editPriority" dense outlined emit-value map-options label="Priorité"
:options="priorityOptions" @update:model-value="saveField('priority', $event)" />
</div>
<div class="col-4">
<q-select v-model="editJobType" dense outlined emit-value map-options label="Type"
:options="jobTypeOptions" @update:model-value="saveField('job_type', $event)" />
</div>
</div>
<div class="row q-col-gutter-sm q-mb-sm">
<div class="col-6">
<q-select v-model="editTech" dense outlined emit-value map-options label="Technicien"
:options="techOptions" clearable @update:model-value="assignTech"
:loading="loadingTechs" @focus="loadTechsOnce">
<template #prepend><q-icon name="person" size="16px" /></template>
</q-select>
</div>
<div class="col-3">
<q-input v-model="editDate" dense outlined type="date" label="Date"
@update:model-value="saveField('scheduled_date', $event || '')" />
</div>
<div class="col-3">
<q-input v-model.number="editDuration" dense outlined type="number" label="Durée (h)"
@blur="saveField('duration_h', editDuration)" />
</div>
</div>
<!-- Tags row -->
<div class="q-mb-sm">
<div class="text-caption text-grey-6 q-mb-xs" style="font-weight:600">
<q-icon name="sell" size="14px" /> Tags (pour dispatch timeline)
</div>
<div class="task-tags-row">
<q-chip v-for="tag in jobTags" :key="tag" removable dense size="sm"
color="indigo-1" text-color="indigo-8" icon="sell"
@remove="removeTag(tag)">
{{ tag }}
</q-chip>
<q-select v-model="newTag" dense outlined emit-value map-options
:options="availableTagOptions" label="+ Tag" style="min-width:140px;max-width:200px"
clearable @update:model-value="addTag" @focus="loadTagsOnce"
:loading="loadingTags" :input-style="{ fontSize: '0.75rem' }" />
</div>
</div>
<!-- Action buttons -->
<div class="row items-center q-gutter-x-sm">
<q-btn flat dense size="sm" icon="open_in_new" label="Dispatch" color="indigo-6" no-caps
@click="goToDispatch">
<q-tooltip>Ouvrir dans le tableau dispatch</q-tooltip>
</q-btn>
<q-space />
<q-btn flat dense size="sm" icon="delete" label="Supprimer" color="red-5" no-caps
@click="confirmDelete">
<q-tooltip>Supprimer cette tâche</q-tooltip>
</q-btn>
</div>
</div>
<!-- Children (recursive) -->
<template v-if="treeExpanded && children.length">
<TaskNode
v-for="child in children" :key="child.name"
:job="child" :all-jobs="allJobs" :depth="depth + 1"
@fire-webhook="(url, j) => $emit('fire-webhook', url, j)"
@deleted="(name) => $emit('deleted', name)"
@updated="(name, data) => $emit('updated', name, data)"
/>
</template>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { Notify, Dialog } from 'quasar'
import { updateJob } from 'src/api/dispatch'
import { deleteDoc, listDocs } from 'src/api/erp'
import { fetchTags, fetchTechnicians } from 'src/api/dispatch'
import { useRouter } from 'vue-router'
const props = defineProps({
job: { type: Object, required: true },
allJobs: { type: Array, default: () => [] },
depth: { type: Number, default: 0 },
})
const emit = defineEmits(['fire-webhook', 'deleted', 'updated'])
const router = useRouter()
const isExpanded = ref(false)
const treeExpanded = ref(true)
// Edit state (synced from job prop)
const editSubject = ref(props.job.subject || '')
const editStatus = ref(props.job.status || 'open')
const editPriority = ref(props.job.priority || 'medium')
const editJobType = ref(props.job.job_type || 'Autre')
const editTech = ref(props.job.assigned_tech || null)
const editDate = ref(props.job.scheduled_date || '')
const editDuration = ref(props.job.duration_h || 1)
// Sync when job prop changes
watch(() => props.job, (j) => {
editSubject.value = j.subject || ''
editStatus.value = j.status || 'open'
editPriority.value = j.priority || 'medium'
editJobType.value = j.job_type || 'Autre'
editTech.value = j.assigned_tech || null
editDate.value = j.scheduled_date || ''
editDuration.value = j.duration_h || 1
}, { deep: true })
// Options
const statusOptions = [
{ label: 'Ouvert', value: 'open' },
{ label: 'Assigné', value: 'assigned' },
{ label: 'En route', value: 'in-route' },
{ label: 'En cours', value: 'in-progress' },
{ label: 'Complété', value: 'completed' },
{ label: 'Annulé', value: 'cancelled' },
]
const priorityOptions = [
{ label: 'Basse', value: 'low' },
{ label: 'Moyenne', value: 'medium' },
{ label: 'Haute', value: 'high' },
]
const jobTypeOptions = [
{ label: 'Installation', value: 'Installation' },
{ label: 'Réparation', value: 'Réparation' },
{ label: 'Maintenance', value: 'Maintenance' },
{ label: 'Retrait', value: 'Retrait' },
{ label: 'Dépannage', value: 'Dépannage' },
{ label: 'Autre', value: 'Autre' },
]
// Tech loading (lazy)
const techOptions = ref([])
const loadingTechs = ref(false)
let techsLoaded = false
async function loadTechsOnce () {
if (techsLoaded) return
loadingTechs.value = true
try {
const techs = await fetchTechnicians()
techOptions.value = techs.map(t => ({
label: t.full_name || t.name,
value: t.technician_id || t.name,
}))
techsLoaded = true
} catch { /* best effort */ }
loadingTechs.value = false
}
// Tags (lazy load, light-themed)
const allTagsList = ref([])
const loadingTags = ref(false)
let tagsLoaded = false
const newTag = ref(null)
const jobTags = computed(() => {
// Parse tags from the job they might be in job.tags child table
if (Array.isArray(props.job.tags)) {
return props.job.tags.map(t => typeof t === 'string' ? t : t.tag).filter(Boolean)
}
return []
})
const availableTagOptions = computed(() => {
const current = new Set(jobTags.value)
return allTagsList.value
.filter(t => !current.has(t.label) && !current.has(t.name))
.map(t => ({ label: t.label || t.name, value: t.label || t.name }))
})
async function loadTagsOnce () {
if (tagsLoaded) return
loadingTags.value = true
try {
allTagsList.value = await fetchTags()
tagsLoaded = true
} catch { /* best effort */ }
loadingTags.value = false
}
function addTag (tagLabel) {
if (!tagLabel) return
const currentTags = jobTags.value.map(t => ({ tag: t }))
currentTags.push({ tag: tagLabel })
newTag.value = null
updateJob(props.job.name, { tags: currentTags }).then(() => {
// Update local state
if (!props.job.tags) props.job.tags = []
props.job.tags.push({ tag: tagLabel })
emit('updated', props.job.name, { tags: props.job.tags })
}).catch(() => Notify.create({ type: 'negative', message: 'Erreur ajout tag' }))
}
function removeTag (tagLabel) {
const newTags = jobTags.value.filter(t => t !== tagLabel).map(t => ({ tag: t }))
updateJob(props.job.name, { tags: newTags }).then(() => {
props.job.tags = newTags
emit('updated', props.job.name, { tags: newTags })
}).catch(() => Notify.create({ type: 'negative', message: 'Erreur suppression tag' }))
}
// Save field
async function saveField (field, value) {
try {
const payload = { [field]: value }
// If status changes, also fire webhook
if (field === 'status') {
const prev = props.job.status
props.job.status = value
if (value === 'completed' && props.job.on_close_webhook) {
emit('fire-webhook', props.job.on_close_webhook, props.job)
} else if (value === 'assigned' && prev === 'open' && props.job.on_open_webhook) {
emit('fire-webhook', props.job.on_open_webhook, props.job)
}
}
await updateJob(props.job.name, payload)
// Update local
props.job[field] = value
emit('updated', props.job.name, payload)
} catch (err) {
Notify.create({ type: 'negative', message: `Erreur: ${err.message || err}` })
}
}
// Assign tech
async function assignTech (techId) {
try {
const payload = { assigned_tech: techId || '' }
if (techId && editStatus.value === 'open') {
payload.status = 'assigned'
editStatus.value = 'assigned'
} else if (!techId && editStatus.value === 'assigned') {
payload.status = 'open'
editStatus.value = 'open'
}
await updateJob(props.job.name, payload)
props.job.assigned_tech = techId || null
if (payload.status) props.job.status = payload.status
emit('updated', props.job.name, payload)
if (techId) {
Notify.create({ type: 'info', message: `Assigné à ${techId}`, timeout: 2000 })
}
} catch (err) {
Notify.create({ type: 'negative', message: `Erreur: ${err.message || err}` })
}
}
// Delete
function confirmDelete () {
Dialog.create({
title: 'Supprimer cette tâche ?',
message: `${props.job.name}: ${props.job.subject}`,
cancel: { flat: true, label: 'Annuler' },
ok: { color: 'red', label: 'Supprimer', unelevated: true },
persistent: true,
}).onOk(async () => {
try {
await deleteDoc('Dispatch Job', props.job.name)
Notify.create({ type: 'positive', message: 'Tâche supprimée', timeout: 2000 })
emit('deleted', props.job.name)
} catch (err) {
Notify.create({ type: 'negative', message: `Erreur: ${err.message || err}` })
}
})
}
// Navigate to dispatch
function goToDispatch () {
router.push('/dispatch')
}
// Visual computed
const children = computed(() =>
props.allJobs
.filter(j => j.parent_job === props.job.name)
.sort((a, b) => (a.step_order || 0) - (b.step_order || 0))
)
const statusIcon = computed(() => {
const s = (props.job.status || '').toLowerCase()
if (s === 'completed') return 'check_circle'
if (s === 'assigned') return 'person'
if (s === 'in progress' || s === 'in-progress') return 'play_circle'
if (s === 'in-route') return 'directions_car'
if (s === 'cancelled') return 'cancel'
return 'radio_button_unchecked'
})
const statusColor = computed(() => {
const s = (props.job.status || '').toLowerCase()
if (s === 'completed') return 'green-6'
if (s === 'assigned') return 'blue-6'
if (s === 'in progress' || s === 'in-progress') return 'orange-6'
if (s === 'in-route') return 'amber-8'
if (s === 'cancelled') return 'grey-5'
return 'grey-5'
})
const badgeClass = computed(() => {
const s = (props.job.status || '').toLowerCase()
if (s === 'completed') return 'active'
if (s === 'assigned') return 'draft'
if (s === 'in progress' || s === 'in-progress') return 'open'
if (s === 'cancelled') return 'inactive'
return 'closed'
})
</script>
<style scoped>
.task-node { transition: margin 0.15s; }
.task-row {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 6px 10px;
transition: all 0.15s;
cursor: pointer;
}
.task-row:hover { border-color: #6366f1; background: #fafafe; }
.task-row.task-expanded { border-color: #6366f1; border-bottom-left-radius: 0; border-bottom-right-radius: 0; }
.task-row.task-completed { border-left: 3px solid #10b981; }
.task-row.task-assigned { border-left: 3px solid #6366f1; }
.task-row.task-open { border-left: 3px solid #94a3b8; }
.task-row.task-cancelled { border-left: 3px solid #ef4444; opacity: 0.6; }
.task-row.task-in-progress { border-left: 3px solid #f59e0b; }
.task-row.task-in-route { border-left: 3px solid #f97316; }
.task-edit-panel {
background: #f8fafc;
border: 1px solid #6366f1;
border-top: none;
border-radius: 0 0 6px 6px;
padding: 10px 12px;
animation: slideDown 0.15s ease;
}
@keyframes slideDown {
from { opacity: 0; max-height: 0; }
to { opacity: 1; max-height: 500px; }
}
.task-id {
font-size: 0.65rem;
color: #6366f1;
background: #eef2ff;
padding: 1px 4px;
border-radius: 3px;
white-space: nowrap;
}
.task-step-badge {
font-size: 0.6rem;
font-weight: 700;
color: #fff;
background: #6366f1;
width: 16px; height: 16px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.task-tag-chip {
font-size: 0.6rem;
font-weight: 600;
color: #4338ca;
background: #e0e7ff;
padding: 0 5px;
border-radius: 8px;
white-space: nowrap;
}
.task-tags-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
}
.ellipsis-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@ -0,0 +1,84 @@
<template>
<div class="modal-field-grid">
<div class="mf"><span class="mf-label">Type</span>
<InlineField :value="doc.equipment_type" field="equipment_type" doctype="Service Equipment" :docname="docName"
type="select" :options="['ONT', 'Router', 'Switch', 'AP', 'OLT', 'Décodeur', 'Modem', 'Autre']"
@saved="v => doc.equipment_type = v.value" />
</div>
<div class="mf"><span class="mf-label">Statut</span>
<InlineField :value="doc.status" field="status" doctype="Service Equipment" :docname="docName"
type="select" :options="['Active', 'Inactive', 'En stock', 'Défectueux', 'Retourné']"
@saved="v => doc.status = v.value" />
</div>
<div class="mf"><span class="mf-label">Marque</span>
<InlineField :value="doc.brand" field="brand" doctype="Service Equipment" :docname="docName"
placeholder="—" @saved="v => doc.brand = v.value" />
</div>
<div class="mf"><span class="mf-label">Modele</span>
<InlineField :value="doc.model" field="model" doctype="Service Equipment" :docname="docName"
placeholder="—" @saved="v => doc.model = v.value" />
</div>
<div class="mf"><span class="mf-label">N serie</span>
<InlineField :value="doc.serial_number" field="serial_number" doctype="Service Equipment" :docname="docName"
placeholder="—" @saved="v => doc.serial_number = v.value" />
</div>
<div class="mf"><span class="mf-label">MAC</span>
<InlineField :value="doc.mac_address" field="mac_address" doctype="Service Equipment" :docname="docName"
placeholder="—" @saved="v => doc.mac_address = v.value" />
</div>
<div class="mf" v-if="doc.barcode"><span class="mf-label">Code-barres</span><code>{{ doc.barcode }}</code></div>
<div class="mf"><span class="mf-label">IP</span>
<InlineField :value="doc.ip_address" field="ip_address" doctype="Service Equipment" :docname="docName"
placeholder="—" @saved="v => doc.ip_address = v.value" />
</div>
<div class="mf"><span class="mf-label">Firmware</span>
<InlineField :value="doc.firmware_version" field="firmware_version" doctype="Service Equipment" :docname="docName"
placeholder="—" @saved="v => doc.firmware_version = v.value" />
</div>
<div class="mf"><span class="mf-label">Propriete</span>
<InlineField :value="doc.ownership" field="ownership" doctype="Service Equipment" :docname="docName"
type="select" :options="['Client', 'Compagnie', 'Location']"
placeholder="—" @saved="v => doc.ownership = v.value" />
</div>
</div>
<div v-if="doc.olt_name" class="q-mt-md">
<div class="info-block-title">Information OLT</div>
<div class="modal-field-grid">
<div class="mf"><span class="mf-label">OLT</span>{{ doc.olt_name }}</div>
<div class="mf"><span class="mf-label">IP OLT</span><code>{{ doc.olt_ip }}</code></div>
<div class="mf"><span class="mf-label">Slot / Port</span>{{ doc.olt_slot }} / {{ doc.olt_port }}</div>
<div class="mf"><span class="mf-label">ONT ID</span>{{ doc.olt_ontid }}</div>
</div>
<div class="mf" v-if="doc.installation_date"><span class="mf-label">Installe le</span>{{ doc.installation_date }}</div>
<div class="mf" v-if="doc.warranty_end"><span class="mf-label">Fin garantie</span>{{ doc.warranty_end }}</div>
<div class="mf" v-if="doc.service_location"><span class="mf-label">Adresse</span>{{ doc.service_location }}</div>
<div class="mf" v-if="doc.subscription"><span class="mf-label">Abonnement</span>{{ doc.subscription }}</div>
</div>
<div class="q-mt-md">
<div class="info-block-title">Acces distant</div>
<div class="info-row q-py-xs"><span class="mf-label">Utilisateur</span>
<InlineField :value="doc.login_user" field="login_user" doctype="Service Equipment" :docname="docName"
placeholder="—" @saved="v => doc.login_user = v.value" />
</div>
</div>
<div class="q-mt-md">
<div class="info-block-title">Notes</div>
<InlineField :value="doc.notes" field="notes" doctype="Service Equipment" :docname="docName"
type="textarea" placeholder="Ajouter des notes..." @saved="v => doc.notes = v.value" />
</div>
<div v-if="doc.move_log?.length" class="q-mt-md">
<div class="info-block-title">Historique ({{ doc.move_log.length }})</div>
<div v-for="m in doc.move_log" :key="m.idx" class="info-row q-py-xs text-caption">
{{ m.date }} --- {{ m.from_location || '?' }} &rarr; {{ m.to_location || '?' }} {{ m.reason ? '(' + m.reason + ')' : '' }}
</div>
</div>
</template>
<script setup>
import InlineField from 'src/components/shared/InlineField.vue'
defineProps({
doc: { type: Object, required: true },
docName: String,
})
</script>

View File

@ -0,0 +1,62 @@
<template>
<div class="modal-field-grid">
<div class="mf"><span class="mf-label">Date</span>{{ doc.posting_date }}</div>
<div class="mf"><span class="mf-label">Echeance</span>{{ doc.due_date || '---' }}</div>
<div class="mf"><span class="mf-label">Statut</span><span class="ops-badge" :class="invStatusClass(doc.status)">{{ doc.status }}</span></div>
<div class="mf"><span class="mf-label">Total HT</span>{{ formatMoney(doc.net_total) }}</div>
<div class="mf"><span class="mf-label">Taxes</span>{{ formatMoney(doc.total_taxes_and_charges) }}</div>
<div class="mf"><span class="mf-label">Total TTC</span><strong>{{ formatMoney(doc.grand_total) }}</strong></div>
<div class="mf"><span class="mf-label">Solde du</span><span :class="{'text-red': doc.outstanding_amount > 0}">{{ formatMoney(doc.outstanding_amount) }}</span></div>
<div class="mf"><span class="mf-label">Devise</span>{{ doc.currency || 'CAD' }}</div>
<div class="mf" v-if="doc.is_return"><span class="mf-label">Type</span><span class="text-red text-weight-medium">Note de credit</span></div>
<div class="mf" v-if="doc.return_against"><span class="mf-label">Renversement de</span><a class="text-indigo-6 cursor-pointer" @click="$emit('navigate', 'Sales Invoice', doc.return_against, 'Facture ' + doc.return_against)">{{ doc.return_against }}</a></div>
</div>
<div class="q-mt-md">
<div class="info-block-title">Remarques</div>
<InlineField :value="doc.remarks === 'No Remarks' ? '' : doc.remarks" field="remarks" doctype="Sales Invoice" :docname="docName"
type="textarea" placeholder="Ajouter des remarques..." @saved="v => doc.remarks = v.value" />
</div>
<div v-if="doc.items?.length" class="q-mt-md">
<div class="info-block-title">Articles ({{ doc.items.length }})</div>
<q-table
:rows="doc.items" :columns="invItemCols" row-key="idx"
flat dense class="ops-table" hide-pagination :pagination="{ rowsPerPage: 0 }"
>
<template #body-cell-amount="p">
<q-td :props="p" class="text-right">{{ formatMoney(p.row.amount) }}</q-td>
</template>
<template #body-cell-rate="p">
<q-td :props="p" class="text-right">{{ formatMoney(p.row.rate) }}</q-td>
</template>
</q-table>
</div>
<div v-if="doc.taxes?.length" class="q-mt-md">
<div class="info-block-title">Taxes</div>
<div v-for="t in doc.taxes" :key="t.idx" class="info-row q-py-xs">
<span>{{ t.description || t.account_head }}</span>
<q-space />
<span>{{ formatMoney(t.tax_amount) }}</span>
</div>
</div>
<div v-if="comments.length" class="q-mt-md">
<div class="info-block-title">Notes ({{ comments.length }})</div>
<div v-for="mc in comments" :key="mc.name" class="q-py-xs" style="border-bottom:1px solid var(--ops-border, #e2e8f0)">
<div class="text-caption text-grey-6">{{ mc.comment_by || 'Systeme' }} &middot; {{ formatDateShort(mc.creation) }}</div>
<div class="text-body2" style="white-space:pre-line">{{ mc.content }}</div>
</div>
</div>
</template>
<script setup>
import { formatMoney, formatDateShort } from 'src/composables/useFormatters'
import { invStatusClass } from 'src/composables/useStatusClasses'
import { invItemCols } from 'src/config/table-columns'
import InlineField from 'src/components/shared/InlineField.vue'
defineProps({
doc: { type: Object, required: true },
docName: String,
comments: { type: Array, default: () => [] },
})
defineEmits(['navigate'])
</script>

View File

@ -0,0 +1,282 @@
<template>
<div class="modal-field-grid">
<div class="mf"><span class="mf-label">Statut</span>
<InlineField :value="doc.status" field="status" doctype="Issue" :docname="docName"
type="select" :options="['Open', 'Replied', 'On Hold', 'Resolved', 'Closed']"
@saved="v => doc.status = v.value" />
</div>
<div class="mf"><span class="mf-label">Priorite</span>
<InlineField :value="doc.priority" field="priority" doctype="Issue" :docname="docName"
type="select" :options="['Low', 'Medium', 'High', 'Urgent']"
@saved="v => doc.priority = v.value" />
</div>
<div class="mf"><span class="mf-label">Ouvert le</span>{{ formatDate(doc.opening_date) }}</div>
<div class="mf" v-if="doc.resolution_time"><span class="mf-label">Resolu le</span>{{ doc.resolution_time }}</div>
<div class="mf"><span class="mf-label">Type</span>
<InlineField :value="doc.issue_type" field="issue_type" doctype="Issue" :docname="docName"
type="select" :options="['Support', 'Installation', 'Demenagement', 'Facturation', 'Reseau', 'Autre']"
placeholder="Type" @saved="v => doc.issue_type = v.value" />
</div>
<div class="mf" v-if="doc.service_location"><span class="mf-label">Adresse</span>{{ doc.service_location }}</div>
<div class="mf" v-if="doc.raised_by"><span class="mf-label">Soumis par</span>{{ doc.raised_by }}</div>
<div class="mf" v-if="doc.owner"><span class="mf-label">Proprietaire</span>{{ doc.owner }}</div>
</div>
<!-- DISPATCH / PROJECT TASKS -->
<div class="q-mt-md dispatch-section">
<div class="row items-center q-mb-xs">
<div class="info-block-title" style="margin-bottom:0">
<q-icon name="account_tree" size="16px" class="q-mr-xs" />
Tâches ({{ dispatchJobs.length }})
</div>
<q-space />
<q-btn-group unelevated dense>
<q-btn dense size="sm" icon="playlist_add" label="Projet"
color="green-7" no-caps @click="showProjectWizard = true">
<q-tooltip>Créer un projet (modèle multi-étapes)</q-tooltip>
</q-btn>
<q-btn dense size="sm" icon="add" label="Tâche"
color="indigo-6" no-caps @click="showCreateDialog = true">
<q-tooltip>Ajouter une tâche simple</q-tooltip>
</q-btn>
</q-btn-group>
</div>
<!-- Nested task tree -->
<div v-if="dispatchJobs.length" class="dispatch-jobs-list">
<!-- Root jobs (no parent) -->
<template v-for="job in rootJobs" :key="job.name">
<TaskNode :job="job" :all-jobs="dispatchJobs" :depth="0"
@fire-webhook="fireWebhook" @deleted="onJobDeleted" @updated="onJobUpdated" />
</template>
<!-- Orphan jobs (parent_job references something outside this ticket) -->
<template v-for="job in orphanJobs" :key="job.name">
<TaskNode :job="job" :all-jobs="dispatchJobs" :depth="0"
@fire-webhook="fireWebhook" @deleted="onJobDeleted" @updated="onJobUpdated" />
</template>
</div>
<div v-else class="text-caption text-grey-5 q-pa-sm">
Aucune tâche liée à ce ticket.
</div>
</div>
<div class="q-mt-sm">
<div class="info-block-title">Sujet</div>
<InlineField :value="doc.subject" field="subject" doctype="Issue" :docname="docName"
placeholder="Sujet du ticket" @saved="v => doc.subject = v.value" />
</div>
<div v-if="doc.issue_split_from" class="q-mt-md">
<div class="info-block-title">Ticket parent</div>
<div class="text-body2 erp-link" style="cursor:pointer" @click="$emit('navigate', 'Issue', doc.issue_split_from)">
{{ doc.issue_split_from }}
</div>
</div>
<div v-if="doc.description" class="q-mt-md">
<div class="info-block-title">Description</div>
<div class="modal-desc" v-html="doc.description"></div>
</div>
<div v-if="doc.resolution_details" class="q-mt-md">
<div class="info-block-title">Resolution</div>
<div class="modal-desc" v-html="doc.resolution_details"></div>
</div>
<div v-if="comms.length" class="q-mt-md">
<div class="info-block-title">Echanges ({{ comms.length }})</div>
<div v-for="comm in comms" :key="comm.name" class="comm-row">
<div class="row items-start">
<div class="col">
<div class="text-caption text-weight-bold">{{ comm.sender || comm.owner }}</div>
<div class="modal-desc q-mt-xs" v-html="comm.content"></div>
</div>
<div class="col-auto text-caption text-grey-6 text-right" style="min-width:90px">
{{ formatDate(comm.creation) }}
</div>
</div>
</div>
</div>
<div v-if="files.length" class="q-mt-md">
<div class="info-block-title">Pieces jointes ({{ files.length }})</div>
<div v-for="f in files" :key="f.name" class="q-py-xs">
<a :href="erpFileUrl(f.file_url)" target="_blank" class="erp-link">
<q-icon name="attach_file" size="14px" /> {{ f.file_name }}
</a>
</div>
</div>
<div v-if="comments.length" class="q-mt-md">
<div class="info-block-title">Fil de discussion ({{ comments.length }})</div>
<div v-for="c in comments" :key="c.name" class="thread-msg">
<div class="thread-header">
<q-icon name="person" size="14px" color="grey-6" class="q-mr-xs" />
<span class="text-weight-bold">{{ c.comment_by || 'Systeme' }}</span>
<span class="text-grey-5 q-ml-auto">{{ formatDateTime(c.creation) }}</span>
</div>
<div class="thread-body" v-html="c.content"></div>
</div>
</div>
<div v-if="!doc.description && !doc.resolution_details && !comms.length && !comments.length" class="text-center text-grey-5 q-pa-lg">
Aucun contenu pour ce ticket
</div>
<div class="q-mt-md reply-box">
<div class="info-block-title">Repondre</div>
<q-input v-model="replyContent" dense outlined type="textarea" autogrow
placeholder="Ecrire une reponse..."
:input-style="{ fontSize: '0.85rem', minHeight: '50px' }"
@keydown.ctrl.enter="sendReply" @keydown.meta.enter="sendReply" />
<div class="row justify-end q-mt-xs">
<q-btn unelevated dense size="sm" label="Envoyer" color="indigo-6" icon="send"
:disable="!replyContent?.trim()" :loading="sendingReply" @click="sendReply" />
</div>
</div>
<!-- Create single Dispatch Job dialog -->
<CreateDispatchJobDialog
v-model="showCreateDialog"
:issue="doc"
:existing-jobs="dispatchJobs"
@created="onJobCreated"
/>
<!-- Project Wizard (multi-step template) -->
<ProjectWizard
v-model="showProjectWizard"
:issue="doc"
:existing-jobs="dispatchJobs"
@created="onJobCreated"
/>
</template>
<script setup>
import { ref, computed } from 'vue'
import { Notify } from 'quasar'
import { formatDate, formatDateTime, erpFileUrl } from 'src/composables/useFormatters'
import { createDoc } from 'src/api/erp'
import { authFetch } from 'src/api/auth'
import InlineField from 'src/components/shared/InlineField.vue'
import CreateDispatchJobDialog from 'src/components/shared/CreateDispatchJobDialog.vue'
import ProjectWizard from 'src/components/shared/ProjectWizard.vue'
import TaskNode from 'src/components/shared/TaskNode.vue'
const props = defineProps({
doc: { type: Object, required: true },
docName: String,
title: String,
comments: { type: Array, default: () => [] },
comms: { type: Array, default: () => [] },
files: { type: Array, default: () => [] },
dispatchJobs: { type: Array, default: () => [] },
})
const emit = defineEmits(['navigate', 'reply-sent', 'dispatch-created', 'dispatch-deleted', 'dispatch-updated'])
const replyContent = ref('')
const sendingReply = ref(false)
const showCreateDialog = ref(false)
const showProjectWizard = ref(false)
// Nested task tree
const rootJobs = computed(() => {
// Jobs with no parent_job, or whose parent_job is not in this ticket's jobs
const jobNames = new Set(props.dispatchJobs.map(j => j.name))
return props.dispatchJobs.filter(j => !j.parent_job || !jobNames.has(j.parent_job))
.sort((a, b) => (a.step_order || 0) - (b.step_order || 0))
})
const orphanJobs = computed(() => {
// Jobs whose parent is in the list but weren't caught as roots or children
// This handles edge cases
const rootAndChildNames = new Set()
function collectNames (jobs) {
for (const j of jobs) {
rootAndChildNames.add(j.name)
}
}
collectNames(rootJobs.value)
// Recursively collect all children
function collectChildren (parentName) {
const children = props.dispatchJobs.filter(j => j.parent_job === parentName)
for (const c of children) {
rootAndChildNames.add(c.name)
collectChildren(c.name)
}
}
for (const r of rootJobs.value) collectChildren(r.name)
return props.dispatchJobs.filter(j => !rootAndChildNames.has(j.name))
})
// n8n webhook firing
async function fireWebhook (url, job) {
if (!url) return
try {
await authFetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
event: url.includes('close') ? 'job_closed' : 'job_opened',
job_name: job.name,
job_subject: job.subject,
job_status: job.status,
job_type: job.job_type,
ticket: props.doc?.name,
customer: props.doc?.customer,
timestamp: new Date().toISOString(),
}),
})
} catch (err) {
console.warn('[n8n webhook] fire failed:', err)
}
}
function onJobCreated (job) {
emit('dispatch-created', job)
}
function onJobDeleted (jobName) {
emit('dispatch-deleted', jobName)
}
function onJobUpdated (jobName, data) {
emit('dispatch-updated', jobName, data)
}
// Reply
async function sendReply () {
if (!replyContent.value?.trim() || sendingReply.value) return
sendingReply.value = true
try {
await createDoc('Communication', {
communication_type: 'Communication',
communication_medium: 'Other',
sent_or_received: 'Sent',
subject: props.title || props.docName,
content: replyContent.value.trim(),
reference_doctype: 'Issue',
reference_name: props.docName,
})
replyContent.value = ''
emit('reply-sent', props.docName)
Notify.create({ type: 'positive', message: 'Reponse envoyee', timeout: 2000 })
} catch {
Notify.create({ type: 'negative', message: 'Erreur: reponse non envoyee', timeout: 3000 })
} finally {
sendingReply.value = false
}
}
</script>
<style scoped>
.dispatch-section {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 10px 12px;
}
.dispatch-jobs-list {
display: flex;
flex-direction: column;
gap: 4px;
}
</style>

View File

@ -0,0 +1,29 @@
<template>
<div class="modal-field-grid">
<div class="mf"><span class="mf-label">Date</span>{{ doc.posting_date }}</div>
<div class="mf"><span class="mf-label">Mode</span>{{ doc.mode_of_payment || '---' }}</div>
<div class="mf"><span class="mf-label">Montant paye</span><strong>{{ formatMoney(doc.paid_amount) }}</strong></div>
<div class="mf"><span class="mf-label">Reference</span>{{ doc.reference_no || '---' }}</div>
<div class="mf" v-if="doc.reference_date"><span class="mf-label">Date ref.</span>{{ doc.reference_date }}</div>
<div class="mf"><span class="mf-label">Compte paye de</span>{{ doc.paid_from || '---' }}</div>
<div class="mf"><span class="mf-label">Compte paye a</span>{{ doc.paid_to || '---' }}</div>
</div>
<div v-if="doc.references?.length" class="q-mt-md">
<div class="info-block-title">Factures liees</div>
<div v-for="r in doc.references" :key="r.idx" class="info-row q-py-xs" style="cursor:pointer" @click="$emit('navigate', r.reference_doctype, r.reference_name)">
<span class="erp-link">{{ r.reference_name }}</span>
<q-space />
<span>{{ formatMoney(r.allocated_amount) }}</span>
</div>
</div>
</template>
<script setup>
import { formatMoney } from 'src/composables/useFormatters'
defineProps({
doc: { type: Object, required: true },
docName: String,
})
defineEmits(['navigate'])
</script>

View File

@ -0,0 +1,52 @@
<template>
<div class="modal-field-grid">
<div class="mf"><span class="mf-label">Statut</span><span class="ops-badge" :class="subStatusClass(doc.status)">{{ doc.status }}</span></div>
<div class="mf"><span class="mf-label">SKU</span><code>{{ doc.item_code }}</code></div>
<div class="mf"><span class="mf-label">Article</span>{{ doc.item_name }}</div>
<div class="mf"><span class="mf-label">Frequence</span>{{ doc.billing_frequency === 'A' ? 'Annuel' : 'Mensuel' }}</div>
<div class="mf"><span class="mf-label">Debut</span>{{ doc.start_date || '---' }}</div>
<div class="mf"><span class="mf-label">Fin</span>{{ doc.end_date || '---' }}</div>
<div class="mf" v-if="doc.service_location"><span class="mf-label">Adresse</span>{{ doc.service_location }}</div>
<div class="mf" v-if="doc.radius_user"><span class="mf-label">PPPoE</span><code>{{ doc.radius_user }}</code></div>
<div class="mf" v-if="doc.radius_pwd"><span class="mf-label">Mot de passe</span><code>{{ doc.radius_pwd }}</code></div>
</div>
<div class="q-mt-md">
<div class="info-block-title">Modifier</div>
<div class="q-gutter-y-sm">
<q-input v-model="doc.custom_description" dense outlined
label="Description personnalisee" @change="$emit('save-field', doc, 'custom_description')" />
<q-input v-model.number="doc.actual_price" dense outlined type="number" step="0.01"
label="Prix" prefix="$" @change="$emit('save-field', doc, 'actual_price')" />
<div class="row items-center q-gutter-x-md q-mt-sm">
<q-toggle
:model-value="!Number(doc.cancel_at_period_end)"
@update:model-value="$emit('toggle-recurring', doc)"
:color="Number(doc.cancel_at_period_end) ? 'grey' : 'green'"
label="Recurrent"
/>
<div v-if="doc.current_invoice_start" class="text-caption text-grey-6">
Prochaine facture: {{ formatDate(doc.current_invoice_start) }} --- {{ formatDate(doc.current_invoice_end) }}
</div>
</div>
</div>
</div>
<div v-if="doc.plans?.length" class="q-mt-md">
<div class="info-block-title">Plans</div>
<div v-for="p in doc.plans" :key="p.idx" class="info-row q-py-xs">
<span>{{ p.plan || p.item }}</span>
<q-space />
<span>{{ formatMoney(p.cost) }}</span>
</div>
</div>
</template>
<script setup>
import { formatDate, formatMoney } from 'src/composables/useFormatters'
import { subStatusClass } from 'src/composables/useStatusClasses'
defineProps({
doc: { type: Object, required: true },
docName: String,
})
defineEmits(['save-field', 'toggle-recurring'])
</script>

View File

@ -0,0 +1,71 @@
import { ref } from 'vue'
import { updateJob } from 'src/api/dispatch'
import { serializeAssistants } from 'src/composables/useHelpers'
export function useContextMenus ({ store, rightPanel, invalidateRoutes, fullUnassign, openMoveModal, openEditModal }) {
const ctxMenu = ref(null)
const techCtx = ref(null)
const assistCtx = ref(null)
const assistNoteModal = ref(null)
function openCtxMenu (e, job, techId) {
e.preventDefault(); e.stopPropagation()
ctxMenu.value = { x: Math.min(e.clientX, window.innerWidth-180), y: Math.min(e.clientY, window.innerHeight-200), job, techId }
}
function closeCtxMenu () { ctxMenu.value = null }
function openTechCtx (e, tech) {
e.preventDefault(); e.stopPropagation()
techCtx.value = { x: Math.min(e.clientX, window.innerWidth-200), y: Math.min(e.clientY, window.innerHeight-200), tech }
}
function openAssistCtx (e, job, techId) {
e.preventDefault(); e.stopPropagation()
assistCtx.value = { x: Math.min(e.clientX, window.innerWidth-200), y: Math.min(e.clientY, window.innerHeight-150), job, techId }
}
function assistCtxTogglePin () {
if (!assistCtx.value) return
const { job, techId } = assistCtx.value
const assist = job.assistants.find(a => a.techId === techId)
if (assist) {
assist.pinned = !assist.pinned
updateJob(job.name || job.id, { assistants: serializeAssistants(job.assistants) }).catch(() => {})
invalidateRoutes()
}
assistCtx.value = null
}
function assistCtxRemove () {
if (!assistCtx.value) return
store.removeAssistant(assistCtx.value.job.id, assistCtx.value.techId)
invalidateRoutes(); assistCtx.value = null
}
function assistCtxNote () {
if (!assistCtx.value) return
const { job, techId } = assistCtx.value
const assist = job.assistants.find(a => a.techId === techId)
assistNoteModal.value = { job, techId, note: assist?.note || '' }
assistCtx.value = null
}
function confirmAssistNote () {
if (!assistNoteModal.value) return
const { job, techId, note } = assistNoteModal.value
const assist = job.assistants.find(a => a.techId === techId)
if (assist) {
assist.note = note
updateJob(job.name || job.id, { assistants: serializeAssistants(job.assistants) }).catch(() => {})
}
assistNoteModal.value = null
}
function ctxDetails () {
const { job, techId } = ctxMenu.value
rightPanel.value = { mode: 'details', data: { job, tech: store.technicians.find(t => t.id === techId) } }; closeCtxMenu()
}
function ctxMove () { const { job, techId } = ctxMenu.value; openMoveModal(job, techId); closeCtxMenu() }
function ctxUnschedule () { fullUnassign(ctxMenu.value.job); closeCtxMenu() }
return {
ctxMenu, techCtx, assistCtx, assistNoteModal,
openCtxMenu, closeCtxMenu, openTechCtx, openAssistCtx,
assistCtxTogglePin, assistCtxRemove, assistCtxNote, confirmAssistNote,
ctxDetails, ctxMove, ctxUnschedule,
}
}

View File

@ -0,0 +1,101 @@
/**
* Composable for customer notes/comments management.
*/
import { ref, computed } from 'vue'
import { listDocs, updateDoc } from 'src/api/erp'
import { authFetch } from 'src/api/auth'
import { BASE_URL } from 'src/config/erpnext'
/**
* @param {import('vue').Ref<Array>} comments - Reactive comments list
* @param {import('vue').Ref<Object>} customer - Reactive customer object
*/
export function useCustomerNotes (comments, customer) {
const newNote = ref('')
const addingNote = ref(false)
const noteInputFocused = ref(false)
const editingNote = ref(null)
const sortedComments = computed(() => {
const pinned = comments.value.filter(c => c._sticky)
const rest = comments.value.filter(c => !c._sticky)
return [...pinned, ...rest]
})
async function addNote () {
if (!newNote.value?.trim() || addingNote.value) return
addingNote.value = true
try {
const body = {
doctype: 'Comment', comment_type: 'Comment',
reference_doctype: 'Customer', reference_name: customer.value.name,
content: newNote.value.trim(),
}
const res = await authFetch(BASE_URL + '/api/resource/Comment', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (res.ok) {
const data = await res.json()
comments.value.unshift(data.data)
newNote.value = ''
noteInputFocused.value = false
}
} catch {} finally {
addingNote.value = false
}
}
async function deleteNote (note) {
try {
const res = await authFetch(BASE_URL + '/api/resource/Comment/' + encodeURIComponent(note.name), { method: 'DELETE' })
if (res.ok) {
comments.value = comments.value.filter(c => c.name !== note.name)
}
} catch {}
}
function toggleStickyNote (note) {
note._sticky = !note._sticky
}
async function saveEditNote (note, newContent) {
if (!newContent?.trim()) return
try {
await updateDoc('Comment', note.name, { content: newContent.trim() })
note.content = newContent.trim()
editingNote.value = null
} catch {}
}
function startEditNote (note) {
editingNote.value = note.name
note._editContent = note.content?.replace(/<[^>]*>/g, '') || ''
}
async function onNoteAdded () {
try {
comments.value = await listDocs('Comment', {
filters: { reference_doctype: 'Customer', reference_name: customer.value?.name, comment_type: 'Comment' },
fields: ['name', 'content', 'comment_by', 'creation'],
limit: 50,
orderBy: 'creation desc',
})
} catch {}
}
return {
newNote,
addingNote,
noteInputFocused,
editingNote,
sortedComments,
addNote,
deleteNote,
toggleStickyNote,
saveEditNote,
startEditNote,
onNoteAdded,
}
}

View File

@ -16,6 +16,7 @@ export function useDetailModal () {
const modalComments = ref([]) const modalComments = ref([])
const modalComms = ref([]) const modalComms = ref([])
const modalFiles = ref([]) const modalFiles = ref([])
const modalDispatchJobs = ref([])
const modalErpLink = computed(() => erpLink(modalDoctype.value, modalDocName.value)) const modalErpLink = computed(() => erpLink(modalDoctype.value, modalDocName.value))
@ -42,6 +43,7 @@ export function useDetailModal () {
modalComments.value = [] modalComments.value = []
modalComms.value = [] modalComms.value = []
modalFiles.value = [] modalFiles.value = []
modalDispatchJobs.value = []
modalOpen.value = true modalOpen.value = true
modalLoading.value = true modalLoading.value = true
@ -57,7 +59,7 @@ export function useDetailModal () {
})) }))
} }
// Fetch communications, files, and comments for Issues // Fetch communications, files, comments, and linked dispatch jobs for Issues
if (doctype === 'Issue') { if (doctype === 'Issue') {
promises.push( promises.push(
listDocs('Communication', { listDocs('Communication', {
@ -75,6 +77,11 @@ export function useDetailModal () {
fields: ['name', 'content', 'comment_by', 'creation'], fields: ['name', 'content', 'comment_by', 'creation'],
limit: 200, orderBy: 'creation asc', limit: 200, orderBy: 'creation asc',
}).catch(() => []), }).catch(() => []),
listDocs('Dispatch Job', {
filters: { source_issue: name },
fields: ['name', 'subject', 'status', 'assigned_tech', 'scheduled_date', 'job_type', 'priority', 'depends_on', 'duration_h', 'parent_job', 'step_order', 'on_open_webhook', 'on_close_webhook'],
limit: 50, orderBy: 'step_order asc, creation asc',
}).catch(() => []),
) )
} }
@ -87,6 +94,7 @@ export function useDetailModal () {
modalComms.value = results[1] || [] modalComms.value = results[1] || []
modalFiles.value = results[2] || [] modalFiles.value = results[2] || []
modalComments.value = results[3] || [] modalComments.value = results[3] || []
modalDispatchJobs.value = results[4] || []
} }
// Auto-derive title from doc if not provided // Auto-derive title from doc if not provided
@ -116,6 +124,7 @@ export function useDetailModal () {
modalFiles, modalFiles,
modalErpLink, modalErpLink,
modalDocFields, modalDocFields,
modalDispatchJobs,
openModal, openModal,
closeModal, closeModal,
} }

View File

@ -58,3 +58,80 @@ export function erpFileUrl (url) {
if (url.startsWith('http')) return url if (url.startsWith('http')) return url
return ERP_BASE + url return ERP_BASE + url
} }
export function formatDateTime (dt) {
if (!dt) return ''
const d = new Date(dt)
if (isNaN(d)) return dt
return d.toLocaleDateString('fr-CA', { year: 'numeric', month: 'short', day: 'numeric' }) + ' ' +
d.toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit' })
}
// ═══ Staff avatar helpers ═══
const AVATAR_COLORS = ['#4f46e5','#0891b2','#059669','#d97706','#dc2626','#7c3aed','#be185d','#0d9488','#6366f1','#ea580c']
/**
* Generate a consistent color from a name string (for avatar backgrounds).
* @param {string|null} name
* @returns {string} Hex color
*/
export function staffColor (name) {
if (!name) return '#9e9e9e'
let h = 0
for (let i = 0; i < name.length; i++) h = name.charCodeAt(i) + ((h << 5) - h)
return AVATAR_COLORS[Math.abs(h) % AVATAR_COLORS.length]
}
/**
* Extract initials from a full name string.
* @param {string|null} name
* @returns {string}
*/
export function staffInitials (name) {
if (!name) return ''
const parts = name.trim().split(/\s+/)
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
return parts[0].substring(0, 2).toUpperCase()
}
/**
* Decode HTML entities (&#039; -> ', &amp; -> &, etc.)
* @param {string|null} str
* @returns {string}
*/
export function decodeHtml (str) {
if (!str) return str
const el = document.createElement('textarea')
el.innerHTML = str
return el.value
}
/**
* Extract display name from email (user@domain -> User)
* @param {string|null} email
* @returns {string}
*/
export function noteAuthorName (email) {
if (!email) return 'Système'
const local = email.split('@')[0]
return local.charAt(0).toUpperCase() + local.slice(1).replace(/[._-]/g, ' ')
}
/**
* Relative time display for notes/comments.
* @param {string|null} d - Date/datetime string
* @returns {string}
*/
export function noteTimeAgo (d) {
if (!d) return ''
const diff = Date.now() - new Date(d).getTime()
const mins = Math.floor(diff / 60000)
if (mins < 1) return "À l'instant"
if (mins < 60) return `Il y a ${mins} min`
const hours = Math.floor(mins / 60)
if (hours < 24) return `Il y a ${hours}h`
const days = Math.floor(hours / 24)
if (days < 7) return `Il y a ${days}j`
return formatDate(d)
}

View File

@ -0,0 +1,103 @@
import { ref } from 'vue'
import { fetchDevices, fetchPositions, createTraccarSession } from 'src/api/traccar'
let __gpsStarted = false
let __gpsInterval = null
let __gpsPolling = false
export function useGpsTracking (technicians) {
const traccarDevices = ref([])
const _techsByDevice = {}
function _buildTechDeviceMap () {
Object.keys(_techsByDevice).forEach(k => delete _techsByDevice[k])
technicians.value.forEach(t => {
if (!t.traccarDeviceId) return
const dev = traccarDevices.value.find(d => d.id === parseInt(t.traccarDeviceId) || d.uniqueId === t.traccarDeviceId)
if (dev) _techsByDevice[dev.id] = t
})
}
function _applyPositions (positions) {
positions.forEach(p => {
const tech = _techsByDevice[p.deviceId]
if (!tech || !p.latitude || !p.longitude) return
const cur = tech.gpsCoords
if (!cur || Math.abs(cur[0] - p.longitude) > 0.00001 || Math.abs(cur[1] - p.latitude) > 0.00001) {
tech.gpsCoords = [p.longitude, p.latitude]
}
tech.gpsSpeed = p.speed || 0
tech.gpsTime = p.fixTime
tech.gpsOnline = true
})
}
async function pollGps () {
if (__gpsPolling) return
__gpsPolling = true
try {
if (!traccarDevices.value.length) traccarDevices.value = await fetchDevices()
_buildTechDeviceMap()
const deviceIds = Object.keys(_techsByDevice).map(Number)
if (!deviceIds.length) return
const positions = await fetchPositions(deviceIds)
_applyPositions(positions)
Object.values(_techsByDevice).forEach(t => {
if (!positions.find(p => _techsByDevice[p.deviceId] === t)) t.gpsOnline = false
})
} catch { /* poll error */ }
finally { __gpsPolling = false }
}
let __ws = null
let __wsBackoff = 1000
function _connectWs () {
if (__ws) return
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const url = proto + '//' + window.location.host + '/traccar/api/socket'
try { __ws = new WebSocket(url) } catch { return }
__ws.onopen = () => {
__wsBackoff = 1000
if (__gpsInterval) { clearInterval(__gpsInterval); __gpsInterval = null }
}
__ws.onmessage = (e) => {
try {
const data = JSON.parse(e.data)
if (data.positions?.length) {
_buildTechDeviceMap()
_applyPositions(data.positions)
}
} catch { /* parse error */ }
}
__ws.onerror = () => {}
__ws.onclose = () => {
__ws = null
if (!__gpsStarted) return
if (!__gpsInterval) {
__gpsInterval = setInterval(pollGps, 30000)
}
setTimeout(_connectWs, __wsBackoff)
__wsBackoff = Math.min(__wsBackoff * 2, 60000)
}
}
async function startGpsTracking () {
if (__gpsStarted) return
__gpsStarted = true
await pollGps()
const sessionOk = await createTraccarSession()
if (sessionOk) {
_connectWs()
} else {
__gpsInterval = setInterval(pollGps, 30000)
}
}
function stopGpsTracking () {
if (__gpsInterval) { clearInterval(__gpsInterval); __gpsInterval = null }
if (__ws) { const ws = __ws; __ws = null; ws.onclose = null; ws.close() }
}
return { traccarDevices, pollGps, startGpsTracking, stopGpsTracking }
}

View File

@ -0,0 +1,64 @@
import { ref, computed, watch } from 'vue'
import { localDateStr, startOfWeek, startOfMonth } from 'src/composables/useHelpers'
export function usePeriodNavigation () {
const currentView = ref(localStorage.getItem('sbv2-view') || 'week')
const savedDate = localStorage.getItem('sbv2-date')
const anchorDate = ref(savedDate ? new Date(savedDate + 'T00:00:00') : new Date())
watch(currentView, v => localStorage.setItem('sbv2-view', v))
watch(anchorDate, d => localStorage.setItem('sbv2-date', localDateStr(d)))
const periodStart = computed(() => {
const d = new Date(anchorDate.value); d.setHours(0,0,0,0)
if (currentView.value === 'day') return d
if (currentView.value === 'week') return startOfWeek(d)
return startOfMonth(d)
})
const periodDays = computed(() => {
if (currentView.value === 'day') return 1
if (currentView.value === 'week') return 7
const s = periodStart.value
return new Date(s.getFullYear(), s.getMonth()+1, 0).getDate()
})
const dayColumns = computed(() => {
const cols = []
for (let i = 0; i < periodDays.value; i++) {
const d = new Date(periodStart.value); d.setDate(d.getDate() + i); cols.push(d)
}
return cols
})
const periodLabel = computed(() => {
const s = periodStart.value
if (currentView.value === 'day')
return s.toLocaleDateString('fr-CA', { weekday:'long', day:'numeric', month:'long', year:'numeric' })
if (currentView.value === 'week') {
const e = new Date(s); e.setDate(e.getDate() + 6)
return `${s.toLocaleDateString('fr-CA',{day:'numeric',month:'short'})} ${e.toLocaleDateString('fr-CA',{day:'numeric',month:'short',year:'numeric'})}`
}
return s.toLocaleDateString('fr-CA', { month:'long', year:'numeric' })
})
const todayStr = localDateStr(new Date())
function prevPeriod () {
const d = new Date(anchorDate.value)
if (currentView.value === 'day') d.setDate(d.getDate()-1)
if (currentView.value === 'week') d.setDate(d.getDate()-7)
if (currentView.value === 'month') d.setMonth(d.getMonth()-1)
anchorDate.value = d
}
function nextPeriod () {
const d = new Date(anchorDate.value)
if (currentView.value === 'day') d.setDate(d.getDate()+1)
if (currentView.value === 'week') d.setDate(d.getDate()+7)
if (currentView.value === 'month') d.setMonth(d.getMonth()+1)
anchorDate.value = d
}
function goToToday () { anchorDate.value = new Date(); currentView.value = 'day' }
function goToDay (d) { anchorDate.value = new Date(d); currentView.value = 'day' }
return {
currentView, anchorDate, periodStart, periodDays, dayColumns, periodLabel, todayStr,
prevPeriod, nextPeriod, goToToday, goToDay,
}
}

View File

@ -0,0 +1,63 @@
import { ref, computed, watch } from 'vue'
export function useResourceFilter (store) {
const selectedResIds = ref(JSON.parse(localStorage.getItem('sbv2-resIds') || '[]'))
const filterStatus = ref(localStorage.getItem('sbv2-filterStatus') || '')
const filterTags = ref(JSON.parse(localStorage.getItem('sbv2-filterTags') || '[]'))
const searchQuery = ref('')
const techSort = ref(localStorage.getItem('sbv2-techSort') || 'default')
const manualOrder = ref(JSON.parse(localStorage.getItem('sbv2-techOrder') || '[]'))
const resSelectorOpen = ref(false)
const tempSelectedIds = ref([])
const dragReorderTech = ref(null)
watch(selectedResIds, v => localStorage.setItem('sbv2-resIds', JSON.stringify(v)), { deep: true })
watch(filterStatus, v => localStorage.setItem('sbv2-filterStatus', v))
watch(techSort, v => localStorage.setItem('sbv2-techSort', v))
const filteredResources = computed(() => {
let list = store.technicians
if (searchQuery.value) { const q = searchQuery.value.toLowerCase(); list = list.filter(t => t.fullName.toLowerCase().includes(q)) }
if (filterStatus.value) list = list.filter(t => (t.status||'available') === filterStatus.value)
if (selectedResIds.value.length) list = list.filter(t => selectedResIds.value.includes(t.id))
if (filterTags.value.length) list = list.filter(t => filterTags.value.every(ft => (t.tags||[]).includes(ft)))
if (techSort.value === 'alpha') {
list = [...list].sort((a, b) => a.fullName.split(' ').pop().toLowerCase().localeCompare(b.fullName.split(' ').pop().toLowerCase()))
} else if (techSort.value === 'manual' && manualOrder.value.length) {
const order = manualOrder.value
list = [...list].sort((a, b) => (order.indexOf(a.id) === -1 ? 999 : order.indexOf(a.id)) - (order.indexOf(b.id) === -1 ? 999 : order.indexOf(b.id)))
}
return list
})
function onTechReorderStart (e, tech) {
dragReorderTech.value = tech
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', 'reorder')
}
function onTechReorderDrop (e, targetTech) {
e.preventDefault()
if (!dragReorderTech.value || dragReorderTech.value.id === targetTech.id) { dragReorderTech.value = null; return }
techSort.value = 'manual'
const ids = filteredResources.value.map(t => t.id)
const fromIdx = ids.indexOf(dragReorderTech.value.id), toIdx = ids.indexOf(targetTech.id)
ids.splice(fromIdx, 1); ids.splice(toIdx, 0, dragReorderTech.value.id)
manualOrder.value = ids; localStorage.setItem('sbv2-techOrder', JSON.stringify(ids))
dragReorderTech.value = null
}
function openResSelector () { tempSelectedIds.value = [...selectedResIds.value]; resSelectorOpen.value = true }
function applyResSelector () { selectedResIds.value = [...tempSelectedIds.value]; resSelectorOpen.value = false }
function toggleTempRes (id) {
const idx = tempSelectedIds.value.indexOf(id)
if (idx >= 0) tempSelectedIds.value.splice(idx, 1); else tempSelectedIds.value.push(id)
}
function clearFilters () { selectedResIds.value = []; filterStatus.value = ''; searchQuery.value = ''; filterTags.value = []; localStorage.removeItem('sbv2-filterTags') }
return {
selectedResIds, filterStatus, filterTags, searchQuery, techSort, manualOrder,
filteredResources, resSelectorOpen, tempSelectedIds, dragReorderTech,
openResSelector, applyResSelector, toggleTempRes, clearFilters,
onTechReorderStart, onTechReorderDrop,
}
}

View File

@ -0,0 +1,105 @@
import { ref, onUnmounted } from 'vue'
const HUB_URL = 'https://msg.gigafibre.ca'
/**
* SSE composable for real-time Communication events from targo-hub.
*
* @param {Object} opts
* @param {Function} opts.onMessage - callback(data) for 'message' events
* @param {Function} opts.onSmsIncoming - callback(data) for 'sms-incoming' events
* @param {Function} opts.onSmsStatus - callback(data) for 'sms-status' events
* @param {Function} opts.onError - callback(event) on connection error
*/
export function useSSE (opts = {}) {
const connected = ref(false)
const clientCount = ref(0)
let es = null
let reconnectTimer = null
let reconnectDelay = 1000 // start at 1s, exponential backoff
function connect (topics) {
disconnect()
if (!topics || !topics.length) return
const topicsStr = topics.join(',')
const url = `${HUB_URL}/sse?topics=${encodeURIComponent(topicsStr)}`
es = new EventSource(url)
es.onopen = () => {
connected.value = true
reconnectDelay = 1000 // reset backoff on successful connect
}
es.addEventListener('message', (e) => {
try {
const data = JSON.parse(e.data)
if (opts.onMessage) opts.onMessage(data)
} catch {}
})
es.addEventListener('sms-incoming', (e) => {
try {
const data = JSON.parse(e.data)
if (opts.onSmsIncoming) opts.onSmsIncoming(data)
} catch {}
})
es.addEventListener('sms-status', (e) => {
try {
const data = JSON.parse(e.data)
if (opts.onSmsStatus) opts.onSmsStatus(data)
} catch {}
})
es.onerror = (e) => {
connected.value = false
if (opts.onError) opts.onError(e)
// EventSource auto-reconnects, but if it closes permanently, do manual reconnect
if (es.readyState === EventSource.CLOSED) {
scheduleReconnect(topics)
}
}
}
function scheduleReconnect (topics) {
if (reconnectTimer) clearTimeout(reconnectTimer)
reconnectTimer = setTimeout(() => {
reconnectDelay = Math.min(reconnectDelay * 2, 30000) // max 30s
connect(topics)
}, reconnectDelay)
}
function disconnect () {
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
if (es) {
es.close()
es = null
}
connected.value = false
}
onUnmounted(disconnect)
return { connect, disconnect, connected }
}
/**
* Send SMS via the hub instead of n8n.
*/
export async function sendSmsViaHub (phone, message, customer) {
const res = await fetch(`${HUB_URL}/send/sms`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone, message, customer }),
})
if (!res.ok) {
const err = await res.text().catch(() => 'Unknown error')
throw new Error('SMS failed (' + res.status + '): ' + err)
}
return res.json()
}

View File

@ -0,0 +1,181 @@
/**
* Composable for subscription management actions.
* Handles status toggling, frequency changes, recurring settings, drag reorder, and field saves.
*/
import { ref } from 'vue'
import { Notify } from 'quasar'
import { authFetch } from 'src/api/auth'
import { BASE_URL } from 'src/config/erpnext'
import { formatMoney } from 'src/composables/useFormatters'
// Frappe REST update for Subscription doctype
async function updateSub (name, fields) {
const res = await authFetch(BASE_URL + '/api/resource/Subscription/' + encodeURIComponent(name), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(fields),
})
if (!res.ok) {
const err = await res.text()
throw new Error('Update failed: ' + err)
}
return res.json()
}
/**
* @param {import('vue').Ref<Array>} subscriptions - Reactive subscriptions list
* @param {import('vue').Ref<Object>} customer - Reactive customer object
* @param {import('vue').Ref<Array>} comments - Reactive comments list
* @param {Function} invalidateCache - Cache invalidation from useSubscriptionGroups
*/
export function useSubscriptionActions (subscriptions, customer, comments, invalidateCache) {
const subSaving = ref(null)
const togglingRecurring = ref(null)
// Log a subscription change as a Comment on the Customer for audit trail
async function logSubChange (sub, message) {
try {
const label = sub.custom_description || sub.item_name || sub.item_code || sub.name
const body = {
doctype: 'Comment', comment_type: 'Comment',
reference_doctype: 'Customer', reference_name: customer.value.name,
content: `[${sub.name}] ${label}${message}`,
}
const res = await authFetch(BASE_URL + '/api/resource/Comment', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (res.ok) {
const data = await res.json()
comments.value.unshift(data.data)
}
} catch {}
}
async function toggleSubStatus (sub) {
const action = sub.name + ':status'
if (subSaving.value) return
subSaving.value = action
const newStatus = sub.status === 'Cancelled' ? 'Active' : 'Cancelled'
const today = new Date().toISOString().slice(0, 10)
try {
const updates = { status: newStatus }
if (newStatus === 'Cancelled') {
updates.cancelation_date = today
if (!sub.end_date) updates.end_date = sub.current_invoice_end || today
} else {
updates.cancelation_date = null
}
await updateSub(sub.name, updates)
sub.status = newStatus
if (updates.end_date) sub.end_date = updates.end_date
const msg = newStatus === 'Cancelled'
? `Service désactivé le ${today}`
: `Service réactivé le ${today}`
logSubChange(sub, msg)
if (sub.service_location) invalidateCache(sub.service_location)
Notify.create({ type: 'positive', message: msg, timeout: 2500 })
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + (e.message || 'impossible de modifier le statut'), timeout: 4000 })
} finally {
subSaving.value = null
}
}
async function toggleFrequency (sub) {
const action = sub.name + ':freq'
if (subSaving.value) return
subSaving.value = action
const oldFreq = sub.billing_frequency
const newFreq = oldFreq === 'A' ? 'M' : 'A'
try {
await updateSub(sub.name, { billing_frequency: newFreq })
const msg = newFreq === 'A'
? `Fréquence changée: Mensuel → Annuel`
: `Fréquence changée: Annuel → Mensuel`
logSubChange(sub, msg)
if (sub.service_location) invalidateCache(sub.service_location)
sub.billing_frequency = newFreq
} catch {} finally {
subSaving.value = null
}
}
async function toggleRecurring (sub) {
if (togglingRecurring.value) return
togglingRecurring.value = sub.name
const newVal = Number(sub.cancel_at_period_end) ? 0 : 1
try {
const updates = { cancel_at_period_end: newVal }
if (newVal && !sub.end_date) updates.end_date = sub.current_invoice_end || new Date().toISOString().slice(0, 10)
await updateSub(sub.name, updates)
sub.cancel_at_period_end = newVal
if (updates.end_date) sub.end_date = updates.end_date
const msg = newVal ? 'Récurrence désactivée' : 'Récurrence activée'
logSubChange(sub, msg)
Notify.create({ type: 'positive', message: msg, timeout: 2500 })
} catch {
Notify.create({ type: 'negative', message: 'Erreur: impossible de modifier le récurrent', timeout: 3000 })
} finally {
togglingRecurring.value = null
}
}
async function toggleRecurringModal (doc) {
const newVal = Number(doc.cancel_at_period_end) ? 0 : 1
try {
const updates = { cancel_at_period_end: newVal }
if (newVal && !doc.end_date) updates.end_date = doc.current_invoice_end || new Date().toISOString().slice(0, 10)
await updateSub(doc.name, updates)
doc.cancel_at_period_end = newVal
if (updates.end_date) doc.end_date = updates.end_date
const listSub = subscriptions.value.find(s => s.name === doc.name)
if (listSub) {
listSub.cancel_at_period_end = newVal
if (updates.end_date) listSub.end_date = updates.end_date
}
const msg = newVal ? 'Récurrence désactivée' : 'Récurrence activée'
logSubChange(doc, msg)
Notify.create({ type: 'positive', message: msg, timeout: 2500 })
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + (e.message || 'impossible de modifier'), timeout: 3000 })
}
}
async function saveSubField (doc, field) {
try {
const oldVal = subscriptions.value.find(s => s.name === doc.name)?.[field]
await updateSub(doc.name, { [field]: doc[field] ?? '' })
const listSub = subscriptions.value.find(s => s.name === doc.name)
if (listSub) {
listSub[field] = doc[field]
if (listSub.service_location) invalidateCache(listSub.service_location)
}
const fieldLabels = { actual_price: 'Prix', custom_description: 'Description' }
if (fieldLabels[field]) {
const newVal = doc[field] ?? ''
const msg = field === 'actual_price'
? `${fieldLabels[field]} modifié: ${formatMoney(oldVal)}${formatMoney(newVal)}`
: `${fieldLabels[field]} modifié: "${oldVal || '—'}" → "${newVal || '—'}"`
logSubChange(doc, msg)
}
} catch {}
}
function onSubDragChange (evt, locName) {
if (evt.added || evt.removed) invalidateCache(locName)
}
return {
subSaving,
togglingRecurring,
toggleSubStatus,
toggleFrequency,
toggleRecurring,
toggleRecurringModal,
saveSubField,
logSubChange,
onSubDragChange,
}
}

View File

@ -0,0 +1,56 @@
import { createTag as apiCreateTag, updateTag as apiUpdateTag, renameTag as apiRenameTag, deleteTag as apiDeleteTag, updateJob, updateTech } from 'src/api/dispatch'
export function useTagManagement (store) {
function getTagColor (tagLabel) {
const t = store.allTags.find(x => x.label === tagLabel)
return t?.color || '#6b7280'
}
async function onCreateTag ({ label, color }) {
color = color || '#6b7280'
try {
const created = await apiCreateTag(label, 'Custom', color)
if (created) store.allTags.push({ name: created.name, label: created.label, color: created.color || color, category: created.category || 'Custom' })
} catch (e) {
if (!store.allTags.some(t => t.label === label))
store.allTags.push({ name: label, label, color, category: 'Custom' })
}
}
async function onUpdateTag ({ name, color }) {
try {
await apiUpdateTag(name, { color })
const t = store.allTags.find(x => x.name === name || x.label === name)
if (t) t.color = color
} catch (_e) { /* update failed */ }
}
async function onRenameTag ({ oldName, newName }) {
try {
await apiRenameTag(oldName, newName)
const t = store.allTags.find(x => x.name === oldName || x.label === oldName)
if (t) { t.name = newName; t.label = newName }
store.jobs.forEach(j => { j.tags = j.tags.map(tg => tg === oldName ? newName : tg) })
store.technicians.forEach(tc => { tc.tags = tc.tags.map(tg => tg === oldName ? newName : tg) })
} catch (_e) { /* rename failed */ }
}
async function onDeleteTag (label) {
try {
await apiDeleteTag(label)
store.allTags.splice(store.allTags.findIndex(t => t.label === label || t.name === label), 1)
store.jobs.forEach(j => { j.tags = j.tags.filter(t => t !== label) })
store.technicians.forEach(tc => { tc.tags = tc.tags.filter(t => t !== label) })
} catch (_e) { /* delete failed */ }
}
function _serializeTags (arr) {
return (arr || []).map(t => typeof t === 'string' ? { tag: t } : { tag: t.tag, level: t.level || 0, required: t.required || 0 })
}
function persistJobTags (job) {
updateJob(job.name || job.id, { tags: _serializeTags(job.tagsWithLevel || job.tags) }).catch(() => {})
}
function persistTechTags (tech) {
updateTech(tech.name || tech.id, { tags: _serializeTags(tech.tagsWithLevel || tech.tags) }).catch(() => {})
}
return {
getTagColor, onCreateTag, onUpdateTag, onRenameTag, onDeleteTag,
_serializeTags, persistJobTags, persistTechTags,
}
}

View File

@ -0,0 +1,74 @@
import { ref } from 'vue'
import { updateTech } from 'src/api/dispatch'
export function useTechManagement (store, invalidateRoutes) {
const editingTech = ref(null)
const newTechName = ref('')
const newTechPhone = ref('')
const newTechDevice = ref('')
const addingTech = ref(false)
async function saveTechField (tech, field, value) {
const trimmed = typeof value === 'string' ? value.trim() : value
if (field === 'full_name') {
if (!trimmed || trimmed === tech.fullName) return
tech.fullName = trimmed
} else if (field === 'status') {
tech.status = trimmed
} else if (field === 'phone') {
if (trimmed === tech.phone) return
tech.phone = trimmed
}
try { await updateTech(tech.name || tech.id, { [field]: trimmed }) }
catch (_e) { /* save failed */ }
}
async function addTech () {
const name = newTechName.value.trim()
if (!name || addingTech.value) return
addingTech.value = true
try {
const tech = await store.createTechnician({
full_name: name,
phone: newTechPhone.value.trim() || '',
traccar_device_id: newTechDevice.value || '',
})
newTechName.value = ''
newTechPhone.value = ''
newTechDevice.value = ''
if (tech.traccarDeviceId) await store.pollGps()
} catch (e) {
const msg = e?.message || String(e)
alert('Erreur création technicien:\n' + msg.replace(/<[^>]+>/g, ''))
}
finally { addingTech.value = false }
}
async function removeTech (tech) {
if (!confirm(`Supprimer ${tech.fullName} ?`)) return
try {
const linkedJobs = store.jobs.filter(j => j.assignedTech === tech.id)
for (const job of linkedJobs) {
await store.unassignJob(job.id)
}
await store.deleteTechnician(tech.id)
} catch (e) {
const msg = e?.message || String(e)
alert('Erreur suppression:\n' + msg.replace(/<[^>]+>/g, ''))
}
}
async function saveTraccarLink (tech, deviceId) {
tech.traccarDeviceId = deviceId || null
tech.gpsCoords = null
tech.gpsOnline = false
await updateTech(tech.name || tech.id, { traccar_device_id: deviceId || '' })
await store.pollGps()
invalidateRoutes()
}
return {
editingTech, newTechName, newTechPhone, newTechDevice, addingTech,
saveTechField, addTech, removeTech, saveTraccarLink,
}
}

View File

@ -0,0 +1,19 @@
/**
* Maps equipment type strings to Material Design icon names.
*/
const DEVICE_ICON_MAP = {
ONT: 'settings_input_hdmi',
Modem: 'router',
Routeur: 'router',
'Décodeur TV': 'connected_tv',
'Téléphone IP': 'phone_in_talk',
Switch: 'hub',
Amplificateur: 'cell_tower',
'AP WiFi': 'wifi',
'Câble/Connecteur': 'cable',
}
export function deviceLucideIcon (type) {
return DEVICE_ICON_MAP[type] || 'devices_other'
}

View File

@ -4,6 +4,9 @@
const viteBase = import.meta.env.BASE_URL || '/' const viteBase = import.meta.env.BASE_URL || '/'
export const BASE_URL = viteBase === '/' ? '' : viteBase.replace(/\/$/, '') export const BASE_URL = viteBase === '/' ? '' : viteBase.replace(/\/$/, '')
// Direct link to ERPNext desk (for admin actions like user management)
export const ERP_DESK_URL = 'https://erp.gigafibre.ca'
export const MAPBOX_TOKEN = 'pk.eyJ1IjoidGFyZ29pbnRlcm5ldCIsImEiOiJjbW13Z3lwMXAwdGt1MnVvamsxNWkybzFkIn0.rdYB17XUdfn96czdnnJ6eg' export const MAPBOX_TOKEN = 'pk.eyJ1IjoidGFyZ29pbnRlcm5ldCIsImEiOiJjbW13Z3lwMXAwdGt1MnVvamsxNWkybzFkIn0.rdYB17XUdfn96czdnnJ6eg'
export const TECH_COLORS = [ export const TECH_COLORS = [

View File

@ -0,0 +1,31 @@
// Ops sidebar navigation + search filter options
export const navItems = [
{ path: '/', icon: 'LayoutDashboard', label: 'Tableau de bord' },
{ path: '/clients', icon: 'Users', label: 'Clients' },
{ path: '/dispatch', icon: 'Truck', label: 'Dispatch' },
{ path: '/tickets', icon: 'Ticket', label: 'Tickets' },
{ path: '/equipe', icon: 'UsersRound', label: 'Équipe' },
{ path: '/rapports', icon: 'BarChart3', label: 'Rapports' },
{ path: '/ocr', icon: 'ScanText', label: 'OCR Factures' },
{ path: '/settings', icon: 'Settings', label: 'Paramètres' },
]
export const territoryOptions = [
{ label: 'Gatineau', value: 'Gatineau' },
{ label: 'Ottawa', value: 'Ottawa' },
{ label: 'Aylmer', value: 'Aylmer' },
{ label: 'Hull', value: 'Hull' },
{ label: 'Buckingham', value: 'Buckingham' },
{ label: 'Masson-Angers', value: 'Masson-Angers' },
]
export const statusOptions = [
{ label: 'Actif', value: 'Active' },
{ label: 'Inactif', value: 'Inactive' },
{ label: 'En attente', value: 'Pending' },
]
export const customerTypeOptions = [
{ label: 'Individu', value: 'Individual' },
{ label: 'Entreprise', value: 'Company' },
]

View File

@ -0,0 +1,210 @@
/**
* Project Templates Pre-defined workflow trees for common service orders.
*
* Each template defines a sequence of Dispatch Job steps that get created
* together when applied to a ticket. Steps can have:
* - subject, job_type, priority, duration_h
* - assigned_group: which team/group handles it
* - depends_on_step: index (0-based) of the step this one depends on
* - on_open_webhook / on_close_webhook: n8n webhook URLs fired on status change
*
* Templates are stored in the frontend for now. Future: sync to ERPNext
* "Dispatch Template" doctype for admin management.
*/
export const PROJECT_TEMPLATES = [
{
id: 'phone_service',
name: 'Service téléphonique résidentiel',
icon: 'phone_in_talk',
description: 'Importation du numéro, installation fibre, portage du numéro',
category: 'Téléphonie',
steps: [
{
subject: 'Importer le numéro de téléphone',
job_type: 'Autre',
priority: 'medium',
duration_h: 0.5,
assigned_group: 'Admin',
depends_on_step: null,
on_open_webhook: '',
on_close_webhook: '',
},
{
subject: 'Installation fibre (pré-requis portage)',
job_type: 'Installation',
priority: 'high',
duration_h: 2,
assigned_group: 'Tech Targo',
depends_on_step: 0,
on_open_webhook: '',
on_close_webhook: '',
},
{
subject: 'Portage du numéro vers Gigafibre',
job_type: 'Autre',
priority: 'medium',
duration_h: 0.5,
assigned_group: 'Admin',
depends_on_step: 1,
on_open_webhook: '',
on_close_webhook: '',
},
{
subject: 'Validation et test du service téléphonique',
job_type: 'Dépannage',
priority: 'medium',
duration_h: 0.5,
assigned_group: 'Tech Targo',
depends_on_step: 2,
on_open_webhook: '',
on_close_webhook: '',
},
],
},
{
id: 'fiber_install',
name: 'Installation fibre résidentielle',
icon: 'cable',
description: 'Vérification pré-install, installation, activation, test de débit',
category: 'Internet',
steps: [
{
subject: 'Vérification pré-installation (éligibilité & OLT)',
job_type: 'Autre',
priority: 'medium',
duration_h: 0.5,
assigned_group: 'Admin',
depends_on_step: null,
on_open_webhook: '',
on_close_webhook: '',
},
{
subject: 'Installation fibre chez le client',
job_type: 'Installation',
priority: 'high',
duration_h: 3,
assigned_group: 'Tech Targo',
depends_on_step: 0,
on_open_webhook: '',
on_close_webhook: '',
},
{
subject: 'Activation du service & configuration ONT',
job_type: 'Installation',
priority: 'high',
duration_h: 0.5,
assigned_group: 'Admin',
depends_on_step: 1,
on_open_webhook: '',
on_close_webhook: '',
},
{
subject: 'Test de débit & validation client',
job_type: 'Dépannage',
priority: 'medium',
duration_h: 0.5,
assigned_group: 'Tech Targo',
depends_on_step: 2,
on_open_webhook: '',
on_close_webhook: '',
},
],
},
{
id: 'move_service',
name: 'Déménagement de service',
icon: 'local_shipping',
description: 'Retrait ancien site, installation nouveau site, transfert abonnement',
category: 'Déménagement',
steps: [
{
subject: 'Préparation déménagement (vérifier éligibilité nouveau site)',
job_type: 'Autre',
priority: 'medium',
duration_h: 0.5,
assigned_group: 'Admin',
depends_on_step: null,
on_open_webhook: '',
on_close_webhook: '',
},
{
subject: 'Retrait équipement ancien site',
job_type: 'Retrait',
priority: 'medium',
duration_h: 1,
assigned_group: 'Tech Targo',
depends_on_step: 0,
on_open_webhook: '',
on_close_webhook: '',
},
{
subject: 'Installation au nouveau site',
job_type: 'Installation',
priority: 'high',
duration_h: 3,
assigned_group: 'Tech Targo',
depends_on_step: 1,
on_open_webhook: '',
on_close_webhook: '',
},
{
subject: 'Transfert abonnement & mise à jour adresse',
job_type: 'Autre',
priority: 'medium',
duration_h: 0.5,
assigned_group: 'Admin',
depends_on_step: 2,
on_open_webhook: '',
on_close_webhook: '',
},
],
},
{
id: 'repair_service',
name: 'Réparation service client',
icon: 'build',
description: 'Diagnostic, intervention terrain, validation',
category: 'Support',
steps: [
{
subject: 'Diagnostic à distance',
job_type: 'Dépannage',
priority: 'high',
duration_h: 0.5,
assigned_group: 'Admin',
depends_on_step: null,
on_open_webhook: '',
on_close_webhook: '',
},
{
subject: 'Intervention terrain',
job_type: 'Réparation',
priority: 'high',
duration_h: 2,
assigned_group: 'Tech Targo',
depends_on_step: 0,
on_open_webhook: '',
on_close_webhook: '',
},
{
subject: 'Validation & suivi client',
job_type: 'Dépannage',
priority: 'medium',
duration_h: 0.5,
assigned_group: 'Admin',
depends_on_step: 1,
on_open_webhook: '',
on_close_webhook: '',
},
],
},
]
export const ASSIGNED_GROUPS = [
'Admin',
'Tech Targo',
'Support',
'NOC',
'Facturation',
]

View File

@ -0,0 +1,37 @@
/**
* Static table column definitions for ClientDetailPage tables.
*/
import { decodeHtml } from 'src/composables/useFormatters'
export const invoiceCols = [
{ name: 'name', label: 'N°', field: 'name', align: 'left' },
{ name: 'posting_date', label: 'Date', field: 'posting_date', align: 'left', sortable: true },
{ name: 'grand_total', label: 'Total', field: 'grand_total', align: 'right', sortable: true },
{ name: 'outstanding_amount', label: 'Solde', field: 'outstanding_amount', align: 'right' },
{ name: 'status', label: 'Statut', field: 'status', align: 'center' },
]
export const paymentCols = [
{ name: 'name', label: 'N°', field: 'name', align: 'left' },
{ name: 'posting_date', label: 'Date', field: 'posting_date', align: 'left', sortable: true },
{ name: 'paid_amount', label: 'Montant', field: 'paid_amount', align: 'right' },
{ name: 'mode_of_payment', label: 'Mode', field: 'mode_of_payment', align: 'left' },
{ name: 'reference_no', label: 'Référence', field: 'reference_no', align: 'left' },
]
export const invItemCols = [
{ name: 'item_name', label: 'Article', field: r => decodeHtml(r.item_name || r.item_code), align: 'left' },
{ name: 'qty', label: 'Qte', field: 'qty', align: 'center' },
{ name: 'rate', label: 'Prix unit.', field: 'rate', align: 'right' },
{ name: 'amount', label: 'Montant', field: 'amount', align: 'right' },
]
export const ticketCols = [
{ name: 'important', label: '', field: 'is_important', align: 'center', style: 'width:20px;padding:0 2px' },
{ name: 'legacy_id', label: '', field: 'legacy_ticket_id', align: 'right', style: 'width:48px;padding:0 4px' },
{ name: 'subject', label: 'Sujet', field: 'subject', align: 'left' },
{ name: 'assigned', label: '', field: 'assigned_staff', align: 'center', style: 'width:48px;padding:0 2px' },
{ name: 'opening_date', label: '', field: 'opening_date', align: 'left', style: 'width:76px;white-space:nowrap;padding:0 4px' },
{ name: 'priority', label: '', field: 'priority', align: 'center', style: 'width:24px;padding:0 2px' },
{ name: 'status', label: '', field: 'status', align: 'center', style: 'width:64px;padding:0 4px' },
]

View File

@ -0,0 +1,55 @@
export const statusOptions = [
{ label: 'Tous', value: 'all' },
{ label: 'Non fermes', value: 'not_closed' },
{ label: 'Ouverts', value: 'Open' },
{ label: 'Repondus', value: 'Replied' },
{ label: 'Resolus', value: 'Resolved' },
{ label: 'Fermes', value: 'Closed' },
]
export const priorityOptions = [
{ label: 'Urgent', value: 'Urgent' },
{ label: 'Haute', value: 'High' },
{ label: 'Moyenne', value: 'Medium' },
{ label: 'Basse', value: 'Low' },
]
export const columns = [
{ name: 'important', label: '', field: 'is_important', align: 'center', style: 'width:30px;padding:0' },
{ name: 'legacy_id', label: '#', field: 'legacy_ticket_id', align: 'left', sortable: true, style: 'width:70px' },
{ name: 'subject', label: 'Sujet', field: 'subject', align: 'left' },
{ name: 'customer_name', label: 'Client', field: 'customer_name', align: 'left', sortable: true },
{ name: 'issue_type', label: 'Type', field: 'issue_type', align: 'left' },
{ name: 'opening_date', label: 'Date', field: 'opening_date', align: 'left', sortable: true },
{ name: 'priority', label: 'Priorite', field: 'priority', align: 'center', sortable: true },
{ name: 'status', label: 'Statut', field: 'status', align: 'center' },
]
export function buildFilters ({ statusFilter, typeFilter, priorityFilter, search, ownerFilter }) {
const filters = {}
if (statusFilter === 'not_closed') {
filters.status = ['!=', 'Closed']
} else if (statusFilter !== 'all') {
filters.status = statusFilter
}
if (typeFilter) filters.issue_type = typeFilter
if (priorityFilter) filters.priority = priorityFilter
if (search?.trim()) {
const q = search.trim()
if (/^\d+$/.test(q)) {
filters.legacy_ticket_id = parseInt(q)
} else {
filters.subject = ['like', '%' + q + '%']
}
}
if (ownerFilter === 'mine') {
filters.owner = ['like', '%']
}
return filters
}
export function getSortField (col) {
if (col === 'opening_date') return 'creation'
if (col === 'legacy_id') return 'legacy_ticket_id'
return col || 'creation'
}

View File

@ -1,16 +1,30 @@
// Targo Ops Global styles // Targo Ops Global styles
:root { :root {
--ops-primary: #1e293b; // Shared dark palette (sidebar + dispatch)
--ops-sidebar-bg: #111422;
--ops-sidebar-hover: rgba(255,255,255,0.06);
--ops-sidebar-border: rgba(255,255,255,0.06);
--ops-sidebar-text: rgba(255,255,255,0.55);
--ops-sidebar-text-active: #ffffff;
// Accent & semantic
--ops-accent: #6366f1; --ops-accent: #6366f1;
--ops-success: #10b981; --ops-success: #10b981;
--ops-warning: #f59e0b; --ops-warning: #f59e0b;
--ops-danger: #ef4444; --ops-danger: #ef4444;
// Light content area
--ops-bg-hover: #eef2ff;
--ops-bg-light: #f8fafc;
--ops-bg: #f8fafc; --ops-bg: #f8fafc;
--ops-surface: #ffffff; --ops-surface: #ffffff;
--ops-border: #e2e8f0; --ops-border: #e2e8f0;
--ops-text: #1e293b; --ops-text: #1e293b;
--ops-text-muted: #64748b; --ops-text-muted: #64748b;
// Legacy alias
--ops-primary: #111422;
} }
body { body {
@ -18,23 +32,78 @@ body {
color: var(--ops-text); color: var(--ops-text);
} }
// Sidebar // Sidebar
.ops-sidebar { .ops-sidebar {
background: var(--ops-primary); background: var(--ops-sidebar-bg) !important;
width: 220px; border-right: 1px solid var(--ops-sidebar-border) !important;
transition: width 0.2s ease;
// Kill Quasar's default white border
&.q-drawer--bordered { border-right-color: var(--ops-sidebar-border) !important; }
.q-list { padding-top: 0; }
.q-item { .q-item {
color: rgba(255,255,255,0.7); color: var(--ops-sidebar-text);
border-radius: 8px; border-radius: 8px;
margin: 2px 8px; margin: 2px 8px;
&:hover { background: rgba(255,255,255,0.08); } min-height: 40px;
transition: all 0.15s ease;
&:hover { background: var(--ops-sidebar-hover); color: rgba(255,255,255,0.8); }
&.active-link { &.active-link {
color: #fff; color: var(--ops-sidebar-text-active);
background: var(--ops-accent); background: var(--ops-accent);
} }
} }
.q-separator--dark { background: var(--ops-sidebar-border) !important; }
// Collapsed state
&.ops-sidebar-mini {
.q-item {
margin: 2px 6px;
padding: 8px 0;
justify-content: center;
.q-item__section--avatar { min-width: unset; padding-right: 0; }
}
}
} }
// Cards // Sidebar bottom section
.ops-sidebar-bottom {
position: absolute; bottom: 0; left: 0; right: 0;
padding: 8px 0;
border-top: 1px solid var(--ops-sidebar-border);
.q-item {
color: var(--ops-sidebar-text);
font-size: 0.78rem;
&:hover { color: rgba(255,255,255,0.8); }
}
}
.ops-collapse-btn {
color: var(--ops-sidebar-text) !important;
opacity: 0.7;
&:hover { opacity: 1; }
}
// Mobile header
.ops-mobile-header {
background: var(--ops-sidebar-bg) !important;
border-bottom: 1px solid var(--ops-sidebar-border) !important;
}
// Desktop top bar
.ops-topbar {
display: flex;
align-items: center;
padding: 8px 24px;
border-bottom: 1px solid var(--ops-border);
background: var(--ops-surface);
position: relative;
}
// Cards
.ops-card { .ops-card {
background: var(--ops-surface); background: var(--ops-surface);
border: 1px solid var(--ops-border); border: 1px solid var(--ops-border);
@ -42,37 +111,25 @@ body {
padding: 16px; padding: 16px;
} }
// Stat cards // Stat cards
.ops-stat { .ops-stat {
text-align: center; text-align: center;
.ops-stat-value { .ops-stat-value { font-size: 1.8rem; font-weight: 700; line-height: 1.2; }
font-size: 1.8rem; .ops-stat-label { font-size: 0.8rem; color: var(--ops-text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
font-weight: 700;
line-height: 1.2;
}
.ops-stat-label {
font-size: 0.8rem;
color: var(--ops-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
} }
// Data tables // Data tables
.ops-table { .ops-table {
.q-table__top { padding: 8px 16px; } .q-table__top { padding: 8px 16px; }
th { font-weight: 600; color: var(--ops-text-muted); font-size: 0.75rem; text-transform: uppercase; } th { font-weight: 600; color: var(--ops-text-muted); font-size: 0.75rem; text-transform: uppercase; }
td { font-size: 0.875rem; } td { font-size: 0.875rem; }
} }
// Status badges // Status badges
.ops-badge { .ops-badge {
display: inline-flex; display: inline-flex; align-items: center;
align-items: center; padding: 2px 10px; border-radius: 20px;
padding: 2px 10px; font-size: 0.75rem; font-weight: 600;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
&.active { background: #d1fae5; color: #065f46; } &.active { background: #d1fae5; color: #065f46; }
&.inactive { background: #fee2e2; color: #991b1b; } &.inactive { background: #fee2e2; color: #991b1b; }
&.draft { background: #e0e7ff; color: #3730a3; } &.draft { background: #e0e7ff; color: #3730a3; }
@ -80,7 +137,7 @@ body {
&.closed { background: #f1f5f9; color: #475569; } &.closed { background: #f1f5f9; color: #475569; }
} }
// Search bar // Search bar
.ops-search { .ops-search {
.q-field__control { .q-field__control {
border-radius: 10px; border-radius: 10px;
@ -88,3 +145,79 @@ body {
border: 1px solid var(--ops-border); border: 1px solid var(--ops-border);
} }
} }
.ops-search-dark {
.q-field__control {
border-radius: 10px;
background: rgba(255,255,255,0.06);
border: 1px solid var(--ops-sidebar-border);
}
.q-field__native { color: #fff; }
}
// Search results dropdown
.ops-search-results {
background: #fff;
border: 1px solid var(--ops-border);
border-top: none;
border-radius: 0 0 10px 10px;
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
max-height: 400px;
overflow-y: auto;
}
.ops-search-results-desktop {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 9999;
}
.ops-search-result {
display: flex;
align-items: center;
padding: 10px 14px;
cursor: pointer;
transition: background 0.1s;
&:hover, &.ops-search-highlighted { background: #f1f5f9; }
&:not(:last-child) { border-bottom: 1px solid #f1f5f9; }
}
.ops-search-title { font-size: 0.875rem; font-weight: 600; color: var(--ops-text); line-height: 1.2; }
.ops-search-sub { font-size: 0.75rem; color: var(--ops-text-muted); }
.ops-search-type {
font-size: 0.65rem; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.05em; color: var(--ops-text-muted); margin-left: auto; white-space: nowrap;
}
// ERP links
.erp-link {
color: var(--ops-accent, #6366f1);
text-decoration: none;
cursor: pointer;
&:hover { text-decoration: underline; }
}
// Clickable table rows
.clickable-table :deep(tbody tr) {
cursor: pointer;
&:hover td { background: var(--ops-bg-hover, #eef2ff) !important; }
}
// Sticky Convos panel (right column)
.convos-sticky {
position: sticky;
top: 16px;
max-height: calc(100vh - 32px);
overflow: hidden;
display: flex;
flex-direction: column;
.chatter-panel {
display: flex;
flex-direction: column;
max-height: calc(100vh - 32px);
overflow: hidden;
.chatter-timeline {
flex: 1;
overflow-y: auto;
}
}
}

View File

@ -1,89 +1,60 @@
<template> <template>
<q-layout view="lHh LpR fFf"> <q-layout view="lHh LpR fFf">
<!-- Sidebar --> <!-- Collapsible Sidebar -->
<q-drawer v-model="drawer" :width="220" :breakpoint="1024" bordered class="ops-sidebar"> <q-drawer v-model="drawer" :width="sidebarW" :breakpoint="1024" class="ops-sidebar" :class="{ 'ops-sidebar-mini': collapsed }">
<q-list> <q-list>
<!-- Logo --> <q-item class="q-py-md q-mb-sm" style="pointer-events:none" :class="{ 'justify-center': collapsed }">
<q-item class="q-py-md q-mb-sm" style="pointer-events:none"> <q-item-section avatar><q-icon name="hub" size="28px" color="white" /></q-item-section>
<q-item-section avatar> <q-item-section v-if="!collapsed"><q-item-label style="color:#fff;font-size:1.1rem;font-weight:700">Targo Ops</q-item-label></q-item-section>
<q-icon name="hub" size="28px" color="white" />
</q-item-section>
<q-item-section>
<q-item-label style="color:#fff;font-size:1.1rem;font-weight:700">Targo Ops</q-item-label>
</q-item-section>
</q-item> </q-item>
<q-separator dark class="q-mb-sm" /> <q-item v-for="nav in navItems" :key="nav.path" clickable :to="nav.path"
:class="{ 'active-link': isActive(nav.path), 'justify-center': collapsed }"
<!-- Nav items --> :title="collapsed ? nav.label : undefined">
<q-item <q-item-section avatar><component :is="icons[nav.icon]" :size="20" /></q-item-section>
v-for="nav in navItems" :key="nav.path" <q-item-section v-if="!collapsed"><q-item-label>{{ nav.label }}</q-item-label></q-item-section>
clickable :to="nav.path" <q-item-section side v-if="nav.badge && !collapsed"><q-badge color="red" :label="nav.badge" rounded /></q-item-section>
:class="{ 'active-link': $route.path === nav.path }" <q-tooltip v-if="collapsed" anchor="center right" self="center left" :offset="[8, 0]">{{ nav.label }}</q-tooltip>
>
<q-item-section avatar>
<q-icon :name="nav.icon" size="22px" />
</q-item-section>
<q-item-section>
<q-item-label>{{ nav.label }}</q-item-label>
</q-item-section>
<q-item-section side v-if="nav.badge">
<q-badge color="red" :label="nav.badge" rounded />
</q-item-section>
</q-item> </q-item>
</q-list> </q-list>
<!-- Bottom: user --> <div class="ops-sidebar-bottom">
<template #mini><!-- prevent mini drawer --></template> <q-item dense clickable @click="toggleCollapse" class="ops-collapse-btn" :class="{ 'justify-center': collapsed }">
<div style="position:absolute;bottom:0;left:0;right:0;padding:12px">
<q-separator dark class="q-mb-sm" />
<q-item dense clickable @click="auth.doLogout()">
<q-item-section avatar> <q-item-section avatar>
<q-icon name="logout" size="20px" color="grey-6" /> <component :is="collapsed ? icons.PanelLeftOpen : icons.PanelLeftClose" :size="16" />
</q-item-section>
<q-item-section>
<q-item-label style="color:rgba(255,255,255,0.5);font-size:0.8rem">
{{ auth.user || 'User' }}
</q-item-label>
</q-item-section> </q-item-section>
<q-item-section v-if="!collapsed"><q-item-label>Réduire</q-item-label></q-item-section>
<q-tooltip v-if="collapsed" anchor="center right" self="center left" :offset="[8, 0]">Agrandir le menu</q-tooltip>
</q-item>
<q-item dense clickable @click="auth.doLogout()" :class="{ 'justify-center': collapsed }">
<q-item-section avatar><component :is="icons.LogOut" :size="16" /></q-item-section>
<q-item-section v-if="!collapsed"><q-item-label>{{ auth.user || 'User' }}</q-item-label></q-item-section>
<q-tooltip v-if="collapsed" anchor="center right" self="center left" :offset="[8, 0]">{{ auth.user || 'Déconnexion' }}</q-tooltip>
</q-item> </q-item>
</div> </div>
</q-drawer> </q-drawer>
<!-- Header (mobile) --> <!-- Mobile header -->
<q-header v-if="$q.screen.lt.lg" class="bg-white text-dark" bordered> <q-header v-if="$q.screen.lt.lg" class="ops-mobile-header">
<q-toolbar> <q-toolbar>
<q-btn flat round dense icon="menu" @click="drawer = !drawer" /> <q-btn flat round dense icon="menu" color="white" @click="drawer = !drawer" />
<q-toolbar-title class="text-weight-bold" style="font-size:1rem"> <q-toolbar-title class="text-weight-bold" style="font-size:1rem;color:#fff">{{ currentNav?.label || 'Targo Ops' }}</q-toolbar-title>
{{ currentNav?.label || 'Targo Ops' }}
</q-toolbar-title>
<q-space /> <q-space />
<q-btn flat round dense icon="search" @click="showMobileSearch = !showMobileSearch" /> <q-btn v-if="!isDispatch" flat round dense icon="search" color="white" @click="mobileSearchOpen = !mobileSearchOpen" />
</q-toolbar> </q-toolbar>
<!-- Mobile search dropdown --> <div v-if="mobileSearchOpen && !isDispatch" class="q-px-sm q-pb-sm" style="background:var(--ops-sidebar-bg)">
<div v-if="showMobileSearch" class="q-px-sm q-pb-sm bg-white"> <q-input v-model="searchQuery" placeholder="Rechercher client, adresse..." dense outlined dark autofocus class="ops-search-dark"
<q-input @keyup.enter="doSearch" @keydown.escape="closeMobileSearch">
ref="mobileSearchRef" <template #prepend><q-icon name="search" color="grey-5" /></template>
v-model="globalSearch" <template #append v-if="searchQuery"><q-icon name="close" class="cursor-pointer" color="grey-5" @click="clearSearch" /></template>
placeholder="Rechercher client, adresse..."
dense outlined autofocus
class="ops-search"
@update:model-value="onSearchInput"
@keyup.enter="goToFirstResult"
@blur="onSearchBlur"
>
<template #prepend><q-icon name="search" color="grey-6" /></template>
<template #append v-if="globalSearch">
<q-icon name="close" class="cursor-pointer" @click="clearSearch" />
</template>
</q-input> </q-input>
<div v-if="searchResults.length" class="search-dropdown"> <div v-if="searchResults.length" class="ops-search-results">
<div v-for="r in searchResults" :key="r.id" class="search-result" @mousedown="goToResult(r)"> <div v-for="r in searchResults" :key="r.id" class="ops-search-result" @mousedown="goToResult(r)">
<q-icon :name="r.icon" size="18px" color="grey-6" class="q-mr-sm" /> <q-icon :name="r.icon" size="18px" :color="r.type === 'customer' ? 'indigo-5' : 'teal-5'" class="q-mr-sm" />
<div> <div style="flex:1;min-width:0">
<div class="search-result-title">{{ r.title }}</div> <div class="ops-search-title">{{ r.title }}</div>
<div class="search-result-sub">{{ r.sub }}</div> <div class="ops-search-sub">{{ r.sub }}</div>
</div> </div>
</div> </div>
</div> </div>
@ -92,114 +63,36 @@
<!-- Main content --> <!-- Main content -->
<q-page-container> <q-page-container>
<!-- Top bar (desktop) --> <!-- Desktop top bar (hidden on dispatch) -->
<div v-if="$q.screen.gt.md" class="row items-center q-px-lg q-py-sm" style="border-bottom:1px solid var(--ops-border);position:relative"> <div v-if="$q.screen.gt.md && !isDispatch" class="ops-topbar">
<div class="text-h6 text-weight-bold">{{ currentNav?.label || '' }}</div> <div class="text-h6 text-weight-bold">{{ currentNav?.label || '' }}</div>
<q-space /> <q-space />
<div style="position:relative;width:440px"> <div style="position:relative;width:400px">
<q-input <q-input v-model="searchQuery" placeholder="Rechercher client, adresse, ticket..." dense outlined class="ops-search"
ref="desktopSearchRef" @keyup.enter="doSearch" @keydown.escape="clearSearch"
v-model="globalSearch" @update:model-value="onSearchInput" @blur="onSearchBlur">
placeholder="Rechercher client, adresse, ticket..." <template #prepend><q-icon name="search" color="grey-6" /></template>
dense outlined <template #append v-if="searchQuery">
class="ops-search" <q-icon name="close" class="cursor-pointer" @click="clearSearch" />
@update:model-value="onSearchInput"
@keyup.enter="goToFirstResult"
@keydown.down.prevent="highlightNext"
@keydown.up.prevent="highlightPrev"
@keydown.escape="clearSearch"
@focus="onSearchFocus"
@blur="onSearchBlur"
>
<template #prepend>
<q-icon name="search" color="grey-6" />
</template>
<template #append>
<q-spinner v-if="searching" size="16px" color="grey-5" />
<q-icon v-else-if="globalSearch" name="close" class="cursor-pointer" @click="clearSearch" />
<q-icon
:name="showAdvanced ? 'expand_less' : 'tune'"
class="cursor-pointer q-ml-xs"
color="grey-6"
size="18px"
@click.stop="toggleAdvanced"
/>
</template> </template>
</q-input> </q-input>
<!-- Dropdown results --> <div v-if="searchResults.length && searchDropdownOpen" class="ops-search-results ops-search-results-desktop">
<div v-if="showDropdown && searchResults.length" class="search-dropdown"> <div v-for="(r, i) in searchResults" :key="r.id" class="ops-search-result"
<div :class="{ 'ops-search-highlighted': i === highlightIdx }"
v-for="(r, i) in searchResults" :key="r.id" @mousedown="goToResult(r)">
class="search-result"
:class="{ highlighted: i === highlightIndex }"
@mousedown="goToResult(r)"
>
<q-icon :name="r.icon" size="18px" :color="r.type === 'customer' ? 'indigo-5' : 'teal-5'" class="q-mr-sm" /> <q-icon :name="r.icon" size="18px" :color="r.type === 'customer' ? 'indigo-5' : 'teal-5'" class="q-mr-sm" />
<div class="col"> <div style="flex:1;min-width:0">
<div class="search-result-title">{{ r.title }}</div> <div class="ops-search-title">{{ r.title }}</div>
<div class="search-result-sub">{{ r.sub }}</div> <div class="ops-search-sub">{{ r.sub }}</div>
</div> </div>
<div class="search-result-type">{{ r.typeLabel }}</div> <div class="ops-search-type">{{ r.typeLabel }}</div>
</div> </div>
</div> </div>
<div v-else-if="showDropdown && globalSearch.length >= 2 && !searching" class="search-dropdown"> <div v-else-if="searchDropdownOpen && searchQuery.length >= 2 && !searchResults.length && !searchLoading" class="ops-search-results ops-search-results-desktop">
<div class="search-result text-grey-5" style="justify-content:center">Aucun résultat</div> <div class="ops-search-result" style="justify-content:center;color:#94a3b8">Aucun résultat</div>
</div>
<!-- Advanced search panel -->
<div v-if="showAdvanced" class="advanced-search-panel" @mousedown.prevent>
<div class="text-weight-bold q-mb-sm" style="font-size:0.85rem;color:var(--ops-text)">Recherche avancée</div>
<div class="row q-col-gutter-sm">
<div class="col-6">
<q-input v-model="adv.customerName" label="Nom client" dense outlined class="adv-input" @keyup.enter="runAdvancedSearch" />
</div>
<div class="col-6">
<q-input v-model="adv.customerId" label="# Client (ID)" dense outlined class="adv-input" @keyup.enter="runAdvancedSearch" />
</div>
<div class="col-6">
<q-input v-model="adv.address" label="Adresse" dense outlined class="adv-input" @keyup.enter="runAdvancedSearch" />
</div>
<div class="col-6">
<q-input v-model="adv.city" label="Ville" dense outlined class="adv-input" @keyup.enter="runAdvancedSearch" />
</div>
<div class="col-6">
<q-input v-model="adv.postalCode" label="Code postal" dense outlined class="adv-input" @keyup.enter="runAdvancedSearch" />
</div>
<div class="col-6">
<q-select v-model="adv.territory" label="Territoire" dense outlined emit-value map-options :options="territoryOptions" clearable class="adv-input" @keyup.enter="runAdvancedSearch" />
</div>
<div class="col-6">
<q-select v-model="adv.status" label="Statut adresse" dense outlined emit-value map-options :options="statusOptions" clearable class="adv-input" />
</div>
<div class="col-6">
<q-select v-model="adv.customerType" label="Type client" dense outlined emit-value map-options :options="[{label:'Individu',value:'Individual'},{label:'Entreprise',value:'Company'}]" clearable class="adv-input" />
</div>
</div>
<div class="row q-mt-sm q-gutter-sm justify-end">
<q-btn flat dense label="Effacer" color="grey-7" size="sm" @click="clearAdvanced" />
<q-btn unelevated dense label="Rechercher" color="primary" size="sm" :loading="advSearching" @click="runAdvancedSearch" />
</div>
<!-- Advanced results -->
<div v-if="advResults.length" class="q-mt-sm" style="max-height:300px;overflow-y:auto">
<div
v-for="r in advResults" :key="r.id"
class="search-result"
@click="goToResult(r); showAdvanced = false"
>
<q-icon :name="r.icon" size="18px" :color="r.type === 'customer' ? 'indigo-5' : 'teal-5'" class="q-mr-sm" />
<div class="col">
<div class="search-result-title">{{ r.title }}</div>
<div class="search-result-sub">{{ r.sub }}</div>
</div>
<div class="search-result-type">{{ r.typeLabel }}</div>
</div>
</div>
<div v-else-if="advSearched && !advSearching" class="text-grey-5 text-center q-py-sm" style="font-size:0.8rem">
Aucun résultat
</div> </div>
</div> </div>
</div> </div>
</div>
<router-view /> <router-view />
</q-page-container> </q-page-container>
@ -207,384 +100,126 @@
</template> </template>
<script setup> <script setup>
import { ref, reactive, computed } from 'vue' import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from 'src/stores/auth' import { useAuthStore } from 'src/stores/auth'
import { listDocs } from 'src/api/erp' import { listDocs } from 'src/api/erp'
import { navItems } from 'src/config/nav'
import {
LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3,
ScanText, Settings, LogOut, PanelLeftOpen, PanelLeftClose,
} from 'lucide-vue-next'
const icons = { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, ScanText, Settings, LogOut, PanelLeftOpen, PanelLeftClose }
const auth = useAuthStore() const auth = useAuthStore()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const drawer = ref(true) const drawer = ref(true)
const showMobileSearch = ref(false) const collapsed = ref(localStorage.getItem('ops-sidebar-collapsed') !== 'false')
const globalSearch = ref('')
const sidebarW = computed(() => collapsed.value ? 64 : 220)
const isDispatch = computed(() => route.path === '/dispatch')
const currentNav = computed(() =>
navItems.find(n => n.path === route.path) || navItems.find(n => route.path.startsWith(n.path) && n.path !== '/')
)
function isActive (path) {
if (path === '/') return route.path === '/'
return route.path === path || route.path.startsWith(path + '/')
}
function toggleCollapse () {
collapsed.value = !collapsed.value
localStorage.setItem('ops-sidebar-collapsed', collapsed.value ? 'true' : 'false')
}
// Simple inline search (no composable, no external state)
const searchQuery = ref('')
const searchResults = ref([]) const searchResults = ref([])
const searching = ref(false) const searchLoading = ref(false)
const showDropdown = ref(false) const searchDropdownOpen = ref(false)
const highlightIndex = ref(-1) const highlightIdx = ref(-1)
const mobileSearchOpen = ref(false)
// Advanced search
const showAdvanced = ref(false)
const advSearching = ref(false)
const advSearched = ref(false)
const advResults = ref([])
const adv = reactive({
customerName: '',
customerId: '',
address: '',
city: '',
postalCode: '',
territory: null,
status: null,
customerType: null,
})
const territoryOptions = [
{ label: 'Gatineau', value: 'Gatineau' },
{ label: 'Ottawa', value: 'Ottawa' },
{ label: 'Aylmer', value: 'Aylmer' },
{ label: 'Hull', value: 'Hull' },
{ label: 'Buckingham', value: 'Buckingham' },
{ label: 'Masson-Angers', value: 'Masson-Angers' },
]
const statusOptions = [
{ label: 'Actif', value: 'Active' },
{ label: 'Inactif', value: 'Inactive' },
{ label: 'En attente', value: 'Pending' },
]
let searchTimer = null let searchTimer = null
const navItems = [
{ path: '/', icon: 'dashboard', label: 'Tableau de bord' },
{ path: '/clients', icon: 'people', label: 'Clients' },
{ path: '/dispatch', icon: 'local_shipping', label: 'Dispatch' },
{ path: '/tickets', icon: 'confirmation_number', label: 'Tickets' },
{ path: '/equipe', icon: 'groups', label: 'Équipe' },
{ path: '/rapports', icon: 'bar_chart', label: 'Rapports' },
{ path: '/ocr', icon: 'document_scanner', label: 'OCR Factures' },
]
const currentNav = computed(() => navItems.find(n => n.path === route.path) || navItems.find(n => route.path.startsWith(n.path) && n.path !== '/'))
function onSearchInput (val) { function onSearchInput (val) {
highlightIndex.value = -1
clearTimeout(searchTimer) clearTimeout(searchTimer)
// Easter egg: @targo Authentik admin login highlightIdx.value = -1
if (val && val.trim().toLowerCase() === '@targo') {
clearSearch()
window.location.href = 'https://auth.targo.ca/if/flow/default-authentication-flow/?next=' + encodeURIComponent(window.location.href)
return
}
if (!val || val.length < 2) { if (!val || val.length < 2) {
searchResults.value = [] searchResults.value = []
showDropdown.value = false searchDropdownOpen.value = false
searchLoading.value = false
return return
} }
showDropdown.value = true searchDropdownOpen.value = true
showAdvanced.value = false searchLoading.value = true
searching.value = true searchTimer = setTimeout(() => runSearch(val), 300)
searchTimer = setTimeout(() => doSearch(val), 300)
} }
async function doSearch (query) { async function runSearch (q) {
const q = query.trim() if (!q || q.length < 2) { searchLoading.value = false; return }
if (q.length < 2) { searching.value = false; return }
try { try {
// Search customers by name AND by ID, plus locations by address, city, and postal code const cf = ['name', 'customer_name', 'customer_type', 'territory', 'disabled']
const [custByName, custById, locByAddr, locByCity] = await Promise.all([ const lf = ['name', 'address_line', 'city', 'customer', 'customer_name', 'status']
listDocs('Customer', { const timeout = new Promise((_, r) => setTimeout(() => r(new Error('timeout')), 4000))
filters: { customer_name: ['like', '%' + q + '%'] }, const [cName, cId, lAddr, lCity] = await Promise.race([
fields: ['name', 'customer_name', 'customer_type', 'territory', 'disabled'], Promise.all([
limit: 6, listDocs('Customer', { filters: { customer_name: ['like', '%' + q + '%'] }, fields: cf, limit: 6, orderBy: 'customer_name asc' }).catch(() => []),
orderBy: 'customer_name asc', listDocs('Customer', { filters: { name: ['like', '%' + q + '%'] }, fields: cf, limit: 4, orderBy: 'name asc' }).catch(() => []),
}).catch(() => []), listDocs('Service Location', { filters: { address_line: ['like', '%' + q + '%'] }, fields: lf, limit: 6, orderBy: 'address_line asc' }).catch(() => []),
listDocs('Customer', { listDocs('Service Location', { filters: { city: ['like', '%' + q + '%'] }, fields: lf, limit: 4, orderBy: 'city asc' }).catch(() => []),
filters: { name: ['like', '%' + q + '%'] }, ]),
fields: ['name', 'customer_name', 'customer_type', 'territory', 'disabled'], timeout,
limit: 4,
orderBy: 'name asc',
}).catch(() => []),
listDocs('Service Location', {
filters: { address_line: ['like', '%' + q + '%'] },
fields: ['name', 'address_line', 'city', 'customer', 'customer_name', 'status'],
limit: 6,
orderBy: 'address_line asc',
}).catch(() => []),
listDocs('Service Location', {
filters: { city: ['like', '%' + q + '%'] },
fields: ['name', 'address_line', 'city', 'customer', 'customer_name', 'status'],
limit: 4,
orderBy: 'city asc',
}).catch(() => []),
]) ])
// Deduplicate
const seen = new Set() const seen = new Set()
const results = [] const out = []
for (const c of [...cName, ...cId]) {
for (const c of [...custByName, ...custById]) { if (seen.has(c.name)) continue; seen.add(c.name)
if (seen.has(c.name)) continue out.push({ id: 'c-' + c.name, type: 'customer', typeLabel: 'Client', icon: 'person', title: c.customer_name, sub: c.name + (c.territory ? ' · ' + c.territory : ''), route: '/clients/' + c.name })
seen.add(c.name)
results.push({
id: 'c-' + c.name,
type: 'customer',
typeLabel: 'Client',
icon: 'person',
title: c.customer_name,
sub: c.name + (c.territory ? ' · ' + c.territory : '') + (c.disabled ? ' · Inactif' : ''),
route: '/clients/' + c.name,
})
} }
for (const l of [...lAddr, ...lCity]) {
for (const l of [...locByAddr, ...locByCity]) { if (seen.has(l.name)) continue; seen.add(l.name)
if (seen.has(l.name)) continue out.push({ id: 'l-' + l.name, type: 'location', typeLabel: 'Adresse', icon: 'location_on', title: l.address_line + (l.city ? ', ' + l.city : ''), sub: (l.customer_name || l.customer) + ' · ' + l.status, route: '/clients/' + l.customer })
seen.add(l.name)
results.push({
id: 'l-' + l.name,
type: 'location',
typeLabel: 'Adresse',
icon: 'location_on',
title: l.address_line + (l.city ? ', ' + l.city : ''),
sub: (l.customer_name || l.customer) + ' · ' + l.status,
route: '/clients/' + l.customer,
})
} }
searchResults.value = out.slice(0, 12)
searchResults.value = results.slice(0, 12)
} catch { } catch {
searchResults.value = [] searchResults.value = []
} }
searching.value = false searchLoading.value = false
} }
function toggleAdvanced () { function doSearch () {
showAdvanced.value = !showAdvanced.value if (searchResults.value.length) {
if (showAdvanced.value) { const idx = highlightIdx.value >= 0 ? highlightIdx.value : 0
showDropdown.value = false goToResult(searchResults.value[idx])
} else if (searchQuery.value.trim()) {
router.push({ path: '/clients', query: { q: searchQuery.value.trim() } })
clearSearch()
} }
} }
async function runAdvancedSearch () {
advSearching.value = true
advSearched.value = false
advResults.value = []
try {
const promises = []
// Build customer filters
const custFilters = {}
if (adv.customerName) custFilters.customer_name = ['like', '%' + adv.customerName + '%']
if (adv.customerId) custFilters.name = ['like', '%' + adv.customerId + '%']
if (adv.customerType) custFilters.customer_type = adv.customerType
if (adv.territory) custFilters.territory = adv.territory
// Build location filters
const locFilters = {}
if (adv.address) locFilters.address_line = ['like', '%' + adv.address + '%']
if (adv.city) locFilters.city = ['like', '%' + adv.city + '%']
if (adv.postalCode) locFilters.postal_code = ['like', '%' + adv.postalCode + '%']
if (adv.status) locFilters.status = adv.status
const hasCustFilter = Object.keys(custFilters).length > 0
const hasLocFilter = Object.keys(locFilters).length > 0
if (hasCustFilter) {
promises.push(
listDocs('Customer', {
filters: custFilters,
fields: ['name', 'customer_name', 'customer_type', 'territory', 'disabled'],
limit: 20,
orderBy: 'customer_name asc',
}).catch(() => [])
)
} else {
promises.push(Promise.resolve([]))
}
if (hasLocFilter) {
promises.push(
listDocs('Service Location', {
filters: locFilters,
fields: ['name', 'address_line', 'city', 'customer', 'customer_name', 'status'],
limit: 20,
orderBy: 'address_line asc',
}).catch(() => [])
)
} else {
promises.push(Promise.resolve([]))
}
const [customers, locations] = await Promise.all(promises)
const results = []
for (const c of customers) {
results.push({
id: 'c-' + c.name,
type: 'customer',
typeLabel: 'Client',
icon: 'person',
title: c.customer_name,
sub: c.name + (c.territory ? ' · ' + c.territory : '') + (c.disabled ? ' · Inactif' : ''),
route: '/clients/' + c.name,
})
}
for (const l of locations) {
results.push({
id: 'l-' + l.name,
type: 'location',
typeLabel: 'Adresse',
icon: 'location_on',
title: l.address_line + (l.city ? ', ' + l.city : ''),
sub: (l.customer_name || l.customer) + ' · ' + l.status,
route: '/clients/' + l.customer,
})
}
advResults.value = results
} catch {
advResults.value = []
}
advSearching.value = false
advSearched.value = true
}
function clearAdvanced () {
Object.assign(adv, { customerName: '', customerId: '', address: '', city: '', postalCode: '', territory: null, status: null, customerType: null })
advResults.value = []
advSearched.value = false
}
function goToResult (r) { function goToResult (r) {
router.push(r.route) router.push(r.route)
clearSearch() clearSearch()
} }
function goToFirstResult () { function clearSearch () {
if (highlightIndex.value >= 0 && searchResults.value[highlightIndex.value]) { searchQuery.value = ''
goToResult(searchResults.value[highlightIndex.value]) searchResults.value = []
} else if (searchResults.value.length) { searchDropdownOpen.value = false
goToResult(searchResults.value[0]) searchLoading.value = false
} else if (globalSearch.value.trim()) { highlightIdx.value = -1
router.push({ path: '/clients', query: { q: globalSearch.value.trim() } }) mobileSearchOpen.value = false
clearTimeout(searchTimer)
}
function closeMobileSearch () {
clearSearch() clearSearch()
} mobileSearchOpen.value = false
}
function highlightNext () {
if (searchResults.value.length) {
highlightIndex.value = (highlightIndex.value + 1) % searchResults.value.length
}
}
function highlightPrev () {
if (searchResults.value.length) {
highlightIndex.value = highlightIndex.value <= 0 ? searchResults.value.length - 1 : highlightIndex.value - 1
}
}
function onSearchFocus () {
if (globalSearch.value.length >= 2 && searchResults.value.length) {
showDropdown.value = true
}
} }
function onSearchBlur () { function onSearchBlur () {
setTimeout(() => { setTimeout(() => { searchDropdownOpen.value = false }, 200)
showDropdown.value = false
if (!showAdvanced.value) return
}, 200)
}
function clearSearch () {
globalSearch.value = ''
searchResults.value = []
showDropdown.value = false
highlightIndex.value = -1
showMobileSearch.value = false
} }
</script> </script>
<style lang="scss" scoped>
.search-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 9999;
background: #fff;
border: 1px solid var(--ops-border);
border-top: none;
border-radius: 0 0 10px 10px;
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
max-height: 400px;
overflow-y: auto;
}
.search-result {
display: flex;
align-items: center;
padding: 10px 14px;
cursor: pointer;
transition: background 0.1s;
&:hover, &.highlighted {
background: #f1f5f9;
}
&:not(:last-child) {
border-bottom: 1px solid #f1f5f9;
}
}
.search-result-title {
font-size: 0.875rem;
font-weight: 600;
color: var(--ops-text);
line-height: 1.2;
}
.search-result-sub {
font-size: 0.75rem;
color: var(--ops-text-muted);
}
.search-result-type {
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--ops-text-muted);
margin-left: auto;
white-space: nowrap;
}
.advanced-search-panel {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 9999;
background: #fff;
border: 1px solid var(--ops-border);
border-top: none;
border-radius: 0 0 12px 12px;
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
padding: 16px;
width: 520px;
right: auto;
}
.adv-input :deep(.q-field__label) {
font-size: 0.8rem;
}
.adv-input :deep(.q-field__native),
.adv-input :deep(.q-field__input) {
font-size: 0.85rem;
}
</style>

View File

@ -6,33 +6,25 @@
</div> </div>
<template v-else-if="customer"> <template v-else-if="customer">
<!-- HEADER --> <!-- TWO-COLUMN LAYOUT: Content left | Convos right -->
<CustomerHeader :customer="customer" :customer-groups="customerGroups" @save="saveCust" />
<!-- TOP ROW: Contact + Info + Billing KPIs -->
<div class="row q-col-gutter-md q-mb-md">
<div class="col-12 col-md-4">
<ContactCard :customer="customer" @save="saveCust" />
</div>
<div class="col-12 col-md-4">
<CustomerInfoCard :customer="customer" @save="saveCust" />
</div>
<div class="col-12 col-md-4">
<BillingKPIs :total-monthly="totalMonthly" :total-outstanding="totalOutstanding"
:invoice-count="invoices.length" :total-paid="totalPaid" />
</div>
</div>
<!-- MAIN CONTENT + NOTES PANEL (Gaiia-style layout) -->
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<!-- LEFT: Main content --> <!-- LEFT: Header + all content sections -->
<div class="col-12 col-lg-8"> <div class="col-12 col-lg-8">
<!-- DELIVERY LOCATIONS --> <!-- HEADER (with contact & info inside) -->
<div class="section-title q-mb-sm" style="font-size:1rem"> <CustomerHeader :customer="customer" :customer-groups="customerGroups">
<template #contact><ContactCard :customer="customer" /></template>
<template #info><CustomerInfoCard :customer="customer" /></template>
</CustomerHeader>
<!-- DELIVERY LOCATIONS (collapsible) -->
<q-expansion-item v-model="sectionsOpen.locations" header-class="section-header" class="q-mb-sm">
<template #header>
<div class="section-title" style="font-size:1rem;width:100%">
<q-icon name="location_on" size="20px" class="q-mr-xs" /> <q-icon name="location_on" size="20px" class="q-mr-xs" />
Lieux de service ({{ locations.length }}) Lieux de service ({{ locations.length }})
</div> </div>
</template>
<div v-if="!locations.length" class="ops-card text-center text-grey-6 q-pa-md q-mb-md"> <div v-if="!locations.length" class="ops-card text-center text-grey-6 q-pa-md q-mb-md">
Aucun lieu de service Aucun lieu de service
@ -265,6 +257,7 @@
</div><!-- /collapsible body --> </div><!-- /collapsible body -->
</div> </div>
</q-expansion-item>
<!-- TICKETS (collapsible) --> <!-- TICKETS (collapsible) -->
<q-expansion-item v-model="sectionsOpen.tickets" header-class="section-header" class="q-mb-sm"> <q-expansion-item v-model="sectionsOpen.tickets" header-class="section-header" class="q-mb-sm">
@ -410,88 +403,24 @@
</div> </div>
</q-expansion-item> </q-expansion-item>
</div><!-- /col-lg-8 main content --> </div><!-- /col-lg-8 left content -->
<!-- RIGHT: Notes panel (Gaiia-style) --> <!-- RIGHT: Convos panel (sticky) -->
<div class="col-12 col-lg-4"> <div class="col-12 col-lg-4">
<div class="notes-panel"> <div class="convos-sticky">
<div class="notes-panel-header"> <ChatterPanel
<span class="text-weight-bold" style="font-size:1.1rem">Notes</span> :customer-name="customer.name"
<q-space /> :customer-phone="customer.cell_phone || customer.tel_home || ''"
<span class="text-caption text-grey-5">{{ comments.length }}</span> :customer-phones="customerPhoneOptions"
</div> :customer-email="customer.email_billing || ''"
:comments="comments"
<!-- Quick add note --> @note-added="onNoteAdded"
<div class="note-input-wrap" @click="noteInputFocused = true"> @note-updated="onNoteAdded"
<div v-if="!noteInputFocused && !newNote" class="note-input-placeholder"> @navigate="(dt, name) => openModal(dt, name)"
Ajouter une note... />
</div>
<template v-else>
<q-input v-model="newNote" ref="noteInput" dense borderless placeholder="Écrire une note..."
type="textarea" autogrow :input-style="{ minHeight: '40px', maxHeight: '150px', fontSize: '0.875rem' }"
@blur="!newNote && (noteInputFocused = false)"
@keydown.ctrl.enter="addNote" @keydown.meta.enter="addNote" autofocus />
<div class="row items-center q-mt-xs">
<q-space />
<q-btn flat dense size="sm" label="Annuler" color="grey" @click="newNote = ''; noteInputFocused = false" class="q-mr-xs" />
<q-btn unelevated dense size="sm" label="Enregistrer" color="indigo-6" :disable="!newNote?.trim()" @click="addNote" :loading="addingNote" />
</div>
</template>
</div>
<!-- Notes list -->
<div class="notes-list">
<div v-if="!comments.length" class="text-center text-grey-5 q-pa-lg text-caption">
Aucune note pour ce client
</div>
<div v-for="c in sortedComments" :key="c.name" class="note-item" :class="{ 'note-sticky': c._sticky }">
<div class="note-header">
<div class="note-avatar">
<q-icon name="person" size="18px" color="grey-6" />
</div>
<div class="col">
<div class="text-weight-bold text-body2">{{ noteAuthorName(c.comment_by) }}</div>
<div class="text-caption text-grey-5">{{ noteTimeAgo(c.creation) }}</div>
</div>
<q-icon v-if="c._sticky" name="push_pin" size="14px" color="amber-8" class="q-mr-xs" />
<q-btn flat dense round size="xs" icon="more_vert" class="note-menu-btn">
<q-menu>
<q-list dense style="min-width:150px">
<q-item clickable v-close-popup @click="startEditNote(c)">
<q-item-section avatar><q-icon name="edit" size="18px" color="grey-7" /></q-item-section>
<q-item-section>Modifier</q-item-section>
</q-item>
<q-item clickable v-close-popup @click="toggleStickyNote(c)">
<q-item-section avatar><q-icon name="push_pin" size="18px" :color="c._sticky ? 'amber-8' : 'grey'" /></q-item-section>
<q-item-section>{{ c._sticky ? 'Désépingler' : 'Épingler' }}</q-item-section>
</q-item>
<q-separator />
<q-item clickable v-close-popup @click="deleteNote(c)">
<q-item-section avatar><q-icon name="delete" size="18px" color="red" /></q-item-section>
<q-item-section class="text-red">Supprimer</q-item-section>
</q-item>
</q-list>
</q-menu>
</q-btn>
</div>
<!-- Edit mode -->
<div v-if="editingNote === c.name" class="q-mt-xs">
<q-input v-model="c._editContent" dense outlined type="textarea" autogrow
:input-style="{ fontSize: '0.85rem', minHeight: '40px' }" autofocus
@keydown.ctrl.enter="saveEditNote(c, c._editContent)" @keydown.meta.enter="saveEditNote(c, c._editContent)" />
<div class="row items-center q-mt-xs">
<q-space />
<q-btn flat dense size="sm" label="Annuler" color="grey" @click="editingNote = null" class="q-mr-xs" />
<q-btn unelevated dense size="sm" label="Sauvegarder" color="indigo-6" @click="saveEditNote(c, c._editContent)" />
</div> </div>
</div> </div>
<!-- Display mode --> </div><!-- /row two-column -->
<div v-else class="note-content" v-html="c.content"></div>
</div>
</div>
</div>
</div>
</div><!-- /row main+notes -->
</template> </template>
@ -514,33 +443,43 @@
:comms="modalComms" :comms="modalComms"
:files="modalFiles" :files="modalFiles"
:doc-fields="modalDocFields" :doc-fields="modalDocFields"
:dispatch-jobs="modalDispatchJobs"
@navigate="(dt, name, t) => openModal(dt, name, t)" @navigate="(dt, name, t) => openModal(dt, name, t)"
@open-pdf="openPdf" @open-pdf="openPdf"
@save-field="saveSubField" @save-field="saveSubField"
@toggle-recurring="toggleRecurringModal" @toggle-recurring="toggleRecurringModal"
@dispatch-created="onDispatchCreated"
@dispatch-deleted="name => { modalDispatchJobs = modalDispatchJobs.filter(j => j.name !== name) }"
/> />
</q-page> </q-page>
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted, reactive, watch } from 'vue' import { ref, computed, onMounted, reactive, watch } from 'vue'
import { Notify } from 'quasar'
import draggable from 'vuedraggable' import draggable from 'vuedraggable'
import { listDocs, getDoc, updateDoc } from 'src/api/erp' import { listDocs, getDoc } from 'src/api/erp'
import { authFetch } from 'src/api/auth' import { authFetch } from 'src/api/auth'
import { BASE_URL } from 'src/config/erpnext' import { BASE_URL } from 'src/config/erpnext'
import { formatDate, formatDateShort, formatMoney, erpLink, erpFileUrl } from 'src/composables/useFormatters' import { formatDate, formatMoney, staffColor, staffInitials } from 'src/composables/useFormatters'
import { locStatusClass, subStatusClass, eqStatusClass, ticketStatusClass, invStatusClass, deviceColorClass } from 'src/composables/useStatusClasses' import { locStatusClass, ticketStatusClass, invStatusClass, deviceColorClass } from 'src/composables/useStatusClasses'
import { useDetailModal } from 'src/composables/useDetailModal' import { useDetailModal } from 'src/composables/useDetailModal'
import { useSubscriptionGroups, isRebate, subMainLabel, subSubLabel, sectionTotal, annualPrice } from 'src/composables/useSubscriptionGroups' import { useSubscriptionGroups, isRebate, subMainLabel, sectionTotal, annualPrice } from 'src/composables/useSubscriptionGroups'
import { useSubscriptionActions } from 'src/composables/useSubscriptionActions'
import { useCustomerNotes } from 'src/composables/useCustomerNotes'
import { invoiceCols, paymentCols, ticketCols } from 'src/config/table-columns'
import { deviceLucideIcon } from 'src/config/device-icons'
import DetailModal from 'src/components/shared/DetailModal.vue' import DetailModal from 'src/components/shared/DetailModal.vue'
import CustomerHeader from 'src/components/customer/CustomerHeader.vue' import CustomerHeader from 'src/components/customer/CustomerHeader.vue'
import ContactCard from 'src/components/customer/ContactCard.vue' import ContactCard from 'src/components/customer/ContactCard.vue'
import CustomerInfoCard from 'src/components/customer/CustomerInfoCard.vue' import CustomerInfoCard from 'src/components/customer/CustomerInfoCard.vue'
import BillingKPIs from 'src/components/customer/BillingKPIs.vue' // BillingKPIs removed data shown in invoices section
import InlineField from 'src/components/shared/InlineField.vue' import InlineField from 'src/components/shared/InlineField.vue'
import ChatterPanel from 'src/components/customer/ChatterPanel.vue'
const props = defineProps({ id: String }) const props = defineProps({ id: String })
// Core data refs
const loading = ref(true) const loading = ref(true)
const customer = ref(null) const customer = ref(null)
const contact = ref(null) const contact = ref(null)
@ -551,81 +490,73 @@ const tickets = ref([])
const invoices = ref([]) const invoices = ref([])
const payments = ref([]) const payments = ref([])
const comments = ref([]) const comments = ref([])
const newNote = ref('') const accountBalance = ref(null)
const customerGroups = ['Commercial', 'Individual', 'Government', 'Non Profit']
// Subscription grouping composable // Composables
const { const {
locSubs, locSubsMonthly, locSubsAnnual, locSubsMonthlyTotal, locSubsAnnualTotal, locSubs, locSubsMonthly, locSubsAnnual, locSubsMonthlyTotal, locSubsAnnualTotal,
locSubsSections, sectionOpen, toggleSection, invalidateCache, invalidateAll, locSubsSections, sectionOpen, toggleSection, invalidateCache, invalidateAll,
subSections,
} = useSubscriptionGroups(subscriptions) } = useSubscriptionGroups(subscriptions)
const addingNote = ref(false)
const noteInputFocused = ref(false)
const editingNote = ref(null) // note name being edited
const customerGroups = ['Commercial', 'Individual', 'Government', 'Non Profit']
// Location collapse state for empty locations (true = collapsed by default) const {
const locCollapsed = reactive({}) subSaving, togglingRecurring,
function locHasSubs (locName) { return subscriptions.value.some(s => s.service_location === locName) } toggleSubStatus, toggleFrequency, toggleRecurring,
function toggleLocCollapse (locName) { toggleRecurringModal, saveSubField, onSubDragChange,
locCollapsed[locName] = locCollapsed[locName] === false ? true : false } = useSubscriptionActions(subscriptions, customer, comments, invalidateCache)
const { onNoteAdded } = useCustomerNotes(comments, customer)
const {
modalOpen, modalLoading, modalDoctype, modalDocName, modalTitle,
modalDoc, modalComments, modalComms, modalFiles, modalDocFields,
modalDispatchJobs, openModal,
} = useDetailModal()
function onDispatchCreated (job) {
// Add to the local list so it shows immediately in the modal
modalDispatchJobs.value.push(job)
} }
// Sorted locations: with subscriptions first, empty ones last // Location state
const locCollapsed = reactive({})
function locHasSubs (locName) { return subscriptions.value.some(s => s.service_location === locName) }
function toggleLocCollapse (locName) { locCollapsed[locName] = locCollapsed[locName] === false ? true : false }
const sortedLocations = computed(() => { const sortedLocations = computed(() => {
const withSubs = locations.value.filter(l => locHasSubs(l.name)) const withSubs = locations.value.filter(l => locHasSubs(l.name))
const withoutSubs = locations.value.filter(l => !locHasSubs(l.name)) const withoutSubs = locations.value.filter(l => !locHasSubs(l.name))
return [...withSubs, ...withoutSubs] return [...withSubs, ...withoutSubs]
}) })
// Collapsible sections tickets open by default, others collapsed // UI state
const sectionsOpen = ref({ tickets: true, invoices: false, payments: false, notes: false }) const sectionsOpen = ref({ locations: true, tickets: true, invoices: false, payments: false, notes: false })
// Modal state (shared composable) // Computed
const { const openTicketCount = computed(() => tickets.value.filter(t => t.status === 'Open').length)
modalOpen, modalLoading, modalDoctype, modalDocName, modalTitle, const totalPaid = computed(() => accountBalance.value?.total_paid || payments.value.reduce((s, p) => s + (p.paid_amount || 0), 0))
modalDoc, modalComments, modalComms, modalFiles, modalDocFields, const totalOutstanding = computed(() => accountBalance.value?.balance ?? 0)
openModal, const totalMonthly = computed(() => {
} = useDetailModal() const monthly = subscriptions.value.filter(s => s.billing_frequency !== 'A').reduce((sum, s) => sum + parseFloat(s.actual_price || 0), 0)
const annualAsMonthly = subscriptions.value.filter(s => s.billing_frequency === 'A').reduce((sum, s) => sum + parseFloat(s.actual_price || 0), 0)
return monthly + annualAsMonthly
})
const customerPhoneOptions = computed(() => {
if (!customer.value) return []
const opts = []
if (customer.value.cell_phone) opts.push({ label: 'Cell: ' + customer.value.cell_phone, value: customer.value.cell_phone })
if (customer.value.tel_home) opts.push({ label: 'Maison: ' + customer.value.tel_home, value: customer.value.tel_home })
if (customer.value.tel_office) opts.push({ label: 'Bureau: ' + customer.value.tel_office, value: customer.value.tel_office })
return opts
})
// Update Subscription via standard Frappe REST API // Small helpers
async function updateSub (name, fields) { function locEquip (locName) { return equipment.value.filter(e => e.service_location === locName) }
const res = await authFetch(BASE_URL + '/api/resource/Subscription/' + encodeURIComponent(name), { function locTickets (locName) { return tickets.value.filter(t => t.service_location === locName) }
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(fields),
})
if (!res.ok) {
const err = await res.text()
throw new Error('Update failed: ' + err)
}
return res.json()
}
// PDF print URL for Sales Invoice
function erpPdfUrl (name) { function erpPdfUrl (name) {
return BASE_URL + '/api/method/frappe.utils.print_format.download_pdf?doctype=Sales%20Invoice&name=' + encodeURIComponent(name) + '&format=Facture%20TARGO' return BASE_URL + '/api/method/frappe.utils.print_format.download_pdf?doctype=Sales%20Invoice&name=' + encodeURIComponent(name) + '&format=Facture%20TARGO'
} }
function openExternal (url) {
window.open(url, '_blank')
}
// Generate consistent color from name string
const AVATAR_COLORS = ['#4f46e5','#0891b2','#059669','#d97706','#dc2626','#7c3aed','#be185d','#0d9488','#6366f1','#ea580c']
function staffColor (name) {
if (!name) return '#9e9e9e'
let h = 0
for (let i = 0; i < name.length; i++) h = name.charCodeAt(i) + ((h << 5) - h)
return AVATAR_COLORS[Math.abs(h) % AVATAR_COLORS.length]
}
function staffInitials (name) {
if (!name) return ''
const parts = name.trim().split(/\s+/)
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
return parts[0].substring(0, 2).toUpperCase()
}
async function openPdf (name) { async function openPdf (name) {
try { try {
const res = await authFetch(erpPdfUrl(name)) const res = await authFetch(erpPdfUrl(name))
@ -635,360 +566,11 @@ async function openPdf (name) {
window.open(url, '_blank') window.open(url, '_blank')
setTimeout(() => URL.revokeObjectURL(url), 60000) setTimeout(() => URL.revokeObjectURL(url), 60000)
} catch (e) { } catch (e) {
const { Notify } = await import('quasar') Notify.create({ type: 'negative', message: 'Erreur PDF : ' + e.message })
Notify?.create?.({ type: 'negative', message: 'Erreur PDF : ' + e.message })
} }
} }
// Decode HTML entities (&#039; ', &amp; &, etc.) // Data loading
function decodeHtml (str) {
if (!str) return str
const el = document.createElement('textarea')
el.innerHTML = str
return el.value
}
// openModal is now from useDetailModal composable
const openTicketCount = computed(() => tickets.value.filter(t => t.status === 'Open').length)
const totalPaid = computed(() => accountBalance.value?.total_paid || payments.value.reduce((s, p) => s + (p.paid_amount || 0), 0))
const totalOutstanding = computed(() => accountBalance.value?.balance ?? 0)
const totalMonthly = computed(() => {
const monthly = subscriptions.value.filter(s => s.billing_frequency !== 'A').reduce((sum, s) => sum + parseFloat(s.actual_price || 0), 0)
const annualAsMonthly = subscriptions.value.filter(s => s.billing_frequency === 'A').reduce((sum, s) => sum + parseFloat(s.actual_price || 0), 0)
return monthly + annualAsMonthly
})
const accountBalance = ref(null)
// locSubs, locSubsMonthly, etc. are from useSubscriptionGroups composable above
// Save customer field inline
const custSaving = ref(false)
const CHECK_FIELDS = ['is_commercial', 'is_bad_payer', 'exclude_fees', 'ppa_enabled']
async function saveCust (field) {
if (custSaving.value) return
custSaving.value = true
try {
let val = customer.value[field]
if (CHECK_FIELDS.includes(field)) val = val ? 1 : 0
await updateDoc('Customer', customer.value.name, { [field]: val ?? '' })
} catch (e) {
console.error('Failed to save customer field:', field, e)
} finally {
custSaving.value = false
}
}
// Toggle recurring (cancel_at_period_end: 0 = recurring, 1 = non-recurring)
const togglingRecurring = ref(null) // name of sub being toggled
async function toggleRecurring (sub) {
if (togglingRecurring.value) return
togglingRecurring.value = sub.name
const newVal = Number(sub.cancel_at_period_end) ? 0 : 1
try {
const updates = { cancel_at_period_end: newVal }
// Frappe requires end_date when setting cancel_at_period_end
if (newVal && !sub.end_date) updates.end_date = sub.current_invoice_end || new Date().toISOString().slice(0, 10)
await updateSub(sub.name, updates)
sub.cancel_at_period_end = newVal
if (updates.end_date) sub.end_date = updates.end_date
const msg = newVal ? 'Récurrence désactivée' : 'Récurrence activée'
logSubChange(sub, msg)
const { Notify } = await import('quasar')
Notify?.create?.({ type: 'positive', message: msg, timeout: 2500 })
} catch (e) {
console.error('Failed to toggle recurring:', sub.name, e)
const { Notify } = await import('quasar')
Notify?.create?.({ type: 'negative', message: 'Erreur: impossible de modifier le récurrent', timeout: 3000 })
} finally {
togglingRecurring.value = null
}
}
// NOTES FUNCTIONS
// Sort: sticky first, then by creation desc
const sortedComments = computed(() => {
const pinned = comments.value.filter(c => c._sticky)
const rest = comments.value.filter(c => !c._sticky)
return [...pinned, ...rest]
})
// Extract display name from email (user@domain User)
function noteAuthorName (email) {
if (!email) return 'Système'
const local = email.split('@')[0]
return local.charAt(0).toUpperCase() + local.slice(1).replace(/[._-]/g, ' ')
}
// Relative time display
function noteTimeAgo (d) {
if (!d) return ''
const diff = Date.now() - new Date(d).getTime()
const mins = Math.floor(diff / 60000)
if (mins < 1) return "À l'instant"
if (mins < 60) return `Il y a ${mins} min`
const hours = Math.floor(mins / 60)
if (hours < 24) return `Il y a ${hours}h`
const days = Math.floor(hours / 24)
if (days < 7) return `Il y a ${days}j`
return formatDate(d)
}
// Add a new note
async function addNote () {
if (!newNote.value?.trim() || addingNote.value) return
addingNote.value = true
try {
const body = { doctype: 'Comment', comment_type: 'Comment', reference_doctype: 'Customer', reference_name: customer.value.name, content: newNote.value.trim() }
const res = await authFetch(BASE_URL + '/api/resource/Comment', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (res.ok) {
const data = await res.json()
comments.value.unshift(data.data)
newNote.value = ''
noteInputFocused.value = false
}
} catch (e) {
console.error('Failed to add note:', e)
} finally {
addingNote.value = false
}
}
// Delete a note
async function deleteNote (note) {
try {
const res = await authFetch(BASE_URL + '/api/resource/Comment/' + encodeURIComponent(note.name), { method: 'DELETE' })
if (res.ok) {
comments.value = comments.value.filter(c => c.name !== note.name)
}
} catch (e) {
console.error('Failed to delete note:', e)
}
}
// Toggle sticky (client-side flag + persist via content prefix)
function toggleStickyNote (note) {
note._sticky = !note._sticky
}
// Edit a note
async function saveEditNote (note, newContent) {
if (!newContent?.trim()) return
try {
await updateDoc('Comment', note.name, { content: newContent.trim() })
note.content = newContent.trim()
editingNote.value = null
} catch (e) {
console.error('Failed to edit note:', e)
}
}
function startEditNote (note) {
editingNote.value = note.name
note._editContent = note.content?.replace(/<[^>]*>/g, '') || ''
}
// Subscription action state
const subSaving = ref(null) // 'subname:action'
// Toggle subscription status (Active Cancelled)
// Log a subscription change as a Comment on the Customer for audit trail
async function logSubChange (sub, message) {
try {
const label = sub.custom_description || sub.item_name || sub.item_code || sub.name
const body = {
doctype: 'Comment', comment_type: 'Comment',
reference_doctype: 'Customer', reference_name: customer.value.name,
content: `[${sub.name}] ${label}${message}`,
}
const res = await authFetch(BASE_URL + '/api/resource/Comment', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (res.ok) {
const data = await res.json()
comments.value.unshift(data.data)
}
} catch (e) {
console.error('Failed to log subscription change:', e)
}
}
async function toggleSubStatus (sub) {
const action = sub.name + ':status'
if (subSaving.value) return
subSaving.value = action
const newStatus = sub.status === 'Cancelled' ? 'Active' : 'Cancelled'
const today = new Date().toISOString().slice(0, 10)
try {
const updates = { status: newStatus }
if (newStatus === 'Cancelled') {
updates.cancelation_date = today
// Frappe requires end_date for subs with follow_calendar_months
if (!sub.end_date) updates.end_date = sub.current_invoice_end || today
} else {
updates.cancelation_date = null
}
await updateSub(sub.name, updates)
sub.status = newStatus
if (updates.end_date) sub.end_date = updates.end_date
// Audit log
const msg = newStatus === 'Cancelled'
? `Service désactivé le ${today}`
: `Service réactivé le ${today}`
logSubChange(sub, msg)
// Invalidate section cache
if (sub.service_location) {
const freq = sub.billing_frequency === 'A' ? 'A' : 'M'
invalidateCache(sub.service_location)
}
const { Notify } = await import('quasar')
Notify?.create?.({ type: 'positive', message: msg, timeout: 2500 })
} catch (e) {
console.error('Failed to toggle subscription status:', e)
const { Notify } = await import('quasar')
Notify?.create?.({ type: 'negative', message: 'Erreur: ' + (e.message || 'impossible de modifier le statut'), timeout: 4000 })
} finally {
subSaving.value = null
}
}
// Toggle billing frequency (M A)
async function toggleFrequency (sub) {
const action = sub.name + ':freq'
if (subSaving.value) return
subSaving.value = action
const oldFreq = sub.billing_frequency
const newFreq = oldFreq === 'A' ? 'M' : 'A'
try {
await updateSub(sub.name, { billing_frequency: newFreq })
// Audit log
const msg = newFreq === 'A'
? `Fréquence changée: Mensuel → Annuel`
: `Fréquence changée: Annuel → Mensuel`
logSubChange(sub, msg)
// Clear old section cache before changing frequency
if (sub.service_location) {
invalidateCache(sub.service_location)
}
sub.billing_frequency = newFreq
} catch (e) {
console.error('Failed to toggle frequency:', e)
} finally {
subSaving.value = null
}
}
// Save a subscription field from the modal and sync back to the list
async function saveSubField (doc, field) {
try {
const oldVal = subscriptions.value.find(s => s.name === doc.name)?.[field]
await updateSub(doc.name, { [field]: doc[field] ?? '' })
// Sync back to subscriptions list
const listSub = subscriptions.value.find(s => s.name === doc.name)
if (listSub) {
listSub[field] = doc[field]
// Invalidate section cache
if (listSub.service_location) {
invalidateCache(listSub.service_location)
}
}
// Audit log for price/description changes
const fieldLabels = { actual_price: 'Prix', custom_description: 'Description' }
if (fieldLabels[field]) {
const newVal = doc[field] ?? ''
const msg = field === 'actual_price'
? `${fieldLabels[field]} modifié: ${formatMoney(oldVal)}${formatMoney(newVal)}`
: `${fieldLabels[field]} modifié: "${oldVal || '—'}" → "${newVal || '—'}"`
logSubChange(doc, msg)
}
} catch (e) {
console.error('Failed to save subscription field:', field, e)
}
}
// Toggle recurring from modal update both modal doc and list item
async function toggleRecurringModal (doc) {
const newVal = Number(doc.cancel_at_period_end) ? 0 : 1
try {
const updates = { cancel_at_period_end: newVal }
if (newVal && !doc.end_date) updates.end_date = doc.current_invoice_end || new Date().toISOString().slice(0, 10)
await updateSub(doc.name, updates)
doc.cancel_at_period_end = newVal
if (updates.end_date) doc.end_date = updates.end_date
const listSub = subscriptions.value.find(s => s.name === doc.name)
if (listSub) {
listSub.cancel_at_period_end = newVal
if (updates.end_date) listSub.end_date = updates.end_date
}
const msg = newVal ? 'Récurrence désactivée' : 'Récurrence activée'
logSubChange(doc, msg)
const { Notify } = await import('quasar')
Notify?.create?.({ type: 'positive', message: msg, timeout: 2500 })
} catch (e) {
console.error('Failed to toggle recurring from modal:', e)
const { Notify } = await import('quasar')
Notify?.create?.({ type: 'negative', message: 'Erreur: ' + (e.message || 'impossible de modifier'), timeout: 3000 })
}
}
// Handle drag between sections invalidate section cache so it re-groups
function onSubDragChange (evt, locName, sectionKey) {
// When an item is added to or removed from a section, clear cache to force recompute
if (evt.added || evt.removed) {
invalidateCache(locName)
}
}
function locEquip (locName) { return equipment.value.filter(e => e.service_location === locName) }
function locTickets (locName) { return tickets.value.filter(t => t.service_location === locName) }
function deviceLucideIcon (type) {
const map = {
ONT: 'settings_input_hdmi',
Modem: 'router',
Routeur: 'router',
'Décodeur TV': 'connected_tv',
'Téléphone IP': 'phone_in_talk',
Switch: 'hub',
Amplificateur: 'cell_tower',
'AP WiFi': 'wifi',
'Câble/Connecteur': 'cable',
}
return map[type] || 'devices_other'
}
const invoiceCols = [
{ name: 'name', label: 'N°', field: 'name', align: 'left' },
{ name: 'posting_date', label: 'Date', field: 'posting_date', align: 'left', sortable: true },
{ name: 'grand_total', label: 'Total', field: 'grand_total', align: 'right', sortable: true },
{ name: 'outstanding_amount', label: 'Solde', field: 'outstanding_amount', align: 'right' },
{ name: 'status', label: 'Statut', field: 'status', align: 'center' },
]
const paymentCols = [
{ name: 'name', label: 'N°', field: 'name', align: 'left' },
{ name: 'posting_date', label: 'Date', field: 'posting_date', align: 'left', sortable: true },
{ name: 'paid_amount', label: 'Montant', field: 'paid_amount', align: 'right' },
{ name: 'mode_of_payment', label: 'Mode', field: 'mode_of_payment', align: 'left' },
{ name: 'reference_no', label: 'Référence', field: 'reference_no', align: 'left' },
]
const ticketCols = [
{ name: 'important', label: '', field: 'is_important', align: 'center', style: 'width:20px;padding:0 2px' },
{ name: 'legacy_id', label: '', field: 'legacy_ticket_id', align: 'right', style: 'width:48px;padding:0 4px' },
{ name: 'subject', label: 'Sujet', field: 'subject', align: 'left' },
{ name: 'assigned', label: '', field: 'assigned_staff', align: 'center', style: 'width:48px;padding:0 2px' },
{ name: 'opening_date', label: '', field: 'opening_date', align: 'left', style: 'width:76px;white-space:nowrap;padding:0 4px' },
{ name: 'priority', label: '', field: 'priority', align: 'center', style: 'width:24px;padding:0 2px' },
{ name: 'status', label: '', field: 'status', align: 'center', style: 'width:64px;padding:0 4px' },
]
async function loadCustomer (id) { async function loadCustomer (id) {
loading.value = true loading.value = true
customer.value = null customer.value = null
@ -1004,10 +586,7 @@ async function loadCustomer (id) {
try { try {
const cust = await getDoc('Customer', id) const cust = await getDoc('Customer', id)
// Coerce check fields to boolean for q-toggle for (const f of ['is_commercial', 'is_bad_payer', 'exclude_fees', 'ppa_enabled']) { cust[f] = !!cust[f] }
for (const f of ['is_commercial', 'is_bad_payer', 'exclude_fees', 'ppa_enabled']) {
cust[f] = !!cust[f]
}
customer.value = cust customer.value = cust
const custFilter = { customer: id } const custFilter = { customer: id }
@ -1019,87 +598,66 @@ async function loadCustomer (id) {
fields: ['name', 'location_name', 'status', 'address_line', 'city', 'postal_code', fields: ['name', 'location_name', 'status', 'address_line', 'city', 'postal_code',
'connection_type', 'olt_port', 'network_id', 'contact_name', 'contact_phone', 'connection_type', 'olt_port', 'network_id', 'contact_name', 'contact_phone',
'longitude', 'latitude'], 'longitude', 'latitude'],
limit: 100, limit: 100, orderBy: 'status asc, address_line asc',
orderBy: 'status asc, address_line asc',
}), }),
listDocs('Subscription', { listDocs('Subscription', {
filters: partyFilter, filters: partyFilter,
fields: ['name', 'status', 'start_date', 'service_location', 'radius_user', fields: ['name', 'status', 'start_date', 'service_location', 'radius_user',
'actual_price', 'custom_description', 'item_code', 'item_group', 'billing_frequency', 'item_name', 'actual_price', 'custom_description', 'item_code', 'item_group', 'billing_frequency', 'item_name',
'cancel_at_period_end', 'current_invoice_start', 'current_invoice_end', 'end_date', 'cancelation_date'], 'cancel_at_period_end', 'current_invoice_start', 'current_invoice_end', 'end_date', 'cancelation_date'],
limit: 100, limit: 100, orderBy: 'start_date desc',
orderBy: 'start_date desc',
}), }),
listDocs('Service Equipment', { listDocs('Service Equipment', {
filters: custFilter, filters: custFilter,
fields: ['name', 'equipment_type', 'brand', 'model', 'serial_number', 'mac_address', fields: ['name', 'equipment_type', 'brand', 'model', 'serial_number', 'mac_address',
'ip_address', 'status', 'service_location', 'ip_address', 'status', 'service_location',
'olt_name', 'olt_ip', 'olt_frame', 'olt_slot', 'olt_port', 'olt_ontid'], 'olt_name', 'olt_ip', 'olt_frame', 'olt_slot', 'olt_port', 'olt_ontid'],
limit: 200, limit: 200, orderBy: 'equipment_type asc',
orderBy: 'equipment_type asc',
}), }),
listDocs('Issue', { listDocs('Issue', {
filters: custFilter, filters: custFilter,
fields: ['name', 'subject', 'status', 'priority', 'opening_date', 'service_location', 'legacy_ticket_id', 'is_important', 'assigned_staff', 'opened_by_staff', 'issue_type'], fields: ['name', 'subject', 'status', 'priority', 'opening_date', 'service_location', 'legacy_ticket_id', 'is_important', 'assigned_staff', 'opened_by_staff', 'issue_type'],
limit: 200, limit: 200, orderBy: 'opening_date desc',
orderBy: 'opening_date desc',
}), }),
listDocs('Sales Invoice', { listDocs('Sales Invoice', {
filters: custFilter, filters: custFilter,
fields: ['name', 'posting_date', 'grand_total', 'outstanding_amount', 'status', 'is_return', 'return_against', 'creation'], fields: ['name', 'posting_date', 'grand_total', 'outstanding_amount', 'status', 'is_return', 'return_against', 'creation'],
limit: 50, limit: 50, orderBy: 'posting_date desc, name desc',
orderBy: 'posting_date desc, name desc',
}), }),
listDocs('Payment Entry', { listDocs('Payment Entry', {
filters: { party_type: 'Customer', party: id }, filters: { party_type: 'Customer', party: id },
fields: ['name', 'posting_date', 'paid_amount', 'mode_of_payment', 'reference_no'], fields: ['name', 'posting_date', 'paid_amount', 'mode_of_payment', 'reference_no'],
limit: 50, limit: 50, orderBy: 'posting_date desc',
orderBy: 'posting_date desc',
}), }),
// Contact linked to this customer listDocs('Contact', { filters: {}, fields: ['name', 'first_name', 'last_name', 'email_id', 'mobile_no', 'phone'], limit: 1 }).catch(() => []),
listDocs('Contact', {
filters: {},
fields: ['name', 'first_name', 'last_name', 'email_id', 'mobile_no', 'phone'],
limit: 1,
}).catch(() => []),
// Comments/memos on this customer
listDocs('Comment', { listDocs('Comment', {
filters: { reference_doctype: 'Customer', reference_name: id, comment_type: 'Comment' }, filters: { reference_doctype: 'Customer', reference_name: id, comment_type: 'Comment' },
fields: ['name', 'content', 'comment_by', 'creation'], fields: ['name', 'content', 'comment_by', 'creation'],
limit: 50, limit: 50, orderBy: 'creation desc',
orderBy: 'creation desc',
}).catch(() => []), }).catch(() => []),
]) ])
locations.value = locs locations.value = locs
subscriptions.value = subs subscriptions.value = subs
// Reset section cache so it rebuilds from fresh data
invalidateAll() invalidateAll()
equipment.value = equip equipment.value = equip
// Sort: important/sticky first, then by date desc
tickets.value = tix.sort((a, b) => (b.is_important || 0) - (a.is_important || 0) || (b.opening_date || '').localeCompare(a.opening_date || '')) tickets.value = tix.sort((a, b) => (b.is_important || 0) - (a.is_important || 0) || (b.opening_date || '').localeCompare(a.opening_date || ''))
invoices.value = invs invoices.value = invs
payments.value = pays payments.value = pays
contact.value = ctc.length ? ctc[0] : null contact.value = ctc.length ? ctc[0] : null
comments.value = memos comments.value = memos
// Fetch accurate account balance from server (covers ALL invoices/payments, not just page limit)
try { try {
const balRes = await authFetch(BASE_URL + '/api/method/customer_balance?customer=' + encodeURIComponent(id)) const balRes = await authFetch(BASE_URL + '/api/method/customer_balance?customer=' + encodeURIComponent(id))
if (balRes.ok) { if (balRes.ok) { accountBalance.value = (await balRes.json()).message }
const balData = await balRes.json()
accountBalance.value = balData.message
}
} catch {} } catch {}
} catch (e) { } catch {
console.error('Failed to load customer', e)
customer.value = null customer.value = null
} finally { } finally {
loading.value = false loading.value = false
} }
} }
// Reload when navigating between clients
watch(() => props.id, (newId) => { if (newId) loadCustomer(newId) }) watch(() => props.id, (newId) => { if (newId) loadCustomer(newId) })
onMounted(() => loadCustomer(props.id)) onMounted(() => loadCustomer(props.id))
</script> </script>
@ -1451,16 +1009,6 @@ code {
font-size: 0.8rem; font-size: 0.8rem;
} }
.erp-link {
color: var(--ops-accent, #6366f1);
text-decoration: none;
font-size: 0.85rem;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
.clickable-row { .clickable-row {
cursor: pointer; cursor: pointer;
transition: background 0.1s; transition: background 0.1s;
@ -1474,12 +1022,6 @@ code {
} }
} }
.clickable-table :deep(tbody tr) {
cursor: pointer;
&:hover td {
background: #eef2ff !important;
}
}
.ticket-subject-cell { .ticket-subject-cell {
white-space: normal !important; white-space: normal !important;
word-break: break-word; word-break: break-word;
@ -1506,40 +1048,6 @@ code {
margin-left: 0; margin-left: 0;
} }
.modal-field-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2px 16px;
}
.mf {
display: flex;
align-items: baseline;
gap: 8px;
padding: 6px 0;
font-size: 0.875rem;
border-bottom: 1px solid #f1f5f9;
}
.mf-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--ops-text-muted);
min-width: 80px;
flex-shrink: 0;
}
.modal-desc {
font-size: 0.85rem;
line-height: 1.5;
color: var(--ops-text);
background: #f8fafc;
border-radius: 8px;
padding: 10px 12px;
max-height: 300px;
overflow-y: auto;
}
.device-strip { .device-strip {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

View File

@ -4,11 +4,16 @@
<div class="row q-col-gutter-md q-mb-lg"> <div class="row q-col-gutter-md q-mb-lg">
<div class="col-6 col-md" v-for="stat in stats" :key="stat.label"> <div class="col-6 col-md" v-for="stat in stats" :key="stat.label">
<div class="ops-card ops-stat"> <div class="ops-card ops-stat">
<div class="row items-center no-wrap q-gutter-x-sm">
<q-icon :name="stat.icon" :style="{ color: stat.color }" size="22px" />
<div>
<div class="ops-stat-value" :style="{ color: stat.color }">{{ stat.value }}</div> <div class="ops-stat-value" :style="{ color: stat.color }">{{ stat.value }}</div>
<div class="ops-stat-label">{{ stat.label }}</div> <div class="ops-stat-label">{{ stat.label }}</div>
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
<!-- Admin controls --> <!-- Admin controls -->
<div class="row q-col-gutter-md q-mb-lg"> <div class="row q-col-gutter-md q-mb-lg">
@ -59,7 +64,11 @@
<div class="ops-card"> <div class="ops-card">
<div class="text-subtitle1 text-weight-bold q-mb-sm">Tickets ouverts</div> <div class="text-subtitle1 text-weight-bold q-mb-sm">Tickets ouverts</div>
<q-list separator> <q-list separator>
<q-item v-for="t in openTickets" :key="t.name" clickable> <q-item v-for="t in openTickets" :key="t.name" clickable @click="$router.push('/tickets')">
<q-item-section avatar style="min-width:32px">
<q-icon name="confirmation_number" size="20px"
:color="t.priority === 'Urgent' ? 'red' : t.priority === 'High' ? 'orange-8' : 'grey-5'" />
</q-item-section>
<q-item-section> <q-item-section>
<q-item-label>{{ t.subject }}</q-item-label> <q-item-label>{{ t.subject }}</q-item-label>
<q-item-label caption>{{ t.customer_name || t.customer }}</q-item-label> <q-item-label caption>{{ t.customer_name || t.customer }}</q-item-label>
@ -78,20 +87,30 @@
</div> </div>
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<div class="ops-card"> <div class="ops-card">
<div class="text-subtitle1 text-weight-bold q-mb-sm">Dispatch aujourd'hui</div> <div class="text-subtitle1 text-weight-bold q-mb-sm">
Dispatch aujourd'hui
<q-badge v-if="todayJobs.length" :label="todayJobs.length" color="indigo-6" class="q-ml-sm" />
</div>
<q-list separator> <q-list separator>
<q-item v-for="j in todayJobs" :key="j.name" clickable> <q-item v-for="j in todayJobs" :key="j.name" clickable @click="$router.push('/dispatch')">
<q-item-section avatar style="min-width:32px">
<q-icon :name="j.status === 'completed' ? 'check_circle' : j.assigned_tech ? 'person' : 'radio_button_unchecked'"
:color="j.status === 'completed' ? 'green-6' : j.assigned_tech ? 'blue-6' : 'grey-5'" size="20px" />
</q-item-section>
<q-item-section> <q-item-section>
<q-item-label>{{ j.subject || j.name }}</q-item-label> <q-item-label>{{ j.subject || j.name }}</q-item-label>
<q-item-label caption>{{ j.customer_name }}</q-item-label> <q-item-label caption>
<span v-if="j.assigned_tech">{{ j.assigned_tech }}</span>
<span v-if="j.customer"> &middot; {{ j.customer }}</span>
</q-item-label>
</q-item-section> </q-item-section>
<q-item-section side> <q-item-section side>
<span class="ops-badge" :class="j.status === 'Completed' ? 'closed' : 'active'">{{ j.status }}</span> <span class="ops-badge" :class="j.status === 'completed' ? 'active' : j.status === 'assigned' ? 'draft' : 'closed'">{{ j.status }}</span>
</q-item-section> </q-item-section>
</q-item> </q-item>
<q-item v-if="!todayJobs.length"> <q-item v-if="!todayJobs.length">
<q-item-section> <q-item-section>
<q-item-label caption>Aucune tâche aujourd'hui</q-item-label> <q-item-label caption>Aucune tâche planifiée aujourd'hui</q-item-label>
</q-item-section> </q-item-section>
</q-item> </q-item>
</q-list> </q-list>
@ -108,11 +127,11 @@ import { authFetch } from 'src/api/auth'
import { BASE_URL } from 'src/config/erpnext' import { BASE_URL } from 'src/config/erpnext'
const stats = ref([ const stats = ref([
{ label: 'Abonnés', value: '...', color: 'var(--ops-accent)' }, { label: 'Abonnés', value: '...', color: 'var(--ops-accent)', icon: 'people' },
{ label: 'Clients', value: '...', color: 'var(--ops-primary)' }, { label: 'Clients', value: '...', color: 'var(--ops-primary)', icon: 'business' },
{ label: 'Abonnements', value: '...', color: 'var(--ops-success)' }, { label: 'Rev. mensuel', value: '...', color: 'var(--ops-success)', icon: 'attach_money' },
{ label: 'Locations', value: '...', color: '#6b7280' }, { label: 'Tickets ouverts', value: '...', color: 'var(--ops-warning)', icon: 'confirmation_number' },
{ label: 'Tickets ouverts', value: '...', color: 'var(--ops-warning)' }, { label: 'Dispatch aujourd\'hui', value: '...', color: 'var(--ops-accent)', icon: 'local_shipping' },
]) ])
const openTickets = ref([]) const openTickets = ref([])
@ -169,36 +188,48 @@ async function runBilling () {
onMounted(async () => { onMounted(async () => {
fetchSchedulerStatus() fetchSchedulerStatus()
const [clients, tickets, subs, locations] = await Promise.all([ const today = new Date().toISOString().slice(0, 10)
const [clients, tickets, todayDispatch, openTix] = await Promise.all([
countDocs('Customer', { disabled: 0 }), countDocs('Customer', { disabled: 0 }),
countDocs('Issue', { status: 'Open' }), countDocs('Issue', { status: 'Open' }),
countDocs('Service Subscription', { status: 'Actif' }), listDocs('Dispatch Job', {
countDocs('Service Location', { status: 'Active' }), filters: { scheduled_date: today },
fields: ['name', 'subject', 'status', 'assigned_tech', 'customer', 'priority', 'source_issue'],
limit: 50, orderBy: 'start_time asc',
}).catch(() => []),
listDocs('Issue', {
filters: { status: 'Open' },
fields: ['name', 'subject', 'customer', 'customer_name', 'priority', 'opening_date'],
limit: 10, orderBy: 'opening_date desc',
}),
]) ])
// Abonnés = unique customers with active subscriptions (via server script) // Abonnés = unique customers with active subscriptions (via server script)
let abonnes = 0 let abonnes = 0
let monthlyRev = 0
try { try {
const res = await authFetch(BASE_URL + '/api/method/subscriber_count') const [subRes, revRes] = await Promise.all([
if (res.ok) { authFetch(BASE_URL + '/api/method/subscriber_count').then(r => r.ok ? r.json() : null).catch(() => null),
const data = await res.json() listDocs('Service Subscription', {
abonnes = data.message?.count || 0 filters: { status: 'Actif' },
} fields: ['actual_price'],
limit: 0,
}).catch(() => []),
])
abonnes = subRes?.message?.count || clients
monthlyRev = revRes.reduce((sum, s) => sum + parseFloat(s.actual_price || 0), 0)
} catch { } catch {
abonnes = clients abonnes = clients
} }
stats.value[0].value = abonnes.toLocaleString() stats.value[0].value = abonnes.toLocaleString()
stats.value[1].value = clients.toLocaleString() stats.value[1].value = clients.toLocaleString()
stats.value[2].value = subs.toLocaleString() stats.value[2].value = monthlyRev ? (Math.round(monthlyRev).toLocaleString() + ' $') : '...'
stats.value[3].value = locations.toLocaleString() stats.value[3].value = tickets.toLocaleString()
stats.value[4].value = tickets.toLocaleString() stats.value[4].value = todayDispatch.length.toLocaleString()
openTickets.value = await listDocs('Issue', { openTickets.value = openTix
filters: { status: 'Open' }, todayJobs.value = todayDispatch
fields: ['name', 'subject', 'customer', 'priority', 'opening_date'],
limit: 10,
orderBy: 'opening_date desc',
})
}) })
</script> </script>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,229 @@
<template>
<q-page padding>
<div class="text-h5 q-mb-md">
<q-icon name="settings" class="q-mr-sm" /> Paramètres
</div>
<div v-if="loading" class="flex flex-center q-pa-xl">
<q-spinner size="40px" color="indigo-6" />
</div>
<template v-else>
<!-- SMS / Twilio -->
<div class="ops-card q-mb-md">
<div class="section-title q-mb-sm">
<q-icon name="sms" size="20px" class="q-mr-xs" /> SMS Twilio
</div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<q-input v-model="settings.twilio_account_sid" label="Account SID" outlined dense
placeholder="ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
@blur="save('twilio_account_sid')" />
</div>
<div class="col-12 col-md-6">
<q-input v-model="settings.twilio_auth_token" label="Auth Token" outlined dense
:type="showToken ? 'text' : 'password'" placeholder="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
@blur="save('twilio_auth_token')">
<template #append>
<q-icon :name="showToken ? 'visibility_off' : 'visibility'" class="cursor-pointer"
@click="showToken = !showToken" />
</template>
</q-input>
</div>
<div class="col-12 col-md-6">
<q-input v-model="settings.twilio_from_number" label="Numéro d'envoi (From)" outlined dense
placeholder="+15145551234"
@blur="save('twilio_from_number')" />
</div>
<div class="col-12 col-md-6">
<div class="row items-center q-gutter-sm">
<q-btn color="indigo-6" icon="send" label="Tester SMS" :loading="testingSms" dense unelevated
@click="testSms" :disable="!settings.twilio_account_sid || !settings.twilio_from_number" />
<q-badge v-if="smsTestResult" :color="smsTestResult.ok ? 'green' : 'red'" class="q-pa-xs">
{{ smsTestResult.message }}
</q-badge>
</div>
</div>
</div>
<div class="text-caption text-grey-6 q-mt-sm">
Les credentials Twilio permettent l'envoi de SMS depuis la fiche client.
<a href="https://console.twilio.com" target="_blank">Console Twilio </a>
</div>
</div>
<!-- SMS Templates -->
<div class="ops-card q-mb-md">
<div class="section-title q-mb-sm">
<q-icon name="textsms" size="20px" class="q-mr-xs" /> Templates SMS
</div>
<div class="row q-col-gutter-md">
<div class="col-12">
<q-input v-model="settings.sms_enroute" label="Technicien en route" outlined dense autogrow
@blur="save('sms_enroute')" />
</div>
<div class="col-12">
<q-input v-model="settings.sms_completed" label="Service complété" outlined dense autogrow
@blur="save('sms_completed')" />
</div>
<div class="col-12">
<q-input v-model="settings.sms_assigned" label="Job assigné (technicien)" outlined dense autogrow
@blur="save('sms_assigned')" />
</div>
</div>
<div class="text-caption text-grey-6 q-mt-sm">
Variables: <code>{client_name}</code>, <code>{tech_name}</code>, <code>{eta}</code>, <code>{job_id}</code>, <code>{address}</code>, <code>{duration}</code>
</div>
</div>
<!-- Email / SMTP -->
<div class="ops-card q-mb-md">
<div class="section-title q-mb-sm">
<q-icon name="email" size="20px" class="q-mr-xs" /> Email SMTP
</div>
<div class="text-caption text-grey-6">
La configuration SMTP (Mailjet) se fait dans ERPNext.
<a :href="erpDeskUrl + '/app/email-account'" target="_blank">Configurer </a>
</div>
</div>
<!-- n8n / Webhooks -->
<div class="ops-card q-mb-md">
<div class="section-title q-mb-sm">
<q-icon name="webhook" size="20px" class="q-mr-xs" /> n8n Webhooks
</div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<q-input v-model="settings.n8n_url" label="n8n URL" outlined dense
@blur="save('n8n_url')" />
</div>
<div class="col-12 col-md-6">
<q-input v-model="settings.n8n_webhook_base" label="Webhook base URL" outlined dense
@blur="save('n8n_webhook_base')" />
</div>
</div>
</div>
<!-- Stripe -->
<div class="ops-card q-mb-md">
<div class="section-title q-mb-sm">
<q-icon name="credit_card" size="20px" class="q-mr-xs" /> Stripe Paiements
</div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-4">
<q-select v-model="settings.stripe_mode" label="Mode" outlined dense emit-value map-options
:options="[{ label: 'Test', value: 'test' }, { label: 'Live', value: 'live' }]"
@update:model-value="save('stripe_mode')" />
</div>
<div class="col-12 col-md-4">
<q-input v-model="settings.stripe_publishable_key" label="Publishable Key" outlined dense
@blur="save('stripe_publishable_key')" />
</div>
<div class="col-12 col-md-4">
<q-input v-model="settings.stripe_secret_key" label="Secret Key" outlined dense
type="password" @blur="save('stripe_secret_key')" />
</div>
</div>
</div>
<!-- Mapbox -->
<div class="ops-card q-mb-md">
<div class="section-title q-mb-sm">
<q-icon name="map" size="20px" class="q-mr-xs" /> Mapbox
</div>
<div class="row q-col-gutter-md">
<div class="col-12">
<q-input v-model="settings.mapbox_token" label="Mapbox Token" outlined dense
@blur="save('mapbox_token')" />
</div>
</div>
</div>
<!-- ERPNext Links -->
<div class="ops-card">
<div class="section-title q-mb-sm">
<q-icon name="launch" size="20px" class="q-mr-xs" /> Liens rapides
</div>
<div class="row q-gutter-sm">
<q-btn flat dense icon="open_in_new" label="ERPNext Desk" :href="erpDeskUrl" target="_blank" />
<q-btn flat dense icon="open_in_new" label="Dispatch Settings"
:href="erpDeskUrl + '/app/dispatch-settings'" target="_blank" />
<q-btn flat dense icon="open_in_new" label="n8n"
:href="'https://n8n.gigafibre.ca'" target="_blank" />
<q-btn flat dense icon="open_in_new" label="Authentik"
:href="'https://auth.targo.ca'" target="_blank" />
<q-btn flat dense icon="open_in_new" label="Twilio Console"
href="https://console.twilio.com" target="_blank" />
</div>
</div>
</template>
</q-page>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { Notify } from 'quasar'
import { authFetch } from 'src/api/auth'
import { BASE_URL } from 'src/config/erpnext'
import { ERP_DESK_URL as erpDeskUrl } from 'src/config/erpnext'
import { sendTestSms } from 'src/api/sms'
const loading = ref(true)
const settings = ref({})
const showToken = ref(false)
const testingSms = ref(false)
const smsTestResult = ref(null)
// Snapshot for change detection
const snapshots = {}
onMounted(async () => {
try {
const res = await authFetch(BASE_URL + '/api/resource/Dispatch Settings/Dispatch Settings')
if (!res.ok) throw new Error('Failed to load settings')
const json = await res.json()
settings.value = json.data
// Snapshot all fields
for (const key of Object.keys(json.data)) {
snapshots[key] = json.data[key] ?? ''
}
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur chargement paramètres: ' + e.message })
} finally {
loading.value = false
}
})
async function save (field) {
const val = settings.value[field] ?? ''
const prev = snapshots[field] ?? ''
if (val === prev) return
snapshots[field] = val
try {
const res = await authFetch(BASE_URL + '/api/resource/Dispatch Settings/Dispatch Settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [field]: val }),
})
if (!res.ok) throw new Error('Save failed: ' + res.status)
Notify.create({ type: 'positive', message: 'Sauvegardé', timeout: 1500 })
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
settings.value[field] = prev
snapshots[field] = prev
}
}
async function testSms () {
testingSms.value = true
smsTestResult.value = null
try {
const result = await sendTestSms('+15149490739', 'Test SMS depuis Targo Ops', '')
smsTestResult.value = { ok: true, message: result.simulated ? 'Simulé (pas envoyé)' : 'SMS envoyé ✓' }
} catch (e) {
smsTestResult.value = { ok: false, message: e.message }
} finally {
testingSms.value = false
}
}
</script>

View File

@ -15,12 +15,12 @@
:options="statusOptions" @update:model-value="resetAndLoad" /> :options="statusOptions" @update:model-value="resetAndLoad" />
</div> </div>
<div class="col-6 col-md-2"> <div class="col-6 col-md-2">
<div class="filter-label">Type / Département</div> <div class="filter-label">Type / Departement</div>
<q-select v-model="typeFilter" dense outlined emit-value map-options clearable <q-select v-model="typeFilter" dense outlined emit-value map-options clearable
:options="issueTypes" @update:model-value="resetAndLoad" placeholder="Tous" /> :options="issueTypes" @update:model-value="resetAndLoad" placeholder="Tous" />
</div> </div>
<div class="col-6 col-md-2"> <div class="col-6 col-md-2">
<div class="filter-label">Priorité</div> <div class="filter-label">Priorite</div>
<q-select v-model="priorityFilter" dense outlined emit-value map-options clearable <q-select v-model="priorityFilter" dense outlined emit-value map-options clearable
:options="priorityOptions" @update:model-value="resetAndLoad" placeholder="Toutes" /> :options="priorityOptions" @update:model-value="resetAndLoad" placeholder="Toutes" />
</div> </div>
@ -72,7 +72,7 @@
<router-link v-if="props.row.customer" :to="'/clients/' + props.row.customer" class="erp-link" @click.stop> <router-link v-if="props.row.customer" :to="'/clients/' + props.row.customer" class="erp-link" @click.stop>
{{ props.row.customer_name || props.row.customer }} {{ props.row.customer_name || props.row.customer }}
</router-link> </router-link>
<span v-else class="text-grey-5"></span> <span v-else class="text-grey-5">---</span>
</q-td> </q-td>
</template> </template>
<template #body-cell-opening_date="props"> <template #body-cell-opening_date="props">
@ -111,7 +111,6 @@
</template> </template>
</q-table> </q-table>
<!-- TICKET DETAIL MODAL -->
<DetailModal <DetailModal
v-model:open="modalOpen" v-model:open="modalOpen"
:loading="modalLoading" :loading="modalLoading"
@ -122,7 +121,10 @@
:comments="modalComments" :comments="modalComments"
:comms="modalComms" :comms="modalComms"
:files="modalFiles" :files="modalFiles"
:dispatch-jobs="modalDispatchJobs"
@navigate="(dt, name) => loadModalTicket(name)" @navigate="(dt, name) => loadModalTicket(name)"
@dispatch-created="j => modalDispatchJobs.push(j)"
@dispatch-deleted="name => { modalDispatchJobs = modalDispatchJobs.filter(j => j.name !== name) }"
> >
<template #title-prefix> <template #title-prefix>
<q-icon v-if="modalTicket?.is_important" name="star" color="amber-7" size="18px" class="q-mr-xs" /> <q-icon v-if="modalTicket?.is_important" name="star" color="amber-7" size="18px" class="q-mr-xs" />
@ -147,6 +149,7 @@ import { listDocs, countDocs } from 'src/api/erp'
import { formatDate } from 'src/composables/useFormatters' import { formatDate } from 'src/composables/useFormatters'
import { ticketStatusClass as statusClass, priorityClass } from 'src/composables/useStatusClasses' import { ticketStatusClass as statusClass, priorityClass } from 'src/composables/useStatusClasses'
import { useDetailModal } from 'src/composables/useDetailModal' import { useDetailModal } from 'src/composables/useDetailModal'
import { statusOptions, priorityOptions, columns, buildFilters, getSortField } from 'src/config/ticket-config'
import DetailModal from 'src/components/shared/DetailModal.vue' import DetailModal from 'src/components/shared/DetailModal.vue'
import InlineField from 'src/components/shared/InlineField.vue' import InlineField from 'src/components/shared/InlineField.vue'
@ -160,86 +163,26 @@ const loading = ref(false)
const total = ref(0) const total = ref(0)
const pagination = ref({ page: 1, rowsPerPage: 25, rowsNumber: 0, sortBy: 'creation', descending: true }) const pagination = ref({ page: 1, rowsPerPage: 25, rowsNumber: 0, sortBy: 'creation', descending: true })
// Modal state (shared composable) const { modalOpen, modalLoading, modalDoc, modalComments, modalComms, modalFiles, modalDispatchJobs, openModal } = useDetailModal()
const { modalOpen, modalLoading, modalDoc, modalComments, modalComms, modalFiles, openModal } = useDetailModal()
const modalTicket = ref(null) const modalTicket = ref(null)
const statusOptions = [
{ label: 'Tous', value: 'all' },
{ label: 'Non fermés', value: 'not_closed' },
{ label: 'Ouverts', value: 'Open' },
{ label: 'Répondus', value: 'Replied' },
{ label: 'Résolus', value: 'Resolved' },
{ label: 'Fermés', value: 'Closed' },
]
const priorityOptions = [
{ label: 'Urgent', value: 'Urgent' },
{ label: 'Haute', value: 'High' },
{ label: 'Moyenne', value: 'Medium' },
{ label: 'Basse', value: 'Low' },
]
// Will be populated from ERPNext
const issueTypes = ref([]) const issueTypes = ref([])
const columns = [
{ name: 'important', label: '', field: 'is_important', align: 'center', style: 'width:30px;padding:0' },
{ name: 'legacy_id', label: '#', field: 'legacy_ticket_id', align: 'left', sortable: true, style: 'width:70px' },
{ name: 'subject', label: 'Sujet', field: 'subject', align: 'left' },
{ name: 'customer_name', label: 'Client', field: 'customer_name', align: 'left', sortable: true },
{ name: 'issue_type', label: 'Type', field: 'issue_type', align: 'left' },
{ name: 'opening_date', label: 'Date', field: 'opening_date', align: 'left', sortable: true },
{ name: 'priority', label: 'Priorité', field: 'priority', align: 'center', sortable: true },
{ name: 'status', label: 'Statut', field: 'status', align: 'center' },
]
// openExternal used for any link in new tab
function openExternal (url) {
window.open(url, '_blank')
}
function buildFilters () {
const filters = {}
if (statusFilter.value === 'not_closed') {
filters.status = ['!=', 'Closed']
} else if (statusFilter.value !== 'all') {
filters.status = statusFilter.value
}
if (typeFilter.value) filters.issue_type = typeFilter.value
if (priorityFilter.value) filters.priority = priorityFilter.value
if (search.value.trim()) {
const q = search.value.trim()
// If search is a number, search by legacy ticket ID
if (/^\d+$/.test(q)) {
filters.legacy_ticket_id = parseInt(q)
} else {
filters.subject = ['like', '%' + q + '%']
}
}
if (ownerFilter.value === 'mine') {
filters.owner = ['like', '%']
}
return filters
}
function resetAndLoad () { function resetAndLoad () {
pagination.value.page = 1 pagination.value.page = 1
loadTickets() loadTickets()
} }
// Map column names to actual sortable fields
function getSortField (col) {
if (col === 'opening_date') return 'creation'
if (col === 'legacy_id') return 'legacy_ticket_id'
return col || 'creation'
}
async function loadTickets () { async function loadTickets () {
loading.value = true loading.value = true
const filters = buildFilters() const filters = buildFilters({
statusFilter: statusFilter.value,
typeFilter: typeFilter.value,
priorityFilter: priorityFilter.value,
search: search.value,
ownerFilter: ownerFilter.value,
})
const limit = Math.min(pagination.value.rowsPerPage, 100) const limit = Math.min(pagination.value.rowsPerPage, 100)
try { try {
const [data, count] = await Promise.all([ const [data, count] = await Promise.all([
listDocs('Issue', { listDocs('Issue', {
@ -254,8 +197,7 @@ async function loadTickets () {
tickets.value = data tickets.value = data
total.value = count total.value = count
pagination.value.rowsNumber = count pagination.value.rowsNumber = count
} catch (e) { } catch {
console.error('Failed to load tickets', e)
tickets.value = [] tickets.value = []
total.value = 0 total.value = 0
pagination.value.rowsNumber = 0 pagination.value.rowsNumber = 0
@ -274,7 +216,6 @@ function onRequest (props) {
async function openTicketModal (row) { async function openTicketModal (row) {
modalTicket.value = row modalTicket.value = row
await openModal('Issue', row.name, row.subject) await openModal('Issue', row.name, row.subject)
// Sync full doc back to modalTicket for header display
if (modalDoc.value) modalTicket.value = { ...modalTicket.value, ...modalDoc.value } if (modalDoc.value) modalTicket.value = { ...modalTicket.value, ...modalDoc.value }
} }
@ -285,11 +226,7 @@ async function loadModalTicket (ticketName) {
async function loadIssueTypes () { async function loadIssueTypes () {
try { try {
const types = await listDocs('Issue Type', { const types = await listDocs('Issue Type', { fields: ['name'], limit: 100, orderBy: 'name asc' })
fields: ['name'],
limit: 100,
orderBy: 'name asc',
})
issueTypes.value = types.map(t => ({ label: t.name, value: t.name })) issueTypes.value = types.map(t => ({ label: t.name, value: t.name }))
} catch { } catch {
issueTypes.value = [] issueTypes.value = []
@ -303,30 +240,5 @@ onMounted(async () => {
</script> </script>
<style scoped> <style scoped>
.filter-label { .filter-label { font-size: 0.75rem; font-weight: 600; color: #6b7280; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.025em; }
font-size: 0.75rem;
font-weight: 600;
color: #6b7280;
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.erp-link {
color: #6366f1;
text-decoration: none;
cursor: pointer;
}
.erp-link:hover {
text-decoration: underline;
}
.clickable-table :deep(tbody tr) {
cursor: pointer;
}
.clickable-table :deep(tbody tr:hover td) {
background: #eef2ff !important;
}
/* Modal styles are in DetailModal.vue */
</style> </style>

View File

@ -0,0 +1,459 @@
/* ── Root ── */
.sb-root {
--sb-bg: #0d0f18; --sb-sidebar: #111422; --sb-card: #181c2e; --sb-card-h: #1e2338;
--sb-border: rgba(255,255,255,0.06); --sb-border-acc: rgba(99,102,241,0.4);
--sb-text: #e2e4ef; --sb-muted: #7b80a0;
--sb-acc: #6366f1; --sb-green: #10b981; --sb-red: #ef4444; --sb-orange: #f59e0b;
display: flex; flex-direction: column;
height: 100vh; width: 100%; overflow: hidden;
background: var(--sb-bg); color: var(--sb-text);
font-family: 'Inter', system-ui, sans-serif; font-size: 0.82rem;
}
/* ── Header ── */
.sb-header { display:flex; align-items:center; gap:0.5rem; padding:0 0.75rem; background:var(--sb-sidebar); border-bottom:1px solid var(--sb-border); box-shadow:0 1px 12px rgba(0,0,0,0.4); z-index:30; overflow:hidden; }
.sb-header-left { display:flex; align-items:center; gap:0.4rem; flex-shrink:0; }
.sb-header-center { display:flex; align-items:center; gap:0.35rem; flex:1; justify-content:center; }
.sb-header-right { display:flex; align-items:center; gap:0.35rem; flex-shrink:0; margin-left:auto; }
.sb-logo-btn { background:none; border:1px solid var(--sb-border); border-radius:6px; color:var(--sb-muted); font-size:0.72rem; font-weight:700; padding:0.2rem 0.55rem; cursor:pointer; white-space:nowrap; text-decoration:none; }
.sb-logo-btn:hover { color:var(--sb-text); border-color:var(--sb-border-acc); }
.sb-tabs { display:flex; align-items:center; border-left:1px solid var(--sb-border); margin-left:0.25rem; padding-left:0.4rem; gap:0.15rem; }
.sb-tab { background:none; border:none; color:var(--sb-muted); font-size:0.72rem; font-weight:600; padding:0.3rem 0.6rem; cursor:pointer; border-bottom:2px solid transparent; transition:color 0.15s, border-color 0.15s; white-space:nowrap; }
.sb-tab.active { color:var(--sb-acc); border-bottom-color:var(--sb-acc); }
.sb-tab-add { opacity:0.45; font-size:1rem; padding:0.15rem 0.4rem; }
.sb-tab-add:hover { opacity:1; }
.sb-hbtn { background:none; border:1px solid var(--sb-border); border-radius:6px; color:var(--sb-text); font-size:0.85rem; padding:0.2rem 0.55rem; cursor:pointer; }
.sb-hbtn:hover { background:var(--sb-card); }
.sb-today-btn { font-size:0.7rem; font-weight:700; }
.sb-period-label { font-size:0.78rem; font-weight:600; min-width:180px; text-align:center; white-space:nowrap; }
.sb-view-sw { display:flex; background:var(--sb-card); border:1px solid var(--sb-border); border-radius:6px; overflow:hidden; }
.sb-view-sw button { background:none; border:none; color:var(--sb-muted); font-size:0.68rem; font-weight:700; padding:0.22rem 0.6rem; cursor:pointer; transition:color 0.12s, background 0.12s; }
.sb-view-sw button.active { background:none; color:var(--sb-acc); box-shadow:inset 0 0 0 1.5px var(--sb-acc); }
.sb-search { background:var(--sb-card); border:1px solid var(--sb-border); border-radius:6px; color:var(--sb-text); font-size:0.72rem; padding:0.22rem 0.55rem; width:160px; outline:none; }
.sb-search::placeholder { color:var(--sb-muted); }
.sb-search:focus { border-color:var(--sb-border-acc); }
.sb-icon-btn { background:none; border:1px solid var(--sb-border); border-radius:6px; color:var(--sb-muted); font-size:0.68rem; font-weight:600; padding:0.22rem 0.55rem; cursor:pointer; white-space:nowrap; transition:color 0.12s, border-color 0.12s; }
.sb-icon-btn:hover, .sb-icon-btn.active { color:var(--sb-text); border-color:var(--sb-border-acc); background:var(--sb-card); }
.sb-user-menu { display:flex; align-items:center; gap:6px; }
.sb-user-name { font-size:11px; color:var(--sb-muted); max-width:120px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.sb-erp-link { font-size:10px; padding:2px 6px; background:rgba(99,102,241,.15); color:var(--sb-accent); border-radius:4px; text-decoration:none; font-weight:600; }
.sb-erp-link:hover { background:rgba(99,102,241,.3); }
.sb-logout-btn { font-size:14px !important; opacity:.6; }
.sb-logout-btn:hover { opacity:1; color:var(--sb-red); }
.sb-erp-dot { width:7px; height:7px; border-radius:50%; background:var(--sb-red); transition:background 0.3s; }
.sb-erp-dot.ok { background:var(--sb-green); }
.sb-wo-btn { background:var(--sb-acc); border:none; border-radius:6px; color:#fff; font-size:0.7rem; font-weight:800; padding:0.22rem 0.65rem; cursor:pointer; white-space:nowrap; }
.sb-wo-btn:hover { filter:brightness(1.15); }
/* ── Body ── */
.sb-body { flex:1; display:flex; overflow:hidden; min-height:0; position:relative; }
/* ── Toolbar dropdown panels ── */
.sb-toolbar-panel { background:var(--sb-sidebar); border-bottom:1px solid var(--sb-border); box-shadow:0 4px 16px rgba(0,0,0,0.4); z-index:18; flex-shrink:0; }
.sb-toolbar-panel-inner { padding:0; }
.sb-slide-down-enter-active, .sb-slide-down-leave-active { transition: max-height 0.2s ease, opacity 0.2s ease; overflow:hidden; }
.sb-slide-down-enter-from, .sb-slide-down-leave-to { max-height:0; opacity:0; }
.sb-slide-down-enter-to, .sb-slide-down-leave-from { max-height:400px; opacity:1; }
/* ── Sidebar ── */
.sb-sidebar-strip { width:48px; min-width:48px; flex-shrink:0; display:flex; flex-direction:column; align-items:center; background:var(--sb-sidebar); border-right:1px solid var(--sb-border); padding:6px 0; gap:2px; z-index:20; }
.sbs-search-wrap { width:100%; padding:0 6px; margin-bottom:4px; }
.sbs-search { width:100%; background:var(--sb-card); border:1px solid var(--sb-border); border-radius:6px; color:var(--sb-text); font-size:0.72rem; padding:5px 4px; outline:none; text-align:center; box-sizing:border-box; }
.sbs-search:focus { border-color:var(--sb-border-acc); }
.sbs-icon { width:36px; height:36px; border-radius:8px; border:none; background:none; color:var(--sb-muted); cursor:pointer; display:flex; align-items:center; justify-content:center; position:relative; transition:background 0.12s, color 0.12s; text-decoration:none; }
.sbs-icon:hover, .sbs-icon.active { background:var(--sb-card); color:var(--sb-text); }
.sbs-icon svg { width:16px; height:16px; }
.sbs-badge { position:absolute; top:4px; right:4px; width:7px; height:7px; border-radius:50%; background:var(--sb-acc); }
.sbs-count { position:absolute; top:2px; right:0; min-width:14px; height:14px; border-radius:7px; background:var(--sb-acc); color:#fff; font-size:0.5rem; font-weight:700; display:flex; align-items:center; justify-content:center; padding:0 3px; }
.sbs-search-full { width:100%; background:var(--sb-card); border:1px solid var(--sb-border); border-radius:6px; color:var(--sb-text); font-size:0.72rem; padding:0.25rem 0.45rem; outline:none; box-sizing:border-box; }
.sbs-search-full:focus { border-color:var(--sb-border-acc); }
/* ── Flyout ── */
.sb-flyout { width:260px; min-width:260px; flex-shrink:0; display:flex; flex-direction:column; background:var(--sb-sidebar); border-right:1px solid var(--sb-border); overflow-y:auto; z-index:19; box-shadow:4px 0 16px rgba(0,0,0,0.3); }
.sb-flyout::-webkit-scrollbar { width:3px; }
.sb-flyout::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.1); }
.sb-overlay-backdrop { position:absolute; top:0; left:0; right:0; bottom:0; z-index:24; background:rgba(0,0,0,0.3); }
.sb-left-overlay { position:absolute; top:0; left:0; z-index:25; width:280px; min-width:280px; height:100%; display:flex; flex-direction:column; background:var(--sb-sidebar); color:var(--sb-text); border-right:1px solid var(--sb-border); box-shadow:4px 0 16px rgba(0,0,0,0.5); }
.sbf-section { padding:0.6rem 0.65rem; border-bottom:1px solid var(--sb-border); flex-shrink:0; }
.sbf-section-grow { flex:1; overflow-y:auto; padding:0.5rem 0.65rem; display:flex; flex-direction:column; gap:0.3rem; }
.sbf-section-grow::-webkit-scrollbar { width:3px; }
.sbf-section-grow::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.1); }
.sbf-title { font-size:0.58rem; font-weight:800; text-transform:uppercase; letter-spacing:0.08em; color:var(--sb-muted); margin-bottom:0.4rem; display:flex; align-items:center; justify-content:space-between; }
.sbf-count { background:var(--sb-acc); color:#fff; font-size:0.58rem; padding:0.05rem 0.35rem; border-radius:8px; font-weight:700; }
.sbf-primary-btn { width:100%; background:var(--sb-acc); border:none; border-radius:6px; color:#fff; font-size:0.7rem; font-weight:700; padding:0.35rem 0.5rem; cursor:pointer; }
.sbf-primary-btn:hover { filter:brightness(1.12); }
.sbf-chip { display:flex; align-items:center; justify-content:space-between; background:rgba(99,102,241,0.12); border:1px solid var(--sb-border-acc); border-radius:6px; padding:0.2rem 0.45rem; font-size:0.68rem; margin-top:0.3rem; color:var(--sb-acc); }
.sbf-chip button { background:none; border:none; color:var(--sb-muted); cursor:pointer; font-size:0.8rem; }
.sbf-lbl { display:block; font-size:0.62rem; color:var(--sb-muted); margin:0.3rem 0 0.15rem; }
.sbf-select { width:100%; background:var(--sb-card); border:1px solid var(--sb-border); border-radius:5px; color:var(--sb-text); font-size:0.7rem; padding:0.22rem 0.4rem; cursor:pointer; }
.sbf-clear-btn { width:100%; background:none; border:1px solid rgba(239,68,68,0.3); border-radius:5px; color:var(--sb-red); font-size:0.67rem; padding:0.22rem; cursor:pointer; margin-top:0.3rem; }
.sbf-auto-btn { background:none; border:1px solid rgba(99,102,241,0.4); border-radius:4px; color:#6366f1; font-size:0.58rem; font-weight:700; padding:1px 6px; cursor:pointer; margin-left:auto; }
.sbf-auto-btn:hover { background:rgba(99,102,241,0.12); }
.sbf-clear-btn:hover { background:rgba(239,68,68,0.08); }
.sbf-drop-active { background:rgba(99,102,241,0.07); border-radius:6px; }
.sbf-drop-hint { font-size:0.68rem; color:var(--sb-acc); font-weight:700; text-align:center; padding:0.4rem; border:1.5px dashed var(--sb-border-acc); border-radius:6px; margin-bottom:0.35rem; }
.sbf-empty { font-size:0.72rem; color:var(--sb-muted); font-style:italic; padding:0.25rem 0; }
.sbf-card { display:flex; border-radius:7px; overflow:hidden; cursor:grab; background:var(--sb-card); border:1px solid var(--sb-border); transition:border-color 0.12s, transform 0.12s; }
.sbf-card:hover { border-color:var(--sb-border-acc); transform:translateY(-1px); }
.sbf-card-stripe { width:4px; flex-shrink:0; }
.sbf-card-body { flex:1; min-width:0; padding:0.35rem 0.45rem; }
.sbf-card-title { font-size:0.72rem; font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.sbf-card-meta { font-size:0.62rem; color:var(--sb-muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
/* ── Center ── */
.sb-center-col { flex:1; display:flex; flex-direction:column; min-width:0; min-height:0; overflow:hidden; }
.sb-board { flex:1; overflow:auto; min-width:0; position:relative; min-height:0; }
.sb-board::-webkit-scrollbar { width:5px; height:5px; }
.sb-board::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.1); border-radius:3px; }
/* ── Grid ── */
.sb-grid { display:flex; flex-direction:column; }
.sb-grid-cal { display:flex; flex-direction:column; width:100%; min-width:0; }
.sb-grid-hdr { display:flex; height:46px; flex-shrink:0; position:sticky; top:0; z-index:15; background:var(--sb-sidebar); border-bottom:1px solid var(--sb-border); }
.sb-res-hdr { width:200px; min-width:200px; flex-shrink:0; position:sticky; left:0; z-index:16; background:var(--sb-sidebar); border-right:1px solid var(--sb-border); display:flex; align-items:center; padding:0 0.75rem; font-size:0.6rem; font-weight:800; text-transform:uppercase; letter-spacing:0.07em; color:var(--sb-muted); gap:0.35rem; }
.sb-time-hdr-wrap { overflow:hidden; position:relative; }
.sb-cal-hdr { display:flex; flex:1; overflow:hidden; }
.sb-cal-hdr-cell { flex:1; min-width:0; border-left:1px solid var(--sb-border); display:flex; flex-direction:column; align-items:center; justify-content:center; gap:1px; padding:2px 0; }
.sb-cal-hdr-cell.sb-col-today { background:rgba(99,102,241,0.06); }
.sb-cal-wd { font-size:0.58rem; font-weight:800; text-transform:uppercase; letter-spacing:0.05em; color:var(--sb-muted); }
.sb-cal-dn { font-size:0.82rem; font-weight:700; color:var(--sb-text); line-height:1; }
.sb-today-bubble { background:var(--sb-acc); color:#fff !important; border-radius:50%; width:20px; height:20px; display:flex; align-items:center; justify-content:center; }
.sb-htick { position:absolute; top:0; height:100%; border-left:1px solid var(--sb-border); }
.sb-htick-lbl { position:absolute; top:6px; left:4px; font-size:0.58rem; color:var(--sb-muted); white-space:nowrap; font-weight:600; }
/* ── Rows ── */
.sb-row { display:flex; border-bottom:1px solid var(--sb-border); transition:background 0.12s; }
.sb-row-cal { align-items:stretch; min-height:44px; height:auto !important; }
.sb-row-sel { background:rgba(99,102,241,0.04); }
.sb-row-elevated { position:relative; z-index:8; }
.sb-row-elevated .sb-timeline { overflow:visible; }
.sb-loading-row, .sb-empty-row { padding:2rem; text-align:center; color:var(--sb-muted); font-style:italic; }
.sb-cal-row { display:flex; flex:1; min-width:0; overflow:hidden; }
.sb-cal-cell { flex:1; min-width:0; border-left:1px solid var(--sb-border); padding:2px 3px; position:relative; display:flex; flex-direction:column; gap:3px; transition:background 0.1s; }
.sb-cal-cell:hover { background:rgba(255,255,255,0.02); }
.sb-cal-drop { position:absolute; inset:1px; border:1.5px dashed var(--sb-acc); border-radius:4px; background:rgba(99,102,241,0.08); pointer-events:none; z-index:2; }
.sb-chip { font-size:0.62rem; font-weight:600; padding:4px 6px; border-radius:6px; border-left:3px solid transparent; overflow:hidden; cursor:pointer; transition:background 0.1s; color:var(--sb-text); line-height:1.5; z-index:1; position:relative; }
.sb-chip:hover { background:rgba(255,255,255,0.12); }
.sb-chip-done { opacity:0.45; text-decoration:line-through; }
.sb-chip-sel { outline:1.5px solid #6366f1; outline-offset:1px; }
.sb-chip-assist { opacity:0.7; border-left-style:dashed !important; font-style:italic; }
.sb-chip-assist-tag { font-size:0.5rem; margin-right:2px; }
.sb-chip-multi { outline:2px solid #f59e0b; outline-offset:1px; }
.sb-chip-urgent { width:6px; height:6px; border-radius:50%; background:#ef4444; flex-shrink:0; box-shadow:0 0 4px #ef4444; }
.sb-chip-line1 { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; display:flex; align-items:center; gap:3px; }
.sb-chip-line2 { font-size:0.52rem; color:rgba(255,255,255,0.7); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; line-height:1.1; display:flex; align-items:center; gap:2px; }
.sb-chip-line2 svg { width:9px; height:9px; flex-shrink:0; }
.sb-day-load { margin-top:auto; flex-shrink:0; padding:2px 2px 1px; display:flex; align-items:center; gap:3px; }
.sb-day-load-track { flex:1; height:3px; background:rgba(255,255,255,0.08); border-radius:2px; overflow:hidden; }
.sb-day-load-fill { height:100%; border-radius:2px; transition:width 0.3s, background 0.3s; }
.sb-day-load-label { font-size:0.5rem; font-weight:700; color:var(--sb-muted); white-space:nowrap; font-variant-numeric:tabular-nums; }
.sb-res-cell { width:200px; min-width:200px; flex-shrink:0; position:sticky; left:0; z-index:5; align-self:stretch; background:var(--sb-sidebar); border-right:1px solid var(--sb-border); display:flex; align-items:center; gap:0.5rem; padding:0 0.65rem; cursor:pointer; transition:background 0.12s; }
.sb-res-cell:hover { background:var(--sb-card-h); }
.sb-avatar { width:32px; height:32px; border-radius:50%; flex-shrink:0; display:flex; align-items:center; justify-content:center; font-size:0.68rem; font-weight:700; color:#fff; }
.sb-res-info { flex:1; min-width:0; }
.sb-res-name { font-size:0.78rem; font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; display:flex; align-items:center; gap:3px; }
.sb-res-tag-dot { width:6px; height:6px; border-radius:50%; flex-shrink:0; }
.sb-res-tag-btn {
background:none; border:none; color:#7b80a0; font-size:0.6rem; font-weight:800;
cursor:pointer; padding:0 2px; line-height:1; opacity:0; transition:opacity 0.15s;
}
.sb-res-cell:hover .sb-res-tag-btn { opacity:1; }
.sb-res-tag-btn:hover { color:#a5b4fc; }
.sb-res-sub { display:flex; align-items:center; gap:0.3rem; margin:0.1rem 0; }
.sb-load { font-size:0.58rem; color:var(--sb-muted); }
.sb-util-bar { height:3px; background:rgba(255,255,255,0.07); border-radius:2px; overflow:hidden; margin-top:0.15rem; }
.sb-util-fill { height:100%; border-radius:2px; transition:width 0.3s, background 0.3s; }
.sb-capacity-line { position:absolute; top:0; bottom:0; width:1px; border-left:1px dashed rgba(255,255,255,0.18); z-index:1; pointer-events:none; }
.sb-st { font-size:0.58rem; font-weight:700; padding:0.07rem 0.3rem; border-radius:4px; white-space:nowrap; }
.st-available { background:rgba(16,185,129,0.15); color:var(--sb-green); }
.st-enroute { background:rgba(245,158,11,0.15); color:var(--sb-orange); }
.st-busy { background:rgba(99,102,241,0.15); color:#818cf8; }
.st-off { background:rgba(239,68,68,0.1); color:var(--sb-red); }
.prio-high { color:var(--sb-red); font-weight:700; }
.prio-med { color:var(--sb-orange); font-weight:700; }
.prio-low { color:var(--sb-green); }
.sb-timeline { position:relative; flex:1; overflow:hidden; }
.sb-day-bg, .sb-month-bg { position:absolute; top:0; bottom:0; }
.sb-bg-alt { background:rgba(255,255,255,0.012); }
.sb-bg-today { background:rgba(99,102,241,0.06); }
.sb-hour-guide { position:absolute; top:0; bottom:0; width:1px; background:var(--sb-border); opacity:0.6; pointer-events:none; }
.sb-quarter-guide { position:absolute; top:0; bottom:0; width:1px; background:var(--sb-border); opacity:0.2; pointer-events:none; }
.sb-drop-line { position:absolute; top:2px; bottom:2px; width:3px; background:#6366f1; border-radius:2px; box-shadow:0 0 10px #6366f1, 0 0 4px #6366f1; pointer-events:none; z-index:20; transform:translateX(-1px); }
.sb-block { position:absolute; border-radius:6px; overflow:hidden; display:flex; align-items:center; cursor:grab; z-index:4; box-shadow:0 2px 8px rgba(0,0,0,0.35); transition:box-shadow 0.12s, transform 0.12s; min-width:18px; }
.sb-block:hover { box-shadow:0 4px 16px rgba(0,0,0,0.5); transform:translateY(-1px); z-index:5; }
.sb-block-done { opacity:0.55; }
.sb-block.sb-block-sel { outline:2px solid #6366f1; outline-offset:1px; z-index:6 !important; }
.sb-block.sb-block-multi { outline:2px solid #f59e0b; outline-offset:1px; z-index:6 !important; }
.sb-multi-bar { position:fixed; bottom:20px; left:50%; transform:translateX(-50%); z-index:200; background:#181c2e; color:#e2e4ef; border:1px solid rgba(99,102,241,0.3); border-radius:10px; padding:6px 14px; display:flex; align-items:center; gap:8px; box-shadow:0 8px 32px rgba(0,0,0,0.5); font-size:0.72rem; }
.sb-multi-count { font-weight:700; color:#f59e0b; }
.sb-multi-btn { background:none; border:1px solid rgba(255,255,255,0.1); border-radius:5px; color:#e2e4ef; font-size:0.65rem; padding:3px 8px; cursor:pointer; }
.sb-multi-btn:hover { background:rgba(255,255,255,0.08); }
.sb-multi-btn.sb-ctx-warn { color:#ef4444; border-color:rgba(239,68,68,0.3); }
.sb-multi-clear { color:#7b80a0; }
.sb-multi-sep { color:rgba(255,255,255,0.15); }
.sb-multi-lbl { color:#7b80a0; font-size:0.62rem; }
.sb-multi-tech { background:none; border:2px solid; border-radius:50%; width:24px; height:24px; display:flex; align-items:center; justify-content:center; font-size:0.5rem; font-weight:800; color:#e2e4ef; cursor:pointer; }
.sb-multi-tech:hover { filter:brightness(1.3); }
.sb-slide-up-enter-active, .sb-slide-up-leave-active { transition:transform 0.2s, opacity 0.2s; }
.sb-slide-up-enter-from, .sb-slide-up-leave-to { transform:translateX(-50%) translateY(20px); opacity:0; }
.sb-lasso { position:absolute; border:1.5px dashed #f59e0b; background:rgba(245,158,11,0.08); border-radius:3px; pointer-events:none; z-index:50; }
.sb-board:has(.sb-lasso) { user-select:none; -webkit-user-select:none; cursor:crosshair; }
.sb-block.sb-block-linked { outline:2px dashed #6366f1; outline-offset:1px; z-index:7 !important; opacity:1 !important; box-shadow:0 4px 16px rgba(99,102,241,0.3) !important; transform:translateY(-1px); }
.sb-block-status-icon { display:inline-flex; align-items:center; margin-right:5px; flex-shrink:0; }
.sb-block-status-icon svg { width:13px; height:13px; }
.sb-block-assistants { display:flex; gap:2px; position:absolute; top:2px; right:2px; }
.sb-assist-badge { width:16px; height:16px; border-radius:50%; font-size:0.45rem; font-weight:800; color:#fff; display:flex; align-items:center; justify-content:center; border:1.5px solid rgba(0,0,0,0.3); }
.sb-assist-badge-lead { border-color:rgba(255,255,255,0.5); }
.sb-block-team { outline:1.5px solid rgba(255,255,255,0.25); outline-offset:1px; }
.sb-block-drop-hover { outline:2px solid #6366f1 !important; outline-offset:1px; filter:brightness(1.3); }
.sb-block-assist { opacity:0.7; border:1.5px dashed rgba(255,255,255,0.25); cursor:pointer; z-index:3; background-image:repeating-linear-gradient(-45deg, transparent, transparent 4px, rgba(255,255,255,0.04) 4px, rgba(255,255,255,0.04) 8px) !important; }
.sb-block-assist .sb-block-meta { font-size:0.52rem; }
.sb-block-assist:hover { opacity:0.9; }
.sb-block-assist-pinned { opacity:0.85; border-style:solid; background-image:none !important; }
.sb-move-handle { position:absolute; left:0; top:0; bottom:0; width:8px; cursor:grab; z-index:6; }
.sb-move-handle:hover { background:rgba(255,255,255,0.15); border-radius:6px 0 0 6px; }
.sb-move-handle::after { content:''; position:absolute; left:1px; top:50%; transform:translateY(-50%); font-size:0.5rem; color:rgba(255,255,255,0.4); }
.sb-move-handle:active { cursor:grabbing; }
.sb-resize-handle { position:absolute; right:0; top:0; bottom:0; width:6px; cursor:ew-resize; z-index:6; }
.sb-resize-handle:hover { background:rgba(255,255,255,0.15); border-radius:0 6px 6px 0; }
.sb-resize-handle::after { content:''; position:absolute; right:2px; top:50%; transform:translateY(-50%); width:2px; height:12px; border-radius:1px; background:rgba(255,255,255,0.3); }
.sbf-team-badges { display:flex; gap:2px; margin-top:2px; }
.sb-type-icon { display:inline-flex; align-items:center; margin-right:3px; flex-shrink:0; vertical-align:middle; }
.sb-type-icon svg { width:11px; height:11px; }
.sb-block-color-bar { width:3px; height:100%; flex-shrink:0; background:rgba(0,0,0,0.25); }
.sb-block-inner { flex:1; min-width:0; padding:0 5px; }
.sb-block-title { font-size:0.68rem; font-weight:700; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; color:#fff; }
.sb-block-meta { font-size:0.58rem; color:rgba(255,255,255,0.7); white-space:nowrap; }
.sb-block-addr { font-size:0.52rem; color:rgba(255,255,255,0.5); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; display:flex; align-items:center; gap:2px; }
.sb-block-addr svg { width:9px; height:9px; flex-shrink:0; }
.sb-block-pin { display:inline-flex; align-items:center; margin-right:2px; }
.sb-block-pin svg { width:10px; height:10px; }
.sb-travel-trail { position:absolute; border-radius:4px; z-index:2; display:flex; align-items:center; justify-content:center; pointer-events:none; transition:opacity 0.2s; }
.sb-travel-route { border-left-style:solid; }
.sb-travel-est { border-left-style:dashed; opacity:0.7; }
.sb-travel-lbl { font-size:0.55rem; color:rgba(255,255,255,0.65); white-space:nowrap; font-style:italic; }
/* ── Bottom panel ── */
.sb-bottom-panel { flex-shrink:0; border-top:1px solid var(--sb-border); display:flex; flex-direction:column; background:var(--sb-sidebar); position:relative; overflow:hidden; }
.sb-bottom-resize { position:absolute; top:0; left:0; right:0; height:4px; z-index:10; cursor:row-resize; background:transparent; }
.sb-bottom-resize:hover { background:rgba(99,102,241,0.35); }
.sb-bottom-hdr { display:flex; align-items:center; gap:0.5rem; padding:0.35rem 0.75rem; border-bottom:1px solid var(--sb-border); flex-shrink:0; }
.sb-bottom-title { font-size:0.62rem; font-weight:800; text-transform:uppercase; letter-spacing:0.07em; color:var(--sb-muted); display:flex; align-items:center; gap:0.35rem; }
.sb-bottom-close { background:none; border:none; color:var(--sb-muted); cursor:pointer; font-size:0.9rem; margin-left:auto; }
.sb-bottom-close:hover { color:var(--sb-red); }
.sb-bottom-body { flex:1; overflow-y:auto; overflow-x:auto; display:flex; flex-direction:column; }
.sb-bottom-body::-webkit-scrollbar { width:4px; height:4px; }
.sb-bottom-body::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.1); }
.sb-bottom-date-sep { display:flex; align-items:center; gap:0.4rem; padding:0.3rem 0.75rem; background:rgba(99,102,241,0.06); border-bottom:1px solid var(--sb-border); position:sticky; top:0; z-index:2; user-select:none; cursor:crosshair; }
.sb-bottom-date-label { font-size:0.62rem; font-weight:800; color:var(--sb-acc); text-transform:uppercase; letter-spacing:0.05em; }
.sb-bottom-date-count { font-size:0.55rem; color:var(--sb-muted); }
.sb-bottom-scroll { flex:1; overflow-y:auto; overflow-x:hidden; min-height:0; cursor:crosshair; }
.sb-bottom-scroll::-webkit-scrollbar { width:4px; }
.sb-bottom-scroll::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.1); }
.sb-bottom-table { width:100%; border-collapse:collapse; font-size:0.72rem; table-layout:fixed; }
.sb-bottom-table thead th { position:sticky; top:0; z-index:3; background:var(--sb-sidebar); text-align:left; font-size:0.55rem; font-weight:800; text-transform:uppercase; letter-spacing:0.06em; color:var(--sb-muted); padding:0.3rem 0.5rem; border-bottom:1px solid var(--sb-border); white-space:nowrap; overflow:hidden; }
.sb-col-resize { position:absolute; right:0; top:0; bottom:0; width:5px; cursor:col-resize; z-index:4; }
.sb-col-resize:hover { background:rgba(99,102,241,0.35); }
.sb-bottom-row { cursor:pointer; transition:background 0.1s; border-bottom:1px solid var(--sb-border); }
.sb-bottom-row:hover { background:var(--sb-card-h); }
.sb-bottom-row-sel { background:rgba(99,102,241,0.1) !important; }
.sb-bottom-row-sel:hover { background:rgba(99,102,241,0.15) !important; }
.sb-bt-lasso { position:absolute; border:1.5px dashed #f59e0b; background:rgba(245,158,11,0.08); pointer-events:none; z-index:50; border-radius:3px; }
.sb-bottom-scroll:has(.sb-bt-lasso) { user-select:none; -webkit-user-select:none; cursor:crosshair; }
.sb-bottom-row td { padding:0.3rem 0.5rem; white-space:nowrap; color:var(--sb-text); overflow:hidden; text-overflow:ellipsis; }
.sb-bt-chk { padding:0 !important; text-align:center !important; }
.sb-bt-checkbox { display:inline-block; width:14px; height:14px; border-radius:3px; border:1.5px solid var(--sb-muted); vertical-align:middle; position:relative; }
.sb-bt-checkbox.checked { background:var(--sb-acc); border-color:var(--sb-acc); }
.sb-bt-checkbox.checked::after { content:''; position:absolute; inset:0; color:#fff; font-size:0.55rem; font-weight:800; display:flex; align-items:center; justify-content:center; }
.sb-bt-prio-dot { width:8px; height:8px; border-radius:50%; display:inline-block; }
.sb-bt-name-text { font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; display:block; }
.sb-bt-addr { color:var(--sb-muted); font-size:0.65rem; overflow:hidden; text-overflow:ellipsis; }
.sb-bt-dur-wrap { display:flex; align-items:center; gap:6px; }
.sb-bt-dur-bar { flex:1; height:4px; background:rgba(255,255,255,0.07); border-radius:2px; overflow:hidden; min-width:30px; }
.sb-bt-dur-fill { height:100%; border-radius:2px; transition:width 0.2s; }
.sb-bt-dur-lbl { font-size:0.62rem; color:var(--sb-muted); font-variant-numeric:tabular-nums; white-space:nowrap; min-width:28px; }
.sb-bt-prio-tag { font-size:0.6rem; font-weight:700; padding:1px 6px; border-radius:4px; }
.sb-bt-skill-chip { display:inline-flex; align-items:center; font-size:0.55rem; font-weight:600; color:var(--sb-text); background:rgba(255,255,255,0.08); border:1px solid var(--sb-border); padding:1px 6px; border-radius:10px; margin-right:3px; white-space:nowrap; }
.sb-bt-no-tag { font-size:0.6rem; color:rgba(255,255,255,0.2); }
.sb-bottom-sel-count { font-size:0.65rem; font-weight:700; color:var(--sb-acc); }
.sb-bottom-sel-lbl { font-size:0.72rem; color:var(--sb-muted); }
.sb-bottom-sel-clear { background:none; border:none; color:var(--sb-muted); cursor:pointer; font-size:0.8rem; }
.sb-bottom-sel-clear:hover { color:var(--sb-red); }
.sb-bottom-sel-all { background:none; border:1px solid var(--sb-border); border-radius:4px; color:var(--sb-muted); font-size:0.58rem; padding:2px 6px; cursor:pointer; }
.sb-bottom-sel-all:hover { color:var(--sb-text); border-color:var(--sb-border-acc); }
.sb-crit-row { display:flex; align-items:center; gap:0.5rem; padding:0.4rem 0.5rem; border-bottom:1px solid var(--sb-border); background:var(--sb-card); border-radius:6px; margin-bottom:4px; cursor:grab; transition:background 0.12s, transform 0.12s, border-color 0.12s; }
.sb-crit-row:active { cursor:grabbing; }
.sb-crit-drag-over { border-color:var(--sb-acc); background:rgba(99,102,241,0.12); transform:scale(1.02); }
.sb-crit-order { font-size:0.65rem; font-weight:800; color:var(--sb-acc); width:18px; text-align:center; flex-shrink:0; }
.sb-crit-label { flex:1; font-size:0.72rem; color:var(--sb-text); display:flex; align-items:center; gap:0.4rem; cursor:pointer; }
.sb-crit-label input[type="checkbox"] { accent-color:var(--sb-acc); }
.sb-crit-arrows { display:flex; flex-direction:column; gap:1px; }
.sb-crit-arrows button { background:none; border:1px solid var(--sb-border); border-radius:3px; color:var(--sb-muted); font-size:0.5rem; padding:0 4px; cursor:pointer; line-height:1.2; }
.sb-crit-arrows button:hover:not(:disabled) { color:var(--sb-text); border-color:var(--sb-border-acc); }
.sb-crit-arrows button:disabled { opacity:0.25; cursor:default; }
.sb-crit-handle { color:var(--sb-muted); font-size:0.7rem; cursor:grab; user-select:none; flex-shrink:0; opacity:0.4; transition:opacity 0.12s; }
.sb-crit-row:hover .sb-crit-handle { opacity:0.8; }
/* ── Month ── */
.sb-month-wrap { flex:1; overflow-y:auto; display:flex; flex-direction:column; padding:0.5rem; gap:0.25rem; }
.sb-month-dow-hdr { display:grid; grid-template-columns:repeat(7,1fr); gap:2px; margin-bottom:2px; flex-shrink:0; }
.sb-month-dow { text-align:center; font-size:0.6rem; font-weight:800; color:var(--sb-muted); padding:0.25rem 0; text-transform:uppercase; letter-spacing:0.07em; }
.sb-month-week { display:grid; grid-template-columns:repeat(7,1fr); gap:2px; }
.sb-month-day { background:var(--sb-card); border:1px solid var(--sb-border); border-radius:6px; padding:0.3rem 0.35rem; min-height:80px; cursor:pointer; display:flex; flex-direction:column; gap:3px; transition:background 0.1s; }
.sb-month-day:hover { background:var(--sb-card-h); }
.sb-month-day-out { opacity:0.28; pointer-events:none; }
.sb-month-day-today { border-color:var(--sb-acc); }
.sb-month-day-num { font-size:0.7rem; font-weight:700; color:var(--sb-text); width:20px; height:20px; display:flex; align-items:center; justify-content:center; border-radius:50%; flex-shrink:0; }
.sb-month-day-today .sb-month-day-num { background:var(--sb-acc); color:#fff; }
.sb-month-avatars { display:flex; flex-wrap:wrap; gap:2px; }
.sb-month-avatar { width:18px; height:18px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:0.48rem; font-weight:800; color:#fff; flex-shrink:0; cursor:pointer; transition:transform 0.1s; box-shadow:0 1px 3px rgba(0,0,0,0.4); }
.sb-month-avatar:hover { transform:scale(1.3); z-index:2; position:relative; }
.sb-month-job-count { font-size:0.52rem; color:var(--sb-muted); margin-top:auto; }
/* ── Map ── */
.sb-map-backdrop { position:absolute; top:0; left:0; right:0; bottom:0; z-index:14; background:rgba(0,0,0,0.15); }
.sb-map-panel { flex-shrink:0; z-index:15; display:flex; flex-direction:column; border-left:1px solid var(--sb-border); overflow:hidden; position:relative; }
.sb-map-resize-handle { position:absolute; left:0; top:0; bottom:0; width:5px; z-index:10; cursor:col-resize; background:transparent; transition:background 0.15s; }
.sb-map-resize-handle:hover { background:rgba(99,102,241,0.35); }
.sb-map-bar { display:flex; align-items:center; gap:0.4rem; padding:0.4rem 0.65rem; background:var(--sb-sidebar); border-bottom:1px solid var(--sb-border); flex-shrink:0; }
.sb-map-title { font-size:0.62rem; font-weight:800; text-transform:uppercase; letter-spacing:0.07em; color:var(--sb-muted); }
.sb-map-tech { font-size:0.7rem; font-weight:700; }
.sb-map-route-hint { font-size:0.58rem; font-weight:400; color:rgba(255,255,255,0.35); margin-left:0.25rem; }
.sb-map-hint { font-size:0.62rem; color:var(--sb-muted); font-style:italic; flex:1; }
.sb-map-bar-geofix { background:rgba(99,102,241,0.18); border-bottom-color:var(--sb-border-acc); }
.sb-geofix-hint { flex:1; font-size:0.68rem; color:var(--sb-text); animation:sb-geofix-pulse 1.4s ease-in-out infinite; }
.sb-geofix-cancel { background:none; border:1px solid var(--sb-border-acc); border-radius:5px; color:var(--sb-muted); font-size:0.65rem; padding:0.18rem 0.45rem; cursor:pointer; }
.sb-geofix-cancel:hover { color:var(--sb-red); border-color:rgba(239,68,68,0.4); }
@keyframes sb-geofix-pulse { 0%,100%{ opacity:1 } 50%{ opacity:0.55 } }
.sb-map-close { background:none; border:none; color:var(--sb-muted); cursor:pointer; font-size:0.9rem; margin-left:auto; }
.sb-map-legend { display:flex; flex-wrap:wrap; gap:0.3rem 0.6rem; padding:0.3rem 0.65rem; background:var(--sb-sidebar); border-bottom:1px solid var(--sb-border); flex-shrink:0; }
.sb-legend-item { display:flex; align-items:center; gap:0.2rem; font-size:0.62rem; color:var(--sb-muted); }
.sb-legend-dot { width:7px; height:7px; border-radius:50%; flex-shrink:0; }
.sb-map { flex:1; min-height:0; }
.sb-map-tech-pin { width:36px; height:36px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:0.65rem; font-weight:800; color:#fff; border:2.5px solid; box-shadow:0 2px 10px rgba(0,0,0,0.55); cursor:pointer; transition:transform 0.15s; }
.sb-map-tech-pin:hover { transform:scale(1.2); }
.sb-map-tech-pin { position:relative; }
.sb-map-crew-badge { position:absolute; top:-4px; right:-6px; min-width:16px; height:16px; border-radius:8px; background:#6366f1; color:#fff; font-size:9px; font-weight:800; display:flex; align-items:center; justify-content:center; border:2px solid #0f172a; line-height:1; padding:0 3px; }
.sb-map-gps-active { box-shadow:0 0 0 3px rgba(16,185,129,0.5), 0 0 12px rgba(16,185,129,0.4), 0 2px 10px rgba(0,0,0,0.55); animation:gps-glow 2s infinite; }
@keyframes gps-glow { 0%,100% { box-shadow:0 0 0 3px rgba(16,185,129,0.5), 0 0 12px rgba(16,185,129,0.4), 0 2px 10px rgba(0,0,0,0.55); } 50% { box-shadow:0 0 0 6px rgba(16,185,129,0.3), 0 0 20px rgba(16,185,129,0.3), 0 2px 10px rgba(0,0,0,0.55); } }
.sb-map-drag-ghost { padding:4px 8px; border-radius:6px; background:rgba(99,102,241,0.9); color:#fff; font-size:0.68rem; font-weight:700; box-shadow:0 4px 16px rgba(0,0,0,0.55); max-width:180px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; user-select:none; }
/* ── Right panel ── */
.sb-right { width:280px; min-width:280px; flex-shrink:0; display:flex; flex-direction:column; background:#111422; color:#e2e4ef; border-left:1px solid rgba(255,255,255,0.06); overflow:hidden; }
.sb-rp-hdr { display:flex; align-items:center; padding:0.55rem 0.75rem; border-bottom:1px solid rgba(255,255,255,0.06); flex-shrink:0; }
.sb-rp-title { font-size:0.68rem; font-weight:800; text-transform:uppercase; letter-spacing:0.06em; color:#7b80a0; flex:1; }
.sb-rp-close { background:none; border:none; color:#7b80a0; cursor:pointer; font-size:0.95rem; transition:color 0.12s; }
.sb-rp-close:hover { color:#ef4444; }
.sb-rp-body { flex:1; overflow-y:auto; padding:0.65rem 0.75rem; color:#e2e4ef; }
.sb-rp-body::-webkit-scrollbar { width:3px; }
.sb-rp-body::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.1); }
.sb-rp-color-bar { height:3px; border-radius:2px; margin-bottom:0.75rem; }
.sb-rp-urgent-tag { background:rgba(239,68,68,0.15); color:#ef4444; font-size:0.7rem; font-weight:700; padding:0.25rem 0.5rem; border-radius:6px; display:inline-block; margin-bottom:0.5rem; }
.sb-rp-field { margin-bottom:0.45rem; color:#e2e4ef; }
.sb-rp-lbl { display:block; font-size:0.58rem; font-weight:700; text-transform:uppercase; letter-spacing:0.05em; color:#7b80a0; margin-bottom:0.1rem; }
.sb-rp-actions { padding:0.65rem 0.75rem; border-top:1px solid rgba(255,255,255,0.06); display:flex; flex-direction:column; gap:0.35rem; flex-shrink:0; }
.sb-rp-primary { background:#6366f1; border:none; border-radius:7px; color:#fff; font-size:0.72rem; font-weight:700; padding:0.4rem 0.75rem; cursor:pointer; }
.sb-rp-primary:hover { filter:brightness(1.12); }
.sb-rp-btn { background:none; border:1px solid rgba(255,255,255,0.06); border-radius:7px; color:#7b80a0; font-size:0.7rem; padding:0.35rem 0.75rem; cursor:pointer; transition:border-color 0.12s, color 0.12s; }
.sb-rp-btn:hover { border-color:rgba(99,102,241,0.4); color:#e2e4ef; }
.sb-assign-grid { display:flex; flex-direction:column; gap:0.3rem; }
.sb-assign-btn { display:flex; align-items:center; gap:0.4rem; background:none; border:1px solid rgba(255,255,255,0.06); border-radius:6px; color:#e2e4ef; font-size:0.7rem; padding:0.3rem 0.55rem; cursor:pointer; transition:background 0.12s; text-align:left; }
.sb-assign-btn:hover { background:#181c2e; }
.sb-assign-dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; }
/* ── Context menu ── */
.sb-ctx { position:fixed; z-index:200; background:#181c2e; color:#e2e4ef; border:1px solid rgba(99,102,241,0.4); border-radius:9px; padding:0.3rem; box-shadow:0 8px 28px rgba(0,0,0,0.55); min-width:180px; }
.sb-ctx-item { display:block; width:100%; background:none; border:none; border-radius:6px; color:#e2e4ef; font-size:0.75rem; padding:0.4rem 0.7rem; cursor:pointer; text-align:left; transition:background 0.1s; }
.sb-ctx-item:hover { background:#1e2338; }
.sb-ctx-sep { height:1px; background:rgba(255,255,255,0.06); margin:0.2rem 0; }
.sb-ctx-warn { color:#ef4444 !important; }
/* ── Modals ── */
.sb-overlay { position:fixed; inset:0; background:rgba(0,0,0,0.65); backdrop-filter:blur(4px); z-index:100; display:flex; align-items:center; justify-content:center; }
.sb-modal { background:#111422; color:#e2e4ef; border:1px solid rgba(255,255,255,0.06); border-radius:14px; padding:0; min-width:360px; max-width:500px; width:100%; box-shadow:0 24px 60px rgba(0,0,0,0.6); overflow:hidden; max-height:85vh; display:flex; flex-direction:column; }
.sb-modal-wide { min-width:580px; max-width:680px; }
.sb-modal-hdr { display:flex; align-items:center; justify-content:space-between; padding:0.75rem 1rem; border-bottom:1px solid rgba(255,255,255,0.06); font-weight:700; font-size:0.85rem; color:#e2e4ef; }
.sb-modal-body { padding:0.75rem 1rem; color:#e2e4ef; overflow-y:auto; flex:1; min-height:0; }
.sb-modal-body::-webkit-scrollbar { width:4px; }
.sb-modal-body::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.1); border-radius:2px; }
.sb-modal-ftr { padding:0.65rem 1rem; border-top:1px solid rgba(255,255,255,0.06); display:flex; gap:0.5rem; }
.sb-modal-footer { padding:0.65rem 1rem; border-top:1px solid rgba(255,255,255,0.06); display:flex; gap:0.5rem; }
.sb-form-row { margin-bottom:0.6rem; }
.sb-form-lbl { display:block; font-size:0.62rem; font-weight:700; text-transform:uppercase; letter-spacing:0.05em; color:#7b80a0; margin-bottom:0.2rem; }
.sb-form-val { font-size:0.78rem; font-weight:600; color:#e2e4ef; }
.sb-form-sel { width:100%; background:#181c2e; border:1px solid rgba(255,255,255,0.06); border-radius:6px; color:#e2e4ef; font-size:0.75rem; padding:0.3rem 0.5rem; cursor:pointer; }
.sb-form-input { width:100%; background:#181c2e; border:1px solid rgba(255,255,255,0.06); border-radius:6px; color:#e2e4ef; font-size:0.75rem; padding:0.3rem 0.5rem; box-sizing:border-box; }
.sb-form-sel:focus, .sb-form-input:focus { border-color:rgba(99,102,241,0.4); outline:none; }
.sb-addr-wrap { position:relative; }
.sb-addr-dropdown { position:absolute; top:100%; left:0; right:0; z-index:50; background:#181c2e; border:1px solid rgba(99,102,241,0.3); border-radius:6px; max-height:200px; overflow-y:auto; box-shadow:0 8px 24px rgba(0,0,0,0.4); }
.sb-addr-item { padding:6px 10px; cursor:pointer; font-size:0.72rem; color:#e2e4ef; border-bottom:1px solid rgba(255,255,255,0.04); }
.sb-addr-item:hover { background:rgba(99,102,241,0.12); }
.sb-addr-item strong { color:#fff; }
.sb-addr-city { float:right; font-size:0.6rem; color:#7b80a0; }
.sb-addr-confirmed { font-size:0.6rem; color:#10b981; margin-top:3px; }
.sb-addr-cp { font-size:0.6rem; color:#6366f1; margin-left:4px; }
.sb-modal-wo { min-width:500px; max-width:720px; }
.sb-wo-body { display:flex; gap:1rem; }
.sb-wo-form { flex:1; min-width:0; }
.sb-wo-minimap { width:280px; flex-shrink:0; display:flex; align-items:flex-start; }
.sb-minimap-img { width:100%; border-radius:8px; border:1px solid rgba(255,255,255,0.06); }
.sb-res-sel-wrap { display:flex; gap:1rem; align-items:flex-start; }
.sb-res-sel-col { flex:1; background:#181c2e; border:1px solid rgba(255,255,255,0.06); border-radius:8px; overflow:hidden; max-height:300px; overflow-y:auto; }
.sb-res-sel-col::-webkit-scrollbar { width:3px; }
.sb-res-sel-col::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.1); }
.sb-res-sel-hdr { padding:0.4rem 0.6rem; background:#111422; font-size:0.6rem; font-weight:800; text-transform:uppercase; letter-spacing:0.06em; color:#7b80a0; border-bottom:1px solid rgba(255,255,255,0.06); }
.sb-res-sel-item { display:flex; align-items:center; gap:0.45rem; padding:0.4rem 0.6rem; cursor:pointer; font-size:0.72rem; color:#e2e4ef; transition:background 0.1s; border-bottom:1px solid rgba(255,255,255,0.06); }
.sb-res-sel-item:hover { background:#1e2338; }
.sb-res-sel-active { color:#6366f1; }
.sb-res-sel-rm { margin-left:auto; color:#7b80a0; font-size:0.8rem; }
.sb-res-sel-arrow { font-size:1.2rem; color:#7b80a0; align-self:center; flex-shrink:0; }
.sb-avatar-xs { width:22px; height:22px; border-radius:50%; flex-shrink:0; display:flex; align-items:center; justify-content:center; font-size:0.55rem; font-weight:700; color:#fff; }
/* ── Transitions ── */
.sb-slide-left-enter-active, .sb-slide-left-leave-active { transition:width 0.18s, min-width 0.18s, opacity 0.18s; }
.sb-slide-left-enter-from, .sb-slide-left-leave-to { width:0!important; min-width:0!important; opacity:0; overflow:hidden; }
.sb-slide-right-enter-active, .sb-slide-right-leave-active { transition:width 0.18s, min-width 0.18s, opacity 0.18s; }
.sb-slide-right-enter-from, .sb-slide-right-leave-to { width:0!important; min-width:0!important; opacity:0; overflow:hidden; }
/* GPS Settings Modal */
.sb-modal-overlay { position:fixed; inset:0; background:rgba(0,0,0,0.6); z-index:200; display:flex; align-items:center; justify-content:center; }
.sb-gps-modal { background:var(--sb-card); border:1px solid var(--sb-border); border-radius:12px; width:700px; max-height:80vh; overflow:hidden; display:flex; flex-direction:column; }
.sb-gps-modal-hdr { padding:14px 20px; border-bottom:1px solid var(--sb-border); display:flex; align-items:center; justify-content:space-between; }
.sb-gps-modal-hdr h3 { font-size:15px; margin:0; }
.sb-gps-close { background:none; border:none; color:var(--sb-muted); cursor:pointer; font-size:20px; }
.sb-gps-modal-body { padding:16px 20px; overflow-y:auto; }
.sb-gps-desc { color:var(--sb-muted); font-size:12px; margin-bottom:14px; }
.sb-gps-table { width:100%; border-collapse:collapse; }
.sb-gps-table th { text-align:left; font-size:11px; text-transform:uppercase; color:var(--sb-muted); padding:6px 8px; border-bottom:1px solid var(--sb-border); }
.sb-gps-table td { padding:8px; border-bottom:1px solid var(--sb-border); font-size:13px; }
.sb-gps-select { width:100%; padding:5px 8px; background:var(--sb-bg); border:1px solid var(--sb-border); border-radius:5px; color:var(--sb-text); font-size:12px; }
.sb-gps-badge { padding:2px 8px; border-radius:10px; font-size:11px; font-weight:600; }
.sb-gps-online { background:rgba(16,185,129,0.15); color:#10b981; }
.sb-gps-offline { background:rgba(245,158,11,0.15); color:#f59e0b; }
.sb-gps-none { background:rgba(107,114,128,0.15); color:#6b7280; }
.sb-gps-coords { font-size:11px; color:var(--sb-muted); font-family:monospace; }
.sb-gps-footer { display:flex; align-items:center; justify-content:space-between; margin-top:12px; padding-top:12px; border-top:1px solid var(--sb-border); }
.sb-gps-info { font-size:11px; color:var(--sb-muted); }
.sb-gps-refresh { padding:5px 12px; background:var(--sb-acc); border:none; border-radius:6px; color:#fff; font-size:12px; cursor:pointer; }
.sb-gps-input { width:100%; padding:4px 8px; background:var(--sb-bg); border:1px solid var(--sb-border); border-radius:6px; color:var(--sb-fg); font-size:12px; }
.sb-gps-add-row td { padding-top:8px; border-top:1px solid var(--sb-border); }
.sb-gps-add-btn { padding:4px 14px; background:var(--sb-acc); border:none; border-radius:6px; color:#fff; font-size:12px; cursor:pointer; white-space:nowrap; }
.sb-gps-add-btn:disabled { opacity:.5; cursor:not-allowed; }
.sb-gps-del-btn { background:none; border:none; color:var(--sb-muted); font-size:16px; cursor:pointer; padding:0 4px; line-height:1; }
.sb-gps-del-btn:hover { color:#f43f5e; }
.sb-gps-editable { cursor:pointer; border-bottom:1px dashed transparent; }
.sb-gps-editable:hover { border-bottom-color:var(--sb-muted); }
.sb-gps-edit-name { font-weight:600; }
.sb-gps-status-sel { min-width:100px; }
.sb-gps-phone { width:110px; font-variant-numeric:tabular-nums; }
/* ── Login Overlay ── */
.sb-login-overlay { position:fixed; inset:0; background:var(--sb-bg); z-index:9999; display:flex; align-items:center; justify-content:center; }
.sb-login-modal { background:var(--sb-card); border:1px solid var(--sb-border); border-radius:14px; padding:40px 36px; width:340px; display:flex; flex-direction:column; align-items:center; gap:16px; box-shadow:0 8px 40px rgba(0,0,0,0.6); }
.sb-login-logo { font-size:1.6rem; font-weight:800; color:var(--sb-acc); letter-spacing:-0.5px; }
.sb-login-sub { font-size:11px; color:var(--sb-muted); margin:0; text-align:center; }
.sb-login-sub a { color:var(--sb-acc); text-decoration:none; }
.sb-login-form { width:100%; display:flex; flex-direction:column; gap:10px; }
.sb-login-input { width:100%; padding:9px 12px; background:var(--sb-bg); border:1px solid var(--sb-border); border-radius:7px; color:var(--sb-text); font-size:13px; outline:none; box-sizing:border-box; }
.sb-login-input:focus { border-color:var(--sb-acc); }
.sb-login-btn { width:100%; padding:10px; background:var(--sb-acc); border:none; border-radius:7px; color:#fff; font-size:13px; font-weight:600; cursor:pointer; transition:opacity 0.15s; }
.sb-login-btn:disabled { opacity:0.6; cursor:not-allowed; }
.sb-login-error { color:var(--sb-red); font-size:12px; margin:0; text-align:center; }

View File

@ -13,10 +13,10 @@ const routes = [
{ path: 'equipe', component: () => import('src/pages/EquipePage.vue') }, { path: 'equipe', component: () => import('src/pages/EquipePage.vue') },
{ path: 'rapports', component: () => import('src/pages/RapportsPage.vue') }, { path: 'rapports', component: () => import('src/pages/RapportsPage.vue') },
{ path: 'ocr', component: () => import('src/pages/OcrPage.vue') }, { path: 'ocr', component: () => import('src/pages/OcrPage.vue') },
{ path: 'settings', component: () => import('src/pages/SettingsPage.vue') },
{ path: 'dispatch', component: () => import('src/pages/DispatchPage.vue') },
], ],
}, },
// Dispatch V2 — full-screen immersive UI with its own header/sidebar
{ path: '/dispatch', component: () => import('src/pages/DispatchPage.vue') },
] ]
export default route(function () { export default route(function () {

View File

@ -1,32 +1,23 @@
// ── Dispatch store ───────────────────────────────────────────────────────────
// Shared state for both MobilePage and DispatchPage.
// All ERPNext calls go through api/dispatch.js — not here.
// ─────────────────────────────────────────────────────────────────────────────
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import { fetchTechnicians, fetchJobs, updateJob, createJob as apiCreateJob, fetchTags, createTech as apiCreateTech, deleteTech as apiDeleteTech } from 'src/api/dispatch' import { fetchTechnicians, fetchJobs, updateJob, createJob as apiCreateJob, fetchTags, createTech as apiCreateTech, deleteTech as apiDeleteTech } from 'src/api/dispatch'
import { fetchDevices, fetchPositions, createTraccarSession } from 'src/api/traccar'
import { TECH_COLORS } from 'src/config/erpnext' import { TECH_COLORS } from 'src/config/erpnext'
import { serializeAssistants } from 'src/composables/useHelpers' import { serializeAssistants } from 'src/composables/useHelpers'
import { useGpsTracking } from 'src/composables/useGpsTracking'
// Module-level GPS guards — survive store re-creation and component remount
let __gpsStarted = false
let __gpsInterval = null
let __gpsPolling = false
export const useDispatchStore = defineStore('dispatch', () => { export const useDispatchStore = defineStore('dispatch', () => {
const technicians = ref([]) const technicians = ref([])
const jobs = ref([]) const jobs = ref([])
const allTags = ref([]) // { name, label, color, category } const allTags = ref([])
const loading = ref(false) const loading = ref(false)
const erpStatus = ref('pending') // 'pending' | 'ok' | 'error' | 'session_expired' const erpStatus = ref('pending')
// ── Data transformers ──────────────────────────────────────────────────── const { traccarDevices, pollGps, startGpsTracking, stopGpsTracking } = useGpsTracking(technicians)
function _mapJob (j) { function _mapJob (j) {
return { return {
id: j.ticket_id || j.name, id: j.ticket_id || j.name,
name: j.name, // ERPNext docname (used for PUT calls) name: j.name,
subject: j.subject || 'Job sans titre', subject: j.subject || 'Job sans titre',
address: j.address || 'Adresse inconnue', address: j.address || 'Adresse inconnue',
coords: [j.longitude || 0, j.latitude || 0], coords: [j.longitude || 0, j.latitude || 0],
@ -43,63 +34,60 @@ export const useDispatchStore = defineStore('dispatch', () => {
assistants: (j.assistants || []).map(a => ({ techId: a.tech_id, techName: a.tech_name, duration: a.duration_h || 0, note: a.note || '', pinned: !!a.pinned })), assistants: (j.assistants || []).map(a => ({ techId: a.tech_id, techName: a.tech_name, duration: a.duration_h || 0, note: a.note || '', pinned: !!a.pinned })),
tags: (j.tags || []).map(t => t.tag), tags: (j.tags || []).map(t => t.tag),
tagsWithLevel: (j.tags || []).map(t => ({ tag: t.tag, level: t.level || 0, required: t.required || 0 })), tagsWithLevel: (j.tags || []).map(t => ({ tag: t.tag, level: t.level || 0, required: t.required || 0 })),
customer: j.customer || null,
serviceLocation: j.service_location || null,
sourceIssue: j.source_issue || null,
dependsOn: j.depends_on || null,
jobType: j.job_type || null,
parentJob: j.parent_job || null,
stepOrder: j.step_order || 0,
onOpenWebhook: j.on_open_webhook || null,
onCloseWebhook: j.on_close_webhook || null,
} }
} }
function _mapTech (t, idx) { function _mapTech (t, idx) {
return { return {
id: t.technician_id || t.name, id: t.technician_id || t.name,
name: t.name, // ERPNext docname name: t.name,
fullName: t.full_name || t.name, fullName: t.full_name || t.name,
status: t.status || '', status: t.status || '',
user: t.user || null, user: t.user || null,
colorIdx: idx % TECH_COLORS.length, colorIdx: idx % TECH_COLORS.length,
coords: [t.longitude || -73.5673, t.latitude || 45.5017], coords: [t.longitude || -73.5673, t.latitude || 45.5017],
gpsCoords: null, // live GPS from Traccar (updated by polling) gpsCoords: null,
gpsSpeed: 0, gpsSpeed: 0,
gpsTime: null, gpsTime: null,
gpsOnline: false, gpsOnline: false,
traccarDeviceId: t.traccar_device_id || null, traccarDeviceId: t.traccar_device_id || null,
phone: t.phone || '', phone: t.phone || '',
email: t.email || '', email: t.email || '',
queue: [], // filled in loadAll() queue: [],
tags: (t.tags || []).map(tg => tg.tag), tags: (t.tags || []).map(tg => tg.tag),
tagsWithLevel: (t.tags || []).map(tg => ({ tag: tg.tag, level: tg.level || 0 })), tagsWithLevel: (t.tags || []).map(tg => ({ tag: tg.tag, level: tg.level || 0 })),
} }
} }
// ── Loaders ──────────────────────────────────────────────────────────────
async function loadAll () { async function loadAll () {
loading.value = true loading.value = true
erpStatus.value = 'pending' erpStatus.value = 'pending'
try { try {
const [rawTechs, rawJobs, rawTags] = await Promise.all([ const [rawTechs, rawJobs, rawTags] = await Promise.all([fetchTechnicians(), fetchJobs(), fetchTags()])
fetchTechnicians(),
fetchJobs(),
fetchTags(),
])
allTags.value = rawTags allTags.value = rawTags
technicians.value = rawTechs.map(_mapTech) technicians.value = rawTechs.map(_mapTech)
jobs.value = rawJobs.map(_mapJob) jobs.value = rawJobs.map(_mapJob)
// Build each tech's ordered queue (primary + assistant jobs)
technicians.value.forEach(tech => { technicians.value.forEach(tech => {
tech.queue = jobs.value tech.queue = jobs.value.filter(j => j.assignedTech === tech.id).sort((a, b) => a.routeOrder - b.routeOrder)
.filter(j => j.assignedTech === tech.id) tech.assistJobs = jobs.value.filter(j => j.assistants.some(a => a.techId === tech.id))
.sort((a, b) => a.routeOrder - b.routeOrder)
tech.assistJobs = jobs.value
.filter(j => j.assistants.some(a => a.techId === tech.id))
}) })
erpStatus.value = 'ok' erpStatus.value = 'ok'
} catch (e) { } catch (e) {
erpStatus.value = e.message?.includes('session') ? 'session_expired' : 'error' erpStatus.value = e.message?.includes('session') ? 'session_expired' : 'error'
console.error('loadAll error:', e)
} finally { } finally {
loading.value = false loading.value = false
} }
} }
// Load jobs assigned to one tech — used by MobilePage
async function loadJobsForTech (techId) { async function loadJobsForTech (techId) {
loading.value = true loading.value = true
try { try {
@ -110,23 +98,43 @@ export const useDispatchStore = defineStore('dispatch', () => {
} }
} }
// ── Mutations (also syncs to ERPNext) ────────────────────────────────────
async function setJobStatus (jobId, status) { async function setJobStatus (jobId, status) {
const job = jobs.value.find(j => j.id === jobId) const job = jobs.value.find(j => j.id === jobId)
if (!job) return if (!job) return
const prevStatus = job.status
job.status = status job.status = status
await updateJob(job.id, { status }) await updateJob(job.id, { status })
// Fire n8n webhooks on status transitions
_fireWebhookIfNeeded(job, prevStatus, status)
}
function _fireWebhookIfNeeded (job, prevStatus, newStatus) {
const prev = (prevStatus || '').toLowerCase()
const next = (newStatus || '').toLowerCase()
let url = null
if (next === 'assigned' && prev === 'open' && job.onOpenWebhook) {
url = job.onOpenWebhook
} else if (next === 'completed' && job.onCloseWebhook) {
url = job.onCloseWebhook
}
if (url) {
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
event: next === 'completed' ? 'job_closed' : 'job_opened',
job_name: job.name, job_subject: job.subject,
job_status: newStatus, job_type: job.jobType,
customer: job.customer, timestamp: new Date().toISOString(),
}),
}).catch(err => console.warn('[n8n webhook]', err))
}
} }
async function assignJobToTech (jobId, techId, routeOrder, scheduledDate) { async function assignJobToTech (jobId, techId, routeOrder, scheduledDate) {
const job = jobs.value.find(j => j.id === jobId) const job = jobs.value.find(j => j.id === jobId)
if (!job) return if (!job) return
// Remove from old tech queue technicians.value.forEach(t => { t.queue = t.queue.filter(q => q.id !== jobId) })
technicians.value.forEach(t => {
t.queue = t.queue.filter(q => q.id !== jobId)
})
// Add to new tech queue
const tech = technicians.value.find(t => t.id === techId) const tech = technicians.value.find(t => t.id === techId)
if (tech) { if (tech) {
job.assignedTech = techId job.assignedTech = techId
@ -134,14 +142,9 @@ export const useDispatchStore = defineStore('dispatch', () => {
job.status = 'assigned' job.status = 'assigned'
if (scheduledDate !== undefined) job.scheduledDate = scheduledDate if (scheduledDate !== undefined) job.scheduledDate = scheduledDate
tech.queue.splice(routeOrder, 0, job) tech.queue.splice(routeOrder, 0, job)
// Re-number route_order
tech.queue.forEach((q, i) => { q.routeOrder = i }) tech.queue.forEach((q, i) => { q.routeOrder = i })
} }
const payload = { const payload = { assigned_tech: techId, route_order: routeOrder, status: 'assigned' }
assigned_tech: techId,
route_order: routeOrder,
status: 'assigned',
}
if (scheduledDate !== undefined) payload.scheduled_date = scheduledDate || '' if (scheduledDate !== undefined) payload.scheduled_date = scheduledDate || ''
await updateJob(job.id, payload) await updateJob(job.id, payload)
} }
@ -156,7 +159,6 @@ export const useDispatchStore = defineStore('dispatch', () => {
} }
async function createJob (fields) { async function createJob (fields) {
// fields: { subject, address, duration_h, priority, assigned_tech?, scheduled_date?, start_time? }
const localId = 'WO-' + Date.now().toString(36).toUpperCase() const localId = 'WO-' + Date.now().toString(36).toUpperCase()
const job = _mapJob({ const job = _mapJob({
ticket_id: localId, name: localId, ticket_id: localId, name: localId,
@ -179,16 +181,16 @@ export const useDispatchStore = defineStore('dispatch', () => {
} }
try { try {
const created = await apiCreateJob({ const created = await apiCreateJob({
subject: job.subject, subject: job.subject, address: job.address,
address: job.address, longitude: job.coords?.[0] || '', latitude: job.coords?.[1] || '',
longitude: job.coords?.[0] || '', duration_h: job.duration, priority: job.priority, status: job.status,
latitude: job.coords?.[1] || '', assigned_tech: job.assignedTech || '', scheduled_date: job.scheduledDate || '',
duration_h: job.duration,
priority: job.priority,
status: job.status,
assigned_tech: job.assignedTech || '',
scheduled_date: job.scheduledDate || '',
start_time: job.startTime || '', start_time: job.startTime || '',
customer: fields.customer || '', service_location: fields.service_location || '',
source_issue: fields.source_issue || '', depends_on: fields.depends_on || '',
job_type: fields.job_type || '',
parent_job: fields.parent_job || '', step_order: fields.step_order || 0,
on_open_webhook: fields.on_open_webhook || '', on_close_webhook: fields.on_close_webhook || '',
}) })
if (created?.name) { job.id = created.name; job.name = created.name } if (created?.name) { job.id = created.name; job.name = created.name }
} catch (_) {} } catch (_) {}
@ -215,17 +217,13 @@ export const useDispatchStore = defineStore('dispatch', () => {
async function addAssistant (jobId, techId) { async function addAssistant (jobId, techId) {
const job = jobs.value.find(j => j.id === jobId) const job = jobs.value.find(j => j.id === jobId)
if (!job) return if (!job) return
if (job.assignedTech === techId) return // already lead if (job.assignedTech === techId) return
if (job.assistants.some(a => a.techId === techId)) return // already assistant if (job.assistants.some(a => a.techId === techId)) return
const tech = technicians.value.find(t => t.id === techId) const tech = technicians.value.find(t => t.id === techId)
const entry = { techId, techName: tech?.fullName || techId, duration: job.duration, note: '', pinned: false } const entry = { techId, techName: tech?.fullName || techId, duration: job.duration, note: '', pinned: false }
job.assistants = [...job.assistants, entry] job.assistants = [...job.assistants, entry]
if (tech) tech.assistJobs = jobs.value.filter(j => j.assistants.some(a => a.techId === tech.id)) if (tech) tech.assistJobs = jobs.value.filter(j => j.assistants.some(a => a.techId === tech.id))
try { try { await updateJob(job.name || job.id, { assistants: serializeAssistants(job.assistants) }) } catch (_) {}
await updateJob(job.name || job.id, {
assistants: serializeAssistants(job.assistants),
})
} catch (_) {}
} }
async function removeAssistant (jobId, techId) { async function removeAssistant (jobId, techId) {
@ -234,11 +232,7 @@ export const useDispatchStore = defineStore('dispatch', () => {
job.assistants = job.assistants.filter(a => a.techId !== techId) job.assistants = job.assistants.filter(a => a.techId !== techId)
const tech = technicians.value.find(t => t.id === techId) const tech = technicians.value.find(t => t.id === techId)
if (tech) tech.assistJobs = jobs.value.filter(j => j.assistants.some(a => a.techId === tech.id)) if (tech) tech.assistJobs = jobs.value.filter(j => j.assistants.some(a => a.techId === tech.id))
try { try { await updateJob(job.name || job.id, { assistants: serializeAssistants(job.assistants) }) } catch (_) {}
await updateJob(job.name || job.id, {
assistants: serializeAssistants(job.assistants),
})
} catch (_) {}
} }
async function reorderTechQueue (techId, fromIdx, toIdx) { async function reorderTechQueue (techId, fromIdx, toIdx) {
@ -247,13 +241,9 @@ export const useDispatchStore = defineStore('dispatch', () => {
const [moved] = tech.queue.splice(fromIdx, 1) const [moved] = tech.queue.splice(fromIdx, 1)
tech.queue.splice(toIdx, 0, moved) tech.queue.splice(toIdx, 0, moved)
tech.queue.forEach((q, i) => { q.routeOrder = i }) tech.queue.forEach((q, i) => { q.routeOrder = i })
// Sync all reordered jobs await Promise.all(tech.queue.map((q, i) => updateJob(q.id, { route_order: i })))
await Promise.all(
tech.queue.map((q, i) => updateJob(q.id, { route_order: i })),
)
} }
// ── Smart assign (removes circular assistant deps) ──────────────────────
function smartAssign (jobId, newTechId, dateStr) { function smartAssign (jobId, newTechId, dateStr) {
const job = jobs.value.find(j => j.id === jobId) const job = jobs.value.find(j => j.id === jobId)
if (!job) return if (!job) return
@ -265,7 +255,6 @@ export const useDispatchStore = defineStore('dispatch', () => {
_rebuildAssistJobs() _rebuildAssistJobs()
} }
// ── Full unassign (clears assistants + unassigns) ──────────────────────
function fullUnassign (jobId) { function fullUnassign (jobId) {
const job = jobs.value.find(j => j.id === jobId) const job = jobs.value.find(j => j.id === jobId)
if (!job) return if (!job) return
@ -274,122 +263,11 @@ export const useDispatchStore = defineStore('dispatch', () => {
_rebuildAssistJobs() _rebuildAssistJobs()
} }
// Rebuild all tech.assistJobs references
function _rebuildAssistJobs () { function _rebuildAssistJobs () {
technicians.value.forEach(t => { t.assistJobs = jobs.value.filter(j => j.assistants.some(a => a.techId === t.id)) }) technicians.value.forEach(t => { t.assistJobs = jobs.value.filter(j => j.assistants.some(a => a.techId === t.id)) })
} }
// ── Traccar GPS — Hybrid: REST initial + WebSocket real-time ─────────────────
const traccarDevices = ref([])
const _techsByDevice = {} // deviceId (number) → tech object
function _buildTechDeviceMap () {
Object.keys(_techsByDevice).forEach(k => delete _techsByDevice[k])
technicians.value.forEach(t => {
if (!t.traccarDeviceId) return
const dev = traccarDevices.value.find(d => d.id === parseInt(t.traccarDeviceId) || d.uniqueId === t.traccarDeviceId)
if (dev) _techsByDevice[dev.id] = t
})
}
function _applyPositions (positions) {
positions.forEach(p => {
const tech = _techsByDevice[p.deviceId]
if (!tech || !p.latitude || !p.longitude) return
const cur = tech.gpsCoords
if (!cur || Math.abs(cur[0] - p.longitude) > 0.00001 || Math.abs(cur[1] - p.latitude) > 0.00001) {
tech.gpsCoords = [p.longitude, p.latitude]
}
tech.gpsSpeed = p.speed || 0
tech.gpsTime = p.fixTime
tech.gpsOnline = true
})
}
// One-shot REST fetch (manual refresh button + initial load)
async function pollGps () {
if (__gpsPolling) return
__gpsPolling = true
try {
if (!traccarDevices.value.length) traccarDevices.value = await fetchDevices()
_buildTechDeviceMap()
const deviceIds = Object.keys(_techsByDevice).map(Number)
if (!deviceIds.length) return
const positions = await fetchPositions(deviceIds)
_applyPositions(positions)
Object.values(_techsByDevice).forEach(t => {
if (!positions.find(p => _techsByDevice[p.deviceId] === t)) t.gpsOnline = false
})
} catch (e) { console.warn('[GPS] Poll error:', e.message) }
finally { __gpsPolling = false }
}
// WebSocket connection with auto-reconnect
let __ws = null
let __wsBackoff = 1000
function _connectWs () {
if (__ws) return
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const url = proto + '//' + window.location.host + '/traccar/api/socket'
try { __ws = new WebSocket(url) } catch (e) { console.warn('[GPS] WS error:', e); return }
__ws.onopen = () => {
__wsBackoff = 1000
// WS connected — stop fallback polling
if (__gpsInterval) { clearInterval(__gpsInterval); __gpsInterval = null }
console.log('[GPS] WebSocket connected — real-time updates active')
}
__ws.onmessage = (e) => {
try {
const data = JSON.parse(e.data)
if (data.positions?.length) {
_buildTechDeviceMap() // refresh map in case techs changed
_applyPositions(data.positions)
}
} catch {}
}
__ws.onerror = () => {}
__ws.onclose = () => {
__ws = null
if (!__gpsStarted) return
// Start fallback polling while WS is down
if (!__gpsInterval) {
__gpsInterval = setInterval(pollGps, 30000)
console.log('[GPS] WS closed — fallback to 30s polling')
}
setTimeout(_connectWs, __wsBackoff)
__wsBackoff = Math.min(__wsBackoff * 2, 60000)
}
}
async function startGpsTracking () {
if (__gpsStarted) return
__gpsStarted = true
// 1. Load devices + initial REST fetch (all last-known positions)
await pollGps()
console.log('[GPS] Initial positions loaded via REST')
// 2. Create session cookie for WebSocket auth, then connect
const sessionOk = await createTraccarSession()
if (sessionOk) {
_connectWs()
} else {
// Session failed — fall back to polling
__gpsInterval = setInterval(pollGps, 30000)
console.log('[GPS] Session failed — fallback to 30s polling')
}
}
function stopGpsTracking () {
if (__gpsInterval) { clearInterval(__gpsInterval); __gpsInterval = null }
if (__ws) { const ws = __ws; __ws = null; ws.onclose = null; ws.close() }
}
const startGpsPolling = startGpsTracking
const stopGpsPolling = stopGpsTracking
// ── Create / Delete technician ─────────────────────────────────────────────
async function createTechnician (fields) { async function createTechnician (fields) {
// Auto-generate technician_id: TECH-N+1
const maxNum = technicians.value.reduce((max, t) => { const maxNum = technicians.value.reduce((max, t) => {
const m = (t.id || '').match(/TECH-(\d+)/) const m = (t.id || '').match(/TECH-(\d+)/)
return m ? Math.max(max, parseInt(m[1])) : max return m ? Math.max(max, parseInt(m[1])) : max
@ -408,6 +286,9 @@ export const useDispatchStore = defineStore('dispatch', () => {
technicians.value = technicians.value.filter(t => t.id !== techId) technicians.value = technicians.value.filter(t => t.id !== techId)
} }
const startGpsPolling = startGpsTracking
const stopGpsPolling = stopGpsTracking
return { return {
technicians, jobs, allTags, loading, erpStatus, traccarDevices, technicians, jobs, allTags, loading, erpStatus, traccarDevices,
loadAll, loadJobsForTech, loadAll, loadJobsForTech,

View File

@ -5,6 +5,8 @@
Field Service Management platform for Gigafibre ISP. Field Service Management platform for Gigafibre ISP.
Inspired by Odoo OCA Field Service, Salesforce Field Service, and Zuper. Inspired by Odoo OCA Field Service, Salesforce Field Service, and Zuper.
> **Note**: For development standards and modularity rules, please refer to the [Design Guidelines](DESIGN_GUIDELINES.md).
## Data Model ## Data Model
``` ```

View File

@ -0,0 +1,24 @@
import frappe
def add_depends_on():
"""Add depends_on field to Dispatch Job."""
fieldname = "depends_on"
if frappe.db.exists("Custom Field", {"dt": "Dispatch Job", "fieldname": fieldname}):
print(f" Field {fieldname} already exists, skipping.")
return
frappe.get_doc({
"doctype": "Custom Field",
"dt": "Dispatch Job",
"fieldname": "depends_on",
"fieldtype": "Link",
"label": "Dépend de",
"options": "Dispatch Job",
"insert_after": "source_issue",
}).insert(ignore_permissions=True)
frappe.db.commit()
print(f" ✓ Dispatch Job.depends_on field added.")
if __name__ == "__main__":
frappe.connect(site="erp.gigafibre.ca")
add_depends_on()
frappe.destroy()

View File

@ -412,6 +412,19 @@ def _extend_dispatch_job():
"insert_after": "service_location"}, "insert_after": "service_location"},
{"dt": "Dispatch Job", "fieldname": "source_issue", "fieldtype": "Link", {"dt": "Dispatch Job", "fieldname": "source_issue", "fieldtype": "Link",
"label": "Ticket source", "options": "Issue", "insert_after": "job_type"}, "label": "Ticket source", "options": "Issue", "insert_after": "job_type"},
{"dt": "Dispatch Job", "fieldname": "depends_on", "fieldtype": "Link",
"label": "Dépend de", "options": "Dispatch Job", "insert_after": "source_issue"},
{"dt": "Dispatch Job", "fieldname": "parent_job", "fieldtype": "Link",
"label": "Tâche parente", "options": "Dispatch Job", "insert_after": "depends_on"},
{"dt": "Dispatch Job", "fieldname": "on_open_webhook", "fieldtype": "Data",
"label": "Webhook n8n (ouverture)", "insert_after": "parent_job",
"description": "URL webhook n8n déclenché à l'ouverture/assignation de la tâche"},
{"dt": "Dispatch Job", "fieldname": "on_close_webhook", "fieldtype": "Data",
"label": "Webhook n8n (fermeture)", "insert_after": "on_open_webhook",
"description": "URL webhook n8n déclenché à la complétion de la tâche"},
{"dt": "Dispatch Job", "fieldname": "step_order", "fieldtype": "Int",
"label": "Ordre d'étape", "insert_after": "on_close_webhook",
"description": "Position dans la séquence du projet (1, 2, 3...)"},
# Equipment # Equipment
{"dt": "Dispatch Job", "fieldname": "sec_equipment", "fieldtype": "Section Break", {"dt": "Dispatch Job", "fieldname": "sec_equipment", "fieldtype": "Section Break",
"label": "Équipements", "insert_after": "tags"}, "label": "Équipements", "insert_after": "tags"},