Blocks

Sales Pipeline Kanban Block

Application UI

A copyable CRM pipeline board for deal filtering, stage review, forecast math, and close-plan editing.

Operations

Sales pipeline kanban

Copy this into CRM, sales operations, partner marketplace, recruiting, agency, or customer-success apps that need a high-signal pipeline board with realistic forecast and deal-review behavior.

1200px

<script setup>
import { computed, ref, watch } from 'vue';
import {
	DomButton,
	DomDialog,
	DomListbox,
	DomRangeInput,
	DomTagCombobox,
	DomTextareaInput,
	DomToggle,
	DomToggleButtonGroup,
	DomTooltip,
} from '@getdom/studio/vue';
import DealStageColumn from '../components/DealStageColumn.vue';
import ForecastPill from '../components/ForecastPill.vue';

const stages = [
	{ value: 'qualified', label: 'Qualified', description: 'Need confirmed budget and pain.', defaultProbability: 25 },
	{ value: 'solution', label: 'Solution fit', description: 'Discovery complete, solution mapped.', defaultProbability: 45 },
	{ value: 'proposal', label: 'Proposal', description: 'Pricing and scope are in buyer review.', defaultProbability: 65 },
	{ value: 'legal', label: 'Legal review', description: 'Security, procurement, or legal review.', defaultProbability: 80 },
	{ value: 'commit', label: 'Commit', description: 'Mutual close plan is active.', defaultProbability: 90 },
];

const forecastModes = [
	{ value: 'pipeline', label: 'Pipeline' },
	{ value: 'weighted', label: 'Weighted' },
	{ value: 'commit', label: 'Commit' },
];

const ownerOptions = [
	{ value: 'ada', label: 'Ada Riley', description: 'Enterprise AE', meta: '$304k open' },
	{ value: 'maya', label: 'Maya Chen', description: 'Strategic AE', meta: '$276k open' },
	{ value: 'jon', label: 'Jon Bell', description: 'Growth AE', meta: '$186k open' },
	{ value: 'sam', label: 'Sam Patel', description: 'Partner lead', meta: '$144k open' },
];

const regionOptions = [
	{ value: 'na', label: 'North America', description: 'US and Canada territory', count: 5 },
	{ value: 'emea', label: 'EMEA', description: 'Europe, Middle East, Africa', count: 4 },
	{ value: 'apac', label: 'APAC', description: 'Asia-Pacific expansion', count: 2 },
];

