Recurso
Receitas

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.

tsx
"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.

tsx
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.

tsx
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.

tsx
"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.

tsx
"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.

tsx
"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).

tsx
"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).

tsx
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.

tsx
"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.