ceo-web/src/app/dashboard/opportunities/pipeline/page.tsx

210 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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>
);
}