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,171 @@
<script setup lang="ts">
/**
* Airline-ticket-style horizontal flow indicator for the sales process.
* Always visible at the top of any sales page, highlighting "you are here."
*/
const props = defineProps<{
/** Which stage the current page represents */
currentStage: 'quick_lead' | 'customer' | 'get_quotes' | 'present_quotes' | 'solicitud' | 'emission'
}>()
const stages = [
{ id: 'quick_lead', label: 'Quick Lead', icon: 'i-heroicons-bolt', route: '/sales/quick-lead' },
{ id: 'customer', label: 'Customer', icon: 'i-heroicons-user-plus', route: '/registration/client' },
{ id: 'get_quotes', label: 'Get Quotes', icon: 'i-heroicons-document-magnifying-glass', route: '/quotes/new' },
{ id: 'present_quotes', label: 'Present Quotes', icon: 'i-heroicons-presentation-chart-bar', route: '/quotes/compare' },
{ id: 'solicitud', label: 'Solicitud', icon: 'i-heroicons-clipboard-document-check', route: '/onboarding/solicitud' },
{ id: 'emission', label: 'Emission', icon: 'i-heroicons-check-badge', route: '/onboarding/emissions' },
] as const
type StageId = typeof stages[number]['id']
function stageIndex(id: StageId): number {
return stages.findIndex(s => s.id === id)
}
const currentIdx = computed(() => stageIndex(props.currentStage))
function state(id: StageId): 'done' | 'current' | 'upcoming' {
const idx = stageIndex(id)
if (idx < currentIdx.value) return 'done'
if (idx === currentIdx.value) return 'current'
return 'upcoming'
}
</script>
<template>
<nav class="sfi-root" aria-label="Sales process flow">
<div class="sfi-track">
<template v-for="(stage, i) in stages" :key="stage.id">
<!-- Connector -->
<div
v-if="i > 0"
class="sfi-connector"
:class="state(stage.id) === 'upcoming' ? 'sfi-conn-upcoming' : 'sfi-conn-done'"
/>
<!-- Stage node -->
<NuxtLink
:to="stage.route"
class="sfi-node"
:class="[
`sfi-node-${state(stage.id)}`,
state(stage.id) !== 'upcoming' ? 'sfi-node-clickable' : '',
]"
:aria-current="state(stage.id) === 'current' ? 'step' : undefined"
>
<div class="sfi-icon-circle" :class="`sfi-ic-${state(stage.id)}`">
<UIcon v-if="state(stage.id) === 'done'" name="i-heroicons-check" style="width: 14px; height: 14px;" />
<UIcon v-else :name="stage.icon" style="width: 14px; height: 14px;" />
</div>
<span class="sfi-label">{{ stage.label }}</span>
</NuxtLink>
</template>
</div>
</nav>
</template>
<style scoped>
.sfi-root {
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);
padding: 16px 20px;
}
.sfi-track {
display: flex;
align-items: flex-start;
justify-content: center;
}
/* ── Connector line ── */
.sfi-connector {
flex: 1;
height: 2px;
min-width: 16px;
max-width: 80px;
margin-top: 17px; /* vertically center with the 36px circle */
border-radius: 1px;
}
.sfi-conn-done {
background: #01696f;
}
.sfi-conn-upcoming {
background: rgba(0,0,0,0.08);
}
/* ── Stage node ── */
.sfi-node {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
text-decoration: none;
min-width: 72px;
padding: 0 4px;
cursor: default;
flex-shrink: 0;
}
.sfi-node-clickable {
cursor: pointer;
}
.sfi-node-clickable:hover .sfi-label {
color: #01696f;
}
.sfi-node-clickable:hover .sfi-ic-done {
box-shadow: 0 0 0 3px rgba(1,105,111,0.12);
}
/* ── Icon circle ── */
.sfi-icon-circle {
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 180ms ease;
}
.sfi-ic-done {
background: #01696f;
color: #fff;
}
.sfi-ic-current {
background: #fff;
border: 2.5px solid #01696f;
color: #01696f;
box-shadow: 0 0 0 4px rgba(1,105,111,0.10);
animation: sfi-glow 2.5s ease-in-out infinite;
}
.sfi-ic-upcoming {
background: rgba(0,0,0,0.04);
color: #c0c0bc;
border: 1.5px solid rgba(0,0,0,0.06);
}
@keyframes sfi-glow {
0%, 100% { box-shadow: 0 0 0 4px rgba(1,105,111,0.10); }
50% { box-shadow: 0 0 0 6px rgba(1,105,111,0.18); }
}
/* ── Labels ── */
.sfi-label {
font-size: 11px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
text-align: center;
transition: color 150ms ease;
}
.sfi-node-upcoming .sfi-label {
color: #c0c0bc;
}
.sfi-node-current .sfi-label {
color: #01696f;
font-weight: 700;
}
</style>

