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,220 @@
'use client';
import Link from 'next/link';
import { format } from 'date-fns';
import { MoreHorizontal, Pencil, Archive } from 'lucide-react';
import type { ColumnDef } from '@tanstack/react-table';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Badge } from '@/components/ui/badge';
import { TagBadge } from '@/components/shared/tag-badge';
export interface InterestRow {
id: string;
clientId: string;
clientName: string | null;
berthId: string | null;
berthMooringNumber: string | null;
pipelineStage: string;
leadCategory: string | null;
source: string | null;
archivedAt: string | null;
createdAt: string;
tags?: Array<{ id: string; name: string; color: string }>;
}
const STAGE_LABELS: Record<string, string> = {
open: 'Open',
details_sent: 'Details Sent',
in_communication: 'In Communication',
visited: 'Visited',
signed_eoi_nda: 'Signed EOI/NDA',
deposit_10pct: 'Deposit 10%',
contract: 'Contract',
completed: 'Completed',
};
const STAGE_COLORS: Record<string, string> = {
open: 'bg-slate-100 text-slate-700',
details_sent: 'bg-blue-100 text-blue-700',
in_communication: 'bg-sky-100 text-sky-700',
visited: 'bg-violet-100 text-violet-700',
signed_eoi_nda: 'bg-amber-100 text-amber-700',
deposit_10pct: 'bg-orange-100 text-orange-700',
contract: 'bg-green-100 text-green-700',
completed: 'bg-emerald-100 text-emerald-700',
};
const CATEGORY_LABELS: Record<string, string> = {
general_interest: 'General Interest',
specific_qualified: 'Specific Qualified',
hot_lead: 'Hot Lead',
};
const SOURCE_LABELS: Record<string, string> = {
website: 'Website',
manual: 'Manual',
referral: 'Referral',
broker: 'Broker',
};
interface GetColumnsOptions {
portSlug: string;
onEdit: (interest: InterestRow) => void;
onArchive: (interest: InterestRow) => void;
}
export function getInterestColumns({
portSlug,
onEdit,
onArchive,
}: GetColumnsOptions): ColumnDef<InterestRow, unknown>[] {
return [
{
id: 'clientName',
accessorKey: 'clientName',
header: 'Client',
cell: ({ row }) => (
<Link
href={`/${portSlug}/clients/${row.original.clientId}`}
className="font-medium text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
{row.original.clientName ?? '—'}
</Link>
),
},
{
id: 'berthMooringNumber',
accessorKey: 'berthMooringNumber',
header: 'Berth',
cell: ({ row }) => {
if (!row.original.berthId || !row.original.berthMooringNumber) {
return <span className="text-muted-foreground"></span>;
}
return (
<Link
href={`/${portSlug}/berths/${row.original.berthId}`}
className="text-primary hover:underline text-sm"
onClick={(e) => e.stopPropagation()}
>
{row.original.berthMooringNumber}
</Link>
);
},
},
{
id: 'pipelineStage',
accessorKey: 'pipelineStage',
header: 'Stage',
cell: ({ getValue }) => {
const stage = getValue() as string;
return (
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${STAGE_COLORS[stage] ?? 'bg-gray-100 text-gray-700'}`}
>
{STAGE_LABELS[stage] ?? stage}
</span>
);
},
},
{
id: 'leadCategory',
accessorKey: 'leadCategory',
header: 'Category',
cell: ({ getValue }) => {
const cat = getValue() as string | null;
if (!cat) return <span className="text-muted-foreground"></span>;
return (
<Badge variant="outline" className="text-xs capitalize">
{CATEGORY_LABELS[cat] ?? cat}
</Badge>
);
},
},
{
id: 'source',
accessorKey: 'source',
header: 'Source',
cell: ({ getValue }) => {
const source = getValue() as string | null;
if (!source) return <span className="text-muted-foreground"></span>;
return (
<Badge variant="outline" className="text-xs">
{SOURCE_LABELS[source] ?? source}
</Badge>
);
},
},
{
id: 'tags',
header: 'Tags',
enableSorting: false,
cell: ({ row }) => {
const rowTags = row.original.tags ?? [];
if (rowTags.length === 0) return <span className="text-muted-foreground"></span>;
return (
<div className="flex flex-wrap gap-1">
{rowTags.slice(0, 3).map((tag) => (
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
))}
{rowTags.length > 3 && (
<Badge variant="secondary" className="text-xs">
+{rowTags.length - 3}
</Badge>
)}
</div>
);
},
},
{
id: 'createdAt',
accessorKey: 'createdAt',
header: 'Created',
cell: ({ getValue }) => (
<span className="text-muted-foreground text-sm">
{format(new Date(getValue() as string), 'MMM d, yyyy')}
</span>
),
},
{
id: 'actions',
header: '',
enableSorting: false,
size: 48,
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit(row.original)}>
<Pencil className="mr-2 h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onClick={() => onArchive(row.original)}
>
<Archive className="mr-2 h-3.5 w-3.5" />
Archive
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
},
];
}