Gerador de relatórios para Next.js

Persistência e views

Config global, views por usuário e definições criadas em runtime — tudo passa por um único contrato, o ReportStorageAdapter. Troque a implementação sem tocar no editor, no designer ou nos exports.

O contrato

ReportStorageAdapter — a única interface que qualquer implementação de storage precisa cumprir. Editor de layout, designer e provisionamento dependem só dela, nunca de um backend específico.

ts
type ReportStorageAdapter = {
  /** Config global salva, ou `null` quando nada foi salvo ainda (chamador aplica
   *  seu próprio default nesse caso). */
  loadGlobalConfig(): Promise<ReportGlobalConfig | null>;
  saveGlobalConfig(config: ReportGlobalConfig): Promise<void>;

  /** Lista as views; `slug` filtra por relatório, omitido retorna todas. */
  listViews(slug?: string): Promise<ReportView[]>;
  getView(id: string): Promise<ReportView | null>;
  /** Upsert por `id`. */
  saveView(view: ReportView): Promise<void>;
  /** Lança se a view for `isSystem` (view do sistema é somente leitura). */
  deleteView(id: string): Promise<void>;
  /** Marca `viewId` como default do `slug` e zera `isDefault` das demais
   *  views do mesmo slug. */
  setDefaultView(slug: string, viewId: string): Promise<void>;

  listDefinitions(): Promise<SerializableReportDefinition[]>;
  getDefinition(slug: string): Promise<SerializableReportDefinition | null>;
  /** Upsert por `slug`. */
  saveDefinition(definition: SerializableReportDefinition): Promise<void>;
  /** Remove a definição E todas as views associadas ao seu `slug`. */
  deleteDefinition(slug: string): Promise<void>;
};

Config global

companyName, logo, papel, margens, moeda — o mesmo objeto ReportGlobalConfig aplicado a todo relatório, salvo uma vez só.

Views

Cada view é um conjunto de overrides (colunas, cabeçalho, estilo, resumo) sobre uma definição — a de sistema nunca é apagada, só as criadas pelo usuário.

Definições

Só entram aqui relatórios criados em runtime pelo CmReportDesigner — os definidos em código (defineReport) nunca passam por saveDefinition.

Pronto para usar

Duas implementações inclusas no pacote — nenhuma exige um banco de dados.

createLocalStorageReportAdapter

Guarda tudo no localStorage do navegador. Use em apps 100% client-side, protótipos e demos — cada usuário mantém suas próprias views, sem servidor nenhum envolvido.

ts
import { createLocalStorageReportAdapter } from "cosmemilton-report";

// options.storage só é acessado DENTRO de cada método (nunca no factory),
// então é seguro instanciar isto durante SSR — só não pode ser *usado* lá.
export const adapter = createLocalStorageReportAdapter({
  prefix: "meuapp:relatorios:",
});

createMemoryReportAdapter

Guarda tudo em Map na memória do processo. Use em testes automatizados e protótipos de servidor — nunca em produção multi-instância, porque cada processo tem sua própria memória e nada é persistido entre reinícios.

ts
import { createMemoryReportAdapter } from "cosmemilton-report";

// Tudo em Map na memória do processo — reinicia zerado a cada
// deploy/restart e não é compartilhado entre instâncias.
export const adapter = createMemoryReportAdapter({
  views: [], // seed opcional
});

Auto-provisionamento

ensureReportViews garante que toda definição do registry tenha ao menos uma view de sistema para abrir — chame no boot do servidor.

ts
import { createReportRegistry, ensureReportViews } from "cosmemilton-report";
import { relatorioVendas, relatorioPorVendedor } from "@/lib/demo-data";
import { adapter } from "@/lib/report-adapter";

const registry = createReportRegistry([relatorioVendas, relatorioPorVendedor]);

// Seguro chamar em todo boot do servidor (ou lazy, na primeira requisição):
// só cria o que falta, nunca duplica.
const { created, existing } = await ensureReportViews({ registry, adapter });

Por que é seguro chamar toda vez

Nunca sobrescreve views existentes — só cria a view de sistema que ainda falta para um slug.

Se o slug já tem alguma view default (do usuário ou de uma execução anterior), a view de sistema nasce com isDefault: false — não rouba o default de ninguém.

Retorna { created, existing } com os ids de cada grupo — útil para log de inicialização.

Adapter Prisma (esqueleto)

Duas tabelas — definições e views — cada uma com colunas de busca (slug, id, flags) e o restante do objeto serializado como JSON em payload.

prisma
// Duas tabelas bastam: a definição (quando criada no designer) e as
// views. columns/header/style/summary não têm formato fixo por coluna
// no banco — vão inteiros como JSON, no mesmo shape do pacote.

model ReportDefinition {
  slug        String   @id
  name        String
  description String?
  dataSource  String?
  // columns, summary, header, style, group — SerializableReportDefinition
  // menos slug/name/description/dataSource, que já são colunas próprias.
  payload     Json
  updatedAt   DateTime @updatedAt

  @@map("report_definitions")
}

