Refactor code structure and remove redundant changes

This commit is contained in:
pacnpal
2025-11-09 16:31:34 -05:00
parent 2884bc23ce
commit eb68cf40c6
1080 changed files with 27361 additions and 56687 deletions

View File

@@ -0,0 +1,126 @@
import { ReactNode, useCallback } from 'react';
import { AdminLayout } from '@/components/layout/AdminLayout';
import { MFAGuard } from '@/components/auth/MFAGuard';
import { QueueSkeleton } from '@/components/moderation/QueueSkeleton';
import { useAdminGuard } from '@/hooks/useAdminGuard';
import { useAdminSettings } from '@/hooks/useAdminSettings';
import { useModerationStats } from '@/hooks/useModerationStats';
interface AdminPageLayoutProps {
/** Page title */
title: string;
/** Page description */
description: string;
/** Main content to render when authorized */
children: ReactNode;
/** Optional refresh handler */
onRefresh?: () => void;
/** Whether to require MFA (default: true) */
requireMFA?: boolean;
/** Number of skeleton items to show while loading */
skeletonCount?: number;
/** Whether to show refresh controls */
showRefreshControls?: boolean;
}
/**
* Reusable admin page layout with auth guards and common UI
*
* Handles:
* - Authentication & authorization checks
* - MFA enforcement
* - Loading states
* - Refresh controls and stats
* - Consistent header layout
*
* @example
* ```tsx
* <AdminPageLayout
* title="User Management"
* description="Manage user profiles and roles"
* onRefresh={handleRefresh}
* >
* <UserManagement />
* </AdminPageLayout>
* ```
*/
export function AdminPageLayout({
title,
description,
children,
onRefresh,
requireMFA = true,
skeletonCount = 5,
showRefreshControls = true,
}: AdminPageLayoutProps) {
const { isLoading, isAuthorized, needsMFA } = useAdminGuard(requireMFA);
const {
getAdminPanelRefreshMode,
getAdminPanelPollInterval,
} = useAdminSettings();
const refreshMode = getAdminPanelRefreshMode() as 'auto' | 'manual';
const pollInterval = getAdminPanelPollInterval() as number;
const { lastUpdated } = useModerationStats({
enabled: isAuthorized && showRefreshControls,
pollingEnabled: refreshMode === 'auto',
pollingInterval: pollInterval,
});
const handleRefreshClick = useCallback(() => {
onRefresh?.();
}, [onRefresh]);
// Loading state
if (isLoading) {
return (
<AdminLayout
onRefresh={showRefreshControls ? handleRefreshClick : undefined}
refreshMode={showRefreshControls ? (refreshMode as 'auto' | 'manual') : undefined}
pollInterval={showRefreshControls ? pollInterval : undefined}
lastUpdated={showRefreshControls ? (lastUpdated as Date) : undefined}
>
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">{title}</h1>
<p className="text-muted-foreground mt-1">{description}</p>
</div>
<QueueSkeleton count={skeletonCount} />
</div>
</AdminLayout>
);
}
// Not authorized
if (!isAuthorized) {
return null;
}
// Main content
return (
<AdminLayout
onRefresh={showRefreshControls ? handleRefreshClick : undefined}
refreshMode={showRefreshControls ? (refreshMode as 'auto' | 'manual') : undefined}
pollInterval={showRefreshControls ? pollInterval : undefined}
lastUpdated={showRefreshControls ? (lastUpdated as Date) : undefined}
>
<MFAGuard>
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">{title}</h1>
<p className="text-muted-foreground mt-1">{description}</p>
</div>
{children}
</div>
</MFAGuard>
</AdminLayout>
);
}

View File

