Initial commit: Port Nimara CRM (Layers 0-4)
Some checks failed
Build & Push Docker Images / build-and-push (push) Has been cancelled
Build & Push Docker Images / deploy (push) Has been cancelled
Build & Push Docker Images / lint (push) Has been cancelled

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>
This commit is contained in:
2026-03-26 11:52:51 +01:00
commit 67d7e6e3d5
572 changed files with 86496 additions and 0 deletions

View File

@@ -0,0 +1,189 @@
import { and, desc, eq, inArray } from 'drizzle-orm';
import { db } from '@/lib/db';
import { clients, clientContacts } from '@/lib/db/schema/clients';
import { interests } from '@/lib/db/schema/interests';
import { berths, berthWaitingList, berthMaintenanceLog } from '@/lib/db/schema/berths';
import { auditLogs } from '@/lib/db/schema/system';
import { ports } from '@/lib/db/schema/ports';
import { NotFoundError } from '@/lib/errors';
import { generatePdf } from '@/lib/pdf/generate';
import {
clientSummaryTemplate,
buildClientSummaryInputs,
} from '@/lib/pdf/templates/client-summary-template';
import {
berthSpecTemplate,
buildBerthSpecInputs,
} from '@/lib/pdf/templates/berth-spec-template';
import {
interestSummaryTemplate,
buildInterestSummaryInputs,
} from '@/lib/pdf/templates/interest-summary-template';
// ─── Export Client PDF ────────────────────────────────────────────────────────
export async function exportClientPdf(clientId: string, portId: string): Promise<Uint8Array> {
const client = await db.query.clients.findFirst({
where: eq(clients.id, clientId),
});
if (!client || client.portId !== portId) {
throw new NotFoundError('Client');
}
const [contactList, port] = await Promise.all([
db.query.clientContacts.findMany({
where: eq(clientContacts.clientId, clientId),
orderBy: (t, { desc }) => [desc(t.isPrimary), desc(t.createdAt)],
}),
db.query.ports.findFirst({ where: eq(ports.id, portId) }),
]);
// Fetch last 20 interests for this client in this port
const interestList = await db
.select()
.from(interests)
.where(and(eq(interests.clientId, clientId), eq(interests.portId, portId)))
.orderBy(desc(interests.updatedAt))
.limit(20);
// Fetch last 20 audit logs for this client
const activity = await db
.select()
.from(auditLogs)
.where(
and(
eq(auditLogs.portId, portId),
eq(auditLogs.entityType, 'client'),
eq(auditLogs.entityId, clientId),
),
)
.orderBy(desc(auditLogs.createdAt))
.limit(20);
// Enrich interests with berth mooring numbers
const berthIds = interestList
.map((i) => i.berthId)
.filter(Boolean) as string[];
let berthsMap: Record<string, string> = {};
if (berthIds.length > 0) {
const berthRows = await db
.select({ id: berths.id, mooringNumber: berths.mooringNumber })
.from(berths)
.where(inArray(berths.id, berthIds));
berthsMap = Object.fromEntries(berthRows.map((b) => [b.id, b.mooringNumber]));
}
const enrichedInterests = interestList.map((i) => ({
...i,
berthMooringNumber: i.berthId ? (berthsMap[i.berthId] ?? null) : null,
}));
const inputs = buildClientSummaryInputs(client, contactList, enrichedInterests, activity, port);
return generatePdf(clientSummaryTemplate, [inputs]);
}
// ─── Export Berth PDF ─────────────────────────────────────────────────────────
export async function exportBerthPdf(berthId: string, portId: string): Promise<Uint8Array> {
const berth = await db.query.berths.findFirst({
where: eq(berths.id, berthId),
});
if (!berth || berth.portId !== portId) {
throw new NotFoundError('Berth');
}
const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) });
// Waiting list with client names
const waitingListRows = await db
.select({
id: berthWaitingList.id,
position: berthWaitingList.position,
priority: berthWaitingList.priority,
notes: berthWaitingList.notes,
clientId: berthWaitingList.clientId,
})
.from(berthWaitingList)
.where(eq(berthWaitingList.berthId, berthId))
.orderBy(berthWaitingList.position);
const clientIds = waitingListRows.map((w) => w.clientId);
let clientsMap: Record<string, string> = {};
if (clientIds.length > 0) {
const clientRows = await db
.select({ id: clients.id, fullName: clients.fullName })
.from(clients)
.where(inArray(clients.id, clientIds));
clientsMap = Object.fromEntries(clientRows.map((c) => [c.id, c.fullName]));
}
const enrichedWaitingList = waitingListRows.map((w) => ({
...w,
clientName: clientsMap[w.clientId] ?? 'Unknown',
}));
// Maintenance log (last 20)
const maintenance = await db
.select()
.from(berthMaintenanceLog)
.where(eq(berthMaintenanceLog.berthId, berthId))
.orderBy(desc(berthMaintenanceLog.performedDate))
.limit(20);
// Linked interests
const linkedInterests = await db
.select()
.from(interests)
.where(and(eq(interests.berthId, berthId), eq(interests.portId, portId)))
.orderBy(desc(interests.updatedAt))
.limit(20);
const inputs = buildBerthSpecInputs(berth, enrichedWaitingList, maintenance, linkedInterests, port);
return generatePdf(berthSpecTemplate, [inputs]);
}
// ─── Export Interest PDF ──────────────────────────────────────────────────────
export async function exportInterestPdf(interestId: string, portId: string): Promise<Uint8Array> {
const interest = await db.query.interests.findFirst({
where: eq(interests.id, interestId),
});
if (!interest || interest.portId !== portId) {
throw new NotFoundError('Interest');
}
const [client, port] = await Promise.all([
db.query.clients.findFirst({ where: eq(clients.id, interest.clientId) }),
db.query.ports.findFirst({ where: eq(ports.id, portId) }),
]);
let berth = null;
if (interest.berthId) {
berth = await db.query.berths.findFirst({ where: eq(berths.id, interest.berthId) });
}
// Audit timeline (last 20 events for this interest)
const timeline = await db
.select()
.from(auditLogs)
.where(
and(
eq(auditLogs.portId, portId),
eq(auditLogs.entityType, 'interest'),
eq(auditLogs.entityId, interestId),
),
)
.orderBy(desc(auditLogs.createdAt))
.limit(20);
const inputs = buildInterestSummaryInputs(interest, client, berth, timeline, port);
return generatePdf(interestSummaryTemplate, [inputs]);
}