model ReportView {
  id        String   @id
  slug      String
  name      String
  isSystem  Boolean  @default(false)
  isDefault Boolean  @default(false)
  // columns, header, style, summary — ReportView menos os campos acima.
  payload   Json
  updatedAt DateTime @updatedAt

  @@index([slug])
  @@map("report_views")
}
ts
import { PrismaClient } from "@prisma/client";
import type {
  ReportGlobalConfig,
  ReportStorageAdapter,
  ReportView,
  SerializableReportDefinition,
} from "cosmemilton-report";

const prisma = new PrismaClient();

// Remonta uma linha da tabela report_views no shape de ReportView.
function rowToView(row: {
  id: string;
  slug: string;
  name: string;
  isSystem: boolean;
  isDefault: boolean;
  payload: unknown;
  updatedAt: Date;
}): ReportView {
  return {
    id: row.id,
    slug: row.slug,
    name: row.name,
    isSystem: row.isSystem,
    isDefault: row.isDefault,
    updatedAt: row.updatedAt.toISOString(),
    ...(row.payload as object), // columns/header/style/summary
  };
}

// Idem para report_definitions → SerializableReportDefinition.
function rowToDefinition(row: {
  slug: string;
  name: string;
  description: string | null;
  dataSource: string | null;
  payload: unknown;
}): SerializableReportDefinition {
  return {
    slug: row.slug,
    name: row.name,
    description: row.description ?? undefined,
    dataSource: row.dataSource ?? undefined,
    columns: [],
    ...(row.payload as object), // columns real, summary, header, style, group
  };
}

export function createPrismaReportAdapter(): ReportStorageAdapter {
  return {
    // Sem tabela própria neste esqueleto: grave/leia de uma linha
    // "singleton" na tabela de settings do seu app.
    async loadGlobalConfig() {
      return null;
    },
    async saveGlobalConfig(_config: ReportGlobalConfig) {
      // await prisma.appSettings.upsert(...)
    },

    async listViews(slug) {
      const rows = await prisma.reportView.findMany({ where: slug ? { slug } : undefined });
      return rows.map(rowToView);
    },
    async getView(id) {
      const row = await prisma.reportView.findUnique({ where: { id } });
      return row ? rowToView(row) : null;
    },
    async saveView(view: ReportView) {
      const { id, slug, name, isSystem, isDefault, updatedAt: _updatedAt, ...rest } = view;
      await prisma.reportView.upsert({
        where: { id },
        create: { id, slug, name, isSystem, isDefault, payload: rest },
        update: { name, isSystem, isDefault, payload: rest },
      });
    },
    async deleteView(id) {
      const view = await prisma.reportView.findUnique({ where: { id } });
      if (view?.isSystem) throw new Error("View de sistema é somente leitura.");
      await prisma.reportView.delete({ where: { id } });
    },
    async setDefaultView(slug, viewId) {
      // Transação: zera o default das demais views do slug e marca a nova.
      await prisma.$transaction([
        prisma.reportView.updateMany({ where: { slug }, data: { isDefault: false } }),
        prisma.reportView.update({ where: { id: viewId }, data: { isDefault: true } }),
      ]);
    },

    async listDefinitions() {
      const rows = await prisma.reportDefinition.findMany();
      return rows.map(rowToDefinition);
    },
    async getDefinition(slug) {
      const row = await prisma.reportDefinition.findUnique({ where: { slug } });
      return row ? rowToDefinition(row) : null;
    },
    async saveDefinition(definition: SerializableReportDefinition) {
      const { slug, name, description, dataSource, ...rest } = definition;
      await prisma.reportDefinition.upsert({
        where: { slug },
        create: { slug, name, description, dataSource, payload: rest },
        update: { name, description, dataSource, payload: rest },
      });
    },
    async deleteDefinition(slug) {
      // Sem cascade no schema acima de propósito: apaga views e definição
      // juntas, numa transação, para não depender de configuração do banco.
      await prisma.$transaction([
        prisma.reportView.deleteMany({ where: { slug } }),
        prisma.reportDefinition.delete({ where: { slug } }),
      ]);
    },
  };
}

Ciclo das views

Do relatório definido em código até a view que um usuário deixou como padrão.

  1. Sistema
    ensureReportViews cria a view system:<slug> a partir da definição em código: isSystem: true, somente leitura, sem overrides — reflete o defineReport tal como está.
  2. Duplicar
    No editor de layout, o usuário duplica a view de sistema (ou outra view). Nasce uma view nova com isSystem: false, livre para editar e apagar.
  3. Editar
    Mostrar/ocultar/reordenar colunas, trocar cabeçalho, estilo ou resumo — cada mudança vira um override salvo via saveView, mesclado sobre a definição na hora de resolver o relatório.
  4. Definir como padrão
    setDefaultView marca a view como isDefault: true e zera o default das demais views do mesmo slug — é ela que abre da próxima vez que alguém abrir esse relatório.