Files
policy-ui/app/pages/settings/referral-channels.vue
2026-04-29 16:25:11 -05:00

480 lines
20 KiB
Vue

<script setup lang="ts">
usePageTitle('Referral Channels · Settings')
interface ReferralChannel {
id: string
name: string
type: 'person' | 'company' | 'digital' | 'event' | 'other'
contactName: string
contactPhone: string
contactEmail: string
note: string
active: boolean
}
const STORAGE_KEY = 'policy-ui.referral-channels'
function loadChannels(): ReferralChannel[] {
if (import.meta.client) {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
try {
return JSON.parse(stored)
} catch {
return []
}
}
}
return []
}
const channels = ref<ReferralChannel[]>(loadChannels())
function saveChannels() {
if (import.meta.client) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(channels.value))
}
}
function addChannel(channel: Omit<ReferralChannel, 'id'>) {
const id = String(Date.now())
channels.value.push({ ...channel, id })
saveChannels()
}
function updateChannel(id: string, updates: Partial<ReferralChannel>) {
const idx = channels.value.findIndex(c => c.id === id)
if (idx !== -1) {
channels.value[idx] = { ...channels.value[idx], ...updates }
saveChannels()
}
}
function removeChannel(id: string) {
channels.value = channels.value.filter(c => c.id !== id)
saveChannels()
}
const toast = useToast()
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 })
}
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,
}))
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>