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>
140 lines
3.9 KiB
TypeScript
140 lines
3.9 KiB
TypeScript
'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>
|
|
);
|
|
}
|