const initialDeals = [
	{
		id: 'deal_northstar',
		account: 'Northstar Labs',
		description: 'Enterprise product analytics rollout',
		stage: 'proposal',
		value: 128000,
		probability: 65,
		forecast: 'Best case',
		forecastTone: 'primary',
		owner: 'ada',
		ownerInitials: 'AR',
		region: 'emea',
		closeDate: 'Jun 28',
		nextStep: 'Security review',
		risks: ['Security', 'Legal'],
		stakeholders: ['CFO', 'VP Product'],
		closePlan: 'Confirm security exception path, then send final order form.',
	},
	{
		id: 'deal_evergreen',
		account: 'Evergreen Clinics',
		description: 'Multi-location patient scheduling suite',
		stage: 'legal',
		value: 94000,
		probability: 78,
		forecast: 'Commit',
		forecastTone: 'success',
		owner: 'maya',
		ownerInitials: 'MC',
		region: 'na',
		closeDate: 'Jun 21',
		nextStep: 'Procurement call',
		risks: ['MSA'],
		stakeholders: ['COO', 'Procurement'],
		closePlan: 'Procurement requested one redline pass and updated implementation dates.',
	},
	{
		id: 'deal_clearpath',
		account: 'Clearpath Freight',
		description: 'Dispatch automation and customer portal',
		stage: 'solution',
		value: 76000,
		probability: 42,
		forecast: 'Pipeline',
		forecastTone: 'neutral',
		owner: 'sam',
		ownerInitials: 'SP',
		region: 'na',
		closeDate: 'Jul 10',
		nextStep: 'Solution workshop',
		risks: ['Integration'],
		stakeholders: ['VP Ops'],
		closePlan: 'Map EDI integration scope before pricing approval.',
	},
	{
		id: 'deal_summit',
		account: 'Summit Finance',
		description: 'Compliance workflow expansion',
		stage: 'commit',
		value: 156000,
		probability: 91,
		forecast: 'Commit',
		forecastTone: 'success',
		owner: 'ada',
		ownerInitials: 'AR',
		region: 'emea',
		closeDate: 'Jun 17',
		nextStep: 'Order form',
		risks: [],
		stakeholders: ['CIO', 'General Counsel'],
		closePlan: 'Send final order form after data residency addendum is attached.',
	},
	{
		id: 'deal_riverline',
		account: 'Riverline Market',
		description: 'Seller onboarding and payouts workflow',
		stage: 'qualified',
		value: 52000,
		probability: 22,
		forecast: 'Pipeline',
		forecastTone: 'neutral',
		owner: 'jon',
		ownerInitials: 'JB',
		region: 'na',
		closeDate: 'Jul 24',
		nextStep: 'Budget check',
		risks: ['Budget'],
		stakeholders: ['Founder'],
		closePlan: 'Confirm implementation budget and marketplace launch date.',
	},
	{
		id: 'deal_monarch',
		account: 'Monarch Education',
		description: 'District onboarding and parent portal',
		stage: 'proposal',
		value: 112000,
		probability: 58,
		forecast: 'Best case',
		forecastTone: 'primary',
		owner: 'maya',
		ownerInitials: 'MC',
		region: 'emea',
		closeDate: 'Jul 03',
		nextStep: 'Board packet',
		risks: ['Timeline'],
		stakeholders: ['Superintendent', 'Finance'],
		closePlan: 'Package implementation timeline and proof points for board review.',
	},
	{
		id: 'deal_atlas',
		account: 'Atlas Robotics',
		description: 'Developer platform usage expansion',
		stage: 'solution',
		value: 88000,
		probability: 50,
		forecast: 'Best case',
		forecastTone: 'primary',
		owner: 'jon',
		ownerInitials: 'JB',
		region: 'apac',
		closeDate: 'Jul 14',
		nextStep: 'Usage model',
		risks: ['Pricing'],
		stakeholders: ['CTO', 'Finance'],
		closePlan: 'Align usage model with procurement cap before proposal.',
	},
	{
		id: 'deal_pixel',
		account: 'Pixel Grove',
		description: 'Creative operations workspace',
		stage: 'qualified',
		value: 38000,
		probability: 30,
		forecast: 'Pipeline',
		forecastTone: 'neutral',
		owner: 'sam',
		ownerInitials: 'SP',
		region: 'apac',
		closeDate: 'Aug 02',
		nextStep: 'Champion call',
		risks: [],
		stakeholders: ['Head of Studio'],
		closePlan: 'Identify executive sponsor and agency rollout path.',
	},
];

const deals = ref(initialDeals.map((deal) => ({ ...deal, stakeholders: [...deal.stakeholders], risks: [...deal.risks] })));
const selectedDealId = ref('deal_northstar');
const selectedOwners = ref(['ada', 'maya', 'jon', 'sam']);
const selectedRegions = ref(['na', 'emea', 'apac']);
const forecastMode = ref('weighted');
const mobileStage = ref('proposal');
const showExecutiveCommit = ref(true);
const reviewOpen = ref(false);
const lastSavedAt = ref('');

