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:
2026-09-03 18:31:07 -03:00
parent 4d67fe5f1a
commit f96c465ee5
31 changed files with 3493 additions and 5 deletions

View File

@@ -1,14 +1,28 @@
import { useNavigate } from "react-router-dom";
import { Link, Outlet } from "react-router-dom";
import { Link, Outlet, useLocation } from "react-router-dom";
import { AppShell } from "@eden/ui";
import type { AppShellNavItem } from "@eden/ui";
import { useAuth } from "../lib/auth/AuthContext.js";
const NAV_ITEMS: AppShellNavItem[] = [{ key: "dashboard", label: "Painel", href: "/", active: true }];
/** `href === "/"` only matches the exact dashboard path — every other item matches by exact path or a `/` prefix boundary (so `/companies` never lights up for `/companies-other`). */
function isNavItemActive(href: string, pathname: string): boolean {
if (href === "/") return pathname === "/";
return pathname === href || pathname.startsWith(`${href}/`);
}
export function AuthenticatedLayout() {
const { user, logout } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const navItems: AppShellNavItem[] = [
{ key: "dashboard", label: "Painel", href: "/" },
{ key: "companies", label: "Empresas", href: "/companies" },
{ key: "products", label: "Produtos", href: "/products" },
{ key: "resellers", label: "Revendas", href: "/resellers" },
{ key: "leads", label: "CRM", href: "/leads" },
{ key: "quotes", label: "Ofertas", href: "/quotes" },
].map((item) => ({ ...item, active: isNavItemActive(item.href, location.pathname) }));
async function handleLogout() {
await logout();
@@ -19,7 +33,7 @@ export function AuthenticatedLayout() {
<AppShell
appName="EDEN Core"
logoSrc="/eden-logo-horizontal.png"
navItems={NAV_ITEMS}
navItems={navItems}
userName={user?.fullName ?? user?.email}
userRole={user?.roleKey}
onLogout={handleLogout}

View File

@@ -0,0 +1,103 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Alert, Badge, Button, Card, PageHeader, Table } from "@eden/ui";
import type { TableColumn } from "@eden/ui";
import { listCompanies } from "../../lib/api/companies.js";
import type { Company } from "../../lib/api/companies.js";
import { ApiError } from "../../lib/api/http.js";
import { formatDocument } from "../../lib/format.js";
import { useAuth } from "../../lib/auth/AuthContext.js";
/**
* List layout follows the DreamsERP theme's list screens (react/src/pages/
* purchase/vendors.tsx) — page header with a primary action on the right,
* a white card wrapping a data table — reimplemented with @eden/ui's
* `Card`/`Table`/`Badge` instead of the theme's antd Datatable.
*/
export function CompaniesListPage() {
const { accessToken } = useAuth();
const navigate = useNavigate();
const [companies, setCompanies] = useState<Company[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!accessToken) return;
let cancelled = false;
setError(null);
listCompanies(accessToken)
.then((data) => {
if (!cancelled) setCompanies(data);
})
.catch((err) => {
if (cancelled) return;
setError(err instanceof ApiError ? err.message : "Não foi possível carregar as empresas.");
});
return () => {
cancelled = true;
};
}, [accessToken]);
const columns: Array<TableColumn<Company>> = [
{
key: "name",
header: "Nome",
render: (company) => (
<div>
<p className="font-semibold text-title">{company.company_name}</p>
{company.trade_name ? <p className="text-xs text-default">{company.trade_name}</p> : null}
</div>
),
},
{ key: "cnpj", header: "CNPJ", render: (company) => formatDocument(company.cnpj) },
{
key: "city",
header: "Cidade/UF",
render: (company) =>
company.address_city
? `${company.address_city}${company.address_state ? `/${company.address_state}` : ""}`
: "—",
},
{
key: "status",
header: "Status",
render: (company) => (
<Badge variant={company.is_active ? "success" : "neutral"}>{company.is_active ? "Ativo" : "Inativo"}</Badge>
),
},
{
key: "actions",
header: "",
className: "text-right",
render: (company) => (
<Button variant="secondary" size="sm" onClick={() => navigate(`/companies/${company.id}/edit`)}>
Editar
</Button>
),
},
];
return (
<div>
<PageHeader
title="Empresas"
description="Empresas do grupo cadastradas no EDEN — dados usados em contratos e documentos."
actions={<Button onClick={() => navigate("/companies/new")}>Nova empresa</Button>}
/>
{error ? (
<Alert variant="error" className="mb-4">
{error}
</Alert>
) : null}
<Card>
{companies === null && !error ? (
<p className="text-sm text-default">Carregando…</p>
) : (
<Table columns={columns} data={companies ?? []} rowKey={(company) => company.id} />
)}
</Card>
</div>
);
}

View File

