WIP jordan

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

View File

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