diff --git a/src/app/dashboard/pipeline/page.tsx b/src/app/dashboard/pipeline/page.tsx new file mode 100644 index 0000000..ac3dc01 --- /dev/null +++ b/src/app/dashboard/pipeline/page.tsx @@ -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('/v1/pipeline', { tenantId }), + enabled: Boolean(tenantId), + staleTime: 60_000, + }); + const { data: orgs = [] } = useQuery({ + queryKey: ['orgs', tenantId], + queryFn: () => apiFetch('/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('/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 = {}; + 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 ( +
+
+
+

Pipeline Vânzări

+

+ {active.length} deal-uri active · Valoare ponderată: {fmtMoney(Math.round(pipelineValue), primaryCurrency)} +

+
+ +
+ + {showCreate && ( +
+

Deal nou

+
+
+ + 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" /> +
+
+ + +
+
+ + setForm((f) => ({ ...f, probability: e.target.value }))} + className="w-full" /> +
+
+ + 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" /> +
+
+
+ + +
+
+ )} + + {isLoading ?
Se încarcă…
+ : deals.length === 0 ? ( +
+

🎯

+

Niciun deal în pipeline.

+ +
+ ) : ( + <> + {/* Stage columns */} +
+ {STAGES.filter((s) => !['won','lost'].includes(s.key)).map((stage) => { + const stageDeals = byStage[stage.key] ?? []; + return ( +
+
+
+

{stage.label}

+ {stageDeals.length} +
+ {stageDeals.length === 0 &&

Gol

} + {stageDeals.map((d) => { + const next = nextStage(d.stage); + return ( +
+

{d.title}

+ {d.organizationId && orgMap[d.organizationId] && ( +

@ {orgMap[d.organizationId]}

+ )} +
+ {d.probability ?? 50}% + {fmtMoney(d.valueMinorUnits, d.currency) !== '—' && ( + {fmtMoney(d.valueMinorUnits, d.currency)} + )} +
+ {next && ( + + )} +
+ ); + })} +
+ ); + })} +
+ + {/* Won/Lost summary */} + {closed.length > 0 && ( +
+ {[['won','Câștigate'],['lost','Pierdute']].map(([stage, label]) => { + const stageDeals = byStage[stage] ?? []; + return ( +
+

{label} ({stageDeals.length})

+ {stageDeals.slice(0, 3).map((d) => ( +

+ {d.title} — {fmtMoney(d.valueMinorUnits, d.currency)} +

+ ))} +
+ ); + })} +
+ )} + + ) + } +
+ ); +}