const selectedDeal = computed(() => deals.value.find((deal) => deal.id === selectedDealId.value) || deals.value[0]);
const filteredDeals = computed(() => deals.value.filter((deal) => {
	return selectedOwners.value.includes(deal.owner) && selectedRegions.value.includes(deal.region);
}));
const visibleStages = computed(() => stages.map((stage) => ({
	...stage,
	deals: filteredDeals.value.filter((deal) => deal.stage === stage.value),
})));
const mobileStageOptions = computed(() => visibleStages.value.map((stage) => ({
	value: stage.value,
	label: `${stage.label} (${stage.deals.length})`,
})));
const activeMobileStage = computed(() => visibleStages.value.find((stage) => stage.value === mobileStage.value) || visibleStages.value[0]);
const openPipelineValue = computed(() => filteredDeals.value.reduce((sum, deal) => sum + deal.value, 0));
const weightedForecastValue = computed(() => filteredDeals.value.reduce((sum, deal) => sum + (deal.value * deal.probability / 100), 0));
const commitValue = computed(() => filteredDeals.value.filter((deal) => deal.forecast === 'Commit').reduce((sum, deal) => sum + deal.value, 0));
const riskCount = computed(() => filteredDeals.value.filter((deal) => deal.risks.length).length);
const commitCoverage = computed(() => {
	if (!openPipelineValue.value) return 0;
	return Math.round((commitValue.value / openPipelineValue.value) * 100);
});
const payload = computed(() => {
	const deal = selectedDeal.value;

	return {
		view: {
			mode: forecastMode.value,
			ownerIds: selectedOwners.value,
			regions: selectedRegions.value,
			showExecutiveCommit: showExecutiveCommit.value,
		},
		selectedDeal: {
			id: deal.id,
			stage: deal.stage,
			value: deal.value,
			probability: deal.probability,
			forecast: deal.forecast,
			closeDate: deal.closeDate,
			nextStep: deal.nextStep,
			stakeholders: deal.stakeholders,
			closePlan: deal.closePlan,
		},
		forecast: {
			openPipeline: openPipelineValue.value,
			weighted: Math.round(weightedForecastValue.value),
			commit: commitValue.value,
		},
	};
});
const payloadJson = computed(() => JSON.stringify(payload.value, null, 2));

watch(selectedDeal, (deal) => {
	if (deal) mobileStage.value = deal.stage;
});

function money(value) {
	return new Intl.NumberFormat('en', {
		style: 'currency',
		currency: 'USD',
		maximumFractionDigits: 0,
	}).format(value);
}

function selectDeal(deal) {
	selectedDealId.value = deal.id;
	mobileStage.value = deal.stage;
}

function openReview(deal) {
	selectDeal(deal);
	reviewOpen.value = true;
}

function forecastToneFor(forecast) {
	if (forecast === 'Commit') return 'success';
	if (forecast === 'Best case') return 'primary';
	return 'neutral';
}

function saveReview() {
	selectedDeal.value.forecastTone = forecastToneFor(selectedDeal.value.forecast);
	lastSavedAt.value = 'Saved just now';
	reviewOpen.value = false;
}
</script>

<template>
	<div class="w-full overflow-hidden rounded-lg border border-border bg-background text-fg shadow-xl shadow-black/10">
		<header class="border-b border-border skin-raised">
			<div class="grid gap-5 p-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:p-6">
				<div class="min-w-0">
					<div class="flex flex-wrap items-center gap-2">
						<span class="inline-flex size-9 items-center justify-center rounded-lg bg-primary text-primary-foreground">
							<svg viewBox="0 0 24 24" class="size-5" fill="none" aria-hidden="true">
								<path d="M4 7h4v10H4V7Zm6-3h4v13h-4V4Zm6 6h4v7h-4v-7ZM3 20h18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
							</svg>
						</span>
						<div>
							<p class="text-xs font-semibold uppercase text-muted-fg">Revenue operations</p>
							<h2 class="text-2xl font-semibold text-fg">Enterprise pipeline</h2>
						</div>
					</div>
					<p class="mt-3 max-w-3xl text-sm leading-6 text-muted-fg">
						Review deal stages, forecast coverage, owner focus, and close-plan readiness without forcing a permanent inspector panel.
					</p>
				</div>

				<div class="grid grid-cols-3 gap-2 text-sm sm:min-w-[28rem]">
					<div class="rounded-lg border border-border bg-background p-3">
						<p class="text-xs text-muted-fg">Pipeline</p>
						<p class="mt-1 font-semibold">{{ money(openPipelineValue) }}</p>
					</div>
					<div class="rounded-lg border border-border bg-background p-3">
						<p class="text-xs text-muted-fg">Weighted</p>
						<p class="mt-1 font-semibold">{{ money(weightedForecastValue) }}</p>
					</div>
					<div class="rounded-lg border border-border bg-background p-3">
						<p class="text-xs text-muted-fg">Commit</p>
						<p class="mt-1 font-semibold">{{ money(commitValue) }}</p>
					</div>
				</div>
			</div>

			<div class="grid gap-3 border-t border-border p-4 lg:grid-cols-[1fr_1fr_auto] lg:p-6">
				<DomTagCombobox
					v-model="selectedOwners"
					:options="ownerOptions"
					label="Owners"
					placeholder="Filter owner"
				>
					<template #item="{ item }">
						<div class="flex min-w-0 items-center justify-between gap-3">
							<div class="min-w-0">
								<span class="block truncate font-medium">{{ item.label }}</span>
								<span class="block truncate text-xs text-muted-fg">{{ item.description }}</span>
							</div>
							<span class="shrink-0 text-xs text-muted-fg">{{ item.meta }}</span>
						</div>
					</template>
				</DomTagCombobox>

				<DomTagCombobox
					v-model="selectedRegions"
					:options="regionOptions"
					label="Regions"
					placeholder="Filter region"
				>
					<template #item="{ item }">
						<div class="flex min-w-0 items-center justify-between gap-3">
							<div class="min-w-0">
								<span class="block truncate font-medium">{{ item.label }}</span>
								<span class="block truncate text-xs text-muted-fg">{{ item.description }}</span>
							</div>
							<span class="shrink-0 rounded-full bg-secondary px-2 py-0.5 text-xs text-muted-fg">{{ item.count }}</span>
						</div>
					</template>
				</DomTagCombobox>

				<div class="grid gap-3 sm:grid-cols-[minmax(15rem,1fr)_auto] lg:min-w-[28rem]">
					<DomToggleButtonGroup
						v-model="forecastMode"
						label="Forecast mode"
						:options="forecastModes"
					/>
					<div class="flex items-end">
						<DomToggle
							v-model="showExecutiveCommit"
							label="Exec commit"
							description="Highlight committed deals."
						/>
					</div>
				</div>
			</div>
		</header>

		<main class="grid gap-0">
			<section class="min-w-0">
				<div class="hidden gap-4 overflow-x-auto p-4 lg:flex lg:p-6">
					<DealStageColumn
						v-for="stage in visibleStages"
						:key="stage.value"
						:stage="stage"
						:deals="stage.deals"
						:selected-deal-id="selectedDeal?.id"
						@select="openReview"
					/>
				</div>

				<div class="grid gap-4 p-4 lg:hidden">
					<DomToggleButtonGroup
						v-model="mobileStage"
						label="Stage"
						:options="mobileStageOptions"
					/>
					<div class="grid gap-3">
						<button
							v-for="deal in activeMobileStage.deals"
							:key="deal.id"
							type="button"
							class="rounded-lg border border-border bg-background p-4 text-left shadow-sm"
							@click="openReview(deal)"
						>
							<div class="flex items-start justify-between gap-3">
								<div class="min-w-0">
									<p class="truncate font-semibold">{{ deal.account }}</p>
									<p class="mt-1 truncate text-sm text-muted-fg">{{ deal.description }}</p>
								</div>
								<ForecastPill :tone="deal.forecastTone" :label="deal.forecast" />
							</div>
							<div class="mt-4 flex items-end justify-between">
								<p class="text-lg font-semibold">{{ money(deal.value) }}</p>
								<p class="text-sm text-muted-fg">{{ deal.probability }}%</p>
							</div>
						</button>
						<div v-if="!activeMobileStage.deals.length" class="rounded-lg border border-dashed border-border p-6 text-center text-sm text-muted-fg">
							No matching deals in {{ activeMobileStage.label }}.
						</div>
					</div>
				</div>
			</section>

			<section class="grid gap-4 border-t border-border bg-secondary/30 p-4 lg:grid-cols-[minmax(0,1fr)_18rem_minmax(20rem,24rem)] lg:p-6">
				<div class="rounded-lg border border-border bg-background p-4">
					<div class="flex items-start justify-between gap-3">
						<div>
							<p class="text-xs font-semibold uppercase text-muted-fg">Selected deal</p>
							<h3 class="mt-1 text-lg font-semibold">{{ selectedDeal.account }}</h3>
						</div>
						<ForecastPill :tone="selectedDeal.forecastTone" :label="selectedDeal.forecast" />
					</div>
					<p class="mt-2 text-sm text-muted-fg">{{ selectedDeal.description }}</p>
					<div class="mt-4 grid grid-cols-2 gap-2 text-sm">
						<div class="rounded-md bg-secondary p-3">
							<p class="text-xs text-muted-fg">Value</p>
							<p class="mt-1 font-semibold">{{ money(selectedDeal.value) }}</p>
						</div>
						<div class="rounded-md bg-secondary p-3">
							<p class="text-xs text-muted-fg">Probability</p>
							<p class="mt-1 font-semibold">{{ selectedDeal.probability }}%</p>
						</div>
					</div>
					<div class="mt-4 flex flex-wrap gap-2">
						<span
							v-for="stakeholder in selectedDeal.stakeholders"
							:key="stakeholder"
							class="rounded-full bg-secondary px-2.5 py-1 text-xs font-medium text-muted-fg"
						>
							{{ stakeholder }}
						</span>
					</div>
					<div class="mt-4 flex flex-wrap gap-2">
						<DomButton size="sm" @click="reviewOpen = true">Review deal</DomButton>
						<DomButton size="sm" variant="secondary">Log activity</DomButton>
						<DomTooltip text="Export the current forecast payload" placement="top">
							<button type="button" class="grid size-9 place-items-center rounded-lg border border-border bg-background text-muted-fg hover:text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40" aria-label="Export forecast payload">
								<svg viewBox="0 0 24 24" class="size-4" fill="none" aria-hidden="true">
									<path d="M12 3v12m0 0 4-4m-4 4-4-4M5 19h14" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
								</svg>
							</button>
						</DomTooltip>
					</div>
					<p v-if="lastSavedAt" class="mt-3 text-xs text-success">{{ lastSavedAt }}</p>
				</div>

				<div class="rounded-lg border border-border bg-background p-4">
					<div class="flex items-center justify-between gap-3">
						<div>
							<h3 class="font-semibold">Forecast health</h3>
							<p class="mt-1 text-sm text-muted-fg">Coverage across filtered opportunities.</p>
						</div>
						<span class="rounded-full bg-success/10 px-3 py-1 text-xs font-semibold text-success">{{ commitCoverage }}%</span>
					</div>
					<div class="mt-4 space-y-3 text-sm">
						<div class="flex items-center justify-between gap-3">
							<span class="text-muted-fg">Open deals</span>
							<span class="font-semibold">{{ filteredDeals.length }}</span>
						</div>
						<div class="flex items-center justify-between gap-3">
							<span class="text-muted-fg">Risk flags</span>
							<span class="font-semibold">{{ riskCount }}</span>
						</div>
						<div class="flex items-center justify-between gap-3">
							<span class="text-muted-fg">Commit coverage</span>
							<span class="font-semibold">{{ commitCoverage }}%</span>
						</div>
					</div>
				</div>

				<div class="rounded-lg border border-border bg-background p-4">
					<h3 class="font-semibold">Command payload</h3>
					<p class="mt-1 text-sm text-muted-fg">Use this shape for save, transition, or forecast export commands.</p>
					<pre class="mt-4 max-h-80 overflow-auto rounded-lg bg-secondary p-3 text-xs leading-5 text-muted-fg">{{ payloadJson }}</pre>
				</div>
			</section>
		</main>

		<DomDialog
			v-model="reviewOpen"
			title="Review deal"
			description="Update stage, probability, and close-plan details before saving the opportunity command."
		>
			<div v-if="selectedDeal" class="grid gap-4">
				<div class="rounded-lg border border-border bg-secondary/40 p-4">
					<div class="flex items-start justify-between gap-3">
						<div>
							<p class="font-semibold">{{ selectedDeal.account }}</p>
							<p class="mt-1 text-sm text-muted-fg">{{ selectedDeal.description }}</p>
						</div>
						<p class="shrink-0 font-semibold">{{ money(selectedDeal.value) }}</p>
					</div>
				</div>

				<DomListbox v-model="selectedDeal.stage" :options="stages" label="Stage">
					<template #option="{ option }">
						<div class="min-w-0">
							<span class="block font-medium">{{ option.label }}</span>
							<span class="block text-xs text-muted-fg">{{ option.description }}</span>
						</div>
						<span class="text-xs font-semibold text-muted-fg">{{ option.defaultProbability }}%</span>
					</template>
				</DomListbox>

				<DomRangeInput
					v-model="selectedDeal.probability"
					label="Probability"
					description="Use seller judgment here; server forecast rules can still override rollups."
					:min="0"
					:max="100"
					:step="5"
					suffix="%"
				/>

				<DomListbox
					v-model="selectedDeal.forecast"
					label="Forecast category"
					:options="[
						{ value: 'Pipeline', label: 'Pipeline', description: 'Early, uncommitted opportunity.' },
						{ value: 'Best case', label: 'Best case', description: 'Possible this period with active work.' },
						{ value: 'Commit', label: 'Commit', description: 'Expected to close this period.' },
					]"
				>
					<template #option="{ option }">
						<span class="block font-medium">{{ option.label }}</span>
						<span class="block text-xs text-muted-fg">{{ option.description }}</span>
					</template>
				</DomListbox>

				<DomTextareaInput
					v-model="selectedDeal.closePlan"
					label="Close plan"
					:rows="4"
				/>
			</div>

			<template #footer>
				<DomButton variant="secondary" data-close>Cancel</DomButton>
				<DomButton @click="saveReview">Save deal</DomButton>
			</template>
		</DomDialog>
	</div>
