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,332 @@
'use client';
import { useCallback, useRef, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { apiFetch } from '@/lib/api/client';
// ─── Types ────────────────────────────────────────────────────────────────────
interface CustomFieldDefinition {
id: string;
fieldName: string;
fieldLabel: string;
fieldType: 'text' | 'number' | 'date' | 'boolean' | 'select';
selectOptions: string[] | null;
isRequired: boolean;
sortOrder: number;
entityType: string;
}
interface CustomFieldValue {
id: string;
fieldId: string;
entityId: string;
value: unknown;
}
interface FieldEntry {
definition: CustomFieldDefinition;
value: CustomFieldValue | null;
}
interface CustomFieldsSectionProps {
entityType: 'client' | 'interest' | 'berth';
entityId: string;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function CustomFieldsSection({ entityType, entityId }: CustomFieldsSectionProps) {
const [collapsed, setCollapsed] = useState(false);
const queryClient = useQueryClient();
// ── Data fetching ──────────────────────────────────────────────────────────
const { data: entries, isLoading } = useQuery<FieldEntry[]>({
queryKey: ['custom-field-values', entityId],
queryFn: async () => {
const res = await apiFetch<{ data: FieldEntry[] }>(
`/api/v1/custom-fields/${entityId}`,
);
return res.data;
},
enabled: !!entityId,
});
// Only show fields for this entity type
const filteredEntries =
entries?.filter((e) => e.definition.entityType === entityType) ?? [];
// ── Mutation ───────────────────────────────────────────────────────────────
const mutation = useMutation({
mutationFn: async (values: Array<{ fieldId: string; value: unknown }>) => {
await apiFetch(`/api/v1/custom-fields/${entityId}`, {
method: 'PUT',
body: { values },
});
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['custom-field-values', entityId] });
},
});
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Custom Fields</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{[1, 2].map((i) => (
<div key={i} className="h-8 animate-pulse rounded bg-muted" />
))}
</div>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader
className="cursor-pointer select-none"
onClick={() => setCollapsed((c) => !c)}
>
<div className="flex items-center justify-between">
<CardTitle className="text-base">Custom Fields</CardTitle>
{collapsed ? (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
</div>
</CardHeader>
{!collapsed && (
<CardContent>
{filteredEntries.length === 0 ? (
<p className="text-sm text-muted-foreground">No custom fields configured.</p>
) : (
<div className="space-y-4">
{filteredEntries.map((entry) => (
<FieldControl
key={entry.definition.id}
entry={entry}
onSave={(fieldId, value) =>
mutation.mutate([{ fieldId, value }])
}
/>
))}
</div>
)}
</CardContent>
)}
</Card>
);
}
// ─── FieldControl ─────────────────────────────────────────────────────────────
interface FieldControlProps {
entry: FieldEntry;
onSave: (fieldId: string, value: unknown) => void;
}
function FieldControl({ entry, onSave }: FieldControlProps) {
const { definition, value: savedValue } = entry;
const initialValue = savedValue?.value ?? null;
// Debounce timer ref
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
function scheduleBlurSave(fieldId: string, val: unknown) {
// Immediate debounce cancel then save after 500ms idle
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => {
onSave(fieldId, val);
}, 500);
}
const label = (
<Label htmlFor={`cf-${definition.id}`} className="text-sm font-medium">
{definition.fieldLabel}
{definition.isRequired && (
<span className="ml-0.5 text-destructive" aria-label="required">
*
</span>
)}
</Label>
);
if (definition.fieldType === 'boolean') {
return (
<BooleanField
definition={definition}
initialValue={initialValue as boolean | null}
label={label}
onSave={onSave}
/>
);
}
if (definition.fieldType === 'select') {
return (
<SelectField
definition={definition}
initialValue={initialValue as string | null}
label={label}
onSave={onSave}
/>
);
}
// text / number / date
return (
<TextLikeField
definition={definition}
initialValue={initialValue}
label={label}
onScheduleSave={scheduleBlurSave}
/>
);
}
// ─── Sub-controls ──────────────────────────────────────────────────────────────
function TextLikeField({
definition,
initialValue,
label,
onScheduleSave,
}: {
definition: CustomFieldDefinition;
initialValue: unknown;
label: React.ReactNode;
onScheduleSave: (fieldId: string, val: unknown) => void;
}) {
const [localValue, setLocalValue] = useState(
initialValue !== null && initialValue !== undefined ? String(initialValue) : '',
);
const inputType =
definition.fieldType === 'number'
? 'number'
: definition.fieldType === 'date'
? 'date'
: 'text';
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const raw = e.target.value;
setLocalValue(raw);
let parsed: unknown = raw;
if (definition.fieldType === 'number') {
parsed = raw === '' ? null : parseFloat(raw);
} else if (raw === '') {
parsed = null;
}
onScheduleSave(definition.id, parsed);
}
return (
<div className="space-y-1.5">
{label}
<Input
id={`cf-${definition.id}`}
type={inputType}
value={localValue}
onChange={handleChange}
placeholder={definition.fieldLabel}
/>
</div>
);
}
function BooleanField({
definition,
initialValue,
label,
onSave,
}: {
definition: CustomFieldDefinition;
initialValue: boolean | null;
label: React.ReactNode;
onSave: (fieldId: string, val: unknown) => void;
}) {
const [checked, setChecked] = useState(initialValue ?? false);
function handleChange(val: boolean) {
setChecked(val);
onSave(definition.id, val);
}
return (
<div className="flex items-center justify-between gap-2">
{label}
<Switch
id={`cf-${definition.id}`}
checked={checked}
onCheckedChange={handleChange}
/>
</div>
);
}
function SelectField({
definition,
initialValue,
label,
onSave,
}: {
definition: CustomFieldDefinition;
initialValue: string | null;
label: React.ReactNode;
onSave: (fieldId: string, val: unknown) => void;
}) {
const options = definition.selectOptions ?? [];
const [selected, setSelected] = useState(initialValue ?? '');
function handleChange(val: string) {
setSelected(val);
onSave(definition.id, val === '__none__' ? null : val);
}
return (
<div className="space-y-1.5">
{label}
<Select value={selected || '__none__'} onValueChange={handleChange}>
<SelectTrigger id={`cf-${definition.id}`}>
<SelectValue placeholder="Select..." />
</SelectTrigger>
<SelectContent>
{!definition.isRequired && (
<SelectItem value="__none__">
<span className="text-muted-foreground">None</span>
</SelectItem>
)}
{options.map((opt) => (
<SelectItem key={opt} value={opt}>
{opt}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}