mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-22 14:51:13 -05:00
Refactor code structure and remove redundant changes
This commit is contained in:
126
src-old/components/admin/AdminPageLayout.tsx
Normal file
126
src-old/components/admin/AdminPageLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
372
src-old/components/admin/AdminUserDeletionDialog.tsx
Normal file
372
src-old/components/admin/AdminUserDeletionDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
202
src-old/components/admin/ApprovalFailureModal.tsx
Normal file
202
src-old/components/admin/ApprovalFailureModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
312
src-old/components/admin/BanUserDialog.tsx
Normal file
312
src-old/components/admin/BanUserDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
306
src-old/components/admin/DesignerForm.tsx
Normal file
306
src-old/components/admin/DesignerForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
177
src-old/components/admin/ErrorAnalytics.tsx
Normal file
177
src-old/components/admin/ErrorAnalytics.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
235
src-old/components/admin/ErrorDetailsModal.tsx
Normal file
235
src-old/components/admin/ErrorDetailsModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
192
src-old/components/admin/HeadquartersLocationInput.tsx
Normal file
192
src-old/components/admin/HeadquartersLocationInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
284
src-old/components/admin/IntegrationTestRunner.tsx
Normal file
284
src-old/components/admin/IntegrationTestRunner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
312
src-old/components/admin/LocationSearch.tsx
Normal file
312
src-old/components/admin/LocationSearch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
310
src-old/components/admin/ManufacturerForm.tsx
Normal file
310
src-old/components/admin/ManufacturerForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
239
src-old/components/admin/MarkdownEditor.tsx
Normal file
239
src-old/components/admin/MarkdownEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
src-old/components/admin/MarkdownEditorLazy.tsx
Normal file
23
src-old/components/admin/MarkdownEditorLazy.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
219
src-old/components/admin/NotificationDebugPanel.tsx
Normal file
219
src-old/components/admin/NotificationDebugPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
166
src-old/components/admin/NovuMigrationUtility.tsx
Normal file
166
src-old/components/admin/NovuMigrationUtility.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
306
src-old/components/admin/OperatorForm.tsx
Normal file
306
src-old/components/admin/OperatorForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
740
src-old/components/admin/ParkForm.tsx
Normal file
740
src-old/components/admin/ParkForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
125
src-old/components/admin/PipelineHealthAlerts.tsx
Normal file
125
src-old/components/admin/PipelineHealthAlerts.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
src-old/components/admin/ProfileAuditLog.tsx
Normal file
114
src-old/components/admin/ProfileAuditLog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
306
src-old/components/admin/PropertyOwnerForm.tsx
Normal file
306
src-old/components/admin/PropertyOwnerForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1578
src-old/components/admin/RideForm.tsx
Normal file
1578
src-old/components/admin/RideForm.tsx
Normal file
File diff suppressed because it is too large
Load Diff
321
src-old/components/admin/RideModelForm.tsx
Normal file
321
src-old/components/admin/RideModelForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1010
src-old/components/admin/SystemActivityLog.tsx
Normal file
1010
src-old/components/admin/SystemActivityLog.tsx
Normal file
File diff suppressed because it is too large
Load Diff
506
src-old/components/admin/TestDataGenerator.tsx
Normal file
506
src-old/components/admin/TestDataGenerator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
src-old/components/admin/UserManagement.tsx
Normal file
30
src-old/components/admin/UserManagement.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
196
src-old/components/admin/VersionCleanupSettings.tsx
Normal file
196
src-old/components/admin/VersionCleanupSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
299
src-old/components/admin/editors/CoasterStatsEditor.tsx
Normal file
299
src-old/components/admin/editors/CoasterStatsEditor.tsx
Normal 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 };
|
||||
}
|
||||
197
src-old/components/admin/editors/FormerNamesEditor.tsx
Normal file
197
src-old/components/admin/editors/FormerNamesEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
287
src-old/components/admin/editors/TechnicalSpecsEditor.tsx
Normal file
287
src-old/components/admin/editors/TechnicalSpecsEditor.tsx
Normal 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 };
|
||||
}
|
||||
19
src-old/components/admin/index.ts
Normal file
19
src-old/components/admin/index.ts
Normal 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';
|
||||
61
src-old/components/admin/ride-form-park-designer-ui.tsx
Normal file
61
src-old/components/admin/ride-form-park-designer-ui.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user