feat(CC-069): add Opportunities Pipeline page (4 probability buckets, weighted value, slider)
This commit is contained in:
parent
9047bcffaf
commit
176850458a
1 changed files with 210 additions and 0 deletions
210
src/app/dashboard/opportunities/pipeline/page.tsx
Normal file
210
src/app/dashboard/opportunities/pipeline/page.tsx
Normal file
|
|
@ -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<Opportunity[]>('/v1/opportunities', { tenantId }),
|
||||
enabled: Boolean(tenantId), staleTime: 30_000,
|
||||
});
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) =>
|
||||
apiFetch<Opportunity>('/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 (
|
||||
<div className="max-w-5xl space-y-6 p-6">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold text-ink">Pipeline Oportunități</h1>
|
||||
<p className="text-sm text-ink-faint mt-1">
|
||||
{isLoading ? 'Se încarcă…' : `${active.length} active`}
|
||||
{totalWeightedValue > 0 && (
|
||||
<span className="text-signal-ok font-medium"> · Valoare ponderată: {new Intl.NumberFormat('ro-RO', { style: 'currency', currency: 'RON', maximumFractionDigits: 0 }).format(totalWeightedValue / 100)}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => setShowCreate(!showCreate)} className="btn btn-primary px-4 py-2 text-sm rounded-lg">
|
||||
+ Oportunitate
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create form */}
|
||||
{showCreate && (
|
||||
<div className="card p-5 space-y-4">
|
||||
<h2 className="text-sm font-semibold text-ink">Oportunitate nouă</h2>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<input placeholder="Sursă / Denumire *" value={form.source}
|
||||
onChange={(e) => 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" />
|
||||
</div>
|
||||
<input placeholder="Tip entitate (ex: organization)" value={form.entityType}
|
||||
onChange={(e) => 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" />
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-ink-faint shrink-0">Prob %</label>
|
||||
<input type="range" min="0" max="1" step="0.05" value={form.probability}
|
||||
onChange={(e) => setForm({ ...form, probability: e.target.value })}
|
||||
className="flex-1" />
|
||||
<span className="text-xs font-mono w-8 text-right">{Math.round(parseFloat(form.probability) * 100)}%</span>
|
||||
</div>
|
||||
<input type="number" placeholder="Valoare min (RON)" value={form.valueMin}
|
||||
onChange={(e) => 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" />
|
||||
<input type="number" placeholder="Valoare max (RON)" value={form.valueMax}
|
||||
onChange={(e) => 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" />
|
||||
<div className="sm:col-span-2">
|
||||
<input placeholder="Următoarea acțiune" value={form.nextAction}
|
||||
onChange={(e) => 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" />
|
||||
</div>
|
||||
<input type="date" placeholder="Expiră la" value={form.expiresAt}
|
||||
onChange={(e) => 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" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => createMut.mutate({
|
||||
source: form.source, entityType: form.entityType || undefined,
|
||||
probability: parseFloat(form.probability),
|
||||
valueRangeMin: form.valueMin ? Math.round(parseFloat(form.valueMin) * 100) : undefined,
|
||||
valueRangeMax: form.valueMax ? Math.round(parseFloat(form.valueMax) * 100) : undefined,
|
||||
nextAction: form.nextAction || undefined,
|
||||
expiresAt: form.expiresAt || undefined,
|
||||
})} disabled={!form.source || createMut.isPending}
|
||||
className="btn btn-primary px-4 py-1.5 text-sm rounded-lg disabled:opacity-50">
|
||||
{createMut.isPending ? 'Se creează…' : 'Adaugă'}
|
||||
</button>
|
||||
<button onClick={() => setShowCreate(false)} className="text-sm text-ink-faint hover:text-ink px-2">Anulează</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pipeline buckets */}
|
||||
{!isLoading && (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{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 (
|
||||
<div key={bucket.label} className={`rounded-xl border p-4 space-y-3 ${bucket.bgCls}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className={`text-xs font-semibold ${bucket.cls}`}>{bucket.label}</h3>
|
||||
<span className="text-xs text-ink-faint">{Math.round(bucket.range[0]*100)}–{Math.round(bucket.range[1]*100)}%</span>
|
||||
</div>
|
||||
<div className="flex items-end justify-between">
|
||||
<span className={`font-display text-2xl font-bold ${bucket.cls}`}>{items.length}</span>
|
||||
{bucketValue > 0 && (
|
||||
<span className="text-[10px] text-ink-faint font-mono">
|
||||
{(bucketValue/100/1000).toFixed(0)}k
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{items.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{items.map((o) => (
|
||||
<div key={o.id} className="rounded-lg bg-card/80 p-2.5 space-y-1">
|
||||
<p className="text-xs font-medium text-ink truncate">{o.source}</p>
|
||||
{fmtRange(o.valueRangeMin, o.valueRangeMax) && (
|
||||
<p className="text-[10px] font-mono text-signal-ok">{fmtRange(o.valueRangeMin, o.valueRangeMax)}</p>
|
||||
)}
|
||||
{o.nextAction && (
|
||||
<p className="text-[10px] text-ink-faint truncate">→ {o.nextAction}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<input type="range" min="0" max="1" step="0.05"
|
||||
defaultValue={String(o.probability ?? 0.5)}
|
||||
onMouseUp={(e) => patchMut.mutate({ id: o.id, probability: parseFloat((e.target as HTMLInputElement).value) })}
|
||||
className="flex-1 h-1" />
|
||||
<span className="text-[9px] font-mono">{Math.round((o.probability ?? 0) * 100)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expired */}
|
||||
{expired.length > 0 && (
|
||||
<div className="card p-4 opacity-60">
|
||||
<h2 className="text-xs font-semibold text-ink-faint mb-3">Expirate ({expired.length})</h2>
|
||||
<div className="space-y-2">
|
||||
{expired.map((o) => (
|
||||
<div key={o.id} className="flex items-center gap-3 py-1.5">
|
||||
<span className="text-sm">⏱</span>
|
||||
<span className="text-xs text-ink-faint truncate flex-1">{o.source}</span>
|
||||
<span className="text-[10px] text-signal-danger shrink-0">{o.expiresAt?.slice(0, 10)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue