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

51
src/hooks/use-auth.ts Normal file
View File

@@ -0,0 +1,51 @@
'use client';
import { useSession } from '@/lib/auth/client';
import type { AuthUser, AuthSession } from '@/types/auth';
interface UseAuthReturn {
user: AuthUser | null;
session: AuthSession | null;
isLoading: boolean;
isAuthenticated: boolean;
}
/**
* Hook that wraps Better Auth's useSession and exposes a typed user + session.
*/
export function useAuth(): UseAuthReturn {
const { data: session, isPending } = useSession();
const user: AuthUser | null = session?.user
? {
id: session.user.id,
email: session.user.email,
name: session.user.name,
image: session.user.image ?? null,
emailVerified: session.user.emailVerified,
createdAt: session.user.createdAt,
updatedAt: session.user.updatedAt,
}
: null;
const typedSession: AuthSession | null = session
? {
id: session.session.id,
userId: session.session.userId,
token: session.session.token,
expiresAt: session.session.expiresAt,
ipAddress: session.session.ipAddress ?? null,
userAgent: session.session.userAgent ?? null,
createdAt: session.session.createdAt,
updatedAt: session.session.updatedAt,
user,
}
: null;
return {
user,
session: typedSession,
isLoading: isPending,
isAuthenticated: !!user,
};
}

14
src/hooks/use-debounce.ts Normal file
View File

@@ -0,0 +1,14 @@
'use client';
import { useState, useEffect } from 'react';
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}

View File

@@ -0,0 +1,59 @@
'use client';
import { useState, useMemo, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api/client';
interface UseEntityOptionsParams {
endpoint: string;
labelKey?: string;
valueKey?: string;
enabled?: boolean;
}
interface EntityOption {
value: string;
label: string;
[key: string]: unknown;
}
/**
* Fetches a list of options for comboboxes/selects from an API endpoint.
* Supports 300ms debounced search.
*/
export function useEntityOptions({
endpoint,
labelKey = 'name',
valueKey = 'id',
enabled = true,
}: UseEntityOptionsParams) {
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
// 300ms debounce
useEffect(() => {
const timer = setTimeout(() => setDebouncedSearch(search), 300);
return () => clearTimeout(timer);
}, [search]);
const queryParams = debouncedSearch ? `?search=${encodeURIComponent(debouncedSearch)}` : '';
const { data, isLoading } = useQuery<unknown[]>({
queryKey: ['entityOptions', endpoint, debouncedSearch],
queryFn: () =>
apiFetch<{ data: unknown[] }>(`${endpoint}${queryParams}`).then((r) => r.data),
enabled,
});
const options: EntityOption[] = useMemo(() => {
if (!data) return [];
return data.map((item: any) => ({
value: String(item[valueKey]),
label: String(item[labelKey]),
...item,
}));
}, [data, valueKey, labelKey]);
return { options, isLoading, search, setSearch };
}

View File

@@ -0,0 +1,18 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api/client';
/**
* Returns true when the given feature flag is enabled for the current port.
* Result is cached for 5 minutes.
*/
export function useFeatureFlag(key: string): boolean {
const { data } = useQuery<{ enabled: boolean }>({
queryKey: ['feature-flag', key],
queryFn: () => apiFetch(`/api/v1/settings/feature-flag?key=${encodeURIComponent(key)}`),
staleTime: 300_000, // 5 min
});
return data?.enabled ?? false;
}

View File

@@ -0,0 +1,47 @@
'use client';
import { useEffect, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useSocket } from '@/providers/socket-provider';
import { apiFetch } from '@/lib/api/client';
export function useNotifications() {
const socket = useSocket();
const queryClient = useQueryClient();
const [unreadCount, setUnreadCount] = useState(0);
// Initial unread count
const { data } = useQuery<{ count: number }>({
queryKey: ['notifications', 'unread-count'],
queryFn: () => apiFetch('/api/v1/notifications/unread-count'),
staleTime: 30_000,
});
useEffect(() => {
if (data) setUnreadCount(data.count);
}, [data]);
// Socket listeners
useEffect(() => {
if (!socket) return;
const handleNew = () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
};
const handleCount = (payload: { count: number }) => {
setUnreadCount(payload.count);
};
socket.on('notification:new' as any, handleNew);
socket.on('notification:unreadCount' as any, handleCount);
return () => {
socket.off('notification:new' as any, handleNew);
socket.off('notification:unreadCount' as any, handleCount);
};
}, [socket, queryClient]);
return { unreadCount };
}

