feat(ui): add company-form for create/edit with 409 handling

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-04-24 13:53:35 +02:00
parent ba86b7a897
commit d6743ed52c

View File

@@ -0,0 +1,262 @@
'use client';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2 } from 'lucide-react';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Separator } from '@/components/ui/separator';
import { TagPicker } from '@/components/shared/tag-picker';
import { apiFetch } from '@/lib/api/client';
import { createCompanySchema, type CreateCompanyInput } from '@/lib/validators/companies';
type CompanyStatus = 'active' | 'dissolved';
type CompanyFormValues = z.input<typeof createCompanySchema>;
interface CompanyFormProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** If provided, form is in edit mode */
company?: {
id: string;
name: string;
legalName: string | null;
taxId: string | null;
registrationNumber: string | null;
incorporationCountry: string | null;
incorporationDate: string | null;
status: string;
billingEmail: string | null;
notes: string | null;
};
}
export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) {
const queryClient = useQueryClient();
const isEdit = !!company;
const [formError, setFormError] = useState<string | null>(null);
const {
register,
handleSubmit,
watch,
setValue,
reset,
formState: { errors, isSubmitting },
} = useForm<CompanyFormValues>({
resolver: zodResolver(createCompanySchema),
defaultValues: {
name: '',
status: 'active',
tagIds: [],
},
});
const tagIds = watch('tagIds') ?? [];
const status = watch('status') ?? 'active';
// Populate form when editing, or reset to defaults in create mode.
useEffect(() => {
if (company && open) {
reset({
name: company.name,
legalName: company.legalName ?? undefined,
taxId: company.taxId ?? undefined,
registrationNumber: company.registrationNumber ?? undefined,
incorporationCountry: company.incorporationCountry ?? undefined,
incorporationDate: company.incorporationDate
? new Date(company.incorporationDate)
: undefined,
status: (company.status as CompanyStatus) ?? 'active',
billingEmail: company.billingEmail ?? undefined,
notes: company.notes ?? undefined,
tagIds: [],
});
} else if (!company && open) {
reset({ name: '', status: 'active', tagIds: [] });
}
setFormError(null);
}, [company, open, reset]);
const mutation = useMutation({
mutationFn: async (data: CreateCompanyInput) => {
if (isEdit) {
// updateCompanySchema omits tagIds — strip them from PATCH body.
const { tagIds: _tIds, ...rest } = data;
void _tIds;
await apiFetch(`/api/v1/companies/${company!.id}`, {
method: 'PATCH',
body: rest,
});
} else {
await apiFetch('/api/v1/companies', { method: 'POST', body: data });
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['companies'] });
onOpenChange(false);
},
onError: (err: unknown) => {
const msg = err instanceof Error ? err.message : 'Failed to save company';
setFormError(msg);
},
});
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full sm:max-w-2xl overflow-y-auto">
<SheetHeader>
<SheetTitle>{isEdit ? 'Edit Company' : 'New Company'}</SheetTitle>
</SheetHeader>
<form
onSubmit={handleSubmit((data) => {
setFormError(null);
mutation.mutate(data as CreateCompanyInput);
})}
className="space-y-6 py-6"
>
{/* Basics */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Basics
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2 space-y-1">
<Label>Name *</Label>
<Input {...register('name')} placeholder="Acme Holdings Ltd" />
{errors.name && <p className="text-xs text-destructive">{errors.name.message}</p>}
</div>
<div className="col-span-2 space-y-1">
<Label>Legal Name</Label>
<Input {...register('legalName')} placeholder="Acme Holdings Limited" />
</div>
</div>
</div>
<Separator />
{/* Registration */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Registration
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<Label>Tax ID</Label>
<Input {...register('taxId')} placeholder="VAT / EIN" />
</div>
<div className="space-y-1">
<Label>Registration Number</Label>
<Input {...register('registrationNumber')} placeholder="Company #" />
</div>
<div className="space-y-1">
<Label>Incorporation Country</Label>
<Input {...register('incorporationCountry')} placeholder="e.g. MT" />
</div>
<div className="space-y-1">
<Label>Incorporation Date</Label>
<Input type="date" {...register('incorporationDate')} />
{errors.incorporationDate && (
<p className="text-xs text-destructive">{errors.incorporationDate.message}</p>
)}
</div>
</div>
</div>
<Separator />
{/* Contact & Status */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Contact & Status
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<Label>Billing Email</Label>
<Input
type="email"
{...register('billingEmail')}
placeholder="billing@example.com"
/>
{errors.billingEmail && (
<p className="text-xs text-destructive">{errors.billingEmail.message}</p>
)}
</div>
<div className="space-y-1">
<Label>Status</Label>
<Select
value={status}
onValueChange={(v) => setValue('status', v as CompanyStatus)}
>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="dissolved">Dissolved</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<Separator />
{/* Notes */}
<div className="space-y-2">
<Label>Notes</Label>
<Textarea
{...register('notes')}
placeholder="Internal notes about this company"
rows={4}
/>
</div>
<Separator />
{/* Tags */}
<div className="space-y-2">
<Label>Tags</Label>
<TagPicker selectedIds={tagIds} onChange={(ids) => setValue('tagIds', ids)} />
</div>
{formError && (
<p className="text-sm text-destructive" role="alert">
{formError}
</p>
)}
<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 Company'}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}