185 lines
6.0 KiB
TypeScript
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,
|
|
}
|
|
}
|