feat: Faza A2 -- cautare companii, segmente salvate, research briefs

- pagina Companii: cautare Apollo prin ceo-api, salveaza cautarea ca
  segment, creeaza research brief per rezultat
- pagina Segmente: reruleaza o cautare salvata, sterge
- pagina Research: listeaza briefs cu surse (linkuri catre Apollo/altele)
- Overview devine un briefing determinist (restante/urmatoarele 7 zile
  + activitate saptamanala), cu explicatia AI marcata explicit ca
  neconstruita in loc de ascunsa
This commit is contained in:
valentinbvro 2026-07-28 22:01:36 +02:00
parent 883ff370cd
commit 60e5befa31
7 changed files with 524 additions and 20 deletions

View file

@ -0,0 +1,89 @@
'use client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { apiFetch } from '../../../lib/api';
const briefSchema = z.object({
title: z.string().min(1, 'Titlul este obligatoriu').max(250),
summary: z.string().min(1, 'Rezumatul este obligatoriu').max(5000),
});
type BriefForm = z.infer<typeof briefSchema>;
interface BriefFormProps {
tenantId: string;
organizationId: string;
organizationName: string;
onClose: () => void;
}
export function BriefForm({ tenantId, organizationId, organizationName, onClose }: BriefFormProps) {
const queryClient = useQueryClient();
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<BriefForm>({
resolver: zodResolver(briefSchema),
defaultValues: { title: `Research brief — ${organizationName}` },
});
const createBrief = useMutation({
mutationFn: (values: BriefForm) =>
apiFetch('/v1/research-briefs', {
method: 'POST',
tenantId,
body: { organizationId, organizationName, ...values },
}),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['research-briefs', tenantId] });
onClose();
},
});
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink/30 p-4">
<div className="card w-full max-w-lg p-6 shadow-overlay">
<h2 className="mb-1 font-display text-lg font-semibold text-ink">Research brief</h2>
<p className="mb-4 text-sm text-ink-faint">{organizationName}</p>
<form onSubmit={handleSubmit((values) => createBrief.mutate(values))} noValidate className="space-y-4">
<div>
<label className="label" htmlFor="brief-title">
Titlu
</label>
<input id="brief-title" type="text" className="field" {...register('title')} />
{errors.title && <p className="mt-1 text-xs text-signal-danger">{errors.title.message}</p>}
</div>
<div>
<label className="label" htmlFor="brief-summary">
Rezumat
</label>
<textarea id="brief-summary" rows={5} className="field" {...register('summary')} />
{errors.summary && <p className="mt-1 text-xs text-signal-danger">{errors.summary.message}</p>}
<p className="mt-1 text-xs text-ink-faint">
Scris de tine nu există încă un AI Gateway care genereze acest rezumat automat.
</p>
</div>
{createBrief.isError && (
<p className="text-sm text-signal-danger">
{createBrief.error instanceof Error ? createBrief.error.message : 'Eroare'}
</p>
)}
<div className="flex justify-end gap-3">
<button type="button" onClick={onClose} className="btn-ghost">
Anulează
</button>
<button type="submit" disabled={isSubmitting || createBrief.isPending} className="btn-primary">
Salvează
</button>
</div>
</form>
</div>
</div>
);
}

View file

@ -0,0 +1,131 @@
'use client';
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { apiFetch, type CompanySearchResponse, type CompanySearchResult } from '../../../lib/api';
import { useSession } from '../../../components/session-provider';
import { BriefForm } from './brief-form';
export default function CompaniesPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const queryClient = useQueryClient();
const [query, setQuery] = useState('');
const [results, setResults] = useState<CompanySearchResult[] | null>(null);
const [segmentName, setSegmentName] = useState('');
const [briefTarget, setBriefTarget] = useState<CompanySearchResult | null>(null);
const search = useMutation({
mutationFn: (q: string) =>
apiFetch<CompanySearchResponse>(
`/v1/intelligence/companies/search?q=${encodeURIComponent(q)}&limit=20`,
{ tenantId },
),
onSuccess: (data) => setResults(data.results),
});
const saveSegment = useMutation({
mutationFn: (name: string) =>
apiFetch('/v1/segments', { method: 'POST', tenantId, body: { name, query, resultLimit: 20 } }),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['segments', tenantId] });
setSegmentName('');
},
});
return (
<div className="max-w-4xl">
<div className="mb-6">
<h1 className="font-display text-2xl font-semibold text-ink">Companii</h1>
<p className="text-sm text-ink-faint">Căutare în datasetul Apollo, prin Intelligence API.</p>
</div>
<form
onSubmit={(event) => {
event.preventDefault();
if (query.trim().length >= 2) {
search.mutate(query.trim());
}
}}
className="card mb-6 flex gap-3 p-4"
>
<input
type="text"
className="field"
placeholder="Nume companie sau domeniu (ex. acme.com)"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<button type="submit" disabled={search.isPending} className="btn-primary shrink-0">
Caută
</button>
</form>
{search.isError && (
<p className="mb-4 text-sm text-signal-danger">
{search.error instanceof Error ? search.error.message : 'Eroare la căutare'}
</p>
)}
{results && (
<>
<div className="mb-4 flex items-center gap-3">
<input
type="text"
className="field"
placeholder="Nume pentru segmentul salvat"
value={segmentName}
onChange={(event) => setSegmentName(event.target.value)}
/>
<button
type="button"
disabled={!segmentName.trim() || saveSegment.isPending}
onClick={() => saveSegment.mutate(segmentName.trim())}
className="btn-ghost shrink-0"
>
Salvează căutarea
</button>
</div>
{results.length === 0 && (
<p className="card p-6 text-sm text-ink-faint">Niciun rezultat pentru această căutare.</p>
)}
<ul className="space-y-2">
{results.map((company) => (
<li key={company.organization_id} className="card flex items-center justify-between p-4">
<div>
<p className="text-sm font-medium text-ink">
{company.organization_name || company.normalized_domain || company.organization_id}
</p>
<p className="text-xs text-ink-faint">
{[company.normalized_domain, company.hq_city, company.hq_country]
.filter(Boolean)
.join(' · ')}
{company.num_current_employees ? ` · ${company.num_current_employees} angajați` : ''}
</p>
</div>
<button
type="button"
onClick={() => setBriefTarget(company)}
className="btn-ghost shrink-0 text-xs"
>
Research brief
</button>
</li>
))}
</ul>
</>
)}
{briefTarget && (
<BriefForm
tenantId={tenantId}
organizationId={briefTarget.organization_id}
organizationName={briefTarget.organization_name || briefTarget.normalized_domain}
onClose={() => setBriefTarget(null)}
/>
)}
</div>
);
}

View file

