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:
262
src/components/companies/company-form.tsx
Normal file
262
src/components/companies/company-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user