refactor: extract composables from 5 largest files — net -1950 lines from main components
DispatchPage.vue: 1320→1217 lines - Extract SbModal.vue + SbContextMenu.vue reusable components - Extract useAbsenceResize composable - Extract dispatch constants to config/dispatch.js ProjectWizard.vue: 1185→673 lines (-43%) - Extract useWizardPublish composable (270-line publish function) - Extract useWizardCatalog composable - Extract wizard-constants.js (step labels, options, categories) SettingsPage.vue: 1172→850 lines (-27%) - Extract usePermissionMatrix composable - Extract useUserGroups composable - Extract useLegacySync composable ClientDetailPage.vue: 1169→864 lines (-26%) - Extract useClientData composable (loadCustomer broken into sub-functions) - Extract useEquipmentActions composable - Extract client-constants.js + erp-pdf.js utility checkout.js: 639→408 lines (-36%) - Extract address-search.js module - Extract otp.js module - Extract email-templates.js module - Extract project-templates.js module - Add erpQuery() helper to DRY repeated URL construction Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
320655b0a0
commit
c6b2dd1491
File diff suppressed because it is too large
Load Diff
57
apps/ops/src/composables/useAbsenceResize.js
Normal file
57
apps/ops/src/composables/useAbsenceResize.js
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { hToTime } from 'src/composables/useHelpers'
|
||||||
|
import { updateTech } from 'src/api/dispatch'
|
||||||
|
|
||||||
|
export function useAbsenceResize (pxPerHr, H_START) {
|
||||||
|
function startAbsenceResize (e, seg, tech, side) {
|
||||||
|
e.preventDefault()
|
||||||
|
const startX = e.clientX
|
||||||
|
const block = e.target.closest('.sb-block-absence')
|
||||||
|
const startW = block.offsetWidth
|
||||||
|
const startL = parseFloat(block.style.left)
|
||||||
|
const SNAP_PX = pxPerHr.value / 4
|
||||||
|
|
||||||
|
const snapPx = px => Math.round(px / SNAP_PX) * SNAP_PX
|
||||||
|
|
||||||
|
function onMove (ev) {
|
||||||
|
const dx = ev.clientX - startX
|
||||||
|
if (side === 'right') {
|
||||||
|
block.style.width = Math.max(SNAP_PX, snapPx(startW + dx)) + 'px'
|
||||||
|
} else {
|
||||||
|
const newL = snapPx(startL + dx)
|
||||||
|
const newW = startW + (startL - newL)
|
||||||
|
if (newW >= SNAP_PX && newL >= 0) {
|
||||||
|
block.style.left = newL + 'px'
|
||||||
|
block.style.width = newW + 'px'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const curL = parseFloat(block.style.left)
|
||||||
|
const curW = parseFloat(block.style.width)
|
||||||
|
const sH = H_START + curL / pxPerHr.value
|
||||||
|
const eH = sH + curW / pxPerHr.value
|
||||||
|
const lbl = block.querySelector('.sb-absence-label')
|
||||||
|
if (lbl) lbl.textContent = `${hToTime(sH)} → ${hToTime(eH)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUp () {
|
||||||
|
document.removeEventListener('mousemove', onMove)
|
||||||
|
document.removeEventListener('mouseup', onUp)
|
||||||
|
const curL = parseFloat(block.style.left)
|
||||||
|
const curW = parseFloat(block.style.width)
|
||||||
|
const newStartH = H_START + curL / pxPerHr.value
|
||||||
|
const newEndH = newStartH + curW / pxPerHr.value
|
||||||
|
const startTime = hToTime(newStartH)
|
||||||
|
const endTime = hToTime(newEndH)
|
||||||
|
tech.absenceStartTime = startTime
|
||||||
|
tech.absenceEndTime = endTime
|
||||||
|
updateTech(tech.name || tech.id, {
|
||||||
|
absence_start_time: startTime,
|
||||||
|
absence_end_time: endTime,
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', onMove)
|
||||||
|
document.addEventListener('mouseup', onUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { startAbsenceResize }
|
||||||
|
}
|
||||||
210
apps/ops/src/composables/useClientData.js
Normal file
210
apps/ops/src/composables/useClientData.js
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { listDocs, getDoc } from 'src/api/erp'
|
||||||
|
import { authFetch } from 'src/api/auth'
|
||||||
|
import { BASE_URL } from 'src/config/erpnext'
|
||||||
|
|
||||||
|
export function useClientData (deps) {
|
||||||
|
const { equipment, modalOpen, ticketsExpanded, invoicesExpanded, paymentsExpanded, invalidateAll, fetchStatus, fetchOltStatus } = deps
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const customer = ref(null)
|
||||||
|
const contact = ref(null)
|
||||||
|
const locations = ref([])
|
||||||
|
const subscriptions = ref([])
|
||||||
|
const tickets = ref([])
|
||||||
|
const invoices = ref([])
|
||||||
|
const payments = ref([])
|
||||||
|
const comments = ref([])
|
||||||
|
const accountBalance = ref(null)
|
||||||
|
|
||||||
|
const loadingMoreTickets = ref(false)
|
||||||
|
const loadingMoreInvoices = ref(false)
|
||||||
|
const loadingMorePayments = ref(false)
|
||||||
|
|
||||||
|
function resetState () {
|
||||||
|
loading.value = true
|
||||||
|
customer.value = null
|
||||||
|
locations.value = []
|
||||||
|
subscriptions.value = []
|
||||||
|
equipment.value = []
|
||||||
|
tickets.value = []
|
||||||
|
invoices.value = []
|
||||||
|
payments.value = []
|
||||||
|
comments.value = []
|
||||||
|
contact.value = null
|
||||||
|
modalOpen.value = false
|
||||||
|
ticketsExpanded.value = false
|
||||||
|
invoicesExpanded.value = false
|
||||||
|
paymentsExpanded.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadLocations (custFilter) {
|
||||||
|
return listDocs('Service Location', {
|
||||||
|
filters: custFilter,
|
||||||
|
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',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSubscriptions (custFilter) {
|
||||||
|
return listDocs('Service Subscription', {
|
||||||
|
filters: custFilter,
|
||||||
|
fields: ['name', 'status', 'start_date', 'end_date', 'service_location',
|
||||||
|
'monthly_price', 'plan_name', 'service_category', 'billing_cycle',
|
||||||
|
'speed_down', 'speed_up', 'cancellation_date', 'cancellation_reason', 'notes'],
|
||||||
|
limit: 200, orderBy: 'start_date desc',
|
||||||
|
}).then(subs => subs.map(s => ({
|
||||||
|
...s,
|
||||||
|
actual_price: s.monthly_price,
|
||||||
|
custom_description: s.plan_name,
|
||||||
|
item_name: s.plan_name,
|
||||||
|
item_code: s.name,
|
||||||
|
item_group: s.service_category || '',
|
||||||
|
billing_frequency: s.billing_cycle === 'Annuel' ? 'A' : 'M',
|
||||||
|
cancel_at_period_end: 0,
|
||||||
|
cancelation_date: s.cancellation_date,
|
||||||
|
status: ({ Actif: 'Active', Annulé: 'Cancelled', Suspendu: 'Cancelled', 'En attente': 'Active' })[s.status] || s.status,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadEquipment (custFilter) {
|
||||||
|
return 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',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadTickets (custFilter) {
|
||||||
|
return 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: 10, orderBy: 'is_important desc, opening_date desc',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadInvoices (custFilter) {
|
||||||
|
return listDocs('Sales Invoice', {
|
||||||
|
filters: custFilter,
|
||||||
|
fields: ['name', 'posting_date', 'grand_total', 'outstanding_amount', 'status', 'is_return', 'return_against', 'creation'],
|
||||||
|
limit: 5, orderBy: 'posting_date desc, name desc',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPayments (id) {
|
||||||
|
return listDocs('Payment Entry', {
|
||||||
|
filters: { party_type: 'Customer', party: id },
|
||||||
|
fields: ['name', 'posting_date', 'paid_amount', 'mode_of_payment', 'reference_no'],
|
||||||
|
limit: 5, orderBy: 'posting_date desc',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadComments (id) {
|
||||||
|
return listDocs('Comment', {
|
||||||
|
filters: { reference_doctype: 'Customer', reference_name: id, comment_type: 'Comment' },
|
||||||
|
fields: ['name', 'content', 'comment_by', 'creation'],
|
||||||
|
limit: 50, orderBy: 'creation desc',
|
||||||
|
}).catch(() => [])
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadBalance (id) {
|
||||||
|
return authFetch(BASE_URL + '/api/method/customer_balance?customer=' + encodeURIComponent(id)).catch(() => null)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCustomer (id) {
|
||||||
|
resetState()
|
||||||
|
try {
|
||||||
|
const custFilter = { customer: id }
|
||||||
|
const [cust, locs, subs, equip, tix, invs, pays, memos, balRes] = await Promise.all([
|
||||||
|
getDoc('Customer', id),
|
||||||
|
loadLocations(custFilter),
|
||||||
|
loadSubscriptions(custFilter),
|
||||||
|
loadEquipment(custFilter),
|
||||||
|
loadTickets(custFilter),
|
||||||
|
loadInvoices(custFilter),
|
||||||
|
loadPayments(id),
|
||||||
|
loadComments(id),
|
||||||
|
loadBalance(id),
|
||||||
|
])
|
||||||
|
|
||||||
|
for (const f of ['is_commercial', 'is_bad_payer', 'exclude_fees', 'ppa_enabled']) { cust[f] = !!cust[f] }
|
||||||
|
customer.value = cust
|
||||||
|
locations.value = locs
|
||||||
|
subscriptions.value = subs
|
||||||
|
invalidateAll()
|
||||||
|
equipment.value = equip
|
||||||
|
if (equip.length) {
|
||||||
|
fetchStatus(equip).catch(() => {})
|
||||||
|
for (const eq of equip) {
|
||||||
|
if (eq.serial_number) fetchOltStatus(eq.serial_number).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 = null
|
||||||
|
comments.value = memos
|
||||||
|
|
||||||
|
if (balRes?.ok) {
|
||||||
|
try { accountBalance.value = (await balRes.json()).message } catch {}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
customer.value = null
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAllTickets () {
|
||||||
|
if (ticketsExpanded.value || !customer.value) return
|
||||||
|
loadingMoreTickets.value = true
|
||||||
|
try {
|
||||||
|
tickets.value = await listDocs('Issue', {
|
||||||
|
filters: { customer: customer.value.name },
|
||||||
|
fields: ['name', 'subject', 'status', 'priority', 'opening_date', 'service_location', 'legacy_ticket_id', 'is_important', 'assigned_staff', 'opened_by_staff', 'issue_type'],
|
||||||
|
limit: 500, orderBy: 'is_important desc, opening_date desc',
|
||||||
|
})
|
||||||
|
ticketsExpanded.value = true
|
||||||
|
} catch {}
|
||||||
|
loadingMoreTickets.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAllInvoices () {
|
||||||
|
if (invoicesExpanded.value || !customer.value) return
|
||||||
|
loadingMoreInvoices.value = true
|
||||||
|
try {
|
||||||
|
invoices.value = await listDocs('Sales Invoice', {
|
||||||
|
filters: { customer: customer.value.name },
|
||||||
|
fields: ['name', 'posting_date', 'grand_total', 'outstanding_amount', 'status', 'is_return', 'return_against', 'creation'],
|
||||||
|
limit: 200, orderBy: 'posting_date desc, name desc',
|
||||||
|
})
|
||||||
|
invoicesExpanded.value = true
|
||||||
|
} catch {}
|
||||||
|
loadingMoreInvoices.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAllPayments () {
|
||||||
|
if (paymentsExpanded.value || !customer.value) return
|
||||||
|
loadingMorePayments.value = true
|
||||||
|
try {
|
||||||
|
payments.value = await listDocs('Payment Entry', {
|
||||||
|
filters: { party_type: 'Customer', party: customer.value.name },
|
||||||
|
fields: ['name', 'posting_date', 'paid_amount', 'mode_of_payment', 'reference_no'],
|
||||||
|
limit: 200, orderBy: 'posting_date desc',
|
||||||
|
})
|
||||||
|
paymentsExpanded.value = true
|
||||||
|
} catch {}
|
||||||
|
loadingMorePayments.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading, customer, contact, locations, subscriptions, tickets,
|
||||||
|
invoices, payments, comments, accountBalance,
|
||||||
|
loadingMoreTickets, loadingMoreInvoices, loadingMorePayments,
|
||||||
|
loadCustomer, loadAllTickets, loadAllInvoices, loadAllPayments,
|
||||||
|
}
|
||||||
|
}
|
||||||
137
apps/ops/src/composables/useEquipmentActions.js
Normal file
137
apps/ops/src/composables/useEquipmentActions.js
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { Notify } from 'quasar'
|
||||||
|
import { listDocs, createDoc, updateDoc } from 'src/api/erp'
|
||||||
|
import { useScanner } from 'src/composables/useScanner'
|
||||||
|
import { equipScanTypeMap, defaultNewEquip } from 'src/data/client-constants'
|
||||||
|
|
||||||
|
export function useEquipmentActions (customer, equipment) {
|
||||||
|
const scannerState = useScanner()
|
||||||
|
const addEquipOpen = ref(false)
|
||||||
|
const addEquipLoc = ref(null)
|
||||||
|
const addingEquip = ref(false)
|
||||||
|
const equipLookupResult = ref(null)
|
||||||
|
const equipLookingUp = ref(false)
|
||||||
|
const newEquip = ref(defaultNewEquip())
|
||||||
|
|
||||||
|
let lookupTimer = null
|
||||||
|
watch(() => newEquip.value.serial_number, (sn) => {
|
||||||
|
clearTimeout(lookupTimer)
|
||||||
|
equipLookupResult.value = null
|
||||||
|
if (!sn || sn.length < 3) return
|
||||||
|
lookupTimer = setTimeout(() => lookupSerial(sn), 500)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function lookupSerial (sn) {
|
||||||
|
equipLookingUp.value = true
|
||||||
|
try {
|
||||||
|
const results = await listDocs('Service Equipment', {
|
||||||
|
filters: { serial_number: sn },
|
||||||
|
fields: ['name', 'equipment_type', 'brand', 'model', 'serial_number', 'service_location', 'customer'],
|
||||||
|
limit: 1,
|
||||||
|
})
|
||||||
|
if (results.length) {
|
||||||
|
equipLookupResult.value = { found: true, equipment: results[0] }
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
finally { equipLookingUp.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddEquipment (loc) {
|
||||||
|
addEquipLoc.value = loc
|
||||||
|
newEquip.value = defaultNewEquip()
|
||||||
|
equipLookupResult.value = null
|
||||||
|
scannerState.clearBarcodes()
|
||||||
|
addEquipOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAddEquipment () {
|
||||||
|
addEquipOpen.value = false
|
||||||
|
addEquipLoc.value = null
|
||||||
|
scannerState.clearBarcodes()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onScanPhoto (e) {
|
||||||
|
const file = e.target?.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
const result = await scannerState.scanEquipmentLabel(file)
|
||||||
|
if (result) {
|
||||||
|
if (result.serial_number && !newEquip.value.serial_number) newEquip.value.serial_number = result.serial_number
|
||||||
|
if (result.brand) newEquip.value.brand = result.brand
|
||||||
|
if (result.model) newEquip.value.model = result.model
|
||||||
|
if (result.mac_address) newEquip.value.mac_address = result.mac_address
|
||||||
|
if (result.ip_address) newEquip.value.ip_address = result.ip_address
|
||||||
|
if (result.equipment_type) {
|
||||||
|
const mapped = equipScanTypeMap[result.equipment_type.toLowerCase()]
|
||||||
|
if (mapped) newEquip.value.equipment_type = mapped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.target.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyScannedCode (code) {
|
||||||
|
newEquip.value.serial_number = code
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createEquipment () {
|
||||||
|
addingEquip.value = true
|
||||||
|
try {
|
||||||
|
const doc = await createDoc('Service Equipment', {
|
||||||
|
...newEquip.value,
|
||||||
|
customer: customer.value.name,
|
||||||
|
service_location: addEquipLoc.value.name,
|
||||||
|
})
|
||||||
|
equipment.value.push({
|
||||||
|
name: doc.name,
|
||||||
|
equipment_type: newEquip.value.equipment_type,
|
||||||
|
brand: newEquip.value.brand,
|
||||||
|
model: newEquip.value.model,
|
||||||
|
serial_number: newEquip.value.serial_number,
|
||||||
|
mac_address: newEquip.value.mac_address,
|
||||||
|
ip_address: newEquip.value.ip_address,
|
||||||
|
status: newEquip.value.status,
|
||||||
|
service_location: addEquipLoc.value.name,
|
||||||
|
})
|
||||||
|
Notify.create({ type: 'positive', message: `Equipement ${doc.name} cree`, timeout: 2000 })
|
||||||
|
closeAddEquipment()
|
||||||
|
} catch (e) {
|
||||||
|
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
|
||||||
|
} finally {
|
||||||
|
addingEquip.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function linkExistingEquipment () {
|
||||||
|
if (!equipLookupResult.value?.found) return
|
||||||
|
addingEquip.value = true
|
||||||
|
try {
|
||||||
|
const existing = equipLookupResult.value.equipment
|
||||||
|
await updateDoc('Service Equipment', existing.name, {
|
||||||
|
customer: customer.value.name,
|
||||||
|
service_location: addEquipLoc.value.name,
|
||||||
|
})
|
||||||
|
const idx = equipment.value.findIndex(e => e.name === existing.name)
|
||||||
|
if (idx >= 0) {
|
||||||
|
equipment.value[idx].service_location = addEquipLoc.value.name
|
||||||
|
} else {
|
||||||
|
equipment.value.push({
|
||||||
|
...existing,
|
||||||
|
service_location: addEquipLoc.value.name,
|
||||||
|
customer: customer.value.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Notify.create({ type: 'positive', message: `Equipement ${existing.name} lie a ${addEquipLoc.value.address_line || addEquipLoc.value.location_name}`, timeout: 2000 })
|
||||||
|
closeAddEquipment()
|
||||||
|
} catch (e) {
|
||||||
|
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
|
||||||
|
} finally {
|
||||||
|
addingEquip.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
scannerState, addEquipOpen, addEquipLoc, addingEquip,
|
||||||
|
equipLookupResult, equipLookingUp, newEquip,
|
||||||
|
openAddEquipment, closeAddEquipment, onScanPhoto,
|
||||||
|
applyScannedCode, createEquipment, linkExistingEquipment,
|
||||||
|
}
|
||||||
|
}
|
||||||
37
apps/ops/src/composables/useLegacySync.js
Normal file
37
apps/ops/src/composables/useLegacySync.js
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { Notify } from 'quasar'
|
||||||
|
import { usePermissions } from './usePermissions'
|
||||||
|
|
||||||
|
export function useLegacySync ({ loadPerms, loadGroupMembers, selectedGroup } = {}) {
|
||||||
|
const { HUB_URL } = usePermissions()
|
||||||
|
|
||||||
|
const legacySyncing = ref(false)
|
||||||
|
const showSyncDialog = ref(false)
|
||||||
|
const syncResult = ref(null)
|
||||||
|
|
||||||
|
async function syncLegacy (dryRun = true) {
|
||||||
|
legacySyncing.value = true
|
||||||
|
try {
|
||||||
|
const res = await fetch(HUB_URL + '/auth/sync-legacy', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ dry_run: dryRun }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Sync failed')
|
||||||
|
syncResult.value = data
|
||||||
|
showSyncDialog.value = true
|
||||||
|
if (!dryRun && data.summary.to_sync > 0) {
|
||||||
|
Notify.create({ type: 'positive', message: `${data.summary.to_sync} utilisateurs synchronises`, timeout: 3000 })
|
||||||
|
if (loadPerms) await loadPerms()
|
||||||
|
if (selectedGroup?.value && loadGroupMembers) await loadGroupMembers(selectedGroup.value)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Notify.create({ type: 'negative', message: 'Erreur sync legacy: ' + e.message, timeout: 4000 })
|
||||||
|
} finally {
|
||||||
|
legacySyncing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { legacySyncing, showSyncDialog, syncResult, syncLegacy }
|
||||||
|
}
|
||||||
123
apps/ops/src/composables/usePermissionMatrix.js
Normal file
123
apps/ops/src/composables/usePermissionMatrix.js
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
import { ref, reactive, computed } from 'vue'
|
||||||
|
import { Notify } from 'quasar'
|
||||||
|
import { usePermissions } from './usePermissions'
|
||||||
|
|
||||||
|
export function usePermissionMatrix () {
|
||||||
|
const { HUB_URL } = usePermissions()
|
||||||
|
|
||||||
|
const permTab = ref('users')
|
||||||
|
const permLoading = ref(false)
|
||||||
|
const permSaving = ref(false)
|
||||||
|
const permDirty = ref(false)
|
||||||
|
const permGroups = ref([])
|
||||||
|
const permCapabilities = ref([])
|
||||||
|
const permMatrix = reactive({})
|
||||||
|
|
||||||
|
const allGroupNames = computed(() => permGroups.value.map(g => g.name))
|
||||||
|
const permCategories = computed(() => [...new Set(permCapabilities.value.map(c => c.category))])
|
||||||
|
const permCapsByCategory = computed(() => {
|
||||||
|
const map = {}
|
||||||
|
for (const c of permCapabilities.value) {
|
||||||
|
if (!map[c.category]) map[c.category] = []
|
||||||
|
map[c.category].push(c)
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadPerms () {
|
||||||
|
permLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await fetch(HUB_URL + '/auth/groups')
|
||||||
|
const data = await res.json()
|
||||||
|
permGroups.value = data.groups
|
||||||
|
permCapabilities.value = data.capabilities
|
||||||
|
for (const g of data.groups) {
|
||||||
|
permMatrix[g.name] = {}
|
||||||
|
for (const cap of data.capabilities) {
|
||||||
|
permMatrix[g.name][cap.key] = g.permissions[cap.key] === true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Notify.create({ type: 'negative', message: 'Erreur chargement permissions: ' + e.message })
|
||||||
|
} finally {
|
||||||
|
permLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAllPerms () {
|
||||||
|
permSaving.value = true
|
||||||
|
try {
|
||||||
|
for (const g of permGroups.value) {
|
||||||
|
await fetch(HUB_URL + '/auth/groups/' + encodeURIComponent(g.name) + '/permissions', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(permMatrix[g.name]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
permDirty.value = false
|
||||||
|
Notify.create({ type: 'positive', message: 'Permissions sauvegardees', timeout: 2000 })
|
||||||
|
} catch (e) {
|
||||||
|
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
|
||||||
|
} finally {
|
||||||
|
permSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveGroupPerms (groupName) {
|
||||||
|
permSaving.value = true
|
||||||
|
try {
|
||||||
|
await fetch(HUB_URL + '/auth/groups/' + encodeURIComponent(groupName) + '/permissions', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(permMatrix[groupName]),
|
||||||
|
})
|
||||||
|
permDirty.value = false
|
||||||
|
Notify.create({ type: 'positive', message: `Permissions "${groupName}" sauvegardees`, timeout: 2000 })
|
||||||
|
} catch (e) {
|
||||||
|
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
|
||||||
|
} finally {
|
||||||
|
permSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function countGroupPerms (g) {
|
||||||
|
return Object.values(permMatrix[g.name] || {}).filter(Boolean).length
|
||||||
|
}
|
||||||
|
|
||||||
|
function effectivePerm (user, capKey) {
|
||||||
|
if (typeof user.overrides[capKey] === 'boolean') return user.overrides[capKey]
|
||||||
|
for (const gName of user.groups) {
|
||||||
|
if (permMatrix[gName]?.[capKey]) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setUserOverride (user, capKey, value) {
|
||||||
|
if (value === true || value === false) {
|
||||||
|
user.overrides[capKey] = value
|
||||||
|
} else {
|
||||||
|
delete user.overrides[capKey]
|
||||||
|
}
|
||||||
|
const clean = {}
|
||||||
|
for (const [k, v] of Object.entries(user.overrides)) {
|
||||||
|
if (typeof v === 'boolean') clean[k] = v
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await fetch(HUB_URL + '/auth/users/' + encodeURIComponent(user.email) + '/overrides', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(clean),
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
Notify.create({ type: 'negative', message: 'Erreur override: ' + e.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
permTab, permLoading, permSaving, permDirty,
|
||||||
|
permGroups, permCapabilities, permMatrix,
|
||||||
|
allGroupNames, permCategories, permCapsByCategory,
|
||||||
|
loadPerms, saveAllPerms, saveGroupPerms, countGroupPerms,
|
||||||
|
effectivePerm, setUserOverride,
|
||||||
|
}
|
||||||
|
}
|
||||||
179
apps/ops/src/composables/useUserGroups.js
Normal file
179
apps/ops/src/composables/useUserGroups.js
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
import { ref, reactive } from 'vue'
|
||||||
|
import { Notify } from 'quasar'
|
||||||
|
import { usePermissions } from './usePermissions'
|
||||||
|
|
||||||
|
export function useUserGroups ({ permGroups, loadGroupMembers: externalLoadGroupMembers } = {}) {
|
||||||
|
const { HUB_URL } = usePermissions()
|
||||||
|
|
||||||
|
// Users tab
|
||||||
|
const userSearch = ref('')
|
||||||
|
const userResults = ref([])
|
||||||
|
const userSearchLoading = ref(false)
|
||||||
|
const userSearchDone = ref(false)
|
||||||
|
const selectedUser = ref(null)
|
||||||
|
const savingGroups = ref(false)
|
||||||
|
|
||||||
|
// Groups tab
|
||||||
|
const selectedGroup = ref(null)
|
||||||
|
const groupMembers = ref(null)
|
||||||
|
const groupMembersLoading = ref(false)
|
||||||
|
const addMemberSearch = ref('')
|
||||||
|
const memberSearchResults = ref([])
|
||||||
|
const memberSearchLoading = ref(false)
|
||||||
|
|
||||||
|
let searchTimer = null
|
||||||
|
function debouncedSearchUsers () {
|
||||||
|
clearTimeout(searchTimer)
|
||||||
|
searchTimer = setTimeout(() => searchUsers(), 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchUsers () {
|
||||||
|
const q = (userSearch.value || '').trim()
|
||||||
|
userSearchLoading.value = true
|
||||||
|
userSearchDone.value = false
|
||||||
|
try {
|
||||||
|
const url = q.length >= 1
|
||||||
|
? HUB_URL + '/auth/users?search=' + encodeURIComponent(q)
|
||||||
|
: HUB_URL + '/auth/users?page=1'
|
||||||
|
const res = await fetch(url)
|
||||||
|
const data = await res.json()
|
||||||
|
userResults.value = data.users.map(u => ({
|
||||||
|
...u,
|
||||||
|
overrides: reactive({ ...u.overrides }),
|
||||||
|
}))
|
||||||
|
userSearchDone.value = true
|
||||||
|
} catch (e) {
|
||||||
|
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
|
||||||
|
} finally {
|
||||||
|
userSearchLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectUser (u) {
|
||||||
|
selectedUser.value = selectedUser.value?.pk === u.pk ? null : u
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleUserGroup (user, groupName) {
|
||||||
|
const idx = user.groups.indexOf(groupName)
|
||||||
|
if (idx >= 0) {
|
||||||
|
user.groups.splice(idx, 1)
|
||||||
|
} else {
|
||||||
|
user.groups.push(groupName)
|
||||||
|
}
|
||||||
|
savingGroups.value = true
|
||||||
|
try {
|
||||||
|
await fetch(HUB_URL + '/auth/users/' + encodeURIComponent(user.email) + '/groups', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ groups: user.groups }),
|
||||||
|
})
|
||||||
|
Notify.create({ type: 'positive', message: `Groupes mis a jour pour ${user.name || user.email}`, timeout: 1500 })
|
||||||
|
} catch (e) {
|
||||||
|
if (idx >= 0) user.groups.push(groupName)
|
||||||
|
else user.groups.splice(user.groups.indexOf(groupName), 1)
|
||||||
|
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
|
||||||
|
} finally {
|
||||||
|
savingGroups.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectGroup (name) {
|
||||||
|
if (selectedGroup.value === name) {
|
||||||
|
selectedGroup.value = null
|
||||||
|
groupMembers.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
selectedGroup.value = name
|
||||||
|
addMemberSearch.value = ''
|
||||||
|
memberSearchResults.value = []
|
||||||
|
await loadGroupMembers(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGroupMembers (name) {
|
||||||
|
groupMembersLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await fetch(HUB_URL + '/auth/users?group=' + encodeURIComponent(name))
|
||||||
|
const data = await res.json()
|
||||||
|
groupMembers.value = data.users
|
||||||
|
} catch (e) {
|
||||||
|
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
|
||||||
|
} finally {
|
||||||
|
groupMembersLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeFromGroup (member, groupName) {
|
||||||
|
const newGroups = member.groups.filter(g => g !== groupName)
|
||||||
|
try {
|
||||||
|
await fetch(HUB_URL + '/auth/users/' + encodeURIComponent(member.email) + '/groups', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ groups: newGroups }),
|
||||||
|
})
|
||||||
|
groupMembers.value = groupMembers.value.filter(m => m.pk !== member.pk)
|
||||||
|
if (permGroups) {
|
||||||
|
const g = permGroups.value.find(g => g.name === groupName)
|
||||||
|
if (g) g.num_users = Math.max(0, g.num_users - 1)
|
||||||
|
}
|
||||||
|
Notify.create({ type: 'positive', message: `${member.name || member.email} retire de ${groupName}`, timeout: 1500 })
|
||||||
|
} catch (e) {
|
||||||
|
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let memberTimer = null
|
||||||
|
function debouncedMemberSearch () {
|
||||||
|
clearTimeout(memberTimer)
|
||||||
|
const q = (addMemberSearch.value || '').trim()
|
||||||
|
if (q.length < 2) { memberSearchResults.value = []; return }
|
||||||
|
memberTimer = setTimeout(() => searchMembersToAdd(q), 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchMembersToAdd (q) {
|
||||||
|
memberSearchLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await fetch(HUB_URL + '/auth/users?search=' + encodeURIComponent(q))
|
||||||
|
const data = await res.json()
|
||||||
|
const existingPks = new Set((groupMembers.value || []).map(m => m.pk))
|
||||||
|
memberSearchResults.value = data.users.filter(u => !existingPks.has(u.pk))
|
||||||
|
} catch (e) {
|
||||||
|
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
|
||||||
|
} finally {
|
||||||
|
memberSearchLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addUserToCurrentGroup (user) {
|
||||||
|
if (!selectedGroup.value) return
|
||||||
|
if (user.groups.includes(selectedGroup.value)) return
|
||||||
|
memberSearchResults.value = []
|
||||||
|
addMemberSearch.value = ''
|
||||||
|
try {
|
||||||
|
const newGroups = [...new Set([...user.groups, selectedGroup.value])]
|
||||||
|
await fetch(HUB_URL + '/auth/users/' + encodeURIComponent(user.email) + '/groups', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ groups: newGroups }),
|
||||||
|
})
|
||||||
|
user.groups = newGroups
|
||||||
|
groupMembers.value.push(user)
|
||||||
|
if (permGroups) {
|
||||||
|
const g = permGroups.value.find(g => g.name === selectedGroup.value)
|
||||||
|
if (g) g.num_users++
|
||||||
|
}
|
||||||
|
Notify.create({ type: 'positive', message: `${user.name || user.email} ajoute a ${selectedGroup.value}`, timeout: 1500 })
|
||||||
|
} catch (e) {
|
||||||
|
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
userSearch, userResults, userSearchLoading, userSearchDone,
|
||||||
|
selectedUser, savingGroups,
|
||||||
|
selectedGroup, groupMembers, groupMembersLoading,
|
||||||
|
addMemberSearch, memberSearchResults, memberSearchLoading,
|
||||||
|
debouncedSearchUsers, searchUsers, selectUser, toggleUserGroup,
|
||||||
|
selectGroup, loadGroupMembers, removeFromGroup,
|
||||||
|
debouncedMemberSearch, searchMembersToAdd, addUserToCurrentGroup,
|
||||||
|
}
|
||||||
|
}
|
||||||
97
apps/ops/src/composables/useWizardCatalog.js
Normal file
97
apps/ops/src/composables/useWizardCatalog.js
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { HUB_URL, CATALOG_CATEGORIES } from 'src/data/wizard-constants'
|
||||||
|
|
||||||
|
const FALLBACK_CATALOG = [
|
||||||
|
{ item_code: 'INT-100', item_name: 'Internet 100 Mbps', rate: 49.99, billing_type: 'Mensuel', service_category: 'Internet', requires_visit: true, project_template_id: 'fiber_install' },
|
||||||
|
{ item_code: 'INT-300', item_name: 'Internet 300 Mbps', rate: 69.99, billing_type: 'Mensuel', service_category: 'Internet', requires_visit: true, project_template_id: 'fiber_install' },
|
||||||
|
{ item_code: 'INT-500', item_name: 'Internet 500 Mbps', rate: 89.99, billing_type: 'Mensuel', service_category: 'Internet', requires_visit: true, project_template_id: 'fiber_install' },
|
||||||
|
{ item_code: 'INT-1000', item_name: 'Internet 1 Gbps', rate: 109.99, billing_type: 'Mensuel', service_category: 'Internet', requires_visit: true, project_template_id: 'fiber_install' },
|
||||||
|
{ item_code: 'TEL-RES', item_name: 'Téléphonie résidentielle', rate: 19.99, billing_type: 'Mensuel', service_category: 'Téléphonie', requires_visit: true, project_template_id: 'phone_service' },
|
||||||
|
{ item_code: 'TEL-ILL', item_name: 'Téléphonie illimitée CA/US', rate: 29.99, billing_type: 'Mensuel', service_category: 'Téléphonie', requires_visit: true, project_template_id: 'phone_service' },
|
||||||
|
{ item_code: 'BDL-DUO-300', item_name: 'Duo Internet 300 + Téléphonie', rate: 79.99, billing_type: 'Mensuel', service_category: 'Bundle', requires_visit: true, project_template_id: 'fiber_install', is_bundle: true },
|
||||||
|
{ item_code: 'BDL-DUO-500', item_name: 'Duo Internet 500 + Téléphonie', rate: 99.99, billing_type: 'Mensuel', service_category: 'Bundle', requires_visit: true, project_template_id: 'fiber_install', is_bundle: true },
|
||||||
|
{ item_code: 'BDL-TRIO', item_name: 'Trio Internet 500 + Tél + IPTV', rate: 119.99, billing_type: 'Mensuel', service_category: 'Bundle', requires_visit: true, project_template_id: 'fiber_install', is_bundle: true },
|
||||||
|
{ item_code: 'EQP-ROUTER', item_name: 'Routeur WiFi 6', rate: 149.99, billing_type: 'Unique', service_category: 'Équipement' },
|
||||||
|
{ item_code: 'FEE-INSTALL', item_name: 'Frais d\'installation', rate: 75.00, billing_type: 'Unique', service_category: 'Frais' },
|
||||||
|
{ item_code: 'FEE-ACTIV', item_name: 'Frais d\'activation', rate: 25.00, billing_type: 'Unique', service_category: 'Frais' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function useWizardCatalog ({ orderItems, wizardSteps, templates, templateLoadedFor }) {
|
||||||
|
const catalogProducts = ref([])
|
||||||
|
const catalogLoading = ref(false)
|
||||||
|
const catalogFilter = ref('Tous')
|
||||||
|
|
||||||
|
const filteredCatalog = computed(() => {
|
||||||
|
if (catalogFilter.value === 'Tous') return catalogProducts.value
|
||||||
|
return catalogProducts.value.filter(p => p.service_category === catalogFilter.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadCatalog () {
|
||||||
|
if (catalogProducts.value.length) return
|
||||||
|
catalogLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${HUB_URL}/api/catalog`)
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.ok && data.items) {
|
||||||
|
catalogProducts.value = data.items
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[Wizard] Catalog load failed:', e.message)
|
||||||
|
catalogProducts.value = [...FALLBACK_CATALOG]
|
||||||
|
} finally {
|
||||||
|
catalogLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCatalogItem (product) {
|
||||||
|
orderItems.value.push({
|
||||||
|
item_code: product.item_code,
|
||||||
|
item_name: product.item_name,
|
||||||
|
qty: 1,
|
||||||
|
rate: product.rate,
|
||||||
|
billing: product.billing_type === 'Mensuel' || product.billing_type === 'Annuel' ? 'recurring' : 'onetime',
|
||||||
|
billing_interval: product.billing_type === 'Annuel' ? 'Year' : 'Month',
|
||||||
|
contract_months: 12,
|
||||||
|
project_template_id: product.project_template_id || '',
|
||||||
|
requires_visit: product.requires_visit || false,
|
||||||
|
})
|
||||||
|
if (product.project_template_id && !templateLoadedFor.value.has(product.project_template_id)) {
|
||||||
|
loadTemplateFromItem({ project_template_id: product.project_template_id })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadTemplateFromItem (item) {
|
||||||
|
if (!item.project_template_id) return
|
||||||
|
const tpl = templates.find(t => t.id === item.project_template_id)
|
||||||
|
if (tpl && !templateLoadedFor.value.has(item.project_template_id)) {
|
||||||
|
const existingSubjects = new Set(wizardSteps.value.map(s => s.subject))
|
||||||
|
for (const step of tpl.steps) {
|
||||||
|
if (!existingSubjects.has(step.subject)) {
|
||||||
|
wizardSteps.value.push({
|
||||||
|
subject: step.subject,
|
||||||
|
job_type: step.job_type,
|
||||||
|
priority: step.priority,
|
||||||
|
duration_h: step.duration_h,
|
||||||
|
assigned_group: step.assigned_group || '',
|
||||||
|
depends_on_step: step.depends_on_step,
|
||||||
|
scheduled_date: '',
|
||||||
|
on_open_webhook: step.on_open_webhook || '',
|
||||||
|
on_close_webhook: step.on_close_webhook || '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templateLoadedFor.value = new Set([...templateLoadedFor.value, item.project_template_id])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
catalogProducts,
|
||||||
|
catalogLoading,
|
||||||
|
catalogFilter,
|
||||||
|
catalogCategories: CATALOG_CATEGORIES,
|
||||||
|
filteredCatalog,
|
||||||
|
loadCatalog,
|
||||||
|
addCatalogItem,
|
||||||
|
loadTemplateFromItem,
|
||||||
|
}
|
||||||
|
}
|
||||||
287
apps/ops/src/composables/useWizardPublish.js
Normal file
287
apps/ops/src/composables/useWizardPublish.js
Normal file
|
|
@ -0,0 +1,287 @@
|
||||||
|
import { Notify } from 'quasar'
|
||||||
|
import { createJob } from 'src/api/dispatch'
|
||||||
|
import { getDoc, createDoc } from 'src/api/erp'
|
||||||
|
import { HUB_URL } from 'src/data/wizard-constants'
|
||||||
|
|
||||||
|
export function useWizardPublish ({ props, emit, state }) {
|
||||||
|
const {
|
||||||
|
publishing, wizardSteps, orderItems, orderMode,
|
||||||
|
contractNotes, requireAcceptance, acceptanceMethod,
|
||||||
|
clientPhone, clientEmail,
|
||||||
|
publishedDocName, publishedDocType, publishedDone,
|
||||||
|
pendingAcceptance, acceptanceLinkUrl, acceptanceLinkSent,
|
||||||
|
acceptanceSentVia, publishedJobCount,
|
||||||
|
sendTo, sendChannel,
|
||||||
|
} = state
|
||||||
|
|
||||||
|
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 customer = props.issue?.customer || ''
|
||||||
|
const today = new Date().toISOString().split('T')[0]
|
||||||
|
const onetimeItems = orderItems.value.filter(i => i.billing === 'onetime' && i.item_name)
|
||||||
|
const recurringItems = orderItems.value.filter(i => i.billing === 'recurring' && i.item_name)
|
||||||
|
let orderDocName = ''
|
||||||
|
let orderDocType = ''
|
||||||
|
const isQuotation = orderMode.value === 'quotation'
|
||||||
|
const needsAcceptance = requireAcceptance.value && isQuotation
|
||||||
|
const hasPhone = !!clientPhone.value
|
||||||
|
const hasEmail = !!clientEmail.value
|
||||||
|
|
||||||
|
const wizardContext = {
|
||||||
|
issue: props.issue?.name || '',
|
||||||
|
customer,
|
||||||
|
address,
|
||||||
|
service_location: props.issue?.service_location || '',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create financial document
|
||||||
|
if (orderItems.value.some(i => i.item_name)) {
|
||||||
|
const allItems = [...onetimeItems, ...recurringItems].map(i => ({
|
||||||
|
item_name: i.item_name,
|
||||||
|
item_code: i.item_code || i.item_name,
|
||||||
|
qty: i.qty,
|
||||||
|
rate: i.rate,
|
||||||
|
description: i.billing === 'recurring'
|
||||||
|
? `${i.item_name} — ${i.rate}$/mois × ${i.contract_months || 12} mois`
|
||||||
|
: i.item_name,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const baseDoc = {
|
||||||
|
customer,
|
||||||
|
company: 'TARGO',
|
||||||
|
currency: 'CAD',
|
||||||
|
selling_price_list: 'Standard Selling',
|
||||||
|
items: allItems,
|
||||||
|
tc_name: contractNotes.value ? '' : undefined,
|
||||||
|
terms: contractNotes.value || undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isQuotation) {
|
||||||
|
const quotPayload = {
|
||||||
|
...baseDoc,
|
||||||
|
quotation_to: 'Customer',
|
||||||
|
party_name: customer,
|
||||||
|
valid_till: new Date(Date.now() + 30 * 86400000).toISOString().split('T')[0],
|
||||||
|
}
|
||||||
|
if (needsAcceptance) {
|
||||||
|
quotPayload.wizard_steps = JSON.stringify(wizardSteps.value.map((s, i) => ({
|
||||||
|
subject: s.subject,
|
||||||
|
job_type: s.job_type || 'Autre',
|
||||||
|
priority: s.priority || 'medium',
|
||||||
|
duration_h: s.duration_h || 1,
|
||||||
|
assigned_group: s.assigned_group || '',
|
||||||
|
depends_on_step: s.depends_on_step,
|
||||||
|
scheduled_date: s.scheduled_date || '',
|
||||||
|
on_open_webhook: s.on_open_webhook || '',
|
||||||
|
on_close_webhook: s.on_close_webhook || '',
|
||||||
|
step_order: i + 1,
|
||||||
|
})))
|
||||||
|
quotPayload.wizard_context = JSON.stringify(wizardContext)
|
||||||
|
}
|
||||||
|
const quot = await createDoc('Quotation', quotPayload)
|
||||||
|
orderDocName = quot.name
|
||||||
|
orderDocType = 'Quotation'
|
||||||
|
} else if (orderMode.value === 'prepaid') {
|
||||||
|
const inv = await createDoc('Sales Invoice', {
|
||||||
|
...baseDoc, posting_date: today, due_date: today, is_pos: 0,
|
||||||
|
})
|
||||||
|
orderDocName = inv.name
|
||||||
|
orderDocType = 'Sales Invoice'
|
||||||
|
} else {
|
||||||
|
const so = await createDoc('Sales Order', {
|
||||||
|
...baseDoc, transaction_date: today, delivery_date: today,
|
||||||
|
})
|
||||||
|
orderDocName = so.name
|
||||||
|
orderDocType = 'Sales Order'
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[ProjectWizard] Order doc creation failed:', e.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create Subscriptions for recurring items (unless deferred)
|
||||||
|
if (!needsAcceptance) {
|
||||||
|
for (const item of recurringItems) {
|
||||||
|
try {
|
||||||
|
let planName = null
|
||||||
|
try {
|
||||||
|
const plans = await createDoc('Subscription Plan', {
|
||||||
|
plan_name: item.item_name,
|
||||||
|
item: item.item_code || item.item_name,
|
||||||
|
currency: 'CAD',
|
||||||
|
price_determination: 'Fixed Rate',
|
||||||
|
cost: item.rate,
|
||||||
|
billing_interval: item.billing_interval || 'Month',
|
||||||
|
billing_interval_count: 1,
|
||||||
|
})
|
||||||
|
planName = plans.name
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
const existing = await getDoc('Subscription Plan', item.item_code || item.item_name)
|
||||||
|
planName = existing.name
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
await createDoc('Subscription', {
|
||||||
|
party_type: 'Customer',
|
||||||
|
party: customer,
|
||||||
|
company: 'TARGO',
|
||||||
|
status: 'Active',
|
||||||
|
start_date: today,
|
||||||
|
generate_invoice_at: 'Beginning of the current subscription period',
|
||||||
|
days_until_due: 30,
|
||||||
|
follow_calendar_months: 1,
|
||||||
|
sales_tax_template: 'QC TPS 5% + TVQ 9.975% - T',
|
||||||
|
custom_description: `${item.item_name} — ${item.rate}$/mois`,
|
||||||
|
plans: planName ? [{ plan: planName, qty: item.qty }] : [],
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[ProjectWizard] Subscription creation failed for', item.item_name, e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create Dispatch Jobs (tasks) — ONLY if NOT waiting for acceptance
|
||||||
|
const createdJobs = []
|
||||||
|
if (!needsAcceptance) {
|
||||||
|
for (let i = 0; i < wizardSteps.value.length; i++) {
|
||||||
|
const step = wizardSteps.value[i]
|
||||||
|
const ticketId = 'DJ-' + Date.now().toString(36).toUpperCase() + '-' + i
|
||||||
|
|
||||||
|
let dependsOn = ''
|
||||||
|
if (step.depends_on_step != null) {
|
||||||
|
const depJob = createdJobs[step.depends_on_step]
|
||||||
|
if (depJob) dependsOn = depJob.name
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentJob = i > 0 && createdJobs[0] ? createdJobs[0].name : ''
|
||||||
|
|
||||||
|
const newJob = await createJob({
|
||||||
|
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,
|
||||||
|
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}` : '',
|
||||||
|
orderDocName ? `${orderDocType}: ${orderDocName}` : '',
|
||||||
|
].filter(Boolean).join(' | '),
|
||||||
|
scheduled_date: step.scheduled_date || '',
|
||||||
|
})
|
||||||
|
createdJobs.push(newJob)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate acceptance link for quotations
|
||||||
|
if (isQuotation && orderDocName) {
|
||||||
|
try {
|
||||||
|
const acceptRes = await fetch(`${HUB_URL}/accept/generate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
quotation: orderDocName,
|
||||||
|
customer,
|
||||||
|
ttl_hours: 168,
|
||||||
|
send_sms: hasPhone,
|
||||||
|
phone: clientPhone.value || '',
|
||||||
|
send_email: hasEmail,
|
||||||
|
email: clientEmail.value || '',
|
||||||
|
use_docuseal: acceptanceMethod.value === 'docuseal',
|
||||||
|
attach_pdf: true,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const acceptData = await acceptRes.json()
|
||||||
|
if (acceptData.ok) {
|
||||||
|
acceptanceLinkUrl.value = acceptData.link || acceptData.sign_url || ''
|
||||||
|
const viaParts = []
|
||||||
|
if (acceptData.sms_sent) viaParts.push('SMS')
|
||||||
|
if (acceptData.email_sent) viaParts.push('courriel')
|
||||||
|
acceptanceSentVia.value = viaParts.length ? ` par ${viaParts.join(' et ')}` : ''
|
||||||
|
acceptanceLinkSent.value = viaParts.length > 0
|
||||||
|
|
||||||
|
Notify.create({
|
||||||
|
type: 'info',
|
||||||
|
message: acceptData.method === 'docuseal'
|
||||||
|
? 'Lien de signature DocuSeal envoyé au client'
|
||||||
|
: acceptanceLinkSent.value
|
||||||
|
? `Lien d'acceptation envoyé${acceptanceSentVia.value}`
|
||||||
|
: 'Lien d\'acceptation généré — copiez-le pour l\'envoyer au client',
|
||||||
|
timeout: 6000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[ProjectWizard] Acceptance link failed:', e.message)
|
||||||
|
acceptanceLinkUrl.value = `${HUB_URL}/accept/doc-pdf/Quotation/${encodeURIComponent(orderDocName)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build notification
|
||||||
|
const parts = []
|
||||||
|
if (createdJobs.length) parts.push(`${createdJobs.length} tâches créées`)
|
||||||
|
if (needsAcceptance) parts.push('en attente d\'acceptation')
|
||||||
|
if (orderDocName) parts.push(`${orderDocType} ${orderDocName}`)
|
||||||
|
if (!needsAcceptance && recurringItems.length) parts.push(`${recurringItems.length} abonnement(s)`)
|
||||||
|
|
||||||
|
Notify.create({
|
||||||
|
type: needsAcceptance ? 'warning' : 'positive',
|
||||||
|
message: parts.join(' · '),
|
||||||
|
timeout: 5000,
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const job of createdJobs) {
|
||||||
|
emit('created', job)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show success screen
|
||||||
|
if (orderDocName) {
|
||||||
|
publishedDocName.value = orderDocName
|
||||||
|
publishedDocType.value = orderDocType
|
||||||
|
publishedDone.value = true
|
||||||
|
pendingAcceptance.value = needsAcceptance
|
||||||
|
publishedJobCount.value = createdJobs.length
|
||||||
|
if (clientEmail.value) { sendTo.value = clientEmail.value; sendChannel.value = 'email' }
|
||||||
|
else if (clientPhone.value) { sendTo.value = clientPhone.value; sendChannel.value = 'sms' }
|
||||||
|
} else if (createdJobs.length) {
|
||||||
|
publishedDocName.value = createdJobs[0]?.name || ''
|
||||||
|
publishedDocType.value = 'Dispatch Job'
|
||||||
|
publishedDone.value = true
|
||||||
|
pendingAcceptance.value = false
|
||||||
|
publishedJobCount.value = createdJobs.length
|
||||||
|
} else {
|
||||||
|
state.cancel()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ProjectWizard] publish error:', err)
|
||||||
|
Notify.create({ type: 'negative', message: `Erreur: ${err.message || err}` })
|
||||||
|
} finally {
|
||||||
|
publishing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { publish }
|
||||||
|
}
|
||||||
10
apps/ops/src/config/dispatch.js
Normal file
10
apps/ops/src/config/dispatch.js
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
export const ROW_H = 68
|
||||||
|
|
||||||
|
export const RES_ICONS = {
|
||||||
|
'Véhicule': '🚛', 'Outil': '🔧', 'Salle': '🏢', 'Équipement': '📦',
|
||||||
|
'Nacelle': '🏗️', 'Grue': '🏗️', 'Fusionneuse': '🔧', 'OTDR': '📡',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HUB_SSE_URL = window.location.hostname === 'localhost'
|
||||||
|
? 'http://localhost:3300'
|
||||||
|
: 'https://msg.gigafibre.ca'
|
||||||
21
apps/ops/src/data/client-constants.js
Normal file
21
apps/ops/src/data/client-constants.js
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
export const locInlineFields = [
|
||||||
|
{ field: 'city', placeholder: 'Ville', prefix: null },
|
||||||
|
{ field: 'postal_code', placeholder: 'Code postal', prefix: ', ' },
|
||||||
|
{ field: 'location_name', placeholder: 'Nom du lieu', prefix: ' — ' },
|
||||||
|
{ field: 'contact_name', placeholder: 'Contact', prefix: ' \u00b7 Contact: ' },
|
||||||
|
{ field: 'contact_phone', placeholder: 'Téléphone', prefix: ' ' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const equipTypeOptions = ['ONT', 'Routeur', 'Modem', 'Décodeur TV', 'VoIP', 'Switch', 'AP', 'Amplificateur', 'Splitter', 'Autre']
|
||||||
|
|
||||||
|
export const equipStatusOptions = ['Actif', 'En inventaire', 'Défectueux', 'Retourné', 'Perdu']
|
||||||
|
|
||||||
|
export const equipScanTypeMap = { ont: 'ONT', onu: 'ONT', router: 'Router', modem: 'Modem', decoder: 'Décodeur', switch: 'Switch' }
|
||||||
|
|
||||||
|
export const phoneLabelMap = { cell_phone: 'Cell', tel_home: 'Maison', tel_office: 'Bureau' }
|
||||||
|
|
||||||
|
export const defaultSectionsOpen = { locations: true, tickets: true, invoices: false, payments: false, notes: false }
|
||||||
|
|
||||||
|
export const defaultNewEquip = () => ({
|
||||||
|
equipment_type: 'ONT', serial_number: '', brand: '', model: '', mac_address: '', ip_address: '', status: 'Actif',
|
||||||
|
})
|
||||||
41
apps/ops/src/data/wizard-constants.js
Normal file
41
apps/ops/src/data/wizard-constants.js
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
export const STEP_LABELS = ['Modèle', 'Étapes', 'Items / Devis', 'Publier']
|
||||||
|
|
||||||
|
export const JOB_TYPE_OPTIONS = [
|
||||||
|
{ 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' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const PRIORITY_OPTIONS = [
|
||||||
|
{ label: 'Basse', value: 'low' },
|
||||||
|
{ label: 'Moyenne', value: 'medium' },
|
||||||
|
{ label: 'Haute', value: 'high' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const ORDER_MODE_OPTIONS = [
|
||||||
|
{ label: 'Devis', value: 'quotation' },
|
||||||
|
{ label: 'Commande directe', value: 'direct' },
|
||||||
|
{ label: 'Pré-payée', value: 'prepaid' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const ACCEPTANCE_METHOD_OPTIONS = [
|
||||||
|
{ label: 'Lien simple (JWT)', value: 'jwt' },
|
||||||
|
{ label: 'Signature DocuSeal', value: 'docuseal' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const BILLING_INTERVAL_OPTIONS = [
|
||||||
|
{ label: 'Mensuel', value: 'Month' },
|
||||||
|
{ label: 'Annuel', value: 'Year' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const CATALOG_CATEGORIES = ['Tous', 'Internet', 'Téléphonie', 'Bundle', 'Équipement', 'Frais']
|
||||||
|
|
||||||
|
const CAT_ICON_MAP = { Internet: 'wifi', Téléphonie: 'phone', Bundle: 'inventory_2', Équipement: 'router', Frais: 'receipt', Autre: 'category' }
|
||||||
|
export function catIcon (cat) {
|
||||||
|
return CAT_ICON_MAP[cat] || 'category'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HUB_URL = window.location.hostname === 'localhost' ? 'http://localhost:3300' : 'https://msg.gigafibre.ca'
|
||||||
11
apps/ops/src/modules/dispatch/components/SbContextMenu.vue
Normal file
11
apps/ops/src/modules/dispatch/components/SbContextMenu.vue
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
pos: { type: Object, default: null },
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="pos" class="sb-ctx" :style="'left:'+pos.x+'px;top:'+pos.y+'px'" @click.stop="()=>{}">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
28
apps/ops/src/modules/dispatch/components/SbModal.vue
Normal file
28
apps/ops/src/modules/dispatch/components/SbModal.vue
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: Boolean, default: false },
|
||||||
|
wide: { type: Boolean, default: false },
|
||||||
|
overlayClass: { type: String, default: '' },
|
||||||
|
modalClass: { type: String, default: '' },
|
||||||
|
bodyStyle: { type: String, default: '' },
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
const close = () => emit('update:modelValue', false)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="modelValue" class="sb-overlay" :class="overlayClass" @click.self="close">
|
||||||
|
<div class="sb-modal" :class="[{ 'sb-modal-wide': wide }, modalClass]">
|
||||||
|
<div class="sb-modal-hdr">
|
||||||
|
<slot name="header" />
|
||||||
|
<button class="sb-rp-close" @click="close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="sb-modal-body" :style="bodyStyle">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
<div class="sb-modal-ftr">
|
||||||
|
<slot name="footer" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -8,13 +8,11 @@
|
||||||
<div class="row q-col-gutter-md">
|
<div class="row q-col-gutter-md">
|
||||||
<div class="col-12 col-lg-8">
|
<div class="col-12 col-lg-8">
|
||||||
|
|
||||||
<!-- SPLIT CANDIDATE: CustomerHeaderSection.vue -->
|
|
||||||
<CustomerHeader :customer="customer">
|
<CustomerHeader :customer="customer">
|
||||||
<template #contact><ContactCard :customer="customer" /></template>
|
<template #contact><ContactCard :customer="customer" /></template>
|
||||||
<template #info><CustomerInfoCard :customer="customer" /></template>
|
<template #info><CustomerInfoCard :customer="customer" /></template>
|
||||||
</CustomerHeader>
|
</CustomerHeader>
|
||||||
|
|
||||||
<!-- SPLIT CANDIDATE: LocationsSection.vue -->
|
|
||||||
<q-expansion-item v-model="sectionsOpen.locations" header-class="section-header" class="q-mb-sm">
|
<q-expansion-item v-model="sectionsOpen.locations" header-class="section-header" class="q-mb-sm">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="section-title" style="font-size:1rem;width:100%">
|
<div class="section-title" style="font-size:1rem;width:100%">
|
||||||
|
|
@ -130,7 +128,6 @@
|
||||||
</q-list>
|
</q-list>
|
||||||
</q-menu>
|
</q-menu>
|
||||||
</div>
|
</div>
|
||||||
<!-- Add equipment button -->
|
|
||||||
<div class="device-icon-chip device-add-chip" @click="openAddEquipment(loc)">
|
<div class="device-icon-chip device-add-chip" @click="openAddEquipment(loc)">
|
||||||
<q-icon name="add" size="20px" />
|
<q-icon name="add" size="20px" />
|
||||||
<q-tooltip>Ajouter un equipement</q-tooltip>
|
<q-tooltip>Ajouter un equipement</q-tooltip>
|
||||||
|
|
@ -138,7 +135,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- SPLIT CANDIDATE: SubscriptionsBlock.vue -->
|
|
||||||
<div class="info-block subs-block">
|
<div class="info-block subs-block">
|
||||||
<div class="info-block-title">Abonnements ({{ locSubs(loc.name).length }})</div>
|
<div class="info-block-title">Abonnements ({{ locSubs(loc.name).length }})</div>
|
||||||
<div v-if="!locSubs(loc.name).length" class="text-caption text-grey-5">Aucun</div>
|
<div v-if="!locSubs(loc.name).length" class="text-caption text-grey-5">Aucun</div>
|
||||||
|
|
@ -288,7 +284,6 @@
|
||||||
</div>
|
</div>
|
||||||
</q-expansion-item>
|
</q-expansion-item>
|
||||||
|
|
||||||
<!-- SPLIT CANDIDATE: TicketsSection.vue -->
|
|
||||||
<q-expansion-item v-model="sectionsOpen.tickets" header-class="section-header" class="q-mb-sm">
|
<q-expansion-item v-model="sectionsOpen.tickets" header-class="section-header" class="q-mb-sm">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="section-title" style="font-size:1rem;width:100%;display:flex;align-items:center">
|
<div class="section-title" style="font-size:1rem;width:100%;display:flex;align-items:center">
|
||||||
|
|
@ -372,7 +367,6 @@
|
||||||
</div>
|
</div>
|
||||||
</q-expansion-item>
|
</q-expansion-item>
|
||||||
|
|
||||||
<!-- SPLIT CANDIDATE: InvoicesSection.vue -->
|
|
||||||
<q-expansion-item v-model="sectionsOpen.invoices" header-class="section-header" class="q-mb-sm">
|
<q-expansion-item v-model="sectionsOpen.invoices" header-class="section-header" class="q-mb-sm">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="section-title" style="font-size:1rem;width:100%">
|
<div class="section-title" style="font-size:1rem;width:100%">
|
||||||
|
|
@ -412,7 +406,6 @@
|
||||||
</div>
|
</div>
|
||||||
</q-expansion-item>
|
</q-expansion-item>
|
||||||
|
|
||||||
<!-- SPLIT CANDIDATE: PaymentsSection.vue -->
|
|
||||||
<q-expansion-item v-model="sectionsOpen.payments" header-class="section-header" class="q-mb-sm">
|
<q-expansion-item v-model="sectionsOpen.payments" header-class="section-header" class="q-mb-sm">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="section-title" style="font-size:1rem;width:100%">
|
<div class="section-title" style="font-size:1rem;width:100%">
|
||||||
|
|
@ -462,7 +455,6 @@
|
||||||
<q-btn flat color="indigo-6" label="Retour" icon="arrow_back" @click="$router.push('/clients')" class="q-mt-md" />
|
<q-btn flat color="indigo-6" label="Retour" icon="arrow_back" @click="$router.push('/clients')" class="q-mt-md" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add Equipment Dialog -->
|
|
||||||
<q-dialog v-model="addEquipOpen" persistent>
|
<q-dialog v-model="addEquipOpen" persistent>
|
||||||
<q-card style="width:520px;max-width:95vw">
|
<q-card style="width:520px;max-width:95vw">
|
||||||
<q-card-section class="row items-center q-pb-sm">
|
<q-card-section class="row items-center q-pb-sm">
|
||||||
|
|
@ -477,7 +469,6 @@
|
||||||
<q-separator />
|
<q-separator />
|
||||||
|
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<!-- Scan section -->
|
|
||||||
<div class="row items-center q-mb-md q-pa-sm" style="background:#f0fdf4;border-radius:8px;border:1px solid #bbf7d0">
|
<div class="row items-center q-mb-md q-pa-sm" style="background:#f0fdf4;border-radius:8px;border:1px solid #bbf7d0">
|
||||||
<q-icon name="qr_code_scanner" size="24px" color="green-7" class="q-mr-sm" />
|
<q-icon name="qr_code_scanner" size="24px" color="green-7" class="q-mr-sm" />
|
||||||
<div class="col">
|
<div class="col">
|
||||||
|
|
@ -490,7 +481,6 @@
|
||||||
:loading="scannerState.scanning.value" @click="$refs.scanInput.click()" />
|
:loading="scannerState.scanning.value" @click="$refs.scanInput.click()" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Scanned codes -->
|
|
||||||
<div v-if="scannerState.barcodes.value.length" class="q-mb-md">
|
<div v-if="scannerState.barcodes.value.length" class="q-mb-md">
|
||||||
<div class="text-caption text-grey-7 q-mb-xs">Codes detectes :</div>
|
<div class="text-caption text-grey-7 q-mb-xs">Codes detectes :</div>
|
||||||
<div class="row q-gutter-xs">
|
<div class="row q-gutter-xs">
|
||||||
|
|
@ -507,14 +497,12 @@
|
||||||
{{ scannerState.error.value }}
|
{{ scannerState.error.value }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Scan photo preview -->
|
|
||||||
<div v-if="scannerState.lastPhoto.value" class="q-mb-md text-center">
|
<div v-if="scannerState.lastPhoto.value" class="q-mb-md text-center">
|
||||||
<img :src="scannerState.lastPhoto.value" style="max-height:120px;border-radius:8px;border:1px solid #e2e8f0" />
|
<img :src="scannerState.lastPhoto.value" style="max-height:120px;border-radius:8px;border:1px solid #e2e8f0" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<q-separator class="q-mb-md" />
|
<q-separator class="q-mb-md" />
|
||||||
|
|
||||||
<!-- Manual form -->
|
|
||||||
<div class="row q-col-gutter-sm">
|
<div class="row q-col-gutter-sm">
|
||||||
<div class="col-6">
|
<div class="col-6">
|
||||||
<q-select v-model="newEquip.equipment_type" label="Type" outlined dense emit-value map-options
|
<q-select v-model="newEquip.equipment_type" label="Type" outlined dense emit-value map-options
|
||||||
|
|
@ -565,7 +553,6 @@
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
|
|
||||||
<!-- Create Ticket Dialog (Unified) -->
|
|
||||||
<UnifiedCreateModal v-model="newTicketOpen" mode="ticket"
|
<UnifiedCreateModal v-model="newTicketOpen" mode="ticket"
|
||||||
:context="ticketContext" :locations="locations"
|
:context="ticketContext" :locations="locations"
|
||||||
@created="onTicketCreated" />
|
@created="onTicketCreated" />
|
||||||
|
|
@ -587,9 +574,8 @@
|
||||||
import { ref, computed, onMounted, reactive, watch } from 'vue'
|
import { ref, computed, onMounted, reactive, watch } from 'vue'
|
||||||
import { Notify, useQuasar } from 'quasar'
|
import { Notify, useQuasar } from 'quasar'
|
||||||
import draggable from 'vuedraggable'
|
import draggable from 'vuedraggable'
|
||||||
import { listDocs, getDoc, deleteDoc, createDoc, updateDoc } from 'src/api/erp'
|
import { deleteDoc } from 'src/api/erp'
|
||||||
import { authFetch } from 'src/api/auth'
|
import { authFetch } from 'src/api/auth'
|
||||||
import { BASE_URL } from 'src/config/erpnext'
|
|
||||||
import { formatDate, formatMoney, staffColor, staffInitials } from 'src/composables/useFormatters'
|
import { formatDate, formatMoney, staffColor, staffInitials } from 'src/composables/useFormatters'
|
||||||
import { locStatusClass, ticketStatusClass, invStatusClass, deviceColorClass, priorityClass } from 'src/composables/useStatusClasses'
|
import { locStatusClass, ticketStatusClass, invStatusClass, deviceColorClass, priorityClass } from 'src/composables/useStatusClasses'
|
||||||
import { useDetailModal } from 'src/composables/useDetailModal'
|
import { useDetailModal } from 'src/composables/useDetailModal'
|
||||||
|
|
@ -607,32 +593,34 @@ import ChatterPanel from 'src/components/customer/ChatterPanel.vue'
|
||||||
import UnifiedCreateModal from 'src/components/shared/UnifiedCreateModal.vue'
|
import UnifiedCreateModal from 'src/components/shared/UnifiedCreateModal.vue'
|
||||||
import { useDeviceStatus } from 'src/composables/useDeviceStatus'
|
import { useDeviceStatus } from 'src/composables/useDeviceStatus'
|
||||||
import { usePermissions } from 'src/composables/usePermissions'
|
import { usePermissions } from 'src/composables/usePermissions'
|
||||||
import { useScanner } from 'src/composables/useScanner'
|
import { useClientData } from 'src/composables/useClientData'
|
||||||
|
import { useEquipmentActions } from 'src/composables/useEquipmentActions'
|
||||||
|
import { locInlineFields, equipTypeOptions, equipStatusOptions, defaultSectionsOpen, phoneLabelMap } from 'src/data/client-constants'
|
||||||
|
import { erpPdfUrl } from 'src/utils/erp-pdf'
|
||||||
|
|
||||||
const $q = useQuasar()
|
const $q = useQuasar()
|
||||||
const { can } = usePermissions()
|
const { can } = usePermissions()
|
||||||
const props = defineProps({ id: String })
|
const props = defineProps({ id: String })
|
||||||
|
|
||||||
const loading = ref(true)
|
|
||||||
const customer = ref(null)
|
|
||||||
const contact = ref(null)
|
|
||||||
const locations = ref([])
|
|
||||||
const subscriptions = ref([])
|
|
||||||
const equipment = ref([])
|
const equipment = ref([])
|
||||||
const tickets = ref([])
|
const ticketsExpanded = ref(false)
|
||||||
const invoices = ref([])
|
const invoicesExpanded = ref(false)
|
||||||
const payments = ref([])
|
const paymentsExpanded = ref(false)
|
||||||
const comments = ref([])
|
|
||||||
const accountBalance = ref(null)
|
|
||||||
|
|
||||||
// Location inline fields config for v-for consolidation
|
const { fetchStatus, fetchOltStatus, getDevice, isOnline, combinedStatus, signalQuality, rebootDevice, refreshDeviceParams } = useDeviceStatus()
|
||||||
const locInlineFields = [
|
|
||||||
{ field: 'city', placeholder: 'Ville', prefix: null },
|
const {
|
||||||
{ field: 'postal_code', placeholder: 'Code postal', prefix: ', ' },
|
modalOpen, modalLoading, modalDoctype, modalDocName, modalTitle,
|
||||||
{ field: 'location_name', placeholder: 'Nom du lieu', prefix: ' — ' },
|
modalDoc, modalComments, modalComms, modalFiles, modalDocFields,
|
||||||
{ field: 'contact_name', placeholder: 'Contact', prefix: ' \u00b7 Contact: ' },
|
modalDispatchJobs, openModal,
|
||||||
{ field: 'contact_phone', placeholder: 'Téléphone', prefix: ' ' },
|
} = useDetailModal()
|
||||||
]
|
|
||||||
|
const {
|
||||||
|
loading, customer, contact, locations, subscriptions, tickets,
|
||||||
|
invoices, payments, comments, accountBalance,
|
||||||
|
loadingMoreTickets, loadingMoreInvoices, loadingMorePayments,
|
||||||
|
loadCustomer, loadAllTickets, loadAllInvoices, loadAllPayments,
|
||||||
|
} = useClientData({ equipment, modalOpen, ticketsExpanded, invoicesExpanded, paymentsExpanded, invalidateAll: () => invalidateAll(), fetchStatus, fetchOltStatus })
|
||||||
|
|
||||||
const {
|
const {
|
||||||
locSubs, locSubsMonthly, locSubsAnnual, locSubsMonthlyTotal, locSubsAnnualTotal,
|
locSubs, locSubsMonthly, locSubsAnnual, locSubsMonthlyTotal, locSubsAnnualTotal,
|
||||||
|
|
@ -646,13 +634,13 @@ const {
|
||||||
} = useSubscriptionActions(subscriptions, customer, comments, invalidateCache)
|
} = useSubscriptionActions(subscriptions, customer, comments, invalidateCache)
|
||||||
|
|
||||||
const { onNoteAdded } = useCustomerNotes(comments, customer)
|
const { onNoteAdded } = useCustomerNotes(comments, customer)
|
||||||
const { fetchStatus, fetchOltStatus, getDevice, isOnline, combinedStatus, signalQuality, rebootDevice, refreshDeviceParams, loading: deviceLoading } = useDeviceStatus()
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
modalOpen, modalLoading, modalDoctype, modalDocName, modalTitle,
|
scannerState, addEquipOpen, addEquipLoc, addingEquip,
|
||||||
modalDoc, modalComments, modalComms, modalFiles, modalDocFields,
|
equipLookupResult, equipLookingUp, newEquip,
|
||||||
modalDispatchJobs, openModal,
|
openAddEquipment, closeAddEquipment, onScanPhoto,
|
||||||
} = useDetailModal()
|
applyScannedCode, createEquipment, linkExistingEquipment,
|
||||||
|
} = useEquipmentActions(customer, equipment)
|
||||||
|
|
||||||
function onDispatchCreated (job) { modalDispatchJobs.value.push(job) }
|
function onDispatchCreated (job) { modalDispatchJobs.value.push(job) }
|
||||||
|
|
||||||
|
|
@ -699,7 +687,6 @@ const sortedLocations = computed(() => {
|
||||||
return [...withSubs, ...withoutSubs]
|
return [...withSubs, ...withoutSubs]
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Delete location ──
|
|
||||||
const deletingLoc = ref(null)
|
const deletingLoc = ref(null)
|
||||||
function confirmDeleteLocation (loc) {
|
function confirmDeleteLocation (loc) {
|
||||||
if (locHasSubs(loc.name)) return
|
if (locHasSubs(loc.name)) return
|
||||||
|
|
@ -723,151 +710,18 @@ function confirmDeleteLocation (loc) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Handle equipment/entity deletion from DetailModal ──
|
|
||||||
function onEntityDeleted (docName) {
|
function onEntityDeleted (docName) {
|
||||||
equipment.value = equipment.value.filter(e => e.name !== docName)
|
equipment.value = equipment.value.filter(e => e.name !== docName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══ Add Equipment with Scanner ═══
|
const sectionsOpen = ref({ ...defaultSectionsOpen })
|
||||||
const scannerState = useScanner()
|
|
||||||
const addEquipOpen = ref(false)
|
|
||||||
const addEquipLoc = ref(null)
|
|
||||||
const addingEquip = ref(false)
|
|
||||||
const equipLookupResult = ref(null)
|
|
||||||
const equipLookingUp = ref(false)
|
|
||||||
const equipTypeOptions = ['ONT', 'Routeur', 'Modem', 'Décodeur TV', 'VoIP', 'Switch', 'AP', 'Amplificateur', 'Splitter', 'Autre']
|
|
||||||
const equipStatusOptions = ['Actif', 'En inventaire', 'Défectueux', 'Retourné', 'Perdu']
|
|
||||||
const newEquip = ref({ equipment_type: 'ONT', serial_number: '', brand: '', model: '', mac_address: '', ip_address: '', status: 'Actif' })
|
|
||||||
|
|
||||||
let lookupTimer = null
|
|
||||||
watch(() => newEquip.value.serial_number, (sn) => {
|
|
||||||
clearTimeout(lookupTimer)
|
|
||||||
equipLookupResult.value = null
|
|
||||||
if (!sn || sn.length < 3) return
|
|
||||||
lookupTimer = setTimeout(() => lookupSerial(sn), 500)
|
|
||||||
})
|
|
||||||
|
|
||||||
async function lookupSerial (sn) {
|
|
||||||
equipLookingUp.value = true
|
|
||||||
try {
|
|
||||||
const results = await listDocs('Service Equipment', {
|
|
||||||
filters: { serial_number: sn },
|
|
||||||
fields: ['name', 'equipment_type', 'brand', 'model', 'serial_number', 'service_location', 'customer'],
|
|
||||||
limit: 1,
|
|
||||||
})
|
|
||||||
if (results.length) {
|
|
||||||
equipLookupResult.value = { found: true, equipment: results[0] }
|
|
||||||
}
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
finally { equipLookingUp.value = false }
|
|
||||||
}
|
|
||||||
|
|
||||||
function openAddEquipment (loc) {
|
|
||||||
addEquipLoc.value = loc
|
|
||||||
newEquip.value = { equipment_type: 'ONT', serial_number: '', brand: '', model: '', mac_address: '', ip_address: '', status: 'Actif' }
|
|
||||||
equipLookupResult.value = null
|
|
||||||
scannerState.clearBarcodes()
|
|
||||||
addEquipOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeAddEquipment () {
|
|
||||||
addEquipOpen.value = false
|
|
||||||
addEquipLoc.value = null
|
|
||||||
scannerState.clearBarcodes()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onScanPhoto (e) {
|
|
||||||
const file = e.target?.files?.[0]
|
|
||||||
if (!file) return
|
|
||||||
const result = await scannerState.scanEquipmentLabel(file)
|
|
||||||
if (result) {
|
|
||||||
// Auto-fill all fields from structured AI response
|
|
||||||
if (result.serial_number && !newEquip.value.serial_number) newEquip.value.serial_number = result.serial_number
|
|
||||||
if (result.brand) newEquip.value.brand = result.brand
|
|
||||||
if (result.model) newEquip.value.model = result.model
|
|
||||||
if (result.mac_address) newEquip.value.mac_address = result.mac_address
|
|
||||||
if (result.ip_address) newEquip.value.ip_address = result.ip_address
|
|
||||||
// Map equipment_type if detected
|
|
||||||
if (result.equipment_type) {
|
|
||||||
const typeMap = { ont: 'ONT', onu: 'ONT', router: 'Router', modem: 'Modem', decoder: 'Décodeur', switch: 'Switch' }
|
|
||||||
const mapped = typeMap[result.equipment_type.toLowerCase()]
|
|
||||||
if (mapped) newEquip.value.equipment_type = mapped
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Reset input so same file can be re-selected
|
|
||||||
e.target.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyScannedCode (code) {
|
|
||||||
newEquip.value.serial_number = code
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createEquipment () {
|
|
||||||
addingEquip.value = true
|
|
||||||
try {
|
|
||||||
const doc = await createDoc('Service Equipment', {
|
|
||||||
...newEquip.value,
|
|
||||||
customer: customer.value.name,
|
|
||||||
service_location: addEquipLoc.value.name,
|
|
||||||
})
|
|
||||||
equipment.value.push({
|
|
||||||
name: doc.name,
|
|
||||||
equipment_type: newEquip.value.equipment_type,
|
|
||||||
brand: newEquip.value.brand,
|
|
||||||
model: newEquip.value.model,
|
|
||||||
serial_number: newEquip.value.serial_number,
|
|
||||||
mac_address: newEquip.value.mac_address,
|
|
||||||
ip_address: newEquip.value.ip_address,
|
|
||||||
status: newEquip.value.status,
|
|
||||||
service_location: addEquipLoc.value.name,
|
|
||||||
})
|
|
||||||
Notify.create({ type: 'positive', message: `Equipement ${doc.name} cree`, timeout: 2000 })
|
|
||||||
closeAddEquipment()
|
|
||||||
} catch (e) {
|
|
||||||
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
|
|
||||||
} finally {
|
|
||||||
addingEquip.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function linkExistingEquipment () {
|
|
||||||
if (!equipLookupResult.value?.found) return
|
|
||||||
addingEquip.value = true
|
|
||||||
try {
|
|
||||||
const existing = equipLookupResult.value.equipment
|
|
||||||
await updateDoc('Service Equipment', existing.name, {
|
|
||||||
customer: customer.value.name,
|
|
||||||
service_location: addEquipLoc.value.name,
|
|
||||||
})
|
|
||||||
// Add to local list or update if already there
|
|
||||||
const idx = equipment.value.findIndex(e => e.name === existing.name)
|
|
||||||
if (idx >= 0) {
|
|
||||||
equipment.value[idx].service_location = addEquipLoc.value.name
|
|
||||||
} else {
|
|
||||||
equipment.value.push({
|
|
||||||
...existing,
|
|
||||||
service_location: addEquipLoc.value.name,
|
|
||||||
customer: customer.value.name,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
Notify.create({ type: 'positive', message: `Equipement ${existing.name} lie a ${addEquipLoc.value.address_line || addEquipLoc.value.location_name}`, timeout: 2000 })
|
|
||||||
closeAddEquipment()
|
|
||||||
} catch (e) {
|
|
||||||
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
|
|
||||||
} finally {
|
|
||||||
addingEquip.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const sectionsOpen = ref({ locations: true, tickets: true, invoices: false, payments: false, notes: false })
|
|
||||||
|
|
||||||
const openTicketCount = computed(() => tickets.value.filter(t => t.status === 'Open').length)
|
const openTicketCount = computed(() => tickets.value.filter(t => t.status === 'Open').length)
|
||||||
const totalOutstanding = computed(() => accountBalance.value?.balance ?? 0)
|
const totalOutstanding = computed(() => accountBalance.value?.balance ?? 0)
|
||||||
|
|
||||||
const customerPhoneOptions = computed(() => {
|
const customerPhoneOptions = computed(() => {
|
||||||
if (!customer.value) return []
|
if (!customer.value) return []
|
||||||
const map = { cell_phone: 'Cell', tel_home: 'Maison', tel_office: 'Bureau' }
|
return Object.entries(phoneLabelMap)
|
||||||
return Object.entries(map)
|
|
||||||
.filter(([k]) => customer.value[k])
|
.filter(([k]) => customer.value[k])
|
||||||
.map(([k, label]) => ({ label: `${label}: ${customer.value[k]}`, value: customer.value[k] }))
|
.map(([k, label]) => ({ label: `${label}: ${customer.value[k]}`, value: customer.value[k] }))
|
||||||
})
|
})
|
||||||
|
|
@ -875,10 +729,6 @@ const customerPhoneOptions = computed(() => {
|
||||||
const locEquip = (locName) => equipment.value.filter(e => e.service_location === locName)
|
const locEquip = (locName) => equipment.value.filter(e => e.service_location === locName)
|
||||||
const locTickets = (locName) => tickets.value.filter(t => t.service_location === locName)
|
const locTickets = (locName) => tickets.value.filter(t => t.service_location === locName)
|
||||||
|
|
||||||
function erpPdfUrl (name) {
|
|
||||||
return `${BASE_URL}/api/method/frappe.utils.print_format.download_pdf?doctype=Sales%20Invoice&name=${encodeURIComponent(name)}&format=Facture%20TARGO`
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openPdf (name) {
|
async function openPdf (name) {
|
||||||
try {
|
try {
|
||||||
const res = await authFetch(erpPdfUrl(name))
|
const res = await authFetch(erpPdfUrl(name))
|
||||||
|
|
@ -892,119 +742,6 @@ async function openPdf (name) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadCustomer (id) {
|
|
||||||
loading.value = true
|
|
||||||
customer.value = null
|
|
||||||
locations.value = []
|
|
||||||
subscriptions.value = []
|
|
||||||
equipment.value = []
|
|
||||||
tickets.value = []
|
|
||||||
invoices.value = []
|
|
||||||
payments.value = []
|
|
||||||
comments.value = []
|
|
||||||
contact.value = null
|
|
||||||
modalOpen.value = false
|
|
||||||
ticketsExpanded.value = false
|
|
||||||
invoicesExpanded.value = false
|
|
||||||
paymentsExpanded.value = false
|
|
||||||
|
|
||||||
try {
|
|
||||||
const custFilter = { customer: id }
|
|
||||||
|
|
||||||
const [cust, locs, subs, equip, tix, invs, pays, memos, balRes] = await Promise.all([
|
|
||||||
getDoc('Customer', id),
|
|
||||||
listDocs('Service Location', {
|
|
||||||
filters: custFilter,
|
|
||||||
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',
|
|
||||||
}),
|
|
||||||
listDocs('Service Subscription', {
|
|
||||||
filters: custFilter,
|
|
||||||
fields: ['name', 'status', 'start_date', 'end_date', 'service_location',
|
|
||||||
'monthly_price', 'plan_name', 'service_category', 'billing_cycle',
|
|
||||||
'speed_down', 'speed_up', 'cancellation_date', 'cancellation_reason', 'notes'],
|
|
||||||
limit: 200, orderBy: 'start_date desc',
|
|
||||||
}).then(subs => subs.map(s => ({
|
|
||||||
...s,
|
|
||||||
actual_price: s.monthly_price,
|
|
||||||
custom_description: s.plan_name,
|
|
||||||
item_name: s.plan_name,
|
|
||||||
item_code: s.name,
|
|
||||||
item_group: s.service_category || '',
|
|
||||||
billing_frequency: s.billing_cycle === 'Annuel' ? 'A' : 'M',
|
|
||||||
cancel_at_period_end: 0,
|
|
||||||
cancelation_date: s.cancellation_date,
|
|
||||||
status: ({ Actif: 'Active', Annulé: 'Cancelled', Suspendu: 'Cancelled', 'En attente': 'Active' })[s.status] || s.status,
|
|
||||||
}))),
|
|
||||||
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',
|
|
||||||
}),
|
|
||||||
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: 10, orderBy: 'is_important desc, opening_date desc',
|
|
||||||
}),
|
|
||||||
listDocs('Sales Invoice', {
|
|
||||||
filters: custFilter,
|
|
||||||
fields: ['name', 'posting_date', 'grand_total', 'outstanding_amount', 'status', 'is_return', 'return_against', 'creation'],
|
|
||||||
limit: 5, 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: 5, orderBy: 'posting_date desc',
|
|
||||||
}),
|
|
||||||
listDocs('Comment', {
|
|
||||||
filters: { reference_doctype: 'Customer', reference_name: id, comment_type: 'Comment' },
|
|
||||||
fields: ['name', 'content', 'comment_by', 'creation'],
|
|
||||||
limit: 50, orderBy: 'creation desc',
|
|
||||||
}).catch(() => []),
|
|
||||||
authFetch(BASE_URL + '/api/method/customer_balance?customer=' + encodeURIComponent(id)).catch(() => null),
|
|
||||||
])
|
|
||||||
|
|
||||||
for (const f of ['is_commercial', 'is_bad_payer', 'exclude_fees', 'ppa_enabled']) { cust[f] = !!cust[f] }
|
|
||||||
customer.value = cust
|
|
||||||
locations.value = locs
|
|
||||||
subscriptions.value = subs
|
|
||||||
invalidateAll()
|
|
||||||
equipment.value = equip
|
|
||||||
if (equip.length) {
|
|
||||||
fetchStatus(equip).catch(() => {})
|
|
||||||
// Fetch OLT SNMP status in parallel for cross-reference
|
|
||||||
for (const eq of equip) {
|
|
||||||
if (eq.serial_number) fetchOltStatus(eq.serial_number).catch(() => {})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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 = null
|
|
||||||
comments.value = memos
|
|
||||||
|
|
||||||
if (balRes?.ok) {
|
|
||||||
try { accountBalance.value = (await balRes.json()).message } catch {}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
customer.value = null
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const ticketsExpanded = ref(false)
|
|
||||||
const invoicesExpanded = ref(false)
|
|
||||||
const paymentsExpanded = ref(false)
|
|
||||||
const loadingMoreTickets = ref(false)
|
|
||||||
const loadingMoreInvoices = ref(false)
|
|
||||||
const loadingMorePayments = ref(false)
|
|
||||||
|
|
||||||
// ── Create Ticket (via UnifiedCreateModal) ──
|
|
||||||
const newTicketOpen = ref(false)
|
const newTicketOpen = ref(false)
|
||||||
const ticketContext = ref({})
|
const ticketContext = ref({})
|
||||||
|
|
||||||
|
|
@ -1030,48 +767,6 @@ function onTicketCreated (doc) {
|
||||||
openModal('Issue', doc.name, doc.subject)
|
openModal('Issue', doc.name, doc.subject)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadAllTickets () {
|
|
||||||
if (ticketsExpanded.value || !customer.value) return
|
|
||||||
loadingMoreTickets.value = true
|
|
||||||
try {
|
|
||||||
tickets.value = await listDocs('Issue', {
|
|
||||||
filters: { customer: customer.value.name },
|
|
||||||
fields: ['name', 'subject', 'status', 'priority', 'opening_date', 'service_location', 'legacy_ticket_id', 'is_important', 'assigned_staff', 'opened_by_staff', 'issue_type'],
|
|
||||||
limit: 500, orderBy: 'is_important desc, opening_date desc',
|
|
||||||
})
|
|
||||||
ticketsExpanded.value = true
|
|
||||||
} catch {}
|
|
||||||
loadingMoreTickets.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadAllInvoices () {
|
|
||||||
if (invoicesExpanded.value || !customer.value) return
|
|
||||||
loadingMoreInvoices.value = true
|
|
||||||
try {
|
|
||||||
invoices.value = await listDocs('Sales Invoice', {
|
|
||||||
filters: { customer: customer.value.name },
|
|
||||||
fields: ['name', 'posting_date', 'grand_total', 'outstanding_amount', 'status', 'is_return', 'return_against', 'creation'],
|
|
||||||
limit: 200, orderBy: 'posting_date desc, name desc',
|
|
||||||
})
|
|
||||||
invoicesExpanded.value = true
|
|
||||||
} catch {}
|
|
||||||
loadingMoreInvoices.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadAllPayments () {
|
|
||||||
if (paymentsExpanded.value || !customer.value) return
|
|
||||||
loadingMorePayments.value = true
|
|
||||||
try {
|
|
||||||
payments.value = await listDocs('Payment Entry', {
|
|
||||||
filters: { party_type: 'Customer', party: customer.value.name },
|
|
||||||
fields: ['name', 'posting_date', 'paid_amount', 'mode_of_payment', 'reference_no'],
|
|
||||||
limit: 200, orderBy: 'posting_date desc',
|
|
||||||
})
|
|
||||||
paymentsExpanded.value = true
|
|
||||||
} catch {}
|
|
||||||
loadingMorePayments.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(() => props.id, (newId) => { if (newId) loadCustomer(newId) })
|
watch(() => props.id, (newId) => { if (newId) loadCustomer(newId) })
|
||||||
onMounted(() => loadCustomer(props.id))
|
onMounted(() => loadCustomer(props.id))
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { ref, computed, watch, onMounted, onUnmounted, nextTick, provide } from
|
||||||
import { useDispatchStore } from 'src/stores/dispatch'
|
import { useDispatchStore } from 'src/stores/dispatch'
|
||||||
import { useAuthStore } from 'src/stores/auth'
|
import { useAuthStore } from 'src/stores/auth'
|
||||||
import { TECH_COLORS, MAPBOX_TOKEN, BASE_URL } from 'src/config/erpnext'
|
import { TECH_COLORS, MAPBOX_TOKEN, BASE_URL } from 'src/config/erpnext'
|
||||||
|
import { ROW_H, RES_ICONS, HUB_SSE_URL } from 'src/config/dispatch'
|
||||||
import { fetchOpenRequests } from 'src/api/service-request'
|
import { fetchOpenRequests } from 'src/api/service-request'
|
||||||
import { updateJob, updateTech } from 'src/api/dispatch'
|
import { updateJob, updateTech } from 'src/api/dispatch'
|
||||||
|
|
||||||
|
|
@ -15,11 +16,14 @@ import PublishScheduleModal from 'src/modules/dispatch/components/PublishSchedul
|
||||||
import WeekCalendar from 'src/modules/dispatch/components/WeekCalendar.vue'
|
import WeekCalendar from 'src/modules/dispatch/components/WeekCalendar.vue'
|
||||||
import MonthCalendar from 'src/modules/dispatch/components/MonthCalendar.vue'
|
import MonthCalendar from 'src/modules/dispatch/components/MonthCalendar.vue'
|
||||||
import RightPanel from 'src/modules/dispatch/components/RightPanel.vue'
|
import RightPanel from 'src/modules/dispatch/components/RightPanel.vue'
|
||||||
|
import SbModal from 'src/modules/dispatch/components/SbModal.vue'
|
||||||
|
import SbContextMenu from 'src/modules/dispatch/components/SbContextMenu.vue'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
localDateStr, timeToH, hToTime, fmtDur,
|
localDateStr, timeToH, hToTime, fmtDur,
|
||||||
SVC_COLORS, prioLabel, prioClass, serializeAssistants,
|
SVC_COLORS, prioLabel, prioClass, serializeAssistants,
|
||||||
jobColor as _jobColorBase, ICON, prioColor,
|
jobColor as _jobColorBase, ICON, prioColor,
|
||||||
|
WEEK_DAYS, DAY_LABELS, DEFAULT_WEEKLY_SCHEDULE, SCHEDULE_PRESETS,
|
||||||
} from 'src/composables/useHelpers'
|
} from 'src/composables/useHelpers'
|
||||||
import { useScheduler } from 'src/composables/useScheduler'
|
import { useScheduler } from 'src/composables/useScheduler'
|
||||||
import { useUndo } from 'src/composables/useUndo'
|
import { useUndo } from 'src/composables/useUndo'
|
||||||
|
|
@ -34,6 +38,7 @@ import { useTagManagement } from 'src/composables/useTagManagement'
|
||||||
import { useContextMenus } from 'src/composables/useContextMenus'
|
import { useContextMenus } from 'src/composables/useContextMenus'
|
||||||
import { useTechManagement } from 'src/composables/useTechManagement'
|
import { useTechManagement } from 'src/composables/useTechManagement'
|
||||||
import { useAddressSearch } from 'src/composables/useAddressSearch'
|
import { useAddressSearch } from 'src/composables/useAddressSearch'
|
||||||
|
import { useAbsenceResize } from 'src/composables/useAbsenceResize'
|
||||||
|
|
||||||
const store = useDispatchStore()
|
const store = useDispatchStore()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
|
@ -61,7 +66,7 @@ const {
|
||||||
|
|
||||||
const { addrResults, addrLoading, searchAddr, selectAddr } = useAddressSearch()
|
const { addrResults, addrLoading, searchAddr, selectAddr } = useAddressSearch()
|
||||||
|
|
||||||
function setEndDate (job, endDate) {
|
const setEndDate = (job, endDate) => {
|
||||||
job.endDate = endDate || null
|
job.endDate = endDate || null
|
||||||
updateJob(job.name || job.id, { end_date: endDate || '' }).catch(() => {})
|
updateJob(job.name || job.id, { end_date: endDate || '' }).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
@ -97,8 +102,8 @@ function confirmEdit () {
|
||||||
invalidateRoutes()
|
invalidateRoutes()
|
||||||
}
|
}
|
||||||
|
|
||||||
function getJobDate (jobId) { return store.jobs.find(j => j.id === jobId)?.scheduledDate || null }
|
const getJobDate = jobId => store.jobs.find(j => j.id === jobId)?.scheduledDate || null
|
||||||
function getJobTime (jobId) { return store.jobs.find(j => j.id === jobId)?.startTime || null }
|
const getJobTime = jobId => store.jobs.find(j => j.id === jobId)?.startTime || null
|
||||||
function setJobTime (jobId, time) {
|
function setJobTime (jobId, time) {
|
||||||
const job = store.jobs.find(j => j.id === jobId)
|
const job = store.jobs.find(j => j.id === jobId)
|
||||||
if (!job) return
|
if (!job) return
|
||||||
|
|
@ -107,11 +112,9 @@ function setJobTime (jobId, time) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeModal = ref(null)
|
const timeModal = ref(null)
|
||||||
function openTimeModal (job, techId) {
|
const openTimeModal = (job, techId) => { timeModal.value = { job, techId, time: getJobTime(job.id) || '08:00', hasPin: !!getJobTime(job.id) } }
|
||||||
timeModal.value = { job, techId, time: getJobTime(job.id) || '08:00', hasPin: !!getJobTime(job.id) }
|
const confirmTime = () => { if (!timeModal.value) return; setJobTime(timeModal.value.job.id, timeModal.value.time); timeModal.value = null }
|
||||||
}
|
const clearTime = () => { if (!timeModal.value) return; setJobTime(timeModal.value.job.id, null); timeModal.value = null }
|
||||||
function confirmTime () { if (!timeModal.value) return; setJobTime(timeModal.value.job.id, timeModal.value.time); timeModal.value = null }
|
|
||||||
function clearTime () { if (!timeModal.value) return; setJobTime(timeModal.value.job.id, null); timeModal.value = null }
|
|
||||||
|
|
||||||
const pendingReqs = ref([])
|
const pendingReqs = ref([])
|
||||||
const pendingLoading = ref(false)
|
const pendingLoading = ref(false)
|
||||||
|
|
@ -122,11 +125,9 @@ async function loadPendingReqs () {
|
||||||
}
|
}
|
||||||
const unscheduledJobs = computed(() => store.jobs.filter(j => !j.assignedTech))
|
const unscheduledJobs = computed(() => store.jobs.filter(j => !j.assignedTech))
|
||||||
const teamJobs = computed(() => store.jobs.filter(j => j.assistants?.length > 0))
|
const teamJobs = computed(() => store.jobs.filter(j => j.assistants?.length > 0))
|
||||||
|
const jobColor = job => _jobColorBase(job, TECH_COLORS, store)
|
||||||
function jobColor (job) { return _jobColorBase(job, TECH_COLORS, store) }
|
|
||||||
|
|
||||||
const PX_PER_HR = ref(80)
|
const PX_PER_HR = ref(80)
|
||||||
const ROW_H = 68
|
|
||||||
const pxPerHr = computed(() => currentView.value === 'week' ? PX_PER_HR.value * 0.55 : currentView.value === 'month' ? 0 : PX_PER_HR.value)
|
const pxPerHr = computed(() => currentView.value === 'week' ? PX_PER_HR.value * 0.55 : currentView.value === 'month' ? 0 : PX_PER_HR.value)
|
||||||
const dayW = computed(() => currentView.value === 'month' ? 110 : (H_END - H_START) * pxPerHr.value)
|
const dayW = computed(() => currentView.value === 'month' ? 110 : (H_END - H_START) * pxPerHr.value)
|
||||||
const totalW = computed(() => dayW.value * periodDays.value)
|
const totalW = computed(() => dayW.value * periodDays.value)
|
||||||
|
|
@ -137,6 +138,8 @@ const {
|
||||||
periodLoadH, techPeriodCapacityH, techDayEndH,
|
periodLoadH, techPeriodCapacityH, techDayEndH,
|
||||||
} = useScheduler(store, currentView, periodStart, periodDays, dayColumns, pxPerHr, getJobDate, getJobTime, jobColor)
|
} = useScheduler(store, currentView, periodStart, periodDays, dayColumns, pxPerHr, getJobDate, getJobTime, jobColor)
|
||||||
|
|
||||||
|
const { startAbsenceResize } = useAbsenceResize(pxPerHr, H_START)
|
||||||
|
|
||||||
const hourTicks = computed(() => {
|
const hourTicks = computed(() => {
|
||||||
if (currentView.value === 'month') return []
|
if (currentView.value === 'month') return []
|
||||||
const ticks = []
|
const ticks = []
|
||||||
|
|
@ -154,7 +157,7 @@ const unassignDropActive = ref(false)
|
||||||
|
|
||||||
const { pushUndo, performUndo } = useUndo(store, invalidateRoutes)
|
const { pushUndo, performUndo } = useUndo(store, invalidateRoutes)
|
||||||
|
|
||||||
function smartAssign (job, newTechId, dateStr) { store.smartAssign(job.id, newTechId, dateStr) }
|
const smartAssign = (job, newTechId, dateStr) => store.smartAssign(job.id, newTechId, dateStr)
|
||||||
function fullUnassign (job) {
|
function fullUnassign (job) {
|
||||||
pushUndo({ type: 'unassignJob', jobId: job.id, techId: job.assignedTech, routeOrder: job.routeOrder, scheduledDate: job.scheduledDate, assistants: [...job.assistants] })
|
pushUndo({ type: 'unassignJob', jobId: job.id, techId: job.assignedTech, routeOrder: job.routeOrder, scheduledDate: job.scheduledDate, assistants: [...job.assistants] })
|
||||||
store.fullUnassign(job.id)
|
store.fullUnassign(job.id)
|
||||||
|
|
@ -183,9 +186,7 @@ const {
|
||||||
techHasLinkedJob, techIsHovered,
|
techHasLinkedJob, techIsHovered,
|
||||||
} = useSelection({ store, periodStart, smartAssign, invalidateRoutes, fullUnassign })
|
} = useSelection({ store, periodStart, smartAssign, invalidateRoutes, fullUnassign })
|
||||||
|
|
||||||
function selectJob (job, techId, isAssist = false, assistTechId = null, event = null) {
|
const selectJob = (job, techId, isAssist = false, assistTechId = null, event = null) => _selectJob(job, techId, isAssist, assistTechId, event, rightPanel)
|
||||||
_selectJob(job, techId, isAssist, assistTechId, event, rightPanel)
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
dragJob, dragSrc, dragIsAssist, dragBatchIds, dropGhost,
|
dragJob, dragSrc, dragIsAssist, dragBatchIds, dropGhost,
|
||||||
|
|
@ -199,60 +200,6 @@ const {
|
||||||
pushUndo, smartAssign, invalidateRoutes,
|
pushUndo, smartAssign, invalidateRoutes,
|
||||||
})
|
})
|
||||||
|
|
||||||
function startAbsenceResize (e, seg, tech, side) {
|
|
||||||
e.preventDefault()
|
|
||||||
const startX = e.clientX
|
|
||||||
const block = e.target.closest('.sb-block-absence')
|
|
||||||
const startW = block.offsetWidth
|
|
||||||
const startL = parseFloat(block.style.left)
|
|
||||||
const SNAP_PX = pxPerHr.value / 4 // 15-minute snap
|
|
||||||
|
|
||||||
function snapPx (px) { return Math.round(px / SNAP_PX) * SNAP_PX }
|
|
||||||
|
|
||||||
function onMove (ev) {
|
|
||||||
const dx = ev.clientX - startX
|
|
||||||
if (side === 'right') {
|
|
||||||
block.style.width = Math.max(SNAP_PX, snapPx(startW + dx)) + 'px'
|
|
||||||
} else {
|
|
||||||
const newL = snapPx(startL + dx)
|
|
||||||
const newW = startW + (startL - newL)
|
|
||||||
if (newW >= SNAP_PX && newL >= 0) {
|
|
||||||
block.style.left = newL + 'px'
|
|
||||||
block.style.width = newW + 'px'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Update label
|
|
||||||
const curL = parseFloat(block.style.left)
|
|
||||||
const curW = parseFloat(block.style.width)
|
|
||||||
const sH = H_START + curL / pxPerHr.value
|
|
||||||
const eH = sH + curW / pxPerHr.value
|
|
||||||
const lbl = block.querySelector('.sb-absence-label')
|
|
||||||
if (lbl) lbl.textContent = `${hToTime(sH)} → ${hToTime(eH)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function onUp () {
|
|
||||||
document.removeEventListener('mousemove', onMove)
|
|
||||||
document.removeEventListener('mouseup', onUp)
|
|
||||||
const curL = parseFloat(block.style.left)
|
|
||||||
const curW = parseFloat(block.style.width)
|
|
||||||
const newStartH = H_START + curL / pxPerHr.value
|
|
||||||
const newEndH = newStartH + curW / pxPerHr.value
|
|
||||||
const startTime = hToTime(newStartH)
|
|
||||||
const endTime = hToTime(newEndH)
|
|
||||||
// Update local state
|
|
||||||
tech.absenceStartTime = startTime
|
|
||||||
tech.absenceEndTime = endTime
|
|
||||||
// Persist to ERPNext
|
|
||||||
updateTech(tech.name || tech.id, {
|
|
||||||
absence_start_time: startTime,
|
|
||||||
absence_end_time: endTime,
|
|
||||||
}).catch(() => {})
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('mousemove', onMove)
|
|
||||||
document.addEventListener('mouseup', onUp)
|
|
||||||
}
|
|
||||||
|
|
||||||
let computeDayRoute = () => {}, drawMapMarkers = () => {}, drawSelectedRoute = () => {}, getMap = () => null
|
let computeDayRoute = () => {}, drawMapMarkers = () => {}, drawSelectedRoute = () => {}, getMap = () => null
|
||||||
const _map = useMap({
|
const _map = useMap({
|
||||||
store, MAPBOX_TOKEN, TECH_COLORS,
|
store, MAPBOX_TOKEN, TECH_COLORS,
|
||||||
|
|
@ -315,9 +262,7 @@ const periodEndStr = computed(() => {
|
||||||
d.setDate(d.getDate() + (periodDays.value || 7) - 1)
|
d.setDate(d.getDate() + (periodDays.value || 7) - 1)
|
||||||
return d.toISOString().slice(0, 10)
|
return d.toISOString().slice(0, 10)
|
||||||
})
|
})
|
||||||
function onPublished (jobNames) {
|
const onPublished = jobNames => store.publishJobsLocal(jobNames)
|
||||||
store.publishJobsLocal(jobNames)
|
|
||||||
}
|
|
||||||
const gpsSettingsOpen = ref(false)
|
const gpsSettingsOpen = ref(false)
|
||||||
const gpsShowInactive = ref(false)
|
const gpsShowInactive = ref(false)
|
||||||
const gpsFilteredTechs = computed(() =>
|
const gpsFilteredTechs = computed(() =>
|
||||||
|
|
@ -335,8 +280,6 @@ const {
|
||||||
|
|
||||||
const newTechGroup = ref('')
|
const newTechGroup = ref('')
|
||||||
|
|
||||||
// ── Schedule editor modal ──
|
|
||||||
import { WEEK_DAYS, DAY_LABELS, DEFAULT_WEEKLY_SCHEDULE, SCHEDULE_PRESETS } from 'src/composables/useHelpers'
|
|
||||||
const scheduleModalTech = ref(null)
|
const scheduleModalTech = ref(null)
|
||||||
const scheduleForm = ref({})
|
const scheduleForm = ref({})
|
||||||
function openScheduleModal (tech) {
|
function openScheduleModal (tech) {
|
||||||
|
|
@ -347,7 +290,7 @@ function openScheduleModal (tech) {
|
||||||
scheduleForm.value[d] = day ? { on: true, start: day.start || '08:00', end: day.end || '16:00' } : { on: false, start: '08:00', end: '16:00' }
|
scheduleForm.value[d] = day ? { on: true, start: day.start || '08:00', end: day.end || '16:00' } : { on: false, start: '08:00', end: '16:00' }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
function applySchedulePreset (preset) {
|
const applySchedulePreset = preset => {
|
||||||
WEEK_DAYS.forEach(d => {
|
WEEK_DAYS.forEach(d => {
|
||||||
const day = preset.schedule[d]
|
const day = preset.schedule[d]
|
||||||
scheduleForm.value[d] = day ? { on: true, start: day.start, end: day.end } : { on: false, start: '08:00', end: '16:00' }
|
scheduleForm.value[d] = day ? { on: true, start: day.start, end: day.end } : { on: false, start: '08:00', end: '16:00' }
|
||||||
|
|
@ -363,7 +306,6 @@ function confirmSchedule () {
|
||||||
scheduleModalTech.value = null
|
scheduleModalTech.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group technicians for the resource selector modal
|
|
||||||
const resSelectorGroupFilter = ref('')
|
const resSelectorGroupFilter = ref('')
|
||||||
const resSelectorSearch = ref('')
|
const resSelectorSearch = ref('')
|
||||||
const savedPresets = ref(JSON.parse(localStorage.getItem('sbv2-resPresets') || '[]'))
|
const savedPresets = ref(JSON.parse(localStorage.getItem('sbv2-resPresets') || '[]'))
|
||||||
|
|
@ -381,11 +323,7 @@ function savePreset () {
|
||||||
presetNameInput.value = ''
|
presetNameInput.value = ''
|
||||||
showPresetSave.value = false
|
showPresetSave.value = false
|
||||||
}
|
}
|
||||||
|
const loadPreset = preset => { tempSelectedIds.value = [...preset.ids] }
|
||||||
function loadPreset (preset) {
|
|
||||||
tempSelectedIds.value = [...preset.ids]
|
|
||||||
}
|
|
||||||
|
|
||||||
function deletePreset (idx) {
|
function deletePreset (idx) {
|
||||||
savedPresets.value.splice(idx, 1)
|
savedPresets.value.splice(idx, 1)
|
||||||
localStorage.setItem('sbv2-resPresets', JSON.stringify(savedPresets.value))
|
localStorage.setItem('sbv2-resPresets', JSON.stringify(savedPresets.value))
|
||||||
|
|
@ -413,9 +351,7 @@ const resSelectorGroupsFiltered = computed(() => {
|
||||||
return sorted.map(([name, techs]) => ({ name, label: name || 'Sans groupe', techs }))
|
return sorted.map(([name, techs]) => ({ name, label: name || 'Sans groupe', techs }))
|
||||||
}
|
}
|
||||||
let techs = store.technicians.filter(t => t.status !== 'inactive')
|
let techs = store.technicians.filter(t => t.status !== 'inactive')
|
||||||
// Apply group filter within selector
|
|
||||||
if (resSelectorGroupFilter.value) techs = techs.filter(t => t.group === resSelectorGroupFilter.value)
|
if (resSelectorGroupFilter.value) techs = techs.filter(t => t.group === resSelectorGroupFilter.value)
|
||||||
// Apply search filter
|
|
||||||
if (resSelectorSearch.value) {
|
if (resSelectorSearch.value) {
|
||||||
const q = resSelectorSearch.value.toLowerCase()
|
const q = resSelectorSearch.value.toLowerCase()
|
||||||
techs = techs.filter(t => t.fullName.toLowerCase().includes(q))
|
techs = techs.filter(t => t.fullName.toLowerCase().includes(q))
|
||||||
|
|
@ -426,7 +362,6 @@ const resSelectorGroupsFiltered = computed(() => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const RES_ICONS = { 'Véhicule': '🚛', 'Outil': '🔧', 'Salle': '🏢', 'Équipement': '📦', 'Nacelle': '🏗️', 'Grue': '🏗️', 'Fusionneuse': '🔧', 'OTDR': '📡' }
|
|
||||||
function resIcon (t) {
|
function resIcon (t) {
|
||||||
if (t.resourceType !== 'material') return ''
|
if (t.resourceType !== 'material') return ''
|
||||||
return RES_ICONS[t.resourceCategory] || RES_ICONS[t.fullName] || '🔧'
|
return RES_ICONS[t.resourceCategory] || RES_ICONS[t.fullName] || '🔧'
|
||||||
|
|
@ -437,11 +372,7 @@ function openResSelectorFull () {
|
||||||
resSelectorSearch.value = ''
|
resSelectorSearch.value = ''
|
||||||
openResSelector()
|
openResSelector()
|
||||||
}
|
}
|
||||||
|
const applyGroupFilter = () => { filterGroup.value = resSelectorGroupFilter.value; resSelectorOpen.value = false }
|
||||||
function applyGroupFilter () {
|
|
||||||
filterGroup.value = resSelectorGroupFilter.value
|
|
||||||
resSelectorOpen.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onTechStatusChange (tech, value) {
|
async function onTechStatusChange (tech, value) {
|
||||||
tech.status = value
|
tech.status = value
|
||||||
|
|
@ -458,14 +389,11 @@ async function saveTechGroup (tech, value) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function openWoModal (prefillDate = null, prefillTech = null) {
|
function openWoModal (prefillDate = null, prefillTech = null) {
|
||||||
woModalCtx.value = {
|
woModalCtx.value = { scheduled_date: prefillDate || todayStr, assigned_tech: prefillTech || null }
|
||||||
scheduled_date: prefillDate || todayStr,
|
|
||||||
assigned_tech: prefillTech || null,
|
|
||||||
}
|
|
||||||
woModalOpen.value = true
|
woModalOpen.value = true
|
||||||
}
|
}
|
||||||
async function confirmWo (formData) {
|
async function confirmWo (formData) {
|
||||||
const job = await store.createJob({
|
return await store.createJob({
|
||||||
subject: formData.subject,
|
subject: formData.subject,
|
||||||
address: formData.address,
|
address: formData.address,
|
||||||
duration_h: formData.duration_h,
|
duration_h: formData.duration_h,
|
||||||
|
|
@ -478,7 +406,6 @@ async function confirmWo (formData) {
|
||||||
tags: (formData.tags || []).map(t => typeof t === 'string' ? { tag: t } : t),
|
tags: (formData.tags || []).map(t => typeof t === 'string' ? { tag: t } : t),
|
||||||
depends_on: formData.depends_on || '',
|
depends_on: formData.depends_on || '',
|
||||||
})
|
})
|
||||||
return job
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { autoDistribute, optimizeRoute: _optimizeRoute } = useAutoDispatch({
|
const { autoDistribute, optimizeRoute: _optimizeRoute } = useAutoDispatch({
|
||||||
|
|
@ -584,14 +511,10 @@ provide('searchAddr', searchAddr)
|
||||||
provide('addrResults', addrResults)
|
provide('addrResults', addrResults)
|
||||||
provide('selectAddr', selectAddr)
|
provide('selectAddr', selectAddr)
|
||||||
|
|
||||||
// ── SSE for real-time tech absence updates ──
|
|
||||||
const HUB_SSE_URL = window.location.hostname === 'localhost' ? 'http://localhost:3300' : 'https://msg.gigafibre.ca'
|
|
||||||
let dispatchSse = null
|
let dispatchSse = null
|
||||||
|
|
||||||
function connectDispatchSSE () {
|
function connectDispatchSSE () {
|
||||||
if (dispatchSse) dispatchSse.close()
|
if (dispatchSse) dispatchSse.close()
|
||||||
dispatchSse = new EventSource(`${HUB_SSE_URL}/sse?topics=dispatch`)
|
dispatchSse = new EventSource(`${HUB_SSE_URL}/sse?topics=dispatch`)
|
||||||
|
|
||||||
dispatchSse.addEventListener('tech-absence', (e) => {
|
dispatchSse.addEventListener('tech-absence', (e) => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(e.data)
|
const data = JSON.parse(e.data)
|
||||||
|
|
@ -916,24 +839,25 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="ctxMenu" class="sb-ctx" :style="'left:'+ctxMenu.x+'px;top:'+ctxMenu.y+'px'" @click.stop="()=>{}">
|
<!-- Context menus -->
|
||||||
|
<SbContextMenu :pos="ctxMenu">
|
||||||
<button class="sb-ctx-item" @click="ctxDetails()">📄 Voir détails</button>
|
<button class="sb-ctx-item" @click="ctxDetails()">📄 Voir détails</button>
|
||||||
<button class="sb-ctx-item" @click="ctxMove()">↔ Déplacer / Réassigner</button>
|
<button class="sb-ctx-item" @click="ctxMove()">↔ Déplacer / Réassigner</button>
|
||||||
<button class="sb-ctx-item" @click="openTimeModal(ctxMenu.job, ctxMenu.techId); closeCtxMenu()">🕐 Fixer l'heure</button>
|
<button class="sb-ctx-item" @click="openTimeModal(ctxMenu.job, ctxMenu.techId); closeCtxMenu()">🕐 Fixer l'heure</button>
|
||||||
<button class="sb-ctx-item" @click="startGeoFix(ctxMenu.job); closeCtxMenu()">📍 Géofixer sur la carte</button>
|
<button class="sb-ctx-item" @click="startGeoFix(ctxMenu.job); closeCtxMenu()">📍 Géofixer sur la carte</button>
|
||||||
<div class="sb-ctx-sep"></div>
|
<div class="sb-ctx-sep"></div>
|
||||||
<button class="sb-ctx-item sb-ctx-warn" @click="ctxUnschedule()">✕ Désaffecter</button>
|
<button class="sb-ctx-item sb-ctx-warn" @click="ctxUnschedule()">✕ Désaffecter</button>
|
||||||
</div>
|
</SbContextMenu>
|
||||||
|
|
||||||
<div v-if="techCtx" class="sb-ctx" :style="'left:'+techCtx.x+'px;top:'+techCtx.y+'px'" @click.stop="()=>{}">
|
<SbContextMenu :pos="techCtx">
|
||||||
<button class="sb-ctx-item" @click="selectTechOnBoard(techCtx.tech); techCtx=null">🗺 Voir sur la carte</button>
|
<button class="sb-ctx-item" @click="selectTechOnBoard(techCtx.tech); techCtx=null">🗺 Voir sur la carte</button>
|
||||||
<button class="sb-ctx-item" @click="optimizeRoute()">🔀 Optimiser la route</button>
|
<button class="sb-ctx-item" @click="optimizeRoute()">🔀 Optimiser la route</button>
|
||||||
<button class="sb-ctx-item" @click="techTagModal=techCtx.tech; techCtx=null">🏷 Skills / Tags</button>
|
<button class="sb-ctx-item" @click="techTagModal=techCtx.tech; techCtx=null">🏷 Skills / Tags</button>
|
||||||
<div class="sb-ctx-sep"></div>
|
<div class="sb-ctx-sep"></div>
|
||||||
<button class="sb-ctx-item" @click="window.open('/desk/Dispatch Technician/'+techCtx.tech.name,'_blank'); techCtx=null">↗ Ouvrir dans ERPNext</button>
|
<button class="sb-ctx-item" @click="window.open('/desk/Dispatch Technician/'+techCtx.tech.name,'_blank'); techCtx=null">↗ Ouvrir dans ERPNext</button>
|
||||||
</div>
|
</SbContextMenu>
|
||||||
|
|
||||||
<div v-if="assistCtx" class="sb-ctx" :style="'left:'+assistCtx.x+'px;top:'+assistCtx.y+'px'" @click.stop="()=>{}">
|
<SbContextMenu :pos="assistCtx">
|
||||||
<button class="sb-ctx-item" @click="assistCtxTogglePin()">
|
<button class="sb-ctx-item" @click="assistCtxTogglePin()">
|
||||||
{{ assistCtx?.job?.assistants?.find(a=>a.techId===assistCtx?.techId)?.pinned ? '↕ Rendre flottant' : '📌 Prioriser dans le timeline' }}
|
{{ assistCtx?.job?.assistants?.find(a=>a.techId===assistCtx?.techId)?.pinned ? '↕ Rendre flottant' : '📌 Prioriser dans le timeline' }}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -941,7 +865,7 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document
|
||||||
<button class="sb-ctx-item" @click="bookingOverlay={job:assistCtx.job, tech:store.technicians.find(t=>t.id===assistCtx.job.assignedTech)}; assistCtx=null">📄 Voir le job parent</button>
|
<button class="sb-ctx-item" @click="bookingOverlay={job:assistCtx.job, tech:store.technicians.find(t=>t.id===assistCtx.job.assignedTech)}; assistCtx=null">📄 Voir le job parent</button>
|
||||||
<div class="sb-ctx-sep"></div>
|
<div class="sb-ctx-sep"></div>
|
||||||
<button class="sb-ctx-item sb-ctx-warn" @click="assistCtxRemove()">✕ Retirer l'assistant</button>
|
<button class="sb-ctx-item sb-ctx-warn" @click="assistCtxRemove()">✕ Retirer l'assistant</button>
|
||||||
</div>
|
</SbContextMenu>
|
||||||
|
|
||||||
<transition name="sb-slide-up">
|
<transition name="sb-slide-up">
|
||||||
<div v-if="multiSelect.length" class="sb-multi-bar">
|
<div v-if="multiSelect.length" class="sb-multi-bar">
|
||||||
|
|
@ -956,146 +880,130 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
|
|
||||||
<div v-if="techTagModal" class="sb-overlay" @click.self="techTagModal=null">
|
<!-- Tech tags modal -->
|
||||||
<div class="sb-modal sb-modal-tags">
|
<SbModal :model-value="!!techTagModal" @update:model-value="v => { if(!v) techTagModal=null }" modal-class="sb-modal-tags" body-style="overflow:visible;min-height:320px">
|
||||||
<div class="sb-modal-hdr"><span>🏷 Tags — {{ techTagModal.fullName }}</span><button class="sb-rp-close" @click="techTagModal=null">✕</button></div>
|
<template #header><span>🏷 Tags — {{ techTagModal?.fullName }}</span></template>
|
||||||
<div class="sb-modal-body" style="overflow:visible;min-height:320px">
|
<TagEditor v-if="techTagModal" :model-value="techTagModal.tagsWithLevel || []" @update:model-value="v => { techTagModal.tagsWithLevel = v; techTagModal.tags = v.map(x => typeof x === 'string' ? x : x.tag); persistTechTags(techTagModal) }"
|
||||||
<TagEditor :model-value="techTagModal.tagsWithLevel || []" @update:model-value="v => { techTagModal.tagsWithLevel = v; techTagModal.tags = v.map(x => typeof x === 'string' ? x : x.tag); persistTechTags(techTagModal) }"
|
:all-tags="store.allTags" :get-color="getTagColor" :show-level="true"
|
||||||
:all-tags="store.allTags" :get-color="getTagColor" :show-level="true"
|
level-label="Compétence" level-hint="1 = base · 5 = expert"
|
||||||
level-label="Compétence" level-hint="1 = base · 5 = expert"
|
@create="onCreateTag" @update-tag="onUpdateTag" @rename-tag="onRenameTag" @delete-tag="onDeleteTag" />
|
||||||
@create="onCreateTag" @update-tag="onUpdateTag" @rename-tag="onRenameTag" @delete-tag="onDeleteTag" />
|
<template #footer><button class="sb-rp-primary" @click="techTagModal=null">Fermer</button></template>
|
||||||
</div>
|
</SbModal>
|
||||||
<div class="sb-modal-ftr"><button class="sb-rp-primary" @click="techTagModal=null">Fermer</button></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="assistNoteModal" class="sb-overlay" @click.self="assistNoteModal=null">
|
<!-- Assistant note modal -->
|
||||||
<div class="sb-modal">
|
<SbModal :model-value="!!assistNoteModal" @update:model-value="v => { if(!v) assistNoteModal=null }">
|
||||||
<div class="sb-modal-hdr"><span>📝 Note assistant</span><button class="sb-rp-close" @click="assistNoteModal=null">✕</button></div>
|
<template #header><span>📝 Note assistant</span></template>
|
||||||
<div class="sb-modal-body">
|
<template v-if="assistNoteModal">
|
||||||
<label class="sb-form-lbl">Titre / note pour cette tâche</label>
|
<label class="sb-form-lbl">Titre / note pour cette tâche</label>
|
||||||
<input type="text" class="sb-form-input" v-model="assistNoteModal.note" placeholder="Ex: Livraison outil, Support câblage..." @keyup.enter="confirmAssistNote" />
|
<input type="text" class="sb-form-input" v-model="assistNoteModal.note" placeholder="Ex: Livraison outil, Support câblage..." @keyup.enter="confirmAssistNote" />
|
||||||
<p style="font-size:0.6rem;color:#7b80a0;margin-top:0.3rem">Job parent : {{ assistNoteModal.job?.subject }}</p>
|
<p style="font-size:0.6rem;color:#7b80a0;margin-top:0.3rem">Job parent : {{ assistNoteModal.job?.subject }}</p>
|
||||||
</div>
|
</template>
|
||||||
<div class="sb-modal-footer"><button class="sb-rp-primary" @click="confirmAssistNote">Enregistrer</button><button class="sb-rp-btn" @click="assistNoteModal=null">Annuler</button></div>
|
<template #footer><button class="sb-rp-primary" @click="confirmAssistNote">Enregistrer</button><button class="sb-rp-btn" @click="assistNoteModal=null">Annuler</button></template>
|
||||||
</div>
|
</SbModal>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="timeModal" class="sb-overlay" @click.self="timeModal=null">
|
<!-- Time pin modal -->
|
||||||
<div class="sb-modal">
|
<SbModal :model-value="!!timeModal" @update:model-value="v => { if(!v) timeModal=null }">
|
||||||
<div class="sb-modal-hdr"><span>🕐 Heure de début fixe</span><button class="sb-rp-close" @click="timeModal=null">✕</button></div>
|
<template #header><span>🕐 Heure de début fixe</span></template>
|
||||||
<div class="sb-modal-body">
|
<template v-if="timeModal">
|
||||||
<div class="sb-form-row"><label class="sb-form-lbl">Job</label><div class="sb-form-val">{{ timeModal.job?.subject }}</div></div>
|
<div class="sb-form-row"><label class="sb-form-lbl">Job</label><div class="sb-form-val">{{ timeModal.job?.subject }}</div></div>
|
||||||
<div class="sb-form-row"><label class="sb-form-lbl">Heure fixe</label><input type="time" class="sb-form-input" v-model="timeModal.time" /></div>
|
<div class="sb-form-row"><label class="sb-form-lbl">Heure fixe</label><input type="time" class="sb-form-input" v-model="timeModal.time" /></div>
|
||||||
<div v-if="timeModal.hasPin" style="font-size:0.68rem;color:#f59e0b;margin-top:0.4rem">⚠ Heure actuellement fixée — modifier ou supprimer ci-dessous.</div>
|
<div v-if="timeModal.hasPin" style="font-size:0.68rem;color:#f59e0b;margin-top:0.4rem">⚠ Heure actuellement fixée — modifier ou supprimer ci-dessous.</div>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<button class="sbf-primary-btn" @click="confirmTime">📌 Fixer</button>
|
||||||
|
<button v-if="timeModal?.hasPin" class="sb-rp-btn" style="color:#ef4444" @click="clearTime">✕ Supprimer</button>
|
||||||
|
<button class="sb-rp-btn" @click="timeModal=null">Annuler</button>
|
||||||
|
</template>
|
||||||
|
</SbModal>
|
||||||
|
|
||||||
|
<!-- Move modal -->
|
||||||
|
<SbModal v-model="moveModalOpen">
|
||||||
|
<template #header><span>Déplacer la réservation</span></template>
|
||||||
|
<template v-if="moveForm">
|
||||||
|
<div class="sb-form-row"><label class="sb-form-lbl">Ticket</label><div class="sb-form-val">{{ moveForm.job?.subject }}</div></div>
|
||||||
|
<div class="sb-form-row"><label class="sb-form-lbl">Technicien actuel</label><div class="sb-form-val">{{ store.technicians.find(t=>t.id===moveForm.srcTechId)?.fullName || '—' }}</div></div>
|
||||||
|
<div class="sb-form-row"><label class="sb-form-lbl">Nouveau technicien</label>
|
||||||
|
<select class="sb-form-sel" v-model="moveForm.newTechId"><option v-for="t in store.technicians" :key="t.id" :value="t.id">{{ t.fullName }}</option></select>
|
||||||
</div>
|
</div>
|
||||||
<div class="sb-modal-ftr">
|
<div class="sb-form-row"><label class="sb-form-lbl">Nouvelle date</label><input type="date" class="sb-form-input" v-model="moveForm.newDate" /></div>
|
||||||
<button class="sbf-primary-btn" @click="confirmTime">📌 Fixer</button>
|
</template>
|
||||||
<button v-if="timeModal.hasPin" class="sb-rp-btn" style="color:#ef4444" @click="clearTime">✕ Supprimer</button>
|
<template #footer><button class="sbf-primary-btn" @click="confirmMove">✓ Confirmer</button><button class="sb-rp-btn" @click="moveModalOpen=false">Annuler</button></template>
|
||||||
<button class="sb-rp-btn" @click="timeModal=null">Annuler</button>
|
</SbModal>
|
||||||
|
|
||||||
|
<!-- Resource selector modal -->
|
||||||
|
<SbModal :model-value="resSelectorOpen" @update:model-value="v => resSelectorOpen=v" :wide="true">
|
||||||
|
<template #header><span>Ressources & Groupes</span></template>
|
||||||
|
<div v-if="savedPresets.length" class="sb-rsel-groups">
|
||||||
|
<div class="sb-rsel-section-title">Sélections sauvegardées</div>
|
||||||
|
<div class="sb-rsel-chips">
|
||||||
|
<button v-for="(p, idx) in savedPresets" :key="p.name" class="sb-rsel-chip sb-rsel-preset"
|
||||||
|
:class="{ active: tempSelectedIds.length && p.ids.length === tempSelectedIds.length && p.ids.every(id => tempSelectedIds.includes(id)) }"
|
||||||
|
@click="loadPreset(p)">
|
||||||
|
{{ p.name }} <span class="sb-rsel-preset-count">{{ p.ids.length }}</span>
|
||||||
|
<span class="sb-rsel-preset-del" @click.stop="deletePreset(idx)" title="Supprimer">✕</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="sb-rsel-groups">
|
||||||
|
<div class="sb-rsel-section-title">Groupes</div>
|
||||||
<div v-if="moveModalOpen" class="sb-overlay" @click.self="moveModalOpen=false">
|
<div class="sb-rsel-chips">
|
||||||
<div class="sb-modal">
|
<button class="sb-rsel-chip" :class="{ active: !resSelectorGroupFilter }" @click="resSelectorGroupFilter=''">Tous</button>
|
||||||
<div class="sb-modal-hdr"><span>Déplacer la réservation</span><button class="sb-rp-close" @click="moveModalOpen=false">✕</button></div>
|
<button v-for="g in availableGroups" :key="g" class="sb-rsel-chip"
|
||||||
<div class="sb-modal-body" v-if="moveForm">
|
:class="{ active: resSelectorGroupFilter === g }" @click="resSelectorGroupFilter = resSelectorGroupFilter === g ? '' : g">{{ g }}</button>
|
||||||
<div class="sb-form-row"><label class="sb-form-lbl">Ticket</label><div class="sb-form-val">{{ moveForm.job?.subject }}</div></div>
|
</div>
|
||||||
<div class="sb-form-row"><label class="sb-form-lbl">Technicien actuel</label><div class="sb-form-val">{{ store.technicians.find(t=>t.id===moveForm.srcTechId)?.fullName || '—' }}</div></div>
|
<div class="sb-rsel-group-actions">
|
||||||
<div class="sb-form-row"><label class="sb-form-lbl">Nouveau technicien</label>
|
<button v-if="resSelectorGroupFilter" class="sb-rsel-apply-group" @click="applyGroupFilter">
|
||||||
<select class="sb-form-sel" v-model="moveForm.newTechId"><option v-for="t in store.technicians" :key="t.id" :value="t.id">{{ t.fullName }}</option></select>
|
Afficher seulement « {{ resSelectorGroupFilter }} »
|
||||||
</div>
|
</button>
|
||||||
<div class="sb-form-row"><label class="sb-form-lbl">Nouvelle date</label><input type="date" class="sb-form-input" v-model="moveForm.newDate" /></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="sb-modal-ftr"><button class="sbf-primary-btn" @click="confirmMove">✓ Confirmer</button><button class="sb-rp-btn" @click="moveModalOpen=false">Annuler</button></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="sb-rsel-search-row">
|
||||||
|
<input v-model="resSelectorSearch" class="sb-rsel-search" placeholder="🔍 Rechercher une ressource…" />
|
||||||
<div v-if="resSelectorOpen" class="sb-overlay" @click.self="resSelectorOpen=false">
|
</div>
|
||||||
<div class="sb-modal sb-modal-wide">
|
<div class="sb-res-sel-wrap">
|
||||||
<div class="sb-modal-hdr"><span>Ressources & Groupes</span><button class="sb-rp-close" @click="resSelectorOpen=false">✕</button></div>
|
<div class="sb-res-sel-col">
|
||||||
<div class="sb-modal-body">
|
<div class="sb-res-sel-hdr">Disponibles</div>
|
||||||
<!-- Saved presets -->
|
<template v-for="group in resSelectorGroupsFiltered.available" :key="'avail-'+group.name">
|
||||||
<div v-if="savedPresets.length" class="sb-rsel-groups">
|
<div v-if="group.techs.length" class="sb-res-sel-group-hdr">{{ group.label }} <span class="sbf-count">{{ group.techs.length }}</span></div>
|
||||||
<div class="sb-rsel-section-title">Sélections sauvegardées</div>
|
<div v-for="t in group.techs" :key="t.id" class="sb-res-sel-item" @click="toggleTempRes(t.id)">
|
||||||
<div class="sb-rsel-chips">
|
<div v-if="t.resourceType==='material'" class="sb-avatar-xs sb-avatar-material">{{ resIcon(t) }}</div>
|
||||||
<button v-for="(p, idx) in savedPresets" :key="p.name" class="sb-rsel-chip sb-rsel-preset"
|
<div v-else class="sb-avatar-xs" :style="'background:'+TECH_COLORS[t.colorIdx]">{{ t.fullName.split(' ').map(n=>n[0]).join('').toUpperCase().slice(0,2) }}</div>
|
||||||
:class="{ active: tempSelectedIds.length && p.ids.length === tempSelectedIds.length && p.ids.every(id => tempSelectedIds.includes(id)) }"
|
<span class="sb-res-sel-name">{{ t.fullName }}</span>
|
||||||
@click="loadPreset(p)">
|
<span v-if="t.resourceCategory" class="sb-res-sel-cat-tag">{{ t.resourceCategory }}</span>
|
||||||
{{ p.name }} <span class="sb-rsel-preset-count">{{ p.ids.length }}</span>
|
<span v-else-if="t.group" class="sb-res-sel-grp-tag">{{ t.group }}</span>
|
||||||
<span class="sb-rsel-preset-del" @click.stop="deletePreset(idx)" title="Supprimer">✕</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Group chips -->
|
|
||||||
<div class="sb-rsel-groups">
|
|
||||||
<div class="sb-rsel-section-title">Groupes</div>
|
|
||||||
<div class="sb-rsel-chips">
|
|
||||||
<button class="sb-rsel-chip" :class="{ active: !resSelectorGroupFilter }" @click="resSelectorGroupFilter=''">Tous</button>
|
|
||||||
<button v-for="g in availableGroups" :key="g" class="sb-rsel-chip"
|
|
||||||
:class="{ active: resSelectorGroupFilter === g }" @click="resSelectorGroupFilter = resSelectorGroupFilter === g ? '' : g">{{ g }}</button>
|
|
||||||
</div>
|
|
||||||
<div class="sb-rsel-group-actions">
|
|
||||||
<button v-if="resSelectorGroupFilter" class="sb-rsel-apply-group" @click="applyGroupFilter">
|
|
||||||
Afficher seulement « {{ resSelectorGroupFilter }} »
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Search -->
|
|
||||||
<div class="sb-rsel-search-row">
|
|
||||||
<input v-model="resSelectorSearch" class="sb-rsel-search" placeholder="🔍 Rechercher une ressource…" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Resource list -->
|
|
||||||
<div class="sb-res-sel-wrap">
|
|
||||||
<div class="sb-res-sel-col">
|
|
||||||
<div class="sb-res-sel-hdr">Disponibles</div>
|
|
||||||
<template v-for="group in resSelectorGroupsFiltered.available" :key="'avail-'+group.name">
|
|
||||||
<div v-if="group.techs.length" class="sb-res-sel-group-hdr">{{ group.label }} <span class="sbf-count">{{ group.techs.length }}</span></div>
|
|
||||||
<div v-for="t in group.techs" :key="t.id" class="sb-res-sel-item" @click="toggleTempRes(t.id)">
|
|
||||||
<div v-if="t.resourceType==='material'" class="sb-avatar-xs sb-avatar-material">{{ resIcon(t) }}</div>
|
|
||||||
<div v-else class="sb-avatar-xs" :style="'background:'+TECH_COLORS[t.colorIdx]">{{ t.fullName.split(' ').map(n=>n[0]).join('').toUpperCase().slice(0,2) }}</div>
|
|
||||||
<span class="sb-res-sel-name">{{ t.fullName }}</span>
|
|
||||||
<span v-if="t.resourceCategory" class="sb-res-sel-cat-tag">{{ t.resourceCategory }}</span>
|
|
||||||
<span v-else-if="t.group" class="sb-res-sel-grp-tag">{{ t.group }}</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<div v-if="!resSelectorGroupsFiltered.available.flatMap(g=>g.techs).length" class="sbf-empty">Toutes sélectionnées</div>
|
|
||||||
</div>
|
|
||||||
<div class="sb-res-sel-arrow">→</div>
|
|
||||||
<div class="sb-res-sel-col">
|
|
||||||
<div class="sb-res-sel-hdr">Sélectionnées <span class="sbf-count">{{ tempSelectedIds.length || 'Toutes' }}</span></div>
|
|
||||||
<template v-for="group in resSelectorGroupsFiltered.selected" :key="'sel-'+group.name">
|
|
||||||
<div v-if="group.techs.length" class="sb-res-sel-group-hdr">{{ group.label }}</div>
|
|
||||||
<div v-for="t in group.techs" :key="t.id" class="sb-res-sel-item sb-res-sel-active" @click="toggleTempRes(t.id)">
|
|
||||||
<div v-if="t.resourceType==='material'" class="sb-avatar-xs sb-avatar-material">{{ resIcon(t) }}</div>
|
|
||||||
<div v-else class="sb-avatar-xs" :style="'background:'+TECH_COLORS[t.colorIdx]">{{ t.fullName.split(' ').map(n=>n[0]).join('').toUpperCase().slice(0,2) }}</div>
|
|
||||||
<span class="sb-res-sel-name">{{ t.fullName }}</span><span class="sb-res-sel-rm">✕</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<div v-if="!tempSelectedIds.length" class="sbf-empty">Toutes affichées</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="sb-modal-ftr">
|
|
||||||
<button class="sbf-primary-btn" @click="applyResSelector">Appliquer</button>
|
|
||||||
<template v-if="tempSelectedIds.length">
|
|
||||||
<button v-if="!showPresetSave" class="sb-rp-btn" @click="showPresetSave=true">💾 Sauvegarder</button>
|
|
||||||
<div v-else class="sb-rsel-save-row">
|
|
||||||
<input v-model="presetNameInput" class="sb-rsel-save-input" placeholder="Nom du groupe…"
|
|
||||||
@keyup.enter="savePreset" @keyup.escape="showPresetSave=false" />
|
|
||||||
<button class="sb-rsel-save-btn" @click="savePreset" :disabled="!presetNameInput.trim()">✓</button>
|
|
||||||
<button class="sb-rp-btn" style="padding:0.2rem 0.4rem" @click="showPresetSave=false">✕</button>
|
|
||||||
</div>
|
|
||||||
<button class="sb-rp-btn" @click="tempSelectedIds=[]">Tout désélectionner</button>
|
|
||||||
</template>
|
</template>
|
||||||
<button class="sb-rp-btn" @click="resSelectorOpen=false">Annuler</button>
|
<div v-if="!resSelectorGroupsFiltered.available.flatMap(g=>g.techs).length" class="sbf-empty">Toutes sélectionnées</div>
|
||||||
|
</div>
|
||||||
|
<div class="sb-res-sel-arrow">→</div>
|
||||||
|
<div class="sb-res-sel-col">
|
||||||
|
<div class="sb-res-sel-hdr">Sélectionnées <span class="sbf-count">{{ tempSelectedIds.length || 'Toutes' }}</span></div>
|
||||||
|
<template v-for="group in resSelectorGroupsFiltered.selected" :key="'sel-'+group.name">
|
||||||
|
<div v-if="group.techs.length" class="sb-res-sel-group-hdr">{{ group.label }}</div>
|
||||||
|
<div v-for="t in group.techs" :key="t.id" class="sb-res-sel-item sb-res-sel-active" @click="toggleTempRes(t.id)">
|
||||||
|
<div v-if="t.resourceType==='material'" class="sb-avatar-xs sb-avatar-material">{{ resIcon(t) }}</div>
|
||||||
|
<div v-else class="sb-avatar-xs" :style="'background:'+TECH_COLORS[t.colorIdx]">{{ t.fullName.split(' ').map(n=>n[0]).join('').toUpperCase().slice(0,2) }}</div>
|
||||||
|
<span class="sb-res-sel-name">{{ t.fullName }}</span><span class="sb-res-sel-rm">✕</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-if="!tempSelectedIds.length" class="sbf-empty">Toutes affichées</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<template #footer>
|
||||||
|
<button class="sbf-primary-btn" @click="applyResSelector">Appliquer</button>
|
||||||
|
<template v-if="tempSelectedIds.length">
|
||||||
|
<button v-if="!showPresetSave" class="sb-rp-btn" @click="showPresetSave=true">💾 Sauvegarder</button>
|
||||||
|
<div v-else class="sb-rsel-save-row">
|
||||||
|
<input v-model="presetNameInput" class="sb-rsel-save-input" placeholder="Nom du groupe…"
|
||||||
|
@keyup.enter="savePreset" @keyup.escape="showPresetSave=false" />
|
||||||
|
<button class="sb-rsel-save-btn" @click="savePreset" :disabled="!presetNameInput.trim()">✓</button>
|
||||||
|
<button class="sb-rp-btn" style="padding:0.2rem 0.4rem" @click="showPresetSave=false">✕</button>
|
||||||
|
</div>
|
||||||
|
<button class="sb-rp-btn" @click="tempSelectedIds=[]">Tout désélectionner</button>
|
||||||
|
</template>
|
||||||
|
<button class="sb-rp-btn" @click="resSelectorOpen=false">Annuler</button>
|
||||||
|
</template>
|
||||||
|
</SbModal>
|
||||||
|
|
||||||
<UnifiedCreateModal v-model="woModalOpen" mode="work-order"
|
<UnifiedCreateModal v-model="woModalOpen" mode="work-order"
|
||||||
:context="woModalCtx"
|
:context="woModalCtx"
|
||||||
|
|
@ -1109,32 +1017,30 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document
|
||||||
:period-start="periodStart" :period-end="periodEndStr"
|
:period-start="periodStart" :period-end="periodEndStr"
|
||||||
@published="onPublished" />
|
@published="onPublished" />
|
||||||
|
|
||||||
<div v-if="dispatchCriteriaModal" class="sb-overlay" @click.self="dispatchCriteriaModal=false">
|
<!-- Dispatch criteria modal -->
|
||||||
<div class="sb-modal">
|
<SbModal :model-value="dispatchCriteriaModal" @update:model-value="v => dispatchCriteriaModal=v">
|
||||||
<div class="sb-modal-hdr"><span>⚙ Critères de dispatch automatique</span><button class="sb-rp-close" @click="dispatchCriteriaModal=false">✕</button></div>
|
<template #header><span>⚙ Critères de dispatch automatique</span></template>
|
||||||
<div class="sb-modal-body">
|
<p style="font-size:0.65rem;color:var(--sb-muted);margin:0 0 0.5rem">Glissez pour réordonner. Les critères du haut ont plus de poids.</p>
|
||||||
<p style="font-size:0.65rem;color:var(--sb-muted);margin:0 0 0.5rem">Glissez pour réordonner. Les critères du haut ont plus de poids.</p>
|
<div v-for="(c, i) in dispatchCriteria" :key="c.id" class="sb-crit-row"
|
||||||
<div v-for="(c, i) in dispatchCriteria" :key="c.id" class="sb-crit-row"
|
draggable="true"
|
||||||
draggable="true"
|
:class="{ 'sb-crit-drag-over': critDragOver === i }"
|
||||||
:class="{ 'sb-crit-drag-over': critDragOver === i }"
|
@dragstart="critDragIdx = i; $event.dataTransfer.effectAllowed = 'move'"
|
||||||
@dragstart="critDragIdx = i; $event.dataTransfer.effectAllowed = 'move'"
|
@dragend="critDragIdx = null; critDragOver = null"
|
||||||
@dragend="critDragIdx = null; critDragOver = null"
|
@dragover.prevent="critDragOver = i"
|
||||||
@dragover.prevent="critDragOver = i"
|
@dragleave="critDragOver === i && (critDragOver = null)"
|
||||||
@dragleave="critDragOver === i && (critDragOver = null)"
|
@drop.prevent="dropCriterion(i)">
|
||||||
@drop.prevent="dropCriterion(i)">
|
<span class="sb-crit-handle" title="Glisser">⠿</span>
|
||||||
<span class="sb-crit-handle" title="Glisser">⠿</span>
|
<span class="sb-crit-order">{{ i + 1 }}</span>
|
||||||
<span class="sb-crit-order">{{ i + 1 }}</span>
|
<label class="sb-crit-label"><input type="checkbox" v-model="c.enabled" />{{ c.label }}</label>
|
||||||
<label class="sb-crit-label"><input type="checkbox" v-model="c.enabled" />{{ c.label }}</label>
|
<div class="sb-crit-arrows">
|
||||||
<div class="sb-crit-arrows">
|
<button :disabled="i===0" @click="moveCriterion(i,-1)">▲</button>
|
||||||
<button :disabled="i===0" @click="moveCriterion(i,-1)">▲</button>
|
<button :disabled="i===dispatchCriteria.length-1" @click="moveCriterion(i,1)">▼</button>
|
||||||
<button :disabled="i===dispatchCriteria.length-1" @click="moveCriterion(i,1)">▼</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="sb-modal-ftr"><button class="sbf-primary-btn" @click="saveDispatchCriteria">✓ Enregistrer</button><button class="sb-rp-btn" @click="dispatchCriteriaModal=false">Annuler</button></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<template #footer><button class="sbf-primary-btn" @click="saveDispatchCriteria">✓ Enregistrer</button><button class="sb-rp-btn" @click="dispatchCriteriaModal=false">Annuler</button></template>
|
||||||
|
</SbModal>
|
||||||
|
|
||||||
|
<!-- GPS settings modal (custom structure, not using SbModal) -->
|
||||||
<div v-if="gpsSettingsOpen" class="sb-modal-overlay" @click.self="gpsSettingsOpen=false">
|
<div v-if="gpsSettingsOpen" class="sb-modal-overlay" @click.self="gpsSettingsOpen=false">
|
||||||
<div class="sb-gps-modal">
|
<div class="sb-gps-modal">
|
||||||
<div class="sb-gps-modal-hdr">
|
<div class="sb-gps-modal-hdr">
|
||||||
|
|
@ -1220,67 +1126,58 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Absence Modal (z-index above GPS modal) -->
|
<!-- Absence modal -->
|
||||||
<div v-if="absenceModalOpen" class="sb-overlay sb-overlay-top" @click.self="absenceModalOpen = false">
|
<SbModal :model-value="absenceModalOpen" @update:model-value="v => absenceModalOpen=v" overlay-class="sb-overlay-top" modal-class="sb-absence-modal">
|
||||||
<div class="sb-modal sb-absence-modal">
|
<template #header><span>⏸ Mettre en absence — {{ absenceModalTech?.fullName }}</span></template>
|
||||||
<div class="sb-modal-hdr">
|
<div class="sb-absence-form">
|
||||||
<span>⏸ Mettre en absence — {{ absenceModalTech?.fullName }}</span>
|
<label class="sb-absence-lbl">Raison</label>
|
||||||
<button class="sb-rp-close" @click="absenceModalOpen = false">✕</button>
|
<div class="sb-absence-reasons">
|
||||||
|
<button v-for="r in ABSENCE_REASONS" :key="r.value"
|
||||||
|
class="sb-absence-reason-btn" :class="{ active: absenceForm.reason === r.value }"
|
||||||
|
@click="absenceForm.reason = r.value">
|
||||||
|
{{ r.icon }} {{ r.label }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="sb-modal-body">
|
<div class="sb-absence-dates">
|
||||||
<div class="sb-absence-form">
|
<div>
|
||||||
<label class="sb-absence-lbl">Raison</label>
|
<label class="sb-absence-lbl">Du</label>
|
||||||
<div class="sb-absence-reasons">
|
<input type="date" class="sb-form-input" v-model="absenceForm.from" />
|
||||||
<button v-for="r in ABSENCE_REASONS" :key="r.value"
|
</div>
|
||||||
class="sb-absence-reason-btn" :class="{ active: absenceForm.reason === r.value }"
|
<div>
|
||||||
@click="absenceForm.reason = r.value">
|
<label class="sb-absence-lbl">Jusqu'au <span class="sb-absence-opt">(optionnel)</span></label>
|
||||||
{{ r.icon }} {{ r.label }}
|
<input type="date" class="sb-form-input" v-model="absenceForm.until" />
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sb-absence-dates">
|
|
||||||
<div>
|
|
||||||
<label class="sb-absence-lbl">Du</label>
|
|
||||||
<input type="date" class="sb-form-input" v-model="absenceForm.from" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="sb-absence-lbl">Jusqu'au <span class="sb-absence-opt">(optionnel)</span></label>
|
|
||||||
<input type="date" class="sb-form-input" v-model="absenceForm.until" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="store.jobs.filter(j => j.assignedTech === absenceModalTech?.id).length" class="sb-absence-jobs">
|
|
||||||
<label class="sb-absence-lbl">
|
|
||||||
{{ store.jobs.filter(j => j.assignedTech === absenceModalTech?.id).length }} job(s) assigné(s)
|
|
||||||
</label>
|
|
||||||
<div class="sb-absence-job-actions">
|
|
||||||
<label class="sb-absence-radio">
|
|
||||||
<input type="radio" v-model="absenceForm.jobAction" value="unassign" />
|
|
||||||
Désassigner (retour dans le pool non assigné)
|
|
||||||
</label>
|
|
||||||
<label class="sb-absence-radio">
|
|
||||||
<input type="radio" v-model="absenceForm.jobAction" value="keep" />
|
|
||||||
Garder assignés (réassigner manuellement après)
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="sb-absence-job-list">
|
|
||||||
<div v-for="j in store.jobs.filter(j => j.assignedTech === absenceModalTech?.id)" :key="j.id" class="sb-absence-job-item">
|
|
||||||
<span class="sb-absence-job-dot" :style="{ background: j.priority === 'urgent' ? '#ef4444' : j.priority === 'high' ? '#f59e0b' : '#6366f1' }"></span>
|
|
||||||
{{ j.subject }} <span class="sb-absence-job-date">{{ j.scheduledDate || '—' }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else class="sb-absence-no-jobs">Aucun job assigné actuellement.</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="sb-modal-ftr">
|
<div v-if="store.jobs.filter(j => j.assignedTech === absenceModalTech?.id).length" class="sb-absence-jobs">
|
||||||
<button class="sbf-primary-btn" @click="confirmAbsence" :disabled="absenceProcessing">
|
<label class="sb-absence-lbl">
|
||||||
{{ absenceProcessing ? 'En cours...' : '⏸ Confirmer l\'absence' }}
|
{{ store.jobs.filter(j => j.assignedTech === absenceModalTech?.id).length }} job(s) assigné(s)
|
||||||
</button>
|
</label>
|
||||||
<button class="sb-rp-btn" @click="absenceModalOpen = false">Annuler</button>
|
<div class="sb-absence-job-actions">
|
||||||
|
<label class="sb-absence-radio">
|
||||||
|
<input type="radio" v-model="absenceForm.jobAction" value="unassign" />
|
||||||
|
Désassigner (retour dans le pool non assigné)
|
||||||
|
</label>
|
||||||
|
<label class="sb-absence-radio">
|
||||||
|
<input type="radio" v-model="absenceForm.jobAction" value="keep" />
|
||||||
|
Garder assignés (réassigner manuellement après)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="sb-absence-job-list">
|
||||||
|
<div v-for="j in store.jobs.filter(j => j.assignedTech === absenceModalTech?.id)" :key="j.id" class="sb-absence-job-item">
|
||||||
|
<span class="sb-absence-job-dot" :style="{ background: j.priority === 'urgent' ? '#ef4444' : j.priority === 'high' ? '#f59e0b' : '#6366f1' }"></span>
|
||||||
|
{{ j.subject }} <span class="sb-absence-job-date">{{ j.scheduledDate || '—' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else class="sb-absence-no-jobs">Aucun job assigné actuellement.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<template #footer>
|
||||||
|
<button class="sbf-primary-btn" @click="confirmAbsence" :disabled="absenceProcessing">
|
||||||
|
{{ absenceProcessing ? 'En cours...' : '⏸ Confirmer l\'absence' }}
|
||||||
|
</button>
|
||||||
|
<button class="sb-rp-btn" @click="absenceModalOpen = false">Annuler</button>
|
||||||
|
</template>
|
||||||
|
</SbModal>
|
||||||
|
|
||||||
<!-- Schedule editor modal -->
|
<!-- Schedule editor modal -->
|
||||||
<div v-if="scheduleModalTech" class="sb-overlay sb-overlay-top" @click.self="scheduleModalTech = null">
|
<div v-if="scheduleModalTech" class="sb-overlay sb-overlay-top" @click.self="scheduleModalTech = null">
|
||||||
|
|
|
||||||
|
|
@ -10,22 +10,16 @@
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
|
||||||
<!-- ════════════════════════════════════════════════
|
<!-- SECTION 1: Utilisateurs & Permissions -->
|
||||||
SECTION 1: Utilisateurs & Permissions
|
|
||||||
════════════════════════════════════════════════ -->
|
|
||||||
<div v-if="can('manage_permissions')" class="ops-card q-mb-md">
|
<div v-if="can('manage_permissions')" class="ops-card q-mb-md">
|
||||||
<q-expansion-item header-class="section-header" expand-icon-class="text-grey-7">
|
<q-expansion-item header-class="section-header" expand-icon-class="text-grey-7">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="row items-center no-wrap" style="width:100%">
|
<SectionHeader icon="admin_panel_settings" label="Utilisateurs & Permissions" />
|
||||||
<q-icon name="admin_panel_settings" size="22px" color="indigo-6" class="q-mr-sm" />
|
|
||||||
<span class="text-subtitle1 text-weight-bold">Utilisateurs & Permissions</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<q-separator />
|
<q-separator />
|
||||||
<div class="q-pa-md">
|
<div class="q-pa-md">
|
||||||
|
|
||||||
<!-- ── Tab navigation ── -->
|
|
||||||
<q-tabs v-model="permTab" dense no-caps active-color="indigo-6" indicator-color="indigo-6"
|
<q-tabs v-model="permTab" dense no-caps active-color="indigo-6" indicator-color="indigo-6"
|
||||||
class="q-mb-md text-grey-7" align="left">
|
class="q-mb-md text-grey-7" align="left">
|
||||||
<q-tab name="users" icon="people" label="Utilisateurs" />
|
<q-tab name="users" icon="people" label="Utilisateurs" />
|
||||||
|
|
@ -33,7 +27,7 @@
|
||||||
<q-tab name="matrix" icon="grid_on" label="Matrice" />
|
<q-tab name="matrix" icon="grid_on" label="Matrice" />
|
||||||
</q-tabs>
|
</q-tabs>
|
||||||
|
|
||||||
<!-- ═══ TAB: Utilisateurs ═══ -->
|
<!-- TAB: Utilisateurs -->
|
||||||
<div v-show="permTab === 'users'">
|
<div v-show="permTab === 'users'">
|
||||||
<div class="row q-gutter-sm items-end q-mb-md">
|
<div class="row q-gutter-sm items-end q-mb-md">
|
||||||
<q-input v-model="userSearch" label="Rechercher un utilisateur (nom, email)..." outlined dense
|
<q-input v-model="userSearch" label="Rechercher un utilisateur (nom, email)..." outlined dense
|
||||||
|
|
@ -46,7 +40,6 @@
|
||||||
</q-input>
|
</q-input>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- User list -->
|
|
||||||
<div v-if="userResults.length" class="user-list">
|
<div v-if="userResults.length" class="user-list">
|
||||||
<div v-for="u in userResults" :key="u.pk" class="user-card"
|
<div v-for="u in userResults" :key="u.pk" class="user-card"
|
||||||
:class="{ 'user-card--selected': selectedUser?.pk === u.pk }"
|
:class="{ 'user-card--selected': selectedUser?.pk === u.pk }"
|
||||||
|
|
@ -54,7 +47,7 @@
|
||||||
<div class="row items-center no-wrap">
|
<div class="row items-center no-wrap">
|
||||||
<q-avatar size="36px" :color="u.is_active ? 'indigo-1' : 'grey-3'"
|
<q-avatar size="36px" :color="u.is_active ? 'indigo-1' : 'grey-3'"
|
||||||
:text-color="u.is_active ? 'indigo-8' : 'grey-6'" class="q-mr-sm">
|
:text-color="u.is_active ? 'indigo-8' : 'grey-6'" class="q-mr-sm">
|
||||||
{{ (u.name || u.username || '?')[0].toUpperCase() }}
|
{{ initial(u) }}
|
||||||
</q-avatar>
|
</q-avatar>
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<div class="text-weight-bold text-body2">{{ u.name || u.username }}</div>
|
<div class="text-weight-bold text-body2">{{ u.name || u.username }}</div>
|
||||||
|
|
@ -79,7 +72,7 @@
|
||||||
<div v-if="selectedUser" class="user-detail-panel q-mt-md">
|
<div v-if="selectedUser" class="user-detail-panel q-mt-md">
|
||||||
<div class="row items-center q-mb-md">
|
<div class="row items-center q-mb-md">
|
||||||
<q-avatar size="42px" color="indigo-1" text-color="indigo-8" class="q-mr-md">
|
<q-avatar size="42px" color="indigo-1" text-color="indigo-8" class="q-mr-md">
|
||||||
{{ (selectedUser.name || selectedUser.username || '?')[0].toUpperCase() }}
|
{{ initial(selectedUser) }}
|
||||||
</q-avatar>
|
</q-avatar>
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<div class="text-h6">{{ selectedUser.name || selectedUser.username }}</div>
|
<div class="text-h6">{{ selectedUser.name || selectedUser.username }}</div>
|
||||||
|
|
@ -110,28 +103,25 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Permission overrides (collapsible) -->
|
<!-- Permission overrides -->
|
||||||
<q-expansion-item dense label="Overrides individuels" caption="Forcer ON/OFF par capacite"
|
<q-expansion-item dense label="Overrides individuels" caption="Forcer ON/OFF par capacite"
|
||||||
icon="tune" header-class="text-grey-7" class="q-mb-sm"
|
icon="tune" header-class="text-grey-7" class="q-mb-sm"
|
||||||
:default-opened="Object.keys(selectedUser.overrides).length > 0">
|
:default-opened="Object.keys(selectedUser.overrides).length > 0">
|
||||||
<div class="q-pa-sm">
|
<div class="q-pa-sm">
|
||||||
<div class="override-grid">
|
<PermGrid :categories="permCategories" :caps-by-category="permCapsByCategory">
|
||||||
<template v-for="cat in permCategories" :key="cat">
|
<template #cap="{ cap }">
|
||||||
<div class="override-cat-label">{{ cat }}</div>
|
<div class="perm-override-chip"
|
||||||
<div class="row q-gutter-xs flex-wrap q-mb-xs">
|
:class="{
|
||||||
<div v-for="cap in permCapsByCategory[cat]" :key="cap.key" class="perm-override-chip"
|
'perm-override-on': selectedUser.overrides[cap.key] === true,
|
||||||
:class="{
|
'perm-override-off': selectedUser.overrides[cap.key] === false
|
||||||
'perm-override-on': selectedUser.overrides[cap.key] === true,
|
}">
|
||||||
'perm-override-off': selectedUser.overrides[cap.key] === false
|
<q-checkbox v-model="selectedUser.overrides[cap.key]" dense size="sm" color="orange-8"
|
||||||
}">
|
indeterminate-value="undefined" toggle-indeterminate
|
||||||
<q-checkbox v-model="selectedUser.overrides[cap.key]" dense size="sm" color="orange-8"
|
@update:model-value="v => setUserOverride(selectedUser, cap.key, v)" />
|
||||||
indeterminate-value="undefined" toggle-indeterminate
|
<span class="text-caption">{{ cap.label }}</span>
|
||||||
@update:model-value="v => setUserOverride(selectedUser, cap.key, v)" />
|
|
||||||
<span class="text-caption">{{ cap.label }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</PermGrid>
|
||||||
<div class="text-caption text-grey-5 q-mt-xs">
|
<div class="text-caption text-grey-5 q-mt-xs">
|
||||||
Indetermine = herite du groupe. Coche = force ON. Decoche = force OFF.
|
Indetermine = herite du groupe. Coche = force ON. Decoche = force OFF.
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -162,7 +152,7 @@
|
||||||
</q-slide-transition>
|
</q-slide-transition>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ═══ TAB: Groupes ═══ -->
|
<!-- TAB: Groupes -->
|
||||||
<div v-show="permTab === 'groups'">
|
<div v-show="permTab === 'groups'">
|
||||||
<!-- Legacy sync banner -->
|
<!-- Legacy sync banner -->
|
||||||
<div class="row items-center q-mb-md q-pa-sm" style="background:#fef3c7;border-radius:8px;border:1px solid #fde68a">
|
<div class="row items-center q-mb-md q-pa-sm" style="background:#fef3c7;border-radius:8px;border:1px solid #fde68a">
|
||||||
|
|
@ -189,7 +179,6 @@
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
|
||||||
<q-card-section v-if="syncResult" class="col" style="overflow-y:auto">
|
<q-card-section v-if="syncResult" class="col" style="overflow-y:auto">
|
||||||
<!-- Summary -->
|
|
||||||
<div class="row q-gutter-sm q-mb-md">
|
<div class="row q-gutter-sm q-mb-md">
|
||||||
<q-badge color="indigo-1" text-color="indigo-8" class="q-pa-sm">
|
<q-badge color="indigo-1" text-color="indigo-8" class="q-pa-sm">
|
||||||
<q-icon name="add_circle" size="14px" class="q-mr-xs" />
|
<q-icon name="add_circle" size="14px" class="q-mr-xs" />
|
||||||
|
|
@ -209,7 +198,6 @@
|
||||||
</q-badge>
|
</q-badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- To sync / synced -->
|
|
||||||
<div v-if="syncResult.matched.length" class="q-mb-md">
|
<div v-if="syncResult.matched.length" class="q-mb-md">
|
||||||
<div class="text-subtitle2 q-mb-xs">
|
<div class="text-subtitle2 q-mb-xs">
|
||||||
{{ syncResult.dry_run ? 'Utilisateurs a ajouter' : 'Utilisateurs synchronises' }}
|
{{ syncResult.dry_run ? 'Utilisateurs a ajouter' : 'Utilisateurs synchronises' }}
|
||||||
|
|
@ -225,7 +213,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Not found -->
|
|
||||||
<div v-if="syncResult.not_found.length" class="q-mb-md">
|
<div v-if="syncResult.not_found.length" class="q-mb-md">
|
||||||
<div class="text-subtitle2 q-mb-xs text-orange-8">Non trouves dans Authentik</div>
|
<div class="text-subtitle2 q-mb-xs text-orange-8">Non trouves dans Authentik</div>
|
||||||
<div v-for="m in syncResult.not_found" :key="m.email" class="row items-center q-py-xs"
|
<div v-for="m in syncResult.not_found" :key="m.email" class="row items-center q-py-xs"
|
||||||
|
|
@ -238,7 +225,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Already OK -->
|
|
||||||
<q-expansion-item v-if="syncResult.already_ok.length" dense
|
<q-expansion-item v-if="syncResult.already_ok.length" dense
|
||||||
:label="`${syncResult.already_ok.length} deja dans le bon groupe`"
|
:label="`${syncResult.already_ok.length} deja dans le bon groupe`"
|
||||||
icon="check_circle" header-class="text-green-7 text-caption">
|
icon="check_circle" header-class="text-green-7 text-caption">
|
||||||
|
|
@ -249,7 +235,6 @@
|
||||||
</div>
|
</div>
|
||||||
</q-expansion-item>
|
</q-expansion-item>
|
||||||
|
|
||||||
<!-- Action button for dry run -->
|
|
||||||
<div v-if="syncResult.dry_run && syncResult.summary.to_sync > 0" class="q-mt-md text-center">
|
<div v-if="syncResult.dry_run && syncResult.summary.to_sync > 0" class="q-mt-md text-center">
|
||||||
<q-btn color="indigo-6" icon="sync" label="Appliquer la synchronisation"
|
<q-btn color="indigo-6" icon="sync" label="Appliquer la synchronisation"
|
||||||
unelevated :loading="legacySyncing" @click="showSyncDialog = false; syncLegacy(false)" />
|
unelevated :loading="legacySyncing" @click="showSyncDialog = false; syncLegacy(false)" />
|
||||||
|
|
@ -285,7 +270,6 @@
|
||||||
<q-btn flat round dense icon="close" @click="selectedGroup = null; groupMembers = null" />
|
<q-btn flat round dense icon="close" @click="selectedGroup = null; groupMembers = null" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Members list -->
|
|
||||||
<div class="text-subtitle2 q-mb-xs">Membres</div>
|
<div class="text-subtitle2 q-mb-xs">Membres</div>
|
||||||
<div v-if="groupMembersLoading" class="text-caption text-grey-5">
|
<div v-if="groupMembersLoading" class="text-caption text-grey-5">
|
||||||
<q-spinner size="14px" class="q-mr-xs" /> Chargement...
|
<q-spinner size="14px" class="q-mr-xs" /> Chargement...
|
||||||
|
|
@ -294,7 +278,7 @@
|
||||||
<div v-for="m in groupMembers" :key="m.pk" class="row items-center q-py-xs"
|
<div v-for="m in groupMembers" :key="m.pk" class="row items-center q-py-xs"
|
||||||
style="border-bottom:1px solid #f1f5f9">
|
style="border-bottom:1px solid #f1f5f9">
|
||||||
<q-avatar size="28px" color="indigo-1" text-color="indigo-8" class="q-mr-sm" style="font-size:0.7rem">
|
<q-avatar size="28px" color="indigo-1" text-color="indigo-8" class="q-mr-sm" style="font-size:0.7rem">
|
||||||
{{ (m.name || m.username || '?')[0].toUpperCase() }}
|
{{ initial(m) }}
|
||||||
</q-avatar>
|
</q-avatar>
|
||||||
<span class="text-body2">{{ m.name || m.username }}</span>
|
<span class="text-body2">{{ m.name || m.username }}</span>
|
||||||
<span class="text-caption text-grey-6 q-ml-sm">{{ m.email }}</span>
|
<span class="text-caption text-grey-6 q-ml-sm">{{ m.email }}</span>
|
||||||
|
|
@ -320,12 +304,11 @@
|
||||||
@click="addMemberSearch = ''; memberSearchResults = []" />
|
@click="addMemberSearch = ''; memberSearchResults = []" />
|
||||||
</template>
|
</template>
|
||||||
</q-input>
|
</q-input>
|
||||||
<!-- Dropdown results -->
|
|
||||||
<div v-if="memberSearchResults.length" class="member-search-dropdown">
|
<div v-if="memberSearchResults.length" class="member-search-dropdown">
|
||||||
<div v-for="u in memberSearchResults" :key="u.pk" class="member-search-item"
|
<div v-for="u in memberSearchResults" :key="u.pk" class="member-search-item"
|
||||||
@click="addUserToCurrentGroup(u)">
|
@click="addUserToCurrentGroup(u)">
|
||||||
<q-avatar size="24px" color="indigo-1" text-color="indigo-8" class="q-mr-sm" style="font-size:0.65rem">
|
<q-avatar size="24px" color="indigo-1" text-color="indigo-8" class="q-mr-sm" style="font-size:0.65rem">
|
||||||
{{ (u.name || u.username || '?')[0].toUpperCase() }}
|
{{ initial(u) }}
|
||||||
</q-avatar>
|
</q-avatar>
|
||||||
<span class="text-body2">{{ u.name || u.username }}</span>
|
<span class="text-body2">{{ u.name || u.username }}</span>
|
||||||
<span class="text-caption text-grey-6 q-ml-sm">{{ u.email }}</span>
|
<span class="text-caption text-grey-6 q-ml-sm">{{ u.email }}</span>
|
||||||
|
|
@ -338,19 +321,16 @@
|
||||||
<q-expansion-item dense label="Permissions du groupe" icon="security"
|
<q-expansion-item dense label="Permissions du groupe" icon="security"
|
||||||
header-class="text-grey-7 q-mt-md" default-opened>
|
header-class="text-grey-7 q-mt-md" default-opened>
|
||||||
<div class="q-pa-sm">
|
<div class="q-pa-sm">
|
||||||
<div class="override-grid">
|
<PermGrid :categories="permCategories" :caps-by-category="permCapsByCategory">
|
||||||
<template v-for="cat in permCategories" :key="cat">
|
<template #cap="{ cap }">
|
||||||
<div class="override-cat-label">{{ cat }}</div>
|
<div class="perm-override-chip"
|
||||||
<div class="row q-gutter-xs flex-wrap q-mb-xs">
|
:class="{ 'perm-override-on': permMatrix[selectedGroup]?.[cap.key] }">
|
||||||
<div v-for="cap in permCapsByCategory[cat]" :key="cap.key" class="perm-override-chip"
|
<q-checkbox v-model="permMatrix[selectedGroup][cap.key]" dense size="sm" color="indigo-6"
|
||||||
:class="{ 'perm-override-on': permMatrix[selectedGroup]?.[cap.key] }">
|
@update:model-value="permDirty = true" />
|
||||||
<q-checkbox v-model="permMatrix[selectedGroup][cap.key]" dense size="sm" color="indigo-6"
|
<span class="text-caption">{{ cap.label }}</span>
|
||||||
@update:model-value="permDirty = true" />
|
|
||||||
<span class="text-caption">{{ cap.label }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</PermGrid>
|
||||||
<q-btn v-if="permDirty" color="indigo-6" icon="save" label="Sauvegarder permissions"
|
<q-btn v-if="permDirty" color="indigo-6" icon="save" label="Sauvegarder permissions"
|
||||||
dense unelevated :loading="permSaving" @click="saveGroupPerms(selectedGroup)" class="q-mt-sm" />
|
dense unelevated :loading="permSaving" @click="saveGroupPerms(selectedGroup)" class="q-mt-sm" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -359,7 +339,7 @@
|
||||||
</q-slide-transition>
|
</q-slide-transition>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ═══ TAB: Matrice (vue globale) ═══ -->
|
<!-- TAB: Matrice (vue globale) -->
|
||||||
<div v-show="permTab === 'matrix'">
|
<div v-show="permTab === 'matrix'">
|
||||||
<div class="row items-center q-mb-sm">
|
<div class="row items-center q-mb-sm">
|
||||||
<span class="text-subtitle2">Matrice globale Groupes x Capacites</span>
|
<span class="text-subtitle2">Matrice globale Groupes x Capacites</span>
|
||||||
|
|
@ -400,16 +380,11 @@
|
||||||
</q-expansion-item>
|
</q-expansion-item>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ════════════════════════════════════════════════
|
<!-- SECTION 2: SMS / Twilio -->
|
||||||
SECTION 2: SMS / Twilio
|
|
||||||
════════════════════════════════════════════════ -->
|
|
||||||
<div class="ops-card q-mb-md">
|
<div class="ops-card q-mb-md">
|
||||||
<q-expansion-item header-class="section-header" expand-icon-class="text-grey-7">
|
<q-expansion-item header-class="section-header" expand-icon-class="text-grey-7">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="row items-center no-wrap" style="width:100%">
|
<SectionHeader icon="sms" label="SMS & Templates" />
|
||||||
<q-icon name="sms" size="20px" color="indigo-6" class="q-mr-sm" />
|
|
||||||
<span class="text-subtitle1 text-weight-bold">SMS & Templates</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
<q-separator />
|
<q-separator />
|
||||||
<div class="q-pa-md">
|
<div class="q-pa-md">
|
||||||
|
|
@ -452,17 +427,9 @@
|
||||||
<q-separator class="q-my-md" />
|
<q-separator class="q-my-md" />
|
||||||
<div class="text-subtitle2 q-mb-sm">Templates SMS</div>
|
<div class="text-subtitle2 q-mb-sm">Templates SMS</div>
|
||||||
<div class="row q-col-gutter-md">
|
<div class="row q-col-gutter-md">
|
||||||
<div class="col-12">
|
<div v-for="t in smsTemplates" :key="t.key" class="col-12">
|
||||||
<q-input v-model="settings.sms_enroute" label="Technicien en route" outlined dense autogrow
|
<q-input v-model="settings[t.key]" :label="t.label" outlined dense autogrow
|
||||||
@blur="save('sms_enroute')" />
|
@blur="save(t.key)" />
|
||||||
</div>
|
|
||||||
<div class="col-12">
|
|
||||||
<q-input v-model="settings.sms_completed" label="Service complete" outlined dense autogrow
|
|
||||||
@blur="save('sms_completed')" />
|
|
||||||
</div>
|
|
||||||
<div class="col-12">
|
|
||||||
<q-input v-model="settings.sms_assigned" label="Job assigne (technicien)" outlined dense autogrow
|
|
||||||
@blur="save('sms_assigned')" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-caption text-grey-6 q-mt-sm">
|
<div class="text-caption text-grey-6 q-mt-sm">
|
||||||
|
|
@ -472,21 +439,15 @@
|
||||||
</q-expansion-item>
|
</q-expansion-item>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ════════════════════════════════════════════════
|
<!-- SECTION 3: Integrations -->
|
||||||
SECTION 3: Integrations
|
|
||||||
════════════════════════════════════════════════ -->
|
|
||||||
<div class="ops-card q-mb-md">
|
<div class="ops-card q-mb-md">
|
||||||
<q-expansion-item header-class="section-header" expand-icon-class="text-grey-7">
|
<q-expansion-item header-class="section-header" expand-icon-class="text-grey-7">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="row items-center no-wrap" style="width:100%">
|
<SectionHeader icon="extension" label="Integrations" />
|
||||||
<q-icon name="extension" size="20px" color="indigo-6" class="q-mr-sm" />
|
|
||||||
<span class="text-subtitle1 text-weight-bold">Integrations</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
<q-separator />
|
<q-separator />
|
||||||
<div class="q-pa-md">
|
<div class="q-pa-md">
|
||||||
|
|
||||||
<!-- Email / SMTP -->
|
|
||||||
<div class="text-subtitle2 q-mb-xs">
|
<div class="text-subtitle2 q-mb-xs">
|
||||||
<q-icon name="email" size="18px" class="q-mr-xs" /> Email — SMTP
|
<q-icon name="email" size="18px" class="q-mr-xs" /> Email — SMTP
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -497,7 +458,6 @@
|
||||||
|
|
||||||
<q-separator class="q-my-md" />
|
<q-separator class="q-my-md" />
|
||||||
|
|
||||||
<!-- n8n / Webhooks -->
|
|
||||||
<div class="text-subtitle2 q-mb-xs">
|
<div class="text-subtitle2 q-mb-xs">
|
||||||
<q-icon name="webhook" size="18px" class="q-mr-xs" /> n8n — Webhooks
|
<q-icon name="webhook" size="18px" class="q-mr-xs" /> n8n — Webhooks
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -512,7 +472,6 @@
|
||||||
|
|
||||||
<q-separator class="q-my-md" />
|
<q-separator class="q-my-md" />
|
||||||
|
|
||||||
<!-- Stripe -->
|
|
||||||
<div class="text-subtitle2 q-mb-xs">
|
<div class="text-subtitle2 q-mb-xs">
|
||||||
<q-icon name="credit_card" size="18px" class="q-mr-xs" /> Stripe — Paiements
|
<q-icon name="credit_card" size="18px" class="q-mr-xs" /> Stripe — Paiements
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -534,7 +493,6 @@
|
||||||
|
|
||||||
<q-separator class="q-my-md" />
|
<q-separator class="q-my-md" />
|
||||||
|
|
||||||
<!-- Mapbox -->
|
|
||||||
<div class="text-subtitle2 q-mb-xs">
|
<div class="text-subtitle2 q-mb-xs">
|
||||||
<q-icon name="map" size="18px" class="q-mr-xs" /> Mapbox
|
<q-icon name="map" size="18px" class="q-mr-xs" /> Mapbox
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -547,16 +505,11 @@
|
||||||
</q-expansion-item>
|
</q-expansion-item>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ════════════════════════════════════════════════
|
<!-- SECTION 4: 3CX Phone -->
|
||||||
SECTION 4: 3CX Phone
|
|
||||||
════════════════════════════════════════════════ -->
|
|
||||||
<div class="ops-card q-mb-md">
|
<div class="ops-card q-mb-md">
|
||||||
<q-expansion-item header-class="section-header" expand-icon-class="text-grey-7">
|
<q-expansion-item header-class="section-header" expand-icon-class="text-grey-7">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="row items-center no-wrap" style="width:100%">
|
<SectionHeader icon="phone" label="3CX — Telephone WebRTC" />
|
||||||
<q-icon name="phone" size="20px" color="indigo-6" class="q-mr-sm" />
|
|
||||||
<span class="text-subtitle1 text-weight-bold">3CX — Telephone WebRTC</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
<q-separator />
|
<q-separator />
|
||||||
<div class="q-pa-md">
|
<div class="q-pa-md">
|
||||||
|
|
@ -597,73 +550,53 @@
|
||||||
</q-expansion-item>
|
</q-expansion-item>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ════════════════════════════════════════════════
|
<!-- SECTION 5: Reseau (OLT) -->
|
||||||
SECTION 5: Réseau (OLT)
|
|
||||||
════════════════════════════════════════════════ -->
|
|
||||||
<div v-if="can('view_clients')" class="ops-card q-mb-md">
|
<div v-if="can('view_clients')" class="ops-card q-mb-md">
|
||||||
<q-expansion-item header-class="section-header" expand-icon-class="text-grey-7"
|
<q-expansion-item header-class="section-header" expand-icon-class="text-grey-7"
|
||||||
@before-show="networkExpanded = true">
|
@before-show="lazyFlags.network = true">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="row items-center no-wrap" style="width:100%">
|
<SectionHeader icon="lan" label="Reseau — OLT / Fibre" />
|
||||||
<q-icon name="lan" size="20px" color="indigo-6" class="q-mr-sm" />
|
|
||||||
<span class="text-subtitle1 text-weight-bold">Reseau — OLT / Fibre</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
<q-separator />
|
<q-separator />
|
||||||
<div class="q-pa-none">
|
<div class="q-pa-none">
|
||||||
<NetworkPage v-if="networkExpanded" />
|
<NetworkPage v-if="lazyFlags.network" />
|
||||||
</div>
|
</div>
|
||||||
</q-expansion-item>
|
</q-expansion-item>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ════════════════════════════════════════════════
|
<!-- SECTION 6: Telephonie (Fonoster/SIP) -->
|
||||||
SECTION 6: Téléphonie (Fonoster/SIP)
|
|
||||||
════════════════════════════════════════════════ -->
|
|
||||||
<div v-if="can('manage_telephony')" class="ops-card q-mb-md">
|
<div v-if="can('manage_telephony')" class="ops-card q-mb-md">
|
||||||
<q-expansion-item header-class="section-header" expand-icon-class="text-grey-7"
|
<q-expansion-item header-class="section-header" expand-icon-class="text-grey-7"
|
||||||
@before-show="telephonyExpanded = true">
|
@before-show="lazyFlags.telephony = true">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="row items-center no-wrap" style="width:100%">
|
<SectionHeader icon="phone_in_talk" label="Telephonie — SIP / Trunks" />
|
||||||
<q-icon name="phone_in_talk" size="20px" color="indigo-6" class="q-mr-sm" />
|
|
||||||
<span class="text-subtitle1 text-weight-bold">Telephonie — SIP / Trunks</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
<q-separator />
|
<q-separator />
|
||||||
<div class="q-pa-none">
|
<div class="q-pa-none">
|
||||||
<TelephonyPage v-if="telephonyExpanded" />
|
<TelephonyPage v-if="lazyFlags.telephony" />
|
||||||
</div>
|
</div>
|
||||||
</q-expansion-item>
|
</q-expansion-item>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ════════════════════════════════════════════════
|
<!-- SECTION 7: Agent AI Flows -->
|
||||||
SECTION 7: Agent AI Flows
|
|
||||||
════════════════════════════════════════════════ -->
|
|
||||||
<div v-if="can('manage_settings')" class="ops-card q-mb-md">
|
<div v-if="can('manage_settings')" class="ops-card q-mb-md">
|
||||||
<q-expansion-item header-class="section-header" expand-icon-class="text-grey-7"
|
<q-expansion-item header-class="section-header" expand-icon-class="text-grey-7"
|
||||||
@before-show="agentExpanded = true">
|
@before-show="lazyFlags.agent = true">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="row items-center no-wrap" style="width:100%">
|
<SectionHeader icon="smart_toy" label="Agent AI — Flows" />
|
||||||
<q-icon name="smart_toy" size="20px" color="indigo-6" class="q-mr-sm" />
|
|
||||||
<span class="text-subtitle1 text-weight-bold">Agent AI — Flows</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
<q-separator />
|
<q-separator />
|
||||||
<div class="q-pa-none">
|
<div class="q-pa-none">
|
||||||
<AgentFlowsPage v-if="agentExpanded" />
|
<AgentFlowsPage v-if="lazyFlags.agent" />
|
||||||
</div>
|
</div>
|
||||||
</q-expansion-item>
|
</q-expansion-item>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ════════════════════════════════════════════════
|
<!-- SECTION 8: Liens rapides -->
|
||||||
SECTION 8: Liens rapides
|
|
||||||
════════════════════════════════════════════════ -->
|
|
||||||
<div class="ops-card">
|
<div class="ops-card">
|
||||||
<q-expansion-item default-opened header-class="section-header" expand-icon-class="text-grey-7">
|
<q-expansion-item default-opened header-class="section-header" expand-icon-class="text-grey-7">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="row items-center no-wrap" style="width:100%">
|
<SectionHeader icon="launch" label="Liens rapides" />
|
||||||
<q-icon name="launch" size="20px" color="indigo-6" class="q-mr-sm" />
|
|
||||||
<span class="text-subtitle1 text-weight-bold">Liens rapides</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
<q-separator />
|
<q-separator />
|
||||||
<div class="q-pa-md">
|
<div class="q-pa-md">
|
||||||
|
|
@ -682,27 +615,71 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
import { ref, reactive, onMounted, watch, defineAsyncComponent, h, resolveComponent } from 'vue'
|
||||||
import { Notify } from 'quasar'
|
import { Notify } from 'quasar'
|
||||||
import { authFetch } from 'src/api/auth'
|
import { authFetch } from 'src/api/auth'
|
||||||
import { BASE_URL } from 'src/config/erpnext'
|
import { BASE_URL, ERP_DESK_URL as erpDeskUrl } from 'src/config/erpnext'
|
||||||
import { ERP_DESK_URL as erpDeskUrl } from 'src/config/erpnext'
|
|
||||||
import { sendTestSms } from 'src/api/sms'
|
import { sendTestSms } from 'src/api/sms'
|
||||||
import { getPhoneConfig, savePhoneConfig, fetch3cxCredentials } from 'src/composables/usePhone'
|
import { getPhoneConfig, savePhoneConfig, fetch3cxCredentials } from 'src/composables/usePhone'
|
||||||
import { usePermissions } from 'src/composables/usePermissions'
|
import { usePermissions } from 'src/composables/usePermissions'
|
||||||
import { defineAsyncComponent } from 'vue'
|
import { usePermissionMatrix } from 'src/composables/usePermissionMatrix'
|
||||||
|
import { useUserGroups } from 'src/composables/useUserGroups'
|
||||||
|
import { useLegacySync } from 'src/composables/useLegacySync'
|
||||||
|
|
||||||
const NetworkPage = defineAsyncComponent(() => import('src/pages/NetworkPage.vue'))
|
const NetworkPage = defineAsyncComponent(() => import('src/pages/NetworkPage.vue'))
|
||||||
const TelephonyPage = defineAsyncComponent(() => import('src/pages/TelephonyPage.vue'))
|
const TelephonyPage = defineAsyncComponent(() => import('src/pages/TelephonyPage.vue'))
|
||||||
const AgentFlowsPage = defineAsyncComponent(() => import('src/pages/AgentFlowsPage.vue'))
|
const AgentFlowsPage = defineAsyncComponent(() => import('src/pages/AgentFlowsPage.vue'))
|
||||||
|
|
||||||
const { can, HUB_URL, isLoaded } = usePermissions()
|
// Inline functional component for section headers
|
||||||
|
const QIcon = resolveComponent('QIcon')
|
||||||
|
const SectionHeader = (props) => h('div', { class: 'row items-center no-wrap', style: 'width:100%' }, [
|
||||||
|
h(QIcon, { name: props.icon, size: props.icon === 'admin_panel_settings' ? '22px' : '20px', color: 'indigo-6', class: 'q-mr-sm' }),
|
||||||
|
h('span', { class: 'text-subtitle1 text-weight-bold' }, props.label),
|
||||||
|
])
|
||||||
|
SectionHeader.props = ['icon', 'label']
|
||||||
|
|
||||||
|
// Inline functional component for permission category grid
|
||||||
|
const PermGrid = (props, { slots }) => {
|
||||||
|
const nodes = []
|
||||||
|
for (const cat of props.categories) {
|
||||||
|
nodes.push(h('div', { class: 'override-cat-label', key: cat + '-label' }, cat))
|
||||||
|
const caps = props.capsByCategory[cat] || []
|
||||||
|
nodes.push(h('div', { class: 'row q-gutter-xs flex-wrap q-mb-xs', key: cat + '-caps' },
|
||||||
|
caps.map(cap => slots.cap({ cap }))
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return h('div', { class: 'override-grid' }, nodes)
|
||||||
|
}
|
||||||
|
PermGrid.props = ['categories', 'capsByCategory']
|
||||||
|
|
||||||
|
const { can, isLoaded } = usePermissions()
|
||||||
|
|
||||||
|
const {
|
||||||
|
permTab, permLoading, permSaving, permDirty,
|
||||||
|
permGroups, permMatrix,
|
||||||
|
allGroupNames, permCategories, permCapsByCategory,
|
||||||
|
loadPerms, saveAllPerms, saveGroupPerms, countGroupPerms,
|
||||||
|
effectivePerm, setUserOverride,
|
||||||
|
} = usePermissionMatrix()
|
||||||
|
|
||||||
|
const {
|
||||||
|
userSearch, userResults, userSearchLoading, userSearchDone,
|
||||||
|
selectedUser, savingGroups,
|
||||||
|
selectedGroup, groupMembers, groupMembersLoading,
|
||||||
|
addMemberSearch, memberSearchResults, memberSearchLoading,
|
||||||
|
debouncedSearchUsers, searchUsers, selectUser, toggleUserGroup,
|
||||||
|
selectGroup, loadGroupMembers, removeFromGroup,
|
||||||
|
debouncedMemberSearch, addUserToCurrentGroup,
|
||||||
|
} = useUserGroups({ permGroups })
|
||||||
|
|
||||||
|
const { legacySyncing, showSyncDialog, syncResult, syncLegacy } = useLegacySync({
|
||||||
|
loadPerms, loadGroupMembers, selectedGroup,
|
||||||
|
})
|
||||||
|
|
||||||
// Lazy-load flags for embedded pages
|
// Lazy-load flags for embedded pages
|
||||||
const networkExpanded = ref(false)
|
const lazyFlags = reactive({ network: false, telephony: false, agent: false })
|
||||||
const telephonyExpanded = ref(false)
|
|
||||||
const agentExpanded = ref(false)
|
|
||||||
|
|
||||||
|
// Page state
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const settings = ref({})
|
const settings = ref({})
|
||||||
const showToken = ref(false)
|
const showToken = ref(false)
|
||||||
|
|
@ -715,9 +692,29 @@ const pbxUsername = ref('')
|
||||||
const pbxPassword = ref('')
|
const pbxPassword = ref('')
|
||||||
const loggingIn3cx = ref(false)
|
const loggingIn3cx = ref(false)
|
||||||
|
|
||||||
|
// SMS template definitions
|
||||||
|
const smsTemplates = [
|
||||||
|
{ key: 'sms_enroute', label: 'Technicien en route' },
|
||||||
|
{ key: 'sms_completed', label: 'Service complete' },
|
||||||
|
{ key: 'sms_assigned', label: 'Job assigne (technicien)' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function notify (msg, type = 'positive', timeout = 1500) {
|
||||||
|
Notify.create({ type, message: msg, timeout })
|
||||||
|
}
|
||||||
|
|
||||||
|
function initial (u) {
|
||||||
|
return (u.name || u.username || '?')[0].toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate (d) {
|
||||||
|
if (!d) return ''
|
||||||
|
return new Date(d).toLocaleDateString('fr-CA', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
function savePhone () {
|
function savePhone () {
|
||||||
savePhoneConfig(phoneConfig.value)
|
savePhoneConfig(phoneConfig.value)
|
||||||
Notify.create({ type: 'positive', message: 'Config telephone sauvegardee', timeout: 1500 })
|
notify('Config telephone sauvegardee')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function login3cx () {
|
async function login3cx () {
|
||||||
|
|
@ -725,15 +722,17 @@ async function login3cx () {
|
||||||
loggingIn3cx.value = true
|
loggingIn3cx.value = true
|
||||||
try {
|
try {
|
||||||
const creds = await fetch3cxCredentials(pbxUsername.value, pbxPassword.value)
|
const creds = await fetch3cxCredentials(pbxUsername.value, pbxPassword.value)
|
||||||
phoneConfig.value.extension = creds.extension
|
Object.assign(phoneConfig.value, {
|
||||||
phoneConfig.value.authId = creds.authId
|
extension: creds.extension,
|
||||||
phoneConfig.value.authPassword = creds.authPassword
|
authId: creds.authId,
|
||||||
phoneConfig.value.displayName = creds.displayName
|
authPassword: creds.authPassword,
|
||||||
|
displayName: creds.displayName,
|
||||||
|
})
|
||||||
savePhoneConfig(phoneConfig.value)
|
savePhoneConfig(phoneConfig.value)
|
||||||
pbxPassword.value = ''
|
pbxPassword.value = ''
|
||||||
Notify.create({ type: 'positive', message: `Connecte — Extension ${creds.extension} (${creds.displayName})`, timeout: 3000 })
|
notify(`Connecte — Extension ${creds.extension} (${creds.displayName})`, 'positive', 3000)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Notify.create({ type: 'negative', message: '3CX login echoue: ' + e.message, timeout: 4000 })
|
notify('3CX login echoue: ' + e.message, 'negative', 4000)
|
||||||
} finally {
|
} finally {
|
||||||
loggingIn3cx.value = false
|
loggingIn3cx.value = false
|
||||||
}
|
}
|
||||||
|
|
@ -752,18 +751,16 @@ onMounted(async () => {
|
||||||
snapshots[key] = json.data[key] ?? ''
|
snapshots[key] = json.data[key] ?? ''
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Notify.create({ type: 'negative', message: 'Erreur chargement parametres: ' + e.message })
|
notify('Erreur chargement parametres: ' + e.message, 'negative')
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
// If permissions already loaded, init now; otherwise watch
|
|
||||||
if (isLoaded.value && can('manage_permissions')) {
|
if (isLoaded.value && can('manage_permissions')) {
|
||||||
loadPerms()
|
loadPerms()
|
||||||
searchUsers()
|
searchUsers()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Watch for permissions loading (race condition: perms may load after onMounted)
|
|
||||||
watch(isLoaded, (loaded) => {
|
watch(isLoaded, (loaded) => {
|
||||||
if (loaded && can('manage_permissions') && !permGroups.value.length) {
|
if (loaded && can('manage_permissions') && !permGroups.value.length) {
|
||||||
loadPerms()
|
loadPerms()
|
||||||
|
|
@ -775,7 +772,6 @@ async function save (field) {
|
||||||
const val = settings.value[field] ?? ''
|
const val = settings.value[field] ?? ''
|
||||||
const prev = snapshots[field] ?? ''
|
const prev = snapshots[field] ?? ''
|
||||||
if (val === prev) return
|
if (val === prev) return
|
||||||
|
|
||||||
snapshots[field] = val
|
snapshots[field] = val
|
||||||
try {
|
try {
|
||||||
const res = await authFetch(BASE_URL + '/api/resource/Dispatch Settings/Dispatch Settings', {
|
const res = await authFetch(BASE_URL + '/api/resource/Dispatch Settings/Dispatch Settings', {
|
||||||
|
|
@ -784,9 +780,9 @@ async function save (field) {
|
||||||
body: JSON.stringify({ [field]: val }),
|
body: JSON.stringify({ [field]: val }),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Save failed: ' + res.status)
|
if (!res.ok) throw new Error('Save failed: ' + res.status)
|
||||||
Notify.create({ type: 'positive', message: 'Sauvegarde', timeout: 1500 })
|
notify('Sauvegarde')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
|
notify('Erreur: ' + e.message, 'negative')
|
||||||
settings.value[field] = prev
|
settings.value[field] = prev
|
||||||
snapshots[field] = prev
|
snapshots[field] = prev
|
||||||
}
|
}
|
||||||
|
|
@ -804,323 +800,11 @@ async function testSms () {
|
||||||
testingSms.value = false
|
testingSms.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════
|
|
||||||
// Permissions & Users
|
|
||||||
// ═══════════════════════════════════════
|
|
||||||
const permTab = ref('users')
|
|
||||||
const permLoading = ref(false)
|
|
||||||
const permSaving = ref(false)
|
|
||||||
const permDirty = ref(false)
|
|
||||||
const permGroups = ref([])
|
|
||||||
const permCapabilities = ref([])
|
|
||||||
const permMatrix = reactive({})
|
|
||||||
|
|
||||||
const allGroupNames = computed(() => permGroups.value.map(g => g.name))
|
|
||||||
const permCategories = computed(() => [...new Set(permCapabilities.value.map(c => c.category))])
|
|
||||||
const permCapsByCategory = computed(() => {
|
|
||||||
const map = {}
|
|
||||||
for (const c of permCapabilities.value) {
|
|
||||||
if (!map[c.category]) map[c.category] = []
|
|
||||||
map[c.category].push(c)
|
|
||||||
}
|
|
||||||
return map
|
|
||||||
})
|
|
||||||
|
|
||||||
async function loadPerms () {
|
|
||||||
permLoading.value = true
|
|
||||||
try {
|
|
||||||
const res = await fetch(HUB_URL + '/auth/groups')
|
|
||||||
const data = await res.json()
|
|
||||||
permGroups.value = data.groups
|
|
||||||
permCapabilities.value = data.capabilities
|
|
||||||
for (const g of data.groups) {
|
|
||||||
permMatrix[g.name] = {}
|
|
||||||
for (const cap of data.capabilities) {
|
|
||||||
permMatrix[g.name][cap.key] = g.permissions[cap.key] === true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
Notify.create({ type: 'negative', message: 'Erreur chargement permissions: ' + e.message })
|
|
||||||
} finally {
|
|
||||||
permLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveAllPerms () {
|
|
||||||
permSaving.value = true
|
|
||||||
try {
|
|
||||||
for (const g of permGroups.value) {
|
|
||||||
await fetch(HUB_URL + '/auth/groups/' + encodeURIComponent(g.name) + '/permissions', {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(permMatrix[g.name]),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
permDirty.value = false
|
|
||||||
Notify.create({ type: 'positive', message: 'Permissions sauvegardees', timeout: 2000 })
|
|
||||||
} catch (e) {
|
|
||||||
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
|
|
||||||
} finally {
|
|
||||||
permSaving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveGroupPerms (groupName) {
|
|
||||||
permSaving.value = true
|
|
||||||
try {
|
|
||||||
await fetch(HUB_URL + '/auth/groups/' + encodeURIComponent(groupName) + '/permissions', {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(permMatrix[groupName]),
|
|
||||||
})
|
|
||||||
permDirty.value = false
|
|
||||||
Notify.create({ type: 'positive', message: `Permissions "${groupName}" sauvegardees`, timeout: 2000 })
|
|
||||||
} catch (e) {
|
|
||||||
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
|
|
||||||
} finally {
|
|
||||||
permSaving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function countGroupPerms (g) {
|
|
||||||
return Object.values(permMatrix[g.name] || {}).filter(Boolean).length
|
|
||||||
}
|
|
||||||
|
|
||||||
// ═══ Users tab ═══
|
|
||||||
const userSearch = ref('')
|
|
||||||
const userResults = ref([])
|
|
||||||
const userSearchLoading = ref(false)
|
|
||||||
const userSearchDone = ref(false)
|
|
||||||
const selectedUser = ref(null)
|
|
||||||
const savingGroups = ref(false)
|
|
||||||
|
|
||||||
let searchTimer = null
|
|
||||||
function debouncedSearchUsers () {
|
|
||||||
clearTimeout(searchTimer)
|
|
||||||
searchTimer = setTimeout(() => searchUsers(), 300)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function searchUsers () {
|
|
||||||
const q = (userSearch.value || '').trim()
|
|
||||||
userSearchLoading.value = true
|
|
||||||
userSearchDone.value = false
|
|
||||||
try {
|
|
||||||
const url = q.length >= 1
|
|
||||||
? HUB_URL + '/auth/users?search=' + encodeURIComponent(q)
|
|
||||||
: HUB_URL + '/auth/users?page=1'
|
|
||||||
const res = await fetch(url)
|
|
||||||
const data = await res.json()
|
|
||||||
userResults.value = data.users.map(u => ({
|
|
||||||
...u,
|
|
||||||
overrides: reactive({ ...u.overrides }),
|
|
||||||
}))
|
|
||||||
userSearchDone.value = true
|
|
||||||
} catch (e) {
|
|
||||||
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
|
|
||||||
} finally {
|
|
||||||
userSearchLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectUser (u) {
|
|
||||||
selectedUser.value = selectedUser.value?.pk === u.pk ? null : u
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleUserGroup (user, groupName) {
|
|
||||||
const idx = user.groups.indexOf(groupName)
|
|
||||||
if (idx >= 0) {
|
|
||||||
user.groups.splice(idx, 1)
|
|
||||||
} else {
|
|
||||||
user.groups.push(groupName)
|
|
||||||
}
|
|
||||||
|
|
||||||
savingGroups.value = true
|
|
||||||
try {
|
|
||||||
await fetch(HUB_URL + '/auth/users/' + encodeURIComponent(user.email) + '/groups', {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ groups: user.groups }),
|
|
||||||
})
|
|
||||||
Notify.create({ type: 'positive', message: `Groupes mis a jour pour ${user.name || user.email}`, timeout: 1500 })
|
|
||||||
} catch (e) {
|
|
||||||
// Revert on error
|
|
||||||
if (idx >= 0) user.groups.push(groupName)
|
|
||||||
else user.groups.splice(user.groups.indexOf(groupName), 1)
|
|
||||||
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
|
|
||||||
} finally {
|
|
||||||
savingGroups.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setUserOverride (user, capKey, value) {
|
|
||||||
if (value === true || value === false) {
|
|
||||||
user.overrides[capKey] = value
|
|
||||||
} else {
|
|
||||||
delete user.overrides[capKey]
|
|
||||||
}
|
|
||||||
const clean = {}
|
|
||||||
for (const [k, v] of Object.entries(user.overrides)) {
|
|
||||||
if (typeof v === 'boolean') clean[k] = v
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await fetch(HUB_URL + '/auth/users/' + encodeURIComponent(user.email) + '/overrides', {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(clean),
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
Notify.create({ type: 'negative', message: 'Erreur override: ' + e.message })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function effectivePerm (user, capKey) {
|
|
||||||
// Override takes precedence
|
|
||||||
if (typeof user.overrides[capKey] === 'boolean') return user.overrides[capKey]
|
|
||||||
// Otherwise merge from groups
|
|
||||||
for (const gName of user.groups) {
|
|
||||||
if (permMatrix[gName]?.[capKey]) return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate (d) {
|
|
||||||
if (!d) return ''
|
|
||||||
return new Date(d).toLocaleDateString('fr-CA', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
|
||||||
}
|
|
||||||
|
|
||||||
// ═══ Groups tab ═══
|
|
||||||
const selectedGroup = ref(null)
|
|
||||||
const groupMembers = ref(null)
|
|
||||||
const groupMembersLoading = ref(false)
|
|
||||||
const addMemberSearch = ref('')
|
|
||||||
const memberSearchResults = ref([])
|
|
||||||
const memberSearchLoading = ref(false)
|
|
||||||
|
|
||||||
async function selectGroup (name) {
|
|
||||||
if (selectedGroup.value === name) {
|
|
||||||
selectedGroup.value = null
|
|
||||||
groupMembers.value = null
|
|
||||||
return
|
|
||||||
}
|
|
||||||
selectedGroup.value = name
|
|
||||||
addMemberSearch.value = ''
|
|
||||||
memberSearchResults.value = []
|
|
||||||
await loadGroupMembers(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadGroupMembers (name) {
|
|
||||||
groupMembersLoading.value = true
|
|
||||||
try {
|
|
||||||
const res = await fetch(HUB_URL + '/auth/users?group=' + encodeURIComponent(name))
|
|
||||||
const data = await res.json()
|
|
||||||
groupMembers.value = data.users
|
|
||||||
} catch (e) {
|
|
||||||
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
|
|
||||||
} finally {
|
|
||||||
groupMembersLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeFromGroup (member, groupName) {
|
|
||||||
const newGroups = member.groups.filter(g => g !== groupName)
|
|
||||||
try {
|
|
||||||
await fetch(HUB_URL + '/auth/users/' + encodeURIComponent(member.email) + '/groups', {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ groups: newGroups }),
|
|
||||||
})
|
|
||||||
groupMembers.value = groupMembers.value.filter(m => m.pk !== member.pk)
|
|
||||||
const g = permGroups.value.find(g => g.name === groupName)
|
|
||||||
if (g) g.num_users = Math.max(0, g.num_users - 1)
|
|
||||||
Notify.create({ type: 'positive', message: `${member.name || member.email} retire de ${groupName}`, timeout: 1500 })
|
|
||||||
} catch (e) {
|
|
||||||
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Live member search for adding to group
|
|
||||||
let memberTimer = null
|
|
||||||
function debouncedMemberSearch () {
|
|
||||||
clearTimeout(memberTimer)
|
|
||||||
const q = (addMemberSearch.value || '').trim()
|
|
||||||
if (q.length < 2) { memberSearchResults.value = []; return }
|
|
||||||
memberTimer = setTimeout(() => searchMembersToAdd(q), 300)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function searchMembersToAdd (q) {
|
|
||||||
memberSearchLoading.value = true
|
|
||||||
try {
|
|
||||||
const res = await fetch(HUB_URL + '/auth/users?search=' + encodeURIComponent(q))
|
|
||||||
const data = await res.json()
|
|
||||||
// Exclude users already in the group
|
|
||||||
const existingPks = new Set((groupMembers.value || []).map(m => m.pk))
|
|
||||||
memberSearchResults.value = data.users.filter(u => !existingPks.has(u.pk))
|
|
||||||
} catch (e) {
|
|
||||||
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
|
|
||||||
} finally {
|
|
||||||
memberSearchLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ═══ Legacy sync ═══
|
|
||||||
const legacySyncing = ref(false)
|
|
||||||
const showSyncDialog = ref(false)
|
|
||||||
const syncResult = ref(null)
|
|
||||||
|
|
||||||
async function syncLegacy (dryRun = true) {
|
|
||||||
legacySyncing.value = true
|
|
||||||
try {
|
|
||||||
const res = await fetch(HUB_URL + '/auth/sync-legacy', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ dry_run: dryRun }),
|
|
||||||
})
|
|
||||||
const data = await res.json()
|
|
||||||
if (!res.ok) throw new Error(data.error || 'Sync failed')
|
|
||||||
syncResult.value = data
|
|
||||||
showSyncDialog.value = true
|
|
||||||
if (!dryRun && data.summary.to_sync > 0) {
|
|
||||||
Notify.create({ type: 'positive', message: `${data.summary.to_sync} utilisateurs synchronises`, timeout: 3000 })
|
|
||||||
// Refresh groups data
|
|
||||||
await loadPerms()
|
|
||||||
if (selectedGroup.value) await loadGroupMembers(selectedGroup.value)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
Notify.create({ type: 'negative', message: 'Erreur sync legacy: ' + e.message, timeout: 4000 })
|
|
||||||
} finally {
|
|
||||||
legacySyncing.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addUserToCurrentGroup (user) {
|
|
||||||
if (!selectedGroup.value) return
|
|
||||||
if (user.groups.includes(selectedGroup.value)) return // already member
|
|
||||||
memberSearchResults.value = []
|
|
||||||
addMemberSearch.value = ''
|
|
||||||
try {
|
|
||||||
const newGroups = [...new Set([...user.groups, selectedGroup.value])]
|
|
||||||
await fetch(HUB_URL + '/auth/users/' + encodeURIComponent(user.email) + '/groups', {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ groups: newGroups }),
|
|
||||||
})
|
|
||||||
// Add to local list
|
|
||||||
user.groups = newGroups
|
|
||||||
groupMembers.value.push(user)
|
|
||||||
const g = permGroups.value.find(g => g.name === selectedGroup.value)
|
|
||||||
if (g) g.num_users++
|
|
||||||
Notify.create({ type: 'positive', message: `${user.name || user.email} ajoute a ${selectedGroup.value}`, timeout: 1500 })
|
|
||||||
} catch (e) {
|
|
||||||
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.section-header { padding: 12px 16px; }
|
.section-header { padding: 12px 16px; }
|
||||||
|
|
||||||
/* Permission matrix table */
|
|
||||||
.perm-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
.perm-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
||||||
.perm-table th, .perm-table td { padding: 4px 8px; border-bottom: 1px solid #e2e8f0; }
|
.perm-table th, .perm-table td { padding: 4px 8px; border-bottom: 1px solid #e2e8f0; }
|
||||||
.perm-cap-col { text-align: left; min-width: 200px; }
|
.perm-cap-col { text-align: left; min-width: 200px; }
|
||||||
|
|
@ -1129,23 +813,19 @@ async function addUserToCurrentGroup (user) {
|
||||||
.perm-cap-label { color: #475569; }
|
.perm-cap-label { color: #475569; }
|
||||||
.perm-matrix { overflow-x: auto; }
|
.perm-matrix { overflow-x: auto; }
|
||||||
|
|
||||||
/* Override chips */
|
|
||||||
.perm-override-chip { display: inline-flex; align-items: center; gap: 2px; padding: 2px 6px; border-radius: 4px; margin: 2px; background: #f1f5f9; }
|
.perm-override-chip { display: inline-flex; align-items: center; gap: 2px; padding: 2px 6px; border-radius: 4px; margin: 2px; background: #f1f5f9; }
|
||||||
.perm-override-on { background: #fef3c7; }
|
.perm-override-on { background: #fef3c7; }
|
||||||
.perm-override-off { background: #fee2e2; }
|
.perm-override-off { background: #fee2e2; }
|
||||||
.override-cat-label { font-size: 0.72rem; font-weight: 700; color: #6b7280; text-transform: uppercase; letter-spacing: 0.5px; margin-top: 8px; margin-bottom: 2px; }
|
.override-cat-label { font-size: 0.72rem; font-weight: 700; color: #6b7280; text-transform: uppercase; letter-spacing: 0.5px; margin-top: 8px; margin-bottom: 2px; }
|
||||||
|
|
||||||
/* User list */
|
|
||||||
.user-list { max-height: 360px; overflow-y: auto; border: 1px solid #e2e8f0; border-radius: 8px; }
|
.user-list { max-height: 360px; overflow-y: auto; border: 1px solid #e2e8f0; border-radius: 8px; }
|
||||||
.user-card { padding: 10px 14px; border-bottom: 1px solid #f1f5f9; cursor: pointer; transition: background 0.15s; }
|
.user-card { padding: 10px 14px; border-bottom: 1px solid #f1f5f9; cursor: pointer; transition: background 0.15s; }
|
||||||
.user-card:last-child { border-bottom: none; }
|
.user-card:last-child { border-bottom: none; }
|
||||||
.user-card:hover { background: #f8fafc; }
|
.user-card:hover { background: #f8fafc; }
|
||||||
.user-card--selected { background: #eef2ff; border-left: 3px solid #6366f1; }
|
.user-card--selected { background: #eef2ff; border-left: 3px solid #6366f1; }
|
||||||
|
|
||||||
/* User detail panel */
|
|
||||||
.user-detail-panel { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px; padding: 16px; }
|
.user-detail-panel { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px; padding: 16px; }
|
||||||
|
|
||||||
/* Group cards */
|
|
||||||
.group-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
|
.group-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
|
||||||
.group-card { padding: 14px; border: 1px solid #e2e8f0; border-radius: 10px; cursor: pointer; transition: all 0.15s; }
|
.group-card { padding: 14px; border: 1px solid #e2e8f0; border-radius: 10px; cursor: pointer; transition: all 0.15s; }
|
||||||
.group-card:hover { border-color: #6366f1; background: #fafafe; }
|
.group-card:hover { border-color: #6366f1; background: #fafafe; }
|
||||||
|
|
@ -1154,10 +834,8 @@ async function addUserToCurrentGroup (user) {
|
||||||
|
|
||||||
.effective-perms .q-badge { margin: 1px; }
|
.effective-perms .q-badge { margin: 1px; }
|
||||||
|
|
||||||
/* Embedded sub-pages: override q-page padding since they're inside expansion items */
|
|
||||||
:deep(.q-page) { min-height: auto !important; padding: 12px !important; }
|
:deep(.q-page) { min-height: auto !important; padding: 12px !important; }
|
||||||
|
|
||||||
/* Member search dropdown */
|
|
||||||
.member-search-dropdown {
|
.member-search-dropdown {
|
||||||
position: absolute; left: 0; right: 0; top: 100%; z-index: 10;
|
position: absolute; left: 0; right: 0; top: 100%; z-index: 10;
|
||||||
background: white; border: 1px solid #e2e8f0; border-radius: 8px;
|
background: white; border: 1px solid #e2e8f0; border-radius: 8px;
|
||||||
|
|
|
||||||
5
apps/ops/src/utils/erp-pdf.js
Normal file
5
apps/ops/src/utils/erp-pdf.js
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { BASE_URL } from 'src/config/erpnext'
|
||||||
|
|
||||||
|
export function erpPdfUrl (name) {
|
||||||
|
return `${BASE_URL}/api/method/frappe.utils.print_format.download_pdf?doctype=Sales%20Invoice&name=${encodeURIComponent(name)}&format=Facture%20TARGO`
|
||||||
|
}
|
||||||
51
services/targo-hub/lib/address-search.js
Normal file
51
services/targo-hub/lib/address-search.js
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
'use strict'
|
||||||
|
const { httpRequest } = require('./helpers')
|
||||||
|
|
||||||
|
const SUPABASE_URL = 'https://rddrjzptzhypltuzmere.supabase.co'
|
||||||
|
const SUPABASE_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InJkZHJqenB0emh5cGx0dXptZXJlIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzA4MTY4NTYsImV4cCI6MjA4NjM5Mjg1Nn0.EluFlKBze8BYM6AFx88G7kt21EvR18EI3uw1zgCXVzs'
|
||||||
|
|
||||||
|
function wordsToIlike (str) {
|
||||||
|
const words = str.split(/\s+/).filter(w => w.length >= 2)
|
||||||
|
if (!words.length) return ''
|
||||||
|
return '*' + words.map(w => encodeURIComponent(w)).join('*') + '*'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchAddresses (term, limit = 8) {
|
||||||
|
const clean = term.trim()
|
||||||
|
if (clean.length < 3) return []
|
||||||
|
|
||||||
|
const numMatch = clean.match(/^\s*(\d+)\s*(.*)/)
|
||||||
|
const headers = { apikey: SUPABASE_KEY, Authorization: 'Bearer ' + SUPABASE_KEY }
|
||||||
|
const select = 'adresse_formatee,numero_municipal,numero_unite,code_postal,odonyme_recompose_normal,nom_municipalite,latitude,longitude,identifiant_unique_adresse'
|
||||||
|
const base = `${SUPABASE_URL}/rest/v1/addresses?select=${select}&limit=${limit}`
|
||||||
|
|
||||||
|
let results = []
|
||||||
|
|
||||||
|
if (numMatch) {
|
||||||
|
const num = numMatch[1]
|
||||||
|
const street = numMatch[2].trim()
|
||||||
|
let url = `${base}&numero_municipal=eq.${num}`
|
||||||
|
if (street) url += `&odonyme_recompose_normal=ilike.${wordsToIlike(street)}`
|
||||||
|
url += '&order=nom_municipalite'
|
||||||
|
const res = await httpRequest(url, '', { headers })
|
||||||
|
results = Array.isArray(res.data) ? res.data : []
|
||||||
|
|
||||||
|
if (!results.length && num.length >= 2) {
|
||||||
|
let url2 = `${base}&numero_municipal=like.${num}*`
|
||||||
|
if (street) url2 += `&odonyme_recompose_normal=ilike.${wordsToIlike(street)}`
|
||||||
|
url2 += '&order=nom_municipalite'
|
||||||
|
const res2 = await httpRequest(url2, '', { headers })
|
||||||
|
results = Array.isArray(res2.data) ? res2.data : []
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const pattern = wordsToIlike(clean)
|
||||||
|
if (!pattern) return []
|
||||||
|
const url = `${base}&odonyme_recompose_normal=ilike.${pattern}&order=nom_municipalite`
|
||||||
|
const res = await httpRequest(url, '', { headers })
|
||||||
|
results = Array.isArray(res.data) ? res.data : []
|
||||||
|
}
|
||||||
|
|
||||||
|
return results.map(a => ({ ...a, fiber_available: false }))
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { searchAddresses, wordsToIlike }
|
||||||
|
|
@ -1,173 +1,27 @@
|
||||||
'use strict'
|
'use strict'
|
||||||
const cfg = require('./config')
|
const { log, json, parseBody, erpFetch } = require('./helpers')
|
||||||
const { log, json, parseBody, erpFetch, httpRequest } = require('./helpers')
|
const { searchAddresses } = require('./address-search')
|
||||||
|
const { sendOTP, verifyOTP } = require('./otp')
|
||||||
|
const { getTemplateSteps } = require('./project-templates')
|
||||||
|
const { orderConfirmationHtml } = require('./email-templates')
|
||||||
|
|
||||||
// ── Supabase Address Search (RQA — Répertoire Québécois des Adresses) ───────
|
function erpQuery (doctype, filters, fields, limit, orderBy) {
|
||||||
const SUPABASE_URL = 'https://rddrjzptzhypltuzmere.supabase.co'
|
let url = `/api/resource/${encodeURIComponent(doctype)}?filters=${encodeURIComponent(JSON.stringify(filters))}&fields=${encodeURIComponent(JSON.stringify(fields))}`
|
||||||
const SUPABASE_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InJkZHJqenB0emh5cGx0dXptZXJlIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzA4MTY4NTYsImV4cCI6MjA4NjM5Mjg1Nn0.EluFlKBze8BYM6AFx88G7kt21EvR18EI3uw1zgCXVzs'
|
if (orderBy) url += `&order_by=${encodeURIComponent(orderBy)}`
|
||||||
|
if (limit) url += `&limit_page_length=${limit}`
|
||||||
async function searchAddresses (term, limit = 8) {
|
return erpFetch(url)
|
||||||
const clean = term.trim()
|
|
||||||
if (clean.length < 3) return []
|
|
||||||
|
|
||||||
// Parse civic number and street parts
|
|
||||||
const numMatch = clean.match(/^\s*(\d+)\s*(.*)/)
|
|
||||||
const headers = { apikey: SUPABASE_KEY, Authorization: 'Bearer ' + SUPABASE_KEY }
|
|
||||||
const select = 'adresse_formatee,numero_municipal,numero_unite,code_postal,odonyme_recompose_normal,nom_municipalite,latitude,longitude,identifiant_unique_adresse'
|
|
||||||
const base = `${SUPABASE_URL}/rest/v1/addresses?select=${select}&limit=${limit}`
|
|
||||||
|
|
||||||
let results = []
|
|
||||||
|
|
||||||
// Build ilike pattern from words: "chemin du lac" → "*chemin*lac*"
|
|
||||||
function wordsToIlike (str) {
|
|
||||||
const words = str.split(/\s+/).filter(w => w.length >= 2)
|
|
||||||
if (!words.length) return ''
|
|
||||||
return '*' + words.map(w => encodeURIComponent(w)).join('*') + '*'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (numMatch) {
|
|
||||||
// Have civic number — fast exact match on numero_municipal + ilike on street
|
|
||||||
const num = numMatch[1]
|
|
||||||
const street = numMatch[2].trim()
|
|
||||||
let url = `${base}&numero_municipal=eq.${num}`
|
|
||||||
if (street) url += `&odonyme_recompose_normal=ilike.${wordsToIlike(street)}`
|
|
||||||
url += '&order=nom_municipalite'
|
|
||||||
const res = await httpRequest(url, '', { headers })
|
|
||||||
results = Array.isArray(res.data) ? res.data : []
|
|
||||||
|
|
||||||
// If no exact match, try prefix on numero_municipal
|
|
||||||
if (!results.length && num.length >= 2) {
|
|
||||||
let url2 = `${base}&numero_municipal=like.${num}*`
|
|
||||||
if (street) url2 += `&odonyme_recompose_normal=ilike.${wordsToIlike(street)}`
|
|
||||||
url2 += '&order=nom_municipalite'
|
|
||||||
const res2 = await httpRequest(url2, '', { headers })
|
|
||||||
results = Array.isArray(res2.data) ? res2.data : []
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No civic number — search by street name only
|
|
||||||
const pattern = wordsToIlike(clean)
|
|
||||||
if (!pattern) return []
|
|
||||||
let url = `${base}&odonyme_recompose_normal=ilike.${pattern}&order=nom_municipalite`
|
|
||||||
const res = await httpRequest(url, '', { headers })
|
|
||||||
results = Array.isArray(res.data) ? res.data : []
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check fiber availability for results
|
|
||||||
return results.map(a => ({
|
|
||||||
...a,
|
|
||||||
fiber_available: false, // TODO: join with fiber_availability table
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── OTP store (in-memory, TTL 10 min) ───────────────────────────────────────
|
|
||||||
const otpStore = new Map() // key = phone/email → { code, expires, customerId, customerName }
|
|
||||||
|
|
||||||
function generateOTP () {
|
|
||||||
return String(Math.floor(100000 + Math.random() * 900000))
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendOTP (identifier) {
|
|
||||||
const isEmail = identifier.includes('@')
|
|
||||||
const code = generateOTP()
|
|
||||||
const expires = Date.now() + 10 * 60 * 1000 // 10 min
|
|
||||||
|
|
||||||
// Lookup customer in ERPNext
|
|
||||||
let customer = null
|
|
||||||
if (isEmail) {
|
|
||||||
const res = await erpFetch(`/api/resource/Customer?filters=${encodeURIComponent(JSON.stringify([['email_id', '=', identifier]]))}&fields=["name","customer_name","cell_phone","email_id"]&limit_page_length=1`)
|
|
||||||
if (res.status === 200 && res.data?.data?.length) customer = res.data.data[0]
|
|
||||||
} else {
|
|
||||||
const { lookupCustomerByPhone } = require('./helpers')
|
|
||||||
customer = await lookupCustomerByPhone(identifier)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!customer) return { found: false }
|
|
||||||
|
|
||||||
otpStore.set(identifier, { code, expires, customerId: customer.name, customerName: customer.customer_name })
|
|
||||||
|
|
||||||
// Send code
|
|
||||||
if (isEmail) {
|
|
||||||
const { sendEmail } = require('./email')
|
|
||||||
await sendEmail({
|
|
||||||
to: identifier,
|
|
||||||
subject: 'Code de vérification Gigafibre',
|
|
||||||
html: `<div style="font-family:system-ui;max-width:400px;margin:0 auto;padding:24px">
|
|
||||||
<h2 style="color:#3949ab;margin:0 0 16px">Vérification</h2>
|
|
||||||
<p style="color:#334155;font-size:14px">Votre code de vérification est :</p>
|
|
||||||
<div style="font-size:32px;font-weight:700;letter-spacing:8px;text-align:center;padding:16px;background:#f1f5f9;border-radius:8px;color:#1e293b">${code}</div>
|
|
||||||
<p style="color:#94a3b8;font-size:12px;margin-top:16px">Ce code expire dans 10 minutes.</p>
|
|
||||||
</div>`,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
const { sendSmsInternal } = require('./twilio')
|
|
||||||
await sendSmsInternal(identifier, `Gigafibre — Votre code de vérification : ${code}\nExpire dans 10 min.`)
|
|
||||||
} catch (e) { log('OTP SMS failed:', e.message) }
|
|
||||||
}
|
|
||||||
|
|
||||||
log(`OTP sent to ${identifier} for customer ${customer.name}`)
|
|
||||||
return { found: true, sent: true, channel: isEmail ? 'email' : 'sms' }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function verifyOTP (identifier, code) {
|
|
||||||
const entry = otpStore.get(identifier)
|
|
||||||
if (!entry) return { valid: false, reason: 'no_otp' }
|
|
||||||
if (Date.now() > entry.expires) { otpStore.delete(identifier); return { valid: false, reason: 'expired' } }
|
|
||||||
if (entry.code !== code) return { valid: false, reason: 'wrong_code' }
|
|
||||||
otpStore.delete(identifier)
|
|
||||||
|
|
||||||
// Fetch customer details + addresses
|
|
||||||
const result = { valid: true, customer_id: entry.customerId, customer_name: entry.customerName }
|
|
||||||
try {
|
|
||||||
// Get customer details (phone, email)
|
|
||||||
const custRes = await erpFetch(`/api/resource/Customer/${encodeURIComponent(entry.customerId)}?fields=["name","customer_name","cell_phone","email_id","tel_home"]`)
|
|
||||||
if (custRes.status === 200 && custRes.data?.data) {
|
|
||||||
const c = custRes.data.data
|
|
||||||
result.phone = c.cell_phone || c.tel_home || ''
|
|
||||||
result.email = c.email_id || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no email on Customer, fetch from linked Contact
|
|
||||||
if (!result.email) {
|
|
||||||
try {
|
|
||||||
const contRes = await erpFetch(`/api/resource/Contact?filters=${encodeURIComponent(JSON.stringify([['Dynamic Link', 'link_doctype', '=', 'Customer'], ['Dynamic Link', 'link_name', '=', entry.customerId]]))}&fields=["email_id"]&limit_page_length=1`)
|
|
||||||
if (contRes.status === 200 && contRes.data?.data?.[0]?.email_id) {
|
|
||||||
result.email = contRes.data.data[0].email_id
|
|
||||||
}
|
|
||||||
} catch (e) { log('OTP - Contact email fallback error:', e.message) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get Service Locations (addresses)
|
|
||||||
const locRes = await erpFetch(`/api/resource/Service%20Location?filters=${encodeURIComponent(JSON.stringify([['customer', '=', entry.customerId]]))}&fields=["name","address_line","city","postal_code","location_name","latitude","longitude"]&limit_page_length=20`)
|
|
||||||
if (locRes.status === 200 && locRes.data?.data?.length) {
|
|
||||||
result.addresses = locRes.data.data.map(l => ({
|
|
||||||
name: l.name,
|
|
||||||
address: l.address_line || l.location_name || l.name,
|
|
||||||
city: l.city || '',
|
|
||||||
postal_code: l.postal_code || '',
|
|
||||||
latitude: l.latitude || null,
|
|
||||||
longitude: l.longitude || null,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
log('OTP verify - customer details fetch error:', e.message)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Product Catalog API ─────────────────────────────────────────────────────
|
// ── Product Catalog API ─────────────────────────────────────────────────────
|
||||||
// Serves the product catalog from ERPNext Items with custom fields
|
|
||||||
|
|
||||||
async function getCatalog () {
|
async function getCatalog () {
|
||||||
const fields = JSON.stringify([
|
const fields = [
|
||||||
'name', 'item_code', 'item_name', 'item_group', 'standard_rate',
|
'name', 'item_code', 'item_name', 'item_group', 'standard_rate',
|
||||||
'description', 'image',
|
'description', 'image',
|
||||||
'project_template_id', 'requires_visit', 'delivery_method',
|
'project_template_id', 'requires_visit', 'delivery_method',
|
||||||
'is_bundle_parent', 'billing_type', 'service_category',
|
'is_bundle_parent', 'billing_type', 'service_category',
|
||||||
])
|
]
|
||||||
const filters = JSON.stringify([['is_sales_item', '=', 1], ['disabled', '=', 0]])
|
const res = await erpQuery('Item', [['is_sales_item', '=', 1], ['disabled', '=', 0]], fields, 100, 'service_category,standard_rate')
|
||||||
const res = await erpFetch(`/api/resource/Item?fields=${encodeURIComponent(fields)}&filters=${encodeURIComponent(filters)}&limit_page_length=100&order_by=service_category,standard_rate`)
|
|
||||||
|
|
||||||
if (res.status !== 200) {
|
if (res.status !== 200) {
|
||||||
log('Catalog fetch failed:', res.status)
|
log('Catalog fetch failed:', res.status)
|
||||||
|
|
@ -191,13 +45,9 @@ async function getCatalog () {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Checkout / Order Processing ─────────────────────────────────────────────
|
// ── Checkout / Order Processing ─────────────────────────────────────────────
|
||||||
// Creates Sales Order + Dispatch Jobs from cart items + project template
|
|
||||||
|
|
||||||
async function processCheckout (body) {
|
async function processCheckout (body) {
|
||||||
// Accept both flat format and nested { contact: {...}, installation: {...} } format
|
const { contact = {}, installation = {} } = body
|
||||||
const contact = body.contact || {}
|
|
||||||
const installation = body.installation || {}
|
|
||||||
|
|
||||||
const items = body.items || []
|
const items = body.items || []
|
||||||
const customer_name = body.customer_name || contact.name || ''
|
const customer_name = body.customer_name || contact.name || ''
|
||||||
const phone = body.phone || contact.phone || ''
|
const phone = body.phone || contact.phone || ''
|
||||||
|
|
@ -207,11 +57,8 @@ async function processCheckout (body) {
|
||||||
const postal_code = body.postal_code || contact.postal_code || ''
|
const postal_code = body.postal_code || contact.postal_code || ''
|
||||||
const preferred_date = body.preferred_date || installation.preferred_date || ''
|
const preferred_date = body.preferred_date || installation.preferred_date || ''
|
||||||
const preferred_slot = body.preferred_slot || installation.preferred_slot || ''
|
const preferred_slot = body.preferred_slot || installation.preferred_slot || ''
|
||||||
const delivery_method = body.delivery_method || ''
|
|
||||||
const notes = body.notes || ''
|
const notes = body.notes || ''
|
||||||
const mode = body.mode || 'postpaid'
|
const mode = body.mode || 'postpaid'
|
||||||
const payment_intent_id = body.payment_intent_id || ''
|
|
||||||
const accepted_terms = body.accepted_terms
|
|
||||||
|
|
||||||
log('Checkout request:', { customer_name, phone, email, items: items.length, mode })
|
log('Checkout request:', { customer_name, phone, email, items: items.length, mode })
|
||||||
|
|
||||||
|
|
@ -226,13 +73,11 @@ async function processCheckout (body) {
|
||||||
let customerName = ''
|
let customerName = ''
|
||||||
const providedCustomerId = body.customer_id || ''
|
const providedCustomerId = body.customer_id || ''
|
||||||
try {
|
try {
|
||||||
// If OTP-verified customer_id provided, use it directly
|
|
||||||
if (providedCustomerId) {
|
if (providedCustomerId) {
|
||||||
customerName = providedCustomerId
|
customerName = providedCustomerId
|
||||||
result.customer_existing = true
|
result.customer_existing = true
|
||||||
log('Using OTP-verified customer:', customerName)
|
log('Using OTP-verified customer:', customerName)
|
||||||
}
|
}
|
||||||
// Search by phone
|
|
||||||
const { lookupCustomerByPhone } = require('./helpers')
|
const { lookupCustomerByPhone } = require('./helpers')
|
||||||
if (!customerName && phone) {
|
if (!customerName && phone) {
|
||||||
const existing = await lookupCustomerByPhone(phone)
|
const existing = await lookupCustomerByPhone(phone)
|
||||||
|
|
@ -241,10 +86,9 @@ async function processCheckout (body) {
|
||||||
result.customer_existing = true
|
result.customer_existing = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Create if not found
|
|
||||||
if (!customerName) {
|
if (!customerName) {
|
||||||
const custPayload = {
|
const custPayload = {
|
||||||
customer_name: customer_name,
|
customer_name,
|
||||||
customer_type: 'Individual',
|
customer_type: 'Individual',
|
||||||
customer_group: 'Individual',
|
customer_group: 'Individual',
|
||||||
territory: 'Canada',
|
territory: 'Canada',
|
||||||
|
|
@ -252,16 +96,12 @@ async function processCheckout (body) {
|
||||||
email_id: email || '',
|
email_id: email || '',
|
||||||
}
|
}
|
||||||
log('Creating customer:', custPayload)
|
log('Creating customer:', custPayload)
|
||||||
const custRes = await erpFetch('/api/resource/Customer', {
|
const custRes = await erpFetch('/api/resource/Customer', { method: 'POST', body: custPayload })
|
||||||
method: 'POST',
|
|
||||||
body: custPayload,
|
|
||||||
})
|
|
||||||
log('Customer creation result:', custRes.status, custRes.data?.data?.name || JSON.stringify(custRes.data).substring(0, 200))
|
log('Customer creation result:', custRes.status, custRes.data?.data?.name || JSON.stringify(custRes.data).substring(0, 200))
|
||||||
if (custRes.status === 200 && custRes.data?.data) {
|
if (custRes.status === 200 && custRes.data?.data) {
|
||||||
customerName = custRes.data.data.name
|
customerName = custRes.data.data.name
|
||||||
result.customer_created = true
|
result.customer_created = true
|
||||||
} else {
|
} else {
|
||||||
// Fallback: use name as-is
|
|
||||||
customerName = customer_name
|
customerName = customer_name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -271,7 +111,6 @@ async function processCheckout (body) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 2. Create Sales Order ──
|
// ── 2. Create Sales Order ──
|
||||||
const onetimeItems = items.filter(i => i.billing_type !== 'Mensuel' && i.billing_type !== 'Annuel')
|
|
||||||
const recurringItems = items.filter(i => i.billing_type === 'Mensuel' || i.billing_type === 'Annuel')
|
const recurringItems = items.filter(i => i.billing_type === 'Mensuel' || i.billing_type === 'Annuel')
|
||||||
const allItems = items.map(i => ({
|
const allItems = items.map(i => ({
|
||||||
item_code: i.item_code,
|
item_code: i.item_code,
|
||||||
|
|
@ -297,10 +136,7 @@ async function processCheckout (body) {
|
||||||
items: allItems,
|
items: allItems,
|
||||||
terms: notes || '',
|
terms: notes || '',
|
||||||
}
|
}
|
||||||
const soRes = await erpFetch('/api/resource/Sales%20Order', {
|
const soRes = await erpFetch('/api/resource/Sales%20Order', { method: 'POST', body: JSON.stringify(soPayload) })
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(soPayload),
|
|
||||||
})
|
|
||||||
if (soRes.status === 200 && soRes.data?.data) {
|
if (soRes.status === 200 && soRes.data?.data) {
|
||||||
orderName = soRes.data.data.name
|
orderName = soRes.data.data.name
|
||||||
result.sales_order = orderName
|
result.sales_order = orderName
|
||||||
|
|
@ -317,7 +153,6 @@ async function processCheckout (body) {
|
||||||
// ── 3. Create Subscriptions for recurring items ──
|
// ── 3. Create Subscriptions for recurring items ──
|
||||||
for (const item of recurringItems) {
|
for (const item of recurringItems) {
|
||||||
try {
|
try {
|
||||||
// Ensure plan exists
|
|
||||||
let planName = null
|
let planName = null
|
||||||
try {
|
try {
|
||||||
const findRes = await erpFetch(`/api/resource/Subscription%20Plan/${encodeURIComponent(item.item_code)}`)
|
const findRes = await erpFetch(`/api/resource/Subscription%20Plan/${encodeURIComponent(item.item_code)}`)
|
||||||
|
|
@ -362,7 +197,6 @@ async function processCheckout (body) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 4. Create Dispatch Jobs from project templates ──
|
// ── 4. Create Dispatch Jobs from project templates ──
|
||||||
// Collect unique templates needed
|
|
||||||
const templatesNeeded = new Set()
|
const templatesNeeded = new Set()
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (item.project_template_id) templatesNeeded.add(item.project_template_id)
|
if (item.project_template_id) templatesNeeded.add(item.project_template_id)
|
||||||
|
|
@ -374,7 +208,6 @@ async function processCheckout (body) {
|
||||||
const fullAddress = [address, city, postal_code].filter(Boolean).join(', ')
|
const fullAddress = [address, city, postal_code].filter(Boolean).join(', ')
|
||||||
|
|
||||||
for (const templateId of templatesNeeded) {
|
for (const templateId of templatesNeeded) {
|
||||||
// Fetch template steps (stored in frontend config, we replicate here)
|
|
||||||
const steps = getTemplateSteps(templateId)
|
const steps = getTemplateSteps(templateId)
|
||||||
if (!steps.length) continue
|
if (!steps.length) continue
|
||||||
|
|
||||||
|
|
@ -383,7 +216,7 @@ async function processCheckout (body) {
|
||||||
const ticketId = 'DJ-' + Date.now().toString(36).toUpperCase() + '-' + i
|
const ticketId = 'DJ-' + Date.now().toString(36).toUpperCase() + '-' + i
|
||||||
|
|
||||||
let dependsOn = ''
|
let dependsOn = ''
|
||||||
if (step.depends_on_step !== null && step.depends_on_step !== undefined) {
|
if (step.depends_on_step != null) {
|
||||||
const depJob = createdJobs[step.depends_on_step]
|
const depJob = createdJobs[step.depends_on_step]
|
||||||
if (depJob) dependsOn = depJob.name
|
if (depJob) dependsOn = depJob.name
|
||||||
}
|
}
|
||||||
|
|
@ -413,10 +246,7 @@ async function processCheckout (body) {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const jobRes = await erpFetch('/api/resource/Dispatch%20Job', {
|
const jobRes = await erpFetch('/api/resource/Dispatch%20Job', { method: 'POST', body: JSON.stringify(jobPayload) })
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(jobPayload),
|
|
||||||
})
|
|
||||||
if (jobRes.status === 200 && jobRes.data?.data) {
|
if (jobRes.status === 200 && jobRes.data?.data) {
|
||||||
createdJobs.push(jobRes.data.data)
|
createdJobs.push(jobRes.data.data)
|
||||||
}
|
}
|
||||||
|
|
@ -451,24 +281,7 @@ async function processCheckout (body) {
|
||||||
await sendEmail({
|
await sendEmail({
|
||||||
to: email,
|
to: email,
|
||||||
subject: `Confirmation de commande${orderName ? ` ${orderName}` : ''} — Gigafibre`,
|
subject: `Confirmation de commande${orderName ? ` ${orderName}` : ''} — Gigafibre`,
|
||||||
html: `<!DOCTYPE html><html><head><meta charset="utf-8"></head><body style="font-family:system-ui;margin:0;padding:0;background:#f1f5f9">
|
html: orderConfirmationHtml({ orderName, customer_name, itemRows, preferred_date, preferred_slot }),
|
||||||
<div style="max-width:560px;margin:0 auto;padding:24px">
|
|
||||||
<div style="background:white;border-radius:16px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,0.06)">
|
|
||||||
<div style="background:linear-gradient(135deg,#22c55e,#16a34a);color:white;padding:24px 28px">
|
|
||||||
<h1 style="margin:0;font-size:20px">Commande confirmée</h1>
|
|
||||||
<p style="margin:6px 0 0;opacity:0.85;font-size:13px">${orderName || 'Merci pour votre commande'}</p>
|
|
||||||
</div>
|
|
||||||
<div style="padding:24px 28px">
|
|
||||||
<p style="color:#334155;font-size:14px;margin:0 0 16px">Bonjour ${customer_name},</p>
|
|
||||||
<table style="width:100%;border-collapse:collapse;font-size:14px"><tbody>${itemRows}</tbody></table>
|
|
||||||
${preferred_date ? `<p style="margin:16px 0 0;color:#475569;font-size:13px"><strong>Date souhaitée :</strong> ${preferred_date} ${preferred_slot || ''}</p>` : ''}
|
|
||||||
<p style="margin:16px 0 0;color:#475569;font-size:13px">Nous vous contacterons pour confirmer les détails.</p>
|
|
||||||
</div>
|
|
||||||
<div style="border-top:1px solid #e2e8f0;padding:16px 28px;text-align:center">
|
|
||||||
<p style="color:#94a3b8;font-size:11px;margin:0">Gigafibre — Targo Télécommunications</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div></body></html>`,
|
|
||||||
})
|
})
|
||||||
result.email_sent = true
|
result.email_sent = true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -480,41 +293,9 @@ async function processCheckout (body) {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Project template steps (replicated from frontend config) ────────────────
|
|
||||||
|
|
||||||
function getTemplateSteps (templateId) {
|
|
||||||
const TEMPLATES = {
|
|
||||||
fiber_install: [
|
|
||||||
{ subject: 'Vérification pré-installation (éligibilité & OLT)', job_type: 'Autre', priority: 'medium', duration_h: 0.5, assigned_group: 'Admin', depends_on_step: null },
|
|
||||||
{ subject: 'Installation fibre chez le client', job_type: 'Installation', priority: 'high', duration_h: 3, assigned_group: 'Tech Targo', depends_on_step: 0 },
|
|
||||||
{ subject: 'Activation du service & configuration ONT', job_type: 'Installation', priority: 'high', duration_h: 0.5, assigned_group: 'Admin', depends_on_step: 1 },
|
|
||||||
{ 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 },
|
|
||||||
],
|
|
||||||
phone_service: [
|
|
||||||
{ 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 },
|
|
||||||
{ subject: 'Installation fibre (pré-requis portage)', job_type: 'Installation', priority: 'high', duration_h: 2, assigned_group: 'Tech Targo', depends_on_step: 0 },
|
|
||||||
{ subject: 'Portage du numéro vers Gigafibre', job_type: 'Autre', priority: 'medium', duration_h: 0.5, assigned_group: 'Admin', depends_on_step: 1 },
|
|
||||||
{ 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 },
|
|
||||||
],
|
|
||||||
move_service: [
|
|
||||||
{ 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 },
|
|
||||||
{ subject: 'Retrait équipement ancien site', job_type: 'Retrait', priority: 'medium', duration_h: 1, assigned_group: 'Tech Targo', depends_on_step: 0 },
|
|
||||||
{ subject: 'Installation au nouveau site', job_type: 'Installation', priority: 'high', duration_h: 3, assigned_group: 'Tech Targo', depends_on_step: 1 },
|
|
||||||
{ subject: 'Transfert abonnement & mise à jour adresse', job_type: 'Autre', priority: 'medium', duration_h: 0.5, assigned_group: 'Admin', depends_on_step: 2 },
|
|
||||||
],
|
|
||||||
repair_service: [
|
|
||||||
{ subject: 'Diagnostic à distance', job_type: 'Dépannage', priority: 'high', duration_h: 0.5, assigned_group: 'Admin', depends_on_step: null },
|
|
||||||
{ subject: 'Intervention terrain', job_type: 'Réparation', priority: 'high', duration_h: 2, assigned_group: 'Tech Targo', depends_on_step: 0 },
|
|
||||||
{ subject: 'Validation & suivi client', job_type: 'Dépannage', priority: 'medium', duration_h: 0.5, assigned_group: 'Admin', depends_on_step: 1 },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
return TEMPLATES[templateId] || []
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── HTTP Handler ────────────────────────────────────────────────────────────
|
// ── HTTP Handler ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function handle (req, res, method, path) {
|
async function handle (req, res, method, path) {
|
||||||
// GET /api/catalog — Public product catalog
|
|
||||||
if (path === '/api/catalog' && method === 'GET') {
|
if (path === '/api/catalog' && method === 'GET') {
|
||||||
try {
|
try {
|
||||||
const catalog = await getCatalog()
|
const catalog = await getCatalog()
|
||||||
|
|
@ -525,7 +306,6 @@ async function handle (req, res, method, path) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/checkout — Process order
|
|
||||||
if (path === '/api/checkout' && method === 'POST') {
|
if (path === '/api/checkout' && method === 'POST') {
|
||||||
try {
|
try {
|
||||||
const body = await parseBody(req)
|
const body = await parseBody(req)
|
||||||
|
|
@ -537,18 +317,16 @@ async function handle (req, res, method, path) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/orders — List recent orders (Sales Orders)
|
|
||||||
if (path === '/api/orders' && method === 'GET') {
|
if (path === '/api/orders' && method === 'GET') {
|
||||||
try {
|
try {
|
||||||
const { URL } = require('url')
|
const { URL } = require('url')
|
||||||
const url = new URL(req.url, 'http://localhost')
|
const url = new URL(req.url, 'http://localhost')
|
||||||
const limit = parseInt(url.searchParams.get('limit') || '20', 10)
|
const limit = parseInt(url.searchParams.get('limit') || '20', 10)
|
||||||
const customer = url.searchParams.get('customer') || ''
|
const customer = url.searchParams.get('customer') || ''
|
||||||
const fields = JSON.stringify(['name', 'customer', 'customer_name', 'transaction_date', 'grand_total', 'status', 'creation'])
|
const fields = ['name', 'customer', 'customer_name', 'transaction_date', 'grand_total', 'status', 'creation']
|
||||||
let filters = [['docstatus', '=', 0]]
|
let filters = [['docstatus', '=', 0]]
|
||||||
if (customer) filters.push(['customer', '=', customer])
|
if (customer) filters.push(['customer', '=', customer])
|
||||||
const fStr = JSON.stringify(filters)
|
const soRes = await erpQuery('Sales Order', filters, fields, limit, 'creation desc')
|
||||||
const soRes = await erpFetch(`/api/resource/Sales%20Order?fields=${encodeURIComponent(fields)}&filters=${encodeURIComponent(fStr)}&order_by=creation desc&limit_page_length=${limit}`)
|
|
||||||
return json(res, 200, { ok: true, orders: soRes.data?.data || [] })
|
return json(res, 200, { ok: true, orders: soRes.data?.data || [] })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log('Orders list error:', e.message)
|
log('Orders list error:', e.message)
|
||||||
|
|
@ -556,7 +334,6 @@ async function handle (req, res, method, path) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/order/:name — Single order detail
|
|
||||||
if (path.startsWith('/api/order/') && method === 'GET') {
|
if (path.startsWith('/api/order/') && method === 'GET') {
|
||||||
try {
|
try {
|
||||||
const orderName = decodeURIComponent(path.replace('/api/order/', ''))
|
const orderName = decodeURIComponent(path.replace('/api/order/', ''))
|
||||||
|
|
@ -568,7 +345,6 @@ async function handle (req, res, method, path) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/address-search — Address autocomplete (Supabase RQA)
|
|
||||||
if (path === '/api/address-search' && method === 'POST') {
|
if (path === '/api/address-search' && method === 'POST') {
|
||||||
try {
|
try {
|
||||||
const body = await parseBody(req)
|
const body = await parseBody(req)
|
||||||
|
|
@ -581,7 +357,6 @@ async function handle (req, res, method, path) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/otp/send — Send OTP to existing customer
|
|
||||||
if (path === '/api/otp/send' && method === 'POST') {
|
if (path === '/api/otp/send' && method === 'POST') {
|
||||||
try {
|
try {
|
||||||
const body = await parseBody(req)
|
const body = await parseBody(req)
|
||||||
|
|
@ -594,7 +369,6 @@ async function handle (req, res, method, path) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/otp/verify — Verify OTP code
|
|
||||||
if (path === '/api/otp/verify' && method === 'POST') {
|
if (path === '/api/otp/verify' && method === 'POST') {
|
||||||
try {
|
try {
|
||||||
const body = await parseBody(req)
|
const body = await parseBody(req)
|
||||||
|
|
@ -606,18 +380,13 @@ async function handle (req, res, method, path) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/accept-for-client — Agent accepts quotation on behalf of client
|
|
||||||
if (path === '/api/accept-for-client' && method === 'POST') {
|
if (path === '/api/accept-for-client' && method === 'POST') {
|
||||||
try {
|
try {
|
||||||
const body = await parseBody(req)
|
const body = await parseBody(req)
|
||||||
const { quotation, agent_name } = body
|
const { quotation, agent_name } = body
|
||||||
if (!quotation) return json(res, 400, { error: 'quotation required' })
|
if (!quotation) return json(res, 400, { error: 'quotation required' })
|
||||||
|
|
||||||
const { acceptQuotation, fetchQuotation } = require('./acceptance')
|
|
||||||
// Intentionally not exported but we call it directly here
|
|
||||||
const acceptance = require('./acceptance')
|
const acceptance = require('./acceptance')
|
||||||
|
|
||||||
// Record acceptance by agent
|
|
||||||
const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress || ''
|
const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress || ''
|
||||||
await acceptance.acceptQuotation(quotation, {
|
await acceptance.acceptQuotation(quotation, {
|
||||||
method: 'Accepté par agent',
|
method: 'Accepté par agent',
|
||||||
|
|
|
||||||
33
services/targo-hub/lib/email-templates.js
Normal file
33
services/targo-hub/lib/email-templates.js
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
function otpEmailHtml (code) {
|
||||||
|
return `<div style="font-family:system-ui;max-width:400px;margin:0 auto;padding:24px">
|
||||||
|
<h2 style="color:#3949ab;margin:0 0 16px">Vérification</h2>
|
||||||
|
<p style="color:#334155;font-size:14px">Votre code de vérification est :</p>
|
||||||
|
<div style="font-size:32px;font-weight:700;letter-spacing:8px;text-align:center;padding:16px;background:#f1f5f9;border-radius:8px;color:#1e293b">${code}</div>
|
||||||
|
<p style="color:#94a3b8;font-size:12px;margin-top:16px">Ce code expire dans 10 minutes.</p>
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderConfirmationHtml ({ orderName, customer_name, itemRows, preferred_date, preferred_slot }) {
|
||||||
|
return `<!DOCTYPE html><html><head><meta charset="utf-8"></head><body style="font-family:system-ui;margin:0;padding:0;background:#f1f5f9">
|
||||||
|
<div style="max-width:560px;margin:0 auto;padding:24px">
|
||||||
|
<div style="background:white;border-radius:16px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,0.06)">
|
||||||
|
<div style="background:linear-gradient(135deg,#22c55e,#16a34a);color:white;padding:24px 28px">
|
||||||
|
<h1 style="margin:0;font-size:20px">Commande confirmée</h1>
|
||||||
|
<p style="margin:6px 0 0;opacity:0.85;font-size:13px">${orderName || 'Merci pour votre commande'}</p>
|
||||||
|
</div>
|
||||||
|
<div style="padding:24px 28px">
|
||||||
|
<p style="color:#334155;font-size:14px;margin:0 0 16px">Bonjour ${customer_name},</p>
|
||||||
|
<table style="width:100%;border-collapse:collapse;font-size:14px"><tbody>${itemRows}</tbody></table>
|
||||||
|
${preferred_date ? `<p style="margin:16px 0 0;color:#475569;font-size:13px"><strong>Date souhaitée :</strong> ${preferred_date} ${preferred_slot || ''}</p>` : ''}
|
||||||
|
<p style="margin:16px 0 0;color:#475569;font-size:13px">Nous vous contacterons pour confirmer les détails.</p>
|
||||||
|
</div>
|
||||||
|
<div style="border-top:1px solid #e2e8f0;padding:16px 28px;text-align:center">
|
||||||
|
<p style="color:#94a3b8;font-size:11px;margin:0">Gigafibre — Targo Télécommunications</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div></body></html>`
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { otpEmailHtml, orderConfirmationHtml }
|
||||||
100
services/targo-hub/lib/otp.js
Normal file
100
services/targo-hub/lib/otp.js
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
'use strict'
|
||||||
|
const { log, erpFetch } = require('./helpers')
|
||||||
|
const { otpEmailHtml } = require('./email-templates')
|
||||||
|
|
||||||
|
const otpStore = new Map()
|
||||||
|
|
||||||
|
function generateOTP () {
|
||||||
|
return String(Math.floor(100000 + Math.random() * 900000))
|
||||||
|
}
|
||||||
|
|
||||||
|
function erpQuery (doctype, filters, fields, limit) {
|
||||||
|
let url = `/api/resource/${encodeURIComponent(doctype)}?filters=${encodeURIComponent(JSON.stringify(filters))}&fields=${encodeURIComponent(JSON.stringify(fields))}`
|
||||||
|
if (limit) url += `&limit_page_length=${limit}`
|
||||||
|
return erpFetch(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendOTP (identifier) {
|
||||||
|
const isEmail = identifier.includes('@')
|
||||||
|
const code = generateOTP()
|
||||||
|
const expires = Date.now() + 10 * 60 * 1000
|
||||||
|
|
||||||
|
let customer = null
|
||||||
|
if (isEmail) {
|
||||||
|
const res = await erpQuery('Customer', [['email_id', '=', identifier]], ['name', 'customer_name', 'cell_phone', 'email_id'], 1)
|
||||||
|
if (res.status === 200 && res.data?.data?.length) customer = res.data.data[0]
|
||||||
|
} else {
|
||||||
|
const { lookupCustomerByPhone } = require('./helpers')
|
||||||
|
customer = await lookupCustomerByPhone(identifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!customer) return { found: false }
|
||||||
|
|
||||||
|
otpStore.set(identifier, { code, expires, customerId: customer.name, customerName: customer.customer_name })
|
||||||
|
|
||||||
|
if (isEmail) {
|
||||||
|
const { sendEmail } = require('./email')
|
||||||
|
await sendEmail({
|
||||||
|
to: identifier,
|
||||||
|
subject: 'Code de vérification Gigafibre',
|
||||||
|
html: otpEmailHtml(code),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const { sendSmsInternal } = require('./twilio')
|
||||||
|
await sendSmsInternal(identifier, `Gigafibre — Votre code de vérification : ${code}\nExpire dans 10 min.`)
|
||||||
|
} catch (e) { log('OTP SMS failed:', e.message) }
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`OTP sent to ${identifier} for customer ${customer.name}`)
|
||||||
|
return { found: true, sent: true, channel: isEmail ? 'email' : 'sms' }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyOTP (identifier, code) {
|
||||||
|
const entry = otpStore.get(identifier)
|
||||||
|
if (!entry) return { valid: false, reason: 'no_otp' }
|
||||||
|
if (Date.now() > entry.expires) { otpStore.delete(identifier); return { valid: false, reason: 'expired' } }
|
||||||
|
if (entry.code !== code) return { valid: false, reason: 'wrong_code' }
|
||||||
|
otpStore.delete(identifier)
|
||||||
|
|
||||||
|
const result = { valid: true, customer_id: entry.customerId, customer_name: entry.customerName }
|
||||||
|
try {
|
||||||
|
const custRes = await erpFetch(`/api/resource/Customer/${encodeURIComponent(entry.customerId)}?fields=${encodeURIComponent(JSON.stringify(['name', 'customer_name', 'cell_phone', 'email_id', 'tel_home']))}`)
|
||||||
|
if (custRes.status === 200 && custRes.data?.data) {
|
||||||
|
const c = custRes.data.data
|
||||||
|
result.phone = c.cell_phone || c.tel_home || ''
|
||||||
|
result.email = c.email_id || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.email) {
|
||||||
|
try {
|
||||||
|
const contRes = await erpQuery('Contact',
|
||||||
|
[['Dynamic Link', 'link_doctype', '=', 'Customer'], ['Dynamic Link', 'link_name', '=', entry.customerId]],
|
||||||
|
['email_id'], 1)
|
||||||
|
if (contRes.status === 200 && contRes.data?.data?.[0]?.email_id) {
|
||||||
|
result.email = contRes.data.data[0].email_id
|
||||||
|
}
|
||||||
|
} catch (e) { log('OTP - Contact email fallback error:', e.message) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const locRes = await erpQuery('Service Location',
|
||||||
|
[['customer', '=', entry.customerId]],
|
||||||
|
['name', 'address_line', 'city', 'postal_code', 'location_name', 'latitude', 'longitude'], 20)
|
||||||
|
if (locRes.status === 200 && locRes.data?.data?.length) {
|
||||||
|
result.addresses = locRes.data.data.map(l => ({
|
||||||
|
name: l.name,
|
||||||
|
address: l.address_line || l.location_name || l.name,
|
||||||
|
city: l.city || '',
|
||||||
|
postal_code: l.postal_code || '',
|
||||||
|
latitude: l.latitude || null,
|
||||||
|
longitude: l.longitude || null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log('OTP verify - customer details fetch error:', e.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { otpStore, generateOTP, sendOTP, verifyOTP }
|
||||||
33
services/targo-hub/lib/project-templates.js
Normal file
33
services/targo-hub/lib/project-templates.js
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
const TEMPLATES = {
|
||||||
|
fiber_install: [
|
||||||
|
{ subject: 'Vérification pré-installation (éligibilité & OLT)', job_type: 'Autre', priority: 'medium', duration_h: 0.5, assigned_group: 'Admin', depends_on_step: null },
|
||||||
|
{ subject: 'Installation fibre chez le client', job_type: 'Installation', priority: 'high', duration_h: 3, assigned_group: 'Tech Targo', depends_on_step: 0 },
|
||||||
|
{ subject: 'Activation du service & configuration ONT', job_type: 'Installation', priority: 'high', duration_h: 0.5, assigned_group: 'Admin', depends_on_step: 1 },
|
||||||
|
{ 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 },
|
||||||
|
],
|
||||||
|
phone_service: [
|
||||||
|
{ 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 },
|
||||||
|
{ subject: 'Installation fibre (pré-requis portage)', job_type: 'Installation', priority: 'high', duration_h: 2, assigned_group: 'Tech Targo', depends_on_step: 0 },
|
||||||
|
{ subject: 'Portage du numéro vers Gigafibre', job_type: 'Autre', priority: 'medium', duration_h: 0.5, assigned_group: 'Admin', depends_on_step: 1 },
|
||||||
|
{ 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 },
|
||||||
|
],
|
||||||
|
move_service: [
|
||||||
|
{ 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 },
|
||||||
|
{ subject: 'Retrait équipement ancien site', job_type: 'Retrait', priority: 'medium', duration_h: 1, assigned_group: 'Tech Targo', depends_on_step: 0 },
|
||||||
|
{ subject: 'Installation au nouveau site', job_type: 'Installation', priority: 'high', duration_h: 3, assigned_group: 'Tech Targo', depends_on_step: 1 },
|
||||||
|
{ subject: 'Transfert abonnement & mise à jour adresse', job_type: 'Autre', priority: 'medium', duration_h: 0.5, assigned_group: 'Admin', depends_on_step: 2 },
|
||||||
|
],
|
||||||
|
repair_service: [
|
||||||
|
{ subject: 'Diagnostic à distance', job_type: 'Dépannage', priority: 'high', duration_h: 0.5, assigned_group: 'Admin', depends_on_step: null },
|
||||||
|
{ subject: 'Intervention terrain', job_type: 'Réparation', priority: 'high', duration_h: 2, assigned_group: 'Tech Targo', depends_on_step: 0 },
|
||||||
|
{ subject: 'Validation & suivi client', job_type: 'Dépannage', priority: 'medium', duration_h: 0.5, assigned_group: 'Admin', depends_on_step: 1 },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTemplateSteps (templateId) {
|
||||||
|
return TEMPLATES[templateId] || []
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { TEMPLATES, getTemplateSteps }
|
||||||
Loading…
Reference in New Issue
Block a user