</template>

Local components

Copy the helper components

<script setup>
import DealCard from './DealCard.vue';

defineProps({
	stage: {
		type: Object,
		required: true,
	},
	deals: {
		type: Array,
		default: () => [],
	},
	selectedDealId: {
		type: String,
		default: '',
	},
});

const emit = defineEmits(['select']);

function money(value) {
	return new Intl.NumberFormat('en', {
		style: 'currency',
		currency: 'USD',
		maximumFractionDigits: 0,
	}).format(value);
}
</script>

<template>
	<section class="flex min-h-[34rem] min-w-[18rem] flex-col rounded-lg border border-border bg-secondary/35">
		<header class="border-b border-border p-4">
			<div class="flex items-start justify-between gap-3">
				<div>
					<h3 class="font-semibold text-fg">{{ stage.label }}</h3>
					<p class="mt-1 text-xs text-muted-fg">{{ stage.description }}</p>
				</div>
				<span class="rounded-full bg-background px-2.5 py-1 text-xs font-semibold text-muted-fg">{{ deals.length }}</span>
			</div>
			<div class="mt-4 grid grid-cols-2 gap-2 text-xs">
				<div class="rounded-md bg-background p-2">
					<p class="text-muted-fg">Pipeline</p>
					<p class="mt-1 font-semibold text-fg">{{ money(deals.reduce((sum, deal) => sum + deal.value, 0)) }}</p>
				</div>
				<div class="rounded-md bg-background p-2">
					<p class="text-muted-fg">Weighted</p>
					<p class="mt-1 font-semibold text-fg">{{ money(deals.reduce((sum, deal) => sum + (deal.value * deal.probability / 100), 0)) }}</p>
				</div>
			</div>
		</header>

		<div class="flex-1 space-y-3 overflow-y-auto p-3">
			<DealCard
				v-for="deal in deals"
				:key="deal.id"
				:deal="deal"
				:selected="selectedDealId === deal.id"
				@select="emit('select', $event)"
			/>
			<div v-if="!deals.length" class="grid min-h-40 place-items-center rounded-lg border border-dashed border-border bg-background/60 p-4 text-center text-sm text-muted-fg">
				No matching deals in this stage.
			</div>
		</div>
	</section>
