From 176850458a3ef182e97d34d2e4a61b3f4db15b2f Mon Sep 17 00:00:00 2001 From: admin-valentin Date: Sun, 2 Aug 2026 12:21:34 +0000 Subject: [PATCH] feat(CC-069): add Opportunities Pipeline page (4 probability buckets, weighted value, slider) --- .../dashboard/opportunities/pipeline/page.tsx | 210 ++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 src/app/dashboard/opportunities/pipeline/page.tsx diff --git a/src/app/dashboard/opportunities/pipeline/page.tsx b/src/app/dashboard/opportunities/pipeline/page.tsx new file mode 100644 index 0000000..8d3651c --- /dev/null +++ b/src/app/dashboard/opportunities/pipeline/page.tsx @@ -0,0 +1,210 @@ +'use client'; + +import { useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { apiFetch } from '../../../../lib/api'; +import { useSession } from '../../../../components/session-provider'; + +interface Opportunity { + id: string; source: string; entityType: string | null; entityId: string | null; + valueRangeMin: number | null; valueRangeMax: number | null; + probability: number | null; nextAction: string | null; + expiresAt: string | null; createdAt: string; +} + +type ProbBucket = { label: string; range: [number, number]; cls: string; bgCls: string; }; + +const BUCKETS: ProbBucket[] = [ + { label: 'Explorare', range: [0, 0.25], cls: 'text-ink-faint', bgCls: 'bg-muted/50 border-border/30' }, + { label: 'Dezvoltare', range: [0.26, 0.5], cls: 'text-sky-500', bgCls: 'bg-sky-500/5 border-sky-500/20' }, + { label: 'Calificare', range: [0.51, 0.75],cls: 'text-signal-warn', bgCls: 'bg-signal-warn/5 border-signal-warn/20' }, + { label: 'Angajament', range: [0.76, 1], cls: 'text-signal-ok', bgCls: 'bg-signal-ok/5 border-signal-ok/20' }, +]; + +function fmtRange(min: number | null, max: number | null, currency = 'RON') { + if (!min && !max) return null; + const fmt = (v: number) => new Intl.NumberFormat('ro-RO', { style: 'currency', currency, maximumFractionDigits: 0 }).format(v / 100); + if (min && max) return `${fmt(min)} – ${fmt(max)}`; + if (min) return `>${fmt(min)}`; + return `<${fmt(max!)}`; +} + +function isExpired(expiresAt: string | null) { + return expiresAt ? new Date(expiresAt) < new Date() : false; +} + +export default function OpportunitiesPipelinePage() { + const { activeTenant } = useSession(); + const tenantId = activeTenant?.tenantId ?? ''; + const qc = useQueryClient(); + + const [showCreate, setShowCreate] = useState(false); + const [form, setForm] = useState({ source: '', entityType: '', valueMin: '', valueMax: '', probability: '0.5', nextAction: '', expiresAt: '' }); + + const { data: opportunities = [], isLoading } = useQuery({ + queryKey: ['opportunities', tenantId], + queryFn: () => apiFetch('/v1/opportunities', { tenantId }), + enabled: Boolean(tenantId), staleTime: 30_000, + }); + + const createMut = useMutation({ + mutationFn: (body: Record) => + apiFetch('/v1/opportunities', { tenantId, method: 'POST', body }), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['opportunities', tenantId] }); setShowCreate(false); setForm({ source: '', entityType: '', valueMin: '', valueMax: '', probability: '0.5', nextAction: '', expiresAt: '' }); }, + }); + + const patchMut = useMutation({ + mutationFn: ({ id, ...body }: { id: string; probability?: number; nextAction?: string }) => + apiFetch(`/v1/opportunities/${id}`, { tenantId, method: 'PATCH', body }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['opportunities', tenantId] }), + }); + + const active = opportunities.filter((o) => !isExpired(o.expiresAt)); + const expired = opportunities.filter((o) => isExpired(o.expiresAt)); + + const totalWeightedValue = active.reduce((sum, o) => { + const mid = ((o.valueRangeMin ?? 0) + (o.valueRangeMax ?? o.valueRangeMin ?? 0)) / 2; + return sum + mid * (o.probability ?? 0.5); + }, 0); + + return ( +
+
+
+

Pipeline Oportunități

+

+ {isLoading ? 'Se încarcă…' : `${active.length} active`} + {totalWeightedValue > 0 && ( + · Valoare ponderată: {new Intl.NumberFormat('ro-RO', { style: 'currency', currency: 'RON', maximumFractionDigits: 0 }).format(totalWeightedValue / 100)} + )} +

+
+ +
+ + {/* Create form */} + {showCreate && ( +
+

Oportunitate nouă

+
+
+ setForm({ ...form, source: e.target.value })} + className="w-full rounded-lg border bg-card px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" /> +
+ setForm({ ...form, entityType: e.target.value })} + className="rounded-lg border bg-card px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" /> +
+ + setForm({ ...form, probability: e.target.value })} + className="flex-1" /> + {Math.round(parseFloat(form.probability) * 100)}% +
+ setForm({ ...form, valueMin: e.target.value })} + className="rounded-lg border bg-card px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" /> + setForm({ ...form, valueMax: e.target.value })} + className="rounded-lg border bg-card px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" /> +
+ setForm({ ...form, nextAction: e.target.value })} + className="w-full rounded-lg border bg-card px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" /> +
+ setForm({ ...form, expiresAt: e.target.value })} + className="rounded-lg border bg-card px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" /> +
+
+ + +
+
+ )} + + {/* Pipeline buckets */} + {!isLoading && ( +
+ {BUCKETS.map((bucket) => { + const items = active.filter((o) => { + const p = o.probability ?? 0; + return p >= bucket.range[0] && p <= bucket.range[1]; + }); + const bucketValue = items.reduce((s, o) => { + const mid = ((o.valueRangeMin ?? 0) + (o.valueRangeMax ?? o.valueRangeMin ?? 0)) / 2; + return s + mid; + }, 0); + return ( +
+
+

{bucket.label}

+ {Math.round(bucket.range[0]*100)}–{Math.round(bucket.range[1]*100)}% +
+
+ {items.length} + {bucketValue > 0 && ( + + {(bucketValue/100/1000).toFixed(0)}k + + )} +
+ {items.length > 0 && ( +
+ {items.map((o) => ( +
+

{o.source}

+ {fmtRange(o.valueRangeMin, o.valueRangeMax) && ( +

{fmtRange(o.valueRangeMin, o.valueRangeMax)}

+ )} + {o.nextAction && ( +

→ {o.nextAction}

+ )} +
+ patchMut.mutate({ id: o.id, probability: parseFloat((e.target as HTMLInputElement).value) })} + className="flex-1 h-1" /> + {Math.round((o.probability ?? 0) * 100)}% +
+
+ ))} +
+ )} +
+ ); + })} +
+ )} + + {/* Expired */} + {expired.length > 0 && ( +
+

Expirate ({expired.length})

+
+ {expired.map((o) => ( +
+ + {o.source} + {o.expiresAt?.slice(0, 10)} +
+ ))} +
+
+ )} +
+ ); +}