@ -2,22 +2,16 @@
import { useQuery } from '@tanstack/react-query';
import Link from 'next/link';
import { apiFetch, type Organization, type Task } from '../../lib/api';
import { apiFetch, type Briefing } from '../../lib/api';
import { useSession } from '../../components/session-provider';
export default function OverviewPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const { data: organizations = [] } = useQuery({
queryKey: ['organizations', tenantId],
queryFn: () => apiFetch<Organization[]>('/v1/organizations', { tenantId }),
enabled: Boolean(tenantId),
});
const { data: openTasks = [] } = useQuery({
queryKey: ['tasks', tenantId, 'open'],
queryFn: () => apiFetch<Task[]>('/v1/tasks?status=open', { tenantId }),
const { data: briefing, isLoading } = useQuery({
queryKey: ['briefing', tenantId],
queryFn: () => apiFetch<Briefing>('/v1/briefing/today', { tenantId }),
enabled: Boolean(tenantId),
});
@ -26,16 +20,83 @@ export default function OverviewPage() {
<h1 className="mb-1 font-display text-2xl font-semibold text-ink">
Bine ai revenit{activeTenant ? `, ${activeTenant.tenantName}` : ''}
</h1>
<p className="mb-8 text-sm text-ink-faint">Prezentarea generală a workspace-ului activ.</p>
<p className="mb-8 text-sm text-ink-faint">Briefingul zilei prioritizare deterministă.</p>
<div className="grid grid-cols-2 gap-4">
<Link href="/dashboard/organizations" className="card block p-5 transition-shadow hover:shadow-overlay">
<p className="label">Companii</p>
<p className="font-display text-3xl font-semibold text-ink">{organizations.length}</p>
{isLoading && <p className="text-sm text-ink-faint">Se încarcă</p>}
{briefing && (
<div className="space-y-6">
<div className="grid grid-cols-3 gap-4">
<div className="card p-5">
<p className="label">Companii noi (7 zile)</p>
<p className="font-display text-3xl font-semibold text-ink">
{briefing.weekInReview.newOrganizations}
</p>
</div>
<div className="card p-5">
<p className="label">Segmente noi</p>
<p className="font-display text-3xl font-semibold text-ink">
{briefing.weekInReview.newSegments}
</p>
</div>
<div className="card p-5">
<p className="label">Research briefs noi</p>
<p className="font-display text-3xl font-semibold text-ink">
{briefing.weekInReview.newResearchBriefs}
</p>
</div>
</div>
{briefing.overdueTasks.length > 0 && (
<section>
<h2 className="label mb-2">Sarcini restante</h2>
<ul className="space-y-2">
{briefing.overdueTasks.map((task) => (
<li key={task.id} className="card flex items-center justify-between p-4">
<span className="text-sm font-medium text-ink">{task.title}</span>
<span className="text-xs text-signal-danger">
{task.dueAt && new Date(task.dueAt).toLocaleDateString('ro-RO')}
</span>
</li>
))}
</ul>
</section>
)}
<section>
<h2 className="label mb-2">Următoarele 7 zile</h2>
{briefing.upcomingTasks.length === 0 ? (
<p className="card p-6 text-sm text-ink-faint">Nimic programat în următoarea săptămână.</p>
) : (
<ul className="space-y-2">
{briefing.upcomingTasks.map((task) => (
<li key={task.id} className="card flex items-center justify-between p-4">
<span className="text-sm font-medium text-ink">{task.title}</span>
<span className="text-xs text-ink-faint">
{task.dueAt && new Date(task.dueAt).toLocaleDateString('ro-RO')}
</span>
</li>
))}
</ul>
)}
</section>
<p className="text-xs text-ink-faint">
Prioritizare deterministă din task-uri. Explicația AI nu e încă disponibilă (AI Gateway
neconstruit).
</p>
</div>
)}
<div className="mt-8 grid grid-cols-3 gap-4">
<Link href="/dashboard/companies" className="card block p-4 text-sm font-medium text-ink hover:shadow-overlay">
Caută companii
</Link>
<Link href="/dashboard/tasks" className="card block p-5 transition-shadow hover:shadow-overlay">
<p className="label">Sarcini deschise</p>
<p className="font-display text-3xl font-semibold text-ink">{openTasks.length}</p>
<Link href="/dashboard/segments" className="card block p-4 text-sm font-medium text-ink hover:shadow-overlay">
Segmente salvate
</Link>
<Link href="/dashboard/research" className="card block p-4 text-sm font-medium text-ink hover:shadow-overlay">
Research briefs
</Link>
</div>
</div>

View file

@ -0,0 +1,75 @@
'use client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiFetch, type ResearchBrief } from '../../../lib/api';
import { useSession } from '../../../components/session-provider';
export default function ResearchPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const queryClient = useQueryClient();
const { data: briefs = [], isLoading } = useQuery({
queryKey: ['research-briefs', tenantId],
queryFn: () => apiFetch<ResearchBrief[]>('/v1/research-briefs', { tenantId }),
enabled: Boolean(tenantId),
});
const removeBrief = useMutation({
mutationFn: (id: string) => apiFetch(`/v1/research-briefs/${id}`, { method: 'DELETE', tenantId }),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['research-briefs', tenantId] });
},
});
return (
<div className="max-w-3xl">
<div className="mb-6">
<h1 className="font-display text-2xl font-semibold text-ink">Research briefs</h1>
<p className="text-sm text-ink-faint">
Dovezi curatate despre companii, cu surse. Se creează din pagina Companii.
</p>
</div>
{isLoading && <p className="text-sm text-ink-faint">Se încarcă</p>}
{!isLoading && briefs.length === 0 && (
<p className="card p-6 text-sm text-ink-faint">Niciun research brief încă.</p>
)}
<ul className="space-y-3">
{briefs.map((brief) => (
<li key={brief.id} className="card p-5">
<div className="mb-2 flex items-start justify-between gap-4">
<div>
<p className="text-sm font-medium text-ink">{brief.title}</p>
<p className="text-xs text-ink-faint">{brief.organizationName ?? brief.organizationId}</p>
</div>
<button
type="button"
onClick={() => removeBrief.mutate(brief.id)}
className="shrink-0 text-xs font-medium text-signal-danger hover:underline"
>
Șterge
</button>
</div>
<p className="whitespace-pre-wrap text-sm text-ink-soft">{brief.summary}</p>
<ul className="mt-3 flex flex-wrap gap-2">
{brief.sources.map((source) => (
<li key={source.url}>
<a
href={source.url}
target="_blank"
rel="noopener noreferrer"
className="rounded-full bg-paper-sunken px-2.5 py-1 text-xs text-bronze-deep hover:bg-bronze-wash"
>
{source.label}
</a>
</li>
))}
</ul>
</li>
))}
</ul>
</div>
);
}

View file