</template>
<script setup>
import ForecastPill from './ForecastPill.vue';

defineProps({
	deal: {
		type: Object,
		required: true,
	},
	selected: {
		type: Boolean,
		default: false,
	},
});

const emit = defineEmits(['select']);

function money(value) {
	return new Intl.NumberFormat('en', {
		style: 'currency',
		currency: 'USD',
		maximumFractionDigits: 0,
	}).format(value);
}
</script>

<template>
	<button
		type="button"
		class="w-full rounded-lg border bg-background p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-primary/40 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
		:class="selected ? 'border-primary ring-2 ring-primary/15' : 'border-border'"
		:aria-pressed="selected"
		@click="emit('select', deal)"
	>
		<div class="flex items-start justify-between gap-3">
			<div class="min-w-0">
				<p class="truncate text-sm font-semibold text-fg">{{ deal.account }}</p>
				<p class="mt-1 truncate text-xs text-muted-fg">{{ deal.description }}</p>
			</div>
			<ForecastPill :tone="deal.forecastTone" :label="deal.forecast" />
		</div>

		<div class="mt-4 flex items-end justify-between gap-3">
			<div>
				<p class="text-lg font-semibold text-fg">{{ money(deal.value) }}</p>
				<p class="mt-0.5 text-xs text-muted-fg">{{ deal.closeDate }} close</p>
			</div>
			<div class="grid size-10 place-items-center rounded-full border border-border bg-secondary text-xs font-semibold text-fg">
				{{ deal.ownerInitials }}
			</div>
		</div>

		<div class="mt-4 h-2 overflow-hidden rounded-full bg-secondary" aria-hidden="true">
			<div class="h-full rounded-full bg-primary" :style="{ width: `${deal.probability}%` }"></div>
		</div>
		<div class="mt-2 flex items-center justify-between gap-3 text-xs text-muted-fg">
			<span>{{ deal.probability }}% probability</span>
			<span>{{ deal.nextStep }}</span>
		</div>

		<div v-if="deal.risks.length" class="mt-3 flex flex-wrap gap-1.5">
			<span
				v-for="risk in deal.risks"
				:key="risk"
				class="rounded-full bg-warning/10 px-2 py-1 text-[0.6875rem] font-medium text-warning"
			>
				{{ risk }}
			</span>
		</div>
	</button>
