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:
parent
101faa21f1
commit
7d7b4fdb06
|
|
@ -9,8 +9,9 @@
|
|||
# Static files go to /opt/ops-app/ on the host, mounted into the container.
|
||||
#
|
||||
# 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 pwa # deploy to remote server (PWA mode, for production)
|
||||
#
|
||||
# Prerequisites (remote):
|
||||
# - 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"
|
||||
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..."
|
||||
npm ci --silent
|
||||
|
||||
echo "==> Building PWA (base=/ops/)..."
|
||||
DEPLOY_BASE=/ops/ npx quasar build -m pwa
|
||||
echo "==> Building $BUILD_MODE (base=/ops/)..."
|
||||
DEPLOY_BASE=/ops/ npx quasar build -m "$BUILD_MODE"
|
||||
|
||||
if [ "$1" = "local" ]; then
|
||||
# ── Local deploy ──
|
||||
echo "==> Deploying to local $DEST..."
|
||||
rm -rf "$DEST"/*
|
||||
cp -r dist/pwa/* "$DEST/"
|
||||
cp -r "$DIST_DIR"/* "$DEST/"
|
||||
echo ""
|
||||
echo "Done! Targo Ops: http://localhost/ops/"
|
||||
else
|
||||
# ── Remote deploy ──
|
||||
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..."
|
||||
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 && \
|
||||
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 && \
|
||||
rm -f /tmp/ops.tar.gz"
|
||||
|
||||
rm -f /tmp/ops-pwa.tar.gz
|
||||
rm -f /tmp/ops-build.tar.gz
|
||||
|
||||
echo ""
|
||||
echo "Done! Targo Ops: https://erp.gigafibre.ca/ops/"
|
||||
echo "Done! Targo Ops ($BUILD_MODE): https://erp.gigafibre.ca/ops/"
|
||||
fi
|
||||
|
|
|
|||
10
apps/ops/package-lock.json
generated
10
apps/ops/package-lock.json
generated
|
|
@ -9,6 +9,7 @@
|
|||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@quasar/extras": "^1.16.12",
|
||||
"lucide-vue-next": "^1.0.0",
|
||||
"pinia": "^2.1.7",
|
||||
"quasar": "^2.16.10",
|
||||
"vue": "^3.4.21",
|
||||
|
|
@ -6916,6 +6917,15 @@
|
|||
"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": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@quasar/extras": "^1.16.12",
|
||||
"lucide-vue-next": "^1.0.0",
|
||||
"pinia": "^2.1.7",
|
||||
"quasar": "^2.16.10",
|
||||
"vue": "^3.4.21",
|
||||
|
|
|
|||
|
|
@ -25,6 +25,11 @@ module.exports = configure(function () {
|
|||
host: '0.0.0.0',
|
||||
port: 9001,
|
||||
proxy: {
|
||||
'/ops/api': {
|
||||
target: 'https://erp.gigafibre.ca',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/ops/, ''),
|
||||
},
|
||||
'/api': {
|
||||
target: 'https://erp.gigafibre.ca',
|
||||
changeOrigin: true,
|
||||
|
|
|
|||
|
|
@ -9,11 +9,10 @@ register(process.env.SERVICE_WORKER_FILE, {
|
|||
cached () {},
|
||||
updatefound () {},
|
||||
updated (reg) {
|
||||
// New service worker available — activate it and reload
|
||||
// New service worker available — activate it silently (no reload)
|
||||
if (reg && reg.waiting) {
|
||||
reg.waiting.postMessage({ type: 'SKIP_WAITING' })
|
||||
}
|
||||
window.location.reload()
|
||||
},
|
||||
offline () {},
|
||||
error () {}
|
||||
|
|
|
|||
|
|
@ -13,14 +13,12 @@ export function authFetch (url, opts = {}) {
|
|||
} else {
|
||||
opts.headers = { ...opts.headers }
|
||||
}
|
||||
opts.redirect = 'manual'
|
||||
if (opts.method && opts.method !== 'GET') {
|
||||
opts.credentials = 'omit'
|
||||
}
|
||||
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()
|
||||
return new Response('{}', { status: 401 })
|
||||
return new Response('{}', { status: res.status })
|
||||
}
|
||||
return res
|
||||
})
|
||||
|
|
|
|||
|
|
@ -54,7 +54,8 @@ export async function createDoc (doctype, data) {
|
|||
|
||||
// Update a document (partial update)
|
||||
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',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
|
|
@ -64,6 +65,14 @@ export async function updateDoc (doctype, name, 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
|
||||
export async function countDocs (doctype, filters = {}, or_filters) {
|
||||
const params = new URLSearchParams({
|
||||
|
|
|
|||
|
|
@ -1,25 +1,32 @@
|
|||
import { BASE_URL } from 'src/config/erpnext'
|
||||
import { authFetch } from './auth'
|
||||
|
||||
/**
|
||||
* Send a test SMS notification via ERPNext server script.
|
||||
* Falls back to logging if Twilio is not configured.
|
||||
* Send SMS via n8n webhook → Twilio.
|
||||
* n8n handles Twilio auth + logs to ERPNext automatically.
|
||||
*
|
||||
* @param {string} phone - Phone number (e.g. +15145551234)
|
||||
* @param {string} message - SMS body
|
||||
* @param {string} customer - Customer ID (e.g. CUST-4)
|
||||
* @returns {Promise<{ok: boolean, message: string}>}
|
||||
* @param {string} customer - Customer ID (e.g. CUST-00001) — logged as Communication in ERPNext
|
||||
* @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',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ phone, message, customer }),
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
if (!res.ok) {
|
||||
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()
|
||||
return data.message || { ok: true, message: 'Sent' }
|
||||
if (data.ok === false) throw new Error(data.message || 'SMS send error')
|
||||
return data
|
||||
}
|
||||
|
|
|
|||
536
apps/ops/src/components/customer/ChatterPanel.vue
Normal file
536
apps/ops/src/components/customer/ChatterPanel.vue
Normal 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"> · {{ 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>
|
||||
166
apps/ops/src/components/customer/ComposeBar.vue
Normal file
166
apps/ops/src/components/customer/ComposeBar.vue
Normal 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>
|
||||
|
|
@ -1,39 +1,16 @@
|
|||
<template>
|
||||
<div class="ops-card" style="height:100%">
|
||||
<div>
|
||||
<div class="section-title">
|
||||
<q-icon name="contact_phone" size="18px" class="q-mr-xs" /> Contact
|
||||
</div>
|
||||
<div class="info-grid">
|
||||
<div class="info-row editable-row">
|
||||
<q-icon name="person" size="16px" color="grey-6" />
|
||||
<q-input v-model="customer.contact_name_legacy" dense borderless placeholder="Contact"
|
||||
input-class="editable-input" @change="$emit('save', 'contact_name_legacy')" />
|
||||
</div>
|
||||
<div class="info-row editable-row">
|
||||
<q-icon name="badge" size="16px" color="grey-6" />
|
||||
<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 v-for="f in fields" :key="f.field" class="info-row editable-row">
|
||||
<q-icon :name="f.icon" size="16px" color="grey-6" />
|
||||
<q-input v-model="customer[f.field]" dense borderless :placeholder="f.label"
|
||||
:input-class="'editable-input' + (f.small ? ' text-caption' : '')"
|
||||
:style="f.small ? 'word-break:break-all' : ''"
|
||||
@blur="save(f.field)" @keyup.enter="$event.target.blur()" />
|
||||
<q-spinner v-if="saving === f.field" size="12px" color="grey-5" class="q-ml-xs" />
|
||||
</div>
|
||||
<div v-if="customer.stripe_id" class="info-row">
|
||||
<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>
|
||||
</div>
|
||||
<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"
|
||||
toggle-color="indigo-6" color="grey-3" text-color="grey-8"
|
||||
:options="[
|
||||
|
|
@ -59,22 +35,15 @@
|
|||
{ label: 'Email', value: 'email', icon: 'email' },
|
||||
]"
|
||||
/>
|
||||
|
||||
<!-- Recipient -->
|
||||
<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" />
|
||||
<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" />
|
||||
|
||||
<!-- Subject (email only) -->
|
||||
<q-input v-if="channel === 'email'" v-model="emailSubject" dense outlined
|
||||
placeholder="Sujet" class="q-mb-xs" :input-style="{ fontSize: '0.82rem' }" />
|
||||
|
||||
<!-- Message body -->
|
||||
<q-input v-model="notifyMessage" dense outlined type="textarea" autogrow
|
||||
placeholder="Message..."
|
||||
:input-style="{ fontSize: '0.82rem', minHeight: '45px', maxHeight: '120px' }" />
|
||||
|
||||
<div class="row items-center q-mt-xs">
|
||||
<span class="text-caption text-grey-5">{{ notifyMessage.length }} car.</span>
|
||||
<q-space />
|
||||
|
|
@ -84,17 +53,59 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { updateDoc } from 'src/api/erp'
|
||||
import { sendTestSms } from 'src/api/sms'
|
||||
|
||||
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 channel = ref('sms')
|
||||
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)
|
||||
lastSentLabel.value = result?.simulated ? 'Simulé' : 'SMS envoyé'
|
||||
} else {
|
||||
// Email via same endpoint pattern — n8n webhook
|
||||
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', {
|
||||
|
|
@ -171,7 +181,6 @@ async function sendNotification () {
|
|||
border-top: 1px solid #e2e8f0;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.notify-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -181,11 +190,9 @@ async function sendNotification () {
|
|||
border-radius: 4px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.notify-header:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.notify-body {
|
||||
padding: 6px 0 2px 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
<template>
|
||||
<div class="ops-card q-mb-md">
|
||||
<div class="row items-center q-col-gutter-md">
|
||||
<div class="customer-header-block">
|
||||
<!-- Main header line -->
|
||||
<div class="row items-center q-col-gutter-sm">
|
||||
<div class="col-auto">
|
||||
<q-btn flat dense round icon="arrow_back" @click="$router.back()" />
|
||||
</div>
|
||||
<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"
|
||||
placeholder="Nom du client" @saved="v => customer.customer_name = v.value" />
|
||||
</div>
|
||||
|
|
@ -16,18 +17,18 @@
|
|||
<q-select v-model="customer.customer_type" dense borderless
|
||||
:options="['Individual', 'Company']" emit-value map-options
|
||||
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>·</span>
|
||||
<q-select v-model="customer.customer_group" dense borderless
|
||||
:options="customerGroups" emit-value map-options
|
||||
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">
|
||||
<span>·</span>
|
||||
<q-select v-model="customer.language" dense borderless
|
||||
: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"
|
||||
@update:model-value="$emit('save', 'language')" />
|
||||
@update:model-value="save('language')" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -35,17 +36,70 @@
|
|||
<span class="ops-badge q-mb-xs" :class="customer.disabled ? 'inactive' : 'active'" style="font-size:0.85rem">
|
||||
{{ customer.disabled ? 'Inactif' : 'Actif' }}
|
||||
</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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<script setup>
|
||||
import { ref } from '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 },
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
.customer-header-block {
|
||||
padding: 8px 0 4px 0;
|
||||
}
|
||||
.contact-toggle-header {
|
||||
padding: 4px 8px !important;
|
||||
min-height: 28px !important;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div class="ops-card" style="height:100%">
|
||||
<div>
|
||||
<div class="section-title">
|
||||
<q-icon name="info" size="18px" class="q-mr-xs" /> Informations
|
||||
</div>
|
||||
|
|
@ -10,7 +10,7 @@
|
|||
<q-select v-model="customer.invoice_delivery_method" dense borderless
|
||||
:options="['Email', 'Poste', 'Email + Poste']" emit-value map-options
|
||||
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 class="info-row editable-row">
|
||||
<q-icon name="receipt_long" size="16px" color="grey-6" />
|
||||
|
|
@ -18,29 +18,29 @@
|
|||
<q-select v-model="customer.tax_category_legacy" dense borderless
|
||||
:options="['Federal + Provincial (9.5%)', 'Federal seulement', 'Exempté']"
|
||||
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 class="info-row">
|
||||
<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'" />
|
||||
<span :class="customer.is_commercial ? 'text-blue-8 text-weight-medium' : 'text-grey-5'">Commercial</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<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'" />
|
||||
<span :class="customer.is_bad_payer ? 'text-red-8 text-weight-medium' : 'text-grey-5'">Mauvais payeur</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<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'" />
|
||||
<span :class="customer.exclude_fees ? 'text-orange-8' : 'text-grey-5'">Exclure frais</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<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"
|
||||
:color="customer.ppa_enabled ? 'green-6' : 'grey-4'" />
|
||||
<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">
|
||||
<q-input v-model="customer.notes_internal" dense borderless type="textarea" autogrow
|
||||
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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({ customer: { type: Object, required: true } })
|
||||
defineEmits(['save'])
|
||||
import { updateDoc } from 'src/api/erp'
|
||||
|
||||
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>
|
||||
|
|
|
|||
253
apps/ops/src/components/customer/SmsThread.vue
Normal file
253
apps/ops/src/components/customer/SmsThread.vue
Normal 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>
|
||||
177
apps/ops/src/components/customer/chatter-panel.scss
Normal file
177
apps/ops/src/components/customer/chatter-panel.scss
Normal 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; }
|
||||
226
apps/ops/src/components/shared/CreateDispatchJobDialog.vue
Normal file
226
apps/ops/src/components/shared/CreateDispatchJobDialog.vue
Normal 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>
|
||||
|
|
@ -13,7 +13,6 @@
|
|||
<slot name="title-suffix" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Action buttons -->
|
||||
<slot name="header-actions" />
|
||||
<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">
|
||||
|
|
@ -30,294 +29,20 @@
|
|||
|
||||
<!-- Content -->
|
||||
<q-card-section v-else-if="doc" class="col q-pt-sm" style="overflow-y:auto">
|
||||
|
||||
<!-- ═══ Sales Invoice ═══ -->
|
||||
<template v-if="doctype === 'Sales Invoice'">
|
||||
<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' }} · {{ 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"
|
||||
<component :is="sectionComponent" v-if="sectionComponent"
|
||||
:doc="doc" :doc-name="docName" :title="title"
|
||||
:comments="comments" :comms="comms" :files="files"
|
||||
:dispatch-jobs="dispatchJobs"
|
||||
@navigate="(...a) => $emit('navigate', ...a)"
|
||||
@reply-sent="(...a) => $emit('reply-sent', ...a)"
|
||||
@save-field="(...a) => $emit('save-field', ...a)"
|
||||
@toggle-recurring="(...a) => $emit('toggle-recurring', ...a)"
|
||||
@dispatch-created="(...a) => $emit('dispatch-created', ...a)"
|
||||
@dispatch-deleted="(...a) => $emit('dispatch-deleted', ...a)"
|
||||
@dispatch-updated="(...a) => $emit('dispatch-updated', ...a)"
|
||||
/>
|
||||
<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 ═══ -->
|
||||
<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 || '?' }} → {{ m.to_location || '?' }} {{ m.reason ? '(' + m.reason + ')' : '' }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ═══ Generic fallback ═══ -->
|
||||
<!-- Generic fallback -->
|
||||
<template v-else>
|
||||
<div class="modal-field-grid">
|
||||
<div v-for="(val, key) in docFields" :key="key" class="mf">
|
||||
|
|
@ -333,11 +58,22 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import { formatDate, formatDateShort, formatMoney, erpFileUrl } from 'src/composables/useFormatters'
|
||||
import { invStatusClass, ticketStatusClass, priorityClass, subStatusClass, eqStatusClass } from 'src/composables/useStatusClasses'
|
||||
import { computed } from 'vue'
|
||||
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({
|
||||
open: Boolean,
|
||||
|
|
@ -350,173 +86,32 @@ const props = defineProps({
|
|||
comms: { type: Array, default: () => [] },
|
||||
files: { type: Array, 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 sectionComponent = computed(() => SECTION_MAP[props.doctype] || null)
|
||||
|
||||
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' },
|
||||
]
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
function openExternal (url) { window.open(url, '_blank') }
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.erp-link {
|
||||
color: #6366f1;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.erp-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.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: #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;
|
||||
}
|
||||
.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: #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>
|
||||
|
|
|
|||
424
apps/ops/src/components/shared/ProjectWizard.vue
Normal file
424
apps/ops/src/components/shared/ProjectWizard.vue
Normal 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 — {{ stepLabels[currentStep] }}
|
||||
</div>
|
||||
<div class="text-caption text-grey-6">{{ issue?.name }} · {{ 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>
|
||||
443
apps/ops/src/components/shared/TaskNode.vue
Normal file
443
apps/ops/src/components/shared/TaskNode.vue
Normal 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>
|
||||
|
|
@ -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 || '?' }} → {{ 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>
|
||||
|
|
@ -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' }} · {{ 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>
|
||||
282
apps/ops/src/components/shared/detail-sections/IssueDetail.vue
Normal file
282
apps/ops/src/components/shared/detail-sections/IssueDetail.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
71
apps/ops/src/composables/useContextMenus.js
Normal file
71
apps/ops/src/composables/useContextMenus.js
Normal 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,
|
||||
}
|
||||
}
|
||||
101
apps/ops/src/composables/useCustomerNotes.js
Normal file
101
apps/ops/src/composables/useCustomerNotes.js
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ export function useDetailModal () {
|
|||
const modalComments = ref([])
|
||||
const modalComms = ref([])
|
||||
const modalFiles = ref([])
|
||||
const modalDispatchJobs = ref([])
|
||||
|
||||
const modalErpLink = computed(() => erpLink(modalDoctype.value, modalDocName.value))
|
||||
|
||||
|
|
@ -42,6 +43,7 @@ export function useDetailModal () {
|
|||
modalComments.value = []
|
||||
modalComms.value = []
|
||||
modalFiles.value = []
|
||||
modalDispatchJobs.value = []
|
||||
modalOpen.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') {
|
||||
promises.push(
|
||||
listDocs('Communication', {
|
||||
|
|
@ -75,6 +77,11 @@ export function useDetailModal () {
|
|||
fields: ['name', 'content', 'comment_by', 'creation'],
|
||||
limit: 200, orderBy: 'creation asc',
|
||||
}).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] || []
|
||||
modalFiles.value = results[2] || []
|
||||
modalComments.value = results[3] || []
|
||||
modalDispatchJobs.value = results[4] || []
|
||||
}
|
||||
|
||||
// Auto-derive title from doc if not provided
|
||||
|
|
@ -116,6 +124,7 @@ export function useDetailModal () {
|
|||
modalFiles,
|
||||
modalErpLink,
|
||||
modalDocFields,
|
||||
modalDispatchJobs,
|
||||
openModal,
|
||||
closeModal,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,3 +58,80 @@ export function erpFileUrl (url) {
|
|||
if (url.startsWith('http')) return 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 (' -> ', & -> &, 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)
|
||||
}
|
||||
|
|
|
|||
103
apps/ops/src/composables/useGpsTracking.js
Normal file
103
apps/ops/src/composables/useGpsTracking.js
Normal 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 }
|
||||
}
|
||||
64
apps/ops/src/composables/usePeriodNavigation.js
Normal file
64
apps/ops/src/composables/usePeriodNavigation.js
Normal 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,
|
||||
}
|
||||
}
|
||||
63
apps/ops/src/composables/useResourceFilter.js
Normal file
63
apps/ops/src/composables/useResourceFilter.js
Normal 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,
|
||||
}
|
||||
}
|
||||
105
apps/ops/src/composables/useSSE.js
Normal file
105
apps/ops/src/composables/useSSE.js
Normal 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()
|
||||
}
|
||||
181
apps/ops/src/composables/useSubscriptionActions.js
Normal file
181
apps/ops/src/composables/useSubscriptionActions.js
Normal 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,
|
||||
}
|
||||
}
|
||||
56
apps/ops/src/composables/useTagManagement.js
Normal file
56
apps/ops/src/composables/useTagManagement.js
Normal 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,
|
||||
}
|
||||
}
|
||||
74
apps/ops/src/composables/useTechManagement.js
Normal file
74
apps/ops/src/composables/useTechManagement.js
Normal 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,
|
||||
}
|
||||
}
|
||||
19
apps/ops/src/config/device-icons.js
Normal file
19
apps/ops/src/config/device-icons.js
Normal 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'
|
||||
}
|
||||
|
|
@ -4,6 +4,9 @@
|
|||
const viteBase = import.meta.env.BASE_URL || '/'
|
||||
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 TECH_COLORS = [
|
||||
|
|
|
|||
31
apps/ops/src/config/nav.js
Normal file
31
apps/ops/src/config/nav.js
Normal 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' },
|
||||
]
|
||||
210
apps/ops/src/config/project-templates.js
Normal file
210
apps/ops/src/config/project-templates.js
Normal 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',
|
||||
]
|
||||
37
apps/ops/src/config/table-columns.js
Normal file
37
apps/ops/src/config/table-columns.js
Normal 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' },
|
||||
]
|
||||
55
apps/ops/src/config/ticket-config.js
Normal file
55
apps/ops/src/config/ticket-config.js
Normal 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'
|
||||
}
|
||||
|
|
@ -1,16 +1,30 @@
|
|||
// Targo Ops — Global styles
|
||||
|
||||
: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-success: #10b981;
|
||||
--ops-warning: #f59e0b;
|
||||
--ops-danger: #ef4444;
|
||||
|
||||
// Light content area
|
||||
--ops-bg-hover: #eef2ff;
|
||||
--ops-bg-light: #f8fafc;
|
||||
--ops-bg: #f8fafc;
|
||||
--ops-surface: #ffffff;
|
||||
--ops-border: #e2e8f0;
|
||||
--ops-text: #1e293b;
|
||||
--ops-text-muted: #64748b;
|
||||
|
||||
// Legacy alias
|
||||
--ops-primary: #111422;
|
||||
}
|
||||
|
||||
body {
|
||||
|
|
@ -18,23 +32,78 @@ body {
|
|||
color: var(--ops-text);
|
||||
}
|
||||
|
||||
// Sidebar
|
||||
// ── Sidebar ─────────────────────────────────────────────────────────────────
|
||||
.ops-sidebar {
|
||||
background: var(--ops-primary);
|
||||
width: 220px;
|
||||
background: var(--ops-sidebar-bg) !important;
|
||||
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 {
|
||||
color: rgba(255,255,255,0.7);
|
||||
color: var(--ops-sidebar-text);
|
||||
border-radius: 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 {
|
||||
color: #fff;
|
||||
color: var(--ops-sidebar-text-active);
|
||||
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 {
|
||||
background: var(--ops-surface);
|
||||
border: 1px solid var(--ops-border);
|
||||
|
|
@ -42,37 +111,25 @@ body {
|
|||
padding: 16px;
|
||||
}
|
||||
|
||||
// Stat cards
|
||||
// ── Stat cards ──────────────────────────────────────────────────────────────
|
||||
.ops-stat {
|
||||
text-align: center;
|
||||
.ops-stat-value {
|
||||
font-size: 1.8rem;
|
||||
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;
|
||||
}
|
||||
.ops-stat-value { font-size: 1.8rem; 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 {
|
||||
.q-table__top { padding: 8px 16px; }
|
||||
th { font-weight: 600; color: var(--ops-text-muted); font-size: 0.75rem; text-transform: uppercase; }
|
||||
td { font-size: 0.875rem; }
|
||||
}
|
||||
|
||||
// Status badges
|
||||
// ── Status badges ───────────────────────────────────────────────────────────
|
||||
.ops-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
display: inline-flex; align-items: center;
|
||||
padding: 2px 10px; border-radius: 20px;
|
||||
font-size: 0.75rem; font-weight: 600;
|
||||
&.active { background: #d1fae5; color: #065f46; }
|
||||
&.inactive { background: #fee2e2; color: #991b1b; }
|
||||
&.draft { background: #e0e7ff; color: #3730a3; }
|
||||
|
|
@ -80,7 +137,7 @@ body {
|
|||
&.closed { background: #f1f5f9; color: #475569; }
|
||||
}
|
||||
|
||||
// Search bar
|
||||
// ── Search bar ──────────────────────────────────────────────────────────────
|
||||
.ops-search {
|
||||
.q-field__control {
|
||||
border-radius: 10px;
|
||||
|
|
@ -88,3 +145,79 @@ body {
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,89 +1,60 @@
|
|||
<template>
|
||||
<q-layout view="lHh LpR fFf">
|
||||
|
||||
<!-- Sidebar -->
|
||||
<q-drawer v-model="drawer" :width="220" :breakpoint="1024" bordered class="ops-sidebar">
|
||||
<!-- Collapsible Sidebar -->
|
||||
<q-drawer v-model="drawer" :width="sidebarW" :breakpoint="1024" class="ops-sidebar" :class="{ 'ops-sidebar-mini': collapsed }">
|
||||
<q-list>
|
||||
<!-- Logo -->
|
||||
<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>
|
||||
<q-item-label style="color:#fff;font-size:1.1rem;font-weight:700">Targo Ops</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item class="q-py-md q-mb-sm" style="pointer-events:none" :class="{ 'justify-center': collapsed }">
|
||||
<q-item-section avatar><q-icon name="hub" size="28px" color="white" /></q-item-section>
|
||||
<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-item>
|
||||
|
||||
<q-separator dark class="q-mb-sm" />
|
||||
|
||||
<!-- Nav items -->
|
||||
<q-item
|
||||
v-for="nav in navItems" :key="nav.path"
|
||||
clickable :to="nav.path"
|
||||
:class="{ 'active-link': $route.path === nav.path }"
|
||||
>
|
||||
<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 v-for="nav in navItems" :key="nav.path" clickable :to="nav.path"
|
||||
:class="{ 'active-link': isActive(nav.path), 'justify-center': collapsed }"
|
||||
:title="collapsed ? nav.label : undefined">
|
||||
<q-item-section avatar><component :is="icons[nav.icon]" :size="20" /></q-item-section>
|
||||
<q-item-section v-if="!collapsed"><q-item-label>{{ nav.label }}</q-item-label></q-item-section>
|
||||
<q-item-section side v-if="nav.badge && !collapsed"><q-badge color="red" :label="nav.badge" rounded /></q-item-section>
|
||||
<q-tooltip v-if="collapsed" anchor="center right" self="center left" :offset="[8, 0]">{{ nav.label }}</q-tooltip>
|
||||
</q-item>
|
||||
</q-list>
|
||||
|
||||
<!-- Bottom: user -->
|
||||
<template #mini><!-- prevent mini drawer --></template>
|
||||
<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()">
|
||||
<div class="ops-sidebar-bottom">
|
||||
<q-item dense clickable @click="toggleCollapse" class="ops-collapse-btn" :class="{ 'justify-center': collapsed }">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="logout" size="20px" color="grey-6" />
|
||||
</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>
|
||||
<component :is="collapsed ? icons.PanelLeftOpen : icons.PanelLeftClose" :size="16" />
|
||||
</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>
|
||||
</div>
|
||||
</q-drawer>
|
||||
|
||||
<!-- Header (mobile) -->
|
||||
<q-header v-if="$q.screen.lt.lg" class="bg-white text-dark" bordered>
|
||||
<!-- Mobile header -->
|
||||
<q-header v-if="$q.screen.lt.lg" class="ops-mobile-header">
|
||||
<q-toolbar>
|
||||
<q-btn flat round dense icon="menu" @click="drawer = !drawer" />
|
||||
<q-toolbar-title class="text-weight-bold" style="font-size:1rem">
|
||||
{{ currentNav?.label || 'Targo Ops' }}
|
||||
</q-toolbar-title>
|
||||
<q-btn flat round dense icon="menu" color="white" @click="drawer = !drawer" />
|
||||
<q-toolbar-title class="text-weight-bold" style="font-size:1rem;color:#fff">{{ currentNav?.label || 'Targo Ops' }}</q-toolbar-title>
|
||||
<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>
|
||||
<!-- Mobile search dropdown -->
|
||||
<div v-if="showMobileSearch" class="q-px-sm q-pb-sm bg-white">
|
||||
<q-input
|
||||
ref="mobileSearchRef"
|
||||
v-model="globalSearch"
|
||||
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>
|
||||
<div v-if="mobileSearchOpen && !isDispatch" class="q-px-sm q-pb-sm" style="background:var(--ops-sidebar-bg)">
|
||||
<q-input v-model="searchQuery" placeholder="Rechercher client, adresse..." dense outlined dark autofocus class="ops-search-dark"
|
||||
@keyup.enter="doSearch" @keydown.escape="closeMobileSearch">
|
||||
<template #prepend><q-icon name="search" color="grey-5" /></template>
|
||||
<template #append v-if="searchQuery"><q-icon name="close" class="cursor-pointer" color="grey-5" @click="clearSearch" /></template>
|
||||
</q-input>
|
||||
<div v-if="searchResults.length" class="search-dropdown">
|
||||
<div v-for="r in searchResults" :key="r.id" class="search-result" @mousedown="goToResult(r)">
|
||||
<q-icon :name="r.icon" size="18px" color="grey-6" class="q-mr-sm" />
|
||||
<div>
|
||||
<div class="search-result-title">{{ r.title }}</div>
|
||||
<div class="search-result-sub">{{ r.sub }}</div>
|
||||
<div v-if="searchResults.length" class="ops-search-results">
|
||||
<div v-for="r in searchResults" :key="r.id" class="ops-search-result" @mousedown="goToResult(r)">
|
||||
<q-icon :name="r.icon" size="18px" :color="r.type === 'customer' ? 'indigo-5' : 'teal-5'" class="q-mr-sm" />
|
||||
<div style="flex:1;min-width:0">
|
||||
<div class="ops-search-title">{{ r.title }}</div>
|
||||
<div class="ops-search-sub">{{ r.sub }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -92,114 +63,36 @@
|
|||
|
||||
<!-- Main content -->
|
||||
<q-page-container>
|
||||
<!-- Top bar (desktop) -->
|
||||
<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">
|
||||
<!-- Desktop top bar (hidden on dispatch) -->
|
||||
<div v-if="$q.screen.gt.md && !isDispatch" class="ops-topbar">
|
||||
<div class="text-h6 text-weight-bold">{{ currentNav?.label || '' }}</div>
|
||||
<q-space />
|
||||
<div style="position:relative;width:440px">
|
||||
<q-input
|
||||
ref="desktopSearchRef"
|
||||
v-model="globalSearch"
|
||||
placeholder="Rechercher client, adresse, ticket..."
|
||||
dense outlined
|
||||
class="ops-search"
|
||||
@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"
|
||||
/>
|
||||
<div style="position:relative;width:400px">
|
||||
<q-input v-model="searchQuery" placeholder="Rechercher client, adresse, ticket..." dense outlined class="ops-search"
|
||||
@keyup.enter="doSearch" @keydown.escape="clearSearch"
|
||||
@update:model-value="onSearchInput" @blur="onSearchBlur">
|
||||
<template #prepend><q-icon name="search" color="grey-6" /></template>
|
||||
<template #append v-if="searchQuery">
|
||||
<q-icon name="close" class="cursor-pointer" @click="clearSearch" />
|
||||
</template>
|
||||
</q-input>
|
||||
<!-- Dropdown results -->
|
||||
<div v-if="showDropdown && searchResults.length" class="search-dropdown">
|
||||
<div
|
||||
v-for="(r, i) in searchResults" :key="r.id"
|
||||
class="search-result"
|
||||
:class="{ highlighted: i === highlightIndex }"
|
||||
@mousedown="goToResult(r)"
|
||||
>
|
||||
<div v-if="searchResults.length && searchDropdownOpen" class="ops-search-results ops-search-results-desktop">
|
||||
<div v-for="(r, i) in searchResults" :key="r.id" class="ops-search-result"
|
||||
:class="{ 'ops-search-highlighted': i === highlightIdx }"
|
||||
@mousedown="goToResult(r)">
|
||||
<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 style="flex:1;min-width:0">
|
||||
<div class="ops-search-title">{{ r.title }}</div>
|
||||
<div class="ops-search-sub">{{ r.sub }}</div>
|
||||
</div>
|
||||
<div class="search-result-type">{{ r.typeLabel }}</div>
|
||||
<div class="ops-search-type">{{ r.typeLabel }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="showDropdown && globalSearch.length >= 2 && !searching" class="search-dropdown">
|
||||
<div class="search-result text-grey-5" style="justify-content:center">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 v-else-if="searchDropdownOpen && searchQuery.length >= 2 && !searchResults.length && !searchLoading" class="ops-search-results ops-search-results-desktop">
|
||||
<div class="ops-search-result" style="justify-content:center;color:#94a3b8">Aucun résultat</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<router-view />
|
||||
</q-page-container>
|
||||
|
||||
|
|
@ -207,384 +100,126 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from 'src/stores/auth'
|
||||
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 route = useRoute()
|
||||
const router = useRouter()
|
||||
const drawer = ref(true)
|
||||
const showMobileSearch = ref(false)
|
||||
const globalSearch = ref('')
|
||||
const collapsed = ref(localStorage.getItem('ops-sidebar-collapsed') !== 'false')
|
||||
|
||||
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 searching = ref(false)
|
||||
const showDropdown = ref(false)
|
||||
const highlightIndex = ref(-1)
|
||||
|
||||
// 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' },
|
||||
]
|
||||
|
||||
const searchLoading = ref(false)
|
||||
const searchDropdownOpen = ref(false)
|
||||
const highlightIdx = ref(-1)
|
||||
const mobileSearchOpen = ref(false)
|
||||
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) {
|
||||
highlightIndex.value = -1
|
||||
clearTimeout(searchTimer)
|
||||
// Easter egg: @targo → Authentik admin login
|
||||
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
|
||||
}
|
||||
highlightIdx.value = -1
|
||||
if (!val || val.length < 2) {
|
||||
searchResults.value = []
|
||||
showDropdown.value = false
|
||||
searchDropdownOpen.value = false
|
||||
searchLoading.value = false
|
||||
return
|
||||
}
|
||||
showDropdown.value = true
|
||||
showAdvanced.value = false
|
||||
searching.value = true
|
||||
searchTimer = setTimeout(() => doSearch(val), 300)
|
||||
searchDropdownOpen.value = true
|
||||
searchLoading.value = true
|
||||
searchTimer = setTimeout(() => runSearch(val), 300)
|
||||
}
|
||||
|
||||
async function doSearch (query) {
|
||||
const q = query.trim()
|
||||
if (q.length < 2) { searching.value = false; return }
|
||||
|
||||
async function runSearch (q) {
|
||||
if (!q || q.length < 2) { searchLoading.value = false; return }
|
||||
try {
|
||||
// Search customers by name AND by ID, plus locations by address, city, and postal code
|
||||
const [custByName, custById, locByAddr, locByCity] = await Promise.all([
|
||||
listDocs('Customer', {
|
||||
filters: { customer_name: ['like', '%' + q + '%'] },
|
||||
fields: ['name', 'customer_name', 'customer_type', 'territory', 'disabled'],
|
||||
limit: 6,
|
||||
orderBy: 'customer_name asc',
|
||||
}).catch(() => []),
|
||||
listDocs('Customer', {
|
||||
filters: { name: ['like', '%' + q + '%'] },
|
||||
fields: ['name', 'customer_name', 'customer_type', 'territory', 'disabled'],
|
||||
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(() => []),
|
||||
const cf = ['name', 'customer_name', 'customer_type', 'territory', 'disabled']
|
||||
const lf = ['name', 'address_line', 'city', 'customer', 'customer_name', 'status']
|
||||
const timeout = new Promise((_, r) => setTimeout(() => r(new Error('timeout')), 4000))
|
||||
const [cName, cId, lAddr, lCity] = await Promise.race([
|
||||
Promise.all([
|
||||
listDocs('Customer', { filters: { customer_name: ['like', '%' + q + '%'] }, fields: cf, limit: 6, orderBy: 'customer_name asc' }).catch(() => []),
|
||||
listDocs('Customer', { filters: { name: ['like', '%' + q + '%'] }, fields: cf, limit: 4, orderBy: 'name asc' }).catch(() => []),
|
||||
listDocs('Service Location', { filters: { address_line: ['like', '%' + q + '%'] }, fields: lf, limit: 6, orderBy: 'address_line asc' }).catch(() => []),
|
||||
listDocs('Service Location', { filters: { city: ['like', '%' + q + '%'] }, fields: lf, limit: 4, orderBy: 'city asc' }).catch(() => []),
|
||||
]),
|
||||
timeout,
|
||||
])
|
||||
|
||||
// Deduplicate
|
||||
const seen = new Set()
|
||||
const results = []
|
||||
|
||||
for (const c of [...custByName, ...custById]) {
|
||||
if (seen.has(c.name)) continue
|
||||
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,
|
||||
})
|
||||
const out = []
|
||||
for (const c of [...cName, ...cId]) {
|
||||
if (seen.has(c.name)) continue; seen.add(c.name)
|
||||
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 })
|
||||
}
|
||||
|
||||
for (const l of [...locByAddr, ...locByCity]) {
|
||||
if (seen.has(l.name)) continue
|
||||
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,
|
||||
})
|
||||
for (const l of [...lAddr, ...lCity]) {
|
||||
if (seen.has(l.name)) continue; seen.add(l.name)
|
||||
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 })
|
||||
}
|
||||
|
||||
searchResults.value = results.slice(0, 12)
|
||||
searchResults.value = out.slice(0, 12)
|
||||
} catch {
|
||||
searchResults.value = []
|
||||
}
|
||||
searching.value = false
|
||||
searchLoading.value = false
|
||||
}
|
||||
|
||||
function toggleAdvanced () {
|
||||
showAdvanced.value = !showAdvanced.value
|
||||
if (showAdvanced.value) {
|
||||
showDropdown.value = false
|
||||
function doSearch () {
|
||||
if (searchResults.value.length) {
|
||||
const idx = highlightIdx.value >= 0 ? highlightIdx.value : 0
|
||||
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) {
|
||||
router.push(r.route)
|
||||
clearSearch()
|
||||
}
|
||||
|
||||
function goToFirstResult () {
|
||||
if (highlightIndex.value >= 0 && searchResults.value[highlightIndex.value]) {
|
||||
goToResult(searchResults.value[highlightIndex.value])
|
||||
} else if (searchResults.value.length) {
|
||||
goToResult(searchResults.value[0])
|
||||
} else if (globalSearch.value.trim()) {
|
||||
router.push({ path: '/clients', query: { q: globalSearch.value.trim() } })
|
||||
function clearSearch () {
|
||||
searchQuery.value = ''
|
||||
searchResults.value = []
|
||||
searchDropdownOpen.value = false
|
||||
searchLoading.value = false
|
||||
highlightIdx.value = -1
|
||||
mobileSearchOpen.value = false
|
||||
clearTimeout(searchTimer)
|
||||
}
|
||||
|
||||
function closeMobileSearch () {
|
||||
clearSearch()
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
mobileSearchOpen.value = false
|
||||
}
|
||||
|
||||
function onSearchBlur () {
|
||||
setTimeout(() => {
|
||||
showDropdown.value = false
|
||||
if (!showAdvanced.value) return
|
||||
}, 200)
|
||||
}
|
||||
|
||||
function clearSearch () {
|
||||
globalSearch.value = ''
|
||||
searchResults.value = []
|
||||
showDropdown.value = false
|
||||
highlightIndex.value = -1
|
||||
showMobileSearch.value = false
|
||||
setTimeout(() => { searchDropdownOpen.value = false }, 200)
|
||||
}
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -6,33 +6,25 @@
|
|||
</div>
|
||||
|
||||
<template v-else-if="customer">
|
||||
<!-- ═══ HEADER ═══ -->
|
||||
<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) ═══ -->
|
||||
<!-- ═══ TWO-COLUMN LAYOUT: Content left | Convos right ═══ -->
|
||||
<div class="row q-col-gutter-md">
|
||||
<!-- LEFT: Main content -->
|
||||
<!-- LEFT: Header + all content sections -->
|
||||
<div class="col-12 col-lg-8">
|
||||
|
||||
<!-- ═══ DELIVERY LOCATIONS ═══ -->
|
||||
<div class="section-title q-mb-sm" style="font-size:1rem">
|
||||
<!-- ═══ HEADER (with contact & info inside) ═══ -->
|
||||
<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" />
|
||||
Lieux de service ({{ locations.length }})
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="!locations.length" class="ops-card text-center text-grey-6 q-pa-md q-mb-md">
|
||||
Aucun lieu de service
|
||||
|
|
@ -265,6 +257,7 @@
|
|||
|
||||
</div><!-- /collapsible body -->
|
||||
</div>
|
||||
</q-expansion-item>
|
||||
|
||||
<!-- ═══ TICKETS (collapsible) ═══ -->
|
||||
<q-expansion-item v-model="sectionsOpen.tickets" header-class="section-header" class="q-mb-sm">
|
||||
|
|
@ -410,88 +403,24 @@
|
|||
</div>
|
||||
</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="notes-panel">
|
||||
<div class="notes-panel-header">
|
||||
<span class="text-weight-bold" style="font-size:1.1rem">Notes</span>
|
||||
<q-space />
|
||||
<span class="text-caption text-grey-5">{{ comments.length }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Quick add note -->
|
||||
<div class="note-input-wrap" @click="noteInputFocused = true">
|
||||
<div v-if="!noteInputFocused && !newNote" class="note-input-placeholder">
|
||||
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 class="convos-sticky">
|
||||
<ChatterPanel
|
||||
:customer-name="customer.name"
|
||||
:customer-phone="customer.cell_phone || customer.tel_home || ''"
|
||||
:customer-phones="customerPhoneOptions"
|
||||
:customer-email="customer.email_billing || ''"
|
||||
:comments="comments"
|
||||
@note-added="onNoteAdded"
|
||||
@note-updated="onNoteAdded"
|
||||
@navigate="(dt, name) => openModal(dt, name)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Display mode -->
|
||||
<div v-else class="note-content" v-html="c.content"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /row main+notes -->
|
||||
</div><!-- /row two-column -->
|
||||
|
||||
</template>
|
||||
|
||||
|
|
@ -514,33 +443,43 @@
|
|||
:comms="modalComms"
|
||||
:files="modalFiles"
|
||||
:doc-fields="modalDocFields"
|
||||
:dispatch-jobs="modalDispatchJobs"
|
||||
@navigate="(dt, name, t) => openModal(dt, name, t)"
|
||||
@open-pdf="openPdf"
|
||||
@save-field="saveSubField"
|
||||
@toggle-recurring="toggleRecurringModal"
|
||||
@dispatch-created="onDispatchCreated"
|
||||
@dispatch-deleted="name => { modalDispatchJobs = modalDispatchJobs.filter(j => j.name !== name) }"
|
||||
/>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, reactive, watch } from 'vue'
|
||||
import { Notify } from 'quasar'
|
||||
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 { BASE_URL } from 'src/config/erpnext'
|
||||
import { formatDate, formatDateShort, formatMoney, erpLink, erpFileUrl } from 'src/composables/useFormatters'
|
||||
import { locStatusClass, subStatusClass, eqStatusClass, ticketStatusClass, invStatusClass, deviceColorClass } from 'src/composables/useStatusClasses'
|
||||
import { formatDate, formatMoney, staffColor, staffInitials } from 'src/composables/useFormatters'
|
||||
import { locStatusClass, ticketStatusClass, invStatusClass, deviceColorClass } from 'src/composables/useStatusClasses'
|
||||
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 CustomerHeader from 'src/components/customer/CustomerHeader.vue'
|
||||
import ContactCard from 'src/components/customer/ContactCard.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 ChatterPanel from 'src/components/customer/ChatterPanel.vue'
|
||||
|
||||
const props = defineProps({ id: String })
|
||||
|
||||
// ═══ Core data refs ═══
|
||||
const loading = ref(true)
|
||||
const customer = ref(null)
|
||||
const contact = ref(null)
|
||||
|
|
@ -551,81 +490,73 @@ const tickets = ref([])
|
|||
const invoices = ref([])
|
||||
const payments = ref([])
|
||||
const comments = ref([])
|
||||
const newNote = ref('')
|
||||
const accountBalance = ref(null)
|
||||
const customerGroups = ['Commercial', 'Individual', 'Government', 'Non Profit']
|
||||
|
||||
// Subscription grouping composable
|
||||
// ═══ Composables ═══
|
||||
const {
|
||||
locSubs, locSubsMonthly, locSubsAnnual, locSubsMonthlyTotal, locSubsAnnualTotal,
|
||||
locSubsSections, sectionOpen, toggleSection, invalidateCache, invalidateAll,
|
||||
subSections,
|
||||
} = 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 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 {
|
||||
subSaving, togglingRecurring,
|
||||
toggleSubStatus, toggleFrequency, toggleRecurring,
|
||||
toggleRecurringModal, saveSubField, onSubDragChange,
|
||||
} = 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 withSubs = locations.value.filter(l => locHasSubs(l.name))
|
||||
const withoutSubs = locations.value.filter(l => !locHasSubs(l.name))
|
||||
return [...withSubs, ...withoutSubs]
|
||||
})
|
||||
|
||||
// Collapsible sections — tickets open by default, others collapsed
|
||||
const sectionsOpen = ref({ tickets: true, invoices: false, payments: false, notes: false })
|
||||
// ═══ UI state ═══
|
||||
const sectionsOpen = ref({ locations: true, tickets: true, invoices: false, payments: false, notes: false })
|
||||
|
||||
// Modal state (shared composable)
|
||||
const {
|
||||
modalOpen, modalLoading, modalDoctype, modalDocName, modalTitle,
|
||||
modalDoc, modalComments, modalComms, modalFiles, modalDocFields,
|
||||
openModal,
|
||||
} = useDetailModal()
|
||||
// ═══ Computed ═══
|
||||
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 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
|
||||
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()
|
||||
}
|
||||
// ═══ Small helpers ═══
|
||||
function locEquip (locName) { return equipment.value.filter(e => e.service_location === locName) }
|
||||
function locTickets (locName) { return tickets.value.filter(t => t.service_location === locName) }
|
||||
|
||||
// PDF print URL for Sales Invoice
|
||||
function erpPdfUrl (name) {
|
||||
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) {
|
||||
try {
|
||||
const res = await authFetch(erpPdfUrl(name))
|
||||
|
|
@ -635,360 +566,11 @@ async function openPdf (name) {
|
|||
window.open(url, '_blank')
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60000)
|
||||
} 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 (' → ', & → &, etc.)
|
||||
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' },
|
||||
]
|
||||
|
||||
// ═══ Data loading ═══
|
||||
async function loadCustomer (id) {
|
||||
loading.value = true
|
||||
customer.value = null
|
||||
|
|
@ -1004,10 +586,7 @@ async function loadCustomer (id) {
|
|||
|
||||
try {
|
||||
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
|
||||
|
||||
const custFilter = { customer: id }
|
||||
|
|
@ -1019,87 +598,66 @@ async function loadCustomer (id) {
|
|||
fields: ['name', 'location_name', 'status', 'address_line', 'city', 'postal_code',
|
||||
'connection_type', 'olt_port', 'network_id', 'contact_name', 'contact_phone',
|
||||
'longitude', 'latitude'],
|
||||
limit: 100,
|
||||
orderBy: 'status asc, address_line asc',
|
||||
limit: 100, orderBy: 'status asc, address_line asc',
|
||||
}),
|
||||
listDocs('Subscription', {
|
||||
filters: partyFilter,
|
||||
fields: ['name', 'status', 'start_date', 'service_location', 'radius_user',
|
||||
'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'],
|
||||
limit: 100,
|
||||
orderBy: 'start_date desc',
|
||||
limit: 100, orderBy: 'start_date desc',
|
||||
}),
|
||||
listDocs('Service Equipment', {
|
||||
filters: custFilter,
|
||||
fields: ['name', 'equipment_type', 'brand', 'model', 'serial_number', 'mac_address',
|
||||
'ip_address', 'status', 'service_location',
|
||||
'olt_name', 'olt_ip', 'olt_frame', 'olt_slot', 'olt_port', 'olt_ontid'],
|
||||
limit: 200,
|
||||
orderBy: 'equipment_type asc',
|
||||
limit: 200, orderBy: 'equipment_type asc',
|
||||
}),
|
||||
listDocs('Issue', {
|
||||
filters: custFilter,
|
||||
fields: ['name', 'subject', 'status', 'priority', 'opening_date', 'service_location', 'legacy_ticket_id', 'is_important', 'assigned_staff', 'opened_by_staff', 'issue_type'],
|
||||
limit: 200,
|
||||
orderBy: 'opening_date desc',
|
||||
limit: 200, orderBy: 'opening_date desc',
|
||||
}),
|
||||
listDocs('Sales Invoice', {
|
||||
filters: custFilter,
|
||||
fields: ['name', 'posting_date', 'grand_total', 'outstanding_amount', 'status', 'is_return', 'return_against', 'creation'],
|
||||
limit: 50,
|
||||
orderBy: 'posting_date desc, name desc',
|
||||
limit: 50, orderBy: 'posting_date desc, name desc',
|
||||
}),
|
||||
listDocs('Payment Entry', {
|
||||
filters: { party_type: 'Customer', party: id },
|
||||
fields: ['name', 'posting_date', 'paid_amount', 'mode_of_payment', 'reference_no'],
|
||||
limit: 50,
|
||||
orderBy: 'posting_date desc',
|
||||
limit: 50, 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(() => []),
|
||||
// Comments/memos on this customer
|
||||
listDocs('Contact', { filters: {}, fields: ['name', 'first_name', 'last_name', 'email_id', 'mobile_no', 'phone'], limit: 1 }).catch(() => []),
|
||||
listDocs('Comment', {
|
||||
filters: { reference_doctype: 'Customer', reference_name: id, comment_type: 'Comment' },
|
||||
fields: ['name', 'content', 'comment_by', 'creation'],
|
||||
limit: 50,
|
||||
orderBy: 'creation desc',
|
||||
limit: 50, orderBy: 'creation desc',
|
||||
}).catch(() => []),
|
||||
])
|
||||
|
||||
locations.value = locs
|
||||
subscriptions.value = subs
|
||||
// Reset section cache so it rebuilds from fresh data
|
||||
invalidateAll()
|
||||
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 || ''))
|
||||
invoices.value = invs
|
||||
payments.value = pays
|
||||
contact.value = ctc.length ? ctc[0] : null
|
||||
comments.value = memos
|
||||
|
||||
// Fetch accurate account balance from server (covers ALL invoices/payments, not just page limit)
|
||||
try {
|
||||
const balRes = await authFetch(BASE_URL + '/api/method/customer_balance?customer=' + encodeURIComponent(id))
|
||||
if (balRes.ok) {
|
||||
const balData = await balRes.json()
|
||||
accountBalance.value = balData.message
|
||||
}
|
||||
if (balRes.ok) { accountBalance.value = (await balRes.json()).message }
|
||||
} catch {}
|
||||
} catch (e) {
|
||||
console.error('Failed to load customer', e)
|
||||
} catch {
|
||||
customer.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Reload when navigating between clients
|
||||
watch(() => props.id, (newId) => { if (newId) loadCustomer(newId) })
|
||||
onMounted(() => loadCustomer(props.id))
|
||||
</script>
|
||||
|
|
@ -1451,16 +1009,6 @@ code {
|
|||
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 {
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
|
|
@ -1474,12 +1022,6 @@ code {
|
|||
}
|
||||
}
|
||||
|
||||
.clickable-table :deep(tbody tr) {
|
||||
cursor: pointer;
|
||||
&:hover td {
|
||||
background: #eef2ff !important;
|
||||
}
|
||||
}
|
||||
.ticket-subject-cell {
|
||||
white-space: normal !important;
|
||||
word-break: break-word;
|
||||
|
|
@ -1506,40 +1048,6 @@ code {
|
|||
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 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
|
|
|||
|
|
@ -4,11 +4,16 @@
|
|||
<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="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-label">{{ stat.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Admin controls -->
|
||||
<div class="row q-col-gutter-md q-mb-lg">
|
||||
|
|
@ -59,7 +64,11 @@
|
|||
<div class="ops-card">
|
||||
<div class="text-subtitle1 text-weight-bold q-mb-sm">Tickets ouverts</div>
|
||||
<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-label>{{ t.subject }}</q-item-label>
|
||||
<q-item-label caption>{{ t.customer_name || t.customer }}</q-item-label>
|
||||
|
|
@ -78,20 +87,30 @@
|
|||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<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-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-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"> · {{ j.customer }}</span>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<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>
|
||||
<q-item v-if="!todayJobs.length">
|
||||
<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>
|
||||
</q-list>
|
||||
|
|
@ -108,11 +127,11 @@ import { authFetch } from 'src/api/auth'
|
|||
import { BASE_URL } from 'src/config/erpnext'
|
||||
|
||||
const stats = ref([
|
||||
{ label: 'Abonnés', value: '...', color: 'var(--ops-accent)' },
|
||||
{ label: 'Clients', value: '...', color: 'var(--ops-primary)' },
|
||||
{ label: 'Abonnements', value: '...', color: 'var(--ops-success)' },
|
||||
{ label: 'Locations', value: '...', color: '#6b7280' },
|
||||
{ label: 'Tickets ouverts', value: '...', color: 'var(--ops-warning)' },
|
||||
{ label: 'Abonnés', value: '...', color: 'var(--ops-accent)', icon: 'people' },
|
||||
{ label: 'Clients', value: '...', color: 'var(--ops-primary)', icon: 'business' },
|
||||
{ label: 'Rev. mensuel', value: '...', color: 'var(--ops-success)', icon: 'attach_money' },
|
||||
{ label: 'Tickets ouverts', value: '...', color: 'var(--ops-warning)', icon: 'confirmation_number' },
|
||||
{ label: 'Dispatch aujourd\'hui', value: '...', color: 'var(--ops-accent)', icon: 'local_shipping' },
|
||||
])
|
||||
|
||||
const openTickets = ref([])
|
||||
|
|
@ -169,36 +188,48 @@ async function runBilling () {
|
|||
onMounted(async () => {
|
||||
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('Issue', { status: 'Open' }),
|
||||
countDocs('Service Subscription', { status: 'Actif' }),
|
||||
countDocs('Service Location', { status: 'Active' }),
|
||||
listDocs('Dispatch Job', {
|
||||
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)
|
||||
let abonnes = 0
|
||||
let monthlyRev = 0
|
||||
try {
|
||||
const res = await authFetch(BASE_URL + '/api/method/subscriber_count')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
abonnes = data.message?.count || 0
|
||||
}
|
||||
const [subRes, revRes] = await Promise.all([
|
||||
authFetch(BASE_URL + '/api/method/subscriber_count').then(r => r.ok ? r.json() : null).catch(() => null),
|
||||
listDocs('Service Subscription', {
|
||||
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 {
|
||||
abonnes = clients
|
||||
}
|
||||
|
||||
stats.value[0].value = abonnes.toLocaleString()
|
||||
stats.value[1].value = clients.toLocaleString()
|
||||
stats.value[2].value = subs.toLocaleString()
|
||||
stats.value[3].value = locations.toLocaleString()
|
||||
stats.value[4].value = tickets.toLocaleString()
|
||||
stats.value[2].value = monthlyRev ? (Math.round(monthlyRev).toLocaleString() + ' $') : '...'
|
||||
stats.value[3].value = tickets.toLocaleString()
|
||||
stats.value[4].value = todayDispatch.length.toLocaleString()
|
||||
|
||||
openTickets.value = await listDocs('Issue', {
|
||||
filters: { status: 'Open' },
|
||||
fields: ['name', 'subject', 'customer', 'priority', 'opening_date'],
|
||||
limit: 10,
|
||||
orderBy: 'opening_date desc',
|
||||
})
|
||||
openTickets.value = openTix
|
||||
todayJobs.value = todayDispatch
|
||||
})
|
||||
</script>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
229
apps/ops/src/pages/SettingsPage.vue
Normal file
229
apps/ops/src/pages/SettingsPage.vue
Normal 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>
|
||||
|
|
@ -15,12 +15,12 @@
|
|||
:options="statusOptions" @update:model-value="resetAndLoad" />
|
||||
</div>
|
||||
<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
|
||||
:options="issueTypes" @update:model-value="resetAndLoad" placeholder="Tous" />
|
||||
</div>
|
||||
<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
|
||||
:options="priorityOptions" @update:model-value="resetAndLoad" placeholder="Toutes" />
|
||||
</div>
|
||||
|
|
@ -72,7 +72,7 @@
|
|||
<router-link v-if="props.row.customer" :to="'/clients/' + props.row.customer" class="erp-link" @click.stop>
|
||||
{{ props.row.customer_name || props.row.customer }}
|
||||
</router-link>
|
||||
<span v-else class="text-grey-5">—</span>
|
||||
<span v-else class="text-grey-5">---</span>
|
||||
</q-td>
|
||||
</template>
|
||||
<template #body-cell-opening_date="props">
|
||||
|
|
@ -111,7 +111,6 @@
|
|||
</template>
|
||||
</q-table>
|
||||
|
||||
<!-- ═══ TICKET DETAIL MODAL ═══ -->
|
||||
<DetailModal
|
||||
v-model:open="modalOpen"
|
||||
:loading="modalLoading"
|
||||
|
|
@ -122,7 +121,10 @@
|
|||
:comments="modalComments"
|
||||
:comms="modalComms"
|
||||
:files="modalFiles"
|
||||
:dispatch-jobs="modalDispatchJobs"
|
||||
@navigate="(dt, name) => loadModalTicket(name)"
|
||||
@dispatch-created="j => modalDispatchJobs.push(j)"
|
||||
@dispatch-deleted="name => { modalDispatchJobs = modalDispatchJobs.filter(j => j.name !== name) }"
|
||||
>
|
||||
<template #title-prefix>
|
||||
<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 { ticketStatusClass as statusClass, priorityClass } from 'src/composables/useStatusClasses'
|
||||
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 InlineField from 'src/components/shared/InlineField.vue'
|
||||
|
||||
|
|
@ -160,86 +163,26 @@ const loading = ref(false)
|
|||
const total = ref(0)
|
||||
const pagination = ref({ page: 1, rowsPerPage: 25, rowsNumber: 0, sortBy: 'creation', descending: true })
|
||||
|
||||
// Modal state (shared composable)
|
||||
const { modalOpen, modalLoading, modalDoc, modalComments, modalComms, modalFiles, openModal } = useDetailModal()
|
||||
const { modalOpen, modalLoading, modalDoc, modalComments, modalComms, modalFiles, modalDispatchJobs, openModal } = useDetailModal()
|
||||
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 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 () {
|
||||
pagination.value.page = 1
|
||||
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 () {
|
||||
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)
|
||||
|
||||
try {
|
||||
const [data, count] = await Promise.all([
|
||||
listDocs('Issue', {
|
||||
|
|
@ -254,8 +197,7 @@ async function loadTickets () {
|
|||
tickets.value = data
|
||||
total.value = count
|
||||
pagination.value.rowsNumber = count
|
||||
} catch (e) {
|
||||
console.error('Failed to load tickets', e)
|
||||
} catch {
|
||||
tickets.value = []
|
||||
total.value = 0
|
||||
pagination.value.rowsNumber = 0
|
||||
|
|
@ -274,7 +216,6 @@ function onRequest (props) {
|
|||
async function openTicketModal (row) {
|
||||
modalTicket.value = row
|
||||
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 }
|
||||
}
|
||||
|
||||
|
|
@ -285,11 +226,7 @@ async function loadModalTicket (ticketName) {
|
|||
|
||||
async function loadIssueTypes () {
|
||||
try {
|
||||
const types = await listDocs('Issue Type', {
|
||||
fields: ['name'],
|
||||
limit: 100,
|
||||
orderBy: 'name asc',
|
||||
})
|
||||
const types = await listDocs('Issue Type', { fields: ['name'], limit: 100, orderBy: 'name asc' })
|
||||
issueTypes.value = types.map(t => ({ label: t.name, value: t.name }))
|
||||
} catch {
|
||||
issueTypes.value = []
|
||||
|
|
@ -303,30 +240,5 @@ onMounted(async () => {
|
|||
</script>
|
||||
|
||||
<style scoped>
|
||||
.filter-label {
|
||||
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 */
|
||||
.filter-label { font-size: 0.75rem; font-weight: 600; color: #6b7280; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.025em; }
|
||||
</style>
|
||||
|
|
|
|||
459
apps/ops/src/pages/dispatch-styles.scss
Normal file
459
apps/ops/src/pages/dispatch-styles.scss
Normal 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; }
|
||||
|
|
@ -13,10 +13,10 @@ const routes = [
|
|||
{ path: 'equipe', component: () => import('src/pages/EquipePage.vue') },
|
||||
{ path: 'rapports', component: () => import('src/pages/RapportsPage.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 () {
|
||||
|
|
|
|||
|
|
@ -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 { ref } from 'vue'
|
||||
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 { serializeAssistants } from 'src/composables/useHelpers'
|
||||
|
||||
// Module-level GPS guards — survive store re-creation and component remount
|
||||
let __gpsStarted = false
|
||||
let __gpsInterval = null
|
||||
let __gpsPolling = false
|
||||
import { useGpsTracking } from 'src/composables/useGpsTracking'
|
||||
|
||||
export const useDispatchStore = defineStore('dispatch', () => {
|
||||
const technicians = ref([])
|
||||
const jobs = ref([])
|
||||
const allTags = ref([]) // { name, label, color, category }
|
||||
const allTags = ref([])
|
||||
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) {
|
||||
return {
|
||||
id: j.ticket_id || j.name,
|
||||
name: j.name, // ERPNext docname (used for PUT calls)
|
||||
name: j.name,
|
||||
subject: j.subject || 'Job sans titre',
|
||||
address: j.address || 'Adresse inconnue',
|
||||
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 })),
|
||||
tags: (j.tags || []).map(t => t.tag),
|
||||
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) {
|
||||
return {
|
||||
id: t.technician_id || t.name,
|
||||
name: t.name, // ERPNext docname
|
||||
name: t.name,
|
||||
fullName: t.full_name || t.name,
|
||||
status: t.status || '',
|
||||
user: t.user || null,
|
||||
colorIdx: idx % TECH_COLORS.length,
|
||||
coords: [t.longitude || -73.5673, t.latitude || 45.5017],
|
||||
gpsCoords: null, // live GPS from Traccar (updated by polling)
|
||||
gpsCoords: null,
|
||||
gpsSpeed: 0,
|
||||
gpsTime: null,
|
||||
gpsOnline: false,
|
||||
traccarDeviceId: t.traccar_device_id || null,
|
||||
phone: t.phone || '',
|
||||
email: t.email || '',
|
||||
queue: [], // filled in loadAll()
|
||||
queue: [],
|
||||
tags: (t.tags || []).map(tg => tg.tag),
|
||||
tagsWithLevel: (t.tags || []).map(tg => ({ tag: tg.tag, level: tg.level || 0 })),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Loaders ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function loadAll () {
|
||||
loading.value = true
|
||||
erpStatus.value = 'pending'
|
||||
try {
|
||||
const [rawTechs, rawJobs, rawTags] = await Promise.all([
|
||||
fetchTechnicians(),
|
||||
fetchJobs(),
|
||||
fetchTags(),
|
||||
])
|
||||
const [rawTechs, rawJobs, rawTags] = await Promise.all([fetchTechnicians(), fetchJobs(), fetchTags()])
|
||||
allTags.value = rawTags
|
||||
technicians.value = rawTechs.map(_mapTech)
|
||||
jobs.value = rawJobs.map(_mapJob)
|
||||
// Build each tech's ordered queue (primary + assistant jobs)
|
||||
technicians.value.forEach(tech => {
|
||||
tech.queue = jobs.value
|
||||
.filter(j => j.assignedTech === tech.id)
|
||||
.sort((a, b) => a.routeOrder - b.routeOrder)
|
||||
tech.assistJobs = jobs.value
|
||||
.filter(j => j.assistants.some(a => a.techId === tech.id))
|
||||
tech.queue = jobs.value.filter(j => j.assignedTech === 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'
|
||||
} catch (e) {
|
||||
erpStatus.value = e.message?.includes('session') ? 'session_expired' : 'error'
|
||||
console.error('loadAll error:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Load jobs assigned to one tech — used by MobilePage
|
||||
async function loadJobsForTech (techId) {
|
||||
loading.value = true
|
||||
try {
|
||||
|
|
@ -110,23 +98,43 @@ export const useDispatchStore = defineStore('dispatch', () => {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Mutations (also syncs to ERPNext) ────────────────────────────────────
|
||||
|
||||
async function setJobStatus (jobId, status) {
|
||||
const job = jobs.value.find(j => j.id === jobId)
|
||||
if (!job) return
|
||||
const prevStatus = job.status
|
||||
job.status = 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) {
|
||||
const job = jobs.value.find(j => j.id === jobId)
|
||||
if (!job) return
|
||||
// Remove from old tech queue
|
||||
technicians.value.forEach(t => {
|
||||
t.queue = t.queue.filter(q => q.id !== jobId)
|
||||
})
|
||||
// Add to new tech queue
|
||||
technicians.value.forEach(t => { t.queue = t.queue.filter(q => q.id !== jobId) })
|
||||
const tech = technicians.value.find(t => t.id === techId)
|
||||
if (tech) {
|
||||
job.assignedTech = techId
|
||||
|
|
@ -134,14 +142,9 @@ export const useDispatchStore = defineStore('dispatch', () => {
|
|||
job.status = 'assigned'
|
||||
if (scheduledDate !== undefined) job.scheduledDate = scheduledDate
|
||||
tech.queue.splice(routeOrder, 0, job)
|
||||
// Re-number route_order
|
||||
tech.queue.forEach((q, i) => { q.routeOrder = i })
|
||||
}
|
||||
const payload = {
|
||||
assigned_tech: techId,
|
||||
route_order: routeOrder,
|
||||
status: 'assigned',
|
||||
}
|
||||
const payload = { assigned_tech: techId, route_order: routeOrder, status: 'assigned' }
|
||||
if (scheduledDate !== undefined) payload.scheduled_date = scheduledDate || ''
|
||||
await updateJob(job.id, payload)
|
||||
}
|
||||
|
|
@ -156,7 +159,6 @@ export const useDispatchStore = defineStore('dispatch', () => {
|
|||
}
|
||||
|
||||
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 job = _mapJob({
|
||||
ticket_id: localId, name: localId,
|
||||
|
|
@ -179,16 +181,16 @@ export const useDispatchStore = defineStore('dispatch', () => {
|
|||
}
|
||||
try {
|
||||
const created = await apiCreateJob({
|
||||
subject: job.subject,
|
||||
address: job.address,
|
||||
longitude: job.coords?.[0] || '',
|
||||
latitude: job.coords?.[1] || '',
|
||||
duration_h: job.duration,
|
||||
priority: job.priority,
|
||||
status: job.status,
|
||||
assigned_tech: job.assignedTech || '',
|
||||
scheduled_date: job.scheduledDate || '',
|
||||
subject: job.subject, address: job.address,
|
||||
longitude: job.coords?.[0] || '', latitude: job.coords?.[1] || '',
|
||||
duration_h: job.duration, priority: job.priority, status: job.status,
|
||||
assigned_tech: job.assignedTech || '', scheduled_date: job.scheduledDate || '',
|
||||
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 }
|
||||
} catch (_) {}
|
||||
|
|
@ -215,17 +217,13 @@ export const useDispatchStore = defineStore('dispatch', () => {
|
|||
async function addAssistant (jobId, techId) {
|
||||
const job = jobs.value.find(j => j.id === jobId)
|
||||
if (!job) return
|
||||
if (job.assignedTech === techId) return // already lead
|
||||
if (job.assistants.some(a => a.techId === techId)) return // already assistant
|
||||
if (job.assignedTech === techId) return
|
||||
if (job.assistants.some(a => a.techId === techId)) return
|
||||
const tech = technicians.value.find(t => t.id === techId)
|
||||
const entry = { techId, techName: tech?.fullName || techId, duration: job.duration, note: '', pinned: false }
|
||||
job.assistants = [...job.assistants, entry]
|
||||
if (tech) tech.assistJobs = jobs.value.filter(j => j.assistants.some(a => a.techId === tech.id))
|
||||
try {
|
||||
await updateJob(job.name || job.id, {
|
||||
assistants: serializeAssistants(job.assistants),
|
||||
})
|
||||
} catch (_) {}
|
||||
try { await updateJob(job.name || job.id, { assistants: serializeAssistants(job.assistants) }) } catch (_) {}
|
||||
}
|
||||
|
||||
async function removeAssistant (jobId, techId) {
|
||||
|
|
@ -234,11 +232,7 @@ export const useDispatchStore = defineStore('dispatch', () => {
|
|||
job.assistants = job.assistants.filter(a => a.techId !== 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))
|
||||
try {
|
||||
await updateJob(job.name || job.id, {
|
||||
assistants: serializeAssistants(job.assistants),
|
||||
})
|
||||
} catch (_) {}
|
||||
try { await updateJob(job.name || job.id, { assistants: serializeAssistants(job.assistants) }) } catch (_) {}
|
||||
}
|
||||
|
||||
async function reorderTechQueue (techId, fromIdx, toIdx) {
|
||||
|
|
@ -247,13 +241,9 @@ export const useDispatchStore = defineStore('dispatch', () => {
|
|||
const [moved] = tech.queue.splice(fromIdx, 1)
|
||||
tech.queue.splice(toIdx, 0, moved)
|
||||
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) {
|
||||
const job = jobs.value.find(j => j.id === jobId)
|
||||
if (!job) return
|
||||
|
|
@ -265,7 +255,6 @@ export const useDispatchStore = defineStore('dispatch', () => {
|
|||
_rebuildAssistJobs()
|
||||
}
|
||||
|
||||
// ── Full unassign (clears assistants + unassigns) ──────────────────────
|
||||
function fullUnassign (jobId) {
|
||||
const job = jobs.value.find(j => j.id === jobId)
|
||||
if (!job) return
|
||||
|
|
@ -274,122 +263,11 @@ export const useDispatchStore = defineStore('dispatch', () => {
|
|||
_rebuildAssistJobs()
|
||||
}
|
||||
|
||||
// Rebuild all tech.assistJobs references
|
||||
function _rebuildAssistJobs () {
|
||||
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) {
|
||||
// Auto-generate technician_id: TECH-N+1
|
||||
const maxNum = technicians.value.reduce((max, t) => {
|
||||
const m = (t.id || '').match(/TECH-(\d+)/)
|
||||
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)
|
||||
}
|
||||
|
||||
const startGpsPolling = startGpsTracking
|
||||
const stopGpsPolling = stopGpsTracking
|
||||
|
||||
return {
|
||||
technicians, jobs, allTags, loading, erpStatus, traccarDevices,
|
||||
loadAll, loadJobsForTech,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
Field Service Management platform for Gigafibre ISP.
|
||||
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
|
||||
|
||||
```
|
||||
|
|
|
|||
24
erpnext/add_depends_on_field.py
Normal file
24
erpnext/add_depends_on_field.py
Normal 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()
|
||||
|
|
@ -412,6 +412,19 @@ def _extend_dispatch_job():
|
|||
"insert_after": "service_location"},
|
||||
{"dt": "Dispatch Job", "fieldname": "source_issue", "fieldtype": "Link",
|
||||
"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
|
||||
{"dt": "Dispatch Job", "fieldname": "sec_equipment", "fieldtype": "Section Break",
|
||||
"label": "Équipements", "insert_after": "tags"},
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user