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

@@ -4,6 +4,20 @@ import { AuthenticatedLayout } from "./routes/AuthenticatedLayout.js";
import { DashboardPage } from "./routes/DashboardPage.js";
import { LoginPage } from "./routes/LoginPage.js";
import { ProtectedRoute } from "./routes/ProtectedRoute.js";
import { CompaniesListPage } from "./routes/companies/CompaniesListPage.js";
import { CompanyFormPage } from "./routes/companies/CompanyFormPage.js";
import { ProductsListPage } from "./routes/products/ProductsListPage.js";
import { ProductFormPage } from "./routes/products/ProductFormPage.js";
import { ResellersListPage } from "./routes/resellers/ResellersListPage.js";
import { ResellerFormPage } from "./routes/resellers/ResellerFormPage.js";
import { LeadsListPage } from "./routes/crm/LeadsListPage.js";
import { LeadFormPage } from "./routes/crm/LeadFormPage.js";
import { ConvertLeadPage } from "./routes/crm/ConvertLeadPage.js";
import { OpportunitiesListPage } from "./routes/crm/OpportunitiesListPage.js";
import { OpportunityFormPage } from "./routes/crm/OpportunityFormPage.js";
import { QuotesListPage } from "./routes/quotes/QuotesListPage.js";
import { QuoteFormPage } from "./routes/quotes/QuoteFormPage.js";
import { QuoteDetailPage } from "./routes/quotes/QuoteDetailPage.js";
export function App() {
return (
@@ -14,6 +28,31 @@ export function App() {
<Route element={<ProtectedRoute />}>
<Route element={<AuthenticatedLayout />}>
<Route path="/" element={<DashboardPage />} />
<Route path="/companies" element={<CompaniesListPage />} />
<Route path="/companies/new" element={<CompanyFormPage />} />
<Route path="/companies/:id/edit" element={<CompanyFormPage />} />
<Route path="/products" element={<ProductsListPage />} />
<Route path="/products/new" element={<ProductFormPage />} />
<Route path="/products/:id/edit" element={<ProductFormPage />} />
<Route path="/resellers" element={<ResellersListPage />} />
<Route path="/resellers/new" element={<ResellerFormPage />} />
<Route path="/resellers/:id/edit" element={<ResellerFormPage />} />
<Route path="/leads" element={<LeadsListPage />} />
<Route path="/leads/new" element={<LeadFormPage />} />
<Route path="/leads/:id/edit" element={<LeadFormPage />} />
<Route path="/leads/:id/convert" element={<ConvertLeadPage />} />
<Route path="/opportunities" element={<OpportunitiesListPage />} />
<Route path="/opportunities/new" element={<OpportunityFormPage />} />
<Route path="/opportunities/:id/edit" element={<OpportunityFormPage />} />
<Route path="/quotes" element={<QuotesListPage />} />
<Route path="/quotes/new" element={<QuoteFormPage />} />
<Route path="/quotes/:id" element={<QuoteDetailPage />} />
</Route>
</Route>
</Routes>

View File

@@ -0,0 +1,37 @@
import { Link } from "react-router-dom";
export interface BreadcrumbItem {
label: string;
href?: string;
}
/**
* Visual reference: DreamsERP theme detail pages
* (.tmp/theme-reference/sales/salesQuotesDetails.tsx,
* .tmp/theme-reference/crm/dealsDetails.tsx) — a small "Back link ›
* Current page" trail above the page title on every detail/form screen.
* Lives in core-web (not packages/ui) because it needs react-router's
* `Link`, which the shared Design System deliberately avoids depending on
* (see AppShell's router-agnostic `LinkComponent` prop).
*/
export function Breadcrumb({ items }: { items: BreadcrumbItem[] }) {
return (
<nav aria-label="Navegação" className="mb-1 flex flex-wrap items-center gap-2 text-sm text-default">
{items.map((item, index) => {
const isLast = index === items.length - 1;
return (
<span key={`${item.label}-${index}`} className="flex items-center gap-2">
{index > 0 ? <span aria-hidden="true">/</span> : null}
{isLast || !item.href ? (
<span className={isLast ? "text-title font-medium" : undefined}>{item.label}</span>
) : (
<Link to={item.href} className="hover:text-primary">
{item.label}
</Link>
)}
</span>
);
})}
</nav>
);
}

View File

@@ -0,0 +1,13 @@
/**
* `@eden/ui`'s `Button` renders a `<button>` — correct for in-page actions,
* but invalid (and inaccessible) to nest a react-router `Link` inside for
* page-to-page navigation styled as a button (e.g. "Novo lead" in a
* `PageHeader`'s `actions` slot). These mirror `Button`'s own class
* strings (packages/ui/src/components/Button.tsx) for that case, kept in
* core-web since they need no dependency beyond Tailwind + react-router.
*/
const BASE =
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-semibold px-4 py-2.5 cursor-pointer transition-colors duration-200 ease-in-out";
export const PRIMARY_LINK_BUTTON_CLASS = `${BASE} bg-dark text-white hover:bg-primary-hover`;
export const SECONDARY_LINK_BUTTON_CLASS = `${BASE} bg-white text-title border border-border-color hover:bg-light`;

View File

@@ -0,0 +1,86 @@
/**
* Client for `apps/api` `/v1/companies` (see apps/api/src/companies/companies.controller.ts,
* companies.service.ts and packages/database/src/repositories/companies.ts for the source of
* truth — shapes below are mirrored, not invented).
*/
import { apiRequest } from "./http.js";
/** Response shape — snake_case, exactly as returned by the `companies` table. */
export interface Company {
id: string;
created_at: string;
updated_at: string;
created_by: string | null;
company_name: string;
trade_name: string | null;
cnpj: string | null;
state_registration: string | null;
municipal_registration: string | null;
address_zip: string | null;
address_street: string | null;
address_number: string | null;
address_complement: string | null;
address_neighborhood: string | null;
address_city: string | null;
address_state: string | null;
address_country: string;
phone: string | null;
email: string | null;
website: string | null;
legal_rep_name: string | null;
legal_rep_cpf: string | null;
legal_rep_role: string | null;
legal_rep_email: string | null;
is_active: boolean;
}
/** Request body — camelCase, matches `CreateCompanyDto` exactly. */
export interface CreateCompanyInput {
companyName: string;
tradeName?: string;
cnpj?: string;
stateRegistration?: string;
municipalRegistration?: string;
addressZip?: string;
addressStreet?: string;
addressNumber?: string;
addressComplement?: string;
addressNeighborhood?: string;
addressCity?: string;
addressState?: string;
addressCountry?: string;
phone?: string;
email?: string;
website?: string;
legalRepName?: string;
legalRepCpf?: string;
legalRepRole?: string;
legalRepEmail?: string;
}
/** Matches `UpdateCompanyDto`: everything optional plus `isActive`. */
export type UpdateCompanyInput = Partial<CreateCompanyInput> & { isActive?: boolean };
export function listCompanies(accessToken: string): Promise<Company[]> {
return apiRequest<Company[]>("/companies", { method: "GET" }, { accessToken });
}
export function getCompany(id: string, accessToken: string): Promise<Company> {
return apiRequest<Company>(`/companies/${id}`, { method: "GET" }, { accessToken });
}
export function createCompany(input: CreateCompanyInput, accessToken: string): Promise<Company> {
return apiRequest<Company>("/companies", { method: "POST", body: JSON.stringify(input) }, { accessToken });
}
export function updateCompany(
id: string,
input: UpdateCompanyInput,
accessToken: string,
): Promise<Company> {
return apiRequest<Company>(
`/companies/${id}`,
{ method: "PATCH", body: JSON.stringify(input) },
{ accessToken },
);
}

View File

@@ -0,0 +1,83 @@
/**
* Client for `apps/api` `/v1/leads` (see apps/api/src/leads/leads.controller.ts,
* leads.service.ts, and packages/database/src/repositories/leads.ts for the
* source of truth).
*
* NOTE: unlike Quotes/Resellers/Products (which pass DB rows through as-is,
* snake_case), Leads/Opportunities go through a repository-level mapper
* (`mapLead`/`mapOpportunity`) that returns camelCase fields — verified by
* reading packages/database/src/repositories/leads.ts and opportunities.ts.
*/
import { apiRequest } from "./http.js";
import type { Opportunity } from "./opportunities.js";
export const LEAD_STATUSES = ["new", "contacted", "qualified", "converted", "discarded"] as const;
export type LeadStatus = (typeof LEAD_STATUSES)[number];
export interface Lead {
id: string;
createdAt: string;
updatedAt: string;
createdBy: string | null;
source: string | null;
contactName: string;
contactEmail: string | null;
contactPhone: string | null;
companyName: string | null;
status: string;
ownerId: string | null;
notes: string | null;
}
export interface CreateLeadInput {
source?: string;
contactName: string;
contactEmail?: string;
contactPhone?: string;
companyName?: string;
status?: LeadStatus;
ownerId?: string;
notes?: string;
}
export type UpdateLeadInput = Partial<CreateLeadInput>;
export interface ConvertLeadInput {
title: string;
stage?: string;
probability?: number;
notes?: string;
}
export interface ConvertLeadResult {
lead: Lead;
opportunity: Opportunity;
}
export function listLeads(accessToken: string): Promise<Lead[]> {
return apiRequest<Lead[]>("/leads", { method: "GET" }, { accessToken });
}
export function getLead(id: string, accessToken: string): Promise<Lead> {
return apiRequest<Lead>(`/leads/${id}`, { method: "GET" }, { accessToken });
}
export function createLead(input: CreateLeadInput, accessToken: string): Promise<Lead> {
return apiRequest<Lead>("/leads", { method: "POST", body: JSON.stringify(input) }, { accessToken });
}
export function updateLead(id: string, input: UpdateLeadInput, accessToken: string): Promise<Lead> {
return apiRequest<Lead>(`/leads/${id}`, { method: "PATCH", body: JSON.stringify(input) }, { accessToken });
}
export function convertLead(
id: string,
input: ConvertLeadInput,
accessToken: string,
): Promise<ConvertLeadResult> {
return apiRequest<ConvertLeadResult>(
`/leads/${id}/convert`,
{ method: "POST", body: JSON.stringify(input) },
{ accessToken },
);
}

View File

@@ -0,0 +1,75 @@
/**
* Client for `apps/api` `/v1/opportunities` (see
* apps/api/src/opportunities/opportunities.controller.ts and
* packages/database/src/repositories/opportunities.ts for the source of
* truth). Response fields are camelCase (repository-level `mapOpportunity`
* mapper) — same note as lib/api/leads.ts.
*/
import { apiRequest } from "./http.js";
export const OPPORTUNITY_STAGES = [
"prospecting",
"qualification",
"proposal",
"negotiation",
"won",
"lost",
] as const;
export type OpportunityStage = (typeof OPPORTUNITY_STAGES)[number];
export interface Opportunity {
id: string;
createdAt: string;
updatedAt: string;
createdBy: string | null;
leadId: string | null;
title: string;
stage: string;
probability: number | null;
ownerId: string | null;
lossReason: string | null;
notes: string | null;
}
export interface CreateOpportunityInput {
leadId?: string;
title: string;
stage?: OpportunityStage;
probability?: number;
ownerId?: string;
lossReason?: string;
notes?: string;
}
export type UpdateOpportunityInput = Partial<CreateOpportunityInput>;
export function listOpportunities(accessToken: string): Promise<Opportunity[]> {
return apiRequest<Opportunity[]>("/opportunities", { method: "GET" }, { accessToken });
}
export function getOpportunity(id: string, accessToken: string): Promise<Opportunity> {
return apiRequest<Opportunity>(`/opportunities/${id}`, { method: "GET" }, { accessToken });
}
export function createOpportunity(
input: CreateOpportunityInput,
accessToken: string,
): Promise<Opportunity> {
return apiRequest<Opportunity>(
"/opportunities",
{ method: "POST", body: JSON.stringify(input) },
{ accessToken },
);
}
export function updateOpportunity(
id: string,
input: UpdateOpportunityInput,
accessToken: string,
): Promise<Opportunity> {
return apiRequest<Opportunity>(
`/opportunities/${id}`,
{ method: "PATCH", body: JSON.stringify(input) },
{ accessToken },
);
}

View File

@@ -0,0 +1,89 @@
/**
* Client for `apps/api` `/v1/products` (see apps/api/src/products/products.controller.ts,
* products.service.ts and packages/database/src/repositories/products.ts for the source of
* truth — shapes below are mirrored, not invented).
*/
import { apiRequest } from "./http.js";
/**
* Response shape — snake_case, exactly as returned by the `products` table.
* Money fields (`price_*`, `impl_unit_price`) come back as `string` (Postgres
* NUMERIC serialized as text by the pg driver) — never coerce to `number` for
* storage, only for display formatting.
*/
export interface Product {
id: string;
created_at: string;
updated_at: string;
created_by: string | null;
product_code: number;
name: string;
description: string | null;
product_type: string;
product_group: string | null;
product_subgroup: string | null;
brand: string | null;
unit: string;
active: boolean;
discontinued: boolean;
tracks_stock: boolean;
tracks_serial: boolean;
tracks_asset_tag: boolean;
tracks_mac: boolean;
price_0: string | null;
price_12: string | null;
price_24: string | null;
price_36: string | null;
price_48: string | null;
impl_unit_price: string;
}
/** Request body — camelCase, matches `CreateProductDto` exactly. */
export interface CreateProductInput {
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;
price0?: number;
price12?: number;
price24?: number;
price36?: number;
price48?: number;
implUnitPrice?: number;
}
/** Matches `UpdateProductDto`: everything optional. */
export type UpdateProductInput = Partial<CreateProductInput>;
export function listProducts(accessToken: string): Promise<Product[]> {
return apiRequest<Product[]>("/products", { method: "GET" }, { accessToken });
}
export function getProduct(id: string, accessToken: string): Promise<Product> {
return apiRequest<Product>(`/products/${id}`, { method: "GET" }, { accessToken });
}
export function createProduct(input: CreateProductInput, accessToken: string): Promise<Product> {
return apiRequest<Product>("/products", { method: "POST", body: JSON.stringify(input) }, { accessToken });
}
export function updateProduct(
id: string,
input: UpdateProductInput,
accessToken: string,
): Promise<Product> {
return apiRequest<Product>(
`/products/${id}`,
{ method: "PATCH", body: JSON.stringify(input) },
{ accessToken },
);
}

View File

@@ -0,0 +1,124 @@
/**
* Client for `apps/api` `/v1/quotes` (see apps/api/src/quotes/quotes.controller.ts,
* quotes.service.ts, dto/*, and packages/database/src/repositories/quotes.ts
* for the source of truth).
*
* Unlike Leads/Opportunities, Quotes pass the DB row straight through with
* no repository-level mapper — the top-level response is snake_case. The
* one exception is `items`, a JSONB column whose element shape
* (`QuoteItem`) IS camelCase — a real inconsistency in the API, not a typo
* here. Money fields (`implementation_fee`, `impl_geral`, `monthly_total`,
* `contract_total`, `proposed_monthly_total`) come back as NUMERIC-typed
* strings from Postgres — always `Number(...)` before formatting/math.
*/
import { apiRequest } from "./http.js";
export const CONTRACT_PERIODS = [0, 12, 24, 36, 48] as const;
export type ContractPeriod = (typeof CONTRACT_PERIODS)[number];
export const IMPL_PAYMENT_CONDITIONS = ["À vista", "1+1", "1+2"] as const;
export type ImplPaymentCondition = (typeof IMPL_PAYMENT_CONDITIONS)[number];
export type ApprovalStatus = "none" | "pending" | "approved" | "rejected";
export type DealStatus = "orcamento" | "fechado" | "perdido";
export interface QuoteItem {
productId: string;
productName: string;
quantity: number;
unitPrice: number;
total: number;
implUnitPrice: number;
implTotal: number;
price0: number | null;
price12: number | null;
price24: number | null;
price36: number | null;
price48: number | null;
}
export interface Quote {
id: string;
created_at: string;
updated_at: string;
created_by: string | null;
quote_number: string;
reseller_id: string;
client_name: string;
client_company: string | null;
client_email: string | null;
client_phone: string;
contract_period: number;
fidelity_period: number | null;
items: QuoteItem[];
implementation_fee: string;
impl_geral: string;
monthly_total: string;
contract_total: string;
proposed_monthly_total: string | null;
approval_status: ApprovalStatus;
approval_notes: string | null;
approved_by_id: string | null;
notes: string | null;
deal_status: DealStatus;
impl_payment_condition: ImplPaymentCondition;
client_registration_id: string | null;
}
export interface QuoteItemInput {
productId: string;
quantity: number;
}
export interface CreateQuoteInput {
resellerId: string;
clientName: string;
clientCompany?: string;
clientEmail?: string;
clientPhone: string;
contractPeriod: ContractPeriod;
fidelityPeriod?: number;
items: QuoteItemInput[];
implGeral?: number;
proposedMonthlyTotal?: number;
implPaymentCondition?: ImplPaymentCondition;
notes?: string;
}
export interface DecideApprovalInput {
decision: "approved" | "rejected";
notes?: string;
}
export interface CloseDealInput {
clientRegistrationId: string;
}
export function listQuotes(accessToken: string, dealStatus?: string): Promise<Quote[]> {
const query = dealStatus ? `?dealStatus=${encodeURIComponent(dealStatus)}` : "";
return apiRequest<Quote[]>(`/quotes${query}`, { method: "GET" }, { accessToken });
}
export function getQuote(id: string, accessToken: string): Promise<Quote> {
return apiRequest<Quote>(`/quotes/${id}`, { method: "GET" }, { accessToken });
}
export function createQuote(input: CreateQuoteInput, accessToken: string): Promise<Quote> {
return apiRequest<Quote>("/quotes", { method: "POST", body: JSON.stringify(input) }, { accessToken });
}
export function decideQuoteApproval(
id: string,
input: DecideApprovalInput,
accessToken: string,
): Promise<Quote> {
return apiRequest<Quote>(
`/quotes/${id}/approval`,
{ method: "PATCH", body: JSON.stringify(input) },
{ accessToken },
);
}
export function closeQuoteDeal(id: string, input: CloseDealInput, accessToken: string): Promise<Quote> {
return apiRequest<Quote>(`/quotes/${id}/close`, { method: "POST", body: JSON.stringify(input) }, { accessToken });
}

View File

@@ -0,0 +1,78 @@
/**
* Client for `apps/api` `/v1/resellers` (see apps/api/src/resellers/resellers.controller.ts,
* resellers.service.ts and packages/database/src/repositories/resellers.ts for the source of
* truth — shapes below are mirrored, not invented).
*/
import { apiRequest } from "./http.js";
/** Response shape — snake_case, exactly as returned by the `resellers` table. */
export interface Reseller {
id: string;
created_at: string;
updated_at: string;
created_by: string | null;
name: string;
document: string | null;
contact_email: string | null;
active: boolean;
}
/** Request body — camelCase, matches `CreateResellerDto` exactly. */
export interface CreateResellerInput {
name: string;
document?: string;
contactEmail?: string;
active?: boolean;
}
/** Matches `UpdateResellerDto`: everything optional. */
export type UpdateResellerInput = Partial<CreateResellerInput>;
export function listResellers(accessToken: string): Promise<Reseller[]> {
return apiRequest<Reseller[]>("/resellers", { method: "GET" }, { accessToken });
}
export function getReseller(id: string, accessToken: string): Promise<Reseller> {
return apiRequest<Reseller>(`/resellers/${id}`, { method: "GET" }, { accessToken });
}
export function createReseller(input: CreateResellerInput, accessToken: string): Promise<Reseller> {
return apiRequest<Reseller>("/resellers", { method: "POST", body: JSON.stringify(input) }, { accessToken });
}
export function updateReseller(
id: string,
input: UpdateResellerInput,
accessToken: string,
): Promise<Reseller> {
return apiRequest<Reseller>(
`/resellers/${id}`,
{ method: "PATCH", body: JSON.stringify(input) },
{ accessToken },
);
}
/** `PATCH /v1/resellers/:id/assign-user` — `:id` in the URL is always the reseller, never the body. */
export function assignUserToReseller(
resellerId: string,
userId: string,
accessToken: string,
): Promise<{ userId: string; resellerId: string }> {
return apiRequest<{ userId: string; resellerId: string }>(
`/resellers/${resellerId}/assign-user`,
{ method: "PATCH", body: JSON.stringify({ userId }) },
{ accessToken },
);
}
/** `POST /v1/resellers/unassign-user` — body `{ userId }`. */
export function unassignUserFromReseller(
userId: string,
accessToken: string,
): Promise<{ userId: string; resellerId: null }> {
return apiRequest<{ userId: string; resellerId: null }>(
"/resellers/unassign-user",
{ method: "POST", body: JSON.stringify({ userId }) },
{ accessToken },
);
}

View File

@@ -0,0 +1,35 @@
/** pt-BR display formatting helpers — currency/document masks only, no business logic. */
const currencyFormatter = new Intl.NumberFormat("pt-BR", { style: "currency", currency: "BRL" });
/**
* Formats a money value for display. Accepts the API's `string | null`
* NUMERIC representation (never coerce it to `number` for anything other
* than display) or a plain `number` from a controlled form input.
*/
export function formatCurrencyBRL(value: string | number | null | undefined): string {
if (value === null || value === undefined || value === "") return currencyFormatter.format(0);
const numeric = typeof value === "number" ? value : Number(value);
if (Number.isNaN(numeric)) return currencyFormatter.format(0);
return currencyFormatter.format(numeric);
}
export function formatDateBR(value: string | Date | null | undefined): string {
if (!value) return "—";
const date = typeof value === "string" ? new Date(value) : value;
if (Number.isNaN(date.getTime())) return "—";
return new Intl.DateTimeFormat("pt-BR").format(date);
}
/** CNPJ (14 digits) or CPF (11 digits) mask — falls back to the raw value for anything else. */
export function formatDocument(value: string | null | undefined): string {
if (!value) return "—";
const digits = value.replace(/\D/g, "");
if (digits.length === 14) {
return digits.replace(/^(\d{2})(\d{3})(\d{3})(\d{4})(\d{2})$/, "$1.$2.$3/$4-$5");
}
if (digits.length === 11) {
return digits.replace(/^(\d{3})(\d{3})(\d{3})(\d{2})$/, "$1.$2.$3-$4");
}
return value;
}

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