Files
policy-ui/app/composables/useSupportTickets.ts
Jordan Weingarten 67482f6629 WIP jordan
2026-04-16 11:11:44 -05:00

185 lines
6.0 KiB
TypeScript

/**
* Support Tickets — composable for queue state, filtering, CRUD, and mock routing.
* Persisted in localStorage via useLocalStorageRef.
*/
import { useLocalStorageRef } from '~/utils/useLocalStorageRef'
import {
type SupportTicket,
type SupportTicketDetail,
type TicketMessage,
type TicketStatus,
type SupportChannel,
type RoutingTier,
type RoutedQueue,
type RoutingRule,
MOCK_SUPPORT_TICKETS,
MOCK_TICKET_DETAILS,
MOCK_ROUTING_RULES,
} from '~/data/mock-support'
interface SupportState {
tickets: SupportTicket[]
details: Record<string, SupportTicketDetail>
routingRules: RoutingRule[]
}
function buildDefaults(): SupportState {
return {
tickets: [...MOCK_SUPPORT_TICKETS],
details: { ...MOCK_TICKET_DETAILS },
routingRules: [...MOCK_ROUTING_RULES],
}
}
export function useSupportTickets() {
const state = useLocalStorageRef<SupportState>('policy-ui-support-tickets-v1', buildDefaults)
// ── Computed: filtered lists ──
const openTickets = computed(() => state.value.tickets.filter(t => t.status === 'open'))
const unresolvedCount = computed(() => state.value.tickets.filter(t => t.status !== 'resolved').length)
const breachedCount = computed(() => state.value.tickets.filter(t => t.slaPercent >= 100).length)
const unassignedCount = computed(() => state.value.tickets.filter(t => !t.assignedTo).length)
const openPoolCount = computed(() => state.value.tickets.filter(t => t.routedQueue === 'open_pool').length)
const inProgressCount = computed(() => state.value.tickets.filter(t => t.status === 'in_progress').length)
const kpis = computed(() => {
const all = state.value.tickets
const unresolved = all.filter(t => t.status !== 'resolved')
const avgDaysOpen = unresolved.length
? Math.round(unresolved.reduce((sum, t) => sum + t.daysOpen, 0) / unresolved.length)
: 0
return {
total: all.length,
open: openTickets.value.length,
inProgress: inProgressCount.value,
breached: breachedCount.value,
avgDaysOpen,
unassigned: unassignedCount.value,
openPool: openPoolCount.value,
}
})
// ── CRUD ──
function updateStatus(ticketId: string, status: TicketStatus) {
const ticket = state.value.tickets.find(t => t.id === ticketId)
if (ticket) {
ticket.status = status
ticket.updatedAt = new Date().toISOString().slice(0, 10)
}
const detail = state.value.details[ticketId]
if (detail) {
detail.status = status
detail.updatedAt = new Date().toISOString().slice(0, 10)
}
}
function assignTicket(ticketId: string, agent: string) {
const ticket = state.value.tickets.find(t => t.id === ticketId)
if (ticket) {
ticket.assignedTo = agent
ticket.updatedAt = new Date().toISOString().slice(0, 10)
}
const detail = state.value.details[ticketId]
if (detail) {
detail.assignedTo = agent
detail.updatedAt = new Date().toISOString().slice(0, 10)
detail.messages.push({
id: `msg-${Date.now()}`,
type: 'system',
direction: 'internal',
from: 'Sistema',
to: null,
subject: null,
body: `Ticket asignado a ${agent}`,
timestamp: new Date().toISOString(),
aiDigest: null,
})
}
}
function addMessage(ticketId: string, message: Omit<TicketMessage, 'id' | 'timestamp'>) {
const detail = state.value.details[ticketId]
if (!detail) return
const msg: TicketMessage = {
...message,
id: `msg-${Date.now()}`,
timestamp: new Date().toISOString(),
}
detail.messages.push(msg)
detail.messageCount = detail.messages.length
detail.updatedAt = new Date().toISOString().slice(0, 10)
detail.lastMessagePreview = msg.body.slice(0, 80)
// sync summary ticket
const ticket = state.value.tickets.find(t => t.id === ticketId)
if (ticket) {
ticket.messageCount = detail.messageCount
ticket.updatedAt = detail.updatedAt
ticket.lastMessagePreview = detail.lastMessagePreview
}
}
// ── Mock Routing ──
const routingKeywords: Record<RoutedQueue, string[]> = {
collections: ['pago', 'factura', 'cobro', 'recibo', 'transferencia', 'mora'],
claims: ['siniestro', 'accidente', 'robo', 'daño', 'choque', 'grúa', 'colisión'],
sales: ['cotización', 'seguro nuevo', 'precio', 'cuánto sale', 'cobertura'],
renewals: ['renovación', 'vencimiento', 'prórroga', 'vigencia'],
operations: ['endoso', 'certificado', 'modificar', 'cambio de beneficiario'],
open_pool: [],
}
function simulateRouting(channel: SupportChannel, body: string): { tier: RoutingTier; queue: RoutedQueue; confidence: number } {
const lower = body.toLowerCase()
// Tier 2: keyword matching
for (const [queue, keywords] of Object.entries(routingKeywords) as [RoutedQueue, string[]][]) {
if (!keywords.length) continue
const matched = keywords.filter(kw => lower.includes(kw))
if (matched.length > 0) {
const confidence = Math.min(0.95, 0.6 + matched.length * 0.1)
return { tier: 'tier2_rule', queue, confidence }
}
}
// Tier 3: no match → open pool
return { tier: 'tier3_open', queue: 'open_pool', confidence: 0.3 }
}
// ── Routing Rules CRUD ──
function toggleRule(ruleId: string) {
const rule = state.value.routingRules.find(r => r.id === ruleId)
if (rule) rule.enabled = !rule.enabled
}
function updateRule(ruleId: string, updates: Partial<RoutingRule>) {
const rule = state.value.routingRules.find(r => r.id === ruleId)
if (rule) Object.assign(rule, updates)
}
function getDetail(ticketId: string): SupportTicketDetail | undefined {
return state.value.details[ticketId]
}
return {
state,
// computed
openTickets,
unresolvedCount,
breachedCount,
unassignedCount,
openPoolCount,
inProgressCount,
kpis,
// CRUD
updateStatus,
assignTicket,
addMessage,
getDetail,
// routing
simulateRouting,
toggleRule,
updateRule,
}
}