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,144 @@
'use client';
import { Download, Eye, FileText, Film, Image, MoreHorizontal, Pencil, Sheet, Trash2 } from 'lucide-react';
import { format } from 'date-fns';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Skeleton } from '@/components/ui/skeleton';
import { PREVIEWABLE_MIMES } from '@/lib/constants/file-validation';
export interface FileRow {
id: string;
filename: string;
originalName: string;
mimeType: string | null;
sizeBytes: string | null;
storagePath?: string;
category: string | null;
createdAt: string | Date;
uploadedBy: string;
}
interface FileGridProps {
files: FileRow[];
onDownload: (file: FileRow) => void;
onPreview: (file: FileRow) => void;
onRename: (file: FileRow) => void;
onDelete: (file: FileRow) => void;
isLoading?: boolean;
}
function formatBytes(bytes: string | null): string {
if (!bytes) return '';
const n = Number(bytes);
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
function FileIcon({ mimeType }: { mimeType: string | null }) {
if (!mimeType) return <FileText className="h-8 w-8 text-muted-foreground" />;
if (mimeType.startsWith('image/')) return <Image className="h-8 w-8 text-blue-500" />;
if (mimeType === 'application/pdf') return <FileText className="h-8 w-8 text-red-500" />;
if (
mimeType === 'application/vnd.ms-excel' ||
mimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
mimeType === 'text/csv'
) {
return <Sheet className="h-8 w-8 text-green-600" />;
}
if (mimeType.startsWith('video/')) return <Film className="h-8 w-8 text-purple-500" />;
return <FileText className="h-8 w-8 text-muted-foreground" />;
}
export function FileGrid({
files,
onDownload,
onPreview,
onRename,
onDelete,
isLoading,
}: FileGridProps) {
if (isLoading) {
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{Array.from({ length: 10 }).map((_, i) => (
<Skeleton key={i} className="h-32 rounded-lg" />
))}
</div>
);
}
if (files.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
<FileText className="h-10 w-10 mb-3 opacity-40" />
<p className="text-sm font-medium">No files yet</p>
<p className="text-xs mt-1">Upload files using the zone above</p>
</div>
);
}
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{files.map((file) => (
<div
key={file.id}
className="group relative rounded-lg border bg-card p-3 hover:border-primary/50 hover:shadow-sm transition-all"
>
<div className="flex flex-col items-center gap-2">
<FileIcon mimeType={file.mimeType} />
<p className="w-full truncate text-center text-xs font-medium" title={file.filename}>
{file.filename}
</p>
<div className="flex flex-col items-center gap-0.5 text-[10px] text-muted-foreground">
<span>{formatBytes(file.sizeBytes)}</span>
<span>{format(new Date(file.createdAt), 'MMM d, yyyy')}</span>
</div>
</div>
<div className="absolute right-1 top-1 opacity-0 group-hover:opacity-100 transition-opacity">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6">
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem onClick={() => onDownload(file)}>
<Download className="mr-2 h-3.5 w-3.5" />
Download
</DropdownMenuItem>
{file.mimeType && PREVIEWABLE_MIMES.has(file.mimeType) && (
<DropdownMenuItem onClick={() => onPreview(file)}>
<Eye className="mr-2 h-3.5 w-3.5" />
Preview
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => onRename(file)}>
<Pencil className="mr-2 h-3.5 w-3.5" />
Rename
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onDelete(file)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,112 @@
'use client';
import { useEffect, useState } from 'react';
import { ExternalLink } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { apiFetch } from '@/lib/api/client';
interface FilePreviewDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
fileId?: string;
fileName?: string;
mimeType?: string;
}
export function FilePreviewDialog({
open,
onOpenChange,
fileId,
fileName,
mimeType,
}: FilePreviewDialogProps) {
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open || !fileId) {
setPreviewUrl(null);
setError(null);
return;
}
setLoading(true);
setError(null);
apiFetch<{ data: { url: string } }>(`/api/v1/files/${fileId}/preview`)
.then((res) => {
setPreviewUrl(res.data.url);
})
.catch(() => {
setError('Failed to load preview');
})
.finally(() => {
setLoading(false);
});
}, [open, fileId]);
const isImage = mimeType?.startsWith('image/');
const isPdf = mimeType === 'application/pdf';
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl w-full h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 truncate">
<span className="truncate">{fileName ?? 'Preview'}</span>
{previewUrl && (
<a
href={previewUrl}
target="_blank"
rel="noopener noreferrer"
className="shrink-0 text-muted-foreground hover:text-foreground"
>
<ExternalLink className="h-4 w-4" />
</a>
)}
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-hidden rounded-lg border bg-muted/20">
{loading && (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Loading preview...
</div>
)}
{error && (
<div className="flex h-full items-center justify-center text-sm text-destructive">
{error}
</div>
)}
{!loading && !error && previewUrl && isImage && (
<div className="flex h-full items-center justify-center p-4">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={previewUrl}
alt={fileName ?? 'Preview'}
className="max-h-full max-w-full object-contain rounded"
/>
</div>
)}
{!loading && !error && previewUrl && isPdf && (
<iframe
src={previewUrl}
title={fileName ?? 'PDF Preview'}
className="h-full w-full"
/>
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,185 @@
'use client';
import { useCallback, useRef, useState } from 'react';
import { Upload, X } from 'lucide-react';
import { cn } from '@/lib/utils';
interface UploadingFile {
id: string;
name: string;
progress: number;
error?: string;
}
interface FileUploadZoneProps {
entityType?: string;
entityId?: string;
clientId?: string;
onUploadComplete?: () => void;
}
export function FileUploadZone({
entityType,
entityId,
clientId,
onUploadComplete,
}: FileUploadZoneProps) {
const [isDragOver, setIsDragOver] = useState(false);
const [uploading, setUploading] = useState<UploadingFile[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
const uploadFiles = useCallback(
async (fileList: FileList) => {
const newUploads: UploadingFile[] = Array.from(fileList).map((f) => ({
id: crypto.randomUUID(),
name: f.name,
progress: 0,
}));
setUploading((prev) => [...prev, ...newUploads]);
await Promise.all(
Array.from(fileList).map(async (file, i) => {
const uploadId = newUploads[i]!.id;
try {
const formData = new FormData();
formData.append('file', file);
formData.append('filename', file.name);
if (clientId) formData.append('clientId', clientId);
if (entityType) formData.append('entityType', entityType);
if (entityId) formData.append('entityId', entityId);
setUploading((prev) =>
prev.map((u) => (u.id === uploadId ? { ...u, progress: 50 } : u)),
);
// Use fetch directly for FormData (apiFetch JSON-encodes body)
const portId = (await import('@/stores/ui-store'))
.useUIStore.getState().currentPortId;
const headers = new Headers();
if (portId) headers.set('X-Port-Id', portId);
const uploadRes = await fetch('/api/v1/files/upload', {
method: 'POST',
headers,
credentials: 'include',
body: formData,
});
if (!uploadRes.ok) {
throw new Error('Upload failed');
}
setUploading((prev) =>
prev.map((u) => (u.id === uploadId ? { ...u, progress: 100 } : u)),
);
} catch {
setUploading((prev) =>
prev.map((u) =>
u.id === uploadId ? { ...u, error: 'Upload failed' } : u,
),
);
}
}),
);
// Clear completed uploads after a moment
setTimeout(() => {
setUploading((prev) => prev.filter((u) => u.error));
onUploadComplete?.();
}, 1500);
},
[clientId, entityType, entityId, onUploadComplete],
);
const handleDrop = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragOver(false);
if (e.dataTransfer.files.length > 0) {
void uploadFiles(e.dataTransfer.files);
}
},
[uploadFiles],
);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
void uploadFiles(e.target.files);
e.target.value = '';
}
},
[uploadFiles],
);
return (
<div className="space-y-3">
<div
role="button"
tabIndex={0}
className={cn(
'flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 transition-colors cursor-pointer',
isDragOver
? 'border-primary bg-primary/5'
: 'border-muted-foreground/25 hover:border-primary/50 hover:bg-muted/30',
)}
onDragOver={(e) => {
e.preventDefault();
setIsDragOver(true);
}}
onDragLeave={() => setIsDragOver(false)}
onDrop={handleDrop}
onClick={() => inputRef.current?.click()}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') inputRef.current?.click();
}}
>
<Upload className="h-8 w-8 text-muted-foreground mb-2" />
<p className="text-sm font-medium">Drop files here or click to upload</p>
<p className="text-xs text-muted-foreground mt-1">
PDF, Word, Excel, images up to 50MB
</p>
<input
ref={inputRef}
type="file"
multiple
className="hidden"
onChange={handleChange}
accept=".jpg,.jpeg,.png,.gif,.webp,.pdf,.doc,.docx,.xls,.xlsx,.txt,.csv"
/>
</div>
{uploading.length > 0 && (
<div className="space-y-2">
{uploading.map((u) => (
<div key={u.id} className="flex items-center gap-3 text-sm">
<span className="flex-1 truncate">{u.name}</span>
{u.error ? (
<span className="text-destructive text-xs">{u.error}</span>
) : (
<div className="flex items-center gap-2">
<div className="h-1.5 w-24 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${u.progress}%` }}
/>
</div>
<span className="text-xs text-muted-foreground">{u.progress}%</span>
</div>
)}
{u.error && (
<button
type="button"
onClick={() =>
setUploading((prev) => prev.filter((x) => x.id !== u.id))
}
>
<X className="h-3.5 w-3.5 text-muted-foreground" />
</button>
)}
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,139 @@
'use client';
import { useState } from 'react';
import { ChevronDown, ChevronRight, Folder, FolderOpen } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { FileRow } from '@/components/files/file-grid';
interface FolderNode {
name: string;
fullPath: string;
children: Record<string, FolderNode>;
}
function buildFolderTree(files: FileRow[]): FolderNode {
const root: FolderNode = { name: '', fullPath: '', children: {} };
for (const file of files) {
const parts = file.storagePath ? file.storagePath.split('/').slice(0, -1) : [];
if (parts.length <= 1) continue; // skip files directly in root/port folder
let node = root;
let accumulated = '';
for (const part of parts.slice(1)) { // skip portSlug prefix
accumulated = accumulated ? `${accumulated}/${part}` : part;
if (!node.children[part]) {
node.children[part] = { name: part, fullPath: accumulated, children: {} };
}
node = node.children[part]!;
}
}
return root;
}
interface FolderNodeComponentProps {
node: FolderNode;
currentFolder: string;
onFolderSelect: (path: string) => void;
depth?: number;
}
function FolderNodeComponent({
node,
currentFolder,
onFolderSelect,
depth = 0,
}: FolderNodeComponentProps) {
const [expanded, setExpanded] = useState(true);
const hasChildren = Object.keys(node.children).length > 0;
const isSelected = currentFolder === node.fullPath;
return (
<div>
<button
type="button"
onClick={() => {
onFolderSelect(node.fullPath);
if (hasChildren) setExpanded((v) => !v);
}}
className={cn(
'flex w-full items-center gap-1.5 rounded px-2 py-1 text-left text-sm hover:bg-muted/60 transition-colors',
isSelected && 'bg-muted font-medium',
)}
style={{ paddingLeft: `${depth * 12 + 8}px` }}
>
{hasChildren ? (
expanded ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)
) : (
<span className="w-3.5" />
)}
{isSelected ? (
<FolderOpen className="h-4 w-4 shrink-0 text-primary" />
) : (
<Folder className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<span className="truncate">{node.name}</span>
</button>
{hasChildren && expanded && (
<div>
{Object.values(node.children).map((child) => (
<FolderNodeComponent
key={child.fullPath}
node={child}
currentFolder={currentFolder}
onFolderSelect={onFolderSelect}
depth={depth + 1}
/>
))}
</div>
)}
</div>
);
}
interface FolderTreeProps {
files: (FileRow & { storagePath: string })[];
currentFolder: string;
onFolderSelect: (path: string) => void;
}
export function FolderTree({ files, currentFolder, onFolderSelect }: FolderTreeProps) {
const tree = buildFolderTree(files);
return (
<div className="space-y-0.5">
<button
type="button"
onClick={() => onFolderSelect('')}
className={cn(
'flex w-full items-center gap-1.5 rounded px-2 py-1 text-left text-sm hover:bg-muted/60 transition-colors',
currentFolder === '' && 'bg-muted font-medium',
)}
>
<span className="w-3.5" />
{currentFolder === '' ? (
<FolderOpen className="h-4 w-4 shrink-0 text-primary" />
) : (
<Folder className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<span>All Files</span>
</button>
{Object.values(tree.children).map((child) => (
<FolderNodeComponent
key={child.fullPath}
node={child}
currentFolder={currentFolder}
onFolderSelect={onFolderSelect}
/>
))}
</div>
);
}