WIP jordan
This commit is contained in:
1036
app/pages/settings/agents.vue
Normal file
1036
app/pages/settings/agents.vue
Normal file
File diff suppressed because it is too large
Load Diff
616
app/pages/settings/alerts.vue
Normal file
616
app/pages/settings/alerts.vue
Normal 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>
|
||||
7
app/pages/settings/appearance.vue
Normal file
7
app/pages/settings/appearance.vue
Normal 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>
|
||||
369
app/pages/settings/customer-attention.vue
Normal file
369
app/pages/settings/customer-attention.vue
Normal 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 & 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">→</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>
|
||||
255
app/pages/settings/forms/index.vue
Normal file
255
app/pages/settings/forms/index.vue
Normal 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>
|
||||
179
app/pages/settings/index.vue
Normal file
179
app/pages/settings/index.vue
Normal 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>
|
||||
178
app/pages/settings/organization.vue
Normal file
178
app/pages/settings/organization.vue
Normal 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>
|
||||
233
app/pages/settings/permissions.vue
Normal file
233
app/pages/settings/permissions.vue
Normal 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 & 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>
|
||||
7
app/pages/settings/personalization.vue
Normal file
7
app/pages/settings/personalization.vue
Normal 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>
|
||||
391
app/pages/settings/profile-layouts.vue
Normal file
391
app/pages/settings/profile-layouts.vue
Normal 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 · {{ 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>
|
||||
73
app/pages/settings/providers.vue
Normal file
73
app/pages/settings/providers.vue
Normal 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 & 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>
|
||||
42
app/pages/settings/quote-requests.vue
Normal file
42
app/pages/settings/quote-requests.vue
Normal 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 aren’t 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>
|
||||
431
app/pages/settings/referral-channels.vue
Normal file
431
app/pages/settings/referral-channels.vue
Normal 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>
|
||||
231
app/pages/settings/support-routing.vue
Normal file
231
app/pages/settings/support-routing.vue
Normal 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>
|
||||
Reference in New Issue
Block a user