Major dispatch/task system overhaul: - Project templates with 3-step wizard (choose template → edit steps → publish) - 4 built-in templates: phone service, fiber install, move, repair - Nested task tree with recursive TaskNode component (parent_job hierarchy) - n8n webhook integration (on_open_webhook, on_close_webhook per task) - Inline task editing: status, priority, type, tech assignment, tags, delete - Tech assignment + tags from ticket modal → jobs appear on dispatch timeline - ERPNext custom fields: parent_job, on_open_webhook, on_close_webhook, step_order - Refactored ClientDetailPage, ChatterPanel, DetailModal, dispatch store - CSS consolidation, dead code cleanup, composable extraction - Dashboard KPIs with dispatch integration Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
138 lines
3.8 KiB
JavaScript
138 lines
3.8 KiB
JavaScript
/**
|
|
* Shared formatting utilities for the ops app.
|
|
* Used in ClientDetailPage, TicketsPage, and future pages.
|
|
*/
|
|
|
|
const ERP_BASE = 'https://erp.gigafibre.ca'
|
|
|
|
/**
|
|
* Format a date string to fr-CA short format (e.g. "30 mars 2026")
|
|
* @param {string|null} d - Date string (ISO or YYYY-MM-DD)
|
|
* @returns {string}
|
|
*/
|
|
export function formatDate (d) {
|
|
if (!d) return '—'
|
|
return new Date(d).toLocaleDateString('fr-CA', { year: 'numeric', month: 'short', day: 'numeric' })
|
|
}
|
|
|
|
/**
|
|
* Format a date string to DD/MM/YYYY
|
|
* @param {string|null} d - Date string
|
|
* @returns {string}
|
|
*/
|
|
export function formatDateShort (d) {
|
|
if (!d) return '—'
|
|
const str = String(d).slice(0, 10)
|
|
if (str.length < 10) return str
|
|
const [y, m, day] = str.split('-')
|
|
return `${day}/${m}/${y}`
|
|
}
|
|
|
|
/**
|
|
* Format a number as CAD currency
|
|
* @param {number} v
|
|
* @returns {string}
|
|
*/
|
|
export function formatMoney (v) {
|
|
return (v || 0).toLocaleString('fr-CA', { style: 'currency', currency: 'CAD' })
|
|
}
|
|
|
|
/**
|
|
* Build a link to the ERPNext desk for a doctype + name
|
|
* @param {string} doctype - e.g. 'Sales Invoice', 'Issue'
|
|
* @param {string} name - Document name/ID
|
|
* @returns {string}
|
|
*/
|
|
export function erpLink (doctype, name) {
|
|
if (!doctype || !name) return '#'
|
|
return ERP_BASE + '/app/' + doctype.toLowerCase().replace(/ /g, '-') + '/' + encodeURIComponent(name)
|
|
}
|
|
|
|
/**
|
|
* Build a full URL for an ERPNext file attachment
|
|
* @param {string} url - Relative or absolute file URL
|
|
* @returns {string}
|
|
*/
|
|
export function erpFileUrl (url) {
|
|
if (!url) return '#'
|
|
if (url.startsWith('http')) return url
|
|
return ERP_BASE + url
|
|
}
|
|
|
|
export function formatDateTime (dt) {
|
|
if (!dt) return ''
|
|
const d = new Date(dt)
|
|
if (isNaN(d)) return dt
|
|
return d.toLocaleDateString('fr-CA', { year: 'numeric', month: 'short', day: 'numeric' }) + ' ' +
|
|
d.toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit' })
|
|
}
|
|
|
|
// ═══ Staff avatar helpers ═══
|
|
|
|
const AVATAR_COLORS = ['#4f46e5','#0891b2','#059669','#d97706','#dc2626','#7c3aed','#be185d','#0d9488','#6366f1','#ea580c']
|
|
|
|
/**
|
|
* Generate a consistent color from a name string (for avatar backgrounds).
|
|
* @param {string|null} name
|
|
* @returns {string} Hex color
|
|
*/
|
|
export function staffColor (name) {
|
|
if (!name) return '#9e9e9e'
|
|
let h = 0
|
|
for (let i = 0; i < name.length; i++) h = name.charCodeAt(i) + ((h << 5) - h)
|
|
return AVATAR_COLORS[Math.abs(h) % AVATAR_COLORS.length]
|
|
}
|
|
|
|
/**
|
|
* Extract initials from a full name string.
|
|
* @param {string|null} name
|
|
* @returns {string}
|
|
*/
|
|
export function staffInitials (name) {
|
|
if (!name) return ''
|
|
const parts = name.trim().split(/\s+/)
|
|
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
|
|
return parts[0].substring(0, 2).toUpperCase()
|
|
}
|
|
|
|
/**
|
|
* Decode HTML entities (' -> ', & -> &, etc.)
|
|
* @param {string|null} str
|
|
* @returns {string}
|
|
*/
|
|
export function decodeHtml (str) {
|
|
if (!str) return str
|
|
const el = document.createElement('textarea')
|
|
el.innerHTML = str
|
|
return el.value
|
|
}
|
|
|
|
/**
|
|
* Extract display name from email (user@domain -> User)
|
|
* @param {string|null} email
|
|
* @returns {string}
|
|
*/
|
|
export function noteAuthorName (email) {
|
|
if (!email) return 'Système'
|
|
const local = email.split('@')[0]
|
|
return local.charAt(0).toUpperCase() + local.slice(1).replace(/[._-]/g, ' ')
|
|
}
|
|
|
|
/**
|
|
* Relative time display for notes/comments.
|
|
* @param {string|null} d - Date/datetime string
|
|
* @returns {string}
|
|
*/
|
|
export function noteTimeAgo (d) {
|
|
if (!d) return ''
|
|
const diff = Date.now() - new Date(d).getTime()
|
|
const mins = Math.floor(diff / 60000)
|
|
if (mins < 1) return "À l'instant"
|
|
if (mins < 60) return `Il y a ${mins} min`
|
|
const hours = Math.floor(mins / 60)
|
|
if (hours < 24) return `Il y a ${hours}h`
|
|
const days = Math.floor(hours / 24)
|
|
if (days < 7) return `Il y a ${days}j`
|
|
return formatDate(d)
|
|
}
|