@@ -0,0 +1,306 @@
import { useEffect, useState } from "react";
import type { FormEvent } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { Alert, Button, Card, Input, PageHeader } from "@eden/ui";
import { createCompany, getCompany, updateCompany } from "../../lib/api/companies.js";
import type { CreateCompanyInput } from "../../lib/api/companies.js";
import { ApiError } from "../../lib/api/http.js";
import { useAuth } from "../../lib/auth/AuthContext.js";
interface CompanyFormState extends Record<keyof CreateCompanyInput, string> {
isActive: boolean;
}
const EMPTY_FORM: CompanyFormState = {
companyName: "",
tradeName: "",
cnpj: "",
stateRegistration: "",
municipalRegistration: "",
addressZip: "",
addressStreet: "",
addressNumber: "",
addressComplement: "",
addressNeighborhood: "",
addressCity: "",
addressState: "",
addressCountry: "",
phone: "",
email: "",
website: "",
legalRepName: "",
legalRepCpf: "",
legalRepRole: "",
legalRepEmail: "",
isActive: true,
};
/** `""` → `undefined` so optional DTO fields (e.g. `email?: IsEmail`) aren't sent as empty strings. */
function toOptional(value: string): string | undefined {
const trimmed = value.trim();
return trimmed === "" ? undefined : trimmed;
}
/** Reused for both `/companies/new` and `/companies/:id/edit` — same fields, only the submit action differs. */
export function CompanyFormPage() {
const { id } = useParams<{ id: string }>();
const isEditing = Boolean(id);
const { accessToken } = useAuth();
const navigate = useNavigate();
const [form, setForm] = useState<CompanyFormState>(EMPTY_FORM);
const [loading, setLoading] = useState(isEditing);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (!id || !accessToken) return;
let cancelled = false;
getCompany(id, accessToken)
.then((company) => {
if (cancelled) return;
setForm({
companyName: company.company_name,
tradeName: company.trade_name ?? "",
cnpj: company.cnpj ?? "",
stateRegistration: company.state_registration ?? "",
municipalRegistration: company.municipal_registration ?? "",
addressZip: company.address_zip ?? "",
addressStreet: company.address_street ?? "",
addressNumber: company.address_number ?? "",
addressComplement: company.address_complement ?? "",
addressNeighborhood: company.address_neighborhood ?? "",
addressCity: company.address_city ?? "",
addressState: company.address_state ?? "",
addressCountry: company.address_country ?? "",
phone: company.phone ?? "",
email: company.email ?? "",
website: company.website ?? "",
legalRepName: company.legal_rep_name ?? "",
legalRepCpf: company.legal_rep_cpf ?? "",
legalRepRole: company.legal_rep_role ?? "",
legalRepEmail: company.legal_rep_email ?? "",
isActive: company.is_active,
});
})
.catch((err) => {
if (!cancelled) setError(err instanceof ApiError ? err.message : "Não foi possível carregar a empresa.");
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [id, accessToken]);
function updateField<K extends keyof CompanyFormState>(field: K, value: CompanyFormState[K]) {
setForm((prev) => ({ ...prev, [field]: value }));
}
async function handleSubmit(event: FormEvent) {
event.preventDefault();
if (!accessToken) return;
setError(null);
setSubmitting(true);
try {
const basePayload: CreateCompanyInput = {
companyName: form.companyName.trim(),
tradeName: toOptional(form.tradeName),
cnpj: toOptional(form.cnpj),
stateRegistration: toOptional(form.stateRegistration),
municipalRegistration: toOptional(form.municipalRegistration),
addressZip: toOptional(form.addressZip),
addressStreet: toOptional(form.addressStreet),
addressNumber: toOptional(form.addressNumber),
addressComplement: toOptional(form.addressComplement),
addressNeighborhood: toOptional(form.addressNeighborhood),
addressCity: toOptional(form.addressCity),
addressState: toOptional(form.addressState),
addressCountry: toOptional(form.addressCountry),
phone: toOptional(form.phone),
email: toOptional(form.email),
website: toOptional(form.website),
legalRepName: toOptional(form.legalRepName),
legalRepCpf: toOptional(form.legalRepCpf),
legalRepRole: toOptional(form.legalRepRole),
legalRepEmail: toOptional(form.legalRepEmail),
};
if (isEditing && id) {
await updateCompany(id, { ...basePayload, isActive: form.isActive }, accessToken);
} else {
await createCompany(basePayload, accessToken);
}
navigate("/companies", { replace: true });
} catch (err) {
setError(err instanceof ApiError ? err.message : "Não foi possível salvar a empresa. Tente novamente.");
} finally {
setSubmitting(false);
}
}
return (
<div>
<PageHeader
title={isEditing ? "Editar empresa" : "Nova empresa"}
description={
<Link to="/companies" className="hover:text-primary">
← Voltar para Empresas
</Link>
}
actions={
<>
<Button variant="secondary" onClick={() => navigate("/companies")} type="button">
Cancelar
</Button>
<Button type="submit" form="company-form" loading={submitting}>
Salvar
</Button>
</>
}
/>
{error ? (
<Alert variant="error" className="mb-4">
{error}
</Alert>
) : null}
{loading ? (
<Card>
<p className="text-sm text-default">Carregando…</p>
</Card>
) : (
<form id="company-form" className="space-y-4" onSubmit={handleSubmit}>
<Card title="Dados gerais">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<Input
label="Razão social"
value={form.companyName}
onChange={(e) => updateField("companyName", e.target.value)}
required
/>
</div>
<Input
label="Nome fantasia"
value={form.tradeName}
onChange={(e) => updateField("tradeName", e.target.value)}
/>
<Input label="CNPJ" value={form.cnpj} onChange={(e) => updateField("cnpj", e.target.value)} />
<Input
label="Inscrição estadual"
value={form.stateRegistration}
onChange={(e) => updateField("stateRegistration", e.target.value)}
/>
<Input
label="Inscrição municipal"
value={form.municipalRegistration}
onChange={(e) => updateField("municipalRegistration", e.target.value)}
/>
{isEditing ? (
<div className="flex flex-col gap-1">
<span className="text-sm font-semibold text-gray-900">Status</span>
<label className="flex items-center gap-2 py-2.5 text-sm text-title">
<input
type="checkbox"
className="shrink-0 rounded-sm border-border-color text-primary focus:ring-primary"
checked={form.isActive}
onChange={(e) => updateField("isActive", e.target.checked)}
/>
Empresa ativa
</label>
</div>
) : null}
</div>
</Card>
<Card title="Endereço">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Input label="CEP" value={form.addressZip} onChange={(e) => updateField("addressZip", e.target.value)} />
<div className="sm:col-span-2">
<Input
label="Logradouro"
value={form.addressStreet}
onChange={(e) => updateField("addressStreet", e.target.value)}
/>
</div>
<Input
label="Número"
value={form.addressNumber}
onChange={(e) => updateField("addressNumber", e.target.value)}
/>
<Input
label="Complemento"
value={form.addressComplement}
onChange={(e) => updateField("addressComplement", e.target.value)}
/>
<Input
label="Bairro"
value={form.addressNeighborhood}
onChange={(e) => updateField("addressNeighborhood", e.target.value)}
/>
<Input
label="Cidade"
value={form.addressCity}
onChange={(e) => updateField("addressCity", e.target.value)}
/>
<Input
label="UF"
maxLength={2}
value={form.addressState}
onChange={(e) => updateField("addressState", e.target.value.toUpperCase())}
/>
<Input
label="País"
value={form.addressCountry}
onChange={(e) => updateField("addressCountry", e.target.value)}
placeholder="Brasil"
/>
</div>
</Card>
<Card title="Contato">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<Input label="Telefone" value={form.phone} onChange={(e) => updateField("phone", e.target.value)} />
<Input
label="E-mail"
type="email"
value={form.email}
onChange={(e) => updateField("email", e.target.value)}
/>
<Input label="Site" value={form.website} onChange={(e) => updateField("website", e.target.value)} />
</div>
</Card>
<Card title="Representante legal">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Input
label="Nome"
value={form.legalRepName}
onChange={(e) => updateField("legalRepName", e.target.value)}
/>
<Input
label="CPF"
value={form.legalRepCpf}
onChange={(e) => updateField("legalRepCpf", e.target.value)}
/>
<Input
label="Cargo"
value={form.legalRepRole}
onChange={(e) => updateField("legalRepRole", e.target.value)}
/>
<Input
label="E-mail"
type="email"
value={form.legalRepEmail}
onChange={(e) => updateField("legalRepEmail", e.target.value)}
/>
</div>
</Card>
</form>
)}
</div>
);
}

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

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

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

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

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

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

View File

@@ -0,0 +1,300 @@
import { useEffect, useState } from "react";
import type { FormEvent } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { Alert, Button, Card, Input, PageHeader } from "@eden/ui";
import { createProduct, getProduct, updateProduct } from "../../lib/api/products.js";
import type { CreateProductInput } from "../../lib/api/products.js";
import { ApiError } from "../../lib/api/http.js";
import { formatCurrencyBRL } from "../../lib/format.js";
import { useAuth } from "../../lib/auth/AuthContext.js";
/** Money fields are edited/stored as integer cents so the mask never loses precision to floating point. */
function centsToDisplay(cents: number): string {
return formatCurrencyBRL(cents / 100);
}
function digitsToCents(raw: string): number {
const digits = raw.replace(/\D/g, "");
return digits === "" ? 0 : Number(digits);
}
/** Masked BRL currency field — typing digits fills in from the right, like a POS terminal. */
function CurrencyInput({
label,
cents,
onChange,
}: {
label: string;
cents: number;
onChange: (cents: number) => void;
}) {
return (
<Input
label={label}
inputMode="numeric"
value={centsToDisplay(cents)}
onChange={(e) => onChange(digitsToCents(e.target.value))}
/>
);
}
interface ProductFormState {
name: string;
description: string;
productType: string;
productGroup: string;
productSubgroup: string;
brand: string;
unit: string;
active: boolean;
discontinued: boolean;
tracksStock: boolean;
tracksSerial: boolean;
tracksAssetTag: boolean;
tracksMac: boolean;
price0Cents: number;
price12Cents: number;
price24Cents: number;
price36Cents: number;
price48Cents: number;
implUnitPriceCents: number;
}
const EMPTY_FORM: ProductFormState = {
name: "",
description: "",
productType: "",
productGroup: "",
productSubgroup: "",
brand: "",
unit: "",
active: true,
discontinued: false,
tracksStock: false,
tracksSerial: false,
tracksAssetTag: false,
tracksMac: false,
price0Cents: 0,
price12Cents: 0,
price24Cents: 0,
price36Cents: 0,
price48Cents: 0,
implUnitPriceCents: 0,
};
function toOptional(value: string): string | undefined {
const trimmed = value.trim();
return trimmed === "" ? undefined : trimmed;
}
/** Reused for both `/products/new` and `/products/:id/edit`. */
export function ProductFormPage() {
const { id } = useParams<{ id: string }>();
const isEditing = Boolean(id);
const { accessToken } = useAuth();
const navigate = useNavigate();
const [form, setForm] = useState<ProductFormState>(EMPTY_FORM);
const [loading, setLoading] = useState(isEditing);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (!id || !accessToken) return;
let cancelled = false;
getProduct(id, accessToken)
.then((product) => {
if (cancelled) return;
setForm({
name: product.name,
description: product.description ?? "",
productType: product.product_type,
productGroup: product.product_group ?? "",
productSubgroup: product.product_subgroup ?? "",
brand: product.brand ?? "",
unit: product.unit,
active: product.active,
discontinued: product.discontinued,
tracksStock: product.tracks_stock,
tracksSerial: product.tracks_serial,
tracksAssetTag: product.tracks_asset_tag,
tracksMac: product.tracks_mac,
price0Cents: Math.round(Number(product.price_0 ?? 0) * 100),
price12Cents: Math.round(Number(product.price_12 ?? 0) * 100),
price24Cents: Math.round(Number(product.price_24 ?? 0) * 100),
price36Cents: Math.round(Number(product.price_36 ?? 0) * 100),
price48Cents: Math.round(Number(product.price_48 ?? 0) * 100),
implUnitPriceCents: Math.round(Number(product.impl_unit_price ?? 0) * 100),
});
})
.catch((err) => {
if (!cancelled) setError(err instanceof ApiError ? err.message : "Não foi possível carregar o produto.");
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [id, accessToken]);
function updateField<K extends keyof ProductFormState>(field: K, value: ProductFormState[K]) {
setForm((prev) => ({ ...prev, [field]: value }));
}
async function handleSubmit(event: FormEvent) {
event.preventDefault();
if (!accessToken) return;
setError(null);
setSubmitting(true);
try {
const payload: CreateProductInput = {
name: form.name.trim(),
description: toOptional(form.description),
productType: form.productType.trim(),
productGroup: toOptional(form.productGroup),
productSubgroup: toOptional(form.productSubgroup),
brand: toOptional(form.brand),
unit: toOptional(form.unit),
active: form.active,
discontinued: form.discontinued,
tracksStock: form.tracksStock,
tracksSerial: form.tracksSerial,
tracksAssetTag: form.tracksAssetTag,
tracksMac: form.tracksMac,
price0: form.price0Cents / 100,
price12: form.price12Cents / 100,
price24: form.price24Cents / 100,
price36: form.price36Cents / 100,
price48: form.price48Cents / 100,
implUnitPrice: form.implUnitPriceCents / 100,
};
if (isEditing && id) {
await updateProduct(id, payload, accessToken);
} else {
await createProduct(payload, accessToken);
}
navigate("/products", { replace: true });
} catch (err) {
setError(err instanceof ApiError ? err.message : "Não foi possível salvar o produto. Tente novamente.");
} finally {
setSubmitting(false);
}
}
return (
<div>
<PageHeader
title={isEditing ? "Editar produto" : "Novo produto"}
description={
<Link to="/products" className="hover:text-primary">
← Voltar para Produtos
</Link>
}
actions={
<>
<Button variant="secondary" onClick={() => navigate("/products")} type="button">
Cancelar
</Button>
<Button type="submit" form="product-form" loading={submitting}>
Salvar
</Button>
</>
}
/>
{error ? (
<Alert variant="error" className="mb-4">
{error}
</Alert>
) : null}
{loading ? (
<Card>
<p className="text-sm text-default">Carregando…</p>
</Card>
) : (
<form id="product-form" className="space-y-4" onSubmit={handleSubmit}>
<Card title="Dados gerais">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<Input label="Nome" value={form.name} onChange={(e) => updateField("name", e.target.value)} required />
</div>
<Input
label="Tipo"
value={form.productType}
onChange={(e) => updateField("productType", e.target.value)}
required
/>
<Input label="Unidade" placeholder="unidade" value={form.unit} onChange={(e) => updateField("unit", e.target.value)} />
<Input
label="Grupo"
value={form.productGroup}
onChange={(e) => updateField("productGroup", e.target.value)}
/>
<Input
label="Subgrupo"
value={form.productSubgroup}
onChange={(e) => updateField("productSubgroup", e.target.value)}
/>
<Input label="Marca" value={form.brand} onChange={(e) => updateField("brand", e.target.value)} />
<div className="sm:col-span-2">
<label className="mb-1 block text-sm font-semibold text-gray-900" htmlFor="product-description">
Descrição
</label>
<textarea
id="product-description"
rows={3}
className="w-full rounded-md border border-border-color bg-white px-3 py-2.5 text-sm text-title placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-primary/30"
value={form.description}
onChange={(e) => updateField("description", e.target.value)}
/>
</div>
</div>
</Card>
<Card title="Preços">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<CurrencyInput label="À vista (0 meses)" cents={form.price0Cents} onChange={(v) => updateField("price0Cents", v)} />
<CurrencyInput label="Fidelidade 12 meses" cents={form.price12Cents} onChange={(v) => updateField("price12Cents", v)} />
<CurrencyInput label="Fidelidade 24 meses" cents={form.price24Cents} onChange={(v) => updateField("price24Cents", v)} />
<CurrencyInput label="Fidelidade 36 meses" cents={form.price36Cents} onChange={(v) => updateField("price36Cents", v)} />
<CurrencyInput label="Fidelidade 48 meses" cents={form.price48Cents} onChange={(v) => updateField("price48Cents", v)} />
<CurrencyInput
label="Valor de implantação"
cents={form.implUnitPriceCents}
onChange={(v) => updateField("implUnitPriceCents", v)}
/>
</div>
</Card>
<Card title="Rastreamento e status">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{(
[
["tracksStock", "Controla estoque"],
["tracksSerial", "Controla número de série"],
["tracksAssetTag", "Controla patrimônio"],
["tracksMac", "Controla MAC address"],
["active", "Ativo"],
["discontinued", "Descontinuado"],
] as const
).map(([field, label]) => (
<label key={field} className="flex items-center gap-2 text-sm text-title">
<input
type="checkbox"
className="shrink-0 rounded-sm border-border-color text-primary focus:ring-primary"
checked={form[field]}
onChange={(e) => updateField(field, e.target.checked)}
/>
{label}
</label>
))}
</div>
</Card>
</form>
)}
</div>
);
}

View File

@@ -0,0 +1,101 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Alert, Badge, Button, Card, PageHeader, Table } from "@eden/ui";
import type { TableColumn } from "@eden/ui";
import { listProducts } from "../../lib/api/products.js";
import type { Product } from "../../lib/api/products.js";
import { ApiError } from "../../lib/api/http.js";
import { formatCurrencyBRL } from "../../lib/format.js";
import { useAuth } from "../../lib/auth/AuthContext.js";
const PRICE_COLUMNS: Array<{ key: keyof Product; label: string }> = [
{ key: "price_0", label: "À vista" },
{ key: "price_12", label: "12 meses" },
{ key: "price_24", label: "24 meses" },
{ key: "price_36", label: "36 meses" },
{ key: "price_48", label: "48 meses" },
];
/** List layout follows the DreamsERP theme's inventory/products.tsx — header + white card + table. */
export function ProductsListPage() {
const { accessToken } = useAuth();
const navigate = useNavigate();
const [products, setProducts] = useState<Product[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!accessToken) return;
let cancelled = false;
setError(null);
listProducts(accessToken)
.then((data) => {
if (!cancelled) setProducts(data);
})
.catch((err) => {
if (cancelled) return;
setError(err instanceof ApiError ? err.message : "Não foi possível carregar os produtos.");
});
return () => {
cancelled = true;
};
}, [accessToken]);
const columns: Array<TableColumn<Product>> = [
{ key: "code", header: "Código", render: (product) => `#${product.product_code}` },
{ key: "name", header: "Nome", render: (product) => <span className="font-semibold text-title">{product.name}</span> },
{ key: "type", header: "Tipo", render: (product) => product.product_type },
...PRICE_COLUMNS.map(
(column): TableColumn<Product> => ({
key: column.key,
header: column.label,
className: "text-right",
render: (product) => formatCurrencyBRL(product[column.key] as string | null),
}),
),
{
key: "status",
header: "Status",
render: (product) =>
product.discontinued ? (
<Badge variant="neutral">Descontinuado</Badge>
) : (
<Badge variant={product.active ? "success" : "warning"}>{product.active ? "Ativo" : "Inativo"}</Badge>
),
},
{
key: "actions",
header: "",
className: "text-right",
render: (product) => (
<Button variant="secondary" size="sm" onClick={() => navigate(`/products/${product.id}/edit`)}>
Editar
</Button>
),
},
];
return (
<div>
<PageHeader
title="Produtos"
description="Catálogo de produtos e serviços, com as faixas de preço por período de fidelidade."
actions={<Button onClick={() => navigate("/products/new")}>Novo produto</Button>}
/>
{error ? (
<Alert variant="error" className="mb-4">
{error}
</Alert>
) : null}
<Card>
{products === null && !error ? (
<p className="text-sm text-default">Carregando…</p>
) : (
<Table columns={columns} data={products ?? []} rowKey={(product) => product.id} />
)}
</Card>
</div>
);
}

