gigafibre-fsm/apps/ops/src/composables/useHelpers.js
louispaulb 320655b0a0 refactor: major cleanup — remove dead dispatch app, commit all backend code, extract client composables
- Remove apps/dispatch/ (100% replaced by ops dispatch module, unmaintained)
- Commit services/targo-hub/lib/ (24 modules, 6290 lines — was never tracked)
- Commit services/docuseal + services/legacy-db docker-compose configs
- Extract client app composables: useOTP, useAddressSearch, catalog data, format utils
- Refactor CartPage.vue 630→175 lines, CatalogPage.vue 375→95 lines
- Clean hardcoded credentials from config.js fallback values
- Add client portal: catalog, cart, checkout, OTP verification, address search
- Add ops: NetworkPage, AgentFlowsPage, ConversationPanel, UnifiedCreateModal
- Add ops composables: useBestTech, useConversations, usePermissions, useScanner
- Add field app: scanner composable, docker/nginx configs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 17:38:38 -04:00

228 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── Pure utility functions (no Vue dependencies) ─────────────────────────────
export function localDateStr (d) {
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
}
export function startOfWeek (d) {
const r = new Date(d); r.setHours(0,0,0,0)
const diff = r.getDay() === 0 ? -6 : 1 - r.getDay()
r.setDate(r.getDate() + diff); return r
}
export function startOfMonth (d) { return new Date(d.getFullYear(), d.getMonth(), 1) }
export function timeToH (t) {
const [h, m] = t.split(':').map(Number)
return h + m / 60
}
export function hToTime (h) {
const totalMin = Math.round(h * 60)
const hh = Math.floor(totalMin / 60)
const mm = totalMin % 60
return `${String(hh).padStart(2,'0')}:${String(mm).padStart(2,'0')}`
}
export function fmtDur (h) {
const totalMin = Math.round((parseFloat(h) || 0) * 60)
const hh = Math.floor(totalMin / 60)
const mm = totalMin % 60
if (hh === 0) return `${mm}m`
if (mm === 0) return `${hh}h`
return `${hh}h${String(mm).padStart(2,'0')}`
}
export const SNAP_MIN = 5
export const SNAP = SNAP_MIN / 60
export function snapH (h) { return Math.round(h * 60 / SNAP_MIN) * SNAP_MIN / 60 }
export function dayLoadColor (ratio) {
const r = Math.min(ratio, 1.2)
if (r <= 0.5) return '#10b981'
if (r <= 0.75) return '#f59e0b'
if (r <= 1) return '#f97316'
return '#ef4444'
}
export function shortAddr (addr) {
if (!addr) return ''
const parts = addr.replace(/[A-Z]\d[A-Z]\s?\d[A-Z]\d/g, '').trim().split(/[\s,]+/)
for (let i = parts.length - 1; i >= 0; i--) {
if (parts[i].length > 2 && /^[A-ZÀ-Ú]/.test(parts[i])) return parts[i]
}
return parts.slice(-2).join(' ')
}
// Service colors & labels
export const SVC_COLORS = { 'Internet':'#3b82f6','Télévisión':'#a855f7','Téléphonie':'#10b981','Multi-service':'#f59e0b' }
export const SVC_ICONS = { 'Internet':'🌐','Télévisión':'📺','Téléphonie':'📞','Multi-service':'🔧' }
const SVC_CODES = { 'Internet':'WEB','Télévisión':'TV','Téléphonie':'TEL','Multi-service':'MX' }
export function jobSvcCode (job) {
if (SVC_CODES[job.service_type]) return SVC_CODES[job.service_type]
const s = (job.subject || '').toLowerCase()
if (s.includes('internet')) return 'WEB'
if (s.includes('tv') || s.includes('télév')) return 'TV'
if (s.includes('téléph')) return 'TEL'
if (s.includes('multi')) return 'MX'
return 'WO'
}
export function jobColor (job, techColors, store) {
if (SVC_COLORS[job.service_type]) return SVC_COLORS[job.service_type]
const s = (job.subject||'').toLowerCase()
if (s.includes('internet')) return '#3b82f6'
if (s.includes('tv')||s.includes('télév')) return '#a855f7'
if (s.includes('téléph')) return '#10b981'
if (s.includes('multi')) return '#f59e0b'
if (job.assignedTech && store) {
const t = store.technicians.find(x=>x.id===job.assignedTech)
if (t) return techColors[t.colorIdx]
}
return '#6b7280'
}
export function jobSpansDate (job, ds) {
const start = job.scheduledDate
const end = job.endDate
if (!start) return false
if (!end) return start === ds
return ds >= start && ds <= end
}
export function sortJobsByTime (jobs) {
return jobs.slice().sort((a, b) => {
const aH = a.startTime ? timeToH(a.startTime) : (a.startHour ?? 8)
const bH = b.startTime ? timeToH(b.startTime) : (b.startHour ?? 8)
return aH - bH
})
}
// ── ERPNext ↔ internal status mapping ──
// ERPNext stores French labels; frontend uses internal codes
export const ERP_STATUS_TO_INTERNAL = {
'Disponible': 'available',
'En route': 'en-route',
'En pause': 'off',
'Hors ligne': 'offline',
'Inactif': 'inactive',
}
export const INTERNAL_STATUS_TO_ERP = Object.fromEntries(
Object.entries(ERP_STATUS_TO_INTERNAL).map(([k, v]) => [v, k])
)
export function normalizeStatus (erpStatus) {
return ERP_STATUS_TO_INTERNAL[erpStatus] || erpStatus || 'available'
}
export function toErpStatus (internalStatus) {
return INTERNAL_STATUS_TO_ERP[internalStatus] || internalStatus || 'Disponible'
}
// ── Weekly schedule helpers ──
const DAY_KEYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']
export const DAY_LABELS = { mon: 'Lun', tue: 'Mar', wed: 'Mer', thu: 'Jeu', fri: 'Ven', sat: 'Sam', sun: 'Dim' }
export const WEEK_DAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
export const DEFAULT_WEEKLY_SCHEDULE = {
mon: { start: '08:00', end: '16:00' }, tue: { start: '08:00', end: '16:00' },
wed: { start: '08:00', end: '16:00' }, thu: { start: '08:00', end: '16:00' },
fri: { start: '08:00', end: '16:00' }, sat: null, sun: null,
}
export const SCHEDULE_PRESETS = [
{ key: 'standard', label: '5×8h (lun-ven)', schedule: { ...DEFAULT_WEEKLY_SCHEDULE } },
{ key: '4x10', label: '4×10h (lun-jeu)', schedule: {
mon: { start: '07:00', end: '17:00' }, tue: { start: '07:00', end: '17:00' },
wed: { start: '07:00', end: '17:00' }, thu: { start: '07:00', end: '17:00' },
fri: null, sat: null, sun: null,
}},
{ key: '3x12', label: '3×12h (lun-mer)', schedule: {
mon: { start: '06:00', end: '18:00' }, tue: { start: '06:00', end: '18:00' },
wed: { start: '06:00', end: '18:00' }, thu: null, fri: null, sat: null, sun: null,
}},
]
export function parseWeeklySchedule (jsonStr) {
if (!jsonStr) return { ...DEFAULT_WEEKLY_SCHEDULE }
try {
const parsed = typeof jsonStr === 'string' ? JSON.parse(jsonStr) : jsonStr
if (typeof parsed !== 'object' || parsed === null) return { ...DEFAULT_WEEKLY_SCHEDULE }
return parsed
} catch { return { ...DEFAULT_WEEKLY_SCHEDULE } }
}
export function techDaySchedule (tech, dateStr) {
const d = new Date(dateStr + 'T12:00:00')
const key = DAY_KEYS[d.getDay()]
const sched = tech.weeklySchedule || DEFAULT_WEEKLY_SCHEDULE
const day = sched[key]
if (!day) return null
const startH = timeToH(day.start || '08:00')
const endH = timeToH(day.end || '16:00')
return { start: day.start, end: day.end, startH, endH, hours: endH - startH }
}
export function techDayCapacityH (tech, dateStr) {
const s = techDaySchedule(tech, dateStr)
return s ? s.hours : 0
}
// Status helpers
export const STATUS_MAP = {
'available': { cls:'st-available', label:'Disponible' },
'en-route': { cls:'st-enroute', label:'En route' },
'busy': { cls:'st-busy', label:'En cours' },
'in progress': { cls:'st-busy', label:'En cours' },
'off': { cls:'st-off', label:'En pause' },
'offline': { cls:'st-off', label:'Hors ligne' },
'inactive': { cls:'st-inactive', label:'Inactif' },
}
export function stOf (t) { return STATUS_MAP[(t.status||'').toLowerCase()] || STATUS_MAP['available'] }
export function prioLabel (p) { return { high:'Haute', medium:'Moyenne', low:'Basse' }[p] || p || '—' }
export function prioClass (p) { return { high:'prio-high', medium:'prio-med', low:'prio-low' }[p] || '' }
// Lucide-style inline SVG icons (stroke-based)
const _s = (d, w=10) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${w}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">${d}</svg>`
export const ICON = {
pin: _s('<path d="M12 17v5"/><path d="M9 11V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v7"/><path d="M4 15h16a1 1 0 0 1 0 2H4a1 1 0 0 1 0-2z"/>'),
mapPin: _s('<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/>'),
wifi: _s('<path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1"/>'),
tv: _s('<rect x="2" y="7" width="20" height="15" rx="2" ry="2"/><path d="m17 2-5 5-5-5"/>'),
phone: _s('<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"/>'),
wrench: _s('<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>'),
cable: _s('<path d="M4 9a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z"/><path d="M8 7V4"/><path d="M16 7V4"/><path d="M12 16v4"/>'),
check: _s('<path d="M20 6L9 17l-5-5"/>'),
x: _s('<path d="M18 6L6 18M6 6l12 12"/>'),
clock: _s('<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>'),
loader: _s('<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/>'),
truck: _s('<path d="M5 17h2l3-6h4l3 6h2M7 17a2 2 0 1 1-4 0M21 17a2 2 0 1 1-4 0"/>'),
}
// Job type icon based on service/subject
export function jobTypeIcon (job) {
const s = (job.subject || '').toLowerCase()
const svc = job.service_type || ''
if (svc === 'Internet' || s.includes('internet') || s.includes('fibre') || s.includes('routeur') || s.includes('wifi')) return ICON.wifi
if (svc === 'Télévisión' || s.includes('tv') || s.includes('télév')) return ICON.tv
if (svc === 'Téléphonie' || s.includes('téléph') || s.includes('phone')) return ICON.phone
if (s.includes('cable') || s.includes('câble') || s.includes('cablage')) return ICON.cable
if (s.includes('camera') || s.includes('install')) return ICON.wrench
return ICON.wrench
}
// Priority color
export function prioColor (p) {
return { high: '#ef4444', medium: '#f59e0b', low: '#7b80a0' }[p] || '#7b80a0'
}
// Status icon (minimal, for timeline blocks)
// Serialize assistants array for ERPNext API calls (used in store + page)
export function serializeAssistants (assistants) {
return (assistants || []).map(a => ({ tech_id: a.techId, tech_name: a.techName, duration_h: a.duration, note: a.note || '', pinned: a.pinned ? 1 : 0 }))
}
export function jobStatusIcon (job) {
const st = (job.status || '').toLowerCase()
if (st === 'completed') return { svg: ICON.check, cls: 'si-done' }
if (st === 'cancelled') return { svg: ICON.x, cls: 'si-cancelled' }
if (st === 'en-route') return { svg: ICON.truck, cls: 'si-enroute' }
if (st === 'in progress') return { svg: ICON.loader, cls: 'si-progress' }
return { svg: '', cls: '' } // no icon for open/assigned — the type icon is enough
}