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,211 @@
'use client';
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Pencil, Archive, RotateCcw, TrendingUp } from 'lucide-react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { TagBadge } from '@/components/shared/tag-badge';
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
import { PermissionGate } from '@/components/shared/permission-gate';
import { InterestForm } from '@/components/interests/interest-form';
import { InterestStagePicker } from '@/components/interests/interest-stage-picker';
import { apiFetch } from '@/lib/api/client';
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',
};
interface InterestDetailHeaderProps {
portSlug: string;
interest: {
id: string;
clientId: string;
clientName: string | null;
berthId: string | null;
berthMooringNumber: string | null;
pipelineStage: string;
leadCategory: string | null;
source: string | null;
notes: string | null;
reminderEnabled: boolean;
reminderDays: number | null;
archivedAt: string | null;
tags?: Array<{ id: string; name: string; color: string }>;
};
}
export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeaderProps) {
const queryClient = useQueryClient();
const [editOpen, setEditOpen] = useState(false);
const [archiveOpen, setArchiveOpen] = useState(false);
const [stageOpen, setStageOpen] = useState(false);
const isArchived = !!interest.archivedAt;
const archiveMutation = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/interests/${interest.id}`, { method: 'DELETE' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['interests', interest.id] });
queryClient.invalidateQueries({ queryKey: ['interests'] });
setArchiveOpen(false);
},
});
const restoreMutation = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/interests/${interest.id}/restore`, { method: 'POST' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['interests', interest.id] });
queryClient.invalidateQueries({ queryKey: ['interests'] });
setArchiveOpen(false);
},
});
return (
<>
<div className="space-y-3">
<div className="flex items-start gap-3 flex-wrap">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h1 className="text-2xl font-bold text-foreground">
{interest.clientName ?? 'Unknown Client'}
</h1>
{isArchived && (
<Badge variant="secondary" className="text-xs">Archived</Badge>
)}
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-sm font-medium ${STAGE_COLORS[interest.pipelineStage] ?? 'bg-gray-100 text-gray-700'}`}
>
{STAGE_LABELS[interest.pipelineStage] ?? interest.pipelineStage}
</span>
</div>
<div className="flex items-center gap-4 mt-2 flex-wrap text-sm text-muted-foreground">
{interest.berthMooringNumber && (
<span>
Berth:{' '}
<Link
href={`/${portSlug}/berths/${interest.berthId}`}
className="text-foreground hover:underline"
>
{interest.berthMooringNumber}
</Link>
</span>
)}
{interest.leadCategory && (
<span>
Category:{' '}
<span className="text-foreground">
{CATEGORY_LABELS[interest.leadCategory] ?? interest.leadCategory}
</span>
</span>
)}
{interest.source && (
<span>
Source:{' '}
<span className="text-foreground capitalize">{interest.source}</span>
</span>
)}
</div>
{interest.tags && interest.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{interest.tags.map((tag) => (
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
))}
</div>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-2 flex-wrap">
<PermissionGate resource="interests" action="edit">
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)}>
<Pencil className="mr-1.5 h-3.5 w-3.5" />
Edit
</Button>
</PermissionGate>
<PermissionGate resource="interests" action="change_stage">
<Button variant="outline" size="sm" onClick={() => setStageOpen(true)}>
<TrendingUp className="mr-1.5 h-3.5 w-3.5" />
Change Stage
</Button>
</PermissionGate>
<PermissionGate resource="interests" action="delete">
<Button variant="outline" size="sm" onClick={() => setArchiveOpen(true)}>
{isArchived ? (
<>
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
Restore
</>
) : (
<>
<Archive className="mr-1.5 h-3.5 w-3.5" />
Archive
</>
)}
</Button>
</PermissionGate>
</div>
</div>
</div>
<InterestForm
open={editOpen}
onOpenChange={setEditOpen}
interest={interest as any}
/>
<InterestStagePicker
open={stageOpen}
onOpenChange={setStageOpen}
interestId={interest.id}
currentStage={interest.pipelineStage}
/>
<ArchiveConfirmDialog
open={archiveOpen}
onOpenChange={setArchiveOpen}
entityName={interest.clientName ?? 'Interest'}
entityType="Interest"
isArchived={isArchived}
onConfirm={() => {
if (isArchived) {
restoreMutation.mutate();
} else {
archiveMutation.mutate();
}
}}
isLoading={archiveMutation.isPending || restoreMutation.isPending}
/>
</>
);
}