@ -0,0 +1,94 @@
'use client';
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiFetch, type CompanySearchResponse, type SavedSegment } from '../../../lib/api';
import { useSession } from '../../../components/session-provider';
export default function SegmentsPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const queryClient = useQueryClient();
const [runResults, setRunResults] = useState<{ segmentId: string; data: CompanySearchResponse } | null>(
null,
);
const { data: segments = [], isLoading } = useQuery({
queryKey: ['segments', tenantId],
queryFn: () => apiFetch<SavedSegment[]>('/v1/segments', { tenantId }),
enabled: Boolean(tenantId),
});
const runSegment = useMutation({
mutationFn: (id: string) =>
apiFetch<{ segment: SavedSegment } & CompanySearchResponse>(`/v1/segments/${id}/run`, { tenantId }),
onSuccess: (data, id) => setRunResults({ segmentId: id, data }),
});
const removeSegment = useMutation({
mutationFn: (id: string) => apiFetch(`/v1/segments/${id}`, { method: 'DELETE', tenantId }),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['segments', tenantId] });
setRunResults(null);
},
});
return (
<div className="max-w-4xl">
<div className="mb-6">
<h1 className="font-display text-2xl font-semibold text-ink">Segmente salvate</h1>
<p className="text-sm text-ink-faint">Căutări de companii salvate, reluabile oricând.</p>
</div>
{isLoading && <p className="text-sm text-ink-faint">Se încarcă</p>}
{!isLoading && segments.length === 0 && (
<p className="card p-6 text-sm text-ink-faint">
Niciun segment salvat încă. Salvează o căutare din pagina Companii.
</p>
)}
<ul className="space-y-2">
{segments.map((segment) => (
<li key={segment.id} className="card p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-ink">{segment.name}</p>
<p className="text-xs text-ink-faint">&ldquo;{segment.query}&rdquo;</p>
</div>
<div className="flex shrink-0 gap-2">
<button
type="button"
onClick={() => runSegment.mutate(segment.id)}
disabled={runSegment.isPending}
className="btn-ghost text-xs"
>
Rulează din nou
</button>
<button
type="button"
onClick={() => removeSegment.mutate(segment.id)}
className="text-xs font-medium text-signal-danger hover:underline"
>
Șterge
</button>
</div>
</div>
{runResults?.segmentId === segment.id && (
<ul className="mt-3 space-y-1.5 border-t border-ink-line pt-3">
{runResults.data.results.length === 0 && (
<li className="text-xs text-ink-faint">Niciun rezultat.</li>
)}
{runResults.data.results.map((company) => (
<li key={company.organization_id} className="text-xs text-ink-soft">
{company.organization_name || company.normalized_domain || company.organization_id}
</li>
))}
</ul>
)}
</li>
))}
</ul>
</div>
);
}

View file

@ -7,9 +7,12 @@ import { useSession } from './session-provider';
import { TenantSwitcher } from './tenant-switcher';
const NAV_ITEMS = [
{ href: '/dashboard', label: 'Prezentare generală' },
{ href: '/dashboard/organizations', label: 'Companii' },
{ href: '/dashboard', label: 'Briefing' },
{ href: '/dashboard/organizations', label: 'Organizațiile mele' },
{ href: '/dashboard/tasks', label: 'Sarcini' },
{ href: '/dashboard/companies', label: 'Căutare companii' },
{ href: '/dashboard/segments', label: 'Segmente' },
{ href: '/dashboard/research', label: 'Research' },
{ href: '/dashboard/members', label: 'Echipă' },
];

View file

@ -103,3 +103,54 @@ export interface TenantMember {
role: 'owner' | 'admin' | 'member';
createdAt: string;
}
export interface CompanySearchResult {
organization_id: string;
organization_name: string;
normalized_domain: string;
hq_city: string;
hq_country: string;
industries: string[];
num_current_employees: number | null;
revenue_in_thousands: number | null;
}
export interface CompanySearchResponse {
results: CompanySearchResult[];
count: number;
}
export interface SavedSegment {
id: string;
name: string;
query: string;
resultLimit: number;
createdAt: string;
}
export interface Source {
label: string;
url: string;
}
export interface ResearchBrief {
id: string;
organizationId: string;
organizationName: string | null;
title: string;
summary: string;
sources: Source[];
createdAt: string;
}
export interface Briefing {
generatedAt: string;
overdueTasks: Task[];
upcomingTasks: Task[];
weekInReview: {
newOrganizations: number;
newSegments: number;
newResearchBriefs: number;
};
aiExplanation: string | null;
}