Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
import { z } from 'zod';
|
|
|
|
|
|
|
|
|
|
export const requestReportSchema = z.object({
|
|
|
|
|
reportType: z.enum(['pipeline', 'revenue', 'activity', 'occupancy']),
|
|
|
|
|
name: z.string().min(1).max(200),
|
|
|
|
|
parameters: z
|
|
|
|
|
.object({
|
|
|
|
|
dateFrom: z.string().optional(),
|
|
|
|
|
dateTo: z.string().optional(),
|
|
|
|
|
})
|
|
|
|
|
.optional()
|
|
|
|
|
.default({}),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export const listReportsSchema = z.object({
|
|
|
|
|
page: z.coerce.number().int().positive().default(1),
|
|
|
|
|
limit: z.coerce.number().int().positive().max(100).default(20),
|
|
|
|
|
status: z.enum(['queued', 'processing', 'ready', 'failed']).optional(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export type RequestReportInput = z.infer<typeof requestReportSchema>;
|
|
|
|
|
export type ListReportsInput = z.infer<typeof listReportsSchema>;
|
2026-05-25 14:26:18 +02:00
|
|
|
|
|
|
|
|
// ─── Reports P2: report_runs + report_schedules CRUD ─────────────────────────
|
|
|
|
|
//
|
|
|
|
|
// Reports page (`/{portSlug}/reports`) — the dedicated builder + history +
|
|
|
|
|
// schedules surface. P1 shipped the schema (migration 0084 + Drizzle); P2
|
|
|
|
|
// adds the CRUD layer on the new tables. The legacy `generatedReports` flow
|
|
|
|
|
// above stays for the existing dashboard-export button until it migrates.
|
|
|
|
|
|
feat(reports-overhaul): sales + operational + custom reports, templates, schedules, exports
End-to-end reports build covering Phases 1, 2, 5, 6, 7 of Initiative 1
in docs/launch-readiness.md. Phases 3 (Marketing) + 4 (Financial)
remain deferred per the gap audit at the bottom of that doc.
Highlights:
- Sales performance report: 7 KPI tiles, pipeline funnel + stage
velocity + win-rate-over-time + source conversion + rep leaderboard
charts, deal-heat section, 5 detail tables, stage / lead-cat /
outcome filters.
- Operational report: 7 KPIs, 7 charts (heatmap, status mix, tenancy
churn, tenure histogram, signing box plot, occupancy by area, docs
in pipeline), 4 tables. Module-OFF banner when tenancies disabled.
- Custom (ad-hoc) builder v1: 4 entities (clients, interests, berths,
tenancies), column-whitelist composer, date filter, CSV download,
save-as-template. Registry-only extension path for the remaining 6
entities documented at src/lib/reports/custom/registry.ts.
- Templates: load / modify / save / save-as on Sales / Operational /
Custom. ?templateId= URL deep-link hydration via useRef guard.
Active-template badge clears when the user drives view-state via
wrapped setters; raw setters used on template apply so the badge
survives.
- Scheduled runs: BullMQ poll fires due schedules, mints report_runs,
renders, optionally emails. Recipients optional (zero-recipient
schedules archive without sending). PDF-only output for v1.
Schedule dialog re-mounts via key prop on schedule.id transitions
to avoid setState-in-effect reset patterns.
- Server-side PDF endpoint + shared payload renderer
(lib/pdf/reports/payload-report.tsx) so client + scheduler share
one rendering path.
- Shared currency formatter (lib/reports/format-currency.ts)
consolidates 5 duplicated formatMoney helpers; fixes hardcoded
'USD' in detail tables; pre-formats money rows so PDF export
(which strips column.format callbacks at the JSON boundary)
renders consistently with CSV / XLSX.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 22:41:53 +02:00
|
|
|
export const REPORT_KINDS = [
|
|
|
|
|
'dashboard',
|
|
|
|
|
'clients',
|
|
|
|
|
'berths',
|
|
|
|
|
'interests',
|
|
|
|
|
'sales',
|
|
|
|
|
'operational',
|
|
|
|
|
'custom',
|
|
|
|
|
] as const;
|
2026-05-25 14:26:18 +02:00
|
|
|
export type ReportKind = (typeof REPORT_KINDS)[number];
|
|
|
|
|
|
|
|
|
|
export const REPORT_OUTPUT_FORMATS = ['pdf', 'csv', 'png'] as const;
|
|
|
|
|
export type ReportOutputFormat = (typeof REPORT_OUTPUT_FORMATS)[number];
|
|
|
|
|
|
|
|
|
|
export const REPORT_RUN_STATUSES = ['pending', 'rendering', 'complete', 'failed'] as const;
|
|
|
|
|
export type ReportRunStatus = (typeof REPORT_RUN_STATUSES)[number];
|
|
|
|
|
|
|
|
|
|
export const REPORT_SCHEDULE_CADENCES = [
|
|
|
|
|
'weekly_monday_9',
|
|
|
|
|
'monthly_first_9',
|
|
|
|
|
'quarterly_first_9',
|
|
|
|
|
] as const;
|
|
|
|
|
export type ReportScheduleCadence = (typeof REPORT_SCHEDULE_CADENCES)[number];
|
|
|
|
|
|
|
|
|
|
// ─── report_runs ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export const listReportRunsSchema = z.object({
|
|
|
|
|
page: z.coerce.number().int().positive().default(1),
|
|
|
|
|
pageSize: z.coerce.number().int().positive().max(100).default(20),
|
|
|
|
|
kind: z.enum(REPORT_KINDS).optional(),
|
|
|
|
|
status: z.enum(REPORT_RUN_STATUSES).optional(),
|
|
|
|
|
templateId: z.string().optional(),
|
|
|
|
|
});
|
|
|
|
|
export type ListReportRunsInput = z.infer<typeof listReportRunsSchema>;
|
|
|
|
|
|
|
|
|
|
const recipientSchema = z.object({
|
|
|
|
|
name: z.string().max(120).optional(),
|
|
|
|
|
email: z.string().email(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export const createReportRunSchema = z.object({
|
|
|
|
|
kind: z.enum(REPORT_KINDS),
|
|
|
|
|
templateId: z.string().optional(),
|
|
|
|
|
// Same opaque shape report_templates accepts — the render queue
|
2026-06-02 12:18:11 +02:00
|
|
|
// re-validates per-kind at use time. NOTE: the optional
|
|
|
|
|
// `config.coverBrandPortId` (cover-page brand swap) is intentionally NOT
|
|
|
|
|
// shape-validated here — it requires a runtime per-user membership check
|
|
|
|
|
// that Zod can't express. `createReportRun` rejects an override the
|
|
|
|
|
// triggering user can't access (H11), and the renderer strips a
|
|
|
|
|
// stale/forged one as defense-in-depth.
|
2026-05-25 14:26:18 +02:00
|
|
|
config: z.record(z.string(), z.unknown()),
|
|
|
|
|
outputFormat: z.enum(REPORT_OUTPUT_FORMATS).default('pdf'),
|
|
|
|
|
emailTo: z.array(recipientSchema).max(50).optional(),
|
|
|
|
|
});
|
|
|
|
|
export type CreateReportRunInput = z.infer<typeof createReportRunSchema>;
|
|
|
|
|
|
|
|
|
|
// ─── report_schedules ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export const listReportSchedulesSchema = z.object({
|
|
|
|
|
page: z.coerce.number().int().positive().default(1),
|
|
|
|
|
pageSize: z.coerce.number().int().positive().max(100).default(50),
|
|
|
|
|
enabled: z
|
|
|
|
|
.union([z.literal('true'), z.literal('false')])
|
|
|
|
|
.optional()
|
|
|
|
|
.transform((v) => (v === undefined ? undefined : v === 'true')),
|
|
|
|
|
templateId: z.string().optional(),
|
|
|
|
|
});
|
|
|
|
|
export type ListReportSchedulesInput = z.infer<typeof listReportSchedulesSchema>;
|
|
|
|
|
|
|
|
|
|
export const createReportScheduleSchema = z.object({
|
|
|
|
|
templateId: z.string().min(1),
|
|
|
|
|
cadence: z.enum(REPORT_SCHEDULE_CADENCES),
|
feat(reports-overhaul): sales + operational + custom reports, templates, schedules, exports
End-to-end reports build covering Phases 1, 2, 5, 6, 7 of Initiative 1
in docs/launch-readiness.md. Phases 3 (Marketing) + 4 (Financial)
remain deferred per the gap audit at the bottom of that doc.
Highlights:
- Sales performance report: 7 KPI tiles, pipeline funnel + stage
velocity + win-rate-over-time + source conversion + rep leaderboard
charts, deal-heat section, 5 detail tables, stage / lead-cat /
outcome filters.
- Operational report: 7 KPIs, 7 charts (heatmap, status mix, tenancy
churn, tenure histogram, signing box plot, occupancy by area, docs
in pipeline), 4 tables. Module-OFF banner when tenancies disabled.
- Custom (ad-hoc) builder v1: 4 entities (clients, interests, berths,
tenancies), column-whitelist composer, date filter, CSV download,
save-as-template. Registry-only extension path for the remaining 6
entities documented at src/lib/reports/custom/registry.ts.
- Templates: load / modify / save / save-as on Sales / Operational /
Custom. ?templateId= URL deep-link hydration via useRef guard.
Active-template badge clears when the user drives view-state via
wrapped setters; raw setters used on template apply so the badge
survives.
- Scheduled runs: BullMQ poll fires due schedules, mints report_runs,
renders, optionally emails. Recipients optional (zero-recipient
schedules archive without sending). PDF-only output for v1.
Schedule dialog re-mounts via key prop on schedule.id transitions
to avoid setState-in-effect reset patterns.
- Server-side PDF endpoint + shared payload renderer
(lib/pdf/reports/payload-report.tsx) so client + scheduler share
one rendering path.
- Shared currency formatter (lib/reports/format-currency.ts)
consolidates 5 duplicated formatMoney helpers; fixes hardcoded
'USD' in detail tables; pre-formats money rows so PDF export
(which strips column.format callbacks at the JSON boundary)
renders consistently with CSV / XLSX.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 22:41:53 +02:00
|
|
|
// Empty recipients list = "run + archive, don't email". Per locked
|
|
|
|
|
// decision (2026-05-27): auto-email is OPTIONAL — an admin can
|
|
|
|
|
// schedule a run that just appears in /reports/runs without
|
|
|
|
|
// forcing an email blast.
|
|
|
|
|
recipients: z.array(recipientSchema).max(50).default([]),
|
2026-05-25 14:26:18 +02:00
|
|
|
outputFormat: z.enum(REPORT_OUTPUT_FORMATS).default('pdf'),
|
|
|
|
|
enabled: z.boolean().default(true),
|
|
|
|
|
});
|
|
|
|
|
export type CreateReportScheduleInput = z.infer<typeof createReportScheduleSchema>;
|
|
|
|
|
|
|
|
|
|
export const updateReportScheduleSchema = z.object({
|
|
|
|
|
cadence: z.enum(REPORT_SCHEDULE_CADENCES).optional(),
|
feat(reports-overhaul): sales + operational + custom reports, templates, schedules, exports
End-to-end reports build covering Phases 1, 2, 5, 6, 7 of Initiative 1
in docs/launch-readiness.md. Phases 3 (Marketing) + 4 (Financial)
remain deferred per the gap audit at the bottom of that doc.
Highlights:
- Sales performance report: 7 KPI tiles, pipeline funnel + stage
velocity + win-rate-over-time + source conversion + rep leaderboard
charts, deal-heat section, 5 detail tables, stage / lead-cat /
outcome filters.
- Operational report: 7 KPIs, 7 charts (heatmap, status mix, tenancy
churn, tenure histogram, signing box plot, occupancy by area, docs
in pipeline), 4 tables. Module-OFF banner when tenancies disabled.
- Custom (ad-hoc) builder v1: 4 entities (clients, interests, berths,
tenancies), column-whitelist composer, date filter, CSV download,
save-as-template. Registry-only extension path for the remaining 6
entities documented at src/lib/reports/custom/registry.ts.
- Templates: load / modify / save / save-as on Sales / Operational /
Custom. ?templateId= URL deep-link hydration via useRef guard.
Active-template badge clears when the user drives view-state via
wrapped setters; raw setters used on template apply so the badge
survives.
- Scheduled runs: BullMQ poll fires due schedules, mints report_runs,
renders, optionally emails. Recipients optional (zero-recipient
schedules archive without sending). PDF-only output for v1.
Schedule dialog re-mounts via key prop on schedule.id transitions
to avoid setState-in-effect reset patterns.
- Server-side PDF endpoint + shared payload renderer
(lib/pdf/reports/payload-report.tsx) so client + scheduler share
one rendering path.
- Shared currency formatter (lib/reports/format-currency.ts)
consolidates 5 duplicated formatMoney helpers; fixes hardcoded
'USD' in detail tables; pre-formats money rows so PDF export
(which strips column.format callbacks at the JSON boundary)
renders consistently with CSV / XLSX.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 22:41:53 +02:00
|
|
|
recipients: z.array(recipientSchema).max(50).optional(),
|
2026-05-25 14:26:18 +02:00
|
|
|
outputFormat: z.enum(REPORT_OUTPUT_FORMATS).optional(),
|
|
|
|
|
enabled: z.boolean().optional(),
|
|
|
|
|
});
|
|
|
|
|
export type UpdateReportScheduleInput = z.infer<typeof updateReportScheduleSchema>;
|