diff --git a/apps/core-web/src/App.tsx b/apps/core-web/src/App.tsx index 4e315dd..cb2c722 100644 --- a/apps/core-web/src/App.tsx +++ b/apps/core-web/src/App.tsx @@ -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() { }> }> } /> + + } /> + } /> + } /> + + } /> + } /> + } /> + + } /> + } /> + } /> + + } /> + } /> + } /> + } /> + + } /> + } /> + } /> + + } /> + } /> + } /> diff --git a/apps/core-web/src/components/Breadcrumb.tsx b/apps/core-web/src/components/Breadcrumb.tsx new file mode 100644 index 0000000..d831787 --- /dev/null +++ b/apps/core-web/src/components/Breadcrumb.tsx @@ -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 ( + + {items.map((item, index) => { + const isLast = index === items.length - 1; + return ( + + {index > 0 ? / : null} + {isLast || !item.href ? ( + {item.label} + ) : ( + + {item.label} + + )} + + ); + })} + + ); +} diff --git a/apps/core-web/src/components/linkButtonClasses.ts b/apps/core-web/src/components/linkButtonClasses.ts new file mode 100644 index 0000000..692ff30 --- /dev/null +++ b/apps/core-web/src/components/linkButtonClasses.ts @@ -0,0 +1,13 @@ +/** + * `@eden/ui`'s `Button` renders a `` — 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`; diff --git a/apps/core-web/src/lib/api/companies.ts b/apps/core-web/src/lib/api/companies.ts new file mode 100644 index 0000000..39b23a9 --- /dev/null +++ b/apps/core-web/src/lib/api/companies.ts @@ -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 & { isActive?: boolean }; + +export function listCompanies(accessToken: string): Promise { + return apiRequest("/companies", { method: "GET" }, { accessToken }); +} + +export function getCompany(id: string, accessToken: string): Promise { + return apiRequest(`/companies/${id}`, { method: "GET" }, { accessToken }); +} + +export function createCompany(input: CreateCompanyInput, accessToken: string): Promise { + return apiRequest("/companies", { method: "POST", body: JSON.stringify(input) }, { accessToken }); +} + +export function updateCompany( + id: string, + input: UpdateCompanyInput, + accessToken: string, +): Promise { + return apiRequest( + `/companies/${id}`, + { method: "PATCH", body: JSON.stringify(input) }, + { accessToken }, + ); +} diff --git a/apps/core-web/src/lib/api/leads.ts b/apps/core-web/src/lib/api/leads.ts new file mode 100644 index 0000000..2ec445b --- /dev/null +++ b/apps/core-web/src/lib/api/leads.ts @@ -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; + +export interface ConvertLeadInput { + title: string; + stage?: string; + probability?: number; + notes?: string; +} + +export interface ConvertLeadResult { + lead: Lead; + opportunity: Opportunity; +} + +export function listLeads(accessToken: string): Promise { + return apiRequest("/leads", { method: "GET" }, { accessToken }); +} + +export function getLead(id: string, accessToken: string): Promise { + return apiRequest(`/leads/${id}`, { method: "GET" }, { accessToken }); +} + +export function createLead(input: CreateLeadInput, accessToken: string): Promise { + return apiRequest("/leads", { method: "POST", body: JSON.stringify(input) }, { accessToken }); +} + +export function updateLead(id: string, input: UpdateLeadInput, accessToken: string): Promise { + return apiRequest(`/leads/${id}`, { method: "PATCH", body: JSON.stringify(input) }, { accessToken }); +} + +export function convertLead( + id: string, + input: ConvertLeadInput, + accessToken: string, +): Promise { + return apiRequest( + `/leads/${id}/convert`, + { method: "POST", body: JSON.stringify(input) }, + { accessToken }, + ); +} diff --git a/apps/core-web/src/lib/api/opportunities.ts b/apps/core-web/src/lib/api/opportunities.ts new file mode 100644 index 0000000..0141399 --- /dev/null +++ b/apps/core-web/src/lib/api/opportunities.ts @@ -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; + +export function listOpportunities(accessToken: string): Promise { + return apiRequest("/opportunities", { method: "GET" }, { accessToken }); +} + +export function getOpportunity(id: string, accessToken: string): Promise { + return apiRequest(`/opportunities/${id}`, { method: "GET" }, { accessToken }); +} + +export function createOpportunity( + input: CreateOpportunityInput, + accessToken: string, +): Promise { + return apiRequest( + "/opportunities", + { method: "POST", body: JSON.stringify(input) }, + { accessToken }, + ); +} + +export function updateOpportunity( + id: string, + input: UpdateOpportunityInput, + accessToken: string, +): Promise { + return apiRequest( + `/opportunities/${id}`, + { method: "PATCH", body: JSON.stringify(input) }, + { accessToken }, + ); +} diff --git a/apps/core-web/src/lib/api/products.ts b/apps/core-web/src/lib/api/products.ts new file mode 100644 index 0000000..6b98607 --- /dev/null +++ b/apps/core-web/src/lib/api/products.ts @@ -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; + +export function listProducts(accessToken: string): Promise { + return apiRequest("/products", { method: "GET" }, { accessToken }); +} + +export function getProduct(id: string, accessToken: string): Promise { + return apiRequest(`/products/${id}`, { method: "GET" }, { accessToken }); +} + +export function createProduct(input: CreateProductInput, accessToken: string): Promise { + return apiRequest("/products", { method: "POST", body: JSON.stringify(input) }, { accessToken }); +} + +export function updateProduct( + id: string, + input: UpdateProductInput, + accessToken: string, +): Promise { + return apiRequest( + `/products/${id}`, + { method: "PATCH", body: JSON.stringify(input) }, + { accessToken }, + ); +} diff --git a/apps/core-web/src/lib/api/quotes.ts b/apps/core-web/src/lib/api/quotes.ts new file mode 100644 index 0000000..7ca84b9 --- /dev/null +++ b/apps/core-web/src/lib/api/quotes.ts @@ -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 { + const query = dealStatus ? `?dealStatus=${encodeURIComponent(dealStatus)}` : ""; + return apiRequest(`/quotes${query}`, { method: "GET" }, { accessToken }); +} + +export function getQuote(id: string, accessToken: string): Promise { + return apiRequest(`/quotes/${id}`, { method: "GET" }, { accessToken }); +} + +export function createQuote(input: CreateQuoteInput, accessToken: string): Promise { + return apiRequest("/quotes", { method: "POST", body: JSON.stringify(input) }, { accessToken }); +} + +export function decideQuoteApproval( + id: string, + input: DecideApprovalInput, + accessToken: string, +): Promise { + return apiRequest( + `/quotes/${id}/approval`, + { method: "PATCH", body: JSON.stringify(input) }, + { accessToken }, + ); +} + +export function closeQuoteDeal(id: string, input: CloseDealInput, accessToken: string): Promise { + return apiRequest(`/quotes/${id}/close`, { method: "POST", body: JSON.stringify(input) }, { accessToken }); +} diff --git a/apps/core-web/src/lib/api/resellers.ts b/apps/core-web/src/lib/api/resellers.ts new file mode 100644 index 0000000..e2aa409 --- /dev/null +++ b/apps/core-web/src/lib/api/resellers.ts @@ -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; + +export function listResellers(accessToken: string): Promise { + return apiRequest("/resellers", { method: "GET" }, { accessToken }); +} + +export function getReseller(id: string, accessToken: string): Promise { + return apiRequest(`/resellers/${id}`, { method: "GET" }, { accessToken }); +} + +export function createReseller(input: CreateResellerInput, accessToken: string): Promise { + return apiRequest("/resellers", { method: "POST", body: JSON.stringify(input) }, { accessToken }); +} + +export function updateReseller( + id: string, + input: UpdateResellerInput, + accessToken: string, +): Promise { + return apiRequest( + `/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 }, + ); +} diff --git a/apps/core-web/src/lib/format.ts b/apps/core-web/src/lib/format.ts new file mode 100644 index 0000000..c30f27b --- /dev/null +++ b/apps/core-web/src/lib/format.ts @@ -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; +} diff --git a/apps/core-web/src/routes/AuthenticatedLayout.tsx b/apps/core-web/src/routes/AuthenticatedLayout.tsx index c2d385b..3efa306 100644 --- a/apps/core-web/src/routes/AuthenticatedLayout.tsx +++ b/apps/core-web/src/routes/AuthenticatedLayout.tsx @@ -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() { (null); + const [error, setError] = useState(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> = [ + { + key: "name", + header: "Nome", + render: (company) => ( + + {company.company_name} + {company.trade_name ? {company.trade_name} : null} + + ), + }, + { 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) => ( + {company.is_active ? "Ativo" : "Inativo"} + ), + }, + { + key: "actions", + header: "", + className: "text-right", + render: (company) => ( + navigate(`/companies/${company.id}/edit`)}> + Editar + + ), + }, + ]; + + return ( + + navigate("/companies/new")}>Nova empresa} + /> + + {error ? ( + + {error} + + ) : null} + + + {companies === null && !error ? ( + Carregando… + ) : ( + company.id} /> + )} + + + ); +} diff --git a/apps/core-web/src/routes/companies/CompanyFormPage.tsx b/apps/core-web/src/routes/companies/CompanyFormPage.tsx new file mode 100644 index 0000000..c69ae6d --- /dev/null +++ b/apps/core-web/src/routes/companies/CompanyFormPage.tsx @@ -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 { + 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(EMPTY_FORM); + const [loading, setLoading] = useState(isEditing); + const [error, setError] = useState(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(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 ( + + + ← Voltar para Empresas + + } + actions={ + <> + navigate("/companies")} type="button"> + Cancelar + + + Salvar + + > + } + /> + + {error ? ( + + {error} + + ) : null} + + {loading ? ( + + Carregando… + + ) : ( + + + + + updateField("companyName", e.target.value)} + required + /> + + updateField("tradeName", e.target.value)} + /> + updateField("cnpj", e.target.value)} /> + updateField("stateRegistration", e.target.value)} + /> + updateField("municipalRegistration", e.target.value)} + /> + {isEditing ? ( + + Status + + updateField("isActive", e.target.checked)} + /> + Empresa ativa + + + ) : null} + + + + + + updateField("addressZip", e.target.value)} /> + + updateField("addressStreet", e.target.value)} + /> + + updateField("addressNumber", e.target.value)} + /> + updateField("addressComplement", e.target.value)} + /> + updateField("addressNeighborhood", e.target.value)} + /> + updateField("addressCity", e.target.value)} + /> + updateField("addressState", e.target.value.toUpperCase())} + /> + updateField("addressCountry", e.target.value)} + placeholder="Brasil" + /> + + + + + + updateField("phone", e.target.value)} /> + updateField("email", e.target.value)} + /> + updateField("website", e.target.value)} /> + + + + + + updateField("legalRepName", e.target.value)} + /> + updateField("legalRepCpf", e.target.value)} + /> + updateField("legalRepRole", e.target.value)} + /> + updateField("legalRepEmail", e.target.value)} + /> + + + + )} + + ); +} diff --git a/apps/core-web/src/routes/crm/ConvertLeadPage.tsx b/apps/core-web/src/routes/crm/ConvertLeadPage.tsx new file mode 100644 index 0000000..d914c51 --- /dev/null +++ b/apps/core-web/src/routes/crm/ConvertLeadPage.tsx @@ -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 = { + 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(null); + const [title, setTitle] = useState(""); + const [stage, setStage] = useState("prospecting"); + const [probability, setProbability] = useState(""); + const [notes, setNotes] = useState(""); + + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(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 ( + + + + + {error ? ( + + {error} + + ) : null} + + + {loading ? ( + Carregando lead… + ) : ( + + setTitle(e.target.value)} + required + autoFocus + /> + setStage(e.target.value as OpportunityStage)} + /> + setProbability(e.target.value)} + /> + + Notas + setNotes(e.target.value)} + /> + + + + Converter + + navigate("/leads")}> + Cancelar + + + + )} + + + ); +} diff --git a/apps/core-web/src/routes/crm/CrmSubNav.tsx b/apps/core-web/src/routes/crm/CrmSubNav.tsx new file mode 100644 index 0000000..03d5697 --- /dev/null +++ b/apps/core-web/src/routes/crm/CrmSubNav.tsx @@ -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 ( + + + Leads + + + Oportunidades + + + ); +} diff --git a/apps/core-web/src/routes/crm/LeadFormPage.tsx b/apps/core-web/src/routes/crm/LeadFormPage.tsx new file mode 100644 index 0000000..0d41695 --- /dev/null +++ b/apps/core-web/src/routes/crm/LeadFormPage.tsx @@ -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 = { + 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("new"); + const [notes, setNotes] = useState(""); + + const [loading, setLoading] = useState(isEdit); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(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 ( + + + + + {error ? ( + + {error} + + ) : null} + + + {loading ? ( + Carregando lead… + ) : ( + + setContactName(e.target.value)} + required + autoFocus + /> + setCompanyName(e.target.value)} /> + setContactEmail(e.target.value)} + /> + setContactPhone(e.target.value)} /> + setSource(e.target.value)} hint="Ex.: Site, indicação, campanha" /> + setStatus(e.target.value as LeadStatus)} + /> + + Notas + setNotes(e.target.value)} + /> + + + + {isEdit ? "Salvar alterações" : "Criar lead"} + + navigate("/leads")}> + Cancelar + + + + )} + + + ); +} diff --git a/apps/core-web/src/routes/crm/LeadsListPage.tsx b/apps/core-web/src/routes/crm/LeadsListPage.tsx new file mode 100644 index 0000000..e8ca3ab --- /dev/null +++ b/apps/core-web/src/routes/crm/LeadsListPage.tsx @@ -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 = { + new: "Novo", + contacted: "Contatado", + qualified: "Qualificado", + converted: "Convertido", + discarded: "Descartado", +}; + +const STATUS_VARIANT: Record = { + new: "info", + contacted: "warning", + qualified: "primary", + converted: "success", + discarded: "danger", +}; + +export function LeadsListPage() { + const { accessToken } = useAuth(); + const [leads, setLeads] = useState(null); + const [error, setError] = useState(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 ( + + + + Novo lead + + } + /> + + {error ? ( + + {error} + + ) : null} + + + {leads === null && !error ? ( + Carregando leads… + ) : leads && leads.length === 0 ? ( + Nenhum lead cadastrado ainda. + ) : leads ? ( + + + + + Contato + Empresa + Status + Ações + + + + {leads.map((lead) => { + const canConvert = lead.status !== "converted" && lead.status !== "discarded"; + return ( + + + + {lead.contactName} + + {lead.contactEmail ? {lead.contactEmail} : null} + + {lead.companyName ?? "—"} + + + {STATUS_LABEL[lead.status] ?? lead.status} + + + + + + Editar + + {canConvert ? ( + + Converter em oportunidade + + ) : null} + + + + ); + })} + + + + ) : null} + + + ); +} diff --git a/apps/core-web/src/routes/crm/OpportunitiesListPage.tsx b/apps/core-web/src/routes/crm/OpportunitiesListPage.tsx new file mode 100644 index 0000000..7e83021 --- /dev/null +++ b/apps/core-web/src/routes/crm/OpportunitiesListPage.tsx @@ -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 = { + prospecting: "Prospecção", + qualification: "Qualificação", + proposal: "Proposta", + negotiation: "Negociação", + won: "Ganha", + lost: "Perdida", +}; + +const STAGE_VARIANT: Record = { + prospecting: "info", + qualification: "primary", + proposal: "warning", + negotiation: "secondary", + won: "success", + lost: "danger", +}; + +export function OpportunitiesListPage() { + const { accessToken } = useAuth(); + const [opportunities, setOpportunities] = useState(null); + const [error, setError] = useState(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 ( + + + + Nova oportunidade + + } + /> + + {error ? ( + + {error} + + ) : null} + + + {opportunities === null && !error ? ( + Carregando oportunidades… + ) : opportunities && opportunities.length === 0 ? ( + Nenhuma oportunidade cadastrada ainda. + ) : opportunities ? ( + + + + + Título + Estágio + Probabilidade + Dono + Ações + + + + {opportunities.map((opportunity) => ( + + + + {opportunity.title} + + + + + {STAGE_LABEL[opportunity.stage] ?? opportunity.stage} + + + + {opportunity.probability != null ? `${opportunity.probability}%` : "—"} + + {opportunity.ownerId ?? "—"} + + + Editar + + + + ))} + + + + ) : null} + + + ); +} diff --git a/apps/core-web/src/routes/crm/OpportunityFormPage.tsx b/apps/core-web/src/routes/crm/OpportunityFormPage.tsx new file mode 100644 index 0000000..8fe8042 --- /dev/null +++ b/apps/core-web/src/routes/crm/OpportunityFormPage.tsx @@ -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 = { + 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("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(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 ( + + + + + {error ? ( + + {error} + + ) : null} + + + {loading ? ( + Carregando oportunidade… + ) : ( + + setTitle(e.target.value)} + required + autoFocus + /> + setStage(e.target.value as OpportunityStage)} + /> + setProbability(e.target.value)} + /> + {stage === "lost" ? ( + setLossReason(e.target.value)} + /> + ) : null} + + Notas + setNotes(e.target.value)} + /> + + + + {isEdit ? "Salvar alterações" : "Criar oportunidade"} + + navigate("/opportunities")}> + Cancelar + + + + )} + + + ); +} diff --git a/apps/core-web/src/routes/products/ProductFormPage.tsx b/apps/core-web/src/routes/products/ProductFormPage.tsx new file mode 100644 index 0000000..8c8d9b9 --- /dev/null +++ b/apps/core-web/src/routes/products/ProductFormPage.tsx @@ -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 ( + 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(EMPTY_FORM); + const [loading, setLoading] = useState(isEditing); + const [error, setError] = useState(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(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 ( + + + ← Voltar para Produtos + + } + actions={ + <> + navigate("/products")} type="button"> + Cancelar + + + Salvar + + > + } + /> + + {error ? ( + + {error} + + ) : null} + + {loading ? ( + + Carregando… + + ) : ( + + + + + updateField("name", e.target.value)} required /> + + updateField("productType", e.target.value)} + required + /> + updateField("unit", e.target.value)} /> + updateField("productGroup", e.target.value)} + /> + updateField("productSubgroup", e.target.value)} + /> + updateField("brand", e.target.value)} /> + + + Descrição + + updateField("description", e.target.value)} + /> + + + + + + + updateField("price0Cents", v)} /> + updateField("price12Cents", v)} /> + updateField("price24Cents", v)} /> + updateField("price36Cents", v)} /> + updateField("price48Cents", v)} /> + updateField("implUnitPriceCents", v)} + /> + + + + + + {( + [ + ["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]) => ( + + updateField(field, e.target.checked)} + /> + {label} + + ))} + + + + )} + + ); +} diff --git a/apps/core-web/src/routes/products/ProductsListPage.tsx b/apps/core-web/src/routes/products/ProductsListPage.tsx new file mode 100644 index 0000000..14deccb --- /dev/null +++ b/apps/core-web/src/routes/products/ProductsListPage.tsx @@ -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(null); + const [error, setError] = useState(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> = [ + { key: "code", header: "Código", render: (product) => `#${product.product_code}` }, + { key: "name", header: "Nome", render: (product) => {product.name} }, + { key: "type", header: "Tipo", render: (product) => product.product_type }, + ...PRICE_COLUMNS.map( + (column): TableColumn => ({ + 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 ? ( + Descontinuado + ) : ( + {product.active ? "Ativo" : "Inativo"} + ), + }, + { + key: "actions", + header: "", + className: "text-right", + render: (product) => ( + navigate(`/products/${product.id}/edit`)}> + Editar + + ), + }, + ]; + + return ( + + navigate("/products/new")}>Novo produto} + /> + + {error ? ( + + {error} + + ) : null} + + + {products === null && !error ? ( + Carregando… + ) : ( + product.id} /> + )} + + + ); +} diff --git a/apps/core-web/src/routes/quotes/QuoteDetailPage.tsx b/apps/core-web/src/routes/quotes/QuoteDetailPage.tsx new file mode 100644 index 0000000..bec95a5 --- /dev/null +++ b/apps/core-web/src/routes/quotes/QuoteDetailPage.tsx @@ -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 = { + none: "Sem aprovação", + pending: "Pendente", + approved: "Aprovada", + rejected: "Rejeitada", +}; + +const APPROVAL_VARIANT: Record = { + none: "neutral", + pending: "warning", + approved: "success", + rejected: "danger", +}; + +const DEAL_LABEL: Record = { + orcamento: "Orçamento", + fechado: "Fechado", + perdido: "Perdido", +}; + +const DEAL_VARIANT: Record = { + orcamento: "info", + fechado: "success", + perdido: "danger", +}; + +export function QuoteDetailPage() { + const { id } = useParams<{ id: string }>(); + const { accessToken } = useAuth(); + + const [quote, setQuote] = useState(null); + const [reseller, setReseller] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( + + + Carregando oferta… + + ); + } + + if (error || !quote) { + return ( + + + {error ?? "Oferta não encontrada."} + + ); + } + + return ( + + + + {APPROVAL_LABEL[quote.approval_status]} + {DEAL_LABEL[quote.deal_status]} + + } + /> + + + + + + + + + + + + + + + + + + + + + + + + + Produto + Qtd. + Preço unit. + Total + Impl. unit. + Impl. total + + + + {quote.items.map((item, index) => ( + + {item.productName} + {item.quantity} + {formatCurrencyBRL(item.unitPrice)} + {formatCurrencyBRL(item.total)} + {formatCurrencyBRL(item.implUnitPrice)} + {formatCurrencyBRL(item.implTotal)} + + ))} + + + + + + + + + {quote.proposed_monthly_total != null ? ( + + ) : null} + + + + + {quote.notes ? ( + + Notas + {quote.notes} + + ) : null} + + + {quote.approval_status === "pending" ? ( + + ) : quote.approval_status !== "none" && quote.approval_notes ? ( + + {quote.approval_notes} + + ) : null} + + {quote.deal_status === "orcamento" ? ( + + ) : quote.client_registration_id ? ( + + + + ) : null} + + + + ); +} + +function Row({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ); +} + +function SummaryRow({ label, value, highlight }: { label: string; value: string; highlight?: boolean }) { + return ( + + {label} + + {value} + + + ); +} + +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(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 ( + + {error ? ( + + {error} + + ) : null} + + Esta oferta tem desconto e/ou fidelidade reduzida e precisa de aprovação antes de seguir. + + setNotes(e.target.value)} + className="mb-3" + /> + + decide("approved")} loading={submitting === "approved"}> + Aprovar + + decide("rejected")} loading={submitting === "rejected"}> + Rejeitar + + + + ); +} + +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(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 ( + + {error ? ( + + {error} + + ) : null} + + setClientRegistrationId(e.target.value)} + hint="Cole o UUID de um cadastro de cliente já existente" + className="min-w-72 flex-1" + required + /> + + Fechar negócio + + + + ); +} diff --git a/apps/core-web/src/routes/quotes/QuoteFormPage.tsx b/apps/core-web/src/routes/quotes/QuoteFormPage.tsx new file mode 100644 index 0000000..1ac1f6e --- /dev/null +++ b/apps/core-web/src/routes/quotes/QuoteFormPage.tsx @@ -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(null); + const [products, setProducts] = useState(null); + const [loadError, setLoadError] = useState(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("12"); + const [fidelityPeriod, setFidelityPeriod] = useState(""); + const [items, setItems] = useState([{ ...EMPTY_ROW }]); + const [implGeral, setImplGeral] = useState(""); + const [proposedMonthlyTotal, setProposedMonthlyTotal] = useState(""); + const [implPaymentCondition, setImplPaymentCondition] = useState(""); + const [notes, setNotes] = useState(""); + + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [createdQuote, setCreatedQuote] = useState(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) { + 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 ( + + + + Oferta criada com sucesso. Confira os totais calculados pelo servidor abaixo. + + + + + + + + {createdQuote.approval_status === "pending" ? ( + + Esta oferta possui desconto e/ou fidelidade reduzida — aguarda aprovação. + + ) : null} + + + Ver oferta completa + + navigate("/quotes")}> + Voltar para a lista + + + + + ); + } + + return ( + + + + + {loadError ? ( + + {loadError} + + ) : null} + {error ? ( + + {error} + + ) : null} + + + {!resellers || !products ? ( + Carregando revendas e produtos… + ) : ( + + + Dados do cliente + + ({ value: r.id, label: r.name }))} + placeholder="Selecione a revenda" + value={resellerId} + onChange={(e) => setResellerId(e.target.value)} + required + /> + setClientName(e.target.value)} required /> + setClientCompany(e.target.value)} /> + setClientEmail(e.target.value)} /> + setClientPhone(e.target.value)} required /> + + + + + Contrato + + ({ + value: String(period), + label: period === 0 ? "Sem fidelidade (0 meses)" : `${period} meses`, + }))} + value={contractPeriod} + onChange={(e) => setContractPeriod(e.target.value)} + required + /> + setFidelityPeriod(e.target.value)} + hint="Só preencha se for menor que o período do contrato — entra em aprovação" + /> + + + + + + Itens + + + Adicionar item + + + + + + + Produto + Quantidade + + + + + {items.map((row, index) => ( + + + updateItem(index, { productId: e.target.value })} + aria-label="Produto" + required + /> + + + updateItem(index, { quantity: e.target.value })} + aria-label="Quantidade" + required + /> + + + {items.length > 1 ? ( + removeItem(index)} + className="rounded-md border border-border-color px-2 py-1 text-xs font-semibold text-danger hover:bg-light" + > + Remover + + ) : null} + + + ))} + + + + + + + Implantação e valores + + setImplGeral(e.target.value)} + hint="Custo de implantação além dos produtos" + /> + setProposedMonthlyTotal(e.target.value)} + hint="Preencha só se for oferecer desconto — a oferta precisará de aprovação" + /> + + ({ + value: condition, + label: condition, + }))} + placeholder="Selecione a condição" + value={implPaymentCondition} + onChange={(e) => setImplPaymentCondition(e.target.value as ImplPaymentCondition)} + /> + + Faixas sugeridas para {formatCurrencyBRL(estimatedImplTotal)} de implantação estimada:{" "} + {availablePaymentConditions.map((c) => ( + + {c} + + ))} + {" "}— o servidor valida a faixa real ao salvar. + + + + + + + Notas + setNotes(e.target.value)} + /> + + + + + Criar oferta + + navigate("/quotes")}> + Cancelar + + + + )} + + + ); +} + +function SummaryField({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ); +} diff --git a/apps/core-web/src/routes/quotes/QuotesListPage.tsx b/apps/core-web/src/routes/quotes/QuotesListPage.tsx new file mode 100644 index 0000000..b1bc219 --- /dev/null +++ b/apps/core-web/src/routes/quotes/QuotesListPage.tsx @@ -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 = { + none: "Sem aprovação", + pending: "Pendente", + approved: "Aprovada", + rejected: "Rejeitada", +}; + +const APPROVAL_VARIANT: Record = { + none: "neutral", + pending: "warning", + approved: "success", + rejected: "danger", +}; + +const DEAL_LABEL: Record = { + orcamento: "Orçamento", + fechado: "Fechado", + perdido: "Perdido", +}; + +const DEAL_VARIANT: Record = { + orcamento: "info", + fechado: "success", + perdido: "danger", +}; + +export function QuotesListPage() { + const { accessToken } = useAuth(); + const [quotes, setQuotes] = useState(null); + const [error, setError] = useState(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 ( + + + Nova oferta + + } + /> + + {error ? ( + + {error} + + ) : null} + + + {quotes === null && !error ? ( + Carregando ofertas… + ) : quotes && quotes.length === 0 ? ( + Nenhuma oferta cadastrada ainda. + ) : quotes ? ( + + + + + Oferta + Cliente + Mensalidade + Aprovação + Negócio + + + + {quotes.map((quote) => ( + + + + {quote.quote_number} + + + + {quote.client_name} + {quote.client_company ? {quote.client_company} : null} + + + {formatCurrencyBRL(quote.proposed_monthly_total ?? quote.monthly_total)} + + + + {APPROVAL_LABEL[quote.approval_status]} + + + + {DEAL_LABEL[quote.deal_status]} + + + ))} + + + + ) : null} + + + ); +} diff --git a/apps/core-web/src/routes/resellers/ResellerFormPage.tsx b/apps/core-web/src/routes/resellers/ResellerFormPage.tsx new file mode 100644 index 0000000..be7c921 --- /dev/null +++ b/apps/core-web/src/routes/resellers/ResellerFormPage.tsx @@ -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(EMPTY_FORM); + const [loading, setLoading] = useState(isEditing); + const [error, setError] = useState(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(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 ( + + + ← Voltar para Revendas + + } + actions={ + <> + navigate("/resellers")} type="button"> + Cancelar + + + Salvar + + > + } + /> + + {error ? ( + + {error} + + ) : null} + + {loading ? ( + + Carregando… + + ) : ( + + + + + updateField("name", e.target.value)} required /> + + updateField("document", e.target.value)} /> + updateField("contactEmail", e.target.value)} + /> + + Status + + updateField("active", e.target.checked)} + /> + Revenda ativa + + + + + + )} + + ); +} diff --git a/apps/core-web/src/routes/resellers/ResellersListPage.tsx b/apps/core-web/src/routes/resellers/ResellersListPage.tsx new file mode 100644 index 0000000..ef60cf8 --- /dev/null +++ b/apps/core-web/src/routes/resellers/ResellersListPage.tsx @@ -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(null); + const [error, setError] = useState(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> = [ + { key: "name", header: "Nome", render: (reseller) => {reseller.name} }, + { 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) => {reseller.active ? "Ativo" : "Inativo"}, + }, + { + key: "actions", + header: "", + className: "text-right", + render: (reseller) => ( + navigate(`/resellers/${reseller.id}/edit`)}> + Editar + + ), + }, + ]; + + return ( + + navigate("/resellers/new")}>Nova revenda} + /> + + {error ? ( + + {error} + + ) : null} + + + {resellers === null && !error ? ( + Carregando… + ) : ( + reseller.id} /> + )} + + + ); +} diff --git a/docs/progress.md b/docs/progress.md index 128f567..c8d34ad 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -294,3 +294,30 @@ Verificado: `pnpm turbo run typecheck build` (28/28), `pnpm --filter @eden/api t ### Pendências Toda a Assinatura Eletrônica (eden.md §1.4-1.9/§5/§6/§7 — envelopes, signatários, OTP, hash-chain, verificação pública), geração real de PDF (Playwright/Chromium) + sanitização de HTML, editor visual Tiptap real (`renderToHtml`/`resolveDocument` com condicionais e repetição de tabela), seed dos 7 templates reais de produção da Handix, regra de idempotência completa com `force`/`extra_fields`. + +--- + +## Fase 2/8 — Telas de negócio reais no EDEN Core (Empresas, Produtos, Revendas, CRM, Ofertas) + +Data: 2026-09-03. Usuário pediu explicitamente para priorizar a parte web ("ja faca a parte web para eu ja ver como esta ficando"). 2 subagentes `eden-frontend` em paralelo, mesma sessão, construindo sobre o shell autenticado + Design System já prontos. + +**Correção de instrução no meio da leva**: o usuário interrompeu para dizer "não esqueça de seguir o template" / "melhor usar ele" — as telas deviam seguir de perto o LAYOUT REAL do tema DreamsERP comprado, não só os tokens visuais abstratos já extraídos. Extraí do `tema_do_Eden.zip` as páginas React reais mais relevantes (`sales/salesQuotes*.tsx`, `crm/leads.tsx`/`deals*.tsx`, `inventory/products*.tsx`, `purchase/vendors.tsx`, `system/userManagement.tsx`, `ui-interface/{tables,forms}`) para dentro do worktree de cada agente (`.tmp/theme-reference/`, nunca versionado) e mandei mensagem de correção para os dois agentes já em execução — via `SendMessage`, não relançando a tarefa (no meio do processo cometi o erro de usar `Agent` em vez de `SendMessage` para a primeira correção, criando um agente duplicado; percebido e corrigido na hora com `TaskStop` antes de qualquer trabalho real ser feito por ele). Ambos incorporaram a referência do tema (estrutura de header/breadcrumb/tabela/formulário em cards) reescrita com `@eden/ui` + Tailwind puro — sem copiar JSX nem trazer dependências do tema (antd/Preline) para o projeto. + +### Empresas / Produtos / Revendas (`apps/core-web/src/routes/{companies,products,resellers}/`) + +CRUD completo (lista + criar/editar) para os 3 módulos, layout modelado em `purchase/vendors.tsx` (empresas/revendas) e `inventory/products.tsx` (produtos, com máscara de moeda para as 5 faixas de preço + implantação). Novo componente `Table` em `packages/ui` (tabela genérica reutilizável, sem dependência de terceiro). + +### CRM e Ofertas/Quotes (`apps/core-web/src/routes/{crm,quotes}/`) + +Leads (lista, criar/editar, converter em oportunidade) e Oportunidades (lista, criar/editar) modelados em `crm/leads.tsx`/`dealsDetails.tsx`. Ofertas — o módulo mais rico: `QuotesListPage`, `QuoteFormPage` (revenda + cliente + período de contrato + fidelidade reduzida opcional + lista dinâmica de itens produto/quantidade + desconto opcional + condição de pagamento, mostra os totais calculados pelo servidor antes de ir para o detalhe) e `QuoteDetailPage` (resumo financeiro completo, aprovar/rejeitar quando pendente, fechar negócio quando ainda é orçamento) — modelados em `sales/salesQuotes.tsx`/`addSalesQuotes.tsx`/`salesQuotesDetails.tsx`. Novo componente `Select` em `packages/ui`. + +**Achado real do próprio agente, corrigindo meu brief**: eu tinha dito no brief que as respostas de Leads/Opportunities eram snake_case (copiando o padrão do resto da API) — o agente verificou contra o código-fonte real (`packages/database/src/repositories/leads.ts`) e descobriu que esse módulo específico mapeia para **camelCase** na resposta (`mapLead`), diferente de Quotes/Companies/Products/Resellers (snake_case direto da linha do banco). Confirmado ao vivo via curl antes de integrar. + +### Integração manual (os 2 agentes trabalharam em paralelo no mesmo `core-web`) + +Um dos agentes não conseguiu `git merge` dentro do próprio sandbox e reconstruiu manualmente (leitura+escrita) o estado já existente do shell autenticado — comparei arquivo por arquivo contra `main` (`diff -rq`) para confirmar que a restauração foi fiel (nenhum componente restaurado divergiu) antes de aceitar qualquer coisa como "nova". `App.tsx`, `AuthenticatedLayout.tsx` (nav unificada, lógica de "ativo" por prefixo de path com borda de `/` para nunca casar `/companies` com `/companies-x`) e `packages/ui/src/index.ts` foram reconciliados manualmente juntando as rotas/exports dos dois agentes; `lib/format.ts` teve as duas versões mescladas (`formatDocument` de um lado, `formatDateBR` do outro). + +Verificado: `pnpm turbo run typecheck build` (28/28). Containers `eden-api`/`eden-core` reconstruídos (o build em background travou duas vezes por algum motivo de recurso do ambiente — refeito em primeiro plano, funcionou); bundle de produção confirmado contendo todas as novas telas; login simulando requisição real de browser (`Origin: http://localhost:3001`) funcionando; listagens de Empresas/Produtos/Revendas retornando dados reais; criação de lead confirmando o formato camelCase ao vivo. + +### Pendências +Client Registration (Cadastro de Cliente) ainda sem tela — `QuoteDetailPage` hoje exige colar manualmente um UUID para fechar negócio. Nenhuma tela de Contracts/Inventory/Support/HR-Timeclock/Fiscal/Billing/Documents ainda. `reseller-web`/`subscriber-web` continuam só com o shell de login (sem telas de negócio). diff --git a/packages/ui/src/components/Card.tsx b/packages/ui/src/components/Card.tsx index 18d3025..0321931 100644 --- a/packages/ui/src/components/Card.tsx +++ b/packages/ui/src/components/Card.tsx @@ -4,10 +4,12 @@ import { cn } from "../utils/cn.js"; export interface CardProps extends Omit, "title"> { title?: ReactNode; actions?: ReactNode; + /** Overrides the default `p-5` on the content wrapper — e.g. `"p-0"` for a flush data table (theme source: sales/salesQuotes.tsx uses `p-4 pb-0` around its table). */ + bodyClassName?: string; } /** Theme source (auth-pages/login.tsx): `bg-white border border-border-color rounded-md shadow-sm`. */ -export function Card({ title, actions, className, children, ...rest }: CardProps) { +export function Card({ title, actions, className, bodyClassName, children, ...rest }: CardProps) { return ( {title || actions ? ( @@ -16,7 +18,7 @@ export function Card({ title, actions, className, children, ...rest }: CardProps {actions ? {actions} : null} ) : null} - {children} + {children} ); } diff --git a/packages/ui/src/components/Select.tsx b/packages/ui/src/components/Select.tsx new file mode 100644 index 0000000..b97a50b --- /dev/null +++ b/packages/ui/src/components/Select.tsx @@ -0,0 +1,93 @@ +import { forwardRef, useId } from "react"; +import type { SelectHTMLAttributes } from "react"; +import { cn } from "../utils/cn.js"; + +export interface SelectOption { + value: string; + label: string; +} + +export interface SelectProps extends Omit, "children"> { + label?: string; + /** Validation/server error message — shown below the field, field border turns danger. */ + error?: string; + /** Helper text shown below the field when there's no error. */ + hint?: string; + options: SelectOption[]; + /** Rendered as a disabled, selected-by-default first option (e.g. "Selecione..."). */ + placeholder?: string; +} + +/** + * Native `` styled to match `Input` (packages/ui/src/components/Input.tsx) + * — same border/padding/focus-ring language as the rest of the Design + * System. Derived from the DreamsERP theme's form field sizing + * (`.tmp/theme-reference/sales/addSalesQuotes.tsx` etc. use a + * `react-select`-based CommonSelect for the same slots, out of our stack — + * this is a plain, dependency-free equivalent with the same visual sizing). + */ +export const Select = forwardRef(function Select( + { label, error, hint, id, className, options, placeholder, ...rest }, + ref, +) { + const generatedId = useId(); + const selectId = id ?? generatedId; + + return ( + + {label ? ( + + {label} + + ) : null} + + + {placeholder ? ( + + {placeholder} + + ) : null} + {options.map((option) => ( + + {option.label} + + ))} + + + + + + {error ? ( + + {error} + + ) : hint ? ( + + {hint} + + ) : null} + + ); +}); diff --git a/packages/ui/src/components/Table.tsx b/packages/ui/src/components/Table.tsx new file mode 100644 index 0000000..94c0bd4 --- /dev/null +++ b/packages/ui/src/components/Table.tsx @@ -0,0 +1,64 @@ +import type { ReactNode } from "react"; +import { cn } from "../utils/cn.js"; + +export interface TableColumn { + key: string; + header: ReactNode; + render: (row: T) => ReactNode; + /** Applied to both the header and body cells — e.g. `"text-right"` for numeric columns. */ + className?: string; +} + +export interface TableProps { + columns: Array>; + data: T[]; + rowKey: (row: T) => string; + /** Shown instead of the table body when `data` is empty (and not loading). */ + emptyMessage?: ReactNode; +} + +/** + * Plain data table styled to match the DreamsERP theme's list screens + * (react/src/pages/inventory/products.tsx et al.) without pulling in the + * theme's antd/react-window dependencies — just a semantic ``. + */ +export function Table({ columns, data, rowKey, emptyMessage = "Nenhum registro encontrado." }: TableProps) { + return ( + + + + + {columns.map((column) => ( + + {column.header} + + ))} + + + + {data.length === 0 ? ( + + + {emptyMessage} + + + ) : ( + data.map((row) => ( + + {columns.map((column) => ( + + {column.render(row)} + + ))} + + )) + )} + + + + ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index d0966ae..3190229 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -13,6 +13,12 @@ export type { ButtonProps, ButtonVariant, ButtonSize } from "./components/Button export { Input } from "./components/Input.js"; export type { InputProps } from "./components/Input.js"; +export { Select } from "./components/Select.js"; +export type { SelectProps, SelectOption } from "./components/Select.js"; + +export { Table } from "./components/Table.js"; +export type { TableProps, TableColumn } from "./components/Table.js"; + export { Card } from "./components/Card.js"; export type { CardProps } from "./components/Card.js";
{ + const query = dealStatus ? `?dealStatus=${encodeURIComponent(dealStatus)}` : ""; + return apiRequest(`/quotes${query}`, { method: "GET" }, { accessToken }); +} + +export function getQuote(id: string, accessToken: string): Promise { + return apiRequest(`/quotes/${id}`, { method: "GET" }, { accessToken }); +} + +export function createQuote(input: CreateQuoteInput, accessToken: string): Promise { + return apiRequest("/quotes", { method: "POST", body: JSON.stringify(input) }, { accessToken }); +} + +export function decideQuoteApproval( + id: string, + input: DecideApprovalInput, + accessToken: string, +): Promise { + return apiRequest( + `/quotes/${id}/approval`, + { method: "PATCH", body: JSON.stringify(input) }, + { accessToken }, + ); +} + +export function closeQuoteDeal(id: string, input: CloseDealInput, accessToken: string): Promise { + return apiRequest(`/quotes/${id}/close`, { method: "POST", body: JSON.stringify(input) }, { accessToken }); +} diff --git a/apps/core-web/src/lib/api/resellers.ts b/apps/core-web/src/lib/api/resellers.ts new file mode 100644 index 0000000..e2aa409 --- /dev/null +++ b/apps/core-web/src/lib/api/resellers.ts @@ -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; + +export function listResellers(accessToken: string): Promise { + return apiRequest("/resellers", { method: "GET" }, { accessToken }); +} + +export function getReseller(id: string, accessToken: string): Promise { + return apiRequest(`/resellers/${id}`, { method: "GET" }, { accessToken }); +} + +export function createReseller(input: CreateResellerInput, accessToken: string): Promise { + return apiRequest("/resellers", { method: "POST", body: JSON.stringify(input) }, { accessToken }); +} + +export function updateReseller( + id: string, + input: UpdateResellerInput, + accessToken: string, +): Promise { + return apiRequest( + `/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 }, + ); +} diff --git a/apps/core-web/src/lib/format.ts b/apps/core-web/src/lib/format.ts new file mode 100644 index 0000000..c30f27b --- /dev/null +++ b/apps/core-web/src/lib/format.ts @@ -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; +} diff --git a/apps/core-web/src/routes/AuthenticatedLayout.tsx b/apps/core-web/src/routes/AuthenticatedLayout.tsx index c2d385b..3efa306 100644 --- a/apps/core-web/src/routes/AuthenticatedLayout.tsx +++ b/apps/core-web/src/routes/AuthenticatedLayout.tsx @@ -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() { (null); + const [error, setError] = useState(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> = [ + { + key: "name", + header: "Nome", + render: (company) => ( + + {company.company_name} + {company.trade_name ? {company.trade_name} : null} + + ), + }, + { 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) => ( + {company.is_active ? "Ativo" : "Inativo"} + ), + }, + { + key: "actions", + header: "", + className: "text-right", + render: (company) => ( + navigate(`/companies/${company.id}/edit`)}> + Editar + + ), + }, + ]; + + return ( + + navigate("/companies/new")}>Nova empresa} + /> + + {error ? ( + + {error} + + ) : null} + + + {companies === null && !error ? ( + Carregando… + ) : ( + company.id} /> + )} + + + ); +} diff --git a/apps/core-web/src/routes/companies/CompanyFormPage.tsx b/apps/core-web/src/routes/companies/CompanyFormPage.tsx new file mode 100644 index 0000000..c69ae6d --- /dev/null +++ b/apps/core-web/src/routes/companies/CompanyFormPage.tsx @@ -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 { + 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(EMPTY_FORM); + const [loading, setLoading] = useState(isEditing); + const [error, setError] = useState(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(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 ( + + + ← Voltar para Empresas + + } + actions={ + <> + navigate("/companies")} type="button"> + Cancelar + + + Salvar + + > + } + /> + + {error ? ( + + {error} + + ) : null} + + {loading ? ( + + Carregando… + + ) : ( + + + + + updateField("companyName", e.target.value)} + required + /> + + updateField("tradeName", e.target.value)} + /> + updateField("cnpj", e.target.value)} /> + updateField("stateRegistration", e.target.value)} + /> + updateField("municipalRegistration", e.target.value)} + /> + {isEditing ? ( + + Status + + updateField("isActive", e.target.checked)} + /> + Empresa ativa + + + ) : null} + + + + + + updateField("addressZip", e.target.value)} /> + + updateField("addressStreet", e.target.value)} + /> + + updateField("addressNumber", e.target.value)} + /> + updateField("addressComplement", e.target.value)} + /> + updateField("addressNeighborhood", e.target.value)} + /> + updateField("addressCity", e.target.value)} + /> + updateField("addressState", e.target.value.toUpperCase())} + /> + updateField("addressCountry", e.target.value)} + placeholder="Brasil" + /> + + + + + + updateField("phone", e.target.value)} /> + updateField("email", e.target.value)} + /> + updateField("website", e.target.value)} /> + + + + + + updateField("legalRepName", e.target.value)} + /> + updateField("legalRepCpf", e.target.value)} + /> + updateField("legalRepRole", e.target.value)} + /> + updateField("legalRepEmail", e.target.value)} + /> + + + + )} + + ); +} diff --git a/apps/core-web/src/routes/crm/ConvertLeadPage.tsx b/apps/core-web/src/routes/crm/ConvertLeadPage.tsx new file mode 100644 index 0000000..d914c51 --- /dev/null +++ b/apps/core-web/src/routes/crm/ConvertLeadPage.tsx @@ -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 = { + 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(null); + const [title, setTitle] = useState(""); + const [stage, setStage] = useState("prospecting"); + const [probability, setProbability] = useState(""); + const [notes, setNotes] = useState(""); + + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(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 ( + + + + + {error ? ( + + {error} + + ) : null} + + + {loading ? ( + Carregando lead… + ) : ( + + setTitle(e.target.value)} + required + autoFocus + /> + setStage(e.target.value as OpportunityStage)} + /> + setProbability(e.target.value)} + /> + + Notas + setNotes(e.target.value)} + /> + + + + Converter + + navigate("/leads")}> + Cancelar + + + + )} + + + ); +} diff --git a/apps/core-web/src/routes/crm/CrmSubNav.tsx b/apps/core-web/src/routes/crm/CrmSubNav.tsx new file mode 100644 index 0000000..03d5697 --- /dev/null +++ b/apps/core-web/src/routes/crm/CrmSubNav.tsx @@ -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 ( + + + Leads + + + Oportunidades + + + ); +} diff --git a/apps/core-web/src/routes/crm/LeadFormPage.tsx b/apps/core-web/src/routes/crm/LeadFormPage.tsx new file mode 100644 index 0000000..0d41695 --- /dev/null +++ b/apps/core-web/src/routes/crm/LeadFormPage.tsx @@ -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 = { + 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("new"); + const [notes, setNotes] = useState(""); + + const [loading, setLoading] = useState(isEdit); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(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 ( + + + + + {error ? ( + + {error} + + ) : null} + + + {loading ? ( + Carregando lead… + ) : ( + + setContactName(e.target.value)} + required + autoFocus + /> + setCompanyName(e.target.value)} /> + setContactEmail(e.target.value)} + /> + setContactPhone(e.target.value)} /> + setSource(e.target.value)} hint="Ex.: Site, indicação, campanha" /> + setStatus(e.target.value as LeadStatus)} + /> + + Notas + setNotes(e.target.value)} + /> + + + + {isEdit ? "Salvar alterações" : "Criar lead"} + + navigate("/leads")}> + Cancelar + + + + )} + + + ); +} diff --git a/apps/core-web/src/routes/crm/LeadsListPage.tsx b/apps/core-web/src/routes/crm/LeadsListPage.tsx new file mode 100644 index 0000000..e8ca3ab --- /dev/null +++ b/apps/core-web/src/routes/crm/LeadsListPage.tsx @@ -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 = { + new: "Novo", + contacted: "Contatado", + qualified: "Qualificado", + converted: "Convertido", + discarded: "Descartado", +}; + +const STATUS_VARIANT: Record = { + new: "info", + contacted: "warning", + qualified: "primary", + converted: "success", + discarded: "danger", +}; + +export function LeadsListPage() { + const { accessToken } = useAuth(); + const [leads, setLeads] = useState(null); + const [error, setError] = useState(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 ( + + + + Novo lead + + } + /> + + {error ? ( + + {error} + + ) : null} + + + {leads === null && !error ? ( + Carregando leads… + ) : leads && leads.length === 0 ? ( + Nenhum lead cadastrado ainda. + ) : leads ? ( + + + + + Contato + Empresa + Status + Ações + + + + {leads.map((lead) => { + const canConvert = lead.status !== "converted" && lead.status !== "discarded"; + return ( + + + + {lead.contactName} + + {lead.contactEmail ? {lead.contactEmail} : null} + + {lead.companyName ?? "—"} + + + {STATUS_LABEL[lead.status] ?? lead.status} + + + + + + Editar + + {canConvert ? ( + + Converter em oportunidade + + ) : null} + + + + ); + })} + + + + ) : null} + + + ); +} diff --git a/apps/core-web/src/routes/crm/OpportunitiesListPage.tsx b/apps/core-web/src/routes/crm/OpportunitiesListPage.tsx new file mode 100644 index 0000000..7e83021 --- /dev/null +++ b/apps/core-web/src/routes/crm/OpportunitiesListPage.tsx @@ -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 = { + prospecting: "Prospecção", + qualification: "Qualificação", + proposal: "Proposta", + negotiation: "Negociação", + won: "Ganha", + lost: "Perdida", +}; + +const STAGE_VARIANT: Record = { + prospecting: "info", + qualification: "primary", + proposal: "warning", + negotiation: "secondary", + won: "success", + lost: "danger", +}; + +export function OpportunitiesListPage() { + const { accessToken } = useAuth(); + const [opportunities, setOpportunities] = useState(null); + const [error, setError] = useState(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 ( + + + + Nova oportunidade + + } + /> + + {error ? ( + + {error} + + ) : null} + + + {opportunities === null && !error ? ( + Carregando oportunidades… + ) : opportunities && opportunities.length === 0 ? ( + Nenhuma oportunidade cadastrada ainda. + ) : opportunities ? ( + + + + + Título + Estágio + Probabilidade + Dono + Ações + + + + {opportunities.map((opportunity) => ( + + + + {opportunity.title} + + + + + {STAGE_LABEL[opportunity.stage] ?? opportunity.stage} + + + + {opportunity.probability != null ? `${opportunity.probability}%` : "—"} + + {opportunity.ownerId ?? "—"} + + + Editar + + + + ))} + + + + ) : null} + + + ); +} diff --git a/apps/core-web/src/routes/crm/OpportunityFormPage.tsx b/apps/core-web/src/routes/crm/OpportunityFormPage.tsx new file mode 100644 index 0000000..8fe8042 --- /dev/null +++ b/apps/core-web/src/routes/crm/OpportunityFormPage.tsx @@ -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 = { + 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("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(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 ( + + + + + {error ? ( + + {error} + + ) : null} + + + {loading ? ( + Carregando oportunidade… + ) : ( + + setTitle(e.target.value)} + required + autoFocus + /> + setStage(e.target.value as OpportunityStage)} + /> + setProbability(e.target.value)} + /> + {stage === "lost" ? ( + setLossReason(e.target.value)} + /> + ) : null} + + Notas + setNotes(e.target.value)} + /> + + + + {isEdit ? "Salvar alterações" : "Criar oportunidade"} + + navigate("/opportunities")}> + Cancelar + + + + )} + + + ); +} diff --git a/apps/core-web/src/routes/products/ProductFormPage.tsx b/apps/core-web/src/routes/products/ProductFormPage.tsx new file mode 100644 index 0000000..8c8d9b9 --- /dev/null +++ b/apps/core-web/src/routes/products/ProductFormPage.tsx @@ -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 ( + 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(EMPTY_FORM); + const [loading, setLoading] = useState(isEditing); + const [error, setError] = useState(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(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 ( + + + ← Voltar para Produtos + + } + actions={ + <> + navigate("/products")} type="button"> + Cancelar + + + Salvar + + > + } + /> + + {error ? ( + + {error} + + ) : null} + + {loading ? ( + + Carregando… + + ) : ( + + + + + updateField("name", e.target.value)} required /> + + updateField("productType", e.target.value)} + required + /> + updateField("unit", e.target.value)} /> + updateField("productGroup", e.target.value)} + /> + updateField("productSubgroup", e.target.value)} + /> + updateField("brand", e.target.value)} /> + + + Descrição + + updateField("description", e.target.value)} + /> + + + + + + + updateField("price0Cents", v)} /> + updateField("price12Cents", v)} /> + updateField("price24Cents", v)} /> + updateField("price36Cents", v)} /> + updateField("price48Cents", v)} /> + updateField("implUnitPriceCents", v)} + /> + + + + + + {( + [ + ["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]) => ( + + updateField(field, e.target.checked)} + /> + {label} + + ))} + + + + )} + + ); +} diff --git a/apps/core-web/src/routes/products/ProductsListPage.tsx b/apps/core-web/src/routes/products/ProductsListPage.tsx new file mode 100644 index 0000000..14deccb --- /dev/null +++ b/apps/core-web/src/routes/products/ProductsListPage.tsx @@ -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(null); + const [error, setError] = useState(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> = [ + { key: "code", header: "Código", render: (product) => `#${product.product_code}` }, + { key: "name", header: "Nome", render: (product) => {product.name} }, + { key: "type", header: "Tipo", render: (product) => product.product_type }, + ...PRICE_COLUMNS.map( + (column): TableColumn => ({ + 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 ? ( + Descontinuado + ) : ( + {product.active ? "Ativo" : "Inativo"} + ), + }, + { + key: "actions", + header: "", + className: "text-right", + render: (product) => ( + navigate(`/products/${product.id}/edit`)}> + Editar + + ), + }, + ]; + + return ( + + navigate("/products/new")}>Novo produto} + /> + + {error ? ( + + {error} + + ) : null} + + + {products === null && !error ? ( + Carregando… + ) : ( + product.id} /> + )} + + + ); +} diff --git a/apps/core-web/src/routes/quotes/QuoteDetailPage.tsx b/apps/core-web/src/routes/quotes/QuoteDetailPage.tsx new file mode 100644 index 0000000..bec95a5 --- /dev/null +++ b/apps/core-web/src/routes/quotes/QuoteDetailPage.tsx @@ -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 = { + none: "Sem aprovação", + pending: "Pendente", + approved: "Aprovada", + rejected: "Rejeitada", +}; + +const APPROVAL_VARIANT: Record = { + none: "neutral", + pending: "warning", + approved: "success", + rejected: "danger", +}; + +const DEAL_LABEL: Record = { + orcamento: "Orçamento", + fechado: "Fechado", + perdido: "Perdido", +}; + +const DEAL_VARIANT: Record = { + orcamento: "info", + fechado: "success", + perdido: "danger", +}; + +export function QuoteDetailPage() { + const { id } = useParams<{ id: string }>(); + const { accessToken } = useAuth(); + + const [quote, setQuote] = useState(null); + const [reseller, setReseller] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( + + + Carregando oferta… + + ); + } + + if (error || !quote) { + return ( + + + {error ?? "Oferta não encontrada."} + + ); + } + + return ( + + + + {APPROVAL_LABEL[quote.approval_status]} + {DEAL_LABEL[quote.deal_status]} + + } + /> + + + + + + + + + + + + + + + + + + + + + + + + + Produto + Qtd. + Preço unit. + Total + Impl. unit. + Impl. total + + + + {quote.items.map((item, index) => ( + + {item.productName} + {item.quantity} + {formatCurrencyBRL(item.unitPrice)} + {formatCurrencyBRL(item.total)} + {formatCurrencyBRL(item.implUnitPrice)} + {formatCurrencyBRL(item.implTotal)} + + ))} + + + + + + + + + {quote.proposed_monthly_total != null ? ( + + ) : null} + + + + + {quote.notes ? ( + + Notas + {quote.notes} + + ) : null} + + + {quote.approval_status === "pending" ? ( + + ) : quote.approval_status !== "none" && quote.approval_notes ? ( + + {quote.approval_notes} + + ) : null} + + {quote.deal_status === "orcamento" ? ( + + ) : quote.client_registration_id ? ( + + + + ) : null} + + + + ); +} + +function Row({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ); +} + +function SummaryRow({ label, value, highlight }: { label: string; value: string; highlight?: boolean }) { + return ( + + {label} + + {value} + + + ); +} + +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(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 ( + + {error ? ( + + {error} + + ) : null} + + Esta oferta tem desconto e/ou fidelidade reduzida e precisa de aprovação antes de seguir. + + setNotes(e.target.value)} + className="mb-3" + /> + + decide("approved")} loading={submitting === "approved"}> + Aprovar + + decide("rejected")} loading={submitting === "rejected"}> + Rejeitar + + + + ); +} + +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(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 ( + + {error ? ( + + {error} + + ) : null} + + setClientRegistrationId(e.target.value)} + hint="Cole o UUID de um cadastro de cliente já existente" + className="min-w-72 flex-1" + required + /> + + Fechar negócio + + + + ); +} diff --git a/apps/core-web/src/routes/quotes/QuoteFormPage.tsx b/apps/core-web/src/routes/quotes/QuoteFormPage.tsx new file mode 100644 index 0000000..1ac1f6e --- /dev/null +++ b/apps/core-web/src/routes/quotes/QuoteFormPage.tsx @@ -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(null); + const [products, setProducts] = useState(null); + const [loadError, setLoadError] = useState(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("12"); + const [fidelityPeriod, setFidelityPeriod] = useState(""); + const [items, setItems] = useState([{ ...EMPTY_ROW }]); + const [implGeral, setImplGeral] = useState(""); + const [proposedMonthlyTotal, setProposedMonthlyTotal] = useState(""); + const [implPaymentCondition, setImplPaymentCondition] = useState(""); + const [notes, setNotes] = useState(""); + + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [createdQuote, setCreatedQuote] = useState(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) { + 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 ( + + + + Oferta criada com sucesso. Confira os totais calculados pelo servidor abaixo. + + + + + + + + {createdQuote.approval_status === "pending" ? ( + + Esta oferta possui desconto e/ou fidelidade reduzida — aguarda aprovação. + + ) : null} + + + Ver oferta completa + + navigate("/quotes")}> + Voltar para a lista + + + + + ); + } + + return ( + + + + + {loadError ? ( + + {loadError} + + ) : null} + {error ? ( + + {error} + + ) : null} + + + {!resellers || !products ? ( + Carregando revendas e produtos… + ) : ( + + + Dados do cliente + + ({ value: r.id, label: r.name }))} + placeholder="Selecione a revenda" + value={resellerId} + onChange={(e) => setResellerId(e.target.value)} + required + /> + setClientName(e.target.value)} required /> + setClientCompany(e.target.value)} /> + setClientEmail(e.target.value)} /> + setClientPhone(e.target.value)} required /> + + + + + Contrato + + ({ + value: String(period), + label: period === 0 ? "Sem fidelidade (0 meses)" : `${period} meses`, + }))} + value={contractPeriod} + onChange={(e) => setContractPeriod(e.target.value)} + required + /> + setFidelityPeriod(e.target.value)} + hint="Só preencha se for menor que o período do contrato — entra em aprovação" + /> + + + + + + Itens + + + Adicionar item + + + + + + + Produto + Quantidade + + + + + {items.map((row, index) => ( + + + updateItem(index, { productId: e.target.value })} + aria-label="Produto" + required + /> + + + updateItem(index, { quantity: e.target.value })} + aria-label="Quantidade" + required + /> + + + {items.length > 1 ? ( + removeItem(index)} + className="rounded-md border border-border-color px-2 py-1 text-xs font-semibold text-danger hover:bg-light" + > + Remover + + ) : null} + + + ))} + + + + + + + Implantação e valores + + setImplGeral(e.target.value)} + hint="Custo de implantação além dos produtos" + /> + setProposedMonthlyTotal(e.target.value)} + hint="Preencha só se for oferecer desconto — a oferta precisará de aprovação" + /> + + ({ + value: condition, + label: condition, + }))} + placeholder="Selecione a condição" + value={implPaymentCondition} + onChange={(e) => setImplPaymentCondition(e.target.value as ImplPaymentCondition)} + /> + + Faixas sugeridas para {formatCurrencyBRL(estimatedImplTotal)} de implantação estimada:{" "} + {availablePaymentConditions.map((c) => ( + + {c} + + ))} + {" "}— o servidor valida a faixa real ao salvar. + + + + + + + Notas + setNotes(e.target.value)} + /> + + + + + Criar oferta + + navigate("/quotes")}> + Cancelar + + + + )} + + + ); +} + +function SummaryField({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ); +} diff --git a/apps/core-web/src/routes/quotes/QuotesListPage.tsx b/apps/core-web/src/routes/quotes/QuotesListPage.tsx new file mode 100644 index 0000000..b1bc219 --- /dev/null +++ b/apps/core-web/src/routes/quotes/QuotesListPage.tsx @@ -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 = { + none: "Sem aprovação", + pending: "Pendente", + approved: "Aprovada", + rejected: "Rejeitada", +}; + +const APPROVAL_VARIANT: Record = { + none: "neutral", + pending: "warning", + approved: "success", + rejected: "danger", +}; + +const DEAL_LABEL: Record = { + orcamento: "Orçamento", + fechado: "Fechado", + perdido: "Perdido", +}; + +const DEAL_VARIANT: Record = { + orcamento: "info", + fechado: "success", + perdido: "danger", +}; + +export function QuotesListPage() { + const { accessToken } = useAuth(); + const [quotes, setQuotes] = useState(null); + const [error, setError] = useState(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 ( + + + Nova oferta + + } + /> + + {error ? ( + + {error} + + ) : null} + + + {quotes === null && !error ? ( + Carregando ofertas… + ) : quotes && quotes.length === 0 ? ( + Nenhuma oferta cadastrada ainda. + ) : quotes ? ( + + + + + Oferta + Cliente + Mensalidade + Aprovação + Negócio + + + + {quotes.map((quote) => ( + + + + {quote.quote_number} + + + + {quote.client_name} + {quote.client_company ? {quote.client_company} : null} + + + {formatCurrencyBRL(quote.proposed_monthly_total ?? quote.monthly_total)} + + + + {APPROVAL_LABEL[quote.approval_status]} + + + + {DEAL_LABEL[quote.deal_status]} + + + ))} + + + + ) : null} + + + ); +} diff --git a/apps/core-web/src/routes/resellers/ResellerFormPage.tsx b/apps/core-web/src/routes/resellers/ResellerFormPage.tsx new file mode 100644 index 0000000..be7c921 --- /dev/null +++ b/apps/core-web/src/routes/resellers/ResellerFormPage.tsx @@ -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(EMPTY_FORM); + const [loading, setLoading] = useState(isEditing); + const [error, setError] = useState(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(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 ( + + + ← Voltar para Revendas + + } + actions={ + <> + navigate("/resellers")} type="button"> + Cancelar + + + Salvar + + > + } + /> + + {error ? ( + + {error} + + ) : null} + + {loading ? ( + + Carregando… + + ) : ( + + + + + updateField("name", e.target.value)} required /> + + updateField("document", e.target.value)} /> + updateField("contactEmail", e.target.value)} + /> + + Status + + updateField("active", e.target.checked)} + /> + Revenda ativa + + + + + + )} + + ); +} diff --git a/apps/core-web/src/routes/resellers/ResellersListPage.tsx b/apps/core-web/src/routes/resellers/ResellersListPage.tsx new file mode 100644 index 0000000..ef60cf8 --- /dev/null +++ b/apps/core-web/src/routes/resellers/ResellersListPage.tsx @@ -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(null); + const [error, setError] = useState(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> = [ + { key: "name", header: "Nome", render: (reseller) => {reseller.name} }, + { 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) => {reseller.active ? "Ativo" : "Inativo"}, + }, + { + key: "actions", + header: "", + className: "text-right", + render: (reseller) => ( + navigate(`/resellers/${reseller.id}/edit`)}> + Editar + + ), + }, + ]; + + return ( + + navigate("/resellers/new")}>Nova revenda} + /> + + {error ? ( + + {error} + + ) : null} + + + {resellers === null && !error ? ( + Carregando… + ) : ( + reseller.id} /> + )} + + + ); +} diff --git a/docs/progress.md b/docs/progress.md index 128f567..c8d34ad 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -294,3 +294,30 @@ Verificado: `pnpm turbo run typecheck build` (28/28), `pnpm --filter @eden/api t ### Pendências Toda a Assinatura Eletrônica (eden.md §1.4-1.9/§5/§6/§7 — envelopes, signatários, OTP, hash-chain, verificação pública), geração real de PDF (Playwright/Chromium) + sanitização de HTML, editor visual Tiptap real (`renderToHtml`/`resolveDocument` com condicionais e repetição de tabela), seed dos 7 templates reais de produção da Handix, regra de idempotência completa com `force`/`extra_fields`. + +--- + +## Fase 2/8 — Telas de negócio reais no EDEN Core (Empresas, Produtos, Revendas, CRM, Ofertas) + +Data: 2026-09-03. Usuário pediu explicitamente para priorizar a parte web ("ja faca a parte web para eu ja ver como esta ficando"). 2 subagentes `eden-frontend` em paralelo, mesma sessão, construindo sobre o shell autenticado + Design System já prontos. + +**Correção de instrução no meio da leva**: o usuário interrompeu para dizer "não esqueça de seguir o template" / "melhor usar ele" — as telas deviam seguir de perto o LAYOUT REAL do tema DreamsERP comprado, não só os tokens visuais abstratos já extraídos. Extraí do `tema_do_Eden.zip` as páginas React reais mais relevantes (`sales/salesQuotes*.tsx`, `crm/leads.tsx`/`deals*.tsx`, `inventory/products*.tsx`, `purchase/vendors.tsx`, `system/userManagement.tsx`, `ui-interface/{tables,forms}`) para dentro do worktree de cada agente (`.tmp/theme-reference/`, nunca versionado) e mandei mensagem de correção para os dois agentes já em execução — via `SendMessage`, não relançando a tarefa (no meio do processo cometi o erro de usar `Agent` em vez de `SendMessage` para a primeira correção, criando um agente duplicado; percebido e corrigido na hora com `TaskStop` antes de qualquer trabalho real ser feito por ele). Ambos incorporaram a referência do tema (estrutura de header/breadcrumb/tabela/formulário em cards) reescrita com `@eden/ui` + Tailwind puro — sem copiar JSX nem trazer dependências do tema (antd/Preline) para o projeto. + +### Empresas / Produtos / Revendas (`apps/core-web/src/routes/{companies,products,resellers}/`) + +CRUD completo (lista + criar/editar) para os 3 módulos, layout modelado em `purchase/vendors.tsx` (empresas/revendas) e `inventory/products.tsx` (produtos, com máscara de moeda para as 5 faixas de preço + implantação). Novo componente `Table` em `packages/ui` (tabela genérica reutilizável, sem dependência de terceiro). + +### CRM e Ofertas/Quotes (`apps/core-web/src/routes/{crm,quotes}/`) + +Leads (lista, criar/editar, converter em oportunidade) e Oportunidades (lista, criar/editar) modelados em `crm/leads.tsx`/`dealsDetails.tsx`. Ofertas — o módulo mais rico: `QuotesListPage`, `QuoteFormPage` (revenda + cliente + período de contrato + fidelidade reduzida opcional + lista dinâmica de itens produto/quantidade + desconto opcional + condição de pagamento, mostra os totais calculados pelo servidor antes de ir para o detalhe) e `QuoteDetailPage` (resumo financeiro completo, aprovar/rejeitar quando pendente, fechar negócio quando ainda é orçamento) — modelados em `sales/salesQuotes.tsx`/`addSalesQuotes.tsx`/`salesQuotesDetails.tsx`. Novo componente `Select` em `packages/ui`. + +**Achado real do próprio agente, corrigindo meu brief**: eu tinha dito no brief que as respostas de Leads/Opportunities eram snake_case (copiando o padrão do resto da API) — o agente verificou contra o código-fonte real (`packages/database/src/repositories/leads.ts`) e descobriu que esse módulo específico mapeia para **camelCase** na resposta (`mapLead`), diferente de Quotes/Companies/Products/Resellers (snake_case direto da linha do banco). Confirmado ao vivo via curl antes de integrar. + +### Integração manual (os 2 agentes trabalharam em paralelo no mesmo `core-web`) + +Um dos agentes não conseguiu `git merge` dentro do próprio sandbox e reconstruiu manualmente (leitura+escrita) o estado já existente do shell autenticado — comparei arquivo por arquivo contra `main` (`diff -rq`) para confirmar que a restauração foi fiel (nenhum componente restaurado divergiu) antes de aceitar qualquer coisa como "nova". `App.tsx`, `AuthenticatedLayout.tsx` (nav unificada, lógica de "ativo" por prefixo de path com borda de `/` para nunca casar `/companies` com `/companies-x`) e `packages/ui/src/index.ts` foram reconciliados manualmente juntando as rotas/exports dos dois agentes; `lib/format.ts` teve as duas versões mescladas (`formatDocument` de um lado, `formatDateBR` do outro). + +Verificado: `pnpm turbo run typecheck build` (28/28). Containers `eden-api`/`eden-core` reconstruídos (o build em background travou duas vezes por algum motivo de recurso do ambiente — refeito em primeiro plano, funcionou); bundle de produção confirmado contendo todas as novas telas; login simulando requisição real de browser (`Origin: http://localhost:3001`) funcionando; listagens de Empresas/Produtos/Revendas retornando dados reais; criação de lead confirmando o formato camelCase ao vivo. + +### Pendências +Client Registration (Cadastro de Cliente) ainda sem tela — `QuoteDetailPage` hoje exige colar manualmente um UUID para fechar negócio. Nenhuma tela de Contracts/Inventory/Support/HR-Timeclock/Fiscal/Billing/Documents ainda. `reseller-web`/`subscriber-web` continuam só com o shell de login (sem telas de negócio). diff --git a/packages/ui/src/components/Card.tsx b/packages/ui/src/components/Card.tsx index 18d3025..0321931 100644 --- a/packages/ui/src/components/Card.tsx +++ b/packages/ui/src/components/Card.tsx @@ -4,10 +4,12 @@ import { cn } from "../utils/cn.js"; export interface CardProps extends Omit, "title"> { title?: ReactNode; actions?: ReactNode; + /** Overrides the default `p-5` on the content wrapper — e.g. `"p-0"` for a flush data table (theme source: sales/salesQuotes.tsx uses `p-4 pb-0` around its table). */ + bodyClassName?: string; } /** Theme source (auth-pages/login.tsx): `bg-white border border-border-color rounded-md shadow-sm`. */ -export function Card({ title, actions, className, children, ...rest }: CardProps) { +export function Card({ title, actions, className, bodyClassName, children, ...rest }: CardProps) { return ( {title || actions ? ( @@ -16,7 +18,7 @@ export function Card({ title, actions, className, children, ...rest }: CardProps {actions ? {actions} : null} ) : null} - {children} + {children} ); } diff --git a/packages/ui/src/components/Select.tsx b/packages/ui/src/components/Select.tsx new file mode 100644 index 0000000..b97a50b --- /dev/null +++ b/packages/ui/src/components/Select.tsx @@ -0,0 +1,93 @@ +import { forwardRef, useId } from "react"; +import type { SelectHTMLAttributes } from "react"; +import { cn } from "../utils/cn.js"; + +export interface SelectOption { + value: string; + label: string; +} + +export interface SelectProps extends Omit, "children"> { + label?: string; + /** Validation/server error message — shown below the field, field border turns danger. */ + error?: string; + /** Helper text shown below the field when there's no error. */ + hint?: string; + options: SelectOption[]; + /** Rendered as a disabled, selected-by-default first option (e.g. "Selecione..."). */ + placeholder?: string; +} + +/** + * Native `` styled to match `Input` (packages/ui/src/components/Input.tsx) + * — same border/padding/focus-ring language as the rest of the Design + * System. Derived from the DreamsERP theme's form field sizing + * (`.tmp/theme-reference/sales/addSalesQuotes.tsx` etc. use a + * `react-select`-based CommonSelect for the same slots, out of our stack — + * this is a plain, dependency-free equivalent with the same visual sizing). + */ +export const Select = forwardRef(function Select( + { label, error, hint, id, className, options, placeholder, ...rest }, + ref, +) { + const generatedId = useId(); + const selectId = id ?? generatedId; + + return ( + + {label ? ( + + {label} + + ) : null} + + + {placeholder ? ( + + {placeholder} + + ) : null} + {options.map((option) => ( + + {option.label} + + ))} + + + + + + {error ? ( + + {error} + + ) : hint ? ( + + {hint} + + ) : null} + + ); +}); diff --git a/packages/ui/src/components/Table.tsx b/packages/ui/src/components/Table.tsx new file mode 100644 index 0000000..94c0bd4 --- /dev/null +++ b/packages/ui/src/components/Table.tsx @@ -0,0 +1,64 @@ +import type { ReactNode } from "react"; +import { cn } from "../utils/cn.js"; + +export interface TableColumn { + key: string; + header: ReactNode; + render: (row: T) => ReactNode; + /** Applied to both the header and body cells — e.g. `"text-right"` for numeric columns. */ + className?: string; +} + +export interface TableProps { + columns: Array>; + data: T[]; + rowKey: (row: T) => string; + /** Shown instead of the table body when `data` is empty (and not loading). */ + emptyMessage?: ReactNode; +} + +/** + * Plain data table styled to match the DreamsERP theme's list screens + * (react/src/pages/inventory/products.tsx et al.) without pulling in the + * theme's antd/react-window dependencies — just a semantic ``. + */ +export function Table({ columns, data, rowKey, emptyMessage = "Nenhum registro encontrado." }: TableProps) { + return ( + + + + + {columns.map((column) => ( + + {column.header} + + ))} + + + + {data.length === 0 ? ( + + + {emptyMessage} + + + ) : ( + data.map((row) => ( + + {columns.map((column) => ( + + {column.render(row)} + + ))} + + )) + )} + + + + ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index d0966ae..3190229 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -13,6 +13,12 @@ export type { ButtonProps, ButtonVariant, ButtonSize } from "./components/Button export { Input } from "./components/Input.js"; export type { InputProps } from "./components/Input.js"; +export { Select } from "./components/Select.js"; +export type { SelectProps, SelectOption } from "./components/Select.js"; + +export { Table } from "./components/Table.js"; +export type { TableProps, TableColumn } from "./components/Table.js"; + export { Card } from "./components/Card.js"; export type { CardProps } from "./components/Card.js";
(`/quotes${query}`, { method: "GET" }, { accessToken }); +} + +export function getQuote(id: string, accessToken: string): Promise { + return apiRequest(`/quotes/${id}`, { method: "GET" }, { accessToken }); +} + +export function createQuote(input: CreateQuoteInput, accessToken: string): Promise { + return apiRequest("/quotes", { method: "POST", body: JSON.stringify(input) }, { accessToken }); +} + +export function decideQuoteApproval( + id: string, + input: DecideApprovalInput, + accessToken: string, +): Promise { + return apiRequest( + `/quotes/${id}/approval`, + { method: "PATCH", body: JSON.stringify(input) }, + { accessToken }, + ); +} + +export function closeQuoteDeal(id: string, input: CloseDealInput, accessToken: string): Promise { + return apiRequest(`/quotes/${id}/close`, { method: "POST", body: JSON.stringify(input) }, { accessToken }); +} diff --git a/apps/core-web/src/lib/api/resellers.ts b/apps/core-web/src/lib/api/resellers.ts new file mode 100644 index 0000000..e2aa409 --- /dev/null +++ b/apps/core-web/src/lib/api/resellers.ts @@ -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; + +export function listResellers(accessToken: string): Promise { + return apiRequest("/resellers", { method: "GET" }, { accessToken }); +} + +export function getReseller(id: string, accessToken: string): Promise { + return apiRequest(`/resellers/${id}`, { method: "GET" }, { accessToken }); +} + +export function createReseller(input: CreateResellerInput, accessToken: string): Promise { + return apiRequest("/resellers", { method: "POST", body: JSON.stringify(input) }, { accessToken }); +} + +export function updateReseller( + id: string, + input: UpdateResellerInput, + accessToken: string, +): Promise { + return apiRequest( + `/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 }, + ); +} diff --git a/apps/core-web/src/lib/format.ts b/apps/core-web/src/lib/format.ts new file mode 100644 index 0000000..c30f27b --- /dev/null +++ b/apps/core-web/src/lib/format.ts @@ -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; +} diff --git a/apps/core-web/src/routes/AuthenticatedLayout.tsx b/apps/core-web/src/routes/AuthenticatedLayout.tsx index c2d385b..3efa306 100644 --- a/apps/core-web/src/routes/AuthenticatedLayout.tsx +++ b/apps/core-web/src/routes/AuthenticatedLayout.tsx @@ -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() { (null); + const [error, setError] = useState(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> = [ + { + key: "name", + header: "Nome", + render: (company) => ( + + {company.company_name} + {company.trade_name ? {company.trade_name} : null} + + ), + }, + { 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) => ( + {company.is_active ? "Ativo" : "Inativo"} + ), + }, + { + key: "actions", + header: "", + className: "text-right", + render: (company) => ( + navigate(`/companies/${company.id}/edit`)}> + Editar + + ), + }, + ]; + + return ( + + navigate("/companies/new")}>Nova empresa} + /> + + {error ? ( + + {error} + + ) : null} + + + {companies === null && !error ? ( + Carregando… + ) : ( + company.id} /> + )} + + + ); +} diff --git a/apps/core-web/src/routes/companies/CompanyFormPage.tsx b/apps/core-web/src/routes/companies/CompanyFormPage.tsx new file mode 100644 index 0000000..c69ae6d --- /dev/null +++ b/apps/core-web/src/routes/companies/CompanyFormPage.tsx @@ -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 { + 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(EMPTY_FORM); + const [loading, setLoading] = useState(isEditing); + const [error, setError] = useState(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(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 ( + + + ← Voltar para Empresas + + } + actions={ + <> + navigate("/companies")} type="button"> + Cancelar + + + Salvar + + > + } + /> + + {error ? ( + + {error} + + ) : null} + + {loading ? ( + + Carregando… + + ) : ( + + + + + updateField("companyName", e.target.value)} + required + /> + + updateField("tradeName", e.target.value)} + /> + updateField("cnpj", e.target.value)} /> + updateField("stateRegistration", e.target.value)} + /> + updateField("municipalRegistration", e.target.value)} + /> + {isEditing ? ( + + Status + + updateField("isActive", e.target.checked)} + /> + Empresa ativa + + + ) : null} + + + + + + updateField("addressZip", e.target.value)} /> + + updateField("addressStreet", e.target.value)} + /> + + updateField("addressNumber", e.target.value)} + /> + updateField("addressComplement", e.target.value)} + /> + updateField("addressNeighborhood", e.target.value)} + /> + updateField("addressCity", e.target.value)} + /> + updateField("addressState", e.target.value.toUpperCase())} + /> + updateField("addressCountry", e.target.value)} + placeholder="Brasil" + /> + + + + + + updateField("phone", e.target.value)} /> + updateField("email", e.target.value)} + /> + updateField("website", e.target.value)} /> + + + + + + updateField("legalRepName", e.target.value)} + /> + updateField("legalRepCpf", e.target.value)} + /> + updateField("legalRepRole", e.target.value)} + /> + updateField("legalRepEmail", e.target.value)} + /> + + + + )} + + ); +} diff --git a/apps/core-web/src/routes/crm/ConvertLeadPage.tsx b/apps/core-web/src/routes/crm/ConvertLeadPage.tsx new file mode 100644 index 0000000..d914c51 --- /dev/null +++ b/apps/core-web/src/routes/crm/ConvertLeadPage.tsx @@ -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 = { + 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(null); + const [title, setTitle] = useState(""); + const [stage, setStage] = useState("prospecting"); + const [probability, setProbability] = useState(""); + const [notes, setNotes] = useState(""); + + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(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 ( + + + + + {error ? ( + + {error} + + ) : null} + + + {loading ? ( + Carregando lead… + ) : ( + + setTitle(e.target.value)} + required + autoFocus + /> + setStage(e.target.value as OpportunityStage)} + /> + setProbability(e.target.value)} + /> + + Notas + setNotes(e.target.value)} + /> + + + + Converter + + navigate("/leads")}> + Cancelar + + + + )} + + + ); +} diff --git a/apps/core-web/src/routes/crm/CrmSubNav.tsx b/apps/core-web/src/routes/crm/CrmSubNav.tsx new file mode 100644 index 0000000..03d5697 --- /dev/null +++ b/apps/core-web/src/routes/crm/CrmSubNav.tsx @@ -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 ( + + + Leads + + + Oportunidades + + + ); +} diff --git a/apps/core-web/src/routes/crm/LeadFormPage.tsx b/apps/core-web/src/routes/crm/LeadFormPage.tsx new file mode 100644 index 0000000..0d41695 --- /dev/null +++ b/apps/core-web/src/routes/crm/LeadFormPage.tsx @@ -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 = { + 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("new"); + const [notes, setNotes] = useState(""); + + const [loading, setLoading] = useState(isEdit); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(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 ( + + + + + {error ? ( + + {error} + + ) : null} + + + {loading ? ( + Carregando lead… + ) : ( + + setContactName(e.target.value)} + required + autoFocus + /> + setCompanyName(e.target.value)} /> + setContactEmail(e.target.value)} + /> + setContactPhone(e.target.value)} /> + setSource(e.target.value)} hint="Ex.: Site, indicação, campanha" /> + setStatus(e.target.value as LeadStatus)} + /> + + Notas + setNotes(e.target.value)} + /> + + + + {isEdit ? "Salvar alterações" : "Criar lead"} + + navigate("/leads")}> + Cancelar + + + + )} + + + ); +} diff --git a/apps/core-web/src/routes/crm/LeadsListPage.tsx b/apps/core-web/src/routes/crm/LeadsListPage.tsx new file mode 100644 index 0000000..e8ca3ab --- /dev/null +++ b/apps/core-web/src/routes/crm/LeadsListPage.tsx @@ -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 = { + new: "Novo", + contacted: "Contatado", + qualified: "Qualificado", + converted: "Convertido", + discarded: "Descartado", +}; + +const STATUS_VARIANT: Record = { + new: "info", + contacted: "warning", + qualified: "primary", + converted: "success", + discarded: "danger", +}; + +export function LeadsListPage() { + const { accessToken } = useAuth(); + const [leads, setLeads] = useState(null); + const [error, setError] = useState(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 ( + + + + Novo lead + + } + /> + + {error ? ( + + {error} + + ) : null} + + + {leads === null && !error ? ( + Carregando leads… + ) : leads && leads.length === 0 ? ( + Nenhum lead cadastrado ainda. + ) : leads ? ( + + + + + Contato + Empresa + Status + Ações + + + + {leads.map((lead) => { + const canConvert = lead.status !== "converted" && lead.status !== "discarded"; + return ( + + + + {lead.contactName} + + {lead.contactEmail ? {lead.contactEmail} : null} + + {lead.companyName ?? "—"} + + + {STATUS_LABEL[lead.status] ?? lead.status} + + + + + + Editar + + {canConvert ? ( + + Converter em oportunidade + + ) : null} + + + + ); + })} + + + + ) : null} + + + ); +} diff --git a/apps/core-web/src/routes/crm/OpportunitiesListPage.tsx b/apps/core-web/src/routes/crm/OpportunitiesListPage.tsx new file mode 100644 index 0000000..7e83021 --- /dev/null +++ b/apps/core-web/src/routes/crm/OpportunitiesListPage.tsx @@ -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 = { + prospecting: "Prospecção", + qualification: "Qualificação", + proposal: "Proposta", + negotiation: "Negociação", + won: "Ganha", + lost: "Perdida", +}; + +const STAGE_VARIANT: Record = { + prospecting: "info", + qualification: "primary", + proposal: "warning", + negotiation: "secondary", + won: "success", + lost: "danger", +}; + +export function OpportunitiesListPage() { + const { accessToken } = useAuth(); + const [opportunities, setOpportunities] = useState(null); + const [error, setError] = useState(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 ( + + + + Nova oportunidade + + } + /> + + {error ? ( + + {error} + + ) : null} + + + {opportunities === null && !error ? ( + Carregando oportunidades… + ) : opportunities && opportunities.length === 0 ? ( + Nenhuma oportunidade cadastrada ainda. + ) : opportunities ? ( + + + + + Título + Estágio + Probabilidade + Dono + Ações + + + + {opportunities.map((opportunity) => ( + + + + {opportunity.title} + + + + + {STAGE_LABEL[opportunity.stage] ?? opportunity.stage} + + + + {opportunity.probability != null ? `${opportunity.probability}%` : "—"} + + {opportunity.ownerId ?? "—"} + + + Editar + + + + ))} + + + + ) : null} + + + ); +} diff --git a/apps/core-web/src/routes/crm/OpportunityFormPage.tsx b/apps/core-web/src/routes/crm/OpportunityFormPage.tsx new file mode 100644 index 0000000..8fe8042 --- /dev/null +++ b/apps/core-web/src/routes/crm/OpportunityFormPage.tsx @@ -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 = { + 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("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(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 ( + + + + + {error ? ( + + {error} + + ) : null} + + + {loading ? ( + Carregando oportunidade… + ) : ( + + setTitle(e.target.value)} + required + autoFocus + /> + setStage(e.target.value as OpportunityStage)} + /> + setProbability(e.target.value)} + /> + {stage === "lost" ? ( + setLossReason(e.target.value)} + /> + ) : null} + + Notas + setNotes(e.target.value)} + /> + + + + {isEdit ? "Salvar alterações" : "Criar oportunidade"} + + navigate("/opportunities")}> + Cancelar + + + + )} + + + ); +} diff --git a/apps/core-web/src/routes/products/ProductFormPage.tsx b/apps/core-web/src/routes/products/ProductFormPage.tsx new file mode 100644 index 0000000..8c8d9b9 --- /dev/null +++ b/apps/core-web/src/routes/products/ProductFormPage.tsx @@ -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 ( + 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(EMPTY_FORM); + const [loading, setLoading] = useState(isEditing); + const [error, setError] = useState(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(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 ( + + + ← Voltar para Produtos + + } + actions={ + <> + navigate("/products")} type="button"> + Cancelar + + + Salvar + + > + } + /> + + {error ? ( + + {error} + + ) : null} + + {loading ? ( + + Carregando… + + ) : ( + + + + + updateField("name", e.target.value)} required /> + + updateField("productType", e.target.value)} + required + /> + updateField("unit", e.target.value)} /> + updateField("productGroup", e.target.value)} + /> + updateField("productSubgroup", e.target.value)} + /> + updateField("brand", e.target.value)} /> + + + Descrição + + updateField("description", e.target.value)} + /> + + + + + + + updateField("price0Cents", v)} /> + updateField("price12Cents", v)} /> + updateField("price24Cents", v)} /> + updateField("price36Cents", v)} /> + updateField("price48Cents", v)} /> + updateField("implUnitPriceCents", v)} + /> + + + + + + {( + [ + ["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]) => ( + + updateField(field, e.target.checked)} + /> + {label} + + ))} + + + + )} + + ); +} diff --git a/apps/core-web/src/routes/products/ProductsListPage.tsx b/apps/core-web/src/routes/products/ProductsListPage.tsx new file mode 100644 index 0000000..14deccb --- /dev/null +++ b/apps/core-web/src/routes/products/ProductsListPage.tsx @@ -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(null); + const [error, setError] = useState(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> = [ + { key: "code", header: "Código", render: (product) => `#${product.product_code}` }, + { key: "name", header: "Nome", render: (product) => {product.name} }, + { key: "type", header: "Tipo", render: (product) => product.product_type }, + ...PRICE_COLUMNS.map( + (column): TableColumn => ({ + 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 ? ( + Descontinuado + ) : ( + {product.active ? "Ativo" : "Inativo"} + ), + }, + { + key: "actions", + header: "", + className: "text-right", + render: (product) => ( + navigate(`/products/${product.id}/edit`)}> + Editar + + ), + }, + ]; + + return ( + + navigate("/products/new")}>Novo produto} + /> + + {error ? ( + + {error} + + ) : null} + + + {products === null && !error ? ( + Carregando… + ) : ( + product.id} /> + )} + + + ); +} diff --git a/apps/core-web/src/routes/quotes/QuoteDetailPage.tsx b/apps/core-web/src/routes/quotes/QuoteDetailPage.tsx new file mode 100644 index 0000000..bec95a5 --- /dev/null +++ b/apps/core-web/src/routes/quotes/QuoteDetailPage.tsx @@ -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 = { + none: "Sem aprovação", + pending: "Pendente", + approved: "Aprovada", + rejected: "Rejeitada", +}; + +const APPROVAL_VARIANT: Record = { + none: "neutral", + pending: "warning", + approved: "success", + rejected: "danger", +}; + +const DEAL_LABEL: Record = { + orcamento: "Orçamento", + fechado: "Fechado", + perdido: "Perdido", +}; + +const DEAL_VARIANT: Record = { + orcamento: "info", + fechado: "success", + perdido: "danger", +}; + +export function QuoteDetailPage() { + const { id } = useParams<{ id: string }>(); + const { accessToken } = useAuth(); + + const [quote, setQuote] = useState(null); + const [reseller, setReseller] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( + + + Carregando oferta… + + ); + } + + if (error || !quote) { + return ( + + + {error ?? "Oferta não encontrada."} + + ); + } + + return ( + + + + {APPROVAL_LABEL[quote.approval_status]} + {DEAL_LABEL[quote.deal_status]} + + } + /> + + + + + + + + + + + + + + + + + + + + + + + + + Produto + Qtd. + Preço unit. + Total + Impl. unit. + Impl. total + + + + {quote.items.map((item, index) => ( + + {item.productName} + {item.quantity} + {formatCurrencyBRL(item.unitPrice)} + {formatCurrencyBRL(item.total)} + {formatCurrencyBRL(item.implUnitPrice)} + {formatCurrencyBRL(item.implTotal)} + + ))} + + + + + + + + + {quote.proposed_monthly_total != null ? ( + + ) : null} + + + + + {quote.notes ? ( + + Notas + {quote.notes} + + ) : null} + + + {quote.approval_status === "pending" ? ( + + ) : quote.approval_status !== "none" && quote.approval_notes ? ( + + {quote.approval_notes} + + ) : null} + + {quote.deal_status === "orcamento" ? ( + + ) : quote.client_registration_id ? ( + + + + ) : null} + + + + ); +} + +function Row({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ); +} + +function SummaryRow({ label, value, highlight }: { label: string; value: string; highlight?: boolean }) { + return ( + + {label} + + {value} + + + ); +} + +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(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 ( + + {error ? ( + + {error} + + ) : null} + + Esta oferta tem desconto e/ou fidelidade reduzida e precisa de aprovação antes de seguir. + + setNotes(e.target.value)} + className="mb-3" + /> + + decide("approved")} loading={submitting === "approved"}> + Aprovar + + decide("rejected")} loading={submitting === "rejected"}> + Rejeitar + + + + ); +} + +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(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 ( + + {error ? ( + + {error} + + ) : null} + + setClientRegistrationId(e.target.value)} + hint="Cole o UUID de um cadastro de cliente já existente" + className="min-w-72 flex-1" + required + /> + + Fechar negócio + + + + ); +} diff --git a/apps/core-web/src/routes/quotes/QuoteFormPage.tsx b/apps/core-web/src/routes/quotes/QuoteFormPage.tsx new file mode 100644 index 0000000..1ac1f6e --- /dev/null +++ b/apps/core-web/src/routes/quotes/QuoteFormPage.tsx @@ -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(null); + const [products, setProducts] = useState(null); + const [loadError, setLoadError] = useState(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("12"); + const [fidelityPeriod, setFidelityPeriod] = useState(""); + const [items, setItems] = useState([{ ...EMPTY_ROW }]); + const [implGeral, setImplGeral] = useState(""); + const [proposedMonthlyTotal, setProposedMonthlyTotal] = useState(""); + const [implPaymentCondition, setImplPaymentCondition] = useState(""); + const [notes, setNotes] = useState(""); + + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [createdQuote, setCreatedQuote] = useState(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) { + 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 ( + + + + Oferta criada com sucesso. Confira os totais calculados pelo servidor abaixo. + + + + + + + + {createdQuote.approval_status === "pending" ? ( + + Esta oferta possui desconto e/ou fidelidade reduzida — aguarda aprovação. + + ) : null} + + + Ver oferta completa + + navigate("/quotes")}> + Voltar para a lista + + + + + ); + } + + return ( + + + + + {loadError ? ( + + {loadError} + + ) : null} + {error ? ( + + {error} + + ) : null} + + + {!resellers || !products ? ( + Carregando revendas e produtos… + ) : ( + + + Dados do cliente + + ({ value: r.id, label: r.name }))} + placeholder="Selecione a revenda" + value={resellerId} + onChange={(e) => setResellerId(e.target.value)} + required + /> + setClientName(e.target.value)} required /> + setClientCompany(e.target.value)} /> + setClientEmail(e.target.value)} /> + setClientPhone(e.target.value)} required /> + + + + + Contrato + + ({ + value: String(period), + label: period === 0 ? "Sem fidelidade (0 meses)" : `${period} meses`, + }))} + value={contractPeriod} + onChange={(e) => setContractPeriod(e.target.value)} + required + /> + setFidelityPeriod(e.target.value)} + hint="Só preencha se for menor que o período do contrato — entra em aprovação" + /> + + + + + + Itens + + + Adicionar item + + + + + + + Produto + Quantidade + + + + + {items.map((row, index) => ( + + + updateItem(index, { productId: e.target.value })} + aria-label="Produto" + required + /> + + + updateItem(index, { quantity: e.target.value })} + aria-label="Quantidade" + required + /> + + + {items.length > 1 ? ( + removeItem(index)} + className="rounded-md border border-border-color px-2 py-1 text-xs font-semibold text-danger hover:bg-light" + > + Remover + + ) : null} + + + ))} + + + + + + + Implantação e valores + + setImplGeral(e.target.value)} + hint="Custo de implantação além dos produtos" + /> + setProposedMonthlyTotal(e.target.value)} + hint="Preencha só se for oferecer desconto — a oferta precisará de aprovação" + /> + + ({ + value: condition, + label: condition, + }))} + placeholder="Selecione a condição" + value={implPaymentCondition} + onChange={(e) => setImplPaymentCondition(e.target.value as ImplPaymentCondition)} + /> + + Faixas sugeridas para {formatCurrencyBRL(estimatedImplTotal)} de implantação estimada:{" "} + {availablePaymentConditions.map((c) => ( + + {c} + + ))} + {" "}— o servidor valida a faixa real ao salvar. + + + + + + + Notas + setNotes(e.target.value)} + /> + + + + + Criar oferta + + navigate("/quotes")}> + Cancelar + + + + )} + + + ); +} + +function SummaryField({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ); +} diff --git a/apps/core-web/src/routes/quotes/QuotesListPage.tsx b/apps/core-web/src/routes/quotes/QuotesListPage.tsx new file mode 100644 index 0000000..b1bc219 --- /dev/null +++ b/apps/core-web/src/routes/quotes/QuotesListPage.tsx @@ -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 = { + none: "Sem aprovação", + pending: "Pendente", + approved: "Aprovada", + rejected: "Rejeitada", +}; + +const APPROVAL_VARIANT: Record = { + none: "neutral", + pending: "warning", + approved: "success", + rejected: "danger", +}; + +const DEAL_LABEL: Record = { + orcamento: "Orçamento", + fechado: "Fechado", + perdido: "Perdido", +}; + +const DEAL_VARIANT: Record = { + orcamento: "info", + fechado: "success", + perdido: "danger", +}; + +export function QuotesListPage() { + const { accessToken } = useAuth(); + const [quotes, setQuotes] = useState(null); + const [error, setError] = useState(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 ( + + + Nova oferta + + } + /> + + {error ? ( + + {error} + + ) : null} + + + {quotes === null && !error ? ( + Carregando ofertas… + ) : quotes && quotes.length === 0 ? ( + Nenhuma oferta cadastrada ainda. + ) : quotes ? ( + + + + + Oferta + Cliente + Mensalidade + Aprovação + Negócio + + + + {quotes.map((quote) => ( + + + + {quote.quote_number} + + + + {quote.client_name} + {quote.client_company ? {quote.client_company} : null} + + + {formatCurrencyBRL(quote.proposed_monthly_total ?? quote.monthly_total)} + + + + {APPROVAL_LABEL[quote.approval_status]} + + + + {DEAL_LABEL[quote.deal_status]} + + + ))} + + + + ) : null} + + + ); +} diff --git a/apps/core-web/src/routes/resellers/ResellerFormPage.tsx b/apps/core-web/src/routes/resellers/ResellerFormPage.tsx new file mode 100644 index 0000000..be7c921 --- /dev/null +++ b/apps/core-web/src/routes/resellers/ResellerFormPage.tsx @@ -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(EMPTY_FORM); + const [loading, setLoading] = useState(isEditing); + const [error, setError] = useState(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(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 ( + + + ← Voltar para Revendas + + } + actions={ + <> + navigate("/resellers")} type="button"> + Cancelar + + + Salvar + + > + } + /> + + {error ? ( + + {error} + + ) : null} + + {loading ? ( + + Carregando… + + ) : ( + + + + + updateField("name", e.target.value)} required /> + + updateField("document", e.target.value)} /> + updateField("contactEmail", e.target.value)} + /> + + Status + + updateField("active", e.target.checked)} + /> + Revenda ativa + + + + + + )} + + ); +} diff --git a/apps/core-web/src/routes/resellers/ResellersListPage.tsx b/apps/core-web/src/routes/resellers/ResellersListPage.tsx new file mode 100644 index 0000000..ef60cf8 --- /dev/null +++ b/apps/core-web/src/routes/resellers/ResellersListPage.tsx @@ -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(null); + const [error, setError] = useState(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> = [ + { key: "name", header: "Nome", render: (reseller) => {reseller.name} }, + { 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) => {reseller.active ? "Ativo" : "Inativo"}, + }, + { + key: "actions", + header: "", + className: "text-right", + render: (reseller) => ( + navigate(`/resellers/${reseller.id}/edit`)}> + Editar + + ), + }, + ]; + + return ( + + navigate("/resellers/new")}>Nova revenda} + /> + + {error ? ( + + {error} + + ) : null} + + + {resellers === null && !error ? ( + Carregando… + ) : ( + reseller.id} /> + )} + + + ); +} diff --git a/docs/progress.md b/docs/progress.md index 128f567..c8d34ad 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -294,3 +294,30 @@ Verificado: `pnpm turbo run typecheck build` (28/28), `pnpm --filter @eden/api t ### Pendências Toda a Assinatura Eletrônica (eden.md §1.4-1.9/§5/§6/§7 — envelopes, signatários, OTP, hash-chain, verificação pública), geração real de PDF (Playwright/Chromium) + sanitização de HTML, editor visual Tiptap real (`renderToHtml`/`resolveDocument` com condicionais e repetição de tabela), seed dos 7 templates reais de produção da Handix, regra de idempotência completa com `force`/`extra_fields`. + +--- + +## Fase 2/8 — Telas de negócio reais no EDEN Core (Empresas, Produtos, Revendas, CRM, Ofertas) + +Data: 2026-09-03. Usuário pediu explicitamente para priorizar a parte web ("ja faca a parte web para eu ja ver como esta ficando"). 2 subagentes `eden-frontend` em paralelo, mesma sessão, construindo sobre o shell autenticado + Design System já prontos. + +**Correção de instrução no meio da leva**: o usuário interrompeu para dizer "não esqueça de seguir o template" / "melhor usar ele" — as telas deviam seguir de perto o LAYOUT REAL do tema DreamsERP comprado, não só os tokens visuais abstratos já extraídos. Extraí do `tema_do_Eden.zip` as páginas React reais mais relevantes (`sales/salesQuotes*.tsx`, `crm/leads.tsx`/`deals*.tsx`, `inventory/products*.tsx`, `purchase/vendors.tsx`, `system/userManagement.tsx`, `ui-interface/{tables,forms}`) para dentro do worktree de cada agente (`.tmp/theme-reference/`, nunca versionado) e mandei mensagem de correção para os dois agentes já em execução — via `SendMessage`, não relançando a tarefa (no meio do processo cometi o erro de usar `Agent` em vez de `SendMessage` para a primeira correção, criando um agente duplicado; percebido e corrigido na hora com `TaskStop` antes de qualquer trabalho real ser feito por ele). Ambos incorporaram a referência do tema (estrutura de header/breadcrumb/tabela/formulário em cards) reescrita com `@eden/ui` + Tailwind puro — sem copiar JSX nem trazer dependências do tema (antd/Preline) para o projeto. + +### Empresas / Produtos / Revendas (`apps/core-web/src/routes/{companies,products,resellers}/`) + +CRUD completo (lista + criar/editar) para os 3 módulos, layout modelado em `purchase/vendors.tsx` (empresas/revendas) e `inventory/products.tsx` (produtos, com máscara de moeda para as 5 faixas de preço + implantação). Novo componente `Table` em `packages/ui` (tabela genérica reutilizável, sem dependência de terceiro). + +### CRM e Ofertas/Quotes (`apps/core-web/src/routes/{crm,quotes}/`) + +Leads (lista, criar/editar, converter em oportunidade) e Oportunidades (lista, criar/editar) modelados em `crm/leads.tsx`/`dealsDetails.tsx`. Ofertas — o módulo mais rico: `QuotesListPage`, `QuoteFormPage` (revenda + cliente + período de contrato + fidelidade reduzida opcional + lista dinâmica de itens produto/quantidade + desconto opcional + condição de pagamento, mostra os totais calculados pelo servidor antes de ir para o detalhe) e `QuoteDetailPage` (resumo financeiro completo, aprovar/rejeitar quando pendente, fechar negócio quando ainda é orçamento) — modelados em `sales/salesQuotes.tsx`/`addSalesQuotes.tsx`/`salesQuotesDetails.tsx`. Novo componente `Select` em `packages/ui`. + +**Achado real do próprio agente, corrigindo meu brief**: eu tinha dito no brief que as respostas de Leads/Opportunities eram snake_case (copiando o padrão do resto da API) — o agente verificou contra o código-fonte real (`packages/database/src/repositories/leads.ts`) e descobriu que esse módulo específico mapeia para **camelCase** na resposta (`mapLead`), diferente de Quotes/Companies/Products/Resellers (snake_case direto da linha do banco). Confirmado ao vivo via curl antes de integrar. + +### Integração manual (os 2 agentes trabalharam em paralelo no mesmo `core-web`) + +Um dos agentes não conseguiu `git merge` dentro do próprio sandbox e reconstruiu manualmente (leitura+escrita) o estado já existente do shell autenticado — comparei arquivo por arquivo contra `main` (`diff -rq`) para confirmar que a restauração foi fiel (nenhum componente restaurado divergiu) antes de aceitar qualquer coisa como "nova". `App.tsx`, `AuthenticatedLayout.tsx` (nav unificada, lógica de "ativo" por prefixo de path com borda de `/` para nunca casar `/companies` com `/companies-x`) e `packages/ui/src/index.ts` foram reconciliados manualmente juntando as rotas/exports dos dois agentes; `lib/format.ts` teve as duas versões mescladas (`formatDocument` de um lado, `formatDateBR` do outro). + +Verificado: `pnpm turbo run typecheck build` (28/28). Containers `eden-api`/`eden-core` reconstruídos (o build em background travou duas vezes por algum motivo de recurso do ambiente — refeito em primeiro plano, funcionou); bundle de produção confirmado contendo todas as novas telas; login simulando requisição real de browser (`Origin: http://localhost:3001`) funcionando; listagens de Empresas/Produtos/Revendas retornando dados reais; criação de lead confirmando o formato camelCase ao vivo. + +### Pendências +Client Registration (Cadastro de Cliente) ainda sem tela — `QuoteDetailPage` hoje exige colar manualmente um UUID para fechar negócio. Nenhuma tela de Contracts/Inventory/Support/HR-Timeclock/Fiscal/Billing/Documents ainda. `reseller-web`/`subscriber-web` continuam só com o shell de login (sem telas de negócio). diff --git a/packages/ui/src/components/Card.tsx b/packages/ui/src/components/Card.tsx index 18d3025..0321931 100644 --- a/packages/ui/src/components/Card.tsx +++ b/packages/ui/src/components/Card.tsx @@ -4,10 +4,12 @@ import { cn } from "../utils/cn.js"; export interface CardProps extends Omit, "title"> { title?: ReactNode; actions?: ReactNode; + /** Overrides the default `p-5` on the content wrapper — e.g. `"p-0"` for a flush data table (theme source: sales/salesQuotes.tsx uses `p-4 pb-0` around its table). */ + bodyClassName?: string; } /** Theme source (auth-pages/login.tsx): `bg-white border border-border-color rounded-md shadow-sm`. */ -export function Card({ title, actions, className, children, ...rest }: CardProps) { +export function Card({ title, actions, className, bodyClassName, children, ...rest }: CardProps) { return ( {title || actions ? ( @@ -16,7 +18,7 @@ export function Card({ title, actions, className, children, ...rest }: CardProps {actions ? {actions} : null} ) : null} - {children} + {children} ); } diff --git a/packages/ui/src/components/Select.tsx b/packages/ui/src/components/Select.tsx new file mode 100644 index 0000000..b97a50b --- /dev/null +++ b/packages/ui/src/components/Select.tsx @@ -0,0 +1,93 @@ +import { forwardRef, useId } from "react"; +import type { SelectHTMLAttributes } from "react"; +import { cn } from "../utils/cn.js"; + +export interface SelectOption { + value: string; + label: string; +} + +export interface SelectProps extends Omit, "children"> { + label?: string; + /** Validation/server error message — shown below the field, field border turns danger. */ + error?: string; + /** Helper text shown below the field when there's no error. */ + hint?: string; + options: SelectOption[]; + /** Rendered as a disabled, selected-by-default first option (e.g. "Selecione..."). */ + placeholder?: string; +} + +/** + * Native `` styled to match `Input` (packages/ui/src/components/Input.tsx) + * — same border/padding/focus-ring language as the rest of the Design + * System. Derived from the DreamsERP theme's form field sizing + * (`.tmp/theme-reference/sales/addSalesQuotes.tsx` etc. use a + * `react-select`-based CommonSelect for the same slots, out of our stack — + * this is a plain, dependency-free equivalent with the same visual sizing). + */ +export const Select = forwardRef(function Select( + { label, error, hint, id, className, options, placeholder, ...rest }, + ref, +) { + const generatedId = useId(); + const selectId = id ?? generatedId; + + return ( + + {label ? ( + + {label} + + ) : null} + + + {placeholder ? ( + + {placeholder} + + ) : null} + {options.map((option) => ( + + {option.label} + + ))} + + + + + + {error ? ( + + {error} + + ) : hint ? ( + + {hint} + + ) : null} + + ); +}); diff --git a/packages/ui/src/components/Table.tsx b/packages/ui/src/components/Table.tsx new file mode 100644 index 0000000..94c0bd4 --- /dev/null +++ b/packages/ui/src/components/Table.tsx @@ -0,0 +1,64 @@ +import type { ReactNode } from "react"; +import { cn } from "../utils/cn.js"; + +export interface TableColumn { + key: string; + header: ReactNode; + render: (row: T) => ReactNode; + /** Applied to both the header and body cells — e.g. `"text-right"` for numeric columns. */ + className?: string; +} + +export interface TableProps { + columns: Array>; + data: T[]; + rowKey: (row: T) => string; + /** Shown instead of the table body when `data` is empty (and not loading). */ + emptyMessage?: ReactNode; +} + +/** + * Plain data table styled to match the DreamsERP theme's list screens + * (react/src/pages/inventory/products.tsx et al.) without pulling in the + * theme's antd/react-window dependencies — just a semantic ``. + */ +export function Table({ columns, data, rowKey, emptyMessage = "Nenhum registro encontrado." }: TableProps) { + return ( + + + + + {columns.map((column) => ( + + {column.header} + + ))} + + + + {data.length === 0 ? ( + + + {emptyMessage} + + + ) : ( + data.map((row) => ( + + {columns.map((column) => ( + + {column.render(row)} + + ))} + + )) + )} + + + + ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index d0966ae..3190229 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -13,6 +13,12 @@ export type { ButtonProps, ButtonVariant, ButtonSize } from "./components/Button export { Input } from "./components/Input.js"; export type { InputProps } from "./components/Input.js"; +export { Select } from "./components/Select.js"; +export type { SelectProps, SelectOption } from "./components/Select.js"; + +export { Table } from "./components/Table.js"; +export type { TableProps, TableColumn } from "./components/Table.js"; + export { Card } from "./components/Card.js"; export type { CardProps } from "./components/Card.js";
{ + return apiRequest(`/quotes/${id}`, { method: "GET" }, { accessToken }); +} + +export function createQuote(input: CreateQuoteInput, accessToken: string): Promise { + return apiRequest("/quotes", { method: "POST", body: JSON.stringify(input) }, { accessToken }); +} + +export function decideQuoteApproval( + id: string, + input: DecideApprovalInput, + accessToken: string, +): Promise { + return apiRequest( + `/quotes/${id}/approval`, + { method: "PATCH", body: JSON.stringify(input) }, + { accessToken }, + ); +} + +export function closeQuoteDeal(id: string, input: CloseDealInput, accessToken: string): Promise { + return apiRequest(`/quotes/${id}/close`, { method: "POST", body: JSON.stringify(input) }, { accessToken }); +} diff --git a/apps/core-web/src/lib/api/resellers.ts b/apps/core-web/src/lib/api/resellers.ts new file mode 100644 index 0000000..e2aa409 --- /dev/null +++ b/apps/core-web/src/lib/api/resellers.ts @@ -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; + +export function listResellers(accessToken: string): Promise { + return apiRequest("/resellers", { method: "GET" }, { accessToken }); +} + +export function getReseller(id: string, accessToken: string): Promise { + return apiRequest(`/resellers/${id}`, { method: "GET" }, { accessToken }); +} + +export function createReseller(input: CreateResellerInput, accessToken: string): Promise { + return apiRequest("/resellers", { method: "POST", body: JSON.stringify(input) }, { accessToken }); +} + +export function updateReseller( + id: string, + input: UpdateResellerInput, + accessToken: string, +): Promise { + return apiRequest( + `/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 }, + ); +} diff --git a/apps/core-web/src/lib/format.ts b/apps/core-web/src/lib/format.ts new file mode 100644 index 0000000..c30f27b --- /dev/null +++ b/apps/core-web/src/lib/format.ts @@ -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; +} diff --git a/apps/core-web/src/routes/AuthenticatedLayout.tsx b/apps/core-web/src/routes/AuthenticatedLayout.tsx index c2d385b..3efa306 100644 --- a/apps/core-web/src/routes/AuthenticatedLayout.tsx +++ b/apps/core-web/src/routes/AuthenticatedLayout.tsx @@ -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() { (null); + const [error, setError] = useState(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> = [ + { + key: "name", + header: "Nome", + render: (company) => ( + + {company.company_name} + {company.trade_name ? {company.trade_name} : null} + + ), + }, + { 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) => ( + {company.is_active ? "Ativo" : "Inativo"} + ), + }, + { + key: "actions", + header: "", + className: "text-right", + render: (company) => ( + navigate(`/companies/${company.id}/edit`)}> + Editar + + ), + }, + ]; + + return ( + + navigate("/companies/new")}>Nova empresa} + /> + + {error ? ( + + {error} + + ) : null} + + + {companies === null && !error ? ( + Carregando… + ) : ( + company.id} /> + )} + + + ); +} diff --git a/apps/core-web/src/routes/companies/CompanyFormPage.tsx b/apps/core-web/src/routes/companies/CompanyFormPage.tsx new file mode 100644 index 0000000..c69ae6d --- /dev/null +++ b/apps/core-web/src/routes/companies/CompanyFormPage.tsx @@ -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 { + 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(EMPTY_FORM); + const [loading, setLoading] = useState(isEditing); + const [error, setError] = useState(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(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 ( + + + ← Voltar para Empresas + + } + actions={ + <> + navigate("/companies")} type="button"> + Cancelar + + + Salvar + + > + } + /> + + {error ? ( + + {error} + + ) : null} + + {loading ? ( + + Carregando… + + ) : ( + + + + + updateField("companyName", e.target.value)} + required + /> + + updateField("tradeName", e.target.value)} + /> + updateField("cnpj", e.target.value)} /> + updateField("stateRegistration", e.target.value)} + /> + updateField("municipalRegistration", e.target.value)} + /> + {isEditing ? ( + + Status + + updateField("isActive", e.target.checked)} + /> + Empresa ativa + + + ) : null} + + + + + + updateField("addressZip", e.target.value)} /> + + updateField("addressStreet", e.target.value)} + /> + + updateField("addressNumber", e.target.value)} + /> + updateField("addressComplement", e.target.value)} + /> + updateField("addressNeighborhood", e.target.value)} + /> + updateField("addressCity", e.target.value)} + /> + updateField("addressState", e.target.value.toUpperCase())} + /> + updateField("addressCountry", e.target.value)} + placeholder="Brasil" + /> + + + + + + updateField("phone", e.target.value)} /> + updateField("email", e.target.value)} + /> + updateField("website", e.target.value)} /> + + + + + + updateField("legalRepName", e.target.value)} + /> + updateField("legalRepCpf", e.target.value)} + /> + updateField("legalRepRole", e.target.value)} + /> + updateField("legalRepEmail", e.target.value)} + /> + + + + )} + + ); +} diff --git a/apps/core-web/src/routes/crm/ConvertLeadPage.tsx b/apps/core-web/src/routes/crm/ConvertLeadPage.tsx new file mode 100644 index 0000000..d914c51 --- /dev/null +++ b/apps/core-web/src/routes/crm/ConvertLeadPage.tsx @@ -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 = { + 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(null); + const [title, setTitle] = useState(""); + const [stage, setStage] = useState("prospecting"); + const [probability, setProbability] = useState(""); + const [notes, setNotes] = useState(""); + + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(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 ( + + + + + {error ? ( + + {error} + + ) : null} + + + {loading ? ( + Carregando lead… + ) : ( + + setTitle(e.target.value)} + required + autoFocus + /> + setStage(e.target.value as OpportunityStage)} + /> + setProbability(e.target.value)} + /> + + Notas + setNotes(e.target.value)} + /> + + + + Converter + + navigate("/leads")}> + Cancelar + + + + )} + + + ); +} diff --git a/apps/core-web/src/routes/crm/CrmSubNav.tsx b/apps/core-web/src/routes/crm/CrmSubNav.tsx new file mode 100644 index 0000000..03d5697 --- /dev/null +++ b/apps/core-web/src/routes/crm/CrmSubNav.tsx @@ -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 ( + + + Leads + + + Oportunidades + + + ); +} diff --git a/apps/core-web/src/routes/crm/LeadFormPage.tsx b/apps/core-web/src/routes/crm/LeadFormPage.tsx new file mode 100644 index 0000000..0d41695 --- /dev/null +++ b/apps/core-web/src/routes/crm/LeadFormPage.tsx @@ -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 = { + 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("new"); + const [notes, setNotes] = useState(""); + + const [loading, setLoading] = useState(isEdit); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(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 ( + + + + + {error ? ( + + {error} + + ) : null} + + + {loading ? ( + Carregando lead… + ) : ( + + setContactName(e.target.value)} + required + autoFocus + /> + setCompanyName(e.target.value)} /> + setContactEmail(e.target.value)} + /> + setContactPhone(e.target.value)} /> + setSource(e.target.value)} hint="Ex.: Site, indicação, campanha" /> + setStatus(e.target.value as LeadStatus)} + /> + + Notas + setNotes(e.target.value)} + /> + + + + {isEdit ? "Salvar alterações" : "Criar lead"} + + navigate("/leads")}> + Cancelar + + + + )} + + + ); +} diff --git a/apps/core-web/src/routes/crm/LeadsListPage.tsx b/apps/core-web/src/routes/crm/LeadsListPage.tsx new file mode 100644 index 0000000..e8ca3ab --- /dev/null +++ b/apps/core-web/src/routes/crm/LeadsListPage.tsx @@ -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 = { + new: "Novo", + contacted: "Contatado", + qualified: "Qualificado", + converted: "Convertido", + discarded: "Descartado", +}; + +const STATUS_VARIANT: Record = { + new: "info", + contacted: "warning", + qualified: "primary", + converted: "success", + discarded: "danger", +}; + +export function LeadsListPage() { + const { accessToken } = useAuth(); + const [leads, setLeads] = useState(null); + const [error, setError] = useState(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 ( + + + + Novo lead + + } + /> + + {error ? ( + + {error} + + ) : null} + + + {leads === null && !error ? ( + Carregando leads… + ) : leads && leads.length === 0 ? ( + Nenhum lead cadastrado ainda. + ) : leads ? ( + + + + + Contato + Empresa + Status + Ações + + + + {leads.map((lead) => { + const canConvert = lead.status !== "converted" && lead.status !== "discarded"; + return ( + + + + {lead.contactName} + + {lead.contactEmail ? {lead.contactEmail} : null} + + {lead.companyName ?? "—"} + + + {STATUS_LABEL[lead.status] ?? lead.status} + + + + + + Editar + + {canConvert ? ( + + Converter em oportunidade + + ) : null} + + + + ); + })} + + + + ) : null} + + + ); +} diff --git a/apps/core-web/src/routes/crm/OpportunitiesListPage.tsx b/apps/core-web/src/routes/crm/OpportunitiesListPage.tsx new file mode 100644 index 0000000..7e83021 --- /dev/null +++ b/apps/core-web/src/routes/crm/OpportunitiesListPage.tsx @@ -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 = { + prospecting: "Prospecção", + qualification: "Qualificação", + proposal: "Proposta", + negotiation: "Negociação", + won: "Ganha", + lost: "Perdida", +}; + +const STAGE_VARIANT: Record = { + prospecting: "info", + qualification: "primary", + proposal: "warning", + negotiation: "secondary", + won: "success", + lost: "danger", +}; + +export function OpportunitiesListPage() { + const { accessToken } = useAuth(); + const [opportunities, setOpportunities] = useState(null); + const [error, setError] = useState(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 ( + + + + Nova oportunidade + + } + /> + + {error ? ( + + {error} + + ) : null} + + + {opportunities === null && !error ? ( + Carregando oportunidades… + ) : opportunities && opportunities.length === 0 ? ( + Nenhuma oportunidade cadastrada ainda. + ) : opportunities ? ( + + + + + Título + Estágio + Probabilidade + Dono + Ações + + + + {opportunities.map((opportunity) => ( + + + + {opportunity.title} + + + + + {STAGE_LABEL[opportunity.stage] ?? opportunity.stage} + + + + {opportunity.probability != null ? `${opportunity.probability}%` : "—"} + + {opportunity.ownerId ?? "—"} + + + Editar + + + + ))} + + + + ) : null} + + + ); +} diff --git a/apps/core-web/src/routes/crm/OpportunityFormPage.tsx b/apps/core-web/src/routes/crm/OpportunityFormPage.tsx new file mode 100644 index 0000000..8fe8042 --- /dev/null +++ b/apps/core-web/src/routes/crm/OpportunityFormPage.tsx @@ -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 = { + 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("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(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 ( + + + + + {error ? ( + + {error} + + ) : null} + + + {loading ? ( + Carregando oportunidade… + ) : ( + + setTitle(e.target.value)} + required + autoFocus + /> + setStage(e.target.value as OpportunityStage)} + /> + setProbability(e.target.value)} + /> + {stage === "lost" ? ( + setLossReason(e.target.value)} + /> + ) : null} + + Notas + setNotes(e.target.value)} + /> + + + + {isEdit ? "Salvar alterações" : "Criar oportunidade"} + + navigate("/opportunities")}> + Cancelar + + + + )} + + + ); +} diff --git a/apps/core-web/src/routes/products/ProductFormPage.tsx b/apps/core-web/src/routes/products/ProductFormPage.tsx new file mode 100644 index 0000000..8c8d9b9 --- /dev/null +++ b/apps/core-web/src/routes/products/ProductFormPage.tsx @@ -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 ( + 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(EMPTY_FORM); + const [loading, setLoading] = useState(isEditing); + const [error, setError] = useState(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(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 ( + + + ← Voltar para Produtos + + } + actions={ + <> + navigate("/products")} type="button"> + Cancelar + + + Salvar + + > + } + /> + + {error ? ( + + {error} + + ) : null} + + {loading ? ( + + Carregando… + + ) : ( + + + + + updateField("name", e.target.value)} required /> + + updateField("productType", e.target.value)} + required + /> + updateField("unit", e.target.value)} /> + updateField("productGroup", e.target.value)} + /> + updateField("productSubgroup", e.target.value)} + /> + updateField("brand", e.target.value)} /> + + + Descrição + + updateField("description", e.target.value)} + /> + + + + + + + updateField("price0Cents", v)} /> + updateField("price12Cents", v)} /> + updateField("price24Cents", v)} /> + updateField("price36Cents", v)} /> + updateField("price48Cents", v)} /> + updateField("implUnitPriceCents", v)} + /> + + + + + + {( + [ + ["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]) => ( + + updateField(field, e.target.checked)} + /> + {label} + + ))} + + + + )} + + ); +} diff --git a/apps/core-web/src/routes/products/ProductsListPage.tsx b/apps/core-web/src/routes/products/ProductsListPage.tsx new file mode 100644 index 0000000..14deccb --- /dev/null +++ b/apps/core-web/src/routes/products/ProductsListPage.tsx @@ -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(null); + const [error, setError] = useState(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> = [ + { key: "code", header: "Código", render: (product) => `#${product.product_code}` }, + { key: "name", header: "Nome", render: (product) => {product.name} }, + { key: "type", header: "Tipo", render: (product) => product.product_type }, + ...PRICE_COLUMNS.map( + (column): TableColumn => ({ + 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 ? ( + Descontinuado + ) : ( + {product.active ? "Ativo" : "Inativo"} + ), + }, + { + key: "actions", + header: "", + className: "text-right", + render: (product) => ( + navigate(`/products/${product.id}/edit`)}> + Editar + + ), + }, + ]; + + return ( + + navigate("/products/new")}>Novo produto} + /> + + {error ? ( + + {error} + + ) : null} + + + {products === null && !error ? ( + Carregando… + ) : ( + product.id} /> + )} + + + ); +} diff --git a/apps/core-web/src/routes/quotes/QuoteDetailPage.tsx b/apps/core-web/src/routes/quotes/QuoteDetailPage.tsx new file mode 100644 index 0000000..bec95a5 --- /dev/null +++ b/apps/core-web/src/routes/quotes/QuoteDetailPage.tsx @@ -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 = { + none: "Sem aprovação", + pending: "Pendente", + approved: "Aprovada", + rejected: "Rejeitada", +}; + +const APPROVAL_VARIANT: Record = { + none: "neutral", + pending: "warning", + approved: "success", + rejected: "danger", +}; + +const DEAL_LABEL: Record = { + orcamento: "Orçamento", + fechado: "Fechado", + perdido: "Perdido", +}; + +const DEAL_VARIANT: Record = { + orcamento: "info", + fechado: "success", + perdido: "danger", +}; + +export function QuoteDetailPage() { + const { id } = useParams<{ id: string }>(); + const { accessToken } = useAuth(); + + const [quote, setQuote] = useState(null); + const [reseller, setReseller] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( + + + Carregando oferta… + + ); + } + + if (error || !quote) { + return ( + + + {error ?? "Oferta não encontrada."} + + ); + } + + return ( + + + + {APPROVAL_LABEL[quote.approval_status]} + {DEAL_LABEL[quote.deal_status]} + + } + /> + + + + + + + + + + + + + + + + + + + + + + + + + Produto + Qtd. + Preço unit. + Total + Impl. unit. + Impl. total + + + + {quote.items.map((item, index) => ( + + {item.productName} + {item.quantity} + {formatCurrencyBRL(item.unitPrice)} + {formatCurrencyBRL(item.total)} + {formatCurrencyBRL(item.implUnitPrice)} + {formatCurrencyBRL(item.implTotal)} + + ))} + + + + + + + + + {quote.proposed_monthly_total != null ? ( + + ) : null} + + + + + {quote.notes ? ( + + Notas + {quote.notes} + + ) : null} + + + {quote.approval_status === "pending" ? ( + + ) : quote.approval_status !== "none" && quote.approval_notes ? ( + + {quote.approval_notes} + + ) : null} + + {quote.deal_status === "orcamento" ? ( + + ) : quote.client_registration_id ? ( + + + + ) : null} + + + + ); +} + +function Row({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ); +} + +function SummaryRow({ label, value, highlight }: { label: string; value: string; highlight?: boolean }) { + return ( + + {label} + + {value} + + + ); +} + +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(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 ( + + {error ? ( + + {error} + + ) : null} + + Esta oferta tem desconto e/ou fidelidade reduzida e precisa de aprovação antes de seguir. + + setNotes(e.target.value)} + className="mb-3" + /> + + decide("approved")} loading={submitting === "approved"}> + Aprovar + + decide("rejected")} loading={submitting === "rejected"}> + Rejeitar + + + + ); +} + +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(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 ( + + {error ? ( + + {error} + + ) : null} + + setClientRegistrationId(e.target.value)} + hint="Cole o UUID de um cadastro de cliente já existente" + className="min-w-72 flex-1" + required + /> + + Fechar negócio + + + + ); +} diff --git a/apps/core-web/src/routes/quotes/QuoteFormPage.tsx b/apps/core-web/src/routes/quotes/QuoteFormPage.tsx new file mode 100644 index 0000000..1ac1f6e --- /dev/null +++ b/apps/core-web/src/routes/quotes/QuoteFormPage.tsx @@ -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(null); + const [products, setProducts] = useState(null); + const [loadError, setLoadError] = useState(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("12"); + const [fidelityPeriod, setFidelityPeriod] = useState(""); + const [items, setItems] = useState([{ ...EMPTY_ROW }]); + const [implGeral, setImplGeral] = useState(""); + const [proposedMonthlyTotal, setProposedMonthlyTotal] = useState(""); + const [implPaymentCondition, setImplPaymentCondition] = useState(""); + const [notes, setNotes] = useState(""); + + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [createdQuote, setCreatedQuote] = useState(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) { + 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 ( + + + + Oferta criada com sucesso. Confira os totais calculados pelo servidor abaixo. + + + + + + + + {createdQuote.approval_status === "pending" ? ( + + Esta oferta possui desconto e/ou fidelidade reduzida — aguarda aprovação. + + ) : null} + + + Ver oferta completa + + navigate("/quotes")}> + Voltar para a lista + + + + + ); + } + + return ( + + + + + {loadError ? ( + + {loadError} + + ) : null} + {error ? ( + + {error} + + ) : null} + + + {!resellers || !products ? ( + Carregando revendas e produtos… + ) : ( + + + Dados do cliente + + ({ value: r.id, label: r.name }))} + placeholder="Selecione a revenda" + value={resellerId} + onChange={(e) => setResellerId(e.target.value)} + required + /> + setClientName(e.target.value)} required /> + setClientCompany(e.target.value)} /> + setClientEmail(e.target.value)} /> + setClientPhone(e.target.value)} required /> + + + + + Contrato + + ({ + value: String(period), + label: period === 0 ? "Sem fidelidade (0 meses)" : `${period} meses`, + }))} + value={contractPeriod} + onChange={(e) => setContractPeriod(e.target.value)} + required + /> + setFidelityPeriod(e.target.value)} + hint="Só preencha se for menor que o período do contrato — entra em aprovação" + /> + + + + + + Itens + + + Adicionar item + + + + + + + Produto + Quantidade + + + + + {items.map((row, index) => ( + + + updateItem(index, { productId: e.target.value })} + aria-label="Produto" + required + /> + + + updateItem(index, { quantity: e.target.value })} + aria-label="Quantidade" + required + /> + + + {items.length > 1 ? ( + removeItem(index)} + className="rounded-md border border-border-color px-2 py-1 text-xs font-semibold text-danger hover:bg-light" + > + Remover + + ) : null} + + + ))} + + + + + + + Implantação e valores + + setImplGeral(e.target.value)} + hint="Custo de implantação além dos produtos" + /> + setProposedMonthlyTotal(e.target.value)} + hint="Preencha só se for oferecer desconto — a oferta precisará de aprovação" + /> + + ({ + value: condition, + label: condition, + }))} + placeholder="Selecione a condição" + value={implPaymentCondition} + onChange={(e) => setImplPaymentCondition(e.target.value as ImplPaymentCondition)} + /> + + Faixas sugeridas para {formatCurrencyBRL(estimatedImplTotal)} de implantação estimada:{" "} + {availablePaymentConditions.map((c) => ( + + {c} + + ))} + {" "}— o servidor valida a faixa real ao salvar. + + + + + + + Notas + setNotes(e.target.value)} + /> + + + + + Criar oferta + + navigate("/quotes")}> + Cancelar + + + + )} + + + ); +} + +function SummaryField({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ); +} diff --git a/apps/core-web/src/routes/quotes/QuotesListPage.tsx b/apps/core-web/src/routes/quotes/QuotesListPage.tsx new file mode 100644 index 0000000..b1bc219 --- /dev/null +++ b/apps/core-web/src/routes/quotes/QuotesListPage.tsx @@ -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 = { + none: "Sem aprovação", + pending: "Pendente", + approved: "Aprovada", + rejected: "Rejeitada", +}; + +const APPROVAL_VARIANT: Record = { + none: "neutral", + pending: "warning", + approved: "success", + rejected: "danger", +}; + +const DEAL_LABEL: Record = { + orcamento: "Orçamento", + fechado: "Fechado", + perdido: "Perdido", +}; + +const DEAL_VARIANT: Record = { + orcamento: "info", + fechado: "success", + perdido: "danger", +}; + +export function QuotesListPage() { + const { accessToken } = useAuth(); + const [quotes, setQuotes] = useState(null); + const [error, setError] = useState(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 ( + + + Nova oferta + + } + /> + + {error ? ( + + {error} + + ) : null} + + + {quotes === null && !error ? ( + Carregando ofertas… + ) : quotes && quotes.length === 0 ? ( + Nenhuma oferta cadastrada ainda. + ) : quotes ? ( + + + + + Oferta + Cliente + Mensalidade + Aprovação + Negócio + + + + {quotes.map((quote) => ( + + + + {quote.quote_number} + + + + {quote.client_name} + {quote.client_company ? {quote.client_company} : null} + + + {formatCurrencyBRL(quote.proposed_monthly_total ?? quote.monthly_total)} + + + + {APPROVAL_LABEL[quote.approval_status]} + + + + {DEAL_LABEL[quote.deal_status]} + + + ))} + + + + ) : null} + + + ); +} diff --git a/apps/core-web/src/routes/resellers/ResellerFormPage.tsx b/apps/core-web/src/routes/resellers/ResellerFormPage.tsx new file mode 100644 index 0000000..be7c921 --- /dev/null +++ b/apps/core-web/src/routes/resellers/ResellerFormPage.tsx @@ -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(EMPTY_FORM); + const [loading, setLoading] = useState(isEditing); + const [error, setError] = useState(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(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 ( + + + ← Voltar para Revendas + + } + actions={ + <> + navigate("/resellers")} type="button"> + Cancelar + + + Salvar + + > + } + /> + + {error ? ( + + {error} + + ) : null} + + {loading ? ( + + Carregando… + + ) : ( + + + + + updateField("name", e.target.value)} required /> + + updateField("document", e.target.value)} /> + updateField("contactEmail", e.target.value)} + /> + + Status + + updateField("active", e.target.checked)} + /> + Revenda ativa + + + + + + )} + + ); +} diff --git a/apps/core-web/src/routes/resellers/ResellersListPage.tsx b/apps/core-web/src/routes/resellers/ResellersListPage.tsx new file mode 100644 index 0000000..ef60cf8 --- /dev/null +++ b/apps/core-web/src/routes/resellers/ResellersListPage.tsx @@ -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(null); + const [error, setError] = useState(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> = [ + { key: "name", header: "Nome", render: (reseller) => {reseller.name} }, + { 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) => {reseller.active ? "Ativo" : "Inativo"}, + }, + { + key: "actions", + header: "", + className: "text-right", + render: (reseller) => ( + navigate(`/resellers/${reseller.id}/edit`)}> + Editar + + ), + }, + ]; + + return ( + + navigate("/resellers/new")}>Nova revenda} + /> + + {error ? ( + + {error} + + ) : null} + + + {resellers === null && !error ? ( + Carregando… + ) : ( + reseller.id} /> + )} + + + ); +} diff --git a/docs/progress.md b/docs/progress.md index 128f567..c8d34ad 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -294,3 +294,30 @@ Verificado: `pnpm turbo run typecheck build` (28/28), `pnpm --filter @eden/api t ### Pendências Toda a Assinatura Eletrônica (eden.md §1.4-1.9/§5/§6/§7 — envelopes, signatários, OTP, hash-chain, verificação pública), geração real de PDF (Playwright/Chromium) + sanitização de HTML, editor visual Tiptap real (`renderToHtml`/`resolveDocument` com condicionais e repetição de tabela), seed dos 7 templates reais de produção da Handix, regra de idempotência completa com `force`/`extra_fields`. + +--- + +## Fase 2/8 — Telas de negócio reais no EDEN Core (Empresas, Produtos, Revendas, CRM, Ofertas) + +Data: 2026-09-03. Usuário pediu explicitamente para priorizar a parte web ("ja faca a parte web para eu ja ver como esta ficando"). 2 subagentes `eden-frontend` em paralelo, mesma sessão, construindo sobre o shell autenticado + Design System já prontos. + +**Correção de instrução no meio da leva**: o usuário interrompeu para dizer "não esqueça de seguir o template" / "melhor usar ele" — as telas deviam seguir de perto o LAYOUT REAL do tema DreamsERP comprado, não só os tokens visuais abstratos já extraídos. Extraí do `tema_do_Eden.zip` as páginas React reais mais relevantes (`sales/salesQuotes*.tsx`, `crm/leads.tsx`/`deals*.tsx`, `inventory/products*.tsx`, `purchase/vendors.tsx`, `system/userManagement.tsx`, `ui-interface/{tables,forms}`) para dentro do worktree de cada agente (`.tmp/theme-reference/`, nunca versionado) e mandei mensagem de correção para os dois agentes já em execução — via `SendMessage`, não relançando a tarefa (no meio do processo cometi o erro de usar `Agent` em vez de `SendMessage` para a primeira correção, criando um agente duplicado; percebido e corrigido na hora com `TaskStop` antes de qualquer trabalho real ser feito por ele). Ambos incorporaram a referência do tema (estrutura de header/breadcrumb/tabela/formulário em cards) reescrita com `@eden/ui` + Tailwind puro — sem copiar JSX nem trazer dependências do tema (antd/Preline) para o projeto. + +### Empresas / Produtos / Revendas (`apps/core-web/src/routes/{companies,products,resellers}/`) + +CRUD completo (lista + criar/editar) para os 3 módulos, layout modelado em `purchase/vendors.tsx` (empresas/revendas) e `inventory/products.tsx` (produtos, com máscara de moeda para as 5 faixas de preço + implantação). Novo componente `Table` em `packages/ui` (tabela genérica reutilizável, sem dependência de terceiro). + +### CRM e Ofertas/Quotes (`apps/core-web/src/routes/{crm,quotes}/`) + +Leads (lista, criar/editar, converter em oportunidade) e Oportunidades (lista, criar/editar) modelados em `crm/leads.tsx`/`dealsDetails.tsx`. Ofertas — o módulo mais rico: `QuotesListPage`, `QuoteFormPage` (revenda + cliente + período de contrato + fidelidade reduzida opcional + lista dinâmica de itens produto/quantidade + desconto opcional + condição de pagamento, mostra os totais calculados pelo servidor antes de ir para o detalhe) e `QuoteDetailPage` (resumo financeiro completo, aprovar/rejeitar quando pendente, fechar negócio quando ainda é orçamento) — modelados em `sales/salesQuotes.tsx`/`addSalesQuotes.tsx`/`salesQuotesDetails.tsx`. Novo componente `Select` em `packages/ui`. + +**Achado real do próprio agente, corrigindo meu brief**: eu tinha dito no brief que as respostas de Leads/Opportunities eram snake_case (copiando o padrão do resto da API) — o agente verificou contra o código-fonte real (`packages/database/src/repositories/leads.ts`) e descobriu que esse módulo específico mapeia para **camelCase** na resposta (`mapLead`), diferente de Quotes/Companies/Products/Resellers (snake_case direto da linha do banco). Confirmado ao vivo via curl antes de integrar. + +### Integração manual (os 2 agentes trabalharam em paralelo no mesmo `core-web`) + +Um dos agentes não conseguiu `git merge` dentro do próprio sandbox e reconstruiu manualmente (leitura+escrita) o estado já existente do shell autenticado — comparei arquivo por arquivo contra `main` (`diff -rq`) para confirmar que a restauração foi fiel (nenhum componente restaurado divergiu) antes de aceitar qualquer coisa como "nova". `App.tsx`, `AuthenticatedLayout.tsx` (nav unificada, lógica de "ativo" por prefixo de path com borda de `/` para nunca casar `/companies` com `/companies-x`) e `packages/ui/src/index.ts` foram reconciliados manualmente juntando as rotas/exports dos dois agentes; `lib/format.ts` teve as duas versões mescladas (`formatDocument` de um lado, `formatDateBR` do outro). + +Verificado: `pnpm turbo run typecheck build` (28/28). Containers `eden-api`/`eden-core` reconstruídos (o build em background travou duas vezes por algum motivo de recurso do ambiente — refeito em primeiro plano, funcionou); bundle de produção confirmado contendo todas as novas telas; login simulando requisição real de browser (`Origin: http://localhost:3001`) funcionando; listagens de Empresas/Produtos/Revendas retornando dados reais; criação de lead confirmando o formato camelCase ao vivo. + +### Pendências +Client Registration (Cadastro de Cliente) ainda sem tela — `QuoteDetailPage` hoje exige colar manualmente um UUID para fechar negócio. Nenhuma tela de Contracts/Inventory/Support/HR-Timeclock/Fiscal/Billing/Documents ainda. `reseller-web`/`subscriber-web` continuam só com o shell de login (sem telas de negócio). diff --git a/packages/ui/src/components/Card.tsx b/packages/ui/src/components/Card.tsx index 18d3025..0321931 100644 --- a/packages/ui/src/components/Card.tsx +++ b/packages/ui/src/components/Card.tsx @@ -4,10 +4,12 @@ import { cn } from "../utils/cn.js"; export interface CardProps extends Omit, "title"> { title?: ReactNode; actions?: ReactNode; + /** Overrides the default `p-5` on the content wrapper — e.g. `"p-0"` for a flush data table (theme source: sales/salesQuotes.tsx uses `p-4 pb-0` around its table). */ + bodyClassName?: string; } /** Theme source (auth-pages/login.tsx): `bg-white border border-border-color rounded-md shadow-sm`. */ -export function Card({ title, actions, className, children, ...rest }: CardProps) { +export function Card({ title, actions, className, bodyClassName, children, ...rest }: CardProps) { return ( {title || actions ? ( @@ -16,7 +18,7 @@ export function Card({ title, actions, className, children, ...rest }: CardProps {actions ? {actions} : null} ) : null} - {children} + {children} ); } diff --git a/packages/ui/src/components/Select.tsx b/packages/ui/src/components/Select.tsx new file mode 100644 index 0000000..b97a50b --- /dev/null +++ b/packages/ui/src/components/Select.tsx @@ -0,0 +1,93 @@ +import { forwardRef, useId } from "react"; +import type { SelectHTMLAttributes } from "react"; +import { cn } from "../utils/cn.js"; + +export interface SelectOption { + value: string; + label: string; +} + +export interface SelectProps extends Omit, "children"> { + label?: string; + /** Validation/server error message — shown below the field, field border turns danger. */ + error?: string; + /** Helper text shown below the field when there's no error. */ + hint?: string; + options: SelectOption[]; + /** Rendered as a disabled, selected-by-default first option (e.g. "Selecione..."). */ + placeholder?: string; +} + +/** + * Native `` styled to match `Input` (packages/ui/src/components/Input.tsx) + * — same border/padding/focus-ring language as the rest of the Design + * System. Derived from the DreamsERP theme's form field sizing + * (`.tmp/theme-reference/sales/addSalesQuotes.tsx` etc. use a + * `react-select`-based CommonSelect for the same slots, out of our stack — + * this is a plain, dependency-free equivalent with the same visual sizing). + */ +export const Select = forwardRef(function Select( + { label, error, hint, id, className, options, placeholder, ...rest }, + ref, +) { + const generatedId = useId(); + const selectId = id ?? generatedId; + + return ( + + {label ? ( + + {label} + + ) : null} + + + {placeholder ? ( + + {placeholder} + + ) : null} + {options.map((option) => ( + + {option.label} + + ))} + + + + + + {error ? ( + + {error} + + ) : hint ? ( + + {hint} + + ) : null} + + ); +}); diff --git a/packages/ui/src/components/Table.tsx b/packages/ui/src/components/Table.tsx new file mode 100644 index 0000000..94c0bd4 --- /dev/null +++ b/packages/ui/src/components/Table.tsx @@ -0,0 +1,64 @@ +import type { ReactNode } from "react"; +import { cn } from "../utils/cn.js"; + +export interface TableColumn { + key: string; + header: ReactNode; + render: (row: T) => ReactNode; + /** Applied to both the header and body cells — e.g. `"text-right"` for numeric columns. */ + className?: string; +} + +export interface TableProps { + columns: Array>; + data: T[]; + rowKey: (row: T) => string; + /** Shown instead of the table body when `data` is empty (and not loading). */ + emptyMessage?: ReactNode; +} + +/** + * Plain data table styled to match the DreamsERP theme's list screens + * (react/src/pages/inventory/products.tsx et al.) without pulling in the + * theme's antd/react-window dependencies — just a semantic ``. + */ +export function Table({ columns, data, rowKey, emptyMessage = "Nenhum registro encontrado." }: TableProps) { + return ( + + + + + {columns.map((column) => ( + + {column.header} + + ))} + + + + {data.length === 0 ? ( + + + {emptyMessage} + + + ) : ( + data.map((row) => ( + + {columns.map((column) => ( + + {column.render(row)} + + ))} + + )) + )} + + + + ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index d0966ae..3190229 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -13,6 +13,12 @@ export type { ButtonProps, ButtonVariant, ButtonSize } from "./components/Button export { Input } from "./components/Input.js"; export type { InputProps } from "./components/Input.js"; +export { Select } from "./components/Select.js"; +export type { SelectProps, SelectOption } from "./components/Select.js"; + +export { Table } from "./components/Table.js"; +export type { TableProps, TableColumn } from "./components/Table.js"; + export { Card } from "./components/Card.js"; export type { CardProps } from "./components/Card.js";
(`/quotes/${id}`, { method: "GET" }, { accessToken }); +} + +export function createQuote(input: CreateQuoteInput, accessToken: string): Promise { + return apiRequest("/quotes", { method: "POST", body: JSON.stringify(input) }, { accessToken }); +} + +export function decideQuoteApproval( + id: string, + input: DecideApprovalInput, + accessToken: string, +): Promise { + return apiRequest( + `/quotes/${id}/approval`, + { method: "PATCH", body: JSON.stringify(input) }, + { accessToken }, + ); +} + +export function closeQuoteDeal(id: string, input: CloseDealInput, accessToken: string): Promise { + return apiRequest(`/quotes/${id}/close`, { method: "POST", body: JSON.stringify(input) }, { accessToken }); +} diff --git a/apps/core-web/src/lib/api/resellers.ts b/apps/core-web/src/lib/api/resellers.ts new file mode 100644 index 0000000..e2aa409 --- /dev/null +++ b/apps/core-web/src/lib/api/resellers.ts @@ -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; + +export function listResellers(accessToken: string): Promise { + return apiRequest("/resellers", { method: "GET" }, { accessToken }); +} + +export function getReseller(id: string, accessToken: string): Promise { + return apiRequest(`/resellers/${id}`, { method: "GET" }, { accessToken }); +} + +export function createReseller(input: CreateResellerInput, accessToken: string): Promise { + return apiRequest("/resellers", { method: "POST", body: JSON.stringify(input) }, { accessToken }); +} + +export function updateReseller( + id: string, + input: UpdateResellerInput, + accessToken: string, +): Promise { + return apiRequest( + `/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 }, + ); +} diff --git a/apps/core-web/src/lib/format.ts b/apps/core-web/src/lib/format.ts new file mode 100644 index 0000000..c30f27b --- /dev/null +++ b/apps/core-web/src/lib/format.ts @@ -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; +} diff --git a/apps/core-web/src/routes/AuthenticatedLayout.tsx b/apps/core-web/src/routes/AuthenticatedLayout.tsx index c2d385b..3efa306 100644 --- a/apps/core-web/src/routes/AuthenticatedLayout.tsx +++ b/apps/core-web/src/routes/AuthenticatedLayout.tsx @@ -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() { (null); + const [error, setError] = useState(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> = [ + { + key: "name", + header: "Nome", + render: (company) => ( + + {company.company_name} + {company.trade_name ? {company.trade_name} : null} + + ), + }, + { 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) => ( + {company.is_active ? "Ativo" : "Inativo"} + ), + }, + { + key: "actions", + header: "", + className: "text-right", + render: (company) => ( + navigate(`/companies/${company.id}/edit`)}> + Editar + + ), + }, + ]; + + return ( + + navigate("/companies/new")}>Nova empresa} + /> + + {error ? ( + + {error} + + ) : null} + + + {companies === null && !error ? ( + Carregando… + ) : ( + company.id} /> + )} + + + ); +} diff --git a/apps/core-web/src/routes/companies/CompanyFormPage.tsx b/apps/core-web/src/routes/companies/CompanyFormPage.tsx new file mode 100644 index 0000000..c69ae6d --- /dev/null +++ b/apps/core-web/src/routes/companies/CompanyFormPage.tsx @@ -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 { + 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(EMPTY_FORM); + const [loading, setLoading] = useState(isEditing); + const [error, setError] = useState(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(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 ( + + + ← Voltar para Empresas + + } + actions={ + <> + navigate("/companies")} type="button"> + Cancelar + + + Salvar + + > + } + /> + + {error ? ( + + {error} + + ) : null} + + {loading ? ( + + Carregando… + + ) : ( + + + + + updateField("companyName", e.target.value)} + required + /> + + updateField("tradeName", e.target.value)} + /> + updateField("cnpj", e.target.value)} /> + updateField("stateRegistration", e.target.value)} + /> + updateField("municipalRegistration", e.target.value)} + /> + {isEditing ? ( + + Status + + updateField("isActive", e.target.checked)} + /> + Empresa ativa + + + ) : null} + + + + + + updateField("addressZip", e.target.value)} /> + + updateField("addressStreet", e.target.value)} + /> + + updateField("addressNumber", e.target.value)} + /> + updateField("addressComplement", e.target.value)} + /> + updateField("addressNeighborhood", e.target.value)} + /> + updateField("addressCity", e.target.value)} + /> + updateField("addressState", e.target.value.toUpperCase())} + /> + updateField("addressCountry", e.target.value)} + placeholder="Brasil" + /> + + + + + + updateField("phone", e.target.value)} /> + updateField("email", e.target.value)} + /> + updateField("website", e.target.value)} /> + + + + + + updateField("legalRepName", e.target.value)} + /> + updateField("legalRepCpf", e.target.value)} + /> + updateField("legalRepRole", e.target.value)} + /> + updateField("legalRepEmail", e.target.value)} + /> + + + + )} + + ); +} diff --git a/apps/core-web/src/routes/crm/ConvertLeadPage.tsx b/apps/core-web/src/routes/crm/ConvertLeadPage.tsx new file mode 100644 index 0000000..d914c51 --- /dev/null +++ b/apps/core-web/src/routes/crm/ConvertLeadPage.tsx @@ -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 = { + 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(null); + const [title, setTitle] = useState(""); + const [stage, setStage] = useState("prospecting"); + const [probability, setProbability] = useState(""); + const [notes, setNotes] = useState(""); + + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(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 ( + + + + + {error ? ( + + {error} + + ) : null} + + + {loading ? ( + Carregando lead… + ) : ( + + setTitle(e.target.value)} + required + autoFocus + /> + setStage(e.target.value as OpportunityStage)} + /> + setProbability(e.target.value)} + /> + + Notas + setNotes(e.target.value)} + /> + + + + Converter + + navigate("/leads")}> + Cancelar + + + + )} + + + ); +} diff --git a/apps/core-web/src/routes/crm/CrmSubNav.tsx b/apps/core-web/src/routes/crm/CrmSubNav.tsx new file mode 100644 index 0000000..03d5697 --- /dev/null +++ b/apps/core-web/src/routes/crm/CrmSubNav.tsx @@ -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 ( + + + Leads + + + Oportunidades + + + ); +} diff --git a/apps/core-web/src/routes/crm/LeadFormPage.tsx b/apps/core-web/src/routes/crm/LeadFormPage.tsx new file mode 100644 index 0000000..0d41695 --- /dev/null +++ b/apps/core-web/src/routes/crm/LeadFormPage.tsx @@ -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 = { + 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("new"); + const [notes, setNotes] = useState(""); + + const [loading, setLoading] = useState(isEdit); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(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 ( + + + + + {error ? ( + + {error} + + ) : null} + + + {loading ? ( + Carregando lead… + ) : ( + + setContactName(e.target.value)} + required + autoFocus + /> + setCompanyName(e.target.value)} /> + setContactEmail(e.target.value)} + /> + setContactPhone(e.target.value)} /> + setSource(e.target.value)} hint="Ex.: Site, indicação, campanha" /> + setStatus(e.target.value as LeadStatus)} + /> + + Notas + setNotes(e.target.value)} + /> + + + + {isEdit ? "Salvar alterações" : "Criar lead"} + + navigate("/leads")}> + Cancelar + + + + )} + + + ); +} diff --git a/apps/core-web/src/routes/crm/LeadsListPage.tsx b/apps/core-web/src/routes/crm/LeadsListPage.tsx new file mode 100644 index 0000000..e8ca3ab --- /dev/null +++ b/apps/core-web/src/routes/crm/LeadsListPage.tsx @@ -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 = { + new: "Novo", + contacted: "Contatado", + qualified: "Qualificado", + converted: "Convertido", + discarded: "Descartado", +}; + +const STATUS_VARIANT: Record = { + new: "info", + contacted: "warning", + qualified: "primary", + converted: "success", + discarded: "danger", +}; + +export function LeadsListPage() { + const { accessToken } = useAuth(); + const [leads, setLeads] = useState(null); + const [error, setError] = useState(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 ( + + + + Novo lead + + } + /> + + {error ? ( + + {error} + + ) : null} + + + {leads === null && !error ? ( + Carregando leads… + ) : leads && leads.length === 0 ? ( + Nenhum lead cadastrado ainda. + ) : leads ? ( + + + + + Contato + Empresa + Status + Ações + + + + {leads.map((lead) => { + const canConvert = lead.status !== "converted" && lead.status !== "discarded"; + return ( + + + + {lead.contactName} + + {lead.contactEmail ? {lead.contactEmail} : null} + + {lead.companyName ?? "—"} + + + {STATUS_LABEL[lead.status] ?? lead.status} + + + + + + Editar + + {canConvert ? ( + + Converter em oportunidade + + ) : null} + + + + ); + })} + + + + ) : null} + + + ); +} diff --git a/apps/core-web/src/routes/crm/OpportunitiesListPage.tsx b/apps/core-web/src/routes/crm/OpportunitiesListPage.tsx new file mode 100644 index 0000000..7e83021 --- /dev/null +++ b/apps/core-web/src/routes/crm/OpportunitiesListPage.tsx @@ -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 = { + prospecting: "Prospecção", + qualification: "Qualificação", + proposal: "Proposta", + negotiation: "Negociação", + won: "Ganha", + lost: "Perdida", +}; + +const STAGE_VARIANT: Record = { + prospecting: "info", + qualification: "primary", + proposal: "warning", + negotiation: "secondary", + won: "success", + lost: "danger", +}; + +export function OpportunitiesListPage() { + const { accessToken } = useAuth(); + const [opportunities, setOpportunities] = useState(null); + const [error, setError] = useState(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 ( + + + + Nova oportunidade + + } + /> + + {error ? ( + + {error} + + ) : null} + + + {opportunities === null && !error ? ( + Carregando oportunidades… + ) : opportunities && opportunities.length === 0 ? ( + Nenhuma oportunidade cadastrada ainda. + ) : opportunities ? ( + + + + + Título + Estágio + Probabilidade + Dono + Ações + + + + {opportunities.map((opportunity) => ( + + + + {opportunity.title} + + + + + {STAGE_LABEL[opportunity.stage] ?? opportunity.stage} + + + + {opportunity.probability != null ? `${opportunity.probability}%` : "—"} + + {opportunity.ownerId ?? "—"} + + + Editar + + + + ))} + + + + ) : null} + + + ); +} diff --git a/apps/core-web/src/routes/crm/OpportunityFormPage.tsx b/apps/core-web/src/routes/crm/OpportunityFormPage.tsx new file mode 100644 index 0000000..8fe8042 --- /dev/null +++ b/apps/core-web/src/routes/crm/OpportunityFormPage.tsx @@ -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 = { + 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("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(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 ( + + + + + {error ? ( + + {error} + + ) : null} + + + {loading ? ( + Carregando oportunidade… + ) : ( + + setTitle(e.target.value)} + required + autoFocus + /> + setStage(e.target.value as OpportunityStage)} + /> + setProbability(e.target.value)} + /> + {stage === "lost" ? ( + setLossReason(e.target.value)} + /> + ) : null} + + Notas + setNotes(e.target.value)} + /> + + + + {isEdit ? "Salvar alterações" : "Criar oportunidade"} + + navigate("/opportunities")}> + Cancelar + + + + )} + + + ); +} diff --git a/apps/core-web/src/routes/products/ProductFormPage.tsx b/apps/core-web/src/routes/products/ProductFormPage.tsx new file mode 100644 index 0000000..8c8d9b9 --- /dev/null +++ b/apps/core-web/src/routes/products/ProductFormPage.tsx @@ -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 ( + 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(EMPTY_FORM); + const [loading, setLoading] = useState(isEditing); + const [error, setError] = useState(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(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 ( + + + ← Voltar para Produtos + + } + actions={ + <> + navigate("/products")} type="button"> + Cancelar + + + Salvar + + > + } + /> + + {error ? ( + + {error} + + ) : null} + + {loading ? ( + + Carregando… + + ) : ( + + + + + updateField("name", e.target.value)} required /> + + updateField("productType", e.target.value)} + required + /> + updateField("unit", e.target.value)} /> + updateField("productGroup", e.target.value)} + /> + updateField("productSubgroup", e.target.value)} + /> + updateField("brand", e.target.value)} /> + + + Descrição + + updateField("description", e.target.value)} + /> + + + + + + + updateField("price0Cents", v)} /> + updateField("price12Cents", v)} /> + updateField("price24Cents", v)} /> + updateField("price36Cents", v)} /> + updateField("price48Cents", v)} /> + updateField("implUnitPriceCents", v)} + /> + + + + + + {( + [ + ["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]) => ( + + updateField(field, e.target.checked)} + /> + {label} + + ))} + + + + )} + + ); +} diff --git a/apps/core-web/src/routes/products/ProductsListPage.tsx b/apps/core-web/src/routes/products/ProductsListPage.tsx new file mode 100644 index 0000000..14deccb --- /dev/null +++ b/apps/core-web/src/routes/products/ProductsListPage.tsx @@ -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(null); + const [error, setError] = useState(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> = [ + { key: "code", header: "Código", render: (product) => `#${product.product_code}` }, + { key: "name", header: "Nome", render: (product) => {product.name} }, + { key: "type", header: "Tipo", render: (product) => product.product_type }, + ...PRICE_COLUMNS.map( + (column): TableColumn => ({ + 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 ? ( + Descontinuado + ) : ( + {product.active ? "Ativo" : "Inativo"} + ), + }, + { + key: "actions", + header: "", + className: "text-right", + render: (product) => ( + navigate(`/products/${product.id}/edit`)}> + Editar + + ), + }, + ]; + + return ( + + navigate("/products/new")}>Novo produto} + /> + + {error ? ( + + {error} + + ) : null} + + + {products === null && !error ? ( + Carregando… + ) : ( + product.id} /> + )} + + + ); +} diff --git a/apps/core-web/src/routes/quotes/QuoteDetailPage.tsx b/apps/core-web/src/routes/quotes/QuoteDetailPage.tsx new file mode 100644 index 0000000..bec95a5 --- /dev/null +++ b/apps/core-web/src/routes/quotes/QuoteDetailPage.tsx @@ -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 = { + none: "Sem aprovação", + pending: "Pendente", + approved: "Aprovada", + rejected: "Rejeitada", +}; + +const APPROVAL_VARIANT: Record = { + none: "neutral", + pending: "warning", + approved: "success", + rejected: "danger", +}; + +const DEAL_LABEL: Record = { + orcamento: "Orçamento", + fechado: "Fechado", + perdido: "Perdido", +}; + +const DEAL_VARIANT: Record = { + orcamento: "info", + fechado: "success", + perdido: "danger", +}; + +export function QuoteDetailPage() { + const { id } = useParams<{ id: string }>(); + const { accessToken } = useAuth(); + + const [quote, setQuote] = useState(null); + const [reseller, setReseller] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( + + + Carregando oferta… + + ); + } + + if (error || !quote) { + return ( + + + {error ?? "Oferta não encontrada."} + + ); + } + + return ( + + + + {APPROVAL_LABEL[quote.approval_status]} + {DEAL_LABEL[quote.deal_status]} + + } + /> + + + + + + + + + + + + + + + + + + + + + + + + + Produto + Qtd. + Preço unit. + Total + Impl. unit. + Impl. total + + + + {quote.items.map((item, index) => ( + + {item.productName} + {item.quantity} + {formatCurrencyBRL(item.unitPrice)} + {formatCurrencyBRL(item.total)} + {formatCurrencyBRL(item.implUnitPrice)} + {formatCurrencyBRL(item.implTotal)} + + ))} + + + + + + + + + {quote.proposed_monthly_total != null ? ( + + ) : null} + + + + + {quote.notes ? ( + + Notas + {quote.notes} + + ) : null} + + + {quote.approval_status === "pending" ? ( + + ) : quote.approval_status !== "none" && quote.approval_notes ? ( + + {quote.approval_notes} + + ) : null} + + {quote.deal_status === "orcamento" ? ( + + ) : quote.client_registration_id ? ( + + + + ) : null} + + + + ); +} + +function Row({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ); +} + +function SummaryRow({ label, value, highlight }: { label: string; value: string; highlight?: boolean }) { + return ( + + {label} + + {value} + + + ); +} + +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(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 ( + + {error ? ( + + {error} + + ) : null} + + Esta oferta tem desconto e/ou fidelidade reduzida e precisa de aprovação antes de seguir. + + setNotes(e.target.value)} + className="mb-3" + /> + + decide("approved")} loading={submitting === "approved"}> + Aprovar + + decide("rejected")} loading={submitting === "rejected"}> + Rejeitar + + + + ); +} + +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(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 ( + + {error ? ( + + {error} + + ) : null} + + setClientRegistrationId(e.target.value)} + hint="Cole o UUID de um cadastro de cliente já existente" + className="min-w-72 flex-1" + required + /> + + Fechar negócio + + + + ); +} diff --git a/apps/core-web/src/routes/quotes/QuoteFormPage.tsx b/apps/core-web/src/routes/quotes/QuoteFormPage.tsx new file mode 100644 index 0000000..1ac1f6e --- /dev/null +++ b/apps/core-web/src/routes/quotes/QuoteFormPage.tsx @@ -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(null); + const [products, setProducts] = useState(null); + const [loadError, setLoadError] = useState(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("12"); + const [fidelityPeriod, setFidelityPeriod] = useState(""); + const [items, setItems] = useState([{ ...EMPTY_ROW }]); + const [implGeral, setImplGeral] = useState(""); + const [proposedMonthlyTotal, setProposedMonthlyTotal] = useState(""); + const [implPaymentCondition, setImplPaymentCondition] = useState(""); + const [notes, setNotes] = useState(""); + + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [createdQuote, setCreatedQuote] = useState(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) { + 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 ( + + + + Oferta criada com sucesso. Confira os totais calculados pelo servidor abaixo. + + + + + + + + {createdQuote.approval_status === "pending" ? ( + + Esta oferta possui desconto e/ou fidelidade reduzida — aguarda aprovação. + + ) : null} + + + Ver oferta completa + + navigate("/quotes")}> + Voltar para a lista + + + + + ); + } + + return ( + + + + + {loadError ? ( + + {loadError} + + ) : null} + {error ? ( + + {error} + + ) : null} + + + {!resellers || !products ? ( + Carregando revendas e produtos… + ) : ( + + + Dados do cliente + + ({ value: r.id, label: r.name }))} + placeholder="Selecione a revenda" + value={resellerId} + onChange={(e) => setResellerId(e.target.value)} + required + /> + setClientName(e.target.value)} required /> + setClientCompany(e.target.value)} /> + setClientEmail(e.target.value)} /> + setClientPhone(e.target.value)} required /> + + + + + Contrato + + ({ + value: String(period), + label: period === 0 ? "Sem fidelidade (0 meses)" : `${period} meses`, + }))} + value={contractPeriod} + onChange={(e) => setContractPeriod(e.target.value)} + required + /> + setFidelityPeriod(e.target.value)} + hint="Só preencha se for menor que o período do contrato — entra em aprovação" + /> + + + + + + Itens + + + Adicionar item + + + + + + + Produto + Quantidade + + + + + {items.map((row, index) => ( + + + updateItem(index, { productId: e.target.value })} + aria-label="Produto" + required + /> + + + updateItem(index, { quantity: e.target.value })} + aria-label="Quantidade" + required + /> + + + {items.length > 1 ? ( + removeItem(index)} + className="rounded-md border border-border-color px-2 py-1 text-xs font-semibold text-danger hover:bg-light" + > + Remover + + ) : null} + + + ))} + + + + + + + Implantação e valores + + setImplGeral(e.target.value)} + hint="Custo de implantação além dos produtos" + /> + setProposedMonthlyTotal(e.target.value)} + hint="Preencha só se for oferecer desconto — a oferta precisará de aprovação" + /> + + ({ + value: condition, + label: condition, + }))} + placeholder="Selecione a condição" + value={implPaymentCondition} + onChange={(e) => setImplPaymentCondition(e.target.value as ImplPaymentCondition)} + /> + + Faixas sugeridas para {formatCurrencyBRL(estimatedImplTotal)} de implantação estimada:{" "} + {availablePaymentConditions.map((c) => ( + + {c} + + ))} + {" "}— o servidor valida a faixa real ao salvar. + + + + + + + Notas + setNotes(e.target.value)} + /> + + + + + Criar oferta + + navigate("/quotes")}> + Cancelar + + + + )} + + + ); +} + +function SummaryField({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ); +} diff --git a/apps/core-web/src/routes/quotes/QuotesListPage.tsx b/apps/core-web/src/routes/quotes/QuotesListPage.tsx new file mode 100644 index 0000000..b1bc219 --- /dev/null +++ b/apps/core-web/src/routes/quotes/QuotesListPage.tsx @@ -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 = { + none: "Sem aprovação", + pending: "Pendente", + approved: "Aprovada", + rejected: "Rejeitada", +}; + +const APPROVAL_VARIANT: Record = { + none: "neutral", + pending: "warning", + approved: "success", + rejected: "danger", +}; + +const DEAL_LABEL: Record = { + orcamento: "Orçamento", + fechado: "Fechado", + perdido: "Perdido", +}; + +const DEAL_VARIANT: Record = { + orcamento: "info", + fechado: "success", + perdido: "danger", +}; + +export function QuotesListPage() { + const { accessToken } = useAuth(); + const [quotes, setQuotes] = useState(null); + const [error, setError] = useState(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 ( + + + Nova oferta + + } + /> + + {error ? ( + + {error} + + ) : null} + + + {quotes === null && !error ? ( + Carregando ofertas… + ) : quotes && quotes.length === 0 ? ( + Nenhuma oferta cadastrada ainda. + ) : quotes ? ( + + + + + Oferta + Cliente + Mensalidade + Aprovação + Negócio + + + + {quotes.map((quote) => ( + + + + {quote.quote_number} + + + + {quote.client_name} + {quote.client_company ? {quote.client_company} : null} + + + {formatCurrencyBRL(quote.proposed_monthly_total ?? quote.monthly_total)} + + + + {APPROVAL_LABEL[quote.approval_status]} + + + + {DEAL_LABEL[quote.deal_status]} + + + ))} + + + + ) : null} + + + ); +} diff --git a/apps/core-web/src/routes/resellers/ResellerFormPage.tsx b/apps/core-web/src/routes/resellers/ResellerFormPage.tsx new file mode 100644 index 0000000..be7c921 --- /dev/null +++ b/apps/core-web/src/routes/resellers/ResellerFormPage.tsx @@ -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(EMPTY_FORM); + const [loading, setLoading] = useState(isEditing); + const [error, setError] = useState(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(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 ( + + + ← Voltar para Revendas + + } + actions={ + <> + navigate("/resellers")} type="button"> + Cancelar + + + Salvar + + > + } + /> + + {error ? ( + + {error} + + ) : null} + + {loading ? ( + + Carregando… + + ) : ( + + + + + updateField("name", e.target.value)} required /> + + updateField("document", e.target.value)} /> + updateField("contactEmail", e.target.value)} + /> + + Status + + updateField("active", e.target.checked)} + /> + Revenda ativa + + + + + + )} + + ); +} diff --git a/apps/core-web/src/routes/resellers/ResellersListPage.tsx b/apps/core-web/src/routes/resellers/ResellersListPage.tsx new file mode 100644 index 0000000..ef60cf8 --- /dev/null +++ b/apps/core-web/src/routes/resellers/ResellersListPage.tsx @@ -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(null); + const [error, setError] = useState(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> = [ + { key: "name", header: "Nome", render: (reseller) => {reseller.name} }, + { 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) => {reseller.active ? "Ativo" : "Inativo"}, + }, + { + key: "actions", + header: "", + className: "text-right", + render: (reseller) => ( + navigate(`/resellers/${reseller.id}/edit`)}> + Editar + + ), + }, + ]; + + return ( + + navigate("/resellers/new")}>Nova revenda} + /> + + {error ? ( + + {error} + + ) : null} + + + {resellers === null && !error ? ( + Carregando… + ) : ( + reseller.id} /> + )} + + + ); +} diff --git a/docs/progress.md b/docs/progress.md index 128f567..c8d34ad 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -294,3 +294,30 @@ Verificado: `pnpm turbo run typecheck build` (28/28), `pnpm --filter @eden/api t ### Pendências Toda a Assinatura Eletrônica (eden.md §1.4-1.9/§5/§6/§7 — envelopes, signatários, OTP, hash-chain, verificação pública), geração real de PDF (Playwright/Chromium) + sanitização de HTML, editor visual Tiptap real (`renderToHtml`/`resolveDocument` com condicionais e repetição de tabela), seed dos 7 templates reais de produção da Handix, regra de idempotência completa com `force`/`extra_fields`. + +--- + +## Fase 2/8 — Telas de negócio reais no EDEN Core (Empresas, Produtos, Revendas, CRM, Ofertas) + +Data: 2026-09-03. Usuário pediu explicitamente para priorizar a parte web ("ja faca a parte web para eu ja ver como esta ficando"). 2 subagentes `eden-frontend` em paralelo, mesma sessão, construindo sobre o shell autenticado + Design System já prontos. + +**Correção de instrução no meio da leva**: o usuário interrompeu para dizer "não esqueça de seguir o template" / "melhor usar ele" — as telas deviam seguir de perto o LAYOUT REAL do tema DreamsERP comprado, não só os tokens visuais abstratos já extraídos. Extraí do `tema_do_Eden.zip` as páginas React reais mais relevantes (`sales/salesQuotes*.tsx`, `crm/leads.tsx`/`deals*.tsx`, `inventory/products*.tsx`, `purchase/vendors.tsx`, `system/userManagement.tsx`, `ui-interface/{tables,forms}`) para dentro do worktree de cada agente (`.tmp/theme-reference/`, nunca versionado) e mandei mensagem de correção para os dois agentes já em execução — via `SendMessage`, não relançando a tarefa (no meio do processo cometi o erro de usar `Agent` em vez de `SendMessage` para a primeira correção, criando um agente duplicado; percebido e corrigido na hora com `TaskStop` antes de qualquer trabalho real ser feito por ele). Ambos incorporaram a referência do tema (estrutura de header/breadcrumb/tabela/formulário em cards) reescrita com `@eden/ui` + Tailwind puro — sem copiar JSX nem trazer dependências do tema (antd/Preline) para o projeto. + +### Empresas / Produtos / Revendas (`apps/core-web/src/routes/{companies,products,resellers}/`) + +CRUD completo (lista + criar/editar) para os 3 módulos, layout modelado em `purchase/vendors.tsx` (empresas/revendas) e `inventory/products.tsx` (produtos, com máscara de moeda para as 5 faixas de preço + implantação). Novo componente `Table` em `packages/ui` (tabela genérica reutilizável, sem dependência de terceiro). + +### CRM e Ofertas/Quotes (`apps/core-web/src/routes/{crm,quotes}/`) + +Leads (lista, criar/editar, converter em oportunidade) e Oportunidades (lista, criar/editar) modelados em `crm/leads.tsx`/`dealsDetails.tsx`. Ofertas — o módulo mais rico: `QuotesListPage`, `QuoteFormPage` (revenda + cliente + período de contrato + fidelidade reduzida opcional + lista dinâmica de itens produto/quantidade + desconto opcional + condição de pagamento, mostra os totais calculados pelo servidor antes de ir para o detalhe) e `QuoteDetailPage` (resumo financeiro completo, aprovar/rejeitar quando pendente, fechar negócio quando ainda é orçamento) — modelados em `sales/salesQuotes.tsx`/`addSalesQuotes.tsx`/`salesQuotesDetails.tsx`. Novo componente `Select` em `packages/ui`. + +**Achado real do próprio agente, corrigindo meu brief**: eu tinha dito no brief que as respostas de Leads/Opportunities eram snake_case (copiando o padrão do resto da API) — o agente verificou contra o código-fonte real (`packages/database/src/repositories/leads.ts`) e descobriu que esse módulo específico mapeia para **camelCase** na resposta (`mapLead`), diferente de Quotes/Companies/Products/Resellers (snake_case direto da linha do banco). Confirmado ao vivo via curl antes de integrar. + +### Integração manual (os 2 agentes trabalharam em paralelo no mesmo `core-web`) + +Um dos agentes não conseguiu `git merge` dentro do próprio sandbox e reconstruiu manualmente (leitura+escrita) o estado já existente do shell autenticado — comparei arquivo por arquivo contra `main` (`diff -rq`) para confirmar que a restauração foi fiel (nenhum componente restaurado divergiu) antes de aceitar qualquer coisa como "nova". `App.tsx`, `AuthenticatedLayout.tsx` (nav unificada, lógica de "ativo" por prefixo de path com borda de `/` para nunca casar `/companies` com `/companies-x`) e `packages/ui/src/index.ts` foram reconciliados manualmente juntando as rotas/exports dos dois agentes; `lib/format.ts` teve as duas versões mescladas (`formatDocument` de um lado, `formatDateBR` do outro). + +Verificado: `pnpm turbo run typecheck build` (28/28). Containers `eden-api`/`eden-core` reconstruídos (o build em background travou duas vezes por algum motivo de recurso do ambiente — refeito em primeiro plano, funcionou); bundle de produção confirmado contendo todas as novas telas; login simulando requisição real de browser (`Origin: http://localhost:3001`) funcionando; listagens de Empresas/Produtos/Revendas retornando dados reais; criação de lead confirmando o formato camelCase ao vivo. + +### Pendências +Client Registration (Cadastro de Cliente) ainda sem tela — `QuoteDetailPage` hoje exige colar manualmente um UUID para fechar negócio. Nenhuma tela de Contracts/Inventory/Support/HR-Timeclock/Fiscal/Billing/Documents ainda. `reseller-web`/`subscriber-web` continuam só com o shell de login (sem telas de negócio). diff --git a/packages/ui/src/components/Card.tsx b/packages/ui/src/components/Card.tsx index 18d3025..0321931 100644 --- a/packages/ui/src/components/Card.tsx +++ b/packages/ui/src/components/Card.tsx @@ -4,10 +4,12 @@ import { cn } from "../utils/cn.js"; export interface CardProps extends Omit, "title"> { title?: ReactNode; actions?: ReactNode; + /** Overrides the default `p-5` on the content wrapper — e.g. `"p-0"` for a flush data table (theme source: sales/salesQuotes.tsx uses `p-4 pb-0` around its table). */ + bodyClassName?: string; } /** Theme source (auth-pages/login.tsx): `bg-white border border-border-color rounded-md shadow-sm`. */ -export function Card({ title, actions, className, children, ...rest }: CardProps) { +export function Card({ title, actions, className, bodyClassName, children, ...rest }: CardProps) { return ( {title || actions ? ( @@ -16,7 +18,7 @@ export function Card({ title, actions, className, children, ...rest }: CardProps {actions ? {actions} : null} ) : null} - {children} + {children} ); } diff --git a/packages/ui/src/components/Select.tsx b/packages/ui/src/components/Select.tsx new file mode 100644 index 0000000..b97a50b --- /dev/null +++ b/packages/ui/src/components/Select.tsx @@ -0,0 +1,93 @@ +import { forwardRef, useId } from "react"; +import type { SelectHTMLAttributes } from "react"; +import { cn } from "../utils/cn.js"; + +export interface SelectOption { + value: string; + label: string; +} + +export interface SelectProps extends Omit, "children"> { + label?: string; + /** Validation/server error message — shown below the field, field border turns danger. */ + error?: string; + /** Helper text shown below the field when there's no error. */ + hint?: string; + options: SelectOption[]; + /** Rendered as a disabled, selected-by-default first option (e.g. "Selecione..."). */ + placeholder?: string; +} + +/** + * Native `` styled to match `Input` (packages/ui/src/components/Input.tsx) + * — same border/padding/focus-ring language as the rest of the Design + * System. Derived from the DreamsERP theme's form field sizing + * (`.tmp/theme-reference/sales/addSalesQuotes.tsx` etc. use a + * `react-select`-based CommonSelect for the same slots, out of our stack — + * this is a plain, dependency-free equivalent with the same visual sizing). + */ +export const Select = forwardRef(function Select( + { label, error, hint, id, className, options, placeholder, ...rest }, + ref, +) { + const generatedId = useId(); + const selectId = id ?? generatedId; + + return ( + + {label ? ( + + {label} + + ) : null} + + + {placeholder ? ( + + {placeholder} + + ) : null} + {options.map((option) => ( + + {option.label} + + ))} + + + + + + {error ? ( + + {error} + + ) : hint ? ( + + {hint} + + ) : null} + + ); +}); diff --git a/packages/ui/src/components/Table.tsx b/packages/ui/src/components/Table.tsx new file mode 100644 index 0000000..94c0bd4 --- /dev/null +++ b/packages/ui/src/components/Table.tsx @@ -0,0 +1,64 @@ +import type { ReactNode } from "react"; +import { cn } from "../utils/cn.js"; + +export interface TableColumn { + key: string; + header: ReactNode; + render: (row: T) => ReactNode; + /** Applied to both the header and body cells — e.g. `"text-right"` for numeric columns. */ + className?: string; +} + +export interface TableProps { + columns: Array>; + data: T[]; + rowKey: (row: T) => string; + /** Shown instead of the table body when `data` is empty (and not loading). */ + emptyMessage?: ReactNode; +} + +/** + * Plain data table styled to match the DreamsERP theme's list screens + * (react/src/pages/inventory/products.tsx et al.) without pulling in the + * theme's antd/react-window dependencies — just a semantic ``. + */ +export function Table({ columns, data, rowKey, emptyMessage = "Nenhum registro encontrado." }: TableProps) { + return ( + + + + + {columns.map((column) => ( + + {column.header} + + ))} + + + + {data.length === 0 ? ( + + + {emptyMessage} + + + ) : ( + data.map((row) => ( + + {columns.map((column) => ( + + {column.render(row)} + + ))} + + )) + )} + + + + ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index d0966ae..3190229 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -13,6 +13,12 @@ export type { ButtonProps, ButtonVariant, ButtonSize } from "./components/Button export { Input } from "./components/Input.js"; export type { InputProps } from "./components/Input.js"; +export { Select } from "./components/Select.js"; +export type { SelectProps, SelectOption } from "./components/Select.js"; + +export { Table } from "./components/Table.js"; +export type { TableProps, TableColumn } from "./components/Table.js"; + export { Card } from "./components/Card.js"; export type { CardProps } from "./components/Card.js";
{ + return apiRequest("/quotes", { method: "POST", body: JSON.stringify(input) }, { accessToken }); +} + +export function decideQuoteApproval( + id: string, + input: DecideApprovalInput, + accessToken: string, +): Promise { + return apiRequest( + `/quotes/${id}/approval`, + { method: "PATCH", body: JSON.stringify(input) }, + { accessToken }, + ); +} + +export function closeQuoteDeal(id: string, input: CloseDealInput, accessToken: string): Promise { + return apiRequest(`/quotes/${id}/close`, { method: "POST", body: JSON.stringify(input) }, { accessToken }); +} diff --git a/apps/core-web/src/lib/api/resellers.ts b/apps/core-web/src/lib/api/resellers.ts new file mode 100644 index 0000000..e2aa409 --- /dev/null +++ b/apps/core-web/src/lib/api/resellers.ts @@ -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; + +export function listResellers(accessToken: string): Promise { + return apiRequest("/resellers", { method: "GET" }, { accessToken }); +} + +export function getReseller(id: string, accessToken: string): Promise { + return apiRequest(`/resellers/${id}`, { method: "GET" }, { accessToken }); +} + +export function createReseller(input: CreateResellerInput, accessToken: string): Promise { + return apiRequest("/resellers", { method: "POST", body: JSON.stringify(input) }, { accessToken }); +} + +export function updateReseller( + id: string, + input: UpdateResellerInput, + accessToken: string, +): Promise { + return apiRequest( + `/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 }, + ); +} diff --git a/apps/core-web/src/lib/format.ts b/apps/core-web/src/lib/format.ts new file mode 100644 index 0000000..c30f27b --- /dev/null +++ b/apps/core-web/src/lib/format.ts @@ -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; +} diff --git a/apps/core-web/src/routes/AuthenticatedLayout.tsx b/apps/core-web/src/routes/AuthenticatedLayout.tsx index c2d385b..3efa306 100644 --- a/apps/core-web/src/routes/AuthenticatedLayout.tsx +++ b/apps/core-web/src/routes/AuthenticatedLayout.tsx @@ -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() { (null); + const [error, setError] = useState(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> = [ + { + key: "name", + header: "Nome", + render: (company) => ( + + {company.company_name} + {company.trade_name ? {company.trade_name} : null} + + ), + }, + { 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) => ( + {company.is_active ? "Ativo" : "Inativo"} + ), + }, + { + key: "actions", + header: "", + className: "text-right", + render: (company) => ( + navigate(`/companies/${company.id}/edit`)}> + Editar + + ), + }, + ]; + + return ( + + navigate("/companies/new")}>Nova empresa} + /> + + {error ? ( + + {error} + + ) : null} + + + {companies === null && !error ? ( + Carregando… + ) : ( + company.id} /> + )} + + + ); +} diff --git a/apps/core-web/src/routes/companies/CompanyFormPage.tsx b/apps/core-web/src/routes/companies/CompanyFormPage.tsx new file mode 100644 index 0000000..c69ae6d --- /dev/null +++ b/apps/core-web/src/routes/companies/CompanyFormPage.tsx @@ -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 { + 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(EMPTY_FORM); + const [loading, setLoading] = useState(isEditing); + const [error, setError] = useState(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(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 ( + + + ← Voltar para Empresas + + } + actions={ + <> + navigate("/companies")} type="button"> + Cancelar + + + Salvar + + > + } + /> + + {error ? ( + + {error} + + ) : null} + + {loading ? ( + + Carregando… + + ) : ( + + + + + updateField("companyName", e.target.value)} + required + /> + + updateField("tradeName", e.target.value)} + /> + updateField("cnpj", e.target.value)} /> + updateField("stateRegistration", e.target.value)} + /> + updateField("municipalRegistration", e.target.value)} + /> + {isEditing ? ( + + Status + + updateField("isActive", e.target.checked)} + /> + Empresa ativa + + + ) : null} + + + + + + updateField("addressZip", e.target.value)} /> + + updateField("addressStreet", e.target.value)} + /> + + updateField("addressNumber", e.target.value)} + /> + updateField("addressComplement", e.target.value)} + /> + updateField("addressNeighborhood", e.target.value)} + /> + updateField("addressCity", e.target.value)} + /> + updateField("addressState", e.target.value.toUpperCase())} + /> + updateField("addressCountry", e.target.value)} + placeholder="Brasil" + /> + + + + + + updateField("phone", e.target.value)} /> + updateField("email", e.target.value)} + /> + updateField("website", e.target.value)} /> + + + + + + updateField("legalRepName", e.target.value)} + /> + updateField("legalRepCpf", e.target.value)} + /> + updateField("legalRepRole", e.target.value)} + /> + updateField("legalRepEmail", e.target.value)} + /> + + + + )} + + ); +} diff --git a/apps/core-web/src/routes/crm/ConvertLeadPage.tsx b/apps/core-web/src/routes/crm/ConvertLeadPage.tsx new file mode 100644 index 0000000..d914c51 --- /dev/null +++ b/apps/core-web/src/routes/crm/ConvertLeadPage.tsx @@ -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 = { + 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(null); + const [title, setTitle] = useState(""); + const [stage, setStage] = useState("prospecting"); + const [probability, setProbability] = useState(""); + const [notes, setNotes] = useState(""); + + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(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 ( + + + + + {error ? ( + + {error} + + ) : null} + + + {loading ? ( + Carregando lead… + ) : ( + + setTitle(e.target.value)} + required + autoFocus + /> + setStage(e.target.value as OpportunityStage)} + /> + setProbability(e.target.value)} + /> + + Notas + setNotes(e.target.value)} + /> + + + + Converter + + navigate("/leads")}> + Cancelar + + + + )} + + + ); +} diff --git a/apps/core-web/src/routes/crm/CrmSubNav.tsx b/apps/core-web/src/routes/crm/CrmSubNav.tsx new file mode 100644 index 0000000..03d5697 --- /dev/null +++ b/apps/core-web/src/routes/crm/CrmSubNav.tsx @@ -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 ( + + + Leads + + + Oportunidades + + + ); +} diff --git a/apps/core-web/src/routes/crm/LeadFormPage.tsx b/apps/core-web/src/routes/crm/LeadFormPage.tsx new file mode 100644 index 0000000..0d41695 --- /dev/null +++ b/apps/core-web/src/routes/crm/LeadFormPage.tsx @@ -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 = { + 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("new"); + const [notes, setNotes] = useState(""); + + const [loading, setLoading] = useState(isEdit); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(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 ( + + + + + {error ? ( + + {error} + + ) : null} + + + {loading ? ( + Carregando lead… + ) : ( + + setContactName(e.target.value)} + required + autoFocus + /> + setCompanyName(e.target.value)} /> + setContactEmail(e.target.value)} + /> + setContactPhone(e.target.value)} /> + setSource(e.target.value)} hint="Ex.: Site, indicação, campanha" /> + setStatus(e.target.value as LeadStatus)} + /> + + Notas + setNotes(e.target.value)} + /> + + + + {isEdit ? "Salvar alterações" : "Criar lead"} + + navigate("/leads")}> + Cancelar + + + + )} + + + ); +} diff --git a/apps/core-web/src/routes/crm/LeadsListPage.tsx b/apps/core-web/src/routes/crm/LeadsListPage.tsx new file mode 100644 index 0000000..e8ca3ab --- /dev/null +++ b/apps/core-web/src/routes/crm/LeadsListPage.tsx @@ -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 = { + new: "Novo", + contacted: "Contatado", + qualified: "Qualificado", + converted: "Convertido", + discarded: "Descartado", +}; + +const STATUS_VARIANT: Record = { + new: "info", + contacted: "warning", + qualified: "primary", + converted: "success", + discarded: "danger", +}; + +export function LeadsListPage() { + const { accessToken } = useAuth(); + const [leads, setLeads] = useState(null); + const [error, setError] = useState(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 ( + + + + Novo lead + + } + /> + + {error ? ( + + {error} + + ) : null} + + + {leads === null && !error ? ( + Carregando leads… + ) : leads && leads.length === 0 ? ( + Nenhum lead cadastrado ainda. + ) : leads ? ( + + + + + Contato + Empresa + Status + Ações + + + + {leads.map((lead) => { + const canConvert = lead.status !== "converted" && lead.status !== "discarded"; + return ( + + + + {lead.contactName} + + {lead.contactEmail ? {lead.contactEmail} : null} + + {lead.companyName ?? "—"} + + + {STATUS_LABEL[lead.status] ?? lead.status} + + + + + + Editar + + {canConvert ? ( + + Converter em oportunidade + + ) : null} + + + + ); + })} + + + + ) : null} + + + ); +} diff --git a/apps/core-web/src/routes/crm/OpportunitiesListPage.tsx b/apps/core-web/src/routes/crm/OpportunitiesListPage.tsx new file mode 100644 index 0000000..7e83021 --- /dev/null +++ b/apps/core-web/src/routes/crm/OpportunitiesListPage.tsx @@ -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 = { + prospecting: "Prospecção", + qualification: "Qualificação", + proposal: "Proposta", + negotiation: "Negociação", + won: "Ganha", + lost: "Perdida", +}; + +const STAGE_VARIANT: Record = { + prospecting: "info", + qualification: "primary", + proposal: "warning", + negotiation: "secondary", + won: "success", + lost: "danger", +}; + +export function OpportunitiesListPage() { + const { accessToken } = useAuth(); + const [opportunities, setOpportunities] = useState(null); + const [error, setError] = useState(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 ( + + + + Nova oportunidade + + } + /> + + {error ? ( + + {error} + + ) : null} + + + {opportunities === null && !error ? ( + Carregando oportunidades… + ) : opportunities && opportunities.length === 0 ? ( + Nenhuma oportunidade cadastrada ainda. + ) : opportunities ? ( + + + + + Título + Estágio + Probabilidade + Dono + Ações + + + + {opportunities.map((opportunity) => ( + + + + {opportunity.title} + + + + + {STAGE_LABEL[opportunity.stage] ?? opportunity.stage} + + + + {opportunity.probability != null ? `${opportunity.probability}%` : "—"} + + {opportunity.ownerId ?? "—"} + + + Editar + + + + ))} + + + + ) : null} + + + ); +} diff --git a/apps/core-web/src/routes/crm/OpportunityFormPage.tsx b/apps/core-web/src/routes/crm/OpportunityFormPage.tsx new file mode 100644 index 0000000..8fe8042 --- /dev/null +++ b/apps/core-web/src/routes/crm/OpportunityFormPage.tsx @@ -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 = { + 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("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(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 ( + + + + + {error ? ( + + {error} + + ) : null} + + + {loading ? ( + Carregando oportunidade… + ) : ( + + setTitle(e.target.value)} + required + autoFocus + /> + setStage(e.target.value as OpportunityStage)} + /> + setProbability(e.target.value)} + /> + {stage === "lost" ? ( + setLossReason(e.target.value)} + /> + ) : null} + + Notas + setNotes(e.target.value)} + /> + + + + {isEdit ? "Salvar alterações" : "Criar oportunidade"} + + navigate("/opportunities")}> + Cancelar + + + + )} + + + ); +} diff --git a/apps/core-web/src/routes/products/ProductFormPage.tsx b/apps/core-web/src/routes/products/ProductFormPage.tsx new file mode 100644 index 0000000..8c8d9b9 --- /dev/null +++ b/apps/core-web/src/routes/products/ProductFormPage.tsx @@ -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 ( + 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(EMPTY_FORM); + const [loading, setLoading] = useState(isEditing); + const [error, setError] = useState(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(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 ( + + + ← Voltar para Produtos + + } + actions={ + <> + navigate("/products")} type="button"> + Cancelar + + + Salvar + + > + } + /> + + {error ? ( + + {error} + + ) : null} + + {loading ? ( + + Carregando… + + ) : ( + + + + + updateField("name", e.target.value)} required /> + + updateField("productType", e.target.value)} + required + /> + updateField("unit", e.target.value)} /> + updateField("productGroup", e.target.value)} + /> + updateField("productSubgroup", e.target.value)} + /> + updateField("brand", e.target.value)} /> + + + Descrição + + updateField("description", e.target.value)} + /> + + + + + + + updateField("price0Cents", v)} /> + updateField("price12Cents", v)} /> + updateField("price24Cents", v)} /> + updateField("price36Cents", v)} /> + updateField("price48Cents", v)} /> + updateField("implUnitPriceCents", v)} + /> + + + + + + {( + [ + ["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]) => ( + + updateField(field, e.target.checked)} + /> + {label} + + ))} + + + + )} + + ); +} diff --git a/apps/core-web/src/routes/products/ProductsListPage.tsx b/apps/core-web/src/routes/products/ProductsListPage.tsx new file mode 100644 index 0000000..14deccb --- /dev/null +++ b/apps/core-web/src/routes/products/ProductsListPage.tsx @@ -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(null); + const [error, setError] = useState(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> = [ + { key: "code", header: "Código", render: (product) => `#${product.product_code}` }, + { key: "name", header: "Nome", render: (product) => {product.name} }, + { key: "type", header: "Tipo", render: (product) => product.product_type }, + ...PRICE_COLUMNS.map( + (column): TableColumn => ({ + 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 ? ( + Descontinuado + ) : ( + {product.active ? "Ativo" : "Inativo"} + ), + }, + { + key: "actions", + header: "", + className: "text-right", + render: (product) => ( + navigate(`/products/${product.id}/edit`)}> + Editar + + ), + }, + ]; + + return ( + + navigate("/products/new")}>Novo produto} + /> + + {error ? ( + + {error} + + ) : null} + + + {products === null && !error ? ( + Carregando… + ) : ( + product.id} /> + )} + + + ); +} diff --git a/apps/core-web/src/routes/quotes/QuoteDetailPage.tsx b/apps/core-web/src/routes/quotes/QuoteDetailPage.tsx new file mode 100644 index 0000000..bec95a5 --- /dev/null +++ b/apps/core-web/src/routes/quotes/QuoteDetailPage.tsx @@ -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 = { + none: "Sem aprovação", + pending: "Pendente", + approved: "Aprovada", + rejected: "Rejeitada", +}; + +const APPROVAL_VARIANT: Record = { + none: "neutral", + pending: "warning", + approved: "success", + rejected: "danger", +}; + +const DEAL_LABEL: Record = { + orcamento: "Orçamento", + fechado: "Fechado", + perdido: "Perdido", +}; + +const DEAL_VARIANT: Record = { + orcamento: "info", + fechado: "success", + perdido: "danger", +}; + +export function QuoteDetailPage() { + const { id } = useParams<{ id: string }>(); + const { accessToken } = useAuth(); + + const [quote, setQuote] = useState(null); + const [reseller, setReseller] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( + + + Carregando oferta… + + ); + } + + if (error || !quote) { + return ( + + + {error ?? "Oferta não encontrada."} + + ); + } + + return ( + + + + {APPROVAL_LABEL[quote.approval_status]} + {DEAL_LABEL[quote.deal_status]} + + } + /> + + + + + + + + + + + + + + + + + + + + + + + + + Produto + Qtd. + Preço unit. + Total + Impl. unit. + Impl. total + + + + {quote.items.map((item, index) => ( + + {item.productName} + {item.quantity} + {formatCurrencyBRL(item.unitPrice)} + {formatCurrencyBRL(item.total)} + {formatCurrencyBRL(item.implUnitPrice)} + {formatCurrencyBRL(item.implTotal)} + + ))} + + + + + + + + + {quote.proposed_monthly_total != null ? ( + + ) : null} + + + + + {quote.notes ? ( + + Notas + {quote.notes} + + ) : null} + + + {quote.approval_status === "pending" ? ( + + ) : quote.approval_status !== "none" && quote.approval_notes ? ( + + {quote.approval_notes} + + ) : null} + + {quote.deal_status === "orcamento" ? ( + + ) : quote.client_registration_id ? ( + + + + ) : null} + + + + ); +} + +function Row({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ); +} + +function SummaryRow({ label, value, highlight }: { label: string; value: string; highlight?: boolean }) { + return ( + + {label} + + {value} + + + ); +} + +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(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 ( + + {error ? ( + + {error} + + ) : null} + + Esta oferta tem desconto e/ou fidelidade reduzida e precisa de aprovação antes de seguir. + + setNotes(e.target.value)} + className="mb-3" + /> + + decide("approved")} loading={submitting === "approved"}> + Aprovar + + decide("rejected")} loading={submitting === "rejected"}> + Rejeitar + + + + ); +} + +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(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 ( + + {error ? ( + + {error} + + ) : null} + + setClientRegistrationId(e.target.value)} + hint="Cole o UUID de um cadastro de cliente já existente" + className="min-w-72 flex-1" + required + /> + + Fechar negócio + + + + ); +} diff --git a/apps/core-web/src/routes/quotes/QuoteFormPage.tsx b/apps/core-web/src/routes/quotes/QuoteFormPage.tsx new file mode 100644 index 0000000..1ac1f6e --- /dev/null +++ b/apps/core-web/src/routes/quotes/QuoteFormPage.tsx @@ -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(null); + const [products, setProducts] = useState(null); + const [loadError, setLoadError] = useState(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("12"); + const [fidelityPeriod, setFidelityPeriod] = useState(""); + const [items, setItems] = useState([{ ...EMPTY_ROW }]); + const [implGeral, setImplGeral] = useState(""); + const [proposedMonthlyTotal, setProposedMonthlyTotal] = useState(""); + const [implPaymentCondition, setImplPaymentCondition] = useState(""); + const [notes, setNotes] = useState(""); + + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [createdQuote, setCreatedQuote] = useState(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) { + 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 ( + + + + Oferta criada com sucesso. Confira os totais calculados pelo servidor abaixo. + + + + + + + + {createdQuote.approval_status === "pending" ? ( + + Esta oferta possui desconto e/ou fidelidade reduzida — aguarda aprovação. + + ) : null} + + + Ver oferta completa + + navigate("/quotes")}> + Voltar para a lista + + + + + ); + } + + return ( + + + + + {loadError ? ( + + {loadError} + + ) : null} + {error ? ( + + {error} + + ) : null} + + + {!resellers || !products ? ( + Carregando revendas e produtos… + ) : ( + + + Dados do cliente + + ({ value: r.id, label: r.name }))} + placeholder="Selecione a revenda" + value={resellerId} + onChange={(e) => setResellerId(e.target.value)} + required + /> + setClientName(e.target.value)} required /> + setClientCompany(e.target.value)} /> + setClientEmail(e.target.value)} /> + setClientPhone(e.target.value)} required /> + + + + + Contrato + + ({ + value: String(period), + label: period === 0 ? "Sem fidelidade (0 meses)" : `${period} meses`, + }))} + value={contractPeriod} + onChange={(e) => setContractPeriod(e.target.value)} + required + /> + setFidelityPeriod(e.target.value)} + hint="Só preencha se for menor que o período do contrato — entra em aprovação" + /> + + + + + + Itens + + + Adicionar item + + + + + + + Produto + Quantidade + + + + + {items.map((row, index) => ( + + + updateItem(index, { productId: e.target.value })} + aria-label="Produto" + required + /> + + + updateItem(index, { quantity: e.target.value })} + aria-label="Quantidade" + required + /> + + + {items.length > 1 ? ( + removeItem(index)} + className="rounded-md border border-border-color px-2 py-1 text-xs font-semibold text-danger hover:bg-light" + > + Remover + + ) : null} + + + ))} + + + + + + + Implantação e valores + + setImplGeral(e.target.value)} + hint="Custo de implantação além dos produtos" + /> + setProposedMonthlyTotal(e.target.value)} + hint="Preencha só se for oferecer desconto — a oferta precisará de aprovação" + /> + + ({ + value: condition, + label: condition, + }))} + placeholder="Selecione a condição" + value={implPaymentCondition} + onChange={(e) => setImplPaymentCondition(e.target.value as ImplPaymentCondition)} + /> + + Faixas sugeridas para {formatCurrencyBRL(estimatedImplTotal)} de implantação estimada:{" "} + {availablePaymentConditions.map((c) => ( + + {c} + + ))} + {" "}— o servidor valida a faixa real ao salvar. + + + + + + + Notas + setNotes(e.target.value)} + /> + + + + + Criar oferta + + navigate("/quotes")}> + Cancelar + + + + )} + + + ); +} + +function SummaryField({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ); +} diff --git a/apps/core-web/src/routes/quotes/QuotesListPage.tsx b/apps/core-web/src/routes/quotes/QuotesListPage.tsx new file mode 100644 index 0000000..b1bc219 --- /dev/null +++ b/apps/core-web/src/routes/quotes/QuotesListPage.tsx @@ -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 = { + none: "Sem aprovação", + pending: "Pendente", + approved: "Aprovada", + rejected: "Rejeitada", +}; + +const APPROVAL_VARIANT: Record = { + none: "neutral", + pending: "warning", + approved: "success", + rejected: "danger", +}; + +const DEAL_LABEL: Record = { + orcamento: "Orçamento", + fechado: "Fechado", + perdido: "Perdido", +}; + +const DEAL_VARIANT: Record = { + orcamento: "info", + fechado: "success", + perdido: "danger", +}; + +export function QuotesListPage() { + const { accessToken } = useAuth(); + const [quotes, setQuotes] = useState(null); + const [error, setError] = useState(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 ( + + + Nova oferta + + } + /> + + {error ? ( + + {error} + + ) : null} + + + {quotes === null && !error ? ( + Carregando ofertas… + ) : quotes && quotes.length === 0 ? ( + Nenhuma oferta cadastrada ainda. + ) : quotes ? ( + + + + + Oferta + Cliente + Mensalidade + Aprovação + Negócio + + + + {quotes.map((quote) => ( + + + + {quote.quote_number} + + + + {quote.client_name} + {quote.client_company ? {quote.client_company} : null} + + + {formatCurrencyBRL(quote.proposed_monthly_total ?? quote.monthly_total)} + + + + {APPROVAL_LABEL[quote.approval_status]} + + + + {DEAL_LABEL[quote.deal_status]} + + + ))} + + + + ) : null} + + + ); +} diff --git a/apps/core-web/src/routes/resellers/ResellerFormPage.tsx b/apps/core-web/src/routes/resellers/ResellerFormPage.tsx new file mode 100644 index 0000000..be7c921 --- /dev/null +++ b/apps/core-web/src/routes/resellers/ResellerFormPage.tsx @@ -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(EMPTY_FORM); + const [loading, setLoading] = useState(isEditing); + const [error, setError] = useState(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(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 ( + + + ← Voltar para Revendas + + } + actions={ + <> + navigate("/resellers")} type="button"> + Cancelar + + + Salvar + + > + } + /> + + {error ? ( + + {error} + + ) : null} + + {loading ? ( + + Carregando… + + ) : ( + + + + + updateField("name", e.target.value)} required /> + + updateField("document", e.target.value)} /> + updateField("contactEmail", e.target.value)} + /> + + Status + + updateField("active", e.target.checked)} + /> + Revenda ativa + + + + + + )} + + ); +} diff --git a/apps/core-web/src/routes/resellers/ResellersListPage.tsx b/apps/core-web/src/routes/resellers/ResellersListPage.tsx new file mode 100644 index 0000000..ef60cf8 --- /dev/null +++ b/apps/core-web/src/routes/resellers/ResellersListPage.tsx @@ -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(null); + const [error, setError] = useState(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> = [ + { key: "name", header: "Nome", render: (reseller) => {reseller.name} }, + { 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) => {reseller.active ? "Ativo" : "Inativo"}, + }, + { + key: "actions", + header: "", + className: "text-right", + render: (reseller) => ( + navigate(`/resellers/${reseller.id}/edit`)}> + Editar + + ), + }, + ]; + + return ( + + navigate("/resellers/new")}>Nova revenda} + /> + + {error ? ( + + {error} + + ) : null} + + + {resellers === null && !error ? ( + Carregando… + ) : ( + reseller.id} /> + )} + + + ); +} diff --git a/docs/progress.md b/docs/progress.md index 128f567..c8d34ad 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -294,3 +294,30 @@ Verificado: `pnpm turbo run typecheck build` (28/28), `pnpm --filter @eden/api t ### Pendências Toda a Assinatura Eletrônica (eden.md §1.4-1.9/§5/§6/§7 — envelopes, signatários, OTP, hash-chain, verificação pública), geração real de PDF (Playwright/Chromium) + sanitização de HTML, editor visual Tiptap real (`renderToHtml`/`resolveDocument` com condicionais e repetição de tabela), seed dos 7 templates reais de produção da Handix, regra de idempotência completa com `force`/`extra_fields`. + +--- + +## Fase 2/8 — Telas de negócio reais no EDEN Core (Empresas, Produtos, Revendas, CRM, Ofertas) + +Data: 2026-09-03. Usuário pediu explicitamente para priorizar a parte web ("ja faca a parte web para eu ja ver como esta ficando"). 2 subagentes `eden-frontend` em paralelo, mesma sessão, construindo sobre o shell autenticado + Design System já prontos. + +**Correção de instrução no meio da leva**: o usuário interrompeu para dizer "não esqueça de seguir o template" / "melhor usar ele" — as telas deviam seguir de perto o LAYOUT REAL do tema DreamsERP comprado, não só os tokens visuais abstratos já extraídos. Extraí do `tema_do_Eden.zip` as páginas React reais mais relevantes (`sales/salesQuotes*.tsx`, `crm/leads.tsx`/`deals*.tsx`, `inventory/products*.tsx`, `purchase/vendors.tsx`, `system/userManagement.tsx`, `ui-interface/{tables,forms}`) para dentro do worktree de cada agente (`.tmp/theme-reference/`, nunca versionado) e mandei mensagem de correção para os dois agentes já em execução — via `SendMessage`, não relançando a tarefa (no meio do processo cometi o erro de usar `Agent` em vez de `SendMessage` para a primeira correção, criando um agente duplicado; percebido e corrigido na hora com `TaskStop` antes de qualquer trabalho real ser feito por ele). Ambos incorporaram a referência do tema (estrutura de header/breadcrumb/tabela/formulário em cards) reescrita com `@eden/ui` + Tailwind puro — sem copiar JSX nem trazer dependências do tema (antd/Preline) para o projeto. + +### Empresas / Produtos / Revendas (`apps/core-web/src/routes/{companies,products,resellers}/`) + +CRUD completo (lista + criar/editar) para os 3 módulos, layout modelado em `purchase/vendors.tsx` (empresas/revendas) e `inventory/products.tsx` (produtos, com máscara de moeda para as 5 faixas de preço + implantação). Novo componente `Table` em `packages/ui` (tabela genérica reutilizável, sem dependência de terceiro). + +### CRM e Ofertas/Quotes (`apps/core-web/src/routes/{crm,quotes}/`) + +Leads (lista, criar/editar, converter em oportunidade) e Oportunidades (lista, criar/editar) modelados em `crm/leads.tsx`/`dealsDetails.tsx`. Ofertas — o módulo mais rico: `QuotesListPage`, `QuoteFormPage` (revenda + cliente + período de contrato + fidelidade reduzida opcional + lista dinâmica de itens produto/quantidade + desconto opcional + condição de pagamento, mostra os totais calculados pelo servidor antes de ir para o detalhe) e `QuoteDetailPage` (resumo financeiro completo, aprovar/rejeitar quando pendente, fechar negócio quando ainda é orçamento) — modelados em `sales/salesQuotes.tsx`/`addSalesQuotes.tsx`/`salesQuotesDetails.tsx`. Novo componente `Select` em `packages/ui`. + +**Achado real do próprio agente, corrigindo meu brief**: eu tinha dito no brief que as respostas de Leads/Opportunities eram snake_case (copiando o padrão do resto da API) — o agente verificou contra o código-fonte real (`packages/database/src/repositories/leads.ts`) e descobriu que esse módulo específico mapeia para **camelCase** na resposta (`mapLead`), diferente de Quotes/Companies/Products/Resellers (snake_case direto da linha do banco). Confirmado ao vivo via curl antes de integrar. + +### Integração manual (os 2 agentes trabalharam em paralelo no mesmo `core-web`) + +Um dos agentes não conseguiu `git merge` dentro do próprio sandbox e reconstruiu manualmente (leitura+escrita) o estado já existente do shell autenticado — comparei arquivo por arquivo contra `main` (`diff -rq`) para confirmar que a restauração foi fiel (nenhum componente restaurado divergiu) antes de aceitar qualquer coisa como "nova". `App.tsx`, `AuthenticatedLayout.tsx` (nav unificada, lógica de "ativo" por prefixo de path com borda de `/` para nunca casar `/companies` com `/companies-x`) e `packages/ui/src/index.ts` foram reconciliados manualmente juntando as rotas/exports dos dois agentes; `lib/format.ts` teve as duas versões mescladas (`formatDocument` de um lado, `formatDateBR` do outro). + +Verificado: `pnpm turbo run typecheck build` (28/28). Containers `eden-api`/`eden-core` reconstruídos (o build em background travou duas vezes por algum motivo de recurso do ambiente — refeito em primeiro plano, funcionou); bundle de produção confirmado contendo todas as novas telas; login simulando requisição real de browser (`Origin: http://localhost:3001`) funcionando; listagens de Empresas/Produtos/Revendas retornando dados reais; criação de lead confirmando o formato camelCase ao vivo. + +### Pendências +Client Registration (Cadastro de Cliente) ainda sem tela — `QuoteDetailPage` hoje exige colar manualmente um UUID para fechar negócio. Nenhuma tela de Contracts/Inventory/Support/HR-Timeclock/Fiscal/Billing/Documents ainda. `reseller-web`/`subscriber-web` continuam só com o shell de login (sem telas de negócio). diff --git a/packages/ui/src/components/Card.tsx b/packages/ui/src/components/Card.tsx index 18d3025..0321931 100644 --- a/packages/ui/src/components/Card.tsx +++ b/packages/ui/src/components/Card.tsx @@ -4,10 +4,12 @@ import { cn } from "../utils/cn.js"; export interface CardProps extends Omit, "title"> { title?: ReactNode; actions?: ReactNode; + /** Overrides the default `p-5` on the content wrapper — e.g. `"p-0"` for a flush data table (theme source: sales/salesQuotes.tsx uses `p-4 pb-0` around its table). */ + bodyClassName?: string; } /** Theme source (auth-pages/login.tsx): `bg-white border border-border-color rounded-md shadow-sm`. */ -export function Card({ title, actions, className, children, ...rest }: CardProps) { +export function Card({ title, actions, className, bodyClassName, children, ...rest }: CardProps) { return ( {title || actions ? ( @@ -16,7 +18,7 @@ export function Card({ title, actions, className, children, ...rest }: CardProps {actions ? {actions} : null} ) : null} - {children} + {children} ); } diff --git a/packages/ui/src/components/Select.tsx b/packages/ui/src/components/Select.tsx new file mode 100644 index 0000000..b97a50b --- /dev/null +++ b/packages/ui/src/components/Select.tsx @@ -0,0 +1,93 @@ +import { forwardRef, useId } from "react"; +import type { SelectHTMLAttributes } from "react"; +import { cn } from "../utils/cn.js"; + +export interface SelectOption { + value: string; + label: string; +} + +export interface SelectProps extends Omit, "children"> { + label?: string; + /** Validation/server error message — shown below the field, field border turns danger. */ + error?: string; + /** Helper text shown below the field when there's no error. */ + hint?: string; + options: SelectOption[]; + /** Rendered as a disabled, selected-by-default first option (e.g. "Selecione..."). */ + placeholder?: string; +} + +/** + * Native `` styled to match `Input` (packages/ui/src/components/Input.tsx) + * — same border/padding/focus-ring language as the rest of the Design + * System. Derived from the DreamsERP theme's form field sizing + * (`.tmp/theme-reference/sales/addSalesQuotes.tsx` etc. use a + * `react-select`-based CommonSelect for the same slots, out of our stack — + * this is a plain, dependency-free equivalent with the same visual sizing). + */ +export const Select = forwardRef(function Select( + { label, error, hint, id, className, options, placeholder, ...rest }, + ref, +) { + const generatedId = useId(); + const selectId = id ?? generatedId; + + return ( + + {label ? ( + + {label} + + ) : null} + + + {placeholder ? ( + + {placeholder} + + ) : null} + {options.map((option) => ( + + {option.label} + + ))} + + + + + + {error ? ( + + {error} + + ) : hint ? ( + + {hint} + + ) : null} + + ); +}); diff --git a/packages/ui/src/components/Table.tsx b/packages/ui/src/components/Table.tsx new file mode 100644 index 0000000..94c0bd4 --- /dev/null +++ b/packages/ui/src/components/Table.tsx @@ -0,0 +1,64 @@ +import type { ReactNode } from "react"; +import { cn } from "../utils/cn.js"; + +export interface TableColumn { + key: string; + header: ReactNode; + render: (row: T) => ReactNode; + /** Applied to both the header and body cells — e.g. `"text-right"` for numeric columns. */ + className?: string; +} + +export interface TableProps { + columns: Array>; + data: T[]; + rowKey: (row: T) => string; + /** Shown instead of the table body when `data` is empty (and not loading). */ + emptyMessage?: ReactNode; +} + +/** + * Plain data table styled to match the DreamsERP theme's list screens + * (react/src/pages/inventory/products.tsx et al.) without pulling in the + * theme's antd/react-window dependencies — just a semantic ``. + */ +export function Table({ columns, data, rowKey, emptyMessage = "Nenhum registro encontrado." }: TableProps) { + return ( + + + + + {columns.map((column) => ( + + {column.header} + + ))} + + + + {data.length === 0 ? ( + + + {emptyMessage} + + + ) : ( + data.map((row) => ( + + {columns.map((column) => ( + + {column.render(row)} + + ))} + + )) + )} + + + + ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index d0966ae..3190229 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -13,6 +13,12 @@ export type { ButtonProps, ButtonVariant, ButtonSize } from "./components/Button export { Input } from "./components/Input.js"; export type { InputProps } from "./components/Input.js"; +export { Select } from "./components/Select.js"; +export type { SelectProps, SelectOption } from "./components/Select.js"; + +export { Table } from "./components/Table.js"; +export type { TableProps, TableColumn } from "./components/Table.js"; + export { Card } from "./components/Card.js"; export type { CardProps } from "./components/Card.js";
("/quotes", { method: "POST", body: JSON.stringify(input) }, { accessToken }); +} + +export function decideQuoteApproval( + id: string, + input: DecideApprovalInput, + accessToken: string, +): Promise { + return apiRequest( + `/quotes/${id}/approval`, + { method: "PATCH", body: JSON.stringify(input) }, + { accessToken }, + ); +} + +export function closeQuoteDeal(id: string, input: CloseDealInput, accessToken: string): Promise { + return apiRequest(`/quotes/${id}/close`, { method: "POST", body: JSON.stringify(input) }, { accessToken }); +} diff --git a/apps/core-web/src/lib/api/resellers.ts b/apps/core-web/src/lib/api/resellers.ts new file mode 100644 index 0000000..e2aa409 --- /dev/null +++ b/apps/core-web/src/lib/api/resellers.ts @@ -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; + +export function listResellers(accessToken: string): Promise { + return apiRequest("/resellers", { method: "GET" }, { accessToken }); +} + +export function getReseller(id: string, accessToken: string): Promise { + return apiRequest(`/resellers/${id}`, { method: "GET" }, { accessToken }); +} + +export function createReseller(input: CreateResellerInput, accessToken: string): Promise { + return apiRequest("/resellers", { method: "POST", body: JSON.stringify(input) }, { accessToken }); +} + +export function updateReseller( + id: string, + input: UpdateResellerInput, + accessToken: string, +): Promise { + return apiRequest( + `/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 }, + ); +} diff --git a/apps/core-web/src/lib/format.ts b/apps/core-web/src/lib/format.ts new file mode 100644 index 0000000..c30f27b --- /dev/null +++ b/apps/core-web/src/lib/format.ts @@ -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; +} diff --git a/apps/core-web/src/routes/AuthenticatedLayout.tsx b/apps/core-web/src/routes/AuthenticatedLayout.tsx index c2d385b..3efa306 100644 --- a/apps/core-web/src/routes/AuthenticatedLayout.tsx +++ b/apps/core-web/src/routes/AuthenticatedLayout.tsx @@ -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() { (null); + const [error, setError] = useState(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> = [ + { + key: "name", + header: "Nome", + render: (company) => ( + + {company.company_name} + {company.trade_name ? {company.trade_name} : null} + + ), + }, + { 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) => ( + {company.is_active ? "Ativo" : "Inativo"} + ), + }, + { + key: "actions", + header: "", + className: "text-right", + render: (company) => ( + navigate(`/companies/${company.id}/edit`)}> + Editar + + ), + }, + ]; + + return ( + + navigate("/companies/new")}>Nova empresa} + /> + + {error ? ( + + {error} + + ) : null} + + + {companies === null && !error ? ( + Carregando… + ) : ( + company.id} /> + )} + + + ); +} diff --git a/apps/core-web/src/routes/companies/CompanyFormPage.tsx b/apps/core-web/src/routes/companies/CompanyFormPage.tsx new file mode 100644 index 0000000..c69ae6d --- /dev/null +++ b/apps/core-web/src/routes/companies/CompanyFormPage.tsx @@ -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 { + 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(EMPTY_FORM); + const [loading, setLoading] = useState(isEditing); + const [error, setError] = useState(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(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 ( + + + ← Voltar para Empresas + + } + actions={ + <> + navigate("/companies")} type="button"> + Cancelar + + + Salvar + + > + } + /> + + {error ? ( + + {error} + + ) : null} + + {loading ? ( + + Carregando… + + ) : ( + + + + + updateField("companyName", e.target.value)} + required + /> + + updateField("tradeName", e.target.value)} + /> + updateField("cnpj", e.target.value)} /> + updateField("stateRegistration", e.target.value)} + /> + updateField("municipalRegistration", e.target.value)} + /> + {isEditing ? ( + + Status + + updateField("isActive", e.target.checked)} + /> + Empresa ativa + + + ) : null} + + + + + + updateField("addressZip", e.target.value)} /> + + updateField("addressStreet", e.target.value)} + /> + + updateField("addressNumber", e.target.value)} + /> + updateField("addressComplement", e.target.value)} + /> + updateField("addressNeighborhood", e.target.value)} + /> + updateField("addressCity", e.target.value)} + /> + updateField("addressState", e.target.value.toUpperCase())} + /> + updateField("addressCountry", e.target.value)} + placeholder="Brasil" + /> + + + + + + updateField("phone", e.target.value)} /> + updateField("email", e.target.value)} + /> + updateField("website", e.target.value)} /> + + + + + + updateField("legalRepName", e.target.value)} + /> + updateField("legalRepCpf", e.target.value)} + /> + updateField("legalRepRole", e.target.value)} + /> + updateField("legalRepEmail", e.target.value)} + /> + + + + )} + + ); +} diff --git a/apps/core-web/src/routes/crm/ConvertLeadPage.tsx b/apps/core-web/src/routes/crm/ConvertLeadPage.tsx new file mode 100644 index 0000000..d914c51 --- /dev/null +++ b/apps/core-web/src/routes/crm/ConvertLeadPage.tsx @@ -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 = { + 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(null); + const [title, setTitle] = useState(""); + const [stage, setStage] = useState("prospecting"); + const [probability, setProbability] = useState(""); + const [notes, setNotes] = useState(""); + + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(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 ( + + + + + {error ? ( + + {error} + + ) : null} + + + {loading ? ( + Carregando lead… + ) : ( + + setTitle(e.target.value)} + required + autoFocus + /> + setStage(e.target.value as OpportunityStage)} + /> + setProbability(e.target.value)} + /> + + Notas + setNotes(e.target.value)} + /> + + + + Converter + + navigate("/leads")}> + Cancelar + + + + )} + + + ); +} diff --git a/apps/core-web/src/routes/crm/CrmSubNav.tsx b/apps/core-web/src/routes/crm/CrmSubNav.tsx new file mode 100644 index 0000000..03d5697 --- /dev/null +++ b/apps/core-web/src/routes/crm/CrmSubNav.tsx @@ -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 ( + + + Leads + + + Oportunidades + + + ); +} diff --git a/apps/core-web/src/routes/crm/LeadFormPage.tsx b/apps/core-web/src/routes/crm/LeadFormPage.tsx new file mode 100644 index 0000000..0d41695 --- /dev/null +++ b/apps/core-web/src/routes/crm/LeadFormPage.tsx @@ -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 = { + 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("new"); + const [notes, setNotes] = useState(""); + + const [loading, setLoading] = useState(isEdit); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(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 ( + + + + + {error ? ( + + {error} + + ) : null} + + + {loading ? ( + Carregando lead… + ) : ( + + setContactName(e.target.value)} + required + autoFocus + /> + setCompanyName(e.target.value)} /> + setContactEmail(e.target.value)} + /> + setContactPhone(e.target.value)} /> + setSource(e.target.value)} hint="Ex.: Site, indicação, campanha" /> + setStatus(e.target.value as LeadStatus)} + /> + + Notas + setNotes(e.target.value)} + /> + + + + {isEdit ? "Salvar alterações" : "Criar lead"} + + navigate("/leads")}> + Cancelar + + + + )} + + + ); +} diff --git a/apps/core-web/src/routes/crm/LeadsListPage.tsx b/apps/core-web/src/routes/crm/LeadsListPage.tsx new file mode 100644 index 0000000..e8ca3ab --- /dev/null +++ b/apps/core-web/src/routes/crm/LeadsListPage.tsx @@ -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 = { + new: "Novo", + contacted: "Contatado", + qualified: "Qualificado", + converted: "Convertido", + discarded: "Descartado", +}; + +const STATUS_VARIANT: Record = { + new: "info", + contacted: "warning", + qualified: "primary", + converted: "success", + discarded: "danger", +}; + +export function LeadsListPage() { + const { accessToken } = useAuth(); + const [leads, setLeads] = useState(null); + const [error, setError] = useState(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 ( + + + + Novo lead + + } + /> + + {error ? ( + + {error} + + ) : null} + + + {leads === null && !error ? ( + Carregando leads… + ) : leads && leads.length === 0 ? ( + Nenhum lead cadastrado ainda. + ) : leads ? ( + + + + + Contato + Empresa + Status + Ações + + + + {leads.map((lead) => { + const canConvert = lead.status !== "converted" && lead.status !== "discarded"; + return ( + + + + {lead.contactName} + + {lead.contactEmail ? {lead.contactEmail} : null} + + {lead.companyName ?? "—"} + + + {STATUS_LABEL[lead.status] ?? lead.status} + + + + + + Editar + + {canConvert ? ( + + Converter em oportunidade + + ) : null} + + + + ); + })} + + + + ) : null} + + + ); +} diff --git a/apps/core-web/src/routes/crm/OpportunitiesListPage.tsx b/apps/core-web/src/routes/crm/OpportunitiesListPage.tsx new file mode 100644 index 0000000..7e83021 --- /dev/null +++ b/apps/core-web/src/routes/crm/OpportunitiesListPage.tsx @@ -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 = { + prospecting: "Prospecção", + qualification: "Qualificação", + proposal: "Proposta", + negotiation: "Negociação", + won: "Ganha", + lost: "Perdida", +}; + +const STAGE_VARIANT: Record = { + prospecting: "info", + qualification: "primary", + proposal: "warning", + negotiation: "secondary", + won: "success", + lost: "danger", +}; + +export function OpportunitiesListPage() { + const { accessToken } = useAuth(); + const [opportunities, setOpportunities] = useState(null); + const [error, setError] = useState(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 ( + + + + Nova oportunidade + + } + /> + + {error ? ( + + {error} + + ) : null} + + + {opportunities === null && !error ? ( + Carregando oportunidades… + ) : opportunities && opportunities.length === 0 ? ( + Nenhuma oportunidade cadastrada ainda. + ) : opportunities ? ( + + + + + Título + Estágio + Probabilidade + Dono + Ações + + + + {opportunities.map((opportunity) => ( + + + + {opportunity.title} + + + + + {STAGE_LABEL[opportunity.stage] ?? opportunity.stage} + + + + {opportunity.probability != null ? `${opportunity.probability}%` : "—"} + + {opportunity.ownerId ?? "—"} + + + Editar + + + + ))} + + + + ) : null} + + + ); +} diff --git a/apps/core-web/src/routes/crm/OpportunityFormPage.tsx b/apps/core-web/src/routes/crm/OpportunityFormPage.tsx new file mode 100644 index 0000000..8fe8042 --- /dev/null +++ b/apps/core-web/src/routes/crm/OpportunityFormPage.tsx @@ -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 = { + 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("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(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 ( + + + + + {error ? ( + + {error} + + ) : null} + + + {loading ? ( + Carregando oportunidade… + ) : ( + + setTitle(e.target.value)} + required + autoFocus + /> + setStage(e.target.value as OpportunityStage)} + /> + setProbability(e.target.value)} + /> + {stage === "lost" ? ( + setLossReason(e.target.value)} + /> + ) : null} + + Notas + setNotes(e.target.value)} + /> + + + + {isEdit ? "Salvar alterações" : "Criar oportunidade"} + + navigate("/opportunities")}> + Cancelar + + + + )} + + + ); +} diff --git a/apps/core-web/src/routes/products/ProductFormPage.tsx b/apps/core-web/src/routes/products/ProductFormPage.tsx new file mode 100644 index 0000000..8c8d9b9 --- /dev/null +++ b/apps/core-web/src/routes/products/ProductFormPage.tsx @@ -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 ( + 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(EMPTY_FORM); + const [loading, setLoading] = useState(isEditing); + const [error, setError] = useState(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(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 ( + + + ← Voltar para Produtos + + } + actions={ + <> + navigate("/products")} type="button"> + Cancelar + + + Salvar + + > + } + /> + + {error ? ( + + {error} + + ) : null} + + {loading ? ( + + Carregando… + + ) : ( + + + + + updateField("name", e.target.value)} required /> + + updateField("productType", e.target.value)} + required + /> + updateField("unit", e.target.value)} /> + updateField("productGroup", e.target.value)} + /> + updateField("productSubgroup", e.target.value)} + /> + updateField("brand", e.target.value)} /> + + + Descrição + + updateField("description", e.target.value)} + /> + + + + + + + updateField("price0Cents", v)} /> + updateField("price12Cents", v)} /> + updateField("price24Cents", v)} /> + updateField("price36Cents", v)} /> + updateField("price48Cents", v)} /> + updateField("implUnitPriceCents", v)} + /> + + + + + + {( + [ + ["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]) => ( + + updateField(field, e.target.checked)} + /> + {label} + + ))} + + + + )} + + ); +} diff --git a/apps/core-web/src/routes/products/ProductsListPage.tsx b/apps/core-web/src/routes/products/ProductsListPage.tsx new file mode 100644 index 0000000..14deccb --- /dev/null +++ b/apps/core-web/src/routes/products/ProductsListPage.tsx @@ -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(null); + const [error, setError] = useState(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> = [ + { key: "code", header: "Código", render: (product) => `#${product.product_code}` }, + { key: "name", header: "Nome", render: (product) => {product.name} }, + { key: "type", header: "Tipo", render: (product) => product.product_type }, + ...PRICE_COLUMNS.map( + (column): TableColumn => ({ + 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 ? ( + Descontinuado + ) : ( + {product.active ? "Ativo" : "Inativo"} + ), + }, + { + key: "actions", + header: "", + className: "text-right", + render: (product) => ( + navigate(`/products/${product.id}/edit`)}> + Editar + + ), + }, + ]; + + return ( + + navigate("/products/new")}>Novo produto} + /> + + {error ? ( + + {error} + + ) : null} + + + {products === null && !error ? ( + Carregando… + ) : ( + product.id} /> + )} + + + ); +} diff --git a/apps/core-web/src/routes/quotes/QuoteDetailPage.tsx b/apps/core-web/src/routes/quotes/QuoteDetailPage.tsx new file mode 100644 index 0000000..bec95a5 --- /dev/null +++ b/apps/core-web/src/routes/quotes/QuoteDetailPage.tsx @@ -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 = { + none: "Sem aprovação", + pending: "Pendente", + approved: "Aprovada", + rejected: "Rejeitada", +}; + +const APPROVAL_VARIANT: Record = { + none: "neutral", + pending: "warning", + approved: "success", + rejected: "danger", +}; + +const DEAL_LABEL: Record = { + orcamento: "Orçamento", + fechado: "Fechado", + perdido: "Perdido", +}; + +const DEAL_VARIANT: Record = { + orcamento: "info", + fechado: "success", + perdido: "danger", +}; + +export function QuoteDetailPage() { + const { id } = useParams<{ id: string }>(); + const { accessToken } = useAuth(); + + const [quote, setQuote] = useState(null); + const [reseller, setReseller] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( + + + Carregando oferta… + + ); + } + + if (error || !quote) { + return ( + + + {error ?? "Oferta não encontrada."} + + ); + } + + return ( + + + + {APPROVAL_LABEL[quote.approval_status]} + {DEAL_LABEL[quote.deal_status]} + + } + /> + + + + + + + + + + + + + + + + + + + + + + + + + Produto + Qtd. + Preço unit. + Total + Impl. unit. + Impl. total + + + + {quote.items.map((item, index) => ( + + {item.productName} + {item.quantity} + {formatCurrencyBRL(item.unitPrice)} + {formatCurrencyBRL(item.total)} + {formatCurrencyBRL(item.implUnitPrice)} + {formatCurrencyBRL(item.implTotal)} + + ))} + + + + + + + + + {quote.proposed_monthly_total != null ? ( + + ) : null} + + + + + {quote.notes ? ( + + Notas + {quote.notes} + + ) : null} + + + {quote.approval_status === "pending" ? ( + + ) : quote.approval_status !== "none" && quote.approval_notes ? ( + + {quote.approval_notes} + + ) : null} + + {quote.deal_status === "orcamento" ? ( + + ) : quote.client_registration_id ? ( + + + + ) : null} + + + + ); +} + +function Row({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ); +} + +function SummaryRow({ label, value, highlight }: { label: string; value: string; highlight?: boolean }) { + return ( + + {label} + + {value} + + + ); +} + +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(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 ( + + {error ? ( + + {error} + + ) : null} + + Esta oferta tem desconto e/ou fidelidade reduzida e precisa de aprovação antes de seguir. + + setNotes(e.target.value)} + className="mb-3" + /> + + decide("approved")} loading={submitting === "approved"}> + Aprovar + + decide("rejected")} loading={submitting === "rejected"}> + Rejeitar + + + + ); +} + +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(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 ( + + {error ? ( + + {error} + + ) : null} + + setClientRegistrationId(e.target.value)} + hint="Cole o UUID de um cadastro de cliente já existente" + className="min-w-72 flex-1" + required + /> + + Fechar negócio + + + + ); +} diff --git a/apps/core-web/src/routes/quotes/QuoteFormPage.tsx b/apps/core-web/src/routes/quotes/QuoteFormPage.tsx new file mode 100644 index 0000000..1ac1f6e --- /dev/null +++ b/apps/core-web/src/routes/quotes/QuoteFormPage.tsx @@ -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(null); + const [products, setProducts] = useState(null); + const [loadError, setLoadError] = useState(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("12"); + const [fidelityPeriod, setFidelityPeriod] = useState(""); + const [items, setItems] = useState([{ ...EMPTY_ROW }]); + const [implGeral, setImplGeral] = useState(""); + const [proposedMonthlyTotal, setProposedMonthlyTotal] = useState(""); + const [implPaymentCondition, setImplPaymentCondition] = useState(""); + const [notes, setNotes] = useState(""); + + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [createdQuote, setCreatedQuote] = useState(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) { + 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 ( + + + + Oferta criada com sucesso. Confira os totais calculados pelo servidor abaixo. + + + + + + + + {createdQuote.approval_status === "pending" ? ( + + Esta oferta possui desconto e/ou fidelidade reduzida — aguarda aprovação. + + ) : null} + + + Ver oferta completa + + navigate("/quotes")}> + Voltar para a lista + + + + + ); + } + + return ( + + + + + {loadError ? ( + + {loadError} + + ) : null} + {error ? ( + + {error} + + ) : null} + + + {!resellers || !products ? ( + Carregando revendas e produtos… + ) : ( + + + Dados do cliente + + ({ value: r.id, label: r.name }))} + placeholder="Selecione a revenda" + value={resellerId} + onChange={(e) => setResellerId(e.target.value)} + required + /> + setClientName(e.target.value)} required /> + setClientCompany(e.target.value)} /> + setClientEmail(e.target.value)} /> + setClientPhone(e.target.value)} required /> + + + + + Contrato + + ({ + value: String(period), + label: period === 0 ? "Sem fidelidade (0 meses)" : `${period} meses`, + }))} + value={contractPeriod} + onChange={(e) => setContractPeriod(e.target.value)} + required + /> + setFidelityPeriod(e.target.value)} + hint="Só preencha se for menor que o período do contrato — entra em aprovação" + /> + + + + + + Itens + + + Adicionar item + + + + + + + Produto + Quantidade + + + + + {items.map((row, index) => ( + + + updateItem(index, { productId: e.target.value })} + aria-label="Produto" + required + /> + + + updateItem(index, { quantity: e.target.value })} + aria-label="Quantidade" + required + /> + + + {items.length > 1 ? ( + removeItem(index)} + className="rounded-md border border-border-color px-2 py-1 text-xs font-semibold text-danger hover:bg-light" + > + Remover + + ) : null} + + + ))} + + + + + + + Implantação e valores + + setImplGeral(e.target.value)} + hint="Custo de implantação além dos produtos" + /> + setProposedMonthlyTotal(e.target.value)} + hint="Preencha só se for oferecer desconto — a oferta precisará de aprovação" + /> + + ({ + value: condition, + label: condition, + }))} + placeholder="Selecione a condição" + value={implPaymentCondition} + onChange={(e) => setImplPaymentCondition(e.target.value as ImplPaymentCondition)} + /> + + Faixas sugeridas para {formatCurrencyBRL(estimatedImplTotal)} de implantação estimada:{" "} + {availablePaymentConditions.map((c) => ( + + {c} + + ))} + {" "}— o servidor valida a faixa real ao salvar. + + + + + + + Notas + setNotes(e.target.value)} + /> + + + + + Criar oferta + + navigate("/quotes")}> + Cancelar + + + + )} + + + ); +} + +function SummaryField({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ); +} diff --git a/apps/core-web/src/routes/quotes/QuotesListPage.tsx b/apps/core-web/src/routes/quotes/QuotesListPage.tsx new file mode 100644 index 0000000..b1bc219 --- /dev/null +++ b/apps/core-web/src/routes/quotes/QuotesListPage.tsx @@ -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 = { + none: "Sem aprovação", + pending: "Pendente", + approved: "Aprovada", + rejected: "Rejeitada", +}; + +const APPROVAL_VARIANT: Record = { + none: "neutral", + pending: "warning", + approved: "success", + rejected: "danger", +}; + +const DEAL_LABEL: Record = { + orcamento: "Orçamento", + fechado: "Fechado", + perdido: "Perdido", +}; + +const DEAL_VARIANT: Record = { + orcamento: "info", + fechado: "success", + perdido: "danger", +}; + +export function QuotesListPage() { + const { accessToken } = useAuth(); + const [quotes, setQuotes] = useState(null); + const [error, setError] = useState(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 ( + + + Nova oferta + + } + /> + + {error ? ( + + {error} + + ) : null} + + + {quotes === null && !error ? ( + Carregando ofertas… + ) : quotes && quotes.length === 0 ? ( + Nenhuma oferta cadastrada ainda. + ) : quotes ? ( + + + + + Oferta + Cliente + Mensalidade + Aprovação + Negócio + + + + {quotes.map((quote) => ( + + + + {quote.quote_number} + + + + {quote.client_name} + {quote.client_company ? {quote.client_company} : null} + + + {formatCurrencyBRL(quote.proposed_monthly_total ?? quote.monthly_total)} + + + + {APPROVAL_LABEL[quote.approval_status]} + + + + {DEAL_LABEL[quote.deal_status]} + + + ))} + + + + ) : null} + + + ); +} diff --git a/apps/core-web/src/routes/resellers/ResellerFormPage.tsx b/apps/core-web/src/routes/resellers/ResellerFormPage.tsx new file mode 100644 index 0000000..be7c921 --- /dev/null +++ b/apps/core-web/src/routes/resellers/ResellerFormPage.tsx @@ -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(EMPTY_FORM); + const [loading, setLoading] = useState(isEditing); + const [error, setError] = useState(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(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 ( + + + ← Voltar para Revendas + + } + actions={ + <> + navigate("/resellers")} type="button"> + Cancelar + + + Salvar + + > + } + /> + + {error ? ( + + {error} + + ) : null} + + {loading ? ( + + Carregando… + + ) : ( + + + + + updateField("name", e.target.value)} required /> + + updateField("document", e.target.value)} /> + updateField("contactEmail", e.target.value)} + /> + + Status + + updateField("active", e.target.checked)} + /> + Revenda ativa + + + + + + )} + + ); +} diff --git a/apps/core-web/src/routes/resellers/ResellersListPage.tsx b/apps/core-web/src/routes/resellers/ResellersListPage.tsx new file mode 100644 index 0000000..ef60cf8 --- /dev/null +++ b/apps/core-web/src/routes/resellers/ResellersListPage.tsx @@ -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(null); + const [error, setError] = useState(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> = [ + { key: "name", header: "Nome", render: (reseller) => {reseller.name} }, + { 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) => {reseller.active ? "Ativo" : "Inativo"}, + }, + { + key: "actions", + header: "", + className: "text-right", + render: (reseller) => ( + navigate(`/resellers/${reseller.id}/edit`)}> + Editar + + ), + }, + ]; + + return ( + + navigate("/resellers/new")}>Nova revenda} + /> + + {error ? ( + + {error} + + ) : null} + + + {resellers === null && !error ? ( + Carregando… + ) : ( + reseller.id} /> + )} + + + ); +} diff --git a/docs/progress.md b/docs/progress.md index 128f567..c8d34ad 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -294,3 +294,30 @@ Verificado: `pnpm turbo run typecheck build` (28/28), `pnpm --filter @eden/api t ### Pendências Toda a Assinatura Eletrônica (eden.md §1.4-1.9/§5/§6/§7 — envelopes, signatários, OTP, hash-chain, verificação pública), geração real de PDF (Playwright/Chromium) + sanitização de HTML, editor visual Tiptap real (`renderToHtml`/`resolveDocument` com condicionais e repetição de tabela), seed dos 7 templates reais de produção da Handix, regra de idempotência completa com `force`/`extra_fields`. + +--- + +## Fase 2/8 — Telas de negócio reais no EDEN Core (Empresas, Produtos, Revendas, CRM, Ofertas) + +Data: 2026-09-03. Usuário pediu explicitamente para priorizar a parte web ("ja faca a parte web para eu ja ver como esta ficando"). 2 subagentes `eden-frontend` em paralelo, mesma sessão, construindo sobre o shell autenticado + Design System já prontos. + +**Correção de instrução no meio da leva**: o usuário interrompeu para dizer "não esqueça de seguir o template" / "melhor usar ele" — as telas deviam seguir de perto o LAYOUT REAL do tema DreamsERP comprado, não só os tokens visuais abstratos já extraídos. Extraí do `tema_do_Eden.zip` as páginas React reais mais relevantes (`sales/salesQuotes*.tsx`, `crm/leads.tsx`/`deals*.tsx`, `inventory/products*.tsx`, `purchase/vendors.tsx`, `system/userManagement.tsx`, `ui-interface/{tables,forms}`) para dentro do worktree de cada agente (`.tmp/theme-reference/`, nunca versionado) e mandei mensagem de correção para os dois agentes já em execução — via `SendMessage`, não relançando a tarefa (no meio do processo cometi o erro de usar `Agent` em vez de `SendMessage` para a primeira correção, criando um agente duplicado; percebido e corrigido na hora com `TaskStop` antes de qualquer trabalho real ser feito por ele). Ambos incorporaram a referência do tema (estrutura de header/breadcrumb/tabela/formulário em cards) reescrita com `@eden/ui` + Tailwind puro — sem copiar JSX nem trazer dependências do tema (antd/Preline) para o projeto. + +### Empresas / Produtos / Revendas (`apps/core-web/src/routes/{companies,products,resellers}/`) + +CRUD completo (lista + criar/editar) para os 3 módulos, layout modelado em `purchase/vendors.tsx` (empresas/revendas) e `inventory/products.tsx` (produtos, com máscara de moeda para as 5 faixas de preço + implantação). Novo componente `Table` em `packages/ui` (tabela genérica reutilizável, sem dependência de terceiro). + +### CRM e Ofertas/Quotes (`apps/core-web/src/routes/{crm,quotes}/`) + +Leads (lista, criar/editar, converter em oportunidade) e Oportunidades (lista, criar/editar) modelados em `crm/leads.tsx`/`dealsDetails.tsx`. Ofertas — o módulo mais rico: `QuotesListPage`, `QuoteFormPage` (revenda + cliente + período de contrato + fidelidade reduzida opcional + lista dinâmica de itens produto/quantidade + desconto opcional + condição de pagamento, mostra os totais calculados pelo servidor antes de ir para o detalhe) e `QuoteDetailPage` (resumo financeiro completo, aprovar/rejeitar quando pendente, fechar negócio quando ainda é orçamento) — modelados em `sales/salesQuotes.tsx`/`addSalesQuotes.tsx`/`salesQuotesDetails.tsx`. Novo componente `Select` em `packages/ui`. + +**Achado real do próprio agente, corrigindo meu brief**: eu tinha dito no brief que as respostas de Leads/Opportunities eram snake_case (copiando o padrão do resto da API) — o agente verificou contra o código-fonte real (`packages/database/src/repositories/leads.ts`) e descobriu que esse módulo específico mapeia para **camelCase** na resposta (`mapLead`), diferente de Quotes/Companies/Products/Resellers (snake_case direto da linha do banco). Confirmado ao vivo via curl antes de integrar. + +### Integração manual (os 2 agentes trabalharam em paralelo no mesmo `core-web`) + +Um dos agentes não conseguiu `git merge` dentro do próprio sandbox e reconstruiu manualmente (leitura+escrita) o estado já existente do shell autenticado — comparei arquivo por arquivo contra `main` (`diff -rq`) para confirmar que a restauração foi fiel (nenhum componente restaurado divergiu) antes de aceitar qualquer coisa como "nova". `App.tsx`, `AuthenticatedLayout.tsx` (nav unificada, lógica de "ativo" por prefixo de path com borda de `/` para nunca casar `/companies` com `/companies-x`) e `packages/ui/src/index.ts` foram reconciliados manualmente juntando as rotas/exports dos dois agentes; `lib/format.ts` teve as duas versões mescladas (`formatDocument` de um lado, `formatDateBR` do outro). + +Verificado: `pnpm turbo run typecheck build` (28/28). Containers `eden-api`/`eden-core` reconstruídos (o build em background travou duas vezes por algum motivo de recurso do ambiente — refeito em primeiro plano, funcionou); bundle de produção confirmado contendo todas as novas telas; login simulando requisição real de browser (`Origin: http://localhost:3001`) funcionando; listagens de Empresas/Produtos/Revendas retornando dados reais; criação de lead confirmando o formato camelCase ao vivo. + +### Pendências +Client Registration (Cadastro de Cliente) ainda sem tela — `QuoteDetailPage` hoje exige colar manualmente um UUID para fechar negócio. Nenhuma tela de Contracts/Inventory/Support/HR-Timeclock/Fiscal/Billing/Documents ainda. `reseller-web`/`subscriber-web` continuam só com o shell de login (sem telas de negócio). diff --git a/packages/ui/src/components/Card.tsx b/packages/ui/src/components/Card.tsx index 18d3025..0321931 100644 --- a/packages/ui/src/components/Card.tsx +++ b/packages/ui/src/components/Card.tsx @@ -4,10 +4,12 @@ import { cn } from "../utils/cn.js"; export interface CardProps extends Omit, "title"> { title?: ReactNode; actions?: ReactNode; + /** Overrides the default `p-5` on the content wrapper — e.g. `"p-0"` for a flush data table (theme source: sales/salesQuotes.tsx uses `p-4 pb-0` around its table). */ + bodyClassName?: string; } /** Theme source (auth-pages/login.tsx): `bg-white border border-border-color rounded-md shadow-sm`. */ -export function Card({ title, actions, className, children, ...rest }: CardProps) { +export function Card({ title, actions, className, bodyClassName, children, ...rest }: CardProps) { return ( {title || actions ? ( @@ -16,7 +18,7 @@ export function Card({ title, actions, className, children, ...rest }: CardProps {actions ? {actions} : null} ) : null} - {children} + {children} ); } diff --git a/packages/ui/src/components/Select.tsx b/packages/ui/src/components/Select.tsx new file mode 100644 index 0000000..b97a50b --- /dev/null +++ b/packages/ui/src/components/Select.tsx @@ -0,0 +1,93 @@ +import { forwardRef, useId } from "react"; +import type { SelectHTMLAttributes } from "react"; +import { cn } from "../utils/cn.js"; + +export interface SelectOption { + value: string; + label: string; +} + +export interface SelectProps extends Omit, "children"> { + label?: string; + /** Validation/server error message — shown below the field, field border turns danger. */ + error?: string; + /** Helper text shown below the field when there's no error. */ + hint?: string; + options: SelectOption[]; + /** Rendered as a disabled, selected-by-default first option (e.g. "Selecione..."). */ + placeholder?: string; +} + +/** + * Native `` styled to match `Input` (packages/ui/src/components/Input.tsx) + * — same border/padding/focus-ring language as the rest of the Design + * System. Derived from the DreamsERP theme's form field sizing + * (`.tmp/theme-reference/sales/addSalesQuotes.tsx` etc. use a + * `react-select`-based CommonSelect for the same slots, out of our stack — + * this is a plain, dependency-free equivalent with the same visual sizing). + */ +export const Select = forwardRef(function Select( + { label, error, hint, id, className, options, placeholder, ...rest }, + ref, +) { + const generatedId = useId(); + const selectId = id ?? generatedId; + + return ( + + {label ? ( + + {label} + + ) : null} + + + {placeholder ? ( + + {placeholder} + + ) : null} + {options.map((option) => ( + + {option.label} + + ))} + + + + + + {error ? ( + + {error} + + ) : hint ? ( + + {hint} + + ) : null} + + ); +}); diff --git a/packages/ui/src/components/Table.tsx b/packages/ui/src/components/Table.tsx new file mode 100644 index 0000000..94c0bd4 --- /dev/null +++ b/packages/ui/src/components/Table.tsx @@ -0,0 +1,64 @@ +import type { ReactNode } from "react"; +import { cn } from "../utils/cn.js"; + +export interface TableColumn { + key: string; + header: ReactNode; + render: (row: T) => ReactNode; + /** Applied to both the header and body cells — e.g. `"text-right"` for numeric columns. */ + className?: string; +} + +export interface TableProps { + columns: Array>; + data: T[]; + rowKey: (row: T) => string; + /** Shown instead of the table body when `data` is empty (and not loading). */ + emptyMessage?: ReactNode; +} + +/** + * Plain data table styled to match the DreamsERP theme's list screens + * (react/src/pages/inventory/products.tsx et al.) without pulling in the + * theme's antd/react-window dependencies — just a semantic ``. + */ +export function Table({ columns, data, rowKey, emptyMessage = "Nenhum registro encontrado." }: TableProps) { + return ( + + + + + {columns.map((column) => ( + + {column.header} + + ))} + + + + {data.length === 0 ? ( + + + {emptyMessage} + + + ) : ( + data.map((row) => ( + + {columns.map((column) => ( + + {column.render(row)} + + ))} + + )) + )} + + + + ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index d0966ae..3190229 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -13,6 +13,12 @@ export type { ButtonProps, ButtonVariant, ButtonSize } from "./components/Button export { Input } from "./components/Input.js"; export type { InputProps } from "./components/Input.js"; +export { Select } from "./components/Select.js"; +export type { SelectProps, SelectOption } from "./components/Select.js"; + +export { Table } from "./components/Table.js"; +export type { TableProps, TableColumn } from "./components/Table.js"; + export { Card } from "./components/Card.js"; export type { CardProps } from "./components/Card.js";
{ + return apiRequest( + `/quotes/${id}/approval`, + { method: "PATCH", body: JSON.stringify(input) }, + { accessToken }, + ); +} + +export function closeQuoteDeal(id: string, input: CloseDealInput, accessToken: string): Promise { + return apiRequest(`/quotes/${id}/close`, { method: "POST", body: JSON.stringify(input) }, { accessToken }); +} diff --git a/apps/core-web/src/lib/api/resellers.ts b/apps/core-web/src/lib/api/resellers.ts new file mode 100644 index 0000000..e2aa409 --- /dev/null +++ b/apps/core-web/src/lib/api/resellers.ts @@ -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; + +export function listResellers(accessToken: string): Promise { + return apiRequest("/resellers", { method: "GET" }, { accessToken }); +} + +export function getReseller(id: string, accessToken: string): Promise { + return apiRequest(`/resellers/${id}`, { method: "GET" }, { accessToken }); +} + +export function createReseller(input: CreateResellerInput, accessToken: string): Promise { + return apiRequest("/resellers", { method: "POST", body: JSON.stringify(input) }, { accessToken }); +} + +export function updateReseller( + id: string, + input: UpdateResellerInput, + accessToken: string, +): Promise { + return apiRequest( + `/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 }, + ); +} diff --git a/apps/core-web/src/lib/format.ts b/apps/core-web/src/lib/format.ts new file mode 100644 index 0000000..c30f27b --- /dev/null +++ b/apps/core-web/src/lib/format.ts @@ -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; +} diff --git a/apps/core-web/src/routes/AuthenticatedLayout.tsx b/apps/core-web/src/routes/AuthenticatedLayout.tsx index c2d385b..3efa306 100644 --- a/apps/core-web/src/routes/AuthenticatedLayout.tsx +++ b/apps/core-web/src/routes/AuthenticatedLayout.tsx @@ -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() { (null); + const [error, setError] = useState(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> = [ + { + key: "name", + header: "Nome", + render: (company) => ( + + {company.company_name} + {company.trade_name ? {company.trade_name} : null} + + ), + }, + { 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) => ( + {company.is_active ? "Ativo" : "Inativo"} + ), + }, + { + key: "actions", + header: "", + className: "text-right", + render: (company) => ( + navigate(`/companies/${company.id}/edit`)}> + Editar + + ), + }, + ]; + + return ( + + navigate("/companies/new")}>Nova empresa} + /> + + {error ? ( + + {error} + + ) : null} + + + {companies === null && !error ? ( + Carregando… + ) : ( + company.id} /> + )} + + + ); +} diff --git a/apps/core-web/src/routes/companies/CompanyFormPage.tsx b/apps/core-web/src/routes/companies/CompanyFormPage.tsx new file mode 100644 index 0000000..c69ae6d --- /dev/null +++ b/apps/core-web/src/routes/companies/CompanyFormPage.tsx @@ -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 { + 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(EMPTY_FORM); + const [loading, setLoading] = useState(isEditing); + const [error, setError] = useState(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(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 ( + + + ← Voltar para Empresas + + } + actions={ + <> + navigate("/companies")} type="button"> + Cancelar + + + Salvar + + > + } + /> + + {error ? ( + + {error} + + ) : null} + + {loading ? ( + + Carregando… + + ) : ( + + + + + updateField("companyName", e.target.value)} + required + /> + + updateField("tradeName", e.target.value)} + /> + updateField("cnpj", e.target.value)} /> + updateField("stateRegistration", e.target.value)} + /> + updateField("municipalRegistration", e.target.value)} + /> + {isEditing ? ( + + Status + + updateField("isActive", e.target.checked)} + /> + Empresa ativa + + + ) : null} + + + + + + updateField("addressZip", e.target.value)} /> + + updateField("addressStreet", e.target.value)} + /> + + updateField("addressNumber", e.target.value)} + /> + updateField("addressComplement", e.target.value)} + /> + updateField("addressNeighborhood", e.target.value)} + /> + updateField("addressCity", e.target.value)} + /> + updateField("addressState", e.target.value.toUpperCase())} + /> + updateField("addressCountry", e.target.value)} + placeholder="Brasil" + /> + + + + + + updateField("phone", e.target.value)} /> + updateField("email", e.target.value)} + /> + updateField("website", e.target.value)} /> + + + + + + updateField("legalRepName", e.target.value)} + /> + updateField("legalRepCpf", e.target.value)} + /> + updateField("legalRepRole", e.target.value)} + /> + updateField("legalRepEmail", e.target.value)} + /> + + + + )} + + ); +} diff --git a/apps/core-web/src/routes/crm/ConvertLeadPage.tsx b/apps/core-web/src/routes/crm/ConvertLeadPage.tsx new file mode 100644 index 0000000..d914c51 --- /dev/null +++ b/apps/core-web/src/routes/crm/ConvertLeadPage.tsx @@ -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 = { + 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(null); + const [title, setTitle] = useState(""); + const [stage, setStage] = useState("prospecting"); + const [probability, setProbability] = useState(""); + const [notes, setNotes] = useState(""); + + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(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 ( + + + + + {error ? ( + + {error} + + ) : null} + + + {loading ? ( + Carregando lead… + ) : ( + + setTitle(e.target.value)} + required + autoFocus + /> + setStage(e.target.value as OpportunityStage)} + /> + setProbability(e.target.value)} + /> + + Notas + setNotes(e.target.value)} + /> + + + + Converter + + navigate("/leads")}> + Cancelar + + + + )} + + + ); +} diff --git a/apps/core-web/src/routes/crm/CrmSubNav.tsx b/apps/core-web/src/routes/crm/CrmSubNav.tsx new file mode 100644 index 0000000..03d5697 --- /dev/null +++ b/apps/core-web/src/routes/crm/CrmSubNav.tsx @@ -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 ( + + + Leads + + + Oportunidades + + + ); +} diff --git a/apps/core-web/src/routes/crm/LeadFormPage.tsx b/apps/core-web/src/routes/crm/LeadFormPage.tsx new file mode 100644 index 0000000..0d41695 --- /dev/null +++ b/apps/core-web/src/routes/crm/LeadFormPage.tsx @@ -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 = { + 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("new"); + const [notes, setNotes] = useState(""); + + const [loading, setLoading] = useState(isEdit); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(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 ( + + + + + {error ? ( + + {error} + + ) : null} + + + {loading ? ( + Carregando lead… + ) : ( + + setContactName(e.target.value)} + required + autoFocus + /> + setCompanyName(e.target.value)} /> + setContactEmail(e.target.value)} + /> + setContactPhone(e.target.value)} /> + setSource(e.target.value)} hint="Ex.: Site, indicação, campanha" /> + setStatus(e.target.value as LeadStatus)} + /> + + Notas + setNotes(e.target.value)} + /> + + + + {isEdit ? "Salvar alterações" : "Criar lead"} + + navigate("/leads")}> + Cancelar + + + + )} + + + ); +} diff --git a/apps/core-web/src/routes/crm/LeadsListPage.tsx b/apps/core-web/src/routes/crm/LeadsListPage.tsx new file mode 100644 index 0000000..e8ca3ab --- /dev/null +++ b/apps/core-web/src/routes/crm/LeadsListPage.tsx @@ -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 = { + new: "Novo", + contacted: "Contatado", + qualified: "Qualificado", + converted: "Convertido", + discarded: "Descartado", +}; + +const STATUS_VARIANT: Record = { + new: "info", + contacted: "warning", + qualified: "primary", + converted: "success", + discarded: "danger", +}; + +export function LeadsListPage() { + const { accessToken } = useAuth(); + const [leads, setLeads] = useState(null); + const [error, setError] = useState(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 ( + + + + Novo lead + + } + /> + + {error ? ( + + {error} + + ) : null} + + + {leads === null && !error ? ( + Carregando leads… + ) : leads && leads.length === 0 ? ( + Nenhum lead cadastrado ainda. + ) : leads ? ( + + + + + Contato + Empresa + Status + Ações + + + + {leads.map((lead) => { + const canConvert = lead.status !== "converted" && lead.status !== "discarded"; + return ( + + + + {lead.contactName} + + {lead.contactEmail ? {lead.contactEmail} : null} + + {lead.companyName ?? "—"} + + + {STATUS_LABEL[lead.status] ?? lead.status} + + + + + + Editar + + {canConvert ? ( + + Converter em oportunidade + + ) : null} + + + + ); + })} + + + + ) : null} + + + ); +} diff --git a/apps/core-web/src/routes/crm/OpportunitiesListPage.tsx b/apps/core-web/src/routes/crm/OpportunitiesListPage.tsx new file mode 100644 index 0000000..7e83021 --- /dev/null +++ b/apps/core-web/src/routes/crm/OpportunitiesListPage.tsx @@ -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 = { + prospecting: "Prospecção", + qualification: "Qualificação", + proposal: "Proposta", + negotiation: "Negociação", + won: "Ganha", + lost: "Perdida", +}; + +const STAGE_VARIANT: Record = { + prospecting: "info", + qualification: "primary", + proposal: "warning", + negotiation: "secondary", + won: "success", + lost: "danger", +}; + +export function OpportunitiesListPage() { + const { accessToken } = useAuth(); + const [opportunities, setOpportunities] = useState(null); + const [error, setError] = useState(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 ( + + + + Nova oportunidade + + } + /> + + {error ? ( + + {error} + + ) : null} + + + {opportunities === null && !error ? ( + Carregando oportunidades… + ) : opportunities && opportunities.length === 0 ? ( + Nenhuma oportunidade cadastrada ainda. + ) : opportunities ? ( + + + + + Título + Estágio + Probabilidade + Dono + Ações + + + + {opportunities.map((opportunity) => ( + + + + {opportunity.title} + + + + + {STAGE_LABEL[opportunity.stage] ?? opportunity.stage} + + + + {opportunity.probability != null ? `${opportunity.probability}%` : "—"} + + {opportunity.ownerId ?? "—"} + + + Editar + + + + ))} + + + + ) : null} + + + ); +} diff --git a/apps/core-web/src/routes/crm/OpportunityFormPage.tsx b/apps/core-web/src/routes/crm/OpportunityFormPage.tsx new file mode 100644 index 0000000..8fe8042 --- /dev/null +++ b/apps/core-web/src/routes/crm/OpportunityFormPage.tsx @@ -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 = { + 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("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(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 ( + + + + + {error ? ( + + {error} + + ) : null} + + + {loading ? ( + Carregando oportunidade… + ) : ( + + setTitle(e.target.value)} + required + autoFocus + /> + setStage(e.target.value as OpportunityStage)} + /> + setProbability(e.target.value)} + /> + {stage === "lost" ? ( + setLossReason(e.target.value)} + /> + ) : null} + + Notas + setNotes(e.target.value)} + /> + + + + {isEdit ? "Salvar alterações" : "Criar oportunidade"} + + navigate("/opportunities")}> + Cancelar + + + + )} + + + ); +} diff --git a/apps/core-web/src/routes/products/ProductFormPage.tsx b/apps/core-web/src/routes/products/ProductFormPage.tsx new file mode 100644 index 0000000..8c8d9b9 --- /dev/null +++ b/apps/core-web/src/routes/products/ProductFormPage.tsx @@ -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 ( + 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(EMPTY_FORM); + const [loading, setLoading] = useState(isEditing); + const [error, setError] = useState(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(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 ( + + + ← Voltar para Produtos + + } + actions={ + <> + navigate("/products")} type="button"> + Cancelar + + + Salvar + + > + } + /> + + {error ? ( + + {error} + + ) : null} + + {loading ? ( + + Carregando… + + ) : ( + + + + + updateField("name", e.target.value)} required /> + + updateField("productType", e.target.value)} + required + /> + updateField("unit", e.target.value)} /> + updateField("productGroup", e.target.value)} + /> + updateField("productSubgroup", e.target.value)} + /> + updateField("brand", e.target.value)} /> + + + Descrição + + updateField("description", e.target.value)} + /> + + + + + + + updateField("price0Cents", v)} /> + updateField("price12Cents", v)} /> + updateField("price24Cents", v)} /> + updateField("price36Cents", v)} /> + updateField("price48Cents", v)} /> + updateField("implUnitPriceCents", v)} + /> + + + + + + {( + [ + ["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]) => ( + + updateField(field, e.target.checked)} + /> + {label} + + ))} + + + + )} + + ); +} diff --git a/apps/core-web/src/routes/products/ProductsListPage.tsx b/apps/core-web/src/routes/products/ProductsListPage.tsx new file mode 100644 index 0000000..14deccb --- /dev/null +++ b/apps/core-web/src/routes/products/ProductsListPage.tsx @@ -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(null); + const [error, setError] = useState(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> = [ + { key: "code", header: "Código", render: (product) => `#${product.product_code}` }, + { key: "name", header: "Nome", render: (product) => {product.name} }, + { key: "type", header: "Tipo", render: (product) => product.product_type }, + ...PRICE_COLUMNS.map( + (column): TableColumn => ({ + 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 ? ( + Descontinuado + ) : ( + {product.active ? "Ativo" : "Inativo"} + ), + }, + { + key: "actions", + header: "", + className: "text-right", + render: (product) => ( + navigate(`/products/${product.id}/edit`)}> + Editar + + ), + }, + ]; + + return ( + + navigate("/products/new")}>Novo produto} + /> + + {error ? ( + + {error} + + ) : null} + + + {products === null && !error ? ( + Carregando… + ) : ( + product.id} /> + )} + + + ); +} diff --git a/apps/core-web/src/routes/quotes/QuoteDetailPage.tsx b/apps/core-web/src/routes/quotes/QuoteDetailPage.tsx new file mode 100644 index 0000000..bec95a5 --- /dev/null +++ b/apps/core-web/src/routes/quotes/QuoteDetailPage.tsx @@ -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 = { + none: "Sem aprovação", + pending: "Pendente", + approved: "Aprovada", + rejected: "Rejeitada", +}; + +const APPROVAL_VARIANT: Record = { + none: "neutral", + pending: "warning", + approved: "success", + rejected: "danger", +}; + +const DEAL_LABEL: Record = { + orcamento: "Orçamento", + fechado: "Fechado", + perdido: "Perdido", +}; + +const DEAL_VARIANT: Record = { + orcamento: "info", + fechado: "success", + perdido: "danger", +}; + +export function QuoteDetailPage() { + const { id } = useParams<{ id: string }>(); + const { accessToken } = useAuth(); + + const [quote, setQuote] = useState(null); + const [reseller, setReseller] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( + + + Carregando oferta… + + ); + } + + if (error || !quote) { + return ( + + + {error ?? "Oferta não encontrada."} + + ); + } + + return ( + + + + {APPROVAL_LABEL[quote.approval_status]} + {DEAL_LABEL[quote.deal_status]} + + } + /> + + + + + + + + + + + + + + + + + + + + + + + + + Produto + Qtd. + Preço unit. + Total + Impl. unit. + Impl. total + + + + {quote.items.map((item, index) => ( + + {item.productName} + {item.quantity} + {formatCurrencyBRL(item.unitPrice)} + {formatCurrencyBRL(item.total)} + {formatCurrencyBRL(item.implUnitPrice)} + {formatCurrencyBRL(item.implTotal)} + + ))} + + + + + + + + + {quote.proposed_monthly_total != null ? ( + + ) : null} + + + + + {quote.notes ? ( + + Notas + {quote.notes} + + ) : null} + + + {quote.approval_status === "pending" ? ( + + ) : quote.approval_status !== "none" && quote.approval_notes ? ( + + {quote.approval_notes} + + ) : null} + + {quote.deal_status === "orcamento" ? ( + + ) : quote.client_registration_id ? ( + + + + ) : null} + + + + ); +} + +function Row({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ); +} + +function SummaryRow({ label, value, highlight }: { label: string; value: string; highlight?: boolean }) { + return ( + + {label} + + {value} + + + ); +} + +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(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 ( + + {error ? ( + + {error} + + ) : null} + + Esta oferta tem desconto e/ou fidelidade reduzida e precisa de aprovação antes de seguir. + + setNotes(e.target.value)} + className="mb-3" + /> + + decide("approved")} loading={submitting === "approved"}> + Aprovar + + decide("rejected")} loading={submitting === "rejected"}> + Rejeitar + + + + ); +} + +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(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 ( + + {error ? ( + + {error} + + ) : null} + + setClientRegistrationId(e.target.value)} + hint="Cole o UUID de um cadastro de cliente já existente" + className="min-w-72 flex-1" + required + /> + + Fechar negócio + + + + ); +} diff --git a/apps/core-web/src/routes/quotes/QuoteFormPage.tsx b/apps/core-web/src/routes/quotes/QuoteFormPage.tsx new file mode 100644 index 0000000..1ac1f6e --- /dev/null +++ b/apps/core-web/src/routes/quotes/QuoteFormPage.tsx @@ -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(null); + const [products, setProducts] = useState(null); + const [loadError, setLoadError] = useState(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("12"); + const [fidelityPeriod, setFidelityPeriod] = useState(""); + const [items, setItems] = useState([{ ...EMPTY_ROW }]); + const [implGeral, setImplGeral] = useState(""); + const [proposedMonthlyTotal, setProposedMonthlyTotal] = useState(""); + const [implPaymentCondition, setImplPaymentCondition] = useState(""); + const [notes, setNotes] = useState(""); + + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [createdQuote, setCreatedQuote] = useState(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) { + 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 ( + + + + Oferta criada com sucesso. Confira os totais calculados pelo servidor abaixo. + + + + + + + + {createdQuote.approval_status === "pending" ? ( + + Esta oferta possui desconto e/ou fidelidade reduzida — aguarda aprovação. + + ) : null} + + + Ver oferta completa + + navigate("/quotes")}> + Voltar para a lista + + + + + ); + } + + return ( + + + + + {loadError ? ( + + {loadError} + + ) : null} + {error ? ( + + {error} + + ) : null} + + + {!resellers || !products ? ( + Carregando revendas e produtos… + ) : ( + + + Dados do cliente + + ({ value: r.id, label: r.name }))} + placeholder="Selecione a revenda" + value={resellerId} + onChange={(e) => setResellerId(e.target.value)} + required + /> + setClientName(e.target.value)} required /> + setClientCompany(e.target.value)} /> + setClientEmail(e.target.value)} /> + setClientPhone(e.target.value)} required /> + + + + + Contrato + + ({ + value: String(period), + label: period === 0 ? "Sem fidelidade (0 meses)" : `${period} meses`, + }))} + value={contractPeriod} + onChange={(e) => setContractPeriod(e.target.value)} + required + /> + setFidelityPeriod(e.target.value)} + hint="Só preencha se for menor que o período do contrato — entra em aprovação" + /> + + + + + + Itens + + + Adicionar item + + + + + + + Produto + Quantidade + + + + + {items.map((row, index) => ( + + + updateItem(index, { productId: e.target.value })} + aria-label="Produto" + required + /> + + + updateItem(index, { quantity: e.target.value })} + aria-label="Quantidade" + required + /> + + + {items.length > 1 ? ( + removeItem(index)} + className="rounded-md border border-border-color px-2 py-1 text-xs font-semibold text-danger hover:bg-light" + > + Remover + + ) : null} + + + ))} + + + + + + + Implantação e valores + + setImplGeral(e.target.value)} + hint="Custo de implantação além dos produtos" + /> + setProposedMonthlyTotal(e.target.value)} + hint="Preencha só se for oferecer desconto — a oferta precisará de aprovação" + /> + + ({ + value: condition, + label: condition, + }))} + placeholder="Selecione a condição" + value={implPaymentCondition} + onChange={(e) => setImplPaymentCondition(e.target.value as ImplPaymentCondition)} + /> + + Faixas sugeridas para {formatCurrencyBRL(estimatedImplTotal)} de implantação estimada:{" "} + {availablePaymentConditions.map((c) => ( + + {c} + + ))} + {" "}— o servidor valida a faixa real ao salvar. + + + + + + + Notas + setNotes(e.target.value)} + /> + + + + + Criar oferta + + navigate("/quotes")}> + Cancelar + + + + )} + + + ); +} + +function SummaryField({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ); +} diff --git a/apps/core-web/src/routes/quotes/QuotesListPage.tsx b/apps/core-web/src/routes/quotes/QuotesListPage.tsx new file mode 100644 index 0000000..b1bc219 --- /dev/null +++ b/apps/core-web/src/routes/quotes/QuotesListPage.tsx @@ -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 = { + none: "Sem aprovação", + pending: "Pendente", + approved: "Aprovada", + rejected: "Rejeitada", +}; + +const APPROVAL_VARIANT: Record = { + none: "neutral", + pending: "warning", + approved: "success", + rejected: "danger", +}; + +const DEAL_LABEL: Record = { + orcamento: "Orçamento", + fechado: "Fechado", + perdido: "Perdido", +}; + +const DEAL_VARIANT: Record = { + orcamento: "info", + fechado: "success", + perdido: "danger", +}; + +export function QuotesListPage() { + const { accessToken } = useAuth(); + const [quotes, setQuotes] = useState(null); + const [error, setError] = useState(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 ( + + + Nova oferta + + } + /> + + {error ? ( + + {error} + + ) : null} + + + {quotes === null && !error ? ( + Carregando ofertas… + ) : quotes && quotes.length === 0 ? ( + Nenhuma oferta cadastrada ainda. + ) : quotes ? ( + + + + + Oferta + Cliente + Mensalidade + Aprovação + Negócio + + + + {quotes.map((quote) => ( + + + + {quote.quote_number} + + + + {quote.client_name} + {quote.client_company ? {quote.client_company} : null} + + + {formatCurrencyBRL(quote.proposed_monthly_total ?? quote.monthly_total)} + + + + {APPROVAL_LABEL[quote.approval_status]} + + + + {DEAL_LABEL[quote.deal_status]} + + + ))} + + + + ) : null} + + + ); +} diff --git a/apps/core-web/src/routes/resellers/ResellerFormPage.tsx b/apps/core-web/src/routes/resellers/ResellerFormPage.tsx new file mode 100644 index 0000000..be7c921 --- /dev/null +++ b/apps/core-web/src/routes/resellers/ResellerFormPage.tsx @@ -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(EMPTY_FORM); + const [loading, setLoading] = useState(isEditing); + const [error, setError] = useState(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(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 ( + + + ← Voltar para Revendas + + } + actions={ + <> + navigate("/resellers")} type="button"> + Cancelar + + + Salvar + + > + } + /> + + {error ? ( + + {error} + + ) : null} + + {loading ? ( + + Carregando… + + ) : ( + + + + + updateField("name", e.target.value)} required /> + + updateField("document", e.target.value)} /> + updateField("contactEmail", e.target.value)} + /> + + Status + + updateField("active", e.target.checked)} + /> + Revenda ativa + + + + + + )} + + ); +} diff --git a/apps/core-web/src/routes/resellers/ResellersListPage.tsx b/apps/core-web/src/routes/resellers/ResellersListPage.tsx new file mode 100644 index 0000000..ef60cf8 --- /dev/null +++ b/apps/core-web/src/routes/resellers/ResellersListPage.tsx @@ -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(null); + const [error, setError] = useState(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> = [ + { key: "name", header: "Nome", render: (reseller) => {reseller.name} }, + { 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) => {reseller.active ? "Ativo" : "Inativo"}, + }, + { + key: "actions", + header: "", + className: "text-right", + render: (reseller) => ( + navigate(`/resellers/${reseller.id}/edit`)}> + Editar + + ), + }, + ]; + + return ( + + navigate("/resellers/new")}>Nova revenda} + /> + + {error ? ( + + {error} + + ) : null} + + + {resellers === null && !error ? ( + Carregando… + ) : ( + reseller.id} /> + )} + + + ); +} diff --git a/docs/progress.md b/docs/progress.md index 128f567..c8d34ad 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -294,3 +294,30 @@ Verificado: `pnpm turbo run typecheck build` (28/28), `pnpm --filter @eden/api t ### Pendências Toda a Assinatura Eletrônica (eden.md §1.4-1.9/§5/§6/§7 — envelopes, signatários, OTP, hash-chain, verificação pública), geração real de PDF (Playwright/Chromium) + sanitização de HTML, editor visual Tiptap real (`renderToHtml`/`resolveDocument` com condicionais e repetição de tabela), seed dos 7 templates reais de produção da Handix, regra de idempotência completa com `force`/`extra_fields`. + +--- + +## Fase 2/8 — Telas de negócio reais no EDEN Core (Empresas, Produtos, Revendas, CRM, Ofertas) + +Data: 2026-09-03. Usuário pediu explicitamente para priorizar a parte web ("ja faca a parte web para eu ja ver como esta ficando"). 2 subagentes `eden-frontend` em paralelo, mesma sessão, construindo sobre o shell autenticado + Design System já prontos. + +**Correção de instrução no meio da leva**: o usuário interrompeu para dizer "não esqueça de seguir o template" / "melhor usar ele" — as telas deviam seguir de perto o LAYOUT REAL do tema DreamsERP comprado, não só os tokens visuais abstratos já extraídos. Extraí do `tema_do_Eden.zip` as páginas React reais mais relevantes (`sales/salesQuotes*.tsx`, `crm/leads.tsx`/`deals*.tsx`, `inventory/products*.tsx`, `purchase/vendors.tsx`, `system/userManagement.tsx`, `ui-interface/{tables,forms}`) para dentro do worktree de cada agente (`.tmp/theme-reference/`, nunca versionado) e mandei mensagem de correção para os dois agentes já em execução — via `SendMessage`, não relançando a tarefa (no meio do processo cometi o erro de usar `Agent` em vez de `SendMessage` para a primeira correção, criando um agente duplicado; percebido e corrigido na hora com `TaskStop` antes de qualquer trabalho real ser feito por ele). Ambos incorporaram a referência do tema (estrutura de header/breadcrumb/tabela/formulário em cards) reescrita com `@eden/ui` + Tailwind puro — sem copiar JSX nem trazer dependências do tema (antd/Preline) para o projeto. + +### Empresas / Produtos / Revendas (`apps/core-web/src/routes/{companies,products,resellers}/`) + +CRUD completo (lista + criar/editar) para os 3 módulos, layout modelado em `purchase/vendors.tsx` (empresas/revendas) e `inventory/products.tsx` (produtos, com máscara de moeda para as 5 faixas de preço + implantação). Novo componente `Table` em `packages/ui` (tabela genérica reutilizável, sem dependência de terceiro). + +### CRM e Ofertas/Quotes (`apps/core-web/src/routes/{crm,quotes}/`) + +Leads (lista, criar/editar, converter em oportunidade) e Oportunidades (lista, criar/editar) modelados em `crm/leads.tsx`/`dealsDetails.tsx`. Ofertas — o módulo mais rico: `QuotesListPage`, `QuoteFormPage` (revenda + cliente + período de contrato + fidelidade reduzida opcional + lista dinâmica de itens produto/quantidade + desconto opcional + condição de pagamento, mostra os totais calculados pelo servidor antes de ir para o detalhe) e `QuoteDetailPage` (resumo financeiro completo, aprovar/rejeitar quando pendente, fechar negócio quando ainda é orçamento) — modelados em `sales/salesQuotes.tsx`/`addSalesQuotes.tsx`/`salesQuotesDetails.tsx`. Novo componente `Select` em `packages/ui`. + +**Achado real do próprio agente, corrigindo meu brief**: eu tinha dito no brief que as respostas de Leads/Opportunities eram snake_case (copiando o padrão do resto da API) — o agente verificou contra o código-fonte real (`packages/database/src/repositories/leads.ts`) e descobriu que esse módulo específico mapeia para **camelCase** na resposta (`mapLead`), diferente de Quotes/Companies/Products/Resellers (snake_case direto da linha do banco). Confirmado ao vivo via curl antes de integrar. + +### Integração manual (os 2 agentes trabalharam em paralelo no mesmo `core-web`) + +Um dos agentes não conseguiu `git merge` dentro do próprio sandbox e reconstruiu manualmente (leitura+escrita) o estado já existente do shell autenticado — comparei arquivo por arquivo contra `main` (`diff -rq`) para confirmar que a restauração foi fiel (nenhum componente restaurado divergiu) antes de aceitar qualquer coisa como "nova". `App.tsx`, `AuthenticatedLayout.tsx` (nav unificada, lógica de "ativo" por prefixo de path com borda de `/` para nunca casar `/companies` com `/companies-x`) e `packages/ui/src/index.ts` foram reconciliados manualmente juntando as rotas/exports dos dois agentes; `lib/format.ts` teve as duas versões mescladas (`formatDocument` de um lado, `formatDateBR` do outro). + +Verificado: `pnpm turbo run typecheck build` (28/28). Containers `eden-api`/`eden-core` reconstruídos (o build em background travou duas vezes por algum motivo de recurso do ambiente — refeito em primeiro plano, funcionou); bundle de produção confirmado contendo todas as novas telas; login simulando requisição real de browser (`Origin: http://localhost:3001`) funcionando; listagens de Empresas/Produtos/Revendas retornando dados reais; criação de lead confirmando o formato camelCase ao vivo. + +### Pendências +Client Registration (Cadastro de Cliente) ainda sem tela — `QuoteDetailPage` hoje exige colar manualmente um UUID para fechar negócio. Nenhuma tela de Contracts/Inventory/Support/HR-Timeclock/Fiscal/Billing/Documents ainda. `reseller-web`/`subscriber-web` continuam só com o shell de login (sem telas de negócio). diff --git a/packages/ui/src/components/Card.tsx b/packages/ui/src/components/Card.tsx index 18d3025..0321931 100644 --- a/packages/ui/src/components/Card.tsx +++ b/packages/ui/src/components/Card.tsx @@ -4,10 +4,12 @@ import { cn } from "../utils/cn.js"; export interface CardProps extends Omit, "title"> { title?: ReactNode; actions?: ReactNode; + /** Overrides the default `p-5` on the content wrapper — e.g. `"p-0"` for a flush data table (theme source: sales/salesQuotes.tsx uses `p-4 pb-0` around its table). */ + bodyClassName?: string; } /** Theme source (auth-pages/login.tsx): `bg-white border border-border-color rounded-md shadow-sm`. */ -export function Card({ title, actions, className, children, ...rest }: CardProps) { +export function Card({ title, actions, className, bodyClassName, children, ...rest }: CardProps) { return ( {title || actions ? ( @@ -16,7 +18,7 @@ export function Card({ title, actions, className, children, ...rest }: CardProps {actions ? {actions} : null} ) : null} - {children} + {children} ); } diff --git a/packages/ui/src/components/Select.tsx b/packages/ui/src/components/Select.tsx new file mode 100644 index 0000000..b97a50b --- /dev/null +++ b/packages/ui/src/components/Select.tsx @@ -0,0 +1,93 @@ +import { forwardRef, useId } from "react"; +import type { SelectHTMLAttributes } from "react"; +import { cn } from "../utils/cn.js"; + +export interface SelectOption { + value: string; + label: string; +} + +export interface SelectProps extends Omit, "children"> { + label?: string; + /** Validation/server error message — shown below the field, field border turns danger. */ + error?: string; + /** Helper text shown below the field when there's no error. */ + hint?: string; + options: SelectOption[]; + /** Rendered as a disabled, selected-by-default first option (e.g. "Selecione..."). */ + placeholder?: string; +} + +/** + * Native `` styled to match `Input` (packages/ui/src/components/Input.tsx) + * — same border/padding/focus-ring language as the rest of the Design + * System. Derived from the DreamsERP theme's form field sizing + * (`.tmp/theme-reference/sales/addSalesQuotes.tsx` etc. use a + * `react-select`-based CommonSelect for the same slots, out of our stack — + * this is a plain, dependency-free equivalent with the same visual sizing). + */ +export const Select = forwardRef(function Select( + { label, error, hint, id, className, options, placeholder, ...rest }, + ref, +) { + const generatedId = useId(); + const selectId = id ?? generatedId; + + return ( + + {label ? ( + + {label} + + ) : null} + + + {placeholder ? ( + + {placeholder} + + ) : null} + {options.map((option) => ( + + {option.label} + + ))} + + + + + + {error ? ( + + {error} + + ) : hint ? ( + + {hint} + + ) : null} + + ); +}); diff --git a/packages/ui/src/components/Table.tsx b/packages/ui/src/components/Table.tsx new file mode 100644 index 0000000..94c0bd4 --- /dev/null +++ b/packages/ui/src/components/Table.tsx @@ -0,0 +1,64 @@ +import type { ReactNode } from "react"; +import { cn } from "../utils/cn.js"; + +export interface TableColumn { + key: string; + header: ReactNode; + render: (row: T) => ReactNode; + /** Applied to both the header and body cells — e.g. `"text-right"` for numeric columns. */ + className?: string; +} + +export interface TableProps { + columns: Array>; + data: T[]; + rowKey: (row: T) => string; + /** Shown instead of the table body when `data` is empty (and not loading). */ + emptyMessage?: ReactNode; +} + +/** + * Plain data table styled to match the DreamsERP theme's list screens + * (react/src/pages/inventory/products.tsx et al.) without pulling in the + * theme's antd/react-window dependencies — just a semantic ``. + */ +export function Table({ columns, data, rowKey, emptyMessage = "Nenhum registro encontrado." }: TableProps) { + return ( + + + + + {columns.map((column) => ( + + {column.header} + + ))} + + + + {data.length === 0 ? ( + + + {emptyMessage} + + + ) : ( + data.map((row) => ( + + {columns.map((column) => ( + + {column.render(row)} + + ))} + + )) + )} + + + + ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index d0966ae..3190229 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -13,6 +13,12 @@ export type { ButtonProps, ButtonVariant, ButtonSize } from "./components/Button export { Input } from "./components/Input.js"; export type { InputProps } from "./components/Input.js"; +export { Select } from "./components/Select.js"; +export type { SelectProps, SelectOption } from "./components/Select.js"; + +export { Table } from "./components/Table.js"; +export type { TableProps, TableColumn } from "./components/Table.js"; + export { Card } from "./components/Card.js"; export type { CardProps } from "./components/Card.js";
( + `/quotes/${id}/approval`, + { method: "PATCH", body: JSON.stringify(input) }, + { accessToken }, + ); +} + +export function closeQuoteDeal(id: string, input: CloseDealInput, accessToken: string): Promise { + return apiRequest(`/quotes/${id}/close`, { method: "POST", body: JSON.stringify(input) }, { accessToken }); +} diff --git a/apps/core-web/src/lib/api/resellers.ts b/apps/core-web/src/lib/api/resellers.ts new file mode 100644 index 0000000..e2aa409 --- /dev/null +++ b/apps/core-web/src/lib/api/resellers.ts @@ -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; + +export function listResellers(accessToken: string): Promise { + return apiRequest("/resellers", { method: "GET" }, { accessToken }); +} + +export function getReseller(id: string, accessToken: string): Promise { + return apiRequest(`/resellers/${id}`, { method: "GET" }, { accessToken }); +} + +export function createReseller(input: CreateResellerInput, accessToken: string): Promise { + return apiRequest("/resellers", { method: "POST", body: JSON.stringify(input) }, { accessToken }); +} + +export function updateReseller( + id: string, + input: UpdateResellerInput, + accessToken: string, +): Promise { + return apiRequest( + `/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 }, + ); +} diff --git a/apps/core-web/src/lib/format.ts b/apps/core-web/src/lib/format.ts new file mode 100644 index 0000000..c30f27b --- /dev/null +++ b/apps/core-web/src/lib/format.ts @@ -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; +} diff --git a/apps/core-web/src/routes/AuthenticatedLayout.tsx b/apps/core-web/src/routes/AuthenticatedLayout.tsx index c2d385b..3efa306 100644 --- a/apps/core-web/src/routes/AuthenticatedLayout.tsx +++ b/apps/core-web/src/routes/AuthenticatedLayout.tsx @@ -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() { (null); + const [error, setError] = useState(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> = [ + { + key: "name", + header: "Nome", + render: (company) => ( + + {company.company_name} + {company.trade_name ? {company.trade_name} : null} + + ), + }, + { 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) => ( + {company.is_active ? "Ativo" : "Inativo"} + ), + }, + { + key: "actions", + header: "", + className: "text-right", + render: (company) => ( + navigate(`/companies/${company.id}/edit`)}> + Editar + + ), + }, + ]; + + return ( + + navigate("/companies/new")}>Nova empresa} + /> + + {error ? ( + + {error} + + ) : null} + + + {companies === null && !error ? ( + Carregando… + ) : ( + company.id} /> + )} + + + ); +} diff --git a/apps/core-web/src/routes/companies/CompanyFormPage.tsx b/apps/core-web/src/routes/companies/CompanyFormPage.tsx new file mode 100644 index 0000000..c69ae6d --- /dev/null +++ b/apps/core-web/src/routes/companies/CompanyFormPage.tsx @@ -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 { + 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(EMPTY_FORM); + const [loading, setLoading] = useState(isEditing); + const [error, setError] = useState(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(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 ( + + + ← Voltar para Empresas + + } + actions={ + <> + navigate("/companies")} type="button"> + Cancelar + + + Salvar + + > + } + /> + + {error ? ( + + {error} + + ) : null} + + {loading ? ( + + Carregando… + + ) : ( + + + + + updateField("companyName", e.target.value)} + required + /> + + updateField("tradeName", e.target.value)} + /> + updateField("cnpj", e.target.value)} /> + updateField("stateRegistration", e.target.value)} + /> + updateField("municipalRegistration", e.target.value)} + /> + {isEditing ? ( + + Status + + updateField("isActive", e.target.checked)} + /> + Empresa ativa + + + ) : null} + + + + + + updateField("addressZip", e.target.value)} /> + + updateField("addressStreet", e.target.value)} + /> + + updateField("addressNumber", e.target.value)} + /> + updateField("addressComplement", e.target.value)} + /> + updateField("addressNeighborhood", e.target.value)} + /> + updateField("addressCity", e.target.value)} + /> + updateField("addressState", e.target.value.toUpperCase())} + /> + updateField("addressCountry", e.target.value)} + placeholder="Brasil" + /> + + + + + + updateField("phone", e.target.value)} /> + updateField("email", e.target.value)} + /> + updateField("website", e.target.value)} /> + + + + + + updateField("legalRepName", e.target.value)} + /> + updateField("legalRepCpf", e.target.value)} + /> + updateField("legalRepRole", e.target.value)} + /> + updateField("legalRepEmail", e.target.value)} + /> + + + + )} + + ); +} diff --git a/apps/core-web/src/routes/crm/ConvertLeadPage.tsx b/apps/core-web/src/routes/crm/ConvertLeadPage.tsx new file mode 100644 index 0000000..d914c51 --- /dev/null +++ b/apps/core-web/src/routes/crm/ConvertLeadPage.tsx @@ -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 = { + 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(null); + const [title, setTitle] = useState(""); + const [stage, setStage] = useState("prospecting"); + const [probability, setProbability] = useState(""); + const [notes, setNotes] = useState(""); + + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(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 ( + + + + + {error ? ( + + {error} + + ) : null} + + + {loading ? ( + Carregando lead… + ) : ( + + setTitle(e.target.value)} + required + autoFocus + /> + setStage(e.target.value as OpportunityStage)} + /> + setProbability(e.target.value)} + /> + + Notas + setNotes(e.target.value)} + /> + + + + Converter + + navigate("/leads")}> + Cancelar + + + + )} + + + ); +} diff --git a/apps/core-web/src/routes/crm/CrmSubNav.tsx b/apps/core-web/src/routes/crm/CrmSubNav.tsx new file mode 100644 index 0000000..03d5697 --- /dev/null +++ b/apps/core-web/src/routes/crm/CrmSubNav.tsx @@ -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 ( + + + Leads + + + Oportunidades + + + ); +} diff --git a/apps/core-web/src/routes/crm/LeadFormPage.tsx b/apps/core-web/src/routes/crm/LeadFormPage.tsx new file mode 100644 index 0000000..0d41695 --- /dev/null +++ b/apps/core-web/src/routes/crm/LeadFormPage.tsx @@ -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 = { + 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("new"); + const [notes, setNotes] = useState(""); + + const [loading, setLoading] = useState(isEdit); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(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 ( + + + + + {error ? ( + + {error} + + ) : null} + + + {loading ? ( + Carregando lead… + ) : ( + + setContactName(e.target.value)} + required + autoFocus + /> + setCompanyName(e.target.value)} /> + setContactEmail(e.target.value)} + /> + setContactPhone(e.target.value)} /> + setSource(e.target.value)} hint="Ex.: Site, indicação, campanha" /> + setStatus(e.target.value as LeadStatus)} + /> + + Notas + setNotes(e.target.value)} + /> + + + + {isEdit ? "Salvar alterações" : "Criar lead"} + + navigate("/leads")}> + Cancelar + + + + )} + + + ); +} diff --git a/apps/core-web/src/routes/crm/LeadsListPage.tsx b/apps/core-web/src/routes/crm/LeadsListPage.tsx new file mode 100644 index 0000000..e8ca3ab --- /dev/null +++ b/apps/core-web/src/routes/crm/LeadsListPage.tsx @@ -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 = { + new: "Novo", + contacted: "Contatado", + qualified: "Qualificado", + converted: "Convertido", + discarded: "Descartado", +}; + +const STATUS_VARIANT: Record = { + new: "info", + contacted: "warning", + qualified: "primary", + converted: "success", + discarded: "danger", +}; + +export function LeadsListPage() { + const { accessToken } = useAuth(); + const [leads, setLeads] = useState(null); + const [error, setError] = useState(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 ( + + + + Novo lead + + } + /> + + {error ? ( + + {error} + + ) : null} + + + {leads === null && !error ? ( + Carregando leads… + ) : leads && leads.length === 0 ? ( + Nenhum lead cadastrado ainda. + ) : leads ? ( + + + + + Contato + Empresa + Status + Ações + + + + {leads.map((lead) => { + const canConvert = lead.status !== "converted" && lead.status !== "discarded"; + return ( + + + + {lead.contactName} + + {lead.contactEmail ? {lead.contactEmail} : null} + + {lead.companyName ?? "—"} + + + {STATUS_LABEL[lead.status] ?? lead.status} + + + + + + Editar + + {canConvert ? ( + + Converter em oportunidade + + ) : null} + + + + ); + })} + + + + ) : null} + + + ); +} diff --git a/apps/core-web/src/routes/crm/OpportunitiesListPage.tsx b/apps/core-web/src/routes/crm/OpportunitiesListPage.tsx new file mode 100644 index 0000000..7e83021 --- /dev/null +++ b/apps/core-web/src/routes/crm/OpportunitiesListPage.tsx @@ -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 = { + prospecting: "Prospecção", + qualification: "Qualificação", + proposal: "Proposta", + negotiation: "Negociação", + won: "Ganha", + lost: "Perdida", +}; + +const STAGE_VARIANT: Record = { + prospecting: "info", + qualification: "primary", + proposal: "warning", + negotiation: "secondary", + won: "success", + lost: "danger", +}; + +export function OpportunitiesListPage() { + const { accessToken } = useAuth(); + const [opportunities, setOpportunities] = useState(null); + const [error, setError] = useState(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 ( + + + + Nova oportunidade + + } + /> + + {error ? ( + + {error} + + ) : null} + + + {opportunities === null && !error ? ( + Carregando oportunidades… + ) : opportunities && opportunities.length === 0 ? ( + Nenhuma oportunidade cadastrada ainda. + ) : opportunities ? ( + + + + + Título + Estágio + Probabilidade + Dono + Ações + + + + {opportunities.map((opportunity) => ( + + + + {opportunity.title} + + + + + {STAGE_LABEL[opportunity.stage] ?? opportunity.stage} + + + + {opportunity.probability != null ? `${opportunity.probability}%` : "—"} + + {opportunity.ownerId ?? "—"} + + + Editar + + + + ))} + + + + ) : null} + + + ); +} diff --git a/apps/core-web/src/routes/crm/OpportunityFormPage.tsx b/apps/core-web/src/routes/crm/OpportunityFormPage.tsx new file mode 100644 index 0000000..8fe8042 --- /dev/null +++ b/apps/core-web/src/routes/crm/OpportunityFormPage.tsx @@ -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 = { + 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("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(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 ( + + + + + {error ? ( + + {error} + + ) : null} + + + {loading ? ( + Carregando oportunidade… + ) : ( + + setTitle(e.target.value)} + required + autoFocus + /> + setStage(e.target.value as OpportunityStage)} + /> + setProbability(e.target.value)} + /> + {stage === "lost" ? ( + setLossReason(e.target.value)} + /> + ) : null} + + Notas + setNotes(e.target.value)} + /> + + + + {isEdit ? "Salvar alterações" : "Criar oportunidade"} + + navigate("/opportunities")}> + Cancelar + + + + )} + + + ); +} diff --git a/apps/core-web/src/routes/products/ProductFormPage.tsx b/apps/core-web/src/routes/products/ProductFormPage.tsx new file mode 100644 index 0000000..8c8d9b9 --- /dev/null +++ b/apps/core-web/src/routes/products/ProductFormPage.tsx @@ -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 ( + 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(EMPTY_FORM); + const [loading, setLoading] = useState(isEditing); + const [error, setError] = useState(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(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 ( + + + ← Voltar para Produtos + + } + actions={ + <> + navigate("/products")} type="button"> + Cancelar + + + Salvar + + > + } + /> + + {error ? ( + + {error} + + ) : null} + + {loading ? ( + + Carregando… + + ) : ( + + + + + updateField("name", e.target.value)} required /> + + updateField("productType", e.target.value)} + required + /> + updateField("unit", e.target.value)} /> + updateField("productGroup", e.target.value)} + /> + updateField("productSubgroup", e.target.value)} + /> + updateField("brand", e.target.value)} /> + + + Descrição + + updateField("description", e.target.value)} + /> + + + + + + + updateField("price0Cents", v)} /> + updateField("price12Cents", v)} /> + updateField("price24Cents", v)} /> + updateField("price36Cents", v)} /> + updateField("price48Cents", v)} /> + updateField("implUnitPriceCents", v)} + /> + + + + + + {( + [ + ["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]) => ( + + updateField(field, e.target.checked)} + /> + {label} + + ))} + + + + )} + + ); +} diff --git a/apps/core-web/src/routes/products/ProductsListPage.tsx b/apps/core-web/src/routes/products/ProductsListPage.tsx new file mode 100644 index 0000000..14deccb --- /dev/null +++ b/apps/core-web/src/routes/products/ProductsListPage.tsx @@ -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(null); + const [error, setError] = useState(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> = [ + { key: "code", header: "Código", render: (product) => `#${product.product_code}` }, + { key: "name", header: "Nome", render: (product) => {product.name} }, + { key: "type", header: "Tipo", render: (product) => product.product_type }, + ...PRICE_COLUMNS.map( + (column): TableColumn => ({ + 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 ? ( + Descontinuado + ) : ( + {product.active ? "Ativo" : "Inativo"} + ), + }, + { + key: "actions", + header: "", + className: "text-right", + render: (product) => ( + navigate(`/products/${product.id}/edit`)}> + Editar + + ), + }, + ]; + + return ( + + navigate("/products/new")}>Novo produto} + /> + + {error ? ( + + {error} + + ) : null} + + + {products === null && !error ? ( + Carregando… + ) : ( + product.id} /> + )} + + + ); +} diff --git a/apps/core-web/src/routes/quotes/QuoteDetailPage.tsx b/apps/core-web/src/routes/quotes/QuoteDetailPage.tsx new file mode 100644 index 0000000..bec95a5 --- /dev/null +++ b/apps/core-web/src/routes/quotes/QuoteDetailPage.tsx @@ -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 = { + none: "Sem aprovação", + pending: "Pendente", + approved: "Aprovada", + rejected: "Rejeitada", +}; + +const APPROVAL_VARIANT: Record = { + none: "neutral", + pending: "warning", + approved: "success", + rejected: "danger", +}; + +const DEAL_LABEL: Record = { + orcamento: "Orçamento", + fechado: "Fechado", + perdido: "Perdido", +}; + +const DEAL_VARIANT: Record = { + orcamento: "info", + fechado: "success", + perdido: "danger", +}; + +export function QuoteDetailPage() { + const { id } = useParams<{ id: string }>(); + const { accessToken } = useAuth(); + + const [quote, setQuote] = useState(null); + const [reseller, setReseller] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( + + + Carregando oferta… + + ); + } + + if (error || !quote) { + return ( + + + {error ?? "Oferta não encontrada."} + + ); + } + + return ( + + + + {APPROVAL_LABEL[quote.approval_status]} + {DEAL_LABEL[quote.deal_status]} + + } + /> + + + + + + + + + + + + + + + + + + + + + + + + + Produto + Qtd. + Preço unit. + Total + Impl. unit. + Impl. total + + + + {quote.items.map((item, index) => ( + + {item.productName} + {item.quantity} + {formatCurrencyBRL(item.unitPrice)} + {formatCurrencyBRL(item.total)} + {formatCurrencyBRL(item.implUnitPrice)} + {formatCurrencyBRL(item.implTotal)} + + ))} + + + + + + + + + {quote.proposed_monthly_total != null ? ( + + ) : null} + + + + + {quote.notes ? ( + + Notas + {quote.notes} + + ) : null} + + + {quote.approval_status === "pending" ? ( + + ) : quote.approval_status !== "none" && quote.approval_notes ? ( + + {quote.approval_notes} + + ) : null} + + {quote.deal_status === "orcamento" ? ( + + ) : quote.client_registration_id ? ( + + + + ) : null} + + + + ); +} + +function Row({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ); +} + +function SummaryRow({ label, value, highlight }: { label: string; value: string; highlight?: boolean }) { + return ( + + {label} + + {value} + + + ); +} + +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(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 ( + + {error ? ( + + {error} + + ) : null} + + Esta oferta tem desconto e/ou fidelidade reduzida e precisa de aprovação antes de seguir. + + setNotes(e.target.value)} + className="mb-3" + /> + + decide("approved")} loading={submitting === "approved"}> + Aprovar + + decide("rejected")} loading={submitting === "rejected"}> + Rejeitar + + + + ); +} + +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(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 ( + + {error ? ( + + {error} + + ) : null} + + setClientRegistrationId(e.target.value)} + hint="Cole o UUID de um cadastro de cliente já existente" + className="min-w-72 flex-1" + required + /> + + Fechar negócio + + + + ); +} diff --git a/apps/core-web/src/routes/quotes/QuoteFormPage.tsx b/apps/core-web/src/routes/quotes/QuoteFormPage.tsx new file mode 100644 index 0000000..1ac1f6e --- /dev/null +++ b/apps/core-web/src/routes/quotes/QuoteFormPage.tsx @@ -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(null); + const [products, setProducts] = useState(null); + const [loadError, setLoadError] = useState(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("12"); + const [fidelityPeriod, setFidelityPeriod] = useState(""); + const [items, setItems] = useState([{ ...EMPTY_ROW }]); + const [implGeral, setImplGeral] = useState(""); + const [proposedMonthlyTotal, setProposedMonthlyTotal] = useState(""); + const [implPaymentCondition, setImplPaymentCondition] = useState(""); + const [notes, setNotes] = useState(""); + + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [createdQuote, setCreatedQuote] = useState(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) { + 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 ( + + + + Oferta criada com sucesso. Confira os totais calculados pelo servidor abaixo. + + + + + + + + {createdQuote.approval_status === "pending" ? ( + + Esta oferta possui desconto e/ou fidelidade reduzida — aguarda aprovação. + + ) : null} + + + Ver oferta completa + + navigate("/quotes")}> + Voltar para a lista + + + + + ); + } + + return ( + + + + + {loadError ? ( + + {loadError} + + ) : null} + {error ? ( + + {error} + + ) : null} + + + {!resellers || !products ? ( + Carregando revendas e produtos… + ) : ( + + + Dados do cliente + + ({ value: r.id, label: r.name }))} + placeholder="Selecione a revenda" + value={resellerId} + onChange={(e) => setResellerId(e.target.value)} + required + /> + setClientName(e.target.value)} required /> + setClientCompany(e.target.value)} /> + setClientEmail(e.target.value)} /> + setClientPhone(e.target.value)} required /> + + + + + Contrato + + ({ + value: String(period), + label: period === 0 ? "Sem fidelidade (0 meses)" : `${period} meses`, + }))} + value={contractPeriod} + onChange={(e) => setContractPeriod(e.target.value)} + required + /> + setFidelityPeriod(e.target.value)} + hint="Só preencha se for menor que o período do contrato — entra em aprovação" + /> + + + + + + Itens + + + Adicionar item + + + + + + + Produto + Quantidade + + + + + {items.map((row, index) => ( + + + updateItem(index, { productId: e.target.value })} + aria-label="Produto" + required + /> + + + updateItem(index, { quantity: e.target.value })} + aria-label="Quantidade" + required + /> + + + {items.length > 1 ? ( + removeItem(index)} + className="rounded-md border border-border-color px-2 py-1 text-xs font-semibold text-danger hover:bg-light" + > + Remover + + ) : null} + + + ))} + + + + + + + Implantação e valores + + setImplGeral(e.target.value)} + hint="Custo de implantação além dos produtos" + /> + setProposedMonthlyTotal(e.target.value)} + hint="Preencha só se for oferecer desconto — a oferta precisará de aprovação" + /> + + ({ + value: condition, + label: condition, + }))} + placeholder="Selecione a condição" + value={implPaymentCondition} + onChange={(e) => setImplPaymentCondition(e.target.value as ImplPaymentCondition)} + /> + + Faixas sugeridas para {formatCurrencyBRL(estimatedImplTotal)} de implantação estimada:{" "} + {availablePaymentConditions.map((c) => ( + + {c} + + ))} + {" "}— o servidor valida a faixa real ao salvar. + + + + + + + Notas + setNotes(e.target.value)} + /> + + + + + Criar oferta + + navigate("/quotes")}> + Cancelar + + + + )} + + + ); +} + +function SummaryField({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ); +} diff --git a/apps/core-web/src/routes/quotes/QuotesListPage.tsx b/apps/core-web/src/routes/quotes/QuotesListPage.tsx new file mode 100644 index 0000000..b1bc219 --- /dev/null +++ b/apps/core-web/src/routes/quotes/QuotesListPage.tsx @@ -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 = { + none: "Sem aprovação", + pending: "Pendente", + approved: "Aprovada", + rejected: "Rejeitada", +}; + +const APPROVAL_VARIANT: Record = { + none: "neutral", + pending: "warning", + approved: "success", + rejected: "danger", +}; + +const DEAL_LABEL: Record = { + orcamento: "Orçamento", + fechado: "Fechado", + perdido: "Perdido", +}; + +const DEAL_VARIANT: Record = { + orcamento: "info", + fechado: "success", + perdido: "danger", +}; + +export function QuotesListPage() { + const { accessToken } = useAuth(); + const [quotes, setQuotes] = useState(null); + const [error, setError] = useState(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 ( + + + Nova oferta + + } + /> + + {error ? ( + + {error} + + ) : null} + + + {quotes === null && !error ? ( + Carregando ofertas… + ) : quotes && quotes.length === 0 ? ( + Nenhuma oferta cadastrada ainda. + ) : quotes ? ( + + + + + Oferta + Cliente + Mensalidade + Aprovação + Negócio + + + + {quotes.map((quote) => ( + + + + {quote.quote_number} + + + + {quote.client_name} + {quote.client_company ? {quote.client_company} : null} + + + {formatCurrencyBRL(quote.proposed_monthly_total ?? quote.monthly_total)} + + + + {APPROVAL_LABEL[quote.approval_status]} + + + + {DEAL_LABEL[quote.deal_status]} + + + ))} + + + + ) : null} + + + ); +} diff --git a/apps/core-web/src/routes/resellers/ResellerFormPage.tsx b/apps/core-web/src/routes/resellers/ResellerFormPage.tsx new file mode 100644 index 0000000..be7c921 --- /dev/null +++ b/apps/core-web/src/routes/resellers/ResellerFormPage.tsx @@ -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(EMPTY_FORM); + const [loading, setLoading] = useState(isEditing); + const [error, setError] = useState(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(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 ( + + + ← Voltar para Revendas + + } + actions={ + <> + navigate("/resellers")} type="button"> + Cancelar + + + Salvar + + > + } + /> + + {error ? ( + + {error} + + ) : null} + + {loading ? ( + + Carregando… + + ) : ( + + + + + updateField("name", e.target.value)} required /> + + updateField("document", e.target.value)} /> + updateField("contactEmail", e.target.value)} + /> + + Status + + updateField("active", e.target.checked)} + /> + Revenda ativa + + + + + + )} + + ); +} diff --git a/apps/core-web/src/routes/resellers/ResellersListPage.tsx b/apps/core-web/src/routes/resellers/ResellersListPage.tsx new file mode 100644 index 0000000..ef60cf8 --- /dev/null +++ b/apps/core-web/src/routes/resellers/ResellersListPage.tsx @@ -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(null); + const [error, setError] = useState(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> = [ + { key: "name", header: "Nome", render: (reseller) => {reseller.name} }, + { 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) => {reseller.active ? "Ativo" : "Inativo"}, + }, + { + key: "actions", + header: "", + className: "text-right", + render: (reseller) => ( + navigate(`/resellers/${reseller.id}/edit`)}> + Editar + + ), + }, + ]; + + return ( + + navigate("/resellers/new")}>Nova revenda} + /> + + {error ? ( + + {error} + + ) : null} + + + {resellers === null && !error ? ( + Carregando… + ) : ( + reseller.id} /> + )} + + + ); +} diff --git a/docs/progress.md b/docs/progress.md index 128f567..c8d34ad 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -294,3 +294,30 @@ Verificado: `pnpm turbo run typecheck build` (28/28), `pnpm --filter @eden/api t ### Pendências Toda a Assinatura Eletrônica (eden.md §1.4-1.9/§5/§6/§7 — envelopes, signatários, OTP, hash-chain, verificação pública), geração real de PDF (Playwright/Chromium) + sanitização de HTML, editor visual Tiptap real (`renderToHtml`/`resolveDocument` com condicionais e repetição de tabela), seed dos 7 templates reais de produção da Handix, regra de idempotência completa com `force`/`extra_fields`. + +--- + +## Fase 2/8 — Telas de negócio reais no EDEN Core (Empresas, Produtos, Revendas, CRM, Ofertas) + +Data: 2026-09-03. Usuário pediu explicitamente para priorizar a parte web ("ja faca a parte web para eu ja ver como esta ficando"). 2 subagentes `eden-frontend` em paralelo, mesma sessão, construindo sobre o shell autenticado + Design System já prontos. + +**Correção de instrução no meio da leva**: o usuário interrompeu para dizer "não esqueça de seguir o template" / "melhor usar ele" — as telas deviam seguir de perto o LAYOUT REAL do tema DreamsERP comprado, não só os tokens visuais abstratos já extraídos. Extraí do `tema_do_Eden.zip` as páginas React reais mais relevantes (`sales/salesQuotes*.tsx`, `crm/leads.tsx`/`deals*.tsx`, `inventory/products*.tsx`, `purchase/vendors.tsx`, `system/userManagement.tsx`, `ui-interface/{tables,forms}`) para dentro do worktree de cada agente (`.tmp/theme-reference/`, nunca versionado) e mandei mensagem de correção para os dois agentes já em execução — via `SendMessage`, não relançando a tarefa (no meio do processo cometi o erro de usar `Agent` em vez de `SendMessage` para a primeira correção, criando um agente duplicado; percebido e corrigido na hora com `TaskStop` antes de qualquer trabalho real ser feito por ele). Ambos incorporaram a referência do tema (estrutura de header/breadcrumb/tabela/formulário em cards) reescrita com `@eden/ui` + Tailwind puro — sem copiar JSX nem trazer dependências do tema (antd/Preline) para o projeto. + +### Empresas / Produtos / Revendas (`apps/core-web/src/routes/{companies,products,resellers}/`) + +CRUD completo (lista + criar/editar) para os 3 módulos, layout modelado em `purchase/vendors.tsx` (empresas/revendas) e `inventory/products.tsx` (produtos, com máscara de moeda para as 5 faixas de preço + implantação). Novo componente `Table` em `packages/ui` (tabela genérica reutilizável, sem dependência de terceiro). + +### CRM e Ofertas/Quotes (`apps/core-web/src/routes/{crm,quotes}/`) + +Leads (lista, criar/editar, converter em oportunidade) e Oportunidades (lista, criar/editar) modelados em `crm/leads.tsx`/`dealsDetails.tsx`. Ofertas — o módulo mais rico: `QuotesListPage`, `QuoteFormPage` (revenda + cliente + período de contrato + fidelidade reduzida opcional + lista dinâmica de itens produto/quantidade + desconto opcional + condição de pagamento, mostra os totais calculados pelo servidor antes de ir para o detalhe) e `QuoteDetailPage` (resumo financeiro completo, aprovar/rejeitar quando pendente, fechar negócio quando ainda é orçamento) — modelados em `sales/salesQuotes.tsx`/`addSalesQuotes.tsx`/`salesQuotesDetails.tsx`. Novo componente `Select` em `packages/ui`. + +**Achado real do próprio agente, corrigindo meu brief**: eu tinha dito no brief que as respostas de Leads/Opportunities eram snake_case (copiando o padrão do resto da API) — o agente verificou contra o código-fonte real (`packages/database/src/repositories/leads.ts`) e descobriu que esse módulo específico mapeia para **camelCase** na resposta (`mapLead`), diferente de Quotes/Companies/Products/Resellers (snake_case direto da linha do banco). Confirmado ao vivo via curl antes de integrar. + +### Integração manual (os 2 agentes trabalharam em paralelo no mesmo `core-web`) + +Um dos agentes não conseguiu `git merge` dentro do próprio sandbox e reconstruiu manualmente (leitura+escrita) o estado já existente do shell autenticado — comparei arquivo por arquivo contra `main` (`diff -rq`) para confirmar que a restauração foi fiel (nenhum componente restaurado divergiu) antes de aceitar qualquer coisa como "nova". `App.tsx`, `AuthenticatedLayout.tsx` (nav unificada, lógica de "ativo" por prefixo de path com borda de `/` para nunca casar `/companies` com `/companies-x`) e `packages/ui/src/index.ts` foram reconciliados manualmente juntando as rotas/exports dos dois agentes; `lib/format.ts` teve as duas versões mescladas (`formatDocument` de um lado, `formatDateBR` do outro). + +Verificado: `pnpm turbo run typecheck build` (28/28). Containers `eden-api`/`eden-core` reconstruídos (o build em background travou duas vezes por algum motivo de recurso do ambiente — refeito em primeiro plano, funcionou); bundle de produção confirmado contendo todas as novas telas; login simulando requisição real de browser (`Origin: http://localhost:3001`) funcionando; listagens de Empresas/Produtos/Revendas retornando dados reais; criação de lead confirmando o formato camelCase ao vivo. + +### Pendências +Client Registration (Cadastro de Cliente) ainda sem tela — `QuoteDetailPage` hoje exige colar manualmente um UUID para fechar negócio. Nenhuma tela de Contracts/Inventory/Support/HR-Timeclock/Fiscal/Billing/Documents ainda. `reseller-web`/`subscriber-web` continuam só com o shell de login (sem telas de negócio). diff --git a/packages/ui/src/components/Card.tsx b/packages/ui/src/components/Card.tsx index 18d3025..0321931 100644 --- a/packages/ui/src/components/Card.tsx +++ b/packages/ui/src/components/Card.tsx @@ -4,10 +4,12 @@ import { cn } from "../utils/cn.js"; export interface CardProps extends Omit, "title"> { title?: ReactNode; actions?: ReactNode; + /** Overrides the default `p-5` on the content wrapper — e.g. `"p-0"` for a flush data table (theme source: sales/salesQuotes.tsx uses `p-4 pb-0` around its table). */ + bodyClassName?: string; } /** Theme source (auth-pages/login.tsx): `bg-white border border-border-color rounded-md shadow-sm`. */ -export function Card({ title, actions, className, children, ...rest }: CardProps) { +export function Card({ title, actions, className, bodyClassName, children, ...rest }: CardProps) { return ( {title || actions ? ( @@ -16,7 +18,7 @@ export function Card({ title, actions, className, children, ...rest }: CardProps {actions ? {actions} : null} ) : null} - {children} + {children} ); } diff --git a/packages/ui/src/components/Select.tsx b/packages/ui/src/components/Select.tsx new file mode 100644 index 0000000..b97a50b --- /dev/null +++ b/packages/ui/src/components/Select.tsx @@ -0,0 +1,93 @@ +import { forwardRef, useId } from "react"; +import type { SelectHTMLAttributes } from "react"; +import { cn } from "../utils/cn.js"; + +export interface SelectOption { + value: string; + label: string; +} + +export interface SelectProps extends Omit, "children"> { + label?: string; + /** Validation/server error message — shown below the field, field border turns danger. */ + error?: string; + /** Helper text shown below the field when there's no error. */ + hint?: string; + options: SelectOption[]; + /** Rendered as a disabled, selected-by-default first option (e.g. "Selecione..."). */ + placeholder?: string; +} + +/** + * Native `` styled to match `Input` (packages/ui/src/components/Input.tsx) + * — same border/padding/focus-ring language as the rest of the Design + * System. Derived from the DreamsERP theme's form field sizing + * (`.tmp/theme-reference/sales/addSalesQuotes.tsx` etc. use a + * `react-select`-based CommonSelect for the same slots, out of our stack — + * this is a plain, dependency-free equivalent with the same visual sizing). + */ +export const Select = forwardRef(function Select( + { label, error, hint, id, className, options, placeholder, ...rest }, + ref, +) { + const generatedId = useId(); + const selectId = id ?? generatedId; + + return ( + + {label ? ( + + {label} + + ) : null} + + + {placeholder ? ( + + {placeholder} + + ) : null} + {options.map((option) => ( + + {option.label} + + ))} + + + + + + {error ? ( + + {error} + + ) : hint ? ( + + {hint} + + ) : null} + + ); +}); diff --git a/packages/ui/src/components/Table.tsx b/packages/ui/src/components/Table.tsx new file mode 100644 index 0000000..94c0bd4 --- /dev/null +++ b/packages/ui/src/components/Table.tsx @@ -0,0 +1,64 @@ +import type { ReactNode } from "react"; +import { cn } from "../utils/cn.js"; + +export interface TableColumn { + key: string; + header: ReactNode; + render: (row: T) => ReactNode; + /** Applied to both the header and body cells — e.g. `"text-right"` for numeric columns. */ + className?: string; +} + +export interface TableProps { + columns: Array>; + data: T[]; + rowKey: (row: T) => string; + /** Shown instead of the table body when `data` is empty (and not loading). */ + emptyMessage?: ReactNode; +} + +/** + * Plain data table styled to match the DreamsERP theme's list screens + * (react/src/pages/inventory/products.tsx et al.) without pulling in the + * theme's antd/react-window dependencies — just a semantic ``. + */ +export function Table({ columns, data, rowKey, emptyMessage = "Nenhum registro encontrado." }: TableProps) { + return ( + + + + + {columns.map((column) => ( + + {column.header} + + ))} + + + + {data.length === 0 ? ( + + + {emptyMessage} + + + ) : ( + data.map((row) => ( + + {columns.map((column) => ( + + {column.render(row)} + + ))} + + )) + )} + + + + ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index d0966ae..3190229 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -13,6 +13,12 @@ export type { ButtonProps, ButtonVariant, ButtonSize } from "./components/Button export { Input } from "./components/Input.js"; export type { InputProps } from "./components/Input.js"; +export { Select } from "./components/Select.js"; +export type { SelectProps, SelectOption } from "./components/Select.js"; + +export { Table } from "./components/Table.js"; +export type { TableProps, TableColumn } from "./components/Table.js"; + export { Card } from "./components/Card.js"; export type { CardProps } from "./components/Card.js";
{ + return apiRequest(`/quotes/${id}/close`, { method: "POST", body: JSON.stringify(input) }, { accessToken }); +} diff --git a/apps/core-web/src/lib/api/resellers.ts b/apps/core-web/src/lib/api/resellers.ts new file mode 100644 index 0000000..e2aa409 --- /dev/null +++ b/apps/core-web/src/lib/api/resellers.ts @@ -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; + +export function listResellers(accessToken: string): Promise { + return apiRequest("/resellers", { method: "GET" }, { accessToken }); +} + +export function getReseller(id: string, accessToken: string): Promise { + return apiRequest(`/resellers/${id}`, { method: "GET" }, { accessToken }); +} + +export function createReseller(input: CreateResellerInput, accessToken: string): Promise { + return apiRequest("/resellers", { method: "POST", body: JSON.stringify(input) }, { accessToken }); +} + +export function updateReseller( + id: string, + input: UpdateResellerInput, + accessToken: string, +): Promise { + return apiRequest( + `/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 }, + ); +} diff --git a/apps/core-web/src/lib/format.ts b/apps/core-web/src/lib/format.ts new file mode 100644 index 0000000..c30f27b --- /dev/null +++ b/apps/core-web/src/lib/format.ts @@ -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; +} diff --git a/apps/core-web/src/routes/AuthenticatedLayout.tsx b/apps/core-web/src/routes/AuthenticatedLayout.tsx index c2d385b..3efa306 100644 --- a/apps/core-web/src/routes/AuthenticatedLayout.tsx +++ b/apps/core-web/src/routes/AuthenticatedLayout.tsx @@ -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() { (null); + const [error, setError] = useState(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> = [ + { + key: "name", + header: "Nome", + render: (company) => ( + + {company.company_name} + {company.trade_name ? {company.trade_name} : null} + + ), + }, + { 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) => ( + {company.is_active ? "Ativo" : "Inativo"} + ), + }, + { + key: "actions", + header: "", + className: "text-right", + render: (company) => ( + navigate(`/companies/${company.id}/edit`)}> + Editar + + ), + }, + ]; + + return ( + + navigate("/companies/new")}>Nova empresa} + /> + + {error ? ( + + {error} + + ) : null} + + + {companies === null && !error ? ( + Carregando… + ) : ( + company.id} /> + )} + + + ); +} diff --git a/apps/core-web/src/routes/companies/CompanyFormPage.tsx b/apps/core-web/src/routes/companies/CompanyFormPage.tsx new file mode 100644 index 0000000..c69ae6d --- /dev/null +++ b/apps/core-web/src/routes/companies/CompanyFormPage.tsx @@ -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 { + 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(EMPTY_FORM); + const [loading, setLoading] = useState(isEditing); + const [error, setError] = useState(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(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 ( + + + ← Voltar para Empresas + + } + actions={ + <> + navigate("/companies")} type="button"> + Cancelar + + + Salvar + + > + } + /> + + {error ? ( + + {error} + + ) : null} + + {loading ? ( + + Carregando… + + ) : ( + + + + + updateField("companyName", e.target.value)} + required + /> + + updateField("tradeName", e.target.value)} + /> + updateField("cnpj", e.target.value)} /> + updateField("stateRegistration", e.target.value)} + /> + updateField("municipalRegistration", e.target.value)} + /> + {isEditing ? ( + + Status + + updateField("isActive", e.target.checked)} + /> + Empresa ativa + + + ) : null} + + + + + + updateField("addressZip", e.target.value)} /> + + updateField("addressStreet", e.target.value)} + /> + + updateField("addressNumber", e.target.value)} + /> + updateField("addressComplement", e.target.value)} + /> + updateField("addressNeighborhood", e.target.value)} + /> + updateField("addressCity", e.target.value)} + /> + updateField("addressState", e.target.value.toUpperCase())} + /> + updateField("addressCountry", e.target.value)} + placeholder="Brasil" + /> + + + + + + updateField("phone", e.target.value)} /> + updateField("email", e.target.value)} + /> + updateField("website", e.target.value)} /> + + + + + + updateField("legalRepName", e.target.value)} + /> + updateField("legalRepCpf", e.target.value)} + /> + updateField("legalRepRole", e.target.value)} + /> + updateField("legalRepEmail", e.target.value)} + /> + + + + )} + + ); +} diff --git a/apps/core-web/src/routes/crm/ConvertLeadPage.tsx b/apps/core-web/src/routes/crm/ConvertLeadPage.tsx new file mode 100644 index 0000000..d914c51 --- /dev/null +++ b/apps/core-web/src/routes/crm/ConvertLeadPage.tsx @@ -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 = { + 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(null); + const [title, setTitle] = useState(""); + const [stage, setStage] = useState("prospecting"); + const [probability, setProbability] = useState(""); + const [notes, setNotes] = useState(""); + + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(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 ( + + + + + {error ? ( + + {error} + + ) : null} + + + {loading ? ( + Carregando lead… + ) : ( + + setTitle(e.target.value)} + required + autoFocus + /> + setStage(e.target.value as OpportunityStage)} + /> + setProbability(e.target.value)} + /> + + Notas + setNotes(e.target.value)} + /> + + + + Converter + + navigate("/leads")}> + Cancelar + + + + )} + + + ); +} diff --git a/apps/core-web/src/routes/crm/CrmSubNav.tsx b/apps/core-web/src/routes/crm/CrmSubNav.tsx new file mode 100644 index 0000000..03d5697 --- /dev/null +++ b/apps/core-web/src/routes/crm/CrmSubNav.tsx @@ -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 ( + + + Leads + + + Oportunidades + + + ); +} diff --git a/apps/core-web/src/routes/crm/LeadFormPage.tsx b/apps/core-web/src/routes/crm/LeadFormPage.tsx new file mode 100644 index 0000000..0d41695 --- /dev/null +++ b/apps/core-web/src/routes/crm/LeadFormPage.tsx @@ -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 = { + 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("new"); + const [notes, setNotes] = useState(""); + + const [loading, setLoading] = useState(isEdit); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(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 ( + + + + + {error ? ( + + {error} + + ) : null} + + + {loading ? ( + Carregando lead… + ) : ( + + setContactName(e.target.value)} + required + autoFocus + /> + setCompanyName(e.target.value)} /> + setContactEmail(e.target.value)} + /> + setContactPhone(e.target.value)} /> + setSource(e.target.value)} hint="Ex.: Site, indicação, campanha" /> + setStatus(e.target.value as LeadStatus)} + /> + + Notas + setNotes(e.target.value)} + /> + + + + {isEdit ? "Salvar alterações" : "Criar lead"} + + navigate("/leads")}> + Cancelar + + + + )} + + + ); +} diff --git a/apps/core-web/src/routes/crm/LeadsListPage.tsx b/apps/core-web/src/routes/crm/LeadsListPage.tsx new file mode 100644 index 0000000..e8ca3ab --- /dev/null +++ b/apps/core-web/src/routes/crm/LeadsListPage.tsx @@ -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 = { + new: "Novo", + contacted: "Contatado", + qualified: "Qualificado", + converted: "Convertido", + discarded: "Descartado", +}; + +const STATUS_VARIANT: Record = { + new: "info", + contacted: "warning", + qualified: "primary", + converted: "success", + discarded: "danger", +}; + +export function LeadsListPage() { + const { accessToken } = useAuth(); + const [leads, setLeads] = useState(null); + const [error, setError] = useState(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 ( + + + + Novo lead + + } + /> + + {error ? ( + + {error} + + ) : null} + + + {leads === null && !error ? ( + Carregando leads… + ) : leads && leads.length === 0 ? ( + Nenhum lead cadastrado ainda. + ) : leads ? ( + + + + + Contato + Empresa + Status + Ações + + + + {leads.map((lead) => { + const canConvert = lead.status !== "converted" && lead.status !== "discarded"; + return ( + + + + {lead.contactName} + + {lead.contactEmail ? {lead.contactEmail} : null} + + {lead.companyName ?? "—"} + + + {STATUS_LABEL[lead.status] ?? lead.status} + + + + + + Editar + + {canConvert ? ( + + Converter em oportunidade + + ) : null} + + + + ); + })} + + + + ) : null} + + + ); +} diff --git a/apps/core-web/src/routes/crm/OpportunitiesListPage.tsx b/apps/core-web/src/routes/crm/OpportunitiesListPage.tsx new file mode 100644 index 0000000..7e83021 --- /dev/null +++ b/apps/core-web/src/routes/crm/OpportunitiesListPage.tsx @@ -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 = { + prospecting: "Prospecção", + qualification: "Qualificação", + proposal: "Proposta", + negotiation: "Negociação", + won: "Ganha", + lost: "Perdida", +}; + +const STAGE_VARIANT: Record = { + prospecting: "info", + qualification: "primary", + proposal: "warning", + negotiation: "secondary", + won: "success", + lost: "danger", +}; + +export function OpportunitiesListPage() { + const { accessToken } = useAuth(); + const [opportunities, setOpportunities] = useState(null); + const [error, setError] = useState(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 ( + + + + Nova oportunidade + + } + /> + + {error ? ( + + {error} + + ) : null} + + + {opportunities === null && !error ? ( + Carregando oportunidades… + ) : opportunities && opportunities.length === 0 ? ( + Nenhuma oportunidade cadastrada ainda. + ) : opportunities ? ( + + + + + Título + Estágio + Probabilidade + Dono + Ações + + + + {opportunities.map((opportunity) => ( + + + + {opportunity.title} + + + + + {STAGE_LABEL[opportunity.stage] ?? opportunity.stage} + + + + {opportunity.probability != null ? `${opportunity.probability}%` : "—"} + + {opportunity.ownerId ?? "—"} + + + Editar + + + + ))} + + + + ) : null} + + + ); +} diff --git a/apps/core-web/src/routes/crm/OpportunityFormPage.tsx b/apps/core-web/src/routes/crm/OpportunityFormPage.tsx new file mode 100644 index 0000000..8fe8042 --- /dev/null +++ b/apps/core-web/src/routes/crm/OpportunityFormPage.tsx @@ -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 = { + 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("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(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 ( + + + + + {error ? ( + + {error} + + ) : null} + + + {loading ? ( + Carregando oportunidade… + ) : ( + + setTitle(e.target.value)} + required + autoFocus + /> + setStage(e.target.value as OpportunityStage)} + /> + setProbability(e.target.value)} + /> + {stage === "lost" ? ( + setLossReason(e.target.value)} + /> + ) : null} + + Notas + setNotes(e.target.value)} + /> + + + + {isEdit ? "Salvar alterações" : "Criar oportunidade"} + + navigate("/opportunities")}> + Cancelar + + + + )} + + + ); +} diff --git a/apps/core-web/src/routes/products/ProductFormPage.tsx b/apps/core-web/src/routes/products/ProductFormPage.tsx new file mode 100644 index 0000000..8c8d9b9 --- /dev/null +++ b/apps/core-web/src/routes/products/ProductFormPage.tsx @@ -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 ( + 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(EMPTY_FORM); + const [loading, setLoading] = useState(isEditing); + const [error, setError] = useState(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(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 ( + + + ← Voltar para Produtos + + } + actions={ + <> + navigate("/products")} type="button"> + Cancelar + + + Salvar + + > + } + /> + + {error ? ( + + {error} + + ) : null} + + {loading ? ( + + Carregando… + + ) : ( + + + + + updateField("name", e.target.value)} required /> + + updateField("productType", e.target.value)} + required + /> + updateField("unit", e.target.value)} /> + updateField("productGroup", e.target.value)} + /> + updateField("productSubgroup", e.target.value)} + /> + updateField("brand", e.target.value)} /> + + + Descrição + + updateField("description", e.target.value)} + /> + + + + + + + updateField("price0Cents", v)} /> + updateField("price12Cents", v)} /> + updateField("price24Cents", v)} /> + updateField("price36Cents", v)} /> + updateField("price48Cents", v)} /> + updateField("implUnitPriceCents", v)} + /> + + + + + + {( + [ + ["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]) => ( + + updateField(field, e.target.checked)} + /> + {label} + + ))} + + + + )} + + ); +} diff --git a/apps/core-web/src/routes/products/ProductsListPage.tsx b/apps/core-web/src/routes/products/ProductsListPage.tsx new file mode 100644 index 0000000..14deccb --- /dev/null +++ b/apps/core-web/src/routes/products/ProductsListPage.tsx @@ -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(null); + const [error, setError] = useState(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> = [ + { key: "code", header: "Código", render: (product) => `#${product.product_code}` }, + { key: "name", header: "Nome", render: (product) => {product.name} }, + { key: "type", header: "Tipo", render: (product) => product.product_type }, + ...PRICE_COLUMNS.map( + (column): TableColumn => ({ + 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 ? ( + Descontinuado + ) : ( + {product.active ? "Ativo" : "Inativo"} + ), + }, + { + key: "actions", + header: "", + className: "text-right", + render: (product) => ( + navigate(`/products/${product.id}/edit`)}> + Editar + + ), + }, + ]; + + return ( + + navigate("/products/new")}>Novo produto} + /> + + {error ? ( + + {error} + + ) : null} + + + {products === null && !error ? ( + Carregando… + ) : ( + product.id} /> + )} + + + ); +} diff --git a/apps/core-web/src/routes/quotes/QuoteDetailPage.tsx b/apps/core-web/src/routes/quotes/QuoteDetailPage.tsx new file mode 100644 index 0000000..bec95a5 --- /dev/null +++ b/apps/core-web/src/routes/quotes/QuoteDetailPage.tsx @@ -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 = { + none: "Sem aprovação", + pending: "Pendente", + approved: "Aprovada", + rejected: "Rejeitada", +}; + +const APPROVAL_VARIANT: Record = { + none: "neutral", + pending: "warning", + approved: "success", + rejected: "danger", +}; + +const DEAL_LABEL: Record = { + orcamento: "Orçamento", + fechado: "Fechado", + perdido: "Perdido", +}; + +const DEAL_VARIANT: Record = { + orcamento: "info", + fechado: "success", + perdido: "danger", +}; + +export function QuoteDetailPage() { + const { id } = useParams<{ id: string }>(); + const { accessToken } = useAuth(); + + const [quote, setQuote] = useState(null); + const [reseller, setReseller] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( + + + Carregando oferta… + + ); + } + + if (error || !quote) { + return ( + + + {error ?? "Oferta não encontrada."} + + ); + } + + return ( + + + + {APPROVAL_LABEL[quote.approval_status]} + {DEAL_LABEL[quote.deal_status]} + + } + /> + + + + + + + + + + + + + + + + + + + + + + + + + Produto + Qtd. + Preço unit. + Total + Impl. unit. + Impl. total + + + + {quote.items.map((item, index) => ( + + {item.productName} + {item.quantity} + {formatCurrencyBRL(item.unitPrice)} + {formatCurrencyBRL(item.total)} + {formatCurrencyBRL(item.implUnitPrice)} + {formatCurrencyBRL(item.implTotal)} + + ))} + + + + + + + + + {quote.proposed_monthly_total != null ? ( + + ) : null} + + + + + {quote.notes ? ( + + Notas + {quote.notes} + + ) : null} + + + {quote.approval_status === "pending" ? ( + + ) : quote.approval_status !== "none" && quote.approval_notes ? ( + + {quote.approval_notes} + + ) : null} + + {quote.deal_status === "orcamento" ? ( + + ) : quote.client_registration_id ? ( + + + + ) : null} + + + + ); +} + +function Row({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ); +} + +function SummaryRow({ label, value, highlight }: { label: string; value: string; highlight?: boolean }) { + return ( + + {label} + + {value} + + + ); +} + +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(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 ( + + {error ? ( + + {error} + + ) : null} + + Esta oferta tem desconto e/ou fidelidade reduzida e precisa de aprovação antes de seguir. + + setNotes(e.target.value)} + className="mb-3" + /> + + decide("approved")} loading={submitting === "approved"}> + Aprovar + + decide("rejected")} loading={submitting === "rejected"}> + Rejeitar + + + + ); +} + +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(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 ( + + {error ? ( + + {error} + + ) : null} + + setClientRegistrationId(e.target.value)} + hint="Cole o UUID de um cadastro de cliente já existente" + className="min-w-72 flex-1" + required + /> + + Fechar negócio + + + + ); +} diff --git a/apps/core-web/src/routes/quotes/QuoteFormPage.tsx b/apps/core-web/src/routes/quotes/QuoteFormPage.tsx new file mode 100644 index 0000000..1ac1f6e --- /dev/null +++ b/apps/core-web/src/routes/quotes/QuoteFormPage.tsx @@ -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(null); + const [products, setProducts] = useState(null); + const [loadError, setLoadError] = useState(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("12"); + const [fidelityPeriod, setFidelityPeriod] = useState(""); + const [items, setItems] = useState([{ ...EMPTY_ROW }]); + const [implGeral, setImplGeral] = useState(""); + const [proposedMonthlyTotal, setProposedMonthlyTotal] = useState(""); + const [implPaymentCondition, setImplPaymentCondition] = useState(""); + const [notes, setNotes] = useState(""); + + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [createdQuote, setCreatedQuote] = useState(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) { + 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 ( + + + + Oferta criada com sucesso. Confira os totais calculados pelo servidor abaixo. + + + + + + + + {createdQuote.approval_status === "pending" ? ( + + Esta oferta possui desconto e/ou fidelidade reduzida — aguarda aprovação. + + ) : null} + + + Ver oferta completa + + navigate("/quotes")}> + Voltar para a lista + + + + + ); + } + + return ( + + + + + {loadError ? ( + + {loadError} + + ) : null} + {error ? ( + + {error} + + ) : null} + + + {!resellers || !products ? ( + Carregando revendas e produtos… + ) : ( + + + Dados do cliente + + ({ value: r.id, label: r.name }))} + placeholder="Selecione a revenda" + value={resellerId} + onChange={(e) => setResellerId(e.target.value)} + required + /> + setClientName(e.target.value)} required /> + setClientCompany(e.target.value)} /> + setClientEmail(e.target.value)} /> + setClientPhone(e.target.value)} required /> + + + + + Contrato + + ({ + value: String(period), + label: period === 0 ? "Sem fidelidade (0 meses)" : `${period} meses`, + }))} + value={contractPeriod} + onChange={(e) => setContractPeriod(e.target.value)} + required + /> + setFidelityPeriod(e.target.value)} + hint="Só preencha se for menor que o período do contrato — entra em aprovação" + /> + + + + + + Itens + + + Adicionar item + + + + + + + Produto + Quantidade + + + + + {items.map((row, index) => ( + + + updateItem(index, { productId: e.target.value })} + aria-label="Produto" + required + /> + + + updateItem(index, { quantity: e.target.value })} + aria-label="Quantidade" + required + /> + + + {items.length > 1 ? ( + removeItem(index)} + className="rounded-md border border-border-color px-2 py-1 text-xs font-semibold text-danger hover:bg-light" + > + Remover + + ) : null} + + + ))} + + + + + + + Implantação e valores + + setImplGeral(e.target.value)} + hint="Custo de implantação além dos produtos" + /> + setProposedMonthlyTotal(e.target.value)} + hint="Preencha só se for oferecer desconto — a oferta precisará de aprovação" + /> + + ({ + value: condition, + label: condition, + }))} + placeholder="Selecione a condição" + value={implPaymentCondition} + onChange={(e) => setImplPaymentCondition(e.target.value as ImplPaymentCondition)} + /> + + Faixas sugeridas para {formatCurrencyBRL(estimatedImplTotal)} de implantação estimada:{" "} + {availablePaymentConditions.map((c) => ( + + {c} + + ))} + {" "}— o servidor valida a faixa real ao salvar. + + + + + + + Notas + setNotes(e.target.value)} + /> + + + + + Criar oferta + + navigate("/quotes")}> + Cancelar + + + + )} + + + ); +} + +function SummaryField({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ); +} diff --git a/apps/core-web/src/routes/quotes/QuotesListPage.tsx b/apps/core-web/src/routes/quotes/QuotesListPage.tsx new file mode 100644 index 0000000..b1bc219 --- /dev/null +++ b/apps/core-web/src/routes/quotes/QuotesListPage.tsx @@ -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 = { + none: "Sem aprovação", + pending: "Pendente", + approved: "Aprovada", + rejected: "Rejeitada", +}; + +const APPROVAL_VARIANT: Record = { + none: "neutral", + pending: "warning", + approved: "success", + rejected: "danger", +}; + +const DEAL_LABEL: Record = { + orcamento: "Orçamento", + fechado: "Fechado", + perdido: "Perdido", +}; + +const DEAL_VARIANT: Record = { + orcamento: "info", + fechado: "success", + perdido: "danger", +}; + +export function QuotesListPage() { + const { accessToken } = useAuth(); + const [quotes, setQuotes] = useState(null); + const [error, setError] = useState(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 ( + + + Nova oferta + + } + /> + + {error ? ( + + {error} + + ) : null} + + + {quotes === null && !error ? ( + Carregando ofertas… + ) : quotes && quotes.length === 0 ? ( + Nenhuma oferta cadastrada ainda. + ) : quotes ? ( + + + + + Oferta + Cliente + Mensalidade + Aprovação + Negócio + + + + {quotes.map((quote) => ( + + + + {quote.quote_number} + + + + {quote.client_name} + {quote.client_company ? {quote.client_company} : null} + + + {formatCurrencyBRL(quote.proposed_monthly_total ?? quote.monthly_total)} + + + + {APPROVAL_LABEL[quote.approval_status]} + + + + {DEAL_LABEL[quote.deal_status]} + + + ))} + + + + ) : null} + + + ); +} diff --git a/apps/core-web/src/routes/resellers/ResellerFormPage.tsx b/apps/core-web/src/routes/resellers/ResellerFormPage.tsx new file mode 100644 index 0000000..be7c921 --- /dev/null +++ b/apps/core-web/src/routes/resellers/ResellerFormPage.tsx @@ -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(EMPTY_FORM); + const [loading, setLoading] = useState(isEditing); + const [error, setError] = useState(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(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 ( + + + ← Voltar para Revendas + + } + actions={ + <> + navigate("/resellers")} type="button"> + Cancelar + + + Salvar + + > + } + /> + + {error ? ( + + {error} + + ) : null} + + {loading ? ( + + Carregando… + + ) : ( + + + + + updateField("name", e.target.value)} required /> + + updateField("document", e.target.value)} /> + updateField("contactEmail", e.target.value)} + /> + + Status + + updateField("active", e.target.checked)} + /> + Revenda ativa + + + + + + )} + + ); +} diff --git a/apps/core-web/src/routes/resellers/ResellersListPage.tsx b/apps/core-web/src/routes/resellers/ResellersListPage.tsx new file mode 100644 index 0000000..ef60cf8 --- /dev/null +++ b/apps/core-web/src/routes/resellers/ResellersListPage.tsx @@ -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(null); + const [error, setError] = useState(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> = [ + { key: "name", header: "Nome", render: (reseller) => {reseller.name} }, + { 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) => {reseller.active ? "Ativo" : "Inativo"}, + }, + { + key: "actions", + header: "", + className: "text-right", + render: (reseller) => ( + navigate(`/resellers/${reseller.id}/edit`)}> + Editar + + ), + }, + ]; + + return ( + + navigate("/resellers/new")}>Nova revenda} + /> + + {error ? ( + + {error} + + ) : null} + + + {resellers === null && !error ? ( + Carregando… + ) : ( + reseller.id} /> + )} + + + ); +} diff --git a/docs/progress.md b/docs/progress.md index 128f567..c8d34ad 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -294,3 +294,30 @@ Verificado: `pnpm turbo run typecheck build` (28/28), `pnpm --filter @eden/api t ### Pendências Toda a Assinatura Eletrônica (eden.md §1.4-1.9/§5/§6/§7 — envelopes, signatários, OTP, hash-chain, verificação pública), geração real de PDF (Playwright/Chromium) + sanitização de HTML, editor visual Tiptap real (`renderToHtml`/`resolveDocument` com condicionais e repetição de tabela), seed dos 7 templates reais de produção da Handix, regra de idempotência completa com `force`/`extra_fields`. + +--- + +## Fase 2/8 — Telas de negócio reais no EDEN Core (Empresas, Produtos, Revendas, CRM, Ofertas) + +Data: 2026-09-03. Usuário pediu explicitamente para priorizar a parte web ("ja faca a parte web para eu ja ver como esta ficando"). 2 subagentes `eden-frontend` em paralelo, mesma sessão, construindo sobre o shell autenticado + Design System já prontos. + +**Correção de instrução no meio da leva**: o usuário interrompeu para dizer "não esqueça de seguir o template" / "melhor usar ele" — as telas deviam seguir de perto o LAYOUT REAL do tema DreamsERP comprado, não só os tokens visuais abstratos já extraídos. Extraí do `tema_do_Eden.zip` as páginas React reais mais relevantes (`sales/salesQuotes*.tsx`, `crm/leads.tsx`/`deals*.tsx`, `inventory/products*.tsx`, `purchase/vendors.tsx`, `system/userManagement.tsx`, `ui-interface/{tables,forms}`) para dentro do worktree de cada agente (`.tmp/theme-reference/`, nunca versionado) e mandei mensagem de correção para os dois agentes já em execução — via `SendMessage`, não relançando a tarefa (no meio do processo cometi o erro de usar `Agent` em vez de `SendMessage` para a primeira correção, criando um agente duplicado; percebido e corrigido na hora com `TaskStop` antes de qualquer trabalho real ser feito por ele). Ambos incorporaram a referência do tema (estrutura de header/breadcrumb/tabela/formulário em cards) reescrita com `@eden/ui` + Tailwind puro — sem copiar JSX nem trazer dependências do tema (antd/Preline) para o projeto. + +### Empresas / Produtos / Revendas (`apps/core-web/src/routes/{companies,products,resellers}/`) + +CRUD completo (lista + criar/editar) para os 3 módulos, layout modelado em `purchase/vendors.tsx` (empresas/revendas) e `inventory/products.tsx` (produtos, com máscara de moeda para as 5 faixas de preço + implantação). Novo componente `Table` em `packages/ui` (tabela genérica reutilizável, sem dependência de terceiro). + +### CRM e Ofertas/Quotes (`apps/core-web/src/routes/{crm,quotes}/`) + +Leads (lista, criar/editar, converter em oportunidade) e Oportunidades (lista, criar/editar) modelados em `crm/leads.tsx`/`dealsDetails.tsx`. Ofertas — o módulo mais rico: `QuotesListPage`, `QuoteFormPage` (revenda + cliente + período de contrato + fidelidade reduzida opcional + lista dinâmica de itens produto/quantidade + desconto opcional + condição de pagamento, mostra os totais calculados pelo servidor antes de ir para o detalhe) e `QuoteDetailPage` (resumo financeiro completo, aprovar/rejeitar quando pendente, fechar negócio quando ainda é orçamento) — modelados em `sales/salesQuotes.tsx`/`addSalesQuotes.tsx`/`salesQuotesDetails.tsx`. Novo componente `Select` em `packages/ui`. + +**Achado real do próprio agente, corrigindo meu brief**: eu tinha dito no brief que as respostas de Leads/Opportunities eram snake_case (copiando o padrão do resto da API) — o agente verificou contra o código-fonte real (`packages/database/src/repositories/leads.ts`) e descobriu que esse módulo específico mapeia para **camelCase** na resposta (`mapLead`), diferente de Quotes/Companies/Products/Resellers (snake_case direto da linha do banco). Confirmado ao vivo via curl antes de integrar. + +### Integração manual (os 2 agentes trabalharam em paralelo no mesmo `core-web`) + +Um dos agentes não conseguiu `git merge` dentro do próprio sandbox e reconstruiu manualmente (leitura+escrita) o estado já existente do shell autenticado — comparei arquivo por arquivo contra `main` (`diff -rq`) para confirmar que a restauração foi fiel (nenhum componente restaurado divergiu) antes de aceitar qualquer coisa como "nova". `App.tsx`, `AuthenticatedLayout.tsx` (nav unificada, lógica de "ativo" por prefixo de path com borda de `/` para nunca casar `/companies` com `/companies-x`) e `packages/ui/src/index.ts` foram reconciliados manualmente juntando as rotas/exports dos dois agentes; `lib/format.ts` teve as duas versões mescladas (`formatDocument` de um lado, `formatDateBR` do outro). + +Verificado: `pnpm turbo run typecheck build` (28/28). Containers `eden-api`/`eden-core` reconstruídos (o build em background travou duas vezes por algum motivo de recurso do ambiente — refeito em primeiro plano, funcionou); bundle de produção confirmado contendo todas as novas telas; login simulando requisição real de browser (`Origin: http://localhost:3001`) funcionando; listagens de Empresas/Produtos/Revendas retornando dados reais; criação de lead confirmando o formato camelCase ao vivo. + +### Pendências +Client Registration (Cadastro de Cliente) ainda sem tela — `QuoteDetailPage` hoje exige colar manualmente um UUID para fechar negócio. Nenhuma tela de Contracts/Inventory/Support/HR-Timeclock/Fiscal/Billing/Documents ainda. `reseller-web`/`subscriber-web` continuam só com o shell de login (sem telas de negócio). diff --git a/packages/ui/src/components/Card.tsx b/packages/ui/src/components/Card.tsx index 18d3025..0321931 100644 --- a/packages/ui/src/components/Card.tsx +++ b/packages/ui/src/components/Card.tsx @@ -4,10 +4,12 @@ import { cn } from "../utils/cn.js"; export interface CardProps extends Omit, "title"> { title?: ReactNode; actions?: ReactNode; + /** Overrides the default `p-5` on the content wrapper — e.g. `"p-0"` for a flush data table (theme source: sales/salesQuotes.tsx uses `p-4 pb-0` around its table). */ + bodyClassName?: string; } /** Theme source (auth-pages/login.tsx): `bg-white border border-border-color rounded-md shadow-sm`. */ -export function Card({ title, actions, className, children, ...rest }: CardProps) { +export function Card({ title, actions, className, bodyClassName, children, ...rest }: CardProps) { return ( {title || actions ? ( @@ -16,7 +18,7 @@ export function Card({ title, actions, className, children, ...rest }: CardProps {actions ? {actions} : null} ) : null} - {children} + {children} ); } diff --git a/packages/ui/src/components/Select.tsx b/packages/ui/src/components/Select.tsx new file mode 100644 index 0000000..b97a50b --- /dev/null +++ b/packages/ui/src/components/Select.tsx @@ -0,0 +1,93 @@ +import { forwardRef, useId } from "react"; +import type { SelectHTMLAttributes } from "react"; +import { cn } from "../utils/cn.js"; + +export interface SelectOption { + value: string; + label: string; +} + +export interface SelectProps extends Omit, "children"> { + label?: string; + /** Validation/server error message — shown below the field, field border turns danger. */ + error?: string; + /** Helper text shown below the field when there's no error. */ + hint?: string; + options: SelectOption[]; + /** Rendered as a disabled, selected-by-default first option (e.g. "Selecione..."). */ + placeholder?: string; +} + +/** + * Native `` styled to match `Input` (packages/ui/src/components/Input.tsx) + * — same border/padding/focus-ring language as the rest of the Design + * System. Derived from the DreamsERP theme's form field sizing + * (`.tmp/theme-reference/sales/addSalesQuotes.tsx` etc. use a + * `react-select`-based CommonSelect for the same slots, out of our stack — + * this is a plain, dependency-free equivalent with the same visual sizing). + */ +export const Select = forwardRef(function Select( + { label, error, hint, id, className, options, placeholder, ...rest }, + ref, +) { + const generatedId = useId(); + const selectId = id ?? generatedId; + + return ( + + {label ? ( + + {label} + + ) : null} + + + {placeholder ? ( + + {placeholder} + + ) : null} + {options.map((option) => ( + + {option.label} + + ))} + + + + + + {error ? ( + + {error} + + ) : hint ? ( + + {hint} + + ) : null} + + ); +}); diff --git a/packages/ui/src/components/Table.tsx b/packages/ui/src/components/Table.tsx new file mode 100644 index 0000000..94c0bd4 --- /dev/null +++ b/packages/ui/src/components/Table.tsx @@ -0,0 +1,64 @@ +import type { ReactNode } from "react"; +import { cn } from "../utils/cn.js"; + +export interface TableColumn { + key: string; + header: ReactNode; + render: (row: T) => ReactNode; + /** Applied to both the header and body cells — e.g. `"text-right"` for numeric columns. */ + className?: string; +} + +export interface TableProps { + columns: Array>; + data: T[]; + rowKey: (row: T) => string; + /** Shown instead of the table body when `data` is empty (and not loading). */ + emptyMessage?: ReactNode; +} + +/** + * Plain data table styled to match the DreamsERP theme's list screens + * (react/src/pages/inventory/products.tsx et al.) without pulling in the + * theme's antd/react-window dependencies — just a semantic ``. + */ +export function Table({ columns, data, rowKey, emptyMessage = "Nenhum registro encontrado." }: TableProps) { + return ( + + + + + {columns.map((column) => ( + + {column.header} + + ))} + + + + {data.length === 0 ? ( + + + {emptyMessage} + + + ) : ( + data.map((row) => ( + + {columns.map((column) => ( + + {column.render(row)} + + ))} + + )) + )} + + + + ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index d0966ae..3190229 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -13,6 +13,12 @@ export type { ButtonProps, ButtonVariant, ButtonSize } from "./components/Button export { Input } from "./components/Input.js"; export type { InputProps } from "./components/Input.js"; +export { Select } from "./components/Select.js"; +export type { SelectProps, SelectOption } from "./components/Select.js"; + +export { Table } from "./components/Table.js"; +export type { TableProps, TableColumn } from "./components/Table.js"; + export { Card } from "./components/Card.js"; export type { CardProps } from "./components/Card.js";
(`/quotes/${id}/close`, { method: "POST", body: JSON.stringify(input) }, { accessToken }); +} diff --git a/apps/core-web/src/lib/api/resellers.ts b/apps/core-web/src/lib/api/resellers.ts new file mode 100644 index 0000000..e2aa409 --- /dev/null +++ b/apps/core-web/src/lib/api/resellers.ts @@ -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; + +export function listResellers(accessToken: string): Promise { + return apiRequest("/resellers", { method: "GET" }, { accessToken }); +} + +export function getReseller(id: string, accessToken: string): Promise { + return apiRequest(`/resellers/${id}`, { method: "GET" }, { accessToken }); +} + +export function createReseller(input: CreateResellerInput, accessToken: string): Promise { + return apiRequest("/resellers", { method: "POST", body: JSON.stringify(input) }, { accessToken }); +} + +export function updateReseller( + id: string, + input: UpdateResellerInput, + accessToken: string, +): Promise { + return apiRequest( + `/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 }, + ); +} diff --git a/apps/core-web/src/lib/format.ts b/apps/core-web/src/lib/format.ts new file mode 100644 index 0000000..c30f27b --- /dev/null +++ b/apps/core-web/src/lib/format.ts @@ -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; +} diff --git a/apps/core-web/src/routes/AuthenticatedLayout.tsx b/apps/core-web/src/routes/AuthenticatedLayout.tsx index c2d385b..3efa306 100644 --- a/apps/core-web/src/routes/AuthenticatedLayout.tsx +++ b/apps/core-web/src/routes/AuthenticatedLayout.tsx @@ -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() { (null); + const [error, setError] = useState(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> = [ + { + key: "name", + header: "Nome", + render: (company) => ( + + {company.company_name} + {company.trade_name ? {company.trade_name} : null} + + ), + }, + { 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) => ( + {company.is_active ? "Ativo" : "Inativo"} + ), + }, + { + key: "actions", + header: "", + className: "text-right", + render: (company) => ( + navigate(`/companies/${company.id}/edit`)}> + Editar + + ), + }, + ]; + + return ( + + navigate("/companies/new")}>Nova empresa} + /> + + {error ? ( + + {error} + + ) : null} + + + {companies === null && !error ? ( + Carregando… + ) : ( + company.id} /> + )} + + + ); +} diff --git a/apps/core-web/src/routes/companies/CompanyFormPage.tsx b/apps/core-web/src/routes/companies/CompanyFormPage.tsx new file mode 100644 index 0000000..c69ae6d --- /dev/null +++ b/apps/core-web/src/routes/companies/CompanyFormPage.tsx @@ -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 { + 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(EMPTY_FORM); + const [loading, setLoading] = useState(isEditing); + const [error, setError] = useState(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(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 ( + + + ← Voltar para Empresas + + } + actions={ + <> + navigate("/companies")} type="button"> + Cancelar + + + Salvar + + > + } + /> + + {error ? ( + + {error} + + ) : null} + + {loading ? ( + + Carregando… + + ) : ( + + + + + updateField("companyName", e.target.value)} + required + /> + + updateField("tradeName", e.target.value)} + /> + updateField("cnpj", e.target.value)} /> + updateField("stateRegistration", e.target.value)} + /> + updateField("municipalRegistration", e.target.value)} + /> + {isEditing ? ( + + Status + + updateField("isActive", e.target.checked)} + /> + Empresa ativa + + + ) : null} + + + + + + updateField("addressZip", e.target.value)} /> + + updateField("addressStreet", e.target.value)} + /> + + updateField("addressNumber", e.target.value)} + /> + updateField("addressComplement", e.target.value)} + /> + updateField("addressNeighborhood", e.target.value)} + /> + updateField("addressCity", e.target.value)} + /> + updateField("addressState", e.target.value.toUpperCase())} + /> + updateField("addressCountry", e.target.value)} + placeholder="Brasil" + /> + + + + + + updateField("phone", e.target.value)} /> + updateField("email", e.target.value)} + /> + updateField("website", e.target.value)} /> + + + + + + updateField("legalRepName", e.target.value)} + /> + updateField("legalRepCpf", e.target.value)} + /> + updateField("legalRepRole", e.target.value)} + /> + updateField("legalRepEmail", e.target.value)} + /> + + + + )} + + ); +} diff --git a/apps/core-web/src/routes/crm/ConvertLeadPage.tsx b/apps/core-web/src/routes/crm/ConvertLeadPage.tsx new file mode 100644 index 0000000..d914c51 --- /dev/null +++ b/apps/core-web/src/routes/crm/ConvertLeadPage.tsx @@ -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 = { + 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(null); + const [title, setTitle] = useState(""); + const [stage, setStage] = useState("prospecting"); + const [probability, setProbability] = useState(""); + const [notes, setNotes] = useState(""); + + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(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 ( + + + + + {error ? ( + + {error} + + ) : null} + + + {loading ? ( + Carregando lead… + ) : ( + + setTitle(e.target.value)} + required + autoFocus + /> + setStage(e.target.value as OpportunityStage)} + /> + setProbability(e.target.value)} + /> + + Notas + setNotes(e.target.value)} + /> + + + + Converter + + navigate("/leads")}> + Cancelar + + + + )} + + + ); +} diff --git a/apps/core-web/src/routes/crm/CrmSubNav.tsx b/apps/core-web/src/routes/crm/CrmSubNav.tsx new file mode 100644 index 0000000..03d5697 --- /dev/null +++ b/apps/core-web/src/routes/crm/CrmSubNav.tsx @@ -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 ( + + + Leads + + + Oportunidades + + + ); +} diff --git a/apps/core-web/src/routes/crm/LeadFormPage.tsx b/apps/core-web/src/routes/crm/LeadFormPage.tsx new file mode 100644 index 0000000..0d41695 --- /dev/null +++ b/apps/core-web/src/routes/crm/LeadFormPage.tsx @@ -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 = { + 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("new"); + const [notes, setNotes] = useState(""); + + const [loading, setLoading] = useState(isEdit); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(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 ( + + + + + {error ? ( + + {error} + + ) : null} + + + {loading ? ( + Carregando lead… + ) : ( + + setContactName(e.target.value)} + required + autoFocus + /> + setCompanyName(e.target.value)} /> + setContactEmail(e.target.value)} + /> + setContactPhone(e.target.value)} /> + setSource(e.target.value)} hint="Ex.: Site, indicação, campanha" /> + setStatus(e.target.value as LeadStatus)} + /> + + Notas + setNotes(e.target.value)} + /> + + + + {isEdit ? "Salvar alterações" : "Criar lead"} + + navigate("/leads")}> + Cancelar + + + + )} + + + ); +} diff --git a/apps/core-web/src/routes/crm/LeadsListPage.tsx b/apps/core-web/src/routes/crm/LeadsListPage.tsx new file mode 100644 index 0000000..e8ca3ab --- /dev/null +++ b/apps/core-web/src/routes/crm/LeadsListPage.tsx @@ -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 = { + new: "Novo", + contacted: "Contatado", + qualified: "Qualificado", + converted: "Convertido", + discarded: "Descartado", +}; + +const STATUS_VARIANT: Record = { + new: "info", + contacted: "warning", + qualified: "primary", + converted: "success", + discarded: "danger", +}; + +export function LeadsListPage() { + const { accessToken } = useAuth(); + const [leads, setLeads] = useState(null); + const [error, setError] = useState(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 ( + + + + Novo lead + + } + /> + + {error ? ( + + {error} + + ) : null} + + + {leads === null && !error ? ( + Carregando leads… + ) : leads && leads.length === 0 ? ( + Nenhum lead cadastrado ainda. + ) : leads ? ( + + + + + Contato + Empresa + Status + Ações + + + + {leads.map((lead) => { + const canConvert = lead.status !== "converted" && lead.status !== "discarded"; + return ( + + + + {lead.contactName} + + {lead.contactEmail ? {lead.contactEmail} : null} + + {lead.companyName ?? "—"} + + + {STATUS_LABEL[lead.status] ?? lead.status} + + + + + + Editar + + {canConvert ? ( + + Converter em oportunidade + + ) : null} + + + + ); + })} + + + + ) : null} + + + ); +} diff --git a/apps/core-web/src/routes/crm/OpportunitiesListPage.tsx b/apps/core-web/src/routes/crm/OpportunitiesListPage.tsx new file mode 100644 index 0000000..7e83021 --- /dev/null +++ b/apps/core-web/src/routes/crm/OpportunitiesListPage.tsx @@ -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 = { + prospecting: "Prospecção", + qualification: "Qualificação", + proposal: "Proposta", + negotiation: "Negociação", + won: "Ganha", + lost: "Perdida", +}; + +const STAGE_VARIANT: Record = { + prospecting: "info", + qualification: "primary", + proposal: "warning", + negotiation: "secondary", + won: "success", + lost: "danger", +}; + +export function OpportunitiesListPage() { + const { accessToken } = useAuth(); + const [opportunities, setOpportunities] = useState(null); + const [error, setError] = useState(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 ( + + + + Nova oportunidade + + } + /> + + {error ? ( + + {error} + + ) : null} + + + {opportunities === null && !error ? ( + Carregando oportunidades… + ) : opportunities && opportunities.length === 0 ? ( + Nenhuma oportunidade cadastrada ainda. + ) : opportunities ? ( + + + + + Título + Estágio + Probabilidade + Dono + Ações + + + + {opportunities.map((opportunity) => ( + + + + {opportunity.title} + + + + + {STAGE_LABEL[opportunity.stage] ?? opportunity.stage} + + + + {opportunity.probability != null ? `${opportunity.probability}%` : "—"} + + {opportunity.ownerId ?? "—"} + + + Editar + + + + ))} + + + + ) : null} + + + ); +} diff --git a/apps/core-web/src/routes/crm/OpportunityFormPage.tsx b/apps/core-web/src/routes/crm/OpportunityFormPage.tsx new file mode 100644 index 0000000..8fe8042 --- /dev/null +++ b/apps/core-web/src/routes/crm/OpportunityFormPage.tsx @@ -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 = { + 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("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(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 ( + + + + + {error ? ( + + {error} + + ) : null} + + + {loading ? ( + Carregando oportunidade… + ) : ( + + setTitle(e.target.value)} + required + autoFocus + /> + setStage(e.target.value as OpportunityStage)} + /> + setProbability(e.target.value)} + /> + {stage === "lost" ? ( + setLossReason(e.target.value)} + /> + ) : null} + + Notas + setNotes(e.target.value)} + /> + + + + {isEdit ? "Salvar alterações" : "Criar oportunidade"} + + navigate("/opportunities")}> + Cancelar + + + + )} + + + ); +} diff --git a/apps/core-web/src/routes/products/ProductFormPage.tsx b/apps/core-web/src/routes/products/ProductFormPage.tsx new file mode 100644 index 0000000..8c8d9b9 --- /dev/null +++ b/apps/core-web/src/routes/products/ProductFormPage.tsx @@ -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 ( + 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(EMPTY_FORM); + const [loading, setLoading] = useState(isEditing); + const [error, setError] = useState(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(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 ( + + + ← Voltar para Produtos + + } + actions={ + <> + navigate("/products")} type="button"> + Cancelar + + + Salvar + + > + } + /> + + {error ? ( + + {error} + + ) : null} + + {loading ? ( + + Carregando… + + ) : ( + + + + + updateField("name", e.target.value)} required /> + + updateField("productType", e.target.value)} + required + /> + updateField("unit", e.target.value)} /> + updateField("productGroup", e.target.value)} + /> + updateField("productSubgroup", e.target.value)} + /> + updateField("brand", e.target.value)} /> + + + Descrição + + updateField("description", e.target.value)} + /> + + + + + + + updateField("price0Cents", v)} /> + updateField("price12Cents", v)} /> + updateField("price24Cents", v)} /> + updateField("price36Cents", v)} /> + updateField("price48Cents", v)} /> + updateField("implUnitPriceCents", v)} + /> + + + + + + {( + [ + ["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]) => ( + + updateField(field, e.target.checked)} + /> + {label} + + ))} + + + + )} + + ); +} diff --git a/apps/core-web/src/routes/products/ProductsListPage.tsx b/apps/core-web/src/routes/products/ProductsListPage.tsx new file mode 100644 index 0000000..14deccb --- /dev/null +++ b/apps/core-web/src/routes/products/ProductsListPage.tsx @@ -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(null); + const [error, setError] = useState(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> = [ + { key: "code", header: "Código", render: (product) => `#${product.product_code}` }, + { key: "name", header: "Nome", render: (product) => {product.name} }, + { key: "type", header: "Tipo", render: (product) => product.product_type }, + ...PRICE_COLUMNS.map( + (column): TableColumn => ({ + 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 ? ( + Descontinuado + ) : ( + {product.active ? "Ativo" : "Inativo"} + ), + }, + { + key: "actions", + header: "", + className: "text-right", + render: (product) => ( + navigate(`/products/${product.id}/edit`)}> + Editar + + ), + }, + ]; + + return ( + + navigate("/products/new")}>Novo produto} + /> + + {error ? ( + + {error} + + ) : null} + + + {products === null && !error ? ( + Carregando… + ) : ( + product.id} /> + )} + + + ); +} diff --git a/apps/core-web/src/routes/quotes/QuoteDetailPage.tsx b/apps/core-web/src/routes/quotes/QuoteDetailPage.tsx new file mode 100644 index 0000000..bec95a5 --- /dev/null +++ b/apps/core-web/src/routes/quotes/QuoteDetailPage.tsx @@ -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 = { + none: "Sem aprovação", + pending: "Pendente", + approved: "Aprovada", + rejected: "Rejeitada", +}; + +const APPROVAL_VARIANT: Record = { + none: "neutral", + pending: "warning", + approved: "success", + rejected: "danger", +}; + +const DEAL_LABEL: Record = { + orcamento: "Orçamento", + fechado: "Fechado", + perdido: "Perdido", +}; + +const DEAL_VARIANT: Record = { + orcamento: "info", + fechado: "success", + perdido: "danger", +}; + +export function QuoteDetailPage() { + const { id } = useParams<{ id: string }>(); + const { accessToken } = useAuth(); + + const [quote, setQuote] = useState(null); + const [reseller, setReseller] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( + + + Carregando oferta… + + ); + } + + if (error || !quote) { + return ( + + + {error ?? "Oferta não encontrada."} + + ); + } + + return ( + + + + {APPROVAL_LABEL[quote.approval_status]} + {DEAL_LABEL[quote.deal_status]} + + } + /> + + + + + + + + + + + + + + + + + + + + + + + + + Produto + Qtd. + Preço unit. + Total + Impl. unit. + Impl. total + + + + {quote.items.map((item, index) => ( + + {item.productName} + {item.quantity} + {formatCurrencyBRL(item.unitPrice)} + {formatCurrencyBRL(item.total)} + {formatCurrencyBRL(item.implUnitPrice)} + {formatCurrencyBRL(item.implTotal)} + + ))} + + + + + + + + + {quote.proposed_monthly_total != null ? ( + + ) : null} + + + + + {quote.notes ? ( + + Notas + {quote.notes} + + ) : null} + + + {quote.approval_status === "pending" ? ( + + ) : quote.approval_status !== "none" && quote.approval_notes ? ( + + {quote.approval_notes} + + ) : null} + + {quote.deal_status === "orcamento" ? ( + + ) : quote.client_registration_id ? ( + + + + ) : null} + + + + ); +} + +function Row({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ); +} + +function SummaryRow({ label, value, highlight }: { label: string; value: string; highlight?: boolean }) { + return ( + + {label} + + {value} + + + ); +} + +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(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 ( + + {error ? ( + + {error} + + ) : null} + + Esta oferta tem desconto e/ou fidelidade reduzida e precisa de aprovação antes de seguir. + + setNotes(e.target.value)} + className="mb-3" + /> + + decide("approved")} loading={submitting === "approved"}> + Aprovar + + decide("rejected")} loading={submitting === "rejected"}> + Rejeitar + + + + ); +} + +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(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 ( + + {error ? ( + + {error} + + ) : null} + + setClientRegistrationId(e.target.value)} + hint="Cole o UUID de um cadastro de cliente já existente" + className="min-w-72 flex-1" + required + /> + + Fechar negócio + + + + ); +} diff --git a/apps/core-web/src/routes/quotes/QuoteFormPage.tsx b/apps/core-web/src/routes/quotes/QuoteFormPage.tsx new file mode 100644 index 0000000..1ac1f6e --- /dev/null +++ b/apps/core-web/src/routes/quotes/QuoteFormPage.tsx @@ -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(null); + const [products, setProducts] = useState(null); + const [loadError, setLoadError] = useState(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("12"); + const [fidelityPeriod, setFidelityPeriod] = useState(""); + const [items, setItems] = useState([{ ...EMPTY_ROW }]); + const [implGeral, setImplGeral] = useState(""); + const [proposedMonthlyTotal, setProposedMonthlyTotal] = useState(""); + const [implPaymentCondition, setImplPaymentCondition] = useState(""); + const [notes, setNotes] = useState(""); + + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [createdQuote, setCreatedQuote] = useState(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) { + 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 ( + + + + Oferta criada com sucesso. Confira os totais calculados pelo servidor abaixo. + + + + + + + + {createdQuote.approval_status === "pending" ? ( + + Esta oferta possui desconto e/ou fidelidade reduzida — aguarda aprovação. + + ) : null} + + + Ver oferta completa + + navigate("/quotes")}> + Voltar para a lista + + + + + ); + } + + return ( + + + + + {loadError ? ( + + {loadError} + + ) : null} + {error ? ( + + {error} + + ) : null} + + + {!resellers || !products ? ( + Carregando revendas e produtos… + ) : ( + + + Dados do cliente + + ({ value: r.id, label: r.name }))} + placeholder="Selecione a revenda" + value={resellerId} + onChange={(e) => setResellerId(e.target.value)} + required + /> + setClientName(e.target.value)} required /> + setClientCompany(e.target.value)} /> + setClientEmail(e.target.value)} /> + setClientPhone(e.target.value)} required /> + + + + + Contrato + + ({ + value: String(period), + label: period === 0 ? "Sem fidelidade (0 meses)" : `${period} meses`, + }))} + value={contractPeriod} + onChange={(e) => setContractPeriod(e.target.value)} + required + /> + setFidelityPeriod(e.target.value)} + hint="Só preencha se for menor que o período do contrato — entra em aprovação" + /> + + + + + + Itens + + + Adicionar item + + + + + + + Produto + Quantidade + + + + + {items.map((row, index) => ( + + + updateItem(index, { productId: e.target.value })} + aria-label="Produto" + required + /> + + + updateItem(index, { quantity: e.target.value })} + aria-label="Quantidade" + required + /> + + + {items.length > 1 ? ( + removeItem(index)} + className="rounded-md border border-border-color px-2 py-1 text-xs font-semibold text-danger hover:bg-light" + > + Remover + + ) : null} + + + ))} + + + + + + + Implantação e valores + + setImplGeral(e.target.value)} + hint="Custo de implantação além dos produtos" + /> + setProposedMonthlyTotal(e.target.value)} + hint="Preencha só se for oferecer desconto — a oferta precisará de aprovação" + /> + + ({ + value: condition, + label: condition, + }))} + placeholder="Selecione a condição" + value={implPaymentCondition} + onChange={(e) => setImplPaymentCondition(e.target.value as ImplPaymentCondition)} + /> + + Faixas sugeridas para {formatCurrencyBRL(estimatedImplTotal)} de implantação estimada:{" "} + {availablePaymentConditions.map((c) => ( + + {c} + + ))} + {" "}— o servidor valida a faixa real ao salvar. + + + + + + + Notas + setNotes(e.target.value)} + /> + + + + + Criar oferta + + navigate("/quotes")}> + Cancelar + + + + )} + + + ); +} + +function SummaryField({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ); +} diff --git a/apps/core-web/src/routes/quotes/QuotesListPage.tsx b/apps/core-web/src/routes/quotes/QuotesListPage.tsx new file mode 100644 index 0000000..b1bc219 --- /dev/null +++ b/apps/core-web/src/routes/quotes/QuotesListPage.tsx @@ -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 = { + none: "Sem aprovação", + pending: "Pendente", + approved: "Aprovada", + rejected: "Rejeitada", +}; + +const APPROVAL_VARIANT: Record = { + none: "neutral", + pending: "warning", + approved: "success", + rejected: "danger", +}; + +const DEAL_LABEL: Record = { + orcamento: "Orçamento", + fechado: "Fechado", + perdido: "Perdido", +}; + +const DEAL_VARIANT: Record = { + orcamento: "info", + fechado: "success", + perdido: "danger", +}; + +export function QuotesListPage() { + const { accessToken } = useAuth(); + const [quotes, setQuotes] = useState(null); + const [error, setError] = useState(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 ( + + + Nova oferta + + } + /> + + {error ? ( + + {error} + + ) : null} + + + {quotes === null && !error ? ( + Carregando ofertas… + ) : quotes && quotes.length === 0 ? ( + Nenhuma oferta cadastrada ainda. + ) : quotes ? ( + + + + + Oferta + Cliente + Mensalidade + Aprovação + Negócio + + + + {quotes.map((quote) => ( + + + + {quote.quote_number} + + + + {quote.client_name} + {quote.client_company ? {quote.client_company} : null} + + + {formatCurrencyBRL(quote.proposed_monthly_total ?? quote.monthly_total)} + + + + {APPROVAL_LABEL[quote.approval_status]} + + + + {DEAL_LABEL[quote.deal_status]} + + + ))} + + + + ) : null} + + + ); +} diff --git a/apps/core-web/src/routes/resellers/ResellerFormPage.tsx b/apps/core-web/src/routes/resellers/ResellerFormPage.tsx new file mode 100644 index 0000000..be7c921 --- /dev/null +++ b/apps/core-web/src/routes/resellers/ResellerFormPage.tsx @@ -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(EMPTY_FORM); + const [loading, setLoading] = useState(isEditing); + const [error, setError] = useState(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(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 ( + + + ← Voltar para Revendas + + } + actions={ + <> + navigate("/resellers")} type="button"> + Cancelar + + + Salvar + + > + } + /> + + {error ? ( + + {error} + + ) : null} + + {loading ? ( + + Carregando… + + ) : ( + + + + + updateField("name", e.target.value)} required /> + + updateField("document", e.target.value)} /> + updateField("contactEmail", e.target.value)} + /> + + Status + + updateField("active", e.target.checked)} + /> + Revenda ativa + + + + + + )} + + ); +} diff --git a/apps/core-web/src/routes/resellers/ResellersListPage.tsx b/apps/core-web/src/routes/resellers/ResellersListPage.tsx new file mode 100644 index 0000000..ef60cf8 --- /dev/null +++ b/apps/core-web/src/routes/resellers/ResellersListPage.tsx @@ -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(null); + const [error, setError] = useState(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> = [ + { key: "name", header: "Nome", render: (reseller) => {reseller.name} }, + { 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) => {reseller.active ? "Ativo" : "Inativo"}, + }, + { + key: "actions", + header: "", + className: "text-right", + render: (reseller) => ( + navigate(`/resellers/${reseller.id}/edit`)}> + Editar + + ), + }, + ]; + + return ( + + navigate("/resellers/new")}>Nova revenda} + /> + + {error ? ( + + {error} + + ) : null} + + + {resellers === null && !error ? ( + Carregando… + ) : ( + reseller.id} /> + )} + + + ); +} diff --git a/docs/progress.md b/docs/progress.md index 128f567..c8d34ad 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -294,3 +294,30 @@ Verificado: `pnpm turbo run typecheck build` (28/28), `pnpm --filter @eden/api t ### Pendências Toda a Assinatura Eletrônica (eden.md §1.4-1.9/§5/§6/§7 — envelopes, signatários, OTP, hash-chain, verificação pública), geração real de PDF (Playwright/Chromium) + sanitização de HTML, editor visual Tiptap real (`renderToHtml`/`resolveDocument` com condicionais e repetição de tabela), seed dos 7 templates reais de produção da Handix, regra de idempotência completa com `force`/`extra_fields`. + +--- + +## Fase 2/8 — Telas de negócio reais no EDEN Core (Empresas, Produtos, Revendas, CRM, Ofertas) + +Data: 2026-09-03. Usuário pediu explicitamente para priorizar a parte web ("ja faca a parte web para eu ja ver como esta ficando"). 2 subagentes `eden-frontend` em paralelo, mesma sessão, construindo sobre o shell autenticado + Design System já prontos. + +**Correção de instrução no meio da leva**: o usuário interrompeu para dizer "não esqueça de seguir o template" / "melhor usar ele" — as telas deviam seguir de perto o LAYOUT REAL do tema DreamsERP comprado, não só os tokens visuais abstratos já extraídos. Extraí do `tema_do_Eden.zip` as páginas React reais mais relevantes (`sales/salesQuotes*.tsx`, `crm/leads.tsx`/`deals*.tsx`, `inventory/products*.tsx`, `purchase/vendors.tsx`, `system/userManagement.tsx`, `ui-interface/{tables,forms}`) para dentro do worktree de cada agente (`.tmp/theme-reference/`, nunca versionado) e mandei mensagem de correção para os dois agentes já em execução — via `SendMessage`, não relançando a tarefa (no meio do processo cometi o erro de usar `Agent` em vez de `SendMessage` para a primeira correção, criando um agente duplicado; percebido e corrigido na hora com `TaskStop` antes de qualquer trabalho real ser feito por ele). Ambos incorporaram a referência do tema (estrutura de header/breadcrumb/tabela/formulário em cards) reescrita com `@eden/ui` + Tailwind puro — sem copiar JSX nem trazer dependências do tema (antd/Preline) para o projeto. + +### Empresas / Produtos / Revendas (`apps/core-web/src/routes/{companies,products,resellers}/`) + +CRUD completo (lista + criar/editar) para os 3 módulos, layout modelado em `purchase/vendors.tsx` (empresas/revendas) e `inventory/products.tsx` (produtos, com máscara de moeda para as 5 faixas de preço + implantação). Novo componente `Table` em `packages/ui` (tabela genérica reutilizável, sem dependência de terceiro). + +### CRM e Ofertas/Quotes (`apps/core-web/src/routes/{crm,quotes}/`) + +Leads (lista, criar/editar, converter em oportunidade) e Oportunidades (lista, criar/editar) modelados em `crm/leads.tsx`/`dealsDetails.tsx`. Ofertas — o módulo mais rico: `QuotesListPage`, `QuoteFormPage` (revenda + cliente + período de contrato + fidelidade reduzida opcional + lista dinâmica de itens produto/quantidade + desconto opcional + condição de pagamento, mostra os totais calculados pelo servidor antes de ir para o detalhe) e `QuoteDetailPage` (resumo financeiro completo, aprovar/rejeitar quando pendente, fechar negócio quando ainda é orçamento) — modelados em `sales/salesQuotes.tsx`/`addSalesQuotes.tsx`/`salesQuotesDetails.tsx`. Novo componente `Select` em `packages/ui`. + +**Achado real do próprio agente, corrigindo meu brief**: eu tinha dito no brief que as respostas de Leads/Opportunities eram snake_case (copiando o padrão do resto da API) — o agente verificou contra o código-fonte real (`packages/database/src/repositories/leads.ts`) e descobriu que esse módulo específico mapeia para **camelCase** na resposta (`mapLead`), diferente de Quotes/Companies/Products/Resellers (snake_case direto da linha do banco). Confirmado ao vivo via curl antes de integrar. + +### Integração manual (os 2 agentes trabalharam em paralelo no mesmo `core-web`) + +Um dos agentes não conseguiu `git merge` dentro do próprio sandbox e reconstruiu manualmente (leitura+escrita) o estado já existente do shell autenticado — comparei arquivo por arquivo contra `main` (`diff -rq`) para confirmar que a restauração foi fiel (nenhum componente restaurado divergiu) antes de aceitar qualquer coisa como "nova". `App.tsx`, `AuthenticatedLayout.tsx` (nav unificada, lógica de "ativo" por prefixo de path com borda de `/` para nunca casar `/companies` com `/companies-x`) e `packages/ui/src/index.ts` foram reconciliados manualmente juntando as rotas/exports dos dois agentes; `lib/format.ts` teve as duas versões mescladas (`formatDocument` de um lado, `formatDateBR` do outro). + +Verificado: `pnpm turbo run typecheck build` (28/28). Containers `eden-api`/`eden-core` reconstruídos (o build em background travou duas vezes por algum motivo de recurso do ambiente — refeito em primeiro plano, funcionou); bundle de produção confirmado contendo todas as novas telas; login simulando requisição real de browser (`Origin: http://localhost:3001`) funcionando; listagens de Empresas/Produtos/Revendas retornando dados reais; criação de lead confirmando o formato camelCase ao vivo. + +### Pendências +Client Registration (Cadastro de Cliente) ainda sem tela — `QuoteDetailPage` hoje exige colar manualmente um UUID para fechar negócio. Nenhuma tela de Contracts/Inventory/Support/HR-Timeclock/Fiscal/Billing/Documents ainda. `reseller-web`/`subscriber-web` continuam só com o shell de login (sem telas de negócio). diff --git a/packages/ui/src/components/Card.tsx b/packages/ui/src/components/Card.tsx index 18d3025..0321931 100644 --- a/packages/ui/src/components/Card.tsx +++ b/packages/ui/src/components/Card.tsx @@ -4,10 +4,12 @@ import { cn } from "../utils/cn.js"; export interface CardProps extends Omit, "title"> { title?: ReactNode; actions?: ReactNode; + /** Overrides the default `p-5` on the content wrapper — e.g. `"p-0"` for a flush data table (theme source: sales/salesQuotes.tsx uses `p-4 pb-0` around its table). */ + bodyClassName?: string; } /** Theme source (auth-pages/login.tsx): `bg-white border border-border-color rounded-md shadow-sm`. */ -export function Card({ title, actions, className, children, ...rest }: CardProps) { +export function Card({ title, actions, className, bodyClassName, children, ...rest }: CardProps) { return ( {title || actions ? ( @@ -16,7 +18,7 @@ export function Card({ title, actions, className, children, ...rest }: CardProps {actions ? {actions} : null} ) : null} - {children} + {children} ); } diff --git a/packages/ui/src/components/Select.tsx b/packages/ui/src/components/Select.tsx new file mode 100644 index 0000000..b97a50b --- /dev/null +++ b/packages/ui/src/components/Select.tsx @@ -0,0 +1,93 @@ +import { forwardRef, useId } from "react"; +import type { SelectHTMLAttributes } from "react"; +import { cn } from "../utils/cn.js"; + +export interface SelectOption { + value: string; + label: string; +} + +export interface SelectProps extends Omit, "children"> { + label?: string; + /** Validation/server error message — shown below the field, field border turns danger. */ + error?: string; + /** Helper text shown below the field when there's no error. */ + hint?: string; + options: SelectOption[]; + /** Rendered as a disabled, selected-by-default first option (e.g. "Selecione..."). */ + placeholder?: string; +} + +/** + * Native `` styled to match `Input` (packages/ui/src/components/Input.tsx) + * — same border/padding/focus-ring language as the rest of the Design + * System. Derived from the DreamsERP theme's form field sizing + * (`.tmp/theme-reference/sales/addSalesQuotes.tsx` etc. use a + * `react-select`-based CommonSelect for the same slots, out of our stack — + * this is a plain, dependency-free equivalent with the same visual sizing). + */ +export const Select = forwardRef(function Select( + { label, error, hint, id, className, options, placeholder, ...rest }, + ref, +) { + const generatedId = useId(); + const selectId = id ?? generatedId; + + return ( + + {label ? ( + + {label} + + ) : null} + + + {placeholder ? ( + + {placeholder} + + ) : null} + {options.map((option) => ( + + {option.label} + + ))} + + + + + + {error ? ( + + {error} + + ) : hint ? ( + + {hint} + + ) : null} + + ); +}); diff --git a/packages/ui/src/components/Table.tsx b/packages/ui/src/components/Table.tsx new file mode 100644 index 0000000..94c0bd4 --- /dev/null +++ b/packages/ui/src/components/Table.tsx @@ -0,0 +1,64 @@ +import type { ReactNode } from "react"; +import { cn } from "../utils/cn.js"; + +export interface TableColumn { + key: string; + header: ReactNode; + render: (row: T) => ReactNode; + /** Applied to both the header and body cells — e.g. `"text-right"` for numeric columns. */ + className?: string; +} + +export interface TableProps { + columns: Array>; + data: T[]; + rowKey: (row: T) => string; + /** Shown instead of the table body when `data` is empty (and not loading). */ + emptyMessage?: ReactNode; +} + +/** + * Plain data table styled to match the DreamsERP theme's list screens + * (react/src/pages/inventory/products.tsx et al.) without pulling in the + * theme's antd/react-window dependencies — just a semantic ``. + */ +export function Table({ columns, data, rowKey, emptyMessage = "Nenhum registro encontrado." }: TableProps) { + return ( + + + + + {columns.map((column) => ( + + {column.header} + + ))} + + + + {data.length === 0 ? ( + + + {emptyMessage} + + + ) : ( + data.map((row) => ( + + {columns.map((column) => ( + + {column.render(row)} + + ))} + + )) + )} + + + + ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index d0966ae..3190229 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -13,6 +13,12 @@ export type { ButtonProps, ButtonVariant, ButtonSize } from "./components/Button export { Input } from "./components/Input.js"; export type { InputProps } from "./components/Input.js"; +export { Select } from "./components/Select.js"; +export type { SelectProps, SelectOption } from "./components/Select.js"; + +export { Table } from "./components/Table.js"; +export type { TableProps, TableColumn } from "./components/Table.js"; + export { Card } from "./components/Card.js"; export type { CardProps } from "./components/Card.js";
{company.company_name}
{company.trade_name}
Carregando…
Carregando lead…
Carregando leads…
Nenhum lead cadastrado ainda.
{lead.contactEmail}
Carregando oportunidades…
Nenhuma oportunidade cadastrada ainda.
Carregando oportunidade…
(null); + const [reseller, setReseller] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( + + + Carregando oferta… + + ); + } + + if (error || !quote) { + return ( + + + {error ?? "Oferta não encontrada."} + + ); + } + + return ( + + + + {APPROVAL_LABEL[quote.approval_status]} + {DEAL_LABEL[quote.deal_status]} + + } + /> + + + + + + + + + + + + + + + + + + + + + + + + + Produto + Qtd. + Preço unit. + Total + Impl. unit. + Impl. total + + + + {quote.items.map((item, index) => ( + + {item.productName} + {item.quantity} + {formatCurrencyBRL(item.unitPrice)} + {formatCurrencyBRL(item.total)} + {formatCurrencyBRL(item.implUnitPrice)} + {formatCurrencyBRL(item.implTotal)} + + ))} + + + + + + + + + {quote.proposed_monthly_total != null ? ( + + ) : null} + + + + + {quote.notes ? ( + + Notas + {quote.notes} + + ) : null} + + + {quote.approval_status === "pending" ? ( + + ) : quote.approval_status !== "none" && quote.approval_notes ? ( + + {quote.approval_notes} + + ) : null} + + {quote.deal_status === "orcamento" ? ( + + ) : quote.client_registration_id ? ( + + + + ) : null} + + + + ); +} + +function Row({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ); +} + +function SummaryRow({ label, value, highlight }: { label: string; value: string; highlight?: boolean }) { + return ( + + {label} + + {value} + + + ); +} + +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(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 ( + + {error ? ( + + {error} + + ) : null} + + Esta oferta tem desconto e/ou fidelidade reduzida e precisa de aprovação antes de seguir. + + setNotes(e.target.value)} + className="mb-3" + /> + + decide("approved")} loading={submitting === "approved"}> + Aprovar + + decide("rejected")} loading={submitting === "rejected"}> + Rejeitar + + + + ); +} + +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(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 ( + + {error ? ( + + {error} + + ) : null} + + setClientRegistrationId(e.target.value)} + hint="Cole o UUID de um cadastro de cliente já existente" + className="min-w-72 flex-1" + required + /> + + Fechar negócio + + + + ); +} diff --git a/apps/core-web/src/routes/quotes/QuoteFormPage.tsx b/apps/core-web/src/routes/quotes/QuoteFormPage.tsx new file mode 100644 index 0000000..1ac1f6e --- /dev/null +++ b/apps/core-web/src/routes/quotes/QuoteFormPage.tsx @@ -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(null); + const [products, setProducts] = useState(null); + const [loadError, setLoadError] = useState(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("12"); + const [fidelityPeriod, setFidelityPeriod] = useState(""); + const [items, setItems] = useState([{ ...EMPTY_ROW }]); + const [implGeral, setImplGeral] = useState(""); + const [proposedMonthlyTotal, setProposedMonthlyTotal] = useState(""); + const [implPaymentCondition, setImplPaymentCondition] = useState(""); + const [notes, setNotes] = useState(""); + + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [createdQuote, setCreatedQuote] = useState(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) { + 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 ( + + + + Oferta criada com sucesso. Confira os totais calculados pelo servidor abaixo. + + + + + + + + {createdQuote.approval_status === "pending" ? ( + + Esta oferta possui desconto e/ou fidelidade reduzida — aguarda aprovação. + + ) : null} + + + Ver oferta completa + + navigate("/quotes")}> + Voltar para a lista + + + + + ); + } + + return ( + + + + + {loadError ? ( + + {loadError} + + ) : null} + {error ? ( + + {error} + + ) : null} + + + {!resellers || !products ? ( + Carregando revendas e produtos… + ) : ( + + + Dados do cliente + + ({ value: r.id, label: r.name }))} + placeholder="Selecione a revenda" + value={resellerId} + onChange={(e) => setResellerId(e.target.value)} + required + /> + setClientName(e.target.value)} required /> + setClientCompany(e.target.value)} /> + setClientEmail(e.target.value)} /> + setClientPhone(e.target.value)} required /> + + + + + Contrato + + ({ + value: String(period), + label: period === 0 ? "Sem fidelidade (0 meses)" : `${period} meses`, + }))} + value={contractPeriod} + onChange={(e) => setContractPeriod(e.target.value)} + required + /> + setFidelityPeriod(e.target.value)} + hint="Só preencha se for menor que o período do contrato — entra em aprovação" + /> + + + + + + Itens + + + Adicionar item + + + + + + + Produto + Quantidade + + + + + {items.map((row, index) => ( + + + updateItem(index, { productId: e.target.value })} + aria-label="Produto" + required + /> + + + updateItem(index, { quantity: e.target.value })} + aria-label="Quantidade" + required + /> + + + {items.length > 1 ? ( + removeItem(index)} + className="rounded-md border border-border-color px-2 py-1 text-xs font-semibold text-danger hover:bg-light" + > + Remover + + ) : null} + + + ))} + + + + + + + Implantação e valores + + setImplGeral(e.target.value)} + hint="Custo de implantação além dos produtos" + /> + setProposedMonthlyTotal(e.target.value)} + hint="Preencha só se for oferecer desconto — a oferta precisará de aprovação" + /> + + ({ + value: condition, + label: condition, + }))} + placeholder="Selecione a condição" + value={implPaymentCondition} + onChange={(e) => setImplPaymentCondition(e.target.value as ImplPaymentCondition)} + /> + + Faixas sugeridas para {formatCurrencyBRL(estimatedImplTotal)} de implantação estimada:{" "} + {availablePaymentConditions.map((c) => ( + + {c} + + ))} + {" "}— o servidor valida a faixa real ao salvar. + + + + + + + Notas + setNotes(e.target.value)} + /> + + + + + Criar oferta + + navigate("/quotes")}> + Cancelar + + + + )} + + + ); +} + +function SummaryField({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ); +} diff --git a/apps/core-web/src/routes/quotes/QuotesListPage.tsx b/apps/core-web/src/routes/quotes/QuotesListPage.tsx new file mode 100644 index 0000000..b1bc219 --- /dev/null +++ b/apps/core-web/src/routes/quotes/QuotesListPage.tsx @@ -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 = { + none: "Sem aprovação", + pending: "Pendente", + approved: "Aprovada", + rejected: "Rejeitada", +}; + +const APPROVAL_VARIANT: Record = { + none: "neutral", + pending: "warning", + approved: "success", + rejected: "danger", +}; + +const DEAL_LABEL: Record = { + orcamento: "Orçamento", + fechado: "Fechado", + perdido: "Perdido", +}; + +const DEAL_VARIANT: Record = { + orcamento: "info", + fechado: "success", + perdido: "danger", +}; + +export function QuotesListPage() { + const { accessToken } = useAuth(); + const [quotes, setQuotes] = useState(null); + const [error, setError] = useState(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 ( + + + Nova oferta + + } + /> + + {error ? ( + + {error} + + ) : null} + + + {quotes === null && !error ? ( + Carregando ofertas… + ) : quotes && quotes.length === 0 ? ( + Nenhuma oferta cadastrada ainda. + ) : quotes ? ( + + + + + Oferta + Cliente + Mensalidade + Aprovação + Negócio + + + + {quotes.map((quote) => ( + + + + {quote.quote_number} + + + + {quote.client_name} + {quote.client_company ? {quote.client_company} : null} + + + {formatCurrencyBRL(quote.proposed_monthly_total ?? quote.monthly_total)} + + + + {APPROVAL_LABEL[quote.approval_status]} + + + + {DEAL_LABEL[quote.deal_status]} + + + ))} + + + + ) : null} + + + ); +} diff --git a/apps/core-web/src/routes/resellers/ResellerFormPage.tsx b/apps/core-web/src/routes/resellers/ResellerFormPage.tsx new file mode 100644 index 0000000..be7c921 --- /dev/null +++ b/apps/core-web/src/routes/resellers/ResellerFormPage.tsx @@ -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(EMPTY_FORM); + const [loading, setLoading] = useState(isEditing); + const [error, setError] = useState(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(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 ( + + + ← Voltar para Revendas + + } + actions={ + <> + navigate("/resellers")} type="button"> + Cancelar + + + Salvar + + > + } + /> + + {error ? ( + + {error} + + ) : null} + + {loading ? ( + + Carregando… + + ) : ( + + + + + updateField("name", e.target.value)} required /> + + updateField("document", e.target.value)} /> + updateField("contactEmail", e.target.value)} + /> + + Status + + updateField("active", e.target.checked)} + /> + Revenda ativa + + + + + + )} + + ); +} diff --git a/apps/core-web/src/routes/resellers/ResellersListPage.tsx b/apps/core-web/src/routes/resellers/ResellersListPage.tsx new file mode 100644 index 0000000..ef60cf8 --- /dev/null +++ b/apps/core-web/src/routes/resellers/ResellersListPage.tsx @@ -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(null); + const [error, setError] = useState(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> = [ + { key: "name", header: "Nome", render: (reseller) => {reseller.name} }, + { 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) => {reseller.active ? "Ativo" : "Inativo"}, + }, + { + key: "actions", + header: "", + className: "text-right", + render: (reseller) => ( + navigate(`/resellers/${reseller.id}/edit`)}> + Editar + + ), + }, + ]; + + return ( + + navigate("/resellers/new")}>Nova revenda} + /> + + {error ? ( + + {error} + + ) : null} + + + {resellers === null && !error ? ( + Carregando… + ) : ( + reseller.id} /> + )} + + + ); +} diff --git a/docs/progress.md b/docs/progress.md index 128f567..c8d34ad 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -294,3 +294,30 @@ Verificado: `pnpm turbo run typecheck build` (28/28), `pnpm --filter @eden/api t ### Pendências Toda a Assinatura Eletrônica (eden.md §1.4-1.9/§5/§6/§7 — envelopes, signatários, OTP, hash-chain, verificação pública), geração real de PDF (Playwright/Chromium) + sanitização de HTML, editor visual Tiptap real (`renderToHtml`/`resolveDocument` com condicionais e repetição de tabela), seed dos 7 templates reais de produção da Handix, regra de idempotência completa com `force`/`extra_fields`. + +--- + +## Fase 2/8 — Telas de negócio reais no EDEN Core (Empresas, Produtos, Revendas, CRM, Ofertas) + +Data: 2026-09-03. Usuário pediu explicitamente para priorizar a parte web ("ja faca a parte web para eu ja ver como esta ficando"). 2 subagentes `eden-frontend` em paralelo, mesma sessão, construindo sobre o shell autenticado + Design System já prontos. + +**Correção de instrução no meio da leva**: o usuário interrompeu para dizer "não esqueça de seguir o template" / "melhor usar ele" — as telas deviam seguir de perto o LAYOUT REAL do tema DreamsERP comprado, não só os tokens visuais abstratos já extraídos. Extraí do `tema_do_Eden.zip` as páginas React reais mais relevantes (`sales/salesQuotes*.tsx`, `crm/leads.tsx`/`deals*.tsx`, `inventory/products*.tsx`, `purchase/vendors.tsx`, `system/userManagement.tsx`, `ui-interface/{tables,forms}`) para dentro do worktree de cada agente (`.tmp/theme-reference/`, nunca versionado) e mandei mensagem de correção para os dois agentes já em execução — via `SendMessage`, não relançando a tarefa (no meio do processo cometi o erro de usar `Agent` em vez de `SendMessage` para a primeira correção, criando um agente duplicado; percebido e corrigido na hora com `TaskStop` antes de qualquer trabalho real ser feito por ele). Ambos incorporaram a referência do tema (estrutura de header/breadcrumb/tabela/formulário em cards) reescrita com `@eden/ui` + Tailwind puro — sem copiar JSX nem trazer dependências do tema (antd/Preline) para o projeto. + +### Empresas / Produtos / Revendas (`apps/core-web/src/routes/{companies,products,resellers}/`) + +CRUD completo (lista + criar/editar) para os 3 módulos, layout modelado em `purchase/vendors.tsx` (empresas/revendas) e `inventory/products.tsx` (produtos, com máscara de moeda para as 5 faixas de preço + implantação). Novo componente `Table` em `packages/ui` (tabela genérica reutilizável, sem dependência de terceiro). + +### CRM e Ofertas/Quotes (`apps/core-web/src/routes/{crm,quotes}/`) + +Leads (lista, criar/editar, converter em oportunidade) e Oportunidades (lista, criar/editar) modelados em `crm/leads.tsx`/`dealsDetails.tsx`. Ofertas — o módulo mais rico: `QuotesListPage`, `QuoteFormPage` (revenda + cliente + período de contrato + fidelidade reduzida opcional + lista dinâmica de itens produto/quantidade + desconto opcional + condição de pagamento, mostra os totais calculados pelo servidor antes de ir para o detalhe) e `QuoteDetailPage` (resumo financeiro completo, aprovar/rejeitar quando pendente, fechar negócio quando ainda é orçamento) — modelados em `sales/salesQuotes.tsx`/`addSalesQuotes.tsx`/`salesQuotesDetails.tsx`. Novo componente `Select` em `packages/ui`. + +**Achado real do próprio agente, corrigindo meu brief**: eu tinha dito no brief que as respostas de Leads/Opportunities eram snake_case (copiando o padrão do resto da API) — o agente verificou contra o código-fonte real (`packages/database/src/repositories/leads.ts`) e descobriu que esse módulo específico mapeia para **camelCase** na resposta (`mapLead`), diferente de Quotes/Companies/Products/Resellers (snake_case direto da linha do banco). Confirmado ao vivo via curl antes de integrar. + +### Integração manual (os 2 agentes trabalharam em paralelo no mesmo `core-web`) + +Um dos agentes não conseguiu `git merge` dentro do próprio sandbox e reconstruiu manualmente (leitura+escrita) o estado já existente do shell autenticado — comparei arquivo por arquivo contra `main` (`diff -rq`) para confirmar que a restauração foi fiel (nenhum componente restaurado divergiu) antes de aceitar qualquer coisa como "nova". `App.tsx`, `AuthenticatedLayout.tsx` (nav unificada, lógica de "ativo" por prefixo de path com borda de `/` para nunca casar `/companies` com `/companies-x`) e `packages/ui/src/index.ts` foram reconciliados manualmente juntando as rotas/exports dos dois agentes; `lib/format.ts` teve as duas versões mescladas (`formatDocument` de um lado, `formatDateBR` do outro). + +Verificado: `pnpm turbo run typecheck build` (28/28). Containers `eden-api`/`eden-core` reconstruídos (o build em background travou duas vezes por algum motivo de recurso do ambiente — refeito em primeiro plano, funcionou); bundle de produção confirmado contendo todas as novas telas; login simulando requisição real de browser (`Origin: http://localhost:3001`) funcionando; listagens de Empresas/Produtos/Revendas retornando dados reais; criação de lead confirmando o formato camelCase ao vivo. + +### Pendências +Client Registration (Cadastro de Cliente) ainda sem tela — `QuoteDetailPage` hoje exige colar manualmente um UUID para fechar negócio. Nenhuma tela de Contracts/Inventory/Support/HR-Timeclock/Fiscal/Billing/Documents ainda. `reseller-web`/`subscriber-web` continuam só com o shell de login (sem telas de negócio). diff --git a/packages/ui/src/components/Card.tsx b/packages/ui/src/components/Card.tsx index 18d3025..0321931 100644 --- a/packages/ui/src/components/Card.tsx +++ b/packages/ui/src/components/Card.tsx @@ -4,10 +4,12 @@ import { cn } from "../utils/cn.js"; export interface CardProps extends Omit, "title"> { title?: ReactNode; actions?: ReactNode; + /** Overrides the default `p-5` on the content wrapper — e.g. `"p-0"` for a flush data table (theme source: sales/salesQuotes.tsx uses `p-4 pb-0` around its table). */ + bodyClassName?: string; } /** Theme source (auth-pages/login.tsx): `bg-white border border-border-color rounded-md shadow-sm`. */ -export function Card({ title, actions, className, children, ...rest }: CardProps) { +export function Card({ title, actions, className, bodyClassName, children, ...rest }: CardProps) { return ( {title || actions ? ( @@ -16,7 +18,7 @@ export function Card({ title, actions, className, children, ...rest }: CardProps {actions ? {actions} : null} ) : null} - {children} + {children} ); } diff --git a/packages/ui/src/components/Select.tsx b/packages/ui/src/components/Select.tsx new file mode 100644 index 0000000..b97a50b --- /dev/null +++ b/packages/ui/src/components/Select.tsx @@ -0,0 +1,93 @@ +import { forwardRef, useId } from "react"; +import type { SelectHTMLAttributes } from "react"; +import { cn } from "../utils/cn.js"; + +export interface SelectOption { + value: string; + label: string; +} + +export interface SelectProps extends Omit, "children"> { + label?: string; + /** Validation/server error message — shown below the field, field border turns danger. */ + error?: string; + /** Helper text shown below the field when there's no error. */ + hint?: string; + options: SelectOption[]; + /** Rendered as a disabled, selected-by-default first option (e.g. "Selecione..."). */ + placeholder?: string; +} + +/** + * Native `` styled to match `Input` (packages/ui/src/components/Input.tsx) + * — same border/padding/focus-ring language as the rest of the Design + * System. Derived from the DreamsERP theme's form field sizing + * (`.tmp/theme-reference/sales/addSalesQuotes.tsx` etc. use a + * `react-select`-based CommonSelect for the same slots, out of our stack — + * this is a plain, dependency-free equivalent with the same visual sizing). + */ +export const Select = forwardRef(function Select( + { label, error, hint, id, className, options, placeholder, ...rest }, + ref, +) { + const generatedId = useId(); + const selectId = id ?? generatedId; + + return ( + + {label ? ( + + {label} + + ) : null} + + + {placeholder ? ( + + {placeholder} + + ) : null} + {options.map((option) => ( + + {option.label} + + ))} + + + + + + {error ? ( + + {error} + + ) : hint ? ( + + {hint} + + ) : null} + + ); +}); diff --git a/packages/ui/src/components/Table.tsx b/packages/ui/src/components/Table.tsx new file mode 100644 index 0000000..94c0bd4 --- /dev/null +++ b/packages/ui/src/components/Table.tsx @@ -0,0 +1,64 @@ +import type { ReactNode } from "react"; +import { cn } from "../utils/cn.js"; + +export interface TableColumn { + key: string; + header: ReactNode; + render: (row: T) => ReactNode; + /** Applied to both the header and body cells — e.g. `"text-right"` for numeric columns. */ + className?: string; +} + +export interface TableProps { + columns: Array>; + data: T[]; + rowKey: (row: T) => string; + /** Shown instead of the table body when `data` is empty (and not loading). */ + emptyMessage?: ReactNode; +} + +/** + * Plain data table styled to match the DreamsERP theme's list screens + * (react/src/pages/inventory/products.tsx et al.) without pulling in the + * theme's antd/react-window dependencies — just a semantic ``. + */ +export function Table({ columns, data, rowKey, emptyMessage = "Nenhum registro encontrado." }: TableProps) { + return ( + + + + + {columns.map((column) => ( + + {column.header} + + ))} + + + + {data.length === 0 ? ( + + + {emptyMessage} + + + ) : ( + data.map((row) => ( + + {columns.map((column) => ( + + {column.render(row)} + + ))} + + )) + )} + + + + ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index d0966ae..3190229 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -13,6 +13,12 @@ export type { ButtonProps, ButtonVariant, ButtonSize } from "./components/Button export { Input } from "./components/Input.js"; export type { InputProps } from "./components/Input.js"; +export { Select } from "./components/Select.js"; +export type { SelectProps, SelectOption } from "./components/Select.js"; + +export { Table } from "./components/Table.js"; +export type { TableProps, TableColumn } from "./components/Table.js"; + export { Card } from "./components/Card.js"; export type { CardProps } from "./components/Card.js";
Carregando oferta…
Notas
{quote.notes}
{quote.approval_notes}
+ Esta oferta tem desconto e/ou fidelidade reduzida e precisa de aprovação antes de seguir. +
(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) { + 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 ( + + + + Oferta criada com sucesso. Confira os totais calculados pelo servidor abaixo. + + + + + + + + {createdQuote.approval_status === "pending" ? ( + + Esta oferta possui desconto e/ou fidelidade reduzida — aguarda aprovação. + + ) : null} + + + Ver oferta completa + + navigate("/quotes")}> + Voltar para a lista + + + + + ); + } + + return ( + + + + + {loadError ? ( + + {loadError} + + ) : null} + {error ? ( + + {error} + + ) : null} + + + {!resellers || !products ? ( + Carregando revendas e produtos… + ) : ( + + + Dados do cliente + + ({ value: r.id, label: r.name }))} + placeholder="Selecione a revenda" + value={resellerId} + onChange={(e) => setResellerId(e.target.value)} + required + /> + setClientName(e.target.value)} required /> + setClientCompany(e.target.value)} /> + setClientEmail(e.target.value)} /> + setClientPhone(e.target.value)} required /> + + + + + Contrato + + ({ + value: String(period), + label: period === 0 ? "Sem fidelidade (0 meses)" : `${period} meses`, + }))} + value={contractPeriod} + onChange={(e) => setContractPeriod(e.target.value)} + required + /> + setFidelityPeriod(e.target.value)} + hint="Só preencha se for menor que o período do contrato — entra em aprovação" + /> + + + + + + Itens + + + Adicionar item + + + + + + + Produto + Quantidade + + + + + {items.map((row, index) => ( + + + updateItem(index, { productId: e.target.value })} + aria-label="Produto" + required + /> + + + updateItem(index, { quantity: e.target.value })} + aria-label="Quantidade" + required + /> + + + {items.length > 1 ? ( + removeItem(index)} + className="rounded-md border border-border-color px-2 py-1 text-xs font-semibold text-danger hover:bg-light" + > + Remover + + ) : null} + + + ))} + + + + + + + Implantação e valores + + setImplGeral(e.target.value)} + hint="Custo de implantação além dos produtos" + /> + setProposedMonthlyTotal(e.target.value)} + hint="Preencha só se for oferecer desconto — a oferta precisará de aprovação" + /> + + ({ + value: condition, + label: condition, + }))} + placeholder="Selecione a condição" + value={implPaymentCondition} + onChange={(e) => setImplPaymentCondition(e.target.value as ImplPaymentCondition)} + /> + + Faixas sugeridas para {formatCurrencyBRL(estimatedImplTotal)} de implantação estimada:{" "} + {availablePaymentConditions.map((c) => ( + + {c} + + ))} + {" "}— o servidor valida a faixa real ao salvar. + + + + + + + Notas + setNotes(e.target.value)} + /> + + + + + Criar oferta + + navigate("/quotes")}> + Cancelar + + + + )} + + + ); +} + +function SummaryField({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ); +} diff --git a/apps/core-web/src/routes/quotes/QuotesListPage.tsx b/apps/core-web/src/routes/quotes/QuotesListPage.tsx new file mode 100644 index 0000000..b1bc219 --- /dev/null +++ b/apps/core-web/src/routes/quotes/QuotesListPage.tsx @@ -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 = { + none: "Sem aprovação", + pending: "Pendente", + approved: "Aprovada", + rejected: "Rejeitada", +}; + +const APPROVAL_VARIANT: Record = { + none: "neutral", + pending: "warning", + approved: "success", + rejected: "danger", +}; + +const DEAL_LABEL: Record = { + orcamento: "Orçamento", + fechado: "Fechado", + perdido: "Perdido", +}; + +const DEAL_VARIANT: Record = { + orcamento: "info", + fechado: "success", + perdido: "danger", +}; + +export function QuotesListPage() { + const { accessToken } = useAuth(); + const [quotes, setQuotes] = useState(null); + const [error, setError] = useState(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 ( + + + Nova oferta + + } + /> + + {error ? ( + + {error} + + ) : null} + + + {quotes === null && !error ? ( + Carregando ofertas… + ) : quotes && quotes.length === 0 ? ( + Nenhuma oferta cadastrada ainda. + ) : quotes ? ( + + + + + Oferta + Cliente + Mensalidade + Aprovação + Negócio + + + + {quotes.map((quote) => ( + + + + {quote.quote_number} + + + + {quote.client_name} + {quote.client_company ? {quote.client_company} : null} + + + {formatCurrencyBRL(quote.proposed_monthly_total ?? quote.monthly_total)} + + + + {APPROVAL_LABEL[quote.approval_status]} + + + + {DEAL_LABEL[quote.deal_status]} + + + ))} + + + + ) : null} + + + ); +} diff --git a/apps/core-web/src/routes/resellers/ResellerFormPage.tsx b/apps/core-web/src/routes/resellers/ResellerFormPage.tsx new file mode 100644 index 0000000..be7c921 --- /dev/null +++ b/apps/core-web/src/routes/resellers/ResellerFormPage.tsx @@ -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(EMPTY_FORM); + const [loading, setLoading] = useState(isEditing); + const [error, setError] = useState(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(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 ( + + + ← Voltar para Revendas + + } + actions={ + <> + navigate("/resellers")} type="button"> + Cancelar + + + Salvar + + > + } + /> + + {error ? ( + + {error} + + ) : null} + + {loading ? ( + + Carregando… + + ) : ( + + + + + updateField("name", e.target.value)} required /> + + updateField("document", e.target.value)} /> + updateField("contactEmail", e.target.value)} + /> + + Status + + updateField("active", e.target.checked)} + /> + Revenda ativa + + + + + + )} + + ); +} diff --git a/apps/core-web/src/routes/resellers/ResellersListPage.tsx b/apps/core-web/src/routes/resellers/ResellersListPage.tsx new file mode 100644 index 0000000..ef60cf8 --- /dev/null +++ b/apps/core-web/src/routes/resellers/ResellersListPage.tsx @@ -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(null); + const [error, setError] = useState(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> = [ + { key: "name", header: "Nome", render: (reseller) => {reseller.name} }, + { 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) => {reseller.active ? "Ativo" : "Inativo"}, + }, + { + key: "actions", + header: "", + className: "text-right", + render: (reseller) => ( + navigate(`/resellers/${reseller.id}/edit`)}> + Editar + + ), + }, + ]; + + return ( + + navigate("/resellers/new")}>Nova revenda} + /> + + {error ? ( + + {error} + + ) : null} + + + {resellers === null && !error ? ( + Carregando… + ) : ( + reseller.id} /> + )} + + + ); +} diff --git a/docs/progress.md b/docs/progress.md index 128f567..c8d34ad 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -294,3 +294,30 @@ Verificado: `pnpm turbo run typecheck build` (28/28), `pnpm --filter @eden/api t ### Pendências Toda a Assinatura Eletrônica (eden.md §1.4-1.9/§5/§6/§7 — envelopes, signatários, OTP, hash-chain, verificação pública), geração real de PDF (Playwright/Chromium) + sanitização de HTML, editor visual Tiptap real (`renderToHtml`/`resolveDocument` com condicionais e repetição de tabela), seed dos 7 templates reais de produção da Handix, regra de idempotência completa com `force`/`extra_fields`. + +--- + +## Fase 2/8 — Telas de negócio reais no EDEN Core (Empresas, Produtos, Revendas, CRM, Ofertas) + +Data: 2026-09-03. Usuário pediu explicitamente para priorizar a parte web ("ja faca a parte web para eu ja ver como esta ficando"). 2 subagentes `eden-frontend` em paralelo, mesma sessão, construindo sobre o shell autenticado + Design System já prontos. + +**Correção de instrução no meio da leva**: o usuário interrompeu para dizer "não esqueça de seguir o template" / "melhor usar ele" — as telas deviam seguir de perto o LAYOUT REAL do tema DreamsERP comprado, não só os tokens visuais abstratos já extraídos. Extraí do `tema_do_Eden.zip` as páginas React reais mais relevantes (`sales/salesQuotes*.tsx`, `crm/leads.tsx`/`deals*.tsx`, `inventory/products*.tsx`, `purchase/vendors.tsx`, `system/userManagement.tsx`, `ui-interface/{tables,forms}`) para dentro do worktree de cada agente (`.tmp/theme-reference/`, nunca versionado) e mandei mensagem de correção para os dois agentes já em execução — via `SendMessage`, não relançando a tarefa (no meio do processo cometi o erro de usar `Agent` em vez de `SendMessage` para a primeira correção, criando um agente duplicado; percebido e corrigido na hora com `TaskStop` antes de qualquer trabalho real ser feito por ele). Ambos incorporaram a referência do tema (estrutura de header/breadcrumb/tabela/formulário em cards) reescrita com `@eden/ui` + Tailwind puro — sem copiar JSX nem trazer dependências do tema (antd/Preline) para o projeto. + +### Empresas / Produtos / Revendas (`apps/core-web/src/routes/{companies,products,resellers}/`) + +CRUD completo (lista + criar/editar) para os 3 módulos, layout modelado em `purchase/vendors.tsx` (empresas/revendas) e `inventory/products.tsx` (produtos, com máscara de moeda para as 5 faixas de preço + implantação). Novo componente `Table` em `packages/ui` (tabela genérica reutilizável, sem dependência de terceiro). + +### CRM e Ofertas/Quotes (`apps/core-web/src/routes/{crm,quotes}/`) + +Leads (lista, criar/editar, converter em oportunidade) e Oportunidades (lista, criar/editar) modelados em `crm/leads.tsx`/`dealsDetails.tsx`. Ofertas — o módulo mais rico: `QuotesListPage`, `QuoteFormPage` (revenda + cliente + período de contrato + fidelidade reduzida opcional + lista dinâmica de itens produto/quantidade + desconto opcional + condição de pagamento, mostra os totais calculados pelo servidor antes de ir para o detalhe) e `QuoteDetailPage` (resumo financeiro completo, aprovar/rejeitar quando pendente, fechar negócio quando ainda é orçamento) — modelados em `sales/salesQuotes.tsx`/`addSalesQuotes.tsx`/`salesQuotesDetails.tsx`. Novo componente `Select` em `packages/ui`. + +**Achado real do próprio agente, corrigindo meu brief**: eu tinha dito no brief que as respostas de Leads/Opportunities eram snake_case (copiando o padrão do resto da API) — o agente verificou contra o código-fonte real (`packages/database/src/repositories/leads.ts`) e descobriu que esse módulo específico mapeia para **camelCase** na resposta (`mapLead`), diferente de Quotes/Companies/Products/Resellers (snake_case direto da linha do banco). Confirmado ao vivo via curl antes de integrar. + +### Integração manual (os 2 agentes trabalharam em paralelo no mesmo `core-web`) + +Um dos agentes não conseguiu `git merge` dentro do próprio sandbox e reconstruiu manualmente (leitura+escrita) o estado já existente do shell autenticado — comparei arquivo por arquivo contra `main` (`diff -rq`) para confirmar que a restauração foi fiel (nenhum componente restaurado divergiu) antes de aceitar qualquer coisa como "nova". `App.tsx`, `AuthenticatedLayout.tsx` (nav unificada, lógica de "ativo" por prefixo de path com borda de `/` para nunca casar `/companies` com `/companies-x`) e `packages/ui/src/index.ts` foram reconciliados manualmente juntando as rotas/exports dos dois agentes; `lib/format.ts` teve as duas versões mescladas (`formatDocument` de um lado, `formatDateBR` do outro). + +Verificado: `pnpm turbo run typecheck build` (28/28). Containers `eden-api`/`eden-core` reconstruídos (o build em background travou duas vezes por algum motivo de recurso do ambiente — refeito em primeiro plano, funcionou); bundle de produção confirmado contendo todas as novas telas; login simulando requisição real de browser (`Origin: http://localhost:3001`) funcionando; listagens de Empresas/Produtos/Revendas retornando dados reais; criação de lead confirmando o formato camelCase ao vivo. + +### Pendências +Client Registration (Cadastro de Cliente) ainda sem tela — `QuoteDetailPage` hoje exige colar manualmente um UUID para fechar negócio. Nenhuma tela de Contracts/Inventory/Support/HR-Timeclock/Fiscal/Billing/Documents ainda. `reseller-web`/`subscriber-web` continuam só com o shell de login (sem telas de negócio). diff --git a/packages/ui/src/components/Card.tsx b/packages/ui/src/components/Card.tsx index 18d3025..0321931 100644 --- a/packages/ui/src/components/Card.tsx +++ b/packages/ui/src/components/Card.tsx @@ -4,10 +4,12 @@ import { cn } from "../utils/cn.js"; export interface CardProps extends Omit, "title"> { title?: ReactNode; actions?: ReactNode; + /** Overrides the default `p-5` on the content wrapper — e.g. `"p-0"` for a flush data table (theme source: sales/salesQuotes.tsx uses `p-4 pb-0` around its table). */ + bodyClassName?: string; } /** Theme source (auth-pages/login.tsx): `bg-white border border-border-color rounded-md shadow-sm`. */ -export function Card({ title, actions, className, children, ...rest }: CardProps) { +export function Card({ title, actions, className, bodyClassName, children, ...rest }: CardProps) { return ( {title || actions ? ( @@ -16,7 +18,7 @@ export function Card({ title, actions, className, children, ...rest }: CardProps {actions ? {actions} : null} ) : null} - {children} + {children} ); } diff --git a/packages/ui/src/components/Select.tsx b/packages/ui/src/components/Select.tsx new file mode 100644 index 0000000..b97a50b --- /dev/null +++ b/packages/ui/src/components/Select.tsx @@ -0,0 +1,93 @@ +import { forwardRef, useId } from "react"; +import type { SelectHTMLAttributes } from "react"; +import { cn } from "../utils/cn.js"; + +export interface SelectOption { + value: string; + label: string; +} + +export interface SelectProps extends Omit, "children"> { + label?: string; + /** Validation/server error message — shown below the field, field border turns danger. */ + error?: string; + /** Helper text shown below the field when there's no error. */ + hint?: string; + options: SelectOption[]; + /** Rendered as a disabled, selected-by-default first option (e.g. "Selecione..."). */ + placeholder?: string; +} + +/** + * Native `` styled to match `Input` (packages/ui/src/components/Input.tsx) + * — same border/padding/focus-ring language as the rest of the Design + * System. Derived from the DreamsERP theme's form field sizing + * (`.tmp/theme-reference/sales/addSalesQuotes.tsx` etc. use a + * `react-select`-based CommonSelect for the same slots, out of our stack — + * this is a plain, dependency-free equivalent with the same visual sizing). + */ +export const Select = forwardRef(function Select( + { label, error, hint, id, className, options, placeholder, ...rest }, + ref, +) { + const generatedId = useId(); + const selectId = id ?? generatedId; + + return ( + + {label ? ( + + {label} + + ) : null} + + + {placeholder ? ( + + {placeholder} + + ) : null} + {options.map((option) => ( + + {option.label} + + ))} + + + + + + {error ? ( + + {error} + + ) : hint ? ( + + {hint} + + ) : null} + + ); +}); diff --git a/packages/ui/src/components/Table.tsx b/packages/ui/src/components/Table.tsx new file mode 100644 index 0000000..94c0bd4 --- /dev/null +++ b/packages/ui/src/components/Table.tsx @@ -0,0 +1,64 @@ +import type { ReactNode } from "react"; +import { cn } from "../utils/cn.js"; + +export interface TableColumn { + key: string; + header: ReactNode; + render: (row: T) => ReactNode; + /** Applied to both the header and body cells — e.g. `"text-right"` for numeric columns. */ + className?: string; +} + +export interface TableProps { + columns: Array>; + data: T[]; + rowKey: (row: T) => string; + /** Shown instead of the table body when `data` is empty (and not loading). */ + emptyMessage?: ReactNode; +} + +/** + * Plain data table styled to match the DreamsERP theme's list screens + * (react/src/pages/inventory/products.tsx et al.) without pulling in the + * theme's antd/react-window dependencies — just a semantic ``. + */ +export function Table({ columns, data, rowKey, emptyMessage = "Nenhum registro encontrado." }: TableProps) { + return ( + + + + + {columns.map((column) => ( + + {column.header} + + ))} + + + + {data.length === 0 ? ( + + + {emptyMessage} + + + ) : ( + data.map((row) => ( + + {columns.map((column) => ( + + {column.render(row)} + + ))} + + )) + )} + + + + ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index d0966ae..3190229 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -13,6 +13,12 @@ export type { ButtonProps, ButtonVariant, ButtonSize } from "./components/Button export { Input } from "./components/Input.js"; export type { InputProps } from "./components/Input.js"; +export { Select } from "./components/Select.js"; +export type { SelectProps, SelectOption } from "./components/Select.js"; + +export { Table } from "./components/Table.js"; +export type { TableProps, TableColumn } from "./components/Table.js"; + export { Card } from "./components/Card.js"; export type { CardProps } from "./components/Card.js";
Carregando revendas e produtos…
+ Faixas sugeridas para {formatCurrencyBRL(estimatedImplTotal)} de implantação estimada:{" "} + {availablePaymentConditions.map((c) => ( + + {c} + + ))} + {" "}— o servidor valida a faixa real ao salvar. +
{label}
{value}
(null); + const [error, setError] = useState(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 ( + + + Nova oferta + + } + /> + + {error ? ( + + {error} + + ) : null} + + + {quotes === null && !error ? ( + Carregando ofertas… + ) : quotes && quotes.length === 0 ? ( + Nenhuma oferta cadastrada ainda. + ) : quotes ? ( + + + + + Oferta + Cliente + Mensalidade + Aprovação + Negócio + + + + {quotes.map((quote) => ( + + + + {quote.quote_number} + + + + {quote.client_name} + {quote.client_company ? {quote.client_company} : null} + + + {formatCurrencyBRL(quote.proposed_monthly_total ?? quote.monthly_total)} + + + + {APPROVAL_LABEL[quote.approval_status]} + + + + {DEAL_LABEL[quote.deal_status]} + + + ))} + + + + ) : null} + + + ); +} diff --git a/apps/core-web/src/routes/resellers/ResellerFormPage.tsx b/apps/core-web/src/routes/resellers/ResellerFormPage.tsx new file mode 100644 index 0000000..be7c921 --- /dev/null +++ b/apps/core-web/src/routes/resellers/ResellerFormPage.tsx @@ -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(EMPTY_FORM); + const [loading, setLoading] = useState(isEditing); + const [error, setError] = useState(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(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 ( + + + ← Voltar para Revendas + + } + actions={ + <> + navigate("/resellers")} type="button"> + Cancelar + + + Salvar + + > + } + /> + + {error ? ( + + {error} + + ) : null} + + {loading ? ( + + Carregando… + + ) : ( + + + + + updateField("name", e.target.value)} required /> + + updateField("document", e.target.value)} /> + updateField("contactEmail", e.target.value)} + /> + + Status + + updateField("active", e.target.checked)} + /> + Revenda ativa + + + + + + )} + + ); +} diff --git a/apps/core-web/src/routes/resellers/ResellersListPage.tsx b/apps/core-web/src/routes/resellers/ResellersListPage.tsx new file mode 100644 index 0000000..ef60cf8 --- /dev/null +++ b/apps/core-web/src/routes/resellers/ResellersListPage.tsx @@ -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(null); + const [error, setError] = useState(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> = [ + { key: "name", header: "Nome", render: (reseller) => {reseller.name} }, + { 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) => {reseller.active ? "Ativo" : "Inativo"}, + }, + { + key: "actions", + header: "", + className: "text-right", + render: (reseller) => ( + navigate(`/resellers/${reseller.id}/edit`)}> + Editar + + ), + }, + ]; + + return ( + + navigate("/resellers/new")}>Nova revenda} + /> + + {error ? ( + + {error} + + ) : null} + + + {resellers === null && !error ? ( + Carregando… + ) : ( + reseller.id} /> + )} + + + ); +} diff --git a/docs/progress.md b/docs/progress.md index 128f567..c8d34ad 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -294,3 +294,30 @@ Verificado: `pnpm turbo run typecheck build` (28/28), `pnpm --filter @eden/api t ### Pendências Toda a Assinatura Eletrônica (eden.md §1.4-1.9/§5/§6/§7 — envelopes, signatários, OTP, hash-chain, verificação pública), geração real de PDF (Playwright/Chromium) + sanitização de HTML, editor visual Tiptap real (`renderToHtml`/`resolveDocument` com condicionais e repetição de tabela), seed dos 7 templates reais de produção da Handix, regra de idempotência completa com `force`/`extra_fields`. + +--- + +## Fase 2/8 — Telas de negócio reais no EDEN Core (Empresas, Produtos, Revendas, CRM, Ofertas) + +Data: 2026-09-03. Usuário pediu explicitamente para priorizar a parte web ("ja faca a parte web para eu ja ver como esta ficando"). 2 subagentes `eden-frontend` em paralelo, mesma sessão, construindo sobre o shell autenticado + Design System já prontos. + +**Correção de instrução no meio da leva**: o usuário interrompeu para dizer "não esqueça de seguir o template" / "melhor usar ele" — as telas deviam seguir de perto o LAYOUT REAL do tema DreamsERP comprado, não só os tokens visuais abstratos já extraídos. Extraí do `tema_do_Eden.zip` as páginas React reais mais relevantes (`sales/salesQuotes*.tsx`, `crm/leads.tsx`/`deals*.tsx`, `inventory/products*.tsx`, `purchase/vendors.tsx`, `system/userManagement.tsx`, `ui-interface/{tables,forms}`) para dentro do worktree de cada agente (`.tmp/theme-reference/`, nunca versionado) e mandei mensagem de correção para os dois agentes já em execução — via `SendMessage`, não relançando a tarefa (no meio do processo cometi o erro de usar `Agent` em vez de `SendMessage` para a primeira correção, criando um agente duplicado; percebido e corrigido na hora com `TaskStop` antes de qualquer trabalho real ser feito por ele). Ambos incorporaram a referência do tema (estrutura de header/breadcrumb/tabela/formulário em cards) reescrita com `@eden/ui` + Tailwind puro — sem copiar JSX nem trazer dependências do tema (antd/Preline) para o projeto. + +### Empresas / Produtos / Revendas (`apps/core-web/src/routes/{companies,products,resellers}/`) + +CRUD completo (lista + criar/editar) para os 3 módulos, layout modelado em `purchase/vendors.tsx` (empresas/revendas) e `inventory/products.tsx` (produtos, com máscara de moeda para as 5 faixas de preço + implantação). Novo componente `Table` em `packages/ui` (tabela genérica reutilizável, sem dependência de terceiro). + +### CRM e Ofertas/Quotes (`apps/core-web/src/routes/{crm,quotes}/`) + +Leads (lista, criar/editar, converter em oportunidade) e Oportunidades (lista, criar/editar) modelados em `crm/leads.tsx`/`dealsDetails.tsx`. Ofertas — o módulo mais rico: `QuotesListPage`, `QuoteFormPage` (revenda + cliente + período de contrato + fidelidade reduzida opcional + lista dinâmica de itens produto/quantidade + desconto opcional + condição de pagamento, mostra os totais calculados pelo servidor antes de ir para o detalhe) e `QuoteDetailPage` (resumo financeiro completo, aprovar/rejeitar quando pendente, fechar negócio quando ainda é orçamento) — modelados em `sales/salesQuotes.tsx`/`addSalesQuotes.tsx`/`salesQuotesDetails.tsx`. Novo componente `Select` em `packages/ui`. + +**Achado real do próprio agente, corrigindo meu brief**: eu tinha dito no brief que as respostas de Leads/Opportunities eram snake_case (copiando o padrão do resto da API) — o agente verificou contra o código-fonte real (`packages/database/src/repositories/leads.ts`) e descobriu que esse módulo específico mapeia para **camelCase** na resposta (`mapLead`), diferente de Quotes/Companies/Products/Resellers (snake_case direto da linha do banco). Confirmado ao vivo via curl antes de integrar. + +### Integração manual (os 2 agentes trabalharam em paralelo no mesmo `core-web`) + +Um dos agentes não conseguiu `git merge` dentro do próprio sandbox e reconstruiu manualmente (leitura+escrita) o estado já existente do shell autenticado — comparei arquivo por arquivo contra `main` (`diff -rq`) para confirmar que a restauração foi fiel (nenhum componente restaurado divergiu) antes de aceitar qualquer coisa como "nova". `App.tsx`, `AuthenticatedLayout.tsx` (nav unificada, lógica de "ativo" por prefixo de path com borda de `/` para nunca casar `/companies` com `/companies-x`) e `packages/ui/src/index.ts` foram reconciliados manualmente juntando as rotas/exports dos dois agentes; `lib/format.ts` teve as duas versões mescladas (`formatDocument` de um lado, `formatDateBR` do outro). + +Verificado: `pnpm turbo run typecheck build` (28/28). Containers `eden-api`/`eden-core` reconstruídos (o build em background travou duas vezes por algum motivo de recurso do ambiente — refeito em primeiro plano, funcionou); bundle de produção confirmado contendo todas as novas telas; login simulando requisição real de browser (`Origin: http://localhost:3001`) funcionando; listagens de Empresas/Produtos/Revendas retornando dados reais; criação de lead confirmando o formato camelCase ao vivo. + +### Pendências +Client Registration (Cadastro de Cliente) ainda sem tela — `QuoteDetailPage` hoje exige colar manualmente um UUID para fechar negócio. Nenhuma tela de Contracts/Inventory/Support/HR-Timeclock/Fiscal/Billing/Documents ainda. `reseller-web`/`subscriber-web` continuam só com o shell de login (sem telas de negócio). diff --git a/packages/ui/src/components/Card.tsx b/packages/ui/src/components/Card.tsx index 18d3025..0321931 100644 --- a/packages/ui/src/components/Card.tsx +++ b/packages/ui/src/components/Card.tsx @@ -4,10 +4,12 @@ import { cn } from "../utils/cn.js"; export interface CardProps extends Omit, "title"> { title?: ReactNode; actions?: ReactNode; + /** Overrides the default `p-5` on the content wrapper — e.g. `"p-0"` for a flush data table (theme source: sales/salesQuotes.tsx uses `p-4 pb-0` around its table). */ + bodyClassName?: string; } /** Theme source (auth-pages/login.tsx): `bg-white border border-border-color rounded-md shadow-sm`. */ -export function Card({ title, actions, className, children, ...rest }: CardProps) { +export function Card({ title, actions, className, bodyClassName, children, ...rest }: CardProps) { return ( {title || actions ? ( @@ -16,7 +18,7 @@ export function Card({ title, actions, className, children, ...rest }: CardProps {actions ? {actions} : null} ) : null} - {children} + {children} ); } diff --git a/packages/ui/src/components/Select.tsx b/packages/ui/src/components/Select.tsx new file mode 100644 index 0000000..b97a50b --- /dev/null +++ b/packages/ui/src/components/Select.tsx @@ -0,0 +1,93 @@ +import { forwardRef, useId } from "react"; +import type { SelectHTMLAttributes } from "react"; +import { cn } from "../utils/cn.js"; + +export interface SelectOption { + value: string; + label: string; +} + +export interface SelectProps extends Omit, "children"> { + label?: string; + /** Validation/server error message — shown below the field, field border turns danger. */ + error?: string; + /** Helper text shown below the field when there's no error. */ + hint?: string; + options: SelectOption[]; + /** Rendered as a disabled, selected-by-default first option (e.g. "Selecione..."). */ + placeholder?: string; +} + +/** + * Native `` styled to match `Input` (packages/ui/src/components/Input.tsx) + * — same border/padding/focus-ring language as the rest of the Design + * System. Derived from the DreamsERP theme's form field sizing + * (`.tmp/theme-reference/sales/addSalesQuotes.tsx` etc. use a + * `react-select`-based CommonSelect for the same slots, out of our stack — + * this is a plain, dependency-free equivalent with the same visual sizing). + */ +export const Select = forwardRef(function Select( + { label, error, hint, id, className, options, placeholder, ...rest }, + ref, +) { + const generatedId = useId(); + const selectId = id ?? generatedId; + + return ( + + {label ? ( + + {label} + + ) : null} + + + {placeholder ? ( + + {placeholder} + + ) : null} + {options.map((option) => ( + + {option.label} + + ))} + + + + + + {error ? ( + + {error} + + ) : hint ? ( + + {hint} + + ) : null} + + ); +}); diff --git a/packages/ui/src/components/Table.tsx b/packages/ui/src/components/Table.tsx new file mode 100644 index 0000000..94c0bd4 --- /dev/null +++ b/packages/ui/src/components/Table.tsx @@ -0,0 +1,64 @@ +import type { ReactNode } from "react"; +import { cn } from "../utils/cn.js"; + +export interface TableColumn { + key: string; + header: ReactNode; + render: (row: T) => ReactNode; + /** Applied to both the header and body cells — e.g. `"text-right"` for numeric columns. */ + className?: string; +} + +export interface TableProps { + columns: Array>; + data: T[]; + rowKey: (row: T) => string; + /** Shown instead of the table body when `data` is empty (and not loading). */ + emptyMessage?: ReactNode; +} + +/** + * Plain data table styled to match the DreamsERP theme's list screens + * (react/src/pages/inventory/products.tsx et al.) without pulling in the + * theme's antd/react-window dependencies — just a semantic ``. + */ +export function Table({ columns, data, rowKey, emptyMessage = "Nenhum registro encontrado." }: TableProps) { + return ( + + + + + {columns.map((column) => ( + + {column.header} + + ))} + + + + {data.length === 0 ? ( + + + {emptyMessage} + + + ) : ( + data.map((row) => ( + + {columns.map((column) => ( + + {column.render(row)} + + ))} + + )) + )} + + + + ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index d0966ae..3190229 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -13,6 +13,12 @@ export type { ButtonProps, ButtonVariant, ButtonSize } from "./components/Button export { Input } from "./components/Input.js"; export type { InputProps } from "./components/Input.js"; +export { Select } from "./components/Select.js"; +export type { SelectProps, SelectOption } from "./components/Select.js"; + +export { Table } from "./components/Table.js"; +export type { TableProps, TableColumn } from "./components/Table.js"; + export { Card } from "./components/Card.js"; export type { CardProps } from "./components/Card.js";
Carregando ofertas…
Nenhuma oferta cadastrada ainda.
{quote.client_name}
{quote.client_company}
+ {error} +
+ {hint} +