// ── 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) => `${d}` export const ICON = { pin: _s(''), mapPin: _s(''), wifi: _s(''), tv: _s(''), phone: _s(''), wrench: _s(''), cable: _s(''), check: _s(''), x: _s(''), clock: _s(''), loader: _s(''), truck: _s(''), // ── 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(''), users: _s(''), package: _s(''), sliders: _s(''), chevDown: _s(''), map: _s(''), clipboard:_s(''), sparkles: _s(''), signal: _s(''), rotateCw: _s(''), alertTri: _s(''), moreH: _s(''), pause: _s(''), play: _s(''), externalLink: _s(''), target: _s(''), calendar: _s(''), } // 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 }