Reports Schema
This page documents how reports are stored and rendered in CRMBuilder. An AI agent building any RP-prefixed template must read this before writing any code.
How reports work
Reports are not stored as table rows — they are server-rendered
Next.js pages that query CRM tables directly. There is no reports
table. Each report is a page at /reports/[slug] that:
- Authenticates via
getServerSession - Scopes all queries to
id_companyfrom the session - Runs one or more SQL queries against CRM tables
- Renders the results as a table, chart, or stat cards
- Optionally exports as CSV
File structure for a new report
src/app/(crm)/reports/
page.tsx ← Report index (list of available reports)
[slug]/
page.tsx ← Individual report page
Follow the module scaffolding pattern in core/custom-modules.
Reports live under src/app/(crm)/ and inherit the CRM layout.
Nav registration
Reports are listed on /reports (the index page). Add a new
report to the reports index page component — do not add individual
reports to the main nav.
Standard report page template
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { redirect } from 'next/navigation';
import { db } from '@/lib/db';
export default async function YourReportPage() {
const session = await getServerSession(authOptions);
if (!session) redirect('/login');
const { id_company } = session.user;
const rows = await db.query(`
SELECT ...
FROM your_tables
WHERE id_company = ?
AND deleted_at IS NULL
ORDER BY created_at DESC
`, [id_company]);
return <YourReportComponent rows={rows} />;
}
CSV export pattern
Every report page should include a CSV export button. The pattern:
// API route: /api/reports/[slug]/export
export async function GET(req: Request) {
const session = await getServerSession(authOptions);
if (!session) return new Response('Unauthorized', { status: 401 });
const { id_company } = session.user;
const rows = await db.query(`SELECT ...`, [id_company]);
const csv = [
Object.keys(rows[0]).join(','), // header row
...rows.map(r => Object.values(r).join(','))
].join('\n');
return new Response(csv, {
headers: {
'Content-Type': 'text/csv',
'Content-Disposition': 'attachment; filename="report.csv"'
}
});
}
Key tables for common report types
Pipeline / deals reports
SELECT d.title, d.value, d.status,
ds.name AS stage_name,
c.first_name, c.last_name
FROM deals d
JOIN deal_stages ds ON ds.id = d.id_stage
JOIN customers c ON c.id = d.id_customer
WHERE d.id_company = :id_company
AND d.deleted_at IS NULL
AND d.status = 'open'
ORDER BY d.value DESC;
SMS campaign performance
SELECT
ca.name AS campaign_name,
COUNT(ci.id) AS total_sent,
COUNT(ci.replied_at) AS total_replied,
ROUND(COUNT(ci.replied_at)::numeric /
NULLIF(COUNT(ci.id), 0) * 100, 1) AS reply_rate
FROM campaigns ca
JOIN campaigns_items ci ON ci.id_campaign = ca.id
WHERE ca.id_company = :id_company
AND ca.deleted_at IS NULL
AND ci.deleted_at IS NULL
GROUP BY ca.id, ca.name
ORDER BY reply_rate DESC;
Team activity
SELECT
u.name AS team_member,
COUNT(CASE WHEN d.status = 'won' THEN 1 END) AS deals_won,
COUNT(d.id) AS total_deals,
SUM(CASE WHEN d.status = 'won' THEN d.value ELSE 0 END) AS revenue
FROM pb_users u
LEFT JOIN deals d ON d.id_owner = u.id
AND d.id_company = :id_company
AND d.deleted_at IS NULL
WHERE u.id_company = :id_company
GROUP BY u.id, u.name
ORDER BY revenue DESC;
What does not exist
- No
reportstable — do not attempt to INSERT report definitions - No
deal_stage_historytable — time-in-stage must be approximated usingdeals.updated_at(not accurate for multi-stage movement) - No
contact_activitiestable — activity is tracked inactivity_logandsmslog/emaillogseparately - No report scheduling system — scheduled email digests would require a new cron job, not a report page