Files
thrilltrack-explorer/src/components/admin/ManufacturerForm.tsx
gpt-engineer-app[bot] d0c613031e Migrate date precision to exact
Batch update all date precision handling to use expanded DatePrecision, replace hardcoded day defaults, and adjust related validation, UI, and helpers. Includes wrapper migration across Phase 1-3 functions, updates to logs, displays, and formatting utilities to align frontend with new precision values ('exact', 'month', 'year', 'decade', 'century', 'approximate').
2025-11-11 22:05:29 +00:00

311 lines
12 KiB
TypeScript

import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { entitySchemas } from '@/lib/entityValidationSchemas';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { SlugField } from '@/components/ui/slug-field';
import { Building2, Save, X } from 'lucide-react';
import { useUserRole } from '@/hooks/useUserRole';
import { HeadquartersLocationInput } from './HeadquartersLocationInput';
import { EntityMultiImageUploader } from '@/components/upload/EntityMultiImageUploader';
import { FlexibleDateInput, type DatePrecision } from '@/components/ui/flexible-date-input';
import { useAuth } from '@/hooks/useAuth';
import { toast } from 'sonner';
import { handleError } from '@/lib/errorHandler';
import { toDateOnly, parseDateOnly, toDateWithPrecision } from '@/lib/dateUtils';
import type { UploadedImage } from '@/types/company';
// Zod output type (after transformation)
type ManufacturerFormData = z.infer<typeof entitySchemas.manufacturer>;
interface ManufacturerFormProps {
onSubmit: (data: ManufacturerFormData) => void;
onCancel: () => void;
initialData?: Partial<ManufacturerFormData & {
id?: string;
banner_image_url?: string;
card_image_url?: string;
}>;
}
export function ManufacturerForm({ onSubmit, onCancel, initialData }: ManufacturerFormProps): React.JSX.Element {
const { isModerator } = useUserRole();
const { user } = useAuth();
const [isSubmitting, setIsSubmitting] = useState(false);
const {
register,
handleSubmit,
setValue,
watch,
formState: { errors }
} = useForm({
resolver: zodResolver(entitySchemas.manufacturer),
defaultValues: {
name: initialData?.name || '',
slug: initialData?.slug || '',
company_type: 'manufacturer' as const,
description: initialData?.description || '',
person_type: initialData?.person_type || ('company' as const),
website_url: initialData?.website_url || '',
founded_year: initialData?.founded_year ? String(initialData.founded_year) : '',
founded_date: initialData?.founded_date || (initialData?.founded_year ? `${initialData.founded_year}-01-01` : undefined),
founded_date_precision: initialData?.founded_date_precision || (initialData?.founded_year ? ('year' as const) : ('exact' as const)),
headquarters_location: initialData?.headquarters_location || '',
source_url: initialData?.source_url || '',
submission_notes: initialData?.submission_notes || '',
images: initialData?.images || { uploaded: [] }
}
});
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 className="w-5 h-5" />
{initialData ? 'Edit Manufacturer' : 'Create New Manufacturer'}
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(async (data) => {
if (!user) {
toast.error('You must be logged in to submit');
return;
}
setIsSubmitting(true);
try {
const formData = {
...data,
company_type: 'manufacturer' as const,
founded_year: data.founded_year ? parseInt(String(data.founded_year)) : undefined,
banner_image_id: undefined,
banner_image_url: undefined,
card_image_id: undefined,
card_image_url: undefined,
};
await onSubmit(formData);
// Only show success toast and close if not editing through moderation queue
if (!initialData?.id) {
toast.success('Manufacturer submitted for review');
onCancel();
}
} catch (error: unknown) {
handleError(error, {
action: initialData?.id ? 'Update Manufacturer' : 'Create Manufacturer',
metadata: { companyName: data.name }
});
// Re-throw so parent can handle modal closing
throw error;
} finally {
setIsSubmitting(false);
}
})} className="space-y-6">
{/* Basic Information */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="name">Name *</Label>
<Input
id="name"
{...register('name')}
placeholder="Enter manufacturer name"
/>
{errors.name && (
<p className="text-sm text-destructive">{errors.name.message}</p>
)}
</div>
<SlugField
name={watch('name')}
slug={watch('slug')}
onSlugChange={(slug) => setValue('slug', slug)}
isModerator={isModerator()}
/>
</div>
{/* Description */}
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
{...register('description')}
placeholder="Describe the manufacturer..."
rows={3}
/>
</div>
{/* Person Type */}
<div className="space-y-2">
<Label>Entity Type *</Label>
<RadioGroup
value={watch('person_type')}
onValueChange={(value) => setValue('person_type', value as 'company' | 'individual' | 'firm' | 'organization')}
className="grid grid-cols-2 md:grid-cols-4 gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="company" id="company" />
<Label htmlFor="company" className="cursor-pointer">Company</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="individual" id="individual" />
<Label htmlFor="individual" className="cursor-pointer">Individual</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="firm" id="firm" />
<Label htmlFor="firm" className="cursor-pointer">Firm</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="organization" id="organization" />
<Label htmlFor="organization" className="cursor-pointer">Organization</Label>
</div>
</RadioGroup>
</div>
{/* Additional Details */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FlexibleDateInput
value={(() => {
const dateValue = watch('founded_date');
if (!dateValue) return undefined;
return parseDateOnly(dateValue);
})()}
precision={(watch('founded_date_precision') as DatePrecision) || 'year'}
onChange={(date, precision) => {
setValue('founded_date', date ? toDateWithPrecision(date, precision) : undefined, { shouldValidate: true });
setValue('founded_date_precision', precision);
}}
label="Founded Date"
placeholder="Select founded date"
disableFuture={true}
fromYear={1800}
/>
<div className="space-y-2">
<Label htmlFor="headquarters_location">Headquarters Location</Label>
<HeadquartersLocationInput
value={watch('headquarters_location') || ''}
onChange={(value) => setValue('headquarters_location', value)}
/>
<p className="text-xs text-muted-foreground">
Search OpenStreetMap for accurate location data, or manually enter location name.
</p>
</div>
</div>
{/* Website */}
<div className="space-y-2">
<Label htmlFor="website_url">Website URL</Label>
<Input
id="website_url"
type="url"
{...register('website_url')}
placeholder="https://example.com"
/>
{errors.website_url && (
<p className="text-sm text-destructive">{errors.website_url.message}</p>
)}
</div>
{/* Submission Context - For Reviewers */}
<div className="space-y-4 border-t pt-6">
<div className="flex items-center gap-2 mb-4">
<Badge variant="secondary" className="text-xs">
For Moderator Review
</Badge>
<p className="text-xs text-muted-foreground">
Help reviewers verify your submission
</p>
</div>
<div className="space-y-2">
<Label htmlFor="source_url" className="flex items-center gap-2">
Source URL
<span className="text-xs text-muted-foreground font-normal">
(Optional)
</span>
</Label>
<Input
id="source_url"
type="url"
{...register('source_url')}
placeholder="https://example.com/article"
/>
<p className="text-xs text-muted-foreground">
Where did you find this information? (e.g., official website, news article, press release)
</p>
{errors.source_url && (
<p className="text-sm text-destructive">{errors.source_url.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="submission_notes" className="flex items-center gap-2">
Notes for Reviewers
<span className="text-xs text-muted-foreground font-normal">
(Optional)
</span>
</Label>
<Textarea
id="submission_notes"
{...register('submission_notes')}
placeholder="Add any context to help moderators verify this information (e.g., 'Confirmed via company website', 'Founded date approximate')"
rows={3}
maxLength={1000}
/>
<p className="text-xs text-muted-foreground">
{watch('submission_notes')?.length || 0}/1000 characters
</p>
{errors.submission_notes && (
<p className="text-sm text-destructive">{errors.submission_notes.message}</p>
)}
</div>
</div>
{/* Images */}
<EntityMultiImageUploader
mode={initialData ? 'edit' : 'create'}
value={watch('images') || { uploaded: [] }}
onChange={(images) => setValue('images', images)}
entityType="manufacturer"
entityId={initialData?.id}
currentBannerUrl={initialData?.banner_image_url}
currentCardUrl={initialData?.card_image_url}
/>
{/* Actions */}
<div className="flex gap-3 justify-end">
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isSubmitting}
>
<X className="w-4 h-4 mr-2" />
Cancel
</Button>
<Button
type="submit"
loading={isSubmitting}
loadingText="Saving..."
>
<Save className="w-4 h-4 mr-2" />
{initialData?.id ? 'Update Manufacturer' : 'Create Manufacturer'}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}