340 lines
11 KiB
Vue
340 lines
11 KiB
Vue
<script setup lang="ts">
|
|
usePageTitle('Support Routing')
|
|
|
|
type RoutingTier = 'tier1_auto' | 'tier2_rule' | 'tier3_open'
|
|
type RoutedQueue = 'collections' | 'claims' | 'sales' | 'renewals' | 'operations' | 'open_pool'
|
|
|
|
interface RoutingRule {
|
|
id: string
|
|
name: string
|
|
condition: string
|
|
tier: RoutingTier
|
|
targetQueue: RoutedQueue
|
|
enabled: boolean
|
|
}
|
|
|
|
interface SupportState {
|
|
routingRules: RoutingRule[]
|
|
}
|
|
|
|
const STORAGE_KEY = 'policy-ui.support-routing'
|
|
|
|
function loadState(): SupportState {
|
|
if (import.meta.client) {
|
|
const stored = localStorage.getItem(STORAGE_KEY)
|
|
if (stored) {
|
|
try {
|
|
return JSON.parse(stored)
|
|
} catch {
|
|
return defaultState()
|
|
}
|
|
}
|
|
}
|
|
return defaultState()
|
|
}
|
|
|
|
function defaultState(): SupportState {
|
|
return {
|
|
routingRules: [
|
|
{
|
|
id: '1',
|
|
name: 'Existing customer with LOB',
|
|
condition: 'Customer exists and has LOB in message',
|
|
tier: 'tier1_auto',
|
|
targetQueue: 'operations',
|
|
enabled: true
|
|
},
|
|
{
|
|
id: '2',
|
|
name: 'Web form submission',
|
|
condition: 'Form submission with customer data',
|
|
tier: 'tier1_auto',
|
|
targetQueue: 'sales',
|
|
enabled: true
|
|
},
|
|
{
|
|
id: '3',
|
|
name: 'Payment inquiry',
|
|
condition: 'Keywords: pago, factura, cobro, payment',
|
|
tier: 'tier2_rule',
|
|
targetQueue: 'collections',
|
|
enabled: true
|
|
},
|
|
{
|
|
id: '4',
|
|
name: 'Claim inquiry',
|
|
condition: 'Keywords: siniestro, reclamo, claim, accident',
|
|
tier: 'tier2_rule',
|
|
targetQueue: 'claims',
|
|
enabled: true
|
|
},
|
|
{
|
|
id: '5',
|
|
name: 'Renewal inquiry',
|
|
condition: 'Keywords: renovación, renewal, vence',
|
|
tier: 'tier2_rule',
|
|
targetQueue: 'renewals',
|
|
enabled: true
|
|
},
|
|
]
|
|
}
|
|
}
|
|
|
|
const state = ref<SupportState>(loadState())
|
|
|
|
function saveState() {
|
|
if (import.meta.client) {
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(state.value))
|
|
}
|
|
}
|
|
|
|
function toggleRule(id: string) {
|
|
const rule = state.value.routingRules.find(r => r.id === id)
|
|
if (rule) {
|
|
rule.enabled = !rule.enabled
|
|
saveState()
|
|
}
|
|
}
|
|
|
|
function updateRule(id: string, updates: Partial<RoutingRule>) {
|
|
const idx = state.value.routingRules.findIndex(r => r.id === id)
|
|
if (idx !== -1) {
|
|
state.value.routingRules[idx] = { ...state.value.routingRules[idx], ...updates }
|
|
saveState()
|
|
}
|
|
}
|
|
|
|
type TierTab = 'tier1' | 'tier2' | 'tier3'
|
|
const activeTier = ref<TierTab>('tier1')
|
|
|
|
const tierTabs: { id: TierTab; label: string; tier: RoutingTier; description: string }[] = [
|
|
{ id: 'tier1', label: 'Tier 1 — Auto', tier: 'tier1_auto', description: 'Ruteo automático para clientes conocidos, formularios web con LOB, y auto-cumplimiento de documentos.' },
|
|
{ id: 'tier2', label: 'Tier 2 — Reglas', tier: 'tier2_rule', description: 'Clasificación por palabras clave del mensaje. El LLM detecta la intención y dirige a la cola correcta.' },
|
|
{ id: 'tier3', label: 'Tier 3 — Pool', tier: 'tier3_open', description: 'Mensajes ambiguos que no coinciden con ninguna regla van al pool abierto para triaje manual.' },
|
|
]
|
|
|
|
const tier1Rules = computed(() => state.value.routingRules.filter(r => r.tier === 'tier1_auto'))
|
|
const tier2Rules = computed(() => state.value.routingRules.filter(r => r.tier === 'tier2_rule'))
|
|
|
|
const queueOptions: { label: string; value: RoutedQueue }[] = [
|
|
{ label: 'Cobros', value: 'collections' },
|
|
{ label: 'Siniestros', value: 'claims' },
|
|
{ label: 'Ventas', value: 'sales' },
|
|
{ label: 'Renovaciones', value: 'renewals' },
|
|
{ label: 'Operaciones', value: 'operations' },
|
|
{ label: 'Pool Abierto', value: 'open_pool' },
|
|
]
|
|
|
|
const QUEUE_LABELS: Record<RoutedQueue, string> = {
|
|
collections: 'Cobros',
|
|
claims: 'Siniestros',
|
|
sales: 'Ventas',
|
|
renewals: 'Renovaciones',
|
|
operations: 'Operaciones',
|
|
open_pool: 'Pool Abierto',
|
|
}
|
|
|
|
const tier3DefaultAssignee = ref('Round-robin')
|
|
const tier3EscalationHours = ref(4)
|
|
|
|
const toast = useToast()
|
|
function handleSave() {
|
|
saveState()
|
|
toast.add({ title: 'Configuración guardada', color: 'green' })
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="srt-page">
|
|
<!-- Back + Header -->
|
|
<NuxtLink to="/settings" class="srt-back-link">
|
|
<UIcon name="i-heroicons-arrow-left" class="w-3.5 h-3.5" />
|
|
Volver a Configuración
|
|
</NuxtLink>
|
|
|
|
<div>
|
|
<h1 class="mt-1 text-2xl font-semibold tracking-tight text-[var(--text-primary)]">Support Routing</h1>
|
|
<p class="mt-2 text-[14px] leading-relaxed text-[var(--text-muted)]">
|
|
Configure 3-tier routing rules for incoming support requests — auto-routing, keyword classification, and open pool settings.
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Tier tabs -->
|
|
<div class="srt-tier-tabs">
|
|
<button
|
|
v-for="tab in tierTabs"
|
|
:key="tab.id"
|
|
type="button"
|
|
class="srt-tier-tab"
|
|
:class="activeTier === tab.id ? 'srt-tier-on' : 'srt-tier-off'"
|
|
@click="activeTier = tab.id"
|
|
>
|
|
{{ tab.label }}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Tier description -->
|
|
<p class="text-[13px] text-[var(--text-muted)] -mt-2">
|
|
{{ tierTabs.find(t => t.id === activeTier)?.description }}
|
|
</p>
|
|
|
|
<!-- ═══════════ TIER 1 ═══════════ -->
|
|
<div v-if="activeTier === 'tier1'" class="srt-rules">
|
|
<div v-for="rule in tier1Rules" :key="rule.id" class="srt-rule-card">
|
|
<div class="srt-rule-header">
|
|
<div class="flex-1 min-w-0">
|
|
<p class="srt-rule-name">{{ rule.name }}</p>
|
|
<p class="srt-rule-condition">{{ rule.condition }}</p>
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
<span class="srt-queue-chip">{{ QUEUE_LABELS[rule.targetQueue] }}</span>
|
|
<USwitch :model-value="rule.enabled" @update:model-value="toggleRule(rule.id)" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ═══════════ TIER 2 ═══════════ -->
|
|
<div v-if="activeTier === 'tier2'" class="srt-rules">
|
|
<div v-for="rule in tier2Rules" :key="rule.id" class="srt-rule-card">
|
|
<div class="srt-rule-header">
|
|
<div class="flex-1 min-w-0">
|
|
<p class="srt-rule-name">{{ rule.name }}</p>
|
|
<p class="srt-rule-condition">{{ rule.condition }}</p>
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
<select
|
|
class="srt-queue-select"
|
|
:value="rule.targetQueue"
|
|
@change="updateRule(rule.id, { targetQueue: ($event.target as HTMLSelectElement).value as RoutedQueue })"
|
|
>
|
|
<option v-for="q in queueOptions" :key="q.value" :value="q.value">{{ q.label }}</option>
|
|
</select>
|
|
<USwitch :model-value="rule.enabled" @update:model-value="toggleRule(rule.id)" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ═══════════ TIER 3 ═══════════ -->
|
|
<div v-if="activeTier === 'tier3'" class="srt-rules">
|
|
<div class="srt-rule-card">
|
|
<div class="srt-rule-header">
|
|
<div class="flex-1">
|
|
<p class="srt-rule-name">Asignación por defecto</p>
|
|
<p class="srt-rule-condition">Tickets sin coincidencia de regla se asignan a:</p>
|
|
</div>
|
|
<select v-model="tier3DefaultAssignee" class="srt-queue-select">
|
|
<option>Round-robin</option>
|
|
<option>Ana R.</option>
|
|
<option>Marco V.</option>
|
|
<option>Carlos Villalba</option>
|
|
<option>María Fernanda Ortiz</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="srt-rule-card">
|
|
<div class="srt-rule-header">
|
|
<div class="flex-1">
|
|
<p class="srt-rule-name">Umbral de escalamiento</p>
|
|
<p class="srt-rule-condition">Si un ticket no asignado permanece en el pool más de este tiempo, se escala automáticamente.</p>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<input
|
|
v-model.number="tier3EscalationHours"
|
|
type="number"
|
|
min="1"
|
|
max="48"
|
|
class="srt-hour-input"
|
|
/>
|
|
<span class="text-[12px] text-[var(--text-muted)]">horas</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Save button -->
|
|
<div class="flex justify-end">
|
|
<button type="button" class="srt-save-btn" @click="handleSave">
|
|
Guardar configuración
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.srt-page {
|
|
max-width: 56rem;
|
|
margin: 0 auto;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 20px;
|
|
padding-bottom: 3rem;
|
|
}
|
|
|
|
.srt-back-link {
|
|
display: inline-flex; align-items: center; gap: 4px;
|
|
font-size: 12px; font-weight: 600; color: #01696f;
|
|
text-decoration: none;
|
|
}
|
|
.srt-back-link:hover { text-decoration: underline; }
|
|
|
|
/* ── Tier tabs ── */
|
|
.srt-tier-tabs {
|
|
display: inline-flex; gap: 2px; padding: 3px;
|
|
border-radius: 10px; background: rgba(0,0,0,0.04);
|
|
width: fit-content;
|
|
}
|
|
.srt-tier-tab {
|
|
padding: 8px 18px; border-radius: 8px;
|
|
font-size: 13px; font-weight: 500; border: none;
|
|
cursor: pointer; transition: all 150ms ease; white-space: nowrap;
|
|
}
|
|
.srt-tier-on { background: #fff; color: var(--text-primary); box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
|
|
.srt-tier-off { background: transparent; color: var(--text-muted); }
|
|
.srt-tier-off:hover { color: var(--text-primary); }
|
|
|
|
/* ── Rules ── */
|
|
.srt-rules { display: flex; flex-direction: column; gap: 10px; }
|
|
.srt-rule-card {
|
|
padding: 16px; border-radius: 12px;
|
|
border: 1px solid rgba(0,0,0,0.06); background: #fff;
|
|
box-shadow: 0 1px 3px rgba(0,0,0,0.03);
|
|
}
|
|
.srt-rule-header {
|
|
display: flex; align-items: center; gap: 16px;
|
|
}
|
|
.srt-rule-name { font-size: 14px; font-weight: 600; color: var(--text-primary); }
|
|
.srt-rule-condition { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
|
|
|
|
.srt-queue-chip {
|
|
display: inline-flex; padding: 2px 8px; border-radius: 8px;
|
|
font-size: 11px; font-weight: 600;
|
|
background: rgba(1,105,111,0.06); color: #01696f;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.srt-queue-select {
|
|
padding: 5px 10px; border-radius: 8px; font-size: 12px; font-weight: 500;
|
|
border: 1px solid rgba(0,0,0,0.08); background: #fff; color: var(--text-primary);
|
|
cursor: pointer; min-width: 120px;
|
|
}
|
|
.srt-queue-select:focus { outline: none; border-color: #01696f; }
|
|
|
|
.srt-hour-input {
|
|
width: 60px; padding: 5px 8px; border-radius: 8px;
|
|
font-size: 13px; font-weight: 600; text-align: center;
|
|
border: 1px solid rgba(0,0,0,0.08); background: #fff; color: var(--text-primary);
|
|
}
|
|
.srt-hour-input:focus { outline: none; border-color: #01696f; }
|
|
|
|
.srt-save-btn {
|
|
display: inline-flex; align-items: center; gap: 6px;
|
|
padding: 8px 20px; border-radius: 8px;
|
|
background: #01696f; color: #fff;
|
|
font-size: 13px; font-weight: 500; border: none;
|
|
cursor: pointer; transition: all 150ms ease;
|
|
}
|
|
.srt-save-btn:hover { background: #015458; }
|
|
</style>
|