gigafibre-fsm/apps/ops/src/composables/useHelpers.js
louispaulb 9b9d307bdf feat: dispatch planning mode, offer pool, shared presets, recurrence selector
- Planning mode toggle: shift availability as background blocks on timeline
  (week view shows green=available, yellow=on-call; month view per-tech)
- On-call/guard shift editor with RRULE recurrence on tech schedules
- Uber-style job offer pool: broadcast/targeted/pool modes with pricing,
  SMS notifications, accept/decline flow, overload detection alerts
- Shared resource group presets via ERPNext Dispatch Preset doctype
  (replaces localStorage, shared between supervisors)
- Google Calendar-style RecurrenceSelector component with contextual
  quick options + custom RRULE editor, integrated in booking overlay
  and extra shift editor
- Remove default "Repos" ghost chips — only visible in planning mode
- Clean up debug console.logs across API, store, and page layers
- Add extra_shifts Custom Field on Dispatch Technician doctype

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 22:44:18 -04:00

319 lines
14 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) {
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"/>'),
}
// 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
}