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 ( + + ); +} 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 ` + ), + }, + ]; + + 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={ + <> + + + + } + /> + + {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 + +
+ ) : 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 + /> + setProbability(e.target.value)} + /> +
+ +