diff --git a/src/components/companies/company-form.tsx b/src/components/companies/company-form.tsx new file mode 100644 index 0000000..6bd3104 --- /dev/null +++ b/src/components/companies/company-form.tsx @@ -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; + +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(null); + + const { + register, + handleSubmit, + watch, + setValue, + reset, + formState: { errors, isSubmitting }, + } = useForm({ + 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 ( + + + + {isEdit ? 'Edit Company' : 'New Company'} + + +
{ + setFormError(null); + mutation.mutate(data as CreateCompanyInput); + })} + className="space-y-6 py-6" + > + {/* Basics */} +
+

+ Basics +

+ +
+
+ + + {errors.name &&

{errors.name.message}

} +
+ +
+ + +
+
+
+ + + + {/* Registration */} +
+

+ Registration +

+
+
+ + +
+
+ + +
+
+ + +
+
+ + + {errors.incorporationDate && ( +

{errors.incorporationDate.message}

+ )} +
+
+
+ + + + {/* Contact & Status */} +
+

+ Contact & Status +

+
+
+ + + {errors.billingEmail && ( +

{errors.billingEmail.message}

+ )} +
+
+ + +
+
+
+ + + + {/* Notes */} +
+ +