Initial commit: Port Nimara CRM (Layers 0-4)
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:
436
src/components/clients/client-form.tsx
Normal file
436
src/components/clients/client-form.tsx
Normal file
@@ -0,0 +1,436 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useForm, useFieldArray } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Trash2, Loader2 } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetFooter,
|
||||
} from '@/components/ui/sheet';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { TagPicker } from '@/components/shared/tag-picker';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { createClientSchema, type CreateClientInput } from '@/lib/validators/clients';
|
||||
|
||||
interface ClientFormProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** If provided, form is in edit mode */
|
||||
client?: {
|
||||
id: string;
|
||||
fullName: string;
|
||||
companyName?: string | null;
|
||||
nationality?: string | null;
|
||||
isProxy?: boolean;
|
||||
proxyType?: string | null;
|
||||
actualOwnerName?: string | null;
|
||||
yachtName?: string | null;
|
||||
berthSizeDesired?: string | null;
|
||||
preferredContactMethod?: string | null;
|
||||
preferredLanguage?: string | null;
|
||||
timezone?: string | null;
|
||||
source?: string | null;
|
||||
sourceDetails?: string | null;
|
||||
contacts?: Array<{
|
||||
channel: string;
|
||||
value: string;
|
||||
label?: string | null;
|
||||
isPrimary?: boolean;
|
||||
}>;
|
||||
tags?: Array<{ id: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const isEdit = !!client;
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
watch,
|
||||
setValue,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<CreateClientInput>({
|
||||
resolver: zodResolver(createClientSchema),
|
||||
defaultValues: {
|
||||
fullName: '',
|
||||
contacts: [{ channel: 'email', value: '', isPrimary: true }],
|
||||
isProxy: false,
|
||||
tagIds: [],
|
||||
},
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({ control, name: 'contacts' });
|
||||
const isProxy = watch('isProxy');
|
||||
const tagIds = watch('tagIds') ?? [];
|
||||
|
||||
// Populate form when editing
|
||||
useEffect(() => {
|
||||
if (client && open) {
|
||||
reset({
|
||||
fullName: client.fullName,
|
||||
companyName: client.companyName ?? undefined,
|
||||
nationality: client.nationality ?? undefined,
|
||||
isProxy: client.isProxy ?? false,
|
||||
proxyType: client.proxyType ?? undefined,
|
||||
actualOwnerName: client.actualOwnerName ?? undefined,
|
||||
yachtName: client.yachtName ?? undefined,
|
||||
berthSizeDesired: client.berthSizeDesired ?? undefined,
|
||||
preferredContactMethod: (client.preferredContactMethod as any) ?? undefined,
|
||||
preferredLanguage: client.preferredLanguage ?? undefined,
|
||||
timezone: client.timezone ?? undefined,
|
||||
source: (client.source as any) ?? undefined,
|
||||
sourceDetails: client.sourceDetails ?? undefined,
|
||||
contacts:
|
||||
client.contacts && client.contacts.length > 0
|
||||
? client.contacts.map((c) => ({
|
||||
channel: c.channel as any,
|
||||
value: c.value,
|
||||
label: c.label ?? undefined,
|
||||
isPrimary: c.isPrimary ?? false,
|
||||
}))
|
||||
: [{ channel: 'email', value: '', isPrimary: true }],
|
||||
tagIds: client.tags?.map((t) => t.id) ?? [],
|
||||
});
|
||||
} else if (!client && open) {
|
||||
reset({
|
||||
fullName: '',
|
||||
contacts: [{ channel: 'email', value: '', isPrimary: true }],
|
||||
isProxy: false,
|
||||
tagIds: [],
|
||||
});
|
||||
}
|
||||
}, [client, open, reset]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (data: CreateClientInput) => {
|
||||
if (isEdit) {
|
||||
const { contacts, tagIds: tIds, ...rest } = data;
|
||||
await apiFetch(`/api/v1/clients/${client!.id}`, { method: 'PATCH', body: rest });
|
||||
if (tIds) {
|
||||
await apiFetch(`/api/v1/clients/${client!.id}/tags`, {
|
||||
method: 'PUT',
|
||||
body: { tagIds: tIds },
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await apiFetch('/api/v1/clients', { method: 'POST', body: data });
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['clients'] });
|
||||
onOpenChange(false);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="w-full sm:max-w-2xl overflow-y-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{isEdit ? 'Edit Client' : 'New Client'}</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit((data) => mutation.mutate(data))}
|
||||
className="space-y-6 py-6"
|
||||
>
|
||||
{/* Basic Info */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Basic Information
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="col-span-2 space-y-1">
|
||||
<Label>Full Name *</Label>
|
||||
<Input {...register('fullName')} placeholder="John Smith" />
|
||||
{errors.fullName && (
|
||||
<p className="text-xs text-destructive">{errors.fullName.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>Company Name</Label>
|
||||
<Input {...register('companyName')} placeholder="Acme Corp" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>Nationality</Label>
|
||||
<Input {...register('nationality')} placeholder="British" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Contacts */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Contacts
|
||||
</h3>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
append({ channel: 'email', value: '', isPrimary: false })
|
||||
}
|
||||
>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
Add Contact
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{errors.contacts?.root && (
|
||||
<p className="text-xs text-destructive">{errors.contacts.root.message}</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{fields.map((field, index) => (
|
||||
<div
|
||||
key={field.id}
|
||||
className="grid grid-cols-12 gap-2 items-end p-3 rounded-lg border bg-muted/30"
|
||||
>
|
||||
<div className="col-span-3 space-y-1">
|
||||
<Label className="text-xs">Channel</Label>
|
||||
<Select
|
||||
value={watch(`contacts.${index}.channel`)}
|
||||
onValueChange={(v) =>
|
||||
setValue(`contacts.${index}.channel`, v as any)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="email">Email</SelectItem>
|
||||
<SelectItem value="phone">Phone</SelectItem>
|
||||
<SelectItem value="whatsapp">WhatsApp</SelectItem>
|
||||
<SelectItem value="other">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="col-span-5 space-y-1">
|
||||
<Label className="text-xs">Value</Label>
|
||||
<Input
|
||||
{...register(`contacts.${index}.value`)}
|
||||
className="h-8"
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2 space-y-1">
|
||||
<Label className="text-xs">Label</Label>
|
||||
<Input
|
||||
{...register(`contacts.${index}.label`)}
|
||||
className="h-8"
|
||||
placeholder="work"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-1 flex items-center gap-1 pb-1">
|
||||
<Checkbox
|
||||
checked={watch(`contacts.${index}.isPrimary`)}
|
||||
onCheckedChange={(v) =>
|
||||
setValue(`contacts.${index}.isPrimary`, !!v)
|
||||
}
|
||||
/>
|
||||
<Label className="text-xs">Primary</Label>
|
||||
</div>
|
||||
|
||||
<div className="col-span-1 flex justify-end pb-1">
|
||||
{fields.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Proxy */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Proxy Information
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="isProxy"
|
||||
checked={watch('isProxy')}
|
||||
onCheckedChange={(v) => setValue('isProxy', !!v)}
|
||||
/>
|
||||
<Label htmlFor="isProxy">This is a proxy client</Label>
|
||||
</div>
|
||||
|
||||
{isProxy && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label>Proxy Type</Label>
|
||||
<Select
|
||||
value={watch('proxyType') ?? ''}
|
||||
onValueChange={(v) => setValue('proxyType', v)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="broker">Broker</SelectItem>
|
||||
<SelectItem value="representative">Representative</SelectItem>
|
||||
<SelectItem value="family_member">Family Member</SelectItem>
|
||||
<SelectItem value="legal_counsel">Legal Counsel</SelectItem>
|
||||
<SelectItem value="other">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Actual Owner Name</Label>
|
||||
<Input
|
||||
{...register('actualOwnerName')}
|
||||
placeholder="Actual owner"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Yacht Details */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Yacht Details
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="col-span-2 space-y-1">
|
||||
<Label>Yacht Name</Label>
|
||||
<Input {...register('yachtName')} placeholder="My Yacht" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Berth Size Desired</Label>
|
||||
<Input {...register('berthSizeDesired')} placeholder="e.g. 30m" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Source & Preferences */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Source & Preferences
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label>Source</Label>
|
||||
<Select
|
||||
value={watch('source') ?? ''}
|
||||
onValueChange={(v) => setValue('source', v as any)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select source" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="website">Website</SelectItem>
|
||||
<SelectItem value="manual">Manual</SelectItem>
|
||||
<SelectItem value="referral">Referral</SelectItem>
|
||||
<SelectItem value="broker">Broker</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Preferred Contact Method</Label>
|
||||
<Select
|
||||
value={watch('preferredContactMethod') ?? ''}
|
||||
onValueChange={(v) => setValue('preferredContactMethod', v as any)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select method" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="email">Email</SelectItem>
|
||||
<SelectItem value="phone">Phone</SelectItem>
|
||||
<SelectItem value="whatsapp">WhatsApp</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Preferred Language</Label>
|
||||
<Input {...register('preferredLanguage')} placeholder="English" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Timezone</Label>
|
||||
<Input {...register('timezone')} placeholder="UTC+0" />
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1">
|
||||
<Label>Source Details</Label>
|
||||
<Input
|
||||
{...register('sourceDetails')}
|
||||
placeholder="Referred by John Doe"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Tags */}
|
||||
<div className="space-y-2">
|
||||
<Label>Tags</Label>
|
||||
<TagPicker
|
||||
selectedIds={tagIds}
|
||||
onChange={(ids) => setValue('tagIds', ids)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SheetFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting || mutation.isPending}>
|
||||
{(isSubmitting || mutation.isPending) && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
{isEdit ? 'Save Changes' : 'Create Client'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user