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:
louispaulb 2026-04-08 17:57:24 -04:00
parent 320655b0a0
commit c6b2dd1491
23 changed files with 1939 additions and 1952 deletions

File diff suppressed because it is too large Load Diff

View 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 }
}

View 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,
}
}

View 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,
}
}

View 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 }
}

View 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,
}
}

View 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,
}
}

View 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,
}
}

View 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 }
}

View 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'

View 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',
})

View 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'

View 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>

View 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>

View File

@ -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>

View File

@ -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,67 +880,59 @@ 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) }"
<!-- 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" />
</div>
<div class="sb-modal-ftr"><button class="sb-rp-primary" @click="techTagModal=null">Fermer</button></div>
</div>
</div>
<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">
<!-- 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>
</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>
</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">
<!-- 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>
</div>
<div class="sb-modal-ftr">
</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 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>
</div>
</div>
</template>
</SbModal>
<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">
<!-- 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-form-row"><label class="sb-form-lbl">Nouvelle date</label><input type="date" class="sb-form-input" v-model="moveForm.newDate" /></div>
</div>
<div class="sb-modal-ftr"><button class="sbf-primary-btn" @click="confirmMove"> Confirmer</button><button class="sb-rp-btn" @click="moveModalOpen=false">Annuler</button></div>
</div>
</div>
</template>
<template #footer><button class="sbf-primary-btn" @click="confirmMove"> Confirmer</button><button class="sb-rp-btn" @click="moveModalOpen=false">Annuler</button></template>
</SbModal>
<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 -->
<!-- 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">
@ -1028,8 +944,6 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document
</button>
</div>
</div>
<!-- Group chips -->
<div class="sb-rsel-groups">
<div class="sb-rsel-section-title">Groupes</div>
<div class="sb-rsel-chips">
@ -1043,13 +957,9 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document
</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>
@ -1079,8 +989,7 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document
<div v-if="!tempSelectedIds.length" class="sbf-empty">Toutes affichées</div>
</div>
</div>
</div>
<div class="sb-modal-ftr">
<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>
@ -1093,9 +1002,8 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document
<button class="sb-rp-btn" @click="tempSelectedIds=[]">Tout désélectionner</button>
</template>
<button class="sb-rp-btn" @click="resSelectorOpen=false">Annuler</button>
</div>
</div>
</div>
</template>
</SbModal>
<UnifiedCreateModal v-model="woModalOpen" mode="work-order"
:context="woModalCtx"
@ -1109,10 +1017,9 @@ 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">
<!-- 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"
@ -1130,11 +1037,10 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document
<button :disabled="i===dispatchCriteria.length-1" @click="moveCriterion(i,1)"></button>
</div>
</div>
</div>
<div class="sb-modal-ftr"><button class="sbf-primary-btn" @click="saveDispatchCriteria"> Enregistrer</button><button class="sb-rp-btn" @click="dispatchCriteriaModal=false">Annuler</button></div>
</div>
</div>
<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,14 +1126,9 @@ 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>
</div>
<div class="sb-modal-body">
<!-- 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">
@ -1237,7 +1138,6 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document
{{ r.icon }} {{ r.label }}
</button>
</div>
<div class="sb-absence-dates">
<div>
<label class="sb-absence-lbl">Du</label>
@ -1248,7 +1148,6 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document
<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)
@ -1272,15 +1171,13 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeyDown); document
</div>
<div v-else class="sb-absence-no-jobs">Aucun job assigné actuellement.</div>
</div>
</div>
<div class="sb-modal-ftr">
<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>
</div>
</div>
</div>
</template>
</SbModal>
<!-- Schedule editor modal -->
<div v-if="scheduleModalTech" class="sb-overlay sb-overlay-top" @click.self="scheduleModalTech = null">

View File

@ -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,16 +103,14 @@
</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"
<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
@ -129,9 +120,8 @@
@update:model-value="v => setUserOverride(selectedUser, cap.key, v)" />
<span class="text-caption">{{ cap.label }}</span>
</div>
</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"
<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>
</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;

View 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`
}

View 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 }

View File

@ -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&eacute;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&eacute;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&eacute;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&eacute;l&eacute;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',

View 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&eacute;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&eacute;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&eacute;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&eacute;l&eacute;communications</p>
</div>
</div>
</div></body></html>`
}
module.exports = { otpEmailHtml, orderConfirmationHtml }

View 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 }

View 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 }