feat(web): telas de negócio reais no EDEN Core (Empresas, Produtos, Revendas, CRM, Ofertas)
Prioriza a parte web a pedido do usuário. 2 subagentes eden-frontend em paralelo sobre o shell autenticado já pronto: - Empresas/Produtos/Revendas: CRUD completo (lista + criar/editar), layout modelado nas páginas reais do tema DreamsERP comprado (purchase/vendors.tsx, inventory/products.tsx) — máscara de moeda para as 5 faixas de preço + implantação. Novo componente Table<T> genérico em packages/ui. - CRM: Leads (lista, criar/editar, converter em oportunidade) e Oportunidades, modelados em crm/leads.tsx/dealsDetails.tsx. - Ofertas/Quotes: o módulo mais rico — criação com lista dinâmica de itens produto/quantidade, fidelidade reduzida opcional, desconto opcional, totais calculados pelo servidor; detalhe com aprovar/rejeitar e fechar negócio. Modelado em sales/salesQuotes*.tsx. Novo componente Select em packages/ui. Correção de instrução no meio da leva: usuário pediu para seguir de perto o layout real do tema comprado, não só os tokens visuais já extraídos — as páginas React relevantes do tema foram extraídas para .tmp/theme-reference/ (nunca versionado) em cada worktree e os agentes já em execução foram redirecionados via mensagem (não relançados). Bug real do próprio agente, corrigindo meu brief: Leads/Opportunities respondem em camelCase (mapLead/mapOpportunity), não snake_case como o resto da API — verificado contra o código-fonte real antes de aceitar. Integração manual: um dos 2 agentes não conseguiu git merge no próprio sandbox e restaurou o shell existente por leitura+escrita — cada arquivo restaurado foi comparado byte a byte contra main (diff -rq) antes de aceitar. App.tsx/AuthenticatedLayout.tsx/packages/ui/index.ts reconciliados manualmente juntando as rotas/exports dos dois agentes. Verificado: pnpm turbo run typecheck build (28/28); containers eden-api/eden-core reconstruídos; bundle de produção contém todas as novas telas; login simulando Origin de browser real; listagens retornando dados reais; criação de lead confirmando o formato camelCase. Claude-Session: https://claude.ai/code/session_01BcWWuKfykGtUweXmHD6cKx Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
138
apps/core-web/src/routes/crm/ConvertLeadPage.tsx
Normal file
138
apps/core-web/src/routes/crm/ConvertLeadPage.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { Alert, Button, Card, Input, PageHeader, Select } from "@eden/ui";
|
||||
import { useAuth } from "../../lib/auth/AuthContext.js";
|
||||
import { ApiError } from "../../lib/api/http.js";
|
||||
import { convertLead, getLead } from "../../lib/api/leads.js";
|
||||
import type { Lead } from "../../lib/api/leads.js";
|
||||
import { OPPORTUNITY_STAGES } from "../../lib/api/opportunities.js";
|
||||
import type { OpportunityStage } from "../../lib/api/opportunities.js";
|
||||
import { Breadcrumb } from "../../components/Breadcrumb.js";
|
||||
|
||||
const STAGE_LABEL: Record<OpportunityStage, string> = {
|
||||
prospecting: "Prospecção",
|
||||
qualification: "Qualificação",
|
||||
proposal: "Proposta",
|
||||
negotiation: "Negociação",
|
||||
won: "Ganha",
|
||||
lost: "Perdida",
|
||||
};
|
||||
|
||||
const STAGE_OPTIONS = OPPORTUNITY_STAGES.map((stage) => ({ value: stage, label: STAGE_LABEL[stage] }));
|
||||
|
||||
export function ConvertLeadPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { accessToken } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [lead, setLead] = useState<Lead | null>(null);
|
||||
const [title, setTitle] = useState("");
|
||||
const [stage, setStage] = useState<OpportunityStage>("prospecting");
|
||||
const [probability, setProbability] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id || !accessToken) return;
|
||||
setLoading(true);
|
||||
getLead(id, accessToken)
|
||||
.then((result) => {
|
||||
setLead(result);
|
||||
setTitle(`${result.contactName}${result.companyName ? ` — ${result.companyName}` : ""}`);
|
||||
})
|
||||
.catch((err) => setError(err instanceof ApiError ? err.message : "Não foi possível carregar o lead."))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id, accessToken]);
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!accessToken || !id) return;
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const result = await convertLead(
|
||||
id,
|
||||
{
|
||||
title,
|
||||
stage,
|
||||
probability: probability ? Number(probability) : undefined,
|
||||
notes: notes || undefined,
|
||||
},
|
||||
accessToken,
|
||||
);
|
||||
navigate(`/opportunities/${result.opportunity.id}/edit`, { replace: true });
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Não foi possível converter o lead.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Breadcrumb items={[{ label: "Leads", href: "/leads" }, { label: "Converter em oportunidade" }]} />
|
||||
<PageHeader
|
||||
title="Converter lead em oportunidade"
|
||||
description={lead ? `Lead: ${lead.contactName}` : undefined}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<Alert variant="error" className="mb-4">
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
{loading ? (
|
||||
<p className="text-sm text-default">Carregando lead…</p>
|
||||
) : (
|
||||
<form className="grid grid-cols-1 gap-4 sm:grid-cols-2" onSubmit={handleSubmit}>
|
||||
<Input
|
||||
label="Título da oportunidade"
|
||||
className="sm:col-span-2"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<Select
|
||||
label="Estágio"
|
||||
options={STAGE_OPTIONS}
|
||||
value={stage}
|
||||
onChange={(e) => setStage(e.target.value as OpportunityStage)}
|
||||
/>
|
||||
<Input
|
||||
label="Probabilidade (%)"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={probability}
|
||||
onChange={(e) => setProbability(e.target.value)}
|
||||
/>
|
||||
<div className="sm:col-span-2 flex flex-col gap-1">
|
||||
<label className="text-sm font-semibold text-gray-900">Notas</label>
|
||||
<textarea
|
||||
className="w-full rounded-md border border-border-color bg-white px-3 py-2.5 text-sm text-title focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||
rows={4}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-2 flex items-center gap-2 pt-2">
|
||||
<Button type="submit" loading={submitting}>
|
||||
Converter
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => navigate("/leads")}>
|
||||
Cancelar
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
apps/core-web/src/routes/crm/CrmSubNav.tsx
Normal file
35
apps/core-web/src/routes/crm/CrmSubNav.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
|
||||
/**
|
||||
* Only two top-level nav slots were allocated for CRM ("CRM" → /leads,
|
||||
* per AuthenticatedLayout.tsx) but the module has two screens (Leads,
|
||||
* Oportunidades) — this small in-page toggle (visual reference: the
|
||||
* list/grid view switch in `.tmp/theme-reference/crm/leads.tsx`,
|
||||
* `inline-flex bg-white border border-border-color rounded-md p-1`) lets
|
||||
* users move between them without a second top-level nav entry.
|
||||
*/
|
||||
export function CrmSubNav() {
|
||||
const location = useLocation();
|
||||
const onLeads = location.pathname.startsWith("/leads");
|
||||
|
||||
return (
|
||||
<div className="mb-4 inline-flex rounded-md border border-border-color bg-white p-1">
|
||||
<Link
|
||||
to="/leads"
|
||||
className={`rounded px-3 py-1.5 text-sm font-semibold ${
|
||||
onLeads ? "bg-dark text-white" : "text-default hover:bg-light"
|
||||
}`}
|
||||
>
|
||||
Leads
|
||||
</Link>
|
||||
<Link
|
||||
to="/opportunities"
|
||||
className={`rounded px-3 py-1.5 text-sm font-semibold ${
|
||||
!onLeads ? "bg-dark text-white" : "text-default hover:bg-light"
|
||||
}`}
|
||||
>
|
||||
Oportunidades
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
144
apps/core-web/src/routes/crm/LeadFormPage.tsx
Normal file
144
apps/core-web/src/routes/crm/LeadFormPage.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { Alert, Button, Card, Input, PageHeader, Select } from "@eden/ui";
|
||||
import { useAuth } from "../../lib/auth/AuthContext.js";
|
||||
import { ApiError } from "../../lib/api/http.js";
|
||||
import { createLead, getLead, LEAD_STATUSES, updateLead } from "../../lib/api/leads.js";
|
||||
import type { LeadStatus } from "../../lib/api/leads.js";
|
||||
import { Breadcrumb } from "../../components/Breadcrumb.js";
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
new: "Novo",
|
||||
contacted: "Contatado",
|
||||
qualified: "Qualificado",
|
||||
converted: "Convertido",
|
||||
discarded: "Descartado",
|
||||
};
|
||||
|
||||
const STATUS_OPTIONS = LEAD_STATUSES.map((status) => ({ value: status, label: STATUS_LABEL[status] ?? status }));
|
||||
|
||||
export function LeadFormPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const isEdit = Boolean(id);
|
||||
const { accessToken } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [source, setSource] = useState("");
|
||||
const [contactName, setContactName] = useState("");
|
||||
const [contactEmail, setContactEmail] = useState("");
|
||||
const [contactPhone, setContactPhone] = useState("");
|
||||
const [companyName, setCompanyName] = useState("");
|
||||
const [status, setStatus] = useState<LeadStatus>("new");
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
const [loading, setLoading] = useState(isEdit);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id || !accessToken) return;
|
||||
setLoading(true);
|
||||
getLead(id, accessToken)
|
||||
.then((lead) => {
|
||||
setSource(lead.source ?? "");
|
||||
setContactName(lead.contactName);
|
||||
setContactEmail(lead.contactEmail ?? "");
|
||||
setContactPhone(lead.contactPhone ?? "");
|
||||
setCompanyName(lead.companyName ?? "");
|
||||
setStatus((lead.status as LeadStatus) ?? "new");
|
||||
setNotes(lead.notes ?? "");
|
||||
})
|
||||
.catch((err) => setError(err instanceof ApiError ? err.message : "Não foi possível carregar o lead."))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id, accessToken]);
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!accessToken) return;
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const input = {
|
||||
source: source || undefined,
|
||||
contactName,
|
||||
contactEmail: contactEmail || undefined,
|
||||
contactPhone: contactPhone || undefined,
|
||||
companyName: companyName || undefined,
|
||||
status,
|
||||
notes: notes || undefined,
|
||||
};
|
||||
if (isEdit && id) {
|
||||
await updateLead(id, input, accessToken);
|
||||
} else {
|
||||
await createLead(input, accessToken);
|
||||
}
|
||||
navigate("/leads", { replace: true });
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Não foi possível salvar o lead.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Breadcrumb items={[{ label: "Leads", href: "/leads" }, { label: isEdit ? "Editar lead" : "Novo lead" }]} />
|
||||
<PageHeader title={isEdit ? "Editar lead" : "Novo lead"} />
|
||||
|
||||
{error ? (
|
||||
<Alert variant="error" className="mb-4">
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
{loading ? (
|
||||
<p className="text-sm text-default">Carregando lead…</p>
|
||||
) : (
|
||||
<form className="grid grid-cols-1 gap-4 sm:grid-cols-2" onSubmit={handleSubmit}>
|
||||
<Input
|
||||
label="Nome de contato"
|
||||
value={contactName}
|
||||
onChange={(e) => setContactName(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<Input label="Empresa" value={companyName} onChange={(e) => setCompanyName(e.target.value)} />
|
||||
<Input
|
||||
label="E-mail"
|
||||
type="email"
|
||||
value={contactEmail}
|
||||
onChange={(e) => setContactEmail(e.target.value)}
|
||||
/>
|
||||
<Input label="Telefone" value={contactPhone} onChange={(e) => setContactPhone(e.target.value)} />
|
||||
<Input label="Origem" value={source} onChange={(e) => setSource(e.target.value)} hint="Ex.: Site, indicação, campanha" />
|
||||
<Select
|
||||
label="Status"
|
||||
options={STATUS_OPTIONS}
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value as LeadStatus)}
|
||||
/>
|
||||
<div className="sm:col-span-2 flex flex-col gap-1">
|
||||
<label className="text-sm font-semibold text-gray-900">Notas</label>
|
||||
<textarea
|
||||
className="w-full rounded-md border border-border-color bg-white px-3 py-2.5 text-sm text-title focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||
rows={4}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-2 flex items-center gap-2 pt-2">
|
||||
<Button type="submit" loading={submitting}>
|
||||
{isEdit ? "Salvar alterações" : "Criar lead"}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => navigate("/leads")}>
|
||||
Cancelar
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
137
apps/core-web/src/routes/crm/LeadsListPage.tsx
Normal file
137
apps/core-web/src/routes/crm/LeadsListPage.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Alert, Badge, Card, PageHeader } from "@eden/ui";
|
||||
import type { BadgeVariant } from "@eden/ui";
|
||||
import { useAuth } from "../../lib/auth/AuthContext.js";
|
||||
import { ApiError } from "../../lib/api/http.js";
|
||||
import { listLeads } from "../../lib/api/leads.js";
|
||||
import type { Lead } from "../../lib/api/leads.js";
|
||||
import { PRIMARY_LINK_BUTTON_CLASS } from "../../components/linkButtonClasses.js";
|
||||
import { CrmSubNav } from "./CrmSubNav.js";
|
||||
|
||||
/**
|
||||
* Layout reference: DreamsERP theme `.tmp/theme-reference/crm/leads.tsx`
|
||||
* (list header with title + "Create" action, flush data table inside a
|
||||
* bordered white card, colored status badges) — rebuilt here with
|
||||
* `@eden/ui` (`PageHeader`, `Card`, `Badge`, `Button`) + plain Tailwind
|
||||
* instead of the theme's antd Datatable/react-select, which are outside
|
||||
* our stack.
|
||||
*/
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
new: "Novo",
|
||||
contacted: "Contatado",
|
||||
qualified: "Qualificado",
|
||||
converted: "Convertido",
|
||||
discarded: "Descartado",
|
||||
};
|
||||
|
||||
const STATUS_VARIANT: Record<string, BadgeVariant> = {
|
||||
new: "info",
|
||||
contacted: "warning",
|
||||
qualified: "primary",
|
||||
converted: "success",
|
||||
discarded: "danger",
|
||||
};
|
||||
|
||||
export function LeadsListPage() {
|
||||
const { accessToken } = useAuth();
|
||||
const [leads, setLeads] = useState<Lead[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken) return;
|
||||
let cancelled = false;
|
||||
setError(null);
|
||||
listLeads(accessToken)
|
||||
.then((result) => {
|
||||
if (!cancelled) setLeads(result);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(err instanceof ApiError ? err.message : "Não foi possível carregar os leads.");
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [accessToken]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CrmSubNav />
|
||||
<PageHeader
|
||||
title="Leads"
|
||||
description="Prospecção comercial — CRM"
|
||||
actions={
|
||||
<Link to="/leads/new" className={PRIMARY_LINK_BUTTON_CLASS}>
|
||||
Novo lead
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<Alert variant="error" className="mb-4">
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Card bodyClassName="p-0">
|
||||
{leads === null && !error ? (
|
||||
<p className="p-5 text-sm text-default">Carregando leads…</p>
|
||||
) : leads && leads.length === 0 ? (
|
||||
<p className="p-5 text-sm text-default">Nenhum lead cadastrado ainda.</p>
|
||||
) : leads ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border-color text-left">
|
||||
<th className="px-5 py-3 font-semibold text-title">Contato</th>
|
||||
<th className="px-5 py-3 font-semibold text-title">Empresa</th>
|
||||
<th className="px-5 py-3 font-semibold text-title">Status</th>
|
||||
<th className="px-5 py-3 font-semibold text-title">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{leads.map((lead) => {
|
||||
const canConvert = lead.status !== "converted" && lead.status !== "discarded";
|
||||
return (
|
||||
<tr key={lead.id} className="border-b border-border-color last:border-0 hover:bg-light">
|
||||
<td className="px-5 py-3">
|
||||
<Link to={`/leads/${lead.id}/edit`} className="font-semibold text-title hover:text-primary">
|
||||
{lead.contactName}
|
||||
</Link>
|
||||
{lead.contactEmail ? <p className="text-xs text-default">{lead.contactEmail}</p> : null}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-default">{lead.companyName ?? "—"}</td>
|
||||
<td className="px-5 py-3">
|
||||
<Badge variant={STATUS_VARIANT[lead.status] ?? "neutral"}>
|
||||
{STATUS_LABEL[lead.status] ?? lead.status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link
|
||||
to={`/leads/${lead.id}/edit`}
|
||||
className="text-xs font-semibold text-default hover:text-primary"
|
||||
>
|
||||
Editar
|
||||
</Link>
|
||||
{canConvert ? (
|
||||
<Link
|
||||
to={`/leads/${lead.id}/convert`}
|
||||
className="rounded-md border border-border-color px-2 py-1 text-xs font-semibold text-title hover:bg-light"
|
||||
>
|
||||
Converter em oportunidade
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
130
apps/core-web/src/routes/crm/OpportunitiesListPage.tsx
Normal file
130
apps/core-web/src/routes/crm/OpportunitiesListPage.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Alert, Badge, Card, PageHeader } from "@eden/ui";
|
||||
import type { BadgeVariant } from "@eden/ui";
|
||||
import { useAuth } from "../../lib/auth/AuthContext.js";
|
||||
import { ApiError } from "../../lib/api/http.js";
|
||||
import { listOpportunities } from "../../lib/api/opportunities.js";
|
||||
import type { Opportunity } from "../../lib/api/opportunities.js";
|
||||
import { PRIMARY_LINK_BUTTON_CLASS } from "../../components/linkButtonClasses.js";
|
||||
import { CrmSubNav } from "./CrmSubNav.js";
|
||||
|
||||
/**
|
||||
* Layout reference: DreamsERP theme `.tmp/theme-reference/crm/deals.tsx`
|
||||
* (kanban-oriented in the theme; kept as a flat table here for MVP scope,
|
||||
* same header/card/table language as `LeadsListPage`).
|
||||
*/
|
||||
const STAGE_LABEL: Record<string, string> = {
|
||||
prospecting: "Prospecção",
|
||||
qualification: "Qualificação",
|
||||
proposal: "Proposta",
|
||||
negotiation: "Negociação",
|
||||
won: "Ganha",
|
||||
lost: "Perdida",
|
||||
};
|
||||
|
||||
const STAGE_VARIANT: Record<string, BadgeVariant> = {
|
||||
prospecting: "info",
|
||||
qualification: "primary",
|
||||
proposal: "warning",
|
||||
negotiation: "secondary",
|
||||
won: "success",
|
||||
lost: "danger",
|
||||
};
|
||||
|
||||
export function OpportunitiesListPage() {
|
||||
const { accessToken } = useAuth();
|
||||
const [opportunities, setOpportunities] = useState<Opportunity[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken) return;
|
||||
let cancelled = false;
|
||||
setError(null);
|
||||
listOpportunities(accessToken)
|
||||
.then((result) => {
|
||||
if (!cancelled) setOpportunities(result);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled)
|
||||
setError(err instanceof ApiError ? err.message : "Não foi possível carregar as oportunidades.");
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [accessToken]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CrmSubNav />
|
||||
<PageHeader
|
||||
title="Oportunidades"
|
||||
description="Funil comercial — CRM"
|
||||
actions={
|
||||
<Link to="/opportunities/new" className={PRIMARY_LINK_BUTTON_CLASS}>
|
||||
Nova oportunidade
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<Alert variant="error" className="mb-4">
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Card bodyClassName="p-0">
|
||||
{opportunities === null && !error ? (
|
||||
<p className="p-5 text-sm text-default">Carregando oportunidades…</p>
|
||||
) : opportunities && opportunities.length === 0 ? (
|
||||
<p className="p-5 text-sm text-default">Nenhuma oportunidade cadastrada ainda.</p>
|
||||
) : opportunities ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border-color text-left">
|
||||
<th className="px-5 py-3 font-semibold text-title">Título</th>
|
||||
<th className="px-5 py-3 font-semibold text-title">Estágio</th>
|
||||
<th className="px-5 py-3 font-semibold text-title">Probabilidade</th>
|
||||
<th className="px-5 py-3 font-semibold text-title">Dono</th>
|
||||
<th className="px-5 py-3 font-semibold text-title">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{opportunities.map((opportunity) => (
|
||||
<tr key={opportunity.id} className="border-b border-border-color last:border-0 hover:bg-light">
|
||||
<td className="px-5 py-3">
|
||||
<Link
|
||||
to={`/opportunities/${opportunity.id}/edit`}
|
||||
className="font-semibold text-title hover:text-primary"
|
||||
>
|
||||
{opportunity.title}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<Badge variant={STAGE_VARIANT[opportunity.stage] ?? "neutral"}>
|
||||
{STAGE_LABEL[opportunity.stage] ?? opportunity.stage}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-default">
|
||||
{opportunity.probability != null ? `${opportunity.probability}%` : "—"}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-default">{opportunity.ownerId ?? "—"}</td>
|
||||
<td className="px-5 py-3">
|
||||
<Link
|
||||
to={`/opportunities/${opportunity.id}/edit`}
|
||||
className="text-xs font-semibold text-default hover:text-primary"
|
||||
>
|
||||
Editar
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
157
apps/core-web/src/routes/crm/OpportunityFormPage.tsx
Normal file
157
apps/core-web/src/routes/crm/OpportunityFormPage.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { Alert, Button, Card, Input, PageHeader, Select } from "@eden/ui";
|
||||
import { useAuth } from "../../lib/auth/AuthContext.js";
|
||||
import { ApiError } from "../../lib/api/http.js";
|
||||
import {
|
||||
createOpportunity,
|
||||
getOpportunity,
|
||||
OPPORTUNITY_STAGES,
|
||||
updateOpportunity,
|
||||
} from "../../lib/api/opportunities.js";
|
||||
import type { OpportunityStage } from "../../lib/api/opportunities.js";
|
||||
import { Breadcrumb } from "../../components/Breadcrumb.js";
|
||||
|
||||
const STAGE_LABEL: Record<OpportunityStage, string> = {
|
||||
prospecting: "Prospecção",
|
||||
qualification: "Qualificação",
|
||||
proposal: "Proposta",
|
||||
negotiation: "Negociação",
|
||||
won: "Ganha",
|
||||
lost: "Perdida",
|
||||
};
|
||||
|
||||
const STAGE_OPTIONS = OPPORTUNITY_STAGES.map((stage) => ({ value: stage, label: STAGE_LABEL[stage] }));
|
||||
|
||||
export function OpportunityFormPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const isEdit = Boolean(id);
|
||||
const { accessToken } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [title, setTitle] = useState("");
|
||||
const [stage, setStage] = useState<OpportunityStage>("prospecting");
|
||||
const [probability, setProbability] = useState("");
|
||||
const [lossReason, setLossReason] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
const [loading, setLoading] = useState(isEdit);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id || !accessToken) return;
|
||||
setLoading(true);
|
||||
getOpportunity(id, accessToken)
|
||||
.then((opportunity) => {
|
||||
setTitle(opportunity.title);
|
||||
setStage((opportunity.stage as OpportunityStage) ?? "prospecting");
|
||||
setProbability(opportunity.probability != null ? String(opportunity.probability) : "");
|
||||
setLossReason(opportunity.lossReason ?? "");
|
||||
setNotes(opportunity.notes ?? "");
|
||||
})
|
||||
.catch((err) => setError(err instanceof ApiError ? err.message : "Não foi possível carregar a oportunidade."))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id, accessToken]);
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!accessToken) return;
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const input = {
|
||||
title,
|
||||
stage,
|
||||
probability: probability ? Number(probability) : undefined,
|
||||
lossReason: stage === "lost" && lossReason ? lossReason : undefined,
|
||||
notes: notes || undefined,
|
||||
};
|
||||
if (isEdit && id) {
|
||||
await updateOpportunity(id, input, accessToken);
|
||||
} else {
|
||||
await createOpportunity(input, accessToken);
|
||||
}
|
||||
navigate("/opportunities", { replace: true });
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Não foi possível salvar a oportunidade.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{ label: "Oportunidades", href: "/opportunities" },
|
||||
{ label: isEdit ? "Editar oportunidade" : "Nova oportunidade" },
|
||||
]}
|
||||
/>
|
||||
<PageHeader title={isEdit ? "Editar oportunidade" : "Nova oportunidade"} />
|
||||
|
||||
{error ? (
|
||||
<Alert variant="error" className="mb-4">
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
{loading ? (
|
||||
<p className="text-sm text-default">Carregando oportunidade…</p>
|
||||
) : (
|
||||
<form className="grid grid-cols-1 gap-4 sm:grid-cols-2" onSubmit={handleSubmit}>
|
||||
<Input
|
||||
label="Título"
|
||||
className="sm:col-span-2"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<Select
|
||||
label="Estágio"
|
||||
options={STAGE_OPTIONS}
|
||||
value={stage}
|
||||
onChange={(e) => setStage(e.target.value as OpportunityStage)}
|
||||
/>
|
||||
<Input
|
||||
label="Probabilidade (%)"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={probability}
|
||||
onChange={(e) => setProbability(e.target.value)}
|
||||
/>
|
||||
{stage === "lost" ? (
|
||||
<Input
|
||||
label="Motivo da perda"
|
||||
className="sm:col-span-2"
|
||||
value={lossReason}
|
||||
onChange={(e) => setLossReason(e.target.value)}
|
||||
/>
|
||||
) : null}
|
||||
<div className="sm:col-span-2 flex flex-col gap-1">
|
||||
<label className="text-sm font-semibold text-gray-900">Notas</label>
|
||||
<textarea
|
||||
className="w-full rounded-md border border-border-color bg-white px-3 py-2.5 text-sm text-title focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||
rows={4}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-2 flex items-center gap-2 pt-2">
|
||||
<Button type="submit" loading={submitting}>
|
||||
{isEdit ? "Salvar alterações" : "Criar oportunidade"}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => navigate("/opportunities")}>
|
||||
Cancelar
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user