448 lines
12 KiB
Vue
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>
|