Receitas de Admin
Receitas copia-e-cola para montar um admin com cosmemilton-ui. Repare nos entry points: chrome e layout vêm de /server, interativos de /client; um formulário mistura os dois.
Galeria de fotos da vistoria
Grade de miniaturas; clique abre o lightbox em tela cheia (setas, Esc e miniaturas).
DataTable server-side
Paginação e ordenação controladas, com totalRows e estado de carregamento simulado.
Vistorias (server-side)
Ordenação e paginação resolvidas a cada busca simulada (600 ms).
Carregando…
Snippets
Trechos prontos para colar no projeto.
Shell: AppShell + Sidebar + Topbar
Layout com sidebar, topbar sticky e menu do usuário. O slot topbar recebe o botão de menu mobile.
"use client";
import { CmAppShell, CmSidebar, CmTopbar, CmUserMenu } from "cosmemilton-ui/client";
import { LayoutDashboard, ClipboardList, Building2 } from "lucide-react";
const groups = [
{
id: "geral",
label: "Geral",
items: [
{ id: "dashboard", label: "Dashboard", href: "/", icon: <LayoutDashboard size={18} /> },
{ id: "vistorias", label: "Vistorias", href: "/vistorias", icon: <ClipboardList size={18} />, badge: 12 },
{ id: "imoveis", label: "Imoveis", href: "/imoveis", icon: <Building2 size={18} /> },
],
},
];
export function AdminShell({ children }: { children: React.ReactNode }) {
return (
<CmAppShell
sidebar={<CmSidebar brand={{ title: "Vistorias", subtitle: "Painel" }} groups={groups} activePathname="/vistorias" />}
topbar={({ mobileMenuButton }) => (
<CmTopbar
start={mobileMenuButton}
title="Vistorias"
end={<CmUserMenu user={{ title: "Milton", email: "milton@exemplo.com" }} items={[{ label: "Sair", onSelect: () => {} }]} />}
sticky
bordered
/>
)}
>
{children}
</CmAppShell>
);
}Cabeçalho de página + filtros
CmPageHeader e CmFilterBar são server-safe; o CmInput de busca é client.
import { CmPageHeader, CmFilterBar } from "cosmemilton-ui/server";
import { CmButton, CmInput } from "cosmemilton-ui/client";
export function VistoriasHeader() {
return (
<>
<CmPageHeader
eyebrow="Operacao"
title="Vistorias"
description="Acompanhe e gerencie as vistorias."
actions={<CmButton tone="primary" icon="+">Nova vistoria</CmButton>}
/>
<CmFilterBar gap="sm" wrap>
<CmInput placeholder="Buscar por imovel ou vistoriador" />
</CmFilterBar>
</>
);
}DataTable client-side
Ordena e pagina o array no cliente. tableKey persiste a visibilidade de colunas do usuário.
import { CmDataTable, type CmDataTableColumn } from "cosmemilton-ui/client";
import { CmBadge } from "cosmemilton-ui/server";
type Vistoria = { id: string; imovel: string; status: string; data: string };
const columns: CmDataTableColumn<Vistoria>[] = [
{ key: "imovel", header: "Imovel", sortable: true },
{ key: "status", header: "Status", render: (row) => <CmBadge>{row.status}</CmBadge> },
{ key: "data", header: "Data", sortable: true, align: "right" },
];
export function VistoriasTable({ rows }: { rows: Vistoria[] }) {
return (
<CmDataTable
columns={columns}
data={rows}
rowKey={(row) => row.id}
tableKey="vistorias"
pagination
defaultRowsPerPage={10}
/>
);
}DataTable server-side (paginação e sort no backend)
Use manualSorting + manualPagination + totalRows + loading e controle page/rowsPerPage/sort. data é apenas a página atual.
"use client";
import { useEffect, useState } from "react";
import { CmDataTable, type CmDataTableColumn, type CmDataTableSort } from "cosmemilton-ui/client";
type Vistoria = { id: string; imovel: string; data: string };
const columns: CmDataTableColumn<Vistoria>[] = [
{ key: "imovel", header: "Imovel", sortable: true },
{ key: "data", header: "Data", sortable: true, align: "right" },
];
export function VistoriasServerTable() {
const [rows, setRows] = useState<Vistoria[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [rowsPerPage, setRowsPerPage] = useState(10);
const [sort, setSort] = useState<CmDataTableSort>({ key: "data", direction: "desc" });
useEffect(() => {
setLoading(true);
fetchVistorias({ page, rowsPerPage, sort }).then((res) => {
setRows(res.rows);
setTotal(res.total);
setLoading(false);
});
}, [page, rowsPerPage, sort]);
return (
<CmDataTable
columns={columns}
data={rows}
rowKey={(row) => row.id}
manualSorting
manualPagination
totalRows={total}
loading={loading}
page={page}
onPageChange={setPage}
rowsPerPage={rowsPerPage}
onRowsPerPageChange={setRowsPerPage}
sortKey={sort.key}
sortDirection={sort.direction}
onSortChange={setSort}
/>
);
}Formulário com validação (react-hook-form + zod)
CmForm/CmField são apresentacionais. Conecte a validação e jogue a mensagem em CmField.error.
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { CmForm, CmField } from "cosmemilton-ui/server";
import { CmInput, CmButton } from "cosmemilton-ui/client";
const schema = z.object({
titulo: z.string().min(3, "Mínimo de 3 caracteres"),
area: z.coerce.number().positive("Informe a área"),
});
type ImovelInput = z.infer<typeof schema>;
export function ImovelForm() {
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<ImovelInput>({
resolver: zodResolver(schema),
});
return (
<CmForm
title="Novo imóvel"
layout="grid"
columns={{ base: 1, md: 2 }}
onSubmit={handleSubmit((values) => console.log(values))}
actions={<CmButton type="submit" tone="primary" loading={isSubmitting}>Salvar</CmButton>}
>
<CmField label="Título" required error={errors.titulo?.message}>
<CmInput {...register("titulo")} />
</CmField>
<CmField label="Área (m2)" required error={errors.area?.message}>
<CmInput type="number" {...register("area")} />
</CmField>
</CmForm>
);
}Login com Auth.js (credentials)
Formulário client-side com CmForm. O preventDefault é obrigatório: sem ele o submit nativo faz GET /login?email=...&password=... e vaza a senha na URL.
"use client";
import { useState, type FormEvent } from "react";
import { useRouter } from "next/navigation";
import { signIn } from "next-auth/react";
import { CmForm, CmField, CmInput, CmButton, useCmToast } from "cosmemilton-ui/client";
export function LoginForm() {
const router = useRouter();
const { toast } = useCmToast();
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); // impede o fallback GET com a senha na query string
const form = new FormData(event.currentTarget);
setSubmitting(true);
const result = await signIn("credentials", {
email: String(form.get("email") ?? ""),
password: String(form.get("password") ?? ""),
redirect: false,
});
setSubmitting(false);
if (result?.error) {
toast("E-mail ou senha inválidos", { tone: "danger" });
return;
}
router.push("/");
router.refresh();
}
return (
<CmForm
title="Entrar"
description="Acesse o painel administrativo."
method="post"
onSubmit={handleSubmit}
actions={
<CmButton type="submit" tone="primary" loading={submitting} fullWidth>
Entrar
</CmButton>
}
>
<CmField label="E-mail" required>
<CmInput name="email" type="email" autoComplete="email" required />
</CmField>
<CmField label="Senha" required>
<CmInput name="password" type="password" autoComplete="current-password" required />
</CmField>
</CmForm>
);
}Galeria de fotos + lightbox
CmGallery embute o CmLightbox (clique abre em tela cheia, com teclado e miniaturas).
"use client";
import { CmGallery, type CmGalleryItem } from "cosmemilton-ui/client";
const fotos: CmGalleryItem[] = [
{ id: "1", src: "/vistorias/1-sala.jpg", alt: "Sala", caption: "Sala de estar" },
{ id: "2", src: "/vistorias/1-cozinha.jpg", alt: "Cozinha" },
{ id: "3", src: "/vistorias/1-quarto.jpg", alt: "Quarto" },
];
export function FotosVistoria() {
return <CmGallery items={fotos} columns={{ base: 2, sm: 3, lg: 4 }} ratio={4 / 3} gap="sm" />;
}Dashboard: MetricCard em grid
Cartões de métrica responsivos. trend.value é um número (sinal define cor/seta).
import { CmGridContainer, CmGrid, CmMetricCard } from "cosmemilton-ui/server";
import { ClipboardCheck, Clock, Building2 } from "lucide-react";
export function DashboardMetrics() {
return (
<CmGridContainer columns={{ base: 1, sm: 2, lg: 4 }} gap="md">
<CmGrid>
<CmMetricCard
title="Vistorias no mes"
value="128"
icon={<ClipboardCheck size={18} />}
trend={{ value: 12, label: "vs. mes anterior" }}
/>
</CmGrid>
<CmGrid>
<CmMetricCard title="Pendentes" value="9" tone="warning" icon={<Clock size={18} />} />
</CmGrid>
<CmGrid>
<CmMetricCard title="Imoveis ativos" value="342" icon={<Building2 size={18} />} />
</CmGrid>
</CmGridContainer>
);
}Excluir com confirmação + toast
CmConfirmDialog para a ação destrutiva e useCmToast para o feedback.
"use client";
import { useState } from "react";
import { CmButton, CmConfirmDialog, CmToastProvider, useCmToast } from "cosmemilton-ui/client";
function DeleteButton({ id }: { id: string }) {
const { toast } = useCmToast();
const [open, setOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
async function handleConfirm() {
setSubmitting(true);
await deleteVistoria(id);
setSubmitting(false);
setOpen(false);
toast("Vistoria excluida", { tone: "success" });
}
return (
<>
<CmButton tone="danger" variant="ghost" onClick={() => setOpen(true)}>Excluir</CmButton>
<CmConfirmDialog
open={open}
onClose={() => setOpen(false)}
onConfirm={handleConfirm}
tone="danger"
title="Excluir vistoria?"
description="Esta ação não pode ser desfeita."
submitting={submitting}
>
Confirme para remover a vistoria definitivamente.
</CmConfirmDialog>
</>
);
}
export function App() {
return (
<CmToastProvider>
<DeleteButton id="1" />
</CmToastProvider>
);
}Notas
Entry points: CmAppShell, CmDataTable, CmInput, CmButton e overlays vêm de /client; CmPageHeader, CmFilterBar, CmMetricCard, CmGrid e CmBadge vêm de /server. CmForm, CmField, CmTopbar, CmText e os primitives de layout (CmStack, CmRow, CmCol, CmContainer, CmBox) existem nos dois entries — num arquivo "use client", importe tudo de /client.
Login: envie credenciais sempre num onSubmit com event.preventDefault() (ou remova name dos campos); o fallback nativo do form faz GET com a senha na query string.
DataTable: o modo padrão ordena e pagina o array no cliente. Para backend paginado ou muitos registros, ative manualSorting + manualPagination, informe totalRows e loading e controle page/rowsPerPage/sortKey/sortDirection.
tableKey persiste a visibilidade de colunas por usuário (via useTablePreference).
CmGallery embute o CmLightbox; use CmLightbox sozinho (controlado por open/index/onClose) para abrir a galeria a partir de outro gatilho.
Validação não é embutida nos formulários: CmForm/CmField cuidam de layout, rótulo e erro; a regra fica com react-hook-form/zod ou validação nativa.
Visualizar PDF não tem componente próprio: use <object>, <iframe> ou uma lib de PDF.