View File

@@ -0,0 +1,447 @@
<script setup lang="ts">
import { PIPELINE_STAGES, type SalesDeal, type PipelineStage, type DealForm } from '~/composables/useSalesPipeline'
const props = defineProps<{
deal: SalesDeal
}>()
const emit = defineEmits<{
(e: 'navigate', stage: PipelineStage): void
}>()
const { stageFormProgress } = useSalesPipeline()
const expandedStage = ref<PipelineStage | null>(null)
function stageState(stageId: PipelineStage): 'completed' | 'active' | 'waiting' | 'upcoming' {
if (props.deal.completedStages.includes(stageId)) return 'completed'
if (props.deal.currentStage === stageId) {
const meta = PIPELINE_STAGES.find(s => s.id === stageId)
return meta?.isWaiting ? 'waiting' : 'active'
}
return 'upcoming'
}
function stageIdx(stageId: PipelineStage): number {
return PIPELINE_STAGES.findIndex(s => s.id === stageId)
}
function isClickable(stageId: PipelineStage): boolean {
const state = stageState(stageId)
return state === 'completed' || state === 'active'
}
function toggleExpand(stageId: PipelineStage) {
expandedStage.value = expandedStage.value === stageId ? null : stageId
}
function stageForms(stageId: PipelineStage): DealForm[] {
return props.deal.forms[stageId] ?? []
}
function formStatusIcon(f: DealForm): string {
if (f.status === 'complete') return 'i-heroicons-check-circle-solid'
if (f.status === 'in_progress') return 'i-heroicons-ellipsis-horizontal-circle'
return 'i-heroicons-minus-circle'
}
function formStatusColor(f: DealForm): string {
if (f.status === 'complete') return '#059669'
if (f.status === 'in_progress') return '#c27b1a'
return '#c0c0bc'
}
function timeAgo(iso?: string) {
if (!iso) return ''
const diff = Date.now() - new Date(iso).getTime()
if (diff < 3600000) return `${Math.max(1, Math.round(diff / 60000))}m ago`
if (diff < 86400000) return `${Math.round(diff / 3600000)}h ago`
if (diff < 172800000) return 'Yesterday'
return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
</script>
<template>
<div class="spb-root">
<!-- Deal header -->
<div class="spb-header">
<div class="spb-deal-info">
<span class="spb-deal-name">{{ deal.customerName }}</span>
<span class="spb-deal-product">{{ deal.productLine }}</span>
<span v-if="deal.carrier" class="spb-deal-carrier">{{ deal.carrier }} {{ deal.carrierProduct }}</span>
</div>
<span class="spb-deal-id">{{ deal.id }}</span>
</div>
<!-- Stage stepper -->
<div class="spb-stepper">
<template v-for="(stage, i) in PIPELINE_STAGES" :key="stage.id">
<!-- Connector line -->
<div v-if="i > 0" class="spb-connector" :class="stageState(stage.id) === 'upcoming' ? 'spb-conn-upcoming' : stageState(PIPELINE_STAGES[i - 1].id) === 'completed' ? 'spb-conn-done' : 'spb-conn-upcoming'" />
<!-- Stage node -->
<button
type="button"
class="spb-stage"
:class="[
`spb-stage-${stageState(stage.id)}`,
isClickable(stage.id) ? 'spb-stage-clickable' : '',
expandedStage === stage.id ? 'spb-stage-expanded' : '',
]"
@click="isClickable(stage.id) ? toggleExpand(stage.id) : undefined"
>
<!-- Stage circle -->
<div class="spb-circle" :class="`spb-circle-${stageState(stage.id)}`">
<UIcon v-if="stageState(stage.id) === 'completed'" name="i-heroicons-check" style="width: 12px; height: 12px;" />
<UIcon v-else-if="stageState(stage.id) === 'waiting'" name="i-heroicons-clock" style="width: 12px; height: 12px;" />
<span v-else-if="stageState(stage.id) === 'active'" class="spb-circle-dot" />
<span v-else class="spb-circle-num">{{ i + 1 }}</span>
</div>
<!-- Stage label + progress -->
<div class="spb-stage-content">
<span class="spb-stage-label">{{ stage.label }}</span>
<!-- Form progress micro-bar (only for non-waiting stages with forms) -->
<template v-if="!stage.isWaiting && stageForms(stage.id).length > 0">
<div class="spb-micro-bar">
<div class="spb-micro-fill" :style="{ width: stageFormProgress(deal, stage.id) + '%' }" :class="stageFormProgress(deal, stage.id) === 100 ? 'spb-fill-done' : stageFormProgress(deal, stage.id) > 0 ? 'spb-fill-progress' : 'spb-fill-empty'" />
</div>
<span class="spb-micro-pct">{{ stageFormProgress(deal, stage.id) }}%</span>
</template>
<!-- Waiting indicator -->
<span v-if="stageState(stage.id) === 'waiting'" class="spb-waiting-label">
{{ timeAgo(deal.stageTimestamps[stage.id]) }}
</span>
</div>
</button>
</template>
</div>
<!-- Expanded stage detail (form list) -->
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 -translate-y-1 max-h-0"
enter-to-class="opacity-100 translate-y-0 max-h-[400px]"
leave-active-class="transition-all duration-150 ease-in"
leave-from-class="opacity-100 max-h-[400px]"
leave-to-class="opacity-0 -translate-y-1 max-h-0"
>
<div v-if="expandedStage" class="spb-detail">
<div class="spb-detail-header">
<span class="spb-detail-title">{{ PIPELINE_STAGES.find(s => s.id === expandedStage)?.label }} Forms</span>
<button type="button" class="spb-detail-nav" @click="emit('navigate', expandedStage!)">
Go to stage
<UIcon name="i-heroicons-arrow-right" style="width: 11px; height: 11px;" />
</button>
</div>
<div v-if="stageForms(expandedStage).length === 0" class="spb-detail-empty">
No forms assigned to this stage.
</div>
<div v-else class="spb-form-list">
<div v-for="f in stageForms(expandedStage)" :key="f.id" class="spb-form-row">
<UIcon :name="formStatusIcon(f)" :style="{ width: '16px', height: '16px', color: formStatusColor(f), flexShrink: 0 }" />
<div class="spb-form-info">
<span class="spb-form-label">{{ f.label }}</span>
<span class="spb-form-fields">{{ f.completedFields }}/{{ f.requiredFields }} fields</span>
</div>
<div class="spb-form-bar-wrap">
<div class="spb-form-bar" :style="{ width: f.completionPct + '%' }" :class="f.completionPct === 100 ? 'spb-bar-done' : f.completionPct > 0 ? 'spb-bar-progress' : 'spb-bar-empty'" />
</div>
<span class="spb-form-pct" :class="f.completionPct === 100 ? 'spb-pct-done' : f.completionPct > 0 ? 'spb-pct-progress' : 'spb-pct-empty'">{{ f.completionPct }}%</span>
</div>
</div>
</div>
</Transition>
</div>
</template>
<style scoped>
.spb-root {
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;
}
/* ── Header ── */
.spb-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
border-bottom: 1px solid rgba(0,0,0,0.04);
background: rgba(0,0,0,0.01);
}
.spb-deal-info {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.spb-deal-name {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.spb-deal-product {
font-size: 10px;
font-weight: 600;
padding: 1px 7px;
border-radius: 9999px;
background: rgba(1,105,111,0.08);
color: #01696f;
}
.spb-deal-carrier {
font-size: 10px;
color: #8a8a86;
}
.spb-deal-id {
font-size: 10px;
font-family: ui-monospace, monospace;
color: #8a8a86;
}
/* ── Stepper ── */
.spb-stepper {
display: flex;
align-items: flex-start;
padding: 14px 16px;
gap: 0;
overflow-x: auto;
}
/* Connector line */
.spb-connector {
flex: 1;
height: 2px;
min-width: 12px;
margin-top: 11px;
border-radius: 1px;
}
.spb-conn-done { background: #01696f; }
.spb-conn-upcoming { background: rgba(0,0,0,0.08); }
/* Stage button */
.spb-stage {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 0;
border: none;
background: none;
cursor: default;
flex-shrink: 0;
min-width: 64px;
}
.spb-stage-clickable { cursor: pointer; }
.spb-stage-clickable:hover .spb-stage-label { color: #01696f; }
/* Circle */
.spb-circle {
width: 24px;
height: 24px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 150ms ease;
}
.spb-circle-completed {
background: #01696f;
color: #fff;
}
.spb-circle-active {
background: #fff;
border: 2px solid #01696f;
color: #01696f;
}
.spb-circle-waiting {
background: #fff;
border: 2px dashed #c27b1a;
color: #c27b1a;
animation: spb-pulse 2s ease-in-out infinite;
}
.spb-circle-upcoming {
background: rgba(0,0,0,0.04);
color: #c0c0bc;
}
.spb-circle-dot {
width: 8px;
height: 8px;
border-radius: 4px;
background: #01696f;
}
.spb-circle-num {
font-size: 10px;
font-weight: 700;
color: #c0c0bc;
}
@keyframes spb-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* Stage content */
.spb-stage-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.spb-stage-label {
font-size: 10px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
transition: color 150ms ease;
}
.spb-stage-upcoming .spb-stage-label {
color: #c0c0bc;
}
.spb-stage-waiting .spb-stage-label {
color: #c27b1a;
}
/* Micro progress bar */
.spb-micro-bar {
width: 40px;
height: 3px;
border-radius: 1.5px;
background: rgba(0,0,0,0.06);
overflow: hidden;
}
.spb-micro-fill {
height: 100%;
border-radius: 1.5px;
transition: width 300ms ease;
}
.spb-fill-done { background: #059669; }
.spb-fill-progress { background: #c27b1a; }
.spb-fill-empty { background: transparent; }
.spb-micro-pct {
font-size: 9px;
font-weight: 700;
color: #8a8a86;
}
.spb-waiting-label {
font-size: 9px;
font-weight: 500;
color: #c27b1a;
font-style: italic;
}
/* ── Expanded detail ── */
.spb-detail {
border-top: 1px solid rgba(0,0,0,0.06);
padding: 12px 16px;
background: rgba(0,0,0,0.01);
overflow: hidden;
}
.spb-detail-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.spb-detail-title {
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
}
.spb-detail-nav {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: 6px;
border: none;
background: rgba(1,105,111,0.06);
color: #01696f;
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: all 150ms ease;
}
.spb-detail-nav:hover { background: rgba(1,105,111,0.12); }
.spb-detail-empty {
font-size: 12px;
color: #8a8a86;
padding: 8px 0;
}
/* Form list */
.spb-form-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.spb-form-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 8px;
background: #fff;
border: 1px solid rgba(0,0,0,0.04);
}
.spb-form-info {
display: flex;
flex-direction: column;
min-width: 0;
flex-shrink: 0;
}
.spb-form-label {
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
}
.spb-form-fields {
font-size: 10px;
color: #8a8a86;
}
/* Form bar */
.spb-form-bar-wrap {
flex: 1;
height: 5px;
border-radius: 2.5px;
background: rgba(0,0,0,0.05);
overflow: hidden;
min-width: 60px;
}
.spb-form-bar {
height: 100%;
border-radius: 2.5px;
transition: width 300ms ease;
min-width: 0;
}
.spb-bar-done { background: #059669; }
.spb-bar-progress { background: #c27b1a; }
.spb-bar-empty { background: transparent; }
.spb-form-pct {
font-size: 11px;
font-weight: 700;
min-width: 32px;
text-align: right;
}
.spb-pct-done { color: #059669; }
.spb-pct-progress { color: #c27b1a; }
.spb-pct-empty { color: #c0c0bc; }
/* ── Expanded highlight ── */
.spb-stage-expanded .spb-circle {
box-shadow: 0 0 0 3px rgba(1,105,111,0.15);
}
</style>