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="col-12 col-lg-8">
|
||||
|
||||
<!-- SPLIT CANDIDATE: CustomerHeaderSection.vue -->
|
||||
<CustomerHeader :customer="customer">
|
||||
<template #contact><ContactCard :customer="customer" /></template>
|
||||
<template #info><CustomerInfoCard :customer="customer" /></template>
|
||||
</CustomerHeader>
|
||||
|
||||
<!-- SPLIT CANDIDATE: LocationsSection.vue -->
|
||||
<q-expansion-item v-model="sectionsOpen.locations" header-class="section-header" class="q-mb-sm">
|
||||
<template #header>
|
||||
<div class="section-title" style="font-size:1rem;width:100%">
|
||||
|
|
@ -130,7 +128,6 @@
|
|||
</q-list>
|
||||
</q-menu>
|
||||
</div>
|
||||
<!-- Add equipment button -->
|
||||
<div class="device-icon-chip device-add-chip" @click="openAddEquipment(loc)">
|
||||
<q-icon name="add" size="20px" />
|
||||
<q-tooltip>Ajouter un equipement</q-tooltip>
|
||||
|
|
@ -138,7 +135,6 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SPLIT CANDIDATE: SubscriptionsBlock.vue -->
|
||||
<div class="info-block subs-block">
|
||||
<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>
|
||||
|
|
@ -288,7 +284,6 @@
|
|||
</div>
|
||||
</q-expansion-item>
|
||||
|
||||
<!-- SPLIT CANDIDATE: TicketsSection.vue -->
|
||||
<q-expansion-item v-model="sectionsOpen.tickets" header-class="section-header" class="q-mb-sm">
|
||||
<template #header>
|
||||
<div class="section-title" style="font-size:1rem;width:100%;display:flex;align-items:center">
|
||||
|
|
@ -372,7 +367,6 @@
|
|||
</div>
|
||||
</q-expansion-item>
|
||||
|
||||
<!-- SPLIT CANDIDATE: InvoicesSection.vue -->
|
||||
<q-expansion-item v-model="sectionsOpen.invoices" header-class="section-header" class="q-mb-sm">
|
||||
<template #header>
|
||||
<div class="section-title" style="font-size:1rem;width:100%">
|
||||
|
|
@ -412,7 +406,6 @@
|
|||
</div>
|
||||
</q-expansion-item>
|
||||
|
||||
<!-- SPLIT CANDIDATE: PaymentsSection.vue -->
|
||||
<q-expansion-item v-model="sectionsOpen.payments" header-class="section-header" class="q-mb-sm">
|
||||
<template #header>
|
||||
<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" />
|
||||
</div>
|
||||
|
||||
<!-- Add Equipment Dialog -->
|
||||
<q-dialog v-model="addEquipOpen" persistent>
|
||||
<q-card style="width:520px;max-width:95vw">
|
||||
<q-card-section class="row items-center q-pb-sm">
|
||||
|
|
@ -477,7 +469,6 @@
|
|||
<q-separator />
|
||||
|
||||
<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">
|
||||
<q-icon name="qr_code_scanner" size="24px" color="green-7" class="q-mr-sm" />
|
||||
<div class="col">
|
||||
|
|
@ -490,7 +481,6 @@
|
|||
:loading="scannerState.scanning.value" @click="$refs.scanInput.click()" />
|
||||
</div>
|
||||
|
||||
<!-- Scanned codes -->
|
||||
<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="row q-gutter-xs">
|
||||
|
|
@ -507,14 +497,12 @@
|
|||
{{ scannerState.error.value }}
|
||||
</div>
|
||||
|
||||
<!-- Scan photo preview -->
|
||||
<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" />
|
||||
</div>
|
||||
|
||||
<q-separator class="q-mb-md" />
|
||||
|
||||
<!-- Manual form -->
|
||||
<div class="row q-col-gutter-sm">
|
||||
<div class="col-6">
|
||||
<q-select v-model="newEquip.equipment_type" label="Type" outlined dense emit-value map-options
|
||||
|
|
@ -565,7 +553,6 @@
|
|||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<!-- Create Ticket Dialog (Unified) -->
|
||||
<UnifiedCreateModal v-model="newTicketOpen" mode="ticket"
|
||||
:context="ticketContext" :locations="locations"
|
||||
@created="onTicketCreated" />
|
||||
|
|
@ -587,9 +574,8 @@
|
|||
import { ref, computed, onMounted, reactive, watch } from 'vue'
|
||||
import { Notify, useQuasar } from 'quasar'
|
||||
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 { BASE_URL } from 'src/config/erpnext'
|
||||
import { formatDate, formatMoney, staffColor, staffInitials } from 'src/composables/useFormatters'
|
||||
import { locStatusClass, ticketStatusClass, invStatusClass, deviceColorClass, priorityClass } from 'src/composables/useStatusClasses'
|
||||
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 { useDeviceStatus } from 'src/composables/useDeviceStatus'
|
||||
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 { can } = usePermissions()
|
||||
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 tickets = ref([])
|
||||
const invoices = ref([])
|
||||
const payments = ref([])
|
||||
const comments = ref([])
|
||||
const accountBalance = ref(null)
|
||||
const ticketsExpanded = ref(false)
|
||||
const invoicesExpanded = ref(false)
|
||||
const paymentsExpanded = ref(false)
|
||||
|
||||
// Location inline fields config for v-for consolidation
|
||||
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: ' ' },
|
||||
]
|
||||
const { fetchStatus, fetchOltStatus, getDevice, isOnline, combinedStatus, signalQuality, rebootDevice, refreshDeviceParams } = useDeviceStatus()
|
||||
|
||||
const {
|
||||
modalOpen, modalLoading, modalDoctype, modalDocName, modalTitle,
|
||||
modalDoc, modalComments, modalComms, modalFiles, modalDocFields,
|
||||
modalDispatchJobs, openModal,
|
||||
} = 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 {
|
||||
locSubs, locSubsMonthly, locSubsAnnual, locSubsMonthlyTotal, locSubsAnnualTotal,
|
||||
|
|
@ -646,13 +634,13 @@ const {
|
|||
} = useSubscriptionActions(subscriptions, customer, comments, invalidateCache)
|
||||
|
||||
const { onNoteAdded } = useCustomerNotes(comments, customer)
|
||||
const { fetchStatus, fetchOltStatus, getDevice, isOnline, combinedStatus, signalQuality, rebootDevice, refreshDeviceParams, loading: deviceLoading } = useDeviceStatus()
|
||||
|
||||
const {
|
||||
modalOpen, modalLoading, modalDoctype, modalDocName, modalTitle,
|
||||
modalDoc, modalComments, modalComms, modalFiles, modalDocFields,
|
||||
modalDispatchJobs, openModal,
|
||||
} = useDetailModal()
|
||||
scannerState, addEquipOpen, addEquipLoc, addingEquip,
|
||||
equipLookupResult, equipLookingUp, newEquip,
|
||||
openAddEquipment, closeAddEquipment, onScanPhoto,
|
||||
applyScannedCode, createEquipment, linkExistingEquipment,
|
||||
} = useEquipmentActions(customer, equipment)
|
||||
|
||||
function onDispatchCreated (job) { modalDispatchJobs.value.push(job) }
|
||||
|
||||
|
|
@ -699,7 +687,6 @@ const sortedLocations = computed(() => {
|
|||
return [...withSubs, ...withoutSubs]
|
||||
})
|
||||
|
||||
// ── Delete location ──
|
||||
const deletingLoc = ref(null)
|
||||
function confirmDeleteLocation (loc) {
|
||||
if (locHasSubs(loc.name)) return
|
||||
|
|
@ -723,151 +710,18 @@ function confirmDeleteLocation (loc) {
|
|||
})
|
||||
}
|
||||
|
||||
// ── Handle equipment/entity deletion from DetailModal ──
|
||||
function onEntityDeleted (docName) {
|
||||
equipment.value = equipment.value.filter(e => e.name !== docName)
|
||||
}
|
||||
|
||||
// ═══ Add Equipment with Scanner ═══
|
||||
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 sectionsOpen = ref({ ...defaultSectionsOpen })
|
||||
|
||||
const openTicketCount = computed(() => tickets.value.filter(t => t.status === 'Open').length)
|
||||
const totalOutstanding = computed(() => accountBalance.value?.balance ?? 0)
|
||||
|
||||
const customerPhoneOptions = computed(() => {
|
||||
if (!customer.value) return []
|
||||
const map = { cell_phone: 'Cell', tel_home: 'Maison', tel_office: 'Bureau' }
|
||||
return Object.entries(map)
|
||||
return Object.entries(phoneLabelMap)
|
||||
.filter(([k]) => 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 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) {
|
||||
try {
|
||||
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 ticketContext = ref({})
|
||||
|
||||
|
|
@ -1030,48 +767,6 @@ function onTicketCreated (doc) {
|
|||
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) })
|
||||
onMounted(() => loadCustomer(props.id))
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { ref, computed, watch, onMounted, onUnmounted, nextTick, provide } from
|
|||
import { useDispatchStore } from 'src/stores/dispatch'
|
||||
import { useAuthStore } from 'src/stores/auth'
|
||||
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 { 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 MonthCalendar from 'src/modules/dispatch/components/MonthCalendar.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 {
|
||||
localDateStr, timeToH, hToTime, fmtDur,
|
||||
SVC_COLORS, prioLabel, prioClass, serializeAssistants,
|
||||
jobColor as _jobColorBase, ICON, prioColor,
|
||||
WEEK_DAYS, DAY_LABELS, DEFAULT_WEEKLY_SCHEDULE, SCHEDULE_PRESETS,
|
||||
} from 'src/composables/useHelpers'
|
||||
import { useScheduler } from 'src/composables/useScheduler'
|
||||
import { useUndo } from 'src/composables/useUndo'
|
||||
|
|
@ -34,6 +38,7 @@ import { useTagManagement } from 'src/composables/useTagManagement'
|
|||
import { useContextMenus } from 'src/composables/useContextMenus'
|
||||
import { useTechManagement } from 'src/composables/useTechManagement'
|
||||
import { useAddressSearch } from 'src/composables/useAddressSearch'
|
||||
import { useAbsenceResize } from 'src/composables/useAbsenceResize'
|
||||
|
||||
const store = useDispatchStore()
|
||||
const auth = useAuthStore()
|
||||
|
|
@ -61,7 +66,7 @@ const {
|
|||
|
||||
const { addrResults, addrLoading, searchAddr, selectAddr } = useAddressSearch()
|
||||
|
||||
function setEndDate (job, endDate) {
|
||||
const setEndDate = (job, endDate) => {
|
||||
job.endDate = endDate || null
|
||||
updateJob(job.name || job.id, { end_date: endDate || '' }).catch(() => {})
|
||||
}
|
||||
|
|
@ -97,8 +102,8 @@ function confirmEdit () {
|
|||
invalidateRoutes()
|
||||
}
|
||||
|
||||
function getJobDate (jobId) { return store.jobs.find(j => j.id === jobId)?.scheduledDate || null }
|
||||
function getJobTime (jobId) { return store.jobs.find(j => j.id === jobId)?.startTime || null }
|
||||
const getJobDate = jobId => store.jobs.find(j => j.id === jobId)?.scheduledDate || null
|
||||
const getJobTime = jobId => store.jobs.find(j => j.id === jobId)?.startTime || null
|
||||
function setJobTime (jobId, time) {
|
||||
const job = store.jobs.find(j => j.id === jobId)
|
||||
if (!job) return
|
||||
|
|
@ -107,11 +112,9 @@ function setJobTime (jobId, time) {
|
|||
}
|
||||
|
||||
const timeModal = ref(null)
|
||||
function openTimeModal (job, techId) {
|
||||
timeModal.value = { job, techId, time: getJobTime(job.id) || '08:00', hasPin: !!getJobTime(job.id) }
|
||||
}
|
||||
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 openTimeModal = (job, techId) => { 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 }
|
||||
|
||||
const pendingReqs = ref([])
|
||||
const pendingLoading = ref(false)
|
||||
|
|
@ -122,11 +125,9 @@ async function loadPendingReqs () {
|
|||
}
|
||||
const unscheduledJobs = computed(() => store.jobs.filter(j => !j.assignedTech))
|
||||
const teamJobs = computed(() => store.jobs.filter(j => j.assistants?.length > 0))
|
||||
|
||||
function jobColor (job) { return _jobColorBase(job, TECH_COLORS, store) }
|
||||
const jobColor = job => _jobColorBase(job, TECH_COLORS, store)
|
||||
|
||||
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 dayW = computed(() => currentView.value === 'month' ? 110 : (H_END - H_START) * pxPerHr.value)
|
||||
const totalW = computed(() => dayW.value * periodDays.value)
|
||||
|
|
@ -137,6 +138,8 @@ const {
|
|||
periodLoadH, techPeriodCapacityH, techDayEndH,
|
||||
} = useScheduler(store, currentView, periodStart, periodDays, dayColumns, pxPerHr, getJobDate, getJobTime, jobColor)
|
||||
|
||||
const { startAbsenceResize } = useAbsenceResize(pxPerHr, H_START)
|
||||
|
||||
const hourTicks = computed(() => {
|
||||
if (currentView.value === 'month') return []
|
||||
const ticks = []
|
||||
|
|
@ -154,7 +157,7 @@ const unassignDropActive = ref(false)
|
|||
|
||||
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) {
|
||||
pushUndo({ type: 'unassignJob', jobId: job.id, techId: job.assignedTech, routeOrder: job.routeOrder, scheduledDate: job.scheduledDate, assistants: [...job.assistants] })
|
||||
store.fullUnassign(job.id)
|
||||
|
|
@ -183,9 +186,7 @@ const {
|
|||
techHasLinkedJob, techIsHovered,
|
||||
} = useSelection({ store, periodStart, smartAssign, invalidateRoutes, fullUnassign })
|
||||
|
||||
function selectJob (job, techId, isAssist = false, assistTechId = null, event = null) {
|
||||
_selectJob(job, techId, isAssist, assistTechId, event, rightPanel)
|
||||
}
|
||||
const selectJob = (job, techId, isAssist = false, assistTechId = null, event = null) => _selectJob(job, techId, isAssist, assistTechId, event, rightPanel)
|
||||
|
||||
const {
|
||||
dragJob, dragSrc, dragIsAssist, dragBatchIds, dropGhost,
|
||||
|
|
@ -199,60 +200,6 @@ const {
|
|||
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
|
||||
const _map = useMap({
|
||||
store, MAPBOX_TOKEN, TECH_COLORS,
|
||||
|
|
@ -315,9 +262,7 @@ const periodEndStr = computed(() => {
|
|||
d.setDate(d.getDate() + (periodDays.value || 7) - 1)
|
||||
return d.toISOString().slice(0, 10)
|
||||
})
|
||||
function onPublished (jobNames) {
|
||||
store.publishJobsLocal(jobNames)
|
||||
}
|
||||
const onPublished = jobNames => store.publishJobsLocal(jobNames)
|
||||
const gpsSettingsOpen = ref(false)
|
||||
const gpsShowInactive = ref(false)
|
||||
const gpsFilteredTechs = computed(() =>
|
||||
|
|
@ -335,8 +280,6 @@ const {
|
|||
|
||||
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 scheduleForm = ref({})
|
||||
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' }
|
||||
})
|
||||
}
|
||||
function applySchedulePreset (preset) {
|
||||
const applySchedulePreset = preset => {
|
||||
WEEK_DAYS.forEach(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' }
|
||||
|
|
@ -363,7 +306,6 @@ function confirmSchedule () {
|
|||
scheduleModalTech.value = null
|
||||
}
|
||||
|
||||
// Group technicians for the resource selector modal
|
||||
const resSelectorGroupFilter = ref('')
|
||||
const resSelectorSearch = ref('')
|
||||
const savedPresets = ref(JSON.parse(localStorage.getItem('sbv2-resPresets') || '[]'))
|
||||
|
|
@ -381,11 +323,7 @@ function savePreset () {
|
|||
presetNameInput.value = ''
|
||||
showPresetSave.value = false
|
||||
}
|
||||
|
||||
function loadPreset (preset) {
|
||||
tempSelectedIds.value = [...preset.ids]
|
||||
}
|
||||
|
||||
const loadPreset = preset => { tempSelectedIds.value = [...preset.ids] }
|
||||
function deletePreset (idx) {
|
||||
savedPresets.value.splice(idx, 1)
|
||||
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 }))
|
||||
}
|
||||
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)
|
||||
// Apply search filter
|
||||
if (resSelectorSearch.value) {
|
||||
const q = resSelectorSearch.value.toLowerCase()
|
||||
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) {
|
||||
if (t.resourceType !== 'material') return ''
|
||||
return RES_ICONS[t.resourceCategory] || RES_ICONS[t.fullName] || '🔧'
|
||||
|
|
@ -437,11 +372,7 @@ function openResSelectorFull () {
|
|||
resSelectorSearch.value = ''
|
||||
openResSelector()
|
||||
}
|
||||
|
||||
function applyGroupFilter () {
|
||||
filterGroup.value = resSelectorGroupFilter.value
|
||||
resSelectorOpen.value = false
|
||||
}
|
||||
const applyGroupFilter = () => { filterGroup.value = resSelectorGroupFilter.value; resSelectorOpen.value = false }
|
||||
|
||||
async function onTechStatusChange (tech, value) {
|
||||
tech.status = value
|
||||
|
|
@ -458,14 +389,11 @@ async function saveTechGroup (tech, value) {
|
|||
}
|
||||
|
||||
function openWoModal (prefillDate = null, prefillTech = null) {
|
||||
woModalCtx.value = {
|
||||
scheduled_date: prefillDate || todayStr,
|
||||
assigned_tech: prefillTech || null,
|
||||
}
|
||||
woModalCtx.value = { scheduled_date: prefillDate || todayStr, assigned_tech: prefillTech || null }
|
||||
woModalOpen.value = true
|
||||
}
|
||||
async function confirmWo (formData) {
|
||||
const job = await store.createJob({
|
||||
return await store.createJob({
|
||||
subject: formData.subject,
|
||||
address: formData.address,
|
||||
duration_h: formData.duration_h,
|
||||
|
|
@ -478,7 +406,6 @@ async function confirmWo (formData) {
|
|||
tags: (formData.tags || []).map(t => typeof t === 'string' ? { tag: t } : t),
|
||||
depends_on: formData.depends_on || '',
|
||||
})
|
||||
return job
|
||||
}
|
||||
|
||||
const { autoDistribute, optimizeRoute: _optimizeRoute } = useAutoDispatch({
|
||||
|
|
@ -584,14 +511,10 @@ provide('searchAddr', searchAddr)
|
|||
provide('addrResults', addrResults)
|
||||
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
|
||||
|
||||
function connectDispatchSSE () {
|
||||
if (dispatchSse) dispatchSse.close()
|
||||
dispatchSse = new EventSource(`${HUB_SSE_URL}/sse?topics=dispatch`)
|
||||
|
||||
dispatchSse.addEventListener('tech-absence', (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data)
|
||||
|
|
@ -916,24 +839,25 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document
|
|||
|
||||
</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="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="startGeoFix(ctxMenu.job); closeCtxMenu()">📍 Géofixer sur la carte</button>
|
||||
<div class="sb-ctx-sep"></div>
|
||||
<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="optimizeRoute()">🔀 Optimiser la route</button>
|
||||
<button class="sb-ctx-item" @click="techTagModal=techCtx.tech; techCtx=null">🏷 Skills / Tags</button>
|
||||
<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>
|
||||
</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()">
|
||||
{{ assistCtx?.job?.assistants?.find(a=>a.techId===assistCtx?.techId)?.pinned ? '↕ Rendre flottant' : '📌 Prioriser dans le timeline' }}
|
||||
</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>
|
||||
<div class="sb-ctx-sep"></div>
|
||||
<button class="sb-ctx-item sb-ctx-warn" @click="assistCtxRemove()">✕ Retirer l'assistant</button>
|
||||
</div>
|
||||
</SbContextMenu>
|
||||
|
||||
<transition name="sb-slide-up">
|
||||
<div v-if="multiSelect.length" class="sb-multi-bar">
|
||||
|
|
@ -956,146 +880,130 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document
|
|||
</div>
|
||||
</transition>
|
||||
|
||||
<div v-if="techTagModal" class="sb-overlay" @click.self="techTagModal=null">
|
||||
<div class="sb-modal sb-modal-tags">
|
||||
<div class="sb-modal-hdr"><span>🏷 Tags — {{ techTagModal.fullName }}</span><button class="sb-rp-close" @click="techTagModal=null">✕</button></div>
|
||||
<div class="sb-modal-body" style="overflow:visible;min-height:320px">
|
||||
<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"
|
||||
level-label="Compétence" level-hint="1 = base · 5 = expert"
|
||||
@create="onCreateTag" @update-tag="onUpdateTag" @rename-tag="onRenameTag" @delete-tag="onDeleteTag" />
|
||||
</div>
|
||||
<div class="sb-modal-ftr"><button class="sb-rp-primary" @click="techTagModal=null">Fermer</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tech tags modal -->
|
||||
<SbModal :model-value="!!techTagModal" @update:model-value="v => { if(!v) techTagModal=null }" modal-class="sb-modal-tags" body-style="overflow:visible;min-height:320px">
|
||||
<template #header><span>🏷 Tags — {{ techTagModal?.fullName }}</span></template>
|
||||
<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) }"
|
||||
:all-tags="store.allTags" :get-color="getTagColor" :show-level="true"
|
||||
level-label="Compétence" level-hint="1 = base · 5 = expert"
|
||||
@create="onCreateTag" @update-tag="onUpdateTag" @rename-tag="onRenameTag" @delete-tag="onDeleteTag" />
|
||||
<template #footer><button class="sb-rp-primary" @click="techTagModal=null">Fermer</button></template>
|
||||
</SbModal>
|
||||
|
||||
<div v-if="assistNoteModal" class="sb-overlay" @click.self="assistNoteModal=null">
|
||||
<div class="sb-modal">
|
||||
<div class="sb-modal-hdr"><span>📝 Note assistant</span><button class="sb-rp-close" @click="assistNoteModal=null">✕</button></div>
|
||||
<div class="sb-modal-body">
|
||||
<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" />
|
||||
<p style="font-size:0.6rem;color:#7b80a0;margin-top:0.3rem">Job parent : {{ assistNoteModal.job?.subject }}</p>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Assistant note modal -->
|
||||
<SbModal :model-value="!!assistNoteModal" @update:model-value="v => { if(!v) assistNoteModal=null }">
|
||||
<template #header><span>📝 Note assistant</span></template>
|
||||
<template v-if="assistNoteModal">
|
||||
<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" />
|
||||
<p style="font-size:0.6rem;color:#7b80a0;margin-top:0.3rem">Job parent : {{ assistNoteModal.job?.subject }}</p>
|
||||
</template>
|
||||
<template #footer><button class="sb-rp-primary" @click="confirmAssistNote">Enregistrer</button><button class="sb-rp-btn" @click="assistNoteModal=null">Annuler</button></template>
|
||||
</SbModal>
|
||||
|
||||
<div v-if="timeModal" class="sb-overlay" @click.self="timeModal=null">
|
||||
<div class="sb-modal">
|
||||
<div class="sb-modal-hdr"><span>🕐 Heure de début fixe</span><button class="sb-rp-close" @click="timeModal=null">✕</button></div>
|
||||
<div class="sb-modal-body">
|
||||
<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 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>
|
||||
<!-- Time pin modal -->
|
||||
<SbModal :model-value="!!timeModal" @update:model-value="v => { if(!v) timeModal=null }">
|
||||
<template #header><span>🕐 Heure de début fixe</span></template>
|
||||
<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">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>
|
||||
</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 class="sb-modal-ftr">
|
||||
<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>
|
||||
<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>
|
||||
</template>
|
||||
<template #footer><button class="sbf-primary-btn" @click="confirmMove">✓ Confirmer</button><button class="sb-rp-btn" @click="moveModalOpen=false">Annuler</button></template>
|
||||
</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 v-if="moveModalOpen" class="sb-overlay" @click.self="moveModalOpen=false">
|
||||
<div class="sb-modal">
|
||||
<div class="sb-modal-hdr"><span>Déplacer la réservation</span><button class="sb-rp-close" @click="moveModalOpen=false">✕</button></div>
|
||||
<div class="sb-modal-body" 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 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 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 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 v-if="resSelectorOpen" class="sb-overlay" @click.self="resSelectorOpen=false">
|
||||
<div class="sb-modal sb-modal-wide">
|
||||
<div class="sb-modal-hdr"><span>Ressources & Groupes</span><button class="sb-rp-close" @click="resSelectorOpen=false">✕</button></div>
|
||||
<div class="sb-modal-body">
|
||||
<!-- Saved presets -->
|
||||
<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 class="sb-rsel-search-row">
|
||||
<input v-model="resSelectorSearch" class="sb-rsel-search" placeholder="🔍 Rechercher une ressource…" />
|
||||
</div>
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
<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"
|
||||
:context="woModalCtx"
|
||||
|
|
@ -1109,32 +1017,30 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document
|
|||
:period-start="periodStart" :period-end="periodEndStr"
|
||||
@published="onPublished" />
|
||||
|
||||
<div v-if="dispatchCriteriaModal" class="sb-overlay" @click.self="dispatchCriteriaModal=false">
|
||||
<div class="sb-modal">
|
||||
<div class="sb-modal-hdr"><span>⚙ Critères de dispatch automatique</span><button class="sb-rp-close" @click="dispatchCriteriaModal=false">✕</button></div>
|
||||
<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>
|
||||
<div v-for="(c, i) in dispatchCriteria" :key="c.id" class="sb-crit-row"
|
||||
draggable="true"
|
||||
:class="{ 'sb-crit-drag-over': critDragOver === i }"
|
||||
@dragstart="critDragIdx = i; $event.dataTransfer.effectAllowed = 'move'"
|
||||
@dragend="critDragIdx = null; critDragOver = null"
|
||||
@dragover.prevent="critDragOver = i"
|
||||
@dragleave="critDragOver === i && (critDragOver = null)"
|
||||
@drop.prevent="dropCriterion(i)">
|
||||
<span class="sb-crit-handle" title="Glisser">⠿</span>
|
||||
<span class="sb-crit-order">{{ i + 1 }}</span>
|
||||
<label class="sb-crit-label"><input type="checkbox" v-model="c.enabled" />{{ c.label }}</label>
|
||||
<div class="sb-crit-arrows">
|
||||
<button :disabled="i===0" @click="moveCriterion(i,-1)">▲</button>
|
||||
<button :disabled="i===dispatchCriteria.length-1" @click="moveCriterion(i,1)">▼</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Dispatch criteria modal -->
|
||||
<SbModal :model-value="dispatchCriteriaModal" @update:model-value="v => dispatchCriteriaModal=v">
|
||||
<template #header><span>⚙ Critères de dispatch automatique</span></template>
|
||||
<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"
|
||||
draggable="true"
|
||||
:class="{ 'sb-crit-drag-over': critDragOver === i }"
|
||||
@dragstart="critDragIdx = i; $event.dataTransfer.effectAllowed = 'move'"
|
||||
@dragend="critDragIdx = null; critDragOver = null"
|
||||
@dragover.prevent="critDragOver = i"
|
||||
@dragleave="critDragOver === i && (critDragOver = null)"
|
||||
@drop.prevent="dropCriterion(i)">
|
||||
<span class="sb-crit-handle" title="Glisser">⠿</span>
|
||||
<span class="sb-crit-order">{{ i + 1 }}</span>
|
||||
<label class="sb-crit-label"><input type="checkbox" v-model="c.enabled" />{{ c.label }}</label>
|
||||
<div class="sb-crit-arrows">
|
||||
<button :disabled="i===0" @click="moveCriterion(i,-1)">▲</button>
|
||||
<button :disabled="i===dispatchCriteria.length-1" @click="moveCriterion(i,1)">▼</button>
|
||||
</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>
|
||||
<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 class="sb-gps-modal">
|
||||
<div class="sb-gps-modal-hdr">
|
||||
|
|
@ -1220,67 +1126,58 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Absence Modal (z-index above GPS modal) -->
|
||||
<div v-if="absenceModalOpen" class="sb-overlay sb-overlay-top" @click.self="absenceModalOpen = false">
|
||||
<div class="sb-modal sb-absence-modal">
|
||||
<div class="sb-modal-hdr">
|
||||
<span>⏸ Mettre en absence — {{ absenceModalTech?.fullName }}</span>
|
||||
<button class="sb-rp-close" @click="absenceModalOpen = false">✕</button>
|
||||
<!-- Absence modal -->
|
||||
<SbModal :model-value="absenceModalOpen" @update:model-value="v => absenceModalOpen=v" overlay-class="sb-overlay-top" modal-class="sb-absence-modal">
|
||||
<template #header><span>⏸ Mettre en absence — {{ absenceModalTech?.fullName }}</span></template>
|
||||
<div class="sb-absence-form">
|
||||
<label class="sb-absence-lbl">Raison</label>
|
||||
<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 class="sb-modal-body">
|
||||
<div class="sb-absence-form">
|
||||
<label class="sb-absence-lbl">Raison</label>
|
||||
<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 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 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 class="sb-modal-ftr">
|
||||
<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>
|
||||
<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>
|
||||
<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 -->
|
||||
<div v-if="scheduleModalTech" class="sb-overlay sb-overlay-top" @click.self="scheduleModalTech = null">
|
||||
|
|
|
|||
|
|
@ -10,22 +10,16 @@
|
|||
|
||||
<template v-else>
|
||||
|
||||
<!-- ════════════════════════════════════════════════
|
||||
SECTION 1: Utilisateurs & Permissions
|
||||
════════════════════════════════════════════════ -->
|
||||
<!-- SECTION 1: Utilisateurs & Permissions -->
|
||||
<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">
|
||||
<template #header>
|
||||
<div class="row items-center no-wrap" style="width:100%">
|
||||
<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>
|
||||
<SectionHeader icon="admin_panel_settings" label="Utilisateurs & Permissions" />
|
||||
</template>
|
||||
|
||||
<q-separator />
|
||||
<div class="q-pa-md">
|
||||
|
||||
<!-- ── Tab navigation ── -->
|
||||
<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">
|
||||
<q-tab name="users" icon="people" label="Utilisateurs" />
|
||||
|
|
@ -33,7 +27,7 @@
|
|||
<q-tab name="matrix" icon="grid_on" label="Matrice" />
|
||||
</q-tabs>
|
||||
|
||||
<!-- ═══ TAB: Utilisateurs ═══ -->
|
||||
<!-- TAB: Utilisateurs -->
|
||||
<div v-show="permTab === 'users'">
|
||||
<div class="row q-gutter-sm items-end q-mb-md">
|
||||
<q-input v-model="userSearch" label="Rechercher un utilisateur (nom, email)..." outlined dense
|
||||
|
|
@ -46,7 +40,6 @@
|
|||
</q-input>
|
||||
</div>
|
||||
|
||||
<!-- User list -->
|
||||
<div v-if="userResults.length" class="user-list">
|
||||
<div v-for="u in userResults" :key="u.pk" class="user-card"
|
||||
:class="{ 'user-card--selected': selectedUser?.pk === u.pk }"
|
||||
|
|
@ -54,7 +47,7 @@
|
|||
<div class="row items-center no-wrap">
|
||||
<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">
|
||||
{{ (u.name || u.username || '?')[0].toUpperCase() }}
|
||||
{{ initial(u) }}
|
||||
</q-avatar>
|
||||
<div class="col">
|
||||
<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 class="row items-center q-mb-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>
|
||||
<div class="col">
|
||||
<div class="text-h6">{{ selectedUser.name || selectedUser.username }}</div>
|
||||
|
|
@ -110,28 +103,25 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Permission overrides (collapsible) -->
|
||||
<!-- Permission overrides -->
|
||||
<q-expansion-item dense label="Overrides individuels" caption="Forcer ON/OFF par capacite"
|
||||
icon="tune" header-class="text-grey-7" class="q-mb-sm"
|
||||
:default-opened="Object.keys(selectedUser.overrides).length > 0">
|
||||
<div class="q-pa-sm">
|
||||
<div class="override-grid">
|
||||
<template v-for="cat in permCategories" :key="cat">
|
||||
<div class="override-cat-label">{{ cat }}</div>
|
||||
<div class="row q-gutter-xs flex-wrap q-mb-xs">
|
||||
<div v-for="cap in permCapsByCategory[cat]" :key="cap.key" class="perm-override-chip"
|
||||
:class="{
|
||||
'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
|
||||
@update:model-value="v => setUserOverride(selectedUser, cap.key, v)" />
|
||||
<span class="text-caption">{{ cap.label }}</span>
|
||||
</div>
|
||||
<PermGrid :categories="permCategories" :caps-by-category="permCapsByCategory">
|
||||
<template #cap="{ cap }">
|
||||
<div class="perm-override-chip"
|
||||
:class="{
|
||||
'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
|
||||
@update:model-value="v => setUserOverride(selectedUser, cap.key, v)" />
|
||||
<span class="text-caption">{{ cap.label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</PermGrid>
|
||||
<div class="text-caption text-grey-5 q-mt-xs">
|
||||
Indetermine = herite du groupe. Coche = force ON. Decoche = force OFF.
|
||||
</div>
|
||||
|
|
@ -162,7 +152,7 @@
|
|||
</q-slide-transition>
|
||||
</div>
|
||||
|
||||
<!-- ═══ TAB: Groupes ═══ -->
|
||||
<!-- TAB: Groupes -->
|
||||
<div v-show="permTab === 'groups'">
|
||||
<!-- Legacy sync banner -->
|
||||
<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 v-if="syncResult" class="col" style="overflow-y:auto">
|
||||
<!-- Summary -->
|
||||
<div class="row q-gutter-sm q-mb-md">
|
||||
<q-badge color="indigo-1" text-color="indigo-8" class="q-pa-sm">
|
||||
<q-icon name="add_circle" size="14px" class="q-mr-xs" />
|
||||
|
|
@ -209,7 +198,6 @@
|
|||
</q-badge>
|
||||
</div>
|
||||
|
||||
<!-- To sync / synced -->
|
||||
<div v-if="syncResult.matched.length" class="q-mb-md">
|
||||
<div class="text-subtitle2 q-mb-xs">
|
||||
{{ syncResult.dry_run ? 'Utilisateurs a ajouter' : 'Utilisateurs synchronises' }}
|
||||
|
|
@ -225,7 +213,6 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Not found -->
|
||||
<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 v-for="m in syncResult.not_found" :key="m.email" class="row items-center q-py-xs"
|
||||
|
|
@ -238,7 +225,6 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Already OK -->
|
||||
<q-expansion-item v-if="syncResult.already_ok.length" dense
|
||||
:label="`${syncResult.already_ok.length} deja dans le bon groupe`"
|
||||
icon="check_circle" header-class="text-green-7 text-caption">
|
||||
|
|
@ -249,7 +235,6 @@
|
|||
</div>
|
||||
</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">
|
||||
<q-btn color="indigo-6" icon="sync" label="Appliquer la synchronisation"
|
||||
unelevated :loading="legacySyncing" @click="showSyncDialog = false; syncLegacy(false)" />
|
||||
|
|
@ -285,7 +270,6 @@
|
|||
<q-btn flat round dense icon="close" @click="selectedGroup = null; groupMembers = null" />
|
||||
</div>
|
||||
|
||||
<!-- Members list -->
|
||||
<div class="text-subtitle2 q-mb-xs">Membres</div>
|
||||
<div v-if="groupMembersLoading" class="text-caption text-grey-5">
|
||||
<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"
|
||||
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">
|
||||
{{ (m.name || m.username || '?')[0].toUpperCase() }}
|
||||
{{ initial(m) }}
|
||||
</q-avatar>
|
||||
<span class="text-body2">{{ m.name || m.username }}</span>
|
||||
<span class="text-caption text-grey-6 q-ml-sm">{{ m.email }}</span>
|
||||
|
|
@ -320,12 +304,11 @@
|
|||
@click="addMemberSearch = ''; memberSearchResults = []" />
|
||||
</template>
|
||||
</q-input>
|
||||
<!-- Dropdown results -->
|
||||
<div v-if="memberSearchResults.length" class="member-search-dropdown">
|
||||
<div v-for="u in memberSearchResults" :key="u.pk" class="member-search-item"
|
||||
@click="addUserToCurrentGroup(u)">
|
||||
<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>
|
||||
<span class="text-body2">{{ u.name || u.username }}</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"
|
||||
header-class="text-grey-7 q-mt-md" default-opened>
|
||||
<div class="q-pa-sm">
|
||||
<div class="override-grid">
|
||||
<template v-for="cat in permCategories" :key="cat">
|
||||
<div class="override-cat-label">{{ cat }}</div>
|
||||
<div class="row q-gutter-xs flex-wrap q-mb-xs">
|
||||
<div v-for="cap in permCapsByCategory[cat]" :key="cap.key" class="perm-override-chip"
|
||||
:class="{ 'perm-override-on': permMatrix[selectedGroup]?.[cap.key] }">
|
||||
<q-checkbox v-model="permMatrix[selectedGroup][cap.key]" dense size="sm" color="indigo-6"
|
||||
@update:model-value="permDirty = true" />
|
||||
<span class="text-caption">{{ cap.label }}</span>
|
||||
</div>
|
||||
<PermGrid :categories="permCategories" :caps-by-category="permCapsByCategory">
|
||||
<template #cap="{ cap }">
|
||||
<div class="perm-override-chip"
|
||||
:class="{ 'perm-override-on': permMatrix[selectedGroup]?.[cap.key] }">
|
||||
<q-checkbox v-model="permMatrix[selectedGroup][cap.key]" dense size="sm" color="indigo-6"
|
||||
@update:model-value="permDirty = true" />
|
||||
<span class="text-caption">{{ cap.label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</PermGrid>
|
||||
<q-btn v-if="permDirty" color="indigo-6" icon="save" label="Sauvegarder permissions"
|
||||
dense unelevated :loading="permSaving" @click="saveGroupPerms(selectedGroup)" class="q-mt-sm" />
|
||||
</div>
|
||||
|
|
@ -359,7 +339,7 @@
|
|||
</q-slide-transition>
|
||||
</div>
|
||||
|
||||
<!-- ═══ TAB: Matrice (vue globale) ═══ -->
|
||||
<!-- TAB: Matrice (vue globale) -->
|
||||
<div v-show="permTab === 'matrix'">
|
||||
<div class="row items-center q-mb-sm">
|
||||
<span class="text-subtitle2">Matrice globale Groupes x Capacites</span>
|
||||
|
|
@ -400,16 +380,11 @@
|
|||
</q-expansion-item>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════════
|
||||
SECTION 2: SMS / Twilio
|
||||
════════════════════════════════════════════════ -->
|
||||
<!-- SECTION 2: SMS / Twilio -->
|
||||
<div class="ops-card q-mb-md">
|
||||
<q-expansion-item header-class="section-header" expand-icon-class="text-grey-7">
|
||||
<template #header>
|
||||
<div class="row items-center no-wrap" style="width:100%">
|
||||
<q-icon name="sms" size="20px" color="indigo-6" class="q-mr-sm" />
|
||||
<span class="text-subtitle1 text-weight-bold">SMS & Templates</span>
|
||||
</div>
|
||||
<SectionHeader icon="sms" label="SMS & Templates" />
|
||||
</template>
|
||||
<q-separator />
|
||||
<div class="q-pa-md">
|
||||
|
|
@ -452,17 +427,9 @@
|
|||
<q-separator class="q-my-md" />
|
||||
<div class="text-subtitle2 q-mb-sm">Templates SMS</div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12">
|
||||
<q-input v-model="settings.sms_enroute" label="Technicien en route" outlined dense autogrow
|
||||
@blur="save('sms_enroute')" />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<q-input v-model="settings.sms_completed" label="Service 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 v-for="t in smsTemplates" :key="t.key" class="col-12">
|
||||
<q-input v-model="settings[t.key]" :label="t.label" outlined dense autogrow
|
||||
@blur="save(t.key)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-caption text-grey-6 q-mt-sm">
|
||||
|
|
@ -472,21 +439,15 @@
|
|||
</q-expansion-item>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════════
|
||||
SECTION 3: Integrations
|
||||
════════════════════════════════════════════════ -->
|
||||
<!-- SECTION 3: Integrations -->
|
||||
<div class="ops-card q-mb-md">
|
||||
<q-expansion-item header-class="section-header" expand-icon-class="text-grey-7">
|
||||
<template #header>
|
||||
<div class="row items-center no-wrap" style="width:100%">
|
||||
<q-icon name="extension" size="20px" color="indigo-6" class="q-mr-sm" />
|
||||
<span class="text-subtitle1 text-weight-bold">Integrations</span>
|
||||
</div>
|
||||
<SectionHeader icon="extension" label="Integrations" />
|
||||
</template>
|
||||
<q-separator />
|
||||
<div class="q-pa-md">
|
||||
|
||||
<!-- Email / SMTP -->
|
||||
<div class="text-subtitle2 q-mb-xs">
|
||||
<q-icon name="email" size="18px" class="q-mr-xs" /> Email — SMTP
|
||||
</div>
|
||||
|
|
@ -497,7 +458,6 @@
|
|||
|
||||
<q-separator class="q-my-md" />
|
||||
|
||||
<!-- n8n / Webhooks -->
|
||||
<div class="text-subtitle2 q-mb-xs">
|
||||
<q-icon name="webhook" size="18px" class="q-mr-xs" /> n8n — Webhooks
|
||||
</div>
|
||||
|
|
@ -512,7 +472,6 @@
|
|||
|
||||
<q-separator class="q-my-md" />
|
||||
|
||||
<!-- Stripe -->
|
||||
<div class="text-subtitle2 q-mb-xs">
|
||||
<q-icon name="credit_card" size="18px" class="q-mr-xs" /> Stripe — Paiements
|
||||
</div>
|
||||
|
|
@ -534,7 +493,6 @@
|
|||
|
||||
<q-separator class="q-my-md" />
|
||||
|
||||
<!-- Mapbox -->
|
||||
<div class="text-subtitle2 q-mb-xs">
|
||||
<q-icon name="map" size="18px" class="q-mr-xs" /> Mapbox
|
||||
</div>
|
||||
|
|
@ -547,16 +505,11 @@
|
|||
</q-expansion-item>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════════
|
||||
SECTION 4: 3CX Phone
|
||||
════════════════════════════════════════════════ -->
|
||||
<!-- SECTION 4: 3CX Phone -->
|
||||
<div class="ops-card q-mb-md">
|
||||
<q-expansion-item header-class="section-header" expand-icon-class="text-grey-7">
|
||||
<template #header>
|
||||
<div class="row items-center no-wrap" style="width:100%">
|
||||
<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>
|
||||
<SectionHeader icon="phone" label="3CX — Telephone WebRTC" />
|
||||
</template>
|
||||
<q-separator />
|
||||
<div class="q-pa-md">
|
||||
|
|
@ -597,73 +550,53 @@
|
|||
</q-expansion-item>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════════
|
||||
SECTION 5: Réseau (OLT)
|
||||
════════════════════════════════════════════════ -->
|
||||
<!-- SECTION 5: Reseau (OLT) -->
|
||||
<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"
|
||||
@before-show="networkExpanded = true">
|
||||
@before-show="lazyFlags.network = true">
|
||||
<template #header>
|
||||
<div class="row items-center no-wrap" style="width:100%">
|
||||
<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>
|
||||
<SectionHeader icon="lan" label="Reseau — OLT / Fibre" />
|
||||
</template>
|
||||
<q-separator />
|
||||
<div class="q-pa-none">
|
||||
<NetworkPage v-if="networkExpanded" />
|
||||
<NetworkPage v-if="lazyFlags.network" />
|
||||
</div>
|
||||
</q-expansion-item>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════════
|
||||
SECTION 6: Téléphonie (Fonoster/SIP)
|
||||
════════════════════════════════════════════════ -->
|
||||
<!-- SECTION 6: Telephonie (Fonoster/SIP) -->
|
||||
<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"
|
||||
@before-show="telephonyExpanded = true">
|
||||
@before-show="lazyFlags.telephony = true">
|
||||
<template #header>
|
||||
<div class="row items-center no-wrap" style="width:100%">
|
||||
<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>
|
||||
<SectionHeader icon="phone_in_talk" label="Telephonie — SIP / Trunks" />
|
||||
</template>
|
||||
<q-separator />
|
||||
<div class="q-pa-none">
|
||||
<TelephonyPage v-if="telephonyExpanded" />
|
||||
<TelephonyPage v-if="lazyFlags.telephony" />
|
||||
</div>
|
||||
</q-expansion-item>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════════
|
||||
SECTION 7: Agent AI Flows
|
||||
════════════════════════════════════════════════ -->
|
||||
<!-- SECTION 7: Agent AI Flows -->
|
||||
<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"
|
||||
@before-show="agentExpanded = true">
|
||||
@before-show="lazyFlags.agent = true">
|
||||
<template #header>
|
||||
<div class="row items-center no-wrap" style="width:100%">
|
||||
<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>
|
||||
<SectionHeader icon="smart_toy" label="Agent AI — Flows" />
|
||||
</template>
|
||||
<q-separator />
|
||||
<div class="q-pa-none">
|
||||
<AgentFlowsPage v-if="agentExpanded" />
|
||||
<AgentFlowsPage v-if="lazyFlags.agent" />
|
||||
</div>
|
||||
</q-expansion-item>
|
||||
</div>
|
||||
|
||||
<!-- ════════════════════════════════════════════════
|
||||
SECTION 8: Liens rapides
|
||||
════════════════════════════════════════════════ -->
|
||||
<!-- SECTION 8: Liens rapides -->
|
||||
<div class="ops-card">
|
||||
<q-expansion-item default-opened header-class="section-header" expand-icon-class="text-grey-7">
|
||||
<template #header>
|
||||
<div class="row items-center no-wrap" style="width:100%">
|
||||
<q-icon name="launch" size="20px" color="indigo-6" class="q-mr-sm" />
|
||||
<span class="text-subtitle1 text-weight-bold">Liens rapides</span>
|
||||
</div>
|
||||
<SectionHeader icon="launch" label="Liens rapides" />
|
||||
</template>
|
||||
<q-separator />
|
||||
<div class="q-pa-md">
|
||||
|
|
@ -682,27 +615,71 @@
|
|||
</template>
|
||||
|
||||
<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 { authFetch } from 'src/api/auth'
|
||||
import { BASE_URL } from 'src/config/erpnext'
|
||||
import { ERP_DESK_URL as erpDeskUrl } from 'src/config/erpnext'
|
||||
import { BASE_URL, ERP_DESK_URL as erpDeskUrl } from 'src/config/erpnext'
|
||||
import { sendTestSms } from 'src/api/sms'
|
||||
import { getPhoneConfig, savePhoneConfig, fetch3cxCredentials } from 'src/composables/usePhone'
|
||||
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 TelephonyPage = defineAsyncComponent(() => import('src/pages/TelephonyPage.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
|
||||
const networkExpanded = ref(false)
|
||||
const telephonyExpanded = ref(false)
|
||||
const agentExpanded = ref(false)
|
||||
const lazyFlags = reactive({ network: false, telephony: false, agent: false })
|
||||
|
||||
// Page state
|
||||
const loading = ref(true)
|
||||
const settings = ref({})
|
||||
const showToken = ref(false)
|
||||
|
|
@ -715,9 +692,29 @@ const pbxUsername = ref('')
|
|||
const pbxPassword = ref('')
|
||||
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 () {
|
||||
savePhoneConfig(phoneConfig.value)
|
||||
Notify.create({ type: 'positive', message: 'Config telephone sauvegardee', timeout: 1500 })
|
||||
notify('Config telephone sauvegardee')
|
||||
}
|
||||
|
||||
async function login3cx () {
|
||||
|
|
@ -725,15 +722,17 @@ async function login3cx () {
|
|||
loggingIn3cx.value = true
|
||||
try {
|
||||
const creds = await fetch3cxCredentials(pbxUsername.value, pbxPassword.value)
|
||||
phoneConfig.value.extension = creds.extension
|
||||
phoneConfig.value.authId = creds.authId
|
||||
phoneConfig.value.authPassword = creds.authPassword
|
||||
phoneConfig.value.displayName = creds.displayName
|
||||
Object.assign(phoneConfig.value, {
|
||||
extension: creds.extension,
|
||||
authId: creds.authId,
|
||||
authPassword: creds.authPassword,
|
||||
displayName: creds.displayName,
|
||||
})
|
||||
savePhoneConfig(phoneConfig.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) {
|
||||
Notify.create({ type: 'negative', message: '3CX login echoue: ' + e.message, timeout: 4000 })
|
||||
notify('3CX login echoue: ' + e.message, 'negative', 4000)
|
||||
} finally {
|
||||
loggingIn3cx.value = false
|
||||
}
|
||||
|
|
@ -752,18 +751,16 @@ onMounted(async () => {
|
|||
snapshots[key] = json.data[key] ?? ''
|
||||
}
|
||||
} catch (e) {
|
||||
Notify.create({ type: 'negative', message: 'Erreur chargement parametres: ' + e.message })
|
||||
notify('Erreur chargement parametres: ' + e.message, 'negative')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
// If permissions already loaded, init now; otherwise watch
|
||||
if (isLoaded.value && can('manage_permissions')) {
|
||||
loadPerms()
|
||||
searchUsers()
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for permissions loading (race condition: perms may load after onMounted)
|
||||
watch(isLoaded, (loaded) => {
|
||||
if (loaded && can('manage_permissions') && !permGroups.value.length) {
|
||||
loadPerms()
|
||||
|
|
@ -775,7 +772,6 @@ async function save (field) {
|
|||
const val = settings.value[field] ?? ''
|
||||
const prev = snapshots[field] ?? ''
|
||||
if (val === prev) return
|
||||
|
||||
snapshots[field] = val
|
||||
try {
|
||||
const res = await authFetch(BASE_URL + '/api/resource/Dispatch Settings/Dispatch Settings', {
|
||||
|
|
@ -784,9 +780,9 @@ async function save (field) {
|
|||
body: JSON.stringify({ [field]: val }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Save failed: ' + res.status)
|
||||
Notify.create({ type: 'positive', message: 'Sauvegarde', timeout: 1500 })
|
||||
notify('Sauvegarde')
|
||||
} catch (e) {
|
||||
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
|
||||
notify('Erreur: ' + e.message, 'negative')
|
||||
settings.value[field] = prev
|
||||
snapshots[field] = prev
|
||||
}
|
||||
|
|
@ -804,323 +800,11 @@ async function testSms () {
|
|||
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>
|
||||
|
||||
<style scoped>
|
||||
.section-header { padding: 12px 16px; }
|
||||
|
||||
/* Permission matrix table */
|
||||
.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-cap-col { text-align: left; min-width: 200px; }
|
||||
|
|
@ -1129,23 +813,19 @@ async function addUserToCurrentGroup (user) {
|
|||
.perm-cap-label { color: #475569; }
|
||||
.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-on { background: #fef3c7; }
|
||||
.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; }
|
||||
|
||||
/* User list */
|
||||
.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:last-child { border-bottom: none; }
|
||||
.user-card:hover { background: #f8fafc; }
|
||||
.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; }
|
||||
|
||||
/* Group cards */
|
||||
.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:hover { border-color: #6366f1; background: #fafafe; }
|
||||
|
|
@ -1154,10 +834,8 @@ async function addUserToCurrentGroup (user) {
|
|||
|
||||
.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; }
|
||||
|
||||
/* Member search dropdown */
|
||||
.member-search-dropdown {
|
||||
position: absolute; left: 0; right: 0; top: 100%; z-index: 10;
|
||||
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'
|
||||
const cfg = require('./config')
|
||||
const { log, json, parseBody, erpFetch, httpRequest } = require('./helpers')
|
||||
const { log, json, parseBody, erpFetch } = 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) ───────
|
||||
const SUPABASE_URL = 'https://rddrjzptzhypltuzmere.supabase.co'
|
||||
const SUPABASE_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InJkZHJqenB0emh5cGx0dXptZXJlIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzA4MTY4NTYsImV4cCI6MjA4NjM5Mjg1Nn0.EluFlKBze8BYM6AFx88G7kt21EvR18EI3uw1zgCXVzs'
|
||||
|
||||
async function searchAddresses (term, limit = 8) {
|
||||
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
|
||||
function erpQuery (doctype, filters, fields, limit, orderBy) {
|
||||
let url = `/api/resource/${encodeURIComponent(doctype)}?filters=${encodeURIComponent(JSON.stringify(filters))}&fields=${encodeURIComponent(JSON.stringify(fields))}`
|
||||
if (orderBy) url += `&order_by=${encodeURIComponent(orderBy)}`
|
||||
if (limit) url += `&limit_page_length=${limit}`
|
||||
return erpFetch(url)
|
||||
}
|
||||
|
||||
// ── Product Catalog API ─────────────────────────────────────────────────────
|
||||
// Serves the product catalog from ERPNext Items with custom fields
|
||||
|
||||
async function getCatalog () {
|
||||
const fields = JSON.stringify([
|
||||
const fields = [
|
||||
'name', 'item_code', 'item_name', 'item_group', 'standard_rate',
|
||||
'description', 'image',
|
||||
'project_template_id', 'requires_visit', 'delivery_method',
|
||||
'is_bundle_parent', 'billing_type', 'service_category',
|
||||
])
|
||||
const filters = JSON.stringify([['is_sales_item', '=', 1], ['disabled', '=', 0]])
|
||||
const res = await erpFetch(`/api/resource/Item?fields=${encodeURIComponent(fields)}&filters=${encodeURIComponent(filters)}&limit_page_length=100&order_by=service_category,standard_rate`)
|
||||
]
|
||||
const res = await erpQuery('Item', [['is_sales_item', '=', 1], ['disabled', '=', 0]], fields, 100, 'service_category,standard_rate')
|
||||
|
||||
if (res.status !== 200) {
|
||||
log('Catalog fetch failed:', res.status)
|
||||
|
|
@ -191,13 +45,9 @@ async function getCatalog () {
|
|||
}
|
||||
|
||||
// ── Checkout / Order Processing ─────────────────────────────────────────────
|
||||
// Creates Sales Order + Dispatch Jobs from cart items + project template
|
||||
|
||||
async function processCheckout (body) {
|
||||
// Accept both flat format and nested { contact: {...}, installation: {...} } format
|
||||
const contact = body.contact || {}
|
||||
const installation = body.installation || {}
|
||||
|
||||
const { contact = {}, installation = {} } = body
|
||||
const items = body.items || []
|
||||
const customer_name = body.customer_name || contact.name || ''
|
||||
const phone = body.phone || contact.phone || ''
|
||||
|
|
@ -207,11 +57,8 @@ async function processCheckout (body) {
|
|||
const postal_code = body.postal_code || contact.postal_code || ''
|
||||
const preferred_date = body.preferred_date || installation.preferred_date || ''
|
||||
const preferred_slot = body.preferred_slot || installation.preferred_slot || ''
|
||||
const delivery_method = body.delivery_method || ''
|
||||
const notes = body.notes || ''
|
||||
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 })
|
||||
|
||||
|
|
@ -226,13 +73,11 @@ async function processCheckout (body) {
|
|||
let customerName = ''
|
||||
const providedCustomerId = body.customer_id || ''
|
||||
try {
|
||||
// If OTP-verified customer_id provided, use it directly
|
||||
if (providedCustomerId) {
|
||||
customerName = providedCustomerId
|
||||
result.customer_existing = true
|
||||
log('Using OTP-verified customer:', customerName)
|
||||
}
|
||||
// Search by phone
|
||||
const { lookupCustomerByPhone } = require('./helpers')
|
||||
if (!customerName && phone) {
|
||||
const existing = await lookupCustomerByPhone(phone)
|
||||
|
|
@ -241,10 +86,9 @@ async function processCheckout (body) {
|
|||
result.customer_existing = true
|
||||
}
|
||||
}
|
||||
// Create if not found
|
||||
if (!customerName) {
|
||||
const custPayload = {
|
||||
customer_name: customer_name,
|
||||
customer_name,
|
||||
customer_type: 'Individual',
|
||||
customer_group: 'Individual',
|
||||
territory: 'Canada',
|
||||
|
|
@ -252,16 +96,12 @@ async function processCheckout (body) {
|
|||
email_id: email || '',
|
||||
}
|
||||
log('Creating customer:', custPayload)
|
||||
const custRes = await erpFetch('/api/resource/Customer', {
|
||||
method: 'POST',
|
||||
body: custPayload,
|
||||
})
|
||||
const custRes = await erpFetch('/api/resource/Customer', { method: 'POST', body: custPayload })
|
||||
log('Customer creation result:', custRes.status, custRes.data?.data?.name || JSON.stringify(custRes.data).substring(0, 200))
|
||||
if (custRes.status === 200 && custRes.data?.data) {
|
||||
customerName = custRes.data.data.name
|
||||
result.customer_created = true
|
||||
} else {
|
||||
// Fallback: use name as-is
|
||||
customerName = customer_name
|
||||
}
|
||||
}
|
||||
|
|
@ -271,7 +111,6 @@ async function processCheckout (body) {
|
|||
}
|
||||
|
||||
// ── 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 allItems = items.map(i => ({
|
||||
item_code: i.item_code,
|
||||
|
|
@ -297,10 +136,7 @@ async function processCheckout (body) {
|
|||
items: allItems,
|
||||
terms: notes || '',
|
||||
}
|
||||
const soRes = await erpFetch('/api/resource/Sales%20Order', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(soPayload),
|
||||
})
|
||||
const soRes = await erpFetch('/api/resource/Sales%20Order', { method: 'POST', body: JSON.stringify(soPayload) })
|
||||
if (soRes.status === 200 && soRes.data?.data) {
|
||||
orderName = soRes.data.data.name
|
||||
result.sales_order = orderName
|
||||
|
|
@ -317,7 +153,6 @@ async function processCheckout (body) {
|
|||
// ── 3. Create Subscriptions for recurring items ──
|
||||
for (const item of recurringItems) {
|
||||
try {
|
||||
// Ensure plan exists
|
||||
let planName = null
|
||||
try {
|
||||
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 ──
|
||||
// Collect unique templates needed
|
||||
const templatesNeeded = new Set()
|
||||
for (const item of items) {
|
||||
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(', ')
|
||||
|
||||
for (const templateId of templatesNeeded) {
|
||||
// Fetch template steps (stored in frontend config, we replicate here)
|
||||
const steps = getTemplateSteps(templateId)
|
||||
if (!steps.length) continue
|
||||
|
||||
|
|
@ -383,7 +216,7 @@ async function processCheckout (body) {
|
|||
const ticketId = 'DJ-' + Date.now().toString(36).toUpperCase() + '-' + i
|
||||
|
||||
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]
|
||||
if (depJob) dependsOn = depJob.name
|
||||
}
|
||||
|
|
@ -413,10 +246,7 @@ async function processCheckout (body) {
|
|||
}
|
||||
|
||||
try {
|
||||
const jobRes = await erpFetch('/api/resource/Dispatch%20Job', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(jobPayload),
|
||||
})
|
||||
const jobRes = await erpFetch('/api/resource/Dispatch%20Job', { method: 'POST', body: JSON.stringify(jobPayload) })
|
||||
if (jobRes.status === 200 && jobRes.data?.data) {
|
||||
createdJobs.push(jobRes.data.data)
|
||||
}
|
||||
|
|
@ -451,24 +281,7 @@ async function processCheckout (body) {
|
|||
await sendEmail({
|
||||
to: email,
|
||||
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">
|
||||
<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>`,
|
||||
html: orderConfirmationHtml({ orderName, customer_name, itemRows, preferred_date, preferred_slot }),
|
||||
})
|
||||
result.email_sent = true
|
||||
} catch (e) {
|
||||
|
|
@ -480,41 +293,9 @@ async function processCheckout (body) {
|
|||
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 ────────────────────────────────────────────────────────────
|
||||
|
||||
async function handle (req, res, method, path) {
|
||||
// GET /api/catalog — Public product catalog
|
||||
if (path === '/api/catalog' && method === 'GET') {
|
||||
try {
|
||||
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') {
|
||||
try {
|
||||
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') {
|
||||
try {
|
||||
const { URL } = require('url')
|
||||
const url = new URL(req.url, 'http://localhost')
|
||||
const limit = parseInt(url.searchParams.get('limit') || '20', 10)
|
||||
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]]
|
||||
if (customer) filters.push(['customer', '=', customer])
|
||||
const fStr = JSON.stringify(filters)
|
||||
const soRes = await erpFetch(`/api/resource/Sales%20Order?fields=${encodeURIComponent(fields)}&filters=${encodeURIComponent(fStr)}&order_by=creation desc&limit_page_length=${limit}`)
|
||||
const soRes = await erpQuery('Sales Order', filters, fields, limit, 'creation desc')
|
||||
return json(res, 200, { ok: true, orders: soRes.data?.data || [] })
|
||||
} catch (e) {
|
||||
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') {
|
||||
try {
|
||||
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') {
|
||||
try {
|
||||
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') {
|
||||
try {
|
||||
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') {
|
||||
try {
|
||||
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') {
|
||||
try {
|
||||
const body = await parseBody(req)
|
||||
const { quotation, agent_name } = body
|
||||
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')
|
||||
|
||||
// Record acceptance by agent
|
||||
const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress || ''
|
||||
await acceptance.acceptQuotation(quotation, {
|
||||
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