View File

@@ -0,0 +1,175 @@
'use client';
import { useState, useCallback, useMemo, useEffect } from 'react';
import { useQuery, useQueryClient, type QueryKey } from '@tanstack/react-query';
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
import { apiFetch } from '@/lib/api/client';
import type { PaginatedResponse } from '@/types/api';
import {
serializeFiltersToParams,
deserializeFiltersFromParams,
type FilterDefinition,
type FilterValues,
} from '@/components/shared/filter-bar';
interface UsePaginatedQueryOptions<T> {
queryKey: QueryKey;
endpoint: string;
initialPage?: number;
initialPageSize?: number;
filterDefinitions?: FilterDefinition[];
}
export function usePaginatedQuery<T>({
queryKey,
endpoint,
initialPage = 1,
initialPageSize = 25,
filterDefinitions = [],
}: UsePaginatedQueryOptions<T>) {
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const queryClient = useQueryClient();
// Read initial state from URL
const pageFromUrl = Number(searchParams.get('page')) || initialPage;
const pageSizeFromUrl = Number(searchParams.get('limit')) || initialPageSize;
const sortFieldFromUrl = searchParams.get('sort') ?? undefined;
const sortOrderFromUrl = (searchParams.get('order') as 'asc' | 'desc') ?? 'desc';
const [page, setPageState] = useState(pageFromUrl);
const [pageSize, setPageSizeState] = useState(pageSizeFromUrl);
const [sort, setSortState] = useState<{ field: string; direction: 'asc' | 'desc' } | undefined>(
sortFieldFromUrl ? { field: sortFieldFromUrl, direction: sortOrderFromUrl } : undefined,
);
const [filters, setFiltersState] = useState<FilterValues>(() =>
deserializeFiltersFromParams(searchParams, filterDefinitions),
);
// Sync state to URL
const syncUrl = useCallback(
(p: number, ps: number, s?: typeof sort, f?: FilterValues) => {
const params = new URLSearchParams();
if (p !== 1) params.set('page', String(p));
if (ps !== initialPageSize) params.set('limit', String(ps));
if (s) {
params.set('sort', s.field);
params.set('order', s.direction);
}
if (f) {
const filterParams = serializeFiltersToParams(f);
filterParams.forEach((value, key) => params.set(key, value));
}
// Keep existing tab param
const tab = searchParams.get('tab');
if (tab) params.set('tab', tab);
const qs = params.toString();
router.replace(`${pathname}${qs ? `?${qs}` : ''}` as any, { scroll: false });
},
[pathname, router, searchParams, initialPageSize],
);
function setPage(p: number) {
setPageState(p);
syncUrl(p, pageSize, sort, filters);
}
function setPageSize(ps: number) {
setPageSizeState(ps);
setPageState(1);
syncUrl(1, ps, sort, filters);
}
function setSort(field: string, direction: 'asc' | 'desc') {
const newSort = { field, direction };
setSortState(newSort);
setPageState(1);
syncUrl(1, pageSize, newSort, filters);
}
function setFilter(key: string, value: unknown) {
const newFilters = { ...filters };
if (value === undefined || value === null) {
delete newFilters[key];
} else {
newFilters[key] = value;
}
setFiltersState(newFilters);
setPageState(1);
syncUrl(1, pageSize, sort, newFilters);
}
function clearFilters() {
setFiltersState({});
setPageState(1);
syncUrl(1, pageSize, sort, {});
}
// Build query string for API
const apiParams = useMemo(() => {
const params = new URLSearchParams();
params.set('page', String(page));
params.set('limit', String(pageSize));
if (sort) {
params.set('sort', sort.field);
params.set('order', sort.direction);
}
const filterParams = serializeFiltersToParams(filters);
filterParams.forEach((value, key) => params.set(key, value));
return params.toString();
}, [page, pageSize, sort, filters]);
const fullQueryKey = [...queryKey, apiParams];
const { data, isLoading, isFetching } = useQuery<PaginatedResponse<T>>({
queryKey: fullQueryKey,
queryFn: () =>
apiFetch<PaginatedResponse<T>>(`${endpoint}?${apiParams}`),
});
const pagination = data?.pagination
? {
page: data.pagination.page,
pageSize: data.pagination.pageSize,
total: data.pagination.total,
totalPages: data.pagination.totalPages,
}
: { page, pageSize, total: 0, totalPages: 0 };
/**
* Optimistically removes an item from the cached list (e.g., after archive).
*/
function optimisticRemove(id: string) {
queryClient.setQueryData<PaginatedResponse<T>>(fullQueryKey, (old) => {
if (!old) return old;
return {
...old,
data: old.data.filter((item: any) => item.id !== id),
pagination: {
...old.pagination,
total: old.pagination.total - 1,
},
};
});
}
return {
data: data?.data ?? [],
pagination,
isLoading,
isFetching,
sort,
setSort,
page,
setPage,
pageSize,
setPageSize,
filters,
setFilter,
clearFilters,
optimisticRemove,
};
}

