feat(CC-064): add Sales Pipeline page (Kanban stages, advance deal, weighted value)
This commit is contained in:
parent
545c4eb9f3
commit
36966e2a91
1 changed files with 210 additions and 0 deletions
210
src/app/dashboard/pipeline/page.tsx
Normal file
210
src/app/dashboard/pipeline/page.tsx
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiFetch } from '../../../lib/api';
|
||||
import { useSession } from '../../../components/session-provider';
|
||||
|
||||
interface Deal {
|
||||
id: string; title: string; stage: string;
|
||||
organizationId: string | null; contactId: string | null;
|
||||
valueMinorUnits: number | null; currency: string | null;
|
||||
probability: number | null; expectedCloseDate: string | null;
|
||||
notes: string | null; createdAt: string;
|
||||
}
|
||||
interface Organization { id: string; name: string; }
|
||||
|
||||
const STAGES = [
|
||||
{ key: 'identified', label: 'Identificat', color: 'bg-sky-400' },
|
||||
{ key: 'qualified', label: 'Calificat', color: 'bg-primary' },
|
||||
{ key: 'proposal', label: 'Propunere', color: 'bg-amber-400' },
|
||||
{ key: 'negotiation', label: 'Negociere', color: 'bg-orange-500' },
|
||||
{ key: 'won', label: 'Câștigat', color: 'bg-signal-ok' },
|
||||
{ key: 'lost', label: 'Pierdut', color: 'bg-signal-danger' },
|
||||
];
|
||||
|
||||
function fmtMoney(minor: number | null, currency: string | null) {
|
||||
if (!minor) return '—';
|
||||
return new Intl.NumberFormat('ro-RO', { style: 'currency', currency: currency ?? 'RON', minimumFractionDigits: 0 }).format(minor / 100);
|
||||
}
|
||||
|
||||
export default function PipelinePage() {
|
||||
const { activeTenant } = useSession();
|
||||
const tenantId = activeTenant?.tenantId ?? '';
|
||||
const qc = useQueryClient();
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [form, setForm] = useState({ title: '', stage: 'identified', probability: '50', expectedCloseDate: '' });
|
||||
|
||||
const { data: deals = [], isLoading } = useQuery({
|
||||
queryKey: ['pipeline', tenantId],
|
||||
queryFn: () => apiFetch<Deal[]>('/v1/pipeline', { tenantId }),
|
||||
enabled: Boolean(tenantId),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const { data: orgs = [] } = useQuery({
|
||||
queryKey: ['orgs', tenantId],
|
||||
queryFn: () => apiFetch<Organization[]>('/v1/organizations', { tenantId }),
|
||||
enabled: Boolean(tenantId), staleTime: 300_000,
|
||||
});
|
||||
const orgMap = Object.fromEntries(orgs.map((o) => [o.id, o.name]));
|
||||
|
||||
const { mutate: create, isPending } = useMutation({
|
||||
mutationFn: () => apiFetch<Deal>('/v1/pipeline', {
|
||||
method: 'POST',
|
||||
body: { ...form, probability: parseInt(form.probability) || 50 },
|
||||
tenantId,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['pipeline', tenantId] });
|
||||
setShowCreate(false);
|
||||
setForm({ title: '', stage: 'identified', probability: '50', expectedCloseDate: '' });
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: advanceStage } = useMutation({
|
||||
mutationFn: ({ id, stage }: { id: string; stage: string }) =>
|
||||
apiFetch(`/v1/pipeline/${id}`, { method: 'PATCH', body: { stage }, tenantId }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['pipeline', tenantId] }),
|
||||
});
|
||||
|
||||
const active = deals.filter((d) => !['won', 'lost'].includes(d.stage));
|
||||
const closed = deals.filter((d) => ['won', 'lost'].includes(d.stage));
|
||||
const pipelineValue = active.reduce((s, d) => s + (d.valueMinorUnits ?? 0) * ((d.probability ?? 50) / 100), 0);
|
||||
const primaryCurrency = deals.find((d) => d.currency)?.currency ?? 'RON';
|
||||
|
||||
// Group by stage
|
||||
const byStage: Record<string, Deal[]> = {};
|
||||
for (const s of STAGES) byStage[s.key] = [];
|
||||
for (const d of deals) {
|
||||
if (!byStage[d.stage]) byStage[d.stage] = [];
|
||||
byStage[d.stage].push(d);
|
||||
}
|
||||
|
||||
const stageIdx = Object.fromEntries(STAGES.map((s, i) => [s.key, i]));
|
||||
const nextStage = (stage: string) => STAGES[stageIdx[stage] + 1]?.key;
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl space-y-6 p-6">
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold text-ink">Pipeline Vânzări</h1>
|
||||
<p className="text-sm text-ink-faint mt-1">
|
||||
{active.length} deal-uri active · Valoare ponderată: {fmtMoney(Math.round(pipelineValue), primaryCurrency)}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => setShowCreate(true)} className="btn btn-primary text-xs px-4 py-2">+ Deal nou</button>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<div className="card p-5 space-y-4 border-primary/30">
|
||||
<h2 className="text-sm font-semibold text-ink">Deal nou</h2>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<label className="text-xs text-ink-faint block mb-1">Titlu deal *</label>
|
||||
<input value={form.title} onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
|
||||
placeholder="Contract SaaS — Firma XYZ"
|
||||
className="w-full rounded-lg border bg-card px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-ink-faint block mb-1">Etapă</label>
|
||||
<select value={form.stage} onChange={(e) => setForm((f) => ({ ...f, stage: e.target.value }))}
|
||||
className="w-full rounded-lg border bg-card px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring">
|
||||
{STAGES.filter((s) => !['won','lost'].includes(s.key)).map((s) => <option key={s.key} value={s.key}>{s.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-ink-faint block mb-1">Probabilitate ({form.probability}%)</label>
|
||||
<input type="range" min="0" max="100" step="5" value={form.probability}
|
||||
onChange={(e) => setForm((f) => ({ ...f, probability: e.target.value }))}
|
||||
className="w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-ink-faint block mb-1">Data estimată close</label>
|
||||
<input type="date" value={form.expectedCloseDate}
|
||||
onChange={(e) => setForm((f) => ({ ...f, expectedCloseDate: e.target.value }))}
|
||||
className="w-full rounded-lg border bg-card px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={() => create()} disabled={isPending || !form.title.trim()}
|
||||
className="btn btn-primary text-xs px-4 py-2 disabled:opacity-50">
|
||||
{isPending ? 'Se salvează…' : 'Salvează'}
|
||||
</button>
|
||||
<button onClick={() => setShowCreate(false)} className="text-xs text-ink-faint hover:text-ink">Anulează</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? <div className="card p-8 text-center text-sm text-ink-faint">Se încarcă…</div>
|
||||
: deals.length === 0 ? (
|
||||
<div className="card p-12 text-center space-y-2">
|
||||
<p className="text-3xl">🎯</p>
|
||||
<p className="text-sm text-ink-faint">Niciun deal în pipeline.</p>
|
||||
<button onClick={() => setShowCreate(true)} className="text-xs text-bronze-deep hover:underline">Adaugă primul deal →</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Stage columns */}
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
{STAGES.filter((s) => !['won','lost'].includes(s.key)).map((stage) => {
|
||||
const stageDeals = byStage[stage.key] ?? [];
|
||||
return (
|
||||
<div key={stage.key} className="card p-3 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${stage.color}`} />
|
||||
<p className="text-xs font-semibold text-ink">{stage.label}</p>
|
||||
<span className="ml-auto text-[10px] text-ink-faint">{stageDeals.length}</span>
|
||||
</div>
|
||||
{stageDeals.length === 0 && <p className="text-[10px] text-ink-faint text-center py-2">Gol</p>}
|
||||
{stageDeals.map((d) => {
|
||||
const next = nextStage(d.stage);
|
||||
return (
|
||||
<div key={d.id} className="rounded-lg border bg-card/50 p-2.5 space-y-1.5">
|
||||
<p className="text-xs font-medium text-ink line-clamp-2">{d.title}</p>
|
||||
{d.organizationId && orgMap[d.organizationId] && (
|
||||
<p className="text-[10px] text-ink-faint">@ {orgMap[d.organizationId]}</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] text-ink-faint">{d.probability ?? 50}%</span>
|
||||
{fmtMoney(d.valueMinorUnits, d.currency) !== '—' && (
|
||||
<span className="text-[10px] font-medium text-ink">{fmtMoney(d.valueMinorUnits, d.currency)}</span>
|
||||
)}
|
||||
</div>
|
||||
{next && (
|
||||
<button onClick={() => advanceStage({ id: d.id, stage: next })}
|
||||
className="w-full text-[10px] text-bronze-deep border border-bronze-deep/20 rounded py-0.5 hover:bg-bronze-deep/5">
|
||||
→ {STAGES.find((s) => s.key === next)?.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Won/Lost summary */}
|
||||
{closed.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{[['won','Câștigate'],['lost','Pierdute']].map(([stage, label]) => {
|
||||
const stageDeals = byStage[stage] ?? [];
|
||||
return (
|
||||
<div key={stage} className="card p-4 space-y-2">
|
||||
<p className="text-xs font-semibold text-ink">{label} ({stageDeals.length})</p>
|
||||
{stageDeals.slice(0, 3).map((d) => (
|
||||
<p key={d.id} className="text-xs text-ink-faint line-clamp-1">
|
||||
{d.title} — {fmtMoney(d.valueMinorUnits, d.currency)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue