gigafibre-fsm/apps/ops/src/composables/useHelpers.js
louispaulb f4138cdd75 Roster AI (planification) + prise de rendez-vous client
Solveur OR-Tools (services/roster-solver) : couverture, compétences,
équité, coût chargé, cadence/efficacité, capacité-par-job ; contraintes
dures/souples façon Timefold.

Hub (lib/roster.js) : génération via solveur, publication par réécriture
de semaine (anti-doublons), demande (effectif ou nb de jobs), cadence/coût/
compétences par tech, pause, congés (Tech Availability + approbation),
booking (slots roster-aware / fit 3-dispos / confirm) + portail public /book.
Réessai sur serialization failures frappe_pg ; appels ERP séquentiels.

Ops : page Planification (grille compacte « J8 », multi-shift, drag-select
+ undo/redo, modèles de semaine, éditeur cadence&coût, congés, SMS opt-in),
page Rendez-vous (répartiteur), jobColor tech en pause → tickets rouges.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:42:44 -04:00

344 lines
18 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) {
if (!d || !(d instanceof Date) || isNaN(d.getTime())) return '—'
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
}
// Safe date formatter — never throws RangeError
export function fmtDate (d, opts = { weekday:'short', day:'numeric', month:'short' }) {
if (!d || !(d instanceof Date) || isNaN(d.getTime())) {
console.warn('[fmtDate] invalid date:', d, typeof d)
return '—'
}
try { return d.toLocaleDateString('fr-CA', opts) } catch (e) {
console.warn('[fmtDate] toLocaleDateString failed:', d, e.message)
return localDateStr(d)
}
}
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) {
// Tech en pause/absent (statut interne 'off') → ses jobs en ROUGE (à réassigner)
if (job.assignedTech && store) {
const at = store.technicians.find(x => x.id === job.assignedTech)
if (at && at.status === 'off') return '#e53935'
}
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, tech) {
const start = job.scheduledDate
const end = job.endDate
if (!start) return false
if (!end) return start === ds
if (ds < start || ds > end) return false
// Multi-day jobs skip the tech's off-days (weekends, custom schedule)
// unless the job is flagged as emergency/continuous
if (tech && !job.continuous) {
const sched = techDaySchedule(tech, ds)
if (!sched) return false
}
return true
}
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"/>'),
// ── Single-color toolbar set (used in dispatch top bar). All inherit
// currentColor; CSS sizes them via .sb-icon-svg below. Strokes only,
// 2px stroke for crisp rendering at 14-16px display size.
user: _s('<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>'),
users: _s('<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>'),
package: _s('<path d="m7.5 4.27 9 5.15"/><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>'),
sliders: _s('<line x1="21" x2="14" y1="4" y2="4"/><line x1="10" x2="3" y1="4" y2="4"/><line x1="21" x2="12" y1="12" y2="12"/><line x1="8" x2="3" y1="12" y2="12"/><line x1="21" x2="16" y1="20" y2="20"/><line x1="12" x2="3" y1="20" y2="20"/><line x1="14" x2="14" y1="2" y2="6"/><line x1="8" x2="8" y1="10" y2="14"/><line x1="16" x2="16" y1="18" y2="22"/>'),
chevDown: _s('<path d="m6 9 6 6 6-6"/>'),
map: _s('<path d="M14.106 5.553a2 2 0 0 0 1.788 0l3.659-1.83A1 1 0 0 1 21 4.619v12.764a1 1 0 0 1-.553.894l-4.553 2.277a2 2 0 0 1-1.788 0l-4.212-2.106a2 2 0 0 0-1.788 0l-3.659 1.83A1 1 0 0 1 3 19.381V6.618a1 1 0 0 1 .553-.894l4.553-2.277a2 2 0 0 1 1.788 0z"/><path d="M15 5.764v15"/><path d="M9 3.236v15"/>'),
clipboard:_s('<rect width="8" height="4" x="8" y="2" rx="1" ry="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>'),
sparkles: _s('<path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/><path d="M20 3v4"/><path d="M22 5h-4"/><path d="M4 17v2"/><path d="M5 18H3"/>'),
signal: _s('<path d="M2 20h.01"/><path d="M7 20v-4"/><path d="M12 20v-8"/><path d="M17 20V8"/><path d="M22 4v16"/>'),
rotateCw: _s('<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/>'),
alertTri: _s('<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/>'),
moreH: _s('<circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/>'),
pause: _s('<rect x="14" y="4" width="4" height="16" rx="1"/><rect x="6" y="4" width="4" height="16" rx="1"/>'),
play: _s('<polygon points="6 3 20 12 6 21 6 3"/>'),
externalLink: _s('<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>'),
target: _s('<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/>'),
calendar: _s('<rect width="18" height="18" x="3" y="4" rx="2" ry="2"/><line x1="16" x2="16" y1="2" y2="6"/><line x1="8" x2="8" y1="2" y2="6"/><line x1="3" x2="21" y1="10" y2="10"/>'),
}
// 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
}
// ── RRULE expansion (pure JS, no deps) ───────────────────────────────────────
const _dayMap = { SU: 0, MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6 }
const _d = s => new Date(s + 'T12:00:00')
const _fmt = dt => `${dt.getFullYear()}-${String(dt.getMonth()+1).padStart(2,'0')}-${String(dt.getDate()).padStart(2,'0')}`
export function expandRRule (rrule, dtStart, rangeStart, rangeEnd, pausePeriods = []) {
if (!rrule || !dtStart) return []
const params = Object.fromEntries(rrule.split(';').map(p => p.split('=')))
const freq = params.FREQ, interval = parseInt(params.INTERVAL || '1', 10)
const byDay = params.BYDAY ? params.BYDAY.split(',') : null
const byMonthDay = params.BYMONTHDAY ? parseInt(params.BYMONTHDAY, 10) : null
const byMonth = params.BYMONTH ? parseInt(params.BYMONTH, 10) : null
const end = _d(rangeEnd), rStart = _d(rangeStart), origin = _d(dtStart)
const results = []
const isPaused = dt => pausePeriods.some(p => dt >= _d(p.from) && dt <= _d(p.until))
const push = dt => { if (dt >= rStart && dt <= end && !isPaused(dt)) results.push(_fmt(dt)) }
if (freq === 'DAILY') {
const c = new Date(origin); while (c <= end) { push(c); c.setDate(c.getDate() + interval) }
} else if (freq === 'WEEKLY') {
const daySet = byDay ? new Set(byDay.map(d => _dayMap[d])) : new Set([origin.getDay()])
const ws = new Date(origin); ws.setDate(ws.getDate() - ws.getDay())
while (ws <= end) {
for (let dow = 0; dow < 7; dow++) {
if (!daySet.has(dow)) continue
const dt = new Date(ws); dt.setDate(dt.getDate() + dow)
if (dt >= origin && dt <= end) push(dt)
}
ws.setDate(ws.getDate() + 7 * interval)
}
} else if (freq === 'MONTHLY') {
const day = byMonthDay || origin.getDate()
const c = new Date(origin); while (c <= end) {
const dt = new Date(c.getFullYear(), c.getMonth(), day, 12)
if (dt.getMonth() === c.getMonth()) push(dt)
c.setMonth(c.getMonth() + interval)
}
} else if (freq === 'YEARLY') {
const month = byMonth ? byMonth - 1 : origin.getMonth()
const day = byMonthDay || origin.getDate()
let year = origin.getFullYear(); while (year <= end.getFullYear()) {
const dt = new Date(year, month, day, 12)
if (dt.getMonth() === month) push(dt)
year += interval
}
}
return results
}
// Build RRULE string from UI fields
export function buildRRule ({ freq, interval, byDay, byMonthDay }) {
let rule = `FREQ=${freq}`
if (interval && interval > 1) rule += `;INTERVAL=${interval}`
if (byDay?.length && freq === 'WEEKLY') rule += `;BYDAY=${byDay.join(',')}`
if (byMonthDay && (freq === 'MONTHLY' || freq === 'YEARLY')) rule += `;BYMONTHDAY=${byMonthDay}`
return rule
}
// Parse RRULE string to UI fields
export function parseRRule (rrule) {
if (!rrule) return { freq: 'WEEKLY', interval: 1, byDay: ['MO'], byMonthDay: null }
const params = Object.fromEntries(rrule.split(';').map(p => p.split('=')))
return {
freq: params.FREQ || 'WEEKLY',
interval: parseInt(params.INTERVAL || '1', 10),
byDay: params.BYDAY ? params.BYDAY.split(',') : [],
byMonthDay: params.BYMONTHDAY ? parseInt(params.BYMONTHDAY, 10) : null,
}
}
// 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
}