View File

@@ -0,0 +1,327 @@
import { useCallback, useEffect, useState } from "react";
import type { FormEvent } from "react";
import { useParams } from "react-router-dom";
import { Alert, Badge, Button, Card, Input, 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 { closeQuoteDeal, decideQuoteApproval, getQuote } from "../../lib/api/quotes.js";
import type { ApprovalStatus, DealStatus, Quote } from "../../lib/api/quotes.js";
import { listResellers } from "../../lib/api/resellers.js";
import type { Reseller } from "../../lib/api/resellers.js";
import { formatCurrencyBRL, formatDateBR } from "../../lib/format.js";
import { Breadcrumb } from "../../components/Breadcrumb.js";
/**
* Layout reference: DreamsERP theme `.tmp/theme-reference/sales/salesQuotesDetails.tsx`
* (breadcrumb + title/status header, bill-to summary block, item table,
* right-aligned financial totals) plus `.tmp/theme-reference/crm/dealsDetails.tsx`
* (summary sidebar's `flex justify-between` key/value rows for the numbers
* that don't fit an invoice-style table). Rebuilt with `@eden/ui` + plain
* Tailwind — no antd/Preline dependency.
*/
const APPROVAL_LABEL: Record<ApprovalStatus, string> = {
none: "Sem aprovação",
pending: "Pendente",
approved: "Aprovada",
rejected: "Rejeitada",
};
const APPROVAL_VARIANT: Record<ApprovalStatus, BadgeVariant> = {
none: "neutral",
pending: "warning",
approved: "success",
rejected: "danger",
};
const DEAL_LABEL: Record<DealStatus, string> = {
orcamento: "Orçamento",
fechado: "Fechado",
perdido: "Perdido",
};
const DEAL_VARIANT: Record<DealStatus, BadgeVariant> = {
orcamento: "info",
fechado: "success",
perdido: "danger",
};
export function QuoteDetailPage() {
const { id } = useParams<{ id: string }>();
const { accessToken } = useAuth();
const [quote, setQuote] = useState<Quote | null>(null);
const [reseller, setReseller] = useState<Reseller | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = useCallback(() => {
if (!id || !accessToken) return;
setLoading(true);
setError(null);
Promise.all([getQuote(id, accessToken), listResellers(accessToken)])
.then(([quoteResult, resellers]) => {
setQuote(quoteResult);
setReseller(resellers.find((r) => r.id === quoteResult.reseller_id) ?? null);
})
.catch((err) => setError(err instanceof ApiError ? err.message : "Não foi possível carregar a oferta."))
.finally(() => setLoading(false));
}, [id, accessToken]);
useEffect(() => {
load();
}, [load]);
if (loading) {
return (
<div>
<PageHeader title="Oferta" />
<p className="text-sm text-default">Carregando oferta…</p>
</div>
);
}
if (error || !quote) {
return (
<div>
<PageHeader title="Oferta" />
<Alert variant="error">{error ?? "Oferta não encontrada."}</Alert>
</div>
);
}
return (
<div>
<Breadcrumb items={[{ label: "Ofertas", href: "/quotes" }, { label: quote.quote_number }]} />
<PageHeader
title={`Oferta ${quote.quote_number}`}
actions={
<div className="flex items-center gap-2">
<Badge variant={APPROVAL_VARIANT[quote.approval_status]}>{APPROVAL_LABEL[quote.approval_status]}</Badge>
<Badge variant={DEAL_VARIANT[quote.deal_status]}>{DEAL_LABEL[quote.deal_status]}</Badge>
</div>
}
/>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<div className="lg:col-span-1">
<Card title="Cliente">
<dl className="space-y-3 text-sm">
<Row label="Nome" value={quote.client_name} />
<Row label="Empresa" value={quote.client_company ?? "—"} />
<Row label="E-mail" value={quote.client_email ?? "—"} />
<Row label="Telefone" value={quote.client_phone} />
<Row label="Revenda" value={reseller?.name ?? quote.reseller_id} />
<Row
label="Período de contrato"
value={quote.contract_period === 0 ? "Sem fidelidade" : `${quote.contract_period} meses`}
/>
<Row
label="Fidelidade reduzida"
value={quote.fidelity_period != null ? `${quote.fidelity_period} meses` : "—"}
/>
<Row label="Condição de pagamento" value={quote.impl_payment_condition} />
<Row label="Criada em" value={formatDateBR(quote.created_at)} />
</dl>
</Card>
</div>
<div className="space-y-4 lg:col-span-2">
<Card title="Itens">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-color text-left">
<th className="py-2 pe-3 font-semibold text-title">Produto</th>
<th className="py-2 pe-3 text-right font-semibold text-title">Qtd.</th>
<th className="py-2 pe-3 text-right font-semibold text-title">Preço unit.</th>
<th className="py-2 pe-3 text-right font-semibold text-title">Total</th>
<th className="py-2 pe-3 text-right font-semibold text-title">Impl. unit.</th>
<th className="py-2 text-right font-semibold text-title">Impl. total</th>
</tr>
</thead>
<tbody>
{quote.items.map((item, index) => (
<tr key={`${item.productId}-${index}`} className="border-b border-border-color last:border-0">
<td className="py-2 pe-3 font-semibold text-title">{item.productName}</td>
<td className="py-2 pe-3 text-right text-default">{item.quantity}</td>
<td className="py-2 pe-3 text-right text-default">{formatCurrencyBRL(item.unitPrice)}</td>
<td className="py-2 pe-3 text-right text-default">{formatCurrencyBRL(item.total)}</td>
<td className="py-2 pe-3 text-right text-default">{formatCurrencyBRL(item.implUnitPrice)}</td>
<td className="py-2 text-right text-default">{formatCurrencyBRL(item.implTotal)}</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
<Card title="Resumo financeiro">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<SummaryRow label="Mensalidade calculada" value={formatCurrencyBRL(quote.monthly_total)} />
{quote.proposed_monthly_total != null ? (
<SummaryRow
label="Mensalidade proposta"
value={formatCurrencyBRL(quote.proposed_monthly_total)}
highlight
/>
) : null}
<SummaryRow label="Implantação (produtos)" value={formatCurrencyBRL(quote.implementation_fee)} />
<SummaryRow label="Implantação geral" value={formatCurrencyBRL(quote.impl_geral)} />
<SummaryRow label="Total do contrato" value={formatCurrencyBRL(quote.contract_total)} highlight />
</div>
{quote.notes ? (
<div className="mt-4 border-t border-border-color pt-4">
<p className="text-xs font-semibold text-default">Notas</p>
<p className="mt-1 text-sm text-title">{quote.notes}</p>
</div>
) : null}
</Card>
{quote.approval_status === "pending" ? (
<ApprovalCard quoteId={quote.id} accessToken={accessToken} onDecided={load} />
) : quote.approval_status !== "none" && quote.approval_notes ? (
<Card title="Aprovação">
<p className="text-sm text-default">{quote.approval_notes}</p>
</Card>
) : null}
{quote.deal_status === "orcamento" ? (
<CloseDealCard quoteId={quote.id} accessToken={accessToken} onClosed={load} />
) : quote.client_registration_id ? (
<Card title="Negócio">
<Row label="Cadastro de cliente" value={quote.client_registration_id} />
</Card>
) : null}
</div>
</div>
</div>
);
}
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-start justify-between gap-3">
<dt className="text-default">{label}</dt>
<dd className="text-right font-semibold text-title">{value}</dd>
</div>
);
}
function SummaryRow({ label, value, highlight }: { label: string; value: string; highlight?: boolean }) {
return (
<div className="flex items-center justify-between rounded-md border border-border-color p-3">
<span className="text-sm text-default">{label}</span>
<span className={highlight ? "text-base font-bold text-primary" : "text-sm font-semibold text-title"}>
{value}
</span>
</div>
);
}
function ApprovalCard({
quoteId,
accessToken,
onDecided,
}: {
quoteId: string;
accessToken: string | null;
onDecided: () => void;
}) {
const [notes, setNotes] = useState("");
const [submitting, setSubmitting] = useState<"approved" | "rejected" | null>(null);
const [error, setError] = useState<string | null>(null);
async function decide(decision: "approved" | "rejected") {
if (!accessToken) return;
setError(null);
setSubmitting(decision);
try {
await decideQuoteApproval(quoteId, { decision, notes: notes || undefined }, accessToken);
onDecided();
} catch (err) {
setError(err instanceof ApiError ? err.message : "Não foi possível registrar a decisão.");
} finally {
setSubmitting(null);
}
}
return (
<Card title="Aprovação pendente">
{error ? (
<Alert variant="error" className="mb-3">
{error}
</Alert>
) : null}
<p className="mb-3 text-sm text-default">
Esta oferta tem desconto e/ou fidelidade reduzida e precisa de aprovação antes de seguir.
</p>
<Input
label="Observação (opcional)"
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="mb-3"
/>
<div className="flex items-center gap-2">
<Button type="button" onClick={() => decide("approved")} loading={submitting === "approved"}>
Aprovar
</Button>
<Button type="button" variant="danger" onClick={() => decide("rejected")} loading={submitting === "rejected"}>
Rejeitar
</Button>
</div>
</Card>
);
}
function CloseDealCard({
quoteId,
accessToken,
onClosed,
}: {
quoteId: string;
accessToken: string | null;
onClosed: () => void;
}) {
const [clientRegistrationId, setClientRegistrationId] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(event: FormEvent) {
event.preventDefault();
if (!accessToken) return;
setError(null);
setSubmitting(true);
try {
await closeQuoteDeal(quoteId, { clientRegistrationId }, accessToken);
onClosed();
} catch (err) {
setError(err instanceof ApiError ? err.message : "Não foi possível fechar o negócio.");
} finally {
setSubmitting(false);
}
}
return (
<Card title="Fechar negócio">
{error ? (
<Alert variant="error" className="mb-3">
{error}
</Alert>
) : null}
<form className="flex flex-wrap items-end gap-3" onSubmit={handleSubmit}>
<Input
label="UUID do cadastro de cliente"
value={clientRegistrationId}
onChange={(e) => setClientRegistrationId(e.target.value)}
hint="Cole o UUID de um cadastro de cliente já existente"
className="min-w-72 flex-1"
required
/>
<Button type="submit" loading={submitting}>
Fechar negócio
</Button>
</form>
</Card>
);
}

View File

@@ -0,0 +1,381 @@
import { useEffect, useMemo, useState } from "react";
import type { FormEvent } from "react";
import { Link, useNavigate } from "react-router-dom";
import { Alert, Badge, Button, Card, Input, PageHeader, Select } from "@eden/ui";
import { useAuth } from "../../lib/auth/AuthContext.js";
import { ApiError } from "../../lib/api/http.js";
import { CONTRACT_PERIODS, IMPL_PAYMENT_CONDITIONS, createQuote } from "../../lib/api/quotes.js";
import type { ContractPeriod, ImplPaymentCondition, Quote } from "../../lib/api/quotes.js";
import { listResellers } from "../../lib/api/resellers.js";
import type { Reseller } from "../../lib/api/resellers.js";
import { listProducts } from "../../lib/api/products.js";
import type { Product } from "../../lib/api/products.js";
import { formatCurrencyBRL } from "../../lib/format.js";
import { Breadcrumb } from "../../components/Breadcrumb.js";
import { PRIMARY_LINK_BUTTON_CLASS } from "../../components/linkButtonClasses.js";
/**
* Layout reference: DreamsERP theme `.tmp/theme-reference/sales/addSalesQuotes.tsx`
* (two-column "details" header, editable item table with add/remove rows,
* right-hand totals panel) — rebuilt with `@eden/ui` + plain Tailwind
* instead of the theme's react-select/CommonDatePicker. Every price/total
* shown here is illustrative only; the server (apps/api/src/quotes/pricing.ts)
* is the single source of truth and recomputes everything on save.
*/
interface ItemRow {
productId: string;
quantity: string;
}
const EMPTY_ROW: ItemRow = { productId: "", quantity: "1" };
function getAvailablePaymentConditions(implTotal: number): readonly ImplPaymentCondition[] {
if (implTotal <= 1500) return ["À vista"];
if (implTotal <= 3000) return ["À vista", "1+1"];
return IMPL_PAYMENT_CONDITIONS;
}
export function QuoteFormPage() {
const { accessToken } = useAuth();
const navigate = useNavigate();
const [resellers, setResellers] = useState<Reseller[] | null>(null);
const [products, setProducts] = useState<Product[] | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [resellerId, setResellerId] = useState("");
const [clientName, setClientName] = useState("");
const [clientCompany, setClientCompany] = useState("");
const [clientEmail, setClientEmail] = useState("");
const [clientPhone, setClientPhone] = useState("");
const [contractPeriod, setContractPeriod] = useState<string>("12");
const [fidelityPeriod, setFidelityPeriod] = useState("");
const [items, setItems] = useState<ItemRow[]>([{ ...EMPTY_ROW }]);
const [implGeral, setImplGeral] = useState("");
const [proposedMonthlyTotal, setProposedMonthlyTotal] = useState("");
const [implPaymentCondition, setImplPaymentCondition] = useState<ImplPaymentCondition | "">("");
const [notes, setNotes] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [createdQuote, setCreatedQuote] = useState<Quote | null>(null);
useEffect(() => {
if (!accessToken) return;
setLoadError(null);
Promise.all([listResellers(accessToken), listProducts(accessToken)])
.then(([resellerList, productList]) => {
setResellers(resellerList);
setProducts(productList);
})
.catch((err) =>
setLoadError(err instanceof ApiError ? err.message : "Não foi possível carregar revendas/produtos."),
);
}, [accessToken]);
const productOptions = useMemo(
() => (products ?? []).map((p) => ({ value: p.id, label: `${p.name} (cód. ${p.product_code})` })),
[products],
);
const estimatedImplTotal = useMemo(() => {
if (!products) return 0;
const fromItems = items.reduce((sum, row) => {
const product = products.find((p) => p.id === row.productId);
const qty = Number(row.quantity) || 0;
if (!product) return sum;
return sum + Number(product.impl_unit_price ?? 0) * qty;
}, 0);
return fromItems + (Number(implGeral) || 0);
}, [items, products, implGeral]);
const availablePaymentConditions = getAvailablePaymentConditions(estimatedImplTotal);
function updateItem(index: number, patch: Partial<ItemRow>) {
setItems((prev) => prev.map((row, i) => (i === index ? { ...row, ...patch } : row)));
}
function addItem() {
setItems((prev) => [...prev, { ...EMPTY_ROW }]);
}
function removeItem(index: number) {
setItems((prev) => (prev.length > 1 ? prev.filter((_, i) => i !== index) : prev));
}
async function handleSubmit(event: FormEvent) {
event.preventDefault();
if (!accessToken) return;
setError(null);
const validItems = items
.filter((row) => row.productId && Number(row.quantity) > 0)
.map((row) => ({ productId: row.productId, quantity: Number(row.quantity) }));
if (validItems.length === 0) {
setError("Adicione ao menos um item com produto e quantidade válidos.");
return;
}
setSubmitting(true);
try {
const quote = await createQuote(
{
resellerId,
clientName,
clientCompany: clientCompany || undefined,
clientEmail: clientEmail || undefined,
clientPhone,
contractPeriod: Number(contractPeriod) as ContractPeriod,
fidelityPeriod: fidelityPeriod ? Number(fidelityPeriod) : undefined,
items: validItems,
implGeral: implGeral ? Number(implGeral) : undefined,
proposedMonthlyTotal: proposedMonthlyTotal ? Number(proposedMonthlyTotal) : undefined,
implPaymentCondition: implPaymentCondition || undefined,
notes: notes || undefined,
},
accessToken,
);
setCreatedQuote(quote);
} catch (err) {
setError(err instanceof ApiError ? err.message : "Não foi possível criar a oferta.");
} finally {
setSubmitting(false);
}
}
if (createdQuote) {
return (
<div>
<PageHeader title="Oferta criada" description={`Número ${createdQuote.quote_number}`} />
<Alert variant="success" className="mb-4">
Oferta criada com sucesso. Confira os totais calculados pelo servidor abaixo.
</Alert>
<Card>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<SummaryField label="Mensalidade" value={formatCurrencyBRL(createdQuote.monthly_total)} />
<SummaryField label="Implantação total" value={formatCurrencyBRL(createdQuote.implementation_fee)} />
<SummaryField label="Total do contrato" value={formatCurrencyBRL(createdQuote.contract_total)} />
</div>
{createdQuote.approval_status === "pending" ? (
<Alert variant="warning" className="mt-4">
Esta oferta possui desconto e/ou fidelidade reduzida — aguarda aprovação.
</Alert>
) : null}
<div className="mt-6 flex items-center gap-2">
<Link to={`/quotes/${createdQuote.id}`} className={PRIMARY_LINK_BUTTON_CLASS}>
Ver oferta completa
</Link>
<Button type="button" variant="secondary" onClick={() => navigate("/quotes")}>
Voltar para a lista
</Button>
</div>
</Card>
</div>
);
}
return (
<div>
<Breadcrumb items={[{ label: "Ofertas", href: "/quotes" }, { label: "Nova oferta" }]} />
<PageHeader title="Nova oferta" description="Orçamento comercial" />
{loadError ? (
<Alert variant="error" className="mb-4">
{loadError}
</Alert>
) : null}
{error ? (
<Alert variant="error" className="mb-4">
{error}
</Alert>
) : null}
<Card>
{!resellers || !products ? (
<p className="text-sm text-default">Carregando revendas e produtos…</p>
) : (
<form className="space-y-8" onSubmit={handleSubmit}>
<div>
<h2 className="mb-4 text-sm font-semibold text-title">Dados do cliente</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Select
label="Revenda"
options={resellers.map((r) => ({ value: r.id, label: r.name }))}
placeholder="Selecione a revenda"
value={resellerId}
onChange={(e) => setResellerId(e.target.value)}
required
/>
<Input label="Nome do cliente" value={clientName} onChange={(e) => setClientName(e.target.value)} required />
<Input label="Empresa" value={clientCompany} onChange={(e) => setClientCompany(e.target.value)} />
<Input label="E-mail" type="email" value={clientEmail} onChange={(e) => setClientEmail(e.target.value)} />
<Input label="Telefone" value={clientPhone} onChange={(e) => setClientPhone(e.target.value)} required />
</div>
</div>
<div>
<h2 className="mb-4 text-sm font-semibold text-title">Contrato</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Select
label="Período de contrato"
options={CONTRACT_PERIODS.map((period) => ({
value: String(period),
label: period === 0 ? "Sem fidelidade (0 meses)" : `${period} meses`,
}))}
value={contractPeriod}
onChange={(e) => setContractPeriod(e.target.value)}
required
/>
<Input
label="Fidelidade reduzida (opcional)"
type="number"
min={0}
value={fidelityPeriod}
onChange={(e) => setFidelityPeriod(e.target.value)}
hint="Só preencha se for menor que o período do contrato — entra em aprovação"
/>
</div>
</div>
<div>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-sm font-semibold text-title">Itens</h2>
<button
type="button"
onClick={addItem}
className="rounded-md border border-border-color px-3 py-1.5 text-xs font-semibold text-title hover:bg-light"
>
+ Adicionar item
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-color text-left">
<th className="py-2 pe-2 font-semibold text-title">Produto</th>
<th className="py-2 pe-2 font-semibold text-title">Quantidade</th>
<th className="py-2 pe-2" />
</tr>
</thead>
<tbody>
{items.map((row, index) => (
<tr key={index} className="border-b border-border-color last:border-0">
<td className="py-2 pe-2 w-2/3">
<Select
options={productOptions}
placeholder="Selecione o produto"
value={row.productId}
onChange={(e) => updateItem(index, { productId: e.target.value })}
aria-label="Produto"
required
/>
</td>
<td className="py-2 pe-2">
<Input
type="number"
min={0.01}
step={0.01}
value={row.quantity}
onChange={(e) => updateItem(index, { quantity: e.target.value })}
aria-label="Quantidade"
required
/>
</td>
<td className="py-2 pe-2 text-center">
{items.length > 1 ? (
<button
type="button"
onClick={() => removeItem(index)}
className="rounded-md border border-border-color px-2 py-1 text-xs font-semibold text-danger hover:bg-light"
>
Remover
</button>
) : null}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div>
<h2 className="mb-4 text-sm font-semibold text-title">Implantação e valores</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Input
label="Implantação geral (opcional)"
type="number"
min={0}
step={0.01}
value={implGeral}
onChange={(e) => setImplGeral(e.target.value)}
hint="Custo de implantação além dos produtos"
/>
<Input
label="Mensalidade proposta (opcional)"
type="number"
min={0.01}
step={0.01}
value={proposedMonthlyTotal}
onChange={(e) => setProposedMonthlyTotal(e.target.value)}
hint="Preencha só se for oferecer desconto — a oferta precisará de aprovação"
/>
<div>
<Select
label="Condição de pagamento da implantação"
options={IMPL_PAYMENT_CONDITIONS.map((condition) => ({
value: condition,
label: condition,
}))}
placeholder="Selecione a condição"
value={implPaymentCondition}
onChange={(e) => setImplPaymentCondition(e.target.value as ImplPaymentCondition)}
/>
<p className="mt-1 text-xs text-default">
Faixas sugeridas para {formatCurrencyBRL(estimatedImplTotal)} de implantação estimada:{" "}
{availablePaymentConditions.map((c) => (
<Badge key={c} variant="neutral" className="ms-1">
{c}
</Badge>
))}
{" "}— o servidor valida a faixa real ao salvar.
</p>
</div>
</div>
</div>
<div>
<label className="mb-1 block 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="flex items-center gap-2 border-t border-border-color pt-5">
<Button type="submit" loading={submitting}>
Criar oferta
</Button>
<Button type="button" variant="secondary" onClick={() => navigate("/quotes")}>
Cancelar
</Button>
</div>
</form>
)}
</Card>
</div>
);
}
function SummaryField({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-md border border-border-color p-4">
<p className="text-xs text-default">{label}</p>
<p className="mt-1 text-lg font-bold text-title">{value}</p>
</div>
);
}

View File

@@ -0,0 +1,132 @@
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 { listQuotes } from "../../lib/api/quotes.js";
import type { ApprovalStatus, DealStatus, Quote } from "../../lib/api/quotes.js";
import { formatCurrencyBRL } from "../../lib/format.js";
import { PRIMARY_LINK_BUTTON_CLASS } from "../../components/linkButtonClasses.js";
/**
* Layout reference: DreamsERP theme `.tmp/theme-reference/sales/salesQuotes.tsx`
* — header with title + "Add New" action, flush data table in a bordered
* white card, colored status pill in the last data column. Rebuilt with
* `@eden/ui` + plain Tailwind (no antd Datatable/date-range-picker).
*/
const APPROVAL_LABEL: Record<ApprovalStatus, string> = {
none: "Sem aprovação",
pending: "Pendente",
approved: "Aprovada",
rejected: "Rejeitada",
};
const APPROVAL_VARIANT: Record<ApprovalStatus, BadgeVariant> = {
none: "neutral",
pending: "warning",
approved: "success",
rejected: "danger",
};
const DEAL_LABEL: Record<DealStatus, string> = {
orcamento: "Orçamento",
fechado: "Fechado",
perdido: "Perdido",
};
const DEAL_VARIANT: Record<DealStatus, BadgeVariant> = {
orcamento: "info",
fechado: "success",
perdido: "danger",
};
export function QuotesListPage() {
const { accessToken } = useAuth();
const [quotes, setQuotes] = useState<Quote[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!accessToken) return;
let cancelled = false;
setError(null);
listQuotes(accessToken)
.then((result) => {
if (!cancelled) setQuotes(result);
})
.catch((err) => {
if (!cancelled) setError(err instanceof ApiError ? err.message : "Não foi possível carregar as ofertas.");
});
return () => {
cancelled = true;
};
}, [accessToken]);
return (
<div>
<PageHeader
title="Ofertas"
description="Orçamentos comerciais"
actions={
<Link to="/quotes/new" className={PRIMARY_LINK_BUTTON_CLASS}>
Nova oferta
</Link>
}
/>
{error ? (
<Alert variant="error" className="mb-4">
{error}
</Alert>
) : null}
<Card bodyClassName="p-0">
{quotes === null && !error ? (
<p className="p-5 text-sm text-default">Carregando ofertas…</p>
) : quotes && quotes.length === 0 ? (
<p className="p-5 text-sm text-default">Nenhuma oferta cadastrada ainda.</p>
) : quotes ? (
<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">Oferta</th>
<th className="px-5 py-3 font-semibold text-title">Cliente</th>
<th className="px-5 py-3 font-semibold text-title">Mensalidade</th>
<th className="px-5 py-3 font-semibold text-title">Aprovação</th>
<th className="px-5 py-3 font-semibold text-title">Negócio</th>
</tr>
</thead>
<tbody>
{quotes.map((quote) => (
<tr key={quote.id} className="border-b border-border-color last:border-0 hover:bg-light">
<td className="px-5 py-3">
<Link to={`/quotes/${quote.id}`} className="font-semibold text-title hover:text-primary">
{quote.quote_number}
</Link>
</td>
<td className="px-5 py-3">
<p className="text-title">{quote.client_name}</p>
{quote.client_company ? <p className="text-xs text-default">{quote.client_company}</p> : null}
</td>
<td className="px-5 py-3 text-default">
{formatCurrencyBRL(quote.proposed_monthly_total ?? quote.monthly_total)}
</td>
<td className="px-5 py-3">
<Badge variant={APPROVAL_VARIANT[quote.approval_status]}>
{APPROVAL_LABEL[quote.approval_status]}
</Badge>
</td>
<td className="px-5 py-3">
<Badge variant={DEAL_VARIANT[quote.deal_status]}>{DEAL_LABEL[quote.deal_status]}</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</Card>
</div>
);
}

View File

@@ -0,0 +1,153 @@
import { useEffect, useState } from "react";
import type { FormEvent } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { Alert, Button, Card, Input, PageHeader } from "@eden/ui";
import { createReseller, getReseller, updateReseller } from "../../lib/api/resellers.js";
import type { CreateResellerInput } from "../../lib/api/resellers.js";
import { ApiError } from "../../lib/api/http.js";
import { useAuth } from "../../lib/auth/AuthContext.js";
interface ResellerFormState {
name: string;
document: string;
contactEmail: string;
active: boolean;
}
const EMPTY_FORM: ResellerFormState = { name: "", document: "", contactEmail: "", active: true };
function toOptional(value: string): string | undefined {
const trimmed = value.trim();
return trimmed === "" ? undefined : trimmed;
}
/** Reused for both `/resellers/new` and `/resellers/:id/edit`. */
export function ResellerFormPage() {
const { id } = useParams<{ id: string }>();
const isEditing = Boolean(id);
const { accessToken } = useAuth();
const navigate = useNavigate();
const [form, setForm] = useState<ResellerFormState>(EMPTY_FORM);
const [loading, setLoading] = useState(isEditing);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (!id || !accessToken) return;
let cancelled = false;
getReseller(id, accessToken)
.then((reseller) => {
if (cancelled) return;
setForm({
name: reseller.name,
document: reseller.document ?? "",
contactEmail: reseller.contact_email ?? "",
active: reseller.active,
});
})
.catch((err) => {
if (!cancelled) setError(err instanceof ApiError ? err.message : "Não foi possível carregar a revenda.");
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [id, accessToken]);
function updateField<K extends keyof ResellerFormState>(field: K, value: ResellerFormState[K]) {
setForm((prev) => ({ ...prev, [field]: value }));
}
async function handleSubmit(event: FormEvent) {
event.preventDefault();
if (!accessToken) return;
setError(null);
setSubmitting(true);
try {
const payload: CreateResellerInput = {
name: form.name.trim(),
document: toOptional(form.document),
contactEmail: toOptional(form.contactEmail),
active: form.active,
};
if (isEditing && id) {
await updateReseller(id, payload, accessToken);
} else {
await createReseller(payload, accessToken);
}
navigate("/resellers", { replace: true });
} catch (err) {
setError(err instanceof ApiError ? err.message : "Não foi possível salvar a revenda. Tente novamente.");
} finally {
setSubmitting(false);
}
}
return (
<div>
<PageHeader
title={isEditing ? "Editar revenda" : "Nova revenda"}
description={
<Link to="/resellers" className="hover:text-primary">
← Voltar para Revendas
</Link>
}
actions={
<>
<Button variant="secondary" onClick={() => navigate("/resellers")} type="button">
Cancelar
</Button>
<Button type="submit" form="reseller-form" loading={submitting}>
Salvar
</Button>
</>
}
/>
{error ? (
<Alert variant="error" className="mb-4">
{error}
</Alert>
) : null}
{loading ? (
<Card>
<p className="text-sm text-default">Carregando…</p>
</Card>
) : (
<form id="reseller-form" className="space-y-4" onSubmit={handleSubmit}>
<Card title="Dados da revenda">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<Input label="Nome" value={form.name} onChange={(e) => updateField("name", e.target.value)} required />
</div>
<Input label="Documento (CPF/CNPJ)" value={form.document} onChange={(e) => updateField("document", e.target.value)} />
<Input
label="E-mail de contato"
type="email"
value={form.contactEmail}
onChange={(e) => updateField("contactEmail", e.target.value)}
/>
<div className="flex flex-col gap-1">
<span className="text-sm font-semibold text-gray-900">Status</span>
<label className="flex items-center gap-2 py-2.5 text-sm text-title">
<input
type="checkbox"
className="shrink-0 rounded-sm border-border-color text-primary focus:ring-primary"
checked={form.active}
onChange={(e) => updateField("active", e.target.checked)}
/>
Revenda ativa
</label>
</div>
</div>
</Card>
</form>
)}
</div>
);
}

View File

@@ -0,0 +1,79 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Alert, Badge, Button, Card, PageHeader, Table } from "@eden/ui";
import type { TableColumn } from "@eden/ui";
import { listResellers } from "../../lib/api/resellers.js";
import type { Reseller } from "../../lib/api/resellers.js";
import { ApiError } from "../../lib/api/http.js";
import { formatDocument } from "../../lib/format.js";
import { useAuth } from "../../lib/auth/AuthContext.js";
export function ResellersListPage() {
const { accessToken } = useAuth();
const navigate = useNavigate();
const [resellers, setResellers] = useState<Reseller[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!accessToken) return;
let cancelled = false;
setError(null);
listResellers(accessToken)
.then((data) => {
if (!cancelled) setResellers(data);
})
.catch((err) => {
if (cancelled) return;
setError(err instanceof ApiError ? err.message : "Não foi possível carregar as revendas.");
});
return () => {
cancelled = true;
};
}, [accessToken]);
const columns: Array<TableColumn<Reseller>> = [
{ key: "name", header: "Nome", render: (reseller) => <span className="font-semibold text-title">{reseller.name}</span> },
{ key: "document", header: "Documento", render: (reseller) => formatDocument(reseller.document) },
{ key: "email", header: "E-mail de contato", render: (reseller) => reseller.contact_email ?? "—" },
{
key: "status",
header: "Status",
render: (reseller) => <Badge variant={reseller.active ? "success" : "neutral"}>{reseller.active ? "Ativo" : "Inativo"}</Badge>,
},
{
key: "actions",
header: "",
className: "text-right",
render: (reseller) => (
<Button variant="secondary" size="sm" onClick={() => navigate(`/resellers/${reseller.id}/edit`)}>
Editar
</Button>
),
},
];
return (
<div>
<PageHeader
title="Revendas"
description="Parceiros revendedores cadastrados no EDEN Core."
actions={<Button onClick={() => navigate("/resellers/new")}>Nova revenda</Button>}
/>
{error ? (
<Alert variant="error" className="mb-4">
{error}
</Alert>
) : null}
<Card>
{resellers === null && !error ? (
<p className="text-sm text-default">Carregando…</p>
) : (
<Table columns={columns} data={resellers ?? []} rowKey={(reseller) => reseller.id} />
)}
</Card>
</div>
);
}