</template>
<script setup>
defineProps({
	tone: {
		type: String,
		default: 'neutral',
	},
	label: {
		type: String,
		required: true,
	},
});
</script>

<template>
	<span
		class="inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-semibold"
		:class="{
			'border-border bg-secondary text-muted-fg': tone === 'neutral',
			'border-success/30 bg-success/10 text-success': tone === 'success',
			'border-warning/30 bg-warning/10 text-warning': tone === 'warning',
			'border-destructive/30 bg-destructive/10 text-destructive': tone === 'danger',
			'border-primary/30 bg-primary/10 text-primary': tone === 'primary',
		}"
	>
		<span class="size-1.5 rounded-full bg-current" aria-hidden="true"></span>
		{{ label }}
	</span>
</template>

Integration

How to use this block

Use this block when teams need to qualify opportunities, compare forecast by stage, and quickly open a deal to update owner notes, next step, stage, probability, or stakeholder context. It works for CRM products, sales-led SaaS dashboards, agency pipelines, recruiting pipelines, partner onboarding, enterprise quote rooms, and customer expansion workflows.

  • Keep stage definitions server-owned so probability defaults, exit criteria, and required fields can evolve without redeploying the UI.
  • Persist stage changes as explicit commands with previous stage, new stage, actor, timestamp, and forecast impact.
  • Calculate weighted forecast from canonical opportunity value and probability on the server; the client preview is for operator feedback.
  • Model owners, regions, deal tags, risk flags, and close plans as structured fields so the same data can power dashboards, reminders, and exec reviews.
  • Use the dialog flow for edits that need validation. Promote drag-to-stage only after backend transition rules and conflict messages are ready.

Data

Recommended deal payload

{
	workspaceId: 'ws_revenue_2048',
	view: {
		mode: 'forecast',
		ownerIds: ['usr_ada', 'usr_maya'],
		regions: ['emea'],
		showExecutiveCommit: true
	},
	stages: [
		{ id: 'qualified', label: 'Qualified', defaultProbability: 25 },
		{ id: 'solution', label: 'Solution fit', defaultProbability: 45 },
		{ id: 'proposal', label: 'Proposal', defaultProbability: 65 },
		{ id: 'legal', label: 'Legal review', defaultProbability: 80 },
		{ id: 'commit', label: 'Commit', defaultProbability: 90 }
	],
	deals: [
		{
			id: 'deal_northstar',
			accountId: 'acct_northstar',
			account: 'Northstar Labs',
			stage: 'proposal',
			value: 128000,
			currency: 'USD',
			probability: 65,
			forecast: 'Best case',
			ownerId: 'usr_ada',
			region: 'emea',
			closeDate: 'Jun 28',
			nextStep: 'Security review',
			risks: ['Legal', 'Security'],
			stakeholders: ['CFO', 'VP Product'],
			closePlan: 'Confirm security exception path, then send final order form.'
		}
	]
}

Customization

Implementation notes

Transition rules

Gate stage changes with server-side exit criteria. Proposal and commit stages usually need pricing approval, legal state, primary buyer, and close date checks.

Forecast quality

Separate seller-entered probability from model-generated health scores. Store overrides and reason codes so revenue leaders can inspect forecast drift.

Future updates

Useful next steps include drag-to-stage, activity sync, stale-deal nudges, quote PDF generation, mutual action plans, renewal pipelines, and reusable kanban column helpers.