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:
@@ -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>
|
||||
|
||||
37
apps/core-web/src/components/Breadcrumb.tsx
Normal file
37
apps/core-web/src/components/Breadcrumb.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
apps/core-web/src/components/linkButtonClasses.ts
Normal file
13
apps/core-web/src/components/linkButtonClasses.ts
Normal 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`;
|
||||
86
apps/core-web/src/lib/api/companies.ts
Normal file
86
apps/core-web/src/lib/api/companies.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
83
apps/core-web/src/lib/api/leads.ts
Normal file
83
apps/core-web/src/lib/api/leads.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
75
apps/core-web/src/lib/api/opportunities.ts
Normal file
75
apps/core-web/src/lib/api/opportunities.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
89
apps/core-web/src/lib/api/products.ts
Normal file
89
apps/core-web/src/lib/api/products.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
124
apps/core-web/src/lib/api/quotes.ts
Normal file
124
apps/core-web/src/lib/api/quotes.ts
Normal 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 });
|
||||
}
|
||||
78
apps/core-web/src/lib/api/resellers.ts
Normal file
78
apps/core-web/src/lib/api/resellers.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
35
apps/core-web/src/lib/format.ts
Normal file
35
apps/core-web/src/lib/format.ts
Normal 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;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
103
apps/core-web/src/routes/companies/CompaniesListPage.tsx
Normal file
103
apps/core-web/src/routes/companies/CompaniesListPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
306
apps/core-web/src/routes/companies/CompanyFormPage.tsx
Normal file
306
apps/core-web/src/routes/companies/CompanyFormPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
138
apps/core-web/src/routes/crm/ConvertLeadPage.tsx
Normal file
138
apps/core-web/src/routes/crm/ConvertLeadPage.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { Alert, Button, Card, Input, PageHeader, Select } from "@eden/ui";
|
||||
import { useAuth } from "../../lib/auth/AuthContext.js";
|
||||
import { ApiError } from "../../lib/api/http.js";
|
||||
import { convertLead, getLead } from "../../lib/api/leads.js";
|
||||
import type { Lead } from "../../lib/api/leads.js";
|
||||
import { OPPORTUNITY_STAGES } from "../../lib/api/opportunities.js";
|
||||
import type { OpportunityStage } from "../../lib/api/opportunities.js";
|
||||
import { Breadcrumb } from "../../components/Breadcrumb.js";
|
||||
|
||||
const STAGE_LABEL: Record<OpportunityStage, string> = {
|
||||
prospecting: "Prospecção",
|
||||
qualification: "Qualificação",
|
||||
proposal: "Proposta",
|
||||
negotiation: "Negociação",
|
||||
won: "Ganha",
|
||||
lost: "Perdida",
|
||||
};
|
||||
|
||||
const STAGE_OPTIONS = OPPORTUNITY_STAGES.map((stage) => ({ value: stage, label: STAGE_LABEL[stage] }));
|
||||
|
||||
export function ConvertLeadPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { accessToken } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [lead, setLead] = useState<Lead | null>(null);
|
||||
const [title, setTitle] = useState("");
|
||||
const [stage, setStage] = useState<OpportunityStage>("prospecting");
|
||||
const [probability, setProbability] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id || !accessToken) return;
|
||||
setLoading(true);
|
||||
getLead(id, accessToken)
|
||||
.then((result) => {
|
||||
setLead(result);
|
||||
setTitle(`${result.contactName}${result.companyName ? ` — ${result.companyName}` : ""}`);
|
||||
})
|
||||
.catch((err) => setError(err instanceof ApiError ? err.message : "Não foi possível carregar o lead."))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id, accessToken]);
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!accessToken || !id) return;
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const result = await convertLead(
|
||||
id,
|
||||
{
|
||||
title,
|
||||
stage,
|
||||
probability: probability ? Number(probability) : undefined,
|
||||
notes: notes || undefined,
|
||||
},
|
||||
accessToken,
|
||||
);
|
||||
navigate(`/opportunities/${result.opportunity.id}/edit`, { replace: true });
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Não foi possível converter o lead.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Breadcrumb items={[{ label: "Leads", href: "/leads" }, { label: "Converter em oportunidade" }]} />
|
||||
<PageHeader
|
||||
title="Converter lead em oportunidade"
|
||||
description={lead ? `Lead: ${lead.contactName}` : undefined}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<Alert variant="error" className="mb-4">
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
{loading ? (
|
||||
<p className="text-sm text-default">Carregando lead…</p>
|
||||
) : (
|
||||
<form className="grid grid-cols-1 gap-4 sm:grid-cols-2" onSubmit={handleSubmit}>
|
||||
<Input
|
||||
label="Título da oportunidade"
|
||||
className="sm:col-span-2"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<Select
|
||||
label="Estágio"
|
||||
options={STAGE_OPTIONS}
|
||||
value={stage}
|
||||
onChange={(e) => setStage(e.target.value as OpportunityStage)}
|
||||
/>
|
||||
<Input
|
||||
label="Probabilidade (%)"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={probability}
|
||||
onChange={(e) => setProbability(e.target.value)}
|
||||
/>
|
||||
<div className="sm:col-span-2 flex flex-col gap-1">
|
||||
<label className="text-sm font-semibold text-gray-900">Notas</label>
|
||||
<textarea
|
||||
className="w-full rounded-md border border-border-color bg-white px-3 py-2.5 text-sm text-title focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||
rows={4}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-2 flex items-center gap-2 pt-2">
|
||||
<Button type="submit" loading={submitting}>
|
||||
Converter
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => navigate("/leads")}>
|
||||
Cancelar
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
apps/core-web/src/routes/crm/CrmSubNav.tsx
Normal file
35
apps/core-web/src/routes/crm/CrmSubNav.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
|
||||
/**
|
||||
* Only two top-level nav slots were allocated for CRM ("CRM" → /leads,
|
||||
* per AuthenticatedLayout.tsx) but the module has two screens (Leads,
|
||||
* Oportunidades) — this small in-page toggle (visual reference: the
|
||||
* list/grid view switch in `.tmp/theme-reference/crm/leads.tsx`,
|
||||
* `inline-flex bg-white border border-border-color rounded-md p-1`) lets
|
||||
* users move between them without a second top-level nav entry.
|
||||
*/
|
||||
export function CrmSubNav() {
|
||||
const location = useLocation();
|
||||
const onLeads = location.pathname.startsWith("/leads");
|
||||
|
||||
return (
|
||||
<div className="mb-4 inline-flex rounded-md border border-border-color bg-white p-1">
|
||||
<Link
|
||||
to="/leads"
|
||||
className={`rounded px-3 py-1.5 text-sm font-semibold ${
|
||||
onLeads ? "bg-dark text-white" : "text-default hover:bg-light"
|
||||
}`}
|
||||
>
|
||||
Leads
|
||||
</Link>
|
||||
<Link
|
||||
to="/opportunities"
|
||||
className={`rounded px-3 py-1.5 text-sm font-semibold ${
|
||||
!onLeads ? "bg-dark text-white" : "text-default hover:bg-light"
|
||||
}`}
|
||||
>
|
||||
Oportunidades
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
144
apps/core-web/src/routes/crm/LeadFormPage.tsx
Normal file
144
apps/core-web/src/routes/crm/LeadFormPage.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { Alert, Button, Card, Input, PageHeader, Select } from "@eden/ui";
|
||||
import { useAuth } from "../../lib/auth/AuthContext.js";
|
||||
import { ApiError } from "../../lib/api/http.js";
|
||||
import { createLead, getLead, LEAD_STATUSES, updateLead } from "../../lib/api/leads.js";
|
||||
import type { LeadStatus } from "../../lib/api/leads.js";
|
||||
import { Breadcrumb } from "../../components/Breadcrumb.js";
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
new: "Novo",
|
||||
contacted: "Contatado",
|
||||
qualified: "Qualificado",
|
||||
converted: "Convertido",
|
||||
discarded: "Descartado",
|
||||
};
|
||||
|
||||
const STATUS_OPTIONS = LEAD_STATUSES.map((status) => ({ value: status, label: STATUS_LABEL[status] ?? status }));
|
||||
|
||||
export function LeadFormPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const isEdit = Boolean(id);
|
||||
const { accessToken } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [source, setSource] = useState("");
|
||||
const [contactName, setContactName] = useState("");
|
||||
const [contactEmail, setContactEmail] = useState("");
|
||||
const [contactPhone, setContactPhone] = useState("");
|
||||
const [companyName, setCompanyName] = useState("");
|
||||
const [status, setStatus] = useState<LeadStatus>("new");
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
const [loading, setLoading] = useState(isEdit);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id || !accessToken) return;
|
||||
setLoading(true);
|
||||
getLead(id, accessToken)
|
||||
.then((lead) => {
|
||||
setSource(lead.source ?? "");
|
||||
setContactName(lead.contactName);
|
||||
setContactEmail(lead.contactEmail ?? "");
|
||||
setContactPhone(lead.contactPhone ?? "");
|
||||
setCompanyName(lead.companyName ?? "");
|
||||
setStatus((lead.status as LeadStatus) ?? "new");
|
||||
setNotes(lead.notes ?? "");
|
||||
})
|
||||
.catch((err) => setError(err instanceof ApiError ? err.message : "Não foi possível carregar o lead."))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id, accessToken]);
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!accessToken) return;
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const input = {
|
||||
source: source || undefined,
|
||||
contactName,
|
||||
contactEmail: contactEmail || undefined,
|
||||
contactPhone: contactPhone || undefined,
|
||||
companyName: companyName || undefined,
|
||||
status,
|
||||
notes: notes || undefined,
|
||||
};
|
||||
if (isEdit && id) {
|
||||
await updateLead(id, input, accessToken);
|
||||
} else {
|
||||
await createLead(input, accessToken);
|
||||
}
|
||||
navigate("/leads", { replace: true });
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Não foi possível salvar o lead.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Breadcrumb items={[{ label: "Leads", href: "/leads" }, { label: isEdit ? "Editar lead" : "Novo lead" }]} />
|
||||
<PageHeader title={isEdit ? "Editar lead" : "Novo lead"} />
|
||||
|
||||
{error ? (
|
||||
<Alert variant="error" className="mb-4">
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
{loading ? (
|
||||
<p className="text-sm text-default">Carregando lead…</p>
|
||||
) : (
|
||||
<form className="grid grid-cols-1 gap-4 sm:grid-cols-2" onSubmit={handleSubmit}>
|
||||
<Input
|
||||
label="Nome de contato"
|
||||
value={contactName}
|
||||
onChange={(e) => setContactName(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<Input label="Empresa" value={companyName} onChange={(e) => setCompanyName(e.target.value)} />
|
||||
<Input
|
||||
label="E-mail"
|
||||
type="email"
|
||||
value={contactEmail}
|
||||
onChange={(e) => setContactEmail(e.target.value)}
|
||||
/>
|
||||
<Input label="Telefone" value={contactPhone} onChange={(e) => setContactPhone(e.target.value)} />
|
||||
<Input label="Origem" value={source} onChange={(e) => setSource(e.target.value)} hint="Ex.: Site, indicação, campanha" />
|
||||
<Select
|
||||
label="Status"
|
||||
options={STATUS_OPTIONS}
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value as LeadStatus)}
|
||||
/>
|
||||
<div className="sm:col-span-2 flex flex-col gap-1">
|
||||
<label className="text-sm font-semibold text-gray-900">Notas</label>
|
||||
<textarea
|
||||
className="w-full rounded-md border border-border-color bg-white px-3 py-2.5 text-sm text-title focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||
rows={4}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-2 flex items-center gap-2 pt-2">
|
||||
<Button type="submit" loading={submitting}>
|
||||
{isEdit ? "Salvar alterações" : "Criar lead"}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => navigate("/leads")}>
|
||||
Cancelar
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
137
apps/core-web/src/routes/crm/LeadsListPage.tsx
Normal file
137
apps/core-web/src/routes/crm/LeadsListPage.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Alert, Badge, Card, PageHeader } from "@eden/ui";
|
||||
import type { BadgeVariant } from "@eden/ui";
|
||||
import { useAuth } from "../../lib/auth/AuthContext.js";
|
||||
import { ApiError } from "../../lib/api/http.js";
|
||||
import { listLeads } from "../../lib/api/leads.js";
|
||||
import type { Lead } from "../../lib/api/leads.js";
|
||||
import { PRIMARY_LINK_BUTTON_CLASS } from "../../components/linkButtonClasses.js";
|
||||
import { CrmSubNav } from "./CrmSubNav.js";
|
||||
|
||||
/**
|
||||
* Layout reference: DreamsERP theme `.tmp/theme-reference/crm/leads.tsx`
|
||||
* (list header with title + "Create" action, flush data table inside a
|
||||
* bordered white card, colored status badges) — rebuilt here with
|
||||
* `@eden/ui` (`PageHeader`, `Card`, `Badge`, `Button`) + plain Tailwind
|
||||
* instead of the theme's antd Datatable/react-select, which are outside
|
||||
* our stack.
|
||||
*/
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
new: "Novo",
|
||||
contacted: "Contatado",
|
||||
qualified: "Qualificado",
|
||||
converted: "Convertido",
|
||||
discarded: "Descartado",
|
||||
};
|
||||
|
||||
const STATUS_VARIANT: Record<string, BadgeVariant> = {
|
||||
new: "info",
|
||||
contacted: "warning",
|
||||
qualified: "primary",
|
||||
converted: "success",
|
||||
discarded: "danger",
|
||||
};
|
||||
|
||||
export function LeadsListPage() {
|
||||
const { accessToken } = useAuth();
|
||||
const [leads, setLeads] = useState<Lead[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken) return;
|
||||
let cancelled = false;
|
||||
setError(null);
|
||||
listLeads(accessToken)
|
||||
.then((result) => {
|
||||
if (!cancelled) setLeads(result);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(err instanceof ApiError ? err.message : "Não foi possível carregar os leads.");
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [accessToken]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CrmSubNav />
|
||||
<PageHeader
|
||||
title="Leads"
|
||||
description="Prospecção comercial — CRM"
|
||||
actions={
|
||||
<Link to="/leads/new" className={PRIMARY_LINK_BUTTON_CLASS}>
|
||||
Novo lead
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<Alert variant="error" className="mb-4">
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Card bodyClassName="p-0">
|
||||
{leads === null && !error ? (
|
||||
<p className="p-5 text-sm text-default">Carregando leads…</p>
|
||||
) : leads && leads.length === 0 ? (
|
||||
<p className="p-5 text-sm text-default">Nenhum lead cadastrado ainda.</p>
|
||||
) : leads ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border-color text-left">
|
||||
<th className="px-5 py-3 font-semibold text-title">Contato</th>
|
||||
<th className="px-5 py-3 font-semibold text-title">Empresa</th>
|
||||
<th className="px-5 py-3 font-semibold text-title">Status</th>
|
||||
<th className="px-5 py-3 font-semibold text-title">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{leads.map((lead) => {
|
||||
const canConvert = lead.status !== "converted" && lead.status !== "discarded";
|
||||
return (
|
||||
<tr key={lead.id} className="border-b border-border-color last:border-0 hover:bg-light">
|
||||
<td className="px-5 py-3">
|
||||
<Link to={`/leads/${lead.id}/edit`} className="font-semibold text-title hover:text-primary">
|
||||
{lead.contactName}
|
||||
</Link>
|
||||
{lead.contactEmail ? <p className="text-xs text-default">{lead.contactEmail}</p> : null}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-default">{lead.companyName ?? "—"}</td>
|
||||
<td className="px-5 py-3">
|
||||
<Badge variant={STATUS_VARIANT[lead.status] ?? "neutral"}>
|
||||
{STATUS_LABEL[lead.status] ?? lead.status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link
|
||||
to={`/leads/${lead.id}/edit`}
|
||||
className="text-xs font-semibold text-default hover:text-primary"
|
||||
>
|
||||
Editar
|
||||
</Link>
|
||||
{canConvert ? (
|
||||
<Link
|
||||
to={`/leads/${lead.id}/convert`}
|
||||
className="rounded-md border border-border-color px-2 py-1 text-xs font-semibold text-title hover:bg-light"
|
||||
>
|
||||
Converter em oportunidade
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
130
apps/core-web/src/routes/crm/OpportunitiesListPage.tsx
Normal file
130
apps/core-web/src/routes/crm/OpportunitiesListPage.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Alert, Badge, Card, PageHeader } from "@eden/ui";
|
||||
import type { BadgeVariant } from "@eden/ui";
|
||||
import { useAuth } from "../../lib/auth/AuthContext.js";
|
||||
import { ApiError } from "../../lib/api/http.js";
|
||||
import { listOpportunities } from "../../lib/api/opportunities.js";
|
||||
import type { Opportunity } from "../../lib/api/opportunities.js";
|
||||
import { PRIMARY_LINK_BUTTON_CLASS } from "../../components/linkButtonClasses.js";
|
||||
import { CrmSubNav } from "./CrmSubNav.js";
|
||||
|
||||
/**
|
||||
* Layout reference: DreamsERP theme `.tmp/theme-reference/crm/deals.tsx`
|
||||
* (kanban-oriented in the theme; kept as a flat table here for MVP scope,
|
||||
* same header/card/table language as `LeadsListPage`).
|
||||
*/
|
||||
const STAGE_LABEL: Record<string, string> = {
|
||||
prospecting: "Prospecção",
|
||||
qualification: "Qualificação",
|
||||
proposal: "Proposta",
|
||||
negotiation: "Negociação",
|
||||
won: "Ganha",
|
||||
lost: "Perdida",
|
||||
};
|
||||
|
||||
const STAGE_VARIANT: Record<string, BadgeVariant> = {
|
||||
prospecting: "info",
|
||||
qualification: "primary",
|
||||
proposal: "warning",
|
||||
negotiation: "secondary",
|
||||
won: "success",
|
||||
lost: "danger",
|
||||
};
|
||||
|
||||
export function OpportunitiesListPage() {
|
||||
const { accessToken } = useAuth();
|
||||
const [opportunities, setOpportunities] = useState<Opportunity[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken) return;
|
||||
let cancelled = false;
|
||||
setError(null);
|
||||
listOpportunities(accessToken)
|
||||
.then((result) => {
|
||||
if (!cancelled) setOpportunities(result);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled)
|
||||
setError(err instanceof ApiError ? err.message : "Não foi possível carregar as oportunidades.");
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [accessToken]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CrmSubNav />
|
||||
<PageHeader
|
||||
title="Oportunidades"
|
||||
description="Funil comercial — CRM"
|
||||
actions={
|
||||
<Link to="/opportunities/new" className={PRIMARY_LINK_BUTTON_CLASS}>
|
||||
Nova oportunidade
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<Alert variant="error" className="mb-4">
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Card bodyClassName="p-0">
|
||||
{opportunities === null && !error ? (
|
||||
<p className="p-5 text-sm text-default">Carregando oportunidades…</p>
|
||||
) : opportunities && opportunities.length === 0 ? (
|
||||
<p className="p-5 text-sm text-default">Nenhuma oportunidade cadastrada ainda.</p>
|
||||
) : opportunities ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border-color text-left">
|
||||
<th className="px-5 py-3 font-semibold text-title">Título</th>
|
||||
<th className="px-5 py-3 font-semibold text-title">Estágio</th>
|
||||
<th className="px-5 py-3 font-semibold text-title">Probabilidade</th>
|
||||
<th className="px-5 py-3 font-semibold text-title">Dono</th>
|
||||
<th className="px-5 py-3 font-semibold text-title">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{opportunities.map((opportunity) => (
|
||||
<tr key={opportunity.id} className="border-b border-border-color last:border-0 hover:bg-light">
|
||||
<td className="px-5 py-3">
|
||||
<Link
|
||||
to={`/opportunities/${opportunity.id}/edit`}
|
||||
className="font-semibold text-title hover:text-primary"
|
||||
>
|
||||
{opportunity.title}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<Badge variant={STAGE_VARIANT[opportunity.stage] ?? "neutral"}>
|
||||
{STAGE_LABEL[opportunity.stage] ?? opportunity.stage}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-default">
|
||||
{opportunity.probability != null ? `${opportunity.probability}%` : "—"}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-default">{opportunity.ownerId ?? "—"}</td>
|
||||
<td className="px-5 py-3">
|
||||
<Link
|
||||
to={`/opportunities/${opportunity.id}/edit`}
|
||||
className="text-xs font-semibold text-default hover:text-primary"
|
||||
>
|
||||
Editar
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
157
apps/core-web/src/routes/crm/OpportunityFormPage.tsx
Normal file
157
apps/core-web/src/routes/crm/OpportunityFormPage.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { Alert, Button, Card, Input, PageHeader, Select } from "@eden/ui";
|
||||
import { useAuth } from "../../lib/auth/AuthContext.js";
|
||||
import { ApiError } from "../../lib/api/http.js";
|
||||
import {
|
||||
createOpportunity,
|
||||
getOpportunity,
|
||||
OPPORTUNITY_STAGES,
|
||||
updateOpportunity,
|
||||
} from "../../lib/api/opportunities.js";
|
||||
import type { OpportunityStage } from "../../lib/api/opportunities.js";
|
||||
import { Breadcrumb } from "../../components/Breadcrumb.js";
|
||||
|
||||
const STAGE_LABEL: Record<OpportunityStage, string> = {
|
||||
prospecting: "Prospecção",
|
||||
qualification: "Qualificação",
|
||||
proposal: "Proposta",
|
||||
negotiation: "Negociação",
|
||||
won: "Ganha",
|
||||
lost: "Perdida",
|
||||
};
|
||||
|
||||
const STAGE_OPTIONS = OPPORTUNITY_STAGES.map((stage) => ({ value: stage, label: STAGE_LABEL[stage] }));
|
||||
|
||||
export function OpportunityFormPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const isEdit = Boolean(id);
|
||||
const { accessToken } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [title, setTitle] = useState("");
|
||||
const [stage, setStage] = useState<OpportunityStage>("prospecting");
|
||||
const [probability, setProbability] = useState("");
|
||||
const [lossReason, setLossReason] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
const [loading, setLoading] = useState(isEdit);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id || !accessToken) return;
|
||||
setLoading(true);
|
||||
getOpportunity(id, accessToken)
|
||||
.then((opportunity) => {
|
||||
setTitle(opportunity.title);
|
||||
setStage((opportunity.stage as OpportunityStage) ?? "prospecting");
|
||||
setProbability(opportunity.probability != null ? String(opportunity.probability) : "");
|
||||
setLossReason(opportunity.lossReason ?? "");
|
||||
setNotes(opportunity.notes ?? "");
|
||||
})
|
||||
.catch((err) => setError(err instanceof ApiError ? err.message : "Não foi possível carregar a oportunidade."))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id, accessToken]);
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!accessToken) return;
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const input = {
|
||||
title,
|
||||
stage,
|
||||
probability: probability ? Number(probability) : undefined,
|
||||
lossReason: stage === "lost" && lossReason ? lossReason : undefined,
|
||||
notes: notes || undefined,
|
||||
};
|
||||
if (isEdit && id) {
|
||||
await updateOpportunity(id, input, accessToken);
|
||||
} else {
|
||||
await createOpportunity(input, accessToken);
|
||||
}
|
||||
navigate("/opportunities", { replace: true });
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Não foi possível salvar a oportunidade.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{ label: "Oportunidades", href: "/opportunities" },
|
||||
{ label: isEdit ? "Editar oportunidade" : "Nova oportunidade" },
|
||||
]}
|
||||
/>
|
||||
<PageHeader title={isEdit ? "Editar oportunidade" : "Nova oportunidade"} />
|
||||
|
||||
{error ? (
|
||||
<Alert variant="error" className="mb-4">
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
{loading ? (
|
||||
<p className="text-sm text-default">Carregando oportunidade…</p>
|
||||
) : (
|
||||
<form className="grid grid-cols-1 gap-4 sm:grid-cols-2" onSubmit={handleSubmit}>
|
||||
<Input
|
||||
label="Título"
|
||||
className="sm:col-span-2"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<Select
|
||||
label="Estágio"
|
||||
options={STAGE_OPTIONS}
|
||||
value={stage}
|
||||
onChange={(e) => setStage(e.target.value as OpportunityStage)}
|
||||
/>
|
||||
<Input
|
||||
label="Probabilidade (%)"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={probability}
|
||||
onChange={(e) => setProbability(e.target.value)}
|
||||
/>
|
||||
{stage === "lost" ? (
|
||||
<Input
|
||||
label="Motivo da perda"
|
||||
className="sm:col-span-2"
|
||||
value={lossReason}
|
||||
onChange={(e) => setLossReason(e.target.value)}
|
||||
/>
|
||||
) : null}
|
||||
<div className="sm:col-span-2 flex flex-col gap-1">
|
||||
<label className="text-sm font-semibold text-gray-900">Notas</label>
|
||||
<textarea
|
||||
className="w-full rounded-md border border-border-color bg-white px-3 py-2.5 text-sm text-title focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||
rows={4}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-2 flex items-center gap-2 pt-2">
|
||||
<Button type="submit" loading={submitting}>
|
||||
{isEdit ? "Salvar alterações" : "Criar oportunidade"}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => navigate("/opportunities")}>
|
||||
Cancelar
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
300
apps/core-web/src/routes/products/ProductFormPage.tsx
Normal file
300
apps/core-web/src/routes/products/ProductFormPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
101
apps/core-web/src/routes/products/ProductsListPage.tsx
Normal file
101
apps/core-web/src/routes/products/ProductsListPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
327
apps/core-web/src/routes/quotes/QuoteDetailPage.tsx
Normal file
327
apps/core-web/src/routes/quotes/QuoteDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
381
apps/core-web/src/routes/quotes/QuoteFormPage.tsx
Normal file
381
apps/core-web/src/routes/quotes/QuoteFormPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
132
apps/core-web/src/routes/quotes/QuotesListPage.tsx
Normal file
132
apps/core-web/src/routes/quotes/QuotesListPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
153
apps/core-web/src/routes/resellers/ResellerFormPage.tsx
Normal file
153
apps/core-web/src/routes/resellers/ResellerFormPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
apps/core-web/src/routes/resellers/ResellersListPage.tsx
Normal file
79
apps/core-web/src/routes/resellers/ResellersListPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user