View File

@@ -0,0 +1,35 @@
'use client';
import { usePermissionsStore } from '@/stores/permissions-store';
import type { RolePermissions } from '@/lib/db/schema/users';
type Resource = keyof RolePermissions;
type Action<R extends Resource> = keyof RolePermissions[R];
interface UsePermissionsReturn {
can: <R extends Resource>(resource: R, action: Action<R>) => boolean;
permissions: RolePermissions | null;
isSuperAdmin: boolean;
userId: string | null;
}
/**
* Hook that provides a `can(resource, action)` check using the current user's
* permissions from the permissions store (populated by PermissionsProvider).
*/
export function usePermissions(): UsePermissionsReturn {
const permissions = usePermissionsStore((s) => s.permissions);
const isSuperAdmin = usePermissionsStore((s) => s.isSuperAdmin);
const userId = usePermissionsStore((s) => s.userId);
function can<R extends Resource>(resource: R, action: Action<R>): boolean {
// Super admins bypass all permission checks
if (isSuperAdmin) return true;
if (!permissions) return false;
const resourcePerms = permissions[resource];
if (!resourcePerms) return false;
return !!(resourcePerms as Record<string, boolean>)[action as string];
}
return { can, permissions, isSuperAdmin, userId };
}

31
src/hooks/use-port.ts Normal file
View File

@@ -0,0 +1,31 @@
'use client';
import { useUIStore } from '@/stores/ui-store';
import { usePortContext } from '@/providers/port-provider';
import type { Port } from '@/lib/db/schema/ports';
interface UsePortReturn {
currentPort: Port | null;
currentPortId: string | null;
currentPortSlug: string | null;
ports: Port[];
setPort: (portId: string, portSlug: string) => void;
}
/**
* Hook to get current port context from Zustand store and PortContext.
*/
export function usePort(): UsePortReturn {
const { currentPort, ports } = usePortContext();
const currentPortId = useUIStore((s) => s.currentPortId);
const currentPortSlug = useUIStore((s) => s.currentPortSlug);
const setPort = useUIStore((s) => s.setPort);
return {
currentPort,
currentPortId,
currentPortSlug,
ports,
setPort,
};
}

View File