@@ -0,0 +1,372 @@
import { useState } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { AlertTriangle, Trash2, Shield, CheckCircle2 } from 'lucide-react';
import { supabase } from '@/lib/supabaseClient';
import { useAuth } from '@/hooks/useAuth';
import { MFAChallenge } from '@/components/auth/MFAChallenge';
import { toast } from '@/hooks/use-toast';
import type { UserRole } from '@/hooks/useUserRole';
import { handleError } from '@/lib/errorHandler';
interface AdminUserDeletionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
targetUser: {
userId: string;
username: string;
email: string;
displayName?: string;
roles: UserRole[];
};
onDeletionComplete: () => void;
}
type DeletionStep = 'warning' | 'aal2_verification' | 'final_confirm' | 'deleting' | 'complete';
export function AdminUserDeletionDialog({
open,
onOpenChange,
targetUser,
onDeletionComplete
}: AdminUserDeletionDialogProps) {
const { session } = useAuth();
const [step, setStep] = useState<DeletionStep>('warning');
const [confirmationText, setConfirmationText] = useState('');
const [acknowledged, setAcknowledged] = useState(false);
const [error, setError] = useState<string | null>(null);
const [factorId, setFactorId] = useState<string | null>(null);
// Reset state when dialog opens/closes
const handleOpenChange = (isOpen: boolean) => {
if (!isOpen) {
setStep('warning');
setConfirmationText('');
setAcknowledged(false);
setError(null);
setFactorId(null);
}
onOpenChange(isOpen);
};
// Step 1: Show warning and proceed
const handleContinueFromWarning = async () => {
setError(null);
// Check if user needs AAL2 verification
const { data: factorsData } = await supabase.auth.mfa.listFactors();
const hasMFAEnrolled = factorsData?.totp?.some(f => f.status === 'verified') || false;
if (hasMFAEnrolled) {
// Check current AAL from JWT
if (session) {
const jwt = session.access_token;
const payload = JSON.parse(atob(jwt.split('.')[1]));
const currentAal = payload.aal || 'aal1';
if (currentAal !== 'aal2') {
// Need to verify MFA
const verifiedFactor = factorsData?.totp?.find(f => f.status === 'verified');
if (verifiedFactor) {
setFactorId(verifiedFactor.id);
setStep('aal2_verification');
return;
}
}
}
}
// If no MFA or already at AAL2, go directly to final confirmation
setStep('final_confirm');
};
// Step 2: Handle successful AAL2 verification
const handleAAL2Success = () => {
setStep('final_confirm');
};
// Step 3: Perform deletion
const handleDelete = async () => {
setError(null);
setStep('deleting');
try {
const { data, error: functionError } = await supabase.functions.invoke('admin-delete-user', {
body: { targetUserId: targetUser.userId }
});
if (functionError) {
throw functionError;
}
if (!data.success) {
if (data.errorCode === 'aal2_required') {
// Session degraded during deletion, restart AAL2 flow
setError('Your session requires re-verification. Please verify again.');
const { data: factorsData } = await supabase.auth.mfa.listFactors();
const verifiedFactor = factorsData?.totp?.find(f => f.status === 'verified');
if (verifiedFactor) {
setFactorId(verifiedFactor.id);
setStep('aal2_verification');
} else {
setStep('warning');
}
return;
}
throw new Error(data.error || 'Failed to delete user');
}
// Success
setStep('complete');
setTimeout(() => {
toast({
title: 'User Deleted',
description: `${targetUser.username} has been permanently deleted.`,
});
onDeletionComplete();
handleOpenChange(false);
}, 2000);
} catch (err) {
handleError(err, {
action: 'Delete User',
metadata: { targetUserId: targetUser.userId }
});
setError(err instanceof Error ? err.message : 'Failed to delete user');
setStep('final_confirm');
}
};
const isDeleteEnabled = confirmationText === 'DELETE' && acknowledged;
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
className="sm:max-w-lg"
onInteractOutside={(e) => step === 'deleting' && e.preventDefault()}
>
{/* Step 1: Warning */}
{step === 'warning' && (
<>
<DialogHeader>
<div className="flex items-center gap-2 justify-center mb-2">
<AlertTriangle className="h-6 w-6 text-destructive" />
<DialogTitle className="text-destructive">Delete User Account</DialogTitle>
</div>
<DialogDescription className="text-center">
You are about to permanently delete this user's account
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* User details */}
<div className="p-4 border rounded-lg bg-muted/50">
<div className="space-y-2">
<div>
<span className="text-sm font-medium">Username:</span>
<span className="ml-2 text-sm">{targetUser.username}</span>
</div>
<div>
<span className="text-sm font-medium">Email:</span>
<span className="ml-2 text-sm">{targetUser.email}</span>
</div>
{targetUser.displayName && (
<div>
<span className="text-sm font-medium">Display Name:</span>
<span className="ml-2 text-sm">{targetUser.displayName}</span>
</div>
)}
<div>
<span className="text-sm font-medium">Roles:</span>
<span className="ml-2 text-sm">{targetUser.roles.join(', ') || 'None'}</span>
</div>
</div>
</div>
{/* Critical warning */}
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription className="font-semibold">
This action is IMMEDIATE and PERMANENT. It cannot be undone.
</AlertDescription>
</Alert>
{/* What will be deleted */}
<div>
<h4 className="font-semibold text-sm mb-2 text-destructive">Will be deleted:</h4>
<ul className="text-sm space-y-1 list-disc list-inside text-muted-foreground">
<li>User profile and personal information</li>
<li>All reviews and ratings</li>
<li>Account preferences and settings</li>
<li>Authentication credentials</li>
</ul>
</div>
{/* What will be preserved */}
<div>
<h4 className="font-semibold text-sm mb-2">Will be preserved (as anonymous):</h4>
<ul className="text-sm space-y-1 list-disc list-inside text-muted-foreground">
<li>Content submissions (parks, rides, etc.)</li>
<li>Uploaded photos</li>
</ul>
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="flex gap-2 justify-end pt-2">
<Button variant="outline" onClick={() => handleOpenChange(false)}>
Cancel
</Button>
<Button variant="destructive" onClick={handleContinueFromWarning}>
Continue
</Button>
</div>
</div>
</>
)}
{/* Step 2: AAL2 Verification */}
{step === 'aal2_verification' && factorId && (
<>
<DialogHeader>
<div className="flex items-center gap-2 justify-center mb-2">
<Shield className="h-6 w-6 text-primary" />
<DialogTitle>Multi-Factor Authentication Verification Required</DialogTitle>
</div>
<DialogDescription className="text-center">
This is a critical action that requires additional verification
</DialogDescription>
</DialogHeader>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<MFAChallenge
factorId={factorId}
onSuccess={handleAAL2Success}
onCancel={() => {
setStep('warning');
setError(null);
}}
/>
</>
)}
{/* Step 3: Final Confirmation */}
{step === 'final_confirm' && (
<>
<DialogHeader>
<div className="flex items-center gap-2 justify-center mb-2">
<Trash2 className="h-6 w-6 text-destructive" />
<DialogTitle className="text-destructive">Final Confirmation</DialogTitle>
</div>
<DialogDescription className="text-center">
Type DELETE to confirm permanent deletion
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
<div className="font-semibold mb-1">Last chance to cancel!</div>
<div className="text-sm">
Deleting {targetUser.username} will immediately and permanently remove their account.
</div>
</AlertDescription>
</Alert>
<div className="space-y-2">
<label className="text-sm font-medium">
Type <span className="font-mono font-bold text-destructive">DELETE</span> to confirm:
</label>
<Input
value={confirmationText}
onChange={(e) => setConfirmationText(e.target.value)}
placeholder="Type DELETE"
className="font-mono"
autoComplete="off"
/>
</div>
<div className="flex items-start space-x-2">
<Checkbox
id="acknowledge"
checked={acknowledged}
onCheckedChange={(checked) => setAcknowledged(checked as boolean)}
/>
<label
htmlFor="acknowledge"
className="text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
I understand this action cannot be undone and will permanently delete this user's account
</label>
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="flex gap-2 justify-end pt-2">
<Button variant="outline" onClick={() => handleOpenChange(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={!isDeleteEnabled}
>
<Trash2 className="w-4 h-4 mr-2" />
Delete User Permanently
</Button>
</div>
</div>
</>
)}
{/* Step 4: Deleting */}
{step === 'deleting' && (
<div className="py-8 text-center space-y-4">
<div className="flex justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
</div>
<div>
<h3 className="font-semibold mb-1">Deleting User...</h3>
<p className="text-sm text-muted-foreground">
This may take a moment. Please do not close this dialog.
</p>
</div>
</div>
)}
{/* Step 5: Complete */}
{step === 'complete' && (
<div className="py-8 text-center space-y-4">
<div className="flex justify-center">
<CheckCircle2 className="h-12 w-12 text-green-500" />
</div>
<div>
<h3 className="font-semibold mb-1">User Deleted Successfully</h3>
<p className="text-sm text-muted-foreground">
{targetUser.username} has been permanently removed.
</p>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,202 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card, CardContent } from '@/components/ui/card';
import { format } from 'date-fns';
import { XCircle, Clock, User, FileText, AlertTriangle } from 'lucide-react';
import { Link } from 'react-router-dom';
interface ApprovalFailure {
id: string;
submission_id: string;
moderator_id: string;
submitter_id: string;
items_count: number;
duration_ms: number | null;
error_message: string | null;
request_id: string | null;
rollback_triggered: boolean | null;
created_at: string;
success: boolean;
moderator?: {
username: string;
avatar_url: string | null;
};
submission?: {
submission_type: string;
user_id: string;
};
}
interface ApprovalFailureModalProps {
failure: ApprovalFailure | null;
onClose: () => void;
}
export function ApprovalFailureModal({ failure, onClose }: ApprovalFailureModalProps) {
if (!failure) return null;
return (
<Dialog open={!!failure} onOpenChange={onClose}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<XCircle className="w-5 h-5 text-destructive" />
Approval Failure Details
</DialogTitle>
</DialogHeader>
<Tabs defaultValue="overview" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="error">Error Details</TabsTrigger>
<TabsTrigger value="metadata">Metadata</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-4">
<Card>
<CardContent className="pt-6 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-sm text-muted-foreground mb-1">Timestamp</div>
<div className="font-medium">
{format(new Date(failure.created_at), 'PPpp')}
</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-1">Duration</div>
<div className="font-medium flex items-center gap-2">
<Clock className="w-4 h-4" />
{failure.duration_ms != null ? `${failure.duration_ms}ms` : 'N/A'}
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-sm text-muted-foreground mb-1">Submission Type</div>
<Badge variant="outline">
{failure.submission?.submission_type || 'Unknown'}
</Badge>
</div>
<div>
<div className="text-sm text-muted-foreground mb-1">Items Count</div>
<div className="font-medium">{failure.items_count}</div>
</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-1">Moderator</div>
<div className="font-medium flex items-center gap-2">
<User className="w-4 h-4" />
{failure.moderator?.username || 'Unknown'}
</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-1">Submission ID</div>
<Link
to={`/admin/moderation?submission=${failure.submission_id}`}
className="font-mono text-sm text-primary hover:underline flex items-center gap-2"
>
<FileText className="w-4 h-4" />
{failure.submission_id}
</Link>
</div>
{failure.rollback_triggered && (
<div className="flex items-center gap-2 p-3 bg-warning/10 text-warning rounded-md">
<AlertTriangle className="w-4 h-4" />
<span className="text-sm font-medium">
Rollback was triggered for this approval
</span>
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="error" className="space-y-4">
<Card>
<CardContent className="pt-6">
<div className="space-y-4">
<div>
<div className="text-sm text-muted-foreground mb-2">Error Message</div>
<div className="p-4 bg-destructive/10 text-destructive rounded-md font-mono text-sm">
{failure.error_message || 'No error message available'}
</div>
</div>
{failure.request_id && (
<div>
<div className="text-sm text-muted-foreground mb-2">Request ID</div>
<div className="p-3 bg-muted rounded-md font-mono text-sm">
{failure.request_id}
</div>
</div>
)}
<div className="mt-4 p-4 bg-muted rounded-md">
<div className="text-sm font-medium mb-2">Troubleshooting Tips</div>
<ul className="text-sm text-muted-foreground space-y-1 list-disc list-inside">
<li>Check if the submission still exists in the database</li>
<li>Verify that all foreign key references are valid</li>
<li>Review the edge function logs for detailed stack traces</li>
<li>Check for concurrent modification conflicts</li>
<li>Verify network connectivity and database availability</li>
</ul>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="metadata" className="space-y-4">
<Card>
<CardContent className="pt-6">
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-sm text-muted-foreground mb-1">Failure ID</div>
<div className="font-mono text-sm">{failure.id}</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-1">Success Status</div>
<Badge variant="destructive">
{failure.success ? 'Success' : 'Failed'}
</Badge>
</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-1">Moderator ID</div>
<div className="font-mono text-sm">{failure.moderator_id}</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-1">Submitter ID</div>
<div className="font-mono text-sm">{failure.submitter_id}</div>
</div>
{failure.request_id && (
<div>
<div className="text-sm text-muted-foreground mb-1">Request ID</div>
<div className="font-mono text-sm break-all">{failure.request_id}</div>
</div>
)}
<div>
<div className="text-sm text-muted-foreground mb-1">Rollback Triggered</div>
<Badge variant={failure.rollback_triggered ? 'destructive' : 'secondary'}>
{failure.rollback_triggered ? 'Yes' : 'No'}
</Badge>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,312 @@
import { useState } from 'react';
import { Ban, UserCheck } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { Alert, AlertDescription } from '@/components/ui/alert';
const BAN_REASONS = [
{ value: 'spam', label: 'Spam or advertising' },
{ value: 'harassment', label: 'Harassment or bullying' },
{ value: 'inappropriate_content', label: 'Inappropriate content' },
{ value: 'violation_tos', label: 'Terms of Service violation' },
{ value: 'abuse', label: 'Abuse of platform features' },
{ value: 'fake_info', label: 'Posting false information' },
{ value: 'copyright', label: 'Copyright infringement' },
{ value: 'multiple_accounts', label: 'Multiple account abuse' },
{ value: 'other', label: 'Other (specify below)' }
] as const;
const BAN_DURATIONS = [
{ value: '1', label: '1 Day', days: 1 },
{ value: '7', label: '7 Days (1 Week)', days: 7 },
{ value: '30', label: '30 Days (1 Month)', days: 30 },
{ value: '90', label: '90 Days (3 Months)', days: 90 },
{ value: 'permanent', label: 'Permanent', days: null }
] as const;
const banFormSchema = z.object({
reason_type: z.enum([
'spam',
'harassment',
'inappropriate_content',
'violation_tos',
'abuse',
'fake_info',
'copyright',
'multiple_accounts',
'other'
]),
custom_reason: z.string().max(500).optional(),
duration: z.enum(['1', '7', '30', '90', 'permanent'])
}).refine(
(data) => data.reason_type !== 'other' || (data.custom_reason && data.custom_reason.trim().length > 0),
{
message: "Please provide a custom reason",
path: ["custom_reason"]
}
);
type BanFormValues = z.infer<typeof banFormSchema>;
interface BanUserDialogProps {
profile: {
user_id: string;
username: string;
banned: boolean;
};
onBanComplete: () => void;
onBanUser: (userId: string, ban: boolean, reason?: string, expiresAt?: Date | null) => Promise<void>;
disabled?: boolean;
}
export function BanUserDialog({ profile, onBanComplete, onBanUser, disabled }: BanUserDialogProps) {
const [open, setOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const form = useForm<BanFormValues>({
resolver: zodResolver(banFormSchema),
defaultValues: {
reason_type: 'violation_tos',
custom_reason: '',
duration: '7'
}
});
const watchReasonType = form.watch('reason_type');
const watchDuration = form.watch('duration');
const onSubmit = async (values: BanFormValues) => {
setIsSubmitting(true);
try {
// Construct the ban reason
let banReason: string;
if (values.reason_type === 'other' && values.custom_reason) {
banReason = values.custom_reason.trim();
} else {
const selectedReason = BAN_REASONS.find(r => r.value === values.reason_type);
banReason = selectedReason?.label || 'Policy violation';
}
// Calculate expiration date
let expiresAt: Date | null = null;
if (values.duration !== 'permanent') {
const durationConfig = BAN_DURATIONS.find(d => d.value === values.duration);
if (durationConfig?.days) {
expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + durationConfig.days);
}
}
await onBanUser(profile.user_id, true, banReason, expiresAt);
setOpen(false);
form.reset();
onBanComplete();
} catch (error) {
// Error handling is done by the parent component
} finally {
setIsSubmitting(false);
}
};
const handleUnban = async () => {
setIsSubmitting(true);
try {
await onBanUser(profile.user_id, false);
setOpen(false);
onBanComplete();
} catch (error) {
// Error handling is done by the parent component
} finally {
setIsSubmitting(false);
}
};
// For unbanning, use simpler dialog
if (profile.banned) {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" disabled={disabled}>
<UserCheck className="w-4 h-4 mr-2" />
Unban
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Unban User</DialogTitle>
<DialogDescription>
Are you sure you want to unban @{profile.username}? They will be able to access the application again.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)} disabled={isSubmitting}>
Cancel
</Button>
<Button onClick={handleUnban} disabled={isSubmitting}>
{isSubmitting ? 'Unbanning...' : 'Unban User'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// For banning, use detailed form
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="destructive" size="sm" disabled={disabled}>
<Ban className="w-4 h-4 mr-2" />
Ban
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Ban User</DialogTitle>
<DialogDescription>
Ban @{profile.username} from accessing the application. You must provide a reason and duration.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="reason_type"
render={({ field }) => (
<FormItem>
<FormLabel>Ban Reason</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a reason" />
</SelectTrigger>
</FormControl>
<SelectContent>
{BAN_REASONS.map((reason) => (
<SelectItem key={reason.value} value={reason.value}>
{reason.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
Choose the primary reason for this ban
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{watchReasonType === 'other' && (
<FormField
control={form.control}
name="custom_reason"
render={({ field }) => (
<FormItem>
<FormLabel>Custom Reason</FormLabel>
<FormControl>
<Textarea
placeholder="Provide a detailed reason for the ban..."
className="min-h-[100px] resize-none"
maxLength={500}
{...field}
/>
</FormControl>
<FormDescription>
{field.value?.length || 0}/500 characters
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="duration"
render={({ field }) => (
<FormItem>
<FormLabel>Ban Duration</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select duration" />
</SelectTrigger>
</FormControl>
<SelectContent>
{BAN_DURATIONS.map((duration) => (
<SelectItem key={duration.value} value={duration.value}>
{duration.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
How long should this ban last?
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Alert>
<AlertDescription>
<strong>User will see:</strong> Your account has been suspended. Reason:{' '}
{watchReasonType === 'other' && form.getValues('custom_reason')
? form.getValues('custom_reason')
: BAN_REASONS.find(r => r.value === watchReasonType)?.label || 'Policy violation'}
.{' '}
{watchDuration === 'permanent'
? 'This is a permanent ban.'
: `This ban will expire in ${BAN_DURATIONS.find(d => d.value === watchDuration)?.label.toLowerCase()}.`}
</AlertDescription>
</Alert>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" variant="destructive" disabled={isSubmitting}>
{isSubmitting ? 'Banning...' : 'Ban User'}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,306 @@
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 { Ruler, Save, X } from 'lucide-react';
import { useUserRole } from '@/hooks/useUserRole';
import { HeadquartersLocationInput } from './HeadquartersLocationInput';
import { EntityMultiImageUploader } from '@/components/upload/EntityMultiImageUploader';
import { useAuth } from '@/hooks/useAuth';
import { toast } from 'sonner';
import { handleError } from '@/lib/errorHandler';
import type { UploadedImage } from '@/types/company';
// Zod output type (after transformation)
type DesignerFormData = z.infer<typeof entitySchemas.designer>;
interface DesignerFormProps {
onSubmit: (data: DesignerFormData) => void;
onCancel: () => void;
initialData?: Partial<DesignerFormData & {
id?: string;
banner_image_url?: string;
card_image_url?: string;
}>;
}
export function DesignerForm({ onSubmit, onCancel, initialData }: DesignerFormProps): 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.designer),
defaultValues: {
name: initialData?.name || '',
slug: initialData?.slug || '',
company_type: 'designer' 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) : '',
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">
<Ruler className="w-5 h-5" />
{initialData ? 'Edit Designer' : 'Create New Designer'}
</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: 'designer' as const,
founded_year: data.founded_year ? parseInt(String(data.founded_year)) : undefined,
founded_date: undefined,
founded_date_precision: 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('Designer submitted for review');
onCancel();
}
} catch (error: unknown) {
handleError(error, {
action: initialData?.id ? 'Update Designer' : 'Create Designer',
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 designer 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 designer..."
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">
<div className="space-y-2">
<Label htmlFor="founded_year">Founded Year</Label>
<Input
id="founded_year"
type="number"
min="1800"
max={new Date().getFullYear()}
{...register('founded_year')}
placeholder="e.g. 1972"
/>
{errors.founded_year && (
<p className="text-sm text-destructive">{errors.founded_year.message}</p>
)}
</div>
<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="designer"
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 Designer' : 'Create Designer'}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,177 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
import { AlertCircle, TrendingUp, Users, Zap, CheckCircle, XCircle } from 'lucide-react';
interface ErrorSummary {
error_type: string | null;
occurrence_count: number | null;
affected_users: number | null;
avg_duration_ms: number | null;
}
interface ApprovalMetric {
id: string;
success: boolean;
duration_ms: number | null;
created_at: string | null;
}
interface ErrorAnalyticsProps {
errorSummary: ErrorSummary[] | undefined;
approvalMetrics: ApprovalMetric[] | undefined;
}
export function ErrorAnalytics({ errorSummary, approvalMetrics }: ErrorAnalyticsProps) {
// Calculate error metrics
const totalErrors = errorSummary?.reduce((sum, item) => sum + (item.occurrence_count || 0), 0) || 0;
const totalAffectedUsers = errorSummary?.reduce((sum, item) => sum + (item.affected_users || 0), 0) || 0;
const avgErrorDuration = errorSummary?.length
? errorSummary.reduce((sum, item) => sum + (item.avg_duration_ms || 0), 0) / errorSummary.length
: 0;
const topErrors = errorSummary?.slice(0, 5) || [];
// Calculate approval metrics
const totalApprovals = approvalMetrics?.length || 0;
const failedApprovals = approvalMetrics?.filter(m => !m.success).length || 0;
const successRate = totalApprovals > 0 ? ((totalApprovals - failedApprovals) / totalApprovals) * 100 : 0;
const avgApprovalDuration = approvalMetrics?.length
? approvalMetrics.reduce((sum, m) => sum + (m.duration_ms || 0), 0) / approvalMetrics.length
: 0;
// Show message if no data available
if ((!errorSummary || errorSummary.length === 0) && (!approvalMetrics || approvalMetrics.length === 0)) {
return (
<Card>
<CardContent className="pt-6">
<p className="text-center text-muted-foreground">No analytics data available</p>
</CardContent>
</Card>
);
}
return (
<div className="space-y-6">
{/* Error Metrics */}
{errorSummary && errorSummary.length > 0 && (
<>
<div>
<h3 className="text-lg font-semibold mb-3">Error Metrics</h3>
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Errors</CardTitle>
<AlertCircle className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalErrors}</div>
<p className="text-xs text-muted-foreground">Last 30 days</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Error Types</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{errorSummary.length}</div>
<p className="text-xs text-muted-foreground">Unique error types</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Affected Users</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalAffectedUsers}</div>
<p className="text-xs text-muted-foreground">Users impacted</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Avg Duration</CardTitle>
<Zap className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{Math.round(avgErrorDuration)}ms</div>
<p className="text-xs text-muted-foreground">Before error occurs</p>
</CardContent>
</Card>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Top 5 Errors</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={topErrors}>
<XAxis dataKey="error_type" />
<YAxis />
<Tooltip />
<Bar dataKey="occurrence_count" fill="hsl(var(--destructive))" />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</>
)}
{/* Approval Metrics */}
{approvalMetrics && approvalMetrics.length > 0 && (
<div>
<h3 className="text-lg font-semibold mb-3">Approval Metrics</h3>
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Approvals</CardTitle>
<CheckCircle className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalApprovals}</div>
<p className="text-xs text-muted-foreground">Last 24 hours</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Failures</CardTitle>
<XCircle className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-destructive">{failedApprovals}</div>
<p className="text-xs text-muted-foreground">Failed approvals</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Success Rate</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{successRate.toFixed(1)}%</div>
<p className="text-xs text-muted-foreground">Overall success rate</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Avg Duration</CardTitle>
<Zap className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{Math.round(avgApprovalDuration)}ms</div>
<p className="text-xs text-muted-foreground">Approval time</p>
</CardContent>
</Card>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,235 @@
import { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Copy, ExternalLink } from 'lucide-react';
import { format } from 'date-fns';
import { toast } from 'sonner';
import { supabase } from '@/lib/supabaseClient';
interface Breadcrumb {
timestamp: string;
category: string;
message: string;
level?: string;
sequence_order?: number;
}
interface ErrorDetails {
request_id: string;
created_at: string;
error_type: string;
error_message: string;
error_stack?: string;
endpoint: string;
method: string;
status_code: number;
duration_ms: number;
user_id?: string;
request_breadcrumbs?: Breadcrumb[];
user_agent?: string;
client_version?: string;
timezone?: string;
referrer?: string;
ip_address_hash?: string;
}
interface ErrorDetailsModalProps {
error: ErrorDetails;
onClose: () => void;
}
export function ErrorDetailsModal({ error, onClose }: ErrorDetailsModalProps) {
// Use breadcrumbs from error object if already fetched, otherwise they'll be empty
const breadcrumbs = error.request_breadcrumbs || [];
const copyErrorId = () => {
navigator.clipboard.writeText(error.request_id);
toast.success('Error ID copied to clipboard');
};
const copyErrorReport = () => {
const report = `
Error Report
============
Request ID: ${error.request_id}
Timestamp: ${format(new Date(error.created_at), 'PPpp')}
Type: ${error.error_type}
Endpoint: ${error.endpoint}
Method: ${error.method}
Status: ${error.status_code}${error.duration_ms != null ? `\nDuration: ${error.duration_ms}ms` : ''}
Error Message:
${error.error_message}
${error.error_stack ? `Stack Trace:\n${error.error_stack}` : ''}
`.trim();
navigator.clipboard.writeText(report);
toast.success('Error report copied to clipboard');
};
return (
<Dialog open onOpenChange={onClose}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
Error Details
<Badge variant="destructive">{error.error_type}</Badge>
</DialogTitle>
</DialogHeader>
<Tabs defaultValue="overview" className="w-full">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="stack">Stack Trace</TabsTrigger>
<TabsTrigger value="breadcrumbs">Breadcrumbs</TabsTrigger>
<TabsTrigger value="environment">Environment</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium">Request ID</label>
<div className="flex items-center gap-2">
<code className="text-sm bg-muted px-2 py-1 rounded">
{error.request_id}
</code>
<Button size="sm" variant="ghost" onClick={copyErrorId}>
<Copy className="w-4 h-4" />
</Button>
</div>
</div>
<div>
<label className="text-sm font-medium">Timestamp</label>
<p className="text-sm">{format(new Date(error.created_at), 'PPpp')}</p>
</div>
<div>
<label className="text-sm font-medium">Endpoint</label>
<p className="text-sm font-mono">{error.endpoint}</p>
</div>
<div>
<label className="text-sm font-medium">Method</label>
<Badge variant="outline">{error.method}</Badge>
</div>
<div>
<label className="text-sm font-medium">Status Code</label>
<p className="text-sm">{error.status_code}</p>
</div>
{error.duration_ms != null && (
<div>
<label className="text-sm font-medium">Duration</label>
<p className="text-sm">{error.duration_ms}ms</p>
</div>
)}
{error.user_id && (
<div>
<label className="text-sm font-medium">User ID</label>
<a
href={`/admin/users?search=${error.user_id}`}
className="text-sm text-primary hover:underline flex items-center gap-1"
>
{error.user_id.slice(0, 8)}...
<ExternalLink className="w-3 h-3" />
</a>
</div>
)}
</div>
<div>
<label className="text-sm font-medium">Error Message</label>
<div className="bg-muted p-4 rounded-lg mt-2">
<p className="text-sm font-mono">{error.error_message}</p>
</div>
</div>
</TabsContent>
<TabsContent value="stack">
{error.error_stack ? (
<pre className="bg-muted p-4 rounded-lg overflow-x-auto text-xs">
{error.error_stack}
</pre>
) : (
<p className="text-muted-foreground">No stack trace available</p>
)}
</TabsContent>
<TabsContent value="breadcrumbs">
{breadcrumbs && breadcrumbs.length > 0 ? (
<div className="space-y-2">
{breadcrumbs
.sort((a, b) => (a.sequence_order || 0) - (b.sequence_order || 0))
.map((crumb, index) => (
<div key={index} className="border-l-2 border-primary pl-4 py-2">
<div className="flex items-center gap-2 mb-1">
<Badge variant="outline" className="text-xs">
{crumb.category}
</Badge>
<Badge variant={crumb.level === 'error' ? 'destructive' : 'secondary'} className="text-xs">
{crumb.level || 'info'}
</Badge>
<span className="text-xs text-muted-foreground">
{format(new Date(crumb.timestamp), 'HH:mm:ss.SSS')}
</span>
</div>
<p className="text-sm">{crumb.message}</p>
</div>
))}
</div>
) : (
<p className="text-muted-foreground">No breadcrumbs recorded</p>
)}
</TabsContent>
<TabsContent value="environment">
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
{error.user_agent && (
<div>
<label className="text-sm font-medium">User Agent</label>
<p className="text-xs font-mono break-all">{error.user_agent}</p>
</div>
)}
{error.client_version && (
<div>
<label className="text-sm font-medium">Client Version</label>
<p className="text-sm">{error.client_version}</p>
</div>
)}
{error.timezone && (
<div>
<label className="text-sm font-medium">Timezone</label>
<p className="text-sm">{error.timezone}</p>
</div>
)}
{error.referrer && (
<div>
<label className="text-sm font-medium">Referrer</label>
<p className="text-xs font-mono break-all">{error.referrer}</p>
</div>
)}
{error.ip_address_hash && (
<div>
<label className="text-sm font-medium">IP Hash</label>
<p className="text-xs font-mono">{error.ip_address_hash}</p>
</div>
)}
</div>
{!error.user_agent && !error.client_version && !error.timezone && !error.referrer && !error.ip_address_hash && (
<p className="text-muted-foreground">No environment data available</p>
)}
</div>
</TabsContent>
</Tabs>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={copyErrorReport}>
<Copy className="w-4 h-4 mr-2" />
Copy Report
</Button>
<Button onClick={onClose}>Close</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,192 @@
import { useState, useEffect } from 'react';
import { Input } from '@/components/ui/input';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Search, Edit, MapPin, Loader2, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { handleNonCriticalError } from '@/lib/errorHandler';
interface LocationResult {
place_id: number;
display_name: string;
address?: {
city?: string;
town?: string;
village?: string;
state?: string;
country?: string;
};
}
interface HeadquartersLocationInputProps {
value: string;
onChange: (value: string) => void;
disabled?: boolean;
className?: string;
}
export function HeadquartersLocationInput({
value,
onChange,
disabled = false,
className
}: HeadquartersLocationInputProps): React.JSX.Element {
const [mode, setMode] = useState<'search' | 'manual'>('search');
const [searchQuery, setSearchQuery] = useState('');
const [results, setResults] = useState<LocationResult[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [showResults, setShowResults] = useState(false);
// Debounced search effect
useEffect(() => {
if (!searchQuery || searchQuery.length < 2) {
setResults([]);
setShowResults(false);
return;
}
const timeoutId = setTimeout(async (): Promise<void> => {
setIsSearching(true);
try {
const response = await fetch(
`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(
searchQuery
)}&limit=5&addressdetails=1`,
{
headers: {
'User-Agent': 'ThemeParkArchive/1.0'
}
}
);
if (response.ok) {
const data = await response.json() as LocationResult[];
setResults(data);
setShowResults(true);
}
} catch (error) {
handleNonCriticalError(error, {
action: 'Search headquarters locations',
metadata: { query: searchQuery }
});
} finally {
setIsSearching(false);
}
}, 500);
return () => clearTimeout(timeoutId);
}, [searchQuery]);
const formatLocation = (result: LocationResult): string => {
const { city, town, village, state, country } = result.address || {};
const cityName = city || town || village;
if (cityName && state && country) {
return `${cityName}, ${state}, ${country}`;
} else if (cityName && country) {
return `${cityName}, ${country}`;
} else if (country) {
return country;
}
return result.display_name;
};
const handleSelectLocation = (result: LocationResult): void => {
const formatted = formatLocation(result);
onChange(formatted);
setSearchQuery('');
setShowResults(false);
setResults([]);
};
const handleClear = (): void => {
onChange('');
setSearchQuery('');
setResults([]);
setShowResults(false);
};
return (
<div className={cn('space-y-2', className)}>
<Tabs value={mode} onValueChange={(val) => setMode(val as 'search' | 'manual')}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="search" disabled={disabled}>
<Search className="w-4 h-4 mr-2" />
Search Location
</TabsTrigger>
<TabsTrigger value="manual" disabled={disabled}>
<Edit className="w-4 h-4 mr-2" />
Manual Entry
</TabsTrigger>
</TabsList>
<TabsContent value="search" className="space-y-2 mt-4">
<div className="relative">
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search for location (e.g., Munich, Germany)..."
disabled={disabled}
className="pr-10"
/>
{isSearching && (
<Loader2 className="w-4 h-4 absolute right-3 top-3 animate-spin text-muted-foreground" />
)}
</div>
{showResults && results.length > 0 && (
<div className="border rounded-md bg-card max-h-48 overflow-y-auto">
{results.map((result) => (
<button
key={result.place_id}
type="button"
onClick={() => handleSelectLocation(result)}
className="w-full text-left px-3 py-2 hover:bg-accent hover:text-accent-foreground text-sm flex items-start gap-2 transition-colors"
disabled={disabled}
>
<MapPin className="w-4 h-4 mt-0.5 flex-shrink-0 text-muted-foreground" />
<span className="flex-1">{formatLocation(result)}</span>
</button>
))}
</div>
)}
{showResults && results.length === 0 && !isSearching && (
<p className="text-sm text-muted-foreground px-3 py-2">
No locations found. Try a different search term.
</p>
)}
{value && (
<div className="flex items-center gap-2 p-3 bg-muted rounded-md">
<MapPin className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<span className="text-sm flex-1">{value}</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleClear}
disabled={disabled}
className="h-6 px-2"
>
<X className="w-3 h-3" />
</Button>
</div>
)}
</TabsContent>
<TabsContent value="manual" className="mt-4">
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="Enter location manually..."
disabled={disabled}
/>
<p className="text-xs text-muted-foreground mt-2">
Enter any location text. For better data quality, use Search mode.
</p>
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,284 @@
/**
* Integration Test Runner Component
*
* Superuser-only UI for running comprehensive integration tests.
* Requires AAL2 if MFA is enrolled.
*/
import { useState, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Progress } from '@/components/ui/progress';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Badge } from '@/components/ui/badge';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { useSuperuserGuard } from '@/hooks/useSuperuserGuard';
import { IntegrationTestRunner as TestRunner, allTestSuites, type TestResult } from '@/lib/integrationTests';
import { Play, Square, Download, ChevronDown, CheckCircle2, XCircle, Clock, SkipForward } from 'lucide-react';
import { toast } from 'sonner';
import { handleError } from '@/lib/errorHandler';
export function IntegrationTestRunner() {
const superuserGuard = useSuperuserGuard();
const [selectedSuites, setSelectedSuites] = useState<string[]>(allTestSuites.map(s => s.id));
const [isRunning, setIsRunning] = useState(false);
const [results, setResults] = useState<TestResult[]>([]);
const [runner] = useState(() => new TestRunner((result) => {
setResults(prev => {
const existing = prev.findIndex(r => r.id === result.id);
if (existing >= 0) {
const updated = [...prev];
updated[existing] = result;
return updated;
}
return [...prev, result];
});
}));
const toggleSuite = useCallback((suiteId: string) => {
setSelectedSuites(prev =>
prev.includes(suiteId)
? prev.filter(id => id !== suiteId)
: [...prev, suiteId]
);
}, []);
const runTests = useCallback(async () => {
const suitesToRun = allTestSuites.filter(s => selectedSuites.includes(s.id));
if (suitesToRun.length === 0) {
toast.error('Please select at least one test suite');
return;
}
setIsRunning(true);
setResults([]);
runner.reset();
toast.info(`Running ${suitesToRun.length} test suite(s)...`);
try {
await runner.runAllSuites(suitesToRun);
const summary = runner.getSummary();
if (summary.failed > 0) {
toast.error(`Tests completed with ${summary.failed} failure(s)`);
} else {
toast.success(`All ${summary.passed} tests passed!`);
}
} catch (error: unknown) {
handleError(error, {
action: 'Run integration tests',
metadata: { suitesCount: suitesToRun.length }
});
toast.error('Test run failed');
} finally {
setIsRunning(false);
}
}, [selectedSuites, runner]);
const stopTests = useCallback(() => {
runner.stop();
setIsRunning(false);
toast.info('Test run stopped');
}, [runner]);
const exportResults = useCallback(() => {
const summary = runner.getSummary();
const exportData = {
timestamp: new Date().toISOString(),
summary,
results: runner.getResults()
};
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `integration-tests-${Date.now()}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success('Test results exported');
}, [runner]);
// Guard is handled by the route/page, no loading state needed here
const summary = runner.getSummary();
const totalTests = allTestSuites
.filter(s => selectedSuites.includes(s.id))
.reduce((sum, s) => sum + s.tests.length, 0);
const progress = totalTests > 0 ? (results.length / totalTests) * 100 : 0;
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
🧪 Integration Test Runner
</CardTitle>
<CardDescription>
Superuser-only comprehensive testing system. Tests run against real database functions and edge functions.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Suite Selection */}
<div className="space-y-3">
<h3 className="font-medium">Select Test Suites:</h3>
<div className="space-y-2">
{allTestSuites.map(suite => (
<div key={suite.id} className="flex items-start space-x-3">
<Checkbox
id={suite.id}
checked={selectedSuites.includes(suite.id)}
onCheckedChange={() => toggleSuite(suite.id)}
disabled={isRunning}
/>
<div className="space-y-1 flex-1">
<label
htmlFor={suite.id}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
{suite.name} ({suite.tests.length} tests)
</label>
<p className="text-sm text-muted-foreground">
{suite.description}
</p>
</div>
</div>
))}
</div>
</div>
{/* Controls */}
<div className="flex gap-2">
<Button onClick={runTests} loading={isRunning} loadingText="Running..." disabled={selectedSuites.length === 0}>
<Play className="w-4 h-4 mr-2" />
Run Selected
</Button>
{isRunning && (
<Button onClick={stopTests} variant="destructive">
<Square className="w-4 h-4 mr-2" />
Stop
</Button>
)}
{results.length > 0 && !isRunning && (
<Button onClick={exportResults} variant="outline">
<Download className="w-4 h-4 mr-2" />
Export Results
</Button>
)}
</div>
{/* Progress */}
{results.length > 0 && (
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>Progress: {results.length}/{totalTests} tests</span>
<span>{progress.toFixed(0)}%</span>
</div>
<Progress value={progress} />
</div>
)}
{/* Summary */}
{results.length > 0 && (
<div className="flex gap-4 text-sm">
<div className="flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-green-500" />
<span>{summary.passed} passed</span>
</div>
<div className="flex items-center gap-2">
<XCircle className="w-4 h-4 text-destructive" />
<span>{summary.failed} failed</span>
</div>
<div className="flex items-center gap-2">
<SkipForward className="w-4 h-4 text-muted-foreground" />
<span>{summary.skipped} skipped</span>
</div>
<div className="flex items-center gap-2">
<Clock className="w-4 h-4" />
<span>{(summary.totalDuration / 1000).toFixed(2)}s</span>
</div>
</div>
)}
</CardContent>
</Card>
{/* Results */}
{results.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Test Results</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-[600px] pr-4">
<div className="space-y-2">
{results.map(result => (
<Collapsible key={result.id}>
<div className="flex items-start gap-3 p-3 rounded-lg border bg-card">
<div className="pt-0.5">
{result.status === 'pass' && <CheckCircle2 className="w-4 h-4 text-green-500" />}
{result.status === 'fail' && <XCircle className="w-4 h-4 text-destructive" />}
{result.status === 'skip' && <SkipForward className="w-4 h-4 text-muted-foreground" />}
{result.status === 'running' && <Clock className="w-4 h-4 text-blue-500 animate-pulse" />}
</div>
<div className="flex-1 space-y-1">
<div className="flex items-start justify-between gap-2">
<div className="space-y-1">
<p className="text-sm font-medium">{result.name}</p>
<p className="text-xs text-muted-foreground">{result.suite}</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">
{result.duration}ms
</Badge>
{(result.error || result.details) && (
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
<ChevronDown className="h-3 w-3" />
</Button>
</CollapsibleTrigger>
)}
</div>
</div>
{result.error && (
<p className="text-sm text-destructive">{result.error}</p>
)}
</div>
</div>
{(result.error || result.details) && (
<CollapsibleContent>
<div className="ml-7 mt-2 p-3 rounded-lg bg-muted/50 space-y-2">
{result.error && result.stack && (
<div>
<p className="text-xs font-medium mb-1">Stack Trace:</p>
<pre className="text-xs whitespace-pre-wrap font-mono bg-background p-2 rounded">
{result.stack}
</pre>
</div>
)}
{result.details && (
<div>
<p className="text-xs font-medium mb-1">Details:</p>
<pre className="text-xs whitespace-pre-wrap font-mono bg-background p-2 rounded">
{JSON.stringify(result.details, null, 2)}
</pre>
</div>
)}
</div>
</CollapsibleContent>
)}
</Collapsible>
))}
</div>
</ScrollArea>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,312 @@
import { useState, useCallback, useEffect } from 'react';
import { useDebounce } from '@/hooks/useDebounce';
import { supabase } from '@/lib/supabaseClient';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { MapPin, Loader2, X } from 'lucide-react';
import { ParkLocationMap } from '@/components/maps/ParkLocationMap';
import { handleNonCriticalError } from '@/lib/errorHandler';
interface LocationResult {
place_id: number;
display_name: string;
lat: string;
lon: string;
address: {
house_number?: string;
road?: string;
city?: string;
town?: string;
village?: string;
municipality?: string;
state?: string;
province?: string;
state_district?: string;
county?: string;
region?: string;
territory?: string;
country?: string;
country_code?: string;
postcode?: string;
};
}
interface SelectedLocation {
name: string;
street_address?: string;
city?: string;
state_province?: string;
country: string;
postal_code?: string;
latitude: number;
longitude: number;
timezone?: string;
display_name: string; // Full OSM display name for reference
}
interface LocationSearchProps {
onLocationSelect: (location: SelectedLocation) => void;
initialLocationId?: string;
className?: string;
}
export function LocationSearch({ onLocationSelect, initialLocationId, className }: LocationSearchProps): React.JSX.Element {
const [searchQuery, setSearchQuery] = useState('');
const [results, setResults] = useState<LocationResult[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [searchError, setSearchError] = useState<string | null>(null);
const [selectedLocation, setSelectedLocation] = useState<SelectedLocation | null>(null);
const [showResults, setShowResults] = useState(false);
const debouncedSearch = useDebounce(searchQuery, 500);
// Load initial location if editing
useEffect(() => {
if (initialLocationId) {
void loadInitialLocation(initialLocationId);
}
}, [initialLocationId]);
const loadInitialLocation = async (locationId: string): Promise<void> => {
const { data, error } = await supabase
.from('locations')
.select('id, name, street_address, city, state_province, country, postal_code, latitude, longitude, timezone')
.eq('id', locationId)
.maybeSingle();
if (data && !error) {
setSelectedLocation({
name: data.name,
street_address: data.street_address || undefined,
city: data.city || undefined,
state_province: data.state_province || undefined,
country: data.country,
postal_code: data.postal_code || undefined,
latitude: parseFloat(data.latitude?.toString() || '0'),
longitude: parseFloat(data.longitude?.toString() || '0'),
timezone: data.timezone || undefined,
display_name: data.name, // Use name as display for existing locations
});
}
};
const searchLocations = useCallback(async (query: string) => {
if (!query || query.length < 3) {
setResults([]);
setSearchError(null);
return;
}
setIsSearching(true);
setSearchError(null);
try {
const response = await fetch(
`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&addressdetails=1&limit=5`,
{
headers: {
'User-Agent': 'ThemeParkDatabase/1.0',
},
}
);
// Check if response is OK and content-type is JSON
if (!response.ok) {
const errorMsg = `Location search failed (${response.status}). Please try again.`;
setSearchError(errorMsg);
setResults([]);
setShowResults(false);
return;
}
const contentType = response.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) {
const errorMsg = 'Invalid response from location service. Please try again.';
setSearchError(errorMsg);
setResults([]);
setShowResults(false);
return;
}
const data = await response.json() as LocationResult[];
setResults(data);
setShowResults(true);
setSearchError(null);
} catch (error: unknown) {
handleNonCriticalError(error, {
action: 'Search locations',
metadata: { query: searchQuery }
});
setSearchError('Failed to search locations. Please check your connection.');
setResults([]);
setShowResults(false);
} finally {
setIsSearching(false);
}
}, []);
useEffect(() => {
if (debouncedSearch) {
void searchLocations(debouncedSearch);
} else {
setResults([]);
setShowResults(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearch]);
const handleSelectResult = (result: LocationResult): void => {
const latitude = parseFloat(result.lat);
const longitude = parseFloat(result.lon);
// Safely access address properties with fallback
const address = result.address || {};
// Extract street address components
const houseNumber = address.house_number || '';
const road = address.road || '';
const streetAddress = [houseNumber, road].filter(Boolean).join(' ').trim() || undefined;
// Extract city
const city = address.city || address.town || address.village || address.municipality;
// Extract state/province (try multiple fields for international support)
const state = address.state ||
address.province ||
address.state_district ||
address.county ||
address.region ||
address.territory;
const country = address.country || 'Unknown';
const postalCode = address.postcode;
// Build location name
const locationParts = [streetAddress, city, state, country].filter(Boolean);
const locationName = locationParts.join(', ');
// Build location data object (no database operations)
const locationData: SelectedLocation = {
name: locationName,
street_address: streetAddress,
city: city || undefined,
state_province: state || undefined,
country: country,
postal_code: postalCode || undefined,
latitude,
longitude,
timezone: undefined, // Will be set by server during approval if needed
display_name: result.display_name,
};
setSelectedLocation(locationData);
setSearchQuery('');
setResults([]);
setShowResults(false);
onLocationSelect(locationData);
};
const handleClear = (): void => {
setSelectedLocation(null);
setSearchQuery('');
setResults([]);
setShowResults(false);
};
return (
<div className={className}>
{!selectedLocation ? (
<div className="space-y-2">
<div className="relative">
<MapPin className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder="Search for a location (city, address, landmark...)"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
{isSearching && (
<Loader2 className="absolute right-3 top-3 h-4 w-4 animate-spin text-muted-foreground" />
)}
</div>
{searchError && (
<div className="text-sm text-destructive mt-1">
{searchError}
</div>
)}
{showResults && results.length > 0 && (
<Card className="absolute z-50 w-full max-h-64 overflow-y-auto">
<div className="divide-y">
{results.map((result) => (
<button
type="button"
key={result.place_id}
onClick={() => void handleSelectResult(result)}
className="w-full text-left p-3 hover:bg-accent transition-colors"
>
<div className="flex items-start gap-2">
<MapPin className="h-4 w-4 mt-0.5 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{result.display_name}</p>
<p className="text-xs text-muted-foreground">
{result.lat}, {result.lon}
</p>
</div>
</div>
</button>
))}
</div>
</Card>
)}
{showResults && results.length === 0 && !isSearching && !searchError && (
<div className="text-sm text-muted-foreground mt-1">
No locations found. Try a different search term.
</div>
)}
</div>
) : (
<div className="space-y-4">
<Card className="p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 flex-1">
<MapPin className="h-5 w-5 text-primary mt-0.5" />
<div className="flex-1 min-w-0">
<p className="font-medium">{selectedLocation.name}</p>
<div className="text-sm text-muted-foreground space-y-1 mt-1">
{selectedLocation.street_address && <p>Street: {selectedLocation.street_address}</p>}
{selectedLocation.city && <p>City: {selectedLocation.city}</p>}
{selectedLocation.state_province && <p>State/Province: {selectedLocation.state_province}</p>}
<p>Country: {selectedLocation.country}</p>
{selectedLocation.postal_code && <p>Postal Code: {selectedLocation.postal_code}</p>}
<p className="text-xs">
Coordinates: {selectedLocation.latitude.toFixed(6)}, {selectedLocation.longitude.toFixed(6)}
</p>
</div>
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleClear}
>
<X className="h-4 w-4" />
</Button>
</div>
</Card>
<ParkLocationMap
latitude={selectedLocation.latitude}
longitude={selectedLocation.longitude}
parkName={selectedLocation.name}
className="h-48"
/>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,310 @@
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) : ('day' 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>
);
}

View File

@@ -0,0 +1,239 @@
import { useEffect, useState, useRef } from 'react';
import {
MDXEditor,
headingsPlugin,
listsPlugin,
quotePlugin,
thematicBreakPlugin,
markdownShortcutPlugin,
linkPlugin,
linkDialogPlugin,
imagePlugin,
tablePlugin,
codeBlockPlugin,
codeMirrorPlugin,
diffSourcePlugin,
toolbarPlugin,
UndoRedo,
BoldItalicUnderlineToggles,
CodeToggle,
ListsToggle,
BlockTypeSelect,
CreateLink,
InsertImage,
InsertTable,
InsertThematicBreak,
DiffSourceToggleWrapper,
type MDXEditorMethods
} from '@mdxeditor/editor';
import '@mdxeditor/editor/style.css';
import '@/styles/mdx-editor-theme.css';
import { useTheme } from '@/components/theme/ThemeProvider';
import { supabase } from '@/lib/supabaseClient';
import { invokeWithTracking } from '@/lib/edgeFunctionTracking';
import { getCloudflareImageUrl } from '@/lib/cloudflareImageUtils';
import { useAutoSave } from '@/hooks/useAutoSave';
import { CheckCircle2, Loader2, AlertCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { handleError } from '@/lib/errorHandler';
interface MarkdownEditorProps {
value: string;
onChange: (value: string) => void;
onSave?: (value: string) => Promise<void>;
autoSave?: boolean;
height?: number;
placeholder?: string;
}
export function MarkdownEditor({
value,
onChange,
onSave,
autoSave = false,
height = 600,
placeholder = 'Write your content in markdown...'
}: MarkdownEditorProps): React.JSX.Element {
const { theme } = useTheme();
const [mounted, setMounted] = useState(false);
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('light');
const editorRef = useRef<MDXEditorMethods>(null);
// Resolve "system" theme to actual theme based on OS preference
useEffect(() => {
if (theme === 'system') {
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
setResolvedTheme(isDark ? 'dark' : 'light');
} else {
setResolvedTheme(theme);
}
}, [theme]);
// Listen for OS theme changes when in system mode
useEffect(() => {
if (theme !== 'system') return;
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e: MediaQueryListEvent): void => {
setResolvedTheme(e.matches ? 'dark' : 'light');
};
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}, [theme]);
// Auto-save integration
const { isSaving, lastSaved, error } = useAutoSave({
data: value,
onSave: onSave || (async () => {}),
debounceMs: 3000,
enabled: autoSave && !!onSave,
isValid: value.length > 0
});
useEffect(() => {
setMounted(true);
}, []);
// Prevent hydration mismatch
if (!mounted) {
return (
<div
className="border border-input rounded-lg bg-muted/50 flex items-center justify-center"
style={{ height }}
>
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
const getLastSavedText = (): string | null => {
if (!lastSaved) return null;
const seconds = Math.floor((Date.now() - lastSaved.getTime()) / 1000);
if (seconds < 60) return `Saved ${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
return `Saved ${minutes}m ago`;
};
return (
<div className="space-y-2">
<div
className="border border-input rounded-lg overflow-hidden"
style={{ minHeight: height }}
>
<MDXEditor
ref={editorRef}
className={cn('mdxeditor', resolvedTheme === 'dark' && 'dark-theme')}
markdown={value}
onChange={onChange}
placeholder={placeholder}
contentEditableClassName="prose dark:prose-invert max-w-none p-4 min-h-[500px] mdx-content-area"
plugins={[
headingsPlugin(),
listsPlugin(),
quotePlugin(),
thematicBreakPlugin(),
markdownShortcutPlugin(),
linkPlugin(),
linkDialogPlugin(),
imagePlugin({
imageUploadHandler: async (file: File): Promise<string> => {
try {
const formData = new FormData();
formData.append('file', file);
const { data, error } = await invokeWithTracking(
'upload-image',
formData,
undefined
);
if (error) throw error;
// Return Cloudflare CDN URL
const imageUrl = getCloudflareImageUrl((data as { id: string }).id, 'public');
if (!imageUrl) throw new Error('Failed to generate image URL');
return imageUrl;
} catch (error: unknown) {
handleError(error, {
action: 'Upload markdown image',
metadata: { fileName: file.name }
});
throw new Error(error instanceof Error ? error.message : 'Failed to upload image');
}
}
}),
tablePlugin(),
codeBlockPlugin({ defaultCodeBlockLanguage: 'js' }),
codeMirrorPlugin({
codeBlockLanguages: {
js: 'JavaScript',
ts: 'TypeScript',
tsx: 'TypeScript (React)',
jsx: 'JavaScript (React)',
css: 'CSS',
html: 'HTML',
python: 'Python',
bash: 'Bash',
json: 'JSON',
sql: 'SQL'
}
}),
diffSourcePlugin({ viewMode: 'rich-text', diffMarkdown: '' }),
toolbarPlugin({
toolbarContents: () => (
<>
<UndoRedo />
<BoldItalicUnderlineToggles />
<CodeToggle />
<BlockTypeSelect />
<ListsToggle />
<CreateLink />
<InsertImage />
<InsertTable />
<InsertThematicBreak />
<DiffSourceToggleWrapper>
<span className="text-sm">Source</span>
</DiffSourceToggleWrapper>
</>
)
})
]}
/>
</div>
{/* Auto-save status indicator */}
{autoSave && onSave && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{isSaving && (
<>
<Loader2 className="h-3 w-3 animate-spin" />
<span>Saving...</span>
</>
)}
{!isSaving && lastSaved && !error && (
<>
<CheckCircle2 className="h-3 w-3 text-green-600 dark:text-green-400" />
<span>{getLastSavedText()}</span>
</>
)}
{error && (
<>
<AlertCircle className="h-3 w-3 text-destructive" />
<span className="text-destructive">Failed to save: {error}</span>
</>
)}
</div>
)}
{/* Word and character count */}
<div className="flex justify-between text-xs text-muted-foreground">
<span>Supports markdown formatting with live preview</span>
<span>
{value.split(/\s+/).filter(Boolean).length} words · {value.length} characters
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import { lazy, Suspense } from 'react';
import { EditorSkeleton } from '@/components/loading/PageSkeletons';
const MarkdownEditor = lazy(() =>
import('./MarkdownEditor').then(module => ({ default: module.MarkdownEditor }))
);
export interface MarkdownEditorProps {
value: string;
onChange: (value: string) => void;
onSave?: (value: string) => Promise<void>;
autoSave?: boolean;
height?: number;
placeholder?: string;
}
export function MarkdownEditorLazy(props: MarkdownEditorProps): React.JSX.Element {
return (
<Suspense fallback={<EditorSkeleton />}>
<MarkdownEditor {...props} />
</Suspense>
);
}

View File

@@ -0,0 +1,219 @@
import { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { AlertTriangle, CheckCircle, RefreshCw, Loader2 } from 'lucide-react';
import { supabase } from '@/lib/supabaseClient';
import { format } from 'date-fns';
import { handleNonCriticalError } from '@/lib/errorHandler';
interface DuplicateStats {
date: string | null;
total_attempts: number | null;
duplicates_prevented: number | null;
prevention_rate: number | null;
health_status: 'healthy' | 'warning' | 'critical';
}
interface RecentDuplicate {
id: string;
user_id: string;
channel: string;
idempotency_key: string | null;
created_at: string;
profiles?: {
username: string;
display_name: string | null;
};
}
export function NotificationDebugPanel() {
const [stats, setStats] = useState<DuplicateStats[]>([]);
const [recentDuplicates, setRecentDuplicates] = useState<RecentDuplicate[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
setIsLoading(true);
try {
// Load health dashboard
const { data: healthData, error: healthError } = await supabase
.from('notification_health_dashboard')
.select('*')
.limit(7);
if (healthError) throw healthError;
if (healthData) {
setStats(healthData.map(stat => ({
...stat,
health_status: stat.health_status as 'healthy' | 'warning' | 'critical'
})));
}
// Load recent prevented duplicates
const { data: duplicates, error: duplicatesError } = await supabase
.from('notification_logs')
.select(`
id,
user_id,
channel,
idempotency_key,
created_at
`)
.eq('is_duplicate', true)
.order('created_at', { ascending: false })
.limit(10);
if (duplicatesError) throw duplicatesError;
if (duplicates) {
// Fetch profiles separately
const userIds = [...new Set(duplicates.map(d => d.user_id))];
const { data: profiles } = await supabase
.from('profiles')
.select('user_id, username, display_name')
.in('user_id', userIds);
const profileMap = new Map(profiles?.map(p => [p.user_id, p]) || []);
setRecentDuplicates(duplicates.map(dup => ({
...dup,
profiles: profileMap.get(dup.user_id)
})));
}
} catch (error: unknown) {
handleNonCriticalError(error, {
action: 'Load notification debug data'
});
} finally {
setIsLoading(false);
}
};
const getHealthBadge = (status: string) => {
switch (status) {
case 'healthy':
return (
<Badge variant="default" className="bg-green-500">
<CheckCircle className="h-3 w-3 mr-1" />
Healthy
</Badge>
);
case 'warning':
return (
<Badge variant="secondary">
<AlertTriangle className="h-3 w-3 mr-1" />
Warning
</Badge>
);
case 'critical':
return (
<Badge variant="destructive">
<AlertTriangle className="h-3 w-3 mr-1" />
Critical
</Badge>
);
default:
return <Badge>Unknown</Badge>;
}
};
if (isLoading) {
return (
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
</CardContent>
</Card>
);
}
return (
<div className="space-y-4">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Notification Health Dashboard</CardTitle>
<CardDescription>Monitor duplicate prevention and notification system health</CardDescription>
</div>
<Button variant="outline" size="sm" onClick={loadData} loading={isLoading} loadingText="Loading...">
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</div>
</CardHeader>
<CardContent>
{stats.length === 0 ? (
<Alert>
<AlertDescription>No notification statistics available yet</AlertDescription>
</Alert>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead className="text-right">Total Attempts</TableHead>
<TableHead className="text-right">Duplicates Prevented</TableHead>
<TableHead className="text-right">Prevention Rate</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{stats.map((stat) => (
<TableRow key={stat.date || 'unknown'}>
<TableCell>{stat.date ? format(new Date(stat.date), 'MMM d, yyyy') : 'N/A'}</TableCell>
<TableCell className="text-right">{stat.total_attempts ?? 0}</TableCell>
<TableCell className="text-right">{stat.duplicates_prevented ?? 0}</TableCell>
<TableCell className="text-right">{stat.prevention_rate !== null ? stat.prevention_rate.toFixed(1) : 'N/A'}%</TableCell>
<TableCell>{getHealthBadge(stat.health_status)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Recent Prevented Duplicates</CardTitle>
<CardDescription>Notifications that were blocked due to duplication</CardDescription>
</CardHeader>
<CardContent>
{recentDuplicates.length === 0 ? (
<Alert>
<CheckCircle className="h-4 w-4" />
<AlertDescription>No recent duplicates detected</AlertDescription>
</Alert>
) : (
<div className="space-y-2">
{recentDuplicates.map((dup) => (
<div key={dup.id} className="flex items-center justify-between p-3 border rounded-lg">
<div>
<div className="font-medium">
{dup.profiles?.display_name || dup.profiles?.username || 'Unknown User'}
</div>
<div className="text-xs text-muted-foreground">
Channel: {dup.channel} Key: {dup.idempotency_key?.substring(0, 12)}...
</div>
</div>
<div className="text-xs text-muted-foreground">
{format(new Date(dup.created_at), 'PPp')}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,166 @@
import { useState } from 'react';
import { supabase } from '@/lib/supabaseClient';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { CheckCircle2, XCircle, AlertCircle, Loader2 } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { getErrorMessage } from '@/lib/errorHandler';
interface MigrationResult {
userId: string;
email: string;
success: boolean;
error?: string;
}
export function NovuMigrationUtility(): React.JSX.Element {
const { toast } = useToast();
const [isRunning, setIsRunning] = useState(false);
const [progress, setProgress] = useState(0);
const [results, setResults] = useState<MigrationResult[]>([]);
const [totalUsers, setTotalUsers] = useState(0);
const runMigration = async (): Promise<void> => {
setIsRunning(true);
setResults([]);
setProgress(0);
try {
// Call the server-side migration function
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
throw new Error('You must be logged in to run the migration');
}
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL as string || 'https://api.thrillwiki.com';
const response = await fetch(
`${supabaseUrl}/functions/v1/migrate-novu-users`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${session.access_token}`,
},
}
);
const data = await response.json() as { success: boolean; error?: string; results?: MigrationResult[]; total?: number };
if (!response.ok || !data.success) {
throw new Error(data.error || 'Migration failed');
}
if (!data.results || data.results.length === 0) {
toast({
title: "No users to migrate",
description: "All users are already registered with Novu.",
});
setIsRunning(false);
return;
}
setTotalUsers(data.total ?? 0);
setResults(data.results ?? []);
setProgress(100);
const successCount = (data.results ?? []).filter((r: MigrationResult) => r.success).length;
const failureCount = (data.results ?? []).length - successCount;
toast({
title: "Migration completed",
description: `Successfully migrated ${successCount} users. ${failureCount} failures.`,
});
} catch (error: unknown) {
const errorMsg = getErrorMessage(error);
toast({
variant: "destructive",
title: "Migration failed",
description: errorMsg,
});
} finally {
setIsRunning(false);
}
};
const successCount = results.filter(r => r.success).length;
const failureCount = results.filter(r => !r.success).length;
return (
<Card>
<CardHeader>
<CardTitle>Novu User Migration</CardTitle>
<CardDescription>
Register existing users with Novu notification service
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
This utility will register all existing users who don't have a Novu subscriber ID.
The process is non-blocking and will continue even if individual registrations fail.
</AlertDescription>
</Alert>
<Button
onClick={() => void runMigration()}
loading={isRunning}
loadingText="Migrating Users..."
className="w-full"
>
Start Migration
</Button>
{isRunning && totalUsers > 0 && (
<div className="space-y-2">
<div className="flex justify-between text-sm text-muted-foreground">
<span>Progress</span>
<span>{Math.round(progress)}%</span>
</div>
<Progress value={progress} />
<p className="text-sm text-muted-foreground">
Processing {results.length} of {totalUsers} users
</p>
</div>
)}
{results.length > 0 && (
<div className="space-y-2">
<div className="flex gap-4 text-sm">
<div className="flex items-center gap-2 text-green-600">
<CheckCircle2 className="h-4 w-4" />
<span>{successCount} succeeded</span>
</div>
<div className="flex items-center gap-2 text-red-600">
<XCircle className="h-4 w-4" />
<span>{failureCount} failed</span>
</div>
</div>
<div className="max-h-60 overflow-y-auto border rounded-md p-2 space-y-1">
{results.map((result, idx) => (
<div
key={idx}
className="flex items-center justify-between text-xs p-2 rounded bg-muted/50"
>
<span className="truncate flex-1">{result.email}</span>
{result.success ? (
<CheckCircle2 className="h-3 w-3 text-green-600 ml-2" />
) : (
<div className="flex items-center gap-1 ml-2">
<XCircle className="h-3 w-3 text-red-600" />
<span className="text-red-600">{result.error}</span>
</div>
)}
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,306 @@
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 { FerrisWheel, Save, X } from 'lucide-react';
import { useUserRole } from '@/hooks/useUserRole';
import { HeadquartersLocationInput } from './HeadquartersLocationInput';
import { EntityMultiImageUploader } from '@/components/upload/EntityMultiImageUploader';
import { useAuth } from '@/hooks/useAuth';
import { toast } from 'sonner';
import { handleError } from '@/lib/errorHandler';
import type { UploadedImage } from '@/types/company';
// Zod output type (after transformation)
type OperatorFormData = z.infer<typeof entitySchemas.operator>;
interface OperatorFormProps {
onSubmit: (data: OperatorFormData) => void;
onCancel: () => void;
initialData?: Partial<OperatorFormData & {
id?: string;
banner_image_url?: string;
card_image_url?: string;
}>;
}
export function OperatorForm({ onSubmit, onCancel, initialData }: OperatorFormProps): 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.operator),
defaultValues: {
name: initialData?.name || '',
slug: initialData?.slug || '',
company_type: 'operator' 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) : '',
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">
<FerrisWheel className="w-5 h-5" />
{initialData ? 'Edit Operator' : 'Create New Operator'}
</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: 'operator' as const,
founded_year: data.founded_year ? parseInt(String(data.founded_year)) : undefined,
founded_date: undefined,
founded_date_precision: 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('Operator submitted for review');
onCancel();
}
} catch (error: unknown) {
handleError(error, {
action: initialData?.id ? 'Update Operator' : 'Create Operator',
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 operator 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 operator..."
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">
<div className="space-y-2">
<Label htmlFor="founded_year">Founded Year</Label>
<Input
id="founded_year"
type="number"
min="1800"
max={new Date().getFullYear()}
{...register('founded_year')}
placeholder="e.g. 1972"
/>
{errors.founded_year && (
<p className="text-sm text-destructive">{errors.founded_year.message}</p>
)}
</div>
<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="operator"
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 Operator' : 'Create Operator'}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,740 @@
import { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { entitySchemas, validateRequiredFields } from '@/lib/entityValidationSchemas';
import { validateSubmissionHandler } from '@/lib/entityFormValidation';
import { getErrorMessage } from '@/lib/errorHandler';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
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 { EntityMultiImageUploader, ImageAssignments } from '@/components/upload/EntityMultiImageUploader';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { DatePicker } from '@/components/ui/date-picker';
import { FlexibleDateInput, type DatePrecision } from '@/components/ui/flexible-date-input';
import { SlugField } from '@/components/ui/slug-field';
import { toast } from '@/hooks/use-toast';
import { handleError } from '@/lib/errorHandler';
import { MapPin, Save, X, Plus, AlertCircle } from 'lucide-react';
import { toDateOnly, parseDateOnly, toDateWithPrecision } from '@/lib/dateUtils';
import { Badge } from '@/components/ui/badge';
import { Combobox } from '@/components/ui/combobox';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { useOperators, usePropertyOwners } from '@/hooks/useAutocompleteData';
import { useUserRole } from '@/hooks/useUserRole';
import { useAuth } from '@/hooks/useAuth';
import type { TempCompanyData } from '@/types/company';
import { LocationSearch } from './LocationSearch';
import { OperatorForm } from './OperatorForm';
import { PropertyOwnerForm } from './PropertyOwnerForm';
import { Checkbox } from '@/components/ui/checkbox';
const parkSchema = z.object({
name: z.string().min(1, 'Park name is required'),
slug: z.string().min(1, 'Slug is required'), // Auto-generated, validated on submit
description: z.string().optional(),
park_type: z.string().min(1, 'Park type is required'),
status: z.string().min(1, 'Status is required'),
opening_date: z.string().optional().transform(val => val || undefined),
opening_date_precision: z.enum(['day', 'month', 'year']).optional(),
closing_date: z.string().optional().transform(val => val || undefined),
closing_date_precision: z.enum(['day', 'month', 'year']).optional(),
location: z.object({
name: z.string(),
street_address: z.string().optional(),
city: z.string().optional(),
state_province: z.string().optional(),
country: z.string(),
postal_code: z.string().optional(),
latitude: z.number(),
longitude: z.number(),
timezone: z.string().optional(),
display_name: z.string(),
}).optional(),
location_id: z.string().uuid().optional(),
website_url: z.string().url().optional().or(z.literal('')),
phone: z.string().optional(),
email: z.string().email().optional().or(z.literal('')),
operator_id: z.string().uuid().optional().or(z.literal('')).transform(val => val || undefined),
property_owner_id: z.string().uuid().optional().or(z.literal('')).transform(val => val || undefined),
source_url: z.string().url().optional().or(z.literal('')),
submission_notes: z.string().max(1000).optional().or(z.literal('')),
images: z.object({
uploaded: z.array(z.object({
url: z.string(),
cloudflare_id: z.string().optional(),
file: z.instanceof(File).optional(),
isLocal: z.boolean().optional(),
caption: z.string().optional(),
})),
banner_assignment: z.number().nullable().optional(),
card_assignment: z.number().nullable().optional(),
}).optional()
});
type ParkFormData = z.infer<typeof parkSchema>;
interface ParkFormProps {
onSubmit: (data: ParkFormData & {
operator_id?: string;
property_owner_id?: string;
_compositeSubmission?: import('@/types/composite-submission').ParkCompositeSubmission;
}) => Promise<void>;
onCancel?: () => void;
initialData?: Partial<ParkFormData & {
id?: string;
operator_id?: string;
property_owner_id?: string;
banner_image_url?: string;
card_image_url?: string;
}>;
isEditing?: boolean;
}
const parkTypes = [
{ value: 'theme_park', label: 'Theme Park' },
{ value: 'amusement_park', label: 'Amusement Park' },
{ value: 'water_park', label: 'Water Park' },
{ value: 'family_entertainment', label: 'Family Entertainment Center' },
{ value: 'adventure_park', label: 'Adventure Park' },
{ value: 'safari_park', label: 'Safari Park' },
{ value: 'carnival', label: 'Carnival' },
{ value: 'fair', label: 'Fair' }
];
const statusOptions = [
'Operating',
'Closed Temporarily',
'Closed Permanently',
'Under Construction',
'Planned',
'Abandoned'
];
// Status mappings
const STATUS_DISPLAY_TO_DB: Record<string, string> = {
'Operating': 'operating',
'Closed Temporarily': 'closed_temporarily',
'Closed Permanently': 'closed_permanently',
'Under Construction': 'under_construction',
'Planned': 'planned',
'Abandoned': 'abandoned'
};
const STATUS_DB_TO_DISPLAY: Record<string, string> = {
'operating': 'Operating',
'closed_temporarily': 'Closed Temporarily',
'closed_permanently': 'Closed Permanently',
'under_construction': 'Under Construction',
'planned': 'Planned',
'abandoned': 'Abandoned'
};
export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }: ParkFormProps) {
const { isModerator } = useUserRole();
// Validate that onSubmit uses submission helpers (dev mode only)
useEffect(() => {
validateSubmissionHandler(onSubmit, 'park');
}, [onSubmit]);
const { user } = useAuth();
const [isSubmitting, setIsSubmitting] = useState(false);
// Operator state
const [selectedOperatorId, setSelectedOperatorId] = useState<string>(initialData?.operator_id || '');
const [tempNewOperator, setTempNewOperator] = useState<TempCompanyData | null>(null);
const [isOperatorModalOpen, setIsOperatorModalOpen] = useState(false);
// Property Owner state
const [selectedPropertyOwnerId, setSelectedPropertyOwnerId] = useState<string>(initialData?.property_owner_id || '');
const [tempNewPropertyOwner, setTempNewPropertyOwner] = useState<TempCompanyData | null>(null);
const [isPropertyOwnerModalOpen, setIsPropertyOwnerModalOpen] = useState(false);
// Operator is Owner checkbox state
const [operatorIsOwner, setOperatorIsOwner] = useState<boolean>(
!!(initialData?.operator_id && initialData?.property_owner_id &&
initialData?.operator_id === initialData?.property_owner_id)
);
// Fetch data
const { operators, loading: operatorsLoading } = useOperators();
const { propertyOwners, loading: ownersLoading } = usePropertyOwners();
const {
register,
handleSubmit,
setValue,
watch,
trigger,
formState: { errors }
} = useForm<ParkFormData>({
resolver: zodResolver(entitySchemas.park),
defaultValues: {
name: initialData?.name || '',
slug: initialData?.slug || '',
description: initialData?.description || '',
park_type: initialData?.park_type || '',
status: initialData?.status || 'operating' as const, // Store DB value
opening_date: initialData?.opening_date || undefined,
closing_date: initialData?.closing_date || undefined,
location_id: initialData?.location_id || undefined,
website_url: initialData?.website_url || '',
phone: initialData?.phone || '',
email: initialData?.email || '',
operator_id: initialData?.operator_id || undefined,
property_owner_id: initialData?.property_owner_id || undefined,
source_url: initialData?.source_url || '',
submission_notes: initialData?.submission_notes || '',
images: { uploaded: [] }
}
});
// Sync property owner with operator when checkbox is enabled
useEffect(() => {
if (operatorIsOwner && selectedOperatorId) {
setSelectedPropertyOwnerId(selectedOperatorId);
setValue('property_owner_id', selectedOperatorId);
}
}, [operatorIsOwner, selectedOperatorId, setValue]);
const handleFormSubmit = async (data: ParkFormData) => {
setIsSubmitting(true);
try {
// Pre-submission validation for required fields
const { valid, errors: validationErrors } = validateRequiredFields('park', data);
if (!valid) {
validationErrors.forEach(error => {
toast({
variant: 'destructive',
title: 'Missing Required Fields',
description: error
});
});
setIsSubmitting(false);
return;
}
// CRITICAL: Block new photo uploads on edits
if (isEditing && data.images?.uploaded) {
const hasNewPhotos = data.images.uploaded.some(img => img.isLocal);
if (hasNewPhotos) {
toast({
variant: 'destructive',
title: 'Validation Error',
description: 'New photos cannot be added during edits. Please remove new photos or use the photo gallery.'
});
return;
}
}
// Build composite submission if new entities were created
const submissionContent: import('@/types/composite-submission').ParkCompositeSubmission = {
park: data,
};
// Add new operator if created
if (tempNewOperator) {
submissionContent.new_operator = tempNewOperator;
submissionContent.park.operator_id = null;
// If operator is also owner, use same entity for both
if (operatorIsOwner) {
submissionContent.new_property_owner = tempNewOperator;
submissionContent.park.property_owner_id = null;
}
}
// Add new property owner if created (and not already set above)
if (tempNewPropertyOwner && !operatorIsOwner) {
submissionContent.new_property_owner = tempNewPropertyOwner;
submissionContent.park.property_owner_id = null;
}
// Determine final IDs to pass
// When creating new entities via composite submission, IDs should be undefined
// When using existing entities, pass their IDs directly
let finalOperatorId: string | undefined;
let finalPropertyOwnerId: string | undefined;
if (tempNewOperator) {
// New operator being created via composite submission
finalOperatorId = undefined;
finalPropertyOwnerId = operatorIsOwner ? undefined :
(tempNewPropertyOwner ? undefined : selectedPropertyOwnerId);
} else {
// Using existing operator
finalOperatorId = selectedOperatorId || undefined;
finalPropertyOwnerId = operatorIsOwner ? finalOperatorId :
(tempNewPropertyOwner ? undefined : selectedPropertyOwnerId);
}
// Debug: Log what's being submitted
const submissionData = {
...data,
operator_id: finalOperatorId,
property_owner_id: finalPropertyOwnerId,
_compositeSubmission: (tempNewOperator || tempNewPropertyOwner) ? submissionContent : undefined
};
console.info('[ParkForm] Submitting park data:', {
hasLocation: !!submissionData.location,
hasLocationId: !!submissionData.location_id,
locationData: submissionData.location,
parkName: submissionData.name,
isEditing
});
await onSubmit(submissionData);
// Parent component handles success feedback
} catch (error: unknown) {
const errorMessage = getErrorMessage(error);
handleError(error, {
action: isEditing ? 'Update Park' : 'Create Park',
userId: user?.id,
metadata: {
parkName: data.name,
hasLocation: !!data.location_id,
hasNewOperator: !!tempNewOperator,
hasNewOwner: !!tempNewPropertyOwner
}
});
// Re-throw so parent can handle modal closing
throw error;
} finally {
setIsSubmitting(false);
}
};
return (
<Card className="w-full max-w-4xl mx-auto">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MapPin className="w-5 h-5" />
{isEditing ? 'Edit Park' : 'Create New Park'}
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(handleFormSubmit)} 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">Park Name *</Label>
<Input
id="name"
{...register('name')}
placeholder="Enter park 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 park..."
rows={4}
/>
</div>
{/* Park Type and Status */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label>Park Type *</Label>
<Select onValueChange={(value) => setValue('park_type', value)} defaultValue={initialData?.park_type}>
<SelectTrigger>
<SelectValue placeholder="Select park type" />
</SelectTrigger>
<SelectContent>
{parkTypes.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.park_type && (
<p className="text-sm text-destructive">{errors.park_type.message}</p>
)}
</div>
<div className="space-y-2">
<Label>Status *</Label>
<Select
onValueChange={(value) => setValue('status', value)}
defaultValue={initialData?.status || 'operating'}
>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
{statusOptions.map((displayStatus) => {
const dbValue = STATUS_DISPLAY_TO_DB[displayStatus];
return (
<SelectItem key={dbValue} value={dbValue}>
{displayStatus}
</SelectItem>
);
})}
</SelectContent>
</Select>
{errors.status && (
<p className="text-sm text-destructive">{errors.status.message}</p>
)}
</div>
</div>
{/* Dates */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FlexibleDateInput
value={watch('opening_date') ? parseDateOnly(watch('opening_date')!) : undefined}
precision={(watch('opening_date_precision') as DatePrecision) || 'day'}
onChange={(date, precision) => {
setValue('opening_date', date ? toDateWithPrecision(date, precision) : undefined);
setValue('opening_date_precision', precision);
}}
label="Opening Date"
placeholder="Select opening date"
disableFuture={true}
fromYear={1800}
/>
<FlexibleDateInput
value={watch('closing_date') ? parseDateOnly(watch('closing_date')!) : undefined}
precision={(watch('closing_date_precision') as DatePrecision) || 'day'}
onChange={(date, precision) => {
setValue('closing_date', date ? toDateWithPrecision(date, precision) : undefined);
setValue('closing_date_precision', precision);
}}
label="Closing Date (if applicable)"
placeholder="Select closing date"
disablePast={false}
fromYear={1800}
/>
</div>
{/* Location */}
<div className="space-y-2">
<Label className="flex items-center gap-1">
Location
<span className="text-destructive">*</span>
</Label>
<LocationSearch
onLocationSelect={(location) => {
console.info('[ParkForm] Location selected:', location);
setValue('location', location);
console.info('[ParkForm] Location set in form:', watch('location'));
// Manually trigger validation for the location field
trigger('location');
}}
initialLocationId={watch('location_id')}
/>
{errors.location && (
<p className="text-sm text-destructive flex items-center gap-1">
<AlertCircle className="w-4 h-4" />
{errors.location.message}
</p>
)}
{!errors.location && (
<p className="text-sm text-muted-foreground">
Search for the park's location using OpenStreetMap. Location will be created when submission is approved.
</p>
)}
</div>
{/* Operator & Property Owner Selection */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">Operator & Property Owner</h3>
<div className="flex items-center space-x-2 mb-4">
<Checkbox
id="operator-is-owner"
checked={operatorIsOwner}
onCheckedChange={(checked) => {
setOperatorIsOwner(checked as boolean);
if (checked && selectedOperatorId) {
setSelectedPropertyOwnerId(selectedOperatorId);
setValue('property_owner_id', selectedOperatorId);
setTempNewPropertyOwner(null);
}
}}
/>
<Label htmlFor="operator-is-owner" className="text-sm font-normal cursor-pointer">
Operator is also the property owner
</Label>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Operator Column */}
<div className="space-y-2">
<Label>Park Operator</Label>
{tempNewOperator ? (
<div className="flex items-center gap-2 p-3 border rounded-md bg-blue-50 dark:bg-blue-950">
<Badge variant="secondary">New</Badge>
<span className="font-medium">{tempNewOperator.name}</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setTempNewOperator(null)}
>
<X className="w-4 h-4" />
</Button>
</div>
) : (
<Combobox
options={operators}
value={watch('operator_id') || ''}
onValueChange={(value) => {
const cleanValue = value || undefined;
setValue('operator_id', cleanValue);
setSelectedOperatorId(cleanValue || '');
}}
placeholder="Select operator"
searchPlaceholder="Search operators..."
emptyText="No operators found"
loading={operatorsLoading}
/>
)}
{!tempNewOperator && (
<Button
type="button"
variant="outline"
size="sm"
className="w-full"
onClick={() => setIsOperatorModalOpen(true)}
>
<Plus className="w-4 h-4 mr-2" />
Create New Operator
</Button>
)}
</div>
{/* Property Owner Column */}
{!operatorIsOwner && (
<div className="space-y-2">
<Label>Property Owner</Label>
{tempNewPropertyOwner ? (
<div className="flex items-center gap-2 p-3 border rounded-md bg-green-50 dark:bg-green-950">
<Badge variant="secondary">New</Badge>
<span className="font-medium">{tempNewPropertyOwner.name}</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setTempNewPropertyOwner(null)}
>
<X className="w-4 h-4" />
</Button>
</div>
) : (
<Combobox
options={propertyOwners}
value={watch('property_owner_id') || ''}
onValueChange={(value) => {
const cleanValue = value || undefined;
setValue('property_owner_id', cleanValue);
setSelectedPropertyOwnerId(cleanValue || '');
}}
placeholder="Select property owner"
searchPlaceholder="Search property owners..."
emptyText="No property owners found"
loading={ownersLoading}
/>
)}
{!tempNewPropertyOwner && (
<Button
type="button"
variant="outline"
size="sm"
className="w-full"
onClick={() => setIsPropertyOwnerModalOpen(true)}
>
<Plus className="w-4 h-4 mr-2" />
Create New Property Owner
</Button>
)}
</div>
)}
</div>
</div>
{/* Contact Information */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="space-y-2">
<Label htmlFor="website_url">Website URL</Label>
<Input
id="website_url"
type="url"
{...register('website_url')}
placeholder="https://..."
/>
{errors.website_url && (
<p className="text-sm text-destructive">{errors.website_url.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="phone">Phone Number</Label>
<Input
id="phone"
{...register('phone')}
placeholder="+1 (555) 123-4567"
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
{...register('email')}
placeholder="contact@park.com"
/>
{errors.email && (
<p className="text-sm text-destructive">{errors.email.message}</p>
)}
</div>
</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 phone call with park', 'Soft opening date not yet announced')"
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={isEditing ? 'edit' : 'create'}
value={watch('images') as ImageAssignments}
onChange={(images: ImageAssignments) => setValue('images', images)}
entityType="park"
entityId={isEditing ? initialData?.id : undefined}
currentBannerUrl={initialData?.banner_image_url}
currentCardUrl={initialData?.card_image_url}
/>
{/* Form Actions */}
<div className="flex gap-4 pt-6">
<Button
type="submit"
className="flex-1"
loading={isSubmitting}
loadingText="Saving..."
>
<Save className="w-4 h-4 mr-2" />
{isEditing ? 'Update Park' : 'Create Park'}
</Button>
{onCancel && (
<Button type="button" variant="outline" onClick={onCancel} disabled={isSubmitting}>
<X className="w-4 h-4 mr-2" />
Cancel
</Button>
)}
</div>
</form>
{/* Operator Modal */}
<Dialog open={isOperatorModalOpen} onOpenChange={setIsOperatorModalOpen}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<OperatorForm
initialData={tempNewOperator || undefined}
onSubmit={(data) => {
setTempNewOperator(data);
setIsOperatorModalOpen(false);
setValue('operator_id', 'temp-operator');
}}
onCancel={() => setIsOperatorModalOpen(false)}
/>
</DialogContent>
</Dialog>
{/* Property Owner Modal */}
<Dialog open={isPropertyOwnerModalOpen} onOpenChange={setIsPropertyOwnerModalOpen}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<PropertyOwnerForm
initialData={tempNewPropertyOwner || undefined}
onSubmit={(data) => {
setTempNewPropertyOwner(data);
setIsPropertyOwnerModalOpen(false);
setValue('property_owner_id', 'temp-property-owner');
}}
onCancel={() => setIsPropertyOwnerModalOpen(false)}
/>
</DialogContent>
</Dialog>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,125 @@
/**
* Pipeline Health Alerts Component
*
* Displays critical pipeline alerts on the admin error monitoring dashboard.
* Shows top 10 active alerts with severity-based styling and resolution actions.
*/
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { useSystemAlerts } from '@/hooks/useSystemHealth';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { AlertTriangle, CheckCircle, XCircle, AlertCircle } from 'lucide-react';
import { format } from 'date-fns';
import { supabase } from '@/lib/supabaseClient';
import { toast } from 'sonner';
const SEVERITY_CONFIG = {
critical: { color: 'destructive', icon: XCircle },
high: { color: 'destructive', icon: AlertCircle },
medium: { color: 'default', icon: AlertTriangle },
low: { color: 'secondary', icon: CheckCircle },
} as const;
const ALERT_TYPE_LABELS: Record<string, string> = {
failed_submissions: 'Failed Submissions',
high_ban_rate: 'High Ban Attempt Rate',
temp_ref_error: 'Temp Reference Error',
orphaned_images: 'Orphaned Images',
slow_approval: 'Slow Approvals',
submission_queue_backlog: 'Queue Backlog',
ban_attempt: 'Ban Attempt',
upload_timeout: 'Upload Timeout',
high_error_rate: 'High Error Rate',
validation_error: 'Validation Error',
stale_submissions: 'Stale Submissions',
circular_dependency: 'Circular Dependency',
rate_limit_violation: 'Rate Limit Violation',
};
export function PipelineHealthAlerts() {
const { data: criticalAlerts } = useSystemAlerts('critical');
const { data: highAlerts } = useSystemAlerts('high');
const { data: mediumAlerts } = useSystemAlerts('medium');
const allAlerts = [
...(criticalAlerts || []),
...(highAlerts || []),
...(mediumAlerts || [])
].slice(0, 10);
const resolveAlert = async (alertId: string) => {
const { error } = await supabase
.from('system_alerts')
.update({ resolved_at: new Date().toISOString() })
.eq('id', alertId);
if (error) {
toast.error('Failed to resolve alert');
} else {
toast.success('Alert resolved');
}
};
if (!allAlerts.length) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CheckCircle className="w-5 h-5 text-green-500" />
Pipeline Health: All Systems Operational
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">No active alerts. The sacred pipeline is flowing smoothly.</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>🚨 Active Pipeline Alerts</CardTitle>
<CardDescription>
Critical issues requiring attention ({allAlerts.length} active)
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{allAlerts.map((alert) => {
const config = SEVERITY_CONFIG[alert.severity];
const Icon = config.icon;
const label = ALERT_TYPE_LABELS[alert.alert_type] || alert.alert_type;
return (
<div
key={alert.id}
className="flex items-start justify-between p-3 border rounded-lg hover:bg-accent transition-colors"
>
<div className="flex items-start gap-3 flex-1">
<Icon className="w-5 h-5 mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Badge variant={config.color as any}>{alert.severity.toUpperCase()}</Badge>
<span className="text-sm font-medium">{label}</span>
</div>
<p className="text-sm text-muted-foreground">{alert.message}</p>
<p className="text-xs text-muted-foreground mt-1">
{format(new Date(alert.created_at), 'PPp')}
</p>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => resolveAlert(alert.id)}
>
Resolve
</Button>
</div>
);
})}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,114 @@
import { useState, useEffect } from 'react';
import { supabase } from '@/lib/supabaseClient';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Loader2 } from 'lucide-react';
import { format } from 'date-fns';
import { handleError } from '@/lib/errorHandler';
import { AuditLogEntry } from '@/types/database';
interface ProfileChangeField {
field_name: string;
old_value: string | null;
new_value: string | null;
}
interface ProfileAuditLogWithChanges extends Omit<AuditLogEntry, 'changes'> {
profile_change_fields?: ProfileChangeField[];
}
export function ProfileAuditLog(): React.JSX.Element {
const [logs, setLogs] = useState<ProfileAuditLogWithChanges[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
void fetchAuditLogs();
}, []);
const fetchAuditLogs = async (): Promise<void> => {
try {
const { data, error } = await supabase
.from('profile_audit_log')
.select(`
*,
profiles!user_id(username, display_name),
profile_change_fields(
field_name,
old_value,
new_value
)
`)
.order('created_at', { ascending: false })
.limit(50);
if (error) throw error;
setLogs((data || []) as ProfileAuditLogWithChanges[]);
} catch (error: unknown) {
handleError(error, { action: 'Load audit logs' });
} finally {
setLoading(false);
}
};
if (loading) {
return (
<Card>
<CardContent className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin" />
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>Profile Audit Log</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Action</TableHead>
<TableHead>Changes</TableHead>
<TableHead>Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{logs.map((log) => (
<TableRow key={log.id}>
<TableCell>
{(log as { profiles?: { display_name?: string; username?: string } }).profiles?.display_name || (log as { profiles?: { username?: string } }).profiles?.username || 'Unknown'}
</TableCell>
<TableCell>
<Badge variant="secondary">{log.action}</Badge>
</TableCell>
<TableCell>
{log.profile_change_fields && log.profile_change_fields.length > 0 ? (
<div className="space-y-1">
{log.profile_change_fields.map((change, idx) => (
<div key={idx} className="text-xs">
<span className="font-medium">{change.field_name}:</span>{' '}
<span className="text-muted-foreground">{change.old_value || 'null'}</span>
{' → '}
<span className="text-foreground">{change.new_value || 'null'}</span>
</div>
))}
</div>
) : (
<span className="text-xs text-muted-foreground">No changes</span>
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{format(new Date(log.created_at), 'PPpp')}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,306 @@
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 { useAuth } from '@/hooks/useAuth';
import { toast } from 'sonner';
import { handleError } from '@/lib/errorHandler';
import type { UploadedImage } from '@/types/company';
// Zod output type (after transformation)
type PropertyOwnerFormData = z.infer<typeof entitySchemas.property_owner>;
interface PropertyOwnerFormProps {
onSubmit: (data: PropertyOwnerFormData) => void;
onCancel: () => void;
initialData?: Partial<PropertyOwnerFormData & {
id?: string;
banner_image_url?: string;
card_image_url?: string;
}>;
}
export function PropertyOwnerForm({ onSubmit, onCancel, initialData }: PropertyOwnerFormProps): 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.property_owner),
defaultValues: {
name: initialData?.name || '',
slug: initialData?.slug || '',
company_type: 'property_owner' 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) : '',
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 Property Owner' : 'Create New Property Owner'}
</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: 'property_owner' as const,
founded_year: data.founded_year ? parseInt(String(data.founded_year)) : undefined,
founded_date: undefined,
founded_date_precision: 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('Property owner submitted for review');
onCancel();
}
} catch (error: unknown) {
handleError(error, {
action: initialData?.id ? 'Update Property Owner' : 'Create Property Owner',
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 property owner 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 property owner..."
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">
<div className="space-y-2">
<Label htmlFor="founded_year">Founded Year</Label>
<Input
id="founded_year"
type="number"
min="1800"
max={new Date().getFullYear()}
{...register('founded_year')}
placeholder="e.g. 1972"
/>
{errors.founded_year && (
<p className="text-sm text-destructive">{errors.founded_year.message}</p>
)}
</div>
<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="property_owner"
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 Property Owner' : 'Create Property Owner'}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,321 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { Button } from '@/components/ui/button';
import type { RideModelTechnicalSpec } from '@/types/database';
import { getErrorMessage } from '@/lib/errorHandler';
import { handleError } from '@/lib/errorHandler';
import { toast } from 'sonner';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { SlugField } from '@/components/ui/slug-field';
import { Layers, Save, X } from 'lucide-react';
import { useUserRole } from '@/hooks/useUserRole';
import { EntityMultiImageUploader, ImageAssignments } from '@/components/upload/EntityMultiImageUploader';
import { TechnicalSpecsEditor } from './editors/TechnicalSpecsEditor';
import { TechnicalSpecification } from '@/types/company';
const rideModelSchema = z.object({
name: z.string().min(1, 'Name is required'),
slug: z.string().min(1, 'Slug is required'),
category: z.string().min(1, 'Category is required'),
ride_type: z.string().min(1, 'Ride type is required'),
description: z.string().optional(),
source_url: z.string().url().optional().or(z.literal('')),
submission_notes: z.string().max(1000).optional().or(z.literal('')),
images: z.object({
uploaded: z.array(z.object({
url: z.string(),
cloudflare_id: z.string().optional(),
file: z.instanceof(File).optional(),
isLocal: z.boolean().optional(),
caption: z.string().optional()
})),
banner_assignment: z.number().nullable().optional(),
card_assignment: z.number().nullable().optional()
}).optional()
});
type RideModelFormData = z.infer<typeof rideModelSchema>;
interface RideModelFormProps {
manufacturerName: string;
manufacturerId?: string;
onSubmit: (data: RideModelFormData & { manufacturer_id?: string; _technical_specifications?: TechnicalSpecification[] }) => void;
onCancel: () => void;
initialData?: Partial<RideModelFormData & {
id?: string;
banner_image_url?: string;
card_image_url?: string;
}>;
}
const categories = [
'roller_coaster',
'flat_ride',
'water_ride',
'dark_ride',
'kiddie_ride',
'transportation'
];
export function RideModelForm({
manufacturerName,
manufacturerId,
onSubmit,
onCancel,
initialData
}: RideModelFormProps) {
const { isModerator } = useUserRole();
const [isSubmitting, setIsSubmitting] = useState(false);
const [technicalSpecs, setTechnicalSpecs] = useState<{
spec_name: string;
spec_value: string;
spec_type: 'string' | 'number' | 'boolean' | 'date';
category?: string;
unit?: string;
display_order: number;
}[]>([]);
const {
register,
handleSubmit,
setValue,
watch,
formState: { errors }
} = useForm<RideModelFormData>({
resolver: zodResolver(rideModelSchema),
defaultValues: {
name: initialData?.name || '',
slug: initialData?.slug || '',
category: initialData?.category || '',
ride_type: initialData?.ride_type || '',
description: initialData?.description || '',
source_url: initialData?.source_url || '',
submission_notes: initialData?.submission_notes || '',
images: initialData?.images || { uploaded: [] }
}
});
const handleFormSubmit = async (data: RideModelFormData) => {
setIsSubmitting(true);
try {
// Include relational technical specs with extended type
await onSubmit({
...data,
manufacturer_id: manufacturerId,
_technical_specifications: technicalSpecs
});
toast.success('Ride model submitted for review');
} catch (error: unknown) {
handleError(error, {
action: initialData?.id ? 'Update Ride Model' : 'Create Ride Model'
});
// Re-throw so parent can handle modal closing
throw error;
} finally {
setIsSubmitting(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Layers className="w-5 h-5" />
{initialData ? 'Edit Ride Model' : 'Create New Ride Model'}
</CardTitle>
<div className="flex items-center gap-2 mt-2">
<span className="text-sm text-muted-foreground">For manufacturer:</span>
<Badge variant="secondary">{manufacturerName}</Badge>
</div>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(handleFormSubmit)} 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">Model Name *</Label>
<Input
id="name"
{...register('name')}
placeholder="e.g. Mega Coaster, Sky Screamer"
/>
{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>
{/* Category and Type */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label>Category *</Label>
<Select
onValueChange={(value) => setValue('category', value)}
defaultValue={initialData?.category}
>
<SelectTrigger>
<SelectValue placeholder="Select category" />
</SelectTrigger>
<SelectContent>
{categories.map((category) => (
<SelectItem key={category} value={category}>
{category.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase())}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.category && (
<p className="text-sm text-destructive">{errors.category.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="ride_type">Ride Type *</Label>
<Input
id="ride_type"
{...register('ride_type')}
placeholder="e.g. Inverted, Wing Coaster, Pendulum"
/>
{errors.ride_type && (
<p className="text-sm text-destructive">{errors.ride_type.message}</p>
)}
</div>
</div>
{/* Description */}
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
{...register('description')}
placeholder="Describe the ride model features and characteristics..."
rows={3}
/>
</div>
{/* Technical Specs */}
<div className="border-t pt-6">
<TechnicalSpecsEditor
specs={technicalSpecs}
onChange={setTechnicalSpecs}
commonSpecs={[
'Typical Track Length',
'Typical Height',
'Typical Speed',
'Standard Train Configuration',
'Typical Capacity',
'Typical Duration'
]}
/>
<p className="text-xs text-muted-foreground mt-2">
General specifications for this model that apply to all installations
</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 manufacturer catalog', 'Model specifications 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="ride_model"
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" />
Save Model
</Button>
</div>
</form>
</CardContent>
</Card>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,506 @@
import { useState, useEffect } from 'react';
import { invokeWithTracking } from '@/lib/edgeFunctionTracking';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Progress } from '@/components/ui/progress';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
import { supabase } from '@/lib/supabaseClient';
import { useToast } from '@/hooks/use-toast';
import { getErrorMessage } from '@/lib/errorHandler';
import { Beaker, CheckCircle, ChevronDown, Trash2, AlertTriangle } from 'lucide-react';
import { clearTestData, getTestDataStats } from '@/lib/testDataGenerator';
import { TestDataTracker } from '@/lib/integrationTests/TestDataTracker';
import { handleNonCriticalError } from '@/lib/errorHandler';
import { useMFAStepUp } from '@/contexts/MFAStepUpContext';
import { isMFACancelledError } from '@/lib/aalErrorDetection';
const PRESETS = {
small: { label: 'Small', description: '~30 submissions - Quick test', counts: '5 parks, 10 rides, 3 companies, 2 models, 5 photo sets' },
medium: { label: 'Medium', description: '~125 submissions - Standard testing', counts: '20 parks, 50 rides, 20 companies, 10 models, 25 photo sets' },
large: { label: 'Large', description: '~600 submissions - Performance testing', counts: '100 parks, 250 rides, 100 companies, 50 models, 100 photo sets' },
stress: { label: 'Stress', description: '~2600 submissions - Load testing', counts: '400 parks, 1000 rides, 400 companies, 200 models, 500 photo sets' }
};
interface TestDataResults {
summary: {
operators: number;
property_owners: number;
manufacturers: number;
designers: number;
parks: number;
rides: number;
rideModels: number;
photos: number;
totalPhotoItems: number;
conflicts: number;
versionChains: number;
companies?: number;
};
time?: string;
}
export function TestDataGenerator(): React.JSX.Element {
const { toast } = useToast();
const { requireAAL2 } = useMFAStepUp();
const [preset, setPreset] = useState<'small' | 'medium' | 'large' | 'stress'>('small');
const [fieldDensity, setFieldDensity] = useState<'mixed' | 'minimal' | 'standard' | 'maximum'>('mixed');
const [entityTypes, setEntityTypes] = useState({
parks: true,
rides: true,
manufacturers: true,
operators: true,
property_owners: true,
designers: true,
ride_models: true,
photos: true
});
const [options, setOptions] = useState({
includeDependencies: true,
includeConflicts: false,
includeVersionChains: false,
includeEscalated: false,
includeExpiredLocks: false
});
const [loading, setLoading] = useState(false);
const [results, setResults] = useState<TestDataResults | null>(null);
const [stats, setStats] = useState<{
total: number;
pending: number;
approved: number;
operators: number;
property_owners: number;
manufacturers: number;
designers: number;
parks: number;
rides: number;
ride_models: number;
} | null>(null);
const selectedEntityTypes = Object.entries(entityTypes)
.filter(([, enabled]) => enabled)
.map(([type]) => type);
useEffect(() => {
void loadStats();
}, []);
const loadStats = async (): Promise<void> => {
try {
const data = await getTestDataStats();
setStats(data);
} catch (error: unknown) {
handleNonCriticalError(error, {
action: 'Load test data stats'
});
}
};
const handleGenerate = async (): Promise<void> => {
setLoading(true);
setResults(null);
try {
const stages = ['companies', 'parks', 'rides', 'photos'] as const;
const allResults = {
operators: 0,
property_owners: 0,
manufacturers: 0,
designers: 0,
parks: 0,
rides: 0,
rideModels: 0,
photos: 0,
totalPhotoItems: 0,
conflicts: 0,
versionChains: 0
};
for (let i = 0; i < stages.length; i++) {
const stage = stages[i];
toast({
title: `Stage ${i + 1}/${stages.length}`,
description: `Generating ${stage}...`,
});
const { data, error } = await invokeWithTracking(
'seed-test-data',
{
preset,
fieldDensity,
entityTypes: selectedEntityTypes,
stage,
...options
},
(await supabase.auth.getUser()).data.user?.id
);
if (error) throw error;
// Merge results
const summary = data.summary as Record<string, number>;
Object.keys(summary).forEach(key => {
if (allResults[key as keyof typeof allResults] !== undefined) {
allResults[key as keyof typeof allResults] += summary[key];
}
});
}
setResults({ summary: allResults });
toast({
title: "Test data generated",
description: `Successfully completed all stages`,
});
await loadStats();
} catch (error: unknown) {
toast({
title: "Generation failed",
description: getErrorMessage(error),
variant: "destructive",
});
} finally {
setLoading(false);
}
};
const handleClear = async (): Promise<void> => {
setLoading(true);
try {
// Wrap operation with AAL2 requirement
const { deleted } = await requireAAL2(
() => clearTestData(),
'Clearing test data requires additional verification'
);
await loadStats();
toast({
title: 'Test Data Cleared',
description: `Removed ${deleted} test submissions`
});
setResults(null);
} catch (error: unknown) {
// Only show error if it's NOT an MFA cancellation
if (!isMFACancelledError(error)) {
toast({
title: 'Clear Failed',
description: getErrorMessage(error),
variant: 'destructive'
});
}
} finally {
setLoading(false);
}
};
const handleEmergencyCleanup = async (): Promise<void> => {
setLoading(true);
try {
// Wrap operation with AAL2 requirement
const { deleted, errors } = await requireAAL2(
() => TestDataTracker.bulkCleanupAllTestData(),
'Emergency cleanup requires additional verification'
);
await loadStats();
toast({
title: 'Emergency Cleanup Complete',
description: `Deleted ${deleted} test records across all tables${errors > 0 ? `, ${errors} errors` : ''}`
});
setResults(null);
} catch (error: unknown) {
// Only show error if it's NOT an MFA cancellation
if (!isMFACancelledError(error)) {
toast({
title: 'Emergency Cleanup Failed',
description: getErrorMessage(error),
variant: 'destructive'
});
}
} finally {
setLoading(false);
}
};
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Beaker className="w-5 h-5" />
<CardTitle>Test Data Generator</CardTitle>
</div>
<CardDescription>
Generate comprehensive test submissions with varying field density and photo support
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
This will create test data in your database. All test submissions are marked and can be cleared later.
</AlertDescription>
</Alert>
{stats && (
<div className="space-y-2">
<div className="flex gap-4 text-sm text-muted-foreground">
<span>Total Test Data: {stats.total}</span>
<span>Pending: {stats.pending}</span>
<span>Approved: {stats.approved}</span>
</div>
{(stats.operators > 0 || stats.property_owners > 0 || stats.manufacturers > 0 ||
stats.designers > 0 || stats.parks > 0 || stats.rides > 0 || stats.ride_models > 0) && (
<Alert>
<AlertDescription>
<div className="text-sm">
<p className="font-medium mb-2">Available Test Dependencies:</p>
<ul className="space-y-1">
{stats.operators > 0 && <li> {stats.operators} test operator{stats.operators > 1 ? 's' : ''}</li>}
{stats.property_owners > 0 && <li> {stats.property_owners} test property owner{stats.property_owners > 1 ? 's' : ''}</li>}
{stats.manufacturers > 0 && <li> {stats.manufacturers} test manufacturer{stats.manufacturers > 1 ? 's' : ''}</li>}
{stats.designers > 0 && <li> {stats.designers} test designer{stats.designers > 1 ? 's' : ''}</li>}
{stats.parks > 0 && <li> {stats.parks} test park{stats.parks > 1 ? 's' : ''}</li>}
{stats.rides > 0 && <li> {stats.rides} test ride{stats.rides > 1 ? 's' : ''}</li>}
{stats.ride_models > 0 && <li> {stats.ride_models} test ride model{stats.ride_models > 1 ? 's' : ''}</li>}
</ul>
<p className="text-xs text-muted-foreground mt-2">
Enable "Include Dependencies" to link new entities to these existing test entities.
</p>
</div>
</AlertDescription>
</Alert>
)}
{/* Dependency warnings */}
{entityTypes.rides && stats.parks === 0 && !entityTypes.parks && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
<strong>Missing Dependency:</strong> You've selected "rides" but no parks exist.
Rides require parks to be created first. Either enable "parks" or generate parks separately first.
</AlertDescription>
</Alert>
)}
{entityTypes.ride_models && stats.manufacturers === 0 && !entityTypes.manufacturers && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
<strong>Missing Dependency:</strong> You've selected "ride models" but no manufacturers exist.
Ride models require manufacturers to be created first. Either enable "manufacturers" or generate manufacturers separately first.
</AlertDescription>
</Alert>
)}
{(entityTypes.parks || entityTypes.rides) &&
stats.operators === 0 && stats.property_owners === 0 &&
!entityTypes.operators && !entityTypes.property_owners && (
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
<strong>Optional Dependencies:</strong> Parks and rides can optionally link to operators and property owners.
Consider enabling "operators" and "property_owners" for more realistic test data with proper dependency chains.
</AlertDescription>
</Alert>
)}
</div>
)}
<div className="space-y-4">
<div>
<Label className="text-base font-semibold">Preset</Label>
<RadioGroup value={preset} onValueChange={(v: string) => setPreset(v as 'small' | 'medium' | 'large' | 'stress')} className="mt-2 space-y-3">
{Object.entries(PRESETS).map(([key, { label, description, counts }]) => (
<div key={key} className="flex items-start space-x-2">
<RadioGroupItem value={key} id={key} className="mt-1" />
<div className="flex-1">
<Label htmlFor={key} className="font-medium cursor-pointer">{label}</Label>
<p className="text-sm text-muted-foreground">{description}</p>
<p className="text-xs text-muted-foreground">{counts}</p>
</div>
</div>
))}
</RadioGroup>
</div>
<div>
<Label className="text-base font-semibold">Field Population Density</Label>
<p className="text-sm text-muted-foreground mt-1 mb-3">
Controls how many optional fields are populated in generated entities
</p>
<RadioGroup value={fieldDensity} onValueChange={(v: string) => setFieldDensity(v as 'mixed' | 'minimal' | 'standard' | 'maximum')} className="space-y-2">
<div className="flex items-center space-x-2">
<RadioGroupItem value="mixed" id="mixed" />
<Label htmlFor="mixed" className="cursor-pointer">
<span className="font-medium">Mixed</span> - Random levels (most realistic)
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="minimal" id="minimal" />
<Label htmlFor="minimal" className="cursor-pointer">
<span className="font-medium">Minimal</span> - Required fields only
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="standard" id="standard" />
<Label htmlFor="standard" className="cursor-pointer">
<span className="font-medium">Standard</span> - 50% optional fields
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="maximum" id="maximum" />
<Label htmlFor="maximum" className="cursor-pointer">
<span className="font-medium">Maximum</span> - All fields + technical data
</Label>
</div>
</RadioGroup>
</div>
<div>
<Label className="text-base font-semibold">Entity Types</Label>
<div className="mt-2 grid grid-cols-2 gap-3">
{Object.entries(entityTypes).map(([key, enabled]) => (
<div key={key} className="flex items-center space-x-2">
<Checkbox
id={key}
checked={enabled}
onCheckedChange={(checked) =>
setEntityTypes({ ...entityTypes, [key]: !!checked })
}
/>
<Label htmlFor={key} className="cursor-pointer capitalize">
{key.replace('_', ' ')}
</Label>
</div>
))}
</div>
</div>
<Collapsible>
<CollapsibleTrigger className="flex items-center gap-2 text-sm font-medium">
<ChevronDown className="w-4 h-4" />
Advanced Options
</CollapsibleTrigger>
<CollapsibleContent className="mt-3 space-y-3">
{Object.entries(options).map(([key, enabled]) => (
<div key={key} className="flex items-center space-x-2">
<Checkbox
id={key}
checked={enabled}
onCheckedChange={(checked) =>
setOptions({ ...options, [key]: !!checked })
}
/>
<Label htmlFor={key} className="cursor-pointer capitalize">
{key.replace(/([A-Z])/g, ' $1').toLowerCase()}
</Label>
</div>
))}
</CollapsibleContent>
</Collapsible>
</div>
{loading && (
<div className="space-y-2">
<Progress value={undefined} />
<p className="text-sm text-center text-muted-foreground">Generating test data...</p>
</div>
)}
{results && (
<Alert>
<CheckCircle className="h-4 w-4 text-green-500" />
<AlertDescription>
<div className="font-medium mb-2">Test Data Generated Successfully</div>
<ul className="text-sm space-y-1">
<li> Created {results.summary.parks} park submissions</li>
<li> Created {results.summary.rides} ride submissions</li>
<li> Created {results.summary.companies} company submissions</li>
<li> Created {results.summary.rideModels} ride model submissions</li>
{results.summary.photos > 0 && (
<li> Created {results.summary.photos} photo submissions ({results.summary.totalPhotoItems || 0} photos)</li>
)}
<li className="font-medium mt-2">Time taken: {results.time}s</li>
</ul>
</AlertDescription>
</Alert>
)}
<div className="flex gap-3">
<Button
onClick={handleGenerate}
loading={loading}
loadingText="Generating..."
disabled={selectedEntityTypes.length === 0}
>
<Beaker className="w-4 h-4 mr-2" />
Generate Test Data
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" disabled={loading}>
<Trash2 className="w-4 h-4 mr-2" />
Clear All Test Data
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Clear All Test Data?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete all test submissions marked with is_test_data: true. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleClear}>Clear Test Data</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" disabled={loading} className="border-destructive text-destructive hover:bg-destructive/10">
<AlertTriangle className="w-4 h-4 mr-2" />
Emergency Cleanup (All Tables)
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Emergency Cleanup - Delete ALL Test Data?</AlertDialogTitle>
<AlertDialogDescription className="space-y-2">
<p className="font-medium text-destructive"> This is a nuclear option!</p>
<p>This will delete ALL records marked with is_test_data: true from ALL entity tables, including:</p>
<ul className="list-disc list-inside text-sm space-y-1">
<li>Parks, Rides, Companies (operators, manufacturers, etc.)</li>
<li>Ride Models, Photos, Reviews</li>
<li>Entity Versions, Edit History</li>
<li>Moderation Queue submissions</li>
</ul>
<p className="font-medium">This goes far beyond the moderation queue and cannot be undone.</p>
<p className="text-sm">Only use this if normal cleanup fails or you need to completely reset test data.</p>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleEmergencyCleanup} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Delete All Test Data
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,30 @@
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ProfileManager } from '@/components/moderation/ProfileManager';
import { UserRoleManager } from '@/components/moderation/UserRoleManager';
import { Shield, UserCheck } from 'lucide-react';
export function UserManagement() {
return (
<div className="space-y-6">
<Tabs defaultValue="profiles" className="space-y-4">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="profiles" className="flex items-center gap-2">
<UserCheck className="w-4 h-4" />
Users
</TabsTrigger>
<TabsTrigger value="roles" className="flex items-center gap-2">
<Shield className="w-4 h-4" />
Roles
</TabsTrigger>
</TabsList>
<TabsContent value="profiles">
<ProfileManager />
</TabsContent>
<TabsContent value="roles">
<UserRoleManager />
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,196 @@
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Loader2, Trash2, CheckCircle, AlertCircle } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { supabase } from '@/lib/supabaseClient';
import { format } from 'date-fns';
import { handleNonCriticalError } from '@/lib/errorHandler';
export function VersionCleanupSettings() {
const [retentionDays, setRetentionDays] = useState(90);
const [lastCleanup, setLastCleanup] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isInitialLoad, setIsInitialLoad] = useState(true);
const { toast } = useToast();
useEffect(() => {
loadSettings();
}, []);
const loadSettings = async () => {
try {
const { data: retention, error: retentionError } = await supabase
.from('admin_settings')
.select('setting_value')
.eq('setting_key', 'version_retention_days')
.single();
if (retentionError) throw retentionError;
const { data: cleanup, error: cleanupError } = await supabase
.from('admin_settings')
.select('setting_value')
.eq('setting_key', 'last_version_cleanup')
.single();
if (cleanupError) throw cleanupError;
if (retention?.setting_value) {
const retentionValue = typeof retention.setting_value === 'string'
? retention.setting_value
: String(retention.setting_value);
setRetentionDays(Number(retentionValue));
}
if (cleanup?.setting_value && cleanup.setting_value !== 'null') {
const cleanupValue = typeof cleanup.setting_value === 'string'
? cleanup.setting_value.replace(/"/g, '')
: String(cleanup.setting_value);
setLastCleanup(cleanupValue);
}
} catch (error: unknown) {
handleNonCriticalError(error, {
action: 'Load version cleanup settings'
});
toast({
title: 'Error',
description: 'Failed to load cleanup settings',
variant: 'destructive',
});
} finally {
setIsInitialLoad(false);
}
};
const handleSaveRetention = async () => {
setIsSaving(true);
try {
const { error } = await supabase
.from('admin_settings')
.update({ setting_value: retentionDays.toString() })
.eq('setting_key', 'version_retention_days');
if (error) throw error;
toast({
title: 'Settings Saved',
description: 'Retention period updated successfully'
});
} catch (error) {
toast({
title: 'Save Failed',
description: error instanceof Error ? error.message : 'Failed to save settings',
variant: 'destructive'
});
} finally {
setIsSaving(false);
}
};
const handleManualCleanup = async () => {
setIsLoading(true);
try {
const { data, error } = await supabase.functions.invoke('cleanup-old-versions', {
body: { manual: true }
});
if (error) throw error;
toast({
title: 'Cleanup Complete',
description: data.message || `Deleted ${data.stats?.item_edit_history_deleted || 0} old versions`,
});
await loadSettings();
} catch (error) {
toast({
title: 'Cleanup Failed',
description: error instanceof Error ? error.message : 'Failed to run cleanup',
variant: 'destructive'
});
} finally {
setIsLoading(false);
}
};
if (isInitialLoad) {
return (
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>Version History Cleanup</CardTitle>
<CardDescription>
Manage automatic cleanup of old version history records
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="retention">Retention Period (days)</Label>
<div className="flex gap-2">
<Input
id="retention"
type="number"
min={30}
max={365}
value={retentionDays}
onChange={(e) => setRetentionDays(Number(e.target.value))}
className="w-32"
/>
<Button onClick={handleSaveRetention} loading={isSaving} loadingText="Saving...">
Save
</Button>
</div>
<p className="text-xs text-muted-foreground">
Keep most recent 10 versions per item, delete older ones beyond this period
</p>
</div>
{lastCleanup ? (
<Alert>
<CheckCircle className="h-4 w-4" />
<AlertDescription>
Last cleanup: {format(new Date(lastCleanup), 'PPpp')}
</AlertDescription>
</Alert>
) : (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
No cleanup has been performed yet
</AlertDescription>
</Alert>
)}
<div className="pt-4 border-t">
<Button
onClick={handleManualCleanup}
loading={isLoading}
loadingText="Running Cleanup..."
variant="outline"
className="w-full"
>
<Trash2 className="h-4 w-4 mr-2" />
Run Manual Cleanup Now
</Button>
<p className="text-xs text-muted-foreground mt-2 text-center">
Automatic cleanup runs every Sunday at 2 AM UTC
</p>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,299 @@
import { Plus, Trash2 } from "lucide-react";
import { useState } from "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 { Textarea } from "@/components/ui/textarea";
import { Card } from "@/components/ui/card";
import { useUnitPreferences } from "@/hooks/useUnitPreferences";
import { toast } from "sonner";
import {
convertValueToMetric,
convertValueFromMetric,
detectUnitType,
getMetricUnit,
getDisplayUnit
} from "@/lib/units";
import { validateMetricUnit } from "@/lib/unitValidation";
import { getErrorMessage } from "@/lib/errorHandler";
interface CoasterStat {
stat_name: string;
stat_value: number;
unit?: string;
category?: string;
description?: string;
display_order: number;
}
interface CoasterStatsEditorProps {
stats: CoasterStat[];
onChange: (stats: CoasterStat[]) => void;
categories?: string[];
}
const DEFAULT_CATEGORIES = ['Speed', 'Height', 'Length', 'Forces', 'Capacity', 'Duration', 'Other'];
const COMMON_STATS = [
{ name: 'Max Speed', unit: 'km/h', category: 'Speed' },
{ name: 'Max Height', unit: 'm', category: 'Height' },
{ name: 'Drop Height', unit: 'm', category: 'Height' },
{ name: 'Track Length', unit: 'm', category: 'Length' },
{ name: 'Max G-Force', unit: 'G', category: 'Forces' },
{ name: 'Max Negative G-Force', unit: 'G', category: 'Forces' },
{ name: 'Ride Duration', unit: 'seconds', category: 'Duration' },
{ name: 'Inversions', unit: 'count', category: 'Other' },
];
export function CoasterStatsEditor({
stats,
onChange,
categories = DEFAULT_CATEGORIES
}: CoasterStatsEditorProps) {
const { preferences } = useUnitPreferences();
const [unitErrors, setUnitErrors] = useState<Record<number, string>>({});
const addStat = () => {
onChange([
...stats,
{
stat_name: '',
stat_value: 0,
unit: '',
category: categories[0],
description: '',
display_order: stats.length
}
]);
};
const addCommonStat = (commonStat: typeof COMMON_STATS[0]) => {
onChange([
...stats,
{
stat_name: commonStat.name,
stat_value: 0,
unit: commonStat.unit,
category: commonStat.category,
description: '',
display_order: stats.length
}
]);
};
const removeStat = (index: number) => {
const newStats = stats.filter((_, i) => i !== index);
onChange(newStats.map((stat, i) => ({ ...stat, display_order: i })));
};
const updateStat = (index: number, field: keyof CoasterStat, value: string | number | boolean | null | undefined) => {
const newStats = [...stats];
// Ensure unit is metric when updating unit field
if (field === 'unit' && value && typeof value === 'string') {
try {
validateMetricUnit(value, 'Unit');
newStats[index] = { ...newStats[index], unit: value };
// Clear error for this index
setUnitErrors(prev => {
const updated = { ...prev };
delete updated[index];
return updated;
});
} catch (error: unknown) {
const message = getErrorMessage(error);
toast.error(message);
// Store error for visual feedback
setUnitErrors(prev => ({ ...prev, [index]: message }));
return;
}
} else {
newStats[index] = { ...newStats[index], [field]: value };
}
onChange(newStats);
};
// Get display value (convert from metric to user's preferred units)
const getDisplayValue = (stat: CoasterStat): string => {
if (!stat.stat_value || !stat.unit) return String(stat.stat_value || '');
const numValue = Number(stat.stat_value);
if (isNaN(numValue)) return String(stat.stat_value);
const unitType = detectUnitType(stat.unit);
if (unitType === 'unknown') return String(stat.stat_value);
// stat.unit is the metric unit (e.g., "km/h")
// Get the display unit based on user preference (e.g., "mph" for imperial)
const displayUnit = getDisplayUnit(stat.unit, preferences.measurement_system);
// Convert from metric to display unit
const displayValue = convertValueFromMetric(numValue, displayUnit, stat.unit);
return String(displayValue);
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label>Coaster Statistics</Label>
<div className="flex gap-2">
<Select onValueChange={(value) => {
const commonStat = COMMON_STATS.find(s => s.name === value);
if (commonStat) addCommonStat(commonStat);
}}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Add common stat..." />
</SelectTrigger>
<SelectContent>
{COMMON_STATS.map(stat => (
<SelectItem key={stat.name} value={stat.name}>
{stat.name} ({stat.unit})
</SelectItem>
))}
</SelectContent>
</Select>
<Button type="button" variant="outline" size="sm" onClick={addStat}>
<Plus className="h-4 w-4 mr-2" />
Add Custom
</Button>
</div>
</div>
{stats.length === 0 ? (
<Card className="p-6 text-center text-muted-foreground">
No statistics added yet. Add a common stat or create a custom one.
</Card>
) : (
<div className="space-y-3">
{stats.map((stat, index) => (
<Card key={index} className="p-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label className="text-xs">Statistic Name</Label>
<Input
value={stat.stat_name}
onChange={(e) => updateStat(index, 'stat_name', e.target.value)}
placeholder="e.g., Max Speed"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-xs">Value</Label>
<Input
type="number"
step="0.01"
value={getDisplayValue(stat)}
onChange={(e) => {
const inputValue = e.target.value;
const numValue = parseFloat(inputValue);
if (!isNaN(numValue) && stat.unit) {
// Determine what unit the user is entering (based on their preference)
const displayUnit = getDisplayUnit(stat.unit, preferences.measurement_system);
// Convert from user's input unit to metric for storage
const metricValue = convertValueToMetric(numValue, displayUnit);
updateStat(index, 'stat_value', metricValue);
} else {
updateStat(index, 'stat_value', numValue || 0);
}
}}
placeholder="0"
/>
{stat.unit && detectUnitType(stat.unit) !== 'unknown' && (
<p className="text-xs text-muted-foreground mt-1">
Enter in {getDisplayUnit(stat.unit, preferences.measurement_system)}
</p>
)}
</div>
<div>
<Label className="text-xs">Unit</Label>
<Input
value={stat.unit || ''}
onChange={(e) => updateStat(index, 'unit', e.target.value)}
placeholder="km/h, m, G..."
className={unitErrors[index] ? 'border-destructive' : ''}
/>
{unitErrors[index] && (
<p className="text-xs text-destructive mt-1">{unitErrors[index]}</p>
)}
<p className="text-xs text-muted-foreground mt-1">
Use metric units only: km/h, m, cm, kg, G, celsius
</p>
</div>
</div>
<div>
<Label className="text-xs">Category</Label>
<Select
value={stat.category || ''}
onValueChange={(value) => updateStat(index, 'category', value)}
>
<SelectTrigger>
<SelectValue placeholder="Select category" />
</SelectTrigger>
<SelectContent>
{categories.map(cat => (
<SelectItem key={cat} value={cat}>{cat}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-end">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeStat(index)}
className="ml-auto"
>
<Trash2 className="h-4 w-4 text-destructive mr-2" />
Remove
</Button>
</div>
<div className="md:col-span-2">
<Label className="text-xs">Description (optional)</Label>
<Textarea
value={stat.description || ''}
onChange={(e) => updateStat(index, 'description', e.target.value)}
placeholder="Additional context about this statistic..."
rows={2}
/>
</div>
</div>
</Card>
))}
</div>
)}
</div>
);
}
/**
* Validates coaster stats before submission
*/
export function validateCoasterStats(stats: CoasterStat[]): { valid: boolean; errors: string[] } {
const errors: string[] = [];
stats.forEach((stat, index) => {
if (!stat.stat_name?.trim()) {
errors.push(`Stat ${index + 1}: Name is required`);
}
if (stat.stat_value === null || stat.stat_value === undefined) {
errors.push(`Stat ${index + 1} (${stat.stat_name}): Value is required`);
}
if (stat.unit) {
try {
validateMetricUnit(stat.unit, `Stat ${index + 1} (${stat.stat_name})`);
} catch (error: unknown) {
errors.push(getErrorMessage(error));
}
}
});
return { valid: errors.length === 0, errors };
}

View File

@@ -0,0 +1,197 @@
import { Plus, Trash2, Calendar } from "lucide-react";
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 { Card } from "@/components/ui/card";
import { DatePicker } from "@/components/ui/date-picker";
interface FormerName {
former_name: string;
date_changed?: Date | null;
reason?: string;
from_year?: number;
to_year?: number;
order_index: number;
}
interface FormerNamesEditorProps {
names: FormerName[];
onChange: (names: FormerName[]) => void;
currentName: string;
}
export function FormerNamesEditor({ names, onChange, currentName }: FormerNamesEditorProps) {
const addName = () => {
onChange([
...names,
{
former_name: '',
date_changed: null,
reason: '',
order_index: names.length
}
]);
};
const removeName = (index: number) => {
const newNames = names.filter((_, i) => i !== index);
onChange(newNames.map((name, i) => ({ ...name, order_index: i })));
};
const updateName = (index: number, field: keyof FormerName, value: string | number | Date | null | undefined) => {
const newNames = [...names];
newNames[index] = { ...newNames[index], [field]: value };
onChange(newNames);
};
// Sort names by date_changed (most recent first) for display
const sortedNames = [...names].sort((a, b) => {
if (!a.date_changed && !b.date_changed) return 0;
if (!a.date_changed) return 1;
if (!b.date_changed) return -1;
return new Date(b.date_changed).getTime() - new Date(a.date_changed).getTime();
});
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label>Former Names</Label>
<p className="text-xs text-muted-foreground mt-1">
Current name: <span className="font-medium">{currentName}</span>
</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={addName}>
<Plus className="h-4 w-4 mr-2" />
Add Former Name
</Button>
</div>
{names.length === 0 ? (
<Card className="p-6 text-center text-muted-foreground">
No former names recorded. This entity has kept its original name.
</Card>
) : (
<div className="space-y-3">
{sortedNames.map((name, displayIndex) => {
const actualIndex = names.findIndex(n => n === name);
return (
<Card key={actualIndex} className="p-4">
<div className="space-y-4">
<div className="flex items-start gap-4">
<div className="flex-1 space-y-4">
<div>
<Label className="text-xs">Former Name</Label>
<Input
value={name.former_name}
onChange={(e) => updateName(actualIndex, 'former_name', e.target.value)}
placeholder="Enter former name..."
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
<Label className="text-xs">Date Changed</Label>
<DatePicker
date={name.date_changed ? new Date(name.date_changed) : undefined}
onSelect={(date) => updateName(actualIndex, 'date_changed', date || undefined)}
placeholder="When was the name changed?"
fromYear={1800}
toYear={new Date().getFullYear()}
/>
</div>
<div>
<Label className="text-xs">From Year (optional)</Label>
<Input
type="number"
value={name.from_year || ''}
onChange={(e) => updateName(actualIndex, 'from_year', e.target.value ? parseInt(e.target.value) : undefined)}
placeholder="Year"
min="1800"
max={new Date().getFullYear()}
/>
</div>
<div>
<Label className="text-xs">To Year (optional)</Label>
<Input
type="number"
value={name.to_year || ''}
onChange={(e) => updateName(actualIndex, 'to_year', e.target.value ? parseInt(e.target.value) : undefined)}
placeholder="Year"
min="1800"
max={new Date().getFullYear()}
/>
</div>
</div>
<div>
<Label className="text-xs">Reason for Change (optional)</Label>
<Textarea
value={name.reason || ''}
onChange={(e) => updateName(actualIndex, 'reason', e.target.value)}
placeholder="Why was the name changed?"
rows={2}
/>
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeName(actualIndex)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
{name.date_changed && (
<div className="flex items-center gap-2 text-xs text-muted-foreground border-t pt-2">
<Calendar className="h-3 w-3" />
<span>
Changed on {new Date(name.date_changed).toLocaleDateString()}
{name.from_year && name.to_year && ` (used from ${name.from_year} to ${name.to_year})`}
</span>
</div>
)}
</div>
</Card>
);
})}
</div>
)}
{names.length > 0 && (
<Card className="p-4 bg-muted/50">
<div className="text-sm">
<strong>Name Timeline:</strong>
<div className="mt-2 space-y-1">
{sortedNames
.filter(n => n.former_name)
.map((name, idx) => (
<div key={idx} className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-primary" />
<span className="font-medium">{name.former_name}</span>
{name.from_year && name.to_year && (
<span className="text-muted-foreground">
({name.from_year} - {name.to_year})
</span>
)}
</div>
))}
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-green-500" />
<span className="font-medium">{currentName}</span>
<span className="text-muted-foreground">(Current)</span>
</div>
</div>
</div>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,287 @@
import { Plus, Trash2 } from "lucide-react";
import { useState } from "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 { Card } from "@/components/ui/card";
import { useUnitPreferences } from "@/hooks/useUnitPreferences";
import { toast } from "sonner";
import {
convertValueToMetric,
convertValueFromMetric,
detectUnitType,
getMetricUnit,
getDisplayUnit
} from "@/lib/units";
import { validateMetricUnit, METRIC_UNITS } from "@/lib/unitValidation";
import { getErrorMessage } from "@/lib/errorHandler";
interface TechnicalSpec {
spec_name: string;
spec_value: string;
spec_type: 'string' | 'number' | 'boolean' | 'date';
category?: string;
unit?: string;
display_order: number;
}
interface TechnicalSpecsEditorProps {
specs: TechnicalSpec[];
onChange: (specs: TechnicalSpec[]) => void;
categories?: string[];
commonSpecs?: string[];
}
const DEFAULT_CATEGORIES = ['Performance', 'Safety', 'Design', 'Capacity', 'Technical', 'Other'];
export function TechnicalSpecsEditor({
specs,
onChange,
categories = DEFAULT_CATEGORIES,
commonSpecs = []
}: TechnicalSpecsEditorProps) {
const { preferences } = useUnitPreferences();
const [unitErrors, setUnitErrors] = useState<Record<number, string>>({});
const addSpec = () => {
onChange([
...specs,
{
spec_name: '',
spec_value: '',
spec_type: 'string',
category: categories[0],
unit: '',
display_order: specs.length
}
]);
};
const removeSpec = (index: number) => {
const newSpecs = specs.filter((_, i) => i !== index);
// Reorder display_order
onChange(newSpecs.map((spec, i) => ({ ...spec, display_order: i })));
};
const updateSpec = (index: number, field: keyof TechnicalSpec, value: string | number | boolean | null | undefined) => {
const newSpecs = [...specs];
// Ensure unit is metric when updating unit field
if (field === 'unit' && value && typeof value === 'string') {
try {
validateMetricUnit(value, 'Unit');
newSpecs[index] = { ...newSpecs[index], unit: value };
// Clear error for this index
setUnitErrors(prev => {
const updated = { ...prev };
delete updated[index];
return updated;
});
} catch (error: unknown) {
const message = getErrorMessage(error);
toast.error(message);
// Store error for visual feedback
setUnitErrors(prev => ({ ...prev, [index]: message }));
return;
}
} else {
newSpecs[index] = { ...newSpecs[index], [field]: value };
}
onChange(newSpecs);
};
// Get display value (convert from metric to user's preferred units)
const getDisplayValue = (spec: TechnicalSpec): string => {
if (!spec.spec_value || !spec.unit || spec.spec_type !== 'number') return spec.spec_value;
const numValue = parseFloat(spec.spec_value);
if (isNaN(numValue)) return spec.spec_value;
const unitType = detectUnitType(spec.unit);
if (unitType === 'unknown') return spec.spec_value;
// spec.unit is the metric unit (e.g., "km/h")
// Get the display unit based on user preference (e.g., "mph" for imperial)
const displayUnit = getDisplayUnit(spec.unit, preferences.measurement_system);
// Convert from metric to display unit
const displayValue = convertValueFromMetric(numValue, displayUnit, spec.unit);
return String(displayValue);
};
const moveSpec = (index: number, direction: 'up' | 'down') => {
if ((direction === 'up' && index === 0) || (direction === 'down' && index === specs.length - 1)) {
return;
}
const newSpecs = [...specs];
const swapIndex = direction === 'up' ? index - 1 : index + 1;
[newSpecs[index], newSpecs[swapIndex]] = [newSpecs[swapIndex], newSpecs[index]];
// Update display_order
newSpecs[index].display_order = index;
newSpecs[swapIndex].display_order = swapIndex;
onChange(newSpecs);
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label>Technical Specifications</Label>
<Button type="button" variant="outline" size="sm" onClick={addSpec}>
<Plus className="h-4 w-4 mr-2" />
Add Specification
</Button>
</div>
{specs.length === 0 ? (
<Card className="p-6 text-center text-muted-foreground">
No specifications added yet. Click "Add Specification" to get started.
</Card>
) : (
<div className="space-y-3">
{specs.map((spec, index) => (
<Card key={index} className="p-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-3">
<div className="lg:col-span-2">
<Label className="text-xs">Specification Name</Label>
<Input
value={spec.spec_name}
onChange={(e) => updateSpec(index, 'spec_name', e.target.value)}
placeholder="e.g., Track Material"
list={`common-specs-${index}`}
/>
{commonSpecs.length > 0 && (
<datalist id={`common-specs-${index}`}>
{commonSpecs.map(s => <option key={s} value={s} />)}
</datalist>
)}
</div>
<div>
<Label className="text-xs">Value</Label>
<Input
value={getDisplayValue(spec)}
onChange={(e) => {
const inputValue = e.target.value;
const numValue = parseFloat(inputValue);
// If type is number and unit is recognized, convert to metric for storage
if (spec.spec_type === 'number' && spec.unit && !isNaN(numValue)) {
// Determine what unit the user is entering (based on their preference)
const displayUnit = getDisplayUnit(spec.unit, preferences.measurement_system);
// Convert from user's input unit to metric for storage
const metricValue = convertValueToMetric(numValue, displayUnit);
updateSpec(index, 'spec_value', String(metricValue));
} else {
updateSpec(index, 'spec_value', inputValue);
}
}}
placeholder="Value"
type={spec.spec_type === 'number' ? 'number' : 'text'}
/>
{spec.spec_type === 'number' && spec.unit && detectUnitType(spec.unit) !== 'unknown' && (
<p className="text-xs text-muted-foreground mt-1">
Enter in {getDisplayUnit(spec.unit, preferences.measurement_system)}
</p>
)}
</div>
<div>
<Label className="text-xs">Type</Label>
<Select
value={spec.spec_type}
onValueChange={(value) => updateSpec(index, 'spec_type', value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="string">Text</SelectItem>
<SelectItem value="number">Number</SelectItem>
<SelectItem value="boolean">Yes/No</SelectItem>
<SelectItem value="date">Date</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs">Category</Label>
<Select
value={spec.category || ''}
onValueChange={(value) => updateSpec(index, 'category', value)}
>
<SelectTrigger>
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent>
{categories.map(cat => (
<SelectItem key={cat} value={cat}>{cat}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-end gap-2">
<div className="flex-1">
<Label className="text-xs">Unit</Label>
<Input
value={spec.unit || ''}
onChange={(e) => updateSpec(index, 'unit', e.target.value)}
placeholder="Unit"
list={`units-${index}`}
className={unitErrors[index] ? 'border-destructive' : ''}
/>
<datalist id={`units-${index}`}>
{METRIC_UNITS.map(u => <option key={u} value={u} />)}
</datalist>
{unitErrors[index] && (
<p className="text-xs text-destructive mt-1">{unitErrors[index]}</p>
)}
<p className="text-xs text-muted-foreground mt-1">
Metric units only
</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeSpec(index)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
</Card>
))}
</div>
)}
</div>
);
}
/**
* Validates technical specs before submission
*/
export function validateTechnicalSpecs(specs: TechnicalSpec[]): { valid: boolean; errors: string[] } {
const errors: string[] = [];
specs.forEach((spec, index) => {
if (!spec.spec_name?.trim()) {
errors.push(`Spec ${index + 1}: Name is required`);
}
if (!spec.spec_value?.trim()) {
errors.push(`Spec ${index + 1} (${spec.spec_name}): Value is required`);
}
if (spec.unit) {
try {
validateMetricUnit(spec.unit, `Spec ${index + 1} (${spec.spec_name})`);
} catch (error: unknown) {
errors.push(getErrorMessage(error));
}
}
});
return { valid: errors.length === 0, errors };
}

View File

@@ -0,0 +1,19 @@
// Admin components barrel exports
export { AdminPageLayout } from './AdminPageLayout';
export { ApprovalFailureModal } from './ApprovalFailureModal';
export { BanUserDialog } from './BanUserDialog';
export { DesignerForm } from './DesignerForm';
export { HeadquartersLocationInput } from './HeadquartersLocationInput';
export { LocationSearch } from './LocationSearch';
export { ManufacturerForm } from './ManufacturerForm';
export { MarkdownEditor } from './MarkdownEditor';
export { NovuMigrationUtility } from './NovuMigrationUtility';
export { OperatorForm } from './OperatorForm';
export { ParkForm } from './ParkForm';
export { ProfileAuditLog } from './ProfileAuditLog';
export { PropertyOwnerForm } from './PropertyOwnerForm';
export { RideForm } from './RideForm';
export { RideModelForm } from './RideModelForm';
export { SystemActivityLog } from './SystemActivityLog';
export { TestDataGenerator } from './TestDataGenerator';
export { UserManagement } from './UserManagement';

View File

@@ -0,0 +1,61 @@
/**
* UI components for Park and Designer creation within RideForm
* Extracted for clarity - import these into RideForm.tsx
*/
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Plus, Building2, X } from 'lucide-react';
import type { TempParkData, TempCompanyData } from '@/types/company';
interface ParkSelectorProps {
tempNewPark: TempParkData | null;
onCreateNew: () => void;
onEdit: () => void;
onRemove: () => void;
parkId?: string;
onParkChange: (id: string) => void;
}
interface DesignerSelectorProps {
tempNewDesigner: TempCompanyData | null;
onCreateNew: () => void;
onEdit: () => void;
onRemove: () => void;
designerId?: string;
onDesignerChange: (id: string) => void;
}
export function RideParkSelector({ tempNewPark, onCreateNew, onEdit, onRemove }: ParkSelectorProps) {
return tempNewPark ? (
<div className="space-y-2">
<Badge variant="secondary" className="gap-2">
<Building2 className="h-3 w-3" />
New: {tempNewPark.name}
<button type="button" onClick={onRemove} className="ml-1 hover:text-destructive">×</button>
</Badge>
<Button type="button" variant="outline" size="sm" onClick={onEdit}>Edit New Park</Button>
</div>
) : (
<Button type="button" variant="outline" size="sm" onClick={onCreateNew}>
<Plus className="h-4 w-4 mr-2" />Create New Park
</Button>
);
}
export function RideDesignerSelector({ tempNewDesigner, onCreateNew, onEdit, onRemove }: DesignerSelectorProps) {
return tempNewDesigner ? (
<div className="space-y-2">
<Badge variant="secondary" className="gap-2">
<Building2 className="h-3 w-3" />
New: {tempNewDesigner.name}
<button type="button" onClick={onRemove} className="ml-1 hover:text-destructive">×</button>
</Badge>
<Button type="button" variant="outline" size="sm" onClick={onEdit}>Edit New Designer</Button>
</div>
) : (
<Button type="button" variant="outline" size="sm" onClick={onCreateNew}>
<Plus className="h-4 w-4 mr-2" />Create New Designer
</Button>
);
}