- 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>
228 lines
10 KiB
JavaScript
228 lines
10 KiB
JavaScript
// ── 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
|
||
}
|