WIP jordan

This commit is contained in:
Jordan Weingarten
2026-04-16 11:11:44 -05:00
parent ff2d7b18b5
commit 67482f6629
163 changed files with 50627 additions and 728 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,616 @@
<script setup lang="ts">
import type { AlertRecipient, CustomAlertRule } from '~/composables/useAlertConfig'
definePageMeta({ ssr: false })
usePageTitle('Alerts & Notifications · Settings')
const { config, addThreshold, removeThreshold, addPaymentTier, removePaymentTier, addCustomRule, updateCustomRule, removeCustomRule } = useAlertConfig()
const toast = useToast()
// ── Recipient helpers ──────────────────────────────────────────────────────
const RECIPIENT_OPTIONS: { id: AlertRecipient; label: string }[] = [
{ id: 'handler', label: 'Handler' },
{ id: 'manager', label: 'Manager' },
{ id: 'customer', label: 'Customer' },
{ id: 'custom', label: 'Custom email' },
]
function toggleRecipient(list: AlertRecipient[], r: AlertRecipient) {
const idx = list.indexOf(r)
if (idx === -1) list.push(r)
else list.splice(idx, 1)
}
// ── Threshold add ──────────────────────────────────────────────────────────
const newRenewalDays = ref(7)
const newCcDays = ref(7)
function addRenewalThreshold() {
addThreshold('renewals', newRenewalDays.value)
newRenewalDays.value = 7
}
function addCcThreshold() {
addThreshold('creditCardExpiry', newCcDays.value)
newCcDays.value = 7
}
// ── Late payment tier add ──────────────────────────────────────────────────
const newLpDays = ref(45)
function addNewPaymentTier() {
addPaymentTier(newLpDays.value, 'Notify assigned handler', ['handler'])
newLpDays.value = 45
}
// ── Custom rule add ────────────────────────────────────────────────────────
const FIELD_OPTIONS = [
{ value: 'premium', label: 'Annual premium' },
{ value: 'policy_count', label: 'Policy count' },
{ value: 'days_to_renewal', label: 'Days to renewal' },
{ value: 'days_overdue', label: 'Days overdue' },
{ value: 'claim_count', label: 'Open claims' },
{ value: 'reserve_amount', label: 'Reserve amount' },
]
const OPERATOR_OPTIONS = [
{ value: 'gte', label: '≥' },
{ value: 'lte', label: '≤' },
{ value: 'eq', label: '=' },
{ value: 'gt', label: '>' },
{ value: 'lt', label: '<' },
]
const showNewRule = ref(false)
const newRule = ref({ alertName: '', field: 'premium', operator: 'gte' as const, value: 0 })
function submitNewRule() {
if (!newRule.value.alertName.trim()) return
addCustomRule({
alertName: newRule.value.alertName,
field: newRule.value.field,
operator: newRule.value.operator,
value: newRule.value.value,
recipients: ['handler'],
enabled: true,
})
newRule.value = { alertName: '', field: 'premium', operator: 'gte', value: 0 }
showNewRule.value = false
}
// ── Escalation dot color ───────────────────────────────────────────────────
function tierDotClass(idx: number, total: number) {
if (total <= 1) return 'al-dot-green'
if (idx === total - 1) return 'al-dot-red'
if (idx >= total / 2) return 'al-dot-amber'
return 'al-dot-green'
}
function handleSave() {
toast.add({ title: 'Alert configuration saved', color: 'green' })
}
</script>
<template>
<div class="al-page">
<!-- Header -->
<div class="al-header">
<div>
<NuxtLink to="/settings" class="al-back-link">
<UIcon name="i-heroicons-arrow-left" class="w-3.5 h-3.5" />
Settings
</NuxtLink>
<h1 class="al-title">Alerts & Notifications</h1>
<p class="al-subtitle">Configure automated email alerts for renewals, cancellations, late payments, and custom triggers.</p>
</div>
<button class="al-save-btn" @click="handleSave">
<UIcon name="i-heroicons-check" class="w-4 h-4" />
Save Changes
</button>
</div>
<!-- 1. EMAIL SENDER -->
<div class="al-card">
<div class="al-card-header">
<UIcon name="i-heroicons-envelope" class="w-5 h-5" />
<div>
<p class="al-card-title">Email Sender Configuration</p>
<p class="al-card-desc">All automated alerts are sent from this email address.</p>
</div>
</div>
<div class="al-field-grid">
<div>
<label class="al-label">Sender email</label>
<input v-model="config.emailSender.senderEmail" class="al-input-med" type="email" placeholder="alertas@miagencia.com" />
</div>
<div>
<label class="al-label">Display name</label>
<input v-model="config.emailSender.senderDisplayName" class="al-input-med" type="text" placeholder="Segur-OS Alertas" />
</div>
<div>
<label class="al-label">Reply-to email</label>
<input v-model="config.emailSender.replyToEmail" class="al-input-med" type="email" placeholder="soporte@miagencia.com" />
</div>
</div>
</div>
<!-- 2. RENEWAL ALERTS -->
<div class="al-card">
<div class="al-card-header">
<UIcon name="i-heroicons-arrow-path" class="w-5 h-5" />
<div style="flex: 1;">
<p class="al-card-title">Renewal Alerts</p>
<p class="al-card-desc">Notify when policies approach their renewal date.</p>
</div>
<button
class="al-toggle"
:class="config.renewals.enabled ? 'al-toggle-on' : 'al-toggle-off'"
@click="config.renewals.enabled = !config.renewals.enabled"
>
<span class="al-toggle-dot" />
</button>
</div>
<template v-if="config.renewals.enabled">
<div class="al-table-wrap">
<table class="al-table">
<thead>
<tr>
<th>Days before expiry</th>
<th style="width: 80px;">Active</th>
<th style="width: 50px;"></th>
</tr>
</thead>
<tbody>
<tr v-for="t in config.renewals.thresholds" :key="t.id">
<td>
<div class="al-input-group">
<input v-model.number="t.daysBefore" class="al-input-sm" type="number" min="1" />
<span class="al-input-suffix">days</span>
</div>
</td>
<td class="text-center">
<button
class="al-toggle"
:class="t.enabled ? 'al-toggle-on' : 'al-toggle-off'"
@click="t.enabled = !t.enabled"
>
<span class="al-toggle-dot" />
</button>
</td>
<td class="text-center">
<button class="al-remove-btn" @click="removeThreshold('renewals', t.id)">
<UIcon name="i-heroicons-x-mark" class="w-3.5 h-3.5" />
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="al-add-row">
<input v-model.number="newRenewalDays" class="al-input-sm" type="number" min="1" />
<span class="al-input-suffix">days</span>
<button class="al-add-btn" @click="addRenewalThreshold">
<UIcon name="i-heroicons-plus" class="w-3.5 h-3.5" />
Add threshold
</button>
</div>
</template>
</div>
<!-- 3. CANCELLATION ALERTS -->
<div class="al-card">
<div class="al-card-header">
<UIcon name="i-heroicons-x-circle" class="w-5 h-5" />
<div style="flex: 1;">
<p class="al-card-title">Cancellation Alerts</p>
<p class="al-card-desc">Alert when a policy enters the cancellation process.</p>
</div>
<button
class="al-toggle"
:class="config.cancellations.enabled ? 'al-toggle-on' : 'al-toggle-off'"
@click="config.cancellations.enabled = !config.cancellations.enabled"
>
<span class="al-toggle-dot" />
</button>
</div>
<template v-if="config.cancellations.enabled">
<p class="al-label" style="margin-bottom: 10px;">Notify these recipients:</p>
<div class="al-checkbox-list">
<label v-for="r in RECIPIENT_OPTIONS" :key="r.id" class="al-checkbox-item">
<input
type="checkbox"
:checked="config.cancellations.recipients.includes(r.id)"
@change="toggleRecipient(config.cancellations.recipients, r.id)"
/>
<span>{{ r.label }}</span>
</label>
</div>
</template>
</div>
<!-- 4. LATE PAYMENT ALERTS -->
<div class="al-card">
<div class="al-card-header">
<UIcon name="i-heroicons-clock" class="w-5 h-5" />
<div style="flex: 1;">
<p class="al-card-title">Late Payment Alerts</p>
<p class="al-card-desc">Escalating notifications based on days overdue.</p>
</div>
<button
class="al-toggle"
:class="config.latePayments.enabled ? 'al-toggle-on' : 'al-toggle-off'"
@click="config.latePayments.enabled = !config.latePayments.enabled"
>
<span class="al-toggle-dot" />
</button>
</div>
<template v-if="config.latePayments.enabled">
<div class="al-escalation-list">
<div v-for="(tier, idx) in config.latePayments.tiers" :key="tier.id" class="al-escalation-row">
<span class="al-escalation-dot" :class="tierDotClass(idx, config.latePayments.tiers.length)" />
<div class="al-escalation-content">
<div class="al-escalation-fields">
<div class="al-field-inline">
<span class="al-label-sm">Days overdue</span>
<input v-model.number="tier.daysOverdue" class="al-input-sm" type="number" min="1" />
</div>
<div class="al-field-inline" style="min-width: 220px;">
<span class="al-label-sm">Action</span>
<input v-model="tier.action" class="al-input-med" type="text" />
</div>
<div class="al-field-inline">
<span class="al-label-sm">Notify</span>
<div class="al-chip-row">
<button
v-for="r in RECIPIENT_OPTIONS"
:key="r.id"
class="al-chip"
:class="tier.recipients.includes(r.id) ? 'al-chip-on' : 'al-chip-off'"
@click="toggleRecipient(tier.recipients, r.id)"
>{{ r.label }}</button>
</div>
</div>
</div>
</div>
<button class="al-remove-btn" @click="removePaymentTier(tier.id)">
<UIcon name="i-heroicons-x-mark" class="w-3.5 h-3.5" />
</button>
</div>
</div>
<div class="al-add-row" style="margin-top: 12px;">
<input v-model.number="newLpDays" class="al-input-sm" type="number" min="1" />
<span class="al-input-suffix">days</span>
<button class="al-add-btn" @click="addNewPaymentTier">
<UIcon name="i-heroicons-plus" class="w-3.5 h-3.5" />
Add tier
</button>
</div>
</template>
</div>
<!-- 5. CREDIT CARD EXPIRY -->
<div class="al-card">
<div class="al-card-header">
<UIcon name="i-heroicons-credit-card" class="w-5 h-5" />
<div style="flex: 1;">
<p class="al-card-title">Credit Card Expiry Alerts</p>
<p class="al-card-desc">Notify customers before their payment card expires.</p>
</div>
<button
class="al-toggle"
:class="config.creditCardExpiry.enabled ? 'al-toggle-on' : 'al-toggle-off'"
@click="config.creditCardExpiry.enabled = !config.creditCardExpiry.enabled"
>
<span class="al-toggle-dot" />
</button>
</div>
<template v-if="config.creditCardExpiry.enabled">
<label class="al-checkbox-item" style="margin-bottom: 12px;">
<input
type="checkbox"
:checked="config.creditCardExpiry.autoDebitOnly"
@change="config.creditCardExpiry.autoDebitOnly = !config.creditCardExpiry.autoDebitOnly"
/>
<span>Only for customers with auto-debit enabled</span>
</label>
<div class="al-table-wrap">
<table class="al-table">
<thead>
<tr>
<th>Days before expiry</th>
<th style="width: 80px;">Active</th>
<th style="width: 50px;"></th>
</tr>
</thead>
<tbody>
<tr v-for="t in config.creditCardExpiry.thresholds" :key="t.id">
<td>
<div class="al-input-group">
<input v-model.number="t.daysBefore" class="al-input-sm" type="number" min="1" />
<span class="al-input-suffix">days</span>
</div>
</td>
<td class="text-center">
<button
class="al-toggle"
:class="t.enabled ? 'al-toggle-on' : 'al-toggle-off'"
@click="t.enabled = !t.enabled"
>
<span class="al-toggle-dot" />
</button>
</td>
<td class="text-center">
<button class="al-remove-btn" @click="removeThreshold('creditCardExpiry', t.id)">
<UIcon name="i-heroicons-x-mark" class="w-3.5 h-3.5" />
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="al-add-row">
<input v-model.number="newCcDays" class="al-input-sm" type="number" min="1" />
<span class="al-input-suffix">days</span>
<button class="al-add-btn" @click="addCcThreshold">
<UIcon name="i-heroicons-plus" class="w-3.5 h-3.5" />
Add threshold
</button>
</div>
</template>
</div>
<!-- 6. CUSTOM ALERTS -->
<div class="al-card">
<div class="al-card-header">
<UIcon name="i-heroicons-adjustments-horizontal" class="w-5 h-5" />
<div>
<p class="al-card-title">Custom Parameter Alerts</p>
<p class="al-card-desc">Define custom rules that trigger alerts based on any field condition.</p>
</div>
</div>
<div class="al-table-wrap">
<table class="al-table">
<thead>
<tr>
<th>Alert name</th>
<th>Field</th>
<th>Operator</th>
<th>Value</th>
<th>Recipients</th>
<th style="width: 70px;">Active</th>
<th style="width: 50px;"></th>
</tr>
</thead>
<tbody>
<tr v-for="rule in config.customRules" :key="rule.id">
<td>
<input v-model="rule.alertName" class="al-input-med" type="text" />
</td>
<td>
<select v-model="rule.field" class="al-select">
<option v-for="f in FIELD_OPTIONS" :key="f.value" :value="f.value">{{ f.label }}</option>
</select>
</td>
<td>
<select v-model="rule.operator" class="al-select" style="width: 56px;">
<option v-for="o in OPERATOR_OPTIONS" :key="o.value" :value="o.value">{{ o.label }}</option>
</select>
</td>
<td>
<input v-model.number="rule.value" class="al-input-sm" type="number" />
</td>
<td>
<div class="al-chip-row">
<button
v-for="r in RECIPIENT_OPTIONS"
:key="r.id"
class="al-chip al-chip-sm"
:class="rule.recipients.includes(r.id) ? 'al-chip-on' : 'al-chip-off'"
@click="toggleRecipient(rule.recipients, r.id)"
>{{ r.label }}</button>
</div>
</td>
<td class="text-center">
<button
class="al-toggle"
:class="rule.enabled ? 'al-toggle-on' : 'al-toggle-off'"
@click="rule.enabled = !rule.enabled"
>
<span class="al-toggle-dot" />
</button>
</td>
<td class="text-center">
<button class="al-remove-btn" @click="removeCustomRule(rule.id)">
<UIcon name="i-heroicons-trash" class="w-3.5 h-3.5" />
</button>
</td>
</tr>
<!-- Inline add row -->
<tr v-if="showNewRule" class="al-new-rule-row">
<td>
<input v-model="newRule.alertName" class="al-input-med" type="text" placeholder="Alert name…" />
</td>
<td>
<select v-model="newRule.field" class="al-select">
<option v-for="f in FIELD_OPTIONS" :key="f.value" :value="f.value">{{ f.label }}</option>
</select>
</td>
<td>
<select v-model="newRule.operator" class="al-select" style="width: 56px;">
<option v-for="o in OPERATOR_OPTIONS" :key="o.value" :value="o.value">{{ o.label }}</option>
</select>
</td>
<td>
<input v-model.number="newRule.value" class="al-input-med" type="number" placeholder="0" />
</td>
<td colspan="2">
<div style="display: flex; gap: 6px;">
<button class="al-add-btn" @click="submitNewRule">Save</button>
<button class="al-remove-btn" @click="showNewRule = false">Cancel</button>
</div>
</td>
<td></td>
</tr>
</tbody>
</table>
</div>
<div v-if="!showNewRule" class="al-add-row">
<button class="al-add-btn" @click="showNewRule = true">
<UIcon name="i-heroicons-plus" class="w-3.5 h-3.5" />
Add custom rule
</button>
</div>
</div>
</div>
</template>
<style scoped>
/* =====================================================================
ALERTS & NOTIFICATIONS SETTINGS — scoped, al- prefix
===================================================================== */
.al-page {
max-width: 64rem; margin: 0 auto;
display: flex; flex-direction: column; gap: 24px; padding-bottom: 48px;
}
/* ── Header ── */
.al-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
.al-back-link {
display: inline-flex; align-items: center; gap: 5px;
font-size: 12px; font-weight: 500; color: #8a8a86;
text-decoration: none; margin-bottom: 8px; transition: color 150ms ease;
}
.al-back-link:hover { color: #01696f; }
.al-title { font-size: 22px; font-weight: 700; color: #1a1a1a; }
.al-subtitle { font-size: 13px; color: #8a8a86; margin-top: 4px; }
.al-save-btn {
display: inline-flex; align-items: center; gap: 6px; padding: 8px 18px;
border-radius: 10px; font-size: 13px; font-weight: 600;
background: #01696f; color: white; border: none; cursor: pointer;
}
.al-save-btn:hover { opacity: 0.9; }
/* ── Card ── */
.al-card {
background: #fff; border: 1px solid rgba(0,0,0,0.06);
border-radius: 12px; padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.03);
}
.al-card-header { display: flex; align-items: flex-start; gap: 12px; margin-bottom: 16px; color: #01696f; }
.al-card-title { font-size: 16px; font-weight: 700; color: #1a1a1a; }
.al-card-desc { font-size: 12px; color: #8a8a86; margin-top: 2px; }
/* ── Field grid ── */
.al-field-grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; }
@media (max-width: 640px) { .al-field-grid { grid-template-columns: 1fr; } }
/* ── Table ── */
.al-table-wrap { overflow-x: auto; }
.al-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.al-table thead th {
padding: 8px 12px; font-size: 10px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.04em;
color: #8a8a86; border-bottom: 1px solid rgba(0,0,0,0.06);
text-align: left; white-space: nowrap;
}
.al-table tbody td {
padding: 10px 12px; border-bottom: 1px solid rgba(0,0,0,0.04);
vertical-align: middle;
}
.al-table tbody tr:last-child td { border-bottom: none; }
/* ── Inputs ── */
.al-input-sm {
width: 72px; padding: 5px 8px; border: 1px solid rgba(0,0,0,0.1);
border-radius: 6px; font-size: 13px; text-align: center;
font-variant-numeric: tabular-nums;
}
.al-input-sm:focus { outline: none; border-color: #01696f; }
.al-input-med {
width: 100%; padding: 5px 10px; border: 1px solid rgba(0,0,0,0.1);
border-radius: 6px; font-size: 13px;
}
.al-input-med:focus { outline: none; border-color: #01696f; }
.al-input-group { display: flex; align-items: center; gap: 6px; }
.al-input-suffix { font-size: 12px; color: #8a8a86; }
.al-label { display: block; font-size: 13px; font-weight: 600; color: #1a1a1a; margin-bottom: 4px; }
.al-label-sm { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.03em; color: #8a8a86; margin-bottom: 2px; }
/* ── Select ── */
.al-select {
padding: 5px 8px; border: 1px solid rgba(0,0,0,0.1);
border-radius: 6px; font-size: 13px; background: #fff; cursor: pointer;
}
.al-select:focus { outline: none; border-color: #01696f; }
/* ── Toggle ── */
.al-toggle {
width: 36px; height: 20px; border-radius: 10px; border: none;
cursor: pointer; position: relative; transition: background 200ms ease;
padding: 0; flex-shrink: 0;
}
.al-toggle-on { background: #01696f; }
.al-toggle-off { background: rgba(0,0,0,0.15); }
.al-toggle-dot {
display: block; width: 16px; height: 16px; border-radius: 50%;
background: white; position: absolute; top: 2px;
transition: left 200ms ease;
}
.al-toggle-on .al-toggle-dot { left: 18px; }
.al-toggle-off .al-toggle-dot { left: 2px; }
/* ── Checkbox ── */
.al-checkbox-list { display: flex; flex-direction: column; gap: 8px; }
.al-checkbox-item {
display: flex; align-items: center; gap: 8px;
font-size: 13px; color: #1a1a1a; cursor: pointer;
}
.al-checkbox-item input[type="checkbox"] { accent-color: #01696f; width: 15px; height: 15px; }
/* ── Escalation ── */
.al-escalation-list { display: flex; flex-direction: column; gap: 16px; }
.al-escalation-row { display: flex; align-items: flex-start; gap: 12px; }
.al-escalation-dot { width: 10px; height: 10px; border-radius: 50%; margin-top: 6px; flex-shrink: 0; }
.al-dot-green { background: #059669; }
.al-dot-amber { background: #c27b1a; }
.al-dot-red { background: #c13838; }
.al-escalation-content { flex: 1; }
.al-escalation-fields { display: flex; gap: 12px; flex-wrap: wrap; }
.al-field-inline { display: flex; flex-direction: column; flex: 1; min-width: 100px; }
/* ── Chips ── */
.al-chip-row { display: flex; gap: 4px; flex-wrap: wrap; }
.al-chip {
padding: 3px 8px; border-radius: 6px; font-size: 11px; font-weight: 600;
border: 1px solid transparent; cursor: pointer; transition: all 150ms ease;
white-space: nowrap;
}
.al-chip-sm { padding: 2px 6px; font-size: 10px; }
.al-chip-on { background: rgba(1,105,111,0.08); color: #01696f; border-color: rgba(1,105,111,0.2); }
.al-chip-off { background: rgba(0,0,0,0.03); color: #8a8a86; border-color: rgba(0,0,0,0.06); }
.al-chip-off:hover { color: #1a1a1a; background: rgba(0,0,0,0.06); }
/* ── Add row ── */
.al-add-row { display: flex; align-items: center; gap: 8px; margin-top: 12px; }
.al-add-btn {
display: inline-flex; align-items: center; gap: 4px; padding: 5px 12px;
border-radius: 8px; font-size: 12px; font-weight: 600;
background: rgba(1,105,111,0.06); color: #01696f;
border: 1px solid rgba(1,105,111,0.15); cursor: pointer;
}
.al-add-btn:hover { background: rgba(1,105,111,0.12); }
/* ── Remove button ── */
.al-remove-btn {
display: inline-flex; align-items: center; justify-content: center;
width: 28px; height: 28px; border-radius: 6px;
background: transparent; border: none; color: #8a8a86;
cursor: pointer; transition: all 150ms ease;
}
.al-remove-btn:hover { background: rgba(193,56,56,0.06); color: #c13838; }
/* ── New rule row ── */
.al-new-rule-row td { background: rgba(1,105,111,0.02); }
/* ── Responsive ── */
@media (max-width: 640px) {
.al-escalation-fields { flex-direction: column; }
}
</style>

View File

@@ -0,0 +1,7 @@
<script setup lang="ts">
await navigateTo('/account', { replace: true })
</script>
<template>
<div class="p-8 text-sm text-[var(--text-muted)]">Redirecting</div>
</template>

View File

@@ -0,0 +1,369 @@
<script setup lang="ts">
import { useCustomerAttention } from '~/composables/useCustomerAttention'
import { MOCK_CUSTOMERS, fmtMoney } from '~/data/mock-customers'
import { customerTier } from '~/data/mock-customers'
definePageMeta({ ssr: false })
usePageTitle('Customer Attention · Settings')
const { config, tiers, rules, getScoreForCustomer, getTierForCustomer } = useCustomerAttention()
/* ── Tabs ── */
type Tab = 'tiers' | 'rules' | 'preview'
const activeTab = ref<Tab>('tiers')
const tabItems: { id: Tab; label: string }[] = [
{ id: 'tiers', label: 'Service Tiers' },
{ id: 'rules', label: 'Classification Rules' },
{ id: 'preview', label: 'Preview' },
]
/* ── Operator labels ── */
const operatorLabel: Record<string, string> = {
gte: '>=',
gt: '>',
lte: '<=',
lt: '<',
eq: '=',
}
/* ── Field labels ── */
const fieldLabel: Record<string, string> = {
premium: 'Annual Premium',
policy_count: 'Policy Count',
commission: 'Annual Commission',
collectivo_member: 'Collectivo Member',
multi_line: 'Unique Lines',
tenure_years: 'Tenure (years)',
has_private_policies: 'Has Private Policies',
}
/* ── Preview data ── */
const currentYear = new Date().getFullYear()
const previewRows = computed(() =>
MOCK_CUSTOMERS.map((c) => {
const activePolicies = c.policies.filter(p => p.status === 'Active' || p.status === 'Pending')
const totalPremium = activePolicies.reduce((sum, p) => sum + p.premium, 0)
const policyCount = activePolicies.length
const lineCount = new Set(activePolicies.map(p => p.line)).size
const sinceYear = c.since ? new Date(c.since).getFullYear() : currentYear
const tenureYears = currentYear - sinceYear
const estimatedCommission = Math.round(totalPremium * 0.15)
const input = {
totalPremium,
policyCount,
lineCount,
tenureYears,
isCollectivoMember: false,
hasPrivatePolicies: activePolicies.length > 0,
estimatedCommission,
}
return {
id: c.id,
name: c.name,
totalPremium,
policyCount,
lineCount,
tenureYears,
score: getScoreForCustomer(input),
tier: getTierForCustomer(input),
}
}).sort((a, b) => b.score - a.score)
)
function formatRuleValue(rule: { field: string; value: number | boolean }): string {
if (typeof rule.value === 'boolean') return rule.value ? 'Yes' : 'No'
if (rule.field === 'premium' || rule.field === 'commission') return fmtMoney(rule.value)
return String(rule.value)
}
</script>
<template>
<div class="ca-page">
<!-- Back -->
<NuxtLink to="/settings" class="inline-flex">
<UButton color="neutral" variant="ghost" size="sm" icon="i-heroicons-arrow-left">Settings</UButton>
</NuxtLink>
<!-- Header -->
<div class="max-w-xl">
<h1 class="mt-1 text-2xl font-semibold tracking-tight text-[var(--text-primary)]">Customer Attention &amp; Service Levels</h1>
<p class="mt-2 text-[14px] leading-relaxed text-[var(--text-muted)]">
Define service tiers and scoring rules to automatically classify customers by value and engagement.
</p>
</div>
<!-- Dev note -->
<div class="ca-dev-note">
<UIcon name="i-heroicons-beaker" style="width: 16px; height: 16px; flex-shrink: 0;" />
<div>
<p class="ca-dev-note-title">In development</p>
<p class="ca-dev-note-text">
This feature uses local configuration only. Once the API is ready, tiers and rules will be persisted server-side and apply across the organization.
</p>
</div>
</div>
<!-- Tabs -->
<div class="ca-filter-tabs">
<button
v-for="t in tabItems"
:key="t.id"
type="button"
class="ca-filter-tab"
:class="activeTab === t.id ? 'ca-filter-on' : 'ca-filter-off'"
@click="activeTab = t.id"
>
{{ t.label }}
</button>
</div>
<!-- Service Tiers -->
<div v-if="activeTab === 'tiers'" class="ca-section-list">
<div v-for="tier in tiers" :key="tier.id" class="ca-card">
<div class="ca-card-top">
<div class="ca-tier-icon" :style="{ background: tier.color + '14', color: tier.color }">
<UIcon :name="tier.icon" style="width: 20px; height: 20px;" />
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 flex-wrap">
<p class="text-[14px] font-semibold text-[var(--text-primary)]">{{ tier.name }}</p>
<span class="ca-score-badge" :style="{ background: tier.color + '14', color: tier.color }">
{{ tier.minScore }}+ pts
</span>
</div>
<p class="mt-0.5 text-[12px] text-[var(--text-muted)]">{{ tier.description }}</p>
</div>
<div class="ca-card-actions">
<button type="button" class="ca-action-btn" title="Edit">
<UIcon name="i-heroicons-pencil-square" style="width: 14px; height: 14px;" />
</button>
<button type="button" class="ca-action-btn ca-action-delete" title="Delete">
<UIcon name="i-heroicons-trash" style="width: 14px; height: 14px;" />
</button>
</div>
</div>
<div class="ca-benefits">
<p class="ca-label">Benefits</p>
<div class="ca-benefit-list">
<span v-for="b in tier.benefits" :key="b" class="ca-benefit-tag">{{ b }}</span>
</div>
</div>
</div>
<button type="button" class="ca-btn-primary" style="align-self: flex-start;">
<UIcon name="i-heroicons-plus" style="width: 14px; height: 14px;" />
Add Tier
</button>
</div>
<!-- Classification Rules -->
<div v-if="activeTab === 'rules'" class="ca-section-list">
<!-- Auto-classify toggle -->
<div class="ca-card" style="flex-direction: row; align-items: center; justify-content: space-between;">
<div>
<p class="text-[13px] font-semibold text-[var(--text-primary)]">Auto-classify customers</p>
<p class="text-[12px] text-[var(--text-muted)] mt-0.5">Automatically assign tiers based on scoring rules</p>
</div>
<button
type="button"
class="ca-toggle"
:class="config.autoClassify ? 'ca-toggle-on' : 'ca-toggle-off'"
@click="config.autoClassify = !config.autoClassify"
>
<span class="ca-toggle-dot" :class="config.autoClassify ? 'ca-dot-on' : 'ca-dot-off'" />
</button>
</div>
<!-- Rules list -->
<div v-for="rule in rules" :key="rule.id" class="ca-rule-row">
<div class="ca-rule-field">{{ fieldLabel[rule.field] ?? rule.field }}</div>
<span class="ca-rule-op">{{ operatorLabel[rule.operator] ?? rule.operator }}</span>
<span class="ca-rule-value">{{ formatRuleValue(rule) }}</span>
<span class="ca-rule-arrow">&#8594;</span>
<span class="ca-rule-points">+{{ rule.points }} pts</span>
<span class="ca-rule-label">{{ rule.label }}</span>
</div>
<button type="button" class="ca-btn-primary" style="align-self: flex-start;">
<UIcon name="i-heroicons-plus" style="width: 14px; height: 14px;" />
Add Rule
</button>
</div>
<!-- Preview -->
<div v-if="activeTab === 'preview'" class="ca-section-list">
<div class="ca-table-wrap">
<table class="ca-table">
<thead>
<tr>
<th>Customer</th>
<th>Premium</th>
<th>Policies</th>
<th>Lines</th>
<th>Tenure</th>
<th>Score</th>
<th>Tier</th>
</tr>
</thead>
<tbody>
<tr v-for="row in previewRows" :key="row.id">
<td class="ca-cell-name">{{ row.name }}</td>
<td>{{ row.totalPremium > 0 ? fmtMoney(row.totalPremium) : '—' }}</td>
<td>{{ row.policyCount }}</td>
<td>{{ row.lineCount }}</td>
<td>{{ row.tenureYears }}yr</td>
<td class="ca-cell-score">{{ row.score }}</td>
<td>
<span
class="ca-tier-badge"
:style="{ background: row.tier.color + '14', color: row.tier.color }"
>
{{ row.tier.name }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
<p class="text-[11px] text-[var(--text-muted)]">
Showing {{ previewRows.length }} mock customers. Scores computed from current rules.
</p>
</div>
</div>
</template>
<style scoped>
.ca-page {
max-width: 48rem; margin: 0 auto;
display: flex; flex-direction: column; gap: 20px; padding-bottom: 3rem;
}
/* ── Dev note ── */
.ca-dev-note {
display: flex; align-items: flex-start; gap: 10px;
padding: 14px 16px; border-radius: 12px;
background: rgba(124, 58, 237, 0.06); border: 1px solid rgba(124, 58, 237, 0.12);
color: #6d28d9;
}
.ca-dev-note-title { font-size: 13px; font-weight: 600; }
.ca-dev-note-text { font-size: 12px; margin-top: 2px; line-height: 1.5; opacity: 0.85; }
/* ── Tabs ── */
.ca-filter-tabs { display: inline-flex; gap: 2px; padding: 3px; border-radius: 10px; background: rgba(0,0,0,0.04); }
.ca-filter-tab {
display: inline-flex; align-items: center; gap: 5px;
padding: 6px 14px; border-radius: 8px;
font-size: 12px; font-weight: 500; border: none;
cursor: pointer; transition: all 150ms ease; white-space: nowrap;
}
.ca-filter-on { background: #fff; color: var(--text-primary); box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
.ca-filter-off { background: transparent; color: var(--text-muted); }
.ca-filter-off:hover { color: var(--text-primary); }
/* ── Section list ── */
.ca-section-list { display: flex; flex-direction: column; gap: 10px; }
/* ── Cards ── */
.ca-card {
display: flex; flex-direction: column; gap: 12px;
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);
transition: all 150ms ease;
}
.ca-card:hover { border-color: rgba(1,105,111,0.15); }
.ca-card-top { display: flex; align-items: center; gap: 12px; }
.ca-tier-icon {
width: 40px; height: 40px; border-radius: 12px;
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.ca-score-badge {
font-size: 10px; font-weight: 600; padding: 2px 8px; border-radius: 9999px; white-space: nowrap;
}
.ca-card-actions { display: flex; gap: 4px; opacity: 0; transition: opacity 150ms ease; flex-shrink: 0; }
.ca-card:hover .ca-card-actions { opacity: 1; }
.ca-action-btn {
display: inline-flex; align-items: center; justify-content: center;
width: 28px; height: 28px; border-radius: 6px; border: none; cursor: pointer;
background: rgba(0,0,0,0.03); color: #8a8a86; transition: all 150ms ease;
}
.ca-action-btn:hover { background: rgba(0,0,0,0.06); color: var(--text-primary); }
.ca-action-delete:hover { background: rgba(193,56,56,0.08); color: #c13838; }
/* ── Benefits ── */
.ca-benefits { padding-left: 52px; }
.ca-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: #8a8a86; margin-bottom: 6px; }
.ca-benefit-list { display: flex; flex-wrap: wrap; gap: 5px; }
.ca-benefit-tag {
font-size: 11px; font-weight: 500; padding: 3px 9px; border-radius: 6px;
background: rgba(0,0,0,0.035); color: var(--text-muted); white-space: nowrap;
}
/* ── Buttons ── */
.ca-btn-primary {
display: inline-flex; align-items: center; gap: 6px;
padding: 8px 16px; border-radius: 8px;
background: #01696f; color: #fff;
font-size: 13px; font-weight: 500; border: none;
cursor: pointer; transition: all 150ms ease; white-space: nowrap;
}
.ca-btn-primary:hover { background: #015458; }
/* ── Toggle ── */
.ca-toggle {
width: 36px; height: 20px; border-radius: 10px; border: none;
cursor: pointer; position: relative; transition: background 150ms ease; flex-shrink: 0;
}
.ca-toggle-on { background: #01696f; }
.ca-toggle-off { background: rgba(0,0,0,0.12); }
.ca-toggle-dot {
display: block; width: 16px; height: 16px; border-radius: 8px;
background: #fff; position: absolute; top: 2px; transition: left 150ms ease;
}
.ca-dot-on { left: 18px; }
.ca-dot-off { left: 2px; }
/* ── Rule rows ── */
.ca-rule-row {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
padding: 12px 16px; border-radius: 10px;
border: 1px solid rgba(0,0,0,0.06); background: #fff;
box-shadow: 0 1px 3px rgba(0,0,0,0.03);
font-size: 13px;
}
.ca-rule-field { font-weight: 600; color: var(--text-primary); min-width: 120px; }
.ca-rule-op { font-size: 12px; font-weight: 600; color: #8a8a86; font-family: monospace; }
.ca-rule-value { font-weight: 600; color: var(--text-primary); }
.ca-rule-arrow { color: #8a8a86; font-size: 14px; }
.ca-rule-points {
font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 9999px;
background: rgba(1,105,111,0.08); color: #01696f; white-space: nowrap;
}
.ca-rule-label { font-size: 11px; color: var(--text-muted); margin-left: auto; }
/* ── Preview table ── */
.ca-table-wrap {
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); overflow: auto;
}
.ca-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.ca-table th {
text-align: left; padding: 10px 14px;
font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em;
color: #8a8a86; border-bottom: 1px solid rgba(0,0,0,0.06); white-space: nowrap;
}
.ca-table td {
padding: 10px 14px; border-bottom: 1px solid rgba(0,0,0,0.04);
color: var(--text-primary); white-space: nowrap;
}
.ca-table tbody tr:last-child td { border-bottom: none; }
.ca-table tbody tr:hover { background: rgba(0,0,0,0.015); }
.ca-cell-name { font-weight: 600; white-space: normal; min-width: 160px; }
.ca-cell-score { font-weight: 700; font-variant-numeric: tabular-nums; }
.ca-tier-badge {
font-size: 11px; font-weight: 600; padding: 2px 9px; border-radius: 9999px; white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,255 @@
<script setup lang="ts">
import { refDebounced } from '~/utils/refDebounced'
import type { FormCatalogRow } from '~/types/form-catalog'
import { productLineLabel, useFormsCatalog } from '~/composables/useFormsCatalog'
usePageTitle('Forms library · Settings')
const { rows, version, insurerItems, subRamoItems } = useFormsCatalog()
const pageSize = ref(10)
const page = ref(1)
const search = ref('')
const debouncedSearch = refDebounced(search, 250)
const pageSizeItems = [
{ label: '10', value: 10 },
{ label: '25', value: 25 },
{ label: '50', value: 50 }
]
function rowSearchText(r: FormCatalogRow): string {
return [
String(r.id),
r.description,
...r.insurerSlugs,
r.subRamoLabel,
r.subRamoKey,
r.fileLabel
]
.join(' ')
.toLowerCase()
}
const filtered = computed(() => {
const q = debouncedSearch.value.trim().toLowerCase()
if (!q) return rows.value
return rows.value.filter((r) => rowSearchText(r).includes(q))
})
watch([debouncedSearch, pageSize], () => {
page.value = 1
})
const total = computed(() => filtered.value.length)
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
watch(pageCount, (c) => {
if (page.value > c) page.value = c
})
const pageRows = computed(() => {
const start = (page.value - 1) * pageSize.value
return filtered.value.slice(start, start + pageSize.value)
})
const rangeLabel = computed(() => {
if (total.value === 0) return 'No records'
const start = (page.value - 1) * pageSize.value + 1
const end = Math.min(page.value * pageSize.value, total.value)
return `Showing ${start} to ${end} of ${total.value} records`
})
function goPrev() {
page.value = Math.max(1, page.value - 1)
}
function goNext() {
page.value = Math.min(pageCount.value, page.value + 1)
}
function personLabel(pk: FormCatalogRow['personKinds']) {
if (pk === 'both') return 'Natural · Jurídica'
return pk === 'natural' ? 'Natural' : 'Jurídica'
}
function exportCsv() {
const headers = [
'ID',
'Description',
'Insurers',
'Sub-ramo',
'Person type',
'Product line',
'File',
'Badge'
]
const lines = [headers.join(',')]
for (const r of filtered.value) {
const cells = [
r.id,
`"${r.description.replace(/"/g, '""')}"`,
`"${r.insurerSlugs.join('; ')}"`,
`"${r.subRamoLabel.replace(/"/g, '""')}"`,
r.personKinds,
r.productLine ?? '',
`"${r.fileLabel.replace(/"/g, '""')}"`,
r.badge ?? ''
]
lines.push(cells.join(','))
}
const blob = new Blob([lines.join('\n')], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `forms-catalog-v${version.value}-${new Date().toISOString().slice(0, 10)}.csv`
a.click()
URL.revokeObjectURL(url)
}
</script>
<template>
<div class="mx-auto max-w-[95rem] space-y-6 pb-12">
<div class="flex flex-wrap items-center gap-x-3 gap-y-2 text-sm">
<AppBackToHome />
<span class="text-[var(--text-muted)] opacity-50" aria-hidden="true">|</span>
<NuxtLink to="/settings" class="font-medium text-[var(--text-muted)] transition hover:text-[var(--text-primary)]">
All settings
</NuxtLink>
</div>
<div>
<h1 class="mt-1 text-2xl font-semibold tracking-tight text-[var(--text-primary)]">Forms library</h1>
<p class="mt-2 max-w-3xl text-[14px] leading-relaxed text-[var(--text-muted)]">
Master catalog: each row maps carriers, sub-ramos, person type, and product line (local vs international
health, auto full coverage vs DAT, etc.) to a template file. The
<NuxtLink to="/onboarding/solicitud" class="app-link">Solicitud</NuxtLink>
flow filters this list for customers.
</p>
<p class="mt-1 text-xs text-[var(--text-muted)]">Catalog version: {{ version }} · {{ rows.length }} rows</p>
</div>
<UAlert
color="info"
variant="soft"
title="Backend wiring next"
description="Upload, OCR metadata, and mapping to open-fetch / APIs will plug in here once storage and services are ready."
/>
<div class="flex flex-wrap items-center justify-between gap-4">
<div class="flex flex-wrap items-center gap-3">
<span class="text-sm text-[var(--text-muted)]">Show</span>
<USelect v-model="pageSize" :items="pageSizeItems" class="w-24" size="sm" />
<span class="text-sm text-[var(--text-muted)]">records</span>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-[var(--text-muted)]">Search:</span>
<UInput
v-model="search"
icon="i-heroicons-magnifying-glass"
placeholder="ID, insurer, sub-ramo, file…"
class="w-72 max-w-full"
size="sm"
/>
</div>
</div>
<div class="overflow-hidden rounded-xl border border-[var(--card-border)] bg-[var(--surface)] shadow-sm">
<div class="overflow-x-auto">
<table class="min-w-full border-collapse text-sm">
<thead>
<tr
class="border-b border-[var(--card-border)] bg-[var(--surface)]/90 text-left text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]"
>
<th class="whitespace-nowrap px-3 py-3">ID</th>
<th class="min-w-[12rem] px-3 py-3">Description</th>
<th class="min-w-[8rem] px-3 py-3">Carrier(s)</th>
<th class="min-w-[10rem] px-3 py-3">Sub-ramo</th>
<th class="whitespace-nowrap px-3 py-3">Person type</th>
<th class="min-w-[8rem] px-3 py-3">Product line</th>
<th class="min-w-[12rem] px-3 py-3">File</th>
<th class="whitespace-nowrap px-3 py-3">Badge</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in pageRows"
:key="row.id"
class="border-b border-[var(--divider)] transition-colors hover:bg-[var(--surface)]/80"
>
<td class="whitespace-nowrap px-3 py-3 font-mono text-[var(--text-primary)]">{{ row.id }}</td>
<td class="max-w-xs px-3 py-3 text-[var(--text-primary)]">{{ row.description }}</td>
<td class="px-3 py-3">
<div class="flex flex-wrap gap-1">
<UBadge v-for="s in row.insurerSlugs" :key="s" color="neutral" variant="soft" size="xs">
{{ s }}
</UBadge>
</div>
</td>
<td class="px-3 py-3 text-[var(--text-primary)]">{{ row.subRamoLabel }}</td>
<td class="whitespace-nowrap px-3 py-3">{{ personLabel(row.personKinds) }}</td>
<td class="px-3 py-3 text-[var(--text-muted)]">{{ productLineLabel(row.productLine) }}</td>
<td class="px-3 py-3">
<a
:href="row.fileUrl"
target="_blank"
rel="noopener noreferrer"
class="break-all text-[var(--brand)] underline hover:text-[var(--brand)]"
>
{{ row.fileLabel }}
</a>
</td>
<td class="px-3 py-3 text-center">
<UBadge v-if="row.badge != null" color="primary" variant="soft">{{ row.badge }}</UBadge>
<span v-else class="text-[var(--text-muted)] opacity-50"></span>
</td>
</tr>
</tbody>
</table>
</div>
<div
class="flex flex-wrap items-center justify-between gap-3 border-t border-[var(--card-border)] px-4 py-3"
>
<p class="text-sm text-[var(--text-muted)]">{{ rangeLabel }}</p>
<div class="flex items-center gap-1">
<UButton
icon="i-heroicons-chevron-left"
color="neutral"
variant="ghost"
size="sm"
:disabled="page <= 1"
@click="goPrev"
/>
<span
class="inline-flex min-w-[2.25rem] items-center justify-center rounded-md bg-primary-500 px-2 py-1 text-sm font-medium text-white"
>
{{ page }}
</span>
<UButton
icon="i-heroicons-chevron-right"
color="neutral"
variant="ghost"
size="sm"
:disabled="page >= pageCount"
@click="goNext"
/>
</div>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<UButton
icon="i-heroicons-arrow-up-tray"
color="neutral"
variant="outline"
size="sm"
@click="exportCsv"
>
Export CSV
</UButton>
<NuxtLink to="/onboarding/solicitud">
<UButton color="primary" variant="soft" size="sm">Open solicitud preview</UButton>
</NuxtLink>
</div>
</div>
</template>

View File

@@ -0,0 +1,179 @@
<script setup lang="ts">
usePageTitle('Settings')
const { isSuperAdmin } = useSuperAdmin()
const sidebarFeatures = useSidebarFeatures()
const cards = computed(() => {
const base: { to: string; title: string; description: string; icon: string }[] = []
if (isSuperAdmin.value) {
base.push({
to: '/settings/organization',
title: 'Organization',
description: 'Company legal name, logo in the chrome, and PDF report headers — tenant-wide (superadmin only).',
icon: 'i-heroicons-building-library'
})
}
base.push(
{
to: '/settings/agents',
title: 'Agents & commissions',
description: 'Manage producer credentials, commission schedules, and cashflow-based compensation.',
icon: 'i-heroicons-user-group'
},
{
to: '/settings/referral-channels',
title: 'Referral channels',
description: 'Manage referral sources — people, companies, digital campaigns, events, and partnerships that generate leads.',
icon: 'i-heroicons-link'
},
{
to: '/settings/providers',
title: 'Providers & carriers',
description: 'Carrier directory, templates, and status.',
icon: 'i-heroicons-building-office-2'
},
{
to: '/settings/permissions',
title: 'Permissions',
description: 'Roles and SEGUROS permission matrix (seed data until API).',
icon: 'i-heroicons-shield-check'
},
{
to: '/settings/forms',
title: 'Forms library',
description: 'Master catalog of carrier PDFs and routing metadata.',
icon: 'i-heroicons-document-duplicate'
},
{
to: '/settings/quote-requests',
title: 'Quote requests',
description: 'Toggle whether quote flows describe outbound emails to carrier quoting addresses.',
icon: 'i-heroicons-envelope'
},
{
to: '/settings/customer-attention',
title: 'Customer Attention',
description: 'Service tiers and scoring rules to auto-classify customers by value and engagement.',
icon: 'i-heroicons-star'
},
{
to: '/settings/profile-layouts',
title: 'Profile Layouts',
description: 'Configure which customer profile sections appear and in what order based on your role.',
icon: 'i-heroicons-rectangle-group'
},
{
to: '/settings/alerts',
title: 'Alerts & Notifications',
description: 'Configure automated email alerts for renewals, cancellations, late payments, and custom triggers.',
icon: 'i-heroicons-bell-alert'
},
{
to: '/settings/support-routing',
title: 'Support Routing',
description: 'Configure 3-tier routing rules for incoming support requests — auto-routing, keyword classification, and open pool settings.',
icon: 'i-heroicons-arrow-path-rounded-square'
}
)
return base
})
</script>
<template>
<div class="mx-auto max-w-4xl space-y-8">
<div>
<h1 class="mt-1 text-2xl font-semibold tracking-tight text-[var(--text-primary)]">Software configuration</h1>
<p class="mt-2 max-w-2xl text-[14px] leading-relaxed text-[var(--text-muted)]">
Carriers, forms, permissions, and tenant organization (superadmin). Your personal theme and profile live under
<NuxtLink to="/account" class="font-medium text-[var(--brand)] underline-offset-2 hover:underline">Account</NuxtLink>.
</p>
</div>
<UAlert
v-if="!isSuperAdmin"
color="neutral"
variant="soft"
title="Organization settings are restricted"
description="Company logo and legal name are changed by a tenant superadmin. In development, superadmin UI defaults on unless localStorage policy-ui.superadmin is set to 0."
/>
<div class="grid gap-4 sm:grid-cols-2">
<NuxtLink
v-for="c in cards"
:key="c.to"
:to="c.to"
class="app-card app-card-interactive group p-5"
>
<div class="flex items-start gap-4">
<div class="inline-flex rounded-lg bg-[var(--brand-soft)] p-2 text-[var(--brand)] ring-1 ring-[var(--brand)]/10">
<UIcon :name="c.icon" class="h-6 w-6" />
</div>
<div>
<p class="font-semibold text-[var(--text-primary)] group-hover:text-[var(--brand)]">{{ c.title }}</p>
<p class="mt-1 text-sm text-[var(--text-muted)]">{{ c.description }}</p>
</div>
</div>
</NuxtLink>
</div>
<!-- Sidebar modules -->
<div>
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Sidebar modules</h2>
<p class="mt-1 text-[13px] text-[var(--text-muted)]">Show or hide optional sections in the navigation sidebar.</p>
<div class="mt-4 flex flex-col gap-3">
<label class="app-card flex flex-col gap-3 p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<UIcon name="i-heroicons-inbox-stack" class="h-5 w-5 text-[var(--text-muted)]" />
<div>
<p class="text-[14px] font-medium text-[var(--text-primary)]">Workstations</p>
<p class="text-[12px] text-[var(--text-muted)]">Specialized task queues for collectivos, claims, renewals, collections, and billing.</p>
</div>
</div>
<USwitch :model-value="sidebarFeatures.showWorkstations" @update:model-value="sidebarFeatures.showWorkstations = $event" />
</div>
<div class="flex items-center gap-2 pl-8 text-[11px] text-[var(--text-muted)]">
<UIcon name="i-heroicons-eye" style="width: 12px; height: 12px;" />
<span>Adds: Collectivos, Facturacion, Claims Queue, Renewals Queue, Collections Queue</span>
</div>
</label>
<label class="app-card flex flex-col gap-3 p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<UIcon name="i-heroicons-sparkles" class="h-5 w-5 text-[var(--text-muted)]" />
<div>
<p class="text-[14px] font-medium text-[var(--text-primary)]">AI Tools</p>
<p class="text-[12px] text-[var(--text-muted)]">AI-powered assistants for sales, policy comparison, email drafting, and case support.</p>
</div>
</div>
<USwitch :model-value="sidebarFeatures.showAiTools" @update:model-value="sidebarFeatures.showAiTools = $event" />
</div>
<div class="flex items-center gap-2 pl-8 text-[11px] text-[var(--text-muted)]">
<UIcon name="i-heroicons-eye" style="width: 12px; height: 12px;" />
<span>Adds: Sales Factory, Policy Advisor, Email Writer, Case Support</span>
</div>
</label>
<label class="app-card flex flex-col gap-3 p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<UIcon name="i-heroicons-signal" class="h-5 w-5 text-[var(--text-muted)]" />
<div>
<p class="text-[14px] font-medium text-[var(--text-primary)]">Leads Hub</p>
<p class="text-[12px] text-[var(--text-muted)]">Centralized lead management with API integrations for Google, Facebook, Instagram, and custom campaigns.</p>
</div>
</div>
<USwitch :model-value="sidebarFeatures.showLeadsHub" @update:model-value="sidebarFeatures.showLeadsHub = $event" />
</div>
<div class="flex items-center gap-2 pl-8 text-[11px] text-[var(--text-muted)]">
<UIcon name="i-heroicons-eye" style="width: 12px; height: 12px;" />
<span>Adds: Leads Hub under Customer Service sidebar group</span>
</div>
</label>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,178 @@
<script setup lang="ts">
import { MAX_LOGO_FILE_BYTES } from '~/types/branding'
import type { BrokerageBrandingState } from '~/types/branding'
definePageMeta({ ssr: false })
usePageTitle('Organization · Settings')
const { isSuperAdmin } = useSuperAdmin()
const toast = useToast()
const { saved, defaultBrokerageBranding } = useBrokerageBranding()
const draft = ref<BrokerageBrandingState>(defaultBrokerageBranding())
const fileInputRef = ref<HTMLInputElement | null>(null)
function syncDraftFromSaved() {
const s = saved.value
draft.value = {
companyName: s.companyName,
logoDataUrl: s.logoDataUrl,
logoFileName: s.logoFileName,
reportPageHeader: s.reportPageHeader,
reportPageFooter: s.reportPageFooter
}
}
onMounted(() => syncDraftFromSaved())
watch(saved, () => syncDraftFromSaved(), { deep: true })
function pickFile() {
fileInputRef.value?.click()
}
function onLogoFile(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
if (!file.type.startsWith('image/')) {
toast.add({ title: 'Choose an image file', color: 'error' })
return
}
if (file.size > MAX_LOGO_FILE_BYTES) {
toast.add({
title: 'Image too large',
description: `Use a file under ${Math.round(MAX_LOGO_FILE_BYTES / 1024)} KB.`,
color: 'error'
})
return
}
const reader = new FileReader()
reader.onload = () => {
draft.value.logoDataUrl = reader.result as string
draft.value.logoFileName = file.name
}
reader.onerror = () => {
toast.add({ title: 'Could not read file', color: 'error' })
}
reader.readAsDataURL(file)
}
function clearLogo() {
draft.value.logoDataUrl = null
draft.value.logoFileName = ''
}
function save() {
saved.value = { ...draft.value }
toast.add({
title: 'Organization saved',
description: 'Sidebar and home use this brokerage identity for all users.',
color: 'success'
})
}
function cancel() {
syncDraftFromSaved()
toast.add({ title: 'Changes discarded', color: 'neutral' })
}
</script>
<template>
<div class="mx-auto max-w-3xl space-y-8">
<div class="flex flex-wrap items-center gap-x-3 gap-y-2 text-sm">
<AppBackToHome />
<span class="text-[var(--text-muted)] opacity-50" aria-hidden="true">|</span>
<NuxtLink to="/settings" class="font-medium text-[var(--text-muted)] transition hover:text-[var(--text-primary)]">
All settings
</NuxtLink>
</div>
<UAlert
v-if="!isSuperAdmin"
color="warning"
variant="soft"
title="Tenant administrators only"
description="Company name, logo, and report headers are managed here for the whole organization. Ask a superadmin to update them or to grant you access."
/>
<template v-else>
<div>
<h1 class="mt-1 text-2xl font-semibold tracking-tight text-[var(--text-primary)]">Organization</h1>
<p class="mt-2 max-w-2xl text-[14px] leading-relaxed text-[var(--text-muted)]">
Brokerage legal name and logo in the app chrome; report header and footer for PDFs and exports. This applies to
the whole tenant not personal account settings.
</p>
</div>
<UCard class="overflow-hidden border-[var(--sidebar-border)] bg-[var(--surface)] ring-1 ring-black/5">
<div class="border-b border-[var(--sidebar-border)] bg-[var(--page-bg)] px-5 py-3 text-sm text-[var(--text-primary)]">
General / General
</div>
<div class="space-y-6 p-5">
<UFormField label="Company legal name" class="w-full">
<UInput
v-model="draft.companyName"
placeholder="Brokerage legal name as registered"
class="w-full"
/>
</UFormField>
<div class="flex flex-col gap-4 sm:flex-row sm:items-start">
<div class="min-w-0 flex-1 space-y-2">
<p class="text-sm font-medium text-[var(--text-primary)]">Company logo (sidebar & home)</p>
<input ref="fileInputRef" type="file" accept="image/*" class="sr-only" @change="onLogoFile" />
<div class="flex flex-wrap items-center gap-2">
<UButton color="primary" size="sm" @click="pickFile">Browse</UButton>
<UButton v-if="draft.logoDataUrl" color="neutral" variant="ghost" size="sm" @click="clearLogo">
Remove logo
</UButton>
</div>
<p v-if="draft.logoFileName" class="text-xs text-[var(--text-muted)]">Current file: {{ draft.logoFileName }}</p>
<p class="text-xs text-[var(--text-muted)]">Transparent PNGs work best with the sidebar chrome treatment.</p>
</div>
<div
class="app-logo-chrome flex h-28 min-w-[200px] shrink-0 items-center justify-center rounded-lg border border-dashed border-[var(--sidebar-border)] px-4 py-3"
>
<img
v-if="draft.logoDataUrl"
:src="draft.logoDataUrl"
:alt="draft.companyName || 'Brokerage logo'"
class="max-h-24 max-w-full object-contain"
/>
<span v-else class="text-center text-xs text-[var(--text-muted)]">Logo preview</span>
</div>
</div>
</div>
</UCard>
<UCard class="overflow-hidden border-[var(--sidebar-border)] bg-[var(--surface)] ring-1 ring-black/5">
<div class="border-b border-[var(--sidebar-border)] bg-[var(--page-bg)] px-5 py-3 text-sm text-[var(--text-primary)]">
Reports / Reportes
</div>
<div class="space-y-4 p-5">
<UFormField label="Page header (PDFs)" class="w-full">
<UTextarea
v-model="draft.reportPageHeader"
:rows="4"
placeholder="Address, phone, RUC — top of generated reports."
class="w-full font-mono text-sm"
/>
</UFormField>
<UFormField label="Page footer (PDFs)" class="w-full">
<UTextarea
v-model="draft.reportPageFooter"
:rows="4"
placeholder="Legal disclaimer, validity, contact — footer for PDFs."
class="w-full font-mono text-sm"
/>
</UFormField>
</div>
</UCard>
<div class="flex flex-wrap gap-3">
<UButton color="neutral" variant="soft" @click="cancel">Cancel</UButton>
<UButton color="primary" icon="i-heroicons-check" @click="save">Save</UButton>
</div>
</template>
</div>
</template>

View File

@@ -0,0 +1,233 @@
<script setup lang="ts">
import { refDebounced } from '~/utils/refDebounced'
import { ROLES_SEGUROS_SEED, SEGUROS_PERMISSION_COLUMNS } from '~/data/roles-seguros'
import type { RoleRow } from '~/types/roles'
usePageTitle('Permissions · Settings')
const pageSize = ref(10)
const page = ref(1)
const search = ref('')
const debouncedSearch = refDebounced(search, 250)
const pageSizeItems = [
{ label: '10', value: 10 },
{ label: '25', value: 25 },
{ label: '50', value: 50 }
]
const rows = ref<RoleRow[]>([...ROLES_SEGUROS_SEED])
const filtered = computed(() => {
const q = debouncedSearch.value.trim().toLowerCase()
if (!q) return rows.value
return rows.value.filter(
(r) => String(r.id).includes(q) || r.description.toLowerCase().includes(q)
)
})
watch([debouncedSearch, pageSize], () => {
page.value = 1
})
const total = computed(() => filtered.value.length)
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
watch(pageCount, (c) => {
if (page.value > c) page.value = c
})
const pageRows = computed(() => {
const start = (page.value - 1) * pageSize.value
return filtered.value.slice(start, start + pageSize.value)
})
const rangeLabel = computed(() => {
if (total.value === 0) return 'Sin registros'
const start = (page.value - 1) * pageSize.value + 1
const end = Math.min(page.value * pageSize.value, total.value)
return `Mostrando ${start} a ${end} de ${total.value} registros`
})
function goPrev() {
page.value = Math.max(1, page.value - 1)
}
function goNext() {
page.value = Math.min(pageCount.value, page.value + 1)
}
function exportCsv() {
const headers = [
'ID',
'Description',
'Status',
...SEGUROS_PERMISSION_COLUMNS.map((c) => `SEGUROS_${c.key}`)
]
const lines = [headers.join(',')]
for (const r of filtered.value) {
const cells = [
r.id,
`"${r.description.replace(/"/g, '""')}"`,
r.active ? 'Active' : 'Inactive',
...SEGUROS_PERMISSION_COLUMNS.map((c) => (r.seguros[c.key] ? '1' : '0'))
]
lines.push(cells.join(','))
}
const blob = new Blob([lines.join('\n')], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `roles-seguros-${new Date().toISOString().slice(0, 10)}.csv`
a.click()
URL.revokeObjectURL(url)
}
</script>
<template>
<div class="min-h-screen bg-gray-50 p-8">
<div class="mx-auto max-w-[90rem] space-y-6">
<div class="flex flex-wrap items-center gap-x-3 gap-y-2 text-sm">
<AppBackToHome />
<span class="text-[var(--text-muted)] opacity-50" aria-hidden="true">|</span>
<NuxtLink to="/settings" class="font-medium text-[var(--text-muted)] transition hover:text-[var(--text-primary)]">
All settings
</NuxtLink>
</div>
<div>
<h1 class="text-2xl font-semibold tracking-tight text-[var(--text-primary)]">Roles &amp; permissions</h1>
<p class="mt-2 max-w-3xl leading-relaxed text-gray-600">
Reference layout for the <strong class="font-medium text-[var(--text-primary)]">SEGUROS</strong> group: each role can
grant seven feature columns. Data is static until your API is connected.
</p>
</div>
<div class="flex flex-wrap items-center justify-between gap-4">
<div class="flex flex-wrap items-center gap-3">
<span class="text-sm text-[var(--text-muted)]">Mostrar</span>
<USelect v-model="pageSize" :items="pageSizeItems" class="w-24" size="sm" />
<span class="text-sm text-[var(--text-muted)]">registros</span>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-[var(--text-muted)]">Buscar:</span>
<UInput
v-model="search"
icon="i-heroicons-magnifying-glass"
placeholder="ID o descripción"
class="w-64 max-w-full"
size="sm"
/>
</div>
</div>
<UCard :ui="{ body: { padding: 'p-0 sm:p-0' } }">
<div class="overflow-x-auto">
<table class="min-w-full border-collapse text-sm">
<thead>
<tr
class="border-b border-[var(--card-border)] bg-[var(--surface)]/90 text-left text-xs font-semibold uppercase tracking-wide text-[var(--text-muted)]"
>
<th class="whitespace-nowrap px-4 py-3">ID</th>
<th class="whitespace-nowrap px-4 py-3">Descripción</th>
<th class="whitespace-nowrap px-4 py-3">Estado</th>
<th
class="border-l border-[var(--card-border)] bg-[var(--badge-muted-bg)]/80 px-2 py-2 text-center"
:colspan="SEGUROS_PERMISSION_COLUMNS.length"
>
SEGUROS
</th>
</tr>
<tr class="border-b border-[var(--card-border)] bg-[var(--surface)] text-[var(--text-muted)]">
<th class="px-4 py-0" colspan="3" />
<th
v-for="col in SEGUROS_PERMISSION_COLUMNS"
:key="col.key"
class="border-l border-[var(--divider)] px-1 py-2 first:border-l-slate-200"
>
<div class="flex justify-center" :title="col.label">
<UIcon :name="col.icon" class="h-5 w-5" />
</div>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in pageRows"
:key="row.id"
class="border-b border-[var(--divider)] transition-colors hover:bg-[var(--surface)]/80"
>
<td class="whitespace-nowrap px-4 py-3 font-mono text-[var(--text-primary)]">{{ row.id }}</td>
<td class="max-w-xs px-4 py-3 font-medium text-[var(--text-primary)]">{{ row.description }}</td>
<td class="whitespace-nowrap px-4 py-3">
<UBadge v-if="row.active" color="success" variant="subtle" class="inline-flex items-center gap-1">
<UIcon name="i-heroicons-check" class="h-3.5 w-3.5" />
Activo
</UBadge>
<UBadge v-else color="neutral" variant="subtle">Inactivo</UBadge>
</td>
<td
v-for="col in SEGUROS_PERMISSION_COLUMNS"
:key="`${row.id}-${col.key}`"
class="border-l border-[var(--divider)] px-2 py-3 text-center first:border-l-slate-200"
>
<span
v-if="row.seguros[col.key]"
class="inline-flex h-7 w-7 items-center justify-center rounded-md bg-[var(--badge-muted-bg)] text-base font-semibold text-[var(--text-primary)]"
:title="`${col.label} — granted`"
aria-label="Permission granted"
>
×
</span>
</td>
</tr>
</tbody>
</table>
</div>
<div
class="flex flex-wrap items-center justify-between gap-3 border-t border-[var(--card-border)] px-4 py-3"
>
<p class="text-sm text-[var(--text-muted)]">{{ rangeLabel }}</p>
<div class="flex items-center gap-1">
<UButton
icon="i-heroicons-chevron-left"
color="neutral"
variant="ghost"
size="sm"
:disabled="page <= 1"
aria-label="Previous page"
@click="goPrev"
/>
<span
class="inline-flex min-w-[2.25rem] items-center justify-center rounded-md bg-primary-500 px-2 py-1 text-sm font-medium text-white"
>
{{ page }}
</span>
<UButton
icon="i-heroicons-chevron-right"
color="neutral"
variant="ghost"
size="sm"
:disabled="page >= pageCount"
aria-label="Next page"
@click="goNext"
/>
</div>
</div>
</UCard>
<div class="flex flex-wrap items-center gap-2">
<UButton
icon="i-heroicons-arrow-up-tray"
color="neutral"
variant="outline"
size="sm"
@click="exportCsv"
>
Export
</UButton>
<span class="text-xs text-[var(--text-muted)]">Downloads CSV for filtered rows (browser only).</span>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,7 @@
<script setup lang="ts">
await navigateTo('/settings/organization', { replace: true })
</script>
<template>
<div class="p-8 text-sm text-[var(--text-muted)]">Redirecting</div>
</template>

View File

@@ -0,0 +1,391 @@
<script setup lang="ts">
import { useProfileLayouts } from '~/composables/useProfileLayouts'
import type { ProfileLayout } from '~/composables/useProfileLayouts'
definePageMeta({ ssr: false })
usePageTitle('Profile Layouts · Settings')
const {
layouts,
activeLayoutId,
activeLayout,
sortedSections,
setActiveLayout,
updateLayout,
removeCustomLayout,
resetToDefaults,
} = useProfileLayouts()
/* ── Built-in vs custom ── */
const builtInLayouts = computed(() => layouts.value.filter(l => !l.isCustom))
const customLayouts = computed(() => layouts.value.filter(l => l.isCustom))
/* ── Toggle section visibility ── */
function toggleSectionVisibility(sectionId: string) {
const updated = activeLayout.value.sections.map(s =>
s.id === sectionId ? { ...s, visible: !s.visible } : s
)
updateLayout(activeLayout.value.id, { sections: updated })
}
/* ── Move section up/down ── */
function moveSection(sectionId: string, direction: 'up' | 'down') {
const sections = [...activeLayout.value.sections].sort((a, b) => a.order - b.order)
const idx = sections.findIndex(s => s.id === sectionId)
if (idx < 0) return
const swapIdx = direction === 'up' ? idx - 1 : idx + 1
if (swapIdx < 0 || swapIdx >= sections.length) return
const tempOrder = sections[idx].order
sections[idx] = { ...sections[idx], order: sections[swapIdx].order }
sections[swapIdx] = { ...sections[swapIdx], order: tempOrder }
updateLayout(activeLayout.value.id, { sections })
}
/* ── Ordered sections for display ── */
const orderedSections = computed(() =>
[...activeLayout.value.sections].sort((a, b) => a.order - b.order)
)
/* ── Delete custom layout ── */
function handleDelete(id: string) {
removeCustomLayout(id)
}
</script>
<template>
<div class="pl-page">
<!-- Back -->
<NuxtLink to="/settings" class="inline-flex">
<UButton color="neutral" variant="ghost" size="sm" icon="i-heroicons-arrow-left">Settings</UButton>
</NuxtLink>
<!-- Header -->
<div class="max-w-xl">
<h1 class="mt-1 text-2xl font-semibold tracking-tight text-[var(--text-primary)]">Profile Layouts</h1>
<p class="mt-2 text-[14px] leading-relaxed text-[var(--text-muted)]">
Configure which sections appear and in what order based on your role.
</p>
</div>
<!-- Dev note -->
<div class="pl-dev-note">
<UIcon name="i-heroicons-beaker" style="width: 16px; height: 16px; flex-shrink: 0;" />
<div>
<p class="pl-dev-note-title">In development</p>
<p class="pl-dev-note-text">
Layout preferences are stored locally. Once the API is ready, layouts will sync across devices and can be assigned organization-wide by role.
</p>
</div>
</div>
<!-- Active Layout Selector -->
<section>
<p class="pl-label">Active layout</p>
<div class="pl-layout-grid">
<button
v-for="layout in builtInLayouts"
:key="layout.id"
type="button"
class="pl-layout-card"
:class="activeLayoutId === layout.id ? 'pl-layout-active' : 'pl-layout-inactive'"
@click="setActiveLayout(layout.id)"
>
<div class="pl-layout-card-inner">
<div class="pl-layout-icon" :class="activeLayoutId === layout.id ? 'pl-icon-active' : 'pl-icon-inactive'">
<UIcon :name="layout.icon" style="width: 20px; height: 20px;" />
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<p class="pl-layout-name">{{ layout.name }}</p>
<UIcon
v-if="activeLayoutId === layout.id"
name="i-heroicons-check-circle-solid"
class="pl-check-icon"
/>
</div>
<p class="pl-layout-desc">{{ layout.description }}</p>
</div>
</div>
</button>
</div>
</section>
<!-- Section Order -->
<section>
<p class="pl-label">Section order &middot; {{ activeLayout.name }}</p>
<p class="pl-sublabel">Reorder sections and toggle visibility for the active layout.</p>
<div class="pl-section-list">
<div
v-for="(section, idx) in orderedSections"
:key="section.id"
class="pl-section-row"
:class="{ 'pl-section-hidden': !section.visible }"
>
<div class="pl-drag-handle" title="Drag to reorder (coming soon)">
<UIcon name="i-heroicons-bars-3" style="width: 16px; height: 16px;" />
</div>
<span class="pl-section-order">{{ idx + 1 }}</span>
<span class="pl-section-label" :class="{ 'pl-section-label-hidden': !section.visible }">
{{ section.label }}
</span>
<div class="pl-section-spacer" />
<div class="pl-section-arrows">
<button
type="button"
class="pl-arrow-btn"
:disabled="idx === 0"
title="Move up"
@click="moveSection(section.id, 'up')"
>
<UIcon name="i-heroicons-chevron-up" style="width: 14px; height: 14px;" />
</button>
<button
type="button"
class="pl-arrow-btn"
:disabled="idx === orderedSections.length - 1"
title="Move down"
@click="moveSection(section.id, 'down')"
>
<UIcon name="i-heroicons-chevron-down" style="width: 14px; height: 14px;" />
</button>
</div>
<button
type="button"
class="pl-toggle"
:class="section.visible ? 'pl-toggle-on' : 'pl-toggle-off'"
:title="section.visible ? 'Hide section' : 'Show section'"
@click="toggleSectionVisibility(section.id)"
>
<span class="pl-toggle-dot" :class="section.visible ? 'pl-dot-on' : 'pl-dot-off'" />
</button>
</div>
</div>
<p class="pl-hint">Default tab: <strong>{{ activeLayout.defaultTab }}</strong></p>
</section>
<!-- Custom Layouts -->
<section>
<p class="pl-label">Custom layouts</p>
<p class="pl-sublabel">Create personalized layouts beyond the built-in role presets.</p>
<div v-if="customLayouts.length" class="pl-section-list">
<div
v-for="layout in customLayouts"
:key="layout.id"
class="pl-custom-row"
>
<div class="pl-layout-icon pl-icon-inactive" style="width: 32px; height: 32px;">
<UIcon :name="layout.icon" style="width: 16px; height: 16px;" />
</div>
<div class="min-w-0 flex-1">
<p class="text-[13px] font-semibold text-[var(--text-primary)]">{{ layout.name }}</p>
<p class="text-[11px] text-[var(--text-muted)]">{{ layout.description }}</p>
</div>
<button
type="button"
class="pl-action-btn"
title="Use this layout"
@click="setActiveLayout(layout.id)"
>
<UIcon name="i-heroicons-check" style="width: 14px; height: 14px;" />
</button>
<button
type="button"
class="pl-action-btn pl-action-delete"
title="Delete custom layout"
@click="handleDelete(layout.id)"
>
<UIcon name="i-heroicons-trash" style="width: 14px; height: 14px;" />
</button>
</div>
</div>
<div v-else class="pl-empty">
<p>No custom layouts yet.</p>
</div>
<button type="button" class="pl-btn-primary" style="margin-top: 12px;" disabled>
<UIcon name="i-heroicons-plus" style="width: 16px; height: 16px;" />
Create Custom Layout
</button>
<p class="pl-hint" style="margin-top: 6px;">Custom layout creation coming soon.</p>
</section>
<!-- Reset -->
<section class="pl-reset-section">
<button type="button" class="pl-btn-ghost" @click="resetToDefaults">
<UIcon name="i-heroicons-arrow-uturn-left" style="width: 14px; height: 14px;" />
Reset all layouts to defaults
</button>
</section>
</div>
</template>
<style scoped>
/* ── Page shell ── */
.pl-page {
max-width: 56rem;
margin: 0 auto;
display: flex; flex-direction: column; gap: 28px;
}
/* ── Dev note ── */
.pl-dev-note {
display: flex; gap: 10px; align-items: flex-start;
padding: 12px 16px; border-radius: 10px;
background: rgba(124, 58, 237, 0.06); color: #7c3aed;
}
.pl-dev-note-title { font-size: 12px; font-weight: 700; }
.pl-dev-note-text { font-size: 12px; line-height: 1.5; opacity: 0.85; }
/* ── Labels ── */
.pl-label {
font-size: 11px; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.04em; color: #8a8a86; margin-bottom: 10px;
}
.pl-sublabel {
font-size: 13px; color: var(--text-muted); margin-top: -4px; margin-bottom: 12px;
}
.pl-hint {
font-size: 12px; color: var(--text-muted); margin-top: 8px;
}
/* ── Layout selector grid ── */
.pl-layout-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px;
}
.pl-layout-card {
border-radius: 12px; padding: 14px; cursor: pointer;
border: 1.5px solid transparent; background: #fff;
box-shadow: 0 1px 3px rgba(0,0,0,0.03);
transition: all 150ms ease; text-align: left;
}
.pl-layout-inactive {
border-color: rgba(0,0,0,0.06);
}
.pl-layout-inactive:hover {
border-color: rgba(0,0,0,0.12);
box-shadow: 0 2px 6px rgba(0,0,0,0.06);
}
.pl-layout-active {
border-color: #01696f;
box-shadow: 0 0 0 1px #01696f, 0 2px 8px rgba(1,105,111,0.10);
}
.pl-layout-card-inner {
display: flex; align-items: flex-start; gap: 10px;
}
.pl-layout-icon {
width: 40px; height: 40px; border-radius: 10px;
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.pl-icon-active { background: rgba(1,105,111,0.10); color: #01696f; }
.pl-icon-inactive { background: rgba(0,0,0,0.04); color: #8a8a86; }
.pl-layout-name { font-size: 13px; font-weight: 600; color: var(--text-primary); }
.pl-layout-desc { font-size: 11px; color: var(--text-muted); margin-top: 2px; line-height: 1.4; }
.pl-check-icon { width: 16px; height: 16px; color: #01696f; flex-shrink: 0; }
/* ── Section list ── */
.pl-section-list {
display: flex; flex-direction: column; gap: 6px;
}
.pl-section-row {
display: flex; align-items: center; gap: 10px;
padding: 10px 14px; border-radius: 10px;
border: 1px solid rgba(0,0,0,0.06); background: #fff;
box-shadow: 0 1px 3px rgba(0,0,0,0.03);
transition: background 150ms ease;
}
.pl-section-row:hover { background: rgba(0,0,0,0.01); }
.pl-section-hidden { opacity: 0.5; }
.pl-drag-handle {
color: #c4c4c0; cursor: grab; display: flex; align-items: center;
}
.pl-section-order {
font-size: 11px; font-weight: 700; color: #8a8a86;
width: 20px; text-align: center; font-variant-numeric: tabular-nums;
}
.pl-section-label { font-size: 13px; font-weight: 500; color: var(--text-primary); }
.pl-section-label-hidden { text-decoration: line-through; color: var(--text-muted); }
.pl-section-spacer { flex: 1; }
/* ── Arrows ── */
.pl-section-arrows { display: flex; flex-direction: column; gap: 1px; }
.pl-arrow-btn {
display: inline-flex; align-items: center; justify-content: center;
width: 22px; height: 18px; border-radius: 4px; border: none; cursor: pointer;
background: transparent; color: #8a8a86; transition: all 120ms ease;
}
.pl-arrow-btn:hover:not(:disabled) { background: rgba(0,0,0,0.06); color: var(--text-primary); }
.pl-arrow-btn:disabled { opacity: 0.25; cursor: not-allowed; }
/* ── Toggle ── */
.pl-toggle {
width: 36px; height: 20px; border-radius: 10px; border: none;
cursor: pointer; position: relative; transition: background 150ms ease; flex-shrink: 0;
}
.pl-toggle-on { background: #01696f; }
.pl-toggle-off { background: rgba(0,0,0,0.12); }
.pl-toggle-dot {
display: block; width: 16px; height: 16px; border-radius: 8px;
background: #fff; position: absolute; top: 2px; transition: left 150ms ease;
}
.pl-dot-on { left: 18px; }
.pl-dot-off { left: 2px; }
/* ── Custom layout rows ── */
.pl-custom-row {
display: flex; align-items: center; gap: 10px;
padding: 10px 14px; border-radius: 10px;
border: 1px solid rgba(0,0,0,0.06); background: #fff;
box-shadow: 0 1px 3px rgba(0,0,0,0.03);
}
.pl-action-btn {
display: inline-flex; align-items: center; justify-content: center;
width: 28px; height: 28px; border-radius: 6px; border: none; cursor: pointer;
background: rgba(0,0,0,0.03); color: #8a8a86; transition: all 150ms ease;
}
.pl-action-btn:hover { background: rgba(0,0,0,0.06); color: var(--text-primary); }
.pl-action-delete:hover { background: rgba(193,56,56,0.08); color: #c13838; }
/* ── Empty state ── */
.pl-empty {
padding: 20px; border-radius: 10px; text-align: center;
border: 1px dashed rgba(0,0,0,0.10); color: var(--text-muted);
font-size: 13px;
}
/* ── Buttons ── */
.pl-btn-primary {
display: inline-flex; align-items: center; gap: 6px;
padding: 8px 16px; border-radius: 8px;
background: #01696f; color: #fff;
font-size: 13px; font-weight: 500; border: none;
cursor: pointer; transition: all 150ms ease; white-space: nowrap;
}
.pl-btn-primary:hover:not(:disabled) { background: #015458; }
.pl-btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.pl-btn-ghost {
display: inline-flex; align-items: center; gap: 6px;
padding: 6px 12px; border-radius: 6px;
background: transparent; color: var(--text-muted);
font-size: 12px; font-weight: 500; border: 1px solid rgba(0,0,0,0.08);
cursor: pointer; transition: all 150ms ease;
}
.pl-btn-ghost:hover { background: rgba(0,0,0,0.03); color: var(--text-primary); }
/* ── Reset section ── */
.pl-reset-section {
padding-top: 8px;
border-top: 1px solid rgba(0,0,0,0.06);
}
</style>

View File

@@ -0,0 +1,73 @@
<script setup lang="ts">
usePageTitle('Providers · Settings')
const bullets = [
'Create and maintain carrier legal identity, contacts, and RUC for reporting.',
'Upload solicitation PDF templates per policy type — fields are discovered after upload.',
'Activate or pause a carrier when you pause quoting or renew credential rotation.'
] as const
</script>
<template>
<div class="mx-auto max-w-4xl space-y-8">
<div>
<h1 class="mt-1 text-2xl font-semibold tracking-tight text-[var(--text-primary)]">
Providers &amp; carriers
</h1>
<p class="mt-2 max-w-2xl text-[14px] leading-relaxed text-[var(--text-muted)]">
Operational directory for insurance carriers. This is the same provider list used across quotes,
onboarding, and policy book configure templates and status per carrier from detail pages.
</p>
</div>
<UAlert
color="primary"
variant="soft"
icon="i-heroicons-information-circle"
title="Brokerage-wide carrier setup"
description="Provider records are shared across the workspace. Changes here affect new business, renewals, and form routing."
/>
<div class="grid gap-4 sm:grid-cols-2">
<NuxtLink
to="/providers"
class="group rounded-xl border border-[var(--card-border)] bg-[var(--surface)] p-5 shadow-sm ring-1 ring-[var(--card-border)]/40 transition hover:border-[var(--brand)]/30 hover:shadow-md"
>
<div class="flex items-start gap-3">
<div class="inline-flex rounded-lg bg-[var(--brand-faint)] p-2 text-[var(--brand)] ring-1 ring-[var(--brand)]/10">
<UIcon name="i-heroicons-building-office-2" class="h-6 w-6" />
</div>
<div>
<p class="font-semibold text-[var(--text-primary)] group-hover:text-[var(--brand)]">Browse providers</p>
<p class="mt-1 text-sm text-[var(--text-muted)]">Search, filter, and open a carrier profile.</p>
</div>
</div>
</NuxtLink>
<NuxtLink
to="/providers/new"
class="group rounded-xl border border-[var(--card-border)] bg-[var(--surface)] p-5 shadow-sm ring-1 ring-[var(--card-border)]/40 transition hover:border-[var(--brand)]/30 hover:shadow-md"
>
<div class="flex items-start gap-3">
<div class="inline-flex rounded-lg bg-emerald-50 p-2 text-emerald-700 ring-1 ring-emerald-100/80">
<UIcon name="i-heroicons-plus-circle" class="h-6 w-6" />
</div>
<div>
<p class="font-semibold text-[var(--text-primary)] group-hover:text-[var(--brand)]">New provider</p>
<p class="mt-1 text-sm text-[var(--text-muted)]">Add a carrier before attaching templates or quotes.</p>
</div>
</div>
</NuxtLink>
</div>
<div class="rounded-xl border border-[var(--card-border)] bg-[var(--surface)] p-6 shadow-sm ring-1 ring-[var(--card-border)]/40">
<h2 class="text-sm font-semibold text-[var(--text-primary)]">What you configure per carrier</h2>
<ul class="mt-4 list-inside list-disc space-y-2 text-sm text-[var(--text-muted)]">
<li v-for="(b, i) in bullets" :key="i">{{ b }}</li>
</ul>
<p class="mt-4 text-sm text-[var(--text-muted)]">
Open any provider from the list to manage templates, credentials, and activity the detail view is
the operational control surface.
</p>
</div>
</div>
</template>

View File

@@ -0,0 +1,42 @@
<script setup lang="ts">
usePageTitle('Quote requests · Settings')
const { quoteRequestEmailEnabled, setQuoteRequestEmailEnabled } = useQuoteRequestEmailEnabled()
</script>
<template>
<div class="mx-auto max-w-3xl space-y-8">
<div class="flex flex-wrap items-center gap-x-3 gap-y-2 text-sm">
<NuxtLink
to="/settings"
class="font-medium text-[var(--text-muted)] transition hover:text-[var(--text-primary)]"
>
All settings
</NuxtLink>
</div>
<div>
<h1 class="mt-1 text-2xl font-semibold tracking-tight text-[var(--text-primary)]">Quote requests</h1>
<p class="mt-2 max-w-2xl text-[14px] leading-relaxed text-[var(--text-muted)]">
Control whether the app <strong>describes</strong> outbound emails to carrier quoting addresses (Settings
Providers). Turn off when your tenant already gets rates from published tables, direct APIs, or agentic / AI
quoting so users arent prompted to email carriers unnecessarily.
</p>
</div>
<UCard class="border-[var(--sidebar-border)] bg-[var(--surface)] ring-1 ring-black/5">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<p class="font-semibold text-[var(--text-primary)]">Provider quote emails</p>
<p class="mt-1 text-sm text-[var(--text-muted)]">
When enabled, auto and health quote flows explain that requests can be emailed to providers. When disabled,
runs are saved in-app for manual pricing, rate tables, or future automation without implying email dispatch.
</p>
</div>
<UToggle :model-value="quoteRequestEmailEnabled" @update:model-value="setQuoteRequestEmailEnabled" />
</div>
</UCard>
<UAlert color="neutral" variant="soft" title="Note" description="This is a UI and workflow toggle for the mock app. Production would enforce the same rule with server-side job dispatch and audit logging." />
</div>
</template>

View File

@@ -0,0 +1,431 @@
<script setup lang="ts">
import { useReferralChannels, type ReferralChannel } from '~/composables/useReferralChannels'
usePageTitle('Referral Channels · Settings')
const { channels, addChannel, updateChannel, removeChannel } = useReferralChannels()
const toast = useToast()
/* ── Form state ── */
const formOpen = ref(false)
const editingId = ref<string | null>(null)
const fname = ref('')
const ftype = ref<ReferralChannel['type']>('person')
const fcontactName = ref('')
const fcontactPhone = ref('')
const fcontactEmail = ref('')
const fnote = ref('')
const factive = ref(true)
const typeOptions = [
{ label: 'Person', value: 'person' as const },
{ label: 'Company', value: 'company' as const },
{ label: 'Digital / Online', value: 'digital' as const },
{ label: 'Event', value: 'event' as const },
{ label: 'Other', value: 'other' as const },
]
function resetForm() {
fname.value = ''
ftype.value = 'person'
fcontactName.value = ''
fcontactPhone.value = ''
fcontactEmail.value = ''
fnote.value = ''
factive.value = true
editingId.value = null
formOpen.value = false
}
function editChannel(ch: ReferralChannel) {
editingId.value = ch.id
fname.value = ch.name
ftype.value = ch.type
fcontactName.value = ch.contactName
fcontactPhone.value = ch.contactPhone
fcontactEmail.value = ch.contactEmail
fnote.value = ch.note
factive.value = ch.active
formOpen.value = true
}
function submit() {
if (!fname.value.trim()) return
if (editingId.value) {
updateChannel(editingId.value, {
name: fname.value.trim(),
type: ftype.value,
contactName: fcontactName.value.trim(),
contactPhone: fcontactPhone.value.trim(),
contactEmail: fcontactEmail.value.trim(),
note: fnote.value.trim(),
active: factive.value,
})
toast.add({ title: 'Channel updated', color: 'success' })
} else {
addChannel({
name: fname.value.trim(),
type: ftype.value,
contactName: fcontactName.value.trim(),
contactPhone: fcontactPhone.value.trim(),
contactEmail: fcontactEmail.value.trim(),
note: fnote.value.trim(),
active: factive.value,
})
toast.add({ title: 'Channel added', description: `${fname.value} added to referral channels`, color: 'success' })
}
resetForm()
}
function confirmRemove(id: string) {
removeChannel(id)
toast.add({ title: 'Channel removed', color: 'neutral' })
}
function toggleActive(id: string) {
const ch = channels.value.find(c => c.id === id)
if (ch) updateChannel(id, { active: !ch.active })
}
/* ── Filter ── */
type ListFilter = 'all' | 'active' | 'inactive'
const activeFilter = ref<ListFilter>('all')
const filteredChannels = computed(() => {
if (activeFilter.value === 'active') return channels.value.filter(c => c.active)
if (activeFilter.value === 'inactive') return channels.value.filter(c => !c.active)
return channels.value
})
const filterCounts = computed(() => ({
all: channels.value.length,
active: channels.value.filter(c => c.active).length,
inactive: channels.value.filter(c => !c.active).length,
}))
/* ── Helpers ── */
const typeMeta: Record<string, { label: string; icon: string; class: string }> = {
person: { label: 'Person', icon: 'i-heroicons-user', class: 'rc-type-person' },
company: { label: 'Company', icon: 'i-heroicons-building-office', class: 'rc-type-company' },
digital: { label: 'Digital', icon: 'i-heroicons-globe-alt', class: 'rc-type-digital' },
event: { label: 'Event', icon: 'i-heroicons-calendar-days', class: 'rc-type-event' },
other: { label: 'Other', icon: 'i-heroicons-tag', class: 'rc-type-other' },
}
</script>
<template>
<div class="rc-page">
<!-- Back -->
<NuxtLink to="/settings" class="inline-flex">
<UButton color="neutral" variant="ghost" size="sm" icon="i-heroicons-arrow-left">Settings</UButton>
</NuxtLink>
<!-- Header -->
<div class="flex flex-wrap items-end justify-between gap-3">
<div class="max-w-xl">
<h1 class="mt-1 text-2xl font-semibold tracking-tight text-[var(--text-primary)]">Referral Channels</h1>
<p class="mt-2 text-[14px] leading-relaxed text-[var(--text-muted)]">
Manage the sources that generate leads and new business people, companies, digital campaigns, events, and more.
</p>
</div>
<button type="button" class="rc-btn-primary" @click="formOpen = !formOpen; if (!formOpen) resetForm()">
<UIcon :name="formOpen ? 'i-heroicons-chevron-up' : 'i-heroicons-plus'" style="width: 14px; height: 14px;" />
{{ formOpen ? 'Close form' : 'Add channel' }}
</button>
</div>
<!-- Add / Edit form -->
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 -translate-y-2 max-h-0"
enter-to-class="opacity-100 translate-y-0 max-h-[700px]"
leave-active-class="transition-all duration-150 ease-in"
leave-from-class="opacity-100 max-h-[700px]"
leave-to-class="opacity-0 -translate-y-2 max-h-0"
>
<div v-if="formOpen" class="rc-form-card">
<div class="rc-section">
<p class="rc-section-title">{{ editingId ? 'Edit channel' : 'New referral channel' }}</p>
<div class="rc-fields">
<div class="rc-field rc-field-full">
<label class="rc-label">Channel name <span class="rc-required">*</span></label>
<UInput v-model="fname" placeholder="e.g. Roberto Jiménez, Instagram Ads, Expo 2026" size="sm" />
</div>
<div class="rc-field">
<label class="rc-label">Type</label>
<USelect v-model="ftype" :items="typeOptions" size="sm" />
</div>
<div class="rc-field">
<label class="rc-label">Status</label>
<div class="flex items-center gap-2 mt-1">
<button type="button" class="rc-toggle" :class="factive ? 'rc-toggle-on' : 'rc-toggle-off'" @click="factive = !factive">
<span class="rc-toggle-dot" :class="factive ? 'rc-dot-on' : 'rc-dot-off'" />
</button>
<span class="text-[12px]" :class="factive ? 'text-[#01696f] font-medium' : 'text-[#8a8a86]'">{{ factive ? 'Active' : 'Inactive' }}</span>
</div>
</div>
</div>
</div>
<div class="rc-divider" />
<div class="rc-section">
<p class="rc-section-title">Contact info</p>
<div class="rc-fields">
<div class="rc-field">
<label class="rc-label">Contact name</label>
<UInput v-model="fcontactName" placeholder="Primary contact person" size="sm" />
</div>
<div class="rc-field">
<label class="rc-label">Phone</label>
<UInput v-model="fcontactPhone" placeholder="+506 0000-0000" size="sm" />
</div>
<div class="rc-field">
<label class="rc-label">Email</label>
<UInput v-model="fcontactEmail" placeholder="email@company.com" size="sm" type="email" />
</div>
<div class="rc-field rc-field-full">
<label class="rc-label">Notes</label>
<UTextarea v-model="fnote" placeholder="Context, relationship, expected lead volume..." size="sm" :rows="2" />
</div>
</div>
</div>
<div class="rc-footer">
<div class="flex items-center gap-2 text-[12px] text-[var(--text-muted)]">
<UIcon name="i-heroicons-link" style="width: 14px; height: 14px; opacity: 0.5;" />
{{ editingId ? 'Editing existing channel' : 'Creates a new referral source' }}
</div>
<div class="flex gap-2">
<button type="button" class="rc-cancel-btn" @click="resetForm">Cancel</button>
<button type="button" class="rc-btn-primary" :class="!fname.trim() ? 'rc-btn-disabled' : ''" @click="submit">
<UIcon :name="editingId ? 'i-heroicons-check' : 'i-heroicons-plus'" style="width: 14px; height: 14px;" />
{{ editingId ? 'Save changes' : 'Add Channel' }}
</button>
</div>
</div>
</div>
</Transition>
<!-- KPI strip -->
<div class="rc-kpi-strip">
<div class="rc-kpi">
<p class="rc-kpi-label">Total channels</p>
<p class="rc-kpi-value">{{ channels.length }}</p>
</div>
<div class="rc-kpi">
<p class="rc-kpi-label">Active</p>
<p class="rc-kpi-value" style="color: #01696f;">{{ filterCounts.active }}</p>
</div>
<div class="rc-kpi">
<p class="rc-kpi-label">Inactive</p>
<p class="rc-kpi-value">{{ filterCounts.inactive }}</p>
</div>
<div class="rc-kpi">
<p class="rc-kpi-label">Types</p>
<p class="rc-kpi-value">{{ new Set(channels.map(c => c.type)).size }}</p>
</div>
</div>
<!-- Filter tabs -->
<div class="flex items-center justify-between gap-3">
<div class="rc-filter-tabs">
<button
v-for="f in ([
{ id: 'all', label: 'All' },
{ id: 'active', label: 'Active' },
{ id: 'inactive', label: 'Inactive' },
] as { id: ListFilter; label: string }[])"
:key="f.id"
type="button"
class="rc-filter-tab"
:class="activeFilter === f.id ? 'rc-filter-on' : 'rc-filter-off'"
@click="activeFilter = f.id"
>
{{ f.label }}
<span class="rc-filter-count" :class="activeFilter === f.id ? 'rc-filter-count-on' : ''">{{ filterCounts[f.id] }}</span>
</button>
</div>
<span class="text-[11px] text-[var(--text-muted)]">{{ filteredChannels.length }} results</span>
</div>
<!-- ═══ Channel list ═══ -->
<div v-if="filteredChannels.length === 0" class="rc-empty">
<UIcon name="i-heroicons-link" style="width: 32px; height: 32px; color: #c0c0bc;" />
<p class="text-[13px] text-[var(--text-muted)] mt-2">No referral channels yet.</p>
<button type="button" class="rc-btn-primary mt-3" @click="formOpen = true">
<UIcon name="i-heroicons-plus" style="width: 14px; height: 14px;" />
Add your first channel
</button>
</div>
<div v-else class="rc-list">
<div v-for="ch in filteredChannels" :key="ch.id" class="rc-card group">
<div class="rc-card-top">
<div class="rc-card-icon" :class="typeMeta[ch.type]?.class">
<UIcon :name="typeMeta[ch.type]?.icon ?? 'i-heroicons-tag'" style="width: 16px; height: 16px;" />
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 flex-wrap">
<p class="text-[14px] font-semibold text-[var(--text-primary)] truncate">{{ ch.name }}</p>
<span class="rc-type-badge" :class="typeMeta[ch.type]?.class">{{ typeMeta[ch.type]?.label ?? ch.type }}</span>
<span v-if="!ch.active" class="rc-inactive-badge">Inactive</span>
</div>
<div class="flex items-center gap-3 mt-0.5 text-[11px] text-[var(--text-muted)]">
<span v-if="ch.contactName">{{ ch.contactName }}</span>
<span v-if="ch.contactPhone">{{ ch.contactPhone }}</span>
<span v-if="ch.contactEmail">{{ ch.contactEmail }}</span>
</div>
</div>
<div class="rc-card-actions">
<button type="button" class="rc-action-btn" :class="ch.active ? 'rc-action-toggle-on' : 'rc-action-toggle-off'" :title="ch.active ? 'Deactivate' : 'Activate'" @click="toggleActive(ch.id)">
<UIcon :name="ch.active ? 'i-heroicons-eye' : 'i-heroicons-eye-slash'" style="width: 14px; height: 14px;" />
</button>
<button type="button" class="rc-action-btn" title="Edit" @click="editChannel(ch)">
<UIcon name="i-heroicons-pencil-square" style="width: 14px; height: 14px;" />
</button>
<button type="button" class="rc-action-btn rc-action-delete" title="Remove" @click="confirmRemove(ch.id)">
<UIcon name="i-heroicons-trash" style="width: 14px; height: 14px;" />
</button>
</div>
</div>
<div v-if="ch.note" class="rc-card-note">
<UIcon name="i-heroicons-chat-bubble-left-ellipsis" style="width: 11px; height: 11px; color: #8a8a86; flex-shrink: 0;" />
<span>{{ ch.note }}</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.rc-page {
max-width: 48rem; margin: 0 auto;
display: flex; flex-direction: column; gap: 20px; padding-bottom: 3rem;
}
/* ── Buttons ── */
.rc-btn-primary {
display: inline-flex; align-items: center; gap: 6px;
padding: 8px 16px; border-radius: 8px;
background: #01696f; color: #fff;
font-size: 13px; font-weight: 500; border: none;
cursor: pointer; transition: all 150ms ease; white-space: nowrap;
}
.rc-btn-primary:hover { background: #015458; }
.rc-btn-disabled { opacity: 0.5; pointer-events: none; }
.rc-cancel-btn {
display: inline-flex; align-items: center; gap: 5px;
padding: 8px 14px; border-radius: 8px;
background: transparent; color: var(--text-muted);
font-size: 13px; font-weight: 500;
border: 1px solid rgba(0,0,0,0.08); cursor: pointer;
transition: all 150ms ease; white-space: nowrap;
}
.rc-cancel-btn:hover { border-color: rgba(0,0,0,0.15); color: var(--text-primary); }
/* ── Form card ── */
.rc-form-card {
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); overflow: hidden;
}
.rc-section { padding: 20px; }
.rc-section-title { font-size: 13px; font-weight: 600; color: var(--text-primary); margin-bottom: 16px; }
.rc-fields { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
@media (max-width: 639px) { .rc-fields { grid-template-columns: 1fr; } }
.rc-field-full { grid-column: 1 / -1; }
.rc-field { display: flex; flex-direction: column; gap: 6px; }
.rc-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: #8a8a86; }
.rc-required { color: #c13838; }
.rc-divider { height: 1px; background: rgba(0,0,0,0.06); margin: 0 20px; }
.rc-footer {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: 16px 20px; border-top: 1px solid rgba(0,0,0,0.06); background: rgba(0,0,0,0.015);
}
/* Toggle */
.rc-toggle {
width: 32px; height: 18px; border-radius: 9px; border: none;
cursor: pointer; position: relative; transition: background 150ms ease;
}
.rc-toggle-on { background: #01696f; }
.rc-toggle-off { background: rgba(0,0,0,0.12); }
.rc-toggle-dot {
display: block; width: 14px; height: 14px; border-radius: 7px;
background: #fff; position: absolute; top: 2px; transition: left 150ms ease;
}
.rc-dot-on { left: 16px; }
.rc-dot-off { left: 2px; }
/* ── KPI strip ── */
.rc-kpi-strip {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px;
border-radius: 12px; border: 1px solid rgba(0,0,0,0.06);
background: rgba(0,0,0,0.06); box-shadow: 0 1px 3px rgba(0,0,0,0.03); overflow: hidden;
}
.rc-kpi { padding: 14px 18px; background: #fff; }
.rc-kpi:first-child { border-radius: 12px 0 0 12px; }
.rc-kpi:last-child { border-radius: 0 12px 12px 0; }
.rc-kpi-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: #8a8a86; }
.rc-kpi-value { margin-top: 4px; font-size: 22px; font-weight: 600; color: var(--text-primary); font-variant-numeric: tabular-nums; }
@media (max-width: 640px) { .rc-kpi-strip { grid-template-columns: repeat(2, 1fr); } }
/* ── Filter tabs ── */
.rc-filter-tabs { display: inline-flex; gap: 2px; padding: 3px; border-radius: 10px; background: rgba(0,0,0,0.04); }
.rc-filter-tab {
display: inline-flex; align-items: center; gap: 5px;
padding: 6px 12px; border-radius: 8px;
font-size: 12px; font-weight: 500; border: none;
cursor: pointer; transition: all 150ms ease; white-space: nowrap;
}
.rc-filter-on { background: #fff; color: var(--text-primary); box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
.rc-filter-off { background: transparent; color: var(--text-muted); }
.rc-filter-off:hover { color: var(--text-primary); }
.rc-filter-count { font-size: 10px; font-weight: 600; padding: 1px 5px; border-radius: 9999px; background: rgba(0,0,0,0.06); color: var(--text-muted); }
.rc-filter-count-on { background: rgba(1,105,111,0.1); color: #01696f; }
/* ── Channel cards ── */
.rc-list { display: flex; flex-direction: column; gap: 6px; }
.rc-card {
display: flex; flex-direction: column; gap: 8px;
padding: 14px 16px; border-radius: 10px;
border: 1px solid rgba(0,0,0,0.06); background: #fff;
transition: all 150ms ease;
}
.rc-card:hover { border-color: rgba(1,105,111,0.15); box-shadow: 0 2px 8px rgba(0,0,0,0.04); }
.rc-card-top { display: flex; align-items: center; gap: 10px; }
.rc-card-icon {
width: 36px; height: 36px; border-radius: 10px;
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.rc-card-actions { display: flex; gap: 4px; opacity: 0; transition: opacity 150ms ease; flex-shrink: 0; }
.rc-card:hover .rc-card-actions { opacity: 1; }
.rc-action-btn {
display: inline-flex; align-items: center; justify-content: center;
width: 28px; height: 28px; border-radius: 6px; border: none; cursor: pointer;
background: rgba(0,0,0,0.03); color: #8a8a86; transition: all 150ms ease;
}
.rc-action-btn:hover { background: rgba(0,0,0,0.06); color: var(--text-primary); }
.rc-action-delete:hover { background: rgba(193,56,56,0.08); color: #c13838; }
.rc-action-toggle-on:hover { background: rgba(1,105,111,0.1); color: #01696f; }
.rc-action-toggle-off { opacity: 0.5; }
.rc-action-toggle-off:hover { background: rgba(194,123,26,0.08); color: #c27b1a; opacity: 1; }
.rc-card-note {
display: flex; align-items: flex-start; gap: 5px; padding-left: 46px;
font-size: 12px; color: var(--text-muted); line-height: 1.4;
}
/* ── Type badges ── */
.rc-type-badge { font-size: 10px; font-weight: 600; padding: 1px 7px; border-radius: 9999px; white-space: nowrap; }
.rc-type-person { background: rgba(59,130,246,0.08); color: #3b82f6; }
.rc-type-company { background: rgba(1,105,111,0.08); color: #01696f; }
.rc-type-digital { background: rgba(147,51,234,0.08); color: #9333ea; }
.rc-type-event { background: rgba(194,123,26,0.08); color: #c27b1a; }
.rc-type-other { background: rgba(0,0,0,0.04); color: #8a8a86; }
.rc-inactive-badge { font-size: 10px; font-weight: 600; padding: 1px 7px; border-radius: 9999px; background: rgba(0,0,0,0.06); color: #8a8a86; white-space: nowrap; }
/* ── Empty state ── */
.rc-empty { display: flex; flex-direction: column; align-items: center; padding: 40px 16px; text-align: center; }
</style>

View File

@@ -0,0 +1,231 @@
<script setup lang="ts">
import { TIER_LABELS, QUEUE_LABELS, type RoutingTier, type RoutedQueue, type RoutingRule } from '~/data/mock-support'
usePageTitle('Support Routing')
const { state, toggleRule, updateRule } = useSupportTickets()
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' },
]
// Tier 3 settings (mock)
const tier3DefaultAssignee = ref('Round-robin')
const tier3EscalationHours = ref(4)
const toast = useToast()
function handleSave() {
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>