Files
policy-ui/app/components/sales/SalesPipelineBar.vue
Jordan Weingarten 67482f6629 WIP jordan
2026-04-16 11:11:44 -05:00

448 lines
12 KiB
Vue

<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>