mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 08:11:13 -05:00
761 lines
25 KiB
TypeScript
761 lines
25 KiB
TypeScript
import type { SubmissionItemData } from '@/types/submissions';
|
|
import type {
|
|
ParkSubmissionData,
|
|
RideSubmissionData,
|
|
CompanySubmissionData,
|
|
RideModelSubmissionData
|
|
} from '@/types/submission-data';
|
|
import { supabase } from '@/integrations/supabase/client';
|
|
|
|
type SubmissionDataTypes =
|
|
| ParkSubmissionData
|
|
| RideSubmissionData
|
|
| CompanySubmissionData
|
|
| RideModelSubmissionData;
|
|
|
|
export interface FieldChange {
|
|
field: string;
|
|
oldValue: any;
|
|
newValue: any;
|
|
changeType: 'added' | 'removed' | 'modified';
|
|
metadata?: {
|
|
isCreatingNewLocation?: boolean;
|
|
precision?: 'day' | 'month' | 'year';
|
|
oldPrecision?: 'day' | 'month' | 'year';
|
|
newPrecision?: 'day' | 'month' | 'year';
|
|
};
|
|
}
|
|
|
|
export interface ImageChange {
|
|
type: 'banner' | 'card';
|
|
oldUrl?: string;
|
|
newUrl?: string;
|
|
oldId?: string;
|
|
newId?: string;
|
|
}
|
|
|
|
export interface PhotoChange {
|
|
type: 'added' | 'edited' | 'deleted';
|
|
photos?: Array<{ url: string; title?: string; caption?: string }>;
|
|
photo?: {
|
|
url: string;
|
|
title?: string;
|
|
caption?: string;
|
|
oldCaption?: string;
|
|
newCaption?: string;
|
|
oldTitle?: string;
|
|
newTitle?: string;
|
|
entity_type?: string;
|
|
entity_name?: string;
|
|
deletion_reason?: string;
|
|
};
|
|
}
|
|
|
|
export interface ChangesSummary {
|
|
action: 'create' | 'edit' | 'delete';
|
|
entityType: string;
|
|
entityName?: string;
|
|
fieldChanges: FieldChange[];
|
|
imageChanges: ImageChange[];
|
|
photoChanges: PhotoChange[];
|
|
hasLocationChange: boolean;
|
|
totalChanges: number;
|
|
}
|
|
|
|
/**
|
|
* Detects photo changes for a submission
|
|
*/
|
|
async function detectPhotoChanges(submissionId: string): Promise<PhotoChange[]> {
|
|
const changes: PhotoChange[] = [];
|
|
|
|
try {
|
|
// First check for photo submission items (photo additions)
|
|
const { data: photoSubmissions, error: photoError } = await supabase
|
|
.from('photo_submissions')
|
|
.select(`
|
|
*,
|
|
items:photo_submission_items(*)
|
|
`)
|
|
.eq('submission_id', submissionId);
|
|
|
|
if (photoError) {
|
|
console.error('Error fetching photo submissions:', photoError);
|
|
} else {
|
|
const photoSubmission = photoSubmissions?.[0];
|
|
if (photoSubmission?.items && photoSubmission.items.length > 0) {
|
|
changes.push({
|
|
type: 'added',
|
|
photos: photoSubmission.items.map((item: any) => ({
|
|
url: item.cloudflare_image_url,
|
|
title: item.title,
|
|
caption: item.caption
|
|
}))
|
|
});
|
|
}
|
|
}
|
|
|
|
// Check for photo edits and deletions in submission_items
|
|
const { data: submissionItems, error: itemsError } = await supabase
|
|
.from('submission_items')
|
|
.select('*')
|
|
.eq('submission_id', submissionId)
|
|
.in('item_type', ['photo_edit', 'photo_delete']);
|
|
|
|
if (itemsError) {
|
|
console.error('Error fetching submission items for photos:', itemsError);
|
|
} else if (submissionItems && submissionItems.length > 0) {
|
|
for (const item of submissionItems) {
|
|
const itemData = item.item_data as Record<string, any>;
|
|
const originalData = item.original_data as Record<string, any> | null;
|
|
|
|
if (item.item_type === 'photo_delete' && itemData) {
|
|
changes.push({
|
|
type: 'deleted',
|
|
photo: {
|
|
url: itemData.cloudflare_image_url || itemData.photo_url || '',
|
|
title: itemData.title,
|
|
caption: itemData.caption,
|
|
entity_type: itemData.entity_type,
|
|
entity_name: itemData.entity_name,
|
|
deletion_reason: itemData.deletion_reason || itemData.reason
|
|
}
|
|
});
|
|
} else if (item.item_type === 'photo_edit' && itemData && originalData) {
|
|
changes.push({
|
|
type: 'edited',
|
|
photo: {
|
|
url: itemData.photo_url || itemData.cloudflare_image_url || '',
|
|
title: itemData.title,
|
|
caption: itemData.caption,
|
|
oldTitle: originalData.title,
|
|
oldCaption: originalData.caption
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('Error detecting photo changes:', err);
|
|
}
|
|
|
|
return changes;
|
|
}
|
|
|
|
/**
|
|
* Detects what changed between original_data and item_data
|
|
*/
|
|
export async function detectChanges(
|
|
item: { item_data?: any; original_data?: any; item_type: string; action_type?: string },
|
|
submissionId?: string
|
|
): Promise<ChangesSummary> {
|
|
const itemData = item.item_data || {};
|
|
const originalData = item.original_data || {};
|
|
|
|
// Determine action type - prioritize explicit action_type field to preserve submission intent
|
|
let action: 'create' | 'edit' | 'delete' = 'edit';
|
|
|
|
if (item.item_type === 'photo_delete' || itemData.action === 'delete' || itemData.deleted) {
|
|
action = 'delete';
|
|
} else if (item.action_type) {
|
|
// Use explicit action_type if set (preserves original submission intent even after moderator edits)
|
|
action = item.action_type as 'create' | 'edit' | 'delete';
|
|
} else if (!originalData || Object.keys(originalData).length === 0) {
|
|
// Fall back to inference for backwards compatibility
|
|
action = 'create';
|
|
}
|
|
|
|
const fieldChanges: FieldChange[] = [];
|
|
const imageChanges: ImageChange[] = [];
|
|
let hasLocationChange = false;
|
|
|
|
if (action === 'create') {
|
|
// Check if this creation was edited by a moderator
|
|
const hasModeratorEdits = originalData && Object.keys(originalData).length > 0;
|
|
|
|
if (hasModeratorEdits) {
|
|
// Compare item_data with original_data to detect moderator changes
|
|
const allKeys = new Set([
|
|
...Object.keys(itemData),
|
|
...Object.keys(originalData)
|
|
]);
|
|
|
|
allKeys.forEach(key => {
|
|
if (!shouldTrackField(key)) return;
|
|
|
|
const oldValue = originalData[key];
|
|
const newValue = itemData[key];
|
|
|
|
// Skip if both are empty
|
|
const oldEmpty = oldValue === null || oldValue === undefined || oldValue === '';
|
|
const newEmpty = newValue === null || newValue === undefined || newValue === '';
|
|
|
|
if (oldEmpty && newEmpty) return;
|
|
|
|
// Detect the type of change
|
|
if (!isEqual(oldValue, newValue)) {
|
|
fieldChanges.push({
|
|
field: key,
|
|
oldValue,
|
|
newValue,
|
|
changeType: oldEmpty && !newEmpty ? 'added' : // Moderator added new field
|
|
newEmpty && !oldEmpty ? 'removed' : // Moderator removed field
|
|
'modified', // Moderator changed value
|
|
});
|
|
} else if (!newEmpty) {
|
|
// Field unchanged - show as 'added' (part of original submission)
|
|
fieldChanges.push({
|
|
field: key,
|
|
oldValue: null,
|
|
newValue,
|
|
changeType: 'added',
|
|
});
|
|
}
|
|
});
|
|
} else {
|
|
// No moderator edits - show all fields as 'added' (original behavior)
|
|
Object.entries(itemData).forEach(([key, value]) => {
|
|
const systemFields = ['id', 'created_at', 'updated_at', 'slug', 'images', 'image_assignments'];
|
|
const shouldShow = !systemFields.includes(key) && value !== null && value !== undefined && value !== '';
|
|
|
|
if (shouldShow) {
|
|
fieldChanges.push({
|
|
field: key,
|
|
oldValue: null,
|
|
newValue: value,
|
|
changeType: 'added',
|
|
});
|
|
}
|
|
});
|
|
}
|
|
} else if (action === 'edit') {
|
|
// Compare each field
|
|
const allKeys = new Set([
|
|
...Object.keys(itemData),
|
|
...Object.keys(originalData)
|
|
]);
|
|
|
|
allKeys.forEach(key => {
|
|
if (!shouldTrackField(key)) return;
|
|
|
|
const oldValue = originalData[key];
|
|
const newValue = itemData[key];
|
|
|
|
// Handle location changes specially - compare objects not IDs
|
|
if (key === 'location' || key === 'location_id') {
|
|
// Skip location_id if we already have a location object
|
|
if (key === 'location_id' && itemData.location) {
|
|
return;
|
|
}
|
|
|
|
const oldLoc = originalData.location;
|
|
const newLoc = itemData.location;
|
|
|
|
// Check if new location entity is being created (old has location_id, new has location object)
|
|
const isCreatingNewLocation = originalData.location_id && newLoc && typeof newLoc === 'object' && !oldLoc;
|
|
|
|
// Only compare if we have location objects with actual data
|
|
if (newLoc && typeof newLoc === 'object' && oldLoc && typeof oldLoc === 'object') {
|
|
// Compare all location data including coordinates
|
|
const locChanged =
|
|
oldLoc.city !== newLoc.city ||
|
|
oldLoc.state_province !== newLoc.state_province ||
|
|
oldLoc.country !== newLoc.country ||
|
|
oldLoc.postal_code !== newLoc.postal_code ||
|
|
Number(oldLoc.latitude) !== Number(newLoc.latitude) ||
|
|
Number(oldLoc.longitude) !== Number(newLoc.longitude);
|
|
|
|
if (locChanged) {
|
|
hasLocationChange = true;
|
|
fieldChanges.push({
|
|
field: 'location',
|
|
oldValue: oldLoc,
|
|
newValue: newLoc,
|
|
changeType: 'modified',
|
|
});
|
|
}
|
|
} else if (isCreatingNewLocation) {
|
|
// New location entity is being created - mark as location change
|
|
hasLocationChange = true;
|
|
fieldChanges.push({
|
|
field: 'location',
|
|
oldValue: { location_id: originalData.location_id },
|
|
newValue: newLoc,
|
|
changeType: 'modified',
|
|
metadata: { isCreatingNewLocation: true },
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Skip if both are "empty" (null, undefined, or empty string)
|
|
const oldEmpty = oldValue === null || oldValue === undefined || oldValue === '';
|
|
const newEmpty = newValue === null || newValue === undefined || newValue === '';
|
|
|
|
if (oldEmpty && newEmpty) {
|
|
return; // Both empty, no change
|
|
}
|
|
|
|
// Check for changes
|
|
if (!isEqual(oldValue, newValue)) {
|
|
const fieldChange: FieldChange = {
|
|
field: key,
|
|
oldValue,
|
|
newValue,
|
|
changeType: oldEmpty && !newEmpty ? 'added' :
|
|
newEmpty && !oldEmpty ? 'removed' :
|
|
'modified',
|
|
};
|
|
|
|
// Add precision metadata for date fields
|
|
if (key.endsWith('_date') && !key.endsWith('_precision')) {
|
|
const precisionKey = `${key}_precision`;
|
|
const newPrecision = itemData[precisionKey];
|
|
const oldPrecision = originalData[precisionKey];
|
|
|
|
if (newPrecision || oldPrecision) {
|
|
fieldChange.metadata = {
|
|
...fieldChange.metadata,
|
|
precision: newPrecision || oldPrecision,
|
|
oldPrecision,
|
|
newPrecision,
|
|
};
|
|
}
|
|
}
|
|
|
|
fieldChanges.push(fieldChange);
|
|
}
|
|
});
|
|
|
|
// Detect image changes
|
|
detectImageChanges(originalData, itemData, imageChanges);
|
|
}
|
|
|
|
// Get entity name - handle different item types
|
|
let entityName = 'Unknown';
|
|
if (item.item_type === 'photo_delete' || item.item_type === 'photo_edit' || item.item_type === 'photo') {
|
|
// For photo operations, prioritize entity_name from item_data
|
|
entityName = itemData.entity_name || itemData.caption || itemData.title || 'Photo';
|
|
|
|
// If we have entity_type and entity_id but no entity_name, fetch it from DB
|
|
if (!itemData.entity_name && itemData.entity_type && itemData.entity_id) {
|
|
try {
|
|
const entityType = itemData.entity_type;
|
|
const entityId = itemData.entity_id;
|
|
|
|
if (entityType === 'park') {
|
|
const { data } = await supabase.from('parks').select('name').eq('id', entityId).maybeSingle();
|
|
if (data?.name) entityName = `${data.name} (${formatEntityType(entityType)})`;
|
|
} else if (entityType === 'ride') {
|
|
const { data } = await supabase.from('rides').select('name').eq('id', entityId).maybeSingle();
|
|
if (data?.name) entityName = `${data.name} (${formatEntityType(entityType)})`;
|
|
} else if (entityType === 'ride_model') {
|
|
const { data } = await supabase.from('ride_models').select('name').eq('id', entityId).maybeSingle();
|
|
if (data?.name) entityName = `${data.name} (${formatEntityType(entityType)})`;
|
|
} else if (['manufacturer', 'operator', 'designer', 'property_owner'].includes(entityType)) {
|
|
const { data } = await supabase.from('companies').select('name').eq('id', entityId).maybeSingle();
|
|
if (data?.name) entityName = `${data.name} (${formatEntityType(entityType)})`;
|
|
}
|
|
} catch (err) {
|
|
console.error('Error fetching entity name for photo operation:', err);
|
|
}
|
|
}
|
|
|
|
// Add debugging warning if critical data is missing
|
|
if (!itemData.entity_name && item.item_type === 'photo_delete') {
|
|
console.warn(`[Photo Delete] Missing entity_name for photo_delete item`, {
|
|
item_type: item.item_type,
|
|
has_entity_type: !!itemData.entity_type,
|
|
has_entity_id: !!itemData.entity_id,
|
|
has_cloudflare_url: !!itemData.cloudflare_image_url
|
|
});
|
|
}
|
|
} else if (item.item_type === 'milestone') {
|
|
// Milestone submissions reference entity_id and entity_type
|
|
// Need to fetch the entity name from the database
|
|
if (itemData.entity_type && itemData.entity_id) {
|
|
try {
|
|
const entityType = itemData.entity_type;
|
|
const entityId = itemData.entity_id;
|
|
|
|
if (entityType === 'park') {
|
|
const { data } = await supabase.from('parks').select('name').eq('id', entityId).maybeSingle();
|
|
if (data?.name) {
|
|
entityName = `${data.name} - ${itemData.title || 'Milestone'}`;
|
|
}
|
|
} else if (entityType === 'ride') {
|
|
const { data: rideData } = await supabase
|
|
.from('rides')
|
|
.select('name, park:parks(name)')
|
|
.eq('id', entityId)
|
|
.maybeSingle();
|
|
|
|
if (rideData?.name) {
|
|
const parkName = rideData.park?.name;
|
|
entityName = parkName
|
|
? `${rideData.name} at ${parkName} - ${itemData.title || 'Milestone'}`
|
|
: `${rideData.name} - ${itemData.title || 'Milestone'}`;
|
|
}
|
|
}
|
|
|
|
// If lookup failed, fall back to title with entity type
|
|
if (entityName === 'Unknown' && itemData.title) {
|
|
entityName = `${formatEntityType(entityType)} - ${itemData.title}`;
|
|
}
|
|
} catch (err) {
|
|
console.error('Error fetching entity name for milestone:', err);
|
|
// Fall back to just the title if database lookup fails
|
|
if (itemData.title) {
|
|
entityName = itemData.title;
|
|
}
|
|
}
|
|
} else if (itemData.title) {
|
|
// No entity reference, just use the milestone title
|
|
entityName = itemData.title;
|
|
}
|
|
|
|
// Add resolved entity name as an explicit field for milestone submissions
|
|
if (itemData.entity_type && itemData.entity_id) {
|
|
let resolvedEntityName = 'Unknown Entity';
|
|
|
|
try {
|
|
const entityType = itemData.entity_type;
|
|
const entityId = itemData.entity_id;
|
|
|
|
if (entityType === 'park') {
|
|
const { data } = await supabase.from('parks').select('name').eq('id', entityId).maybeSingle();
|
|
if (data?.name) {
|
|
resolvedEntityName = data.name;
|
|
}
|
|
} else if (entityType === 'ride') {
|
|
const { data: rideData } = await supabase
|
|
.from('rides')
|
|
.select('name, park:parks(name)')
|
|
.eq('id', entityId)
|
|
.maybeSingle();
|
|
|
|
if (rideData?.name) {
|
|
const parkName = rideData.park?.name;
|
|
resolvedEntityName = parkName
|
|
? `${rideData.name} at ${parkName}`
|
|
: rideData.name;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('Error resolving entity name for field display:', err);
|
|
}
|
|
|
|
// Add entity name as an explicit field change at the beginning
|
|
fieldChanges.unshift({
|
|
field: 'entity_name',
|
|
oldValue: null,
|
|
newValue: resolvedEntityName,
|
|
changeType: 'added',
|
|
});
|
|
}
|
|
} else {
|
|
// For regular entities, use name field
|
|
entityName = itemData.name || originalData?.name || 'Unknown';
|
|
}
|
|
|
|
// Detect photo changes if submissionId provided
|
|
const photoChanges = submissionId ? await detectPhotoChanges(submissionId) : [];
|
|
|
|
return {
|
|
action,
|
|
entityType: item.item_type,
|
|
entityName,
|
|
fieldChanges,
|
|
imageChanges,
|
|
photoChanges,
|
|
hasLocationChange,
|
|
totalChanges: fieldChanges.length + imageChanges.length + photoChanges.length + (hasLocationChange ? 1 : 0)
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Determines if a field should be tracked for changes
|
|
*/
|
|
function shouldTrackField(key: string): boolean {
|
|
const excludedFields = [
|
|
// System fields
|
|
'id',
|
|
'created_at',
|
|
'updated_at',
|
|
'slug',
|
|
|
|
// Image-related (handled separately)
|
|
'images',
|
|
'image_assignments',
|
|
'banner_image_url',
|
|
'banner_image_id',
|
|
'card_image_url',
|
|
'card_image_id',
|
|
|
|
// Reference IDs (not editable, just for linking)
|
|
'park_id',
|
|
'ride_id',
|
|
'company_id',
|
|
'manufacturer_id',
|
|
'operator_id',
|
|
'designer_id',
|
|
'property_owner_id',
|
|
'location_id', // Location object is tracked instead
|
|
|
|
// Computed/aggregated fields (not editable)
|
|
'ride_count',
|
|
'review_count',
|
|
'coaster_count',
|
|
'average_rating',
|
|
|
|
// Analytics fields (auto-updated by system)
|
|
'view_count_7d',
|
|
'view_count_30d',
|
|
'view_count_all',
|
|
];
|
|
|
|
return !excludedFields.includes(key);
|
|
}
|
|
|
|
/**
|
|
* Normalizes values for consistent comparison
|
|
* Handles enum-like strings (snake_case and Title Case) by ensuring lowercase
|
|
*/
|
|
function normalizeForComparison(value: any): any {
|
|
// Null/undefined pass through
|
|
if (value == null) return value;
|
|
|
|
// Normalize enum-like strings to lowercase for comparison
|
|
// Matches patterns like: "operating", "Operating", "amusement_park", "Amusement_Park", "Amusement Park"
|
|
if (typeof value === 'string' && /^[a-zA-Z_\s]+$/.test(value)) {
|
|
return value
|
|
.toLowerCase()
|
|
.replace(/_/g, ' ') // Replace underscores with spaces
|
|
.replace(/\s+/g, ' ') // Collapse multiple spaces
|
|
.trim();
|
|
}
|
|
|
|
// Recursively normalize arrays
|
|
if (Array.isArray(value)) {
|
|
return value.map(normalizeForComparison);
|
|
}
|
|
|
|
// Recursively normalize objects (but not Date objects)
|
|
if (typeof value === 'object' && !(value instanceof Date)) {
|
|
const normalized: Record<string, any> = {};
|
|
for (const [key, val] of Object.entries(value)) {
|
|
normalized[key] = normalizeForComparison(val);
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
/**
|
|
* Deep equality check for values with normalization
|
|
*/
|
|
function isEqual(a: any, b: any): boolean {
|
|
// Normalize both values before comparison
|
|
const normalizedA = normalizeForComparison(a);
|
|
const normalizedB = normalizeForComparison(b);
|
|
|
|
if (normalizedA === normalizedB) return true;
|
|
if (normalizedA == null || normalizedB == null) return normalizedA === normalizedB;
|
|
if (typeof normalizedA !== typeof normalizedB) return false;
|
|
|
|
if (typeof normalizedA === 'object') {
|
|
if (Array.isArray(normalizedA) && Array.isArray(normalizedB)) {
|
|
if (normalizedA.length !== normalizedB.length) return false;
|
|
return normalizedA.every((item, i) => isEqual(item, normalizedB[i]));
|
|
}
|
|
|
|
const keysA = Object.keys(normalizedA);
|
|
const keysB = Object.keys(normalizedB);
|
|
if (keysA.length !== keysB.length) return false;
|
|
|
|
return keysA.every(key => isEqual(normalizedA[key], normalizedB[key]));
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Normalizes image data structures to extract IDs consistently
|
|
*/
|
|
function getImageIds(data: any): { banner?: string; card?: string } {
|
|
const result: { banner?: string; card?: string } = {};
|
|
|
|
// Handle flat structure (original_data from DB)
|
|
if (data.banner_image_id) result.banner = data.banner_image_id;
|
|
if (data.card_image_id) result.card = data.card_image_id;
|
|
|
|
// Handle nested structure (item_data from form)
|
|
if (data.images?.uploaded && Array.isArray(data.images.uploaded)) {
|
|
const uploaded = data.images.uploaded;
|
|
|
|
// Handle banner/card assignment mapping (default to indices 0 and 1)
|
|
const bannerIdx = data.images.banner_assignment ?? 0;
|
|
const cardIdx = data.images.card_assignment ?? 1;
|
|
|
|
// Try both 'cloudflare_id' and 'id' for compatibility
|
|
if (uploaded[bannerIdx]) {
|
|
result.banner = uploaded[bannerIdx].cloudflare_id || uploaded[bannerIdx].id;
|
|
}
|
|
if (uploaded[cardIdx]) {
|
|
result.card = uploaded[cardIdx].cloudflare_id || uploaded[cardIdx].id;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Detects changes in banner/card images
|
|
*/
|
|
function detectImageChanges(
|
|
originalData: any,
|
|
itemData: any,
|
|
imageChanges: ImageChange[]
|
|
): void {
|
|
// Normalize both data structures before comparing
|
|
const oldIds = getImageIds(originalData);
|
|
const newIds = getImageIds(itemData);
|
|
|
|
// Check banner image
|
|
if (oldIds.banner !== newIds.banner) {
|
|
imageChanges.push({
|
|
type: 'banner',
|
|
oldUrl: originalData.banner_image_url,
|
|
newUrl: itemData.banner_image_url || itemData.images?.uploaded?.[0]?.url,
|
|
oldId: oldIds.banner,
|
|
newId: newIds.banner,
|
|
});
|
|
}
|
|
|
|
// Check card image
|
|
if (oldIds.card !== newIds.card) {
|
|
imageChanges.push({
|
|
type: 'card',
|
|
oldUrl: originalData.card_image_url,
|
|
newUrl: itemData.card_image_url || itemData.images?.uploaded?.[1]?.url,
|
|
oldId: oldIds.card,
|
|
newId: newIds.card,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Format field name for display
|
|
*/
|
|
export function formatFieldName(field: string): string {
|
|
return field
|
|
.replace(/_/g, ' ')
|
|
.replace(/([A-Z])/g, ' $1')
|
|
.replace(/^./, str => str.toUpperCase())
|
|
.trim();
|
|
}
|
|
|
|
/**
|
|
* Get table name for entity type
|
|
*/
|
|
function getTableNameForEntityType(entityType: string): string | null {
|
|
const mapping: Record<string, string> = {
|
|
'park': 'parks',
|
|
'ride': 'rides',
|
|
'manufacturer': 'companies',
|
|
'operator': 'companies',
|
|
'designer': 'companies',
|
|
'property_owner': 'companies',
|
|
'ride_model': 'ride_models'
|
|
};
|
|
return mapping[entityType] || null;
|
|
}
|
|
|
|
/**
|
|
* Format entity type for display
|
|
*/
|
|
function formatEntityType(entityType: string): string {
|
|
return entityType
|
|
.split('_')
|
|
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
|
.join(' ');
|
|
}
|
|
|
|
/**
|
|
* Format field value for display
|
|
*/
|
|
export function formatFieldValue(value: any, precision?: 'day' | 'month' | 'year'): string {
|
|
if (value === null || value === undefined) return 'None';
|
|
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
|
|
|
|
// Handle dates with precision support
|
|
if (value instanceof Date || (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}/.test(value))) {
|
|
try {
|
|
const date = new Date(value);
|
|
|
|
// Apply precision if provided
|
|
if (precision === 'year') {
|
|
return date.getFullYear().toString();
|
|
} else if (precision === 'month') {
|
|
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'long' });
|
|
}
|
|
|
|
// Default: full date
|
|
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
|
} catch {
|
|
return String(value);
|
|
}
|
|
}
|
|
|
|
// Handle arrays - show actual items
|
|
if (Array.isArray(value)) {
|
|
if (value.length === 0) return 'None';
|
|
if (value.length <= 3) return value.map(v => String(v)).join(', ');
|
|
return `${value.slice(0, 3).map(v => String(v)).join(', ')}... +${value.length - 3} more`;
|
|
}
|
|
|
|
// Handle objects - create readable summary
|
|
if (typeof value === 'object') {
|
|
// Location object
|
|
if (value.city || value.state_province || value.country) {
|
|
const parts = [value.city, value.state_province, value.country].filter(Boolean);
|
|
return parts.join(', ');
|
|
}
|
|
|
|
// Generic object - show key-value pairs
|
|
const entries = Object.entries(value).slice(0, 3);
|
|
if (entries.length === 0) return 'Empty';
|
|
return entries.map(([k, v]) => `${k}: ${v}`).join(', ');
|
|
}
|
|
|
|
// Handle year-like numbers (prevent comma formatting for founded_year)
|
|
if (typeof value === 'number') {
|
|
const currentYear = new Date().getFullYear();
|
|
if (value >= 1800 && value <= currentYear + 10) {
|
|
return value.toString(); // Don't add commas for year values
|
|
}
|
|
return value.toLocaleString(); // Add commas for other numbers
|
|
}
|
|
|
|
// Handle URLs
|
|
if (typeof value === 'string' && value.startsWith('http')) {
|
|
try {
|
|
const url = new URL(value);
|
|
return url.hostname + (url.pathname !== '/' ? url.pathname.slice(0, 30) : '');
|
|
} catch {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
// Handle enum-like strings (snake_case or kebab-case) - capitalize and replace separators
|
|
if (typeof value === 'string' && (value.includes('_') || value.includes('-'))) {
|
|
return value
|
|
.split(/[_-]/)
|
|
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
|
.join(' ');
|
|
}
|
|
|
|
if (typeof value === 'number') return value.toLocaleString();
|
|
return String(value);
|
|
}
|