@@ -0,0 +1,47 @@
'use client';
import { useEffect } from 'react';
import { useQueryClient, type QueryKey } from '@tanstack/react-query';
import { useSocket } from '@/providers/socket-provider';
/**
* Subscribes to socket events and invalidates React Query caches.
*
* @param eventMap - Maps socket event names to arrays of query keys to invalidate.
*
* @example
* useRealtimeInvalidation({
* 'client:created': [['clients']],
* 'client:updated': [['clients'], ['clients', clientId]],
* 'client:archived': [['clients']],
* });
*/
export function useRealtimeInvalidation(
eventMap: Record<string, QueryKey[]>,
) {
const socket = useSocket();
const queryClient = useQueryClient();
useEffect(() => {
if (!socket) return;
const handlers: Array<{ event: string; handler: (...args: unknown[]) => void }> = [];
for (const [event, queryKeys] of Object.entries(eventMap)) {
const handler = () => {
for (const key of queryKeys) {
queryClient.invalidateQueries({ queryKey: key });
}
};
socket.on(event as any, handler);
handlers.push({ event, handler });
}
return () => {
for (const { event, handler } of handlers) {
socket.off(event as any, handler);
}
};
}, [socket, queryClient, eventMap]);
}

View File

@@ -0,0 +1,66 @@
'use client';
import { useState } from 'react';
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api/client';
import type { SavedView } from '@/lib/db/schema/system';
export function useSavedViews(entityType: string) {
const queryClient = useQueryClient();
const [activeViewId, setActiveViewId] = useState<string | null>(null);
const queryKey = ['savedViews', entityType];
const { data: views = [] } = useQuery<SavedView[]>({
queryKey,
queryFn: () =>
apiFetch<{ data: SavedView[] }>(
`/api/v1/saved-views?entityType=${entityType}`,
).then((r) => r.data),
});
const saveMutation = useMutation({
mutationFn: (params: {
name: string;
filters: Record<string, unknown>;
sortConfig?: unknown;
}) =>
apiFetch<{ data: SavedView }>('/api/v1/saved-views', {
method: 'POST',
body: { ...params, entityType },
}),
onSuccess: () => queryClient.invalidateQueries({ queryKey }),
});
const deleteMutation = useMutation({
mutationFn: (viewId: string) =>
apiFetch(`/api/v1/saved-views/${viewId}`, { method: 'DELETE' }),
onSuccess: () => queryClient.invalidateQueries({ queryKey }),
});
async function saveCurrentView(
name: string,
filters: Record<string, unknown>,
sortConfig?: unknown,
) {
await saveMutation.mutateAsync({ name, filters, sortConfig });
}
function deleteView(viewId: string) {
if (activeViewId === viewId) setActiveViewId(null);
deleteMutation.mutate(viewId);
}
function applyView(viewId: string) {
setActiveViewId(viewId);
}
return {
views,
activeViewId,
saveCurrentView,
deleteView,
applyView,
};
}

45
src/hooks/use-search.ts Normal file
View File

@@ -0,0 +1,45 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api/client';
import { useDebounce } from '@/hooks/use-debounce';
// ─── Types ────────────────────────────────────────────────────────────────────
interface SearchResults {
clients: Array<{ id: string; fullName: string; companyName: string | null }>;
interests: Array<{
id: string;
clientName: string;
berthMooringNumber: string | null;
pipelineStage: string;
}>;
berths: Array<{ id: string; mooringNumber: string; area: string | null; status: string }>;
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
export function useSearch(query: string) {
const debouncedQuery = useDebounce(query, 300);
const searchQuery = useQuery<SearchResults>({
queryKey: ['search', debouncedQuery],
queryFn: () =>
apiFetch<SearchResults>(`/api/v1/search?q=${encodeURIComponent(debouncedQuery)}`),
enabled: debouncedQuery.length >= 2,
staleTime: 30_000,
});
const recentQuery = useQuery<{ searches: string[] }>({
queryKey: ['search', 'recent'],
queryFn: () => apiFetch<{ searches: string[] }>('/api/v1/search/recent'),
staleTime: 60_000,
});
return {
results: searchQuery.data,
isLoading: searchQuery.isLoading,
recentSearches: recentQuery.data?.searches ?? [],
};
}

3
src/hooks/use-socket.ts Normal file
View File

@@ -0,0 +1,3 @@
'use client';
export { useSocket } from '@/providers/socket-provider';